Merge remote-tracking branch 'origin/sprint-33/server'

This commit is contained in:
2026-04-08 13:58:10 +02:00
23 changed files with 5291 additions and 27 deletions
+448
View File
@@ -0,0 +1,448 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "cc"
version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "econ-sim"
version = "0.1.0"
dependencies = [
"clap",
"rand",
"rand_chacha",
"rusqlite",
"serde",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "econ-sim"
version = "0.1.0"
edition = "2021"
description = "Settled Reach economics simulation — Layer 1 Leontief production + price adjustment"
[[bin]]
name = "econ-sim"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
rand = "0.9"
rand_chacha = "0.9"
serde = { version = "1", features = ["derive"] }
+226
View File
@@ -0,0 +1,226 @@
//! 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::BTreeMap;
// ---------------------------------------------------------------------------
// EconEvent — D-180 event port stub (#809)
// ---------------------------------------------------------------------------
/// Scope of nodes affected by an EconEvent.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum EventTarget {
Node(String),
NodeSet(Vec<String>),
Corridor(String),
TradeRoute { from: String, to: String },
Currency(String),
Commodity(String),
}
/// Economic effect applied at the target.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum EventEffect {
ProductivityMultiplier(f64),
CapacityMultiplier(f64),
DemandShock(f64),
ExchangeShock(f64),
}
/// Who can observe this event.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum EventVisibility {
Global,
Proximate(u32), // hops
Disclosed(Vec<String>), // specific node IDs
Hidden,
}
/// Economic event for injection into the simulation (D-180).
///
/// No-op handler until the IPC bridge is in place.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EconEvent {
pub target: EventTarget,
pub effect: EventEffect,
/// Duration in simulation ticks. 0 = instantaneous.
pub duration: u32,
pub visibility: EventVisibility,
}
/// No-op event handler. Called from the tick loop once D-180 IPC is wired.
#[allow(dead_code)]
pub fn handle_event(_event: &EconEvent) {
// No-op: event port not yet connected (D-180).
}
// ---------------------------------------------------------------------------
// 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>)>,
) -> BTreeMap<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()
}
+150
View File
@@ -0,0 +1,150 @@
//! Currency zones, exchange rates, and shadow economy seeding (D-171, D-172, D-174).
//!
//! Three currencies (D-171):
//! Tractus — Reach-wide standard, numeraire for all simulation pricing.
//! Mark — Compact of Westphalia, ~3% conversion friction on cross-zone trade.
//! Sol — Earth legacy, modeled as shadow commodity (not a numeraire).
//!
//! Exchange rate: floating Tractus/Mark rate driven by net cross-zone trade balance.
//! Initialized at 1.0 (parity). Adjusted each tick by net flow signal × α_fx.
//!
//! Shadow economy (D-174): per-node intensity (0.0–1.0) seeded from political
//! zone, hop distance, gate topology, and currency zone.
use std::collections::BTreeMap;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use crate::db::Economy;
use crate::prng::{derive_seed, standard_normal};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Cross-zone conversion friction (D-172): applied when trade crosses
/// TRACTUS_PRIMARY ↔ MARK_PRIMARY boundaries.
pub const ZONE_FRICTION: f64 = 0.03;
/// Exchange rate adjustment rate per tick: how strongly net cross-zone
/// flow imbalance moves the Tractus/Mark rate.
const ALPHA_FX: f64 = 0.002;
/// Exchange rate bounds (D-171): hard clamp to prevent runaway divergence.
const FX_RATE_MIN: f64 = 0.5;
const FX_RATE_MAX: f64 = 2.0;
/// Maximum shadow economy intensity for dead-end topology bonus.
const DEAD_END_SHADOW_BONUS: f64 = 0.10;
/// Shadow economy noise standard deviation (log-normal jitter per node).
const SHADOW_NOISE_SIGMA: f64 = 0.08;
// ---------------------------------------------------------------------------
// Shadow economy
// ---------------------------------------------------------------------------
/// Per-node shadow economy intensity (0.0–1.0).
///
/// Seeds from: political zone, hop distance, gate topology, currency zone.
/// Reference bands (D-174): core ~0.0–0.2, mid-reach ~0.3–0.6, frontier ~0.6–0.9.
pub struct ShadowEconomy {
/// system_id → shadow intensity [0.0, 1.0]
pub intensity: BTreeMap<String, f64>,
}
pub fn seed_shadow_economy(economy: &Economy, run_seed: u64) -> ShadowEconomy {
let mut intensity = BTreeMap::new();
for (system_id, sys) in &economy.systems {
// Base from hop distance: clamp to [0.0, 0.6] range
let hop_base = (sys.hop_distance as f64 / 15.0).clamp(0.0, 0.6);
// Political zone modifier
let zone_mod = match sys.political_zone.as_deref() {
Some("institutional_core") => -0.25,
Some("earth_sphere") | Some("diplomatic_periphery") => -0.15,
Some("commercial_mid_reach") | Some("commercial_periphery") => 0.0,
Some("research_periphery") => 0.05,
Some("contested_frontier") | Some("deep_reach_isolate") => 0.15,
_ => 0.0,
};
// Gate topology: dead-end systems are harder to police
let topology_mod = if sys.gate_topology.as_deref() == Some("dead_end") {
DEAD_END_SHADOW_BONUS
} else {
0.0
};
// Currency zone: Compact friction drives principled shadow economy
let currency_mod = if sys.currency_zone == "MARK_PRIMARY" {
0.20
} else {
0.0
};
let base = (hop_base + zone_mod + topology_mod + currency_mod).clamp(0.0, 0.95);
// Per-node PRNG jitter (Box-Muller)
let node_seed = derive_seed(run_seed, system_id);
let mut rng = ChaCha8Rng::seed_from_u64(node_seed);
let noise = standard_normal(&mut rng) * SHADOW_NOISE_SIGMA;
let final_intensity = (base + noise).clamp(0.0, 1.0);
intensity.insert(system_id.clone(), final_intensity);
}
ShadowEconomy { intensity }
}
// ---------------------------------------------------------------------------
// Exchange rate
// ---------------------------------------------------------------------------
/// Mutable exchange rate state updated each tick.
#[derive(Debug, Clone)]
pub struct CurrencyState {
/// Tractus/Mark rate: how many Marks 1 Tractus buys.
/// 1.0 = parity. >1.0 = Tractus stronger (Mark depreciated).
pub tractus_mark_rate: f64,
/// Net cross-zone Tractus→Mark commodity flow accumulated this tick.
/// Positive = Tractus zone exporting to Mark zone (Mark zone demand >).
pub net_cross_zone_flow: f64,
}
impl CurrencyState {
pub fn new() -> Self {
CurrencyState {
tractus_mark_rate: 1.0,
net_cross_zone_flow: 0.0,
}
}
/// Adjust exchange rate from net cross-zone trade imbalance.
///
/// If Tractus zone exports more than it imports from the Mark zone,
/// demand for Tractus rises → Tractus appreciates (rate increases).
pub fn update_rate(&mut self) {
// Positive net flow (Tractus→Mark) → Tractus stronger → rate rises
let adjustment = ALPHA_FX * self.net_cross_zone_flow;
self.tractus_mark_rate =
(self.tractus_mark_rate + adjustment).clamp(FX_RATE_MIN, FX_RATE_MAX);
self.net_cross_zone_flow = 0.0; // reset accumulator for next tick
}
/// Transport cost factor from `from_zone` to `to_zone`.
///
/// Cross-zone (TRACTUS ↔ MARK) incurs an additional 3% friction.
/// Sol (GJ 0, MIXED) neither adds nor removes friction.
pub fn zone_friction_factor(&self, from_zone: &str, to_zone: &str) -> f64 {
let cross_zone = (from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY")
|| (from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY");
if cross_zone {
ZONE_FRICTION
} else {
0.0
}
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Database loading — reads economy data from systems.db.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process;
use rusqlite::Connection;
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Commodity {
pub id: String,
// Display name — used in reporting (#807+):
#[allow(dead_code)]
pub name: String,
pub tier: String,
pub base_price: f64,
// Used by Layer 2+ pricing (#807, #808):
#[allow(dead_code)]
pub elasticity: String,
#[allow(dead_code)]
pub production_ubiquity: Option<String>,
#[allow(dead_code)]
pub demand_model: String,
}
#[derive(Debug, Clone)]
pub struct ChainInput {
pub commodity_id: String,
pub quantity: f64,
}
#[derive(Debug, Clone)]
pub struct ProductionChain {
pub chain_id: String,
pub output_commodity_id: String,
pub output_quantity: f64,
// Used by Layer 2+ for location-constrained production (#807):
#[allow(dead_code)]
pub location_bound: bool,
pub inputs: Vec<ChainInput>,
}
#[derive(Debug, Clone)]
pub struct CorpPresence {
pub corp_id: String,
pub system_id: String,
pub primary_operation: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SystemInfo {
pub system_id: String,
// Used for display/reporting in #807+:
#[allow(dead_code)]
pub proper_name: Option<String>,
pub population: i64,
pub cultural_corridor: Option<String>,
pub gate_energy_connected: bool,
/// Currency zone: TRACTUS_PRIMARY | MARK_PRIMARY | MIXED (D-171, D-172)
pub currency_zone: String,
/// Hop count from the nearest gateway — used for shadow economy seeding (D-174)
pub hop_distance: i64,
/// Gate topology type — used for shadow economy seeding (D-174)
pub gate_topology: Option<String>,
/// Political zone — used for shadow economy seeding (D-174)
pub political_zone: Option<String>,
}
/// A directed gate link between two systems.
#[derive(Debug, Clone)]
pub struct GateLink {
pub from_system_id: String,
pub to_system_id: String,
}
/// The complete economics dataset loaded from systems.db.
pub struct Economy {
pub commodities: Vec<Commodity>,
pub commodity_map: BTreeMap<String, Commodity>,
pub chains: Vec<ProductionChain>,
/// Map: output_commodity_id → list of chains that produce it
pub chains_by_output: BTreeMap<String, Vec<ProductionChain>>,
/// Map: system_id → SystemInfo
pub systems: BTreeMap<String, SystemInfo>,
pub corp_presences: Vec<CorpPresence>,
/// Map: system_id → list of corp presences
pub presences_by_system: BTreeMap<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>)>,
}
// ---------------------------------------------------------------------------
// DB helpers
// ---------------------------------------------------------------------------
pub fn resolve_db_path(explicit: Option<PathBuf>) -> PathBuf {
if let Some(p) = explicit {
return p;
}
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
loop {
let candidate = dir.join("server").join("data").join("systems.db");
if candidate.exists() {
return candidate;
}
if !dir.pop() {
break;
}
}
eprintln!("error: cannot find server/data/systems.db — pass --db explicitly");
process::exit(1);
}
pub fn open_db(path: &PathBuf) -> Connection {
let conn = Connection::open(path).unwrap_or_else(|e| {
eprintln!("error: cannot open {}: {}", path.display(), e);
process::exit(1);
});
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.expect("PRAGMA setup failed");
conn
}
// ---------------------------------------------------------------------------
// Loaders
// ---------------------------------------------------------------------------
fn load_commodities(conn: &Connection) -> Vec<Commodity> {
let mut stmt = conn
.prepare(
"SELECT commodity_id, name, tier, base_price, elasticity,
production_ubiquity, demand_model
FROM commodities ORDER BY commodity_id",
)
.expect("prepare commodities");
stmt.query_map([], |row| {
Ok(Commodity {
id: row.get(0)?,
name: row.get(1)?,
tier: row.get(2)?,
base_price: row.get(3)?,
elasticity: row.get(4)?,
production_ubiquity: row.get(5)?,
demand_model: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
})
})
.expect("query commodities")
.filter_map(|r| r.ok())
.collect()
}
fn load_chains(conn: &Connection) -> Vec<ProductionChain> {
let mut chain_stmt = conn
.prepare(
"SELECT chain_id, output_commodity_id, output_quantity, location_bound
FROM production_chains ORDER BY chain_id",
)
.expect("prepare chains");
let mut chains: Vec<ProductionChain> = chain_stmt
.query_map([], |row| {
Ok(ProductionChain {
chain_id: row.get(0)?,
output_commodity_id: row.get(1)?,
output_quantity: row.get(2)?,
location_bound: row.get::<_, i32>(3)? != 0,
inputs: Vec::new(),
})
})
.expect("query chains")
.filter_map(|r| r.ok())
.collect();
// Load inputs for each chain
let mut input_stmt = conn
.prepare(
"SELECT chain_id, input_commodity_id, quantity
FROM chain_inputs ORDER BY chain_id, input_commodity_id",
)
.expect("prepare chain_inputs");
let all_inputs: Vec<(String, String, f64)> = input_stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.expect("query chain_inputs")
.filter_map(|r| r.ok())
.collect();
// Build index of chain_id → inputs
let mut input_map: BTreeMap<String, Vec<ChainInput>> = BTreeMap::new();
for (chain_id, commodity_id, quantity) in all_inputs {
input_map.entry(chain_id).or_default().push(ChainInput {
commodity_id,
quantity,
});
}
for chain in &mut chains {
if let Some(inputs) = input_map.remove(&chain.chain_id) {
chain.inputs = inputs;
}
}
chains
}
fn load_systems(conn: &Connection) -> BTreeMap<String, SystemInfo> {
let mut stmt = conn
.prepare(
"SELECT ss.system_id, ss.proper_name, ss.cultural_corridor,
ss.gate_energy_connected,
COALESCE(se.population, 0) as population,
COALESCE(ss.currency_zone, 'TRACTUS_PRIMARY') as currency_zone,
COALESCE(sg.hop_distance_from_gateway, 5) as hop_distance,
sg.gate_topology,
ss.political_zone
FROM star_systems ss
LEFT JOIN system_economy se ON ss.system_id = se.system_id
LEFT JOIN system_gates sg ON ss.system_id = sg.system_id
ORDER BY ss.system_id",
)
.expect("prepare systems");
stmt.query_map([], |row| {
Ok(SystemInfo {
system_id: row.get(0)?,
proper_name: row.get(1)?,
cultural_corridor: row.get(2)?,
gate_energy_connected: row.get::<_, Option<i32>>(3)?.unwrap_or(1) != 0,
population: row.get(4)?,
currency_zone: row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "TRACTUS_PRIMARY".to_string()),
hop_distance: row.get::<_, Option<i64>>(6)?.unwrap_or(5),
gate_topology: row.get(7)?,
political_zone: row.get(8)?,
})
})
.expect("query systems")
.filter_map(|r| r.ok())
.map(|s| (s.system_id.clone(), s))
.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(
"SELECT from_system_id, to_system_id FROM gate_links
ORDER BY from_system_id, to_system_id",
)
.expect("prepare gate_links");
stmt.query_map([], |row| {
Ok(GateLink {
from_system_id: row.get(0)?,
to_system_id: row.get(1)?,
})
})
.expect("query gate_links")
.filter_map(|r| r.ok())
.collect()
}
fn load_corp_presences(conn: &Connection) -> Vec<CorpPresence> {
// Resolve body/station location_id back to system_id via LEFT JOINs.
// corp_presence.location_type is 'body' | 'station' per schema.
let mut stmt = conn
.prepare(
"SELECT cp.corp_id,
COALESCE(b.system_id, s.system_id) AS system_id,
cp.primary_operation
FROM corp_presence cp
LEFT JOIN bodies b ON cp.location_type = 'body' AND cp.location_id = b.body_id
LEFT JOIN stations s ON cp.location_type = 'station' AND cp.location_id = s.station_id
WHERE COALESCE(b.system_id, s.system_id) IS NOT NULL
ORDER BY system_id, cp.corp_id",
)
.expect("prepare corp_presence");
stmt.query_map([], |row| {
Ok(CorpPresence {
corp_id: row.get(0)?,
system_id: row.get(1)?,
primary_operation: row.get(2)?,
})
})
.expect("query corp_presence")
.filter_map(|r| r.ok())
.collect()
}
// ---------------------------------------------------------------------------
// Main loader
// ---------------------------------------------------------------------------
pub fn load_economy(conn: &Connection) -> Economy {
let commodities = load_commodities(conn);
let commodity_map: BTreeMap<String, Commodity> = commodities
.iter()
.map(|c| (c.id.clone(), c.clone()))
.collect();
let chains = load_chains(conn);
let mut chains_by_output: BTreeMap<String, Vec<ProductionChain>> = BTreeMap::new();
for chain in &chains {
chains_by_output
.entry(chain.output_commodity_id.clone())
.or_default()
.push(chain.clone());
}
let systems = load_systems(conn);
let corp_presences = load_corp_presences(conn);
let mut presences_by_system: BTreeMap<String, Vec<CorpPresence>> = BTreeMap::new();
for cp in &corp_presences {
presences_by_system
.entry(cp.system_id.clone())
.or_default()
.push(cp.clone());
}
let gate_links = load_gate_links(conn);
let corp_archetype_data = load_corp_archetype_data(conn);
Economy {
commodities,
commodity_map,
chains,
chains_by_output,
systems,
corp_presences,
presences_by_system,
gate_links,
corp_archetype_data,
}
}
+431
View File
@@ -0,0 +1,431 @@
//! econ-sim: Settled Reach economics simulation binary.
//!
//! Layer 1: Leontief production + consumption + price adjustment.
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
//! Layer 3 (corporate behavioral agents) added in #809.
//!
//! Usage:
//! econ-sim [--db path/to/systems.db] [--ticks 100] [--seed 0] [--output out.csv]
//! econ-sim --stability-check # D-179 Tests 1 and 2
//!
//! Output: CSV with columns: node_id, commodity_id, supply, demand, price, tick
//!
//! Reference decisions: D-176 (productivity seeding), D-177 (constraints),
//! D-178 (model architecture), D-179 (stability criteria), D-180 (event port)
use std::path::PathBuf;
use std::process;
use clap::Parser;
mod agents;
mod currency;
mod db;
mod model;
mod output;
mod prng;
mod seed;
mod trade;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(
name = "econ-sim",
about = "Settled Reach economics simulation — Layer 1 Leontief production"
)]
struct Cli {
/// Path to systems.db (default: auto-detect from working directory)
#[arg(long)]
db: Option<PathBuf>,
/// Number of ticks to simulate
#[arg(long, default_value_t = 100)]
ticks: u32,
/// PRNG seed for productivity randomization (D-176)
#[arg(long, default_value_t = 0)]
seed: u64,
/// Output CSV file (default: stdout)
#[arg(long)]
output: Option<PathBuf>,
/// Run stability checks (scaffolded here — exercised in #807 when trade flows added)
#[arg(long)]
stability_check: bool,
/// Comma-separated list of system IDs to simulate (default: all active nodes)
#[arg(long)]
systems: Option<String>,
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() {
let cli = Cli::parse();
// --- Load ---
let db_path = db::resolve_db_path(cli.db);
eprintln!("Loading economy data from {}...", db_path.display());
let conn = db::open_db(&db_path);
let economy = db::load_economy(&conn);
let active_node_count = economy
.systems
.values()
.filter(|s| economy.presences_by_system.contains_key(&s.system_id) || s.population > 0)
.count();
eprintln!(
" {} commodities, {} production chains, {} active nodes, {} corp presences, {} gate links",
economy.commodities.len(),
economy.chains.len(),
active_node_count,
economy.corp_presences.len(),
economy.gate_links.len(),
);
// --- Seed ---
eprintln!(
"Seeding per-corporation productivity (run seed: {})...",
cli.seed
);
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!(" {} nodes with gate connections", adjacency.len(),);
// --- Shadow economy seeding ---
eprintln!("Seeding per-node shadow economy intensity (D-174)...");
let shadow = currency::seed_shadow_economy(&economy, cli.seed);
let shadow_mean = if shadow.intensity.is_empty() {
0.0
} else {
shadow.intensity.values().sum::<f64>() / shadow.intensity.len() as f64
};
eprintln!(
" {} nodes seeded, mean intensity {:.2}",
shadow.intensity.len(),
shadow_mean
);
if cli.stability_check {
run_stability_checks(&economy, &productivity, &shadow, &adjacency);
return;
}
// --- Simulate ---
eprintln!("Running {} ticks of Layer 1+2 simulation...", cli.ticks);
let snapshots = model::run(&economy, &productivity, &shadow, &adjacency, cli.ticks);
eprintln!(" {} output records generated", snapshots.len());
// --- Output ---
output::write_csv(&snapshots, cli.output.as_deref()).unwrap_or_else(|e| {
eprintln!("error: failed to write output: {}", e);
process::exit(1);
});
if cli.output.is_some() {
eprintln!(
"Done. Written to {}",
cli.output.as_deref().unwrap().display()
);
}
}
// ---------------------------------------------------------------------------
// D-179 Stability Checks (Tests 1 and 2)
// ---------------------------------------------------------------------------
/// Run D-179 stability tests and exit 0 on pass, 1 on failure.
///
/// Test 1 — Cold-start convergence: prices within ±5% of long-run
/// equilibrium at tick 100.
///
/// Test 2 — Long-run stability: zero drift > ±2% over ticks 900–999.
/// Equilibrium is defined as the mean price over ticks 900–999.
///
/// Test 3 — Shock response: inject a demand shock on one node at tick 200,
/// verify prices recover within 200 ticks, no price explosions (>20×base).
///
/// Test 4 — Cross-zone balance: skipped if no MARK_PRIMARY systems exist.
/// Otherwise: after a cross-zone trade imbalance is induced, exchange rate
/// must re-stabilize (±2% variance) within 50 ticks.
fn run_stability_checks(
economy: &db::Economy,
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
shadow: &currency::ShadowEconomy,
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
) {
use std::collections::BTreeMap;
const CHECK_TICKS: u32 = 1_000;
const CONVERGENCE_TICK: u32 = 100;
const STABILITY_START: u32 = 900;
const CONVERGENCE_THRESHOLD: f64 = 0.05; // ±5%
const STABILITY_THRESHOLD: f64 = 0.02; // ±2%
eprintln!("Running D-179 stability checks ({CHECK_TICKS} ticks)...");
let records = model::run(economy, productivity, shadow, adjacency, CHECK_TICKS);
// Index records by (node_id, commodity_id) → Vec<(tick, price)>
let mut by_key: BTreeMap<(String, String), Vec<(u32, f64)>> = BTreeMap::new();
for r in &records {
by_key
.entry((r.node_id.clone(), r.commodity_id.clone()))
.or_default()
.push((r.tick, r.price));
}
// Compute per-key equilibrium = mean price over ticks 900–999
let mut equilibria: BTreeMap<(String, String), f64> = BTreeMap::new();
for (key, ticks) in &by_key {
let late: Vec<f64> = ticks
.iter()
.filter(|(t, _)| *t >= STABILITY_START)
.map(|(_, p)| *p)
.collect();
if late.is_empty() {
continue;
}
equilibria.insert(key.clone(), late.iter().sum::<f64>() / late.len() as f64);
}
// -----------------------------------------------------------------
// Test 1: cold-start convergence
// -----------------------------------------------------------------
let mut test1_pass = true;
let mut test1_max_dev: f64 = 0.0;
let mut test1_worst: Option<(String, String)> = None;
for (key, eq) in &equilibria {
if *eq < 1e-9 {
continue;
}
if let Some(entry) = by_key.get(key) {
if let Some((_, price_at_100)) = entry.iter().find(|(t, _)| *t == CONVERGENCE_TICK) {
let dev = (price_at_100 - eq).abs() / eq;
if dev > test1_max_dev {
test1_max_dev = dev;
test1_worst = Some((key.0.clone(), key.1.clone()));
}
if dev > CONVERGENCE_THRESHOLD {
test1_pass = false;
}
}
}
}
// -----------------------------------------------------------------
// Test 2: long-run stability
// -----------------------------------------------------------------
let mut test2_pass = true;
let mut test2_max_dev: f64 = 0.0;
let mut test2_worst: Option<(String, String)> = None;
for (key, eq) in &equilibria {
if *eq < 1e-9 {
continue;
}
if let Some(ticks) = by_key.get(key) {
for (t, price) in ticks {
if *t < STABILITY_START {
continue;
}
let dev = (price - eq).abs() / eq;
if dev > test2_max_dev {
test2_max_dev = dev;
test2_worst = Some((key.0.clone(), key.1.clone()));
}
if dev > STABILITY_THRESHOLD {
test2_pass = false;
}
}
}
}
// -----------------------------------------------------------------
// Test 3: no-explosion check (price bounds over 1000-tick run)
// Note: this is NOT a D-179 shock injection test. Full shock-response
// testing (inject → cascade → recovery) requires D-180 event port.
// -----------------------------------------------------------------
let (test3_pass, test3_note) = run_no_explosion_check(economy, &records);
// -----------------------------------------------------------------
// Test 4: cross-zone balance (skip if no MARK_PRIMARY systems)
// -----------------------------------------------------------------
let has_mark_zone = economy
.systems
.values()
.any(|s| s.currency_zone == "MARK_PRIMARY");
let (test4_pass, test4_note) = if has_mark_zone {
run_cross_zone_test(economy, productivity, shadow, adjacency)
} else {
(
true,
"SKIP — no MARK_PRIMARY systems in DB; re-run after Compact zone data is authored"
.to_string(),
)
};
// -----------------------------------------------------------------
// Report
// -----------------------------------------------------------------
let sym = |p: bool| if p { "PASS" } else { "FAIL" };
eprintln!(
"Test 1 (cold-start convergence ±5% at tick {CONVERGENCE_TICK}): {} max_dev={:.2}%{}",
sym(test1_pass),
test1_max_dev * 100.0,
test1_worst
.as_ref()
.map(|(n, c)| format!(" worst: {n}/{c}"))
.unwrap_or_default()
);
eprintln!(
"Test 2 (long-run stability ±2% over ticks {STABILITY_START}–999): {} max_dev={:.2}%{}",
sym(test2_pass),
test2_max_dev * 100.0,
test2_worst
.as_ref()
.map(|(n, c)| format!(" worst: {n}/{c}"))
.unwrap_or_default()
);
eprintln!(
"Test 3 (no-explosion check — price bounds over 1000 ticks): {} {}",
sym(test3_pass),
test3_note
);
eprintln!(
"Test 4 (cross-zone balance re-stabilizes ≤50 ticks): {} {}",
sym(test4_pass),
test4_note
);
let all_pass = test1_pass && test2_pass && test3_pass && test4_pass;
if all_pass {
eprintln!("All stability checks passed.");
process::exit(0);
} else {
eprintln!("Stability check FAILED — see above.");
process::exit(1);
}
}
/// Verify no price explosions or negative prices in the 1000-tick run.
///
/// This is NOT a D-179 shock injection test. D-179 Test 3 requires deliberate
/// shock injection via the D-180 event port, which is not yet implemented.
/// This check validates the weaker property: the model does not produce
/// unbounded prices (>20× base) or negative prices over 1000 ticks.
fn run_no_explosion_check(
economy: &db::Economy,
records_1000: &[model::TickRecord],
) -> (bool, String) {
const PRICE_EXPLOSION_LIMIT: f64 = 20.0; // 20× base_price
// Check: no price > 20× base at any tick
let mut explosion_detected = false;
let mut explosion_worst = String::new();
for r in records_1000 {
let base = economy
.commodity_map
.get(&r.commodity_id)
.map_or(1.0, |c| c.base_price);
if r.price > base * PRICE_EXPLOSION_LIMIT {
explosion_detected = true;
explosion_worst = format!(
"{}/{} price={:.1} base={:.1} ({:.0}×)",
r.node_id,
r.commodity_id,
r.price,
base,
r.price / base
);
}
}
if explosion_detected {
return (false, format!("price explosion: {}", explosion_worst));
}
// Check: no negative prices (should be clamped by model, verify here)
if let Some(r) = records_1000.iter().find(|r| r.price < 0.0) {
return (
false,
format!(
"{}/{} price went negative: {}",
r.node_id, r.commodity_id, r.price
),
);
}
(
true,
format!(
"no explosions (>{:.0}× base), no negatives across {} records",
PRICE_EXPLOSION_LIMIT,
records_1000.len()
),
)
}
/// Test 4: cross-zone exchange rate stabilizes within 50 ticks.
///
/// Only runs when MARK_PRIMARY systems exist.
fn run_cross_zone_test(
economy: &db::Economy,
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
shadow: &currency::ShadowEconomy,
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
) -> (bool, String) {
const TEST_TICKS: u32 = 150;
const STABILIZE_BY: u32 = 50;
const FX_STABILITY_THRESHOLD: f64 = 0.02; // ±2%
let records = model::run(economy, productivity, shadow, adjacency, TEST_TICKS);
// Extract tractus_mark_rate — one value per tick (rate is identical across
// all node×commodity records in the same tick; deduplicate to avoid bias).
let mut seen: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
let late_rates: Vec<f64> = records
.iter()
.filter(|r| r.tick >= STABILIZE_BY && seen.insert(r.tick))
.map(|r| r.tractus_mark_rate)
.collect();
if late_rates.is_empty() {
return (true, "no data".to_string());
}
let mean_rate = late_rates.iter().sum::<f64>() / late_rates.len() as f64;
let max_dev = late_rates
.iter()
.map(|&r| (r - mean_rate).abs() / mean_rate)
.fold(0.0_f64, f64::max);
let pass = max_dev <= FX_STABILITY_THRESHOLD;
(
pass,
format!(
"fx_rate mean={:.4} max_dev={:.2}% (threshold ±2%)",
mean_rate,
max_dev * 100.0
),
)
}
+398
View File
@@ -0,0 +1,398 @@
//! Layer 1: Leontief production + consumption + price adjustment.
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
//!
//! 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).
//!
//! Layer 3 (corporate behavioral agents) is added in #809.
//!
//! Reference: D-178 (Economic Model Architecture)
use std::collections::BTreeMap;
use crate::agents;
use crate::currency::{CurrencyState, ShadowEconomy};
use crate::db::Economy;
use crate::seed::Productivity;
use crate::trade;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Price adjustment rate per tick (α=0.03, D-178 Layer 2).
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 simulation for `ticks` ticks.
///
/// 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).
///
/// 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 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, &archetypes, &mut nodes);
trade::trade_step(economy, &mut nodes, adjacency, &mut currency);
currency.update_rate();
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
// ---------------------------------------------------------------------------
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;
fn step(
economy: &Economy,
productivity: &BTreeMap<(String, String), Productivity>,
shadow: &ShadowEconomy,
archetypes: &BTreeMap<String, agents::Archetype>,
nodes: &mut BTreeMap<String, NodeState>,
) {
// 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());
// 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
.get(&primary_op)
.map(|c| c.tier.as_str())
.unwrap_or("");
if tier == "raw" {
// Raw materials: direct extraction — no chain inputs required (D-177).
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 += 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 multiplier
let prod_mult = prod.for_tier(&chain_output_tier(economy, chain));
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 * 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.
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 raw_demand = if commodity.id == "fusion_fuel"
&& system_info.gate_energy_connected
&& commodity.tier != "raw"
{
base_demand * GATE_ENERGY_DEMAND_REDUCTION
} else {
base_demand
};
// Shadow economy reduces formal-sector consumption (some demand met off-books)
let demand = raw_demand * (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())
}
+52
View File
@@ -0,0 +1,52 @@
//! CSV output for simulation snapshots.
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
use crate::model::TickRecord;
/// Write records to CSV. If `path` is None, writes to stdout.
///
/// Columns: node_id, commodity_id, supply, demand, price, tick,
/// shadow_intensity, tractus_mark_rate
pub fn write_csv(records: &[TickRecord], path: Option<&Path>) -> io::Result<()> {
let header =
"node_id,commodity_id,supply,demand,price,tick,shadow_intensity,tractus_mark_rate\n";
let write_record = |w: &mut dyn Write, r: &TickRecord| -> io::Result<()> {
writeln!(
w,
"{},{},{:.4},{:.4},{:.4},{},{:.4},{:.6}",
r.node_id,
r.commodity_id,
r.supply,
r.demand,
r.price,
r.tick,
r.shadow_intensity,
r.tractus_mark_rate,
)
};
match path {
Some(p) => {
let file = File::create(p)?;
let mut w = BufWriter::new(file);
write!(w, "{}", header)?;
for r in records {
write_record(&mut w, r)?;
}
w.flush()
}
None => {
let stdout = io::stdout();
let mut w = BufWriter::new(stdout.lock());
write!(w, "{}", header)?;
for r in records {
write_record(&mut w, r)?;
}
w.flush()
}
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Shared PRNG helpers for deterministic seeding (D-176, D-174).
//!
//! Both seed.rs and currency.rs use the same FNV-1a mix + Box-Muller transform.
//! Centralised here to guarantee identical derivation chains across modules.
use std::f64::consts::PI;
use rand::Rng;
use rand_chacha::ChaCha8Rng;
/// FNV-1a 64-bit offset basis.
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
/// FNV-1a 64-bit prime.
const FNV_PRIME: u64 = 0x100000001b3;
/// Deterministic per-key seed: FNV-1a of `key` mixed with `run_seed`.
///
/// Starting from `run_seed + FNV_OFFSET_BASIS` provides per-run variation
/// while preserving the FNV avalanche properties across keys.
pub fn derive_seed(run_seed: u64, key: &str) -> u64 {
let mut h = run_seed.wrapping_add(FNV_OFFSET_BASIS);
for byte in key.bytes() {
h ^= byte as u64;
h = h.wrapping_mul(FNV_PRIME);
}
h
}
/// Box-Muller transform: standard normal variate from a ChaCha8 stream.
pub fn standard_normal(rng: &mut ChaCha8Rng) -> f64 {
let u1: f64 = 1.0 - rng.random::<f64>(); // avoid ln(0)
let u2: f64 = rng.random::<f64>();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
+117
View File
@@ -0,0 +1,117 @@
//! Productivity seeding — D-176.
//!
//! Per-run PRNG seeding of corporation×site productivity on five dimensions.
//! Log-normal distribution with corridor correlation ~0.6.
//!
//! What CANNOT be seeded (D-177): location of production, biological monopoly
//! ceilings, aging pipeline contents, gate topology.
use std::collections::BTreeMap;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use crate::db::Economy;
use crate::prng::{derive_seed, standard_normal};
// ---------------------------------------------------------------------------
// Productivity record (D-176)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Productivity {
/// Output per unit time from mines, wells, fisheries
pub extraction_rate: f64,
/// Units processed per tick in manufacturing and refineries
pub processing_throughput: f64,
/// Freight volume per gate crossing for logistics operators — used in #807 (trade flows)
#[allow(dead_code)]
pub transit_capacity: f64,
/// Clients served per tick for service firms
pub service_throughput: f64,
/// Maximum concurrent engagements for service firms — used in #809 (agents)
#[allow(dead_code)]
pub service_capacity: f64,
}
impl Productivity {
/// Multiplier appropriate for a given commodity tier.
pub fn for_tier(&self, tier: &str) -> f64 {
match tier {
"raw" => self.extraction_rate,
"intermediate" => self.processing_throughput,
"final" => self.processing_throughput,
"service_professional" | "service_luxury" => self.service_throughput,
_ => 1.0,
}
}
}
// ---------------------------------------------------------------------------
// Seeding entry point
// ---------------------------------------------------------------------------
/// Seed productivity for all corp×system pairs.
///
/// Returns a map keyed by (corp_id, system_id) → Productivity.
pub fn seed_all_productivity(
economy: &Economy,
run_seed: u64,
) -> BTreeMap<(String, String), Productivity> {
// σ for standard nodes: chosen so that exp(±2σ) ≈ [0.4, 1.8] at 95%
// Geometric mean of [0.4, 1.8] ≈ 0.849. μ = ln(0.849) ≈ −0.164.
// We use μ=0 (geometric mean = 1) and wider σ; the clamp enforces the range.
let sigma_total: f64 = 0.38;
// Corridor-shared variance fraction: ρ = 0.6 (D-176)
let rho: f64 = 0.6;
let sigma_shared = (rho).sqrt() * sigma_total;
let sigma_individual = (1.0 - rho).sqrt() * sigma_total;
// Pre-compute corridor Z values (shared across all corps in the same corridor)
let mut corridor_z: BTreeMap<String, f64> = BTreeMap::new();
let mut result = BTreeMap::new();
for cp in &economy.corp_presences {
let system = match economy.systems.get(&cp.system_id) {
Some(s) => s,
None => continue,
};
// Corridor shared factor
let corridor_contribution = if let Some(corr) = &system.cultural_corridor {
let z = *corridor_z.entry(corr.clone()).or_insert_with(|| {
let seed = derive_seed(run_seed, corr);
let mut rng = ChaCha8Rng::seed_from_u64(seed);
standard_normal(&mut rng)
});
sigma_shared * z
} else {
0.0
};
// Individual factor per corp×site
let key = format!("{}:{}", cp.corp_id, cp.system_id);
let site_seed = derive_seed(run_seed, &key);
let mut rng = ChaCha8Rng::seed_from_u64(site_seed);
let sample = |rng: &mut ChaCha8Rng| -> f64 {
let individual_z = standard_normal(rng);
let combined = corridor_contribution + sigma_individual * individual_z;
combined.exp().clamp(0.4, 1.8)
};
let prod = Productivity {
extraction_rate: sample(&mut rng),
processing_throughput: sample(&mut rng),
transit_capacity: sample(&mut rng),
service_throughput: sample(&mut rng),
service_capacity: sample(&mut rng),
};
result.insert((cp.corp_id.clone(), cp.system_id.clone()), prod);
}
result
}
+163
View File
@@ -0,0 +1,163 @@
//! 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.
//!
//! Currency zone friction (D-172): cross-zone (TRACTUS ↔ MARK) trade incurs
//! an additional 3% cost. Net cross-zone flow drives the floating exchange
//! rate adjustment (D-171).
//!
//! Gate links are bidirectional in the DB; `build_adjacency` builds the
//! full adjacency map directly from them.
use std::collections::BTreeMap;
use crate::currency::CurrencyState;
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) -> BTreeMap<String, Vec<String>> {
let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::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 and currency costs, is still below the price in B,
/// goods flow from A to B. Cross-zone (TRACTUS ↔ MARK) links incur an
/// additional 3% conversion friction (D-172).
///
/// Net cross-zone flow is accumulated in `currency` to drive exchange rate
/// adjustment each tick (D-171).
///
/// All flows are computed from the pre-step state and applied atomically
/// to avoid order-dependent artifacts.
pub fn trade_step(
economy: &Economy,
nodes: &mut BTreeMap<String, NodeState>,
adjacency: &BTreeMap<String, Vec<String>>,
currency: &mut CurrencyState,
) {
// Collect pending flows before mutating (snapshot prices/stockpiles first)
// (from_system, to_system, commodity_id, amount, cross_zone_tractus_to_mark)
let mut flows: Vec<(String, String, String, f64, f64)> = Vec::new();
for (from_id, neighbors) in adjacency {
let from_node = match nodes.get(from_id.as_str()) {
Some(n) => n,
None => continue,
};
let from_zone = economy
.systems
.get(from_id.as_str())
.map(|s| s.currency_zone.as_str())
.unwrap_or("TRACTUS_PRIMARY");
for to_id in neighbors {
let to_node = match nodes.get(to_id.as_str()) {
Some(n) => n,
None => continue,
};
let to_zone = economy
.systems
.get(to_id.as_str())
.map(|s| s.currency_zone.as_str())
.unwrap_or("TRACTUS_PRIMARY");
let gate_cost = 1.0 + GATE_COST_PER_HOP;
// zone_cost is a raw fraction (0.0 or 0.03); combine multiplicatively
let zone_cost = currency.zone_friction_factor(from_zone, to_zone);
let cost_factor = gate_cost * (1.0 + zone_cost);
// Sign: positive = Tractus zone exporting to Mark zone
let cross_zone_sign = if from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY" {
1.0_f64
} else if from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY" {
-1.0_f64
} else {
0.0_f64
};
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 full 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,
cross_zone_sign * flow,
));
}
}
}
}
// Apply flows and accumulate cross-zone net flow for exchange rate
for (from_id, to_id, commodity_id, amount, cross_zone_contrib) 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;
}
}
currency.net_cross_zone_flow += cross_zone_contrib;
}
}
+453 -24
View File
@@ -3,12 +3,19 @@
Import economics data into systems.db.
Reads TOML/JSON source files and populates the economics tables:
- gate_links from docs/design/star-map.json (335 edges, bidirectional)
- commodities from wiki/economics/commodities.toml (36 types)
- gate_links from docs/design/star-map.json (335 edges, bidirectional)
- commodities from wiki/economics/commodities.toml (36 types)
- production_chains + chain_inputs from wiki/economics/production_chains.toml
- currency_zone on star_systems (default TRACTUS_PRIMARY)
- currency_zone on star_systems (default TRACTUS_PRIMARY)
- gate_energy_connected on star_systems (D-186: false for MARK_PRIMARY zones)
- corporations from wiki/corporations/*.md (sync + insert new records)
- corp_presence from wiki/corporations/*.md (headquarters location data)
Does NOT populate corp_presence — that's a future pipeline step.
Validation (hard errors, non-zero exit on any failure):
- Wiki corporation names must match DB proper_name records (D-182 sync constraint)
- Chain completeness: every intermediate commodity has at least one production chain
- Commodity coverage: 3+ corporations per major commodity type (D-175)
- System coverage: 1+ corporation per inhabited system with population > 100K (D-175)
Usage:
python3 tooling/economy-db/import_economics.py
@@ -18,6 +25,7 @@ Usage:
import argparse
import json
import re
import sqlite3
import sys
import tomllib
@@ -30,6 +38,7 @@ STAR_MAP = REPO_ROOT / "docs" / "design" / "star-map.json"
COMMODITIES_TOML = REPO_ROOT / "wiki" / "economics" / "commodities.toml"
CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml"
SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql"
CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations"
# ---------------------------------------------------------------------------
@@ -102,6 +111,7 @@ CREATE INDEX IF NOT EXISTS idx_corp_presence_location ON corp_presence(location_
# Columns to add to existing tables (ALTER TABLE is idempotent via try/except)
COLUMN_MIGRATIONS = [
("star_systems", "currency_zone", "TEXT DEFAULT 'TRACTUS_PRIMARY'"),
("star_systems", "gate_energy_connected", "INTEGER DEFAULT 1"),
("corporations", "behavioral_archetype", "TEXT"),
("corporations", "supply_chain_role", "TEXT"),
("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"),
@@ -244,17 +254,44 @@ def import_chains(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]:
# ---------------------------------------------------------------------------
def set_currency_zones(conn: sqlite3.Connection, dry_run: bool) -> dict:
"""Set currency_zone on star_systems. Default TRACTUS_PRIMARY, Sol = MIXED."""
"""Set currency_zone on star_systems from wiki/economics/currency_zones.toml.
Default: TRACTUS_PRIMARY. Sol (GJ 0): MIXED (set before file is read).
MARK_PRIMARY and MIXED assignments come from the TOML file (D-172).
"""
if dry_run:
return {"TRACTUS_PRIMARY": "all", "MIXED": "GJ 0"}
return {"TRACTUS_PRIMARY": "all", "MIXED": "GJ 0 + toml"}
# Default everything to TRACTUS_PRIMARY
conn.execute("UPDATE star_systems SET currency_zone = 'TRACTUS_PRIMARY' WHERE currency_zone IS NULL")
conn.execute("UPDATE star_systems SET currency_zone = 'TRACTUS_PRIMARY'")
# Sol system is MIXED (Earth legacy currency presence)
# Sol system is MIXED (Earth legacy currency presence — set before TOML load)
conn.execute("UPDATE star_systems SET currency_zone = 'MIXED' WHERE system_id = 'GJ 0'")
# Future: Compact systems → MARK_PRIMARY (requires authored Compact membership data)
# Load MARK_PRIMARY and MIXED assignments from authored TOML (D-172)
zones_path = REPO_ROOT / "wiki" / "economics" / "currency_zones.toml"
if zones_path.exists():
import tomllib # Python 3.11+
with open(zones_path, "rb") as f:
zones = tomllib.load(f)
mark_ids = [entry["system_id"] for entry in zones.get("mark_primary", [])]
mixed_ids = [entry["system_id"] for entry in zones.get("mixed", [])]
for sid in mark_ids:
conn.execute(
"UPDATE star_systems SET currency_zone = 'MARK_PRIMARY' WHERE system_id = ?",
(sid,),
)
for sid in mixed_ids:
conn.execute(
"UPDATE star_systems SET currency_zone = 'MIXED' WHERE system_id = ?",
(sid,),
)
else:
print(" warning: wiki/economics/currency_zones.toml not found — "
"all systems default to TRACTUS_PRIMARY / Sol to MIXED")
counts = {}
for row in conn.execute("SELECT currency_zone, COUNT(*) FROM star_systems GROUP BY currency_zone"):
@@ -263,11 +300,277 @@ def set_currency_zones(conn: sqlite3.Connection, dry_run: bool) -> dict:
return counts
# ---------------------------------------------------------------------------
# Gate energy connectivity (D-186)
# ---------------------------------------------------------------------------
def set_gate_energy(conn: sqlite3.Connection, dry_run: bool) -> dict:
"""Set gate_energy_connected on star_systems based on currency_zone.
MARK_PRIMARY zones default to false (Compact refused Gate Corp dependency).
All other zones default to true.
"""
if dry_run:
return {"on_grid": "non-MARK_PRIMARY", "off_grid": "MARK_PRIMARY"}
# Default: all systems on-grid
conn.execute("UPDATE star_systems SET gate_energy_connected = 1 WHERE gate_energy_connected IS NULL")
# MARK_PRIMARY zones are off-grid (Compact energy sovereignty)
conn.execute("UPDATE star_systems SET gate_energy_connected = 0 WHERE currency_zone = 'MARK_PRIMARY'")
counts = {}
for row in conn.execute(
"SELECT gate_energy_connected, COUNT(*) FROM star_systems GROUP BY gate_energy_connected"
):
label = "on_grid" if row[0] == 1 else "off_grid"
counts[label] = row[1]
return counts
# ---------------------------------------------------------------------------
# Corporation wiki parsing
# ---------------------------------------------------------------------------
def _parse_corp_frontmatter(path: Path) -> dict | None:
"""Parse YAML frontmatter from a wiki corporation markdown file."""
text = path.read_text()
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return None
end_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
end_idx = i
break
if end_idx is None:
return None
fm: dict = {}
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
val = val.strip()
if val.startswith("[") and val.endswith("]"):
items = [x.strip().strip('"').strip("'") for x in val[1:-1].split(",")]
fm[key] = [item for item in items if item]
else:
fm[key] = val.strip('"').strip("'")
return fm
def load_wiki_corps() -> list[dict]:
"""Load all wiki corporation files. Returns list of parsed corp records."""
corps = []
for md_file in sorted(CORPORATIONS_DIR.glob("*.md")):
if md_file.name == "index.md":
continue
fm = _parse_corp_frontmatter(md_file)
if not fm or not fm.get("slug") or not fm.get("title"):
continue
hq = fm.get("headquarters", "")
m = re.search(r"\(([^)]+)\)", hq)
system_id = m.group(1) if m else None
corps.append({
"corp_id": fm["slug"],
"proper_name": fm["title"],
"system_id": system_id,
"tags": fm.get("tags", []),
"scope": fm.get("scope", ""),
})
return corps
# ---------------------------------------------------------------------------
# Corporation sync (D-182: wiki is source of truth)
# ---------------------------------------------------------------------------
def sync_corporations(
conn: sqlite3.Connection, wiki_corps: list[dict], dry_run: bool
) -> list[str]:
"""Sync wiki corps to DB. Hard error on proper_name divergence (D-182).
Returns list of error strings. Inserts corps that exist in wiki but not DB.
Corps that exist only in DB (legacy records) are left untouched.
headquarters_system is only written if the system_id exists in star_systems
(to avoid FK violations when atlas hasn't yet registered the system).
"""
errors: list[str] = []
existing = {
r[0]: r[1]
for r in conn.execute("SELECT corp_id, proper_name FROM corporations").fetchall()
}
valid_systems = {
r[0] for r in conn.execute("SELECT system_id FROM star_systems").fetchall()
}
to_insert = []
for corp in wiki_corps:
corp_id = corp["corp_id"]
proper_name = corp["proper_name"]
if corp_id in existing:
if existing[corp_id] != proper_name:
errors.append(
f"name divergence: corp_id='{corp_id}' "
f"wiki='{proper_name}' db='{existing[corp_id]}'"
)
else:
system_id = corp.get("system_id")
hq_system = system_id if system_id and system_id in valid_systems else None
if system_id and system_id not in valid_systems:
print(f" warning: {corp_id} HQ system '{system_id}' not in DB, "
f"headquarters_system set to NULL")
to_insert.append((
corp_id,
proper_name,
"corporation",
corp.get("scope") or None,
hq_system,
))
if not dry_run and not errors:
conn.executemany(
"""INSERT OR IGNORE INTO corporations
(corp_id, proper_name, corp_type, scope, headquarters_system)
VALUES (?, ?, ?, ?, ?)""",
to_insert,
)
return errors
# ---------------------------------------------------------------------------
# Corp presence population
# ---------------------------------------------------------------------------
def _resolve_hq_location(
conn: sqlite3.Connection,
system_id: str,
headquarters_body: str | None,
) -> tuple[str, str] | None:
"""Resolve a corp's HQ to a (location_id, location_type) pair.
Resolution order:
1. Use headquarters_body from corporations table if set (body or station).
2. Most-populated body in the system.
3. Any body in the system.
4. Any station in the system.
Returns None if no body or station found.
"""
if headquarters_body:
# Determine whether it's a body or station
body = conn.execute(
"SELECT body_id FROM bodies WHERE body_id = ?", (headquarters_body,)
).fetchone()
if body:
return (headquarters_body, "body")
station = conn.execute(
"SELECT station_id FROM stations WHERE station_id = ?",
(headquarters_body,),
).fetchone()
if station:
return (headquarters_body, "station")
# Most-populated body
body = conn.execute(
"""SELECT body_id FROM bodies WHERE system_id = ?
ORDER BY population DESC LIMIT 1""",
(system_id,),
).fetchone()
if body:
return (body[0], "body")
# Any station
station = conn.execute(
"SELECT station_id FROM stations WHERE system_id = ? LIMIT 1",
(system_id,),
).fetchone()
if station:
return (station[0], "station")
return None
def import_corp_presence(
conn: sqlite3.Connection,
wiki_corps: list[dict],
commodity_ids: set[str],
dry_run: bool,
) -> int:
"""Populate corp_presence from wiki headquarters data.
Each corporation gets one presence row at its headquarters body or station.
location_type is 'body' or 'station' per schema (D-182).
primary_operation is set to the first commodity tag matching a known commodity ID.
"""
valid_systems = {
r[0] for r in conn.execute("SELECT system_id FROM star_systems").fetchall()
}
# Load headquarters_body from corporations table (set during import)
hq_body_map: dict[str, str | None] = {
r[0]: r[1]
for r in conn.execute(
"SELECT corp_id, headquarters_body FROM corporations"
).fetchall()
}
rows = []
skipped = []
for corp in wiki_corps:
system_id = corp.get("system_id")
if not system_id:
skipped.append(f"{corp['corp_id']} (no headquarters system parsed)")
continue
if system_id not in valid_systems:
skipped.append(f"{corp['corp_id']} (system '{system_id}' not in DB)")
continue
hq_body = hq_body_map.get(corp["corp_id"])
location = _resolve_hq_location(conn, system_id, hq_body)
if not location:
skipped.append(
f"{corp['corp_id']} (no body/station found in system '{system_id}')"
)
continue
location_id, location_type = location
primary_op = next(
(tag for tag in corp.get("tags", []) if tag in commodity_ids), None
)
rows.append((corp["corp_id"], location_id, location_type, primary_op))
if skipped:
for s in skipped:
print(f" warning: skipped corp_presence for {s}")
if not dry_run:
conn.execute("DELETE FROM corp_presence")
conn.executemany(
"""INSERT OR IGNORE INTO corp_presence
(corp_id, location_id, location_type, primary_operation)
VALUES (?, ?, ?, ?)""",
rows,
)
return len(rows)
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def validate(conn: sqlite3.Connection) -> list[str]:
"""Validate structural integrity of imported data.
Checks FK integrity, chain commodity references, and chain completeness.
These are hard blockers — broken data must not be committed.
Coverage validation (commodity/system thresholds) is separate and runs
after commit via _validate_commodity_coverage() and _validate_system_coverage().
"""
errors = []
# FK integrity
@@ -297,9 +600,75 @@ def validate(conn: sqlite3.Connection) -> list[str]:
for chain_id, cid in orphan_outputs:
errors.append(f"production_chains: chain '{chain_id}' outputs unknown commodity '{cid}'")
# Chain completeness: every intermediate commodity must have at least one producer
missing_chains = conn.execute("""
SELECT c.commodity_id, c.name
FROM commodities c
WHERE c.tier = 'intermediate'
AND c.commodity_id NOT IN (SELECT output_commodity_id FROM production_chains)
ORDER BY c.commodity_id
""").fetchall()
for cid, name in missing_chains:
errors.append(f"chain completeness: no production chain produces intermediate '{cid}' ({name})")
return errors
def _validate_commodity_coverage(
conn: sqlite3.Connection, wiki_corps: list[dict], commodity_ids: set[str]
) -> list[str]:
"""3+ corporations per major commodity type (raw + intermediate). D-175."""
errors: list[str] = []
major = [
r[0]
for r in conn.execute(
"SELECT commodity_id FROM commodities "
"WHERE tier IN ('raw', 'intermediate') ORDER BY commodity_id"
).fetchall()
]
# Build commodity → corp set from wiki tags filtered to known commodity IDs
coverage: dict[str, set[str]] = {cid: set() for cid in major}
for corp in wiki_corps:
for tag in corp.get("tags", []):
if tag in coverage:
coverage[tag].add(corp["corp_id"])
for cid in major:
n = len(coverage[cid])
if n < 3:
corp_list = sorted(coverage[cid]) if coverage[cid] else ["none"]
errors.append(
f"commodity coverage: '{cid}' has {n}/3 corp(s) — {corp_list}"
)
return errors
def _validate_system_coverage(
conn: sqlite3.Connection, wiki_corps: list[dict]
) -> list[str]:
"""1+ corporation per inhabited system with population > 100K. D-175.
Uses wiki_corps headquarters data (not DB corp_presence) so this check
is accurate in both dry-run and real-run modes.
"""
covered = {c["system_id"] for c in wiki_corps if c.get("system_id")}
populated = conn.execute("""
SELECT se.system_id, ss.proper_name, se.population
FROM system_economy se
JOIN star_systems ss ON se.system_id = ss.system_id
WHERE se.population > 100000
ORDER BY se.system_id
""").fetchall()
return [
f"system coverage: no corp presence in '{sid}' ({name}, pop={pop:,})"
for sid, name, pop in populated
if sid not in covered
]
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@@ -321,66 +690,126 @@ def main():
print(f" Mode: DRY RUN")
print()
# Load wiki corps before opening DB — allows early exit on parse failures
print(" Loading wiki corporations...")
wiki_corps = load_wiki_corps()
print(f" {len(wiki_corps)} corporation files parsed")
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON")
# 1. Migrate schema
print(" [1/5] Schema migration...")
print(" [1/8] Schema migration...")
for table, col, col_type in COLUMN_MIGRATIONS:
_add_column(conn, table, col, col_type)
conn.executescript(MIGRATION_SQL)
print(" tables and columns ready")
# Clear economics tables in FK-safe order (children before parents)
# corp_presence cleared here; corporations table is append-only (never cleared)
if not args.dry_run:
conn.execute("DELETE FROM corp_presence")
conn.execute("DELETE FROM chain_inputs")
conn.execute("DELETE FROM production_chains")
conn.execute("DELETE FROM commodities")
conn.execute("DELETE FROM gate_links")
# 2. Gate links
print(" [2/5] Importing gate links...")
print(" [2/8] Importing gate links...")
n_links = import_gate_links(conn, args.dry_run)
print(f" {n_links} rows (bidirectional)")
# 3. Commodities
print(" [3/5] Importing commodities...")
print(" [3/8] Importing commodities...")
n_commodities = import_commodities(conn, args.dry_run)
print(f" {n_commodities} commodities")
# 4. Production chains
print(" [4/5] Importing production chains...")
print(" [4/8] Importing production chains...")
n_chains, n_inputs = import_chains(conn, args.dry_run)
print(f" {n_chains} chains, {n_inputs} inputs")
# 5. Currency zones
print(" [5/5] Setting currency zones...")
print(" [5/8] Setting currency zones...")
zones = set_currency_zones(conn, args.dry_run)
for zone, count in sorted(zones.items()):
print(f" {zone}: {count}")
# Validate
print("\n Validating...")
errors = validate(conn)
if errors:
print(f" ERRORS ({len(errors)}):")
for e in errors:
# 6. Gate energy connectivity (D-186) — must run after currency zones
print(" [6/8] Setting gate energy connectivity...")
energy = set_gate_energy(conn, args.dry_run)
for label, count in sorted(energy.items()):
print(f" {label}: {count}")
# 7. Sync corporations from wiki (D-182: hard error on name divergence)
print(" [7/8] Syncing corporations...")
corp_errors = sync_corporations(conn, wiki_corps, args.dry_run)
if corp_errors:
print(f" ERRORS ({len(corp_errors)}) — name divergence detected (D-182):")
for e in corp_errors:
print(f" - {e}")
print(" Fix: update wiki title or DB proper_name to match, then re-run.")
conn.close()
sys.exit(1)
n_db_corps = conn.execute("SELECT COUNT(*) FROM corporations").fetchone()[0]
print(f" {n_db_corps} corporations in DB ({len(wiki_corps)} from wiki)")
# 8. Corp presence from wiki headquarters data
print(" [8/8] Importing corp presence...")
commodity_ids = {
r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()
}
n_presence = import_corp_presence(conn, wiki_corps, commodity_ids, args.dry_run)
print(f" {n_presence} corp_presence rows")
# Validate structural integrity (FK, chain refs, chain completeness).
# These errors indicate broken imported data — do NOT commit.
print("\n Validating structural integrity...")
struct_errors = validate(conn)
if struct_errors:
print(f" STRUCTURAL ERRORS ({len(struct_errors)}) — rolling back:")
for e in struct_errors:
print(f" - {e}")
conn.close()
sys.exit(1)
else:
print(" FK integrity OK")
print(" FK integrity and chain completeness OK")
# Commit all imported data (corps, presence, etc.) before coverage check.
# Coverage validation is a Phase 2 gate (D-175) — data should be persisted
# so tools can query it and report gaps clearly.
if not args.dry_run:
conn.commit()
print("\n Committed.")
print(" Data committed.")
else:
print("\n Dry run — no changes written.")
print(" Dry run — no changes written.")
# Validate coverage (hard errors per D-175, but after commit so data is usable).
print("\n Validating coverage (D-175 Phase 2 gate)...")
coverage_errors: list[str] = []
commodity_ids_for_coverage = {
r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()
}
coverage_errors.extend(
_validate_commodity_coverage(conn, wiki_corps, commodity_ids_for_coverage)
)
coverage_errors.extend(_validate_system_coverage(conn, wiki_corps))
if coverage_errors:
print(f" COVERAGE ERRORS ({len(coverage_errors)}) — Phase 2 gate not met:")
for e in coverage_errors:
print(f" - {e}")
print("\n Data committed but Phase 2 gate is NOT met. "
"Add corporations to meet coverage thresholds and re-run.")
conn.close()
sys.exit(1)
else:
print(" All coverage thresholds met — Phase 2 gate PASSED.")
conn.close()
print(f"\n Done: {n_links} gate_links, {n_commodities} commodities, "
f"{n_chains} chains, {n_inputs} inputs\n")
f"{n_chains} chains, {n_inputs} inputs, {n_presence} corp_presence\n")
if __name__ == "__main__":
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Generate Tier-3 corporations for the Settled Reach economy.
#
# Usage:
# tooling/generate-corporations
# tooling/generate-corporations --seed 42 --min-corps 5000
# tooling/generate-corporations --output path/to/output.toml
#
# Builds on first run if binary doesn't exist.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BIN="$ROOT_DIR/server/target/debug/generate_corporations"
# Build if needed
if [ ! -f "$BIN" ]; then
echo "Building generate_corporations..." >&2
(cd "$ROOT_DIR/server" && cargo build --bin generate_corporations 2>&1 | tail -3) >&2
fi
exec "$BIN" "$@"