doc-control 0.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,609 @@
1
+ """Quote anchors: a passage named by its text and context, found again after edits.
2
+
3
+ An anchor is a quote plus the characters before and after it (``prefix``, ``suffix``),
4
+ the paragraph number it was taken from (``hint``) and that paragraph's fingerprint.
5
+ Unlike an address it survives edits: :func:`resolve` looks the quote up again and says
6
+ where it is now, how well it matches, or that it cannot tell.
7
+
8
+ Matching
9
+ Exact first: every place the quote occurs verbatim is a candidate. Only when no exact
10
+ candidate is good enough does the fuzzy search run, over a short list of paragraphs
11
+ (the paragraph with the anchor's fingerprint, the ones near the hint, and the ones
12
+ sharing the most character runs of the quote, rare runs counting more). In each,
13
+ ``difflib`` aligns the quote with the text and the densest run of matching blocks gives
14
+ a window, whose ends are then moved by whole words while that helps; the same paragraph
15
+ is searched again with that window masked, so a repeated phrase shows up as several
16
+ candidates. Quotes of three words or fewer are never matched fuzzily.
17
+
18
+ Score
19
+ ``(0.5*quote + 0.2*prefix + 0.2*suffix) / 0.9``, each part in ``[0, 1]``, compared word
20
+ by word: a word with a typo (character similarity >= 0.8) counts by that similarity,
21
+ a different word counts nothing, longer words weigh more, and in a fuzzy quote a
22
+ different word also scales the quote part down. The context is the text before and
23
+ after (it may reach into neighbouring paragraphs, joined by ``"\\n"``), and counts by how
24
+ much of the old context is still there. Position is not scored: it only orders equal
25
+ scores. A candidate in the anchor's own paragraph (same fingerprint) or within
26
+ :data:`NEAR_PARAGRAPHS` of its hint needs :data:`MIN_SCORE`; any other needs
27
+ :data:`FAR_MIN_SCORE`; a short quote needs :data:`SHORT_MIN_SCORE` wherever it is.
28
+ When anything scoring at least :data:`MIN_SCORE` is within :data:`TIE_MARGIN` of the
29
+ best, the anchor is ambiguous and the candidates are reported, unless exactly one of
30
+ them is in a paragraph with the anchor's fingerprint.
31
+
32
+ Offsets are code points into a paragraph's visible text, like every other address. A
33
+ range never starts or ends between a character and the combining marks after it.
34
+ Quotes are limited to one paragraph.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import difflib
40
+ import itertools
41
+ import math
42
+ import re
43
+ import unicodedata
44
+ from collections import Counter
45
+ from dataclasses import dataclass
46
+ from typing import Any, Literal
47
+
48
+ from doc_control.errors import DocControlError, ErrorCode
49
+ from doc_control.model.index import DocumentIndex, Paragraph
50
+ from doc_control.model.segment import DEFAULT_LANGUAGE, split_sentences
51
+
52
+ MIN_SCORE = 0.80
53
+ TIE_MARGIN = 0.02
54
+ MAX_REPORTED = 5
55
+ CONTEXT_CHARS = 32
56
+ MAX_CONTEXT_CHARS = 200
57
+ MAX_QUOTE_CHARS = 1000
58
+ FAR_MIN_SCORE = 0.85 # minimum for a candidate that is not where the anchor came from
59
+ NEAR_PARAGRAPHS = 8 # "where the anchor came from": the hint's paragraph number +- this
60
+ SHORT_QUOTE_WORDS = 3 # quotes this short are never matched fuzzily
61
+ SHORT_MIN_SCORE = 0.95 # and need nearly all of their context to match
62
+ W_QUOTE, W_PREFIX, W_SUFFIX = 0.5, 0.2, 0.2
63
+ TOKEN_MATCH = 0.8 # character similarity at which two words count as the same word, typo aside
64
+
65
+ _FLOOR = 1e-9 # slack so 0.8 computed as 0.7999999999 is still 0.8
66
+ _MAX_FUZZY_PARAGRAPHS = 40
67
+ _NEIGHBOURS = 3 # paragraphs on each side of the hint always searched
68
+ _MAX_WINDOWS = 3 # fuzzy windows taken from one paragraph
69
+ _MIN_WINDOW_RATIO = 0.4
70
+ _WORD = re.compile(r"\w+")
71
+ _GRAM = 5 # characters in a run used to shortlist paragraphs
72
+ _GRAM_SHARE = 0.25 # share of the quote's runs a shortlisted paragraph must hold
73
+
74
+ MatchKind = Literal["exact", "fuzzy"]
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Anchor:
79
+ """The JSON form is :meth:`to_dict`; only ``exact`` is required."""
80
+
81
+ exact: str
82
+ prefix: str = ""
83
+ suffix: str = ""
84
+ hint: int | None = None # paragraph number at creation
85
+ fingerprint: str | None = None # of that paragraph then
86
+ at: str | None = None # the range at creation, informative
87
+ sentence: str | None = None # sentence address at creation, if one was anchored
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ out: dict[str, Any] = {"exact": self.exact, "prefix": self.prefix, "suffix": self.suffix}
91
+ if self.hint is not None:
92
+ out["hint"] = f"p{self.hint}"
93
+ if self.fingerprint is not None:
94
+ out["fingerprint"] = self.fingerprint
95
+ if self.at is not None:
96
+ out["at"] = self.at
97
+ if self.sentence is not None:
98
+ out["sentence"] = self.sentence
99
+ return out
100
+
101
+ @classmethod
102
+ def from_dict(cls, data: dict[str, Any]) -> Anchor:
103
+ hint = data.get("hint")
104
+ number: int | None = None
105
+ if hint is not None:
106
+ m = re.fullmatch(r"p([1-9][0-9]{0,11})", hint)
107
+ if m is None:
108
+ raise DocControlError(
109
+ ErrorCode.SCHEMA_INVALID,
110
+ f"hint {hint!r} is not a paragraph number like p142",
111
+ path="$.anchor.hint",
112
+ )
113
+ number = int(m[1])
114
+ return cls(
115
+ exact=data["exact"],
116
+ prefix=data.get("prefix", ""),
117
+ suffix=data.get("suffix", ""),
118
+ hint=number,
119
+ fingerprint=data.get("fingerprint"),
120
+ at=data.get("at"),
121
+ sentence=data.get("sentence"),
122
+ )
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Candidate:
127
+ """A place the anchor may now be: paragraph ``number`` characters ``[start, end)``."""
128
+
129
+ number: int
130
+ fp: str
131
+ start: int
132
+ end: int
133
+ score: float
134
+ kind: MatchKind
135
+ near: bool = True # in the anchor's own paragraph (by fingerprint) or close to its hint
136
+
137
+ @property
138
+ def threshold(self) -> float:
139
+ return MIN_SCORE if self.near else FAR_MIN_SCORE
140
+
141
+ @property
142
+ def at(self) -> str:
143
+ return f"p{self.number}#{self.fp}:{self.start}-{self.end}"
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ return {"at": self.at, "score": round(self.score, 4)}
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class Resolution:
151
+ """The outcome of :func:`resolve`.
152
+
153
+ ``found``: ``best`` is the answer. ``ambiguous``: ``candidates`` (best first, at most
154
+ :data:`MAX_REPORTED`) are within the tie margin. ``not_found``: ``best`` is the closest
155
+ thing below the minimum score, if anything came close at all.
156
+ """
157
+
158
+ status: Literal["found", "ambiguous", "not_found"]
159
+ best: Candidate | None
160
+ candidates: tuple[Candidate, ...] = ()
161
+
162
+
163
+ def _is_mark(ch: str) -> bool:
164
+ return unicodedata.category(ch).startswith("M")
165
+
166
+
167
+ def _nfc(text: str) -> str:
168
+ return unicodedata.normalize("NFC", text)
169
+
170
+
171
+ def _tokens(text: str) -> list[str]:
172
+ return _WORD.findall(_nfc(text).casefold())
173
+
174
+
175
+ def _token_credit(ta: list[str], tb: list[str]) -> float:
176
+ return _token_credit_pair(ta, tb)[0]
177
+
178
+
179
+ def _token_credit_pair(ta: list[str], tb: list[str]) -> tuple[float, int]:
180
+ """Characters of ``ta`` that ``tb`` matches: whole words fully, a word with a typo
181
+ (character similarity of at least :data:`TOKEN_MATCH`) by that similarity, a different
182
+ word not at all."""
183
+ credit = 0.0
184
+ matched = 0
185
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, ta, tb, autojunk=False).get_opcodes():
186
+ if tag == "equal":
187
+ n = sum(len(t) for t in ta[i1:i2])
188
+ credit += n
189
+ matched += n
190
+ elif tag == "replace":
191
+ for x, y in zip(ta[i1:i2], tb[j1:j2], strict=False):
192
+ r = difflib.SequenceMatcher(None, x, y, autojunk=False).ratio()
193
+ if r >= TOKEN_MATCH:
194
+ credit += r * len(x)
195
+ matched += len(x)
196
+ return credit, matched
197
+
198
+
199
+ def _similarity(a: str, b: str) -> float:
200
+ """How alike two stretches of text are, word by word, in ``[0, 1]``.
201
+
202
+ Longer words weigh more, so changing a content word costs far more than changing
203
+ "la"; a word with a typo still counts. Text without words falls back to character
204
+ similarity.
205
+ """
206
+ if a == b:
207
+ return 1.0
208
+ ta, tb = _tokens(a), _tokens(b)
209
+ if not ta and not tb:
210
+ return difflib.SequenceMatcher(None, _nfc(a), _nfc(b), autojunk=False).ratio()
211
+ if not ta or not tb:
212
+ return 0.0
213
+ total = max(sum(map(len, ta)), sum(map(len, tb)))
214
+ return min(1.0, _token_credit(ta, tb) / total)
215
+
216
+
217
+ def _quote_similarity(quote: str, found: str) -> float:
218
+ """Word-level similarity of a quote to what was found, for fuzzy matches.
219
+
220
+ Typos cost a little. A word that is simply different costs twice: it earns no credit
221
+ and it also scales the whole score down, so a near-copy with one content word changed
222
+ cannot pass for the quote.
223
+ """
224
+ ta, tb = _tokens(quote), _tokens(found)
225
+ if not ta or not tb:
226
+ return _similarity(quote, found)
227
+ credit, matched = _token_credit_pair(ta, tb)
228
+ total = max(sum(map(len, ta)), sum(map(len, tb)))
229
+ return min(1.0, credit / total) * (matched / sum(map(len, ta)))
230
+
231
+
232
+ def _word_edges(text: str, lo: int, hi: int) -> list[tuple[int, int]]:
233
+ """Windows one word wider or narrower than ``text[lo:hi]`` at either end."""
234
+ out: list[tuple[int, int]] = []
235
+ k = lo
236
+ while k > 0 and text[k - 1].isspace():
237
+ k -= 1
238
+ if k > 0:
239
+ while k > 0 and (text[k - 1].isalnum() or _is_mark(text[k - 1])):
240
+ k -= 1
241
+ out.append((k, hi))
242
+ k = hi
243
+ while k < len(text) and text[k].isspace():
244
+ k += 1
245
+ if k < len(text):
246
+ while k < len(text) and (text[k].isalnum() or _is_mark(text[k])):
247
+ k += 1
248
+ out.append((lo, k))
249
+ cut = text.find(" ", lo, hi)
250
+ if cut >= 0:
251
+ out.append((cut + 1, hi))
252
+ cut = text.rfind(" ", lo, hi)
253
+ if cut >= 0:
254
+ out.append((lo, cut))
255
+ return out
256
+
257
+
258
+ def _refine(quote: str, text: str, lo: int, hi: int) -> tuple[int, int]:
259
+ """Move the ends of a window by whole words while that makes it more like the quote."""
260
+ best = _quote_similarity(quote, text[lo:hi])
261
+ for _ in range(6):
262
+ scored = [
263
+ (_quote_similarity(quote, text[a:b]), a, b)
264
+ for a, b in _word_edges(text, lo, hi)
265
+ if "\x00" not in text[a:b] and b > a
266
+ ]
267
+ if not scored:
268
+ break
269
+ top = max(scored)
270
+ if top[0] <= best + 0.005:
271
+ break
272
+ best, lo, hi = top
273
+ return lo, hi
274
+
275
+
276
+ def _context_similarity(anchor_side: str, actual: str, *, before: bool) -> float:
277
+ """How much of the anchor's prefix (``before``) or suffix is found in the text now.
278
+
279
+ ``actual`` is a longer stretch than the anchor's, so words inserted nearby do not push
280
+ the old ones out of view; the outermost word on each side may be cut and is ignored.
281
+ What counts is how much of the old context is still there, word for word.
282
+ """
283
+ ta, tb = _tokens(anchor_side), _tokens(actual)
284
+ if len(ta) > 2:
285
+ ta = ta[1:] if before else ta[:-1]
286
+ if len(tb) > 2:
287
+ tb = tb[1:] if before else tb[:-1]
288
+ total = sum(map(len, ta))
289
+ if total == 0:
290
+ return 1.0
291
+ return min(1.0, _token_credit(ta, tb) / total)
292
+
293
+
294
+ # -- context -----------------------------------------------------------------
295
+
296
+
297
+ def _text_before(index: DocumentIndex, number: int, offset: int, size: int) -> str:
298
+ """Up to ``size`` characters ending at ``offset``, reaching back over earlier paragraphs."""
299
+ if size <= 0:
300
+ return ""
301
+ text = index.paragraphs[number - 1].text
302
+ start = max(0, offset - size)
303
+ while 0 < start < offset and _is_mark(text[start]):
304
+ start -= 1
305
+ out = text[start:offset]
306
+ k = number - 1
307
+ while len(out) < size and k >= 1:
308
+ prev = index.paragraphs[k - 1].text
309
+ room = size - len(out) - 1
310
+ out = (prev[max(0, len(prev) - room) :] if room > 0 else "") + "\n" + out
311
+ k -= 1
312
+ return out[-size:] if len(out) > size else out
313
+
314
+
315
+ def _text_after(index: DocumentIndex, number: int, offset: int, size: int) -> str:
316
+ if size <= 0:
317
+ return ""
318
+ text = index.paragraphs[number - 1].text
319
+ end = min(len(text), offset + size)
320
+ while offset < end < len(text) and _is_mark(text[end]):
321
+ end += 1
322
+ out = text[offset:end]
323
+ k = number + 1
324
+ while len(out) < size and k <= len(index):
325
+ nxt = index.paragraphs[k - 1].text
326
+ room = size - len(out) - 1
327
+ out = out + "\n" + (nxt[:room] if room > 0 else "")
328
+ k += 1
329
+ return out[:size] if len(out) > size else out
330
+
331
+
332
+ # -- create ------------------------------------------------------------------
333
+
334
+
335
+ def _snap(text: str, start: int, end: int) -> tuple[int, int]:
336
+ """Widen ``[start, end)`` so it neither starts on nor ends before a combining mark."""
337
+ while 0 < start < len(text) and _is_mark(text[start]):
338
+ start -= 1
339
+ while 0 < end < len(text) and _is_mark(text[end]):
340
+ end += 1
341
+ return start, end
342
+
343
+
344
+ def create(
345
+ index: DocumentIndex,
346
+ at: str,
347
+ context: int = CONTEXT_CHARS,
348
+ language: str = DEFAULT_LANGUAGE,
349
+ ) -> Anchor:
350
+ """The anchor for the range ``at`` (any address form), which must lie in one paragraph."""
351
+ resolved = index.resolve_range(at, language)
352
+ if resolved.first is not resolved.last:
353
+ raise DocControlError(
354
+ ErrorCode.SCHEMA_INVALID,
355
+ "an anchor covers one paragraph; anchor each paragraph of the range separately",
356
+ path="$.at",
357
+ )
358
+ para = resolved.first
359
+ start, end = _snap(para.text, resolved.start_offset or 0, resolved.end_offset or 0)
360
+ quote = para.text[start:end]
361
+ if not quote.strip():
362
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "cannot anchor empty text", path="$.at")
363
+ if len(quote) > MAX_QUOTE_CHARS:
364
+ raise DocControlError(
365
+ ErrorCode.SCHEMA_INVALID,
366
+ f"quote is {len(quote)} characters; an anchor holds at most {MAX_QUOTE_CHARS}",
367
+ path="$.at",
368
+ )
369
+ sentence = None
370
+ if resolved.sentence is not None:
371
+ sentence = f"{para.address}.s{resolved.sentence}"
372
+ return Anchor(
373
+ exact=quote,
374
+ prefix=_text_before(index, para.number, start, context),
375
+ suffix=_text_after(index, para.number, end, context),
376
+ hint=para.number,
377
+ fingerprint=para.fp,
378
+ at=f"{para.address}:{start}-{end}",
379
+ sentence=sentence,
380
+ )
381
+
382
+
383
+ # -- resolve -----------------------------------------------------------------
384
+
385
+
386
+ def _candidate(
387
+ index: DocumentIndex,
388
+ anchor: Anchor,
389
+ para: Paragraph,
390
+ start: int,
391
+ end: int,
392
+ quote_score: float,
393
+ kind: MatchKind,
394
+ ) -> Candidate:
395
+ near = (para.fp == anchor.fingerprint) or (
396
+ anchor.hint is not None and abs(para.number - anchor.hint) <= NEAR_PARAGRAPHS
397
+ )
398
+ wide = len(anchor.prefix) * 3 // 2 + 1, len(anchor.suffix) * 3 // 2 + 1
399
+ prefix = (
400
+ _context_similarity(
401
+ anchor.prefix, _text_before(index, para.number, start, wide[0]), before=True
402
+ )
403
+ if anchor.prefix
404
+ else 1.0
405
+ )
406
+ suffix = (
407
+ _context_similarity(
408
+ anchor.suffix, _text_after(index, para.number, end, wide[1]), before=False
409
+ )
410
+ if anchor.suffix
411
+ else 1.0
412
+ )
413
+ # Normalised to [0, 1]; position is not part of it, it only orders equal scores.
414
+ score = (W_QUOTE * quote_score + W_PREFIX * prefix + W_SUFFIX * suffix) / (
415
+ W_QUOTE + W_PREFIX + W_SUFFIX
416
+ )
417
+ return Candidate(para.number, para.fp, start, end, score, kind, near)
418
+
419
+
420
+ def _exact_spans(text: str, quote: str) -> list[tuple[int, int]]:
421
+ """Verbatim occurrences of ``quote`` that do not cut a character from its marks."""
422
+ out: list[tuple[int, int]] = []
423
+ i = text.find(quote)
424
+ while i >= 0:
425
+ j = i + len(quote)
426
+ if not (j < len(text) and _is_mark(text[j])) and not (i > 0 and _is_mark(text[i])):
427
+ out.append((i, j))
428
+ i = text.find(quote, i + 1)
429
+ return out
430
+
431
+
432
+ def resolve(index: DocumentIndex, anchor: Anchor, *, exact_only: bool = False) -> Resolution:
433
+ """Find ``anchor`` in ``index``.
434
+
435
+ With ``exact_only`` (``anchor.resolve`` ``mode: "exact"``) the fuzzy search is skipped
436
+ and every verbatim occurrence of the quote is accepted whatever its context score: the
437
+ score only breaks ties, and several occurrences about equally good are ambiguous.
438
+ """
439
+ quote = anchor.exact
440
+ found: list[Candidate] = []
441
+ taken: dict[int, list[tuple[int, int]]] = {}
442
+ for para in index.paragraphs:
443
+ if len(para.text) < len(quote):
444
+ continue
445
+ for start, end in _exact_spans(para.text, quote):
446
+ taken.setdefault(para.number, []).append((start, end))
447
+ found.append(_candidate(index, anchor, para, start, end, 1.0, "exact"))
448
+ short = len(_WORD.findall(quote)) <= SHORT_QUOTE_WORDS
449
+
450
+ def accepted_of(pool: list[Candidate]) -> list[Candidate]:
451
+ # A short quote has too little text to prove a match: it needs the stricter bar
452
+ # even where it came from.
453
+ if exact_only:
454
+ return list(pool)
455
+ return [c for c in pool if c.score + _FLOOR >= (SHORT_MIN_SCORE if short else c.threshold)]
456
+
457
+ accepted = accepted_of(found)
458
+ if not accepted and not exact_only and not short:
459
+ found += _fuzzy(index, anchor, taken)
460
+ accepted = accepted_of(found)
461
+ order = anchor.hint or 0
462
+
463
+ def key(c: Candidate) -> tuple[float, int, int, int]:
464
+ # Position only orders results that score the same; it never lifts a score.
465
+ return (-round(c.score, 6), abs(c.number - order), c.number, c.start)
466
+
467
+ ranked = sorted(found, key=key)
468
+ if not accepted:
469
+ return Resolution("not_found", ranked[0] if ranked else None)
470
+ accepted.sort(key=key)
471
+ best = accepted[0]
472
+ # Everything that scores nearly as well counts, accepted or not: a runner-up the
473
+ # stricter bar rejected still shows the passage is not unique.
474
+ tied = [
475
+ c
476
+ for c in sorted(found, key=key)
477
+ if (exact_only or c.score + _FLOOR >= MIN_SCORE)
478
+ and best.score - c.score <= TIE_MARGIN + _FLOOR
479
+ ]
480
+ if len(tied) > 1:
481
+ # The one paragraph that still has the anchor's own fingerprint is where it came from.
482
+ own = [c for c in tied if c.fp == anchor.fingerprint]
483
+ if len(own) == 1 and own[0] in accepted:
484
+ return Resolution("found", own[0])
485
+ return Resolution("ambiguous", None, tuple(tied[:MAX_REPORTED]))
486
+ return Resolution("found", best)
487
+
488
+
489
+ def _fuzzy_paragraphs(index: DocumentIndex, anchor: Anchor) -> list[Paragraph]:
490
+ """The paragraphs worth a fuzzy comparison, in a stable order."""
491
+ chosen: dict[int, Paragraph] = {}
492
+ if anchor.hint is not None:
493
+ for n in range(anchor.hint - _NEIGHBOURS, anchor.hint + _NEIGHBOURS + 1):
494
+ if 1 <= n <= len(index):
495
+ chosen[n] = index.paragraphs[n - 1]
496
+ if anchor.fingerprint is not None:
497
+ for para in index.paragraphs:
498
+ if para.fp == anchor.fingerprint:
499
+ chosen[para.number] = para
500
+ # Character runs of the quote that survive a typo: the paragraphs holding the most of
501
+ # them (rare runs counting more) are the likeliest homes of an edited quote.
502
+ quote = unicodedata.normalize("NFC", anchor.exact)
503
+ size = min(_GRAM, len(quote))
504
+ wanted = {quote[i : i + size] for i in range(len(quote) - size + 1)}
505
+ wanted = {g for g in wanted if g.strip()}
506
+ if wanted:
507
+ need = max(1, math.ceil(len(wanted) * _GRAM_SHARE))
508
+ shared: list[tuple[Paragraph, list[str]]] = []
509
+ df: Counter[str] = Counter()
510
+ for para in index.paragraphs:
511
+ if len(para.text) < size:
512
+ continue
513
+ common = [g for g in wanted if g in para.text]
514
+ if len(common) >= need:
515
+ shared.append((para, common))
516
+ df.update(common)
517
+ hint = anchor.hint or 0
518
+
519
+ def rank(item: tuple[Paragraph, list[str]]) -> tuple[float, int, int]:
520
+ para, common = item
521
+ weight = sum(1.0 / df[g] for g in common)
522
+ return (-weight, abs(para.number - hint), para.number)
523
+
524
+ for para, _ in sorted(shared, key=rank)[:_MAX_FUZZY_PARAGRAPHS]:
525
+ chosen[para.number] = para
526
+ return [chosen[n] for n in sorted(chosen)]
527
+
528
+
529
+ def _fuzzy(
530
+ index: DocumentIndex, anchor: Anchor, taken: dict[int, list[tuple[int, int]]]
531
+ ) -> list[Candidate]:
532
+ out: list[Candidate] = []
533
+ quote = anchor.exact
534
+ for para in _fuzzy_paragraphs(index, anchor):
535
+ text = para.text
536
+ for s, e in taken.get(para.number, ()):
537
+ text = text[:s] + "\x00" * (e - s) + text[e:]
538
+ for _ in range(_MAX_WINDOWS):
539
+ window = _best_window(quote, text)
540
+ if window is None:
541
+ break
542
+ start, end, _ = window
543
+ lo, hi = start, end
544
+ while lo > 0 and (text[lo - 1].isalnum() or _is_mark(text[lo - 1])):
545
+ lo -= 1
546
+ while hi < len(text) and (text[hi].isalnum() or _is_mark(text[hi])):
547
+ hi += 1
548
+ if "\x00" in text[lo:hi]:
549
+ lo, hi = start, end
550
+ lo, hi = _refine(quote, text, lo, hi)
551
+ quote_score = _quote_similarity(quote, text[lo:hi])
552
+ out.append(_candidate(index, anchor, para, lo, hi, quote_score, "fuzzy"))
553
+ text = text[:start] + "\x00" * (end - start) + text[end:]
554
+ return out
555
+
556
+
557
+ def _best_window(quote: str, text: str) -> tuple[int, int, float] | None:
558
+ """The stretch of ``text`` most like ``quote``, with its similarity, or ``None``."""
559
+ m = len(quote)
560
+ if m == 0 or len(text) == 0:
561
+ return None
562
+ matcher = difflib.SequenceMatcher(None, text, quote, autojunk=False)
563
+ floor = max(1, min(3, m // 2))
564
+ blocks = [b for b in matcher.get_matching_blocks() if b.size >= floor]
565
+ if not blocks:
566
+ return None
567
+ # Group blocks that sit where the quote would: the text between two blocks may be
568
+ # longer than the quote between them (an insertion), but only by a bounded amount.
569
+ slack = max(10, m // 4)
570
+ groups: list[list[difflib.Match]] = [[blocks[0]]]
571
+ for prev, cur in itertools.pairwise(blocks):
572
+ gap_text = cur.a - (prev.a + prev.size)
573
+ gap_quote = cur.b - (prev.b + prev.size)
574
+ if gap_text <= gap_quote + slack:
575
+ groups[-1].append(cur)
576
+ else:
577
+ groups.append([cur])
578
+ group = max(groups, key=lambda g: sum(b.size for b in g))
579
+ first, last = group[0], group[-1]
580
+ starts = {max(0, first.a - first.b), first.a}
581
+ ends = {min(len(text), last.a + last.size + (m - last.b - last.size)), last.a + last.size}
582
+ best: tuple[int, int, float] | None = None
583
+ for start in sorted(starts):
584
+ for end in sorted(ends, reverse=True):
585
+ if end <= start:
586
+ continue
587
+ s, e = _snap(text, start, end)
588
+ piece = text[s:e]
589
+ if "\x00" in piece:
590
+ continue
591
+ ratio = difflib.SequenceMatcher(None, _nfc(quote), _nfc(piece), autojunk=False).ratio()
592
+ if best is None or ratio > best[2] + 0.005:
593
+ best = (s, e, ratio)
594
+ if best is None or best[2] < _MIN_WINDOW_RATIO:
595
+ return None
596
+ return best
597
+
598
+
599
+ def sentence_of(
600
+ index: DocumentIndex, candidate: Candidate, language: str = DEFAULT_LANGUAGE
601
+ ) -> str:
602
+ """The address of the sentence that holds the start of ``candidate``."""
603
+ para = index.paragraphs[candidate.number - 1]
604
+ spans = split_sentences(para.text, language)
605
+ k = 1
606
+ for i, span in enumerate(spans, 1):
607
+ if span.start <= candidate.start:
608
+ k = i
609
+ return f"{para.address}.s{k}"