dsh-prime-memory 0.26.5-beta.0 → 0.26.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +52 -1
  4. package/dist/client.js +36785 -134
  5. package/dist/config.d.ts +9 -0
  6. package/dist/config.js +8 -0
  7. package/dist/contract.d.ts +76 -0
  8. package/dist/embedding-child.cjs +192 -0
  9. package/dist/embedding-worker.cjs +2 -0
  10. package/dist/graph/apply.d.ts +9 -0
  11. package/dist/graph/apply.js +12 -2
  12. package/dist/graph/constraints.d.ts +3 -1
  13. package/dist/graph/constraints.js +4 -2
  14. package/dist/graph/preview.d.ts +92 -0
  15. package/dist/graph/preview.js +186 -0
  16. package/dist/graph/search.d.ts +32 -6
  17. package/dist/graph/search.js +102 -12
  18. package/dist/graph/types.d.ts +26 -1
  19. package/dist/graph/types.js +26 -1
  20. package/dist/hooks/recall.js +36 -2
  21. package/dist/index.js +23 -3
  22. package/dist/llm.d.ts +7 -2
  23. package/dist/llm.js +14 -22
  24. package/dist/pipeline/graph.d.ts +26 -1
  25. package/dist/pipeline/graph.js +74 -4
  26. package/dist/pipeline/l1.js +87 -26
  27. package/dist/pipeline/md-sync.js +4 -2
  28. package/dist/pipeline/runner.d.ts +26 -0
  29. package/dist/pipeline/runner.js +74 -7
  30. package/dist/pipeline/slot-migrate.d.ts +3 -2
  31. package/dist/pipeline/slot-migrate.js +3 -2
  32. package/dist/projection/recall.d.ts +10 -3
  33. package/dist/projection/recall.js +14 -0
  34. package/dist/settings.js +3 -0
  35. package/dist/stats.d.ts +1 -1
  36. package/dist/stats.js +47 -12
  37. package/dist/store/embedding-source.d.ts +4 -2
  38. package/dist/store/embedding-source.js +5 -2
  39. package/dist/store/graph-store.d.ts +111 -4
  40. package/dist/store/graph-store.js +324 -15
  41. package/dist/store/l1.d.ts +17 -9
  42. package/dist/store/l1.js +55 -18
  43. package/dist/store/local-embedding.d.ts +9 -5
  44. package/dist/store/local-embedding.js +137 -17
  45. package/dist/store/memory-md-disable.d.ts +3 -2
  46. package/dist/store/memory-md.d.ts +3 -3
  47. package/dist/store/memory-md.js +6 -4
  48. package/dist/store/runtime-installer.d.ts +13 -1
  49. package/dist/store/runtime-installer.js +40 -2
  50. package/dist/store/session-recalled.d.ts +9 -3
  51. package/dist/store/session-recalled.js +24 -5
  52. package/dist/store/sqlite.d.ts +24 -12
  53. package/dist/store/sqlite.js +97 -31
  54. package/dist/store/supersede.d.ts +10 -2
  55. package/dist/store/supersede.js +10 -2
  56. package/dist/store/validity.d.ts +150 -0
  57. package/dist/store/validity.js +209 -0
  58. package/dist/tools/index.d.ts +15 -1
  59. package/dist/tools/index.js +393 -11
  60. package/dist/types.d.ts +9 -0
  61. package/dist/util/json-repair.d.ts +48 -0
  62. package/dist/util/json-repair.js +285 -0
  63. package/dist/validity-backfill.d.ts +78 -0
  64. package/dist/validity-backfill.js +160 -0
  65. package/dsh.plugin.json +2 -2
  66. package/package.json +5 -4
@@ -19,9 +19,12 @@
19
19
  */
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { applyGraphProjection } from '../graph/apply.js';
22
- import { searchGraphNodes } from '../graph/search.js';
23
- import { GRAPH_JOB_BACKOFF_BASE_MS, GRAPH_JOB_BATCH, GRAPH_JOB_MAX_ATTEMPTS, GRAPH_PRIORITY_BACKFILL, GRAPH_PROJECTOR_VERSION, } from '../graph/types.js';
22
+ import { expandHitRecordIds, searchGraphNodes } from '../graph/search.js';
23
+ import { buildFullView, buildGraphPreview } from '../graph/preview.js';
24
+ import { GRAPH_JOB_BACKOFF_BASE_MS, GRAPH_JOB_BATCH, GRAPH_JOB_MAX_ATTEMPTS, GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS, GRAPH_PRIORITY_BACKFILL, GRAPH_PRIORITY_NEW, GRAPH_PROJECTOR_VERSION, GRAPH_VEC_SEED_CAP, } from '../graph/types.js';
24
25
  import { normPersistence } from '../types.js';
26
+ import { classifyValidity, isExitedState, validToSourceOf } from './validity.js';
27
+ import { readSupersedeMarker } from './supersede.js';
25
28
  import { isZeroVector, vecToBuffer } from './vec-utils.js';
26
29
  const TAG = '[memory][graph]';
27
30
  /** 坏 JSON 容忍解析:图谱表列损坏只损失该行派生信息,不抛。 */
@@ -460,6 +463,98 @@ export class GraphStore {
460
463
  : nodes;
461
464
  return searchGraphNodes(filtered, edges, query, limit);
462
465
  }
466
+ /**
467
+ * §D 第 3 路存储缝(回链记录 id 序;graphLane 直接消费):词法命中 ∪ 路内向量种子
468
+ * → 1-hop 边扩展 → 保序去重的 L1 记录 id 序列。
469
+ *
470
+ * - **向量种子是路内并集、不是新路**:embedding 由调用方传入(装配层惰性查
471
+ * l1.embedText;未就绪/失败传 undefined = 纯词法路,与旧 searchNodes+直连回链
472
+ * 逐位一致);并集不占 RRF 路数名额,"未接线 = 恰为 2 路"不变量不动;
473
+ * - **种子序**:词法命中在前、向量种子追加在后(RRF 用 rank,顺序即权重),
474
+ * 同节点去重;向量种子同样过 visible 族集与 active|disputed 状态闸(宁可漏不可串);
475
+ * - **1-hop 邻居不按族预筛**:来源记录的跨族由 graphLane 尾部记录级 family/scope
476
+ * 双闸兜底(与本路既有纪律同源);
477
+ * - 降级(db 未就绪/空图)返回空数组,不抛。
478
+ *
479
+ * 返回记录 id 而非 hits 是刻意的:回链/扩展属于读路径细节,路侧只需排名序列。
480
+ */
481
+ searchLaneRecords(query, embedding, limit, families) {
482
+ if (!this.db)
483
+ return [];
484
+ const { nodes, edges } = this.loadGraph();
485
+ const visible = families && families.length > 0
486
+ ? nodes.filter((n) => n.families.some((f) => families.includes(f)))
487
+ : nodes;
488
+ const lexical = searchGraphNodes(visible, edges, query, limit);
489
+ let hits = lexical;
490
+ if (embedding && embedding.length > 0) {
491
+ const visibleIds = new Set(visible.map((n) => n.id));
492
+ const seenNodes = new Set(lexical.map((h) => h.node.id));
493
+ const extra = [];
494
+ for (const v of this.searchNodesByVector(embedding, GRAPH_VEC_SEED_CAP)) {
495
+ if (seenNodes.has(v.node.id) || !visibleIds.has(v.node.id))
496
+ continue;
497
+ if (v.node.status !== 'active' && v.node.status !== 'disputed')
498
+ continue;
499
+ seenNodes.add(v.node.id);
500
+ extra.push({ node: v.node, score: v.score, matchedFields: ['name'], matchReason: '向量种子命中' });
501
+ }
502
+ if (extra.length > 0)
503
+ hits = [...lexical, ...extra];
504
+ }
505
+ return expandHitRecordIds(hits, nodes, edges);
506
+ }
507
+ /**
508
+ * 面板图谱预览存储缝(契约 `dsh-memory/graph-preview` 的数据源):
509
+ * loadGraph 一次 + job 计数(pending/running/failed 未定项与全量分母)传入纯函数。
510
+ * 降级(db 未就绪)返**全零结构**不抛——预览是展示面,永不拖垮端点。
511
+ * mode==='full' 时响应附 `full` 轻量块;**缺省时该键缺席**(可选键零漂移纪律,
512
+ * 不是 undefined 占位——消费方 `'full' in res` 与 JSON 形状双判据都干净)。
513
+ */
514
+ buildPreview(seedId, mode) {
515
+ const zero = { seeds: [], stats: { nodes: 0, edges: 0, causalEdges: 0, pendingJobs: 0, jobsTotal: 0 } };
516
+ if (!this.db)
517
+ return mode === 'full' ? { ...zero, full: { nodes: [], edges: [], truncated: false } } : zero;
518
+ const { nodes, edges } = this.loadGraph();
519
+ let pendingJobs = 0;
520
+ let jobsTotal = 0;
521
+ let deadJobs = 0;
522
+ try {
523
+ pendingJobs = this.db
524
+ .prepare("SELECT COUNT(1) AS c FROM graph_projection_jobs WHERE status IN ('pending','running','failed')")
525
+ .get().c;
526
+ jobsTotal = this.db.prepare('SELECT COUNT(1) AS c FROM graph_projection_jobs').get().c;
527
+ deadJobs = this.db.prepare("SELECT COUNT(1) AS c FROM graph_projection_jobs WHERE status = 'dead'").get().c;
528
+ }
529
+ catch (err) {
530
+ this.logger?.warn(`${TAG} 预览 job 计数失败(计数回零): ${err instanceof Error ? err.message : String(err)}`);
531
+ }
532
+ const r = buildGraphPreview(nodes, edges, { seedId, pendingJobs, jobsTotal, deadJobs });
533
+ if (mode === 'full')
534
+ return { ...r, full: buildFullView(nodes, edges) };
535
+ return r;
536
+ }
537
+ /**
538
+ * 清理历史 dead 作业行(纯审计痕迹删除,不影响已重入队的记录)。
539
+ * olderThanMs: 只删早于 now-olderThanMs 的;缺省删全部 dead。
540
+ */
541
+ cleanupDead(olderThanMs) {
542
+ if (!this.db)
543
+ return 0;
544
+ try {
545
+ const cutoff = olderThanMs != null && olderThanMs > 0
546
+ ? new Date(Date.now() - olderThanMs).toISOString()
547
+ : undefined;
548
+ const result = cutoff
549
+ ? this.db.prepare("DELETE FROM graph_projection_jobs WHERE status = 'dead' AND updated_time < ?").run(cutoff)
550
+ : this.db.prepare("DELETE FROM graph_projection_jobs WHERE status = 'dead'").run();
551
+ return Number(result.changes ?? 0);
552
+ }
553
+ catch (err) {
554
+ this.logger?.warn(`${TAG} cleanupDead 失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
555
+ return 0;
556
+ }
557
+ }
463
558
  // ============================
464
559
  // 投影 job 队列
465
560
  // ============================
@@ -472,8 +567,16 @@ export class GraphStore {
472
567
  * 记录跳过。返回实际新建的 job 数。
473
568
  */
474
569
  queueGraphProjection(recordIds, priority) {
570
+ return this.queueGraphProjectionDetailed(recordIds, priority).created;
571
+ }
572
+ /**
573
+ * 入队的详细形态(与 `queueGraphProjection` 同一实现,只是把新建 job id 也交出来):
574
+ * `memory_reproject` 工具面需要 `{queued, jobIds}` 回报,而既有调用方只数 created——
575
+ * 两个返回形状共享一份去重/分片逻辑,不漂移。
576
+ */
577
+ queueGraphProjectionDetailed(recordIds, priority) {
475
578
  if (!this.db || recordIds.length === 0)
476
- return 0;
579
+ return { created: 0, jobIds: [] };
477
580
  try {
478
581
  const db = this.db;
479
582
  const ids = [...new Set(recordIds)];
@@ -492,10 +595,10 @@ export class GraphStore {
492
595
  }
493
596
  const fresh = ids.filter((id) => !blocked.has(id));
494
597
  if (fresh.length === 0)
495
- return 0;
598
+ return { created: 0, jobIds: [] };
496
599
  const now = new Date().toISOString();
497
600
  return this.tx(() => {
498
- let created = 0;
601
+ const jobIds = [];
499
602
  for (let i = 0; i < fresh.length; i += GRAPH_JOB_BATCH) {
500
603
  const batch = fresh.slice(i, i + GRAPH_JOB_BATCH);
501
604
  const jobId = this.newJobId();
@@ -504,14 +607,121 @@ export class GraphStore {
504
607
  for (const id of batch) {
505
608
  db.prepare('INSERT OR REPLACE INTO graph_job_records (record_id, job_id) VALUES (?, ?)').run(id, jobId);
506
609
  }
507
- created++;
610
+ jobIds.push(jobId);
508
611
  }
509
- return created;
612
+ return { created: jobIds.length, jobIds };
510
613
  });
511
614
  }
512
615
  catch (err) {
513
616
  this.logger?.warn(`${TAG} 投影入队失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
514
- return 0;
617
+ return { created: 0, jobIds: [] };
618
+ }
619
+ }
620
+ /**
621
+ * 强制重投(计划 expiry-tiering R10 冻结契约 · 先行刀):
622
+ * 抹掉 `graph_projected_records` 里这些记录的"已按当前版本投过"凭证,再按
623
+ * 新蒸馏优先级入队——绕开 `projector_version` 的永久跳过,让"改了 L1 → 图谱跟随"
624
+ * 有一条不依赖重启/周期 sweep 的纠正通道。
625
+ *
626
+ * **不动在途 `graph_job_records` 映射**(与上游 task_7.5 字面的一处刻意偏差,
627
+ * 理由记入写面计划 findings):claim 在认领时才现读 L1 记录,在途 job 天然投的是
628
+ * **改后**内容;若把映射一并删掉,同一次调用稍后再来一次就会绕过 PK 去重,
629
+ * 堆出内容重叠的双 job——保留映射才是"重复调用不堆重 job"的构造性保证。
630
+ *
631
+ * 幂等:在途有 job → blocked 集吸收,`created=0`;job 已完成 → 凭证刚被
632
+ * `complete` 写回,本方法再删再投,是合法的新重投而非堆积。
633
+ */
634
+ forceReprojectRecord(recordIds) {
635
+ if (!this.db || recordIds.length === 0)
636
+ return { queued: 0, jobIds: [] };
637
+ const ids = [...new Set(recordIds)];
638
+ try {
639
+ this.tx(() => {
640
+ for (const chunk of chunkOf(ids)) {
641
+ const ph = chunk.map(() => '?').join(',');
642
+ this.db.prepare(`DELETE FROM graph_projected_records WHERE record_id IN (${ph})`).run(...chunk);
643
+ }
644
+ });
645
+ const r = this.queueGraphProjectionDetailed(ids, GRAPH_PRIORITY_NEW);
646
+ return { queued: r.created, jobIds: r.jobIds };
647
+ }
648
+ catch (err) {
649
+ this.logger?.warn(`${TAG} 强制重投入队失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
650
+ return { queued: 0, jobIds: [] };
651
+ }
652
+ }
653
+ /**
654
+ * 确定性边提案落库面(graph-write-plane R4 兜底支路):把"工具传入的显式边提案"
655
+ * 当作一次确定性投影输入,经 `applyGraphProjection` **纯函数**校验后在同一事务内
656
+ * 写回——不是裸 UPDATE graph_edges:validSources 硬校验、同 (from,relation) 换目标
657
+ * 闭旧边的确定性支路、来源并集封顶,全部复用既有守门代码(R5 不变量由它保证)。
658
+ *
659
+ * 提案形态:两端节点按现有 name/type 重提(消歧合并会命中现有节点,不新铸实体),
660
+ * 边 (from → to, relation) 携调用方给的 sourceRecordIds。claim 同款现读 L1:
661
+ * allowedRecordIds = 命中的来源记录集,未命中的 id 会被纯函数整条拒掉(返回
662
+ * ok:false, reason='dropped')——工具面传的"非现存 L1"在这里撞上硬校验,而非静默落库。
663
+ */
664
+ applyRelationProposal(opts) {
665
+ const fail = (reason) => ({ ok: false, reason, closedEdgeIds: [], edgeIds: [], touchedNodeIds: [] });
666
+ if (!this.db)
667
+ return fail('graph-not-ready');
668
+ const relation = String(opts.relation ?? '').trim();
669
+ const sourceIds = [...new Set((opts.sourceRecordIds ?? []).map((s) => String(s).trim()).filter((s) => s.length > 0))];
670
+ if (!opts.fromNodeId || !opts.toNodeId || opts.fromNodeId === opts.toNodeId)
671
+ return fail('bad-nodes');
672
+ if (!relation || sourceIds.length === 0)
673
+ return fail('empty-relation-or-sources');
674
+ try {
675
+ return this.tx(() => {
676
+ const { nodes, edges } = this.loadGraphInTx();
677
+ const from = nodes.find((n) => n.id === opts.fromNodeId);
678
+ const to = nodes.find((n) => n.id === opts.toNodeId);
679
+ if (!from || !to)
680
+ return fail('nodes-not-found');
681
+ const records = this.loadRecordsInTx(sourceIds);
682
+ if (records.length === 0)
683
+ return fail('sources-not-in-l1');
684
+ const now = opts.now ?? new Date().toISOString();
685
+ const idFactory = (prefix) => `${prefix}_${Date.now()}_${randomBytes(3).toString('hex')}`;
686
+ const result = {
687
+ reason: 'tool-explicit-proposal',
688
+ nodes: [
689
+ { ref: 'from', name: from.name, type: from.type, sourceRecordIds: sourceIds, status: 'active' },
690
+ { ref: 'to', name: to.name, type: to.type, sourceRecordIds: sourceIds, status: 'active' },
691
+ ],
692
+ edges: [{ fromRef: 'from', toRef: 'to', relation, sourceRecordIds: sourceIds, status: 'active' }],
693
+ };
694
+ const statusBefore = new Map(edges.map((e) => [e.id, e.status]));
695
+ const outcome = applyGraphProjection({
696
+ nodes,
697
+ edges,
698
+ records,
699
+ result,
700
+ allowedRecordIds: new Set(records.map((r) => r.id)),
701
+ now,
702
+ idFactory,
703
+ });
704
+ if (outcome.edgeIds.length === 0)
705
+ return fail('proposal-dropped-by-validator');
706
+ const touchedNodes = nodes.filter((n) => outcome.nodeIds.includes(n.id));
707
+ const touchedEdges = edges.filter((e) => outcome.edgeIds.includes(e.id));
708
+ this.upsertTouchedInTx(touchedNodes, touchedEdges);
709
+ const closedEdgeIds = touchedEdges
710
+ .filter((e) => e.status === 'superseded' && statusBefore.get(e.id) !== 'superseded')
711
+ .map((e) => e.id);
712
+ const newEdgeIds = touchedEdges.filter((e) => e.status === 'active' && e.relation === relation).map((e) => e.id);
713
+ this.logger?.info(`${TAG} 确定性边提案落库:${newEdgeIds.length} 边 active,${closedEdgeIds.length} 旧边 superseded(来源 ${sourceIds.length} 条,校验丢弃 ${outcome.dropped} 项)`);
714
+ return {
715
+ ok: true,
716
+ closedEdgeIds,
717
+ edgeIds: newEdgeIds,
718
+ touchedNodeIds: touchedNodes.map((n) => n.id),
719
+ };
720
+ }, true);
721
+ }
722
+ catch (err) {
723
+ this.logger?.warn(`${TAG} 确定性边提案失败(整体回滚): ${err instanceof Error ? err.message : String(err)}`);
724
+ return fail('tx-error');
515
725
  }
516
726
  }
517
727
  /**
@@ -568,7 +778,7 @@ export class GraphStore {
568
778
  AND status IN ('pending', 'failed')
569
779
  AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
570
780
  ORDER BY priority DESC, created_time ASC LIMIT 1`)
571
- .get(GRAPH_PROJECTOR_VERSION, GRAPH_JOB_MAX_ATTEMPTS, nowMs);
781
+ .get(GRAPH_PROJECTOR_VERSION, GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS, nowMs);
572
782
  if (!row)
573
783
  return null;
574
784
  db.prepare(`UPDATE graph_projection_jobs SET status = 'running', attempts = attempts + 1, error = NULL, updated_time = ? WHERE job_id = ?`).run(nowIso, row.job_id);
@@ -599,10 +809,14 @@ export class GraphStore {
599
809
  * 校验)→ 写回 touched 行 → job 置 completed + 登记已投影记录 + 放掉 mapping。
600
810
  * job 非 running 态(已完成/已 dead/已回收)一律幂等 no-op;任何一步抛错整体
601
811
  * 回滚,不留半写。
812
+ *
813
+ * 返回 touched 节点中 active/disputed 的那批(写面向量收尾的输入,G-vec 缺陷修):
814
+ * 调用方只关心"哪些节点刚被写活",向量补算失败不回滚投影——返回值不参与控制流。
602
815
  */
603
816
  complete(jobId, result, opts) {
604
817
  if (!this.db)
605
- return;
818
+ return [];
819
+ let touchedVecNodes = [];
606
820
  try {
607
821
  const db = this.db;
608
822
  const now = opts?.now ?? new Date().toISOString();
@@ -628,6 +842,9 @@ export class GraphStore {
628
842
  const touchedNodes = nodes.filter((n) => outcome.nodeIds.includes(n.id));
629
843
  const touchedEdges = edges.filter((e) => outcome.edgeIds.includes(e.id));
630
844
  this.upsertTouchedInTx(touchedNodes, touchedEdges);
845
+ // 写面收尾的输入(只递交落库后的 active/disputed 节点):墓碑不需要向量,
846
+ // 读侧 searchNodesByVector 本就按状态闸过滤陈旧向量行。
847
+ touchedVecNodes = touchedNodes.filter((n) => n.status === 'active' || n.status === 'disputed');
631
848
  db.prepare(`UPDATE graph_projection_jobs SET status = 'completed', error = NULL, updated_time = ? WHERE job_id = ?`).run(now, jobId);
632
849
  for (const id of wanted) {
633
850
  db.prepare('INSERT OR REPLACE INTO graph_projected_records (record_id, projector_version) VALUES (?, ?)').run(id, GRAPH_PROJECTOR_VERSION);
@@ -643,26 +860,30 @@ export class GraphStore {
643
860
  // 提交失败:job 仍在 running 态,下次启动 recoverRunning 回收重投
644
861
  this.logger?.warn(`${TAG} 投影提交失败(待启动回收重投): ${err instanceof Error ? err.message : String(err)}`);
645
862
  }
863
+ return touchedVecNodes;
646
864
  }
647
865
  /**
648
- * 投影失败收尾:attempts 已在 claim 时 +1——封顶转 dead(放掉 mapping,允许
649
- * 重新入队);未封顶转 failed + 指数退避 nextAttemptAt(mapping 保留防重复入队)。
866
+ * 投影失败收尾:attempts 已在 claim 时 +1——根据错误分类选不同封顶:
867
+ * - 永久错误(校验/解析):attempts >= GRAPH_JOB_MAX_ATTEMPTS(3) → dead
868
+ * - 瞬态错误(aborted/timeout/网络):attempts >= GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS(6) → dead
869
+ * dead 放掉 mapping(允许下次 backfill 重新入队);未封顶转 failed + 指数退避。
650
870
  */
651
- fail(jobId, error) {
871
+ fail(jobId, error, opts) {
652
872
  if (!this.db)
653
873
  return;
654
874
  try {
655
875
  const db = this.db;
656
876
  const nowMs = Date.now();
657
877
  const nowIso = new Date(nowMs).toISOString();
878
+ const cap = opts?.transient ? GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS : GRAPH_JOB_MAX_ATTEMPTS;
658
879
  this.tx(() => {
659
880
  const row = db.prepare('SELECT attempts FROM graph_projection_jobs WHERE job_id = ?').get(jobId);
660
881
  if (!row)
661
882
  return;
662
- if (row.attempts >= GRAPH_JOB_MAX_ATTEMPTS) {
883
+ if (row.attempts >= cap) {
663
884
  db.prepare(`UPDATE graph_projection_jobs SET status = 'dead', error = ?, updated_time = ? WHERE job_id = ?`).run(error.slice(0, 500), nowIso, jobId);
664
885
  db.prepare('DELETE FROM graph_job_records WHERE job_id = ?').run(jobId);
665
- this.logger?.warn(`${TAG} 投影任务达重试上限,标记 dead(job=${jobId}): ${error.slice(0, 200)}`);
886
+ this.logger?.warn(`${TAG} 投影任务达重试上限(${opts?.transient ? '瞬态' : '永久'}cap=${cap}),标记 dead(job=${jobId}): ${error.slice(0, 200)}`);
666
887
  }
667
888
  else {
668
889
  const backoffMs = GRAPH_JOB_BACKOFF_BASE_MS * 2 ** (row.attempts - 1);
@@ -778,6 +999,94 @@ export class GraphStore {
778
999
  this.logger?.warn(`${TAG} 删除传播失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
779
1000
  }
780
1001
  }
1002
+ /**
1003
+ * 退场传播(计划 expiry-tiering R9 冻结契约 · 先行刀):软删/取代后的图谱
1004
+ * 来源活性重算清扫。
1005
+ *
1006
+ * 与 `markSourcesDeleted` 的分野:那边判"来源 row 是否还活着",对软删(主表行
1007
+ * 一直在)结构性失效;这边判"来源是否全部处于**退场态**"——即
1008
+ * `classifyValidity` ∈ {superseded, retired}。退场判据单源在 validity 层,
1009
+ * 本方不再自写一份 valid_to/marker 比较(写面计划约束 §4)。
1010
+ *
1011
+ * 重算式 sweep(不是逐条事件改写):每次按当前 L1 全量判定候选节点/边,
1012
+ * 双向收敛——
1013
+ * - active/disputed 且来源全部退场(含 row 消失)→ `archived` 墓碑(保留行,
1014
+ * expand 可见,与物理删传播同落库形态);
1015
+ * - archived 且来源全部复活(row 存在且非退场态)→ 回 `active`(restore 的回药;
1016
+ * 被物理删清空来源的永不满 足此条,不会误复活)。
1017
+ *
1018
+ * `changedIds` 只作早退与诊断日志的口径,不参与判定(判定面 = 全图来源集)。
1019
+ */
1020
+ markSourcesRetired(changedIds) {
1021
+ if (!this.db || changedIds.length === 0)
1022
+ return;
1023
+ try {
1024
+ const { nodes, edges } = this.loadGraph();
1025
+ const sourceIds = new Set();
1026
+ for (const n of nodes)
1027
+ for (const id of n.sourceRecordIds)
1028
+ sourceIds.add(id);
1029
+ for (const e of edges)
1030
+ for (const id of e.sourceRecordIds)
1031
+ sourceIds.add(id);
1032
+ if (sourceIds.size === 0)
1033
+ return;
1034
+ // 现读 L1 行(含已退场软删行——主表行一直在,差别在 marker);
1035
+ // 列形状按 l1SelectCols 同款容缺(独立测试夹具/旧库可能没有时间增强列)。
1036
+ const now = Date.now();
1037
+ const byId = new Map();
1038
+ for (const chunk of chunkOf([...sourceIds])) {
1039
+ const ph = chunk.map(() => '?').join(',');
1040
+ for (const r of this.db
1041
+ .prepare(`SELECT ${l1SelectCols(this.db)} FROM l1_records WHERE record_id IN (${ph})`)
1042
+ .all(...chunk)) {
1043
+ byId.set(String(r.record_id), rowToRecord(r));
1044
+ }
1045
+ }
1046
+ /** 单条来源是否处于退场态:row 消失也算(与物理删传播同口径,宁保守勿复活)。 */
1047
+ const exited = (id) => {
1048
+ const rec = byId.get(id);
1049
+ if (!rec)
1050
+ return true;
1051
+ const state = classifyValidity({
1052
+ marker: readSupersedeMarker(rec.metadata),
1053
+ persistence: rec.persistence,
1054
+ validTo: rec.validTo,
1055
+ validToSource: validToSourceOf(rec.metadata),
1056
+ now,
1057
+ });
1058
+ return isExitedState(state);
1059
+ };
1060
+ const allExited = (ids) => ids.length > 0 && ids.every(exited);
1061
+ const allRevived = (ids) => ids.length > 0 && ids.every((id) => byId.has(id) && !exited(id));
1062
+ const toArchive = nodes.filter((n) => (n.status === 'active' || n.status === 'disputed') && allExited(n.sourceRecordIds));
1063
+ const toArchiveEdges = edges.filter((e) => (e.status === 'active' || e.status === 'disputed') && allExited(e.sourceRecordIds));
1064
+ const toRevive = nodes.filter((n) => n.status === 'archived' && allRevived(n.sourceRecordIds));
1065
+ const toReviveEdges = edges.filter((e) => e.status === 'archived' && allRevived(e.sourceRecordIds));
1066
+ if (toArchive.length === 0 && toArchiveEdges.length === 0 && toRevive.length === 0 && toReviveEdges.length === 0)
1067
+ return;
1068
+ const nowIso = new Date(now).toISOString();
1069
+ this.tx(() => {
1070
+ for (const n of toArchive) {
1071
+ this.db.prepare(`UPDATE graph_nodes SET status = 'archived', updated_time = ? WHERE node_id = ?`).run(nowIso, n.id);
1072
+ }
1073
+ for (const e of toArchiveEdges) {
1074
+ this.db.prepare(`UPDATE graph_edges SET status = 'archived', updated_time = ? WHERE edge_id = ?`).run(nowIso, e.id);
1075
+ }
1076
+ for (const n of toRevive) {
1077
+ this.db.prepare(`UPDATE graph_nodes SET status = 'active', updated_time = ? WHERE node_id = ?`).run(nowIso, n.id);
1078
+ }
1079
+ for (const e of toReviveEdges) {
1080
+ this.db.prepare(`UPDATE graph_edges SET status = 'active', updated_time = ? WHERE edge_id = ?`).run(nowIso, e.id);
1081
+ }
1082
+ });
1083
+ this.logger?.info(`${TAG} 退场传播(重算清扫):${toArchive.length + toArchiveEdges.length} 项标 archived,` +
1084
+ `${toRevive.length + toReviveEdges.length} 项复活(触发记录 ${changedIds.length} 条)`);
1085
+ }
1086
+ catch (err) {
1087
+ this.logger?.warn(`${TAG} 退场传播失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
1088
+ }
1089
+ }
781
1090
  /**
782
1091
  * §C 矛盾冻结:把图谱的 `disputed` 状态**同步**到给定冲突集。
783
1092
  *
@@ -1,5 +1,4 @@
1
1
  import type { L1Hit, MemoryFamily, MemoryLogger, MemoryRecord, RoomCount } from '../types.js';
2
- import type { GraphNodeSearchResult } from '../graph/types.js';
3
2
  import type { L1Receipt, ReceiptQuery } from './receipts.js';
4
3
  import type { ConflictClaimGroup, ConflictPair, ConflictRejected, ConflictResolution, ConflictType } from './conflicts.js';
5
4
  import { type SupersedeInfo } from './supersede.js';
@@ -9,11 +8,13 @@ import { type L1MetaLite, type MemoryDb } from './sqlite.js';
9
8
  import type { L1FileSyncHook } from './memory-md-sync.js';
10
9
  export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
11
10
  /**
12
- * 图谱路提供者(§D 第 3 路):按查询返回图谱命中(已按 score 降序)。
11
+ * 图谱路提供者(§D 第 3 路):按查询返回图谱回链的 L1 记录 id 有序列
12
+ * (向量种子并集与 1-hop 边扩展在 provider 内完成,见 graph-store.searchLaneRecords)。
13
13
  * 抽成注入式而非直接读 `db.graphStore`,是为了给 hybrid 融合留一个可替换的测试缝,
14
- * 并让「未接线 = 恰为 2 路」成为默认行为(既有调用方零行为变化)。
14
+ * 并让「未接线 = 恰为 2 路」成为默认行为(既有调用方零行为变化)。允许返回 Promise
15
+ * 是给向量种子的 embed 查询(装配层惰性取嵌入源);异常由 graphLane 吞并降级。
15
16
  */
16
- export type GraphLaneProvider = (query: string, limit: number, family?: MemoryFamily) => readonly GraphNodeSearchResult[];
17
+ export type GraphLaneProvider = (query: string, limit: number, family?: MemoryFamily) => readonly string[] | Promise<readonly string[]>;
17
18
  export interface L1SearchOptions {
18
19
  /** 按记忆类型精确过滤(后置过滤,官方做法)。 */
19
20
  type?: string;
@@ -251,6 +252,11 @@ export declare class L1Store {
251
252
  };
252
253
  /** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
253
254
  getByIds(ids: string[]): MemoryRecord[];
255
+ /**
256
+ * 反向取代链(task_4.4,门3):返回"被 newId 取代"的退场记录 id。
257
+ * 走 MemoryDb 的 LIKE 全扫缝(百~千量级;阈值告警在那边)。
258
+ */
259
+ findSupersededTargets(newId: string): string[];
254
260
  /** 外部导入幂等 pre-check(轨3-P0):ext_key 命中已存在记录则返回其 id,否则 undefined。 */
255
261
  findIdByExtKey(extKey: string): string | undefined;
256
262
  /**
@@ -424,17 +430,19 @@ export declare class L1Store {
424
430
  */
425
431
  private recencyLane;
426
432
  /**
427
- * §D 第 3 路(图谱路径,hybrid 专用):图谱命中 → `sourceRecordIds` 回链 →
428
- * L1 记录,作为第 3 条**已排序**列表参与 RRF。
433
+ * §D 第 3 路(图谱路径,hybrid 专用):provider 给出回链记录 id 序(路内向量种子
434
+ * 并集 + 1-hop 边扩展在 provider 内完成),本缝只做点查回形 + family/scope 双闸,
435
+ * 作为第 3 条**已排序**列表参与 RRF。
429
436
  *
430
437
  * 为什么值得:图谱是按实体/关系组织的**可重建派生投影**,能召回词法与向量
431
438
  * 都命不中的记录(同义表述、关系可达)——这正是本路相对双路的增量。
432
439
  *
433
440
  * 三条边界:
434
441
  * - **异常降级**:图谱是派生投影,不得因它失败而拖垮主检索 → 记 warn、返回空路,
435
- * 融合退回双路(路数随之降为 2,分数回到既有量纲);
436
- * - **族隔离**:图谱节点已按族过滤,但其来源记录可能跨族 → 这里再按 `family`
437
- * 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
442
+ * 融合退回双路(路数随之降为 2,分数回到既有量纲);await 在 try 内——
443
+ * provider 的 Promise reject 与同步 throw 同路径降级;
444
+ * - **族隔离**:图谱节点已按族过滤(含向量种子),但 1-hop 邻居与来源记录可能跨族
445
+ * → 这里再按 `family` 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
438
446
  * - **墓碑边界**:图谱行可能回链到已被删除的 L1 记录 → 取不到就跳过,
439
447
  * 不补空占位(占位会在 RRF 里凭空加分)。
440
448
  */
package/dist/store/l1.js CHANGED
@@ -9,8 +9,8 @@
9
9
  import { existsSync, mkdirSync, promises as fs, writeFileSync } from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import { familyForType, isScopeVisible } from '../types.js';
12
- import { graphHitRecordIds } from '../graph/search.js';
13
12
  import { isRetired } from './supersede.js';
13
+ import { stampValidity } from './validity.js';
14
14
  import { exportThenPurge, readSnapshotManifest, readSnapshotRecords, restoreL1Snapshot, selectSnapshotTargets, snapshotDirFor, listSnapshots as listSnapshotsIn } from './l1-snapshot.js';
15
15
  import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
16
16
  import { appendJsonl, dayKey, ensureDir, readJsonl } from '../util/io.js';
@@ -393,6 +393,13 @@ export class L1Store {
393
393
  getByIds(ids) {
394
394
  return this.db.getL1ByIds(ids);
395
395
  }
396
+ /**
397
+ * 反向取代链(task_4.4,门3):返回"被 newId 取代"的退场记录 id。
398
+ * 走 MemoryDb 的 LIKE 全扫缝(百~千量级;阈值告警在那边)。
399
+ */
400
+ findSupersededTargets(newId) {
401
+ return this.db.findSupersededTargets(newId);
402
+ }
396
403
  /** 外部导入幂等 pre-check(轨3-P0):ext_key 命中已存在记录则返回其 id,否则 undefined。 */
397
404
  findIdByExtKey(extKey) {
398
405
  return this.db.findRecordIdByExtKey(extKey);
@@ -474,9 +481,14 @@ export class L1Store {
474
481
  async appendNew(records) {
475
482
  if (records.length === 0)
476
483
  return;
484
+ // 写侧标注汇流口(task_3.2):所有新写路径(pipeline / memory_add / import / memport /
485
+ // 重建保留…)都经这里,统一盖 dsh_validity 快照;源头盖过的行(pipeline 带 evidence、
486
+ // 工具带 llm_inferred)保留其 validToSource,未盖过的补 'unknown'(fail-open)。
487
+ const stampNow = Date.now();
477
488
  for (const r of records) {
478
489
  if (!r.family)
479
490
  r.family = familyForType(r.type);
491
+ stampValidity(r, stampNow);
480
492
  }
481
493
  // 起名必须在**追加事实源之前**:名字是文件身份键,进了 JSONL 才能跟记录一起重生。
482
494
  // 这批还没进库,所以只算名字不落库(persist=false),由下面的 upsert 一并写。
@@ -507,7 +519,9 @@ export class L1Store {
507
519
  async upsert(record) {
508
520
  if (!record.family)
509
521
  record.family = familyForType(record.type);
510
- // merge 分支会继承胜方的 name;两边都没名字时在这里补上,别让新铸的行继续带空名。
522
+ // 写侧标注汇流口(与 appendNew 同口径):merge 分支会继承胜方的 name;
523
+ // 两边都没名字时在这里补上,别让新铸的行继续带空名。
524
+ stampValidity(record, Date.now());
511
525
  this.assignMissingNames([record], { persist: false });
512
526
  const vec = (await this.helper.batch([record.content]))[0];
513
527
  if (!this.db.upsertL1(record, vec)) {
@@ -585,8 +599,9 @@ export class L1Store {
585
599
  // **只清理确实处于退场态的记录**。这道复核必须在删除发生的地方(而不是调用方):
586
600
  // 否则任何调用方传一个 id 列表就能绕过软删、把活动记忆直接物理抹掉——
587
601
  // 那等于给"先导出后清理"留了一条硬删后门。
602
+ // 门1 解耦:判据与全仓同为 marker 单源 `isRetired`(不再拿 valid_to 当退场凭据)。
588
603
  const known = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
589
- const retiredIds = ids.filter((id) => known.get(id)?.validTo !== undefined);
604
+ const retiredIds = ids.filter((id) => { const r = known.get(id); return r !== undefined && isRetired(r); });
590
605
  return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger, new Date(), opts);
591
606
  }
592
607
  // ───────────────────────────────────────────────────────────────────────────
@@ -735,14 +750,35 @@ export class L1Store {
735
750
  const vecList = vecRaw ? this.db.searchL1Vector(vecRaw, candidateK, opts?.family, opts?.workspaceId) : [];
736
751
  // 第 3 路(图谱)仅在接线时**结构性存在**:未接线不占路数名额,故既有调用方
737
752
  // 仍走 2 路、得分与改动前逐位一致(见 findings.md §12.1 的路数语义)
738
- const lanes = [ftsList, vecList];
753
+ const lanes = [['fts', ftsList], ['vec', vecList]];
739
754
  if (this.graphLaneProvider)
740
- lanes.push(this.graphLane(query, candidateK, opts?.family, opts?.workspaceId));
755
+ lanes.push(['graph', await this.graphLane(query, candidateK, opts?.family, opts?.workspaceId)]);
741
756
  // 第 4 路(时效)在衰减开启时**结构性存在**;关掉衰减 = 该路不存在
742
757
  if (this.decayHalfLifeDays > 0)
743
- lanes.push(this.recencyLane([...ftsList, ...vecList]));
744
- const merged = rrfMerge(lanes, (h) => h.id);
745
- return this.postProcess(this.applyDecay(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore, lanes.length) }))), opts?.type, limit);
758
+ lanes.push(['recency', this.recencyLane([...ftsList, ...vecList])]);
759
+ const laneLists = lanes.map(([, list]) => list);
760
+ // 路归因(与融合并行,不改 rrfMerge 本体):id → 命中路集合,按路构造序
761
+ // 去重追加(fts→vec→graph→recency,确定性输出)。命中对象在下方 map 里
762
+ // 新建(不往变回路的列表元素上写),挂 lanes 后随 postProcess/applyDecay 透传。
763
+ const lanesById = new Map();
764
+ for (const [name, list] of lanes) {
765
+ for (const h of list) {
766
+ const arr = lanesById.get(h.id);
767
+ if (arr) {
768
+ if (!arr.includes(name))
769
+ arr.push(name);
770
+ }
771
+ else {
772
+ lanesById.set(h.id, [name]);
773
+ }
774
+ }
775
+ }
776
+ const merged = rrfMerge(laneLists, (h) => h.id);
777
+ return this.postProcess(this.applyDecay(merged.map(({ rrfScore, ...h }) => ({
778
+ ...h,
779
+ lanes: lanesById.get(h.id),
780
+ score: normalizeRrf(rrfScore, laneLists.length),
781
+ }))), opts?.type, limit);
746
782
  }
747
783
  /**
748
784
  * §D 第 4 路(时效路,hybrid 专用):把候选池按 `applyDecayWeight` 加权后的
@@ -770,36 +806,37 @@ export class L1Store {
770
806
  return this.applyDecay(deduped);
771
807
  }
772
808
  /**
773
- * §D 第 3 路(图谱路径,hybrid 专用):图谱命中 → `sourceRecordIds` 回链 →
774
- * L1 记录,作为第 3 条**已排序**列表参与 RRF。
809
+ * §D 第 3 路(图谱路径,hybrid 专用):provider 给出回链记录 id 序(路内向量种子
810
+ * 并集 + 1-hop 边扩展在 provider 内完成),本缝只做点查回形 + family/scope 双闸,
811
+ * 作为第 3 条**已排序**列表参与 RRF。
775
812
  *
776
813
  * 为什么值得:图谱是按实体/关系组织的**可重建派生投影**,能召回词法与向量
777
814
  * 都命不中的记录(同义表述、关系可达)——这正是本路相对双路的增量。
778
815
  *
779
816
  * 三条边界:
780
817
  * - **异常降级**:图谱是派生投影,不得因它失败而拖垮主检索 → 记 warn、返回空路,
781
- * 融合退回双路(路数随之降为 2,分数回到既有量纲);
782
- * - **族隔离**:图谱节点已按族过滤,但其来源记录可能跨族 → 这里再按 `family`
783
- * 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
818
+ * 融合退回双路(路数随之降为 2,分数回到既有量纲);await 在 try 内——
819
+ * provider 的 Promise reject 与同步 throw 同路径降级;
820
+ * - **族隔离**:图谱节点已按族过滤(含向量种子),但 1-hop 邻居与来源记录可能跨族
821
+ * → 这里再按 `family` 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
784
822
  * - **墓碑边界**:图谱行可能回链到已被删除的 L1 记录 → 取不到就跳过,
785
823
  * 不补空占位(占位会在 RRF 里凭空加分)。
786
824
  */
787
- graphLane(query, limit, family, workspaceId) {
825
+ async graphLane(query, limit, family, workspaceId) {
788
826
  const provider = this.graphLaneProvider;
789
827
  if (!provider)
790
828
  return [];
791
- let hits;
829
+ let ids;
792
830
  try {
793
- hits = provider(query, limit, family);
831
+ ids = await provider(query, limit, family);
794
832
  }
795
833
  catch (err) {
796
834
  this.logger?.warn(`[memory] 图谱路检索失败,本轮退回双路: ${err instanceof Error ? err.message : String(err)}`);
797
835
  return [];
798
836
  }
799
- const ids = graphHitRecordIds(hits);
800
837
  if (ids.length === 0)
801
838
  return [];
802
- const byId = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
839
+ const byId = new Map(this.db.getL1ByIds([...ids]).map((r) => [r.id, r]));
803
840
  const out = [];
804
841
  for (const id of ids) {
805
842
  const r = byId.get(id);