//! Layer 1: Leontief production + consumption + price adjustment. //! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178). //! Layer 3: Corporate behavioral agents (D-178) — added in #809. //! //! 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). //! //! Event port (D-180) — added in #810: //! External disruptions enter via `EventPort` passed to `run_with_events`. //! `run()` is the no-event fast path (delegates to `run_with_events`). //! //! Reference: D-178 (Economic Model Architecture), D-180 (Event Input Port) use std::collections::BTreeMap; use crate::agents; use crate::currency::{CurrencyState, ShadowEconomy}; use crate::db::Economy; use crate::events::{EventModifiers, EventPort}; use crate::seed::Productivity; use crate::trade; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Price adjustment rate per tick (α=0.03, D-178 Layer 2). /// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823). pub 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+3 simulation for `ticks` ticks (no external events). /// /// Fast path: delegates to `run_with_events` with an empty `EventPort`. /// Use `run_with_events` when event injection is required (D-180 tests, debug). /// /// 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 mut port = EventPort::new(); run_with_events(economy, productivity, shadow, adjacency, ticks, &mut port) } /// Run the Layer 1+2+3 simulation with D-180 event injection. /// /// 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). /// Layer 3: Corporate behavioral archetypes (D-178). /// Events: external disruptions applied each tick (D-180). /// /// Tick loop invariant: /// 1. `events.activate_scheduled(tick)` — inject events due this tick. /// 2. `events.compute_modifiers()` → modifier maps for this tick. /// 3. `step_inner` — production + demand + price adjustment with modifiers. /// 4. `currency.apply_exchange_shock` — apply any exchange shock from events. /// 5. `trade_step` — inter-node trade flows. /// 6. `currency.update_rate` — FX adjustment from net cross-zone flow. /// 7. `events.advance_remaining` — decrement and expire finished events. /// /// Returns a flat list of TickRecords (one per active node×commodity×tick). pub fn run_with_events( economy: &Economy, productivity: &BTreeMap<(String, String), Productivity>, shadow: &ShadowEconomy, adjacency: &BTreeMap>, ticks: u32, events: &mut EventPort, ) -> 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 { // Activate any events scheduled for this tick (D-180) events.activate_scheduled(tick as u64); let mods = events.compute_modifiers(economy); step_inner(economy, productivity, shadow, &archetypes, &mut nodes, &mods, ALPHA); currency.apply_exchange_shock(mods.exchange_shock); trade::trade_step(economy, &mut nodes, adjacency, &mut currency, trade::BETA); currency.update_rate(); // Expire events that have completed their duration events.advance_remaining(); 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 // --------------------------------------------------------------------------- /// Initialize node states for all active systems (corp presence or population > 0). /// /// Public for use by [`crate::sim::Simulation`] and external callers that need /// a stateful simulation runner rather than the batch `run_with_events` API. pub 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; /// Single simulation tick: Layer 1 production + demand + price adjustment. /// /// `event_mods` carries per-(node, commodity) multipliers from active D-180 events. /// Pass `&EventModifiers::default()` when no events are active. /// /// Public for use by [`crate::sim::Simulation`] and external stateful runners. pub fn step_inner( economy: &Economy, productivity: &BTreeMap<(String, String), Productivity>, shadow: &ShadowEconomy, archetypes: &BTreeMap, nodes: &mut BTreeMap, event_mods: &EventModifiers, alpha: f64, ) { // 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()); // D-180: capacity multiplier from active events (1.0 if no event) let cap_mult = event_mods.capacity_for(system_id.as_str(), &primary_op); // Effective baseline = BASELINE_CAPACITY scaled by archetype and event let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale * cap_mult; // 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). // D-180: productivity multiplier from active events (1.0 if no event) let prod_mult_event = event_mods.productivity_for(system_id.as_str(), &primary_op); let gross_output = effective_capacity * prod.extraction_rate * prod_mult_event; // 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 multipliers (seeded + event) let prod_mult = prod.for_tier(&chain_output_tier(economy, chain)); let prod_mult_event = event_mods .productivity_for(system_id.as_str(), &chain.output_commodity_id); let gross_output = effective_capacity * chain.output_quantity * capacity_fraction * prod_mult * prod_mult_event; 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. // // D-180: DemandShock events multiply demand further (or compress it). 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 gate_reduced = if commodity.id == "fusion_fuel" && system_info.gate_energy_connected && commodity.tier != "raw" { base_demand * GATE_ENERGY_DEMAND_REDUCTION } else { base_demand }; // D-180: demand shock multiplier from active events (1.0 if no event) let demand_mult = event_mods.demand_for(system_id.as_str(), &commodity.id); // Shadow economy reduces formal-sector consumption (some demand met off-books) let demand = gate_reduced * demand_mult * (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()) }