feat(ms-ai-architect): Sesjon 15 — B2 K10 søsken-scope-ikke-overlapp

- Refaktor: overlap-kjerne flyttet til scripts/kb-eval/lib/sibling-overlap.mjs
  (bryter sirkulær import eval.mjs<->detect); detect re-eksporterer → B1-tester urørt
- K10 = søsken-scope-ikke-overlapp, deterministisk cross-skill: perSkillSiblingOverlap
  + attachSiblingOverlap i eval.mjs. combined = boundaryTension + df-vektet leksikalsk;
  per-skill verdikt = verste søskenpar; terskel 7.0 (naturlig gap 7.42→6.67)
- Empirisk (alle 5): eng+infra FAIL (7.42 mot hverandre), advisor/gov/sec PASS
  → eng↔infra-signal mater B3 merge/saner (ikke blokkering)
- Gated baseline-regen via --write (descriptions urørt → judge K1/K4/K7/K8/K9 merget
  uendret, ikke fabrikkert); rubric K1-K10
- TDD: +9 tester (tests/kb-eval/test-k10-sibling-overlap.test.mjs), kb-eval 31→40
- 0 skriving til skills/. Suiter: validate 239 · kb-update 122 · kb-integrity 192/192
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 11:59:59 +02:00
commit ba597eb988
5 changed files with 400 additions and 123 deletions

View file

@ -39,6 +39,29 @@ import { fileURLToPath } from 'node:url';
import { splitFrontmatter, extractDescription, checkK3 } from './eval.mjs';
import { loadTaxonomy } from '../kb-update/lib/taxonomy.mjs';
import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs';
import {
FOCUS_PAIR,
tokenize,
extractTriggerSurface,
buildDocumentFrequency,
pairKey,
lexicalOverlap,
boundaryTensionMatrix,
computeOverlapFromInputs,
} from './lib/sibling-overlap.mjs';
// Re-export the overlap core (moved to lib/sibling-overlap.mjs in S15/B2 to
// avoid a circular import with eval.mjs) so existing B1 callers/tests keep
// importing it from this module unchanged.
export {
tokenize,
extractTriggerSurface,
buildDocumentFrequency,
pairKey,
lexicalOverlap,
boundaryTensionMatrix,
computeOverlapFromInputs,
};
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
@ -48,9 +71,6 @@ const TAX_DATA_DIR = join(__dirname, '..', 'kb-update', 'data');
const PROMPTS_FILE = join(DATA_DIR, 'k1-trigger-prompts.json');
const OUT_FILE = join(DATA_DIR, 'skill-lifecycle-report.json');
// Operator-designated B1 focus boundary.
const FOCUS_PAIR = ['ms-ai-engineering', 'ms-ai-infrastructure'];
// --- S14 detector thresholds (named, documented) -------------------------
// K3 hard body-length limit (mirrors eval.mjs K3_MAX_BODY_LINES — single source
// of bodyLines is eval.checkK3; this constant only computes the margin).
@ -74,121 +94,6 @@ const LAST_UPDATED_PATTERNS = [
/\*\*Dato:\*\*\s*([\d-]+)/i,
];
// Function words (no + en) of length >= 3. Tokens < 3 chars are dropped anyway,
// so this list only needs the longer connectives. Domain nouns are NOT here —
// df-weighting handles common domain vocabulary instead.
const STOPWORDS = new Set([
'the', 'and', 'for', 'with', 'between', 'before', 'not', 'are', 'that', 'this',
'into', 'over', 'per', 'use', 'used', 'when', 'which', 'how', 'via', 'from',
'eller', 'som', 'til', 'med', 'mot', 'ved', 'for', 'har', 'kan', 'ikke', 'der',
'det', 'den', 'ein', 'eit', 'sin',
// description-format boilerplate (every skill ends with "Triggers on:")
'triggers', 'trigger',
]);
/** Lowercase, split on non-alphanumeric, drop stopwords + tokens < 3 chars. */
export function tokenize(text) {
return (text.toLowerCase().match(/[a-z0-9æøå]+/g) || [])
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
}
/** Trigger surface of a description: quoted phrases + content-token set. */
export function extractTriggerSurface(description) {
const phrases = (description.match(/"([^"]+)"/g) || []).map((p) => p.slice(1, -1));
return { phrases, tokens: new Set(tokenize(description)) };
}
/** token -> number of skill-surfaces that contain it. */
export function buildDocumentFrequency(surfaces) {
const df = new Map();
for (const s of surfaces) {
for (const t of s.tokens) df.set(t, (df.get(t) || 0) + 1);
}
return df;
}
/** Order-independent pair key. */
export function pairKey(a, b) {
return [a, b].sort().join('|');
}
/** Lexical overlap between two surfaces, df-weighted. */
export function lexicalOverlap(surfaceA, surfaceB, df) {
const shared = [];
for (const t of surfaceA.tokens) if (surfaceB.tokens.has(t)) shared.push(t);
shared.sort();
const union = new Set([...surfaceA.tokens, ...surfaceB.tokens]).size;
const jaccard = union > 0 ? shared.length / union : 0;
let weightedScore = 0;
for (const t of shared) weightedScore += 1 / (df.get(t) || 1);
return {
shared,
jaccard: Number(jaccard.toFixed(4)),
weightedScore: Number(weightedScore.toFixed(4)),
};
}
/**
* Symmetric boundary-tension matrix from the curated prompt set.
* Counts out_of_domain entries whose belongs_to is one of the real skills
* (controls / out-of-stack entries are ignored). Keyed by pairKey.
*/
export function boundaryTensionMatrix(promptSet) {
const skills = Object.keys(promptSet).filter((k) => k !== '_meta');
const skillSet = new Set(skills);
const m = {};
for (const s of skills) {
for (const e of promptSet[s].out_of_domain || []) {
const b = e && e.belongs_to;
if (!skillSet.has(b) || b === s) continue;
const k = pairKey(s, b);
m[k] = (m[k] || 0) + 1;
}
}
return m;
}
/**
* Pure core: given { skill -> description } and the curated prompt set, compute
* the overlap report section. combined = boundaryTension + weightedScore
* (operator-grounded primary signal + distinctive-lexical corroboration).
*/
export function computeOverlapFromInputs(descriptionsBySkill, promptSet) {
const skills = Object.keys(descriptionsBySkill).sort();
const surfaces = {};
for (const s of skills) surfaces[s] = extractTriggerSurface(descriptionsBySkill[s]);
const df = buildDocumentFrequency(Object.values(surfaces));
const tension = boundaryTensionMatrix(promptSet);
const pairs = [];
for (let i = 0; i < skills.length; i++) {
for (let j = i + 1; j < skills.length; j++) {
const a = skills[i];
const b = skills[j];
const key = pairKey(a, b);
const lexical = lexicalOverlap(surfaces[a], surfaces[b], df);
const boundaryTension = tension[key] || 0;
const combined = Number((boundaryTension + lexical.weightedScore).toFixed(4));
pairs.push({ pair: [a, b], key, boundaryTension, lexical, combined });
}
}
// sort by combined desc, then key asc for stable ties
pairs.sort((x, y) => y.combined - x.combined || x.key.localeCompare(y.key));
const focusKey = pairKey(...FOCUS_PAIR);
const focusPair = pairs.find((p) => p.key === focusKey) || null;
return {
method:
'deterministic: (1) operator-curated boundary-tension (k1-trigger-prompts belongs_to), ' +
'(2) df-weighted lexical trigger-surface overlap. combined = boundaryTension + weightedScore.',
focusPairReason:
'Azure-deployment boundary engineering(build) <-> infrastructure(operate) — operator-designated B1 target.',
pairs,
focusPair,
};
}
// ===========================================================================
// S14 — Detector 2: coverage/gap (in-domain) — taxonomy vs physical disk
// ===========================================================================