Commit 2648074d by PLN (Algolia)

fix(boot): the #55 seed NEVER RAN — Pulsar fused three statements into one (#79)

PLN this morning: "moving to track 2, wap.. no bass? when i move the knob C5 it
starts sounding". And on do_it_right: "ctrl_enter, i hear the 4-bar pattern each
bar lower, 4th barely audible, its clearly a xfade".

Both sentences are the same bug, and the second one hid the first for days.

THE MASK. Every dN is `xfade N` with xfadeIn 4, so the four bars you hear after
an eval are the PREVIOUS pattern leaving. A silent eval gets a graceful exit and
reads as a "drift to silence". The sound after ctrl+enter is not evidence the
eval worked — it is evidence the last one did.

THE BUG. Pulsar does not feed BootTidal.hs to ghci verbatim. boot-tidal.js splits
it on BLANK LINES and strips the `:{`/`:}` it finds; repl.js tidalSendExpression
then wraps each chunk in its OWN `:{ ... :}`. A ghci `:{ ... :}` accepts exactly
ONE statement. The #55 seed was written as

    :{
    let _seed = concat [ ... ]
    :}
    mapM_ (\(k, v) -> setF k (pure v)) _seed
    putStrLn "[BootTidal] seeded ..."

— correct for a file read line-by-line, and fused by Pulsar into a single
statement that dies with "parse error (possibly incorrect indentation or
mismatched brackets)".

So THE SEED NEVER RAN. Not once between 2026-07-27, when it was written, and
today. Every boot left the control map empty. An untouched "^NN" yields NO
EVENTS — not 0, nothing — so any `# param (range a b "^NN")` emptied its whole
orbit, and the rig only made those sounds after a knob was physically moved.
Which is precisely what PLN described, in the sentence I had filed as a separate
question about crush ranges.

MEASURED, with tools/silent-eval.py (added earlier today) over the OPAL setlist:
    empty control map   23 orbits silent across 10 of 10 buildable tracks,
                        including desire d1-d6 — the ENTIRE track
    with the seed       ZERO silent. Every declared orbit emits events.
The seed is necessary AND sufficient. The tracks were never the problem.

THE FIX is two blank lines. They are load-bearing and the file now says so.
Verified through the real seam, not by inspection: replicating Pulsar's exact
chunking and wrapping and feeding it to ghci now yields 51 setF calls with the
right values (13=0.0, 49=0.5, 77=0.769, 78=1.0 ...) and prints the boot banner.
Before the fix the same harness produced only the parse error.

WHY EVERY EXISTING GUARD MISSED IT. check-boot.sh passes 1-3 prove the helper
block typechecks, the seed block typechecks, and the helpers emit events against
an empty control map. All three were green throughout. They test the Haskell; the
failure was in how the file is CHUNKED AND FED. Green checks on a component say
nothing about whether the data reaches it — the rig's signature failure, and this
is the purest instance of it yet. #61 was closed on exactly that false comfort.

So this adds check-boot.sh pass 4 / tools/check-boot-blocks.py, which replicates
Pulsar's chunking bug-for-bug (the non-global .replace included), feeds every
block to a bare ghci, and fails on parse errors only — "not in scope" and type
errors are expected without Tidal and are ignored. Negative-tested against the
pre-fix file: it names block 26 at BootTidal.hs:663 and exits 1.

One bug found in the guard before trusting it: capturing stdout and stderr
separately and concatenating them put every marker before every error, so each
parse error was attributed to the LAST block rather than its own — it confidently
blamed block 28. Now one interleaved stream.
parent 02cdd985
...@@ -709,7 +709,30 @@ let _seed = concat ...@@ -709,7 +709,30 @@ let _seed = concat
, [ ("1", 0), ("21", 0) ] , [ ("1", 0), ("21", 0) ]
] :: [(String, Double)] ] :: [(String, Double)]
:} :}
-- ONE STATEMENT PER BLANK-LINE-DELIMITED BLOCK. THIS IS NOT STYLE (#79).
--
-- Pulsar does not feed this file to ghci verbatim. pulsar-tidalcycles splits it
-- on BLANK LINES, strips the `:{`/`:}` it finds, and re-wraps each chunk in its
-- own `:{ ... :}` (repl.js tidalSendExpression). A `:{ ... :}` accepts exactly
-- ONE statement — so when `let _seed = ...`, the `mapM_` and the `putStrLn` sat
-- in one blank-line block, they were fused into a single statement and the whole
-- thing died with "parse error (possibly incorrect indentation or mismatched
-- brackets)".
--
-- Consequence: THE SEED NEVER RAN. Not once between 2026-07-27, when it was
-- written, and 2026-07-29. Every boot left the control map empty, so 23 orbits
-- across the OPAL set emitted NOTHING until a knob was physically moved — which
-- is exactly what PLN reported: "when i move the knob C5 it starts sounding".
--
-- It typechecked perfectly the whole time. check-boot.sh proved the Haskell was
-- valid Haskell, which it was; the failure was in the SEAM — how the file is
-- chunked and fed. Verified green code that never executes is this rig's
-- signature failure, and this is the purest example of it yet.
--
-- The blank lines below are load-bearing. Do not tidy them away.
mapM_ (\(k, v) -> setF k (pure v)) _seed mapM_ (\(k, v) -> setF k (pure v)) _seed
putStrLn ("[BootTidal] seeded " ++ show (length _seed) ++ " LCXL controls (#55) — untouched knobs no longer silence their stream") putStrLn ("[BootTidal] seeded " ++ show (length _seed) ++ " LCXL controls (#55) — untouched knobs no longer silence their stream")
:set prompt "tidal> " :set prompt "tidal> "
......
#!/usr/bin/env python3
"""check-boot-blocks — every block of BootTidal.hs must PARSE the way Pulsar sends it.
Why this exists (2026-07-29, J-5 to OPAL, #79)
----------------------------------------------
Pulsar does not feed BootTidal.hs to ghci verbatim. pulsar-tidalcycles does this
(lib/boot-tidal.js + lib/repl.js):
text.split('\\n\\n') -- chunk on BLANK LINES
.map(b => b.replace(':{','').replace(':}',''))
...then tidalSendExpression() wraps EACH chunk in its own `:{ ... :}`
and a ghci `:{ ... :}` accepts exactly ONE statement.
So the file's own `:{ :}` markers are meaningless — what decides the chunking is
BLANK LINES, and any chunk holding more than one statement is a parse error.
That is not hypothetical. The #55 control seed was written as
:{
let _seed = concat [ ... ]
:}
mapM_ (\\(k, v) -> setF k (pure v)) _seed
putStrLn "[BootTidal] seeded ..."
which is correct in a file read line-by-line, and which Pulsar fused into one
statement. It died with "parse error (possibly incorrect indentation or
mismatched brackets)" on EVERY boot from 2026-07-27 to 2026-07-29. The seed never
ran once. 23 orbits across the OPAL set emitted nothing until a knob was
physically moved, which is what PLN heard as "no bass — when i move the knob C5
it starts sounding".
It typechecked the whole time. check-boot.sh proved the Haskell was valid
Haskell, and it was. The failure was in the SEAM — how the file gets chunked and
fed — and green checks on a component say nothing about whether the data reaches
it. This guard tests the seam.
Method
------
Replicate Pulsar's chunking exactly, wrap each chunk as repl.js does, and feed
the lot to a bare ghci. Then look ONLY for parse errors: a bare ghci has no Tidal
in scope, so "Variable not in scope" and type errors are EXPECTED and ignored.
A parse error is not about scope — it means the chunk is not one statement, and
it will fail identically in the real boot.
No Tidal, no stream, no scsynth, no port 6010. Safe to run mid-set.
Usage
-----
tools/check-boot-blocks.py [BootTidal.hs]
Exit 0 = every block parses as a single statement.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MARKER = "@@PVBLOCK"
def pulsar_blocks(text: str) -> list[tuple[int, str]]:
"""(1-indexed start line, chunk) exactly as pulsar-tidalcycles produces them.
The `.replace()` calls are deliberately NON-global, mirroring the JS: it uses
string arguments, which replace only the first occurrence. Reproducing the
bug-for-bug behaviour is the whole point — a "cleaner" split here would test
something Pulsar never does.
"""
out: list[tuple[int, str]] = []
line = 1
for raw in text.split("\n\n"):
chunk = raw.replace(":{", "", 1).replace(":}", "", 1)
if raw.startswith(":set"):
for sub in chunk.split("\n"):
out.append((line, sub))
line += 1
else:
out.append((line, chunk))
line += raw.count("\n") + 1
line += 1 # the blank line that delimited the chunk
return out
def build_script(blocks: list[tuple[int, str]]) -> str:
parts = []
for i, (start, chunk) in enumerate(blocks):
if not chunk.strip():
continue
parts.append(f'putStrLn "{MARKER} {i} {start}"')
if chunk.startswith(":set") or chunk.startswith(":"):
parts.append(chunk) # ghci directives are not wrappable
else:
parts.append(":{\n" + chunk + "\n:}")
return "\n".join(parts) + "\n"
def main(argv: list[str]) -> int:
boot = Path(argv[1]) if len(argv) > 1 else ROOT / "BootTidal.hs"
if not boot.exists():
print(f"check-boot-blocks: no such file: {boot}", file=sys.stderr)
return 2
blocks = pulsar_blocks(boot.read_text())
script = build_script(blocks)
print(f"check-boot-blocks: {len(blocks)} block(s) from {boot.name}, "
f"fed as pulsar-tidalcycles feeds them ...")
try:
# STDOUT+STDERR must be ONE INTERLEAVED stream. Capturing them separately
# and concatenating puts every marker before every error, so each error
# gets attributed to the LAST block instead of its own — which is exactly
# how the first run of this tool blamed the wrong block.
r = subprocess.run(["ghci", "-v0"], input=script, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=600)
except FileNotFoundError:
print("check-boot-blocks: SKIP — ghci not on PATH", file=sys.stderr)
return 0
except subprocess.TimeoutExpired:
print("check-boot-blocks: FAIL — ghci timed out", file=sys.stderr)
return 2
# Attribute each parse error to the block whose marker preceded it.
cur: tuple[int, int] | None = None
bad: dict[tuple[int, int], list[str]] = {}
for ln in r.stdout.splitlines():
if MARKER in ln:
i, start = ln.split(MARKER)[1].split()[:2]
cur = (int(i), int(start))
continue
if "parse error" in ln and cur is not None:
bad.setdefault(cur, []).append(ln.strip())
if not bad:
print("check-boot-blocks: OK — every block parses as a single statement.")
return 0
print("", file=sys.stderr)
for (i, start), errs in sorted(bad.items()):
print(f"check-boot-blocks: FAIL — block {i} (starts near "
f"{boot.name}:{start}) is NOT one statement:", file=sys.stderr)
for e in errs[:3]:
print(f" {e}", file=sys.stderr)
preview = blocks[i][1].strip().splitlines()
for pl in preview[:4]:
print(f" | {pl}", file=sys.stderr)
if len(preview) > 4:
print(f" | ... ({len(preview)} lines)", file=sys.stderr)
print("", file=sys.stderr)
print("Fix: put a BLANK LINE between the statements. Pulsar chunks this file",
file=sys.stderr)
print("on blank lines and wraps each chunk in its own `:{ ... :}`, which",
file=sys.stderr)
print("holds exactly ONE statement. This is how the #55 seed silently never",
file=sys.stderr)
print("ran for two days (#79).", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
...@@ -177,3 +177,18 @@ else ...@@ -177,3 +177,18 @@ else
echo "check-boot: WARN — no #55 control-seed block found (untouched knobs" >&2 echo "check-boot: WARN — no #55 control-seed block found (untouched knobs" >&2
echo " will silence their streams; see BootTidal.hs history)." >&2 echo " will silence their streams; see BootTidal.hs history)." >&2
fi fi
# --- Pass 4: does each block PARSE THE WAY PULSAR SENDS IT? (#79) ----------
# Passes 1-3 all prove things about the Haskell. None of them can see the bug
# that actually silenced the rig: Pulsar chunks this file on BLANK LINES and
# wraps each chunk in its own `:{ ... :}`, which holds exactly ONE statement.
# The #55 seed put `let _seed`, `mapM_` and `putStrLn` in one chunk, so it was
# a parse error on every boot for two days and the seed never ran — while every
# check above stayed green. Verified code that never executes is this rig's
# signature failure; this pass tests the SEAM.
if [ -x "$ROOT/tools/check-boot-blocks.py" ]; then
if ! python3 "$ROOT/tools/check-boot-blocks.py" "$BOOT"; then
echo "check-boot: FAIL — a block will not parse as Pulsar sends it." >&2
exit 1
fi
fi
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