feat(simulation): add economics simulation binary with Layer 1+2 (#806, #807)

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:
2026-04-07 13:56:06 +02:00
co-authored by Claude Sonnet 4.6
parent 7b0465a8c6
commit fb87f933f5
1169 changed files with 11201 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
//! Database loading — reads economy data from systems.db.
use std::collections::HashMap;
use std::path::PathBuf;
use std::process;
use rusqlite::Connection;
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Commodity {
pub id: String,
// Display name — used in reporting (#807+):
#[allow(dead_code)]
pub name: String,
pub tier: String,
pub base_price: f64,
// Used by Layer 2+ pricing (#807, #808):
#[allow(dead_code)]
pub elasticity: String,
#[allow(dead_code)]
pub production_ubiquity: Option<String>,
#[allow(dead_code)]
pub demand_model: String,
}
#[derive(Debug, Clone)]
pub struct ChainInput {
pub commodity_id: String,
pub quantity: f64,
}
#[derive(Debug, Clone)]
pub struct ProductionChain {
pub chain_id: String,
pub output_commodity_id: String,
pub output_quantity: f64,
// Used by Layer 2+ for location-constrained production (#807):
#[allow(dead_code)]
pub location_bound: bool,
pub inputs: Vec<ChainInput>,
}
#[derive(Debug, Clone)]
pub struct CorpPresence {
pub corp_id: String,
pub system_id: String,
pub primary_operation: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SystemInfo {
pub system_id: String,
// Used for display/reporting in #807+:
#[allow(dead_code)]
pub proper_name: Option<String>,
pub population: i64,
pub cultural_corridor: Option<String>,
pub gate_energy_connected: bool,
}
/// A directed gate link between two systems.
#[derive(Debug, Clone)]
pub struct GateLink {
pub from_system_id: String,
pub to_system_id: String,
}
/// The complete economics dataset loaded from systems.db.
pub struct Economy {
pub commodities: Vec<Commodity>,
pub commodity_map: HashMap<String, Commodity>,
pub chains: Vec<ProductionChain>,
/// Map: output_commodity_id → list of chains that produce it
pub chains_by_output: HashMap<String, Vec<ProductionChain>>,
/// Map: system_id → SystemInfo
pub systems: HashMap<String, SystemInfo>,
pub corp_presences: Vec<CorpPresence>,
/// Map: system_id → list of corp presences
pub presences_by_system: HashMap<String, Vec<CorpPresence>>,
/// Bidirectional gate links (transport graph)
pub gate_links: Vec<GateLink>,
}
// ---------------------------------------------------------------------------
// DB helpers
// ---------------------------------------------------------------------------
pub fn resolve_db_path(explicit: Option<PathBuf>) -> PathBuf {
if let Some(p) = explicit {
return p;
}
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
loop {
let candidate = dir.join("server").join("data").join("systems.db");
if candidate.exists() {
return candidate;
}
if !dir.pop() {
break;
}
}
eprintln!("error: cannot find server/data/systems.db — pass --db explicitly");
process::exit(1);
}
pub fn open_db(path: &PathBuf) -> Connection {
let conn = Connection::open(path).unwrap_or_else(|e| {
eprintln!("error: cannot open {}: {}", path.display(), e);
process::exit(1);
});
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.expect("PRAGMA setup failed");
conn
}
// ---------------------------------------------------------------------------
// Loaders
// ---------------------------------------------------------------------------
fn load_commodities(conn: &Connection) -> Vec<Commodity> {
let mut stmt = conn
.prepare(
"SELECT commodity_id, name, tier, base_price, elasticity,
production_ubiquity, demand_model
FROM commodities ORDER BY commodity_id",
)
.expect("prepare commodities");
stmt.query_map([], |row| {
Ok(Commodity {
id: row.get(0)?,
name: row.get(1)?,
tier: row.get(2)?,
base_price: row.get(3)?,
elasticity: row.get(4)?,
production_ubiquity: row.get(5)?,
demand_model: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
})
})
.expect("query commodities")
.filter_map(|r| r.ok())
.collect()
}
fn load_chains(conn: &Connection) -> Vec<ProductionChain> {
let mut chain_stmt = conn
.prepare(
"SELECT chain_id, output_commodity_id, output_quantity, location_bound
FROM production_chains ORDER BY chain_id",
)
.expect("prepare chains");
let mut chains: Vec<ProductionChain> = chain_stmt
.query_map([], |row| {
Ok(ProductionChain {
chain_id: row.get(0)?,
output_commodity_id: row.get(1)?,
output_quantity: row.get(2)?,
location_bound: row.get::<_, i32>(3)? != 0,
inputs: Vec::new(),
})
})
.expect("query chains")
.filter_map(|r| r.ok())
.collect();
// Load inputs for each chain
let mut input_stmt = conn
.prepare(
"SELECT chain_id, input_commodity_id, quantity
FROM chain_inputs ORDER BY chain_id, input_commodity_id",
)
.expect("prepare chain_inputs");
let all_inputs: Vec<(String, String, f64)> = input_stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.expect("query chain_inputs")
.filter_map(|r| r.ok())
.collect();
// Build index of chain_id → inputs
let mut input_map: HashMap<String, Vec<ChainInput>> = HashMap::new();
for (chain_id, commodity_id, quantity) in all_inputs {
input_map
.entry(chain_id)
.or_default()
.push(ChainInput { commodity_id, quantity });
}
for chain in &mut chains {
if let Some(inputs) = input_map.remove(&chain.chain_id) {
chain.inputs = inputs;
}
}
chains
}
fn load_systems(conn: &Connection) -> HashMap<String, SystemInfo> {
let mut stmt = conn
.prepare(
"SELECT ss.system_id, ss.proper_name, ss.cultural_corridor,
ss.gate_energy_connected,
COALESCE(se.population, 0) as population
FROM star_systems ss
LEFT JOIN system_economy se ON ss.system_id = se.system_id
ORDER BY ss.system_id",
)
.expect("prepare systems");
stmt.query_map([], |row| {
Ok(SystemInfo {
system_id: row.get(0)?,
proper_name: row.get(1)?,
population: row.get(4)?,
cultural_corridor: row.get(2)?,
gate_energy_connected: row.get::<_, Option<i32>>(3)?.unwrap_or(1) != 0,
})
})
.expect("query systems")
.filter_map(|r| r.ok())
.map(|s| (s.system_id.clone(), s))
.collect()
}
fn load_gate_links(conn: &Connection) -> Vec<GateLink> {
let mut stmt = conn
.prepare(
"SELECT from_system_id, to_system_id FROM gate_links
ORDER BY from_system_id, to_system_id",
)
.expect("prepare gate_links");
stmt.query_map([], |row| {
Ok(GateLink {
from_system_id: row.get(0)?,
to_system_id: row.get(1)?,
})
})
.expect("query gate_links")
.filter_map(|r| r.ok())
.collect()
}
fn load_corp_presences(conn: &Connection) -> Vec<CorpPresence> {
let mut stmt = conn
.prepare(
"SELECT corp_id, location_id, primary_operation
FROM corp_presence
WHERE location_type = 'system'
ORDER BY location_id, corp_id",
)
.expect("prepare corp_presence");
stmt.query_map([], |row| {
Ok(CorpPresence {
corp_id: row.get(0)?,
system_id: row.get(1)?,
primary_operation: row.get(2)?,
})
})
.expect("query corp_presence")
.filter_map(|r| r.ok())
.collect()
}
// ---------------------------------------------------------------------------
// Main loader
// ---------------------------------------------------------------------------
pub fn load_economy(conn: &Connection) -> Economy {
let commodities = load_commodities(conn);
let commodity_map: HashMap<String, Commodity> =
commodities.iter().map(|c| (c.id.clone(), c.clone())).collect();
let chains = load_chains(conn);
let mut chains_by_output: HashMap<String, Vec<ProductionChain>> = HashMap::new();
for chain in &chains {
chains_by_output
.entry(chain.output_commodity_id.clone())
.or_default()
.push(chain.clone());
}
let systems = load_systems(conn);
let corp_presences = load_corp_presences(conn);
let mut presences_by_system: HashMap<String, Vec<CorpPresence>> = HashMap::new();
for cp in &corp_presences {
presences_by_system
.entry(cp.system_id.clone())
.or_default()
.push(cp.clone());
}
let gate_links = load_gate_links(conn);
Economy {
commodities,
commodity_map,
chains,
chains_by_output,
systems,
corp_presences,
presences_by_system,
gate_links,
}
}
+254
View File
@@ -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 900999.
/// Equilibrium is defined as the mean price over ticks 900999.
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 900999
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 900999
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);
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Layer 1: Leontief production + consumption + price adjustment.
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
//!
//! Each system with economic activity (corp presence or population > 0)
//! is an active market node. Goods flow along gate links when price
//! differentials exceed transport costs (α=0.03, β=0.4).
//!
//! Layer 3 (corporate behavioral agents) is added in #809.
//!
//! Reference: D-178 (Economic Model Architecture)
use std::collections::HashMap;
use crate::db::Economy;
use crate::seed::Productivity;
use crate::trade;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Price adjustment rate per tick (α=0.03, D-178 Layer 2).
const ALPHA: f64 = 0.03;
/// Baseline production capacity per corp per tick (units/tick).
const BASELINE_CAPACITY: f64 = 10.0;
/// Initial stockpile buffer (in ticks of baseline demand).
const INITIAL_STOCKPILE_BUFFER: f64 = 4.0;
/// Per-capita demand coefficient for final goods (units/tick per person).
const DEMAND_PER_CAPITA_FINAL: f64 = 1.0e-6;
/// Per-capita demand coefficient for services (units/tick per person).
const DEMAND_PER_CAPITA_SERVICE: f64 = 0.5e-6;
/// Fusion fuel utility demand reduction for gate-energy-connected nodes (D-186, D-188).
const GATE_ENERGY_DEMAND_REDUCTION: f64 = 0.3;
// ---------------------------------------------------------------------------
// Node state
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct CommodityState {
pub supply: f64,
pub demand: f64,
pub price: f64,
pub stockpile: f64,
}
#[derive(Debug, Clone)]
pub struct NodeState {
pub system_id: String,
/// commodity_id → state
pub commodities: HashMap<String, CommodityState>,
}
// ---------------------------------------------------------------------------
// Tick snapshot (output record)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct TickRecord {
pub tick: u32,
pub node_id: String,
pub commodity_id: String,
pub supply: f64,
pub demand: f64,
pub price: f64,
}
// ---------------------------------------------------------------------------
// Simulation
// ---------------------------------------------------------------------------
/// Run the Layer 1+2 simulation for `ticks` ticks.
///
/// Layer 1: Leontief production + consumption + stockpile update.
/// Layer 2: Damped tâtonnement trade flows along gate links (D-178).
///
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
pub fn run(
economy: &Economy,
productivity: &HashMap<(String, String), Productivity>,
adjacency: &HashMap<String, Vec<String>>,
ticks: u32,
) -> Vec<TickRecord> {
let mut nodes = init_nodes(economy);
let mut records = Vec::new();
for tick in 0..ticks {
step(economy, productivity, &mut nodes);
trade::trade_step(&mut nodes, adjacency);
for node in nodes.values() {
for (commodity_id, state) in &node.commodities {
records.push(TickRecord {
tick,
node_id: node.system_id.clone(),
commodity_id: commodity_id.clone(),
supply: state.supply,
demand: state.demand,
price: state.price,
});
}
}
}
records
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
fn init_nodes(economy: &Economy) -> HashMap<String, NodeState> {
let mut nodes: HashMap<String, NodeState> = HashMap::new();
// Activate nodes that have corp presence or non-zero population
for (system_id, system) in &economy.systems {
let has_corps = economy.presences_by_system.contains_key(system_id);
let has_population = system.population > 0;
if !has_corps && !has_population {
continue;
}
let mut commodity_states: HashMap<String, CommodityState> = HashMap::new();
for commodity in &economy.commodities {
let base_price = commodity.base_price;
let base_demand = base_population_demand(system.population, &commodity.tier);
// Warm start: all commodities get a baseline inventory so production
// chains can run from tick 0. This represents the "economy already
// operating" state rather than a cold start from empty warehouses.
let stockpile = BASELINE_CAPACITY * INITIAL_STOCKPILE_BUFFER;
commodity_states.insert(
commodity.id.clone(),
CommodityState {
supply: 0.0,
demand: base_demand,
price: base_price,
stockpile,
},
);
}
nodes.insert(
system_id.clone(),
NodeState {
system_id: system_id.clone(),
commodities: commodity_states,
},
);
}
nodes
}
/// Baseline population-driven demand for direct consumption.
///
/// Raw and intermediate commodities have zero direct population demand —
/// they are consumed through production chains only.
fn base_population_demand(population: i64, tier: &str) -> f64 {
let pop = population as f64;
match tier {
"final" => pop * DEMAND_PER_CAPITA_FINAL,
"service_professional" | "service_luxury" => pop * DEMAND_PER_CAPITA_SERVICE,
_ => 0.0, // raw and intermediate: demand comes from production chain inputs only
}
}
// ---------------------------------------------------------------------------
// Simulation step
// ---------------------------------------------------------------------------
fn step(
economy: &Economy,
productivity: &HashMap<(String, String), Productivity>,
nodes: &mut HashMap<String, NodeState>,
) {
// Process each active node independently (Layer 1: no inter-system trade)
let system_ids: Vec<String> = nodes.keys().cloned().collect();
for system_id in &system_ids {
let node = nodes.get_mut(system_id).unwrap();
let system_info = match economy.systems.get(system_id) {
Some(s) => s,
None => continue,
};
// Reset per-tick supply
for state in node.commodities.values_mut() {
state.supply = 0.0;
}
// --- Production step ---
// For each corp present at this node, run the production chains
// that produce their primary_operation commodity.
let corps = economy
.presences_by_system
.get(system_id)
.cloned()
.unwrap_or_default();
for corp_presence in &corps {
let prod = match productivity.get(&(corp_presence.corp_id.clone(), system_id.clone()))
{
Some(p) => p,
None => continue,
};
let primary_op = match &corp_presence.primary_operation {
Some(op) => op.clone(),
None => continue,
};
// Determine the tier of the primary_operation commodity
let tier = economy
.commodity_map
.get(&primary_op)
.map(|c| c.tier.as_str())
.unwrap_or("");
if tier == "raw" {
// Raw materials: direct extraction — no chain inputs required (D-177).
// Extraction rate multiplier applies.
let output = BASELINE_CAPACITY * prod.extraction_rate;
if let Some(state) = node.commodities.get_mut(&primary_op) {
state.supply += output;
}
} else {
// Intermediate / final goods: run production chain with Leontief inputs.
let chains = match economy.chains_by_output.get(&primary_op) {
Some(c) => c.clone(),
None => continue,
};
for chain in &chains {
// Leontief constraint: minimum input availability fraction
let mut capacity_fraction = 1.0_f64;
for input in &chain.inputs {
if let Some(state) = node.commodities.get(&input.commodity_id) {
let available = state.stockpile;
let required = input.quantity * BASELINE_CAPACITY;
if required > 0.0 {
capacity_fraction = capacity_fraction
.min(available / required)
.clamp(0.0, 1.0);
}
} else {
capacity_fraction = 0.0;
break;
}
}
// Apply productivity multiplier
let prod_mult = prod.for_tier(&chain_output_tier(economy, chain));
let actual_output =
BASELINE_CAPACITY * chain.output_quantity * capacity_fraction * prod_mult;
// Consume inputs (Leontief: fixed-coefficient deduction)
for input in &chain.inputs {
if let Some(state) = node.commodities.get_mut(&input.commodity_id) {
let consumed = input.quantity * BASELINE_CAPACITY * capacity_fraction;
state.stockpile = (state.stockpile - consumed).max(0.0);
}
}
// Add output to supply
if let Some(state) = node.commodities.get_mut(&chain.output_commodity_id) {
state.supply += actual_output;
}
}
}
}
// --- Demand step ---
// Population demand for final goods and services.
// Industrial demand (chain inputs) was already deducted during production.
for commodity in &economy.commodities {
let base_demand = base_population_demand(system_info.population, &commodity.tier);
// D-186/D-188: reduce fusion_fuel utility demand if gate energy is connected
let demand = if commodity.id == "fusion_fuel"
&& system_info.gate_energy_connected
&& commodity.tier != "raw"
{
base_demand * GATE_ENERGY_DEMAND_REDUCTION
} else {
base_demand
};
if let Some(state) = node.commodities.get_mut(&commodity.id) {
state.demand = demand;
// Domestic consumption from stockpile
state.stockpile = (state.stockpile - demand).max(0.0);
}
}
// --- Stockpile update ---
// Add this tick's supply to stockpile
for state in node.commodities.values_mut() {
state.stockpile += state.supply;
}
// --- Price adjustment (tâtonnement, Layer 1 local) ---
// Adjust based on stockpile level relative to demand.
// At equilibrium, stockpile ≈ INITIAL_STOCKPILE_BUFFER × demand.
for (commodity_id, state) in &mut node.commodities {
let equilibrium_stock = state.demand * INITIAL_STOCKPILE_BUFFER;
let base_price = economy
.commodity_map
.get(commodity_id)
.map_or(1.0, |c| c.base_price);
// Positive excess → price falls; negative excess → price rises
let excess = if equilibrium_stock > 0.0 {
(state.stockpile - equilibrium_stock) / equilibrium_stock
} else if state.supply > 0.0 {
1.0 // over-supplied vs zero demand
} else {
0.0
};
state.price = (state.price * (1.0 - ALPHA * excess))
.clamp(base_price * 0.05, base_price * 20.0);
}
}
}
/// Look up the tier of the output commodity for a given chain.
fn chain_output_tier(economy: &Economy, chain: &crate::db::ProductionChain) -> String {
economy
.commodity_map
.get(&chain.output_commodity_id)
.map(|c| c.tier.clone())
.unwrap_or_else(|| "intermediate".to_string())
}
+41
View File
@@ -0,0 +1,41 @@
//! CSV output for simulation snapshots.
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
use crate::model::TickRecord;
/// Write records to CSV. If `path` is None, writes to stdout.
pub fn write_csv(records: &[TickRecord], path: Option<&Path>) -> io::Result<()> {
let header = "node_id,commodity_id,supply,demand,price,tick\n";
match path {
Some(p) => {
let file = File::create(p)?;
let mut w = BufWriter::new(file);
write!(w, "{}", header)?;
for r in records {
writeln!(
w,
"{},{},{:.4},{:.4},{:.4},{}",
r.node_id, r.commodity_id, r.supply, r.demand, r.price, r.tick
)?;
}
w.flush()
}
None => {
let stdout = io::stdout();
let mut w = BufWriter::new(stdout.lock());
write!(w, "{}", header)?;
for r in records {
writeln!(
w,
"{},{},{:.4},{:.4},{:.4},{}",
r.node_id, r.commodity_id, r.supply, r.demand, r.price, r.tick
)?;
}
w.flush()
}
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Productivity seeding — D-176.
//!
//! Per-run PRNG seeding of corporation×site productivity on five dimensions.
//! Log-normal distribution with corridor correlation ~0.6.
//!
//! What CANNOT be seeded (D-177): location of production, biological monopoly
//! ceilings, aging pipeline contents, gate topology.
use std::collections::HashMap;
use std::f64::consts::PI;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use crate::db::Economy;
// ---------------------------------------------------------------------------
// Productivity record (D-176)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Productivity {
/// Output per unit time from mines, wells, fisheries
pub extraction_rate: f64,
/// Units processed per tick in manufacturing and refineries
pub processing_throughput: f64,
/// Freight volume per gate crossing for logistics operators — used in #807 (trade flows)
#[allow(dead_code)]
pub transit_capacity: f64,
/// Clients served per tick for service firms
pub service_throughput: f64,
/// Maximum concurrent engagements for service firms — used in #809 (agents)
#[allow(dead_code)]
pub service_capacity: f64,
}
impl Productivity {
/// Multiplier appropriate for a given commodity tier.
pub fn for_tier(&self, tier: &str) -> f64 {
match tier {
"raw" => self.extraction_rate,
"intermediate" => self.processing_throughput,
"final" => self.processing_throughput,
"service_professional" | "service_luxury" => self.service_throughput,
_ => 1.0,
}
}
}
// ---------------------------------------------------------------------------
// PRNG helpers
// ---------------------------------------------------------------------------
/// Deterministic hash mix: FNV-1a of a string mixed with a 64-bit seed.
fn derive_seed(run_seed: u64, key: &str) -> u64 {
let mut h = run_seed.wrapping_add(14_695_981_039_346_656_037u64);
for byte in key.bytes() {
h ^= byte as u64;
h = h.wrapping_mul(1_099_511_628_211u64);
}
h
}
/// Box-Muller transform: returns a standard normal variate from [0,1) samples.
fn standard_normal(rng: &mut ChaCha8Rng) -> f64 {
use rand::Rng;
let u1: f64 = 1.0 - rng.random::<f64>(); // avoid ln(0)
let u2: f64 = rng.random::<f64>();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
// ---------------------------------------------------------------------------
// Seeding entry point
// ---------------------------------------------------------------------------
/// Seed productivity for all corp×system pairs.
///
/// Returns a map keyed by (corp_id, system_id) → Productivity.
pub fn seed_all_productivity(
economy: &Economy,
run_seed: u64,
) -> HashMap<(String, String), Productivity> {
// σ for standard nodes: chosen so that exp(±2σ) ≈ [0.4, 1.8] at 95%
// Geometric mean of [0.4, 1.8] ≈ 0.849. μ = ln(0.849) ≈ 0.164.
// We use μ=0 (geometric mean = 1) and wider σ; the clamp enforces the range.
let sigma_total: f64 = 0.38;
// Corridor-shared variance fraction: ρ = 0.6 (D-176)
let rho: f64 = 0.6;
let sigma_shared = (rho).sqrt() * sigma_total;
let sigma_individual = (1.0 - rho).sqrt() * sigma_total;
// Pre-compute corridor Z values (shared across all corps in the same corridor)
let mut corridor_z: HashMap<String, f64> = HashMap::new();
let mut result = HashMap::new();
for cp in &economy.corp_presences {
let system = match economy.systems.get(&cp.system_id) {
Some(s) => s,
None => continue,
};
// Corridor shared factor
let corridor_contribution = if let Some(corr) = &system.cultural_corridor {
let z = *corridor_z.entry(corr.clone()).or_insert_with(|| {
let seed = derive_seed(run_seed, corr);
let mut rng = ChaCha8Rng::seed_from_u64(seed);
standard_normal(&mut rng)
});
sigma_shared * z
} else {
0.0
};
// Individual factor per corp×site
let key = format!("{}:{}", cp.corp_id, cp.system_id);
let site_seed = derive_seed(run_seed, &key);
let mut rng = ChaCha8Rng::seed_from_u64(site_seed);
let sample = |rng: &mut ChaCha8Rng| -> f64 {
let individual_z = standard_normal(rng);
let combined = corridor_contribution + sigma_individual * individual_z;
combined.exp().clamp(0.4, 1.8)
};
let prod = Productivity {
extraction_rate: sample(&mut rng),
processing_throughput: sample(&mut rng),
transit_capacity: sample(&mut rng),
service_throughput: sample(&mut rng),
service_capacity: sample(&mut rng),
};
result.insert((cp.corp_id.clone(), cp.system_id.clone()), prod);
}
result
}
+130
View File
@@ -0,0 +1,130 @@
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
//!
//! Goods flow along direct gate links when price differentials exceed
//! transport costs. Multi-hop propagation occurs over multiple ticks as
//! direct-neighbor flows compound. β=0.4 dampens flows to prevent cobweb
//! oscillation.
//!
//! Gate links are bidirectional in the DB; `build_adjacency` builds the
//! full adjacency map directly from them.
use std::collections::HashMap;
use crate::db::Economy;
use crate::model::NodeState;
// ---------------------------------------------------------------------------
// Constants (D-178)
// ---------------------------------------------------------------------------
/// Transport cost per gate hop (midpoint of 512% range from D-178).
const GATE_COST_PER_HOP: f64 = 0.08;
/// Damping factor β (D-178): fraction of potential flow that actually moves
/// per tick. Prevents cobweb oscillation.
const BETA: f64 = 0.4;
/// Maximum fraction of a node's stockpile exported per tick via a single link.
/// Limits shock propagation speed.
const MAX_EXPORT_FRACTION: f64 = 0.15;
// ---------------------------------------------------------------------------
// Adjacency
// ---------------------------------------------------------------------------
/// Build a direct-neighbor map from the gate link list.
///
/// DB stores links bidirectionally (A→B and B→A both present), so we
/// collect them as-is without adding reverse edges. The resulting map
/// covers all active market nodes that have at least one gate connection.
pub fn build_adjacency(economy: &Economy) -> HashMap<String, Vec<String>> {
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
for link in &economy.gate_links {
adj.entry(link.from_system_id.clone())
.or_default()
.push(link.to_system_id.clone());
}
adj
}
// ---------------------------------------------------------------------------
// Trade step
// ---------------------------------------------------------------------------
/// Apply one tick of inter-node trade flows along direct gate links.
///
/// For each directed gate link (A → B): if the price of a commodity in A,
/// after paying transport cost, is still below the price in B, goods flow
/// from A to B. The flow is damped by β and capped by MAX_EXPORT_FRACTION
/// of A's stockpile.
///
/// All flows are computed from the pre-step state and applied atomically
/// to avoid order-dependent artifacts.
pub fn trade_step(
nodes: &mut HashMap<String, NodeState>,
adjacency: &HashMap<String, Vec<String>>,
) {
let cost_factor = 1.0 + GATE_COST_PER_HOP;
// Collect pending flows before mutating (snapshot prices/stockpiles first)
// (from_system, to_system, commodity_id, amount)
let mut flows: Vec<(String, String, String, f64)> = Vec::new();
for (from_id, neighbors) in adjacency {
let from_node = match nodes.get(from_id.as_str()) {
Some(n) => n,
None => continue,
};
for to_id in neighbors {
let to_node = match nodes.get(to_id.as_str()) {
Some(n) => n,
None => continue,
};
for (commodity_id, from_state) in &from_node.commodities {
let to_state = match to_node.commodities.get(commodity_id) {
Some(s) => s,
None => continue,
};
// Only trade if profitable after transport cost
let effective_price = from_state.price * cost_factor;
if effective_price >= to_state.price {
continue;
}
// Normalised price differential ∈ (0, 1) drives flow magnitude
let price_ratio =
(to_state.price - effective_price) / to_state.price;
// Damped flow capped at MAX_EXPORT_FRACTION of exporter's stockpile
let max_export = from_state.stockpile * MAX_EXPORT_FRACTION;
let flow = BETA * price_ratio * max_export;
if flow > 1e-6 {
flows.push((
from_id.clone(),
to_id.clone(),
commodity_id.clone(),
flow,
));
}
}
}
}
// Apply flows
for (from_id, to_id, commodity_id, amount) in flows {
if let Some(from_node) = nodes.get_mut(&from_id) {
if let Some(state) = from_node.commodities.get_mut(&commodity_id) {
state.stockpile = (state.stockpile - amount).max(0.0);
}
}
if let Some(to_node) = nodes.get_mut(&to_id) {
if let Some(state) = to_node.commodities.get_mut(&commodity_id) {
state.stockpile += amount;
}
}
}
}