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.
- data_olympus/__init__.py +14 -0
- data_olympus/_bin/_kb_detect_workspace.sh +57 -0
- data_olympus/_bin/_kb_enforce.py +619 -0
- data_olympus/_bin/_kb_fallback.py +361 -0
- data_olympus/_bin/kb +957 -0
- data_olympus/_bin/kb-enforce-hook +337 -0
- data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
- data_olympus/audit_log.py +275 -0
- data_olympus/audit_trailers.py +42 -0
- data_olympus/auth.py +139 -0
- data_olympus/cli/__init__.py +1 -0
- data_olympus/cli/import_cmd.py +115 -0
- data_olympus/cli/indexgen.py +60 -0
- data_olympus/cli/main.py +151 -0
- data_olympus/cli/report_cmd.py +181 -0
- data_olympus/config.py +261 -0
- data_olympus/cooccurrence.py +393 -0
- data_olympus/dedup.py +57 -0
- data_olympus/durable.py +51 -0
- data_olympus/embeddings.py +317 -0
- data_olympus/enforce_policy.py +297 -0
- data_olympus/format/__init__.py +16 -0
- data_olympus/format/document.py +39 -0
- data_olympus/format/frontmatter.py +35 -0
- data_olympus/format/lint.py +92 -0
- data_olympus/format/validate.py +71 -0
- data_olympus/git_ops.py +397 -0
- data_olympus/health.py +114 -0
- data_olympus/importer/__init__.py +13 -0
- data_olympus/importer/adr.py +286 -0
- data_olympus/importer/flat.py +170 -0
- data_olympus/importer/model.py +106 -0
- data_olympus/importer/okf.py +192 -0
- data_olympus/importer/run.py +416 -0
- data_olympus/importer/stamp.py +227 -0
- data_olympus/index.py +1745 -0
- data_olympus/markdown_parse.py +103 -0
- data_olympus/models.py +480 -0
- data_olympus/onboarding.py +131 -0
- data_olympus/onboarding_inflight.py +137 -0
- data_olympus/onboarding_playbook.py +99 -0
- data_olympus/pending.py +533 -0
- data_olympus/principals.py +168 -0
- data_olympus/prompts.py +35 -0
- data_olympus/push_queue.py +261 -0
- data_olympus/query_expansion.py +200 -0
- data_olympus/rate_limit.py +81 -0
- data_olympus/refresh.py +329 -0
- data_olympus/report.py +133 -0
- data_olympus/rest_api.py +845 -0
- data_olympus/safe_id.py +36 -0
- data_olympus/search_gate.py +64 -0
- data_olympus/search_shortcut.py +146 -0
- data_olympus/server.py +1115 -0
- data_olympus/session_metrics.py +303 -0
- data_olympus/setup_wizard.py +751 -0
- data_olympus/thin_pointer.py +20 -0
- data_olympus/tools_audit.py +41 -0
- data_olympus/tools_enforce.py +198 -0
- data_olympus/tools_onboarding.py +585 -0
- data_olympus/tools_read.py +230 -0
- data_olympus/tools_write.py +878 -0
- data_olympus/trigram.py +126 -0
- data_olympus/viewer/__init__.py +1 -0
- data_olympus/viewer/generator.py +375 -0
- data_olympus/worktrees.py +147 -0
- data_olympus/write_gate.py +382 -0
- data_olympus-0.3.0.dist-info/METADATA +97 -0
- data_olympus-0.3.0.dist-info/RECORD +73 -0
- data_olympus-0.3.0.dist-info/WHEEL +4 -0
- data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
- data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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", [])
|
data_olympus/durable.py
ADDED
|
@@ -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)
|