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.
- package/CHANGELOG.md +39 -0
- package/README.en.md +62 -1
- package/README.md +52 -1
- package/dist/client.js +36785 -134
- package/dist/config.d.ts +9 -0
- package/dist/config.js +8 -0
- package/dist/contract.d.ts +76 -0
- package/dist/embedding-child.cjs +192 -0
- package/dist/embedding-worker.cjs +2 -0
- package/dist/graph/apply.d.ts +9 -0
- package/dist/graph/apply.js +12 -2
- package/dist/graph/constraints.d.ts +3 -1
- package/dist/graph/constraints.js +4 -2
- package/dist/graph/preview.d.ts +92 -0
- package/dist/graph/preview.js +186 -0
- package/dist/graph/search.d.ts +32 -6
- package/dist/graph/search.js +102 -12
- package/dist/graph/types.d.ts +26 -1
- package/dist/graph/types.js +26 -1
- package/dist/hooks/recall.js +36 -2
- package/dist/index.js +23 -3
- package/dist/llm.d.ts +7 -2
- package/dist/llm.js +14 -22
- package/dist/pipeline/graph.d.ts +26 -1
- package/dist/pipeline/graph.js +74 -4
- package/dist/pipeline/l1.js +87 -26
- package/dist/pipeline/md-sync.js +4 -2
- package/dist/pipeline/runner.d.ts +26 -0
- package/dist/pipeline/runner.js +74 -7
- package/dist/pipeline/slot-migrate.d.ts +3 -2
- package/dist/pipeline/slot-migrate.js +3 -2
- package/dist/projection/recall.d.ts +10 -3
- package/dist/projection/recall.js +14 -0
- package/dist/settings.js +3 -0
- package/dist/stats.d.ts +1 -1
- package/dist/stats.js +47 -12
- package/dist/store/embedding-source.d.ts +4 -2
- package/dist/store/embedding-source.js +5 -2
- package/dist/store/graph-store.d.ts +111 -4
- package/dist/store/graph-store.js +324 -15
- package/dist/store/l1.d.ts +17 -9
- package/dist/store/l1.js +55 -18
- package/dist/store/local-embedding.d.ts +9 -5
- package/dist/store/local-embedding.js +137 -17
- package/dist/store/memory-md-disable.d.ts +3 -2
- package/dist/store/memory-md.d.ts +3 -3
- package/dist/store/memory-md.js +6 -4
- package/dist/store/runtime-installer.d.ts +13 -1
- package/dist/store/runtime-installer.js +40 -2
- package/dist/store/session-recalled.d.ts +9 -3
- package/dist/store/session-recalled.js +24 -5
- package/dist/store/sqlite.d.ts +24 -12
- package/dist/store/sqlite.js +97 -31
- package/dist/store/supersede.d.ts +10 -2
- package/dist/store/supersede.js +10 -2
- package/dist/store/validity.d.ts +150 -0
- package/dist/store/validity.js +209 -0
- package/dist/tools/index.d.ts +15 -1
- package/dist/tools/index.js +393 -11
- package/dist/types.d.ts +9 -0
- package/dist/util/json-repair.d.ts +48 -0
- package/dist/util/json-repair.js +285 -0
- package/dist/validity-backfill.d.ts +78 -0
- package/dist/validity-backfill.js +160 -0
- package/dsh.plugin.json +2 -2
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
649
|
-
*
|
|
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 >=
|
|
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}
|
|
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
|
*
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -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 路)
|
|
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
|
|
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 专用)
|
|
428
|
-
*
|
|
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
|
-
*
|
|
437
|
-
*
|
|
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)
|
|
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
|
|
745
|
-
|
|
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 专用)
|
|
774
|
-
*
|
|
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
|
-
*
|
|
783
|
-
*
|
|
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
|
|
829
|
+
let ids;
|
|
792
830
|
try {
|
|
793
|
-
|
|
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);
|