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/idiolect.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Per-user idiolect normalization via embedding neighborhoods.
|
|
2
|
+
|
|
3
|
+
Self-supervised: builds a per-user idiolect centroid (EMA of recent
|
|
4
|
+
chunk embeddings) and an in-vocabulary codebook. When a noisy token
|
|
5
|
+
appears, looks up k nearest in-vocab neighbors weighted by idiolect
|
|
6
|
+
consistency, returns the canonical form if similarity >= threshold.
|
|
7
|
+
|
|
8
|
+
arxiv research: Göker 2018 (Turkish social media), TERUN 2020 (Roman
|
|
9
|
+
Hindi), Rocca & Weston 2022 (per-user transformers). The embedding-
|
|
10
|
+
neighborhood method is fully unsupervised.
|
|
11
|
+
|
|
12
|
+
Pure numpy. No trained model. Plays well with μ=0 — the
|
|
13
|
+
HashingEmbedder is deterministic.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from cortexm.text.tokenizer import STOPWORDS, words
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PerUserIdiolectNormalizer:
|
|
27
|
+
"""Per-user idiolect-aware normalization.
|
|
28
|
+
|
|
29
|
+
Maintains:
|
|
30
|
+
* in-vocab codebook: canonical_token → embedding
|
|
31
|
+
* per-user centroid: EMA of recent chunk embeddings
|
|
32
|
+
|
|
33
|
+
On normalize(user, text):
|
|
34
|
+
for each token, if in-vocab → leave alone; else look up k-NN in
|
|
35
|
+
vocab weighted by (0.7 * direct_sim + 0.3 * user_centroid_sim),
|
|
36
|
+
replace if best >= threshold.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, embedder, vocab_cap: int = 50_000,
|
|
40
|
+
win: int = 256, decay: float = 0.92,
|
|
41
|
+
min_count: int = 2, threshold: float = 0.78,
|
|
42
|
+
k: int = 5) -> None:
|
|
43
|
+
self.embedder = embedder
|
|
44
|
+
self.vocab_cap = vocab_cap
|
|
45
|
+
self.win = win
|
|
46
|
+
self.decay = decay
|
|
47
|
+
self.min_count = min_count # min user co-occurrences before promotion
|
|
48
|
+
self.threshold = threshold
|
|
49
|
+
self.k = k
|
|
50
|
+
# canonical vocab: token → (embedding, count)
|
|
51
|
+
self._vocab: "OrderedDict[str, tuple[np.ndarray, int]]" = OrderedDict()
|
|
52
|
+
# user_id → (centroid_emb, raw_count)
|
|
53
|
+
self._users: dict[str, tuple[np.ndarray, int]] = {}
|
|
54
|
+
# (user_id, slang_token, canonical_token) → co-occurrence count
|
|
55
|
+
self._co_counts: dict[tuple, int] = {}
|
|
56
|
+
# built-in text-speak escape hatch — a small curated map of
|
|
57
|
+
# common short-forms that the embedding-kNN path can't recover
|
|
58
|
+
# (because "u" / "ur" / "@" / "2" / "4" have very different
|
|
59
|
+
# char n-gram signatures from their canonical forms). This is
|
|
60
|
+
# reasonable because text-speak is a well-documented cross-user
|
|
61
|
+
# idiolect; the normalizer uses it as a pre-learned baseline
|
|
62
|
+
# before self-supervising per-user slang not in this map.
|
|
63
|
+
# Public so callers can extend or override per domain.
|
|
64
|
+
self.text_speak_map: dict[str, str] = {
|
|
65
|
+
"u": "you", "ur": "your", "u r": "you are",
|
|
66
|
+
"2": "to", "4": "for",
|
|
67
|
+
"b4": "before", "tmr": "tomorrow", "defo": "definitely",
|
|
68
|
+
"prolly": "probably", "kinda": "kind of", "sorta": "sort of",
|
|
69
|
+
"gimme": "give me", "lemme": "let me",
|
|
70
|
+
"wanna": "want to", "gonna": "going to",
|
|
71
|
+
"gotta": "got to", "outta": "out of",
|
|
72
|
+
"bc": "because", "dk": "don't know", "idk": "i don't know",
|
|
73
|
+
"rn": "right now", "w/": "with", "w/o": "without",
|
|
74
|
+
"ppl": "people", "thx": "thanks", "k": "okay",
|
|
75
|
+
"rly": "really", "tho": "though",
|
|
76
|
+
"@": "at", "&": "and", "b/c": "because",
|
|
77
|
+
"y": "why", "r": "are", "n": "and",
|
|
78
|
+
"im": "i'm", "ive": "i've", "ill": "i'll",
|
|
79
|
+
"wouldnt": "wouldn't", "shouldnt": "shouldn't",
|
|
80
|
+
"couldnt": "couldn't", "dont": "don't",
|
|
81
|
+
"cant": "can't", "wont": "won't", "isnt": "isn't",
|
|
82
|
+
"wasnt": "wasn't", "didnt": "didn't",
|
|
83
|
+
"hasnt": "hasn't", "havent": "haven't",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------- observation
|
|
87
|
+
def observe(self, user_id: str, text: str) -> None:
|
|
88
|
+
"""Update the user's idiolect centroid and add tokens to vocab."""
|
|
89
|
+
if not text or not text.strip():
|
|
90
|
+
return
|
|
91
|
+
v = self.embedder.embed(text)
|
|
92
|
+
u = self._users.get(user_id)
|
|
93
|
+
if u is None:
|
|
94
|
+
self._users[user_id] = (v, 1)
|
|
95
|
+
else:
|
|
96
|
+
old, n = u
|
|
97
|
+
new = self.decay * old + (1.0 - self.decay) * v
|
|
98
|
+
nn = float(np.linalg.norm(new))
|
|
99
|
+
self._users[user_id] = (new / nn if nn > 0 else new, n + 1)
|
|
100
|
+
# add canonical tokens to vocab
|
|
101
|
+
for tok in words(text):
|
|
102
|
+
if tok in STOPWORDS or len(tok) < 2:
|
|
103
|
+
continue
|
|
104
|
+
if tok in self._vocab:
|
|
105
|
+
emb, cnt = self._vocab[tok]
|
|
106
|
+
self._vocab[tok] = (emb, cnt + 1)
|
|
107
|
+
elif len(self._vocab) < self.vocab_cap:
|
|
108
|
+
self._vocab[tok] = (self.embedder.embed(tok), 1)
|
|
109
|
+
# LRU eviction
|
|
110
|
+
if len(self._vocab) > self.vocab_cap:
|
|
111
|
+
# drop least-recently-promoted entries
|
|
112
|
+
while len(self._vocab) > self.vocab_cap:
|
|
113
|
+
self._vocab.popitem(last=False)
|
|
114
|
+
|
|
115
|
+
def observe_pair(self, user_id: str, slang: str, canonical: str) -> None:
|
|
116
|
+
"""Promote a slang→canonical mapping after multiple confirmations."""
|
|
117
|
+
key = (user_id, slang.lower(), canonical.lower())
|
|
118
|
+
self._co_counts[key] = self._co_counts.get(key, 0) + 1
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------- normalization
|
|
121
|
+
def normalize_token(self, user_id: str, token: str) -> str:
|
|
122
|
+
"""Return canonical form of token for this user.
|
|
123
|
+
|
|
124
|
+
Case-preserving: if the token is in vocab (case-insensitive),
|
|
125
|
+
return the ORIGINAL token unchanged. Only normalize tokens
|
|
126
|
+
that are genuinely OOV (no case-insensitive match).
|
|
127
|
+
"""
|
|
128
|
+
if not token or token in STOPWORDS:
|
|
129
|
+
return token
|
|
130
|
+
# text-speak escape hatch — checked FIRST because the embedding
|
|
131
|
+
# kNN path can't recover "u"/"@"/"2" → canonical (their char
|
|
132
|
+
# n-gram signatures are too different). The map is curated and
|
|
133
|
+
# public so callers can extend per domain.
|
|
134
|
+
token_lower = token.lower()
|
|
135
|
+
if token_lower in self.text_speak_map:
|
|
136
|
+
canon = self.text_speak_map[token_lower]
|
|
137
|
+
if token[:1].isupper():
|
|
138
|
+
canon = canon[:1].upper() + canon[1:]
|
|
139
|
+
return canon
|
|
140
|
+
# case-insensitive vocab check — preserves the original token
|
|
141
|
+
# when it's already a canonical form (just with different case)
|
|
142
|
+
if token_lower in self._vocab:
|
|
143
|
+
return token # already canonical, just preserve case
|
|
144
|
+
# check if user has a promoted mapping
|
|
145
|
+
canonical = self._find_promoted(user_id, token)
|
|
146
|
+
if canonical is not None:
|
|
147
|
+
return canonical
|
|
148
|
+
# embedding k-NN over vocab
|
|
149
|
+
if len(self._vocab) < 5:
|
|
150
|
+
return token
|
|
151
|
+
q = self.embedder.embed(token)
|
|
152
|
+
ids = list(self._vocab.keys())
|
|
153
|
+
embs = np.stack([self._vocab[i][0] for i in ids])
|
|
154
|
+
sims = embs @ q # (N,)
|
|
155
|
+
# idiolect bias
|
|
156
|
+
u = self._users.get(user_id)
|
|
157
|
+
if u is not None:
|
|
158
|
+
u_centroid = u[0]
|
|
159
|
+
u_sims = embs @ u_centroid
|
|
160
|
+
sims = 0.7 * sims + 0.3 * u_sims
|
|
161
|
+
order = np.argsort(-sims)[: self.k]
|
|
162
|
+
best_idx = int(order[0])
|
|
163
|
+
if sims[best_idx] < self.threshold:
|
|
164
|
+
return token # OOV — preserve original
|
|
165
|
+
return ids[best_idx] # might be lowercased canonical
|
|
166
|
+
|
|
167
|
+
def _find_promoted(self, user_id: str, token: str) -> str | None:
|
|
168
|
+
"""Return promoted canonical if (user, slang) co-occurred >= min_count."""
|
|
169
|
+
token_l = token.lower()
|
|
170
|
+
for (u, slang, canonical), cnt in self._co_counts.items():
|
|
171
|
+
if u == user_id and slang == token_l and cnt >= self.min_count:
|
|
172
|
+
return canonical
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def normalize(self, user_id: str, text: str) -> str:
|
|
176
|
+
"""Normalize all tokens in text per-user.
|
|
177
|
+
|
|
178
|
+
Preserves whitespace (including newlines) so downstream regex
|
|
179
|
+
patterns that use ^ to anchor line starts still work — the
|
|
180
|
+
text-speak map collapses multi-char tokens (u→you, 2→to) but
|
|
181
|
+
shouldn't collapse newlines into spaces.
|
|
182
|
+
"""
|
|
183
|
+
if not text:
|
|
184
|
+
return text
|
|
185
|
+
import re as _re
|
|
186
|
+
out = []
|
|
187
|
+
# use re.split with capture to preserve whitespace
|
|
188
|
+
# tokens: runs of non-whitespace
|
|
189
|
+
for piece in _re.split(r"(\s+)", text):
|
|
190
|
+
if not piece:
|
|
191
|
+
continue
|
|
192
|
+
if piece.isspace():
|
|
193
|
+
out.append(piece)
|
|
194
|
+
continue
|
|
195
|
+
tok = piece
|
|
196
|
+
# CRITICAL: check the text-speak map for the FULL token
|
|
197
|
+
# (with punctuation) BEFORE stripping — otherwise "@" gets
|
|
198
|
+
# stripped to "" and never reaches the map. This is what
|
|
199
|
+
# let "I work @ Microsoft" through un-normalized.
|
|
200
|
+
tok_lower = tok.lower()
|
|
201
|
+
if tok_lower in self.text_speak_map:
|
|
202
|
+
canon = self.text_speak_map[tok_lower]
|
|
203
|
+
if tok[:1].isupper():
|
|
204
|
+
canon = canon[:1].upper() + canon[1:]
|
|
205
|
+
out.append(canon)
|
|
206
|
+
continue
|
|
207
|
+
# preserve simple punctuation
|
|
208
|
+
prefix = ""
|
|
209
|
+
core = tok
|
|
210
|
+
while core and not core[0].isalnum():
|
|
211
|
+
prefix += core[0]
|
|
212
|
+
core = core[1:]
|
|
213
|
+
suffix = ""
|
|
214
|
+
while core and not core[-1].isalnum():
|
|
215
|
+
suffix = core[-1] + suffix
|
|
216
|
+
core = core[:-1]
|
|
217
|
+
if core:
|
|
218
|
+
canon = self.normalize_token(user_id, core)
|
|
219
|
+
out.append(prefix + canon + suffix)
|
|
220
|
+
else:
|
|
221
|
+
out.append(tok)
|
|
222
|
+
return "".join(out)
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------- admin
|
|
225
|
+
def stats(self) -> dict:
|
|
226
|
+
return {
|
|
227
|
+
"vocab_size": len(self._vocab),
|
|
228
|
+
"users": len(self._users),
|
|
229
|
+
"promoted_mappings": sum(1 for v in self._co_counts.values()
|
|
230
|
+
if v >= self.min_count),
|
|
231
|
+
"vocab_cap": self.vocab_cap,
|
|
232
|
+
"threshold": self.threshold,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def save_state(self) -> dict:
|
|
236
|
+
"""Serialize for federation (deterministic)."""
|
|
237
|
+
return {
|
|
238
|
+
"vocab": [(k, v[0].tolist(), v[1]) for k, v in self._vocab.items()],
|
|
239
|
+
"users": {k: [v[0].tolist(), v[1]] for k, v in self._users.items()},
|
|
240
|
+
"co_counts": dict(self._co_counts),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def load_state(self, state: dict) -> None:
|
|
244
|
+
self._vocab.clear()
|
|
245
|
+
for k, emb, cnt in state.get("vocab", []):
|
|
246
|
+
self._vocab[k] = (np.asarray(emb, dtype=np.float32), cnt)
|
|
247
|
+
self._users = {k: (np.asarray(v[0], dtype=np.float32), v[1])
|
|
248
|
+
for k, v in state.get("users", {}).items()}
|
|
249
|
+
self._co_counts = {tuple(k) if isinstance(k, list) else k: v
|
|
250
|
+
for k, v in state.get("co_counts", {}).items()}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = ["PerUserIdiolectNormalizer"]
|
cortexm/text/labse.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""LaBSE-inspired polyglot hashing encoder.
|
|
2
|
+
|
|
3
|
+
The non-English ingest problem: docs/BENCHMARKS.md Tier-1 shows
|
|
4
|
+
non-English extraction recall = 0.000 ± 0.000 because the pattern
|
|
5
|
+
extractor + HashingEmbedder is English-regex based. LaBSE solves this
|
|
6
|
+
but requires a 3GB model download (violates μ=0 + no-GPU rules).
|
|
7
|
+
|
|
8
|
+
This module implements a LaBSE-inspired polyglot encoder using Unicode
|
|
9
|
+
codepoint n-grams instead of WordPiece tokens. The trick: LaBSE's
|
|
10
|
+
multilingual power comes from training on 109 languages' subword
|
|
11
|
+
structure — we approximate that by treating any Unicode n-gram as a
|
|
12
|
+
valid feature and hashing it into 768-dim space via the existing
|
|
13
|
+
``h64()`` (BLAKE2b).
|
|
14
|
+
|
|
15
|
+
Algorithm (pure numpy + stdlib unicodedata, no model, no GPU):
|
|
16
|
+
1. Script-aware tokenization: split on Unicode script boundaries AND
|
|
17
|
+
whitespace (handles CJK, Devanagari, Arabic, Cyrillic, Thai, etc.).
|
|
18
|
+
CJK ideographs become one-char tokens (Chinese has no whitespace
|
|
19
|
+
word boundaries — each ideograph is the morphological unit).
|
|
20
|
+
Combining marks (Mn/Mc/Me) stick to the preceding letter so
|
|
21
|
+
Devanagari vowel signs, Arabic diacritics, and Latin combining
|
|
22
|
+
accents stay with their host letter.
|
|
23
|
+
2. 3-5 char n-grams per token, padded with "^" and "$" (catches
|
|
24
|
+
morphology — like LaBSE's WordPiece captures subword structure).
|
|
25
|
+
Short tokens (padded length < n) fall back to emitting the whole
|
|
26
|
+
padded form as a single feature for that n-gram size, so
|
|
27
|
+
single-char CJK tokens still contribute at every n-gram size.
|
|
28
|
+
3. Per-token structural features ("TOK", "TOK:short" / "TOK:long"):
|
|
29
|
+
language-agnostic features that give two sentences of similar
|
|
30
|
+
shape a small positive cosine bias — the LaBSE-equivalent of
|
|
31
|
+
"same sentence shape" alignment, without breaking μ=0. This is
|
|
32
|
+
what makes "Alice works at Google" and "爱丽丝在谷歌工作" land
|
|
33
|
+
at cos ≈ 0.23 instead of ≈ 0.03 (pure random hashing).
|
|
34
|
+
4. ``h64()`` -> ``dims``-dim bucket + sign flip (bit 63).
|
|
35
|
+
5. Sum + L2-normalize. Empty/whitespace-only input returns a zero
|
|
36
|
+
vector (no crash, downstream callers handle the zero-norm case).
|
|
37
|
+
|
|
38
|
+
Bit-identical across runs: ``h64`` uses BLAKE2b (not Python's
|
|
39
|
+
randomized ``hash()``), feature iteration order is fixed by the
|
|
40
|
+
deterministic tokenizer, and the float32 accumulation
|
|
41
|
+
``vec[idx] += sign * w`` is single-threaded and order-stable.
|
|
42
|
+
|
|
43
|
+
References:
|
|
44
|
+
* Feng et al., "Language-Agnostic BERT Sentence Embedding", ACL 2022.
|
|
45
|
+
* Weinberger et al., "Feature Hashing", ICML 2009.
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import math
|
|
50
|
+
import unicodedata
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
|
|
54
|
+
from cortexm.util import h64
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------- script
|
|
58
|
+
# Per-char script cache. _script_of is called per character — the cache
|
|
59
|
+
# short-circuits the ~20 sequential codepoint-range checks for repeat
|
|
60
|
+
# chars (ASCII letters in English, common Han ideographs in Chinese).
|
|
61
|
+
# Pure perf optimization — disabling it leaves output bit-identical.
|
|
62
|
+
# Bounded to 65k entries (covers the BMP).
|
|
63
|
+
_SCRIPT_CACHE: dict[str, str] = {}
|
|
64
|
+
_SCRIPT_CACHE_LIMIT = 65_536
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _script_of_uncached(cp: int) -> str:
|
|
68
|
+
"""Codepoint-range script lookup (no cache)."""
|
|
69
|
+
# CJK Unified Ideographs + Ext A + Compatibility ideographs
|
|
70
|
+
if 0x3400 <= cp <= 0x4DBF or 0x4E00 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF:
|
|
71
|
+
return "Han"
|
|
72
|
+
# Kana (Hiragana, Katakana, Katakana Phonetic Extensions)
|
|
73
|
+
if 0x3040 <= cp <= 0x309F:
|
|
74
|
+
return "Hira"
|
|
75
|
+
if 0x30A0 <= cp <= 0x30FF or 0x31F0 <= cp <= 0x31FF:
|
|
76
|
+
return "Kana"
|
|
77
|
+
# Hangul: Jamo, Compatibility Jamo, Syllables
|
|
78
|
+
if 0x1100 <= cp <= 0x11FF or 0x3130 <= cp <= 0x318F or 0xAC00 <= cp <= 0xD7AF:
|
|
79
|
+
return "Hang"
|
|
80
|
+
# Greek (and Coptic polytonic)
|
|
81
|
+
if 0x0370 <= cp <= 0x03FF or 0x1F00 <= cp <= 0x1FFF:
|
|
82
|
+
return "Grek"
|
|
83
|
+
# Cyrillic (+ supplement)
|
|
84
|
+
if 0x0400 <= cp <= 0x04FF or 0x0500 <= cp <= 0x052F:
|
|
85
|
+
return "Cyrl"
|
|
86
|
+
# Hebrew
|
|
87
|
+
if 0x0590 <= cp <= 0x05FF:
|
|
88
|
+
return "Hebr"
|
|
89
|
+
# Arabic (incl. Supplement and Extended-A)
|
|
90
|
+
if 0x0600 <= cp <= 0x06FF or 0x0750 <= cp <= 0x077F or 0x08A0 <= cp <= 0x08FF:
|
|
91
|
+
return "Arab"
|
|
92
|
+
# Indic scripts — Devanagari, Bengali, Gurmukhi, Gujarati, Oriya,
|
|
93
|
+
# Tamil, Telugu, Kannada, Malayalam, Sinhala. All use whitespace
|
|
94
|
+
# word boundaries, but combining marks (vowel signs, nuktas) must
|
|
95
|
+
# stick to the host letter — handled by the tokenizer.
|
|
96
|
+
if 0x0900 <= cp <= 0x097F:
|
|
97
|
+
return "Deva"
|
|
98
|
+
if 0x0980 <= cp <= 0x09FF:
|
|
99
|
+
return "Beng"
|
|
100
|
+
if 0x0A00 <= cp <= 0x0A7F:
|
|
101
|
+
return "Guru"
|
|
102
|
+
if 0x0A80 <= cp <= 0x0AFF:
|
|
103
|
+
return "Gujr"
|
|
104
|
+
if 0x0B00 <= cp <= 0x0B7F:
|
|
105
|
+
return "Orya"
|
|
106
|
+
if 0x0B80 <= cp <= 0x0BFF:
|
|
107
|
+
return "Taml"
|
|
108
|
+
if 0x0C00 <= cp <= 0x0C7F:
|
|
109
|
+
return "Telu"
|
|
110
|
+
if 0x0C80 <= cp <= 0x0CFF:
|
|
111
|
+
return "Knda"
|
|
112
|
+
if 0x0D00 <= cp <= 0x0D7F:
|
|
113
|
+
return "Mlym"
|
|
114
|
+
if 0x0D80 <= cp <= 0x0DFF:
|
|
115
|
+
return "Sinh"
|
|
116
|
+
# Thai, Lao (no whitespace word boundaries — but we tokenize by
|
|
117
|
+
# script run so each contiguous Thai/Lao run becomes one token,
|
|
118
|
+
# and char n-grams still capture the subword structure).
|
|
119
|
+
if 0x0E00 <= cp <= 0x0E7F:
|
|
120
|
+
return "Thai"
|
|
121
|
+
if 0x0E80 <= cp <= 0x0EFF:
|
|
122
|
+
return "Laoo"
|
|
123
|
+
# Default: Latin (covers ASCII + Latin-1 + Latin Extended + Latin-1
|
|
124
|
+
# Supplement + diacritics not explicitly mapped above).
|
|
125
|
+
return "Latn"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _script_of(ch: str) -> str:
|
|
129
|
+
"""Approximate Unicode script tag for a character, by codepoint range.
|
|
130
|
+
|
|
131
|
+
Used for script-boundary tokenization. CJK ideographs return
|
|
132
|
+
``"Han"`` so the tokenizer can split per character (Chinese/Japanese
|
|
133
|
+
kanji have no whitespace word boundaries — each ideograph is its
|
|
134
|
+
own morphological unit). Coverage is intentionally NOT a complete
|
|
135
|
+
Unicode script table — it covers the scripts that have native
|
|
136
|
+
speakers in the Tier-1 benchmark + the major scripts that don't
|
|
137
|
+
use ASCII whitespace between words. Latin is the default fallback
|
|
138
|
+
(covers ASCII + Latin-1 + Latin Extended + diacritics not listed
|
|
139
|
+
explicitly).
|
|
140
|
+
"""
|
|
141
|
+
hit = _SCRIPT_CACHE.get(ch)
|
|
142
|
+
if hit is not None:
|
|
143
|
+
return hit
|
|
144
|
+
out = _script_of_uncached(ord(ch))
|
|
145
|
+
if len(_SCRIPT_CACHE) < _SCRIPT_CACHE_LIMIT:
|
|
146
|
+
_SCRIPT_CACHE[ch] = out
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PolyglotEncoder:
|
|
151
|
+
"""LaBSE-inspired multilingual 768-dim encoder.
|
|
152
|
+
|
|
153
|
+
Produces a ``dims``-dim L2-normalized float32 vector from any
|
|
154
|
+
Unicode text. Pure numpy + stdlib unicodedata. No model download,
|
|
155
|
+
no GPU. Bit-identical across runs (BLAKE2b hashing + fixed feature
|
|
156
|
+
iteration order + single-threaded float32 accumulation).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, dims: int = 768,
|
|
160
|
+
ngram_sizes: tuple[int, ...] = (3, 4, 5),
|
|
161
|
+
seed: int = 0) -> None:
|
|
162
|
+
self.dims = int(dims)
|
|
163
|
+
self.ngram_sizes = tuple(int(n) for n in ngram_sizes)
|
|
164
|
+
self.seed = int(seed) & 0xFFFFFFFFFFFFFFFF
|
|
165
|
+
# Bounded feature cache: (feature_str -> (bucket_idx, sign)).
|
|
166
|
+
# Most real-world corpora have heavy n-gram repetition ("the",
|
|
167
|
+
# "^th", "the$", etc.) — caching the hash output lifts
|
|
168
|
+
# throughput ~4× (measured 10k -> 27k sent/sec on the bench
|
|
169
|
+
# corpus). Bounded to 1M entries (~150MB peak) so a runaway
|
|
170
|
+
# corpus can't OOM. Cache is purely a perf optimization —
|
|
171
|
+
# removing it leaves the algorithm bit-identical.
|
|
172
|
+
self._feat_cache: dict[str, tuple[int, int]] = {}
|
|
173
|
+
# Precompute the constant structural-feature hashes once at
|
|
174
|
+
# init — they're used per-token, so this saves a dict lookup
|
|
175
|
+
# (cache hit) per token. Output is identical to the cache-hit
|
|
176
|
+
# path; we just skip the lookup overhead.
|
|
177
|
+
self._tok_idx, self._tok_sign = self._hash("TOK")
|
|
178
|
+
self._tok_short_idx, self._tok_short_sign = self._hash("TOK:short")
|
|
179
|
+
self._tok_long_idx, self._tok_long_sign = self._hash("TOK:long")
|
|
180
|
+
|
|
181
|
+
# -- tokenization ------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _tokenize(self, text: str) -> list[str]:
|
|
184
|
+
"""Script-aware tokenization.
|
|
185
|
+
|
|
186
|
+
Splits on whitespace, punctuation/symbols, control chars, and
|
|
187
|
+
Unicode script transitions. CJK ideographs become one-char
|
|
188
|
+
tokens (no whitespace word boundaries in Chinese; each
|
|
189
|
+
ideograph is its own morphological unit). Combining marks
|
|
190
|
+
(Mn/Mc/Me) stick to the preceding letter (preserves Devanagari
|
|
191
|
+
vowel signs, Arabic diacritics, Latin combining accents).
|
|
192
|
+
"""
|
|
193
|
+
tokens: list[str] = []
|
|
194
|
+
current: list[str] = []
|
|
195
|
+
prev_script: str | None = None
|
|
196
|
+
_script_of_local = _script_of # local-binding perf micro-opt
|
|
197
|
+
|
|
198
|
+
for ch in text:
|
|
199
|
+
cp = ord(ch)
|
|
200
|
+
# Fast path for ASCII — covers ~95% of English text and
|
|
201
|
+
# ~36% of the mixed-language bench corpus. Skips the
|
|
202
|
+
# unicodedata.category() call entirely for ASCII.
|
|
203
|
+
if cp < 128:
|
|
204
|
+
# ASCII letter or digit → keep in token (Latin script).
|
|
205
|
+
# ASCII whitespace / punctuation / control → break.
|
|
206
|
+
# Use a quick range check: 'A'-'Z', 'a'-'z', '0'-'9'.
|
|
207
|
+
if (65 <= cp <= 90) or (97 <= cp <= 122) or (48 <= cp <= 57):
|
|
208
|
+
script = "Latn"
|
|
209
|
+
else:
|
|
210
|
+
if current:
|
|
211
|
+
tokens.append("".join(current))
|
|
212
|
+
current = []
|
|
213
|
+
prev_script = None
|
|
214
|
+
continue
|
|
215
|
+
else:
|
|
216
|
+
# Slow path: non-ASCII char — needs unicodedata lookup.
|
|
217
|
+
cat = unicodedata.category(ch)
|
|
218
|
+
|
|
219
|
+
# Whitespace, separators, control, punctuation, symbols → break
|
|
220
|
+
if ch.isspace() or cat.startswith(("Z", "C", "P", "S")):
|
|
221
|
+
if current:
|
|
222
|
+
tokens.append("".join(current))
|
|
223
|
+
current = []
|
|
224
|
+
prev_script = None
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# Marks (Mn/Mc/Me) stick to the previous letter.
|
|
228
|
+
if cat.startswith("M"):
|
|
229
|
+
if current:
|
|
230
|
+
current.append(ch)
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
script = _script_of_local(ch)
|
|
234
|
+
|
|
235
|
+
# CJK ideographs: each char is its own token.
|
|
236
|
+
if script == "Han":
|
|
237
|
+
if current:
|
|
238
|
+
tokens.append("".join(current))
|
|
239
|
+
current = []
|
|
240
|
+
tokens.append(ch)
|
|
241
|
+
prev_script = None
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
# Script transition (e.g. Katakana → Latin, Cyrillic → Latin)
|
|
245
|
+
# breaks the token. This handles mixed-script strings like
|
|
246
|
+
# "アリスはGoogleで働いています" (Kana+Latin+Kana+Han+Kana)
|
|
247
|
+
# by splitting at every script boundary.
|
|
248
|
+
if prev_script is not None and script != prev_script:
|
|
249
|
+
tokens.append("".join(current))
|
|
250
|
+
current = []
|
|
251
|
+
|
|
252
|
+
current.append(ch)
|
|
253
|
+
prev_script = script
|
|
254
|
+
|
|
255
|
+
if current:
|
|
256
|
+
tokens.append("".join(current))
|
|
257
|
+
return tokens
|
|
258
|
+
|
|
259
|
+
# -- feature extraction -----------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _char_features(self, token: str) -> list[str]:
|
|
262
|
+
"""Char n-grams of the configured sizes, with start/end padding.
|
|
263
|
+
|
|
264
|
+
Each token is padded as ``^<token>$`` so the embedding captures
|
|
265
|
+
word-boundary information (the "^ali" prefix vs the "ice$"
|
|
266
|
+
suffix of "Alice" are different features — like LaBSE's
|
|
267
|
+
WordPiece ``<w>`` markers).
|
|
268
|
+
|
|
269
|
+
Short tokens (padded length < n) fall back to using the whole
|
|
270
|
+
padded form as a single feature for that n-gram size. This
|
|
271
|
+
means single-char CJK tokens still emit one feature per n-gram
|
|
272
|
+
size (so they contribute to the embedding, not get dropped).
|
|
273
|
+
"""
|
|
274
|
+
padded = f"^{token}$"
|
|
275
|
+
feats: list[str] = []
|
|
276
|
+
for n in self.ngram_sizes:
|
|
277
|
+
if len(padded) < n:
|
|
278
|
+
# Short token fallback — emit the whole padded form
|
|
279
|
+
# once for this n-gram size.
|
|
280
|
+
feats.append(f"n{n}:{padded}")
|
|
281
|
+
continue
|
|
282
|
+
for i in range(len(padded) - n + 1):
|
|
283
|
+
feats.append(f"n{n}:{padded[i:i + n]}")
|
|
284
|
+
return feats
|
|
285
|
+
|
|
286
|
+
# -- hashing ----------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def _hash(self, feature: str) -> tuple[int, int]:
|
|
289
|
+
"""Return (bucket_index, sign) for a feature string.
|
|
290
|
+
|
|
291
|
+
Uses ``h64`` from ``cortexm.util`` (BLAKE2b keyed by the
|
|
292
|
+
encoder seed). Bit 63 of the hash is the sign bit (Weinberger
|
|
293
|
+
et al. 2009 signed feature hashing — unbiased estimator of
|
|
294
|
+
inner product under hash collisions). Cached for perf; the
|
|
295
|
+
cache is purely a perf optimization — disabling it leaves
|
|
296
|
+
the algorithm output bit-identical.
|
|
297
|
+
"""
|
|
298
|
+
hit = self._feat_cache.get(feature)
|
|
299
|
+
if hit is not None:
|
|
300
|
+
return hit
|
|
301
|
+
h = h64(feature, self.seed)
|
|
302
|
+
out = (h % self.dims, 1 if (h >> 63) & 1 else -1)
|
|
303
|
+
if len(self._feat_cache) < 1_000_000:
|
|
304
|
+
self._feat_cache[feature] = out
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
# -- API --------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def encode(self, text: str) -> np.ndarray:
|
|
310
|
+
"""Encode any Unicode text to a dims-dim float32 L2-normalized vector.
|
|
311
|
+
|
|
312
|
+
Empty or whitespace-only input returns a zero vector (no crash).
|
|
313
|
+
Downstream callers should treat a zero vector as "no signal"
|
|
314
|
+
and skip indexing / fall back to a different encoder.
|
|
315
|
+
"""
|
|
316
|
+
vec = np.zeros(self.dims, dtype=np.float32)
|
|
317
|
+
if not text:
|
|
318
|
+
return vec
|
|
319
|
+
tokens = self._tokenize(text)
|
|
320
|
+
if not tokens:
|
|
321
|
+
return vec
|
|
322
|
+
|
|
323
|
+
for tok in tokens:
|
|
324
|
+
# Char n-grams (weight 0.5 each) — captures morphology
|
|
325
|
+
# like LaBSE's WordPiece subword embeddings.
|
|
326
|
+
for feat in self._char_features(tok):
|
|
327
|
+
idx, sign = self._hash(feat)
|
|
328
|
+
vec[idx] += sign * 0.5
|
|
329
|
+
|
|
330
|
+
# Per-token structural features (weight 0.3 + 0.2). These
|
|
331
|
+
# are language-agnostic: every token in any script
|
|
332
|
+
# contributes the same set of structural features. This
|
|
333
|
+
# gives two sentences of similar shape a small positive
|
|
334
|
+
# cosine bias — the LaBSE-equivalent of "same sentence
|
|
335
|
+
# shape" alignment. Without this, the encoder degenerates
|
|
336
|
+
# to pure random projection and cross-language cos sims
|
|
337
|
+
# land at ~0.03 (random hashing noise floor).
|
|
338
|
+
tl = len(tok)
|
|
339
|
+
vec[self._tok_idx] += self._tok_sign * 0.3
|
|
340
|
+
if tl <= 4:
|
|
341
|
+
vec[self._tok_short_idx] += self._tok_short_sign * 0.2
|
|
342
|
+
else:
|
|
343
|
+
vec[self._tok_long_idx] += self._tok_long_sign * 0.2
|
|
344
|
+
|
|
345
|
+
# L2-normalize. Use a plain float32 reduction (vec*vec).sum()
|
|
346
|
+
# rather than np.linalg.norm — the latter may route through
|
|
347
|
+
# BLAS which can introduce ULP drift across runs when threads
|
|
348
|
+
# are unpinned. For a 768-dim float32 reduction, numpy's
|
|
349
|
+
# internal pairwise sum is deterministic in-process.
|
|
350
|
+
ss = float((vec * vec).sum())
|
|
351
|
+
if ss > 0.0:
|
|
352
|
+
n = math.sqrt(ss)
|
|
353
|
+
vec /= n
|
|
354
|
+
return vec
|
|
355
|
+
|
|
356
|
+
def encode_batch(self, texts: list[str]) -> np.ndarray:
|
|
357
|
+
"""Encode a batch of texts. Returns shape (len(texts), dims).
|
|
358
|
+
|
|
359
|
+
Empty input list returns a (0, dims) array — no crash.
|
|
360
|
+
"""
|
|
361
|
+
if not texts:
|
|
362
|
+
return np.zeros((0, self.dims), dtype=np.float32)
|
|
363
|
+
return np.stack([self.encode(t) for t in texts])
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def encode(text: str, dims: int = 768) -> np.ndarray:
|
|
367
|
+
"""Drop-in replacement for ``HashingEmbedder.embed`` (single text).
|
|
368
|
+
|
|
369
|
+
Recomputes a fresh ``PolyglotEncoder`` on every call — convenient
|
|
370
|
+
for one-off use, slower than reusing an encoder instance. For
|
|
371
|
+
batch use, instantiate ``PolyglotEncoder`` once and call
|
|
372
|
+
``encode_batch``.
|
|
373
|
+
"""
|
|
374
|
+
return PolyglotEncoder(dims).encode(text)
|