cortexm 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.
- context_m.py +17 -0
- cortexm/__init__.py +45 -0
- cortexm/accel.py +403 -0
- cortexm/api/__init__.py +0 -0
- cortexm/api/chaos.py +118 -0
- cortexm/api/memory.py +635 -0
- cortexm/bench/__init__.py +0 -0
- cortexm/bench/abilities.py +311 -0
- cortexm/bench/baselines.py +89 -0
- cortexm/bench/beam_loader.py +317 -0
- cortexm/bench/generator.py +376 -0
- cortexm/bench/harness.py +211 -0
- cortexm/bench/messy.py +218 -0
- cortexm/bench/micro.py +251 -0
- cortexm/bench/ood.py +443 -0
- cortexm/bench/run.py +137 -0
- cortexm/bridge/__init__.py +0 -0
- cortexm/bridge/dates.py +178 -0
- cortexm/bridge/decoders.py +204 -0
- cortexm/bridge/enrich.py +255 -0
- cortexm/bridge/extractor.py +316 -0
- cortexm/bridge/fallback.py +332 -0
- cortexm/bridge/onnx_runtime.py +158 -0
- cortexm/bridge/patterns.py +760 -0
- cortexm/bridge/ppr.py +104 -0
- cortexm/bridge/prefilter.py +188 -0
- cortexm/bridge/query_extract.py +420 -0
- cortexm/bridge/reader.py +1174 -0
- cortexm/bridge/rerank.py +204 -0
- cortexm/bridge/writer.py +492 -0
- cortexm/cli.py +295 -0
- cortexm/cognition/__init__.py +53 -0
- cortexm/cognition/abstraction.py +192 -0
- cortexm/cognition/analogy.py +159 -0
- cortexm/cognition/engine.py +204 -0
- cortexm/cognition/gaps.py +365 -0
- cortexm/cognition/scanner.py +204 -0
- cortexm/config.py +375 -0
- cortexm/cortexm.py +8 -0
- cortexm/enterprise/__init__.py +0 -0
- cortexm/enterprise/audit.py +178 -0
- cortexm/enterprise/governance.py +239 -0
- cortexm/errors.py +35 -0
- cortexm/features/__init__.py +0 -0
- cortexm/features/git.py +204 -0
- cortexm/features/prefetch.py +88 -0
- cortexm/features/zk.py +105 -0
- cortexm/federation/__init__.py +39 -0
- cortexm/federation/crdt.py +275 -0
- cortexm/federation/fabric.py +109 -0
- cortexm/federation/hlc.py +80 -0
- cortexm/federation/node.py +145 -0
- cortexm/federation/schema_report.py +73 -0
- cortexm/federation/transport.py +164 -0
- cortexm/index/__init__.py +19 -0
- cortexm/index/nsg.py +386 -0
- cortexm/mcp/__init__.py +0 -0
- cortexm/mcp/server.py +985 -0
- cortexm/metrics.py +62 -0
- cortexm/migrate/__init__.py +0 -0
- cortexm/migrate/importers.py +192 -0
- cortexm/provenance/__init__.py +78 -0
- cortexm/provenance/agent.py +214 -0
- cortexm/provenance/cose.py +201 -0
- cortexm/provenance/scitt.py +258 -0
- cortexm/provenance/vc.py +250 -0
- cortexm/security/__init__.py +0 -0
- cortexm/security/crypto.py +162 -0
- cortexm/security/hashes.py +140 -0
- cortexm/security/injection.py +149 -0
- cortexm/security/mind.py +154 -0
- cortexm/security/pii.py +265 -0
- cortexm/security/rbac.py +169 -0
- cortexm/security/sandbox.py +131 -0
- cortexm/security/zk_hamming.py +142 -0
- cortexm/security/zk_sql.py +485 -0
- cortexm/server/__init__.py +0 -0
- cortexm/server/metrics.py +88 -0
- cortexm/server/rest.py +936 -0
- cortexm/server/sparql.py +984 -0
- cortexm/text/__init__.py +0 -0
- cortexm/text/dissim.py +252 -0
- cortexm/text/embedder.py +155 -0
- cortexm/text/fuzzy.py +218 -0
- cortexm/text/idiolect.py +253 -0
- cortexm/text/labse.py +374 -0
- cortexm/text/tokenizer.py +79 -0
- cortexm/trace/__init__.py +0 -0
- cortexm/trace/blob_arena.py +277 -0
- cortexm/trace/consolidate.py +337 -0
- cortexm/trace/contradictions.py +69 -0
- cortexm/trace/dedup.py +114 -0
- cortexm/trace/edges.py +214 -0
- cortexm/trace/fact.py +121 -0
- cortexm/trace/fade.py +245 -0
- cortexm/trace/lifecycle.py +112 -0
- cortexm/trace/rebuild.py +173 -0
- cortexm/trace/rules.py +171 -0
- cortexm/trace/store.py +680 -0
- cortexm/trace/structural.py +183 -0
- cortexm/trace/tmt.py +335 -0
- cortexm/util.py +148 -0
- cortexm/vsa/__init__.py +0 -0
- cortexm/vsa/attribution.py +149 -0
- cortexm/vsa/cleanup.py +161 -0
- cortexm/vsa/codecs.py +397 -0
- cortexm/vsa/hologram_overlay.py +139 -0
- cortexm/vsa/index.py +163 -0
- cortexm/vsa/ops.py +149 -0
- cortexm/vsa/palace.py +446 -0
- cortexm/vsa/role_vectors.py +236 -0
- cortexm/vsa/slb.py +78 -0
- cortexm/vsa/tlsh_trie.py +137 -0
- cortexm/vsa/working_memory.py +249 -0
- cortexm-0.3.0.dist-info/METADATA +482 -0
- cortexm-0.3.0.dist-info/RECORD +120 -0
- cortexm-0.3.0.dist-info/WHEEL +5 -0
- cortexm-0.3.0.dist-info/entry_points.txt +2 -0
- cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
- cortexm-0.3.0.dist-info/top_level.txt +2 -0
cortexm/text/__init__.py
ADDED
|
File without changes
|
cortexm/text/dissim.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""DisSim v1 — rule-based discourse-aware text simplification.
|
|
2
|
+
|
|
3
|
+
Recursive syntactic splitting on subordinate-clause markers, relative
|
|
4
|
+
clauses, coordination, and appositions. Each split produces simpler
|
|
5
|
+
core sentences linked by discourse relations (TEMPORAL_WHEN, CAUSAL,
|
|
6
|
+
CONCESSION, RELATIVE_CLAUSE, etc).
|
|
7
|
+
|
|
8
|
+
arxiv research: Niklaus, Cetto, Niklaus — DisSim (ACL 2019 workshop),
|
|
9
|
+
arXiv:2308.00425 (2023). Pure-Python port of the rule-based v1
|
|
10
|
+
algorithm — no T5-small dependency, no LLM call, μ=0 safe.
|
|
11
|
+
|
|
12
|
+
Why this matters: the deterministic μ=0 extractor in bridge/extractor.py
|
|
13
|
+
relies on regex pattern packs; complex compound sentences defeat every
|
|
14
|
+
pattern. Splitting "Although Alice works at Google, she quit yesterday"
|
|
15
|
+
into three simpler sentences lets each one match its own pattern.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SimplifiedClause:
|
|
27
|
+
"""A simplified core sentence + its discourse relation to parent."""
|
|
28
|
+
text: str
|
|
29
|
+
parent_id: int | None = None # index of parent clause in split list
|
|
30
|
+
relation: str = "ROOT" # discourse relation type
|
|
31
|
+
marker: str = "" # the connective word/phrase
|
|
32
|
+
depth: int = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Rule table: (regex_marker, relation_type, order)
|
|
36
|
+
# order = "before" → main clause comes before marker
|
|
37
|
+
# order = "after" → main clause comes after marker
|
|
38
|
+
# order = "wrap" → both sides are clauses, mark the second as the child
|
|
39
|
+
RULES = [
|
|
40
|
+
(r"\bwhen\b", "TEMPORAL_WHEN", "before"),
|
|
41
|
+
(r"\bwhile\b", "TEMPORAL_WHILE", "before"),
|
|
42
|
+
(r"\bsince\b", "TEMPORAL_SINCE", "before"),
|
|
43
|
+
(r"\buntil\b", "TEMPORAL_UNTIL", "before"),
|
|
44
|
+
(r"\bafter\b", "TEMPORAL_AFTER", "before"),
|
|
45
|
+
(r"\bbefore\b", "TEMPORAL_BEFORE", "before"),
|
|
46
|
+
(r"\bbecause\b", "CAUSAL", "after"),
|
|
47
|
+
(r"\bsince\b", "CAUSAL_SINCE", "after"), # ambiguous with temporal
|
|
48
|
+
(r"\bso that\b", "PURPOSE", "before"),
|
|
49
|
+
(r"\bso\b", "RESULT", "after"),
|
|
50
|
+
(r"\balthough\b", "CONCESSION", "before"),
|
|
51
|
+
(r"\beven though\b","CONCESSION", "before"),
|
|
52
|
+
(r"\bthough\b", "CONCESSION", "before"),
|
|
53
|
+
(r"\bbut\b", "CONTRAST", "before"),
|
|
54
|
+
(r"\bhowever\b", "CONTRAST", "before"),
|
|
55
|
+
(r"\bwhich\b", "RELATIVE_CLAUSE", "wrap"),
|
|
56
|
+
(r"\bwho\b", "RELATIVE_CLAUSE", "wrap"),
|
|
57
|
+
(r"\bthat\b", "RELATIVE_CLAUSE", "wrap"), # only when post-noun
|
|
58
|
+
(r"\bif\b", "CONDITION", "before"),
|
|
59
|
+
(r"\bunless\b", "CONDITION", "before"),
|
|
60
|
+
(r"\bwhereas\b", "CONTRAST", "before"),
|
|
61
|
+
(r"\bwhere\b", "LOCATION", "wrap"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DisSimSplitter:
|
|
66
|
+
"""Recursive syntactic splitter — pure-Python DisSim v1."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, max_depth: int = 3, min_clause_len: int = 3) -> None:
|
|
69
|
+
self.max_depth = max_depth
|
|
70
|
+
self.min_clause_len = min_clause_len
|
|
71
|
+
self.rules = [(re.compile(p, re.IGNORECASE), rel, order)
|
|
72
|
+
for p, rel, order in RULES]
|
|
73
|
+
|
|
74
|
+
def split(self, sentence: str, depth: int = 0,
|
|
75
|
+
parent_id: int | None = None) -> list[SimplifiedClause]:
|
|
76
|
+
"""Recursively simplify a sentence into core clauses.
|
|
77
|
+
|
|
78
|
+
Preserves trailing sentence-ending punctuation (. ! ?). The
|
|
79
|
+
downstream μ=0 pattern library in bridge/patterns.py uses
|
|
80
|
+
lookaheads like ``(?=[,.!?]|...|$)`` to anchor value capture;
|
|
81
|
+
stripping the trailing period silently broke role/role_as/
|
|
82
|
+
role_my patterns on clauses emitted here (the "Tier-4 unmess
|
|
83
|
+
trailing-punct" bug). We capture the terminator up-front and
|
|
84
|
+
re-attach it to the LAST clause produced, so downstream
|
|
85
|
+
patterns still see ``"I work as an engineer."`` instead of
|
|
86
|
+
``"I work as an engineer"``.
|
|
87
|
+
"""
|
|
88
|
+
# Capture trailing sentence terminator before splitting.
|
|
89
|
+
s = sentence.strip()
|
|
90
|
+
terminator = ""
|
|
91
|
+
if s and s[-1] in ".!?":
|
|
92
|
+
terminator = s[-1]
|
|
93
|
+
s = s[:-1].rstrip()
|
|
94
|
+
sentence = s
|
|
95
|
+
if depth >= self.max_depth or not sentence:
|
|
96
|
+
text = sentence + terminator if sentence else terminator
|
|
97
|
+
return [SimplifiedClause(text=text, parent_id=parent_id,
|
|
98
|
+
depth=depth)]
|
|
99
|
+
|
|
100
|
+
# find first matching rule
|
|
101
|
+
for pat, rel, order in self.rules:
|
|
102
|
+
m = pat.search(sentence)
|
|
103
|
+
if not m:
|
|
104
|
+
continue
|
|
105
|
+
marker = m.group(0).lower()
|
|
106
|
+
before = sentence[:m.start()].strip()
|
|
107
|
+
after = sentence[m.end():].strip()
|
|
108
|
+
|
|
109
|
+
clauses: list[SimplifiedClause] = []
|
|
110
|
+
|
|
111
|
+
# Handle the case where the marker is at position 0 (before is empty).
|
|
112
|
+
# For subordinate-clause markers (when, although, etc.), the
|
|
113
|
+
# dependent clause comes first, ending at the next comma or
|
|
114
|
+
# sentence boundary. The main clause follows.
|
|
115
|
+
if order == "before" and not before:
|
|
116
|
+
# dependent clause is from marker to next comma (or sentence end)
|
|
117
|
+
# main clause is the rest
|
|
118
|
+
comma_pos = after.find(",")
|
|
119
|
+
if comma_pos > 0:
|
|
120
|
+
dependent = after[:comma_pos].strip()
|
|
121
|
+
main = after[comma_pos + 1:].strip()
|
|
122
|
+
else:
|
|
123
|
+
# no comma — treat entire after as the dependent clause
|
|
124
|
+
dependent = after
|
|
125
|
+
main = ""
|
|
126
|
+
if dependent and len(dependent.split()) >= self.min_clause_len:
|
|
127
|
+
clauses.append(SimplifiedClause(
|
|
128
|
+
text=dependent, parent_id=parent_id,
|
|
129
|
+
relation=rel, marker=marker, depth=depth))
|
|
130
|
+
dep_idx = len(clauses) - 1
|
|
131
|
+
if main:
|
|
132
|
+
clauses.extend(self.split(main, depth + 1, dep_idx))
|
|
133
|
+
for c in clauses:
|
|
134
|
+
if (c.parent_id == dep_idx
|
|
135
|
+
and c.relation == "ROOT"):
|
|
136
|
+
c.relation = "MAIN"
|
|
137
|
+
break
|
|
138
|
+
else:
|
|
139
|
+
continue
|
|
140
|
+
elif order == "before" and before and after:
|
|
141
|
+
# main clause first, then dependent
|
|
142
|
+
if (len(before.split()) >= self.min_clause_len
|
|
143
|
+
or len(after.split()) >= self.min_clause_len):
|
|
144
|
+
clauses.append(SimplifiedClause(
|
|
145
|
+
text=before, parent_id=parent_id,
|
|
146
|
+
relation="ROOT" if depth == 0 else "PARENT",
|
|
147
|
+
marker=marker, depth=depth))
|
|
148
|
+
main_idx = len(clauses) - 1
|
|
149
|
+
clauses.extend(self.split(after, depth + 1, main_idx))
|
|
150
|
+
for c in clauses:
|
|
151
|
+
if (c.parent_id == main_idx
|
|
152
|
+
and c.relation == "ROOT"):
|
|
153
|
+
c.relation = rel
|
|
154
|
+
c.marker = marker
|
|
155
|
+
break
|
|
156
|
+
else:
|
|
157
|
+
continue
|
|
158
|
+
elif order == "after" and before and after:
|
|
159
|
+
# dependent (cause) first, main (effect) after
|
|
160
|
+
if (len(before.split()) >= self.min_clause_len
|
|
161
|
+
or len(after.split()) >= self.min_clause_len):
|
|
162
|
+
clauses.append(SimplifiedClause(
|
|
163
|
+
text=before, parent_id=parent_id,
|
|
164
|
+
relation=rel, marker=marker, depth=depth))
|
|
165
|
+
cause_idx = len(clauses) - 1
|
|
166
|
+
clauses.extend(self.split(after, depth + 1, cause_idx))
|
|
167
|
+
for c in clauses:
|
|
168
|
+
if (c.parent_id == cause_idx
|
|
169
|
+
and c.relation == "ROOT"):
|
|
170
|
+
c.relation = "EFFECT"
|
|
171
|
+
break
|
|
172
|
+
else:
|
|
173
|
+
continue
|
|
174
|
+
elif order == "wrap" and before and after:
|
|
175
|
+
# relative clause: split into main + relative
|
|
176
|
+
clauses.append(SimplifiedClause(
|
|
177
|
+
text=before, parent_id=parent_id,
|
|
178
|
+
relation="ROOT" if depth == 0 else "PARENT",
|
|
179
|
+
marker=marker, depth=depth))
|
|
180
|
+
main_idx = len(clauses) - 1
|
|
181
|
+
clauses.extend(self.split(after, depth + 1, main_idx))
|
|
182
|
+
for c in clauses:
|
|
183
|
+
if c.parent_id == main_idx and c.relation == "ROOT":
|
|
184
|
+
c.relation = rel
|
|
185
|
+
c.marker = marker
|
|
186
|
+
break
|
|
187
|
+
else:
|
|
188
|
+
# not enough content on both sides — try next rule
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
if clauses:
|
|
192
|
+
# try to recursively split each clause further
|
|
193
|
+
expanded: list[SimplifiedClause] = []
|
|
194
|
+
for c in clauses:
|
|
195
|
+
if c.depth >= self.max_depth:
|
|
196
|
+
expanded.append(c)
|
|
197
|
+
else:
|
|
198
|
+
sub = self.split(c.text, c.depth + 1, c.parent_id)
|
|
199
|
+
if len(sub) > 1:
|
|
200
|
+
for sc in sub:
|
|
201
|
+
if sc.parent_id == c.parent_id:
|
|
202
|
+
sc.relation = c.relation
|
|
203
|
+
sc.marker = c.marker
|
|
204
|
+
expanded.extend(sub)
|
|
205
|
+
else:
|
|
206
|
+
expanded.append(c)
|
|
207
|
+
# de-duplicate by text
|
|
208
|
+
seen: set[str] = set()
|
|
209
|
+
out: list[SimplifiedClause] = []
|
|
210
|
+
for c in expanded:
|
|
211
|
+
if c.text and c.text not in seen:
|
|
212
|
+
seen.add(c.text)
|
|
213
|
+
out.append(c)
|
|
214
|
+
if not out:
|
|
215
|
+
out = [SimplifiedClause(text=sentence, parent_id=parent_id,
|
|
216
|
+
depth=depth)]
|
|
217
|
+
# re-attach the trailing terminator to the LAST clause
|
|
218
|
+
# so downstream μ=0 patterns that anchor on [.!?] still
|
|
219
|
+
# match (Tier-4 unmess trailing-punct bug fix).
|
|
220
|
+
if terminator and out:
|
|
221
|
+
last = out[-1]
|
|
222
|
+
if not (last.text and last.text[-1] in ".!?"):
|
|
223
|
+
last.text = last.text + terminator
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
# no rule matched — re-attach the terminator
|
|
227
|
+
return [SimplifiedClause(text=sentence + terminator,
|
|
228
|
+
parent_id=parent_id, depth=depth)]
|
|
229
|
+
|
|
230
|
+
def simplify_text(self, text: str) -> list[SimplifiedClause]:
|
|
231
|
+
"""Split a multi-sentence text into simplified clauses."""
|
|
232
|
+
out: list[SimplifiedClause] = []
|
|
233
|
+
for sent in _split_sentences(text):
|
|
234
|
+
out.extend(self.split(sent))
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _split_sentences(text: str) -> list[str]:
|
|
239
|
+
"""Simple sentence splitter — respects ., !, ? but preserves common
|
|
240
|
+
abbreviations (Dr., Mr., Inc., etc.)."""
|
|
241
|
+
if not text:
|
|
242
|
+
return []
|
|
243
|
+
# protect common abbreviations
|
|
244
|
+
protected = text
|
|
245
|
+
for abbr in ("Mr.", "Mrs.", "Dr.", "Ms.", "Jr.", "Sr.", "Inc.",
|
|
246
|
+
"Ltd.", "Corp.", "vs.", "etc.", "i.e.", "e.g."):
|
|
247
|
+
protected = protected.replace(abbr, abbr.replace(".", "<DOT>"))
|
|
248
|
+
parts = re.split(r"(?<=[.!?])\s+", protected)
|
|
249
|
+
return [p.replace("<DOT>", ".").strip() for p in parts if p.strip()]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = ["DisSimSplitter", "SimplifiedClause"]
|
cortexm/text/embedder.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Deterministic feature-hashing embedder (μ=0 even at the embedding layer).
|
|
2
|
+
|
|
3
|
+
No external model, no API call, fully reproducible from a seed. Produces
|
|
4
|
+
L2-normalized float32 vectors of ``dims`` dimensions from token unigrams,
|
|
5
|
+
token bigrams and character n-grams via signed feature hashing
|
|
6
|
+
(Weinberger et al., 2009). A ``EmbeddingProvider`` protocol allows
|
|
7
|
+
swapping in a local transformer (e.g. ONNX MiniLM) or an API-backed
|
|
8
|
+
model in production without touching the rest of the fabric.
|
|
9
|
+
|
|
10
|
+
With ``labse_enabled=True`` (Config.labse_enabled), text whose non-ASCII
|
|
11
|
+
ratio exceeds 30% is delegated to ``PolyglotEncoder`` — a LaBSE-inspired
|
|
12
|
+
Unicode n-gram hasher that handles CJK / Devanagari / Arabic / Cyrillic
|
|
13
|
+
scripts (which the regex tokenizer drops entirely, producing a constant
|
|
14
|
+
embedding and zero retrieval recall — see docs/BENCHMARKS.md Tier-1).
|
|
15
|
+
English text stays on the existing fast path. See
|
|
16
|
+
``context_m/text/labse.py`` for the polyglot algorithm.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from typing import Protocol
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from cortexm.text.labse import PolyglotEncoder
|
|
27
|
+
from cortexm.text.tokenizer import STOPWORDS, words
|
|
28
|
+
from cortexm.util import h64 as _h64
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EmbeddingProvider(Protocol):
|
|
32
|
+
dims: int
|
|
33
|
+
|
|
34
|
+
def embed(self, text: str) -> np.ndarray: ...
|
|
35
|
+
|
|
36
|
+
def embed_many(self, texts: list[str]) -> np.ndarray: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _h64(feature: str, seed: int) -> int:
|
|
40
|
+
import hashlib
|
|
41
|
+
return int.from_bytes(
|
|
42
|
+
hashlib.blake2b(feature.encode("utf-8"), digest_size=8,
|
|
43
|
+
key=seed.to_bytes(8, "little")).digest(), "little")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HashingEmbedder:
|
|
47
|
+
"""Signed feature hashing over token/bigram/char-ngram features.
|
|
48
|
+
|
|
49
|
+
With ``labse_enabled=True``, non-English text (>30% non-ASCII chars)
|
|
50
|
+
is delegated to ``PolyglotEncoder`` — a LaBSE-inspired Unicode
|
|
51
|
+
n-gram encoder that handles scripts the regex tokenizer drops
|
|
52
|
+
(CJK, Devanagari, Arabic, Cyrillic, Thai, Hangul, Kana). English
|
|
53
|
+
text stays on the existing fast path. Default OFF so existing
|
|
54
|
+
behavior is unchanged; opt in via ``Config.labse_enabled`` or the
|
|
55
|
+
``CONTEXT_M_LABSE`` env var.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, dims: int = 768, seed: int = 0x0C0FFEE,
|
|
59
|
+
char_ngrams: tuple[int, ...] = (3, 4, 5),
|
|
60
|
+
use_bigrams: bool = True,
|
|
61
|
+
labse_enabled: bool = False) -> None:
|
|
62
|
+
self.dims = dims
|
|
63
|
+
self.seed = seed & 0xFFFFFFFFFFFFFFFF
|
|
64
|
+
self.char_ngrams = char_ngrams
|
|
65
|
+
self.use_bigrams = use_bigrams
|
|
66
|
+
self.labse_enabled = labse_enabled
|
|
67
|
+
self._feat_cache: dict[str, tuple[int, int, float]] = {}
|
|
68
|
+
# Lazy-initialized polyglot encoder — only built when first needed
|
|
69
|
+
# so the labse.py module import cost is paid only by users who
|
|
70
|
+
# actually ingest non-English text.
|
|
71
|
+
self._polyglot: PolyglotEncoder | None = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def polyglot(self) -> PolyglotEncoder:
|
|
75
|
+
"""Lazily-constructed PolyglotEncoder (dims/seed-matched)."""
|
|
76
|
+
if self._polyglot is None:
|
|
77
|
+
self._polyglot = PolyglotEncoder(
|
|
78
|
+
dims=self.dims, seed=self.seed)
|
|
79
|
+
return self._polyglot
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _non_ascii_ratio(text: str) -> float:
|
|
83
|
+
"""Fraction of non-ASCII chars in text. 0.0 for empty input."""
|
|
84
|
+
if not text:
|
|
85
|
+
return 0.0
|
|
86
|
+
non_ascii = sum(1 for c in text if ord(c) > 127)
|
|
87
|
+
return non_ascii / len(text)
|
|
88
|
+
|
|
89
|
+
# -- feature extraction -------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _feature(self, token: str) -> tuple[int, int, float]:
|
|
92
|
+
hit = self._feat_cache.get(token)
|
|
93
|
+
if hit is not None:
|
|
94
|
+
return hit
|
|
95
|
+
h = _h64(token, self.seed)
|
|
96
|
+
idx = h % self.dims
|
|
97
|
+
sign = 1 if (h >> 63) & 1 else -1
|
|
98
|
+
base = 0.35 if token in STOPWORDS else 1.0
|
|
99
|
+
out = (idx, sign, base)
|
|
100
|
+
if len(self._feat_cache) < 500_000:
|
|
101
|
+
self._feat_cache[token] = out
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
def _char_features(self, token: str) -> list[tuple[int, int, float]]:
|
|
105
|
+
padded = f"^{token}$"
|
|
106
|
+
feats = []
|
|
107
|
+
for n in self.char_ngrams:
|
|
108
|
+
if len(padded) < n:
|
|
109
|
+
feats.append((padded, 0.5))
|
|
110
|
+
continue
|
|
111
|
+
for i in range(len(padded) - n + 1):
|
|
112
|
+
feats.append((padded[i:i + n], 0.5))
|
|
113
|
+
out = []
|
|
114
|
+
for gram, w in feats:
|
|
115
|
+
h = _h64(gram, self.seed ^ 0xA5A5)
|
|
116
|
+
out.append((h % self.dims, 1 if (h >> 63) & 1 else -1, w))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
# -- API ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def embed(self, text: str) -> np.ndarray:
|
|
122
|
+
# Polyglot fallback for non-English text — the regex tokenizer
|
|
123
|
+
# in words() drops non-ASCII letters entirely, so without this
|
|
124
|
+
# delegation, every non-English sentence embeds to the constant
|
|
125
|
+
# [1, 0, 0, ...] vector (Tier-1 non-English recall = 0.000).
|
|
126
|
+
if self.labse_enabled and self._non_ascii_ratio(text) > 0.30:
|
|
127
|
+
return self.polyglot.encode(text)
|
|
128
|
+
vec = np.zeros(self.dims, dtype=np.float32)
|
|
129
|
+
toks = words(text)
|
|
130
|
+
if not toks:
|
|
131
|
+
vec[0] = 1.0
|
|
132
|
+
return vec
|
|
133
|
+
counts: dict[str, float] = {}
|
|
134
|
+
for t in toks:
|
|
135
|
+
counts[t] = counts.get(t, 0.0) + 1.0
|
|
136
|
+
for tok, tf in counts.items():
|
|
137
|
+
idx, sign, base = self._feature(tok)
|
|
138
|
+
vec[idx] += sign * base * (1.0 + math.log(tf))
|
|
139
|
+
for cidx, csign, cw in self._char_features(tok):
|
|
140
|
+
vec[cidx] += csign * cw * (1.0 + math.log(tf)) * 0.35
|
|
141
|
+
if self.use_bigrams:
|
|
142
|
+
for a, b in zip(toks, toks[1:]):
|
|
143
|
+
if a in STOPWORDS and b in STOPWORDS:
|
|
144
|
+
continue
|
|
145
|
+
idx, sign, base = self._feature(f"{a}_{b}")
|
|
146
|
+
vec[idx] += sign * base * 0.7
|
|
147
|
+
n = float(np.linalg.norm(vec))
|
|
148
|
+
if n > 0:
|
|
149
|
+
vec /= n
|
|
150
|
+
return vec
|
|
151
|
+
|
|
152
|
+
def embed_many(self, texts: list[str]) -> np.ndarray:
|
|
153
|
+
if not texts:
|
|
154
|
+
return np.zeros((0, self.dims), dtype=np.float32)
|
|
155
|
+
return np.stack([self.embed(t) for t in texts])
|
cortexm/text/fuzzy.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Bitap + Levenshtein + n-gram approximate string matching.
|
|
2
|
+
|
|
3
|
+
Wu-Manber k-error Bitap (Baeza-Yates-Gonnet 1992; Wu-Manber 1994) for
|
|
4
|
+
patterns <= 63 chars — single-word packed, O(n*k) for k errors. Falls
|
|
5
|
+
back to early-exit Levenshtein DP for longer patterns. Used to:
|
|
6
|
+
|
|
7
|
+
* replace util.levenshtein() inner loop on short strings (5-20x faster)
|
|
8
|
+
* power spelling-tolerant pattern triggers in bridge/patterns.py
|
|
9
|
+
* surface "close enough" candidates in normalization (idiolect.py)
|
|
10
|
+
|
|
11
|
+
arxiv research note: Bitap is the bitwise-baseline for fuzzy matching;
|
|
12
|
+
Myers' bit-parallel DP has the same asymptotic but worse constants on
|
|
13
|
+
small alphabets. For Context-M's typical pattern lengths (<32) and
|
|
14
|
+
small edit budgets (k<=3), Bitap wins.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bitap_levenshtein(text: str, pattern: str, max_edits: int = 3) -> int | None:
|
|
24
|
+
"""Wu-Manber k-error Bitap (substring matching with errors).
|
|
25
|
+
|
|
26
|
+
Returns the smallest edit distance within max_edits if the pattern
|
|
27
|
+
appears as a substring of text (allowing up to max_edits insertions,
|
|
28
|
+
deletions, substitutions), else None.
|
|
29
|
+
|
|
30
|
+
Pattern length must be <= 63 for single uint64 packing; longer
|
|
31
|
+
patterns fall back to DP. If pattern is longer than text + max_edits,
|
|
32
|
+
no match is possible and we return None early.
|
|
33
|
+
"""
|
|
34
|
+
m = len(pattern)
|
|
35
|
+
if m == 0:
|
|
36
|
+
return 0
|
|
37
|
+
# early-exit: substring match impossible if pattern too long for text+errors
|
|
38
|
+
if m > len(text) + max_edits:
|
|
39
|
+
return None
|
|
40
|
+
if m > 63:
|
|
41
|
+
return _levenshtein_substring(text, pattern, max_edits)
|
|
42
|
+
# build per-char bitmask
|
|
43
|
+
R: dict[str, int] = {}
|
|
44
|
+
for i, ch in enumerate(pattern):
|
|
45
|
+
R[ch] = R.get(ch, 0) | (1 << i)
|
|
46
|
+
mask = (1 << m) - 1
|
|
47
|
+
match_bit = 1 << (m - 1)
|
|
48
|
+
# Wu-Manber initial states:
|
|
49
|
+
# T[0] = mask with bit 0 cleared (no chars consumed yet)
|
|
50
|
+
# T[e] = T[e-1] << 1 (allow e leading insertions = e leading chars of
|
|
51
|
+
# pattern can be "matched" via insertion without consuming text)
|
|
52
|
+
states = [0] * (max_edits + 1)
|
|
53
|
+
states[0] = mask ^ 1 # bit 0 cleared
|
|
54
|
+
for e in range(1, max_edits + 1):
|
|
55
|
+
# T[e] = T[e-1] shifted left by 1 (each leading insertion advances
|
|
56
|
+
# the pattern position by 1)
|
|
57
|
+
states[e] = ((states[e - 1] << 1) | 1) & mask
|
|
58
|
+
best = None
|
|
59
|
+
for ch in text:
|
|
60
|
+
rc = R.get(ch, 0)
|
|
61
|
+
prev = states[0]
|
|
62
|
+
new_states = list(states)
|
|
63
|
+
new_states[0] = ((states[0] << 1) | 1) & rc & mask
|
|
64
|
+
for e in range(1, max_edits + 1):
|
|
65
|
+
# match: T[e] advances if char matches
|
|
66
|
+
# substitution: T[e-1] advances (use 1 error)
|
|
67
|
+
# deletion in text: T[e-1] (use 1 error, skip text char)
|
|
68
|
+
# insertion in text: T[e] advances (use 1 error, skip pattern char)
|
|
69
|
+
cur = ((states[e] << 1) | 1) & rc
|
|
70
|
+
cur |= prev | (prev << 1) | (states[e] << 1)
|
|
71
|
+
cur &= mask
|
|
72
|
+
prev = states[e] # save OLD states[e] before overwrite
|
|
73
|
+
new_states[e] = cur
|
|
74
|
+
states = new_states
|
|
75
|
+
# check for a match at any error budget
|
|
76
|
+
for e in range(max_edits + 1):
|
|
77
|
+
if states[e] & match_bit:
|
|
78
|
+
if best is None or e < best:
|
|
79
|
+
best = e
|
|
80
|
+
break # lower e wins
|
|
81
|
+
return best
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _levenshtein_substring(text: str, pattern: str, max_edits: int) -> int | None:
|
|
85
|
+
"""Sliding-window DP for patterns > 63 chars. O(n*m) but rare path."""
|
|
86
|
+
n, m = len(text), len(pattern)
|
|
87
|
+
if m == 0:
|
|
88
|
+
return 0
|
|
89
|
+
if abs(n - m) > max_edits and n < m:
|
|
90
|
+
return None
|
|
91
|
+
best = None
|
|
92
|
+
# slide a window of [m-max_edits, m+max_edits] over text
|
|
93
|
+
lo = max(0, m - max_edits)
|
|
94
|
+
hi = min(n, m + max_edits)
|
|
95
|
+
for start in range(0, n - lo + 1):
|
|
96
|
+
sub = text[start:start + hi]
|
|
97
|
+
if not sub:
|
|
98
|
+
continue
|
|
99
|
+
d = _levenshtein_full(sub, pattern)
|
|
100
|
+
if d <= max_edits and (best is None or d < best):
|
|
101
|
+
best = d
|
|
102
|
+
if best == 0:
|
|
103
|
+
return 0
|
|
104
|
+
return best
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _levenshtein_full(a: str, b: str) -> int:
|
|
108
|
+
"""Classic 2-row DP — used only as fallback for long patterns."""
|
|
109
|
+
if a == b:
|
|
110
|
+
return 0
|
|
111
|
+
if not a:
|
|
112
|
+
return len(b)
|
|
113
|
+
if not b:
|
|
114
|
+
return len(a)
|
|
115
|
+
prev = list(range(len(b) + 1))
|
|
116
|
+
for i, ca in enumerate(a, 1):
|
|
117
|
+
cur = [i] + [0] * len(b)
|
|
118
|
+
for j, cb in enumerate(b, 1):
|
|
119
|
+
ins = cur[j - 1] + 1
|
|
120
|
+
dele = prev[j] + 1
|
|
121
|
+
sub = prev[j - 1] + (0 if ca == cb else 1)
|
|
122
|
+
cur[j] = min(ins, dele, sub)
|
|
123
|
+
prev = cur
|
|
124
|
+
return prev[-1]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def levenshtein(a: str, b: str, cutoff: float | None = None) -> int:
|
|
128
|
+
"""Full edit distance (not substring-distance). Uses DP.
|
|
129
|
+
|
|
130
|
+
This is the right metric for word-level similarity. Bitap is for
|
|
131
|
+
substring matching inside longer text — different semantic.
|
|
132
|
+
"""
|
|
133
|
+
if a == b:
|
|
134
|
+
return 0
|
|
135
|
+
if not a:
|
|
136
|
+
return len(b)
|
|
137
|
+
if not b:
|
|
138
|
+
return len(a)
|
|
139
|
+
# Early-exit: if length diff exceeds cutoff, bail
|
|
140
|
+
if cutoff is not None and abs(len(a) - len(b)) > cutoff * max(len(a), len(b)):
|
|
141
|
+
return max(len(a), len(b))
|
|
142
|
+
return _levenshtein_full(a, b)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def similarity(a: str, b: str) -> float:
|
|
146
|
+
"""1 - normalized Levenshtein. Bitap-fast on short strings."""
|
|
147
|
+
if not a and not b:
|
|
148
|
+
return 1.0
|
|
149
|
+
d = levenshtein(a, b)
|
|
150
|
+
return max(0.0, 1.0 - d / max(len(a), len(b)))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def ngram_jaccard(a: str, b: str, n: int = 3) -> float:
|
|
154
|
+
"""Character n-gram Jaccard — for fuzzy lexicon lookup.
|
|
155
|
+
|
|
156
|
+
Faster than full Levenshtein for similarity thresholding on
|
|
157
|
+
short tokens. Complements Bitap (which is good for substring
|
|
158
|
+
matching) by being good at "are these two tokens plausibly the
|
|
159
|
+
same word" judgments.
|
|
160
|
+
"""
|
|
161
|
+
if not a or not b:
|
|
162
|
+
return 0.0
|
|
163
|
+
ga = {a[i:i + n] for i in range(len(a) - n + 1)} or {a}
|
|
164
|
+
gb = {b[i:i + n] for i in range(len(b) - n + 1)} or {b}
|
|
165
|
+
inter = len(ga & gb)
|
|
166
|
+
union = len(ga | gb)
|
|
167
|
+
return inter / union if union else 0.0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def best_match(query: str, candidates: Iterable[str],
|
|
171
|
+
max_edits: int = 2, min_sim: float = 0.7) -> str | None:
|
|
172
|
+
"""Find the best fuzzy match for query among candidates.
|
|
173
|
+
|
|
174
|
+
Uses Levenshtein similarity (1 - dist/max_len) as the primary metric
|
|
175
|
+
and n-gram Jaccard as tiebreaker. Returns None if no candidate
|
|
176
|
+
crosses min_sim.
|
|
177
|
+
"""
|
|
178
|
+
if not query:
|
|
179
|
+
return None
|
|
180
|
+
cand_list = list(candidates)
|
|
181
|
+
if not cand_list:
|
|
182
|
+
return None
|
|
183
|
+
best, best_score = None, 0.0
|
|
184
|
+
q = query.lower()
|
|
185
|
+
for c in cand_list:
|
|
186
|
+
cl = c.lower()
|
|
187
|
+
if q == cl:
|
|
188
|
+
return c
|
|
189
|
+
# primary: normalized Levenshtein similarity
|
|
190
|
+
d = _levenshtein_full(q, cl)
|
|
191
|
+
sim = 1.0 - d / max(len(q), len(cl))
|
|
192
|
+
# boost with n-gram Jaccard (catches character-level similarity)
|
|
193
|
+
j = ngram_jaccard(q, cl)
|
|
194
|
+
score = 0.7 * sim + 0.3 * j
|
|
195
|
+
if score > best_score:
|
|
196
|
+
best_score, best = score, c
|
|
197
|
+
return best if best_score >= min_sim else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def fuzzy_contains(haystack: str, needle: str, max_edits: int = 2) -> bool:
|
|
201
|
+
"""True if needle appears in haystack within max_edits (Bitap)."""
|
|
202
|
+
if not needle:
|
|
203
|
+
return True
|
|
204
|
+
if len(needle) <= 63:
|
|
205
|
+
return bitap_levenshtein(haystack, needle, max_edits) is not None
|
|
206
|
+
# fallback: regex with whitespace tolerance
|
|
207
|
+
pattern = re.escape(needle)
|
|
208
|
+
return bool(re.search(pattern, haystack, re.IGNORECASE))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = [
|
|
212
|
+
"bitap_levenshtein",
|
|
213
|
+
"levenshtein",
|
|
214
|
+
"similarity",
|
|
215
|
+
"ngram_jaccard",
|
|
216
|
+
"best_match",
|
|
217
|
+
"fuzzy_contains",
|
|
218
|
+
]
|