fix(simulation): address PR #122 review — determinism, correctness, labeling

- HashMap → BTreeMap throughout econ-sim for deterministic iteration (D-010)
- Fix cost_factor: multiplicative gate×zone instead of additive (trade.rs)
- Extract derive_seed to shared prng.rs, consolidate FNV-1a implementation
- Rename run_shock_test → run_no_explosion_check (not D-179 Test 3)
- Deduplicate cross-zone FX rate collection in Test 4
- Replace ORDER BY RANDOM() with deterministic ordering + ChaCha8Rng
- Make commodity coverage failure a hard error consistent with D-175
- Fix gap-fill off-by-one (4 corps → 3 when coverage = 0)
- Correct test report: EconEvent exists, location_type is body/station

All four D-179 stability tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 13:51:23 +02:00
co-authored by Claude Opus 4.6
parent dcd1c1cf4e
commit d45cfe0fa3
10 changed files with 241 additions and 201 deletions
+21 -20
View File
@@ -9,7 +9,7 @@
//!
//! Reference: D-178 (Economic Model Architecture)
use std::collections::HashMap;
use std::collections::BTreeMap;
use crate::agents;
use crate::currency::{CurrencyState, ShadowEconomy};
@@ -54,7 +54,7 @@ pub struct CommodityState {
pub struct NodeState {
pub system_id: String,
/// commodity_id → state
pub commodities: HashMap<String, CommodityState>,
pub commodities: BTreeMap<String, CommodityState>,
}
// ---------------------------------------------------------------------------
@@ -89,9 +89,9 @@ pub struct TickRecord {
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
pub fn run(
economy: &Economy,
productivity: &HashMap<(String, String), Productivity>,
productivity: &BTreeMap<(String, String), Productivity>,
shadow: &ShadowEconomy,
adjacency: &HashMap<String, Vec<String>>,
adjacency: &BTreeMap<String, Vec<String>>,
ticks: u32,
) -> Vec<TickRecord> {
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
@@ -106,7 +106,11 @@ pub fn run(
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);
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,
@@ -129,8 +133,8 @@ pub fn run(
// Initialization
// ---------------------------------------------------------------------------
fn init_nodes(economy: &Economy) -> HashMap<String, NodeState> {
let mut nodes: HashMap<String, NodeState> = HashMap::new();
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 {
@@ -140,7 +144,7 @@ fn init_nodes(economy: &Economy) -> HashMap<String, NodeState> {
continue;
}
let mut commodity_states: HashMap<String, CommodityState> = HashMap::new();
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);
@@ -197,10 +201,10 @@ const SHADOW_DEMAND_COVERAGE: f64 = 0.30;
fn step(
economy: &Economy,
productivity: &HashMap<(String, String), Productivity>,
productivity: &BTreeMap<(String, String), Productivity>,
shadow: &ShadowEconomy,
archetypes: &HashMap<String, agents::Archetype>,
nodes: &mut HashMap<String, NodeState>,
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();
@@ -227,8 +231,7 @@ fn step(
.unwrap_or_default();
for corp_presence in &corps {
let prod = match productivity.get(&(corp_presence.corp_id.clone(), system_id.clone()))
{
let prod = match productivity.get(&(corp_presence.corp_id.clone(), system_id.clone())) {
Some(p) => p,
None => continue,
};
@@ -277,9 +280,8 @@ fn step(
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);
capacity_fraction =
capacity_fraction.min(available / required).clamp(0.0, 1.0);
}
} else {
capacity_fraction = 0.0;
@@ -318,8 +320,7 @@ fn step(
.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);
state.price = (state.price + nudge).clamp(base_price * 0.05, base_price * 20.0);
}
}
}
@@ -381,8 +382,8 @@ fn step(
0.0
};
state.price = (state.price * (1.0 - ALPHA * excess))
.clamp(base_price * 0.05, base_price * 20.0);
state.price =
(state.price * (1.0 - ALPHA * excess)).clamp(base_price * 0.05, base_price * 20.0);
}
}
}