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,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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 5–12% 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user