@hicaru/pi-rlm 0.3.20 → 0.3.21
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/package.json +1 -1
- package/src/commands/rlm.ts +14 -7
- package/src/config/defaults.ts +33 -10
- package/src/config/settings.ts +6 -0
- package/src/config/skillstate.ts +236 -44
- package/src/core/budget.ts +7 -3
- package/src/core/compaction.ts +2 -2
- package/src/core/engine.ts +87 -19
- package/src/core/root-context.ts +74 -21
- package/src/core/root-digest.ts +48 -11
- package/src/core/root-state.ts +39 -12
- package/src/core/run-state.ts +86 -14
- package/src/core/session-archive.ts +174 -0
- package/src/core/types.ts +6 -0
- package/src/index.ts +142 -12
- package/src/mode/rlm-mode.ts +2 -2
- package/src/prompts/glossary.ts +34 -5
- package/src/prompts/native.ts +8 -2
- package/src/prompts/user.ts +4 -3
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +202 -36
- package/src/sandbox/py/scaffold.py +20 -5
- package/src/sandbox/py/worker.py +1 -1
- package/src/sandbox/sandbox-manager.ts +19 -0
- package/src/text/parsing.ts +133 -2
- package/src/text/tokens.ts +39 -4
- package/src/tool/repl-render.ts +38 -2
- package/src/tool/repl-tool.ts +34 -18
- package/src/tool/subcall-render.ts +7 -4
- package/src/ui/config-panel.ts +2 -2
- package/src/ui/intro.ts +1 -1
- package/src/ui/python-highlight.ts +49 -0
- package/src/ui/stage-cards.ts +192 -0
- package/src/ui/tree/tree-model.ts +69 -19
- package/src/ui/tree/tree-rows.ts +2 -1
- package/src/util/abort.ts +34 -0
- package/src/util/bm25.ts +170 -21
- package/src/util/errors.ts +1 -1
package/src/util/bm25.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* BM25 DUALITY, documented not accidental: this file and `sandbox/py/retrieval.py`
|
|
5
5
|
* (`_Bm25Index`) implement the SAME scoring for two runtimes — identical constants, identical
|
|
6
|
-
* tokenizer, identical idf/norm formulas — so a
|
|
7
|
-
* sandbox-side `search` would put it. Keep the two
|
|
6
|
+
* tokenizer + stemmer, identical idf/norm formulas, identical PRF/phrase-bonus pipeline — so a
|
|
7
|
+
* note ranked here lands in the same order the sandbox-side `search` would put it. Keep the two
|
|
8
|
+
* files in lockstep (AGENTS.md convention). Python-only extras (window overlap, glob
|
|
9
|
+
* pre-filter, adjacent-window merge) are window mechanics, not scoring, and stop at that file.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
const BM25_K1 = 1.2; // mirrors retrieval.py:_BM25_K1
|
|
@@ -14,18 +16,69 @@ const BM25_B = 0.75; // mirrors retrieval.py:_BM25_B
|
|
|
14
16
|
const TOKEN_SPLIT = /[^0-9A-Za-z]+/;
|
|
15
17
|
const CAMEL_SPLIT = /(?<=[a-z0-9])(?=[A-Z])/;
|
|
16
18
|
|
|
19
|
+
// BM25 V2 constants (twin: retrieval.py — identical values there).
|
|
20
|
+
const PRF_FEEDBACK_DOCS = 3; // top first-pass docs harvested for expansion terms
|
|
21
|
+
const PRF_EXPANSION_TERMS = 8; // max terms added by pseudo-relevance feedback
|
|
22
|
+
const PRF_EXPANSION_WEIGHT = 0.4; // Rocchio beta: expansion terms contribute at this weight
|
|
23
|
+
const PRF_MIN_DOCS = 12; // below this the corpus is too small to harvest from
|
|
24
|
+
const PHRASE_BONUS_WEIGHT = 0.25; // per adjacent query-bigram occurrence in a doc
|
|
25
|
+
const RERANK_POOL_MULT = 3; // phrase-bonus pool = top (k * mult) docs, capped
|
|
26
|
+
const RERANK_POOL_CAP = 60;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Light deterministic suffix stripper (TWIN: retrieval.py `_stem` — identical rules).
|
|
30
|
+
* A matching aid, not linguistics: different surface forms converge (files/file → fil,
|
|
31
|
+
* running/run → run, studies/study → studi); identical forms always map to themselves.
|
|
32
|
+
*/
|
|
33
|
+
function stem(t: string): string {
|
|
34
|
+
if (t.length <= 3) return t;
|
|
35
|
+
let r: string;
|
|
36
|
+
if (t.endsWith("ies")) {
|
|
37
|
+
r = t.slice(0, -3) + "i"; // studies → studi
|
|
38
|
+
} else if (t.endsWith("sses")) {
|
|
39
|
+
r = t.slice(0, -2); // classes → class
|
|
40
|
+
} else if (t.endsWith("es")) {
|
|
41
|
+
const stem2 = t.slice(0, -2);
|
|
42
|
+
r = /(x|ch|sh)$/.test(stem2) ? stem2 : t.slice(0, -1); // boxes → box, files → file
|
|
43
|
+
} else if (t.endsWith("s") && !/(ss|us|is)$/.test(t)) {
|
|
44
|
+
r = t.length > 4 ? t.slice(0, -1) : t; // cats → cat; keeps "was"/"its"
|
|
45
|
+
} else {
|
|
46
|
+
r = t;
|
|
47
|
+
}
|
|
48
|
+
if (r.endsWith("ing") && r.length >= 6) {
|
|
49
|
+
let base = r.slice(0, -3); // running → runn
|
|
50
|
+
if (base.length >= 4) {
|
|
51
|
+
if (base.length >= 2 && base[base.length - 1] === base[base.length - 2]) {
|
|
52
|
+
base = base.slice(0, -1); // runn → run
|
|
53
|
+
}
|
|
54
|
+
r = base; // "string" stays whole
|
|
55
|
+
}
|
|
56
|
+
} else if (r.endsWith("ed") && r.length >= 5) {
|
|
57
|
+
let base = r.slice(0, -2); // mapped → mapp
|
|
58
|
+
if (base.length >= 4) {
|
|
59
|
+
if (base.length >= 2 && base[base.length - 1] === base[base.length - 2]) {
|
|
60
|
+
base = base.slice(0, -1); // mapp → map
|
|
61
|
+
}
|
|
62
|
+
r = base;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (r.endsWith("y") && r.length > 3) r = r.slice(0, -1) + "i"; // study → studi (meets studies)
|
|
66
|
+
if (r.endsWith("e") && r.length > 3) r = r.slice(0, -1); // file → fil (meets files)
|
|
67
|
+
return r.length >= 3 ? r : t;
|
|
68
|
+
}
|
|
69
|
+
|
|
17
70
|
export function bm25Tokenize(text: string): readonly string[] {
|
|
18
71
|
const out: string[] = [];
|
|
19
72
|
for (const raw of text.split(TOKEN_SPLIT)) {
|
|
20
73
|
if (raw === "") continue;
|
|
21
74
|
const lowered = raw.toLowerCase();
|
|
22
|
-
out.push(lowered);
|
|
75
|
+
out.push(stem(lowered));
|
|
23
76
|
if (raw.length > 3) {
|
|
24
77
|
const parts = raw.split(CAMEL_SPLIT);
|
|
25
78
|
if (parts.length > 1) {
|
|
26
79
|
for (const part of parts) {
|
|
27
80
|
const piece = part.toLowerCase();
|
|
28
|
-
if (piece !== "" && piece !== lowered) out.push(piece);
|
|
81
|
+
if (piece !== "" && piece !== lowered) out.push(stem(piece));
|
|
29
82
|
}
|
|
30
83
|
}
|
|
31
84
|
}
|
|
@@ -43,14 +96,42 @@ export interface Bm25Hit<T> {
|
|
|
43
96
|
readonly score: number;
|
|
44
97
|
}
|
|
45
98
|
|
|
99
|
+
/** Ranking knobs (frozen option bags; defaults enable the full V2 pipeline). */
|
|
100
|
+
export interface Bm25RankOptions {
|
|
101
|
+
/** Pseudo-relevance-feedback query expansion (auto-off below PRF_MIN_DOCS docs). */
|
|
102
|
+
readonly prf?: boolean;
|
|
103
|
+
/** Adjacent-bigram phrase bonus over a re-rank pool. */
|
|
104
|
+
readonly phraseBonus?: boolean;
|
|
105
|
+
}
|
|
106
|
+
export const BM25_RANK_DEFAULTS: Readonly<Bm25RankOptions> = Object.freeze({
|
|
107
|
+
prf: true,
|
|
108
|
+
phraseBonus: true,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Positive-scoring docs as (idx, score) pairs, best first. The Python twin's score dict only
|
|
113
|
+
* ever holds docs with ≥1 matching term; a dense TS array would otherwise let zero-score docs
|
|
114
|
+
* into PRF feedback and the phrase pool — a divergence the parity suite catches.
|
|
115
|
+
*/
|
|
116
|
+
function positivePairs(scores: readonly number[]): readonly (readonly [number, number])[] {
|
|
117
|
+
const pairs: (readonly [number, number])[] = [];
|
|
118
|
+
for (let i = 0; i < scores.length; i++) {
|
|
119
|
+
if (scores[i] > 0) pairs.push([i, scores[i]]);
|
|
120
|
+
}
|
|
121
|
+
pairs.sort((a, b) => b[1] - a[1] || a[0] - b[0]);
|
|
122
|
+
return pairs;
|
|
123
|
+
}
|
|
124
|
+
|
|
46
125
|
/**
|
|
47
126
|
* Rank entries against a query, best first, top-k, score > 0 only.
|
|
48
|
-
*
|
|
127
|
+
* Pipeline mirrors retrieval.py:search — weighted scoring → PRF expansion → phrase bonus
|
|
128
|
+
* over a re-rank pool → top-k. Pre-allocated arrays; no growth in the scoring loops.
|
|
49
129
|
*/
|
|
50
130
|
export function bm25Rank<T>(
|
|
51
131
|
query: string,
|
|
52
132
|
entries: readonly Bm25Entry<T>[],
|
|
53
133
|
k: number,
|
|
134
|
+
options: Bm25RankOptions = BM25_RANK_DEFAULTS,
|
|
54
135
|
): readonly Bm25Hit<T>[] {
|
|
55
136
|
const n = entries.length;
|
|
56
137
|
if (n === 0 || k <= 0) return [];
|
|
@@ -70,28 +151,96 @@ export function bm25Rank<T>(
|
|
|
70
151
|
let totalLen = 0;
|
|
71
152
|
for (let i = 0; i < n; i++) totalLen += docLens[i];
|
|
72
153
|
const avgLen = totalLen > 0 ? totalLen / n : 1.0;
|
|
154
|
+
const idfOf = (term: string): number => {
|
|
155
|
+
const df = postings.get(term)?.length ?? 0;
|
|
156
|
+
return Math.log(1.0 + (n - df + 0.5) / (df + 0.5));
|
|
157
|
+
};
|
|
158
|
+
const score = (weights: ReadonlyMap<string, number>): number[] => {
|
|
159
|
+
const scores = new Array<number>(n).fill(0);
|
|
160
|
+
for (const [term, weight] of weights) {
|
|
161
|
+
const posting = postings.get(term);
|
|
162
|
+
if (posting === undefined) continue;
|
|
163
|
+
const idf = idfOf(term);
|
|
164
|
+
for (const [idx, tf] of posting) {
|
|
165
|
+
const norm = BM25_K1 * (1.0 - BM25_B + BM25_B * (docLens[idx] / avgLen));
|
|
166
|
+
scores[idx] += weight * idf * (tf * (BM25_K1 + 1.0)) / (tf + norm);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return scores;
|
|
170
|
+
};
|
|
73
171
|
|
|
74
|
-
const
|
|
75
|
-
const seen = new Set<string>();
|
|
172
|
+
const weights = new Map<string, number>();
|
|
76
173
|
for (const term of bm25Tokenize(query)) {
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
174
|
+
if (!weights.has(term)) weights.set(term, 1.0); // Python twin: first occurrence wins
|
|
175
|
+
}
|
|
176
|
+
let scores = score(weights);
|
|
177
|
+
const basePositive = positivePairs(scores);
|
|
178
|
+
if (options.prf !== false && n >= PRF_MIN_DOCS && basePositive.length > 0) {
|
|
179
|
+
const feedback = basePositive.slice(0, PRF_FEEDBACK_DOCS).map(([idx]) => idx);
|
|
180
|
+
const tf = new Map<string, number>();
|
|
181
|
+
for (const idx of feedback) {
|
|
182
|
+
for (const term of bm25Tokenize(entries[idx].text)) {
|
|
183
|
+
tf.set(term, (tf.get(term) ?? 0) + 1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Exclude query terms BEFORE slicing — the twin (retrieval.py:_expansion_terms) excludes
|
|
187
|
+
// first, then takes the top-8; slicing first would let query terms eat expansion slots.
|
|
188
|
+
const ranked: readonly (readonly [number, string])[] = Array.from(tf, ([term, f]) => [f * idfOf(term), term] as const)
|
|
189
|
+
.filter(([, term]) => !weights.has(term))
|
|
190
|
+
.sort((a, b) => b[0] - a[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
191
|
+
.slice(0, PRF_EXPANSION_TERMS);
|
|
192
|
+
let added = false;
|
|
193
|
+
for (const [, term] of ranked) {
|
|
194
|
+
if (!weights.has(term)) {
|
|
195
|
+
weights.set(term, PRF_EXPANSION_WEIGHT);
|
|
196
|
+
added = true;
|
|
197
|
+
}
|
|
86
198
|
}
|
|
199
|
+
if (added) scores = score(weights);
|
|
200
|
+
}
|
|
201
|
+
if (scores.every((s) => s <= 0)) return [];
|
|
202
|
+
|
|
203
|
+
// Pool of (idx, score) pairs — the phrase bonus MUTATES the score (twin parity), so the
|
|
204
|
+
// reported value must come from the pool pairs, not the pre-bonus score array.
|
|
205
|
+
const positive = positivePairs(scores);
|
|
206
|
+
const poolN = Math.min(positive.length, RERANK_POOL_CAP, Math.max(k * RERANK_POOL_MULT, PRF_FEEDBACK_DOCS));
|
|
207
|
+
let pool = positive.slice(0, poolN);
|
|
208
|
+
const seq = bm25Tokenize(query); // raw sequence — repeated terms make self-bigrams, like Python
|
|
209
|
+
const bigrams: (readonly [string, string])[] = [];
|
|
210
|
+
if (options.phraseBonus !== false) {
|
|
211
|
+
for (let i = 0; i < seq.length - 1; i++) {
|
|
212
|
+
if (seq[i] !== seq[i + 1] && !bigrams.some(([a, b]) => a === seq[i] && b === seq[i + 1])) {
|
|
213
|
+
bigrams.push([seq[i], seq[i + 1]]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (bigrams.length > 0) {
|
|
218
|
+
const boosted = pool.map(([idx, s]) => {
|
|
219
|
+
const toks = bm25Tokenize(entries[idx].text);
|
|
220
|
+
const pos = new Map<string, number[]>();
|
|
221
|
+
for (let i = 0; i < toks.length; i++) {
|
|
222
|
+
const list = pos.get(toks[i]);
|
|
223
|
+
if (list !== undefined) list.push(i);
|
|
224
|
+
else pos.set(toks[i], [i]);
|
|
225
|
+
}
|
|
226
|
+
let bonus = 0;
|
|
227
|
+
for (const [a, b] of bigrams) {
|
|
228
|
+
const pa = pos.get(a);
|
|
229
|
+
const pb = pos.get(b);
|
|
230
|
+
if (pa !== undefined && pb !== undefined && pa.some((x) => pb.some((y) => y === x + 1))) {
|
|
231
|
+
bonus += PHRASE_BONUS_WEIGHT * Math.max(idfOf(a), idfOf(b));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return [idx, s + bonus] as const;
|
|
235
|
+
});
|
|
236
|
+
boosted.sort((x, y) => y[1] - x[1] || x[0] - y[0]);
|
|
237
|
+
pool = boosted;
|
|
87
238
|
}
|
|
88
239
|
|
|
89
|
-
const order = Array.from({ length: n }, (_, i) => i);
|
|
90
|
-
order.sort((a, b) => scores[b] - scores[a] || a - b); // deterministic tie-break
|
|
91
240
|
const out: Bm25Hit<T>[] = [];
|
|
92
|
-
for (
|
|
93
|
-
|
|
94
|
-
if (
|
|
241
|
+
for (const [idx, s] of pool) {
|
|
242
|
+
if (out.length >= k) break;
|
|
243
|
+
if (s > 0) out.push({ item: entries[idx].item, score: s });
|
|
95
244
|
}
|
|
96
245
|
return out;
|
|
97
246
|
}
|
package/src/util/errors.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function err<T = never, E = string>(error: E): Result<T, E> {
|
|
|
12
12
|
return { ok: false, error };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const ERROR_PREFIX = "Error:";
|
|
15
|
+
export const ERROR_PREFIX = "Error:";
|
|
16
16
|
|
|
17
17
|
export function formatError(message: string): string {
|
|
18
18
|
return `${ERROR_PREFIX} ${message}`;
|