Skip to content

Follow the ink around a fused loop, and the counter stays open - #562

Merged
MarkusNeusinger merged 3 commits into
mainfrom
zwei-zuege-modell
Sep 7, 2026
Merged

Follow the ink around a fused loop, and the counter stays open#562
MarkusNeusinger merged 3 commits into
mainfrom
zwei-zuege-modell

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

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 Measure where the counters get lost, from plate ink to stored row #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.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

R3 of the counter diagnosis, armed with the per-loop targets #556 froze.
Where two pen strokes fuse around a small counter the ink is the union of
two capsules of the KNOWN pen half width, and the medial axis of that
fused lump is not the pen path — so the new tools/pairlab/zweizuege.py
deconvolves the pen instead of thinning the blob: a point of the plate's
counter is inked exactly when some pen sample lies within w_pen of it,
so no pen sample may sit closer than w_pen to a counter the plate holds
open, and the correction pushes the samples that do just far enough out
along the counter's own distance field.

Measured on the frozen sep05 roots with BLAS pinned, one knob against an
otherwise identical base. The mechanism reaches its geometric target
completely — 106 of 106 corrected loops open at the plate pen, 85 of them
within the pre-registered 0.02 xh of the plate's expectation — and every
distance reading improves (dev-19 dtw median -0.000701 at 14:3, p=0.0127;
aiou 0.7583 -> 0.7601; the 63-word Soll distance 80 -> 78). It fails the
structure gate on two words and the continuity gate on 1588 new kink
events, and the cause is measured rather than guessed: the blend lengths
are stated in x-heights while the trace is sampled in points, so at
0.0265 xh per sample they span 2.7 and 1.4 samples and the fade fades
over three points. The pre-registered repair R3b (blend one rung up the
sensor's own ladder) confirms the prediction in both directions and is
also not adopted, which measures the formulation out: precision and
smoothness hang on the same length here.

The switch is off by default, no chain solve changes, no core/ byte
moves, and no Laufform row is read or written.

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 7, 2026 00:18

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

Counter matching and closed-loop reporting are incorrect, and several recorded gate verdicts contradict the pre-registration.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a default-off two-stroke correction experiment for fused-loop counters and records the rejected R3/R3b measurements.

Changes:

  • Loads frozen ink masks and applies loop-local path correction.
  • Adds CLI controls and focused tests.
  • Documents measurements, verdicts, glossary terms, and rescue paths.
File summaries
File Description
tools/wordlab/cases.py Loads frozen reference masks.
tools/pairlab/zweizuege.py Implements the correction and reporting.
tools/pairlab/follow.py Integrates the optional correction.
tools/docs_budget/__init__.py Raises the route reading budget.
tests/test_zweizuege.py Tests geometry and refusals.
docs/reference/werkzeuge.md Documents the tool.
docs/reference/verfahren-kette.md Adds the route ledger entry.
docs/reference/messjournal.md Records R3 and R3b results.
docs/reference/kurzglossar.md Adds the short definition.
docs/reference/glossar.md Adds detailed terminology.
docs/proposals/tintenfolger.md Updates rescue paths and open arms.
changelog.d/zwei-zuege-modell.md Adds the changelog fragment.
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 8
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tools/pairlab/zweizuege.py Outdated
Comment thread tools/pairlab/follow.py
Comment thread tools/pairlab/zweizuege.py Outdated
Comment thread changelog.d/zwei-zuege-modell.md Outdated
Comment thread docs/proposals/tintenfolger.md Outdated
Comment thread docs/reference/messjournal.md Outdated
Comment thread docs/reference/messjournal.md Outdated
Comment thread tools/pairlab/zweizuege.py
Four rounds landed on the same registers while this one was measuring, so
the conflicts are all "both sides appended": the §14 register and its
entries (LF15, J4 round 5 + adoption, J6), the Kette ledger (LF15's
chart-seed row), the §7.9 rescue-path table, and the reading budgets.
Every row of both sides is kept.

The budgets are re-measured AFTER the merge rather than stacked, which is
the precedent main itself set in the same file:

- `mess-runde-route` needed 6947 on this branch against the pre-merge
  number; LF15's raise to 6890 already covers the merged path at 6636, so
  this branch's raise is dropped and the reasoning kept as a comment.
- `mess-runde` came out 201 over after the merge and is brought back under
  by condensing this round's own rows — the two register rows and the two
  §7.11 rows — rather than by a raise.
- `werkzeug-abschnitt` is raised once: the bench/lab inventory is where a
  new tool has to be named, and it stood at 4066 of 4073 on main.

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

Thanks — seven of the eight are fixed, and one I'm answering rather than changing.

tools/pairlab/zweizuege.py — the counter assignment. Correct, and it is the load-bearing one: a flat 0.45 xh proximity search over every counter of the word is exactly what #553 measured going wrong (it hands a loop the neighbour's hole), and my own module docstring cited the catalogue's rule without using it. catalogue_targets now runs the Slot-Lineal verbatim — a counter is owned by the slot whose BODY strokes span its x (deferred marks excluded), and loops and counters are paired one-to-one inside that letter — so a loop can no longer take a counter its letter does not own, and the word-global claim set is gone. Because that changes which loops are corrected, the whole round is being re-measured on the fixed rule; the §14 entry, the register rows and the ledger line will carry the new numbers, not the old ones.

zweizuege.py — the word-level early return. Right, and it defeated the module's own promise: catalogue_targets already measures each in-scope lump and records REFUSAL_NO_COUNTER/REFUSAL_NO_SEPARATION, so the shortcut silenced exactly the occurrences the arm cannot help. Removed; the two lump tests no longer need a decoy counter, and there is a new test for the neighbour case above.

follow.py — the fade length in the help text. Fixed: the field docstring said "one nib" where the constant is half a nib, and it now says so and names what the round measured about it.

changelog.d — the fade as a property. Fixed; it now reads as an attempt the round measured as too short, which is what happened.

tintenfolger.md — the two open arms missing from the Stand block. Fixed, both added to "Offen (§7.11)" with their trigger.

messjournal.md — gate (a) and gate (c). Both correct and both serious, because they are the failure the pre-registration exists to prevent. Gate (a) is stated per corrected loop, so 85 of 106 inside the tolerance is RED, not "the one gate the mechanism fully satisfies"; gate (c) is stated as median AND p90, so a p90 of +0.000699 is RED and a kill criterion. The entry, the register rows and the ledger line are being rewritten to say four gates red — with the readings that improved still reported as readings, not as gates.

ZweiZuegeOptions — the German identifier. Keeping it, and here is the precedent rather than an exception: the repo's settled convention is that a project Fachbegriff stays its own identifier — core/laufform.py, LAUFFORM_VARIANT, tools/laufform/, tools/eigenhand/, core/eigenhand/, tools/tracebench/kringel.py and kringel_catalogue.json. CLAUDE.md's rule is that a German term without an established English translation gets an identifier plus one explanatory comment, which is what the module docstring is. "Zwei-Züge-Modell" is a coined term with a glossary entry, a §14 entry and a ledger row under that name; renaming the code to TwoStrokeOptions would cut the one thread a reader follows from a measurement back to the code.

…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
MarkusNeusinger merged commit 6a92712 into main Sep 7, 2026
8 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the zwei-zuege-modell branch September 7, 2026 01:07
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