feat(engine): generate_brands pipeline — 10K minor brands from templates (#829)

New generate_brands binary reads 126 brand archetype templates
(wiki/economics/archetypes/brand_templates.toml), assigns halo+volume pairs
to all 48 hand-authored corps, outputs wiki/economics/corporations/generated_brands.toml.

Result: 10,000 brand_product rows, 26,750 brand_inputs, all 48 corps covered.
Brand structural validation V-B01..V-B06 passes. Generated file is gitignored
(regenerated on each `make economy-db` run).

make economy-db now runs generate-brands before import_economics.py.
import_economics.py merges generated_brands.toml alongside hand-authored brands.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 13:15:03 +02:00
co-authored by Claude Sonnet 4.6
parent bfeb7006c6
commit e11a9c308a
7 changed files with 1254 additions and 5 deletions
+2
View File
@@ -328,6 +328,8 @@ db-install:
@tooling/db-install
economy-db: ## Import economics data (commodities, chains, gate links) into systems.db
@echo " Generating minor brands (D-189 #829)..."
@tooling/generate-brands
@python3 tooling/economy-db/import_economics.py
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
@@ -0,0 +1,176 @@
---
title: "Bincode v1 → v2 Migration — Risk Audit (Sprint 36)"
description: "Audit of bincode usage in the server crate, risk assessment, and migration recommendation for ticket #636."
type: architecture
status: final
ticket: "#636"
author: "Tyre"
created: 2026-04-19
updated: 2026-04-19
---
# Bincode Migration — Risk Audit
**Ticket:** #636 (Migrate bincode v1.x to v2.x)
**Advisory:** RUSTSEC-2025-0141 (bincode v1.3.3 unmaintained)
**Author:** Tyre
**TL;DR:** **bincode is an orphan dependency — nothing in the server crate actually calls it. Remove it outright. The "migration" is a four-line change.**
---
## 1. What the audit found
Grep-audit of the **entire repository**, not just `server/src/`:
```bash
grep -rn "use bincode\|bincode::" server/ tests/ tooling/ --include="*.rs"
# → 0 hits
```
Cargo manifest references:
```bash
grep -rn "bincode" server/ --include="*.toml" --include="*.lock"
# → server/Cargo.toml:19 bincode = "1"
# → server/Cargo.lock:329 [[package]] name = "bincode" version = "1.3.3"
# → server/Cargo.lock:1311 " bincode"," — under settled-reach-server deps
# → server/audit.toml:6 ignore = ["RUSTSEC-2025-0141"]
```
No Rust source file in **any** crate (`server/`, `tests/`, `tooling/`) contains the string `bincode`. The lockfile entry under `tooling/test-client` shows bincode transiting through `rmp-serde` or a sibling — **not** from direct use.
**Conclusion:** `bincode = "1"` in `server/Cargo.toml` was added in anticipation of save-load / Rust-Rust sync (see `docs/workshops/v01-gap-analysis/round1-tyre.md`, `docs/workshops/save-load-architecture/workshop-brief.md`) but **the implementation path chose `rmp-serde` / MessagePack instead** (see `server/src/bridge/types.rs` — tests use `rmp_serde::to_vec_named` and `rmp_serde::from_slice`, line 1052 onward).
Bincode is a dead dependency.
## 2. Recommended migration: DELETE, don't bump
### 2.1 The actual changes
**server/Cargo.toml** — remove line 19:
```diff
-bincode = "1"
```
**server/audit.toml** — remove the ignore (lines 59):
```diff
-[advisories]
-# RUSTSEC-2025-0141: bincode v1.3.3 is unmaintained.
-# Migration to bincode v2 or an alternative is tracked in ticket #636.
-# This ignore can be removed once #636 is resolved.
-ignore = ["RUSTSEC-2025-0141"]
```
(If removing the whole `[advisories]` section leaves `audit.toml` empty, either delete the file or leave the file with just a header comment — check `.config/cargo-audit/` or the Makefile for how `cargo audit` is invoked.)
**server/Cargo.lock** — regenerate by running `cargo check` in `server/`. Verify `bincode` no longer appears.
### 2.2 Verification
```bash
# 1. No source regressions:
grep -rn "bincode" server/ tests/ tooling/ --include="*.rs"
# Expected: 0 hits.
# 2. Clean build:
cargo check --workspace --all-features
# 3. Clean tests:
cargo test --workspace
# 4. Audit is green without the ignore:
cargo audit
# Expected: no RUSTSEC-2025-0141 mention.
# 5. cargo-deny (once #726 lands):
cargo deny check
```
### 2.3 Risk assessment
| Risk | Likelihood | Impact | Mitigation |
|------------------------------------------------|------------|--------|------------|
| Hidden `use bincode` I missed | Near-zero | Build break | Covered by §2.2 step 1 grep + `cargo check` |
| Proc-macro or build.rs pulling bincode | Near-zero | Build break | No `build.rs` in server crate; no proc-macro deps use it |
| Transitive need (some crate depends on it) | Zero | N/A | Transitive deps come through lockfile without a manifest entry |
| Future save-load work expects it in manifest | Low | Re-add | If save-load lands with bincode later, re-add `bincode = "2"` then — fresh v2 install, no migration |
**All four risks are trivially mitigated. Net risk: ~0.**
---
## 3. If the team decides to keep bincode — the v1 → v2 cheat sheet
Included for completeness even though §2 is the recommendation. If save-load (#553, D-085) or a future Rust↔Rust server-sync feature decides to use bincode, adopt it fresh at v2 with these signature changes:
### 3.1 The core API difference
**v1 (current, unmaintained):**
```rust
// Relies on serde Serialize/Deserialize derives.
let bytes: Vec<u8> = bincode::serialize(&value)?;
let value: MyType = bincode::deserialize(&bytes)?;
```
**v2 (stable):**
```rust
// New "Encode"/"Decode" derives, explicit config.
use bincode::{config, encode_to_vec, decode_from_slice};
let cfg = config::standard();
let bytes: Vec<u8> = encode_to_vec(&value, cfg)?;
let (value, _used): (MyType, usize) = decode_from_slice(&bytes, cfg)?;
```
**Derive change:** v2 introduced its own `#[derive(bincode::Encode, bincode::Decode)]` traits. If the type must stay serde-compatible (required for us — we use `rmp-serde` and `ron` side-by-side), use the compat shim:
```rust
use bincode::serde::{encode_to_vec, decode_from_slice};
let bytes = encode_to_vec(&value, config::standard())?;
let (value, _) = decode_from_slice::<MyType, _>(&bytes, config::standard())?;
```
This keeps `#[derive(Serialize, Deserialize)]` as the only derives on the data types — no dual-derive required. That matters because the same types cross the MessagePack boundary via `rmp-serde`.
### 3.2 Config
v2 makes encoding config explicit. `config::standard()` uses variable-int, little-endian — matches v1 default for our types (no floats in the save shape today, so endian parity is not critical). For perfectly-byte-identical output to v1, use `config::legacy()`. **Any new adoption should use `config::standard()`** — don't inherit v1 quirks.
### 3.3 Known gotchas (for future reference)
- v2 does **not** auto-handle untagged serde enums in the compat layer (pre-v2.0.1); if we adopt it and hit an untagged enum, use `bincode::serde::Compat`.
- v2's `decode_from_slice` returns the byte count consumed — v1 silently ignored trailing bytes. Useful for streaming multi-message frames; irrelevant for one-shot save files.
- The `bincode::options()` builder from v1 (`with_fixint_encoding()` etc.) is gone — replaced by `config::Configuration`.
- Binary format is **not** compatible across v1 ↔ v2. Any v1-written blob is unreadable by v2. (This is moot for us — we have none.)
### 3.4 Touch points if we were actually migrating
None. Literally no source file imports or uses it.
---
## 4. For Dudley — execution checklist
1. Delete `bincode = "1"` from `server/Cargo.toml`.
2. Delete the `RUSTSEC-2025-0141` ignore block from `server/audit.toml`.
3. `cargo check --workspace` — regenerates `Cargo.lock`.
4. `cargo test --workspace` — must pass.
5. `cargo audit` — must not print RUSTSEC-2025-0141 anymore.
6. Commit:
```
fix(deps): remove unused bincode dependency (#636)
RUSTSEC-2025-0141 no longer relevant — bincode was declared but
never imported. Drop the crate and the audit ignore. Future
save-load work that wants bincode should adopt v2 fresh.
```
**Estimated effort:** ~15 minutes including verification.
## 5. What this means for docs
One doc to update: `docs/sprints/sprint-27/server.md` line 93 mentions the audit ignore. Either leave it (it's historical notes) or strike through. Not blocking.
---
**Audit status:** Complete. Recommendation: remove bincode entirely. If the team prefers "migrate now, don't remove" (symbolic commitment to the migration path), say the word and I'll spec that instead — but it costs more with zero benefit given the usage survey.
Binary file not shown.
+747
View File
@@ -0,0 +1,747 @@
//! Generate ~10,000 minor brand products for the Settled Reach economy.
//!
//! Reads brand archetype templates from `wiki/economics/archetypes/brand_templates.toml`,
//! queries `systems.db` for the 48 hand-authored Tier-1/2 corporations, then generates
//! minor brand products (halo + volume tier pairs) assigned to those corporations.
//!
//! Output: `wiki/economics/corporations/generated_brands.toml`
//! Format: [[brand_products]] and [[brand_inputs]] TOML arrays compatible with
//! `import_economics.py`'s `import_brands()` function.
//!
//! Each (corp, template) eligible pair produces 2 brand_product rows:
//! - halo tier — `{corp_id}-{archetype}-{scale}-halo`
//! - volume tier — `{corp_id}-{archetype}-{scale}-vol`
//!
//! Decision references: D-189 (brand layer architecture), D-190 (volume calibration)
//!
//! # Usage
//! ```sh
//! cargo run --bin generate_brands
//! cargo run --bin generate_brands -- --seed 42 --min-brands 10000
//! cargo run --bin generate_brands -- --db server/data/systems.db --output wiki/economics/corporations/generated_brands.toml
//! ```
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process;
use clap::Parser;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
mod names;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(
name = "generate_brands",
about = "Generate minor brand products (halo+volume pairs) from archetype templates"
)]
struct Cli {
/// Path to systems.db
#[arg(long)]
db: Option<PathBuf>,
/// Path to brand_templates.toml
#[arg(long)]
templates: Option<PathBuf>,
/// Output TOML path
#[arg(
long,
default_value = "wiki/economics/corporations/generated_brands.toml"
)]
output: PathBuf,
/// PRNG seed for deterministic generation
#[arg(long, default_value = "1")]
seed: u64,
/// Minimum brand_product rows to generate (approximate target)
#[arg(long, default_value = "10000")]
min_brands: usize,
}
// ---------------------------------------------------------------------------
// Brand template deserialization
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct CommodityInputTemplate {
commodity_id: String,
quantity_min: f64,
quantity_max: f64,
}
#[derive(Debug, Deserialize)]
struct BrandTemplate {
archetype_group: String,
brand_category: String,
scale_tier: String, // local | regional | reach_wide
naming_pattern: String,
commodity_inputs: Vec<CommodityInputTemplate>,
premium_range: [f64; 2],
scarcity_class: String,
value_trajectory: String,
#[serde(default)]
terroir_locked: bool,
}
// ---------------------------------------------------------------------------
// DB types
// ---------------------------------------------------------------------------
struct Corp {
corp_id: String,
scope: String,
headquarters_system: Option<String>,
geographic_sector: Option<String>,
currency_zone: Option<String>,
shadow_economy_access: bool,
}
// ---------------------------------------------------------------------------
// Output types
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct BrandProduct {
brand_product_id: String,
corp_id: String,
product_name: String,
brand_category: String,
value_trajectory: String,
scarcity_class: String,
#[serde(skip_serializing_if = "Option::is_none")]
product_subcategory: Option<String>,
base_premium_multiplier: f64,
premium_floor: f64,
#[serde(skip_serializing_if = "Option::is_none")]
origin_system: Option<String>,
terroir_locked: bool,
currency_denomination: String,
shadow_viable: bool,
brand_tier: String,
#[serde(skip_serializing_if = "Option::is_none")]
halo_brand_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct BrandInput {
brand_product_id: String,
commodity_id: String,
quantity: f64,
}
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
fn resolve_repo_root() -> PathBuf {
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
loop {
if dir.join("server").join("data").join("systems.db").exists() {
return dir;
}
if !dir.pop() {
break;
}
}
eprintln!("error: cannot find repo root (looking for server/data/systems.db)");
process::exit(1);
}
// ---------------------------------------------------------------------------
// Template loading
// ---------------------------------------------------------------------------
fn load_templates(path: &PathBuf) -> Vec<(String, BrandTemplate)> {
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("error: cannot read {}: {}", path.display(), e);
process::exit(1);
});
let raw: toml::Value = toml::from_str(&content).unwrap_or_else(|e| {
eprintln!("error: cannot parse {}: {}", path.display(), e);
process::exit(1);
});
let table = raw.as_table().unwrap_or_else(|| {
eprintln!("error: brand_templates.toml is not a TOML table");
process::exit(1);
});
let mut templates = Vec::new();
for (key, value) in table {
if value.is_table() {
// Skip comment-only keys (headers are usually bare strings, not tables)
match toml::Value::try_into::<BrandTemplate>(value.clone()) {
Ok(t) => templates.push((key.clone(), t)),
Err(e) => {
eprintln!("warning: skipping template {:?}: {}", key, e);
}
}
}
}
// Sort by key for deterministic ordering
templates.sort_by(|a, b| a.0.cmp(&b.0));
templates
}
// ---------------------------------------------------------------------------
// DB queries
// ---------------------------------------------------------------------------
fn load_corps(conn: &Connection) -> Vec<Corp> {
let mut stmt = conn
.prepare(
"SELECT co.corp_id, co.scope, co.headquarters_system,
ss.geographic_sector,
ss.currency_zone,
co.shadow_economy_access
FROM corporations co
LEFT JOIN star_systems ss ON co.headquarters_system = ss.system_id
ORDER BY co.corp_id",
)
.unwrap();
stmt.query_map([], |row| {
Ok(Corp {
corp_id: row.get(0)?,
scope: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
headquarters_system: row.get(2)?,
geographic_sector: row.get(3)?,
currency_zone: row.get(4)?,
shadow_economy_access: row.get::<_, i64>(5).unwrap_or(0) != 0,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
fn load_valid_commodity_ids(conn: &Connection) -> BTreeSet<String> {
let mut stmt = conn
.prepare("SELECT commodity_id FROM commodities")
.unwrap();
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
// ---------------------------------------------------------------------------
// Template-to-corridor parsing
// ---------------------------------------------------------------------------
/// Parse the corridor prefix from a template's naming_pattern field.
/// The pattern starts with "{corridor} — ..." or "{corridor}/{sub} — ...".
fn parse_template_corridor(naming_pattern: &str) -> &str {
if let Some(idx) = naming_pattern.find("") {
&naming_pattern[..idx]
} else if let Some(idx) = naming_pattern.find(" - ") {
&naming_pattern[..idx]
} else {
"reach_wide"
}
}
/// Determine whether a corp is eligible for a given template.
///
/// Matching rules:
/// - `reach_wide` scale templates: all corps eligible.
/// - `regional` or `local` templates with corridor `reach_wide`, `inner_corridor*`:
/// all corps eligible (the inner corridor is the Reach's trade hub).
/// - `regional` or `local` templates with specific corridor: reach-wide corps
/// (scope = "reach-wide") plus corps whose HQ geographic_sector matches.
fn corp_eligible(corp: &Corp, template_scale: &str, template_corridor: &str) -> bool {
// Reach-wide scale — no restriction
if template_scale == "reach_wide" {
return true;
}
// Neutral / inner-corridor templates — open to all
if matches!(
template_corridor,
"reach_wide" | "inner_corridor" | "inner_corridor/neutral"
) {
return true;
}
// Reach-wide corps carry everything
if corp.scope == "reach-wide" {
return true;
}
// Match sector to corridor
let sector = corp.geographic_sector.as_deref().unwrap_or("core");
let corridor_matches_sector = match template_corridor {
"north_reach" | "north_reach/compact" => sector == "north_reach",
"south_reach" => sector == "south_reach",
"west_reach" | "west_reach/compact" => sector == "west_reach",
"east_reach" | "inner_corridor/east_reach" => {
sector == "east_reach" || sector == "core"
}
"frontier" => sector == "deep_frontier",
_ => false,
};
corridor_matches_sector
}
// ---------------------------------------------------------------------------
// Brand ID / naming helpers
// ---------------------------------------------------------------------------
fn scale_abbrev(scale_tier: &str) -> &'static str {
match scale_tier {
"local" => "l",
"regional" => "r",
"reach_wide" => "rw",
_ => "x",
}
}
fn to_slug(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn archetype_slug(archetype_group: &str) -> String {
to_slug(archetype_group)
}
fn halo_id(corp_id: &str, archetype_group: &str, scale_tier: &str) -> String {
format!(
"{}-{}-{}-halo",
corp_id,
archetype_slug(archetype_group),
scale_abbrev(scale_tier)
)
}
fn volume_id(corp_id: &str, archetype_group: &str, scale_tier: &str) -> String {
format!(
"{}-{}-{}-vol",
corp_id,
archetype_slug(archetype_group),
scale_abbrev(scale_tier)
)
}
fn currency_for_corp(corp: &Corp) -> &'static str {
match corp.currency_zone.as_deref() {
Some(z) if z.contains("MARK") => "mark",
Some(z) if z.contains("SOL") => "sol_adjacent",
_ => "tractus",
}
}
// ---------------------------------------------------------------------------
// Brand generation
// ---------------------------------------------------------------------------
struct GeneratedPair {
halo: BrandProduct,
volume: BrandProduct,
inputs_halo: Vec<BrandInput>,
inputs_volume: Vec<BrandInput>,
}
fn generate_pair(
rng: &mut ChaCha8Rng,
corp: &Corp,
template_key: &str,
template: &BrandTemplate,
valid_commodities: &BTreeSet<String>,
) -> Option<GeneratedPair> {
let corridor = parse_template_corridor(&template.naming_pattern);
let halo_bid = halo_id(&corp.corp_id, &template.archetype_group, &template.scale_tier);
let vol_bid = volume_id(&corp.corp_id, &template.archetype_group, &template.scale_tier);
let halo_name = names::generate_halo_name(rng, corridor, &template.brand_category);
let vol_name = names::generate_volume_name(rng, corridor, &template.brand_category);
let base_premium = {
let lo = template.premium_range[0];
let hi = template.premium_range[1];
lo + rng.random::<f64>() * (hi - lo)
};
let premium_floor = base_premium * 0.45;
let origin_system = if template.terroir_locked {
corp.headquarters_system.clone()
} else {
None
};
let currency = currency_for_corp(corp).to_string();
let subcategory = Some(archetype_slug(&template.archetype_group));
let halo = BrandProduct {
brand_product_id: halo_bid.clone(),
corp_id: corp.corp_id.clone(),
product_name: halo_name,
brand_category: template.brand_category.clone(),
value_trajectory: template.value_trajectory.clone(),
scarcity_class: template.scarcity_class.clone(),
product_subcategory: subcategory.clone(),
base_premium_multiplier: round2(base_premium),
premium_floor: round2(premium_floor),
origin_system: origin_system.clone(),
terroir_locked: template.terroir_locked,
currency_denomination: currency.clone(),
shadow_viable: corp.shadow_economy_access,
brand_tier: "halo".to_string(),
halo_brand_id: None,
};
let vol_premium = base_premium * 0.35 + rng.random::<f64>() * (base_premium * 0.25);
let vol_floor = vol_premium * 0.30;
let volume = BrandProduct {
brand_product_id: vol_bid.clone(),
corp_id: corp.corp_id.clone(),
product_name: vol_name,
brand_category: template.brand_category.clone(),
value_trajectory: template.value_trajectory.clone(),
scarcity_class: downgrade_scarcity(&template.scarcity_class),
product_subcategory: subcategory,
base_premium_multiplier: round2(vol_premium),
premium_floor: round2(vol_floor),
origin_system: None,
terroir_locked: false,
currency_denomination: currency,
shadow_viable: corp.shadow_economy_access,
brand_tier: "volume".to_string(),
halo_brand_id: Some(halo_bid.clone()),
};
// Build inputs — filter to valid commodity IDs only
let inputs_halo: Vec<BrandInput> = template
.commodity_inputs
.iter()
.filter(|ci| valid_commodities.contains(&ci.commodity_id))
.map(|ci| {
let qty_range = ci.quantity_max - ci.quantity_min;
let qty = ci.quantity_min + rng.random::<f64>() * qty_range;
BrandInput {
brand_product_id: halo_bid.clone(),
commodity_id: ci.commodity_id.clone(),
quantity: round2(qty),
}
})
.collect();
// Halo brands without any valid commodity inputs fail V-B03 validation
if inputs_halo.is_empty() {
eprintln!(
"warning: template {:?} has no valid commodity inputs — skipping",
template_key
);
return None;
}
// Volume inputs: same commodities, 35× higher quantities
let vol_scale = 3.0 + rng.random::<f64>() * 2.0;
let inputs_volume: Vec<BrandInput> = inputs_halo
.iter()
.map(|i| BrandInput {
brand_product_id: vol_bid.clone(),
commodity_id: i.commodity_id.clone(),
quantity: round2(i.quantity * vol_scale),
})
.collect();
Some(GeneratedPair {
halo,
volume,
inputs_halo,
inputs_volume,
})
}
/// Volume tiers get a slightly less restrictive scarcity class.
fn downgrade_scarcity(scarcity: &str) -> String {
match scarcity {
"capped" => "constrained".to_string(),
"constrained" => "scalable".to_string(),
other => other.to_string(),
}
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
// ---------------------------------------------------------------------------
// Output serialization
// ---------------------------------------------------------------------------
fn write_output(
path: &PathBuf,
products: &[BrandProduct],
inputs: &[BrandInput],
) {
let mut out = String::new();
out.push_str("# Generated Minor Brand Products — The Settled Reach\n");
out.push_str("# Auto-generated by generate_brands binary. Do not hand-edit.\n");
out.push_str("# Re-run: tooling/generate-brands\n");
out.push_str(&format!("# Total: {} brand_products, {} brand_inputs\n\n", products.len(), inputs.len()));
for p in products {
out.push_str("[[brand_products]]\n");
out.push_str(&format!("brand_product_id = {:?}\n", p.brand_product_id));
out.push_str(&format!("corp_id = {:?}\n", p.corp_id));
out.push_str(&format!("product_name = {:?}\n", p.product_name));
out.push_str(&format!("brand_category = {:?}\n", p.brand_category));
out.push_str(&format!("value_trajectory = {:?}\n", p.value_trajectory));
out.push_str(&format!("scarcity_class = {:?}\n", p.scarcity_class));
if let Some(ref sub) = p.product_subcategory {
out.push_str(&format!("product_subcategory = {:?}\n", sub));
}
out.push_str(&format!(
"base_premium_multiplier = {:.2}\n",
p.base_premium_multiplier
));
out.push_str(&format!("premium_floor = {:.2}\n", p.premium_floor));
if let Some(ref sys) = p.origin_system {
out.push_str(&format!("origin_system = {:?}\n", sys));
}
out.push_str(&format!("terroir_locked = {}\n", p.terroir_locked));
out.push_str(&format!("currency_denomination = {:?}\n", p.currency_denomination));
out.push_str(&format!("shadow_viable = {}\n", p.shadow_viable));
out.push_str(&format!("brand_tier = {:?}\n", p.brand_tier));
if let Some(ref hid) = p.halo_brand_id {
out.push_str(&format!("halo_brand_id = {:?}\n", hid));
}
out.push('\n');
}
for i in inputs {
out.push_str("[[brand_inputs]]\n");
out.push_str(&format!("brand_product_id = {:?}\n", i.brand_product_id));
out.push_str(&format!("commodity_id = {:?}\n", i.commodity_id));
out.push_str(&format!("quantity = {:.2}\n", i.quantity));
out.push('\n');
}
std::fs::write(path, &out).unwrap_or_else(|e| {
eprintln!("error: cannot write {}: {}", path.display(), e);
process::exit(1);
});
}
// ---------------------------------------------------------------------------
// Coverage report
// ---------------------------------------------------------------------------
fn print_coverage(products: &[BrandProduct]) {
let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
let mut by_tier: BTreeMap<String, usize> = BTreeMap::new();
let mut by_corp: BTreeMap<String, usize> = BTreeMap::new();
for p in products {
*by_category.entry(p.brand_category.clone()).or_insert(0) += 1;
*by_tier.entry(p.brand_tier.clone()).or_insert(0) += 1;
*by_corp.entry(p.corp_id.clone()).or_insert(0) += 1;
}
println!("\n Coverage Report:");
println!(" Total brand_products: {}", products.len());
println!(" By category:");
for (cat, n) in &by_category {
println!(" {}: {}", cat, n);
}
println!(" By brand_tier:");
for (tier, n) in &by_tier {
println!(" {}: {}", tier, n);
}
let min_per_corp = by_corp.values().min().copied().unwrap_or(0);
let max_per_corp = by_corp.values().max().copied().unwrap_or(0);
println!(" Corps covered: {}", by_corp.len());
println!(" Brands per corp: min={} max={}", min_per_corp, max_per_corp);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() {
let cli = Cli::parse();
let repo_root = resolve_repo_root();
let db_path = cli
.db
.unwrap_or_else(|| repo_root.join("server").join("data").join("systems.db"));
let templates_path = cli
.templates
.unwrap_or_else(|| repo_root.join("wiki/economics/archetypes/brand_templates.toml"));
// Resolve output relative to repo root if it's a relative path
let output_path = if cli.output.is_relative() {
repo_root.join(&cli.output)
} else {
cli.output.clone()
};
println!("\n Minor Brand Generator");
println!(" DB: {}", db_path.display());
println!(" Templates: {}", templates_path.display());
println!(" Output: {}", output_path.display());
println!(" Seed: {}", cli.seed);
println!(" Target: {} brand_product rows", cli.min_brands);
println!();
// Load templates
println!(" [1/5] Loading brand archetype templates...");
let templates = load_templates(&templates_path);
println!(" {} templates loaded", templates.len());
// Open DB
let conn = Connection::open(&db_path).unwrap_or_else(|e| {
eprintln!("error: cannot open {}: {}", db_path.display(), e);
process::exit(1);
});
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.unwrap();
// Load corps and commodity IDs
println!(" [2/5] Loading corporations and commodities...");
let corps = load_corps(&conn);
let valid_commodities = load_valid_commodity_ids(&conn);
println!(" {} corps, {} valid commodities", corps.len(), valid_commodities.len());
// Generate brand pairs
println!(" [3/5] Generating brand pairs...");
let mut rng = ChaCha8Rng::seed_from_u64(cli.seed);
let mut products: Vec<BrandProduct> = Vec::new();
let mut inputs: Vec<BrandInput> = Vec::new();
let mut seen_ids: BTreeSet<String> = BTreeSet::new();
let mut skipped = 0usize;
for (template_key, template) in &templates {
for corp in &corps {
if !corp_eligible(corp, &template.scale_tier, parse_template_corridor(&template.naming_pattern)) {
continue;
}
let halo_bid = halo_id(&corp.corp_id, &template.archetype_group, &template.scale_tier);
let vol_bid = volume_id(&corp.corp_id, &template.archetype_group, &template.scale_tier);
// Skip if IDs already generated (name-collision guard)
if seen_ids.contains(&halo_bid) || seen_ids.contains(&vol_bid) {
skipped += 1;
continue;
}
if let Some(pair) = generate_pair(&mut rng, corp, template_key, template, &valid_commodities) {
seen_ids.insert(halo_bid);
seen_ids.insert(vol_bid);
products.push(pair.halo);
products.push(pair.volume);
inputs.extend(pair.inputs_halo);
inputs.extend(pair.inputs_volume);
}
}
}
println!(" {} brand_products generated ({} skipped)", products.len(), skipped);
// Gap-fill: if under target, add more by re-applying reach_wide templates
// with a counter suffix to avoid ID collisions.
if products.len() < cli.min_brands {
println!(" [4/5] Gap-fill to reach {} rows...", cli.min_brands);
let reach_wide_templates: Vec<&(String, BrandTemplate)> = templates
.iter()
.filter(|(_, t)| t.scale_tier == "reach_wide")
.collect();
let mut counter = 0usize;
let mut template_idx = 0usize;
while products.len() < cli.min_brands && !reach_wide_templates.is_empty() {
let (template_key, template) = reach_wide_templates[template_idx % reach_wide_templates.len()];
let corp = &corps[counter % corps.len()];
counter += 1;
template_idx += 1;
// Use a suffixed ID to avoid duplicates
let suffix = counter;
let halo_bid = format!(
"{}-{}-rw-halo-{}",
corp.corp_id,
archetype_slug(&template.archetype_group),
suffix
);
let vol_bid = format!(
"{}-{}-rw-vol-{}",
corp.corp_id,
archetype_slug(&template.archetype_group),
suffix
);
if seen_ids.contains(&halo_bid) {
continue;
}
if let Some(pair) = generate_pair(&mut rng, corp, template_key, template, &valid_commodities) {
let mut halo = pair.halo;
let mut volume = pair.volume;
halo.brand_product_id = halo_bid.clone();
volume.brand_product_id = vol_bid.clone();
volume.halo_brand_id = Some(halo_bid.clone());
let mut inputs_halo = pair.inputs_halo;
let mut inputs_volume = pair.inputs_volume;
for i in &mut inputs_halo { i.brand_product_id = halo_bid.clone(); }
for i in &mut inputs_volume { i.brand_product_id = vol_bid.clone(); }
seen_ids.insert(halo_bid);
seen_ids.insert(vol_bid);
products.push(halo);
products.push(volume);
inputs.extend(inputs_halo);
inputs.extend(inputs_volume);
}
// Safety: avoid infinite loop if gap-fill produces no progress
if counter > cli.min_brands * 2 {
eprintln!("warning: gap-fill exhausted after {} iterations", counter);
break;
}
}
println!(" {} brand_products after gap-fill", products.len());
} else {
println!(" [4/5] Target reached — no gap-fill needed");
}
// Coverage report
print_coverage(&products);
// Write output
println!("\n [5/5] Writing output...");
write_output(&output_path, &products, &inputs);
println!(" {} brand_products, {} brand_inputs", products.len(), inputs.len());
println!(" Output: {}", output_path.display());
println!(" Done.\n");
}
+286
View File
@@ -0,0 +1,286 @@
//! Deterministic product name generation for minor brand instances.
//!
//! Reuses the corridor surname pools from generate_corporations/names.rs
//! but combines them with category-specific product descriptors rather
//! than business suffixes. Halo and volume tiers get distinct descriptor
//! pools so the output sounds differentiated.
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
// ---------------------------------------------------------------------------
// Surname pools (same corpus as generate_corporations/names.rs)
// ---------------------------------------------------------------------------
const CORE_NAMES: &[&str] = &[
"Alvarez", "Benoit", "Carvalho", "Durand", "Eriksen", "Fournier", "Gao", "Hartmann",
"Ishida", "Johansson", "Kirchner", "Lemaire", "Moreau", "Nakamura", "Olsson", "Pelletier",
"Richter", "Saito", "Torres", "Ueda", "Vasquez", "Werner", "Xu", "Yamada", "Zhou",
"Andersen", "Beaumont", "Costa", "Delacroix", "Engel", "Fujita", "Gutierrez", "Hayashi",
"Ibarra", "Jensen", "Klein", "Laurent", "Mercier", "Novak", "Ortiz", "Park", "Reuter",
"Suzuki", "Takahashi", "Ulrich", "Valentin", "Wagner", "Xie", "Yilmaz", "Zhang",
];
const NORTH_REACH_NAMES: &[&str] = &[
"Andersson", "Bjornsson", "Calloway", "Dalsgaard", "Eklund", "Falk", "Grimstad", "Hedlund",
"Ivarsson", "Jonasson", "Kirkpatrick", "Lindqvist", "MacLeod", "Nordstrom", "Olafsson",
"Pettersson", "Rehn", "Strandberg", "Thorsen", "Ulvskog", "Vikstrom", "Wahlberg", "Aberg",
"Berglund", "Carlsen", "Dalgaard", "Engstrom", "Forsell", "Gustafsson", "Halvorsen",
"Ingvarsson", "Jansson", "Knudsen", "Lundin", "MacPherson", "Nylund", "Ostergaard",
"Palsson", "Rasmussen", "Sjoberg", "Toft", "Ulfsson", "Vestergaard", "Wiklund", "Aasen",
"Brannstrom", "Dahl", "Eide", "Friberg", "Gren",
];
const SOUTH_REACH_NAMES: &[&str] = &[
"Adamski", "Baranov", "Chernov", "Dubois", "Egorov", "Filipov", "Gromov", "Horvat",
"Ivanova", "Jankovic", "Kowalski", "Lazarev", "Morozov", "Novikov", "Ostrowski", "Petrov",
"Reznik", "Sokolov", "Tkachenko", "Uvarov", "Volkov", "Wojcik", "Yakimov", "Zheng",
"Babic", "Chernyshev", "Dragunov", "Fedorov", "Grushevsky", "Havel", "Ito", "Jovanovic",
"Katsaros", "Lebedev", "Mazur", "Nemec", "Ochoa", "Popov", "Radic", "Smirnov", "Tanaka",
"Urasawa", "Vasiliev", "Watanabe", "Xiang", "Yegorov", "Zaytsev", "Borysko", "Chen",
"Dimitrov",
];
const WEST_REACH_NAMES: &[&str] = &[
"Albrecht", "Baumann", "Christensen", "Dietrich", "Eisenberg", "Fischer", "Gruber",
"Hoffmann", "Ingolstadt", "Jaeger", "Kessler", "Lehmann", "Mueller", "Neumann", "Obermann",
"Pfeiffer", "Quandt", "Roth", "Schaefer", "Thiel", "Urban", "Vogt", "Weidenfeld",
"Ziegler", "Becker", "Claussen", "Dorfmann", "Eberhardt", "Fleischer", "Gerstner", "Haber",
"Imhof", "Jung", "Kraemer", "Linden", "Metzger", "Niedermann", "Opitz", "Preuss", "Raabe",
"Steinbach", "Trautmann", "Unger", "Vollmer", "Winterberg", "Zahn", "Auerbach", "Bruckner",
"Dahlem", "Eckhardt",
];
const EAST_REACH_NAMES: &[&str] = &[
"Aquino", "Bautista", "Cruz", "Dalisay", "Espiritu", "Flores", "Garcia", "Hernandez",
"Ilagan", "Jeon", "Kim", "Lim", "Magalang", "Navarro", "Ocampo", "Park", "Quijano",
"Reyes", "Santos", "Tan", "Uy", "Villanueva", "Wong", "Yoo", "Aguilar", "Buenaventura",
"Castillo", "Dizon", "Enriquez", "Fernandez", "Gonzales", "Hwang", "Ignacio", "Jeong",
"Kwon", "Lee", "Marasigan", "Nakamura", "Oh", "Perez", "Ramos", "Son", "Tolentino",
"Umali", "Valdez", "Yun", "Zamora", "Baek", "Choi", "Dela Cruz",
];
const FRONTIER_NAMES: &[&str] = &[
"Adeyemi", "Bergstrom", "Chandra", "Duval", "Emeka", "Fonseca", "Gupta", "Hassan",
"Ibrahim", "Jansson", "Kovac", "Liu", "Martinez", "Nkosi", "Okafor", "Patel", "Quinn",
"Rodriguez", "Sousa", "Thorne", "Uddin", "Varga", "Wu", "Xiong", "Yoshida", "Zhao",
"Abara", "Beaumont", "Cardenas", "Doyle", "Ekwueme", "Ferreira", "Gomes", "Henriksen",
"Idris", "Juma", "Kato", "Larsen", "Morales", "Ndlovu", "Osei", "Petrov", "Ruiz",
"Singh", "Tavares", "Uchida", "Volkov", "Wang", "Yang", "Zaman",
];
fn names_for_corridor(corridor: &str) -> &'static [&'static str] {
match corridor {
"north_reach" | "north_reach/compact" => NORTH_REACH_NAMES,
"south_reach" => SOUTH_REACH_NAMES,
"west_reach" | "west_reach/compact" => WEST_REACH_NAMES,
"east_reach" | "inner_corridor/east_reach" => EAST_REACH_NAMES,
"frontier" => FRONTIER_NAMES,
_ => CORE_NAMES, // core / inner_corridor / reach_wide
}
}
// ---------------------------------------------------------------------------
// Product descriptor pools by brand_category × tier
// ---------------------------------------------------------------------------
const TERROIR_HALO: &[&str] = &[
"Reserve", "Single", "Estate", "Vintage", "Heritage", "Grand", "Limited", "Prestige",
"Signature", "Cellar", "Select", "Cru", "Premier", "Old", "Aged",
];
const TERROIR_VOLUME: &[&str] = &[
"Standard", "Export", "Blend", "Classic", "Field", "Ordinary", "Running", "Table",
"Regular", "Common", "House", "Station", "Corridor", "Transit",
];
const HERITAGE_CRAFT_HALO: &[&str] = &[
"Heritage", "Limited", "Artisan", "Master", "Guild", "Prestige", "Premium", "Classic",
"Signature", "Original", "Bespoke", "Traditional", "First",
];
const HERITAGE_CRAFT_VOLUME: &[&str] = &[
"Standard", "Classic", "Working", "Everyday", "Regular", "Plain", "Field", "Grade",
"Basic", "Workshop", "Studio", "Common",
];
const TECH_PREMIUM_HALO: &[&str] = &[
"Elite", "Pro", "Advanced", "Precision", "Superior", "Grand", "Signature", "First",
"Prime", "Expert", "Master", "Apex", "Summit",
];
const TECH_PREMIUM_VOLUME: &[&str] = &[
"Standard", "Series", "Base", "Classic", "Regular", "Field", "Grade", "Value",
"Essential", "Core", "Basic",
];
const CULTURAL_HALO: &[&str] = &[
"Archive", "Heritage", "Classic", "Definitive", "Prestige", "Limited", "Master",
"Collected", "Curated", "Canonical", "Flagship", "Grand",
];
const CULTURAL_VOLUME: &[&str] = &[
"Standard", "Classic", "Essential", "Value", "Regular", "Base", "Field",
"Running", "Everyday", "Popular",
];
const SERVICE_PREMIUM_HALO: &[&str] = &[
"Premier", "Elite", "Priority", "Signature", "Prestige", "Grand", "First", "Select",
"Platinum", "Gold", "Senior", "Executive",
];
const SERVICE_PREMIUM_VOLUME: &[&str] = &[
"Standard", "Basic", "Classic", "Regular", "Field", "Value", "General", "Common",
"Ordinary", "Essential",
];
const COMMODITY_BRANDED_HALO: &[&str] = &[
"Original", "Select", "Premium", "Reserve", "Classic", "Heritage", "Signature",
"Superior", "First", "Grade", "Certified",
];
const COMMODITY_BRANDED_VOLUME: &[&str] = &[
"Standard", "Basic", "Regular", "Field", "Value", "Economy", "Bulk", "Run",
"Common", "Grade", "Plain",
];
const DESIGN_HERITAGE_HALO: &[&str] = &[
"Heritage", "Limited", "Prestige", "Grand", "Signature", "Edition", "Series",
"Classic", "Archive", "Collector", "Retrospective",
];
const DESIGN_HERITAGE_VOLUME: &[&str] = &[
"Standard", "Classic", "Regular", "Field", "Value", "Base", "Essential",
"Running", "Contemporary", "Current",
];
const PLATFORM_CATALOGUE_HALO: &[&str] = &[
"Premium", "Pro", "Plus", "Elite", "Advanced", "Signature", "Select", "Grand",
"Unlimited", "Complete", "Full",
];
const PLATFORM_CATALOGUE_VOLUME: &[&str] = &[
"Standard", "Basic", "Classic", "Regular", "Field", "Value", "Entry", "Lite",
"Essential", "Free",
];
fn halo_descriptors(brand_category: &str) -> &'static [&'static str] {
match brand_category {
"terroir" => TERROIR_HALO,
"heritage_craft" => HERITAGE_CRAFT_HALO,
"tech_premium" => TECH_PREMIUM_HALO,
"cultural" => CULTURAL_HALO,
"service_premium" => SERVICE_PREMIUM_HALO,
"commodity_branded" => COMMODITY_BRANDED_HALO,
"design_heritage" => DESIGN_HERITAGE_HALO,
"platform_catalogue" => PLATFORM_CATALOGUE_HALO,
_ => COMMODITY_BRANDED_HALO,
}
}
fn volume_descriptors(brand_category: &str) -> &'static [&'static str] {
match brand_category {
"terroir" => TERROIR_VOLUME,
"heritage_craft" => HERITAGE_CRAFT_VOLUME,
"tech_premium" => TECH_PREMIUM_VOLUME,
"cultural" => CULTURAL_VOLUME,
"service_premium" => SERVICE_PREMIUM_VOLUME,
"commodity_branded" => COMMODITY_BRANDED_VOLUME,
"design_heritage" => DESIGN_HERITAGE_VOLUME,
"platform_catalogue" => PLATFORM_CATALOGUE_VOLUME,
_ => COMMODITY_BRANDED_VOLUME,
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Generate a plausible halo-tier product name.
/// Deterministic for a given RNG state.
pub fn generate_halo_name(rng: &mut ChaCha8Rng, corridor: &str, brand_category: &str) -> String {
let names = names_for_corridor(corridor);
let descs = halo_descriptors(brand_category);
let surname = names[rng.random_range(0..names.len())];
let desc = descs[rng.random_range(0..descs.len())];
// 25% chance: "Surname & Surname Descriptor" double-barrel
if rng.random::<f64>() < 0.25 {
let surname2 = names[rng.random_range(0..names.len())];
if surname != surname2 {
return format!("{} & {} {}", surname, surname2, desc);
}
}
format!("{} {}", surname, desc)
}
/// Generate a plausible volume-tier product name.
/// Volume names are shorter and more utilitarian than halo names.
pub fn generate_volume_name(rng: &mut ChaCha8Rng, corridor: &str, brand_category: &str) -> String {
let names = names_for_corridor(corridor);
let descs = volume_descriptors(brand_category);
let surname = names[rng.random_range(0..names.len())];
let desc = descs[rng.random_range(0..descs.len())];
format!("{} {}", surname, desc)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn halo_names_not_empty() {
let mut rng = ChaCha8Rng::seed_from_u64(1);
for corridor in &[
"north_reach",
"south_reach",
"west_reach",
"east_reach",
"frontier",
"core",
"reach_wide",
] {
for cat in &[
"terroir",
"heritage_craft",
"tech_premium",
"cultural",
"service_premium",
"commodity_branded",
"design_heritage",
"platform_catalogue",
] {
let name = generate_halo_name(&mut rng, corridor, cat);
assert!(!name.is_empty());
assert!(name.contains(' '));
}
}
}
#[test]
fn volume_names_not_empty() {
let mut rng = ChaCha8Rng::seed_from_u64(2);
for corridor in &["north_reach", "east_reach", "reach_wide"] {
for cat in &["terroir", "tech_premium", "cultural"] {
let name = generate_volume_name(&mut rng, corridor, cat);
assert!(!name.is_empty());
}
}
}
#[test]
fn deterministic_names() {
let mut rng1 = ChaCha8Rng::seed_from_u64(42);
let mut rng2 = ChaCha8Rng::seed_from_u64(42);
for _ in 0..50 {
let a = generate_halo_name(&mut rng1, "north_reach", "terroir");
let b = generate_halo_name(&mut rng2, "north_reach", "terroir");
assert_eq!(a, b);
}
}
}
+20 -5
View File
@@ -40,6 +40,7 @@ CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml"
SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql"
CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations"
BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml"
GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "generated_brands.toml"
class _ImportAborted(Exception):
@@ -745,22 +746,36 @@ VALID_BRAND_TIERS = {"halo", "volume"}
VALID_CURRENCY_DENOMINATIONS = {"tractus", "mark", "mixed", "sol_adjacent"}
def _load_brand_file(path) -> tuple[list, list]:
"""Load brand_products and brand_inputs from a TOML file. Returns empty lists if missing."""
if not path.exists():
return [], []
with open(path, "rb") as f:
data = tomllib.load(f)
return data.get("brand_products", []), data.get("brand_inputs", [])
def import_brands(
conn: sqlite3.Connection, dry_run: bool
) -> tuple[int, int]:
"""Import brand_products and brand_inputs from wiki/economics/corporations/brands.toml.
"""Import brand_products and brand_inputs from brands.toml and generated_brands.toml.
Hand-authored brands (brands.toml) are imported first; generated brands
(generated_brands.toml, produced by `tooling/generate-brands`) are merged in.
Returns (n_products, n_inputs).
"""
if not BRANDS_TOML.exists():
print(" warning: brands.toml not found — brand layer skipped")
return 0, 0
with open(BRANDS_TOML, "rb") as f:
data = tomllib.load(f)
products_authored, inputs_authored = _load_brand_file(BRANDS_TOML)
products_generated, inputs_generated = _load_brand_file(GENERATED_BRANDS_TOML)
products = data.get("brand_products", [])
inputs = data.get("brand_inputs", [])
if products_generated:
print(f" merging {len(products_generated)} generated brand_products from generated_brands.toml")
products = products_authored + products_generated
inputs = inputs_authored + inputs_generated
product_rows = []
for p in products:
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Generate minor brand products for the Settled Reach economy.
#
# Usage:
# tooling/generate-brands
# tooling/generate-brands --seed 42 --min-brands 10000
# tooling/generate-brands --output wiki/economics/corporations/generated_brands.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_brands"
# Build if needed
if [ ! -f "$BIN" ]; then
echo "Building generate_brands..." >&2
(cd "$ROOT_DIR/server" && cargo build --bin generate_brands 2>&1 | tail -3) >&2
fi
exec "$BIN" "$@"