- 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>
521 lines
18 KiB
Rust
521 lines
18 KiB
Rust
//! 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 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<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 900–999
|
||
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: ¤cy::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: ¤cy::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
|
||
),
|
||
)
|
||
}
|