fix(okf): top-level frontmatter title survives a nested sources: title

P15 (order 20260912T220951Z). okf._frontmatter_from_text was linewise
last-write-wins over EVERY line regardless of indentation, so a curated
concept's own top-level `title:` got silently overwritten by the nested
`sources:\n  - title: ...` block's title. Fix: a top-level (unindented)
key always wins over an indented one of the same name; a nested line
with no top-level counterpart is still preserved (SPEC §4).

Red-before/green-after: new test
test_parse_frontmatter_top_level_title_survives_nested_sources_title
(tests/test_okf.py) failed on 45edbf5 (fm["title"] == "N500:2024",
expected the concept's own), green after the fix.

Re-measured on all four vegnormal-okf bases (concept files / distinct
titles): n100-2023 446/446 (was 1) - n200-2024 1133/1133 (was 1) -
n500-2024 270/270 (was 1) - r761-2025 2756/2407 (genuine repeated
process names, not a collapse). directory_listing on krav/N500:
269 documents / 269 distinct titles (was 1).

tests/test_context_sets_loadbearing.py:
- The P14 tripwire test (asserting parse_frontmatter DID collapse
  titles) is INVERTED, not deleted, per the order: it now asserts the
  fix holds, as a live regression guard.
- own_frontmatter() stays (not replaced by parse_frontmatter): measured
  29,500 field reads (type/title/req_number/prosessnr, all four bases)
  agree exactly except for quote-stripping (2,728/29,500, zero value
  mismatches) - own_frontmatter unquotes for fasit comparison,
  parse_frontmatter deliberately doesn't (D1/(a)/(i): unquote_scalar is
  the ONE unquoting rule).

docs/2026-09-12-p14-kontekstsett.md Part B correction: the "22 of 22
cost words absent from n100/n200/n500" claim was false - n500-2024
carries `kroner` as a false positive (substring match inside
"borkroner", drill bits, not money). The original 22-word list was
never persisted, so only ~9 of the 22 survive named. Replaced with a
newly named, persisted 22-word list and the actual re-measured count:
n100 22/22 absent - n200 22/22 - n500 21/22 (kroner via borkroner) -
r761 18/22 (4 genuine cost words). No gate touched (no fasit anchor is
`kroner`).

Verification: full suite 1643 passed / 5 skipped (was 1642/5 on
45edbf5, +1 new test, 0 removed) - `uv run pytest -q`. ruff check +
ruff format --check clean on the three changed source/test files.
Golden transcripts byte-unchanged: shasum -a 1
tests/golden/demo-transcript.stdout = ea8c534773acdbe41ae68f2c55724d69aaf8be4f,
demo-transcript.stderr = ede3e2f685ce6a14ad9888e9de421d1a66f6c611.
No version bump, no push (both forbidden by the order).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-13 07:40:57 +02:00
commit f13dc64a0a
4 changed files with 127 additions and 38 deletions

View file

@ -119,17 +119,42 @@ Den døra er ikke stresstestens sti — stresstesten går `--mandate` over den o
### 2.2 Falsifiseringsgaten — og hvorfor den er så skarp her
Hvert sett bærer minst to spørsmål basen **ikke kan besvare**, der riktig svar er å si det.
MÅLT over alle fire basene, 22 prøvde kostnadsord:
**RETTET 2026-09-13 (P15, ordre `20260912T220951Z`).** Den opprinnelige setningen her — «samtlige 22
er FRAVÆRENDE fra n100/n200/n500» — var USANN og er fjernet. Kun 8 av de 22 opprinnelig prøvde
ordene ble noensinne navngitt (listen over, med et avsluttende «…»), og selve 22-ordslisten ble
ALDRI persistert noe sted i repoet — den kan derfor ikke re-måles verbatim. Det er en
nevner-svikt av Verifiseringslovens ansikt 4-type: et «fraværende»-utsagn uten en gjenfinnbar
liste er ikke et mål, det er en påstand om et mål som fant sted.
- **n100-2023, n200-2024, n500-2024: samtlige 22 er FRAVÆRENDE fra hele basen**`enhetspris`,
`kroner`, `budsjett`, `kostnadsestimat`, `prisskjema`, `timepris`, `nåverdi`, `driftskostnad`
- **r761-2025:** 18 av 22 fraværende; basen bærer `kroner`, `budsjett`, `enhetspris` og `hydrogen`,
fordi prosesskoden ER kontraktsspråk.
**Ny, navngitt og persistert 22-ordsliste** (2026-09-13, denne rettelsen — velges for domene-treffsikkerhet, ikke for å oppnå et bestemt utfall), re-målt med gatens egen
substring-regel (`anchors_are_absent`, `tests/test_context_sets_loadbearing.py`) mot alle fire
basene:
Det er et funn i seg selv, og det er hele grunnen til at gaten er verdt å teste: po sitt oppdrag er
å finne **kostnadsbesparelser**, og tre av fire baser inneholder ikke ett eneste pengeord. En
kjøring som svarer med et kronebeløp mot N100 har ikke lest basen — den har funnet på.
`enhetspris`, `kroner`, `budsjett`, `kostnadsestimat`, `prisskjema`, `timepris`, `nåverdi`,
`driftskostnad`, `anleggskostnad`, `materialkostnad`, `arbeidskostnad`, `investeringskostnad`,
`vedlikeholdskostnad`, `kapitalkostnad`, `finansieringskostnad`, `livssykluskostnad`,
`kontraktssum`, `anbudspris`, `tilbudspris`, `merverdiavgift`, `avskrivning`,
`besparelsespotensial`.
**Faktisk telling per base (446/1133/270/2756 konsepter):**
- **n100-2023: 22 av 22 fraværende.**
- **n200-2024: 22 av 22 fraværende.**
- **n500-2024: 21 av 22 fraværende — IKKE 22.** Basen bærer `kroner`, men som en FALSK POSITIV:
ordet forekommer kun inni `borkroner` (boreutstyr, ikke penger) i
`krav/N500/id-41a2f459-e362-42d8-d725-aaee8290c7c1.md` — «(styrestenger, borkroner), nøyaktighet
ved a…». Delstreng-regelen i Rule U treffer bevisst uten ordgrenser (§ 2.3), og dette er den
konkrete prisen for det: en ekte nulltreff-base kan likevel «bære» et pengeord gjennom et
urelatert sammensatt ord. Funnet endrer INGEN gate — ingen fasit-anchor er `kroner` — men det
gjør STATE-påstanden usann og er derfor rettet her.
- **r761-2025: 18 av 22 fraværende** (uendret konklusjon fra tidligere, men re-målt mot den nye
listen): basen bærer `enhetspris`, `kroner`, `budsjett` og `kapitalkostnad` — alle fire er ekte
kostnadsspråk (f.eks. «kapitalkostnader», «mengder i kroner avviker fra summen»), konsistent med
at prosesskoden ER kontraktsspråk.
Konklusjonen står: tre av fire baser er reelt uten kostnadsspråk (n500s ene treff er en
falsk positiv, ikke et pengeord), og det er hele grunnen til at gaten er verdt å teste. En kjøring
som svarer med et kronebeløp mot N100 har ikke lest basen — den har funnet på.
### 2.3 Regelen for «kan ikke besvares» (DEL 3c) — skrevet ned

View file

@ -105,13 +105,34 @@ def _split_frontmatter(text: str) -> tuple[list[str], str, bool]:
def _frontmatter_from_text(text: str) -> dict[str, str]:
"""``parse_frontmatter``'s rule, applied to already-read text: every frontmatter line that
carries a colon becomes one ``key: value`` pair, last write winning. Unterminated blocks are
parsed as if closed the pre-split behaviour, preserved deliberately."""
carries a colon becomes one ``key: value`` pair, last write winning AMONG LINES AT THE SAME
LEVEL. Unterminated blocks are parsed as if closed the pre-split behaviour, preserved
deliberately.
A TOP-LEVEL key (no leading whitespace ``_split_frontmatter`` preserves indentation
verbatim, so this is a real structural signal, not a guess) always wins over an indented one
of the same name: a nested line is a block-sequence/-mapping entry under some OTHER top-level
key (``sources:\\n - title: `` names the SOURCE, not the concept) and must never overwrite
the concept's own field, however late it appears in the scan. Measured 2026-09-13 on
vegnormal-okf concepts: without this, every ``sources:``-bearing file's nested ``title``
replaced its own, collapsing 4605 concepts to 4 distinct titles. A nested line with no
top-level counterpart is still preserved (OKF SPEC §4, "unknown fields are preserved")."""
fm: dict[str, str] = {}
top_level: set[str] = set()
for line in _split_frontmatter(text)[0]:
if not line:
continue
key, sep, val = line.partition(":")
if sep:
fm[key.strip()] = val.strip()
if not sep:
continue
key = key.strip()
if line[0].isspace():
if key in top_level:
continue
fm[key] = val.strip()
else:
fm[key] = val.strip()
top_level.add(key)
return fm

View file

@ -57,22 +57,31 @@ _MIN_ANCHOR_CHARS = 4
def own_frontmatter(path: Path) -> dict[str, str]:
"""The concept's OWN frontmatter: top-level keys only, FIRST occurrence winning.
**``okf.parse_frontmatter`` cannot be used for this, and that is measured rather than assumed.**
It is linewise and last-write-wins by documented design, so a nested block overwrites a
top-level key of the same name. Every vegnormal concept ends its frontmatter with
**P15 (2026-09-13) fixed the finding this helper was written against.** Before P15,
``okf.parse_frontmatter`` was linewise and last-write-wins over EVERY line regardless of
indentation, so a nested block overwrote a top-level key of the same name. Every vegnormal
concept ends its frontmatter with
sources:
- resource: https://
title: N500:2024
and the indented ``title`` therefore replaces the concept's own. MEASURED on n500-2024:
``okf.navigate_bundle`` yields 270 concept files carrying **1 distinct title** (``N500:2024``,
270 times), and ``okf.directory_listing(bundle, path="krav/N500")`` returns 269 documents whose
``title`` is that same string so rung 2 and rung 3 of the navigation ladder tell a navigator
apart only by an opaque UUID filename and a character count. That is a finding about the
PRODUCT (recorded in ``docs/2026-09-12-p14-kontekstsett.md § 5``), not about this test; what it
means here is that a fasit assert against ``parse_frontmatter``'s ``title`` would compare every
concept to the same constant and be VACUOUS.
and the indented ``title`` used to replace the concept's own. MEASURED on n500-2024 before the
fix: ``okf.navigate_bundle`` yielded 270 concept files carrying **1 distinct title**
(``N500:2024``, 270 times). ``okf.parse_frontmatter`` now makes indentation load-bearing
a top-level (unindented) key always wins over a nested one of the same name and re-measured
AFTER the fix, the same base's 269 ``krav/N500`` documents carry **269 distinct titles**.
**This helper still isn't a plain call to ``okf.parse_frontmatter``, and that remains
measured rather than assumed:** ``own_frontmatter`` also strips one layer of enclosing
``'`` quotes (``.strip("'")``) so a value matches the fasit's stored plain-text title
verbatim, while ``okf.parse_frontmatter`` deliberately leaves scalars quoted unquoting is
``okf.unquote_scalar``'s ONE job (D1/(a)/(i)), and a second copy of that rule here would be
the drifting one. Re-measured across all four bases (29 500 field reads: ``type``, ``title``,
``req_number``, ``prosessnr`` on every concept file) the two now agree EXACTLY except for
quoted scalars (2 728 of 29 500 checks every one a quote-stripping difference, none a value
difference), so this helper stays for that one reason, not for the nested-override bug P15
closed.
Uses ``okf._split_frontmatter`` deliberately: it is the module's ONE place ``---`` is compared
(B4), and a second delimiter rule here would be the copy that drifts.
@ -376,14 +385,16 @@ def test_the_fasit_titles_are_distinct_not_the_collapsed_sources_title(set_dir:
"""The fasit's recorded titles must tell the cited concepts APART.
Paired with ``own_frontmatter``'s measurement, this is what keeps arm (b) from being vacuous: if
the recorded titles were ``parse_frontmatter``'s, every one of them would be the base's
``sources`` title and the assert would hold against any concept in the base.
the recorded titles were ``parse_frontmatter``'s PRE-P15 titles, every one of them would be the
base's ``sources`` title and the assert would hold against any concept in the base.
**TRIPWIRE, deliberately.** The second half asserts that ``okf.parse_frontmatter`` DOES still
collapse them. The day that stops being true an okf bump, or a decision to read block
mappings here this arm goes red, and whoever sees it should read this docstring, confirm the
listing now carries real titles, and DELETE this half rather than weaken it.
"""
**Former tripwire, INVERTED 2026-09-13 by P15 (deliberately, per that order not deleted).**
Until P15 the second half asserted that ``okf.parse_frontmatter`` DID still collapse the
titles, as a red flag that would fire the day the production bug was fixed here instead of at
the call site. P15 fixed it AT THE SOURCE (``okf._frontmatter_from_text``: a top-level key now
always wins over a nested one of the same name), so the second half now asserts the opposite
that ``parse_frontmatter`` agrees with the fasit's own distinct titles — as a live regression
guard against the collapse coming back."""
declared = read_bundle_txt(set_dir / "bundle.txt")
base = _require_base(declared)
fasit = json.loads((set_dir / "fasit.json").read_text(encoding="utf-8"))
@ -391,8 +402,8 @@ def test_the_fasit_titles_are_distinct_not_the_collapsed_sources_title(set_dir:
cited = [c for row in fasit["must_cite"] for c in row["concepts"]]
assert len({c["title"] for c in cited}) == len(cited), "recorded titles do not tell them apart"
collapsed = {okf.parse_frontmatter(base / c["path"]).get("title", "") for c in cited}
assert len(collapsed) == 1, (
"okf.parse_frontmatter no longer collapses these titles onto the sources block — read this "
"test's docstring: the finding it guards may be gone, and this half should be deleted"
titles = {okf.parse_frontmatter(base / c["path"]).get("title", "") for c in cited}
assert len(titles) == len(cited), (
"okf.parse_frontmatter collapsed these titles onto the sources block again — the P15 fix "
"in okf._frontmatter_from_text has regressed"
)

View file

@ -315,6 +315,38 @@ def test_parse_frontmatter_reads_scalar_fields() -> None:
assert fm["decision"] == "approved_with_adjustment"
def test_parse_frontmatter_top_level_title_survives_nested_sources_title(tmp_path) -> None:
"""A concept's OWN ``title`` sits at top level; ``sources:`` is a block sequence whose nested
``title:`` names the SOURCE document, not the concept (measured on a real vegnormal-okf
concept, ``krav/N500/id-bfb0edb4-``). Top-level keys carry no indentation and must win
a nested line must never overwrite a top-level key of the same name, however late it appears
in the scan. Without this, ``directory_listing`` on ``krav/N500`` returns 269 documents that
all share the one nested title, ``N500:2024`` rung 2/3 of the navigation ladder collapse to
an opaque UUID filename and a character count (P14 finding,
``docs/2026-09-12-p14-kontekstsett.md`` § 5).
Known-negatives that must stay green: ``type`` and ``req_number`` are untouched top-level
scalars either way, and the ``verified:`` block-form decoder (SPEC §5.2) reads
``_split_frontmatter``'s lines directly and is unaffected by this fix."""
text = (
"---\n"
"type: Krav\n"
"title: Krav 10.4.3—1 Mekanisk ventilasjon (impulsventilator)\n"
"req_number: Krav 10.4.3—1\n"
"sources:\n"
" - resource: https://example.invalid/859990\n"
" title: N500:2024\n"
"---\n\n"
"## Krav\nbody\n"
)
path = tmp_path / "concept.md"
path.write_text(text, encoding="utf-8")
fm = okf.parse_frontmatter(path)
assert fm["title"] == "Krav 10.4.3—1 Mekanisk ventilasjon (impulsventilator)"
assert fm["type"] == "Krav"
assert fm["req_number"] == "Krav 10.4.3—1"
def test_render_frontmatter_roundtrips_consumed_fields(tmp_path) -> None:
"""Step-8 writer: ``render_frontmatter`` + ``write_concept_file`` emit a block that
``parse_frontmatter`` re-reads with the fields ``seed_store_from_bundle`` consumes