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.
Files changed (56) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +52 -1
  4. package/dist/config.d.ts +9 -0
  5. package/dist/config.js +8 -0
  6. package/dist/contract.d.ts +2 -0
  7. package/dist/embedding-child.cjs +192 -0
  8. package/dist/embedding-worker.cjs +2 -0
  9. package/dist/graph/apply.d.ts +9 -0
  10. package/dist/graph/apply.js +12 -2
  11. package/dist/graph/search.d.ts +11 -2
  12. package/dist/graph/search.js +38 -10
  13. package/dist/hooks/recall.js +28 -1
  14. package/dist/index.js +8 -1
  15. package/dist/llm.d.ts +7 -2
  16. package/dist/llm.js +14 -22
  17. package/dist/pipeline/graph.d.ts +17 -1
  18. package/dist/pipeline/graph.js +45 -2
  19. package/dist/pipeline/l1.js +87 -26
  20. package/dist/pipeline/md-sync.js +4 -2
  21. package/dist/pipeline/runner.d.ts +26 -0
  22. package/dist/pipeline/runner.js +74 -7
  23. package/dist/pipeline/slot-migrate.d.ts +3 -2
  24. package/dist/pipeline/slot-migrate.js +3 -2
  25. package/dist/projection/recall.d.ts +10 -3
  26. package/dist/projection/recall.js +14 -0
  27. package/dist/settings.js +3 -0
  28. package/dist/stats.d.ts +1 -1
  29. package/dist/stats.js +17 -11
  30. package/dist/store/embedding-source.d.ts +4 -2
  31. package/dist/store/embedding-source.js +5 -2
  32. package/dist/store/graph-store.d.ts +71 -1
  33. package/dist/store/graph-store.js +221 -8
  34. package/dist/store/l1.d.ts +5 -0
  35. package/dist/store/l1.js +18 -2
  36. package/dist/store/local-embedding.d.ts +9 -5
  37. package/dist/store/local-embedding.js +137 -17
  38. package/dist/store/memory-md-disable.d.ts +3 -2
  39. package/dist/store/memory-md.d.ts +3 -3
  40. package/dist/store/memory-md.js +6 -4
  41. package/dist/store/runtime-installer.d.ts +13 -1
  42. package/dist/store/runtime-installer.js +40 -2
  43. package/dist/store/sqlite.d.ts +24 -12
  44. package/dist/store/sqlite.js +97 -31
  45. package/dist/store/supersede.d.ts +10 -2
  46. package/dist/store/supersede.js +10 -2
  47. package/dist/store/validity.d.ts +150 -0
  48. package/dist/store/validity.js +209 -0
  49. package/dist/tools/index.d.ts +15 -1
  50. package/dist/tools/index.js +393 -11
  51. package/dist/util/json-repair.d.ts +48 -0
  52. package/dist/util/json-repair.js +285 -0
  53. package/dist/validity-backfill.d.ts +78 -0
  54. package/dist/validity-backfill.js +160 -0
  55. package/dsh.plugin.json +2 -2
  56. 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.validTo === undefined || r.validTo === null).map((r) => r.id));
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`,故直接 Pick 自 MemoryRecord,不在投影里自写时间比较。
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
- /** 退场判据:`valid_to` 闭合即已退场(软删)。由 `listL1`/`getByIds` 透传。 */
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` 只改 `valid_to`,不会动这两列,所以取在退场前后等价,取在前更直白。
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
- // 传退场**后**的快照:渲染出的 frontmatter 才带 `retired: true`,
551
- // 凭证自己就能说清它为什么存在。
552
- record: { ...rec, validTo: Date.parse(retiredAt) || Date.now() },
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
- retired: r.validTo !== undefined,
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.validTo !== undefined));
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
- retiredAt: r.validTo === undefined ? '' : new Date(r.validTo).toISOString(),
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
- const retired = r.validTo !== undefined;
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
- * 推理在 worker 线程(见 local-embedding.ts);此处只传 runtime 目录与模型目录。 */
67
- export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger, maxInputChars?: number): (modelId: string) => LocalEmbeddingService | null;
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
- * 推理在 worker 线程(见 local-embedding.ts);此处只传 runtime 目录与模型目录。 */
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): void;
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
- let created = 0;
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
- created++;
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
  *