principles-disciple 1.40.0 → 1.41.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.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Tests for PrincipleCompiler orchestrator (Task 5)
3
+ *
4
+ * Strategy:
5
+ * - Create a temp workspace with TrajectoryDatabase and ledger
6
+ * - Insert a test principle with derivedFromPainIds
7
+ * - Record pain events and tool calls in the trajectory DB that match those painIds
8
+ * - Compile and verify success, ruleId, code content, and ledger registration
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+ import fs from 'fs';
13
+ import os from 'os';
14
+ import path from 'path';
15
+ import { TrajectoryDatabase } from '../../src/core/trajectory.js';
16
+ import {
17
+ saveLedger,
18
+ loadLedger,
19
+ type LedgerPrinciple,
20
+ type HybridLedgerStore,
21
+ } from '../../src/core/principle-tree-ledger.js';
22
+ import { PrincipleCompiler, type CompileResult } from '../../src/core/principle-compiler/compiler.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function makePrinciple(overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
29
+ return {
30
+ id: 'P_066',
31
+ version: 1,
32
+ text: 'Never run destructive bash commands without confirmation',
33
+ triggerPattern: 'bash.*destructive',
34
+ action: 'Block destructive bash commands',
35
+ status: 'active',
36
+ priority: 'P0',
37
+ scope: 'general',
38
+ evaluability: 'deterministic',
39
+ valueScore: 0,
40
+ adherenceRate: 0,
41
+ painPreventedCount: 0,
42
+ derivedFromPainIds: ['pain-bash-rm-001'],
43
+ ruleIds: [],
44
+ conflictsWithPrincipleIds: [],
45
+ createdAt: '2026-04-15T00:00:00.000Z',
46
+ updatedAt: '2026-04-15T00:00:00.000Z',
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ function makeLedgerStore(principle: LedgerPrinciple): HybridLedgerStore {
52
+ return {
53
+ trainingStore: {},
54
+ tree: {
55
+ principles: { [principle.id]: principle },
56
+ rules: {},
57
+ implementations: {},
58
+ metrics: {},
59
+ lastUpdated: '2026-04-15T00:00:00.000Z',
60
+ },
61
+ };
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Tests
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe('PrincipleCompiler', () => {
69
+ let tempDir: string;
70
+ let stateDir: string;
71
+ let trajectory: TrajectoryDatabase;
72
+
73
+ beforeEach(() => {
74
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-compiler-'));
75
+ stateDir = path.join(tempDir, '.state');
76
+ fs.mkdirSync(stateDir, { recursive: true });
77
+ trajectory = new TrajectoryDatabase({ workspaceDir: tempDir });
78
+ });
79
+
80
+ afterEach(() => {
81
+ trajectory.dispose();
82
+ fs.rmSync(tempDir, { recursive: true, force: true });
83
+ });
84
+
85
+ // -----------------------------------------------------------------------
86
+ // compileOne — happy path
87
+ // -----------------------------------------------------------------------
88
+
89
+ it('compiles a principle with pain events and tool calls into a rule', () => {
90
+ const principle = makePrinciple();
91
+ saveLedger(stateDir, makeLedgerStore(principle));
92
+
93
+ // Record a pain event whose reason contains the painId stored in derivedFromPainIds
94
+ trajectory.recordPainEvent({
95
+ sessionId: 's1',
96
+ source: 'gate',
97
+ score: 80,
98
+ reason: 'pain-bash-rm-001: destructive bash command rm -rf',
99
+ severity: 'severe',
100
+ createdAt: '2026-04-15T10:00:00.000Z',
101
+ });
102
+
103
+ // Record a failed tool call for context
104
+ trajectory.recordToolCall({
105
+ sessionId: 's1',
106
+ toolName: 'bash',
107
+ outcome: 'failure',
108
+ errorType: 'EACCES',
109
+ errorMessage: 'permission denied',
110
+ createdAt: '2026-04-15T10:01:00.000Z',
111
+ });
112
+
113
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
114
+ const result = compiler.compileOne('P_066');
115
+
116
+ expect(result.success).toBe(true);
117
+ expect(result.principleId).toBe('P_066');
118
+ expect(result.ruleId).toBe('R_P_066_auto');
119
+ expect(result.implementationId).toBe('IMPL_P_066_auto');
120
+ expect(result.code).toBeTruthy();
121
+ expect(result.code).toContain('evaluate');
122
+ // The code should contain the tool name from the pain event reason
123
+ expect(result.code).toContain('bash');
124
+
125
+ // Verify ledger registration
126
+ const ledger = loadLedger(stateDir);
127
+ const rule = ledger.tree.rules['R_P_066_auto'];
128
+ expect(rule).toBeDefined();
129
+ expect(rule.type).toBe('gate');
130
+ expect(rule.enforcement).toBe('block');
131
+ expect(rule.status).toBe('proposed');
132
+
133
+ const impl = ledger.tree.implementations['IMPL_P_066_auto'];
134
+ expect(impl).toBeDefined();
135
+ expect(impl.lifecycleState).toBe('active');
136
+ });
137
+
138
+ // -----------------------------------------------------------------------
139
+ // compileOne — no context (principle not found)
140
+ // -----------------------------------------------------------------------
141
+
142
+ it('returns failure when principle is not found in ledger', () => {
143
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
144
+ const result = compiler.compileOne('P_NONEXISTENT');
145
+
146
+ expect(result.success).toBe(false);
147
+ expect(result.principleId).toBe('P_NONEXISTENT');
148
+ expect(result.reason).toBe('no context');
149
+ });
150
+
151
+ // -----------------------------------------------------------------------
152
+ // compileOne — no derivedFromPainIds
153
+ // -----------------------------------------------------------------------
154
+
155
+ it('returns failure when principle has no derivedFromPainIds', () => {
156
+ const principle = makePrinciple({ derivedFromPainIds: [] });
157
+ saveLedger(stateDir, makeLedgerStore(principle));
158
+
159
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
160
+ const result = compiler.compileOne('P_066');
161
+
162
+ expect(result.success).toBe(false);
163
+ expect(result.reason).toBe('no context');
164
+ });
165
+
166
+ // -----------------------------------------------------------------------
167
+ // compileOne — no patterns extracted (pain events exist but no tool info)
168
+ // -----------------------------------------------------------------------
169
+
170
+ it('returns failure when no patterns can be extracted from context', () => {
171
+ const principle = makePrinciple();
172
+ saveLedger(stateDir, makeLedgerStore(principle));
173
+
174
+ // Record a pain event that matches but has no tool-related info
175
+ trajectory.recordPainEvent({
176
+ sessionId: 's1',
177
+ source: 'gate',
178
+ score: 50,
179
+ reason: 'pain-bash-rm-001: something happened',
180
+ severity: 'mild',
181
+ createdAt: '2026-04-15T10:00:00.000Z',
182
+ });
183
+
184
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
185
+ const result = compiler.compileOne('P_066');
186
+
187
+ // Even without tool calls, if we can infer toolName from the pain event,
188
+ // we get patterns. This pain event has no tool name info, so we should
189
+ // still attempt to extract patterns but the sessionSnapshot.toolCalls
190
+ // will be empty. The pain event's reason doesn't contain a recognizable tool name.
191
+ // However, we may still extract patterns from the pain event if we match tool names.
192
+ // This depends on implementation — but without any tool info, patterns may be empty.
193
+ if (!result.success) {
194
+ expect(result.reason).toBeTruthy();
195
+ }
196
+ // This test verifies the compiler handles the edge case gracefully.
197
+ });
198
+
199
+ // -----------------------------------------------------------------------
200
+ // compileAll
201
+ // -----------------------------------------------------------------------
202
+
203
+ it('compiles all eligible principles', () => {
204
+ const p1 = makePrinciple({ id: 'P_066', derivedFromPainIds: ['pain-bash-rm-001'] });
205
+ const p2 = makePrinciple({
206
+ id: 'P_067',
207
+ derivedFromPainIds: ['pain-edit-bad-001'],
208
+ triggerPattern: 'edit.*unsafe',
209
+ text: 'Never edit files without reading first',
210
+ });
211
+ saveLedger(stateDir, makeLedgerStore(p1));
212
+ // Add second principle to existing ledger
213
+ const ledger = loadLedger(stateDir);
214
+ ledger.tree.principles['P_067'] = p2;
215
+ saveLedger(stateDir, ledger);
216
+
217
+ // Pain events for both
218
+ trajectory.recordPainEvent({
219
+ sessionId: 's1',
220
+ source: 'gate',
221
+ score: 80,
222
+ reason: 'pain-bash-rm-001: destructive bash command',
223
+ severity: 'severe',
224
+ createdAt: '2026-04-15T10:00:00.000Z',
225
+ });
226
+
227
+ trajectory.recordToolCall({
228
+ sessionId: 's1',
229
+ toolName: 'bash',
230
+ outcome: 'failure',
231
+ createdAt: '2026-04-15T10:01:00.000Z',
232
+ });
233
+
234
+ trajectory.recordPainEvent({
235
+ sessionId: 's2',
236
+ source: 'gate',
237
+ score: 70,
238
+ reason: 'pain-edit-bad-001: unsafe edit operation on config.ts',
239
+ severity: 'moderate',
240
+ createdAt: '2026-04-15T11:00:00.000Z',
241
+ });
242
+
243
+ trajectory.recordToolCall({
244
+ sessionId: 's2',
245
+ toolName: 'edit',
246
+ outcome: 'failure',
247
+ createdAt: '2026-04-15T11:01:00.000Z',
248
+ });
249
+
250
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
251
+ const results = compiler.compileAll();
252
+
253
+ expect(results).toHaveLength(2);
254
+ expect(results.every((r) => r.success)).toBe(true);
255
+ expect(results.map((r) => r.principleId).sort()).toEqual(['P_066', 'P_067']);
256
+ });
257
+
258
+ it('skips principles without derivedFromPainIds in compileAll', () => {
259
+ const p1 = makePrinciple({ derivedFromPainIds: ['pain-001'] });
260
+ const p2 = makePrinciple({ id: 'P_067', derivedFromPainIds: [] });
261
+ saveLedger(stateDir, makeLedgerStore(p1));
262
+ const ledger = loadLedger(stateDir);
263
+ ledger.tree.principles['P_067'] = p2;
264
+ saveLedger(stateDir, ledger);
265
+
266
+ trajectory.recordPainEvent({
267
+ sessionId: 's1',
268
+ source: 'gate',
269
+ score: 80,
270
+ reason: 'pain-001: bash destructive',
271
+ severity: 'severe',
272
+ createdAt: '2026-04-15T10:00:00.000Z',
273
+ });
274
+
275
+ trajectory.recordToolCall({
276
+ sessionId: 's1',
277
+ toolName: 'bash',
278
+ outcome: 'failure',
279
+ createdAt: '2026-04-15T10:01:00.000Z',
280
+ });
281
+
282
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
283
+ const results = compiler.compileAll();
284
+
285
+ expect(results).toHaveLength(1);
286
+ expect(results[0].principleId).toBe('P_066');
287
+ });
288
+
289
+ // -----------------------------------------------------------------------
290
+ // Pattern extraction edge cases
291
+ // -----------------------------------------------------------------------
292
+
293
+ it('extracts toolName from pain event reason containing "edit"', () => {
294
+ const principle = makePrinciple({
295
+ id: 'P_070',
296
+ derivedFromPainIds: ['pain-edit-001'],
297
+ });
298
+ saveLedger(stateDir, makeLedgerStore(principle));
299
+
300
+ trajectory.recordPainEvent({
301
+ sessionId: 's1',
302
+ source: 'gate',
303
+ score: 75,
304
+ reason: 'pain-edit-001: unsafe edit on src/config.ts without reading',
305
+ severity: 'moderate',
306
+ createdAt: '2026-04-15T10:00:00.000Z',
307
+ });
308
+
309
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
310
+ const result = compiler.compileOne('P_070');
311
+
312
+ expect(result.success).toBe(true);
313
+ expect(result.code).toContain('edit');
314
+ });
315
+
316
+ it('extracts toolName from sessionSnapshot tool calls', () => {
317
+ const principle = makePrinciple({
318
+ id: 'P_071',
319
+ derivedFromPainIds: ['pain-write-001'],
320
+ });
321
+ saveLedger(stateDir, makeLedgerStore(principle));
322
+
323
+ trajectory.recordPainEvent({
324
+ sessionId: 's1',
325
+ source: 'gate',
326
+ score: 70,
327
+ reason: 'pain-write-001: something bad happened',
328
+ severity: 'moderate',
329
+ createdAt: '2026-04-15T10:00:00.000Z',
330
+ });
331
+
332
+ // Failed tool call provides the tool name
333
+ trajectory.recordToolCall({
334
+ sessionId: 's1',
335
+ toolName: 'write',
336
+ outcome: 'failure',
337
+ errorType: 'ENOENT',
338
+ errorMessage: 'file not found',
339
+ createdAt: '2026-04-15T10:01:00.000Z',
340
+ });
341
+
342
+ const compiler = new PrincipleCompiler(stateDir, trajectory);
343
+ const result = compiler.compileOne('P_071');
344
+
345
+ expect(result.success).toBe(true);
346
+ expect(result.code).toContain('write');
347
+ });
348
+ });
@@ -0,0 +1,356 @@
1
+ /**
2
+ * ReflectionContextCollector Tests
3
+ *
4
+ * TDD test suite for the unified pipeline input collector.
5
+ */
6
+
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
10
+ import { safeRmDir } from '../test-utils.js';
11
+ import { saveLedger, type HybridLedgerStore, type LedgerPrinciple, type LedgerRule } from '../../src/core/principle-tree-ledger.js';
12
+ import { TrajectoryDatabase } from '../../src/core/trajectory.js';
13
+ import { ReflectionContextCollector, type ReflectionContext } from '../../src/core/reflection/reflection-context.js';
14
+
15
+ describe('ReflectionContextCollector', () => {
16
+ let tempDir: string;
17
+ let stateDir: string;
18
+ let workspaceDir: string;
19
+ let trajectory: TrajectoryDatabase;
20
+ let collector: ReflectionContextCollector;
21
+
22
+ beforeAll(() => {
23
+ tempDir = fs.mkdtempSync(path.join(process.env.TMP || '/tmp', 'pd-reflect-'));
24
+ workspaceDir = path.join(tempDir, 'workspace');
25
+ stateDir = path.join(workspaceDir, '.state');
26
+ fs.mkdirSync(stateDir, { recursive: true });
27
+
28
+ trajectory = new TrajectoryDatabase({ workspaceDir });
29
+ });
30
+
31
+ afterAll(() => {
32
+ trajectory.dispose();
33
+ safeRmDir(tempDir);
34
+ });
35
+
36
+ beforeEach(() => {
37
+ collector = new ReflectionContextCollector(stateDir, trajectory);
38
+ });
39
+
40
+ afterEach(() => {
41
+ // Clean up ledger state file
42
+ const stateFile = path.join(stateDir, 'principle_training_state.json');
43
+ if (fs.existsSync(stateFile)) {
44
+ fs.unlinkSync(stateFile);
45
+ }
46
+ });
47
+
48
+ // Helper: Create a minimal ledger principle
49
+ function makePrinciple(
50
+ principleId: string,
51
+ overrides: Partial<LedgerPrinciple> = {},
52
+ ): LedgerPrinciple {
53
+ return {
54
+ id: principleId,
55
+ version: 1,
56
+ text: `Test principle ${principleId}`,
57
+ triggerPattern: 'test',
58
+ action: 'test action',
59
+ status: 'active',
60
+ priority: 'P1',
61
+ scope: 'general',
62
+ evaluability: 'deterministic',
63
+ valueScore: 0,
64
+ adherenceRate: 0,
65
+ painPreventedCount: 0,
66
+ derivedFromPainIds: [],
67
+ ruleIds: [],
68
+ conflictsWithPrincipleIds: [],
69
+ createdAt: '2026-04-10T00:00:00.000Z',
70
+ updatedAt: '2026-04-10T00:00:00.000Z',
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ // Helper: Save ledger with given principles
76
+ function setupLedger(principles: LedgerPrinciple[]): void {
77
+ const tree = {
78
+ principles: {} as Record<string, LedgerPrinciple>,
79
+ rules: {} as Record<string, LedgerRule>,
80
+ implementations: {},
81
+ metrics: {},
82
+ lastUpdated: new Date().toISOString(),
83
+ };
84
+
85
+ for (const p of principles) {
86
+ tree.principles[p.id] = p;
87
+ }
88
+
89
+ const store: HybridLedgerStore = {
90
+ trainingStore: {},
91
+ tree,
92
+ };
93
+
94
+ saveLedger(stateDir, store);
95
+ }
96
+
97
+ // Helper: Record a session with pain events
98
+ function setupSession(
99
+ sessionId: string,
100
+ painEvents: { source: string; score: number; reason?: string; severity?: string }[],
101
+ ): void {
102
+ trajectory.recordSession({ sessionId });
103
+ for (const pe of painEvents) {
104
+ trajectory.recordPainEvent({
105
+ sessionId,
106
+ source: pe.source,
107
+ score: pe.score,
108
+ reason: pe.reason ?? null,
109
+ severity: pe.severity ?? null,
110
+ });
111
+ }
112
+ }
113
+
114
+ // -----------------------------------------------------------------------
115
+ // collect()
116
+ // -----------------------------------------------------------------------
117
+
118
+ describe('collect', () => {
119
+ it('returns null when principle is not found', () => {
120
+ setupLedger([]);
121
+ const result = collector.collect('P_MISSING');
122
+ expect(result).toBeNull();
123
+ });
124
+
125
+ it('returns null when principle has no derivedFromPainIds', () => {
126
+ setupLedger([makePrinciple('P_001')]);
127
+ const result = collector.collect('P_001');
128
+ expect(result).toBeNull();
129
+ });
130
+
131
+ it('returns context with empty painEvents when painIds do not match any session', () => {
132
+ setupLedger([
133
+ makePrinciple('P_001', { derivedFromPainIds: ['pain_no_exist'] }),
134
+ ]);
135
+
136
+ const result = collector.collect('P_001');
137
+
138
+ expect(result).not.toBeNull();
139
+ expect(result!.principle.id).toBe('P_001');
140
+ expect(result!.painEvents).toEqual([]);
141
+ expect(result!.sessionSnapshot).toBeNull();
142
+ expect(result!.lineage.sourcePainIds).toEqual(['pain_no_exist']);
143
+ expect(result!.lineage.sessionId).toBeNull();
144
+ });
145
+
146
+ it('returns context with pain events and null snapshot when painIds match events but not a specific session', () => {
147
+ setupSession('sess_001', [
148
+ { source: 'gate_block', score: 80, reason: 'unsafe write', severity: 'severe' },
149
+ ]);
150
+
151
+ // We can't directly match painIds to sessions via the current API,
152
+ // so we provide the session that contains the pain events.
153
+ // Since painId resolution is a known gap, we return what we can.
154
+ setupLedger([
155
+ makePrinciple('P_002', { derivedFromPainIds: ['pain_001'] }),
156
+ ]);
157
+
158
+ const result = collector.collect('P_002');
159
+
160
+ expect(result).not.toBeNull();
161
+ expect(result!.principle.id).toBe('P_002');
162
+ expect(result!.lineage.sourcePainIds).toEqual(['pain_001']);
163
+ // painId -> sessionId resolution is a known gap, so painEvents may be empty
164
+ // and sessionSnapshot may be null
165
+ });
166
+
167
+ it('resolves pain events from a known session', () => {
168
+ const sessionId = 'sess_with_pain';
169
+ setupSession(sessionId, [
170
+ { source: 'gate_block', score: 90, reason: 'unsafe delete', severity: 'severe' },
171
+ { source: 'tool_failure', score: 50, reason: 'bad edit', severity: 'moderate' },
172
+ ]);
173
+
174
+ setupLedger([
175
+ makePrinciple('P_003', {
176
+ derivedFromPainIds: ['pain_from_sess_with_pain'],
177
+ }),
178
+ ]);
179
+
180
+ const result = collector.collect('P_003');
181
+
182
+ expect(result).not.toBeNull();
183
+ expect(result!.principle.id).toBe('P_003');
184
+ // painId -> sessionId gap: we attempt to find sessions containing pain events
185
+ // The lineage sessionId should be populated if we can resolve it
186
+ expect(result!.lineage.sourcePainIds).toEqual(['pain_from_sess_with_pain']);
187
+ });
188
+ });
189
+
190
+ // -----------------------------------------------------------------------
191
+ // collectBatch()
192
+ // -----------------------------------------------------------------------
193
+
194
+ describe('collectBatch', () => {
195
+ it('returns empty array when no principles exist', () => {
196
+ setupLedger([]);
197
+ const results = collector.collectBatch();
198
+ expect(results).toEqual([]);
199
+ });
200
+
201
+ it('skips principles without derivedFromPainIds', () => {
202
+ setupLedger([
203
+ makePrinciple('P_NO_PAIN'),
204
+ makePrinciple('P_WITH_PAIN', { derivedFromPainIds: ['pain_001'] }),
205
+ ]);
206
+
207
+ const results = collector.collectBatch();
208
+
209
+ expect(results).toHaveLength(1);
210
+ expect(results[0].principle.id).toBe('P_WITH_PAIN');
211
+ });
212
+
213
+ it('filters by status when provided', () => {
214
+ setupLedger([
215
+ makePrinciple('P_ACTIVE', {
216
+ status: 'active',
217
+ derivedFromPainIds: ['pain_001'],
218
+ }),
219
+ makePrinciple('P_DEPRECATED', {
220
+ status: 'deprecated',
221
+ derivedFromPainIds: ['pain_002'],
222
+ }),
223
+ makePrinciple('P_CANDIDATE', {
224
+ status: 'candidate',
225
+ derivedFromPainIds: ['pain_003'],
226
+ }),
227
+ ]);
228
+
229
+ const activeResults = collector.collectBatch({ status: 'active' });
230
+ expect(activeResults).toHaveLength(1);
231
+ expect(activeResults[0].principle.id).toBe('P_ACTIVE');
232
+
233
+ const deprecatedResults = collector.collectBatch({ status: 'deprecated' });
234
+ expect(deprecatedResults).toHaveLength(1);
235
+ expect(deprecatedResults[0].principle.id).toBe('P_DEPRECATED');
236
+ });
237
+
238
+ it('returns all principles with derivedFromPainIds when no filter', () => {
239
+ setupLedger([
240
+ makePrinciple('P_001', { derivedFromPainIds: ['pain_a'] }),
241
+ makePrinciple('P_002', { derivedFromPainIds: ['pain_b', 'pain_c'] }),
242
+ makePrinciple('P_NO_PAIN'),
243
+ ]);
244
+
245
+ const results = collector.collectBatch();
246
+
247
+ expect(results).toHaveLength(2);
248
+ const ids = results.map((r) => r.principle.id).sort();
249
+ expect(ids).toEqual(['P_001', 'P_002']);
250
+ });
251
+
252
+ it('each result contains valid lineage info', () => {
253
+ setupLedger([
254
+ makePrinciple('P_MULTI', { derivedFromPainIds: ['p1', 'p2', 'p3'] }),
255
+ ]);
256
+
257
+ const results = collector.collectBatch();
258
+ expect(results).toHaveLength(1);
259
+
260
+ const ctx = results[0];
261
+ expect(ctx.lineage.sourcePainIds).toEqual(['p1', 'p2', 'p3']);
262
+ expect(ctx.lineage.sessionId).toBeNull();
263
+ expect(ctx.sessionSnapshot).toBeNull();
264
+ });
265
+ });
266
+
267
+ // -----------------------------------------------------------------------
268
+ // Edge cases
269
+ // -----------------------------------------------------------------------
270
+
271
+ describe('edge cases', () => {
272
+ it('prefers exact ID match over substring heuristic', () => {
273
+ // Create two pain events in a new session.
274
+ // After earlier tests, these will get auto-increment IDs (e.g., 4 and 5).
275
+ // We'll use the SECOND event's ID for matching, then verify only that
276
+ // ONE event matches (not both by substring).
277
+ const sessionId = 'sess_exact_match';
278
+ setupSession(sessionId, [
279
+ { source: 'gate_block', score: 90, reason: 'exact_match_target', severity: 'severe' },
280
+ { source: 'gate_block', score: 50, reason: 'exact_match_target_10', severity: 'moderate' },
281
+ ]);
282
+
283
+ // Find the actual ID of the first pain event in this session
284
+ const sessionPainEvents = trajectory.listPainEventsForSession(sessionId);
285
+ expect(sessionPainEvents.length).toBeGreaterThanOrEqual(2);
286
+ const targetId = String(sessionPainEvents[0].id);
287
+
288
+ // If substring matching were used, the painId (e.g., "4") could match
289
+ // strings containing "4" in other sessions. Exact match prevents this.
290
+ setupLedger([
291
+ makePrinciple('P_EXACT', { derivedFromPainIds: [targetId] }),
292
+ ]);
293
+
294
+ const result = collector.collect('P_EXACT');
295
+
296
+ expect(result).not.toBeNull();
297
+ // Exact match: only ONE pain event should match
298
+ expect(result!.painEvents).toHaveLength(1);
299
+ expect(result!.painEvents[0].reason).toBe('exact_match_target');
300
+ expect(result!.lineage.sessionId).toBe(sessionId);
301
+ });
302
+
303
+ it('falls back to substring heuristic when no exact ID match', () => {
304
+ const sessionId = 'sess_heuristic';
305
+ setupSession(sessionId, [
306
+ { source: 'gate_block', score: 80, reason: 'UNIQUE_SUBSTRING_xyz write', severity: 'severe' },
307
+ ]);
308
+
309
+ // No pain event has this as its row ID, but substring "UNIQUE_SUBSTRING_xyz" is in the reason
310
+ setupLedger([
311
+ makePrinciple('P_HEUR', { derivedFromPainIds: ['UNIQUE_SUBSTRING_xyz'] }),
312
+ ]);
313
+
314
+ const result = collector.collect('P_HEUR');
315
+
316
+ expect(result).not.toBeNull();
317
+ expect(result!.painEvents.length).toBeGreaterThanOrEqual(1);
318
+ expect(result!.painEvents.some(pe => pe.reason === 'UNIQUE_SUBSTRING_xyz write')).toBe(true);
319
+ });
320
+
321
+ it('handles empty derivedFromPainIds array (not undefined)', () => {
322
+ setupLedger([
323
+ makePrinciple('P_EMPTY', { derivedFromPainIds: [] }),
324
+ ]);
325
+
326
+ const result = collector.collect('P_EMPTY');
327
+ expect(result).toBeNull();
328
+ });
329
+
330
+ it('collectBatch returns context objects with correct shape', () => {
331
+ setupLedger([
332
+ makePrinciple('P_SHAPE', {
333
+ derivedFromPainIds: ['pain_shape'],
334
+ text: 'Shape test principle',
335
+ action: 'Do the thing',
336
+ }),
337
+ ]);
338
+
339
+ const results = collector.collectBatch();
340
+ expect(results).toHaveLength(1);
341
+
342
+ const ctx: ReflectionContext = results[0];
343
+ // Verify all fields exist
344
+ expect(ctx).toHaveProperty('principle');
345
+ expect(ctx).toHaveProperty('painEvents');
346
+ expect(ctx).toHaveProperty('sessionSnapshot');
347
+ expect(ctx).toHaveProperty('lineage');
348
+
349
+ expect(ctx.principle.id).toBe('P_SHAPE');
350
+ expect(ctx.principle.text).toBe('Shape test principle');
351
+ expect(ctx.principle.action).toBe('Do the thing');
352
+ expect(Array.isArray(ctx.painEvents)).toBe(true);
353
+ expect(ctx.lineage.sourcePainIds).toEqual(['pain_shape']);
354
+ });
355
+ });
356
+ });