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.
This commit is contained in:
Justin Visser 2026-06-18 20:07:02 +02:00
parent a0635544d9
commit f21994cc96
2 changed files with 35 additions and 8 deletions

View file

@ -29,6 +29,12 @@ func TestValidateConfigRejectsOutOfBounds(t *testing.T) {
{"triangle probability above one", func(c *Config) { c.TriangleProb = 1.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"}, {"negative forward probability", func(c *Config) { c.ForwardProb = -0.1 }, "forwardProb"},
{"guaranteed forwarding", func(c *Config) { c.ForwardProb = 0.95 }, "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"}, {"negative education budget", func(c *Config) { c.NumEducated = -1 }, "numEducated"},
{"educating more students than exist", func(c *Config) { c.NumEducated = 121 }, "numEducated"}, {"educating more students than exist", func(c *Config) { c.NumEducated = 121 }, "numEducated"},
{"negative origin", func(c *Config) { c.Origin = -1 }, "origin"}, {"negative origin", func(c *Config) { c.Origin = -1 }, "origin"},
@ -59,6 +65,9 @@ func TestValidateConfigAcceptsBoundaryValues(t *testing.T) {
config.EdgesPerNode = bounds.EdgesPerNode.Max config.EdgesPerNode = bounds.EdgesPerNode.Max
config.ForwardProb = bounds.ForwardProb.Max config.ForwardProb = bounds.ForwardProb.Max
config.TriangleProb = bounds.TriangleProb.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.NumEducated = config.NumStudents
config.Origin = config.NumStudents - 1 config.Origin = config.NumStudents - 1
if err := ValidateConfig(config); err != nil { if err := ValidateConfig(config); err != nil {

View file

@ -108,10 +108,13 @@ type FloatBounds struct {
// rate. numEducated and origin are relational (0..numStudents and // rate. numEducated and origin are relational (0..numStudents and
// 0..numStudents-1) and therefore not listed; seeds are unrestricted. // 0..numStudents-1) and therefore not listed; seeds are unrestricted.
type Bounds struct { type Bounds struct {
NumStudents IntBounds `json:"numStudents"` NumStudents IntBounds `json:"numStudents"`
EdgesPerNode IntBounds `json:"edgesPerNode"` EdgesPerNode IntBounds `json:"edgesPerNode"`
TriangleProb FloatBounds `json:"triangleProb"` TriangleProb FloatBounds `json:"triangleProb"`
ForwardProb FloatBounds `json:"forwardProb"` 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 // ConfigBounds returns the bounds the engine enforces. The frontend
@ -119,10 +122,13 @@ type Bounds struct {
// sliders and clamping from the same numbers. // sliders and clamping from the same numbers.
func ConfigBounds() Bounds { func ConfigBounds() Bounds {
return Bounds{ return Bounds{
NumStudents: IntBounds{Min: 10, Max: 500}, NumStudents: IntBounds{Min: 10, Max: 500},
EdgesPerNode: IntBounds{Min: 1, Max: 8}, EdgesPerNode: IntBounds{Min: 1, Max: 8},
TriangleProb: FloatBounds{Min: 0, Max: 1}, TriangleProb: FloatBounds{Min: 0, Max: 1},
ForwardProb: FloatBounds{Min: 0, Max: 0.9}, 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", return fmt.Errorf("scenario: need %v <= forwardProb <= %v, got %v",
bounds.ForwardProb.Min, bounds.ForwardProb.Max, config.ForwardProb) 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 { if config.NumEducated < 0 || config.NumEducated > config.NumStudents {
return fmt.Errorf("scenario: need 0 <= numEducated <= %d students, got %d", return fmt.Errorf("scenario: need 0 <= numEducated <= %d students, got %d",
config.NumStudents, config.NumEducated) config.NumStudents, config.NumEducated)