//! 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::BTreeMap; use crate::agents; use crate::currency::{CurrencyState, ShadowEconomy}; 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: BTreeMap, } // --------------------------------------------------------------------------- // 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, /// Node-level shadow economy intensity [0.0, 1.0] (D-174, Signal 7). /// Same value for all commodities at this node/tick. pub shadow_intensity: f64, /// Tractus/Mark exchange rate at this tick (1.0 = parity, D-171). pub tractus_mark_rate: 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). /// Currency zone friction and exchange rate adjustment (D-171, D-172). /// /// Returns a flat list of TickRecords (one per active node×commodity×tick). pub fn run( economy: &Economy, productivity: &BTreeMap<(String, String), Productivity>, shadow: &ShadowEconomy, adjacency: &BTreeMap>, ticks: u32, ) -> Vec { let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone()); let mut nodes = init_nodes(economy); let mut currency = CurrencyState::new(); let mut records = Vec::new(); for tick in 0..ticks { step(economy, productivity, shadow, &archetypes, &mut nodes); trade::trade_step(economy, &mut nodes, adjacency, &mut currency); currency.update_rate(); let fx_rate = currency.tractus_mark_rate; for node in nodes.values() { let node_shadow = shadow .intensity .get(&node.system_id) .copied() .unwrap_or(0.0); 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, shadow_intensity: node_shadow, tractus_mark_rate: fx_rate, }); } } } records } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- fn init_nodes(economy: &Economy) -> BTreeMap { let mut nodes: BTreeMap = BTreeMap::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: BTreeMap = BTreeMap::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 // --------------------------------------------------------------------------- /// Fraction of formal demand that shadow economy can satisfy at intensity=1.0. /// /// Shadow goods circulate outside formal channels, reducing stockpile /// consumption by formal-sector demand. At 0% intensity, no shadow goods. /// At 100% intensity, shadow goods meet up to this fraction of demand. const SHADOW_DEMAND_COVERAGE: f64 = 0.30; fn step( economy: &Economy, productivity: &BTreeMap<(String, String), Productivity>, shadow: &ShadowEconomy, archetypes: &BTreeMap, nodes: &mut BTreeMap, ) { // Process each active node independently (Layer 1: no inter-system trade) let system_ids: Vec = 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, }; // Layer 3: behavioral archetype parameters for this corporation let arch_params = archetypes .get(&corp_presence.corp_id) .map(|a| a.params()) .unwrap_or_else(|| agents::Archetype::Producer.params()); // Effective baseline = BASELINE_CAPACITY scaled by archetype let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale; // 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). let gross_output = effective_capacity * prod.extraction_rate; // Monopolist withholds a fraction of output let net_output = gross_output * (1.0 - arch_params.supply_withheld); if let Some(state) = node.commodities.get_mut(&primary_op) { state.supply += net_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 * effective_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 gross_output = effective_capacity * chain.output_quantity * capacity_fraction * prod_mult; let net_output = gross_output * (1.0 - arch_params.supply_withheld); // 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 * effective_capacity * capacity_fraction; state.stockpile = (state.stockpile - consumed).max(0.0); } } // Add net output to supply if let Some(state) = node.commodities.get_mut(&chain.output_commodity_id) { state.supply += net_output; } } } // Price premium: apply archetype price signal to primary commodity at this node. // Positive premium pushes price up; negative discounts it. // Applied as a small additive tâtonnement nudge capped to avoid instability. if arch_params.price_premium.abs() > 1e-6 { if let Some(state) = node.commodities.get_mut(&primary_op) { let base_price = economy .commodity_map .get(&primary_op) .map_or(1.0, |c| c.base_price); let nudge = base_price * arch_params.price_premium * ALPHA; state.price = (state.price + nudge).clamp(base_price * 0.05, base_price * 20.0); } } } // --- Demand step --- // Population demand for final goods and services. // Industrial demand (chain inputs) was already deducted during production. // // Shadow economy (D-174): shadow goods satisfy a fraction of formal demand, // reducing formal-sector stockpile consumption proportionally. let shadow_intensity = shadow.intensity.get(system_id).copied().unwrap_or(0.0); let shadow_coverage = shadow_intensity * SHADOW_DEMAND_COVERAGE; 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 raw_demand = if commodity.id == "fusion_fuel" && system_info.gate_energy_connected && commodity.tier != "raw" { base_demand * GATE_ENERGY_DEMAND_REDUCTION } else { base_demand }; // Shadow economy reduces formal-sector consumption (some demand met off-books) let demand = raw_demand * (1.0 - shadow_coverage); 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()) }