@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/commands/rlm.ts +14 -7
  3. package/src/config/defaults.ts +33 -10
  4. package/src/config/settings.ts +6 -0
  5. package/src/config/skillstate.ts +236 -44
  6. package/src/core/budget.ts +7 -3
  7. package/src/core/compaction.ts +2 -2
  8. package/src/core/engine.ts +87 -19
  9. package/src/core/root-context.ts +74 -21
  10. package/src/core/root-digest.ts +48 -11
  11. package/src/core/root-state.ts +39 -12
  12. package/src/core/run-state.ts +86 -14
  13. package/src/core/session-archive.ts +174 -0
  14. package/src/core/types.ts +6 -0
  15. package/src/index.ts +142 -12
  16. package/src/mode/rlm-mode.ts +2 -2
  17. package/src/prompts/glossary.ts +34 -5
  18. package/src/prompts/native.ts +8 -2
  19. package/src/prompts/user.ts +4 -3
  20. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  21. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  22. package/src/sandbox/py/retrieval.py +202 -36
  23. package/src/sandbox/py/scaffold.py +20 -5
  24. package/src/sandbox/py/worker.py +1 -1
  25. package/src/sandbox/sandbox-manager.ts +19 -0
  26. package/src/text/parsing.ts +133 -2
  27. package/src/text/tokens.ts +39 -4
  28. package/src/tool/repl-render.ts +38 -2
  29. package/src/tool/repl-tool.ts +34 -18
  30. package/src/tool/subcall-render.ts +7 -4
  31. package/src/ui/config-panel.ts +2 -2
  32. package/src/ui/intro.ts +1 -1
  33. package/src/ui/python-highlight.ts +49 -0
  34. package/src/ui/stage-cards.ts +192 -0
  35. package/src/ui/tree/tree-model.ts +69 -19
  36. package/src/ui/tree/tree-rows.ts +2 -1
  37. package/src/util/abort.ts +34 -0
  38. package/src/util/bm25.ts +170 -21
  39. 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 note ranked here lands in the same order the
7
- * sandbox-side `search` would put it. Keep the two files in lockstep (AGENTS.md convention).
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
- * Pre-allocated score/index arrays; no growth in the scoring loops.
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 scores = new Array<number>(n).fill(0);
75
- const seen = new Set<string>();
172
+ const weights = new Map<string, number>();
76
173
  for (const term of bm25Tokenize(query)) {
77
- if (seen.has(term)) continue; // Python twin scores set(terms)
78
- seen.add(term);
79
- const posting = postings.get(term);
80
- if (posting === undefined) continue;
81
- const df = posting.length;
82
- const idf = Math.log(1.0 + (n - df + 0.5) / (df + 0.5));
83
- for (const [idx, tf] of posting) {
84
- const norm = BM25_K1 * (1.0 - BM25_B + BM25_B * (docLens[idx] / avgLen));
85
- scores[idx] += (idf * (tf * (BM25_K1 + 1.0))) / (tf + norm);
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 (let i = 0; i < n && out.length < k; i++) {
93
- const idx = order[i];
94
- if (scores[idx] > 0) out.push({ item: entries[idx].item, score: scores[idx] });
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
  }
@@ -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}`;