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 on45edbf5(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 on45edbf5, +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:
parent
45edbf5957
commit
f13dc64a0a
4 changed files with 127 additions and 38 deletions
|
|
@ -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
|
### 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.
|
**RETTET 2026-09-13 (P15, ordre `20260912T220951Z`).** Den opprinnelige setningen her — «samtlige 22
|
||||||
MÅLT over alle fire basene, 22 prøvde kostnadsord:
|
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`,
|
**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
|
||||||
`kroner`, `budsjett`, `kostnadsestimat`, `prisskjema`, `timepris`, `nåverdi`, `driftskostnad` …
|
substring-regel (`anchors_are_absent`, `tests/test_context_sets_loadbearing.py`) mot alle fire
|
||||||
- **r761-2025:** 18 av 22 fraværende; basen bærer `kroner`, `budsjett`, `enhetspris` og `hydrogen`,
|
basene:
|
||||||
fordi prosesskoden ER kontraktsspråk.
|
|
||||||
|
|
||||||
Det er et funn i seg selv, og det er hele grunnen til at gaten er verdt å teste: po sitt oppdrag er
|
`enhetspris`, `kroner`, `budsjett`, `kostnadsestimat`, `prisskjema`, `timepris`, `nåverdi`,
|
||||||
å finne **kostnadsbesparelser**, og tre av fire baser inneholder ikke ett eneste pengeord. En
|
`driftskostnad`, `anleggskostnad`, `materialkostnad`, `arbeidskostnad`, `investeringskostnad`,
|
||||||
kjøring som svarer med et kronebeløp mot N100 har ikke lest basen — den har funnet på.
|
`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
|
### 2.3 Regelen for «kan ikke besvares» (DEL 3c) — skrevet ned
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,13 +105,34 @@ def _split_frontmatter(text: str) -> tuple[list[str], str, bool]:
|
||||||
|
|
||||||
def _frontmatter_from_text(text: str) -> dict[str, str]:
|
def _frontmatter_from_text(text: str) -> dict[str, str]:
|
||||||
"""``parse_frontmatter``'s rule, applied to already-read text: every frontmatter line that
|
"""``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
|
carries a colon becomes one ``key: value`` pair, last write winning AMONG LINES AT THE SAME
|
||||||
parsed as if closed — the pre-split behaviour, preserved deliberately."""
|
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] = {}
|
fm: dict[str, str] = {}
|
||||||
|
top_level: set[str] = set()
|
||||||
for line in _split_frontmatter(text)[0]:
|
for line in _split_frontmatter(text)[0]:
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
key, sep, val = line.partition(":")
|
key, sep, val = line.partition(":")
|
||||||
if sep:
|
if not sep:
|
||||||
fm[key.strip()] = val.strip()
|
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
|
return fm
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,22 +57,31 @@ _MIN_ANCHOR_CHARS = 4
|
||||||
def own_frontmatter(path: Path) -> dict[str, str]:
|
def own_frontmatter(path: Path) -> dict[str, str]:
|
||||||
"""The concept's OWN frontmatter: top-level keys only, FIRST occurrence winning.
|
"""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.**
|
**P15 (2026-09-13) fixed the finding this helper was written against.** Before P15,
|
||||||
It is linewise and last-write-wins by documented design, so a nested block overwrites a
|
``okf.parse_frontmatter`` was linewise and last-write-wins over EVERY line regardless of
|
||||||
top-level key of the same name. Every vegnormal concept ends its frontmatter with
|
indentation, so a nested block overwrote a top-level key of the same name. Every vegnormal
|
||||||
|
concept ends its frontmatter with
|
||||||
|
|
||||||
sources:
|
sources:
|
||||||
- resource: https://…
|
- resource: https://…
|
||||||
title: N500:2024
|
title: N500:2024
|
||||||
|
|
||||||
and the indented ``title`` therefore replaces the concept's own. MEASURED on n500-2024:
|
and the indented ``title`` used to replace the concept's own. MEASURED on n500-2024 before the
|
||||||
``okf.navigate_bundle`` yields 270 concept files carrying **1 distinct title** (``N500:2024``,
|
fix: ``okf.navigate_bundle`` yielded 270 concept files carrying **1 distinct title**
|
||||||
270 times), and ``okf.directory_listing(bundle, path="krav/N500")`` returns 269 documents whose
|
(``N500:2024``, 270 times). ``okf.parse_frontmatter`` now makes indentation load-bearing —
|
||||||
``title`` is that same string — so rung 2 and rung 3 of the navigation ladder tell a navigator
|
a top-level (unindented) key always wins over a nested one of the same name — and re-measured
|
||||||
apart only by an opaque UUID filename and a character count. That is a finding about the
|
AFTER the fix, the same base's 269 ``krav/N500`` documents carry **269 distinct titles**.
|
||||||
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
|
**This helper still isn't a plain call to ``okf.parse_frontmatter``, and that remains
|
||||||
concept to the same constant and be VACUOUS.
|
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
|
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.
|
(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.
|
"""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
|
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
|
the recorded titles were ``parse_frontmatter``'s PRE-P15 titles, every one of them would be the
|
||||||
``sources`` title and the assert would hold against any concept in the base.
|
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
|
**Former tripwire, INVERTED 2026-09-13 by P15 (deliberately, per that order — not deleted).**
|
||||||
collapse them. The day that stops being true — an okf bump, or a decision to read block
|
Until P15 the second half asserted that ``okf.parse_frontmatter`` DID still collapse the
|
||||||
mappings here — this arm goes red, and whoever sees it should read this docstring, confirm the
|
titles, as a red flag that would fire the day the production bug was fixed here instead of at
|
||||||
listing now carries real titles, and DELETE this half rather than weaken it.
|
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")
|
declared = read_bundle_txt(set_dir / "bundle.txt")
|
||||||
base = _require_base(declared)
|
base = _require_base(declared)
|
||||||
fasit = json.loads((set_dir / "fasit.json").read_text(encoding="utf-8"))
|
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"]]
|
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"
|
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}
|
titles = {okf.parse_frontmatter(base / c["path"]).get("title", "") for c in cited}
|
||||||
assert len(collapsed) == 1, (
|
assert len(titles) == len(cited), (
|
||||||
"okf.parse_frontmatter no longer collapses these titles onto the sources block — read this "
|
"okf.parse_frontmatter collapsed these titles onto the sources block again — the P15 fix "
|
||||||
"test's docstring: the finding it guards may be gone, and this half should be deleted"
|
"in okf._frontmatter_from_text has regressed"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,38 @@ def test_parse_frontmatter_reads_scalar_fields() -> None:
|
||||||
assert fm["decision"] == "approved_with_adjustment"
|
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:
|
def test_render_frontmatter_roundtrips_consumed_fields(tmp_path) -> None:
|
||||||
"""Step-8 writer: ``render_frontmatter`` + ``write_concept_file`` emit a block that
|
"""Step-8 writer: ``render_frontmatter`` + ``write_concept_file`` emit a block that
|
||||||
``parse_frontmatter`` re-reads with the fields ``seed_store_from_bundle`` consumes
|
``parse_frontmatter`` re-reads with the fields ``seed_store_from_bundle`` consumes
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue