rag-memory-epf-mcp 6.4.0 → 6.4.1

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.
@@ -0,0 +1,438 @@
1
+ // kg-relation-evidence (specs/changes/kg-relation-evidence/proposal.md r3, D1·D2).
2
+ // Evidence-backed relationship chunks: everything that must be computed identically in four places —
3
+ // generation, the commit-time re-check, and both KG embedding write-backs (dedicated + backfill).
4
+ // Pure functions first; the resolvers further down take a better-sqlite3 handle and never open a
5
+ // transaction themselves: callers own the transaction (CODE_CONTEXT §2).
6
+ import { createHash } from 'node:crypto';
7
+ export const EVIDENCE_FORMAT = 'evidence-v1';
8
+ // Policy limits for this experiment, not a claim of sufficiency (proposal D1). Over-cap input is
9
+ // refused, never truncated, and an item is never dropped from S to fit.
10
+ export const EVIDENCE_CAPS = Object.freeze({ maxEntries: 8, maxQuoteCodepoints: 4000, maxBodyCodepoints: 8000 });
11
+ export const FINGERPRINT_TAG = 'kg-evidence-fp/1';
12
+ export const LEGACY_CMP_TAG = 'kg-legacy-cmp/1';
13
+ // "Evidence-backed" is a claim about references, not about truth (proposal D1, last bullet).
14
+ export const EVIDENCE_SEMANTICS = 'caller-selected evidence; the engine verified the references exist verbatim; not an independent proof of the relation';
15
+ // Codepoints, never UTF-16 units: '🎉'.length === 2 but cpLength('🎉') === 1. Caps and [start, end)
16
+ // are defined in codepoints (D1), like chunk start_pos/end_pos since migration 11.
17
+ export function cpLength(s) {
18
+ let n = 0;
19
+ for (const _ of s)
20
+ n++;
21
+ return n;
22
+ }
23
+ export function cpSlice(s, start, end) {
24
+ return Array.from(s).slice(start, end).join('');
25
+ }
26
+ // SHA-256 of the UTF-8 bytes, lowercase hex. No normalisation, no newline rewriting. Node encodes a
27
+ // lone surrogate as U+FFFD (EF BF BD), the same bytes TextEncoder produces.
28
+ export function sha256Utf8(s) {
29
+ return createHash('sha256').update(s, 'utf8').digest('hex');
30
+ }
31
+ // Fixed order: kind, then ref, then hash with null first; strings compared by code unit. The reader
32
+ // sorts with the same function, so "same sources" means the same sorted list.
33
+ export function compareSourceTuples(a, b) {
34
+ const c = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
35
+ if (a.source_kind !== b.source_kind)
36
+ return c(a.source_kind, b.source_kind);
37
+ if (a.source_ref !== b.source_ref)
38
+ return c(a.source_ref, b.source_ref);
39
+ // An absent hash is null (global constraints): normalise here so undefined vs 'a' is not a tie.
40
+ const ah = a.source_hash ?? null;
41
+ const bh = b.source_hash ?? null;
42
+ if (ah === bh)
43
+ return 0;
44
+ if (ah === null)
45
+ return -1;
46
+ if (bh === null)
47
+ return 1;
48
+ return c(ah, bh);
49
+ }
50
+ function int(v, what) {
51
+ if (typeof v !== 'number' || !Number.isInteger(v))
52
+ throw new Error(`fingerprint: ${what} must be an integer (got ${String(v)})`);
53
+ return v;
54
+ }
55
+ // The one serialisation (plan "Fingerprint serialisation"). Positional arrays only, so no key order can
56
+ // drift; evidence in INPUT order — the range is included because the same quote text can sit at two
57
+ // positions of one chunk ([0,13) vs [14,27), REVIEW-ALPHA2 §2.1).
58
+ export function fingerprintSerialization(f) {
59
+ const refs = f.refs.map((r) => r.kind === 'document'
60
+ ? ['document', r.document_id, r.chunk_id, int(r.start, 'start'), int(r.end, 'end'), r.chunk_text_sha256, r.quote_sha256]
61
+ : ['observation', r.observation_id, r.entity_id, int(r.revision_no, 'revision_no'), r.content_sha256,
62
+ [...r.sources].sort(compareSourceTuples).map((s) => [s.source_kind, s.source_ref, s.source_hash ?? null])]);
63
+ return JSON.stringify([FINGERPRINT_TAG, f.relationship_id, f.source_entity_id, f.target_entity_id,
64
+ f.relation_type, f.source_name, f.target_name, f.kg_format, refs, int(f.profile_id, 'profile_id')]);
65
+ }
66
+ export function computeInputFingerprint(f) {
67
+ return sha256Utf8(fingerprintSerialization(f));
68
+ }
69
+ export function fingerprintFromMetadata(m, profileId) {
70
+ return computeInputFingerprint({
71
+ relationship_id: m.relationship_id, source_entity_id: m.source_entity_id, target_entity_id: m.target_entity_id,
72
+ relation_type: m.relation_type, source_name: m.source_entity, target_name: m.target_entity,
73
+ kg_format: EVIDENCE_FORMAT, refs: m.evidence_refs, profile_id: profileId,
74
+ });
75
+ }
76
+ // Claim line (raw type, direction kept) + each quote verbatim in input order (D1). No IDs, hashes or
77
+ // internal state in the embedded text.
78
+ export function buildEvidenceBody(sourceName, relationType, targetName, quotes) {
79
+ return [`${sourceName} — ${relationType} — ${targetName}`, ...quotes].join('\n\n');
80
+ }
81
+ // Same deterministic id as the triplet chunk (index.ts:2311): one relationship chunk per relationship.
82
+ export function evidenceChunkId(relationshipId) {
83
+ return `kg_relationship_${relationshipId}`;
84
+ }
85
+ export function parseEvidenceMetadata(raw) {
86
+ if (raw === null)
87
+ return null;
88
+ let m;
89
+ try {
90
+ m = JSON.parse(raw);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ if (!m || typeof m !== 'object' || m.kg_format !== EVIDENCE_FORMAT)
96
+ return null;
97
+ return m;
98
+ }
99
+ // D2⑤ comparison value. Evidence-v1: the stored fingerprint, but only if it still recomputes from the
100
+ // row's own metadata and belongs to this row (a hand-edited or foreign row is never "equal"). Legacy
101
+ // entity/triplet rows have no fingerprint field, so the value is derived at read time from the source
102
+ // identity + body hash. Anything missing returns null, and null is never equal (proposal D2⑤).
103
+ export function kgComparisonValue(row) {
104
+ if (row.text === null)
105
+ return null;
106
+ let meta;
107
+ try {
108
+ const parsed = JSON.parse(row.metadata ?? '{}');
109
+ if (!parsed || typeof parsed !== 'object')
110
+ return null;
111
+ meta = parsed;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ if (meta.kg_format !== undefined) {
117
+ if (meta.kg_format !== EVIDENCE_FORMAT || row.chunk_type !== 'relationship')
118
+ return null;
119
+ const m = meta;
120
+ if (typeof m.input_fingerprint !== 'string' || typeof m.fingerprint_profile_id !== 'number')
121
+ return null;
122
+ // A row without a source id is never equal (D2⑤), even if its metadata agrees with that null.
123
+ if (typeof row.relationship_id !== 'string' || row.relationship_id === '')
124
+ return null;
125
+ if (m.relationship_id !== row.relationship_id)
126
+ return null;
127
+ // Identifiers must be non-empty; names and the raw type are stored as-is and may legitimately be
128
+ // empty strings (the engine accepts them), so for those only a non-string is "missing".
129
+ for (const k of [m.source_entity_id, m.target_entity_id]) {
130
+ if (typeof k !== 'string' || k === '')
131
+ return null;
132
+ }
133
+ for (const k of [m.source_entity, m.target_entity, m.relation_type]) {
134
+ if (typeof k !== 'string')
135
+ return null;
136
+ }
137
+ // Final review A2: the reader (resolveEvidenceStatus) and every dependency lookup (collectCiting) treat
138
+ // an empty or malformed evidence_refs as ineligible; a row that cannot be resolved must not be
139
+ // comparable either, even when its stored fingerprint recomputes from that metadata.
140
+ const refs = m.evidence_refs;
141
+ if (!Array.isArray(refs) || refs.length === 0 || !refs.every(isWellFormedRef))
142
+ return null;
143
+ try {
144
+ if (fingerprintFromMetadata(m, m.fingerprint_profile_id) !== m.input_fingerprint)
145
+ return null;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ return `fp:${m.input_fingerprint}`;
151
+ }
152
+ const sourceId = row.chunk_type === 'entity' ? row.entity_id : row.chunk_type === 'relationship' ? row.relationship_id : null;
153
+ if (!sourceId)
154
+ return null;
155
+ return `legacy:${sha256Utf8(JSON.stringify([LEGACY_CMP_TAG, row.chunk_type, sourceId, sha256Utf8(row.text)]))}`;
156
+ }
157
+ // Taken immediately BEFORE inference; writeKgVectorIfCurrent (index.ts) re-checks all of it at write.
158
+ export function captureKgEmbedInput(row, profileId) {
159
+ return { rowid: row.rowid, chunk_id: row.chunk_id, text_sha256: sha256Utf8(row.text ?? ''), cmp: kgComparisonValue(row), profile_id: profileId };
160
+ }
161
+ // (resolvers are added by Tasks 5, 6 and 7 below this line)
162
+ // ---- D1 resolution (generation AND commit re-check run exactly this) ----
163
+ export class EvidenceInputError extends Error {
164
+ code;
165
+ itemIndex;
166
+ constructor(code, itemIndex, detail) {
167
+ super(`generateRelationEvidenceChunks: item ${itemIndex}: ${code}${detail ? ` (${detail})` : ''}`);
168
+ this.code = code;
169
+ this.itemIndex = itemIndex;
170
+ this.name = 'EvidenceInputError';
171
+ }
172
+ }
173
+ export class EvidenceCommitConflictError extends Error {
174
+ itemIndex;
175
+ reason;
176
+ constructor(itemIndex, reason) {
177
+ super(`generateRelationEvidenceChunks: item ${itemIndex} changed between preparation and commit: ${reason}`);
178
+ this.itemIndex = itemIndex;
179
+ this.reason = reason;
180
+ this.name = 'EvidenceCommitConflictError';
181
+ }
182
+ }
183
+ const WHY_STATUSES = ['reason-stated', 'relation-only', 'unjudged'];
184
+ // Every stored source of an observation, sorted (D1: the real rows, never a summary label).
185
+ export function readSourceTuples(db, observationId) {
186
+ const rows = db.prepare(`SELECT source_kind, source_ref, source_hash FROM observation_sources WHERE observation_id = ?`)
187
+ .all(observationId);
188
+ return rows.map(r => ({ source_kind: r.source_kind, source_ref: r.source_ref, source_hash: r.source_hash ?? null }))
189
+ .sort(compareSourceTuples);
190
+ }
191
+ function sourceSummary(sources) {
192
+ if (sources.length === 0)
193
+ return 'unrecorded';
194
+ return sources.every(s => s.source_kind === 'import') ? 'import-only' : 'recorded';
195
+ }
196
+ // Validate one item against the CURRENT rows and build its body + metadata. Any failure throws
197
+ // EvidenceInputError; nothing is written here. Order matters for the commit re-check
198
+ // (REVIEW-ALPHA3 §3.1): an observation's ACTIVE status and a document chunk's type/parent are checked
199
+ // before any hash or fingerprint, because a retract keeps id, revision and content and changes only
200
+ // the status (lifecycle.ts:146-147) — the fingerprint alone cannot see it.
201
+ export function resolveEvidenceItem(db, item, itemIndex, profileId) {
202
+ const fail = (code, detail = '') => { throw new EvidenceInputError(code, itemIndex, detail); };
203
+ if (!item || typeof item !== 'object')
204
+ fail('invalid-item');
205
+ if (typeof item.relationship_id !== 'string' || item.relationship_id === '')
206
+ fail('invalid-item', 'relationship_id');
207
+ if (!Array.isArray(item.evidence) || item.evidence.length === 0)
208
+ fail('empty-evidence');
209
+ if (item.evidence.length > EVIDENCE_CAPS.maxEntries)
210
+ fail('too-many-entries', `${item.evidence.length} > ${EVIDENCE_CAPS.maxEntries}`);
211
+ const rel = db.prepare(`
212
+ SELECT r.id, r.source_entity, r.target_entity, r.relationType, r.confidence,
213
+ e1.name AS source_name, e2.name AS target_name
214
+ FROM relationships r
215
+ JOIN entities e1 ON e1.id = r.source_entity
216
+ JOIN entities e2 ON e2.id = r.target_entity
217
+ WHERE r.id = ?`).get(item.relationship_id);
218
+ if (!rel)
219
+ return fail('unknown-relationship', item.relationship_id);
220
+ // kgComparisonValue (D2⑤) never accepts an empty endpoint id, so a chunk published with one could never
221
+ // be embedded by either CAS path. The schema has no non-empty CHECK and importGraph copies ids as given,
222
+ // so the shared preparation/commit resolver checks it before any write. Names and the raw type may
223
+ // legitimately be empty; only the ids are identifiers (REVIEW-T5-2_ I2).
224
+ if (typeof rel.source_entity !== 'string' || rel.source_entity === '' || typeof rel.target_entity !== 'string' || rel.target_entity === '') {
225
+ return fail('invalid-relationship-endpoint', item.relationship_id);
226
+ }
227
+ const refs = [];
228
+ const quotes = [];
229
+ // Array.from visits every index; forEach alone skips holes, so new Array(1) passed the 1–8 count with zero
230
+ // resolved entries and [ref, <hole>] dropped one silently (D1: at least one entry, none dropped;
231
+ // REVIEW-T5-2_ I1). This is a JS manager API, so no JSON layer turns holes into null first. A hole
232
+ // reads as undefined and fails as an invalid entry.
233
+ Array.from(item.evidence).forEach((e, j) => {
234
+ if (!e || typeof e !== 'object')
235
+ return fail('unknown-evidence-kind', `entry ${j}${j in item.evidence ? '' : ': hole'}`);
236
+ if (e.kind === 'document') {
237
+ if (typeof e.chunk_id !== 'string')
238
+ return fail('unknown-chunk', `entry ${j}`);
239
+ const c = db.prepare(`
240
+ SELECT m.chunk_id, m.chunk_type, m.document_id, m.text, d.id AS doc_id
241
+ FROM chunk_metadata m LEFT JOIN documents d ON d.id = m.document_id
242
+ WHERE m.chunk_id = ?`).get(e.chunk_id);
243
+ if (!c)
244
+ return fail('unknown-chunk', `entry ${j}: ${e.chunk_id}`);
245
+ if (c.chunk_type !== 'document' || c.document_id === null || c.doc_id === null || c.text === null) {
246
+ return fail('not-a-document-chunk', `entry ${j}: ${e.chunk_id}`);
247
+ }
248
+ if (typeof e.start !== 'number' || typeof e.end !== 'number' || !Number.isInteger(e.start) || !Number.isInteger(e.end)) {
249
+ return fail('range-not-integer', `entry ${j}`);
250
+ }
251
+ const n = cpLength(c.text);
252
+ if (!(0 <= e.start && e.start < e.end && e.end <= n))
253
+ return fail('range-out-of-bounds', `entry ${j}: [${e.start},${e.end}) of ${n} codepoints`);
254
+ if (e.end - e.start > EVIDENCE_CAPS.maxQuoteCodepoints)
255
+ return fail('quote-over-cap', `entry ${j}: ${e.end - e.start} codepoints`);
256
+ const quote = cpSlice(c.text, e.start, e.end);
257
+ if (sha256Utf8(quote) !== e.quote_sha256)
258
+ return fail('quote-hash-mismatch', `entry ${j}`);
259
+ refs.push({ kind: 'document', document_id: c.document_id, chunk_id: c.chunk_id, start: e.start, end: e.end,
260
+ chunk_text_sha256: sha256Utf8(c.text), quote_sha256: e.quote_sha256 });
261
+ quotes.push(quote);
262
+ return;
263
+ }
264
+ if (e.kind === 'observation') {
265
+ if (typeof e.observation_id !== 'string')
266
+ return fail('unknown-observation', `entry ${j}`);
267
+ const o = db.prepare(`SELECT observation_id, entity_id, revision_no, content, status FROM entity_observations WHERE observation_id = ?`)
268
+ .get(e.observation_id);
269
+ if (!o)
270
+ return fail('unknown-observation', `entry ${j}: ${e.observation_id}`);
271
+ if (o.status !== 'active')
272
+ return fail('observation-not-active', `entry ${j}: status ${o.status}`);
273
+ if (o.revision_no !== e.revision_no)
274
+ return fail('observation-revision-mismatch', `entry ${j}`);
275
+ if (sha256Utf8(o.content) !== e.content_sha256)
276
+ return fail('observation-content-mismatch', `entry ${j}`);
277
+ const len = cpLength(o.content); // the quoted unit is the WHOLE current content; never cut
278
+ if (len > EVIDENCE_CAPS.maxQuoteCodepoints)
279
+ return fail('quote-over-cap', `entry ${j}: ${len} codepoints`);
280
+ const sources = readSourceTuples(db, o.observation_id);
281
+ refs.push({ kind: 'observation', observation_id: o.observation_id, entity_id: o.entity_id, revision_no: o.revision_no,
282
+ content_sha256: e.content_sha256, status_at_generation: 'active', sources, source_summary: sourceSummary(sources) });
283
+ quotes.push(o.content);
284
+ return;
285
+ }
286
+ return fail('unknown-evidence-kind', `entry ${j}`);
287
+ });
288
+ const sup = item.support === undefined ? {} : item.support;
289
+ if (sup === null || typeof sup !== 'object')
290
+ fail('invalid-support');
291
+ const why = sup.why_status ?? 'unjudged';
292
+ if (!WHY_STATUSES.includes(why))
293
+ fail('invalid-support', `why_status ${String(why)}`);
294
+ if (sup.valid_time !== undefined && typeof sup.valid_time !== 'string')
295
+ fail('invalid-support', 'valid_time');
296
+ if (sup.scope_note !== undefined && typeof sup.scope_note !== 'string')
297
+ fail('invalid-support', 'scope_note');
298
+ const text = buildEvidenceBody(rel.source_name, rel.relationType, rel.target_name, quotes);
299
+ const bodyCp = cpLength(text);
300
+ if (bodyCp > EVIDENCE_CAPS.maxBodyCodepoints)
301
+ fail('body-over-cap', `${bodyCp} codepoints`);
302
+ const fp = computeInputFingerprint({
303
+ relationship_id: rel.id, source_entity_id: rel.source_entity, target_entity_id: rel.target_entity,
304
+ relation_type: rel.relationType, source_name: rel.source_name, target_name: rel.target_name,
305
+ kg_format: EVIDENCE_FORMAT, refs, profile_id: profileId,
306
+ });
307
+ const metadata = {
308
+ kg_format: EVIDENCE_FORMAT,
309
+ relationship_id: rel.id, source_entity_id: rel.source_entity, target_entity_id: rel.target_entity,
310
+ source_entity: rel.source_name, target_entity: rel.target_name, relation_type: rel.relationType, direction: 'source->target',
311
+ confidence: rel.confidence,
312
+ evidence_refs: refs,
313
+ support: { why_status: why, valid_time: sup.valid_time ?? null, scope_note: sup.scope_note ?? null },
314
+ generated_ref_status: 'refs-resolved',
315
+ input_fingerprint: fp, fingerprint_profile_id: profileId,
316
+ body_codepoints: bodyCp,
317
+ embedded_truncation: 'unchecked',
318
+ evidence_semantics: EVIDENCE_SEMANTICS,
319
+ };
320
+ return { chunk_id: evidenceChunkId(rel.id), relationship_id: rel.id, text, body_codepoints: bodyCp, metadata };
321
+ }
322
+ // legacy triplet -> claim_only, no refs. evidence-v1 -> each reference resolved against the CURRENT
323
+ // rows; all available -> refs_available, anything else (missing, mismatch, malformed or empty list) ->
324
+ // refs_unavailable. An inactive or historical revision is 'mismatch', never 'available'.
325
+ export function resolveEvidenceStatus(db, meta) {
326
+ if (!meta || meta.kg_format === undefined)
327
+ return { status: 'claim_only', refs: [] };
328
+ const list = meta.kg_format === EVIDENCE_FORMAT && Array.isArray(meta.evidence_refs) ? meta.evidence_refs : null;
329
+ if (!list || list.length === 0)
330
+ return { status: 'refs_unavailable', refs: [] };
331
+ const refs = list.map((r, i) => resolveOneRef(db, r, i));
332
+ return { status: refs.every(r => r.status === 'available') ? 'refs_available' : 'refs_unavailable', refs };
333
+ }
334
+ // R4 (REVIEW-PLAN): structure is checked at runtime before any field is used — `as SourceTuple[]` is not
335
+ // a check, and `sources: [null]` used to reach `s.source_kind`. A structurally malformed reference is
336
+ // 'mismatch' (so the chunk is refs_unavailable), never a TypeError. Real DB errors are NOT caught here.
337
+ const isStr = (v) => typeof v === 'string';
338
+ const isInt = (v) => typeof v === 'number' && Number.isInteger(v);
339
+ export function isSourceTuple(v) {
340
+ if (!v || typeof v !== 'object')
341
+ return false;
342
+ const s = v;
343
+ return isStr(s.source_kind) && isStr(s.source_ref) && (s.source_hash === null || isStr(s.source_hash));
344
+ }
345
+ export function isWellFormedRef(v) {
346
+ if (!v || typeof v !== 'object')
347
+ return false;
348
+ const r = v;
349
+ if (r.kind === 'document') {
350
+ return isStr(r.document_id) && isStr(r.chunk_id) && isInt(r.start) && isInt(r.end) && isStr(r.chunk_text_sha256) && isStr(r.quote_sha256);
351
+ }
352
+ if (r.kind === 'observation') {
353
+ return isStr(r.observation_id) && isStr(r.entity_id) && isInt(r.revision_no) && isStr(r.content_sha256)
354
+ && Array.isArray(r.sources) && r.sources.every(isSourceTuple);
355
+ }
356
+ return false;
357
+ }
358
+ function resolveOneRef(db, raw, index) {
359
+ const r = raw && typeof raw === 'object' ? raw : {};
360
+ const out = (status, text = null) => ({ index, kind: typeof r.kind === 'string' ? r.kind : 'unknown', status, ref: raw, text });
361
+ if (!isWellFormedRef(raw))
362
+ return out('mismatch');
363
+ if (r.kind === 'document') {
364
+ const c = db.prepare(`
365
+ SELECT m.chunk_type, m.document_id, m.text, d.id AS doc_id
366
+ FROM chunk_metadata m LEFT JOIN documents d ON d.id = m.document_id WHERE m.chunk_id = ?`)
367
+ .get(r.chunk_id);
368
+ if (!c || c.doc_id === null)
369
+ return out('missing');
370
+ if (c.chunk_type !== 'document' || c.document_id !== r.document_id || c.text === null)
371
+ return out('mismatch');
372
+ if (sha256Utf8(c.text) !== r.chunk_text_sha256)
373
+ return out('mismatch');
374
+ if (typeof r.start !== 'number' || typeof r.end !== 'number' || !Number.isInteger(r.start) || !Number.isInteger(r.end))
375
+ return out('mismatch');
376
+ if (!(0 <= r.start && r.start < r.end && r.end <= cpLength(c.text)))
377
+ return out('mismatch');
378
+ const quote = cpSlice(c.text, r.start, r.end);
379
+ if (sha256Utf8(quote) !== r.quote_sha256)
380
+ return out('mismatch');
381
+ return out('available', quote);
382
+ }
383
+ if (r.kind === 'observation') {
384
+ const o = db.prepare(`SELECT entity_id, revision_no, content, status FROM entity_observations WHERE observation_id = ?`)
385
+ .get(r.observation_id);
386
+ if (!o)
387
+ return out('missing');
388
+ if (o.status !== 'active' || o.entity_id !== r.entity_id || o.revision_no !== r.revision_no || sha256Utf8(o.content) !== r.content_sha256) {
389
+ return out('mismatch');
390
+ }
391
+ const now = readSourceTuples(db, r.observation_id).map(s => [s.source_kind, s.source_ref, s.source_hash]);
392
+ const stored = [...r.sources].sort(compareSourceTuples).map(s => [s.source_kind, s.source_ref, s.source_hash]);
393
+ if (JSON.stringify(now) !== JSON.stringify(stored))
394
+ return out('mismatch');
395
+ return out('available', o.content);
396
+ }
397
+ return out('mismatch');
398
+ }
399
+ function evidenceRowViews(db) {
400
+ const rows = db.prepare(`SELECT rowid, metadata FROM chunk_metadata WHERE chunk_type = 'relationship' AND metadata LIKE '%evidence-v1%'`).all();
401
+ const out = [];
402
+ for (const r of rows) {
403
+ let m;
404
+ try {
405
+ m = JSON.parse(r.metadata ?? 'null');
406
+ }
407
+ catch {
408
+ out.push({ rowid: r.rowid, refs: null });
409
+ continue;
410
+ }
411
+ if (!m || typeof m !== 'object') {
412
+ out.push({ rowid: r.rowid, refs: null });
413
+ continue;
414
+ }
415
+ const meta = m;
416
+ if (meta.kg_format !== EVIDENCE_FORMAT)
417
+ continue; // a legacy row that merely mentions the string
418
+ out.push({ rowid: r.rowid, refs: Array.isArray(meta.evidence_refs) ? meta.evidence_refs : null });
419
+ }
420
+ return out;
421
+ }
422
+ function collectCiting(db, hit) {
423
+ return evidenceRowViews(db)
424
+ .filter(v => v.refs === null || v.refs.length === 0 || v.refs.some(r => !isWellFormedRef(r) || hit(r)))
425
+ .map(v => v.rowid);
426
+ }
427
+ export function collectEvidenceRowidsCitingObservations(db, observationIds) {
428
+ if (observationIds.length === 0)
429
+ return [];
430
+ const ids = new Set(observationIds);
431
+ return collectCiting(db, r => r.kind === 'observation' && typeof r.observation_id === 'string' && ids.has(r.observation_id));
432
+ }
433
+ export function collectEvidenceRowidsCitingDocument(db, documentId) {
434
+ return collectCiting(db, r => r.kind === 'document' && r.document_id === documentId);
435
+ }
436
+ export function collectEvidenceRowidsCitingOwnerEntity(db, entityId) {
437
+ return collectCiting(db, r => r.kind === 'observation' && r.entity_id === entityId);
438
+ }
@@ -2,3 +2,5 @@ import type Database from 'better-sqlite3';
2
2
  export declare function rebuildProjection(db: Database.Database, entityId: string): void;
3
3
  export declare function deleteStaleKgChunks(db: Database.Database, entityId: string): number;
4
4
  export declare function deleteKgRelationshipChunks(db: Database.Database, relationshipIds: string[]): number;
5
+ export declare function deleteKgChunkRows(db: Database.Database, rowids: number[]): number;
6
+ export declare function refreshKgRelationshipConfidence(db: Database.Database, relationshipId: string): number;
@@ -46,3 +46,52 @@ export function deleteKgRelationshipChunks(db, relationshipIds) {
46
46
  }
47
47
  return n;
48
48
  }
49
+ // kg-relation-evidence D2: the one place that removes KG chunk rows by rowid. A rowid that is not a
50
+ // KG row (or no longer exists) is skipped BEFORE anything is deleted, so a rowid reused by a document
51
+ // chunk never loses its vector. Order: vector (vec0 needs a literal integer rowid), entity links,
52
+ // metadata (the chunks_fts delete trigger fires on the metadata row). Callers already inside a
53
+ // transaction pass rowids they collected before mutating the source.
54
+ export function deleteKgChunkRows(db, rowids) {
55
+ let n = 0;
56
+ for (const raw of new Set(rowids)) {
57
+ const rowid = Number(raw);
58
+ if (!Number.isInteger(rowid) || rowid < 0)
59
+ throw new Error(`Invalid rowid: ${raw}`);
60
+ const row = db.prepare(`SELECT chunk_type FROM chunk_metadata WHERE rowid = ?`).get(rowid);
61
+ if (!row || (row.chunk_type !== 'entity' && row.chunk_type !== 'relationship'))
62
+ continue;
63
+ db.exec(`DELETE FROM chunks WHERE rowid = ${rowid}`);
64
+ db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(rowid);
65
+ n += db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(rowid).changes;
66
+ }
67
+ return n;
68
+ }
69
+ // kg-relation-evidence D2②: relationship KG chunks (triplet and evidence-v1) carry a DISPLAY copy of
70
+ // `confidence` (index.ts:2319-2324). It is not a search input, so refresh it in place: the body, the
71
+ // input_hash and the vector stay, and kg_format / evidence_refs / input_fingerprint are never touched.
72
+ // The narrowed FTS trigger (migration 12) fires only on text/chunk_id, so this does not reindex.
73
+ export function refreshKgRelationshipConfidence(db, relationshipId) {
74
+ const rel = db.prepare(`SELECT confidence FROM relationships WHERE id = ?`).get(relationshipId);
75
+ if (!rel)
76
+ return 0;
77
+ const rows = db.prepare(`SELECT rowid, metadata FROM chunk_metadata WHERE chunk_type = 'relationship' AND relationship_id = ?`).all(relationshipId);
78
+ let n = 0;
79
+ for (const r of rows) {
80
+ let meta;
81
+ try {
82
+ meta = JSON.parse(r.metadata ?? '{}');
83
+ }
84
+ catch {
85
+ continue;
86
+ } // malformed: the reader reports it
87
+ if (!meta || typeof meta !== 'object')
88
+ continue;
89
+ const m = meta;
90
+ if (m.confidence === rel.confidence)
91
+ continue;
92
+ m.confidence = rel.confidence;
93
+ db.prepare(`UPDATE chunk_metadata SET metadata = ? WHERE rowid = ?`).run(JSON.stringify(m), r.rowid);
94
+ n++;
95
+ }
96
+ return n;
97
+ }
@@ -287,7 +287,7 @@ const hybridSearchCapability = {
287
287
  },
288
288
  limit: {
289
289
  type: 'number',
290
- description: 'Maximum number of results to return',
290
+ description: 'Maximum number of results to return AND the retrieval depth (each query variant requests up to limit x 3 vector and up to limit x 3 FTS candidates, then merged), so hybridSearch(q, 30) cut to 10 can differ from hybridSearch(q, 10)',
291
291
  default: 5
292
292
  },
293
293
  useGraph: {
@@ -312,6 +312,8 @@ chunk out of the top-5 in ~40% of known-item queries. For relationship explorati
312
312
  - (!important!) **Graph re-ranking is opt-in (default false since v5.3.0)** — it re-orders candidates by matched/connected entity links, which is measured to hurt known-item retrieval; enable it only for backward compatibility or controlled evaluation
313
313
  - (!important!) Graph re-ranking does not generate candidates — for "what is connected to X" use openNodes -> getNeighbors
314
314
  - (!important!) relevance_score is a RANK score on the default call: 1/(60 + vector rank) + 1/(60 + FTS rank), at most 2/61; it is not a similarity or probability and is only comparable within one call (vector_similarity and fts_boost are reported alongside)
315
+ - (!important!) limit is both the maximum result count and the retrieval depth (each query variant requests up to limit x 3 vector candidates and up to limit x 3 FTS candidates, which are then merged), so results, even the top-1, can differ between limits; for comparisons fix the call limit and cut k from the returned list
316
+ - (!important!) full_context_available means getDetailedContext can resolve the stored chunk body; it is not a verified-evidence signal (for knowledge-graph relationship chunks read evidence_status there)
315
317
  - (!important!) **Best results when knowledge graph is well-populated** with entities and relationships
316
318
  </importantNotes>
317
319
 
@@ -345,7 +347,7 @@ chunk out of the top-5 in ~40% of known-item queries. For relationship explorati
345
347
 
346
348
  <parameters>
347
349
  - query: Search query — a natural-language question or an exact term/identifier (string, required)
348
- - limit: Maximum results to return, default 5 (number, optional)
350
+ - limit: Maximum results to return AND retrieval depth (up to limit x 3 vector and limit x 3 FTS candidates per query variant), default 5 (number, optional). For comparisons fix the call limit and cut k from the returned list
349
351
  - useGraph: Enable knowledge graph re-ranking, default false / opt-in (boolean, optional)
350
352
  </parameters>
351
353
 
@@ -356,7 +358,7 @@ chunk out of the top-5 in ~40% of known-item queries. For relationship explorati
356
358
  </examples>`;
357
359
  const hybridSearchSchema = {
358
360
  query: z.string().describe('The search query to find relevant information'),
359
- limit: z.number().optional().default(5).describe('Maximum number of results to return'),
361
+ limit: z.number().optional().default(5).describe('Maximum number of results to return; also the retrieval depth (up to limit x 3 vector and limit x 3 FTS candidates per query variant)'),
360
362
  useGraph: z.boolean().optional().default(false).describe('Opt-in legacy/experimental graph re-ranker (default false since v5.3.0; measured to hurt known-item retrieval; for exploration use openNodes -> getNeighbors)'),
361
363
  };
362
364
  export const hybridSearchTool = {
@@ -461,6 +463,8 @@ Perfect drill-down tool for exploring the full content behind search result high
461
463
  - (!important!) **Returns complete chunk text** - not just summaries or highlights
462
464
  - (!important!) **Includes surrounding chunks** - for better context understanding
463
465
  - (!important!) **Shows entity associations** - to understand knowledge graph connections
466
+ - (!important!) **Knowledge-graph chunks** (chunk_type entity/relationship) return document_id: null and document_title: null with source_kind and source_id; an entity chunk returns the entity and its active observations, a relationship chunk returns the relationship, evidence_status (claim_only | refs_available | refs_unavailable) and per-reference evidence_refs (available | missing | mismatch)
467
+ - (!important!) evidence_status is reference resolution, not a truth verdict: refs_available means every cited quote or observation still matches verbatim (current chunk range and hashes, or the current active observation revision); whoever generated the chunk selected the evidence
464
468
  </importantNotes>
465
469
 
466
470
  <whenToUseThisTool>
package/docs/UPDATING.md CHANGED
@@ -108,6 +108,16 @@ path and holder pid (e.g. `.download-<key>.lock`). Verify the holder process
108
108
  is genuinely gone or hung (`ps -p <pid>`), then remove the lock file manually;
109
109
  the next start becomes a clean download owner.
110
110
 
111
+ ## v6.4.1 (schema v14, unchanged): evidence-backed relationship chunks (opt-in), KG lifecycle fixes, faster oversize split
112
+
113
+ **What changes for an existing database.** Nothing is migrated and search defaults are unchanged (the 113-query
114
+ parity run on a frozen real project database returned the same ids, scores and field types as 6.4.0). Documents
115
+ chunked from now on are stamped `fallback=cp-window-N`; documents stamped `cp-exact-N` by 6.4.0 stay current format
116
+ and an unchanged-content sync does not re-chunk them. KG chunks for a deleted relationship or a changed cited source
117
+ now leave search in the same transaction. **`importGraph` now refuses non-string document ids and
118
+ observation-source ids** (the whole import, nothing written) — a dump produced by `exportGraph` uses string ids.
119
+ Details: README changelog.
120
+
111
121
  ## v6.4.0 (schema v14, unchanged): `hybridSearch` default = rank fusion, summaries opt-in
112
122
 
113
123
  (Published first as `6.4.0-rc.1` on the `next` dist-tag; promoted to `latest` after the published artifact passed a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "6.4.0",
3
+ "version": "6.4.1",
4
4
  "engines": {
5
5
  "node": ">=24"
6
6
  },
@@ -45,7 +45,7 @@
45
45
  "prepare": "npm run build",
46
46
  "watch": "tsc --watch",
47
47
  "verify:invariants": "node test/chunk-invariants.test.mjs",
48
- "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/search-graph-default.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/alias-link-gate.test.mjs && node test/entity-name-boundary.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/search-fusion-rrf.test.mjs && node test/query-input.test.mjs && node test/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.test.mjs && node test/delete-entities-kg-hygiene.test.mjs && node test/backup-publish-portable.test.mjs && node test/graph-context-explain.test.mjs && node test/eval-graph-role-libs.test.mjs && node test/eval-graph-role-t5b.test.mjs && node --test test/eval-graph-role-t8-fix.test.mjs && node --test test/eval-graph-role-t7-upstream.test.mjs && node --test test/eval-graph-role-t11-decision.test.mjs && node --test test/eval-graph-role-prereq-fix.test.mjs && node test/db-path.test.mjs && node test/mmap-option.test.mjs && node test/wal-exit.test.mjs && node test/wal-sidecar-guard.test.mjs",
48
+ "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/search-graph-default.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node --no-concurrent-sparkplug test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/alias-link-gate.test.mjs && node test/entity-name-boundary.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/search-fusion-rrf.test.mjs && node test/query-input.test.mjs && node test/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.test.mjs && node test/delete-entities-kg-hygiene.test.mjs && node test/backup-publish-portable.test.mjs && node test/graph-context-explain.test.mjs && node test/eval-graph-role-libs.test.mjs && node test/eval-graph-role-t5b.test.mjs && node --test test/eval-graph-role-t8-fix.test.mjs && node --test test/eval-graph-role-t7-upstream.test.mjs && node --test test/eval-graph-role-t11-decision.test.mjs && node --test test/eval-graph-role-prereq-fix.test.mjs && node test/db-path.test.mjs && node test/mmap-option.test.mjs && node test/wal-exit.test.mjs && node test/wal-sidecar-guard.test.mjs && node test/kg-evidence-core.test.mjs && node test/kg-delete-relation.test.mjs && node test/kg-confidence-copy.test.mjs && node test/kg-embed-cas.test.mjs && node test/kg-relation-evidence.test.mjs && node test/kg-detail-context.test.mjs && node test/kg-evidence-invalidation.test.mjs && node test/kg-evidence-cas.test.mjs && node test/kg-defaults.test.mjs && node test/chunker-oversize-cost.test.mjs && node test/chunker-structure-parity.test.mjs && node test/search-limit-depth.test.mjs && node test/kg-import-id-contract.test.mjs && node test/kg-evidence-guards.test.mjs",
49
49
  "test": "npm run build && npm run verify:invariants && npm run verify:engine",
50
50
  "prepublishOnly": "npm run build && npm run verify:invariants && npm run verify:engine"
51
51
  },