@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aman Jha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,313 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CABAR
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Context-Budget-Aware Retrieval.** Your retriever found 24 chunks. CABAR decides which ones are actually worth the prompt tokens, sends those, and squeezes the rest into a short tail instead of dropping them.
|
|
4
|
+
|
|
5
|
+
Pure TypeScript. Zero dependencies. No LLM calls, no I/O, embeddings optional. Deterministic: same input, same output.
|
|
6
|
+
|
|
7
|
+
On LongMemEval-S it keeps **90% of the answer evidence with 79% fewer tokens**, where top-k at the same token spend keeps 79%. In a 72B-reader run on multi-session questions, accuracy went from **35% to 80%** with 84% fewer tokens, because the reader stopped drowning in context. ([Results](#results), including where it loses.)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install @whyparabola/cabar
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The problem
|
|
16
|
+
|
|
17
|
+
Most RAG and agent-memory pipelines do this:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
const chunks = await retrieve(query, { topK: 24 });
|
|
21
|
+
prompt += chunks.map((c) => c.content).join('\n'); // send everything
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That costs you three ways:
|
|
25
|
+
|
|
26
|
+
1. **Tokens.** Around 20k tokens per query in our memory system, mostly redundant.
|
|
27
|
+
2. **Latency.** Answer time grows with prompt length. In our profile, ~80% of answer latency was the LLM reading context.
|
|
28
|
+
3. **Accuracy.** Past a point, more context makes answers *worse*: the one fact that matters gets lost among near-duplicates and weak hits.
|
|
29
|
+
|
|
30
|
+
Top-K truncation is not the fix. It keeps duplicates (two chunks saying the same thing both score high) and silently drops the long tail, where multi-session and temporal questions usually find their answer.
|
|
31
|
+
|
|
32
|
+
## How CABAR works
|
|
33
|
+
|
|
34
|
+
CABAR treats context as a **knapsack**: every chunk has a value and a token cost, and the prompt has a budget.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
utility(u, S) = relevance(u) × recency(u) × novelty(u, S)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- **relevance**: the score from your retriever, normalized to [0, 1].
|
|
41
|
+
- **recency**: `λ · e^(−age/365d) + (1 − λ)`. A gentle preference for newer chunks (λ = 0.3), never a cliff.
|
|
42
|
+
- **novelty**: `1 − max Jaccard(u, s)` over chunks `s` already selected. Recomputed every round, so a near-duplicate of something already picked is worth almost nothing.
|
|
43
|
+
|
|
44
|
+
Then:
|
|
45
|
+
|
|
46
|
+
1. **Greedy fill by utility per token.** Each round, pick the chunk with the best marginal utility per token that still fits the budget.
|
|
47
|
+
2. **Best-single swap.** If one chunk alone beats the whole greedy set, take it instead. This is the classic budgeted-max-coverage trick (Khuller, Moss & Naor) that gives greedy a constant-factor guarantee.
|
|
48
|
+
3. **Adaptive stopping** (optional). After `minKeep` chunks, stop when the next candidate falls off a *relevance cliff* (below 40% of the top score) or adds too little novelty (< 0.2). Noisy pools stop early, and pools with a real relevant tail keep more. It doesn't look at the question type, only at the shape of the pool.
|
|
49
|
+
4. **Summarize the tail, never drop it.** Every unselected chunk above `tauFloor` goes into a compact extractive summary: the sentences that overlap the query most, within a hard token cap. If that summary would cost more than the raw chunks, it sends the raw chunks instead.
|
|
50
|
+
|
|
51
|
+
Selection is O(n · k²) similarity checks for n candidates and k selected: sub-millisecond for typical pools, negligible next to the LLM call it shrinks.
|
|
52
|
+
|
|
53
|
+
### Prism: multi-fidelity (CABAR-2)
|
|
54
|
+
|
|
55
|
+
CABAR's choice is binary: a chunk is sent in full or goes to the tail. **Prism** turns that into a ladder. Each chunk can be rendered at four fidelities:
|
|
56
|
+
|
|
57
|
+
| Fidelity | What gets sent | Utility kept (default) |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `verbatim` | the full chunk | 1.00 |
|
|
60
|
+
| `extract` | query-relevant sentences, ≤ ~40% of the tokens | 0.85 |
|
|
61
|
+
| `claims` | pre-extracted facts you attach to the chunk | 0.75 |
|
|
62
|
+
| `mention` | a one-line pointer: `[id@date] first sentence…` | 0.30 |
|
|
63
|
+
|
|
64
|
+
Prism solves this as a **multiple-choice knapsack**. Each move either adds a chunk at some fidelity or upgrades an already-selected chunk to a richer one, always taking the densest move. Under tight budgets it beats all-or-nothing, because three extracts often carry more than one verbatim chunk.
|
|
65
|
+
|
|
66
|
+
Prism also emits selected chunks in **timestamp order, not score order**, so the same chunks land in the same prompt positions turn after turn. That keeps your provider's prompt cache warm. An optional hysteresis bonus (`hysteresisBonus`) makes it stick with last turn's choices for the same reason.
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { CabarSelector, normalizeScores, toContext } from '@whyparabola/cabar';
|
|
72
|
+
|
|
73
|
+
const hits = await retrieve(query); // [{ id, content, score, timestamp? }]
|
|
74
|
+
|
|
75
|
+
const result = CabarSelector.select(normalizeScores(hits), query); // ≤ 8 chunks, ≤ 4000 tokens
|
|
76
|
+
const context = toContext(result); // selected chunks + "Other possibly relevant notes:" tail
|
|
77
|
+
|
|
78
|
+
console.log(`${result.stats.candidateTokens} → ${result.stats.utilizedTokens} tokens`);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Normalize your scores first.** CABAR expects relevance in [0, 1] with the best hit near 1. `tauFloor` and the adaptive cliff are fractions of that scale. Raw scores rarely fit:
|
|
82
|
+
- Cosine similarities bunch up in 0.7–0.85, so nothing ever falls below `tauFloor`.
|
|
83
|
+
- BM25 and reranker logits are unbounded.
|
|
84
|
+
- Vector-store distances run the wrong way.
|
|
85
|
+
|
|
86
|
+
`normalizeScores(hits)` maps any of them to [0, 1] (min-max by default). For distances, pass `{ higherIsBetter: false }`.
|
|
87
|
+
|
|
88
|
+
**Start with the defaults.** In the evidence benchmarks below, fixed CABAR had the best balance of evidence kept and tokens spent.
|
|
89
|
+
|
|
90
|
+
**Capped-adaptive** (`adaptive: true, capToMaxUnits: true`) saves more tokens (87% vs 79%) by stopping at the relevance cliff. The cost: it keeps less evidence verbatim (75% vs 90%), though most of the rest still reaches the prompt through the tail. How early it stops depends on the shape of your scores. Try it in shadow mode against your own pools before switching.
|
|
91
|
+
|
|
92
|
+
### Vercel AI SDK (or any LLM call)
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { generateText } from 'ai';
|
|
96
|
+
import { CabarSelector, normalizeScores, toContext } from '@whyparabola/cabar';
|
|
97
|
+
|
|
98
|
+
const result = CabarSelector.select(normalizeScores(hits), question, { budgetTokens: 2000 });
|
|
99
|
+
|
|
100
|
+
const { text } = await generateText({
|
|
101
|
+
model,
|
|
102
|
+
system: `Answer from these notes:\n\n${toContext(result, { format: (t, id) => `<note id="${id}">${t}</note>` })}`,
|
|
103
|
+
prompt: question,
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### LangChain.js
|
|
108
|
+
|
|
109
|
+
`@whyparabola/cabar/langchain` is a document compressor. Drop it into `ContextualCompressionRetriever`:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { ContextualCompressionRetriever } from '@langchain/classic/retrievers/contextual_compression'; // 'langchain/retrievers/contextual_compression' on LangChain 0.3
|
|
113
|
+
import { CabarCompressor } from '@whyparabola/cabar/langchain';
|
|
114
|
+
|
|
115
|
+
const retriever = new ContextualCompressionRetriever({
|
|
116
|
+
baseCompressor: new CabarCompressor({ budgetTokens: 2000 }),
|
|
117
|
+
baseRetriever: vectorStore.asRetriever(24),
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- **Scores:** it reads them from `metadata.relevance_score` or `metadata.score` and normalizes them. If they're missing, it falls back to the retriever's order.
|
|
122
|
+
- **Tail:** it comes back as one extra `Document` with `metadata.cabar = 'tail'`. Pass `includeTail: false` to skip it.
|
|
123
|
+
|
|
124
|
+
`@langchain/core` is an optional peer dependency.
|
|
125
|
+
|
|
126
|
+
### Catch paraphrases with embeddings
|
|
127
|
+
|
|
128
|
+
The default novelty check is word overlap, so it misses paraphrases. If your chunks already have embeddings, use those instead:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { CabarSelector, embeddingSimilarity } from '@whyparabola/cabar';
|
|
132
|
+
|
|
133
|
+
// each chunk: { id, content, score, embedding: number[] }
|
|
134
|
+
const result = CabarSelector.select(chunks, query, { similarity: embeddingSimilarity });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Any `(a, b) => number` in [0, 1] works as `similarity`. It's available in both selectors.
|
|
138
|
+
|
|
139
|
+
### Exact token counts
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { encode } from 'gpt-tokenizer';
|
|
143
|
+
|
|
144
|
+
CabarSelector.select(chunks, query, { tokenCounter: { name: 'o200k', count: (t) => encode(t).length } });
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### See why, and try it in shadow mode
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { CabarSelector, shadowReport } from '@whyparabola/cabar';
|
|
151
|
+
|
|
152
|
+
const { decisions } = CabarSelector.select(chunks, query, { explain: true });
|
|
153
|
+
// [{ id: 'u7', status: 'tail', reason: 'redundant with u2 (similarity 0.91)', tokens: 84, ... }, ...]
|
|
154
|
+
|
|
155
|
+
const report = shadowReport(chunks, query); // doesn't change what you send
|
|
156
|
+
log({ saved: report.savedFraction, dropped: report.droppedIds });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Shadow mode is the safe rollout:
|
|
160
|
+
1. Keep sending what you send today.
|
|
161
|
+
2. Log what CABAR would have sent.
|
|
162
|
+
3. Compare answers on a sample, then switch.
|
|
163
|
+
|
|
164
|
+
### Prism
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { PrismSelector, toContext } from '@whyparabola/cabar';
|
|
168
|
+
|
|
169
|
+
const result = PrismSelector.select(chunksWithClaims, query, { budgetTokens: 2000 });
|
|
170
|
+
for (const s of result.selected) console.log(s.unit.id, s.fidelity, s.rendered);
|
|
171
|
+
const context = toContext(result);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Run the example: `npm run example` (source: [`examples/basic.ts`](examples/basic.ts)).
|
|
175
|
+
|
|
176
|
+
## Options
|
|
177
|
+
|
|
178
|
+
Both selectors share these (Prism's defaults in parentheses where different):
|
|
179
|
+
|
|
180
|
+
| Option | Default | What it does |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `budgetTokens` | 4000 | Token budget for `guaranteedTokens` + selected chunks |
|
|
183
|
+
| `maxUnits` | 8 (12) | Max distinct chunks selected |
|
|
184
|
+
| `tailBudgetTokens` | 600 | Hard cap for the tail, on top of the budget |
|
|
185
|
+
| `tauFloor` | 0.15 | Chunks scoring below this are dropped, not summarized |
|
|
186
|
+
| `recencyLambda` | 0.3 | 0 = ignore age entirely (safest for cross-session questions) |
|
|
187
|
+
| `recencyDecayDays` | 365 | Scale of the exponential recency decay |
|
|
188
|
+
| `noveltyWeight` | 1 | 0 = ignore redundancy, 1 = full MMR-style novelty |
|
|
189
|
+
| `guaranteedTokens` | 0 | Tokens you've already spent (system prompt, a facts block), pre-charged against the budget |
|
|
190
|
+
| `now` | `new Date()` | Pass a fixed date for reproducible results |
|
|
191
|
+
| `tokenCounter` | chars / 4 | `{ name, count(text) }`, e.g. a real BPE tokenizer |
|
|
192
|
+
| `similarity` | word-set Jaccard | `(a, b) => [0, 1]` for novelty, e.g. `embeddingSimilarity` |
|
|
193
|
+
| `adaptive` | false | Enable the relevance-cliff/novelty stopping rule |
|
|
194
|
+
| `minKeep` | 3 | Chunks kept before the adaptive rule can stop |
|
|
195
|
+
| `relFloor` | 0.4 | Adaptive: stop below this fraction of the top score |
|
|
196
|
+
| `novFloor` | 0.2 | Adaptive: stop below this novelty |
|
|
197
|
+
| `maxCeilingTokens` | 4000 | Adaptive: the hard token bound (replaces `budgetTokens`) |
|
|
198
|
+
| `capToMaxUnits` | false | CABAR only: adaptive also respects `maxUnits` |
|
|
199
|
+
| `costExponent` | 1 | CABAR only: density = utility / tokens^x. Lower it (0.5) when answers live in long chunks |
|
|
200
|
+
| `explain` | false | CABAR only: return `decisions`, the reason for every chunk |
|
|
201
|
+
|
|
202
|
+
Prism only: `fidelityFactors`, `hysteresisBonus`, `prevSelected`, and `claims?: string[]` on each chunk.
|
|
203
|
+
|
|
204
|
+
## Results
|
|
205
|
+
|
|
206
|
+
### 1. Evidence recall on LongMemEval-S (reproducible, no LLM)
|
|
207
|
+
|
|
208
|
+
[`bench/evidence-recall.ts`](bench/evidence-recall.ts) runs in about 10 seconds on the public [LongMemEval-S](https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned) dataset:
|
|
209
|
+
1. For each of the 500 questions, a plain BM25 retriever pulls the top 24 chat turns from the haystack.
|
|
210
|
+
2. Each selector picks from those 24 turns.
|
|
211
|
+
3. The script counts how many of the turns LongMemEval marks as answer evidence survive, and what they cost.
|
|
212
|
+
|
|
213
|
+
The 57 questions where BM25 missed all evidence are skipped, leaving 443. Full per-type tables: [`bench/longmemeval-results.md`](bench/longmemeval-results.md).
|
|
214
|
+
|
|
215
|
+
| Selector | Evidence kept verbatim | All evidence kept | Evidence kept or in tail | Avg tokens | Tokens saved |
|
|
216
|
+
|---|---|---|---|---|---|
|
|
217
|
+
| Send all 24 | 100% | 100% | 100% | 6,756 | 0% |
|
|
218
|
+
| Top-8 by score | 84.6% | 79.2% | 84.6% | 2,052 | 70% |
|
|
219
|
+
| **CABAR (defaults)** | **90.3%** | **86.2%** | **93.6%** | **1,432** | **79%** |
|
|
220
|
+
| Top-k at CABAR's token spend | 78.8% | 74.3% | 78.8% | 1,728 | 74% |
|
|
221
|
+
| CABAR capped-adaptive | 75.1% | 66.8% | 91.6% | 890 | 87% |
|
|
222
|
+
| Top-k at capped-adaptive's spend | 72.6% | 66.4% | 72.6% | 1,144 | 83% |
|
|
223
|
+
| Prism (defaults) | 93.6% | 91.0% | 94.6% | 2,481 | 63% |
|
|
224
|
+
|
|
225
|
+
- **Multi-session questions** (118), where the answer is spread across several sessions: CABAR keeps 87.8% of the evidence, top-k at the same spend 65.9%, top-8 75.5%. Removing duplicates makes room for the second and third piece of evidence.
|
|
226
|
+
- **Where it loses:** on single-session-assistant questions (52), the evidence is usually one long assistant reply. CABAR ranks by utility *per token*, so it keeps 88.5% verbatim vs 94.2% for top-k (the tail still reaches 98.1%). Capped-adaptive drops to 61.5%.
|
|
227
|
+
- **`costExponent: 0.5` fixes that case** (94.2%, level with top-k), but overall evidence drops to 88.8% and token savings to 73%. That's why it's an option, not the default.
|
|
228
|
+
- BM25 plus min-max normalization is a weak, steep-scoring retriever. Your numbers will move with your retriever.
|
|
229
|
+
|
|
230
|
+
### 2. Multi-hop evidence on HotpotQA (reproducible, no LLM)
|
|
231
|
+
|
|
232
|
+
[`bench/hotpot-evidence.ts`](bench/hotpot-evidence.ts) runs in 4 seconds on all 7,405 dev questions (distractor setting):
|
|
233
|
+
- Each question comes with 10 paragraphs: the 2 it needs and 8 distractors chosen to look relevant.
|
|
234
|
+
- BM25 scores the 10 paragraphs, and each selector keeps k of them.
|
|
235
|
+
- The script counts how often **both** gold paragraphs survive.
|
|
236
|
+
|
|
237
|
+
Full table: [`bench/hotpot-results.md`](bench/hotpot-results.md).
|
|
238
|
+
|
|
239
|
+
| Paragraphs kept | Top-k: both gold kept | CABAR: both gold kept | CABAR tokens vs top-k |
|
|
240
|
+
|---|---|---|---|
|
|
241
|
+
| 2 | 31.5% | **32.7%** | 22% fewer |
|
|
242
|
+
| 3 | 48.1% | **50.6%** | 20% fewer |
|
|
243
|
+
| 4 | 59.4% | **62.7%** | 17% fewer |
|
|
244
|
+
|
|
245
|
+
Small but consistent: a few points more evidence on fewer tokens. The gain is much smaller than on LongMemEval because HotpotQA paragraphs come from different articles and rarely repeat each other, so there's little redundancy to remove. CABAR helps most where chunks overlap: chat logs, memory stores, tickets.
|
|
246
|
+
|
|
247
|
+
### 3. Answer accuracy with a 72B reader (from MetaMemory)
|
|
248
|
+
|
|
249
|
+
Measured inside MetaMemory, the agent-memory system CABAR was built for. Setup: LongMemEval-S, Qwen2.5-72B-Instruct-AWQ as reader and judge, tokens counted with o200k BPE.
|
|
250
|
+
|
|
251
|
+
| Test set | Send everything | CABAR | Token reduction |
|
|
252
|
+
|---|---|---|---|
|
|
253
|
+
| Mixed, all question types (24 q) | 19/24 @ 7,780 tok | 19/24 @ 2,158 tok | 72% |
|
|
254
|
+
| Multi-session (18 q) + abstention (2 q) | 7/20 @ 15,156 tok | **16/20** @ 2,406 tok | 84% |
|
|
255
|
+
| Generic answer prompt, 6 types × 10 (60 q) | 40/60 @ 8,578 tok | **45/60** @ 2,051 tok | 76% |
|
|
256
|
+
| Smoke test (6 q) | 6/6 @ 8,285 tok | 5/6 @ 2,064 tok | 75% |
|
|
257
|
+
|
|
258
|
+
- **Adaptive CABAR:** on a rerun of the multi-session set it scored 16/20 at 2,137 tokens. On the 60-question set it scored 43/60 at 3,544.
|
|
259
|
+
- **Prism:** 20/24 on the mixed set, but **13/20 on multi-session**, worse than plain CABAR there.
|
|
260
|
+
- **Capped-adaptive:** checked only by which chunks it picks (on concentrated pools, exactly the same as fixed CABAR). Its accuracy wasn't benchmarked on its own.
|
|
261
|
+
|
|
262
|
+
**Read these honestly:**
|
|
263
|
+
- The samples are small: 20 questions gives a 95% interval of roughly ±20 points.
|
|
264
|
+
- One reader model, one dataset.
|
|
265
|
+
- This harness lives in MetaMemory, not in this repo.
|
|
266
|
+
|
|
267
|
+
The direction is consistent (equal or better accuracy at a quarter of the tokens), but treat it as evidence, not a guarantee. Run CABAR in shadow mode on your own traffic first: log what it *would* send next to what you send now, and compare answers.
|
|
268
|
+
|
|
269
|
+
### 4. Correctness
|
|
270
|
+
|
|
271
|
+
[`test/properties.test.ts`](test/properties.test.ts) checks the guarantees on 500 random pools each:
|
|
272
|
+
- The budget, `maxUnits` and the tail cap are never exceeded.
|
|
273
|
+
- Output is deterministic.
|
|
274
|
+
- Nothing at or above `tauFloor` is silently dropped.
|
|
275
|
+
- An exact duplicate is never selected twice.
|
|
276
|
+
- Prism always emits chunks in cache-stable order.
|
|
277
|
+
|
|
278
|
+
On 30,000 random selections, this package produced byte-identical output to the original MetaMemory implementation.
|
|
279
|
+
|
|
280
|
+
## When to use it
|
|
281
|
+
|
|
282
|
+
**Good fit:**
|
|
283
|
+
- Agent memory and RAG pipelines that retrieve 10–50 chunks per query.
|
|
284
|
+
- Pools with lots of near-duplicate chunks (chat logs, memory stores, support tickets).
|
|
285
|
+
- Anywhere prompt tokens cost you latency or money.
|
|
286
|
+
|
|
287
|
+
**Not a fit:**
|
|
288
|
+
- If you already send fewer than ~5 short chunks, there's little to trim.
|
|
289
|
+
- Pools of distinct, non-overlapping documents gain only a little (see HotpotQA above).
|
|
290
|
+
- Without `similarity: embeddingSimilarity`, novelty is lexical (word-set Jaccard), so paraphrases with different wording aren't caught.
|
|
291
|
+
|
|
292
|
+
**Know the quirk:** the greedy fill ranks by utility *per token*, so it prefers short chunks. When one long chunk holds the answer (a long assistant reply, a big doc section), top-k can beat it: see single-session-assistant above. `costExponent: 0.5`, or chunking documents to similar sizes, keeps this in check.
|
|
293
|
+
|
|
294
|
+
## Design rules
|
|
295
|
+
|
|
296
|
+
- **Pure and deterministic.** No clock reads (pass `now`), no network, no randomness. The same pool always yields the same prompt, which makes it replayable and testable.
|
|
297
|
+
- **Provider-agnostic.** The default token cost is `chars / 4`. Inject a real tokenizer when you need exact numbers.
|
|
298
|
+
- **Never silently drop something relevant.** Anything above `tauFloor` that misses the budget still reaches the prompt, as a summary sentence (CABAR) or a mention line (Prism).
|
|
299
|
+
|
|
300
|
+
## Development
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
npm install
|
|
304
|
+
npm test # 100 tests (unit + property)
|
|
305
|
+
npm run typecheck
|
|
306
|
+
npm run build # ESM + CJS + types into dist/
|
|
307
|
+
npx tsx bench/evidence-recall.ts longmemeval_s_cleaned.json
|
|
308
|
+
npx tsx bench/hotpot-evidence.ts hotpot_dev_distractor_v1.json
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
[MIT](LICENSE) © Aman Jha. Built for MetaMemory, extracted as a standalone library.
|
|
@@ -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 };
|