Commit 2704f92c by PLN (Algolia)

triangle: catalog confirmation view (A=score ⋈ C=metadata ⋈ B=recording)

Reconcile every track across three corners and light the cells where they
agree or fight, so "is our analysis good?" becomes a measured number.

- build_catalog_view.py: reconciler over 73 tracks; pure build() + thin main(),
  validates against the pydantic CatalogView on emit
- tidal_score.py: parse indented do-block dN (recovered burn_this_book + 13 more)
- agree() taxonomy: unparsed / no-claim / agree / partial / conflict / divergent
  — a parser miss is NEVER reported as a disagreement (the cardinal fix)
- models.py CatalogView → gen_ts_types.py → types.gen.ts (DRY single source)
- tests/: 28 pytest — parser regressions, agree taxonomy, IT-on-real-data with
  coverage regression guards, DRY contract (mechanically tests the metadata prior)
- triangle.html: Ship's Bridge lit-cell dashboard (served by serve.py)
- tasks/013: captain's log

Result: 47 agree / 5 partial / 2 conflict / 3 divergent / 16 no-claim;
recorded 40/73; EDA coverage 1/63 (the gap, now visible). The first conflicts
flagged were our own parser bugs — metadata was righter than the machine.
parent 5bb32aea
---
log: 013
title: "The triangle that checks itself"
date: 2026-06-06
task: "#46–#49 triangle reconciler + tests; #47 model; #48 viz"
tags: [tooling, salvage, epistemics, testing, catalog]
shareable: true
---
## Cap (what & why)
"Is our analysis of ParVagues tracks actually any good?" To answer it instead of
hoping, we built the **triangle**: reconcile every track across three corners —
**A** the `.tidal` score (ground truth from code), **C** the site's gig metadata
(`tracks.json` ingredients), **B** the Ardour recordings — and *light the cells*
where they agree or fight. One view, a measured coverage number.
## Manœuvre (how)
`build_catalog_view.py` joins the corners (73 distinct tracks) and computes an
A↔C agreement per track, reusing `tidal_score`'s own extractor so both sides parse
identically (DRY). Modeled the output as a pydantic `CatalogView` (single source of
truth → generated TS), validated on emit. Standalone `triangle.html` (Ship's Bridge
palette) renders it, served by `serve.py`. Then we did what PLN asked — **tested it
mechanically**: 28 pytest cases (parser regressions, the agree taxonomy, real-corpus
invariants, coverage regression guards, the DRY contract).
## Prise (findings / artifacts)
- 73 tracks · score parsed 73/73 · **A↔C: 47 agree · 5 partial · 2 conflict · 3 divergent · 16 no-claim**.
- **Recording: 40 have a candidate take, 33 unrecorded. EDA coverage = 1/63 takes** — the real gap, now a red number on screen.
- Two flagged "conflicts" turned out to be **our tooling's fault, not the metadata's**:
- *Burn this Book* — score parsed EMPTY because the whole track is an indented
`do`-block and the parser only read column-0 `dN`. `drums_nes`/`cpluck` were there
all along. Fixed the parser; it now agrees.
- *Blue Gold*`ccc0.tidal` (a 30-min workshop ébauche from a given kit) genuinely
has none of the claimed `suns_keys`/`suns_guitar`. The site's `tracks.json` had
**fallen back to the first `.tidal` in the `ccc/` folder** because the real file
(`collab/mousquetaires/blue_gold.tidal`) isn't there. A wrong *link*, not wrong
metadata. New `divergent` level + alias-fragmentation flag catch exactly this.
- Files: `build_catalog_view.py`, `tidal_score.py` (indented-dN), `models.py`
(`CatalogView`), `tools/gen_ts_types.py`, `tests/` (28 green), `triangle.html`.
## Sel (the shareable learning)
The cardinal sin our first pass committed: **`agree()` reported "I couldn't read the
score" as "the score disagrees."** A blind parser that blames the data is worse than
no check at all — we'd have "corrected" correct metadata. The fix is epistemic, not
just code: separate *unparsed* / *divergent* / *conflict*, and never let a parser miss
masquerade as a finding. Katana-first: sharpen the blade before you cut.
## Hameçon (hook)
We built a tool to fact-check the catalog. The first three things it flagged were its
own bugs. The metadata was righter than the machine — and 67 of 73 ParVagues tracks
hide their sounds behind `gFunc` abstractions, so "the score is ground truth" is true,
but *reading* it is the hard part.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -19,7 +19,7 @@ from datetime import date
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
# ── provenance ────────────────────────────────────────────────────────────────
......@@ -220,3 +220,99 @@ class PlayerData(BaseModel):
roleGroups: list[RoleGroup]
note: str = ""
takes: list[Take]
# ── catalog view — the TRIANGLE: A=score ⋈ C=metadata ⋈ B=recording (#46) ─────
# Generated downstream artifact (build_catalog_view.build), validated on emit.
# A confirmation map: does the .tidal score, the site metadata, and the recordings
# agree? Lit-cell dashboard consumes this. (parsers-over-copy; tested mechanically)
class AgreeLevel(str, Enum):
empty = "empty" # neither side has parseable sounds
unparsed = "unparsed" # corner A blind — WE couldn't read it (NOT a conflict)
no_claim = "no-claim" # corner C lists no parseable sound
agree = "agree" # ≥60% of claimed sounds are in the score
partial = "partial" # 30–60%
conflict = "conflict" # 0–30% — real partial disagreement
divergent = "divergent" # zero overlap, both rich → wrong file link / collision
class AgreeResult(BaseModel):
"""A↔C: how well the site's claimed ingredients match the actual score."""
level: AgreeLevel
precision: Optional[float] = None # |C∩A|/|C| — how true the claims are
jaccard: Optional[float] = None
score_only: list[str] = Field(default_factory=list) # in score, not claimed
metadata_only: list[str] = Field(default_factory=list) # claimed, not in score
shared: list[str] = Field(default_factory=list)
class TrackMeta(BaseModel):
"""Corner C facts for one gig appearance (site tracklist, a pointer)."""
gig: str
date: str = ""
bpm: Optional[float] = None
style: Optional[str] = None
dur: Optional[float] = None
class TakeRef(BaseModel):
"""Corner B candidate: a take this track plausibly lives in (date-join, L0)."""
take: str
type: str # track | SET | sketch | empty
via: str # the gig slug that joined them
method: str # date-exact | date±Nd
is_set: bool
eda: Optional[str] = None # path to the take's EDA artifact, if grounded
class TrackRow(BaseModel):
"""One track (identity = its .tidal path), reconciled across the three corners."""
track: str # canonical id = .tidal path
names: list[str] = Field(default_factory=list)
name: str
gigs: list[str] = Field(default_factory=list)
metas: list[TrackMeta] = Field(default_factory=list)
# corner A — score
score_present: bool
n_orbits: int
score: dict[str, str] = Field(default_factory=dict) # {"d1":"kick",…}
score_sounds: list[str] = Field(default_factory=list)
# corner C — metadata
claimed_sounds: list[str] = Field(default_factory=list)
# A↔C
ac: AgreeResult
# corner B — recording
takes: list[TakeRef] = Field(default_factory=list)
n_takes: int
n_takes_eda: int
recorded: bool
alias_siblings: list[str] = Field(default_factory=list) # other files sharing the name
class CatalogStats(BaseModel):
tracks_total: int
score_present: int
score_parsed: int
with_metadata: int
ac_agree: int
ac_partial: int
ac_conflict: int
ac_divergent: int
ac_unparsed: int
ac_no_claim: int
ac_empty: int
alias_fragmented: int
recorded: int
unrecorded: int
with_eda: int
takes_total: int
takes_with_eda: int
gigs_total: int
class CatalogView(BaseModel):
model_config = ConfigDict(populate_by_name=True)
schema_: str = Field(alias="schema") # 'schema' key; alias dodges BaseModel.schema
as_of: str
stats: CatalogStats
tracks: list[TrackRow] = Field(default_factory=list)
"""pytest setup for the tide-table pipeline tests.
Puts the tide-table dir on sys.path so the pipeline modules import as top-level
(`import tidal_score`, `import build_catalog_view`) exactly as they do in prod.
"""
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
TIDE_TABLE = HERE.parent
REPO = TIDE_TABLE.parent.parent # …/Sound/Tidal
sys.path.insert(0, str(TIDE_TABLE))
@pytest.fixture(scope="session")
def repo():
return REPO
@pytest.fixture(scope="session")
def fixtures():
return HERE / "fixtures"
@pytest.fixture(scope="session")
def view():
"""The real pipeline output, built once (IT-on-real-data)."""
import build_catalog_view as bcv
return bcv.build()
-- fixture: whole track wrapped in a `do` block with INDENTED dN (the
-- burn_this_book.tidal idiom that a column-0-only parser read as empty).
main = do
let g = id
d1 $ g $ s "jazz:2"
d3 $ g $ s "drums_nes:3" -- claimed-but-"missing" until the parser fix
d4 $ g $ s "cpluck:1"
d5
$ s "meth_bass"
# orbit 7 -- `# orbit 7` ⇒ d8, not d5
-- d9 $ s "should_be_ignored" (commented-out block must not parse)
"""A↔C agreement taxonomy. Encodes the cardinal lesson: never blame the metadata
for a parser miss — `unparsed` and `divergent` are NOT `conflict`."""
import build_catalog_view as bcv
def test_unparsed_is_not_conflict():
# corner A empty (parser couldn't read it) ⇒ unparsed, never conflict
r = bcv.agree([], ["drums_nes", "cpluck"])
assert r["level"] == "unparsed"
def test_no_claim_when_metadata_silent():
assert bcv.agree(["kick", "snare"], [])["level"] == "no-claim"
def test_empty_both_sides():
assert bcv.agree([], [])["level"] == "empty"
def test_agree_when_claims_present_in_score():
r = bcv.agree(["kick", "snare", "hat"], ["kick", "snare"])
assert r["level"] == "agree"
assert r["precision"] == 1.0
assert r["metadata_only"] == []
assert r["score_only"] == ["hat"] # drums C omitted — benign
def test_partial_band():
# 1 of 3 claimed present → precision .333 → partial
r = bcv.agree(["kick", "x", "y"], ["kick", "a", "b"])
assert r["level"] == "partial"
def test_conflict_thin_overlap():
# 1 of 4 claimed present → .25 → conflict
assert bcv.agree(["kick", "x"], ["kick", "a", "b", "c"])["level"] == "conflict"
def test_divergent_rich_disjoint():
# zero overlap, both sides rich → wrong-link/identity collision (Blue Gold→ccc0)
r = bcv.agree(["moog", "ccc", "909"], ["suns_keys", "suns_guitar"])
assert r["level"] == "divergent"
assert r["precision"] == 0.0
def test_conflict_when_disjoint_but_thin():
# zero overlap but only 1 claimed → conflict, not divergent
assert bcv.agree(["moog", "ccc"], ["zzz"])["level"] == "conflict"
"""Integration test on the REAL corpus: structural invariants + coverage
regression guards. This is the mechanical answer to 'metadata prior; nothing
verified' — the agreement numbers are now asserted, so they can't silently rot.
Ranges (not exact equality) absorb honest content drift while still catching a
regression (e.g. the parser breaking and conflicts spiking)."""
import re
# ── structural invariants (must hold regardless of content) ──────────────────
def test_every_cited_score_parses(view):
"""Corner A: all 73 cited .tidal exist AND parse to a non-empty score.
Guards the do-block / col-0 parser regression catalog-wide."""
s = view["stats"]
assert s["score_present"] == s["tracks_total"]
assert s["score_parsed"] == s["tracks_total"]
assert s["ac_unparsed"] == 0
def test_conflict_and_divergent_require_a_real_score(view):
"""The cardinal rule: a parser miss is NEVER reported as a disagreement."""
for r in view["tracks"]:
if r["ac"]["level"] in ("conflict", "divergent", "partial", "agree"):
assert r["n_orbits"] > 0, f"{r['name']} flagged {r['ac']['level']} on empty score"
def test_metadata_only_is_truly_disjoint_from_score(view):
"""metadata_only must never contain a sound that's also in the score."""
for r in view["tracks"]:
a = set(r["score_sounds"])
assert not (set(r["ac"]["metadata_only"]) & a), r["name"]
def test_candidate_takes_are_real(view):
for r in view["tracks"]:
for t in r["takes"]:
assert re.fullmatch(r"Take\d+", t["take"]), t["take"]
def test_eda_coverage_is_honest(view):
"""The whole point of surfacing the gap: EDA is sparse and we don't hide it."""
s = view["stats"]
assert 0 < s["takes_with_eda"] <= s["takes_total"]
assert s["with_eda"] <= s["recorded"]
# ── coverage regression guards (catch silent rot in the agreement numbers) ───
def test_agreement_distribution_within_bounds(view):
s = view["stats"]
assert s["tracks_total"] == 73 # update consciously if catalog grows
assert s["ac_agree"] >= 45 # was 48 — alarm if it craters
assert s["ac_conflict"] <= 4 # was 2 — alarm if conflicts spike
assert s["ac_divergent"] <= 6 # was 3 — wrong-link suspects
# ── specific cases that taught us the lessons ────────────────────────────────
def _row(view, track):
return next(r for r in view["tracks"] if r["track"] == track)
def test_burn_this_book_now_agrees(view):
r = _row(view, "live/techno/noir/burn_this_book.tidal")
assert r["ac"]["level"] == "agree" # was a false conflict
def test_blue_gold_ccc0_is_divergent_not_conflict(view):
r = _row(view, "live/collab/ccc/ccc0.tidal")
assert r["ac"]["level"] == "divergent" # wrong file link, properly labelled
assert r["alias_siblings"], "should share the name with the real blue_gold.tidal"
# ── DRY contract: the emitted view validates against the pydantic model ───────
def test_view_validates_against_model(view):
"""models.py is the single source of truth; the build must conform to it
(and so must the generated TS, which is derived from the same schema)."""
from models import CatalogView
cv = CatalogView.model_validate(view)
assert cv.stats.tracks_total == len(cv.tracks)
"""Corner C ingredient parsing, the date-join, and the audio_lens family
classifier (the cpluck-as-perc regression)."""
import build_catalog_view as bcv
import audio_lens as al
from sample_tfidf import sound_vocab
def test_ingredient_sounds_parses_codes():
vocab, _ = sound_vocab()
track = {"ingredients": [
{"type": "sample", "code": 's "[kick:4]"'},
{"type": "sample", "code": 's "jungle_breaks:84"'},
{"type": "moment", "code": "highlights"}, # non-sound type → skipped
{"type": "effect", "code": "d10"}, # effect type → skipped
]}
s = bcv.ingredient_sounds(track, vocab)
assert "kick" in s and "jungle_breaks" in s
assert "highlights" not in s
def test_takes_for_gig_exact_fuzzy_and_miss():
takes = [{"date": "2024-10-01", "take": "Take20", "dur": "", "orbits": 13,
"type": "SET", "label": ""}]
exact = bcv.takes_for_gig("2024-10-01", takes)
assert exact and exact[0][1] == "date-exact"
fuzzy = bcv.takes_for_gig("2024-10-03", takes) # within ±3d
assert fuzzy and "date±2d" in fuzzy[0][1]
assert bcv.takes_for_gig("2024-11-01", takes) == [] # outside window
def test_classify_family_cpluck_not_perc():
# the bug that mislabeled cpluck as a drum
assert al.classify_family("cpluck")[0] != "percs"
def test_classify_family_perc_exact():
assert al.classify_family("cp")[0] == "percs"
assert al.classify_family("dr")[0] == "percs"
def test_classify_family_breaks_are_tops():
assert al.classify_family("jungle_breaks")[0] == "tops"
def test_classify_family_bass_register():
assert al.classify_family("meth_bass", {"centroid": 100})[0] == "bass"
"""Corner A — the .tidal score parser. Regression-guards the idiom-blindness
bugs that manufactured false A↔C conflicts."""
import tidal_score as ts
def test_indented_doblock_parses(fixtures):
"""do-block / indented dN must NOT parse to an empty score."""
m = ts.orbit_sounds(fixtures / "doblock.tidal")
assert m, "indented do-block parsed to empty — the burn_this_book bug"
sounds = {d["sound"] for d in m.values()}
assert "drums_nes" in sounds
assert "cpluck" in sounds
def test_orbit_override_maps_to_dn_plus_1(fixtures):
"""`# orbit 7` ⇒ d8 (SuperDirt 0-indexed); d5 header must not survive."""
m = ts.orbit_sounds(fixtures / "doblock.tidal")
assert 8 in m and m[8]["sound"] == "meth_bass"
assert 5 not in m
assert m[8]["orbit_override"] is True
def test_commented_block_ignored(fixtures):
m = ts.orbit_sounds(fixtures / "doblock.tidal")
assert 9 not in m, "a -- commented dN block must not parse"
def test_dn_header_rejects_identifiers():
"""`\\b` after the digits keeps degradeBy / d4bass from matching as headers."""
assert ts.DN_HEADER.match('degradeBy 0.5 $ s "x"') is None
assert ts.DN_HEADER.match(' d4bass = s "x"') is None
assert ts.DN_HEADER.match(' d4 $ s "x"') is not None # indentation OK
assert ts.DN_HEADER.match('d1 $ s "x"') is not None
def test_real_burn_this_book_regression(repo):
"""The exact file that exposed the col-0-only bug: drums_nes + cpluck ARE
in the score (metadata was right all along)."""
m = ts.orbit_sounds(repo / "live/techno/noir/burn_this_book.tidal")
sounds = {d["sound"] for d in m.values()}
assert {"drums_nes", "cpluck"} <= sounds
......@@ -33,8 +33,12 @@ from sample_tfidf import SPLIT, sound_vocab # noqa: E402
# "last token wins" make `$` safe and necessary (kick/snare/meth_bass live there).
SOURCE_CTX = re.compile(r'(?:\bsound\b|\bs\b|#|\$)\s*"([^"]*)"')
# a `dN` block header at column 0 (real, not commented). Tidal comments are `--`.
DN_HEADER = re.compile(r"^(d|p)(\d{1,2})\b")
# a `dN` block header (real, not commented). Tidal comments are `--`. Leading
# whitespace IS allowed: ParVagues often wraps the whole track in a `do` block
# with INDENTED `d1`/`d2`… (e.g. burn_this_book.tidal) — a column-0-only match
# silently parsed those to an empty score. The `(\d{1,2})\b` guard still rejects
# identifiers like `degradeBy`/`d4bass` (no word-boundary after the digits).
DN_HEADER = re.compile(r"^\s*(d|p)(\d{1,2})\b")
ORBIT_OVERRIDE = re.compile(r"#\s*orbit\s+(\d+)")
......
// AUTO-GENERATED from armada/tide-table/models.py — DO NOT EDIT.
// Regenerate: python3 tools/gen_ts_types.py (DRY data layer, #42)
export type AgreeLevel = 'empty' | 'unparsed' | 'no-claim' | 'agree' | 'partial' | 'conflict' | 'divergent'
export type Variant = 'stream' | 'club'
/** A↔C: how well the site's claimed ingredients match the actual score. */
export interface AgreeResult {
level: AgreeLevel
precision?: number | null
jaccard?: number | null
score_only?: string[]
metadata_only?: string[]
shared?: string[]
}
export interface CatalogStats {
tracks_total: number
score_present: number
score_parsed: number
with_metadata: number
ac_agree: number
ac_partial: number
ac_conflict: number
ac_divergent: number
ac_unparsed: number
ac_no_claim: number
ac_empty: number
alias_fragmented: number
recorded: number
unrecorded: number
with_eda: number
takes_total: number
takes_with_eda: number
gigs_total: number
}
export interface LoudTrace {
trace: number[]
stepS: number
......@@ -45,6 +78,45 @@ export interface Take {
orbits: OrbitActivity[]
}
/** Corner B candidate: a take this track plausibly lives in (date-join, L0). */
export interface TakeRef {
take: string
type: string
via: string
method: string
is_set: boolean
eda?: string | null
}
/** Corner C facts for one gig appearance (site tracklist, a pointer). */
export interface TrackMeta {
gig: string
date?: string
bpm?: number | null
style?: string | null
dur?: number | null
}
/** One track (identity = its .tidal path), reconciled across the three corners. */
export interface TrackRow {
track: string
names?: string[]
name: string
gigs?: string[]
metas?: TrackMeta[]
score_present: boolean
n_orbits: number
score?: Record<string, string>
score_sounds?: string[]
claimed_sounds?: string[]
ac: AgreeResult
takes?: TakeRef[]
n_takes: number
n_takes_eda: number
recorded: boolean
alias_siblings?: string[]
}
export interface PlayerData {
track: string
calibration: string
......@@ -61,3 +133,10 @@ export interface Note {
t: number
text: string
}
export interface CatalogView {
schema: string
as_of: string
stats: CatalogStats
tracks?: TrackRow[]
}
......@@ -14,9 +14,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "armada" / "tide-table"))
from models import Note, PlayerData # noqa: E402
from models import CatalogView, Note, PlayerData # noqa: E402
ROOTS = [PlayerData, Note] # top-level UI-facing models
ROOTS = [PlayerData, Note, CatalogView] # top-level UI-facing models
OUT = ROOT / "armada" / "ui" / "src" / "types.gen.ts"
SCALARS = {"string": "string", "number": "number", "integer": "number",
"boolean": "boolean", "null": "null"}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment