principles-disciple 1.14.0 → 1.16.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.
@@ -1174,7 +1174,15 @@ async function executeNocturnalReflectionWithAdapter(
1174
1174
  quotaCheckPassed: true,
1175
1175
  },
1176
1176
  };
1177
- diagnostics.idle = { isIdle: true, mostRecentActivityAt: 0, idleForMs: 0, userActiveSessions: 0, abandonedSessionIds: [], trajectoryGuardrailConfirmsIdle: true, reason: 'selector skipped (override provided)' };
1177
+ diagnostics.idle = {
1178
+ isIdle: true,
1179
+ mostRecentActivityAt: 0,
1180
+ idleForMs: 0,
1181
+ userActiveSessions: 0,
1182
+ abandonedSessionIds: [],
1183
+ trajectoryGuardrailConfirmsIdle: true,
1184
+ reason: 'selector skipped (override provided)',
1185
+ };
1178
1186
  } else {
1179
1187
  // Normal Selector path
1180
1188
  const extractor = createNocturnalTrajectoryExtractor(workspaceDir, stateDir);
@@ -234,6 +234,7 @@ export class NocturnalWorkflowManager implements WorkflowManager {
234
234
  // When principleId is provided, we pass it as principleIdOverride to skip Selector.
235
235
  // When principleId is missing, Selector will choose a principle from training store.
236
236
  this.logger.info(`[PD:NocturnalWorkflow] Calling executeNocturnalReflectionAsync for full pipeline (principleId=${principleId ?? 'auto-select'})`);
237
+ const pipelineStart = Date.now();
237
238
 
238
239
  // #213: Wrap fire-and-forget Promise with .catch() to prevent
239
240
  // unhandled promise rejections if anything throws outside the try-catch
@@ -263,25 +264,32 @@ export class NocturnalWorkflowManager implements WorkflowManager {
263
264
  );
264
265
 
265
266
  if (result.success) {
267
+ this.logger.info(`[PD:NocturnalWorkflow] [${workflowId}] Pipeline step 4/4: Completed successfully, artifactId=${result.diagnostics?.persistedPath}`);
268
+ this.store.updateWorkflowState(workflowId, 'completed');
266
269
  this.store.recordEvent(workflowId, 'nocturnal_completed', null, 'completed', 'Full pipeline completed via executeNocturnalReflectionAsync', {
267
270
  artifactId: result.diagnostics?.persistedPath,
268
271
  });
269
272
  this.completedWorkflows.set(workflowId, Date.now());
270
273
  } else {
271
274
  const reason = result.noTargetSelected ? 'no_target_selected' : 'validation_failed';
275
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Pipeline failed: reason=${reason}, noTargetSelected=${result.noTargetSelected}, skipReason=${result.skipReason ?? 'none'}, validationFailures=${result.validationFailures?.length ?? 0}`);
276
+ this.store.updateWorkflowState(workflowId, 'terminal_error');
272
277
  this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', reason, {
273
278
  failures: result.validationFailures,
274
279
  skipReason: result.skipReason,
275
280
  });
276
281
  }
277
282
  } catch (err) {
278
- this.logger.error(`[PD:NocturnalWorkflow] executeNocturnalReflectionAsync threw: ${String(err)}`);
283
+ const errDuration = Date.now() - pipelineStart;
284
+ this.logger.error(`[PD:NocturnalWorkflow] [${workflowId}] executeNocturnalReflectionAsync threw after ${errDuration}ms: ${String(err)}`);
285
+ this.store.updateWorkflowState(workflowId, 'terminal_error');
279
286
  this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', String(err), { workflowId });
280
287
  }
281
288
  }).catch((err) => {
282
- // #213: Outer catch catches errors from the fire-and-forget promise
289
+ // #213: Outer catch catches errors from the fire-and-forget promise
283
290
  // itself (e.g., if the async callback throws before entering try).
284
291
  this.logger.error(`[PD:NocturnalWorkflow] Unhandled error in async pipeline for ${workflowId}: ${String(err)}`);
292
+ this.store.updateWorkflowState(workflowId, 'terminal_error');
285
293
  this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', String(err), { workflowId });
286
294
  });
287
295
 
@@ -0,0 +1,582 @@
1
+ /**
2
+ * Bootstrap Rules Tests (Phase 17)
3
+ *
4
+ * TDD test suite for minimal rule bootstrap functionality.
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 {
12
+ bootstrapRules,
13
+ selectPrinciplesForBootstrap,
14
+ validateBootstrap,
15
+ } from '../../src/core/bootstrap-rules.js';
16
+ import {
17
+ createRule,
18
+ loadLedger,
19
+ saveLedger,
20
+ type HybridLedgerStore,
21
+ type LedgerPrinciple,
22
+ type LedgerRule,
23
+ } from '../../src/core/principle-tree-ledger.js';
24
+ import type { LegacyPrincipleTrainingStore, LegacyPrincipleTrainingState } from '../../src/core/principle-tree-ledger.js';
25
+
26
+ describe('bootstrap-rules', () => {
27
+ let tempDir: string;
28
+ let stateDir: string;
29
+
30
+ beforeAll(() => {
31
+ tempDir = fs.mkdtempSync(path.join(process.env.TMP || '/tmp', 'pd-bootstrap-'));
32
+ stateDir = path.join(tempDir, '.state');
33
+ fs.mkdirSync(stateDir, { recursive: true });
34
+ });
35
+
36
+ afterAll(() => {
37
+ safeRmDir(tempDir);
38
+ });
39
+
40
+ afterEach(() => {
41
+ // Clean up state file after each test
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 createLedgerPrinciple(principleId: string, overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
50
+ return {
51
+ id: principleId,
52
+ version: 1,
53
+ text: `Test principle ${principleId}`,
54
+ triggerPattern: 'test',
55
+ action: 'test action',
56
+ status: 'active',
57
+ priority: 'P1',
58
+ scope: 'general',
59
+ evaluability: 'deterministic',
60
+ valueScore: 0,
61
+ adherenceRate: 0,
62
+ painPreventedCount: 0,
63
+ derivedFromPainIds: [],
64
+ ruleIds: [],
65
+ conflictsWithPrincipleIds: [],
66
+ createdAt: '2026-04-10T00:00:00.000Z',
67
+ updatedAt: '2026-04-10T00:00:00.000Z',
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ // Helper: Setup ledger with training store and principles
73
+ function setupLedger(trainingStates: LegacyPrincipleTrainingState[], principles: LedgerPrinciple[]): void {
74
+ const trainingStore: LegacyPrincipleTrainingStore = {};
75
+ for (const state of trainingStates) {
76
+ trainingStore[state.principleId] = state;
77
+ }
78
+
79
+ const tree = {
80
+ principles: {} as Record<string, LedgerPrinciple>,
81
+ rules: {} as Record<string, LedgerRule>,
82
+ implementations: {},
83
+ metrics: {},
84
+ lastUpdated: new Date().toISOString(),
85
+ };
86
+
87
+ for (const principle of principles) {
88
+ tree.principles[principle.id] = principle;
89
+ }
90
+
91
+ const store: HybridLedgerStore = {
92
+ trainingStore,
93
+ tree,
94
+ };
95
+
96
+ saveLedger(stateDir, store);
97
+ }
98
+
99
+ describe('selectPrinciplesForBootstrap', () => {
100
+ it('selects deterministic principles sorted by violation count', () => {
101
+ // Setup: 4 principles (3 deterministic with violations 10, 5, 1; 1 manual_only with 100)
102
+ const trainingStates: LegacyPrincipleTrainingState[] = [
103
+ {
104
+ principleId: 'P_001',
105
+ evaluability: 'deterministic',
106
+ applicableOpportunityCount: 20,
107
+ observedViolationCount: 10,
108
+ complianceRate: 0.5,
109
+ violationTrend: 0,
110
+ generatedSampleCount: 0,
111
+ approvedSampleCount: 0,
112
+ includedTrainRunIds: [],
113
+ deployedCheckpointIds: [],
114
+ internalizationStatus: 'prompt_only',
115
+ },
116
+ {
117
+ principleId: 'P_002',
118
+ evaluability: 'deterministic',
119
+ applicableOpportunityCount: 15,
120
+ observedViolationCount: 5,
121
+ complianceRate: 0.6,
122
+ violationTrend: 0,
123
+ generatedSampleCount: 0,
124
+ approvedSampleCount: 0,
125
+ includedTrainRunIds: [],
126
+ deployedCheckpointIds: [],
127
+ internalizationStatus: 'prompt_only',
128
+ },
129
+ {
130
+ principleId: 'P_003',
131
+ evaluability: 'deterministic',
132
+ applicableOpportunityCount: 10,
133
+ observedViolationCount: 1,
134
+ complianceRate: 0.7,
135
+ violationTrend: 0,
136
+ generatedSampleCount: 0,
137
+ approvedSampleCount: 0,
138
+ includedTrainRunIds: [],
139
+ deployedCheckpointIds: [],
140
+ internalizationStatus: 'prompt_only',
141
+ },
142
+ {
143
+ principleId: 'P_004',
144
+ evaluability: 'manual_only',
145
+ applicableOpportunityCount: 100,
146
+ observedViolationCount: 100,
147
+ complianceRate: 0.5,
148
+ violationTrend: 0,
149
+ generatedSampleCount: 0,
150
+ approvedSampleCount: 0,
151
+ includedTrainRunIds: [],
152
+ deployedCheckpointIds: [],
153
+ internalizationStatus: 'prompt_only',
154
+ },
155
+ ];
156
+
157
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId, { evaluability: s.evaluability }));
158
+ setupLedger(trainingStates, principles);
159
+
160
+ // Act: Select top 2
161
+ const selected = selectPrinciplesForBootstrap(stateDir, 2);
162
+
163
+ // Assert: Should get P_001 (10 violations) and P_002 (5 violations)
164
+ expect(selected).toHaveLength(2);
165
+ expect(selected).toContain('P_001');
166
+ expect(selected).toContain('P_002');
167
+ expect(selected).not.toContain('P_003'); // Only 1 violation, not in top 2
168
+ expect(selected).not.toContain('P_004'); // manual_only, excluded
169
+ });
170
+
171
+ it('falls back to all deterministic when violation data is sparse', () => {
172
+ // Setup: 2 deterministic principles with 0 violations
173
+ const trainingStates: LegacyPrincipleTrainingState[] = [
174
+ {
175
+ principleId: 'P_001',
176
+ evaluability: 'deterministic',
177
+ applicableOpportunityCount: 0,
178
+ observedViolationCount: 0,
179
+ complianceRate: 1,
180
+ violationTrend: 0,
181
+ generatedSampleCount: 0,
182
+ approvedSampleCount: 0,
183
+ includedTrainRunIds: [],
184
+ deployedCheckpointIds: [],
185
+ internalizationStatus: 'prompt_only',
186
+ },
187
+ {
188
+ principleId: 'P_002',
189
+ evaluability: 'deterministic',
190
+ applicableOpportunityCount: 0,
191
+ observedViolationCount: 0,
192
+ complianceRate: 1,
193
+ violationTrend: 0,
194
+ generatedSampleCount: 0,
195
+ approvedSampleCount: 0,
196
+ includedTrainRunIds: [],
197
+ deployedCheckpointIds: [],
198
+ internalizationStatus: 'prompt_only',
199
+ },
200
+ ];
201
+
202
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId));
203
+ setupLedger(trainingStates, principles);
204
+
205
+ // Act: Select up to 3
206
+ const selected = selectPrinciplesForBootstrap(stateDir, 3);
207
+
208
+ // Assert: Both deterministic principles returned
209
+ expect(selected).toHaveLength(2);
210
+ expect(selected).toContain('P_001');
211
+ expect(selected).toContain('P_002');
212
+ });
213
+
214
+ it('throws when no deterministic principles exist', () => {
215
+ // Setup: Only manual_only principles
216
+ const trainingStates: LegacyPrincipleTrainingState[] = [
217
+ {
218
+ principleId: 'P_001',
219
+ evaluability: 'manual_only',
220
+ applicableOpportunityCount: 10,
221
+ observedViolationCount: 5,
222
+ complianceRate: 0.5,
223
+ violationTrend: 0,
224
+ generatedSampleCount: 0,
225
+ approvedSampleCount: 0,
226
+ includedTrainRunIds: [],
227
+ deployedCheckpointIds: [],
228
+ internalizationStatus: 'prompt_only',
229
+ },
230
+ ];
231
+
232
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId, { evaluability: s.evaluability }));
233
+ setupLedger(trainingStates, principles);
234
+
235
+ // Act & Assert: Should throw
236
+ expect(() => selectPrinciplesForBootstrap(stateDir, 3)).toThrow('No deterministic principles');
237
+ });
238
+ });
239
+
240
+ describe('bootstrapRules', () => {
241
+ it('creates stub rules with correct ID format and fields', () => {
242
+ // Setup: 2 deterministic principles
243
+ const trainingStates: LegacyPrincipleTrainingState[] = [
244
+ {
245
+ principleId: 'P_001',
246
+ evaluability: 'deterministic',
247
+ applicableOpportunityCount: 10,
248
+ observedViolationCount: 5,
249
+ complianceRate: 0.5,
250
+ violationTrend: 0,
251
+ generatedSampleCount: 0,
252
+ approvedSampleCount: 0,
253
+ includedTrainRunIds: [],
254
+ deployedCheckpointIds: [],
255
+ internalizationStatus: 'prompt_only',
256
+ },
257
+ {
258
+ principleId: 'P_002',
259
+ evaluability: 'deterministic',
260
+ applicableOpportunityCount: 8,
261
+ observedViolationCount: 3,
262
+ complianceRate: 0.6,
263
+ violationTrend: 0,
264
+ generatedSampleCount: 0,
265
+ approvedSampleCount: 0,
266
+ includedTrainRunIds: [],
267
+ deployedCheckpointIds: [],
268
+ internalizationStatus: 'prompt_only',
269
+ },
270
+ ];
271
+
272
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId));
273
+ setupLedger(trainingStates, principles);
274
+
275
+ // Act: Bootstrap 2 principles
276
+ const results = bootstrapRules(stateDir, 2);
277
+
278
+ // Assert: Check results
279
+ expect(results).toHaveLength(2);
280
+
281
+ const ledger = loadLedger(stateDir);
282
+
283
+ for (const result of results) {
284
+ expect(result.status).toBe('created');
285
+ expect(result.ruleId).toBe(`${result.principleId}_stub_bootstrap`);
286
+
287
+ const rule = ledger.tree.rules[result.ruleId];
288
+ expect(rule).toBeDefined();
289
+ expect(rule?.id).toBe(result.ruleId);
290
+ expect(rule?.type).toBe('hook');
291
+ expect(rule?.triggerCondition).toBe('stub: bootstrap placeholder');
292
+ expect(rule?.enforcement).toBe('warn');
293
+ expect(rule?.action).toBe('allow (stub)');
294
+ expect(rule?.status).toBe('proposed');
295
+ expect(rule?.coverageRate).toBe(0);
296
+ expect(rule?.falsePositiveRate).toBe(0);
297
+ expect(rule?.principleId).toBe(result.principleId);
298
+ }
299
+ });
300
+
301
+ it('links principles to rules via suggestedRules array', () => {
302
+ // Setup: 2 deterministic principles
303
+ const trainingStates: LegacyPrincipleTrainingState[] = [
304
+ {
305
+ principleId: 'P_001',
306
+ evaluability: 'deterministic',
307
+ applicableOpportunityCount: 10,
308
+ observedViolationCount: 5,
309
+ complianceRate: 0.5,
310
+ violationTrend: 0,
311
+ generatedSampleCount: 0,
312
+ approvedSampleCount: 0,
313
+ includedTrainRunIds: [],
314
+ deployedCheckpointIds: [],
315
+ internalizationStatus: 'prompt_only',
316
+ },
317
+ {
318
+ principleId: 'P_002',
319
+ evaluability: 'deterministic',
320
+ applicableOpportunityCount: 8,
321
+ observedViolationCount: 3,
322
+ complianceRate: 0.6,
323
+ violationTrend: 0,
324
+ generatedSampleCount: 0,
325
+ approvedSampleCount: 0,
326
+ includedTrainRunIds: [],
327
+ deployedCheckpointIds: [],
328
+ internalizationStatus: 'prompt_only',
329
+ },
330
+ ];
331
+
332
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId));
333
+ setupLedger(trainingStates, principles);
334
+
335
+ // Act: Bootstrap
336
+ const results = bootstrapRules(stateDir, 2);
337
+
338
+ // Assert: Check suggestedRules linkage
339
+ const ledger = loadLedger(stateDir);
340
+
341
+ for (const result of results) {
342
+ const principle = ledger.tree.principles[result.principleId];
343
+ expect(principle).toBeDefined();
344
+ expect(principle?.suggestedRules).toContain(result.ruleId);
345
+ }
346
+ });
347
+
348
+ it('is idempotent - second run skips existing rules', () => {
349
+ // Setup: 2 deterministic principles
350
+ const trainingStates: LegacyPrincipleTrainingState[] = [
351
+ {
352
+ principleId: 'P_001',
353
+ evaluability: 'deterministic',
354
+ applicableOpportunityCount: 10,
355
+ observedViolationCount: 5,
356
+ complianceRate: 0.5,
357
+ violationTrend: 0,
358
+ generatedSampleCount: 0,
359
+ approvedSampleCount: 0,
360
+ includedTrainRunIds: [],
361
+ deployedCheckpointIds: [],
362
+ internalizationStatus: 'prompt_only',
363
+ },
364
+ {
365
+ principleId: 'P_002',
366
+ evaluability: 'deterministic',
367
+ applicableOpportunityCount: 8,
368
+ observedViolationCount: 3,
369
+ complianceRate: 0.6,
370
+ violationTrend: 0,
371
+ generatedSampleCount: 0,
372
+ approvedSampleCount: 0,
373
+ includedTrainRunIds: [],
374
+ deployedCheckpointIds: [],
375
+ internalizationStatus: 'prompt_only',
376
+ },
377
+ ];
378
+
379
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId));
380
+ setupLedger(trainingStates, principles);
381
+
382
+ // Act: First bootstrap
383
+ const firstResults = bootstrapRules(stateDir, 2);
384
+ expect(firstResults.every((r) => r.status === 'created')).toBe(true);
385
+
386
+ // Act: Second bootstrap
387
+ const secondResults = bootstrapRules(stateDir, 2);
388
+
389
+ // Assert: All skipped
390
+ expect(secondResults).toHaveLength(2);
391
+ expect(secondResults.every((r) => r.status === 'skipped')).toBe(true);
392
+
393
+ // Verify ledger unchanged
394
+ const ledger = loadLedger(stateDir);
395
+ expect(Object.keys(ledger.tree.rules)).toHaveLength(2);
396
+ });
397
+
398
+ it('limits bootstrap to requested count', () => {
399
+ // Setup: 5 deterministic principles
400
+ const trainingStates: LegacyPrincipleTrainingState[] = [
401
+ {
402
+ principleId: 'P_001',
403
+ evaluability: 'deterministic',
404
+ applicableOpportunityCount: 20,
405
+ observedViolationCount: 10,
406
+ complianceRate: 0.5,
407
+ violationTrend: 0,
408
+ generatedSampleCount: 0,
409
+ approvedSampleCount: 0,
410
+ includedTrainRunIds: [],
411
+ deployedCheckpointIds: [],
412
+ internalizationStatus: 'prompt_only',
413
+ },
414
+ {
415
+ principleId: 'P_002',
416
+ evaluability: 'deterministic',
417
+ applicableOpportunityCount: 15,
418
+ observedViolationCount: 8,
419
+ complianceRate: 0.6,
420
+ violationTrend: 0,
421
+ generatedSampleCount: 0,
422
+ approvedSampleCount: 0,
423
+ includedTrainRunIds: [],
424
+ deployedCheckpointIds: [],
425
+ internalizationStatus: 'prompt_only',
426
+ },
427
+ {
428
+ principleId: 'P_003',
429
+ evaluability: 'deterministic',
430
+ applicableOpportunityCount: 12,
431
+ observedViolationCount: 6,
432
+ complianceRate: 0.7,
433
+ violationTrend: 0,
434
+ generatedSampleCount: 0,
435
+ approvedSampleCount: 0,
436
+ includedTrainRunIds: [],
437
+ deployedCheckpointIds: [],
438
+ internalizationStatus: 'prompt_only',
439
+ },
440
+ {
441
+ principleId: 'P_004',
442
+ evaluability: 'deterministic',
443
+ applicableOpportunityCount: 10,
444
+ observedViolationCount: 4,
445
+ complianceRate: 0.8,
446
+ violationTrend: 0,
447
+ generatedSampleCount: 0,
448
+ approvedSampleCount: 0,
449
+ includedTrainRunIds: [],
450
+ deployedCheckpointIds: [],
451
+ internalizationStatus: 'prompt_only',
452
+ },
453
+ {
454
+ principleId: 'P_005',
455
+ evaluability: 'deterministic',
456
+ applicableOpportunityCount: 5,
457
+ observedViolationCount: 2,
458
+ complianceRate: 0.9,
459
+ violationTrend: 0,
460
+ generatedSampleCount: 0,
461
+ approvedSampleCount: 0,
462
+ includedTrainRunIds: [],
463
+ deployedCheckpointIds: [],
464
+ internalizationStatus: 'prompt_only',
465
+ },
466
+ ];
467
+
468
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId));
469
+ setupLedger(trainingStates, principles);
470
+
471
+ // Act: Bootstrap only 3
472
+ const results = bootstrapRules(stateDir, 3);
473
+
474
+ // Assert: Only 3 rules created
475
+ expect(results).toHaveLength(3);
476
+ expect(results.every((r) => r.status === 'created')).toBe(true);
477
+
478
+ // Verify only top 3 by violation count
479
+ const ledger = loadLedger(stateDir);
480
+ expect(Object.keys(ledger.tree.rules)).toHaveLength(3);
481
+ expect(ledger.tree.rules['P_001_stub_bootstrap']).toBeDefined();
482
+ expect(ledger.tree.rules['P_002_stub_bootstrap']).toBeDefined();
483
+ expect(ledger.tree.rules['P_003_stub_bootstrap']).toBeDefined();
484
+ expect(ledger.tree.rules['P_004_stub_bootstrap']).toBeUndefined();
485
+ expect(ledger.tree.rules['P_005_stub_bootstrap']).toBeUndefined();
486
+ });
487
+
488
+ it('throws when no deterministic principles exist', () => {
489
+ // Setup: Only manual_only principles
490
+ const trainingStates: LegacyPrincipleTrainingState[] = [
491
+ {
492
+ principleId: 'P_001',
493
+ evaluability: 'manual_only',
494
+ applicableOpportunityCount: 10,
495
+ observedViolationCount: 5,
496
+ complianceRate: 0.5,
497
+ violationTrend: 0,
498
+ generatedSampleCount: 0,
499
+ approvedSampleCount: 0,
500
+ includedTrainRunIds: [],
501
+ deployedCheckpointIds: [],
502
+ internalizationStatus: 'prompt_only',
503
+ },
504
+ ];
505
+
506
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId, { evaluability: s.evaluability }));
507
+ setupLedger(trainingStates, principles);
508
+
509
+ // Act & Assert: Should throw
510
+ expect(() => bootstrapRules(stateDir, 3)).toThrow('No deterministic principles');
511
+ });
512
+ });
513
+
514
+ describe('validateBootstrap', () => {
515
+ it('returns true for correctly bootstrapped state', () => {
516
+ // Setup: Bootstrap 1 principle
517
+ const trainingStates: LegacyPrincipleTrainingState[] = [
518
+ {
519
+ principleId: 'P_001',
520
+ evaluability: 'deterministic',
521
+ applicableOpportunityCount: 10,
522
+ observedViolationCount: 5,
523
+ complianceRate: 0.5,
524
+ violationTrend: 0,
525
+ generatedSampleCount: 0,
526
+ approvedSampleCount: 0,
527
+ includedTrainRunIds: [],
528
+ deployedCheckpointIds: [],
529
+ internalizationStatus: 'prompt_only',
530
+ },
531
+ ];
532
+
533
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId));
534
+ setupLedger(trainingStates, principles);
535
+
536
+ const results = bootstrapRules(stateDir, 1);
537
+ const bootstrappedIds = results.map((r) => r.principleId);
538
+
539
+ // Act: Validate
540
+ const valid = validateBootstrap(stateDir, bootstrappedIds);
541
+
542
+ // Assert: Should pass
543
+ expect(valid).toBe(true);
544
+ });
545
+
546
+ it('throws when suggestedRules is empty', () => {
547
+ // Setup: Bootstrap 1 principle
548
+ const trainingStates: LegacyPrincipleTrainingState[] = [
549
+ {
550
+ principleId: 'P_001',
551
+ evaluability: 'deterministic',
552
+ applicableOpportunityCount: 10,
553
+ observedViolationCount: 5,
554
+ complianceRate: 0.5,
555
+ violationTrend: 0,
556
+ generatedSampleCount: 0,
557
+ approvedSampleCount: 0,
558
+ includedTrainRunIds: [],
559
+ deployedCheckpointIds: [],
560
+ internalizationStatus: 'prompt_only',
561
+ },
562
+ ];
563
+
564
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId));
565
+ setupLedger(trainingStates, principles);
566
+
567
+ const results = bootstrapRules(stateDir, 1);
568
+ const bootstrappedIds = results.map((r) => r.principleId);
569
+
570
+ // Manually clear suggestedRules
571
+ const ledger = loadLedger(stateDir);
572
+ const principle = ledger.tree.principles[bootstrappedIds[0]];
573
+ if (principle) {
574
+ principle.suggestedRules = [];
575
+ saveLedger(stateDir, ledger);
576
+ }
577
+
578
+ // Act & Assert: Should throw
579
+ expect(() => validateBootstrap(stateDir, bootstrappedIds)).toThrow();
580
+ });
581
+ });
582
+ });