//! 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 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: no-explosion check (price bounds over 1000-tick run) // Note: this is NOT a D-179 shock injection test. Full shock-response // testing (inject → cascade → recovery) requires D-180 event port. // ----------------------------------------------------------------- let (test3_pass, test3_note) = run_no_explosion_check(economy, &records); // ----------------------------------------------------------------- // 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 (no-explosion check — price bounds over 1000 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); } } /// Verify no price explosions or negative prices in the 1000-tick run. /// /// This is NOT a D-179 shock injection test. D-179 Test 3 requires deliberate /// shock injection via the D-180 event port, which is not yet implemented. /// This check validates the weaker property: the model does not produce /// unbounded prices (>20× base) or negative prices over 1000 ticks. fn run_no_explosion_check( economy: &db::Economy, records_1000: &[model::TickRecord], ) -> (bool, String) { const PRICE_EXPLOSION_LIMIT: f64 = 20.0; // 20× base_price // Check: no price > 20× base at any tick let mut explosion_detected = false; let mut explosion_worst = String::new(); for r in records_1000 { let base = economy .commodity_map .get(&r.commodity_id) .map_or(1.0, |c| c.base_price); if r.price > base * PRICE_EXPLOSION_LIMIT { explosion_detected = true; explosion_worst = format!( "{}/{} price={:.1} base={:.1} ({:.0}×)", r.node_id, r.commodity_id, r.price, base, r.price / base ); } } if explosion_detected { return (false, format!("price explosion: {}", explosion_worst)); } // Check: no negative prices (should be clamped by model, verify here) if let Some(r) = records_1000.iter().find(|r| r.price < 0.0) { return ( false, format!( "{}/{} price went negative: {}", r.node_id, r.commodity_id, r.price ), ); } ( true, format!( "no explosions (>{:.0}× base), no negatives across {} records", PRICE_EXPLOSION_LIMIT, records_1000.len() ), ) } /// 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 ), ) }