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
+3
View File
@@ -5,6 +5,8 @@
server/settings.db
server/settings.db-shm
server/settings.db-wal
server/data/systems.db-shm
server/data/systems.db-wal
# Build and cache
.cache/
@@ -13,6 +15,7 @@ server/target/
server/sr-voice/target/
server/models/
tooling/content-converter/target/
tooling/econ-sim/target/
tooling/line-previewer/target/
tooling/test-client/target/
content-ron/
+11
View File
@@ -327,6 +327,17 @@ db-install:
economy-db: ## Import economics data (commodities, chains, gate links) into systems.db
@python3 tooling/economy-db/import_economics.py
econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade)
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
@echo "Built: tooling/econ-sim/target/release/econ-sim"
econ-sim-run: ## Run a quick economics simulation (100 ticks, output to /tmp/econ-sim.csv)
@tooling/econ-sim/target/release/econ-sim --ticks 100 --output /tmp/econ-sim.csv
@echo "Output: /tmp/econ-sim.csv"
econ-sim-stability: ## Run D-179 stability checks (Tests 1 and 2)
@tooling/econ-sim/target/release/econ-sim --stability-check
# --- Decisions ---
decisions-sync:
+54 -3
View File
@@ -226,7 +226,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn",
"toml_edit",
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
@@ -1221,6 +1221,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -1236,7 +1245,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.31"
version = "0.1.32"
dependencies = [
"bevy_app",
"bevy_ecs",
@@ -1254,6 +1263,7 @@ dependencies = [
"serde_yaml",
"sysinfo",
"thiserror",
"toml",
"tracing",
"tracing-subscriber",
]
@@ -1378,6 +1388,27 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -1387,6 +1418,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
@@ -1394,7 +1439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap",
"toml_datetime",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
]
@@ -1408,6 +1453,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tracing"
version = "0.1.44"
+1
View File
@@ -22,6 +22,7 @@ crossbeam-channel = "0.5"
sysinfo = "0.35"
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
toml = "0.8"
[features]
default = ["gauntlet"]
+5
View File
@@ -41,6 +41,11 @@ CREATE TABLE IF NOT EXISTS star_systems (
-- Economics (D-172)
currency_zone TEXT DEFAULT 'TRACTUS_PRIMARY', -- TRACTUS_PRIMARY | MARK_PRIMARY | MIXED
-- Energy-over-gate (D-186)
-- Gate Corp energy service: on-grid nodes get ~0.3× fusion_fuel utility demand.
-- MARK_PRIMARY zones default false (Compact refused Gate Corp dependency).
gate_energy_connected INTEGER DEFAULT 1, -- boolean 0/1
updated_at TEXT DEFAULT (datetime('now'))
);
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,543 @@
//! Deterministic name generation for Tier-3 corporations.
//!
//! Names are composed from culture-specific pools matching the Settled Reach's
//! geographic sectors. Each sector has dominant cultural influences derived
//! from lore (wiki settlements, founding cultures, corridor identities).
//!
//! Pattern: `{surname/word} {business_suffix}` where surname draws from
//! the sector's cultural pool and suffix from the lore category.
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
// ---------------------------------------------------------------------------
// Surname pools by sector (drawn from founding cultures in wiki canon)
// ---------------------------------------------------------------------------
/// Core systems: cosmopolitan mix — the Reach's center of gravity.
const CORE_NAMES: &[&str] = &[
"Alvarez",
"Benoit",
"Carvalho",
"Durand",
"Eriksen",
"Fournier",
"Gao",
"Hartmann",
"Ishida",
"Johansson",
"Kirchner",
"Lemaire",
"Moreau",
"Nakamura",
"Olsson",
"Pelletier",
"Richter",
"Saito",
"Torres",
"Ueda",
"Vasquez",
"Werner",
"Xu",
"Yamada",
"Zhou",
"Andersen",
"Beaumont",
"Costa",
"Delacroix",
"Engel",
"Fujita",
"Gutierrez",
"Hayashi",
"Ibarra",
"Jensen",
"Klein",
"Laurent",
"Mercier",
"Novak",
"Ortiz",
"Park",
"Reuter",
"Suzuki",
"Takahashi",
"Ulrich",
"Valentin",
"Wagner",
"Xie",
"Yilmaz",
"Zhang",
];
/// North reach: Nordic, Scottish, northern European — Calloway heritage.
const NORTH_REACH_NAMES: &[&str] = &[
"Andersson",
"Bjornsson",
"Calloway",
"Dalsgaard",
"Eklund",
"Falk",
"Grimstad",
"Hedlund",
"Ivarsson",
"Jonasson",
"Kirkpatrick",
"Lindqvist",
"MacLeod",
"Nordstrom",
"Olafsson",
"Pettersson",
"Rehn",
"Strandberg",
"Thorsen",
"Ulvskog",
"Vikstrom",
"Wahlberg",
"Aberg",
"Berglund",
"Carlsen",
"Dalgaard",
"Engstrom",
"Forsell",
"Gustafsson",
"Halvorsen",
"Ingvarsson",
"Jansson",
"Knudsen",
"Lundin",
"MacPherson",
"Nylund",
"Ostergaard",
"Palsson",
"Rasmussen",
"Sjoberg",
"Toft",
"Ulfsson",
"Vestergaard",
"Wiklund",
"Aasen",
"Brannstrom",
"Dahl",
"Eide",
"Friberg",
"Gren",
];
/// South reach: Eastern European, East Asian industrial — Stalownia corridor.
const SOUTH_REACH_NAMES: &[&str] = &[
"Adamski",
"Baranov",
"Chernov",
"Dubois",
"Egorov",
"Filipov",
"Gromov",
"Horvat",
"Ivanova",
"Jankovic",
"Kowalski",
"Lazarev",
"Morozov",
"Novikov",
"Ostrowski",
"Petrov",
"Reznik",
"Sokolov",
"Tkachenko",
"Uvarov",
"Volkov",
"Wojcik",
"Yakimov",
"Zheng",
"Babic",
"Chernyshev",
"Dragunov",
"Fedorov",
"Grushevsky",
"Havel",
"Ito",
"Jovanovic",
"Katsaros",
"Lebedev",
"Mazur",
"Nemec",
"Ochoa",
"Popov",
"Radic",
"Smirnov",
"Tanaka",
"Urasawa",
"Vasiliev",
"Watanabe",
"Xiang",
"Yegorov",
"Zaytsev",
"Borysko",
"Chen",
"Dimitrov",
];
/// West reach: German, Central European — Compact territory, Westphalian influence.
const WEST_REACH_NAMES: &[&str] = &[
"Albrecht",
"Baumann",
"Christensen",
"Dietrich",
"Eisenberg",
"Fischer",
"Gruber",
"Hoffmann",
"Ingolstadt",
"Jaeger",
"Kessler",
"Lehmann",
"Mueller",
"Neumann",
"Obermann",
"Pfeiffer",
"Quandt",
"Roth",
"Schaefer",
"Thiel",
"Urban",
"Vogt",
"Weidenfeld",
"Ziegler",
"Becker",
"Claussen",
"Dorfmann",
"Eberhardt",
"Fleischer",
"Gerstner",
"Haber",
"Imhof",
"Jung",
"Kraemer",
"Linden",
"Metzger",
"Niedermann",
"Opitz",
"Preuss",
"Raabe",
"Steinbach",
"Trautmann",
"Unger",
"Vollmer",
"Winterberg",
"Zahn",
"Auerbach",
"Bruckner",
"Dahlem",
"Eckhardt",
];
/// East reach: Filipino, Korean, maritime Asian — distinctive identity.
const EAST_REACH_NAMES: &[&str] = &[
"Aquino",
"Bautista",
"Cruz",
"Dalisay",
"Espiritu",
"Flores",
"Garcia",
"Hernandez",
"Ilagan",
"Jeon",
"Kim",
"Lim",
"Magalang",
"Navarro",
"Ocampo",
"Park",
"Quijano",
"Reyes",
"Santos",
"Tan",
"Uy",
"Villanueva",
"Wong",
"Yoo",
"Aguilar",
"Buenaventura",
"Castillo",
"Dizon",
"Enriquez",
"Fernandez",
"Gonzales",
"Hwang",
"Ignacio",
"Jeong",
"Kwon",
"Lee",
"Marasigan",
"Nakamura",
"Oh",
"Perez",
"Ramos",
"Son",
"Tolentino",
"Umali",
"Valdez",
"Yun",
"Zamora",
"Baek",
"Choi",
"Dela Cruz",
];
/// Deep frontier: mixed backgrounds from all settler waves — no dominant culture.
const FRONTIER_NAMES: &[&str] = &[
"Adeyemi",
"Bergstrom",
"Chandra",
"Duval",
"Emeka",
"Fonseca",
"Gupta",
"Hassan",
"Ibrahim",
"Jansson",
"Kovac",
"Liu",
"Martinez",
"Nkosi",
"Okafor",
"Patel",
"Quinn",
"Rodriguez",
"Sousa",
"Thorne",
"Uddin",
"Varga",
"Wu",
"Xiong",
"Yoshida",
"Zhao",
"Abara",
"Beaumont",
"Cardenas",
"Doyle",
"Ekwueme",
"Ferreira",
"Gomes",
"Henriksen",
"Idris",
"Juma",
"Kato",
"Larsen",
"Morales",
"Ndlovu",
"Osei",
"Petrov",
"Ruiz",
"Singh",
"Tavares",
"Uchida",
"Volkov",
"Wang",
"Yang",
"Zaman",
];
// ---------------------------------------------------------------------------
// Business suffix pools by lore category
// ---------------------------------------------------------------------------
const EXTRACTION_SUFFIXES: &[&str] = &[
"Mining Co.",
"Extraction",
"Resources",
"Minerals",
"Mining",
"Quarry Works",
"Deep Drill",
"Ore Works",
"Claims",
"Mining & Salvage",
"Prospecting",
"Dig Co.",
"Rock Works",
"Shaft Mining",
"Surface Mining",
];
const AGRICULTURE_SUFFIXES: &[&str] = &[
"Farms",
"Agricultural Co.",
"Growers",
"Harvest",
"Provisions",
"Ranchers",
"Fisheries",
"Food Co.",
"Plantations",
"Cultivators",
"Produce",
"Orchard",
"Dairy",
"Stockfeed",
"Processing",
];
const MANUFACTURING_SUFFIXES: &[&str] = &[
"Manufacturing",
"Works",
"Industries",
"Fabrication",
"Engineering",
"Precision",
"Assembly",
"Components",
"Foundry",
"Machine Works",
"Systems",
"Technical",
"Metalworks",
"Forging",
"Production",
];
const TRADE_LOGISTICS_SUFFIXES: &[&str] = &[
"Freight",
"Logistics",
"Shipping",
"Transport",
"Haulage",
"Cargo",
"Transit",
"Distribution",
"Forwarding",
"Express",
"Lines",
"Carriers",
"Fleet",
"Couriers",
"Supply Co.",
];
const SERVICES_SUFFIXES: &[&str] = &[
"Services",
"Associates",
"Consulting",
"Partners",
"Group",
"Holdings",
"Clinic",
"Bureau",
"Agency",
"Office",
"Practice",
"Solutions",
"Advisors",
"Trust",
"Institute",
];
const INTELLIGENCE_SUFFIXES: &[&str] = &[
"Analytics",
"Intelligence",
"Data Services",
"Information",
"Research",
"Advisory",
"Insights",
"Consulting",
"Networks",
"Analysis",
];
// ---------------------------------------------------------------------------
// Name generation
// ---------------------------------------------------------------------------
fn names_for_sector(sector: &str) -> &'static [&'static str] {
match sector {
"core" => CORE_NAMES,
"north_reach" => NORTH_REACH_NAMES,
"south_reach" => SOUTH_REACH_NAMES,
"west_reach" => WEST_REACH_NAMES,
"east_reach" => EAST_REACH_NAMES,
"deep_frontier" => FRONTIER_NAMES,
_ => CORE_NAMES,
}
}
fn suffixes_for_category(category: &str) -> &'static [&'static str] {
match category {
"extraction" => EXTRACTION_SUFFIXES,
"agriculture" => AGRICULTURE_SUFFIXES,
"manufacturing" => MANUFACTURING_SUFFIXES,
"trade_logistics" => TRADE_LOGISTICS_SUFFIXES,
"services" => SERVICES_SUFFIXES,
"intelligence" => INTELLIGENCE_SUFFIXES,
_ => SERVICES_SUFFIXES,
}
}
/// Generate a plausible business name for the given sector and lore category.
/// Deterministic for a given RNG state.
pub fn generate_name(rng: &mut ChaCha8Rng, sector: &str, category: &str) -> String {
let names = names_for_sector(sector);
let suffixes = suffixes_for_category(category);
let surname = names[rng.random_range(0..names.len())];
let suffix = suffixes[rng.random_range(0..suffixes.len())];
// 20% chance of double-barrel name (Surname & Surname Suffix)
if rng.random::<f64>() < 0.20 {
let surname2 = names[rng.random_range(0..names.len())];
if surname != surname2 {
return format!("{} & {} {}", surname, surname2, suffix);
}
}
// 15% chance of "Surname's Suffix" or "Surname Bros. Suffix"
if rng.random::<f64>() < 0.15 {
return format!("{} Bros. {}", surname, suffix);
}
format!("{} {}", surname, suffix)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn deterministic_names() {
let mut rng1 = ChaCha8Rng::seed_from_u64(42);
let mut rng2 = ChaCha8Rng::seed_from_u64(42);
for _ in 0..100 {
let a = generate_name(&mut rng1, "core", "extraction");
let b = generate_name(&mut rng2, "core", "extraction");
assert_eq!(a, b);
}
}
#[test]
fn names_not_empty() {
let mut rng = ChaCha8Rng::seed_from_u64(1);
for sector in &[
"core",
"north_reach",
"south_reach",
"west_reach",
"east_reach",
"deep_frontier",
] {
for cat in &[
"extraction",
"agriculture",
"manufacturing",
"trade_logistics",
"services",
"intelligence",
] {
let name = generate_name(&mut rng, sector, cat);
assert!(!name.is_empty(), "Empty name for {}/{}", sector, cat);
assert!(name.contains(' '), "No space in name: {}", name);
}
}
}
}
+691
View File
@@ -0,0 +1,691 @@
# Test Plan: Sprint 33 — Pecunia (Economics Simulation)
- **Sprint:** 33
- **Date:** 2026-04-07
- **Author:** Hoshe (QA)
- **Branch:** `sprint-33/server`
- **Tickets:** #813, #805, #806, #807, #808, #809
- **Key spec:** `decisions/economics.md` (D-171D-187), D-179 (stability acceptance criteria)
---
## How to Use This Document
Verification queries are written for `tooling/db/sqlite-query`. Stability tests run via
`tooling/econ-sim --stability-check` once #807 lands. All SQL queries assume a fully-imported
`server/data/systems.db` (after running `make economy-db`).
**Pass/fail convention:** Each test has an **Expected** clause. A test fails if the output
deviates from Expected in any measurable way. Failures from #807 and later that involve
oscillation or divergence indicate a broken model — tune α/β before calling it a feature (D-179).
---
## Pre-Flight: Baseline Data Sanity
Run these before testing any ticket. If they fail, the DB state is corrupted and ticket-level
tests are meaningless.
```sql
-- BF-1: Commodity count must be 36 (D-184)
SELECT COUNT(*) FROM commodities;
-- Expected: 36
-- BF-2: Commodity tier breakdown must match D-184 (9/10/9/5/3)
SELECT tier, COUNT(*) FROM commodities GROUP BY tier ORDER BY tier;
-- Expected:
-- intermediate 10
-- raw 9
-- final 9
-- service_professional 5
-- service_luxury 3
-- BF-3: Production chain count must be 21 (D-184)
SELECT COUNT(*) FROM production_chains;
-- Expected: 21
-- BF-4: Chain input count must be 40 (count inputs from production_chains.toml)
SELECT COUNT(*) FROM chain_inputs;
-- Expected: 40
-- BF-5: Gate links must be bidirectional (every from→to has a matching to→from)
SELECT COUNT(*) FROM gate_links gl
WHERE NOT EXISTS (
SELECT 1 FROM gate_links rev
WHERE rev.from_system_id = gl.to_system_id
AND rev.to_system_id = gl.from_system_id
);
-- Expected: 0
-- BF-6: All chain inputs reference valid commodities
SELECT COUNT(*) FROM chain_inputs ci
LEFT JOIN commodities c ON ci.input_commodity_id = c.commodity_id
WHERE c.commodity_id IS NULL;
-- Expected: 0
-- BF-7: All chain outputs reference valid commodities
SELECT COUNT(*) FROM production_chains pc
LEFT JOIN commodities c ON pc.output_commodity_id = c.commodity_id
WHERE c.commodity_id IS NULL;
-- Expected: 0
```
**Known discrepancy to verify:** The `commodities.toml` section header reads
`# PROFESSIONAL SERVICES (7)` but only 5 entries follow. The total must be 36 (matching
D-184: 9+10+9+5+3). Flag if the count is 38.
---
## #813 — Energy-over-gate Schema Extension
**Spec ref:** D-186
**Assigned to:** Tyre
**Status:** in_progress
### What was changed
- `gate_energy_connected INTEGER DEFAULT 1` column added to `star_systems`
(system-level, not per body/station — all nodes in a system inherit the system's setting
via a join. Gate Corp energy is a system-wide commercial contract, not a per-node toggle.)
- `MARK_PRIMARY` zones default to `false` (0)
- All other zones default to `true` (1)
- Migration is idempotent (safe to re-run via COLUMN_MIGRATIONS)
- `set_gate_energy()` runs as step 6, after `set_currency_zones()` step 5 (correct ordering)
### Verification queries
```sql
-- 813-1: Column exists on star_systems table
PRAGMA table_info(star_systems);
-- Expected: row with name='gate_energy_connected' and type='INTEGER'
-- 813-2: MARK_PRIMARY systems have gate_energy_connected = 0
SELECT COUNT(*) FROM star_systems
WHERE currency_zone = 'MARK_PRIMARY'
AND gate_energy_connected != 0;
-- Expected: 0
-- 813-3: TRACTUS_PRIMARY systems have gate_energy_connected = 1
SELECT COUNT(*) FROM star_systems
WHERE currency_zone = 'TRACTUS_PRIMARY'
AND gate_energy_connected != 1;
-- Expected: 0
-- 813-4: MIXED systems have gate_energy_connected = 1
SELECT COUNT(*) FROM star_systems
WHERE currency_zone = 'MIXED'
AND gate_energy_connected != 1;
-- Expected: 0
-- 813-5: gate_energy_connected is never NULL
SELECT COUNT(*) FROM star_systems WHERE gate_energy_connected IS NULL;
-- Expected: 0
-- 813-6: At least one MARK_PRIMARY system exists (validates zone data is present)
SELECT COUNT(*) FROM star_systems WHERE currency_zone = 'MARK_PRIMARY';
-- Expected: > 0 (requires #820 copy work to be merged first; skip if copy branch not merged)
-- 813-7: Sim binary can read gate_energy via join (integration spot-check)
-- The sim must join bodies/stations to star_systems to get gate_energy_connected.
-- Verify the join is correct:
SELECT b.body_id, ss.gate_energy_connected
FROM bodies b
JOIN star_systems ss ON b.system_id = ss.system_id
WHERE b.inhabited = 1
LIMIT 5;
-- Expected: 5 rows with gate_energy_connected = 0 or 1 (not NULL)
```
### Edge cases
**813-E1: Idempotent migration**
Run `make economy-db` twice on the same DB. Second run must not raise an error, and query
813-1 through 813-7 must still pass.
**813-E2: Systems with NULL currency_zone**
If any star system has `currency_zone IS NULL`, the migration logic must treat it as
`TRACTUS_PRIMARY` (default to `true`). Verify no bodies end up with `gate_energy_connected = 0`
due to a NULL zone.
```sql
SELECT COUNT(*) FROM star_systems WHERE currency_zone IS NULL;
-- Expected: 0 (import pipeline sets default; but verify regardless)
```
**813-E3: Demand reduction is NOT implemented here**
Confirm the `~0.3× fusion_fuel` utility demand reduction is absent from the schema-only ticket.
The demand model lives in the sim binary (#806). Verify:
- No column named `utility_demand_modifier` or similar on star_systems
- No new columns beyond `gate_energy_connected` on star_systems
### Regression markers
- `tooling/economy-db/import_economics.py` migration block must still be idempotent
- Existing BF-1 through BF-7 must still pass after #813 migration
---
## #805 — Corporation Pipeline and Validation
**Spec ref:** D-175, D-182
**Assigned to:** Dudley
**Status:** in_progress
### What was changed
- `import_economics.py` (or a new companion script) reads `wiki/corporations/` markdown files
- Populates `corp_presence` table from authored location data
- Validates wiki corp names ↔ DB `corporations.proper_name` sync (D-182 sync constraint)
- Coverage rules: 3+ corps per major commodity type, 1+ per inhabited system >100K pop
- Chain completeness validation: every intermediate commodity has ≥1 producing chain
- Coverage failures exit non-zero (D-175 phase gate)
### Verification queries
```sql
-- 805-1: corp_presence is no longer empty after pipeline run
SELECT COUNT(*) FROM corp_presence;
-- Expected: > 0
-- 805-2: All corp_presence rows reference valid corp_id
SELECT COUNT(*) FROM corp_presence cp
LEFT JOIN corporations c ON cp.corp_id = c.corp_id
WHERE c.corp_id IS NULL;
-- Expected: 0
-- 805-3: All corp_presence rows reference valid location_id
-- (either a body_id or station_id — location_type determines which table)
SELECT COUNT(*) FROM corp_presence WHERE location_type = 'body'
AND location_id NOT IN (SELECT body_id FROM bodies);
-- Expected: 0
SELECT COUNT(*) FROM corp_presence WHERE location_type = 'station'
AND location_id NOT IN (SELECT station_id FROM stations);
-- Expected: 0
-- 805-4: Chain completeness — every intermediate must have a producing chain
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
);
-- Expected: 0 rows (all 10 intermediates have a producing chain)
-- 805-5: Chain completeness — every final good must have a producing chain
SELECT c.commodity_id, c.name
FROM commodities c
WHERE c.tier = 'final'
AND c.commodity_id NOT IN (
SELECT output_commodity_id FROM production_chains
);
-- Expected: 0 rows (all 9 finals have a producing chain)
-- 805-6: Services have NO producing chains (they are demand sinks, not outputs)
SELECT c.commodity_id, c.name
FROM commodities c
WHERE c.tier IN ('service_professional', 'service_luxury')
AND c.commodity_id IN (SELECT output_commodity_id FROM production_chains);
-- Expected: 0 rows
-- 805-7: Raw materials have NO producing chains (they are inputs, not outputs)
SELECT c.commodity_id, c.name
FROM commodities c
WHERE c.tier = 'raw'
AND c.commodity_id IN (SELECT output_commodity_id FROM production_chains);
-- Expected: 0 rows (fusion_fuel is intermediate, not raw — verify separately)
-- 805-8: Coverage rule — corporations per major commodity type (D-175: 3+ per major type)
-- "Major commodity type" = intermediates and finals with demand_model = 'market'
-- This requires corp_presence.primary_operation to reference a commodity_id; adjust
-- query if the schema uses a different field. Flag if the field is absent.
SELECT commodity_id, COUNT(DISTINCT cp.corp_id) AS corp_count
FROM corp_presence cp
JOIN corporations c ON cp.corp_id = c.corp_id
WHERE cp.primary_operation IS NOT NULL
GROUP BY cp.primary_operation
HAVING corp_count < 3;
-- Expected: 0 rows (every commodity with corp presence has 3+ corps)
-- 805-9: Coverage rule — inhabited systems > 100K pop have at least one corp
SELECT ss.system_id, ss.proper_name
FROM star_systems ss
JOIN system_economy se ON ss.system_id = se.system_id
WHERE se.population > 100000
AND NOT EXISTS (
SELECT 1 FROM corp_presence cp
JOIN bodies b ON cp.location_id = b.body_id AND cp.location_type = 'body'
WHERE b.system_id = ss.system_id
UNION
SELECT 1 FROM corp_presence cp
JOIN stations s ON cp.location_id = s.station_id AND cp.location_type = 'station'
WHERE s.system_id = ss.system_id
);
-- Expected: 0 rows
```
### Exit code tests
Run the pipeline with intentional violations and verify non-zero exit:
**805-E1: Wiki name mismatch causes hard error**
Temporarily rename a corporation in the DB to something the wiki doesn't know, re-run pipeline.
Expected: non-zero exit with clear error message identifying the mismatch.
**805-E2: Missing commodity coverage causes hard error**
If coverage drops below 3 corps for any major commodity type, pipeline must exit non-zero.
Expected: non-zero exit with specific commodity identified.
**805-E3: Coverage failure for underpopulated system causes hard error**
If an inhabited system with >100K pop has zero corp presence, pipeline must exit non-zero.
Expected: non-zero exit with system_id identified.
**805-E4: Dry-run still works**
`python3 tooling/economy-db/import_economics.py --dry-run` must:
- Not write to corp_presence
- Run all validations and report but not halt on coverage gaps (dry-run output is informational)
- Exit 0 (dry-run is for inspection, not a gate)
Wait — check this with Dudley. If dry-run is meant to be a gate too, this should exit non-zero
on validation failure. The existing pipeline exits 0 on dry-run. Confirm expected behavior
before locking this test.
### Regression markers
- BF-1 through BF-7 still pass (new pipeline must not corrupt commodity/chain data)
- `corp_presence` table's FK constraint still enforced (BF-6 analog for corps)
- Existing `gate_links` bidirectionality (BF-5) unaffected
---
## #806 — Skeleton Economy_sim Binary
**Spec ref:** D-176, D-177, D-178
**Assigned to:** Dudley
**Status:** backlog (blocked on #805)
### What was changed
- New Rust binary at `tooling/econ-sim/`
- Reads systems.db: gate_links, commodities, production_chains, chain_inputs, corp_presence
- Seeds per-corp-site productivity (5 dimensions, PRNG, log-normal distribution)
- Layer 1 Leontief only (no inter-system trade, no currency)
- Outputs per-node CSV: node_id, commodity_id, supply, demand, price, tick
- `--stability-check` flag compiles (stub, not yet meaningful)
### Build verification
```bash
cd tooling/econ-sim && cargo build
# Expected: exits 0, no compile errors
tooling/econ-sim --help
# Expected: help text with --db, --stability-check, and --output flags visible
```
### CSV output verification
```bash
tooling/econ-sim --db server/data/systems.db --output /tmp/econ_out.csv
```
**806-1:** CSV file is created at the specified path
**806-2:** CSV header contains: `node_id,commodity_id,supply,demand,price,tick`
**806-3:** Row count is `active_nodes × 36` (760 active nodes × 36 commodities = ~27,360 rows)
- Acceptable range: ±10% of 27,360 (active node count may differ slightly from spec estimate)
**806-4:** No `price` values are negative or zero for commodity types with non-zero base_price
**806-5:** `tick` column is 0 for initial seeding output (first tick)
### Productivity seeding verification
**806-6: Standard node range (D-176)**
```python
# Pseudocode — inspect CSV output
import csv, math
prices = [float(row['price']) for row in csv.DictReader(open('/tmp/econ_out.csv'))]
# For standard commodities, productivity multiplier range is 0.41.8x
# Price variation relative to base_price should reflect this range
base_price_by_id = { ... } # from commodities table
multipliers = [price / base_price_by_id[row['commodity_id']] for row in rows]
assert all(0.3 <= m <= 2.0 for m in multipliers), "multiplier out of expected range"
# Actual range check: most values should fall within 0.41.8x (log-normal tails permitted)
```
**806-7: Monopoly-source node range (D-176)**
Nodes producing `lattice_grade_material` (production_ubiquity = 'monopolistic') must show
tighter multiplier range: 0.71.4×. Verify variance is lower than standard nodes.
**806-8: D-177 constraints — what must NOT vary**
Verify the binary never seeds or varies:
- Location of production (the set of nodes producing each commodity is fixed from DB data,
not random)
- `lattice_grade_material` productivity: must stay within 0.71.4× (monopolistic ceiling)
- Absence of seeded "starting disruptions" (no negative productivity, no corps with zero
initial output as a seeded state)
**806-9: Corridor correlation (D-176 ~0.6)**
Nodes in the same geographic corridor should have correlated productivity across runs with
similar PRNG seeds. Spot-check: run binary twice with seeds differing by 1; nodes in same
corridor should show ~0.6 Pearson correlation on their multipliers.
### Stub stability check
```bash
tooling/econ-sim --stability-check
# Expected: exits with some non-panic output, even if it's "stability tests not yet implemented"
# Must NOT crash or segfault
```
### Regression markers
- Atlas binary still builds: `cargo build --bin atlas`
- D-177 lore constraints respected (see 806-8)
---
## #807 — Trade Flows and Stability Testing
**Spec ref:** D-178, D-179
**Assigned to:** Dudley
**Status:** backlog (blocked on #806)
**This is the most critical ticket. D-179 defines the exit condition for Phase 2.**
### Tâtonnement parameters
Verify from source code:
- α = 0.03 (price adjustment speed)
- β = 0.4 (damping coefficient)
If these are configurable via CLI flags, document the defaults. If hardcoded, grep for them:
```bash
grep -r "0\.03" tooling/econ-sim/src/
grep -r "0\.4" tooling/econ-sim/src/
```
### Floyd-Warshall startup performance
```bash
time tooling/econ-sim --stability-check 2>&1 | head -5
# Expected: FW initialization completes in < 2s (D-178 spec: ~0.5s, allow 4x margin)
# Flag if > 5s: likely iterating over all 3700 nodes instead of the ~760 active subgraph
```
### Market node tiering (D-178)
**807-1:** Active node count is approximately 760 (inhabited bodies + all stations)
```sql
-- Count active market nodes per D-178 definition
SELECT COUNT(*) FROM (
SELECT body_id AS node_id FROM bodies WHERE inhabited = 1
UNION ALL
SELECT station_id FROM stations
);
-- Expected: ~760 (accept 700820 as the spec estimate may not match actual DB state)
```
**807-2:** Passive producer count is approximately 240
```sql
SELECT COUNT(*) FROM bodies WHERE inhabited = 0 AND population > 0;
-- Expected: ~240 (bodies with economic activity but no market function)
-- Adjust query based on how the sim defines "passive producer"
```
### Transport cost model
Verify in source or via output that:
**807-3:** Gate edges cost 512% per hop (inter-system)
**807-4:** Orbital edges cost 13% (intra-system)
**807-5:** Transport costs are applied to commodity prices, not abstracted away
### D-179 Stability Tests
```bash
tooling/econ-sim --stability-check
```
All four tests are run by this flag (Tests 12 in #807, Tests 34 in #808). After #807:
**Test 1: Cold-start convergence (D-179)**
- Simulate 100 game-days from cold start
- Measure price deviation from equilibrium at tick 100
- **Pass criterion:** All active commodity prices within ±5% of equilibrium
- **Fail indicators:** oscillation, monotonic drift, any price < 0
**Test 2: Long-run stability (D-179)**
- Simulate 1,000 game-days with zero external events
- Measure maximum price drift from tick-0 equilibrium
- **Pass criterion:** Zero drift > ±2% over the full 1,000-tick run
- **Fail indicators:** slow drift accumulation, oscillation amplitude > 2%, any negative price
### Stockpile buffer test
**807-6:** Single-tick supply removal does not cause price explosion
```
procedure:
1. Run sim to equilibrium (100 ticks)
2. Inject a single tick of zero supply for one commodity at one node
3. Observe price at that node for next 5 ticks
Expected: price rises but does not exceed 10× base_price
Fail: price goes to infinity, NaN, or negative
```
### Regression markers
- Test 1 and Test 2 must pass with `--stability-check` before #808 begins
- If either test fails: do NOT mark #807 done, do NOT proceed to #808
- α/β must be documented (in source comments or README) so future tuning is traceable
---
## #808 — Currency Zones and Exchange Rates
**Spec ref:** D-171, D-172, D-174, D-181, D-186
**Assigned to:** Dudley
**Status:** backlog (blocked on #807)
### Currency zone model
**808-1:** Tractus↔Mark friction = ~3%
Verify in cross-zone trade: cost of a commodity transiting from a TRACTUS_PRIMARY to a
MARK_PRIMARY node is ~3% higher than same-zone transit at equal hop distance.
**808-2:** Zero friction within MARK_PRIMARY zones
Two nodes both in MARK_PRIMARY zones trading with each other incur no currency conversion cost
beyond the standard transport cost.
**808-3:** Sol is NOT a zone flag
```sql
SELECT COUNT(*) FROM star_systems WHERE currency_zone = 'SOL_PRIMARY';
-- Expected: 0 (Sol is shadow economy only, D-171)
```
**808-4:** Exchange rate is driven by trade balance, not hardcoded
The Tractus/Mark exchange rate must change between runs (or across ticks as trade flows change).
Hardcoded rates are a test failure.
### Signal vocabulary (D-181)
All 7 signals must be present in sim output per active node:
**808-5:**
```
1. price_current — present in output
2. price_trend — present in output (direction + rate)
3. trade_flow_volume — present in output
4. corporate_presence — present in output
5. stockpile_weeks — present in output
6. production_vs_baseline — present in output
7. official_coverage_ratio — present in output (derived from shadow_economy_intensity)
```
Edge case: For `official_coverage_ratio`, verify nodes with no shadow economy intensity
(TRACTUS_PRIMARY core systems) produce `official_coverage_ratio = 1.0` (formal economy
covers 100% of activity), not NULL.
### gate_energy_connected demand reduction (D-186)
**808-6:** Nodes with `gate_energy_connected = true` show `fusion_fuel` demand ~0.3× baseline
- Run sim on a TRACTUS_PRIMARY system (gate_energy_connected = true)
- Run sim on a MARK_PRIMARY system (gate_energy_connected = false)
- Compare `fusion_fuel` demand signal: on-grid node demand must be ~30% of off-grid
**808-7:** Industrial chain inputs are NOT reduced (D-186)
- `smelt_ore` still requires `fusion_fuel` at 0.3 coefficient regardless of gate energy
- `alloy_fabrication` still requires `fusion_fuel` at 0.2 coefficient
- `electronics_fabrication` still requires `fusion_fuel` at 0.2 coefficient
### D-179 Tests 34
**Test 3: Shock response (D-179)**
- Apply a single supply shock to one commodity at one node
- **Pass criteria:**
- Cascade propagates to dependent commodities (Leontief input scarcity visible)
- Recovery to within 10% of pre-shock price within 200 ticks
- No price explosions (no value > 100× base_price)
- No negative prices
- **Fail indicators:** runaway cascade, no recovery, shock isolated (no cascade = broken Leontief)
**Test 4: Cross-zone trade balance (D-179)**
- Increase trade volume across a TRACTUS_PRIMARY / MARK_PRIMARY boundary
- **Pass criteria:**
- Exchange rate adjusts in response (Tractus/Mark ratio changes)
- Rate re-stabilizes within 50 ticks
- Friction cost is visible (cross-zone goods 3% more expensive than same-zone equivalent)
- **Fail indicators:** no rate adjustment, infinite oscillation, rate diverges
All four D-179 tests must pass before #809 begins.
### Regression markers
- Tests 1 and 2 from #807 must still pass with currency layer active
- Tractus prices are still the numeraire (no price expressed in Mark or Sol units)
---
## #809 — Corporate Agent Behavior
**Spec ref:** D-175, D-178, D-180, D-181
**Assigned to:** Dudley
**Status:** backlog (blocked on #808, #799, #800)
### Corporate data loading
**809-1:** Corporations are loaded from DB, not hardcoded
```bash
grep -r "hardcoded\|\"Gate Corporation\"\|\"Vethara\"" tooling/econ-sim/src/
# Expected: corporation names should appear only in test fixtures or SQL queries,
# not as string literals in behavioral logic
```
**809-2:** Behavioral archetype template is read from TOML
```bash
ls wiki/economics/archetypes/behavioral.toml
# Expected: file exists (created by copy team per sprint briefing)
```
**809-3:** Each archetype is instantiated per corporation from corp_presence
```sql
-- Every corporation with corp_presence rows has a behavioral_archetype in DB
SELECT COUNT(*) FROM corp_presence cp
JOIN corporations c ON cp.corp_id = c.corp_id
WHERE c.behavioral_archetype IS NULL;
-- Expected: 0 (all corps with presence have an archetype assigned)
```
### Six behavioral archetypes (D-175)
**809-4:** All 6 archetypes are implemented
```bash
grep -r "Monopolist\|Distributor\|Producer\|Specialist\|Cooperative\|Intermediary" \
tooling/econ-sim/src/
# Expected: all 6 appear in behavioral logic, not just data loading
```
**809-5:** Archetypes produce distinguishably different behavior
Run stability check with only Monopolist corps vs. only Cooperative corps in a test system.
Price signals should differ between the two runs. If all archetypes produce identical output,
the behavioral differentiation is not implemented.
### EconEvent stub (D-180)
**809-6:** EconEvent struct compiles with all required fields
```bash
grep -r "EconEvent" tooling/econ-sim/src/
# Expected: struct definition with: target, effect, duration, visibility fields
```
**809-7:** Visibility variants are defined
```bash
grep -r "Global\|Proximate\|Disclosed\|Hidden" tooling/econ-sim/src/
# Expected: all 4 visibility variants present in the EconEvent type
```
**809-8:** Event handler is a no-op (not exercised in Phase 2)
Any call to `handle_event(EconEvent { ... })` should produce no observable simulation change.
The port must compile and accept events without crashing.
### Signal completeness (D-181)
**809-9:** All 7 signals produced per active node with agents active
Repeat 808-5 checks with corporate agents running. Agent behavior must not suppress or break
signal production.
**809-10:** `production_vs_baseline` reflects agent output vs seeded baseline
A Monopolist corp restricting supply should show `production_vs_baseline < 1.0`.
A Cooperative corp operating at full capacity should show `production_vs_baseline ≈ 1.0`.
### D-179 Full Test Suite with Agents Active
**This is the Phase 2 exit condition.**
```bash
tooling/econ-sim --stability-check
```
**809-11:** All four stability tests pass with corporate agents active:
- Test 1: Cold-start convergence ±5% within 100 game-days
- Test 2: Long-run stability ±2% over 1,000 game-days
- Test 3: Shock response, recovery within 200 ticks, no explosions or negatives
- Test 4: Cross-zone balance re-stabilizes within 50 ticks
If agents CAUSE instability that wasn't present in #808, the agent behavioral parameters need
tuning — this is a model bug, not a design decision. Investigate price-setting behavior before
concluding the architecture is wrong.
### Regression markers
- All prior D-179 tests still pass
- EconEvent handler does not crash on any valid input permutation
- `behavioral.toml` is a required file — binary must error on missing file with a clear message
---
## Checklist: Verification Order
| Order | Ticket | Gate condition | Who verifies |
|-------|--------|----------------|--------------|
| 1 | Pre-flight BF-17 | DB baseline valid | Hoshe, post #804 |
| 2 | #813 | Schema correct, defaults correct | Hoshe, when Tyre delivers |
| 3 | #805 | corp_presence populated, coverage valid, exits non-zero on failure | Hoshe, when Dudley delivers |
| 4 | #806 | Binary builds, CSV output correct, seeding in range | Hoshe, when Dudley delivers |
| 5 | #807 | Tests 1+2 pass `--stability-check` | Hoshe, when Dudley delivers |
| 6 | #808 | Tests 3+4 pass, all 7 signals present | Hoshe, when Dudley delivers |
| 7 | #809 | All 4 D-179 tests pass with agents active | Hoshe, when Dudley delivers |
**Phase 2 is complete only when step 7 passes.** Steps 5 through 7 are the formal exit gate
per D-179 and D-183.
---
## Appendix: Quick Reference — D-179 Stability Criteria
| Test | Condition | Pass threshold | Run at |
|------|-----------|---------------|--------|
| 1 | Cold-start convergence | ±5% of equilibrium within 100 game-days | #807 |
| 2 | Long-run stability | ±2% drift over 1,000 game-days, zero events | #807 |
| 3 | Shock response | Recovery within 200 ticks, no explosions, no negatives | #808 |
| 4 | Cross-zone trade balance | Re-stabilizes within 50 ticks | #808 |
All four must pass simultaneously with corporate agents active (#809) for Phase 2 sign-off.
+114
View File
@@ -0,0 +1,114 @@
# Test Report: PR #122 — Sprint 33 Economics Simulation
- **Date:** 2026-04-07
- **Build:** `sprint-33/server` → commit `b19bfb32` (Layer 3)
- **PR:** #122 (`main ← sprint-33/server`)
- **Tickets:** #813, #805, #800, #806, #807, #808, #809
- **Spec ref:** D-179 (stability criteria), D-180 (event port), D-181 (signals)
- **Tests run:** D-179 stability suite + manual verification
- **Passed:** D-179 Tests 1, 2, 3 (Test 4 correctly skipped)
- **Failed:** 0
- **Gaps:** 1 (D-181 signal coverage)
---
## D-179 Stability Test Results
**Command:** `make econ-sim-stability`
```
Loading economy data from server/data/systems.db...
36 commodities, 21 production chains, 31 active nodes, 37 corp presences, 668 gate links
Seeding per-corporation productivity (run seed: 0)...
37 corp×site productivity records seeded
48 corporation behavioral archetypes loaded (inferred where not set in DB)
301 nodes with gate connections
Seeding per-node shadow economy intensity (D-174)...
301 nodes seeded, mean intensity 0.50
Test 1 (cold-start convergence ±5% at tick 100): PASS max_dev=1.05% worst: GJ 144/medical_goods
Test 2 (long-run stability ±2% over ticks 900999): PASS max_dev=0.00% worst: GJ 144/medical_goods
Test 3 (shock response — cascade + recovery ≤200 ticks): PASS no explosions (>20× base), no negatives across 1,116,000 records
Test 4 (cross-zone balance re-stabilizes ≤50 ticks): PASS SKIP — no MARK_PRIMARY systems in DB
All stability checks passed.
```
**Test 4 skip is correct.** The implementation checks for MARK_PRIMARY zone data and skips gracefully when none exists (line 275-277, main.rs). Re-run after copy team delivers #820 (Compact zone assignments).
---
## Build Verification
| Check | Result |
|-------|--------|
| `make econ-sim` | PASS — compiled in 0.94s (release) |
| `make econ-sim-run` | PASS — 111,601 rows (100 ticks × 31 nodes × 36 commodities + header) |
| CSV header | PASS — `node_id,commodity_id,supply,demand,price,tick,shadow_intensity,tractus_mark_rate` |
| Negative prices | PASS — none found across 1,116,000 records |
---
## Model Parameter Verification
| Parameter | Spec (D-178) | Actual | Result |
|-----------|-------------|--------|--------|
| α (price adjustment rate) | 0.03 | 0.03 (model.rs:25) | ✅ |
| β (damping — implicit in tâtonnement) | 0.4 | 0.4 (trade.rs) | ✅ |
| Transport cost per gate hop | 512% | 8% flat (trade.rs) | ✅ (within range) |
| Fusion fuel demand reduction (on-grid) | ~0.3× | 0.3 (model.rs:39) | ✅ |
| Initial stockpile buffer | — | 4× baseline demand (model.rs:32) | ✅ |
---
## Architecture Cross-Checks
**corp_presence location_type:** The import pipeline resolves each corp's HQ to a specific body or station and stores `location_type = 'body'` or `'station'` per schema (import_economics.py:453-491). The sim binary queries accordingly. Consistent with schema intent.
**gate_energy_connected join:** Model reads gate energy via `JOIN star_systems` (not directly on bodies/stations). Confirmed the GATE_ENERGY_DEMAND_REDUCTION constant (0.3) is applied to fusion_fuel utility demand for on-grid nodes.
**Active node count = 31:** Expected. Active nodes are limited to systems with corp presence or population > 0 in the DB. The ~760 active node target (D-178) assumes a fully-authored atlas. Stability tests passing at 31 nodes is encouraging; re-run at scale when atlas authoring progresses.
---
## Gaps (Non-Blocking for D-179, Required for Phase 2 Complete)
### Gap 1 — D-181: Only signals 1 and 7 (partial) are produced [MEDIUM]
D-181 requires all 7 signals per active node. The `TickRecord` struct contains:
- Signal 1 (`price_current`) → `price`
- Signal 7 proxy (`shadow_intensity`) → present but `official_coverage_ratio` (1 - shadow_intensity) is not computed ⚠️
**Missing from `TickRecord` and CSV output:**
- Signal 2: `price_trend` — direction + rate of change over last N ticks
- Signal 3: `trade_flow_volume` — freight volume through node (computed by trade.rs but not emitted)
- Signal 4: `corporate_presence` — which corps operate here (static, in DB, not per-tick)
- Signal 5: `stockpile_weeks``stockpile` IS tracked in `CommodityState` but not in `TickRecord`
- Signal 6: `production_vs_baseline` — not computed or tracked
D-181: "Phase 2 sim must produce all 7 signals. Phase 3 determines how the player accesses them."
Signals 4 and 7 are reasonable to defer (static data from DB + derivable from shadow_intensity). Signals 2, 3, 5, 6 require additions to `TickRecord` and `output.rs`. Signals 5 (`stockpile_weeks`) is the easiest — `stockpile` is already computed in the model; it just needs to be added to the output struct.
**Recommendation:** Open a follow-up task for signal completeness. Does not block D-179 tests or PR merge if the team accepts iterative delivery (D-183 allows this). Block merge only if Phase 2 is declared complete.
### Gap 2 — Test 3: Warm-start proxy, not deliberate injection [LOW]
D-179 Test 3 spec: "After a single supply shock, cascade propagates realistically; recovery within 200 ticks; no price explosions or negative prices."
The implementation uses the warm-start disturbance (4× buffer initialization) as the proxy shock and verifies no explosions across 1,000 ticks. This tests the stability envelope but does not test explicit cascade propagation or recovery time measurement. The code comments acknowledge this: "Full shock-response testing will be added when D-180 event port is implemented."
**Verdict:** Acceptable for this sprint given D-180 port isn't implemented. Test 3 as implemented validates the core stability guarantee. The stricter cascade test follows once the event port lands. Low priority for PR block.
---
## Summary
D-179 passes cleanly. The simulation is stable, builds clean, produces correct output.
**Recommend PR merge with one follow-up task:**
1. Add signals 2, 3, 5, 6 to TickRecord and CSV output (D-181 completeness)
**Must re-run `make econ-sim-stability` after:**
- Copy team delivers #820 (Compact MARK_PRIMARY assignments) — enables Test 4
- Atlas authoring reaches higher node counts — validates stability at scale
+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.01.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.01.0).
///
/// Seeds from: political zone, hop distance, gate topology, currency zone.
/// Reference bands (D-174): core ~0.00.2, mid-reach ~0.30.6, frontier ~0.60.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 900999.
/// Equilibrium is defined as the mean price over ticks 900999.
///
/// 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 900999
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 512% 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" "$@"