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
+46 -38
View File
@@ -534,9 +534,21 @@ fn validate_coverage(
commodity_ids: &BTreeSet<String>,
conn: &Connection,
) -> CoverageReport {
// Track which commodities have at least one producer
// Only track commodities that appear in at least one lore archetype's
// commodity lists. Chain intermediates (e.g. fusion_fuel, lattice_substrate)
// are implicitly covered through production chains, not direct assignment.
let archetype_commodities: BTreeSet<String> = lore_archetypes
.values()
.flat_map(|a| {
a.primary_commodities
.iter()
.chain(a.secondary_commodities.iter())
.cloned()
})
.collect();
let mut commodity_coverage: BTreeMap<String, usize> = BTreeMap::new();
for cid in commodity_ids {
for cid in commodity_ids.intersection(&archetype_commodities) {
commodity_coverage.insert(cid.clone(), 0);
}
@@ -559,8 +571,8 @@ fn validate_coverage(
*system_coverage.entry(corp.system_id.clone()).or_insert(0) += 1;
}
// Find uncovered commodities (services excluded from Tier-3 production coverage
// since they're produced by the archetype presence, not physical production)
// Find uncovered commodities — only archetype-assignable ones are checked.
// Chain intermediates not in any archetype are excluded (see filter above).
let uncovered_commodities: Vec<String> = commodity_coverage
.iter()
.filter(|(_, &count)| count < 3)
@@ -706,7 +718,7 @@ fn fill_coverage_gaps(
&lore.typical_sectors[rng.random_range(0..lore.typical_sectors.len())]
};
// Find a populated body in this sector
// Find populated bodies in this sector (deterministic ordering)
let mut stmt = conn
.prepare(
"SELECT b.body_id, b.system_id
@@ -714,50 +726,40 @@ fn fill_coverage_gaps(
JOIN star_systems ss ON b.system_id = ss.system_id
WHERE b.inhabited = 1 AND b.population > 1000
AND ss.geographic_sector = ?1
ORDER BY RANDOM() LIMIT 1",
ORDER BY b.body_id",
)
.unwrap();
let loc: Option<(String, String)> = stmt
let candidates: Vec<(String, String)> = stmt
.query_map(rusqlite::params![sector], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.filter_map(|r| r.ok())
.next();
.collect();
if let Some((loc_id, sys_id)) = loc {
let behavioral = pick_behavioral_archetype(rng, &lore.behavioral_affinity);
let name = names::generate_name(rng, sector, &lore.category);
let corp_id = make_corp_id(&name);
if candidates.is_empty() {
continue;
}
// Generate enough to reach the 3-corp minimum
let current_count = report.commodity_coverage.get(com_id).copied().unwrap_or(0);
for _ in current_count..3 {
let n = names::generate_name(rng, sector, &lore.category);
let cid = make_corp_id(&n);
generated.entry(cid).or_insert_with(|| GeneratedCorp {
proper_name: n,
lore_archetype: arch_id.to_string(),
behavioral_archetype: pick_behavioral_archetype(
rng,
&lore.behavioral_affinity,
),
location_id: loc_id.clone(),
location_type: "body".to_string(),
system_id: sys_id.clone(),
geographic_sector: sector.to_string(),
brands: vec![],
});
}
let (loc_id, sys_id) =
&candidates[rng.random_range(0..candidates.len())];
generated.entry(corp_id).or_insert_with(|| GeneratedCorp {
proper_name: name,
// Generate exactly enough to reach the 3-corp minimum
let current_count = report.commodity_coverage.get(com_id).copied().unwrap_or(0);
for _ in current_count..3 {
let n = names::generate_name(rng, sector, &lore.category);
let cid = make_corp_id(&n);
generated.entry(cid).or_insert_with(|| GeneratedCorp {
proper_name: n,
lore_archetype: arch_id.to_string(),
behavioral_archetype: behavioral,
location_id: loc_id,
behavioral_archetype: pick_behavioral_archetype(
rng,
&lore.behavioral_affinity,
),
location_id: loc_id.clone(),
location_type: "body".to_string(),
system_id: sys_id,
system_id: sys_id.clone(),
geographic_sector: sector.to_string(),
brands: vec![],
});
@@ -960,12 +962,16 @@ fn main() {
println!(" {}: {}", sector, count);
}
// Per D-175: both coverage rules are Phase 2 gate conditions — hard errors
let mut coverage_failed = false;
if !report.uncovered_commodities.is_empty() {
eprintln!(
"\n WARNING: {} commodities below 3-corp minimum: {:?}",
"\n ERROR: {} commodities below 3-corp minimum: {:?}",
report.uncovered_commodities.len(),
report.uncovered_commodities
);
coverage_failed = true;
}
if !report.uncovered_systems.is_empty() {
eprintln!(
@@ -973,7 +979,9 @@ fn main() {
report.uncovered_systems.len(),
&report.uncovered_systems[..report.uncovered_systems.len().min(10)]
);
// Per D-175: coverage validation failures are hard errors
coverage_failed = true;
}
if coverage_failed {
process::exit(1);
}