//! 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> { let mut adj: HashMap> = 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, adjacency: &HashMap>, ) { 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; } } } }