chore(engine): server hygiene batch — workers tests, surname dedup, save pin (T-1063, T-1064)

- workers/pool.rs: 5 new tests; catch_unwind keeps worker threads alive on
  handler panic (in-flight request loss unchanged, pinned by test + #843
  docs); stubs.rs no longer falsely claims the pool is tested
- save/load: execute_save_load pinned .after(Storyteller) so the scheduler
  cannot legally save pre-Input state; exclusive-system exception recorded
  in tick_phases.rs rules
- surname corpus extracted to bin/shared/surname_corpus.rs (both economy
  generators import it; byte-identical output verified on 23.6MB+1.45MB
  TOMLs); all three stamp/watch registries updated
- generator_spike gated behind non-default 'generator-spike' feature
- economy.rs: 11 new D-181 signal-derivation tests on the new
  econ_sim Simulation::from_economy in-memory constructor
- perception exemption comments now state the consumer sort contract;
  unused bytemuck removed; rayon comment corrected; the 22 allow(dead_code)
  documented as serde schema enforcement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 16:22:28 +02:00
co-authored by Claude Fable 5
parent c64231e8ee
commit 0bd895fcac
19 changed files with 903 additions and 673 deletions
+254
View File
@@ -380,3 +380,257 @@ pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateReso
}
}
}
// ---------------------------------------------------------------------------
// Tests (T-1064): D-181 signal derivation in rebuild_signals.
//
// Built on a minimal in-memory econ_sim::Simulation (one system, one
// commodity) via Simulation::from_economy — no systems.db involved. Node
// commodity state is set directly through the public `sim.nodes` field so
// each test controls the exact price/supply/stockpile/demand sequence the
// signals are derived from.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use econ_sim::db::{Commodity, Economy, SystemInfo};
const SYS: &str = "sys-test";
const COM: &str = "ore";
fn minimal_economy() -> Economy {
let commodity = Commodity {
id: COM.to_string(),
name: "Test Ore".to_string(),
tier: "raw".to_string(),
base_price: 10.0,
elasticity: "normal".to_string(),
production_ubiquity: None,
demand_model: "population".to_string(),
};
let system = SystemInfo {
system_id: SYS.to_string(),
proper_name: None,
// Non-zero population activates the node in model::init_nodes.
population: 1_000,
cultural_corridor: None,
gate_energy_connected: true,
currency_zone: "TRACTUS_PRIMARY".to_string(),
hop_distance: 0,
gate_topology: None,
political_zone: None,
};
Economy {
commodities: vec![commodity.clone()],
commodity_map: BTreeMap::from([(COM.to_string(), commodity)]),
chains: Vec::new(),
chains_by_output: BTreeMap::new(),
systems: BTreeMap::from([(SYS.to_string(), system)]),
corp_presences: Vec::new(),
presences_by_system: BTreeMap::new(),
gate_links: Vec::new(),
corp_archetype_data: Vec::new(),
}
}
fn test_resources() -> (EconSimResource, EconStateResource) {
let sim = Simulation::from_economy(minimal_economy(), 42);
let econ = EconSimResource::new(sim);
assert!(
econ.sim.nodes.contains_key(SYS),
"test system must be an active node"
);
(econ, EconStateResource::default())
}
/// Set the test node's commodity state, then rebuild signals as one
/// economy tick would.
fn set_state_and_rebuild(
econ: &mut EconSimResource,
state: &mut EconStateResource,
econ_tick: u64,
price: f64,
supply: f64,
stockpile: f64,
demand: f64,
) {
let cs = econ
.sim
.nodes
.get_mut(SYS)
.expect("test node active")
.commodities
.get_mut(COM)
.expect("test commodity present");
cs.price = price;
cs.supply = supply;
cs.stockpile = stockpile;
cs.demand = demand;
rebuild_signals(econ, state, econ_tick, 1.0);
}
fn signals(state: &EconStateResource) -> &EconNodeSignals {
state
.signals
.get(&(SYS.to_string(), COM.to_string()))
.expect("signals present for active node")
}
// -- Signal 2: price trend windowing --------------------------------------
#[test]
fn trend_is_zero_with_single_history_entry() {
let (mut econ, mut state) = test_resources();
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 1.0, 1.0);
assert_eq!(signals(&state).price_trend, 0.0);
assert_eq!(signals(&state).price_current, 10.0);
}
#[test]
fn trend_under_window_spans_full_history() {
let (mut econ, mut state) = test_resources();
// 3 ticks (< TREND_WINDOW = 5): trend = current oldest = 16 10.
for (tick, price) in [(1u64, 10.0), (2, 12.0), (3, 16.0)] {
set_state_and_rebuild(&mut econ, &mut state, tick, price, 1.0, 1.0, 1.0);
}
assert_eq!(signals(&state).price_trend, 6.0);
}
#[test]
fn trend_at_exact_window_spans_window() {
let (mut econ, mut state) = test_resources();
// Exactly TREND_WINDOW prices: 10..14 → trend = 14 10.
for i in 0..TREND_WINDOW {
let price = 10.0 + i as f64;
set_state_and_rebuild(&mut econ, &mut state, i as u64 + 1, price, 1.0, 1.0, 1.0);
}
assert_eq!(signals(&state).price_trend, (TREND_WINDOW - 1) as f64);
}
#[test]
fn trend_over_window_slides_oldest_out() {
let (mut econ, mut state) = test_resources();
// 7 monotonically rising prices 10..16; the ring buffer keeps the last
// TREND_WINDOW (5) entries [12..16] → trend = 16 12, NOT 16 10.
for i in 0..7u64 {
let price = 10.0 + i as f64;
set_state_and_rebuild(&mut econ, &mut state, i + 1, price, 1.0, 1.0, 1.0);
}
assert_eq!(signals(&state).price_trend, (TREND_WINDOW - 1) as f64);
}
#[test]
fn trend_is_negative_when_price_falls() {
let (mut econ, mut state) = test_resources();
for (tick, price) in [(1u64, 20.0), (2, 15.0)] {
set_state_and_rebuild(&mut econ, &mut state, tick, price, 1.0, 1.0, 1.0);
}
assert_eq!(signals(&state).price_trend, -5.0);
}
// -- Signal 6: first-tick baseline capture --------------------------------
#[test]
fn baseline_captured_on_first_tick_and_held() {
let (mut econ, mut state) = test_resources();
// First rebuild records supply 50 as the permanent baseline.
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 50.0, 1.0, 1.0);
assert_eq!(signals(&state).production_vs_baseline, 1.0);
// Later supply is measured against that first-tick baseline.
set_state_and_rebuild(&mut econ, &mut state, 2, 10.0, 25.0, 1.0, 1.0);
assert_eq!(signals(&state).production_vs_baseline, 0.5);
set_state_and_rebuild(&mut econ, &mut state, 3, 10.0, 100.0, 1.0, 1.0);
assert_eq!(signals(&state).production_vs_baseline, 2.0);
}
#[test]
fn zero_baseline_yields_unity_ratio() {
let (mut econ, mut state) = test_resources();
// init_nodes warm-starts supply at 0.0 — a zero first-tick baseline
// must not divide; the signal pins at 1.0 (at-baseline) instead.
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 0.0, 1.0, 1.0);
assert_eq!(signals(&state).production_vs_baseline, 1.0);
set_state_and_rebuild(&mut econ, &mut state, 2, 10.0, 37.0, 1.0, 1.0);
let s = signals(&state);
assert_eq!(s.production_vs_baseline, 1.0);
assert!(s.production_vs_baseline.is_finite());
}
// -- Signal 5: stockpile_weeks zero-demand edge ----------------------------
#[test]
fn zero_demand_stockpile_weeks_is_zero_not_infinite() {
let (mut econ, mut state) = test_resources();
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 500.0, 0.0);
let s = signals(&state);
assert_eq!(s.stockpile_weeks, 0.0);
assert!(s.stockpile_weeks.is_finite());
}
#[test]
fn stockpile_weeks_divides_by_weekly_demand() {
let (mut econ, mut state) = test_resources();
// demand 10/tick → 70/week; stockpile 140 → 2 weeks.
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 140.0, 10.0);
assert_eq!(signals(&state).stockpile_weeks, 2.0);
}
// -- Cross-cutting: remaining signals + state bookkeeping ------------------
#[test]
fn rebuild_populates_remaining_signals_and_metadata() {
let (mut econ, mut state) = test_resources();
set_state_and_rebuild(&mut econ, &mut state, 9, 12.5, 33.0, 70.0, 10.0);
assert_eq!(state.econ_tick, 9);
assert_eq!(state.tractus_mark_rate, 1.0);
assert_eq!(state.signals.len(), 1);
let shadow_intensity = econ
.sim
.shadow()
.intensity
.get(SYS)
.copied()
.expect("shadow intensity seeded for test system");
let s = signals(&state);
assert_eq!(s.system_id, SYS);
assert_eq!(s.commodity_id, COM);
assert_eq!(s.price_current, 12.5);
// Signal 3 is a supply proxy in Phase 2.
assert_eq!(s.trade_flow_volume, 33.0);
// No corp presences in the minimal economy.
assert_eq!(s.corporate_presence, 0);
// Signal 7 derives directly from the seeded shadow intensity.
assert_eq!(s.official_coverage_ratio, 1.0 - shadow_intensity);
}
#[test]
fn rebuild_clears_stale_signals() {
let (mut econ, mut state) = test_resources();
// Seed a stale entry for a node that no longer exists.
state.signals.insert(
("ghost-system".to_string(), COM.to_string()),
EconNodeSignals {
system_id: "ghost-system".to_string(),
commodity_id: COM.to_string(),
price_current: 0.0,
price_trend: 0.0,
trade_flow_volume: 0.0,
corporate_presence: 0,
stockpile_weeks: 0.0,
production_vs_baseline: 0.0,
official_coverage_ratio: 0.0,
},
);
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 1.0, 1.0);
assert_eq!(state.signals.len(), 1);
assert!(!state
.signals
.contains_key(&("ghost-system".to_string(), COM.to_string())));
}
}
+7 -1
View File
@@ -107,10 +107,16 @@ impl Plugin for SimulationPlugin {
// save_load is an exclusive system (takes &mut World).
// Exclusive systems in Bevy 0.18 cannot use .in_set() — use .before()/.after()
// to position it within the Snapshot phase window.
// to position it within the Snapshot phase window (the sanctioned exception
// to the tick_phases.rs rules; see the note there).
// Both bounds matter: `.after(Storyteller)` pins the lower bound so the
// scheduler cannot legally run the save before Input/Simulation/Storyteller
// have executed — a save must capture post-Storyteller state, matching what
// compute_observer_snapshot is about to serialize (T-1064 / audit S-09).
app.add_systems(
Update,
save_io::execute_save_load
.after(crate::tick_phases::TickPhase::Storyteller)
.before(crate::perception::observer::compute_observer_snapshot),
);