feat(okr): sandbagging-vakt i alle tre rapportformene

This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 06:57:53 +02:00
commit 47e7ac9e2a
3 changed files with 180 additions and 31 deletions

View file

@ -97,6 +97,62 @@ function aspirationalSnitt(okrer) {
return `**Snitt aspirational: ${snitt.toFixed(2)}** over ${scorer.length} KR${hale}.`;
}
// --- Sandbagging-vakten (steg 13): felles for alle tre rapportformene ---
//
// Regelen: en aspirational-OKR med lav score presenteres ALDRI som avvik
// (okr-offentlig-governance.md:148-156). Den er kodet ETT sted og brukes av alle
// formene, fordi en regel som maa gjentas per form er en regel som glipper i den
// fjerde.
//
// Begrunnelsen er kausal, ikke normativ (okr-framework.md:158-161, Bogsnes):
// faar aspirational-scoren konsekvenser i rapporteringen, er target og forecast
// re-bundlet -- og da kommer sandbaggingen tilbake av strukturell noedvendighet,
// uansett hvor disiplinert den som setter maalet er. Vakten beskytter altsaa
// ikke aspirational-KR-et; den beskytter maalsettingen i neste syklus.
// Avvik er committed-only. Predikatet er det samme som Avvik-kolonnen bruker --
// via beregnScore, aldri naa >= target -- saa listen og kolonnen ikke kan gi
// ulikt svar om samme KR.
function avvikListe(okrer) {
const rader = [];
for (const okr of okrer) {
for (const kr of okr.krer) {
if (kr.type !== 'committed' || formatAvvik(kr, true) !== 'Ja') continue;
rader.push(
`- ${okr.tittel}: ${kr.navn} -- naa ${kr.naa} mot krav ${kr.target} `
+ `(score ${formatScore(beregnScore(kr))})`,
);
}
}
return rader;
}
// Overskriften varierer med mottakeren; filteret gjoer det ikke.
function avvikSeksjon(okrer, overskrift) {
const rader = avvikListe(okrer);
return [
`## ${overskrift}`,
'',
'Kun committed KR staar her: et committed krav er naadd eller ikke. Aspirational',
'KR er holdt utenfor med vilje -- se forventningen til aspirational under.',
'',
...(rader.length > 0 ? rader : ['Ingen committed KR ligger under kravet i denne syklusen.']),
'',
];
}
// Forventningsteksten hoerer i rapporten, ikke bare i koden: mottakeren i
// departementet leser ikke 0.7 som suksess med mindre det staar eksplisitt
// (governance-regel 1).
const ASPIRATIONAL_RAMME = [
'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes',
'paa snittet paa tvers av alle aspirational-OKR -- ikke paa ett enkelt KR.',
'En lav score er derfor maaloppnaaelse, ikke svikt, og et aspirational KR staar',
'aldri i avviks-seksjonen. Fikk lav aspirational-score konsekvenser i',
'rapporteringen, ville maal og prognose vaert bundlet sammen igjen -- og',
'sandbagging fulgt strukturelt, ikke som et disiplinproblem.',
];
// Felles inngangsvakt for alle rapportformene. En form som stille rapporterte
// en tom syklus ville produsert et styringsdokument uten innhold, som ser
// komplett ut.
@ -167,13 +223,14 @@ export function tertialrapport(syklus, opts = {}) {
);
}
ut.push(...avvikSeksjon(syklus.okrer, 'Avvik som skal forklares'));
if (aspirational.antallKr > 0) {
ut.push(
'## Aspirational Key Results',
'',
// Governance-invariant 1: typen merkes, slik at 0.7 ikke leses som svikt.
'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes',
'paa snittet paa tvers av alle aspirational-OKR -- ikke paa ett enkelt KR.',
...ASPIRATIONAL_RAMME,
'',
...aspirational.linjer,
);
@ -276,6 +333,8 @@ export function arsrapportDelIII(syklus, opts = {}) {
ut.push('### Vurdering av maaloppnaaelse', '', ...VURDERINGSFELT, '');
}
ut.push(...avvikSeksjon(syklus.okrer, 'Avvik som skal forklares'));
// Snittet hoerer PAA TVERS av Objectives (okr-framework.md:557) og staar derfor
// ikke i noen enkelt Objective-seksjon. Committed har bevisst ingen motpart:
// et snitt av binaere krav er ikke en stoerrelse.
@ -284,8 +343,7 @@ export function arsrapportDelIII(syklus, opts = {}) {
ut.push(
'## Aspirational maaloppnaaelse paa tvers av Objectives',
'',
'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes',
'paa snittet -- ikke paa ett enkelt KR.',
...ASPIRATIONAL_RAMME,
'',
snitt,
'',
@ -309,23 +367,6 @@ export function arsrapportDelIII(syklus, opts = {}) {
// og form fastsettes i departementets hovedinstruks. Et underlag som foregrep
// kadensen ville gjeninnfoert den samme uverifiserte paastanden i maskinform.
// Avvik er committed-only. Predikatet er det samme som Avvik-kolonnen bruker --
// via beregnScore, aldri naa >= target -- saa listen og kolonnen ikke kan gi
// ulikt svar om samme KR.
function avvikListe(okrer) {
const rader = [];
for (const okr of okrer) {
for (const kr of okr.krer) {
if (kr.type !== 'committed' || formatAvvik(kr, true) !== 'Ja') continue;
rader.push(
`- ${okr.tittel}: ${kr.navn} -- naa ${kr.naa} mot krav ${kr.target} `
+ `(score ${formatScore(beregnScore(kr))})`,
);
}
}
return rader;
}
/**
* Bygger underlaget til et etatsstyringsmoete for en syklus lest av lesSyklus().
*
@ -366,15 +407,11 @@ export function etatsstyringsunderlag(syklus, opts = {}) {
}
}
const avvik = avvikListe(syklus.okrer);
ut.push(
'## Avvik som krever departementets oppmerksomhet',
'',
...(avvik.length > 0
? avvik
: ['Ingen committed KR ligger under kravet i denne syklusen.']),
'',
);
ut.push(...avvikSeksjon(syklus.okrer, 'Avvik som krever departementets oppmerksomhet'));
// Forventningen staar rett under avvikslisten, der spoersmaalet «hvorfor er
// ikke DETTE et avvik?» faktisk oppstaar hos mottakeren.
ut.push('## Aspirational Key Results - forventning', '', ...ASPIRATIONAL_RAMME, '');
// Hjemmelen for at tildelingsbrevet SKAL inneholde styringsparametere er
// bestemmelser om oekonomistyring i staten («bestemmelsene») punkt 1.5,

View file

@ -155,6 +155,25 @@ Tre regler:
skalaen.** En leser som tror 0.7 betyr «70 % av målet» trekker feil konklusjon om
både ambisjonsnivå og resultat.
#### Hvorfor regelen er strukturell, ikke bare god skikk
De tre reglene kan leses som hensynsfull formidling. Det er de ikke. Den kausale
begrunnelsen ligger i target/forecast-separasjonen — se `okr-framework.md`,
«Beyond Budgeting og target/forecast-separasjonen», som er kanonisk for mekanismen.
Kort: **får aspirational-scoren konsekvenser i rapporteringen, er målet og prognosen
re-bundlet til ett tall.** Da har den som setter målet neste syklus en egeninteresse i
å sette det lavt — og sandbagging følger av systemet, ikke av manglende disiplin hos
den enkelte. En avviksliste som tar med aspirational-KR er derfor ikke først og fremst
urimelig mot årets team; den svekker neste års målsetting.
Det er også svaret til en revisor som spør hvorfor et aspirational-KR med lav score
ikke står oppført som avvik: fordi det aldri var et krav, og fordi å behandle det som
ett ville gjort neste års mål mindre ambisiøse. Merk at dette *ikke* er et argument
for å slippe å forklare lav score — aspirational-resultater skal analyseres og læres
av (se del III over). Det er et argument for at analysen hører hjemme et annet sted
enn i avvikslisten.
---
## Gevinstrealisering

View file

@ -203,7 +203,7 @@ function seksjonsKropp(md, overskrift) {
test('(8a) del III bygger paa aarsrapportens eget anker og en seksjon per Objective', () => {
const md = delIII();
assert.match(md, /[Aa]arets aktiviteter og resultater/, 'del III-ankeret mangler');
const objectives = md.split('\n').filter((l) => /^##\s+(?!Flerarig|Aspirational)/.test(l));
const objectives = md.split('\n').filter((l) => /^##\s+(?!Flerarig|Aspirational|Avvik)/.test(l));
assert.equal(objectives.length, 2, `forventet 2 Objective-seksjoner, fant ${objectives.length}`);
assert.match(md, /Trafikksikkerhet og tunnelsikkerhet/);
assert.match(md, /Digitalisering av tjenester/);
@ -302,6 +302,99 @@ test('(9d) etatsstyringsunderlag kaster ved tom eller ufullstendig syklus', () =
assert.throws(() => etatsstyringsunderlag(undefined), /syklus/i);
});
// --- (10) Sandbagging-vakten (steg 13) ---
//
// Briefens navngitte suksesskriterium: en aspirational-OKR med lav score
// presenteres ALDRI som avvik. Regelen staar i okr-offentlig-governance.md:148-156;
// her far den mekanisk handhevelse i alle tre rapportformene.
//
// Vakten er bygget i tre ledd, og rekkefolgen er ikke tilfeldig:
// (10a) POSITIV -- et committed KR under kravet STAAR i avviks-seksjonen.
// Uten dette leddet ville (10b) bestaatt trivielt paa en
// seksjon som ikke finnes eller er tom, og vakten vaert prosa.
// (10b) NEGATIV -- fixturens aspirational-KR med lav score staar der ikke.
// (10c) MUTASJON -- samme KR, flyttet til committed, DUKKER OPP. Det beviser at
// filteret henger paa TYPEN og ikke paa navnet, og at
// seksjonslokatoren i (10b) faktisk fant noe aa lete i.
//
// De tre formene har strukturelt ulike avviks-regioner, saa lokatoren ankrer paa
// overskriftsformen (`## Avvik...`), som er felles.
function avvikKropp(md) {
const overskrift = md.split('\n').find((l) => /^##\s+Avvik/.test(l));
assert.ok(overskrift, 'rapportformen har ingen avviks-seksjon aa filtrere');
const kropp = seksjonsKropp(md, overskrift.trim());
assert.ok(kropp && kropp.length > 0, 'avviks-seksjonen er tom');
return kropp;
}
// Fixturens KR-er, med de rollene steg 7 ga dem.
const COMMITTED_UNDER_KRAV = 'Tunneler med oppgradert sikkerhetsutrustning';
const ASPIRATIONAL_LAV_SCORE = ['Andel digitale soeknader', 'Gjennomsnittlig saksbehandlingstid'];
for (const [navn, bygger] of FORMER) {
test(`(10a/${navn}) committed KR under kravet STAAR i avviks-seksjonen`, () => {
const kropp = avvikKropp(byggFor(bygger));
assert.match(
kropp,
new RegExp(COMMITTED_UNDER_KRAV),
`committed KR under kravet mangler i avviks-seksjonen:\n${kropp}`,
);
});
test(`(10b/${navn}) aspirational KR med lav score staar IKKE i avviks-seksjonen`, () => {
const kropp = avvikKropp(byggFor(bygger));
for (const kr of ASPIRATIONAL_LAV_SCORE) {
assert.doesNotMatch(
kropp,
new RegExp(kr),
`aspirational KR presentert som avvik -- sandbagging-vakten er brutt:\n${kropp}`,
);
}
});
test(`(10c/${navn}) mutasjon: samme KR som committed ER et avvik`, () => {
const syklus = lesSyklus(SYKLUS_DIR);
const mutert = {
...syklus,
okrer: syklus.okrer.map((okr) => ({
...okr,
krer: okr.krer.map((kr) => ({ ...kr, type: 'committed' })),
})),
};
const kropp = avvikKropp(bygger(mutert, { naa: NAA }));
for (const kr of ASPIRATIONAL_LAV_SCORE) {
assert.match(
kropp,
new RegExp(kr),
`filteret henger ikke paa typen -- KR-et uteble ogsaa som committed:\n${kropp}`,
);
}
});
// Uten den kausale begrunnelsen er vakten en norm mottakeren kan overproeve.
// Bogsnes' poeng (okr-framework.md:158-161) er at re-bundling av
// aspirational-scoren i rapporteringen henter sandbaggingen tilbake
// strukturelt -- det argumentet holder for en Riksrevisjon-leser.
test(`(10d/${navn}) rapporten baerer forventningen til aspirational, ikke bare tallet`, () => {
const md = byggFor(bygger);
assert.match(md, /0\.7/, 'forventningen om ~0.7 maa staa eksplisitt');
assert.match(md, /sandbagging/i, 'begrunnelsen for at lav aspirational-score ikke er avvik mangler');
});
}
// Generatoren handhever regelen; governance-fila baerer begrunnelsen. Staar
// begrunnelsen bare i koden, kan en leser overproeve vakten uten aa moete
// argumentet -- og det er nettopp en Riksrevisjon-vendt leser som vil sporre.
test('(10e) governance-fila begrunner regelen kausalt, ikke bare normativt', () => {
const doc = readFileSync(
join(ROOT, 'skills/okr-offentlig-sektor/references/okr-offentlig-governance.md'),
'utf8',
);
assert.match(doc, /re-bundl/i, 'den kausale mekanismen (re-bundling) mangler');
assert.match(doc, /okr-framework\.md/, 'begrunnelsen maa peke til kanon, ikke duplisere den');
});
// --- (2d) Avvik-kolonnen maa vaere retningsagnostisk ---
//
// `naa >= target` ser riktig ut for oppadgaaende maal og er FEIL for