diff --git a/API.md b/API.md index 1a33796..8b68a0c 100644 --- a/API.md +++ b/API.md @@ -121,6 +121,22 @@ interface GraphState { --- +## Errors + +All typed errors subclass `Error` and set `.name` so they're catchable by both `instanceof` and name checks. + +| Error | Thrown by | When | +|-------|-----------|------| +| `MemoryNotFoundError` | `applyCommand` (`memory.update`, `memory.retract`) | Target item doesn't exist | +| `EdgeNotFoundError` | `applyCommand` (`edge.update`, `edge.retract`) | Target edge doesn't exist | +| `DuplicateMemoryError` | `applyCommand` (`memory.create`) | Item id already exists | +| `DuplicateEdgeError` | `applyCommand` (`edge.create`) | Edge id already exists | +| `InvalidTimestampError` | `extractTimestamp`, envelope `ts` parsing | Malformed UUIDv7 or ISO 8601 string | + +Bulk replay functions never re-throw these — they collect them in `skipped: ReplayFailure[]` and continue. See [Replay](#replay). + +--- + ## Factories ### createMemoryItem(input) @@ -140,7 +156,9 @@ const item = createMemoryItem({ ### createEdge(input) -Creates an `Edge` with auto-generated `edge_id`. Defaults `active` to `true`. +Creates an `Edge` with auto-generated `edge_id` and `active: true` by default. Validates score fields are in [0, 1]. + +**Self-referencing edges** (`from === to`) are permitted. They represent meaningful graph anomalies (e.g. a self-CONTRADICTS marks an internally inconsistent item) and are tolerated by downstream traversal code. ### createEventEnvelope(type, payload, opts?) @@ -184,7 +202,7 @@ const { state, events } = applyCommand(state, { - `content` is shallow-merged (`{ ...existing.content, ...partial.content }`) - `meta` is shallow-merged (`{ ...existing.meta, ...partial.meta }`) - `undefined` values in partials are ignored (field is not changed) -- `id` in partials is ignored (cannot change item identity) +- `id` and `created_at` in partials are ignored (identity and creation time are immutable) - All other fields are replaced **Errors:** `DuplicateMemoryError`, `MemoryNotFoundError`, `DuplicateEdgeError`, `EdgeNotFoundError`. @@ -380,11 +398,20 @@ Returns items that have the given item in their `parents`. ### extractTimestamp(uuidv7Id) -Extracts millisecond unix timestamp from a uuidv7 id. +Extracts millisecond unix timestamp from a uuidv7 id. Throws `InvalidTimestampError` if the argument is not a valid UUIDv7 — callers at an API boundary are expected to fix their input. ```ts const ms = extractTimestamp(item.id); const date = new Date(ms); + +// typed + catchable +try { + extractTimestamp(userInput); +} catch (err) { + if (err instanceof InvalidTimestampError) { + // log + drop; don't crash the daemon + } +} ``` --- @@ -463,6 +490,10 @@ const context = getItemsByBudget(state, { }); ``` +**Cost semantics:** +- `costFn` may return `0` — zero-cost items (cached, free, ephemeral) are always included regardless of remaining budget. +- Negative or non-finite costs throw `RangeError`. + --- ## Smart Retrieval @@ -499,6 +530,8 @@ const context = smartRetrieve(state, { }); ``` +Same cost semantics as `getItemsByBudget`: `costFn` may return `0` (free items always included); negative or non-finite costs throw `RangeError`. + ### filterContradictions(state, scored) Removes superseded items (losers of resolved contradictions). For unresolved contradictions, keeps only the higher-scoring side. Use when you want a clean, non-contradictory result set. @@ -615,16 +648,27 @@ Note: for query-time decay without mutating stored values, use `ScoreWeights.dec ## Graph Integrity +MemEX is tolerant of noisy input. Graph-mutation helpers below never throw on +degenerate shapes (self-references, stale resolves) — they record, flag, or +silently no-op so the fold can continue. Only API-boundary helpers throw, and +they throw typed errors that callers are expected to catch. + ### Conflict Detection & Resolution ```ts -// mark two items as contradicting +// mark two items as contradicting. +// itemIdA === itemIdB is ALLOWED and recorded as a self-CONTRADICTS edge: +// it represents an internally inconsistent / tainted item. Downstream +// `surfaceContradictions` skips self-edges during annotation. markContradiction(state, itemIdA, itemIdB, author, meta?) // find all active contradictions getContradictions(state) -> Contradiction[] -// resolve: winner supersedes loser, loser authority lowered +// resolve: winner supersedes loser, loser authority lowered. +// If no active CONTRADICTS edge exists between them, this is a silent no-op +// (returns { state, events: [] }) — a stale or duplicate resolve is not a +// structural violation and should not crash the pipeline. resolveContradiction(state, winnerId, loserId, author, reason?) ``` @@ -637,7 +681,9 @@ getStaleItems(state) -> StaleItem[] // get direct or transitive dependents getDependents(state, itemId, transitive?) -> MemoryItem[] -// retract an item and all its transitive dependents +// retract an item and all its transitive dependents. +// Retraction uses DFS post-order so shared descendants are retracted +// before their parents (valid topological order for DAGs, cycle-safe). cascadeRetract(state, itemId, author, reason?) -> { state, events, retracted: string[] } ``` @@ -645,7 +691,9 @@ cascadeRetract(state, itemId, author, reason?) ### Identity / Aliasing ```ts -// mark two items as referring to the same entity (bidirectional) +// mark two items as referring to the same entity (bidirectional). +// itemIdA === itemIdB is a silent no-op: a self-alias is redundant and +// would only pollute getAliases output. markAlias(state, itemIdA, itemIdB, author, meta?) // direct aliases @@ -655,6 +703,12 @@ getAliases(state, itemId) -> MemoryItem[] getAliasGroup(state, itemId) -> MemoryItem[] ``` +### Self-referencing edges + +`createEdge({ from: x, to: x })` is permitted. Self-edges carry meaning (e.g. +a self-CONTRADICTS marks an internally inconsistent item) and are tolerated +by traversal code. Higher layers decide how to interpret them. + --- ## Event Envelope Utilities @@ -675,13 +729,56 @@ Creates a `state.edge` envelope. ## Replay +Bulk replay is **integrity-tolerant**. Individual bad items (unparsable +timestamps, duplicate ids, missing items on update) are collected in a `skipped` list +rather than aborting the batch — a long-running daemon keeps running. + +```ts +interface ReplayFailure { + index: number; // position in the input array + command?: MemoryCommand; // populated for replayCommands + envelope?: EventEnvelope; // populated for replayFromEnvelopes + error: Error; // typed; e.g. InvalidTimestampError, DuplicateMemoryError +} +``` + ### replayCommands(commands) -Folds an array of `MemoryCommand` from an empty state. Returns final state and all lifecycle events. +Folds an array of `MemoryCommand` from an empty state. Returns: + +```ts +{ state, events, skipped: ReplayFailure[] } +``` + +Commands that throw (`DuplicateMemoryError`, `MemoryNotFoundError`, etc.) are +added to `skipped` and the rest of the batch continues. ### replayFromEnvelopes(envelopes) -Sorts `EventEnvelope[]` by timestamp, extracts payloads, replays. +Sorts `EventEnvelope[]` chronologically, extracts payloads, replays. + +- Envelope `ts` must be strict ISO 8601: `YYYY-MM-DDTHH:mm:ss[.SSS](Z|±HH:MM)`. + Sub-millisecond precision, impossible calendar dates (e.g. `2024-02-31`), + and non-ISO formats are rejected as `InvalidTimestampError` and collected + in `skipped` — NOT thrown. +- Years `0000–0099` are parsed correctly (the implementation bypasses + `Date.UTC`'s legacy two-digit-year coercion). +- Apply-time failures that the reducer raises — `DuplicateMemoryError`, + `MemoryNotFoundError`, `DuplicateEdgeError`, `EdgeNotFoundError` — are + also collected in `skipped`. Note the reducer does **not** validate that + a `memory.create`'s `parents` exist, so missing-parent references pass + through silently; detect those via `getStaleItems(state)` after replay. + +Returns `{ state, events, skipped: ReplayFailure[] }`. + +```ts +const { state, skipped } = replayFromEnvelopes(envelopes); +if (skipped.length > 0) { + for (const failure of skipped) { + logger.warn({ err: failure.error, ts: failure.envelope?.ts }); + } +} +``` --- diff --git a/README.md b/README.md index 383e914..7c7578e 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,23 @@ Commands go in, lifecycle events come out of the reducer, state events are full `applyCommand` never mutates input state. It returns a new `GraphState` and an array of lifecycle events. History is in the append-only event log; `GraphState` is always the latest snapshot. +### Error Handling & Tolerance + +MemEX is a memory graph for noisy agent outputs and background reasoning, not a strict transactional ledger. Errors are layered: + +| Layer | Behavior | +|-------|----------| +| **Graph mutations** (`markAlias`, `markContradiction`, `resolveContradiction`, `createEdge`) | Never throw on degenerate shapes. Self-alias is a silent no-op; self-contradiction is recorded as a meaningful "internally inconsistent" marker; resolving a nonexistent contradiction is a no-op. The fold survives noisy input. | +| **API boundary** (`extractTimestamp`, envelope `ts` parsing) | Throw typed, catchable errors (`InvalidTimestampError`). The caller is expected to fix their input. | +| **Bulk replay** (`replayCommands`, `replayFromEnvelopes`) | Integrity-tolerant. Per-item failures (unparsable timestamps, duplicate ids, updates targeting nonexistent items) are collected in a `skipped: ReplayFailure[]` list rather than aborting the batch. A long-running daemon keeps running. | + +```ts +const { state, events, skipped } = replayFromEnvelopes(envelopes); +for (const failure of skipped) { + logger.warn({ err: failure.error, at: failure.envelope?.ts }); +} +``` + ## Design Philosophy Every system encodes assumptions about truth, knowledge, and time — whether it acknowledges them or not. MemEX makes those assumptions explicit. @@ -411,7 +428,9 @@ Memory is no longer a local resource. It is portable belief. - Serialization (`toJSON` / `fromJSON` / `stringify` / `parse`) - Graph stats (counts by kind, author, scope, edge kind) - Event envelope wrapping for bus integration -- Command log replay for state reconstruction +- Integrity-tolerant replay: per-item failures collected as `skipped: ReplayFailure[]`, batch continues +- Cascade retraction uses DFS post-order (valid topological sort for DAGs, cycle-safe) +- Typed errors at API boundaries (`InvalidTimestampError`, `DuplicateMemoryError`, ...) **Intent graph:** - Status machine: active ↔ paused → completed / cancelled diff --git a/package.json b/package.json index 63f663c..2e1906a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ai2070/memex", - "version": "0.11.1", + "version": "0.12.0", "description": "MemEX memory layer — Multi-session continuity for AI systems", "type": "module", "main": "./dist/index.js", diff --git a/src/errors.ts b/src/errors.ts index cad8753..9ee5852 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -25,3 +25,10 @@ export class DuplicateEdgeError extends Error { this.name = "DuplicateEdgeError"; } } + +export class InvalidTimestampError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidTimestampError"; + } +} diff --git a/src/helpers.ts b/src/helpers.ts index c2d0390..969f5a7 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,4 +1,4 @@ -import { uuidv7 } from "uuidv7"; +import { uuidv7, UUID } from "uuidv7"; import type { MemoryItem, Edge, EventEnvelope, Namespace } from "./types.js"; function validateScore(value: number | undefined, name: string): void { @@ -12,11 +12,22 @@ function validateScore(value: number | undefined, name: string): void { * Returns null for non-UUIDv7 ids. */ function safeExtractTimestamp(id: string): number | null { - const stripped = id.replace(/-/g, ""); - if (stripped.length < 16 || stripped[12] !== "7") return null; - const ts = parseInt(stripped.slice(0, 12), 16); - if (isNaN(ts) || ts <= 0) return null; - return ts; + let parsed: UUID; + try { + parsed = UUID.parse(id); + } catch { + return null; + } + if (parsed.getVersion() !== 7) return null; + const b = parsed.bytes; + const ts = + b[0] * 2 ** 40 + + b[1] * 2 ** 32 + + b[2] * 2 ** 24 + + b[3] * 2 ** 16 + + b[4] * 2 ** 8 + + b[5]; + return ts > 0 ? ts : null; } export function createMemoryItem( @@ -40,11 +51,10 @@ export function createEdge( active?: boolean; }, ): Edge { - if (input.from === input.to) { - throw new Error( - `Self-referencing edge not allowed: from and to are both "${input.from}"`, - ); - } + // Self-referencing edges are permitted. They carry meaning (e.g. a self + // CONTRADICTS marks an internally inconsistent item) and MemEX is supposed + // to tolerate noisy input rather than crash the fold over one degenerate + // event. Downstream traversal/dedup code already handles them. validateScore(input.authority, "authority"); validateScore(input.weight, "weight"); diff --git a/src/index.ts b/src/index.ts index 1f43283..cf09202 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export { EdgeNotFoundError, DuplicateMemoryError, DuplicateEdgeError, + InvalidTimestampError, } from "./errors.js"; export { applyCommand } from "./reducer.js"; export { @@ -60,6 +61,7 @@ export { wrapEdgeStateEvent, } from "./envelope.js"; export { replayCommands, replayFromEnvelopes } from "./replay.js"; +export type { ReplayFailure } from "./replay.js"; export { getContradictions, markContradiction, diff --git a/src/integrity.ts b/src/integrity.ts index 1c485d2..8b7f538 100644 --- a/src/integrity.ts +++ b/src/integrity.ts @@ -48,6 +48,9 @@ export function markContradiction( author: string, meta?: Record, ): { state: GraphState; events: MemoryLifecycleEvent[] } { + // A self-CONTRADICTS edge is meaningful: it represents an internally + // inconsistent/tainted item. Downstream (surfaceContradictions) already + // skips self-edges during annotation, so the edge is safe to record. return applyCommand(state, { type: "edge.create", edge: { @@ -106,9 +109,9 @@ export function resolveContradiction( } if (toRetract.length === 0) { - throw new Error( - `No active CONTRADICTS edge between ${winnerId} and ${loserId}`, - ); + // Nothing to resolve — this is a stale or duplicate call, not a + // structural violation. No-op rather than crash the fold. + return { state: current, events: allEvents }; } // create SUPERSEDES edge @@ -205,26 +208,56 @@ export function cascadeRetract( author: string, reason?: string, ): { state: GraphState; events: MemoryLifecycleEvent[]; retracted: string[] } { - const dependents = getDependents(state, itemId, true); + // Iterative DFS post-order traversal: a valid topological sort (leaves + // before roots) for DAGs with shared children, cycle-safe, and does not + // consume JS call stack on deep dependency chains. + // + // Pre-mark the root as visited so any cycle that points back to it is + // ignored — the root is retracted separately at the end of this function, + // never prematurely as part of the descendants list. + const visited = new Set([itemId]); + const order: string[] = []; + + type Frame = { id: string; phase: "enter" | "exit" }; + const stack: Frame[] = []; + for (const child of getChildren(state, itemId)) { + stack.push({ id: child.id, phase: "enter" }); + } + + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.phase === "exit") { + order.push(frame.id); + continue; + } + if (visited.has(frame.id)) continue; + visited.add(frame.id); + // Push exit first so it's processed after all children (post-order). + stack.push({ id: frame.id, phase: "exit" }); + for (const child of getChildren(state, frame.id)) { + if (!visited.has(child.id)) { + stack.push({ id: child.id, phase: "enter" }); + } + } + } + let current = state; const allEvents: MemoryLifecycleEvent[] = []; const retracted: string[] = []; - // retract dependents first (leaves before roots) - for (const dep of dependents.reverse()) { - if (!current.items.has(dep.id)) continue; + for (const depId of order) { + if (!current.items.has(depId)) continue; const r = applyCommand(current, { type: "memory.retract", - item_id: dep.id, + item_id: depId, author, reason: reason ?? `parent ${itemId} retracted`, }); current = r.state; allEvents.push(...r.events); - retracted.push(dep.id); + retracted.push(depId); } - // retract the item itself if (current.items.has(itemId)) { const r = applyCommand(current, { type: "memory.retract", @@ -255,6 +288,11 @@ export function markAlias( author: string, meta?: Record, ): { state: GraphState; events: MemoryLifecycleEvent[] } { + if (itemIdA === itemIdB) { + // Self-alias is redundant (an item is trivially aliased to itself) + // and would only pollute `getAliases` output. No-op rather than throw. + return { state, events: [] }; + } let current = state; const allEvents: MemoryLifecycleEvent[] = []; @@ -367,14 +405,15 @@ export function getItemsByBudget( for (const entry of scored) { const cost = options.costFn(entry.item); - if (!(cost > 0)) { - throw new RangeError(`costFn must return a positive number, got ${cost}`); + if (cost < 0 || !Number.isFinite(cost)) { + throw new RangeError( + `costFn must return a finite non-negative number, got ${cost}`, + ); } if (cost <= remaining) { results.push(entry); remaining -= cost; } - if (remaining <= 0) break; } return results; diff --git a/src/query.ts b/src/query.ts index f92e6ce..7262860 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,3 +1,4 @@ +import { UUID } from "uuidv7"; import type { GraphState, MemoryItem, @@ -10,6 +11,7 @@ import type { ScoreWeights, ScoredItem, } from "./types.js"; +import { InvalidTimestampError } from "./errors.js"; function resolvePath(obj: unknown, path: string): unknown { let current = obj; @@ -155,11 +157,22 @@ function matchesFilter(item: MemoryItem, filter: MemoryFilter): boolean { * Returns null for non-UUIDv7 ids. */ function safeExtractTimestamp(id: string): number | null { - const stripped = id.replace(/-/g, ""); - if (stripped.length < 16 || stripped[12] !== "7") return null; - const ts = parseInt(stripped.slice(0, 12), 16); - if (isNaN(ts) || ts <= 0) return null; - return ts; + let parsed: UUID; + try { + parsed = UUID.parse(id); + } catch { + return null; + } + if (parsed.getVersion() !== 7) return null; + const b = parsed.bytes; + const ts = + b[0] * 2 ** 40 + + b[1] * 2 ** 32 + + b[2] * 2 ** 24 + + b[3] * 2 ** 16 + + b[4] * 2 ** 8 + + b[5]; + return ts > 0 ? ts : null; } /** @@ -169,7 +182,7 @@ function safeExtractTimestamp(id: string): number | null { export function extractTimestamp(uuidv7Id: string): number { const ts = safeExtractTimestamp(uuidv7Id); if (ts === null) { - throw new Error( + throw new InvalidTimestampError( `Cannot extract timestamp: "${uuidv7Id}" is not a valid UUIDv7`, ); } @@ -179,7 +192,7 @@ export function extractTimestamp(uuidv7Id: string): number { function itemTimestamp(item: MemoryItem): number { const ts = item.created_at ?? safeExtractTimestamp(item.id); if (ts === null || ts === undefined) { - throw new Error( + throw new InvalidTimestampError( `Cannot determine timestamp for item "${item.id}": set created_at or use a UUIDv7 id`, ); } diff --git a/src/reducer.ts b/src/reducer.ts index fd2c287..9110016 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -39,6 +39,7 @@ export function mergeItem( content: partialContent, meta: partialMeta, id: _id, + created_at: _createdAt, ...rest } = partial; return { diff --git a/src/replay.ts b/src/replay.ts index 823811b..c198071 100644 --- a/src/replay.ts +++ b/src/replay.ts @@ -6,27 +6,163 @@ import type { } from "./types.js"; import { createGraphState } from "./graph.js"; import { applyCommand } from "./reducer.js"; +import { InvalidTimestampError } from "./errors.js"; + +/** + * A command or envelope that failed during replay. The rest of the batch + * proceeds — bulk replay is an integrity-tolerant operation. + */ +export interface ReplayFailure { + index: number; + command?: MemoryCommand; + envelope?: EventEnvelope; + error: Error; +} export function replayCommands(commands: MemoryCommand[]): { state: GraphState; events: MemoryLifecycleEvent[]; + skipped: ReplayFailure[]; } { let state = createGraphState(); const allEvents: MemoryLifecycleEvent[] = []; + const skipped: ReplayFailure[] = []; + + for (let i = 0; i < commands.length; i++) { + try { + const result = applyCommand(state, commands[i]); + state = result.state; + allEvents.push(...result.events); + } catch (err) { + skipped.push({ + index: i, + command: commands[i], + error: err instanceof Error ? err : new Error(String(err)), + }); + } + } + + return { state, events: allEvents, skipped }; +} + +// Strict ISO 8601 with milliseconds-only precision and an explicit offset. +// Sub-millisecond precision is rejected because Date.UTC drops it, which +// would collapse distinct timestamps and break chronological replay. We also +// validate calendar fields manually so that impossible dates like 2024-02-31 +// don't silently normalize under Date.parse. +const ISO_8601_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:Z|([+-])(\d{2}):(\d{2}))$/; + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) return isLeapYear(year) ? 29 : 28; + if (month === 4 || month === 6 || month === 9 || month === 11) return 30; + return 31; +} + +function parseIsoTs(ts: string): number { + const m = ISO_8601_RE.exec(ts); + if (!m) { + throw new InvalidTimestampError( + `Invalid envelope timestamp: "${ts}" (expected ISO 8601)`, + ); + } + const year = +m[1]; + const month = +m[2]; + const day = +m[3]; + const hour = +m[4]; + const minute = +m[5]; + const second = +m[6]; + const ms = m[7] ? +m[7].padEnd(3, "0") : 0; + + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 59 + ) { + throw new InvalidTimestampError( + `Invalid envelope timestamp: "${ts}" (calendar fields out of range)`, + ); + } + + // Date.UTC legacy-coerces years 0..99 into 1900..1999; setUTCFullYear + // bypasses that so years like 0050 round-trip correctly. + const date = new Date( + Date.UTC(2000, month - 1, day, hour, minute, second, ms), + ); + date.setUTCFullYear(year); + let epoch = date.getTime(); - for (const cmd of commands) { - const result = applyCommand(state, cmd); - state = result.state; - allEvents.push(...result.events); + if (m[8]) { + const offH = +m[9]; + const offM = +m[10]; + if (offH > 23 || offM > 59) { + throw new InvalidTimestampError( + `Invalid envelope timestamp: "${ts}" (bad offset)`, + ); + } + const sign = m[8] === "-" ? 1 : -1; + epoch += sign * (offH * 60 + offM) * 60 * 1000; } - return { state, events: allEvents }; + return epoch; } export function replayFromEnvelopes( envelopes: EventEnvelope[], -): { state: GraphState; events: MemoryLifecycleEvent[] } { - const sorted = [...envelopes].sort((a, b) => a.ts.localeCompare(b.ts)); - const commands = sorted.map((env) => env.payload); - return replayCommands(commands); +): { + state: GraphState; + events: MemoryLifecycleEvent[]; + skipped: ReplayFailure[]; +} { + const skipped: ReplayFailure[] = []; + const sortable: { + env: EventEnvelope; + ts: number; + index: number; + }[] = []; + + for (let i = 0; i < envelopes.length; i++) { + try { + sortable.push({ + env: envelopes[i], + ts: parseIsoTs(envelopes[i].ts), + index: i, + }); + } catch (err) { + skipped.push({ + index: i, + envelope: envelopes[i], + error: err instanceof Error ? err : new Error(String(err)), + }); + } + } + + sortable.sort((a, b) => a.ts - b.ts); + + let state = createGraphState(); + const allEvents: MemoryLifecycleEvent[] = []; + + for (const { env, index } of sortable) { + try { + const result = applyCommand(state, env.payload); + state = result.state; + allEvents.push(...result.events); + } catch (err) { + skipped.push({ + index, + envelope: env, + error: err instanceof Error ? err : new Error(String(err)), + }); + } + } + + return { state, events: allEvents, skipped }; } diff --git a/src/retrieval.ts b/src/retrieval.ts index 186b514..57415ff 100644 --- a/src/retrieval.ts +++ b/src/retrieval.ts @@ -324,14 +324,15 @@ export function smartRetrieve( for (const entry of scored) { const cost = options.costFn(entry.item); - if (!(cost > 0)) { - throw new RangeError(`costFn must return a positive number, got ${cost}`); + if (cost < 0 || !Number.isFinite(cost)) { + throw new RangeError( + `costFn must return a finite non-negative number, got ${cost}`, + ); } if (cost <= remaining) { results.push(entry); remaining -= cost; } - if (remaining <= 0) break; } return results; diff --git a/src/transplant.ts b/src/transplant.ts index 56a2dd8..6635221 100644 --- a/src/transplant.ts +++ b/src/transplant.ts @@ -269,6 +269,34 @@ export interface ImportReport { }; } +function deepValueEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const aIsArr = Array.isArray(a); + const bIsArr = Array.isArray(b); + if (aIsArr !== bIsArr) return false; + if (aIsArr && bIsArr) { + const arrA = a as unknown[]; + const arrB = b as unknown[]; + if (arrA.length !== arrB.length) return false; + for (let i = 0; i < arrA.length; i++) { + if (!deepValueEqual(arrA[i], arrB[i])) return false; + } + return true; + } + if ( + typeof a === "object" && + a !== null && + typeof b === "object" && + b !== null + ) { + return shallowEqual( + a as Record, + b as Record, + ); + } + return false; +} + function shallowEqual( a: Record, b: Record, @@ -277,50 +305,7 @@ function shallowEqual( const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { - const va = a[key]; - const vb = b[key]; - if (va === vb) continue; - if (Array.isArray(va) && Array.isArray(vb)) { - if (va.length !== vb.length) return false; - for (let i = 0; i < va.length; i++) { - const ai = va[i]; - const bi = vb[i]; - if (ai === bi) continue; - if ( - typeof ai === "object" && - ai !== null && - typeof bi === "object" && - bi !== null && - !Array.isArray(ai) && - !Array.isArray(bi) - ) { - if ( - !shallowEqual( - ai as Record, - bi as Record, - ) - ) - return false; - } else { - return false; - } - } - } else if ( - typeof va === "object" && - va !== null && - typeof vb === "object" && - vb !== null - ) { - if ( - !shallowEqual( - va as Record, - vb as Record, - ) - ) - return false; - } else { - return false; - } + if (!deepValueEqual(a[key], b[key])) return false; } return true; } diff --git a/tests/bugfix-and-coverage.test.ts b/tests/bugfix-and-coverage.test.ts index 7ee2a95..323debe 100644 --- a/tests/bugfix-and-coverage.test.ts +++ b/tests/bugfix-and-coverage.test.ts @@ -375,15 +375,17 @@ describe("decay interval validation", () => { // ============================================================ describe("resolveContradiction edge cases", () => { - it("throws when no CONTRADICTS edge exists between the items", () => { + it("no-ops when no CONTRADICTS edge exists between the items", () => { const state = stateWith([ makeItem("m1", { authority: 0.9 }), makeItem("m2", { authority: 0.7 }), ]); - // no markContradiction first — calling resolve directly should throw - expect(() => - resolveContradiction(state, "m1", "m2", "system:resolver"), - ).toThrow(/No active CONTRADICTS edge/); + // No markContradiction first — resolve should be a silent no-op, + // not crash the fold. Stale/duplicate resolves are expected traffic. + const result = resolveContradiction(state, "m1", "m2", "system:resolver"); + expect(result.events).toHaveLength(0); + expect(result.state.items.get("m1")!.authority).toBe(0.9); + expect(result.state.items.get("m2")!.authority).toBe(0.7); }); }); diff --git a/tests/bugfix-holes.test.ts b/tests/bugfix-holes.test.ts index 7600053..a6ec1f5 100644 --- a/tests/bugfix-holes.test.ts +++ b/tests/bugfix-holes.test.ts @@ -502,13 +502,12 @@ describe("getItemsByBudget with zero-cost items", () => { makeItem("m3", { authority: 0.7 }), ]); - expect(() => - getItemsByBudget(state, { - budget: 5, - costFn: () => 0, - weights: { authority: 1 }, - }), - ).toThrow(RangeError); + const result = getItemsByBudget(state, { + budget: 5, + costFn: () => 0, + weights: { authority: 1 }, + }); + expect(result).toHaveLength(3); }); it("mixes zero-cost and positive-cost items correctly", () => { @@ -518,10 +517,22 @@ describe("getItemsByBudget with zero-cost items", () => { makeItem("m3", { authority: 0.7 }), ]); + const result = getItemsByBudget(state, { + budget: 2, + costFn: (item) => (item.id === "m2" ? 0 : 1), + weights: { authority: 1 }, + }); + const ids = result.map((r) => r.item.id).sort(); + // m1 (cost 1), m2 (cost 0), m3 (cost 1) — all fit within budget 2. + expect(ids).toEqual(["m1", "m2", "m3"]); + }); + + it("rejects negative cost", () => { + const state = stateWith([makeItem("m1", { authority: 0.9 })]); expect(() => getItemsByBudget(state, { - budget: 2, - costFn: (item) => (item.id === "m2" ? 0 : 1), + budget: 5, + costFn: () => -1, weights: { authority: 1 }, }), ).toThrow(RangeError); diff --git a/tests/bugfix-surface-contradictions-dedup.test.ts b/tests/bugfix-surface-contradictions-dedup.test.ts index 40740ea..6e4f30f 100644 --- a/tests/bugfix-surface-contradictions-dedup.test.ts +++ b/tests/bugfix-surface-contradictions-dedup.test.ts @@ -84,10 +84,22 @@ describe("surfaceContradictions dedupes contradicted_by by item id", () => { it("does not annotate self-contradicting items", () => { const m1 = makeItem("m1"); let state = stateWith([m1]); - // A self-edge can sneak in via applyCommand (the createEdge helper rejects - // it but the reducer path doesn't). Ensure it doesn't land in the item's - // own contradicted_by list. - state = markContradiction(state, "m1", "m1", "system:detector").state; + // `markContradiction` rejects self-references, but a self-edge could + // still be introduced by direct `edge.create` commands. Ensure the + // dedup path does not annotate an item with itself. + state = applyCommand(state, { + type: "edge.create", + edge: { + edge_id: "e-self", + from: "m1", + to: "m1", + kind: "CONTRADICTS", + author: "system:detector", + source_kind: "derived_deterministic", + authority: 1, + active: true, + }, + }).state; const scored = toScored([m1], [0.5]); const result = surfaceContradictions(state, scored); diff --git a/tests/bugfix-sweep.test.ts b/tests/bugfix-sweep.test.ts new file mode 100644 index 0000000..f7c2dc3 --- /dev/null +++ b/tests/bugfix-sweep.test.ts @@ -0,0 +1,842 @@ +import { describe, it, expect } from "vitest"; +import { applyCommand, mergeItem } from "../src/reducer.js"; +import { createGraphState } from "../src/graph.js"; +import { createMemoryItem, createEventEnvelope } from "../src/helpers.js"; +import { extractTimestamp } from "../src/query.js"; +import { smartRetrieve, getSupportSet } from "../src/retrieval.js"; +import { + markAlias, + markContradiction, + resolveContradiction, + cascadeRetract, + getAliases, + getItemsByBudget, +} from "../src/integrity.js"; +import { createEdge } from "../src/helpers.js"; +import { + InvalidTimestampError, + MemoryNotFoundError, + DuplicateMemoryError, +} from "../src/errors.js"; +import { replayFromEnvelopes, replayCommands } from "../src/replay.js"; +import { exportSlice, importSlice } from "../src/transplant.js"; +import { createIntentState } from "../src/intent.js"; +import { createTaskState } from "../src/task.js"; +import type { MemoryItem, MemoryCommand, EventEnvelope } from "../src/types.js"; + +const mkItem = (id: string, overrides: Partial = {}): MemoryItem => + createMemoryItem({ + id, + scope: "test", + kind: "observation", + content: {}, + author: "agent:a", + source_kind: "observed", + authority: 0.5, + ...overrides, + }); + +describe("bugfix-sweep: smartRetrieve / getItemsByBudget accept zero-cost items", () => { + it("smartRetrieve does not throw when costFn returns 0", () => { + let state = createGraphState(); + for (let i = 0; i < 3; i++) { + const item = mkItem( + `0190${i.toString().padStart(4, "0")}-0000-7000-8000-000000000000`, + { + importance: 0.5, + }, + ); + state = applyCommand(state, { type: "memory.create", item }).state; + } + const out = smartRetrieve(state, { + budget: 10, + costFn: () => 0, + weights: { importance: 1 }, + }); + expect(out.length).toBe(3); + }); + + it("smartRetrieve still rejects negative cost", () => { + let state = createGraphState(); + const item = mkItem("01900000-0000-7000-8000-000000000001"); + state = applyCommand(state, { type: "memory.create", item }).state; + expect(() => + smartRetrieve(state, { + budget: 10, + costFn: () => -1, + weights: { importance: 1 }, + }), + ).toThrow(RangeError); + }); + + it("getItemsByBudget accepts zero-cost items", () => { + let state = createGraphState(); + const item = mkItem("01900000-0000-7000-8000-000000000002", { + importance: 0.9, + }); + state = applyCommand(state, { type: "memory.create", item }).state; + const out = getItemsByBudget(state, { + budget: 5, + costFn: () => 0, + weights: { importance: 1 }, + }); + expect(out.length).toBe(1); + }); + + it("smartRetrieve still includes zero-cost items after budget exhaustion", () => { + // Two expensive items fill the budget, then three free items must still + // be included. The old `remaining <= 0` early-break would have skipped + // every zero-cost entry after the budget hit zero. + let state = createGraphState(); + const ids = [ + "01900000-0000-7000-8000-00000000f001", + "01900000-0000-7000-8000-00000000f002", + "01900000-0000-7000-8000-00000000f003", + "01900000-0000-7000-8000-00000000f004", + "01900000-0000-7000-8000-00000000f005", + ]; + // Give expensive items higher importance so they sort first. + for (let i = 0; i < ids.length; i++) { + const item = mkItem(ids[i], { importance: i < 2 ? 0.9 : 0.5 }); + state = applyCommand(state, { type: "memory.create", item }).state; + } + const expensive = new Set([ids[0], ids[1]]); + const out = smartRetrieve(state, { + budget: 10, + costFn: (item) => (expensive.has(item.id) ? 5 : 0), + weights: { importance: 1 }, + }); + // All 5 items fit: 5 + 5 + 0 + 0 + 0. + expect(out.length).toBe(5); + }); + + it("getItemsByBudget still includes zero-cost items after budget exhaustion", () => { + let state = createGraphState(); + const ids = [ + "01900000-0000-7000-8000-00000000b001", + "01900000-0000-7000-8000-00000000b002", + "01900000-0000-7000-8000-00000000b003", + ]; + for (let i = 0; i < ids.length; i++) { + const item = mkItem(ids[i], { authority: i === 0 ? 0.9 : 0.5 }); + state = applyCommand(state, { type: "memory.create", item }).state; + } + const out = getItemsByBudget(state, { + budget: 5, + // First item costs exactly the budget, the rest are free. + costFn: (item) => (item.id === ids[0] ? 5 : 0), + weights: { authority: 1 }, + }); + expect(out.length).toBe(3); + }); +}); + +describe("bugfix-sweep: transplant shallowEqual handles nested arrays", () => { + it("treats identical arrays-of-arrays as equal and skips import", () => { + const itemA: MemoryItem = mkItem("01900000-0000-7000-8000-0000000000aa", { + content: { tags: [["x"], ["y", "z"]] } as Record, + }); + let memState = createGraphState(); + memState = applyCommand(memState, { + type: "memory.create", + item: itemA, + }).state; + + const sameItem: MemoryItem = mkItem( + "01900000-0000-7000-8000-0000000000aa", + { content: { tags: [["x"], ["y", "z"]] } as Record }, + ); + + const slice = { + memories: [sameItem], + intents: [], + tasks: [], + edges: [], + } as ReturnType; + + const { report } = importSlice( + memState, + createIntentState(), + createTaskState(), + slice, + { shallowCompareExisting: true, reIdOnDifference: true }, + ); + + expect(report.skipped.memories).toContain(sameItem.id); + expect(report.created.memories).toHaveLength(0); + }); + + it("differing nested arrays still conflict", () => { + const itemA: MemoryItem = mkItem("01900000-0000-7000-8000-0000000000ab", { + content: { tags: [["x"]] } as Record, + }); + let memState = createGraphState(); + memState = applyCommand(memState, { + type: "memory.create", + item: itemA, + }).state; + + const diffItem: MemoryItem = mkItem( + "01900000-0000-7000-8000-0000000000ab", + { content: { tags: [["y"]] } as Record }, + ); + + const slice = { + memories: [diffItem], + intents: [], + tasks: [], + edges: [], + } as ReturnType; + + const { report } = importSlice( + memState, + createIntentState(), + createTaskState(), + slice, + { shallowCompareExisting: true }, + ); + + expect(report.conflicts.memories).toContain(diffItem.id); + }); +}); + +describe("bugfix-sweep: replayFromEnvelopes sorts chronologically, not lexically", () => { + it("orders envelopes by instant even when timezones differ", () => { + const id1 = "01900000-0000-7000-8000-000000000101"; + const id2 = "01900000-0000-7000-8000-000000000102"; + const cmd1: MemoryCommand = { + type: "memory.create", + item: mkItem(id1), + }; + const cmd2: MemoryCommand = { + type: "memory.create", + item: mkItem(id2), + }; + + // Same instant, different wall-clock representation. + // "2024-01-01T10:00:00+02:00" == "2024-01-01T08:00:00Z". + // The first lexically precedes "2024-01-01T09:00:00Z" but represents + // a LATER instant than it. + const envEarly: EventEnvelope = { + id: "e1", + namespace: "memory", + type: "memory.create", + ts: "2024-01-01T09:00:00Z", + payload: cmd1, + }; + const envLate: EventEnvelope = { + id: "e2", + namespace: "memory", + type: "memory.create", + ts: "2024-01-01T10:00:00+02:00", // 08:00Z — earlier instant than envEarly + payload: cmd2, + }; + + const { events } = replayFromEnvelopes([envEarly, envLate]); + // Chronologically envLate (08:00Z) must fire before envEarly (09:00Z). + expect(events[0].type).toBe("memory.created"); + expect((events[0] as { item: MemoryItem }).item.id).toBe(id2); + expect((events[1] as { item: MemoryItem }).item.id).toBe(id1); + }); + + it("collects unparsable timestamps in the skipped list instead of throwing", () => { + const env: EventEnvelope = { + id: "e", + namespace: "memory", + type: "memory.create", + ts: "not-a-date", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000103"), + }, + }; + const env2: EventEnvelope = { + ...env, + id: "f", + ts: "2024-01-01T00:00:00Z", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-0000000001aa"), + }, + }; + const { state, skipped } = replayFromEnvelopes([env, env2]); + // Good envelope applied, bad one collected — pipeline did not crash. + expect(state.items.size).toBe(1); + expect(skipped).toHaveLength(1); + expect(skipped[0].envelope).toBe(env); + expect(skipped[0].error.name).toBe("InvalidTimestampError"); + }); + + it("skips non-ISO timestamps that Date.parse would accept non-deterministically", () => { + // Formats like "Jan 1, 2024" or "2024/01/01" parse on V8 but are + // implementation-defined. Reject them up front and collect as skipped. + for (const bad of [ + "Jan 1, 2024", + "2024/01/01 10:00:00", + "2024-01-01 10:00:00Z", // space instead of T + "2024-01-01T10:00:00", // missing offset + "2024-01-01T10:00:00+0200", // offset without colon + ]) { + const env: EventEnvelope = { + id: "e", + namespace: "memory", + type: "memory.create", + ts: bad, + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000104"), + }, + }; + const { skipped } = replayFromEnvelopes([env]); + expect(skipped, bad).toHaveLength(1); + expect(skipped[0].error.name, bad).toBe("InvalidTimestampError"); + } + }); + + it("skips sub-millisecond precision so distinct timestamps do not collapse", () => { + // `Date.parse` silently truncates anything past the milliseconds place, + // which would collapse distinct instants to the same epoch ms and break + // chronological replay order. + for (const bad of [ + "2024-01-01T00:00:00.0001Z", + "2024-01-01T00:00:00.000001Z", + "2024-01-01T00:00:00.000000001Z", + ]) { + const env: EventEnvelope = { + id: "e", + namespace: "memory", + type: "memory.create", + ts: bad, + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000110"), + }, + }; + const { skipped } = replayFromEnvelopes([env]); + expect(skipped, bad).toHaveLength(1); + } + }); + + it("skips impossible calendar dates that Date.parse would normalize", () => { + // Date.parse("2024-02-31T00:00:00Z") returns a valid number (March 2), + // which would replay the envelope under the wrong date. Reject outright. + for (const bad of [ + "2024-02-30T00:00:00Z", + "2024-02-31T00:00:00Z", + "2023-02-29T00:00:00Z", // not a leap year + "2024-13-01T00:00:00Z", + "2024-00-01T00:00:00Z", + "2024-04-31T00:00:00Z", // April has 30 days + "2024-01-32T00:00:00Z", + "2024-01-00T00:00:00Z", + "2024-01-01T24:00:00Z", + "2024-01-01T00:60:00Z", + "2024-01-01T00:00:61Z", + ]) { + const env: EventEnvelope = { + id: "e", + namespace: "memory", + type: "memory.create", + ts: bad, + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000111"), + }, + }; + const { skipped } = replayFromEnvelopes([env]); + expect(skipped, bad).toHaveLength(1); + } + }); + + it("handles years 0000-0099 without Date.UTC's legacy coercion", () => { + // Date.UTC(50, 0, 1) silently maps to 1950; setUTCFullYear must bypass + // that so an ISO timestamp "0050-01-01" sorts before "1950-01-01". + const oldEnv: EventEnvelope = { + id: "a", + namespace: "memory", + type: "memory.create", + ts: "0050-01-01T00:00:00Z", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000120"), + }, + }; + const modernEnv: EventEnvelope = { + id: "b", + namespace: "memory", + type: "memory.create", + ts: "1950-01-01T00:00:00Z", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000121"), + }, + }; + // Pass modern first to force a sort reorder if the two-digit bug returns. + const { events, skipped } = replayFromEnvelopes([modernEnv, oldEnv]); + expect(skipped).toHaveLength(0); + expect((events[0] as { item: MemoryItem }).item.id).toBe( + "01900000-0000-7000-8000-000000000120", // year 0050 comes first + ); + }); + + it("accepts Feb 29 in a leap year", () => { + const env: EventEnvelope = { + id: "e", + namespace: "memory", + type: "memory.create", + ts: "2024-02-29T12:00:00.500Z", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000112"), + }, + }; + expect(() => replayFromEnvelopes([env])).not.toThrow(); + }); + + it("accepts ISO 8601 with Z and with numeric offset", () => { + const envZ: EventEnvelope = { + id: "a", + namespace: "memory", + type: "memory.create", + ts: "2024-01-01T00:00:00.000Z", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000105"), + }, + }; + const envOffset: EventEnvelope = { + id: "b", + namespace: "memory", + type: "memory.create", + ts: "2024-01-01T02:00:00+02:00", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000106"), + }, + }; + expect(() => replayFromEnvelopes([envZ, envOffset])).not.toThrow(); + }); +}); + +describe("bugfix-sweep: mergeItem does not allow rewriting created_at", () => { + it("preserves created_at when partial attempts to change it", () => { + const item = mkItem("01900000-0000-7000-8000-000000000201", { + created_at: 1_700_000_000_000, + }); + const merged = mergeItem(item, { + authority: 0.9, + created_at: 1, // should be ignored + } as Partial); + expect(merged.created_at).toBe(1_700_000_000_000); + expect(merged.authority).toBe(0.9); + }); + + it("memory.update command cannot rewrite created_at", () => { + const item = mkItem("01900000-0000-7000-8000-000000000202", { + created_at: 1_700_000_000_000, + }); + let state = createGraphState(); + state = applyCommand(state, { type: "memory.create", item }).state; + const res = applyCommand(state, { + type: "memory.update", + item_id: item.id, + partial: { created_at: 42 } as Partial, + author: "tester", + }); + expect(res.state.items.get(item.id)!.created_at).toBe(1_700_000_000_000); + }); +}); + +describe("bugfix-sweep: markAlias / markContradiction soft-handle self-reference", () => { + it("markAlias(a,a) is a silent no-op (self-alias is redundant)", () => { + const id = "01900000-0000-7000-8000-000000000301"; + let state = createGraphState(); + state = applyCommand(state, { + type: "memory.create", + item: mkItem(id), + }).state; + const before = state; + const result = markAlias(state, id, id, "tester"); + // Nothing recorded, state unchanged — no ALIAS edges polluting getAliases. + expect(result.events).toHaveLength(0); + expect(result.state).toBe(before); + }); + + it("markContradiction(a,a) records the self-edge (internal inconsistency)", () => { + const id = "01900000-0000-7000-8000-000000000302"; + let state = createGraphState(); + state = applyCommand(state, { + type: "memory.create", + item: mkItem(id), + }).state; + const result = markContradiction(state, id, id, "tester"); + // A self-CONTRADICTS edge is a meaningful marker: "this item is tainted". + expect(result.events).toHaveLength(1); + const contradictEdges = Array.from(result.state.edges.values()).filter( + (e) => e.kind === "CONTRADICTS", + ); + expect(contradictEdges).toHaveLength(1); + expect(contradictEdges[0].from).toBe(id); + expect(contradictEdges[0].to).toBe(id); + }); + + it("markAlias(a,a) does not pollute getAliases output", () => { + const id = "01900000-0000-7000-8000-000000000303"; + let state = createGraphState(); + state = applyCommand(state, { + type: "memory.create", + item: mkItem(id), + }).state; + state = markAlias(state, id, id, "tester").state; + expect(getAliases(state, id)).toEqual([]); + }); +}); + +describe("bugfix-sweep: soft-failure semantics (record-and-continue)", () => { + it("createEdge permits self-referencing edges", () => { + const edge = createEdge({ + from: "m1", + to: "m1", + kind: "CONTRADICTS", + author: "agent:detector", + source_kind: "derived_deterministic", + authority: 1, + }); + expect(edge.from).toBe("m1"); + expect(edge.to).toBe("m1"); + expect(edge.edge_id).toBeTypeOf("string"); + expect(edge.active).toBe(true); + }); + + it("resolveContradiction is a no-op when no CONTRADICTS edge exists", () => { + const a = "01900000-0000-7000-8000-000000000401"; + const b = "01900000-0000-7000-8000-000000000402"; + let state = createGraphState(); + state = applyCommand(state, { + type: "memory.create", + item: mkItem(a, { authority: 0.9 }), + }).state; + state = applyCommand(state, { + type: "memory.create", + item: mkItem(b, { authority: 0.7 }), + }).state; + + const result = resolveContradiction(state, a, b, "agent:resolver"); + // Nothing should have happened: no SUPERSEDES edge, no authority change. + expect(result.events).toHaveLength(0); + expect(result.state.items.get(a)!.authority).toBe(0.9); + expect(result.state.items.get(b)!.authority).toBe(0.7); + const supersedes = Array.from(result.state.edges.values()).filter( + (e) => e.kind === "SUPERSEDES", + ); + expect(supersedes).toHaveLength(0); + }); + + it("resolveContradiction handles duplicate calls gracefully", () => { + const a = "01900000-0000-7000-8000-000000000403"; + const b = "01900000-0000-7000-8000-000000000404"; + let state = createGraphState(); + state = applyCommand(state, { + type: "memory.create", + item: mkItem(a, { authority: 0.9 }), + }).state; + state = applyCommand(state, { + type: "memory.create", + item: mkItem(b, { authority: 0.7 }), + }).state; + state = markContradiction(state, a, b, "detector").state; + + // First resolve succeeds, second is a stale duplicate. + const r1 = resolveContradiction(state, a, b, "agent:resolver"); + expect(r1.events.length).toBeGreaterThan(0); + const r2 = resolveContradiction(r1.state, a, b, "agent:resolver"); + expect(r2.events).toHaveLength(0); // no crash, no-op + expect(r2.state).toBe(r1.state); + }); +}); + +describe("bugfix-sweep: bulk replay is soft — log-and-continue, not crash", () => { + it("mix of good and bad envelopes produces a partial state + skipped list", () => { + const good1 = mkItem("01900000-0000-7000-8000-000000000701"); + const good2 = mkItem("01900000-0000-7000-8000-000000000702"); + const envs: EventEnvelope[] = [ + { + id: "a", + namespace: "memory", + type: "memory.create", + ts: "2024-01-01T00:00:00Z", + payload: { type: "memory.create", item: good1 }, + }, + { + id: "b", + namespace: "memory", + type: "memory.create", + ts: "garbage", + payload: { type: "memory.create", item: mkItem("x") }, + }, + { + id: "c", + namespace: "memory", + type: "memory.create", + ts: "2024-01-02T00:00:00Z", + payload: { type: "memory.create", item: good2 }, + }, + ]; + const { state, skipped } = replayFromEnvelopes(envs); + expect(state.items.size).toBe(2); + expect(state.items.has(good1.id)).toBe(true); + expect(state.items.has(good2.id)).toBe(true); + expect(skipped).toHaveLength(1); + expect(skipped[0].error).toBeInstanceOf(InvalidTimestampError); + }); + + it("records apply failures (e.g. DuplicateMemoryError) without aborting", () => { + const item = mkItem("01900000-0000-7000-8000-000000000703"); + const envs: EventEnvelope[] = [ + { + id: "a", + namespace: "memory", + type: "memory.create", + ts: "2024-01-01T00:00:00Z", + payload: { type: "memory.create", item }, + }, + { + id: "b", + namespace: "memory", + type: "memory.create", + ts: "2024-01-02T00:00:00Z", + payload: { type: "memory.create", item }, // duplicate id + }, + { + id: "c", + namespace: "memory", + type: "memory.create", + ts: "2024-01-03T00:00:00Z", + payload: { + type: "memory.create", + item: mkItem("01900000-0000-7000-8000-000000000704"), + }, + }, + ]; + const { state, skipped } = replayFromEnvelopes(envs); + expect(state.items.size).toBe(2); + expect(skipped).toHaveLength(1); + expect(skipped[0].error).toBeInstanceOf(DuplicateMemoryError); + }); + + it("replayCommands collects per-command failures and continues", () => { + const item1 = mkItem("01900000-0000-7000-8000-000000000705"); + const item2 = mkItem("01900000-0000-7000-8000-000000000706"); + const commands: MemoryCommand[] = [ + { type: "memory.create", item: item1 }, + { + type: "memory.update", + item_id: "missing", + partial: { authority: 0.1 }, + author: "tester", + }, + { type: "memory.create", item: item2 }, + ]; + const { state, skipped } = replayCommands(commands); + expect(state.items.size).toBe(2); + expect(skipped).toHaveLength(1); + expect(skipped[0].index).toBe(1); + expect(skipped[0].error).toBeInstanceOf(MemoryNotFoundError); + }); + + it("empty input returns empty skipped list", () => { + const r1 = replayFromEnvelopes([]); + expect(r1.skipped).toEqual([]); + const r2 = replayCommands([]); + expect(r2.skipped).toEqual([]); + }); +}); + +describe("bugfix-sweep: extractTimestamp requires a true UUIDv7", () => { + it("rejects malformed 16-character strings with InvalidTimestampError", () => { + expect(() => extractTimestamp("abcdefghijkl7mno")).toThrow( + InvalidTimestampError, + ); + }); + + it("rejects non-hex characters even with correct length", () => { + const id = "zzzzzzzz-zzzz-7zzz-8zzz-zzzzzzzzzzzz"; + expect(() => extractTimestamp(id)).toThrow(InvalidTimestampError); + }); + + it("rejects UUIDs with the wrong version byte", () => { + const id = "00000000-0000-4000-8000-000000000000"; + expect(() => extractTimestamp(id)).toThrow(InvalidTimestampError); + }); + + it("accepts a well-formed UUIDv7", () => { + // 0x018bbd1b3000 == 1_699_684_757_504 ms + const id = "018bbd1b-3000-7000-8000-000000000000"; + expect(extractTimestamp(id)).toBe(1_699_684_757_504); + }); + + it("thrown error is an Error subclass so generic catch still works", () => { + let caught: unknown; + try { + extractTimestamp("not-a-uuid"); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect(caught).toBeInstanceOf(InvalidTimestampError); + expect((caught as Error).name).toBe("InvalidTimestampError"); + }); +}); + +describe("bugfix-sweep: cascadeRetract produces a valid topological order for DAGs", () => { + it("retracts shared grandchild before its two parents", () => { + // A is root. B and C are children of A. D is a child of both B and C. + // Expected retract order: D before B and C, then A last. + const A = "01900000-0000-7000-8000-000000000401"; + const B = "01900000-0000-7000-8000-000000000402"; + const C = "01900000-0000-7000-8000-000000000403"; + const D = "01900000-0000-7000-8000-000000000404"; + + let state = createGraphState(); + state = applyCommand(state, { + type: "memory.create", + item: mkItem(A), + }).state; + state = applyCommand(state, { + type: "memory.create", + item: mkItem(B, { parents: [A] }), + }).state; + state = applyCommand(state, { + type: "memory.create", + item: mkItem(C, { parents: [A] }), + }).state; + state = applyCommand(state, { + type: "memory.create", + item: mkItem(D, { parents: [B, C] }), + }).state; + + const res = cascadeRetract(state, A, "tester"); + + // D must appear before B and C; A must be last. + const idxD = res.retracted.indexOf(D); + const idxB = res.retracted.indexOf(B); + const idxC = res.retracted.indexOf(C); + const idxA = res.retracted.indexOf(A); + + expect(idxD).toBeGreaterThanOrEqual(0); + expect(idxD).toBeLessThan(idxB); + expect(idxD).toBeLessThan(idxC); + expect(idxA).toBe(res.retracted.length - 1); + + // all items should be gone from state + expect(res.state.items.has(A)).toBe(false); + expect(res.state.items.has(B)).toBe(false); + expect(res.state.items.has(C)).toBe(false); + expect(res.state.items.has(D)).toBe(false); + }); + + it("does not loop on cyclic parent references", () => { + // Not normally reachable via applyCommand (create order prevents cycles), + // but the DFS should terminate regardless if the shape somehow appears. + const X = "01900000-0000-7000-8000-000000000501"; + const Y = "01900000-0000-7000-8000-000000000502"; + + const x = mkItem(X, { parents: [Y] }); + const y = mkItem(Y, { parents: [X] }); + // Bypass reducer to construct a cycle directly. + const state = { + items: new Map([ + [X, x], + [Y, y], + ]), + edges: new Map(), + }; + const res = cascadeRetract(state, X, "tester"); + // Must terminate, and must retract both. + expect(res.state.items.size).toBe(0); + }); + + it("handles cycles that point back to the root without retracting root prematurely", () => { + // A is the root. B is a child of A. A is listed as a child of B (cycle + // back through the root). A must be retracted LAST — the pre-marked + // visited set prevents the cycle from re-adding A into the descendants + // ordering. + const A = "01900000-0000-7000-8000-000000000510"; + const B = "01900000-0000-7000-8000-000000000511"; + + const a = mkItem(A, { parents: [B] }); + const b = mkItem(B, { parents: [A] }); + const state = { + items: new Map([ + [A, a], + [B, b], + ]), + edges: new Map(), + }; + + const res = cascadeRetract(state, A, "tester"); + // A must be the final retract; B must come first. + expect(res.retracted).toEqual([B, A]); + expect(res.state.items.size).toBe(0); + }); + + it( + "handles deep dependency chains without stack overflow", + { timeout: 30_000 }, + () => { + // A chain of 5,000 nodes is deep enough to blow Node's default call + // stack on a recursive DFS (empirically ~10k with simple frames, less + // with closures / try-catch). The iterative traversal must handle it. + // getChildren scans the item map, so cascade is O(N^2); keep N bounded. + const N = 5_000; + const ids: string[] = []; + const items = new Map(); + for (let i = 0; i < N; i++) { + const id = `chain-${i.toString().padStart(6, "0")}`; + ids.push(id); + const item = mkItem(id, { + created_at: 1_700_000_000_000 + i, + parents: i === 0 ? undefined : [ids[i - 1]], + }); + items.set(id, item); + } + const state = { items, edges: new Map() }; + + const res = cascadeRetract(state, ids[0], "tester"); + expect(res.retracted).toHaveLength(N); + expect(res.retracted[res.retracted.length - 1]).toBe(ids[0]); + expect(res.retracted[0]).toBe(ids[N - 1]); + expect(res.state.items.size).toBe(0); + }, + ); +}); + +describe("bugfix-sweep: createEventEnvelope still produces parseable timestamps", () => { + it("envelope ts round-trips through replay", () => { + const id = "01900000-0000-7000-8000-000000000601"; + const cmd: MemoryCommand = { + type: "memory.create", + item: mkItem(id), + }; + const env = createEventEnvelope("memory.create", cmd); + const { state } = replayFromEnvelopes([env]); + expect(state.items.has(id)).toBe(true); + }); +}); + +// Sanity: the UUIDv7 parser we now use accepts canonical form and rejects junk +describe("bugfix-sweep: getSupportSet tolerates items with non-UUIDv7 string ids", () => { + it("works when items use arbitrary string ids (created_at supplied explicitly)", () => { + const a = mkItem("custom-id-root", { created_at: 1_700_000_000_000 }); + const b = mkItem("custom-id-child", { + parents: ["custom-id-root"], + created_at: 1_700_000_000_001, + }); + let state = createGraphState(); + state = applyCommand(state, { type: "memory.create", item: a }).state; + state = applyCommand(state, { type: "memory.create", item: b }).state; + const support = getSupportSet(state, b.id); + const ids = support.map((i) => i.id).sort(); + expect(ids).toEqual(["custom-id-child", "custom-id-root"]); + }); +}); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 4cfa5fa..446278d 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -115,10 +115,11 @@ describe("createEdge", () => { expect(edge.weight).toBe(0.5); }); - it("throws Error when from equals to (self-referencing edge)", () => { - expect(() => createEdge({ ...base, from: "m1", to: "m1" })).toThrow( - "Self-referencing edge not allowed", - ); + it("allows self-referencing edges (MemEX records anomalies, does not crash)", () => { + const edge = createEdge({ ...base, from: "m1", to: "m1" }); + expect(edge.from).toBe("m1"); + expect(edge.to).toBe("m1"); + expect(edge.active).toBe(true); }); }); diff --git a/tests/integrity.test.ts b/tests/integrity.test.ts index 156d68f..406ed2b 100644 --- a/tests/integrity.test.ts +++ b/tests/integrity.test.ts @@ -369,15 +369,14 @@ describe("getItemsByBudget", () => { expect(result[1].item.id).toBe("m3"); }); - it("throws RangeError when costFn returns 0", () => { + it("accepts zero-cost items (free/cached entries)", () => { const state = stateWith([makeItem("m1", { authority: 0.9 })]); - expect(() => - getItemsByBudget(state, { - budget: 100, - costFn: () => 0, - weights: { authority: 1 }, - }), - ).toThrow(RangeError); + const result = getItemsByBudget(state, { + budget: 100, + costFn: () => 0, + weights: { authority: 1 }, + }); + expect(result).toHaveLength(1); }); it("throws RangeError when costFn returns negative value", () => { diff --git a/tests/replay.test.ts b/tests/replay.test.ts index bb4cd28..e4951c2 100644 --- a/tests/replay.test.ts +++ b/tests/replay.test.ts @@ -76,7 +76,7 @@ describe("replayCommands", () => { expect(state.edges.size).toBe(1); }); - it("throws at point of failure for invalid command", () => { + it("collects per-command failures as skipped entries rather than crashing", () => { const commands: MemoryCommand[] = [ { type: "memory.create", item: item1 }, { @@ -85,8 +85,14 @@ describe("replayCommands", () => { partial: { authority: 0.1 }, author: "test", }, + { type: "memory.create", item: item2 }, ]; - expect(() => replayCommands(commands)).toThrow(MemoryNotFoundError); + const { state, skipped } = replayCommands(commands); + // First and third commands succeed; the bad update is collected. + expect(state.items.size).toBe(2); + expect(skipped).toHaveLength(1); + expect(skipped[0].index).toBe(1); + expect(skipped[0].error).toBeInstanceOf(MemoryNotFoundError); }); }); diff --git a/tests/retrieval.test.ts b/tests/retrieval.test.ts index 331a6db..27a098b 100644 --- a/tests/retrieval.test.ts +++ b/tests/retrieval.test.ts @@ -678,15 +678,14 @@ describe("smartRetrieve", () => { expect(result[1].item.id).toBe("m3"); }); - it("throws RangeError when costFn returns 0", () => { + it("accepts zero-cost items (free/cached entries)", () => { const state = stateWith([makeItem("m1", { authority: 0.9 })]); - expect(() => - smartRetrieve(state, { - budget: 100, - costFn: () => 0, - weights: { authority: 1 }, - }), - ).toThrow(RangeError); + const result = smartRetrieve(state, { + budget: 100, + costFn: () => 0, + weights: { authority: 1 }, + }); + expect(result).toHaveLength(1); }); it("throws RangeError when costFn returns negative value", () => {