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.
This commit is contained in:
Justin Visser 2026-06-11 16:16:27 +02:00
parent bd890384e5
commit 268575ed6c
8 changed files with 337 additions and 18 deletions

View file

@ -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<NumberFieldDef[]>(() => {
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() {
<input
type="number"
:min="def.min"
:max="def.max"
:step="def.step"
:value="store.state.base[def.field]"
:aria-invalid="offendingField === def.field || undefined"

View file

@ -36,8 +36,8 @@ const offendsNumEducated = computed(
label="Chance to forward"
hint="per friendship, per round"
:value="base.forwardProb"
:min="0"
:max="1"
:min="store.state.bounds?.forwardProb.min ?? 0"
:max="store.state.bounds?.forwardProb.max ?? 1"
:step="0.01"
:display-value="formatPct(base.forwardProb * 100)"
:invalid="offendsForwardProb"

View file

@ -40,6 +40,45 @@ function setOverride(field: keyof Config, event: Event) {
if (Number.isFinite(nextValue)) store.setPanelOverride(props.panel.id, field, nextValue)
}
// Native min/max hints from the served bounds; the store clamps anyway,
// these just make the spinners stop at the right place. Relational
// ceilings follow this panel's effective student count.
function fieldMin(field: keyof Config): number | undefined {
const bounds = store.state.bounds
switch (field) {
case 'numStudents':
return bounds?.numStudents.min
case 'edgesPerNode':
return bounds?.edgesPerNode.min
case 'triangleProb':
return bounds?.triangleProb.min
case 'forwardProb':
return bounds?.forwardProb.min
default:
return 0
}
}
function fieldMax(field: keyof Config): number | undefined {
const bounds = store.state.bounds
switch (field) {
case 'numStudents':
return bounds?.numStudents.max
case 'edgesPerNode':
return bounds?.edgesPerNode.max
case 'triangleProb':
return bounds?.triangleProb.max
case 'forwardProb':
return bounds?.forwardProb.max
case 'numEducated':
return effectiveValue('numStudents')
case 'origin':
return effectiveValue('numStudents') - 1
default:
return undefined
}
}
function rename(event: Event) {
const label = (event.target as HTMLInputElement).value.trim()
if (label) store.renamePanel(props.panel.id, label)
@ -123,6 +162,8 @@ onBeforeUnmount(() => {
<input
:id="`${panel.id}-${field}`"
type="number"
:min="fieldMin(field)"
:max="fieldMax(field)"
:value="effectiveValue(field)"
:aria-invalid="offendingField === field || undefined"
@change="setOverride(field, $event)"

View file

@ -1,14 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createSimStore, RUN_DEBOUNCE_MS } from '../useSimStore'
import { ApiError, runScenario } from '@/lib/api'
import { ApiError, fetchDefaultConfig, runScenario } from '@/lib/api'
import type { ScenarioRequest, ScenarioResponse } from '@/types/api'
import type { Bounds } from '@/types/engine'
vi.mock('@/lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/api')>()
return { ...actual, runScenario: vi.fn<typeof actual.runScenario>() }
return {
...actual,
runScenario: vi.fn<typeof actual.runScenario>(),
fetchDefaultConfig: vi.fn<typeof actual.fetchDefaultConfig>(),
}
})
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
})
})

View file

@ -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<string, Result>
edgesByGraphHash: Record<string, number[][]>
@ -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<void> {
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)
}

View file

@ -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)
})
})

View file

@ -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<T>(input: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T>
}
export function fetchDefaultConfig(): Promise<Config> {
return requestJSON<Config>('/api/config/default')
export function fetchDefaultConfig(): Promise<DefaultConfigResponse> {
return requestJSON<DefaultConfigResponse>('/api/config/default')
}
export function runComparison(config: Config): Promise<ComparisonResponse> {

61
web/src/lib/bounds.ts Normal file
View file

@ -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<keyof Config> = 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
}