@konneal/engine 0.2.104 → 0.2.109
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/dist/ablate.d.ts +31 -0
- package/dist/ask-EAKLN77G.js +15 -0
- package/dist/{chunk-25ECO5M7.js → chunk-2R7OVYAO.js} +377 -185
- package/dist/{chunk-U4ERFUOK.js → chunk-4NXH5ZI6.js} +1 -1
- package/dist/{chunk-DTW4UW2E.js → chunk-DJ65EF5G.js} +1 -1
- package/dist/{chunk-NCZHN6QW.js → chunk-G4KBK5XZ.js} +175 -22
- package/dist/{chunk-ANUHRJ4Z.js → chunk-G56TYXNN.js} +3 -3
- package/dist/{chunk-TFGJ6NYS.js → chunk-HAU5INEJ.js} +5 -5
- package/dist/{chunk-KNCRYAX3.js → chunk-TH5XGPD5.js} +1 -1
- package/dist/{chunk-BOAITSVV.js → chunk-VT7DR6NQ.js} +11 -1
- package/dist/config.d.ts +6 -0
- package/dist/openapi-types.d.ts +24 -0
- package/dist/prompts/doc-summary.md +1 -0
- package/dist/prompts/speculative-verify.md +9 -0
- package/dist/route.d.ts +29 -0
- package/dist/search-HKGN7F3O.js +13 -0
- package/dist/speculative.d.ts +9 -0
- package/dist/{tools-EXDADNWX.js → tools-WSWEGJVQ.js} +4 -4
- package/dist/worker_mcp/src/index.js +2 -2
- package/dist/worker_public/src/config.js +1 -1
- package/dist/worker_public/src/index.js +20 -16
- package/dist/worker_public/src/requestScope.js +2 -2
- package/docs/spec-pipeline.md +12 -1
- package/package.json +1 -1
- package/workers/worker_public/openapi.yaml +53 -0
- package/workers/worker_public/prompts/doc-summary.md +1 -0
- package/workers/worker_public/prompts/speculative-verify.md +9 -0
- package/workers/worker_public/src/ablate.ts +97 -0
- package/workers/worker_public/src/admin.ts +14 -6
- package/workers/worker_public/src/ask.ts +120 -16
- package/workers/worker_public/src/config.ts +10 -0
- package/workers/worker_public/src/pipeline.ts +5 -2
- package/workers/worker_public/src/route.ts +77 -0
- package/workers/worker_public/src/speculative.ts +39 -0
- package/workers/worker_public/src/stages/graphPpr.ts +189 -0
- package/workers/worker_public/src/stages/index.ts +19 -0
- package/workers/worker_public/src/stages/sectionDescent.ts +91 -7
- package/workers/worker_public/src/stages/types.ts +6 -0
- package/dist/ask-M3VRAIJB.js +0 -15
- package/dist/search-5SNP5CHL.js +0 -13
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { LIMITS, MODELS, THRESHOLDS, num, sha256Hex, roleModel, answerEffort, requestEffort, effortBudget } from "./config";
|
|
7
7
|
import { portModelRunner } from "./env.ts";
|
|
8
8
|
import { refCodec } from "./codecs";
|
|
9
|
-
import { buildMessages, citations, retrieve, retrievalQuery, identityNote, splitHistory, listwiseRerank, refusalAnswer, Hit } from "./pipeline";
|
|
9
|
+
import { buildMessages, citations, fill, promptVars, retrieve, retrievalQuery, identityNote, splitHistory, listwiseRerank, refusalAnswer, Hit } from "./pipeline";
|
|
10
10
|
import { sessionFrom } from "./auth";
|
|
11
11
|
import { retrieveInternal } from "./internal_gateway";
|
|
12
12
|
import { understandQuery } from "./understand";
|
|
@@ -34,6 +34,11 @@ import { memoryNote } from "./memories";
|
|
|
34
34
|
import { entitlementScope, resolveRequestScope, requestSalt, standardKeysFrom } from "./requestScope";
|
|
35
35
|
import { rawSessionToken, type SessionClaims } from "./session";
|
|
36
36
|
import { cacheKeyMaterial, corpusGen, exactCacheKey, freshRequested } from "./answercache";
|
|
37
|
+
import { parseAblate } from "./ablate";
|
|
38
|
+
import { STAGE_NAMES } from "./stages/index.ts";
|
|
39
|
+
import { routeFor, fastRouteStages } from "./route.ts";
|
|
40
|
+
import { partitionSubsets, type Partitionable } from "./speculative.ts";
|
|
41
|
+
import speculativeVerifyPrompt from "../prompts/speculative-verify.md";
|
|
37
42
|
import type { Env } from "./env";
|
|
38
43
|
export type { Env };
|
|
39
44
|
import { json, err, corsHeaders, readJson, validateQuery, type ApiKey } from "./lib/http";
|
|
@@ -59,6 +64,57 @@ function userImageDataUrl(body: any): string | null {
|
|
|
59
64
|
return img;
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
// drafts are candidates, not the answer: half the window budget keeps
|
|
68
|
+
// each draft short and keeps the parallel leg inside the single-call
|
|
69
|
+
// latency it must beat
|
|
70
|
+
const DRAFT_TOKEN_BUDGET = 1600;
|
|
71
|
+
|
|
72
|
+
/** The verifier's passage listing: the same [n] label grammar the
|
|
73
|
+
* answer path builds (the final answer cites against it). */
|
|
74
|
+
function speculativeListing(hits: Hit[]): string {
|
|
75
|
+
return hits
|
|
76
|
+
.map((h, i) => {
|
|
77
|
+
const id = (h.metadata.docidentifier || h.metadata.doc_id || "source").replace(/\s*\(([A-Z])\)\s*$/, "").trim();
|
|
78
|
+
const edition = h.metadata.edition && !id.includes(h.metadata.edition) ? ":" + h.metadata.edition : "";
|
|
79
|
+
const anchor = h.metadata.clause_anchor ? ` §${h.metadata.clause_anchor}` : "";
|
|
80
|
+
return `[${i + 1}] ${id}${edition}${anchor} ${h.text.replace(/\s+/g, " ").slice(0, 700)}`;
|
|
81
|
+
})
|
|
82
|
+
.join("\n\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Speculative draft-verify: draft per diversified subset (parallel,
|
|
86
|
+
* cheap model, contract-shaped messages), then ONE strong call
|
|
87
|
+
* verifies every candidate against the FULL window and produces the
|
|
88
|
+
* final answer. Null on any failure — the caller's normal generation
|
|
89
|
+
* stands. */
|
|
90
|
+
async function speculativeDraftVerify(
|
|
91
|
+
env: Env,
|
|
92
|
+
opts: { query: string; hits: Hit[]; lang?: string | null; draftModel: string; verifierModel: string },
|
|
93
|
+
): Promise<{ answer: string; chosen: number; nDrafts: number } | null> {
|
|
94
|
+
const subsets = partitionSubsets(opts.hits as unknown as Partitionable[]);
|
|
95
|
+
if (!subsets) return null;
|
|
96
|
+
const draftSets = subsets.map((subset) =>
|
|
97
|
+
buildMessages(opts.query, subset as unknown as Hit[], opts.lang ?? undefined, [], undefined, undefined, DRAFT_TOKEN_BUDGET).messages,
|
|
98
|
+
);
|
|
99
|
+
const drafts = await Promise.all(
|
|
100
|
+
draftSets.map((m) => generateOnce(env, opts.draftModel, m, "low").catch(() => null)),
|
|
101
|
+
);
|
|
102
|
+
const candidates = drafts
|
|
103
|
+
.map((d) => (d ?? "").trim())
|
|
104
|
+
.filter((d) => d.length > 0);
|
|
105
|
+
if (!candidates.length) return null;
|
|
106
|
+
const candidateText = candidates
|
|
107
|
+
.map((d, i) => `<candidate ${i + 1} (prepared from a partial view)>\n${d}\n</candidate ${i + 1}>`)
|
|
108
|
+
.join("\n\n");
|
|
109
|
+
const system = fill(speculativeVerifyPrompt.trimEnd(), promptVars({}));
|
|
110
|
+
const answer = await generateOnce(env, opts.verifierModel, [
|
|
111
|
+
{ role: "system", content: system },
|
|
112
|
+
{ role: "user", content: `Question: ${opts.query}\n\nCandidate draft answers:\n${candidateText}\n\nFull context passages:\n${speculativeListing(opts.hits)}` },
|
|
113
|
+
], "low").catch(() => null);
|
|
114
|
+
if (!answer || !answer.trim()) return null;
|
|
115
|
+
return { answer, chosen: candidates.length, nDrafts: drafts.length };
|
|
116
|
+
}
|
|
117
|
+
|
|
62
118
|
async function cacheGet(env: Env, gen: string, ns: string, query: string, lang?: string, salt?: string | null) {
|
|
63
119
|
const key = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(query, lang, salt)));
|
|
64
120
|
const hit = await env.CACHE.get(key, "json");
|
|
@@ -298,6 +354,12 @@ async function handleAsk(
|
|
|
298
354
|
const body = await readJson(req);
|
|
299
355
|
const q = validateQuery(body);
|
|
300
356
|
if (!q) return err(400, "invalid_input", `query is required (1-${LIMITS.maxInputChars} chars)`);
|
|
357
|
+
// the ablation boundary (TODO.sota/09): a runner-scored configuration
|
|
358
|
+
// rides the body behind the admin credential. Failures are loud — a
|
|
359
|
+
// runner must never mistake a rejected configuration for a measured one.
|
|
360
|
+
const ablated = parseAblate(body, env.ADMIN_TOKEN, req.headers.get("x-admin-token"), STAGE_NAMES);
|
|
361
|
+
if (!ablated.ok) return err(ablated.status, ablated.code, ablated.message);
|
|
362
|
+
const ablate = ablated.config;
|
|
301
363
|
|
|
302
364
|
// The declared context (TODO.ai-platform/02): the panel's opt-in chips.
|
|
303
365
|
// A declared context makes the answer depend on MORE than the query, so
|
|
@@ -339,6 +401,13 @@ async function handleAsk(
|
|
|
339
401
|
: null);
|
|
340
402
|
// resolved before the quota check: the effort choice prices the ask
|
|
341
403
|
const effort = requestEffort(env, member, (body as any)?.effort);
|
|
404
|
+
// the fast route floors GENERATION effort at low (a scoped or
|
|
405
|
+
// definitional question needs no deep reasoning; the caller's
|
|
406
|
+
// declared effort always wins). Quota keeps the declared weight —
|
|
407
|
+
// the floor is a serving decision, not a billing one.
|
|
408
|
+
const declaredEffort = (body as any)?.effort;
|
|
409
|
+
const routeEffort = (route: "fast" | "deep") =>
|
|
410
|
+
route === "fast" && !declaredEffort ? ("low" as const) : effort;
|
|
342
411
|
|
|
343
412
|
const limit =
|
|
344
413
|
tier === "key" ? key!.day_limit : tier === "member" || member ? num(env as any, "MEMBER_DAY_ASK", 300) : num(env as any, "ANON_DAY_ASK", 20);
|
|
@@ -469,7 +538,7 @@ async function handleAsk(
|
|
|
469
538
|
// cache.py) and old-generation entries miss (oimlsmart/rag#72)
|
|
470
539
|
const gen = await corpusGen(env.CACHE);
|
|
471
540
|
const withImage = effectiveImage || storedImage;
|
|
472
|
-
const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || withImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
|
|
541
|
+
const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || withImage || ablate ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
|
|
473
542
|
const wantsStream = body?.stream === true || (tier === "anon" && body?.stream !== false);
|
|
474
543
|
|
|
475
544
|
if (cached) {
|
|
@@ -877,7 +946,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
877
946
|
// the worker EXECUTES the bound node's machine checks against the
|
|
878
947
|
// question's stated values; the model narrates the computed verdict
|
|
879
948
|
// and the verdict BLOCK is server-built — data, never generated prose
|
|
880
|
-
const machineVerdict = boundModel && !boundModel.gated ? machineEvaluate(boundModel.content, q.query) : null;
|
|
949
|
+
const machineVerdict = boundModel && !boundModel.gated && !ablate?.noVerdict ? machineEvaluate(boundModel.content, q.query) : null;
|
|
881
950
|
const machineNote = machineVerdict && boundModel ? verdictNote(machineVerdict, boundModel) : undefined;
|
|
882
951
|
// ── condition-set membership (konneal/engine#90): the test-method
|
|
883
952
|
// packages' severity menus as machine-verifiable membership. Fires
|
|
@@ -903,7 +972,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
903
972
|
};
|
|
904
973
|
let conditionVerdict: ConditionVerdict | null = null;
|
|
905
974
|
let conditionStandard: string | null = null;
|
|
906
|
-
if (!machineVerdict && !boundModel && P().publisher.features?.model_plane) {
|
|
975
|
+
if (!machineVerdict && !boundModel && !ablate?.noVerdict && P().publisher.features?.model_plane) {
|
|
907
976
|
const ql = q.query.toLowerCase();
|
|
908
977
|
const severityWord = /\b(severity|test|valid|tolerance|condition|within)\b/.test(ql);
|
|
909
978
|
const stated = quantitiesIn(q.query);
|
|
@@ -1055,6 +1124,18 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1055
1124
|
boundaryNote = boundaryNoteText(match, citing);
|
|
1056
1125
|
}
|
|
1057
1126
|
}
|
|
1127
|
+
// ── the adaptive route (TODO.sota/05): understanding's features pick
|
|
1128
|
+
// fast (the registry minus the expansion lanes) or deep (full). The
|
|
1129
|
+
// ablation override wins when present — the grid measures the routes
|
|
1130
|
+
// by forcing them; adaptive is the serving default. The decision
|
|
1131
|
+
// reads the FINAL understanding (the declared-context and text-naming
|
|
1132
|
+
// reconciliations above may have written the scope the router trusts).
|
|
1133
|
+
const routeMode = ablate?.route ?? "adaptive";
|
|
1134
|
+
const routed = routeMode === "adaptive"
|
|
1135
|
+
? routeFor(understanding, q.query)
|
|
1136
|
+
: { route: routeMode, features: [`forced-${routeMode}`] };
|
|
1137
|
+
const routeSubset = ablate?.stages ?? (routed.route === "fast" ? fastRouteStages(STAGE_NAMES) : null);
|
|
1138
|
+
if (routeSubset) console.log("route:", routed.route, routed.features.join("+"), "—", STAGE_NAMES.length - routeSubset.length, "lanes dropped");
|
|
1058
1139
|
try {
|
|
1059
1140
|
const tR = Date.now();
|
|
1060
1141
|
// ── The "my account" live read (TODO.ai-platform/03) — resolved
|
|
@@ -1111,7 +1192,8 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1111
1192
|
// publications there).
|
|
1112
1193
|
retrieved = await retrieve(env, q.query, { prev, understanding, federate, warmEmbed, graphDocNumbers,
|
|
1113
1194
|
sealScope: declaredScoped ? docScope : null, optimisticHits, optimisticVec,
|
|
1114
|
-
datasetScope: narrowed ? corpora : null, standardKeys, lexicalBoost, licensedDocNumbers
|
|
1195
|
+
datasetScope: narrowed ? corpora : null, standardKeys, lexicalBoost, licensedDocNumbers,
|
|
1196
|
+
...(routeSubset ? { stageSubset: routeSubset } : {}) });
|
|
1115
1197
|
stageTiming["retrieve-core"] = Date.now() - tR;
|
|
1116
1198
|
console.log("stage: retrieve", Date.now() - tR, "ms");
|
|
1117
1199
|
// ── TTFT surgery: the two post-retrieval LLM calls run IN PARALLEL —
|
|
@@ -1145,7 +1227,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1145
1227
|
if (grade === "weak" && understanding?.docidentifier) {
|
|
1146
1228
|
const broaden = `${understanding.standalone_query || q.query} ${understanding.docidentifier}`.trim();
|
|
1147
1229
|
const tc = Date.now();
|
|
1148
|
-
const second = await retrieve(env, q.query, { prev, understanding, queryOverride: broaden, federate, datasetScope: narrowed ? corpora : null, standardKeys, sealScope: declaredScoped ? docScope : null, lexicalBoost });
|
|
1230
|
+
const second = await retrieve(env, q.query, { prev, understanding, queryOverride: broaden, federate, datasetScope: narrowed ? corpora : null, standardKeys, sealScope: declaredScoped ? docScope : null, lexicalBoost, ...(routeSubset ? { stageSubset: routeSubset } : {}) });
|
|
1149
1231
|
const grade2 = await gradeRetrieval(env.AI, roleModel(env, "grader"), q.query, second.hits.map((h: Hit) => h.text));
|
|
1150
1232
|
stageTiming.corrective = Date.now() - tc;
|
|
1151
1233
|
if (grade2 === "good") retrieved = second; // corrective retry must be strictly better
|
|
@@ -1163,7 +1245,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1163
1245
|
// point at the declare flow
|
|
1164
1246
|
const answer =
|
|
1165
1247
|
licenseBoundaryRefusal(modelDocHint?.doc_number ?? understanding?.doc_number ?? null, standardKeys) ?? refusalAnswer();
|
|
1166
|
-
const out = { answer, citations: [], model, query_hash: await sha256Hex(q.query), context_applied: ctxApplied };
|
|
1248
|
+
const out = { answer, citations: [], model, query_hash: await sha256Hex(q.query), context_applied: ctxApplied, ...(ablate ? { ablate } : {}), route: { route: routed.route, features: routed.features } };
|
|
1167
1249
|
telemetry(env, ctx, tier, "ask", model, true, answer.length, out.query_hash, q.lang, undefined, telemetryMeta());
|
|
1168
1250
|
return json({ ...out, quota, });
|
|
1169
1251
|
}
|
|
@@ -1304,7 +1386,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1304
1386
|
const cites = boundModel ? [modelCitation(boundModel), ...citations(usedHits)] : citations(usedHits);
|
|
1305
1387
|
|
|
1306
1388
|
if (wantsStream) {
|
|
1307
|
-
const stream = await generateStream(env, model, messages,
|
|
1389
|
+
const stream = await generateStream(env, model, messages, routeEffort(routed.route));
|
|
1308
1390
|
if (stream) {
|
|
1309
1391
|
const encoder = new TextEncoder();
|
|
1310
1392
|
const sse = new ReadableStream({
|
|
@@ -1349,7 +1431,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1349
1431
|
...messages.slice(0, -1),
|
|
1350
1432
|
{ role: "user", content: "The photograph was attached and the register note lists actual matching certificate rows. Present those rows — number, holder, model, status, and every document link — as the answer. Do not claim that nothing was provided." },
|
|
1351
1433
|
messages[messages.length - 1],
|
|
1352
|
-
],
|
|
1434
|
+
], routeEffort(routed.route));
|
|
1353
1435
|
if (regen) {
|
|
1354
1436
|
full = "";
|
|
1355
1437
|
try {
|
|
@@ -1401,7 +1483,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1401
1483
|
if (streamed.violations.length > 0) {
|
|
1402
1484
|
console.log("anchors:", streamed.violations.length, "of", streamed.total, "unverified — not caching");
|
|
1403
1485
|
}
|
|
1404
|
-
if (streamed.violations.length === 0 && canonical.length > 0 && !contextual && !declaredCtx && !canonical.includes(refusalAnswer())) {
|
|
1486
|
+
if (streamed.violations.length === 0 && canonical.length > 0 && !contextual && !declaredCtx && !canonical.includes(refusalAnswer()) && !ablate) {
|
|
1405
1487
|
ctx.waitUntil(
|
|
1406
1488
|
env.CACHE.put(exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt))), JSON.stringify({ answer: canonical, citations: cites, model, query_hash: queryHash, ...(sourceQuality ? { source_quality: sourceQuality, confidence_note: streamedNote || qualityNote(sourceQuality) } : {}), ...(streamedEntailment ? { entailment: streamedEntailment } : {}) }), { expirationTtl: LIMITS.cacheTtlSec }),
|
|
1407
1489
|
);
|
|
@@ -1421,7 +1503,28 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1421
1503
|
}
|
|
1422
1504
|
|
|
1423
1505
|
const tGen = Date.now();
|
|
1424
|
-
|
|
1506
|
+
// ── speculative draft-verify (TODO.sota/02 row 2): when the ablation
|
|
1507
|
+
// config arms it, the cheap model drafts per diversified subset in
|
|
1508
|
+
// parallel and one strong call verifies against the FULL window. The
|
|
1509
|
+
// seam replaces only the primary generation call — the contract
|
|
1510
|
+
// checks below run on the verifier's output exactly as on any answer —
|
|
1511
|
+
// and any failure falls through to the normal single-shot path.
|
|
1512
|
+
let answer: string | null = null;
|
|
1513
|
+
if (ablate?.speculative) {
|
|
1514
|
+
const spec = await speculativeDraftVerify(env, {
|
|
1515
|
+
query: q.query, hits: usedHits, lang: q.lang,
|
|
1516
|
+
draftModel: MODELS.member, verifierModel: roleModel(env, "verifier"),
|
|
1517
|
+
}).catch((e: unknown) => {
|
|
1518
|
+
console.log("speculative: failed, falling through —", String(e).slice(0, 120));
|
|
1519
|
+
return null;
|
|
1520
|
+
});
|
|
1521
|
+
if (spec) {
|
|
1522
|
+
answer = spec.answer;
|
|
1523
|
+
stageTiming.speculative = Date.now() - tGen;
|
|
1524
|
+
console.log("speculative:", spec.nDrafts, "drafts,", spec.chosen, "viable — verified");
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (!answer) answer = await generateOnce(env, model, messages, routeEffort(routed.route));
|
|
1425
1528
|
if (answer === null) {
|
|
1426
1529
|
generateRetries += 1;
|
|
1427
1530
|
// the fallback is a text-only model: image parts must be flattened
|
|
@@ -1441,7 +1544,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1441
1544
|
? m
|
|
1442
1545
|
: { ...m, content: m.content.filter((p: any) => p?.type === "text").map((p: any) => (p?.text ?? "").replace(/\n?\(The user attached an image with this question; interpret it directly when answering\.\)/, "")).join("\n") },
|
|
1443
1546
|
);
|
|
1444
|
-
answer = await generateOnce(env, MODELS.fallback, flat,
|
|
1547
|
+
answer = await generateOnce(env, MODELS.fallback, flat, routeEffort(routed.route));
|
|
1445
1548
|
}
|
|
1446
1549
|
if (answer) answer = canonicalRefusal(answer);
|
|
1447
1550
|
stageTiming.generate = Date.now() - tGen;
|
|
@@ -1479,7 +1582,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1479
1582
|
? `Correction notice: your draft reproduced a table as markdown or presented a served table's data without its reference. Rewrite the answer: describe the table in prose, cite the clause, and write the reference token [[u:${tableUnitId ?? "<unit id>"}]] exactly where the table belongs. Do not render any table as markdown.`
|
|
1480
1583
|
: ANCHOR_CORRECTION_NOTE;
|
|
1481
1584
|
generateRetries += 1;
|
|
1482
|
-
const corrected = await generateOnce(env, model, [...messages, { role: "system", content: note }],
|
|
1585
|
+
const corrected = await generateOnce(env, model, [...messages, { role: "system", content: note }], routeEffort(routed.route));
|
|
1483
1586
|
if (corrected) {
|
|
1484
1587
|
const correctedAnswer = canonicalRefusal(corrected);
|
|
1485
1588
|
const retryAnchors = checkQuoteAnchors(correctedAnswer, used.map((h: Hit) => h.text));
|
|
@@ -1519,10 +1622,11 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1519
1622
|
sealScope: declaredScoped ? docScope : null,
|
|
1520
1623
|
datasetScope: narrowed ? corpora : null,
|
|
1521
1624
|
standardKeys,
|
|
1625
|
+
...(routeSubset ? { stageSubset: routeSubset } : {}),
|
|
1522
1626
|
});
|
|
1523
1627
|
if (retryRetrieve.hits.length > 0) {
|
|
1524
1628
|
const { messages: retryMessages, usedHits: retryUsed } = buildMessages(q.query, retryRetrieve.hits, q.lang, keptHistory, undefined, summary, budget);
|
|
1525
|
-
const retryAnswer = await generateOnce(env, model, retryMessages,
|
|
1629
|
+
const retryAnswer = await generateOnce(env, model, retryMessages, routeEffort(routed.route));
|
|
1526
1630
|
// the answer now comes from the retry passages — citations must follow
|
|
1527
1631
|
if (retryAnswer) {
|
|
1528
1632
|
answer = canonicalRefusal(retryAnswer);
|
|
@@ -1602,8 +1706,8 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1602
1706
|
if (v.support !== "supported") console.log("entailment:", v.support, raced.score, raced.ungrounded_claims?.slice(0, 2));
|
|
1603
1707
|
}
|
|
1604
1708
|
}
|
|
1605
|
-
const out = { answer, citations: finalCites, ...(jsonQuality ? { source_quality: jsonQuality, confidence_note: entailmentNote || qualityNote(jsonQuality), ...(entailment ? { entailment } : {}), ...(jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {}) } : {}), model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}) };
|
|
1606
|
-
const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
|
|
1709
|
+
const out = { answer, citations: finalCites, ...(jsonQuality ? { source_quality: jsonQuality, confidence_note: entailmentNote || qualityNote(jsonQuality), ...(entailment ? { entailment } : {}), ...(jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {}) } : {}), model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}), ...(ablate ? { ablate } : {}), route: { route: routed.route, features: routed.features } };
|
|
1710
|
+
const cacheable = !contextual && !declaredCtx && !ablate && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
|
|
1607
1711
|
if (cacheable) {
|
|
1608
1712
|
const ck = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt)));
|
|
1609
1713
|
ctx.waitUntil(env.CACHE.put(ck, JSON.stringify(out), { expirationTtl: LIMITS.cacheTtlSec }));
|
|
@@ -24,6 +24,11 @@ export const MODELS = {
|
|
|
24
24
|
listwise: "@cf/zai-org/glm-4.7-flash",
|
|
25
25
|
// deep-research loop (G10): bounded agentic iterations, members-only
|
|
26
26
|
research: "@cf/zai-org/glm-5.3-flash",
|
|
27
|
+
// speculative draft-verify (TODO.sota/02 row 2): the strong verifier
|
|
28
|
+
// over the cheap drafts — the research-tier model verifies; the drafter
|
|
29
|
+
// is the standard answer model (MODELS.member). VERIFIER_MODEL swaps it
|
|
30
|
+
// live, same lever as every role.
|
|
31
|
+
verifier: "@cf/zai-org/glm-5.2",
|
|
27
32
|
} as const;
|
|
28
33
|
|
|
29
34
|
export const LIMITS = {
|
|
@@ -62,6 +67,11 @@ export const THRESHOLDS = {
|
|
|
62
67
|
* pool below the primary dense lane; they must earn their window
|
|
63
68
|
* slot under the cross-encoder, not by graph membership alone. */
|
|
64
69
|
graphLaneDiscount: 0.75,
|
|
70
|
+
/** The PPR lane's merge discount (TODO.sota/06, catalog row 4): graph-
|
|
71
|
+
* propagated candidates arrive at the same half-discount the narrowed
|
|
72
|
+
* graph lane pays (rag#137's law — flooding dilutes the pool), and
|
|
73
|
+
* only the cross-encoder's preference survives the cut. */
|
|
74
|
+
pprDiscount: 0.375,
|
|
65
75
|
licensedLaneScore: 0.5,
|
|
66
76
|
/** The entailment gate (TODO.new-era/10): the faithfulness scorer
|
|
67
77
|
* promoted from eval-only onto the serving path. The confidence line
|
|
@@ -36,7 +36,7 @@ import { QueryFilters, toVectorizeFilter, standardKeyAllowed } from "./selfquery
|
|
|
36
36
|
import { hitQuality } from "./quality";
|
|
37
37
|
import { lexicalPrefilter } from "./lexical";
|
|
38
38
|
import { positionOrder } from "./structural";
|
|
39
|
-
import { STAGES, runStages } from "./stages";
|
|
39
|
+
import { STAGES, projectStages, runStages } from "./stages";
|
|
40
40
|
import type { PipelineContext, RetrieveOptions, GlossaryEntry } from "./stages/types";
|
|
41
41
|
// the chunk wire contract lives with its pydantic twin (workers/shared/
|
|
42
42
|
// chunk.ts ↔ ingest/vector_adapter.py); re-exported here so the existing
|
|
@@ -144,7 +144,10 @@ export async function retrieve(
|
|
|
144
144
|
env, query, rq, folded, u, filters, filter, vector, lexicalHits,
|
|
145
145
|
matches: [], hits: [], finalHits: [], glossary: [], notes: [], opts, lane: {},
|
|
146
146
|
};
|
|
147
|
-
|
|
147
|
+
// the registry IS the switch: an ablation configuration projects the
|
|
148
|
+
// SAME registry (same modules, same order) to its named subset — a
|
|
149
|
+
// narrower pipeline, never a forked one
|
|
150
|
+
await runStages(opts.stageSubset?.length ? projectStages(opts.stageSubset) : STAGES, ctx);
|
|
148
151
|
return {
|
|
149
152
|
hits: ctx.finalHits,
|
|
150
153
|
filters: ctx.filters ?? {},
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ── The adaptive router (TODO.sota/05, the catalog's row 1) ──
|
|
2
|
+
//
|
|
3
|
+
// Query features (understanding's own output, never string hacks) pick
|
|
4
|
+
// the route: FAST drops the candidate-expansion lanes (hyde, multi-
|
|
5
|
+
// query, sub-query — each costs extra embeds and Vectorize queries, and
|
|
6
|
+
// for a scoped, definitional, or short question they rarely fire); DEEP
|
|
7
|
+
// keeps the full registry. The stage registry IS the switch: the route
|
|
8
|
+
// is a projection of the same registry, never a second pipeline.
|
|
9
|
+
//
|
|
10
|
+
// The policy is deliberately conservative — ambiguous questions route
|
|
11
|
+
// DEEP, and the caller-declared effort always wins — because the
|
|
12
|
+
// routing frontier is a measured claim (TODO.sota/09's grid scores
|
|
13
|
+
// route-fast, route-deep and adaptive on the dev half; the policy
|
|
14
|
+
// thresholds tune after that evidence, never before).
|
|
15
|
+
//
|
|
16
|
+
// Self-contained (no imports) so the unit tests run on plain node type
|
|
17
|
+
// stripping, like ablate/answercache/verdict.
|
|
18
|
+
|
|
19
|
+
export type Route = "fast" | "deep";
|
|
20
|
+
|
|
21
|
+
/** Minimal structural shape of the understanding the router reads —
|
|
22
|
+
* the full contract lives in understandContract.ts; this subset keeps
|
|
23
|
+
* the module self-contained. */
|
|
24
|
+
export interface RouteFeatures {
|
|
25
|
+
complexity?: "simple" | "complex" | null;
|
|
26
|
+
doc_number?: string | null;
|
|
27
|
+
term?: string | null;
|
|
28
|
+
defined_terms?: string[];
|
|
29
|
+
sub_queries?: string[];
|
|
30
|
+
process_intent?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** THRESHOLDS-adjacent policy numbers (declared here until the grid
|
|
34
|
+
* measures them; every steering number lives in one place — this is
|
|
35
|
+
* the router's place). */
|
|
36
|
+
export const ROUTE_FAST_WORD_CAP = 10;
|
|
37
|
+
|
|
38
|
+
/** The candidate-expansion lanes the fast route drops (the stage
|
|
39
|
+
* names in the registry). */
|
|
40
|
+
export const ROUTE_FAST_DROPS = ["hyde", "multi-query", "sub-query"];
|
|
41
|
+
|
|
42
|
+
/** Route one question from its understanding features. Returns the
|
|
43
|
+
* route and the fired features — the response echoes both, which is
|
|
44
|
+
* the telemetry the routing-accuracy analysis reads (TODO.sota/05). */
|
|
45
|
+
export function routeFor(u: RouteFeatures | null, query: string): {
|
|
46
|
+
route: Route;
|
|
47
|
+
features: string[];
|
|
48
|
+
} {
|
|
49
|
+
const features: string[] = [];
|
|
50
|
+
const words = query.trim().split(/\s+/).filter(Boolean).length;
|
|
51
|
+
|
|
52
|
+
// DEEP features — complexity is the understanding model's own
|
|
53
|
+
// multi-perspective judgment; sub-queries and process intent both
|
|
54
|
+
// need the widest candidate pool
|
|
55
|
+
if (u?.complexity === "complex") features.push("complex");
|
|
56
|
+
if ((u?.sub_queries ?? []).length > 0) features.push("sub-queries");
|
|
57
|
+
if (u?.process_intent) features.push("process-intent");
|
|
58
|
+
if (words > ROUTE_FAST_WORD_CAP) features.push("long-question");
|
|
59
|
+
if (features.length) return { route: "deep", features };
|
|
60
|
+
|
|
61
|
+
// FAST — everything else: a question within the word cap rides the
|
|
62
|
+
// fast registry. The conservative direction is structural here: the
|
|
63
|
+
// long, the complex, the multi-perspective and the process-shaped all
|
|
64
|
+
// routed deep above; under-routing costs accuracy, over-routing costs
|
|
65
|
+
// only latency, and the grid prices both.
|
|
66
|
+
if (u?.doc_number) features.push("doc-scoped");
|
|
67
|
+
if (u?.term) features.push("definitional");
|
|
68
|
+
if ((u?.defined_terms ?? []).length > 0) features.push("terminology");
|
|
69
|
+
features.push("short-question");
|
|
70
|
+
return { route: "fast", features };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The fast route's stage list: the registry minus the expansion lanes.
|
|
74
|
+
* projectStages owns the ordering and validation semantics. */
|
|
75
|
+
export function fastRouteStages(registry: readonly string[]): string[] {
|
|
76
|
+
return registry.filter((n) => !ROUTE_FAST_DROPS.includes(n));
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ── Speculative draft-verify, the geometry (TODO.sota/02, row 2) ──
|
|
2
|
+
//
|
|
3
|
+
// The partition of the answer window into DIVERSIFIED subsets: hits
|
|
4
|
+
// group by document (first-seen order), the groups deal round-robin
|
|
5
|
+
// into the subsets, and each subset keeps reading order. Fewer than two
|
|
6
|
+
// distinct documents means nothing to diversify — the orchestration
|
|
7
|
+
// (ask.ts) falls through to the normal single-shot generation.
|
|
8
|
+
//
|
|
9
|
+
// Self-contained (no imports) so the unit tests run on plain node type
|
|
10
|
+
// stripping, like ablate/answercache/verdict. The LLM legs (draft per
|
|
11
|
+
// subset, the strong verifier) live in ask.ts beside the other
|
|
12
|
+
// ask-path mechanisms; their gate is the ablation grid (TODO.sota/09),
|
|
13
|
+
// never the unit suite.
|
|
14
|
+
|
|
15
|
+
export const SPECULATIVE_SUBSETS = 3;
|
|
16
|
+
|
|
17
|
+
/** Minimal structural shape the partition reads. */
|
|
18
|
+
export interface Partitionable {
|
|
19
|
+
metadata: { docidentifier?: string; doc_id?: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function partitionSubsets<T extends Partitionable>(
|
|
23
|
+
hits: T[],
|
|
24
|
+
k: number = SPECULATIVE_SUBSETS,
|
|
25
|
+
): T[][] | null {
|
|
26
|
+
const groups: T[][] = [];
|
|
27
|
+
const byDoc = new Map<string, T[]>();
|
|
28
|
+
for (const h of hits) {
|
|
29
|
+
const key = h.metadata.docidentifier || h.metadata.doc_id || "";
|
|
30
|
+
const arr = byDoc.get(key) ?? [];
|
|
31
|
+
arr.push(h);
|
|
32
|
+
if (arr.length === 1) groups.push(arr);
|
|
33
|
+
byDoc.set(key, arr);
|
|
34
|
+
}
|
|
35
|
+
if (groups.length < 2) return null;
|
|
36
|
+
const subsets: T[][] = Array.from({ length: Math.min(k, groups.length) }, () => []);
|
|
37
|
+
groups.forEach((g, i) => subsets[i % subsets.length].push(...g));
|
|
38
|
+
return subsets;
|
|
39
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Graph PPR lane (additive, TODO.sota/06 — the catalog's row 4,
|
|
2
|
+
// HippoRAG-style multi-hop memory): Personalized-PageRank over the D1
|
|
3
|
+
// graph projection, seeded from the query's resolved entities — the
|
|
4
|
+
// vocabulary link's concepts and the understood publication's family
|
|
5
|
+
// node — spreading over the domain's own law edges (defines, successor,
|
|
6
|
+
// part_of, variant_of, amends, cites). Documents ranked by stationary
|
|
7
|
+
// mass merge their query-relevant candidates like the graph lane does:
|
|
8
|
+
// multi-hop neighborhoods (a term defined by R 87, whose family cites
|
|
9
|
+
// R 61) surface without any hand-written traversal.
|
|
10
|
+
//
|
|
11
|
+
// The neighborhood is bounded (two hops, capped frontier) and the
|
|
12
|
+
// iteration is deterministic power iteration over the undirected
|
|
13
|
+
// adjacency — same query, same ranks, always.
|
|
14
|
+
import { THRESHOLDS } from "../config.ts";
|
|
15
|
+
import { lexicalWithin } from "../lexical.ts";
|
|
16
|
+
import { refCodec } from "../codecs.ts";
|
|
17
|
+
import { portStore } from "../env.ts";
|
|
18
|
+
import type { StoreQuery } from "../ports/store.ts";
|
|
19
|
+
import type { Stage } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
const EDGE_KINDS = "('defines','successor','part_of','variant_of','amends','cites')";
|
|
22
|
+
const PPR_DAMPING = 0.85;
|
|
23
|
+
const PPR_ITERATIONS = 12;
|
|
24
|
+
const PPR_FRONTIER_CAP = 24;
|
|
25
|
+
|
|
26
|
+
/** Deterministic Personalized-PageRank: uniform teleport over the
|
|
27
|
+
* seeds, propagation over the UNDIRECTED adjacency (typed direction
|
|
28
|
+
* matters for semantics, not for spread — a doc defines a concept as
|
|
29
|
+
* much as the concept is defined by the doc). Pure: the unit tests
|
|
30
|
+
* pin the convergence and the seed preference. */
|
|
31
|
+
export function pageRank(
|
|
32
|
+
edges: [string, string][],
|
|
33
|
+
seeds: string[],
|
|
34
|
+
opts: { damping?: number; iterations?: number } = {},
|
|
35
|
+
): Map<string, number> {
|
|
36
|
+
const damping = opts.damping ?? PPR_DAMPING;
|
|
37
|
+
const iterations = opts.iterations ?? PPR_ITERATIONS;
|
|
38
|
+
const adj = new Map<string, string[]>();
|
|
39
|
+
for (const [a, b] of edges) {
|
|
40
|
+
if (!adj.has(a)) adj.set(a, []);
|
|
41
|
+
if (!adj.has(b)) adj.set(b, []);
|
|
42
|
+
adj.get(a)!.push(b);
|
|
43
|
+
adj.get(b)!.push(a);
|
|
44
|
+
}
|
|
45
|
+
const live = seeds.filter((s) => adj.has(s));
|
|
46
|
+
if (!live.length) return new Map();
|
|
47
|
+
const base = 1 / live.length;
|
|
48
|
+
let rank = new Map<string, number>(live.map((s) => [s, base]));
|
|
49
|
+
for (let it = 0; it < iterations; it++) {
|
|
50
|
+
const next = new Map<string, number>();
|
|
51
|
+
for (const s of live) next.set(s, (1 - damping) * base);
|
|
52
|
+
for (const [node, r] of rank) {
|
|
53
|
+
const nbrs = adj.get(node);
|
|
54
|
+
if (!nbrs?.length) {
|
|
55
|
+
// a dangling node returns its mass to the seeds
|
|
56
|
+
for (const s of live) next.set(s, (next.get(s) ?? 0) + damping * r * base);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const share = (damping * r) / nbrs.length;
|
|
60
|
+
for (const n of nbrs) next.set(n, (next.get(n) ?? 0) + share);
|
|
61
|
+
}
|
|
62
|
+
rank = next;
|
|
63
|
+
}
|
|
64
|
+
return rank;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Seed node ids for a query: the glossary's concepts (label match,
|
|
68
|
+
* the concept-graph lane's resolution) and the understood
|
|
69
|
+
* publication's family node. */
|
|
70
|
+
async function seedNodes(store: StoreQuery, glossary: { term: string }[], docidentifier: string | null): Promise<string[]> {
|
|
71
|
+
const jobs: Promise<string[]>[] = [];
|
|
72
|
+
for (const gl of glossary.slice(0, 3)) {
|
|
73
|
+
if (gl.term.length < 3) continue;
|
|
74
|
+
jobs.push(
|
|
75
|
+
store.prepare(
|
|
76
|
+
"SELECT id FROM graph_nodes WHERE kind = 'concept' AND (label = ?1 OR label LIKE ?2) LIMIT 6",
|
|
77
|
+
)
|
|
78
|
+
.bind(gl.term, `%${gl.term}%`)
|
|
79
|
+
.all()
|
|
80
|
+
.then((r: any) => (r.results ?? []).map((x: any) => String(x.id)))
|
|
81
|
+
.catch(() => [] as string[]),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const fam = docidentifier ? refCodec().familyOf(docidentifier) : null;
|
|
85
|
+
if (fam) {
|
|
86
|
+
jobs.push(
|
|
87
|
+
store.prepare("SELECT id FROM graph_nodes WHERE kind = 'family' AND id = ?1")
|
|
88
|
+
.bind(`family:${fam}`)
|
|
89
|
+
.first()
|
|
90
|
+
.then((r: any) => (r?.id ? [String(r.id)] : []))
|
|
91
|
+
.catch(() => [] as string[]),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const settled = await Promise.all(jobs);
|
|
95
|
+
return [...new Set(settled.flat())].slice(0, 4);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Two bounded hops of the seed neighborhood: the edge list and the
|
|
99
|
+
* frontier's node kinds (doc nodes are what retrieval can use). */
|
|
100
|
+
async function neighborhood(store: StoreQuery, seeds: string[]): Promise<{ edges: [string, string][]; kinds: Map<string, string> }> {
|
|
101
|
+
const seenEdges = new Set<string>();
|
|
102
|
+
const edges: [string, string][] = [];
|
|
103
|
+
let frontier = seeds.slice(0, PPR_FRONTIER_CAP);
|
|
104
|
+
const visited = new Set(seeds);
|
|
105
|
+
for (let hop = 0; hop < 2 && frontier.length; hop++) {
|
|
106
|
+
const marks = frontier.map(() => "?").join(",");
|
|
107
|
+
const rows = (await store.prepare(
|
|
108
|
+
`SELECT src, dst FROM graph_edges WHERE kind IN ${EDGE_KINDS} AND (src IN (${marks}) OR dst IN (${marks})) LIMIT 400`,
|
|
109
|
+
)
|
|
110
|
+
.bind(...frontier)
|
|
111
|
+
.all()
|
|
112
|
+
.catch(() => ({ results: [] }))) as any;
|
|
113
|
+
const next: string[] = [];
|
|
114
|
+
for (const r of rows.results ?? []) {
|
|
115
|
+
const key = `${r.src}>${r.dst}`;
|
|
116
|
+
if (seenEdges.has(key)) continue;
|
|
117
|
+
seenEdges.add(key);
|
|
118
|
+
edges.push([String(r.src), String(r.dst)]);
|
|
119
|
+
for (const n of [String(r.src), String(r.dst)]) {
|
|
120
|
+
if (!visited.has(n)) {
|
|
121
|
+
visited.add(n);
|
|
122
|
+
if (next.length < PPR_FRONTIER_CAP) next.push(n);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
frontier = next;
|
|
127
|
+
}
|
|
128
|
+
const nodeIds = [...visited].slice(0, 60);
|
|
129
|
+
const kinds = new Map<string, string>();
|
|
130
|
+
for (let i = 0; i < nodeIds.length; i += 30) {
|
|
131
|
+
const chunk = nodeIds.slice(i, i + 30);
|
|
132
|
+
const marks = chunk.map(() => "?").join(",");
|
|
133
|
+
const rows = (await store.prepare(`SELECT id, kind FROM graph_nodes WHERE id IN (${marks})`)
|
|
134
|
+
.bind(...chunk)
|
|
135
|
+
.all()
|
|
136
|
+
.catch(() => ({ results: [] }))) as any;
|
|
137
|
+
for (const r of rows.results ?? []) kinds.set(String(r.id), String(r.kind));
|
|
138
|
+
}
|
|
139
|
+
return { edges, kinds };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The ranked document numbers for the query's seeds: PPR mass over
|
|
143
|
+
* the neighborhood, doc nodes only, rendered through the codec. */
|
|
144
|
+
export async function pprDocuments(
|
|
145
|
+
store: StoreQuery,
|
|
146
|
+
glossary: { term: string }[],
|
|
147
|
+
docidentifier: string | null,
|
|
148
|
+
): Promise<string[]> {
|
|
149
|
+
const seeds = await seedNodes(store, glossary, docidentifier);
|
|
150
|
+
if (!seeds.length) return [];
|
|
151
|
+
const { edges, kinds } = await neighborhood(store, seeds);
|
|
152
|
+
if (!edges.length) return [];
|
|
153
|
+
const rank = pageRank(edges, seeds);
|
|
154
|
+
const docs = [...rank.entries()]
|
|
155
|
+
.filter(([id, mass]) => kinds.get(id) === "doc" && mass > 0)
|
|
156
|
+
.sort((a, b) => b[1] - a[1])
|
|
157
|
+
.map(([id]) => refCodec().graphDocNumber(id))
|
|
158
|
+
.filter((n): n is string => !!n);
|
|
159
|
+
return [...new Set(docs)].slice(0, 6);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const graphPpr: Stage = {
|
|
163
|
+
name: "graph-ppr",
|
|
164
|
+
failure: "additive",
|
|
165
|
+
// no raw-binding guard: a deployment without the store degrades
|
|
166
|
+
// through the prefetch's own catch (the lane stays silent)
|
|
167
|
+
when: (c) => (c.glossary.length > 0 || !!c.u?.docidentifier) && c.vector.length > 0,
|
|
168
|
+
prefetch: (c) => {
|
|
169
|
+
c.lane["graph-ppr"] = pprDocuments(portStore(c.env), c.glossary, c.u?.docidentifier ?? null).catch(() => [] as string[]);
|
|
170
|
+
},
|
|
171
|
+
run: async (c) => {
|
|
172
|
+
const docs = (await c.lane["graph-ppr"]!) as string[];
|
|
173
|
+
if (!docs?.length) return;
|
|
174
|
+
// same identity resolution and narrowing law as the graph lane
|
|
175
|
+
// (rag#137): BM25-within-set candidates at the half discount, five
|
|
176
|
+
// at most, cross-encoder preference decides
|
|
177
|
+
const g = await lexicalWithin(c.env, c.rq || c.query, docs, 6);
|
|
178
|
+
const seenIds = new Set(c.matches.map((m: any) => m.id));
|
|
179
|
+
let merged = 0;
|
|
180
|
+
for (const m of (g ?? []).slice(0, 5)) {
|
|
181
|
+
if (!seenIds.has(m.id)) {
|
|
182
|
+
c.matches.push({ id: m.id, score: m.score * THRESHOLDS.pprDiscount, metadata: m.metadata });
|
|
183
|
+
seenIds.add(m.id);
|
|
184
|
+
merged++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (merged) console.log("graph ppr:", docs.join(","), "— merged", merged);
|
|
188
|
+
},
|
|
189
|
+
};
|