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:
parent
5ad4ed025c
commit
ba597eb988
5 changed files with 400 additions and 123 deletions
180
scripts/kb-eval/lib/sibling-overlap.mjs
Normal file
180
scripts/kb-eval/lib/sibling-overlap.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// sibling-overlap.mjs — shared deterministic overlap core for the skill rubric.
|
||||
//
|
||||
// Extracted from detect-skill-lifecycle.mjs (Sesjon 15 / B2) so BOTH the B1
|
||||
// detector and eval.mjs can use the same overlap mechanics without a circular
|
||||
// import (detect already imports from eval.mjs; eval.mjs now needs the overlap
|
||||
// core, so it lives here and both import it).
|
||||
//
|
||||
// Two deterministic signals, combined per skill pair:
|
||||
// (1) boundary-tension graph — operator-curated k1-trigger-prompts.json:
|
||||
// each out_of_domain entry is a sibling prompt tagged belongs_to=<skill>,
|
||||
// a hand-labelled "confusable neighbour". Symmetric counts = how much two
|
||||
// skills sit on each other's trigger boundary.
|
||||
// (2) df-weighted lexical trigger-surface overlap — shared content tokens
|
||||
// between two descriptions, each weighted 1/df so domain-common vocabulary
|
||||
// ("azure") contributes little and distinctive shared tokens contribute more.
|
||||
// combined = boundaryTension + weightedScore.
|
||||
//
|
||||
// K10 (B2): perSkillSiblingOverlap maps the pairwise combined scores to a
|
||||
// per-skill verdict — a skill's K10 is its worst sibling pair; it FAILS when
|
||||
// that exceeds the rubric threshold. This is the bar B3 merge/sanitize rests on.
|
||||
//
|
||||
// Zero dependencies — pure functions only (no fs/path). Inputs are passed in.
|
||||
|
||||
// Operator-designated B1 focus boundary (Azure-deployment: build <-> operate).
|
||||
export const FOCUS_PAIR = ['ms-ai-engineering', 'ms-ai-infrastructure'];
|
||||
|
||||
// K10 rubric threshold — a skill FAILS sibling-scope-non-overlap when its worst
|
||||
// sibling pair's combined score is at or above this. Set in the natural
|
||||
// distribution gap (7.42 -> 6.67) just below the operator-designated eng<->infra
|
||||
// boundary, so exactly that pair is surfaced as the B3 merge/sanitize signal.
|
||||
export const K10_OVERLAP_THRESHOLD = 7.0;
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* K10 — sibling-scope-non-overlap, per skill.
|
||||
*
|
||||
* Maps the pairwise `combined` scores (from computeOverlapFromInputs) to a
|
||||
* per-skill verdict: a skill's K10 is its WORST sibling pair (max combined among
|
||||
* pairs containing it). pass = maxCombined < threshold. A failing skill is a
|
||||
* B3 merge/sanitize candidate against its worst sibling — never a blocker here.
|
||||
*
|
||||
* @param {Array<{pair:string[], combined:number}>} pairs from computeOverlapFromInputs
|
||||
* @param {{threshold?:number}} [opts]
|
||||
* @returns {Record<string,{maxCombined:number, worstSibling:string|null, pass:boolean, threshold:number}>}
|
||||
*/
|
||||
export function perSkillSiblingOverlap(pairs, { threshold = K10_OVERLAP_THRESHOLD } = {}) {
|
||||
const out = {};
|
||||
const ensure = (s) => {
|
||||
if (!out[s]) out[s] = { maxCombined: 0, worstSibling: null, pass: true, threshold };
|
||||
};
|
||||
for (const p of pairs) {
|
||||
const [a, b] = p.pair;
|
||||
ensure(a);
|
||||
ensure(b);
|
||||
if (p.combined > out[a].maxCombined) {
|
||||
out[a].maxCombined = p.combined;
|
||||
out[a].worstSibling = b;
|
||||
}
|
||||
if (p.combined > out[b].maxCombined) {
|
||||
out[b].maxCombined = p.combined;
|
||||
out[b].worstSibling = a;
|
||||
}
|
||||
}
|
||||
for (const s of Object.keys(out)) out[s].pass = out[s].maxCombined < threshold;
|
||||
return out;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue