Skip to content

feat!: replace punkt-segmenter with a swappable segmenter seam - #11

Merged
Halvanhelv merged 6 commits into
mainfrom
feat/own-segmenter
Sep 8, 2026
Merged

Halvanhelv merged 6 commits into
mainfrom
feat/own-segmenter

Conversation

@Halvanhelv

Copy link
Copy Markdown
Owner

Replaces the abandoned punkt-segmenter with two segmenters behind one swappable seam, and makes the better of the two the default.

Why

punkt-segmenter was last released in 2010 and last committed in 2018. It exists to run an unsupervised training pass over a corpus and learn abbreviations, and this gem was calling it as Punkt::SentenceTokenizer.new(text).sentences_from_text(text) — training on the single text node it was about to segment, on every call. It learned nothing useful that way. It also dragged in unicode_utils, whose Unicode tables are eleven versions behind what Ruby has shipped natively since 2.4.

What changed

TranslationDiff.segmenter is now swappable the way the adapter and cache store already are, with two implementations:

  • Segmenters::Pragmatic — the default. Wraps pragmatic_segmenter, which picks its rule set per language.
  • Segmenters::Simple — the in-house, zero-dependency alternative, kept for callers who want no extra gem and translate only from cased scripts.

The contract is split_offsets(text, language: nil), returning split points rather than strings, so the tokenizer reconstructs the source character for character by construction. Request threads the source language down to the segmenter when the caller passed from:; in the auto-detection path it is nil, because the detection sample is built from the segmented text and asking earlier would be circular.

Runtime dependencies stay at two: ox and pragmatic_segmenter.

Segmentation quality

Golden Rules corpus, 80 exemplars across 10 languages, taken from the context "Golden Rules" blocks of the upstream per-language specs:

language punkt (before) Simple Pragmatic (default)
English 24/51 29/51 48/51
German 1/3 1/3 3/3
Spanish 4/5 4/5 5/5
Italian 3/3 3/3 3/3
Russian 3/3 3/3 3/3
Greek 0/1 0/1 1/1
Armenian 1/3 1/3 3/3
Arabic 2/5 2/5 5/5
Hindi 0/1 0/1 1/1
Japanese 0/5 4/5 4/5
total 38/80 47/80 76/80

The gain is concentrated on scripts with no letter case — Arabic, Hindi, Armenian, Greek — where Simple's central rule ("the next visible character is lowercase, so do not split") has nothing to work with. That is a limit of the approach, not of its tuning.

Two of the three remaining English misses are not boundary disagreements: the corpus expects an errant newline rewritten into a space, and this gem never rewrites the source. The only real disagreement is a lowercase list separated by bare newlines.

Two things pragmatic_segmenter does that had to be handled

It treats any single newline as a sentence boundary. "The cat sat on the mat\nand looked at the moon." came back as two sentences. That is a false split, which is the harmful kind of error here — each half would reach the provider without the other. Single newlines are now shadowed as spaces before segmentation and the offsets applied to the original, so the source is untouched. This knowingly costs one Golden Rules point, on the bare-newline list case; inside this gem, list items are separated by markup and are already distinct text nodes, so a newline within one node is almost always incidental formatting.

It rewrites the text it hands back. It collapses runs of three or more spaces and respaces Ph.D. into Ph. D., so the returned sentence is not always findable in the source. Offset recovery keeps every boundary it can verify character for character, stops at the first it cannot, and lets the remainder stand as one unit. It never emits an offset it did not prove, and a coarser cache unit is the harmless direction — the text still translates correctly.

Verification

124 tests, RuboCop clean with no cop disabled. The reassembly invariant was fuzzed at 500k inputs across 24 language codes with zero violations, and the guards on the recovery path were each shown to have a real triggering input rather than only a synthetic one.

punkt-segmenter trains an unsupervised abbreviation model on the single
text node it is about to segment, which teaches it nothing and degrades
to bare period-space-capital rules -- measured against real input, it
false-splits abbreviations and initials in most of the cases that matter
(Проф., Dr., St., No., fig., рис., initials like А. С. Пушкин). It also
drags in unicode_utils, eleven Unicode versions behind, for three methods
Ruby has done natively since 2.4.

TranslationDiff::Segmenter replaces it: split after a terminator run
followed by whitespace (or a CJK terminator, no whitespace needed) unless
a guard fires -- lowercase follows, a known abbreviation or initial
precedes, digits sit on both sides, or the period is inside a URL/email.
Every guard only prevents a split, never adds one, because a missed
sentence boundary just makes a bigger cache unit while a false one sends
half a sentence to the translation provider on its own.

split_offsets returns character offsets rather than punkt's [start, end]
pairs, which makes full text coverage true by construction and removes
Tokenizer's boundary-extension patch-up. TranslationDiff.segmenter is
swappable the way .api and .cache_store already are.

The thirteen existing tokenizer tests, written against punkt, pass
unchanged. ox is now the gem's only runtime dependency.
Measured against the Golden Rules corpus, the in-house segmenter scores
47/80, worst on languages with no letter case at all (Arabic, Hindi,
Armenian, Greek) since its central rule depends on case. pragmatic_segmenter
scores 78/80 on the same corpus, so it becomes the default.

- Rename TranslationDiff::Segmenter to TranslationDiff::Segmenters::Simple,
  moved to its own namespace alongside the new
  TranslationDiff::Segmenters::Pragmatic. All 21 of Simple's tests move with
  it unchanged; it stays available as the zero-dependency option.
- Add TranslationDiff::Segmenters::Pragmatic, wrapping pragmatic_segmenter's
  per-language rule sets. It recovers offsets by locating each returned
  sentence in the source, in order, and raises rather than guessing if a
  sentence cannot be found -- verified against a real trigger (a Japanese
  cleaner rule that deletes a newline after "の").
- split_offsets gains a language: keyword, threaded from Request through
  Tokenizer, since pragmatic_segmenter picks its rules by language and
  otherwise falls back to English (which mis-segments, e.g., Russian
  abbreviations).
- Add pragmatic_segmenter as a runtime dependency (MIT, zero dependencies of
  its own).
- Add a Golden Rules regression sample and document the segmenter contract,
  both implementations, and the language argument in the README.

Two of the thirteen existing tokenizer tests now fail under the new default
and are left unedited, as instructed: one is a defensible missed-boundary
difference (a bare "!" no longer treated as its own sentence), the other is
a genuine false-split risk (pragmatic_segmenter treats a bare newline not
preceded by whitespace as a sentence boundary even with no punctuation at
all). Full analysis in .superpowers/sdd/2026-09-07-translation-diff-plan/pragmatic-report.md.
pragmatic_segmenter treats almost any single newline as a sentence boundary
candidate, even with no punctuation at all -- a false split, the harmful
kind, and a common one since HTML text nodes routinely carry incidental
newlines from source formatting. Fix the segmenter rather than the input:
replace single newlines (not part of a \n\n+ run) with a space in a shadow
copy, segment the shadow, and recover offsets against it, then slice the
original text -- "\n" and " " are both one character, so every recovered
offset is valid in the source too, guarded by an explicit length check
rather than an assumption.

This costs one Golden Rules point (76/80 -> 75/80): a bare list of items
separated by single newlines, with no punctuation, now segments as one unit
instead of three. That shape doesn't arise in this gem's actual input --
HTML list items are already separated into distinct text nodes by markup --
so it's a deliberate trade for closing a false-split class that does occur
in real input. It also fixes the Japanese trigger for the offset-recovery
raise found previously; a new, non-newline-related real trigger
(pragmatic_segmenter's InlineFormattingRule, which deletes a PDF/OCR
artefact unconditionally) replaces it in the test suite, since the raise is
narrowed, not removed.

Also accept the controller's ruling on the other tokenizer disagreement: a
bare "!" no longer starts its own sentence under Pragmatic, which is a
missed boundary (the safe direction), so
test_tokenizes_text_split_into_sentences now expects the merged text with a
comment recording why.
H1: pragmatic_segmenter's cleaner rewrites sentences in ways newline
shadowing doesn't cover -- it collapses runs of three or more spaces and
respaces abbreviations like "Ph.D." into "Ph. D.", among other things -- so
recovery could still fail to find a sentence verbatim, and it raised. That
was wrong: raising is not the only alternative to guessing. Recover every
offset that can be verified, stop at the first sentence that cannot be
located, and let the remainder of the text stand as one final unit. [0] is
always a legal answer -- it means the node isn't sliced further -- so this
can never corrupt a document, only coarsen a cache unit. Also close a
related case: an empty sentence from upstream is skipped rather than
emitting a duplicate, non-increasing offset. The one remaining raise is a
postcondition assertion (offsets start at 0, strictly increase, stay within
the text) that #recover_offsets guarantees by construction and should never
actually fire.

M1: language codes reaching Segmenters::Pragmatic were not normalised, so
"RU" and "ru-RU" fell through to English rules -- and DeepL, this gem's own
adapter, sends exactly those shapes. Downcase and drop the region subtag
before checking the code against PragmaticSegmenter::Languages::LANGUAGE_CODES,
falling back to English explicitly for anything unrecognised.

Also: reworded the class comment to describe today's pinned-version cleaner
behaviour as present-tense fact rather than a hypothetical future one;
corrected golden_rules_test.rb's comment (eleven exemplars, one Japanese, two
Arabic, not "a dozen" and "two Japanese"); added a test for Simple's
language: keyword being accepted and inert; and added a reconstruction case
with multiple blank lines (more than one, and more than one gap) for both
segmenters.

Golden Rules: 75/80 holds (per-language breakdown unchanged from the
previous round -- neither fix touches any exemplar in the sample).
The scratchpad corpus used to score the previous two rounds was a
reconstruction from a leftover JSON dump and was off by one exemplar, so
every derived figure (README, CHANGELOG, the class comment) was one point
low. Rebuilt from the authoritative source instead -- the `context "Golden
Rules" do` block of each of the ten per-language spec files on
diasks2/pragmatic_segmenter -- which yields exactly 80 exemplars and the
canonical per-language counts. Verified directly on three commits of this
branch: 48e5659 scores 77/80, 229b2e3 (newline shadowing) 76/80, and the
current code (H1 + M1 fixes) 76/80. Every delta already documented still
holds -- shadowing costs exactly one point, the H1 fix costs nothing -- only
the absolute numbers move, from 75/80 to 76/80.

Also, in the three places that stated the number, name where it comes from,
so a bare figure with no provenance cannot drift unnoticed again the way
this one did. And made the README honest about what the remaining misses
actually are: of the 4 exemplars Pragmatic misses, 3 (two English, one
Japanese) are not boundary disagreements -- pragmatic_segmenter's own
expected value has an incidental newline rewritten into a space, which this
gem's reconstruction invariant forbids doing to the source -- leaving
exactly one real boundary disagreement, the newline-separated list
shadowing already documents as a deliberate trade.

Re-measured punkt on the corrected corpus: still 38/80, unchanged from what
CHANGELOG.md already claimed.

golden_rules_test.rb's own citation was also wrong (it named a spec/lib/
file that does not exist upstream) and its "79 exemplars" is now 80;
corrected both.

No behaviour changed; the suite stays at 123 runs.
N3: recover_offsets discarded a boundary it had already proved. For "First
is fine. Hello   world mid. Third one here.", "First is fine." is matched
verbatim but the walk still returned [0], throwing away the fact that we
know exactly where it ends -- the algorithm only emitted the starts of
located sentences, and the second sentence's start was the thing that
couldn't be found. Now the cursor left by the last successfully located
sentence is emitted too when the walk stops early, guarded so it can never
equal the text's length or fail to advance past the last offset already
emitted (the one place this round put the offsets invariant at risk). Split
the walk out of recover_offsets into its own method to keep both under
RuboCop's method-length limit without disabling the cop.

N1: test_an_unrecognised_language_code_falls_back_to_english_rules passed
whether the fallback worked or not -- its Russian fixture segments
identically under English rules and under Common, so the assertion couldn't
tell a working fallback from a broken one. Replaced it with a fixture where
they genuinely differ (English's cleaner disables its no-space-between-
sentences abbreviation guard, Common's doesn't), verified to fail if the
fallback is reverted to passing the code straight through.

N2: locate's dead ternary arm (unreachable once the empty-sentence check
above it holds, since a located non-empty sentence always advances the
cursor by construction) is removed, and its comment no longer claims a
branch exists that cannot be taken.

Golden Rules: 76/80 holds -- the corpus contains no multi-sentence text
where a prefix is verified and a later sentence in the same node fails, so
N3 has nothing to move here; it changes granularity only on the kind of
input the corpus doesn't happen to contain.
@Halvanhelv
Halvanhelv merged commit f254386 into main Sep 8, 2026
5 checks passed
@Halvanhelv
Halvanhelv deleted the feat/own-segmenter branch September 8, 2026 00:34
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.

1 participant