@whyparabola/cabar 0.0.0-stage → 0.1.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.
@@ -0,0 +1,35 @@
1
+ import { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';
2
+ import { DocumentInterface } from '@langchain/core/documents';
3
+ import { b as CabarSelectOptions } from './cabar-B-R-rHby.js';
4
+
5
+ /**
6
+ * LangChain.js adapter: CABAR as a document compressor.
7
+ *
8
+ * import { ContextualCompressionRetriever } from '@langchain/classic/retrievers/contextual_compression';
9
+ * import { CabarCompressor } from '@whyparabola/cabar/langchain';
10
+ *
11
+ * const retriever = new ContextualCompressionRetriever({
12
+ * baseCompressor: new CabarCompressor({ budgetTokens: 2000 }),
13
+ * baseRetriever: vectorStore.asRetriever(24),
14
+ * });
15
+ */
16
+
17
+ interface CabarCompressorOptions extends CabarSelectOptions {
18
+ /**
19
+ * Metadata keys holding a relevance score, tried in order. Default:
20
+ * `relevance_score` (rerankers), `relevanceScore`, `score`. Documents without
21
+ * any of them are scored by their position in the retriever's order.
22
+ */
23
+ scoreKeys?: string[];
24
+ /** Metadata key holding a date (Date, ISO string or epoch ms) for recency. Default: `timestamp`. */
25
+ timestampKey?: string;
26
+ /** Append the tail summary as one extra Document (metadata `{ cabar: 'tail' }`). Default true. */
27
+ includeTail?: boolean;
28
+ }
29
+ declare class CabarCompressor extends BaseDocumentCompressor {
30
+ private readonly opts;
31
+ constructor(opts?: CabarCompressorOptions);
32
+ compressDocuments(documents: DocumentInterface[], query: string): Promise<DocumentInterface[]>;
33
+ }
34
+
35
+ export { CabarCompressor, type CabarCompressorOptions };
@@ -0,0 +1,50 @@
1
+ import {
2
+ CabarSelector,
3
+ normalizeScores
4
+ } from "./chunk-UE4FTY7E.js";
5
+
6
+ // src/langchain.ts
7
+ import { BaseDocumentCompressor } from "@langchain/core/retrievers/document_compressors";
8
+ import { Document } from "@langchain/core/documents";
9
+ var DEFAULT_SCORE_KEYS = ["relevance_score", "relevanceScore", "score"];
10
+ function toDate(v) {
11
+ if (v instanceof Date) return v;
12
+ if (typeof v === "string" || typeof v === "number") {
13
+ const d = new Date(v);
14
+ return Number.isNaN(d.getTime()) ? void 0 : d;
15
+ }
16
+ return void 0;
17
+ }
18
+ var CabarCompressor = class extends BaseDocumentCompressor {
19
+ opts;
20
+ constructor(opts = {}) {
21
+ super();
22
+ this.opts = opts;
23
+ }
24
+ async compressDocuments(documents, query) {
25
+ if (documents.length === 0) return [];
26
+ const { scoreKeys = DEFAULT_SCORE_KEYS, timestampKey = "timestamp", includeTail = true, ...selectOpts } = this.opts;
27
+ const scored = documents.map((doc) => {
28
+ const key = scoreKeys.find((k) => typeof doc.metadata?.[k] === "number" && Number.isFinite(doc.metadata[k]));
29
+ return key ? doc.metadata[key] : null;
30
+ });
31
+ const useRank = scored.some((s) => s === null);
32
+ const units = normalizeScores(
33
+ documents.map((doc, i) => ({
34
+ id: String(i),
35
+ content: doc.pageContent,
36
+ score: useRank ? documents.length - i : scored[i],
37
+ timestamp: toDate(doc.metadata?.[timestampKey])
38
+ }))
39
+ );
40
+ const result = CabarSelector.select(units, query, selectOpts);
41
+ const out = result.selected.map((u) => documents[Number(u.id)]);
42
+ if (includeTail && result.tailSummary) {
43
+ out.push(new Document({ pageContent: result.tailSummary, metadata: { cabar: "tail", units: result.tail.map((u) => Number(u.id)) } }));
44
+ }
45
+ return out;
46
+ }
47
+ };
48
+ export {
49
+ CabarCompressor
50
+ };
package/package.json CHANGED
@@ -1,6 +1,86 @@
1
1
  {
2
2
  "name": "@whyparabola/cabar",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Context-budget-aware retrieval: pick which retrieved chunks are worth the prompt tokens. Pure TypeScript, zero dependencies, no LLM calls.",
5
+ "license": "MIT",
6
+ "author": "Aman Jha (https://x.com/WhyParabola)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/e-man07/cabar.git"
10
+ },
11
+ "homepage": "https://github.com/e-man07/cabar#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/e-man07/cabar/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.cjs",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "import": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "require": {
26
+ "types": "./dist/index.d.cts",
27
+ "default": "./dist/index.cjs"
28
+ }
29
+ },
30
+ "./langchain": {
31
+ "import": {
32
+ "types": "./dist/langchain.d.ts",
33
+ "default": "./dist/langchain.js"
34
+ },
35
+ "require": {
36
+ "types": "./dist/langchain.d.cts",
37
+ "default": "./dist/langchain.cjs"
38
+ }
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "src",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "sideEffects": false,
48
+ "scripts": {
49
+ "build": "tsup src/index.ts src/langchain.ts --format esm,cjs --dts --clean --external @langchain/core",
50
+ "test": "vitest run",
51
+ "typecheck": "tsc --noEmit",
52
+ "example": "tsx examples/basic.ts",
53
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
54
+ },
55
+ "keywords": [
56
+ "rag",
57
+ "retrieval",
58
+ "context-window",
59
+ "token-budget",
60
+ "llm",
61
+ "agent-memory",
62
+ "knapsack",
63
+ "mmr",
64
+ "compression",
65
+ "prompt"
66
+ ],
67
+ "peerDependencies": {
68
+ "@langchain/core": ">=0.3.0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@langchain/core": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "devDependencies": {
76
+ "@langchain/core": "^1.2.13",
77
+ "@types/node": "^22.10.2",
78
+ "tsup": "8.3.5",
79
+ "tsx": "4.19.2",
80
+ "typescript": "5.7.3",
81
+ "vitest": "2.1.8"
82
+ },
83
+ "engines": {
84
+ "node": ">=18"
85
+ }
86
+ }
package/src/cabar.ts ADDED
@@ -0,0 +1,485 @@
1
+ /**
2
+ * CabarSelector: Context-Budget-Aware Retrieval.
3
+ *
4
+ * Pure, deterministic selection of which retrieved units are worth spending
5
+ * prompt-token budget on, replacing "take the top-K by score" with a greedy
6
+ * utility/cost knapsack:
7
+ *
8
+ * utility(u, S) = relevance(u) × recency(u) × novelty(u, S)
9
+ *
10
+ * where S is the set already selected (novelty is recomputed against the
11
+ * CURRENT S on every iteration, so a second near-duplicate of an already-picked
12
+ * unit contributes ~0 marginal utility and is skipped in favor of budget spent
13
+ * elsewhere). Units below `tauFloor` relevance are dropped entirely; units at
14
+ * or above the floor that don't make the cut are extractively summarized into
15
+ * a compact `tailSummary` instead of being discarded outright.
16
+ *
17
+ * No LLM calls, no I/O, no `Date.now()`: the caller supplies `now` (defaults
18
+ * to `new Date()` at the call site) so `select()` is a pure, replayable
19
+ * function of its inputs.
20
+ */
21
+
22
+ import { CHAR4_COUNTER, DEFAULT_RECENCY_DECAY_DAYS, recencyDecay, stripUndefined, type Similarity, type TokenCounter } from './shared';
23
+ import { truncateToTokens as truncateWithCounter } from './renderings';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface CabarUnit {
30
+ id: string;
31
+ content: string;
32
+ /** Relevance from your retriever, normalized to [0,1] (top item ideally 1.0). */
33
+ score: number;
34
+ timestamp?: Date;
35
+ /** Optional vector for `similarity: embeddingSimilarity`. */
36
+ embedding?: ArrayLike<number>;
37
+ }
38
+
39
+ export interface CabarSelectOptions {
40
+ /** Token budget for the guaranteed block + selected units. The tail summary is capped separately by tailBudgetTokens. */
41
+ budgetTokens?: number;
42
+ /** Hard cap on the number of units selected, independent of budget. */
43
+ maxUnits?: number;
44
+ /** Hard token cap for the extractive tail summary (on top of budgetTokens). */
45
+ tailBudgetTokens?: number;
46
+ /** Minimum relevance score for a unit to survive into the tail at all (below this: dropped). */
47
+ tauFloor?: number;
48
+ /**
49
+ * Blend factor between "ignore recency" (0) and "full recency effect" (1).
50
+ * ρ = recencyLambda × decay + (1 − recencyLambda). At 0, ρ ≡ 1 for every
51
+ * unit regardless of timestamp — required for multi-session safety (a
52
+ * cross-session query shouldn't be penalized just because the recall is old).
53
+ */
54
+ recencyLambda?: number;
55
+ /** Recency scale in days for the exponential decay e^(-age/days). Default 365. */
56
+ recencyDecayDays?: number;
57
+ /**
58
+ * Blend factor between "ignore novelty" (0) and "full novelty effect" (1)
59
+ * applied to the raw novelty(u,S) ∈ [0,1] term:
60
+ * effectiveNovelty = 1 − noveltyWeight × (1 − novelty(u,S))
61
+ * Defaults to 1, i.e. utility = r × ρ × novelty(u,S) exactly as specified.
62
+ */
63
+ noveltyWeight?: number;
64
+ /** Tokens pre-charged against budgetTokens before selection starts (e.g. the resolved-claims block). */
65
+ guaranteedTokens?: number;
66
+ /** Deterministic "current time" for recency scoring. Defaults to `new Date()`. */
67
+ now?: Date;
68
+ /**
69
+ * How hard long units are penalized: greedy density = utility / tokens^costExponent.
70
+ * 1 (default) = pure value per token, which favors short units. 0.5 softens it so a
71
+ * long unit that holds the answer can still win; 0 ignores length (pure utility order).
72
+ */
73
+ costExponent?: number;
74
+ /** Also return `decisions`: why each unit was selected, summarized into the tail, or dropped. */
75
+ explain?: boolean;
76
+ /** Injectable token cost model. Defaults to the char/4 estimator. */
77
+ tokenCounter?: TokenCounter;
78
+ /**
79
+ * Similarity used for novelty, in [0, 1]. Defaults to word-set Jaccard.
80
+ * Pass `embeddingSimilarity` (with `embedding` on each unit) to catch paraphrases.
81
+ */
82
+ similarity?: Similarity<CabarUnit>;
83
+ /**
84
+ * Adaptive stopping mode. When true, the greedy loop's
85
+ * stop condition is replaced: instead of the fixed `budgetTokens`/`maxUnits`
86
+ * caps, it keeps accepting units past `minKeep` only while the next unit's
87
+ * relevance fraction (score / top-candidate score) is >= `relFloor` AND its
88
+ * novelty vs the already-selected set is >= `novFloor`; `maxCeilingTokens`
89
+ * becomes the only hard token bound (`maxUnits` is ignored). This lets the
90
+ * pool's own relevance-score shape decide how much to keep — noisy pools
91
+ * (one strong hit, many weak ones) stop near `minKeep`; concentrated/spread
92
+ * pools with a genuine relevant tail keep more. Default false: behavior is
93
+ * byte-identical to the fixed-budget path.
94
+ */
95
+ adaptive?: boolean;
96
+ /** Adaptive mode: units accepted before the relFloor/novFloor stop rule can trigger. Default 3. */
97
+ minKeep?: number;
98
+ /** Adaptive mode: below this fraction of the top candidate's score, the greedy fill stops. Default 0.4. */
99
+ relFloor?: number;
100
+ /** Adaptive mode: below this novelty vs the selected set, the greedy fill stops. Default 0.2. */
101
+ novFloor?: number;
102
+ /** Adaptive mode: hard token ceiling — replaces budgetTokens as the bound. Default 4000. */
103
+ maxCeilingTokens?: number;
104
+ /**
105
+ * Adaptive mode: also honor `maxUnits` as a hard ceiling ("capped adaptive").
106
+ * The relFloor/novFloor early-stop can still stop BELOW maxUnits on cliff-y
107
+ * (noisy) pools — this only prevents adaptive from keeping MORE than maxUnits on
108
+ * uniformly-relevant (concentrated) pools, where the uncapped rule over-keeps
109
+ * with no accuracy payoff. Unifies cabar1's ceiling with adaptive's early stop:
110
+ * concentrated → cap binds (≡ cabar1); noisy → cliff stops early (≡ adaptive).
111
+ * Default false (preserves the original uncapped adaptive arm byte-identically).
112
+ */
113
+ capToMaxUnits?: boolean;
114
+ }
115
+
116
+ export interface CabarStats {
117
+ candidateCount: number;
118
+ candidateTokens: number;
119
+ selectedTokens: number;
120
+ tailSummaryTokens: number;
121
+ budgetTokens: number;
122
+ /** guaranteedTokens + selectedTokens + tailSummaryTokens — actual spend against budgetTokens. */
123
+ utilizedTokens: number;
124
+ }
125
+
126
+ export interface CabarDecision {
127
+ id: string;
128
+ status: 'selected' | 'tail' | 'dropped';
129
+ /** Plain-English reason, e.g. "redundant with u3 (similarity 0.82)". */
130
+ reason: string;
131
+ tokens: number;
132
+ relevance: number;
133
+ recency: number;
134
+ /** 1 − highest similarity to any (other) selected unit. */
135
+ novelty: number;
136
+ /** For redundant units: the selected unit it overlaps most. */
137
+ redundantWith?: string;
138
+ }
139
+
140
+ export interface CabarSelectResult {
141
+ selected: CabarUnit[];
142
+ /** Unselected units at or above tauFloor — summarized into tailSummary, not dropped. */
143
+ tail: CabarUnit[];
144
+ tailSummary: string;
145
+ stats: CabarStats;
146
+ /** Present when `explain: true`, one entry per input unit in input order. */
147
+ decisions?: CabarDecision[];
148
+ }
149
+
150
+ /** Fixed-mode selector defaults. Exported so `cabar.config.ts` can source the
151
+ * budget/maxUnits ceilings from one place instead of re-declaring the literals. */
152
+ export const DEFAULTS: Required<Omit<CabarSelectOptions, 'now' | 'tokenCounter' | 'similarity' | 'explain' | 'adaptive' | 'minKeep' | 'relFloor' | 'novFloor' | 'maxCeilingTokens' | 'capToMaxUnits'>> = {
153
+ budgetTokens: 4000,
154
+ maxUnits: 8,
155
+ tailBudgetTokens: 600,
156
+ tauFloor: 0.15,
157
+ recencyLambda: 0.3,
158
+ recencyDecayDays: DEFAULT_RECENCY_DECAY_DAYS,
159
+ noveltyWeight: 1,
160
+ guaranteedTokens: 0,
161
+ costExponent: 1,
162
+ };
163
+
164
+ /** Adaptive-mode-only defaults (fixed global constants, never tuned per query type). */
165
+ export const ADAPTIVE_DEFAULTS = {
166
+ minKeep: 3,
167
+ relFloor: 0.4,
168
+ novFloor: 0.2,
169
+ maxCeilingTokens: 4000,
170
+ };
171
+
172
+ const MS_PER_DAY = 86_400_000;
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Tokenization / similarity helpers
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Lowercased word (3+ chars) token set. CabarSelector runs after retrieval, on
180
+ * already-scored units, without embeddings: Jaccard over word tokens is the
181
+ * deliberately cheap novelty stand-in. Cosine over the units' embeddings
182
+ * would be a drop-in upgrade.
183
+ */
184
+ function tokenize(text: string): Set<string> {
185
+ const matches = text.toLowerCase().match(/[a-z0-9]{3,}/g);
186
+ return new Set(matches ?? []);
187
+ }
188
+
189
+ function jaccard(a: Set<string>, b: Set<string>): number {
190
+ if (a.size === 0 || b.size === 0) return 0;
191
+ let intersection = 0;
192
+ for (const t of a) {
193
+ if (b.has(t)) intersection++;
194
+ }
195
+ const union = a.size + b.size - intersection;
196
+ return union === 0 ? 0 : intersection / union;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // CabarSelector
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export class CabarSelector {
204
+ /**
205
+ * Select which units fit the budget, greedily maximizing utility/cost
206
+ * density, with a best-single-vs-greedy swap (budgeted-max-coverage 2-approx
207
+ * guarantee) and an extractive tail summary for the rest.
208
+ */
209
+ static select(units: CabarUnit[], query: string, opts: CabarSelectOptions = {}): CabarSelectResult {
210
+ const cfg = { ...DEFAULTS, ...stripUndefined(opts) };
211
+ const now = opts.now ?? new Date();
212
+ const counter = opts.tokenCounter ?? CHAR4_COUNTER;
213
+ const count = (t: string) => counter.count(t);
214
+
215
+ const adaptive = opts.adaptive === true;
216
+ const minKeep = opts.minKeep ?? ADAPTIVE_DEFAULTS.minKeep;
217
+ const relFloor = opts.relFloor ?? ADAPTIVE_DEFAULTS.relFloor;
218
+ const novFloor = opts.novFloor ?? ADAPTIVE_DEFAULTS.novFloor;
219
+ const maxCeilingTokens = opts.maxCeilingTokens ?? ADAPTIVE_DEFAULTS.maxCeilingTokens;
220
+ const capToMaxUnits = opts.capToMaxUnits === true;
221
+ // The top score is computed as an explicit max (not a units[0] read) so a
222
+ // caller that passes an unsorted or re-ranked pool still gets the correct,
223
+ // fixed-at-the-start normalizer for the adaptive relevance cliff.
224
+ const topScore = units.length > 0 ? Math.max(...units.map((u) => u.score)) : 1;
225
+
226
+ const costOf = new Map<string, number>();
227
+ const tokensOf = new Map<string, Set<string>>();
228
+ let candidateTokens = 0;
229
+ for (const u of units) {
230
+ const cost = count(u.content);
231
+ costOf.set(u.id, cost);
232
+ tokensOf.set(u.id, tokenize(u.content));
233
+ candidateTokens += cost;
234
+ }
235
+
236
+ // Adaptive mode: maxCeilingTokens is the only hard token bound (budgetTokens/
237
+ // maxUnits are ignored); guaranteedTokens is still pre-charged against it.
238
+ const effectiveBudget = adaptive ? maxCeilingTokens : cfg.budgetTokens;
239
+ const remainingBudget = Math.max(0, effectiveBudget - cfg.guaranteedTokens);
240
+
241
+ const recencyOf = (u: CabarUnit): number => {
242
+ if (!u.timestamp || cfg.recencyLambda === 0) return 1;
243
+ const ageInDays = Math.max(0, (now.getTime() - u.timestamp.getTime()) / MS_PER_DAY);
244
+ const decay = recencyDecay(ageInDays, cfg.recencyDecayDays);
245
+ return cfg.recencyLambda * decay + (1 - cfg.recencyLambda);
246
+ };
247
+
248
+ const noveltyOf = (u: CabarUnit, selectedSoFar: CabarUnit[]): number => {
249
+ if (selectedSoFar.length === 0) return 1;
250
+ const a = tokensOf.get(u.id)!;
251
+ let maxSim = 0;
252
+ for (const s of selectedSoFar) {
253
+ const sim = opts.similarity ? opts.similarity(u, s) : jaccard(a, tokensOf.get(s.id)!);
254
+ if (sim > maxSim) maxSim = sim;
255
+ }
256
+ return 1 - maxSim;
257
+ };
258
+
259
+ const utilityOf = (u: CabarUnit, selectedSoFar: CabarUnit[]): number => {
260
+ const r = u.score;
261
+ const rho = recencyOf(u);
262
+ const noveltyRaw = noveltyOf(u, selectedSoFar);
263
+ const effectiveNovelty = 1 - cfg.noveltyWeight * (1 - noveltyRaw);
264
+ return r * rho * effectiveNovelty;
265
+ };
266
+
267
+ // --- Greedy loop: pick feasible argmax utility/cost density each round ---
268
+ const selected: CabarUnit[] = [];
269
+ const selectedIds = new Set<string>();
270
+ let selectedTokens = 0;
271
+ let greedyTotalUtility = 0;
272
+
273
+ // Capped adaptive: when capToMaxUnits is set, adaptive also respects maxUnits as
274
+ // a hard ceiling (the relFloor/novFloor rule below can still stop earlier).
275
+ while ((adaptive && !capToMaxUnits) || selected.length < cfg.maxUnits) {
276
+ let best: { unit: CabarUnit; utility: number; density: number } | null = null;
277
+
278
+ for (const u of units) {
279
+ if (selectedIds.has(u.id)) continue;
280
+ const cost = costOf.get(u.id)!;
281
+ if (selectedTokens + cost > remainingBudget) continue;
282
+
283
+ const utility = utilityOf(u, selected);
284
+ if (utility <= 0) continue; // no marginal benefit — don't spend budget on it
285
+
286
+ const density = cost > 0 ? utility / Math.pow(cost, cfg.costExponent) : Infinity;
287
+ if (!best || density > best.density) {
288
+ best = { unit: u, utility, density };
289
+ }
290
+ }
291
+
292
+ if (!best) break;
293
+
294
+ // Adaptive stopping rule: past the minKeep floor, stop
295
+ // the greedy fill once the next candidate's relevance fraction or novelty
296
+ // vs the selected set drops below their floors — replaces the fixed
297
+ // budgetTokens/maxUnits caps as the pool's own relevance shape decides
298
+ // how much to keep.
299
+ if (adaptive && selected.length >= minKeep) {
300
+ const relFrac = best.unit.score / (topScore || 1);
301
+ const nov = noveltyOf(best.unit, selected);
302
+ if (relFrac < relFloor || nov < novFloor) break;
303
+ }
304
+
305
+ selected.push(best.unit);
306
+ selectedIds.add(best.unit.id);
307
+ selectedTokens += costOf.get(best.unit.id)!;
308
+ greedyTotalUtility += best.utility;
309
+ }
310
+
311
+ // --- Best-single-vs-greedy swap (budgeted-max-coverage guarantee) ---
312
+ let swapped = false;
313
+ // maxUnits is ignored in adaptive mode, so the guard is unconditional then.
314
+ if (adaptive || cfg.maxUnits >= 1) {
315
+ let bestSingle: { unit: CabarUnit; utility: number } | null = null;
316
+ for (const u of units) {
317
+ const cost = costOf.get(u.id)!;
318
+ if (cost > remainingBudget) continue;
319
+ const utility = utilityOf(u, []); // evaluated alone, novelty(u, ∅) = 1
320
+ if (!bestSingle || utility > bestSingle.utility) {
321
+ bestSingle = { unit: u, utility };
322
+ }
323
+ }
324
+ if (bestSingle && bestSingle.utility > greedyTotalUtility) {
325
+ swapped = true;
326
+ selected.length = 0;
327
+ selected.push(bestSingle.unit);
328
+ selectedIds.clear();
329
+ selectedIds.add(bestSingle.unit.id);
330
+ selectedTokens = costOf.get(bestSingle.unit.id)!;
331
+ }
332
+ }
333
+
334
+ // --- Tail: unselected units at/above tauFloor, extractively summarized ---
335
+ const tail = units.filter((u) => !selectedIds.has(u.id) && u.score >= cfg.tauFloor);
336
+ // Tail-cost guard: on small pools the per-unit-prefixed extractive summary
337
+ // can cost MORE than the content it replaces. The summary must never
338
+ // exceed the verbatim tail content: build both candidates, emit the
339
+ // cheaper, and hard-cap at tailBudgetTokens either way.
340
+ const extractive = buildTailSummary(tail, query, cfg.tailBudgetTokens, count);
341
+ const verbatim = tail.map((u) => u.content.trim()).join('\n');
342
+ let tailSummary =
343
+ tail.length === 0 ? '' : count(verbatim) < count(extractive) ? verbatim : extractive;
344
+ if (count(tailSummary) > cfg.tailBudgetTokens) {
345
+ // char/4 keeps its original character cut; a real tokenizer gets a word-by-word cut.
346
+ tailSummary =
347
+ counter === CHAR4_COUNTER
348
+ ? truncateToTokens(tailSummary, cfg.tailBudgetTokens)
349
+ : truncateWithCounter(tailSummary, cfg.tailBudgetTokens, counter);
350
+ }
351
+ const tailSummaryTokens = count(tailSummary);
352
+
353
+ const result: CabarSelectResult = {
354
+ selected,
355
+ tail,
356
+ tailSummary,
357
+ stats: {
358
+ candidateCount: units.length,
359
+ candidateTokens,
360
+ selectedTokens,
361
+ tailSummaryTokens,
362
+ budgetTokens: effectiveBudget,
363
+ utilizedTokens: cfg.guaranteedTokens + selectedTokens + tailSummaryTokens,
364
+ },
365
+ };
366
+ if (!opts.explain) return result;
367
+
368
+ // --- Explanations (computed after the fact; selection above is unaffected) ---
369
+ const sim = (a: CabarUnit, b: CabarUnit) => (opts.similarity ? opts.similarity(a, b) : jaccard(tokensOf.get(a.id)!, tokensOf.get(b.id)!));
370
+ const unitCap = !adaptive || capToMaxUnits;
371
+ result.decisions = units.map((u) => {
372
+ const tokens = costOf.get(u.id)!;
373
+ const others = selected.filter((s) => s.id !== u.id);
374
+ let maxSim = 0;
375
+ let closest: string | undefined;
376
+ for (const s of others) {
377
+ const v = sim(u, s);
378
+ if (v > maxSim) [maxSim, closest] = [v, s.id];
379
+ }
380
+ const base = { id: u.id, tokens, relevance: u.score, recency: recencyOf(u), novelty: 1 - maxSim };
381
+ if (selectedIds.has(u.id)) {
382
+ return { ...base, status: 'selected' as const, reason: swapped ? 'best single unit: worth more alone than the greedy set' : 'selected' };
383
+ }
384
+ if (u.score < cfg.tauFloor) {
385
+ return { ...base, status: 'dropped' as const, reason: `relevance ${u.score.toFixed(2)} is below tauFloor ${cfg.tauFloor}` };
386
+ }
387
+ let reason: string;
388
+ if (swapped) reason = 'a single unit beat the greedy set';
389
+ else if (maxSim >= 1 - (adaptive ? novFloor : 0.2) && closest)
390
+ reason = `redundant with ${closest} (similarity ${maxSim.toFixed(2)})`;
391
+ else if (selectedTokens + tokens > remainingBudget) reason = `doesn't fit: needs ${tokens} tokens, ${Math.max(0, remainingBudget - selectedTokens)} left`;
392
+ else if (unitCap && selected.length >= cfg.maxUnits) reason = `maxUnits (${cfg.maxUnits}) reached`;
393
+ else if (adaptive && u.score / (topScore || 1) < relFloor) reason = `below the adaptive relevance cliff (${(u.score / (topScore || 1)).toFixed(2)} of the top score)`;
394
+ else reason = 'adaptive stop: lower value per token than what was kept';
395
+ return { ...base, status: 'tail' as const, reason, ...(reason.startsWith('redundant') ? { redundantWith: closest } : {}) };
396
+ });
397
+ return result;
398
+ }
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Extractive tail summary (deterministic, no LLM)
403
+ // ---------------------------------------------------------------------------
404
+
405
+ interface ScoredSentence {
406
+ text: string;
407
+ score: number;
408
+ unitId: string;
409
+ timestamp?: Date;
410
+ order: number;
411
+ }
412
+
413
+ /** Hard-cap text at a token budget using the shared char/4 heuristic (word-boundary cut). */
414
+ function truncateToTokens(text: string, budgetTokens: number): string {
415
+ const maxChars = budgetTokens * 4;
416
+ if (text.length <= maxChars) return text;
417
+ const cut = text.slice(0, maxChars);
418
+ const lastSpace = cut.lastIndexOf(' ');
419
+ return (lastSpace > maxChars * 0.8 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…';
420
+ }
421
+
422
+ /**
423
+ * Splits each tail unit into sentences, scores each by (query-term overlap +
424
+ * parent unit's relevance score), and greedily fills `tailBudgetTokens` in
425
+ * descending score order (stable tie-break on original order, so this is
426
+ * deterministic across runs for the same inputs). Exact-normalized-text dedup
427
+ * drops near-identical sentences. No LLM call.
428
+ */
429
+ function buildTailSummary(
430
+ tail: CabarUnit[],
431
+ query: string,
432
+ tailBudgetTokens: number,
433
+ count: (text: string) => number,
434
+ ): string {
435
+ if (tail.length === 0 || tailBudgetTokens <= 0) return '';
436
+
437
+ const queryTokens = tokenize(query);
438
+ const sentences: ScoredSentence[] = [];
439
+ let order = 0;
440
+
441
+ for (const unit of tail) {
442
+ const parts = unit.content
443
+ .split(/(?<=[.!?])\s+/)
444
+ .map((s) => s.trim())
445
+ .filter(Boolean);
446
+
447
+ for (const text of parts) {
448
+ const tokens = tokenize(text);
449
+ let overlap = 0;
450
+ for (const t of tokens) {
451
+ if (queryTokens.has(t)) overlap++;
452
+ }
453
+ sentences.push({
454
+ text,
455
+ score: overlap + unit.score,
456
+ unitId: unit.id,
457
+ timestamp: unit.timestamp,
458
+ order: order++,
459
+ });
460
+ }
461
+ }
462
+
463
+ sentences.sort((a, b) => b.score - a.score || a.order - b.order);
464
+
465
+ const seen = new Set<string>();
466
+ const lines: string[] = [];
467
+ let usedTokens = 0;
468
+
469
+ for (const s of sentences) {
470
+ const normalized = s.text.toLowerCase().replace(/\s+/g, ' ').trim();
471
+ if (seen.has(normalized)) continue;
472
+
473
+ const idPart = s.timestamp ? `${s.unitId}@${s.timestamp.toISOString().slice(0, 10)}` : s.unitId;
474
+ const line = `[${idPart}] ${s.text}`;
475
+ const lineTokens = count(lines.length === 0 ? line : `\n${line}`);
476
+
477
+ if (usedTokens + lineTokens > tailBudgetTokens) break; // greedy fill stops at the budget
478
+
479
+ seen.add(normalized);
480
+ lines.push(line);
481
+ usedTokens += lineTokens;
482
+ }
483
+
484
+ return lines.join('\n');
485
+ }