Adds tooling/econ-sim — a standalone Rust binary for the Phase 2 economics simulation: Layer 1 (Leontief production, #806): - Deterministic per-run PRNG seeding of corp×site productivity (D-176) - Fixed-coefficient production chains; scarcity cascades downstream (D-178) - Per-capita population demand for finals and services - Gate-energy demand reduction for fusion_fuel at connected nodes (D-186) - Price adjustment via local tâtonnement Layer 2 (spatial price equilibrium, #807): - Damped tâtonnement trade flows along gate links (α=0.03, β=0.4, D-178) - 8% transport cost per hop damps long-distance arbitrage - Flows computed from pre-step snapshot; applied atomically - --stability-check implements D-179 Tests 1 and 2: · Test 1: cold-start convergence ±5% at tick 100 → PASS (max 1.05%) · Test 2: long-run stability ±2% over ticks 900–999 → PASS (max 0.00%) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
//! 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 db;
|
||||
mod model;
|
||||
mod output;
|
||||
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());
|
||||
|
||||
// --- Gate adjacency ---
|
||||
let adjacency = trade::build_adjacency(&economy);
|
||||
eprintln!(
|
||||
" {} nodes with gate connections",
|
||||
adjacency.len(),
|
||||
);
|
||||
|
||||
if cli.stability_check {
|
||||
run_stability_checks(&economy, &productivity, &adjacency);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Simulate ---
|
||||
eprintln!("Running {} ticks of Layer 1+2 simulation...", cli.ticks);
|
||||
let snapshots = model::run(&economy, &productivity, &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.
|
||||
fn run_stability_checks(
|
||||
economy: &db::Economy,
|
||||
productivity: &std::collections::HashMap<(String, String), seed::Productivity>,
|
||||
adjacency: &std::collections::HashMap<String, Vec<String>>,
|
||||
) {
|
||||
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, adjacency, CHECK_TICKS);
|
||||
|
||||
// Index records by (node_id, commodity_id) → Vec<(tick, price)>
|
||||
use std::collections::HashMap;
|
||||
let mut by_key: HashMap<(String, String), Vec<(u32, f64)>> = HashMap::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: HashMap<(String, String), f64> = HashMap::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: max deviation at tick 100 from equilibrium
|
||||
let mut test1_pass = true;
|
||||
let mut test1_max_dev: f64 = 0.0;
|
||||
let mut test1_worst: Option<(String, String, f64)> = 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(), dev));
|
||||
}
|
||||
if dev > CONVERGENCE_THRESHOLD {
|
||||
test1_pass = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: max deviation from equilibrium over ticks 900–999
|
||||
let mut test2_pass = true;
|
||||
let mut test2_max_dev: f64 = 0.0;
|
||||
let mut test2_worst: Option<(String, String, f64)> = 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(), dev));
|
||||
}
|
||||
if dev > STABILITY_THRESHOLD {
|
||||
test2_pass = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report
|
||||
let t1_symbol = if test1_pass { "PASS" } else { "FAIL" };
|
||||
let t2_symbol = if test2_pass { "PASS" } else { "FAIL" };
|
||||
|
||||
eprintln!(
|
||||
"Test 1 (cold-start convergence ±5% at tick {CONVERGENCE_TICK}): {t1_symbol} \
|
||||
max_dev={:.2}%{}",
|
||||
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): {t2_symbol} \
|
||||
max_dev={:.2}%{}",
|
||||
test2_max_dev * 100.0,
|
||||
test2_worst
|
||||
.as_ref()
|
||||
.map(|(n, c, _)| format!(" worst: {n}/{c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
if test1_pass && test2_pass {
|
||||
eprintln!("All stability checks passed.");
|
||||
process::exit(0);
|
||||
} else {
|
||||
eprintln!("Stability check FAILED — see above.");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user