import_economics.py 2,620 → 309 lines — a thin orchestrator keeping the exact CLI, single-transaction/rollback contract, and exit codes. The 16 import steps, MIGRATION_SQL, brands shell-out, validators, and stamp write now live in tooling/economy-db/economy_import/ (db, migration, economy, corporations, brands, bodies, atlas, specialization, traits, validators, stamp, paths, errors). Full type hints throughout. tooling/generator_sources.py replaces the triplicated source registry (importer / stamp checker / pr-process watch list — the skill now derives its list via --list). The registry stamps itself, and economy_import/ modules are globbed fail-closed, so a future module is stamped the moment it exists — closing the silently-weakened-stamp failure mode. Rider: connector config helpers centralized in tooling/db/common.py. Byte-identical behavior proven: full-import table dump diff EMPTY over 107,843 lines / 37 tables (volatile timestamp fields excluded); dry-run output parity; generated_brands.toml sha unchanged. make test-tooling PASS; ruff clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
250 lines
9.9 KiB
Python
250 lines
9.9 KiB
Python
"""Brand layer (D-189, #827): generate_brands shell-out, TOML import, validation."""
|
|
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
from .errors import ImportAborted
|
|
from .paths import BRANDS_TOML, GENERATE_BRANDS_WRAPPER, GENERATED_BRANDS_TOML, REPO_ROOT
|
|
|
|
VALID_BRAND_CATEGORIES: set[str] = {
|
|
"terroir", "heritage_craft", "tech_premium", "cultural",
|
|
"service_premium", "commodity_branded", "design_heritage", "platform_catalogue",
|
|
}
|
|
VALID_VALUE_TRAJECTORIES: set[str] = {"appreciating", "depreciating", "timeless"}
|
|
VALID_SCARCITY_CLASSES: set[str] = {"capped", "constrained", "scalable", "unlimited"}
|
|
VALID_BRAND_TIERS: set[str] = {"halo", "volume"}
|
|
VALID_CURRENCY_DENOMINATIONS: set[str] = {"tractus", "mark", "mixed", "sol_adjacent"}
|
|
VALID_PRICE_TIERS: set[str] = {"mass", "premium", "luxury", "flagship", "institutional"}
|
|
|
|
|
|
def regenerate_brands() -> None:
|
|
"""Run the Rust generate_brands binary to refresh generated_brands.toml.
|
|
|
|
Invoked as the first step of import_economics' main flow so the TOML on disk
|
|
always matches the current Rust source before the Python import reads it.
|
|
This replaces the former split (tooling/generate-brands run separately by
|
|
make regen-db) with a single, coherent brand pipeline owned by one stamp.
|
|
|
|
The wrapper script builds the binary on demand and runs it with the default
|
|
canonical seed=1; callers that need non-canonical seeds must still invoke
|
|
the wrapper directly (experimentation only — committed output must be seed=1).
|
|
"""
|
|
if not GENERATE_BRANDS_WRAPPER.exists():
|
|
raise FileNotFoundError(
|
|
f"generate_brands wrapper not found at {GENERATE_BRANDS_WRAPPER}"
|
|
)
|
|
print(" [pre/10] Running generate_brands (Rust) to refresh generated_brands.toml...")
|
|
result = subprocess.run(
|
|
[str(GENERATE_BRANDS_WRAPPER)],
|
|
cwd=str(REPO_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
print(result.stdout, file=sys.stderr)
|
|
print(result.stderr, file=sys.stderr)
|
|
raise ImportAborted()
|
|
# Print the Rust binary's own summary lines (brands generated, coverage).
|
|
# Indent so they fold under the pre-step heading.
|
|
for line in result.stdout.splitlines():
|
|
if line.strip():
|
|
print(f" {line}")
|
|
|
|
|
|
def _load_brand_file(path: 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 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
|
|
|
|
products_authored, inputs_authored = _load_brand_file(BRANDS_TOML)
|
|
products_generated, inputs_generated = _load_brand_file(GENERATED_BRANDS_TOML)
|
|
|
|
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: list[tuple] = []
|
|
for p in products:
|
|
product_rows.append((
|
|
p["brand_product_id"],
|
|
p["corp_id"],
|
|
p["product_name"],
|
|
p["brand_category"],
|
|
p["value_trajectory"],
|
|
p["scarcity_class"],
|
|
p.get("product_subcategory"),
|
|
p.get("base_premium_multiplier", 1.0),
|
|
p.get("premium_floor", 0.0),
|
|
p.get("origin_system"),
|
|
int(p.get("terroir_locked", False)),
|
|
p.get("currency_denomination", "tractus"),
|
|
int(p.get("shadow_viable", False)),
|
|
p["brand_tier"],
|
|
p.get("halo_brand_id"),
|
|
p.get("price_tier"),
|
|
))
|
|
|
|
input_rows: list[tuple] = []
|
|
for inp in inputs:
|
|
input_rows.append((
|
|
inp["brand_product_id"],
|
|
inp["commodity_id"],
|
|
inp["quantity"],
|
|
))
|
|
|
|
if not dry_run:
|
|
conn.executemany(
|
|
"""INSERT OR REPLACE INTO brand_products (
|
|
brand_product_id, corp_id, product_name, brand_category,
|
|
value_trajectory, scarcity_class, product_subcategory,
|
|
base_premium_multiplier, premium_floor, origin_system,
|
|
terroir_locked, currency_denomination, shadow_viable,
|
|
brand_tier, halo_brand_id, price_tier
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
product_rows,
|
|
)
|
|
conn.executemany(
|
|
"""INSERT OR REPLACE INTO brand_inputs
|
|
(brand_product_id, commodity_id, quantity) VALUES (?, ?, ?)""",
|
|
input_rows,
|
|
)
|
|
|
|
return len(product_rows), len(input_rows)
|
|
|
|
|
|
def validate_brands(conn: sqlite3.Connection) -> list[str]:
|
|
"""Brand layer structural validation rules V-B01 through V-B06.
|
|
|
|
V-B01: Every brand_products row has a valid corp_id (FK to corporations).
|
|
V-B02: Every brand_inputs row has valid brand_product_id and commodity_id FKs.
|
|
V-B03: Every halo brand has at least one brand_inputs entry (demand stub must consume).
|
|
V-B04: Every volume tier must reference an existing halo brand_product_id.
|
|
V-B05: No brand_product_id is used as halo_brand_id by a non-volume-tier product.
|
|
V-B06: Every enum column (brand_category, value_trajectory, scarcity_class,
|
|
brand_tier, currency_denomination) is a member of its VALID_* set.
|
|
"""
|
|
errors: list[str] = []
|
|
|
|
# V-B01: brand_products → corporations FK
|
|
orphan_corps = conn.execute("""
|
|
SELECT bp.brand_product_id, bp.corp_id
|
|
FROM brand_products bp
|
|
LEFT JOIN corporations c ON bp.corp_id = c.corp_id
|
|
WHERE c.corp_id IS NULL
|
|
""").fetchall()
|
|
for pid, corp_id in orphan_corps:
|
|
errors.append(
|
|
f"V-B01: brand_product '{pid}' references unknown corp_id '{corp_id}'"
|
|
)
|
|
|
|
# V-B02: brand_inputs → brand_products and brand_inputs → commodities FKs
|
|
orphan_inputs_bp = conn.execute("""
|
|
SELECT bi.brand_product_id, bi.commodity_id
|
|
FROM brand_inputs bi
|
|
LEFT JOIN brand_products bp ON bi.brand_product_id = bp.brand_product_id
|
|
WHERE bp.brand_product_id IS NULL
|
|
""").fetchall()
|
|
for pid, cid in orphan_inputs_bp:
|
|
errors.append(
|
|
f"V-B02: brand_inputs row ({pid}, {cid}) references unknown brand_product_id"
|
|
)
|
|
|
|
orphan_inputs_comm = conn.execute("""
|
|
SELECT bi.brand_product_id, bi.commodity_id
|
|
FROM brand_inputs bi
|
|
LEFT JOIN commodities c ON bi.commodity_id = c.commodity_id
|
|
WHERE c.commodity_id IS NULL
|
|
""").fetchall()
|
|
for pid, cid in orphan_inputs_comm:
|
|
errors.append(
|
|
f"V-B02: brand_inputs row ({pid}, {cid}) references unknown commodity_id '{cid}'"
|
|
)
|
|
|
|
# V-B03: every halo brand has at least one brand_inputs entry
|
|
halo_no_inputs = conn.execute("""
|
|
SELECT bp.brand_product_id
|
|
FROM brand_products bp
|
|
WHERE bp.brand_tier = 'halo'
|
|
AND bp.brand_product_id NOT IN (SELECT brand_product_id FROM brand_inputs)
|
|
""").fetchall()
|
|
for (pid,) in halo_no_inputs:
|
|
errors.append(
|
|
f"V-B03: halo brand '{pid}' has no brand_inputs entries "
|
|
f"(must consume at least one commodity as a demand node)"
|
|
)
|
|
|
|
# V-B04: volume tiers reference valid halo_brand_id
|
|
volume_bad_halo = conn.execute("""
|
|
SELECT bp.brand_product_id, bp.halo_brand_id
|
|
FROM brand_products bp
|
|
WHERE bp.brand_tier = 'volume'
|
|
AND (bp.halo_brand_id IS NULL
|
|
OR bp.halo_brand_id NOT IN (SELECT brand_product_id FROM brand_products))
|
|
""").fetchall()
|
|
for pid, halo_id in volume_bad_halo:
|
|
errors.append(
|
|
f"V-B04: volume brand '{pid}' has invalid halo_brand_id '{halo_id}'"
|
|
)
|
|
|
|
# V-B05: halo_brand_id must only point to halo-tier products
|
|
halo_points_to_non_halo = conn.execute("""
|
|
SELECT child.brand_product_id, child.halo_brand_id, parent.brand_tier
|
|
FROM brand_products child
|
|
JOIN brand_products parent ON child.halo_brand_id = parent.brand_product_id
|
|
WHERE child.brand_tier = 'volume'
|
|
AND parent.brand_tier != 'halo'
|
|
""").fetchall()
|
|
for child_id, halo_id, parent_tier in halo_points_to_non_halo:
|
|
errors.append(
|
|
f"V-B05: volume brand '{child_id}' points to '{halo_id}' "
|
|
f"which has brand_tier='{parent_tier}', not 'halo'"
|
|
)
|
|
|
|
# V-B06: every enum column is in its VALID_* set. The SQL columns are
|
|
# plain TEXT without CHECK constraints, so a typo like `terrior` would
|
|
# otherwise silently import.
|
|
enum_checks: list[tuple[str, set[str]]] = [
|
|
("brand_category", VALID_BRAND_CATEGORIES),
|
|
("value_trajectory", VALID_VALUE_TRAJECTORIES),
|
|
("scarcity_class", VALID_SCARCITY_CLASSES),
|
|
("brand_tier", VALID_BRAND_TIERS),
|
|
("currency_denomination", VALID_CURRENCY_DENOMINATIONS),
|
|
("price_tier", VALID_PRICE_TIERS),
|
|
]
|
|
for column, valid_set in enum_checks:
|
|
bad = conn.execute(
|
|
f"SELECT brand_product_id, {column} FROM brand_products"
|
|
).fetchall()
|
|
for pid, value in bad:
|
|
if value is None:
|
|
continue # nullable columns (e.g. price_tier) may be unset
|
|
if value not in valid_set:
|
|
errors.append(
|
|
f"V-B06: brand_product '{pid}' has {column}='{value}' — "
|
|
f"must be one of {sorted(valid_set)}"
|
|
)
|
|
|
|
return errors
|