// 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=, // 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} */ 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; }