principles-disciple 1.52.0 → 1.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.planning/phases/01-basic-visualization/01-GAP-CLOSURE-VERIFICATION.md +113 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/bootstrap-rules.ts +43 -4
- package/src/core/evolution-hook.ts +74 -0
- package/src/core/file-storage-adapter.ts +203 -0
- package/src/core/init.ts +29 -2
- package/src/core/nocturnal-trinity.ts +230 -0
- package/src/core/observability.ts +242 -0
- package/src/core/pain-lifecycle.ts +38 -0
- package/src/core/pain-signal-adapter.ts +42 -0
- package/src/core/pain-signal.ts +139 -0
- package/src/core/principle-injection.ts +208 -0
- package/src/core/principle-injector.ts +84 -0
- package/src/core/storage-adapter.ts +65 -0
- package/src/core/telemetry-event.ts +109 -0
- package/src/hooks/prompt.ts +18 -3
- package/src/service/evolution-worker.ts +59 -2
- package/tests/core/evolution-hook.test.ts +123 -0
- package/tests/core/file-storage-adapter.test.ts +285 -0
- package/tests/core/nocturnal-trinity.test.ts +236 -0
- package/tests/core/observability.test.ts +383 -0
- package/tests/core/pain-lifecycle.test.ts +37 -0
- package/tests/core/pain-signal-adapter.test.ts +116 -0
- package/tests/core/pain-signal.test.ts +190 -0
- package/tests/core/principle-injection.test.ts +223 -0
- package/tests/core/principle-injector.test.ts +90 -0
- package/tests/core/storage-conformance.test.ts +429 -0
- package/tests/core/telemetry-event.test.ts +119 -0
- package/tests/integration/pain-lifecycle-e2e.test.ts +74 -0
|
@@ -18,6 +18,7 @@ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
|
18
18
|
import type { PrincipleEvaluability } from '../types/principle-tree-schema.js';
|
|
19
19
|
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
20
20
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
21
|
+
import { validatePainSignal, type PainSignalValidationResult } from '../core/pain-signal.js';
|
|
21
22
|
|
|
22
23
|
// Re-export queue I/O (extracted to queue-io.ts)
|
|
23
24
|
export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
|
|
@@ -51,6 +52,7 @@ import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
|
51
52
|
import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
|
|
52
53
|
import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
|
|
53
54
|
import { reconcileStartup } from './startup-reconciler.js';
|
|
55
|
+
import { clearPainFlag } from '../core/pain-lifecycle.js';
|
|
54
56
|
import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
|
|
55
57
|
import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
|
|
56
58
|
|
|
@@ -330,6 +332,27 @@ async function doEnqueuePainTask(
|
|
|
330
332
|
return result;
|
|
331
333
|
}
|
|
332
334
|
|
|
335
|
+
// Validate pain signal through TypeBox schema before enqueuing.
|
|
336
|
+
// Malformed signals are logged and skipped — they never enter the queue.
|
|
337
|
+
const signalInput = {
|
|
338
|
+
source: v.source,
|
|
339
|
+
score: v.score,
|
|
340
|
+
timestamp: new Date().toISOString(),
|
|
341
|
+
reason: v.reason,
|
|
342
|
+
sessionId: v.sessionId ?? undefined,
|
|
343
|
+
agentId: v.agentId ?? undefined,
|
|
344
|
+
traceId: v.traceId ?? undefined,
|
|
345
|
+
triggerTextPreview: v.preview,
|
|
346
|
+
};
|
|
347
|
+
const validation: PainSignalValidationResult = validatePainSignal(signalInput);
|
|
348
|
+
if (!validation.valid) {
|
|
349
|
+
result.skipped_reason = `invalid_pain_signal (${validation.errors.join('; ')})`;
|
|
350
|
+
if (logger) logger.warn(`[PD:EvolutionWorker] Pain signal validation failed, skipping enqueue: ${validation.errors.join('; ')}`);
|
|
351
|
+
SystemLogger.log(wctx.workspaceDir, 'PAIN_SIGNAL_INVALID', `Validation errors: ${validation.errors.join('; ')} | source=${v.source} score=${v.score}`);
|
|
352
|
+
clearPainFlag(wctx.workspaceDir);
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
|
|
333
356
|
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
334
357
|
const releaseLock = await requireQueueLock(queuePath, logger, 'checkPainFlag');
|
|
335
358
|
try {
|
|
@@ -344,6 +367,7 @@ async function doEnqueuePainTask(
|
|
|
344
367
|
result.enqueued = true;
|
|
345
368
|
result.skipped_reason = 'duplicate';
|
|
346
369
|
if (logger) logger.info(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${v.source} preview=${v.preview || 'N/A'}`);
|
|
370
|
+
clearPainFlag(wctx.workspaceDir);
|
|
347
371
|
return result;
|
|
348
372
|
}
|
|
349
373
|
|
|
@@ -387,6 +411,7 @@ async function doEnqueuePainTask(
|
|
|
387
411
|
enqueuedAt: nowIso,
|
|
388
412
|
});
|
|
389
413
|
} finally { releaseLock(); }
|
|
414
|
+
clearPainFlag(wctx.workspaceDir);
|
|
390
415
|
return result;
|
|
391
416
|
}
|
|
392
417
|
|
|
@@ -419,6 +444,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
419
444
|
if (isQueued) {
|
|
420
445
|
result.skipped_reason = 'already_queued';
|
|
421
446
|
if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
|
|
447
|
+
clearPainFlag(wctx.workspaceDir, painEventId);
|
|
422
448
|
return result;
|
|
423
449
|
}
|
|
424
450
|
|
|
@@ -432,6 +458,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
432
458
|
result.exists = true;
|
|
433
459
|
result.skipped_reason = `invalid_pain_flag (${contract.missingFields.join(', ') || contract.format})`;
|
|
434
460
|
if (logger) logger.warn(`[PD:EvolutionWorker] Invalid pain flag skipped: ${result.skipped_reason}`);
|
|
461
|
+
clearPainFlag(wctx.workspaceDir);
|
|
435
462
|
return result;
|
|
436
463
|
}
|
|
437
464
|
|
|
@@ -472,6 +499,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
472
499
|
result.enqueued = true;
|
|
473
500
|
result.skipped_reason = 'already_queued';
|
|
474
501
|
if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${jsonScore}, source=${jsonSource})`);
|
|
502
|
+
clearPainFlag(wctx.workspaceDir, jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined);
|
|
475
503
|
return result;
|
|
476
504
|
}
|
|
477
505
|
|
|
@@ -763,9 +791,38 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
763
791
|
}
|
|
764
792
|
|
|
765
793
|
// V2: Migrate queue to current schema if needed
|
|
766
|
-
|
|
794
|
+
let queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
|
|
795
|
+
|
|
796
|
+
// Validate queue items — filter out malformed entries before processing.
|
|
797
|
+
// Malformed items are logged + skipped; they never crash the evolution cycle.
|
|
798
|
+
const beforeValidation = queue.length;
|
|
799
|
+
queue = queue.filter((item) => {
|
|
800
|
+
const errors: string[] = [];
|
|
801
|
+
if (!item.id || typeof item.id !== 'string') errors.push('missing/invalid id');
|
|
802
|
+
if (!item.source || typeof item.source !== 'string') errors.push('missing/invalid source');
|
|
803
|
+
if (typeof item.score !== 'number') errors.push('missing/invalid score');
|
|
804
|
+
if (!item.status || typeof item.status !== 'string') errors.push('missing/invalid status');
|
|
805
|
+
if (!item.taskKind || typeof item.taskKind !== 'string') errors.push('missing/invalid taskKind');
|
|
806
|
+
else {
|
|
807
|
+
const validTaskKinds = ['pain_diagnosis', 'sleep_reflection', 'model_eval', 'keyword_optimization'];
|
|
808
|
+
if (!validTaskKinds.includes(item.taskKind)) {
|
|
809
|
+
errors.push(`invalid taskKind value '${item.taskKind}' (expected one of: ${validTaskKinds.join(', ')})`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (typeof item.retryCount !== 'number') errors.push('missing/invalid retryCount');
|
|
813
|
+
if (typeof item.maxRetries !== 'number') errors.push('missing/invalid maxRetries');
|
|
814
|
+
if (errors.length > 0) {
|
|
815
|
+
logger?.warn?.(`[PD:EvolutionWorker] Skipping malformed queue item: ${errors.join(', ')} | ${JSON.stringify(item).slice(0, 200)}`);
|
|
816
|
+
SystemLogger.log(wctx.workspaceDir, 'QUEUE_ITEM_MALFORMED', `Skipped: ${errors.join(', ')} | id=${item.id || 'N/A'}`);
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
return true;
|
|
820
|
+
});
|
|
821
|
+
if (queue.length < beforeValidation) {
|
|
822
|
+
logger?.info?.(`[PD:EvolutionWorker] Filtered ${beforeValidation - queue.length} malformed queue item(s)`);
|
|
823
|
+
}
|
|
767
824
|
|
|
768
|
-
let queueChanged = rawQueue.some(isLegacyQueueItem);
|
|
825
|
+
let queueChanged = rawQueue.some(isLegacyQueueItem) || queue.length < beforeValidation;
|
|
769
826
|
|
|
770
827
|
// Guard: Skip keyword_optimization if one is already pending/in-progress (CORR-08)
|
|
771
828
|
if (hasPendingTask(queue, 'keyword_optimization')) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { EvolutionHook, PrincipleCreatedEvent, PrinciplePromotedEvent } from '../../src/core/evolution-hook.js';
|
|
3
|
+
import { noOpEvolutionHook } from '../../src/core/evolution-hook.js';
|
|
4
|
+
import type { PainSignal } from '../../src/core/pain-signal.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function validPainSignal(overrides: Partial<PainSignal> = {}): PainSignal {
|
|
11
|
+
return {
|
|
12
|
+
source: 'tool_failure',
|
|
13
|
+
score: 75,
|
|
14
|
+
timestamp: '2026-04-17T00:00:00.000Z',
|
|
15
|
+
reason: 'File not found',
|
|
16
|
+
sessionId: 'session-001',
|
|
17
|
+
agentId: 'main',
|
|
18
|
+
traceId: 'trace-001',
|
|
19
|
+
triggerTextPreview: 'File not found: test.ts',
|
|
20
|
+
domain: 'coding',
|
|
21
|
+
severity: 'high',
|
|
22
|
+
context: {},
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Tests
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe('EvolutionHook', () => {
|
|
32
|
+
it('implements all 3 methods', () => {
|
|
33
|
+
const calls: string[] = [];
|
|
34
|
+
const hook: EvolutionHook = {
|
|
35
|
+
onPainDetected(signal: PainSignal): void { calls.push(`pain:${signal.source}`); },
|
|
36
|
+
onPrincipleCreated(event: PrincipleCreatedEvent): void { calls.push(`created:${event.id}`); },
|
|
37
|
+
onPrinciplePromoted(event: PrinciplePromotedEvent): void { calls.push(`promoted:${event.id}`); },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
hook.onPainDetected(validPainSignal());
|
|
41
|
+
hook.onPrincipleCreated({ id: 'p-1', text: 'Test principle', trigger: 'tool failure' });
|
|
42
|
+
hook.onPrinciplePromoted({ id: 'p-1', from: 'candidate', to: 'active' });
|
|
43
|
+
|
|
44
|
+
expect(calls).toEqual(['pain:tool_failure', 'created:p-1', 'promoted:p-1']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('onPainDetected receives a PainSignal', () => {
|
|
48
|
+
let received: PainSignal | undefined;
|
|
49
|
+
const hook: EvolutionHook = {
|
|
50
|
+
...noOpEvolutionHook,
|
|
51
|
+
onPainDetected(signal: PainSignal): void { received = signal; },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const signal = validPainSignal();
|
|
55
|
+
hook.onPainDetected(signal);
|
|
56
|
+
expect(received).toEqual(signal);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('onPrincipleCreated receives a PrincipleCreatedEvent', () => {
|
|
60
|
+
let received: PrincipleCreatedEvent | undefined;
|
|
61
|
+
const hook: EvolutionHook = {
|
|
62
|
+
...noOpEvolutionHook,
|
|
63
|
+
onPrincipleCreated(event: PrincipleCreatedEvent): void { received = event; },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const event = { id: 'p-1', text: 'Test principle', trigger: 'tool failure' };
|
|
67
|
+
hook.onPrincipleCreated(event);
|
|
68
|
+
expect(received).toEqual(event);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('onPrinciplePromoted receives a PrinciplePromotedEvent', () => {
|
|
72
|
+
let received: PrinciplePromotedEvent | undefined;
|
|
73
|
+
const hook: EvolutionHook = {
|
|
74
|
+
...noOpEvolutionHook,
|
|
75
|
+
onPrinciplePromoted(event: PrinciplePromotedEvent): void { received = event; },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const event = { id: 'p-1', from: 'candidate', to: 'active' };
|
|
79
|
+
hook.onPrinciplePromoted(event);
|
|
80
|
+
expect(received).toEqual(event);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('noOpEvolutionHook', () => {
|
|
85
|
+
it('implements all 3 methods as no-ops', () => {
|
|
86
|
+
expect(() => {
|
|
87
|
+
noOpEvolutionHook.onPainDetected(validPainSignal());
|
|
88
|
+
noOpEvolutionHook.onPrincipleCreated({ id: 'p-1', text: 'Test', trigger: 'test' });
|
|
89
|
+
noOpEvolutionHook.onPrinciplePromoted({ id: 'p-1', from: 'candidate', to: 'active' });
|
|
90
|
+
}).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('can be spread to override individual methods', () => {
|
|
94
|
+
const calls: string[] = [];
|
|
95
|
+
const hook: EvolutionHook = {
|
|
96
|
+
...noOpEvolutionHook,
|
|
97
|
+
onPainDetected(_signal: PainSignal): void { calls.push('pain'); },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
hook.onPainDetected(validPainSignal());
|
|
101
|
+
hook.onPrincipleCreated({ id: 'p-1', text: 'Test', trigger: 'test' });
|
|
102
|
+
|
|
103
|
+
expect(calls).toEqual(['pain']);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('PrincipleCreatedEvent', () => {
|
|
108
|
+
it('has required fields: id, text, trigger', () => {
|
|
109
|
+
const event: PrincipleCreatedEvent = { id: 'p-1', text: 'Always verify', trigger: 'tool failure' };
|
|
110
|
+
expect(event.id).toBe('p-1');
|
|
111
|
+
expect(event.text).toBe('Always verify');
|
|
112
|
+
expect(event.trigger).toBe('tool failure');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('PrinciplePromotedEvent', () => {
|
|
117
|
+
it('has required fields: id, from, to', () => {
|
|
118
|
+
const event: PrinciplePromotedEvent = { id: 'p-1', from: 'candidate', to: 'active' };
|
|
119
|
+
expect(event.id).toBe('p-1');
|
|
120
|
+
expect(event.from).toBe('candidate');
|
|
121
|
+
expect(event.to).toBe('active');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { FileStorageAdapter } from '../../src/core/file-storage-adapter.js';
|
|
6
|
+
import type { HybridLedgerStore } from '../../src/core/principle-tree-ledger.js';
|
|
7
|
+
import { TREE_NAMESPACE, loadLedger } from '../../src/core/principle-tree-ledger.js';
|
|
8
|
+
import { safeRmDir } from '../test-utils.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function createTmpDir(): string {
|
|
15
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-file-storage-adapter-test-'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createEmptyStore(): HybridLedgerStore {
|
|
19
|
+
return {
|
|
20
|
+
trainingStore: {},
|
|
21
|
+
tree: {
|
|
22
|
+
principles: {},
|
|
23
|
+
rules: {},
|
|
24
|
+
implementations: {},
|
|
25
|
+
metrics: {},
|
|
26
|
+
lastUpdated: new Date(0).toISOString(),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Tests
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
describe('FileStorageAdapter', () => {
|
|
36
|
+
let tmpDir: string;
|
|
37
|
+
let adapter: FileStorageAdapter;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tmpDir = createTmpDir();
|
|
41
|
+
adapter = new FileStorageAdapter(tmpDir, tmpDir);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
safeRmDir(tmpDir);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// -------------------------------------------------------------------------
|
|
49
|
+
// loadLedger
|
|
50
|
+
// -------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe('loadLedger', () => {
|
|
53
|
+
it('returns empty store when no file exists', async () => {
|
|
54
|
+
const store = await adapter.loadLedger();
|
|
55
|
+
expect(store.trainingStore).toEqual({});
|
|
56
|
+
expect(store.tree.principles).toEqual({});
|
|
57
|
+
expect(store.tree.rules).toEqual({});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('loads existing persisted store', async () => {
|
|
61
|
+
const original = createEmptyStore();
|
|
62
|
+
original.tree.principles['P-001'] = {
|
|
63
|
+
id: 'P-001',
|
|
64
|
+
version: 1,
|
|
65
|
+
text: 'Write before delete',
|
|
66
|
+
triggerPattern: 'delete',
|
|
67
|
+
action: 'write first',
|
|
68
|
+
status: 'active',
|
|
69
|
+
priority: 'P1',
|
|
70
|
+
scope: 'general',
|
|
71
|
+
evaluability: 'deterministic',
|
|
72
|
+
valueScore: 0,
|
|
73
|
+
adherenceRate: 0,
|
|
74
|
+
painPreventedCount: 0,
|
|
75
|
+
derivedFromPainIds: [],
|
|
76
|
+
ruleIds: [],
|
|
77
|
+
conflictsWithPrincipleIds: [],
|
|
78
|
+
createdAt: '2026-04-17T00:00:00.000Z',
|
|
79
|
+
updatedAt: '2026-04-17T00:00:00.000Z',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Persist using the low-level ledger to seed the file
|
|
83
|
+
await adapter.saveLedger(original);
|
|
84
|
+
const loaded = await adapter.loadLedger();
|
|
85
|
+
expect(loaded.tree.principles['P-001']).toBeDefined();
|
|
86
|
+
expect(loaded.tree.principles['P-001'].text).toBe('Write before delete');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
// saveLedger
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('saveLedger', () => {
|
|
95
|
+
it('persists store to disk', async () => {
|
|
96
|
+
const store = createEmptyStore();
|
|
97
|
+
store.trainingStore['test-principle'] = {
|
|
98
|
+
principleId: 'test-principle',
|
|
99
|
+
evaluability: 'manual_only',
|
|
100
|
+
applicableOpportunityCount: 0,
|
|
101
|
+
observedViolationCount: 0,
|
|
102
|
+
complianceRate: 0,
|
|
103
|
+
violationTrend: 0,
|
|
104
|
+
generatedSampleCount: 0,
|
|
105
|
+
approvedSampleCount: 0,
|
|
106
|
+
includedTrainRunIds: [],
|
|
107
|
+
deployedCheckpointIds: [],
|
|
108
|
+
internalizationStatus: 'prompt_only',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await adapter.saveLedger(store);
|
|
112
|
+
|
|
113
|
+
// Verify file exists and contains the data
|
|
114
|
+
const filePath = path.join(tmpDir, 'principle_training_state.json');
|
|
115
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
116
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
117
|
+
expect(raw['test-principle']).toBeDefined();
|
|
118
|
+
expect(raw[TREE_NAMESPACE]).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('round-trips data through save and load', async () => {
|
|
122
|
+
const store = createEmptyStore();
|
|
123
|
+
store.trainingStore['p-1'] = {
|
|
124
|
+
principleId: 'p-1',
|
|
125
|
+
evaluability: 'weak_heuristic',
|
|
126
|
+
applicableOpportunityCount: 5,
|
|
127
|
+
observedViolationCount: 2,
|
|
128
|
+
complianceRate: 0.6,
|
|
129
|
+
violationTrend: -0.1,
|
|
130
|
+
generatedSampleCount: 3,
|
|
131
|
+
approvedSampleCount: 2,
|
|
132
|
+
includedTrainRunIds: ['run-1'],
|
|
133
|
+
deployedCheckpointIds: [],
|
|
134
|
+
internalizationStatus: 'in_training',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await adapter.saveLedger(store);
|
|
138
|
+
const loaded = await adapter.loadLedger();
|
|
139
|
+
expect(loaded.trainingStore['p-1'].evaluability).toBe('weak_heuristic');
|
|
140
|
+
expect(loaded.trainingStore['p-1'].applicableOpportunityCount).toBe(5);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// mutateLedger
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
describe('mutateLedger', () => {
|
|
149
|
+
it('reads, mutates, and writes atomically', async () => {
|
|
150
|
+
// Start with empty store
|
|
151
|
+
await adapter.saveLedger(createEmptyStore());
|
|
152
|
+
|
|
153
|
+
const result = await adapter.mutateLedger((store) => {
|
|
154
|
+
store.tree.principles['P-002'] = {
|
|
155
|
+
id: 'P-002',
|
|
156
|
+
version: 1,
|
|
157
|
+
text: 'Test principle',
|
|
158
|
+
triggerPattern: 'test',
|
|
159
|
+
action: 'do something',
|
|
160
|
+
status: 'candidate',
|
|
161
|
+
priority: 'P2',
|
|
162
|
+
scope: 'general',
|
|
163
|
+
evaluability: 'manual_only',
|
|
164
|
+
valueScore: 0,
|
|
165
|
+
adherenceRate: 0,
|
|
166
|
+
painPreventedCount: 0,
|
|
167
|
+
derivedFromPainIds: [],
|
|
168
|
+
ruleIds: [],
|
|
169
|
+
conflictsWithPrincipleIds: [],
|
|
170
|
+
createdAt: '2026-04-17T00:00:00.000Z',
|
|
171
|
+
updatedAt: '2026-04-17T00:00:00.000Z',
|
|
172
|
+
};
|
|
173
|
+
return 42;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(result).toBe(42);
|
|
177
|
+
const loaded = await adapter.loadLedger();
|
|
178
|
+
expect(loaded.tree.principles['P-002']).toBeDefined();
|
|
179
|
+
expect(loaded.tree.principles['P-002'].text).toBe('Test principle');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns the value from the mutate function', async () => {
|
|
183
|
+
await adapter.saveLedger(createEmptyStore());
|
|
184
|
+
|
|
185
|
+
const count = await adapter.mutateLedger((store) => {
|
|
186
|
+
return Object.keys(store.tree.principles).length;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(count).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('supports async mutate functions', async () => {
|
|
193
|
+
await adapter.saveLedger(createEmptyStore());
|
|
194
|
+
|
|
195
|
+
const result = await adapter.mutateLedger(async (store) => {
|
|
196
|
+
// Simulate async work
|
|
197
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
198
|
+
store.trainingStore['async-test'] = {
|
|
199
|
+
principleId: 'async-test',
|
|
200
|
+
evaluability: 'deterministic',
|
|
201
|
+
applicableOpportunityCount: 1,
|
|
202
|
+
observedViolationCount: 0,
|
|
203
|
+
complianceRate: 1.0,
|
|
204
|
+
violationTrend: 0,
|
|
205
|
+
generatedSampleCount: 0,
|
|
206
|
+
approvedSampleCount: 0,
|
|
207
|
+
includedTrainRunIds: [],
|
|
208
|
+
deployedCheckpointIds: [],
|
|
209
|
+
internalizationStatus: 'prompt_only',
|
|
210
|
+
};
|
|
211
|
+
return 'async-done';
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(result).toBe('async-done');
|
|
215
|
+
const loaded = await adapter.loadLedger();
|
|
216
|
+
expect(loaded.trainingStore['async-test']).toBeDefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('persists via atomicWriteFileSync (file is not corrupted)', async () => {
|
|
220
|
+
await adapter.saveLedger(createEmptyStore());
|
|
221
|
+
|
|
222
|
+
await adapter.mutateLedger((store) => {
|
|
223
|
+
store.tree.principles['P-003'] = {
|
|
224
|
+
id: 'P-003',
|
|
225
|
+
version: 1,
|
|
226
|
+
text: 'Atomic write test',
|
|
227
|
+
triggerPattern: 'test',
|
|
228
|
+
action: 'verify atomicity',
|
|
229
|
+
status: 'candidate',
|
|
230
|
+
priority: 'P1',
|
|
231
|
+
scope: 'general',
|
|
232
|
+
evaluability: 'manual_only',
|
|
233
|
+
valueScore: 0,
|
|
234
|
+
adherenceRate: 0,
|
|
235
|
+
painPreventedCount: 0,
|
|
236
|
+
derivedFromPainIds: [],
|
|
237
|
+
ruleIds: [],
|
|
238
|
+
conflictsWithPrincipleIds: [],
|
|
239
|
+
createdAt: '2026-04-17T00:00:00.000Z',
|
|
240
|
+
updatedAt: '2026-04-17T00:00:00.000Z',
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Verify no leftover temp file
|
|
245
|
+
const filePath = path.join(tmpDir, 'principle_training_state.json');
|
|
246
|
+
expect(fs.existsSync(filePath + '.tmp')).toBe(false);
|
|
247
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
248
|
+
|
|
249
|
+
// File is valid JSON
|
|
250
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
251
|
+
expect(raw[TREE_NAMESPACE].principles['P-003']).toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('is compatible with low-level loadLedger', async () => {
|
|
255
|
+
await adapter.saveLedger(createEmptyStore());
|
|
256
|
+
|
|
257
|
+
await adapter.mutateLedger((store) => {
|
|
258
|
+
store.tree.principles['P-COMPAT'] = {
|
|
259
|
+
id: 'P-COMPAT',
|
|
260
|
+
version: 1,
|
|
261
|
+
text: 'Compatibility test',
|
|
262
|
+
triggerPattern: 'compat',
|
|
263
|
+
action: 'verify',
|
|
264
|
+
status: 'active',
|
|
265
|
+
priority: 'P1',
|
|
266
|
+
scope: 'general',
|
|
267
|
+
evaluability: 'deterministic',
|
|
268
|
+
valueScore: 10,
|
|
269
|
+
adherenceRate: 0.8,
|
|
270
|
+
painPreventedCount: 5,
|
|
271
|
+
derivedFromPainIds: ['pain-1'],
|
|
272
|
+
ruleIds: [],
|
|
273
|
+
conflictsWithPrincipleIds: [],
|
|
274
|
+
createdAt: '2026-04-17T00:00:00.000Z',
|
|
275
|
+
updatedAt: '2026-04-17T00:00:00.000Z',
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Load with the low-level function — should see the same data
|
|
280
|
+
const ledger = loadLedger(tmpDir);
|
|
281
|
+
expect(ledger.tree.principles['P-COMPAT']).toBeDefined();
|
|
282
|
+
expect(ledger.tree.principles['P-COMPAT'].text).toBe('Compatibility test');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|