feat(okr): syklus-leser med kanonisk scoreberegning

This commit is contained in:
Kjell Tore Guttormsen 2026-08-01 22:35:27 +02:00
commit 1592a73267
2 changed files with 236 additions and 0 deletions

142
lib/syklus-data.mjs Normal file
View file

@ -0,0 +1,142 @@
// syklus-data.mjs
// D5 steg 8: leser en OKR-syklus fra disk og beregner KR-score kanonisk.
// REN modul -- ingen shebang, ingen isMain-CLI (lib/-siden av splitten;
// orkestratoren bor i scripts/syklus-rapport.mjs). Zero npm dependencies.
//
// Datamodellen er beslutning B-1: flate `krN_`-noekler i okr-*.md sin frontmatter.
// Noekkelen `krN_type` kolliderer ikke med OKF-noekkelen `type`, fordi
// lib/frontmatter.mjs:29 ankrer paa `^\s*type:` -- verifisert kjoert mot data,
// ikke bare lest (tests/syklus-data.test.mjs A4).
//
// Beslutning B-2: score BEREGNES, lagres aldri. En lagret score er en andre
// sannhetskilde som kan drifte fra tallene den ble utledet av.
//
// Disiplin: modulen KASTER heller enn aa falle tilbake paa en stille default
// (moenster: lib/innboks-frontmatter.mjs:64-72). En manglende target som stille
// ble 0 ville produsert en score som ser gyldig ut, og et styringsdokument som
// lyver med to desimalers presisjon er verre enn et som feiler.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
import { parseFrontmatter } from './frontmatter.mjs';
// De fem feltene KR-datakontrakten krever. Alle maa vaere til stede per KR n --
// et delvis utfylt KR er en feil, ikke et KR med hull.
const KR_FELT = ['navn', 'baseline', 'target', 'naa', 'type'];
const KR_TALLFELT = ['baseline', 'target', 'naa'];
const KR_TYPER = ['committed', 'aspirational'];
function somTall(verdi, felt, hvor) {
const n = Number(verdi);
if (verdi === null || verdi === undefined || String(verdi).trim() === '' || Number.isNaN(n)) {
throw new Error(`${hvor}: ${felt} maa vaere et rent tall uten enhet (fikk: ${verdi})`);
}
return n;
}
/**
* Kanonisk KR-score: (naa - baseline) / (target - baseline), kappet til [0, 1.0].
*
* Tre kanter, alle fra okr-framework.md:319 / okr-calculator.md:7-24:
* - target === baseline -> undefined (IKKE 0). Forholdet er udefinert, ikke null
* fremgang; forskjellen er den mellom et KR som feilet og et som ikke kan
* scores som ratio.
* - nedadgaaende maal trenger ingen saertilfelle: teller og nevner blir begge
* negative, saa fortegnet gaar opp av seg selv.
* - binaert KR (baseline 0, target 1) faller ut av samme formel som 0 eller 1.
*
* @returns {number|undefined} score i [0, 1.0], eller undefined naar udefinert.
*/
export function beregnScore(kr) {
if (!kr || typeof kr !== 'object') {
throw new Error(`beregnScore: forventet et KR-objekt (fikk: ${kr})`);
}
const baseline = somTall(kr.baseline, 'baseline', 'beregnScore');
const target = somTall(kr.target, 'target', 'beregnScore');
const naa = somTall(kr.naa, 'naa', 'beregnScore');
if (target === baseline) return undefined;
const raa = (naa - baseline) / (target - baseline);
return Math.min(1, Math.max(0, raa));
}
// Samler `krN_*`-noeklene i frontmatteren til en sortert KR-liste. Nummereringen
// leses fra dataene (ikke antatt 1..n), saa et hull i nummerserien blir synlig
// som et manglende KR i stedet for aa forskyve alle KR-ene etter det.
function lesKrer(fm, hvor) {
const numre = new Set();
for (const linje of (fm.raw ?? '').split('\n')) {
const m = /^\s*kr(\d+)_[a-z]+\s*:/.exec(linje);
if (m) numre.add(Number(m[1]));
}
const krer = [];
for (const n of [...numre].sort((a, b) => a - b)) {
const raa = {};
for (const felt of KR_FELT) {
const verdi = fm.get(`kr${n}_${felt}`);
if (verdi === null) {
throw new Error(
`${hvor}: kr${n} mangler ${felt}. KR-datakontrakten krever alle fem feltene ` +
`(${KR_FELT.join(', ')}) -- se /okr:skriv.`,
);
}
raa[felt] = verdi;
}
const type = String(raa.type).toLowerCase();
if (!KR_TYPER.includes(type)) {
throw new Error(
`${hvor}: kr${n}_type maa vaere ${KR_TYPER.join(' eller ')} (fikk: ${raa.type})`,
);
}
const kr = { n, navn: raa.navn, type };
for (const felt of KR_TALLFELT) {
kr[felt] = somTall(raa[felt], `kr${n}_${felt}`, hvor);
}
krer.push({ n, navn: kr.navn, baseline: kr.baseline, target: kr.target, naa: kr.naa, type });
}
return krer;
}
/**
* Leser alle `okr-*.md` i en syklus-katalog til en normalisert struktur.
*
* Filrekkefolgen er sortert filnavn og er DEL AV KONTRAKTEN: rapportgeneratoren
* arver den, saa to kjoeringer over samme katalog gir samme dokument.
*
* @param {string} syklusDir katalog som inneholder okr-*.md
* @returns {{id: string, okrer: Array<{fil: string, tittel: string, krer: Array}>}}
*/
export function lesSyklus(syklusDir) {
let stat;
try {
stat = statSync(syklusDir);
} catch {
throw new Error(`lesSyklus: syklus-katalogen finnes ikke: ${syklusDir}`);
}
if (!stat.isDirectory()) {
throw new Error(`lesSyklus: syklus-katalogen finnes ikke som katalog: ${syklusDir}`);
}
const filer = readdirSync(syklusDir)
.filter((n) => n.startsWith('okr-') && n.endsWith('.md'))
.sort();
if (filer.length === 0) {
throw new Error(`lesSyklus: fant ingen okr-*.md i ${syklusDir}`);
}
const okrer = filer.map((fil) => {
const fm = parseFrontmatter(readFileSync(join(syklusDir, fil), 'utf8'));
return {
fil,
tittel: fm.get('title') ?? fil.replace(/\.md$/, ''),
krer: lesKrer(fm, fil),
};
});
return { id: basename(syklusDir), okrer };
}

View file

@ -72,3 +72,97 @@ test('(A4) steg 7: krN_type forstyrrer ikke OKF-noekkelen type', async () => {
assert.equal(fm.get('type'), 'OKR', `${f}: OKF-type forskjovet av krN_type`);
}
});
// --- (B) lib/syklus-data.mjs: leser + scoreberegning (steg 8) ---
const { lesSyklus, beregnScore } = await import('../lib/syklus-data.mjs');
const SYKLUS_DIR = join(ROOT, SYKLUS);
test('(B1) lesSyklus leser begge OKR-filene med KR-ene sine', () => {
const syklus = lesSyklus(SYKLUS_DIR);
assert.equal(syklus.id, 'T1-2026');
assert.equal(syklus.okrer.length, 2, 'forventet begge okr-*.md');
// Deterministisk rekkefolge (sortert filnavn) -- rapportgeneratoren i steg 9
// arver denne rekkefolgen, saa den er del av kontrakten, ikke en tilfeldighet.
assert.deepEqual(
syklus.okrer.map((o) => o.fil),
['okr-digitalisering.md', 'okr-trafikksikkerhet.md'],
);
const digi = syklus.okrer[0];
assert.equal(digi.tittel, 'Digitalisering av tjenester');
assert.equal(digi.krer.length, 2);
assert.deepEqual(digi.krer[0], {
n: 1,
navn: 'Andel digitale soeknader (prosent)',
baseline: 60,
target: 85,
naa: 63,
type: 'aspirational',
});
});
test('(B2) lesSyklus tolker tallene som tall, ikke strenger', () => {
const syklus = lesSyklus(SYKLUS_DIR);
for (const okr of syklus.okrer) {
for (const kr of okr.krer) {
for (const felt of ['baseline', 'target', 'naa']) {
assert.equal(typeof kr[felt], 'number', `${okr.fil} kr${kr.n}.${felt} er ikke tall`);
}
}
}
});
test('(B3) beregnScore: kanonisk formel paa et kjent KR-par', () => {
// Tunneler oppgradert: (17 - 12) / (20 - 12) = 0.625
assert.equal(beregnScore({ baseline: 12, target: 20, naa: 17 }), 0.625);
});
test('(B4) beregnScore: kapper til [0, 1.0]', () => {
assert.equal(beregnScore({ baseline: 0, target: 10, naa: 25 }), 1.0, 'over target -> 1.0');
assert.equal(beregnScore({ baseline: 10, target: 20, naa: 4 }), 0, 'under baseline -> 0');
});
// B-2, den kanoniske kanten (okr-framework.md:319). En score paa 0 ville lest som
// "ingen fremgang" naar sannheten er "forholdet er ikke definert" -- forskjellen
// mellom et KR som feilet og et KR som ikke kan scores som ratio.
test('(B5) beregnScore: target == baseline gir undefined, ikke 0', () => {
const score = beregnScore({ baseline: 0, target: 0, naa: 0 });
assert.equal(score, undefined);
assert.notEqual(score, 0, 'udefinert score maa ikke kollapse til 0');
});
test('(B6) beregnScore: nedadgaaende maal gir positiv score uten saertilfelle', () => {
// Saksbehandlingstid 14 -> 5 dager, naa 11: (11 - 14) / (5 - 14) = 0.333...
const score = beregnScore({ baseline: 14, target: 5, naa: 11 });
assert.ok(score > 0.33 && score < 0.34, `forventet ~0.333, fikk ${score}`);
});
test('(B7) beregnScore: binaert KR gir 0 eller 1', () => {
assert.equal(beregnScore({ baseline: 0, target: 1, naa: 0 }), 0);
assert.equal(beregnScore({ baseline: 0, target: 1, naa: 1 }), 1);
});
// Kast heller enn stille default (moenster: lib/innboks-frontmatter.mjs:64-72).
// En manglende target som stille ble 0 ville produsert en score som SER gyldig ut.
test('(B8) beregnScore kaster ved manglende eller ikke-numerisk felt', () => {
assert.throws(() => beregnScore({ baseline: 1, naa: 2 }), /target/);
assert.throws(() => beregnScore({ baseline: 1, target: 'x', naa: 2 }), /target/);
assert.throws(() => beregnScore(undefined), /KR/);
});
test('(B9) lesSyklus kaster ved ufullstendig KR i frontmatter', async (t) => {
const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const dir = mkdtempSync(join(tmpdir(), 'okr-syklus-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
writeFileSync(
join(dir, 'okr-ufullstendig.md'),
['---', 'type: OKR', 'title: Ufullstendig', 'kr1_navn: Mangler tall', 'kr1_baseline: 3', '---', '# Ufullstendig', ''].join('\n'),
);
assert.throws(() => lesSyklus(dir), /kr1/i);
});
test('(B10) lesSyklus kaster naar katalogen ikke finnes', () => {
assert.throws(() => lesSyklus(join(ROOT, 'tests/fixtures/finnes-ikke')), /finnes ikke/i);
});