Print the Bogen fainter, and let one word jump a frozen plan - #555
Merged
Conversation
Two owner wishes for the own-hand capture sheets, both about what the first printed Bogen has to be. The lineature drops one step per role: the baseline takes the width the auxiliary lines had (0.22 -> 0.12 mm), waist 0.15 -> 0.10, ascender/descender 0.12 -> 0.08, the slant grid 0.10 -> 0.06. It only has to guide the hand for the seconds a row takes, and everything it does after that is damage — so thinner is the safer direction: faint cyan still prints (0.06 mm is 1.4 dots at 600 dpi), while every millimetre of it that is NOT on the paper is one the ingest cannot mistake for ink or smear into a measured stroke. The `box` role keeps 0.12 because the same role draws the verdict box, which the writer must find and tick. Positions, Passmarken and the printed ruler check are untouched; the golden PDF hash is re-pinned as a declared re-baseline. "Kurrentschrift" was not in the Wortvorrat and the plan was frozen at 180 strips, so the word could not be pushed into a strip. `pool pin` appends one strip of its own for every word in `corpus.PINNED_FIRST` and registers it in the plan's new `pins` block; `plan.ordered_strips` puts that block at the head of plan order, so the print queue, the coverage progression and the Bestand all lead with it. Append-never holds — the guard runs on this path too, and the pin leaves the front the ordinary way, by being written. S0181 carries "Kurrentschrift" (shaped `Kurrentſchrift`: the default rules give the long s at the start of the second morpheme, so no fugen form and a plain label), and it is row 1 of the next Bogen. 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 repeatable pin path can combine multiple pinned words into one strip, violating the documented one-strip-per-pin contract.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Refines Eigenhand capture sheets and adds pinned words to the frozen strip plan.
Changes:
- Makes capture rulings fainter while preserving layout geometry.
- Adds append-only pinned strips, led by “Kurrentschrift”.
- Updates tests, documentation, plan data, and changelog.
File summaries
| File | Description |
|---|---|
tools/eigenhand/pool.py |
Adds pinned-strip generation and CLI support. |
tools/eigenhand/corpus.py |
Curates the first pinned word. |
core/eigenhand/plan.py |
Places pinned strips first. |
core/eigenhand/geometry.py |
Reduces capture ruling widths. |
core/eigenhand/streifen.json |
Appends pinned strip S0181. |
tests/test_eigenhand_stack.py |
Tests print-queue pin behavior. |
tests/test_eigenhand_pdf.py |
Re-baselines PDF output. |
tests/test_eigenhand_geometry.py |
Tests capture ruling properties. |
tests/test_eigenhand_coverage.py |
Tests pin creation and immutability. |
docs/proposals/eigenhand-erfassung.md |
Documents both design changes. |
docs/reference/werkzeuge.md |
Documents the pin command. |
docs/reference/glossar.md |
Defines “Anheftung”. |
data/samples/own-hand/README.md |
Updates printing instructions. |
changelog.d/eigenhand-feine-lineatur-und-anheftung.md |
Records user-visible changes. |
Review details
Suppressed comments (1)
tests/test_eigenhand_geometry.py:77
- The numeric explanation is reversed: one 600 dpi dot is about 0.042 mm, so 0.05 mm is slightly more than one dot, not under one. Correcting this keeps the test's physical threshold understandable.
# A line the printer cannot resolve is guidance that never reaches the
# paper: 0.05 mm is under one dot at 600 dpi (0.042 mm).
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Both from the Copilot review, both real. `pin_words` ran the fresh words through the wave packer, so two short pins would have shared a row — while the docstring, the proposal, the glossary and the README all promise each pinned word a strip of its own and the full row width. Pins are few and deliberate, so the promise is the better half: one strip per fresh word, with the reason next to it, and a test with two short pins that the packer would have merged. The hierarchy test compared against a reverse-sorted copy, which accepts two roles at the same width — that is exactly the state the assertion exists to forbid. It now compares adjacent widths with `>`. The printable floor's comment had its arithmetic backwards (0.05 mm is about one dot at 600 dpi, not under one); it says what the floor means instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Owner
Author
|
Both review findings are fixed in 743f475.
|
MarkusNeusinger
added a commit
that referenced
this pull request
Sep 6, 2026
…556) 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` one** — `Sprü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](#556 (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.com/claude-code) https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two owner wishes for the own-hand capture sheets (2026-09-06), both about what the FIRST printed Bogen has to be. No sheet has been printed yet, so neither change costs paper.
1 · A fainter lineature
Owner: "as long as it is faintly recognisable in the print, it is strong enough." Every ruling of the capture theme (
core/eigenhand/geometry.py::CAPTURE_STYLES) moves down one step — the baseline takes the width the auxiliary lines had, and the auxiliary lines go thinner again:Why thinner is the safe direction: the lineature only has to guide the hand for the seconds a row takes, and everything it does afterwards is damage. Faint cyan still prints — 0.06 mm is 1.4 dots at 600 dpi, so the device draws its thinnest line rather than nothing — while every millimetre of cyan that is NOT on the paper is one the ingest cannot mistake for ink, cannot leave in the blue channel and cannot smear into a stroke the fit measures. The
boxrole keeps 0.12 on purpose: the same role draws the verdict box in the right margin, which the writer has to find and tick (a forgotten tick discards the row).The order baseline > waist > ascender/descender > slant survives, so the row still reads at a glance. Positions, Passmarken and the printed ruler check are byte-identical — only stroke widths moved, which is why every layout test and every
cfgstamp stayed put and only the golden PDF hash is re-pinned (declared re-baseline, with the reason next to it).The owner's print task text stays valid as written: colour printer, A4, 100 %, ruler on the Passmarken at 190.0 / 277.0 mm.
data/samples/own-hand/README.mdgains one rule saying the lineature is deliberately this faint — a print in which it is barely visible is the right one, not a printer fault.2 · „Kurrentschrift" on the first sheet — the Anheftung
The word is the site's hero and the author wants a written specimen early. It was not in the Wortvorrat, and the strip plan is frozen at 180 strips across three waves, so it could not be put INTO a strip: append-never (Prüfstein 4) forbids exactly that.
The mechanism is the smallest one that fits the doctrine instead of bending it:
tools/eigenhand/corpus.pygains apincuration layer withPINNED_FIRST— words that earn their place by what they are, not by their coverage. So the word is a normal pool word: it enters the Soll universe and is counted in Bestand and coverage like any other.python -m tools.eigenhand.pool pinappends ONE strip of its own per pinned word and registers it in the plan's newpinsblock.verify_immutableruns on this path too; the diff tostreifen.jsonis purely additive.core/eigenhand/plan.py::ordered_stripsputspinsat the head of plan order. The pin is expressed in plan order rather than in the queue so that every reader agrees on what comes first — print queue, coverage progression and Bestand walk the one order. A pin leaves the front the ordinary way: once the strip isbelegtit is out of the queue.Result:
S0181= „Kurrentschrift", row 1 of the next Bogen (B0001, seven rows, then S0001 … S0006 as before). Spelling: the plain word, whichcore/shaping.pyalready shapes asKurrentſchrift— the default rules give the long ſ at the start of the second morpheme-schrift, so no fugen form is needed and the printed label carries no hint marks.pinsis additive, so the plan format stays 2; a secondpool pinrun is a no-op ("already planned, not pinned again").Three decisions taken here and written down in the proposal: the plan stays hand-agnostic (one plan serves all three scripts — a hand-scoped pin would need hand-scoped plans), a word already planned is never pinned again, and a pinned strip is not filled up with quota words (that would need the local-only Übergangsraum weights; the box generator hands the free row width to the one word, so „Kurrentschrift" gets the full 180 mm).
Verification
/verify-core: full suite green (2474 passed, 18 skipped),ruff check+ruff format --checkclean.tools.docs_budget check,tools.docs_register check,tools.changelog checkgreen.core/change touches page geometry and plan order, not extraction, composition or glyph rendering.New tests: the capture lineature stays strictly fainter than the app's
drucktheme, keeps its hierarchy and stays printable; a pin appends and leads without touching a frozen strip; an already-planned word is not pinned again; an uncurated word is refused; the committed plan pins what the curation pins; the pinned strip is row 1 of the first sheet, and a written pin leaves the front of the queue.New term Anheftung is in
docs/reference/glossar.md(§5 + Schnellindex). It is not inkurzglossar.md: that file's own Nachzieh rule (docs/dokument-status.md) is the two-of-three threshold, and the term reaches one source so far.🤖 Generated with Claude Code
https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3