Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
23 changes: 17 additions & 6 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(
Expand Down
48 changes: 38 additions & 10 deletions src/integrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export function markContradiction(
author: string,
meta?: Record<string, unknown>,
): { state: GraphState; events: MemoryLifecycleEvent[] } {
if (itemIdA === itemIdB) {
throw new Error(
`Self-contradiction not allowed: both ids are "${itemIdA}"`,
);
}
return applyCommand(state, {
type: "edge.create",
edge: {
Expand Down Expand Up @@ -205,26 +210,45 @@ export function cascadeRetract(
author: string,
reason?: string,
): { state: GraphState; events: MemoryLifecycleEvent[]; retracted: string[] } {
const dependents = getDependents(state, itemId, true);
// DFS post-order traversal gives a valid topological sort (leaves before
// roots) even when descendants form a DAG with shared children.
const visited = new Set<string>();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const onStack = new Set<string>();
const order: string[] = [];

const visit = (id: string): void => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (visited.has(id)) return;
if (onStack.has(id)) return; // cycle: break without revisiting
onStack.add(id);
for (const child of getChildren(state, id)) {
visit(child.id);
}
onStack.delete(id);
visited.add(id);
order.push(id);
};

for (const child of getChildren(state, itemId)) {
visit(child.id);
}

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",
Expand Down Expand Up @@ -255,6 +279,9 @@ export function markAlias(
author: string,
meta?: Record<string, unknown>,
): { state: GraphState; events: MemoryLifecycleEvent[] } {
if (itemIdA === itemIdB) {
throw new Error(`Self-alias not allowed: both ids are "${itemIdA}"`);
}
let current = state;
const allEvents: MemoryLifecycleEvent[] = [];

Expand Down Expand Up @@ -367,14 +394,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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
remaining -= cost;
}
if (remaining <= 0) break;
}

return results;
Expand Down
22 changes: 17 additions & 5 deletions src/query.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UUID } from "uuidv7";
import type {
GraphState,
MemoryItem,
Expand Down Expand Up @@ -155,11 +156,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;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function mergeItem(
content: partialContent,
meta: partialMeta,
id: _id,
created_at: _createdAt,
...rest
} = partial;
return {
Expand Down
65 changes: 63 additions & 2 deletions src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,71 @@ export function replayCommands(commands: MemoryCommand[]): {
return { state, events: allEvents };
}

// 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 Error(`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 Error(
`Invalid envelope timestamp: "${ts}" (calendar fields out of range)`,
);
}

let epoch = Date.UTC(year, month - 1, day, hour, minute, second, ms);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

if (m[8]) {
const offH = +m[9];
const offM = +m[10];
if (offH > 23 || offM > 59) {
throw new Error(`Invalid envelope timestamp: "${ts}" (bad offset)`);
}
const sign = m[8] === "-" ? 1 : -1;
epoch += sign * (offH * 60 + offM) * 60 * 1000;
}

return epoch;
}

export function replayFromEnvelopes(
envelopes: EventEnvelope<MemoryCommand>[],
): { state: GraphState; events: MemoryLifecycleEvent[] } {
const sorted = [...envelopes].sort((a, b) => a.ts.localeCompare(b.ts));
const commands = sorted.map((env) => env.payload);
const indexed = envelopes.map((env) => ({ env, ts: parseIsoTs(env.ts) }));
indexed.sort((a, b) => a.ts - b.ts);
const commands = indexed.map(({ env }) => env.payload);
return replayCommands(commands);
}
7 changes: 4 additions & 3 deletions src/retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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;
Expand Down
73 changes: 29 additions & 44 deletions src/transplant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
b as Record<string, unknown>,
);
}
return false;
}

function shallowEqual(
a: Record<string, unknown>,
b: Record<string, unknown>,
Expand All @@ -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<string, unknown>,
bi as Record<string, unknown>,
)
)
return false;
} else {
return false;
}
}
} else if (
typeof va === "object" &&
va !== null &&
typeof vb === "object" &&
vb !== null
) {
if (
!shallowEqual(
va as Record<string, unknown>,
vb as Record<string, unknown>,
)
)
return false;
} else {
return false;
}
if (!deepValueEqual(a[key], b[key])) return false;
}
return true;
}
Expand Down
29 changes: 20 additions & 9 deletions tests/bugfix-holes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
Expand Down
Loading