dsh-prime-memory 0.26.5 → 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/config.d.ts +9 -0
- package/dist/config.js +8 -0
- package/dist/contract.d.ts +2 -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/search.d.ts +11 -2
- package/dist/graph/search.js +38 -10
- package/dist/hooks/recall.js +28 -1
- package/dist/index.js +8 -1
- package/dist/llm.d.ts +7 -2
- package/dist/llm.js +14 -22
- package/dist/pipeline/graph.d.ts +17 -1
- package/dist/pipeline/graph.js +45 -2
- 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 +17 -11
- package/dist/store/embedding-source.d.ts +4 -2
- package/dist/store/embedding-source.js +5 -2
- package/dist/store/graph-store.d.ts +71 -1
- package/dist/store/graph-store.js +221 -8
- package/dist/store/l1.d.ts +5 -0
- package/dist/store/l1.js +18 -2
- 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/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/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 +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { errDetail } from '../util/filelog.js';
|
|
2
|
+
import { readSupersedeMarker } from '../store/supersede.js';
|
|
2
3
|
/** 判定 + 挑选可迁移槽位(纯读,无副作用;面板预览与反刍执行共用)。 */
|
|
3
4
|
export function pickMigratableSlots(slots, l1) {
|
|
4
5
|
if (l1 === undefined)
|
|
@@ -6,9 +7,9 @@ export function pickMigratableSlots(slots, l1) {
|
|
|
6
7
|
const open = slots.filter((s) => s.status === 'open' && s.pinned === true && s.kind === 'rule');
|
|
7
8
|
if (open.length === 0)
|
|
8
9
|
return [];
|
|
9
|
-
// 一次批量取全部 refs,避免 N
|
|
10
|
+
// 一次批量取全部 refs,避免 N 次查询。存活 = 无退场 marker(门1 解耦同口径)。
|
|
10
11
|
const allRefs = [...new Set(open.flatMap((s) => s.refs ?? []))];
|
|
11
|
-
const alive = new Set(l1.getByIds(allRefs).filter((r) => r.
|
|
12
|
+
const alive = new Set(l1.getByIds(allRefs).filter((r) => readSupersedeMarker(r.metadata) === undefined).map((r) => r.id));
|
|
12
13
|
const out = [];
|
|
13
14
|
for (const s of open) {
|
|
14
15
|
const carrier = (s.refs ?? []).find((r) => alive.has(r));
|
|
@@ -58,6 +58,13 @@ export interface RecallItemView {
|
|
|
58
58
|
* 读侧据此把「已被摘要取代的旧召回」与「此刻仍在上下文里的召回」区分开。
|
|
59
59
|
*/
|
|
60
60
|
superseded?: true;
|
|
61
|
+
/**
|
|
62
|
+
* 时效定性快照(task_3.5,expiry-tiering 门2 展示面):仅在 `expired`/`expired_suspect`
|
|
63
|
+
* 两档写键(泡泡如实标"已于 X 失效/疑似失效");current/historical 等缺席=旧帧兼容。
|
|
64
|
+
*/
|
|
65
|
+
validity?: import('../store/validity.js').ValidityState;
|
|
66
|
+
/** 现实有效期(ISO):与 `validity` 同进同退,只在写 validity 时带上。 */
|
|
67
|
+
validUntil?: string;
|
|
61
68
|
}
|
|
62
69
|
/** 持久态:本会话的活召回 id 集 + 判脏序号。 */
|
|
63
70
|
export interface RecallProjectionState {
|
|
@@ -89,13 +96,13 @@ interface SchemaLike<T> {
|
|
|
89
96
|
}
|
|
90
97
|
/** state 校验(registry 用它挡掉损坏/过期的 checkpoint 行)。 */
|
|
91
98
|
export declare const stateSchema: SchemaLike<RecallProjectionState>;
|
|
92
|
-
/** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
|
|
93
99
|
export declare const viewSchema: SchemaLike<RecallView>;
|
|
94
100
|
/** 投影需要的 L1 只读面(结构化声明,避免跨模块类型耦合)。
|
|
95
|
-
* 判退场要 `validTo`/`metadata
|
|
101
|
+
* 判退场要 `validTo`/`metadata`,时效定性还要 `persistence`/`validFrom`,
|
|
102
|
+
* 故直接 Pick 自 MemoryRecord,不在投影里自写时间比较。
|
|
96
103
|
* `name` 是名称蒸馏产出的短标题,优先作为展示标题。 */
|
|
97
104
|
export interface RecallL1Face {
|
|
98
|
-
getByIds(ids: string[]): Array<Pick<MemoryRecord, 'id' | 'type' | 'content' | 'priority' | 'metadata' | 'validTo' | 'name'>>;
|
|
105
|
+
getByIds(ids: string[]): Array<Pick<MemoryRecord, 'id' | 'type' | 'content' | 'priority' | 'metadata' | 'validFrom' | 'validTo' | 'persistence' | 'name'>>;
|
|
99
106
|
}
|
|
100
107
|
/** 投影需要的反馈只读面(缺席 = view 不带 rating,旧行为)。 */
|
|
101
108
|
export interface RecallFeedbackFace {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRetired } from '../store/supersede.js';
|
|
2
|
+
import { stateOfRecord } from '../store/validity.js';
|
|
2
3
|
export const MEMORY_RECALL_KEY = 'memoryRecall';
|
|
3
4
|
/** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
|
|
4
5
|
export const RECALL_VIEW_CAP = 20;
|
|
@@ -43,6 +44,7 @@ export const stateSchema = {
|
|
|
43
44
|
},
|
|
44
45
|
};
|
|
45
46
|
/** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
|
|
47
|
+
const VALIDITY_STATES_VIEW = ['current', 'historical', 'expired', 'expired_suspect', 'superseded', 'retired'];
|
|
46
48
|
export const viewSchema = {
|
|
47
49
|
parse: (value) => {
|
|
48
50
|
const what = 'memoryRecall.view';
|
|
@@ -73,6 +75,12 @@ export const viewSchema = {
|
|
|
73
75
|
invalid(at, 'retired 只能是 true 或缺席');
|
|
74
76
|
if (item.superseded !== undefined && item.superseded !== true)
|
|
75
77
|
invalid(at, 'superseded 只能是 true 或缺席');
|
|
78
|
+
// 时效标注(task_3.5):缺席=旧帧容忍;有则必须六态枚举内,validUntil 必字符串。
|
|
79
|
+
if (item.validity !== undefined && VALIDITY_STATES_VIEW.indexOf(item.validity) < 0) {
|
|
80
|
+
invalid(at, 'validity 必须是六态之一或缺席');
|
|
81
|
+
}
|
|
82
|
+
if (item.validUntil !== undefined)
|
|
83
|
+
requireString(item.validUntil, at, 'validUntil');
|
|
76
84
|
});
|
|
77
85
|
return value;
|
|
78
86
|
},
|
|
@@ -150,6 +158,11 @@ function makeView(state, l1, feedback) {
|
|
|
150
158
|
? r.metadata?.dsh_usage_scenario
|
|
151
159
|
: undefined;
|
|
152
160
|
const rating = feedback?.lastOf(id);
|
|
161
|
+
// 时效标注(task_3.5):只对"过时两档"写键——current/historical 不必让人看见时效噪声;
|
|
162
|
+
// 旧帧无此键,读侧缺席即活跃。判定现算(stateOfRecord),不吃落库快照;
|
|
163
|
+
// 未退场行才会真判过时两档(退场行由 retired 键表达,validity 不再叠加)。
|
|
164
|
+
const vState = isRetired(r) ? undefined : stateOfRecord(r, Date.now());
|
|
165
|
+
const showValidity = vState === 'expired' || vState === 'expired_suspect';
|
|
153
166
|
items.push({
|
|
154
167
|
id: r.id,
|
|
155
168
|
type: r.type,
|
|
@@ -162,6 +175,7 @@ function makeView(state, l1, feedback) {
|
|
|
162
175
|
...(isRetired(r) ? { retired: true } : {}),
|
|
163
176
|
// 已被摘要取代:正文已不在上下文里。与 retired 正交,两者可同时在。
|
|
164
177
|
...(superseded.has(id) ? { superseded: true } : {}),
|
|
178
|
+
...(showValidity ? { validity: vState, validUntil: r.validTo === undefined ? '' : new Date(r.validTo).toISOString() } : {}),
|
|
165
179
|
});
|
|
166
180
|
}
|
|
167
181
|
const next = { rev: state.rev, sessionId: state.sessionId, count: state.ids.length, items };
|
package/dist/settings.js
CHANGED
|
@@ -85,6 +85,8 @@ const ALWAYS_ON = {
|
|
|
85
85
|
redistillEnabled: false,
|
|
86
86
|
mdSyncEnabled: false,
|
|
87
87
|
memportEnabled: false,
|
|
88
|
+
// 时效分级门2 灰度开关:默认关(注入与改前逐字一致)
|
|
89
|
+
validityTiering: false,
|
|
88
90
|
// md 投射护栏运行时覆盖:0 = 跟随部署 cfg.memExport.maxRecords(面板上可改)
|
|
89
91
|
mdExportMaxRecords: 0,
|
|
90
92
|
// 回填并行度运行时覆盖:0 = 跟随部署 cfg.backfill.concurrency(概览页滑块可改)
|
|
@@ -329,6 +331,7 @@ function resolveSettings(value) {
|
|
|
329
331
|
redistillEnabled: v.redistillEnabled === true,
|
|
330
332
|
mdSyncEnabled: v.mdSyncEnabled === true,
|
|
331
333
|
memportEnabled: v.memportEnabled === true,
|
|
334
|
+
validityTiering: v.validityTiering === true,
|
|
332
335
|
// md 投射护栏钳 0~10000(与 live schema 同上限);0 = 跟随部署默认
|
|
333
336
|
mdExportMaxRecords: Math.min(10_000, Math.max(0, num(v.mdExportMaxRecords))),
|
|
334
337
|
// 回填并行度钳 0~8(与 live schema 同上限);0 = 跟随部署 cfg.backfill.concurrency
|
package/dist/stats.d.ts
CHANGED
|
@@ -183,7 +183,7 @@ export declare function hitToUiRecord(r: {
|
|
|
183
183
|
scope?: string;
|
|
184
184
|
/** 归属工作区路径(仅 scope='workspace' 时非空)。 */
|
|
185
185
|
workspaceId?: string;
|
|
186
|
-
/**
|
|
186
|
+
/** 退场判据:与库侧同源(门1 解耦后 marker 单源,`isRetired`)。由 `listL1`/`getByIds` 透传。 */
|
|
187
187
|
validTo?: number;
|
|
188
188
|
}, extra?: {
|
|
189
189
|
agingWeight?: number | null;
|
package/dist/stats.js
CHANGED
|
@@ -26,7 +26,7 @@ import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/re
|
|
|
26
26
|
import { getTraceStore } from './store/trace.js';
|
|
27
27
|
import { resolveConflictPair, listConflictPairs } from './conflict-service.js';
|
|
28
28
|
import { sourceAnchorLabels } from './pipeline/anchors.js';
|
|
29
|
-
import { readSupersedeMarker } from './store/supersede.js';
|
|
29
|
+
import { readSupersedeMarker, withSupersedeMarker, isRetired } from './store/supersede.js';
|
|
30
30
|
import { isSnapshotName } from './store/l1-snapshot.js';
|
|
31
31
|
import { WING_CATALOG, WING_FALLBACK } from './types.js';
|
|
32
32
|
import { isRoomSlug } from './metadata-validators.js';
|
|
@@ -525,7 +525,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
525
525
|
let retired = false;
|
|
526
526
|
let announced = false;
|
|
527
527
|
// 退场**前**的记录快照(带 workspaceId/name)——失效联动要靠它算落点,
|
|
528
|
-
// 而 `retire`
|
|
528
|
+
// 而 `retire` 只写 marker,不会动这两列,所以取在退场前后等价,取在前更直白。
|
|
529
529
|
let rec;
|
|
530
530
|
if (p.score === -1) {
|
|
531
531
|
rec = stores.l1.getByIds([id])[0];
|
|
@@ -547,9 +547,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
547
547
|
// (面板无消费方),磁盘凭证的对账走 mem-reconcile。
|
|
548
548
|
if (retired && rec !== undefined && cfg.memExport?.enabled === true && stores.mdLedger !== undefined) {
|
|
549
549
|
void disableProjectionFile({
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
record: {
|
|
550
|
+
// 传退场**后**的快照:门1 解耦后 md 层按 marker 判退场,这里补上同样形状的
|
|
551
|
+
// 取代标记,渲染出的 frontmatter 才带 `retired: true`——凭证自己就能说清它为什么存在。
|
|
552
|
+
record: withSupersedeMarker(rec.metadata, { at: retiredAt, reason: 'obsolete' }) === rec.metadata
|
|
553
|
+
? rec
|
|
554
|
+
: { ...rec, metadata: withSupersedeMarker(rec.metadata, { at: retiredAt, reason: 'obsolete' }) },
|
|
553
555
|
cfgRoot: cfg.memExport.root,
|
|
554
556
|
dataDir: deps.dataDir,
|
|
555
557
|
ledger: stores.mdLedger,
|
|
@@ -1352,6 +1354,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1352
1354
|
redistillEnabled: live?.get()?.redistillEnabled === true,
|
|
1353
1355
|
mdSyncEnabled: live?.get()?.mdSyncEnabled === true,
|
|
1354
1356
|
memportEnabled: live?.get()?.memportEnabled === true,
|
|
1357
|
+
validityTiering: live?.get()?.validityTiering === true,
|
|
1355
1358
|
mdExportMaxRecords: live?.get()?.mdExportMaxRecords ?? 0,
|
|
1356
1359
|
backfillConcurrency: live?.get()?.backfillConcurrency ?? 0,
|
|
1357
1360
|
}),
|
|
@@ -1750,7 +1753,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1750
1753
|
...(Array.isArray(r.metadata?.dsh_keywords) ? { keywords: r.metadata.dsh_keywords.filter((k) => typeof k === 'string' && k !== '') } : {}),
|
|
1751
1754
|
...(Array.isArray(r.metadata?.dsh_usage_scenario) ? { usageScenario: r.metadata.dsh_usage_scenario } : {}),
|
|
1752
1755
|
filePath,
|
|
1753
|
-
|
|
1756
|
+
// 退场判据与库侧同源(门1:marker 单源,valid_to 只是现实有效期)。
|
|
1757
|
+
retired: isRetired(r),
|
|
1754
1758
|
};
|
|
1755
1759
|
});
|
|
1756
1760
|
return { items };
|
|
@@ -1828,8 +1832,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1828
1832
|
let targets;
|
|
1829
1833
|
if (explicit.length > 0) {
|
|
1830
1834
|
// 显式 id 也要**复核**是否真的处于已退场态:防止调用方用一个 id 列表
|
|
1831
|
-
// 把活动记忆绕过软删直接物理抹掉(那等于给了一条硬删后门)。
|
|
1832
|
-
targets = explicit.filter((id) => stores.l1.getByIds([id]).some((r) => r
|
|
1835
|
+
// 把活动记忆绕过软删直接物理抹掉(那等于给了一条硬删后门)。判据 marker 单源(门1)。
|
|
1836
|
+
targets = explicit.filter((id) => stores.l1.getByIds([id]).some((r) => isRetired(r)));
|
|
1833
1837
|
}
|
|
1834
1838
|
else {
|
|
1835
1839
|
targets = [];
|
|
@@ -1847,7 +1851,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1847
1851
|
id: r.id,
|
|
1848
1852
|
name: r.name ?? '',
|
|
1849
1853
|
type: r.type,
|
|
1850
|
-
|
|
1854
|
+
// 退场时刻:marker.at 优先;旧版 retire 写的 valid_to 作回退(存量行兼容)。
|
|
1855
|
+
retiredAt: readSupersedeMarker(r.metadata)?.at ?? (r.validTo === undefined ? '' : new Date(r.validTo).toISOString()),
|
|
1851
1856
|
}));
|
|
1852
1857
|
const resp = {
|
|
1853
1858
|
dryRun: true,
|
|
@@ -1893,7 +1898,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1893
1898
|
id: x.id,
|
|
1894
1899
|
name: x.name ?? '',
|
|
1895
1900
|
type: x.type,
|
|
1896
|
-
retiredAt: x.validTo === undefined ? '' : new Date(x.validTo).toISOString(),
|
|
1901
|
+
retiredAt: readSupersedeMarker(x.metadata)?.at ?? (x.validTo === undefined ? '' : new Date(x.validTo).toISOString()),
|
|
1897
1902
|
})),
|
|
1898
1903
|
};
|
|
1899
1904
|
return resp;
|
|
@@ -2351,7 +2356,8 @@ export function agingWeightOf(updatedAtMs, halfLifeDays, now = Date.now()) {
|
|
|
2351
2356
|
return Math.max(0.5, Math.pow(0.5, ageDays / halfLifeDays));
|
|
2352
2357
|
}
|
|
2353
2358
|
export function hitToUiRecord(r, extra) {
|
|
2354
|
-
|
|
2359
|
+
// 退场判据 marker 单源(门1 解耦,与 `isRetired`/`retiredL1Sql` 同口径)。
|
|
2360
|
+
const retired = isRetired(r);
|
|
2355
2361
|
const mark = retired ? readSupersedeMarker(r.metadata) : undefined;
|
|
2356
2362
|
return {
|
|
2357
2363
|
id: r.id,
|
|
@@ -63,8 +63,10 @@ export interface InitialEmbedding {
|
|
|
63
63
|
export declare function remoteCeiling(cfg: MemoryConfig): boolean;
|
|
64
64
|
export declare function resolveInitialEmbedding(cfg: MemoryConfig, sourceStore: EmbeddingSourceStore, downloader: ModelDownloadQueue, makeLocal: (modelId: string) => LocalEmbeddingService | null, logger?: MemoryLogger): Promise<InitialEmbedding>;
|
|
65
65
|
/** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
|
|
66
|
-
* 推理在
|
|
67
|
-
export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger, maxInputChars?: number
|
|
66
|
+
* 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。 */
|
|
67
|
+
export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger, maxInputChars?: number,
|
|
68
|
+
/** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
|
|
69
|
+
childPath?: string): (modelId: string) => LocalEmbeddingService | null;
|
|
68
70
|
export interface EmbeddingManagerDeps {
|
|
69
71
|
dataDir: string;
|
|
70
72
|
cfg: MemoryConfig;
|
|
@@ -153,14 +153,17 @@ export async function resolveInitialEmbedding(cfg, sourceStore, downloader, make
|
|
|
153
153
|
return { svc, dims: cfg.embedding.dimensions, providerInfo: svc.getProviderInfo() };
|
|
154
154
|
}
|
|
155
155
|
/** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
|
|
156
|
-
* 推理在
|
|
157
|
-
export function makeLocalServiceFactory(installer, downloader, logger, maxInputChars
|
|
156
|
+
* 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。 */
|
|
157
|
+
export function makeLocalServiceFactory(installer, downloader, logger, maxInputChars,
|
|
158
|
+
/** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
|
|
159
|
+
childPath) {
|
|
158
160
|
return (modelId) => {
|
|
159
161
|
const entry = catalogById(modelId);
|
|
160
162
|
if (!entry)
|
|
161
163
|
return null;
|
|
162
164
|
return new LocalEmbeddingService(entry, downloader.modelsDir(entry.id), {
|
|
163
165
|
runtimeDir: installer.runtimeDir,
|
|
166
|
+
childPath,
|
|
164
167
|
logger,
|
|
165
168
|
maxInputChars,
|
|
166
169
|
});
|
|
@@ -139,6 +139,54 @@ export declare class GraphStore {
|
|
|
139
139
|
* 记录跳过。返回实际新建的 job 数。
|
|
140
140
|
*/
|
|
141
141
|
queueGraphProjection(recordIds: readonly string[], priority: number): number;
|
|
142
|
+
/**
|
|
143
|
+
* 入队的详细形态(与 `queueGraphProjection` 同一实现,只是把新建 job id 也交出来):
|
|
144
|
+
* `memory_reproject` 工具面需要 `{queued, jobIds}` 回报,而既有调用方只数 created——
|
|
145
|
+
* 两个返回形状共享一份去重/分片逻辑,不漂移。
|
|
146
|
+
*/
|
|
147
|
+
private queueGraphProjectionDetailed;
|
|
148
|
+
/**
|
|
149
|
+
* 强制重投(计划 expiry-tiering R10 冻结契约 · 先行刀):
|
|
150
|
+
* 抹掉 `graph_projected_records` 里这些记录的"已按当前版本投过"凭证,再按
|
|
151
|
+
* 新蒸馏优先级入队——绕开 `projector_version` 的永久跳过,让"改了 L1 → 图谱跟随"
|
|
152
|
+
* 有一条不依赖重启/周期 sweep 的纠正通道。
|
|
153
|
+
*
|
|
154
|
+
* **不动在途 `graph_job_records` 映射**(与上游 task_7.5 字面的一处刻意偏差,
|
|
155
|
+
* 理由记入写面计划 findings):claim 在认领时才现读 L1 记录,在途 job 天然投的是
|
|
156
|
+
* **改后**内容;若把映射一并删掉,同一次调用稍后再来一次就会绕过 PK 去重,
|
|
157
|
+
* 堆出内容重叠的双 job——保留映射才是"重复调用不堆重 job"的构造性保证。
|
|
158
|
+
*
|
|
159
|
+
* 幂等:在途有 job → blocked 集吸收,`created=0`;job 已完成 → 凭证刚被
|
|
160
|
+
* `complete` 写回,本方法再删再投,是合法的新重投而非堆积。
|
|
161
|
+
*/
|
|
162
|
+
forceReprojectRecord(recordIds: readonly string[]): {
|
|
163
|
+
queued: number;
|
|
164
|
+
jobIds: string[];
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* 确定性边提案落库面(graph-write-plane R4 兜底支路):把"工具传入的显式边提案"
|
|
168
|
+
* 当作一次确定性投影输入,经 `applyGraphProjection` **纯函数**校验后在同一事务内
|
|
169
|
+
* 写回——不是裸 UPDATE graph_edges:validSources 硬校验、同 (from,relation) 换目标
|
|
170
|
+
* 闭旧边的确定性支路、来源并集封顶,全部复用既有守门代码(R5 不变量由它保证)。
|
|
171
|
+
*
|
|
172
|
+
* 提案形态:两端节点按现有 name/type 重提(消歧合并会命中现有节点,不新铸实体),
|
|
173
|
+
* 边 (from → to, relation) 携调用方给的 sourceRecordIds。claim 同款现读 L1:
|
|
174
|
+
* allowedRecordIds = 命中的来源记录集,未命中的 id 会被纯函数整条拒掉(返回
|
|
175
|
+
* ok:false, reason='dropped')——工具面传的"非现存 L1"在这里撞上硬校验,而非静默落库。
|
|
176
|
+
*/
|
|
177
|
+
applyRelationProposal(opts: {
|
|
178
|
+
fromNodeId: string;
|
|
179
|
+
toNodeId: string;
|
|
180
|
+
relation: string;
|
|
181
|
+
sourceRecordIds: readonly string[];
|
|
182
|
+
now?: string;
|
|
183
|
+
}): {
|
|
184
|
+
ok: boolean;
|
|
185
|
+
reason?: string;
|
|
186
|
+
closedEdgeIds: string[];
|
|
187
|
+
edgeIds: string[];
|
|
188
|
+
touchedNodeIds: string[];
|
|
189
|
+
};
|
|
142
190
|
/**
|
|
143
191
|
* 记录被物理删除后,把两张**索引表**里指向它的行一并抹掉。
|
|
144
192
|
*
|
|
@@ -164,8 +212,11 @@ export declare class GraphStore {
|
|
|
164
212
|
* 校验)→ 写回 touched 行 → job 置 completed + 登记已投影记录 + 放掉 mapping。
|
|
165
213
|
* job 非 running 态(已完成/已 dead/已回收)一律幂等 no-op;任何一步抛错整体
|
|
166
214
|
* 回滚,不留半写。
|
|
215
|
+
*
|
|
216
|
+
* 返回 touched 节点中 active/disputed 的那批(写面向量收尾的输入,G-vec 缺陷修):
|
|
217
|
+
* 调用方只关心"哪些节点刚被写活",向量补算失败不回滚投影——返回值不参与控制流。
|
|
167
218
|
*/
|
|
168
|
-
complete(jobId: string, result: GraphProjectionResult, opts?: CompleteOptions):
|
|
219
|
+
complete(jobId: string, result: GraphProjectionResult, opts?: CompleteOptions): GraphNode[];
|
|
169
220
|
/**
|
|
170
221
|
* 投影失败收尾:attempts 已在 claim 时 +1——根据错误分类选不同封顶:
|
|
171
222
|
* - 永久错误(校验/解析):attempts >= GRAPH_JOB_MAX_ATTEMPTS(3) → dead
|
|
@@ -191,6 +242,25 @@ export declare class GraphStore {
|
|
|
191
242
|
* 之后;按 L1 存活集判定(而非本次删除集合),跨多次删除与历史孤儿一并收敛。
|
|
192
243
|
*/
|
|
193
244
|
markSourcesDeleted(deletedIds: readonly string[]): void;
|
|
245
|
+
/**
|
|
246
|
+
* 退场传播(计划 expiry-tiering R9 冻结契约 · 先行刀):软删/取代后的图谱
|
|
247
|
+
* 来源活性重算清扫。
|
|
248
|
+
*
|
|
249
|
+
* 与 `markSourcesDeleted` 的分野:那边判"来源 row 是否还活着",对软删(主表行
|
|
250
|
+
* 一直在)结构性失效;这边判"来源是否全部处于**退场态**"——即
|
|
251
|
+
* `classifyValidity` ∈ {superseded, retired}。退场判据单源在 validity 层,
|
|
252
|
+
* 本方不再自写一份 valid_to/marker 比较(写面计划约束 §4)。
|
|
253
|
+
*
|
|
254
|
+
* 重算式 sweep(不是逐条事件改写):每次按当前 L1 全量判定候选节点/边,
|
|
255
|
+
* 双向收敛——
|
|
256
|
+
* - active/disputed 且来源全部退场(含 row 消失)→ `archived` 墓碑(保留行,
|
|
257
|
+
* expand 可见,与物理删传播同落库形态);
|
|
258
|
+
* - archived 且来源全部复活(row 存在且非退场态)→ 回 `active`(restore 的回药;
|
|
259
|
+
* 被物理删清空来源的永不满 足此条,不会误复活)。
|
|
260
|
+
*
|
|
261
|
+
* `changedIds` 只作早退与诊断日志的口径,不参与判定(判定面 = 全图来源集)。
|
|
262
|
+
*/
|
|
263
|
+
markSourcesRetired(changedIds: readonly string[]): void;
|
|
194
264
|
/**
|
|
195
265
|
* §C 矛盾冻结:把图谱的 `disputed` 状态**同步**到给定冲突集。
|
|
196
266
|
*
|
|
@@ -21,8 +21,10 @@ import { randomBytes } from 'node:crypto';
|
|
|
21
21
|
import { applyGraphProjection } from '../graph/apply.js';
|
|
22
22
|
import { expandHitRecordIds, searchGraphNodes } from '../graph/search.js';
|
|
23
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_PROJECTOR_VERSION, GRAPH_VEC_SEED_CAP, } from '../graph/types.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';
|
|
25
25
|
import { normPersistence } from '../types.js';
|
|
26
|
+
import { classifyValidity, isExitedState, validToSourceOf } from './validity.js';
|
|
27
|
+
import { readSupersedeMarker } from './supersede.js';
|
|
26
28
|
import { isZeroVector, vecToBuffer } from './vec-utils.js';
|
|
27
29
|
const TAG = '[memory][graph]';
|
|
28
30
|
/** 坏 JSON 容忍解析:图谱表列损坏只损失该行派生信息,不抛。 */
|
|
@@ -565,8 +567,16 @@ export class GraphStore {
|
|
|
565
567
|
* 记录跳过。返回实际新建的 job 数。
|
|
566
568
|
*/
|
|
567
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) {
|
|
568
578
|
if (!this.db || recordIds.length === 0)
|
|
569
|
-
return 0;
|
|
579
|
+
return { created: 0, jobIds: [] };
|
|
570
580
|
try {
|
|
571
581
|
const db = this.db;
|
|
572
582
|
const ids = [...new Set(recordIds)];
|
|
@@ -585,10 +595,10 @@ export class GraphStore {
|
|
|
585
595
|
}
|
|
586
596
|
const fresh = ids.filter((id) => !blocked.has(id));
|
|
587
597
|
if (fresh.length === 0)
|
|
588
|
-
return 0;
|
|
598
|
+
return { created: 0, jobIds: [] };
|
|
589
599
|
const now = new Date().toISOString();
|
|
590
600
|
return this.tx(() => {
|
|
591
|
-
|
|
601
|
+
const jobIds = [];
|
|
592
602
|
for (let i = 0; i < fresh.length; i += GRAPH_JOB_BATCH) {
|
|
593
603
|
const batch = fresh.slice(i, i + GRAPH_JOB_BATCH);
|
|
594
604
|
const jobId = this.newJobId();
|
|
@@ -597,14 +607,121 @@ export class GraphStore {
|
|
|
597
607
|
for (const id of batch) {
|
|
598
608
|
db.prepare('INSERT OR REPLACE INTO graph_job_records (record_id, job_id) VALUES (?, ?)').run(id, jobId);
|
|
599
609
|
}
|
|
600
|
-
|
|
610
|
+
jobIds.push(jobId);
|
|
601
611
|
}
|
|
602
|
-
return created;
|
|
612
|
+
return { created: jobIds.length, jobIds };
|
|
603
613
|
});
|
|
604
614
|
}
|
|
605
615
|
catch (err) {
|
|
606
616
|
this.logger?.warn(`${TAG} 投影入队失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
607
|
-
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');
|
|
608
725
|
}
|
|
609
726
|
}
|
|
610
727
|
/**
|
|
@@ -692,10 +809,14 @@ export class GraphStore {
|
|
|
692
809
|
* 校验)→ 写回 touched 行 → job 置 completed + 登记已投影记录 + 放掉 mapping。
|
|
693
810
|
* job 非 running 态(已完成/已 dead/已回收)一律幂等 no-op;任何一步抛错整体
|
|
694
811
|
* 回滚,不留半写。
|
|
812
|
+
*
|
|
813
|
+
* 返回 touched 节点中 active/disputed 的那批(写面向量收尾的输入,G-vec 缺陷修):
|
|
814
|
+
* 调用方只关心"哪些节点刚被写活",向量补算失败不回滚投影——返回值不参与控制流。
|
|
695
815
|
*/
|
|
696
816
|
complete(jobId, result, opts) {
|
|
697
817
|
if (!this.db)
|
|
698
|
-
return;
|
|
818
|
+
return [];
|
|
819
|
+
let touchedVecNodes = [];
|
|
699
820
|
try {
|
|
700
821
|
const db = this.db;
|
|
701
822
|
const now = opts?.now ?? new Date().toISOString();
|
|
@@ -721,6 +842,9 @@ export class GraphStore {
|
|
|
721
842
|
const touchedNodes = nodes.filter((n) => outcome.nodeIds.includes(n.id));
|
|
722
843
|
const touchedEdges = edges.filter((e) => outcome.edgeIds.includes(e.id));
|
|
723
844
|
this.upsertTouchedInTx(touchedNodes, touchedEdges);
|
|
845
|
+
// 写面收尾的输入(只递交落库后的 active/disputed 节点):墓碑不需要向量,
|
|
846
|
+
// 读侧 searchNodesByVector 本就按状态闸过滤陈旧向量行。
|
|
847
|
+
touchedVecNodes = touchedNodes.filter((n) => n.status === 'active' || n.status === 'disputed');
|
|
724
848
|
db.prepare(`UPDATE graph_projection_jobs SET status = 'completed', error = NULL, updated_time = ? WHERE job_id = ?`).run(now, jobId);
|
|
725
849
|
for (const id of wanted) {
|
|
726
850
|
db.prepare('INSERT OR REPLACE INTO graph_projected_records (record_id, projector_version) VALUES (?, ?)').run(id, GRAPH_PROJECTOR_VERSION);
|
|
@@ -736,6 +860,7 @@ export class GraphStore {
|
|
|
736
860
|
// 提交失败:job 仍在 running 态,下次启动 recoverRunning 回收重投
|
|
737
861
|
this.logger?.warn(`${TAG} 投影提交失败(待启动回收重投): ${err instanceof Error ? err.message : String(err)}`);
|
|
738
862
|
}
|
|
863
|
+
return touchedVecNodes;
|
|
739
864
|
}
|
|
740
865
|
/**
|
|
741
866
|
* 投影失败收尾:attempts 已在 claim 时 +1——根据错误分类选不同封顶:
|
|
@@ -874,6 +999,94 @@ export class GraphStore {
|
|
|
874
999
|
this.logger?.warn(`${TAG} 删除传播失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
875
1000
|
}
|
|
876
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
|
+
}
|
|
877
1090
|
/**
|
|
878
1091
|
* §C 矛盾冻结:把图谱的 `disputed` 状态**同步**到给定冲突集。
|
|
879
1092
|
*
|