@vellumai/assistant 0.8.9-staging.3 → 0.8.9-staging.4
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/openapi.yaml +16 -115
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +23 -8
- package/src/__tests__/context-window-manager-compact-retry.test.ts +120 -14
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/llm-resolver.test.ts +73 -0
- package/src/__tests__/plugin-external-api.test.ts +68 -0
- package/src/__tests__/published-app-updater.test.ts +138 -0
- package/src/api/responses/memory-v3-selection-log.ts +19 -10
- package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
- package/src/cli/commands/memory-v3.ts +57 -199
- package/src/config/call-site-defaults.ts +0 -1
- package/src/config/llm-resolver.ts +39 -7
- package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
- package/src/config/schemas/call-site-catalog.ts +0 -7
- package/src/config/schemas/llm.ts +17 -1
- package/src/config/schemas/memory-v3.ts +58 -8
- package/src/config/seed-inference-profiles.ts +18 -0
- package/src/daemon/conversation-agent-loop.ts +9 -17
- package/src/daemon/external-plugins-bootstrap.ts +8 -3
- package/src/daemon/tool-side-effects.ts +15 -0
- package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
- package/src/memory/v2/__tests__/page-store.test.ts +22 -0
- package/src/memory/v2/consolidation-job.ts +2 -72
- package/src/memory/v2/types.ts +5 -0
- package/src/plugins/defaults/compaction/window-manager.ts +56 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
- package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
- package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
- package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
- package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
- package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
- package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
- package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
- package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
- package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
- package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
- package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
- package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
- package/src/plugins/external-api.ts +114 -0
- package/src/providers/anthropic/client.ts +9 -10
- package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
- package/src/providers/inference/logit-bias.ts +55 -0
- package/src/providers/openai/chat-completions-provider.ts +19 -1
- package/src/providers/retry.ts +22 -0
- package/src/providers/types.ts +6 -0
- package/src/runtime/routes/app-management-routes.ts +3 -0
- package/src/runtime/routes/memory-v3-routes.ts +64 -314
- package/src/services/published-app-updater.ts +30 -8
- package/src/tools/skills/load.ts +1 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
- package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
- package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
- package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
- package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
- package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
- package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
- package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
- package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
- package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
- package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
- package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
- package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
- package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
- package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
- package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
- package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
- package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
- package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
- package/src/util/map-limit.ts +0 -27
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { makeMockLogger } from "../../../../__tests__/helpers/mock-logger.js";
|
|
4
|
+
import type { AssistantConfig } from "../../../../config/types.js";
|
|
5
|
+
|
|
6
|
+
mock.module("../../../../util/logger.js", () => ({
|
|
7
|
+
getLogger: () => makeMockLogger(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
mock.module("../../../../memory/qdrant-client.js", () => ({
|
|
11
|
+
resolveQdrantUrl: () => "http://127.0.0.1:6333",
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Stub the shared embedding backend. Records the queries it was asked to embed
|
|
15
|
+
// and returns one deterministic vector so `denseLane` can issue the search.
|
|
16
|
+
const embedState = {
|
|
17
|
+
calls: [] as string[][],
|
|
18
|
+
throws: null as Error | null,
|
|
19
|
+
};
|
|
20
|
+
mock.module("../../../../memory/embedding-backend.js", () => ({
|
|
21
|
+
embedWithBackend: async (_config: unknown, inputs: string[]) => {
|
|
22
|
+
embedState.calls.push(inputs);
|
|
23
|
+
if (embedState.throws) throw embedState.throws;
|
|
24
|
+
return {
|
|
25
|
+
provider: "local",
|
|
26
|
+
model: "test-model",
|
|
27
|
+
vectors: inputs.map(() => [0.1, 0.2, 0.3, 0.4]),
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock the @qdrant/js-client-rest client. The mock records the query params and
|
|
33
|
+
// returns whatever section points the test programmed (in descending score
|
|
34
|
+
// order, matching Qdrant's behavior).
|
|
35
|
+
type MockPoint = {
|
|
36
|
+
payload: { article: string; ordinal: number };
|
|
37
|
+
score: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const state = {
|
|
41
|
+
points: [] as MockPoint[],
|
|
42
|
+
queryThrows: null as Error | null,
|
|
43
|
+
queryCalls: [] as Array<{
|
|
44
|
+
collection: string;
|
|
45
|
+
query: unknown;
|
|
46
|
+
limit: number;
|
|
47
|
+
}>,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
class MockQdrantClient {
|
|
51
|
+
constructor(_opts: unknown) {}
|
|
52
|
+
async query(name: string, params: { query: unknown; limit: number }) {
|
|
53
|
+
state.queryCalls.push({
|
|
54
|
+
collection: name,
|
|
55
|
+
query: params.query,
|
|
56
|
+
limit: params.limit,
|
|
57
|
+
});
|
|
58
|
+
if (state.queryThrows) throw state.queryThrows;
|
|
59
|
+
return { points: state.points };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
mock.module("@qdrant/js-client-rest", () => ({
|
|
64
|
+
QdrantClient: MockQdrantClient,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
const { denseLane, OVERSAMPLE } = await import("../dense.js");
|
|
68
|
+
const { SECTION_COLLECTION, _resetSectionDenseStoreForTests } =
|
|
69
|
+
await import("../section-dense-store.js");
|
|
70
|
+
|
|
71
|
+
const CONFIG = {
|
|
72
|
+
memory: { qdrant: { vectorSize: 4, onDisk: true } },
|
|
73
|
+
} as unknown as AssistantConfig;
|
|
74
|
+
|
|
75
|
+
function point(article: string, ordinal: number, score: number): MockPoint {
|
|
76
|
+
return { payload: { article, ordinal }, score };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resetState(): void {
|
|
80
|
+
embedState.calls.length = 0;
|
|
81
|
+
embedState.throws = null;
|
|
82
|
+
state.points = [];
|
|
83
|
+
state.queryThrows = null;
|
|
84
|
+
state.queryCalls.length = 0;
|
|
85
|
+
_resetSectionDenseStoreForTests();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("memory v3 dense lane", () => {
|
|
89
|
+
beforeEach(resetState);
|
|
90
|
+
afterEach(resetState);
|
|
91
|
+
|
|
92
|
+
test("targets the section collection at k * OVERSAMPLE depth", async () => {
|
|
93
|
+
state.points = [point("page-a", 0, 0.9)];
|
|
94
|
+
|
|
95
|
+
await denseLane(CONFIG, "some query", 5);
|
|
96
|
+
|
|
97
|
+
// Embedded the query once.
|
|
98
|
+
expect(embedState.calls).toEqual([["some query"]]);
|
|
99
|
+
// Oversampled the section search so dedupe still yields k articles.
|
|
100
|
+
expect(state.queryCalls).toHaveLength(1);
|
|
101
|
+
expect(state.queryCalls[0]!.collection).toBe(SECTION_COLLECTION);
|
|
102
|
+
expect(state.queryCalls[0]!.limit).toBe(5 * OVERSAMPLE);
|
|
103
|
+
expect(state.queryCalls[0]!.query).toEqual([0.1, 0.2, 0.3, 0.4]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("dedupes section points to distinct articles with their best section", async () => {
|
|
107
|
+
// page-a's best section is ordinal 2 (highest score); the later page-a
|
|
108
|
+
// section is ignored once the article is already represented.
|
|
109
|
+
state.points = [
|
|
110
|
+
point("page-a", 2, 0.95),
|
|
111
|
+
point("topic-x", 0, 0.9),
|
|
112
|
+
point("page-a", 1, 0.5),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const hits = await denseLane(CONFIG, "query", 5);
|
|
116
|
+
|
|
117
|
+
expect(hits).toEqual([
|
|
118
|
+
{ article: "page-a", section: 2 },
|
|
119
|
+
{ article: "topic-x", section: 0 },
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("oversamples then truncates to k distinct articles", async () => {
|
|
124
|
+
state.points = [
|
|
125
|
+
point("page-a", 0, 0.99),
|
|
126
|
+
point("page-b", 0, 0.98),
|
|
127
|
+
point("page-c", 0, 0.97),
|
|
128
|
+
point("page-d", 0, 0.96),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const hits = await denseLane(CONFIG, "query", 2);
|
|
132
|
+
|
|
133
|
+
expect(hits).toEqual([
|
|
134
|
+
{ article: "page-a", section: 0 },
|
|
135
|
+
{ article: "page-b", section: 0 },
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("ignores malformed payloads", async () => {
|
|
140
|
+
state.points = [
|
|
141
|
+
{
|
|
142
|
+
payload: {
|
|
143
|
+
article: 123,
|
|
144
|
+
ordinal: 0,
|
|
145
|
+
} as unknown as MockPoint["payload"],
|
|
146
|
+
score: 0.9,
|
|
147
|
+
},
|
|
148
|
+
point("topic-x", 1, 0.8),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const hits = await denseLane(CONFIG, "query", 5);
|
|
152
|
+
|
|
153
|
+
expect(hits).toEqual([{ article: "topic-x", section: 1 }]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a thrown Qdrant search degrades to []", async () => {
|
|
157
|
+
state.queryThrows = new Error("qdrant unreachable");
|
|
158
|
+
|
|
159
|
+
const hits = await denseLane(CONFIG, "query", 5);
|
|
160
|
+
|
|
161
|
+
expect(hits).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a failed embedding degrades to []", async () => {
|
|
165
|
+
embedState.throws = new Error("embed backend down");
|
|
166
|
+
|
|
167
|
+
const hits = await denseLane(CONFIG, "query", 5);
|
|
168
|
+
|
|
169
|
+
expect(hits).toEqual([]);
|
|
170
|
+
// No Qdrant round-trip when embedding fails.
|
|
171
|
+
expect(state.queryCalls).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("non-positive k short-circuits without a search", async () => {
|
|
175
|
+
const hits = await denseLane(CONFIG, "query", 0);
|
|
176
|
+
|
|
177
|
+
expect(hits).toEqual([]);
|
|
178
|
+
expect(embedState.calls).toEqual([]);
|
|
179
|
+
expect(state.queryCalls).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { PageIndexEntry } from "../../../../memory/v2/page-index.js";
|
|
4
|
+
import { buildEdgeGraph, edgeExpand } from "../edge.js";
|
|
5
|
+
import type { Slug } from "../types.js";
|
|
6
|
+
|
|
7
|
+
/** Minimal page-index entry factory — only the fields the edge lane reads. */
|
|
8
|
+
function entry(id: number, slug: Slug, edges: number[] = []): PageIndexEntry {
|
|
9
|
+
return { id, slug, summary: "", edges, leaves: [], modifiedAt: 0 };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Build a raw-page reader from a fixture map; missing slugs read as "". */
|
|
13
|
+
function rawReader(pages: Record<string, string>) {
|
|
14
|
+
return async (slug: Slug): Promise<string> => pages[slug] ?? "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Frontmatter helper: wrap a `links:` list around an optional body. */
|
|
18
|
+
function withLinks(links: string[], body = "body"): string {
|
|
19
|
+
const block = links.map((l) => ` - "${l}"`).join("\n");
|
|
20
|
+
return `---\ntitle: x\nlinks:\n${block}\n---\n\n${body}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("buildEdgeGraph — links: frontmatter", () => {
|
|
24
|
+
test("parses target + description split on the first ' — '", async () => {
|
|
25
|
+
const articles = [entry(1, "page-a"), entry(2, "page-b")];
|
|
26
|
+
const graph = await buildEdgeGraph(
|
|
27
|
+
articles,
|
|
28
|
+
rawReader({
|
|
29
|
+
"page-a": withLinks(["page-b — the b page — with an extra dash"]),
|
|
30
|
+
"page-b": "no frontmatter",
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
const out = graph.adjacency.get("page-a")!;
|
|
34
|
+
// Split on the FIRST ' — ' only: description keeps the trailing dash text.
|
|
35
|
+
expect(out.get("page-b")).toBe("the b page — with an extra dash");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("drops unknown/dangling link targets", async () => {
|
|
39
|
+
const articles = [entry(1, "page-a"), entry(2, "page-b")];
|
|
40
|
+
const graph = await buildEdgeGraph(
|
|
41
|
+
articles,
|
|
42
|
+
rawReader({
|
|
43
|
+
"page-a": withLinks([
|
|
44
|
+
"page-b — known target",
|
|
45
|
+
"page-ghost — dangling target not in corpus",
|
|
46
|
+
]),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
const out = graph.adjacency.get("page-a")!;
|
|
50
|
+
expect(out.has("page-b")).toBe(true);
|
|
51
|
+
expect(out.has("page-ghost")).toBe(false);
|
|
52
|
+
expect([...out.keys()]).toEqual(["page-b"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a page with no links: falls back to wikilinks and numeric edges", async () => {
|
|
56
|
+
const articles = [
|
|
57
|
+
entry(1, "page-a", [2]), // numeric edge → page-b
|
|
58
|
+
entry(2, "page-b"),
|
|
59
|
+
entry(3, "topic-x"),
|
|
60
|
+
];
|
|
61
|
+
const graph = await buildEdgeGraph(
|
|
62
|
+
articles,
|
|
63
|
+
rawReader({
|
|
64
|
+
// No links: frontmatter; body has an inline wikilink to topic-x.
|
|
65
|
+
"page-a": "plain body referencing [[topic-x]] inline",
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const out = graph.adjacency.get("page-a")!;
|
|
69
|
+
expect(out.has("page-b")).toBe(true); // from numeric edge
|
|
70
|
+
expect(out.has("topic-x")).toBe(true); // from wikilink
|
|
71
|
+
// No curated descriptions on wikilink/numeric edges.
|
|
72
|
+
expect(out.get("page-b")).toBeUndefined();
|
|
73
|
+
expect(out.get("topic-x")).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("buildEdgeGraph — three-source union", () => {
|
|
78
|
+
test("links ∪ wikilinks ∪ numeric edges merge into one adjacency map", async () => {
|
|
79
|
+
const articles = [
|
|
80
|
+
entry(1, "page-a", [4]), // numeric edge → topic-y
|
|
81
|
+
entry(2, "page-b"),
|
|
82
|
+
entry(3, "topic-x"),
|
|
83
|
+
entry(4, "topic-y"),
|
|
84
|
+
];
|
|
85
|
+
const graph = await buildEdgeGraph(
|
|
86
|
+
articles,
|
|
87
|
+
rawReader({
|
|
88
|
+
"page-a": withLinks(
|
|
89
|
+
["page-b — authored neighbour"],
|
|
90
|
+
"and a [[topic-x]] wikilink in the body",
|
|
91
|
+
),
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
const out = graph.adjacency.get("page-a")!;
|
|
95
|
+
expect(new Set(out.keys())).toEqual(
|
|
96
|
+
new Set(["page-b", "topic-x", "topic-y"]),
|
|
97
|
+
);
|
|
98
|
+
expect(out.get("page-b")).toBe("authored neighbour"); // from links
|
|
99
|
+
expect(out.get("topic-x")).toBeUndefined(); // from wikilink
|
|
100
|
+
expect(out.get("topic-y")).toBeUndefined(); // from numeric edge
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("read failure still keeps numeric-edge fallback", async () => {
|
|
104
|
+
const articles = [entry(1, "page-a", [2]), entry(2, "page-b")];
|
|
105
|
+
const graph = await buildEdgeGraph(articles, async () => {
|
|
106
|
+
throw new Error("read failed");
|
|
107
|
+
});
|
|
108
|
+
const out = graph.adjacency.get("page-a")!;
|
|
109
|
+
expect(out.has("page-b")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("buildEdgeGraph — hubs", () => {
|
|
114
|
+
test("marks articles with in-degree > hubDegree as hubs", async () => {
|
|
115
|
+
// hub receives 3 inbound edges; with hubDegree=2 it is a hub.
|
|
116
|
+
const articles = [
|
|
117
|
+
entry(1, "page-a"),
|
|
118
|
+
entry(2, "page-b"),
|
|
119
|
+
entry(3, "page-c"),
|
|
120
|
+
entry(4, "hub"),
|
|
121
|
+
];
|
|
122
|
+
const graph = await buildEdgeGraph(
|
|
123
|
+
articles,
|
|
124
|
+
rawReader({
|
|
125
|
+
"page-a": withLinks(["hub — to hub"]),
|
|
126
|
+
"page-b": withLinks(["hub — to hub"]),
|
|
127
|
+
"page-c": withLinks(["hub — to hub"]),
|
|
128
|
+
}),
|
|
129
|
+
{ hubDegree: 2 },
|
|
130
|
+
);
|
|
131
|
+
expect(graph.hubs.has("hub")).toBe(true);
|
|
132
|
+
expect(graph.hubs.has("page-a")).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("edgeExpand", () => {
|
|
137
|
+
/** Build a star/chain graph: page-a → b,c,d,e,f,g ; topic-x → b. */
|
|
138
|
+
async function fixtureGraph(hubDegree = 30) {
|
|
139
|
+
const articles = [
|
|
140
|
+
entry(1, "page-a"),
|
|
141
|
+
entry(2, "page-b"),
|
|
142
|
+
entry(3, "page-c"),
|
|
143
|
+
entry(4, "page-d"),
|
|
144
|
+
entry(5, "page-e"),
|
|
145
|
+
entry(6, "page-f"),
|
|
146
|
+
entry(7, "page-g"),
|
|
147
|
+
entry(8, "topic-x"),
|
|
148
|
+
];
|
|
149
|
+
return buildEdgeGraph(
|
|
150
|
+
articles,
|
|
151
|
+
rawReader({
|
|
152
|
+
"page-a": withLinks([
|
|
153
|
+
"page-b — neighbour b",
|
|
154
|
+
"page-c — neighbour c",
|
|
155
|
+
"page-d — neighbour d",
|
|
156
|
+
"page-e — neighbour e",
|
|
157
|
+
"page-f — neighbour f",
|
|
158
|
+
"page-g — neighbour g",
|
|
159
|
+
]),
|
|
160
|
+
"topic-x": withLinks(["page-b — also to b"]),
|
|
161
|
+
}),
|
|
162
|
+
{ hubDegree },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test("carries the curated links description through expansion", async () => {
|
|
167
|
+
const graph = await fixtureGraph();
|
|
168
|
+
const out = edgeExpand(graph, ["page-a"]);
|
|
169
|
+
const b = out.find((n) => n.article === "page-b");
|
|
170
|
+
expect(b?.description).toBe("neighbour b");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("respects perSeed", async () => {
|
|
174
|
+
const graph = await fixtureGraph();
|
|
175
|
+
const out = edgeExpand(graph, ["page-a"], { perSeed: 2 });
|
|
176
|
+
expect(out).toHaveLength(2);
|
|
177
|
+
expect(out.map((n) => n.article)).toEqual(["page-b", "page-c"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("respects cap across seeds", async () => {
|
|
181
|
+
const graph = await fixtureGraph();
|
|
182
|
+
// Two seeds each could add many; cap=3 truncates the total.
|
|
183
|
+
const out = edgeExpand(graph, ["page-a", "topic-x"], {
|
|
184
|
+
perSeed: 6,
|
|
185
|
+
cap: 3,
|
|
186
|
+
});
|
|
187
|
+
expect(out).toHaveLength(3);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("respects seedCount (only top-N seeds expanded)", async () => {
|
|
191
|
+
// seedCount=1: only the first seed expands; the second seed's unique
|
|
192
|
+
// neighbour is never reached.
|
|
193
|
+
const articles = [
|
|
194
|
+
entry(1, "seed-1"),
|
|
195
|
+
entry(2, "seed-2"),
|
|
196
|
+
entry(3, "only-via-2"),
|
|
197
|
+
entry(4, "via-1"),
|
|
198
|
+
];
|
|
199
|
+
const g = await buildEdgeGraph(
|
|
200
|
+
articles,
|
|
201
|
+
rawReader({
|
|
202
|
+
"seed-1": withLinks(["via-1 — x"]),
|
|
203
|
+
"seed-2": withLinks(["only-via-2 — y"]),
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
const out = edgeExpand(g, ["seed-1", "seed-2"], { seedCount: 1 });
|
|
207
|
+
expect(out.map((n) => n.article)).toEqual(["via-1"]);
|
|
208
|
+
expect(out.find((n) => n.article === "only-via-2")).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("excludes hub neighbours from expansion", async () => {
|
|
212
|
+
// page-b has in-degree 2 (page-a + topic-x). At the default threshold it
|
|
213
|
+
// is not a hub; lowering hubDegree below 2 makes it one.
|
|
214
|
+
const nonHubGraph = await fixtureGraph();
|
|
215
|
+
expect(nonHubGraph.hubs.has("page-b")).toBe(false);
|
|
216
|
+
|
|
217
|
+
const hubGraph = await fixtureGraph(1); // page-b in-degree 2 > 1 → hub
|
|
218
|
+
expect(hubGraph.hubs.has("page-b")).toBe(true);
|
|
219
|
+
const out = edgeExpand(hubGraph, ["page-a"]);
|
|
220
|
+
expect(out.find((n) => n.article === "page-b")).toBeUndefined();
|
|
221
|
+
// non-hub neighbours still surface
|
|
222
|
+
expect(out.find((n) => n.article === "page-c")).toBeDefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("respects the alive predicate", async () => {
|
|
226
|
+
const graph = await fixtureGraph();
|
|
227
|
+
const out = edgeExpand(graph, ["page-a"], {
|
|
228
|
+
alive: (slug) => slug !== "page-c",
|
|
229
|
+
});
|
|
230
|
+
expect(out.find((n) => n.article === "page-c")).toBeUndefined();
|
|
231
|
+
expect(out.find((n) => n.article === "page-b")).toBeDefined();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("does not surface the seeds themselves", async () => {
|
|
235
|
+
const articles = [entry(1, "page-a"), entry(2, "page-b")];
|
|
236
|
+
const graph = await buildEdgeGraph(
|
|
237
|
+
articles,
|
|
238
|
+
rawReader({
|
|
239
|
+
"page-a": withLinks(["page-b — to b"]),
|
|
240
|
+
"page-b": withLinks(["page-a — back to a"]),
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
const out = edgeExpand(graph, ["page-a", "page-b"]);
|
|
244
|
+
// page-a and page-b are both seeds; neither should be surfaced.
|
|
245
|
+
expect(out).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
});
|