@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.
package/src/context.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Helpers around a selection result: turn it into prompt text, and compare it
3
+ * with what you send today (shadow mode).
4
+ */
5
+ import { CabarSelector, type CabarSelectOptions, type CabarSelectResult, type CabarUnit } from './cabar';
6
+ import type { PrismSelectResult } from './prism';
7
+ import { CHAR4_COUNTER } from './shared';
8
+
9
+ export interface ToContextOptions {
10
+ /** Separator between selected units. Default: a blank line. */
11
+ separator?: string;
12
+ /** Heading put above the tail. Default: "Other possibly relevant notes:". Pass '' for none. */
13
+ tailHeading?: string;
14
+ /** Wraps each selected unit, e.g. to add ids or XML tags. Default: the text as is. */
15
+ format?: (text: string, id: string) => string;
16
+ }
17
+
18
+ function isPrism(r: CabarSelectResult | PrismSelectResult): r is PrismSelectResult {
19
+ return 'tailBlock' in r;
20
+ }
21
+
22
+ /** Joins a CABAR or Prism result into one string to put in the prompt. */
23
+ export function toContext(result: CabarSelectResult | PrismSelectResult, opts: ToContextOptions = {}): string {
24
+ const separator = opts.separator ?? '\n\n';
25
+ const heading = opts.tailHeading ?? 'Other possibly relevant notes:';
26
+ const format = opts.format ?? ((text: string) => text);
27
+ const parts = isPrism(result)
28
+ ? result.selected.map((s) => format(s.rendered, s.unit.id))
29
+ : result.selected.map((u) => format(u.content, u.id));
30
+ const tail = isPrism(result) ? result.tailBlock : result.tailSummary;
31
+ if (tail) parts.push(heading ? `${heading}\n${tail}` : tail);
32
+ return parts.join(separator);
33
+ }
34
+
35
+ export interface ShadowReport {
36
+ /** Tokens of every unit you passed in (what "send everything" costs). */
37
+ sendAllTokens: number;
38
+ /** Tokens CABAR would send: selected + tail. */
39
+ cabarTokens: number;
40
+ /** 1 − cabarTokens / sendAllTokens. */
41
+ savedFraction: number;
42
+ selectedIds: string[];
43
+ tailIds: string[];
44
+ droppedIds: string[];
45
+ result: CabarSelectResult;
46
+ }
47
+
48
+ /**
49
+ * Shadow mode: run CABAR next to your current pipeline without changing what
50
+ * you send. Log the report, compare answers, then switch when you trust it.
51
+ */
52
+ export function shadowReport(units: CabarUnit[], query: string, opts: CabarSelectOptions = {}): ShadowReport {
53
+ const counter = opts.tokenCounter ?? CHAR4_COUNTER;
54
+ const result = CabarSelector.select(units, query, opts);
55
+ const sendAllTokens = units.reduce((n, u) => n + counter.count(u.content), 0);
56
+ const cabarTokens = result.stats.selectedTokens + result.stats.tailSummaryTokens;
57
+ const kept = new Set([...result.selected, ...result.tail].map((u) => u.id));
58
+ return {
59
+ sendAllTokens,
60
+ cabarTokens,
61
+ savedFraction: sendAllTokens === 0 ? 0 : 1 - cabarTokens / sendAllTokens,
62
+ selectedIds: result.selected.map((u) => u.id),
63
+ tailIds: result.tail.map((u) => u.id),
64
+ droppedIds: units.filter((u) => !kept.has(u.id)).map((u) => u.id),
65
+ result,
66
+ };
67
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { CabarSelector, DEFAULTS, ADAPTIVE_DEFAULTS } from './cabar';
2
+ export type { CabarUnit, CabarSelectOptions, CabarSelectResult, CabarStats, CabarDecision } from './cabar';
3
+ export { PrismSelector } from './prism';
4
+ export type {
5
+ PrismUnit,
6
+ PrismSelectOptions,
7
+ PrismSelectResult,
8
+ PrismSelectedUnit,
9
+ PrismStats,
10
+ Fidelity,
11
+ FidelityFactors,
12
+ TokenCounter,
13
+ } from './prism';
14
+ export { verbatim, extract, claimsRendering, mention, tokenize, jaccard, splitSentences, truncateToTokens } from './renderings';
15
+ export { estimateTokens, recencyDecay, DEFAULT_RECENCY_DECAY_DAYS, CHAR4_COUNTER, cosineSimilarity, embeddingSimilarity } from './shared';
16
+ export type { Similarity } from './shared';
17
+ export { normalizeScores } from './normalize';
18
+ export type { NormalizeOptions } from './normalize';
19
+ export { toContext, shadowReport } from './context';
20
+ export type { ToContextOptions, ShadowReport } from './context';
@@ -0,0 +1,75 @@
1
+ /**
2
+ * LangChain.js adapter: CABAR as a document compressor.
3
+ *
4
+ * import { ContextualCompressionRetriever } from '@langchain/classic/retrievers/contextual_compression';
5
+ * import { CabarCompressor } from '@whyparabola/cabar/langchain';
6
+ *
7
+ * const retriever = new ContextualCompressionRetriever({
8
+ * baseCompressor: new CabarCompressor({ budgetTokens: 2000 }),
9
+ * baseRetriever: vectorStore.asRetriever(24),
10
+ * });
11
+ */
12
+ import { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';
13
+ import { Document, type DocumentInterface } from '@langchain/core/documents';
14
+ import { CabarSelector, type CabarSelectOptions, type CabarUnit } from './cabar';
15
+ import { normalizeScores } from './normalize';
16
+
17
+ export 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
+
30
+ const DEFAULT_SCORE_KEYS = ['relevance_score', 'relevanceScore', 'score'];
31
+
32
+ function toDate(v: unknown): Date | undefined {
33
+ if (v instanceof Date) return v;
34
+ if (typeof v === 'string' || typeof v === 'number') {
35
+ const d = new Date(v);
36
+ return Number.isNaN(d.getTime()) ? undefined : d;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ export class CabarCompressor extends BaseDocumentCompressor {
42
+ private readonly opts: CabarCompressorOptions;
43
+
44
+ constructor(opts: CabarCompressorOptions = {}) {
45
+ super();
46
+ this.opts = opts;
47
+ }
48
+
49
+ async compressDocuments(documents: DocumentInterface[], query: string): Promise<DocumentInterface[]> {
50
+ if (documents.length === 0) return [];
51
+ const { scoreKeys = DEFAULT_SCORE_KEYS, timestampKey = 'timestamp', includeTail = true, ...selectOpts } = this.opts;
52
+
53
+ const scored = documents.map((doc) => {
54
+ const key = scoreKeys.find((k) => typeof doc.metadata?.[k] === 'number' && Number.isFinite(doc.metadata[k]));
55
+ return key ? (doc.metadata[key] as number) : null;
56
+ });
57
+ // Mixed or missing scores: fall back to the retriever's order (first = most relevant).
58
+ const useRank = scored.some((s) => s === null);
59
+ const units: CabarUnit[] = normalizeScores(
60
+ documents.map((doc, i) => ({
61
+ id: String(i),
62
+ content: doc.pageContent,
63
+ score: useRank ? documents.length - i : (scored[i] as number),
64
+ timestamp: toDate(doc.metadata?.[timestampKey]),
65
+ })),
66
+ );
67
+
68
+ const result = CabarSelector.select(units, query, selectOpts);
69
+ const out: DocumentInterface[] = result.selected.map((u) => documents[Number(u.id)]!);
70
+ if (includeTail && result.tailSummary) {
71
+ out.push(new Document({ pageContent: result.tailSummary, metadata: { cabar: 'tail', units: result.tail.map((u) => Number(u.id)) } }));
72
+ }
73
+ return out;
74
+ }
75
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Score normalization. The selectors expect relevance in [0, 1] with the top
3
+ * candidate at (or near) 1.0: `tauFloor` and the adaptive relevance cliff are
4
+ * fractions of that scale. Raw retriever scores rarely are: cosine
5
+ * similarities bunch up in 0.7–0.85, BM25 and reranker logits are unbounded,
6
+ * and vector-store distances run the wrong way.
7
+ */
8
+
9
+ export interface NormalizeOptions {
10
+ /**
11
+ * - `minmax` (default): best → 1, worst → 0. Spreads bunched-up scores
12
+ * (cosine similarities) across the full range.
13
+ * - `max`: divide by the best score. Keeps ratios between scores; only for
14
+ * non-negative scores.
15
+ */
16
+ method?: 'minmax' | 'max';
17
+ /** Set to false for distances (lower = more relevant). Default true. */
18
+ higherIsBetter?: boolean;
19
+ }
20
+
21
+ /** Returns copies of the units with `score` mapped to [0, 1]. Input order is kept. */
22
+ export function normalizeScores<T extends { score: number }>(units: T[], opts: NormalizeOptions = {}): T[] {
23
+ if (units.length === 0) return [];
24
+ const method = opts.method ?? 'minmax';
25
+ const raw = units.map((u) => (opts.higherIsBetter === false ? -u.score : u.score));
26
+ for (const s of raw) {
27
+ if (!Number.isFinite(s)) throw new RangeError('normalizeScores: every score must be a finite number');
28
+ }
29
+ const hi = Math.max(...raw);
30
+ const lo = Math.min(...raw);
31
+
32
+ let map: (s: number) => number;
33
+ if (method === 'max') {
34
+ if (lo < 0) throw new RangeError("normalizeScores: method 'max' needs non-negative scores, use 'minmax'");
35
+ map = (s) => (hi === 0 ? 1 : s / hi);
36
+ } else {
37
+ map = (s) => (hi === lo ? 1 : (s - lo) / (hi - lo));
38
+ }
39
+ return units.map((u, i) => ({ ...u, score: map(raw[i]!) }));
40
+ }
package/src/prism.ts ADDED
@@ -0,0 +1,482 @@
1
+ /**
2
+ * PrismSelector: CABAR-2 multi-fidelity budgeted selection.
3
+ *
4
+ * Where CabarSelector answers "which units do we spend verbatim budget
5
+ * on, and summarize the rest", Prism answers the finer question "at WHICH
6
+ * FIDELITY do we render each unit". It solves a Multiple-Choice Knapsack
7
+ * Problem (MCKP): each unit offers a small menu of renderings — verbatim /
8
+ * extract / claims / mention — with distinct (cost, utility) points, and the
9
+ * selector greedily packs marginal-utility-per-token, allowed to UPGRADE an
10
+ * already-selected unit to a costlier fidelity when that upgrade is the densest
11
+ * feasible move. All-or-nothing (CABAR-1) is the special case of a two-point
12
+ * menu {verbatim, absent}; Prism dominates it under tight budgets by trading a
13
+ * verbatim unit for several cheaper-fidelity units that together carry more.
14
+ *
15
+ * Design invariants (same purity contract as CabarSelector):
16
+ * - PURE + deterministic: no LLM, no I/O, no Date.now — the caller supplies
17
+ * `now`; `claims` are pre-fetched onto the units by the caller.
18
+ * - Provider-agnostic token cost: the injectable `TokenCounter` defaults to
19
+ * the shared char/4 estimator (prod), and benchmarks inject a real BPE
20
+ * counter. Selection logic never assumes a specific vocabulary.
21
+ * - Cache-stable OUTPUT ORDERING: selected units are emitted by
22
+ * (timestamp asc, id asc), NOT by score, so the same units land in the same
23
+ * prompt slots turn-over-turn (KV-cache friendly).
24
+ * - β-hysteresis: an optional per-unit bonus for re-selecting a unit at
25
+ * ≥ its previous fidelity (keeps prompts stable turn-over-turn). Defaults
26
+ * to 0, a no-op.
27
+ * - Answer-lossless FLOOR: every unit at or above `tauFloor` that misses the
28
+ * budget still gets a mention line in the tail, so nothing recallable is
29
+ * silently dropped.
30
+ */
31
+
32
+ import { CHAR4_COUNTER, DEFAULT_RECENCY_DECAY_DAYS, recencyDecay, stripUndefined, type Similarity, type TokenCounter } from './shared';
33
+ import { extract, mention, claimsRendering, verbatim, tokenize, jaccard, truncateToTokens } from './renderings';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type { TokenCounter } from './shared';
40
+
41
+ /** The four rendering fidelities, most→least faithful. */
42
+ export type Fidelity = 'verbatim' | 'extract' | 'claims' | 'mention';
43
+
44
+ /** Fidelity rank (higher = more faithful) — used by the β-hysteresis predicate. */
45
+ const FIDELITY_RANK: Record<Fidelity, number> = { mention: 0, claims: 1, extract: 2, verbatim: 3 };
46
+
47
+ /** A retrieved unit plus its caller-pre-fetched claims (the CabarUnit + claims). */
48
+ export interface PrismUnit {
49
+ id: string;
50
+ content: string;
51
+ /** Relevance from your retriever, normalized to [0,1] (top item ideally 1.0). */
52
+ score: number;
53
+ timestamp?: Date;
54
+ /** Pre-resolved claim strings; enables the CLAIMS fidelity. Selector never fetches these. */
55
+ claims?: string[];
56
+ /** Optional vector for `similarity: embeddingSimilarity`. */
57
+ embedding?: ArrayLike<number>;
58
+ }
59
+
60
+ /** Per-fidelity multipliers φ(f) — how much utility a fidelity retains vs verbatim. */
61
+ export type FidelityFactors = Record<Fidelity, number>;
62
+
63
+ export interface PrismSelectOptions {
64
+ /** Token budget for the guaranteed block + selected renderings. The tail is capped separately by tailBudgetTokens. */
65
+ budgetTokens?: number;
66
+ /** Hard cap on DISTINCT units selected (upgrades do not count against it). */
67
+ maxUnits?: number;
68
+ /** Hard token cap for the mention-line tail (separate from budgetTokens). */
69
+ tailBudgetTokens?: number;
70
+ /** Minimum score for a unit to earn a tail mention line (below this: dropped). */
71
+ tauFloor?: number;
72
+ /** ρ = recencyLambda·decay + (1−recencyLambda). 0 ⇒ ρ≡1 (multi-session safe). */
73
+ recencyLambda?: number;
74
+ /** Recency scale in days for the exponential decay e^(-age/days). Default 365. */
75
+ recencyDecayDays?: number;
76
+ /** Blend on the novelty term: effectiveNovelty = 1 − noveltyWeight·(1 − novelty). */
77
+ noveltyWeight?: number;
78
+ /** Tokens pre-charged against budgetTokens before selection (e.g. resolved-claims block). */
79
+ guaranteedTokens?: number;
80
+ /** Deterministic "current time" for recency. Defaults to `new Date()`. */
81
+ now?: Date;
82
+ /** Injectable token cost model. Defaults to the shared char/4 estimator. */
83
+ tokenCounter?: TokenCounter;
84
+ /**
85
+ * Unit-level similarity used for novelty, in [0, 1]. Defaults to word-set
86
+ * Jaccard between the renderings. Pass `embeddingSimilarity` to catch paraphrases.
87
+ */
88
+ similarity?: Similarity<PrismUnit>;
89
+ /** Per-fidelity utility multipliers φ. Merged over defaults. */
90
+ fidelityFactors?: Partial<FidelityFactors>;
91
+ /** β — multiplicative bonus ×(1+β) for re-selecting a unit at ≥ prev fidelity. */
92
+ hysteresisBonus?: number;
93
+ /** Prior turn's selection (unit → fidelity) for the β-hysteresis predicate. */
94
+ prevSelected?: Array<{ unitId: string; fidelity: Fidelity }>;
95
+ /**
96
+ * Adaptive stopping mode. When true, ADD-move admission
97
+ * (a unit newly entering the selection) is gated: past `minKeep` distinct
98
+ * units, a unit may only be ADD-admitted if its relevance fraction (score /
99
+ * top-candidate score) is >= `relFloor` AND the candidate rendering's
100
+ * novelty vs the already-chosen renderings is >= `novFloor`. UPGRADE moves
101
+ * and the mention-tail are unaffected. `maxCeilingTokens` replaces
102
+ * `budgetTokens` as the only hard token bound (`maxUnits` is ignored).
103
+ * Default false: behavior is byte-identical to the fixed-budget path.
104
+ */
105
+ adaptive?: boolean;
106
+ /** Adaptive mode: distinct units ADD-admitted before the relFloor/novFloor gate applies. Default 3. */
107
+ minKeep?: number;
108
+ /** Adaptive mode: below this fraction of the top candidate's score, ADD admission stops. Default 0.4. */
109
+ relFloor?: number;
110
+ /** Adaptive mode: below this novelty vs chosen renderings, ADD admission stops. Default 0.2. */
111
+ novFloor?: number;
112
+ /** Adaptive mode: hard token ceiling — replaces budgetTokens as the bound. Default 4000. */
113
+ maxCeilingTokens?: number;
114
+ }
115
+
116
+ export interface PrismSelectedUnit {
117
+ unit: PrismUnit;
118
+ fidelity: Fidelity;
119
+ rendered: string;
120
+ tokens: number;
121
+ }
122
+
123
+ export interface PrismStats {
124
+ /** Sum of every candidate unit's VERBATIM cost (the pre-selection ceiling). */
125
+ candidateTokens: number;
126
+ selectedTokens: number;
127
+ tailTokens: number;
128
+ budgetTokens: number;
129
+ /** guaranteedTokens + selectedTokens + tailTokens — actual spend vs budgetTokens. */
130
+ utilizedTokens: number;
131
+ fidelityHistogram: Record<Fidelity, number>;
132
+ /** Number of greedy moves applied (adds + upgrades). */
133
+ moves: number;
134
+ }
135
+
136
+ export interface PrismSelectResult {
137
+ selected: PrismSelectedUnit[];
138
+ /** Unselected units at or above tauFloor — floored into the mention tail. */
139
+ tail: PrismUnit[];
140
+ /** The rendered tail block (mention lines, or verbatim on the tiny-pool guard). */
141
+ tailBlock: string;
142
+ stats: PrismStats;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Defaults
147
+ // ---------------------------------------------------------------------------
148
+
149
+ const DEFAULTS: Required<
150
+ Omit<
151
+ PrismSelectOptions,
152
+ | 'now'
153
+ | 'tokenCounter'
154
+ | 'similarity'
155
+ | 'fidelityFactors'
156
+ | 'prevSelected'
157
+ | 'adaptive'
158
+ | 'minKeep'
159
+ | 'relFloor'
160
+ | 'novFloor'
161
+ | 'maxCeilingTokens'
162
+ >
163
+ > = {
164
+ budgetTokens: 4000,
165
+ maxUnits: 12,
166
+ tailBudgetTokens: 600,
167
+ tauFloor: 0.15,
168
+ recencyLambda: 0.3,
169
+ recencyDecayDays: DEFAULT_RECENCY_DECAY_DAYS,
170
+ noveltyWeight: 1,
171
+ guaranteedTokens: 0,
172
+ hysteresisBonus: 0,
173
+ };
174
+
175
+ /** Adaptive-mode-only defaults (fixed global constants, never tuned per query type). */
176
+ const ADAPTIVE_DEFAULTS = {
177
+ minKeep: 3,
178
+ relFloor: 0.4,
179
+ novFloor: 0.2,
180
+ maxCeilingTokens: 4000,
181
+ };
182
+
183
+ /** Default φ: verbatim full-value, extract/claims discounted, mention a thin pointer. */
184
+ const DEFAULT_FIDELITY_FACTORS: FidelityFactors = {
185
+ verbatim: 1.0,
186
+ extract: 0.85,
187
+ claims: 0.75,
188
+ mention: 0.3,
189
+ };
190
+
191
+
192
+ const MS_PER_DAY = 86_400_000;
193
+
194
+ /** All fidelities in fixed order (drives stable move iteration + histogram init). */
195
+ const ALL_FIDELITIES: Fidelity[] = ['verbatim', 'extract', 'claims', 'mention'];
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Internal per-unit rendering menu
199
+ // ---------------------------------------------------------------------------
200
+
201
+ interface Rendering {
202
+ fidelity: Fidelity;
203
+ rendered: string;
204
+ cost: number;
205
+ tokens: Set<string>;
206
+ phi: number;
207
+ rank: number;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // PrismSelector
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export class PrismSelector {
215
+ /**
216
+ * MCKP-greedy multi-fidelity selection: build each unit's rendering menu,
217
+ * greedily apply the densest feasible ADD/UPGRADE move, run the KMN
218
+ * best-single swap, then floor the remainder into a mention tail.
219
+ */
220
+ static select(units: PrismUnit[], query: string, opts: PrismSelectOptions = {}): PrismSelectResult {
221
+ const cfg = { ...DEFAULTS, ...stripUndefined(opts) };
222
+ const now = opts.now ?? new Date();
223
+ const counter = opts.tokenCounter ?? CHAR4_COUNTER;
224
+ const phi: FidelityFactors = { ...DEFAULT_FIDELITY_FACTORS, ...opts.fidelityFactors };
225
+
226
+ const adaptive = opts.adaptive === true;
227
+ const minKeep = opts.minKeep ?? ADAPTIVE_DEFAULTS.minKeep;
228
+ const relFloor = opts.relFloor ?? ADAPTIVE_DEFAULTS.relFloor;
229
+ const novFloor = opts.novFloor ?? ADAPTIVE_DEFAULTS.novFloor;
230
+ const maxCeilingTokens = opts.maxCeilingTokens ?? ADAPTIVE_DEFAULTS.maxCeilingTokens;
231
+ // The top score is computed as an explicit max (not a units[0] read) so a
232
+ // caller that passes an unsorted or re-ranked pool still gets the correct,
233
+ // fixed-at-the-start normalizer for the adaptive relevance cliff.
234
+ const topScore = units.length > 0 ? Math.max(...units.map((u) => u.score)) : 1;
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 budget = Math.max(0, effectiveBudget - cfg.guaranteedTokens);
240
+
241
+ const unitById = new Map<string, PrismUnit>();
242
+ const inputIndex = new Map<string, number>();
243
+ units.forEach((u, i) => {
244
+ unitById.set(u.id, u);
245
+ inputIndex.set(u.id, i);
246
+ });
247
+
248
+ // --- Build each unit's rendering menu (with the cost-dominance guard) ---
249
+ // Verbatim is always offered; any other rendering costing ≥ verbatim is
250
+ // dropped (e.g. claims lines longer than the memory itself, or an extract
251
+ // that collapsed to the whole single-sentence content).
252
+ const menuOf = new Map<string, Rendering[]>();
253
+ let candidateTokens = 0;
254
+ for (const u of units) {
255
+ const vCost = counter.count(u.content);
256
+ candidateTokens += vCost;
257
+
258
+ const menu: Rendering[] = [];
259
+ for (const f of ALL_FIDELITIES) {
260
+ const rendered = renderFidelity(u, f, query, counter);
261
+ if (rendered === null) continue; // UNAVAILABLE (e.g. no claims)
262
+ const cost = counter.count(rendered);
263
+ if (f !== 'verbatim' && cost >= vCost) continue; // cost-dominance guard
264
+ menu.push({ fidelity: f, rendered, cost, tokens: tokenize(rendered), phi: phi[f], rank: FIDELITY_RANK[f] });
265
+ }
266
+ menuOf.set(u.id, menu);
267
+ }
268
+
269
+ // --- Recency ρ (same curve as CabarSelector) ---
270
+ const recencyOf = (u: PrismUnit): number => {
271
+ if (!u.timestamp || cfg.recencyLambda === 0) return 1;
272
+ const ageInDays = Math.max(0, (now.getTime() - u.timestamp.getTime()) / MS_PER_DAY);
273
+ const decay = recencyDecay(ageInDays, cfg.recencyDecayDays);
274
+ return cfg.recencyLambda * decay + (1 - cfg.recencyLambda);
275
+ };
276
+
277
+ // --- Selection state ---
278
+ const chosen = new Map<string, Rendering>();
279
+ let selectedTokens = 0;
280
+ let totalUtility = 0;
281
+ let moves = 0;
282
+
283
+ // Prior-turn fidelity ranks for the β-hysteresis predicate.
284
+ const prevRank = new Map<string, number>();
285
+ for (const p of opts.prevSelected ?? []) {
286
+ const r = FIDELITY_RANK[p.fidelity];
287
+ const existing = prevRank.get(p.unitId);
288
+ if (existing === undefined || r < existing) prevRank.set(p.unitId, r); // lowest prev rank = easiest to satisfy ≥
289
+ }
290
+
291
+ // Novelty of a token-set vs the CHOSEN renderings of already-selected units
292
+ // (excluding `excludeId` so an upgrade isn't penalized against itself).
293
+ const noveltyOf = (u: PrismUnit, tokens: Set<string>, excludeId: string | null): number => {
294
+ let maxSim = 0;
295
+ for (const [id, r] of chosen) {
296
+ if (id === excludeId) continue;
297
+ const sim = opts.similarity ? opts.similarity(u, unitById.get(id)!) : jaccard(tokens, r.tokens);
298
+ if (sim > maxSim) maxSim = sim;
299
+ }
300
+ return 1 - maxSim;
301
+ };
302
+
303
+ // Utility g(u, rendering) with novelty vs chosen (minus excludeId) and β.
304
+ const utilityOf = (u: PrismUnit, r: Rendering, excludeId: string | null): number => {
305
+ const noveltyRaw = noveltyOf(u, r.tokens, excludeId);
306
+ const effectiveNovelty = 1 - cfg.noveltyWeight * (1 - noveltyRaw);
307
+ let g = u.score * recencyOf(u) * effectiveNovelty * r.phi;
308
+ const pr = prevRank.get(u.id);
309
+ if (cfg.hysteresisBonus > 0 && pr !== undefined && r.rank >= pr) {
310
+ g *= 1 + cfg.hysteresisBonus;
311
+ }
312
+ return g;
313
+ };
314
+
315
+ // --- Greedy MCKP loop: densest feasible ADD or UPGRADE each round ---
316
+ interface Move {
317
+ unitId: string;
318
+ rendering: Rendering;
319
+ deltaG: number;
320
+ deltaC: number;
321
+ density: number;
322
+ }
323
+ // Strict comparison + fixed iteration order (units in input order, fidelities
324
+ // in ALL_FIDELITIES order) ⇒ ties resolve to stable input order.
325
+ const better = (a: Move, b: Move | null): boolean =>
326
+ b === null || a.density > b.density || (a.density === b.density && a.deltaG > b.deltaG);
327
+
328
+ for (;;) {
329
+ let best: Move | null = null;
330
+
331
+ for (const u of units) {
332
+ const menu = menuOf.get(u.id)!;
333
+ const current = chosen.get(u.id);
334
+
335
+ if (!current) {
336
+ // ADD moves — only if a distinct-unit slot remains. maxUnits is
337
+ // ignored in adaptive mode (the relFloor/novFloor gate below
338
+ // replaces it as the thing that stops new units from entering).
339
+ if (!adaptive && chosen.size >= cfg.maxUnits) continue;
340
+ for (const r of menu) {
341
+ if (r.cost <= 0) continue;
342
+ if (selectedTokens + r.cost > budget) continue;
343
+ // Adaptive ADD-move admission gate: past minKeep
344
+ // distinct units, a unit may only be newly added if it clears both
345
+ // floors — relevance fraction vs the pool's top score, and this
346
+ // candidate rendering's novelty vs what's already chosen. UPGRADE
347
+ // moves (the `else` branch below) are never gated.
348
+ if (adaptive && chosen.size >= minKeep) {
349
+ const relFrac = u.score / (topScore || 1);
350
+ const nov = noveltyOf(u, r.tokens, null);
351
+ if (relFrac < relFloor || nov < novFloor) continue;
352
+ }
353
+ const deltaG = utilityOf(u, r, null);
354
+ if (deltaG <= 0) continue;
355
+ const move: Move = { unitId: u.id, rendering: r, deltaG, deltaC: r.cost, density: deltaG / r.cost };
356
+ if (better(move, best)) best = move;
357
+ }
358
+ } else {
359
+ // UPGRADE moves — to a strictly costlier fidelity of the same unit.
360
+ const baseG = utilityOf(u, current, u.id);
361
+ for (const r of menu) {
362
+ const deltaC = r.cost - current.cost;
363
+ if (deltaC <= 0) continue; // must be strictly costlier
364
+ if (selectedTokens + deltaC > budget) continue;
365
+ const deltaG = utilityOf(u, r, u.id) - baseG;
366
+ if (deltaG <= 0) continue;
367
+ const move: Move = { unitId: u.id, rendering: r, deltaG, deltaC, density: deltaG / deltaC };
368
+ if (better(move, best)) best = move;
369
+ }
370
+ }
371
+ }
372
+
373
+ if (!best) break;
374
+ selectedTokens += best.deltaC;
375
+ totalUtility += best.deltaG;
376
+ chosen.set(best.unitId, best.rendering);
377
+ moves++;
378
+ }
379
+
380
+ // --- KMN best-single-vs-greedy swap (same as CabarSelector) ---
381
+ // A single verbatim unit whose standalone utility beats the whole greedy
382
+ // pack is taken instead — the budgeted-max-coverage 2-approx guarantee.
383
+ // maxUnits is ignored in adaptive mode, so the guard is unconditional then.
384
+ if (adaptive || cfg.maxUnits >= 1) {
385
+ let bestSingle: { unit: PrismUnit; rendering: Rendering; utility: number } | null = null;
386
+ for (const u of units) {
387
+ const vr = menuOf.get(u.id)!.find((r) => r.fidelity === 'verbatim');
388
+ if (!vr || vr.cost > budget) continue;
389
+ const utility = u.score * recencyOf(u) * vr.phi; // evaluated alone: novelty(∅)=1
390
+ if (!bestSingle || utility > bestSingle.utility) bestSingle = { unit: u, rendering: vr, utility };
391
+ }
392
+ if (bestSingle && bestSingle.utility > totalUtility) {
393
+ chosen.clear();
394
+ chosen.set(bestSingle.unit.id, bestSingle.rendering);
395
+ selectedTokens = bestSingle.rendering.cost;
396
+ totalUtility = bestSingle.utility;
397
+ }
398
+ }
399
+
400
+ // --- Tail: mention-line floor for every unselected unit ≥ tauFloor ---
401
+ // Mention lines replace CABAR-1's sentence soup: an answer-lossless,
402
+ // dereferenceable pointer per unit. Greedy by score into a hard tail cap.
403
+ const eligible = units
404
+ .filter((u) => !chosen.has(u.id) && u.score >= cfg.tauFloor)
405
+ .sort((a, b) => b.score - a.score || inputIndex.get(a.id)! - inputIndex.get(b.id)!);
406
+
407
+ const mentionLines: string[] = [];
408
+ let mentionUsed = 0;
409
+ for (const u of eligible) {
410
+ const line = mention(u, counter);
411
+ const lineTokens = counter.count(mentionLines.length === 0 ? line : `\n${line}`);
412
+ if (mentionUsed + lineTokens > cfg.tailBudgetTokens) break; // hard cap, never exceed
413
+ mentionLines.push(line);
414
+ mentionUsed += lineTokens;
415
+ }
416
+ const mentionBlock = mentionLines.join('\n');
417
+
418
+ // Tail-cost guard (tiny pools): if the prefixed mention lines cost more than
419
+ // the raw content they point at, emit the cheaper verbatim block instead.
420
+ const verbatimBlock = eligible.map((u) => u.content.trim()).join('\n');
421
+ let tailBlock =
422
+ eligible.length === 0
423
+ ? ''
424
+ : counter.count(verbatimBlock) < counter.count(mentionBlock)
425
+ ? verbatimBlock
426
+ : mentionBlock;
427
+ if (counter.count(tailBlock) > cfg.tailBudgetTokens) {
428
+ tailBlock = truncateToTokens(tailBlock, cfg.tailBudgetTokens, counter);
429
+ }
430
+ const tailTokens = counter.count(tailBlock);
431
+
432
+ // --- Output ordering: cache-stable (timestamp asc, id asc), NOT score ---
433
+ const selected: PrismSelectedUnit[] = [...chosen.entries()]
434
+ .map(([id, r]) => ({ unit: unitById.get(id)!, fidelity: r.fidelity, rendered: r.rendered, tokens: r.cost }))
435
+ .sort((a, b) => tsKey(a.unit) - tsKey(b.unit) || a.unit.id.localeCompare(b.unit.id));
436
+
437
+ const fidelityHistogram: Record<Fidelity, number> = { verbatim: 0, extract: 0, claims: 0, mention: 0 };
438
+ for (const s of selected) fidelityHistogram[s.fidelity]++;
439
+
440
+ return {
441
+ selected,
442
+ tail: eligible,
443
+ tailBlock,
444
+ stats: {
445
+ candidateTokens,
446
+ selectedTokens,
447
+ tailTokens,
448
+ budgetTokens: effectiveBudget,
449
+ utilizedTokens: cfg.guaranteedTokens + selectedTokens + tailTokens,
450
+ fidelityHistogram,
451
+ moves,
452
+ },
453
+ };
454
+ }
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Helpers
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /** Dispatch to the right rendering; null = UNAVAILABLE (e.g. no claims). */
462
+ function renderFidelity(u: PrismUnit, f: Fidelity, query: string, counter: TokenCounter): string | null {
463
+ switch (f) {
464
+ case 'verbatim':
465
+ return verbatim(u);
466
+ case 'extract':
467
+ return extract(u, query, counter);
468
+ case 'claims':
469
+ return claimsRendering(u);
470
+ case 'mention':
471
+ return mention(u, counter);
472
+ default: {
473
+ const _exhaustive: never = f;
474
+ return _exhaustive;
475
+ }
476
+ }
477
+ }
478
+
479
+ /** Undated units sort first (epoch 0); stable across turns for cache-friendliness. */
480
+ function tsKey(u: PrismUnit): number {
481
+ return u.timestamp ? u.timestamp.getTime() : 0;
482
+ }