pi-mega-compact 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/vector-cortex.js +20 -0
- package/dist/dedup/digest.js +30 -0
- package/dist/dedup/l1-lsh-v2.js +60 -0
- package/dist/dedup/l1-lsh.js +52 -0
- package/dist/dedup/l1-minhash-v2.js +193 -0
- package/dist/dedup/l1-minhash.js +91 -0
- package/dist/dedup/l1-verify.js +54 -0
- package/dist/dedup/mmr.js +45 -0
- package/dist/dedup/normalize.js +39 -0
- package/dist/dedup/raptor/buildHistory.js +164 -0
- package/dist/dedup/raptor/guardrails.js +83 -0
- package/dist/dedup/raptor/incremental.js +304 -0
- package/dist/dedup/raptor/index.js +190 -0
- package/dist/dedup/raptor/kmeans.js +152 -0
- package/dist/dedup/raptor/multilevel.js +184 -0
- package/dist/dedup/raptor/retrieval.js +93 -0
- package/dist/dedup/raptor/summarizer.js +86 -0
- package/dist/dedup/raptor/tree.js +202 -0
- package/dist/dedup/topk.js +60 -0
- package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +2 -0
- package/dist/extensions/mega-events/context-handler/dbMirrorAppend.js +63 -0
- package/dist/extensions/mega-events/context-handler/gateCheck.js +59 -0
- package/dist/extensions/mega-events/context-handler/liveTrim.js +178 -0
- package/dist/extensions/mega-events/context-handler/pipelineRun.js +37 -0
- package/dist/extensions/mega-events/context-handler.js +39 -305
- package/dist/src/config/vector-cortex.js +20 -0
- package/dist/src/config.js +1 -1
- package/dist/src/dedup/l1-lsh-v2.js +60 -0
- package/dist/src/dedup/l1-minhash-v2.js +193 -0
- package/dist/src/dedup/normalize.js +1 -1
- package/dist/src/vector-cortex/conformance/emit.js +46 -0
- package/dist/src/vector-cortex/conformance/manifest.js +235 -0
- package/dist/src/vector-cortex/conformance/runner.js +111 -0
- package/dist/src/vector-cortex/conformance/triadB-reader.js +95 -0
- package/dist/src/vector-cortex/encoder/asset.js +142 -0
- package/dist/src/vector-cortex/encoder/emit.js +42 -0
- package/dist/src/vector-cortex/encoder/runtime.js +228 -0
- package/dist/src/vector-cortex/encoder/types.js +85 -0
- package/dist/src/vector-cortex/migrations/minhash-v2.js +209 -0
- package/dist/vector-cortex/conformance/emit.js +46 -0
- package/dist/vector-cortex/conformance/manifest.js +235 -0
- package/dist/vector-cortex/conformance/runner.js +111 -0
- package/dist/vector-cortex/conformance/triadB-reader.js +95 -0
- package/dist/vector-cortex/encoder/asset.js +142 -0
- package/dist/vector-cortex/encoder/emit.js +42 -0
- package/dist/vector-cortex/encoder/runtime.js +228 -0
- package/dist/vector-cortex/encoder/types.js +85 -0
- package/dist/vector-cortex/migrations/minhash-v2.js +209 -0
- package/extensions/dashboard-client/dist/assets/{AreaChart-CAnWomhw.js → AreaChart-BQXFF7UI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AreaChart-CAnWomhw.js.map → AreaChart-BQXFF7UI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{BarChart-lZ2-s71k.js → BarChart-BVtoz9Eg.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{BarChart-lZ2-s71k.js.map → BarChart-BVtoz9Eg.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-BL2Thh66.js → CacheTab-trI2TIGy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-BL2Thh66.js.map → CacheTab-trI2TIGy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-DEh2Z9fp.js → EventsTab-CNUbk9__.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-DEh2Z9fp.js.map → EventsTab-CNUbk9__.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{HealthTab-DTwKclxk.js → HealthTab-K-bd_CM-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{HealthTab-DTwKclxk.js.map → HealthTab-K-bd_CM-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DDfBEdbX.js → MaintenanceTab-CoEioKzx.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DDfBEdbX.js.map → MaintenanceTab-CoEioKzx.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-Dgfmg-0Q.js → MemoryMapTab-CJo24ulv.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-Dgfmg-0Q.js.map → MemoryMapTab-CJo24ulv.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DOcMm0Tx.js → MetricsTab-DCNlQWWP.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DOcMm0Tx.js.map → MetricsTab-DCNlQWWP.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-DHT5aoHg.js → OverviewTab-CQQlyIQ6.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-DHT5aoHg.js.map → OverviewTab-CQQlyIQ6.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-CGAmtR_c.js → ReposTab-Cqm82_yn.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-CGAmtR_c.js.map → ReposTab-Cqm82_yn.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-BIgx6iKT.js → SessionsTab-A8xTAdET.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-BIgx6iKT.js.map → SessionsTab-A8xTAdET.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SetupTab-ADghKuEI.js → SetupTab-CqaSgaZl.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SetupTab-ADghKuEI.js.map → SetupTab-CqaSgaZl.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-C33UiiOM.js → TimeSavedCard-C_f3q8GR.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-C33UiiOM.js.map → TimeSavedCard-C_f3q8GR.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DdbL9wPN.js → TurnsTab-BEsafACo.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DdbL9wPN.js.map → TurnsTab-BEsafACo.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-DD1CMK80.js +2 -0
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-DD1CMK80.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{WikiTab-Ch00OFKs.js → WikiTab-Bf01M2_Z.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{WikiTab-Ch00OFKs.js.map → WikiTab-Bf01M2_Z.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{button-ByeEdo2g.js → button-BEJPTk_E.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{button-ByeEdo2g.js.map → button-BEJPTk_E.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{card-DesoLXvc.js → card-DK1V382s.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{card-DesoLXvc.js.map → card-DK1V382s.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-CCAB7Iz4.js → generateCategoricalChart-CRtaPphL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-CCAB7Iz4.js.map → generateCategoricalChart-CRtaPphL.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-B6cgyAPr.js → index-CB6BslE4.js} +3 -3
- package/extensions/dashboard-client/dist/assets/{index-B6cgyAPr.js.map → index-CB6BslE4.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{switch-DIZC-oe-.js → switch-C4FV6B9k.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{switch-DIZC-oe-.js.map → switch-C4FV6B9k.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{toggle-CIoWaH2e.js → toggle-GBmeENMd.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{toggle-CIoWaH2e.js.map → toggle-GBmeENMd.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-Bv4943l1.js → useSSE-BOKZGSn1.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-Bv4943l1.js.map → useSSE-BOKZGSn1.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +1 -1
- package/extensions/dashboard-client/src/tabs/VectorCortexTab.tsx +1 -1
- package/extensions/dashboard-client/tsconfig.json +1 -1
- package/extensions/dashboard-server/routes-rag-settings-helpers.ts +12 -0
- package/extensions/mega-events/context-handler/dbMirrorAppend.ts +93 -0
- package/extensions/mega-events/context-handler/gateCheck.ts +101 -0
- package/extensions/mega-events/context-handler/liveTrim.ts +241 -0
- package/extensions/mega-events/context-handler/pipelineRun.ts +79 -0
- package/extensions/mega-events/context-handler.ts +45 -347
- package/package.json +1 -1
- package/src/config/vector-cortex.ts +22 -0
- package/src/config.ts +2 -0
- package/src/dedup/l1-lsh-v2.ts +78 -0
- package/src/dedup/l1-minhash-v2.ts +221 -0
- package/src/dedup/normalize.ts +1 -1
- package/src/store/sqlite/game-scores.ts +2 -1
- package/src/vector-cortex/conformance/emit.ts +56 -0
- package/src/vector-cortex/conformance/manifest.ts +298 -0
- package/src/vector-cortex/conformance/runner.ts +165 -0
- package/src/vector-cortex/conformance/triadB-reader.ts +110 -0
- package/src/vector-cortex/encoder/asset.ts +155 -0
- package/src/vector-cortex/encoder/emit.ts +51 -0
- package/src/vector-cortex/encoder/runtime.ts +283 -0
- package/src/vector-cortex/encoder/types.ts +165 -0
- package/src/vector-cortex/migrations/minhash-v2.ts +263 -0
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-Cl1pbEdR.js +0 -2
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-Cl1pbEdR.js.map +0 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — RAPTOR orchestrator (Sprint 13, Phase 6 → promoted to live in S42B).
|
|
3
|
+
*
|
|
4
|
+
* Entry point that turns a session's leaves into a hierarchical summary tree.
|
|
5
|
+
* The tree is BUILT, PERSISTED to raptor_nodes, and SERVED in recall queries
|
|
6
|
+
* when RAPTOR_ENABLED=true (default). Set RAPTOR_SHADOW_MODE=true to build +
|
|
7
|
+
* persist only without serving (transition/eval use).
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: no network here. Any Ollama call lives in summarizer.ts
|
|
10
|
+
* (localhost-only, annotated). This module is pure orchestration.
|
|
11
|
+
*/
|
|
12
|
+
import { defaultEmbedder } from "../../embedder.js";
|
|
13
|
+
import { buildRaptorTree } from "./tree.js";
|
|
14
|
+
import { incrementRaptorTree } from "./incremental.js";
|
|
15
|
+
import { stagedExpansion } from "./retrieval.js";
|
|
16
|
+
import { saveRaptorTree, listRaptorNodes } from "../../store/sqlite.js";
|
|
17
|
+
import { insertBuildHistory, computeCoherenceScore } from "./buildHistory.js";
|
|
18
|
+
import { loadDedupConfig } from "../../config/dedup.js";
|
|
19
|
+
/**
|
|
20
|
+
* Shadow mode default OFF: the tree is built, persisted, AND served when
|
|
21
|
+
* RAPTOR_ENABLED=true (the Setup tab toggle). Set RAPTOR_SHADOW_MODE=true to
|
|
22
|
+
* build + persist only (transition/eval use without serving live recall).
|
|
23
|
+
*/
|
|
24
|
+
export function isShadowMode() {
|
|
25
|
+
return process.env.RAPTOR_SHADOW_MODE === "true";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the RAPTOR tree for a session's leaves. Per shadow-mode rules, the tree
|
|
29
|
+
* is persisted + logged regardless; whether it is served is the caller's choice
|
|
30
|
+
* (Sprint 13: it is built but NOT injected into recallAndInline).
|
|
31
|
+
*
|
|
32
|
+
* Returns the built tree (in-memory) for eval/tests, and persists it to the
|
|
33
|
+
* store. Never throws — on any build error it logs and returns null.
|
|
34
|
+
*/
|
|
35
|
+
export function runRaptor(leaves, opts) {
|
|
36
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
37
|
+
const logger = opts.logger;
|
|
38
|
+
const startedAt = opts.builtAt ?? Date.now();
|
|
39
|
+
const builtAt = startedAt;
|
|
40
|
+
try {
|
|
41
|
+
// Try incremental update first when the flag is ON (Sprint 26, #7).
|
|
42
|
+
// The incremental path reads the existing tree, diffs by leaf id, and
|
|
43
|
+
// inserts only new leaves into the nearest clusters. Falls back to a full
|
|
44
|
+
// rebuild when the tree is missing, corrupted, or >50% of nodes are new.
|
|
45
|
+
const cfg = loadDedupConfig();
|
|
46
|
+
let tree = null;
|
|
47
|
+
if (cfg.RAPTOR_INCREMENTAL) {
|
|
48
|
+
tree = incrementRaptorTree(leaves, leaves, {
|
|
49
|
+
embedder,
|
|
50
|
+
stateDir: opts.stateDir,
|
|
51
|
+
sessionId: opts.sessionId,
|
|
52
|
+
budgetMs: opts.budgetMs,
|
|
53
|
+
clustersPerLevel: opts.clustersPerLevel,
|
|
54
|
+
consistencyThreshold: opts.consistencyThreshold,
|
|
55
|
+
logger,
|
|
56
|
+
builtAt,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Fall back to full rebuild when incremental returned null (not attempted,
|
|
60
|
+
// tree missing, ratio exceeded, or internal error).
|
|
61
|
+
if (!tree) {
|
|
62
|
+
tree = buildRaptorTree(leaves, {
|
|
63
|
+
embedder,
|
|
64
|
+
budgetMs: opts.budgetMs,
|
|
65
|
+
clustersPerLevel: opts.clustersPerLevel,
|
|
66
|
+
consistencyThreshold: opts.consistencyThreshold,
|
|
67
|
+
});
|
|
68
|
+
saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
|
|
69
|
+
}
|
|
70
|
+
logger?.info("raptor_build", {
|
|
71
|
+
sessionId: opts.sessionId,
|
|
72
|
+
nodes: tree.nodes.size,
|
|
73
|
+
levels: tree.levels,
|
|
74
|
+
rootId: tree.rootId,
|
|
75
|
+
timedOut: tree.timedOut,
|
|
76
|
+
shadow: isShadowMode(),
|
|
77
|
+
});
|
|
78
|
+
// S42D: record structured build history (coherence score + leaf count) so
|
|
79
|
+
// the freshness check can skip the next rebuild when the tree is recent
|
|
80
|
+
// and the chunk count is stable. Best-effort: a history-write failure must
|
|
81
|
+
// never break the build.
|
|
82
|
+
try {
|
|
83
|
+
// Map leaf id → embedding so computeCoherenceScore can score real
|
|
84
|
+
// intra-cluster spread (without this, leaves collapse to parent centroids
|
|
85
|
+
// and the score inflates to ~1.0 for every tree).
|
|
86
|
+
const leafEmbs = new Map();
|
|
87
|
+
for (const l of leaves)
|
|
88
|
+
if (l.embedding.length > 0)
|
|
89
|
+
leafEmbs.set(l.id, l.embedding);
|
|
90
|
+
const coherence = computeCoherenceScore(tree, leafEmbs);
|
|
91
|
+
insertBuildHistory({
|
|
92
|
+
sessionId: opts.sessionId,
|
|
93
|
+
stateDir: opts.stateDir,
|
|
94
|
+
startedAt,
|
|
95
|
+
completedAt: Date.now(),
|
|
96
|
+
nodeCount: tree.nodes.size,
|
|
97
|
+
leafCount: leaves.length,
|
|
98
|
+
depth: tree.levels,
|
|
99
|
+
configJson: JSON.stringify({
|
|
100
|
+
budgetMs: opts.budgetMs,
|
|
101
|
+
clustersPerLevel: opts.clustersPerLevel,
|
|
102
|
+
consistencyThreshold: opts.consistencyThreshold,
|
|
103
|
+
}),
|
|
104
|
+
coherenceScore: coherence,
|
|
105
|
+
timedOut: tree.timedOut,
|
|
106
|
+
}, opts.stateDir);
|
|
107
|
+
}
|
|
108
|
+
catch (histErr) {
|
|
109
|
+
logger?.warn("raptor_build_history_failed", {
|
|
110
|
+
sessionId: opts.sessionId,
|
|
111
|
+
error: String(histErr instanceof Error ? histErr.message : histErr),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (isShadowMode()) {
|
|
115
|
+
// Build + log only. Do NOT replace retrieval.
|
|
116
|
+
logger?.info("raptor_shadow", { sessionId: opts.sessionId, served: false });
|
|
117
|
+
}
|
|
118
|
+
return tree;
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
logger?.error("raptor_build_failed", {
|
|
122
|
+
sessionId: opts.sessionId,
|
|
123
|
+
error: String(e instanceof Error ? e.message : e),
|
|
124
|
+
});
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Staged retrieval over a persisted/session tree. Only meaningful when RAPTOR
|
|
130
|
+
* is promoted (Sprint 14); provided here so eval can measure it in shadow.
|
|
131
|
+
*
|
|
132
|
+
* Returns the leaf ids the staged expansion would serve for `query`.
|
|
133
|
+
*/
|
|
134
|
+
export function recallRaptor(query, sessionId, opts) {
|
|
135
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
136
|
+
const tree = rehydrateRaptorTree(sessionId, opts.stateDir);
|
|
137
|
+
if (!tree)
|
|
138
|
+
return [];
|
|
139
|
+
return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Rehydrate a persisted RAPTOR tree from raptor_nodes (Fix D): rebuild the
|
|
143
|
+
* in-memory RaptorTree + parent links so vectorStore.search can serve it live.
|
|
144
|
+
* Returns null when no tree exists (caller falls back to the flat path).
|
|
145
|
+
*/
|
|
146
|
+
export function rehydrateRaptorTree(sessionId, stateDir) {
|
|
147
|
+
const nodes = listRaptorNodes(sessionId, stateDir);
|
|
148
|
+
if (nodes.length === 0)
|
|
149
|
+
return null;
|
|
150
|
+
// S25: derive freshness + fallback metadata from the persisted nodes.
|
|
151
|
+
// builtAt = max node built_at (0 when unknown → caller treats as stale).
|
|
152
|
+
// timedOut = the tree's root is the extractive-fallback marker (level 99).
|
|
153
|
+
const builtAt = nodes.reduce((max, n) => Math.max(max, n.builtAt), 0);
|
|
154
|
+
const root = nodes.reduce((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null);
|
|
155
|
+
const timedOut = root != null && root.level >= 99;
|
|
156
|
+
const tree = {
|
|
157
|
+
nodes: new Map(nodes.map((n) => [
|
|
158
|
+
n.id,
|
|
159
|
+
{
|
|
160
|
+
id: n.id,
|
|
161
|
+
level: n.level,
|
|
162
|
+
parentId: n.parentId,
|
|
163
|
+
children: n.children,
|
|
164
|
+
summary: n.summary,
|
|
165
|
+
embedding: n.embedding,
|
|
166
|
+
qualityMarker: n.qualityMarker,
|
|
167
|
+
tokenEstimate: n.tokenEstimate,
|
|
168
|
+
},
|
|
169
|
+
])),
|
|
170
|
+
rootId: root?.id ?? null,
|
|
171
|
+
levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
|
|
172
|
+
timedOut,
|
|
173
|
+
builtAt,
|
|
174
|
+
};
|
|
175
|
+
return tree;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Return the RAPTOR root summary for a session, if a tree has been built.
|
|
179
|
+
* Used by the durable-trim driver (Fix B/D) to supply pi a session-level
|
|
180
|
+
* compressed summary instead of one slice's extractive summary. Returns
|
|
181
|
+
* undefined when no tree exists yet (caller falls back to the slice summary).
|
|
182
|
+
*/
|
|
183
|
+
export function recallRaptorRootSummary(sessionId, stateDir) {
|
|
184
|
+
const nodes = listRaptorNodes(sessionId, stateDir);
|
|
185
|
+
if (nodes.length === 0)
|
|
186
|
+
return undefined;
|
|
187
|
+
// Highest-level node = the root (covers all leaves).
|
|
188
|
+
const root = nodes.reduce((best, n) => (!best || n.level > best.level ? n : best), null);
|
|
189
|
+
return root?.summary || undefined;
|
|
190
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kmeans.ts — k-means++ clustering over embeddings (Sprint 13, RAPTOR Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* Pure TS, zero deps, deterministic (seeded). Used to group leaf chunks into
|
|
5
|
+
* nodes for the RAPTOR summary tree. QA #11: GMM is the spec's long-term target
|
|
6
|
+
* for cosine space; k-means++ is the shippable local default, with a
|
|
7
|
+
* near-zero-variance merge guard so degenerate inputs can't spin forever.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: math only, no network, no model.
|
|
10
|
+
*/
|
|
11
|
+
import { cosineSimilarity } from "../../embedder.js";
|
|
12
|
+
/** Seeded PRNG (mulberry32) so clustering is deterministic across runs. */
|
|
13
|
+
function rng(seed) {
|
|
14
|
+
let a = seed >>> 0;
|
|
15
|
+
return () => {
|
|
16
|
+
a |= 0;
|
|
17
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
18
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
19
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
20
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** L2 distance squared between two equal-length vectors. */
|
|
24
|
+
function dist2(a, b) {
|
|
25
|
+
let s = 0;
|
|
26
|
+
for (let i = 0; i < a.length; i++) {
|
|
27
|
+
const d = a[i] - b[i];
|
|
28
|
+
s += d * d;
|
|
29
|
+
}
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* k-means++ clustering. Deterministic given `seed`.
|
|
34
|
+
*
|
|
35
|
+
* Near-zero-variance guard (QA #11): if the max pairwise distance among input
|
|
36
|
+
* points is below `varianceFloor`, all points are effectively identical — return
|
|
37
|
+
* a single cluster (the mean) instead of iterating. Prevents the centroid-init
|
|
38
|
+
* from dividing by ~0 and keeps degenerate sessions from spinning.
|
|
39
|
+
*/
|
|
40
|
+
export function kmeanspp(points, k, opts = {}) {
|
|
41
|
+
const n = points.length;
|
|
42
|
+
const seed = opts.seed ?? 0x9e3779b9;
|
|
43
|
+
const maxIter = opts.maxIter ?? 25;
|
|
44
|
+
const varianceFloor = opts.varianceFloor ?? 1e-12;
|
|
45
|
+
if (n === 0)
|
|
46
|
+
return { assignments: [], centroids: [], k: 0 };
|
|
47
|
+
if (n === 1)
|
|
48
|
+
return { assignments: [0], centroids: [points[0].slice()], k: 1 };
|
|
49
|
+
// Near-zero-variance merge guard.
|
|
50
|
+
let maxPair = 0;
|
|
51
|
+
for (let i = 0; i < n; i++) {
|
|
52
|
+
for (let j = i + 1; j < n; j++) {
|
|
53
|
+
const d = dist2(points[i], points[j]);
|
|
54
|
+
if (d > maxPair)
|
|
55
|
+
maxPair = d;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (maxPair < varianceFloor) {
|
|
59
|
+
const mean = new Array(points[0].length).fill(0);
|
|
60
|
+
for (const p of points)
|
|
61
|
+
for (let i = 0; i < mean.length; i++)
|
|
62
|
+
mean[i] += p[i] / n;
|
|
63
|
+
return { assignments: new Array(n).fill(0), centroids: [mean], k: 1 };
|
|
64
|
+
}
|
|
65
|
+
const kk = Math.min(k, n);
|
|
66
|
+
const rand = rng(seed);
|
|
67
|
+
// k-means++ seeding: first centroid random, rest weighted by squared distance.
|
|
68
|
+
const centroids = [points[Math.floor(rand() * n)].slice()];
|
|
69
|
+
const d2 = new Array(n).fill(Infinity);
|
|
70
|
+
while (centroids.length < kk) {
|
|
71
|
+
let sum = 0;
|
|
72
|
+
for (let i = 0; i < n; i++) {
|
|
73
|
+
const d = dist2(points[i], centroids[centroids.length - 1]);
|
|
74
|
+
if (d < d2[i])
|
|
75
|
+
d2[i] = d;
|
|
76
|
+
sum += d2[i];
|
|
77
|
+
}
|
|
78
|
+
if (sum === 0) {
|
|
79
|
+
// All remaining points coincide with an existing centroid.
|
|
80
|
+
for (const p of points)
|
|
81
|
+
if (!centroids.some((c) => dist2(c, p) < 1e-12))
|
|
82
|
+
centroids.push(p.slice());
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
let target = rand() * sum;
|
|
86
|
+
let chosen = 0;
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
target -= d2[i];
|
|
89
|
+
if (target <= 0) {
|
|
90
|
+
chosen = i;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
centroids.push(points[chosen].slice());
|
|
95
|
+
}
|
|
96
|
+
const assignments = new Array(n).fill(0);
|
|
97
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
98
|
+
let changed = false;
|
|
99
|
+
// Assign each point to its nearest centroid.
|
|
100
|
+
for (let i = 0; i < n; i++) {
|
|
101
|
+
let best = 0;
|
|
102
|
+
let bestD = Infinity;
|
|
103
|
+
for (let c = 0; c < centroids.length; c++) {
|
|
104
|
+
const d = dist2(points[i], centroids[c]);
|
|
105
|
+
if (d < bestD) {
|
|
106
|
+
bestD = d;
|
|
107
|
+
best = c;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (assignments[i] !== best) {
|
|
111
|
+
assignments[i] = best;
|
|
112
|
+
changed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Recompute centroids as the mean of assigned points.
|
|
116
|
+
const sums = centroids.map(() => new Array(points[0].length).fill(0));
|
|
117
|
+
const counts = new Array(centroids.length).fill(0);
|
|
118
|
+
for (let i = 0; i < n; i++) {
|
|
119
|
+
const c = assignments[i];
|
|
120
|
+
counts[c]++;
|
|
121
|
+
for (let d = 0; d < points[i].length; d++)
|
|
122
|
+
sums[c][d] += points[i][d];
|
|
123
|
+
}
|
|
124
|
+
for (let c = 0; c < centroids.length; c++) {
|
|
125
|
+
if (counts[c] === 0)
|
|
126
|
+
continue; // keep prior centroid if a cluster emptied
|
|
127
|
+
centroids[c] = sums[c].map((s) => s / counts[c]);
|
|
128
|
+
}
|
|
129
|
+
if (!changed && iter > 0)
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
return { assignments, centroids, k: centroids.length };
|
|
133
|
+
}
|
|
134
|
+
/** Mean embedding of a set of vectors (used to reduce a cluster to its centroid). */
|
|
135
|
+
export function meanVector(vectors) {
|
|
136
|
+
if (vectors.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
const dim = vectors[0].length;
|
|
139
|
+
const sum = new Array(dim).fill(0);
|
|
140
|
+
for (const v of vectors)
|
|
141
|
+
for (let i = 0; i < dim; i++)
|
|
142
|
+
sum[i] += v[i];
|
|
143
|
+
return sum.map((s) => s / vectors.length);
|
|
144
|
+
}
|
|
145
|
+
/** Centroid of the union of two clusters (cosine-space "midpoint" via mean). */
|
|
146
|
+
export function mergeCentroids(a, b) {
|
|
147
|
+
return meanVector([a, b]);
|
|
148
|
+
}
|
|
149
|
+
/** Cosine distance (1 - cosine) — used by tree.ts for budget/quality checks. */
|
|
150
|
+
export function cosineDistance(a, b) {
|
|
151
|
+
return 1 - cosineSimilarity(a, b);
|
|
152
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* multilevel.ts — Multi-level RAPTOR retrieval engine (S42A).
|
|
3
|
+
*
|
|
4
|
+
* Upgrades the RAPTOR recall path from flat (leaf-only) to multi-level
|
|
5
|
+
* retrieval across the entire hierarchical tree. Searches ALL levels with
|
|
6
|
+
* configurable level weights, supports leaf expansion for cluster hits,
|
|
7
|
+
* and deduplicates overlapping results.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: pure in-process math (cosine, BFS, extractive). No network.
|
|
10
|
+
* PREVENT-PI-001: produces SearchHit[] that feed into recallAndInline() —
|
|
11
|
+
* affects which checkpoints are recalled, not how messages are dropped.
|
|
12
|
+
*/
|
|
13
|
+
import { cosineSimilarity } from "../../embedder.js";
|
|
14
|
+
import { mmrRerank } from "../mmr.js";
|
|
15
|
+
import { leafDescendants } from "./retrieval.js";
|
|
16
|
+
const DEFAULT_LEVEL_WEIGHTS = [1.0, 0.9, 0.8, 0.7, 0.5];
|
|
17
|
+
// ── S42A-2: Level-weighted scoring ─────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Score all RAPTOR tree nodes by cosine similarity to the query, then apply
|
|
20
|
+
* level-specific weights. Returns hits sorted by weighted score descending.
|
|
21
|
+
*
|
|
22
|
+
* Level weights: leaves (level 0) get weight 1.0, level 1 gets 0.9, etc.
|
|
23
|
+
* This ensures detailed leaves score highest while still surfacing higher-level
|
|
24
|
+
* summaries when they're highly relevant.
|
|
25
|
+
*/
|
|
26
|
+
export function scoreTreeLevels(query, tree, opts) {
|
|
27
|
+
const { embedder } = opts;
|
|
28
|
+
const weights = opts.levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
|
|
29
|
+
const qv = embedder.embed(query);
|
|
30
|
+
const hits = [];
|
|
31
|
+
// 1. Score all internal (summary) nodes.
|
|
32
|
+
for (const node of tree.nodes.values()) {
|
|
33
|
+
const rawScore = cosineSimilarity(qv, node.embedding);
|
|
34
|
+
const levelWeight = weights[Math.min(node.level, weights.length - 1)];
|
|
35
|
+
hits.push({
|
|
36
|
+
nodeId: node.id,
|
|
37
|
+
level: node.level,
|
|
38
|
+
score: rawScore * levelWeight,
|
|
39
|
+
rawScore,
|
|
40
|
+
isLeaf: false,
|
|
41
|
+
leafIds: node.children,
|
|
42
|
+
summary: node.summary,
|
|
43
|
+
embedding: node.embedding,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// 2. Score leaf nodes. Leaf ids are not in tree.nodes — they are children
|
|
47
|
+
// referenced by internal nodes. Each leaf's embedding is the level-0
|
|
48
|
+
// parent node that wraps it (same approach as stagedExpansion:95–102).
|
|
49
|
+
const seenLeaves = new Set();
|
|
50
|
+
for (const node of tree.nodes.values()) {
|
|
51
|
+
for (const leafId of node.children) {
|
|
52
|
+
if (seenLeaves.has(leafId) || tree.nodes.has(leafId))
|
|
53
|
+
continue;
|
|
54
|
+
seenLeaves.add(leafId);
|
|
55
|
+
const rawScore = cosineSimilarity(qv, node.embedding);
|
|
56
|
+
const leafWeight = weights[0];
|
|
57
|
+
hits.push({
|
|
58
|
+
nodeId: leafId,
|
|
59
|
+
level: 0,
|
|
60
|
+
score: rawScore * leafWeight,
|
|
61
|
+
rawScore,
|
|
62
|
+
isLeaf: true,
|
|
63
|
+
leafIds: [leafId],
|
|
64
|
+
summary: "", // leaves have no summary — they are raw checkpoint ids
|
|
65
|
+
embedding: node.embedding,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
hits.sort((a, b) => b.score - a.score);
|
|
70
|
+
return hits;
|
|
71
|
+
}
|
|
72
|
+
// ── S42A-3: Leaf expansion ─────────────────────────────────────────────────
|
|
73
|
+
/** Build a reverse index: childId → parent RaptorNode. O(N). */
|
|
74
|
+
export function buildChildParentIndex(tree) {
|
|
75
|
+
const idx = new Map();
|
|
76
|
+
for (const node of tree.nodes.values()) {
|
|
77
|
+
for (const cid of node.children) {
|
|
78
|
+
if (!idx.has(cid))
|
|
79
|
+
idx.set(cid, node);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return idx;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Given a set of cluster-level hits, expand each one to include its leaf
|
|
86
|
+
* descendants. Deduplicates: if a leaf is already present as a direct hit,
|
|
87
|
+
* it is not duplicated. Returns the merged set (original hits + expanded leaves).
|
|
88
|
+
*/
|
|
89
|
+
export function expandLeafDescendants(hits, tree, maxPerCluster, _embedder, queryVector, levelWeights) {
|
|
90
|
+
const weights = levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
|
|
91
|
+
const existingIds = new Set(hits.map((h) => h.nodeId));
|
|
92
|
+
const childParentIdx = buildChildParentIndex(tree);
|
|
93
|
+
const expanded = [];
|
|
94
|
+
for (const hit of hits) {
|
|
95
|
+
if (hit.isLeaf) {
|
|
96
|
+
expanded.push(hit);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Get all leaf descendants for this cluster node.
|
|
100
|
+
const node = tree.nodes.get(hit.nodeId);
|
|
101
|
+
if (!node) {
|
|
102
|
+
expanded.push(hit);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const rawLeafIds = leafDescendants(node, tree);
|
|
106
|
+
// Sort by cosine similarity to query, cap at maxPerCluster.
|
|
107
|
+
// Skip leaves with no parent (orphan) — they have no reliable embedding.
|
|
108
|
+
const leafHits = rawLeafIds
|
|
109
|
+
.map((lid) => {
|
|
110
|
+
const parent = childParentIdx.get(lid);
|
|
111
|
+
const sim = parent
|
|
112
|
+
? cosineSimilarity(queryVector, parent.embedding)
|
|
113
|
+
: -Infinity; // orphan: excluded
|
|
114
|
+
return { lid, sim, parent };
|
|
115
|
+
})
|
|
116
|
+
.filter((l) => l.sim > -Infinity && !existingIds.has(l.lid))
|
|
117
|
+
.sort((a, b) => b.sim - a.sim)
|
|
118
|
+
.slice(0, maxPerCluster)
|
|
119
|
+
.map((l) => {
|
|
120
|
+
existingIds.add(l.lid);
|
|
121
|
+
const rawScore = l.sim;
|
|
122
|
+
return {
|
|
123
|
+
nodeId: l.lid,
|
|
124
|
+
level: 0,
|
|
125
|
+
score: rawScore * weights[0],
|
|
126
|
+
rawScore,
|
|
127
|
+
isLeaf: true,
|
|
128
|
+
leafIds: [l.lid],
|
|
129
|
+
summary: "",
|
|
130
|
+
embedding: l.parent.embedding, // parent guaranteed non-null here
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
expanded.push(hit, ...leafHits);
|
|
134
|
+
}
|
|
135
|
+
return expanded;
|
|
136
|
+
}
|
|
137
|
+
// ── S42A-4: Result dedup ───────────────────────────────────────────────────
|
|
138
|
+
/**
|
|
139
|
+
* Deduplicate hits: if both a cluster node and its leaf children appear in
|
|
140
|
+
* results, remove the cluster hit (leaves provide more specific context).
|
|
141
|
+
* If no leaves are in the set, keep the cluster hit (it provides the abstract view).
|
|
142
|
+
*/
|
|
143
|
+
export function deduplicateMultilevelHits(hits) {
|
|
144
|
+
const leafIds = new Set(hits.filter((h) => h.isLeaf).map((h) => h.nodeId));
|
|
145
|
+
return hits.filter((h) => {
|
|
146
|
+
if (h.isLeaf)
|
|
147
|
+
return true;
|
|
148
|
+
// Cluster hit: keep only if none of its leaf children are present.
|
|
149
|
+
return !h.leafIds.some((lid) => leafIds.has(lid));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// ── S42A-5: Top-level pipeline ─────────────────────────────────────────────
|
|
153
|
+
/**
|
|
154
|
+
* Full multi-level retrieval pipeline: score → expand → dedup → MMR → top-K.
|
|
155
|
+
* Drop-in replacement for `stagedExpansion()` in the RAPTOR recall path.
|
|
156
|
+
*/
|
|
157
|
+
export function multilevelRetrieval(query, tree, opts) {
|
|
158
|
+
if (!tree.rootId)
|
|
159
|
+
return [];
|
|
160
|
+
const { embedder } = opts;
|
|
161
|
+
const weights = opts.levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
|
|
162
|
+
const leafExp = opts.leafExpansion !== false; // default true
|
|
163
|
+
const maxLeafExp = opts.maxLeafExpansion ?? 10;
|
|
164
|
+
const k = opts.k ?? 5;
|
|
165
|
+
const lambda = opts.mmrLambda ?? 0.5;
|
|
166
|
+
const qv = embedder.embed(query);
|
|
167
|
+
// 1. Score all nodes with level weights.
|
|
168
|
+
const scored = scoreTreeLevels(query, tree, { embedder, levelWeights: weights });
|
|
169
|
+
// 2. Top-N candidates for MMR diversity window.
|
|
170
|
+
const topN = scored.slice(0, k * 3);
|
|
171
|
+
// 3. Leaf expansion (optional).
|
|
172
|
+
const expanded = leafExp
|
|
173
|
+
? expandLeafDescendants(topN, tree, maxLeafExp, embedder, qv, weights)
|
|
174
|
+
: topN;
|
|
175
|
+
// 4. Dedup: remove cluster hits when leaf children are present.
|
|
176
|
+
const deduped = deduplicateMultilevelHits(expanded);
|
|
177
|
+
// 5. MMR rerank to k.
|
|
178
|
+
const mmrItems = deduped.map((h) => ({
|
|
179
|
+
item: h,
|
|
180
|
+
vector: h.embedding,
|
|
181
|
+
relevance: h.score,
|
|
182
|
+
}));
|
|
183
|
+
return mmrRerank(mmrItems, k, lambda);
|
|
184
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retrieval.ts — staged RAPTOR retrieval (Sprint 13, Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* Given a built tree (shadow or live), answer a query by:
|
|
5
|
+
* 1. ANN: score the top-level nodes at every level by cosine to the query.
|
|
6
|
+
* 2. expand: take the top-M nodes (across levels) and descend.
|
|
7
|
+
* 3. BFS: from those anchors, walk down to leaf nodes.
|
|
8
|
+
* 4. MMR: diversify the resulting leaf set before returning.
|
|
9
|
+
*
|
|
10
|
+
* This module is pure query logic over an in-memory RaptorTree. In Sprint 13 it
|
|
11
|
+
* is exercised only in shadow/eval; the live store (vectorStore.search) is NOT
|
|
12
|
+
* replaced until Sprint 14 promotes RAPTOR.
|
|
13
|
+
*/
|
|
14
|
+
import { cosineSimilarity } from "../../embedder.js";
|
|
15
|
+
import { mmrRerank } from "../mmr.js";
|
|
16
|
+
/** Any child id not present in the node map is a raw leaf id. */
|
|
17
|
+
function isLeafId(id, tree) {
|
|
18
|
+
return !tree.nodes.has(id);
|
|
19
|
+
}
|
|
20
|
+
/** Build a reverse index: childId → parent RaptorNode. O(N). */
|
|
21
|
+
export function buildChildParentIndex(tree) {
|
|
22
|
+
const idx = new Map();
|
|
23
|
+
for (const node of tree.nodes.values()) {
|
|
24
|
+
for (const cid of node.children) {
|
|
25
|
+
if (!idx.has(cid))
|
|
26
|
+
idx.set(cid, node);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return idx;
|
|
30
|
+
}
|
|
31
|
+
/** All leaf (raw) ids reachable beneath a node via BFS. */
|
|
32
|
+
export function leafDescendants(node, tree) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const queue = [node];
|
|
35
|
+
while (queue.length) {
|
|
36
|
+
const cur = queue.shift();
|
|
37
|
+
for (const childId of cur.children) {
|
|
38
|
+
if (isLeafId(childId, tree))
|
|
39
|
+
out.push(childId);
|
|
40
|
+
else {
|
|
41
|
+
const child = tree.nodes.get(childId);
|
|
42
|
+
if (child)
|
|
43
|
+
queue.push(child);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Staged expansion retrieval over a RAPTOR tree.
|
|
51
|
+
*
|
|
52
|
+
* Returns up to `k` leaf ids, diversified by MMR. Deterministic given the
|
|
53
|
+
* tree + query. Throws nothing — returns [] on empty tree.
|
|
54
|
+
*/
|
|
55
|
+
export function stagedExpansion(query, tree, opts) {
|
|
56
|
+
if (!tree.rootId)
|
|
57
|
+
return [];
|
|
58
|
+
const embedder = opts.embedder;
|
|
59
|
+
const topM = opts.topM ?? 3;
|
|
60
|
+
const k = opts.k ?? 5;
|
|
61
|
+
const lambda = opts.mmrLambda ?? 0.5;
|
|
62
|
+
const qv = embedder.embed(query);
|
|
63
|
+
// 1. ANN: score every node at every level by cosine to the query.
|
|
64
|
+
const scored = [...tree.nodes.values()].map((n) => ({
|
|
65
|
+
node: n,
|
|
66
|
+
score: cosineSimilarity(qv, n.embedding),
|
|
67
|
+
}));
|
|
68
|
+
// 2. expand: top-M nodes overall (BFS anchors).
|
|
69
|
+
const anchors = scored
|
|
70
|
+
.slice()
|
|
71
|
+
.sort((a, b) => b.score - a.score)
|
|
72
|
+
.slice(0, topM)
|
|
73
|
+
.map((s) => s.node);
|
|
74
|
+
// 3. BFS to leaves from those anchors, using a reverse child→parent index.
|
|
75
|
+
const childParentIdx = buildChildParentIndex(tree);
|
|
76
|
+
const leaves = new Map();
|
|
77
|
+
for (const a of anchors) {
|
|
78
|
+
for (const lid of leafDescendants(a, tree)) {
|
|
79
|
+
// Represent each leaf by its nearest internal parent so we can score it.
|
|
80
|
+
// (The leaf's own centroid is stored on the level-0 node that wraps it.)
|
|
81
|
+
const parent = childParentIdx.get(lid);
|
|
82
|
+
if (parent)
|
|
83
|
+
leaves.set(lid, parent);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// 4. MMR diversify the expanded leaf set by their (parent) embeddings.
|
|
87
|
+
const items = [...leaves.entries()].map(([lid, n]) => ({
|
|
88
|
+
item: lid,
|
|
89
|
+
vector: n.embedding,
|
|
90
|
+
relevance: cosineSimilarity(qv, n.embedding),
|
|
91
|
+
}));
|
|
92
|
+
return mmrRerank(items, k, lambda);
|
|
93
|
+
}
|