Adopt the exit trim, and make the dissection replay it - #559
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Pairlab replay does not reevaluate trim eligibility after shifts, and span measurement accepts contradictory options.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adopts exit trimming as the production composition default and updates measurement tooling, tests, metrics, and documentation accordingly.
Changes:
- Enables
exit_trimby default with explicit opt-out tooling. - Adds Pairlab trim replay and parity coverage.
- Re-baselines documented measurements and behavior.
File summaries
| File | Summary and findings |
|---|---|
tools/wordbench/run.py |
Reverses the benchmark flag. Nit (2 votes): The README still documents the removed opt-in flag and old default. |
tools/pairlab/spanmeas.py |
Aligns span measurements with production. Moderate (2 votes): Contradictory trim options must be mutually exclusive. |
tools/pairlab/prodconn.py |
Adds trim replay. Moderate (1 vote): Replay preserves the original trim decision instead of reevaluating shifted geometry. Nit (1 vote): Its zero-shift replay contract is obsolete. |
tools/humanbench/wordarm.py |
Resolves trim behavior from composer defaults. Nits (1 vote each): Help text and module overview still imply trimming is opt-in. |
tools/docs_budget/__init__.py |
Raises the measurement-documentation budget. No findings. |
tests/test_pairlab_connector_parity.py |
Covers trimmed connector replay parity. No findings. |
tests/test_humanbench_wordarm.py |
Covers default and explicit trim settings. No findings. |
tests/test_compose_joins.py |
Verifies trimming is enabled by default. No findings. |
docs/reference/write-api.md |
Documents changed word-rendering behavior. No findings. |
docs/reference/werkzeuge.md |
Updates measurement commands. Nit (1 vote): Base and arm commands are reversed, inverting reported deltas. |
docs/reference/qualitaetsmetrik.md |
Records updated benchmark metrics. No findings. |
docs/reference/messjournal.md |
Records adoption and measurements. Nit (1 vote): It incorrectly claims every public word response changes. |
docs/reference/glossar.md |
Marks Austritts-Trim as adopted. No findings. |
docs/proposals/tintenfolger.md |
Updates completed and follow-up measurement work. No findings. |
core/compose.py |
Enables exit trimming by default. Nits (1 vote each): It overstates affected responses and retains a stale “opt-in” comment. |
changelog.d/j4-exit-trim-adoption.md |
Records the production change. Nit (1 vote): It overstates the API impact by claiming every response changes. |
Review details
Suppressed comments (8)
changelog.d/j4-exit-trim-adoption.md:7
- The claim that every
/write/wordanswer moves contradicts this fragment's own documented unchanged cases:wovonand solitary glyphs do not trigger the trim. Describe this as affecting answers with eligible joins so the released changelog does not overstate the API change.
after the blind word round went 34 : 2 for it; `compose_word` now applies it
unless a caller says otherwise, so every `/write/word` answer moves (the edge
cache holds the old one for up to 24 h, as after the LF11 write). Measured on
core/compose.py:1969
- This says every public word response moves, but the same change documents that
wovonand solitary glyphs remain byte-identical because the rule does not fire. Limit the claim to responses containing an eligible join.
round of `sep06` decided it; the flip is a DECLARED re-baseline — the
golden fixture was re-baked in the same PR, and every public
``/write/word`` answer moves with it. Passing ``False`` reproduces the
pre-adoption base, which is what a bench arm does.
core/compose.py:1918
- The implementation comment at
core/compose.py:2805still labels exit-side collinearity “opt-in,” contradicting this newTruedefault. Update that nearby maintenance comment to say it is enabled by default; otherwise future edits to the join block are guided by stale behavior.
exit_trim: bool = True,
docs/reference/messjournal.md:11453
- This repeats the inaccurate claim that every public word response changes, although the entry above records unchanged words and solitary glyphs. Narrow it to responses whose joins satisfy the trim guards.
**Was der Autor davon merkt.** Jede öffentliche `/write/word`-Antwort ändert
sich; der Edge-Cache hält sie bis zu 24 h, der Wechsel wird dort also
verzögert sichtbar (dieselbe Lage wie beim LF11-Write, wo kein Purge
docs/reference/werkzeuge.md:205
- These commands now reverse the intended base/arm roles. The first command saves the adopted trim as
base.json, then the second treats--no-exit-trimas the arm, so every reported delta and fall share has the opposite sign. Save the pre-adoption run as the base, then compare the default production run against it.
uv run python -m tools.pairlab.spanmeas --set words --expect-root <digest> --json temp/base.json
uv run python -m tools.pairlab.spanmeas --set words --expect-root <digest> --no-exit-trim --base temp/base.json
tools/humanbench/wordarm.py:312
- This help text places “the composer's default” after the pre-adoption
--no-exit-trimarm, which incorrectly reads as thoughFalsewere still the default. Omitting the option is what delegates to the composer's now-true default; state that explicitly to avoid building the wrong humanbench arm.
help="the exit-collinearity rule (core.compose EXIT_TRIM_WINDOW, shipped since the A37 "
"adoption) — pass --no-exit-trim for the pre-adoption base arm [the composer's default]",
tools/humanbench/wordarm.py:7
- The module overview still groups
exit_trimwith the composer's opt-in switches at lines 18–20, which contradicts this new default-oriented example. Update that overview so future arm authors do not treat the production rule as opt-in.
uv run python -m tools.humanbench.wordarm --arm "ohne J4" --no-exit-trim --out temp/base.json
tools/pairlab/prodconn.py:256
- The earlier contract in this same docstring still says a zero-shift replay equals the recorded generator centerline point for point. That is now false whenever
exit_stubis present, because replay returns the trimmed straight instead. Update the opening paragraph so callers do not rely on the obsolete return contract.
**The post-processing this function had to grow.** Not every decision about
a join happens inside ``_connector_centerline``. The exit trim (``exit_trim``,
arm J4) REPLACES the returned centerline afterwards, in ``compose_word``'s
own block — and since the A37 adoption of 2026-09-06 it does so on every
- Files reviewed: 16/17 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Review answered in f0d9f8c — all three findings were right, and the first one was a real bug.
The golden set carries five eligible-but-uncut joins (30 eligible, 25 cut), and moving B by 0.2 xh gives one of them a cut; One limit stays and is now written down instead of left implicit: the ELIGIBILITY guards (base class, tangent band, exit height, no cap retrace, no stem ride) are production's, evaluated where production placed the letters. They are recorded rather than re-derived for the same reason
Bench unchanged after the fixes: words 0.109026, pairs 0.148236, |
|
Rebased onto today's The budget raise is dropped. Main's S2 agrees with the eye, and it knew nothing about this arm. The continuity sensor was built, frozen and accepted the same day against the UNTRIMMED composition, with no constant taken from round 5. Measured on the same root against the same base:
65 discontinuity events fewer, the survivors flatter, less wobble, and the sagitta measured AROUND THE GENERATED JOINS — the column that looks at exactly this seam — more than halved. It is a report column and no gate, so it decided nothing here and the adoption rests where it rested; it is simply the place where two instruments that know nothing of each other say the same thing, the eye at 34 : 2 and the sensor at −65 kinks. The corollary is booked with it: the S2 entry's own numbers now describe the composition BEFORE the trim, which is where they are correctly filed, and they are not touched. Headline unchanged across the merge: words 0.109026 (0.108444 with |
|
Rebased again onto
Suite 2534 passed / 8 skipped on the merged tree; bench unchanged (words 0.109026, pairs 0.148236). |
Author decision A37 of 2026-09-06 — "1 ja" — turns `exit_trim` into the
production default after the blind word round of `sep06` went 34 : 2 for it
among the decided screens. The switch stays, because the pre-adoption base
is a bench arm like any other; what moves is which side of it production
writes on.
The re-baseline is measured on the UNCHANGED frozen root
(eaa195aa7c84 / 0fbde2d72b64, `--expect-root`, BLAS pinned), so the two
numbers are paired rather than merely consecutive:
words 0.108444 -> 0.109026 (+0.000581)
pairs 0.148236 -> 0.148236 (byte-identical)
seam_dep_median +7.59 -> -0.70 (absolute median 12.67 -> 2.30)
gleichzug_doublings 14 = 14
worst_word han 0.232609 -> regieren 0.233052
The word ruler rises knowingly. The round showed the whole of that cost
sits in `naht-stark`, the class where the eye votes 26 : 2 FOR the trim, and
that of the 30 words the ruler punishes 18 go to the candidate and none to
the base. `EXIT_TRIM_MIN_KINK_DEG` stays 0.0: the same round measured the
narrowing, and while it does enrich the strong-seam class from 10 degrees on
it never separates the two — the only rung that buys ruler back gives the
seam repair up to do it.
The golden fixture is re-baked as a declared re-baseline. Measured before
the regen: 10 of its 11 words move, `wovon` does not, no word gains or
loses a draw item, and a SOLITARY glyph with `pen=None` stays byte-identical
across all 23 payload glyphs — the rule cannot fire without a connector.
The continuity sensor agrees, and it knew nothing about this arm. S2 was
built, frozen and accepted the same day against the UNTRIMMED composition,
with no constant from this round: cont_kink_total 402 -> 337, the survivors
flatter (19.75 -> 18.52 degrees), less wobble (2.707 -> 2.482), the sagitta
around the generated joins more than halved (0.0091 -> 0.0042). A report
column and no gate, so it decided nothing; it is the place where two
instruments that know nothing of each other say the same thing. Its own
entry's numbers now describe the composition before the trim, where they are
correctly filed and stay untouched.
`tools/pairlab/prodconn` had named this case itself: the trim replaces the
generator's return value inside `compose_word`, "harmless while the switch
is off — should it ever become the default, this function has to grow the
same post-processing or the dissection will quietly measure the wrong
curve". It now does, and it RE-DECIDES rather than repeats: `_exit_trim_index`
is recorded for every join the rule is eligible on, cut or no cut, because a
join production did not trim can become trimmable once pairlab moves the
letters; `_cut_exit_stub` stays recorded for the one thing the index cannot
say, whether the min-kink narrowing declined a cut that was found. The golden
set carries five eligible-but-uncut joins and moving B by 0.2 xh gives one of
them a cut — a test pins exactly that.
The chain is exempt, and that is measured rather than assumed: its init
default is the frozen mirror, and its composition soll does not move — all
126 `ductus_soll` rows are identical, crossings 292, zones 177, strokes 111,
touches 89, overlaps 11 on both sides. The pilot's map IS the composed path,
so its dev-19 numbers are due a re-measure; that is its own round under
`/verify-trace` and is filed as an open arm in tintenfolger.md §7.11.
The tools now follow the shipped default instead of overriding it:
`--exit-trim` becomes `--no-exit-trim` in wordbench, wordarm and spanmeas,
wordarm reads the default off the composer's signature, and spanmeas gained
wordbench's guard against combining the narrowing with the base arm.
`mess-runde` is raised to 26 484 (measured 24 077 after the merge, plus the
documented 10 %) for ONE row: the second of the standard pair, the adoption,
whose first row main's own raise already absorbed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
8e8dd2f to
1e95e29
Compare
…from (#563) R3c, the conversion R3's own §14 entry named and R3b turned from a guess into evidence: **precision and smoothness hang on the same blend length, so the condition belongs inside the solve rather than behind it.** ## The mechanism, in three sentences 1. The statement about the ink is unchanged from R3 — a Gleichzug pen paints the Minkowski sum of its path with a disc of `w_pen`, so **no pen sample may sit closer than `w_pen` = 0.0968 xh to a counter the plate holds open.** 2. What moves is where it is stated: a quadratic hinge on the **signed** distance field of the plate's `offen` counters, which the follower sees in **every round**, normalised exactly like `e_geo` and folded back through `sampling_op` onto the far sparser anchors. 3. So the solver — not a fade whose length has to be guessed — trades the aperture against the ink, the Tikhonov pull and the connector smoothness, and the anchors are the smoothing. It enters the follower's ROUNDS only: `chain.fit_word_chain` is untouched, so every other consumer of the chain, the harvest included, is byte-identical by construction. It reads the plate, the frozen Kringel catalogue and one frozen pen constant — never a Laufform row. ## What the measurement says Frozen root `eaa195aa7c84…`, BLAS pinned, `--jobs 4` for the base and every rung, one knob. The null test on real data comes first: with the field in the code and the switch off, `das` is stroke-identical to the pre-change base, largest coordinate difference **0.000e+00 xh**. **Verdict: not adopted — gate (a) is red on every rung.** And the round is the exact counterpart of R3: | Gate | Reading (best rung, weight 64, against the base) | Verdict | |---|---|---| | (a) aperture | best rung **64 of 156** within ±0.02 xh, required 149; ladder {1,4,16,64,256} → 22 · 38 · 52 · **64** · 51 | **red** | | (b) structure | `cross_missing` 11 → 11, **`cross_spurious` 9 → 10** | **red** | | (c) distance | dtw median Δ **−0.000365**, p90 Δ **+0.001618**, sign test 7 : 10 (p = 0.63) | **red at p90** | | (d) continuity | kink events **2296 → 2258**, median 8.08° → 7.53°, wobble median better | **green** | | (e) reference-free | 63-word Soll **85 → 81** (5 better / 57 same / 1 worse) | reported | | (f) word bench | 0.109026 · 0.148236, unchanged | green | | (g) golden | `core/` untouched, 2581 tests pass | green | | (h) determinism | two runs, **63/63 stroke-identical, 0.000e+00** | green | **Gate (d) is the result.** R3's post-hoc push added 1626 kink events and R3b still 462; the same condition as a term adds none — it lowers the count. That is what the conversion predicted, and it costs nothing, because there is no blend length to choose. **Gate (a)'s failure decomposes, and the decomposition is the yield.** Sorted by what the base already draws at that counter: | class of the base loop | n | hits ±0.02 | median movement | |---|---|---|---| | collapsed (`D0` < 0.05) | 13 | 1 | **+0.0000** | | drawn but shut at 0.097 | 18 | 2 | **+0.0000** | | already open at 0.097 | 124 | 61 | **+0.0227** | The condition moves the loops that were already open and the 31 shut ones **not at all** — it does not help where the defect is. Per occurrence: `sz`#0 in `muß-2` goes 0.2300 → **0.3366** (expectation 0.3226) and `r`#0 in `Zorn` 0.2354 → **0.3216** (0.3269), while `o`#0 in `Galoppieren` stays at 0.1668 → 0.1687. A fused lump does not move: the ink term itself sits on the lump's axis there, and a gradient term cannot break a symmetric configuration that a point-wise separation can. Together the two rounds bracket the problem and point at R3's conversion (4): correct the **evidence**, not the output and not the objective. ## Two corrections before the first arm number, both in the entry - **The scope went back to R3's `klein`/`mittel`.** The first draft took every size class on the reasoning that a satisfied constraint is inert — true, but the Kette's large loops are not satisfied either, so a `gross` counter binds hardest and buys nothing (three quarters of a large hole survives any pen). A one-word calibration probe showed it and, just as important, a conversion arm that changes the scope as well as the placement is no longer a single-factor step. `--counter-size-classes` keeps the wider scope reachable as its own arm. - **`kringel_lost` is not a metric a follower arm can move.** `kringel_by_word` reads `derive_word(case).composed["items"]` — the composition, not the candidate. R3's pre-registration named it as its Kennzahl; it cannot be one. Gate (a) is the trace-side number and this round says so. ## Disclosure about the base: it is not R3's The A37 adoption (`exit_trim` becomes the default, #559) landed between the two rounds. It moves the composition every chain fit initialises on, so the Kette base moved with it: dtw median 0.045384 → **0.045881**, aiou 0.7583 → **0.7660**, `cross_missing`/`cross_spurious` 12/7 → **11/9**. Every gate here stands on the fresh base. That also corrects a standing §7.11 row — the A37 entry notes "the Kette is exempt by measurement (composition Soll 0 of 126)", which holds for the **Soll** and not for the **fit**; this round is that re-measurement. ## Not in this PR, on purpose **No Laufform card and no re-derived row.** The harvest has no follower path by the standing Route-A guard rail, and LF14's fixed-point finding blocks the write until the fixed-point arm has run. ## Paperwork The §14 pair (pre-registration + result) with both register rows, the `verfahren-kette.md` ledger line, the §7.9 rescue-path row with four named conversions, three §7.11 rows (two new arms this round's numbers are the evidence for, plus the corrected J4 row), glossary + Kurzglossar entries for **Binnenflächen-Bedingung**, `werkzeuge.md`, a changelog fragment, and 15 tests including the synthetic fused blob whose target is arithmetic rather than a measurement. `mess-runde` and `mess-runde-route` were both over budget after the round's rows; raised deliberately with the accounting in `tools/docs_budget/__init__.py`, each row condensed once first. 🤖 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>
The owner approved this on 2026-09-06 ("1 ja") — booked as author decision A37. Stacked on #554, which files the round this decision rests on; merge #554 first, this PR's base is its branch.
Turns
exit_triminto the production default. The switch stays — the pre-adoption base is a bench arm like any other — but production now writes on the other side of it, and every/write/wordanswer moves with it (the edge cache holds the old one for up to 24 h, as after the LF11 write). No DB row is touched: this decision flips a rule, it does not write geometry.The re-baseline, on an UNCHANGED root
--expect-root eaa195aa7c84,0fbde2d72b64,OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1. The root does not move, so these two lines are paired, not merely consecutive:seam_dep_medianseam_arr_mediangleichzug_doublings--no-exit-trim)63/63 words and 33/33 pair drills scored, none skipped, none failed.
worst_wordmoveshan0.232609 →regieren0.233052;worst_pairstaysIn0.283819. Components:comp_transition0.089804 → 0.091028,comp_coverage0.101580 → 0.101667,comp_width0.162397 unchanged — the trim touches the seam, not the width. The doublings at the delivered nib stay at 14, which was not a given for a rule that takes ink away.The word ruler rises knowingly. Round 5 showed the whole of that cost sits in
naht-stark, the class where the eye votes 26 : 2 for the trim; of the 30 words the ruler punishes, 18 go to the candidate at the eye and none to the base.EXIT_TRIM_MIN_KINK_DEGstays 0.0 — the same round measured the narrowing and it separates neither class at any rung.The golden fixture: a declared re-baseline
Re-baked with
REGEN_GOLDEN=1. Measured before the regen:wovondoes not (its exits are backward, bow and arm exits, which the class excludes by construction).pen=Nonestays byte-identical — checked across all 23 glyphs in the golden payloads, 0 move, because the rule cannot fire without a following connector. The CLAUDE.md invariant holds as written.Two measurement layers had to follow, and one is a repair
pairlab.prodconnhad named this exact case itself. Its docstring said the trim replaces the generator's return value insidecompose_word, "harmless while the switch is off — should it ever become the default, this function has to grow the same post-processing or the dissection will quietly measure the wrong curve." It now does. The recorder additionally captures_cut_exit_stub, which runs if and only if the trim was really applied (guards, collinear cut and the min-kink narrowing all sit in front of it), so what is recorded is the DECISION rather than a re-derivation of it;replayshifts that stub with its letter and calls_exit_trim_index/_straight_toagain. The rule is never restated intools/. The parity tests now say it in two halves: an untrimmed join reproduces the recorded call point for point, a trimmed one reproduces the rule's own two invariants — the coupling point does not move, and the join is a straight line.The chain is exempt, and that is measured rather than assumed. Its init default is the frozen mirror (the production init was measured as K-F on
sep04and rejected), and its guard reads the composition soll.ductus_sollover all 63 word samples returns 126 rows of which 0 move: crossings 292, zones 177, strokes 111, touches 89, overlaps 11 on both sides. The trim cuts a stub and straightens a connector; it does not touch the composed word's topology, sok0eval, the structure guard and every chain number stand on unchanged ground.What is left due, named rather than done here: the pilot's map IS the composed path, so its
sep05dev-19 numbers now stand on a composition that no longer exists. That is a route measurement with its own protocol (/verify-trace), not something an adoption PR should fold in — it is filed as an open arm intintenfolger.md§7.11 and is due before the next pilot statement.Docs and gates
messjournal.mdgains the dated adoption entry plus its register row and a headline-ledger row;qualitaetsmetrik.md's status block carries the new headline (and lost two lines to stay under its own 40-line cap); the glossary entry for Austritts-Trim flips from "opt-in, not adopted" to adopted with both instruments' numbers;werkzeuge.mdandwrite-api.mdfollow.mess-rundeis raised to 23 567 — measured 21 425 plus the documented 10 %. The reason is the one the budget's own comment already licenses: the register books the standard PAIR of rows for a round and the adoption it triggered. Both rows were condensed to roughly 300 tokens each (they stood near 500, three times the register's ~163-token average) and the §7.11 J4 row was rewritten SHORTER than it was, so the residual prose growth is one new open-arm row and 11 tokens in a Stand block.Gates green locally:
pytest2478 passed / 8 skipped,pre-commit run --all-files,tools.changelog check,tools.docs_register check(2 §14 entries, register agrees),tools.docs_budget check.🤖 Generated with Claude Code
https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3