@whyparabola/cabar 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +312 -2
- package/dist/cabar-B-R-rHby.d.cts +202 -0
- package/dist/cabar-B-R-rHby.d.ts +202 -0
- package/dist/chunk-UE4FTY7E.js +388 -0
- package/dist/index.cjs +681 -0
- package/dist/index.d.cts +255 -0
- package/dist/index.d.ts +255 -0
- package/dist/index.js +289 -0
- package/dist/langchain.cjs +358 -0
- package/dist/langchain.d.cts +35 -0
- package/dist/langchain.d.ts +35 -0
- package/dist/langchain.js +50 -0
- package/package.json +84 -4
- package/src/cabar.ts +485 -0
- package/src/context.ts +67 -0
- package/src/index.ts +20 -0
- package/src/langchain.ts +75 -0
- package/src/normalize.ts +40 -0
- package/src/prism.ts +482 -0
- package/src/renderings.ts +170 -0
- package/src/shared.ts +85 -0
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';
|
package/src/langchain.ts
ADDED
|
@@ -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
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -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
|
+
}
|