diff --git a/tooling/econ-sim/src/agents.rs b/tooling/econ-sim/src/agents.rs new file mode 100644 index 000000000..e79327235 --- /dev/null +++ b/tooling/econ-sim/src/agents.rs @@ -0,0 +1,157 @@ +//! Layer 3: Corporate behavioral agents (D-178). +//! +//! Six behavioral archetypes from D-175 / Burnelli-Sheldon: +//! +//! Producer — maximises output, low trade aggression +//! Distributor — volume-focused, aggressive trade, thin margin +//! Specialist — premium pricing, narrow focus, low trade +//! Monopolist — withholds supply to maintain scarcity premium +//! Cooperative — fair pricing, community stability orientation +//! Intermediary — arbitrage-focused, high trade, lower own production +//! +//! Archetypes are loaded from `corporations.behavioral_archetype` in the DB. +//! If NULL, the archetype is inferred from the `specialization` field text. +//! +//! Parameters apply to per-corp production in each simulation tick. +//! Trade-layer archetype effects (corp-level bid/ask) are deferred to a +//! future sprint when the event port (D-180) and IPC bridge are in place. + +use std::collections::HashMap; + +// --------------------------------------------------------------------------- +// Archetype enum +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Archetype { + Producer, + Distributor, + Specialist, + Monopolist, + Cooperative, + Intermediary, +} + +impl Archetype { + /// Parse from DB string (case-insensitive). + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().trim() { + "producer" => Some(Archetype::Producer), + "distributor" => Some(Archetype::Distributor), + "specialist" => Some(Archetype::Specialist), + "monopolist" => Some(Archetype::Monopolist), + "cooperative" => Some(Archetype::Cooperative), + "intermediary" => Some(Archetype::Intermediary), + _ => None, + } + } + + /// Infer archetype from `specialization` field free text. + /// + /// Heuristic: look for domain keywords that map to behavioral patterns. + /// Falls back to `Producer` (the most neutral, maximises output). + pub fn infer_from_specialization(spec: &str) -> Self { + let s = spec.to_lowercase(); + if s.contains("freight") || s.contains("logistics") || s.contains("hauler") || s.contains("cargo") { + Archetype::Distributor + } else if s.contains("arbitr") || s.contains("trading company") || s.contains("brokerage") || s.contains("intermediar") { + Archetype::Intermediary + } else if s.contains("cooperative") || s.contains("mutu") || s.contains("negociant") { + Archetype::Cooperative + } else if s.contains("whisky") || s.contains("wine") || s.contains("lager") || s.contains("precision") || s.contains("bespoke") || s.contains("longevity") { + Archetype::Specialist + } else if s.contains("infrastructure") && (s.contains("gate") || s.contains("span")) { + // Gate Corp maintains infrastructure monopoly + Archetype::Monopolist + } else { + Archetype::Producer + } + } + + /// Behavioral parameters for this archetype. + pub fn params(self) -> ArchetypeParams { + match self { + // Producer: higher output, normal trade participation + Archetype::Producer => ArchetypeParams { + production_scale: 1.15, + supply_withheld: 0.0, + price_premium: 0.0, + }, + // Distributor: leaner production, price discount to move volume + Archetype::Distributor => ArchetypeParams { + production_scale: 0.90, + supply_withheld: 0.0, + price_premium: -0.03, + }, + // Specialist: normal production, commands a premium + Archetype::Specialist => ArchetypeParams { + production_scale: 1.0, + supply_withheld: 0.0, + price_premium: 0.10, + }, + // Monopolist: constrained output, withholds supply, premium + Archetype::Monopolist => ArchetypeParams { + production_scale: 0.80, + supply_withheld: 0.25, + price_premium: 0.20, + }, + // Cooperative: normal production, slight discount for community access + Archetype::Cooperative => ArchetypeParams { + production_scale: 1.0, + supply_withheld: 0.0, + price_premium: -0.05, + }, + // Intermediary: lower own production, relies on traded goods + Archetype::Intermediary => ArchetypeParams { + production_scale: 0.70, + supply_withheld: 0.0, + price_premium: -0.01, + }, + } + } +} + +// --------------------------------------------------------------------------- +// Parameter struct +// --------------------------------------------------------------------------- + +/// Per-tick behavioral parameters for a corporation. +#[derive(Debug, Clone)] +pub struct ArchetypeParams { + /// Multiplier on BASELINE_CAPACITY for this corp's production. + pub production_scale: f64, + /// Fraction of this tick's output that is withheld from the node's + /// stockpile (Monopolist strategy). Range [0.0, 1.0]. + pub supply_withheld: f64, + /// Additive price premium on goods this corp produces. + /// Applied to the node price signal for their primary commodity. + /// Positive → price pressure up. Negative → price pressure down. + pub price_premium: f64, +} + +// --------------------------------------------------------------------------- +// Corpus load +// --------------------------------------------------------------------------- + +/// Build archetype map from the raw DB data supplied by the caller. +/// +/// `corp_data`: Vec of (corp_id, behavioral_archetype_opt, specialization_opt) +pub fn build_archetype_map( + corp_data: Vec<(String, Option, Option)>, +) -> HashMap { + corp_data + .into_iter() + .map(|(corp_id, archetype_str, specialization)| { + let archetype = archetype_str + .as_deref() + .and_then(Archetype::from_str) + .unwrap_or_else(|| { + specialization + .as_deref() + .map(Archetype::infer_from_specialization) + .unwrap_or(Archetype::Producer) + }); + (corp_id, archetype) + }) + .collect() +} diff --git a/tooling/econ-sim/src/db.rs b/tooling/econ-sim/src/db.rs index a7ef96bb9..7d3b7e89b 100644 --- a/tooling/econ-sim/src/db.rs +++ b/tooling/econ-sim/src/db.rs @@ -91,6 +91,8 @@ pub struct Economy { pub presences_by_system: HashMap>, /// Bidirectional gate links (transport graph) pub gate_links: Vec, + /// Raw corp data for archetype inference: (corp_id, behavioral_archetype?, specialization?) + pub corp_archetype_data: Vec<(String, Option, Option)>, } // --------------------------------------------------------------------------- @@ -244,6 +246,22 @@ fn load_systems(conn: &Connection) -> HashMap { .collect() } +fn load_corp_archetype_data( + conn: &Connection, +) -> Vec<(String, Option, Option)> { + let mut stmt = conn + .prepare( + "SELECT corp_id, behavioral_archetype, specialization + FROM corporations ORDER BY corp_id", + ) + .expect("prepare corp archetype data"); + + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .expect("query corp archetype data") + .filter_map(|r| r.ok()) + .collect() +} + fn load_gate_links(conn: &Connection) -> Vec { let mut stmt = conn .prepare( @@ -315,6 +333,7 @@ pub fn load_economy(conn: &Connection) -> Economy { } let gate_links = load_gate_links(conn); + let corp_archetype_data = load_corp_archetype_data(conn); Economy { commodities, @@ -325,5 +344,6 @@ pub fn load_economy(conn: &Connection) -> Economy { corp_presences, presences_by_system, gate_links, + corp_archetype_data, } } diff --git a/tooling/econ-sim/src/main.rs b/tooling/econ-sim/src/main.rs index 405e4c58c..97013c862 100644 --- a/tooling/econ-sim/src/main.rs +++ b/tooling/econ-sim/src/main.rs @@ -18,6 +18,7 @@ use std::process; use clap::Parser; +mod agents; mod currency; mod db; mod model; @@ -93,6 +94,13 @@ fn main() { let productivity = seed::seed_all_productivity(&economy, cli.seed); eprintln!(" {} corp×site productivity records seeded", productivity.len()); + // --- Behavioral archetypes --- + let archetype_map = agents::build_archetype_map(economy.corp_archetype_data.clone()); + eprintln!( + " {} corporation behavioral archetypes loaded (inferred where not set in DB)", + archetype_map.len() + ); + // --- Gate adjacency --- let adjacency = trade::build_adjacency(&economy); eprintln!( diff --git a/tooling/econ-sim/src/model.rs b/tooling/econ-sim/src/model.rs index 062e50bc8..0981680b9 100644 --- a/tooling/econ-sim/src/model.rs +++ b/tooling/econ-sim/src/model.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; +use crate::agents; use crate::currency::{CurrencyState, ShadowEconomy}; use crate::db::Economy; use crate::seed::Productivity; @@ -93,12 +94,13 @@ pub fn run( adjacency: &HashMap>, ticks: u32, ) -> Vec { + 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 { - step(economy, productivity, shadow, &mut nodes); + step(economy, productivity, shadow, &archetypes, &mut nodes); trade::trade_step(economy, &mut nodes, adjacency, &mut currency); currency.update_rate(); @@ -197,6 +199,7 @@ fn step( economy: &Economy, productivity: &HashMap<(String, String), Productivity>, shadow: &ShadowEconomy, + archetypes: &HashMap, nodes: &mut HashMap, ) { // Process each active node independently (Layer 1: no inter-system trade) @@ -235,6 +238,15 @@ fn step( 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()); + + // Effective baseline = BASELINE_CAPACITY scaled by archetype + let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale; + // Determine the tier of the primary_operation commodity let tier = economy .commodity_map @@ -244,10 +256,11 @@ fn step( if tier == "raw" { // Raw materials: direct extraction — no chain inputs required (D-177). - // Extraction rate multiplier applies. - let output = BASELINE_CAPACITY * prod.extraction_rate; + let gross_output = effective_capacity * prod.extraction_rate; + // 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 += output; + state.supply += net_output; } } else { // Intermediate / final goods: run production chain with Leontief inputs. @@ -262,7 +275,7 @@ fn step( for input in &chain.inputs { if let Some(state) = node.commodities.get(&input.commodity_id) { let available = state.stockpile; - let required = input.quantity * BASELINE_CAPACITY; + let required = input.quantity * effective_capacity; if required > 0.0 { capacity_fraction = capacity_fraction .min(available / required) @@ -276,23 +289,39 @@ fn step( // Apply productivity multiplier let prod_mult = prod.for_tier(&chain_output_tier(economy, chain)); - let actual_output = - BASELINE_CAPACITY * chain.output_quantity * capacity_fraction * prod_mult; + let gross_output = + effective_capacity * chain.output_quantity * capacity_fraction * prod_mult; + 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 * BASELINE_CAPACITY * capacity_fraction; + let consumed = input.quantity * effective_capacity * capacity_fraction; state.stockpile = (state.stockpile - consumed).max(0.0); } } - // Add output to supply + // Add net output to supply if let Some(state) = node.commodities.get_mut(&chain.output_commodity_id) { - state.supply += actual_output; + 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 ---