diff --git a/docs/api-error-policy.md b/docs/api-error-policy.md index 72eec41..c4975d9 100644 --- a/docs/api-error-policy.md +++ b/docs/api-error-policy.md @@ -63,12 +63,12 @@ txid once the owner daemon lands). A stale/mismatched cursor returns a | `add_observations` | unknown entity → `ENTITY_NOT_FOUND`; limit breaches → `LIMIT_EXCEEDED` (atomic, all offenders) | per-entity `{entityName, addedObservations, alreadyPresent}` | | `delete_entities` / `delete_relations` / `delete_observations` | nothing errors — absence is `notFound` (R4) | `{deleted, notFound}` ledgers | | `sequentialthinking` | unknown `previousCtxId` → `ENTITY_NOT_FOUND` (chain integrity); limits → `LIMIT_EXCEEDED` | `{ctxId, linkedTo}` | -| `kb_load` | bad extension / unreadable file → `INVALID_FILE`; entity & relation stages per their rows | per-stage ledger (document, stats, entity/relation outcomes) | +| `kb_load` | bad extension / unreadable file → `INVALID_FILE`; entity & relation stages per their rows | counts per stage: `entities {created, existing}`, `relations {created, skippedDuplicates}` | | `open_nodes` | ALL requested names missing → `ENTITY_NOT_FOUND` | graph result + `missing[]` for partial misses | | `get_neighbors` | unknown start → `ENTITY_NOT_FOUND` | neighbors (depth 0 = immediate) | | `search_nodes` | invalid ERE → `INVALID_REGEX`; literal query, zero matches → `NO_MATCHES` | paginated graph | | `get_entities_by_type` | type absent from the data-derived type set → `TYPE_NOT_FOUND` | entities of that type | -| `random_walk` | unknown start → `ENTITY_NOT_FOUND` | `{entity, path, modeUsed, stopReason}` | +| `random_walk` | unknown start → `ENTITY_NOT_FOUND` | `{entity, path, modeUsed, fallbackSteps}` (`modeUsed` = `merw` \| `uniform` \| `merw+fallback`) | | `find_path` | — (β-contract already reports `targetReached` / `budgetExhausted` / `note`) | unchanged — the reference pattern for R1-style reporting | | reads (`get_entity_types`, `get_relation_types`, `get_stats`, `get_orphaned_entities`, `validate_graph`, `decode_timestamp`) | unchanged | unchanged | @@ -78,12 +78,16 @@ faults; Q1 = atomic-fail on missing endpoints; Q2/Q3 = error. ## Status -- **This change (envelope PR):** R1 (envelope + codes + dispatch conversion), - R2 (validate-first atomicity for `create_entities`, `create_relations`, - `add_observations`, `sequentialthinking`), R4 (drops→errors: - `ENDPOINT_MISSING`, all-missing `open_nodes`, `TYPE_NOT_FOUND`, unknown - starts), `INVALID_FILE`, `INVALID_REGEX`, `NO_MATCHES` envelope. -- **Ledger PR (next):** R3 result ledgers + report fields (`missing[]`, - `modeUsed`, `alreadyPresent`), R5 cursor fingerprinting. +- **Envelope PR (#247, merged):** R1 (envelope + codes + dispatch + conversion), R2 (validate-first atomicity for `create_entities`, + `create_relations`, `add_observations`, `sequentialthinking`), R4 + (drops→errors: `ENDPOINT_MISSING`, all-missing `open_nodes`, + `TYPE_NOT_FOUND`, unknown starts), `INVALID_FILE`, `INVALID_REGEX`, + `NO_MATCHES` envelope. +- **Ledger PR:** R3 result ledgers + report fields + (`created`/`existing`/`skippedDuplicates`/`notFound`/`alreadyPresent`, + `open_nodes.missing[]`, `random_walk.modeUsed`/`fallbackSteps`, + `sequentialthinking.linkedTo`). +- **Next:** R5 cursor fingerprinting (`CURSOR_STALE`). - **v4 line (`feat/libsegstore`):** inherits this policy at reconciliation; kbd4's batch=txn=commit model is the natural home for R2 atomicity. diff --git a/native/graph.c b/native/graph.c index bcea550..c3fa687 100644 --- a/native/graph.c +++ b/native/graph.c @@ -866,9 +866,11 @@ u32 graph_compute_merw_psi(graph_t *g, double alpha, u32 max_iter, double tol) { } u32 graph_random_walk(graph_t *g, u64 start, u32 depth, u32 direction, int merw_mode, - u64 seed, int avoid_cycles, u64 *out_path, u32 max_path) { + u64 seed, int avoid_cycles, u64 *out_path, u32 max_path, + u32 *out_uniform_steps) { u64 st = seed ? seed : g_rng; u32 plen = 0; + u32 uniform_steps = 0; if (max_path >= 1) out_path[plen] = start; plen = 1; u64 cur = start; @@ -905,6 +907,7 @@ u32 graph_random_walk(graph_t *g, u64 start, u32 depth, u32 direction, int merw_ double r = rng_d(&st) * total_psi, cum = 0; chosen = cand[nc - 1]; for (u32 j = 0; j < nc; j++) { cum += cpsi[j]; if (r <= cum) { chosen = cand[j]; break; } } } else { + if (merw_mode) uniform_steps++; /* psi unavailable at this step: fallback */ u32 ix = (u32)(rng_d(&st) * nc); if (ix >= nc) ix = nc - 1; chosen = cand[ix]; } free(cand); free(cpsi); @@ -913,6 +916,7 @@ u32 graph_random_walk(graph_t *g, u64 start, u32 depth, u32 direction, int merw_ plen++; } if (!seed) g_rng = st; + if (out_uniform_steps) *out_uniform_steps = uniform_steps; return plen; } diff --git a/native/graph.h b/native/graph.h index 6521fd9..5b99add 100644 --- a/native/graph.h +++ b/native/graph.h @@ -134,8 +134,11 @@ u32 graph_structural_sample(graph_t *g, u32 iterations, double damping); /* u32 graph_compute_merw_psi(graph_t *g, double alpha, u32 max_iter, double tol); /* iters run */ /* random walk; mode: 1=merw (weighted by psi), 0=uniform; seed 0 = use global rng. * avoid_cycles: 1 = self-avoiding (never revisits a node; stops early when every - * neighbor is already on the path). Returns path node count. */ + * neighbor is already on the path). Returns path node count. + * out_uniform_steps (optional): number of merw-requested steps that fell back to + * uniform sampling because psi weighting was unavailable at that step. */ u32 graph_random_walk(graph_t *g, u64 start, u32 depth, u32 direction, int merw_mode, - u64 seed, int avoid_cycles, u64 *out_path, u32 max_path); + u64 seed, int avoid_cycles, u64 *out_path, u32 max_path, + u32 *out_uniform_steps); #endif /* GRAPH_H */ diff --git a/native/graphbind.c b/native/graphbind.c index 25ddbc7..0fe492d 100644 --- a/native/graphbind.c +++ b/native/graphbind.c @@ -244,8 +244,12 @@ static napi_value n_merw(napi_env env, napi_callback_info info) { ARG static napi_value n_seed(napi_env env, napi_callback_info info) { ARGS(2); (void)unwrap(env, argv[0]); graph_seed_rng(getU64(env, argv[1])); return NULL; } static napi_value n_random_walk(napi_env env, napi_callback_info info) { ARGS(7); STORE; u32 depth = getU32(env, argv[2]); u32 cap = depth + 1; u64 *out = malloc((size_t)cap * 8); - u32 n = graph_random_walk(s->g, getU64(env, argv[1]), depth, getU32(env, argv[3]), getU32(env, argv[4]), getU64(env, argv[5]), getU32(env, argv[6]), out, cap); - napi_value r = u64arr(env, out, n < cap ? n : cap); free(out); return r; + u32 uniform_steps = 0; + u32 n = graph_random_walk(s->g, getU64(env, argv[1]), depth, getU32(env, argv[3]), getU32(env, argv[4]), getU64(env, argv[5]), getU32(env, argv[6]), out, cap, &uniform_steps); + napi_value r; napi_create_object(env, &r); + napi_set_named_property(env, r, "path", u64arr(env, out, n < cap ? n : cap)); + napi_set_named_property(env, r, "uniformSteps", mkU32(env, uniform_steps)); + free(out); return r; } /* ---- validate ---- */ diff --git a/native/op_bench.c b/native/op_bench.c index 04668bb..f446142 100644 --- a/native/op_bench.c +++ b/native/op_bench.c @@ -166,7 +166,7 @@ int main(int argc, char **argv) { ADAPT("find_path_d6", sink32 = graph_find_path(g, off[idx[_k]], off[idx2[_k]], 6, DIR_ANY, out, cap)); ADAPT("search", sink32 = graph_search(g, pat[_k], out, cap)); ADAPT("entities_by_type", sink32 = graph_entities_by_type(g, (const u8 *)tynames[tpix[_k]], tylen[tpix[_k]], out, cap)); - ADAPT("random_walk_d5", sink32 = graph_random_walk(g, off[idx[_k]], 5, DIR_ANY, 1, seed[_k], 0, out, 8)); + ADAPT("random_walk_d5", sink32 = graph_random_walk(g, off[idx[_k]], 5, DIR_ANY, 1, seed[_k], 0, out, 8, NULL)); ADAPT("inc_walker_visit", graph_inc_walker_visit(g, off[idx[_k]])); ADAPT("structural_sample", graph_structural_sample(g, 1, 0.85)); ADAPT("compute_merw_psi", graph_compute_merw_psi(g, 0.85, 100, 1e-8)); diff --git a/server.ts b/server.ts index e2731a0..4e0e903 100644 --- a/server.ts +++ b/server.ts @@ -614,7 +614,7 @@ export class KnowledgeGraphManager { }; } - async createEntities(entities: Entity[]): Promise { + async createEntities(entities: Entity[]): Promise<{ created: Entity[]; existing: string[] }> { // Validate observation limits across the WHOLE batch first, collecting // every violation rather than stopping at the first (R2: validate-first; // the caller gets all offenders in one round-trip). @@ -651,6 +651,7 @@ export class KnowledgeGraphManager { // Pass 1 — classify every entity before writing anything. A collision // fails the whole batch (R2: atomic) and lists every offender. const toCreate: Entity[] = []; + const existingNames: string[] = []; const collisions: { entity: string; code: ToolErrorCode; message: string }[] = []; for (const e of entities) { const existingOffset = this.db.lookup(e.name); @@ -659,7 +660,7 @@ export class KnowledgeGraphManager { const sameType = existing.entityType === e.entityType; const sameObs = existing.observations.length === e.observations.length && existing.observations.every((o, i) => o === e.observations[i]); - if (sameType && sameObs) continue; // exact duplicate: intent satisfied + if (sameType && sameObs) { existingNames.push(e.name); continue; } // exact duplicate: intent satisfied (R3 ledger) collisions.push({ entity: e.name, code: 'COLLISION', @@ -693,11 +694,11 @@ export class KnowledgeGraphManager { newEntities.push(newEntity); } - return newEntities; + return { created: newEntities, existing: existingNames }; }); } - async createRelations(relations: Relation[]): Promise { + async createRelations(relations: Relation[]): Promise<{ created: Relation[]; skippedDuplicates: Relation[] }> { return this.withWriteLock(() => { const now = BigInt(Date.now()); @@ -731,6 +732,7 @@ export class KnowledgeGraphManager { // Pass 2 — create. Duplicates are already-satisfied intent and are // skipped (they become part of the result ledger under policy R3). const newRelations: Relation[] = []; + const skippedDuplicates: Relation[] = []; for (const r of relations) { const fromOffset = this.db.lookup(r.from); const toOffset = this.db.lookup(r.to); @@ -738,23 +740,23 @@ export class KnowledgeGraphManager { const isDuplicate = this.db.edges(fromOffset).some(e => e.direction === DIR_FORWARD && e.target === toOffset && e.relType === r.relationType ); - if (isDuplicate) continue; + if (isDuplicate) { skippedDuplicates.push(r); continue; } // C owns the bidirectional edges + relType interning/refcounts. this.db.createRelation(fromOffset, toOffset, r.relationType, now); newRelations.push({ ...r, mtime: Number(now) }); } - return newRelations; + return { created: newRelations, skippedDuplicates }; }); } - async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[] }[]> { + async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[]; alreadyPresent: string[] }[]> { return this.withWriteLock(() => { // Pass 1 — validate the WHOLE batch before touching state (R2: atomic; // the old code validated inside the write loop, so a late failure left // earlier items applied). Collect every violation for one round-trip. - const pending: { entityName: string; offset: bigint; newObservations: string[] }[] = []; + const pending: { entityName: string; offset: bigint; newObservations: string[]; alreadyPresent: string[] }[] = []; const violations: { entityName: string; code: ToolErrorCode; message: string }[] = []; for (const o of observations) { @@ -776,6 +778,7 @@ export class KnowledgeGraphManager { const existingObs = this.db.readEntity(offset).observations; const newObservations = o.contents.filter(content => !existingObs.includes(content)); + const alreadyPresent = o.contents.filter(content => existingObs.includes(content)); if (existingObs.length + newObservations.length > 2) { violations.push({ entityName: o.entityName, @@ -783,7 +786,7 @@ export class KnowledgeGraphManager { message: `adding ${newObservations.length} observation(s) would give ${existingObs.length + newObservations.length} total; maximum is 2`, }); } - pending.push({ entityName: o.entityName, offset, newObservations }); + pending.push({ entityName: o.entityName, offset, newObservations, alreadyPresent }); } if (violations.length > 0) { @@ -797,53 +800,78 @@ export class KnowledgeGraphManager { // Pass 2 — apply. const now = BigInt(Date.now()); - const results: { entityName: string; addedObservations: string[] }[] = []; + const results: { entityName: string; addedObservations: string[]; alreadyPresent: string[] }[] = []; for (const p of pending) { for (const obs of p.newObservations) { this.db.addObservation(p.offset, obs, now); } - results.push({ entityName: p.entityName, addedObservations: p.newObservations }); + results.push({ entityName: p.entityName, addedObservations: p.newObservations, alreadyPresent: p.alreadyPresent }); } return results; }); } - async deleteEntities(entityNames: string[]): Promise { - this.withWriteLock(() => { + async deleteEntities(entityNames: string[]): Promise<{ deleted: string[]; notFound: string[] }> { + return this.withWriteLock(() => { + const deleted: string[] = []; + const notFound: string[] = []; for (const name of entityNames) { const offset = this.db.lookup(name); - if (offset === 0n) continue; + if (offset === 0n) { notFound.push(name); continue; } // C deletes the record + adjacency, drops mirror edges, and releases // every string ref (name/type/obs + relType per edge + mirror). this.db.deleteEntity(offset); + deleted.push(name); } + return { deleted, notFound }; }); } - async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise { - this.withWriteLock(() => { + async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<{ + deleted: { entityName: string; observations: string[] }[]; + notFound: { entityName: string; observation?: string; reason: 'entity' | 'observation' }[]; + }> { + return this.withWriteLock(() => { const now = BigInt(Date.now()); + const deleted: { entityName: string; observations: string[] }[] = []; + const notFound: { entityName: string; observation?: string; reason: 'entity' | 'observation' }[] = []; for (const d of deletions) { const offset = this.db.lookup(d.entityName); - if (offset === 0n) continue; - + if (offset === 0n) { notFound.push({ entityName: d.entityName, reason: 'entity' }); continue; } + const existing = this.db.readEntity(offset).observations; + const removed: string[] = []; for (const obs of d.observations) { + if (!existing.includes(obs)) { notFound.push({ entityName: d.entityName, observation: obs, reason: 'observation' }); continue; } this.db.removeObservation(offset, obs, now); + removed.push(obs); } + if (removed.length > 0) deleted.push({ entityName: d.entityName, observations: removed }); } + return { deleted, notFound }; }); } - async deleteRelations(relations: Relation[]): Promise { - this.withWriteLock(() => { + async deleteRelations(relations: Relation[]): Promise<{ deleted: Relation[]; notFound: { from: string; to: string; relationType: string; reason: 'entity' | 'relation' }[] }> { + return this.withWriteLock(() => { + const deleted: Relation[] = []; + const notFound: { from: string; to: string; relationType: string; reason: 'entity' | 'relation' }[] = []; for (const r of relations) { const fromOffset = this.db.lookup(r.from); const toOffset = this.db.lookup(r.to); - if (fromOffset === 0n || toOffset === 0n) continue; + if (fromOffset === 0n || toOffset === 0n) { + notFound.push({ from: r.from, to: r.to, relationType: r.relationType, reason: 'entity' }); + continue; + } + const exists = this.db.edges(fromOffset).some(e => + e.direction === DIR_FORWARD && e.target === toOffset && e.relType === r.relationType + ); + if (!exists) { notFound.push({ from: r.from, to: r.to, relationType: r.relationType, reason: 'relation' }); continue; } // C removes both directed edges and releases the two relType refs. this.db.deleteRelation(fromOffset, toOffset, r.relationType); + deleted.push(r); } + return { deleted, notFound }; }); } @@ -903,13 +931,14 @@ export class KnowledgeGraphManager { ); } - async openNodes(names: string[], direction: 'forward' | 'backward' | 'any' = 'forward'): Promise { + async openNodes(names: string[], direction: 'forward' | 'backward' | 'any' = 'forward'): Promise { return this.withReadLock(() => { const filteredEntities: Entity[] = []; + const missing: string[] = []; const offsetByName = new Map(); for (const name of names) { const offset = this.db.lookup(name); - if (offset === 0n) continue; + if (offset === 0n) { missing.push(name); continue; } filteredEntities.push(this.recordToEntity(this.db.readEntity(offset))); offsetByName.set(name, offset); } @@ -952,7 +981,7 @@ export class KnowledgeGraphManager { } } - return { entities: filteredEntities, relations: filteredRelations }; + return { entities: filteredEntities, relations: filteredRelations, missing }; }); } @@ -1269,7 +1298,7 @@ export class KnowledgeGraphManager { direction: 'forward' | 'backward' | 'any' = 'forward', mode: RandomWalkMode = 'merw', avoidCycles: boolean = false, - ): Promise<{ entity: string; path: string[] }> { + ): Promise<{ entity: string; path: string[]; modeUsed: 'merw' | 'uniform' | 'merw+fallback'; fallbackSteps: number }> { return traced( 'kb.random_walk', { @@ -1289,12 +1318,18 @@ export class KnowledgeGraphManager { // seed of 0 means "use the global RNG" (unseeded), so hashSeed (never // 0) keeps seeded walks reproducible. const seedU64 = seed !== undefined ? BigInt(this.hashSeed(seed) >>> 0) : 0n; - const pathOffsets = this.db.randomWalk(startOffset, depth, direction, mode === 'merw', seedU64, avoidCycles); - const pathNames = pathOffsets.map(o => this.db.entityName(o)); + const walk = this.db.randomWalk(startOffset, depth, direction, mode === 'merw', seedU64, avoidCycles); + const pathNames = walk.path.map(o => this.db.entityName(o)); span.setAttribute('kb.walker.steps_taken', pathNames.length - 1); span.setAttribute('kb.walker.truncated', pathNames.length - 1 < depth); - return { entity: pathNames[pathNames.length - 1], path: pathNames }; + span.setAttribute('kb.walker.fallback_steps', walk.uniformSteps); + // R3 report (docs/api-error-policy.md): which policy actually ran. + // 'merw+fallback' = psi weighting was unavailable at some steps, so + // those steps sampled uniformly instead. + const modeUsed: 'merw' | 'uniform' | 'merw+fallback' = + mode === 'uniform' ? 'uniform' : (walk.uniformSteps === 0 ? 'merw' : 'merw+fallback'); + return { entity: pathNames[pathNames.length - 1], path: pathNames, modeUsed, fallbackSteps: walk.uniformSteps }; }), ); } @@ -1348,7 +1383,7 @@ export class KnowledgeGraphManager { return result; } - async addThought(observations: string[], previousCtxId?: string): Promise<{ ctxId: string }> { + async addThought(observations: string[], previousCtxId?: string): Promise<{ ctxId: string; linkedTo: string | null }> { // Validate observation limits across the whole request first. const violations: { code: ToolErrorCode; message: string }[] = []; if (observations.length > 2) { @@ -1390,7 +1425,7 @@ export class KnowledgeGraphManager { this.db.createRelation(offset, prevOffset, 'preceded_by', now); } - return { ctxId }; + return { ctxId, linkedTo: previousCtxId ?? null }; }); } @@ -1808,17 +1843,20 @@ The file MUST be plaintext (.txt, .tex, .md, source code, etc.). For PDFs, use p } case "add_observations": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.addObservations(args.observations as { entityName: string; contents: string[] }[]), null, 2) }] }; - case "delete_entities": - await knowledgeGraphManager.deleteEntities(args.entityNames as string[]); + case "delete_entities": { + const result = await knowledgeGraphManager.deleteEntities(args.entityNames as string[]); knowledgeGraphManager.resample(); // Re-run structural sampling after graph mutation - return { content: [{ type: "text", text: "Entities deleted successfully" }] }; - case "delete_observations": - await knowledgeGraphManager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[]); - return { content: [{ type: "text", text: "Observations deleted successfully" }] }; - case "delete_relations": - await knowledgeGraphManager.deleteRelations(args.relations as Relation[]); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + case "delete_observations": { + const result = await knowledgeGraphManager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[]); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + case "delete_relations": { + const result = await knowledgeGraphManager.deleteRelations(args.relations as Relation[]); knowledgeGraphManager.resample(); // Re-run structural sampling after graph mutation - return { content: [{ type: "text", text: "Relations deleted successfully" }] }; + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } case "search_nodes": { const query = args.query as string; const graph = await knowledgeGraphManager.searchNodes( @@ -1860,7 +1898,9 @@ The file MUST be plaintext (.txt, .tex, .md, source code, etc.). For PDFs, use p const graph = await knowledgeGraphManager.openNodes(args.names as string[], (args.direction as 'forward' | 'backward' | 'any') ?? 'forward'); // Record walker visits for opened nodes knowledgeGraphManager.recordWalkerVisits(graph.entities.map(e => e.name)); - return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor as number ?? 0, args.relationCursor as number ?? 0)) }] }; + // R3: report partial misses alongside the (paginated) graph. + const paginated = paginateGraph(graph, args.entityCursor as number ?? 0, args.relationCursor as number ?? 0); + return { content: [{ type: "text", text: JSON.stringify({ ...paginated, missing: graph.missing }) }] }; } case "get_neighbors": { const neighbors = await knowledgeGraphManager.getNeighbors(args.entityName as string, args.depth as number ?? 0, args.sortBy as EntitySortField | undefined, args.sortDir as SortDirection | undefined, (args.direction as 'forward' | 'backward' | 'any') ?? 'forward'); @@ -1997,8 +2037,8 @@ The file MUST be plaintext (.txt, .tex, .md, source code, etc.). For PDFs, use p text: JSON.stringify({ document: title, stats: loadResult.stats, - entitiesCreated: entities.length, - relationsCreated: relations.length, + entities: { created: entities.created.length, existing: entities.existing.length }, + relations: { created: relations.created.length, skippedDuplicates: relations.skippedDuplicates.length }, }, null, 2), }], }; diff --git a/src/store.ts b/src/store.ts index 4dd0076..b7bd15a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -82,7 +82,7 @@ interface NativeStore { structuralSample(h: unknown, iterations: number, damping: number): number; computeMerwPsi(h: unknown, alpha: number, maxIter: number, tol: number): number; seedRng(h: unknown, seed: bigint): void; - randomWalk(h: unknown, start: bigint, depth: number, direction: number, merwMode: number, seed: bigint, avoidCycles: number): bigint[]; + randomWalk(h: unknown, start: bigint, depth: number, direction: number, merwMode: number, seed: bigint, avoidCycles: number): { path: bigint[]; uniformSteps: number }; validateObs(h: unknown): { offset: bigint; count: number; oversize: number }[]; validateDangling(h: unknown): { src: bigint; target: bigint }[]; setEntityFields(h: unknown, off: bigint, mtime: bigint, obsMtime: bigint, structuralVisits: bigint, walkerVisits: bigint, psi: number): void; @@ -171,7 +171,7 @@ export class Store { structuralSample(iterations: number, damping: number): number { return native.structuralSample(this.h, iterations, damping); } computeMerwPsi(alpha: number, maxIter: number, tol: number): number { return native.computeMerwPsi(this.h, alpha, maxIter, tol); } seedRng(seed: bigint): void { native.seedRng(this.h, seed); } - randomWalk(start: bigint, depth: number, direction: Direction, merwMode: boolean, seed: bigint, avoidCycles: boolean): bigint[] { + randomWalk(start: bigint, depth: number, direction: Direction, merwMode: boolean, seed: bigint, avoidCycles: boolean): { path: bigint[]; uniformSteps: number } { return native.randomWalk(this.h, start, depth, dirCode(direction), merwMode ? 1 : 0, seed, avoidCycles ? 1 : 0); } diff --git a/tests/memory-server.test.ts b/tests/memory-server.test.ts index 52ff6b5..8b59035 100644 --- a/tests/memory-server.test.ts +++ b/tests/memory-server.test.ts @@ -39,11 +39,12 @@ describe('MCP Memory Server E2E Tests', () => { { name: 'Alice', entityType: 'Person', observations: ['Likes coding'] }, { name: 'Bob', entityType: 'Person', observations: ['Likes music'] } ] - }) as Entity[]; + }) as { created: Entity[]; existing: string[] }; - expect(result).toHaveLength(2); - expect(result[0].name).toBe('Alice'); - expect(result[1].name).toBe('Bob'); + expect(result.created).toHaveLength(2); + expect(result.created[0].name).toBe('Alice'); + expect(result.created[1].name).toBe('Bob'); + expect(result.existing).toEqual([]); }); it('should not duplicate existing entities', async () => { @@ -51,17 +52,16 @@ describe('MCP Memory Server E2E Tests', () => { entities: [{ name: 'Alice', entityType: 'Person', observations: ['First'] }] }); - // Exact same entity should be silently skipped + // Exact same entity is reported in `existing`, not created again (R3). const result = await callTool(client, 'create_entities', { entities: [ { name: 'Alice', entityType: 'Person', observations: ['First'] }, { name: 'Bob', entityType: 'Person', observations: ['New'] } ] - }) as Entity[]; + }) as { created: Entity[]; existing: string[] }; - // Only Bob should be returned as new - expect(result).toHaveLength(1); - expect(result[0].name).toBe('Bob'); + expect(result.created.map(e => e.name)).toEqual(['Bob']); + expect(result.existing).toEqual(['Alice']); }); it('should error on duplicate name with different data (COLLISION, visible)', async () => { @@ -201,9 +201,10 @@ describe('MCP Memory Server E2E Tests', () => { { from: 'A', to: 'B', relationType: 'connects' }, { from: 'B', to: 'C', relationType: 'connects' } ] - }) as Relation[]; + }) as { created: Relation[]; skippedDuplicates: Relation[] }; - expect(result).toHaveLength(2); + expect(result.created).toHaveLength(2); + expect(result.skippedDuplicates).toEqual([]); }); it('should not duplicate relations', async () => { @@ -216,10 +217,12 @@ describe('MCP Memory Server E2E Tests', () => { { from: 'A', to: 'B', relationType: 'connects' }, { from: 'A', to: 'C', relationType: 'connects' } ] - }) as Relation[]; + }) as { created: Relation[]; skippedDuplicates: Relation[] }; - expect(result).toHaveLength(1); - expect(result[0].to).toBe('C'); + expect(result.created).toHaveLength(1); + expect(result.created[0].to).toBe('C'); + expect(result.skippedDuplicates).toHaveLength(1); + expect(result.skippedDuplicates[0].to).toBe('B'); }); it('should delete relations', async () => { @@ -2002,8 +2005,8 @@ describe('MCP Memory Server E2E Tests', () => { const result = await callTool(client, 'kb_load', { filePath: docFile }) as any; expect(result.document).toBe('test-doc'); - expect(result.entitiesCreated).toBeGreaterThan(0); - expect(result.relationsCreated).toBeGreaterThan(0); + expect(result.entities.created).toBeGreaterThan(0); + expect(result.relations.created).toBeGreaterThan(0); expect(result.stats.chunks).toBeGreaterThan(0); expect(result.stats.sentences).toBeGreaterThan(0); }); @@ -2111,7 +2114,7 @@ describe('MCP Memory Server E2E Tests', () => { await fs.writeFile(docFile, `${longWord} is a very long word that tests our splitting logic handles edge cases.`); const result = await callTool(client, 'kb_load', { filePath: docFile }) as any; - expect(result.entitiesCreated).toBeGreaterThan(0); + expect(result.entities.created).toBeGreaterThan(0); // All observations should be within limits const chunks = await callTool(client, 'get_entities_by_type', { entityType: 'TextChunk' }) as PaginatedResult; @@ -2133,7 +2136,7 @@ describe('MCP Memory Server E2E Tests', () => { filePath, title: `ext-test-${ext.slice(1)}`, }) as any; - expect(result.entitiesCreated).toBeGreaterThan(0); + expect(result.entities.created).toBeGreaterThan(0); } }); }); @@ -2248,4 +2251,146 @@ describe('MCP Memory Server E2E Tests', () => { expect(err.message).toContain('NoSuchEntity'); }); }); + + describe('API ledgers (R3)', () => { + it('create_entities reports exact duplicates in existing', async () => { + await callTool(client, 'create_entities', { + entities: [{ name: 'LedgerA', entityType: 'LedgerTag', observations: ['one'] }], + }); + + const result = await callTool(client, 'create_entities', { + entities: [ + { name: 'LedgerA', entityType: 'LedgerTag', observations: ['one'] }, // exact dupe + { name: 'LedgerB', entityType: 'LedgerTag', observations: ['two'] }, + ], + }) as { created: Entity[]; existing: string[] }; + + expect(result.created.map(e => e.name)).toEqual(['LedgerB']); + expect(result.existing).toEqual(['LedgerA']); + }); + + it('create_relations reports duplicates in skippedDuplicates', async () => { + await callTool(client, 'create_entities', { + entities: [ + { name: 'LedgerC', entityType: 'LedgerTag', observations: [] }, + { name: 'LedgerD', entityType: 'LedgerTag', observations: [] }, + ], + }); + await callTool(client, 'create_relations', { + relations: [{ from: 'LedgerC', to: 'LedgerD', relationType: 'LEDGES' }], + }); + + const result = await callTool(client, 'create_relations', { + relations: [ + { from: 'LedgerC', to: 'LedgerD', relationType: 'LEDGES' }, // dupe + { from: 'LedgerD', to: 'LedgerC', relationType: 'LEDGES' }, // new (reverse direction) + ], + }) as { created: Array<{ from: string; to: string }>; skippedDuplicates: Array<{ from: string; to: string }> }; + + expect(result.created).toHaveLength(1); + expect(result.created[0]).toMatchObject({ from: 'LedgerD', to: 'LedgerC' }); + expect(result.skippedDuplicates).toHaveLength(1); + expect(result.skippedDuplicates[0]).toMatchObject({ from: 'LedgerC', to: 'LedgerD' }); + }); + + it('add_observations reports alreadyPresent', async () => { + await callTool(client, 'create_entities', { + entities: [{ name: 'LedgerE', entityType: 'LedgerTag', observations: ['seed'] }], + }); + + const result = await callTool(client, 'add_observations', { + observations: [{ entityName: 'LedgerE', contents: ['seed', 'fresh'] }], + }) as Array<{ entityName: string; addedObservations: string[]; alreadyPresent: string[] }>; + + expect(result[0].addedObservations).toEqual(['fresh']); + expect(result[0].alreadyPresent).toEqual(['seed']); + }); + + it('delete_entities reports deleted + notFound', async () => { + await callTool(client, 'create_entities', { + entities: [{ name: 'LedgerF', entityType: 'LedgerTag', observations: [] }], + }); + + const result = await callTool(client, 'delete_entities', { + entityNames: ['LedgerF', 'LedgerGhost'], + }) as { deleted: string[]; notFound: string[] }; + + expect(result.deleted).toEqual(['LedgerF']); + expect(result.notFound).toEqual(['LedgerGhost']); + }); + + it('delete_relations distinguishes missing entity vs missing relation', async () => { + await callTool(client, 'create_entities', { + entities: [ + { name: 'LedgerG', entityType: 'LedgerTag', observations: [] }, + { name: 'LedgerH', entityType: 'LedgerTag', observations: [] }, + ], + }); + + const result = await callTool(client, 'delete_relations', { + relations: [ + { from: 'LedgerG', to: 'LedgerH', relationType: 'LEDGES' }, // entity exists, relation never created + { from: 'LedgerG', to: 'LedgerGhost', relationType: 'LEDGES' }, // entity missing + ], + }) as { deleted: unknown[]; notFound: Array<{ reason: string }> }; + + expect(result.deleted).toEqual([]); + expect(result.notFound.map(n => n.reason).sort()).toEqual(['entity', 'relation']); + }); + + it('delete_observations distinguishes entity vs observation', async () => { + await callTool(client, 'create_entities', { + entities: [{ name: 'LedgerI', entityType: 'LedgerTag', observations: ['keep'] }], + }); + + const result = await callTool(client, 'delete_observations', { + deletions: [ + { entityName: 'LedgerI', observations: ['keep', 'never-added'] }, + { entityName: 'LedgerGhost', observations: ['x'] }, + ], + }) as { deleted: Array<{ entityName: string; observations: string[] }>; notFound: Array<{ reason: string }> }; + + expect(result.deleted).toEqual([{ entityName: 'LedgerI', observations: ['keep'] }]); + expect(result.notFound.map(n => n.reason).sort()).toEqual(['entity', 'observation']); + }); + + it('open_nodes reports partial misses in missing[]', async () => { + await callTool(client, 'create_entities', { + entities: [{ name: 'LedgerJ', entityType: 'LedgerTag', observations: [] }], + }); + + const result = await callTool(client, 'open_nodes', { names: ['LedgerJ', 'LedgerGhost'] }) as PaginatedGraph; + + expect(result.entities.items.map(e => e.name)).toEqual(['LedgerJ']); + expect(result.missing).toEqual(['LedgerGhost']); + }); + + it('random_walk reports modeUsed and fallbackSteps', async () => { + await callTool(client, 'create_entities', { + entities: [ + { name: 'LedgerK', entityType: 'LedgerTag', observations: [] }, + { name: 'LedgerL', entityType: 'LedgerTag', observations: [] }, + ], + }); + await callTool(client, 'create_relations', { + relations: [{ from: 'LedgerK', to: 'LedgerL', relationType: 'LEDGES' }], + }); + + const merw = await callTool(client, 'random_walk', { start: 'LedgerK', depth: 1 }) as { modeUsed: string; fallbackSteps: number }; + expect(['merw', 'merw+fallback']).toContain(merw.modeUsed); + expect(merw.modeUsed === 'merw').toBe(merw.fallbackSteps === 0); + + const uni = await callTool(client, 'random_walk', { start: 'LedgerK', depth: 1, mode: 'uniform' }) as { modeUsed: string; fallbackSteps: number }; + expect(uni.modeUsed).toBe('uniform'); + expect(uni.fallbackSteps).toBe(0); + }); + + it('sequentialthinking returns linkedTo', async () => { + const first = await callTool(client, 'sequentialthinking', { observations: ['ledger thought 1'] }) as { ctxId: string; linkedTo: string | null }; + expect(first.linkedTo).toBeNull(); + + const second = await callTool(client, 'sequentialthinking', { observations: ['ledger thought 2'], previousCtxId: first.ctxId }) as { ctxId: string; linkedTo: string | null }; + expect(second.linkedTo).toBe(first.ctxId); + }); + }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index ea99ebe..745bd4d 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -14,6 +14,8 @@ export interface PaginatedResult { export interface PaginatedGraph { entities: PaginatedResult; relations: PaginatedResult; + /** open_nodes only: requested names that do not exist (partial misses). */ + missing?: string[]; } /**