feat(simulation): add corporate behavioral archetypes — Layer 3 (#809)

Implements the 6 behavioral archetypes from D-175 (Burnelli-Sheldon):

  Producer     — 1.15× production scale, neutral price signal
  Distributor  — 0.9× production, −3% price discount to move volume
  Specialist   — 1.0× production, +10% price premium for expertise
  Monopolist   — 0.8× production, withholds 25% of output, +20% premium
  Cooperative  — 1.0× production, −5% community discount
  Intermediary — 0.7× production, relies on traded goods

Archetype loading:
- Reads from corporations.behavioral_archetype (currently NULL for all corps)
- Falls back to heuristic inference from specialization text (freight → Distributor,
  extraction → Producer, luxury goods → Specialist, etc.)
- 48 corps loaded on current DB, all inferred (DB column to be populated when
  wiki corp frontmatter is extended with the behavioral_archetype field)

Applied in model.rs step():
- Per-corp effective_capacity = BASELINE_CAPACITY × production_scale
- Monopolist supply_withheld fraction reduces net output to stockpile
- Price premium: small ALPHA-scaled nudge to node price for primary commodity

All D-179 stability checks still pass with archetypes active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 22:27:00 +02:00
co-authored by Claude Sonnet 4.6
parent 26716bfc56
commit b19bfb3239
4 changed files with 224 additions and 10 deletions
+157
View File
@@ -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<Self> {
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<String>, Option<String>)>,
) -> HashMap<String, Archetype> {
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()
}
+20
View File
@@ -91,6 +91,8 @@ pub struct Economy {
pub presences_by_system: HashMap<String, Vec<CorpPresence>>,
/// Bidirectional gate links (transport graph)
pub gate_links: Vec<GateLink>,
/// Raw corp data for archetype inference: (corp_id, behavioral_archetype?, specialization?)
pub corp_archetype_data: Vec<(String, Option<String>, Option<String>)>,
}
// ---------------------------------------------------------------------------
@@ -244,6 +246,22 @@ fn load_systems(conn: &Connection) -> HashMap<String, SystemInfo> {
.collect()
}
fn load_corp_archetype_data(
conn: &Connection,
) -> Vec<(String, Option<String>, Option<String>)> {
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<GateLink> {
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,
}
}
+8
View File
@@ -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!(
+39 -10
View File
@@ -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<String, Vec<String>>,
ticks: u32,
) -> 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 {
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<String, agents::Archetype>,
nodes: &mut HashMap<String, NodeState>,
) {
// 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 ---