@aigne/afs-index 1.12.0-beta.5
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/LICENSE.md +26 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.cjs +11 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs +10 -0
- package/dist/backend/d1-index-backend.cjs +656 -0
- package/dist/backend/d1-index-backend.d.cts +149 -0
- package/dist/backend/d1-index-backend.d.cts.map +1 -0
- package/dist/backend/d1-index-backend.d.mts +149 -0
- package/dist/backend/d1-index-backend.d.mts.map +1 -0
- package/dist/backend/d1-index-backend.mjs +657 -0
- package/dist/backend/d1-index-backend.mjs.map +1 -0
- package/dist/backend/d1-vector-backend.cjs +83 -0
- package/dist/backend/d1-vector-backend.d.cts +21 -0
- package/dist/backend/d1-vector-backend.d.cts.map +1 -0
- package/dist/backend/d1-vector-backend.d.mts +21 -0
- package/dist/backend/d1-vector-backend.d.mts.map +1 -0
- package/dist/backend/d1-vector-backend.mjs +84 -0
- package/dist/backend/d1-vector-backend.mjs.map +1 -0
- package/dist/backend/memory-backend.cjs +304 -0
- package/dist/backend/memory-backend.d.cts +66 -0
- package/dist/backend/memory-backend.d.cts.map +1 -0
- package/dist/backend/memory-backend.d.mts +66 -0
- package/dist/backend/memory-backend.d.mts.map +1 -0
- package/dist/backend/memory-backend.mjs +305 -0
- package/dist/backend/memory-backend.mjs.map +1 -0
- package/dist/backend/prefix-range.cjs +25 -0
- package/dist/backend/prefix-range.mjs +25 -0
- package/dist/backend/prefix-range.mjs.map +1 -0
- package/dist/backend/schema.cjs +187 -0
- package/dist/backend/schema.mjs +188 -0
- package/dist/backend/schema.mjs.map +1 -0
- package/dist/backend/sqlite-backend.cjs +889 -0
- package/dist/backend/sqlite-backend.d.cts +96 -0
- package/dist/backend/sqlite-backend.d.cts.map +1 -0
- package/dist/backend/sqlite-backend.d.mts +96 -0
- package/dist/backend/sqlite-backend.d.mts.map +1 -0
- package/dist/backend/sqlite-backend.mjs +890 -0
- package/dist/backend/sqlite-backend.mjs.map +1 -0
- package/dist/backend/types.d.cts +209 -0
- package/dist/backend/types.d.cts.map +1 -0
- package/dist/backend/types.d.mts +209 -0
- package/dist/backend/types.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.cjs +98 -0
- package/dist/backend/upstash-vector-backend.d.cts +21 -0
- package/dist/backend/upstash-vector-backend.d.cts.map +1 -0
- package/dist/backend/upstash-vector-backend.d.mts +21 -0
- package/dist/backend/upstash-vector-backend.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.mjs +98 -0
- package/dist/backend/upstash-vector-backend.mjs.map +1 -0
- package/dist/backend/vector-backend.d.cts +29 -0
- package/dist/backend/vector-backend.d.cts.map +1 -0
- package/dist/backend/vector-backend.d.mts +29 -0
- package/dist/backend/vector-backend.d.mts.map +1 -0
- package/dist/dirty-queue.d.cts +41 -0
- package/dist/dirty-queue.d.cts.map +1 -0
- package/dist/dirty-queue.d.mts +41 -0
- package/dist/dirty-queue.d.mts.map +1 -0
- package/dist/embed.cjs +45 -0
- package/dist/embed.d.cts +20 -0
- package/dist/embed.d.cts.map +1 -0
- package/dist/embed.d.mts +20 -0
- package/dist/embed.d.mts.map +1 -0
- package/dist/embed.mjs +46 -0
- package/dist/embed.mjs.map +1 -0
- package/dist/extraction/defaults.cjs +242 -0
- package/dist/extraction/defaults.d.cts +14 -0
- package/dist/extraction/defaults.d.cts.map +1 -0
- package/dist/extraction/defaults.d.mts +14 -0
- package/dist/extraction/defaults.d.mts.map +1 -0
- package/dist/extraction/defaults.mjs +242 -0
- package/dist/extraction/defaults.mjs.map +1 -0
- package/dist/extraction/field-map.cjs +162 -0
- package/dist/extraction/field-map.d.cts +70 -0
- package/dist/extraction/field-map.d.cts.map +1 -0
- package/dist/extraction/field-map.d.mts +70 -0
- package/dist/extraction/field-map.d.mts.map +1 -0
- package/dist/extraction/field-map.mjs +157 -0
- package/dist/extraction/field-map.mjs.map +1 -0
- package/dist/extraction/manager.cjs +74 -0
- package/dist/extraction/manager.d.cts +47 -0
- package/dist/extraction/manager.d.cts.map +1 -0
- package/dist/extraction/manager.d.mts +47 -0
- package/dist/extraction/manager.d.mts.map +1 -0
- package/dist/extraction/manager.mjs +74 -0
- package/dist/extraction/manager.mjs.map +1 -0
- package/dist/extraction/path-anchors.cjs +86 -0
- package/dist/extraction/path-anchors.d.cts +72 -0
- package/dist/extraction/path-anchors.d.cts.map +1 -0
- package/dist/extraction/path-anchors.d.mts +72 -0
- package/dist/extraction/path-anchors.d.mts.map +1 -0
- package/dist/extraction/path-anchors.mjs +86 -0
- package/dist/extraction/path-anchors.mjs.map +1 -0
- package/dist/follower-scheduler.cjs +92 -0
- package/dist/follower-scheduler.d.cts +58 -0
- package/dist/follower-scheduler.d.cts.map +1 -0
- package/dist/follower-scheduler.d.mts +58 -0
- package/dist/follower-scheduler.d.mts.map +1 -0
- package/dist/follower-scheduler.mjs +92 -0
- package/dist/follower-scheduler.mjs.map +1 -0
- package/dist/follower.cjs +577 -0
- package/dist/follower.d.cts +241 -0
- package/dist/follower.d.cts.map +1 -0
- package/dist/follower.d.mts +241 -0
- package/dist/follower.d.mts.map +1 -0
- package/dist/follower.mjs +571 -0
- package/dist/follower.mjs.map +1 -0
- package/dist/index-provider.cjs +1166 -0
- package/dist/index-provider.d.cts +175 -0
- package/dist/index-provider.d.cts.map +1 -0
- package/dist/index-provider.d.mts +175 -0
- package/dist/index-provider.d.mts.map +1 -0
- package/dist/index-provider.mjs +1167 -0
- package/dist/index-provider.mjs.map +1 -0
- package/dist/index.cjs +53 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.mts +22 -0
- package/dist/index.mjs +20 -0
- package/dist/keys/exact.cjs +20 -0
- package/dist/keys/exact.mjs +20 -0
- package/dist/keys/exact.mjs.map +1 -0
- package/dist/keys/location.cjs +13 -0
- package/dist/keys/location.mjs +13 -0
- package/dist/keys/location.mjs.map +1 -0
- package/dist/keys/normalized.cjs +62 -0
- package/dist/keys/normalized.mjs +63 -0
- package/dist/keys/normalized.mjs.map +1 -0
- package/dist/keys/person.cjs +15 -0
- package/dist/keys/person.mjs +15 -0
- package/dist/keys/person.mjs.map +1 -0
- package/dist/keys/registry.cjs +45 -0
- package/dist/keys/registry.d.cts +15 -0
- package/dist/keys/registry.d.cts.map +1 -0
- package/dist/keys/registry.d.mts +15 -0
- package/dist/keys/registry.d.mts.map +1 -0
- package/dist/keys/registry.mjs +46 -0
- package/dist/keys/registry.mjs.map +1 -0
- package/dist/keys/time.cjs +128 -0
- package/dist/keys/time.mjs +128 -0
- package/dist/keys/time.mjs.map +1 -0
- package/dist/keys/topic.cjs +23 -0
- package/dist/keys/topic.mjs +23 -0
- package/dist/keys/topic.mjs.map +1 -0
- package/dist/manifest-index.cjs +29 -0
- package/dist/manifest-index.d.cts +24 -0
- package/dist/manifest-index.d.cts.map +1 -0
- package/dist/manifest-index.d.mts +24 -0
- package/dist/manifest-index.d.mts.map +1 -0
- package/dist/manifest-index.mjs +28 -0
- package/dist/manifest-index.mjs.map +1 -0
- package/dist/offline-rebuild.cjs +48 -0
- package/dist/offline-rebuild.d.cts +57 -0
- package/dist/offline-rebuild.d.cts.map +1 -0
- package/dist/offline-rebuild.d.mts +57 -0
- package/dist/offline-rebuild.d.mts.map +1 -0
- package/dist/offline-rebuild.mjs +49 -0
- package/dist/offline-rebuild.mjs.map +1 -0
- package/dist/query/engine.cjs +433 -0
- package/dist/query/engine.d.cts +34 -0
- package/dist/query/engine.d.cts.map +1 -0
- package/dist/query/engine.d.mts +34 -0
- package/dist/query/engine.d.mts.map +1 -0
- package/dist/query/engine.mjs +433 -0
- package/dist/query/engine.mjs.map +1 -0
- package/dist/query/limits.cjs +12 -0
- package/dist/query/limits.mjs +12 -0
- package/dist/query/limits.mjs.map +1 -0
- package/dist/text/cjk.cjs +30 -0
- package/dist/text/cjk.d.cts +24 -0
- package/dist/text/cjk.d.cts.map +1 -0
- package/dist/text/cjk.d.mts +24 -0
- package/dist/text/cjk.d.mts.map +1 -0
- package/dist/text/cjk.mjs +30 -0
- package/dist/text/cjk.mjs.map +1 -0
- package/dist/text/fts.cjs +244 -0
- package/dist/text/fts.mjs +245 -0
- package/dist/text/fts.mjs.map +1 -0
- package/dist/text/host.cjs +39 -0
- package/dist/text/host.mjs +38 -0
- package/dist/text/host.mjs.map +1 -0
- package/dist/types.cjs +44 -0
- package/dist/types.d.cts +393 -0
- package/dist/types.d.cts.map +1 -0
- package/dist/types.d.mts +393 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +42 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
//#region src/extraction/manager.ts
|
|
2
|
+
var ExtractorManager = class {
|
|
3
|
+
registrations = /* @__PURE__ */ new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Register an extractor for a domain.
|
|
6
|
+
* @param domain - Domain name this extractor serves
|
|
7
|
+
* @param registration - Extractor configuration
|
|
8
|
+
*/
|
|
9
|
+
register(domain, registration) {
|
|
10
|
+
this.registrations.set(domain, registration);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Unregister an extractor for a domain.
|
|
14
|
+
*/
|
|
15
|
+
unregister(domain) {
|
|
16
|
+
this.registrations.delete(domain);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Find the first extractor whose scope matches the given path.
|
|
20
|
+
* @returns [domainName, registration] or undefined if no match
|
|
21
|
+
*/
|
|
22
|
+
findForPath(path) {
|
|
23
|
+
for (const [domain, reg] of this.registrations) if (path.startsWith(reg.scope)) return [domain, reg];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Dispatch extraction for a path using the matching extractor.
|
|
27
|
+
* Calls the extractor's AFS exec endpoint and returns the result.
|
|
28
|
+
*
|
|
29
|
+
* @returns ExtractionResult or null if no extractor matched or extraction failed
|
|
30
|
+
*/
|
|
31
|
+
async extract(path, root) {
|
|
32
|
+
const match = this.findForPath(path);
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
const [domain, reg] = match;
|
|
35
|
+
if (!reg.extractor || !root.exec) return null;
|
|
36
|
+
try {
|
|
37
|
+
const execResult = await root.exec(reg.extractor, { path }, {});
|
|
38
|
+
if (!execResult.success || !execResult.data) return null;
|
|
39
|
+
const data = execResult.data;
|
|
40
|
+
return {
|
|
41
|
+
domain,
|
|
42
|
+
result: {
|
|
43
|
+
anchors: data.anchors ?? [],
|
|
44
|
+
summary: data.summary,
|
|
45
|
+
embedding: data.embedding
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* List all registered extractors.
|
|
54
|
+
*/
|
|
55
|
+
list() {
|
|
56
|
+
return this.registrations;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check if any extractors are registered.
|
|
60
|
+
*/
|
|
61
|
+
get hasRegistrations() {
|
|
62
|
+
return this.registrations.size > 0;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Clear all registrations.
|
|
66
|
+
*/
|
|
67
|
+
clear() {
|
|
68
|
+
this.registrations.clear();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { ExtractorManager };
|
|
74
|
+
//# sourceMappingURL=manager.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manager.mjs","names":[],"sources":["../../src/extraction/manager.ts"],"sourcesContent":["/**\n * ExtractorManager — manages extractor registrations and dispatches extraction requests.\n *\n * Extractors are external AFS exec endpoints that accept a path and return\n * { anchors, summary, embedding? }. They are registered per-domain with a scope\n * that determines which write events they handle.\n */\nimport type { AFSRoot } from \"@aigne/afs\";\nimport type { Anchor, ExtractionResult, ExtractorRegistration } from \"../types.js\";\n\nexport class ExtractorManager {\n private readonly registrations = new Map<string, ExtractorRegistration>();\n\n /**\n * Register an extractor for a domain.\n * @param domain - Domain name this extractor serves\n * @param registration - Extractor configuration\n */\n register(domain: string, registration: ExtractorRegistration): void {\n this.registrations.set(domain, registration);\n }\n\n /**\n * Unregister an extractor for a domain.\n */\n unregister(domain: string): void {\n this.registrations.delete(domain);\n }\n\n /**\n * Find the first extractor whose scope matches the given path.\n * @returns [domainName, registration] or undefined if no match\n */\n findForPath(path: string): [string, ExtractorRegistration] | undefined {\n for (const [domain, reg] of this.registrations) {\n if (path.startsWith(reg.scope)) {\n return [domain, reg];\n }\n }\n return undefined;\n }\n\n /**\n * Dispatch extraction for a path using the matching extractor.\n * Calls the extractor's AFS exec endpoint and returns the result.\n *\n * @returns ExtractionResult or null if no extractor matched or extraction failed\n */\n async extract(\n path: string,\n root: AFSRoot,\n ): Promise<{ domain: string; result: ExtractionResult } | null> {\n const match = this.findForPath(path);\n if (!match) return null;\n\n const [domain, reg] = match;\n if (!reg.extractor || !root.exec) return null;\n\n try {\n const execResult = await root.exec(reg.extractor, { path }, {});\n if (!execResult.success || !execResult.data) return null;\n\n const data = execResult.data as {\n anchors?: Anchor[];\n summary?: string;\n embedding?: number[];\n };\n return {\n domain,\n result: {\n anchors: (data.anchors as Anchor[]) ?? [],\n summary: data.summary,\n embedding: data.embedding,\n },\n };\n } catch {\n return null;\n }\n }\n\n /**\n * List all registered extractors.\n */\n list(): ReadonlyMap<string, ExtractorRegistration> {\n return this.registrations;\n }\n\n /**\n * Check if any extractors are registered.\n */\n get hasRegistrations(): boolean {\n return this.registrations.size > 0;\n }\n\n /**\n * Clear all registrations.\n */\n clear(): void {\n this.registrations.clear();\n }\n}\n"],"mappings":";AAUA,IAAa,mBAAb,MAA8B;CAC5B,AAAiB,gCAAgB,IAAI,KAAoC;;;;;;CAOzE,SAAS,QAAgB,cAA2C;AAClE,OAAK,cAAc,IAAI,QAAQ,aAAa;;;;;CAM9C,WAAW,QAAsB;AAC/B,OAAK,cAAc,OAAO,OAAO;;;;;;CAOnC,YAAY,MAA2D;AACrE,OAAK,MAAM,CAAC,QAAQ,QAAQ,KAAK,cAC/B,KAAI,KAAK,WAAW,IAAI,MAAM,CAC5B,QAAO,CAAC,QAAQ,IAAI;;;;;;;;CAY1B,MAAM,QACJ,MACA,MAC8D;EAC9D,MAAM,QAAQ,KAAK,YAAY,KAAK;AACpC,MAAI,CAAC,MAAO,QAAO;EAEnB,MAAM,CAAC,QAAQ,OAAO;AACtB,MAAI,CAAC,IAAI,aAAa,CAAC,KAAK,KAAM,QAAO;AAEzC,MAAI;GACF,MAAM,aAAa,MAAM,KAAK,KAAK,IAAI,WAAW,EAAE,MAAM,EAAE,EAAE,CAAC;AAC/D,OAAI,CAAC,WAAW,WAAW,CAAC,WAAW,KAAM,QAAO;GAEpD,MAAM,OAAO,WAAW;AAKxB,UAAO;IACL;IACA,QAAQ;KACN,SAAU,KAAK,WAAwB,EAAE;KACzC,SAAS,KAAK;KACd,WAAW,KAAK;KACjB;IACF;UACK;AACN,UAAO;;;;;;CAOX,OAAmD;AACjD,SAAO,KAAK;;;;;CAMd,IAAI,mBAA4B;AAC9B,SAAO,KAAK,cAAc,OAAO;;;;;CAMnC,QAAc;AACZ,OAAK,cAAc,OAAO"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/extraction/path-anchors.ts
|
|
3
|
+
/**
|
|
4
|
+
* Path-derived topic anchor extractor.
|
|
5
|
+
*
|
|
6
|
+
* Promoted from the retrieval bench's hand-rolled helper after it
|
|
7
|
+
* lifted the docs/ corpus from R@5=46.7% (FTS only) to R@5=100%
|
|
8
|
+
* (with these anchors + text-embedding-3-small). The win came from
|
|
9
|
+
* making path segments — already the strongest semantic signal in
|
|
10
|
+
* any docs tree — directly addressable as topic anchors.
|
|
11
|
+
*
|
|
12
|
+
* Strategy:
|
|
13
|
+
*
|
|
14
|
+
* 1. Each `/`-delimited segment is added as a topic. Numeric ADR
|
|
15
|
+
* prefixes (`0001-`) and the `.md` extension get stripped.
|
|
16
|
+
* Example: `docs/adr/0001-afs-top-level-path-categories.md` →
|
|
17
|
+
* `docs`, `adr`, `afs-top-level-path-categories`.
|
|
18
|
+
*
|
|
19
|
+
* 2. Project-jargon prefixes (afs-, aup-, ash-, adr-, aigne-, did-,
|
|
20
|
+
* dev-) are also stripped to yield a second anchor without the
|
|
21
|
+
* prefix — `afs-top-level-path-categories` →
|
|
22
|
+
* `top-level-path-categories`. Lets queries that name the topic
|
|
23
|
+
* without the namespace prefix still match.
|
|
24
|
+
*
|
|
25
|
+
* 3. Trailing 3-4 grams of the stripped form. Specific enough
|
|
26
|
+
* ("aup-surface-family", "level-path-categories") to add real
|
|
27
|
+
* retrieval signal; long enough (≥8 chars) to not crowd out
|
|
28
|
+
* gold matches with generic words. Only trailing because the
|
|
29
|
+
* head of a slug usually IS the namespace prefix already
|
|
30
|
+
* captured above.
|
|
31
|
+
*
|
|
32
|
+
* 4. ADR ids surface as `ADR-0001` so questions that name an ADR
|
|
33
|
+
* pin the right doc deterministically.
|
|
34
|
+
*
|
|
35
|
+
* 5. The first H1 (when content provided) becomes a topic — long
|
|
36
|
+
* titles fall back to the first 80 chars to keep the lookup
|
|
37
|
+
* table compact.
|
|
38
|
+
*
|
|
39
|
+
* Sub-token expansion (1- and 2-grams) was tried earlier and hurt
|
|
40
|
+
* R@1 because every doc shares short tokens like "afs" and "agent" —
|
|
41
|
+
* the over-broad match crowded out specific gold hits.
|
|
42
|
+
*/
|
|
43
|
+
const JARGON_PREFIX = /^(afs|aup|ash|adr|aigne|did|dev)-/i;
|
|
44
|
+
/**
|
|
45
|
+
* Extract a single `topic`-typed anchor entry from an AFS path.
|
|
46
|
+
*
|
|
47
|
+
* Returns the canonical shape consumed by `AFSIndex.execIndex`:
|
|
48
|
+
* `[{ type: "topic", values: string[], confidence: 1 }]`. Empty when
|
|
49
|
+
* nothing useful surfaces (e.g. path was just `/` or all segments
|
|
50
|
+
* were stop-words).
|
|
51
|
+
*/
|
|
52
|
+
function extractPathTopicAnchors(entryPath, options = {}) {
|
|
53
|
+
const topics = /* @__PURE__ */ new Set();
|
|
54
|
+
const cleanPath = entryPath.replace(/^\/+/, "");
|
|
55
|
+
for (const seg of cleanPath.split("/")) {
|
|
56
|
+
const stripped = seg.replace(/\.md$/, "").replace(/^\d+-/, "");
|
|
57
|
+
if (!stripped || stripped.toLowerCase() === "readme") continue;
|
|
58
|
+
topics.add(stripped);
|
|
59
|
+
const noPrefix = stripped.replace(JARGON_PREFIX, "");
|
|
60
|
+
if (noPrefix !== stripped && noPrefix.length > 0) topics.add(noPrefix);
|
|
61
|
+
const parts = stripped.toLowerCase().split("-").filter(Boolean);
|
|
62
|
+
if (parts.length >= 4) for (let n = 3; n <= 4; n++) for (let i = parts.length - n; i >= 0 && i >= parts.length - 4; i--) {
|
|
63
|
+
const gram = parts.slice(i, i + n).join("-");
|
|
64
|
+
if (gram.length >= 8) topics.add(gram);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const adrMatch = /\b(\d{4})-/.exec(cleanPath);
|
|
68
|
+
if (adrMatch) topics.add(`ADR-${adrMatch[1]}`);
|
|
69
|
+
if (options.content) {
|
|
70
|
+
const h1 = options.content.match(/^#\s+(.+)$/m);
|
|
71
|
+
if (h1) {
|
|
72
|
+
const title = h1[1].slice(0, 80).trim();
|
|
73
|
+
if (title) topics.add(title);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (topics.size === 0) return [];
|
|
77
|
+
const cap = options.maxValues ?? 32;
|
|
78
|
+
return [{
|
|
79
|
+
type: "topic",
|
|
80
|
+
values: [...topics].slice(0, cap),
|
|
81
|
+
confidence: 1
|
|
82
|
+
}];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
exports.extractPathTopicAnchors = extractPathTopicAnchors;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//#region src/extraction/path-anchors.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Path-derived topic anchor extractor.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from the retrieval bench's hand-rolled helper after it
|
|
6
|
+
* lifted the docs/ corpus from R@5=46.7% (FTS only) to R@5=100%
|
|
7
|
+
* (with these anchors + text-embedding-3-small). The win came from
|
|
8
|
+
* making path segments — already the strongest semantic signal in
|
|
9
|
+
* any docs tree — directly addressable as topic anchors.
|
|
10
|
+
*
|
|
11
|
+
* Strategy:
|
|
12
|
+
*
|
|
13
|
+
* 1. Each `/`-delimited segment is added as a topic. Numeric ADR
|
|
14
|
+
* prefixes (`0001-`) and the `.md` extension get stripped.
|
|
15
|
+
* Example: `docs/adr/0001-afs-top-level-path-categories.md` →
|
|
16
|
+
* `docs`, `adr`, `afs-top-level-path-categories`.
|
|
17
|
+
*
|
|
18
|
+
* 2. Project-jargon prefixes (afs-, aup-, ash-, adr-, aigne-, did-,
|
|
19
|
+
* dev-) are also stripped to yield a second anchor without the
|
|
20
|
+
* prefix — `afs-top-level-path-categories` →
|
|
21
|
+
* `top-level-path-categories`. Lets queries that name the topic
|
|
22
|
+
* without the namespace prefix still match.
|
|
23
|
+
*
|
|
24
|
+
* 3. Trailing 3-4 grams of the stripped form. Specific enough
|
|
25
|
+
* ("aup-surface-family", "level-path-categories") to add real
|
|
26
|
+
* retrieval signal; long enough (≥8 chars) to not crowd out
|
|
27
|
+
* gold matches with generic words. Only trailing because the
|
|
28
|
+
* head of a slug usually IS the namespace prefix already
|
|
29
|
+
* captured above.
|
|
30
|
+
*
|
|
31
|
+
* 4. ADR ids surface as `ADR-0001` so questions that name an ADR
|
|
32
|
+
* pin the right doc deterministically.
|
|
33
|
+
*
|
|
34
|
+
* 5. The first H1 (when content provided) becomes a topic — long
|
|
35
|
+
* titles fall back to the first 80 chars to keep the lookup
|
|
36
|
+
* table compact.
|
|
37
|
+
*
|
|
38
|
+
* Sub-token expansion (1- and 2-grams) was tried earlier and hurt
|
|
39
|
+
* R@1 because every doc shares short tokens like "afs" and "agent" —
|
|
40
|
+
* the over-broad match crowded out specific gold hits.
|
|
41
|
+
*/
|
|
42
|
+
interface PathAnchorOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Optional document body. When supplied, the first H1 line
|
|
45
|
+
* (`# Title`) becomes a topic. Truncated to 80 chars so a long
|
|
46
|
+
* title doesn't blow up the lookup table.
|
|
47
|
+
*/
|
|
48
|
+
content?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Maximum number of distinct anchor values to emit. Defaults to 32.
|
|
51
|
+
* The anchor backend stores one row per (entry_path, key), so a
|
|
52
|
+
* very high cap inflates `idx_anchor_key` without proportional recall
|
|
53
|
+
* gain. 32 was the sweet spot in the docs/ corpus bench.
|
|
54
|
+
*/
|
|
55
|
+
maxValues?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract a single `topic`-typed anchor entry from an AFS path.
|
|
59
|
+
*
|
|
60
|
+
* Returns the canonical shape consumed by `AFSIndex.execIndex`:
|
|
61
|
+
* `[{ type: "topic", values: string[], confidence: 1 }]`. Empty when
|
|
62
|
+
* nothing useful surfaces (e.g. path was just `/` or all segments
|
|
63
|
+
* were stop-words).
|
|
64
|
+
*/
|
|
65
|
+
declare function extractPathTopicAnchors(entryPath: string, options?: PathAnchorOptions): Array<{
|
|
66
|
+
type: "topic";
|
|
67
|
+
values: string[];
|
|
68
|
+
confidence: number;
|
|
69
|
+
}>;
|
|
70
|
+
//#endregion
|
|
71
|
+
export { PathAnchorOptions, extractPathTopicAnchors };
|
|
72
|
+
//# sourceMappingURL=path-anchors.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-anchors.d.cts","names":[],"sources":["../../src/extraction/path-anchors.ts"],"mappings":";;AA2CA;;;;;AAwBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAxBiB,iBAAA;;;;;;EAMf,OAAA;;;;;;;EAOA,SAAA;AAAA;;;;;;;;;iBAWc,uBAAA,CACd,SAAA,UACA,OAAA,GAAS,iBAAA,GACR,KAAA;EAAQ,IAAA;EAAe,MAAA;EAAkB,UAAA;AAAA"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//#region src/extraction/path-anchors.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Path-derived topic anchor extractor.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from the retrieval bench's hand-rolled helper after it
|
|
6
|
+
* lifted the docs/ corpus from R@5=46.7% (FTS only) to R@5=100%
|
|
7
|
+
* (with these anchors + text-embedding-3-small). The win came from
|
|
8
|
+
* making path segments — already the strongest semantic signal in
|
|
9
|
+
* any docs tree — directly addressable as topic anchors.
|
|
10
|
+
*
|
|
11
|
+
* Strategy:
|
|
12
|
+
*
|
|
13
|
+
* 1. Each `/`-delimited segment is added as a topic. Numeric ADR
|
|
14
|
+
* prefixes (`0001-`) and the `.md` extension get stripped.
|
|
15
|
+
* Example: `docs/adr/0001-afs-top-level-path-categories.md` →
|
|
16
|
+
* `docs`, `adr`, `afs-top-level-path-categories`.
|
|
17
|
+
*
|
|
18
|
+
* 2. Project-jargon prefixes (afs-, aup-, ash-, adr-, aigne-, did-,
|
|
19
|
+
* dev-) are also stripped to yield a second anchor without the
|
|
20
|
+
* prefix — `afs-top-level-path-categories` →
|
|
21
|
+
* `top-level-path-categories`. Lets queries that name the topic
|
|
22
|
+
* without the namespace prefix still match.
|
|
23
|
+
*
|
|
24
|
+
* 3. Trailing 3-4 grams of the stripped form. Specific enough
|
|
25
|
+
* ("aup-surface-family", "level-path-categories") to add real
|
|
26
|
+
* retrieval signal; long enough (≥8 chars) to not crowd out
|
|
27
|
+
* gold matches with generic words. Only trailing because the
|
|
28
|
+
* head of a slug usually IS the namespace prefix already
|
|
29
|
+
* captured above.
|
|
30
|
+
*
|
|
31
|
+
* 4. ADR ids surface as `ADR-0001` so questions that name an ADR
|
|
32
|
+
* pin the right doc deterministically.
|
|
33
|
+
*
|
|
34
|
+
* 5. The first H1 (when content provided) becomes a topic — long
|
|
35
|
+
* titles fall back to the first 80 chars to keep the lookup
|
|
36
|
+
* table compact.
|
|
37
|
+
*
|
|
38
|
+
* Sub-token expansion (1- and 2-grams) was tried earlier and hurt
|
|
39
|
+
* R@1 because every doc shares short tokens like "afs" and "agent" —
|
|
40
|
+
* the over-broad match crowded out specific gold hits.
|
|
41
|
+
*/
|
|
42
|
+
interface PathAnchorOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Optional document body. When supplied, the first H1 line
|
|
45
|
+
* (`# Title`) becomes a topic. Truncated to 80 chars so a long
|
|
46
|
+
* title doesn't blow up the lookup table.
|
|
47
|
+
*/
|
|
48
|
+
content?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Maximum number of distinct anchor values to emit. Defaults to 32.
|
|
51
|
+
* The anchor backend stores one row per (entry_path, key), so a
|
|
52
|
+
* very high cap inflates `idx_anchor_key` without proportional recall
|
|
53
|
+
* gain. 32 was the sweet spot in the docs/ corpus bench.
|
|
54
|
+
*/
|
|
55
|
+
maxValues?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract a single `topic`-typed anchor entry from an AFS path.
|
|
59
|
+
*
|
|
60
|
+
* Returns the canonical shape consumed by `AFSIndex.execIndex`:
|
|
61
|
+
* `[{ type: "topic", values: string[], confidence: 1 }]`. Empty when
|
|
62
|
+
* nothing useful surfaces (e.g. path was just `/` or all segments
|
|
63
|
+
* were stop-words).
|
|
64
|
+
*/
|
|
65
|
+
declare function extractPathTopicAnchors(entryPath: string, options?: PathAnchorOptions): Array<{
|
|
66
|
+
type: "topic";
|
|
67
|
+
values: string[];
|
|
68
|
+
confidence: number;
|
|
69
|
+
}>;
|
|
70
|
+
//#endregion
|
|
71
|
+
export { PathAnchorOptions, extractPathTopicAnchors };
|
|
72
|
+
//# sourceMappingURL=path-anchors.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-anchors.d.mts","names":[],"sources":["../../src/extraction/path-anchors.ts"],"mappings":";;AA2CA;;;;;AAwBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAxBiB,iBAAA;;;;;;EAMf,OAAA;;;;;;;EAOA,SAAA;AAAA;;;;;;;;;iBAWc,uBAAA,CACd,SAAA,UACA,OAAA,GAAS,iBAAA,GACR,KAAA;EAAQ,IAAA;EAAe,MAAA;EAAkB,UAAA;AAAA"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//#region src/extraction/path-anchors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Path-derived topic anchor extractor.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from the retrieval bench's hand-rolled helper after it
|
|
6
|
+
* lifted the docs/ corpus from R@5=46.7% (FTS only) to R@5=100%
|
|
7
|
+
* (with these anchors + text-embedding-3-small). The win came from
|
|
8
|
+
* making path segments — already the strongest semantic signal in
|
|
9
|
+
* any docs tree — directly addressable as topic anchors.
|
|
10
|
+
*
|
|
11
|
+
* Strategy:
|
|
12
|
+
*
|
|
13
|
+
* 1. Each `/`-delimited segment is added as a topic. Numeric ADR
|
|
14
|
+
* prefixes (`0001-`) and the `.md` extension get stripped.
|
|
15
|
+
* Example: `docs/adr/0001-afs-top-level-path-categories.md` →
|
|
16
|
+
* `docs`, `adr`, `afs-top-level-path-categories`.
|
|
17
|
+
*
|
|
18
|
+
* 2. Project-jargon prefixes (afs-, aup-, ash-, adr-, aigne-, did-,
|
|
19
|
+
* dev-) are also stripped to yield a second anchor without the
|
|
20
|
+
* prefix — `afs-top-level-path-categories` →
|
|
21
|
+
* `top-level-path-categories`. Lets queries that name the topic
|
|
22
|
+
* without the namespace prefix still match.
|
|
23
|
+
*
|
|
24
|
+
* 3. Trailing 3-4 grams of the stripped form. Specific enough
|
|
25
|
+
* ("aup-surface-family", "level-path-categories") to add real
|
|
26
|
+
* retrieval signal; long enough (≥8 chars) to not crowd out
|
|
27
|
+
* gold matches with generic words. Only trailing because the
|
|
28
|
+
* head of a slug usually IS the namespace prefix already
|
|
29
|
+
* captured above.
|
|
30
|
+
*
|
|
31
|
+
* 4. ADR ids surface as `ADR-0001` so questions that name an ADR
|
|
32
|
+
* pin the right doc deterministically.
|
|
33
|
+
*
|
|
34
|
+
* 5. The first H1 (when content provided) becomes a topic — long
|
|
35
|
+
* titles fall back to the first 80 chars to keep the lookup
|
|
36
|
+
* table compact.
|
|
37
|
+
*
|
|
38
|
+
* Sub-token expansion (1- and 2-grams) was tried earlier and hurt
|
|
39
|
+
* R@1 because every doc shares short tokens like "afs" and "agent" —
|
|
40
|
+
* the over-broad match crowded out specific gold hits.
|
|
41
|
+
*/
|
|
42
|
+
const JARGON_PREFIX = /^(afs|aup|ash|adr|aigne|did|dev)-/i;
|
|
43
|
+
/**
|
|
44
|
+
* Extract a single `topic`-typed anchor entry from an AFS path.
|
|
45
|
+
*
|
|
46
|
+
* Returns the canonical shape consumed by `AFSIndex.execIndex`:
|
|
47
|
+
* `[{ type: "topic", values: string[], confidence: 1 }]`. Empty when
|
|
48
|
+
* nothing useful surfaces (e.g. path was just `/` or all segments
|
|
49
|
+
* were stop-words).
|
|
50
|
+
*/
|
|
51
|
+
function extractPathTopicAnchors(entryPath, options = {}) {
|
|
52
|
+
const topics = /* @__PURE__ */ new Set();
|
|
53
|
+
const cleanPath = entryPath.replace(/^\/+/, "");
|
|
54
|
+
for (const seg of cleanPath.split("/")) {
|
|
55
|
+
const stripped = seg.replace(/\.md$/, "").replace(/^\d+-/, "");
|
|
56
|
+
if (!stripped || stripped.toLowerCase() === "readme") continue;
|
|
57
|
+
topics.add(stripped);
|
|
58
|
+
const noPrefix = stripped.replace(JARGON_PREFIX, "");
|
|
59
|
+
if (noPrefix !== stripped && noPrefix.length > 0) topics.add(noPrefix);
|
|
60
|
+
const parts = stripped.toLowerCase().split("-").filter(Boolean);
|
|
61
|
+
if (parts.length >= 4) for (let n = 3; n <= 4; n++) for (let i = parts.length - n; i >= 0 && i >= parts.length - 4; i--) {
|
|
62
|
+
const gram = parts.slice(i, i + n).join("-");
|
|
63
|
+
if (gram.length >= 8) topics.add(gram);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const adrMatch = /\b(\d{4})-/.exec(cleanPath);
|
|
67
|
+
if (adrMatch) topics.add(`ADR-${adrMatch[1]}`);
|
|
68
|
+
if (options.content) {
|
|
69
|
+
const h1 = options.content.match(/^#\s+(.+)$/m);
|
|
70
|
+
if (h1) {
|
|
71
|
+
const title = h1[1].slice(0, 80).trim();
|
|
72
|
+
if (title) topics.add(title);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (topics.size === 0) return [];
|
|
76
|
+
const cap = options.maxValues ?? 32;
|
|
77
|
+
return [{
|
|
78
|
+
type: "topic",
|
|
79
|
+
values: [...topics].slice(0, cap),
|
|
80
|
+
confidence: 1
|
|
81
|
+
}];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
export { extractPathTopicAnchors };
|
|
86
|
+
//# sourceMappingURL=path-anchors.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-anchors.mjs","names":[],"sources":["../../src/extraction/path-anchors.ts"],"sourcesContent":["/**\n * Path-derived topic anchor extractor.\n *\n * Promoted from the retrieval bench's hand-rolled helper after it\n * lifted the docs/ corpus from R@5=46.7% (FTS only) to R@5=100%\n * (with these anchors + text-embedding-3-small). The win came from\n * making path segments — already the strongest semantic signal in\n * any docs tree — directly addressable as topic anchors.\n *\n * Strategy:\n *\n * 1. Each `/`-delimited segment is added as a topic. Numeric ADR\n * prefixes (`0001-`) and the `.md` extension get stripped.\n * Example: `docs/adr/0001-afs-top-level-path-categories.md` →\n * `docs`, `adr`, `afs-top-level-path-categories`.\n *\n * 2. Project-jargon prefixes (afs-, aup-, ash-, adr-, aigne-, did-,\n * dev-) are also stripped to yield a second anchor without the\n * prefix — `afs-top-level-path-categories` →\n * `top-level-path-categories`. Lets queries that name the topic\n * without the namespace prefix still match.\n *\n * 3. Trailing 3-4 grams of the stripped form. Specific enough\n * (\"aup-surface-family\", \"level-path-categories\") to add real\n * retrieval signal; long enough (≥8 chars) to not crowd out\n * gold matches with generic words. Only trailing because the\n * head of a slug usually IS the namespace prefix already\n * captured above.\n *\n * 4. ADR ids surface as `ADR-0001` so questions that name an ADR\n * pin the right doc deterministically.\n *\n * 5. The first H1 (when content provided) becomes a topic — long\n * titles fall back to the first 80 chars to keep the lookup\n * table compact.\n *\n * Sub-token expansion (1- and 2-grams) was tried earlier and hurt\n * R@1 because every doc shares short tokens like \"afs\" and \"agent\" —\n * the over-broad match crowded out specific gold hits.\n */\n\nconst JARGON_PREFIX = /^(afs|aup|ash|adr|aigne|did|dev)-/i;\n\nexport interface PathAnchorOptions {\n /**\n * Optional document body. When supplied, the first H1 line\n * (`# Title`) becomes a topic. Truncated to 80 chars so a long\n * title doesn't blow up the lookup table.\n */\n content?: string;\n /**\n * Maximum number of distinct anchor values to emit. Defaults to 32.\n * The anchor backend stores one row per (entry_path, key), so a\n * very high cap inflates `idx_anchor_key` without proportional recall\n * gain. 32 was the sweet spot in the docs/ corpus bench.\n */\n maxValues?: number;\n}\n\n/**\n * Extract a single `topic`-typed anchor entry from an AFS path.\n *\n * Returns the canonical shape consumed by `AFSIndex.execIndex`:\n * `[{ type: \"topic\", values: string[], confidence: 1 }]`. Empty when\n * nothing useful surfaces (e.g. path was just `/` or all segments\n * were stop-words).\n */\nexport function extractPathTopicAnchors(\n entryPath: string,\n options: PathAnchorOptions = {},\n): Array<{ type: \"topic\"; values: string[]; confidence: number }> {\n const topics = new Set<string>();\n const cleanPath = entryPath.replace(/^\\/+/, \"\");\n\n for (const seg of cleanPath.split(\"/\")) {\n const stripped = seg.replace(/\\.md$/, \"\").replace(/^\\d+-/, \"\");\n if (!stripped || stripped.toLowerCase() === \"readme\") continue;\n\n topics.add(stripped);\n\n const noPrefix = stripped.replace(JARGON_PREFIX, \"\");\n if (noPrefix !== stripped && noPrefix.length > 0) topics.add(noPrefix);\n\n const parts = stripped.toLowerCase().split(\"-\").filter(Boolean);\n if (parts.length >= 4) {\n for (let n = 3; n <= 4; n++) {\n for (let i = parts.length - n; i >= 0 && i >= parts.length - 4; i--) {\n const gram = parts.slice(i, i + n).join(\"-\");\n if (gram.length >= 8) topics.add(gram);\n }\n }\n }\n }\n\n const adrMatch = /\\b(\\d{4})-/.exec(cleanPath);\n if (adrMatch) topics.add(`ADR-${adrMatch[1]}`);\n\n if (options.content) {\n const h1 = options.content.match(/^#\\s+(.+)$/m);\n if (h1) {\n const title = h1[1]!.slice(0, 80).trim();\n if (title) topics.add(title);\n }\n }\n\n if (topics.size === 0) return [];\n\n const cap = options.maxValues ?? 32;\n return [\n {\n type: \"topic\",\n values: [...topics].slice(0, cap),\n confidence: 1,\n },\n ];\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,MAAM,gBAAgB;;;;;;;;;AA0BtB,SAAgB,wBACd,WACA,UAA6B,EAAE,EACiC;CAChE,MAAM,yBAAS,IAAI,KAAa;CAChC,MAAM,YAAY,UAAU,QAAQ,QAAQ,GAAG;AAE/C,MAAK,MAAM,OAAO,UAAU,MAAM,IAAI,EAAE;EACtC,MAAM,WAAW,IAAI,QAAQ,SAAS,GAAG,CAAC,QAAQ,SAAS,GAAG;AAC9D,MAAI,CAAC,YAAY,SAAS,aAAa,KAAK,SAAU;AAEtD,SAAO,IAAI,SAAS;EAEpB,MAAM,WAAW,SAAS,QAAQ,eAAe,GAAG;AACpD,MAAI,aAAa,YAAY,SAAS,SAAS,EAAG,QAAO,IAAI,SAAS;EAEtE,MAAM,QAAQ,SAAS,aAAa,CAAC,MAAM,IAAI,CAAC,OAAO,QAAQ;AAC/D,MAAI,MAAM,UAAU,EAClB,MAAK,IAAI,IAAI,GAAG,KAAK,GAAG,IACtB,MAAK,IAAI,IAAI,MAAM,SAAS,GAAG,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG,KAAK;GACnE,MAAM,OAAO,MAAM,MAAM,GAAG,IAAI,EAAE,CAAC,KAAK,IAAI;AAC5C,OAAI,KAAK,UAAU,EAAG,QAAO,IAAI,KAAK;;;CAM9C,MAAM,WAAW,aAAa,KAAK,UAAU;AAC7C,KAAI,SAAU,QAAO,IAAI,OAAO,SAAS,KAAK;AAE9C,KAAI,QAAQ,SAAS;EACnB,MAAM,KAAK,QAAQ,QAAQ,MAAM,cAAc;AAC/C,MAAI,IAAI;GACN,MAAM,QAAQ,GAAG,GAAI,MAAM,GAAG,GAAG,CAAC,MAAM;AACxC,OAAI,MAAO,QAAO,IAAI,MAAM;;;AAIhC,KAAI,OAAO,SAAS,EAAG,QAAO,EAAE;CAEhC,MAAM,MAAM,QAAQ,aAAa;AACjC,QAAO,CACL;EACE,MAAM;EACN,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI;EACjC,YAAY;EACb,CACF"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/follower-scheduler.ts
|
|
3
|
+
function clampSpent(value, remaining) {
|
|
4
|
+
if (!Number.isFinite(value ?? 0) || (value ?? 0) <= 0) return 0;
|
|
5
|
+
return Math.min(value ?? 0, remaining);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Fairly spend one index-follower tick across a selected scope set.
|
|
9
|
+
*
|
|
10
|
+
* Each scope receives a small bounded slice first; scopes that still report
|
|
11
|
+
* `hasMore` are appended to the tail and can receive another slice only after
|
|
12
|
+
* the other selected scopes had their chance. The helper is intentionally
|
|
13
|
+
* storage/runtime-agnostic so Cloudflare and Node share the same starvation
|
|
14
|
+
* semantics while keeping different budget sizes.
|
|
15
|
+
*/
|
|
16
|
+
async function runIndexFollowerRoundRobin(opts) {
|
|
17
|
+
let remainingProcessed = Math.max(0, opts.maxProcessed);
|
|
18
|
+
let remainingNoop = Math.max(0, opts.maxNoop);
|
|
19
|
+
let remainingScan = Math.max(0, opts.maxScan);
|
|
20
|
+
const perScopeMaxProcessed = Math.max(0, opts.perScopeMaxProcessed ?? 1);
|
|
21
|
+
const perScopeMaxNoop = Math.max(0, opts.perScopeMaxNoop ?? 2);
|
|
22
|
+
const perScopeMaxScan = Math.max(1, opts.perScopeMaxScan ?? 50);
|
|
23
|
+
const maxRuns = opts.maxRuns ?? opts.scopes.length + Math.ceil((remainingProcessed || 0) / Math.max(1, perScopeMaxProcessed || 1)) + Math.ceil((remainingNoop || 0) / Math.max(1, perScopeMaxNoop || 1)) + Math.ceil((remainingScan || 0) / Math.max(1, perScopeMaxScan));
|
|
24
|
+
const queue = [...opts.scopes];
|
|
25
|
+
const runs = [];
|
|
26
|
+
let indexedThisTick = 0;
|
|
27
|
+
let deletedThisTick = 0;
|
|
28
|
+
let noopedThisTick = 0;
|
|
29
|
+
let ackRaceThisTick = 0;
|
|
30
|
+
let stoppedBy = "drained";
|
|
31
|
+
let sawBudgetBlockedScope = false;
|
|
32
|
+
while (queue.length > 0) {
|
|
33
|
+
if (opts.shouldStop?.()) {
|
|
34
|
+
stoppedBy = "time";
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (runs.length >= maxRuns) {
|
|
38
|
+
stoppedBy = "max_runs";
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (remainingScan <= 0 || remainingProcessed <= 0 && remainingNoop <= 0) {
|
|
42
|
+
stoppedBy = "budget";
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
const scope = queue.shift();
|
|
46
|
+
const limits = {
|
|
47
|
+
maxProcessed: Math.min(perScopeMaxProcessed, remainingProcessed),
|
|
48
|
+
maxNoop: Math.min(perScopeMaxNoop, remainingNoop),
|
|
49
|
+
maxScan: Math.min(perScopeMaxScan, remainingScan)
|
|
50
|
+
};
|
|
51
|
+
if (limits.maxScan <= 0 || limits.maxProcessed <= 0 && limits.maxNoop <= 0) {
|
|
52
|
+
stoppedBy = "budget";
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
const result = await opts.runScope(scope, limits);
|
|
56
|
+
runs.push({
|
|
57
|
+
scope,
|
|
58
|
+
limits,
|
|
59
|
+
result
|
|
60
|
+
});
|
|
61
|
+
remainingProcessed -= clampSpent(result.processed, remainingProcessed);
|
|
62
|
+
remainingNoop -= clampSpent(result.noopProcessed, remainingNoop);
|
|
63
|
+
remainingScan -= clampSpent(result.scanned, remainingScan);
|
|
64
|
+
indexedThisTick += result.indexed;
|
|
65
|
+
deletedThisTick += result.deleted;
|
|
66
|
+
noopedThisTick += result.nooped;
|
|
67
|
+
if (result.ackRace) ackRaceThisTick++;
|
|
68
|
+
if (!result.advanced && result.hasMore && [
|
|
69
|
+
"processed_budget",
|
|
70
|
+
"noop_budget",
|
|
71
|
+
"scan_budget"
|
|
72
|
+
].includes(result.stopReason ?? "")) sawBudgetBlockedScope = true;
|
|
73
|
+
if (result.advanced && result.hasMore && !result.aborted && remainingScan > 0 && (remainingProcessed > 0 || remainingNoop > 0)) queue.push(scope);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
runs,
|
|
77
|
+
processedThisTick: opts.maxProcessed - remainingProcessed,
|
|
78
|
+
noopThisTick: opts.maxNoop - remainingNoop,
|
|
79
|
+
indexedThisTick,
|
|
80
|
+
deletedThisTick,
|
|
81
|
+
noopedThisTick,
|
|
82
|
+
scannedThisTick: opts.maxScan - remainingScan,
|
|
83
|
+
ackRaceThisTick,
|
|
84
|
+
stoppedBy: stoppedBy === "drained" && sawBudgetBlockedScope ? "budget" : stoppedBy,
|
|
85
|
+
remainingProcessed,
|
|
86
|
+
remainingNoop,
|
|
87
|
+
remainingScan
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
//#endregion
|
|
92
|
+
exports.runIndexFollowerRoundRobin = runIndexFollowerRoundRobin;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { IndexFollowerBatchResult } from "./follower.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/follower-scheduler.d.ts
|
|
4
|
+
interface IndexFollowerRunLimits {
|
|
5
|
+
maxProcessed: number;
|
|
6
|
+
maxNoop: number;
|
|
7
|
+
maxScan: number;
|
|
8
|
+
}
|
|
9
|
+
interface IndexFollowerRoundRobinOptions<T> {
|
|
10
|
+
scopes: T[];
|
|
11
|
+
maxProcessed: number;
|
|
12
|
+
maxNoop: number;
|
|
13
|
+
maxScan: number;
|
|
14
|
+
perScopeMaxProcessed?: number;
|
|
15
|
+
perScopeMaxNoop?: number;
|
|
16
|
+
perScopeMaxScan?: number;
|
|
17
|
+
maxRuns?: number;
|
|
18
|
+
shouldStop?: () => boolean;
|
|
19
|
+
runScope: (scope: T, limits: IndexFollowerRunLimits) => Promise<IndexFollowerBatchResult>;
|
|
20
|
+
}
|
|
21
|
+
interface IndexFollowerRoundRobinRun<T> {
|
|
22
|
+
scope: T;
|
|
23
|
+
limits: IndexFollowerRunLimits;
|
|
24
|
+
result: IndexFollowerBatchResult;
|
|
25
|
+
}
|
|
26
|
+
interface IndexFollowerRoundRobinResult<T> {
|
|
27
|
+
runs: IndexFollowerRoundRobinRun<T>[];
|
|
28
|
+
processedThisTick: number;
|
|
29
|
+
noopThisTick: number;
|
|
30
|
+
indexedThisTick: number;
|
|
31
|
+
deletedThisTick: number;
|
|
32
|
+
noopedThisTick: number;
|
|
33
|
+
scannedThisTick: number;
|
|
34
|
+
/**
|
|
35
|
+
* Drain RUNS that lost the ack race-guard this tick (healthy concurrent-write
|
|
36
|
+
* churn, not failures). Counted per `runScope` call, so a single hot scope
|
|
37
|
+
* requeued within the tick can contribute more than once — this is a run
|
|
38
|
+
* occurrence count, not a distinct-scope count.
|
|
39
|
+
*/
|
|
40
|
+
ackRaceThisTick: number;
|
|
41
|
+
stoppedBy: "drained" | "budget" | "max_runs" | "time";
|
|
42
|
+
remainingProcessed: number;
|
|
43
|
+
remainingNoop: number;
|
|
44
|
+
remainingScan: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fairly spend one index-follower tick across a selected scope set.
|
|
48
|
+
*
|
|
49
|
+
* Each scope receives a small bounded slice first; scopes that still report
|
|
50
|
+
* `hasMore` are appended to the tail and can receive another slice only after
|
|
51
|
+
* the other selected scopes had their chance. The helper is intentionally
|
|
52
|
+
* storage/runtime-agnostic so Cloudflare and Node share the same starvation
|
|
53
|
+
* semantics while keeping different budget sizes.
|
|
54
|
+
*/
|
|
55
|
+
declare function runIndexFollowerRoundRobin<T>(opts: IndexFollowerRoundRobinOptions<T>): Promise<IndexFollowerRoundRobinResult<T>>;
|
|
56
|
+
//#endregion
|
|
57
|
+
export { IndexFollowerRoundRobinOptions, IndexFollowerRoundRobinResult, IndexFollowerRoundRobinRun, IndexFollowerRunLimits, runIndexFollowerRoundRobin };
|
|
58
|
+
//# sourceMappingURL=follower-scheduler.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"follower-scheduler.d.cts","names":[],"sources":["../src/follower-scheduler.ts"],"mappings":";;;UAMiB,sBAAA;EACf,YAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAGe,8BAAA;EACf,MAAA,EAAQ,CAAA;EACR,YAAA;EACA,OAAA;EACA,OAAA;EACA,oBAAA;EACA,eAAA;EACA,eAAA;EACA,OAAA;EACA,UAAA;EACA,QAAA,GAAW,KAAA,EAAO,CAAA,EAAG,MAAA,EAAQ,sBAAA,KAA2B,OAAA,CAAQ,wBAAA;AAAA;AAAA,UAGjD,0BAAA;EACf,KAAA,EAAO,CAAA;EACP,MAAA,EAAQ,sBAAA;EACR,MAAA,EAAQ,wBAAA;AAAA;AAAA,UAGO,6BAAA;EACf,IAAA,EAAM,0BAAA,CAA2B,CAAA;EACjC,iBAAA;EACA,YAAA;EACA,eAAA;EACA,eAAA;EACA,cAAA;EACA,eAAA;EAlBA;;;;;;EAyBA,eAAA;EACA,SAAA;EACA,kBAAA;EACA,aAAA;EACA,aAAA;AAAA;;;;;;;;;;iBAiBoB,0BAAA,GAAA,CACpB,IAAA,EAAM,8BAAA,CAA+B,CAAA,IACpC,OAAA,CAAQ,6BAAA,CAA8B,CAAA"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { IndexFollowerBatchResult } from "./follower.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/follower-scheduler.d.ts
|
|
4
|
+
interface IndexFollowerRunLimits {
|
|
5
|
+
maxProcessed: number;
|
|
6
|
+
maxNoop: number;
|
|
7
|
+
maxScan: number;
|
|
8
|
+
}
|
|
9
|
+
interface IndexFollowerRoundRobinOptions<T> {
|
|
10
|
+
scopes: T[];
|
|
11
|
+
maxProcessed: number;
|
|
12
|
+
maxNoop: number;
|
|
13
|
+
maxScan: number;
|
|
14
|
+
perScopeMaxProcessed?: number;
|
|
15
|
+
perScopeMaxNoop?: number;
|
|
16
|
+
perScopeMaxScan?: number;
|
|
17
|
+
maxRuns?: number;
|
|
18
|
+
shouldStop?: () => boolean;
|
|
19
|
+
runScope: (scope: T, limits: IndexFollowerRunLimits) => Promise<IndexFollowerBatchResult>;
|
|
20
|
+
}
|
|
21
|
+
interface IndexFollowerRoundRobinRun<T> {
|
|
22
|
+
scope: T;
|
|
23
|
+
limits: IndexFollowerRunLimits;
|
|
24
|
+
result: IndexFollowerBatchResult;
|
|
25
|
+
}
|
|
26
|
+
interface IndexFollowerRoundRobinResult<T> {
|
|
27
|
+
runs: IndexFollowerRoundRobinRun<T>[];
|
|
28
|
+
processedThisTick: number;
|
|
29
|
+
noopThisTick: number;
|
|
30
|
+
indexedThisTick: number;
|
|
31
|
+
deletedThisTick: number;
|
|
32
|
+
noopedThisTick: number;
|
|
33
|
+
scannedThisTick: number;
|
|
34
|
+
/**
|
|
35
|
+
* Drain RUNS that lost the ack race-guard this tick (healthy concurrent-write
|
|
36
|
+
* churn, not failures). Counted per `runScope` call, so a single hot scope
|
|
37
|
+
* requeued within the tick can contribute more than once — this is a run
|
|
38
|
+
* occurrence count, not a distinct-scope count.
|
|
39
|
+
*/
|
|
40
|
+
ackRaceThisTick: number;
|
|
41
|
+
stoppedBy: "drained" | "budget" | "max_runs" | "time";
|
|
42
|
+
remainingProcessed: number;
|
|
43
|
+
remainingNoop: number;
|
|
44
|
+
remainingScan: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fairly spend one index-follower tick across a selected scope set.
|
|
48
|
+
*
|
|
49
|
+
* Each scope receives a small bounded slice first; scopes that still report
|
|
50
|
+
* `hasMore` are appended to the tail and can receive another slice only after
|
|
51
|
+
* the other selected scopes had their chance. The helper is intentionally
|
|
52
|
+
* storage/runtime-agnostic so Cloudflare and Node share the same starvation
|
|
53
|
+
* semantics while keeping different budget sizes.
|
|
54
|
+
*/
|
|
55
|
+
declare function runIndexFollowerRoundRobin<T>(opts: IndexFollowerRoundRobinOptions<T>): Promise<IndexFollowerRoundRobinResult<T>>;
|
|
56
|
+
//#endregion
|
|
57
|
+
export { IndexFollowerRoundRobinOptions, IndexFollowerRoundRobinResult, IndexFollowerRoundRobinRun, IndexFollowerRunLimits, runIndexFollowerRoundRobin };
|
|
58
|
+
//# sourceMappingURL=follower-scheduler.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"follower-scheduler.d.mts","names":[],"sources":["../src/follower-scheduler.ts"],"mappings":";;;UAMiB,sBAAA;EACf,YAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAGe,8BAAA;EACf,MAAA,EAAQ,CAAA;EACR,YAAA;EACA,OAAA;EACA,OAAA;EACA,oBAAA;EACA,eAAA;EACA,eAAA;EACA,OAAA;EACA,UAAA;EACA,QAAA,GAAW,KAAA,EAAO,CAAA,EAAG,MAAA,EAAQ,sBAAA,KAA2B,OAAA,CAAQ,wBAAA;AAAA;AAAA,UAGjD,0BAAA;EACf,KAAA,EAAO,CAAA;EACP,MAAA,EAAQ,sBAAA;EACR,MAAA,EAAQ,wBAAA;AAAA;AAAA,UAGO,6BAAA;EACf,IAAA,EAAM,0BAAA,CAA2B,CAAA;EACjC,iBAAA;EACA,YAAA;EACA,eAAA;EACA,eAAA;EACA,cAAA;EACA,eAAA;EAlBA;;;;;;EAyBA,eAAA;EACA,SAAA;EACA,kBAAA;EACA,aAAA;EACA,aAAA;AAAA;;;;;;;;;;iBAiBoB,0BAAA,GAAA,CACpB,IAAA,EAAM,8BAAA,CAA+B,CAAA,IACpC,OAAA,CAAQ,6BAAA,CAA8B,CAAA"}
|