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.
This commit is contained in:
parent
232faf892e
commit
bd890384e5
7 changed files with 194 additions and 15 deletions
|
|
@ -11,6 +11,14 @@ import (
|
||||||
"github.com/JustinZeus/spreadlab/internal/engine"
|
"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
|
// ComparisonResponse bundles what the dashboard needs to render one
|
||||||
// comparison: the config that was run, echoed back so frontend state stays
|
// comparison: the config that was run, echoed back so frontend state stays
|
||||||
// honest, and one result per strategy.
|
// honest, and one result per strategy.
|
||||||
|
|
@ -53,7 +61,10 @@ func NewServer() http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDefaultConfig(w http.ResponseWriter, _ *http.Request) {
|
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) {
|
func handleComparison(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,15 @@ func TestDefaultConfigEndpoint(t *testing.T) {
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
var config engine.Config
|
var response DefaultConfigResponse
|
||||||
if err := json.Unmarshal(recorder.Body.Bytes(), &config); err != nil {
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if config != engine.DefaultConfig() {
|
if response.Config != engine.DefaultConfig() {
|
||||||
t.Errorf("served config %+v, want %+v", 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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
67
internal/engine/bounds_test.go
Normal file
67
internal/engine/bounds_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
// Result is the outcome of one scenario run.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Strategy Strategy `json:"strategy"`
|
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
|
// cascade. Scenarios with the same config share the same world, so
|
||||||
// comparing strategies compares only the lever.
|
// comparing strategies compares only the lever.
|
||||||
func RunScenario(config Config, strategy Strategy) (Result, error) {
|
func RunScenario(config Config, strategy Strategy) (Result, error) {
|
||||||
if config.ForwardProb < 0 || config.ForwardProb > 1 {
|
if err := ValidateConfig(config); err != nil {
|
||||||
return Result{}, fmt.Errorf("scenario: need 0 <= forwardProb <= 1, got %v", config.ForwardProb)
|
return Result{}, err
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
graph, err := HolmeKim(config.NumStudents, config.EdgesPerNode, config.TriangleProb, newRand(config.GraphSeed))
|
graph, err := HolmeKim(config.NumStudents, config.EdgesPerNode, config.TriangleProb, newRand(config.GraphSeed))
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ packages:
|
||||||
include_files:
|
include_files:
|
||||||
- "api.go"
|
- "api.go"
|
||||||
frontmatter: |
|
frontmatter: |
|
||||||
import type { Config, Result, Strategy } from "./engine";
|
import type { Bounds, Config, Result, Strategy } from "./engine";
|
||||||
type_mappings:
|
type_mappings:
|
||||||
|
engine.Bounds: "Bounds"
|
||||||
engine.Config: "Config"
|
engine.Config: "Config"
|
||||||
engine.Result: "Result"
|
engine.Result: "Result"
|
||||||
engine.Strategy: "Strategy"
|
engine.Strategy: "Strategy"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Code generated by tygo. DO NOT EDIT.
|
// 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
|
// 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.
|
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
|
* ComparisonResponse bundles what the dashboard needs to render one
|
||||||
* comparison: the config that was run, echoed back so frontend state stays
|
* comparison: the config that was run, echoed back so frontend state stays
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,33 @@ export interface Config {
|
||||||
thresholdSeed: number /* uint64 */;
|
thresholdSeed: number /* uint64 */;
|
||||||
educationSeed: number /* uint64 */; // used by the random strategy only
|
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.
|
* Result is the outcome of one scenario run.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue