ms-ai-architect/tests/kb-eval/test-skill-score-gate.test.mjs
Kjell Tore Guttormsen 75ee9ec062 feat(ms-ai-architect): Spor 3 Port 3 (CT5) — deterministisk sourcedness erstatter LLM-samplet K8 [skip-docs]
§4 Port 3 verifiseringskrav: «CT5 (sourcedness) ERSTATTER K8s rolle». K8 var
LLM-judge (sample 5 ref-filer, ikke-deterministisk); CT5 er samme signal gjort
deterministisk + hel-korpus fra Port 1-kontrakten. Per ref-kb-direction-note §45
MÅ de ikke leve parallelt (dobbelttelling + divergerende dashbord) — så K8 er
fjernet, ikke duplisert.

eval.mjs: checkCT5(refFiles) — blant filer som deklarerer type:reference, andel
med **Source:** (template/methodology/regulatory ekskludert fra nevneren).
Wiret som deterministic.CT5_sourcedness. parseTypeHeader/parseSourceHeader
importert fra kb-update/lib/kb-headers (tillatt retning).

skill-score.mjs: K8-kriteriet (source:judge) → CT5 (source:det, vekt 1).
available:false når referenceFiles=0 → droppet fra vektet snitt → tanker IKKE
scoren på umigrert korpus (alarm-tretthet unngått, direction-note §45).

judge-prompt.md: K8 fjernet fra rubrikk/instruksjon/JSON (judgen gjør nå
K1/K4/K7/K9).

Ekte kjøring: alle 5 skills CT5 dormant (referenceFiles:0, umigrert), scorer
uendret (91-96, 0 under mål). CT5 aktiveres deterministisk når Spor 1 migrerer.

TDD (Iron Law): 5 checkCT5 + 3 CT5-integrasjon; død K8-fixture-data ryddet.
Suite 620/620. Plugin-validering 239/0/0.
2026-06-29 15:10:14 +02:00

145 lines
6.8 KiB
JavaScript

// tests/kb-eval/test-skill-score-gate.test.mjs
// Tests for the Spor D, STEG B lifecycle-gate building blocks:
// - lib/skill-score.mjs gateSkill(report, name): the PURE post-mutation verdict
// used by apply-skill-op.mjs to re-score the affected skill incrementally.
// The deterministic floor (K10) is always enforced; an unjudged skill is
// flagged `provisional` (the K1 floor cannot be enforced -> nudge to re-judge).
// - lib/skill-score.mjs buildScoreCache(result): the compact, PURE cache shape
// persisted as data/skill-score-report.json and consumed by STEG C surfacing.
// - the score-skill.mjs CLI --write flag: persists the whole-corpus cache.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync, rmSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { gateSkill, buildScoreCache, scoreReport } from '../../scripts/kb-eval/lib/skill-score.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI = join(__dirname, '..', '..', 'scripts', 'kb-eval', 'score-skill.mjs');
// A synthetic skill whose deterministic criteria all pass; K10 + judge are
// parametrizable so a single helper drives the meets/blocked/provisional cases.
function skill(name, { k10Pass = true, k10Combined = 1, worst = 'other', judge = null } = {}) {
return {
name,
deterministic: {
K2_descriptionFormat: { pass: true },
K3_bodyLength: { bodyLines: 100, pass: true },
K5_progressiveDisclosure: { namedRatio: 0.5, pass: true },
K6_routingTable: { pass: true },
refCountConsistency: { consistent: true },
K10_siblingScopeOverlap: { maxCombined: k10Combined, pass: k10Pass, threshold: 7, worstSibling: worst },
N1_nameValidity: { pass: true },
N2_descriptionLength: { pass: true },
N3_refNestingDepth: { pass: true },
N4_refToc: { ratio: 1, pass: true, withToc: 1, largeFiles: 1 },
N5_forwardSlashPaths: { pass: true },
},
judge,
};
}
const FULL_JUDGE = {
K1_triggerPrecision: { precision: 1, pass: true },
K4_noDuplication: { score: 5, pass: true },
K9_noTimeSensitive: { pass: true, findings: [] },
K7_imperativeStyle: { ratio: 1, pass: true },
};
test('gateSkill — affected skill that meets the target is not blocked', () => {
const report = { skills: [skill('alpha', { judge: FULL_JUDGE }), skill('beta', { judge: FULL_JUDGE })] };
const v = gateSkill(report, 'alpha', { target: 90 });
assert.equal(v.found, true);
assert.equal(v.meetsTarget, true);
assert.equal(v.blocked, false);
assert.equal(v.provisional, false);
});
test('gateSkill — a failing K10 floor blocks the affected skill (deterministic, enforced immediately)', () => {
const report = {
skills: [skill('good', { judge: FULL_JUDGE }), skill('bad', { k10Pass: false, k10Combined: 9, worst: 'good', judge: FULL_JUDGE })],
};
const v = gateSkill(report, 'bad', { target: 90 });
assert.equal(v.found, true);
assert.equal(v.meetsTarget, false);
assert.equal(v.blocked, true);
assert.ok(v.score <= 89, `K10 floor must cap below 90, got ${v.score}`);
assert.ok(v.improvements.some((i) => i.key === 'K10'), 'K10 must surface as an improvement');
});
test('gateSkill — an unjudged skill is provisional but the deterministic floor still blocks it', () => {
// judge:null -> K1 floor cannot be enforced (provisional), but K10 (deterministic) still blocks.
const report = { skills: [skill('good'), skill('bad', { k10Pass: false, k10Combined: 9, worst: 'good' })] };
const blocked = gateSkill(report, 'bad', { target: 90 });
assert.equal(blocked.provisional, true, 'missing judge -> provisional');
assert.equal(blocked.blocked, true, 'deterministic K10 floor still blocks when provisional');
const ok = gateSkill(report, 'good', { target: 90 });
assert.equal(ok.provisional, true, 'missing judge -> provisional even when passing');
assert.equal(ok.meetsTarget, true, 'deterministic-only score still passes');
});
test('gateSkill — an unknown affected skill returns found:false (no crash)', () => {
const report = { skills: [skill('alpha', { judge: FULL_JUDGE })] };
const v = gateSkill(report, 'does-not-exist', { target: 90 });
assert.equal(v.found, false);
assert.equal(v.name, 'does-not-exist');
});
test('buildScoreCache — compact whole-corpus shape with below[] as names', () => {
const report = {
skills: [skill('good', { judge: FULL_JUDGE }), skill('bad', { k10Pass: false, k10Combined: 9, worst: 'good', judge: FULL_JUDGE })],
};
const result = scoreReport(report, { target: 90 });
const cache = buildScoreCache(result, { generatedAt: '2026-06-23' });
assert.equal(cache.target, 90);
assert.equal(cache.generatedAt, '2026-06-23');
assert.equal(cache.scored.length, 2);
// compact per-skill entry: the fields STEG C surfacing needs, not the verbose criteria[]
const good = cache.scored.find((s) => s.name === 'good');
assert.equal(typeof good.score, 'number');
assert.equal(typeof good.meetsTarget, 'boolean');
assert.ok(!('criteria' in good), 'cache must stay compact (no criteria[])');
// below[] is a list of NAMES (cheap for the SessionStart one-liner)
assert.deepEqual(cache.below, ['bad']);
});
test('buildScoreCache — deterministic given an injected generatedAt (no Date.now)', () => {
const report = { skills: [skill('alpha', { judge: FULL_JUDGE })] };
const result = scoreReport(report, { target: 90 });
const a = buildScoreCache(result, { generatedAt: '2026-01-01' });
const b = buildScoreCache(result, { generatedAt: '2026-01-01' });
assert.deepEqual(a, b);
});
test('CLI --write <path> — persists the whole-corpus cache (all 5 skills) to disk', () => {
const out = join(tmpdir(), `skill-score-cache-${process.pid}.json`);
try {
execFileSync('node', [CLI, '--write', out], { encoding: 'utf8' });
const cache = JSON.parse(readFileSync(out, 'utf8'));
assert.equal(cache.target, 90);
assert.equal(cache.scored.length, 5, 'cache must cover the whole corpus, not a filtered view');
assert.ok(Array.isArray(cache.below), 'below[] present');
for (const s of cache.scored) {
assert.equal(typeof s.name, 'string');
assert.equal(typeof s.score, 'number');
assert.equal(typeof s.meetsTarget, 'boolean');
}
} finally {
rmSync(out, { force: true });
}
});
test('CLI --write with --skill still writes the whole corpus (cache is corpus-wide)', () => {
const out = join(tmpdir(), `skill-score-cache-corpus-${process.pid}.json`);
try {
execFileSync('node', [CLI, '--skill', 'ms-ai-advisor', '--write', out], { encoding: 'utf8' });
const cache = JSON.parse(readFileSync(out, 'utf8'));
assert.equal(cache.scored.length, 5, '--skill scopes stdout, not the persisted cache');
} finally {
rmSync(out, { force: true });
}
});