From f21994cc966289db40f2bc40dc9ce09016442c1a Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 18 Jun 2026 20:07:02 +0200 Subject: [PATCH] engine: bounds and validation for the new forwarding levers Novelty, HarmAwareness and ProgramEffect each get a 0..1 realism bound, served in ConfigBounds and enforced in ValidateConfig with field-named errors (the frontend matches on the JSON name to flag the right control). Reject/boundary test cases added. --- internal/engine/bounds_test.go | 9 +++++++++ internal/engine/scenario.go | 34 ++++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/internal/engine/bounds_test.go b/internal/engine/bounds_test.go index 61e09d8..4bce6b7 100644 --- a/internal/engine/bounds_test.go +++ b/internal/engine/bounds_test.go @@ -29,6 +29,12 @@ func TestValidateConfigRejectsOutOfBounds(t *testing.T) { {"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 novelty", func(c *Config) { c.Novelty = -0.1 }, "novelty"}, + {"novelty above one", func(c *Config) { c.Novelty = 1.1 }, "novelty"}, + {"negative harm awareness", func(c *Config) { c.HarmAwareness = -0.1 }, "harmAwareness"}, + {"harm awareness above one", func(c *Config) { c.HarmAwareness = 1.1 }, "harmAwareness"}, + {"negative program effect", func(c *Config) { c.ProgramEffect = -0.1 }, "programEffect"}, + {"program effect above one", func(c *Config) { c.ProgramEffect = 1.1 }, "programEffect"}, {"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"}, @@ -59,6 +65,9 @@ func TestValidateConfigAcceptsBoundaryValues(t *testing.T) { config.EdgesPerNode = bounds.EdgesPerNode.Max config.ForwardProb = bounds.ForwardProb.Max config.TriangleProb = bounds.TriangleProb.Max + config.Novelty = bounds.Novelty.Max + config.HarmAwareness = bounds.HarmAwareness.Max + config.ProgramEffect = bounds.ProgramEffect.Max config.NumEducated = config.NumStudents config.Origin = config.NumStudents - 1 if err := ValidateConfig(config); err != nil { diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go index 27e3a98..f9cccb7 100644 --- a/internal/engine/scenario.go +++ b/internal/engine/scenario.go @@ -108,10 +108,13 @@ type FloatBounds struct { // 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"` + NumStudents IntBounds `json:"numStudents"` + EdgesPerNode IntBounds `json:"edgesPerNode"` + TriangleProb FloatBounds `json:"triangleProb"` + ForwardProb FloatBounds `json:"forwardProb"` + Novelty FloatBounds `json:"novelty"` + HarmAwareness FloatBounds `json:"harmAwareness"` + ProgramEffect FloatBounds `json:"programEffect"` } // ConfigBounds returns the bounds the engine enforces. The frontend @@ -119,10 +122,13 @@ type Bounds struct { // 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}, + 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}, + Novelty: FloatBounds{Min: 0, Max: 1}, + HarmAwareness: FloatBounds{Min: 0, Max: 1}, + ProgramEffect: FloatBounds{Min: 0, Max: 1}, } } @@ -147,6 +153,18 @@ func ValidateConfig(config Config) error { return fmt.Errorf("scenario: need %v <= forwardProb <= %v, got %v", bounds.ForwardProb.Min, bounds.ForwardProb.Max, config.ForwardProb) } + if config.Novelty < bounds.Novelty.Min || config.Novelty > bounds.Novelty.Max { + return fmt.Errorf("scenario: need %v <= novelty <= %v, got %v", + bounds.Novelty.Min, bounds.Novelty.Max, config.Novelty) + } + if config.HarmAwareness < bounds.HarmAwareness.Min || config.HarmAwareness > bounds.HarmAwareness.Max { + return fmt.Errorf("scenario: need %v <= harmAwareness <= %v, got %v", + bounds.HarmAwareness.Min, bounds.HarmAwareness.Max, config.HarmAwareness) + } + if config.ProgramEffect < bounds.ProgramEffect.Min || config.ProgramEffect > bounds.ProgramEffect.Max { + return fmt.Errorf("scenario: need %v <= programEffect <= %v, got %v", + bounds.ProgramEffect.Min, bounds.ProgramEffect.Max, config.ProgramEffect) + } if config.NumEducated < 0 || config.NumEducated > config.NumStudents { return fmt.Errorf("scenario: need 0 <= numEducated <= %d students, got %d", config.NumStudents, config.NumEducated)