Merge remote-tracking branch 'origin/sprint-33/server'
This commit is contained in:
Generated
+448
@@ -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",
|
||||
]
|
||||
@@ -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"] }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Currency zones, exchange rates, and shadow economy seeding (D-171, D-172, D-174).
|
||||
//!
|
||||
//! Three currencies (D-171):
|
||||
//! Tractus — Reach-wide standard, numeraire for all simulation pricing.
|
||||
//! Mark — Compact of Westphalia, ~3% conversion friction on cross-zone trade.
|
||||
//! Sol — Earth legacy, modeled as shadow commodity (not a numeraire).
|
||||
//!
|
||||
//! Exchange rate: floating Tractus/Mark rate driven by net cross-zone trade balance.
|
||||
//! Initialized at 1.0 (parity). Adjusted each tick by net flow signal × α_fx.
|
||||
//!
|
||||
//! Shadow economy (D-174): per-node intensity (0.0–1.0) seeded from political
|
||||
//! zone, hop distance, gate topology, and currency zone.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
use crate::db::Economy;
|
||||
use crate::prng::{derive_seed, standard_normal};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cross-zone conversion friction (D-172): applied when trade crosses
|
||||
/// TRACTUS_PRIMARY ↔ MARK_PRIMARY boundaries.
|
||||
pub const ZONE_FRICTION: f64 = 0.03;
|
||||
|
||||
/// Exchange rate adjustment rate per tick: how strongly net cross-zone
|
||||
/// flow imbalance moves the Tractus/Mark rate.
|
||||
const ALPHA_FX: f64 = 0.002;
|
||||
|
||||
/// Exchange rate bounds (D-171): hard clamp to prevent runaway divergence.
|
||||
const FX_RATE_MIN: f64 = 0.5;
|
||||
const FX_RATE_MAX: f64 = 2.0;
|
||||
|
||||
/// Maximum shadow economy intensity for dead-end topology bonus.
|
||||
const DEAD_END_SHADOW_BONUS: f64 = 0.10;
|
||||
|
||||
/// Shadow economy noise standard deviation (log-normal jitter per node).
|
||||
const SHADOW_NOISE_SIGMA: f64 = 0.08;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shadow economy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-node shadow economy intensity (0.0–1.0).
|
||||
///
|
||||
/// Seeds from: political zone, hop distance, gate topology, currency zone.
|
||||
/// Reference bands (D-174): core ~0.0–0.2, mid-reach ~0.3–0.6, frontier ~0.6–0.9.
|
||||
pub struct ShadowEconomy {
|
||||
/// system_id → shadow intensity [0.0, 1.0]
|
||||
pub intensity: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
pub fn seed_shadow_economy(economy: &Economy, run_seed: u64) -> ShadowEconomy {
|
||||
let mut intensity = BTreeMap::new();
|
||||
|
||||
for (system_id, sys) in &economy.systems {
|
||||
// Base from hop distance: clamp to [0.0, 0.6] range
|
||||
let hop_base = (sys.hop_distance as f64 / 15.0).clamp(0.0, 0.6);
|
||||
|
||||
// Political zone modifier
|
||||
let zone_mod = match sys.political_zone.as_deref() {
|
||||
Some("institutional_core") => -0.25,
|
||||
Some("earth_sphere") | Some("diplomatic_periphery") => -0.15,
|
||||
Some("commercial_mid_reach") | Some("commercial_periphery") => 0.0,
|
||||
Some("research_periphery") => 0.05,
|
||||
Some("contested_frontier") | Some("deep_reach_isolate") => 0.15,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
// Gate topology: dead-end systems are harder to police
|
||||
let topology_mod = if sys.gate_topology.as_deref() == Some("dead_end") {
|
||||
DEAD_END_SHADOW_BONUS
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Currency zone: Compact friction drives principled shadow economy
|
||||
let currency_mod = if sys.currency_zone == "MARK_PRIMARY" {
|
||||
0.20
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base = (hop_base + zone_mod + topology_mod + currency_mod).clamp(0.0, 0.95);
|
||||
|
||||
// Per-node PRNG jitter (Box-Muller)
|
||||
let node_seed = derive_seed(run_seed, system_id);
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(node_seed);
|
||||
let noise = standard_normal(&mut rng) * SHADOW_NOISE_SIGMA;
|
||||
|
||||
let final_intensity = (base + noise).clamp(0.0, 1.0);
|
||||
intensity.insert(system_id.clone(), final_intensity);
|
||||
}
|
||||
|
||||
ShadowEconomy { intensity }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exchange rate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mutable exchange rate state updated each tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CurrencyState {
|
||||
/// Tractus/Mark rate: how many Marks 1 Tractus buys.
|
||||
/// 1.0 = parity. >1.0 = Tractus stronger (Mark depreciated).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Net cross-zone Tractus→Mark commodity flow accumulated this tick.
|
||||
/// Positive = Tractus zone exporting to Mark zone (Mark zone demand >).
|
||||
pub net_cross_zone_flow: f64,
|
||||
}
|
||||
|
||||
impl CurrencyState {
|
||||
pub fn new() -> Self {
|
||||
CurrencyState {
|
||||
tractus_mark_rate: 1.0,
|
||||
net_cross_zone_flow: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust exchange rate from net cross-zone trade imbalance.
|
||||
///
|
||||
/// If Tractus zone exports more than it imports from the Mark zone,
|
||||
/// demand for Tractus rises → Tractus appreciates (rate increases).
|
||||
pub fn update_rate(&mut self) {
|
||||
// Positive net flow (Tractus→Mark) → Tractus stronger → rate rises
|
||||
let adjustment = ALPHA_FX * self.net_cross_zone_flow;
|
||||
self.tractus_mark_rate =
|
||||
(self.tractus_mark_rate + adjustment).clamp(FX_RATE_MIN, FX_RATE_MAX);
|
||||
self.net_cross_zone_flow = 0.0; // reset accumulator for next tick
|
||||
}
|
||||
|
||||
/// Transport cost factor from `from_zone` to `to_zone`.
|
||||
///
|
||||
/// Cross-zone (TRACTUS ↔ MARK) incurs an additional 3% friction.
|
||||
/// Sol (GJ 0, MIXED) neither adds nor removes friction.
|
||||
pub fn zone_friction_factor(&self, from_zone: &str, to_zone: &str) -> f64 {
|
||||
let cross_zone = (from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY")
|
||||
|| (from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY");
|
||||
if cross_zone {
|
||||
ZONE_FRICTION
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//! econ-sim: Settled Reach economics simulation binary.
|
||||
//!
|
||||
//! Layer 1: Leontief production + consumption + price adjustment.
|
||||
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
|
||||
//! Layer 3 (corporate behavioral agents) added in #809.
|
||||
//!
|
||||
//! Usage:
|
||||
//! econ-sim [--db path/to/systems.db] [--ticks 100] [--seed 0] [--output out.csv]
|
||||
//! econ-sim --stability-check # D-179 Tests 1 and 2
|
||||
//!
|
||||
//! Output: CSV with columns: node_id, commodity_id, supply, demand, price, tick
|
||||
//!
|
||||
//! Reference decisions: D-176 (productivity seeding), D-177 (constraints),
|
||||
//! D-178 (model architecture), D-179 (stability criteria), D-180 (event port)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
mod agents;
|
||||
mod currency;
|
||||
mod db;
|
||||
mod model;
|
||||
mod output;
|
||||
mod prng;
|
||||
mod seed;
|
||||
mod trade;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "econ-sim",
|
||||
about = "Settled Reach economics simulation — Layer 1 Leontief production"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to systems.db (default: auto-detect from working directory)
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
|
||||
/// Number of ticks to simulate
|
||||
#[arg(long, default_value_t = 100)]
|
||||
ticks: u32,
|
||||
|
||||
/// PRNG seed for productivity randomization (D-176)
|
||||
#[arg(long, default_value_t = 0)]
|
||||
seed: u64,
|
||||
|
||||
/// Output CSV file (default: stdout)
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Run stability checks (scaffolded here — exercised in #807 when trade flows added)
|
||||
#[arg(long)]
|
||||
stability_check: bool,
|
||||
|
||||
/// Comma-separated list of system IDs to simulate (default: all active nodes)
|
||||
#[arg(long)]
|
||||
systems: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// --- Load ---
|
||||
let db_path = db::resolve_db_path(cli.db);
|
||||
eprintln!("Loading economy data from {}...", db_path.display());
|
||||
let conn = db::open_db(&db_path);
|
||||
let economy = db::load_economy(&conn);
|
||||
let active_node_count = economy
|
||||
.systems
|
||||
.values()
|
||||
.filter(|s| economy.presences_by_system.contains_key(&s.system_id) || s.population > 0)
|
||||
.count();
|
||||
eprintln!(
|
||||
" {} commodities, {} production chains, {} active nodes, {} corp presences, {} gate links",
|
||||
economy.commodities.len(),
|
||||
economy.chains.len(),
|
||||
active_node_count,
|
||||
economy.corp_presences.len(),
|
||||
economy.gate_links.len(),
|
||||
);
|
||||
|
||||
// --- Seed ---
|
||||
eprintln!(
|
||||
"Seeding per-corporation productivity (run seed: {})...",
|
||||
cli.seed
|
||||
);
|
||||
let productivity = seed::seed_all_productivity(&economy, cli.seed);
|
||||
eprintln!(
|
||||
" {} corp×site productivity records seeded",
|
||||
productivity.len()
|
||||
);
|
||||
|
||||
// --- Behavioral archetypes ---
|
||||
let archetype_map = agents::build_archetype_map(economy.corp_archetype_data.clone());
|
||||
eprintln!(
|
||||
" {} corporation behavioral archetypes loaded (inferred where not set in DB)",
|
||||
archetype_map.len()
|
||||
);
|
||||
|
||||
// --- Gate adjacency ---
|
||||
let adjacency = trade::build_adjacency(&economy);
|
||||
eprintln!(" {} nodes with gate connections", adjacency.len(),);
|
||||
|
||||
// --- Shadow economy seeding ---
|
||||
eprintln!("Seeding per-node shadow economy intensity (D-174)...");
|
||||
let shadow = currency::seed_shadow_economy(&economy, cli.seed);
|
||||
let shadow_mean = if shadow.intensity.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
shadow.intensity.values().sum::<f64>() / shadow.intensity.len() as f64
|
||||
};
|
||||
eprintln!(
|
||||
" {} nodes seeded, mean intensity {:.2}",
|
||||
shadow.intensity.len(),
|
||||
shadow_mean
|
||||
);
|
||||
|
||||
if cli.stability_check {
|
||||
run_stability_checks(&economy, &productivity, &shadow, &adjacency);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Simulate ---
|
||||
eprintln!("Running {} ticks of Layer 1+2 simulation...", cli.ticks);
|
||||
let snapshots = model::run(&economy, &productivity, &shadow, &adjacency, cli.ticks);
|
||||
eprintln!(" {} output records generated", snapshots.len());
|
||||
|
||||
// --- Output ---
|
||||
output::write_csv(&snapshots, cli.output.as_deref()).unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to write output: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
if cli.output.is_some() {
|
||||
eprintln!(
|
||||
"Done. Written to {}",
|
||||
cli.output.as_deref().unwrap().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D-179 Stability Checks (Tests 1 and 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run D-179 stability tests and exit 0 on pass, 1 on failure.
|
||||
///
|
||||
/// Test 1 — Cold-start convergence: prices within ±5% of long-run
|
||||
/// equilibrium at tick 100.
|
||||
///
|
||||
/// Test 2 — Long-run stability: zero drift > ±2% over ticks 900–999.
|
||||
/// Equilibrium is defined as the mean price over ticks 900–999.
|
||||
///
|
||||
/// Test 3 — Shock response: inject a demand shock on one node at tick 200,
|
||||
/// verify prices recover within 200 ticks, no price explosions (>20×base).
|
||||
///
|
||||
/// Test 4 — Cross-zone balance: skipped if no MARK_PRIMARY systems exist.
|
||||
/// Otherwise: after a cross-zone trade imbalance is induced, exchange rate
|
||||
/// must re-stabilize (±2% variance) within 50 ticks.
|
||||
fn run_stability_checks(
|
||||
economy: &db::Economy,
|
||||
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
|
||||
shadow: ¤cy::ShadowEconomy,
|
||||
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
) {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const CHECK_TICKS: u32 = 1_000;
|
||||
const CONVERGENCE_TICK: u32 = 100;
|
||||
const STABILITY_START: u32 = 900;
|
||||
const CONVERGENCE_THRESHOLD: f64 = 0.05; // ±5%
|
||||
const STABILITY_THRESHOLD: f64 = 0.02; // ±2%
|
||||
|
||||
eprintln!("Running D-179 stability checks ({CHECK_TICKS} ticks)...");
|
||||
let records = model::run(economy, productivity, shadow, adjacency, CHECK_TICKS);
|
||||
|
||||
// Index records by (node_id, commodity_id) → Vec<(tick, price)>
|
||||
let mut by_key: BTreeMap<(String, String), Vec<(u32, f64)>> = BTreeMap::new();
|
||||
for r in &records {
|
||||
by_key
|
||||
.entry((r.node_id.clone(), r.commodity_id.clone()))
|
||||
.or_default()
|
||||
.push((r.tick, r.price));
|
||||
}
|
||||
|
||||
// Compute per-key equilibrium = mean price over ticks 900–999
|
||||
let mut equilibria: BTreeMap<(String, String), f64> = BTreeMap::new();
|
||||
for (key, ticks) in &by_key {
|
||||
let late: Vec<f64> = ticks
|
||||
.iter()
|
||||
.filter(|(t, _)| *t >= STABILITY_START)
|
||||
.map(|(_, p)| *p)
|
||||
.collect();
|
||||
if late.is_empty() {
|
||||
continue;
|
||||
}
|
||||
equilibria.insert(key.clone(), late.iter().sum::<f64>() / late.len() as f64);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 1: cold-start convergence
|
||||
// -----------------------------------------------------------------
|
||||
let mut test1_pass = true;
|
||||
let mut test1_max_dev: f64 = 0.0;
|
||||
let mut test1_worst: Option<(String, String)> = None;
|
||||
|
||||
for (key, eq) in &equilibria {
|
||||
if *eq < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = by_key.get(key) {
|
||||
if let Some((_, price_at_100)) = entry.iter().find(|(t, _)| *t == CONVERGENCE_TICK) {
|
||||
let dev = (price_at_100 - eq).abs() / eq;
|
||||
if dev > test1_max_dev {
|
||||
test1_max_dev = dev;
|
||||
test1_worst = Some((key.0.clone(), key.1.clone()));
|
||||
}
|
||||
if dev > CONVERGENCE_THRESHOLD {
|
||||
test1_pass = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 2: long-run stability
|
||||
// -----------------------------------------------------------------
|
||||
let mut test2_pass = true;
|
||||
let mut test2_max_dev: f64 = 0.0;
|
||||
let mut test2_worst: Option<(String, String)> = None;
|
||||
|
||||
for (key, eq) in &equilibria {
|
||||
if *eq < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some(ticks) = by_key.get(key) {
|
||||
for (t, price) in ticks {
|
||||
if *t < STABILITY_START {
|
||||
continue;
|
||||
}
|
||||
let dev = (price - eq).abs() / eq;
|
||||
if dev > test2_max_dev {
|
||||
test2_max_dev = dev;
|
||||
test2_worst = Some((key.0.clone(), key.1.clone()));
|
||||
}
|
||||
if dev > STABILITY_THRESHOLD {
|
||||
test2_pass = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 3: no-explosion check (price bounds over 1000-tick run)
|
||||
// Note: this is NOT a D-179 shock injection test. Full shock-response
|
||||
// testing (inject → cascade → recovery) requires D-180 event port.
|
||||
// -----------------------------------------------------------------
|
||||
let (test3_pass, test3_note) = run_no_explosion_check(economy, &records);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 4: cross-zone balance (skip if no MARK_PRIMARY systems)
|
||||
// -----------------------------------------------------------------
|
||||
let has_mark_zone = economy
|
||||
.systems
|
||||
.values()
|
||||
.any(|s| s.currency_zone == "MARK_PRIMARY");
|
||||
|
||||
let (test4_pass, test4_note) = if has_mark_zone {
|
||||
run_cross_zone_test(economy, productivity, shadow, adjacency)
|
||||
} else {
|
||||
(
|
||||
true,
|
||||
"SKIP — no MARK_PRIMARY systems in DB; re-run after Compact zone data is authored"
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Report
|
||||
// -----------------------------------------------------------------
|
||||
let sym = |p: bool| if p { "PASS" } else { "FAIL" };
|
||||
eprintln!(
|
||||
"Test 1 (cold-start convergence ±5% at tick {CONVERGENCE_TICK}): {} max_dev={:.2}%{}",
|
||||
sym(test1_pass),
|
||||
test1_max_dev * 100.0,
|
||||
test1_worst
|
||||
.as_ref()
|
||||
.map(|(n, c)| format!(" worst: {n}/{c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
eprintln!(
|
||||
"Test 2 (long-run stability ±2% over ticks {STABILITY_START}–999): {} max_dev={:.2}%{}",
|
||||
sym(test2_pass),
|
||||
test2_max_dev * 100.0,
|
||||
test2_worst
|
||||
.as_ref()
|
||||
.map(|(n, c)| format!(" worst: {n}/{c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
eprintln!(
|
||||
"Test 3 (no-explosion check — price bounds over 1000 ticks): {} {}",
|
||||
sym(test3_pass),
|
||||
test3_note
|
||||
);
|
||||
eprintln!(
|
||||
"Test 4 (cross-zone balance re-stabilizes ≤50 ticks): {} {}",
|
||||
sym(test4_pass),
|
||||
test4_note
|
||||
);
|
||||
|
||||
let all_pass = test1_pass && test2_pass && test3_pass && test4_pass;
|
||||
if all_pass {
|
||||
eprintln!("All stability checks passed.");
|
||||
process::exit(0);
|
||||
} else {
|
||||
eprintln!("Stability check FAILED — see above.");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify no price explosions or negative prices in the 1000-tick run.
|
||||
///
|
||||
/// This is NOT a D-179 shock injection test. D-179 Test 3 requires deliberate
|
||||
/// shock injection via the D-180 event port, which is not yet implemented.
|
||||
/// This check validates the weaker property: the model does not produce
|
||||
/// unbounded prices (>20× base) or negative prices over 1000 ticks.
|
||||
fn run_no_explosion_check(
|
||||
economy: &db::Economy,
|
||||
records_1000: &[model::TickRecord],
|
||||
) -> (bool, String) {
|
||||
const PRICE_EXPLOSION_LIMIT: f64 = 20.0; // 20× base_price
|
||||
|
||||
// Check: no price > 20× base at any tick
|
||||
let mut explosion_detected = false;
|
||||
let mut explosion_worst = String::new();
|
||||
for r in records_1000 {
|
||||
let base = economy
|
||||
.commodity_map
|
||||
.get(&r.commodity_id)
|
||||
.map_or(1.0, |c| c.base_price);
|
||||
if r.price > base * PRICE_EXPLOSION_LIMIT {
|
||||
explosion_detected = true;
|
||||
explosion_worst = format!(
|
||||
"{}/{} price={:.1} base={:.1} ({:.0}×)",
|
||||
r.node_id,
|
||||
r.commodity_id,
|
||||
r.price,
|
||||
base,
|
||||
r.price / base
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if explosion_detected {
|
||||
return (false, format!("price explosion: {}", explosion_worst));
|
||||
}
|
||||
|
||||
// Check: no negative prices (should be clamped by model, verify here)
|
||||
if let Some(r) = records_1000.iter().find(|r| r.price < 0.0) {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
"{}/{} price went negative: {}",
|
||||
r.node_id, r.commodity_id, r.price
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
true,
|
||||
format!(
|
||||
"no explosions (>{:.0}× base), no negatives across {} records",
|
||||
PRICE_EXPLOSION_LIMIT,
|
||||
records_1000.len()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Test 4: cross-zone exchange rate stabilizes within 50 ticks.
|
||||
///
|
||||
/// Only runs when MARK_PRIMARY systems exist.
|
||||
fn run_cross_zone_test(
|
||||
economy: &db::Economy,
|
||||
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
|
||||
shadow: ¤cy::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
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
|
||||
//!
|
||||
//! Goods flow along direct gate links when price differentials exceed
|
||||
//! transport costs. Multi-hop propagation occurs over multiple ticks as
|
||||
//! direct-neighbor flows compound. β=0.4 dampens flows to prevent cobweb
|
||||
//! oscillation.
|
||||
//!
|
||||
//! Currency zone friction (D-172): cross-zone (TRACTUS ↔ MARK) trade incurs
|
||||
//! an additional 3% cost. Net cross-zone flow drives the floating exchange
|
||||
//! rate adjustment (D-171).
|
||||
//!
|
||||
//! Gate links are bidirectional in the DB; `build_adjacency` builds the
|
||||
//! full adjacency map directly from them.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::currency::CurrencyState;
|
||||
use crate::db::Economy;
|
||||
use crate::model::NodeState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (D-178)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Transport cost per gate hop (midpoint of 5–12% range from D-178).
|
||||
const GATE_COST_PER_HOP: f64 = 0.08;
|
||||
|
||||
/// Damping factor β (D-178): fraction of potential flow that actually moves
|
||||
/// per tick. Prevents cobweb oscillation.
|
||||
const BETA: f64 = 0.4;
|
||||
|
||||
/// Maximum fraction of a node's stockpile exported per tick via a single link.
|
||||
/// Limits shock propagation speed.
|
||||
const MAX_EXPORT_FRACTION: f64 = 0.15;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adjacency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a direct-neighbor map from the gate link list.
|
||||
///
|
||||
/// DB stores links bidirectionally (A→B and B→A both present), so we
|
||||
/// collect them as-is without adding reverse edges. The resulting map
|
||||
/// covers all active market nodes that have at least one gate connection.
|
||||
pub fn build_adjacency(economy: &Economy) -> BTreeMap<String, Vec<String>> {
|
||||
let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for link in &economy.gate_links {
|
||||
adj.entry(link.from_system_id.clone())
|
||||
.or_default()
|
||||
.push(link.to_system_id.clone());
|
||||
}
|
||||
adj
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trade step
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply one tick of inter-node trade flows along direct gate links.
|
||||
///
|
||||
/// For each directed gate link (A → B): if the price of a commodity in A,
|
||||
/// after paying transport and currency costs, is still below the price in B,
|
||||
/// goods flow from A to B. Cross-zone (TRACTUS ↔ MARK) links incur an
|
||||
/// additional 3% conversion friction (D-172).
|
||||
///
|
||||
/// Net cross-zone flow is accumulated in `currency` to drive exchange rate
|
||||
/// adjustment each tick (D-171).
|
||||
///
|
||||
/// All flows are computed from the pre-step state and applied atomically
|
||||
/// to avoid order-dependent artifacts.
|
||||
pub fn trade_step(
|
||||
economy: &Economy,
|
||||
nodes: &mut BTreeMap<String, NodeState>,
|
||||
adjacency: &BTreeMap<String, Vec<String>>,
|
||||
currency: &mut CurrencyState,
|
||||
) {
|
||||
// Collect pending flows before mutating (snapshot prices/stockpiles first)
|
||||
// (from_system, to_system, commodity_id, amount, cross_zone_tractus_to_mark)
|
||||
let mut flows: Vec<(String, String, String, f64, f64)> = Vec::new();
|
||||
|
||||
for (from_id, neighbors) in adjacency {
|
||||
let from_node = match nodes.get(from_id.as_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let from_zone = economy
|
||||
.systems
|
||||
.get(from_id.as_str())
|
||||
.map(|s| s.currency_zone.as_str())
|
||||
.unwrap_or("TRACTUS_PRIMARY");
|
||||
|
||||
for to_id in neighbors {
|
||||
let to_node = match nodes.get(to_id.as_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let to_zone = economy
|
||||
.systems
|
||||
.get(to_id.as_str())
|
||||
.map(|s| s.currency_zone.as_str())
|
||||
.unwrap_or("TRACTUS_PRIMARY");
|
||||
|
||||
let gate_cost = 1.0 + GATE_COST_PER_HOP;
|
||||
// zone_cost is a raw fraction (0.0 or 0.03); combine multiplicatively
|
||||
let zone_cost = currency.zone_friction_factor(from_zone, to_zone);
|
||||
let cost_factor = gate_cost * (1.0 + zone_cost);
|
||||
|
||||
// Sign: positive = Tractus zone exporting to Mark zone
|
||||
let cross_zone_sign = if from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY" {
|
||||
1.0_f64
|
||||
} else if from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY" {
|
||||
-1.0_f64
|
||||
} else {
|
||||
0.0_f64
|
||||
};
|
||||
|
||||
for (commodity_id, from_state) in &from_node.commodities {
|
||||
let to_state = match to_node.commodities.get(commodity_id) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Only trade if profitable after full cost
|
||||
let effective_price = from_state.price * cost_factor;
|
||||
if effective_price >= to_state.price {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalised price differential ∈ (0, 1) drives flow magnitude
|
||||
let price_ratio = (to_state.price - effective_price) / to_state.price;
|
||||
|
||||
// Damped flow capped at MAX_EXPORT_FRACTION of exporter's stockpile
|
||||
let max_export = from_state.stockpile * MAX_EXPORT_FRACTION;
|
||||
let flow = BETA * price_ratio * max_export;
|
||||
|
||||
if flow > 1e-6 {
|
||||
flows.push((
|
||||
from_id.clone(),
|
||||
to_id.clone(),
|
||||
commodity_id.clone(),
|
||||
flow,
|
||||
cross_zone_sign * flow,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply flows and accumulate cross-zone net flow for exchange rate
|
||||
for (from_id, to_id, commodity_id, amount, cross_zone_contrib) in flows {
|
||||
if let Some(from_node) = nodes.get_mut(&from_id) {
|
||||
if let Some(state) = from_node.commodities.get_mut(&commodity_id) {
|
||||
state.stockpile = (state.stockpile - amount).max(0.0);
|
||||
}
|
||||
}
|
||||
if let Some(to_node) = nodes.get_mut(&to_id) {
|
||||
if let Some(state) = to_node.commodities.get_mut(&commodity_id) {
|
||||
state.stockpile += amount;
|
||||
}
|
||||
}
|
||||
currency.net_cross_zone_flow += cross_zone_contrib;
|
||||
}
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
Executable
+23
@@ -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" "$@"
|
||||
Reference in New Issue
Block a user