Commit f92f4455 by PLN (Algolia)

feat(tools): button-column-audit — 174 tracks still speak the pre-remap layout

PLN, mid-session in the cafe tracks: "some d1 had still 42 not 41 as button, did
we not do all refact? doesnt our tools shout at such error snow?"

Both halves were right, and the answers are different.

DID WE NOT DO ALL REFACT — no. CC 41 used to be gMask, so while it was, an
orbit's gate started a column late: d1 -> ^42, d2 -> ^43. Retiring gMask freed 41
and REMAP PHASE 2 (5910aacc, 2026-07-29) column-aligned the board. That commit
touched FOURTEEN FILES — the OPAL set, not the corpus. 174 of 696 tracks in live/
still carry the old layout, cafe_glace / cafe_tiede / cafe_bouillant among them.
Measured: every single shifted file is +1. Not 174 typos, one layout generation.

DOESNT OUR TOOLS SHOUT — they structurally cannot. pvlint's button rule is PV008,
"one physical button driving two orbits", and it fires on a COLLISION. A
uniformly shifted file has no collision: every orbit is off by the same amount,
so no two orbits ever meet on the same button. The file slides one column over
and stays internally consistent. PV008 is blind to it by construction, and it is
the rule that caught the perfect.tidal mis-migration — so this is a genuine gap,
not a missing check someone forgot to run.

WHY THIS IS NOT A NEW PVLINT RULE
PV014 set the house bar: measure the convention before encoding it (it earned its
place at 894/984 = 90.9%). The same measurement for buttons:

    button refs on orbits 1-8      2700
    on the orbit's OWN column       963   (35.7%)
    on ANOTHER column              1737   (64.3%)

35.7% is a minority, not a convention. A strict "column N = orbit N" rule would
emit 1737 findings and hold the pre-gig gate shut over the corpus's normal state.
The crisp signal is the shift SIGNATURE — >=3 orbits sharing one non-zero offset
— which is one finding per FILE (174) instead of one per reference (1737).

So this ships as a standalone lens, not a gate rule: it cannot break gig-up.sh
while PLN is away, and severity stays his call.

Migration is migrate-columns.py's job and is POST-GIG on purpose: retuning 174
tracks' muscle memory days before a set is how you reach for the wrong button on
stage.
parent e65e3a87
#!/usr/bin/env python3
"""button-column-audit — which tracks still carry the PRE-REMAP button layout?
THE QUESTION THIS ANSWERS
-------------------------
2026-08-15, PLN, mid-session in the cafe tracks:
"some d1 had still 42 not 41 as button, did we not do all refact?
doesnt our tools shout at such error snow?"
Both halves are right, and the answer to each is worth writing down.
WHY d1 IS ON ^42
----------------
CC 41 used to be gMask. While it was, an orbit's gate row started one column
late: d1 -> ^42, d2 -> ^43, and so on. Retiring gMask FREED 41, and REMAP PHASE 2
(5910aac, 2026-07-29) column-aligned the board so that column N IS orbit N --
d1's gate became ^41.
That commit touched FOURTEEN FILES. It was the OPAL set, not the corpus. Every
track not in those fourteen still speaks the old layout, and cafe_glace /
cafe_tiede / cafe_bouillant are three of them. So "did we not do all refact?" --
no. The remap was scoped to the set being played, and nothing since has widened
it.
WHY NOTHING SHOUTED
-------------------
pvlint DOES have a button rule: PV008, "one physical button driving two orbits".
It is how the perfect.tidal mis-migration was caught. But PV008 fires on a
COLLISION -- two orbits reaching for one CC. A uniformly shifted file has no
collision: every orbit is off by the same amount, so no two orbits ever meet on
the same button. The whole file slides one column over and stays internally
consistent. PV008 is structurally blind to it, and so is every other rule.
This tool is that missing lens. It is deliberately NOT a pvlint rule -- see the
measurement below for why.
WHY THIS IS NOT A LINT RULE
---------------------------
PV014 set the house standard for encoding a convention: measure it first. It
earned its place at 894/984 = 90.9% conformance. The same measurement for
buttons, across live/:
button refs on orbits 1-8 2700
on the orbit's OWN column 963 (35.7%)
on ANOTHER column 1737 (64.3%)
files with >=1 off-column ref 306 of 696
35.7% is not a convention, it is a minority. A strict "column N = orbit N" rule
would emit 1737 findings and hold the gig gate shut over the corpus's normal
state. ([[feedback_stated_invariant_is_a_hypothesis]] -- "column N IS orbit N" is
the SETTLED DESIGN, and a hypothesis about the corpus. The corpus says no.)
What IS crisp is the shift SIGNATURE: a track where three or more orbits share
one non-zero column offset. That is a layout generation, not a typo, and it is
one finding per file instead of one per reference.
USAGE
-----
python3 tools/button-column-audit.py # summary + shifted files
python3 tools/button-column-audit.py --stats # conformance numbers only
python3 tools/button-column-audit.py live/midi/... # specific files
python3 tools/button-column-audit.py --paths # bare paths, for xargs
Reports only. Migrating them is tools/migrate-columns.py's job, and it is a
POST-GIG move -- retuning 174 tracks' muscle memory days before a set is how you
arrive on stage reaching for the wrong button.
"""
from __future__ import annotations
import argparse
import collections
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from pvlint.core import load, strip_comment # noqa: E402
from pvlint.rules import BUTTON_CCS, FAMILY_CCS, CC_IN_CODE # noqa: E402
import lcxl_grid as grid # noqa: E402
# Column of each button CC, derived from the one authored grid rather than
# hardcoded -- a second copy of the map is how the HUD's went stale (#97).
COLUMN: dict[int, int] = {}
for _row in ("E", "F"):
for _col, _cc in grid.as_dict()["rows"][_row]["columns"].items():
COLUMN[int(_cc)] = int(_col)
HOME = {o: grid.orbit_home(o) for o in range(1, 13)}
MIN_ORBITS_FOR_SHIFT = 3 # below this it is a one-off, not a layout
def button_sites(track):
"""(orbit, line, cc) for every non-family button ref on orbits 1-8."""
for orb in track.orbits():
if not 1 <= orb.number <= 8:
continue
for off, ln in enumerate(orb.lines):
for m in CC_IN_CODE.finditer(strip_comment(ln)):
cc = int(m.group(1))
if cc in BUTTON_CCS and cc not in FAMILY_CCS:
yield orb.number, orb.start + off, cc
def audit(path: pathlib.Path):
"""-> (dominant_shift, orbits_shifted, orbits_on_home, refs, off_home)"""
try:
track = load(str(path))
except Exception:
return None
deltas: dict[int, set[int]] = collections.defaultdict(set)
refs = off_home = 0
for orbit, _line, cc in button_sites(track):
refs += 1
home = HOME.get(orbit, {})
if cc not in (home.get("gate"), home.get("gate2")):
off_home += 1
if cc in COLUMN:
deltas[COLUMN[cc] - orbit].add(orbit)
shifted = {d: o for d, o in deltas.items()
if d != 0 and len(o) >= MIN_ORBITS_FOR_SHIFT}
if not shifted:
return (0, [], sorted(deltas.get(0, ())), refs, off_home)
dom = max(shifted, key=lambda d: len(shifted[d]))
return (dom, sorted(shifted[dom]), sorted(deltas.get(0, ())), refs, off_home)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("paths", nargs="*", help="files (default: all of live/)")
ap.add_argument("--stats", action="store_true", help="conformance numbers only")
ap.add_argument("--paths", dest="bare", action="store_true",
help="print bare paths of shifted tracks, for xargs")
args = ap.parse_args()
files = ([pathlib.Path(p) for p in args.paths] if args.paths
else sorted(pathlib.Path("live").rglob("*.tidal")))
refs = off_home = 0
shifted, by_delta = [], collections.Counter()
for f in files:
got = audit(f)
if got is None:
continue
dom, orbs, home_orbs, r, oh = got
refs += r
off_home += oh
if dom:
shifted.append((f, dom, orbs, home_orbs))
by_delta[dom] += 1
if args.bare:
for f, *_ in shifted:
print(f)
return 0
if refs:
print(f"button refs on orbits 1-8 {refs}")
print(f" on the orbit's OWN column {refs - off_home:5d} "
f"({100 * (refs - off_home) / refs:.1f}%)")
print(f" on ANOTHER column {off_home:5d} "
f"({100 * off_home / refs:.1f}%)")
print(f"files scanned {len(files)}")
print(f"files with a LAYOUT SHIFT {len(shifted)} "
f"(>={MIN_ORBITS_FOR_SHIFT} orbits sharing one offset)")
for d, n in sorted(by_delta.items()):
print(f" shift {d:+d}: {n} files"
+ (" <- the pre-remap layout (gMask still held CC 41)"
if d == 1 else ""))
if not args.stats and shifted:
print("\nshifted tracks:")
for f, dom, orbs, home_orbs in sorted(shifted, key=lambda x: -len(x[2])):
home = f" on-home: d{home_orbs}" if home_orbs else ""
print(f" {dom:+d} d{orbs}{home}\n {f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
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