@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.
@@ -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())