@a9i5k4/dsh-auto-memory 2.1.4 → 2.1.6
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.
- package/lib/client.js +3791 -3713
- package/lib/index.js +182 -8
- package/lib/python-setup.js +229 -0
- package/package.json +6 -3
- package/python/m7_activation_features_v2.py +392 -0
- package/python/m7_embedding_v1.py +381 -0
- package/python/policies/activation_policy_v2.json +88 -0
- package/python/policies/decision-record-activation-v2-delta-exp-override-20260824.json +21 -0
- package/python/policies/decision-record-reasoning-kind-admission-20260826.json +20 -0
- package/python/policies/decision-record-stale-gate-per-candidate-20260825.json +33 -0
- package/python/policies/recall_intent_lr_v1.json +1 -0
- package/python/verify_policy_artifact.py +122 -0
- package/python/worker_semantic_v1.py +1344 -0
- package/python/worker_v1.py +623 -0
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""M7-3 semantic sidecar worker (docs/M7-ALGORITHM-DECISION.md D4).
|
|
4
|
+
|
|
5
|
+
Extends the tested M7-0/M7-1 fake worker (python/worker_v1.py) WITHOUT
|
|
6
|
+
touching its protocol semantics: same JSONL framing, same validators, same
|
|
7
|
+
index_sync rejection matrix, same atomic derived-corpus persistence. Adds:
|
|
8
|
+
|
|
9
|
+
- after a successful index_sync commit: chunk (m7_chunk_v1) + embed
|
|
10
|
+
(frozen provider) every record and persist versioned vectors with an
|
|
11
|
+
identity block under <dsh-home>/memory/semantic/ (atomic replace)
|
|
12
|
+
- on startup: reuse persisted vectors only when the identity block
|
|
13
|
+
matches the running embedding config; any mismatch = stale = refuse to
|
|
14
|
+
serve until the next commit rebuilds (fail closed, never mix)
|
|
15
|
+
- on context_push: dense top-8 shadow candidates appended to a bounded
|
|
16
|
+
semantic/candidates-shadow.jsonl. NO new wire frames in M7-3: the
|
|
17
|
+
frozen client correlates only acks/activations, so unsolicited
|
|
18
|
+
candidate_result frames would regress it; the frame type stays reserved.
|
|
19
|
+
|
|
20
|
+
Embedding backend is selected by an optional JSON config file passed via
|
|
21
|
+
the DSH_M7_EMBEDDING_CONFIG environment variable (no CLI change, no JS
|
|
22
|
+
change): {"provider":"bge-m3-pre-v1"|"hash-pre-v1", "modelDir":...,
|
|
23
|
+
"modelRevision":..., "dimension":1024, "torchThreads":16}.
|
|
24
|
+
Without the env var the worker degrades to fake-worker behavior (protocol
|
|
25
|
+
alive, embedding not ready) - never crashes, never changes ack semantics.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
import unicodedata
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
|
|
38
|
+
import worker_v1 as base # noqa: E402 (tested M7-0/M7-1 protocol layer)
|
|
39
|
+
import m7_embedding_v1 as emb # noqa: E402
|
|
40
|
+
try: # M7 activation feature v2 (round-1 shadow wiring)
|
|
41
|
+
import m7_activation_features_v2 as featv2 # noqa: E402
|
|
42
|
+
except Exception as _featv2_import_exc: # pragma: no cover
|
|
43
|
+
featv2 = None
|
|
44
|
+
_FEATV2_IMPORT_ERROR = str(_featv2_import_exc)[:160]
|
|
45
|
+
else:
|
|
46
|
+
_FEATV2_IMPORT_ERROR = ''
|
|
47
|
+
|
|
48
|
+
EMBEDDING_CONFIG_ENV = 'DSH_M7_EMBEDDING_CONFIG'
|
|
49
|
+
SHADOW_LOG_MAX = 256
|
|
50
|
+
SHADOW_TOP_K = 8
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def canonical_workspace_key(key):
|
|
54
|
+
"""Byte-twin of lib/evidence-store.js canonicalWorkspaceKey:
|
|
55
|
+
path.resolve + backslash->slash + lowercase."""
|
|
56
|
+
return os.path.abspath(str(key == None and '' or key)).replace('\\', '/').lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def wsref_of(workspace_key):
|
|
60
|
+
"""Byte-twin of evidence-store.js workspaceRefOf. JS owns identity;
|
|
61
|
+
this is a deterministic reproduction of its published pure function so
|
|
62
|
+
the worker can apply the workspace/scope/miv triple filter required by
|
|
63
|
+
the M7-7.5 hardening audit (P1: isolation must be explicit, never an
|
|
64
|
+
artifact of differing miv values)."""
|
|
65
|
+
canon = canonical_workspace_key(workspace_key)
|
|
66
|
+
return 'wsr_' + hashlib.sha256(
|
|
67
|
+
('evidence-wsref-pre-v1\u0000' + canon).encode('utf-8')).hexdigest()[:32]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tokenize(text, stopwords=frozenset()):
|
|
71
|
+
"""lexical_v2 parity tokenizer: NFKC + CJK 2-gram + ascii tokens."""
|
|
72
|
+
t = unicodedata.normalize('NFKC', str(text)).lower()
|
|
73
|
+
out = []
|
|
74
|
+
for run in __import__('re').findall(r'[\u4e00-\u9fff]+|[a-z0-9_./-]+', t):
|
|
75
|
+
if run[:1] >= '\u4e00':
|
|
76
|
+
grams = [run[i:i + 2] for i in range(len(run) - 1)] or [run]
|
|
77
|
+
out.extend(g for g in grams if g not in stopwords)
|
|
78
|
+
elif run not in stopwords and len(run) > 1:
|
|
79
|
+
out.append(run)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LexicalBM25:
|
|
84
|
+
"""BM25 k1=1.2 b=0.75, JS-parity formula (D6 lexical arm)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, docs_tokens):
|
|
87
|
+
self.N = len(docs_tokens)
|
|
88
|
+
self.doc_len = [len(d) for d in docs_tokens]
|
|
89
|
+
self.avgdl = (sum(self.doc_len) / self.N) if self.N else 1.0
|
|
90
|
+
self.tf, self.df = [], {}
|
|
91
|
+
for d in docs_tokens:
|
|
92
|
+
counts = {}
|
|
93
|
+
for tok in d:
|
|
94
|
+
counts[tok] = counts.get(tok, 0) + 1
|
|
95
|
+
self.tf.append(counts)
|
|
96
|
+
for tok in counts:
|
|
97
|
+
self.df[tok] = self.df.get(tok, 0) + 1
|
|
98
|
+
|
|
99
|
+
def score(self, query_tokens, idx):
|
|
100
|
+
dl = self.doc_len[idx] or 1
|
|
101
|
+
counts = self.tf[idx]
|
|
102
|
+
k1, b = 1.2, 0.75
|
|
103
|
+
s = 0.0
|
|
104
|
+
for tok in set(query_tokens):
|
|
105
|
+
if tok not in counts:
|
|
106
|
+
continue
|
|
107
|
+
tf = counts[tok]
|
|
108
|
+
df = self.df[tok]
|
|
109
|
+
idf = __import__('math').log(1.0 + (self.N - df + 0.5) / (df + 0.5))
|
|
110
|
+
s += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / self.avgdl))
|
|
111
|
+
return s
|
|
112
|
+
|
|
113
|
+
# ---- M7-6 activation policy (default: shadow calibration only) ----
|
|
114
|
+
ACTIVATION_POLICY_VERSION = 'm7_semantic_threshold_v1'
|
|
115
|
+
DEFAULT_ACTIVATION_POLICY = {
|
|
116
|
+
'mode': 'shadow', # 'shadow' = calibrate/log only; 'active' = emit frames
|
|
117
|
+
'tOn': 0.62, 'tOff': 0.52, # dual threshold, T_on > T_off (hysteresis)
|
|
118
|
+
'cooldownObs': 3, # observations to skip after an emission
|
|
119
|
+
'maxCandidates': 8,
|
|
120
|
+
'ttlSteps': 3,
|
|
121
|
+
# semantic score blend (all features recorded separately in the log);
|
|
122
|
+
# correction is NEGATIVE (audit H4): a corrected memory must not gain
|
|
123
|
+
# activation score, and corrected candidates are hard-dropped pre-rank.
|
|
124
|
+
'w': {'top': 0.6, 'margin': 0.15, 'evidence': 0.1, 'recency': 0.15,
|
|
125
|
+
'toolFail': 0.05},
|
|
126
|
+
'levelBands': [[0.75, 'excerpt'], [0.0, 'hint']],
|
|
127
|
+
}
|
|
128
|
+
# ---- M7-7.5 search policy (D6 weighted hybrid, frozen) ----
|
|
129
|
+
DEFAULT_SEARCH_POLICY = {
|
|
130
|
+
'mode': 'hybrid', # 'hybrid' = D6 fusion; 'dense' = dense only
|
|
131
|
+
'wDense': 0.7, # D6: dense 0.7 + lexical 0.3
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SessionSemanticState:
|
|
136
|
+
"""Per (sessionId, workspaceKey, scope); never a scope-less global."""
|
|
137
|
+
|
|
138
|
+
def __init__(self):
|
|
139
|
+
self.obs = 0
|
|
140
|
+
self.arming = 'suppressed' # suppressed | prefetched | armed
|
|
141
|
+
self.lastScore = 0.0
|
|
142
|
+
self.lastEmitObs = -10 ** 9
|
|
143
|
+
self.lastFeatures = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _vec_file(ws_ref, scope):
|
|
147
|
+
key = hashlib.sha256((ws_ref + '|' + scope).encode('utf-8')).hexdigest()[:16]
|
|
148
|
+
return 'vectors-' + key + '.json'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SemanticWorker(base.Worker):
|
|
152
|
+
def __init__(self, expect_epoch, dsh_home, embedding_config):
|
|
153
|
+
super().__init__(expect_epoch, dsh_home)
|
|
154
|
+
self.embedding_config = embedding_config or {}
|
|
155
|
+
self.embedder = None
|
|
156
|
+
self.vectors = {} # (wsRef, scope) -> {'identity':..., 'chunks':[...], 'vectors':[...]}
|
|
157
|
+
self.embedding_error = ''
|
|
158
|
+
self.session_states = {} # (sid, wsKey, scope) -> SessionSemanticState
|
|
159
|
+
act = self.embedding_config.get('activationPolicy') or {}
|
|
160
|
+
self.activation_policy = dict(DEFAULT_ACTIVATION_POLICY)
|
|
161
|
+
self.activation_policy.update(act if isinstance(act, dict) else {})
|
|
162
|
+
srch = self.embedding_config.get('search') or {}
|
|
163
|
+
self.search_policy = dict(DEFAULT_SEARCH_POLICY)
|
|
164
|
+
self.search_policy.update(srch if isinstance(srch, dict) else {})
|
|
165
|
+
# ---- fv2 → wire 发射门(2026-08-26 闭环接线) ----
|
|
166
|
+
# 'shadow'(默认)=只记 shadow 行零发射;'canary-explicit'=仅 explicit 车道
|
|
167
|
+
# 的 emit 决策发 activation_request 帧;'active' 预留。非法值回退 shadow
|
|
168
|
+
# (fail closed)。此开关属 JS/用户运营面,不进策略工件——阈值权威仍在
|
|
169
|
+
# activation_policy_v2.json(append-only),发射节流依赖 M6 收件箱的
|
|
170
|
+
# 硬校验+cooldown+TTL+latest-wins,worker 侧不重复限速。
|
|
171
|
+
_em = str(self.embedding_config.get('activationEmitMode') or 'shadow')
|
|
172
|
+
self.activation_emit_mode = _em if _em in (
|
|
173
|
+
'shadow', 'canary-explicit', 'active') else 'shadow'
|
|
174
|
+
self._stopwords = frozenset(
|
|
175
|
+
self.embedding_config.get('lexicalStopwords') or [])
|
|
176
|
+
self._lex_cache = None # (wsRef, scope, miv) -> LexicalBM25
|
|
177
|
+
# ---- M7 activation feature v2 (round-1 shadow wiring) ----
|
|
178
|
+
self._fv2 = None
|
|
179
|
+
self._fv2_invalid = ''
|
|
180
|
+
if _FEATV2_IMPORT_ERROR:
|
|
181
|
+
self._fv2_invalid = 'import-error: ' + _FEATV2_IMPORT_ERROR
|
|
182
|
+
else:
|
|
183
|
+
pol_dir = os.environ.get('DSH_M7_ACTIVATION_POLICY_DIR') or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
184
|
+
'policies')
|
|
185
|
+
try:
|
|
186
|
+
self._fv2 = featv2.load_and_verify_policy(
|
|
187
|
+
os.path.join(pol_dir, 'recall_intent_lr_v1.json'),
|
|
188
|
+
os.path.join(pol_dir,
|
|
189
|
+
'activation_policy_v2.json'))
|
|
190
|
+
except Exception as exc: # fail closed, retrieval unaffected
|
|
191
|
+
self._fv2_invalid = str(exc)[:200]
|
|
192
|
+
base.diag('featuresV2-policy-invalid: ' + self._fv2_invalid)
|
|
193
|
+
self._fv2_rep = {} # (sid,topicKey) -> decayed counters
|
|
194
|
+
self._fv2_rows = 0
|
|
195
|
+
if self.embedding_config.get('provider'):
|
|
196
|
+
self._init_embedding()
|
|
197
|
+
|
|
198
|
+
# ---------- embedding lifecycle ----------
|
|
199
|
+
|
|
200
|
+
def _init_embedding(self):
|
|
201
|
+
try:
|
|
202
|
+
self.embedder = emb.load_embedder(self.embedding_config)
|
|
203
|
+
self._load_vectors_from_disk()
|
|
204
|
+
except Exception as exc: # noqa: BLE001 - protocol must survive
|
|
205
|
+
self.embedding_error = str(exc)[:200]
|
|
206
|
+
self.embedder = None
|
|
207
|
+
base.diag('embedding-init-failed: ' + self.embedding_error)
|
|
208
|
+
|
|
209
|
+
def _semantic_dir(self):
|
|
210
|
+
return os.path.join(self.dsh_home, 'memory', 'semantic')
|
|
211
|
+
|
|
212
|
+
def _load_vectors_from_disk(self):
|
|
213
|
+
if not self.dsh_home:
|
|
214
|
+
return
|
|
215
|
+
d = self._semantic_dir()
|
|
216
|
+
if not os.path.isdir(d):
|
|
217
|
+
return
|
|
218
|
+
identity = emb.identity_block(self.embedder.provider,
|
|
219
|
+
self.embedding_config)
|
|
220
|
+
for fn in os.listdir(d):
|
|
221
|
+
if not (fn.startswith('vectors-') and fn.endswith('.json')):
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
with open(os.path.join(d, fn), encoding='utf-8') as f:
|
|
225
|
+
payload = json.load(f)
|
|
226
|
+
except (OSError, ValueError):
|
|
227
|
+
continue
|
|
228
|
+
if not isinstance(payload, dict):
|
|
229
|
+
continue
|
|
230
|
+
key = (payload.get('workspaceRef'), payload.get('scope'))
|
|
231
|
+
if not isinstance(key[0], str) or key[1] not in ('Workspace', 'User'):
|
|
232
|
+
continue
|
|
233
|
+
block = payload.get('identity') or {}
|
|
234
|
+
# stale check: identity mismatch -> ignore persisted vectors,
|
|
235
|
+
# they will be rebuilt by the next index_sync commit
|
|
236
|
+
usable = all(block.get(k) == identity.get(k) for k in identity)
|
|
237
|
+
self.vectors[key] = {
|
|
238
|
+
'identity': block,
|
|
239
|
+
'memoryIndexVersion': payload.get('memoryIndexVersion'),
|
|
240
|
+
'chunks': payload.get('chunks') or [],
|
|
241
|
+
'vectors': payload.get('vectors') or [],
|
|
242
|
+
'stale': (not usable) or
|
|
243
|
+
payload.get('memoryIndexVersion') != self.derived.get(key, {}).get('memoryIndexVersion'),
|
|
244
|
+
}
|
|
245
|
+
if self.vectors[key]['stale']:
|
|
246
|
+
base.diag('vectors stale for %s (identity=%s version=%s)'
|
|
247
|
+
% (key, not usable,
|
|
248
|
+
payload.get('memoryIndexVersion')))
|
|
249
|
+
|
|
250
|
+
def embedding_view(self):
|
|
251
|
+
if not self.embedding_config.get('provider'):
|
|
252
|
+
return {'enabled': False, 'ready': False,
|
|
253
|
+
'reason': 'no-embedding-config'}
|
|
254
|
+
if self.embedder is None:
|
|
255
|
+
return {'enabled': True, 'ready': False,
|
|
256
|
+
'error': self.embedding_error or 'init-failed'}
|
|
257
|
+
ready = sum(1 for v in self.vectors.values() if not v['stale'])
|
|
258
|
+
return {
|
|
259
|
+
'enabled': True, 'ready': ready > 0, 'entries': len(self.vectors),
|
|
260
|
+
'staleEntries': sum(1 for v in self.vectors.values() if v['stale']),
|
|
261
|
+
'chunks': sum(len(v['chunks']) for v in self.vectors.values()
|
|
262
|
+
if not v['stale']),
|
|
263
|
+
'provider': self.embedder.provider,
|
|
264
|
+
'policyVersion': emb.CHUNK_POLICY_VERSION,
|
|
265
|
+
'configHash': emb.identity_block(self.embedder.provider,
|
|
266
|
+
self.embedding_config)['configHash'],
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# ---------- vector build after commit ----------
|
|
270
|
+
|
|
271
|
+
def _chunk_texts_for(self, text):
|
|
272
|
+
"""provider-aware chunking: real tokenizer ids vs deterministic
|
|
273
|
+
char-paragraph packing for the offline hash provider."""
|
|
274
|
+
if hasattr(self.embedder, 'chunk_and_encode'):
|
|
275
|
+
id_chunks, _vecs = self.embedder.chunk_and_encode(text)
|
|
276
|
+
texts = [self.embedder.tokenizer.decode(ids, skip_special_tokens=True)
|
|
277
|
+
for ids in id_chunks]
|
|
278
|
+
return texts
|
|
279
|
+
# hash provider: pack whole paragraphs up to 2048 chars per chunk;
|
|
280
|
+
# an oversized paragraph is hard-split into 2048-char windows
|
|
281
|
+
paras = [p for p in text.split('\n') if p.strip()]
|
|
282
|
+
chunks, cur = [], ''
|
|
283
|
+
for p in paras:
|
|
284
|
+
if len(p) > 2048:
|
|
285
|
+
if cur:
|
|
286
|
+
chunks.append(cur)
|
|
287
|
+
cur = ''
|
|
288
|
+
chunks.extend(p[i:i + 2048] for i in range(0, len(p), 2048))
|
|
289
|
+
continue
|
|
290
|
+
if cur and len(cur) + len(p) + 1 <= 2048:
|
|
291
|
+
cur = cur + '\n' + p
|
|
292
|
+
else:
|
|
293
|
+
if cur:
|
|
294
|
+
chunks.append(cur)
|
|
295
|
+
cur = p
|
|
296
|
+
if cur:
|
|
297
|
+
chunks.append(cur)
|
|
298
|
+
return chunks or ['']
|
|
299
|
+
|
|
300
|
+
def build_vectors(self, ws_ref, scope):
|
|
301
|
+
entry = self.derived[(ws_ref, scope)]
|
|
302
|
+
real = hasattr(self.embedder, 'encode_ids') # tokenizer-id path
|
|
303
|
+
chunk_rows, encode_items = [], []
|
|
304
|
+
for rec in entry['records']:
|
|
305
|
+
if real:
|
|
306
|
+
# audit fix P0: real provider embeds TOKEN IDS from the model
|
|
307
|
+
# tokenizer directly (chunk_record_token_ids -> build_doc_ids
|
|
308
|
+
# -> encode_ids); never decode->re-encode drift, and
|
|
309
|
+
# encode_texts exists on both providers but the id path is
|
|
310
|
+
# the canonical one for corpus building.
|
|
311
|
+
id_chunks = emb.chunk_record_token_ids(self.embedder.tokenizer,
|
|
312
|
+
rec.get('text') or '')
|
|
313
|
+
texts = [self.embedder.tokenizer.decode(ids, skip_special_tokens=True)
|
|
314
|
+
for ids in id_chunks]
|
|
315
|
+
for ordinal, (ids, ctext) in enumerate(zip(id_chunks, texts)):
|
|
316
|
+
chunk_rows.append({
|
|
317
|
+
'chunkId': emb.chunk_id_for(rec['memoryId'],
|
|
318
|
+
rec['recordDigest'], ordinal),
|
|
319
|
+
'memoryId': rec['memoryId'],
|
|
320
|
+
'anchorId': rec['anchorId'],
|
|
321
|
+
'scope': rec['scope'],
|
|
322
|
+
'workspaceRef': rec['workspaceRef'],
|
|
323
|
+
'sourceRef': rec['sourceRef'],
|
|
324
|
+
'sourceEpoch': rec['sourceEpoch'],
|
|
325
|
+
'sourceVersion': rec['sourceVersion'],
|
|
326
|
+
'fileDigest': rec['fileDigest'],
|
|
327
|
+
'recordDigest': rec['recordDigest'],
|
|
328
|
+
'chunkOrdinal': ordinal,
|
|
329
|
+
'chunkCount': len(texts),
|
|
330
|
+
'occurredAt': rec.get('occurredAt'),
|
|
331
|
+
'excerpt': (ctext[:160] + '…') if len(ctext) > 160 else ctext,
|
|
332
|
+
})
|
|
333
|
+
encode_items.append(self.embedder.build_doc_ids(ids))
|
|
334
|
+
else:
|
|
335
|
+
texts = self._chunk_texts_for(rec.get('text') or '')
|
|
336
|
+
for ordinal, ctext in enumerate(texts):
|
|
337
|
+
chunk_rows.append({
|
|
338
|
+
'chunkId': emb.chunk_id_for(rec['memoryId'],
|
|
339
|
+
rec['recordDigest'], ordinal),
|
|
340
|
+
'memoryId': rec['memoryId'],
|
|
341
|
+
'anchorId': rec['anchorId'],
|
|
342
|
+
'scope': rec['scope'],
|
|
343
|
+
'workspaceRef': rec['workspaceRef'],
|
|
344
|
+
'sourceRef': rec['sourceRef'],
|
|
345
|
+
'sourceEpoch': rec['sourceEpoch'],
|
|
346
|
+
'sourceVersion': rec['sourceVersion'],
|
|
347
|
+
'fileDigest': rec['fileDigest'],
|
|
348
|
+
'recordDigest': rec['recordDigest'],
|
|
349
|
+
'chunkOrdinal': ordinal,
|
|
350
|
+
'chunkCount': len(texts),
|
|
351
|
+
'occurredAt': rec.get('occurredAt'),
|
|
352
|
+
'excerpt': (ctext[:160] + '…') if len(ctext) > 160 else ctext,
|
|
353
|
+
})
|
|
354
|
+
encode_items.append(ctext)
|
|
355
|
+
if real:
|
|
356
|
+
vectors = self.embedder.encode_ids(encode_items)
|
|
357
|
+
else:
|
|
358
|
+
vectors = self.embedder.encode_texts(encode_items)
|
|
359
|
+
identity = emb.identity_block(self.embedder.provider,
|
|
360
|
+
self.embedding_config)
|
|
361
|
+
payload = {
|
|
362
|
+
'schemaVersion': 1,
|
|
363
|
+
'namespace': base.NAMESPACE,
|
|
364
|
+
'policyVersion': 'semantic_vectors_v1',
|
|
365
|
+
'identity': identity,
|
|
366
|
+
'workspaceRef': ws_ref,
|
|
367
|
+
'scope': scope,
|
|
368
|
+
'memoryIndexVersion': entry['memoryIndexVersion'],
|
|
369
|
+
'chunks': chunk_rows,
|
|
370
|
+
'vectors': vectors,
|
|
371
|
+
}
|
|
372
|
+
persisted = self._atomic_write_json(_vec_file(ws_ref, scope), payload)
|
|
373
|
+
self.vectors[(ws_ref, scope)] = {
|
|
374
|
+
'identity': identity,
|
|
375
|
+
'memoryIndexVersion': entry['memoryIndexVersion'],
|
|
376
|
+
'chunks': chunk_rows,
|
|
377
|
+
'vectors': vectors,
|
|
378
|
+
'stale': False,
|
|
379
|
+
}
|
|
380
|
+
return persisted, len(chunk_rows)
|
|
381
|
+
|
|
382
|
+
def _atomic_write_json(self, filename, payload):
|
|
383
|
+
if not self.dsh_home:
|
|
384
|
+
return False
|
|
385
|
+
d = self._semantic_dir()
|
|
386
|
+
try:
|
|
387
|
+
os.makedirs(d, exist_ok=True)
|
|
388
|
+
fd, tmp = base.tempfile.mkstemp(dir=d, prefix='.tmp-vec-',
|
|
389
|
+
suffix='.json')
|
|
390
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
391
|
+
fh.write((base.dumps(payload) + '\n').encode('utf-8'))
|
|
392
|
+
fh.flush()
|
|
393
|
+
os.fsync(fh.fileno())
|
|
394
|
+
os.replace(tmp, os.path.join(d, filename))
|
|
395
|
+
return True
|
|
396
|
+
except OSError as exc:
|
|
397
|
+
base.diag('vector-persist-failed: ' + str(exc))
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
# ---------- dense shadow search ----------
|
|
401
|
+
|
|
402
|
+
def _cosine(self, a, b):
|
|
403
|
+
num = sum(x * y for x, y in zip(a, b))
|
|
404
|
+
return num # vectors are stored L2-normalized
|
|
405
|
+
|
|
406
|
+
def dense_search(self, query_text, workspace_key, scope, miv,
|
|
407
|
+
top_k=SHADOW_TOP_K):
|
|
408
|
+
"""Hard triple filter (M7-7.5 audit P1): workspaceRef + scope + miv
|
|
409
|
+
must ALL match the request; isolation never relies on miv differing.
|
|
410
|
+
workspaceRef is reproduced from the request's workspaceKey via the
|
|
411
|
+
JS-published pure function (see wsref_of)."""
|
|
412
|
+
if self.embedder is None:
|
|
413
|
+
return []
|
|
414
|
+
ws_ref = wsref_of(workspace_key)
|
|
415
|
+
try:
|
|
416
|
+
if hasattr(self.embedder, 'encode_query'):
|
|
417
|
+
qv = self.embedder.encode_query(query_text)
|
|
418
|
+
else:
|
|
419
|
+
qv = self.embedder.encode_texts([query_text])[0]
|
|
420
|
+
except Exception as exc: # noqa: BLE001
|
|
421
|
+
base.diag('query-encode-failed: ' + str(exc))
|
|
422
|
+
return []
|
|
423
|
+
scored = []
|
|
424
|
+
entry = self.vectors.get((ws_ref, scope))
|
|
425
|
+
if entry is not None and not entry['stale'] \
|
|
426
|
+
and entry['memoryIndexVersion'] == miv:
|
|
427
|
+
for i, chunk in enumerate(entry['chunks']):
|
|
428
|
+
vec = entry['vectors'][i]
|
|
429
|
+
s = self._cosine(qv, vec)
|
|
430
|
+
scored.append((s, chunk))
|
|
431
|
+
scored.sort(key=lambda t: (-t[0], t[1]['memoryId'], t[1]['chunkOrdinal']))
|
|
432
|
+
# aggregate to parent memory: top chunk score wins (frozen D2)
|
|
433
|
+
seen, out = set(), []
|
|
434
|
+
for s, chunk in scored:
|
|
435
|
+
if chunk['memoryId'] in seen:
|
|
436
|
+
continue
|
|
437
|
+
seen.add(chunk['memoryId'])
|
|
438
|
+
out.append({'score': round(s, 6), **{k: chunk[k] for k in (
|
|
439
|
+
'chunkId', 'memoryId', 'anchorId', 'scope', 'workspaceRef',
|
|
440
|
+
'sourceRef', 'sourceEpoch', 'sourceVersion', 'fileDigest',
|
|
441
|
+
'recordDigest', 'chunkOrdinal', 'occurredAt', 'excerpt')}})
|
|
442
|
+
if len(out) >= top_k:
|
|
443
|
+
break
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
def _lexical_scores(self, ws_ref, scope, miv, query_text, memory_ids):
|
|
447
|
+
"""D6 lexical arm over full authorized record text (from the derived
|
|
448
|
+
corpus the worker already holds); BM25 k1=1.2 b=0.75."""
|
|
449
|
+
key = (ws_ref, scope, miv)
|
|
450
|
+
if self._lex_cache is None or self._lex_cache[0] != key:
|
|
451
|
+
entry = self.derived.get((ws_ref, scope))
|
|
452
|
+
texts = [(r['memoryId'], r.get('text') or '')
|
|
453
|
+
for r in (entry['records'] if entry else [])]
|
|
454
|
+
self._lex_cache = (key, LexicalBM25(
|
|
455
|
+
[_tokenize(t, self._stopwords) for _, t in texts]))
|
|
456
|
+
bm = self._lex_cache[1]
|
|
457
|
+
qt = _tokenize(query_text, self._stopwords)
|
|
458
|
+
return {mid: bm.score(qt, i) for i, (mid, _) in enumerate(
|
|
459
|
+
[(r['memoryId'], '') for r in
|
|
460
|
+
self.derived.get((ws_ref, scope), {}).get('records', [])])}
|
|
461
|
+
|
|
462
|
+
@staticmethod
|
|
463
|
+
def _minmax(vals):
|
|
464
|
+
lo, hi = min(vals), max(vals)
|
|
465
|
+
return [0.0] * len(vals) if hi <= lo else \
|
|
466
|
+
[(v - lo) / (hi - lo) for v in vals]
|
|
467
|
+
|
|
468
|
+
def hybrid_rank(self, candidates, query_text, workspace_key, scope, miv):
|
|
469
|
+
"""D6 frozen fusion: fused = 0.7*minmax(dense) + 0.3*minmax(lexical).
|
|
470
|
+
Single-candidate sets pass through unfused. Returns re-ranked list;
|
|
471
|
+
each candidate gains denseScore/lexicalScore/fusedScore."""
|
|
472
|
+
if self.search_policy['mode'] != 'hybrid' or len(candidates) < 2:
|
|
473
|
+
for c in candidates:
|
|
474
|
+
c['denseScore'] = c['lexicalScore'] = c['fusedScore'] = c['score']
|
|
475
|
+
return candidates
|
|
476
|
+
ws_ref = wsref_of(workspace_key)
|
|
477
|
+
lex_all = self._lexical_scores(ws_ref, scope, miv, query_text,
|
|
478
|
+
[c['memoryId'] for c in candidates])
|
|
479
|
+
dense_norm = self._minmax([c['score'] for c in candidates])
|
|
480
|
+
lex_norm = self._minmax([lex_all.get(c['memoryId'], 0.0)
|
|
481
|
+
for c in candidates])
|
|
482
|
+
w = float(self.search_policy['wDense'])
|
|
483
|
+
for c, dn, ln in zip(candidates, dense_norm, lex_norm):
|
|
484
|
+
c['denseScore'] = round(float(dn), 6)
|
|
485
|
+
c['lexicalScore'] = round(float(ln), 6)
|
|
486
|
+
c['fusedScore'] = round(w * dn + (1 - w) * ln, 6)
|
|
487
|
+
candidates.sort(key=lambda c: (-c['fusedScore'], c['memoryId'],
|
|
488
|
+
c['chunkOrdinal']))
|
|
489
|
+
return candidates
|
|
490
|
+
|
|
491
|
+
def _append_shadow(self, row):
|
|
492
|
+
if not self.dsh_home:
|
|
493
|
+
return
|
|
494
|
+
d = self._semantic_dir()
|
|
495
|
+
try:
|
|
496
|
+
os.makedirs(d, exist_ok=True)
|
|
497
|
+
path = os.path.join(d, 'candidates-shadow.jsonl')
|
|
498
|
+
lines = []
|
|
499
|
+
if os.path.isfile(path):
|
|
500
|
+
with open(path, encoding='utf-8') as f:
|
|
501
|
+
lines = [l for l in f.read().splitlines() if l.strip()]
|
|
502
|
+
lines.append(base.dumps(row))
|
|
503
|
+
lines = lines[-SHADOW_LOG_MAX:]
|
|
504
|
+
fd, tmp = base.tempfile.mkstemp(dir=d, prefix='.tmp-shadow-',
|
|
505
|
+
suffix='.jsonl')
|
|
506
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
507
|
+
fh.write(('\n'.join(lines) + '\n').encode('utf-8'))
|
|
508
|
+
fh.flush()
|
|
509
|
+
os.fsync(fh.fileno())
|
|
510
|
+
os.replace(tmp, path)
|
|
511
|
+
except OSError as exc:
|
|
512
|
+
base.diag('shadow-append-failed: ' + str(exc))
|
|
513
|
+
|
|
514
|
+
# ---------- M7-6 semantic activation (dual threshold + hysteresis) ----------
|
|
515
|
+
|
|
516
|
+
def _session_state(self, p):
|
|
517
|
+
session = p.get('session') or {}
|
|
518
|
+
key = (str(session.get('sessionId', '')),
|
|
519
|
+
str(session.get('workspaceKey', '')),
|
|
520
|
+
session.get('scope'))
|
|
521
|
+
if key not in self.session_states:
|
|
522
|
+
self.session_states[key] = SessionSemanticState()
|
|
523
|
+
return self.session_states[key]
|
|
524
|
+
|
|
525
|
+
def _activation_features(self, p, candidates):
|
|
526
|
+
"""All feature groups recorded separately (task set §10).
|
|
527
|
+
Audit H4 fixes: correction is NEGATIVE evidence; toolFailures carry
|
|
528
|
+
explicit weight; recency consumes candidate occurredAt (now
|
|
529
|
+
propagated through build_vectors -> dense_search)."""
|
|
530
|
+
top = candidates[0]['score'] if candidates else 0.0
|
|
531
|
+
second = candidates[1]['score'] if len(candidates) > 1 else 0.0
|
|
532
|
+
evidence = p.get('evidence') if isinstance(p.get('evidence'), list) else []
|
|
533
|
+
ev_seen = sum(int(e.get('seen') or 0) for e in evidence
|
|
534
|
+
if isinstance(e, dict))
|
|
535
|
+
ev_cite = sum(int(e.get('cite') or 0) for e in evidence
|
|
536
|
+
if isinstance(e, dict))
|
|
537
|
+
ev_correction = sum(int(e.get('correction') or 0) for e in evidence
|
|
538
|
+
if isinstance(e, dict))
|
|
539
|
+
window = p.get('window') if isinstance(p.get('window'), list) else []
|
|
540
|
+
tool_failures = sum(
|
|
541
|
+
1 for s in window if isinstance(s, dict) and
|
|
542
|
+
(s.get('errorName') or s.get('errorCode') or s.get('toolOk') is False))
|
|
543
|
+
recency = 0.0
|
|
544
|
+
occurred = (candidates[0] or {}).get('occurredAt') if candidates else None
|
|
545
|
+
if isinstance(occurred, (int, float)) and occurred > 0:
|
|
546
|
+
import time as _t
|
|
547
|
+
age_days = max(0.0, (_t.time() * 1000 - occurred) / 86400000.0)
|
|
548
|
+
recency = 1.0 / (1.0 + age_days / 30.0)
|
|
549
|
+
return {
|
|
550
|
+
'denseTop': round(float(top), 6),
|
|
551
|
+
'denseMargin': round(float(max(0.0, top - second)), 6),
|
|
552
|
+
'evidenceSeen': ev_seen, 'evidenceCite': ev_cite,
|
|
553
|
+
'evidenceCorrection': ev_correction,
|
|
554
|
+
'toolFailures': tool_failures,
|
|
555
|
+
'recencyBoost': round(float(recency), 6),
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
def _semantic_score(self, f):
|
|
559
|
+
w = self.activation_policy['w']
|
|
560
|
+
# audit H4: correction LOWERS confidence (was wrongly positive)
|
|
561
|
+
ev_term = max(-1.0, min(1.0,
|
|
562
|
+
f['evidenceSeen'] * 0.05 +
|
|
563
|
+
f['evidenceCite'] * 0.10 -
|
|
564
|
+
f['evidenceCorrection'] * 0.20))
|
|
565
|
+
s = (w['top'] * f['denseTop'] + w['margin'] * min(1.0, f['denseMargin'] * 4)
|
|
566
|
+
+ w['evidence'] * ev_term + w['recency'] * f['recencyBoost']
|
|
567
|
+
+ w.get('toolFail', 0.05) * min(1.0, f['toolFailures'] * 0.5))
|
|
568
|
+
return round(max(0.0, min(1.0, s)), 6)
|
|
569
|
+
|
|
570
|
+
def _conflict_filter(self, p, candidates):
|
|
571
|
+
"""Audit H4 hard suppression: a memory carrying correction evidence is
|
|
572
|
+
suppressed from candidates entirely (old claim must not activate)."""
|
|
573
|
+
evidence = p.get('evidence') if isinstance(p.get('evidence'), list) else []
|
|
574
|
+
corrected = {str(e.get('memoryId')) for e in evidence
|
|
575
|
+
if isinstance(e, dict) and int(e.get('correction') or 0) > 0}
|
|
576
|
+
if not corrected or not candidates:
|
|
577
|
+
return candidates, []
|
|
578
|
+
kept = [c for c in candidates if c['memoryId'] not in corrected]
|
|
579
|
+
dropped = [c['memoryId'] for c in candidates
|
|
580
|
+
if c['memoryId'] in corrected]
|
|
581
|
+
return kept, dropped
|
|
582
|
+
|
|
583
|
+
def _activation_decision(self, state, score):
|
|
584
|
+
t_on = float(self.activation_policy['tOn'])
|
|
585
|
+
t_off = float(self.activation_policy['tOff'])
|
|
586
|
+
cooldown = int(self.activation_policy['cooldownObs'])
|
|
587
|
+
if state.obs - state.lastEmitObs <= cooldown:
|
|
588
|
+
return 'cooldown'
|
|
589
|
+
if state.arming == 'suppressed':
|
|
590
|
+
if score >= t_on:
|
|
591
|
+
state.arming = 'armed'
|
|
592
|
+
return 'emit'
|
|
593
|
+
if score >= t_off:
|
|
594
|
+
state.arming = 'prefetched'
|
|
595
|
+
return 'prefetch'
|
|
596
|
+
return 'suppress'
|
|
597
|
+
if state.arming == 'prefetched':
|
|
598
|
+
if score >= t_on:
|
|
599
|
+
state.arming = 'armed'
|
|
600
|
+
return 'emit'
|
|
601
|
+
if score < t_off:
|
|
602
|
+
state.arming = 'suppressed'
|
|
603
|
+
return 'suppress'
|
|
604
|
+
return 'prefetch'
|
|
605
|
+
# armed: hysteresis - stay armed until score falls below T_off
|
|
606
|
+
if score < t_off:
|
|
607
|
+
state.arming = 'suppressed'
|
|
608
|
+
return 'suppress'
|
|
609
|
+
if score >= t_on and state.obs - state.lastEmitObs > cooldown:
|
|
610
|
+
return 'emit'
|
|
611
|
+
return 'hold'
|
|
612
|
+
|
|
613
|
+
def _build_activation(self, req, p, candidates, score, features):
|
|
614
|
+
pol = self.activation_policy
|
|
615
|
+
session = p.get('session') or {}
|
|
616
|
+
cursor = p.get('cursor') or {}
|
|
617
|
+
obs = str(p.get('observationId', ''))
|
|
618
|
+
miv = str((p.get('index') or {}).get('memoryIndexVersion', ''))
|
|
619
|
+
level = 'hint'
|
|
620
|
+
for bound, lv in pol['levelBands']:
|
|
621
|
+
if score >= float(bound):
|
|
622
|
+
level = lv
|
|
623
|
+
break
|
|
624
|
+
cands = []
|
|
625
|
+
for i, c in enumerate(candidates[:int(pol['maxCandidates'])]):
|
|
626
|
+
excerpt = (c.get('excerpt') or '')[:160]
|
|
627
|
+
if len(excerpt.encode('utf-8')) > 480:
|
|
628
|
+
excerpt = excerpt[:150]
|
|
629
|
+
cands.append({
|
|
630
|
+
'candidateId': 'cand_' + base.first32(
|
|
631
|
+
base.sha_str('m7-semantic-cand\u0000' + obs + '\u0000' +
|
|
632
|
+
c['memoryId'] + '\u0000' + str(i))),
|
|
633
|
+
'memoryId': c['memoryId'], 'anchorId': c['anchorId'],
|
|
634
|
+
'scope': c['scope'], 'sourceRef': c['sourceRef'],
|
|
635
|
+
'sourceEpoch': c['sourceEpoch'], 'sourceVersion': c['sourceVersion'],
|
|
636
|
+
'fileDigest': c['fileDigest'], 'recordDigest': c['recordDigest'],
|
|
637
|
+
'score': round(min(1.0, max(0.0, c['score'])), 6),
|
|
638
|
+
'excerpt': excerpt,
|
|
639
|
+
})
|
|
640
|
+
if not cands:
|
|
641
|
+
return None
|
|
642
|
+
activation_id = 'act_' + base.first32(
|
|
643
|
+
base.sha_str('m7-semantic-activation-pre-v1\u0000' + obs))
|
|
644
|
+
created = req.get('sentAt', 0)
|
|
645
|
+
ttl = int(pol['ttlSteps'])
|
|
646
|
+
return {
|
|
647
|
+
'schemaVersion': 1, 'namespace': base.NAMESPACE,
|
|
648
|
+
'kind': 'activation_request',
|
|
649
|
+
'activationId': activation_id, 'observationId': obs,
|
|
650
|
+
'workerEpoch': str(req.get('workerEpoch', '')),
|
|
651
|
+
'sessionId': str(session.get('sessionId', '')),
|
|
652
|
+
'agentId': str(session.get('agentId', '')),
|
|
653
|
+
'workspaceKey': str(session.get('workspaceKey', '')),
|
|
654
|
+
'scope': session.get('scope'),
|
|
655
|
+
'contextVersion': cursor.get('contextVersion'),
|
|
656
|
+
'memoryIndexVersion': miv,
|
|
657
|
+
'threshold': {
|
|
658
|
+
'policyVersion': ACTIVATION_POLICY_VERSION,
|
|
659
|
+
'score': score,
|
|
660
|
+
'threshold': float(pol['tOn']),
|
|
661
|
+
'reason': ('semantic dual-threshold t_on=%s score=%s top=%s'
|
|
662
|
+
% (pol['tOn'], score, features['denseTop']))[:160],
|
|
663
|
+
},
|
|
664
|
+
'level': level,
|
|
665
|
+
'candidates': cands,
|
|
666
|
+
'ttlSteps': max(1, min(10, ttl)),
|
|
667
|
+
'createdAt': created,
|
|
668
|
+
'expiresAt': created + max(1, min(10, ttl)) * 60000,
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
def _build_fv2_activation(self, req, p, candidates, out):
|
|
672
|
+
"""fv2 两车道决策 → ActivationRequestPre。候选/身份块复用 v1 构造器
|
|
673
|
+
(同一 provenance 契约),判定块改用 fv2 策略版本与 reasonCodes;
|
|
674
|
+
score=intentProb、threshold=tauHi(explicit 车道的放行量);
|
|
675
|
+
level 固定 'excerpt'=最小内容级(full/checklist 需更严预算与用户策略,
|
|
676
|
+
canary 不开放)。"""
|
|
677
|
+
pol = self._fv2['policy'] if self._fv2 else {}
|
|
678
|
+
th = pol.get('thresholds') or {}
|
|
679
|
+
feats = out.get('features') or {}
|
|
680
|
+
if not isinstance(feats, dict):
|
|
681
|
+
feats = {}
|
|
682
|
+
try:
|
|
683
|
+
score = float(feats.get('intentProb') or 0.0)
|
|
684
|
+
except (TypeError, ValueError):
|
|
685
|
+
score = 0.0
|
|
686
|
+
# v1 构造器的 reason 串引用 features['denseTop'];传快照避免 KeyError,
|
|
687
|
+
# 该 threshold 块随后整体被 fv2 判定块覆写。
|
|
688
|
+
act = self._build_activation(req, p, candidates, score,
|
|
689
|
+
{'denseTop': feats.get('denseTop', 0)})
|
|
690
|
+
if act is None:
|
|
691
|
+
return None
|
|
692
|
+
reasons = ','.join(str(x) for x in (out.get('reasonCodes') or []))
|
|
693
|
+
act['threshold'] = {
|
|
694
|
+
'policyVersion': featv2.ACTIVATION_POLICY_VERSION,
|
|
695
|
+
'score': round(score, 6),
|
|
696
|
+
'threshold': float(th.get('tauHi', 0.45)),
|
|
697
|
+
'reason': ('fv2 lane=%s %s %s' % (
|
|
698
|
+
feats.get('lane'), out.get('decision'), reasons))[:160],
|
|
699
|
+
}
|
|
700
|
+
act['level'] = 'excerpt'
|
|
701
|
+
return act
|
|
702
|
+
|
|
703
|
+
def _append_activation_shadow(self, row):
|
|
704
|
+
if not self.dsh_home:
|
|
705
|
+
return
|
|
706
|
+
d = self._semantic_dir()
|
|
707
|
+
try:
|
|
708
|
+
os.makedirs(d, exist_ok=True)
|
|
709
|
+
path = os.path.join(d, 'activation-shadow.jsonl')
|
|
710
|
+
lines = []
|
|
711
|
+
if os.path.isfile(path):
|
|
712
|
+
with open(path, encoding='utf-8') as f:
|
|
713
|
+
lines = [l for l in f.read().splitlines() if l.strip()]
|
|
714
|
+
lines.append(base.dumps(row))
|
|
715
|
+
lines = lines[-SHADOW_LOG_MAX:]
|
|
716
|
+
fd, tmp = base.tempfile.mkstemp(dir=d, prefix='.tmp-actshadow-',
|
|
717
|
+
suffix='.jsonl')
|
|
718
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
719
|
+
fh.write(('\n'.join(lines) + '\n').encode('utf-8'))
|
|
720
|
+
fh.flush()
|
|
721
|
+
os.fsync(fh.fileno())
|
|
722
|
+
os.replace(tmp, path)
|
|
723
|
+
except OSError as exc:
|
|
724
|
+
base.diag('activation-shadow-append-failed: ' + str(exc))
|
|
725
|
+
|
|
726
|
+
# ---------- M7-7 judgement shadow (audit only, never writes) ----------
|
|
727
|
+
|
|
728
|
+
JUDGEMENT_POLICY = 'judgement_shadow_v1'
|
|
729
|
+
|
|
730
|
+
_J_MARKERS = ('CORRECTION', 'UPDATED', 'REVISED', 'FREEZE',
|
|
731
|
+
'HARD RULE', 'DECISION reversing', '纠正', '更新:',
|
|
732
|
+
'决定:', '冻结')
|
|
733
|
+
|
|
734
|
+
def _judge_kind(self, text):
|
|
735
|
+
t = str(text or '')
|
|
736
|
+
low = t.lower()
|
|
737
|
+
if any(m in t for m in self._J_MARKERS[:9]):
|
|
738
|
+
return 'conflict_or_supersede_candidate'
|
|
739
|
+
if any(k in low for k in ('runbook', 'checklist', '步骤', '手册', '流程')):
|
|
740
|
+
return 'procedure_candidate'
|
|
741
|
+
if any(k in low for k in ('http', 'registry', '.exe', 'error', '错误码',
|
|
742
|
+
'stack', '路径', 'cmd')):
|
|
743
|
+
return 'resource_candidate'
|
|
744
|
+
if any(k in low for k in ('偏好', '规则', '用户偏好', 'preference',
|
|
745
|
+
'convention')):
|
|
746
|
+
return 'profile_candidate'
|
|
747
|
+
if any(k in t for k in ('午饭', 'lunch', 'backup', '日程')) or \
|
|
748
|
+
len(t) < 120:
|
|
749
|
+
return 'working_only' if len(t) < 80 else 'episodic_candidate'
|
|
750
|
+
return 'semantic_candidate'
|
|
751
|
+
|
|
752
|
+
def _judgement_rows(self, p, candidates, query):
|
|
753
|
+
miv = str((p.get('index') or {}).get('memoryIndexVersion', ''))
|
|
754
|
+
cv = (p.get('cursor') or {}).get('contextVersion')
|
|
755
|
+
rows = []
|
|
756
|
+
top = candidates[:3]
|
|
757
|
+
for rank, c in enumerate(top):
|
|
758
|
+
kind = self._judge_kind(c.get('excerpt') or '')
|
|
759
|
+
conf = round(min(0.95, 0.4 + 0.15 * float(c['score'])), 4)
|
|
760
|
+
rows.append({
|
|
761
|
+
'schemaVersion': 1, 'namespace': base.NAMESPACE,
|
|
762
|
+
'policyVersion': self.JUDGEMENT_POLICY,
|
|
763
|
+
'observationId': str(p.get('observationId', '')),
|
|
764
|
+
'contextVersion': cv, 'memoryIndexVersion': miv,
|
|
765
|
+
'kindCandidate': kind,
|
|
766
|
+
'suggestion': 'keep_suggest',
|
|
767
|
+
'sourceIds': [c['memoryId']],
|
|
768
|
+
'supportEvidence': {'denseScore': c['score'], 'rank': rank,
|
|
769
|
+
'queryChars': len(query)},
|
|
770
|
+
'counterEvidence': ({} if rank > 0 else
|
|
771
|
+
{'secondScore': candidates[1]['score']
|
|
772
|
+
if len(candidates) > 1 else 0.0}),
|
|
773
|
+
'confidence': conf,
|
|
774
|
+
})
|
|
775
|
+
# duplicate/merge hint: top pair with near-identical scores
|
|
776
|
+
if len(candidates) >= 2 and \
|
|
777
|
+
float(candidates[0]['score']) - float(candidates[1]['score']) < 0.01:
|
|
778
|
+
rows.append({
|
|
779
|
+
'schemaVersion': 1, 'namespace': base.NAMESPACE,
|
|
780
|
+
'policyVersion': self.JUDGEMENT_POLICY,
|
|
781
|
+
'observationId': str(p.get('observationId', '')),
|
|
782
|
+
'contextVersion': cv, 'memoryIndexVersion': miv,
|
|
783
|
+
'kindCandidate': 'semantic_candidate',
|
|
784
|
+
'suggestion': 'merge_suggest',
|
|
785
|
+
'sourceIds': [candidates[0]['memoryId'],
|
|
786
|
+
candidates[1]['memoryId']],
|
|
787
|
+
'supportEvidence': {'scoreGap': round(float(
|
|
788
|
+
candidates[0]['score']) - float(candidates[1]['score']), 6)},
|
|
789
|
+
'counterEvidence': {},
|
|
790
|
+
'confidence': 0.5,
|
|
791
|
+
})
|
|
792
|
+
# supersede hint from explicit correction markers in top-5
|
|
793
|
+
for c in candidates[:5]:
|
|
794
|
+
if any(m in str(c.get('excerpt') or '') for m in self._J_MARKERS):
|
|
795
|
+
rows.append({
|
|
796
|
+
'schemaVersion': 1, 'namespace': base.NAMESPACE,
|
|
797
|
+
'policyVersion': self.JUDGEMENT_POLICY,
|
|
798
|
+
'observationId': str(p.get('observationId', '')),
|
|
799
|
+
'contextVersion': cv, 'memoryIndexVersion': miv,
|
|
800
|
+
'kindCandidate': 'conflict_or_supersede_candidate',
|
|
801
|
+
'suggestion': 'supersede_suggest',
|
|
802
|
+
'sourceIds': [c['memoryId']],
|
|
803
|
+
'supportEvidence': {'markerHit': True,
|
|
804
|
+
'denseScore': c['score']},
|
|
805
|
+
'counterEvidence': {},
|
|
806
|
+
'confidence': 0.6,
|
|
807
|
+
})
|
|
808
|
+
break
|
|
809
|
+
return rows
|
|
810
|
+
|
|
811
|
+
def _append_judgement_shadow(self, rows):
|
|
812
|
+
if not self.dsh_home or not rows:
|
|
813
|
+
return
|
|
814
|
+
d = self._semantic_dir()
|
|
815
|
+
try:
|
|
816
|
+
os.makedirs(d, exist_ok=True)
|
|
817
|
+
path = os.path.join(d, 'judgement-shadow.jsonl')
|
|
818
|
+
lines = []
|
|
819
|
+
if os.path.isfile(path):
|
|
820
|
+
with open(path, encoding='utf-8') as f:
|
|
821
|
+
lines = [l for l in f.read().splitlines() if l.strip()]
|
|
822
|
+
lines.extend(base.dumps(r) for r in rows)
|
|
823
|
+
lines = lines[-SHADOW_LOG_MAX:]
|
|
824
|
+
fd, tmp = base.tempfile.mkstemp(dir=d, prefix='.tmp-judge-',
|
|
825
|
+
suffix='.jsonl')
|
|
826
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
827
|
+
fh.write(('\n'.join(lines) + '\n').encode('utf-8'))
|
|
828
|
+
fh.flush()
|
|
829
|
+
os.fsync(fh.fileno())
|
|
830
|
+
os.replace(tmp, path)
|
|
831
|
+
except OSError as exc:
|
|
832
|
+
base.diag('judgement-shadow-append-failed: ' + str(exc))
|
|
833
|
+
|
|
834
|
+
# ---------- overrides ----------
|
|
835
|
+
|
|
836
|
+
def handle_index_commit(self, req):
|
|
837
|
+
frames = super().handle_index_commit(req)
|
|
838
|
+
accepted = bool(frames and frames[-1].get('payload', {}).get('accepted'))
|
|
839
|
+
if accepted and self.embedder is not None:
|
|
840
|
+
for key in list(self.derived.keys()):
|
|
841
|
+
if self.vectors.get(key, {}).get('stale', True) or \
|
|
842
|
+
self.vectors.get(key, {}).get('memoryIndexVersion') != \
|
|
843
|
+
self.derived[key]['memoryIndexVersion']:
|
|
844
|
+
try:
|
|
845
|
+
persisted, n = self.build_vectors(*key)
|
|
846
|
+
base.diag('vectors built for %s: chunks=%d persisted=%s'
|
|
847
|
+
% (key, n, persisted))
|
|
848
|
+
except Exception as exc: # noqa: BLE001
|
|
849
|
+
base.diag('vector-build-failed %s: %s' % (key, exc))
|
|
850
|
+
return frames
|
|
851
|
+
|
|
852
|
+
def maybe_activation(self, req, p):
|
|
853
|
+
"""Semantic worker stage M7-3: no activations at all (fake path
|
|
854
|
+
suppressed; real proactive activation arrives in M7-6)."""
|
|
855
|
+
return None
|
|
856
|
+
|
|
857
|
+
def handle_context_push(self, req):
|
|
858
|
+
"""Wrapper: any exception in the semantic pipeline is persisted to
|
|
859
|
+
fv2-debug.log before re-raising (protocol layer turns it into an
|
|
860
|
+
error frame; without this log the failure was invisible on live)."""
|
|
861
|
+
try:
|
|
862
|
+
return self._handle_context_push_impl(req)
|
|
863
|
+
except Exception as exc: # noqa: BLE001
|
|
864
|
+
try:
|
|
865
|
+
dbg = os.path.join(self.dsh_home or '', 'memory',
|
|
866
|
+
'semantic', 'fv2-debug.log')
|
|
867
|
+
with open(dbg, 'a', encoding='utf-8') as f:
|
|
868
|
+
f.write('CTX-PUSH-EXC: %s\n%s\n' % (
|
|
869
|
+
repr(exc)[:300],
|
|
870
|
+
__import__('traceback').format_exc()[-1800:]))
|
|
871
|
+
except Exception:
|
|
872
|
+
pass
|
|
873
|
+
raise
|
|
874
|
+
|
|
875
|
+
def _handle_context_push_impl(self, req):
|
|
876
|
+
frames = super().handle_context_push(req)
|
|
877
|
+
p = req.get('payload') or {}
|
|
878
|
+
if not (frames and frames[0].get('payload', {}).get('accepted')):
|
|
879
|
+
return frames
|
|
880
|
+
miv = str((p.get('index') or {}).get('memoryIndexVersion', ''))
|
|
881
|
+
if not base.RE_IDX.match(miv):
|
|
882
|
+
return frames
|
|
883
|
+
text_parts = []
|
|
884
|
+
for seg in ([p.get('trigger')] + list(p.get('window') or [])):
|
|
885
|
+
if isinstance(seg, dict) and isinstance(seg.get('text'), str):
|
|
886
|
+
text_parts.append(seg['text'])
|
|
887
|
+
query = ' '.join(text_parts)[-2000:]
|
|
888
|
+
session = p.get('session') or {}
|
|
889
|
+
candidates = self.dense_search(query, str(session.get('workspaceKey', '')),
|
|
890
|
+
session.get('scope'), miv) or []
|
|
891
|
+
conflict_dropped = []
|
|
892
|
+
if candidates:
|
|
893
|
+
# audit H4: corrected memories are hard-suppressed before ranking
|
|
894
|
+
candidates, conflict_dropped = self._conflict_filter(p, candidates)
|
|
895
|
+
# D6 frozen fusion (M7-7.5): weighted hybrid, not dense-only
|
|
896
|
+
candidates = self.hybrid_rank(candidates, query,
|
|
897
|
+
str(session.get('workspaceKey', '')),
|
|
898
|
+
session.get('scope'), miv)
|
|
899
|
+
if candidates:
|
|
900
|
+
self._append_shadow({
|
|
901
|
+
'schemaVersion': 1,
|
|
902
|
+
'namespace': base.NAMESPACE,
|
|
903
|
+
'policyVersion': 'semantic_shadow_v1',
|
|
904
|
+
'observationId': str(p.get('observationId', '')),
|
|
905
|
+
'workerEpoch': str(req.get('workerEpoch', '')),
|
|
906
|
+
'memoryIndexVersion': miv,
|
|
907
|
+
'method': self.search_policy['mode'],
|
|
908
|
+
'queryChars': len(query),
|
|
909
|
+
'conflictDropped': conflict_dropped,
|
|
910
|
+
'candidates': candidates,
|
|
911
|
+
})
|
|
912
|
+
# ---- M7-7 judgement shadow (audit only) ----
|
|
913
|
+
if candidates:
|
|
914
|
+
self._append_judgement_shadow(self._judgement_rows(p, candidates,
|
|
915
|
+
query))
|
|
916
|
+
# ---- M7-6 dual-threshold activation (shadow default) ----
|
|
917
|
+
# no corpus view / no candidates at all -> nothing semantic to say;
|
|
918
|
+
# activation path stays silent (fail closed, no log noise)
|
|
919
|
+
if candidates:
|
|
920
|
+
state = self._session_state(p)
|
|
921
|
+
state.obs += 1
|
|
922
|
+
features = self._activation_features(p, candidates)
|
|
923
|
+
score = self._semantic_score(features)
|
|
924
|
+
decision = self._activation_decision(state, score)
|
|
925
|
+
state.lastScore = score
|
|
926
|
+
state.lastFeatures = features
|
|
927
|
+
row = {
|
|
928
|
+
'ts': int(time.time()),
|
|
929
|
+
'schemaVersion': 1, 'namespace': base.NAMESPACE,
|
|
930
|
+
'policyVersion': ACTIVATION_POLICY_VERSION,
|
|
931
|
+
'mode': self.activation_policy['mode'],
|
|
932
|
+
'observationId': str(p.get('observationId', '')),
|
|
933
|
+
'obs': state.obs, 'score': score, 'decision': decision,
|
|
934
|
+
'arming': state.arming,
|
|
935
|
+
'features': features,
|
|
936
|
+
}
|
|
937
|
+
if decision == 'emit':
|
|
938
|
+
state.lastEmitObs = state.obs
|
|
939
|
+
act = self._build_activation(req, p, candidates, score, features)
|
|
940
|
+
if act is None:
|
|
941
|
+
row['decision'] = 'emit-blocked-no-candidates'
|
|
942
|
+
else:
|
|
943
|
+
row['activationId'] = act['activationId']
|
|
944
|
+
row['level'] = act['level']
|
|
945
|
+
if self.activation_policy['mode'] == 'active':
|
|
946
|
+
frames.append(self._frame(req, 'activation_request',
|
|
947
|
+
{'activation': act},
|
|
948
|
+
fid_prefix='act_'))
|
|
949
|
+
self._append_activation_shadow(row)
|
|
950
|
+
# ---- feature v2 two-lane decision (shadow rows always; wire emits
|
|
951
|
+
# gated by embedding-config activationEmitMode, default shadow) ----
|
|
952
|
+
try:
|
|
953
|
+
self._fv2_shadow_decide(req, p, candidates or [], frames)
|
|
954
|
+
except Exception as _fv2_err:
|
|
955
|
+
base.diag('fv2-callsite-error: ' + str(_fv2_err)[:300])
|
|
956
|
+
return frames
|
|
957
|
+
|
|
958
|
+
def handle_frame(self, req):
|
|
959
|
+
import traceback as _tb
|
|
960
|
+
import sys as _sys
|
|
961
|
+
try:
|
|
962
|
+
return super().handle_frame(req)
|
|
963
|
+
except Exception:
|
|
964
|
+
_sys.stderr.write('[fv2-trace] ' + _tb.format_exc() + '\n')
|
|
965
|
+
raise
|
|
966
|
+
|
|
967
|
+
def handle_close_session(self, req):
|
|
968
|
+
p = req.get('payload') or {}
|
|
969
|
+
sid = str(p.get('sessionId', ''))
|
|
970
|
+
if sid:
|
|
971
|
+
for key in [k for k in self.session_states if k[0] == sid]:
|
|
972
|
+
del self.session_states[key]
|
|
973
|
+
return super().handle_close_session(req)
|
|
974
|
+
|
|
975
|
+
def handle_health(self, req):
|
|
976
|
+
frames = super().handle_health(req)
|
|
977
|
+
payload = frames[0]['payload']
|
|
978
|
+
payload['worker'] = 'semantic'
|
|
979
|
+
payload['capabilities'] = ['index-sync-v1', 'embedding-shadow-v1']
|
|
980
|
+
payload['embedding'] = self.embedding_view()
|
|
981
|
+
payload['featuresV2'] = {
|
|
982
|
+
'loaded': self._fv2 is not None,
|
|
983
|
+
'invalid': self._fv2_invalid or None,
|
|
984
|
+
'rowsWritten': self._fv2_rows,
|
|
985
|
+
}
|
|
986
|
+
return frames
|
|
987
|
+
|
|
988
|
+
# ---- feature v2 shadow wiring (round-1) ----
|
|
989
|
+
|
|
990
|
+
def _fv2_append(self, filename, obj):
|
|
991
|
+
if not self.dsh_home:
|
|
992
|
+
return
|
|
993
|
+
d = self._semantic_dir()
|
|
994
|
+
try:
|
|
995
|
+
os.makedirs(d, exist_ok=True)
|
|
996
|
+
path = os.path.join(d, filename)
|
|
997
|
+
prev = []
|
|
998
|
+
if os.path.isfile(path):
|
|
999
|
+
with open(path, encoding='utf-8') as f:
|
|
1000
|
+
prev = [l for l in f.read().splitlines() if l.strip()]
|
|
1001
|
+
prev.append(base.dumps(obj))
|
|
1002
|
+
prev = prev[-256:]
|
|
1003
|
+
fd, tmp = base.tempfile.mkstemp(dir=d, prefix='.tmp-fv2-',
|
|
1004
|
+
suffix='.jsonl')
|
|
1005
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
1006
|
+
fh.write(('\n'.join(prev) + '\n').encode('utf-8'))
|
|
1007
|
+
fh.flush()
|
|
1008
|
+
os.fsync(fh.fileno())
|
|
1009
|
+
os.replace(tmp, path)
|
|
1010
|
+
except OSError as exc:
|
|
1011
|
+
base.diag('fv2-append-failed: ' + str(exc))
|
|
1012
|
+
|
|
1013
|
+
def _fv2_repetition(self, sid, query):
|
|
1014
|
+
"""30-min decayed counters; logging-only, never activates."""
|
|
1015
|
+
now = time.time()
|
|
1016
|
+
topic = featv2.normalize_text(query)[:24]
|
|
1017
|
+
key = (sid, hashlib.sha256(topic.encode('utf-8')).hexdigest()[:16])
|
|
1018
|
+
st = self._fv2_rep.get(key) or {'mentions': 0, 'failures': 0,
|
|
1019
|
+
'lastSeen': 0}
|
|
1020
|
+
if now - st['lastSeen'] > 1800:
|
|
1021
|
+
st['mentions'] = 0
|
|
1022
|
+
st['failures'] = 0
|
|
1023
|
+
st['mentions'] += 1
|
|
1024
|
+
st['lastSeen'] = int(now)
|
|
1025
|
+
self._fv2_rep[key] = st
|
|
1026
|
+
return {'topicKey': key[1], 'mentions': st['mentions'],
|
|
1027
|
+
'failures': st['failures'], 'decayWindowSec': 1800}
|
|
1028
|
+
|
|
1029
|
+
def _intent_config_hash(self):
|
|
1030
|
+
ip_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
1031
|
+
'policies', 'recall_intent_lr_v1.json')
|
|
1032
|
+
try:
|
|
1033
|
+
with open(ip_path, encoding='utf-8') as f:
|
|
1034
|
+
ip = json.load(f)
|
|
1035
|
+
probe = {k: v for k, v in ip.items() if k != 'configHash'}
|
|
1036
|
+
payload = json.dumps(probe, sort_keys=True, ensure_ascii=False)
|
|
1037
|
+
return 'cfgh_' + hashlib.sha256(
|
|
1038
|
+
payload.encode('utf-8')).hexdigest()[:32]
|
|
1039
|
+
except Exception as exc:
|
|
1040
|
+
base.diag('intent-hash-failed: ' + str(exc))
|
|
1041
|
+
return None
|
|
1042
|
+
|
|
1043
|
+
def _fv2_shadow_decide(self, req, p, candidates, frames):
|
|
1044
|
+
try:
|
|
1045
|
+
dbg = os.path.join(self.dsh_home or '', 'memory', 'semantic',
|
|
1046
|
+
'fv2-debug.log')
|
|
1047
|
+
with open(dbg, 'a', encoding='utf-8') as f:
|
|
1048
|
+
f.write('CALLED obs=%s ncand=%s fv2=%s nrefs=%s nev=%s ws=%s\n' % (
|
|
1049
|
+
str(p.get('observationId', ''))[:24],
|
|
1050
|
+
len(candidates) if candidates else 0,
|
|
1051
|
+
self._fv2 is not None,
|
|
1052
|
+
len(p.get('memoryRefs') or []),
|
|
1053
|
+
len(p.get('evidence') or []),
|
|
1054
|
+
str((p.get('session') or {}).get('workspaceKey', ''))[-16:]))
|
|
1055
|
+
except Exception:
|
|
1056
|
+
pass
|
|
1057
|
+
"""Round-1: compute the feature-v2 decision alongside v1 and append a
|
|
1058
|
+
bounded shadow row. Never emits frames; fail closed when policy
|
|
1059
|
+
artifacts are invalid. No raw query text or absolute paths are
|
|
1060
|
+
persisted (hash + chars only)."""
|
|
1061
|
+
obs = str(p.get('observationId', ''))
|
|
1062
|
+
session = p.get('session') or {}
|
|
1063
|
+
sid = str(session.get('sessionId', ''))
|
|
1064
|
+
query = ' '.join(
|
|
1065
|
+
seg.get('text') for seg in ([p.get('trigger')] +
|
|
1066
|
+
list(p.get('window') or []))
|
|
1067
|
+
if isinstance(seg, dict) and isinstance(seg.get('text'), str))[-2000:]
|
|
1068
|
+
base_row = {
|
|
1069
|
+
'schemaVersion': 1, 'namespace': base.NAMESPACE,
|
|
1070
|
+
'featurePolicyVersion': (featv2.FEATURES_POLICY_VERSION
|
|
1071
|
+
if featv2 else None),
|
|
1072
|
+
'observationId': obs, 'queryChars': len(query),
|
|
1073
|
+
'candidateCount': len(candidates or []),
|
|
1074
|
+
'v1Available': bool(candidates),
|
|
1075
|
+
}
|
|
1076
|
+
if self._fv2 is None:
|
|
1077
|
+
base_row.update({'shadowReason': 'policy-invalid',
|
|
1078
|
+
'error': self._fv2_invalid[:160]})
|
|
1079
|
+
self._fv2_rows += 1
|
|
1080
|
+
self._fv2_append('activation-shadow-v2.jsonl', base_row)
|
|
1081
|
+
return
|
|
1082
|
+
try:
|
|
1083
|
+
head = self._fv2['head']
|
|
1084
|
+
pol = self._fv2['policy']
|
|
1085
|
+
intent = featv2.infer_recall_intent(query, head)
|
|
1086
|
+
dact = featv2.infer_dialogue_act(query, intent)
|
|
1087
|
+
tneed = featv2.infer_task_need(dact)
|
|
1088
|
+
top = candidates[0] if candidates else None
|
|
1089
|
+
cand_text = ''
|
|
1090
|
+
ws_ref_key = None
|
|
1091
|
+
if top is not None:
|
|
1092
|
+
for key in self.derived:
|
|
1093
|
+
recs = self.derived[key].get('records') or []
|
|
1094
|
+
if any(rr['memoryId'] == top['memoryId'] for rr in recs):
|
|
1095
|
+
ws_ref_key = key
|
|
1096
|
+
for rr in recs:
|
|
1097
|
+
if rr['memoryId'] == top['memoryId']:
|
|
1098
|
+
cand_text = rr.get('text') or ''
|
|
1099
|
+
break
|
|
1100
|
+
break
|
|
1101
|
+
containment = featv2.lexical_containment(query, cand_text)
|
|
1102
|
+
dense_top = float(top['score']) if top else 0.0
|
|
1103
|
+
second = (float(candidates[1]['score'])
|
|
1104
|
+
if len(candidates) > 1 else 0.0)
|
|
1105
|
+
margin = max(0.0, dense_top - second)
|
|
1106
|
+
tl = query.lower()
|
|
1107
|
+
mark = int(any(x in tl for x in
|
|
1108
|
+
featv2.INTERROG + featv2.RECALL_CTX))
|
|
1109
|
+
mem_ref_ids = {mr.get('memoryId')
|
|
1110
|
+
for mr in (p.get('memoryRefs') or [])
|
|
1111
|
+
if isinstance(mr, dict)}
|
|
1112
|
+
# 2026-08-30 candidateHit 接缝修复(变体 A,受控 shadow 14 条对账发现):
|
|
1113
|
+
# 生产 refs=JS 词法臂(快照),与稠密 top-K 交集窄 → candidateHit 12/12 False
|
|
1114
|
+
# → 高 intent 全 suppress。变体 A(candidatehit_variant_replay,63 gold 复放:
|
|
1115
|
+
# precision 0.846 / emitOnSup 0):词法臂对 top-K 候选有实质 BM25 命中(≥12.0,
|
|
1116
|
+
# activate 与 non-activate 在此分离)即信任单臂证据。baseline 交集仍保留。
|
|
1117
|
+
max_lex_raw = 0.0
|
|
1118
|
+
try:
|
|
1119
|
+
_lex_key = (wsref_of(str(session.get('workspaceKey', ''))),
|
|
1120
|
+
session.get('scope'))
|
|
1121
|
+
_lex_entry = self.derived.get(_lex_key) or {}
|
|
1122
|
+
_lex_miv = _lex_entry.get('memoryIndexVersion')
|
|
1123
|
+
_lex_cached = getattr(self, '_lex_bm25_cache', None)
|
|
1124
|
+
if not _lex_cached or _lex_cached[0] != _lex_miv:
|
|
1125
|
+
_lex_docs = [(rr.get('memoryId'), rr.get('text') or '')
|
|
1126
|
+
for rr in (_lex_entry.get('records') or [])]
|
|
1127
|
+
_lex_cached = (_lex_miv,
|
|
1128
|
+
LexicalBM25([_tokenize(t) for _, t in _lex_docs]),
|
|
1129
|
+
{mid: i for i, (mid, _) in enumerate(_lex_docs)})
|
|
1130
|
+
self._lex_bm25_cache = _lex_cached
|
|
1131
|
+
_qt = _tokenize(query)
|
|
1132
|
+
for c in candidates:
|
|
1133
|
+
_di = _lex_cached[2].get(c.get('memoryId'))
|
|
1134
|
+
if _di is not None:
|
|
1135
|
+
_s = _lex_cached[1].score(_qt, _di)
|
|
1136
|
+
if _s > max_lex_raw:
|
|
1137
|
+
max_lex_raw = _s
|
|
1138
|
+
except Exception:
|
|
1139
|
+
max_lex_raw = 0.0
|
|
1140
|
+
candidate_hit = bool(mem_ref_ids &
|
|
1141
|
+
{c['memoryId'] for c in candidates}) \
|
|
1142
|
+
or max_lex_raw >= 12.0
|
|
1143
|
+
rep = self._fv2_repetition(sid, query)
|
|
1144
|
+
evidence = (p.get('evidence')
|
|
1145
|
+
if isinstance(p.get('evidence'), list) else [])
|
|
1146
|
+
cand_ids = {c['memoryId'] for c in candidates}
|
|
1147
|
+
|
|
1148
|
+
def _evidence_gate(field):
|
|
1149
|
+
"""Per-candidate hard gate (controlled-shadow 2026-08-25
|
|
1150
|
+
finding: a session-wide any() gate let one stale/corrected
|
|
1151
|
+
memory anywhere in the evidence stream permanently suppress
|
|
1152
|
+
ALL 94 observations; the gate may only fire when the affected
|
|
1153
|
+
memoryId is among the CURRENT top-K candidates). See
|
|
1154
|
+
docs/M7-ACTIVATION-V2-CONTROLLED-SHADOW.md §3."""
|
|
1155
|
+
return any(e.get('memoryId') in cand_ids and
|
|
1156
|
+
(e.get('freshness') == 'stale' if field == 'stale'
|
|
1157
|
+
else int(e.get(field) or 0) > 0)
|
|
1158
|
+
for e in evidence if isinstance(e, dict))
|
|
1159
|
+
|
|
1160
|
+
correction_gate = _evidence_gate('correction')
|
|
1161
|
+
stale_gate = _evidence_gate('stale')
|
|
1162
|
+
# F1 降版本容忍(2026-08-31,docs/A3-RISK-ASSESSMENT-20260830.md):
|
|
1163
|
+
# 候选本身就是当前语料快照检索出来的记录(digest 与语料同版本),而 evidence
|
|
1164
|
+
# aggregate 的 stale 只表示「最近一条证据事件描述的是旧版本 digest」——语料
|
|
1165
|
+
# 每次重锚定(每日日志追加)都会让全部历史证据一夜变 stale,压制窗口随之振荡
|
|
1166
|
+
# (实测 35/64 记忆 stale,emit 全灭,靠用户恰好发起读取才自愈)。纠正风险已由
|
|
1167
|
+
# correction 门独立覆盖;stale 门据此降级:不进 hardGates(不再 suppress/
|
|
1168
|
+
# prefetch 压制),只作为 reasonCodes 标注 + emit 降 prefetch 的软信号保留。
|
|
1169
|
+
# 不动 fv2 决策核(hardGates 仍透传 correction)。
|
|
1170
|
+
features = {
|
|
1171
|
+
'id': obs, 'text': query,
|
|
1172
|
+
'denseTop': round(dense_top, 6), 'margin': round(margin, 6),
|
|
1173
|
+
'containment': round(containment, 4), 'mark': mark,
|
|
1174
|
+
'nCand': len(candidates), 'candidateHit': candidate_hit,
|
|
1175
|
+
'resolvedTargets': None, 'requiredHint': None,
|
|
1176
|
+
'hardGates': {'correction': correction_gate},
|
|
1177
|
+
'repetition': rep,
|
|
1178
|
+
'requiresRelayFlag': False, 'piiClass': 'unknown',
|
|
1179
|
+
}
|
|
1180
|
+
out = featv2.decide_activation_v2(features, head, pol)
|
|
1181
|
+
# stale 软处理(2026-08-27 优化④ 语义保留):emit 遇 stale 降级为 prefetch
|
|
1182
|
+
# (stale 内容不注入,保留预取),并标记 staleDowngraded 供索引刷新。
|
|
1183
|
+
if stale_gate and out.get('decision') == 'emit':
|
|
1184
|
+
out['decision'] = 'prefetch'
|
|
1185
|
+
out['reasonCodes'] = list(out.get('reasonCodes') or []) + ['stale_downgraded']
|
|
1186
|
+
nh = hashlib.sha256(featv2.normalize_text(query).encode(
|
|
1187
|
+
'utf-8')).hexdigest()[:16]
|
|
1188
|
+
row = {
|
|
1189
|
+
'ts': int(time.time()),
|
|
1190
|
+
'schemaVersion': 1, 'namespace': base.NAMESPACE,
|
|
1191
|
+
'policyVersions': {
|
|
1192
|
+
'features': featv2.FEATURES_POLICY_VERSION,
|
|
1193
|
+
'intent': featv2.INTENT_POLICY_VERSION,
|
|
1194
|
+
'activation': featv2.ACTIVATION_POLICY_VERSION},
|
|
1195
|
+
'configHashes': {
|
|
1196
|
+
'activation': pol['configHash'],
|
|
1197
|
+
'intent': self._intent_config_hash()},
|
|
1198
|
+
'goldDigest': pol['goldDigest'],
|
|
1199
|
+
'mode': pol['mode'],
|
|
1200
|
+
'observationId': obs, 'queryChars': len(query),
|
|
1201
|
+
'normTextHash': nh,
|
|
1202
|
+
'normTextLen': len(featv2.normalize_text(query)),
|
|
1203
|
+
'maxLexRaw': round(max_lex_raw, 3),
|
|
1204
|
+
'lane': out.get('features', {}).get('lane'),
|
|
1205
|
+
'decision': out['decision'],
|
|
1206
|
+
'reasonCodes': out['reasonCodes'],
|
|
1207
|
+
'features': out.get('features'),
|
|
1208
|
+
'candidateProvenance': [
|
|
1209
|
+
{'memoryId': c['memoryId'],
|
|
1210
|
+
'recordDigest': c['recordDigest']}
|
|
1211
|
+
for c in candidates[:3]],
|
|
1212
|
+
'candidateHit': candidate_hit,
|
|
1213
|
+
'requiresCrossWorkspaceRelay':
|
|
1214
|
+
bool(features['requiresRelayFlag']),
|
|
1215
|
+
'piiClass': features['piiClass'], 'advisoryOnly': None,
|
|
1216
|
+
}
|
|
1217
|
+
self._fv2_rows += 1
|
|
1218
|
+
row['rowsWritten'] = self._fv2_rows
|
|
1219
|
+
self._fv2_append('activation-shadow-v2.jsonl', row)
|
|
1220
|
+
# ---- fv2 emit bridge:shadow 行恒写(观测连续性);发射仅在上面的
|
|
1221
|
+
# activationEmitMode 门放行时发生。canary-explicit 只发 explicit 车道;
|
|
1222
|
+
# proactive 车道 round-1 本就到 prefetch 为止,结构上不会发射。----
|
|
1223
|
+
_feats = out.get('features') or {}
|
|
1224
|
+
_lane = str(_feats.get('lane') or '') if isinstance(_feats, dict) else ''
|
|
1225
|
+
if (out.get('decision') == 'emit'
|
|
1226
|
+
and (self.activation_emit_mode == 'active'
|
|
1227
|
+
or (self.activation_emit_mode == 'canary-explicit'
|
|
1228
|
+
and _lane == 'explicit'))):
|
|
1229
|
+
_act = self._build_fv2_activation(req, p, candidates or [], out)
|
|
1230
|
+
if _act is not None:
|
|
1231
|
+
frames.append(self._frame(req, 'activation_request',
|
|
1232
|
+
{'activation': _act},
|
|
1233
|
+
fid_prefix='act_'))
|
|
1234
|
+
base.diag('fv2-emit act=%s lane=%s mode=%s' % (
|
|
1235
|
+
str(_act.get('activationId', ''))[:24], _lane,
|
|
1236
|
+
self.activation_emit_mode))
|
|
1237
|
+
except Exception as exc: # fail closed; retrieval unaffected
|
|
1238
|
+
import traceback as _tb2
|
|
1239
|
+
base.diag('FV2-DEEP:' + chr(10) + _tb2.format_exc()[-3000:])
|
|
1240
|
+
base.diag('fv2-decide-failed: ' + str(exc)[:200])
|
|
1241
|
+
self._fv2_rows += 1
|
|
1242
|
+
self._fv2_append('activation-shadow-v2.jsonl', {
|
|
1243
|
+
'shadowReason': 'decide-failed',
|
|
1244
|
+
'error': str(exc)[:200], 'observationId': obs})
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def load_embedding_config_from_env(dsh_home=''):
|
|
1248
|
+
"""M7-8 live path: env var overrides; otherwise fall back to a
|
|
1249
|
+
host-provisioned config at <dsh-home>/memory/semantic/embedding-config.json
|
|
1250
|
+
so the real provider survives restarts without env inheritance."""
|
|
1251
|
+
path = os.environ.get(EMBEDDING_CONFIG_ENV, '')
|
|
1252
|
+
if not path and dsh_home:
|
|
1253
|
+
cand = os.path.join(dsh_home, 'memory', 'semantic',
|
|
1254
|
+
'embedding-config.json')
|
|
1255
|
+
if os.path.isfile(cand):
|
|
1256
|
+
path = cand
|
|
1257
|
+
if not path:
|
|
1258
|
+
return {}
|
|
1259
|
+
try:
|
|
1260
|
+
with open(path, encoding='utf-8') as f:
|
|
1261
|
+
cfg = json.load(f)
|
|
1262
|
+
return cfg if isinstance(cfg, dict) else {}
|
|
1263
|
+
except (OSError, ValueError) as exc:
|
|
1264
|
+
base.diag('embedding-config-unreadable: ' + str(exc))
|
|
1265
|
+
return {}
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def run_loop(worker):
|
|
1269
|
+
"""Byte-level twin of worker_v1.main()'s stdin/stdout loop, bound to
|
|
1270
|
+
the semantic worker. Kept as a copy so the tested M7-0 file stays
|
|
1271
|
+
untouched."""
|
|
1272
|
+
out = sys.stdout.buffer
|
|
1273
|
+
inp = sys.stdin.buffer
|
|
1274
|
+
while True:
|
|
1275
|
+
raw = inp.readline(base.MAX_LINE_BYTES + 2)
|
|
1276
|
+
if raw == b'':
|
|
1277
|
+
break
|
|
1278
|
+
ended_with_newline = raw.endswith(b'\n')
|
|
1279
|
+
line = raw[:-1] if ended_with_newline else raw
|
|
1280
|
+
oversized = (len(line) > base.MAX_LINE_BYTES) or (
|
|
1281
|
+
not ended_with_newline and len(raw) >= base.MAX_LINE_BYTES + 1)
|
|
1282
|
+
if oversized:
|
|
1283
|
+
err = worker.error_frame({'requestId': '', 'workerEpoch': '',
|
|
1284
|
+
'sentAt': 0}, 'line-oversize')
|
|
1285
|
+
out.write((base.dumps(err) + '\n').encode('utf-8'))
|
|
1286
|
+
out.flush()
|
|
1287
|
+
break
|
|
1288
|
+
req_for_error = {'requestId': '', 'workerEpoch': '', 'sentAt': 0}
|
|
1289
|
+
try:
|
|
1290
|
+
obj = json.loads(line.decode('utf-8'))
|
|
1291
|
+
if isinstance(obj, dict):
|
|
1292
|
+
req_for_error = {'requestId': str(obj.get('requestId', '')),
|
|
1293
|
+
'workerEpoch': str(obj.get('workerEpoch', '')),
|
|
1294
|
+
'sentAt': obj.get('sentAt', 0)}
|
|
1295
|
+
except (UnicodeDecodeError, ValueError):
|
|
1296
|
+
obj = None
|
|
1297
|
+
if not isinstance(obj, dict) or not base.envelope_shape_ok(obj):
|
|
1298
|
+
out.write((base.dumps(worker.error_frame(req_for_error,
|
|
1299
|
+
'invalid-envelope')) + '\n').encode('utf-8'))
|
|
1300
|
+
out.flush()
|
|
1301
|
+
worker.counts['errors'] += 1
|
|
1302
|
+
continue
|
|
1303
|
+
if worker.expect_epoch and obj['workerEpoch'] != worker.expect_epoch:
|
|
1304
|
+
out.write((base.dumps(worker.error_frame(req_for_error,
|
|
1305
|
+
'epoch-mismatch')) + '\n').encode('utf-8'))
|
|
1306
|
+
out.flush()
|
|
1307
|
+
worker.counts['errors'] += 1
|
|
1308
|
+
continue
|
|
1309
|
+
try:
|
|
1310
|
+
frames = worker.handle_frame(obj)
|
|
1311
|
+
except base.ProtocolError as exc:
|
|
1312
|
+
worker.counts['errors'] += 1
|
|
1313
|
+
frames = [worker.error_frame(req_for_error, exc.code, exc.detail)]
|
|
1314
|
+
except Exception as exc: # noqa: BLE001 - worker never dies on a bad frame
|
|
1315
|
+
worker.counts['errors'] += 1
|
|
1316
|
+
frames = [worker.error_frame(req_for_error, 'internal-error',
|
|
1317
|
+
str(exc)[:120])]
|
|
1318
|
+
for fr in frames:
|
|
1319
|
+
out.write((base.dumps(fr) + '\n').encode('utf-8'))
|
|
1320
|
+
out.flush()
|
|
1321
|
+
return 0
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def main():
|
|
1325
|
+
ap = argparse.ArgumentParser(add_help=False)
|
|
1326
|
+
ap.add_argument('--expect-epoch', default='')
|
|
1327
|
+
ap.add_argument('--dsh-home', default='')
|
|
1328
|
+
ap.add_argument('--selftest', action='store_true')
|
|
1329
|
+
args, _unknown = ap.parse_known_args()
|
|
1330
|
+
if args.selftest:
|
|
1331
|
+
base.run_selftest()
|
|
1332
|
+
w = SemanticWorker('ep', '', {'provider': 'hash-pre-v1',
|
|
1333
|
+
'dimension': 64})
|
|
1334
|
+
view = w.embedding_view()
|
|
1335
|
+
assert view['enabled'] is True and view['ready'] is False
|
|
1336
|
+
sys.stderr.write('SEMANTIC SELFTEST OK\n')
|
|
1337
|
+
return 0
|
|
1338
|
+
cfg = load_embedding_config_from_env(args.dsh_home)
|
|
1339
|
+
worker = SemanticWorker(args.expect_epoch, args.dsh_home, cfg)
|
|
1340
|
+
return run_loop(worker)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
if __name__ == '__main__':
|
|
1344
|
+
sys.exit(main())
|