ms-ai-architect/scripts/kb-eval/build-sample-frame.mjs
Kjell Tore Guttormsen f7fba5e4e7 feat(ms-ai-architect): Fase 0 steg 1 — deterministisk sample-frame (45 volatil + 10 kontroll) + TDD [skip-docs]
Internt Fase 0-måleverktøy (ingen brukervendt flate: ingen ny kommando/agent/skill).

- build-sample-frame.mjs: volatilitets-scorer (K9-grense for stabile identifikatorer)
  + sti-boost (cost-optimization/platforms) + stratifisering (floor + masse-vektet
  largest-remainder). Deterministisk, ingen Math.random.
- 14 unit-tester (suite 509 -> 523, alle grønne)
- fase0-sample-frame.json: 55 filer fra 306 sourced-populasjon; oversampler
  pris/SKU/TPM/region/versjon/preview-tette filer; kontroll-stratum fra
  responsible-ai/npsg/architecture (stabile påstander)
2026-06-26 14:18:53 +02:00

265 lines
10 KiB
JavaScript

#!/usr/bin/env node
// build-sample-frame.mjs — Fase 0, steg 1: deterministic volatility-weighted,
// stratified sample frame for ref-file correctness verification.
//
// Selects ~45 volatile-dense reference files (oversampling cost/platform/SKU/
// price/TPM/region/version/preview-dense files across all 5 skills) plus a
// control stratum of low-volatility methodology/regulatory files. Selection is
// DETERMINISTIC — same input yields the same frame (no Math.random).
//
// The volatility scorer is a RANKING heuristic for sample SELECTION, not the
// correctness classifier (that is the subagent step against live MS Learn).
// Its signal set + stable-identifier boundary follow the K9 rule in
// scripts/kb-eval/judge-prompt.md: regulation years, case numbers, standard
// version names (OWASP ... 2025, MADR v3.0) and filenames are NOT volatile.
//
// Verifiable population = the 306 ref files that cite >=1 MS Learn URL, derived
// by inverting scripts/kb-update/data/url-registry.json (urls{}.reference_files[]).
//
// Usage:
// node scripts/kb-eval/build-sample-frame.mjs # human-readable summary
// node scripts/kb-eval/build-sample-frame.mjs --json # frame JSON to stdout
// node scripts/kb-eval/build-sample-frame.mjs --write # persist data/fase0-sample-frame.json
//
// Zero dependencies. Reuses kb-update/lib/atomic-write.mjs for the gated write.
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const REGISTRY = join(PLUGIN_ROOT, 'scripts/kb-update/data/url-registry.json');
const OUT_FILE = join(__dirname, 'data', 'fase0-sample-frame.json');
// --- volatility signal patterns (ranking heuristic; K9 boundary) -------------
// Stable identifiers deliberately do NOT match: regulation years (2024/1689),
// case numbers (C-311/18), standard version names (MADR v3.0, OWASP LLM Top 10
// 2025) and filenames — see judge-prompt.md K9.
const SIGNAL_PATTERNS = {
tpmPtu: /\b(TPM|PTU|tokens?[ -]per[ -]minute|provisioned throughput)\b/gi,
price:
/(\bNOK\b|\bUSD\b|\bEUR\b|\$\s?\d|\bkr\b|per\s?1[ ,.]?0{3,}\s?tokens|per\s?1\s?[MK]\b|\/month|\/m[åa]ned)/gi,
sku: /\b(SKU|GlobalStandard|DataZoneStandard|DataZone|Pay-?as-?you-?go|PayGo|provisioned deployment|deployment type)\b/gi,
region:
/\b(East US|West US|West Europe|North Europe|Sweden Central|Norway East|norwayeast|swedencentral|region(?:al)? availability|available in (?:the )?following regions)\b/gi,
version:
/\b(GPT-[0-9](?:\.[0-9])?|GPT-4o|o1|o3|o4-mini|Claude\s?[0-9]|Gemini\s?[0-9]|text-embedding-[0-9]|api-version=\d{4}-\d{2}-\d{2})\b/gi,
previewGa: /(public preview|private preview|in preview|\(preview\)|generally available|now available)/gi,
};
const SIGNAL_WEIGHTS = { tpmPtu: 3, price: 2, sku: 2, region: 2, version: 3, previewGa: 2 };
const PATH_BOOST = { 'cost-optimization': 5, platforms: 5 };
/** Skill name from a `skills/<skill>/references/...` path, else ''. */
export function skillOf(relpath) {
const m = /^skills\/([^/]+)\/references\//.exec(relpath);
return m ? m[1] : '';
}
/** Immediate folder under `references/`, else '' (file sits directly in references/). */
export function topFolder(relpath) {
const m = /^skills\/[^/]+\/references\/([^/]+)\//.exec(relpath);
return m ? m[1] : '';
}
/** Count volatility-signal hits in text; weighted sum = ranking score. */
export function scoreVolatility(text) {
const signals = {};
let score = 0;
for (const [key, pat] of Object.entries(SIGNAL_PATTERNS)) {
const matches = text.match(pat);
const n = matches ? matches.length : 0;
signals[key] = n;
score += n * SIGNAL_WEIGHTS[key];
}
return { score, signals };
}
/** Path-based boost for volatility-dense folders (cost-optimization, platforms). */
export function pathBoost(relpath) {
return PATH_BOOST[topFolder(relpath)] || 0;
}
/** Total ranking score for a file = content volatility + path boost. */
export function fileScore({ relpath, text }) {
return scoreVolatility(text).score + pathBoost(relpath);
}
/**
* Apportion `total` selections across skills: a floor per skill (balance) plus
* the remainder distributed by volatility mass (oversample dense skills), using
* largest-remainder apportionment. Deterministic — ties broken by skill name.
*/
export function allocateQuota(massBySkill, total, floorPerSkill) {
const skills = Object.keys(massBySkill).sort();
const quotas = {};
for (const s of skills) quotas[s] = floorPerSkill;
let remaining = total - floorPerSkill * skills.length;
if (remaining <= 0) return quotas;
const totalMass = skills.reduce((sum, s) => sum + massBySkill[s], 0);
if (totalMass <= 0) {
// no mass signal — distribute round-robin deterministically by skill name
let i = 0;
while (remaining > 0) {
quotas[skills[i % skills.length]] += 1;
remaining--;
i++;
}
return quotas;
}
const shares = skills.map((s) => {
const exact = (massBySkill[s] / totalMass) * remaining;
const base = Math.floor(exact);
return { s, base, frac: exact - base };
});
for (const sh of shares) {
quotas[sh.s] += sh.base;
remaining -= sh.base;
}
shares.sort((a, b) => b.frac - a.frac || (a.s < b.s ? -1 : 1));
for (let i = 0; i < shares.length && remaining > 0; i++) {
quotas[shares[i].s] += 1;
remaining--;
}
return quotas;
}
const byScoreThenPath = (a, b) =>
b.score - a.score || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
/**
* Split scored files into a volatile stratum (~volatileTarget, balanced across
* skills, oversampling dense ones) and a control stratum (low-volatility files
* from controlFolders). Deterministic.
*/
export function stratify(scored, cfg) {
const controlSet = new Set();
const control = scored
.filter((f) => cfg.controlFolders.includes(f.topFolder) && f.score <= cfg.controlMaxScore)
.sort((a, b) => a.score - b.score || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))
.slice(0, cfg.controlTarget)
.map((f) => {
controlSet.add(f.file);
return { ...f, stratum: 'control' };
});
const pool = scored.filter((f) => !controlSet.has(f.file) && f.skill);
const bySkill = {};
const massBySkill = {};
for (const f of pool) {
(bySkill[f.skill] ||= []).push(f);
massBySkill[f.skill] = (massBySkill[f.skill] || 0) + f.score;
}
const quotas = allocateQuota(massBySkill, cfg.volatileTarget, cfg.floorPerSkill);
const volatile = [];
for (const skill of Object.keys(bySkill).sort()) {
for (const f of bySkill[skill].sort(byScoreThenPath).slice(0, quotas[skill] || 0)) {
volatile.push({ ...f, stratum: 'volatile' });
}
}
volatile.sort(byScoreThenPath);
return { volatile, control };
}
// --- CLI / frame assembly ----------------------------------------------------
const CFG = {
volatileTarget: 45,
floorPerSkill: 5,
// Control = stable-claim folders (the real analogues of the contract's
// "methodology/regulatory"): responsible-AI principles, Norwegian public-sector
// governance/law, and architecture methodology. Filtered to low volatility score
// so the control stratum measures the stable-claim sanity rate, not volatile churn.
controlFolders: ['responsible-ai', 'norwegian-public-sector-governance', 'architecture'],
controlMaxScore: 4,
controlTarget: 10,
};
/** Invert url-registry → Map(reference_file -> sorted unique cited URLs). */
function invertRegistry() {
const reg = JSON.parse(readFileSync(REGISTRY, 'utf8'));
const inv = new Map();
for (const [url, meta] of Object.entries(reg.urls || {})) {
for (const rf of meta.reference_files || []) {
if (!inv.has(rf)) inv.set(rf, new Set());
inv.get(rf).add(url);
}
}
return inv;
}
function scoreSourcedFiles() {
const inv = invertRegistry();
const scored = [];
for (const [relpath, urlSet] of inv) {
const abs = join(PLUGIN_ROOT, relpath);
if (!existsSync(abs)) continue; // sourced file since deleted — skip
const text = readFileSync(abs, 'utf8');
const { score, signals } = scoreVolatility(text);
scored.push({
file: relpath,
skill: skillOf(relpath),
topFolder: topFolder(relpath),
score: score + pathBoost(relpath),
signals,
citedUrls: [...urlSet].sort(),
});
}
return scored;
}
function buildFrame() {
const scored = scoreSourcedFiles();
const { volatile, control } = stratify(scored, CFG);
const stamp = new Date().toISOString();
return {
_meta: {
created: stamp,
method:
'volatility-weighted stratified selection; deterministic (no random). ' +
'Scorer is a ranking heuristic for sample selection, not the correctness classifier.',
population: scored.length,
volatile_count: volatile.length,
control_count: control.length,
config: CFG,
signal_weights: SIGNAL_WEIGHTS,
},
volatile,
control,
};
}
function summarize(frame) {
const perSkill = {};
for (const f of [...frame.volatile, ...frame.control]) {
const k = `${f.skill}/${f.stratum}`;
perSkill[k] = (perSkill[k] || 0) + 1;
}
console.log(`Sourced population: ${frame._meta.population} files`);
console.log(`Volatile stratum: ${frame.volatile.length}`);
console.log(`Control stratum: ${frame.control.length}`);
console.log('\nPer skill / stratum:');
for (const k of Object.keys(perSkill).sort()) console.log(` ${k.padEnd(34)} ${perSkill[k]}`);
console.log('\nTop 10 volatile (score · file):');
for (const f of frame.volatile.slice(0, 10)) {
console.log(` ${String(f.score).padStart(4)} ${f.file}`);
}
}
function main() {
const args = process.argv.slice(2);
const frame = buildFrame();
if (args.includes('--json')) {
process.stdout.write(JSON.stringify(frame, null, 2) + '\n');
return;
}
summarize(frame);
if (args.includes('--write')) {
atomicWriteJson(OUT_FILE, frame);
console.log(`\nWrote ${OUT_FILE}`);
} else {
console.log('\n(dry run — pass --write to persist data/fase0-sample-frame.json)');
}
}
if (import.meta.url === `file://${process.argv[1]}`) main();