music/transforms/apply_theme_plant_nodes.py
#!/usr/bin/env python3
"""STAGED TRANSFORM -- write plant_node and diegetic_surface onto the Tier-A theme rows.
GENERATED by harness/music_gen/exposure_ledger.py. DO NOT HAND-EDIT: re-run the ledger.
WHY THIS IS A SCRIPT AND NOT AN EDIT. REGISTRY LAW: no lane edits a registries/ CSV or the
fidelity baseline. This file is the deterministic APPLY VECTOR the director runs, with
`python harness/registry_fidelity.py --emit-baseline` in the SAME commit. It is idempotent
(re-running writes nothing new) and it has a --check mode that writes nothing at all.
WHAT IT DISCHARGES. T1_Audio_Spec section 10.3, verbatim: "Owed before the next render of any
Tier-A row: a plant node and a diegetic surface per Tier-A theme, assigned from the spine entry
it plants in, with the protagonist theme first." Both columns read 0/38 before this transform.
EVERY VALUE CARRIES ITS DERIVATION. The plant node is read out of the theme row's OWN cells
(recurrence_anchors beat ids, then node ids, then prose "Ch NN"), never guessed from adjacency.
The diegetic surface is the first sounding source in the beat graphs of the first three nodes
from the plant -- the doctrine's own window -- quoted from the spine so the director can check
each one against the node. A row whose window holds no sounding source is DECLARED ABSENT and is
NOT written; an invented surface is worse than an empty column, which is why the column was left
empty in the first place.
python build/audio/transforms/apply_theme_plant_nodes.py --check # writes nothing
python build/audio/transforms/apply_theme_plant_nodes.py --apply
"""
from __future__ import annotations
import argparse
import csv
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
REGISTRY = ROOT / "registries"
# theme_id -> (plant_node, diegetic_surface, derivation)
VALUES = {
'JOURNEY_WORLD': ('CH_PROLOGUE', 'CH_02 :: CH02_B10 :: gong',
"plant from row.canonical_anchor (prose 'Prologue', earliest wins); diegetic quoted from the spine: e where the lingko fields spread their spider-web lines and gong-waning carries up from the houses (the first vril-active-instrumen"),
'HOME_AND_LOSS': ('CH_PROLOGUE', 'CH_02 :: CH02_B10 :: gong',
'plant from row.recurrence_anchors (beat id); beat CHPRO_B07; diegetic quoted from the spine: e where the lingko fields spread their spider-web lines and gong-waning carries up from the houses (the first vril-active-instrumen'),
'ARCHITECT_THEME': ('CH_38', '',
"plant from row.canonical_anchor (prose 'Ch NN', earliest wins) -> CLAMPED to reveal_gate CH_38: the derived node CH_18 precedes this theme's own gate, and the doctrine scopes the plant to 'the first three nodes it appears AFTER its reveal_gate'; diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_38, CH_39, CH_40 -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms."),
'CASSIUS_PHASE_1': ('CH_76', '',
'plant from row.recurrence_anchors (scene id); diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_76, CH_77, CH_EPILOGUE -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms.'),
'CASSIUS_PHASE_2': ('CH_76', '',
'plant from row.recurrence_anchors (scene id); diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_76, CH_77, CH_EPILOGUE -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms.'),
'CASSIUS_PHASE_3': ('CH_76', '',
'plant from row.recurrence_anchors (scene id); diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_76, CH_77, CH_EPILOGUE -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms.'),
'ENDING_EVIL': ('CH_77', '',
'plant from row.recurrence_anchors (scene id); diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_77, CH_EPILOGUE -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms.'),
'ENDING_GOOD_ENOUGH': ('CH_77', '',
'plant from row.recurrence_anchors (scene id); diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_77, CH_EPILOGUE -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms.'),
'ENDING_TRULY_GOOD': ('CH_77', '',
'plant from row.recurrence_anchors (scene id); diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_77, CH_EPILOGUE -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms.'),
'FAMILIAR_BOND': ('', '',
'plant from NOT DERIVABLE; diegetic DECLARED ABSENT -- no plant node, so no window to scan'),
'GRAND_SAGE_REVEAL': ('CH_77', '',
'plant from row.recurrence_anchors (scene id); diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_77, CH_EPILOGUE -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms.'),
'HOUSE_OF_VELHEIM_THEME': ('CH_38', '',
"plant from row.recurrence_anchors (prose 'Ch NN', earliest wins) -> CLAMPED to reveal_gate CH_38: the derived node CH_18 precedes this theme's own gate, and the doctrine scopes the plant to 'the first three nodes it appears AFTER its reveal_gate'; diegetic DECLARED ABSENT -- no sounding source in the beat graphs of CH_38, CH_39, CH_40 -- DECLARED ABSENT rather than invented. LEITMOTIF_ARCHITECTURE section 5 names this state for the Prologue in exactly these terms."),
'PROTAGONIST_THEME': ('CH_PROLOGUE', 'CH_02 :: CH02_B10 :: gong',
"plant from row.canonical_anchor (prose 'Ch NN' + prose 'Prologue', earliest wins); diegetic quoted from the spine: e where the lingko fields spread their spider-web lines and gong-waning carries up from the houses (the first vril-active-instrumen"),
'VIMANA_ACTIVATION': ('CH_55', 'CH_57 :: CH57_B01 :: song',
'plant from row.recurrence_anchors (scene id); diegetic quoted from the spine: aming as the ever-present, ordered reality it is — the land sung and named and known, holding a yesterday and a today and a'),
}
COLUMNS = ("plant_node", "diegetic_surface")
def target() -> Path:
hits = sorted(REGISTRY.glob("T0_Theme_Registry [[]*"))
if not hits:
raise SystemExit("T0_Theme_Registry not found")
csvs = sorted(hits[0].glob("*.csv"))
if not csvs:
raise SystemExit(f"no csv tab under {hits[0]}")
return csvs[0]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="write; otherwise dry run")
ap.add_argument("--check", action="store_true",
help="report the diff and write nothing, ever")
args = ap.parse_args()
path = target()
raw = path.read_bytes()
# PRESERVE WHAT WAS THERE. A registry write that silently changes the byte-level shape of the
# file -- a stripped BOM, a flipped line terminator -- shows up as every row changed in the
# fidelity diff and buries the seventeen cells that actually moved.
bom = raw.startswith(b"\xef\xbb\xbf")
crlf = raw.count(b"\r\n") > 0
with path.open(encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
header = list(reader.fieldnames or [])
rows = list(reader)
missing = [c for c in COLUMNS if c not in header]
if missing:
print(f"REFUSING: T0_Theme_Registry has no column(s) {missing}. This transform "
f"populates existing columns; it does not mint schema.")
return 2
changes, already, absent = [], [], []
for row in rows:
want = VALUES.get(row["theme_id"])
if not want:
continue
plant, surface, _why = want
for col, val in (("plant_node", plant), ("diegetic_surface", surface)):
if not val:
absent.append(f"{row['theme_id']}.{col} DECLARED ABSENT -- not written")
continue
cur = (row.get(col) or "").strip()
if cur == val:
already.append(f"{row['theme_id']}.{col}")
elif cur:
print(f"REFUSING: {row['theme_id']}.{col} already reads {cur!r} and this "
f"transform would write {val!r}. A non-empty cell is somebody's "
f"decision; resolve it rather than overwriting it.")
return 2
else:
changes.append((row, col, val))
for row, col, val in changes:
print(f" {row['theme_id']:32s} {col:18s} <- {val}")
for note in absent:
print(f" DECLARED ABSENT {note}")
print(f"\n{len(changes)} cell(s) to write, {len(already)} already correct, "
f"{len(absent)} declared absent.")
if args.check:
print("--check: nothing written. Exit 1 if any cell is still owed.")
return 1 if changes else 0
if not args.apply:
print("DRY RUN -- re-run with --apply to write.")
return 0
if not changes:
print("Idempotent: nothing to write.")
return 0
for row, col, val in changes:
row[col] = val
with path.open("w", encoding="utf-8-sig" if bom else "utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=header,
lineterminator="\r\n" if crlf else "\n")
writer.writeheader()
writer.writerows(rows)
print(f"WROTE {path}")
print("NEXT, IN THIS SAME COMMIT: python harness/registry_fidelity.py --emit-baseline")
return 0
if __name__ == "__main__":
sys.exit(main())