@machinespirits/eval 0.2.0 → 0.3.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 (74) hide show
  1. package/README.md +91 -9
  2. package/config/eval-settings.yaml +3 -3
  3. package/config/paper-manifest.json +486 -0
  4. package/config/providers.yaml +9 -6
  5. package/config/tutor-agents.yaml +2261 -0
  6. package/content/README.md +23 -0
  7. package/content/courses/479/course.md +53 -0
  8. package/content/courses/479/lecture-1.md +361 -0
  9. package/content/courses/479/lecture-2.md +360 -0
  10. package/content/courses/479/lecture-3.md +655 -0
  11. package/content/courses/479/lecture-4.md +530 -0
  12. package/content/courses/479/lecture-5.md +326 -0
  13. package/content/courses/479/lecture-6.md +346 -0
  14. package/content/courses/479/lecture-7.md +326 -0
  15. package/content/courses/479/lecture-8.md +273 -0
  16. package/content/courses/479/roadmap-slides.md +656 -0
  17. package/content/manifest.yaml +8 -0
  18. package/docs/research/build.sh +44 -20
  19. package/docs/research/figures/figure10.png +0 -0
  20. package/docs/research/figures/figure11.png +0 -0
  21. package/docs/research/figures/figure3.png +0 -0
  22. package/docs/research/figures/figure4.png +0 -0
  23. package/docs/research/figures/figure5.png +0 -0
  24. package/docs/research/figures/figure6.png +0 -0
  25. package/docs/research/figures/figure7.png +0 -0
  26. package/docs/research/figures/figure8.png +0 -0
  27. package/docs/research/figures/figure9.png +0 -0
  28. package/docs/research/header.tex +23 -2
  29. package/docs/research/paper-full.md +941 -285
  30. package/docs/research/paper-short.md +216 -585
  31. package/docs/research/references.bib +132 -0
  32. package/docs/research/slides-header.tex +188 -0
  33. package/docs/research/slides-pptx.md +363 -0
  34. package/docs/research/slides.md +531 -0
  35. package/docs/research/style-reference-pptx.py +199 -0
  36. package/package.json +6 -5
  37. package/scripts/analyze-eval-results.js +69 -17
  38. package/scripts/analyze-mechanism-traces.js +763 -0
  39. package/scripts/analyze-modulation-learning.js +498 -0
  40. package/scripts/analyze-prosthesis.js +144 -0
  41. package/scripts/analyze-run.js +264 -79
  42. package/scripts/assess-transcripts.js +853 -0
  43. package/scripts/browse-transcripts.js +854 -0
  44. package/scripts/check-parse-failures.js +73 -0
  45. package/scripts/code-dialectical-modulation.js +1320 -0
  46. package/scripts/download-data.sh +55 -0
  47. package/scripts/eval-cli.js +106 -18
  48. package/scripts/generate-paper-figures.js +663 -0
  49. package/scripts/generate-paper-figures.py +577 -76
  50. package/scripts/generate-paper-tables.js +299 -0
  51. package/scripts/qualitative-analysis-ai.js +3 -3
  52. package/scripts/render-sequence-diagram.js +694 -0
  53. package/scripts/test-latency.js +210 -0
  54. package/scripts/test-rate-limit.js +95 -0
  55. package/scripts/test-token-budget.js +332 -0
  56. package/scripts/validate-paper-manifest.js +670 -0
  57. package/services/__tests__/evalConfigLoader.test.js +2 -2
  58. package/services/__tests__/learnerRubricEvaluator.test.js +361 -0
  59. package/services/__tests__/learnerTutorInteractionEngine.test.js +326 -0
  60. package/services/evaluationRunner.js +975 -98
  61. package/services/evaluationStore.js +12 -4
  62. package/services/learnerTutorInteractionEngine.js +27 -2
  63. package/services/mockProvider.js +133 -0
  64. package/services/promptRewriter.js +1471 -5
  65. package/services/rubricEvaluator.js +55 -2
  66. package/services/transcriptFormatter.js +675 -0
  67. package/docs/EVALUATION-VARIABLES.md +0 -589
  68. package/docs/REPLICATION-PLAN.md +0 -577
  69. package/scripts/analyze-run.mjs +0 -282
  70. package/scripts/compare-runs.js +0 -44
  71. package/scripts/compare-suggestions.js +0 -80
  72. package/scripts/dig-into-run.js +0 -158
  73. package/scripts/show-failed-suggestions.js +0 -64
  74. /package/scripts/{check-run.mjs → check-run.js} +0 -0
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Tests for learnerRubricEvaluator — learner-side scoring.
3
+ *
4
+ * Uses node:test (built-in, no dependencies required).
5
+ * Run: node --test services/__tests__/learnerRubricEvaluator.test.js
6
+ */
7
+
8
+ import { describe, it } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+
11
+ import {
12
+ loadLearnerRubric,
13
+ getLearnerDimensions,
14
+ calculateLearnerOverallScore,
15
+ buildLearnerEvaluationPrompt,
16
+ } from '../learnerRubricEvaluator.js';
17
+
18
+ // ============================================================================
19
+ // loadLearnerRubric
20
+ // ============================================================================
21
+
22
+ describe('loadLearnerRubric', () => {
23
+ it('loads and parses the learner rubric YAML', () => {
24
+ const rubric = loadLearnerRubric({ forceReload: true });
25
+ assert.ok(rubric, 'should return parsed rubric');
26
+ assert.ok(rubric.dimensions, 'should have dimensions');
27
+ assert.ok(rubric.name, 'should have name');
28
+ });
29
+
30
+ it('returns cached result on second call', () => {
31
+ const first = loadLearnerRubric({ forceReload: true });
32
+ const second = loadLearnerRubric();
33
+ assert.strictEqual(first, second, 'should return same cached reference');
34
+ });
35
+
36
+ it('contains all 6 expected dimensions', () => {
37
+ const rubric = loadLearnerRubric({ forceReload: true });
38
+ const keys = Object.keys(rubric.dimensions);
39
+ assert.ok(keys.includes('learner_authenticity'));
40
+ assert.ok(keys.includes('question_quality'));
41
+ assert.ok(keys.includes('conceptual_engagement'));
42
+ assert.ok(keys.includes('revision_signals'));
43
+ assert.ok(keys.includes('deliberation_depth'));
44
+ assert.ok(keys.includes('persona_consistency'));
45
+ assert.strictEqual(keys.length, 6);
46
+ });
47
+
48
+ it('each dimension has name, weight, description, and criteria', () => {
49
+ const rubric = loadLearnerRubric({ forceReload: true });
50
+ for (const [key, dim] of Object.entries(rubric.dimensions)) {
51
+ assert.ok(dim.name, `${key} should have name`);
52
+ assert.ok(typeof dim.weight === 'number', `${key} should have numeric weight`);
53
+ assert.ok(dim.description, `${key} should have description`);
54
+ assert.ok(dim.criteria, `${key} should have criteria`);
55
+ }
56
+ });
57
+
58
+ it('weights sum to 1.0', () => {
59
+ const rubric = loadLearnerRubric({ forceReload: true });
60
+ const totalWeight = Object.values(rubric.dimensions)
61
+ .reduce((sum, dim) => sum + dim.weight, 0);
62
+ assert.ok(
63
+ Math.abs(totalWeight - 1.0) < 0.001,
64
+ `weights should sum to 1.0, got ${totalWeight}`
65
+ );
66
+ });
67
+ });
68
+
69
+ // ============================================================================
70
+ // getLearnerDimensions
71
+ // ============================================================================
72
+
73
+ describe('getLearnerDimensions', () => {
74
+ it('returns all 6 dimensions for multi-agent learners', () => {
75
+ const dims = getLearnerDimensions({ isMultiAgent: true });
76
+ assert.strictEqual(Object.keys(dims).length, 6);
77
+ assert.ok('deliberation_depth' in dims);
78
+ });
79
+
80
+ it('returns 5 dimensions for single-agent learners (excludes deliberation_depth)', () => {
81
+ const dims = getLearnerDimensions({ isMultiAgent: false });
82
+ assert.strictEqual(Object.keys(dims).length, 5);
83
+ assert.ok(!('deliberation_depth' in dims));
84
+ });
85
+
86
+ it('defaults to single-agent when no options provided', () => {
87
+ const dims = getLearnerDimensions();
88
+ assert.strictEqual(Object.keys(dims).length, 5);
89
+ assert.ok(!('deliberation_depth' in dims));
90
+ });
91
+
92
+ it('does not mutate the cached rubric', () => {
93
+ // Get single-agent dims (which deletes deliberation_depth from a copy)
94
+ getLearnerDimensions({ isMultiAgent: false });
95
+ // Then get multi-agent — should still have all 6
96
+ const multiDims = getLearnerDimensions({ isMultiAgent: true });
97
+ assert.strictEqual(Object.keys(multiDims).length, 6);
98
+ assert.ok('deliberation_depth' in multiDims);
99
+ });
100
+ });
101
+
102
+ // ============================================================================
103
+ // calculateLearnerOverallScore
104
+ // ============================================================================
105
+
106
+ describe('calculateLearnerOverallScore', () => {
107
+ // Rubric weights are 0.20, 0.20, 0.20, 0.15, 0.15, 0.10 — these don't sum
108
+ // to exactly 1.0 in IEEE 754, so use approximate comparison for score results.
109
+ const approxEqual = (actual, expected, msg) => {
110
+ assert.ok(
111
+ Math.abs(actual - expected) < 0.01,
112
+ `${msg || 'approxEqual'}: expected ~${expected}, got ${actual}`
113
+ );
114
+ };
115
+
116
+ it('returns ~100 when all scores are 5 (multi-agent)', () => {
117
+ const scores = {
118
+ learner_authenticity: { score: 5, reasoning: 'test' },
119
+ question_quality: { score: 5, reasoning: 'test' },
120
+ conceptual_engagement: { score: 5, reasoning: 'test' },
121
+ revision_signals: { score: 5, reasoning: 'test' },
122
+ deliberation_depth: { score: 5, reasoning: 'test' },
123
+ persona_consistency: { score: 5, reasoning: 'test' },
124
+ };
125
+ const result = calculateLearnerOverallScore(scores, true);
126
+ approxEqual(result, 100);
127
+ });
128
+
129
+ it('returns 0 when all scores are 1 (multi-agent)', () => {
130
+ const scores = {
131
+ learner_authenticity: { score: 1, reasoning: 'test' },
132
+ question_quality: { score: 1, reasoning: 'test' },
133
+ conceptual_engagement: { score: 1, reasoning: 'test' },
134
+ revision_signals: { score: 1, reasoning: 'test' },
135
+ deliberation_depth: { score: 1, reasoning: 'test' },
136
+ persona_consistency: { score: 1, reasoning: 'test' },
137
+ };
138
+ const result = calculateLearnerOverallScore(scores, true);
139
+ approxEqual(result, 0);
140
+ });
141
+
142
+ it('returns ~50 when all scores are 3 (midpoint)', () => {
143
+ const scores = {
144
+ learner_authenticity: { score: 3, reasoning: 'test' },
145
+ question_quality: { score: 3, reasoning: 'test' },
146
+ conceptual_engagement: { score: 3, reasoning: 'test' },
147
+ revision_signals: { score: 3, reasoning: 'test' },
148
+ deliberation_depth: { score: 3, reasoning: 'test' },
149
+ persona_consistency: { score: 3, reasoning: 'test' },
150
+ };
151
+ const result = calculateLearnerOverallScore(scores, true);
152
+ approxEqual(result, 50);
153
+ });
154
+
155
+ it('returns ~100 when all scores are 5 (single-agent, no deliberation_depth)', () => {
156
+ const scores = {
157
+ learner_authenticity: { score: 5, reasoning: 'test' },
158
+ question_quality: { score: 5, reasoning: 'test' },
159
+ conceptual_engagement: { score: 5, reasoning: 'test' },
160
+ revision_signals: { score: 5, reasoning: 'test' },
161
+ persona_consistency: { score: 5, reasoning: 'test' },
162
+ };
163
+ const result = calculateLearnerOverallScore(scores, false);
164
+ approxEqual(result, 100);
165
+ });
166
+
167
+ it('ignores deliberation_depth for single-agent even if provided', () => {
168
+ const scores = {
169
+ learner_authenticity: { score: 5, reasoning: 'test' },
170
+ question_quality: { score: 5, reasoning: 'test' },
171
+ conceptual_engagement: { score: 5, reasoning: 'test' },
172
+ revision_signals: { score: 5, reasoning: 'test' },
173
+ persona_consistency: { score: 5, reasoning: 'test' },
174
+ deliberation_depth: { score: 1, reasoning: 'should be ignored' },
175
+ };
176
+ // Single-agent: deliberation_depth excluded, so all 5s → ~100
177
+ const result = calculateLearnerOverallScore(scores, false);
178
+ approxEqual(result, 100);
179
+ });
180
+
181
+ it('handles plain number scores (not {score, reasoning} objects)', () => {
182
+ const scores = {
183
+ learner_authenticity: 4,
184
+ question_quality: 4,
185
+ conceptual_engagement: 4,
186
+ revision_signals: 4,
187
+ persona_consistency: 4,
188
+ };
189
+ const result = calculateLearnerOverallScore(scores, false);
190
+ approxEqual(result, 75); // (4-1)/4 * 100 = 75
191
+ });
192
+
193
+ it('returns 0 when no scores provided', () => {
194
+ const result = calculateLearnerOverallScore({}, false);
195
+ assert.strictEqual(result, 0);
196
+ });
197
+
198
+ it('skips invalid scores (out of 1-5 range)', () => {
199
+ const scores = {
200
+ learner_authenticity: { score: 0, reasoning: 'invalid' },
201
+ question_quality: { score: 6, reasoning: 'invalid' },
202
+ conceptual_engagement: { score: 3, reasoning: 'valid' },
203
+ revision_signals: { score: 3, reasoning: 'valid' },
204
+ persona_consistency: { score: 3, reasoning: 'valid' },
205
+ };
206
+ const result = calculateLearnerOverallScore(scores, false);
207
+ // Only the three valid scores (all 3s) count → ~50
208
+ approxEqual(result, 50);
209
+ });
210
+
211
+ it('correctly applies weights for mixed scores', () => {
212
+ // Multi-agent: weights are 0.20, 0.20, 0.20, 0.15, 0.15, 0.10
213
+ const scores = {
214
+ learner_authenticity: { score: 5, reasoning: '' }, // 0.20
215
+ question_quality: { score: 5, reasoning: '' }, // 0.20
216
+ conceptual_engagement: { score: 5, reasoning: '' }, // 0.20
217
+ revision_signals: { score: 1, reasoning: '' }, // 0.15
218
+ deliberation_depth: { score: 1, reasoning: '' }, // 0.15
219
+ persona_consistency: { score: 1, reasoning: '' }, // 0.10
220
+ };
221
+ // weighted avg = (5*0.20 + 5*0.20 + 5*0.20 + 1*0.15 + 1*0.15 + 1*0.10) / 1.0
222
+ // = 3.4
223
+ // overall = (3.4 - 1) / 4 * 100 = 60
224
+ const result = calculateLearnerOverallScore(scores, true);
225
+ approxEqual(result, 60);
226
+ });
227
+ });
228
+
229
+ // ============================================================================
230
+ // buildLearnerEvaluationPrompt
231
+ // ============================================================================
232
+
233
+ describe('buildLearnerEvaluationPrompt', () => {
234
+ const sampleTurns = [
235
+ {
236
+ turnNumber: 0,
237
+ phase: 'learner',
238
+ externalMessage: 'I do not understand dialectics at all.',
239
+ },
240
+ {
241
+ turnNumber: 1,
242
+ phase: 'tutor',
243
+ externalMessage: 'Let me explain — dialectics is about transformation through contradiction.',
244
+ },
245
+ {
246
+ turnNumber: 1,
247
+ phase: 'learner',
248
+ externalMessage: 'Oh wait, so it is not just about arguing?',
249
+ internalDeliberation: [
250
+ { role: 'ego_initial', content: 'This is confusing but interesting.' },
251
+ { role: 'superego', content: 'Push deeper — what exactly changed in your understanding?' },
252
+ { role: 'ego_revision', content: 'I think I was wrong about dialectics being just arguments.' },
253
+ ],
254
+ },
255
+ ];
256
+
257
+ it('builds a prompt string containing key sections', () => {
258
+ const prompt = buildLearnerEvaluationPrompt({
259
+ turns: sampleTurns,
260
+ targetTurnIndex: 2,
261
+ personaId: 'productive_struggler',
262
+ personaDescription: 'A student who struggles productively',
263
+ learnerArchitecture: 'multi_agent',
264
+ scenarioName: 'Misconception Correction',
265
+ topic: 'Hegelian dialectics',
266
+ });
267
+
268
+ assert.ok(typeof prompt === 'string');
269
+ assert.ok(prompt.includes('EVALUATION RUBRIC'));
270
+ assert.ok(prompt.includes('LEARNER CONTEXT'));
271
+ assert.ok(prompt.includes('DIALOGUE HISTORY'));
272
+ assert.ok(prompt.includes('LEARNER TURN TO EVALUATE'));
273
+ assert.ok(prompt.includes('productive_struggler'));
274
+ assert.ok(prompt.includes('Misconception Correction'));
275
+ assert.ok(prompt.includes('Hegelian dialectics'));
276
+ });
277
+
278
+ it('includes all 6 dimension keys for multi-agent', () => {
279
+ const prompt = buildLearnerEvaluationPrompt({
280
+ turns: sampleTurns,
281
+ targetTurnIndex: 2,
282
+ learnerArchitecture: 'multi_agent',
283
+ });
284
+
285
+ assert.ok(prompt.includes('learner_authenticity'));
286
+ assert.ok(prompt.includes('question_quality'));
287
+ assert.ok(prompt.includes('conceptual_engagement'));
288
+ assert.ok(prompt.includes('revision_signals'));
289
+ assert.ok(prompt.includes('deliberation_depth'));
290
+ assert.ok(prompt.includes('persona_consistency'));
291
+ });
292
+
293
+ it('excludes deliberation_depth for unified learner', () => {
294
+ const prompt = buildLearnerEvaluationPrompt({
295
+ turns: sampleTurns,
296
+ targetTurnIndex: 2,
297
+ learnerArchitecture: 'unified',
298
+ });
299
+
300
+ // The dimension key should NOT appear in the JSON example section
301
+ assert.ok(prompt.includes('learner_authenticity'));
302
+ assert.ok(prompt.includes('OMIT the deliberation_depth dimension'));
303
+ });
304
+
305
+ it('includes internal deliberation section for multi-agent learners', () => {
306
+ const prompt = buildLearnerEvaluationPrompt({
307
+ turns: sampleTurns,
308
+ targetTurnIndex: 2,
309
+ learnerArchitecture: 'multi_agent',
310
+ });
311
+
312
+ assert.ok(prompt.includes('Internal deliberation'));
313
+ assert.ok(prompt.includes('Ego (initial reaction)'));
314
+ assert.ok(prompt.includes('Superego (critique)'));
315
+ assert.ok(prompt.includes('Ego (revision'));
316
+ });
317
+
318
+ it('truncates transcript at targetTurnIndex (no future turns)', () => {
319
+ const extraTurns = [
320
+ ...sampleTurns,
321
+ {
322
+ turnNumber: 2,
323
+ phase: 'tutor',
324
+ externalMessage: 'THIS SHOULD NOT APPEAR IN PROMPT',
325
+ },
326
+ ];
327
+
328
+ const prompt = buildLearnerEvaluationPrompt({
329
+ turns: extraTurns,
330
+ targetTurnIndex: 2, // Evaluate the learner turn at index 2
331
+ learnerArchitecture: 'unified',
332
+ });
333
+
334
+ assert.ok(!prompt.includes('THIS SHOULD NOT APPEAR IN PROMPT'));
335
+ });
336
+
337
+ it('handles missing externalMessage gracefully', () => {
338
+ const turns = [
339
+ { turnNumber: 0, phase: 'learner', externalMessage: null },
340
+ ];
341
+
342
+ const prompt = buildLearnerEvaluationPrompt({
343
+ turns,
344
+ targetTurnIndex: 0,
345
+ learnerArchitecture: 'unified',
346
+ });
347
+
348
+ assert.ok(prompt.includes('(no message)'));
349
+ });
350
+
351
+ it('recognizes psychodynamic as multi-agent', () => {
352
+ const prompt = buildLearnerEvaluationPrompt({
353
+ turns: sampleTurns,
354
+ targetTurnIndex: 2,
355
+ learnerArchitecture: 'psychodynamic',
356
+ });
357
+
358
+ assert.ok(prompt.includes('deliberation_depth'));
359
+ assert.ok(prompt.includes('Score ALL dimensions including deliberation_depth'));
360
+ });
361
+ });
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Tests for pure helper functions in learnerTutorInteractionEngine.
3
+ *
4
+ * Tests only the exported utility functions that have no LLM dependencies.
5
+ * The full runInteraction() and generateLearnerResponse() flows require
6
+ * LLM calls and are better tested via integration tests.
7
+ *
8
+ * Uses node:test (built-in, no dependencies required).
9
+ * Run: node --test services/__tests__/learnerTutorInteractionEngine.test.js
10
+ */
11
+
12
+ import { describe, it } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+
15
+ import {
16
+ detectEmotionalState,
17
+ detectUnderstandingLevel,
18
+ detectTutorStrategy,
19
+ extractTutorMessage,
20
+ calculateMemoryDelta,
21
+ INTERACTION_OUTCOMES,
22
+ } from '../learnerTutorInteractionEngine.js';
23
+
24
+ // ============================================================================
25
+ // INTERACTION_OUTCOMES
26
+ // ============================================================================
27
+
28
+ describe('INTERACTION_OUTCOMES', () => {
29
+ it('contains all expected outcome types', () => {
30
+ assert.strictEqual(INTERACTION_OUTCOMES.BREAKTHROUGH, 'breakthrough');
31
+ assert.strictEqual(INTERACTION_OUTCOMES.PRODUCTIVE_STRUGGLE, 'productive_struggle');
32
+ assert.strictEqual(INTERACTION_OUTCOMES.MUTUAL_RECOGNITION, 'mutual_recognition');
33
+ assert.strictEqual(INTERACTION_OUTCOMES.FRUSTRATION, 'frustration');
34
+ assert.strictEqual(INTERACTION_OUTCOMES.DISENGAGEMENT, 'disengagement');
35
+ assert.strictEqual(INTERACTION_OUTCOMES.SCAFFOLDING_NEEDED, 'scaffolding_needed');
36
+ assert.strictEqual(INTERACTION_OUTCOMES.FADING_APPROPRIATE, 'fading_appropriate');
37
+ assert.strictEqual(INTERACTION_OUTCOMES.TRANSFORMATION, 'transformation');
38
+ });
39
+
40
+ it('has exactly 8 outcomes', () => {
41
+ assert.strictEqual(Object.keys(INTERACTION_OUTCOMES).length, 8);
42
+ });
43
+ });
44
+
45
+ // ============================================================================
46
+ // detectEmotionalState
47
+ // ============================================================================
48
+
49
+ describe('detectEmotionalState', () => {
50
+ it('detects frustrated state', () => {
51
+ const delib = [{ role: 'ego', content: 'I am so frustrated, I want to give up on this confusing topic.' }];
52
+ assert.strictEqual(detectEmotionalState(delib), 'frustrated');
53
+ });
54
+
55
+ it('detects engaged state from excitement', () => {
56
+ const delib = [{ role: 'ego', content: 'This is really exciting and interesting!' }];
57
+ assert.strictEqual(detectEmotionalState(delib), 'engaged');
58
+ });
59
+
60
+ it('detects engaged state from curiosity', () => {
61
+ const delib = [{ role: 'ego', content: 'I am curious about how this works.' }];
62
+ assert.strictEqual(detectEmotionalState(delib), 'engaged');
63
+ });
64
+
65
+ it('detects disengaged state', () => {
66
+ const delib = [{ role: 'ego', content: 'I am bored with this, whatever.' }];
67
+ assert.strictEqual(detectEmotionalState(delib), 'disengaged');
68
+ });
69
+
70
+ it('detects satisfied state', () => {
71
+ const delib = [{ role: 'ego', content: 'I understand this concept now.' }];
72
+ assert.strictEqual(detectEmotionalState(delib), 'satisfied');
73
+ });
74
+
75
+ it('detects confused state', () => {
76
+ const delib = [{ role: 'ego', content: 'I am confused by the terminology.' }];
77
+ assert.strictEqual(detectEmotionalState(delib), 'confused');
78
+ });
79
+
80
+ it('returns neutral when no signals found', () => {
81
+ const delib = [{ role: 'ego', content: 'The topic at hand is dialectics.' }];
82
+ assert.strictEqual(detectEmotionalState(delib), 'neutral');
83
+ });
84
+
85
+ it('combines text from multiple deliberation steps', () => {
86
+ const delib = [
87
+ { role: 'ego', content: 'Hmm let me think about this.' },
88
+ { role: 'superego', content: 'This is really interesting, push deeper.' },
89
+ ];
90
+ // 'interesting' triggers engaged
91
+ assert.strictEqual(detectEmotionalState(delib), 'engaged');
92
+ });
93
+ });
94
+
95
+ // ============================================================================
96
+ // detectUnderstandingLevel
97
+ // ============================================================================
98
+
99
+ describe('detectUnderstandingLevel', () => {
100
+ it('detects none level', () => {
101
+ const delib = [{ role: 'ego', content: 'I am completely lost here, I have no idea what this means.' }];
102
+ assert.strictEqual(detectUnderstandingLevel(delib), 'none');
103
+ });
104
+
105
+ it('detects partial level', () => {
106
+ const delib = [{ role: 'ego', content: 'I am starting to see the pattern, maybe it works like this.' }];
107
+ assert.strictEqual(detectUnderstandingLevel(delib), 'partial');
108
+ });
109
+
110
+ it('detects solid level with "makes sense"', () => {
111
+ const delib = [{ role: 'ego', content: 'That makes sense now, I see how these ideas connect.' }];
112
+ assert.strictEqual(detectUnderstandingLevel(delib), 'solid');
113
+ });
114
+
115
+ it('detects solid level with "i get it"', () => {
116
+ const delib = [{ role: 'ego', content: 'Oh, i get it! The synthesis transforms both sides.' }];
117
+ assert.strictEqual(detectUnderstandingLevel(delib), 'solid');
118
+ });
119
+
120
+ it('detects transforming level', () => {
121
+ const delib = [{ role: 'ego', content: 'Wait, so that means the whole framework needs restructuring.' }];
122
+ assert.strictEqual(detectUnderstandingLevel(delib), 'transforming');
123
+ });
124
+
125
+ it('returns developing by default', () => {
126
+ const delib = [{ role: 'ego', content: 'I am working through the problem carefully.' }];
127
+ assert.strictEqual(detectUnderstandingLevel(delib), 'developing');
128
+ });
129
+ });
130
+
131
+ // ============================================================================
132
+ // detectTutorStrategy
133
+ // ============================================================================
134
+
135
+ describe('detectTutorStrategy', () => {
136
+ it('detects socratic_questioning', () => {
137
+ assert.strictEqual(
138
+ detectTutorStrategy('What do you think would happen if we applied this differently?'),
139
+ 'socratic_questioning'
140
+ );
141
+ });
142
+
143
+ it('detects socratic_questioning with "how might"', () => {
144
+ assert.strictEqual(
145
+ detectTutorStrategy('How might this concept relate to your experience?'),
146
+ 'socratic_questioning'
147
+ );
148
+ });
149
+
150
+ it('detects concrete_examples', () => {
151
+ assert.strictEqual(
152
+ detectTutorStrategy('For example, imagine you are building a bridge.'),
153
+ 'concrete_examples'
154
+ );
155
+ });
156
+
157
+ it('detects concrete_examples with "like when"', () => {
158
+ assert.strictEqual(
159
+ detectTutorStrategy('It is like when you first learned to ride a bicycle.'),
160
+ 'concrete_examples'
161
+ );
162
+ });
163
+
164
+ it('detects scaffolding', () => {
165
+ assert.strictEqual(
166
+ detectTutorStrategy('Let me break this down. First, we look at the thesis.'),
167
+ 'scaffolding'
168
+ );
169
+ });
170
+
171
+ it('detects validation', () => {
172
+ assert.strictEqual(
173
+ detectTutorStrategy("You're right, that is an important insight."),
174
+ 'validation'
175
+ );
176
+ });
177
+
178
+ it('detects validation with "good observation"', () => {
179
+ assert.strictEqual(
180
+ detectTutorStrategy('Good observation! That connection is key.'),
181
+ 'validation'
182
+ );
183
+ });
184
+
185
+ it('detects gentle_correction', () => {
186
+ assert.strictEqual(
187
+ detectTutorStrategy('Actually, there is an important distinction between these concepts.'),
188
+ 'gentle_correction'
189
+ );
190
+ });
191
+
192
+ it('detects intellectual_challenge', () => {
193
+ assert.strictEqual(
194
+ detectTutorStrategy('Consider what would happen in the opposite case.'),
195
+ 'intellectual_challenge'
196
+ );
197
+ });
198
+
199
+ it('returns direct_explanation as default', () => {
200
+ assert.strictEqual(
201
+ detectTutorStrategy('Dialectics is a philosophical framework developed by Hegel.'),
202
+ 'direct_explanation'
203
+ );
204
+ });
205
+ });
206
+
207
+ // ============================================================================
208
+ // extractTutorMessage
209
+ // ============================================================================
210
+
211
+ describe('extractTutorMessage', () => {
212
+ it('returns plain text as-is', () => {
213
+ assert.strictEqual(
214
+ extractTutorMessage('Hello, let me help you understand this concept.'),
215
+ 'Hello, let me help you understand this concept.'
216
+ );
217
+ });
218
+
219
+ it('extracts message from JSON array (tutor suggestion format)', () => {
220
+ const json = JSON.stringify([{ message: 'This is the tutor response.' }]);
221
+ assert.strictEqual(
222
+ extractTutorMessage(json),
223
+ 'This is the tutor response.'
224
+ );
225
+ });
226
+
227
+ it('extracts message from single JSON object', () => {
228
+ const json = JSON.stringify({ message: 'A single suggestion.' });
229
+ assert.strictEqual(
230
+ extractTutorMessage(json),
231
+ 'A single suggestion.'
232
+ );
233
+ });
234
+
235
+ it('returns empty string for null input', () => {
236
+ assert.strictEqual(extractTutorMessage(null), '');
237
+ });
238
+
239
+ it('returns empty string for undefined input', () => {
240
+ assert.strictEqual(extractTutorMessage(undefined), '');
241
+ });
242
+
243
+ it('returns empty string for empty string input', () => {
244
+ assert.strictEqual(extractTutorMessage(''), '');
245
+ });
246
+
247
+ it('returns original text for invalid JSON that starts with [', () => {
248
+ const text = '[not valid json at all';
249
+ assert.strictEqual(extractTutorMessage(text), text);
250
+ });
251
+
252
+ it('returns original text for JSON array without message field', () => {
253
+ const json = JSON.stringify([{ text: 'no message field' }]);
254
+ assert.strictEqual(extractTutorMessage(json), json);
255
+ });
256
+
257
+ it('handles JSON with whitespace padding', () => {
258
+ const json = ' ' + JSON.stringify([{ message: 'padded' }]) + ' ';
259
+ assert.strictEqual(extractTutorMessage(json), 'padded');
260
+ });
261
+ });
262
+
263
+ // ============================================================================
264
+ // calculateMemoryDelta
265
+ // ============================================================================
266
+
267
+ describe('calculateMemoryDelta', () => {
268
+ it('returns noData when before is null', () => {
269
+ const result = calculateMemoryDelta(null, { preconscious: {} });
270
+ assert.deepStrictEqual(result, { noData: true });
271
+ });
272
+
273
+ it('returns noData when after is null', () => {
274
+ const result = calculateMemoryDelta({ preconscious: {} }, null);
275
+ assert.deepStrictEqual(result, { noData: true });
276
+ });
277
+
278
+ it('returns noData when both are null', () => {
279
+ const result = calculateMemoryDelta(null, null);
280
+ assert.deepStrictEqual(result, { noData: true });
281
+ });
282
+
283
+ it('calculates zero delta when nothing changed', () => {
284
+ const state = {
285
+ preconscious: { lessons: ['a', 'b'] },
286
+ unconscious: { breakthroughs: ['x'], unresolvedTraumas: [] },
287
+ };
288
+ const result = calculateMemoryDelta(state, state);
289
+ assert.deepStrictEqual(result, {
290
+ newLessons: 0,
291
+ newBreakthroughs: 0,
292
+ newTraumas: 0,
293
+ });
294
+ });
295
+
296
+ it('calculates positive deltas when items added', () => {
297
+ const before = {
298
+ preconscious: { lessons: ['a'] },
299
+ unconscious: { breakthroughs: [], unresolvedTraumas: [] },
300
+ };
301
+ const after = {
302
+ preconscious: { lessons: ['a', 'b', 'c'] },
303
+ unconscious: { breakthroughs: ['x'], unresolvedTraumas: ['y'] },
304
+ };
305
+ const result = calculateMemoryDelta(before, after);
306
+ assert.deepStrictEqual(result, {
307
+ newLessons: 2,
308
+ newBreakthroughs: 1,
309
+ newTraumas: 1,
310
+ });
311
+ });
312
+
313
+ it('handles missing nested properties gracefully', () => {
314
+ const before = {};
315
+ const after = {
316
+ preconscious: { lessons: ['a'] },
317
+ unconscious: { breakthroughs: ['b'] },
318
+ };
319
+ const result = calculateMemoryDelta(before, after);
320
+ assert.deepStrictEqual(result, {
321
+ newLessons: 1,
322
+ newBreakthroughs: 1,
323
+ newTraumas: 0,
324
+ });
325
+ });
326
+ });