diff --git a/Makefile b/Makefile index 139f1dc71..2ef96f5cc 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/docs/architecture/sprint-36-bincode-audit.md b/docs/architecture/sprint-36-bincode-audit.md new file mode 100644 index 000000000..98d50523c --- /dev/null +++ b/docs/architecture/sprint-36-bincode-audit.md @@ -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 5–9): +```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 = 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 = 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::(&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. diff --git a/server/data/systems.db b/server/data/systems.db index e339af242..921af5f37 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/bin/generate_brands/main.rs b/server/src/bin/generate_brands/main.rs new file mode 100644 index 000000000..dde9831d9 --- /dev/null +++ b/server/src/bin/generate_brands/main.rs @@ -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, + + /// Path to brand_templates.toml + #[arg(long)] + templates: Option, + + /// 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, + 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, + geographic_sector: Option, + currency_zone: Option, + 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, + base_premium_multiplier: f64, + premium_floor: f64, + #[serde(skip_serializing_if = "Option::is_none")] + origin_system: Option, + terroir_locked: bool, + currency_denomination: String, + shadow_viable: bool, + brand_tier: String, + #[serde(skip_serializing_if = "Option::is_none")] + halo_brand_id: Option, +} + +#[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::(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 { + 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>(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 { + 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::() + .split('-') + .filter(|p| !p.is_empty()) + .collect::>() + .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, + inputs_volume: Vec, +} + +fn generate_pair( + rng: &mut ChaCha8Rng, + corp: &Corp, + template_key: &str, + template: &BrandTemplate, + valid_commodities: &BTreeSet, +) -> Option { + 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::() * (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::() * (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 = 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::() * 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, 3–5× higher quantities + let vol_scale = 3.0 + rng.random::() * 2.0; + let inputs_volume: Vec = 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 = BTreeMap::new(); + let mut by_tier: BTreeMap = BTreeMap::new(); + let mut by_corp: BTreeMap = 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 = Vec::new(); + let mut inputs: Vec = Vec::new(); + let mut seen_ids: BTreeSet = 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"); +} diff --git a/server/src/bin/generate_brands/names.rs b/server/src/bin/generate_brands/names.rs new file mode 100644 index 000000000..5aa2f2297 --- /dev/null +++ b/server/src/bin/generate_brands/names.rs @@ -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::() < 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); + } + } +} diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 67d4d58a7..baad80146 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -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: diff --git a/tooling/generate-brands b/tooling/generate-brands new file mode 100755 index 000000000..fd9109206 --- /dev/null +++ b/tooling/generate-brands @@ -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" "$@"