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/index.js +67 -0
- package/js/agentskills_export.js +129 -0
- package/js/auto_index.js +262 -0
- package/js/autotune.js +627 -0
- package/js/checkpoint.js +108 -0
- package/js/cli.js +321 -0
- package/js/cogops.js +513 -0
- package/js/config.js +53 -0
- package/js/distill.js +155 -0
- package/js/evolution_daemon.js +335 -0
- package/js/federation.js +444 -0
- package/js/gateways.js +170 -0
- package/js/multimodal.js +64 -0
- package/js/repo_map.js +112 -0
- package/js/server.js +465 -0
- package/js/skills.js +124 -0
- package/js/value_tracker.js +181 -0
- package/js/vault.js +237 -0
- package/js/vault_observer.js +129 -0
- package/js/workspace.js +116 -0
- package/package.json +51 -0
- package/pkg/entroly_wasm.d.ts +156 -0
- package/pkg/entroly_wasm.js +468 -0
- package/pkg/entroly_wasm_bg.wasm +0 -0
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
|
+
};
|