Read the counters per letter and per loop, not against one threshold - #556
Conversation
The owner's design input of 2026-09-06 turns "this loop must stay open" from a global threshold into a property of one loop of one letter, read off the plate: a size class, and for the small ones a state — open, varying, or a Punktkringel that is a dot by construction. This is a measurement round, not an arm. No candidate, no switch, no adoption, no core/ byte, no DB write, no root re-export. What ships is a frozen catalogue, a report-only sensor, and the decomposition of the "26 closing words" the counter diagnosis left standing. The catalogue (tools/tracebench/kringel_catalogue.json, built by tools/tracebench/kringelcat.py from the frozen sep05 root eaa195aa7c84) carries 46 loops over 27 glyphs: 22 klein, 16 mittel, 8 gross, and 41 offen, 4 wechselnd, 1 punkt. The size cuts are counted in widths of the plate's own pen (W = 0.1936 xh) because the pen erodes a centerline loop by exactly W, so the classes describe the instrument rather than the sample; the state comes from how often the plate shows a hole at all. Both rules were fixed before the first catalogue number and are not moved by it. The sensor (tools/tracebench/kringel.py) adds `kringel <lost>/<offen>` to every trace-bench word line and `kringel_lost` to the block. Only an `offen` loop that the delivered pen runs shut counts as a loss; `wechselnd` closures are carried apart because the plate closes those itself, and `punkt` loops are exempt. Report-only, proved rather than asserted: words 0.108444 and pairs 0.148236 unchanged, and the trace report is line-for-line identical beside the new column (68 lines on the authored identity run, 56 on the chain dev run). Re-reading the 26 through the catalogue: on the diagnosis's own path 27 words close, 24 of them a real `offen` loss and 3 only a `wechselnd` one. Over all loops the number goes the other way — 34 of the 63 word specimens lose an `offen` loop at half width 0.097, and beside them stand 19 counters no composed loop accounts for at all. Two findings fall out: the d loop needs nothing, and the `t` is the blind spot the ductus loop finder never had a range for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
`SIZE_CLASSES` and `STATES` stood as documentation and nothing read them. Now `load_catalogue` checks every row against them, because an unknown state does not fail loudly: it falls through `word_kringel` as "no expectation", which is indistinguishable from a Punktkringel, and a whole class of loops would go unwatched while the report looked healthy. The fourth mark gets its own name, `UNATTESTED` — the absence of both a class and a state, for a loop the plate answers in no occurrence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
There was a problem hiding this comment.
🟡 Changes recommended
The sensor can mismeasure deferred-diacritic glyphs, apply Sütterlin expectations to other styles, expose reserved data, and publish inconsistent totals.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a report-only, per-loop Kringel landmark for tracebench, backed by a frozen Sütterlin catalogue and documented measurement round.
Changes:
- Adds catalogue generation, loop classification, and tracebench reporting.
- Adds comprehensive unit tests for the new sensor.
- Records methodology, findings, terminology, and changelog updates.
File summaries
| File | Description |
|---|---|
tools/tracebench/summary.py |
Prints per-word Kringel results. |
tools/tracebench/run.py |
Integrates the sensor and CLI option. |
tools/tracebench/kringelcat.py |
Builds the frozen catalogue. |
tools/tracebench/kringel.py |
Implements loop detection and classification. |
tools/tracebench/kringel_catalogue.json |
Stores measured loop expectations. |
tools/docs_budget/__init__.py |
Raises the measurement-path budget. |
tests/test_tracebench_kringel.py |
Tests classification and reporting behavior. |
docs/reference/werkzeuge.md |
Documents the new tooling. |
docs/reference/messjournal.md |
Records the measurement round. |
docs/reference/menschliche-bewertung.md |
Connects the sensor to human evaluation. |
docs/reference/kurzglossar.md |
Adds the short glossary entry. |
docs/reference/glossar.md |
Defines the new terminology. |
docs/proposals/tintenfolger.md |
Records the landmark and rescue paths. |
changelog.d/kringel-landmark.md |
Adds the changelog fragment. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 11
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review round on the Kringel landmark. The substantive find is the first one; the rest close doors the sensor left open. A delayed diacritic carries its slot's `slot_index` but is flushed at the END of the word (`flush_diacritics`), so reading a slot's index range naively runs from its body to past the last letter: "the item after the slot's last" is then a foreign connector, and the mark itself joins the stroke set the loops are measured on. Both the sensor's grouping and the catalogue's slot-assignment now build that index from body items only, through one shared `body_items`. On the sep05 root this is a clean null probe — the rebuilt catalogue is byte-identical to the committed one apart from its new `style` key, because a mark sits directly above its own letter and so never won a span or a nearest-stroke comparison. The fix stands anyway: the reading was right by luck, not by construction. Three refusals, each guarding a failure that would look like a result: - A catalogue belongs to ONE hand. Every class in it is counted in the width of one plate's pen and every state read off one plate's ink, so the sensor now compares the catalogue's `style` and root against the run's and omits the column on a mismatch, instead of publishing Sütterlin-1922 expectations under a Kurrent run's name. - `load_catalogue` raises ValueError for a malformed row too. A missing field used to raise KeyError, which `kringel_by_word` does not catch — the documented "an unreadable catalogue costs the column, never the run" would have aborted the bench instead. The per-word boundary now also covers the measurement, not only the composition. - `--kringel-half-width` refuses a non-finite or non-positive value. A negative width widens every aperture and a NaN makes every comparison false; either typo yields a plausible column that means nothing, and nothing downstream would flag it. Docs: the committed catalogue is now written down as a NAMED exception to the open-core rule in quellen-und-rechte.md §5 — it is an expectation ruler, not a holding, and what makes that true is stated and tested (no geometry, no per-occurrence rows, no word specimen). The changelog fragment carried the pre-splitter-floor totals; it now names both populations, 24 of 27 on the diagnosis's path and 34 of 63 over all loops. The journal's "largest relative loss" claim contradicted its own table — `t`#1 keeps 13 % where the e keeps 19 % — and is reworded. The Kurzglossar's declared term count had drifted to 79 against 81 entries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
|
Review round on 1 · The honest footnote: on the 2 · malformed rows escape as 3 · the catalogue is applied to any style and root — correct, and the sharpest of the eleven. Every class in it is counted in the width of this plate's pen and every state read off this plate's ink; publishing that under a Kurrent run's name would be a fabricated expectation. The catalogue now records the hand it belongs to ( 7 · 5 · the committed catalogue and the open-core boundary — the right question to raise, and it needed a decision rather than a code change. Committing it is the author's instruction for this round, so what was missing was the written exception, and that is now in 8 · the changelog totals contradict the record — correct, they were the pre-splitter-floor numbers. The fragment now names both populations rather than blending them: 24 of 27 real 10 · 11 · "delivered" pen wording — correct and worth fixing precisely because the number reads as a production failure otherwise. The flag defaults to the plate pen 0.0968, not the root's delivered nib 0.0724. Both docstrings now say "selected", the flag's help says which pen the default is, and the summary line spells it out: 9 · the Kurzglossar term count — correct that it was stale, off by one more than reported. Counting lemma lines in the term section ( Gates after the round: |
Three conflicts, all of them two rounds of the same day landing in the same place: - `docs_budget`: #558 raised `mess-runde` for its own pair of register rows against the pre-merge number, this branch raised it for its one. Both comments stay, and the budget is re-measured ONCE on the merged file (21 722 for all three rows) rather than added up from two branches that each measured without the other. - `glossar.md`: both sides appended to the same two Schnellindex lines; all four terms kept. - `messjournal.md`: both sides appended a register row and a §14 entry at the file end; both kept, in merge order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
The suite total in the entry's gate table was measured before the merge. Re-run on the merged base it is 2524 of 2532. Both report-only proofs were re-run there too and still hold: words 0.108444 and pairs 0.148236 unchanged, and the authored identity run 68 lines identical, line for line, beside the new column. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
…written Seven findings from the code review, and the two that matter both changed the result rather than the prose. The counter assignment was a flat 0.45 xh proximity search over every hole of the word — exactly what #553 measured going wrong, because it hands a loop the neighbour's hole and a word-global claim set then cascades the mistake. `catalogue_targets` now runs #556's Slot-Lineal verbatim: a hole belongs to the slot whose own BODY strokes span its x, and loops and holes are paired one-to-one inside that letter. The whole round is re-measured on the fixed rule (108 loops instead of 106; the "belongs to another loop" refusal drops from 9 to 1, which is what the old rule was mislabelling). And the round's own gates were read too generously. The pre-registration says the aperture has to land within 0.02 xh PER corrected loop, and that dtw must not be worse in median AND p90; 83 of 108 and a p90 of +0.000699 make both red. The entry, its register rows, the Kette ledger line and the §7.9 row now say four gates red — with every reading that improved still reported as a reading. Reading a gate more kindly after seeing the number is the one thing a pre-registration exists to prevent. The rest: the word-level early return that silenced the per-loop refusals when a plate shows no hole at all is gone (its two tests no longer need a decoy counter, and a new test pins the neighbour case); the fade length in the follower's help text said one nib where the constant is half a nib; the changelog described the fade as a property rather than as the attempt the round measured as too short; and the two new open arms are now in tintenfolger.md's Stand block. `ZweiZuegeOptions` keeps its name — `Laufform`, `Kringel` and `Eigenhand` are the repo's settled convention for a project Fachbegriff, and the code, the glossary and the ledger row have to be findable under one word. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
R3 of the counter diagnosis, armed with the per-loop targets #556 froze, and the owner's decision of 2026-09-06 ("2 ja"): after the catalogue, the two-stroke model is the next lever. His principle carries the whole arm — **follow the ink correctly and the counters stay open.** ## The mechanism, in three sentences 1. Where two pen strokes fuse around a small counter the ink is the union of two capsules of the **known** pen half width (0.0968 xh, the plate's own), and the medial axis of that fused lump is not the pen path — the H0 finding of #551, which every consumer of the skeleton inherits before any fit runs. 2. A point of the plate's counter is inked exactly when some pen sample lies within `w_pen` of it, so the model reads that backwards as a hard statement about the path: **no pen sample may sit closer than `w_pen` to a counter the plate holds open.** 3. The correction pushes every violating sample out along the counter's own distance field, fades the displacement to zero C¹ at both ends of the corrected run, and **reverts** any push that pulls the loop's own self-crossing open. Scope is the catalogue's and nothing else: state `offen`, size class `klein` or `mittel` (the classes where the instrument decides), and only where the plate shows a hole in that very occurrence. A loop the trace does not draw is a topology loss and goes to R4 untouched; a lump the plate closed is measured (`stroke_separation`) and refused, because with no hole nothing says which side of it each pass ran on. ## Where it sits, and why that is the smallest honest point After the last follower round, on the assembled pen path, behind `--zwei-zuege` (**default off**). No chain solve changes, no `core/` byte moves (so the golden parity fixture is byte-identical by construction), and it reads the plate plus one frozen pen constant — never a Laufform row, so it cannot close the harvest fixed point #553 warns about. ## What the measurement says Frozen roots `eaa195aa7c84…` / `0fbde2d72b64…`, BLAS pinned, one knob against an otherwise identical base that reproduces the campaign's `sep05` numbers digit for digit. **Verdict: not adopted.** Gates (b) and (d) are red; the switch stays off. And the mechanism reaches its target completely: | Gate | Reading | Verdict | |---|---|---| | (a) aperture | **106 of 106** corrected loops open at 0.097 (before 101/106); within ±0.02 xh of the plate's expectation in **85 of 106** | green | | (b) structure | `cross_missing` 12 → 13, `cross_spurious` 7 → 9 — **the whole amount on `will` and `Galoppieren`**, the other 17 dev words move no counter | **red** | | (c) distance | dev-19 dtw median −0.000701, sign test **14 : 3** (p = 0.0127), aiou 0.7583 → **0.7601**, chamfer better both ways, 63-word Soll **80 → 78** | green | | (d) continuity | **1588** new kink events, p90 Δ +16.74° | **red** | | (e) word bench | 0.108444 · 0.148236 unchanged | green | | (f) golden | `core/` untouched, 2540 tests pass | green | | (g) fixed point | 58 of 63 words have the same target set with and without the rows; the 5 that differ find MORE loops with a row, never a different target — the target value never comes from one | green | Per-loop, the three keys the round was aimed at: `a`#0 0.2046 → **0.2751**, `o`#0 0.1741 → **0.2535**, `sz`#0 0.2231 → **0.3063**, every occurrence open. **Gate (d)'s cause is measured, not guessed.** A trace carries a sample every 0.0265 xh, so the declared anti-raster window (0.0363) spans 1.37 samples and the C¹ fade (0.0725) spans 2.74 — both are stated in pen widths and are, in samples, shorter than the discretisation they were meant to smooth, so the plate's pixel raster lands in the pen path. **R3b**, pre-registered in the same round and run on an offline path validated to reproduce the follower arm exactly (largest coordinate difference 0.000e+00), moves that one length up to the sensor's own reading window. The prediction holds in both directions: kinks 1588 → **465**, p90 +16.74° → **+4.81°**, `cross_spurious` back to 7 and `will` clean — but gate (a) 85/106 → **62/110** and the k0 Soll 78 → 82. Also not adopted, and together the two arms measure the formulation out: precision and smoothness hang on the same length, so the constraint belongs **inside** the follower solve rather than behind it. That is now evidence rather than the assumption R4 wrote down. ## Not in this PR, on purpose - **No Laufform row is re-derived and no card is written.** The harvest has no follower path by the standing Route-A guard rail, and LF14's fixed-point finding blocks the write anyway. The "24 closing words" is a property of the rows, so this arm cannot move it; the path there is filed as an open arm. - **What the `t` needs, measured:** `loop_ranges` scans self-crossings **per pen stroke**, and the `t` closes its counter *between* its two strokes — chart row 120 anchors, `stroke_starts [0, 83]`, loops per stroke `[[], []]`, both strokes together one hole `D0 = 0.2475` spanned by anchors **66..109**, across the stroke boundary. So the follow-up is not "add a range" but "let the finder see cross-stroke crossings, and decide what a range spanning a pen lift means for `align_loops`". Filed in §7.11, not built here. ## Paperwork §14 entry (pre-registration, measurement, R3b) with its register row, the Kette ledger row, the §7.9 rescue-path row with four named conversions, two §7.11 open arms, glossary + Kurzglossar entries for **Zwei-Züge-Modell**, `werkzeuge.md`, a changelog fragment, and 16 new tests including the synthetic fused blob whose target is arithmetic rather than a measurement. `mess-runde-route` was over budget by 138 tokens after the ledger row was condensed once; raised deliberately with the reasoning in `tools/docs_budget/__init__.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
One pre-registered measurement round on the owner's design input of 2026-09-06:
"this loop must stay open" is not a threshold for the whole hand, it is a
property of ONE loop of ONE letter, read off the plate — a size class, and for
the small ones a state (open · varying · Punktkringel, closed by construction).
This is not an arm. No candidate, no switch, no adoption, no
core/byte,no DB write, no root re-export, no ruler touched. The frozen
sep05roots(
eaa195aa7c84/0fbde2d72b64) were used as they stand and reproduce theirheadline digit for digit (0.108444 · 0.148236) with
--expect-rootand BLASpinned. What ships is a frozen catalogue, a report-only sensor, and the
decomposition of the "26 closing words" #551 left standing.
Part 1 — the catalogue
tools/tracebench/kringel_catalogue.json, built bytools/tracebench/kringelcat.pyfrom one frozen root. 46 loops over 27glyphs, each with the plate's own aperture, the composed one, a size class and
a state.
Both rules were written down before the first catalogue number and are not moved
by it:
W = 2·0.0968 = 0.1936 xhand erodes a centerline loop by exactlyW, sokleinisD0 < 2W,mittel2W ≤ D0 < 4W,großD0 ≥ 4W. The cut describes theinstrument, not the sample. Afterwards, honestly: the
2Wcut does notfall in a gap — 18 values sit between 0.3049 and 0.4787 — while
4Wdoes(0.7212 → 0.8057). Both stay where they were.
hole at all:
offen ≥ 0.8,punkt ≤ 0.2, otherwisewechselnd. Blind to howWIDE the hole is — a Punktkringel is defined by the plate never opening it.
Acceptance against #551: six of the nine narrow glyphs read digit for digit
(
oszgrvG),a+0.0016,p+0.0086,k+0.0396 (the last twobecause the slot ruler attributes one occurrence more each).
w_pen0.0968and 202 plate counters are #551's numbers too.
Two corrections after the first pass, named rather than hidden. The
pre-registered assignment was containment (the plate hole's centre inside the
composed loop's region) — it claims only 121 of 202 counters, because a
narrowed loop no longer contains the hole, i.e. it fails exactly at the object
of measurement. Replaced by the slot ruler (the counter belongs to the slot
whose own strokes span its x, then one-to-one by centre distance inside the
letter): 183 of 202. The third ruler carried alongside (overlapping
inscribed circles) sits at 144. And a splinter floor of 0.05 xh was added
when a 0.015 xh loop of the
wclaimed a 0.59 xh counter — without it aCOLLAPSED loop is booked as a narrow one instead of a missing one.
Part 2 — the sensor, report-only
tools/tracebench/kringel.pyaddskringel <lost>/<offen>to every word lineand
kringel_lost/kringel_wechselnd_zuto the block. Only anoffenloopthe delivered pen runs shut is a loss;
wechselndclosures stand apart becausethe plate closes those itself, and
punktloops are exempt.Report-only, proved rather than asserted:
wordbench.run --set allruntime_stracebench --split all --candidate authoredtracebench --split dev --candidate chainThe price is one extra composition pass (as for the Duktus-Soll): the 29-word
identity run goes 114.8 → 175.3 s.
Part 3 — the 26, re-read
On #551's own path (its eight loop keys, and only where the plate shows the hole
in that very occurrence) this apparatus finds 27 words, one more because the
slot ruler attributes an extra
aand an extrak. Of those 27:offenloss,wechselndone —Sprünge·Zügel·regieren, all throughthe
gbowl — and are therefore not defects,The honest number that replaces 26 is 24. But the full catalogue corrects
the other way: over ALL loops, 34 of the 63 word specimens lose an
offenloop at half width 0.097 (44 occurrences), and beside them stand 19 topology
losses in 14 words — counters no composed loop accounts for at all. 36 of the
63 words carry one or the other.
a#0t#1tfirst needs a ductus loop range at allo#0sz#0G#0r#0k#1v#0wszGmnPSefTwo findings worth the round on their own. The
dloop needs nothing — ourrow draws it 0.017 xh WIDER than the plate, as do
s#0 andk#2. And thetis the blind spot:
core.aggregate.loop_rangeshas no loop range for it (norfor
nmiuc), the plate holds two counters there in 9 of 9occurrences, and
t#1 (0.0517 against 0.3912) is the catalogue's largest deficit— and the ONLY loss that survives at the delivered nib 0.0724, where #551 counted
zero.
Filing
§14 entry „Kringel-Landmarke
sep06" at the end ofmessjournal.mdwith itsregister row, a §7.9 rescue-path row in
tintenfolger.md, the landmark sentencein
tintenfolger.md§2.3 and inmenschliche-bewertung.md§3.6b, glossaryentries Kringel-Landmarke and Topologie-Verlust (Kringel) (plus the
Schnellindex and the Kurzglossar), the tool in
werkzeuge.md, and a changelogfragment.
mess-rundewas raised 21 057 → 23 179 with the arithmetic writteninto
tools/docs_budget/__init__.py— one dense register row, 15 tokens short,re-measured plus the documented 10 %; the §14 entry itself was trimmed to 4 500
against its unchanged 4 503 ceiling instead.
The review round
Eleven Copilot threads, all acted on — the full point-by-point is in the reply
comment.
Three of them changed something worth naming here:
the word, so a slot's naive index range ran past the last letter: the
"connector after the slot" was a foreign item and the mark joined the stroke
set the loops were measured on. Fixed at both call sites through one shared
body_items. On this root it is a clean null probe — the rebuiltcatalogue is byte-identical apart from its new
stylekey, because a marksits directly above its own letter. The reading was right by luck, not by
construction, which is why the fix stays.
counted in the width of this plate's pen, so applying it to a
--style kurrentrun would publish a fabricated expectation; the sensor compares thecatalogue's style and root against the run's and omits the column on a
mismatch.
open-core rule in
quellen-und-rechte.md§5, beside the gitignore rule itqualifies: it is an expectation ruler, not a holding — no geometry, no
per-occurrence rows, no word specimen, so no letter can be reconstructed from
it. A test keeps that true, and the exception is written as covering this
shape only.
Plus three refusals where a typo would otherwise have produced a plausible
column that means nothing (a non-finite or negative pen width, a malformed
catalogue row escaping as
KeyErrorpast the "costs the column, never the run"contract, and the per-word boundary that covered only the composition), and
three factual corrections: the changelog's totals, the entry's "largest
relative loss" claim (
t#1 keeps 13 %,e#0 19 %), and the Kurzglossar's termcount, which had already drifted before this round.
Verification
/verify-coreon the merged base: 2524 passed, 8 skipped;ruff checkandruff format --checkclean over 370 files. 26 new tests pin the two classification rules,the raster loop finder against circles of known diameter, the slot grouping
including a deferred mark, the report contract (
punktexempt,wechselndapart, an unknown glyph counted separately), and the four refusals — a missing
catalogue, a malformed row, a word outside the vocabulary, and a catalogue from
another hand or another root.
docs_budget check,docs_register checkand thechangelog fragment gate pass. The catalogue rebuilds byte-identically from the
same root, and both report-only proofs were re-run on the MERGED base after
origin/mainmoved under the branch: words 0.108444 · pairs 0.148236 unchanged,authored identity run 68 lines identical.
origin/mainmoved mid-review (#555, #557, #558) and the PR wentCONFLICTING,which starts no CI at all rather than a red one. Merged in and resolved: #558
raised the same
mess-rundebudget on the same day for its own register pair,so both raise comments stay and the budget is re-measured once on the merged
file (21 722 for all three rows) instead of being added up from two branches
that each measured without the other; the two Schnellindex lines and the two
appended journal entries keep both sides.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3