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.
Files changed (120) hide show
  1. context_m.py +17 -0
  2. cortexm/__init__.py +45 -0
  3. cortexm/accel.py +403 -0
  4. cortexm/api/__init__.py +0 -0
  5. cortexm/api/chaos.py +118 -0
  6. cortexm/api/memory.py +635 -0
  7. cortexm/bench/__init__.py +0 -0
  8. cortexm/bench/abilities.py +311 -0
  9. cortexm/bench/baselines.py +89 -0
  10. cortexm/bench/beam_loader.py +317 -0
  11. cortexm/bench/generator.py +376 -0
  12. cortexm/bench/harness.py +211 -0
  13. cortexm/bench/messy.py +218 -0
  14. cortexm/bench/micro.py +251 -0
  15. cortexm/bench/ood.py +443 -0
  16. cortexm/bench/run.py +137 -0
  17. cortexm/bridge/__init__.py +0 -0
  18. cortexm/bridge/dates.py +178 -0
  19. cortexm/bridge/decoders.py +204 -0
  20. cortexm/bridge/enrich.py +255 -0
  21. cortexm/bridge/extractor.py +316 -0
  22. cortexm/bridge/fallback.py +332 -0
  23. cortexm/bridge/onnx_runtime.py +158 -0
  24. cortexm/bridge/patterns.py +760 -0
  25. cortexm/bridge/ppr.py +104 -0
  26. cortexm/bridge/prefilter.py +188 -0
  27. cortexm/bridge/query_extract.py +420 -0
  28. cortexm/bridge/reader.py +1174 -0
  29. cortexm/bridge/rerank.py +204 -0
  30. cortexm/bridge/writer.py +492 -0
  31. cortexm/cli.py +295 -0
  32. cortexm/cognition/__init__.py +53 -0
  33. cortexm/cognition/abstraction.py +192 -0
  34. cortexm/cognition/analogy.py +159 -0
  35. cortexm/cognition/engine.py +204 -0
  36. cortexm/cognition/gaps.py +365 -0
  37. cortexm/cognition/scanner.py +204 -0
  38. cortexm/config.py +375 -0
  39. cortexm/cortexm.py +8 -0
  40. cortexm/enterprise/__init__.py +0 -0
  41. cortexm/enterprise/audit.py +178 -0
  42. cortexm/enterprise/governance.py +239 -0
  43. cortexm/errors.py +35 -0
  44. cortexm/features/__init__.py +0 -0
  45. cortexm/features/git.py +204 -0
  46. cortexm/features/prefetch.py +88 -0
  47. cortexm/features/zk.py +105 -0
  48. cortexm/federation/__init__.py +39 -0
  49. cortexm/federation/crdt.py +275 -0
  50. cortexm/federation/fabric.py +109 -0
  51. cortexm/federation/hlc.py +80 -0
  52. cortexm/federation/node.py +145 -0
  53. cortexm/federation/schema_report.py +73 -0
  54. cortexm/federation/transport.py +164 -0
  55. cortexm/index/__init__.py +19 -0
  56. cortexm/index/nsg.py +386 -0
  57. cortexm/mcp/__init__.py +0 -0
  58. cortexm/mcp/server.py +985 -0
  59. cortexm/metrics.py +62 -0
  60. cortexm/migrate/__init__.py +0 -0
  61. cortexm/migrate/importers.py +192 -0
  62. cortexm/provenance/__init__.py +78 -0
  63. cortexm/provenance/agent.py +214 -0
  64. cortexm/provenance/cose.py +201 -0
  65. cortexm/provenance/scitt.py +258 -0
  66. cortexm/provenance/vc.py +250 -0
  67. cortexm/security/__init__.py +0 -0
  68. cortexm/security/crypto.py +162 -0
  69. cortexm/security/hashes.py +140 -0
  70. cortexm/security/injection.py +149 -0
  71. cortexm/security/mind.py +154 -0
  72. cortexm/security/pii.py +265 -0
  73. cortexm/security/rbac.py +169 -0
  74. cortexm/security/sandbox.py +131 -0
  75. cortexm/security/zk_hamming.py +142 -0
  76. cortexm/security/zk_sql.py +485 -0
  77. cortexm/server/__init__.py +0 -0
  78. cortexm/server/metrics.py +88 -0
  79. cortexm/server/rest.py +936 -0
  80. cortexm/server/sparql.py +984 -0
  81. cortexm/text/__init__.py +0 -0
  82. cortexm/text/dissim.py +252 -0
  83. cortexm/text/embedder.py +155 -0
  84. cortexm/text/fuzzy.py +218 -0
  85. cortexm/text/idiolect.py +253 -0
  86. cortexm/text/labse.py +374 -0
  87. cortexm/text/tokenizer.py +79 -0
  88. cortexm/trace/__init__.py +0 -0
  89. cortexm/trace/blob_arena.py +277 -0
  90. cortexm/trace/consolidate.py +337 -0
  91. cortexm/trace/contradictions.py +69 -0
  92. cortexm/trace/dedup.py +114 -0
  93. cortexm/trace/edges.py +214 -0
  94. cortexm/trace/fact.py +121 -0
  95. cortexm/trace/fade.py +245 -0
  96. cortexm/trace/lifecycle.py +112 -0
  97. cortexm/trace/rebuild.py +173 -0
  98. cortexm/trace/rules.py +171 -0
  99. cortexm/trace/store.py +680 -0
  100. cortexm/trace/structural.py +183 -0
  101. cortexm/trace/tmt.py +335 -0
  102. cortexm/util.py +148 -0
  103. cortexm/vsa/__init__.py +0 -0
  104. cortexm/vsa/attribution.py +149 -0
  105. cortexm/vsa/cleanup.py +161 -0
  106. cortexm/vsa/codecs.py +397 -0
  107. cortexm/vsa/hologram_overlay.py +139 -0
  108. cortexm/vsa/index.py +163 -0
  109. cortexm/vsa/ops.py +149 -0
  110. cortexm/vsa/palace.py +446 -0
  111. cortexm/vsa/role_vectors.py +236 -0
  112. cortexm/vsa/slb.py +78 -0
  113. cortexm/vsa/tlsh_trie.py +137 -0
  114. cortexm/vsa/working_memory.py +249 -0
  115. cortexm-0.3.0.dist-info/METADATA +482 -0
  116. cortexm-0.3.0.dist-info/RECORD +120 -0
  117. cortexm-0.3.0.dist-info/WHEEL +5 -0
  118. cortexm-0.3.0.dist-info/entry_points.txt +2 -0
  119. cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
  120. cortexm-0.3.0.dist-info/top_level.txt +2 -0
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"]
@@ -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
+ ]