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>
131 lines
4.8 KiB
Rust
131 lines
4.8 KiB
Rust
//! 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;
|
||
}
|
||
}
|
||
}
|
||
}
|