rag-memory-epf-mcp 6.4.0 → 6.4.2

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/dist/index.js CHANGED
@@ -23,9 +23,10 @@ import { getAllMCPTools, validateToolArgs, getSystemInfo } from './src/tools/too
23
23
  // Import migration system
24
24
  import { MigrationManager } from './src/migrations/migration-manager.js';
25
25
  import { backupBeforeMigration } from './src/backup/preflight.js';
26
- import { rebuildProjection, deleteStaleKgChunks, deleteKgRelationshipChunks } from './src/observations/projection.js';
26
+ import { rebuildProjection, deleteStaleKgChunks, deleteKgRelationshipChunks, deleteKgChunkRows, refreshKgRelationshipConfidence } from './src/observations/projection.js';
27
27
  import { addRevision, correctRevision, transitionStatus, linkSources, nextProjectionOrder } from './src/observations/lifecycle.js';
28
28
  import { getObservationHistory } from './src/observations/history.js';
29
+ import { kgComparisonValue, captureKgEmbedInput, parseEvidenceMetadata, fingerprintFromMetadata, sha256Utf8, EVIDENCE_FORMAT, resolveEvidenceItem, EvidenceInputError, EvidenceCommitConflictError, resolveEvidenceStatus, collectEvidenceRowidsCitingObservations, collectEvidenceRowidsCitingDocument, collectEvidenceRowidsCitingOwnerEntity } from './src/kg/relation-evidence.js';
29
30
  // Import chunk text algorithm (extracted for publish-time invariant testing)
30
31
  import { chunkStructured as chunkStructuredText, effectiveSignature, isCurrentFormatSignature, LEGACY_SIGNATURE, DEFAULT_MAX_TOKENS } from './src/chunkerC.js';
31
32
  import { migrations } from './src/migrations/migrations.js';
@@ -94,6 +95,12 @@ export function compileFtsLiteralQuery(q) {
94
95
  return null;
95
96
  return terms.map(t => `"${t}"`).join(' OR ');
96
97
  }
98
+ // Test-only seam for the real model loader: replaces `pipeline` so a test can see the exact
99
+ // arguments buildRealLoader passes (same rule as setSyncFaultPoint: an argument, never an env var).
100
+ let __pipelineImpl = pipeline;
101
+ export function setPipelineImpl(fn) {
102
+ __pipelineImpl = (fn ?? pipeline);
103
+ }
97
104
  // Configure Hugging Face transformers for better compatibility
98
105
  if (env.backends?.onnx?.wasm) {
99
106
  env.backends.onnx.wasm.wasmPaths = './node_modules/@huggingface/transformers/dist/';
@@ -141,6 +148,12 @@ const IS_DEFAULT_MODEL_CONFIG = !process.env.EMBEDDING_MODEL || process.env.EMBE
141
148
  // on 'main' (their vectors are never auto-grandfathered anyway).
142
149
  const MODEL_REVISION = IS_DEFAULT_MODEL_CONFIG ? '4de13258303883538bd53b696b452bf8099f0858' : 'main';
143
150
  const MODEL_DTYPE = 'fp16';
151
+ // ONNX session options for the embedding model. The CPU memory arena is off: with it on, one long
152
+ // input (12,000 chars = 5,774 tokens) left the process holding its peak (footprint 1.4 → 8.0 GB,
153
+ // measured 2026-10-31 on transformers 3.8.1 and 4.3.2) and every MCP client runs its own engine.
154
+ // Arena off returned to 1.5-2.4 GB with byte-identical vectors; the cost is per-call allocation
155
+ // (query 29 → 33 ms, 484-token chunk 574 → 639 ms on 4.3.2). Not part of the cache artifact key.
156
+ export const MODEL_SESSION_OPTIONS = Object.freeze({ enableCpuMemArena: false });
144
157
  // Entity embedding text builder version — mixed into entity input hashes so a
145
158
  // builder change re-backfills entities without touching chunk vectors (spec §6c).
146
159
  const TEXT_BUILDER_VERSION = 'tb1';
@@ -186,6 +199,13 @@ export function setSyncFaultPoint(point, fn) {
186
199
  __syncFaultHook = point && fn ? (p) => { if (p === point)
187
200
  fn(); } : null;
188
201
  }
202
+ // Test-only fault hook for the KG writers (kg-relation-evidence D1/D2⑤). Same rule as
203
+ // setSyncFaultPoint: an argument, never an environment variable.
204
+ let __kgFaultHook = null;
205
+ export function setKgFaultPoint(point, fn) {
206
+ __kgFaultHook = point && fn ? (p) => { if (p === point)
207
+ fn(); } : null;
208
+ }
189
209
  // Pure vector-reuse decision (spec §5.2 조건 1~5). Returns an OWNED Buffer copy:
190
210
  // a Buffer read back from SQLite has no byteOffset-0 guarantee, and inserting
191
211
  // `.buffer` of a subarray would write the wrong 4,096 bytes (advisor r5-9).
@@ -379,7 +399,8 @@ export class RAGKnowledgeGraphManager {
379
399
  env.allowRemoteModels = true;
380
400
  env.allowLocalModels = true;
381
401
  console.error(`🤖 Loading embedding model: ${EMBEDDING_MODEL} (1024-dim, cache=${cacheDir})...`);
382
- const model = await pipeline('feature-extraction', EMBEDDING_MODEL, { revision: MODEL_REVISION, dtype: MODEL_DTYPE, cache_dir: cacheDir });
402
+ const model = await __pipelineImpl('feature-extraction', EMBEDDING_MODEL, { revision: MODEL_REVISION, dtype: MODEL_DTYPE, cache_dir: cacheDir,
403
+ session_options: { ...MODEL_SESSION_OPTIONS } });
383
404
  // dims fail-fast (spec §2 / beta B8): probe the ACTUAL output length — a
384
405
  // 384/768-dim custom model must fail here with a clear message, not at
385
406
  // every subsequent vector write.
@@ -499,11 +520,20 @@ export class RAGKnowledgeGraphManager {
499
520
  mutateEntityAndInvalidate(entityId, mutate) {
500
521
  let changed = false;
501
522
  const tx = this.db.transaction(() => {
502
- changed = mutate() !== false;
523
+ // D2③ (kg-relation-evidence): the fifth obligation — evidence chunks citing a changed observation
524
+ // go in the same transaction. Collected before the mutation (purge deletes the rows they name).
525
+ const marked = new Set();
526
+ const kg = {
527
+ collect: (ids) => collectEvidenceRowidsCitingObservations(this.db, ids),
528
+ mark: (rowids) => { for (const r of rowids)
529
+ marked.add(r); },
530
+ };
531
+ changed = mutate(kg) !== false;
503
532
  if (!changed)
504
533
  return;
505
534
  rebuildProjection(this.db, entityId);
506
535
  this.invalidateDerivedForEntity(entityId);
536
+ deleteKgChunkRows(this.db, [...marked]);
507
537
  });
508
538
  tx();
509
539
  if (changed)
@@ -543,15 +573,70 @@ export class RAGKnowledgeGraphManager {
543
573
  tx();
544
574
  this.coordinator?.invalidateCoverage();
545
575
  }
576
+ // D2⑤ (kg-relation-evidence): the one write-back for KG chunk vectors, shared by the dedicated
577
+ // embedKnowledgeGraphChunks loop and the backfill callback. `captured` was taken immediately before
578
+ // inference; every part must still hold at write time: same rowid -> same chunk_id, same text hash,
579
+ // same comparison value (evidence fingerprint, or for legacy rows source identity + body hash), and
580
+ // the profile that was current when inference started. null comparison values never match. A
581
+ // profile change BEFORE inference is normal: the vector and the evidence fingerprint are recorded for
582
+ // the profile captured here, so the stored fingerprint never stays pinned to an outdated profile.
583
+ writeKgVectorIfCurrent(captured, embedding) {
584
+ const rowid = safeRowid(captured.rowid);
585
+ const tx = this.db.transaction(() => {
586
+ const cur = this.db.prepare(`SELECT rowid, chunk_id, chunk_type, entity_id, relationship_id, text, metadata
587
+ FROM chunk_metadata WHERE rowid = ?`).get(rowid);
588
+ if (!cur || cur.text === null)
589
+ return false; // deleted during inference
590
+ if (cur.chunk_type !== 'entity' && cur.chunk_type !== 'relationship')
591
+ return false;
592
+ if (cur.chunk_id !== captured.chunk_id)
593
+ return false; // rowid reused by another chunk
594
+ if (sha256Utf8(cur.text) !== captured.text_sha256)
595
+ return false; // body changed
596
+ if (captured.cmp === null || kgComparisonValue(cur) !== captured.cmp)
597
+ return false; // inputs changed
598
+ if (this.currentProfileId !== captured.profile_id)
599
+ return false; // profile changed during inference
600
+ this.db.exec(`DELETE FROM chunks WHERE rowid = ${rowid}`);
601
+ this.db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(Buffer.from(embedding.buffer));
602
+ this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
603
+ .run(captured.text_sha256, captured.profile_id, rowid);
604
+ const meta = parseEvidenceMetadata(cur.metadata);
605
+ if (meta && meta.fingerprint_profile_id !== captured.profile_id) {
606
+ meta.input_fingerprint = fingerprintFromMetadata(meta, captured.profile_id);
607
+ meta.fingerprint_profile_id = captured.profile_id;
608
+ this.db.prepare(`UPDATE chunk_metadata SET metadata = ? WHERE rowid = ?`).run(JSON.stringify(meta), rowid);
609
+ }
610
+ this.db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id = ?`).run(String(rowid));
611
+ return true;
612
+ });
613
+ const written = tx();
614
+ this.coordinator?.invalidateCoverage();
615
+ return written;
616
+ }
546
617
  // Backfill callback: re-embed one chunk and commit vector + provenance in a
547
618
  // single transaction (§6a-2).
548
619
  async reembedChunkByRowid(rowid) {
549
620
  if (!this.db)
550
621
  return false;
551
- const row = this.db.prepare(`SELECT text FROM chunk_metadata WHERE rowid = ?`)
552
- .get(rowid);
622
+ const row = this.db.prepare(`SELECT rowid, chunk_id, chunk_type, entity_id, relationship_id, text, metadata
623
+ FROM chunk_metadata WHERE rowid = ?`).get(rowid);
553
624
  if (!row || row.text === null)
554
625
  return false;
626
+ if (row.chunk_type === 'entity' || row.chunk_type === 'relationship') {
627
+ // D2⑤: the text-only check below cannot tell a regenerated evidence chunk with the same body,
628
+ // or another chunk at a reused rowid with the same text, from the input we embedded.
629
+ const captured = captureKgEmbedInput(row, this.currentProfileId);
630
+ try {
631
+ const embedding = await this.generateEmbedding(row.text, 1024, false, 'backfill', `profile:${captured.profile_id}`);
632
+ return this.writeKgVectorIfCurrent(captured, embedding);
633
+ }
634
+ catch (e) {
635
+ if (e instanceof GateNotReadyError || e instanceof GateDisabledError)
636
+ return false;
637
+ throw e;
638
+ }
639
+ }
555
640
  try {
556
641
  const embedding = await this.generateEmbedding(row.text, 1024, false, 'backfill');
557
642
  const hash = createHash('sha256').update(row.text).digest('hex');
@@ -955,7 +1040,7 @@ export class RAGKnowledgeGraphManager {
955
1040
  // entity INSERT 도 같은 트랜잭션 안이다. 밖에 두면 lifecycle INSERT 가
956
1041
  // 실패할 때 entity 행만 남는 split state 가 생긴다
957
1042
  // (advisor 구현리뷰 r1 발견 2, 실행 재현).
958
- const changed = this.mutateEntityAndInvalidate(entityId, () => {
1043
+ const changed = this.mutateEntityAndInvalidate(entityId, (kg) => {
959
1044
  created = insertStmt.run(entityId, entity.name, entity.entityType, '{}').changes > 0;
960
1045
  if (!created && entity.entityType && entity.entityType !== 'CONCEPT') {
961
1046
  const cur = this.db.prepare(`SELECT entityType FROM entities WHERE id = ?`)
@@ -976,8 +1061,15 @@ export class RAGKnowledgeGraphManager {
976
1061
  const dupId = activeByBare.get(bare);
977
1062
  if (dupId) {
978
1063
  // 같은 사실이 다른 출처에서 다시 왔다 = evidence 추가, 새 revision 아님.
979
- if (entity.sources?.length)
980
- sourcesAdded += linkSources(this.db, dupId, entity.sources, ts);
1064
+ // D2③: a new source row changes the cited observation's source list (a fingerprint input):
1065
+ // collect before linking, drop only if a row was actually added.
1066
+ if (entity.sources?.length) {
1067
+ const cited = kg.collect([dupId]);
1068
+ const added = linkSources(this.db, dupId, entity.sources, ts);
1069
+ if (added > 0)
1070
+ kg.mark(cited);
1071
+ sourcesAdded += added;
1072
+ }
981
1073
  ids.push(null);
982
1074
  continue;
983
1075
  }
@@ -1062,14 +1154,20 @@ export class RAGKnowledgeGraphManager {
1062
1154
  const ids = [];
1063
1155
  const added = [];
1064
1156
  let sourcesAdded = 0;
1065
- const changed = this.mutateEntityAndInvalidate(entityId, () => {
1157
+ const changed = this.mutateEntityAndInvalidate(entityId, (kg) => {
1066
1158
  for (const raw of obs.contents) {
1067
1159
  const content = this._timestampObservation(raw);
1068
1160
  const bare = stripDate(content);
1069
1161
  const dupId = activeByBare.get(bare);
1070
1162
  if (dupId) {
1071
- if (obs.sources?.length)
1072
- sourcesAdded += linkSources(this.db, dupId, obs.sources, ts);
1163
+ // D2③: see createEntities — collect before linking, drop only if a source row was added.
1164
+ if (obs.sources?.length) {
1165
+ const cited = kg.collect([dupId]);
1166
+ const added = linkSources(this.db, dupId, obs.sources, ts);
1167
+ if (added > 0)
1168
+ kg.mark(cited);
1169
+ sourcesAdded += added;
1170
+ }
1073
1171
  ids.push(null);
1074
1172
  continue;
1075
1173
  }
@@ -1104,11 +1202,13 @@ export class RAGKnowledgeGraphManager {
1104
1202
  throw new Error(`observation ${observationId} not found`);
1105
1203
  let newId = '';
1106
1204
  const ts = new Date().toISOString();
1107
- this.mutateEntityAndInvalidate(row.entity_id, () => {
1205
+ this.mutateEntityAndInvalidate(row.entity_id, (kg) => {
1206
+ const cited = kg.collect([observationId]);
1108
1207
  newId = correctRevision(this.db, {
1109
1208
  observationId, content: this._timestampObservation(content),
1110
1209
  changeKind, reason: reason ?? null, ts
1111
1210
  });
1211
+ kg.mark(cited);
1112
1212
  });
1113
1213
  await this.tryEmbedEntity(row.entity_id, 'bulk');
1114
1214
  return newId;
@@ -1121,8 +1221,10 @@ export class RAGKnowledgeGraphManager {
1121
1221
  if (!row)
1122
1222
  throw new Error(`observation ${observationId} not found`);
1123
1223
  const ts = new Date().toISOString();
1124
- this.mutateEntityAndInvalidate(row.entity_id, () => {
1224
+ this.mutateEntityAndInvalidate(row.entity_id, (kg) => {
1225
+ const cited = kg.collect([observationId]);
1125
1226
  transitionStatus(this.db, { observationId, event, reason: reason ?? null, ts });
1227
+ kg.mark(cited);
1126
1228
  });
1127
1229
  await this.tryEmbedEntity(row.entity_id, 'bulk');
1128
1230
  }
@@ -1164,17 +1266,20 @@ export class RAGKnowledgeGraphManager {
1164
1266
  if (!row)
1165
1267
  return { purged: 0 };
1166
1268
  let purged = 0;
1167
- this.mutateEntityAndInvalidate(row.entity_id, () => {
1269
+ this.mutateEntityAndInvalidate(row.entity_id, (kg) => {
1168
1270
  // newest-first so each DELETE has no successor referencing it
1169
1271
  const victims = this.db.prepare(`SELECT observation_id FROM entity_observations
1170
1272
  WHERE root_id = ? AND revision_no >= ?
1171
1273
  ORDER BY revision_no DESC`).all(row.root_id, row.revision_no);
1274
+ const cited = kg.collect(victims.map(v => v.observation_id));
1172
1275
  for (const v of victims) {
1173
1276
  this.db.prepare(`DELETE FROM observation_events WHERE from_id = ? OR to_id = ?`)
1174
1277
  .run(v.observation_id, v.observation_id);
1175
1278
  purged += this.db.prepare(`DELETE FROM entity_observations WHERE observation_id = ?`)
1176
1279
  .run(v.observation_id).changes;
1177
1280
  }
1281
+ if (purged > 0)
1282
+ kg.mark(cited);
1178
1283
  return purged > 0;
1179
1284
  });
1180
1285
  await this.tryEmbedEntity(row.entity_id, 'bulk');
@@ -1190,6 +1295,7 @@ export class RAGKnowledgeGraphManager {
1190
1295
  if (!this.db)
1191
1296
  throw new Error('Database not initialized');
1192
1297
  console.error(`🗑️ Deleting entities: ${entityNames.join(', ')}`);
1298
+ let anyDeleted = false;
1193
1299
  for (const name of entityNames) {
1194
1300
  const entityId = `entity_${name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
1195
1301
  // One entity = one transaction. The four deletion steps are a single unit:
@@ -1205,6 +1311,9 @@ export class RAGKnowledgeGraphManager {
1205
1311
  `).get(eid);
1206
1312
  if (!entityExists)
1207
1313
  return false;
1314
+ // D2③: evidence chunks of ANY relationship may cite this entity's observations; collect them
1315
+ // before the FK CASCADE takes the observation rows (step 3).
1316
+ const citedEvidence = collectEvidenceRowidsCitingOwnerEntity(this.db, eid);
1208
1317
  // Step 0: Delete entity embeddings
1209
1318
  const embeddingMetadata = this.db.prepare(`
1210
1319
  SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?
@@ -1247,6 +1356,7 @@ export class RAGKnowledgeGraphManager {
1247
1356
  // deletion unless swept here (same fail-closed rationale as deleteStaleKgChunks).
1248
1357
  deleteStaleKgChunks(this.db, eid);
1249
1358
  deleteKgRelationshipChunks(this.db, relIds.map(r => r.id));
1359
+ deleteKgChunkRows(this.db, citedEvidence);
1250
1360
  // Step 3: Finally delete the entity itself (FK CASCADE takes observation lifecycle rows)
1251
1361
  const entity = this.db.prepare(`
1252
1362
  DELETE FROM entities WHERE id = ?
@@ -1254,6 +1364,7 @@ export class RAGKnowledgeGraphManager {
1254
1364
  return entity.changes > 0;
1255
1365
  })(entityId);
1256
1366
  if (deleted) {
1367
+ anyDeleted = true;
1257
1368
  console.error(` └─ Deleted entity '${name}' successfully`);
1258
1369
  }
1259
1370
  else {
@@ -1265,6 +1376,10 @@ export class RAGKnowledgeGraphManager {
1265
1376
  // Continue with other entities instead of failing completely
1266
1377
  }
1267
1378
  }
1379
+ // D2③ (final review A1): the committed deletes removed entity vectors and KG/evidence chunks, so the
1380
+ // cached coverage snapshot is stale — drop it like every other D2③ writer, once, after the commits.
1381
+ if (anyDeleted)
1382
+ this.coordinator?.invalidateCoverage();
1268
1383
  console.error(`✅ Entity deletion process completed`);
1269
1384
  }
1270
1385
  // v3.6 (spec §5c, breaking): structured per-entity results + re-embedding.
@@ -1321,6 +1436,8 @@ export class RAGKnowledgeGraphManager {
1321
1436
  if (touched.length > 0) {
1322
1437
  const tx = this.db.transaction(() => {
1323
1438
  for (const p of touched) {
1439
+ // D2③: this shim does not go through mutateEntityAndInvalidate, so it owes the evidence step itself.
1440
+ const citedEvidence = collectEvidenceRowidsCitingObservations(this.db, p.ids);
1324
1441
  for (const id of p.ids) {
1325
1442
  transitionStatus(this.db, { observationId: id, event: 'retract',
1326
1443
  reason: 'deleteObservations (deprecated shim)', ts });
@@ -1334,6 +1451,7 @@ export class RAGKnowledgeGraphManager {
1334
1451
  .run(p.entityId);
1335
1452
  }
1336
1453
  deleteStaleKgChunks(this.db, p.entityId);
1454
+ deleteKgChunkRows(this.db, citedEvidence);
1337
1455
  }
1338
1456
  });
1339
1457
  tx();
@@ -1359,11 +1477,22 @@ export class RAGKnowledgeGraphManager {
1359
1477
  for (const relation of relations) {
1360
1478
  const sourceId = `entity_${relation.from.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
1361
1479
  const targetId = `entity_${relation.to.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
1362
- this.db.prepare(`
1363
- DELETE FROM relationships
1364
- WHERE source_entity = ? AND target_entity = ? AND relationType = ?
1365
- `).run(sourceId, targetId, relation.relationType);
1480
+ // D2① (kg-relation-evidence): the KG chunk is keyed by relationship_id, so capture the ids
1481
+ // BEFORE the DELETE and remove chunk, vector and FTS row in the same transaction as the row.
1482
+ // 6.4.0 deleted only the row and the chunk stayed searchable (test/kg-delete-relation.test.mjs).
1483
+ this.db.transaction(() => {
1484
+ const ids = this.db.prepare(`
1485
+ SELECT id FROM relationships
1486
+ WHERE source_entity = ? AND target_entity = ? AND relationType = ?
1487
+ `).all(sourceId, targetId, relation.relationType);
1488
+ deleteKgRelationshipChunks(this.db, ids.map(r => r.id));
1489
+ this.db.prepare(`
1490
+ DELETE FROM relationships
1491
+ WHERE source_entity = ? AND target_entity = ? AND relationType = ?
1492
+ `).run(sourceId, targetId, relation.relationType);
1493
+ })();
1366
1494
  }
1495
+ this.coordinator?.invalidateCoverage();
1367
1496
  }
1368
1497
  async updateRelations(updates) {
1369
1498
  if (!this.db)
@@ -1397,9 +1526,15 @@ export class RAGKnowledgeGraphManager {
1397
1526
  continue;
1398
1527
  }
1399
1528
  values.push(relationId);
1400
- this.db.prepare(`
1401
- UPDATE relationships SET ${setClauses.join(', ')} WHERE id = ?
1402
- `).run(...values);
1529
+ // D2② (kg-relation-evidence): the copied display confidence in the KG chunk is refreshed in the
1530
+ // same transaction; body unchanged, so no re-embed (test/kg-confidence-copy.test.mjs).
1531
+ this.db.transaction(() => {
1532
+ this.db.prepare(`
1533
+ UPDATE relationships SET ${setClauses.join(', ')} WHERE id = ?
1534
+ `).run(...values);
1535
+ if (update.confidence !== undefined)
1536
+ refreshKgRelationshipConfidence(this.db, relationId);
1537
+ })();
1403
1538
  updated++;
1404
1539
  }
1405
1540
  return { updated, notFound };
@@ -1970,34 +2105,17 @@ export class RAGKnowledgeGraphManager {
1970
2105
  };
1971
2106
  }
1972
2107
  // NEW: Generate knowledge graph chunks for entities and relationships
2108
+ // D2⑤ (kg-relation-evidence): build every row first, then swap old -> new in ONE transaction. 6.4.0
2109
+ // deleted the old chunks and inserted row by row, so a failure in between published a partial KG.
2110
+ // Bodies, chunk IDs, metadata, offsets and counts are byte-for-byte 6.4.0's (C7.3) — including
2111
+ // end_pos = UTF-16 length, a pre-existing quirk kept on purpose (eval/kg-defaults/generate-snapshot.mjs).
1973
2112
  async generateKnowledgeGraphChunks() {
1974
2113
  if (!this.db)
1975
2114
  throw new Error('Database not initialized');
1976
2115
  console.error('🧠 Generating knowledge graph chunks...');
1977
- // Clean up existing knowledge graph chunks
1978
- await this.cleanupKnowledgeGraphChunks();
1979
- let entityChunks = 0;
1980
- let relationshipChunks = 0;
1981
- // Generate entity chunks
1982
2116
  const entities = this.db.prepare(`
1983
2117
  SELECT id, name, entityType, observations FROM entities
1984
2118
  `).all();
1985
- for (const entity of entities) {
1986
- const observations = JSON.parse(entity.observations);
1987
- const chunkText = this.generateEntityChunkText(entity.name, entity.entityType, observations);
1988
- const chunkId = `kg_entity_${entity.id}`;
1989
- // Store chunk metadata
1990
- this.db.prepare(`
1991
- INSERT INTO chunk_metadata (
1992
- chunk_id, chunk_type, entity_id, chunk_index, text, start_pos, end_pos, metadata
1993
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1994
- `).run(chunkId, 'entity', entity.id, 0, chunkText, 0, chunkText.length, JSON.stringify({
1995
- entity_name: entity.name,
1996
- entity_type: entity.entityType
1997
- }));
1998
- entityChunks++;
1999
- }
2000
- // Generate relationship chunks
2001
2119
  const relationships = this.db.prepare(`
2002
2120
  SELECT
2003
2121
  r.id,
@@ -2009,65 +2127,147 @@ export class RAGKnowledgeGraphManager {
2009
2127
  JOIN entities e1 ON r.source_entity = e1.id
2010
2128
  JOIN entities e2 ON r.target_entity = e2.id
2011
2129
  `).all();
2130
+ const rows = [];
2131
+ for (const entity of entities) {
2132
+ const chunkText = this.generateEntityChunkText(entity.name, entity.entityType, JSON.parse(entity.observations));
2133
+ rows.push({ chunk_id: `kg_entity_${entity.id}`, chunk_type: 'entity', source: entity.id, text: chunkText,
2134
+ metadata: JSON.stringify({ entity_name: entity.name, entity_type: entity.entityType }) });
2135
+ }
2012
2136
  for (const rel of relationships) {
2013
2137
  const chunkText = this.generateRelationshipChunkText(rel.source_name, rel.target_name, rel.relationType);
2014
- const chunkId = `kg_relationship_${rel.id}`;
2015
- // Store chunk metadata
2016
- this.db.prepare(`
2017
- INSERT INTO chunk_metadata (
2018
- chunk_id, chunk_type, relationship_id, chunk_index, text, start_pos, end_pos, metadata
2019
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2020
- `).run(chunkId, 'relationship', rel.id, 0, chunkText, 0, chunkText.length, JSON.stringify({
2021
- source_entity: rel.source_name,
2022
- target_entity: rel.target_name,
2023
- relation_type: rel.relationType,
2024
- confidence: rel.confidence
2025
- }));
2026
- relationshipChunks++;
2027
- }
2138
+ rows.push({ chunk_id: `kg_relationship_${rel.id}`, chunk_type: 'relationship', source: rel.id, text: chunkText,
2139
+ metadata: JSON.stringify({ source_entity: rel.source_name, target_entity: rel.target_name, relation_type: rel.relationType, confidence: rel.confidence }) });
2140
+ }
2141
+ const insEntity = this.db.prepare(`
2142
+ INSERT INTO chunk_metadata (chunk_id, chunk_type, entity_id, chunk_index, text, start_pos, end_pos, metadata)
2143
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
2144
+ const insRel = this.db.prepare(`
2145
+ INSERT INTO chunk_metadata (chunk_id, chunk_type, relationship_id, chunk_index, text, start_pos, end_pos, metadata)
2146
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
2147
+ let entityChunks = 0;
2148
+ let relationshipChunks = 0;
2149
+ this.db.transaction(() => {
2150
+ this.deleteAllKgChunks();
2151
+ for (const r of rows) {
2152
+ (r.chunk_type === 'entity' ? insEntity : insRel).run(r.chunk_id, r.chunk_type, r.source, 0, r.text, 0, r.text.length, r.metadata);
2153
+ if (r.chunk_type === 'entity')
2154
+ entityChunks++;
2155
+ else
2156
+ relationshipChunks++;
2157
+ if (entityChunks + relationshipChunks === 1)
2158
+ __kgFaultHook?.('kg-generate-mid');
2159
+ }
2160
+ })();
2161
+ this.coordinator?.invalidateCoverage();
2028
2162
  console.error(`✅ Knowledge graph chunks generated: ${entityChunks} entities, ${relationshipChunks} relationships`);
2029
2163
  return { entityChunks, relationshipChunks };
2030
2164
  }
2031
2165
  // NEW: Embed knowledge graph chunks
2166
+ // D2⑤: capture before inference, re-check at write (writeKgVectorIfCurrent). 6.4.0 wrote whatever
2167
+ // came back, onto whatever row then held the rowid.
2032
2168
  async embedKnowledgeGraphChunks() {
2033
2169
  if (!this.db)
2034
2170
  throw new Error('Database not initialized');
2035
2171
  console.error('🔮 Embedding knowledge graph chunks...');
2036
- // Get all knowledge graph chunks
2037
2172
  const chunks = this.db.prepare(`
2038
- SELECT rowid, chunk_id, text
2039
- FROM chunk_metadata
2173
+ SELECT rowid, chunk_id, chunk_type, entity_id, relationship_id, text, metadata
2174
+ FROM chunk_metadata
2040
2175
  WHERE chunk_type IN ('entity', 'relationship')
2041
2176
  `).all();
2042
2177
  let embeddedCount = 0;
2178
+ let discarded = 0;
2043
2179
  const errors = [];
2044
2180
  for (const chunk of chunks) {
2045
- // Generate embedding
2046
- const embedding = await this.generateEmbedding(chunk.text, 1024, false, 'bulk');
2047
- const rowid = safeRowid(chunk.rowid);
2181
+ if (chunk.text === null)
2182
+ continue;
2183
+ const captured = captureKgEmbedInput(chunk, this.currentProfileId);
2184
+ const embedding = await this.generateEmbedding(chunk.text, 1024, false, 'bulk', `profile:${captured.profile_id}`);
2048
2185
  try {
2049
- // vector + verified provenance in one transaction (§6a-2) — KG chunks
2050
- // must never become vector-bearing provenance-NULL rows post-recon.
2051
- const tx = this.db.transaction(() => {
2052
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${rowid}`);
2053
- this.db.prepare(`
2054
- INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)
2055
- `).run(Buffer.from(embedding.buffer));
2056
- this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
2057
- .run(createHash('sha256').update(chunk.text).digest('hex'), this.currentProfileId, chunk.rowid);
2058
- });
2059
- tx();
2060
- embeddedCount++;
2186
+ if (this.writeKgVectorIfCurrent(captured, embedding))
2187
+ embeddedCount++;
2188
+ else
2189
+ discarded++;
2061
2190
  }
2062
2191
  catch (error) {
2063
- const errMsg = `chunk ${chunk.chunk_id} (rowid=${rowid}, type=${typeof chunk.rowid}): ${error instanceof Error ? error.message : String(error)}`;
2192
+ const errMsg = `chunk ${chunk.chunk_id} (rowid=${chunk.rowid}): ${error instanceof Error ? error.message : String(error)}`;
2064
2193
  console.error(`Failed to embed ${errMsg}`);
2065
2194
  errors.push(errMsg);
2066
2195
  }
2067
2196
  }
2068
2197
  this.coordinator?.invalidateCoverage();
2069
- console.error(`✅ Knowledge graph chunks embedded: ${embeddedCount}/${chunks.length}`);
2070
- return { embeddedChunks: embeddedCount, totalChunks: chunks.length, ...(errors.length > 0 && { errors: errors.slice(0, 5) }) };
2198
+ console.error(`✅ Knowledge graph chunks embedded: ${embeddedCount}/${chunks.length}${discarded > 0 ? ` (${discarded} discarded: input changed during inference)` : ''}`);
2199
+ return {
2200
+ embeddedChunks: embeddedCount, totalChunks: chunks.length,
2201
+ ...(discarded > 0 && { discardedChunks: discarded }),
2202
+ ...(errors.length > 0 && { errors: errors.slice(0, 5) }),
2203
+ };
2204
+ }
2205
+ // D1 (kg-relation-evidence): opt-in evidence-backed relationship chunks. Manager method only — not an
2206
+ // MCP tool (Q3). `items` is the COMPLETE set S of relationships that get a relationship chunk: a
2207
+ // successful call atomically replaces ALL relationship KG chunks (triplet or evidence) with the
2208
+ // evidence-v1 chunks of S; entity KG chunks, documents and relationship rows are untouched; [] is
2209
+ // valid and empties relationship KG chunks. Any invalid item rejects the whole call before any
2210
+ // write. Bodies are prepared first; inside the commit transaction every item is re-resolved from
2211
+ // the CURRENT rows (active status and chunk type/parent first, then the fingerprint) and must match
2212
+ // what was prepared; the re-resolved rows are what gets inserted. Vectors come later from
2213
+ // embedKnowledgeGraphChunks or the backfill.
2214
+ async generateRelationEvidenceChunks(items, options) {
2215
+ if (!this.db)
2216
+ throw new Error('Database not initialized');
2217
+ if (options?.formatVersion !== EVIDENCE_FORMAT) {
2218
+ throw new Error(`generateRelationEvidenceChunks: formatVersion must be '${EVIDENCE_FORMAT}'`);
2219
+ }
2220
+ if (!Array.isArray(items))
2221
+ throw new Error('generateRelationEvidenceChunks: items must be an array');
2222
+ const seen = new Set();
2223
+ items.forEach((it, i) => {
2224
+ const id = it?.relationship_id;
2225
+ if (typeof id !== 'string')
2226
+ return;
2227
+ if (seen.has(id))
2228
+ throw new EvidenceInputError('duplicate-relationship', i, id);
2229
+ seen.add(id);
2230
+ });
2231
+ const profileAtPrepare = this.currentProfileId;
2232
+ // Array.from, not map: map skips holes, so a hole in items reached the commit loop unprepared and
2233
+ // surfaced as a commit conflict instead of an input error (D1, same class as REVIEW-T5-2_ I1).
2234
+ const prepared = Array.from(items, (it, i) => resolveEvidenceItem(this.db, it, i, profileAtPrepare));
2235
+ __kgFaultHook?.('evidence-pre-commit');
2236
+ let removed = 0;
2237
+ this.db.transaction(() => {
2238
+ const db = this.db;
2239
+ // R1 (REVIEW-PLAN): what is published is the metadata re-resolved HERE, from the current rows —
2240
+ // not the prepared copy. A display-only change between preparation and commit (confidence is not a
2241
+ // fingerprint input) therefore succeeds with the current value instead of re-publishing the old one;
2242
+ // any fingerprint input change still fails the whole call.
2243
+ const current = [];
2244
+ for (let i = 0; i < items.length; i++) {
2245
+ let now;
2246
+ try {
2247
+ now = resolveEvidenceItem(db, items[i], i, this.currentProfileId);
2248
+ }
2249
+ catch (e) {
2250
+ throw new EvidenceCommitConflictError(i, e instanceof EvidenceInputError ? e.code : String(e));
2251
+ }
2252
+ if (now.metadata.input_fingerprint !== prepared[i].metadata.input_fingerprint) {
2253
+ throw new EvidenceCommitConflictError(i, 'fingerprint-changed');
2254
+ }
2255
+ current.push(now); // same fingerprint => same body (every body input is a fingerprint input)
2256
+ }
2257
+ const old = db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type = 'relationship'`).all();
2258
+ removed = deleteKgChunkRows(db, old.map(r => r.rowid));
2259
+ const ins = db.prepare(`
2260
+ INSERT INTO chunk_metadata (chunk_id, chunk_type, relationship_id, chunk_index, text, start_pos, end_pos, metadata)
2261
+ VALUES (?, 'relationship', ?, 0, ?, 0, ?, ?)`);
2262
+ current.forEach((p, i) => {
2263
+ ins.run(p.chunk_id, p.relationship_id, p.text, p.body_codepoints, JSON.stringify(p.metadata));
2264
+ if (i === 0)
2265
+ __kgFaultHook?.('evidence-mid-commit');
2266
+ });
2267
+ })();
2268
+ this.coordinator?.invalidateCoverage();
2269
+ console.error(`✅ Evidence relationship chunks: ${prepared.length} written, ${removed} relationship chunks replaced`);
2270
+ return { relationshipChunks: prepared.length, removedRelationshipChunks: removed, formatVersion: EVIDENCE_FORMAT };
2071
2271
  }
2072
2272
  // NEW: Generate textual representation for entity chunks
2073
2273
  generateEntityChunkText(name, entityType, observations) {
@@ -2080,29 +2280,26 @@ export class RAGKnowledgeGraphManager {
2080
2280
  const relationText = relationType.toLowerCase().replace(/_/g, ' ');
2081
2281
  return `${sourceName} ${relationText} ${targetName}`;
2082
2282
  }
2083
- // NEW: Clean up existing knowledge graph chunks
2084
- async cleanupKnowledgeGraphChunks() {
2283
+ // Delete every KG chunk (vectors, links, metadata). Synchronous so it can run inside the swap
2284
+ // transaction of generateKnowledgeGraphChunks (D2⑤). Body = 6.4.0 cleanupKnowledgeGraphChunks.
2285
+ deleteAllKgChunks() {
2085
2286
  if (!this.db)
2086
2287
  return;
2087
2288
  console.error('🧹 Cleaning up existing knowledge graph chunks...');
2088
- // Get existing knowledge graph chunks
2089
2289
  const existingChunks = this.db.prepare(`
2090
2290
  SELECT rowid FROM chunk_metadata WHERE chunk_type IN ('entity', 'relationship')
2091
2291
  `).all();
2092
2292
  let deletedVectors = 0;
2093
2293
  let deletedAssociations = 0;
2094
- // Delete vectors and associations
2095
2294
  for (const chunk of existingChunks) {
2096
2295
  // Delete vector embeddings (vec0 needs literal integer, not parameterized)
2097
2296
  this.db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(chunk.rowid)}`);
2098
2297
  deletedVectors++;
2099
- // Delete chunk-entity associations
2100
2298
  const associations = this.db.prepare(`
2101
2299
  DELETE FROM chunk_entities WHERE chunk_rowid = ?
2102
2300
  `).run(chunk.rowid);
2103
2301
  deletedAssociations += associations.changes;
2104
2302
  }
2105
- // Delete chunk metadata
2106
2303
  const metadata = this.db.prepare(`
2107
2304
  DELETE FROM chunk_metadata WHERE chunk_type IN ('entity', 'relationship')
2108
2305
  `).run();
@@ -2300,9 +2497,13 @@ export class RAGKnowledgeGraphManager {
2300
2497
  }
2301
2498
  // Generate embeddings using sentence transformers
2302
2499
  // isQuery: true for search queries (adds instruction prefix), false for documents/entities
2303
- async generateEmbedding(text, dimensions = 1024, isQuery = false, priority = 'interactive') {
2500
+ async generateEmbedding(text, dimensions = 1024, isQuery = false, priority = 'interactive', cacheScope) {
2304
2501
  // Check cache first (hash-based key to avoid collisions on long texts)
2305
- const cacheKey = createHash('md5').update(`${text}_${dimensions}_${isQuery}`).digest('hex');
2502
+ // kg-relation-evidence R5: the key has no profile, so a vector cached under profile P1 would be
2503
+ // returned — and stamped P2 by the KG CAS — after a switch to P2. The KG write paths pass
2504
+ // cacheScope = 'profile:<captured id>'; without a scope the key is exactly the 6.4.0 key, so
2505
+ // document and query caching is unchanged (their profile policy is not this change's scope).
2506
+ const cacheKey = createHash('md5').update(`${text}_${dimensions}_${isQuery}${cacheScope === undefined ? '' : `_${cacheScope}`}`).digest('hex');
2306
2507
  const cached = this.embeddingCache.get(cacheKey);
2307
2508
  if (cached)
2308
2509
  return cached;
@@ -2431,12 +2632,16 @@ export class RAGKnowledgeGraphManager {
2431
2632
  now.metadata === snap.metadata && now.chunking_signature === snap.chunking_signature);
2432
2633
  if (!same)
2433
2634
  throw new SyncCasConflictError(documentId);
2635
+ // D2③: collect evidence citing this document before its chunks are replaced; delete it below,
2636
+ // in this transaction (no separate deletion moment).
2637
+ const citedEvidence = collectEvidenceRowidsCitingDocument(db, documentId);
2434
2638
  const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2435
2639
  for (const ch of existing) {
2436
2640
  db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
2437
2641
  db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
2438
2642
  }
2439
2643
  db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
2644
+ deleteKgChunkRows(db, citedEvidence);
2440
2645
  db.prepare(`DELETE FROM documents WHERE id = ?`).run(documentId);
2441
2646
  db.prepare(`INSERT INTO documents (id, content, metadata, chunking_signature) VALUES (?, ?, ?, ?)`)
2442
2647
  .run(documentId, content, JSON.stringify(metadata), signature);
@@ -2508,13 +2713,22 @@ export class RAGKnowledgeGraphManager {
2508
2713
  // Decide `replaced` from the document row, not from the chunk count: a document stored but
2509
2714
  // never chunked still gets overwritten here, and reporting that as a fresh write would be a lie.
2510
2715
  const existed = this.db.prepare(`SELECT 1 FROM documents WHERE id = ?`).get(id) !== undefined;
2511
- // Clean up existing document
2512
- const cleaned = await this.cleanupDocument(id);
2513
- // Store document
2514
- this.db.prepare(`
2515
- INSERT OR REPLACE INTO documents (id, content, metadata)
2516
- VALUES (?, ?, ?)
2517
- `).run(id, content, JSON.stringify(metadata));
2716
+ // D2③ + R-B (2_ RESULT-T7-TXN-2_): serialise first, then ONE synchronous transaction = cleanup
2717
+ // (collect citing evidence, delete chunks and evidence) + the documents REPLACE. With an await
2718
+ // between the two, another call could publish evidence on this document after the collection, and a
2719
+ // failing REPLACE left the chunks and evidence already deleted.
2720
+ const metadataJson = JSON.stringify(metadata);
2721
+ const cleaned = this.db.transaction(() => {
2722
+ const c = this.cleanupDocument(id);
2723
+ this.db.prepare(`
2724
+ INSERT OR REPLACE INTO documents (id, content, metadata)
2725
+ VALUES (?, ?, ?)
2726
+ `).run(id, content, metadataJson);
2727
+ return c;
2728
+ })();
2729
+ // R-B item 6 (review I1): only after the commit succeeded — chunks and evidence were removed, so the
2730
+ // cached coverage snapshot is stale. A rollback throws above and never reaches this.
2731
+ this.coordinator?.invalidateCoverage();
2518
2732
  console.error(`✅ Document stored: ${id}`);
2519
2733
  return { id, stored: true, replaced: existed, deletedChunks: cleaned.deletedChunks };
2520
2734
  }
@@ -2530,32 +2744,38 @@ export class RAGKnowledgeGraphManager {
2530
2744
  }
2531
2745
  const { maxTokens } = this.validateChunkParams(options);
2532
2746
  console.error(`🔪 Chunking document: ${documentId} (maxTokens: ${maxTokens}, chunker: c1)`);
2533
- // Clean up existing chunks
2534
- await this.cleanupDocument(documentId);
2535
- // Create chunks
2747
+ // Create chunks. D2③ + R-B (2_ RESULT-T7-TXN-2_): chunking is synchronous and runs BEFORE anything
2748
+ // is deleted, so a chunker failure deletes nothing.
2536
2749
  const chunks = this.chunkStructured(document.content, maxTokens);
2537
2750
  const resultChunks = [];
2538
- for (const chunk of chunks) {
2539
- const chunkId = `${documentId}_chunk_${chunk.chunk_index}`;
2540
- // Store chunk metadata (no embedding yet)
2541
- this.db.prepare(`
2542
- INSERT INTO chunk_metadata (
2543
- chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token
2544
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2545
- `).run(chunkId, documentId, chunk.chunk_index, chunk.text, chunk.start_pos, chunk.end_pos, chunk.start_token, chunk.end_token);
2546
- resultChunks.push({
2547
- id: chunkId,
2548
- text: chunk.text,
2549
- startPos: chunk.start_pos,
2550
- endPos: chunk.end_pos,
2551
- startToken: chunk.start_token,
2552
- endToken: chunk.end_token
2553
- });
2554
- }
2751
+ // ONE synchronous transaction = cleanup (collect evidence citing this document, delete its chunks and
2752
+ // that evidence) + every new chunk INSERT + the signature UPDATE. A re-chunk reuses the ordinal chunk
2753
+ // ids, so with an await between cleanup and the INSERTs another call could cite the new D_chunk_0
2754
+ // after the collection; and a failure after cleanup lost the old chunks for good.
2755
+ this.db.transaction(() => {
2756
+ this.cleanupDocument(documentId);
2757
+ for (const chunk of chunks) {
2758
+ const chunkId = `${documentId}_chunk_${chunk.chunk_index}`;
2759
+ // Store chunk metadata (no embedding yet)
2760
+ this.db.prepare(`
2761
+ INSERT INTO chunk_metadata (
2762
+ chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token
2763
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2764
+ `).run(chunkId, documentId, chunk.chunk_index, chunk.text, chunk.start_pos, chunk.end_pos, chunk.start_token, chunk.end_token);
2765
+ resultChunks.push({
2766
+ id: chunkId,
2767
+ text: chunk.text,
2768
+ startPos: chunk.start_pos,
2769
+ endPos: chunk.end_pos,
2770
+ startToken: chunk.start_token,
2771
+ endToken: chunk.end_token
2772
+ });
2773
+ }
2774
+ // spec §7.1: 두 번째 chunk 생성 경로 — 스탬프를 안 박으면 §5.1 관측이 조용히 샌다.
2775
+ this.db.prepare(`UPDATE documents SET chunking_signature = ? WHERE id = ?`)
2776
+ .run(effectiveSignature(maxTokens), documentId);
2777
+ })();
2555
2778
  console.error(`✅ Document chunked: ${chunks.length} chunks created`);
2556
- // spec §7.1: 두 번째 chunk 생성 경로 — 스탬프를 안 박으면 §5.1 관측이 조용히 샌다.
2557
- this.db.prepare(`UPDATE documents SET chunking_signature = ? WHERE id = ?`)
2558
- .run(effectiveSignature(maxTokens), documentId);
2559
2779
  // Indirect missing-row producer (spec §5): freshly chunked rows have no
2560
2780
  // vectors yet — let the coordinator recover them without a restart.
2561
2781
  this.coordinator?.invalidateCoverage();
@@ -3007,37 +3227,44 @@ export class RAGKnowledgeGraphManager {
3007
3227
  // that replaces a document could not tell from the return value that anything was deleted
3008
3228
  // (2026-08-05 field report from a deployed project: "{stored:true} came back and I did not know
3009
3229
  // what I had just wiped"). Silent destruction is the defect; the numbers are free.
3010
- async cleanupDocument(documentId) {
3230
+ cleanupDocument(documentId) {
3011
3231
  if (!this.db)
3012
3232
  return { deletedChunks: 0, deletedAssociations: 0, deletedVectors: 0 };
3013
3233
  console.error(`🧹 Cleaning up document: ${documentId}`);
3014
- // Get existing chunks
3015
- const existingChunks = this.db.prepare(`
3016
- SELECT rowid FROM chunk_metadata WHERE document_id = ?
3017
- `).all(documentId);
3018
- let deletedAssociations = 0;
3019
- let deletedVectors = 0;
3020
- // Delete associations and vectors
3021
- for (const chunk of existingChunks) {
3022
- // Delete chunk-entity associations
3023
- const associations = this.db.prepare(`
3024
- DELETE FROM chunk_entities WHERE chunk_rowid = ?
3025
- `).run(chunk.rowid);
3026
- deletedAssociations += associations.changes;
3027
- // Delete vector embeddings (vec0 needs literal integer, not parameterized)
3028
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(chunk.rowid)}`);
3029
- deletedVectors++;
3030
- }
3031
- // Delete chunk metadata
3032
- const metadata = this.db.prepare(`
3033
- DELETE FROM chunk_metadata WHERE document_id = ?
3034
- `).run(documentId);
3035
- if (existingChunks.length > 0) {
3036
- console.error(` ├─ Deleted ${deletedAssociations} entity associations`);
3037
- console.error(` ├─ Deleted ${deletedVectors} vector embeddings`);
3038
- console.error(` └─ Deleted ${metadata.changes} chunk metadata records`);
3039
- }
3040
- return { deletedChunks: existingChunks.length, deletedAssociations, deletedVectors };
3234
+ // D2③ (kg-relation-evidence): collect -> delete chunks -> invalidate citing evidence in ONE
3235
+ // transaction. 6.4.0 ran these statements one by one in autocommit. Used by storeDocument,
3236
+ // chunkDocument (re-chunk that reuses ordinal ids) and deleteDocument(s).
3237
+ // R-B (2_ RESULT-T7-TXN-2_): SYNCHRONOUS — results and exceptions reach the caller in the same
3238
+ // tick. Every caller runs it inside its own transaction together with the source change, so this
3239
+ // transaction nests as a savepoint and the caller's final SQL is inside the same rollback scope.
3240
+ return this.db.transaction(() => {
3241
+ const db = this.db;
3242
+ const citedEvidence = collectEvidenceRowidsCitingDocument(db, documentId);
3243
+ const existingChunks = db.prepare(`
3244
+ SELECT rowid FROM chunk_metadata WHERE document_id = ?
3245
+ `).all(documentId);
3246
+ let deletedAssociations = 0;
3247
+ let deletedVectors = 0;
3248
+ for (const chunk of existingChunks) {
3249
+ const associations = db.prepare(`
3250
+ DELETE FROM chunk_entities WHERE chunk_rowid = ?
3251
+ `).run(chunk.rowid);
3252
+ deletedAssociations += associations.changes;
3253
+ // Delete vector embeddings (vec0 needs literal integer, not parameterized)
3254
+ db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(chunk.rowid)}`);
3255
+ deletedVectors++;
3256
+ }
3257
+ const metadata = db.prepare(`
3258
+ DELETE FROM chunk_metadata WHERE document_id = ?
3259
+ `).run(documentId);
3260
+ deleteKgChunkRows(db, citedEvidence);
3261
+ if (existingChunks.length > 0) {
3262
+ console.error(` ├─ Deleted ${deletedAssociations} entity associations`);
3263
+ console.error(` ├─ Deleted ${deletedVectors} vector embeddings`);
3264
+ console.error(` └─ Deleted ${metadata.changes} chunk metadata records`);
3265
+ }
3266
+ return { deletedChunks: existingChunks.length, deletedAssociations, deletedVectors };
3267
+ })();
3041
3268
  }
3042
3269
  async deleteDocument(documentId) {
3043
3270
  if (!this.db)
@@ -3052,12 +3279,18 @@ export class RAGKnowledgeGraphManager {
3052
3279
  console.warn(`⚠️ Document '${documentId}' not found`);
3053
3280
  return { documentId, deleted: false };
3054
3281
  }
3055
- // Clean up all associated data
3056
- await this.cleanupDocument(documentId);
3057
- // Delete the document itself
3058
- const result = this.db.prepare(`
3059
- DELETE FROM documents WHERE id = ?
3060
- `).run(documentId);
3282
+ // D2③ + R-B (2_ RESULT-T7-TXN-2_): cleanup (collect evidence citing this document, delete its
3283
+ // chunks and that evidence) and the document DELETE are ONE synchronous transaction — no await
3284
+ // between them, so no evidence can be published on this document after the collection, and a
3285
+ // failing DELETE rolls the cleanup back.
3286
+ const result = this.db.transaction(() => {
3287
+ this.cleanupDocument(documentId);
3288
+ return this.db.prepare(`
3289
+ DELETE FROM documents WHERE id = ?
3290
+ `).run(documentId);
3291
+ })();
3292
+ // R-B item 6 (review I1): after the successful commit only (a rollback throws above).
3293
+ this.coordinator?.invalidateCoverage();
3061
3294
  if (result.changes > 0) {
3062
3295
  console.error(`✅ Document '${documentId}' deleted successfully`);
3063
3296
  return { documentId, deleted: true };
@@ -3217,6 +3450,26 @@ export class RAGKnowledgeGraphManager {
3217
3450
  if (!this.db)
3218
3451
  throw new Error('Database not initialized');
3219
3452
  console.error(`📥 Importing knowledge graph (merge: ${options.merge !== false})...`);
3453
+ // D2③ / C3 (final review I1): the evidence dependency collectors compare string ids strictly, but
3454
+ // the INSERTs below bind whatever the dump holds, and SQLite stores a number bound to a TEXT id
3455
+ // column as its decimal text (7.5 -> '7.5'). A numeric id could therefore replace a document, or add
3456
+ // a source to an observation, that cited evidence names by string while the collector misses it.
3457
+ // One id contract for writing and collecting: these two ids must be strings, checked before any
3458
+ // write, so a violation refuses the whole import with nothing written. No numeric normalisation.
3459
+ if (Array.isArray(data.documents)) {
3460
+ data.documents.forEach((doc, i) => {
3461
+ if (typeof doc?.id !== 'string') {
3462
+ throw new Error(`importGraph: documents[${i}].id must be a string (got ${doc?.id === null ? 'null' : typeof doc?.id}); nothing was imported`);
3463
+ }
3464
+ });
3465
+ }
3466
+ if (Array.isArray(data.observation_sources)) {
3467
+ data.observation_sources.forEach((so, i) => {
3468
+ if (typeof so?.observation_id !== 'string') {
3469
+ throw new Error(`importGraph: observation_sources[${i}].observation_id must be a string (got ${so?.observation_id === null ? 'null' : typeof so?.observation_id}); nothing was imported`);
3470
+ }
3471
+ });
3472
+ }
3220
3473
  const imported = { entities: 0, relations: 0, documents: 0 };
3221
3474
  const skipped = { entities: 0, relations: 0, documents: 0 };
3222
3475
  // merge 로 배열 위치가 재배정된 관찰. 조용히 순서를 바꾸면 호출자가 알 수 없으므로
@@ -3253,6 +3506,10 @@ export class RAGKnowledgeGraphManager {
3253
3506
  this.db.exec(`DELETE FROM entity_embedding_metadata`);
3254
3507
  console.error('🗑️ Cleared existing data for full import');
3255
3508
  }
3509
+ // D2③ (kg-relation-evidence): import is an observation AND a document writer. Evidence citing an
3510
+ // observation that gains a source row, or a document this import replaces, is collected before the
3511
+ // row is written and deleted at the end of this transaction.
3512
+ const citedEvidence = new Set();
3256
3513
  // Import entities using INSERT OR IGNORE
3257
3514
  if (data.entities && Array.isArray(data.entities)) {
3258
3515
  const stmt = this.db.prepare(`
@@ -3295,6 +3552,9 @@ export class RAGKnowledgeGraphManager {
3295
3552
  VALUES (?, ?, ?, ?)
3296
3553
  `);
3297
3554
  for (const doc of data.documents) {
3555
+ // doc.id is a string here (checked above, before any write).
3556
+ for (const r of collectEvidenceRowidsCitingDocument(this.db, doc.id))
3557
+ citedEvidence.add(r);
3298
3558
  const result = stmt.run(doc.id, doc.content, JSON.stringify(doc.metadata || {}), doc.created_at || new Date().toISOString());
3299
3559
  if (result.changes > 0) {
3300
3560
  imported.documents++;
@@ -3387,6 +3647,8 @@ export class RAGKnowledgeGraphManager {
3387
3647
  `${so.observation_id}/${so.source_kind}/${so.source_ref} differs from the existing row`);
3388
3648
  continue;
3389
3649
  }
3650
+ for (const r of collectEvidenceRowidsCitingObservations(this.db, [so.observation_id]))
3651
+ citedEvidence.add(r);
3390
3652
  this.db.prepare(`INSERT INTO observation_sources
3391
3653
  (observation_id, source_kind, source_ref, source_hash, recorded_at) VALUES (?, ?, ?, ?, ?)`)
3392
3654
  .run(so.observation_id, so.source_kind, so.source_ref, so.source_hash ?? null, so.recorded_at);
@@ -3427,6 +3689,7 @@ export class RAGKnowledgeGraphManager {
3427
3689
  });
3428
3690
  tx();
3429
3691
  }
3692
+ deleteKgChunkRows(this.db, [...citedEvidence]);
3430
3693
  // projection 재합성 + 파생 상태 무효화.
3431
3694
  // 무효화가 없으면 이미 있던 entity 를 덮어쓴 뒤에도 옛 벡터·옛 KG chunk 가
3432
3695
  // 검색에 남는다. import 는 관찰을 바꾸는 writer 이므로 다른 writer 와 같은
@@ -3759,6 +4022,11 @@ export class RAGKnowledgeGraphManager {
3759
4022
  for (const result of vectorResults) {
3760
4023
  // Get entities associated with this chunk (for document chunks)
3761
4024
  let chunkEntities = [];
4025
+ // D2④ (kg-relation-evidence): full_context_available = "getDetailedContext can resolve the stored
4026
+ // body". Document chunks: unchanged (true — the FK cascade removes a chunk with its document). KG
4027
+ // chunks: only while their entity / relationship row exists. It is NOT a verified-evidence signal;
4028
+ // that is evidence_status in getDetailedContext. 6.4.0 hard-coded true.
4029
+ let sourceResolved = result.chunk_type === 'document';
3762
4030
  if (result.chunk_type === 'document') {
3763
4031
  chunkEntities = this.db.prepare(`
3764
4032
  SELECT e.name
@@ -3774,6 +4042,7 @@ export class RAGKnowledgeGraphManager {
3774
4042
  `).get(result.entity_id);
3775
4043
  if (entity) {
3776
4044
  chunkEntities = [entity.name];
4045
+ sourceResolved = true;
3777
4046
  }
3778
4047
  }
3779
4048
  else if (result.chunk_type === 'relationship' && result.relationship_id) {
@@ -3787,6 +4056,7 @@ export class RAGKnowledgeGraphManager {
3787
4056
  `).get(result.relationship_id);
3788
4057
  if (relEntities) {
3789
4058
  chunkEntities = [relEntities.source_name, relEntities.target_name];
4059
+ sourceResolved = true;
3790
4060
  }
3791
4061
  }
3792
4062
  // Enhanced graph boost calculation with decay + cap
@@ -3889,7 +4159,7 @@ export class RAGKnowledgeGraphManager {
3889
4159
  vector_similarity: vectorSimilarity,
3890
4160
  graph_boost: (useGraph && !vectorDegraded) ? graphBoost : undefined,
3891
4161
  fts_boost: ftsBoost > 0 ? ftsBoost : undefined,
3892
- full_context_available: true,
4162
+ full_context_available: sourceResolved,
3893
4163
  chunk_type: result.chunk_type,
3894
4164
  source_id: sourceId
3895
4165
  });
@@ -3937,6 +4207,13 @@ export class RAGKnowledgeGraphManager {
3937
4207
  if (!this.db)
3938
4208
  throw new Error('Database not initialized');
3939
4209
  console.error(`📖 Getting detailed context for chunk: ${chunkId}`);
4210
+ // D2④ (kg-relation-evidence): KG chunks have no documents row, so the INNER JOIN below never found
4211
+ // them (6.4.0: every KG hit failed here). Branch on chunk_type first; the document branch below is
4212
+ // unchanged.
4213
+ const head = this.db.prepare(`SELECT chunk_type FROM chunk_metadata WHERE chunk_id = ?`).get(chunkId);
4214
+ if (head && (head.chunk_type === 'entity' || head.chunk_type === 'relationship')) {
4215
+ return this.getKgDetailedContext(chunkId);
4216
+ }
3940
4217
  // Get the main chunk
3941
4218
  const chunk = this.db.prepare(`
3942
4219
  SELECT
@@ -4002,6 +4279,49 @@ export class RAGKnowledgeGraphManager {
4002
4279
  metadata: metadata
4003
4280
  };
4004
4281
  }
4282
+ // D2④: entity -> the entity and its ACTIVE observations (history stays with getObservationHistory);
4283
+ // relationship -> the relationship row + evidence_status + per-reference evidence_refs. When the
4284
+ // entity / relationship row itself is gone the call fails as an unknown chunk does.
4285
+ getKgDetailedContext(chunkId) {
4286
+ const row = this.db.prepare(`SELECT chunk_id, chunk_type, entity_id, relationship_id, text, metadata FROM chunk_metadata WHERE chunk_id = ?`)
4287
+ .get(chunkId);
4288
+ let meta = {};
4289
+ try {
4290
+ const parsed = JSON.parse(row.metadata ?? '{}');
4291
+ if (parsed && typeof parsed === 'object')
4292
+ meta = parsed;
4293
+ }
4294
+ catch {
4295
+ meta = {};
4296
+ }
4297
+ const notFound = () => new Error(`Chunk with ID ${chunkId} not found`);
4298
+ if (row.chunk_type === 'entity') {
4299
+ const ent = row.entity_id
4300
+ ? this.db.prepare(`SELECT id, name, entityType FROM entities WHERE id = ?`).get(row.entity_id)
4301
+ : undefined;
4302
+ if (!ent)
4303
+ throw notFound();
4304
+ const observations = this.db.prepare(`SELECT observation_id, revision_no, content FROM entity_observations
4305
+ WHERE entity_id = ? AND status = 'active' ORDER BY projection_order`).all(ent.id);
4306
+ return { chunk_id: row.chunk_id, document_id: null, document_title: null, source_kind: 'entity', source_id: ent.id,
4307
+ full_text: row.text ?? '', entities: [ent.name], metadata: meta,
4308
+ entity: { name: ent.name, entityType: ent.entityType, observations } };
4309
+ }
4310
+ const rel = row.relationship_id
4311
+ ? this.db.prepare(`
4312
+ SELECT r.id, r.relationType, r.confidence, e1.name AS source_name, e2.name AS target_name
4313
+ FROM relationships r JOIN entities e1 ON e1.id = r.source_entity JOIN entities e2 ON e2.id = r.target_entity
4314
+ WHERE r.id = ?`).get(row.relationship_id)
4315
+ : undefined;
4316
+ if (!rel)
4317
+ throw notFound();
4318
+ const ev = resolveEvidenceStatus(this.db, meta);
4319
+ return { chunk_id: row.chunk_id, document_id: null, document_title: null, source_kind: 'relationship', source_id: rel.id,
4320
+ full_text: row.text ?? '', entities: [rel.source_name, rel.target_name], metadata: meta,
4321
+ relationship: { id: rel.id, source: rel.source_name, target: rel.target_name, relationType: rel.relationType, confidence: rel.confidence },
4322
+ evidence_status: ev.status, evidence_refs: ev.refs,
4323
+ support: meta.kg_format === EVIDENCE_FORMAT && meta.support ? meta.support : null };
4324
+ }
4005
4325
  async getKnowledgeGraphStats() {
4006
4326
  if (!this.db)
4007
4327
  throw new Error('Database not initialized');