feat(simulation): add economics simulation binary with Layer 1+2 (#806, #807)

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:
2026-04-07 13:56:06 +02:00
co-authored by Claude Sonnet 4.6
parent 7b0465a8c6
commit fb87f933f5
1169 changed files with 11201 additions and 0 deletions
+130
View File
@@ -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;
}
}
}
}