Strip old dubtitle at mux + isolate it from pipeline context #4

Closed
xenarathon wants to merge 199 commits from feat/strip-and-isolate-old-dubtitles into main
Owner

What

The pipeline embedded its output as a subtitle track named Dubtitles and then treated that track's presence as "done" — so a regeneration silently no-op'd unless a separate heavy pre-strip pass ran first. Worse, the track is codec=ass, language=eng, so every stage that reads the fansub as context picked it up as if it were the human fansub and repaired / aligned / mined the new run against the previous run's own mistakes.

Context isolation — one shared marker, common.TRACK_NAME:

  • common.eng_sub_streams() now queries stream_tags=language,title and drops any stream titled Dubtitles. It is the single chokepoint for repair.py, tools/timing_compare.py and dub_signs_merge.py, which inherit the fix with no code change.
  • mine_glossary.eng_sub_text() has its own selector and got the same exclusion.
  • No fallback by design: an episode whose only English sub is our old dubtitle runs reference-free rather than reading itself.

Strip-at-mux:

  • mux.keep_sub() drops any track named TRACK_NAME as its first check, so build_cmd's single mkvmerge pass drops the old track and appends the new one — a replace, not a duplicate, and self-healing if a past run left several.
  • mux.process() skips on the stamp alone; the ffprobe "already-muxed" backstop is gone (re-mux is now idempotent). generate.py's SKIP_IF_MUXED guard and its has_dubtitles_track() go with it.

Regeneration trigger:

  • common.PIPELINE_VERSION (1) is recorded in .dubtitles.done; stamp_valid() rejects anything older. GRANDFATHER_VERSION (1) covers pre-versioning stamps, so the rollout regenerates nothing. Bumping PIPELINE_VERSION is the operator's deliberate signal for a global in-place regeneration.
  • scripts/migrate_write_v1_stamps.py stamps files that carry a Dubtitles track but no stamp (dry-run by default), so removing the backstop doesn't trigger a mass regeneration on deploy.

Review findings fixed in this branch

Two review passes ran against the first cut; both found real defects, each now covered by a test:

  • -s is a whitelist and mkvmerge's default is copy-every-subtitle-track. Omitting -s on an empty keep list copied back the very track dropped reported as removed — so every mp4-origin episode, and the "only English sub is ours" case, would have ended up with two Dubtitles tracks. verify() is presence-only, so it would have passed and been stamped. An empty keep list now emits an explicit -S. (The test covering that path had asserted the broken command shape.)
  • A version bump could be silently defeated. The sidecar-existence skips aren't version-aware, so a file with a stale stamp and a leftover sidecar had generate return already-ass forever while mux re-embedded the old subtitle and stamped it current. Fixed via common.stale_version_stamp() + generate.discard_stale_sidecars(). A second pass caught that the first cut of that fix deleted freshly transcribed sidecars — so a sidecar now counts as a leftover only if it predates the stamp, and .dubtitles.fail episodes are exempt.
  • common.is_our_track() is now the one predicate for both track shapes (ffprobe tags.title, mkvmerge properties.track_name) so the exclude-from-context and drop-at-mux tests can't drift.
  • A failed write_stamp returned a bare error after the remux had already landed — a silent per-sweep full remux, forever. Now stamp-write-failed, logged loudly, sidecars kept for retry.
  • stamp_valid() no longer raises on a non-int version (that check runs outside mux.process()'s try, so one corrupt sidecar aborted a whole sweep); properties: None hardening across mux.

Testing

395 tests pass; ruff clean on the CI target (now including common.py, mine_glossary.py, scripts/). Every new guard was verified honest by reverting its hunk and watching the test fail.

Still pending — needs real media on the server:

  • Dry-run mux.py on an already-dubbed episode; expect drop-tracks=['sub:Dubtitles(old)'].
  • After a real --apply, confirm mkvmerge -J shows exactly one Dubtitles track. Use an mp4-origin episode — that's the shape the -S bug hid in.
  • Run scripts/migrate_write_v1_stamps.py (dry run, then --apply) before deploying, so no-stamp files don't mass-regenerate.
  • Bump PIPELINE_VERSION on a test show and confirm in-place regeneration.

Notes

  • strip_op.py (referenced in the spec's non-goals) does not exist in this repo and never has — nothing to deprecate.
  • The new Dubtitles track is appended last, so if an old one sat mid-list the dubtitle moves to the end. Players use the default-track flag; index-based scripts may see a different position. Documented in the README.

🤖 Generated with Claude Code

## What The pipeline embedded its output as a subtitle track named `Dubtitles` and then treated that track's presence as "done" — so a regeneration silently no-op'd unless a separate heavy pre-strip pass ran first. Worse, the track is `codec=ass, language=eng`, so every stage that reads the fansub *as context* picked it up as if it were the human fansub and repaired / aligned / mined the new run against the previous run's own mistakes. **Context isolation** — one shared marker, `common.TRACK_NAME`: - `common.eng_sub_streams()` now queries `stream_tags=language,title` and drops any stream titled `Dubtitles`. It is the single chokepoint for `repair.py`, `tools/timing_compare.py` and `dub_signs_merge.py`, which inherit the fix with no code change. - `mine_glossary.eng_sub_text()` has its own selector and got the same exclusion. - No fallback by design: an episode whose only English sub is our old dubtitle runs **reference-free** rather than reading itself. **Strip-at-mux:** - `mux.keep_sub()` drops any track named `TRACK_NAME` as its first check, so `build_cmd`'s single mkvmerge pass drops the old track and appends the new one — a replace, not a duplicate, and self-healing if a past run left several. - `mux.process()` skips on the stamp alone; the ffprobe "already-muxed" backstop is gone (re-mux is now idempotent). `generate.py`'s `SKIP_IF_MUXED` guard and its `has_dubtitles_track()` go with it. **Regeneration trigger:** - `common.PIPELINE_VERSION` (1) is recorded in `.dubtitles.done`; `stamp_valid()` rejects anything older. `GRANDFATHER_VERSION` (1) covers pre-versioning stamps, so **the rollout regenerates nothing**. Bumping `PIPELINE_VERSION` is the operator's deliberate signal for a global in-place regeneration. - `scripts/migrate_write_v1_stamps.py` stamps files that carry a `Dubtitles` track but no stamp (dry-run by default), so removing the backstop doesn't trigger a mass regeneration on deploy. ## Review findings fixed in this branch Two review passes ran against the first cut; both found real defects, each now covered by a test: - **`-s` is a whitelist and mkvmerge's default is copy-every-subtitle-track.** Omitting `-s` on an empty keep list copied back the very track `dropped` reported as removed — so every mp4-origin episode, and the "only English sub is ours" case, would have ended up with **two** `Dubtitles` tracks. `verify()` is presence-only, so it would have passed and been stamped. An empty keep list now emits an explicit `-S`. (The test covering that path had asserted the broken command shape.) - **A version bump could be silently defeated.** The sidecar-existence skips aren't version-aware, so a file with a stale stamp *and* a leftover sidecar had `generate` return `already-ass` forever while `mux` re-embedded the old subtitle and stamped it current. Fixed via `common.stale_version_stamp()` + `generate.discard_stale_sidecars()`. A second pass caught that the first cut of *that* fix deleted freshly transcribed sidecars — so a sidecar now counts as a leftover only if it **predates the stamp**, and `.dubtitles.fail` episodes are exempt. - `common.is_our_track()` is now the one predicate for both track shapes (ffprobe `tags.title`, mkvmerge `properties.track_name`) so the exclude-from-context and drop-at-mux tests can't drift. - A failed `write_stamp` returned a bare `error` after the remux had already landed — a silent per-sweep full remux, forever. Now `stamp-write-failed`, logged loudly, sidecars kept for retry. - `stamp_valid()` no longer raises on a non-int `version` (that check runs outside `mux.process()`'s `try`, so one corrupt sidecar aborted a whole sweep); `properties: None` hardening across `mux`. ## Testing 395 tests pass; `ruff` clean on the CI target (now including `common.py`, `mine_glossary.py`, `scripts/`). Every new guard was verified honest by reverting its hunk and watching the test fail. **Still pending — needs real media on the server:** - [ ] Dry-run `mux.py` on an already-dubbed episode; expect `drop-tracks=['sub:Dubtitles(old)']`. - [ ] After a real `--apply`, confirm `mkvmerge -J` shows exactly **one** `Dubtitles` track. Use an **mp4-origin** episode — that's the shape the `-S` bug hid in. - [ ] Run `scripts/migrate_write_v1_stamps.py` (dry run, then `--apply`) before deploying, so no-stamp files don't mass-regenerate. - [ ] Bump `PIPELINE_VERSION` on a test show and confirm in-place regeneration. ## Notes - `strip_op.py` (referenced in the spec's non-goals) does not exist in this repo and never has — nothing to deprecate. - The new `Dubtitles` track is appended last, so if an old one sat mid-list the dubtitle moves to the end. Players use the default-track flag; index-based scripts may see a different position. Documented in the README. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Design for retiring the separate pre-strip pass: mux drops the old Dubtitles
track when embedding the new one, a pipeline-version stamp triggers in-place
regeneration, and every context reader (repair/timing-compare/signs-merge via
eng_sub_streams, plus mine_glossary) excludes the Dubtitles track so old-version
errors never influence the new output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pipeline embedded its output as a subtitle track named Dubtitles and then
treated that track's presence as done -- so a regeneration silently no-op'd
unless a separate heavy pre-strip pass ran first. Worse, the track is
codec=ass/language=eng, so every stage that reads the fansub as context picked
it up as if it were the human fansub and repaired/aligned/mined the new run
against the previous run's own mistakes.

Context isolation (one marker, common.TRACK_NAME):
- common.eng_sub_streams() now fetches stream_tags=title and drops any stream
  titled Dubtitles -- the single chokepoint for repair.py, timing_compare.py
  and dub_signs_merge.py, which inherit it with no code change.
- mine_glossary.eng_sub_text() has its own selector; same exclusion added.
- No fallback by design: an episode whose only English sub is our old dubtitle
  runs reference-free rather than reading itself.

Strip-at-mux:
- mux.keep_sub() drops any track named TRACK_NAME as its FIRST check, so
  build_cmd's single pass drops the old track and appends the new one --
  a replace, not a duplicate, and self-healing if a past run left several.
- mux.process() skips on the stamp alone; the ffprobe already-muxed backstop
  is gone (re-mux is now idempotent). generate.py's SKIP_IF_MUXED guard and its
  has_dubtitles_track() go with it.

Regeneration trigger:
- common.PIPELINE_VERSION (1) is recorded in .dubtitles.done; stamp_valid()
  rejects anything older. GRANDFATHER_VERSION (1) covers pre-versioning stamps,
  so the rollout regenerates nothing. Bumping PIPELINE_VERSION is the operator's
  deliberate signal for a global in-place regeneration.
- scripts/migrate_write_v1_stamps.py stamps files that carry a Dubtitles track
  but no stamp (dry-run by default), so removing the backstop doesn't trigger a
  mass regeneration on deploy.

377 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six fixes from review, each covered by a test:

1. CRITICAL: build_cmd omitted -s when the keep list was empty. mkvmerge's -s is a
   WHITELIST and its default is copy-every-subtitle-track, so the old Dubtitles track
   was copied back and the file ended up with TWO — exactly the mp4-origin and
   only-sub-is-ours cases the feature exists for. verify() is presence-only, so it
   passed and got stamped. An empty keep list now emits an explicit -S.
   (The test for this path previously asserted the broken command shape.)

2. common.is_our_track() is now the one predicate for both track shapes (ffprobe
   tags.title via stream_title(), mkvmerge properties.track_name). keep_sub compared
   raw, so a padded track name was excluded from context but KEPT at mux — a silent
   duplicate. Also replaces mine_glossary's import of the private _track_title.

3. A version bump could be silently defeated: the sidecar-existence skips are not
   version-aware, so a file with a stale stamp AND a leftover sidecar (mux crashed
   after stamping) had generate return already-ass forever while mux re-embedded the
   OLD subtitle and stamped it current. common.stale_version_stamp() identifies that
   state; generate discards those sidecars and needs_work() lets process() reach them.

4. A failed stamp write returned a bare error after the remux had already landed —
   with the ffprobe backstop retired that means redoing a multi-GB mkvmerge every
   sweep, forever, invisibly. Now stamp-write-failed, logged loudly, sidecars kept.

5. stamp_valid() no longer raises TypeError on a non-int version; the check runs
   outside mux.process()'s try, so one corrupt sidecar aborted the whole sweep.

6. properties: None hardening across mux's track accessors; a present-but-null
   language tag no longer raises in mine_glossary.

Spec corrected: its orphaned-sidecar recovery claim (#3) was wrong, and the strip_op.py
non-goal referenced a file that never existed.

391 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(strip-isolate): only discard sidecars that predate the stamp; exempt poison files
Some checks failed
tests / test (push) Failing after 3s
CI / test (push) Failing after 27s
tests / test (pull_request) Failing after 4s
CI / test (pull_request) Failing after 30s
146a6d1d32
Follow-up review found the first cut of the stale-sidecar fix rested on circular
reasoning -- it claimed generate could not have written a fresh sidecar because the
stale one blocks it, when unblocking generate is precisely what that fix does. The
stamp does not advance until mux succeeds, so a freshly transcribed sidecar sits
beside a still-stale stamp for at least one MERGE_INTERVAL, and indefinitely if the
mux keeps failing (skip-no-room, verify-*). Discarding it there re-ran Whisper on
every gen_loop.sh resume pass and, since the stall detector counts .srt files, the
deletions read as "no progress" and abandoned the show mid-regeneration.

- A sidecar is a LEFTOVER only if it predates the stamp: the run that stamped wrote
  its sidecars first and deleted them just after, so older-than-stamp belongs to that
  finished run and newer-than-stamp is this regeneration's own work.
- A .dubtitles.fail episode is exempt: it is never transcribed, so discarding its
  sidecars is pure destruction -- mux would have nothing to embed until the marker is
  cleared by hand. needs_work() checks the marker first too, so the two agree and a
  poisoned file no longer drags in the ~40s model load.
- Spec corrected: the circular safety argument is replaced with the real predicate.

Both guards verified honest by reverting each hunk in a scratch copy.
395 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(migrate): distinguish a failed ffprobe from "no Dubtitles track"
Some checks failed
tests / test (push) Failing after 6s
tests / test (pull_request) Failing after 7s
CI / test (push) Failing after 43s
CI / test (pull_request) Failing after 42s
f29f30f293
has_dubtitles_track() wrapped everything in `except Exception: return False`, so an
unreadable or corrupt file was silently filed under `no-dubtitles` -- "not muxed yet,
the normal pipeline owns it". A run over a partly-broken library would therefore
report zero errors and read as clean while skipping exactly the files worth looking
at; the `error` status was only reachable from a failed stamp WRITE, so a 0 count
proved nothing about the 3587 files probed.

Now tri-state: True/False when ffprobe answers, None when it could not be run, timed
out, exited nonzero, or emitted unparseable output. process() maps None to its own
`probe-failed` status -- counted separately in the SUMMARY, never stamped -- and
main() prints an explicit warning when any occur.

Found by a dry run against the live 3587-file library.
399 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documented --raw path needs a dump_whisper.py capture that this repo does not
ship, so running a bake-off meant re-transcribing an episode on the GPU first --
which is why no bake-off had been run since the models changed.

--conf takes a production <stem>.dubtitles.conf.json instead. Those rows are already
post-reflow, post-hallucination-gate and post-collapse and carry every field
repair.is_target() reads (text, avg_logprob, no_speech_prob, word_probs) -- and it is
the very file repair.py consumes in production, so the models are judged on precisely
the lines the live pipeline would have sent them. No GPU, no reflow re-derivation.

Exactly one of --raw/--conf is required; a malformed conf (not a list, or a row with
no "text") exits loudly rather than silently producing empty prompts.

Verified against a real episode: 772 cards -> 163 targets.
407 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Some models cannot be served by Ollama at all. Nanbeige 4.2-3B's GGUF needs a patched
llama.cpp -- `ollama create` refuses it with "failed to validate GGUF with
llama-quantize without compatibility patches" -- which is why it runs in its own
container behind a llama.cpp server. repair.py can already run such a model
(REPAIR_BACKEND=llamacpp + REPAIR_LLAMACPP_URL), so the bake-off had a gap: it could
not evaluate a model the pipeline is capable of using.

--llamacpp NAME=URL routes those model names to a llama.cpp /completion endpoint while
everything else still goes to Ollama. The request body mirrors repair.llm_llamacpp
exactly -- no model selector (the server has one model loaded), n_predict/stop bounded,
reply read from "content" -- so the bake-off measures the configuration that would
actually ship rather than an approximation of it.

ask() split into ask_ollama/ask_llamacpp behind a dispatching ask(); shared _post_json
and _first_line so both backends parse replies identically.

411 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
repair.llm_llamacpp posts a RAW prompt to /completion, which applies no chat template.
Verified against the live Nanbeige 4.2-3B server: that path returns nothing but
newlines -- 200 tokens of "\n" -- because the instruct model never sees its template.
This is a latent bug in repair.py's llamacpp backend, not just a bake-off gap; it can
only work for a base/completion model.

--llamacpp-chat routes a candidate through /v1/chat/completions instead, which applies
the template, and passes chat_template_kwargs {enable_thinking: false}. Both are
required for this fork: with the template but thinking still on, the model fills
reasoning_content and returns an empty message (measured: empty after 114s at
max_tokens=512; correct output in 4.3s with thinking off).

An empty reply is surfaced as "<EMPTY thinking not disabled?>" rather than "" --
an empty string would score as "the model correctly left the line unchanged", turning
a broken configuration into a silent perfect no-op.

--llamacpp (raw) is kept so the shipping configuration can still be measured as-is.

414 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(repair): restructure the prompt to stop glossary-name fabrication
Some checks failed
tests / test (push) Failing after 3s
tests / test (pull_request) Failing after 6s
CI / test (push) Failing after 46s
CI / test (pull_request) Failing after 43s
a4f7dd29de
Measured on real conf.json targets across three shows, qwen3.5:9b was rewriting a
large fraction of lines under the old prompt and pasting glossary names over text
that was already correct:

  This is Border Control.  -> This is Cipher Pol.
  Hey, Sonny,              -> Hey, Shanks,
  Oh, Neptune!             -> Oh, Nefertari Vivi!     (Neptune was already right)
  Straw Hat was innocent.  -> Straw Hat Pirates were innocent.
  Uchihime!                -> Uchiha!                 (a name from another franchise)

The old prompt already contained "NEVER replace a name in the line with a different
name", so restating the rule is not what fixes this. Two alternatives were tested and
rejected: stating it more forcefully, and removing the glossary from the prompt
entirely (still 38% -- the name list is NOT the trigger, and its removal made the
conservative model worse, 8% -> 18%).

What worked was structure:
  * the name list framed as VERIFICATION ONLY, not as material to apply
  * two worked examples -- one correcting a misspelling, one LEAVING an unlisted name
    alone, which is the behaviour prose failed to secure
  * nothing after the ASR line; context and reference now precede it

That last point is its own bug: an intermediate version put a trailing "Remember:"
reminder after the ASR line and the model echoed the rule text straight into the
subtitle output ("...friends in jail We're victims here, Remember: do not introduce
or swap names..."). A regression test now pins that nothing follows the ASR line.

Edit rate, old -> new (qwen3.5:9b, 40 targets each):
  One Pace              42% -> 25%
  Jujutsu Kaisen        28% -> 22%
  Delicious in Dungeon  22% -> 15%

Every glossary-name fabrication above is gone; the good repairs (human -centric ->
human-centric, sentence-boundary splits) survive.

NOT fixed: the model still invents phonetic names ("Syrahose" -> "Shyarros" on one
line and "Shirahoshi" on another -- same token, two answers). Prompt tuning has
plateaued there; tracked separately for a deterministic guard.

Also carries the bakeoff tooling used to measure this: --conf input, llama.cpp raw
and chat backends, and per-model incremental output so a timeout cannot discard
completed results.

421 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(repair): prompt that works for both models + fix the llama.cpp backend
Some checks failed
tests / test (push) Failing after 5s
tests / test (pull_request) Failing after 31s
CI / test (push) Failing after 50s
CI / test (pull_request) Failing after 53s
cc9469eaff
Goal: make a 3B model viable so the pipeline consolidates onto one small model that
others can actually run. Two things blocked that.

1. THE PROMPT. The previous one balanced badly across models:
     qwen3.5:9b     told only what NOT to do -> rewrote 42% of lines, pasting glossary
                    names over correct text (Neptune -> "Nefertari Vivi", Uchihime ->
                    "Uchiha", a name from a different franchise)
     nanbeige4.2-3b same prohibitions -> inert. 0 safe fixes across 120 targets,
                    returning input verbatim, losing the real repairs it had made

   A 6-variant sweep (3 shows x 40 targets, temperature 0, GPU residency verified before
   each run) found one prompt that fixes both. Measured, 120 targets:

     qwen3.5:9b      safe fixes  6 -> 23    name edits 17 -> 14
     nanbeige4.2-3b  safe fixes  0 -> 16    name edits  1 ->  2
     zero prompt leaks or length blowups for either

   What did it:
     - explicit POSITIVE DUTY. Rules phrased only as prohibitions produce a model that
       does nothing, which is not a repair stage.
     - REMOVING the worked example that showed a name being left alone. Counter-intuitive,
       but it over-anchored inaction; removing it was the single biggest gain AND name
       edits fell for both models.
     - dropping the blanket "if unsure, return UNCHANGED" escape, which a cautious model
       takes on every line. Restraint now attaches specifically to proper nouns, where the
       damage actually was.

2. THE LLAMA.CPP BACKEND, which nanbeige needs, was broken. llm_llamacpp posted a RAW
   prompt to /completion, applying no chat template; against a live Nanbeige server that
   returns nothing but newlines (200 tokens of "\n"). It could only ever have worked for a
   base/completion model. Now uses /v1/chat/completions with
   chat_template_kwargs.enable_thinking=false -- both required for this fork, which
   otherwise fills reasoning_content and returns an empty message (empty after 114s at
   max_tokens=512; correct output in 4.3s with thinking off). An empty reply is treated as
   "no repair", never as text to embed.

   REPAIR_LLAMACPP_URL default updated to the chat endpoint. The pre-existing test pinned
   the raw-/completion body, i.e. pinned the broken shape; updated with the reason.

Verified end to end: REPAIR_BACKEND=llamacpp against the live server repairs
"it worked Now we run" -> "It worked. Now we run.", fixes "human -centric", and leaves
"Sonny" alone.

Not fixed: qwen still invents names under this prompt (14 edits/120, incl. one glossary
paste, "catch Hirohoshi" -> "catch Crocodile"). nanbeige makes 2 name edits/120. That gap,
plus 4.13 GB vs 6.59 GB and ~3.4x faster, is the case for consolidating on nanbeige.

425 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
recreate_srt.py rebuilds <stem>.eng.dubtitles.srt from the conf.json. mux removes
sidecars on success, and repair.py returns "skip" when the srt is absent -- so without
this tool in the image there is no way to re-run repair on an already-muxed episode
except by re-transcribing it from scratch.

That makes it a hard blocker for rolling any model or prompt change out to the existing
library: 295 of One Pace's 469 episodes are muxed with their sidecars gone, but all 461
conf.json files survive, and conf.json holds the ORIGINAL pre-repair transcription
(repair.py reads it and never writes it back). recreate_srt -> repair therefore re-repairs
cleanly from the original text, with no compounding of previous edits and no GPU time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(mux): verify the VIDEO track duration, not the container
Some checks failed
tests / test (push) Failing after 8s
tests / test (pull_request) Failing after 14s
CI / test (push) Failing after 58s
CI / test (pull_request) Failing after 1m7s
335bd88ea8
verify()'s truncation canary compared ffprobe's format=duration between source and
remux. In Matroska that is the LONGEST TRACK, not the video -- so it counts tracks this
stage deliberately drops.

Releases routinely ship a foreign subtitle that outlasts the picture. JUJUTSU KAISEN
S02E04 carries a Polish fansub track ending at 24:12.74 while the video ends at 23:54.85.
mux correctly drops it (not a keep language), the longest remaining track becomes audio,
and the container duration falls 18.9s -- eight times DUR_TOL. Measured on the real file:

  container: orig=1453.880  out=1434.952   delta=18.928s  -> rejected
  VIDEO:     orig=1434.850  out=1434.850   delta= 0.000s  -> correct

The remux was perfect every time. verify() rejected it, deleted the temp, and the merge
sweep retried it 10 minutes later, forever: that one episode's sidecars were assembled
2026-07-02 and it had been failing for 25 days. It was not alone -- a single sweep showed
11 consecutive verify-duration-mismatch failures across Demon Slayer and JUJUTSU KAISEN,
all multi-language AV1 releases, i.e. exactly the shape that ships an over-long sub.

video_duration() reads the first video stream: the duration field, then Matroska's
DURATION tag (usually where it actually lives, the field is N/A), then the container as a
last resort. duration() is kept -- it is still the right answer for "how long is this
file", just not for this check.

Note the irony: this duration check replaced a half-size heuristic that was removed for
false-positiving on compact muxes. It had the same defect, on a different axis.

431 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(merge): dedup on layer+tags so stacked signs keep their white top layer
Some checks failed
tests / test (push) Failing after 4s
tests / test (pull_request) Failing after 12s
CI / test (push) Failing after 52s
CI / test (pull_request) Failing after 51s
53840abf84
Fansub typesetters build a sign from several events stacked on the same
\pos: a black backing copy supplying the stroke/drop-shadow on the lower
layer, and the visible copy above it (\bord0 plus a \t() animating the
fill to white). They share start, end, style and -- because the colour
lives entirely in override tags -- identical plaintext.

The dedup key was (start, end, style, plaintext), which made those two
events indistinguishable and kept only the first: the black backing.
Every credit, caption and title rendered as solid black text instead of
white-with-black-stroke.

Proven on One Pace S01E01, whose source tracks halve exactly under the
old key -- Credits-207+ 32->16, Captions-207+ 6->3, Credits 32->16 --
and come back whole under the new one (80 sign events, matching source).

Keying on the full override text plus the layer keeps compositions intact
while still collapsing byte-identical events, which is what the dedup was
for: the same sign carried by both the full track and the signs/songs
track. Both behaviours are now covered by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
39 of the library's 79 releases ship more than one English ASS track, and
the same signs are typeset into each of them -- a full dialogue track, a
signs/songs track, often a CC track. build() read all of them, so every
sign rendered two or three times a few pixels apart. One Pace S01E01
carried the episode title three times over.

Add common.signs_sub_streams(): when any track names itself as the signs
track, that one is the release's curated signs list and the only one read;
several rival ones (DAN DA DAN ships MALD's and CR's) collapse to the
first. Titles that say nothing useful ("inid4c + SFX") fall back to the
historic scan of every track classified per event, which is how a single
mixed dialogue+signs track has always worked.

SIGNS_TITLE covers the spellings actually present in the library, taken
from a probe of all 79 releases: "Signs & Songs", "S&S", "Signs/Songs",
"English[Signs]", "Songs + Signs", "Signs and Songs(Hydes)", and "Forced"
(a forced track is signs plus foreign-dialogue captions, and releases that
ship ['Forced', ''] mean it as the signs track). Not "karaoke" -- FLE's
"Karaoke / English / ASS / FLE" is one mixed track, not a signs-only one.

eng_sub_streams() keeps its exact contract for repair.py and
timing_compare.py, which want the DIALOGUE track; both now share one probe
via eng_sub_tracks() so the is_our_track() exclusion cannot drift.

Verified on the real One Pace mkv: 80 sign events across three tracks ->
40 from the signs track alone, one title instead of three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every v1 dubtitle has broken signs (plaintext dedup collapsing stacked
compositions to their black backing layer; signs lifted from every English
track at once), so the whole library needs regenerating. Bump
PIPELINE_VERSION -- the deliberate operator trigger -- to 2.

Roughly a third of built episodes no longer have their conf.json, so
recreate_srt.py cannot rebuild their sidecar and they would go back through
Whisper: ~850 episodes on a 6 GB 1060, days of GPU. Their dialogue is not
lost though -- it is sitting in the muxed track, carrying the "Dubtitles"
style, already transcribed/corrected/repaired. The signs beside it are
exactly what the rebuild replaces.

tools/recover_dub_srt.py lifts those events back into a sidecar, turning a
days-long GPU job into hours of remuxing. It is the one place the pipeline
reads its own output on purpose; that is not the leak eng_sub_streams()
guards against, since nothing is treated as a fansub reference and the
lines go straight back out as dialogue. It refuses to write an empty
sidecar (merge_pass discovers work BY the sidecar, so an empty one would
mux a dubtitle track with no dialogue) and never clobbers an existing one.

repair.process() now treats a missing conf.json as "skip" instead of
raising FileNotFoundError: merge_pass.sh calls repair.py unconditionally,
and a recovered episode has no conf. That text was repaired when first
built, so there is nothing to redo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration ran while PIPELINE_VERSION == GRANDFATHER_VERSION, which is
what made its stamps read as current. The v2 bump deliberately makes them
stale again -- which the very next test already asserts -- so this one has
to pin the version it is actually describing rather than assume the
constants stay equal forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build: ship tools/ in the image
Some checks failed
tests / test (push) Failing after 10s
tests / test (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m9s
CI / test (push) Failing after 1m21s
c20ff33902
recover_dub_srt.py has to run inside the container to reach the media, for
the same reason recreate_srt.py does: it is the only route that regenerates
an episode whose conf.json is gone without sending it back through Whisper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(review): status pass — most outstanding items were already fixed
Some checks failed
tests / test (push) Failing after 6s
tests / test (pull_request) Failing after 39s
CI / test (push) Failing after 51s
CI / test (pull_request) Failing after 52s
54c2b8de15
Verified each item in the latest addendum against the branch rather than
taking it at face value: the addendum was written where pytest could not
run, and most of what it lists as outstanding is already in-tree. Records
what is genuinely still open (the phonetic-name guard, whose specified
design is unsound), what is deliberate (default-track demotion), and the
two signs bugs found this pass that no earlier review caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ollama/llamacpp dispatch lived only in repair.py, so glossary_verify
could reach nothing but Ollama -- the pipeline had to keep a second model
resident on a shared 8 GB GPU purely for adjudication.

Hoist the client into common.llm_chat(), with the llama.cpp specifics the
repair backend had to learn the hard way: /v1/chat/completions rather than
/completion (which applies no chat template -- a templated instruct model
answers it with nothing but newlines), enable_thinking=false (or the fork
spends its whole budget on reasoning_content and returns an empty message),
and no model selector (one model per server).

Two knobs adjudication needs that repair does not: max_tokens, since a JSON
object needs a real budget where a subtitle line needs 80; and
first_line=False, since taking the first line of a JSON object truncates it
to its opening brace.

VERIFY_BACKEND/VERIFY_LLAMACPP_URL default to the REPAIR_* values, so
pointing repair at a backend moves adjudication with it unless overridden.

Checked against the live servers on four real One Pace adjudications rather
than assumed: nanbeige and qwen3:8b agree on all four, and nanbeige answers
the hard one in 1.2s against qwen's 16.1s. Small sample, and the one case
they differ on ("Zolo" -> "Zolo" vs "Roronoa Zoro") is arguably right both
ways, since this stage picks the DUB-preferred spelling.

repair.py keeps its own dispatch for now -- it is the quality-critical path
and a library-wide regeneration is mid-flight; folding it onto llm_chat()
is a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An episode whose release ships no embedded signs never gets an .ass: the
merge returns "no-signs" and mux embeds the .srt directly, so its Dubtitles
track is codec subrip. pysubs2 styles every event of an SRT "Default", so
filtering on the "Dubtitles" style discarded those episodes wholesale --
37 of 50 in one stretch of the live seeding run came back "no-dialogue".

Such a track holds nothing but our dialogue (there are no signs in it to
confuse with it), so every event counts. The .ass path is unchanged: there
the non-Dubtitles events ARE the signs being replaced.

srt-ness has to come from ffprobe, not from the parsed file -- an extracted
SRT and a style-less ASS are indistinguishable by the time pysubs2 is done
with them -- so our_track_index() now returns the codec alongside the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(merge_pass): a missing shell/lib.sh killed the whole pass silently
Some checks failed
tests / test (push) Failing after 7s
tests / test (pull_request) Failing after 13s
CI / test (push) Failing after 57s
CI / test (pull_request) Failing after 58s
0c571707db
`.` is a POSIX special builtin: sourcing a file that does not exist is a
fatal error that terminates a non-interactive shell before `|| true` is
ever considered. So `. "$APP/shell/lib.sh" 2>/dev/null || true` did not
degrade to the inline fallback it sits next to -- it ended the script.

APP defaults to /scripts and only container_run.sh sets APP_DIR=/app, so
running merge_pass.sh directly (a targeted one-season pass, say) produced
no output at all, exited 2, and assembled nothing. The fallback regex the
comment promises for exactly that case was unreachable.

Guard with a file test, which is the POSIX-safe form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(repair): reject repairs that lift their wording from the fansub reference
Some checks failed
tests / test (push) Failing after 4s
tests / test (pull_request) Failing after 5s
CI / test (push) Failing after 34s
CI / test (pull_request) Failing after 32s
6e94939922
The reference exists to disambiguate a garbled ASR line, not to supply its
text. A dubtitle has to match the DUB AUDIO, so a repair that swaps the
dub's phrasing for the fansub's yields a subtitle that reads perfectly and
is wrong against the sound -- the worst failure available here, because
nothing about it looks broken.

Measured across every repair summary the library had accumulated:

  qwen3:8b   2520 repairs -- 84.1% imported words from the reference (29.2% imported 3+)
  nanbeige   8456 repairs -- 52.5% imported words from the reference (17.1% imported 3+)

Both models do it; qwen far worse. Real lines that shipped:
  "That's enough of that, idiots!" -> "Hold it, you brats!"   (the reference, verbatim)
  "Let's go, Chopper."            -> "Well then, shall we go, Chopper?"

The only gate was a 0.4-2.5 length band, which a same-length rewrite sails
straight through, and which admitted "Huh?" -> "Huh? Help!" at exactly 2.5.

accept_repair() now also rejects a repair importing MAX_REF_BORROW (3) or
more NEW words present in the reference, and tightens the band to 0.6-1.5.
Both env-tunable so they can be adjusted without rebuilding the image.
Rejections are counted into the per-episode summary as rejected_guard, so
the guard's effect stays visible rather than silently shrinking the numbers.

Replayed over the 10971 repairs already on disk: blocks 26.0% of nanbeige's
and 37.1% of qwen's, while keeping 2733 of 2736 punctuation-only fixes and
name corrections like "Naya Yaskirihara" -> "Kirihara Naoyasu". The known
cost is a 3-word proper-noun correction ("Chinlong Tong" -> "Qing Long
Tang") now being refused; the deterministic glossary layer is the right
place for those anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(recover): recover an SRT that reached the container as ASS
Some checks failed
tests / test (push) Failing after 3s
tests / test (pull_request) Failing after 8s
CI / test (push) Failing after 36s
CI / test (pull_request) Failing after 34s
1797912215
An integrity sweep over the whole library found exactly one episode stuck:
v1 stamp, no sidecar, no conf.json -- invisible to merge_pass, which
discovers work BY the sidecar, so it would have sat at v1 forever.

Its Dubtitles track is codec ASS but every event is styled "Default": an
SRT muxed as ASS. srt-ness was decided from the codec alone, so it took the
ASS path, the "Dubtitles"-style filter matched nothing, and recovery
reported no-dialogue.

Fall back to every event when NOTHING carries our style name AND all events
are styled "Default" -- which is precisely what pysubs2 gives an SRT. The
second half of that condition is load-bearing: without it a signs-only
track would be returned as dialogue, putting signs into the sidecar as
spoken lines, which an existing test rightly forbids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VRAM fit test on the 3500g node (VM100, GTX 1050 Ti 4GB, driver
550.163.01) against Akiba Maid War S01E01:

  large-v3       int8 beam=7  -> OOM, exit 3
  large-v3       int8 beam=1  -> fits at 2173 MiB, flagged=76, over_cps=111
  large-v3-turbo int8 beam=7  -> fits at 1405 MiB, flagged=35, over_cps=98

turbo at the default beam is both smaller and better than large-v3
forced down to greedy, which is the only way large-v3 fits at all on a
4GB card. It is also the right trade for this pipeline specifically:
REQUIRE_ENG=1 means we only ever transcribe English audio to English
text, and the turbo distillation's real quality regression is on
translation, which we never do.

Image size drops ~3GB to ~1.5GB. The image on VM100 was already
rebuilt with this; the repo was the last place still saying large-v3.
fix(generate): default WHISPER_MODEL to large-v3-turbo
Some checks failed
tests / test (push) Failing after 6s
tests / test (pull_request) Failing after 24s
CI / test (push) Failing after 45s
CI / test (pull_request) Failing after 44s
b8ace200e0
The previous commit made the image bake turbo and nothing else, but the
default here was still large-v3 -- so a container with no explicit
WHISPER_MODEL would find /models empty of what it asked for and try to
download 3GB of large-v3 at runtime, every start, on a box picked
partly for having a small GPU.

V2 A9 is no longer pending: the test it was waiting on ran on the 3500g
node. Comment rewritten to say what was measured instead of what was
planned, and to warn that this default and Dockerfile.builder's baked
model have to move together.

Operator note: any compose still pinning WHISPER_MODEL=large-v3
explicitly will still trigger that runtime download. Drop the line or
set it to large-v3-turbo.
build(builder): WHISPER_MODEL as a build-arg, per-GPU rather than global
Some checks failed
tests / test (push) Failing after 4s
tests / test (pull_request) Failing after 24s
CI / test (push) Failing after 42s
CI / test (pull_request) Failing after 41s
e93fb4c3a6
The two nodes want different models and the last two commits picked one
globally, which was wrong. large-v3 fits the 1060's 6GB at the default
beam_size=7 -- the config IMPROVEMENTS.md measured as the best accuracy
this pipeline can get. It does NOT fit the 3500g node's 4GB 1050ti,
where it OOMs at beam 7 and only runs forced down to greedy, worse than
turbo at the full beam. Turbo there is a VRAM concession, not an upgrade.

So: bake whichever model the build-arg names, default large-v3, and the
3500g node builds with --build-arg WHISPER_MODEL=large-v3-turbo.

The ARG is also re-exported as an ENV, which is the part that matters
operationally. Baked model and requested model can no longer disagree,
because they are one value. That mismatch is the failure this whole
sequence was chasing: it is not an error, faster-whisper just silently
downloads the missing model into /models on every single start.

generate.py's default goes back to large-v3, now documented as a bare-
checkout fallback -- inside the container the image's ENV wins.
Names can only enter a glossary by mining an embedded fansub track, so a
release that ships none leaves that stretch of the show with no name
source at all. One Pace S29-30 is the case in hand: 0/12 episodes carry
a non-Dubtitles subtitle track, and the glossary's 83 names are
consequently all early-arc while Shirahoshi, Hody and Decken are absent.
The wiki verifier cannot close it -- verify() only walks terms already
in the glossary, so the One Piece wiki sits in the config knowing every
character and never gets asked.

The design inverts the obvious join: the wiki owns the candidate list
and every canonical string, and the show's own transcripts only decide
which wiki entities are worth asking about. Our errors can raise a
question; they can never become an answer.

Two measurements shaped it. Whisper confidence does not separate right
from wrong (botched "Syrahose" scores -0.065, correct "Shirahoshi"
-0.173), and neither does raw frequency (wrong "Deccan" 21 beats right
"Decken" 8 across 45 episodes). So dominance within a cluster, judged
against a wiki-owned canonical, is the only signal left.

Panel-reviewed via salyut, and the review earned its keep: probing how
phonetic clustering handles Japanese romanisation prompted a
measurement that killed the original design. Metaphone buckets
Shirahoshi/Syrahose/Hirohoshi as XRHX/SRHS/HRHX and Decken/Deccan as
TKN/TKKN -- an exact-key gate would have dropped both motivating cases
while passing the easy ones. Scoring now runs token-against-title with
Jaro-Winkler primary and the phonetic key demoted to a signal. The same
review replaced the bare 5:1 ratio with a Wilson lower bound, which
stops 5-vs-1 from reading as strongly as 56-vs-2.
Re-consulted the panel after fixing salyut's groq provider (it had been
404ing on a decommissioned model, so the first review was two voices).
gpt-oss-120b answered substantively, and while its specific numbers were
invented -- it asserted JW("Syrahose","Shirahoshi")=0.92 against a
measured 0.755, and built a scenario where R1 admits a non-title -- two
of its structural points held up and led somewhere better than either
panel got on its own.

Measuring every pair the design must match against every near-miss it
must refuse shows the two classes overlap completely: every true pair
scores LOWER than every false one (Syrahose/Shirahoshi 0.755 vs
Warlords/Warlord 0.975). No metric separates them, so the reviewer's
"use phoneme-weighted Levenshtein" doesn't help either. Similarity is a
recall device; R2 and R3 carry all the safety. That also caught a bug in
v2 of this spec: ACQUIRE_MIN_SIM was 0.88, which would have rejected
both motivating cases outright. It is 0.72 now.

The self-read question that two panels failed to answer turns out to
have a concrete answer, and it doesn't run through the canonical string
where the wiki gate sits. The glossary is additive and never pruned, and
it also seeds Whisper's initial_prompt -- so a wrong entry biases
transcription, which raises that spelling's count, which strengthens the
very dominance test that admitted it. The gate governs step one; the
rest of the loop is closed inside our own output, looking better
evidenced each pass. Mitigated by provenance in `acquired`, keeping
acquired names out of initial_prompt, and a --revert path.
Self-review against the arithmetic found two ways this spec would have
produced a broken implementation.

Both must-apply cases in the verification plan score a Wilson lower
bound of 0.000, not >0.80 -- Whisper never once emitted Kin'emon or
Brook, so the canonical count is zero and they qualify only through the
never-appears branch. An implementer checking the headline rule alone
would have failed the whole acceptance test.

And Deccan/Decken, cited in the problem statement, is flagged rather
than fixed: the correct form is the minority at 8 against 21, scoring
0.147. That is the right outcome but the spec implied otherwise. It is
the same shape as Smoker/Smokey (0.409), where the minority form is
legitimate dub dialogue -- no frequency signal separates a consistent
mishearing from a genuine second form, so both go to a human.
The spec said 'flagged for human review' throughout and no such review
exists. flagged is write-only -- glossary_verify has written it since it
shipped and nothing has ever read it back. Forty terms are sitting in it
across nine live glossaries.

Inspecting them is the useful part: they are almost all correct. Yuji,
Megumi, Gojo, Izuku, Deku, Loid, Anya, Shinra -- flagged no-match only
because the wiki titles characters by full name and the similarity
cutoff cannot bridge a given name to a full one. A review mechanism on
its own would have shown a human forty items and forty of them would
have been fine.

That is the R2 expansion rule seen from the other side: Yuji inside Yuji
Itadori is the same relationship as Warlords inside Seven Warlords of
the Sea, and R2 already declines to substitute it. What was missing is
that 'found it and correctly left it alone' was recorded identically to
'never found it'. Tier 0 now records those as known, deterministically,
which empties most of the queue.

Tier C then puts the LLM where frequency cannot reach: R3 cannot tell a
consistent mishearing (Deccan/Decken, 0.147) from a name the dub says
two ways (Smokey/Smoker, 0.409), but the surrounding lines make it
obvious. This refines rather than drops v3's no-escalation rule -- R2
failures still never escalate, R3 failures may, because R3 is only a
statistical proxy for a question the model can answer with real
evidence. R1 holds throughout: the wiki still owns every canonical.

Plan for the implementation is committed alongside.
Tier 0 makes an expansion match a KNOWN short form rather than a
flagged failure -- Yuji inside Yuji Itadori and Warlords inside Seven
Warlords of the Sea are the same relationship, and in both the right
action is to leave the dialogue alone. Tier C escalates only
share-too-close verdicts to a context-based merge decision, enforced in
code so would-expand can never reach a model. Tier H is --review, with
flagged entries carrying their own evidence so the roadmap's Web UI can
render the same queue.

Two deliberate contract changes are called out where they happen: task 6's
would-expand verdict becomes task 13's known/short-form, and task 8's
bare-string flagged entries become task 16's objects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
propose() runs best_title + decide() over every harvested token, emitting one
proposal per variant that resolves to a wiki title (unmatched tokens are the
tier-B queue's business, not a silent drop here).

Amendment: the brief's below-floor assertion for "Hirohoshi" was written
against an earlier decide() whose floor used the variant count alone; the
shipped decide() sums the cluster total (1 + 56 = 57), clearing both the
floor and Wilson dominance, so that case now asserts apply/dominant.

Amendment: added an english-word gate so an ordinary capitalised English
word (e.g. "Name") can never be hard_fixed into a phonetically similar
character name (JW("name","nami") = 0.90 clears MIN_SIM and decide() alone
would apply it). The gate flags rather than drops, since real characters can
also be English words (Brook, Law) and a flag stays recoverable by a human
reviewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Guard glossary_verify.fetch_titles() against any exception, not just wiki-unresolved.
- Write via temp file + os.replace() so a mid-serialise failure cannot leave a truncated
  glossary; catch Exception (not just OSError) around the write.
- run_id now includes a sha1 digest of the actual variant->canonical proposals, so two
  runs with the same title count but different matched content no longer collide.

Addresses CRITICAL and IMPORTANT findings from task-10 review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add build_merge_prompt/adjudicate_merge/escalate: only share-too-close proposals
reach the LLM for context-based merge adjudication, never would-expand/short-form.
Wire escalate() into acquire() after propose().

Fix acquire()'s report: "flagged" previously counted proposals - applied, which
folded tier-0 "known" verdicts into flagged and inverted tier 0's purpose. Now
applied/known/flagged are each counted directly from the (escalated) verdicts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bool(d.get("same_entity")) treated any non-empty string ("no", "false") as
truthy, turning a model's textual "no" into a merge -- exactly the false
merge tier C exists to prevent. Now only the literal JSON `true` counts;
anything else (including 0/1/strings) means "do not merge", since a miss
is recoverable but a false merge silently rewrites dialogue.

Also corrects escalate()'s docstring: would-expand was retired in an
earlier task and no longer exists as a reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the human review tier (review_items/record_decision + --review CLI branch) and
reshapes flagged to the object form so a decision can be made without re-reading
transcripts, while still loading glossary_verify's legacy bare-string entries. Also
persists tier-B (dub-only-name) adjudications into flagged as no-wiki-match on the
--apply path, closing the gap where they only ever reached the printed report.
record_decision no longer strands a term in both known and hard_fixes/acquired: accept now
clears any stale known entry, reject now clears any stale hard_fix/acquired entry. --review
also stops asking "accept this fix?" for legacy no-canonical entries (glossary_verify's bare
strings), where accept and reject were indistinguishable; it now asks whether the spelling is
correct as-is, leaving the term genuinely pending if the reviewer says no.
Whole-branch review verdict was DO NOT MERGE (three Criticals + structural
issues); this closes every numbered finding in
.superpowers/sdd/2026-08-19-glossary-name-acquisition/final-fix-brief.md.

C1 (critical): tier B's adjudicate() canonical is free-form LLM text, never
checked against the wiki title list before acquire() persisted it and
record_decision() wrote it straight to hard_fixes. Both sites now re-run R1
(wiki membership) and R2 (not is_expansion) before a canonical can become a
hard_fix; a failing tier-B canonical is recorded empty (still reviewable,
nothing to auto-apply), and record_decision defends independently since it
cannot see the title list.

C2 (critical): apply_proposals only ever added to `known`, so an unattended
--apply sweep silently re-created and re-applied a hard_fix a human had
already rejected via --review. propose() gains an optional `settled` set
(known | acquired) and skips those tokens outright; apply_proposals now
clears whatever a prior run left behind for a term regardless of which way
the new verdict goes (also closes I1/I2/I3 - stale flagged entries).

C3 (critical): main()'s --review/--apply and --revert/--apply write paths
still used bare `open(path, "w")` + json.dump - truncate-on-open, no atomic
replace, no explicit encoding. Extracted `_write_json()` (tmp + os.replace,
encoding="utf-8") and used it at all three write sites; --revert's read is
now guarded so a malformed glossary reports instead of tracebacking.

I4: flagged proposals now carry real sampled context lines instead of an
always-empty list - the evidence --review was built to show a human.
I5: acquire()'s `flagged` name was shadowed by a dict of the same name in
the tier-B write path, inflating the reported flagged count by whatever was
already on disk; renamed to `flag_props`.

R4: --revert now exempts any entry whose `run == "review"` (a human
decision), regardless of run_id, so reverting an automated sweep can never
also undo what a person approved.
R6e: the english-word gate no longer overwrites a `known`/short-form verdict
wholesale - it only ever demotes something that wasn't already a safe no-op.

Also: restored the harvest-first short-circuit (skip the wiki round-trip
entirely when nothing was harvested), and pinned is_english to False in the
pre-existing canonical-count test that silently depended on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ACQUIRE_APPLY previously did double duty as both "run this step" and
"write its output", so the step never ran (not even dry-run/log-only) unless
writes were already authorised. Split it: ACQUIRE (default on) runs the
step dry-run and logs proposals; ACQUIRE_APPLY additionally passes --apply.
Matches the comment already in the script, which the code didn't.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(acquire-spec): correct the acceptance table against real Punk Hazard data
Some checks failed
tests / test (push) Failing after 3s
tests / test (pull_request) Failing after 15s
CI / test (push) Failing after 57s
CI / test (pull_request) Failing after 55s
65928d1b0d
The final-review reproduced this table against real data and found two rows
wrong and two right for the wrong reason:
- Brooke->Brook now flags (english-word gate: is_english("brooke") is True
  on cracklib-small/wamerican), not applies.
- Warlords is rejected via tier B (never resolves a title at tier 0 -
  similarity 0.627 < MIN_SIM), not via R2/tier 0 as previously claimed.
- Smokey/Smoker now reaches tier C (LLM merge adjudication), so the outcome
  depends on the LLM, not the 0.409 bound alone.
- Surrender/Maybe/Hurry/Listen are not "ignored" - they clear the frequency
  floor and land in flagged (tier A or tier B), same as any other proposal.
Also records that WORDLIST_PATH=/usr/share/cracklib/cracklib-small is
required to exercise the english-word gate at all on this laptop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf(acquire): eliminate the token x title join's redundant cost
Some checks failed
tests / test (push) Failing after 3s
tests / test (pull_request) Failing after 4s
CI / test (push) Failing after 1m5s
CI / test (pull_request) Failing after 1m1s
919e7ac69f
propose()/unmatched() ran best_title() over every harvested token, recomputing each
title's reduce_form/metaphone/soundex on every comparison, and unmatched() reran the
whole scan a second time for tokens propose() had already resolved. On One Pace
(8202 tokens x 8109 titles) this took ~816s for propose()+unmatched() alone, blowing
gen_loop.sh's 600s acquire timeout.

Precompute each title's (normalised, reduced, metaphone, soundex) once via
_title_index()/_best_title_indexed() (best_title() becomes a thin wrapper over these),
and run the join exactly once via _resolve_tokens(), shared by propose() and
unmatched() through a new optional resolved= parameter. acquire() now computes it
once and passes it to both.

Measured on the same real corpus: 816.44s -> 121.60s (6.7x), with the new proposals/
unmatched output verified byte-identical to the baseline run.

A naive "drop tokens below MIN_COUNT before matching" pre-filter was considered and
rejected: decide()'s floor gate is on variant_count + canonical_count, not
variant_count alone (see the existing Hirohoshi/Shirahoshi cluster test), and the
tight/safe version of that filter prunes 0 of 8202 tokens on real data, so no
token-side pre-filter is applied. See .superpowers/sdd/2026-08-19-glossary-name-
acquisition/perf-fix-report.md for the full writeup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(acquire): gate canonical-unseen on similarity, prefer whole-word title matches
Some checks failed
tests / test (push) Failing after 7s
tests / test (pull_request) Failing after 8s
CI / test (pull_request) Failing after 1m29s
CI / test (push) Failing after 1m34s
4dd39530f2
A real dry run over 22 episodes proposed 12 auto-fixes; 11 corrupted correctly-spelled
names (Zoro->Zoryu, Doflamingo->Domino, etc). All took the canonical-unseen escape
clause, which read "canonical never appears" as evidence on its own -- with 8109 wiki
titles any correct name finds some obscure article within MIN_SIM that never appears in
dialogue because it isn't in the show.

Fix 1: canonical-unseen now requires reduce_form(variant)==reduce_form(canonical) or
score >= ACQUIRE_UNSEEN_SIM (0.98, env-tunable); otherwise it holds as
unseen-needs-evidence rather than applying.

Fix 2: best_title/_resolve_tokens now prefer a title the token is a whole word of
(built once per run from the existing title-index pass, O(1) per token) over a
higher-jaro-winkler decoy -- 'Zoro' resolves to 'Roronoa Zoro', not 'Zoryu', and routes
through the existing known/short-form path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xenarathon closed this pull request 2026-09-04 15:08:01 -04:00
Some checks failed
tests / test (push) Failing after 7s
tests / test (pull_request) Failing after 8s
CI / test (pull_request) Failing after 1m29s
CI / test (push) Failing after 1m34s

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
xenarathon/DubTitlerr!4
No description provided.