feat(okr): syklus-leser med kanonisk scoreberegning
This commit is contained in:
parent
e767922154
commit
1592a73267
2 changed files with 236 additions and 0 deletions
142
lib/syklus-data.mjs
Normal file
142
lib/syklus-data.mjs
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue