@interleavelove/keating 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.
Files changed (39) hide show
  1. package/README.md +274 -0
  2. package/bin/keating.js +31 -0
  3. package/dist/src/cli/main.js +165 -0
  4. package/dist/src/core/animation.js +372 -0
  5. package/dist/src/core/benchmark.js +238 -0
  6. package/dist/src/core/config.js +81 -0
  7. package/dist/src/core/evolution.js +224 -0
  8. package/dist/src/core/learner-state.js +88 -0
  9. package/dist/src/core/lesson-plan.js +155 -0
  10. package/dist/src/core/map.js +89 -0
  11. package/dist/src/core/paths.js +69 -0
  12. package/dist/src/core/pi-agent.js +58 -0
  13. package/dist/src/core/policy.js +53 -0
  14. package/dist/src/core/project.js +189 -0
  15. package/dist/src/core/prompt-evolution.js +337 -0
  16. package/dist/src/core/random.js +19 -0
  17. package/dist/src/core/self-improve.js +419 -0
  18. package/dist/src/core/topics.js +620 -0
  19. package/dist/src/core/types.js +1 -0
  20. package/dist/src/core/util.js +28 -0
  21. package/dist/src/core/verification.js +162 -0
  22. package/dist/src/pi/hyperteacher-extension.js +180 -0
  23. package/dist/src/runtime/pi.js +118 -0
  24. package/dist/test/animation.test.js +43 -0
  25. package/dist/test/config.test.js +36 -0
  26. package/dist/test/evolution.test.js +39 -0
  27. package/dist/test/fuzz.test.js +37 -0
  28. package/dist/test/hyperteacher-extension.test.js +122 -0
  29. package/dist/test/lesson-plan.test.js +35 -0
  30. package/dist/test/pipeline.test.js +57 -0
  31. package/dist/test/prompt-evolution.test.js +89 -0
  32. package/package.json +58 -0
  33. package/pi/prompts/bridge.md +14 -0
  34. package/pi/prompts/diagnose.md +15 -0
  35. package/pi/prompts/improve.md +39 -0
  36. package/pi/prompts/learn.md +21 -0
  37. package/pi/prompts/quiz.md +14 -0
  38. package/pi/skills/adaptive-teaching/SKILL.md +33 -0
  39. package/scripts/install/install.sh +307 -0
@@ -0,0 +1,372 @@
1
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { buildLessonPlan } from "./lesson-plan.js";
4
+ import { animationsDir } from "./paths.js";
5
+ import { resolveTopic } from "./topics.js";
6
+ import { slugify } from "./util.js";
7
+ function pickSceneKind(topicName) {
8
+ const topic = resolveTopic(topicName);
9
+ // Canonical slug overrides for backward compatibility
10
+ if (topic.slug === "derivative")
11
+ return "function-graph";
12
+ if (topic.slug === "entropy")
13
+ return "distribution-bars";
14
+ if (topic.slug === "bayes-rule")
15
+ return "belief-update";
16
+ // Route by domain
17
+ switch (topic.domain) {
18
+ case "math": return "function-graph";
19
+ case "science": return "distribution-bars";
20
+ case "code": return "code-trace";
21
+ case "history": return "timeline";
22
+ case "law":
23
+ case "politics": return "case-diagram";
24
+ case "psychology":
25
+ case "arts": return "mind-map";
26
+ case "medicine": return "distribution-bars";
27
+ default: return "concept-card";
28
+ }
29
+ }
30
+ function importSpecifierFrom(dirPath, targetPath) {
31
+ const specifier = relative(dirPath, targetPath).replaceAll("\\", "/");
32
+ return specifier.startsWith(".") ? specifier : `./${specifier}`;
33
+ }
34
+ function installedManimWebDistDir() {
35
+ const moduleUrl = import.meta.url;
36
+ const modulePath = decodeURIComponent(moduleUrl.replace(/^file:\/\//, ""));
37
+ const packageRoot = modulePath.includes("/src/")
38
+ ? modulePath.split("/src/")[0]
39
+ : modulePath.includes("/dist/src/")
40
+ ? modulePath.split("/dist/src/")[0]
41
+ : join(dirname(modulePath), "../../../");
42
+ return join(packageRoot, "node_modules/manim-web/dist");
43
+ }
44
+ async function copyDir(sourceDir, targetDir) {
45
+ await mkdir(targetDir, { recursive: true });
46
+ const entries = await readdir(sourceDir, { withFileTypes: true });
47
+ for (const entry of entries) {
48
+ const sourcePath = join(sourceDir, entry.name);
49
+ const targetPath = join(targetDir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ await copyDir(sourcePath, targetPath);
52
+ }
53
+ else if (entry.isFile()) {
54
+ await writeFile(targetPath, await readFile(sourcePath));
55
+ }
56
+ }
57
+ }
58
+ function sceneRationale(topicName, policy, sceneKind) {
59
+ const topic = resolveTopic(topicName);
60
+ return [
61
+ `${topic.title} is marked visualizable=${String(topic.visualizable)} and belongs to ${topic.domain}.`,
62
+ `The current policy prefers diagrams at ${policy.diagramBias.toFixed(2)} and formalism at ${policy.formalism.toFixed(2)}.`,
63
+ sceneKind === "function-graph"
64
+ ? "A function graph highlights local change, secant-to-tangent motion, and equation refinement."
65
+ : sceneKind === "distribution-bars"
66
+ ? "A bar chart makes multiplicity, relative weight, and statistical relationships legible before symbol manipulation."
67
+ : sceneKind === "belief-update"
68
+ ? "A belief-update chart makes prior, evidence, and posterior shifts visible instead of purely verbal."
69
+ : sceneKind === "code-trace"
70
+ ? "A code-trace scene shows execution flow, variable state, and call-stack evolution step by step."
71
+ : sceneKind === "timeline"
72
+ ? "A timeline scene places events in chronological order so causal relationships and periodization become visible."
73
+ : sceneKind === "case-diagram"
74
+ ? "A case-diagram scene structures arguments as premises leading to conclusions, making reasoning transparent."
75
+ : sceneKind === "mind-map"
76
+ ? "A mind-map scene radiates concepts from a central idea, revealing connections and clustering."
77
+ : "A concept-card scene is safer when the concept is philosophical or the visual grammar is still exploratory.",
78
+ `Interdisciplinary hooks carried into the scene: ${topic.interdisciplinaryHooks.join(", ")}.`
79
+ ];
80
+ }
81
+ export function buildAnimationManifest(topicName, policy) {
82
+ const topic = resolveTopic(topicName);
83
+ const plan = buildLessonPlan(topicName, policy);
84
+ const sceneKind = pickSceneKind(topicName);
85
+ return {
86
+ topic: topic.title,
87
+ slug: slugify(topicName),
88
+ domain: topic.domain,
89
+ sceneKind,
90
+ rationale: sceneRationale(topicName, policy, sceneKind),
91
+ focusMoments: plan.phases.slice(0, 4).map((phase) => `${phase.title}: ${phase.purpose}`)
92
+ };
93
+ }
94
+ export function animationSceneSource(topicName, policy, importSpecifier) {
95
+ const topic = resolveTopic(topicName);
96
+ const manifest = buildAnimationManifest(topicName, policy);
97
+ const thesis = topic.formalCore[0] ?? topic.summary;
98
+ const misconception = topic.misconceptions[0] ?? `Avoid flattening ${topic.title} into a slogan.`;
99
+ const bridge = topic.interdisciplinaryHooks[0] ?? "application";
100
+ const palette = {
101
+ background: "#08111f",
102
+ ink: "#f8f5ec",
103
+ accent: "#ff7a59",
104
+ support: "#7bb0ff",
105
+ soft: "#9dd7c8",
106
+ warning: "#ff8e72"
107
+ };
108
+ const commonHelpers = `
109
+ const palette = ${JSON.stringify(palette, null, 2)};
110
+
111
+ function textLine(text, y, fontSize = 28, color = palette.ink, fontFamily = "Iowan Old Style, Georgia, serif") {
112
+ const node = new Text({
113
+ text,
114
+ fontSize,
115
+ color,
116
+ fontFamily,
117
+ lineHeight: 1.15
118
+ });
119
+ node.moveTo([0, y, 0]);
120
+ return node;
121
+ }
122
+ `;
123
+ let sceneLogic = "";
124
+ switch (manifest.sceneKind) {
125
+ case "function-graph":
126
+ sceneLogic = `
127
+ const axes = new Axes({
128
+ xRange: [-2, 3, 1],
129
+ yRange: [-1, 6, 1],
130
+ xLength: 10,
131
+ yLength: 5.5,
132
+ color: palette.support
133
+ });
134
+ axes.moveTo([0, -0.2, 0]);
135
+
136
+ const plot = axes.plot((x) => x * x, {
137
+ xRange: [-1.8, 2.2],
138
+ color: palette.accent,
139
+ strokeWidth: 5
140
+ });
141
+
142
+ const formula = new MathTex({
143
+ latex: ${JSON.stringify(topic.formalCore[0] || "f(x)")},
144
+ fontSize: 42,
145
+ color: palette.ink
146
+ });
147
+ formula.moveTo([0, -3.05, 0]);
148
+
149
+ await scene.play(new Create(axes), new Create(plot));
150
+ await scene.play(new Write(formula));
151
+ `;
152
+ break;
153
+ case "distribution-bars":
154
+ case "belief-update":
155
+ sceneLogic = `
156
+ const chart = new BarChart({
157
+ values: [0.2, 0.5, 0.8],
158
+ xLabels: ["initial", "mid", "target"],
159
+ barColors: [palette.support, palette.soft, palette.accent],
160
+ yRange: [0, 1, 0.2],
161
+ width: 8.5,
162
+ height: 4.6,
163
+ axisColor: palette.ink
164
+ });
165
+ chart.moveTo([0, -0.25, 0]);
166
+
167
+ const label = textLine(${JSON.stringify(topic.summary)}, -3.0, 24, palette.soft);
168
+
169
+ await scene.play(new Create(chart));
170
+ await scene.play(new FadeIn(label));
171
+ `;
172
+ break;
173
+ case "code-trace":
174
+ sceneLogic = `
175
+ const code = textLine(${JSON.stringify(topic.examples[0] || "// example code")}, 0, 24, palette.soft, "'Fira Code', monospace");
176
+ const state = textLine("State: evolving...", -2.0, 24, palette.accent);
177
+ await scene.play(new Write(code));
178
+ await scene.play(new FadeIn(state));
179
+ `;
180
+ break;
181
+ case "timeline":
182
+ sceneLogic = `
183
+ const events = ${JSON.stringify(topic.diagramNodes.slice(0, 4))};
184
+ for (let i = 0; i < events.length; i++) {
185
+ const node = textLine(events[i], 1.5 - i * 1.0, 28, i % 2 === 0 ? palette.accent : palette.support);
186
+ await scene.play(new FadeIn(node));
187
+ await scene.wait(0.2);
188
+ }
189
+ `;
190
+ break;
191
+ case "case-diagram":
192
+ sceneLogic = `
193
+ const premise = textLine(${JSON.stringify(topic.formalCore[0] || "Premise")}, 1.0, 26, palette.soft);
194
+ const conclusion = textLine(${JSON.stringify(topic.summary)}, -1.0, 30, palette.accent);
195
+ await scene.play(new Write(premise));
196
+ await scene.wait(0.5);
197
+ await scene.play(new Transform(premise, conclusion));
198
+ `;
199
+ break;
200
+ case "mind-map":
201
+ sceneLogic = `
202
+ const center = textLine(${JSON.stringify(topic.title)}, 0, 42, palette.accent);
203
+ await scene.play(new Write(center));
204
+ const nodes = ${JSON.stringify(topic.diagramNodes.slice(0, 4))};
205
+ const positions = [[-4, 2], [4, 2], [-4, -2], [4, -2]];
206
+ for (let i = 0; i < nodes.length; i++) {
207
+ const node = textLine(nodes[i], 0, 24, palette.support);
208
+ node.moveTo([...positions[i], 0]);
209
+ await scene.play(new FadeIn(node));
210
+ }
211
+ `;
212
+ break;
213
+ default: // concept-card
214
+ sceneLogic = `
215
+ const thesis = textLine(${JSON.stringify(thesis)}, 1.25, 26);
216
+ const warning = textLine(${JSON.stringify(`Misconception: ${misconception}`)}, -0.15, 24, palette.warning);
217
+ const hook = textLine(${JSON.stringify(`Bridge: ${bridge}`)}, -1.65, 24, palette.soft);
218
+
219
+ await scene.play(new FadeIn(thesis), new FadeIn(warning), new FadeIn(hook));
220
+ `;
221
+ }
222
+ return `import {
223
+ Scene, Text, MathTex, Axes, BarChart,
224
+ Create, Write, FadeIn, FadeOut, Transform
225
+ } from ${JSON.stringify(importSpecifier)};
226
+
227
+ ${commonHelpers}
228
+
229
+ export async function construct(scene) {
230
+ const title = textLine(${JSON.stringify(`${topic.title}: ${manifest.sceneKind}`)}, 3.2, 34, palette.accent);
231
+ await scene.play(new Write(title));
232
+
233
+ ${sceneLogic}
234
+
235
+ await scene.wait(2.0);
236
+ }
237
+ `;
238
+ }
239
+ export function animationStoryboardMarkdown(topicName, policy) {
240
+ const manifest = buildAnimationManifest(topicName, policy);
241
+ const plan = buildLessonPlan(topicName, policy);
242
+ const lines = [
243
+ `# Animation Storyboard: ${manifest.topic}`,
244
+ "",
245
+ `- Scene kind: ${manifest.sceneKind}`,
246
+ `- Domain: ${manifest.domain}`,
247
+ `- Policy: ${policy.name}`,
248
+ "",
249
+ "## Why This Visual",
250
+ ...manifest.rationale.map((item) => `- ${item}`),
251
+ "",
252
+ "## Focus Moments",
253
+ ...manifest.focusMoments.map((item) => `- ${item}`),
254
+ "",
255
+ "## Teaching Beats",
256
+ ...plan.phases.map((phase) => `- ${phase.title}: ${phase.purpose}`),
257
+ ""
258
+ ];
259
+ return `${lines.join("\n").trim()}\n`;
260
+ }
261
+ export async function writeLessonAnimation(cwd, topicName, policy) {
262
+ const slug = slugify(topicName);
263
+ const topicDir = join(animationsDir(cwd), slug);
264
+ await mkdir(topicDir, { recursive: true });
265
+ const vendorDir = join(topicDir, "_vendor", "manim-web");
266
+ await copyDir(installedManimWebDistDir(), vendorDir);
267
+ const importSpecifier = importSpecifierFrom(topicDir, join(vendorDir, "index.js"));
268
+ const playerPath = join(topicDir, "player.html");
269
+ const scenePath = join(topicDir, "scene.mjs");
270
+ const storyboardPath = join(topicDir, "storyboard.md");
271
+ const manifestPath = join(topicDir, "manifest.json");
272
+ const readmePath = join(topicDir, "README.md");
273
+ const manifest = buildAnimationManifest(topicName, policy);
274
+ const sceneSource = animationSceneSource(topicName, policy, importSpecifier);
275
+ const playerHtml = `<!doctype html>
276
+ <html lang="en">
277
+ <head>
278
+ <meta charset="utf-8" />
279
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
280
+ <title>Keating Animation: ${manifest.topic}</title>
281
+ <style>
282
+ :root {
283
+ color-scheme: dark;
284
+ --paper: #f8f5ec;
285
+ --ink: #07111d;
286
+ --frame: #10223a;
287
+ --muted: #90a3b8;
288
+ }
289
+ body {
290
+ margin: 0;
291
+ min-height: 100vh;
292
+ display: grid;
293
+ place-items: center;
294
+ background:
295
+ radial-gradient(circle at top, rgba(121, 168, 255, 0.25), transparent 28rem),
296
+ linear-gradient(180deg, #08111f 0%, #050a12 100%);
297
+ color: var(--paper);
298
+ font-family: "Iowan Old Style", Georgia, serif;
299
+ }
300
+ main {
301
+ width: min(92vw, 1280px);
302
+ display: grid;
303
+ gap: 1rem;
304
+ }
305
+ header {
306
+ display: flex;
307
+ justify-content: space-between;
308
+ gap: 1rem;
309
+ align-items: end;
310
+ }
311
+ .meta {
312
+ color: var(--muted);
313
+ font-size: 0.95rem;
314
+ }
315
+ #scene {
316
+ height: min(72vh, 760px);
317
+ border: 1px solid rgba(255, 255, 255, 0.14);
318
+ border-radius: 20px;
319
+ overflow: hidden;
320
+ box-shadow: 0 24px 90px rgba(0, 0, 0, 0.35);
321
+ background: #08111f;
322
+ }
323
+ a { color: #b5d2ff; }
324
+ </style>
325
+ </head>
326
+ <body>
327
+ <main>
328
+ <header>
329
+ <div>
330
+ <div class="meta">Keating visual artifact</div>
331
+ <h1>${manifest.topic}</h1>
332
+ </div>
333
+ <div class="meta">
334
+ <div><a href="./storyboard.md">storyboard.md</a></div>
335
+ <div><a href="./manifest.json">manifest.json</a></div>
336
+ </div>
337
+ </header>
338
+ <div id="scene"></div>
339
+ </main>
340
+ <script type="module">
341
+ import { Scene } from ${JSON.stringify(importSpecifier)};
342
+ import { construct } from "./scene.mjs";
343
+
344
+ const container = document.getElementById("scene");
345
+ const scene = new Scene(container, {
346
+ width: 1280,
347
+ height: 720,
348
+ backgroundColor: "#08111f"
349
+ });
350
+
351
+ await construct(scene);
352
+ </script>
353
+ </body>
354
+ </html>
355
+ `;
356
+ const readme = `# ${manifest.topic} Animation Bundle
357
+
358
+ - Serve the repository root with a static file server, for example: \`python3 -m http.server 4173\`
359
+ - Open: \`http://localhost:4173/${relative(cwd, playerPath).replaceAll("\\", "/")}\`
360
+ - Inspect the bundle in \`scene.mjs\`, \`storyboard.md\`, and \`manifest.json\`
361
+
362
+ This bundle is deterministic source output. Keating does not yet export a video in Node; it generates a browser-runnable \`manim-web\` scene so the visual teaching layer can evolve under versioned prompts and tests.
363
+ `;
364
+ await Promise.all([
365
+ writeFile(scenePath, sceneSource, "utf8"),
366
+ writeFile(playerPath, playerHtml, "utf8"),
367
+ writeFile(storyboardPath, animationStoryboardMarkdown(topicName, policy), "utf8"),
368
+ writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
369
+ writeFile(readmePath, readme, "utf8")
370
+ ]);
371
+ return { topicDir, playerPath, scenePath, storyboardPath, manifestPath };
372
+ }
@@ -0,0 +1,238 @@
1
+ import { Prng } from "./random.js";
2
+ import { benchmarkTopics } from "./topics.js";
3
+ import { clamp, mean } from "./util.js";
4
+ function buildLearnerPopulation(seed, count) {
5
+ const prng = new Prng(seed);
6
+ const learners = [];
7
+ for (let index = 0; index < count; index += 1) {
8
+ learners.push({
9
+ id: `learner-${seed}-${index}`,
10
+ priorKnowledge: prng.next(),
11
+ abstractionComfort: prng.next(),
12
+ analogyNeed: prng.next(),
13
+ dialoguePreference: prng.next(),
14
+ diagramAffinity: prng.next(),
15
+ persistence: prng.next(),
16
+ transferDesire: prng.next(),
17
+ anxiety: prng.next()
18
+ });
19
+ }
20
+ return learners;
21
+ }
22
+ export function simulateTeaching(policy, topic, learner) {
23
+ const intuitionFit = 1 - Math.abs(policy.analogyDensity - learner.analogyNeed);
24
+ const rigorTarget = clamp((topic.formalism + learner.abstractionComfort) / 2);
25
+ const rigorFit = 1 - Math.abs(policy.formalism - rigorTarget);
26
+ const dialogueFit = 1 - Math.abs(policy.socraticRatio - learner.dialoguePreference);
27
+ const diagramTarget = topic.visualizable ? learner.diagramAffinity : 0.2;
28
+ const diagramFit = 1 - Math.abs(policy.diagramBias - diagramTarget);
29
+ const practiceNeed = clamp(1 - learner.priorKnowledge + learner.anxiety * 0.2);
30
+ const practiceFit = 1 - Math.abs(policy.exerciseCount / 5 - practiceNeed);
31
+ const reflectionFit = 1 - Math.abs(policy.reflectionBias - learner.transferDesire);
32
+ const overload = clamp(policy.formalism * 0.35 +
33
+ (policy.exerciseCount / 5) * 0.15 +
34
+ policy.challengeRate * 0.3 -
35
+ learner.persistence * 0.2 +
36
+ learner.anxiety * 0.25 -
37
+ learner.priorKnowledge * 0.15);
38
+ const masteryGain = clamp(0.14 +
39
+ intuitionFit * 0.18 +
40
+ rigorFit * 0.2 +
41
+ dialogueFit * 0.12 +
42
+ diagramFit * 0.09 +
43
+ practiceFit * 0.12 +
44
+ (1 - overload) * 0.18);
45
+ const retention = clamp(masteryGain * (0.55 + policy.retrievalPractice * 0.45));
46
+ const engagement = clamp(0.12 +
47
+ intuitionFit * 0.16 +
48
+ dialogueFit * 0.16 +
49
+ diagramFit * 0.1 +
50
+ reflectionFit * 0.14 +
51
+ (1 - overload) * 0.18);
52
+ const transfer = clamp(retention * (0.55 + policy.interdisciplinaryBias * 0.25 + learner.transferDesire * 0.2));
53
+ const confusion = clamp(0.04 +
54
+ overload * 0.55 +
55
+ Math.abs(policy.formalism - learner.abstractionComfort) * 0.18 +
56
+ Math.abs(policy.challengeRate - learner.persistence) * 0.12);
57
+ const score = clamp(masteryGain * 0.34 +
58
+ retention * 0.2 +
59
+ engagement * 0.16 +
60
+ transfer * 0.18 -
61
+ confusion * 0.18, 0, 1);
62
+ const explanation = [];
63
+ if (intuitionFit >= 0.8)
64
+ explanation.push("analogy pacing matched the learner well");
65
+ if (rigorFit >= 0.8)
66
+ explanation.push("formal depth fit the learner's abstraction comfort");
67
+ if (practiceFit >= 0.75)
68
+ explanation.push("exercise load matched the learner's need for repetition");
69
+ if (reflectionFit >= 0.75)
70
+ explanation.push("reflection and transfer demands aligned with the learner");
71
+ if (overload >= 0.55)
72
+ explanation.push("challenge and formal load pushed the learner toward overload");
73
+ if (diagramFit <= 0.45)
74
+ explanation.push("diagram emphasis mismatched the learner's visual preference");
75
+ if (explanation.length === 0)
76
+ explanation.push("the lesson was balanced but not strongly optimized for this learner");
77
+ return {
78
+ learner,
79
+ topic,
80
+ masteryGain,
81
+ retention,
82
+ engagement,
83
+ transfer,
84
+ confusion,
85
+ score,
86
+ breakdown: {
87
+ intuitionFit,
88
+ rigorFit,
89
+ dialogueFit,
90
+ diagramFit,
91
+ practiceFit,
92
+ reflectionFit,
93
+ overload
94
+ },
95
+ explanation
96
+ };
97
+ }
98
+ function classifyDominantSignal(simulations, kind) {
99
+ const metrics = {
100
+ intuitionFit: mean(simulations.map((entry) => entry.breakdown.intuitionFit)),
101
+ rigorFit: mean(simulations.map((entry) => entry.breakdown.rigorFit)),
102
+ dialogueFit: mean(simulations.map((entry) => entry.breakdown.dialogueFit)),
103
+ diagramFit: mean(simulations.map((entry) => entry.breakdown.diagramFit)),
104
+ practiceFit: mean(simulations.map((entry) => entry.breakdown.practiceFit)),
105
+ reflectionFit: mean(simulations.map((entry) => entry.breakdown.reflectionFit)),
106
+ overload: mean(simulations.map((entry) => entry.breakdown.overload))
107
+ };
108
+ const ordered = Object.entries(metrics).sort((left, right) => kind === "strength" ? right[1] - left[1] : left[1] - right[1]);
109
+ const [name] = ordered[0] ?? ["unknown"];
110
+ return name;
111
+ }
112
+ function summarizeTopic(topic, simulations, traceLimit) {
113
+ const ranked = [...simulations].sort((left, right) => right.score - left.score);
114
+ return {
115
+ topic,
116
+ learnerCount: simulations.length,
117
+ meanScore: mean(simulations.map((entry) => entry.score)) * 100,
118
+ meanMasteryGain: mean(simulations.map((entry) => entry.masteryGain)),
119
+ meanRetention: mean(simulations.map((entry) => entry.retention)),
120
+ meanEngagement: mean(simulations.map((entry) => entry.engagement)),
121
+ meanTransfer: mean(simulations.map((entry) => entry.transfer)),
122
+ meanConfusion: mean(simulations.map((entry) => entry.confusion)),
123
+ topLearners: ranked.slice(0, traceLimit),
124
+ strugglingLearners: ranked.slice(-traceLimit).reverse(),
125
+ dominantStrength: classifyDominantSignal(simulations, "strength"),
126
+ dominantWeakness: classifyDominantSignal(simulations, "weakness")
127
+ };
128
+ }
129
+ export function runBenchmarkSuite(policy, focusTopic, seed = 20260401, traceLimit = 3) {
130
+ const topics = benchmarkTopics(focusTopic);
131
+ const topicTraces = [];
132
+ const topicBenchmarks = topics.map((topic, index) => {
133
+ const learners = buildLearnerPopulation(seed + index * 97, 18);
134
+ const simulations = learners.map((learner) => simulateTeaching(policy, topic, learner));
135
+ const summary = summarizeTopic(topic, simulations, traceLimit);
136
+ topicTraces.push({
137
+ topic: topic.title,
138
+ topLearners: summary.topLearners.map((entry) => ({
139
+ learnerId: entry.learner.id,
140
+ score: entry.score,
141
+ explanation: entry.explanation
142
+ })),
143
+ strugglingLearners: summary.strugglingLearners.map((entry) => ({
144
+ learnerId: entry.learner.id,
145
+ score: entry.score,
146
+ explanation: entry.explanation
147
+ })),
148
+ metricMeans: {
149
+ masteryGain: summary.meanMasteryGain,
150
+ retention: summary.meanRetention,
151
+ engagement: summary.meanEngagement,
152
+ transfer: summary.meanTransfer,
153
+ confusion: summary.meanConfusion
154
+ },
155
+ dominantStrength: summary.dominantStrength,
156
+ dominantWeakness: summary.dominantWeakness
157
+ });
158
+ return summary;
159
+ });
160
+ const weakest = [...topicBenchmarks].sort((left, right) => left.meanScore - right.meanScore)[0];
161
+ return {
162
+ policy,
163
+ suiteName: focusTopic ? `focused:${focusTopic}` : "core-suite",
164
+ topicBenchmarks,
165
+ overallScore: mean(topicBenchmarks.map((entry) => entry.meanScore)),
166
+ weakestTopic: weakest?.topic.title ?? "n/a",
167
+ trace: {
168
+ seed,
169
+ learnerCountPerTopic: 18,
170
+ topicTraces
171
+ }
172
+ };
173
+ }
174
+ export function benchmarkToMarkdown(result) {
175
+ const lines = [
176
+ `# Benchmark Report: ${result.policy.name}`,
177
+ "",
178
+ `- Suite: ${result.suiteName}`,
179
+ `- Overall score: ${result.overallScore.toFixed(2)}`,
180
+ `- Weakest topic: ${result.weakestTopic}`,
181
+ "",
182
+ "| Topic | Score | Mastery | Retention | Engagement | Transfer | Confusion |",
183
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"
184
+ ];
185
+ for (const benchmark of result.topicBenchmarks) {
186
+ lines.push(`| ${benchmark.topic.title} | ${benchmark.meanScore.toFixed(2)} | ${benchmark.meanMasteryGain.toFixed(2)} | ${benchmark.meanRetention.toFixed(2)} | ${benchmark.meanEngagement.toFixed(2)} | ${benchmark.meanTransfer.toFixed(2)} | ${benchmark.meanConfusion.toFixed(2)} |`);
187
+ }
188
+ lines.push("");
189
+ lines.push("## Interpretation");
190
+ lines.push("");
191
+ lines.push(`- The policy currently underperforms most on ${result.weakestTopic}, which is a useful anchor for mutation and curriculum repair.`);
192
+ lines.push("- Invariants tracked here favor durable learning signals: mastery, retention, engagement, transfer, and bounded confusion.");
193
+ lines.push("- Debug traces below explain which learners the policy helped most, where it struggled, and which signal dominated.");
194
+ lines.push("");
195
+ lines.push("## Debug Trace");
196
+ lines.push("");
197
+ for (const benchmark of result.topicBenchmarks) {
198
+ lines.push(`### ${benchmark.topic.title}`);
199
+ lines.push(`- Dominant strength: ${benchmark.dominantStrength}`);
200
+ lines.push(`- Dominant weakness: ${benchmark.dominantWeakness}`);
201
+ lines.push("- Top learners:");
202
+ for (const learner of benchmark.topLearners) {
203
+ lines.push(` - ${learner.learner.id}: ${(learner.score * 100).toFixed(1)} because ${learner.explanation.join("; ")}`);
204
+ }
205
+ lines.push("- Struggling learners:");
206
+ for (const learner of benchmark.strugglingLearners) {
207
+ lines.push(` - ${learner.learner.id}: ${(learner.score * 100).toFixed(1)} because ${learner.explanation.join("; ")}`);
208
+ }
209
+ lines.push("");
210
+ }
211
+ lines.push("");
212
+ return `${lines.join("\n")}\n`;
213
+ }
214
+ const BASE_WEIGHTS = {
215
+ masteryGain: 0.34,
216
+ retention: 0.20,
217
+ engagement: 0.16,
218
+ transfer: 0.18,
219
+ confusion: 0.18
220
+ };
221
+ export function applyFeedbackBias(feedback) {
222
+ if (feedback.sampleSize < 5)
223
+ return { ...BASE_WEIGHTS };
224
+ const weights = { ...BASE_WEIGHTS };
225
+ // High confusion from real users → increase confusion penalty weight
226
+ weights.confusion = clamp(weights.confusion + feedback.confusionRate * 0.08);
227
+ // High satisfaction → slightly increase engagement weight
228
+ weights.engagement = clamp(weights.engagement + feedback.satisfactionRate * 0.04);
229
+ // Renormalize positive weights to sum to ~0.88 (1 - confusion weight)
230
+ const positiveSum = weights.masteryGain + weights.retention + weights.engagement + weights.transfer;
231
+ const targetPositive = 1 - weights.confusion;
232
+ const scale = targetPositive / positiveSum;
233
+ weights.masteryGain *= scale;
234
+ weights.retention *= scale;
235
+ weights.engagement *= scale;
236
+ weights.transfer *= scale;
237
+ return weights;
238
+ }
@@ -0,0 +1,81 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ export const DEFAULT_KEATING_CONFIG = {
5
+ pi: {
6
+ runtimePreference: "standalone-only",
7
+ defaultProvider: "google",
8
+ defaultModel: "google/gemini-2.5-pro",
9
+ defaultThinking: "medium"
10
+ },
11
+ debug: {
12
+ persistTraces: true,
13
+ traceTopLearners: 3,
14
+ consoleSummary: true
15
+ }
16
+ };
17
+ export function configPath(cwd) {
18
+ return resolve(cwd, "keating.config.json");
19
+ }
20
+ function sanitizeRuntimePreference(value) {
21
+ if (value === "standalone-only" || value === "prefer-standalone" || value === "embedded-only") {
22
+ return value;
23
+ }
24
+ return DEFAULT_KEATING_CONFIG.pi.runtimePreference;
25
+ }
26
+ function sanitizeOptionalString(value) {
27
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
28
+ }
29
+ export async function loadKeatingConfig(cwd) {
30
+ const path = configPath(cwd);
31
+ try {
32
+ const parsed = JSON.parse(await readFile(path, "utf8"));
33
+ return {
34
+ pi: {
35
+ runtimePreference: sanitizeRuntimePreference(parsed.pi?.runtimePreference),
36
+ defaultProvider: sanitizeOptionalString(parsed.pi?.defaultProvider) ?? DEFAULT_KEATING_CONFIG.pi.defaultProvider,
37
+ defaultModel: sanitizeOptionalString(parsed.pi?.defaultModel) ?? DEFAULT_KEATING_CONFIG.pi.defaultModel,
38
+ defaultThinking: sanitizeOptionalString(parsed.pi?.defaultThinking) ?? DEFAULT_KEATING_CONFIG.pi.defaultThinking
39
+ },
40
+ debug: {
41
+ persistTraces: typeof parsed.debug?.persistTraces === "boolean"
42
+ ? parsed.debug.persistTraces
43
+ : DEFAULT_KEATING_CONFIG.debug.persistTraces,
44
+ traceTopLearners: typeof parsed.debug?.traceTopLearners === "number" && parsed.debug.traceTopLearners > 0
45
+ ? Math.round(parsed.debug.traceTopLearners)
46
+ : DEFAULT_KEATING_CONFIG.debug.traceTopLearners,
47
+ consoleSummary: typeof parsed.debug?.consoleSummary === "boolean"
48
+ ? parsed.debug.consoleSummary
49
+ : DEFAULT_KEATING_CONFIG.debug.consoleSummary
50
+ }
51
+ };
52
+ }
53
+ catch {
54
+ return DEFAULT_KEATING_CONFIG;
55
+ }
56
+ }
57
+ export async function ensureConfig(cwd) {
58
+ const path = configPath(cwd);
59
+ if (!existsSync(path)) {
60
+ await writeFile(path, `${JSON.stringify(DEFAULT_KEATING_CONFIG, null, 2)}\n`, "utf8");
61
+ }
62
+ }
63
+ export function mergePiDefaults(config, args) {
64
+ const merged = [...args];
65
+ const hasProvider = merged.includes("--provider");
66
+ const hasModel = merged.includes("--model");
67
+ const hasThinking = merged.includes("--thinking");
68
+ if (!hasProvider && config.pi.defaultProvider) {
69
+ merged.unshift(config.pi.defaultProvider);
70
+ merged.unshift("--provider");
71
+ }
72
+ if (!hasModel && config.pi.defaultModel) {
73
+ merged.unshift(config.pi.defaultModel);
74
+ merged.unshift("--model");
75
+ }
76
+ if (!hasThinking && config.pi.defaultThinking) {
77
+ merged.unshift(config.pi.defaultThinking);
78
+ merged.unshift("--thinking");
79
+ }
80
+ return merged;
81
+ }