entroly-wasm 0.9.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/js/autotune.js ADDED
@@ -0,0 +1,627 @@
1
+ // Entroly Autotune v2 — Cross-Session Weight Optimization
2
+ //
3
+ // Learns optimal scoring weights (recency, frequency, semantic, entropy)
4
+ // from real developer feedback across sessions. Replaces synthetic benchmarks
5
+ // with a feedback journal that persists between restarts.
6
+ //
7
+ // Key mechanisms:
8
+ // - Reward-weighted regression with advantage normalization
9
+ // - Exponential decay for non-stationarity (γ=0.995)
10
+ // - Per-dimension adaptive step sizes via SNR
11
+ // - Task-conditioned profiles (different weights for debugging vs features)
12
+ //
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ // ── Constants ──
18
+ const WEIGHT_KEYS = ['w_r', 'w_f', 'w_s', 'w_e'];
19
+ const DEFAULTS = { w_r: 0.30, w_f: 0.25, w_s: 0.25, w_e: 0.20, exploration: 0.1 };
20
+ const DECAY_GAMMA = 0.995; // Per-episode decay (half-life ≈ 138 episodes)
21
+ const WARMUP_EPISODES = 8; // Min episodes before tuning
22
+ const MAX_BLEND_RATE = 0.5; // Max single-step movement toward optimal
23
+ const EXPLORATION_C = 0.1; // UCB exploration constant
24
+ const MIN_WEIGHT = 0.05; // Floor per weight
25
+ const MAX_WEIGHT = 0.80; // Ceiling per weight
26
+ const JOURNAL_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
27
+
28
+
29
+ // --- Feedback Journal ---
30
+
31
+ class FeedbackJournal {
32
+ constructor(journalDir) {
33
+ this.journalPath = path.join(journalDir, 'feedback_journal.jsonl');
34
+ try { fs.mkdirSync(journalDir, { recursive: true }); } catch {}
35
+ this._cache = null;
36
+ }
37
+
38
+ /**
39
+ * Log a feedback episode.
40
+ * @param {object} episode
41
+ * @param {object} episode.weights - {w_r, w_f, w_s, w_e} at time of last optimize
42
+ * @param {string[]} episode.selectedSources - Sources selected by optimizer
43
+ * @param {number} episode.selectedCount - Fragment count
44
+ * @param {number} episode.tokenBudget - Budget used
45
+ * @param {string} episode.query - Query string
46
+ * @param {number} episode.reward - +1.0 success, -1.0 failure, or continuous [-1,1]
47
+ * @param {number} episode.turn - Session turn number
48
+ */
49
+ log(episode) {
50
+ const entry = {
51
+ t: Date.now(),
52
+ w: episode.weights,
53
+ n: episode.selectedCount,
54
+ src: (episode.selectedSources || []).slice(0, 15),
55
+ q: (episode.query || '').slice(0, 120),
56
+ r: Math.max(-1, Math.min(1, episode.reward)), // clamp to [-1, 1]
57
+ turn: episode.turn,
58
+ bgt: episode.tokenBudget,
59
+ };
60
+ try {
61
+ fs.appendFileSync(this.journalPath, JSON.stringify(entry) + '\n');
62
+ this._cache = null;
63
+ } catch {}
64
+ }
65
+
66
+ /**
67
+ * Load episodes, filtered by max age.
68
+ */
69
+ load(maxAge = JOURNAL_MAX_AGE_MS) {
70
+ if (this._cache) return this._cache;
71
+ const cutoff = Date.now() - maxAge;
72
+ try {
73
+ const lines = fs.readFileSync(this.journalPath, 'utf-8').split('\n').filter(Boolean);
74
+ this._cache = [];
75
+ for (const line of lines) {
76
+ try {
77
+ const e = JSON.parse(line);
78
+ if (e && e.t >= cutoff && e.w) this._cache.push(e);
79
+ } catch {}
80
+ }
81
+ return this._cache;
82
+ } catch {
83
+ this._cache = [];
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /** Prune old episodes */
89
+ prune(maxAge = JOURNAL_MAX_AGE_MS) {
90
+ this._cache = null; // invalidate so load() re-reads with new maxAge
91
+ const kept = this.load(maxAge);
92
+ this._cache = null; // invalidate again after write
93
+ try {
94
+ fs.writeFileSync(this.journalPath,
95
+ kept.map(e => JSON.stringify(e)).join('\n') + (kept.length ? '\n' : ''));
96
+ } catch {}
97
+ }
98
+
99
+ count() { return this.load().length; }
100
+
101
+ stats() {
102
+ const eps = this.load();
103
+ if (!eps.length) return { episodes: 0, successes: 0, failures: 0, avgReward: 0, oldestDays: 0 };
104
+ const successes = eps.filter(e => e.r > 0).length;
105
+ const failures = eps.filter(e => e.r < 0).length;
106
+ const avgReward = eps.reduce((s, e) => s + e.r, 0) / eps.length;
107
+ const oldestDays = Math.round((Date.now() - Math.min(...eps.map(e => e.t))) / 86400000 * 10) / 10;
108
+ return {
109
+ episodes: eps.length, successes, failures,
110
+ avgReward: Math.round(avgReward * 1000) / 1000,
111
+ oldestDays,
112
+ };
113
+ }
114
+ }
115
+
116
+
117
+ // --- Reward-Weighted Optimizer ---
118
+
119
+ /**
120
+ * Extract weight vector from an episode's stored format.
121
+ */
122
+ function extractWeights(w) {
123
+ return {
124
+ w_r: w.w_r ?? w.R ?? DEFAULTS.w_r,
125
+ w_f: w.w_f ?? w.F ?? DEFAULTS.w_f,
126
+ w_s: w.w_s ?? w.S ?? DEFAULTS.w_s,
127
+ w_e: w.w_e ?? w.E ?? DEFAULTS.w_e,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Normalize weight vector to sum=1, clamped to [MIN_WEIGHT, MAX_WEIGHT].
133
+ */
134
+ function normalizeWeights(w) {
135
+ const out = {};
136
+ for (const k of WEIGHT_KEYS) out[k] = Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w[k]));
137
+ const sum = WEIGHT_KEYS.reduce((s, k) => s + out[k], 0);
138
+ for (const k of WEIGHT_KEYS) out[k] = Math.round(out[k] / sum * 10000) / 10000;
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Core optimizer: reward-weighted regression with all research enhancements.
144
+ *
145
+ * @param {object[]} episodes - Raw journal episodes
146
+ * @param {object} currentWeights - Current weight vector {w_r, w_f, w_s, w_e}
147
+ * @returns {object|null} Optimization result
148
+ */
149
+ function optimize(episodes, currentWeights) {
150
+ if (episodes.length < 3) return null;
151
+
152
+ // Advantage normalization
153
+ const rewards = episodes.map(e => e.r);
154
+ const μ = rewards.reduce((a, b) => a + b) / rewards.length;
155
+ const σ = Math.sqrt(rewards.reduce((s, r) => s + (r - μ) ** 2, 0) / rewards.length);
156
+
157
+ // Edge case: when all rewards are identical (σ≈0), advantage normalization
158
+ // produces all-zero advantages. Fall back to raw reward weighting with decay.
159
+ const useRawRewards = σ < 1e-6;
160
+ const advantages = useRawRewards
161
+ ? rewards.map(r => r > 0 ? 1.0 : r < 0 ? -1.0 : 0)
162
+ : rewards.map(r => (r - μ) / (σ + 1e-8));
163
+
164
+ // Exponential decay — newer episodes matter more
165
+ const sortedEps = [...episodes].sort((a, b) => a.t - b.t);
166
+ const decayWeights = sortedEps.map((e, i) => DECAY_GAMMA ** (episodes.length - 1 - i));
167
+
168
+ // Reward-weighted mean: positive advantages attract, negative repel
169
+ const attract = { w_r: 0, w_f: 0, w_s: 0, w_e: 0 };
170
+ let attractSum = 0;
171
+ const repel = { w_r: 0, w_f: 0, w_s: 0, w_e: 0 };
172
+ let repelSum = 0;
173
+
174
+ for (let i = 0; i < sortedEps.length; i++) {
175
+ const ep = sortedEps[i];
176
+ const w = extractWeights(ep.w);
177
+ const adv = advantages[episodes.indexOf(ep)] || 0;
178
+ const decay = decayWeights[i];
179
+
180
+ if (adv > 0) {
181
+ const weight = decay * adv;
182
+ for (const k of WEIGHT_KEYS) attract[k] += weight * w[k];
183
+ attractSum += weight;
184
+ } else if (adv < 0) {
185
+ const weight = decay * Math.abs(adv);
186
+ for (const k of WEIGHT_KEYS) repel[k] += weight * w[k];
187
+ repelSum += weight;
188
+ }
189
+ }
190
+
191
+ // Normalize. If no positive signal at all, return null.
192
+ if (attractSum > 0) for (const k of WEIGHT_KEYS) attract[k] /= attractSum;
193
+ else return null;
194
+
195
+ if (repelSum > 0) for (const k of WEIGHT_KEYS) repel[k] /= repelSum;
196
+
197
+ // Per-dimension step size: high SNR → larger step
198
+ const dimStats = {};
199
+ for (const k of WEIGHT_KEYS) {
200
+ const values = sortedEps.map(e => extractWeights(e.w)[k]);
201
+ const mean = values.reduce((a, b) => a + b) / values.length;
202
+ const std = Math.sqrt(values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length) || 0.01;
203
+ const snr = Math.abs(attract[k] - currentWeights[k]) / std;
204
+ dimStats[k] = { mean, std, snr };
205
+ }
206
+
207
+ // Compute per-dimension blend rate: α_k = base_α · sigmoid(SNR_k)
208
+ const N = episodes.length;
209
+ const confidence = Math.min(1.0, N / WARMUP_EPISODES);
210
+ const baseAlpha = confidence * MAX_BLEND_RATE;
211
+
212
+ // Natural gradient update with attraction/repulsion
213
+ const β = repelSum > 0 ? 0.15 * Math.min(1.0, repelSum / attractSum) : 0;
214
+ const optimal = {};
215
+ const blended = {};
216
+
217
+ for (const k of WEIGHT_KEYS) {
218
+ // Raw optimal direction
219
+ const attractDelta = attract[k] - currentWeights[k];
220
+ const repelDelta = repelSum > 0 ? (repel[k] - currentWeights[k]) : 0;
221
+ const natGradScale = 1.0 / (dimStats[k].std ** 2 + 0.01); // Inverse Fisher (diagonal)
222
+ const sigmoidSNR = 1.0 / (1.0 + Math.exp(-2 * (dimStats[k].snr - 0.5))); // sigmoid centering
223
+
224
+ // Per-dimension adaptive alpha
225
+ const αk = baseAlpha * sigmoidSNR;
226
+
227
+ // Optimal = attraction - β·repulsion
228
+ optimal[k] = attract[k] - β * repelDelta;
229
+
230
+ // Blended = current + αk · natGrad · direction
231
+ const direction = (optimal[k] - currentWeights[k]) * natGradScale;
232
+ const clampedDirection = Math.max(-0.1, Math.min(0.1, direction)); // Prevent wild jumps
233
+ blended[k] = currentWeights[k] + αk * clampedDirection;
234
+ }
235
+
236
+ const normalizedOptimal = normalizeWeights(optimal);
237
+ const normalizedBlended = normalizeWeights(blended);
238
+
239
+ // Exploration bonus
240
+ const exploration = computeExplorationBonus(sortedEps);
241
+
242
+ // Polyak average
243
+ const polyak = { w_r: 0, w_f: 0, w_s: 0, w_e: 0 };
244
+ for (const ep of sortedEps) {
245
+ const w = extractWeights(ep.w);
246
+ for (const k of WEIGHT_KEYS) polyak[k] += w[k];
247
+ }
248
+ for (const k of WEIGHT_KEYS) polyak[k] /= sortedEps.length;
249
+ const normalizedPolyak = normalizeWeights(polyak);
250
+
251
+ // Regret estimation
252
+ const avgObserved = μ;
253
+ const avgPositive = episodes.filter(e => e.r > 0).length > 0
254
+ ? episodes.filter(e => e.r > 0).reduce((s, e) => s + e.r, 0) / episodes.filter(e => e.r > 0).length
255
+ : 0;
256
+ const estimatedRegret = Math.max(0, (avgPositive - avgObserved) * N);
257
+
258
+ return {
259
+ optimal: normalizedOptimal,
260
+ blended: normalizedBlended,
261
+ polyak: normalizedPolyak,
262
+ confidence,
263
+ baseAlpha,
264
+ perDimStats: dimStats,
265
+ exploration,
266
+ successCount: episodes.filter(e => e.r > 0).length,
267
+ failureCount: episodes.filter(e => e.r < 0).length,
268
+ neutralCount: episodes.filter(e => e.r === 0).length,
269
+ totalEpisodes: N,
270
+ estimatedRegret: Math.round(estimatedRegret * 100) / 100,
271
+ advantageMean: Math.round(μ * 1000) / 1000,
272
+ advantageStd: Math.round(σ * 1000) / 1000,
273
+ decayEffectiveN: Math.round(decayWeights.reduce((a, b) => a + b) * 10) / 10,
274
+ };
275
+ }
276
+
277
+
278
+ /**
279
+ * UCB1-style exploration bonus.
280
+ * Identifies which weight dimension is most under-explored.
281
+ */
282
+ function computeExplorationBonus(episodes) {
283
+ if (episodes.length < 5) return null;
284
+
285
+ const stats = {};
286
+ for (const k of WEIGHT_KEYS) {
287
+ const values = episodes.map(e => extractWeights(e.w)[k]);
288
+ const mean = values.reduce((a, b) => a + b) / values.length;
289
+ const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length;
290
+ const std = Math.sqrt(variance);
291
+ const cv = std / Math.max(mean, 0.01); // coefficient of variation
292
+
293
+ // UCB bonus: how uncertain are we about this dimension?
294
+ const ucbBonus = EXPLORATION_C * Math.sqrt(Math.log(episodes.length) / Math.max(values.length, 1));
295
+ stats[k] = { mean: Math.round(mean * 1000) / 1000, std: Math.round(std * 1000) / 1000, cv: Math.round(cv * 1000) / 1000, ucbBonus: Math.round(ucbBonus * 1000) / 1000 };
296
+ }
297
+
298
+ // Most under-explored = highest CV
299
+ let maxCv = 0, underExplored = null;
300
+ for (const k of WEIGHT_KEYS) {
301
+ if (stats[k].cv > maxCv) { maxCv = stats[k].cv; underExplored = k; }
302
+ }
303
+
304
+ return { perDim: stats, underExplored, suggestion: `Try varying ${underExplored} more` };
305
+ }
306
+
307
+
308
+ // --- Config I/O ---
309
+
310
+ function findConfigPath() {
311
+ for (const p of [
312
+ path.join(process.cwd(), 'tuning_config.json'),
313
+ path.join(process.cwd(), 'bench', 'tuning_config.json'),
314
+ path.join(__dirname, '..', '..', 'bench', 'tuning_config.json'),
315
+ ]) { try { if (fs.existsSync(p)) return p; } catch {} }
316
+ return null;
317
+ }
318
+
319
+ function loadConfig() {
320
+ const p = findConfigPath();
321
+ if (!p) return null;
322
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return null; }
323
+ }
324
+
325
+ function saveConfig(config) {
326
+ const p = findConfigPath();
327
+ if (p) try { fs.writeFileSync(p, JSON.stringify(config, null, 2)); } catch {}
328
+ }
329
+
330
+
331
+ // --- Public API ---
332
+
333
+ /**
334
+ * Run optimization from journal data.
335
+ * @param {string} checkpointDir
336
+ * @returns {object|null}
337
+ */
338
+ function optimizeFromJournal(checkpointDir) {
339
+ const journal = new FeedbackJournal(checkpointDir);
340
+ const episodes = journal.load();
341
+ if (episodes.length < 3) return null;
342
+
343
+ const cfg = loadConfig();
344
+ const currentWeights = {
345
+ w_r: cfg?.weights?.recency ?? DEFAULTS.w_r,
346
+ w_f: cfg?.weights?.frequency ?? DEFAULTS.w_f,
347
+ w_s: cfg?.weights?.semantic_sim ?? DEFAULTS.w_s,
348
+ w_e: cfg?.weights?.entropy ?? DEFAULTS.w_e,
349
+ };
350
+
351
+ return optimize(episodes, currentWeights);
352
+ }
353
+
354
+ /**
355
+ * Hot-reload weights from tuning_config.json into a live engine.
356
+ */
357
+ function hotReloadWeights(engine) {
358
+ const cfg = loadConfig();
359
+ if (!cfg) return;
360
+ engine.set_weights(
361
+ cfg.weights?.recency ?? DEFAULTS.w_r,
362
+ cfg.weights?.frequency ?? DEFAULTS.w_f,
363
+ cfg.weights?.semantic_sim ?? DEFAULTS.w_s,
364
+ cfg.weights?.entropy ?? DEFAULTS.w_e,
365
+ );
366
+ }
367
+
368
+ /**
369
+ * Start autotune daemon.
370
+ *
371
+ * Flow per tick:
372
+ * 1. Load feedback journal
373
+ * 2. If < warmup episodes → hot-reload from config only
374
+ * 3. If ≥ warmup → compute optimal via reward-weighted regression
375
+ * 4. Blend optimal into current weights (confidence-ramped, per-dim adaptive)
376
+ * 5. Push to live engine
377
+ * 6. Periodically prune journal
378
+ */
379
+ function startAutotuneDaemon(engine, checkpointDir, intervalMs = 30000) {
380
+ const cfg = loadConfig();
381
+ if (cfg?.autotuner?.enabled === false) {
382
+ console.error('[autotune] Disabled via tuning_config.json');
383
+ return null;
384
+ }
385
+
386
+ const dir = checkpointDir || path.join(process.cwd(), '.entroly');
387
+ const journal = new FeedbackJournal(dir);
388
+
389
+ hotReloadWeights(engine);
390
+
391
+ const timer = setInterval(() => {
392
+ try {
393
+ const episodes = journal.load();
394
+
395
+ if (episodes.length < WARMUP_EPISODES) {
396
+ hotReloadWeights(engine);
397
+ return;
398
+ }
399
+
400
+ const cfg2 = loadConfig();
401
+ const current = {
402
+ w_r: cfg2?.weights?.recency ?? DEFAULTS.w_r,
403
+ w_f: cfg2?.weights?.frequency ?? DEFAULTS.w_f,
404
+ w_s: cfg2?.weights?.semantic_sim ?? DEFAULTS.w_s,
405
+ w_e: cfg2?.weights?.entropy ?? DEFAULTS.w_e,
406
+ };
407
+
408
+ const result = optimize(episodes, current);
409
+ if (result) {
410
+ const w = result.blended;
411
+ engine.set_weights(w.w_r, w.w_f, w.w_s, w.w_e);
412
+ console.error(
413
+ `[autotune] ${result.totalEpisodes} episodes ` +
414
+ `(${result.successCount}✓ ${result.failureCount}✗) ` +
415
+ `α_eff=${result.baseAlpha.toFixed(2)} regret=${result.estimatedRegret} → ` +
416
+ `R=${w.w_r.toFixed(3)} F=${w.w_f.toFixed(3)} S=${w.w_s.toFixed(3)} E=${w.w_e.toFixed(3)}`
417
+ );
418
+ }
419
+
420
+ // Prune 5% of the time
421
+ if (Math.random() < 0.05) journal.prune();
422
+ } catch (e) {
423
+ console.error(`[autotune] Error: ${e.message}`);
424
+ }
425
+ }, intervalMs);
426
+
427
+ return { timer, journal };
428
+ }
429
+
430
+ /**
431
+ * CLI command: show status and run optimization.
432
+ */
433
+ function runAutotune() {
434
+ const { EntrolyConfig } = require('./config');
435
+ const config = new EntrolyConfig();
436
+ const journal = new FeedbackJournal(config.checkpointDir);
437
+ const stats = journal.stats();
438
+
439
+ console.error(`[autotune] Journal: ${journal.journalPath}`);
440
+ console.error(`[autotune] Episodes: ${stats.episodes} (${stats.successes}✓ ${stats.failures}✗ avg=${stats.avgReward} span=${stats.oldestDays}d)`);
441
+
442
+
443
+ if (stats.episodes < 3) {
444
+ console.error('[autotune] ⚠ Need ≥3 feedback episodes. Use MCP tools to provide feedback:');
445
+ console.error('[autotune] 1. optimize_context → generates context');
446
+ console.error('[autotune] 2. record_outcome → tells autotune if it was helpful');
447
+ console.error('[autotune] (The server does this automatically when you use it)');
448
+ return;
449
+ }
450
+
451
+ const result = optimizeFromJournal(config.checkpointDir);
452
+ if (!result) { console.error('[autotune] No positive signal found yet.'); return; }
453
+
454
+ console.error(`[autotune] ✓ Optimization from ${result.totalEpisodes} episodes:`);
455
+ console.error(`[autotune] Confidence: ${(result.confidence * 100).toFixed(0)}%`);
456
+ console.error(`[autotune] Effective N (after decay): ${result.decayEffectiveN}`);
457
+ console.error(`[autotune] Estimated regret: ${result.estimatedRegret}`);
458
+ console.error(`[autotune] Reward distribution: μ=${result.advantageMean} σ=${result.advantageStd}`);
459
+ console.error('[autotune]');
460
+ console.error(`[autotune] Optimal (raw): R=${result.optimal.w_r} F=${result.optimal.w_f} S=${result.optimal.w_s} E=${result.optimal.w_e}`);
461
+ console.error(`[autotune] Blended (safe): R=${result.blended.w_r} F=${result.blended.w_f} S=${result.blended.w_s} E=${result.blended.w_e}`);
462
+ console.error(`[autotune] Polyak average: R=${result.polyak.w_r} F=${result.polyak.w_f} S=${result.polyak.w_s} E=${result.polyak.w_e}`);
463
+
464
+ if (result.exploration) {
465
+ console.error('[autotune]');
466
+ console.error(`[autotune] Exploration: ${result.exploration.suggestion}`);
467
+ for (const [k, v] of Object.entries(result.exploration.perDim)) {
468
+ console.error(`[autotune] ${k}: μ=${v.mean} σ=${v.std} CV=${v.cv} UCB=${v.ucbBonus}`);
469
+ }
470
+ }
471
+
472
+ // Save blended weights
473
+ const cfg = loadConfig();
474
+ if (cfg?.weights) {
475
+ cfg.weights.recency = result.blended.w_r;
476
+ cfg.weights.frequency = result.blended.w_f;
477
+ cfg.weights.semantic_sim = result.blended.w_s;
478
+ cfg.weights.entropy = result.blended.w_e;
479
+ saveConfig(cfg);
480
+ console.error('[autotune]');
481
+ console.error('[autotune] ✓ Blended weights saved to tuning_config.json');
482
+ }
483
+ }
484
+
485
+ // --- Task-Conditioned Weight Profiles ---
486
+ // Different tasks need different weights: debugging prioritizes recency+semantic,
487
+ // feature work prioritizes entropy+frequency. Profiles are optimized independently.
488
+
489
+ // Simple task classifier (mirrors the Rust classify_task logic)
490
+ const TASK_PATTERNS = {
491
+ Debugging: /\b(fix|bug|error|crash|issue|debug|broken|fail|wrong|exception|undefined|null|stack|trace)\b/i,
492
+ Feature: /\b(add|implement|create|build|feature|new|support|integrate|enable)\b/i,
493
+ Refactoring: /\b(refactor|clean|reorganize|simplify|extract|rename|move|split|merge|dedup)\b/i,
494
+ Performance: /\b(optimize|performance|slow|fast|speed|cache|memory|leak|bottleneck|latency)\b/i,
495
+ Testing: /\b(test|spec|assert|expect|mock|stub|coverage|unit|integration|e2e)\b/i,
496
+ Documentation:/\b(document|readme|comment|explain|describe|usage|api|jsdoc|typedoc)\b/i,
497
+ };
498
+
499
+ function classifyQuery(query) {
500
+ if (!query) return 'General';
501
+ for (const [type, pattern] of Object.entries(TASK_PATTERNS)) {
502
+ if (pattern.test(query)) return type;
503
+ }
504
+ return 'General';
505
+ }
506
+
507
+ // Default weight priors per task type (informed by domain knowledge)
508
+ const TASK_PRIORS = {
509
+ Debugging: { w_r: 0.35, w_f: 0.15, w_s: 0.35, w_e: 0.15 },
510
+ Feature: { w_r: 0.20, w_f: 0.25, w_s: 0.25, w_e: 0.30 },
511
+ Refactoring: { w_r: 0.15, w_f: 0.35, w_s: 0.20, w_e: 0.30 },
512
+ Performance: { w_r: 0.25, w_f: 0.30, w_s: 0.25, w_e: 0.20 },
513
+ Testing: { w_r: 0.30, w_f: 0.20, w_s: 0.30, w_e: 0.20 },
514
+ Documentation: { w_r: 0.20, w_f: 0.20, w_s: 0.30, w_e: 0.30 },
515
+ General: { ...DEFAULTS },
516
+ };
517
+
518
+ class TaskProfileOptimizer {
519
+ constructor(journal) {
520
+ this.journal = journal;
521
+ this._profiles = {}; // { taskType: { weights, lastUpdated, episodeCount } }
522
+ }
523
+
524
+ /**
525
+ * Classify all journal episodes by task type and optimize each independently.
526
+ * @returns {{ profiles: object, totalEpisodes: number }}
527
+ */
528
+ optimizeAll() {
529
+ const episodes = this.journal.load();
530
+ if (episodes.length < 3) return { profiles: {}, totalEpisodes: episodes.length };
531
+
532
+ // Partition episodes by task type
533
+ const buckets = {};
534
+ for (const ep of episodes) {
535
+ const taskType = classifyQuery(ep.q);
536
+ if (!buckets[taskType]) buckets[taskType] = [];
537
+ buckets[taskType].push(ep);
538
+ }
539
+
540
+ // Optimize each task type independently
541
+ const profiles = {};
542
+ for (const [taskType, taskEpisodes] of Object.entries(buckets)) {
543
+ const prior = TASK_PRIORS[taskType] || TASK_PRIORS.General;
544
+ if (taskEpisodes.length >= 3) {
545
+ const result = optimize(taskEpisodes, prior);
546
+ if (result) {
547
+ profiles[taskType] = {
548
+ weights: result.blended,
549
+ confidence: result.confidence,
550
+ episodes: taskEpisodes.length,
551
+ regret: result.estimatedRegret,
552
+ };
553
+ }
554
+ }
555
+ // If not enough episodes or no signal, use prior
556
+ if (!profiles[taskType]) {
557
+ profiles[taskType] = {
558
+ weights: { ...prior },
559
+ confidence: 0,
560
+ episodes: taskEpisodes.length,
561
+ regret: 0,
562
+ };
563
+ }
564
+ }
565
+
566
+ // Add priors for unseen task types
567
+ for (const taskType of Object.keys(TASK_PRIORS)) {
568
+ if (!profiles[taskType]) {
569
+ profiles[taskType] = {
570
+ weights: { ...TASK_PRIORS[taskType] },
571
+ confidence: 0,
572
+ episodes: 0,
573
+ regret: 0,
574
+ };
575
+ }
576
+ }
577
+
578
+ this._profiles = profiles;
579
+ return { profiles, totalEpisodes: episodes.length };
580
+ }
581
+
582
+ /**
583
+ * Get the optimal weight profile for a given query.
584
+ * @param {string} query
585
+ * @returns {{ weights: object, taskType: string, confidence: number }}
586
+ */
587
+ getProfileForQuery(query) {
588
+ const taskType = classifyQuery(query);
589
+ const profile = this._profiles[taskType];
590
+ if (profile && profile.confidence > 0) {
591
+ return { weights: profile.weights, taskType, confidence: profile.confidence };
592
+ }
593
+ // Fallback to task prior
594
+ return { weights: TASK_PRIORS[taskType] || TASK_PRIORS.General, taskType, confidence: 0 };
595
+ }
596
+
597
+ /**
598
+ * Apply task-conditioned weights to an engine for a specific query.
599
+ * @param {WasmEntrolyEngine} engine
600
+ * @param {string} query
601
+ */
602
+ applyToEngine(engine, query) {
603
+ const { weights, taskType, confidence } = this.getProfileForQuery(query);
604
+ engine.set_weights(weights.w_r, weights.w_f, weights.w_s, weights.w_e);
605
+ return { taskType, confidence, weights };
606
+ }
607
+ }
608
+
609
+
610
+ module.exports = {
611
+ FeedbackJournal,
612
+ optimize,
613
+ computeExplorationBonus,
614
+ optimizeFromJournal,
615
+ startAutotuneDaemon,
616
+ runAutotune,
617
+ hotReloadWeights,
618
+ // Task-conditioned profiles
619
+ TaskProfileOptimizer,
620
+ classifyQuery,
621
+ TASK_PRIORS,
622
+ // Internals exposed for testing
623
+ extractWeights,
624
+ normalizeWeights,
625
+ WEIGHT_KEYS,
626
+ DEFAULTS,
627
+ };