fix: drop \def\v0 / \def\v1, which broke \v for every other argument - #87
Conversation
…ument
TeX has no multi-token control sequences, so `\def\v0{\vec{0}}` and
`\def\v1{\vec{1}}` never defined macros named `\v0` and `\v1`. Each
defined `\v` with a *delimited* parameter whose delimiter is the
literal digit, and `\def\v1` came last, so the surviving definition of
`\v` matched only a following literal `1`.
That silently overrode `\renewcommand{\v}[1]{\vecf{#1}}`, so `\v1`
worked by accident of ordering while `\v0` and the documented
one-argument form `\v{x}` both failed with
Use of \v doesn't match its definition.
It took down d-morrison/rme's book build:
https://github.com/d-morrison/rme/actions/runs/34375808884/job/102548187534
Removing both pseudo-definitions leaves `\renewcommand{\v}[1]` as the
only definition of `\v`, so `\v0`, `\v1` and `\v{x}` all expand as
one-argument invocations. `\vone` joins the existing `\vzero` for
consumers that prefer a named form.
Verified with pandoc 3.8.3, whose `latex_macros` extension expands macros
defined in the same document. Appending `$\v0$ $\v1$ $\v{x}$` to each
version of `macros.qmd` and running `quarto pandoc -t latex` gives:
no definitions present \v0 \v1 (\v{x} absent)
macros.qmd before \v0 \tilde{1} \v{x}
macros.qmd after \tilde{0} \tilde{1} \tilde{x}
The middle row is the bug: only `\v1` expands, because the delimited
`\def\v1` is the surviving definition of `\v`. The top row shows what
an unexpanded macro looks like, which is what the middle row's `\v0` and
`\v{x}` are -- and those are exactly the tokens LuaLaTeX then chokes on.
The bottom row is the fix.
Existing consumer usages `\tp{\v1}\vx` and `\pi^{\v1 \cdot \vy}`
expand identically before and after.
Closes #86
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claimed by a Claude Code CLI session — driving this PR to a clean review and green CI. Filed and fixed from the consumer side: this defect broke d-morrison/rme's book build, worked around there in d-morrison/rme#1159 by switching to Posted by Claude Code (AI agent) --- not written by a human. |
include-in-header recipe may leave \v as the LaTeX caron accent, contrary to README (unverified)
#88
|
Corrected an error in this PR's own table, caught by the The null row's So Nothing about the fix or the other five cells changes — the discriminating middle row is untouched. Worth recording plainly: this is a table written to correct a reviewer's mis-measurement, which itself shipped with a mis-measured cell, for the same underlying reason — a test that omitted one of the things the claim covered. Posted by Claude Code (AI agent) --- not written by a human. |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Claude finished review — View run Tabs are correct. Everything checks out — the diff is small, well-scoped, and the TeX reasoning in the PR description is technically sound (control-word tokenization in TeX does terminate at a digit, so
No lab-manual concerns: this is a pure I did not find any evidence of hallucinated APIs/functions — the PR only edits macro/text definitions and a TSV mapping, no external calls or citations to fabricate. VerdictReady for merge Structured Review Data (JSON){
"schema_version": "1.1",
"reviewer": "claude",
"commit_sha": "2426617cf9c2cdfe67fe665a76ddccca3997c76f",
"verdict": "CLEAN",
"findings": [],
"detailed_assessment": "Checked macros.qmd and interpretations.tsv for leftover \\v0/\\v1 references and confirmed macros-table.qmd's parsing regex still matches the new \\vone definition correctly.",
"holistic_assessment": "Fix is narrowly scoped, restores documented \\v{x} one-argument usage, keeps interpretations.tsv synchronized with macros.qmd, and introduces no regression risk to other consumers."
}Reviewed commit: 2426617 |
…port, or phrased outside the guard vocabulary (#3475) * Stop hook: fix subagent-relevance scoping, source_note derivation, and citations (ai-config#3472) Extends no-incomplete-check-enumeration.py to WARN on broader merge-readiness vocabulary and on a terminal claim resting solely on a dispatched subagent's own report. Addresses five findings from an adversarial review of the staged extension: 1. BLOCKING REGRESSION: subagent relevance was scoped to the whole transcript, so any unrelated earlier Agent dispatch silently downgraded the original BLOCK case to a WARN. Now scoped to the claim's own evidence window (after the CI reading in play, or a matching PR target). 2. The WARN message's source_note was chosen by an independent predicate that could disagree with what actually fired, producing a false explanation. Now derived directly from the firing condition. 3. Wrong citations (ai-config#3468, d-morrison/macros#87 as if they were write-ups). Fixed to cite ai-config#3472 as the write-up, naming the other two as the subject PRs where the claims were made in chat. 4. RX_DECLARE_MERGE_READY had no PR/git anchoring, so an ordinary data-frame merge sentence could false-positive. Now requires a nearby #N/PR/branch token. 5. Removed unreachable dead-code branch in the test suite's classify(). Registered in hooks.json but NOT activated as a new hook -- it already governs an existing, currently-active hook, so no separate activation gate applies; this PR only changes that hook's already-active behavior on merge. Closes #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: isolate the matches_target arm of _relevant_last_subagent Review of d1673c2 found the branch untested: deleting it outright (`matches_target = False`) left all 34 cases passing, so its documented behaviour was unverified and could have been inverted or reading the wrong PR set without the suite noticing. Every existing subagent-relevance case varies only the TIMING of the dispatch, so all of them are decided by `in_window` alone. Nothing exercised the other arm. Adds SAME_PR_AGENT_DISPATCH / SAME_PR_AGENT_REPORT and a case pairing them: a subagent dispatched about the SAME PR as the claim, landing BEFORE the CI reading. It is outside the timing window, so `in_window` is False, and only `matches_target` can carry it. Its shape is identical to the unrelated-#9999 case directly above, which blocks -- the PR reference is the single difference between them. Mutation-checked, which is the point of the case: matches_target = False -> 34/35, FAIL warn (got block) restored -> 35/35 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): stop the WARN text asserting things the transcript contradicts Three fixes from the review of 1e3bfcf (#3475), plus the generated plugin copy that CI caught out of date. **Finding 2 -- the scoping fix was inert wherever it mattered most.** `in_window = idx > last_partial` reads `idx > -1` when there is no CI reading anywhere in the transcript, which is true for every event. So a transcript with no partial reading re-admitted the entire transcript through the very branch written to scope it, and an unrelated dispatch for #9999 made a later claim about #123 look subagent-sourced. Requires a reading to exist before there is a window to be inside. **Finding 1 -- the WARN blamed the subagent for a push's staleness.** The subagent reason fired whenever a relevant event existed, without asking whether the subagent is why the claim is uncovered. On AGENT_DISPATCH, AGENT_REPORT, CHECKER, PUSH the session *did* run the complete instrument, after the report -- so "not a reading you ran yourself" is false, and the push is the real cause. Gated on `last_subagent > last_complete`, with a distinct push-staleness reason for the other case. This suite had that scenario baked in backwards: its CONTENT_CASES asserted the subagent text was correct there. Corrected, and paired with a case where the report IS the last evidence, so each reason is pinned to the situation it is true in. Mutation-checked, each against the restored 37/37: in_window back to `idx > last_partial` -> 36/37 subagent reason ungated -> 36/37 push-staleness reason removed -> 36/37 Also regenerates skills/ai-config-hooks/hooks/hooks.json, which `gen-hooks-plugin.py --check` failed on in CI. Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): give the tie a reason, so no WARN ships an empty explanation Round-2 review of e9eaeb5 found the same defect class the previous commit claimed to have closed, one case further out. A push and a complete instrument read in the SAME turn -- two tool_use blocks in one message, or one `git push && check-pr-fully-clean.py` command -- land on the same transcript index. Both reason guards are strict `>`, so at a tie neither fires; with `hit_core` true the vocabulary reason does not fire either, and `source_note` comes out empty. The message then had a blank gap where its explanation belongs, and its headline asserted no instrument run existed "since the most recent push" when one had run in that very turn. Three changes: - A tie reason. It says what is actually true: the transcript cannot order two events in one turn, so re-run the instrument alone. - The headline now says "postdates" rather than "since", which stays true at a tie. - The push reason drops its `last_complete >= last_subagent` conjunct. Paired with the subagent guard's `>`, that made the two mutually exclusive, so the adjacent comment's "both apply when both reasons are in play" could never happen. Both can now fire when both are true. Also drops the `and last_partial >= 0` conjunct from `is_original_ci_case`, which the guard above already forces, and covers `your call to merge`, an alternative deletable with the suite green. Mutation-checked against the restored 39/39: tie reason removed -> 38/39 `your call to merge` removed -> 38/39 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): derive the WARN reason from an argmax, not from independent guards Three review rounds each found another transcript where the WARN text asserted something the transcript contradicts, and each was patched with one more guard. Round 3's was a partial CI reading landing AFTER a subagent's report: `last_subagent > last_complete` held, so the message still said the subagent's report was "the most recent evidence in this transcript", while a fresher reading sat right there. Tightening that guard to `last_subagent > max(last_complete, last_partial)` just resurfaces round 2's empty-note bug on the same transcript -- no reason fires at all. That is the tell: the shape is wrong, not the thresholds. Independent predicates over four indices have no reason to be exhaustive, and nothing makes them agree with each other. So derive the reason from which evidence is actually newest: evidence = [(last_subagent, "subagent"), (last_partial, "partial"), (last_push, "push"), (last_complete, "complete")] newest = max(v for v, _ in evidence) kinds = {k for v, k in evidence if v == newest and v >= 0} A complete read tied with something it would have to postdate is the tie case. Otherwise every kind at the newest index contributes its reason, so the note names exactly what the transcript holds -- and ties between two non-complete kinds name both rather than picking one. An argmax cannot be empty and cannot name evidence that is not the newest, so both failure modes are structural rather than patched. Adds the missing partial-reading reason, which had no WARN-path text at all -- the gap that let round 3's transcript fall through to the subagent. Verified against the reviewer's own reproducer: subagent report, then `gh pr checks 42`, then "PR #42 is ready to merge." now names the short CI surface and does not blame the subagent. Mutation-checked against the restored 40/40: partial dropped from the evidence set -> 39/40 tie branch removed -> 39/40 partial reason removed -> 39/40 subagent reason removed -> 39/40 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): the push reason must not assert a complete read that never ran Round 4. The argmax rewrite carried the push reason's text over from the guarded form it replaced, and dropped the guard with it. That text opens "A complete instrument read is in this transcript, but a `git push` landed after it" -- unconditional on `last_complete >= 0`. So on gh pr checks 100 -> git push -> "#100 is good to merge" with no `check-pr-fully-clean.py` anywhere, the WARN blamed a complete read that does not exist. The true account is that a short CI surface was outdated by the push. Splits the reason in two, one sentence per case, and adds the missing one. Also pins two things the reviewer found resting on nothing: - `_relevant_last_subagent`'s strict `idx > last_partial`. A subagent report sharing an index with a partial reading is not after it, so it stays outside the window and the original case still BLOCKs. Loosening to `>=` flips that input to WARN, and until now nothing noticed. - The `len(kinds) > 1` conjunct on the tie branch, which exhaustive enumeration over the 920 reachable states shows is inert -- the early return forecloses `complete` being the sole newest kind. Removed, with a comment recording why reaching that line means a tie. Mutation-checked against the restored 42/42: push sentence ungated -> 41/42 no-complete-read reason gone -> 41/42 `in_window` loosened to `>=` -> 41/42 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): label the claim's own PR, not the first one in the message Round 5. `_pr_label` took the first `#N` anywhere in the message, so "#100 was closed as a duplicate. #200 is green, awaiting your merge." reported a claim "about #100" and handed back a remediation command pointed at the wrong PR -- on both the BLOCK and WARN paths. A guard that tells you to run the instrument is worth little if it names the wrong argument. Takes the nearest reference at or before the matched claim phrase, since a claim's subject usually precedes it, falling back to the nearest one after and then to the old behaviour. Mutation-checked against the restored 43/43: _pr_label called without the match -> 42/43 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): bound the PR label, and decline to name one outside the window Round 6. The nearest-before heuristic was unbounded, so it reached across a whole message to grab a reference the text itself called unrelated, and an issue number sitting before the claim outranked the PR the claim was about. Both produce a confident wrong label in the remediation command the user is being told to run. No heuristic over free text picks right every time, so this one stops trying to. It takes the nearest reference within 120 characters of the claim phrase, and otherwise returns "the PR you named". A vague label the reader resolves themselves is strictly better than a precise one pointing at the wrong PR: the first costs a moment, the second sends the instrument somewhere it will answer a question nobody asked. Mutation-checked against the restored 44/44: _LABEL_WINDOW unbounded -> 43/44 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): resolve the claim's subject once, so two call sites cannot disagree Round 7. `claim_pr_refs` took every `#N` in the message while `_pr_label`, added two rounds earlier, took a windowed reference near the claim. Two notions of "what this claim is about", derived from the same text, and the disagreement was exploitable: dispatch(#100) -> report(#100) -> gh pr checks 651 -> "#100 was closed as a duplicate. #651 is fully clean." The #100 subagent matched `claim_pr_refs`, so `last_subagent >= 0` flipped `is_original_ci_case` to False and the canonical BLOCK case only WARNed -- reopening round 1's regression for any message naming more than one PR. A multi-PR status recap is exactly the shape `_pr_label`'s own tests model, so the guard was defeated by the situation it had just been taught about. Resolves the subject once, via `_pr_label`, and derives `claim_pr_refs` from it. This is round 3's lesson again: the defect was two things allowed to disagree, so the fix removes the possibility rather than aligning them. Mutation-checked against the restored 46/46: claim_pr_refs back to whole-message refs -> 44/46 (both new cases fail: the #200 claim warns off a #100 subagent, and the canonical #651 BLOCK downgrades to a WARN) Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): stop free-text PR matching from deciding BLOCK at all Round 8. Round 7 narrowed `claim_pr_refs` to the single labelled PR to stop an unrelated subagent downgrading the canonical BLOCK case. That purchased the fix with a worse failure: on dispatch(#500) -> report(#500) -> "#500: implemented the changes over in #501 as a follow-on. It's fully clean." the label resolves to #501, the #500 subagent stops matching, and with no CI reading to supply a timing match the guard goes SILENT -- on exactly the shape the extension exists to catch. Neither width works, because the two consumers want opposite things. The LABEL needs one PR, conservatively, since it goes into the command the user runs. The MATCH needs all plausible referents, permissively, since it only asks whether a subagent touched something this claim is about. So separate what each may decide, rather than trying to find one width that serves both: - `subagent_timed` -- landed after the CI reading, pure ordering, no text heuristic -- is now the ONLY thing that can suppress the BLOCK. Free-text subject attribution is not reliably decidable, so it must not decide whether the canonical case blocks. - `last_subagent` stays permissive over a window around the claim and feeds only the reasons and `reading_needed_since`, whose worst outcome is a WARN. This hook family accepts noise over silence. Three existing expectations changed, deliberately, and each is now annotated with why: a same-PR subagent before the reading blocks rather than warns, a nearby unrelated subagent warns rather than staying silent, and one content case moved to merge-ready vocabulary to stay on the WARN path it was written to check. `last_partial >= 0` returns to `is_original_ci_case`, and its history is worth keeping. Round 4 proved it inert by exhaustive enumeration, and it was -- while the line read `last_subagent < 0`, which the early return made imply it. Keying on `subagent_timed` breaks the implication and the conjunct is load-bearing again. An inertness proof is a statement about the surrounding guards, not about the conjunct. Mutation-checked against the restored 48/48: BLOCK routed through the permissive match again -> 45/48 `last_partial >= 0` dropped -> 40/48 matching narrowed to the single label -> 45/48 matching widened to the whole message -> 47/48 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): hedge the subagent reason when the match is only proximity Round 9. The routing held up, but the WARN's sentence did not. On dispatch(#100) -> report(#100) -> "#100 was closed as a duplicate. #200 is fully clean." the message correctly labelled the claim as being about #200 and then asserted that "the most recent evidence in this transcript for that claim is a dispatched subagent's OWN report" -- of a subagent that worked only on #100, in a message whose own text says #100 is closed and unrelated. A reader could go audit the wrong PR's evidence chain. The permissive window is right for deciding whether to look; it is not evidence of what the claim rests on. So the sentence now depends on how the subagent was matched: - Matched by ordering, or working on the PR the claim names: assert. Both are facts the transcript settles. - Matched only by a nearby reference to some other PR: hedge. It says a report concerns a PR mentioned alongside the claim, that whether the claim rests on it is not something the transcript settles, and what follows if it does. This is the round-3 lesson at the level of prose rather than control flow: derive what you say from what you actually know, instead of from the condition that happened to fire. Mutation-checked against the restored 49/49: hedged variant removed -> 48/49 label check dropped from on-topic -> 48/49 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hooks): ordering settles when a report arrived, not what it concerns Round 10. The previous commit's `subagent_on_topic` kept an ordering disjunct -- `last_subagent == subagent_timed` -- and that arm carries no topical check at all. So the round-9 false sentence came back through the timing path instead of the proximity one, on the repo's own fixture whose dispatch prompt literally reads "look into unrelated task #9999, nothing to do with 651": gh pr checks 651 -> dispatch(#9999) -> report(#9999) -> "#651 is fully clean at a5f4f3f2." The WARN named that report as "the most recent evidence in this transcript for that claim". It is the newest event, and it concerns nothing the claim mentions. The disjunct is gone. Topic is decided by topic: does the newest relevant subagent's own refs contain the PR this claim names. Ordering still decides relevance and still decides the BLOCK routing, where it is the right instrument because it needs no text heuristic -- it just cannot also underwrite a sentence about subject matter. The existing case over that fixture pair asserted only `decision == "warn"`, so the wording was unpinned; a content case now covers it. Mutation-checked against the restored 50/50: ordering disjunct restored -> 49/50 Refs #3472 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
…is a claim too (#3468) * ums(verify-the-right-artifact): a reviewer's own counter-measurement is a claim too Measured 2026-09-09 driving d-morrison/macros#87: an adversarial-reviewer subagent filed a factual-error finding against a commit message's verification claim ("verified through `pandoc -t latex`"). The reviewer's own counter-test appended `\v0 \v1 \v{x}` to a document with NO macro definitions, observed pandoc pass every token through unexpanded, and concluded pandoc never expands TeX macros in math mode -- so the original claim could not possibly have discriminated a working macro file from a broken one. The reviewer's measurement was real and its reasoning from it was valid. It omitted a precondition: pandoc's `latex_macros` extension only expands macros defined in the same document, so the reviewer tested the null case and read it as the general case. Re-measuring with the precondition restored produces the actual discriminator (a three-row table), which is now embedded in the fix's own PR body. Adds a new section to verify-the-right-artifact.md making two points: (1) `dont-take-my-word-for-it` applies to reviewers, not just to humans and documents -- an accepted or rebutted finding both require re-measuring; (2) the fix for a vague verification claim is to put the discriminating measurement, including the null row, into the durable artifact rather than arguing it out in a PR comment where it dies with the thread. Cross-links from dont-take-my-word-for-it.md's AI-to-AI hallucination-loop bullet. Closes #3467 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: semantic line breaks Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ums: fix table cell, redundancy framing, and Do/Don't pairing Addresses adversarial-reviewer findings on the prior commit: - The 'no definitions present' row's \v{x} cell was wrong (claimed absent; independently re-verified against d-morrison/macros#87's actual branches with quarto pandoc -t latex: it passes through unexpanded like \v0, matching the other two rows). - Reframed the section's opening: it is a plain artifact substitution (shape 1), not an instance of the immediately preceding "scoped narrower" section, and says so explicitly instead of using "identical substitution" ambiguously right after that section. - Restructured the Do/Don't list into three matched pattern/anti-pattern pairs per CLAUDE.md's pairing convention (was 3 Do / 2 Don't). - Moved the dont-take-my-word-for-it.md cross-link from the "AI-to-AI hallucination loops" bullet (about ungrounded claims) to "The illusion of prior verification" (about false confidence from a real signal), which fits the reviewer's-own-counter-test case better. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ums: drop imprecise shape-1 label, restore untouched bullet's line wrap Addresses second-round adversarial-reviewer findings: - 'shape 1' (cached/stale copy) doesn't fit a document that never had the precondition rather than one that is merely stale; drop the specific number and just say it's the same kind of substitution the four shapes describe, without forcing it into one of them. - The AI-to-AI-hallucination-loops bullet's line wrapping was incidentally flattened by the sembr tool while editing the adjacent bullet, though its wording didn't change; restored its original 4-line wrap per semantic-line-breaks.md's preserve-existing-breaks rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ums: wrap the two new sentences to match the file's sibling bullets Third-round adversarial-reviewer finding: the two new sentences added to dont-take-my-word-for-it.md's "illusion of prior verification" bullet were left as single unwrapped lines (438 and 263 chars) while every sibling bullet in the same list wraps at ~80 chars per semantic-line-breaks.md -- an inconsistency within one bullet list, distinct from verify-the-right-artifact.md's already-common long single-sentence lines elsewhere in that file, which needed no change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
What was wrong
TeX has no multi-token control sequences, so
\def\v0{\vec{0}}(line 68) and\def\v1{\vec{1}}(line 375) never defined macros named\v0and\v1. Each defined\vwith a delimited parameter whose delimiter is the literal digit.
\def\v1came last, sothe surviving definition of
\vmatched only a following literal1— silently overriding\renewcommand{\v}[1]{\vecf{#1}}at line 356.So
\v1worked by accident of ordering, while\v0and the documented one-argument form\v{x}both failed withUse of \v doesn't match its definition.It took down
d-morrison/rme's book build:run 34375808884.
The change
Remove both pseudo-definitions.
\renewcommand{\v}[1]{\vecf{#1}}becomes the onlydefinition of
\v, so\v0,\v1and\v{x}all expand as ordinary one-argumentinvocations.
\vonejoins the existing\vzerofor consumers preferring a named form, andinterpretations.tsvis updated to match.Verification
Appending
$\v0$ $\v1$ $\v{x}$to each version ofmacros.qmdand runningquarto pandoc -t latex(pandoc 3.8.3, whoselatex_macrosextension expands macros definedin the same document):
\v0\v1\v{x}\v0\v1\v{x}macros.qmdbefore\v0\tilde{1}\v{x}macros.qmdafter\tilde{0}\tilde{1}\tilde{x}The middle row is the bug: only
\v1expands. The top row shows what an unexpanded macrolooks like, which is what the middle row's
\v0and\v{x}are — and those are exactly thetokens LuaLaTeX chokes on.
Also checked:
\tp{\v1}\vxand\pi^{\v1 \cdot \vy}(fromd-morrison/rme's_sec_linear_algebra.qmd,_sec_logistic_score_fn.qmd,_sec_binary_likelihood_no_covs.qmd)produce byte-identical output before and after.
macros-table.qmdrewrites only\providecommandlines into\deffor MathJax, so the
\renewcommand{\v}[1]passes through unmodified and works there too.macros-table.qmd's ownparse_macros()+ merge step run against the changed files:793 macros, 0 missing interpretation rows, 0 orphaned TSV rows.
Closes #86
🤖 Generated with Claude Code