From bd890384e5f425ac3cb7c1625c35e7a024963c1d Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 11 Jun 2026 15:54:07 +0200 Subject: [PATCH] engine: realistic config bounds, enforced and served ConfigBounds (students 10-500, edgesPerNode 1-8, forwardProb 0-0.9, triangleProb 0-1) caps what RunScenario accepts: a student does not keep 150 close friendships and a guaranteed forward is not a real base rate. numEducated gains its missing upper limit (numStudents). Bounds-side validation also protects the public server from absurd numStudents values. GET /api/config/default now returns {config, bounds} so the frontend can drive its controls from the same numbers; the frontend does not call it yet, so this commit changes no UI behaviour. --- internal/api/api.go | 13 +++++- internal/api/api_test.go | 11 +++-- internal/engine/bounds_test.go | 67 +++++++++++++++++++++++++++++ internal/engine/scenario.go | 77 ++++++++++++++++++++++++++++++---- tygo.yaml | 3 +- web/src/types/api.ts | 11 ++++- web/src/types/engine.ts | 27 ++++++++++++ 7 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 internal/engine/bounds_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 38dc419..7f5c44c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -11,6 +11,14 @@ import ( "github.com/JustinZeus/spreadlab/internal/engine" ) +// DefaultConfigResponse is the body of GET /api/config/default: the +// default world plus the engine's field bounds, so the frontend drives +// its controls and clamping from the same numbers the engine enforces. +type DefaultConfigResponse struct { + Config engine.Config `json:"config"` + Bounds engine.Bounds `json:"bounds"` +} + // ComparisonResponse bundles what the dashboard needs to render one // comparison: the config that was run, echoed back so frontend state stays // honest, and one result per strategy. @@ -53,7 +61,10 @@ func NewServer() http.Handler { } func handleDefaultConfig(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, engine.DefaultConfig()) + writeJSON(w, http.StatusOK, DefaultConfigResponse{ + Config: engine.DefaultConfig(), + Bounds: engine.ConfigBounds(), + }) } func handleComparison(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2af740b..6eaedd7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -51,12 +51,15 @@ func TestDefaultConfigEndpoint(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) } - var config engine.Config - if err := json.Unmarshal(recorder.Body.Bytes(), &config); err != nil { + var response DefaultConfigResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatal(err) } - if config != engine.DefaultConfig() { - t.Errorf("served config %+v, want %+v", config, engine.DefaultConfig()) + if response.Config != engine.DefaultConfig() { + t.Errorf("served config %+v, want %+v", response.Config, engine.DefaultConfig()) + } + if response.Bounds != engine.ConfigBounds() { + t.Errorf("served bounds %+v, want %+v", response.Bounds, engine.ConfigBounds()) } } diff --git a/internal/engine/bounds_test.go b/internal/engine/bounds_test.go new file mode 100644 index 0000000..61e09d8 --- /dev/null +++ b/internal/engine/bounds_test.go @@ -0,0 +1,67 @@ +package engine + +import ( + "strings" + "testing" +) + +// Bounds are realism limits agreed 2026-06-11 (e.g. a student does not +// have 150 close friendships, and a guaranteed forward is not a real +// base rate), enforced engine-side so the public API is covered too. + +func TestConfigBoundsContainDefaultConfig(t *testing.T) { + if err := ValidateConfig(DefaultConfig()); err != nil { + t.Fatalf("ValidateConfig(DefaultConfig()) = %v, want nil", err) + } +} + +func TestValidateConfigRejectsOutOfBounds(t *testing.T) { + cases := []struct { + name string + mutate func(*Config) + wantMention string // the offending field's JSON name, for the UI's inline message + }{ + {"too few students", func(c *Config) { c.NumStudents = 9 }, "numStudents"}, + {"too many students", func(c *Config) { c.NumStudents = 501 }, "numStudents"}, + {"no friendships", func(c *Config) { c.EdgesPerNode = 0 }, "edgesPerNode"}, + {"implausibly many friendships", func(c *Config) { c.EdgesPerNode = 9 }, "edgesPerNode"}, + {"negative triangle probability", func(c *Config) { c.TriangleProb = -0.1 }, "triangleProb"}, + {"triangle probability above one", func(c *Config) { c.TriangleProb = 1.1 }, "triangleProb"}, + {"negative forward probability", func(c *Config) { c.ForwardProb = -0.1 }, "forwardProb"}, + {"guaranteed forwarding", func(c *Config) { c.ForwardProb = 0.95 }, "forwardProb"}, + {"negative education budget", func(c *Config) { c.NumEducated = -1 }, "numEducated"}, + {"educating more students than exist", func(c *Config) { c.NumEducated = 121 }, "numEducated"}, + {"negative origin", func(c *Config) { c.Origin = -1 }, "origin"}, + {"origin beyond the last student", func(c *Config) { c.Origin = 120 }, "origin"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + config := DefaultConfig() + testCase.mutate(&config) + err := ValidateConfig(config) + if err == nil { + t.Fatalf("ValidateConfig accepted %+v, want error", config) + } + if !strings.Contains(err.Error(), testCase.wantMention) { + t.Errorf("error %q does not name field %q", err, testCase.wantMention) + } + if _, err := RunScenario(config, StrategyNone); err == nil { + t.Error("RunScenario accepted the same config, want error") + } + }) + } +} + +func TestValidateConfigAcceptsBoundaryValues(t *testing.T) { + config := DefaultConfig() + bounds := ConfigBounds() + config.NumStudents = bounds.NumStudents.Max + config.EdgesPerNode = bounds.EdgesPerNode.Max + config.ForwardProb = bounds.ForwardProb.Max + config.TriangleProb = bounds.TriangleProb.Max + config.NumEducated = config.NumStudents + config.Origin = config.NumStudents - 1 + if err := ValidateConfig(config); err != nil { + t.Fatalf("ValidateConfig at the upper bounds = %v, want nil", err) + } +} diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go index 37d2e59..a210703 100644 --- a/internal/engine/scenario.go +++ b/internal/engine/scenario.go @@ -50,6 +50,73 @@ func DefaultConfig() Config { } } +// IntBounds is an inclusive allowed range for an integer Config field. +type IntBounds struct { + Min int `json:"min"` + Max int `json:"max"` +} + +// FloatBounds is an inclusive allowed range for a float Config field. +type FloatBounds struct { + Min float64 `json:"min"` + Max float64 `json:"max"` +} + +// Bounds gives the allowed range for every absolute Config field. These +// are realism limits, not mathematical ones: a student does not keep 150 +// close friendships, and a guaranteed forward is not a plausible base +// rate. numEducated and origin are relational (0..numStudents and +// 0..numStudents-1) and therefore not listed; seeds are unrestricted. +type Bounds struct { + NumStudents IntBounds `json:"numStudents"` + EdgesPerNode IntBounds `json:"edgesPerNode"` + TriangleProb FloatBounds `json:"triangleProb"` + ForwardProb FloatBounds `json:"forwardProb"` +} + +// ConfigBounds returns the bounds the engine enforces. The frontend +// receives this verbatim (with the default config) and drives its +// sliders and clamping from the same numbers. +func ConfigBounds() Bounds { + return Bounds{ + NumStudents: IntBounds{Min: 10, Max: 500}, + EdgesPerNode: IntBounds{Min: 1, Max: 8}, + TriangleProb: FloatBounds{Min: 0, Max: 1}, + ForwardProb: FloatBounds{Min: 0, Max: 0.9}, + } +} + +// ValidateConfig rejects configs outside ConfigBounds or with relational +// fields out of range. Error messages contain the offending field's JSON +// name; the frontend matches on it to mark the right control invalid. +func ValidateConfig(config Config) error { + bounds := ConfigBounds() + if config.NumStudents < bounds.NumStudents.Min || config.NumStudents > bounds.NumStudents.Max { + return fmt.Errorf("scenario: need %d <= numStudents <= %d, got %d", + bounds.NumStudents.Min, bounds.NumStudents.Max, config.NumStudents) + } + if config.EdgesPerNode < bounds.EdgesPerNode.Min || config.EdgesPerNode > bounds.EdgesPerNode.Max { + return fmt.Errorf("scenario: need %d <= edgesPerNode <= %d, got %d", + bounds.EdgesPerNode.Min, bounds.EdgesPerNode.Max, config.EdgesPerNode) + } + if config.TriangleProb < bounds.TriangleProb.Min || config.TriangleProb > bounds.TriangleProb.Max { + return fmt.Errorf("scenario: need %v <= triangleProb <= %v, got %v", + bounds.TriangleProb.Min, bounds.TriangleProb.Max, config.TriangleProb) + } + if config.ForwardProb < bounds.ForwardProb.Min || config.ForwardProb > bounds.ForwardProb.Max { + return fmt.Errorf("scenario: need %v <= forwardProb <= %v, got %v", + bounds.ForwardProb.Min, bounds.ForwardProb.Max, config.ForwardProb) + } + if config.NumEducated < 0 || config.NumEducated > config.NumStudents { + return fmt.Errorf("scenario: need 0 <= numEducated <= %d students, got %d", + config.NumStudents, config.NumEducated) + } + if config.Origin < 0 || config.Origin >= config.NumStudents { + return fmt.Errorf("scenario: origin %d outside 0..%d", config.Origin, config.NumStudents-1) + } + return nil +} + // Result is the outcome of one scenario run. type Result struct { Strategy Strategy `json:"strategy"` @@ -77,14 +144,8 @@ func GraphEdges(config Config) ([][2]int, error) { // cascade. Scenarios with the same config share the same world, so // comparing strategies compares only the lever. func RunScenario(config Config, strategy Strategy) (Result, error) { - if config.ForwardProb < 0 || config.ForwardProb > 1 { - return Result{}, fmt.Errorf("scenario: need 0 <= forwardProb <= 1, got %v", config.ForwardProb) - } - if config.Origin < 0 || config.Origin >= config.NumStudents { - return Result{}, fmt.Errorf("scenario: origin %d outside 0..%d", config.Origin, config.NumStudents-1) - } - if config.NumEducated < 0 { - return Result{}, fmt.Errorf("scenario: need numEducated >= 0, got %d", config.NumEducated) + if err := ValidateConfig(config); err != nil { + return Result{}, err } graph, err := HolmeKim(config.NumStudents, config.EdgesPerNode, config.TriangleProb, newRand(config.GraphSeed)) diff --git a/tygo.yaml b/tygo.yaml index 024b496..aacdf2d 100644 --- a/tygo.yaml +++ b/tygo.yaml @@ -11,8 +11,9 @@ packages: include_files: - "api.go" frontmatter: | - import type { Config, Result, Strategy } from "./engine"; + import type { Bounds, Config, Result, Strategy } from "./engine"; type_mappings: + engine.Bounds: "Bounds" engine.Config: "Config" engine.Result: "Result" engine.Strategy: "Strategy" diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 34811ec..355cd3e 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,5 +1,5 @@ // Code generated by tygo. DO NOT EDIT. -import type { Config, Result, Strategy } from "./engine"; +import type { Bounds, Config, Result, Strategy } from "./engine"; ////////// // source: api.go @@ -9,6 +9,15 @@ thin on purpose: decode, run the engine, encode. All validation lives in the engine; the API only translates errors into status codes. */ +/** + * DefaultConfigResponse is the body of GET /api/config/default: the + * default world plus the engine's field bounds, so the frontend drives + * its controls and clamping from the same numbers the engine enforces. + */ +export interface DefaultConfigResponse { + config: Config; + bounds: Bounds; +} /** * ComparisonResponse bundles what the dashboard needs to render one * comparison: the config that was run, echoed back so frontend state stays diff --git a/web/src/types/engine.ts b/web/src/types/engine.ts index 09c0e3b..dc412a9 100644 --- a/web/src/types/engine.ts +++ b/web/src/types/engine.ts @@ -27,6 +27,33 @@ export interface Config { thresholdSeed: number /* uint64 */; educationSeed: number /* uint64 */; // used by the random strategy only } +/** + * IntBounds is an inclusive allowed range for an integer Config field. + */ +export interface IntBounds { + min: number /* int */; + max: number /* int */; +} +/** + * FloatBounds is an inclusive allowed range for a float Config field. + */ +export interface FloatBounds { + min: number /* float64 */; + max: number /* float64 */; +} +/** + * Bounds gives the allowed range for every absolute Config field. These + * are realism limits, not mathematical ones: a student does not keep 150 + * close friendships, and a guaranteed forward is not a plausible base + * rate. numEducated and origin are relational (0..numStudents and + * 0..numStudents-1) and therefore not listed; seeds are unrestricted. + */ +export interface Bounds { + numStudents: IntBounds; + edgesPerNode: IntBounds; + triangleProb: FloatBounds; + forwardProb: FloatBounds; +} /** * Result is the outcome of one scenario run. */