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:
@@ -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())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user