My Words: tolerate typography when matching the writer's words - #587
Open
kcarnold wants to merge 2 commits into
Open
My Words: tolerate typography when matching the writer's words#587kcarnold wants to merge 2 commits into
kcarnold wants to merge 2 commits into
Conversation
Edits were failing for reasons that had nothing to do with the edit. A
phrase has to be found in text a host stores, and the sources disagree
constantly: Word autocorrects a hyphen into an en dash and an apostrophe
into a curly one, transcription emits straight ASCII, paste brings
non-breaking spaces, and hyphenation is simply unstable ("well-being" /
"well being"). Exact `indexOf` read every one of those as "not found", so
the model burned its turn re-viewing an edit it had got right.
Add a fold ladder (`utilities/textMatching`) — exact, then typographic,
then loose (case, collapsed spaces, hyphen-as-space) — exhausting each
rung across the whole search space before loosening, so an exact hit
always beats a folded one elsewhere. Folds carry an index map, and spans
are sliced from the source, so what gets replaced or selected is always
the writer's real characters. `ops.findSpan` and the Lexical
`selectPhrase` both use it. Closed vs. hyphenated compounds ("email" /
"e-mail") stay a miss on purpose: matching across a deleted separator
risks silently rewriting the wrong span.
The word-bank gate had the same problem from the other side — it tokenized
"well-being" as one atomic word, so a hyphen disagreement rejected the
writer's own words as not theirs, in both directions. Treat an intra-word
hyphen as a separator instead. The phrase rule is unchanged: "well being"
must still appear contiguously in the corpus for "well-being" to pass.
Two things this surfaced:
- `move` re-inserted `op.phrase`, so a lenient match would have let it
restyle the text it carried. It now re-places the characters it cut.
- Failure notices outlived their cause. The strip cleared only on
replacement or dismissal, so one word-bank rejection sat there through
many successful turns, and a stale "that was blocked" is
indistinguishable from a live one. Retract it when the writer takes the
floor or an edit lands (session.ts invariant 6); the log keeps history.
Also adds frontend/src/pages/my-words/CLAUDE.md — a call-path index for
the feature, including the four places text gets matched, which were the
expensive thing to establish here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GDgXb9TvnpCgoPtTWjqe9p
…ories
Two gaps in the previous commit, both from review.
"email" / "e-mail" is the same ASR ambiguity as "well-being" / "well
being" — one utterance, and the transcriber picks a spelling that need
not be the document's. Excluding it was inconsistent. What it actually
needs is care about *how* it matches, since dropping the separator lets
letters re-segment:
- In the locator, add a run-together tier that drops separators, anchored
to word boundaries in the source. Without the anchor "email" also
matches inside "the ache mail" and the span rewrites the wrong text;
the anchor is on that rung alone, so literal substring matches behave
as they always have.
- In the word bank, re-segmentation invents meaning rather than just
picking a bad span — "notable" to "not able" is a claim the writer
never made. So the corpus carries both readings of a *hyphenated*
compound and no others: a closed-up spelling is legal exactly where a
hyphen appears on one side or the other, never off a plain space. The
same rule makes a hyphen bind tighter than the glue rule, so "in-depth"
is lifted whole instead of read as "in" plus a stray "depth".
Span ends now resolve to one past the last matched character rather than
the next character the fold kept, which is what makes "e-mail" come back
as a span of "e-mail" and not "e-mail ".
Replace the hand-listed character table with Unicode general categories
(\p{Pd} dashes, \p{Zs} spaces, \p{Cf} invisibles) so a variant nobody
listed can't go missing; quotes stay enumerated because Pi/Pf don't tell
single from double. Normalizing by dropping non-alphanumerics instead
would break two things, now written down in the module header:
punctuation is editable content here, so a locator blind to it can't tell
whether a trailing period is inside the span; and it collapses the ladder,
leaving nothing between byte-exact and maximally lenient.
Also replaces the two literal NUL bytes in corpus.ts with named escapes,
which is why git stops treating that file as binary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GDgXb9TvnpCgoPtTWjqe9p
This was referenced Jul 30, 2026
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.
Makes My Words' text matching tolerant of typographic variation, so edits stop failing over hyphens, curly quotes, and spacing. Also fixes failure notices persisting long after the failure they describe.
Background
Every My Words tool that acts on the document —
str_replace,insert … after,move,highlight— must locate a phrase the model produced inside text a host stores. Those two strings disagree routinely:-, curly’for'Matching was exact
indexOf, so each of these read as "not found". The model then spent its turn re-viewing and retrying an edit whose target text was correct.The word bank had the mirror-image problem in
corpus.ts:tokenizetreatedwell-beingas a single atomic token, so a hyphenation disagreement rejected the writer's own words as not theirs — in both directions, since either side may be the hyphenated one.Changes
New:
frontend/src/utilities/textMatching.tsA leniency ladder, applied most-literal-first:
email↔e-mail)Each tier is exhausted across the entire search space before the next is tried, so an exact hit anywhere beats a folded hit elsewhere; leniency only rescues a search that would otherwise fail outright. Folds carry an index map back to the source, and callers slice the original text with it, so replaced or selected text is always the writer's real characters rather than the needle's spelling.
Consumers:
interaction/ops.tsfindSpan, and the standalone editor'sselectPhraseineditor/editor.tsx. Word'sselectPhrasewas already lenient (ignorePunct/ignoreSpace/matchCase: false), which produced a specific failure: the reveal would highlight a phrase the subsequent apply then failed to find.corpus.tsAn intra-word hyphen is now a word separator, and the corpus indexes both readings of a hyphenated compound (
e-mailase+mail, and asemail). A proposal matching either reading validates.The phrase rule is unchanged:
well beingmust still appear contiguously in the corpus forwell-beingto be legal. What the AI gains is only the freedom to hyphenate or unhyphenate the writer's own adjacent words.voice/session.ts,voice/VoiceSession.tsxonNoticeacceptsnull, which retracts the standing notice. The session retracts on writer speech and on a successful apply (documented as invariant 6). Previously a notice cleared only when replaced or manually dismissed, so a single word-bank rejection stayed on screen across many successful turns; a stale "that was blocked" is indistinguishable from a live one. Notices are still logged; only the strip is transient.Design notes
Why the run-together tier is anchored. Dropping separators lets letters re-segment, so
emailmatches insidethe ache mail— a span no reader would call the same phrase, and splicing it rewrites the wrong text. That tier therefore requires the hit to begin and end on a word boundary in the source. The anchor is on that tier alone: literal substring matching is unchanged, socatstill matches insideconcatenateas it always has.Why the word bank draws the line at hyphens. In the locator, a bad match picks the wrong span. In the word bank, re-segmentation invents meaning:
notable→not ableis a claim the writer never made. So the closed-up form is offered only where a hyphen actually appears on one side or the other, never off a plain space — a hyphen is a mark the sources disagree about, a space is not. Both directions of thenotable/not ablecase remain rejected, with tests.The same rule makes a hyphen bind tighter than the glue-word rule:
in-depthmust be lifted whole rather than read asin(glue) plus a straydepth.Why folds substitute by category instead of stripping non-alphanumerics. Stripping would break two things:
str_replaceis routinely used to change a comma to a period. A locator blind to punctuation can't tell whether a trailing.falls inside the match, so the span it returns eats or drops a character of the writer's prose.The disagreements are per-character glyph substitutions, so the fold is a same-length substitution. Substitutions derive from Unicode general categories (
\p{Pd},\p{Zs},\p{Cf}) rather than a hand-listed table, so an unlisted variant can't go missing. Quotes stay enumerated becausePi/Pfdon't distinguish single from double, and folding both to one character would make'match".Span ends resolve to one past the last matched character, not to the next character the fold kept. Otherwise a match on
e-mailinsend an e-mail nowreturns a span ofe-mail— the trailing space rides along because it was dropped by the fold.Behavior change:
movemovere-insertedop.phrase. Under exact matching that equalled the text it cut; under lenient matching it need not, so a move could silently restyle the text it relocated. It now re-places the characters it actually removed.Incidental
frontend/src/pages/my-words/CLAUDE.md(new) — call-path index for the feature: the two turn-loops, the shared edit path, and a table of the four places text is matched against the document. Those four have to stay in agreement, and nothing named them in one place. Notes that Google Docs'selectPhrasedelegates to Apps Script and is not folded, and thatMockEditoralways succeeds, so a miss there is invisible in tests.corpus.tscontained two literal NUL bytes (the source-boundary sentinel), which made git treat the file as binary. They are named escapes now, so it diffs as text.docs/my-words-interaction-design.md— §4.5 and §4.6 record the notice lifetime and the typography/vocabulary split. Prior §4.5 is renumbered to §4.7; no existing cross-references pointed at it.Testing
src/utilities/__tests__/textMatching.test.ts(new) — tier ordering, index-map round-trips and monotonicity, span-end resolution, the word-boundary anchor (including that it doesn't retroactively constrain the exact tier), and that newlines are never folded into spaces.ops.test.ts— hyphenation in both directions, dash family, curly apostrophes, source-slicing, paragraph scoping under lenient matching,movefidelity, and that a genuine miss still throws.corpus.test.ts— hyphenation both ways, hyphen chains, closed vs. hyphenated compounds both ways, thenotable/not ableguard in both directions, and hyphen-over-glue binding.voiceControl.test.ts— invariant 6: retraction on speech and on a landed edit, notices still reaching the log, and a fresh notice standing until superseded.267 unit tests pass;
tsc --noEmitclean. The one eslint error and five prettier warnings present in the tree are pre-existing onmain, in files this PR does not touch.