data-olympus 0.3.0__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 (73) hide show
  1. data_olympus/__init__.py +14 -0
  2. data_olympus/_bin/_kb_detect_workspace.sh +57 -0
  3. data_olympus/_bin/_kb_enforce.py +619 -0
  4. data_olympus/_bin/_kb_fallback.py +361 -0
  5. data_olympus/_bin/kb +957 -0
  6. data_olympus/_bin/kb-enforce-hook +337 -0
  7. data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
  8. data_olympus/audit_log.py +275 -0
  9. data_olympus/audit_trailers.py +42 -0
  10. data_olympus/auth.py +139 -0
  11. data_olympus/cli/__init__.py +1 -0
  12. data_olympus/cli/import_cmd.py +115 -0
  13. data_olympus/cli/indexgen.py +60 -0
  14. data_olympus/cli/main.py +151 -0
  15. data_olympus/cli/report_cmd.py +181 -0
  16. data_olympus/config.py +261 -0
  17. data_olympus/cooccurrence.py +393 -0
  18. data_olympus/dedup.py +57 -0
  19. data_olympus/durable.py +51 -0
  20. data_olympus/embeddings.py +317 -0
  21. data_olympus/enforce_policy.py +297 -0
  22. data_olympus/format/__init__.py +16 -0
  23. data_olympus/format/document.py +39 -0
  24. data_olympus/format/frontmatter.py +35 -0
  25. data_olympus/format/lint.py +92 -0
  26. data_olympus/format/validate.py +71 -0
  27. data_olympus/git_ops.py +397 -0
  28. data_olympus/health.py +114 -0
  29. data_olympus/importer/__init__.py +13 -0
  30. data_olympus/importer/adr.py +286 -0
  31. data_olympus/importer/flat.py +170 -0
  32. data_olympus/importer/model.py +106 -0
  33. data_olympus/importer/okf.py +192 -0
  34. data_olympus/importer/run.py +416 -0
  35. data_olympus/importer/stamp.py +227 -0
  36. data_olympus/index.py +1745 -0
  37. data_olympus/markdown_parse.py +103 -0
  38. data_olympus/models.py +480 -0
  39. data_olympus/onboarding.py +131 -0
  40. data_olympus/onboarding_inflight.py +137 -0
  41. data_olympus/onboarding_playbook.py +99 -0
  42. data_olympus/pending.py +533 -0
  43. data_olympus/principals.py +168 -0
  44. data_olympus/prompts.py +35 -0
  45. data_olympus/push_queue.py +261 -0
  46. data_olympus/query_expansion.py +200 -0
  47. data_olympus/rate_limit.py +81 -0
  48. data_olympus/refresh.py +329 -0
  49. data_olympus/report.py +133 -0
  50. data_olympus/rest_api.py +845 -0
  51. data_olympus/safe_id.py +36 -0
  52. data_olympus/search_gate.py +64 -0
  53. data_olympus/search_shortcut.py +146 -0
  54. data_olympus/server.py +1115 -0
  55. data_olympus/session_metrics.py +303 -0
  56. data_olympus/setup_wizard.py +751 -0
  57. data_olympus/thin_pointer.py +20 -0
  58. data_olympus/tools_audit.py +41 -0
  59. data_olympus/tools_enforce.py +198 -0
  60. data_olympus/tools_onboarding.py +585 -0
  61. data_olympus/tools_read.py +230 -0
  62. data_olympus/tools_write.py +878 -0
  63. data_olympus/trigram.py +126 -0
  64. data_olympus/viewer/__init__.py +1 -0
  65. data_olympus/viewer/generator.py +375 -0
  66. data_olympus/worktrees.py +147 -0
  67. data_olympus/write_gate.py +382 -0
  68. data_olympus-0.3.0.dist-info/METADATA +97 -0
  69. data_olympus-0.3.0.dist-info/RECORD +73 -0
  70. data_olympus-0.3.0.dist-info/WHEEL +4 -0
  71. data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
  72. data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. data_olympus-0.3.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,393 @@
1
+ """Corpus co-occurrence query expansion (issue #40).
2
+
3
+ Embedding-free semantic broadening. At index-build time we learn, for each
4
+ reasonable term in the corpus, the handful of other terms it most strongly
5
+ co-occurs with (measured by pointwise mutual information, PMI, at document
6
+ granularity) and store a small bounded ``related_terms`` table alongside the
7
+ FTS index. At query time an expander looks each query term up in that table and
8
+ appends its top related terms. Those appended terms are DOWN-WEIGHTED not by
9
+ their position (FTS5 bm25 has no positional preference) but because
10
+ ``Index.search`` matches the expansion terms in a SEPARATE penalized backfill
11
+ pass whose hits can only rank BELOW the worst primary-term hit (finding (a); see
12
+ ``Index._expansion_backfill``). So a doc that matches only a corpus-related term
13
+ can never outrank a doc matching a term the user actually typed.
14
+
15
+ Why PMI over raw co-occurrence counts: raw counts are dominated by globally
16
+ frequent tokens (every doc mentions "the", "a", "you"), so a raw-count table
17
+ would relate every term to the same stopword-ish set. PMI normalises by the
18
+ marginal frequencies, surfacing terms that co-occur *more than chance* -- the
19
+ signal we want. Stopword-like and very short tokens are dropped up front so
20
+ they neither pollute the table nor consume a related-term slot.
21
+
22
+ Design constraints (see issue #40):
23
+ - The table is built as part of ``Index.build`` into the same tmp DB and swapped
24
+ atomically, so a query never sees a half-built table.
25
+ - Hard-bounded: top-k related terms per term (``k`` small, default 5) above a
26
+ minimum co-occurrence count and PMI threshold; only "reasonable" tokens (min
27
+ length, not a stopword, alphabetic-ish) are considered as heads or tails.
28
+ - Build cost stays negligible at the current corpus scale: a single pass to
29
+ build per-doc token sets, then pair counting restricted to the kept
30
+ vocabulary.
31
+ - Composes WITH the synonym expander (issue #38) rather than replacing it; see
32
+ ``compose_expanders``.
33
+ - Configuration follows ``config.py``: ``KB_COOCCURRENCE_MODE`` (on/off),
34
+ ``KB_COOCCURRENCE_K``, ``KB_COOCCURRENCE_MIN_PMI``, ``KB_COOCCURRENCE_MIN_COUNT``.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import math
39
+ import os
40
+ import re
41
+ from collections import Counter
42
+ from typing import TYPE_CHECKING
43
+
44
+ if TYPE_CHECKING:
45
+ import sqlite3
46
+ from collections.abc import Callable, Iterable
47
+
48
+ # Token = a run of letters (with internal digits allowed after the first letter,
49
+ # so "k8s"/"utf8" survive) of at least MIN_TOKEN_LEN characters. Pure numbers and
50
+ # punctuation-only runs are excluded. Matching is case-insensitive; tokens are
51
+ # lower-cased.
52
+ _TOKEN_RE = re.compile(r"[a-z][a-z0-9]{2,}")
53
+
54
+ # Minimum token length to be a co-occurrence head or tail. Short tokens ("is",
55
+ # "to", "db") carry little topical signal and already match literally without
56
+ # expansion, so they are skipped to keep the table focused.
57
+ MIN_TOKEN_LEN = 3
58
+
59
+ # A compact, deployment-neutral English stopword set plus a few markdown/tech
60
+ # filler words. Kept intentionally small: PMI already suppresses ubiquitous
61
+ # tokens, this list just avoids wasting related-term slots on obvious noise.
62
+ _STOPWORDS: frozenset[str] = frozenset({
63
+ "the", "and", "for", "are", "but", "not", "you", "all", "any", "can", "had",
64
+ "her", "was", "one", "our", "out", "day", "get", "has", "him", "his", "how",
65
+ "man", "new", "now", "old", "see", "two", "way", "who", "did", "its", "let",
66
+ "put", "say", "she", "too", "use", "this", "that", "with", "from", "they",
67
+ "will", "would", "there", "their", "what", "which", "when", "where", "while",
68
+ "about", "into", "your", "only", "other", "than", "then", "them", "these",
69
+ "those", "been", "being", "have", "also", "such", "very", "much", "more",
70
+ "most", "some", "many", "each", "both", "few", "own", "same", "over", "under",
71
+ "above", "below", "after", "before", "between", "because", "during",
72
+ "through", "against", "doc", "docs", "note", "notes", "via", "etc", "within",
73
+ "without", "upon", "per",
74
+ })
75
+
76
+
77
+ def is_reasonable_token(token: str) -> bool:
78
+ """True if ``token`` is a plausible co-occurrence head/tail.
79
+
80
+ Reasonable = at least ``MIN_TOKEN_LEN`` chars, matches the token shape (a
81
+ letter then letters/digits), and is not a stopword. The token is assumed to
82
+ already be lower-cased.
83
+ """
84
+ if len(token) < MIN_TOKEN_LEN:
85
+ return False
86
+ if token in _STOPWORDS:
87
+ return False
88
+ return bool(_TOKEN_RE.fullmatch(token))
89
+
90
+
91
+ def tokenize_doc(*parts: str) -> set[str]:
92
+ """Return the SET of reasonable tokens in the given text parts.
93
+
94
+ A set (not a bag) because co-occurrence is measured at document granularity:
95
+ whether a term appears in a document, not how many times. Deduping per-doc
96
+ keeps a term that is repeated many times in one doc from dominating counts.
97
+ """
98
+ tokens: set[str] = set()
99
+ for part in parts:
100
+ for m in _TOKEN_RE.finditer(part.lower()):
101
+ tok = m.group(0)
102
+ if tok not in _STOPWORDS:
103
+ tokens.add(tok)
104
+ return tokens
105
+
106
+
107
+ # Defaults for the bounded table. Kept small so both build cost and query-time
108
+ # MATCH growth stay negligible.
109
+ #
110
+ # min_count=3 / min_pmi>0 (finding (b)): at min_count=2 / min_pmi=0 a single pair
111
+ # of docs sharing any two tokens produces a "related" edge, which on a small
112
+ # corpus is noise, not signal. Requiring a pair to co-occur in at least 3 docs
113
+ # and to have STRICTLY positive PMI (co-occur MORE than chance) keeps only edges
114
+ # with real corpus support.
115
+ DEFAULT_K = 5
116
+ DEFAULT_MIN_COUNT = 3
117
+ DEFAULT_MIN_PMI = 0.1
118
+
119
+ # Corpus-size floor below which co-occurrence expansion is auto-disabled (finding
120
+ # (b)). PMI is a ratio estimate; on a handful of docs the marginal counts are too
121
+ # small for it to mean anything, so we skip building the table entirely rather
122
+ # than emit noise. A deployment can lower it via KB_COOCCURRENCE_MIN_DOCS.
123
+ DEFAULT_MIN_DOCS = 50
124
+
125
+ # Cap on the number of unique tokens from one document fed into pair counting
126
+ # (finding (b)). Pair counting is O(unique-tokens^2) per doc; a multi-thousand-
127
+ # word doc with thousands of unique tokens is a build-time memory/CPU cliff. The
128
+ # most topical tokens are kept (longest first, then alphabetical for determinism)
129
+ # so the cap trims the long tail of incidental vocabulary, not the signal.
130
+ DEFAULT_MAX_DOC_TOKENS = 400
131
+
132
+
133
+ def build_cooccurrence_table(
134
+ doc_token_sets: Iterable[set[str]],
135
+ *,
136
+ k: int = DEFAULT_K,
137
+ min_count: int = DEFAULT_MIN_COUNT,
138
+ min_pmi: float = DEFAULT_MIN_PMI,
139
+ min_docs: int = DEFAULT_MIN_DOCS,
140
+ max_doc_tokens: int = DEFAULT_MAX_DOC_TOKENS,
141
+ ) -> dict[str, list[str]]:
142
+ """Compute a bounded ``{term: [related...]}`` table from per-doc token sets.
143
+
144
+ For each unordered pair (a, b) co-occurring in at least ``min_count``
145
+ documents, the pointwise mutual information is::
146
+
147
+ pmi(a, b) = log( P(a, b) / (P(a) * P(b)) )
148
+ = log( (c_ab * N) / (c_a * c_b) )
149
+
150
+ where ``N`` is the document count, ``c_a``/``c_b`` the per-term document
151
+ counts and ``c_ab`` the co-occurrence count. Pairs with ``pmi < min_pmi``
152
+ are dropped. For each term the top-``k`` partners by PMI (ties broken by
153
+ co-occurrence count, then alphabetically for determinism) are kept.
154
+
155
+ The result is directional in storage (both ``a -> b`` and ``b -> a`` are
156
+ emitted, each capped independently at k) so a lookup of either term finds the
157
+ other. Returns an order-stable dict; each value is at most ``k`` long.
158
+
159
+ Returns the empty table (co-occurrence auto-disabled) when the corpus has
160
+ fewer than ``min_docs`` documents (finding (b)): PMI is a ratio estimate that
161
+ is pure noise on a handful of docs. Each document contributes at most
162
+ ``max_doc_tokens`` unique tokens to pair counting (finding (b)); pair counting
163
+ is O(unique-tokens^2) per doc, so an unbounded huge doc is a build-time cliff.
164
+ The kept tokens are the longest ones (most topical), tie-broken alphabetically.
165
+ """
166
+ materialised = [s for s in doc_token_sets if s]
167
+ n_docs = len(materialised)
168
+ # Corpus-size floor: below it PMI is noise, so skip building entirely.
169
+ if n_docs < max(2, min_docs) or k <= 0:
170
+ return {}
171
+
172
+ term_counts: Counter[str] = Counter()
173
+ pair_counts: Counter[tuple[str, str]] = Counter()
174
+ for tokens in materialised:
175
+ ordered = sorted(tokens)
176
+ # Cap the per-doc unique-token count fed into O(n^2) pair counting. Keep
177
+ # the longest tokens (most topical), tie-broken alphabetically so the
178
+ # trim is deterministic, then re-sort alphabetically for stable pairing.
179
+ if max_doc_tokens > 0 and len(ordered) > max_doc_tokens:
180
+ ordered = sorted(
181
+ sorted(ordered, key=lambda t: (-len(t), t))[:max_doc_tokens]
182
+ )
183
+ for tok in ordered:
184
+ term_counts[tok] += 1
185
+ for i, a in enumerate(ordered):
186
+ for b in ordered[i + 1 :]:
187
+ pair_counts[(a, b)] += 1
188
+
189
+ # candidates[term] = list of (pmi, count, other)
190
+ candidates: dict[str, list[tuple[float, int, str]]] = {}
191
+ for (a, b), c_ab in pair_counts.items():
192
+ if c_ab < min_count:
193
+ continue
194
+ c_a = term_counts[a]
195
+ c_b = term_counts[b]
196
+ pmi = math.log((c_ab * n_docs) / (c_a * c_b))
197
+ if pmi < min_pmi:
198
+ continue
199
+ candidates.setdefault(a, []).append((pmi, c_ab, b))
200
+ candidates.setdefault(b, []).append((pmi, c_ab, a))
201
+
202
+ table: dict[str, list[str]] = {}
203
+ for term, partners in candidates.items():
204
+ # Sort by PMI desc, then count desc, then partner asc (deterministic).
205
+ partners.sort(key=lambda t: (-t[0], -t[1], t[2]))
206
+ table[term] = [other for _pmi, _c, other in partners[:k]]
207
+ return table
208
+
209
+
210
+ # --- SQLite persistence (built inside Index.build, read at query time) --------
211
+
212
+ RELATED_TERMS_SCHEMA = """
213
+ CREATE TABLE IF NOT EXISTS related_terms (
214
+ term TEXT NOT NULL,
215
+ related TEXT NOT NULL,
216
+ rank INTEGER NOT NULL,
217
+ PRIMARY KEY (term, related)
218
+ );
219
+ CREATE INDEX IF NOT EXISTS idx_related_terms_term ON related_terms (term);
220
+ """
221
+
222
+
223
+ def write_cooccurrence_table(
224
+ conn: sqlite3.Connection, table: dict[str, list[str]],
225
+ ) -> None:
226
+ """Populate the ``related_terms`` table on an open (tmp-build) connection.
227
+
228
+ Called from ``Index.build`` against the tmp DB before the atomic swap, so
229
+ the table is part of the same build and never seen half-populated. ``rank``
230
+ (0-based) preserves the PMI ordering so the query-time lookup can keep the
231
+ strongest partners first.
232
+ """
233
+ rows = [
234
+ (term, related, rank)
235
+ for term, partners in table.items()
236
+ for rank, related in enumerate(partners)
237
+ ]
238
+ if rows:
239
+ conn.executemany(
240
+ "INSERT OR REPLACE INTO related_terms (term, related, rank) "
241
+ "VALUES (?, ?, ?)",
242
+ rows,
243
+ )
244
+
245
+
246
+ def lookup_related_terms(
247
+ conn: sqlite3.Connection, term: str, *, limit: int,
248
+ ) -> list[str]:
249
+ """Return up to ``limit`` related terms for ``term``, strongest first.
250
+
251
+ Reads the ``related_terms`` table; ordered by the stored ``rank`` so the
252
+ highest-PMI partners come first. An unknown term (or a build with no table)
253
+ yields the empty list.
254
+ """
255
+ if limit <= 0:
256
+ return []
257
+ rows = conn.execute(
258
+ "SELECT related FROM related_terms WHERE term = ? ORDER BY rank LIMIT ?",
259
+ (term.lower(), limit),
260
+ ).fetchall()
261
+ return [r[0] for r in rows]
262
+
263
+
264
+ # --- query-time expander ------------------------------------------------------
265
+
266
+ # Upper bound on the expanded term list, matching the synonym expander's cap so a
267
+ # composed chain stays bounded regardless of how many related terms exist.
268
+ DEFAULT_MAX_TERMS = 32
269
+
270
+
271
+ def make_cooccurrence_expander(
272
+ lookup: Callable[[str, int], list[str]],
273
+ *,
274
+ k: int = DEFAULT_K,
275
+ max_terms: int = DEFAULT_MAX_TERMS,
276
+ ) -> Callable[[list[str]], list[str]]:
277
+ """Build a ``query_expander`` closure backed by a related-terms lookup.
278
+
279
+ ``lookup(term, k)`` returns the related terms for ``term`` (typically bound
280
+ to the live index DB via ``Index``). The returned callable keeps the original
281
+ terms first (order-stable, de-duplicated) and then appends related terms
282
+ (also de-duplicated, bounded by ``max_terms``). Originals are never dropped
283
+ by the cap. The appended related terms are down-weighted by ``Index.search``
284
+ matching them in a separate penalized backfill pass (they can only rank below
285
+ the primary hits); the "first" positioning here is only so ``Index.search``
286
+ can split originals from expansion terms, NOT a bm25 positional effect.
287
+ """
288
+
289
+ def expander(terms: list[str]) -> list[str]:
290
+ out: list[str] = []
291
+ seen: set[str] = set()
292
+ for t in terms:
293
+ low = t.lower()
294
+ if low not in seen:
295
+ out.append(t)
296
+ seen.add(low)
297
+ # Append related terms for each ORIGINAL term (not the appended ones, to
298
+ # avoid a second-order expansion blow-up).
299
+ for t in terms:
300
+ if len(out) >= max_terms:
301
+ break
302
+ for related in lookup(t.lower(), k):
303
+ if len(out) >= max_terms:
304
+ break
305
+ if related not in seen:
306
+ out.append(related)
307
+ seen.add(related)
308
+ return out
309
+
310
+ return expander
311
+
312
+
313
+ # --- composition --------------------------------------------------------------
314
+
315
+
316
+ def compose_expanders(
317
+ *expanders: Callable[[list[str]], list[str]] | None,
318
+ max_terms: int = DEFAULT_MAX_TERMS,
319
+ ) -> Callable[[list[str]], list[str]] | None:
320
+ """Chain query expanders left-to-right, feeding each output into the next.
321
+
322
+ ``None`` expanders are skipped. Returns ``None`` when nothing is left (so the
323
+ caller can treat "no expansion" uniformly). The composed output is
324
+ de-duplicated (case-insensitively, order-stable) and bounded by ``max_terms``
325
+ as a final safety net, so composing two bounded expanders can never exceed
326
+ the cap. Order matters: pass the synonym expander first, then the
327
+ co-occurrence expander, so co-occurrence broadens the synonym-expanded set.
328
+ """
329
+ active = [e for e in expanders if e is not None]
330
+ if not active:
331
+ return None
332
+
333
+ def composed(terms: list[str]) -> list[str]:
334
+ current = terms
335
+ for expander in active:
336
+ current = expander(current)
337
+ # Final de-dup + cap (case-insensitive, order-stable).
338
+ out: list[str] = []
339
+ seen: set[str] = set()
340
+ for t in current:
341
+ low = t.lower()
342
+ if low not in seen:
343
+ out.append(t)
344
+ seen.add(low)
345
+ if len(out) >= max_terms:
346
+ break
347
+ return out
348
+
349
+ return composed
350
+
351
+
352
+ # --- env configuration --------------------------------------------------------
353
+
354
+
355
+ def _env_int(name: str, default: int) -> int:
356
+ raw = os.getenv(name, "").strip()
357
+ if not raw:
358
+ return default
359
+ return int(raw)
360
+
361
+
362
+ def _env_float(name: str, default: float) -> float:
363
+ raw = os.getenv(name, "").strip()
364
+ if not raw:
365
+ return default
366
+ return float(raw)
367
+
368
+
369
+ def cooccurrence_enabled() -> bool:
370
+ """Whether co-occurrence expansion is active. Default ON.
371
+
372
+ ``KB_COOCCURRENCE_MODE=off`` disables it (both the build-time table and the
373
+ query-time expander); any other value (including unset) leaves it on.
374
+ """
375
+ return os.getenv("KB_COOCCURRENCE_MODE", "on").strip().lower() != "off"
376
+
377
+
378
+ def cooccurrence_build_params() -> dict[str, int | float]:
379
+ """Build-time knobs from env: k, min_count, min_pmi, min_docs, max_doc_tokens.
380
+
381
+ All have sane defaults (see the module constants). ``min_docs`` is the corpus
382
+ floor below which the table is auto-disabled; ``max_doc_tokens`` caps the
383
+ per-doc unique tokens fed into O(n^2) pair counting (finding (b)).
384
+ """
385
+ return {
386
+ "k": _env_int("KB_COOCCURRENCE_K", DEFAULT_K),
387
+ "min_count": _env_int("KB_COOCCURRENCE_MIN_COUNT", DEFAULT_MIN_COUNT),
388
+ "min_pmi": _env_float("KB_COOCCURRENCE_MIN_PMI", DEFAULT_MIN_PMI),
389
+ "min_docs": _env_int("KB_COOCCURRENCE_MIN_DOCS", DEFAULT_MIN_DOCS),
390
+ "max_doc_tokens": _env_int(
391
+ "KB_COOCCURRENCE_MAX_DOC_TOKENS", DEFAULT_MAX_DOC_TOKENS
392
+ ),
393
+ }
data_olympus/dedup.py ADDED
@@ -0,0 +1,57 @@
1
+ """Deterministic repo<->KB overlap detection. No LLM: exact-hash for duplicates,
2
+ heading-set + k-shingle Jaccard for partial overlap. Ambiguous partials are left
3
+ for the calling agent to judge (escalation path)."""
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import re
8
+
9
+ _FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
10
+ _HEADING_RE = re.compile(r"^#{1,6}\s+(.*\S)\s*$", re.MULTILINE)
11
+ _WS_RE = re.compile(r"\s+")
12
+
13
+
14
+ def strip_frontmatter(text: str) -> str:
15
+ return _FRONTMATTER_RE.sub("", text, count=1)
16
+
17
+
18
+ def normalize_markdown(text: str) -> str:
19
+ body = strip_frontmatter(text)
20
+ return _WS_RE.sub(" ", body).strip().lower()
21
+
22
+
23
+ def content_hash(text: str) -> str:
24
+ return hashlib.sha256(normalize_markdown(text).encode("utf-8")).hexdigest()
25
+
26
+
27
+ def extract_headings(text: str) -> set[str]:
28
+ return {m.strip().lower() for m in _HEADING_RE.findall(text)}
29
+
30
+
31
+ def shingles(text: str, k: int = 5) -> set[str]:
32
+ words = normalize_markdown(text).split()
33
+ if not words:
34
+ return set()
35
+ if len(words) < k:
36
+ return {" ".join(words)}
37
+ return {" ".join(words[i:i + k]) for i in range(len(words) - k + 1)}
38
+
39
+
40
+ def jaccard(a: set[str], b: set[str]) -> float:
41
+ if not a and not b:
42
+ return 1.0
43
+ if not a or not b:
44
+ return 0.0
45
+ return len(a & b) / len(a | b)
46
+
47
+
48
+ def classify_overlap(
49
+ local_text: str, kb_text: str, *, jaccard_threshold: float = 0.6,
50
+ ) -> tuple[str, list[str]]:
51
+ """Return (classification, overlap_headings)."""
52
+ if content_hash(local_text) == content_hash(kb_text):
53
+ return ("imported_duplicate", [])
54
+ if jaccard(shingles(local_text), shingles(kb_text)) >= jaccard_threshold:
55
+ shared = sorted(extract_headings(local_text) & extract_headings(kb_text))
56
+ return ("partial_overlap", shared)
57
+ return ("unique", [])
@@ -0,0 +1,51 @@
1
+ """Crash-safe filesystem primitives used by push queue, pending queue, locks.
2
+
3
+ Sequence:
4
+ write_to_tmp -> fsync_tmp -> os.replace(tmp, target) -> fsync_parent_dir
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import tempfile
11
+ from typing import Any
12
+
13
+
14
+ def atomic_write_json(path: str, payload: dict[str, Any]) -> None:
15
+ """Crash-safe JSON write. Returns only after bytes are durable AND the
16
+ rename + parent-dir entry are durable on disk.
17
+
18
+ Raises FileNotFoundError if the parent directory does not exist (caller
19
+ must ensure mkdir before first write).
20
+ """
21
+ parent = os.path.dirname(path) or "."
22
+ fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".tmp.", dir=parent)
23
+ try:
24
+ with os.fdopen(fd, "w") as f:
25
+ json.dump(payload, f)
26
+ f.flush()
27
+ os.fsync(f.fileno())
28
+ os.replace(tmp, path)
29
+ dirfd = os.open(parent, os.O_RDONLY)
30
+ try:
31
+ os.fsync(dirfd)
32
+ finally:
33
+ os.close(dirfd)
34
+ except Exception:
35
+ if os.path.exists(tmp):
36
+ os.unlink(tmp)
37
+ raise
38
+
39
+
40
+ def atomic_remove(path: str) -> None:
41
+ """Crash-safe removal: unlink + fsync parent. Missing file is a no-op."""
42
+ parent = os.path.dirname(path) or "."
43
+ try:
44
+ os.unlink(path)
45
+ except FileNotFoundError:
46
+ return
47
+ dirfd = os.open(parent, os.O_RDONLY)
48
+ try:
49
+ os.fsync(dirfd)
50
+ finally:
51
+ os.close(dirfd)