feat(db): architecture zone-bias + color-register-band tables + V-TT-06/07 (T-988)

Ratified content baked into systems.db as two new tables:
- architecture_zone_bias (57 rows): sparse per-template, per-zone_type
  token-weight overrides (integer bps), Miri-authored — the D-235 step-2
  zone bias. V-TT-06: every token must exist in that template's own
  visual_bundle axis.
- color_register_bands (28 rows): per color_register integer HSV bands
  (hue centidegrees, sat/val bps), Araminta-authored. V-TT-07: full
  catalog coverage + valid integer bounds (min<max, in range).

Both TOMLs registered in generator_sources (stamp coverage); DDL in
systems-schema.sql + migration.py; validation wired into import_economics
steps 18/19 and test_traits.py failure-branch units (make test-tooling).
systems.db regenerated + re-stamped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 09:03:19 +02:00
co-authored by Claude Fable 5
parent 14b2eb69c5
commit ba781f9324
10 changed files with 1147 additions and 4 deletions
@@ -255,6 +255,26 @@ CREATE TABLE IF NOT EXISTS atlas_body_trait_bias (
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_body ON atlas_body_trait_bias(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_tag ON atlas_body_trait_bias(template_tag);
-- D-235 exterior-grammar content (T-988). Mirrors the canonical DDL in
-- systems-schema.sql; here so the migration path (existing DBs) gets the
-- tables, not just fresh systems-schema.sql builds.
CREATE TABLE IF NOT EXISTS architecture_zone_bias (
template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE,
zone_type_id TEXT NOT NULL,
bias TEXT NOT NULL,
PRIMARY KEY (template_tag, zone_type_id)
);
CREATE INDEX IF NOT EXISTS idx_architecture_zone_bias_template ON architecture_zone_bias(template_tag);
CREATE TABLE IF NOT EXISTS color_register_bands (
color_register TEXT PRIMARY KEY,
hue_min INTEGER NOT NULL,
hue_max INTEGER NOT NULL,
sat_min INTEGER NOT NULL,
sat_max INTEGER NOT NULL,
val_min INTEGER NOT NULL,
val_max INTEGER NOT NULL
);
-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911).
-- Idempotent: each UPDATE is a no-op if the old value is already gone.
UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture');
@@ -11,6 +11,8 @@ from pathlib import Path
from generator_sources import (
ARCHITECTURE_TRAIT_BIAS_TOML,
ARCHITECTURE_TRAIT_CATALOG_TOML,
ARCHITECTURE_ZONE_BIAS_TOML,
COLOR_REGISTER_BANDS_TOML,
GENERATE_BRANDS_WRAPPER,
OBJECT_TAG_VOCABULARY_TOML,
REPO_ROOT,
@@ -21,8 +23,10 @@ from generator_sources import (
__all__ = [
"ARCHITECTURE_TRAIT_BIAS_TOML",
"ARCHITECTURE_TRAIT_CATALOG_TOML",
"ARCHITECTURE_ZONE_BIAS_TOML",
"BRANDS_TOML",
"CHAINS_TOML",
"COLOR_REGISTER_BANDS_TOML",
"COMMODITIES_TOML",
"CORPORATIONS_DIR",
"CURRENCY_ZONES_TOML",
+189
View File
@@ -8,6 +8,8 @@ from .errors import ImportAborted
from .paths import (
ARCHITECTURE_TRAIT_BIAS_TOML,
ARCHITECTURE_TRAIT_CATALOG_TOML,
ARCHITECTURE_ZONE_BIAS_TOML,
COLOR_REGISTER_BANDS_TOML,
OBJECT_TAG_VOCABULARY_TOML,
)
@@ -250,6 +252,193 @@ def populate_trait_templates(conn: sqlite3.Connection, dry_run: bool) -> int:
return len(rows)
def populate_architecture_zone_bias(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Bake the D-235 step-2 zone-type bias table (T-988, resolves the ticket's
"REAL authored table" requirement).
Reads ARCHITECTURE_ZONE_BIAS_TOML (`[bias.<template_tag>.<zone_type_id>]`
stanzas; each is an optional `{axis: {token: weight_bps}}` sub-table per
axis — wall/roof/facade/street). Sparse/fallback model: a (template,
zone_type) pair absent here — or a token absent within a listed entry —
uses a uniform draw across the template's own `visual_bundle.<axis>` (the
generator's job, not this baker's).
V-TT-06: every referenced token must already appear in THAT template's own
`visual_bundle.<axis>` (checked against the just-baked `trait_templates`
row, not the catalog TOML in memory, so the check can never silently pass
against a stale in-process parse) — this file never expands a template's
palette, only re-weights within it. Must run AFTER populate_trait_templates
(FK + visual_bundle lookup). Absent source -> 0 rows.
"""
if not ARCHITECTURE_ZONE_BIAS_TOML.exists():
if not dry_run:
conn.execute("DELETE FROM architecture_zone_bias")
return 0
with open(ARCHITECTURE_ZONE_BIAS_TOML, "rb") as f:
data = tomllib.load(f)
bias_root = data.get("bias", {})
known_tags: set[str] = set()
template_axis_tokens: dict[str, dict[str, set[str]]] = {}
for tag, vb_json in conn.execute("SELECT tag, visual_bundle FROM trait_templates"):
known_tags.add(tag)
vb = json.loads(vb_json) if vb_json else {}
template_axis_tokens[tag] = {axis: set(vb.get(axis) or []) for axis in _TAG_AXES}
errors: list[str] = []
rows: list[tuple] = []
for template_tag, zone_map in bias_root.items():
if template_tag not in known_tags:
errors.append(f"architecture_zone_bias '{template_tag}': not in trait_templates")
continue
if not isinstance(zone_map, dict):
errors.append(
f"architecture_zone_bias '{template_tag}': expected a table of zone_type_id entries"
)
continue
for zone_type_id, axes in zone_map.items():
if not isinstance(axes, dict):
errors.append(
f"architecture_zone_bias '{template_tag}.{zone_type_id}': expected a table of axis entries"
)
continue
for axis, weights in axes.items():
if axis not in _TAG_AXES:
errors.append(
f"architecture_zone_bias '{template_tag}.{zone_type_id}': axis '{axis}' "
f"not one of {_TAG_AXES}"
)
continue
allowed = template_axis_tokens.get(template_tag, {}).get(axis, set())
for token, weight in (weights or {}).items():
if token not in allowed:
errors.append(
f"V-TT-06: architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}': "
f"token '{token}' not in this template's visual_bundle.{axis} {sorted(allowed)}"
)
if not isinstance(weight, int) or weight <= 0:
errors.append(
f"architecture_zone_bias '{template_tag}.{zone_type_id}.{axis}.{token}': "
f"weight_bps must be a positive integer, got {weight!r}"
)
try:
bias_json = json.dumps(axes)
except (TypeError, ValueError):
errors.append(
f"architecture_zone_bias '{template_tag}.{zone_type_id}': not JSON-serialisable"
)
continue
rows.append((template_tag, zone_type_id, bias_json))
if errors:
print(f" ARCHITECTURE ZONE BIAS ERRORS ({len(errors)}):")
for e in errors:
print(f" - {e}")
raise ImportAborted()
if not dry_run:
conn.execute("DELETE FROM architecture_zone_bias")
conn.executemany(
"INSERT INTO architecture_zone_bias (template_tag, zone_type_id, bias) VALUES (?,?,?)",
rows,
)
return len(rows)
# D-235 numeric HSV axis bounds (T-988): hue is centidegrees (0..36000 = 0-360
# deg x 100); sat/val are basis points (0..10000 = 0-100.00%).
_COLOR_AXIS_BOUNDS: dict[str, int] = {"hue": 36000, "sat": 10000, "val": 10000}
def populate_color_register_bands(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Bake the D-235 numeric color-register HSV sampling bands (T-988).
Reads COLOR_REGISTER_BANDS_TOML (`[register.<name>]` stanzas; each of
`hue`/`sat`/`val` a 2-element `[min, max]` integer array). The fill seed
samples a single (hue, sat, val) point uniformly within a template's
register band per building (D-235) — this baker only validates + stores
the bands.
V-TT-07: (1) every band's ranges are in-bounds (hue 0..36000, sat/val
0..10000) with `min < max`; (2) every `color_register` referenced by
`trait_templates.visual_bundle.color_register` (checked against the
just-baked table, same self-consistency reasoning as V-TT-06) has a band
here — coverage, not just shape. Must run AFTER populate_trait_templates.
Absent source -> 0 rows.
"""
if not COLOR_REGISTER_BANDS_TOML.exists():
if not dry_run:
conn.execute("DELETE FROM color_register_bands")
return 0
with open(COLOR_REGISTER_BANDS_TOML, "rb") as f:
data = tomllib.load(f)
registers = data.get("register", {})
errors: list[str] = []
rows: list[tuple] = []
for name, band in registers.items():
values: dict[str, tuple[int, int]] = {}
for axis, bound in _COLOR_AXIS_BOUNDS.items():
pair = band.get(axis) if isinstance(band, dict) else None
if not (isinstance(pair, list) and len(pair) == 2):
errors.append(
f"V-TT-07: color_register_bands '{name}': '{axis}' must be a 2-element "
"[min, max] array"
)
continue
lo, hi = pair
if isinstance(lo, bool) or isinstance(hi, bool) or not (
isinstance(lo, int) and isinstance(hi, int)
):
errors.append(
f"V-TT-07: color_register_bands '{name}.{axis}': min/max must be integers, "
f"got {pair!r}"
)
continue
if not (0 <= lo < hi <= bound):
errors.append(
f"V-TT-07: color_register_bands '{name}.{axis}': range [{lo}, {hi}] must "
f"satisfy 0 <= min < max <= {bound}"
)
continue
values[axis] = (lo, hi)
if len(values) == 3:
rows.append((
name,
values["hue"][0], values["hue"][1],
values["sat"][0], values["sat"][1],
values["val"][0], values["val"][1],
))
# Coverage half of V-TT-07: every color_register the catalog actually
# references must have a band.
referenced: set[str] = set()
for (vb_json,) in conn.execute("SELECT visual_bundle FROM trait_templates"):
vb = json.loads(vb_json) if vb_json else {}
reg = vb.get("color_register")
if reg:
referenced.add(reg)
banded = {r[0] for r in rows}
for reg in sorted(referenced - banded):
errors.append(f"V-TT-07: color_register '{reg}' referenced by trait_templates but has no band")
if errors:
print(f" COLOR REGISTER BAND ERRORS ({len(errors)}):")
for e in errors:
print(f" - {e}")
raise ImportAborted()
if not dry_run:
conn.execute("DELETE FROM color_register_bands")
conn.executemany(
"""INSERT INTO color_register_bands
(color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max)
VALUES (?,?,?,?,?,?,?)""",
rows,
)
return len(rows)
def populate_atlas_body_trait_bias(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Bake the sparse per-body hero pins into atlas_body_trait_bias (#993).
+13 -1
View File
@@ -228,10 +228,22 @@ def main() -> None:
print(f" {n_tilt} bodies updated with axial_tilt_deg")
# 17. biosphere_class from body frontmatter override + two-gate default (D-247, T-1085)
print(" [17/17] Populating biosphere_class (D-247)...")
print(" [17/19] Populating biosphere_class (D-247)...")
n_bio = bodies.populate_biosphere_class(conn, args.dry_run)
print(f" {n_bio} bodies updated with biosphere_class")
# 18. Architecture zone-type bias table (T-988, D-235 step 2) — must
# follow trait_templates (V-TT-06 validates against its visual_bundle).
print(" [18/19] Baking architecture_zone_bias table (D-235)...")
n_zone_bias = traits.populate_architecture_zone_bias(conn, args.dry_run)
print(f" {n_zone_bias} zone-bias rows")
# 19. Color register bands (T-988, D-235) — numeric HSV sampling bands
# per trait-template color_register; also follows trait_templates (V-TT-07).
print(" [19/19] Baking color_register_bands table (D-235)...")
n_color_bands = traits.populate_color_register_bands(conn, args.dry_run)
print(f" {n_color_bands} color register bands")
# Validate structural integrity (FK, chain refs, chain completeness).
# These errors indicate broken imported data — do NOT commit.
print("\n Validating structural integrity...")
+168 -3
View File
@@ -3,14 +3,16 @@
Unit tests for economy_import.traits validation (T-995, PR #173 review H2).
Covers the failure branches of the ObjectTag registry loader/validator
(V-TT-03 existence/axis, V-TT-04 fallback-graph) and the V-TT-05 pin bound —
the `make test-tooling` dry-run only exercises the happy path against the
committed, already-valid registry.
(V-TT-03 existence/axis, V-TT-04 fallback-graph), the V-TT-05 pin bound, the
V-TT-06 zone-bias token-existence check, and the V-TT-07 color-register band
shape/coverage checks (T-988) — the `make test-tooling` dry-run only
exercises the happy path against the committed, already-valid content.
Stdlib only (unittest) — run directly or via `make test-tooling`:
python3 tooling/economy-db/test_traits.py
"""
import json
import sqlite3
import sys
import tempfile
@@ -309,5 +311,168 @@ class PinBoundTests(unittest.TestCase):
self.populate(self.bias_toml(6))
class ZoneBiasValidationTests(unittest.TestCase):
"""populate_architecture_zone_bias: V-TT-06 token-existence check (T-988)."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp.name)
self._orig_zone_bias = traits.ARCHITECTURE_ZONE_BIAS_TOML
self.conn = sqlite3.connect(":memory:")
self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)")
self.conn.execute(
"""CREATE TABLE architecture_zone_bias (
template_tag TEXT NOT NULL, zone_type_id TEXT NOT NULL, bias TEXT NOT NULL,
PRIMARY KEY (template_tag, zone_type_id))"""
)
self.conn.execute(
"INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)",
(
"test_template",
json.dumps({
"wall": ["brick_wall", "rendered_wall"],
"roof": ["flat_roof"],
"facade": ["regular_facade"],
"street": ["paved"],
"color_register": "neutral_grey",
}),
),
)
def tearDown(self):
traits.ARCHITECTURE_ZONE_BIAS_TOML = self._orig_zone_bias
self.conn.close()
self._tmp.cleanup()
def populate(self, body: str):
path = self.tmp / "architecture_zone_bias.toml"
path.write_text(body, encoding="utf-8")
traits.ARCHITECTURE_ZONE_BIAS_TOML = path
return traits.populate_architecture_zone_bias(self.conn, dry_run=True)
def test_valid_bias_within_visual_bundle_passes(self):
n = self.populate(
'[bias.test_template.commercial_market]\n'
'wall = { brick_wall = 15000 }\n'
'street = { paved = 13000 }\n'
)
self.assertEqual(n, 1)
def test_token_outside_visual_bundle_is_rejected(self):
# steel_frame is a real ObjectTag, but never appears in test_template's
# own visual_bundle.wall above — V-TT-06 must reject it.
import contextlib
import io
out = io.StringIO()
with contextlib.redirect_stdout(out):
with self.assertRaises(ImportAborted):
self.populate(
'[bias.test_template.industrial_freight]\n'
'wall = { steel_frame = 16000 }\n'
)
self.assertIn("V-TT-06", out.getvalue())
self.assertIn("steel_frame", out.getvalue())
def test_unknown_template_tag_is_rejected(self):
with self.assertRaises(ImportAborted):
self.populate(
'[bias.no_such_template.commercial_market]\n'
'wall = { brick_wall = 15000 }\n'
)
def test_non_positive_weight_is_rejected(self):
with self.assertRaises(ImportAborted):
self.populate(
'[bias.test_template.commercial_market]\n'
'wall = { brick_wall = 0 }\n'
)
def test_missing_source_yields_zero_rows(self):
traits.ARCHITECTURE_ZONE_BIAS_TOML = self.tmp / "missing.toml"
self.assertEqual(traits.populate_architecture_zone_bias(self.conn, dry_run=True), 0)
class ColorRegisterBandTests(unittest.TestCase):
"""populate_color_register_bands: V-TT-07 shape + coverage checks (T-988)."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp.name)
self._orig_bands = traits.COLOR_REGISTER_BANDS_TOML
self.conn = sqlite3.connect(":memory:")
self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)")
self.conn.execute(
"""CREATE TABLE color_register_bands (
color_register TEXT PRIMARY KEY, hue_min INTEGER NOT NULL, hue_max INTEGER NOT NULL,
sat_min INTEGER NOT NULL, sat_max INTEGER NOT NULL,
val_min INTEGER NOT NULL, val_max INTEGER NOT NULL)"""
)
self.conn.execute(
"INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)",
("test_template", json.dumps({"color_register": "neutral_grey"})),
)
def tearDown(self):
traits.COLOR_REGISTER_BANDS_TOML = self._orig_bands
self.conn.close()
self._tmp.cleanup()
def populate(self, body: str):
path = self.tmp / "color_register_bands.toml"
path.write_text(body, encoding="utf-8")
traits.COLOR_REGISTER_BANDS_TOML = path
return traits.populate_color_register_bands(self.conn, dry_run=True)
def test_valid_band_covering_the_referenced_register_passes(self):
n = self.populate(
'[register.neutral_grey]\n'
'hue = [20000, 21200]\n'
'sat = [400, 1200]\n'
'val = [4800, 6400]\n'
)
self.assertEqual(n, 1)
def test_missing_coverage_for_referenced_register_is_rejected(self):
import contextlib
import io
out = io.StringIO()
with contextlib.redirect_stdout(out):
with self.assertRaises(ImportAborted):
# A band for a DIFFERENT register — neutral_grey (referenced by
# test_template above) has no band at all.
self.populate(
'[register.some_other_register]\n'
'hue = [0, 100]\n'
'sat = [0, 100]\n'
'val = [0, 100]\n'
)
self.assertIn("V-TT-07", out.getvalue())
self.assertIn("neutral_grey", out.getvalue())
def test_hue_out_of_bounds_is_rejected(self):
with self.assertRaises(ImportAborted):
self.populate(
'[register.neutral_grey]\n'
'hue = [30000, 40000]\n' # 40000 > 36000 bound
'sat = [400, 1200]\n'
'val = [4800, 6400]\n'
)
def test_min_not_less_than_max_is_rejected(self):
with self.assertRaises(ImportAborted):
self.populate(
'[register.neutral_grey]\n'
'hue = [20000, 20000]\n' # min == max, not min < max
'sat = [400, 1200]\n'
'val = [4800, 6400]\n'
)
def test_missing_source_yields_zero_rows(self):
traits.COLOR_REGISTER_BANDS_TOML = self.tmp / "missing.toml"
self.assertEqual(traits.populate_color_register_bands(self.conn, dry_run=True), 0)
if __name__ == "__main__":
unittest.main(verbosity=2)
+13
View File
@@ -88,6 +88,17 @@ ARCHITECTURE_TRAIT_BIAS_TOML: Path = (
OBJECT_TAG_VOCABULARY_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "object_tag_vocabulary.toml"
)
# D-235 exterior-grammar content (T-988): per-(template, zone_type) axis-token
# bias overrides, and per-color_register integer HSV sampling bands. Both are
# baked alongside trait_templates (traits.py: populate_architecture_zone_bias,
# populate_color_register_bands), so an edit to either must flip the stamp
# exactly like the catalog/registry above.
ARCHITECTURE_ZONE_BIAS_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "architecture_zone_bias.toml"
)
COLOR_REGISTER_BANDS_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "color_register_bands.toml"
)
def _economy_import_modules() -> tuple[Path, ...]:
@@ -124,6 +135,8 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
ARCHITECTURE_TRAIT_CATALOG_TOML,
ARCHITECTURE_TRAIT_BIAS_TOML,
OBJECT_TAG_VOCABULARY_TOML,
ARCHITECTURE_ZONE_BIAS_TOML,
COLOR_REGISTER_BANDS_TOML,
)
# ---------------------------------------------------------------------------