From 268575ed6c46526d2146b766d1950cf6f879aace Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 11 Jun 2026 16:16:27 +0200 Subject: [PATCH] web: drive control limits from the served bounds and clamp, not 400 The store fetches {config, bounds} at startup and snaps values into range everywhere they enter: typed fields, sliders, panel overrides, and shared links (a ?edgesPerNode=150 link now runs at 8 instead of showing a validation error). Shrinking numStudents pulls numEducated and origin down with it. If the bounds fetch fails the app still starts and the engine's validation backstops. Native min/max attributes follow the same numbers, so spinners stop at the bounds. --- web/src/components/AdvancedFields.vue | 46 ++++++++++-- web/src/components/ControlsCard.vue | 4 +- web/src/components/PanelEditorPopover.vue | 41 +++++++++++ .../composables/__tests__/useSimStore.spec.ts | 73 ++++++++++++++++++- web/src/composables/useSimStore.ts | 49 ++++++++++++- web/src/lib/__tests__/bounds.spec.ts | 70 ++++++++++++++++++ web/src/lib/api.ts | 11 ++- web/src/lib/bounds.ts | 61 ++++++++++++++++ 8 files changed, 337 insertions(+), 18 deletions(-) create mode 100644 web/src/lib/__tests__/bounds.spec.ts create mode 100644 web/src/lib/bounds.ts diff --git a/web/src/components/AdvancedFields.vue b/web/src/components/AdvancedFields.vue index 9977106..d6d96a7 100644 --- a/web/src/components/AdvancedFields.vue +++ b/web/src/components/AdvancedFields.vue @@ -14,15 +14,46 @@ interface NumberFieldDef { field: keyof Config label: string min: number + max: number | undefined step: number } -const numberFields: NumberFieldDef[] = [ - { field: 'numStudents', label: CONFIG_FIELD_LABELS.numStudents, min: 2, step: 1 }, - { field: 'edgesPerNode', label: CONFIG_FIELD_LABELS.edgesPerNode, min: 1, step: 1 }, - { field: 'triangleProb', label: CONFIG_FIELD_LABELS.triangleProb, min: 0, step: 0.05 }, - { field: 'origin', label: CONFIG_FIELD_LABELS.origin, min: 0, step: 1 }, -] +// min/max come from the engine's served bounds; until they arrive the +// inputs are unbounded and the store clamps nothing (the engine's own +// validation backstops). origin's ceiling tracks numStudents. +const numberFields = computed(() => { + const bounds = store.state.bounds + return [ + { + field: 'numStudents', + label: CONFIG_FIELD_LABELS.numStudents, + min: bounds?.numStudents.min ?? 2, + max: bounds?.numStudents.max, + step: 1, + }, + { + field: 'edgesPerNode', + label: CONFIG_FIELD_LABELS.edgesPerNode, + min: bounds?.edgesPerNode.min ?? 1, + max: bounds?.edgesPerNode.max, + step: 1, + }, + { + field: 'triangleProb', + label: CONFIG_FIELD_LABELS.triangleProb, + min: bounds?.triangleProb.min ?? 0, + max: bounds?.triangleProb.max, + step: 0.05, + }, + { + field: 'origin', + label: CONFIG_FIELD_LABELS.origin, + min: 0, + max: store.state.base.numStudents - 1, + step: 1, + }, + ] +}) interface SeedFieldDef { field: keyof Config @@ -39,7 +70,7 @@ const seedFields: SeedFieldDef[] = [ const offendingField = computed(() => { const validationError = store.state.validationError if (!validationError) return null - const allFields = [...numberFields, ...seedFields].map(({ field }) => field) + const allFields = [...numberFields.value, ...seedFields].map(({ field }) => field) return allFields.find((field) => validationError.includes(field)) ?? null }) @@ -82,6 +113,7 @@ function rerollWorld() { { { const actual = await importOriginal() - return { ...actual, runScenario: vi.fn() } + return { + ...actual, + runScenario: vi.fn(), + fetchDefaultConfig: vi.fn(), + } }) const runScenarioMock = vi.mocked(runScenario) +const fetchDefaultConfigMock = vi.mocked(fetchDefaultConfig) + +// Stand-in bounds matching the engine's real ones closely enough for the +// clamping tests; the store treats them as opaque numbers either way. +const TEST_BOUNDS: Bounds = { + numStudents: { min: 10, max: 500 }, + edgesPerNode: { min: 1, max: 8 }, + triangleProb: { min: 0, max: 1 }, + forwardProb: { min: 0, max: 0.9 }, +} // The fake engine: numReached mirrors numEducated so tests can tell which // config produced a result, and the no-program panel runs the longest. @@ -40,6 +55,11 @@ beforeEach(() => { vi.useFakeTimers() runScenarioMock.mockReset() runScenarioMock.mockImplementation(async (request) => fakeResponse(request)) + fetchDefaultConfigMock.mockReset() + fetchDefaultConfigMock.mockImplementation(async () => { + const { deepfakeSchoolPreset } = await import('@/presets/deepfake-school') + return { config: deepfakeSchoolPreset.base, bounds: TEST_BOUNDS } + }) window.history.replaceState(null, '', '/') }) @@ -271,3 +291,52 @@ describe('useSimStore panel management', () => { expect(window.location.search).not.toContain('focus=') }) }) + +describe('bounds clamping', () => { + it('stores the served bounds on initialize', async () => { + const store = createSimStore() + await store.initialize('') + expect(store.state.bounds).toEqual(TEST_BOUNDS) + }) + + it('clamps wild shared-link values instead of running them', async () => { + const store = createSimStore() + await store.initialize('?numStudents=5000&edgesPerNode=150&forwardProb=1&panel=Wild~random~numEducated:9999') + + expect(store.state.base.numStudents).toBe(500) + expect(store.state.base.edgesPerNode).toBe(8) + expect(store.state.base.forwardProb).toBe(0.9) + expect(store.state.panels[0]!.overrides.numEducated).toBe(500) + }) + + it('clamps typed base values to the bounds', async () => { + const store = createSimStore() + await store.initialize('') + + store.setBaseField('forwardProb', 1) + expect(store.state.base.forwardProb).toBe(0.9) + store.setBaseField('edgesPerNode', 150) + expect(store.state.base.edgesPerNode).toBe(8) + }) + + it('pulls relational fields down when numStudents shrinks', async () => { + const store = createSimStore() + await store.initialize('') + store.setBaseField('origin', 100) + store.setBaseField('numEducated', 110) + + store.setBaseField('numStudents', 50) + expect(store.state.base.origin).toBe(49) + expect(store.state.base.numEducated).toBe(50) + }) + + it('starts without clamping when the bounds fetch fails', async () => { + fetchDefaultConfigMock.mockRejectedValue(new Error('offline')) + const store = createSimStore() + await store.initialize('') + + expect(store.state.bounds).toBeNull() + store.setBaseField('forwardProb', 1) + expect(store.state.base.forwardProb).toBe(1) // the engine 400 backstops + }) +}) diff --git a/web/src/composables/useSimStore.ts b/web/src/composables/useSimStore.ts index 9bff27d..ab4e543 100644 --- a/web/src/composables/useSimStore.ts +++ b/web/src/composables/useSimStore.ts @@ -1,11 +1,12 @@ import { reactive } from 'vue' -import { ApiError, runScenario } from '@/lib/api' +import { ApiError, fetchDefaultConfig, runScenario } from '@/lib/api' +import { clampConfig, clampConfigField } from '@/lib/bounds' import { roundPct } from '@/lib/format' import { graphKey } from '@/lib/graph' import { parseUrlState, serializeUrlState } from '@/lib/urlState' import { deepfakeSchoolPreset } from '@/presets/deepfake-school' import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types' -import type { Config, Result, Strategy } from '@/types/engine' +import type { Bounds, Config, Result, Strategy } from '@/types/engine' import { StrategyNone } from '@/types/engine' // The one store (spec section 4): a plain reactive module singleton, no @@ -21,6 +22,10 @@ export type PlaybackSpeed = 0.5 | 1 | 2 export interface SimState { base: Config + // The engine's field bounds, fetched at startup; null until they arrive + // (or when the fetch failed, in which case the engine's own validation + // is the backstop and nothing clamps client-side). + bounds: Bounds | null panels: PanelSpec[] resultsByPanelId: Record edgesByGraphHash: Record @@ -52,6 +57,7 @@ function clonePanel(panel: PanelSpec): PanelSpec { export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) { const state: SimState = reactive({ base: { ...preset.base }, + bounds: null, panels: preset.panels.map(clonePanel), resultsByPanelId: {}, edgesByGraphHash: {}, @@ -181,10 +187,31 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) { field: FieldName, value: Config[FieldName], ) { + if (state.bounds) { + value = clampConfigField(field, value, state.base.numStudents, state.bounds) + } state.base[field] = value + if (field === 'numStudents') clampEverythingToBounds() scheduleRun() } + // Re-clamp the base config and every panel override; needed when + // numStudents changes (the relational ceilings move) and when the + // bounds first arrive while the URL may have carried wild values. + function clampEverythingToBounds() { + const bounds = state.bounds + if (!bounds) return + state.base = clampConfig(state.base, bounds) + for (const panel of state.panels) { + const panelStudents = panel.overrides.numStudents ?? state.base.numStudents + for (const field of Object.keys(panel.overrides) as (keyof Config)[]) { + const override = panel.overrides[field] + if (override === undefined) continue + panel.overrides[field] = clampConfigField(field, override, panelStudents, bounds) + } + } + } + function addPanel(): PanelSpec | null { if (state.panels.length >= MAX_PANELS) return null const panel: PanelSpec = { @@ -253,6 +280,11 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) { ) { const panel = panelById(panelId) if (panel === undefined) return + if (state.bounds) { + const panelStudents = + field === 'numStudents' ? value : (panel.overrides.numStudents ?? state.base.numStudents) + value = clampConfigField(field, value, panelStudents, state.bounds) + } if (value === state.base[field]) { // Typing the base value back is "no override" (spec 5.7). delete panel.overrides[field] @@ -281,10 +313,18 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) { } // Parse the opened URL (or fall back to the preset) and run every panel. - // The initial run is not debounced: nothing is on screen yet. - function initialize( + // The initial run is not debounced: nothing is on screen yet. The bounds + // arrive first so a shared link's wild values clamp instead of 400ing; + // if that fetch fails the app still starts, with the engine's own + // validation as the backstop. + async function initialize( search: string = typeof window === 'undefined' ? '' : window.location.search, ): Promise { + try { + state.bounds = (await fetchDefaultConfig()).bounds + } catch { + state.bounds = null + } const parsed = parseUrlState(search, preset, createPanelId) if (parsed === null) { state.urlStateInvalid = true // preset defaults are already loaded @@ -294,6 +334,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) { state.focusPanelId = parsed.focusIndex !== null ? (parsed.panels[parsed.focusIndex]?.id ?? null) : null } + clampEverythingToBounds() return runPanels(null) } diff --git a/web/src/lib/__tests__/bounds.spec.ts b/web/src/lib/__tests__/bounds.spec.ts new file mode 100644 index 0000000..8d29f55 --- /dev/null +++ b/web/src/lib/__tests__/bounds.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { clampConfig, clampConfigField } from '../bounds' +import type { Bounds, Config } from '@/types/engine' + +// Bounds values here are test stand-ins; the real ones come from the API. +const bounds: Bounds = { + numStudents: { min: 10, max: 500 }, + edgesPerNode: { min: 1, max: 8 }, + triangleProb: { min: 0, max: 1 }, + forwardProb: { min: 0, max: 0.9 }, +} + +const config: Config = { + numStudents: 120, + edgesPerNode: 3, + triangleProb: 0.45, + forwardProb: 0.38, + numEducated: 36, + origin: 0, + graphSeed: 17, + thresholdSeed: 2, + educationSeed: 1, +} + +describe('clampConfigField', () => { + it('snaps absolute fields to their bounds', () => { + expect(clampConfigField('edgesPerNode', 150, 120, bounds)).toBe(8) + expect(clampConfigField('edgesPerNode', 0, 120, bounds)).toBe(1) + expect(clampConfigField('forwardProb', 1, 120, bounds)).toBe(0.9) + expect(clampConfigField('numStudents', 5000, 120, bounds)).toBe(500) + expect(clampConfigField('numStudents', 2, 120, bounds)).toBe(10) + }) + + it('clamps relational fields against numStudents', () => { + expect(clampConfigField('numEducated', 200, 120, bounds)).toBe(120) + expect(clampConfigField('origin', 120, 120, bounds)).toBe(119) + expect(clampConfigField('origin', -3, 120, bounds)).toBe(0) + }) + + it('rounds integer fields and keeps seeds non-negative', () => { + expect(clampConfigField('numStudents', 99.7, 120, bounds)).toBe(100) + expect(clampConfigField('graphSeed', -5, 120, bounds)).toBe(0) + expect(clampConfigField('triangleProb', 0.45, 120, bounds)).toBe(0.45) + }) + + it('leaves in-range values untouched', () => { + expect(clampConfigField('forwardProb', 0.38, 120, bounds)).toBe(0.38) + expect(clampConfigField('numEducated', 36, 120, bounds)).toBe(36) + }) +}) + +describe('clampConfig', () => { + it('returns an equal config when everything is in range', () => { + expect(clampConfig(config, bounds)).toEqual(config) + }) + + it('clamps numStudents first so relational fields follow the corrected value', () => { + const wild = { ...config, numStudents: 5000, numEducated: 1000, origin: 999 } + const clamped = clampConfig(wild, bounds) + expect(clamped.numStudents).toBe(500) + expect(clamped.numEducated).toBe(500) + expect(clamped.origin).toBe(499) + }) + + it('does not mutate its input', () => { + const wild = { ...config, forwardProb: 2 } + clampConfig(wild, bounds) + expect(wild.forwardProb).toBe(2) + }) +}) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 29ef2da..15a0455 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,10 @@ import type { Config } from '@/types/engine' -import type { ComparisonResponse, ScenarioRequest, ScenarioResponse } from '@/types/api' +import type { + ComparisonResponse, + DefaultConfigResponse, + ScenarioRequest, + ScenarioResponse, +} from '@/types/api' // Thin typed wrappers around the JSON API. The types come from // src/types/, which is generated from the Go structs (single source of @@ -44,8 +49,8 @@ async function requestJSON(input: string, init?: RequestInit): Promise { return response.json() as Promise } -export function fetchDefaultConfig(): Promise { - return requestJSON('/api/config/default') +export function fetchDefaultConfig(): Promise { + return requestJSON('/api/config/default') } export function runComparison(config: Config): Promise { diff --git a/web/src/lib/bounds.ts b/web/src/lib/bounds.ts new file mode 100644 index 0000000..6e88ea5 --- /dev/null +++ b/web/src/lib/bounds.ts @@ -0,0 +1,61 @@ +import type { Bounds, Config } from '@/types/engine' + +// Frontend mirror of the engine's ValidateConfig, but clamping instead of +// rejecting (decided 2026-06-11): typed values snap to the nearest bound +// and out-of-range shared links keep working. The numbers themselves are +// never duplicated here; they arrive from GET /api/config/default. + +const INTEGER_FIELDS: ReadonlySet = new Set([ + 'numStudents', + 'edgesPerNode', + 'numEducated', + 'origin', + 'graphSeed', + 'thresholdSeed', + 'educationSeed', +]) + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +// clampConfigField snaps one field's value into range. Relational fields +// (numEducated, origin) clamp against numStudents, which callers pass from +// whatever config the field is about to land in; seeds only need to be +// non-negative integers. +export function clampConfigField( + field: keyof Config, + value: number, + numStudents: number, + bounds: Bounds, +): number { + const rounded = INTEGER_FIELDS.has(field) ? Math.round(value) : value + switch (field) { + case 'numStudents': + return clampNumber(rounded, bounds.numStudents.min, bounds.numStudents.max) + case 'edgesPerNode': + return clampNumber(rounded, bounds.edgesPerNode.min, bounds.edgesPerNode.max) + case 'triangleProb': + return clampNumber(rounded, bounds.triangleProb.min, bounds.triangleProb.max) + case 'forwardProb': + return clampNumber(rounded, bounds.forwardProb.min, bounds.forwardProb.max) + case 'numEducated': + return clampNumber(rounded, 0, numStudents) + case 'origin': + return clampNumber(rounded, 0, numStudents - 1) + default: + return Math.max(0, rounded) + } +} + +// clampConfig snaps a whole config into range, numStudents first so the +// relational fields clamp against the corrected value. +export function clampConfig(config: Config, bounds: Bounds): Config { + const clamped = { ...config } + clamped.numStudents = clampConfigField('numStudents', config.numStudents, 0, bounds) + for (const field of Object.keys(config) as (keyof Config)[]) { + if (field === 'numStudents') continue + clamped[field] = clampConfigField(field, config[field], clamped.numStudents, bounds) + } + return clamped +}