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:
parent
bd890384e5
commit
268575ed6c
8 changed files with 337 additions and 18 deletions
|
|
@ -14,15 +14,46 @@ interface NumberFieldDef {
|
||||||
field: keyof Config
|
field: keyof Config
|
||||||
label: string
|
label: string
|
||||||
min: number
|
min: number
|
||||||
|
max: number | undefined
|
||||||
step: number
|
step: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberFields: NumberFieldDef[] = [
|
// min/max come from the engine's served bounds; until they arrive the
|
||||||
{ field: 'numStudents', label: CONFIG_FIELD_LABELS.numStudents, min: 2, step: 1 },
|
// inputs are unbounded and the store clamps nothing (the engine's own
|
||||||
{ field: 'edgesPerNode', label: CONFIG_FIELD_LABELS.edgesPerNode, min: 1, step: 1 },
|
// validation backstops). origin's ceiling tracks numStudents.
|
||||||
{ field: 'triangleProb', label: CONFIG_FIELD_LABELS.triangleProb, min: 0, step: 0.05 },
|
const numberFields = computed<NumberFieldDef[]>(() => {
|
||||||
{ field: 'origin', label: CONFIG_FIELD_LABELS.origin, min: 0, step: 1 },
|
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 {
|
interface SeedFieldDef {
|
||||||
field: keyof Config
|
field: keyof Config
|
||||||
|
|
@ -39,7 +70,7 @@ const seedFields: SeedFieldDef[] = [
|
||||||
const offendingField = computed(() => {
|
const offendingField = computed(() => {
|
||||||
const validationError = store.state.validationError
|
const validationError = store.state.validationError
|
||||||
if (!validationError) return null
|
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
|
return allFields.find((field) => validationError.includes(field)) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -82,6 +113,7 @@ function rerollWorld() {
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:min="def.min"
|
:min="def.min"
|
||||||
|
:max="def.max"
|
||||||
:step="def.step"
|
:step="def.step"
|
||||||
:value="store.state.base[def.field]"
|
:value="store.state.base[def.field]"
|
||||||
:aria-invalid="offendingField === def.field || undefined"
|
:aria-invalid="offendingField === def.field || undefined"
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ const offendsNumEducated = computed(
|
||||||
label="Chance to forward"
|
label="Chance to forward"
|
||||||
hint="per friendship, per round"
|
hint="per friendship, per round"
|
||||||
:value="base.forwardProb"
|
:value="base.forwardProb"
|
||||||
:min="0"
|
:min="store.state.bounds?.forwardProb.min ?? 0"
|
||||||
:max="1"
|
:max="store.state.bounds?.forwardProb.max ?? 1"
|
||||||
:step="0.01"
|
:step="0.01"
|
||||||
:display-value="formatPct(base.forwardProb * 100)"
|
:display-value="formatPct(base.forwardProb * 100)"
|
||||||
:invalid="offendsForwardProb"
|
:invalid="offendsForwardProb"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,45 @@ function setOverride(field: keyof Config, event: Event) {
|
||||||
if (Number.isFinite(nextValue)) store.setPanelOverride(props.panel.id, field, nextValue)
|
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) {
|
function rename(event: Event) {
|
||||||
const label = (event.target as HTMLInputElement).value.trim()
|
const label = (event.target as HTMLInputElement).value.trim()
|
||||||
if (label) store.renamePanel(props.panel.id, label)
|
if (label) store.renamePanel(props.panel.id, label)
|
||||||
|
|
@ -123,6 +162,8 @@ onBeforeUnmount(() => {
|
||||||
<input
|
<input
|
||||||
:id="`${panel.id}-${field}`"
|
:id="`${panel.id}-${field}`"
|
||||||
type="number"
|
type="number"
|
||||||
|
:min="fieldMin(field)"
|
||||||
|
:max="fieldMax(field)"
|
||||||
:value="effectiveValue(field)"
|
:value="effectiveValue(field)"
|
||||||
:aria-invalid="offendingField === field || undefined"
|
:aria-invalid="offendingField === field || undefined"
|
||||||
@change="setOverride(field, $event)"
|
@change="setOverride(field, $event)"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createSimStore, RUN_DEBOUNCE_MS } from '../useSimStore'
|
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 { ScenarioRequest, ScenarioResponse } from '@/types/api'
|
||||||
|
import type { Bounds } from '@/types/engine'
|
||||||
|
|
||||||
vi.mock('@/lib/api', async (importOriginal) => {
|
vi.mock('@/lib/api', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/api')>()
|
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 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
|
// The fake engine: numReached mirrors numEducated so tests can tell which
|
||||||
// config produced a result, and the no-program panel runs the longest.
|
// config produced a result, and the no-program panel runs the longest.
|
||||||
|
|
@ -40,6 +55,11 @@ beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
runScenarioMock.mockReset()
|
runScenarioMock.mockReset()
|
||||||
runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
|
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, '', '/')
|
window.history.replaceState(null, '', '/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -271,3 +291,52 @@ describe('useSimStore panel management', () => {
|
||||||
expect(window.location.search).not.toContain('focus=')
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { reactive } from 'vue'
|
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 { roundPct } from '@/lib/format'
|
||||||
import { graphKey } from '@/lib/graph'
|
import { graphKey } from '@/lib/graph'
|
||||||
import { parseUrlState, serializeUrlState } from '@/lib/urlState'
|
import { parseUrlState, serializeUrlState } from '@/lib/urlState'
|
||||||
import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
|
import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
|
||||||
import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types'
|
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'
|
import { StrategyNone } from '@/types/engine'
|
||||||
|
|
||||||
// The one store (spec section 4): a plain reactive module singleton, no
|
// 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 {
|
export interface SimState {
|
||||||
base: Config
|
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[]
|
panels: PanelSpec[]
|
||||||
resultsByPanelId: Record<string, Result>
|
resultsByPanelId: Record<string, Result>
|
||||||
edgesByGraphHash: Record<string, number[][]>
|
edgesByGraphHash: Record<string, number[][]>
|
||||||
|
|
@ -52,6 +57,7 @@ function clonePanel(panel: PanelSpec): PanelSpec {
|
||||||
export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
const state: SimState = reactive({
|
const state: SimState = reactive({
|
||||||
base: { ...preset.base },
|
base: { ...preset.base },
|
||||||
|
bounds: null,
|
||||||
panels: preset.panels.map(clonePanel),
|
panels: preset.panels.map(clonePanel),
|
||||||
resultsByPanelId: {},
|
resultsByPanelId: {},
|
||||||
edgesByGraphHash: {},
|
edgesByGraphHash: {},
|
||||||
|
|
@ -181,10 +187,31 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
field: FieldName,
|
field: FieldName,
|
||||||
value: Config[FieldName],
|
value: Config[FieldName],
|
||||||
) {
|
) {
|
||||||
|
if (state.bounds) {
|
||||||
|
value = clampConfigField(field, value, state.base.numStudents, state.bounds)
|
||||||
|
}
|
||||||
state.base[field] = value
|
state.base[field] = value
|
||||||
|
if (field === 'numStudents') clampEverythingToBounds()
|
||||||
scheduleRun()
|
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 {
|
function addPanel(): PanelSpec | null {
|
||||||
if (state.panels.length >= MAX_PANELS) return null
|
if (state.panels.length >= MAX_PANELS) return null
|
||||||
const panel: PanelSpec = {
|
const panel: PanelSpec = {
|
||||||
|
|
@ -253,6 +280,11 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
) {
|
) {
|
||||||
const panel = panelById(panelId)
|
const panel = panelById(panelId)
|
||||||
if (panel === undefined) return
|
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]) {
|
if (value === state.base[field]) {
|
||||||
// Typing the base value back is "no override" (spec 5.7).
|
// Typing the base value back is "no override" (spec 5.7).
|
||||||
delete panel.overrides[field]
|
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.
|
// 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.
|
// The initial run is not debounced: nothing is on screen yet. The bounds
|
||||||
function initialize(
|
// 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,
|
search: string = typeof window === 'undefined' ? '' : window.location.search,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
state.bounds = (await fetchDefaultConfig()).bounds
|
||||||
|
} catch {
|
||||||
|
state.bounds = null
|
||||||
|
}
|
||||||
const parsed = parseUrlState(search, preset, createPanelId)
|
const parsed = parseUrlState(search, preset, createPanelId)
|
||||||
if (parsed === null) {
|
if (parsed === null) {
|
||||||
state.urlStateInvalid = true // preset defaults are already loaded
|
state.urlStateInvalid = true // preset defaults are already loaded
|
||||||
|
|
@ -294,6 +334,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
state.focusPanelId =
|
state.focusPanelId =
|
||||||
parsed.focusIndex !== null ? (parsed.panels[parsed.focusIndex]?.id ?? null) : null
|
parsed.focusIndex !== null ? (parsed.panels[parsed.focusIndex]?.id ?? null) : null
|
||||||
}
|
}
|
||||||
|
clampEverythingToBounds()
|
||||||
return runPanels(null)
|
return runPanels(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
70
web/src/lib/__tests__/bounds.spec.ts
Normal file
70
web/src/lib/__tests__/bounds.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import type { Config } from '@/types/engine'
|
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
|
// Thin typed wrappers around the JSON API. The types come from
|
||||||
// src/types/, which is generated from the Go structs (single source of
|
// 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>
|
return response.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDefaultConfig(): Promise<Config> {
|
export function fetchDefaultConfig(): Promise<DefaultConfigResponse> {
|
||||||
return requestJSON<Config>('/api/config/default')
|
return requestJSON<DefaultConfigResponse>('/api/config/default')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runComparison(config: Config): Promise<ComparisonResponse> {
|
export function runComparison(config: Config): Promise<ComparisonResponse> {
|
||||||
|
|
|
||||||
61
web/src/lib/bounds.ts
Normal file
61
web/src/lib/bounds.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue