//! econ-sim: Settled Reach economics simulation binary. //! //! Layer 1: Leontief production + consumption + price adjustment. //! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178). //! Layer 3 (corporate behavioral agents) added in #809. //! //! Usage: //! econ-sim [--db path/to/systems.db] [--ticks 100] [--seed 0] [--output out.csv] //! econ-sim --stability-check # D-179 Tests 1 and 2 //! //! Output: CSV with columns: node_id, commodity_id, supply, demand, price, tick //! //! Reference decisions: D-176 (productivity seeding), D-177 (constraints), //! D-178 (model architecture), D-179 (stability criteria), D-180 (event port) use std::path::PathBuf; use std::process; use clap::Parser; mod agents; mod currency; mod db; mod events; mod model; mod output; mod prng; mod seed; mod trade; // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- #[derive(Parser)] #[command( name = "econ-sim", about = "Settled Reach economics simulation — Layer 1 Leontief production" )] struct Cli { /// Path to systems.db (default: auto-detect from working directory) #[arg(long)] db: Option, /// Number of ticks to simulate #[arg(long, default_value_t = 100)] ticks: u32, /// PRNG seed for productivity randomization (D-176) #[arg(long, default_value_t = 0)] seed: u64, /// Output CSV file (default: stdout) #[arg(long)] output: Option, /// Run stability checks (scaffolded here — exercised in #807 when trade flows added) #[arg(long)] stability_check: bool, /// Comma-separated list of system IDs to simulate (default: all active nodes) #[arg(long)] systems: Option, } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- fn main() { let cli = Cli::parse(); // --- Load --- let db_path = db::resolve_db_path(cli.db); eprintln!("Loading economy data from {}...", db_path.display()); let conn = db::open_db(&db_path); let economy = db::load_economy(&conn); let active_node_count = economy .systems .values() .filter(|s| economy.presences_by_system.contains_key(&s.system_id) || s.population > 0) .count(); eprintln!( " {} commodities, {} production chains, {} active nodes, {} corp presences, {} gate links", economy.commodities.len(), economy.chains.len(), active_node_count, economy.corp_presences.len(), economy.gate_links.len(), ); // --- Seed --- eprintln!( "Seeding per-corporation productivity (run seed: {})...", cli.seed ); let productivity = seed::seed_all_productivity(&economy, cli.seed); eprintln!( " {} corp×site productivity records seeded", productivity.len() ); // --- Behavioral archetypes --- let archetype_map = agents::build_archetype_map(economy.corp_archetype_data.clone()); eprintln!( " {} corporation behavioral archetypes loaded (inferred where not set in DB)", archetype_map.len() ); // --- Gate adjacency --- let adjacency = trade::build_adjacency(&economy); eprintln!(" {} nodes with gate connections", adjacency.len(),); // --- Shadow economy seeding --- eprintln!("Seeding per-node shadow economy intensity (D-174)..."); let shadow = currency::seed_shadow_economy(&economy, cli.seed); let shadow_mean = if shadow.intensity.is_empty() { 0.0 } else { shadow.intensity.values().sum::() / shadow.intensity.len() as f64 }; eprintln!( " {} nodes seeded, mean intensity {:.2}", shadow.intensity.len(), shadow_mean ); if cli.stability_check { run_stability_checks(&economy, &productivity, &shadow, &adjacency); return; } // --- Simulate --- eprintln!("Running {} ticks of Layer 1+2 simulation...", cli.ticks); let snapshots = model::run(&economy, &productivity, &shadow, &adjacency, cli.ticks); eprintln!(" {} output records generated", snapshots.len()); // --- Output --- output::write_csv(&snapshots, cli.output.as_deref()).unwrap_or_else(|e| { eprintln!("error: failed to write output: {}", e); process::exit(1); }); if cli.output.is_some() { eprintln!( "Done. Written to {}", cli.output.as_deref().unwrap().display() ); } } // --------------------------------------------------------------------------- // D-179 Stability Checks (Tests 1 and 2) // --------------------------------------------------------------------------- /// Run D-179 stability tests and exit 0 on pass, 1 on failure. /// /// Test 1 — Cold-start convergence: prices within ±5% of long-run /// equilibrium at tick 100. /// /// Test 2 — Long-run stability: zero drift > ±2% over ticks 900–999. /// Equilibrium is defined as the mean price over ticks 900–999. /// /// Test 3 — Shock response: inject a demand shock on one node at tick 200, /// verify prices recover within 200 ticks, no price explosions (>20×base). /// /// Test 4 — Cross-zone balance: skipped if no MARK_PRIMARY systems exist. /// Otherwise: after a cross-zone trade imbalance is induced, exchange rate /// must re-stabilize (±2% variance) within 50 ticks. fn run_stability_checks( economy: &db::Economy, productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>, shadow: ¤cy::ShadowEconomy, adjacency: &std::collections::BTreeMap>, ) { use std::collections::BTreeMap; const CHECK_TICKS: u32 = 1_000; const CONVERGENCE_TICK: u32 = 100; const STABILITY_START: u32 = 900; const CONVERGENCE_THRESHOLD: f64 = 0.05; // ±5% const STABILITY_THRESHOLD: f64 = 0.02; // ±2% eprintln!("Running D-179 stability checks ({CHECK_TICKS} ticks)..."); let records = model::run(economy, productivity, shadow, adjacency, CHECK_TICKS); // Index records by (node_id, commodity_id) → Vec<(tick, price)> let mut by_key: BTreeMap<(String, String), Vec<(u32, f64)>> = BTreeMap::new(); for r in &records { by_key .entry((r.node_id.clone(), r.commodity_id.clone())) .or_default() .push((r.tick, r.price)); } // Compute per-key equilibrium = mean price over ticks 900–999 let mut equilibria: BTreeMap<(String, String), f64> = BTreeMap::new(); for (key, ticks) in &by_key { let late: Vec = ticks .iter() .filter(|(t, _)| *t >= STABILITY_START) .map(|(_, p)| *p) .collect(); if late.is_empty() { continue; } equilibria.insert(key.clone(), late.iter().sum::() / late.len() as f64); } // ----------------------------------------------------------------- // Test 1: cold-start convergence // ----------------------------------------------------------------- let mut test1_pass = true; let mut test1_max_dev: f64 = 0.0; let mut test1_worst: Option<(String, String)> = None; for (key, eq) in &equilibria { if *eq < 1e-9 { continue; } if let Some(entry) = by_key.get(key) { if let Some((_, price_at_100)) = entry.iter().find(|(t, _)| *t == CONVERGENCE_TICK) { let dev = (price_at_100 - eq).abs() / eq; if dev > test1_max_dev { test1_max_dev = dev; test1_worst = Some((key.0.clone(), key.1.clone())); } if dev > CONVERGENCE_THRESHOLD { test1_pass = false; } } } } // ----------------------------------------------------------------- // Test 2: long-run stability // ----------------------------------------------------------------- let mut test2_pass = true; let mut test2_max_dev: f64 = 0.0; let mut test2_worst: Option<(String, String)> = None; for (key, eq) in &equilibria { if *eq < 1e-9 { continue; } if let Some(ticks) = by_key.get(key) { for (t, price) in ticks { if *t < STABILITY_START { continue; } let dev = (price - eq).abs() / eq; if dev > test2_max_dev { test2_max_dev = dev; test2_worst = Some((key.0.clone(), key.1.clone())); } if dev > STABILITY_THRESHOLD { test2_pass = false; } } } } // ----------------------------------------------------------------- // Test 3: D-179 shock response — inject supply shock, verify cascade // and recovery within 200 ticks (D-179 Test 3, D-180 event port). // ----------------------------------------------------------------- let (test3_pass, test3_note) = run_shock_response_test(economy, productivity, shadow, adjacency); // ----------------------------------------------------------------- // Test 4: cross-zone balance (skip if no MARK_PRIMARY systems) // ----------------------------------------------------------------- let has_mark_zone = economy .systems .values() .any(|s| s.currency_zone == "MARK_PRIMARY"); let (test4_pass, test4_note) = if has_mark_zone { run_cross_zone_test(economy, productivity, shadow, adjacency) } else { ( true, "SKIP — no MARK_PRIMARY systems in DB; re-run after Compact zone data is authored" .to_string(), ) }; // ----------------------------------------------------------------- // Report // ----------------------------------------------------------------- let sym = |p: bool| if p { "PASS" } else { "FAIL" }; eprintln!( "Test 1 (cold-start convergence ±5% at tick {CONVERGENCE_TICK}): {} max_dev={:.2}%{}", sym(test1_pass), test1_max_dev * 100.0, test1_worst .as_ref() .map(|(n, c)| format!(" worst: {n}/{c}")) .unwrap_or_default() ); eprintln!( "Test 2 (long-run stability ±2% over ticks {STABILITY_START}–999): {} max_dev={:.2}%{}", sym(test2_pass), test2_max_dev * 100.0, test2_worst .as_ref() .map(|(n, c)| format!(" worst: {n}/{c}")) .unwrap_or_default() ); eprintln!( "Test 3 (shock response — D-180 CapacityMult event, recovery ≤200 ticks): {} {}", sym(test3_pass), test3_note ); eprintln!( "Test 4 (cross-zone balance re-stabilizes ≤50 ticks): {} {}", sym(test4_pass), test4_note ); let all_pass = test1_pass && test2_pass && test3_pass && test4_pass; if all_pass { eprintln!("All stability checks passed."); process::exit(0); } else { eprintln!("Stability check FAILED — see above."); process::exit(1); } } /// D-179 Test 3: shock response — inject supply disruption, verify cascade and recovery. /// /// Protocol: /// 1. Run WARMUP_TICKS with no events to establish a stable price baseline. /// 2. At tick WARMUP_TICKS, inject a `CapacityMultiplier(0.1)` event on the /// most active node for SHOCK_DURATION ticks (90% capacity reduction). /// 3. Continue for RECOVERY_WINDOW ticks after the shock expires. /// 4. Verify: no price explosion (>20× base) at any tick. /// 5. Verify: all prices at end of recovery ≤ ±5% of the pre-shock baseline. /// /// A `CapacityMultiplier(0.1)` supply disruption is severe enough to deplete /// stockpiles and propagate price signals to neighboring nodes (cascade), /// while remaining recoverable within the 200-tick window (recovery). fn run_shock_response_test( economy: &db::Economy, productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>, shadow: ¤cy::ShadowEconomy, adjacency: &std::collections::BTreeMap>, ) -> (bool, String) { use std::collections::BTreeMap; const WARMUP_TICKS: u32 = 100; const SHOCK_DURATION: u32 = 50; const RECOVERY_WINDOW: u32 = 200; const RECOVERY_THRESHOLD: f64 = 0.05; // ±5% of pre-shock baseline // Pick the first active node (has corp presence) as the shock target let shock_node = economy .presences_by_system .keys() .next() .cloned() .or_else(|| { economy .systems .values() .find(|s| s.population > 0) .map(|s| s.system_id.clone()) }); let shock_node = match shock_node { Some(n) => n, None => return (true, "SKIP — no active nodes for shock test".to_string()), }; // Schedule: inject 90% capacity disruption at tick WARMUP_TICKS let mut port = events::EventPort::new(); port.push_at( WARMUP_TICKS as u64, events::EconEvent { target: events::EconEventTarget::Node(shock_node.clone()), effect: events::EconEventEffect::CapacityMultiplier(0.1), duration: SHOCK_DURATION, visibility: events::EconEventVisibility::Global, }, ); let total_ticks = WARMUP_TICKS + SHOCK_DURATION + RECOVERY_WINDOW; let records = model::run_with_events( economy, productivity, shadow, adjacency, total_ticks, &mut port, ); // Index records by (node_id, commodity_id, tick) for lookups let baseline: BTreeMap<(String, String), f64> = records .iter() .filter(|r| r.tick == WARMUP_TICKS - 1) .map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price)) .collect(); // Check 1: no price explosions or negatives at any tick const PRICE_EXPLOSION_LIMIT: f64 = 20.0; for r in &records { let base = economy .commodity_map .get(&r.commodity_id) .map_or(1.0, |c| c.base_price); if r.price > base * PRICE_EXPLOSION_LIMIT { return ( false, format!( "price explosion at tick {}: {}/{} price={:.1} ({:.0}×base)", r.tick, r.node_id, r.commodity_id, r.price, r.price / base ), ); } if r.price < 0.0 { return ( false, format!( "negative price at tick {}: {}/{} price={:.4}", r.tick, r.node_id, r.commodity_id, r.price ), ); } } // Check 2: prices at end of recovery window are within ±5% of pre-shock baseline let recovery_end_tick = total_ticks - 1; let recovery_prices: BTreeMap<(String, String), f64> = records .iter() .filter(|r| r.tick == recovery_end_tick) .map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price)) .collect(); let mut worst_dev: f64 = 0.0; let mut worst_key = String::new(); for ((node_id, commodity_id), &baseline_price) in &baseline { if baseline_price < 1e-9 { continue; } let key = (node_id.clone(), commodity_id.clone()); if let Some(&recovery_price) = recovery_prices.get(&key) { let dev = (recovery_price - baseline_price).abs() / baseline_price; if dev > worst_dev { worst_dev = dev; worst_key = format!("{node_id}/{commodity_id}"); } } } let pass = worst_dev <= RECOVERY_THRESHOLD; ( pass, format!( "CapacityMult(0.1)×{SHOCK_DURATION}t on {shock_node} at t={WARMUP_TICKS}, \ max_dev={:.1}% at t={recovery_end_tick} (threshold ±5%){}", worst_dev * 100.0, if !worst_key.is_empty() { format!(" worst: {worst_key}") } else { String::new() } ), ) } /// Test 4: cross-zone exchange rate stabilizes within 50 ticks. /// /// Only runs when MARK_PRIMARY systems exist. fn run_cross_zone_test( economy: &db::Economy, productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>, shadow: ¤cy::ShadowEconomy, adjacency: &std::collections::BTreeMap>, ) -> (bool, String) { const TEST_TICKS: u32 = 150; const STABILIZE_BY: u32 = 50; const FX_STABILITY_THRESHOLD: f64 = 0.02; // ±2% let records = model::run(economy, productivity, shadow, adjacency, TEST_TICKS); // Extract tractus_mark_rate — one value per tick (rate is identical across // all node×commodity records in the same tick; deduplicate to avoid bias). let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); let late_rates: Vec = records .iter() .filter(|r| r.tick >= STABILIZE_BY && seen.insert(r.tick)) .map(|r| r.tractus_mark_rate) .collect(); if late_rates.is_empty() { return (true, "no data".to_string()); } let mean_rate = late_rates.iter().sum::() / late_rates.len() as f64; let max_dev = late_rates .iter() .map(|&r| (r - mean_rate).abs() / mean_rate) .fold(0.0_f64, f64::max); let pass = max_dev <= FX_STABILITY_THRESHOLD; ( pass, format!( "fx_rate mean={:.4} max_dev={:.2}% (threshold ±2%)", mean_rate, max_dev * 100.0 ), ) }