Files
settled-reach/tooling/econ-sim/src/model.rs
T
jpmschweitzerandClaude Opus 4.6 1498115aba fix(simulation): address PR #125 review — tick truncation, ordering, perf, protocol test
- Widen EventPort tick methods from u32 to u64 (prevents overflow)
- Add is_identity() guard on hot-path String allocation in modifiers
- Replace Vec::remove(0) with VecDeque::pop_front() in price history
- Add .after(tick_economy_simulation) ordering for debug commands
- Fix stale PROTOCOL_VERSION assertion (20 → 21) in serialization test
- Add D-181 Phase 2 visibility scope comment on serve_econ_state_query
- Eliminate double lookup in rebuild_signals via single-pass extraction
- Track economy seed TODO with backlog ticket reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:06:54 +02:00

468 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, CommodityState>,
}
// ---------------------------------------------------------------------------
// 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<String, Vec<String>>,
ticks: u32,
) -> Vec<TickRecord> {
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<String, Vec<String>>,
ticks: u32,
events: &mut EventPort,
) -> Vec<TickRecord> {
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<String, NodeState> {
let mut nodes: BTreeMap<String, NodeState> = 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<String, CommodityState> = 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<String, agents::Archetype>,
nodes: &mut BTreeMap<String, NodeState>,
event_mods: &EventModifiers,
alpha: f64,
) {
// Process each active node independently (Layer 1: no inter-system trade)
let system_ids: Vec<String> = 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())
}