Skip to content

Read the counters per letter and per loop, not against one threshold - #556

Merged
MarkusNeusinger merged 5 commits into
mainfrom
kringel-landmark
Sep 6, 2026
Merged

Read the counters per letter and per loop, not against one threshold#556
MarkusNeusinger merged 5 commits into
mainfrom
kringel-landmark

Conversation

@MarkusNeusinger

@MarkusNeusinger MarkusNeusinger commented Sep 6, 2026

Copy link
Copy Markdown
Owner

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 sep05 roots
(eaa195aa7c84 / 0fbde2d72b64) were used as they stand and reproduce their
headline digit for digit (0.108444 · 0.148236) with --expect-root and BLAS
pinned. 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 by
tools/tracebench/kringelcat.py from one frozen root. 46 loops over 27
glyphs
, each with the plate's own aperture, the composed one, a size class and
a state.

klein mittel groß Σ
offen 18 15 8 41
wechselnd 3 1 4
Punktkringel 1 1
Σ 22 16 8 46

Both rules were written down before the first catalogue number and are not moved
by it:

  • Size in pen widths. The plate's pen has full width W = 2·0.0968 = 0.1936 xh and erodes a centerline loop by exactly W, so klein is
    D0 < 2W, mittel 2W ≤ D0 < 4W, groß D0 ≥ 4W. The cut describes the
    instrument, not the sample. Afterwards, honestly: the 2W cut does not
    fall in a gap — 18 values sit between 0.3049 and 0.4787 — while 4W does
    (0.7212 → 0.8057). Both stay where they were.
  • State from the plate. The share of occurrences in which the plate shows a
    hole at all: offen ≥ 0.8, punkt ≤ 0.2, otherwise wechselnd. Blind to how
    WIDE 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
(o sz g r v G), a +0.0016, p +0.0086, k +0.0396 (the last two
because the slot ruler attributes one occurrence more each). w_pen 0.0968
and 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 w claimed a 0.59 xh counter — without it a
COLLAPSED loop is booked as a narrow one instead of a missing one.

Part 2 — the sensor, report-only

tools/tracebench/kringel.py adds kringel <lost>/<offen> to every word line
and kringel_lost / kringel_wechselnd_zu to the block. Only an offen loop
the delivered pen runs shut is a loss; wechselnd closures stand apart because
the plate closes those itself, and punkt loops are exempt.

Report-only, proved rather than asserted:

run before after
wordbench.run --set all 0.108444 · 0.148236 0.108444 · 0.148236, report identical but for runtime_s
tracebench --split all --candidate authored dtw 0.000000 · aiou 0.7308 · gate PASS 68 lines identical, line for line
tracebench --split dev --candidate chain dtw 0.049757 · p90 0.091389 56 lines identical

The 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 a and an extra k. Of those 27:

  • 24 carry a real offen loss,
  • 3 only a wechselnd oneSprünge · Zügel · regieren, all through
    the g bowl — and are therefore not defects,
  • 0 are Punktkringel.

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 offen
loop 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.

loop class losses deficit rescue path
a#0 klein 15 +0.1055 R3 — loop geometry from the evidence
t#1 mittel 9 +0.3395 two-stroke model at fused loops; the t first needs a ductus loop range at all
o#0 klein 5 +0.1461 R3
sz#0 klein 5 +0.1349 R3
G#0 klein 3 +0.1367 skeleton loops for capitals (no running-form row, H3)
r#0 klein 3 +0.1182 running-form coverage — the row survives, the chart fallbacks don't (#551)
k#1 mittel 2 +0.2366 skeleton loops for capitals
v#0 klein 2 +0.1027 skeleton loops / author path
topology: w sz G m n P S e f 19 counters medial-axis term (R4 indicator) + two-stroke model

Two findings worth the round on their own. The d loop needs nothing — our
row draws it 0.017 xh WIDER than the plate, as do s#0 and k#2. And the t
is the blind spot
: core.aggregate.loop_ranges has no loop range for it (nor
for n m i u c), the plate holds two counters there in 9 of 9
occurrences, 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 of messjournal.md with its
register row, a §7.9 rescue-path row in tintenfolger.md, the landmark sentence
in tintenfolger.md §2.3 and in menschliche-bewertung.md §3.6b, glossary
entries Kringel-Landmarke and Topologie-Verlust (Kringel) (plus the
Schnellindex and the Kurzglossar), the tool in werkzeuge.md, and a changelog
fragment. mess-runde was raised 21 057 → 23 179 with the arithmetic written
into 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:

  • A deferred diacritic carries its slot's index but is flushed at the END of
    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 rebuilt
    catalogue is byte-identical apart from its new style key, because a mark
    sits directly above its own letter. The reading was right by luck, not by
    construction, which is why the fix stays.
  • The catalogue now belongs to one hand and says so. Every class in it is
    counted in the width of this plate's pen, so applying it to a --style kurrent run would publish a fabricated expectation; the sensor compares the
    catalogue's style and root against the run's and omits the column on a
    mismatch.
  • The committed catalogue is written down as a NAMED exception to the
    open-core rule in quellen-und-rechte.md §5, beside the gitignore rule it
    qualifies: 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 KeyError past 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 term
count, which had already drifted before this round.

Verification

/verify-core on the merged base: 2524 passed, 8 skipped; ruff check and
ruff format --check clean 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 (punkt exempt, wechselnd
apart, 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 check and the
changelog 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/main moved under the branch: words 0.108444 · pairs 0.148236 unchanged,
authored identity run 68 lines identical.

origin/main moved mid-review (#555, #557, #558) and the PR went CONFLICTING,
which starts no CI at all rather than a red one. Merged in and resolved: #558
raised the same mess-runde budget 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

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
Copilot AI balanced review requested due to automatic review settings September 6, 2026 21:58
`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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread tools/tracebench/kringel.py Outdated
Comment thread tools/tracebench/kringel.py
Comment thread tools/tracebench/kringel.py
Comment thread tools/tracebench/kringel.py Outdated
Comment thread tools/tracebench/kringel_catalogue.json
Comment thread tools/tracebench/run.py
Comment thread changelog.d/kringel-landmark.md Outdated
Comment thread docs/reference/kurzglossar.md
Comment thread docs/reference/messjournal.md Outdated
Comment thread tools/tracebench/run.py
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
@MarkusNeusinger

Copy link
Copy Markdown
Owner Author

Review round on eece259 — all eleven threads acted on. Point by point, because two of them changed a measurement and one changed a licensing decision.

1 · slot_index also tags deferred diacritics (kringel.py:209) and 6 · the same in the counter-assignment ruler (kringelcat.py:195) — correct, and it was a real bug. flush_diacritics appends the marks after the whole word, so a slot's naive index range ran from its body to past the last letter: items[hi + 1] was a foreign item rather than this letter's outgoing connector, and the mark joined the stroke set the loops were measured on. Both call sites now build the index from body items only, through one shared body_items, and no mark reaches a loop measurement or an x-span.

The honest footnote: on the sep05 root this moves nothing. The rebuilt catalogue is byte-identical to the committed one apart from its new style key — a deferred mark sits directly above its own letter, so it never won a span test or a nearest-stroke comparison, and no mark encloses a region for the raster finder to find. The reading was right by luck, not by construction, which is exactly why the fix stays. A test pins it (test_a_deferred_mark_neither_joins_its_slot_nor_moves_its_span).

2 · malformed rows escape as KeyError (kringel.py:239) — correct. The documented contract is "an unreadable catalogue costs the column, never the run", and kringel_by_word catches ValueError only, so a missing field would have aborted the bench. load_catalogue now validates shape, types and vocabulary and raises ValueError throughout. While in there: 4 · the per-word boundary now wraps kringel_row_fields too, not only derive_word — rasterising a degenerate stroke set is as able to raise as composing is.

3 · the catalogue is applied to any style and rootcorrect, 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 (_source.style plus its root), kringel_by_word takes the run's root name, and a mismatch omits the column with a warning. Two tests cover the foreign style and the foreign root of the same style.

7 · --kringel-half-width accepts negative and NaNcorrect. A negative width widens every aperture, a NaN makes every comparison false, and either typo yields a plausible-looking column that nothing downstream would flag. Rejected at parse time now, with a test over -0.05 / 0 / nan / inf.

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 quellen-und-rechte.md §5 next to the gitignore rule it qualifies. The argument, and the line it draws: the catalogue is an expectation ruler, not a holding — 46 rows for 27 glyphs, each an aperture median/min/max, a class, a state and an occurrence COUNT. It carries no geometry (no anchors, no centerlines), no per-occurrence rows and no word specimen, so no letter can be reconstructed from it and nothing can be written with it. A test keeps that true (anchors/centerline must not appear in a row), and the exception is written as covering this shape only — a catalogue with one row per occurrence would fall back under the general rule.

8 · the changelog totals contradict the recordcorrect, they were the pre-splitter-floor numbers. The fragment now names both populations rather than blending them: 24 of 27 real offen losses on the diagnosis's own path, 34 of 63 over all loops.

10 · t#1 has the larger relative loss, not e#0correct, and the entry contradicted its own table. t#1 keeps 13 % of its plate width, e#0 keeps 19 %. Reworded in both the entry and its register row to state the percentage rather than a superlative, and t#1's 13 % is named beside it.

11 · "delivered" pen wordingcorrect 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: at half width 0.0968, the PLATE pen, not the delivered nib.

9 · the Kurzglossar term countcorrect that it was stale, off by one more than reported. Counting lemma lines in the term section (^\*\*…\*\*( \*(…)\*)? — from the first entry) gives 81 with this PR's entry and 80 without, against a declared 79 — so the header had already drifted before this round. Set to 81 rather than 80, and the status date bumped with it.

Gates after the round: pytest 2501 passed, 8 skipped; ruff check and ruff format --check clean; docs_budget, docs_register and the changelog fragment gate pass, with the §14 entry re-trimmed to 4 500 against its unchanged 4 503 ceiling. The report-only proof was re-run on the new head and still holds: the authored identity run is 68 lines identical, line for line, beside the new column.

MarkusNeusinger and others added 2 commits September 7, 2026 00:27
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
@MarkusNeusinger
MarkusNeusinger merged commit 5f495d2 into main Sep 6, 2026
8 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the kringel-landmark branch September 6, 2026 22:39
MarkusNeusinger added a commit that referenced this pull request Sep 7, 2026
…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
MarkusNeusinger added a commit that referenced this pull request Sep 7, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants