principles-disciple 1.28.0 → 1.28.1
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/openclaw.plugin.json +1 -1
- package/package.json +4 -4
- package/scripts/validate-live-path.ts +18 -18
- package/src/commands/nocturnal-train.ts +4 -6
- package/src/commands/pain.ts +8 -11
- package/src/commands/pd-reflect.ts +1 -1
- package/src/core/bootstrap-rules.ts +3 -3
- package/src/core/merge-gate-audit.ts +1 -1
- package/src/core/nocturnal-candidate-scoring.ts +131 -0
- package/src/core/nocturnal-reasoning-deriver.ts +337 -0
- package/src/core/nocturnal-trinity.ts +454 -18
- package/src/core/pain-context-extractor.ts +1 -3
- package/src/core/principle-tree-migration.ts +2 -4
- package/src/core/thinking-os-parser.ts +3 -3
- package/src/hooks/bash-risk.ts +1 -1
- package/src/hooks/gfi-gate.ts +1 -1
- package/src/hooks/pain.ts +1 -1
- package/src/hooks/prompt.ts +36 -2
- package/src/hooks/subagent.ts +1 -1
- package/src/index.ts +1 -1
- package/src/service/evolution-worker.ts +1 -1
- package/src/service/health-query-service.ts +15 -6
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +0 -1
- package/tests/core/nocturnal-candidate-scoring.test.ts +132 -0
- package/tests/core/nocturnal-reasoning-deriver.test.ts +372 -0
- package/tests/core/nocturnal-trinity.test.ts +791 -0
|
@@ -235,11 +235,9 @@ export async function extractRecentConversation(
|
|
|
235
235
|
/**
|
|
236
236
|
* Extracts failed tool call context with argument correlation.
|
|
237
237
|
*/
|
|
238
|
-
// Reason: breaking API change - default param must precede required params for type inference compatibility
|
|
239
|
-
|
|
240
238
|
export async function extractFailedToolContext(
|
|
241
239
|
sessionId: string,
|
|
242
|
-
agentId
|
|
240
|
+
agentId: string,
|
|
243
241
|
toolName: string,
|
|
244
242
|
filePath?: string,
|
|
245
243
|
): Promise<string> {
|
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
* - Or run manually: node scripts/migrate-principle-tree.mjs <workspace-dir>
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import * as fs from 'fs';
|
|
13
|
-
import * as path from 'path';
|
|
14
12
|
import {
|
|
15
13
|
loadLedger,
|
|
16
14
|
saveLedger,
|
|
@@ -23,11 +21,11 @@ export interface PrincipleTreeMigrationResult {
|
|
|
23
21
|
migratedCount: number;
|
|
24
22
|
skippedCount: number;
|
|
25
23
|
errorCount: number;
|
|
26
|
-
details:
|
|
24
|
+
details: {
|
|
27
25
|
principleId: string;
|
|
28
26
|
status: 'migrated' | 'skipped' | 'error';
|
|
29
27
|
reason?: string;
|
|
30
|
-
}
|
|
28
|
+
}[];
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
/**
|
|
@@ -45,10 +45,10 @@ export function parseThinkingOsMd(content: string): ThinkingOsDirective[] {
|
|
|
45
45
|
// Match all <directive ...> ... </directive> blocks
|
|
46
46
|
const directiveRegex = /<directive\s+([^>]*)>([\s\S]*?)<\/directive>/gi;
|
|
47
47
|
|
|
48
|
-
let
|
|
48
|
+
let _match: RegExpExecArray | null = null;
|
|
49
49
|
|
|
50
|
-
while ((
|
|
51
|
-
const [, attrs, body] =
|
|
50
|
+
while ((_match = directiveRegex.exec(content)) !== null) {
|
|
51
|
+
const [, attrs, body] = _match;
|
|
52
52
|
|
|
53
53
|
const idMatch = /id="([^"]+)"/i.exec(attrs);
|
|
54
54
|
const nameMatch = /name="([^"]+)"/i.exec(attrs);
|
package/src/hooks/bash-risk.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function analyzeBashCommand(
|
|
|
66
66
|
// - Word joiner (U+2060)
|
|
67
67
|
// - Zero-width invisible separator (U+FEFF)
|
|
68
68
|
|
|
69
|
-
const ZERO_WIDTH_CHARS =
|
|
69
|
+
const ZERO_WIDTH_CHARS = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g;
|
|
70
70
|
if (ZERO_WIDTH_CHARS.test(command)) {
|
|
71
71
|
logger?.warn?.(`[PD_GATE] Bash command contains zero-width characters — blocking as dangerous`);
|
|
72
72
|
return 'dangerous'; // Fail-closed: zero-width chars are suspicious
|
package/src/hooks/gfi-gate.ts
CHANGED
package/src/hooks/pain.ts
CHANGED
|
@@ -191,7 +191,7 @@ export function handleAfterToolCall(
|
|
|
191
191
|
// Only reduce tool_failure source GFI by 50%, preserve user_empathy and other sources
|
|
192
192
|
// This prevents "read file success" from wiping user frustration signals
|
|
193
193
|
const session = getSession(sessionId);
|
|
194
|
-
const toolFailureGfi = session?.gfiBySource?.
|
|
194
|
+
const toolFailureGfi = session?.gfiBySource?.tool_failure || 0;
|
|
195
195
|
|
|
196
196
|
let resetState: SessionState;
|
|
197
197
|
if (toolFailureGfi > 0) {
|
package/src/hooks/prompt.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { extractSummary, getHistoryVersions, parseWorkingMemorySection, workingM
|
|
|
10
10
|
import { EmpathyObserverWorkflowManager, empathyObserverWorkflowSpec, isExpectedSubagentError } from '../service/subagent-workflow/index.js';
|
|
11
11
|
import { PathResolver } from '../core/path-resolver.js';
|
|
12
12
|
import { isSubagentRuntimeAvailable } from '../utils/subagent-probe.js';
|
|
13
|
+
import { getPendingDiagnosticianTasks } from '../core/diagnostician-task-store.js';
|
|
13
14
|
import {
|
|
14
15
|
matchEmpathyKeywords,
|
|
15
16
|
loadKeywordStore,
|
|
@@ -366,7 +367,7 @@ export async function handleBeforePromptBuild(
|
|
|
366
367
|
// prependContext: Only short dynamic directives: evolutionDirective + heartbeat
|
|
367
368
|
|
|
368
369
|
|
|
369
|
-
let prependSystemContext
|
|
370
|
+
let prependSystemContext: string;
|
|
370
371
|
let prependContext = '';
|
|
371
372
|
let appendSystemContext = '';
|
|
372
373
|
|
|
@@ -644,11 +645,44 @@ ACTION: Run self-audit. If stable, reply ONLY with "HEARTBEAT_OK".
|
|
|
644
645
|
logger?.error(`[PD:Prompt] Failed to read HEARTBEAT: ${String(e)}`);
|
|
645
646
|
}
|
|
646
647
|
}
|
|
648
|
+
|
|
649
|
+
// ──── 4b. Inject pending diagnostician tasks ────
|
|
650
|
+
// FIX (#283): The evolution worker writes pain diagnosis tasks to
|
|
651
|
+
// diagnostician_tasks.json. The heartbeat prompt hook must read and inject
|
|
652
|
+
// them so the LLM (acting as diagnostician) can process them.
|
|
653
|
+
try {
|
|
654
|
+
const pendingTasks = getPendingDiagnosticianTasks(wctx.stateDir);
|
|
655
|
+
if (pendingTasks.length > 0) {
|
|
656
|
+
const taskBlocks = pendingTasks
|
|
657
|
+
.slice(0, 3)
|
|
658
|
+
.map(({ id, task }) => `<diagnostician_task id="${id}">\n${task.prompt}\n</diagnostician_task>`)
|
|
659
|
+
.join('\n\n');
|
|
660
|
+
|
|
661
|
+
const pendingCount = pendingTasks.length;
|
|
662
|
+
const processingNote = pendingCount > 3
|
|
663
|
+
? `\n\nNOTE: ${pendingCount - 3} more tasks are queued. Process these 3 first; remaining tasks will be handled on subsequent heartbeats.`
|
|
664
|
+
: '';
|
|
665
|
+
|
|
666
|
+
prependContext += `<diagnostician_tasks pending="${pendingCount}">
|
|
667
|
+
You are acting as a **Pain Diagnostician**. Process the following task(s) by:
|
|
668
|
+
1. Analyzing the pain signal and its context
|
|
669
|
+
2. Identifying the root cause and violated principles
|
|
670
|
+
3. Writing a completion marker file: .evolution_complete_<TASK_ID>
|
|
671
|
+
4. Writing a diagnostic report: .diagnostician_report_<TASK_ID>.json
|
|
672
|
+
|
|
673
|
+
${taskBlocks}${processingNote}
|
|
674
|
+
</diagnostician_tasks>\n`;
|
|
675
|
+
|
|
676
|
+
logger?.info?.(`[PD:Prompt] Injected ${Math.min(pendingCount, 3)}/${pendingCount} pending diagnostician task(s) into heartbeat prompt`);
|
|
677
|
+
}
|
|
678
|
+
} catch (e) {
|
|
679
|
+
logger?.warn?.(`[PD:Prompt] Failed to read diagnostician tasks: ${String(e)}`);
|
|
680
|
+
}
|
|
647
681
|
}
|
|
648
682
|
|
|
649
683
|
// ──── 6. Dynamic Attitude Matrix (based on GFI) ────
|
|
650
684
|
|
|
651
|
-
let attitudeDirective
|
|
685
|
+
let attitudeDirective: string;
|
|
652
686
|
const currentGfi = session?.currentGfi || 0;
|
|
653
687
|
|
|
654
688
|
if (currentGfi >= 70) {
|
package/src/hooks/subagent.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -118,7 +118,7 @@ function computeRuntimeShadowTaskFingerprint(event: PluginHookSubagentSpawningEv
|
|
|
118
118
|
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
function
|
|
121
|
+
function _resolveCommandWorkspaceDirStrict(
|
|
122
122
|
api: OpenClawPluginApi,
|
|
123
123
|
ctx: WorkspaceResolutionContext,
|
|
124
124
|
): string {
|
|
@@ -382,7 +382,7 @@ function buildFallbackNocturnalSnapshot(
|
|
|
382
382
|
// #246-fix: Use minToolCalls=0 to avoid filtering out sessions with 0 tool calls.
|
|
383
383
|
// The pain-triggering session may have no tool calls but still be worth tracking.
|
|
384
384
|
const summaries = extractor.listRecentNocturnalCandidateSessions({ limit: 300, minToolCalls: 0 });
|
|
385
|
-
const match = summaries.find(s => s.sessionId === painContext.mostRecent
|
|
385
|
+
const match = summaries.find(s => s.sessionId === painContext.mostRecent?.sessionId);
|
|
386
386
|
if (match) {
|
|
387
387
|
realStats = {
|
|
388
388
|
totalAssistantTurns: match.assistantTurnCount,
|
|
@@ -553,8 +553,8 @@ export class HealthQueryService {
|
|
|
553
553
|
const streamPath = resolvePdPath(this.workspaceDir, 'EVOLUTION_STREAM');
|
|
554
554
|
if (!fs.existsSync(streamPath)) return [];
|
|
555
555
|
|
|
556
|
-
|
|
557
|
-
let lines: string[]
|
|
556
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
557
|
+
let lines: string[];
|
|
558
558
|
try {
|
|
559
559
|
const raw = fs.readFileSync(streamPath, 'utf8').trim();
|
|
560
560
|
if (!raw) return [];
|
|
@@ -565,8 +565,9 @@ export class HealthQueryService {
|
|
|
565
565
|
|
|
566
566
|
const records: RecentPrincipleChange[] = [];
|
|
567
567
|
for (const line of lines) {
|
|
568
|
-
|
|
569
|
-
|
|
568
|
+
|
|
569
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
570
|
+
let event: EvolutionStreamRecord | null;
|
|
570
571
|
try {
|
|
571
572
|
event = JSON.parse(line) as EvolutionStreamRecord;
|
|
572
573
|
} catch {
|
|
@@ -784,6 +785,7 @@ export class HealthQueryService {
|
|
|
784
785
|
}
|
|
785
786
|
|
|
786
787
|
|
|
788
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
787
789
|
private getEventDedupKey(entry: EventLogEntry): string {
|
|
788
790
|
const eventId = typeof entry.data?.eventId === 'string' ? entry.data.eventId : null;
|
|
789
791
|
if (eventId) {
|
|
@@ -854,6 +856,7 @@ export class HealthQueryService {
|
|
|
854
856
|
}
|
|
855
857
|
|
|
856
858
|
|
|
859
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
857
860
|
private resolveGateType(row: GateBlockRow): string {
|
|
858
861
|
if (typeof row.gate_type === 'string' && row.gate_type.trim().length > 0) {
|
|
859
862
|
return row.gate_type;
|
|
@@ -878,6 +881,7 @@ export class HealthQueryService {
|
|
|
878
881
|
}
|
|
879
882
|
|
|
880
883
|
|
|
884
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
881
885
|
private scoreToStatus(score: number): string {
|
|
882
886
|
if (score >= 70) return 'healthy';
|
|
883
887
|
if (score >= 40) return 'warning';
|
|
@@ -885,6 +889,7 @@ export class HealthQueryService {
|
|
|
885
889
|
}
|
|
886
890
|
|
|
887
891
|
|
|
892
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
888
893
|
private evolutionToStatus(tier: string, points: number): string {
|
|
889
894
|
const lower = tier.toLowerCase();
|
|
890
895
|
if (lower === 'forest' || lower === 'tree') return 'healthy';
|
|
@@ -893,6 +898,7 @@ export class HealthQueryService {
|
|
|
893
898
|
}
|
|
894
899
|
|
|
895
900
|
|
|
901
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
896
902
|
private safeListFiles(dirPath: string, predicate: (_name: string) => boolean): string[] {
|
|
897
903
|
if (!fs.existsSync(dirPath)) return [];
|
|
898
904
|
try {
|
|
@@ -905,6 +911,7 @@ export class HealthQueryService {
|
|
|
905
911
|
}
|
|
906
912
|
|
|
907
913
|
|
|
914
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
908
915
|
private readJsonFile<T>(filePath: string, fallback: T): T {
|
|
909
916
|
if (!fs.existsSync(filePath)) return fallback;
|
|
910
917
|
try {
|
|
@@ -915,11 +922,13 @@ export class HealthQueryService {
|
|
|
915
922
|
}
|
|
916
923
|
|
|
917
924
|
|
|
925
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
918
926
|
private asNumber(value: unknown, fallback: number): number {
|
|
919
927
|
return Number.isFinite(value) ? Number(value) : fallback;
|
|
920
928
|
}
|
|
921
929
|
|
|
922
930
|
|
|
931
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
923
932
|
private asNullableNumber(value: unknown): number | null {
|
|
924
933
|
if (Number.isFinite(value)) return Number(value);
|
|
925
934
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
@@ -954,7 +963,7 @@ export class HealthQueryService {
|
|
|
954
963
|
dailyGfiPeak,
|
|
955
964
|
today,
|
|
956
965
|
);
|
|
957
|
-
} catch
|
|
966
|
+
} catch {
|
|
958
967
|
// Non-critical: GFI sync failure should not block queries
|
|
959
968
|
}
|
|
960
969
|
}
|
|
@@ -1015,7 +1024,7 @@ export class HealthQueryService {
|
|
|
1015
1024
|
}
|
|
1016
1025
|
|
|
1017
1026
|
return latest;
|
|
1018
|
-
} catch
|
|
1027
|
+
} catch {
|
|
1019
1028
|
// Non-critical: failure to read session files should not crash the service
|
|
1020
1029
|
return null;
|
|
1021
1030
|
}
|
|
@@ -36,7 +36,6 @@ import {
|
|
|
36
36
|
} from '../nocturnal-service.js';
|
|
37
37
|
import { type TrinityStageFailure, type TrinityResult } from '../../core/nocturnal-trinity.js';
|
|
38
38
|
import type { TrinityRuntimeAdapter } from '../../core/nocturnal-trinity.js';
|
|
39
|
-
import type { NocturnalSessionSnapshot } from '../../core/nocturnal-trajectory-extractor.js';
|
|
40
39
|
import type { RecentPainContext } from '../evolution-worker.js';
|
|
41
40
|
import * as fs from 'fs';
|
|
42
41
|
import * as path from 'path';
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
rankCandidates,
|
|
6
6
|
runTournament,
|
|
7
7
|
DEFAULT_SCORING_WEIGHTS,
|
|
8
|
+
validateCandidateDiversity,
|
|
8
9
|
} from '../../src/core/nocturnal-candidate-scoring.js';
|
|
9
10
|
import type { DreamerCandidate, PhilosopherJudgment } from '../../src/core/nocturnal-trinity.js';
|
|
10
11
|
import type { ThresholdValues } from '../../src/core/adaptive-thresholds.js';
|
|
@@ -398,3 +399,134 @@ describe('DEFAULT_SCORING_WEIGHTS', () => {
|
|
|
398
399
|
}
|
|
399
400
|
});
|
|
400
401
|
});
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Tests: validateCandidateDiversity
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
describe('validateCandidateDiversity', () => {
|
|
408
|
+
it('passes when candidates have 2+ distinct risk levels and low keyword overlap', () => {
|
|
409
|
+
const candidates: DreamerCandidate[] = [
|
|
410
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Read config.json to verify settings' }),
|
|
411
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Refactor the entire authentication module from scratch' }),
|
|
412
|
+
];
|
|
413
|
+
const result = validateCandidateDiversity(candidates);
|
|
414
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
415
|
+
expect(result.riskLevelDiversity).toBe(true);
|
|
416
|
+
expect(result.keywordOverlapPassed).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('fails when all candidates have the same risk level', () => {
|
|
420
|
+
const candidates: DreamerCandidate[] = [
|
|
421
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Read file A to check settings' }),
|
|
422
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'low', betterDecision: 'Review file completely different approach' }),
|
|
423
|
+
makeCandidate({ candidateIndex: 2, riskLevel: 'low', betterDecision: 'Inspect another unique diagnostic method' }),
|
|
424
|
+
];
|
|
425
|
+
const result = validateCandidateDiversity(candidates);
|
|
426
|
+
expect(result.diversityCheckPassed).toBe(false);
|
|
427
|
+
expect(result.riskLevelDiversity).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('fails when candidate pair has keyword overlap > 0.8', () => {
|
|
431
|
+
const candidates: DreamerCandidate[] = [
|
|
432
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Review the authentication configuration file before making any changes to the system' }),
|
|
433
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Review the authentication configuration file before making any changes to the system' }),
|
|
434
|
+
];
|
|
435
|
+
const result = validateCandidateDiversity(candidates);
|
|
436
|
+
expect(result.diversityCheckPassed).toBe(false);
|
|
437
|
+
expect(result.keywordOverlapPassed).toBe(false);
|
|
438
|
+
expect(result.maxOverlapScore).toBeGreaterThan(0.8);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('passes for single candidate', () => {
|
|
442
|
+
const candidates: DreamerCandidate[] = [
|
|
443
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low' }),
|
|
444
|
+
];
|
|
445
|
+
const result = validateCandidateDiversity(candidates);
|
|
446
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
447
|
+
expect(result.details).toContain('Single candidate');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('passes for empty array', () => {
|
|
451
|
+
const result = validateCandidateDiversity([]);
|
|
452
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
453
|
+
expect(result.details).toContain('No candidates');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('passes when candidates lack riskLevel (graceful degradation)', () => {
|
|
457
|
+
const candidates: DreamerCandidate[] = [
|
|
458
|
+
makeCandidate({ candidateIndex: 0, betterDecision: 'Read config.json to verify settings' }),
|
|
459
|
+
makeCandidate({ candidateIndex: 1, betterDecision: 'Refactor the entire authentication module from scratch' }),
|
|
460
|
+
];
|
|
461
|
+
// No riskLevel on any candidate - should pass (no risk levels to check)
|
|
462
|
+
const result = validateCandidateDiversity(candidates);
|
|
463
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
464
|
+
expect(result.riskLevelDiversity).toBe(true);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('fails when some candidates have riskLevel but fewer than 2 distinct values', () => {
|
|
468
|
+
const candidates: DreamerCandidate[] = [
|
|
469
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'medium', betterDecision: 'Read config.json to verify settings' }),
|
|
470
|
+
makeCandidate({ candidateIndex: 1, betterDecision: 'Refactor the entire authentication module from scratch' }),
|
|
471
|
+
];
|
|
472
|
+
// Only 1 candidate has riskLevel, so only 1 distinct value → fail
|
|
473
|
+
const result = validateCandidateDiversity(candidates);
|
|
474
|
+
expect(result.diversityCheckPassed).toBe(false);
|
|
475
|
+
expect(result.riskLevelDiversity).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('uses max(|A|, |B|) as denominator for keyword overlap', () => {
|
|
479
|
+
// Short text A, long text B - overlap should use max as denominator
|
|
480
|
+
const candidates: DreamerCandidate[] = [
|
|
481
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'review authentication configuration' }),
|
|
482
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'review authentication configuration before proceeding with changes to the deployment pipeline infrastructure' }),
|
|
483
|
+
];
|
|
484
|
+
const result = validateCandidateDiversity(candidates);
|
|
485
|
+
// "review", "authentication", "configuration" overlap in both
|
|
486
|
+
// Set A = {review, authentication, configuration} = 3
|
|
487
|
+
// Set B = {review, authentication, configuration, before, proceeding, with, changes, deployment, pipeline, infrastructure} = 10
|
|
488
|
+
// intersection = 3, max(3, 10) = 10, overlap = 3/10 = 0.3
|
|
489
|
+
expect(result.maxOverlapScore).toBeLessThanOrEqual(0.4);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('ignores words <= 3 characters in keyword overlap', () => {
|
|
493
|
+
const candidates: DreamerCandidate[] = [
|
|
494
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'the and but for' }),
|
|
495
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'the and but for' }),
|
|
496
|
+
];
|
|
497
|
+
// All words are <= 3 chars, so no keywords extracted → overlap = 0
|
|
498
|
+
const result = validateCandidateDiversity(candidates);
|
|
499
|
+
expect(result.keywordOverlapPassed).toBe(true);
|
|
500
|
+
expect(result.maxOverlapScore).toBe(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('never throws on malformed input', () => {
|
|
504
|
+
// Undefined candidates
|
|
505
|
+
expect(() => validateCandidateDiversity(undefined as unknown as DreamerCandidate[])).not.toThrow();
|
|
506
|
+
// Null candidates
|
|
507
|
+
expect(() => validateCandidateDiversity(null as unknown as DreamerCandidate[])).not.toThrow();
|
|
508
|
+
// Candidates with undefined fields
|
|
509
|
+
expect(() => validateCandidateDiversity([
|
|
510
|
+
{ candidateIndex: 0 } as DreamerCandidate,
|
|
511
|
+
])).not.toThrow();
|
|
512
|
+
// Mixed valid and malformed
|
|
513
|
+
expect(() => validateCandidateDiversity([
|
|
514
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low' }),
|
|
515
|
+
{ candidateIndex: 1 } as DreamerCandidate,
|
|
516
|
+
])).not.toThrow();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('returns correct maxOverlapScore rounded to 2 decimal places', () => {
|
|
520
|
+
const candidates: DreamerCandidate[] = [
|
|
521
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Review configuration settings before deployment' }),
|
|
522
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Review configuration settings before deployment testing' }),
|
|
523
|
+
];
|
|
524
|
+
const result = validateCandidateDiversity(candidates);
|
|
525
|
+
// Verify the maxOverlapScore is a number with at most 2 decimal places
|
|
526
|
+
const decimalPart = result.maxOverlapScore.toString().split('.')[1];
|
|
527
|
+
if (decimalPart) {
|
|
528
|
+
expect(decimalPart.length).toBeLessThanOrEqual(2);
|
|
529
|
+
}
|
|
530
|
+
expect(typeof result.maxOverlapScore).toBe('number');
|
|
531
|
+
});
|
|
532
|
+
});
|