@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/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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prism renderings: deterministic multi-fidelity renderings of a retrieved
|
|
3
|
+
* unit (the CABAR-2 "Prism" extension of CABAR's all-or-nothing selection).
|
|
4
|
+
*
|
|
5
|
+
* CABAR-1 spent budget on a unit at a single fidelity (verbatim, or nothing —
|
|
6
|
+
* with an extractive tail for the rest). Prism turns that binary into a ladder:
|
|
7
|
+
* a unit can be rendered at VERBATIM (full content), EXTRACT (query-aware
|
|
8
|
+
* sentence extract, ≤~40% of verbatim tokens), CLAIMS (its pre-resolved claim
|
|
9
|
+
* bullets), or MENTION (a one-line dereferenceable pointer). The MCKP selector
|
|
10
|
+
* (PrismSelector) then picks per-unit fidelities to maximize utility under the
|
|
11
|
+
* token budget instead of dropping units wholesale.
|
|
12
|
+
*
|
|
13
|
+
* Every rendering here is PURE and deterministic: no LLM, no I/O, no clock.
|
|
14
|
+
* Costs are measured through an injected `TokenCounter` (char/4 by default,
|
|
15
|
+
* real BPE when you inject one) so the same code costs the same
|
|
16
|
+
* text the caller will bill for. Claims are pre-fetched by the caller and live
|
|
17
|
+
* on the unit — this module never fetches them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PrismUnit } from './prism';
|
|
21
|
+
import type { TokenCounter } from './shared';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** EXTRACT targets at most this fraction of the verbatim token count. */
|
|
28
|
+
export const EXTRACT_TARGET_RATIO = 0.4;
|
|
29
|
+
/** Positional prior: the opening sentence carries topic-setting signal. */
|
|
30
|
+
export const EXTRACT_FIRST_SENTENCE_BONUS = 1;
|
|
31
|
+
/** MENTION lines truncate their sentence to at most this many tokens. */
|
|
32
|
+
export const MENTION_MAX_TOKENS = 25;
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Shared lexical helpers (reused by PrismSelector for novelty Jaccard so the
|
|
36
|
+
// rendering token-sets and the MMR token-sets are tokenized identically — and
|
|
37
|
+
// so verbatim novelty matches CabarSelector's exactly for the equivalence test)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Lowercased word (3+ chars) token set — same family as CabarSelector.tokenize. */
|
|
41
|
+
export function tokenize(text: string): Set<string> {
|
|
42
|
+
const matches = text.toLowerCase().match(/[a-z0-9]{3,}/g);
|
|
43
|
+
return new Set(matches ?? []);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function jaccard(a: Set<string>, b: Set<string>): number {
|
|
47
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
48
|
+
let intersection = 0;
|
|
49
|
+
for (const t of a) {
|
|
50
|
+
if (b.has(t)) intersection++;
|
|
51
|
+
}
|
|
52
|
+
const union = a.size + b.size - intersection;
|
|
53
|
+
return union === 0 ? 0 : intersection / union;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Sentence split — same regex family as CabarSelector.buildTailSummary. */
|
|
57
|
+
export function splitSentences(text: string): string[] {
|
|
58
|
+
return text
|
|
59
|
+
.split(/(?<=[.!?])\s+/)
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Word-boundary truncation to a token budget, appending an ellipsis only when
|
|
66
|
+
* the text is actually cut. Uses the injected counter so the cut respects the
|
|
67
|
+
* caller's tokenization (char/4 or BPE). Always keeps at least the first word.
|
|
68
|
+
*/
|
|
69
|
+
export function truncateToTokens(text: string, maxTokens: number, counter: TokenCounter): string {
|
|
70
|
+
if (counter.count(text) <= maxTokens) return text;
|
|
71
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
72
|
+
let out = '';
|
|
73
|
+
for (const w of words) {
|
|
74
|
+
const candidate = out ? `${out} ${w}` : w;
|
|
75
|
+
if (counter.count(`${candidate}…`) > maxTokens) break;
|
|
76
|
+
out = candidate;
|
|
77
|
+
}
|
|
78
|
+
if (!out) out = words[0] ?? '';
|
|
79
|
+
return `${out}…`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Renderings (each deterministic, no LLM)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** VERBATIM — the unit's full content, unchanged. */
|
|
87
|
+
export function verbatim(u: PrismUnit): string {
|
|
88
|
+
return u.content;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* EXTRACT — query-aware sentence extraction targeting ≤~40% of verbatim tokens.
|
|
93
|
+
*
|
|
94
|
+
* Sentences are scored by query-term overlap plus a first-sentence positional
|
|
95
|
+
* prior, deduped on normalized text, then greedily packed (highest score first,
|
|
96
|
+
* stable on original order) up to the ~40% token target. Selected sentences are
|
|
97
|
+
* emitted in ORIGINAL narrative order (unlike the score-ordered tail) so the
|
|
98
|
+
* extract reads coherently. Never returns empty for non-empty content: if the
|
|
99
|
+
* target is too small for any sentence, the single top-scored sentence is kept
|
|
100
|
+
* (a single-sentence unit thus extracts to itself, and is dropped later by the
|
|
101
|
+
* selector's cost-dominance guard rather than duplicating verbatim).
|
|
102
|
+
*/
|
|
103
|
+
export function extract(u: PrismUnit, query: string, counter: TokenCounter): string {
|
|
104
|
+
const sentences = splitSentences(u.content);
|
|
105
|
+
if (sentences.length <= 1) return u.content.trim();
|
|
106
|
+
|
|
107
|
+
const queryTokens = tokenize(query);
|
|
108
|
+
const target = Math.max(1, Math.floor(counter.count(u.content) * EXTRACT_TARGET_RATIO));
|
|
109
|
+
|
|
110
|
+
interface Scored {
|
|
111
|
+
text: string;
|
|
112
|
+
order: number;
|
|
113
|
+
score: number;
|
|
114
|
+
cost: number;
|
|
115
|
+
}
|
|
116
|
+
const scored: Scored[] = [];
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
sentences.forEach((text, order) => {
|
|
119
|
+
const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
120
|
+
if (seen.has(normalized)) return; // exact-normalized dedup, keep first occurrence
|
|
121
|
+
seen.add(normalized);
|
|
122
|
+
let overlap = 0;
|
|
123
|
+
for (const t of tokenize(text)) {
|
|
124
|
+
if (queryTokens.has(t)) overlap++;
|
|
125
|
+
}
|
|
126
|
+
scored.push({
|
|
127
|
+
text,
|
|
128
|
+
order,
|
|
129
|
+
score: overlap + (order === 0 ? EXTRACT_FIRST_SENTENCE_BONUS : 0),
|
|
130
|
+
cost: counter.count(text),
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Greedy pack by score (stable on original order), staying within the target.
|
|
135
|
+
const byScore = [...scored].sort((a, b) => b.score - a.score || a.order - b.order);
|
|
136
|
+
const chosen: Scored[] = [];
|
|
137
|
+
let used = 0;
|
|
138
|
+
for (const s of byScore) {
|
|
139
|
+
if (used + s.cost > target) continue; // skip; a smaller lower-scored sentence may still fit
|
|
140
|
+
chosen.push(s);
|
|
141
|
+
used += s.cost;
|
|
142
|
+
}
|
|
143
|
+
if (chosen.length === 0) chosen.push(byScore[0]!); // never empty for multi-sentence content
|
|
144
|
+
|
|
145
|
+
// Emit in original narrative order.
|
|
146
|
+
chosen.sort((a, b) => a.order - b.order);
|
|
147
|
+
return chosen.map((s) => s.text).join(' ');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* CLAIMS — the unit's pre-resolved claims as '- <claim>' bullet lines.
|
|
152
|
+
* UNAVAILABLE (null) when the caller pre-fetched no claims for this unit.
|
|
153
|
+
*/
|
|
154
|
+
export function claimsRendering(u: PrismUnit): string | null {
|
|
155
|
+
if (!u.claims?.length) return null;
|
|
156
|
+
return u.claims.map((c) => `- ${c}`).join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* MENTION — a one-line dereferenceable pointer:
|
|
161
|
+
* [<id>@<yyyy-mm-dd if timestamp>] <first sentence, ≤25 tokens>
|
|
162
|
+
* This is the answer-lossless floor: even a unit that loses the budget contest
|
|
163
|
+
* still leaves a citable trace the caller can dereference back to the full unit.
|
|
164
|
+
*/
|
|
165
|
+
export function mention(u: PrismUnit, counter: TokenCounter): string {
|
|
166
|
+
const first = splitSentences(u.content)[0] ?? u.content.trim();
|
|
167
|
+
const snippet = truncateToTokens(first, MENTION_MAX_TOKENS, counter);
|
|
168
|
+
const idPart = u.timestamp ? `${u.id}@${u.timestamp.toISOString().slice(0, 10)}` : u.id;
|
|
169
|
+
return `[${idPart}] ${snippet}`;
|
|
170
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shared helpers: the default token estimator and the recency decay curve.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Token-count heuristic: ~4 characters per token.
|
|
7
|
+
*
|
|
8
|
+
* Not a real tokenizer. Deliberately cheap and provider-agnostic (no
|
|
9
|
+
* dependency on any one model's BPE vocabulary): good enough for budget
|
|
10
|
+
* heuristics, not for exact billing. Pass your own `TokenCounter` to either
|
|
11
|
+
* selector when you need exact counts.
|
|
12
|
+
*/
|
|
13
|
+
export function estimateTokens(text: string): number {
|
|
14
|
+
return Math.ceil(text.length / 4);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A pluggable token counter. Defaults to the char/4 estimator; inject a real
|
|
19
|
+
* BPE tokenizer (e.g. `gpt-tokenizer`, tiktoken) for exact costs.
|
|
20
|
+
*/
|
|
21
|
+
export interface TokenCounter {
|
|
22
|
+
name: string;
|
|
23
|
+
count(text: string): number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Provider-agnostic default counter: the char/4 heuristic. */
|
|
27
|
+
export const CHAR4_COUNTER: TokenCounter = { name: 'char4', count: estimateTokens };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Similarity between two units in [0, 1], used for the novelty term in place
|
|
31
|
+
* of the built-in word-set Jaccard. 1 = same content, 0 = unrelated.
|
|
32
|
+
*/
|
|
33
|
+
export type Similarity<T> = (a: T, b: T) => number;
|
|
34
|
+
|
|
35
|
+
/** Cosine similarity of two equal-length vectors, clamped to [0, 1]. */
|
|
36
|
+
export function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number {
|
|
37
|
+
if (a.length !== b.length) throw new RangeError('cosineSimilarity: vectors must have the same length');
|
|
38
|
+
let dot = 0, na = 0, nb = 0;
|
|
39
|
+
for (let i = 0; i < a.length; i++) {
|
|
40
|
+
dot += a[i]! * b[i]!;
|
|
41
|
+
na += a[i]! * a[i]!;
|
|
42
|
+
nb += b[i]! * b[i]!;
|
|
43
|
+
}
|
|
44
|
+
if (na === 0 || nb === 0) return 0;
|
|
45
|
+
return Math.max(0, Math.min(1, dot / Math.sqrt(na * nb)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ready-made `similarity` option: cosine over each unit's `embedding`.
|
|
50
|
+
* Catches paraphrases that word overlap misses. Throws if a unit has no embedding.
|
|
51
|
+
*/
|
|
52
|
+
export function embeddingSimilarity<T extends { id: string; embedding?: ArrayLike<number> }>(a: T, b: T): number {
|
|
53
|
+
if (!a.embedding || !b.embedding) throw new Error(`embeddingSimilarity: unit ${!a.embedding ? a.id : b.id} has no embedding`);
|
|
54
|
+
return cosineSimilarity(a.embedding, b.embedding);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Default recency scale in days: score = e^(-age / 365). */
|
|
58
|
+
export const DEFAULT_RECENCY_DECAY_DAYS = 365;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Exponential recency decay in [0, 1]: 1 = brand new, → 0 as the unit ages.
|
|
62
|
+
* score = e^(-ageInDays / decayDays)
|
|
63
|
+
*/
|
|
64
|
+
export function recencyDecay(ageInDays: number, decayDays: number = DEFAULT_RECENCY_DECAY_DAYS): number {
|
|
65
|
+
if (ageInDays < 0) throw new RangeError('ageInDays must be >= 0');
|
|
66
|
+
if (decayDays <= 0) throw new RangeError('decayDays must be > 0');
|
|
67
|
+
return Math.exp(-ageInDays / decayDays);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Strips explicitly-`undefined`-valued keys from an options object before it
|
|
72
|
+
* gets spread over defaults. `{ ...DEFAULTS, ...opts }` alone is not enough:
|
|
73
|
+
* a caller that merges option layers can produce a key that IS present with
|
|
74
|
+
* value `undefined` rather than omitting it, and object spread lets that
|
|
75
|
+
* `undefined` win over the default, turning `budgetTokens - guaranteedTokens`
|
|
76
|
+
* into NaN and silently disabling every budget check (NaN comparisons are
|
|
77
|
+
* always false). Only present keys with a defined value override a default.
|
|
78
|
+
*/
|
|
79
|
+
export function stripUndefined<T extends object>(obj: T): Partial<T> {
|
|
80
|
+
const out: Partial<T> = {};
|
|
81
|
+
for (const key of Object.keys(obj) as (keyof T)[]) {
|
|
82
|
+
if (obj[key] !== undefined) out[key] = obj[key];
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|