rag-memory-epf-mcp 5.3.0 → 5.3.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.
package/dist/index.d.ts CHANGED
@@ -372,6 +372,36 @@ export declare class RAGKnowledgeGraphManager {
372
372
  to: number;
373
373
  }>;
374
374
  }>;
375
+ /**
376
+ * Diagnostic seam for the graph re-ranker (evaluation change graph-role-evaluation, R2).
377
+ * Returns the seed entities (query-vector matched, similarity > 0.4, top-10 per variant) and the
378
+ * 1-hop connected entities exactly as hybridSearch(useGraph:true) computes them, plus edge detail
379
+ * that hybridSearch itself does not use (edge id, type, direction, confidence). It never generates
380
+ * candidates and never changes ranking; hybridSearch consumes only the name sets.
381
+ * `opts.chunkVectorDegraded` lets a caller hand over a decision it has already made; omit it and
382
+ * the seam derives eligibility itself.
383
+ */
384
+ explainGraphContext(query: string, queryVariants?: string[], opts?: {
385
+ chunkVectorDegraded?: boolean;
386
+ }): Promise<{
387
+ status: 'vector' | 'entity-text-fallback' | 'chunk-vector-disabled' | 'error';
388
+ query_variants: string[];
389
+ seeds: Array<{
390
+ entity_id: string;
391
+ name: string;
392
+ similarity: number;
393
+ }>;
394
+ connected: Array<{
395
+ entity_id: string;
396
+ name: string;
397
+ via_seed_id: string;
398
+ via_seed_name: string;
399
+ edge_id: string;
400
+ relation_type: string;
401
+ direction: 'out' | 'in';
402
+ confidence: number | null;
403
+ }>;
404
+ }>;
375
405
  hybridSearch(query: string, limit?: number, useGraph?: boolean): Promise<{
376
406
  results: EnhancedSearchResult[];
377
407
  search_mode: 'hybrid' | 'hybrid-partial' | 'fts-only';
package/dist/index.js CHANGED
@@ -2923,6 +2923,97 @@ export class RAGKnowledgeGraphManager {
2923
2923
  this.coordinator?.kick();
2924
2924
  return { imported, skipped, observation_order_remap: remapReport };
2925
2925
  }
2926
+ /**
2927
+ * Diagnostic seam for the graph re-ranker (evaluation change graph-role-evaluation, R2).
2928
+ * Returns the seed entities (query-vector matched, similarity > 0.4, top-10 per variant) and the
2929
+ * 1-hop connected entities exactly as hybridSearch(useGraph:true) computes them, plus edge detail
2930
+ * that hybridSearch itself does not use (edge id, type, direction, confidence). It never generates
2931
+ * candidates and never changes ranking; hybridSearch consumes only the name sets.
2932
+ * `opts.chunkVectorDegraded` lets a caller hand over a decision it has already made; omit it and
2933
+ * the seam derives eligibility itself.
2934
+ */
2935
+ async explainGraphContext(query, queryVariants, opts) {
2936
+ if (!this.db)
2937
+ throw new Error('Database not initialized');
2938
+ const variants = queryVariants ?? this.buildCrossLingualVariants(query);
2939
+ const empty = { query_variants: variants, seeds: [], connected: [] };
2940
+ // The caller's latched decision wins (review finding I2). hybridSearch decides chunk-vector
2941
+ // degradation once, before the chunk-embedding awaits, and passes that value down; re-deriving
2942
+ // it here would let an eligibility flip during those awaits give the seam a different answer
2943
+ // than the ranking path already acted on — the pre-extraction code read it once, so this keeps
2944
+ // behaviour identical. A standalone caller passes nothing and gets the live derivation.
2945
+ const chunkVectorDegraded = opts?.chunkVectorDegraded ?? !(this.coordinator?.eligible ?? false);
2946
+ if (chunkVectorDegraded)
2947
+ return { status: 'chunk-vector-disabled', ...empty };
2948
+ try {
2949
+ const searchEntities = (embedding) => this.db.prepare(`
2950
+ SELECT em.entity_id, e.name, ee.distance
2951
+ FROM entity_embeddings ee
2952
+ JOIN entity_embedding_metadata em ON ee.rowid = em.rowid
2953
+ JOIN entities e ON e.id = em.entity_id
2954
+ WHERE ee.embedding MATCH ? AND k = 10
2955
+ ORDER BY ee.distance
2956
+ `).all(Buffer.from(embedding.buffer));
2957
+ const entityMap = new Map();
2958
+ for (const variant of variants) {
2959
+ const embedding = await this.generateEmbedding(variant, 1024, true);
2960
+ for (const e of searchEntities(embedding)) {
2961
+ const existing = entityMap.get(e.entity_id);
2962
+ if (!existing || e.distance < existing.distance)
2963
+ entityMap.set(e.entity_id, e);
2964
+ }
2965
+ }
2966
+ const similar = Array.from(entityMap.values()).sort((a, b) => a.distance - b.distance || (a.entity_id < b.entity_id ? -1 : 1));
2967
+ const seeds = [];
2968
+ const connected = [];
2969
+ const edgeStmt = this.db.prepare(`
2970
+ SELECT r.id AS edge_id, r.relationType AS relation_type, r.confidence,
2971
+ CASE WHEN r.source_entity = ? THEN e2.id ELSE e1.id END AS entity_id,
2972
+ CASE WHEN r.source_entity = ? THEN e2.name ELSE e1.name END AS name,
2973
+ CASE WHEN r.source_entity = ? THEN 'out' ELSE 'in' END AS direction
2974
+ FROM relationships r
2975
+ JOIN entities e1 ON e1.id = r.source_entity
2976
+ JOIN entities e2 ON e2.id = r.target_entity
2977
+ WHERE r.source_entity = ? OR r.target_entity = ?
2978
+ ORDER BY r.id`);
2979
+ for (const entity of similar) {
2980
+ const similarity = Math.max(0, 1 - entity.distance / 2);
2981
+ if (similarity > 0.4) {
2982
+ seeds.push({ entity_id: entity.entity_id, name: entity.name, similarity });
2983
+ for (const row of edgeStmt.all(entity.entity_id, entity.entity_id, entity.entity_id, entity.entity_id, entity.entity_id)) {
2984
+ connected.push({ entity_id: row.entity_id, name: row.name, via_seed_id: entity.entity_id, via_seed_name: entity.name,
2985
+ edge_id: row.edge_id, relation_type: row.relation_type, direction: row.direction,
2986
+ confidence: row.confidence === null || row.confidence === undefined ? null : Number(row.confidence) });
2987
+ }
2988
+ }
2989
+ }
2990
+ return { status: 'vector', query_variants: variants, seeds, connected };
2991
+ }
2992
+ catch (error) {
2993
+ console.error('⚠️ Entity vector search for graph enhancement failed:', error);
2994
+ // Fallback: text-based matching (original behavior) — same SQL as before extraction.
2995
+ const connected = [];
2996
+ const queryEntities = this.extractTermsFromText(query);
2997
+ for (const entity of queryEntities) {
2998
+ const rows = this.db.prepare(`
2999
+ SELECT DISTINCT
3000
+ CASE WHEN r.source_entity = e1.id THEN e2.name ELSE e1.name END as connected_name,
3001
+ CASE WHEN r.source_entity = e1.id THEN e2.id ELSE e1.id END as connected_id,
3002
+ r.id AS edge_id, r.relationType AS relation_type, r.confidence,
3003
+ CASE WHEN r.source_entity = e1.id THEN 'out' ELSE 'in' END AS direction
3004
+ FROM entities e1
3005
+ JOIN relationships r ON (r.source_entity = e1.id OR r.target_entity = e1.id)
3006
+ JOIN entities e2 ON (e2.id = r.source_entity OR e2.id = r.target_entity)
3007
+ WHERE e1.name = ? AND e2.name != ?
3008
+ ORDER BY r.id`).all(entity, entity);
3009
+ for (const row of rows)
3010
+ connected.push({ entity_id: row.connected_id, name: row.connected_name, via_seed_id: '', via_seed_name: entity,
3011
+ edge_id: row.edge_id, relation_type: row.relation_type, direction: row.direction,
3012
+ confidence: row.confidence === null || row.confidence === undefined ? null : Number(row.confidence) });
3013
+ }
3014
+ return { status: 'entity-text-fallback', query_variants: variants, seeds: [], connected };
3015
+ }
3016
+ }
2926
3017
  // v5.3.0: the graph re-ranker is OPT-IN (harm-reduced default, not a validated improvement).
2927
3018
  // Measured 2026-08-17 on three real corpora (self-retrieval, usable samples 120/117/120,
2928
3019
  // summaries off): with the additive graph boost on, the known-item chunk got WORSE in
@@ -3102,77 +3193,16 @@ export class RAGKnowledgeGraphManager {
3102
3193
  ...(ftsUnsearchable ? { warning: 'query has no searchable terms for FTS' } : {}),
3103
3194
  };
3104
3195
  }
3105
- // Get entity information for graph enhancement via vector similarity
3196
+ // Get entity information for graph enhancement via the diagnostic seam (evaluation change
3197
+ // graph-role-evaluation R2). Same SQL, same threshold, same fallback; hybridSearch consumes only names.
3106
3198
  let connectedEntities = new Set();
3107
3199
  let queryMatchedEntities = new Set();
3108
3200
  if (useGraph && !vectorDegraded) {
3109
- // Vector search: find entities semantically similar to the query (dual search)
3110
- try {
3111
- const searchEntities = (embedding) => {
3112
- return this.db.prepare(`
3113
- SELECT
3114
- em.entity_id,
3115
- e.name,
3116
- ee.distance
3117
- FROM entity_embeddings ee
3118
- JOIN entity_embedding_metadata em ON ee.rowid = em.rowid
3119
- JOIN entities e ON e.id = em.entity_id
3120
- WHERE ee.embedding MATCH ?
3121
- AND k = 10
3122
- ORDER BY ee.distance
3123
- `).all(Buffer.from(embedding.buffer));
3124
- };
3125
- // Merge all query variant entity results
3126
- const entityMap = new Map();
3127
- for (const variant of queryVariants) {
3128
- const embedding = await this.generateEmbedding(variant, 1024, true);
3129
- for (const e of searchEntities(embedding)) {
3130
- const existing = entityMap.get(e.entity_id);
3131
- if (!existing || e.distance < existing.distance) {
3132
- entityMap.set(e.entity_id, e);
3133
- }
3134
- }
3135
- }
3136
- const similarEntities = Array.from(entityMap.values()).sort((a, b) => a.distance - b.distance);
3137
- for (const entity of similarEntities) {
3138
- const similarity = Math.max(0, 1 - entity.distance / 2);
3139
- if (similarity > 0.4) {
3140
- queryMatchedEntities.add(entity.name);
3141
- // Get connected entities via relationships
3142
- const connected = this.db.prepare(`
3143
- SELECT DISTINCT
3144
- CASE
3145
- WHEN r.source_entity = ? THEN e2.name
3146
- ELSE e1.name
3147
- END as connected_name
3148
- FROM relationships r
3149
- JOIN entities e1 ON e1.id = r.source_entity
3150
- JOIN entities e2 ON e2.id = r.target_entity
3151
- WHERE r.source_entity = ? OR r.target_entity = ?
3152
- `).all(entity.entity_id, entity.entity_id, entity.entity_id);
3153
- connected.forEach((row) => connectedEntities.add(row.connected_name));
3154
- }
3155
- }
3156
- }
3157
- catch (error) {
3158
- console.error('⚠️ Entity vector search for graph enhancement failed:', error);
3159
- // Fallback: text-based matching (original behavior)
3160
- const queryEntities = this.extractTermsFromText(query);
3161
- for (const entity of queryEntities) {
3162
- const connected = this.db.prepare(`
3163
- SELECT DISTINCT
3164
- CASE
3165
- WHEN r.source_entity = e1.id THEN e2.name
3166
- ELSE e1.name
3167
- END as connected_name
3168
- FROM entities e1
3169
- JOIN relationships r ON (r.source_entity = e1.id OR r.target_entity = e1.id)
3170
- JOIN entities e2 ON (e2.id = r.source_entity OR e2.id = r.target_entity)
3171
- WHERE e1.name = ? AND e2.name != ?
3172
- `).all(entity, entity);
3173
- connected.forEach((row) => connectedEntities.add(row.connected_name));
3174
- }
3175
- }
3201
+ const ctx = await this.explainGraphContext(query, queryVariants, { chunkVectorDegraded: vectorDegraded });
3202
+ for (const s of ctx.seeds)
3203
+ queryMatchedEntities.add(s.name);
3204
+ for (const c of ctx.connected)
3205
+ connectedEntities.add(c.name);
3176
3206
  }
3177
3207
  // Process results with semantic summaries
3178
3208
  const enhancedResults = [];
@@ -1,4 +1,4 @@
1
- import { existsSync, openSync, readSync, closeSync, linkSync, unlinkSync } from 'node:fs';
1
+ import { existsSync, openSync, readSync, closeSync, copyFileSync, unlinkSync, constants } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import Database from 'better-sqlite3';
4
4
  // 이 파일이 지켜야 하는 것은 두 줄이다:
@@ -101,21 +101,51 @@ function verifyRecoveryPoint(path) {
101
101
  v.close();
102
102
  }
103
103
  }
104
- // 슬롯 게시. `link()` 목적지가 있으면 EEXIST 로 실패하므로 **원자적 no-clobber** 다
105
- // (rename 은 조용히 덮어쓴다). 그래서 경쟁하는 프로세스가 있어도 복구점을 잃지 않는다.
104
+ // 슬롯 게시. **`COPYFILE_EXCL` 복사**로 발행한다 — 목적지가 있으면 EEXIST 로 실패하므로
105
+ // no-clobber 의미는 `link()` 와 같고(rename 은 조용히 덮어쓴다), 경쟁 프로세스가 있어도
106
+ // 복구점을 잃지 않는다.
107
+ //
108
+ // **왜 `link()` 가 아닌가** (2026-08-22, 필드 보고): Google Drive File Stream(Windows `G:\`)은
109
+ // 하드링크를 지원하지 않는다. 실측 = 그 FS 에서 `ln` 이 "Invalid request code" 로 실패하고
110
+ // node 의 `linkSync` 는 같은 조건에서 `EISDIR`(errno -4068)로 표면화한다. `EEXIST` 가 아니므로
111
+ // 위 루프가 그대로 throw 했고, 이 함수는 `server.connect()` **전에** 도는 fail-closed 경로라
112
+ // 사용자에게는 원인 없는 "MCP 연결 실패"로만 보였다. 대기 마이그레이션이 없는 동안에는
113
+ // 이 코드가 아예 안 돌기 때문에 **배포 시점이 아니라 스키마 범프 시점에** 터진다.
114
+ // 🔴 이 파일 위쪽 주석이 *"이 프로젝트군은 non-git 환경(Google Drive 폴더)에 배포된다"* 고
115
+ // 적어 놓고 그 FS 가 없는 syscall 로 발행하고 있었다.
116
+ //
117
+ // **FS 별 분기를 두지 않는다.** 하드링크가 되는 곳에서만 link 를 쓰는 폴백 구조는 드문 경로가
118
+ // 영영 안 밟혀서, 정작 필요한 날 처음 실행된다. 모든 FS 가 같은 경로를 타게 한다.
119
+ // 비용은 복사 한 번 추가인데 백업 자체가 이미 `db.backup()` 전체 복사다.
120
+ //
121
+ // ⚠ **복사는 원자적이지 않다** — link 는 O(1) 이라 사실상 원자적이었지만 복사는 바이트를 다시
122
+ // 쓴다. 중간에 죽으면 잘린 파일이 슬롯을 차지하고, `pickRecoverySlot` 은 기존 파일을 **의도적으로**
123
+ // 검증하지 않으므로(아래 주석) 그 파일은 영영 복구점 행세를 한다. 그래서 **목적지 기준으로
124
+ // 다시 검증**하고, 실패하면 슬롯을 비운다. tmp 는 이미 검증했지만 그 뒤에 바이트가 다시 쓰였다.
106
125
  function publishNoClobber(tmp, base) {
107
126
  for (let attempt = 0; attempt < MAX_RECOVERY_POINTS; attempt++) {
108
127
  const slot = pickRecoverySlot(base);
109
128
  try {
110
- linkSync(tmp, slot);
111
- unlinkSync(tmp);
112
- return slot;
129
+ copyFileSync(tmp, slot, constants.COPYFILE_EXCL);
130
+ }
131
+ catch (e) {
132
+ if (e.code === 'EEXIST')
133
+ continue; // 슬롯 경쟁 — 다음 빈 슬롯으로
134
+ throw e;
135
+ }
136
+ try {
137
+ verifyRecoveryPoint(slot);
113
138
  }
114
139
  catch (e) {
115
- if (e.code !== 'EEXIST')
116
- throw e;
117
- // 그 슬롯을 누가 먼저 가져갔다 — 다음 빈 슬롯으로.
140
+ // 잘린/손상된 사본이 슬롯을 점유한 채 복구점 행세를 하지 못하게 한다.
141
+ try {
142
+ unlinkSync(slot);
143
+ }
144
+ catch { /* 정리 실패는 원인을 가리지 않는다 */ }
145
+ throw e;
118
146
  }
147
+ unlinkSync(tmp);
148
+ return slot;
119
149
  }
120
150
  throw slotsFullError(base);
121
151
  }
@@ -0,0 +1,10 @@
1
+ import type { Tiktoken } from 'tiktoken';
2
+ export interface ChunkSegment {
3
+ text: string;
4
+ start_pos: number | null;
5
+ end_pos: number | null;
6
+ start_token: number;
7
+ end_token: number;
8
+ }
9
+ export declare function trimIncompleteUtf8(bytes: Uint8Array, trimHead: boolean, trimTail: boolean): Uint8Array;
10
+ export declare function chunkText(text: string, encoding: Tiktoken, maxTokens?: number, overlap?: number): ChunkSegment[];
@@ -0,0 +1,104 @@
1
+ // Tokenize and chunk text using a BPE encoder while reporting both token-space
2
+ // and char-space (Unicode codepoint) offsets back into the original string.
3
+ //
4
+ // BPE tokenizers (cl100k_base) split multi-byte UTF-8 sequences across tokens.
5
+ // Slicing token arrays at arbitrary boundaries can leave incomplete UTF-8
6
+ // prefix/suffix bytes, which TextDecoder replaces with U+FFFD (�). We trim the
7
+ // incomplete sequences at chunk boundaries; overlap covers the removed bytes.
8
+ //
9
+ // Each chunk records both token-space offsets (start_token/end_token from the
10
+ // BPE encoder loop) and char-space offsets (start_pos/end_pos into the original
11
+ // text). Char offsets are Unicode codepoint counts — language-neutral, so SQL
12
+ // substr, Python str slicing, and JS [...str] iteration all line up. JS's
13
+ // native UTF-16 indexing differs for supplementary characters (emoji, rare CJK),
14
+ // so the function maintains parallel UTF-16 and codepoint cursors and reports
15
+ // codepoint offsets. On a coincidental indexOf miss the char offsets are NULL.
16
+ //
17
+ // Extracted to a standalone module so publish-time invariant tests can exercise
18
+ // the algorithm directly without booting the full RAG-Memory stack.
19
+ // trimIncompleteUtf8: strip incomplete UTF-8 sequences from the head/tail of a
20
+ // byte buffer produced by decoding an arbitrary token slice. A multi-byte
21
+ // codepoint that begins or ends on the cut edge belongs to an adjacent chunk
22
+ // and must be removed so TextDecoder does not emit U+FFFD. Pass
23
+ // trimHead/trimTail=false to preserve head/tail bytes (first/last chunks).
24
+ export function trimIncompleteUtf8(bytes, trimHead, trimTail) {
25
+ let start = 0;
26
+ let end = bytes.length;
27
+ if (trimHead) {
28
+ while (start < end && (bytes[start] & 0xC0) === 0x80)
29
+ start++;
30
+ }
31
+ if (trimTail) {
32
+ let i = end - 1;
33
+ while (i >= start && (bytes[i] & 0xC0) === 0x80)
34
+ i--;
35
+ if (i >= start) {
36
+ const lead = bytes[i];
37
+ let needed = 1;
38
+ if ((lead & 0x80) === 0)
39
+ needed = 1;
40
+ else if ((lead & 0xE0) === 0xC0)
41
+ needed = 2;
42
+ else if ((lead & 0xF0) === 0xE0)
43
+ needed = 3;
44
+ else if ((lead & 0xF8) === 0xF0)
45
+ needed = 4;
46
+ if (end - i < needed)
47
+ end = i;
48
+ }
49
+ }
50
+ return bytes.subarray(start, end);
51
+ }
52
+ export function chunkText(text, encoding, maxTokens = 800, overlap = 160) {
53
+ const tokens = encoding.encode(text);
54
+ const segments = [];
55
+ let utf16Cursor = 0;
56
+ let cpCursor = 0;
57
+ for (let i = 0; i < tokens.length; i += maxTokens - overlap) {
58
+ const chunkTokens = tokens.slice(i, i + maxTokens);
59
+ const decodedBytes = encoding.decode(chunkTokens);
60
+ const isFirst = i === 0;
61
+ const isLast = i + chunkTokens.length >= tokens.length;
62
+ const safeBytes = trimIncompleteUtf8(decodedBytes, !isFirst, !isLast);
63
+ const chunkTextStr = new TextDecoder('utf-8').decode(safeBytes);
64
+ let startPos;
65
+ let endPos;
66
+ if (isFirst) {
67
+ startPos = 0;
68
+ endPos = [...chunkTextStr].length;
69
+ utf16Cursor = 0;
70
+ cpCursor = 0;
71
+ }
72
+ else if (chunkTextStr.length === 0) {
73
+ startPos = null;
74
+ endPos = null;
75
+ }
76
+ else {
77
+ const utfIdx = text.indexOf(chunkTextStr, utf16Cursor);
78
+ if (utfIdx >= 0) {
79
+ // Advance cpCursor by codepoints between the previous cursor and the
80
+ // new chunk's start (handles overlap by anchoring at the previous
81
+ // chunk's start, not its end).
82
+ if (utfIdx > utf16Cursor) {
83
+ cpCursor += [...text.slice(utf16Cursor, utfIdx)].length;
84
+ utf16Cursor = utfIdx;
85
+ }
86
+ const cpLen = [...chunkTextStr].length;
87
+ startPos = cpCursor;
88
+ endPos = cpCursor + cpLen;
89
+ }
90
+ else {
91
+ startPos = null;
92
+ endPos = null;
93
+ }
94
+ }
95
+ segments.push({
96
+ text: chunkTextStr,
97
+ start_pos: startPos,
98
+ end_pos: endPos,
99
+ start_token: i,
100
+ end_token: i + chunkTokens.length
101
+ });
102
+ }
103
+ return segments;
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "5.3.0",
3
+ "version": "5.3.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/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/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.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/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/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.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",
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
  },