Commit 3060f506 by PLN (Algolia)

feat(pvlint): PV008 — one physical button driving two orbits, the #94 bug as a permanent rule

Earned during the phase-2 button remap (5910aacc). The migrator left an over-budget
control where it was, which LOOKS conservative and is the opposite: once ^42 became
d2's button, d1's surviving reference to ^42 meant one press fired both orbits.
perfect.tidal shipped that way until a hand-written check found it — and that check
then existed only in a terminal scrollback, which is not a gate.

WHY IT DESERVES TO BE AN ERROR, NOT A WARNING
Nothing errors. Nothing goes silent. The extra layer only appears while a specific
button is held. On stage that reads as "the track is broken today" rather than as a
mapping bug, which is the exact signature of the failures this rig keeps losing
evenings to. Same family as the orphan-orbit ghost: audible, plausible, and invisible
to every static check we had.

WHAT IT DOES NOT FLAG — the cry-wolf cases, each a real line from the corpus
  * gF1-3 and gMute1-3 (and gPanic) are per-FAMILY BY DESIGN — measured, not assumed:
    gF1 -> d1/d2/d3/d8, gF2 -> d4, gF3 -> leads. Flagging those would fire on every
    track in the set and the rule would be switched off within a day.
  * one orbit referencing a button three times is ONE gesture, not three collisions
    (bombe_dj's kick does exactly that) — findings are per ORBIT, as in PV004.
  * COMMENTED-OUT gestures. This matters more than it looks: commenting the surplus
    is precisely how #94 parks an over-budget control, so a rule that counted
    comments would fail every track the migrator just fixed.
  * knobs. Knob sharing is a columns question, not a button collision.

VALIDATION
  OPAL setlist        0 PV008 findings — clean, as of 5910aacc
  whole corpus        136 findings across 56 of 792 files
  pvlint tests        38 passed (5 new: the real perfect.tidal case, plus one
                      negative per cry-wolf class above)

The corpus number is history, not exposure: those are old-world tracks that predate
the column grid, and per the measure-the-set-not-the-corpus lesson the number that
matters before a gig is the setlist's, which is zero. The corpus gets fixed when it
gets migrated (#64 / post-gig), and now there is a rule that will prove it.
parent 5910aacc
......@@ -410,6 +410,65 @@ def pv007_orbit_inventory(track: Track) -> Iterable[Finding]:
)
# --------------------------------------------------------------------------
# PV008 — one physical button driving two orbits
# --------------------------------------------------------------------------
# BT (top button row) and BL (bottom). BL1-3 are gMute1/2/3, the per-family
# mutes, so they are SUPPOSED to be shared and are excluded below.
BT_CCS = set(range(41, 45)) | set(range(57, 61))
BL_CCS = set(range(73, 77)) | set(range(89, 93))
FAMILY_CCS = {49, 50, 51, 73, 74, 75, 93} # gF1-3, gMute1-3, gPanic
CC_IN_CODE = re.compile(r'"\^(\d+)"')
@rule
def pv008_button_drives_two_orbits(track: Track) -> Iterable[Finding]:
"""One button, two orbits: pressing d2's gate also fires d1's.
Earned on 2026-07-29, during the phase-2 button remap (#94). The migrator
left an over-budget control where it was — which LOOKS conservative and is
the opposite. Once ^42 became d2's button, d1's surviving reference to ^42
meant one press drove both, and perfect.tidal shipped that way until a
hand-written check found it.
The symptom is why this is a rule and not a note: nothing errors, nothing is
silent, and the extra layer only appears when a specific button is held. On
stage that reads as "the track is broken today", not as a mapping bug.
Family controls are excluded on purpose: gF1-3 and gMute1-3 are per-FAMILY
by design (measured across the corpus), so sharing them is correct.
"""
owners: dict[int, dict[int, int]] = {} # cc -> {orbit: first line}
for orb in track.orbits():
if not 1 <= orb.number <= 8:
continue
for ln_off, ln in enumerate(orb.lines):
for m in CC_IN_CODE.finditer(strip_comment(ln)):
cc = int(m.group(1))
if cc in (BT_CCS | BL_CCS) and cc not in FAMILY_CCS:
owners.setdefault(cc, {}).setdefault(
orb.number, orb.start + ln_off)
for cc, per_orbit in sorted(owners.items()):
if len(per_orbit) < 2:
continue
where = ", ".join(f"d{o}" for o in sorted(per_orbit))
# One finding per ORBIT sharing it, anchored at that orbit's first use,
# so each place needing an edit is reported once (as PV004 does).
for _orbit, line in sorted(per_orbit.items()):
yield Finding(
rule="PV008",
severity="error",
line=line,
message=f"button ^{cc} is driven by {where}",
detail="One press fires both orbits. Nothing errors and nothing "
"is silent, so this only shows up as an unexplained extra "
"layer mid-set. Give the over-budget orbit its own control, "
"or comment the surplus gesture out with a FIXME until "
"gSel can fold it onto one control.",
)
def check(track: Track, enabled: set[str] | None = None) -> list[Finding]:
out: list[Finding] = []
for fn in RULES:
......
......@@ -212,3 +212,59 @@ def test_pv007_reports_the_orbit_inventory():
src = 'd1 $ s "bd"\n\nd7 $ s "sn"\n'
f = lint(src, "PV007")
assert len(f) == 1 and "d1,d7" in f[0].message
# ------------------------------------------------------------------- PV008
def test_pv008_flags_one_button_driving_two_orbits():
"""The real perfect.tidal bug: d1 kept ^42 after d2 was given ^42.
One press fires both. Nothing errors, nothing goes silent — it only shows up
as an unexplained extra layer while a button is held.
"""
src = ('d1 $ midiOn "^42" (<| "k k")\n\n'
'd2 $ midiOn "^42" (mask "t f")\n')
f = lint(src, "PV008")
assert len(f) == 2 # one per orbit needing an edit
assert all(x.severity == "error" for x in f)
assert "^42" in f[0].message and "d1, d2" in f[0].message
def test_pv008_one_orbit_using_a_button_many_times_is_fine():
"""Three references from ONE orbit is one gesture, not a collision."""
src = ('d1 $ midiOn "^42" (<| "k k")\n'
' $ midiOff "^42" (<| "k ~")\n'
' $ midiOn ("^41" - "^42") (<| "k*2")\n')
assert lint(src, "PV008") == []
def test_pv008_ignores_the_family_controls():
"""gMute1-3 and gF1-3 are per-FAMILY by design — sharing them is correct.
Measured across the corpus: gF1 -> d1/d2/d3/d8, gF2 -> d4, gF3 -> leads.
A rule that flagged those would fire on every track in the set.
"""
src = ('d1 $ midiOn "^73" (mask "f*16")\n\n'
'd2 $ midiOn "^73" (mask "f*16")\n\n'
'd3 $ midiOn "^49" (# lpf 400)\n\n'
'd4 $ midiOn "^49" (# lpf 400)\n')
assert lint(src, "PV008") == []
def test_pv008_ignores_knobs():
"""Knob sharing is a different question (columns), not a button collision."""
src = ('d1 $ midiOn "^29" (# crush 4)\n\n'
'd2 $ midiOn "^29" (# crush 4)\n')
assert lint(src, "PV008") == []
def test_pv008_ignores_commented_out_gestures():
"""A commented gesture is exactly how #94 parks an over-budget control.
If this flagged, every track the migrator touched would fail the gate — the
cry-wolf failure that makes a pre-gig check worthless.
"""
src = ('d1 $ s "bd"\n'
' -- $ midiOn "^42" (<| "k k")\n\n'
'd2 $ midiOn "^42" (mask "t f")\n')
assert lint(src, "PV008") == []
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