The sole generator of systems.db moves to tooling/domains/ledger/ and is now `reach ledger import`. economy_import/ keeps its name (Rust comments in server/src cite it); the entrypoint becomes service.py; schema_version.py moves with the importer, which is where the version is defined. The stamp survived the move, which is the thing that had to hold: - generated_brands.toml is byte-identical (sha256 e748531…) before and after - `reach check systems-db-stamp` reported STALE after the move (the registry saw it) and OK after the regen - the dry-run carries every count and warning of the baseline transcript, and exit 2 — imported and stamped, coverage gate unmet — still reaches the caller through @command `make regen-db` survives as a one-line delegate, per D-263's muscle-memory clause: about fifty files name it, including the headers of generated wiki TOMLs and the remedies the push gate prints. `make economy-db` is retired; it ran `reach generate brands` before the import, which the import already does as its first step. economy_import.errors is reconciled as DOMAINS.md asked. ImportAborted stays as internal rollback control flow and never reaches a caller; the service converts it to a ReachError carrying the remedy. regenerate_brands caught cargo_binary's ReachError, printed it and raised ImportAborted, dropping the remedy. It runs before the import transaction opens, so there is nothing to roll back — it now propagates. Both sys.path bootstraps are gone; they existed only because the directory was hyphenated. The step labels ran [1/10]…[10/13]…[17/19]; one 24-step counter now drives the event phase and progress. Stale pointers fixed on the way: MIGRATION_SQL has lived in economy_import/migration.py since T-1067, but the asset-pipeline rule, DEVOPS and the schema comments still sent readers to import_economics.py; the rule and DEVOPS also still named the check scripts T-1281 retired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
10 KiB
Python
247 lines
10 KiB
Python
"""Brand layer (D-189, #827): generate_brands shell-out, TOML import, validation."""
|
|
|
|
import sqlite3
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
from .paths import BRANDS_TOML, GENERATED_BRANDS_TOML
|
|
from tooling.core import console
|
|
|
|
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 binary is built on demand and run with the default canonical seed=1;
|
|
callers that need non-canonical seeds invoke it directly (experimentation
|
|
only — committed output must be seed=1).
|
|
|
|
This used to shell out to a `tooling/generate-brands` bash wrapper. The
|
|
wrapper was one of three identical copies of build-if-missing-then-exec, so
|
|
it was retired into `core.process.cargo_binary` (T-1286) and this calls that
|
|
helper instead. Same binary, same seed, same output.
|
|
|
|
A build or run failure propagates as the ReachError `cargo_binary` raised.
|
|
It used to be printed and swapped for ImportAborted, which dropped its
|
|
remedy (T-1289). Nothing needs rolling back: this runs before the import
|
|
transaction opens.
|
|
"""
|
|
from tooling.core.process import cargo_binary
|
|
|
|
console.event("Running generate_brands (Rust) to refresh generated_brands.toml...", phase="pre")
|
|
stdout = cargo_binary("generate_brands")
|
|
|
|
# Relay the Rust binary's own summary lines (brands generated, coverage).
|
|
for line in stdout.splitlines():
|
|
if line.strip():
|
|
console.event(line.strip(), phase="pre")
|
|
|
|
|
|
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 `reach generate brands`) are merged in.
|
|
Returns (n_products, n_inputs).
|
|
"""
|
|
if not BRANDS_TOML.exists():
|
|
console.event("brands.toml not found — brand layer skipped", level="warn")
|
|
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:
|
|
console.event(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
|