@ai2070/memex 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +35 -0
- package/API.md +1078 -0
- package/LICENSE +190 -0
- package/README.md +574 -0
- package/package.json +30 -0
- package/src/bulk.ts +128 -0
- package/src/envelope.ts +52 -0
- package/src/errors.ts +27 -0
- package/src/graph.ts +15 -0
- package/src/helpers.ts +51 -0
- package/src/index.ts +142 -0
- package/src/integrity.ts +378 -0
- package/src/intent.ts +311 -0
- package/src/query.ts +357 -0
- package/src/reducer.ts +177 -0
- package/src/replay.ts +32 -0
- package/src/retrieval.ts +306 -0
- package/src/serialization.ts +34 -0
- package/src/stats.ts +62 -0
- package/src/task.ts +373 -0
- package/src/transplant.ts +488 -0
- package/src/types.ts +248 -0
- package/tests/bugfix-and-coverage.test.ts +958 -0
- package/tests/bugfix-holes.test.ts +856 -0
- package/tests/bulk.test.ts +256 -0
- package/tests/edge-cases-v2.test.ts +355 -0
- package/tests/edge-cases.test.ts +661 -0
- package/tests/envelope.test.ts +92 -0
- package/tests/graph.test.ts +41 -0
- package/tests/helpers.test.ts +120 -0
- package/tests/integrity.test.ts +371 -0
- package/tests/intent.test.ts +276 -0
- package/tests/query-advanced.test.ts +252 -0
- package/tests/query.test.ts +623 -0
- package/tests/reducer.test.ts +342 -0
- package/tests/replay.test.ts +145 -0
- package/tests/retrieval.test.ts +691 -0
- package/tests/serialization.test.ts +118 -0
- package/tests/setup.test.ts +7 -0
- package/tests/stats.test.ts +163 -0
- package/tests/task.test.ts +322 -0
- package/tests/transplant.test.ts +385 -0
- package/tests/types.test.ts +231 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
wrapLifecycleEvent,
|
|
4
|
+
wrapStateEvent,
|
|
5
|
+
wrapEdgeStateEvent,
|
|
6
|
+
} from "../src/envelope.js";
|
|
7
|
+
import type { MemoryItem, Edge, MemoryLifecycleEvent } from "../src/types.js";
|
|
8
|
+
|
|
9
|
+
const UUID_RE =
|
|
10
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
11
|
+
|
|
12
|
+
const item: MemoryItem = {
|
|
13
|
+
id: "m1",
|
|
14
|
+
scope: "test",
|
|
15
|
+
kind: "observation",
|
|
16
|
+
content: {},
|
|
17
|
+
author: "test",
|
|
18
|
+
source_kind: "observed",
|
|
19
|
+
authority: 1,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const edge: Edge = {
|
|
23
|
+
edge_id: "e1",
|
|
24
|
+
from: "m1",
|
|
25
|
+
to: "m2",
|
|
26
|
+
kind: "SUPPORTS",
|
|
27
|
+
author: "test",
|
|
28
|
+
source_kind: "observed",
|
|
29
|
+
authority: 1,
|
|
30
|
+
active: true,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("wrapLifecycleEvent", () => {
|
|
34
|
+
it("produces correct namespace, type, and valid id", () => {
|
|
35
|
+
const event: MemoryLifecycleEvent = {
|
|
36
|
+
namespace: "memory",
|
|
37
|
+
type: "memory.created",
|
|
38
|
+
item,
|
|
39
|
+
cause_type: "memory.create",
|
|
40
|
+
};
|
|
41
|
+
const env = wrapLifecycleEvent(event, "cmd-123");
|
|
42
|
+
expect(env.id).toMatch(UUID_RE);
|
|
43
|
+
expect(env.namespace).toBe("memory");
|
|
44
|
+
expect(env.type).toBe("memory.created");
|
|
45
|
+
expect(new Date(env.ts).toISOString()).toBe(env.ts);
|
|
46
|
+
expect(env.payload.cause_id).toBe("cmd-123");
|
|
47
|
+
expect(env.payload.item).toEqual(item);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("propagates trace_id", () => {
|
|
51
|
+
const event: MemoryLifecycleEvent = {
|
|
52
|
+
namespace: "memory",
|
|
53
|
+
type: "memory.updated",
|
|
54
|
+
item,
|
|
55
|
+
};
|
|
56
|
+
const env = wrapLifecycleEvent(event, "cmd-1", "trace-abc");
|
|
57
|
+
expect(env.trace_id).toBe("trace-abc");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("omits trace_id when not provided", () => {
|
|
61
|
+
const event: MemoryLifecycleEvent = {
|
|
62
|
+
namespace: "memory",
|
|
63
|
+
type: "memory.retracted",
|
|
64
|
+
item,
|
|
65
|
+
};
|
|
66
|
+
const env = wrapLifecycleEvent(event, "cmd-1");
|
|
67
|
+
expect(env.trace_id).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("wrapStateEvent", () => {
|
|
72
|
+
it("produces state.memory envelope with cause_id", () => {
|
|
73
|
+
const env = wrapStateEvent(item, "cmd-456");
|
|
74
|
+
expect(env.type).toBe("state.memory");
|
|
75
|
+
expect(env.payload.item).toEqual(item);
|
|
76
|
+
expect(env.payload.cause_id).toBe("cmd-456");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("propagates trace_id", () => {
|
|
80
|
+
const env = wrapStateEvent(item, "cmd-1", "trace-xyz");
|
|
81
|
+
expect(env.trace_id).toBe("trace-xyz");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("wrapEdgeStateEvent", () => {
|
|
86
|
+
it("produces state.edge envelope with cause_id", () => {
|
|
87
|
+
const env = wrapEdgeStateEvent(edge, "cmd-789");
|
|
88
|
+
expect(env.type).toBe("state.edge");
|
|
89
|
+
expect(env.payload.edge).toEqual(edge);
|
|
90
|
+
expect(env.payload.cause_id).toBe("cmd-789");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createGraphState, cloneGraphState } from "../src/graph.js";
|
|
3
|
+
import type { MemoryItem } from "../src/types.js";
|
|
4
|
+
|
|
5
|
+
describe("createGraphState", () => {
|
|
6
|
+
it("returns empty Maps", () => {
|
|
7
|
+
const state = createGraphState();
|
|
8
|
+
expect(state.items).toBeInstanceOf(Map);
|
|
9
|
+
expect(state.edges).toBeInstanceOf(Map);
|
|
10
|
+
expect(state.items.size).toBe(0);
|
|
11
|
+
expect(state.edges.size).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("cloneGraphState", () => {
|
|
16
|
+
it("returns a new object with new Map references", () => {
|
|
17
|
+
const original = createGraphState();
|
|
18
|
+
const cloned = cloneGraphState(original);
|
|
19
|
+
expect(cloned).not.toBe(original);
|
|
20
|
+
expect(cloned.items).not.toBe(original.items);
|
|
21
|
+
expect(cloned.edges).not.toBe(original.edges);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("mutation of clone does not affect original", () => {
|
|
25
|
+
const original = createGraphState();
|
|
26
|
+
const item: MemoryItem = {
|
|
27
|
+
id: "m1",
|
|
28
|
+
scope: "test",
|
|
29
|
+
kind: "observation",
|
|
30
|
+
content: {},
|
|
31
|
+
author: "test",
|
|
32
|
+
source_kind: "observed",
|
|
33
|
+
authority: 1,
|
|
34
|
+
};
|
|
35
|
+
original.items.set("m1", item);
|
|
36
|
+
const cloned = cloneGraphState(original);
|
|
37
|
+
cloned.items.delete("m1");
|
|
38
|
+
expect(original.items.has("m1")).toBe(true);
|
|
39
|
+
expect(cloned.items.has("m1")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createMemoryItem,
|
|
4
|
+
createEdge,
|
|
5
|
+
createEventEnvelope,
|
|
6
|
+
} from "../src/helpers.js";
|
|
7
|
+
|
|
8
|
+
const UUID_RE =
|
|
9
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
10
|
+
|
|
11
|
+
describe("createMemoryItem", () => {
|
|
12
|
+
const base = {
|
|
13
|
+
scope: "test",
|
|
14
|
+
kind: "observation" as const,
|
|
15
|
+
content: { key: "value" },
|
|
16
|
+
author: "user:laz",
|
|
17
|
+
source_kind: "observed" as const,
|
|
18
|
+
authority: 0.9,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
it("generates a valid uuidv7 id", () => {
|
|
22
|
+
const item = createMemoryItem(base);
|
|
23
|
+
expect(item.id).toMatch(UUID_RE);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("preserves a caller-supplied id", () => {
|
|
27
|
+
const item = createMemoryItem({ ...base, id: "custom-id" });
|
|
28
|
+
expect(item.id).toBe("custom-id");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("throws RangeError for authority > 1", () => {
|
|
32
|
+
expect(() => createMemoryItem({ ...base, authority: 1.5 })).toThrow(
|
|
33
|
+
RangeError,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("throws RangeError for authority < 0", () => {
|
|
38
|
+
expect(() => createMemoryItem({ ...base, authority: -0.1 })).toThrow(
|
|
39
|
+
RangeError,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("throws RangeError for conviction out of range", () => {
|
|
44
|
+
expect(() => createMemoryItem({ ...base, conviction: 2 })).toThrow(
|
|
45
|
+
RangeError,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("throws RangeError for importance out of range", () => {
|
|
50
|
+
expect(() => createMemoryItem({ ...base, importance: -1 })).toThrow(
|
|
51
|
+
RangeError,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("accepts undefined optional scores", () => {
|
|
56
|
+
const item = createMemoryItem(base);
|
|
57
|
+
expect(item.conviction).toBeUndefined();
|
|
58
|
+
expect(item.importance).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("preserves all input fields", () => {
|
|
62
|
+
const item = createMemoryItem({
|
|
63
|
+
...base,
|
|
64
|
+
conviction: 0.8,
|
|
65
|
+
importance: 0.5,
|
|
66
|
+
meta: { agent_id: "agent:x" },
|
|
67
|
+
});
|
|
68
|
+
expect(item.conviction).toBe(0.8);
|
|
69
|
+
expect(item.importance).toBe(0.5);
|
|
70
|
+
expect(item.meta?.agent_id).toBe("agent:x");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("createEdge", () => {
|
|
75
|
+
const base = {
|
|
76
|
+
from: "m1",
|
|
77
|
+
to: "m2",
|
|
78
|
+
kind: "SUPPORTS" as const,
|
|
79
|
+
author: "system:rule",
|
|
80
|
+
source_kind: "derived_deterministic" as const,
|
|
81
|
+
authority: 0.9,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
it("generates a uuidv7 id and defaults active to true", () => {
|
|
85
|
+
const edge = createEdge(base);
|
|
86
|
+
expect(edge.edge_id).toMatch(UUID_RE);
|
|
87
|
+
expect(edge.active).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("preserves caller-supplied edge_id and active", () => {
|
|
91
|
+
const edge = createEdge({ ...base, edge_id: "e-custom", active: false });
|
|
92
|
+
expect(edge.edge_id).toBe("e-custom");
|
|
93
|
+
expect(edge.active).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("throws RangeError for authority out of range", () => {
|
|
97
|
+
expect(() => createEdge({ ...base, authority: 1.01 })).toThrow(RangeError);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("createEventEnvelope", () => {
|
|
102
|
+
it("produces correct namespace, valid ts, and uuidv7 id", () => {
|
|
103
|
+
const env = createEventEnvelope("memory.create", { test: true });
|
|
104
|
+
expect(env.id).toMatch(UUID_RE);
|
|
105
|
+
expect(env.namespace).toBe("memory");
|
|
106
|
+
expect(env.type).toBe("memory.create");
|
|
107
|
+
expect(new Date(env.ts).toISOString()).toBe(env.ts);
|
|
108
|
+
expect(env.payload).toEqual({ test: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("includes trace_id when provided", () => {
|
|
112
|
+
const env = createEventEnvelope("test", null, { trace_id: "t-123" });
|
|
113
|
+
expect(env.trace_id).toBe("t-123");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("omits trace_id when not provided", () => {
|
|
117
|
+
const env = createEventEnvelope("test", null);
|
|
118
|
+
expect(env.trace_id).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getContradictions,
|
|
4
|
+
markContradiction,
|
|
5
|
+
resolveContradiction,
|
|
6
|
+
getStaleItems,
|
|
7
|
+
getDependents,
|
|
8
|
+
cascadeRetract,
|
|
9
|
+
markAlias,
|
|
10
|
+
getAliases,
|
|
11
|
+
getAliasGroup,
|
|
12
|
+
getItemsByBudget,
|
|
13
|
+
} from "../src/integrity.js";
|
|
14
|
+
import { applyCommand } from "../src/reducer.js";
|
|
15
|
+
import { createGraphState } from "../src/graph.js";
|
|
16
|
+
import type { MemoryItem, Edge, GraphState } from "../src/types.js";
|
|
17
|
+
|
|
18
|
+
// -- helpers --
|
|
19
|
+
|
|
20
|
+
const makeItem = (
|
|
21
|
+
id: string,
|
|
22
|
+
overrides: Partial<MemoryItem> = {},
|
|
23
|
+
): MemoryItem => ({
|
|
24
|
+
id,
|
|
25
|
+
scope: "test",
|
|
26
|
+
kind: "observation",
|
|
27
|
+
content: {},
|
|
28
|
+
author: "user:laz",
|
|
29
|
+
source_kind: "observed",
|
|
30
|
+
authority: 0.8,
|
|
31
|
+
...overrides,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function stateWith(items: MemoryItem[], edges: Edge[] = []): GraphState {
|
|
35
|
+
const s = createGraphState();
|
|
36
|
+
for (const i of items) s.items.set(i.id, i);
|
|
37
|
+
for (const e of edges) s.edges.set(e.edge_id, e);
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function addItem(state: GraphState, item: MemoryItem): GraphState {
|
|
42
|
+
return applyCommand(state, { type: "memory.create", item }).state;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================
|
|
46
|
+
// 1. Temporal forking — contradiction detection & resolution
|
|
47
|
+
// ============================================================
|
|
48
|
+
|
|
49
|
+
describe("contradictions", () => {
|
|
50
|
+
it("markContradiction creates a CONTRADICTS edge", () => {
|
|
51
|
+
const state = stateWith([makeItem("m1"), makeItem("m2")]);
|
|
52
|
+
const { state: next, events } = markContradiction(
|
|
53
|
+
state,
|
|
54
|
+
"m1",
|
|
55
|
+
"m2",
|
|
56
|
+
"system:detector",
|
|
57
|
+
);
|
|
58
|
+
const edges = [...next.edges.values()];
|
|
59
|
+
expect(edges).toHaveLength(1);
|
|
60
|
+
expect(edges[0].kind).toBe("CONTRADICTS");
|
|
61
|
+
expect(edges[0].from).toBe("m1");
|
|
62
|
+
expect(edges[0].to).toBe("m2");
|
|
63
|
+
expect(events[0].type).toBe("edge.created");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("getContradictions finds contradiction pairs", () => {
|
|
67
|
+
const state = stateWith([makeItem("m1"), makeItem("m2")]);
|
|
68
|
+
const { state: next } = markContradiction(
|
|
69
|
+
state,
|
|
70
|
+
"m1",
|
|
71
|
+
"m2",
|
|
72
|
+
"system:detector",
|
|
73
|
+
);
|
|
74
|
+
const contradictions = getContradictions(next);
|
|
75
|
+
expect(contradictions).toHaveLength(1);
|
|
76
|
+
expect(contradictions[0].a.id).toBe("m1");
|
|
77
|
+
expect(contradictions[0].b.id).toBe("m2");
|
|
78
|
+
expect(contradictions[0].edge).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("getContradictions returns empty when none exist", () => {
|
|
82
|
+
const state = stateWith([makeItem("m1"), makeItem("m2")]);
|
|
83
|
+
expect(getContradictions(state)).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("resolveContradiction supersedes loser and lowers authority", () => {
|
|
87
|
+
const state = stateWith([
|
|
88
|
+
makeItem("m1", { authority: 0.9 }),
|
|
89
|
+
makeItem("m2", { authority: 0.7 }),
|
|
90
|
+
]);
|
|
91
|
+
const { state: marked } = markContradiction(
|
|
92
|
+
state,
|
|
93
|
+
"m1",
|
|
94
|
+
"m2",
|
|
95
|
+
"system:detector",
|
|
96
|
+
);
|
|
97
|
+
const { state: resolved, events } = resolveContradiction(
|
|
98
|
+
marked,
|
|
99
|
+
"m1",
|
|
100
|
+
"m2",
|
|
101
|
+
"system:resolver",
|
|
102
|
+
"m1 has more evidence",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// CONTRADICTS edge retracted
|
|
106
|
+
const contradicts = [...resolved.edges.values()].filter(
|
|
107
|
+
(e) => e.kind === "CONTRADICTS" && e.active,
|
|
108
|
+
);
|
|
109
|
+
expect(contradicts).toHaveLength(0);
|
|
110
|
+
|
|
111
|
+
// SUPERSEDES edge created
|
|
112
|
+
const supersedes = [...resolved.edges.values()].filter(
|
|
113
|
+
(e) => e.kind === "SUPERSEDES",
|
|
114
|
+
);
|
|
115
|
+
expect(supersedes).toHaveLength(1);
|
|
116
|
+
expect(supersedes[0].from).toBe("m1");
|
|
117
|
+
expect(supersedes[0].to).toBe("m2");
|
|
118
|
+
|
|
119
|
+
// loser authority lowered
|
|
120
|
+
expect(resolved.items.get("m2")!.authority).toBeCloseTo(0.07);
|
|
121
|
+
|
|
122
|
+
expect(events.length).toBeGreaterThanOrEqual(3);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ============================================================
|
|
127
|
+
// 2. Observational continuity — stale detection & cascade
|
|
128
|
+
// ============================================================
|
|
129
|
+
|
|
130
|
+
describe("observational continuity", () => {
|
|
131
|
+
it("getStaleItems finds items with missing parents", () => {
|
|
132
|
+
// m2 has parent m1, but m1 is not in state
|
|
133
|
+
const state = stateWith([
|
|
134
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
135
|
+
makeItem("m3"),
|
|
136
|
+
]);
|
|
137
|
+
const stale = getStaleItems(state);
|
|
138
|
+
expect(stale).toHaveLength(1);
|
|
139
|
+
expect(stale[0].item.id).toBe("m2");
|
|
140
|
+
expect(stale[0].missing_parents).toEqual(["m1"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("getStaleItems returns empty when all parents exist", () => {
|
|
144
|
+
const state = stateWith([
|
|
145
|
+
makeItem("m1"),
|
|
146
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
147
|
+
]);
|
|
148
|
+
expect(getStaleItems(state)).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("getDependents returns direct children", () => {
|
|
152
|
+
const state = stateWith([
|
|
153
|
+
makeItem("m1"),
|
|
154
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
155
|
+
makeItem("m3", { parents: ["m1"] }),
|
|
156
|
+
makeItem("m4"),
|
|
157
|
+
]);
|
|
158
|
+
const deps = getDependents(state, "m1");
|
|
159
|
+
expect(deps).toHaveLength(2);
|
|
160
|
+
expect(deps.map((d) => d.id).sort()).toEqual(["m2", "m3"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("getDependents with transitive finds full chain", () => {
|
|
164
|
+
const state = stateWith([
|
|
165
|
+
makeItem("m1"),
|
|
166
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
167
|
+
makeItem("m3", { parents: ["m2"] }),
|
|
168
|
+
makeItem("m4", { parents: ["m3"] }),
|
|
169
|
+
]);
|
|
170
|
+
const deps = getDependents(state, "m1", true);
|
|
171
|
+
expect(deps).toHaveLength(3);
|
|
172
|
+
expect(deps.map((d) => d.id).sort()).toEqual(["m2", "m3", "m4"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("cascadeRetract retracts item and all transitive dependents", () => {
|
|
176
|
+
const state = stateWith([
|
|
177
|
+
makeItem("m1"),
|
|
178
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
179
|
+
makeItem("m3", { parents: ["m2"] }),
|
|
180
|
+
makeItem("m4"),
|
|
181
|
+
]);
|
|
182
|
+
const { state: next, retracted } = cascadeRetract(
|
|
183
|
+
state,
|
|
184
|
+
"m1",
|
|
185
|
+
"system:cleanup",
|
|
186
|
+
"invalid source",
|
|
187
|
+
);
|
|
188
|
+
expect(next.items.has("m1")).toBe(false);
|
|
189
|
+
expect(next.items.has("m2")).toBe(false);
|
|
190
|
+
expect(next.items.has("m3")).toBe(false);
|
|
191
|
+
expect(next.items.has("m4")).toBe(true); // unrelated
|
|
192
|
+
expect(retracted.sort()).toEqual(["m1", "m2", "m3"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("cascadeRetract handles diamond dependencies", () => {
|
|
196
|
+
// m1 → m2, m1 → m3, m2 → m4, m3 → m4
|
|
197
|
+
const state = stateWith([
|
|
198
|
+
makeItem("m1"),
|
|
199
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
200
|
+
makeItem("m3", { parents: ["m1"] }),
|
|
201
|
+
makeItem("m4", { parents: ["m2", "m3"] }),
|
|
202
|
+
]);
|
|
203
|
+
const { state: next, retracted } = cascadeRetract(
|
|
204
|
+
state,
|
|
205
|
+
"m1",
|
|
206
|
+
"system:cleanup",
|
|
207
|
+
);
|
|
208
|
+
expect(next.items.size).toBe(0);
|
|
209
|
+
expect(retracted.sort()).toEqual(["m1", "m2", "m3", "m4"]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ============================================================
|
|
214
|
+
// 3. Recognition vs discovery — identity / aliasing
|
|
215
|
+
// ============================================================
|
|
216
|
+
|
|
217
|
+
describe("aliasing", () => {
|
|
218
|
+
it("markAlias creates bidirectional ALIAS edges", () => {
|
|
219
|
+
const state = stateWith([makeItem("m1"), makeItem("m2")]);
|
|
220
|
+
const { state: next, events } = markAlias(
|
|
221
|
+
state,
|
|
222
|
+
"m1",
|
|
223
|
+
"m2",
|
|
224
|
+
"system:dedup",
|
|
225
|
+
);
|
|
226
|
+
const aliasEdges = [...next.edges.values()].filter(
|
|
227
|
+
(e) => e.kind === "ALIAS",
|
|
228
|
+
);
|
|
229
|
+
expect(aliasEdges).toHaveLength(2);
|
|
230
|
+
expect(aliasEdges.some((e) => e.from === "m1" && e.to === "m2")).toBe(true);
|
|
231
|
+
expect(aliasEdges.some((e) => e.from === "m2" && e.to === "m1")).toBe(true);
|
|
232
|
+
expect(events).toHaveLength(2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("getAliases returns direct aliases", () => {
|
|
236
|
+
const state = stateWith([makeItem("m1"), makeItem("m2"), makeItem("m3")]);
|
|
237
|
+
let next = markAlias(state, "m1", "m2", "system:dedup").state;
|
|
238
|
+
const aliases = getAliases(next, "m1");
|
|
239
|
+
expect(aliases).toHaveLength(1);
|
|
240
|
+
expect(aliases[0].id).toBe("m2");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("getAliases returns empty when none exist", () => {
|
|
244
|
+
const state = stateWith([makeItem("m1")]);
|
|
245
|
+
expect(getAliases(state, "m1")).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("getAliasGroup returns transitive closure", () => {
|
|
249
|
+
const state = stateWith([makeItem("m1"), makeItem("m2"), makeItem("m3")]);
|
|
250
|
+
let next = markAlias(state, "m1", "m2", "system:dedup").state;
|
|
251
|
+
next = markAlias(next, "m2", "m3", "system:dedup").state;
|
|
252
|
+
const group = getAliasGroup(next, "m1");
|
|
253
|
+
expect(group).toHaveLength(3);
|
|
254
|
+
expect(group.map((i) => i.id).sort()).toEqual(["m1", "m2", "m3"]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("getAliasGroup from any member returns same group", () => {
|
|
258
|
+
const state = stateWith([makeItem("m1"), makeItem("m2"), makeItem("m3")]);
|
|
259
|
+
let next = markAlias(state, "m1", "m2", "system:dedup").state;
|
|
260
|
+
next = markAlias(next, "m2", "m3", "system:dedup").state;
|
|
261
|
+
const fromM3 = getAliasGroup(next, "m3");
|
|
262
|
+
expect(fromM3.map((i) => i.id).sort()).toEqual(["m1", "m2", "m3"]);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ============================================================
|
|
267
|
+
// 4. Budget-aware probabilistic retrieval
|
|
268
|
+
// ============================================================
|
|
269
|
+
|
|
270
|
+
describe("getItemsByBudget", () => {
|
|
271
|
+
it("packs highest-scoring items within budget", () => {
|
|
272
|
+
const state = stateWith([
|
|
273
|
+
makeItem("m1", {
|
|
274
|
+
authority: 0.9,
|
|
275
|
+
importance: 0.8,
|
|
276
|
+
content: { text: "short" },
|
|
277
|
+
}),
|
|
278
|
+
makeItem("m2", {
|
|
279
|
+
authority: 0.3,
|
|
280
|
+
importance: 0.2,
|
|
281
|
+
content: { text: "short" },
|
|
282
|
+
}),
|
|
283
|
+
makeItem("m3", {
|
|
284
|
+
authority: 0.7,
|
|
285
|
+
importance: 0.6,
|
|
286
|
+
content: { text: "short" },
|
|
287
|
+
}),
|
|
288
|
+
]);
|
|
289
|
+
const result = getItemsByBudget(state, {
|
|
290
|
+
budget: 20,
|
|
291
|
+
costFn: () => 10, // each item costs 10
|
|
292
|
+
weights: { authority: 1 },
|
|
293
|
+
});
|
|
294
|
+
// budget 20, cost 10 each → 2 items, highest authority first
|
|
295
|
+
expect(result).toHaveLength(2);
|
|
296
|
+
expect(result[0].item.id).toBe("m1"); // 0.9
|
|
297
|
+
expect(result[1].item.id).toBe("m3"); // 0.7
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("respects variable cost per item", () => {
|
|
301
|
+
const state = stateWith([
|
|
302
|
+
makeItem("m1", { authority: 0.9, content: { text: "a".repeat(100) } }),
|
|
303
|
+
makeItem("m2", { authority: 0.8, content: { text: "b".repeat(10) } }),
|
|
304
|
+
makeItem("m3", { authority: 0.7, content: { text: "c".repeat(10) } }),
|
|
305
|
+
]);
|
|
306
|
+
const result = getItemsByBudget(state, {
|
|
307
|
+
budget: 50,
|
|
308
|
+
costFn: (item) => JSON.stringify(item.content).length,
|
|
309
|
+
weights: { authority: 1 },
|
|
310
|
+
});
|
|
311
|
+
// m1 costs ~106 (too expensive), m2 and m3 cost ~16 each → both fit in 50
|
|
312
|
+
expect(result.map((r) => r.item.id)).toEqual(["m2", "m3"]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("returns empty when budget is 0", () => {
|
|
316
|
+
const state = stateWith([makeItem("m1")]);
|
|
317
|
+
const result = getItemsByBudget(state, {
|
|
318
|
+
budget: 0,
|
|
319
|
+
costFn: () => 1,
|
|
320
|
+
weights: { authority: 1 },
|
|
321
|
+
});
|
|
322
|
+
expect(result).toHaveLength(0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("applies filter before scoring", () => {
|
|
326
|
+
const state = stateWith([
|
|
327
|
+
makeItem("m1", { scope: "a", authority: 0.9 }),
|
|
328
|
+
makeItem("m2", { scope: "b", authority: 0.8 }),
|
|
329
|
+
makeItem("m3", { scope: "a", authority: 0.7 }),
|
|
330
|
+
]);
|
|
331
|
+
const result = getItemsByBudget(state, {
|
|
332
|
+
budget: 100,
|
|
333
|
+
costFn: () => 1,
|
|
334
|
+
weights: { authority: 1 },
|
|
335
|
+
filter: { scope: "a" },
|
|
336
|
+
});
|
|
337
|
+
expect(result).toHaveLength(2);
|
|
338
|
+
expect(result.every((r) => r.item.scope === "a")).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("uses weighted scores for ranking", () => {
|
|
342
|
+
const state = stateWith([
|
|
343
|
+
makeItem("m1", { authority: 0.9, importance: 0.1 }),
|
|
344
|
+
makeItem("m2", { authority: 0.3, importance: 0.9 }),
|
|
345
|
+
]);
|
|
346
|
+
// importance-heavy weighting should rank m2 first
|
|
347
|
+
const result = getItemsByBudget(state, {
|
|
348
|
+
budget: 100,
|
|
349
|
+
costFn: () => 1,
|
|
350
|
+
weights: { authority: 0.1, importance: 0.9 },
|
|
351
|
+
});
|
|
352
|
+
expect(result[0].item.id).toBe("m2");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("skips expensive items and takes cheaper ones", () => {
|
|
356
|
+
const state = stateWith([
|
|
357
|
+
makeItem("m1", { authority: 0.9 }),
|
|
358
|
+
makeItem("m2", { authority: 0.5 }),
|
|
359
|
+
makeItem("m3", { authority: 0.3 }),
|
|
360
|
+
]);
|
|
361
|
+
const result = getItemsByBudget(state, {
|
|
362
|
+
budget: 5,
|
|
363
|
+
costFn: (item) => (item.id === "m1" ? 100 : 2), // m1 is too expensive
|
|
364
|
+
weights: { authority: 1 },
|
|
365
|
+
});
|
|
366
|
+
// m1 skipped (too expensive), m2 and m3 fit
|
|
367
|
+
expect(result).toHaveLength(2);
|
|
368
|
+
expect(result[0].item.id).toBe("m2");
|
|
369
|
+
expect(result[1].item.id).toBe("m3");
|
|
370
|
+
});
|
|
371
|
+
});
|