feat(ms-ai-architect): gjør reduksjonsenheten til dokumentet, ikke feltet [skip-docs]

Linje-cappen på 25 var ikke en reduksjon, men en seleksjon etter fast
rekkefølge. Målt 2026-08-26 (bake-off arm A2b, flatt org/, 55 korpusdokumenter):
61 filer og 183 felt inn, 24 felt ut — alle fra 8 filer — mens 1 013 av de 4 000
verditegnene sto ubrukt. 53 filer var usynlige, og usynligheten var stille:
brukeren tror pluginen er onboardet mens rådet er generisk.

Sammendraget bygges ambient ved sesjonsstart, FØR det finnes et spørsmål, så det
finnes ingen relevans å rangere mot. Når man ikke kan rangere, er det ærlige
alternativet å dekke:

- linjebudsjettet utledes fra filantallet (én linje per fil) i stedet for å være
  konstant, begrenset av MIN_COVERAGE_VALUE_LEN = 60 tegn per linje — forankret
  i den designede onboardingens egne feltlengder (median 34, snitt 75, maks 278)
- seleksjonen er bredde-først på tvers av filer: hver fil gir fra seg sitt første
  felt før noen fil gir fra seg sitt andre. Emisjonsrekkefølgen er fortsatt
  filrekkefølgen, så leserekkefølgen er uendret
- overflow-markøren oppgir hvor mange FILER som er utelatt, ikke bare felt

Målt mot bake-off-harnesset (forhåndsregistrert gullsett, 40 spørsmål x 4
frikontekst-lengder), kontrollmålt mot git stash:

  n=55   A2b 10/160 -> 160/160   (kostnad 4 443 -> 4 923 tegn)
  n=25   A2b 10/160 ->  68/160
  n=10   A2b 10/160 ->  24/160

A2b treffer nå last-alt-taket (identisk på alle fire korpusstørrelser) til 4 923
tegn mot 33 127. Ved den designede onboardingen (6 filer, 19 felt) er hookens
output byte-identisk med før — kostnaden øker kun der den gamle koden skjulte
dokumenter. Lesesiden verifisert ende-til-ende: hooken emitterer 62 linjer mot
25, med markøren "0 av 61 filer helt utelatt".

Reach-metrikken sier at dokumentet er representert, ikke at svaret er riktig;
hver fil bæres av ~65 tegn av sitt første felt. Den harde faktum-metrikken er
ugyldig — dens egen kjent-positiv (last-alt) treffer bare 4/40.

Tester: 7 nye i test-user-data.test.mjs (3 røde mot HEAD, verifisert med git
stash). Sporet suite 1070/1070.

[skip-docs]: intern reduksjonsmekanikk. Ingen kommando, agent, skill eller hook
endrer grensesnitt; README/CLAUDE.md beskriver ingen av disse konstantene.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-26 14:49:40 +02:00
commit 31d2b62aed
2 changed files with 206 additions and 18 deletions

View file

@ -375,3 +375,86 @@ test('buildOrgSummary — overflow without free-context is unchanged (back-compa
assert.equal(lines[8], 'Felt8: verdi8');
assert.match(lines[9], /flere felt/);
});
// ===========================================================================
// (E) Reduksjon ved SKALA — the unit of reduction is the DOCUMENT, not the
// field. Measured 2026-08-26 (bake-off arm A2b, flat org/, n=55 corpus
// docs): 61 files and 183 fields went in, 24 fields came out, and those 24
// came from 8 files — 53 files were invisible while 1013 of the 4000 value
// chars stood unspent. What remained was selection by FIXED ORDER, which is
// relevance-blind, and the summary is built ambiently at session start —
// before any question exists — so there is no relevance signal to rank
// against. The honest substitute for ranking is COVERAGE.
// ===========================================================================
test('buildOrgSummary — every file contributes at least one field when the budget allows it', () => {
const all = {};
for (let f = 0; f < 40; f++) {
all[`fil${String(f).padStart(2, '0')}.md`] = Array.from(
{ length: 3 },
(_, i) => `## F${f}H${i}\nverdi fra fil ${f} felt ${i}`,
).join('\n\n');
}
const s = buildOrgSummary(all);
const missing = [];
for (let f = 0; f < 40; f++) {
if (!new RegExp(`^F${f}H\\d: `, 'm').test(s)) missing.push(f);
}
assert.deepEqual(missing, [], `every file must reach the summary; ${missing.length}/40 did not`);
});
test('buildOrgSummary — selection is breadth-first across files, not fixed order within one', () => {
const all = {
'organization-profile.md': '## A0\na0\n\n## A1\na1\n\n## A2\na2',
'technology-stack.md': '## B0\nb0\n\n## B1\nb1\n\n## B2\nb2',
'security-compliance.md': '## C0\nc0\n\n## C1\nc1\n\n## C2\nc2',
};
// cap 4 => 3 field lines + marker. Fixed order would spend all three on the
// first file; coverage spends one on each.
const lines = buildOrgSummary(all, { cap: 4 }).split('\n');
assert.deepEqual(lines.slice(0, 3), ['A0: a0', 'B0: b0', 'C0: c0']);
assert.match(lines[3], /flere felt/);
});
test('buildOrgSummary — the overflow marker reports how many FILES were cut, not only fields', () => {
const all = {};
for (let f = 0; f < 200; f++) all[`fil${String(f).padStart(3, '0')}.md`] = `## H${f}\nverdi ${f}`;
const s = buildOrgSummary(all);
const marker = s.split('\n').at(-1);
assert.match(marker, /flere felt/, 'field residue still reported');
assert.match(marker, /\d+ filer/, 'file residue must be named — a silent drop is the defect');
});
test('buildOrgSummary — coverage yields to the char budget, never starving lines to fragments', () => {
const all = {};
for (let f = 0; f < 200; f++) all[`fil${String(f).padStart(3, '0')}.md`] = `## H${f}\n${'x'.repeat(400)}`;
const s = buildOrgSummary(all);
const values = s.split('\n').filter((l) => /^H\d+: /.test(l)).map((l) => l.slice(l.indexOf(': ') + 2));
assert.ok(values.length > 0, 'fields emitted');
for (const v of values) {
assert.ok(v.length >= 60, `a coverage line must stay readable, got ${v.length}`);
}
assert.ok(
values.reduce((a, v) => a + v.length, 0) <= 4000,
'the total value budget still binds at scale',
);
});
test('buildOrgSummary — the designed onboarding size is untouched by the coverage rule', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\nverdi${i}`).join('\n\n');
}
const lines = buildOrgSummary(all).split('\n');
assert.equal(lines.length, 20, '20 fields over 5 files fit; no marker, no reordering');
assert.equal(lines[0], 'organization-profile.md-Felt0: verdi0');
assert.equal(lines[3], 'organization-profile.md-Felt3: verdi3');
});
test('buildOrgSummary — Fri kontekst still survives the cut at scale', () => {
const all = {};
for (let f = 0; f < 40; f++) all[`fil${String(f).padStart(2, '0')}.md`] = `## H${f}\nverdi ${f}`;
all['free-context.md'] = 'Den ene sloten der brukeren skriver fritt.';
const s = buildOrgSummary(all, { cap: 10 });
assert.match(s, /Fri kontekst: Den ene sloten/, 'free-context must not be the first field dropped');
});