Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ jobs:

- name: Build head benchmark
run: |
make -C native bench >/dev/null 2>&1
if ! make -C native bench >/tmp/head_build.log 2>&1; then
echo "Head bench build/run failed:"
tail -50 /tmp/head_build.log
exit 1
fi
cp /tmp/mf_test_bench /tmp/bench_head

- name: Build base benchmark (head harness, base store sources)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ The eigenvector is computed via sparse power iteration with teleportation dampin
- `depth` (number, default: 3): Number of hops to take
- `seed` (string, optional): Seed for reproducible walks
- `direction` (string, optional): Edge direction filter (`forward`, `backward`, `any`). Default: `forward`
- `avoidCycles` (boolean, optional): Self-avoiding walk — never revisits a node; stops early when every neighbor is already on the path. Default: `false`
- Neighbors are selected proportional to their MERW eigenvector component ψ
- Falls back to uniform sampling if ψ has not been computed
- Returns the terminal entity name and the path taken
Expand Down
98 changes: 98 additions & 0 deletions docs/api-error-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# API Error & Result Policy

Status: **adopted 2026-10-08** (implementation ongoing — see [Status](#status)).

This document is the contract for how every MCP tool reports failure and
success. It replaces the original "never fail, funnel the caller into
unfailing completion" approach: with RL-era models, errors are signal — a
silent no-op or a hidden protocol error teaches the caller nothing and lets
wrong beliefs persist (KB: `BGS_API_ErrorInconsistency`,
`Inventory_APISilentDrops_2026_10_08`).

## Rules

**R1 — Visible failures.**
Every failure the caller can act on is returned as a tool-level error:
`isError: true` with a JSON envelope body:

```json
{
"error": {
"tool": "create_relations",
"code": "ENDPOINT_MISSING",
"message": "2 relation(s) reference nonexistent entities; nothing was created (details below).",
"details": [
{ "from": "A", "to": "Ghost", "relationType": "USES", "code": "ENDPOINT_MISSING",
"missing": ["to"], "message": "endpoint(s) not found: to" }
]
}
}
```

Stable codes: `ENTITY_NOT_FOUND`, `TYPE_NOT_FOUND`, `ENDPOINT_MISSING`,
`COLLISION`, `LIMIT_EXCEEDED`, `INVALID_REGEX`, `INVALID_FILE`,
`NO_MATCHES`, `CURSOR_STALE`, `VALIDATION_FAILED`. JSON-RPC protocol errors
are reserved for genuine server faults and are never used for
caller-actionable failures.

**R2 — Validate-first, atomic.**
A mutation validates the entire request under the write lock before touching
state, and is all-or-nothing: a batch containing a failing item changes
nothing, and the error lists every offending item in one round-trip.

**R3 — Per-item ledger.**
Every mutating success enumerates per-item outcomes
(`created` / `existing` / `skippedDuplicates` / `notFound` / `deleted` /
`added` / `alreadyPresent`). There is no bare "successfully" response.

**R4 — No silent drops.**
An item that cannot be satisfied errors (R1) unless the caller's intent is
provably satisfied — duplicates are "already there", deleting an absent
thing is "already absent". Those cases succeed **and are reported** (R3).

**R5 — Pagination integrity.**
Cursors identify the result set they came from (a fingerprint now; a store
txid once the owner daemon lands). A stale/mismatched cursor returns a
`CURSOR_STALE` error instead of a silently wrong page.

## Per-op policy

| Tool | On failure | On success |
|---|---|---|
| `create_entities` | same-name-different-data → `COLLISION` (atomic, all offenders); limit breaches → `LIMIT_EXCEEDED` | `{created, existing}` — exact duplicates are success |
| `create_relations` | any endpoint missing → `ENDPOINT_MISSING` (atomic, all offenders) | `{created, skippedDuplicates}` |
| `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 | 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, 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 |

Decisions of record (Lev, 2026-10-08): duplicates are ledger reports, not
errors; the unified surface is `isError` with protocol errors reserved for
faults; Q1 = atomic-fail on missing endpoints; Q2/Q3 = error.

## Status

- **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 (#248, merged):** R3 result ledgers + report fields
(`created`/`existing`/`skippedDuplicates`/`notFound`/`alreadyPresent`,
`open_nodes.missing[]`, `random_walk.modeUsed`/`fallbackSteps`,
`sequentialthinking.linkedTo`).
- **Cursor PR (this change):** R5 — cursors are `<index>:<fnv32(result set)>`;
any change to the set between pages → visible `CURSOR_STALE`; malformed
cursors rejected; bare-number cursors accepted as legacy (unchecked).
Prerequisite shipped in the same change: all sort tie-breaks are
deterministic (name), so result sets have a stable total order.
- **v4 line (`feat/libsegstore`):** inherits this policy at reconciliation;
kbd4's batch=txn=commit model is the natural home for R2 atomicity.
17 changes: 16 additions & 1 deletion native/graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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;
Expand All @@ -881,6 +883,17 @@ u32 graph_random_walk(graph_t *g, u64 start, u32 depth, u32 direction, int merw_
for (u32 k = 0; k < ec; k++) {
if (!dir_match(direction, es[k].direction)) continue;
u64 t = es[k].target_offset; if (t == cur) continue;
if (avoid_cycles) {
/* Self-avoiding walk: skip any node already on the path. The
* path prefix doubles as the visited set (callers pass
* max_path >= depth+1, so plen never exceeds max_path and the
* prefix in out_path is complete). All neighbors visited ->
* nc == 0 below -> stops early. */
u32 vmax = plen < max_path ? plen : max_path;
int seen = 0;
for (u32 j = 0; j < vmax; j++) if (out_path[j] == t) { seen = 1; break; }
if (seen) continue;
}
double p = rdf64(g->mf, t + E_PSI);
int found = 0;
for (u32 j = 0; j < nc; j++) if (cand[j] == t) { if (p > cpsi[j]) cpsi[j] = p; found = 1; break; }
Expand All @@ -894,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);
Expand All @@ -902,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;
}

Expand Down
9 changes: 7 additions & 2 deletions native/graph.h
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,13 @@ void graph_set_entity_fields(graph_t *g, u64 off, u64 mtime, u64 obs_mtime,
void graph_set_totals(graph_t *g, u64 structural_total, u64 walker_total);
u32 graph_structural_sample(graph_t *g, u32 iterations, double damping); /* MC pagerank; total visits */
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. Returns path node count. */
/* 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.
* 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, u64 *out_path, u32 max_path);
u64 seed, int avoid_cycles, u64 *out_path, u32 max_path,
u32 *out_uniform_steps);

#endif /* GRAPH_H */
10 changes: 7 additions & 3 deletions native/graphbind.c
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,13 @@ static napi_value n_structural_sample(napi_env env, napi_callback_info info){ AR
static napi_value n_merw(napi_env env, napi_callback_info info) { ARGS(4); STORE; return mkU32(env, graph_compute_merw_psi(s->g, getF64(env, argv[1]), getU32(env, argv[2]), getF64(env, argv[3]))); }
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(6); 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]), out, cap);
napi_value r = u64arr(env, out, n < cap ? n : cap); free(out); return r;
ARGS(7); STORE; u32 depth = getU32(env, argv[2]); u32 cap = depth + 1; u64 *out = malloc((size_t)cap * 8);
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 ---- */
Expand Down
2 changes: 1 addition & 1 deletion native/op_bench.c
Original file line number Diff line number Diff line change
Expand Up @@ -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], 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));
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@levalicious/server-memory",
"version": "0.0.29",
"version": "0.0.30",
"description": "MCP server for enabling memory for Claude through a knowledge graph",
"license": "MIT",
"author": "Levalicious",
Expand Down
Loading
Loading