Files
settled-reach/tooling/econ-sim/src/main.rs
T
jpmschweitzerandClaude Opus 4.6 1498115aba fix(simulation): address PR #125 review — tick truncation, ordering, perf, protocol test
- Widen EventPort tick methods from u32 to u64 (prevents overflow)
- Add is_identity() guard on hot-path String allocation in modifiers
- Replace Vec::remove(0) with VecDeque::pop_front() in price history
- Add .after(tick_economy_simulation) ordering for debug commands
- Fix stale PROTOCOL_VERSION assertion (20 → 21) in serialization test
- Add D-181 Phase 2 visibility scope comment on serve_econ_state_query
- Eliminate double lookup in rebuild_signals via single-pass extraction
- Track economy seed TODO with backlog ticket reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:06:54 +02:00

521 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<PathBuf>,
/// 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<PathBuf>,
/// 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<String>,
}
// ---------------------------------------------------------------------------
// 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::<f64>() / 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 900999.
/// Equilibrium is defined as the mean price over ticks 900999.
///
/// 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: &currency::ShadowEconomy,
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
) {
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 900999
let mut equilibria: BTreeMap<(String, String), f64> = BTreeMap::new();
for (key, ticks) in &by_key {
let late: Vec<f64> = ticks
.iter()
.filter(|(t, _)| *t >= STABILITY_START)
.map(|(_, p)| *p)
.collect();
if late.is_empty() {
continue;
}
equilibria.insert(key.clone(), late.iter().sum::<f64>() / 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: &currency::ShadowEconomy,
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
) -> (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: &currency::ShadowEconomy,
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
) -> (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<u32> = std::collections::BTreeSet::new();
let late_rates: Vec<f64> = 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::<f64>() / 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
),
)
}