Optimize the diff algorithm, part 3 - #5
Conversation
|
(Review by Claude Code) I replayed this branch against master over a corpus of full article Method17 parity fixtures — full histories, 11 wikis (ar, de, en, es, he, hi, Where the two disagree, I classified each token by introduced vs Six of the largest fixtures (Paris en+fr, Adolf Hitler, Barack Obama, Attribution impact
Per-article it ranges from 0% up to 10.2% (Brasil) and 6.0% (القاهرة). This is the part I'd flag hardest: the two regression goldens in Is the change better or worse?Better, clearly. On the decisive cases — context-unique token windows Of 5,624 decisive cases:
Fourteen articles produced decisive cases. This branch wins on 11 of Runtime is neutral: 0.95x–1.33x, median ~1.03x.
|
sentences reachable from a revision but absent from sentences_ht |
|
|---|---|
| master | 0 revisions |
| this PR | 615 revisions |
Only 4 restores fire on that article; the resulting sentences then persist
through paragraph carry-forward for hundreds of revisions. Master holds
this invariant at exactly zero, so it's a real structural change, not a
pre-existing quirk.
Registering sentence_reused alongside the unmatched sentences (same
place, same value/splitted clearing) takes the violation count back to
0 with the restore path still firing.
To be clear about severity: I could not make this produce a wrong
answer. I built that fix and replayed the six most restore-heavy
articles with and without it — Jesse Owens, Albert Einstein, España,
日本, Brasil, Москва — and the final attribution is byte-identical:
0 differing origins across 381,716 aligned tokens. So today this is
latent, not a live bug, and I don't consider it a blocker.
It would surface only when a partially-restored sentence is later deleted
and reinserted, at which point the lookup consults the stale
sentence_prev rather than what's actually live. Given the fix is ~8
lines and restores the invariant exactly, I'd still take it as cheap
insurance — but it's your call, and the evidence says nothing is broken
right now.
For what it's worth the path is very rare anyway — 42 firings in 22,964
calls across the corpus — so if the restoration is hard to make correct,
dropping it would cost almost nothing.
Not a defect, but worth knowing
Duplicate token_ids within a single revision's token stream are
pre-existing on master (6,344 of 6,461 revisions on Jesse Owens,
identical counts on both branches). I chased this before realizing it
predates the PR. Flagging it so nobody else does — and because it means
"no duplicate tokens" isn't available as an invariant to test against.
|
Indeed. I should add more test cases to the existing suite. |
|
Now all the changes are covered by the current test cases. |
|
@ragesoss You can review it now. I don't plan to put the fix to "Yeah Yeah Yeahs"-article issue (https://phabricator.wikimedia.org/T434097) in this PR. When I find the proper fix that does not regress, I'll put it in a separate PR. |
ragesoss
left a comment
There was a problem hiding this comment.
Review notes — analysis run with Claude Code
How to read this. These findings come from an AI-assisted review session. They are not
to be automatically trusted or blindly acted upon. Each one is an observation that warrants
independent verification; some are likely to be wrong or to miss context the author has.
Read them, check the ones that matter, and use or discard them on your own judgment. The
empirical claims below are stated with the commands that produced them so they can be
rerun rather than taken on faith.
Scope of what was examined. The ~460 lines of code diff across WikiWho/wikiwho.py and
WikiWho/utils.py (the remaining +146k is fixtures); the test suite at the PR head; and a
per-token attribution replay comparing master (38f4117) against the PR head (7de9ae5) on
the repository's own revision fixtures.
Coverage limitation, stated up front. splatoon_3_revisions.json (39.5 MB) and
through_the_looking-glass_revisions.json (28 MB) were not replayed. analyse_article
holds the token graph for every revision resident simultaneously, and replaying those two
exhausted 31 GiB of RAM plus swap on the review machine. All drift numbers below therefore
cover only these six fixtures: adam_himebauch, luis_donaldo_colosio_riojas,
the_tell-tale_brain, one_thing_leads_2_another, american_goldfinch,
nintendo_switch (6.3 MB total, ~70 MB peak RSS for the whole suite). This bounds items 1
and 4 below; item 2's bisect does not depend on the excluded fixtures.
1. Nine tokens lose provenance to a later revision, on the citation this PR adds a test for
Comparing final-snapshot origin_rev_id per token, master vs PR head, across the six
fixtures: 73 content tokens gain an older origin, and 9 get a newer one. All 9 are on
Luis Donaldo Colosio Riojas, and they are exactly three template date fields:
| token | master | PR head |
|---|---|---|
4 june 2024 (|term_start2=) |
#67 (1234010057) | #89 (1347221443) — the snapshot |
23 march 2019 (|date=) |
#31 (1028090673) | #57 (1222367802) |
8 june 2021 (|date=) |
#30 (1028074078) | #57 (1222367802) |
Each of these exact substrings is present continuously in the wikitext from its first
appearance through the snapshot — verified against the fixture; 8 June 2021 appears in
60/60 consecutive revisions from #30, 23 March 2019 in 59/59 from #31, 4 June 2024 in
24/24 from #66. None is ever deleted and re-added, so a snapshot-or-later origin looks
incorrect.
One qualification that cuts against master as well: 4 June 2024 first appears at rev
#66 (1227424767), while master credits #67. Master is off by one revision there too —
so this is not a case of master being right and the PR head wrong, but of the PR head
moving the origin 22 revisions later, to the snapshot.
Rev 1222367802 is worth looking at closely, because it only reorders the fields of the
citation — |date= moves to the front:
prev (1220166917): {{cite news|url=…|title=Colosio, Vizcaíno, …|publisher=…|date=8 June 2021}}
curr (1222367802): {{cite news |date=8 June 2021 |title=Colosio, Vizcaíno, … |url=… |publisher=…}}
This is the same citation whose |title= the PR adds a passing test for
(colosio_citation_title_move_across_punctuation, asserting origin 1028074078). Within one
template and one revision, the title's provenance is fixed and the date's is lost.
2. Bisected to one line, with a mechanism
Reverting each of the change's four independent parts individually on top of the PR head
isolates it. The reproduction needs only the failing transition — revisions [56:58] of
the fixture, two revisions rather than 90 — which makes this cheap to rerun:
| reverted on top of the PR head | the three dates |
|---|---|
utils.py paragraph split |
still regressed |
_can_partially_restore_historical_sentence disabled |
still regressed |
_recover_unique_template_field_words disabled |
still regressed |
the _copy_safe_moved_run / coverage rewrite |
restored |
…of that, only the per-offset gate (_unique_moved_run_coverage → _has_unique_content_window) |
still regressed |
| …of that, only the threshold, 4 → 3 | restored, but breaks a PR test — see item 3 |
The responsible line is in _copy_safe_moved_run:
- if len(content_core) >= WORD_MATCH_MOVE_MIN_INFO_TOKENS: # master: 3
+ if len(content_core) >= WORD_MATCH_MOVE_MIN_ANCHOR_INFO_TOKENS: # PR head: 4The change hoists weak-anchor rejection up out of the per-offset check into the
copy-safety gate. On master, a 3-token content core passed this gate and was discriminated
downstream in _has_unique_content_window, which carried an explicit exception for
digit-bearing runs of exactly 3:
if any(any(char.isdigit() for char in token) for token in needle):
return (_count_subsequence_cached(count_text_prev, needle, count_state) == 1 and ...)Hoisting the gate dropped that exception. Template date fields are its casualty:
| date = 8 June 2021 has _longest_content_core of exactly ('8','june','2021') — three
informative tokens — and contains no [[, so the new _link_anchor_bounds fallback does
not apply either. The whole moved run is rejected before any of its tokens reaches the new
_unique_moved_run_coverage check, and the tokens are recreated at the current revision.
3. The conflict is real — a straight 4 → 3 revert is not the fix
Worth flagging because it is the non-obvious part.
test_reference_markup_does_not_strengthen_a_weak_move_anchor, added by this PR, uses
["across","the","country",".","<","ref","name","=",":","0",">"]. Its longest content core
is across the country — also exactly three informative tokens. Lowering the threshold
back to 3 restores the dates and makes that test fail (assert 16 is None). The digit
heuristic on master is precisely what separated these two cases; the ≥4 threshold cannot
distinguish them.
A candidate patch that satisfies both is below. It keeps the ≥4 gate and adds an OR-branch
for a ≥3 core that is either preceded by a |name= template-field opener or contains a
digit. Neither disjunct suffices alone — the field anchor misses | term_start2 = 4 June 2024 (its moved run does not include the |), and the digit test alone leaves ~15 Adam
Himebauch tokens attributed later than the field anchor achieves. Uniqueness in both prev
and curr is still required, so the existing safety property is unchanged.
This patch was written by Claude Code and has not been reviewed or run by a human.
Treat it as a description of one workable shape for the fix, not as a proposed commit.
def _template_field_anchor(run, content_core):
"""True when content_core sits directly after a `|name=` template field opener."""
core_length = len(content_core)
for start in range(len(run) - core_length + 1):
if tuple(run[start:start + core_length]) != content_core:
continue
if start < 3 or run[start - 1] != '=':
continue
index = start - 2
while index >= 0 and _is_informative_move_token(run[index]):
index -= 1
if index >= 0 and index < start - 2 and run[index] == '|':
return True
return Falseand the gate in _copy_safe_moved_run becomes:
def _copy_safe_moved_run(count_text_prev, count_text_curr, text_curr, curr_start, length, count_state):
run = text_curr[curr_start:curr_start + length]
content_core = _longest_content_core(run)
- if len(content_core) >= WORD_MATCH_MOVE_MIN_ANCHOR_INFO_TOKENS:
+ if (len(content_core) >= WORD_MATCH_MOVE_MIN_ANCHOR_INFO_TOKENS or
+ (len(content_core) >= WORD_MATCH_MOVE_MIN_INFO_TOKENS and
+ (_template_field_anchor(run, content_core) or
+ any(char.isdigit() for token in content_core for char in token)))):
return (_count_subsequence_cached(count_text_prev, content_core, count_state) == 1 and
_count_subsequence_cached(count_text_curr, content_core, count_state) == 1)With that applied, all nine tokens return to master's origins, and per-token drift over the
six fixtures becomes 100 content tokens older, 0 newer (versus 73 older / 9 newer at the
PR head) — so it also recovers 27 further improvements rather than merely undoing the
regression. Every hand-written assertion in tests/ passes, including the weak-anchor test.
4. The golden fixtures cannot detect a regression in this PR
*_golden.json were regenerated from the PR head, so they encode its output exactly. Built
from each version's ww.tokens using the same fields test_token_authorship_matches_golden
compares:
| fixture | master | PR head | with the item-3 patch |
|---|---|---|---|
adam_himebauch |
8341 tokens, mismatch | exact match (8336) | 8285, mismatch |
luis_donaldo_colosio_riojas |
6886, mismatch | exact match (6908) | 6859, mismatch |
the_tell-tale_brain |
5019, mismatch | exact match (4980) | exact match |
one_thing_leads_2_another |
1621, mismatch | exact match (1616) | exact match |
Because the goldens match the PR head byte-for-byte, they will accept whatever the PR head
does, including item 1. Note the direction of the patch's mismatches: it creates fewer
tokens (Colosio 6908 → 6859), i.e. it retains more identities rather than recreating them —
which is the stated goal of the series, but registers as a golden failure. Per-token origin
direction (older vs. newer) discriminates here where the goldens do not.
Separately, five revision fixtures are present but unused by ARTICLES:
through_the_looking-glass, japan_cup, american_goldfinch, nintendo_switch,
2026_canvas_security_incident. Generating goldens for them would widen coverage; note
that the first of those is where most of the PR's newer-origin drift appeared in the
earlier, RAM-limited pass, and it has no golden today.
5. Other observations from reading the diff
Items (a)–(e) were checked by direct execution against the PR head; (f) is a code-reading
observation only.
(a) WORD_MATCH_HISTORICAL_MIN_EVIDENCE_RATIO is unreachable. With MIN_AVAILABLE=24
and MAX_OCCUPIED=2, len(available) < 4 * occupied_count cannot be true once the first
two gates pass — brute-forcing the reachable space returns no qualifying pairs. The
docstring lists "outnumber by at least 4:1" as an independent criterion, but it is implied.
Either drop it or lower MIN_AVAILABLE.
(b) _has_template_name_spacing_change also matches link pipes. The filter is
key[:2] == ('wikitext', '|') with no constraint on key[2], so
('wikitext','|','link', ('British','Columbia'), 0, ('BC',)) qualifies. Confirmed:
[[British Columbia|BC]] → [[BritishColumbia|BC]] returns True.
_pipe_key_changed_only_by_template_spacing has the same gap. Constraining key[2] to the
template-field key kinds would close both.
(c) The same gate fires when nothing changed. It tests the cross-product of name forms,
so a revision pair where both sides contain both spacing variants returns True.
Confirmed: "{{single chart|switzerland|62}} and {{singlechart|austria|7}}" compared against
itself returns True. Comparing per-name form sets (prev_forms[c] != curr_forms[c]) would
avoid this.
(d) Cost when it fires. _recover_unique_template_field_words indexes n-grams over the
whole token lists (prev_spans = [(0, len(text_prev))]), ignoring the candidate spans that
keep _recover_moved_word_runs bounded. Measured on synthetic diff regions, with and
without a template-name-spacing change present to trigger the pass:
n= 500 tokens 0.45 ms -> 2.64 ms
n= 1500 tokens 1.36 ms -> 8.54 ms
n= 3000 tokens 2.67 ms -> 17.16 ms (~6.4x for this stage)
End-to-end the effect was modest in an earlier pass over the full fixture set (~+4% total;
Colosio +52%, japan_cup +20%) — those aggregate figures were not re-measured here, and
japan_cup is outside the six fixtures used above. Items (b) and (c) make this pass fire in
cases where it has nothing to recover.
(e) utils.py: a line-start |}} is still split.
split_into_paragraphs("{{cite web\n|url=x\n|}}\nnext") returns
['{{cite web\n|url=x\n|}', '}\nnext'] — the same bug class the |} fix addresses, when the
template close begins a line. A (?!\}) lookahead would cover it; the only construct that
costs is a table close followed immediately by } on the same line, which is not valid
table syntax. Also, the other patterns in this file are module-level compiled (regex_dot,
regex_url, regex_cjk) while the new re.sub is inline, and this runs per revision.
(f) sentence_reused is never inserted into self.sentences_ht, and its .value /
.splitted are never cleared. Every other retained sentence gets value = '' /
splitted = None after hash-table insertion, specifically to keep pickles small; this one
retains the full sentence string indefinitely. More substantively: since only the original
sentence_prev remains in sentences_ht, a later delete-and-readd of that sentence looks up
a word list whose token objects now live in a different sentence of the current revision.
Worth confirming that is intended rather than incidental.
6. Smaller notes
- Threshold coupling in
_recover_moved_word_runs:needs_link_contextuses
< MIN_INFO_TOKENS(3) while_copy_safe_moved_run's gate uses
>= MIN_ANCHOR_INFO_TOKENS(4). Since_link_anchor_boundsguarantees ≥2 informative
tokens before[[, the link-window path is reachable only for a content core of exactly
2; a core of 3 passes copy-safety via the link branch but is then routed through
_unique_moved_run_coverage. Two constants doing different jobs three lines apart would
benefit from a comment. - An undocumented tightening. The old
_has_unique_content_windowreturnedTrue
unconditionally for non-informative tokens, so punctuation and markup inside an accepted
run always kept provenance._unique_moved_run_coveragerequires them to fall inside a
unique window, so edge punctuation can now lose it. The description presents item 5 as
purely more permissive; in this respect it is stricter. _can_partially_restore_historical_sentencebuildsremoval_revisionsunder
if word.outboundand then separately evaluatesall(word.outbound …); one pass would do.- Pre-existing, not introduced here: the
for matched_word in matched_words_prev:loop
(~line 1400) usesword_prev, left over from the preceding loop, rather than
matched_word, so those words never getlast_rev_idupdated. Worth a separate issue. - Downstream note for
wikiwho_api: the paragraph-split change alters paragraph and
sentence hashes, so the first revision processed against an existing pickle will not match
paragraphs containing an inline|}and will fall through to word-level matching. Since
pickles carry no algorithm-version invalidation, a rebuild rather than an incremental
upgrade seems safer.
7. What the replay shows the change doing
Stated neutrally, for the six fixtures measured: 73 content tokens move to an older origin
and 9 to a newer one; token counts drop on three of four goldens (more identities retained);
the |} handling is correct wikitext semantics for the inline case; count_state is shared
across both recovery passes, reusing the subsequence cache; and the new tests are
unit-level, each targeting a single rule. test_reference_markup_does_not_strengthen_a_weak_move_anchor
is the negative test that constrains the anchor gate — and, per item 3, the constraint that
makes the fix for item 1 non-trivial.
Drafted across two Claude Code sessions (~3.5 h wall clock, 5 human messages). Sage
initiated the review, and redirected its method after the first session exhausted the
machine's memory replaying the two largest fixtures — which is why item 1's reproduction was
narrowed to a two-revision slice. Sage read this comment in full before posting but did not
independently reproduce any finding, and did not run or review the patch in item 3, which
Claude Code wrote. The empirical claims — drift counts, the bisect, the golden comparisons,
and items 5a–5e — were produced by scripts Claude Code wrote and ran in-session, and have not
been checked by a second party; item 5d's end-to-end percentages are carried over from the
first session and were not re-measured. Items 5f and 6 are code-reading observations,
unverified by execution. The two largest fixtures were never replayed at all.
(Comment written by Claude Code.)
|
Changelist of 7f8604b:
|
There was a problem hiding this comment.
Pull request overview
Improves WikiWho’s token attribution across complex wikitext edits and historical sentence restoration.
Changes:
- Refines moved-text and template-field recovery.
- Adds conservative partial historical-sentence restoration.
- Corrects table-ending parsing and expands regression fixtures.
Reviewed changes
Copilot reviewed 4 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
.gitignore |
Ignores macOS metadata. |
WikiWho/wikiwho.py |
Extends matching and restoration logic. |
WikiWho/utils.py |
Refines table-ending parsing. |
tests/test_regression.py |
Adds targeted regression tests. |
tests/authorship_cases.json |
Adds authorship expectations. |
tests/fixtures/one_thing_leads_2_another_revisions.json |
Adds revision-history fixture. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Okay. Those are all resolved. Let's ask Copilot again for review. |
|
Hmmm. It seems that GitHub's "Student Pro" plan no longer has any AI quota... 😓 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
WikiWho/wikiwho.py:719
- For a validated run, this issues roughly eight distinct subsequence queries per token. Each cache miss scans both full revisions in
_count_subsequence, and adjacent windows generally have different keys, so a long moved block is still Θ(run length × revision length) and can reintroduce quadratic runtime on the large articles this matcher is intended to handle. Please pre-index 3–10-token window frequencies once (under the existing window cap) or otherwise bound the coverage scan.
if (_count_subsequence_cached(count_text_prev, needle, count_state) == 1 and
_count_subsequence_cached(count_text_curr, needle, count_state) == 1):
MusikAnimal
left a comment
There was a problem hiding this comment.
OK by me, but… I wholly rely on the AI for the reviews of this codebase. I am grateful for the fixes, but I think once we consider things "stable" we should refrain from further non-necessary changes. The algorithm is just too fragile, it seems.
If @ragesoss is happy this I will deploy it
Indeed. I also don't plan to modify it further as the current #6 edition is the best balance I can find between time and space consumption. Unless there are new reports on correctness cases, we can mark the current edition the "stable version" and call it a day. |
|
I will review it this week. |
Re-review notes — analysis run with Claude CodeHow to read this. These findings come from an AI-assisted review session. They are not to Scope. The five commits since the previous review ( The coverage limitation from both previous reviews is gone, and it was self-inflicted.
Status of the previous review's items
Items 1–3. On the two-revision slice ( Item 4. The goldens are still regenerated from the head — all four available goldens are an Item 5f. Measuring sentence objects reachable from a revision but absent from
The invariant is restored exactly. Item 5e. The
Every sampled Items 5a and 5c reproduce unchanged. Brute-forcing the reachable What the change does to attributionPer-token origin on the final revision,
Net improvement, consistent with the 72.4 % measured over the wider 17-article corpus in the The raw directional metric flatters the PR. Counting only whether origin moved older or The losing cases cluster on repeated citation dates. Of the sampled master-wins, 15 of 26 New observations(a) The three-token structural exception has an arbitrary boundary. This is not a regression — master recovers none of the nine shapes — but it excludes the Worth flagging, since (b) The two template-field recovery passes almost never fire. Instrumented over 22,586
This is an improvement on the previous measurement (0 assignments in 86,676 revisions) — the (c) A silent-disable guard. run_length = WORD_MATCH_MOVE_MIN_INFO_TOKENS
if run_length != 3:
returnToday that is a no-op. If (d) (e) Nothing runs these tests automatically. Smaller notes
Drafted in a Claude Code session (~1 h, 3 human messages). Sage asked for the re-review and set (Comment written by Claude Code.) |
Follow-up: the attribution measurement, on a wide corpus this timeSame caveat as before — these numbers came out of an AI-assisted session, they are observations The 65.6% figure in the previous comment came from the eight fixtures in this repository, ~3,300 Re-run against the current head,
Combined with the eight repository fixtures: 1,378 of 1,955 decisive cases (70.5%). So the Every article that produced a decisive case, so the small samples are visible rather than
Three articles go to Two things this does not say. It does not say the change is regression-free. Across both corpora it moves roughly 577 tokens It also does not cover everything. About 48% of drifted tokens were ambiguous either way — On reproducing it. The whole run took 8 minutes at 0.21 GB peak RSS, single process, no for line in open(history_path, encoding='utf-8'):
ww.analyse_article([json.loads(line)]) # not analyse_article(revs)keeps a 10,000-revision replay of a large article at ~0.2 GB. Deployment note repeated from the previous comment because it bears on timing rather than Drafted in a Claude Code session (~1.5 h, 6 human messages). Sage directed the review, set the (Comment written by Claude Code.) |
|
@MusikAnimal I'm happy with it now. |
This PR is a follow-up of the efforts made in #4 and #2. During testing and benchmarking, I found a bunch of edge cases that are worthy of fixing, thus made the following changes:
Parse template endings without mistaking them for table endings
WikiWho previously treated every
|}sequence as the end of a table. This incorrectly split template endings such as|}}into two separate}tokens.Table endings are now recognized only at the beginning of a line. Inline template endings remain intact as
}}.Recover two moved words when an unchanged link provides sufficient context
Existing moved-text recovery normally requires at least three contiguous content words. This misses sequences such as:
lived in [[British Columbia]]The new rule can retain the two words immediately before the link when:
[[and]];This rule recovers only the two preceding words. It does not assign the link’s provenance or change how ordinary two-word phrases are matched.
Retain template-field content when template-name spacing changes
A change such as:
{{singlechart|switzerland|62|...}}to:
{{single chart|switzerland|62|...}}changes the structural keys of the template separators. The matcher can now retain field content such as
switzerlandwhen:The templates themselves are not treated as identical; only the independently supported field content is retained.
Run the new template-field recovery only when relevant
The additional search is skipped unless the compared revisions actually contain a template-name spacing change. Candidate windows remain bounded, preserving the normal Stage 2 fast path.
Recover moved text across punctuation
Previously, each recovered token needed to belong to a unique window of consecutive informative tokens. That was too restrictive for genuine moved passages containing punctuation, such as comma-separated names:
Although the entire passage was distinctive, punctuation divided it into short content runs, preventing words such as
marinafrom retaining their provenance.The commit replaces that rule with
_unique_moved_run_coverage():This allows punctuation-bearing text to retain provenance while still requiring exact, unique contextual evidence.
Strengthen the initial moved-run anchor
The previous constant:
was replaced with:
The initial candidate run must now contain an informative content core of at least four tokens before ordinary moved-run recovery can proceed. The existing balanced-internal-link exception remains available when the entire structured run is unique.
Rationale: markup should not turn a weak phrase into a strong anchor. For example:
The three common words are not enough evidence merely because reference markup follows them. This stronger run-level gate compensates for the more flexible punctuation-aware coverage rule.
The design therefore uses two evidence levels:
Neither rule depends on English stopword lists or any particular language.
Compute moved-run coverage once
Within
_recover_moved_word_runs(), the matcher now computes the set of uniquely supported positions once per accepted run and reuses that set while assigning matches.Previously, uniqueness was checked separately around every informative token.
Rationale:
The specialized link-context path remains separate for low-content balanced links.
Partially restore a deleted historical sentence
The old exact-sentence restoration logic could reuse a historical sentence only if none of its token objects had already been matched elsewhere. If one or two generic tokens—such as a comma or
of—had been consumed by another match, the entire returning sentence was rejected, even when dozens of its other tokens clearly represented the same deleted sentence.The new
_can_partially_restore_historical_sentence()permits conservative partial restoration when:These conditions identify a delayed reinsertion supported by a large, coherent block of historical evidence—not an ordinary edit to the immediately previous sentence.
Preserve available identities without duplicating occupied ones
When the partial-restoration predicate passes, WikiWho constructs the returning sentence position by position:
Wordobjects.original_addsare updated normally.Rationale: preserve the strongly supported majority of the returning sentence without assigning one historical token identity to two current positions. Ambiguous occupied positions are treated as new text rather than stealing provenance from their existing matches.
Several test cases and 3 more golden fixtures are added to reflect these changes; 2 existing golden fixtures are regenerated, one existing test case is removed as it is already covered by the new golden fixtures.