- 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>
468 lines
19 KiB
Rust
468 lines
19 KiB
Rust
//! 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())
|
||
}
|