@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shared helpers: the default token estimator and the recency decay curve.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Token-count heuristic: ~4 characters per token.
|
|
6
|
+
*
|
|
7
|
+
* Not a real tokenizer. Deliberately cheap and provider-agnostic (no
|
|
8
|
+
* dependency on any one model's BPE vocabulary): good enough for budget
|
|
9
|
+
* heuristics, not for exact billing. Pass your own `TokenCounter` to either
|
|
10
|
+
* selector when you need exact counts.
|
|
11
|
+
*/
|
|
12
|
+
declare function estimateTokens(text: string): number;
|
|
13
|
+
/**
|
|
14
|
+
* A pluggable token counter. Defaults to the char/4 estimator; inject a real
|
|
15
|
+
* BPE tokenizer (e.g. `gpt-tokenizer`, tiktoken) for exact costs.
|
|
16
|
+
*/
|
|
17
|
+
interface TokenCounter {
|
|
18
|
+
name: string;
|
|
19
|
+
count(text: string): number;
|
|
20
|
+
}
|
|
21
|
+
/** Provider-agnostic default counter: the char/4 heuristic. */
|
|
22
|
+
declare const CHAR4_COUNTER: TokenCounter;
|
|
23
|
+
/**
|
|
24
|
+
* Similarity between two units in [0, 1], used for the novelty term in place
|
|
25
|
+
* of the built-in word-set Jaccard. 1 = same content, 0 = unrelated.
|
|
26
|
+
*/
|
|
27
|
+
type Similarity<T> = (a: T, b: T) => number;
|
|
28
|
+
/** Cosine similarity of two equal-length vectors, clamped to [0, 1]. */
|
|
29
|
+
declare function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number;
|
|
30
|
+
/**
|
|
31
|
+
* Ready-made `similarity` option: cosine over each unit's `embedding`.
|
|
32
|
+
* Catches paraphrases that word overlap misses. Throws if a unit has no embedding.
|
|
33
|
+
*/
|
|
34
|
+
declare function embeddingSimilarity<T extends {
|
|
35
|
+
id: string;
|
|
36
|
+
embedding?: ArrayLike<number>;
|
|
37
|
+
}>(a: T, b: T): number;
|
|
38
|
+
/** Default recency scale in days: score = e^(-age / 365). */
|
|
39
|
+
declare const DEFAULT_RECENCY_DECAY_DAYS = 365;
|
|
40
|
+
/**
|
|
41
|
+
* Exponential recency decay in [0, 1]: 1 = brand new, → 0 as the unit ages.
|
|
42
|
+
* score = e^(-ageInDays / decayDays)
|
|
43
|
+
*/
|
|
44
|
+
declare function recencyDecay(ageInDays: number, decayDays?: number): number;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* CabarSelector: Context-Budget-Aware Retrieval.
|
|
48
|
+
*
|
|
49
|
+
* Pure, deterministic selection of which retrieved units are worth spending
|
|
50
|
+
* prompt-token budget on, replacing "take the top-K by score" with a greedy
|
|
51
|
+
* utility/cost knapsack:
|
|
52
|
+
*
|
|
53
|
+
* utility(u, S) = relevance(u) × recency(u) × novelty(u, S)
|
|
54
|
+
*
|
|
55
|
+
* where S is the set already selected (novelty is recomputed against the
|
|
56
|
+
* CURRENT S on every iteration, so a second near-duplicate of an already-picked
|
|
57
|
+
* unit contributes ~0 marginal utility and is skipped in favor of budget spent
|
|
58
|
+
* elsewhere). Units below `tauFloor` relevance are dropped entirely; units at
|
|
59
|
+
* or above the floor that don't make the cut are extractively summarized into
|
|
60
|
+
* a compact `tailSummary` instead of being discarded outright.
|
|
61
|
+
*
|
|
62
|
+
* No LLM calls, no I/O, no `Date.now()`: the caller supplies `now` (defaults
|
|
63
|
+
* to `new Date()` at the call site) so `select()` is a pure, replayable
|
|
64
|
+
* function of its inputs.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
interface CabarUnit {
|
|
68
|
+
id: string;
|
|
69
|
+
content: string;
|
|
70
|
+
/** Relevance from your retriever, normalized to [0,1] (top item ideally 1.0). */
|
|
71
|
+
score: number;
|
|
72
|
+
timestamp?: Date;
|
|
73
|
+
/** Optional vector for `similarity: embeddingSimilarity`. */
|
|
74
|
+
embedding?: ArrayLike<number>;
|
|
75
|
+
}
|
|
76
|
+
interface CabarSelectOptions {
|
|
77
|
+
/** Token budget for the guaranteed block + selected units. The tail summary is capped separately by tailBudgetTokens. */
|
|
78
|
+
budgetTokens?: number;
|
|
79
|
+
/** Hard cap on the number of units selected, independent of budget. */
|
|
80
|
+
maxUnits?: number;
|
|
81
|
+
/** Hard token cap for the extractive tail summary (on top of budgetTokens). */
|
|
82
|
+
tailBudgetTokens?: number;
|
|
83
|
+
/** Minimum relevance score for a unit to survive into the tail at all (below this: dropped). */
|
|
84
|
+
tauFloor?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Blend factor between "ignore recency" (0) and "full recency effect" (1).
|
|
87
|
+
* ρ = recencyLambda × decay + (1 − recencyLambda). At 0, ρ ≡ 1 for every
|
|
88
|
+
* unit regardless of timestamp — required for multi-session safety (a
|
|
89
|
+
* cross-session query shouldn't be penalized just because the recall is old).
|
|
90
|
+
*/
|
|
91
|
+
recencyLambda?: number;
|
|
92
|
+
/** Recency scale in days for the exponential decay e^(-age/days). Default 365. */
|
|
93
|
+
recencyDecayDays?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Blend factor between "ignore novelty" (0) and "full novelty effect" (1)
|
|
96
|
+
* applied to the raw novelty(u,S) ∈ [0,1] term:
|
|
97
|
+
* effectiveNovelty = 1 − noveltyWeight × (1 − novelty(u,S))
|
|
98
|
+
* Defaults to 1, i.e. utility = r × ρ × novelty(u,S) exactly as specified.
|
|
99
|
+
*/
|
|
100
|
+
noveltyWeight?: number;
|
|
101
|
+
/** Tokens pre-charged against budgetTokens before selection starts (e.g. the resolved-claims block). */
|
|
102
|
+
guaranteedTokens?: number;
|
|
103
|
+
/** Deterministic "current time" for recency scoring. Defaults to `new Date()`. */
|
|
104
|
+
now?: Date;
|
|
105
|
+
/**
|
|
106
|
+
* How hard long units are penalized: greedy density = utility / tokens^costExponent.
|
|
107
|
+
* 1 (default) = pure value per token, which favors short units. 0.5 softens it so a
|
|
108
|
+
* long unit that holds the answer can still win; 0 ignores length (pure utility order).
|
|
109
|
+
*/
|
|
110
|
+
costExponent?: number;
|
|
111
|
+
/** Also return `decisions`: why each unit was selected, summarized into the tail, or dropped. */
|
|
112
|
+
explain?: boolean;
|
|
113
|
+
/** Injectable token cost model. Defaults to the char/4 estimator. */
|
|
114
|
+
tokenCounter?: TokenCounter;
|
|
115
|
+
/**
|
|
116
|
+
* Similarity used for novelty, in [0, 1]. Defaults to word-set Jaccard.
|
|
117
|
+
* Pass `embeddingSimilarity` (with `embedding` on each unit) to catch paraphrases.
|
|
118
|
+
*/
|
|
119
|
+
similarity?: Similarity<CabarUnit>;
|
|
120
|
+
/**
|
|
121
|
+
* Adaptive stopping mode. When true, the greedy loop's
|
|
122
|
+
* stop condition is replaced: instead of the fixed `budgetTokens`/`maxUnits`
|
|
123
|
+
* caps, it keeps accepting units past `minKeep` only while the next unit's
|
|
124
|
+
* relevance fraction (score / top-candidate score) is >= `relFloor` AND its
|
|
125
|
+
* novelty vs the already-selected set is >= `novFloor`; `maxCeilingTokens`
|
|
126
|
+
* becomes the only hard token bound (`maxUnits` is ignored). This lets the
|
|
127
|
+
* pool's own relevance-score shape decide how much to keep — noisy pools
|
|
128
|
+
* (one strong hit, many weak ones) stop near `minKeep`; concentrated/spread
|
|
129
|
+
* pools with a genuine relevant tail keep more. Default false: behavior is
|
|
130
|
+
* byte-identical to the fixed-budget path.
|
|
131
|
+
*/
|
|
132
|
+
adaptive?: boolean;
|
|
133
|
+
/** Adaptive mode: units accepted before the relFloor/novFloor stop rule can trigger. Default 3. */
|
|
134
|
+
minKeep?: number;
|
|
135
|
+
/** Adaptive mode: below this fraction of the top candidate's score, the greedy fill stops. Default 0.4. */
|
|
136
|
+
relFloor?: number;
|
|
137
|
+
/** Adaptive mode: below this novelty vs the selected set, the greedy fill stops. Default 0.2. */
|
|
138
|
+
novFloor?: number;
|
|
139
|
+
/** Adaptive mode: hard token ceiling — replaces budgetTokens as the bound. Default 4000. */
|
|
140
|
+
maxCeilingTokens?: number;
|
|
141
|
+
/**
|
|
142
|
+
* Adaptive mode: also honor `maxUnits` as a hard ceiling ("capped adaptive").
|
|
143
|
+
* The relFloor/novFloor early-stop can still stop BELOW maxUnits on cliff-y
|
|
144
|
+
* (noisy) pools — this only prevents adaptive from keeping MORE than maxUnits on
|
|
145
|
+
* uniformly-relevant (concentrated) pools, where the uncapped rule over-keeps
|
|
146
|
+
* with no accuracy payoff. Unifies cabar1's ceiling with adaptive's early stop:
|
|
147
|
+
* concentrated → cap binds (≡ cabar1); noisy → cliff stops early (≡ adaptive).
|
|
148
|
+
* Default false (preserves the original uncapped adaptive arm byte-identically).
|
|
149
|
+
*/
|
|
150
|
+
capToMaxUnits?: boolean;
|
|
151
|
+
}
|
|
152
|
+
interface CabarStats {
|
|
153
|
+
candidateCount: number;
|
|
154
|
+
candidateTokens: number;
|
|
155
|
+
selectedTokens: number;
|
|
156
|
+
tailSummaryTokens: number;
|
|
157
|
+
budgetTokens: number;
|
|
158
|
+
/** guaranteedTokens + selectedTokens + tailSummaryTokens — actual spend against budgetTokens. */
|
|
159
|
+
utilizedTokens: number;
|
|
160
|
+
}
|
|
161
|
+
interface CabarDecision {
|
|
162
|
+
id: string;
|
|
163
|
+
status: 'selected' | 'tail' | 'dropped';
|
|
164
|
+
/** Plain-English reason, e.g. "redundant with u3 (similarity 0.82)". */
|
|
165
|
+
reason: string;
|
|
166
|
+
tokens: number;
|
|
167
|
+
relevance: number;
|
|
168
|
+
recency: number;
|
|
169
|
+
/** 1 − highest similarity to any (other) selected unit. */
|
|
170
|
+
novelty: number;
|
|
171
|
+
/** For redundant units: the selected unit it overlaps most. */
|
|
172
|
+
redundantWith?: string;
|
|
173
|
+
}
|
|
174
|
+
interface CabarSelectResult {
|
|
175
|
+
selected: CabarUnit[];
|
|
176
|
+
/** Unselected units at or above tauFloor — summarized into tailSummary, not dropped. */
|
|
177
|
+
tail: CabarUnit[];
|
|
178
|
+
tailSummary: string;
|
|
179
|
+
stats: CabarStats;
|
|
180
|
+
/** Present when `explain: true`, one entry per input unit in input order. */
|
|
181
|
+
decisions?: CabarDecision[];
|
|
182
|
+
}
|
|
183
|
+
/** Fixed-mode selector defaults. Exported so `cabar.config.ts` can source the
|
|
184
|
+
* budget/maxUnits ceilings from one place instead of re-declaring the literals. */
|
|
185
|
+
declare const DEFAULTS: Required<Omit<CabarSelectOptions, 'now' | 'tokenCounter' | 'similarity' | 'explain' | 'adaptive' | 'minKeep' | 'relFloor' | 'novFloor' | 'maxCeilingTokens' | 'capToMaxUnits'>>;
|
|
186
|
+
/** Adaptive-mode-only defaults (fixed global constants, never tuned per query type). */
|
|
187
|
+
declare const ADAPTIVE_DEFAULTS: {
|
|
188
|
+
minKeep: number;
|
|
189
|
+
relFloor: number;
|
|
190
|
+
novFloor: number;
|
|
191
|
+
maxCeilingTokens: number;
|
|
192
|
+
};
|
|
193
|
+
declare class CabarSelector {
|
|
194
|
+
/**
|
|
195
|
+
* Select which units fit the budget, greedily maximizing utility/cost
|
|
196
|
+
* density, with a best-single-vs-greedy swap (budgeted-max-coverage 2-approx
|
|
197
|
+
* guarantee) and an extractive tail summary for the rest.
|
|
198
|
+
*/
|
|
199
|
+
static select(units: CabarUnit[], query: string, opts?: CabarSelectOptions): CabarSelectResult;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { ADAPTIVE_DEFAULTS as A, type CabarSelectResult as C, DEFAULTS as D, type Similarity as S, type TokenCounter as T, type CabarUnit as a, type CabarSelectOptions as b, CHAR4_COUNTER as c, type CabarDecision as d, CabarSelector as e, type CabarStats as f, DEFAULT_RECENCY_DECAY_DAYS as g, cosineSimilarity as h, embeddingSimilarity as i, estimateTokens as j, recencyDecay as r };
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// src/shared.ts
|
|
2
|
+
function estimateTokens(text) {
|
|
3
|
+
return Math.ceil(text.length / 4);
|
|
4
|
+
}
|
|
5
|
+
var CHAR4_COUNTER = { name: "char4", count: estimateTokens };
|
|
6
|
+
function cosineSimilarity(a, b) {
|
|
7
|
+
if (a.length !== b.length) throw new RangeError("cosineSimilarity: vectors must have the same length");
|
|
8
|
+
let dot = 0, na = 0, nb = 0;
|
|
9
|
+
for (let i = 0; i < a.length; i++) {
|
|
10
|
+
dot += a[i] * b[i];
|
|
11
|
+
na += a[i] * a[i];
|
|
12
|
+
nb += b[i] * b[i];
|
|
13
|
+
}
|
|
14
|
+
if (na === 0 || nb === 0) return 0;
|
|
15
|
+
return Math.max(0, Math.min(1, dot / Math.sqrt(na * nb)));
|
|
16
|
+
}
|
|
17
|
+
function embeddingSimilarity(a, b) {
|
|
18
|
+
if (!a.embedding || !b.embedding) throw new Error(`embeddingSimilarity: unit ${!a.embedding ? a.id : b.id} has no embedding`);
|
|
19
|
+
return cosineSimilarity(a.embedding, b.embedding);
|
|
20
|
+
}
|
|
21
|
+
var DEFAULT_RECENCY_DECAY_DAYS = 365;
|
|
22
|
+
function recencyDecay(ageInDays, decayDays = DEFAULT_RECENCY_DECAY_DAYS) {
|
|
23
|
+
if (ageInDays < 0) throw new RangeError("ageInDays must be >= 0");
|
|
24
|
+
if (decayDays <= 0) throw new RangeError("decayDays must be > 0");
|
|
25
|
+
return Math.exp(-ageInDays / decayDays);
|
|
26
|
+
}
|
|
27
|
+
function stripUndefined(obj) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const key of Object.keys(obj)) {
|
|
30
|
+
if (obj[key] !== void 0) out[key] = obj[key];
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/renderings.ts
|
|
36
|
+
var EXTRACT_TARGET_RATIO = 0.4;
|
|
37
|
+
var EXTRACT_FIRST_SENTENCE_BONUS = 1;
|
|
38
|
+
var MENTION_MAX_TOKENS = 25;
|
|
39
|
+
function tokenize(text) {
|
|
40
|
+
const matches = text.toLowerCase().match(/[a-z0-9]{3,}/g);
|
|
41
|
+
return new Set(matches ?? []);
|
|
42
|
+
}
|
|
43
|
+
function jaccard(a, b) {
|
|
44
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
45
|
+
let intersection = 0;
|
|
46
|
+
for (const t of a) {
|
|
47
|
+
if (b.has(t)) intersection++;
|
|
48
|
+
}
|
|
49
|
+
const union = a.size + b.size - intersection;
|
|
50
|
+
return union === 0 ? 0 : intersection / union;
|
|
51
|
+
}
|
|
52
|
+
function splitSentences(text) {
|
|
53
|
+
return text.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
function truncateToTokens(text, maxTokens, counter) {
|
|
56
|
+
if (counter.count(text) <= maxTokens) return text;
|
|
57
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
58
|
+
let out = "";
|
|
59
|
+
for (const w of words) {
|
|
60
|
+
const candidate = out ? `${out} ${w}` : w;
|
|
61
|
+
if (counter.count(`${candidate}\u2026`) > maxTokens) break;
|
|
62
|
+
out = candidate;
|
|
63
|
+
}
|
|
64
|
+
if (!out) out = words[0] ?? "";
|
|
65
|
+
return `${out}\u2026`;
|
|
66
|
+
}
|
|
67
|
+
function verbatim(u) {
|
|
68
|
+
return u.content;
|
|
69
|
+
}
|
|
70
|
+
function extract(u, query, counter) {
|
|
71
|
+
const sentences = splitSentences(u.content);
|
|
72
|
+
if (sentences.length <= 1) return u.content.trim();
|
|
73
|
+
const queryTokens = tokenize(query);
|
|
74
|
+
const target = Math.max(1, Math.floor(counter.count(u.content) * EXTRACT_TARGET_RATIO));
|
|
75
|
+
const scored = [];
|
|
76
|
+
const seen = /* @__PURE__ */ new Set();
|
|
77
|
+
sentences.forEach((text, order) => {
|
|
78
|
+
const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
79
|
+
if (seen.has(normalized)) return;
|
|
80
|
+
seen.add(normalized);
|
|
81
|
+
let overlap = 0;
|
|
82
|
+
for (const t of tokenize(text)) {
|
|
83
|
+
if (queryTokens.has(t)) overlap++;
|
|
84
|
+
}
|
|
85
|
+
scored.push({
|
|
86
|
+
text,
|
|
87
|
+
order,
|
|
88
|
+
score: overlap + (order === 0 ? EXTRACT_FIRST_SENTENCE_BONUS : 0),
|
|
89
|
+
cost: counter.count(text)
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
const byScore = [...scored].sort((a, b) => b.score - a.score || a.order - b.order);
|
|
93
|
+
const chosen = [];
|
|
94
|
+
let used = 0;
|
|
95
|
+
for (const s of byScore) {
|
|
96
|
+
if (used + s.cost > target) continue;
|
|
97
|
+
chosen.push(s);
|
|
98
|
+
used += s.cost;
|
|
99
|
+
}
|
|
100
|
+
if (chosen.length === 0) chosen.push(byScore[0]);
|
|
101
|
+
chosen.sort((a, b) => a.order - b.order);
|
|
102
|
+
return chosen.map((s) => s.text).join(" ");
|
|
103
|
+
}
|
|
104
|
+
function claimsRendering(u) {
|
|
105
|
+
if (!u.claims?.length) return null;
|
|
106
|
+
return u.claims.map((c) => `- ${c}`).join("\n");
|
|
107
|
+
}
|
|
108
|
+
function mention(u, counter) {
|
|
109
|
+
const first = splitSentences(u.content)[0] ?? u.content.trim();
|
|
110
|
+
const snippet = truncateToTokens(first, MENTION_MAX_TOKENS, counter);
|
|
111
|
+
const idPart = u.timestamp ? `${u.id}@${u.timestamp.toISOString().slice(0, 10)}` : u.id;
|
|
112
|
+
return `[${idPart}] ${snippet}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/cabar.ts
|
|
116
|
+
var DEFAULTS = {
|
|
117
|
+
budgetTokens: 4e3,
|
|
118
|
+
maxUnits: 8,
|
|
119
|
+
tailBudgetTokens: 600,
|
|
120
|
+
tauFloor: 0.15,
|
|
121
|
+
recencyLambda: 0.3,
|
|
122
|
+
recencyDecayDays: DEFAULT_RECENCY_DECAY_DAYS,
|
|
123
|
+
noveltyWeight: 1,
|
|
124
|
+
guaranteedTokens: 0,
|
|
125
|
+
costExponent: 1
|
|
126
|
+
};
|
|
127
|
+
var ADAPTIVE_DEFAULTS = {
|
|
128
|
+
minKeep: 3,
|
|
129
|
+
relFloor: 0.4,
|
|
130
|
+
novFloor: 0.2,
|
|
131
|
+
maxCeilingTokens: 4e3
|
|
132
|
+
};
|
|
133
|
+
var MS_PER_DAY = 864e5;
|
|
134
|
+
function tokenize2(text) {
|
|
135
|
+
const matches = text.toLowerCase().match(/[a-z0-9]{3,}/g);
|
|
136
|
+
return new Set(matches ?? []);
|
|
137
|
+
}
|
|
138
|
+
function jaccard2(a, b) {
|
|
139
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
140
|
+
let intersection = 0;
|
|
141
|
+
for (const t of a) {
|
|
142
|
+
if (b.has(t)) intersection++;
|
|
143
|
+
}
|
|
144
|
+
const union = a.size + b.size - intersection;
|
|
145
|
+
return union === 0 ? 0 : intersection / union;
|
|
146
|
+
}
|
|
147
|
+
var CabarSelector = class {
|
|
148
|
+
/**
|
|
149
|
+
* Select which units fit the budget, greedily maximizing utility/cost
|
|
150
|
+
* density, with a best-single-vs-greedy swap (budgeted-max-coverage 2-approx
|
|
151
|
+
* guarantee) and an extractive tail summary for the rest.
|
|
152
|
+
*/
|
|
153
|
+
static select(units, query, opts = {}) {
|
|
154
|
+
const cfg = { ...DEFAULTS, ...stripUndefined(opts) };
|
|
155
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
156
|
+
const counter = opts.tokenCounter ?? CHAR4_COUNTER;
|
|
157
|
+
const count = (t) => counter.count(t);
|
|
158
|
+
const adaptive = opts.adaptive === true;
|
|
159
|
+
const minKeep = opts.minKeep ?? ADAPTIVE_DEFAULTS.minKeep;
|
|
160
|
+
const relFloor = opts.relFloor ?? ADAPTIVE_DEFAULTS.relFloor;
|
|
161
|
+
const novFloor = opts.novFloor ?? ADAPTIVE_DEFAULTS.novFloor;
|
|
162
|
+
const maxCeilingTokens = opts.maxCeilingTokens ?? ADAPTIVE_DEFAULTS.maxCeilingTokens;
|
|
163
|
+
const capToMaxUnits = opts.capToMaxUnits === true;
|
|
164
|
+
const topScore = units.length > 0 ? Math.max(...units.map((u) => u.score)) : 1;
|
|
165
|
+
const costOf = /* @__PURE__ */ new Map();
|
|
166
|
+
const tokensOf = /* @__PURE__ */ new Map();
|
|
167
|
+
let candidateTokens = 0;
|
|
168
|
+
for (const u of units) {
|
|
169
|
+
const cost = count(u.content);
|
|
170
|
+
costOf.set(u.id, cost);
|
|
171
|
+
tokensOf.set(u.id, tokenize2(u.content));
|
|
172
|
+
candidateTokens += cost;
|
|
173
|
+
}
|
|
174
|
+
const effectiveBudget = adaptive ? maxCeilingTokens : cfg.budgetTokens;
|
|
175
|
+
const remainingBudget = Math.max(0, effectiveBudget - cfg.guaranteedTokens);
|
|
176
|
+
const recencyOf = (u) => {
|
|
177
|
+
if (!u.timestamp || cfg.recencyLambda === 0) return 1;
|
|
178
|
+
const ageInDays = Math.max(0, (now.getTime() - u.timestamp.getTime()) / MS_PER_DAY);
|
|
179
|
+
const decay = recencyDecay(ageInDays, cfg.recencyDecayDays);
|
|
180
|
+
return cfg.recencyLambda * decay + (1 - cfg.recencyLambda);
|
|
181
|
+
};
|
|
182
|
+
const noveltyOf = (u, selectedSoFar) => {
|
|
183
|
+
if (selectedSoFar.length === 0) return 1;
|
|
184
|
+
const a = tokensOf.get(u.id);
|
|
185
|
+
let maxSim = 0;
|
|
186
|
+
for (const s of selectedSoFar) {
|
|
187
|
+
const sim2 = opts.similarity ? opts.similarity(u, s) : jaccard2(a, tokensOf.get(s.id));
|
|
188
|
+
if (sim2 > maxSim) maxSim = sim2;
|
|
189
|
+
}
|
|
190
|
+
return 1 - maxSim;
|
|
191
|
+
};
|
|
192
|
+
const utilityOf = (u, selectedSoFar) => {
|
|
193
|
+
const r = u.score;
|
|
194
|
+
const rho = recencyOf(u);
|
|
195
|
+
const noveltyRaw = noveltyOf(u, selectedSoFar);
|
|
196
|
+
const effectiveNovelty = 1 - cfg.noveltyWeight * (1 - noveltyRaw);
|
|
197
|
+
return r * rho * effectiveNovelty;
|
|
198
|
+
};
|
|
199
|
+
const selected = [];
|
|
200
|
+
const selectedIds = /* @__PURE__ */ new Set();
|
|
201
|
+
let selectedTokens = 0;
|
|
202
|
+
let greedyTotalUtility = 0;
|
|
203
|
+
while (adaptive && !capToMaxUnits || selected.length < cfg.maxUnits) {
|
|
204
|
+
let best = null;
|
|
205
|
+
for (const u of units) {
|
|
206
|
+
if (selectedIds.has(u.id)) continue;
|
|
207
|
+
const cost = costOf.get(u.id);
|
|
208
|
+
if (selectedTokens + cost > remainingBudget) continue;
|
|
209
|
+
const utility = utilityOf(u, selected);
|
|
210
|
+
if (utility <= 0) continue;
|
|
211
|
+
const density = cost > 0 ? utility / Math.pow(cost, cfg.costExponent) : Infinity;
|
|
212
|
+
if (!best || density > best.density) {
|
|
213
|
+
best = { unit: u, utility, density };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!best) break;
|
|
217
|
+
if (adaptive && selected.length >= minKeep) {
|
|
218
|
+
const relFrac = best.unit.score / (topScore || 1);
|
|
219
|
+
const nov = noveltyOf(best.unit, selected);
|
|
220
|
+
if (relFrac < relFloor || nov < novFloor) break;
|
|
221
|
+
}
|
|
222
|
+
selected.push(best.unit);
|
|
223
|
+
selectedIds.add(best.unit.id);
|
|
224
|
+
selectedTokens += costOf.get(best.unit.id);
|
|
225
|
+
greedyTotalUtility += best.utility;
|
|
226
|
+
}
|
|
227
|
+
let swapped = false;
|
|
228
|
+
if (adaptive || cfg.maxUnits >= 1) {
|
|
229
|
+
let bestSingle = null;
|
|
230
|
+
for (const u of units) {
|
|
231
|
+
const cost = costOf.get(u.id);
|
|
232
|
+
if (cost > remainingBudget) continue;
|
|
233
|
+
const utility = utilityOf(u, []);
|
|
234
|
+
if (!bestSingle || utility > bestSingle.utility) {
|
|
235
|
+
bestSingle = { unit: u, utility };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (bestSingle && bestSingle.utility > greedyTotalUtility) {
|
|
239
|
+
swapped = true;
|
|
240
|
+
selected.length = 0;
|
|
241
|
+
selected.push(bestSingle.unit);
|
|
242
|
+
selectedIds.clear();
|
|
243
|
+
selectedIds.add(bestSingle.unit.id);
|
|
244
|
+
selectedTokens = costOf.get(bestSingle.unit.id);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const tail = units.filter((u) => !selectedIds.has(u.id) && u.score >= cfg.tauFloor);
|
|
248
|
+
const extractive = buildTailSummary(tail, query, cfg.tailBudgetTokens, count);
|
|
249
|
+
const verbatim2 = tail.map((u) => u.content.trim()).join("\n");
|
|
250
|
+
let tailSummary = tail.length === 0 ? "" : count(verbatim2) < count(extractive) ? verbatim2 : extractive;
|
|
251
|
+
if (count(tailSummary) > cfg.tailBudgetTokens) {
|
|
252
|
+
tailSummary = counter === CHAR4_COUNTER ? truncateToTokens2(tailSummary, cfg.tailBudgetTokens) : truncateToTokens(tailSummary, cfg.tailBudgetTokens, counter);
|
|
253
|
+
}
|
|
254
|
+
const tailSummaryTokens = count(tailSummary);
|
|
255
|
+
const result = {
|
|
256
|
+
selected,
|
|
257
|
+
tail,
|
|
258
|
+
tailSummary,
|
|
259
|
+
stats: {
|
|
260
|
+
candidateCount: units.length,
|
|
261
|
+
candidateTokens,
|
|
262
|
+
selectedTokens,
|
|
263
|
+
tailSummaryTokens,
|
|
264
|
+
budgetTokens: effectiveBudget,
|
|
265
|
+
utilizedTokens: cfg.guaranteedTokens + selectedTokens + tailSummaryTokens
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
if (!opts.explain) return result;
|
|
269
|
+
const sim = (a, b) => opts.similarity ? opts.similarity(a, b) : jaccard2(tokensOf.get(a.id), tokensOf.get(b.id));
|
|
270
|
+
const unitCap = !adaptive || capToMaxUnits;
|
|
271
|
+
result.decisions = units.map((u) => {
|
|
272
|
+
const tokens = costOf.get(u.id);
|
|
273
|
+
const others = selected.filter((s) => s.id !== u.id);
|
|
274
|
+
let maxSim = 0;
|
|
275
|
+
let closest;
|
|
276
|
+
for (const s of others) {
|
|
277
|
+
const v = sim(u, s);
|
|
278
|
+
if (v > maxSim) [maxSim, closest] = [v, s.id];
|
|
279
|
+
}
|
|
280
|
+
const base = { id: u.id, tokens, relevance: u.score, recency: recencyOf(u), novelty: 1 - maxSim };
|
|
281
|
+
if (selectedIds.has(u.id)) {
|
|
282
|
+
return { ...base, status: "selected", reason: swapped ? "best single unit: worth more alone than the greedy set" : "selected" };
|
|
283
|
+
}
|
|
284
|
+
if (u.score < cfg.tauFloor) {
|
|
285
|
+
return { ...base, status: "dropped", reason: `relevance ${u.score.toFixed(2)} is below tauFloor ${cfg.tauFloor}` };
|
|
286
|
+
}
|
|
287
|
+
let reason;
|
|
288
|
+
if (swapped) reason = "a single unit beat the greedy set";
|
|
289
|
+
else if (maxSim >= 1 - (adaptive ? novFloor : 0.2) && closest)
|
|
290
|
+
reason = `redundant with ${closest} (similarity ${maxSim.toFixed(2)})`;
|
|
291
|
+
else if (selectedTokens + tokens > remainingBudget) reason = `doesn't fit: needs ${tokens} tokens, ${Math.max(0, remainingBudget - selectedTokens)} left`;
|
|
292
|
+
else if (unitCap && selected.length >= cfg.maxUnits) reason = `maxUnits (${cfg.maxUnits}) reached`;
|
|
293
|
+
else if (adaptive && u.score / (topScore || 1) < relFloor) reason = `below the adaptive relevance cliff (${(u.score / (topScore || 1)).toFixed(2)} of the top score)`;
|
|
294
|
+
else reason = "adaptive stop: lower value per token than what was kept";
|
|
295
|
+
return { ...base, status: "tail", reason, ...reason.startsWith("redundant") ? { redundantWith: closest } : {} };
|
|
296
|
+
});
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
function truncateToTokens2(text, budgetTokens) {
|
|
301
|
+
const maxChars = budgetTokens * 4;
|
|
302
|
+
if (text.length <= maxChars) return text;
|
|
303
|
+
const cut = text.slice(0, maxChars);
|
|
304
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
305
|
+
return (lastSpace > maxChars * 0.8 ? cut.slice(0, lastSpace) : cut).trimEnd() + "\u2026";
|
|
306
|
+
}
|
|
307
|
+
function buildTailSummary(tail, query, tailBudgetTokens, count) {
|
|
308
|
+
if (tail.length === 0 || tailBudgetTokens <= 0) return "";
|
|
309
|
+
const queryTokens = tokenize2(query);
|
|
310
|
+
const sentences = [];
|
|
311
|
+
let order = 0;
|
|
312
|
+
for (const unit of tail) {
|
|
313
|
+
const parts = unit.content.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
314
|
+
for (const text of parts) {
|
|
315
|
+
const tokens = tokenize2(text);
|
|
316
|
+
let overlap = 0;
|
|
317
|
+
for (const t of tokens) {
|
|
318
|
+
if (queryTokens.has(t)) overlap++;
|
|
319
|
+
}
|
|
320
|
+
sentences.push({
|
|
321
|
+
text,
|
|
322
|
+
score: overlap + unit.score,
|
|
323
|
+
unitId: unit.id,
|
|
324
|
+
timestamp: unit.timestamp,
|
|
325
|
+
order: order++
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
sentences.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
330
|
+
const seen = /* @__PURE__ */ new Set();
|
|
331
|
+
const lines = [];
|
|
332
|
+
let usedTokens = 0;
|
|
333
|
+
for (const s of sentences) {
|
|
334
|
+
const normalized = s.text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
335
|
+
if (seen.has(normalized)) continue;
|
|
336
|
+
const idPart = s.timestamp ? `${s.unitId}@${s.timestamp.toISOString().slice(0, 10)}` : s.unitId;
|
|
337
|
+
const line = `[${idPart}] ${s.text}`;
|
|
338
|
+
const lineTokens = count(lines.length === 0 ? line : `
|
|
339
|
+
${line}`);
|
|
340
|
+
if (usedTokens + lineTokens > tailBudgetTokens) break;
|
|
341
|
+
seen.add(normalized);
|
|
342
|
+
lines.push(line);
|
|
343
|
+
usedTokens += lineTokens;
|
|
344
|
+
}
|
|
345
|
+
return lines.join("\n");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/normalize.ts
|
|
349
|
+
function normalizeScores(units, opts = {}) {
|
|
350
|
+
if (units.length === 0) return [];
|
|
351
|
+
const method = opts.method ?? "minmax";
|
|
352
|
+
const raw = units.map((u) => opts.higherIsBetter === false ? -u.score : u.score);
|
|
353
|
+
for (const s of raw) {
|
|
354
|
+
if (!Number.isFinite(s)) throw new RangeError("normalizeScores: every score must be a finite number");
|
|
355
|
+
}
|
|
356
|
+
const hi = Math.max(...raw);
|
|
357
|
+
const lo = Math.min(...raw);
|
|
358
|
+
let map;
|
|
359
|
+
if (method === "max") {
|
|
360
|
+
if (lo < 0) throw new RangeError("normalizeScores: method 'max' needs non-negative scores, use 'minmax'");
|
|
361
|
+
map = (s) => hi === 0 ? 1 : s / hi;
|
|
362
|
+
} else {
|
|
363
|
+
map = (s) => hi === lo ? 1 : (s - lo) / (hi - lo);
|
|
364
|
+
}
|
|
365
|
+
return units.map((u, i) => ({ ...u, score: map(raw[i]) }));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export {
|
|
369
|
+
estimateTokens,
|
|
370
|
+
CHAR4_COUNTER,
|
|
371
|
+
cosineSimilarity,
|
|
372
|
+
embeddingSimilarity,
|
|
373
|
+
DEFAULT_RECENCY_DECAY_DAYS,
|
|
374
|
+
recencyDecay,
|
|
375
|
+
stripUndefined,
|
|
376
|
+
tokenize,
|
|
377
|
+
jaccard,
|
|
378
|
+
splitSentences,
|
|
379
|
+
truncateToTokens,
|
|
380
|
+
verbatim,
|
|
381
|
+
extract,
|
|
382
|
+
claimsRendering,
|
|
383
|
+
mention,
|
|
384
|
+
DEFAULTS,
|
|
385
|
+
ADAPTIVE_DEFAULTS,
|
|
386
|
+
CabarSelector,
|
|
387
|
+
normalizeScores
|
|
388
|
+
};
|