HTR-Edit — a non-generative text-editing reconstruction model
Status: exploratory (validation only). Run artifacts/runs/medium_edit_tiny (M4M64a, 2026-11-10). Test-split
predictions exist (artifacts/predictions/medium_test/outputs_htr-edit-tiny.jsonl) but are not scored here; the
confirmatory scoring is run once by the coordinator.
1. Why a non-generative model
Author request (2026-11-10): a second model family built on the same principle (LLM rewrite → pre-2018 human prose) that is not generative. HTR-Edit is a LaserTagger/Felix-style tagger:
- No free generation. The output consists only of (i) source tokens that are kept, in their original order, and (ii) phrases from a closed vocabulary of 4 000 frequent inserted token sequences (1–4 SentencePiece tokens) built on the training split. It cannot write a word sequence that is neither in its input nor in that list.
- Numbers and citations cannot be invented or lost. Phrases containing a placeholder (
⟦C1⟧, …) or a digit are excluded from the vocabulary, and at inference every protected source piece is forced to KEEP, together with the pieces glued to it (see §3). Measured on validation: number recall 1.000 and placeholders exact 1.000. - Untouched spans are preserved exactly (byte-identical up to SentencePiece NFKC normalisation).
- Deterministic, one forward pass: argmax per position, no decoding loop, no beam, no sampling. 15 649 test passages are edited in 19 s on one M4 Max (MPS).
2. Edit scheme (src/htr/training/edits.py)
Source (source_text, placeholder form) and target (target_text) are tokenised with the 32 k SentencePiece model
(artifacts/tokenizers/medium_v0) and aligned with difflib.SequenceMatcher(autojunk=False) on token ids.
Labels per source of length n: tags[i] ∈ {KEEP, DELETE} (n labels) and inserts[k] = phrase id inserted before
source position k (k = 0..n, slot n = after the last token; 0 = <none>).
| opcode | labels |
|---|---|
| equal | KEEP |
| delete | DELETE |
| insert | the inserted sequence must be one in-vocabulary phrase in slot i1 |
| replace src[i1:i2] → tgt[j1:j2] | DELETE the k = i2−i1 source tokens; segment the inserted sequence (minimum pieces, DP) into ≤ k phrases placed in slots i1, i1+1, … |
Unrepresentable edits (OOV, more pieces than slots, protected token inside): a replace falls back to KEEP on its
source span (locally the identity), an insert to <none>. Realisation:
out = ins[0] (src[0] if KEEP) ins[1] … (src[n-1] if KEEP) ins[n].
Phrase vocabulary (train split only): every inserted sequence of length ≤ 4 counts once as a whole; each token of
a longer inserted sequence counts once as a unigram (so long rewrites can be segmented into frequent units). Top
4 000 admissible sequences kept (+ <none>); persisted as phrases.json with the run and every checkpoint.
222 813 candidate sequences, 214 995 admissible; the 4 000 kept carry 70.3 % of the admissible count mass. The most
frequent phrases are function words and punctuation (▁the, ▁of, ,, ▁in, ▁to, ▁is, ▁and, …).
Coverage (artifacts/runs/medium_edit_tiny/coverage.json)
| train (72 698 pairs) | val (first 3 000 pairs) | |
|---|---|---|
| exact coverage (labels reproduce the target ids) | 0.32 % | 0.27 % |
| pairs with ≥ 1 unrepresentable insertion | 99.68 % | 99.73 % |
| edit spans / unrepresentable | 1 960 672 / 645 128 (32.9 %) | 77 824 / 25 912 (33.3 %) |
| source tokens deleted by the gold labels | 19.4 % | 19.4 % |
| insertion slots used by the gold labels | 10.5 % | 10.6 % |
| pairs dropped (source > 510 tokens) | 100 | 0 |
Oracle (gold labels realised, 3 000 val pairs): chrF++ 81.57 vs 68.43 for the identity after the same tokenizer round trip. So the scheme cannot reproduce full passages (Haiku rewrites change ~⅓ of edit spans in ways the 4 000-phrase vocabulary cannot express), but a perfect tagger would still sit ~13 chrF above identity: the headroom exists, the question is whether a tagger can find it.
3. Model (src/htr/models/tagger.py)
- Input
[BOS] src [EOS]; encoder = the project's pre-LNEncoderLayerstack (RoPE self-attention, SwiGLU), tiny configuration (d 384, 6 heads, d_ff 1 536, 4 layers), parameter namesemb,enc,enc_lnidentical toHTRModel. 23.6 M parameters. - Initialised from the Medium denoiser encoder (
artifacts/runs/denoise_m1_tiny_medium_c/best): 47/47 encoder tensors loaded; new:tag_head(Linear d→2) andins_head(MLP 2d→d→4 001 onconcat(h_k, h_{k+1}), i.e. the states on both sides of slot k; slot 0 uses BOS, slot n uses the EOS state = the learned end-of-sequence slot). - Loss = CE(tag) + weighted CE(insert): weight 1 on slots with a gold insertion, 0.1 on
<none>slots (≈ 90 % of slots are<none>; without down-weighting the insertion head collapses to<none>). - Inference-time logit adjustment. With class weights w_c the weighted-CE optimum is q_c ∝ w_c p_c; the editor
adds log(w_edit / w_none) = log 10 = 2.303 to the
<none>logit before the argmax to undo the weighting (stored asnone_logit_biasinconfig.json;--none-biasoverrides it). This is a fixed correction, not tuned on validation. - Protection plan (
protection_plan): protected pieces (placeholders, any digit) are kept; a protected piece with no leading▁(second piece of a multi-piece number, number after a bare▁or() also keeps its left neighbour and blocks the insertion slot before it; a non-protected piece glued to the right of a protected one (%,.5,mm) is kept and its slot blocked. The unit test with a random model showed that keeping digit pieces alone is not enough (an insertion between▁3and7corrupts "37").
Checkpoints: model.safetensors + config.json (model_type: "htr_edit") + phrases.json + tokenizer.model +
release_meta.json. API: HTREditor.from_pretrained(dir).edit(text, metadata) / edit_many(...) returns a
ReconstructionResult (same fields as the generative pipeline; metadata_used.edits counts deletions/insertions).
4. Training (scripts/train_tagger.py)
AdamW (lr 3e-4, betas 0.9/0.98, wd 0.01), 500 warmup steps + cosine to 10 %, clip 1.0, token-budget buckets of 4 096
tokens (≤ 64 pairs), eval every 1 000 steps on 2 000 validation pairs, early stopping on validation loss (patience 4),
MPS cache release every 20 steps, PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.7 PYTORCH_MPS_LOW_WATERMARK_RATIO=0.5, seed 1.
PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.7 PYTORCH_MPS_LOW_WATERMARK_RATIO=0.5 .venv/bin/python scripts/train_tagger.py \
--pairs data/pairs/medium_v1_haiku/validated/pairs.jsonl --splits data/splits/medium_v1/splits.jsonl \
--tokenizer artifacts/tokenizers/medium_v0 --init-from artifacts/runs/denoise_m1_tiny_medium_c/best \
--run medium_edit_tiny --max-steps 15000 --eval-every 1000 --max-tokens 4096 --max-batch 64 --patience 4 --seed 1 --device mps
.venv/bin/python scripts/predict_tagger.py --pairs … --splits … --split val --limit 3000 \
--checkpoint artifacts/runs/medium_edit_tiny/best --system htr-edit-tiny --out artifacts/predictions/medium_valRun (M4M64a, Apple M4 Max, torch 2.12.1): 72 598 training pairs, budget 15 000 steps, early-stopped at 10 000 (≈ 4 epochs, 39.4 M source tokens, 1 088 s); best checkpoint at step 6 000, validation loss 3.094.
| at best step (2 000 val pairs) | value |
|---|---|
| tag accuracy (KEEP/DELETE) | 0.826 |
insertion-slot accuracy (incl. <none>) |
0.892 |
| insertion recall (exact phrase on gold insertion slots) | 0.082 |
| deletion recall / precision | 0.354 / 0.589 |
| exact match of all labels / of the realised target | 0.000 / 0.000 |
Validation loss reached its minimum at step 6 000 and rose afterwards while training loss kept falling (overfitting of the insertion head).
5. Results (validation, exploratory; 3 000 pairs, 284 document clusters, 2 000 bootstrap resamples)
From artifacts/metrics/medium_val_exploratory/report.json (report rewritten with all systems, 2026-11-10).
| system | chrF++ | edit sim | number recall | placeholders exact | W1 to originals ↓ | paired chrF vs B0 |
|---|---|---|---|---|---|---|
| B0 identity | 68.42 [67.60, 69.23] | 0.678 | 1.000 | 1.000 | 0.164 | — |
HTR-Edit tiny (htr-edit-tiny) |
66.15 [65.27, 67.03] | 0.671 [0.663, 0.679] | 1.000 | 1.000 | 0.157 | −2.27 [−2.54, −2.03], p_Holm < 0.001 |
M* generative HTR-Tiny M1 s3 (htr-medium_m1_tiny_mediumdenoiseinit_s3) |
68.84 [68.03, 69.62] | 0.679 | 0.991 | 0.995 | 0.100 | +0.42 [+0.20, +0.66], p_Holm 0.003 |
Other paired differences of HTR-Edit vs B0: edit similarity −0.0075 [−0.0096, −0.0055]; ROUGE-L +0.018 [+0.016, +0.019]; number recall and placeholders exactly 0 (identical to identity; the runner's p_Holm of 0.0 on a zero-variance difference is an artefact, not a significant effect). Stylometric probe P(original) mean 0.51 (B0 0.42, M* 0.52). Predicted edits on validation: 11.6 % of source tokens deleted, 2.24 insertions per 100 source tokens, 63/3 000 outputs unchanged.
Reading. The non-generative editor meets its design guarantees (perfect number and placeholder preservation,
determinism, speed) but is worse than identity on chrF++ (−2.3) and far from the generative M* on the
stylometric distance (W1 0.157 vs 0.100). The cause is visible in the training metrics and outputs: the tag head
learns where the rewrite departs from the original (deletion precision 0.59) but the insertion head recovers the
replacement phrase only 8 % of the time, so most replacements degenerate into bare deletions ("Sharpe (1994) ⟦C1⟧
applies the Treynor …", "Using the same methodology"); outputs are 11 % shorter than the source in words while
originals are 4 % longer. ROUGE-L rises (longest-common-subsequence rewards deleting rewrite-only words) while chrF++
and edit similarity fall. The oracle (81.6 chrF) shows the limitation is the tagger's prediction, not only the scheme.
6. Limitations and next steps
- Coverage is ~0.3 % exact: the Haiku rewrites are lexical paraphrases, not local edits; a 4 000-phrase vocabulary of ≤ 4 tokens cannot express a third of the edit spans (they fall back to KEEP in training).
- Independent per-position argmax: deletions and their replacement insertions are not coupled. Candidate fixes, all untested here: decode a replace only when its insertion is predicted (joint DELETE+INSERT constraint), a Felix-style pointer/reordering + masked insertion model, a larger phrase vocabulary, or a tag/insert confidence threshold chosen on a separate tuning slice (not on the validation pairs used for model selection).
- Model selection on validation loss; the same validation split is used for the exploratory scoring (as for the generative models).
- The node mirror
~/wpai0002is not a git checkout: the manifest'sgit.commitisnull; the code that produced the run is the files listed in §2–4 (uncommitted at run time). - Test predictions (15 649 pairs) are written but deliberately unscored.