test(fixtures): replace sector-specific example material with generic, fictitious examples — green

Every fixture, test document, tool example and document now uses an invented
kitchen-and-baking handbook series, written in this repository. The package's
behaviour is unchanged; src/ changes are comments and help text only.

- Generated fixtures are regenerated from their generators. Their structural
  counts are identical before and after: elements, images, rows, cells,
  headings, bookmarks and the witness inventory's per-document totals. The
  image-inbox and accounting documents are renamed kapittel-84-*.
- tools/okf_accounting_gate.py: the two options that named one real corpus
  each are replaced by a generic, repeatable --corpus PATH with no default.
  Row 5 compares the PDF pair alone. Gate verdict unchanged: RED rows 2, 3, 6.
- tools/okf_witness.py: the STS JSON reader for one publisher's delivery is
  removed, along with its three twins and five tests. The mutation harness
  loses W09.
- docs/: 13 dated reports that documented runs on a retired reference corpus
  are removed, and 40 are neutralized. Dead links are removed, and no new
  dangling path is introduced.
- The synthetic MCP-gate corpus and the residual probe words are neutral.

Valgt: keep the `okf quality --fasit` bar value (the measured fraction, one corpus) and
rewrite only its provenance, because the verdict stays unchanged and the
number names nothing.

Term check with the local list: 0 of 411 tracked files, 0 file names, 0 of
27 binary fixtures. Suite after git add: 2457 passed, 1 skipped. The base
tree had 2460 passed and 2 skipped; five tests went with the JSON reader and
four were added by the term check. ruff, ruff format and mypy --strict src/
are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-23 13:54:57 +02:00
commit 9d1f4b14ed
174 changed files with 1889 additions and 6512 deletions

View file

@ -17,7 +17,7 @@ and reproducible from that one file.
|---|---|
| `two-line-krav.pdf` | One heading plus one requirement row with label and value on the **same line**. That pairing is the property `pdfplumber` was chosen for. |
| `no-text-layer.pdf` | A structurally valid page with no text operators — the shape a scanned or image-only PDF presents. Must fail fast (`extractor_empty_pdf`), never persist as an empty concept. |
| `outline-collision.pdf` | **Two bookmarks whose destinations resolve to the same line** — the tree's root node and a front-matter node, both on line 0, which is the shape R761 carries. The marks are collected in a dict keyed on the line index, so without this fixture the second node is dropped with nothing counting it: 2 763 nodes in, 2 762 marks out, `unresolved` at 0. A one-bookmark-per-line fixture cannot see that. |
| `outline-collision.pdf` | **Two bookmarks whose destinations resolve to the same line** — the tree's root node and a front-matter node, both on line 0, which is the shape a 701-page reference standard carries. The marks are collected in a dict keyed on the line index, so without this fixture the second node is dropped with nothing counting it: 2 763 nodes in, 2 762 marks out, `unresolved` at 0. A one-bookmark-per-line fixture cannot see that. |
| `three-page-krav.pdf` | Three pages, one line of text each, and **the middle page carries no text operators**. The extractor drops empty pages, so the last page's text belongs to page 3 — which is what separates a page NUMBER from a count of the pages that produced text. Two pages could not tell those apart. |
## The XML fixtures
@ -31,9 +31,9 @@ round-trip-symmetric defect.
| Fixture | What it is for |
|---|---|
| `sts-mini.xml` | The known-positive. A `<standard>` root with `<sec>` at three nesting levels carrying `<label>`+`<title>`, two lettered points (`a)`, `b)`) with a **label and no title**, one `<table-wrap>` with a label and two rows, `<p>` bodies, a `<list>`, and **one unnumbered section** (`Forord`, `<title>` with no `<label>`) mirroring the single such section in R761. The lettered points are what the 4 954 label-only `<sec>` in that document look like: promoted to headings they would bury its own 2 761. |
| `sts-empty-label.xml` | A `<sec>` carrying a `<label>` and **nothing else**, between a lettered point that has a body and the next titled section. The label is held as a prefix for a body line that never arrives, so it was overwritten and lost: measured on R761 that is exactly one `x)`, two characters of 1 283 395, ratio 0.999998. An exact invariant does not get to be 0.999998. |
| `sts-identity.xml` | A document that **states who it is**: exactly one `<std-ident>` with a `<doc-number>` and a `<year>`, one `<title-wrap>` whose `<full>` carries a **comma** (as R761's does, which is why that title cannot be written into a `sources` flow mapping verbatim), and a `<std-ref type="dated">`. Its body carries the `sec-type="spec"` shape the `description` rule reads: a titled `<sec>` whose first spec point has one `<p>`, a second spec point that must never become the description, a titled child with no spec point of its own, and a spec point with **two** `<p>` of which only the first counts. `sts-mini.xml` is the half identity (a `<title-wrap>`, no `<doc-number>`) and `sts-empty-label.xml` the absent one. |
| `sts-mini.xml` | The known-positive. A `<standard>` root with `<sec>` at three nesting levels carrying `<label>`+`<title>`, two lettered points (`a)`, `b)`) with a **label and no title**, one `<table-wrap>` with a label and two rows, `<p>` bodies, a `<list>`, and **one unnumbered section** (`Forord`, `<title>` with no `<label>`) mirroring the single such section in that reference standard. The lettered points are what the 4 954 label-only `<sec>` in that document look like: promoted to headings they would bury its own 2 761. |
| `sts-empty-label.xml` | A `<sec>` carrying a `<label>` and **nothing else**, between a lettered point that has a body and the next titled section. The label is held as a prefix for a body line that never arrives, so it was overwritten and lost: measured on the reference standard that is exactly one `x)`, two characters of 1 283 395, ratio 0.999998. An exact invariant does not get to be 0.999998. |
| `sts-identity.xml` | A document that **states who it is**: exactly one `<std-ident>` with a `<doc-number>` and a `<year>`, one `<title-wrap>` whose `<full>` carries a **comma** (as the reference standard's does, which is why that title cannot be written into a `sources` flow mapping verbatim), and a `<std-ref type="dated">`. Its body carries the `sec-type="spec"` shape the `description` rule reads: a titled `<sec>` whose first spec point has one `<p>`, a second spec point that must never become the description, a titled child with no spec point of its own, and a spec point with **two** `<p>` of which only the first counts. `sts-mini.xml` is the half identity (a `<title-wrap>`, no `<doc-number>`) and `sts-empty-label.xml` the absent one. |
| `generic-feed.xml` | Known-negative: XML that is **not** STS. It must produce text and ONE plan — never zero, never a crash, and never element names promoted to headings. |
| `xml-doctype-bomb.xml` | Known-negative, security: a `<!DOCTYPE` with a small nested-entity expansion. It must be refused by `code`, and the test asserts the expansion appears in **no** output, including the error text. Small on purpose — the point is that it is never parsed, not that it detonates. |
| `xml-malformed.xml` | Known-negative: an unterminated tag must raise a typed `ExtractionError`, not leak `ParseError` and not yield zero concepts in silence. |
@ -208,13 +208,13 @@ python3 tests/fixtures/accounting/make_accounting_fixtures.py
| Fixture | What it carries that nothing else did |
|---|---|
| `topptekst-og-kommentar.docx` | A header, a footer, a comment, an endnote and a **text box** -- and a footnote, a table and a heading, three types the only other docx has at 0. The header says "Utkast - gjelder ikke etter 2026-01-01" and the comment says the requirement does NOT apply in tunnels: two statements that reverse the document's meaning and that the build carries none of. |
| `topptekst-og-kommentar.docx` | A header, a footer, a comment, an endnote and a **text box** -- and a footnote, a table and a heading, three types the only other docx has at 0. The header says "Utkast - gjelder ikke etter 2026-01-01" and the comment says the requirement does NOT apply in basements: two statements that reverse the document's meaning and that the build carries none of. |
| `notater-og-skjult.pptx` | A **speaker note** and a **hidden slide** (`show="0"`), plus a table and paragraphs. A hidden slide counted as an ordinary one is indistinguishable from one that is shown. |
| `skjult-ark-og-formel.xlsx` | A **hidden sheet**, a **formula** (`<f>B2*2</f>`) and a **picture**. The picture is what makes the operator's `.xlsx image` exception exercisable at all: the old fixture had none. |
| `liste-og-bilde.odt` | A **header and footer** (they live in `styles.xml`, so a reader of `content.xml` cannot see them), an **annotation**, a list and a picture. |
| `bilde.rtf` | A `\pict` picture: the rtf witness's image count was 0 in its only fixture. |
| `figur.html` | A picture and a table under `.html`; `side.htm` gained one too, so `.htm` and `.html` each exercise `image`. |
| `sts-rikt.xml` | A **`mixed-citation`**, an **`mml:math`**, a **`fig` with a caption**, a table with a label, cells, a list item and a footnote -- six STS roles the R761 delivery does not contain at all, which is why the gate's only real corpus could not see the hole in the role map. |
| `sts-rikt.xml` | A **`mixed-citation`**, an **`mml:math`**, a **`fig` with a caption**, a table with a label, cells, a list item and a footnote -- six STS roles a plain section-and-paragraph document does not carry, so each one of them reaches the role map. |
### The hand counts
@ -227,9 +227,7 @@ fixture's own bytes -- the XML parts of a zip, the control words of the rtf,
the objects of the PDF -- never by running the witness and writing down what
it said.
`witness/prosess-84-sts.twin.json` is the STS document written by hand in the
publisher's JSON node form (`standardContent`, nodes with `e`/`t`/`x`), so the
two STS witnesses can be compared on a fixture as well as on R761. Eight of
Eight of
the thirteen documents are byte copies of fixtures documented above
(`image-inbox/`, `k2-office/`, `prisark.xlsx`); the other five
(`notat.md`, `logg.txt`, `mengder.csv`, `parametre.json`, `side.htm`) are
@ -238,7 +236,7 @@ heading.
## What these fixtures do not cover
Structured table recovery. Measured on real Vegnormalene, only 45 of 196
Structured table recovery. Measured on a real regulatory reference corpus, only 45 of 196
detected table objects are clean enough to hand to `render_table` unchanged;
two independent parsers return the same wrong shape, because the breakage is in
the documents' ruling geometry rather than in either library. PDFs enter this

View file

@ -3,9 +3,9 @@
<head><title>Figur 84-1</title></head>
<body>
<h1>Figur 84-1</h1>
<p>Prinsippet for toleranseklasser.</p>
<p>Prinsippet for hevetidsklasser.</p>
<img src="graphics/figur-84-1.png" alt="Prinsippskisse">
<table><tr><th>Klasse</th><th>Avvik</th></tr><tr><td>A</td><td>5 mm</td></tr></table>
<table><tr><th>Klasse</th><th>Avvik</th></tr><tr><td>A</td><td>5 min</td></tr></table>
<ul><li>Klasse A</li><li>Klasse B</li></ul>
</body>
</html>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<standard xmlns:xlink="http://www.w3.org/1999/xlink">
<front><std-ident><doc-number>R761</doc-number></std-ident></front>
<front><std-ident><doc-number>P761</doc-number></std-ident></front>
<body>
<sec>
<label>84</label>
<title>Konstruksjoner av betong</title>
<title>Brød og boller</title>
<sec>
<label>84.1</label>
<title>Toleranser</title>
<p>Toleranseklasse er gitt i tabell 84-2.</p>
<title>Hevetider</title>
<p>Hevetidsklasse er gitt i tabell 84-2.</p>
<graphic xlink:href="graphics/tabell-84-2.png"/>
<p>Figur 84-1 viser prinsippet.</p>
<graphic xlink:href="figur-84-1.png"/>

View file

@ -1,9 +1,9 @@
<!doctype html>
<html><head><title>Prosess 84</title></head>
<html><head><title>Kapittel 84</title></head>
<body>
<h1>84 Konstruksjoner av betong</h1>
<p>Toleranseklasse for de enkelte konstruksjonsdeler er gitt i tabell 84-2.</p>
<img src="graphics/tabell-84-2.png" alt="Tabell 84-2 Toleranseklasser">
<h1>84 Brød og boller</h1>
<p>Hevetidsklasse for de enkelte deigtyper er gitt i tabell 84-2.</p>
<img src="graphics/tabell-84-2.png" alt="Tabell 84-2 Hevetidsklasser">
<p>Figuren under viser prinsippet.</p>
<figure>
<img src="graphics/figur-84-1.png" alt="Figur 84-1 Prinsippskisse">

View file

@ -1,11 +1,11 @@
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b\fs32 Kravspesifikasjon for tunnelbelysning\b0\fs24\par
\pard\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b\fs32 Kravspesifikasjon for kj\u248 ?kkenbelysning\b0\fs24\par
\pard\sa180 Dokumentet samler kravene til belysning i storkj\u248 ?kken over 500 kvadratmeter.\par
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Dokumentnummer:\cell \pard\intbl SVV-2026-0417\cell \row
\pard\intbl Dokumentnummer:\cell \pard\intbl EKS-2026-0417\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Tittel:\cell \pard\intbl Tunnelbelysning i hovedl\u248 ?pet\cell \row
\pard\intbl Tittel:\cell \pard\intbl Kj\u248 ?kkenbelysning i hovedsalen\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Ansvarlig avdeling:\cell \pard\intbl Utbyggingsdivisjonen\cell \row
\pard\intbl Ansvarlig avdeling:\cell \pard\intbl Kj\u248 ?kkendivisjonen\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Fagomr\u229 ?de:\cell \pard\intbl Elektro og belysning\cell \row
\trowd\trgaph108\cellx3000\cellx6000
@ -13,21 +13,21 @@
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Gyldig fra:\cell \pard\intbl 01.03.2026\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Erstatter:\cell \pard\intbl SVV-2024-0188\cell \row
\pard\intbl Erstatter:\cell \pard\intbl EKS-2024-0188\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Tunnellengde:\cell \pard\intbl 1240 meter\cell \row
\pard\intbl Salens lengde:\cell \pard\intbl 42 meter\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Dimensjonerende fart:\cell \pard\intbl 80 km/t\cell \row
\pard\intbl Dimensjonerende gjester:\cell \pard\intbl 80 kuverter\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl \u197 ?rsd\u248 ?gntrafikk:\cell \pard\intbl 12400 kj\u248 ?ret\u248 ?y\cell \row
\pard\intbl \u197 ?rsproduksjon:\cell \pard\intbl 12400 porsjoner\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Terskelluminans:\cell \pard\intbl 145 candela\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Overgangssone:\cell \pard\intbl Tre trinn nedtrapping\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Innerstrekning:\cell \pard\intbl 3,0 candela\cell \row
\pard\intbl Innerste sone:\cell \pard\intbl 3,0 candela\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Utkj\u248 ?ringssone:\cell \pard\intbl Ingen forsterkning\cell \row
\pard\intbl Utgangssone:\cell \pard\intbl Ingen forsterkning\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Fargetemperatur:\cell \pard\intbl 4000 kelvin\cell \row
\trowd\trgaph108\cellx3000\cellx6000
@ -40,7 +40,7 @@
\pard\intbl M\u229 ?lemetode:\cell \pard\intbl M\u229 ?ling med luminanskamera\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Avviksh\u229 ?ndtering:\cell \pard\intbl S\u248 ?knad om fravik\cell \row
\pard\sa180 Luminansmatrise per trafikklasse\par
\pard\sa180 Luminansmatrise per romklasse\par
\trowd\trgaph108\cellx3000\cellx6000\cellx9000\cellx12000
\pard\intbl Sone\cell \pard\intbl Klasse A\cell \pard\intbl Klasse B\cell \pard\intbl Klasse C\cell \row
\trowd\trgaph108\cellx3000\cellx6000\cellx9000\cellx12000

View file

@ -1,4 +1,4 @@
Driftslogg for tunnelen.
Driftslogg for bakerovnen.
Vask utført i mars.
Lys kontrollert i april.

View file

@ -1,15 +1,15 @@
# Drift av tunneler
# Stell av bakerovner
Tunnelen driftes etter håndboka.
Ovnen stelles etter håndboka.
## Vask
Vask skjer to ganger i året.
| Sone | Intervall |
| Hylle | Intervall |
| --- | --- |
| Inngang | 6 mnd |
| Hovedløp | 12 mnd |
| Øverst | 6 mnd |
| Nederst | 12 mnd |
![Figur 1 Vaskeplan](graphics/tabell-84-2.png)

View file

@ -1 +1 @@
{"tunnel": "Eksempeltunnelen", "lengde_m": 1240, "soner": ["inngang", "hovedlop"], "belysning": {"terskel_cd": 145, "fart_kmt": 80}}
{"ovn": "Eksempelovnen", "hoyde_cm": 124, "hyller": ["overst", "nederst"], "varme": {"grader_c": 145, "minutter": 80}}

View file

@ -1,10 +1,10 @@
<!doctype html>
<html><head><title>Side</title></head>
<body>
<h1>Belysning</h1>
<p>Terskelluminansen er 145 candela.</p>
<img src="graphics/tabell-84-2.png" alt="Sonekart">
<h2>Soner</h2>
<ul><li>Inngang</li><li>Hovedløp</li></ul>
<table><tr><th>Sone</th><th>Lengde</th></tr><tr><td>Inngang</td><td>120 m</td></tr></table>
<h1>Ovnen</h1>
<p>Steketemperaturen er 145 grader.</p>
<img src="graphics/tabell-84-2.png" alt="Hyllekart">
<h2>Hyller</h2>
<ul><li>Øverst</li><li>Nederst</li></ul>
<table><tr><th>Hylle</th><th>Temperatur</th></tr><tr><td>Øverst</td><td>180 C</td></tr></table>
</body></html>

View file

@ -1,16 +1,16 @@
<standard>
<front><std-ident><doc-number>R762</doc-number><year>2025</year></std-ident></front>
<front><std-ident><doc-number>P762</doc-number><year>2025</year></std-ident></front>
<body>
<sec><label>85</label><title>Vegdekker</title>
<p>Dekket skal ha jevnhet etter <mixed-citation>NS-EN 13036-1:2010</mixed-citation>.</p>
<p>Kravet regnes som <mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>IRI</mml:mi><mml:mo>&lt;</mml:mo><mml:mn>2</mml:mn></mml:math>.</p>
<fig><label>Figur 85-1</label><caption><p>Maalepunkter langs vegbanen.</p></caption>
<sec><label>85</label><title>Bakeplater</title>
<p>Platen skal ha jevn varme etter <mixed-citation>EKS 1234-1:2030</mixed-citation>.</p>
<p>Kravet regnes som <mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>T</mml:mi><mml:mo>&lt;</mml:mo><mml:mn>2</mml:mn></mml:math>.</p>
<fig><label>Figur 85-1</label><caption><p>Maalepunkter langs bakeplaten.</p></caption>
<graphic xlink:href="figur-84-1.png" xmlns:xlink="http://www.w3.org/1999/xlink"/></fig>
<table-wrap><label>Tabell 85-1</label>
<table><tr><th>Klasse</th><th>IRI</th></tr><tr><td>1</td><td>1,5</td></tr></table>
<table><tr><th>Klasse</th><th>T</th></tr><tr><td>1</td><td>1,5</td></tr></table>
</table-wrap>
<list><list-item><p>Maales hvert 20. meter.</p></list-item></list>
<fn><p>Gjelder ikke gang- og sykkelveger.</p></fn>
<list><list-item><p>Maales hver 20. centimeter.</p></list-item></list>
<fn><p>Gjelder ikke steinovner.</p></fn>
</sec>
</body>
</standard>

File diff suppressed because it is too large Load diff

View file

@ -51,14 +51,14 @@ def build_zip(parts: dict[str, str | bytes]) -> bytes:
_DOCX_BODY = (
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>'
"<w:r><w:t>Krav til gangbruer</w:t></w:r></w:p>"
"<w:p><w:r><w:t>Gangbruer skal ha rekkverk paa begge sider.</w:t></w:r></w:p>"
"<w:r><w:t>Krav til bakebrett</w:t></w:r></w:p>"
"<w:p><w:r><w:t>Bakebrett skal ha kant paa begge sider.</w:t></w:r></w:p>"
"<w:tbl><w:tr>"
"<w:tc><w:p><w:r><w:t>Bredde</w:t></w:r></w:p></w:tc>"
"<w:tc><w:p><w:r><w:t>3,0 m</w:t></w:r></w:p></w:tc>"
"<w:tc><w:p><w:r><w:t>0,4 m</w:t></w:r></w:p></w:tc>"
"</w:tr></w:tbl>"
"<w:p><w:r><w:pict><w:txbxContent>"
"<w:p><w:r><w:t>Merk: kravet gjelder ikke midlertidige bruer.</w:t></w:r></w:p>"
"<w:p><w:r><w:t>Merk: kravet gjelder ikke engangsbrett.</w:t></w:r></w:p>"
"</w:txbxContent></w:pict></w:r></w:p>"
)
@ -90,15 +90,15 @@ _DOCX_PARTS: dict[str, str | bytes] = {
+ f"<w:hdr {_W}><w:p><w:r><w:t>Utkast - gjelder ikke etter 2026-01-01</w:t></w:r></w:p>"
+ "</w:hdr>",
"word/footer1.xml": _XML
+ f"<w:ftr {_W}><w:p><w:r><w:t>Statens vegvesen, side 1</w:t></w:r></w:p></w:ftr>",
+ f"<w:ftr {_W}><w:p><w:r><w:t>Eksempelforlaget, side 1</w:t></w:r></w:p></w:ftr>",
"word/comments.xml": _XML
+ f"<w:comments {_W}>"
+ '<w:comment w:id="1"><w:p><w:r><w:t>Unntak: gjelder IKKE gangbruer i tunnel.'
+ '<w:comment w:id="1"><w:p><w:r><w:t>Unntak: gjelder IKKE bakebrett i kjeller.'
+ "</w:t></w:r></w:p></w:comment></w:comments>",
"word/footnotes.xml": _XML
+ f"<w:footnotes {_W}>"
+ '<w:footnote w:id="0"><w:p><w:r><w:t>separator</w:t></w:r></w:p></w:footnote>'
+ '<w:footnote w:id="2"><w:p><w:r><w:t>Se haandbok N400 kapittel 5.</w:t></w:r></w:p>'
+ '<w:footnote w:id="2"><w:p><w:r><w:t>Se kokebok Q400 kapittel 5.</w:t></w:r></w:p>'
+ "</w:footnote></w:footnotes>",
"word/endnotes.xml": _XML
+ f"<w:endnotes {_W}>"
@ -164,11 +164,11 @@ _PPTX_PARTS: dict[str, str | bytes] = {
+ f"<p:presentation {_P}><p:sldIdLst>"
+ '<p:sldId id="256" r:id="rId1"/><p:sldId id="257" r:id="rId2"/>'
+ "</p:sldIdLst></p:presentation>",
"ppt/slides/slide1.xml": _slide("Prosess 84 Konstruksjoner", "Toleranser er gitt i tabell."),
"ppt/slides/slide1.xml": _slide("Kapittel 84 Boller", "Hevetider er gitt i tabell."),
"ppt/slides/slide2.xml": _slide("Utgaatt lysbilde", "Ikke vis dette.", hidden=True),
"ppt/notesSlides/notesSlide1.xml": _XML
+ f"<p:notes {_P} {_A}><p:cSld><p:spTree><p:sp><p:txBody>"
+ "<a:p><a:r><a:t>Husk aa nevne at toleranseklassen er skjerpet.</a:t></a:r></a:p>"
+ "<a:p><a:r><a:t>Husk aa nevne at hevetidsklassen er skjerpet.</a:t></a:r></a:p>"
+ "</p:txBody></p:sp></p:spTree></p:cSld></p:notes>",
}
@ -267,17 +267,17 @@ _ODT_PARTS: dict[str, str | bytes] = {
"content.xml": _XML
+ f'<office:document-content {_ODT_NS} office:version="1.3">'
+ "<office:body><office:text>"
+ '<text:h text:outline-level="1">Drift av gangbruer</text:h>'
+ "<text:p>Gangbruer inspiseres hvert aar.</text:p>"
+ "<text:list><text:list-item><text:p>Rekkverk</text:p></text:list-item>"
+ "<text:list-item><text:p>Dekke</text:p></text:list-item></text:list>"
+ '<text:h text:outline-level="1">Stell av bakebrett</text:h>'
+ "<text:p>Bakebrett vaskes hver uke.</text:p>"
+ "<text:list><text:list-item><text:p>Kant</text:p></text:list-item>"
+ "<text:list-item><text:p>Flate</text:p></text:list-item></text:list>"
+ "<text:p>Se figuren under."
+ '<draw:frame><draw:image xlink:href="graphics/figur-84-1.png"/></draw:frame></text:p>'
+ "<office:annotation><text:p>Sjekk denne mot N400 foer utsendelse.</text:p>"
+ "<office:annotation><text:p>Sjekk denne mot Q400 foer utsendelse.</text:p>"
+ "</office:annotation>"
+ "<table:table><table:table-row>"
+ "<table:table-cell><text:p>Type</text:p></table:table-cell>"
+ "<table:table-cell><text:p>Gangbru</text:p></table:table-cell>"
+ "<table:table-cell><text:p>Bakebrett</text:p></table:table-cell>"
+ "</table:table-row></table:table>"
+ "</office:text></office:body></office:document-content>",
"styles.xml": _XML
@ -285,7 +285,7 @@ _ODT_PARTS: dict[str, str | bytes] = {
+ "<office:styles/><office:master-styles>"
+ '<style:master-page style:name="Standard">'
+ "<style:header><text:p>Intern arbeidsversjon</text:p></style:header>"
+ "<style:footer><text:p>Vegdirektoratet</text:p></style:footer>"
+ "<style:footer><text:p>Eksempelforlaget</text:p></style:footer>"
+ "</style:master-page></office:master-styles></office:document-styles>",
}
@ -307,9 +307,9 @@ _HTML = """<!DOCTYPE html>
<head><title>Figur 84-1</title></head>
<body>
<h1>Figur 84-1</h1>
<p>Prinsippet for toleranseklasser.</p>
<p>Prinsippet for hevetidsklasser.</p>
<img src="graphics/figur-84-1.png" alt="Prinsippskisse">
<table><tr><th>Klasse</th><th>Avvik</th></tr><tr><td>A</td><td>5 mm</td></tr></table>
<table><tr><th>Klasse</th><th>Avvik</th></tr><tr><td>A</td><td>5 min</td></tr></table>
<ul><li>Klasse A</li><li>Klasse B</li></ul>
</body>
</html>
@ -319,18 +319,18 @@ _HTML = """<!DOCTYPE html>
# --- sts: a citation, a formula, a figure with a caption, a table, a footnote -
_STS = """<standard>
<front><std-ident><doc-number>R762</doc-number><year>2025</year></std-ident></front>
<front><std-ident><doc-number>P762</doc-number><year>2025</year></std-ident></front>
<body>
<sec><label>85</label><title>Vegdekker</title>
<p>Dekket skal ha jevnhet etter <mixed-citation>NS-EN 13036-1:2010</mixed-citation>.</p>
<p>Kravet regnes som <mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>IRI</mml:mi><mml:mo>&lt;</mml:mo><mml:mn>2</mml:mn></mml:math>.</p>
<fig><label>Figur 85-1</label><caption><p>Maalepunkter langs vegbanen.</p></caption>
<sec><label>85</label><title>Bakeplater</title>
<p>Platen skal ha jevn varme etter <mixed-citation>EKS 1234-1:2030</mixed-citation>.</p>
<p>Kravet regnes som <mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>T</mml:mi><mml:mo>&lt;</mml:mo><mml:mn>2</mml:mn></mml:math>.</p>
<fig><label>Figur 85-1</label><caption><p>Maalepunkter langs bakeplaten.</p></caption>
<graphic xlink:href="figur-84-1.png" xmlns:xlink="http://www.w3.org/1999/xlink"/></fig>
<table-wrap><label>Tabell 85-1</label>
<table><tr><th>Klasse</th><th>IRI</th></tr><tr><td>1</td><td>1,5</td></tr></table>
<table><tr><th>Klasse</th><th>T</th></tr><tr><td>1</td><td>1,5</td></tr></table>
</table-wrap>
<list><list-item><p>Maales hvert 20. meter.</p></list-item></list>
<fn><p>Gjelder ikke gang- og sykkelveger.</p></fn>
<list><list-item><p>Maales hver 20. centimeter.</p></list-item></list>
<fn><p>Gjelder ikke steinovner.</p></fn>
</sec>
</body>
</standard>

View file

@ -1,216 +0,0 @@
{
"productInfo": {
"meta": {
"productTitle": "R761 fixture twin"
}
},
"standardContent": {
"id": null,
"tag": "#document",
"a": {},
"c": [
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "standard",
"a": {},
"c": [
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "front",
"a": {},
"c": [
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "std-ident",
"a": {},
"c": [
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "doc-number",
"a": {},
"c": [
{
"e": 2,
"t": "R761",
"x": null
}
]
}
}
]
}
}
]
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "body",
"a": {},
"c": [
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "sec",
"a": {},
"c": [
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "label",
"a": {},
"c": [
{
"e": 2,
"t": "84",
"x": null
}
]
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "title",
"a": {},
"c": [
{
"e": 2,
"t": "Konstruksjoner av betong",
"x": null
}
]
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "sec",
"a": {},
"c": [
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "label",
"a": {},
"c": [
{
"e": 2,
"t": "84.1",
"x": null
}
]
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "title",
"a": {},
"c": [
{
"e": 2,
"t": "Toleranser",
"x": null
}
]
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "p",
"a": {},
"c": [
{
"e": 2,
"t": "Toleranseklasse er gitt i tabell 84-2.",
"x": null
}
]
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "graphic",
"a": {
"xlink:href": "graphic_0001.png"
},
"c": []
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "p",
"a": {},
"c": [
{
"e": 2,
"t": "Figur 84-1 viser prinsippet.",
"x": null
}
]
}
},
{
"e": 1,
"t": null,
"x": {
"id": null,
"tag": "graphic",
"a": {
"xlink:href": "graphic_0002.png"
},
"c": []
}
}
]
}
}
]
}
}
]
}
}
]
}
}
]
}
}

View file

@ -1,23 +0,0 @@
{
"productInfo": {"meta": {"productTitle": "Label inside title"}},
"standardContent": {
"id": null, "tag": "#document", "a": {},
"c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "standard", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "body", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "sec", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "title", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "label", "a": {}, "c": [
{"e": 2, "t": "84.1", "x": null}
]}},
{"e": 2, "t": "Toleranser", "x": null}
]}},
{"e": 1, "t": null, "x": {"id": null, "tag": "p", "a": {}, "c": [
{"e": 2, "t": "Toleranseklasse er gitt i tabell 84-2.", "x": null}
]}}
]}}
]}}
]}}
]
}
}

View file

@ -1,37 +0,0 @@
{
"productInfo": {"meta": {"productTitle": "Formula and figure caption"}},
"standardContent": {
"id": null, "tag": "#document", "a": {},
"c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "standard", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "body", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "sec", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "title", "a": {}, "c": [
{"e": 2, "t": "Vegdekker", "x": null}
]}},
{"e": 1, "t": null, "x": {"id": null, "tag": "p", "a": {}, "c": [
{"e": 2, "t": "Kravet regnes som ", "x": null},
{"e": 1, "t": null, "x": {"id": null, "tag": "inline-formula", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "mml:math", "a": {}, "c": [
{"e": 2, "t": "IRI < 2", "x": null}
]}}
]}}
]}},
{"e": 1, "t": null, "x": {"id": null, "tag": "fig", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "graphic", "a": {"href": "figur-85-1.png"}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "figcaption", "a": {}, "c": [
{"e": 1, "t": null, "x": {"id": null, "tag": "graphic-caption-label", "a": {}, "c": [
{"e": 2, "t": "Figur 85-1", "x": null}
]}},
{"e": 1, "t": null, "x": {"id": null, "tag": "graphic-caption-title", "a": {}, "c": [
{"e": 2, "t": "Maalepunkter langs vegbanen.", "x": null}
]}}
]}}
]}}
]}}
]}}
]}}
]}}
]
}
}

View file

@ -12,9 +12,9 @@ endobj
<< /Length 221 >>
stream
BT /F2 20 Tf 50 700 Td (Generelle tekniske krav) Tj ET
BT /F1 10 Tf 50 670 Td (Utkilingen skal ha helning 1:15.) Tj ET
BT /F1 10 Tf 50 670 Td (Deigkulene skal ha hevetid 1:15.) Tj ET
BT /F2 14 Tf 50 640 Td (Merking) Tj ET
BT /F1 10 Tf 50 610 Td (Kravet gjelder alle veiklasser.) Tj ET
BT /F1 10 Tf 50 610 Td (Kravet gjelder alle bakeformer.) Tj ET
endstream
endobj
5 0 obj

View file

@ -3,12 +3,12 @@
<title>Driftsmeldinger</title>
<description>Meldinger om drift og vedlikehold.</description>
<item>
<title>Stengt tunnel</title>
<description>Tunnelen er stengt for vedlikehold natt til fredag.</description>
<title>Stengt kantine</title>
<description>Kantinen er stengt for vedlikehold natt til fredag.</description>
</item>
<item>
<title>Redusert framkommelighet</title>
<description>Ett felt er stengt i anleggsperioden.</description>
<title>Redusert åpningstid</title>
<description>Ett kjøkken er stengt i ombyggingsperioden.</description>
</item>
</channel>
</rss>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<standard xmlns:xlink="http://www.w3.org/1999/xlink">
<front><std-ident><doc-number>R761</doc-number></std-ident></front>
<front><std-ident><doc-number>P761</doc-number></std-ident></front>
<body>
<sec>
<label>84</label>
<title>Konstruksjoner av betong</title>
<title>Brød og boller</title>
<sec>
<label>84.1</label>
<title>Toleranser</title>
<p>Toleranseklasse er gitt i tabell 84-2.</p>
<title>Hevetider</title>
<p>Hevetidsklasse er gitt i tabell 84-2.</p>
<graphic xlink:href="graphics/tabell-84-2.png"/>
<p>Figur 84-1 viser prinsippet.</p>
<graphic xlink:href="figur-84-1.png"/>

View file

@ -1,9 +1,9 @@
<!doctype html>
<html><head><title>Prosess 84</title></head>
<html><head><title>Kapittel 84</title></head>
<body>
<h1>84 Konstruksjoner av betong</h1>
<p>Toleranseklasse for de enkelte konstruksjonsdeler er gitt i tabell 84-2.</p>
<img src="graphics/tabell-84-2.png" alt="Tabell 84-2 Toleranseklasser">
<h1>84 Brød og boller</h1>
<p>Hevetidsklasse for de enkelte deigtyper er gitt i tabell 84-2.</p>
<img src="graphics/tabell-84-2.png" alt="Tabell 84-2 Hevetidsklasser">
<p>Figuren under viser prinsippet.</p>
<figure>
<img src="graphics/figur-84-1.png" alt="Figur 84-1 Prinsippskisse">

View file

@ -55,7 +55,7 @@
"krav-rikt-tekstformat.rtf": {
"authored_titles": 1,
"titles": [
"Kravspesifikasjon for tunnelbelysning"
"Kravspesifikasjon for kjøkkenbelysning"
],
"bold_runs_that_are_not_titles": 0,
"declares_heading_style": false,
@ -64,8 +64,8 @@
"krav-rikt-tekstformat-fet-alene.rtf": {
"authored_titles": 2,
"titles": [
"Kravspesifikasjon for tunnelbelysning",
"Luminanskrav per trafikklasse"
"Kravspesifikasjon for kjøkkenbelysning",
"Luminanskrav per romklasse"
],
"bold_runs_that_are_not_titles": 0,
"declares_heading_style": false,
@ -74,7 +74,7 @@
"krav-rikt-tekstformat-fet-i-avsnitt.rtf": {
"authored_titles": 1,
"titles": [
"Kravspesifikasjon for tunnelbelysning"
"Kravspesifikasjon for kjøkkenbelysning"
],
"bold_runs_that_are_not_titles": 1,
"declares_heading_style": false,
@ -83,8 +83,8 @@
"krav-rikt-tekstformat-stil.rtf": {
"authored_titles": 2,
"titles": [
"Kravspesifikasjon for tunnelbelysning",
"Luminanskrav per trafikklasse"
"Kravspesifikasjon for kjøkkenbelysning",
"Luminanskrav per romklasse"
],
"bold_runs_that_are_not_titles": 0,
"declares_heading_style": true,

Binary file not shown.

View file

@ -1,11 +1,11 @@
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b\fs32 Kravspesifikasjon for tunnelbelysning\b0\fs24\par
\pard\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b\fs32 Kravspesifikasjon for kj\u248 ?kkenbelysning\b0\fs24\par
\pard\sa180 Dokumentet samler kravene til belysning i storkj\u248 ?kken over 500 kvadratmeter.\par
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Dokumentnummer:\cell \pard\intbl SVV-2026-0417\cell \row
\pard\intbl Dokumentnummer:\cell \pard\intbl EKS-2026-0417\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Tittel:\cell \pard\intbl Tunnelbelysning i hovedl\u248 ?pet\cell \row
\pard\intbl Tittel:\cell \pard\intbl Kj\u248 ?kkenbelysning i hovedsalen\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Ansvarlig avdeling:\cell \pard\intbl Utbyggingsdivisjonen\cell \row
\pard\intbl Ansvarlig avdeling:\cell \pard\intbl Kj\u248 ?kkendivisjonen\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Fagomr\u229 ?de:\cell \pard\intbl Elektro og belysning\cell \row
\trowd\trgaph108\cellx3000\cellx6000
@ -13,21 +13,21 @@
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Gyldig fra:\cell \pard\intbl 01.03.2026\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Erstatter:\cell \pard\intbl SVV-2024-0188\cell \row
\pard\intbl Erstatter:\cell \pard\intbl EKS-2024-0188\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Tunnellengde:\cell \pard\intbl 1240 meter\cell \row
\pard\intbl Salens lengde:\cell \pard\intbl 42 meter\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Dimensjonerende fart:\cell \pard\intbl 80 km/t\cell \row
\pard\intbl Dimensjonerende gjester:\cell \pard\intbl 80 kuverter\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl \u197 ?rsd\u248 ?gntrafikk:\cell \pard\intbl 12400 kj\u248 ?ret\u248 ?y\cell \row
\pard\intbl \u197 ?rsproduksjon:\cell \pard\intbl 12400 porsjoner\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Terskelluminans:\cell \pard\intbl 145 candela\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Overgangssone:\cell \pard\intbl Tre trinn nedtrapping\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Innerstrekning:\cell \pard\intbl 3,0 candela\cell \row
\pard\intbl Innerste sone:\cell \pard\intbl 3,0 candela\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Utkj\u248 ?ringssone:\cell \pard\intbl Ingen forsterkning\cell \row
\pard\intbl Utgangssone:\cell \pard\intbl Ingen forsterkning\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Fargetemperatur:\cell \pard\intbl 4000 kelvin\cell \row
\trowd\trgaph108\cellx3000\cellx6000
@ -40,7 +40,7 @@
\pard\intbl M\u229 ?lemetode:\cell \pard\intbl M\u229 ?ling med luminanskamera\cell \row
\trowd\trgaph108\cellx3000\cellx6000
\pard\intbl Avviksh\u229 ?ndtering:\cell \pard\intbl S\u248 ?knad om fravik\cell \row
\pard\sa180 Luminansmatrise per trafikklasse\par
\pard\sa180 Luminansmatrise per romklasse\par
\trowd\trgaph108\cellx3000\cellx6000\cellx9000\cellx12000
\pard\intbl Sone\cell \pard\intbl Klasse A\cell \pard\intbl Klasse B\cell \pard\intbl Klasse C\cell \row
\trowd\trgaph108\cellx3000\cellx6000\cellx9000\cellx12000

Binary file not shown.

View file

@ -1,5 +1,5 @@
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b Kravspesifikasjon for tunnelbelysning\b0\par
\pard\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
\pard\sa180\b Luminanskrav per trafikklasse\b0\par
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b Kravspesifikasjon for kj\u248 ?kkenbelysning\b0\par
\pard\sa180 Dokumentet samler kravene til belysning i storkj\u248 ?kken over 500 kvadratmeter.\par
\pard\sa180\b Luminanskrav per romklasse\b0\par
\pard\sa180 Terskelluminansen er 145 candela og gjelder i hele overgangssonen.\par
}

View file

@ -1,4 +1,4 @@
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b Kravspesifikasjon for tunnelbelysning\b0\par
\pard\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
\pard\sa180 Avvik fra kravene krever soknad om \b fravik\b0 fra byggherren for arbeidet starter.\par
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b Kravspesifikasjon for kj\u248 ?kkenbelysning\b0\par
\pard\sa180 Dokumentet samler kravene til belysning i storkj\u248 ?kken over 500 kvadratmeter.\par
\pard\sa180 Avvik fra kravene krever soknad om \b fravik\b0 fra kjokkensjefen for arbeidet starter.\par
}

View file

@ -1,5 +1,5 @@
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}{\stylesheet{\s0 Normal;}{\s1\b\fs32 heading 1;}{\s2\b\fs28 heading 2;}}\pard\s1\b\fs32 Kravspesifikasjon for tunnelbelysning\b0\fs24\par
\pard\s0\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
\pard\s2\b\fs28 Luminanskrav per trafikklasse\b0\fs24\par
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}{\stylesheet{\s0 Normal;}{\s1\b\fs32 heading 1;}{\s2\b\fs28 heading 2;}}\pard\s1\b\fs32 Kravspesifikasjon for kj\u248 ?kkenbelysning\b0\fs24\par
\pard\s0\sa180 Dokumentet samler kravene til belysning i storkj\u248 ?kken over 500 kvadratmeter.\par
\pard\s2\b\fs28 Luminanskrav per romklasse\b0\fs24\par
\pard\s0\sa180 Terskelluminansen er 145 candela og gjelder i hele overgangssonen.\par
}

View file

@ -23,12 +23,11 @@ from pathlib import Path
HERE = Path(__file__).parent
# Two text lines: a heading, and one requirement row with label and value on
# the SAME line. That pairing is the property the parser choice was made on
# (see docs/2026-08-21-g2-pdf-extraction-measurement.md), so the fixture
# fails visibly if a parser upgrade ever breaks it. Byte 0xE5 is the Norwegian
# the SAME line. That pairing is the property the parser choice was made on,
# so the fixture fails visibly if a parser upgrade ever breaks it. Byte 0xE5 is the Norwegian
# 'a-ring' in WinAnsiEncoding, which the font object below declares.
KRAV_CONTENT = (
b"BT /F1 12 Tf 20 160 Td (Krav til helning p\xe5 utkilingen) Tj ET\n"
b"BT /F1 12 Tf 20 160 Td (Krav til hevetid p\xe5 deigkulene) Tj ET\n"
b"BT /F1 12 Tf 20 140 Td (60 og 70 1:15) Tj ET\n"
)
@ -45,9 +44,9 @@ NO_TEXT_CONTENT = b"20 20 160 160 re S\n"
# extractor drops empty pages, so the third page's text belongs to page 3 and
# to no other number.
PAGED_CONTENTS = (
b"BT /F1 12 Tf 20 160 Td (Side en om helning) Tj ET\n",
b"BT /F1 12 Tf 20 160 Td (Side en om hevetid) Tj ET\n",
b"20 20 160 160 re S\n",
b"BT /F1 12 Tf 20 160 Td (Side tre om utkiling) Tj ET\n",
b"BT /F1 12 Tf 20 160 Td (Side tre om deigkule) Tj ET\n",
)
@ -59,9 +58,9 @@ PAGED_CONTENTS = (
# which is what gives the reader a body size to compare against.
FONT_HEADING_CONTENT = (
b"BT /F2 20 Tf 50 700 Td (Generelle tekniske krav) Tj ET\n"
b"BT /F1 10 Tf 50 670 Td (Utkilingen skal ha helning 1:15.) Tj ET\n"
b"BT /F1 10 Tf 50 670 Td (Deigkulene skal ha hevetid 1:15.) Tj ET\n"
b"BT /F2 14 Tf 50 640 Td (Merking) Tj ET\n"
b"BT /F1 10 Tf 50 610 Td (Kravet gjelder alle veiklasser.) Tj ET\n"
b"BT /F1 10 Tf 50 610 Td (Kravet gjelder alle bakeformer.) Tj ET\n"
)
@ -74,9 +73,9 @@ FONT_HEADING_CONTENT = (
NUMBERED_FONT_CONTENT = (
b"BT /F2 20 Tf 50 700 Td (Forord) Tj ET\n"
b"BT /F1 10 Tf 50 670 Td (1 Generelle krav) Tj ET\n"
b"BT /F1 10 Tf 50 640 Td (Utkilingen skal ha helning 1:15.) Tj ET\n"
b"BT /F1 10 Tf 50 640 Td (Deigkulene skal ha hevetid 1:15.) Tj ET\n"
b"BT /F1 10 Tf 50 610 Td (2 Merking) Tj ET\n"
b"BT /F1 10 Tf 50 580 Td (Kravet gjelder alle veiklasser.) Tj ET\n"
b"BT /F1 10 Tf 50 580 Td (Kravet gjelder alle bakeformer.) Tj ET\n"
b"BT /F1 10 Tf 50 550 Td (3 Vedlegg) Tj ET\n"
b"BT /F1 10 Tf 50 520 Td (Vedlegget er eget oppslag.) Tj ET\n"
)
@ -113,25 +112,25 @@ OUTLINED_TREE = (
("1.2.1 Materialer", 3, 2, 150),
)
#: TWO bookmarks whose destinations resolve to the SAME line, mirroring what
#: R761 carries: its tree's root node `R761 Prosesskoden` and the node
#: `SVV - Forside` both land on line 0. Measured on that document, 2 763 nodes
#: TWO bookmarks whose destinations resolve to the SAME line, mirroring what a
#: 701-page reference standard carries: its tree's root node and its front-matter
#: node both land on line 0. Measured on that document, 2 763 nodes
#: entered the bridge and 2 762 marks came out with `unresolved` at 0 -- the
#: difference was a dict keyed on the line index, dropping the second node with
#: nothing counting it. A one-bookmark-per-line fixture cannot see that.
COLLISION_CONTENTS = (
b"BT /F1 12 Tf 20 170 Td (R761 Prosesskoden) Tj ET\n"
b"BT /F1 12 Tf 20 170 Td (P761 Oppskriftsboka) Tj ET\n"
b"BT /F1 12 Tf 20 150 Td (Innledende tekst om grunnlaget.) Tj ET\n",
)
#: Both point at `/XYZ 20 185`, which is above the page's first line.
COLLISION_TREE = (
("R761 Prosesskoden", 1, 0, 185),
("SVV - Forside", 2, 0, 185),
("P761 Oppskriftsboka", 1, 0, 185),
("EKS - Forside", 2, 0, 185),
)
#: One resolvable bookmark and one whose `/Dest` names an object that is not a
#: page. A PDF in the wild carries these; R761 carries none of them, so
#: page. A PDF in the wild carries these; the reference standard carries none, so
#: without this fixture the "drop it, count it, do not fabricate a boundary"
#: branch would ship having never run.
BROKEN_DEST_CONTENT = (
@ -391,7 +390,7 @@ _DOCX_PARTS = {
+ "</w:styles>",
"word/document.xml": _XML
+ '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>'
+ '<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Krav til helning</w:t></w:r></w:p>'
+ '<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Krav til hevetid</w:t></w:r></w:p>'
+ "<w:p><w:r><w:t>60 og 70 1:15</w:t></w:r></w:p>"
+ "</w:body></w:document>",
}
@ -444,7 +443,7 @@ _XLSX_PARTS = {
+ '<sheets><sheet name="Krav" sheetId="1" r:id="rId1"/></sheets></workbook>',
"xl/sharedStrings.xml": _XML
+ '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="3" uniqueCount="3">'
+ "<si><t>Krav til helning</t></si><si><t>60 og 70</t></si><si><t>1:15</t></si></sst>",
+ "<si><t>Krav til hevetid</t></si><si><t>60 og 70</t></si><si><t>1:15</t></si></sst>",
"xl/worksheets/sheet1.xml": _XML
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
+ '<dimension ref="A1:B2"/><sheetData>'

View file

@ -58,8 +58,8 @@ def jpeg_header(width: int, height: int) -> bytes:
Not a decodable photograph, and that is the point of it. The `pdf` reader
passes `DCTDecode` bytes through untouched and reads only the frame header
for the size, which is precisely what R761's own table images need -- 29 of
its 50 image objects are `DCTDecode`. A fixture that also carried entropy
for the size, which is precisely what a real reference PDF's table images
need -- 29 of its 50 image objects are `DCTDecode`. A fixture that also carried entropy
data would exercise no additional line of this package and could not be read
byte by byte by a person.
"""
@ -77,7 +77,7 @@ def jpeg_header(width: int, height: int) -> bytes:
# --- pdf ---------------------------------------------------------------------
#
# TWO images on one page, and they are deliberately of the two kinds the
# measurement on R761 found: 29 `DCTDecode` objects, which arrive as a finished
# measurement on a reference PDF found: 29 `DCTDecode` objects, which arrive as a finished
# JPEG file, and 21 `FlateDecode` ones, which arrive as raw samples with the
# colour model in the dictionary beside them and have to be encoded to be
# carried at all. A fixture with only one kind would leave half the reader
@ -88,7 +88,7 @@ PDF_GRAY_SAMPLES = bytes([0, 60, 120, 180, 20, 80, 140, 200, 40, 100, 160, 255])
PDF_JPEG_WIDTH, PDF_JPEG_HEIGHT = 360, 269
PDF_CONTENT = (
b"BT /F1 12 Tf 20 170 Td (Toleranseklasse er gitt i tabell 84-2) Tj ET\n"
b"BT /F1 12 Tf 20 170 Td (Hevetidsklasse er gitt i tabell 84-2) Tj ET\n"
b"q 80 0 0 60 20 90 cm /ImFlate Do Q\n"
b"q 80 0 0 60 20 20 cm /ImJpeg Do Q\n"
)
@ -153,11 +153,11 @@ def build_image_pdf() -> bytes:
# the gate counts it as found-and-not-carried rather than as absent.
HTML_DOCUMENT = """<!doctype html>
<html><head><title>Prosess 84</title></head>
<html><head><title>Kapittel 84</title></head>
<body>
<h1>84 Konstruksjoner av betong</h1>
<p>Toleranseklasse for de enkelte konstruksjonsdeler er gitt i tabell 84-2.</p>
<img src="graphics/tabell-84-2.png" alt="Tabell 84-2 Toleranseklasser">
<h1>84 Brød og boller</h1>
<p>Hevetidsklasse for de enkelte deigtyper er gitt i tabell 84-2.</p>
<img src="graphics/tabell-84-2.png" alt="Tabell 84-2 Hevetidsklasser">
<p>Figuren under viser prinsippet.</p>
<figure>
<img src="graphics/figur-84-1.png" alt="Figur 84-1 Prinsippskisse">
@ -170,7 +170,7 @@ HTML_DOCUMENT = """<!doctype html>
# --- niso-sts ----------------------------------------------------------------
#
# The shape the R761 delivery actually has, measured 2026-09-16: 50 `<graphic>`
# The shape a real NISO-STS delivery has, measured 2026-09-16: 50 `<graphic>`
# elements, every one a direct child of a `<sec>`, none inside a `<table-wrap>`,
# each carrying a bare file name in `xlink:href` that resolves against a sibling
# `graphics/` directory. No `<caption>` anywhere near them -- the caption a
@ -178,15 +178,15 @@ HTML_DOCUMENT = """<!doctype html>
STS_DOCUMENT = """<?xml version="1.0" encoding="UTF-8"?>
<standard xmlns:xlink="http://www.w3.org/1999/xlink">
<front><std-ident><doc-number>R761</doc-number></std-ident></front>
<front><std-ident><doc-number>P761</doc-number></std-ident></front>
<body>
<sec>
<label>84</label>
<title>Konstruksjoner av betong</title>
<title>Brød og boller</title>
<sec>
<label>84.1</label>
<title>Toleranser</title>
<p>Toleranseklasse er gitt i tabell 84-2.</p>
<title>Hevetider</title>
<p>Hevetidsklasse er gitt i tabell 84-2.</p>
<graphic xlink:href="graphics/tabell-84-2.png"/>
<p>Figur 84-1 viser prinsippet.</p>
<graphic xlink:href="figur-84-1.png"/>
@ -225,18 +225,18 @@ def build_docx() -> bytes:
+ ' xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"'
+ ' xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"'
+ ' xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>'
+ "<w:p><w:r><w:t>Toleranseklasse er gitt i tabell 84-2.</w:t></w:r></w:p>"
+ "<w:p><w:r><w:t>Hevetidsklasse er gitt i tabell 84-2.</w:t></w:r></w:p>"
+ '<w:p><w:r><w:drawing><wp:inline><wp:extent cx="381000" cy="285750"/>'
+ '<wp:docPr id="1" name="Bilde 1" descr="Tabell 84-2 Toleranseklasser"/>'
+ '<wp:docPr id="1" name="Bilde 1" descr="Tabell 84-2 Hevetidsklasser"/>'
+ '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006'
+ '/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="tabell-84-2.png"'
+ ' descr="Tabell 84-2 Toleranseklasser"/><pic:cNvPicPr/></pic:nvPicPr>'
+ ' descr="Tabell 84-2 Hevetidsklasser"/><pic:cNvPicPr/></pic:nvPicPr>'
+ '<pic:blipFill><a:blip r:embed="rIdImg"/><a:stretch><a:fillRect/></a:stretch>'
+ '</pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/>'
+ '<a:ext cx="381000" cy="285750"/></a:xfrm>'
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>'
+ "</a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"
+ "<w:p><w:r><w:t>Etter tabellen gjelder NS-EN 13670.</w:t></w:r></w:p>"
+ "<w:p><w:r><w:t>Etter tabellen kommer steketidene.</w:t></w:r></w:p>"
+ "</w:body></w:document>",
}
return build_container(parts)
@ -283,7 +283,7 @@ def build_pptx() -> bytes:
+ "<p:grpSpPr/>"
+ '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Tittel 1"/><p:cNvSpPr/>'
+ '<p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:spPr/>'
+ "<p:txBody><a:bodyPr/><a:p><a:r><a:t>Toleranser</a:t></a:r></a:p></p:txBody></p:sp>"
+ "<p:txBody><a:bodyPr/><a:p><a:r><a:t>Hevetider</a:t></a:r></a:p></p:txBody></p:sp>"
+ '<p:pic><p:nvPicPr><p:cNvPr id="3" name="skisse.png" descr="Prinsippskisse"/>'
+ "<p:cNvPicPr/><p:nvPr/></p:nvPicPr>"
+ '<p:blipFill><a:blip r:embed="rIdImg"/><a:stretch><a:fillRect/></a:stretch>'
@ -310,17 +310,17 @@ if __name__ == "__main__":
# FIVE DISTINCT STEMS. One stem across five extensions is refused by the
# door's own SS 3 collision rule -- measured while building this fixture:
# `prosess-84.{pdf,docx,pptx}` gave `inbox_slug_collision: 2/7` and two of
# `kapittel-84.{pdf,docx,pptx}` gave `inbox_slug_collision: 2/7` and two of
# the five readers were never exercised at all, with the gate reporting a
# carrying defect that was really a fixture defect.
written: list[tuple[str, bytes]] = [
("graphics/tabell-84-2.png", png(120, 90, value=0x20)),
("graphics/figur-84-1.png", png(64, 48, value=0x80)),
("prosess-84-web.html", HTML_DOCUMENT.encode("utf-8")),
("prosess-84-sts.xml", STS_DOCUMENT.encode("utf-8")),
("prosess-84-tabell.pdf", build_image_pdf()),
("prosess-84-notat.docx", build_docx()),
("prosess-84-presentasjon.pptx", build_pptx()),
("kapittel-84-web.html", HTML_DOCUMENT.encode("utf-8")),
("kapittel-84-sts.xml", STS_DOCUMENT.encode("utf-8")),
("kapittel-84-tabell.pdf", build_image_pdf()),
("kapittel-84-notat.docx", build_docx()),
("kapittel-84-presentasjon.pptx", build_pptx()),
]
for name, payload in written:
(IMAGES / name).write_bytes(payload)

View file

@ -32,28 +32,28 @@ OUT = HERE / "k2-office"
#: containers would enter that run and the denominator would stop being three.
RTF_OUT = HERE / "k2-rtf-variants"
TITLE = "Kravspesifikasjon for tunnelbelysning"
INTRO = "Dokumentet samler kravene til belysning i vegtunneler over 500 meter."
TITLE = "Kravspesifikasjon for kjøkkenbelysning"
INTRO = "Dokumentet samler kravene til belysning i storkjøkken over 500 kvadratmeter."
# The pairing table. Every label ends in a colon, which is the rule
# `okf_fidelity.label_value_rows` selects on -- so these twenty rows ARE the
# pairable denominator, and a converter that drops a value or breaks a row over
# two output lines shows up as a fall from 20.
PAIRS: tuple[tuple[str, str], ...] = (
("Dokumentnummer:", "SVV-2026-0417"),
("Tittel:", "Tunnelbelysning i hovedløpet"),
("Ansvarlig avdeling:", "Utbyggingsdivisjonen"),
("Dokumentnummer:", "EKS-2026-0417"),
("Tittel:", "Kjøkkenbelysning i hovedsalen"),
("Ansvarlig avdeling:", "Kjøkkendivisjonen"),
("Fagområde:", "Elektro og belysning"),
("Versjon:", "2.3 godkjent"),
("Gyldig fra:", "01.03.2026"),
("Erstatter:", "SVV-2024-0188"),
("Tunnellengde:", "1240 meter"),
("Dimensjonerende fart:", "80 km/t"),
("Årsdøgntrafikk:", "12400 kjøretøy"),
("Erstatter:", "EKS-2024-0188"),
("Salens lengde:", "42 meter"),
("Dimensjonerende gjester:", "80 kuverter"),
("Årsproduksjon:", "12400 porsjoner"),
("Terskelluminans:", "145 candela"),
("Overgangssone:", "Tre trinn nedtrapping"),
("Innerstrekning:", "3,0 candela"),
("Utkjøringssone:", "Ingen forsterkning"),
("Innerste sone:", "3,0 candela"),
("Utgangssone:", "Ingen forsterkning"),
("Fargetemperatur:", "4000 kelvin"),
("Fargegjengivelse:", "Ra større enn 70"),
("Nødbelysning:", "60 minutter drift"),
@ -70,7 +70,7 @@ PAIRS: tuple[tuple[str, str], ...] = (
# empty `\\pard\\par` left the converter joining the 2-column rows and the
# 4-column rows into a single four-column table, so the grid stopped being a
# second table at all.
GRID_CAPTION = "Luminansmatrise per trafikklasse"
GRID_CAPTION = "Luminansmatrise per romklasse"
GRID: tuple[tuple[str, ...], ...] = (
("Sone", "Klasse A", "Klasse B", "Klasse C"),
@ -342,9 +342,11 @@ def _rtf_row(row: tuple[str, ...]) -> str:
# is, not how much text survives -- variant A already carries the coverage and
# fidelity counts for the container.
RTF_VARIANT_TITLE = "Kravspesifikasjon for tunnelbelysning"
RTF_VARIANT_SECTION = "Luminanskrav per trafikklasse"
RTF_VARIANT_BODY_ONE = "Dokumentet samler kravene til belysning i vegtunneler over 500 meter."
RTF_VARIANT_TITLE = "Kravspesifikasjon for kjøkkenbelysning"
RTF_VARIANT_SECTION = "Luminanskrav per romklasse"
RTF_VARIANT_BODY_ONE = (
"Dokumentet samler kravene til belysning i storkjøkken over 500 kvadratmeter."
)
RTF_VARIANT_BODY_TWO = "Terskelluminansen er 145 candela og gjelder i hele overgangssonen."
#: The bold RUN in `-fet-i-avsnitt`, set inside a sentence that continues past
#: it. Authored as emphasis, never as a title.
@ -376,7 +378,7 @@ def rtf_bold_inside_paragraph_bytes() -> bytes:
+ "\\b "
+ _rtf_escape(RTF_VARIANT_EMPHASIS)
+ "\\b0 "
+ " fra byggherren for arbeidet starter."
+ " fra kjokkensjefen for arbeidet starter."
)
return _rtf_document(
[

Binary file not shown.

View file

@ -13,9 +13,9 @@ endobj
stream
BT /F2 20 Tf 50 700 Td (Forord) Tj ET
BT /F1 10 Tf 50 670 Td (1 Generelle krav) Tj ET
BT /F1 10 Tf 50 640 Td (Utkilingen skal ha helning 1:15.) Tj ET
BT /F1 10 Tf 50 640 Td (Deigkulene skal ha hevetid 1:15.) Tj ET
BT /F1 10 Tf 50 610 Td (2 Merking) Tj ET
BT /F1 10 Tf 50 580 Td (Kravet gjelder alle veiklasser.) Tj ET
BT /F1 10 Tf 50 580 Td (Kravet gjelder alle bakeformer.) Tj ET
BT /F1 10 Tf 50 550 Td (3 Vedlegg) Tj ET
BT /F1 10 Tf 50 520 Td (Vedlegget er eget oppslag.) Tj ET
endstream

View file

@ -9,9 +9,9 @@ endobj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 112 >>
<< /Length 114 >>
stream
BT /F1 12 Tf 20 170 Td (R761 Prosesskoden) Tj ET
BT /F1 12 Tf 20 170 Td (P761 Oppskriftsboka) Tj ET
BT /F1 12 Tf 20 150 Td (Innledende tekst om grunnlaget.) Tj ET
endstream
endobj
@ -22,10 +22,10 @@ endobj
<< /Type /Outlines /First 7 0 R /Last 7 0 R /Count 2 >>
endobj
7 0 obj
<< /Title (R761 Prosesskoden) /Parent 6 0 R /First 8 0 R /Last 8 0 R /Count 1 /Dest [3 0 R /XYZ 20 185 0] >>
<< /Title (P761 Oppskriftsboka) /Parent 6 0 R /First 8 0 R /Last 8 0 R /Count 1 /Dest [3 0 R /XYZ 20 185 0] >>
endobj
8 0 obj
<< /Title (SVV - Forside) /Parent 7 0 R /Dest [3 0 R /XYZ 20 185 0] >>
<< /Title (EKS - Forside) /Parent 7 0 R /Dest [3 0 R /XYZ 20 185 0] >>
endobj
xref
0 9
@ -34,12 +34,12 @@ xref
0000000074 00000 n
0000000131 00000 n
0000000257 00000 n
0000000419 00000 n
0000000516 00000 n
0000000587 00000 n
0000000711 00000 n
0000000421 00000 n
0000000518 00000 n
0000000589 00000 n
0000000715 00000 n
trailer
<< /Size 9 /Root 1 0 R >>
startxref
797
801
%%EOF

View file

@ -2,21 +2,21 @@
<standard>
<front>
<std-doc-meta>
<title-wrap xml:lang="nb"><intro></intro><main></main><compl></compl><full>R900 Testnormalen Standard for fiksturer, tester og kontroll</full></title-wrap>
<std-ident><originator>TST</originator><doc-type>Innledning</doc-type><doc-number>R900 Testnormalen</doc-number><isbn>000-00-0000-000-0</isbn><year>2024</year></std-ident>
<std-ref type="dated">R900 Testnormalen:2024</std-ref>
<title-wrap xml:lang="nb"><intro></intro><main></main><compl></compl><full>P900 Testnormalen Standard for fiksturer, tester og kontroll</full></title-wrap>
<std-ident><originator>TST</originator><doc-type>Innledning</doc-type><doc-number>P900 Testnormalen</doc-number><isbn>000-00-0000-000-0</isbn><year>2024</year></std-ident>
<std-ref type="dated">P900 Testnormalen:2024</std-ref>
</std-doc-meta>
</front>
<body>
<sec id="s-88" sec-type="procedure">
<label>88</label>
<title>Rekkverk</title>
<title>Gelender</title>
<sec id="s-88-612" sec-type="procedure">
<label>88.612</label>
<title>Utskifting av enkeltkomponenter</title>
<sec id="s-88-612-a" sec-type="spec">
<label>a)</label>
<p>Omfatter utskifting av skadde enkeltkomponenter i rekkverk.</p>
<p>Omfatter utskifting av skadde enkeltkomponenter i gelender.</p>
</sec>
<sec id="s-88-612-b" sec-type="spec">
<label>b)</label>
@ -29,10 +29,10 @@
</sec>
<sec id="s-88-613" sec-type="procedure">
<label>88.613</label>
<title>Maling av rekkverk</title>
<title>Maling av gelender</title>
<sec id="s-88-613-a" sec-type="spec">
<label>a)</label>
<p>Omfatter maling av rekkverk.</p>
<p>Omfatter maling av gelender.</p>
<p>Et andre avsnitt i samme punkt blir ikke med.</p>
</sec>
</sec>

View file

@ -7,11 +7,11 @@
</front>
<body>
<sec sec-type="procedure" id="s7"><label>7</label><title>Vedlikehold av utstyr</title>
<sec sec-type="spec"><label>a)</label><p>Omfatter vedlikehold av alt utstyr langs vegen.</p></sec>
<sec sec-type="procedure" id="s7-6"><label>7.6</label><title>Rekkverk</title>
<sec sec-type="spec"><label>a)</label><p>Omfatter vedlikehold av alt utstyr i kjøkkenet.</p></sec>
<sec sec-type="procedure" id="s7-6"><label>7.6</label><title>Gelender</title>
<sec sec-type="procedure" id="s7-61"><label>7.61</label><title>Utskifting</title>
<sec sec-type="procedure" id="s7-612"><label>7.612</label><title>Utskifting av enkeltdeler</title>
<sec sec-type="spec"><label>a)</label><p>Omfatter utskifting av skadde enkeltdeler i rekkverk.</p></sec>
<sec sec-type="spec"><label>a)</label><p>Omfatter utskifting av skadde enkeltdeler i gelender.</p></sec>
<sec sec-type="procedure" id="s7-6121"><label>7.6121</label><title>Utskifting av list</title></sec>
<sec sec-type="procedure" id="s7-6122"><label>7.6122</label><title>Utskifting av stolpe</title></sec>
</sec>

View file

@ -11,7 +11,7 @@ endobj
4 0 obj
<< /Length 50 >>
stream
BT /F1 12 Tf 20 160 Td (Side en om helning) Tj ET
BT /F1 12 Tf 20 160 Td (Side en om hevetid) Tj ET
endstream
endobj
5 0 obj
@ -29,7 +29,7 @@ endobj
8 0 obj
<< /Length 52 >>
stream
BT /F1 12 Tf 20 160 Td (Side tre om utkiling) Tj ET
BT /F1 12 Tf 20 160 Td (Side tre om deigkule) Tj ET
endstream
endobj
9 0 obj

Binary file not shown.

View file

@ -11,7 +11,7 @@ endobj
4 0 obj
<< /Length 107 >>
stream
BT /F1 12 Tf 20 160 Td (Krav til helning på utkilingen) Tj ET
BT /F1 12 Tf 20 160 Td (Krav til hevetid på deigkulene) Tj ET
BT /F1 12 Tf 20 140 Td (60 og 70 1:15) Tj ET
endstream
endobj

Binary file not shown.

View file

@ -63,8 +63,7 @@ from pathlib import Path
fixtures = Path({fixtures!r})
w.witness_inbox(fixtures / "corpus")
w.witness_inbox(fixtures / "rejected")
w.count_sts_json((fixtures / "witness" / "prosess-84-sts.twin.json").read_bytes())
w.pdf_poppler(fixtures / "corpus" / "prosess-84-tabell.pdf")
w.pdf_poppler(fixtures / "corpus" / "kapittel-84-tabell.pdf")
print(sorted(m for m in sys.modules if m.split(".")[0] == "llm_ingestion_okf"))
"""
@ -127,7 +126,7 @@ HAND_COUNTS = {
},
"krav-rikt-tekstformat.rtf": {"cell": 56, "image": 0, "paragraph": 3, "table_row": 24},
"bilde.rtf": {"cell": 0, "image": 1, "paragraph": 2, "table_row": 0},
"prosess-84-sts.xml": {
"kapittel-84-sts.xml": {
"cell": 0,
"citation": 0,
"figure": 0,
@ -192,8 +191,8 @@ HAND_COUNTS = {
"logg.txt": {"line": 4, "paragraph": 3},
"mengder.csv": {"cell": 6, "header_cell": 3, "row": 2},
"parametre.json": {"key": 6, "value": 6},
"prosess-84-tabell.pdf": {"image": 2, "page": 1},
"prosess-84-web.html": {
"kapittel-84-tabell.pdf": {"image": 2, "page": 1},
"kapittel-84-web.html": {
"cell": 0,
"heading": 1,
"image": 3,
@ -201,7 +200,7 @@ HAND_COUNTS = {
"paragraph": 3,
"table": 0,
},
"prosess-84-notat.docx": {
"kapittel-84-notat.docx": {
"cell": 0,
"comment": 0,
"endnote": 0,
@ -213,7 +212,7 @@ HAND_COUNTS = {
"table": 0,
"text_box": 0,
},
"prosess-84-presentasjon.pptx": {
"kapittel-84-presentasjon.pptx": {
"cell": 0,
"hidden_slide": 0,
"image": 1,
@ -351,103 +350,6 @@ def test_the_sts_witness_counts_citations_formulas_and_figure_captions() -> None
assert inventory.elements[role] > 0, role
def test_the_json_role_map_reads_a_prefixed_tag_and_the_publishers_figcaption() -> None:
"""H3, measured by PM on N200 2026-09-18: the delivery carries 74
`mml:math` and 49 `figcaption`, and the witness counted 0 and 0.
`count_sts_xml` takes every tag through `_local`, which strips both a
Clark-notation namespace and a prefix; `count_sts_json` compared the RAW
string, so `mml:math` never reached `tag == "math"`. And the publisher's
JSON puts a figure's caption in `figcaption` under the `graphic`, not in
the `fig/caption` NISO-STS writes -- measured over the eight deliveries on
this machine: 430 `figcaption`, 0 of them under a `caption`.
The fixture is the JSON half of `sts-rikt.xml`'s family, and both halves
of the defect are in it."""
twin = gate.FIXTURES / "witness" / "sts-mml-and-figcaption.twin.json"
counts = witness.count_sts_json(twin.read_bytes()).counts
assert counts["math"] == 1
assert counts["figure_caption"] == 1
def test_a_prefixed_tag_that_names_no_role_stays_uncounted() -> None:
"""The known-negative for stripping the prefix: N101 ships 568 `tbx:`
tags, and not one of their local names is a role. A rule that generalises
is measured against what it must NOT take."""
for tag in ("tbx:term", "tbx:definition", "tbx:note", "tbx:termEntry"):
assert witness._sts_role_json(witness._local(tag), "sec", "body") is None
#: THE ONE PLACE THIS NUMBER LIVES. The count itself was measured over the
#: delivery by a `skipif`-gated test that read a file only this machine has
#: (removed 2026-09-21 with the retired test track), so on a fresh clone the
#: four published sentences were unguarded again, which is how
#: 574 survived in four docstrings until PM counted it. The guard below needs
#: no delivery: it reads the published sentences and holds them to each other
#: and to this constant.
N101_TBX_TAGS = 568
#: The published sentence, in the five files that carry it. Anchored on `N101`
#: and on the literal `tbx:` that follows the number, so it cannot match some
#: other count standing nearby.
_PUBLISHED_TBX = re.compile(r"N101(?:'s)?[^.]{0,80}?\*{0,2}(\d+)\*{0,2}\s*`tbx:`")
#: Every file that states it. A sentence moving to a sixth file without being
#: added here is the residual, and it is the same residual the constant
#: replaces one level down -- this list is what keeps the number readable in
#: one place rather than five.
_TBX_PUBLISHERS = (
"CHANGELOG.md",
"CLAUDE.md",
"tools/okf_witness.py",
"tests/test_accounting_gate.py",
"docs/2026-09-19-regnskapsgaten-rest-og-normaliseringsdoren.md",
)
def test_the_published_tbx_count_is_one_number_and_needs_no_delivery() -> None:
"""The published strings are held to each other, on any machine.
This is the half the measurement could not cover. The delivery count
cannot run where the delivery is absent, and an assertion that skips
guards nothing. Editing `CLAUDE.md` to
600 tomorrow is red here, on a fresh clone, with no corpus.
It proves nothing about the WORLD -- five files agreeing is agreement, not
a count -- and this test only holds the sentences to the constant it
asserts.
"""
root = Path(__file__).resolve().parents[1]
for name in _TBX_PUBLISHERS:
path = root / name
assert path.is_file(), f"{name}: the file that publishes the count is gone"
# Whitespace-folded first: the sentence wraps differently in each file.
text = " ".join(path.read_text(encoding="utf-8").split())
found = _PUBLISHED_TBX.findall(text)
assert found, f"{name}: the published sentence is gone, or no longer says `tbx:`"
assert [int(value) for value in found] == [N101_TBX_TAGS] * len(found), (
f"{name}: publishes {found}, and the number this repository stands behind "
f"is {N101_TBX_TAGS}"
)
# KNOWN-POSITIVE for the pattern itself: it must find a number that is
# NOT the published one, or the loop above could be passing over nothing.
# Assembled from pieces so the scan above does not read this line as a
# sixth publisher -- written whole, it made the guard red on its own
# fixture, which is also the clearest demonstration that the scan reads
# the file it is pointed at.
wrong = "N101 ships " + "574" + " `tbx:` tags"
assert _PUBLISHED_TBX.findall(wrong) == ["574"]
def test_the_two_sts_role_maps_are_written_twice_and_not_shared() -> None:
"""M-2: both STS witnesses went through ONE `_sts_role`, so row 5 could
never see a hole in it. Two maps, each written for its own delivery."""
assert witness._sts_role_xml is not witness._sts_role_json
assert "_sts_role_json" not in witness._sts_role_xml.__code__.co_names
assert "_sts_role_xml" not in witness._sts_role_json.__code__.co_names
def test_every_witnessed_type_says_what_it_does_not_count() -> None:
assert set(witness.NOT_COUNTED) == set(witness.WITNESSED_SUFFIXES)
assert all(witness.NOT_COUNTED[suffix] for suffix in witness.WITNESSED_SUFFIXES)
@ -543,7 +445,8 @@ def test_the_judge_proves_a_CONVERTED_image_was_carried(tmp_path: Path) -> None:
those bytes. That is right for an image carried verbatim and wrong for one
the build converts: the run did carry the picture, under a new digest the
bundle STATES, and a judge that only knew the first rule reported 19 of
R761's 50 images as claimed-and-not-found the day the conversion landed.
a real delivery's 50 images as claimed-and-not-found the day the conversion
landed.
The second route is not the build's naming rule restated. The gate reads
the two digests the bundle writes, and then HASHES the asset itself: the
@ -656,7 +559,7 @@ def test_a_document_cannot_forge_a_conversion_claim(tmp_path: Path) -> None:
other = "0" * 64
arms = {
"plain body text": f"{pointer}\n\nProsess 84. {clause}. Se figuren over.\n",
"plain body text": f"{pointer}\n\nKapittel 84. {clause}. Se figuren over.\n",
"a table cell": f"{pointer}\n\n| Krav | Kilde |\n| --- | --- |\n| 84-1 | {clause} |\n",
"a figure caption": f"{pointer}\n\nFigur 84-1 -- {clause}\n",
"a fenced code block": f"{pointer}\n\n```\n{clause}\n```\n",
@ -748,10 +651,10 @@ def test_the_build_never_writes_a_claim_the_document_supplied(tmp_path: Path) ->
before = gate._sha256(never_carried)
after = gate._sha256(real)
clause = f"converted from image/bmp sha256:{before} to image/png sha256:{after}"
(corpus / "prosess.html").write_text(
"<!doctype html>\n<html><head><title>Prosess 84</title></head>\n<body>\n"
"<h1>84 Konstruksjoner av betong</h1>\n"
"<p>Toleranseklassene staar i figuren under.</p>\n"
(corpus / "kapittel.html").write_text(
"<!doctype html>\n<html><head><title>Kapittel 84</title></head>\n<body>\n"
"<h1>84 Brød og boller</h1>\n"
"<p>Hevetidsklassene staar i figuren under.</p>\n"
f'<img src="graphics/ekte.png" alt="Figur 84-1 -- {clause}">\n'
"<p>Og den store figuren:</p>\n"
'<img src="graphics/figur.bmp" alt="Figur 84-2">\n'
@ -803,9 +706,9 @@ def _forgery_corpus(root: Path, body: str, note: str | None = None) -> tuple[Pat
(inbox / "graphics" / "figur.bmp").write_bytes(_small_bmp())
before = gate._sha256(never_carried)
after = gate._sha256(real)
(inbox / "prosess.html").write_text(
"<!doctype html>\n<html><head><title>Prosess 84</title></head>\n<body>\n"
"<h1>84 Konstruksjoner av betong</h1>\n"
(inbox / "kapittel.html").write_text(
"<!doctype html>\n<html><head><title>Kapittel 84</title></head>\n<body>\n"
"<h1>84 Brød og boller</h1>\n"
'<img src="graphics/stor.bmp" alt="Stor">'
'<img src="graphics/ekte.png" alt="Ekte">'
'<img src="graphics/figur.bmp" alt="Figur">\n'
@ -859,8 +762,8 @@ def test_a_document_cannot_forge_the_WHOLE_pointer_block(tmp_path: Path) -> None
# both digests are readable from a bundle this build already wrote.
inbox, before, after = _forgery_corpus(root, "<p>placeholder</p>")
shaped = {"before": before, "after": after, "after12": after[:12]}
(inbox / "prosess.html").write_text(
(inbox / "prosess.html")
(inbox / "kapittel.html").write_text(
(inbox / "kapittel.html")
.read_text(encoding="utf-8")
.replace("<p>placeholder</p>", body.format(**shaped)),
encoding="utf-8",
@ -1318,14 +1221,14 @@ def test_an_asset_with_the_right_name_and_the_wrong_bytes_is_not_carried(tmp_pat
def test_an_asset_under_a_reduced_name_still_proves_the_carry(tmp_path: Path) -> None:
"""The build lowercases and folds the source's basename and sniffs the
suffix from the bytes. Measured on R761, a judge checking the FULL name
suffix from the bytes. Measured on a real delivery, a judge checking the FULL name
called 50 of 50 carried images missing -- the judge's defect, not the
build's, so the content address is the check and the readable tail is not.
"""
corpus = _corpus(tmp_path)
source = corpus / "graphics" / "x.png"
digest = gate._sha256(source)
reduced = {f"{digest[:12]}-25-0143-tabeller-r761-r762.jpeg": digest}
reduced = {f"{digest[:12]}-25-0143-tabeller-p761-p762.jpeg": digest}
build = _build(accounting=_declared(fate="carried"), assets=reduced)
assert gate.account(_inventory(), build, corpus)[1].clean
@ -1400,8 +1303,8 @@ def test_row5_is_red_when_a_witness_is_missing() -> None:
def test_row6_without_its_source_is_red_locally_and_skipped_in_ci(tmp_path: Path) -> None:
missing = tmp_path / "absent"
local = gate.row6(missing, None, ci=False)
ci = gate.row6(missing, None, ci=True)
local = gate.row6([missing], ci=False)
ci = gate.row6([missing], ci=True)
assert (local.status, local.fails) == (gate.RED, True)
assert (ci.status, ci.fails) == (gate.SKIPPED, False)
assert "source missing" in ci.reason
@ -1416,7 +1319,7 @@ def test_a_skipped_row_never_leaves_the_verdict_unqualified() -> None:
def test_a_corpus_refused_whole_under_the_default_gate_is_red(tmp_path: Path) -> None:
"""Row 6 was GREEN with R761 100 % rejected: every element booked as a
"""Row 6 was GREEN with a real corpus 100 % rejected: every element booked as a
coded rejection satisfies u = 0 and d = 0. The build order asked for an
honest red there, so the row says this on its own."""
inventory = {
@ -1453,7 +1356,7 @@ def test_a_corpus_refused_whole_under_the_default_gate_is_red(tmp_path: Path) ->
def test_a_corpus_whose_every_document_has_no_declared_fate_says_so() -> None:
"""H6: N200 contributes one blank red. `okf build` proposes 0 plans on it
"""H6: a JSON corpus contributes one blank red. `okf build` proposes 0 plans on it
and FAILS (exit 2) before the accounting door is reached -- reproduced
2026-09-19: no accounting file is written at all -- so all 16 549 elements
land as `u` with `no declared fates` and the corpus measures none of the
@ -1479,11 +1382,19 @@ def test_a_corpus_with_one_declared_document_is_not_called_blank() -> None:
assert gate.measures_no_class([]) is None
def test_the_two_real_corpora_are_named_and_the_second_is_not_r761() -> None:
"""R761 holds 0 `fig`, 0 formulas and 0 references, so the gate's only
real corpus could not see the hole in the STS role map."""
corpora = gate.real_corpora(Path("/r761"), Path("/n200.json"))
assert [c.label.split()[0] for c in corpora] == ["R761", "N200"]
def test_a_real_corpus_is_named_by_its_path_and_read_by_its_kind(tmp_path: Path) -> None:
"""Row 6 knows no corpus by name: each `--corpus` path is one corpus,
labelled by its own file name and read as a zip, a directory or one file."""
folder = tmp_path / "samling"
folder.mkdir()
corpora = gate.real_corpora([tmp_path / "levering.zip", folder, tmp_path / "ett.json"])
assert [(c.label, c.kind) for c in corpora] == [
("levering.zip", "zip"),
("samling", "dir"),
("ett.json", "file"),
]
assert [c.available for c in corpora] == [False, True, False]
assert gate.real_corpora([]) == []
def test_a_unit_clean_in_only_one_of_the_two_builds_is_not_clean() -> None:
@ -1523,7 +1434,7 @@ def test_the_gate_exits_one_when_a_row_is_red(monkeypatch: pytest.MonkeyPatch) -
pytest.importorskip("pdfplumber")
pytest.importorskip("pypandoc")
monkeypatch.delenv("CI", raising=False)
code = gate.main(["--r761", "/no/such/corpus", "--n200", "/no/such/file.json"])
code = gate.main(["--corpus", "/no/such/corpus", "--corpus", "/no/such/file.json"])
assert code == 1
@ -1560,17 +1471,6 @@ def test_a_rejection_code_inside_an_element_fate_must_also_be_known(tmp_path: Pa
assert "because_i_said_so" in "; ".join(unit.notes)
def test_the_json_twin_is_read_with_the_json_role_map(tmp_path: Path) -> None:
"""M-2, measured on R761 2026-09-18: the XML delivery places a section's
label at `sec/label` (7 714, and 0 inside a title); the JSON delivery puts
2 760 of them inside the title. Read with the XML map, the twin loses
every one of those."""
twin = gate.FIXTURES / "witness" / "sts-label-in-title.twin.json"
assert witness.count_sts_json(twin.read_bytes()).counts["section_label"] == 1
assert witness._sts_role_xml("label", "title", "sec") is None
assert witness._sts_role_json("label", "title", "sec") == "section_label"
def test_an_approved_exception_is_read_and_says_what_it_does() -> None:
"""m-3: `APPROVED_EXCEPTIONS` was read by no row, so approving one changed
nothing and the list could have said anything."""
@ -1627,7 +1527,7 @@ def test_bad_usage_exits_two() -> None:
def real_rows() -> list[gate.Row]:
pytest.importorskip("pdfplumber")
pytest.importorskip("pypandoc")
return gate.evaluate(r761=None, n200=None, ci=True, consume=False)
return gate.evaluate(ci=True, consume=False)
def _cheating_report(inventory: dict[str, Any], mode: str) -> dict[str, Any]:
@ -1677,8 +1577,8 @@ def test_the_door_exists() -> None:
def test_the_real_gate_names_what_the_build_does_not_account_for(
real_rows: list[gate.Row],
) -> None:
"""Rows 1-5 against the real `okf build`; row 6 needs R761 and is skipped
here.
"""Rows 1-5 against the real `okf build`; row 6 needs a real corpus and is
skipped here.
Rows 2 and 3 were GREEN at `864570b` and are RED now, and that is the
hardening working rather than a regression: the witness counts thirteen
@ -1818,7 +1718,7 @@ def test_a_bundle_with_no_ledger_says_why_a_converted_image_cannot_be_proved() -
That is the honest reading, and until now it was a silent one: the images
concerned are counted as claimed-and-not-found with no statement that the
run carried no ledger to prove them by. R761 read 19 that way the day
run carried no ledger to prove them by. A real delivery read 19 that way the day
before the ledger existed. The gate itself always passes the flag, so this
is for every OTHER reader of a doorless bundle.
"""

View file

@ -14,11 +14,11 @@ editable tree -- an editable install reads `src/` live, so a "before" run taken
in this working tree would have been measuring the change it was supposed to
predate:
carried 0 of 2 local (2 declared) prosess-84-tabell.pdf
carried 0 of 1 local (1 declared) prosess-84-notat.docx
carried 0 of 1 local (1 declared) prosess-84-presentasjon.pptx
carried 0 of 2 local (3 declared) prosess-84-web.html
carried 0 of 2 local (2 declared) prosess-84-sts.xml
carried 0 of 2 local (2 declared) kapittel-84-tabell.pdf
carried 0 of 1 local (1 declared) kapittel-84-notat.docx
carried 0 of 1 local (1 declared) kapittel-84-presentasjon.pptx
carried 0 of 2 local (3 declared) kapittel-84-web.html
carried 0 of 2 local (2 declared) kapittel-84-sts.xml
---------------------------------------------------------------
carried 0 of 8 local images across 5 documents (9 declared),
and the bundle held no `assets/` directory at all.
@ -31,8 +31,8 @@ is this one.
WHY THE GATE IS A BUNDLE-LEVEL COUNT and not a per-function assertion: the
failure this exists to stop is not "the reader returned nothing". It is a
bundle that looks complete and is not -- R761's process 84 saying "toleranse-
klasse ... er gitt i tabell 84-2" over an empty space, with `okf check` green
bundle that looks complete and is not -- a real reference standard saying
"... er gitt i tabell 84-2" over an empty space, with `okf check` green
and `okf quality` unable to see it. So the gate runs the real command, walks
the written bundle, and asks whether the bytes are there.
@ -62,11 +62,11 @@ OKF_VERSION = "0.2"
#: Every document in the fixture inbox, with the reader it exercises.
DOCUMENTS = (
"prosess-84-tabell.pdf",
"prosess-84-notat.docx",
"prosess-84-presentasjon.pptx",
"prosess-84-web.html",
"prosess-84-sts.xml",
"kapittel-84-tabell.pdf",
"kapittel-84-notat.docx",
"kapittel-84-presentasjon.pptx",
"kapittel-84-web.html",
"kapittel-84-sts.xml",
)
@ -218,7 +218,7 @@ def test_a_remote_image_is_a_pointer_without_a_file_never_a_silent_drop(
) -> None:
"""The network boundary, stated in the artifact rather than implied by absence."""
bundle, _ = built
html = [path for path in _concepts(bundle) if "prosess-84-web.html" in path.read_text("utf-8")]
html = [path for path in _concepts(bundle) if "kapittel-84-web.html" in path.read_text("utf-8")]
assert html, "the html document produced no concept at all"
body = "\n".join(path.read_text("utf-8") for path in html)
assert "https://example.invalid/ekstern.png" in body, (

View file

@ -20,7 +20,7 @@ read an `<img>` attribute or opened an image stream at all.
The limit is READ OFF the corpora rather than chosen: over the 4 828 image
objects of the 43-document reference corpus the largest is 4 515 x 4 128
(18.6 MP, a landscape drawing), and over R761's 109 delivered pictures the
(18.6 MP, a landscape drawing), and over one reference delivery's 109 pictures the
largest is 2 072 x 656 (1.4 MP). `MAX_IMAGE_PIXELS` sits above both with
room to spare, and anything larger is a counted refusal rather than a
killed build.
@ -206,7 +206,7 @@ def _bomb(
def test_the_limit_is_above_every_image_measured_in_the_corpora() -> None:
"""4 515 x 4 128 = 18.6 MP is the largest of the 4 828 objects measured in
the reference corpus; R761's largest delivered picture is 1.4 MP."""
the reference corpus; one reference delivery's largest picture is 1.4 MP."""
assert assets.MAX_IMAGE_PIXELS > 4515 * 4128
assert assets.MAX_IMAGE_BYTES >= assets.MAX_IMAGE_PIXELS

View file

@ -24,23 +24,23 @@ from __future__ import annotations
from llm_ingestion_okf.structure import derive_document_structure
POINTER = (
"# 84 Konstruksjoner\n\n"
"Toleranseklasse er gitt i tabell 84-2, jf. N500.\n\n"
"# 84 Boller\n\n"
"Hevetidsklasse er gitt i tabell 84-2, jf. Q500.\n\n"
"![Tabell 84-2](/assets/e54e5f5da0e8-tabell-84-2.png)\n"
"Image: graphics/tabell-84-2.png (120x90 px) -- Tabell 84-2\n\n"
"Se ellers [N100](n100.md).\n"
"Se ellers [Q100](q100.md).\n"
)
def test_an_image_pointer_is_not_a_reference() -> None:
structure = derive_document_structure(POINTER, source_file="r761.xml")
structure = derive_document_structure(POINTER, source_file="p761.xml")
assert "/assets/e54e5f5da0e8-tabell-84-2.png" not in structure.references
def test_the_links_beside_it_still_are() -> None:
"""The known-positive in the same text: masking must not eat real edges."""
structure = derive_document_structure(POINTER, source_file="r761.xml")
assert "n100.md" in structure.references
structure = derive_document_structure(POINTER, source_file="p761.xml")
assert "q100.md" in structure.references
def test_the_asset_name_does_not_leak_a_number_into_references() -> None:
@ -50,6 +50,6 @@ def test_the_asset_name_does_not_leak_a_number_into_references() -> None:
would move every later offset and break first-appearance order, and leaving
it would let the number scan read an identifier out of a file name.
"""
structure = derive_document_structure(POINTER, source_file="r761.xml")
structure = derive_document_structure(POINTER, source_file="p761.xml")
assert all("ASSETS" not in subject.upper() for subject in structure.references)
assert "N500" in structure.references
assert "Q500" in structure.references

View file

@ -1,7 +1,7 @@
"""Every image a bundle carries must be one a model can actually SHOW.
0.10.0 carried what a document declared, in whatever format the publisher
happened to ship. Measured 2026-09-19 over the frozen R761 delivery's own
happened to ship. Measured 2026-09-19 over a frozen reference delivery's own
`assets/` directory (denominator 50): 29 JPEG, 2 PNG and **19 "PC bitmap,
Windows 3.x, 8-bit, compression 1"** -- RLE8 BMP. The 19 are carried byte for
byte and are correct files; they are also files no model reads, so a figure
@ -128,7 +128,7 @@ def _file_header(offbits: int, body: int) -> bytes:
def bmp_rle8(width: int, height: int, palette: bytes, stream: bytes) -> bytes:
"""A Windows 3.x 8-bit RLE8 BMP, the shape 19 of R761's 50 assets have."""
"""A Windows 3.x 8-bit RLE8 BMP, the shape 19 of a real delivery's 50 assets have."""
entries = len(palette) // 3
table = b"".join(
bytes((palette[i * 3 + 2], palette[i * 3 + 1], palette[i * 3], 0)) for i in range(entries)
@ -226,11 +226,11 @@ def _inbox(root: Path) -> Path:
(inbox / "graphics" / "figur-rle8.bmp").write_bytes(bmp_rle8(8, 4, PALETTE, RLE8_STREAM))
(inbox / "graphics" / "figur-flat.bmp").write_bytes(bmp_24(RGB_ROWS))
(inbox / "graphics" / "skjema.tiff").write_bytes(TIFF_STUB)
(inbox / "prosess.html").write_text(
"<!doctype html>\n<html><head><title>Prosess 84</title></head>\n<body>\n"
"<h1>84 Konstruksjoner av betong</h1>\n"
"<p>Toleranseklassene staar i figuren under.</p>\n"
'<img src="graphics/figur-rle8.bmp" alt="Figur 84-1 Toleranseklasser">\n'
(inbox / "kapittel.html").write_text(
"<!doctype html>\n<html><head><title>Kapittel 84</title></head>\n<body>\n"
"<h1>84 Brød og boller</h1>\n"
"<p>Hevetidsklassene staar i figuren under.</p>\n"
'<img src="graphics/figur-rle8.bmp" alt="Figur 84-1 Hevetidsklasser">\n'
"<p>Og prinsippet:</p>\n"
'<img src="graphics/figur-flat.bmp" alt="Figur 84-2 Prinsipp">\n'
"<p>Og skjemaet:</p>\n"
@ -446,7 +446,7 @@ def test_a_bmp_whose_name_claims_png_is_still_converted() -> None:
carry this file unchanged and call it viewable, and every structural check
downstream would agree with it -- the count, the suffix and the pointer.
The R761 delivery is why this is not hypothetical: its graphics directory
A real delivery is why this is not hypothetical: its graphics directory
holds `.bmp`, `.jpg` and `.png` side by side and the document's own
`xlink:href` values are whatever the publisher's tool wrote.
"""
@ -573,7 +573,7 @@ def test_the_cuts_below_land_on_the_opcodes_this_file_names() -> None:
def test_a_truncated_rle8_stream_is_refused_with_a_published_code() -> None:
"""Eval point 6 again, on the one case that was silent.
Measured by PM 2026-09-19 on a real R761 asset (352x548 = 192 896 pixels):
Measured by PM 2026-09-19 on a real delivered asset (352x548 = 192 896 pixels):
a stream cut to 90 % carried with 13 923 pixels wrong, to 50 % with 95 890,
to 10 % with 166 525 -- no code, no row, a partly blank PNG under a name
that says it holds the source's pixels. The uncompressed path already
@ -585,7 +585,7 @@ def test_a_truncated_rle8_stream_is_refused_with_a_published_code() -> None:
the format's own value and what an independent decoder produces.
THIS GUARD IS THE TERMINATOR, and it is read off the corpus rather than
chosen: over the 25 RLE8 BMPs the frozen R761 delivery ships (24 distinct;
chosen: over the 25 RLE8 BMPs a frozen reference delivery ships (24 distinct;
the bundle carries 19), 25 of 25 end at an explicit end-of-bitmap escape, that escape is the stream's LAST
two bytes on 25 of 25, and `biSizeImage` equals the available bytes on
25 of 25. So a stream that runs out before its terminator is refused, and
@ -723,7 +723,7 @@ def test_a_stream_that_stops_before_the_frame_is_refused() -> None:
A PIXEL coverage count would be a different rule and a wrong one: it
refuses the delta the format defines, and 0 of the 25 RLE8 BMPs
the R761 delivery ships would be affected either way (25 of 25 paint every
that delivery ships would be affected either way (25 of 25 paint every
pixel, 25 of 25 reach the end of the frame, 0 of 25 use a delta), so the
corpus cannot choose between them. The independent decoder can.
"""

View file

@ -4,10 +4,10 @@ Until 0.10.0 no reader in this package fetched, named, described or copied a
single image, and the only writer into a bundle was
`materialize.write_bytes(bundle_dir, name, content: str)` -- UTF-8, text, no
binary path anywhere. A document whose table is a raster picture therefore
reached a concept as an absence with no denominator: measured on R761
Prosesskoden:2025, the process text is carried in full while 12 `Tabell N-N`
and 9 `Figur N-N` captions stand over nothing, and process 84 says "toleranse-
klasse ... er gitt i tabell 84-2" above an empty space.
reached a concept as an absence with no denominator: measured on a 701-page
reference standard, its text is carried in full while 12 `Tabell N-N` and 9
`Figur N-N` captions stand over nothing, and a section saying "... er gitt i
tabell 84-2" stands above an empty space.
THIS MODULE IS THE ONE PLACE THAT DECIDES WHAT AN IMAGE IS. Every reader hands
it bytes and gets back either a carried image or a coded rejection, so a
@ -178,7 +178,7 @@ def test_dimensions_absent_is_absent_never_zero() -> None:
def test_asset_name_is_digest_plus_a_reduced_original() -> None:
data = _jpeg(360, 269)
image = assets.read_image(data, name="25-0143 - Tabeller - Prosesskoden (R761-R762).jpg")
image = assets.read_image(data, name="25-0143 - Tabeller - Oppskriftsboka (P761-P762).jpg")
name = assets.asset_name(image)
assert name.startswith(hashlib.sha256(data).hexdigest()[: assets.DIGEST_PREFIX] + "-")
assert name.endswith(".jpg")
@ -212,13 +212,13 @@ def test_a_nameless_image_still_gets_a_name() -> None:
def test_block_names_the_original_the_size_and_the_label() -> None:
image = assets.read_image(
_jpeg(360, 269),
name="25-0143 - Tabeller - Prosesskoden (R761-R762).jpg",
name="25-0143 - Tabeller - Oppskriftsboka (P761-P762).jpg",
label="Tabell 84-2",
)
block = assets.render_block(image)
first, second = block.split("\n")
assert first == f"![Tabell 84-2]({assets.asset_href(image)})"
assert second.startswith("Image: 25-0143 - Tabeller - Prosesskoden (R761-R762).jpg")
assert second.startswith("Image: 25-0143 - Tabeller - Oppskriftsboka (P761-P762).jpg")
assert "360x269 px" in second
assert second.endswith("Tabell 84-2")
@ -309,7 +309,7 @@ def test_the_pointer_regex_finds_every_shipped_block() -> None:
assets.read_image(_png(4, 3), name="a.png", label="Figur 1"),
assets.read_image(_jpeg(9, 9), name="b.jpg"),
]
text = "Prosess 84\n\n" + "\n\n".join(assets.render_block(i) for i in images) + "\n\nSlutt\n"
text = "Kapittel 84\n\n" + "\n\n".join(assets.render_block(i) for i in images) + "\n\nSlutt\n"
found = assets.IMAGE_POINTER.findall(text)
assert [match[1] for match in found] == [assets.asset_name(i) for i in images]
@ -374,8 +374,8 @@ def test_the_restated_reduction_is_materializes_own() -> None:
from llm_ingestion_okf.materialize import reduce_to_id_grammar
for value in (
"25-0143 - Tabeller - Prosesskoden (R761-R762)",
"Figur 11.1 Toleransekrav",
"25-0143 - Tabeller - Oppskriftsboka (P761-P762)",
"Figur 11.1 Hevetidskrav",
"grafikk_med_æøå",
"---",
"",

View file

@ -7,7 +7,7 @@ is bold text, the proposer proposes nothing, and the document reaches Door B's
inbox as one flat concept. Content preserved, structure zero.
WHAT THE GRAMMAR READS, AND WHERE IT LIVES. Not `rtf` markup. The converter
already emits that bold title as `**Kravspesifikasjon for tunnelbelysning**` in
already emits that bold title as `**Kravspesifikasjon for kjøkkenbelysning**` in
the SAME markdown the other office rows produce, so this rule is a markdown
rule and no `rtf`-only grammar exists -- the same shape of decision as the PDF
font reader emitting ATX rather than a PDF-only heading form. Bold INSIDE a
@ -79,18 +79,18 @@ DEFAULT = dict(
#: The shape the converter hands back for the `rtf` fixture: a bold title, a
#: prose paragraph, then a grid table of label/value pairs.
BOLD_TITLED = """**Kravspesifikasjon for tunnelbelysning**
BOLD_TITLED = """**Kravspesifikasjon for kjøkkenbelysning**
Dokumentet samler kravene til belysning i vegtunneler over 500 meter.
Dokumentet samler kravene til belysning i storkjøkken over 500 kvadratmeter.
----------------------- ------------------------------
Dokumentnummer: SVV-2026-0417
Tittel: Tunnelbelysning i hovedlopet
Dokumentnummer: EKS-2026-0417
Tittel: Kjokkenbelysning i hovedsalen
----------------------- ------------------------------
**Luminansmatrise**
Matrisen gjelder per trafikklasse og er bindende.
Matrisen gjelder per romklasse og er bindende.
"""
#: The known-negative for the whole-line clause. `**fet**` here is a phrase
@ -118,7 +118,7 @@ Leverandoren bekrefter at opplysningene i skjemaet er riktige.
#: lines are sentence fragments, not section names.
BOLD_SENTENCE_FRAGMENTS = """**er inngaatt mellom:**
Byggherren og leverandoren, representert ved sine kontaktpersoner.
Kjøkkensjefen og leverandoren, representert ved sine kontaktpersoner.
**Sted og dato:**
@ -139,7 +139,7 @@ def _titles(text: str, **overrides: object) -> list[str]:
def test_a_bold_standalone_line_becomes_a_title_candidate() -> None:
"""The `rtf` row's whole finding: the title the container never declared."""
assert _titles(BOLD_TITLED, bold_title=True) == [
"Kravspesifikasjon for tunnelbelysning",
"Kravspesifikasjon for kjøkkenbelysning",
"Luminansmatrise",
]
@ -183,7 +183,7 @@ def test_the_rtf_row_recovers_every_authored_title_over_four_documents() -> None
Round 9: 0 of 0 declared headings, 0 concepts, 1368 of 1368 characters in
no segment, N = 1. Here: 6 of 6 AUTHORED titles over N = 4, 0 false bold
titles, 0 of 1994 characters in no segment.
titles, 0 of 2089 characters in no segment.
`authored_titles` is deliberately not a count of what the container
declares. `rtf` has no heading style, so a "declared heading" count for
@ -213,7 +213,7 @@ def test_the_rtf_row_recovers_every_authored_title_over_four_documents() -> None
unsegmented += len(text) - sum(c.end - c.start for c in found)
assert (recovered, authored) == (6, 6), "every authored title, over four containers"
assert false_titles == 0, "the bold RUN inside a sentence must not become a title"
assert (unsegmented, total) == (0, 1994), "no character left outside a segment"
assert (unsegmented, total) == (0, 2089), "no character left outside a segment"
def test_the_row_is_zero_without_the_rule_which_is_round_nine_reproduced() -> None:
@ -238,5 +238,5 @@ def test_the_container_declaring_a_heading_style_still_reaches_us_as_bold() -> N
"""
path = _fixture("krav-rikt-tekstformat-stil.rtf")
text = extract_text(path.name, path.read_bytes())
assert "**Kravspesifikasjon for tunnelbelysning**" in text
assert "**Kravspesifikasjon for kjøkkenbelysning**" in text
assert [line for line in text.splitlines() if line.startswith("#")] == []

View file

@ -494,7 +494,7 @@ def test_the_root_index_does_not_link_the_run_log(tmp_path: Path) -> None:
ARM_DOCUMENTS = {
"delta.md": (
"## 4 Grunnforhold\n\nGrunnen er morene over berg i hele omraadet.\n\n"
"### 4.1 Loesmasser\n\nLoesmassene er telefarlige og maa skiftes ut.\n"
"### 4.1 Loesmasser\n\nLoesmassene er vaate og maa skiftes ut.\n"
),
"epsilon.md": ("## 5 Prissammenstilling\n\n| Post | Sum |\n|------|-----|\n| 01 | 100 |\n"),
}

View file

@ -195,7 +195,7 @@ def test_the_classes_this_package_does_not_count_are_named(name: str) -> None:
def test_the_inventory_resolves_the_files_a_document_points_at() -> None:
got = accounting.inventory(CORPUS, CORPUS / "prosess-84-sts.xml")
got = accounting.inventory(CORPUS, CORPUS / "kapittel-84-sts.xml")
assert got.pointed_files() == {"graphics/tabell-84-2.png", "graphics/figur-84-1.png"}
@ -246,7 +246,7 @@ def test_the_door_books_every_element_of_the_fixture_corpus_but_the_known_losses
assert found == KNOWN_LOSSES
files = {entry["source_file"]: entry["fate"] for entry in data["files"]}
assert files == {"graphics/figur-84-1.png": "carried", "graphics/tabell-84-2.png": "carried"}
web = next(d for d in data["documents"] if d["source_file"] == "prosess-84-web.html")
web = next(d for d in data["documents"] if d["source_file"] == "kapittel-84-web.html")
assert web["fates"]["image"] == {"carried": 2, "pointer": 1, "rejected": {}}

View file

@ -48,7 +48,7 @@ GENERIC = FIXTURES / "generic-feed.xml"
# document inside it.
OPAQUE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.xml"
FULL_TITLE = "R900 Testnormalen Standard for fiksturer, tester og kontroll"
FULL_TITLE = "P900 Testnormalen Standard for fiksturer, tester og kontroll"
def _build(tmp_path: Path, files: dict[str, bytes]) -> Path:
@ -82,7 +82,7 @@ def test_the_identity_is_read_from_the_documents_own_elements() -> None:
identity = extract.declared_identity(IDENTITY.name, IDENTITY.read_bytes())
assert identity == extract.DeclaredIdentity(
doc_number="R900 Testnormalen", year="2024", title=FULL_TITLE
doc_number="P900 Testnormalen", year="2024", title=FULL_TITLE
)
@ -101,7 +101,7 @@ def test_a_document_declaring_neither_has_no_identity(fixture: Path) -> None:
def test_only_the_row_that_can_read_a_declaration_is_asked() -> None:
"""A markdown file whose text LOOKS like an identity declares nothing."""
data = b"<std-ident><doc-number>R900 Testnormalen</doc-number></std-ident>\n"
data = b"<std-ident><doc-number>P900 Testnormalen</doc-number></std-ident>\n"
assert extract.declared_identity("notat.md", data) is None
@ -130,7 +130,7 @@ def test_the_doc_number_names_the_document_directory(tmp_path: Path) -> None:
concepts = _concepts(_build(tmp_path, {OPAQUE: IDENTITY.read_bytes()}))
assert concepts, "the fixture must produce concepts for this to mean anything"
assert all(name.startswith("r900-testnormalen/") for name in concepts), sorted(concepts)
assert all(name.startswith("p900-testnormalen/") for name in concepts), sorted(concepts)
assert not any(Path(OPAQUE).stem in name for name in concepts)
@ -143,16 +143,16 @@ def test_the_address_keeps_the_file_and_the_title_is_the_documents(tmp_path: Pat
concepts = _concepts(_build(tmp_path, {OPAQUE: IDENTITY.read_bytes()}))
assert {values["sources"] for values in concepts.values()} == {
_sources(OPAQUE, "R900 Testnormalen 2024")
_sources(OPAQUE, "P900 Testnormalen 2024")
}
def test_a_nested_document_keeps_the_folders_above_it(tmp_path: Path) -> None:
concepts = _concepts(_build(tmp_path, {f"leveranse/{OPAQUE}": IDENTITY.read_bytes()}))
assert all(name.startswith("leveranse/r900-testnormalen/") for name in concepts)
assert all(name.startswith("leveranse/p900-testnormalen/") for name in concepts)
assert {values["sources"] for values in concepts.values()} == {
_sources(f"leveranse/{OPAQUE}", "R900 Testnormalen 2024")
_sources(f"leveranse/{OPAQUE}", "P900 Testnormalen 2024")
}
@ -204,13 +204,13 @@ def test_two_documents_declaring_one_doc_number_both_keep_their_file_name(
bundle = _build(tmp_path, {"a.xml": IDENTITY.read_bytes(), "b.xml": second})
concepts = _concepts(bundle)
assert not any(name.startswith("r900-testnormalen/") for name in concepts)
assert not any(name.startswith("p900-testnormalen/") for name in concepts)
assert {name.split("/", 1)[0] for name in concepts} == {"a", "b"}
# Every section of both documents landed: same sections, two directories.
assert sum(name.startswith("a/") for name in concepts) == sum(
name.startswith("b/") for name in concepts
)
assert "R900 Testnormalen" in capsys.readouterr().err
assert "P900 Testnormalen" in capsys.readouterr().err
def test_a_declared_name_another_documents_file_name_holds_is_not_taken(
@ -218,13 +218,13 @@ def test_a_declared_name_another_documents_file_name_holds_is_not_taken(
) -> None:
markdown = b"# Innledning\n\nTekst her.\n\n# Omfang\n\nMer tekst her.\n"
concepts = _concepts(
_build(tmp_path, {"r900-testnormalen.md": markdown, OPAQUE: IDENTITY.read_bytes()})
_build(tmp_path, {"p900-testnormalen.md": markdown, OPAQUE: IDENTITY.read_bytes()})
)
stems = {name.split("/", 1)[0] for name in concepts}
assert stems == {"r900-testnormalen", Path(OPAQUE).stem}
assert stems == {"p900-testnormalen", Path(OPAQUE).stem}
assert all(
values["source_file"] == "r900-testnormalen.md"
values["source_file"] == "p900-testnormalen.md"
for name, values in concepts.items()
if name.startswith("r900-testnormalen/")
if name.startswith("p900-testnormalen/")
)

View file

@ -636,7 +636,7 @@ def run_segmented_inbox(tmp_path: Path, *, plan_overrides: dict[str, Any], **kwa
inbox = tmp_path / "round"
inbox.mkdir(parents=True, exist_ok=True)
body = "Brannkonsept: krav til seksjonering.\n"
(inbox / "n500.md").write_text(body, encoding="utf-8", newline="")
(inbox / "q500.md").write_text(body, encoding="utf-8", newline="")
payload = segmentation_payload(
source_sha256=hashlib.sha256(body.encode("utf-8")).hexdigest(),
entries=[segmentation_entry(span=[0, len(body)])],
@ -815,7 +815,7 @@ def test_asset_pdf_unsupported() -> None:
from llm_ingestion_okf.extract import extract_document
data = (
Path(__file__).parent / "fixtures" / "image-inbox" / "prosess-84-tabell.pdf"
Path(__file__).parent / "fixtures" / "image-inbox" / "kapittel-84-tabell.pdf"
).read_bytes()
stencil = data.replace(
b"/BitsPerComponent 8 /Filter /FlateDecode", b"/BitsPerComponent 1 /Filter /FlateDecode", 1

View file

@ -500,7 +500,7 @@ def test_office_conversion_warns_that_it_is_lossy(
# (pdfplumber pins pdfminer.six==20260107 exactly; pdfminer.six ships
# date-stamped releases with no stability contract). This literal is what makes
# a parser upgrade break something visible instead of drifting silently.
KRAV_TEXT = "Krav til helning på utkilingen\n60 og 70 1:15"
KRAV_TEXT = "Krav til hevetid på deigkulene\n60 og 70 1:15"
# The office fixtures are frozen the same way, and against a NAMED converter
# version -- a frozen literal means nothing without one, because the thing it
@ -513,7 +513,7 @@ requires_pandoc = pytest.mark.skipif(
# docx: a heading and one requirement row with label and value on the SAME
# line, mirroring the property the PDF fixture pins.
DOCX_TEXT = "# Krav til helning\n\n60 og 70 1:15"
DOCX_TEXT = "# Krav til hevetid\n\n60 og 70 1:15"
# xlsx: the sheet name becomes a heading and the rows become a table. The
# label/value pairing survives on one row, which is the property that matters.
@ -522,11 +522,11 @@ DOCX_TEXT = "# Krav til helning\n\n60 og 70 1:15"
# `docs/2026-09-08-prisform-og-loggen-k2.md`: the spreadsheet row now writes
# pipe tables, so the cells arrive delimited instead of padded. Every character
# of content is the same; only the table form changed.
XLSX_TEXT = "## Krav {#sheet-1}\n\n| Krav til helning | |\n|----|----|\n| 60 og 70 | 1:15 |"
XLSX_TEXT = "## Krav {#sheet-1}\n\n| Krav til hevetid | |\n|----|----|\n| 60 og 70 | 1:15 |"
# The negative control, committed rather than described: the SAME document
# without `word/styles.xml`. The body survives and the heading marker does not.
DOCX_NO_STYLES_TEXT = "Krav til helning\n\n60 og 70 1:15"
DOCX_NO_STYLES_TEXT = "Krav til hevetid\n\n60 og 70 1:15"
@requires_pandoc
@ -767,13 +767,13 @@ def test_a_single_column_sheet_gains_no_columns() -> None:
# A red here means the writer stopped being scoped -- read the diff and decide.
OFFICE_TEXT_DIGESTS = {
"k2-office/krav-tekstdokument.odt": (
"58c9776f0d7f2b2a3a9d2774e4ae243b265c31b5b6b96914ef4db419fa66e4e2"
"581d43835416721157f662d5403a621277718f9841d167331c5ee274e59548d7"
),
"k2-office/krav-presentasjon.pptx": (
"752420a04d651a416938ff9f0b3c2de5849bd2ea1d52063dd88aaae65ab99b90"
"c6fc68dcdb0e28522249f3988e2350a2932c891d0f6361fa4dbf139148f0dede"
),
"k2-office/krav-rikt-tekstformat.rtf": (
"79cbf756eb482bb603f82c171d11efe74b9ba62ab9ef679ecd6bb8c3b3740ffb"
"89222009cae6587b71e261ca029c2e01fa7f1736d04fbc1ad4cb54ae8f851fd0"
),
}

View file

@ -36,12 +36,12 @@ def test_default_carries_no_facets_and_renders_exactly_what_it_did() -> None:
def test_a_faceted_policy_renders_present_facets_in_policy_order() -> None:
policy = STRUCTURED_V1.index
line = policy.render_link(
"N500 Vegbygging",
"inbox-n500-vegbygging.md",
facets={"status": "gjeldende", "number": "N500"},
"Q500 Surdeigsbaking",
"inbox-q500-surdeigsbaking.md",
facets={"status": "gjeldende", "number": "Q500"},
)
assert line == (
"- [N500 Vegbygging](inbox-n500-vegbygging.md) — number: N500; status: gjeldende"
"- [Q500 Surdeigsbaking](inbox-q500-surdeigsbaking.md) — number: Q500; status: gjeldende"
)
@ -86,12 +86,12 @@ def test_a_facet_value_that_would_break_the_grammar_is_refused_never_repaired(ba
def test_render_and_parse_round_trip() -> None:
policy = STRUCTURED_V1.index
facets = {"number": "N500", "status": "gjeldende", "references": "[N200, N300?]"}
line = policy.render_link("N500 Vegbygging", "inbox-n500.md", facets=facets)
facets = {"number": "Q500", "status": "gjeldende", "references": "[Q200, Q300?]"}
line = policy.render_link("Q500 Surdeigsbaking", "inbox-q500.md", facets=facets)
entry = policy.parse_entry(line)
assert entry is not None
assert entry.label == "N500 Vegbygging"
assert entry.target == "inbox-n500.md"
assert entry.label == "Q500 Surdeigsbaking"
assert entry.target == "inbox-q500.md"
assert entry.facets == facets

View file

@ -138,7 +138,7 @@ def test_a_type_with_no_reader_is_refused_rather_than_scored_zero(tmp_path: Path
SLIDE_XML = """<?xml version="1.0"?>
<p:sld xmlns:a="a" xmlns:p="p"><p:cSld><p:spTree>
<p:sp><p:txBody><a:p><a:r><a:t>Kravspesifikasjon for tunnel</a:t></a:r></a:p></p:txBody></p:sp>
<p:sp><p:txBody><a:p><a:r><a:t>Kravspesifikasjon for kjeller</a:t></a:r></a:p></p:txBody></p:sp>
<p:graphicFrame><a:tbl><a:tr>
<a:tc><a:txBody><a:p><a:r><a:t>Navn tilbyder:</a:t></a:r></a:p></a:txBody></a:tc>
<a:tc><a:txBody><a:p><a:r><a:t>Entreprenoer AS</a:t></a:r></a:p></a:txBody></a:tc>
@ -149,7 +149,7 @@ SLIDE_XML = """<?xml version="1.0"?>
CONTENT_XML = """<?xml version="1.0"?>
<office:document-content xmlns:office="o" xmlns:text="t" xmlns:table="tb">
<office:body><office:text>
<text:h text:outline-level="1">Kravspesifikasjon for tunnel</text:h>
<text:h text:outline-level="1">Kravspesifikasjon for kjeller</text:h>
<table:table><table:table-row>
<table:table-cell><text:p>Navn <text:span>tilbyder</text:span>:</text:p></table:table-cell>
<table:table-cell><text:p>Entreprenoer AS</text:p></table:table-cell>
@ -159,7 +159,7 @@ CONTENT_XML = """<?xml version="1.0"?>
RTF_STREAM = (
"{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0\\froman Times New Roman;}}\n"
"\\pard Kravspesifikasjon for tunnel\\par\n"
"\\pard Kravspesifikasjon for kjeller\\par\n"
"\\trowd\\cellx3000\\cellx6000\n"
"\\pard\\intbl Navn tilbyder:\\cell \\pard\\intbl Entrepren\\u248 ?r AS\\cell \\row\n"
"}"
@ -189,7 +189,7 @@ def rtf(tmp_path: Path) -> Path:
def test_a_presentations_slide_text_is_read(tmp_path: Path) -> None:
"""One string per `a:p`, table cells included -- a cell is a paragraph."""
assert set(okf_fidelity.source_strings(pptx(tmp_path))) == {
"kravspesifikasjon for tunnel",
"kravspesifikasjon for kjeller",
"navn tilbyder:",
"entreprenoer as",
}
@ -198,7 +198,7 @@ def test_a_presentations_slide_text_is_read(tmp_path: Path) -> None:
def test_an_odf_bodys_paragraphs_are_read(tmp_path: Path) -> None:
"""Headings count, and a run split across a `text:span` is still one string."""
assert set(okf_fidelity.source_strings(odt(tmp_path))) == {
"kravspesifikasjon for tunnel",
"kravspesifikasjon for kjeller",
"navn tilbyder:",
"entreprenoer as",
}
@ -207,7 +207,7 @@ def test_an_odf_bodys_paragraphs_are_read(tmp_path: Path) -> None:
def test_an_rtf_streams_paragraphs_and_cells_are_read(tmp_path: Path) -> None:
"""The control words are markup; the text between them is the document."""
assert set(okf_fidelity.source_strings(rtf(tmp_path))) == {
"kravspesifikasjon for tunnel",
"kravspesifikasjon for kjeller",
"navn tilbyder:",
"entreprenør as",
}

View file

@ -1,6 +1,6 @@
"""Door C's own outcome is a bundle the reading direction can open.
vegnormal-okf, 2026-09-08 (FUNN 1): `import_bundle` wrote the root index with
A downstream consumer repository, 2026-09-08 (its finding 1): `import_bundle` wrote the root index with
no frontmatter and took no `root_frontmatter_values`, so it could not declare
`bundle_id`. `okf consume` then refused the result with exit 1,
`bundle_id_missing` -- section 3.1's identity tuple is `(bundle_id,

View file

@ -1,6 +1,6 @@
"""Door C carries the sender's OWN metadata into the index — and nothing else.
The measured defect this closes, as data. vegnormal-okf ran a pre-registered
The measured defect this closes, as data. A downstream consumer ran a pre-registered
reasoning bake-off on 2026-08-27 over the same bundle through two index shapes:
the arm reading DEFAULT's `index.md` scored 0 hits of 8, the arm reading a
faceted index of the same frontmatter scored 25 of 29. The DEFAULT arm did not
@ -82,15 +82,15 @@ def test_door_c_carries_the_senders_declared_facets_into_the_index(tmp_path: Pat
# index the reasoning arm is handed now states them.
place(
tmp_path / "source",
"krav/n500-3-1.md",
"---\ntype: dataset\nnumber: N500\nstatus: gjeldende\ndate: 2024-06-01\n---\n\nBody.\n",
"krav/q500-3-1.md",
"---\ntype: dataset\nnumber: Q500\nstatus: gjeldende\ndate: 2024-06-01\n---\n\nBody.\n",
)
_, bundle = run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
assert index_of(bundle, STRUCTURED_V1) == (
"- [krav/n500-3-1](import-krav-n500-3-1.md)"
" — number: N500; status: gjeldende; date: 2024-06-01\n"
"- [krav/q500-3-1](import-krav-q500-3-1.md)"
" — number: Q500; status: gjeldende; date: 2024-06-01\n"
)
@ -101,13 +101,13 @@ def test_a_facet_key_the_policy_names_is_projected_whatever_it_means(tmp_path: P
place(
tmp_path / "source",
"0f9a.md",
"---\ntype: dataset\ntitle: Vegtunneler\nnumber: N500\n---\n\nBody.\n",
"---\ntype: dataset\ntitle: Vinkjellere\nnumber: Q500\n---\n\nBody.\n",
)
_, bundle = run_with(tmp_path, StubImportGate(), profile=TITLED)
assert index_of(bundle, TITLED) == (
"- [0f9a](import-0f9a.md) — title: Vegtunneler; number: N500\n"
"- [0f9a](import-0f9a.md) — title: Vinkjellere; number: Q500\n"
)
@ -122,14 +122,14 @@ def test_door_c_derives_nothing_the_sender_did_not_declare(tmp_path: Path) -> No
place(
tmp_path / "source",
"b.md",
"---\ntype: dataset\nstatus: gjeldende\n---\n\n# N500 Vegtunneler\n\nSe N100.\n",
"---\ntype: dataset\nstatus: gjeldende\n---\n\n# Q500 Vinkjellere\n\nSe Q100.\n",
)
_, bundle = run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
index = index_of(bundle, STRUCTURED_V1)
assert index == "- [b](import-b.md) — status: gjeldende\n"
assert "N500" not in index
assert "Q500" not in index
assert "references" not in index
@ -141,14 +141,14 @@ def test_the_senders_own_derived_list_travels_verbatim(tmp_path: Path) -> None:
place(
tmp_path / "source",
"c.md",
"---\ntype: dataset\nnumber: N130\nstatus: gjeldende\n"
"---\ntype: dataset\nnumber: Q130\nstatus: gjeldende\n"
"derived: [number, status]\n---\n\nBody.\n",
)
_, bundle = run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
assert index_of(bundle, STRUCTURED_V1) == (
"- [c](import-c.md) — number: N130; status: gjeldende; derived: [number, status]\n"
"- [c](import-c.md) — number: Q130; status: gjeldende; derived: [number, status]\n"
)
@ -158,7 +158,7 @@ def test_the_senders_own_derived_list_travels_verbatim(tmp_path: Path) -> None:
def test_an_unnumbered_document_still_gets_the_facets_it_has(tmp_path: Path) -> None:
# Operator directive 2026-08-27: everything built around OKF must work for
# ALL content, so a design that only works for numbered norms is wrong even
# when it scores well on N100/N200/N500. A meeting note has no number, no
# when it scores well on Q100/Q200/Q500. A meeting note has no number, no
# parent and no version — and must still reach the index carrying what it
# does have, with no half-written separator for what it does not.
place(
@ -197,7 +197,7 @@ def test_the_default_profile_leaves_door_c_byte_identical(tmp_path: Path) -> Non
place(
tmp_path / "source",
"e.md",
"---\ntype: dataset\nnumber: N500\nstatus: gjeldende\n---\n\nBody.\n",
"---\ntype: dataset\nnumber: Q500\nstatus: gjeldende\n---\n\nBody.\n",
)
bundle = tmp_path / "bundle"
@ -219,7 +219,7 @@ def test_the_concept_file_is_still_written_verbatim_under_a_faceted_profile(
# The verbatim guarantee is what makes projection safe, so it is pinned on
# the faceted path too: it is `index.md` that gains bytes, never the file
# the sender wrote.
text = "---\ntype: dataset\nnumber: N500\n---\n\nBody.\n"
text = "---\ntype: dataset\nnumber: Q500\n---\n\nBody.\n"
place(tmp_path / "source", "f.md", text)
_, bundle = run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
@ -236,14 +236,14 @@ def test_an_unrenderable_facet_value_drops_the_facet_not_the_concept(tmp_path: P
place(
tmp_path / "source",
"g.md",
"---\ntype: dataset\nnumber: N500\nstatus: utkast; til horing\n---\n\nBody.\n",
"---\ntype: dataset\nnumber: Q500\nstatus: utkast; til horing\n---\n\nBody.\n",
)
result, bundle = run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
assert [entry.concept_path for entry in result.merged] == ["g.md"]
assert (bundle / "import-g.md").is_file()
assert index_of(bundle, STRUCTURED_V1) == "- [g](import-g.md) — number: N500\n"
assert index_of(bundle, STRUCTURED_V1) == "- [g](import-g.md) — number: Q500\n"
assert [(entry.concept_path, entry.key) for entry in result.unrendered_facets] == [
("g.md", "status")
]
@ -266,19 +266,19 @@ def test_a_reimported_concept_updates_its_index_entry_rather_than_going_stale(
# An index that contradicts the bundle it indexes is worse than one that
# says nothing, because a reasoning arm reads the index and stops.
source = tmp_path / "source"
place(source, "a.md", "---\ntype: dataset\nnumber: N500\nstatus: utkast\n---\n\nBody.\n")
place(source, "a.md", "---\ntype: dataset\nnumber: Q500\nstatus: utkast\n---\n\nBody.\n")
_, bundle = run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
assert index_of(bundle, STRUCTURED_V1) == (
"- [a](import-a.md) — number: N500; status: utkast\n"
"- [a](import-a.md) — number: Q500; status: utkast\n"
)
(bundle / "import-a.md").unlink()
place(source, "a.md", "---\ntype: dataset\nnumber: N500\nstatus: gjeldende\n---\n\nBody.\n")
place(source, "a.md", "---\ntype: dataset\nnumber: Q500\nstatus: gjeldende\n---\n\nBody.\n")
run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
assert "gjeldende" in (bundle / "import-a.md").read_text(encoding="utf-8")
assert index_of(bundle, STRUCTURED_V1) == (
"- [a](import-a.md) — number: N500; status: gjeldende\n"
"- [a](import-a.md) — number: Q500; status: gjeldende\n"
)
@ -335,13 +335,13 @@ def test_the_door_writes_into_the_profiles_namespace_not_defaults(tmp_path: Path
STRUCTURED_V1,
paths=replace(STRUCTURED_V1.paths, import_prefix="ekstern-"),
)
place(tmp_path / "source", "a.md", "---\ntype: dataset\nnumber: N500\n---\n\nBody.\n")
place(tmp_path / "source", "a.md", "---\ntype: dataset\nnumber: Q500\n---\n\nBody.\n")
_, bundle = run_with(tmp_path, StubImportGate(), profile=profile)
assert (bundle / "ekstern-a.md").is_file()
assert not (bundle / "import-a.md").exists()
assert index_of(bundle, profile) == "- [a](ekstern-a.md) — number: N500\n"
assert index_of(bundle, profile) == "- [a](ekstern-a.md) — number: Q500\n"
def test_a_key_the_sender_declared_empty_produces_no_facet(tmp_path: Path) -> None:
@ -351,9 +351,9 @@ def test_a_key_the_sender_declared_empty_produces_no_facet(tmp_path: Path) -> No
place(
tmp_path / "source",
"a.md",
"---\ntype: dataset\nnumber: N500\nstatus:\ndate:\n---\n\nBody.\n",
"---\ntype: dataset\nnumber: Q500\nstatus:\ndate:\n---\n\nBody.\n",
)
_, bundle = run_with(tmp_path, StubImportGate(), profile=STRUCTURED_V1)
assert index_of(bundle, STRUCTURED_V1) == "- [a](import-a.md) — number: N500\n"
assert index_of(bundle, STRUCTURED_V1) == "- [a](import-a.md) — number: Q500\n"

View file

@ -63,7 +63,7 @@ def test_default_is_byte_identical_with_and_without_the_new_parameter(tmp_path:
# The additivity claim at the byte level. If this ever fails, the feature
# stopped being additive and every existing Door B bundle churns.
for label in ("implicit", "explicit"):
drop(tmp_path / label, "n500-vegbygging.md", "# Vegbygging\n\nSee N200.\n")
drop(tmp_path / label, "q500-surdeigsbaking.md", "# Surdeigsbaking\n\nSee Q200.\n")
process_inbox(
tmp_path / "implicit",
tmp_path / "a",
@ -79,7 +79,7 @@ def test_default_is_byte_identical_with_and_without_the_new_parameter(tmp_path:
gate=gate,
profile=DEFAULT,
)
for name in ("index.md", "inbox-n500-vegbygging.md"):
for name in ("index.md", "inbox-q500-surdeigsbaking.md"):
assert (tmp_path / "a" / name).read_bytes() == (tmp_path / "b" / name).read_bytes()
@ -87,9 +87,9 @@ def test_default_still_labels_the_entry_with_the_filename_stem(tmp_path: Path) -
# Title derivation arrives with the profile that asked for it. DEFAULT
# states commons' index layer, and changing its label source from here
# would change a contract this repo does not own.
drop(tmp_path / "round", "n500-vegbygging.md", "# Vegbygging\n\nbody\n")
drop(tmp_path / "round", "q500-surdeigsbaking.md", "# Surdeigsbaking\n\nbody\n")
run(tmp_path, profile=DEFAULT)
assert "- [n500-vegbygging](inbox-n500-vegbygging.md)\n" == index_of(tmp_path)
assert "- [q500-surdeigsbaking](inbox-q500-surdeigsbaking.md)\n" == index_of(tmp_path)
# --- what STRUCTURED_V1 adds ----------------------------------------------
@ -97,20 +97,20 @@ def test_default_still_labels_the_entry_with_the_filename_stem(tmp_path: Path) -
def test_the_title_is_derived_from_the_leading_heading(tmp_path: Path) -> None:
# The answer recorded in dc9ea59 for the Door B / Door A capability gap.
drop(tmp_path / "round", "n500-vegbygging.md", "# Vegbygging\n\nbody\n")
drop(tmp_path / "round", "q500-surdeigsbaking.md", "# Surdeigsbaking\n\nbody\n")
run(tmp_path)
assert "title: Vegbygging" in concept(tmp_path, "inbox-n500-vegbygging.md")
assert "- [Vegbygging](inbox-n500-vegbygging.md)" in index_of(tmp_path)
assert "title: Surdeigsbaking" in concept(tmp_path, "inbox-q500-surdeigsbaking.md")
assert "- [Surdeigsbaking](inbox-q500-surdeigsbaking.md)" in index_of(tmp_path)
def test_the_concept_carries_the_derived_structure_in_its_own_frontmatter(
tmp_path: Path,
) -> None:
drop(tmp_path / "round", "n500-vegbygging.md", "# Vegbygging\n\nSee N200.\n")
drop(tmp_path / "round", "q500-surdeigsbaking.md", "# Surdeigsbaking\n\nSee Q200.\n")
run(tmp_path)
head = concept(tmp_path, "inbox-n500-vegbygging.md").split("---")[1]
assert "number: N500" in head
assert "references: [N200]" in head
head = concept(tmp_path, "inbox-q500-surdeigsbaking.md").split("---")[1]
assert "number: Q500" in head
assert "references: [Q200]" in head
# The confidence marker, without which the two lines above are assertions
# a consumer cannot audit.
assert "derived: [title, number, references]" in head
@ -121,18 +121,18 @@ def test_the_index_entry_carries_the_facets_its_document_carries(tmp_path: Path)
# documents was the whole reason the OKF arm lost on trap exposure.
drop(
tmp_path / "round",
"n500-vegbygging.md",
"---\nstatus: gjeldende\ndate: 2026-01-01\n---\n\n# Vegbygging\n\nbody\n",
"q500-surdeigsbaking.md",
"---\nstatus: gjeldende\ndate: 2026-01-01\n---\n\n# Surdeigsbaking\n\nbody\n",
)
run(tmp_path)
entry = index_of(tmp_path).strip()
assert "number: N500" in entry
assert "number: Q500" in entry
assert "status: gjeldende" in entry
assert "date: 2026-01-01" in entry
def test_a_declared_value_is_not_marked_derived_in_the_index(tmp_path: Path) -> None:
drop(tmp_path / "round", "x.md", "---\ntitle: Declared\nnumber: N500\n---\n\nbody\n")
drop(tmp_path / "round", "x.md", "---\ntitle: Declared\nnumber: Q500\n---\n\nbody\n")
run(tmp_path)
assert "derived:" not in index_of(tmp_path)
@ -143,20 +143,20 @@ def test_a_declared_value_is_not_marked_derived_in_the_index(tmp_path: Path) ->
def test_a_reference_to_a_document_not_yet_dropped_is_marked_unresolved(
tmp_path: Path,
) -> None:
drop(tmp_path / "round", "n500-x.md", "# A\n\nSee N200.\n")
drop(tmp_path / "round", "q500-x.md", "# A\n\nSee Q200.\n")
run(tmp_path)
assert "references: [N200?]" in index_of(tmp_path)
assert "references: [Q200?]" in index_of(tmp_path)
def test_the_marker_clears_when_the_target_arrives_in_a_later_round(tmp_path: Path) -> None:
drop(tmp_path / "r1", "n500-x.md", "# A\n\nSee N200.\n")
drop(tmp_path / "r1", "q500-x.md", "# A\n\nSee Q200.\n")
run(tmp_path, round_name="r1")
assert "references: [N200?]" in index_of(tmp_path)
assert "references: [Q200?]" in index_of(tmp_path)
drop(tmp_path / "r2", "n200-y.md", "# B\n\nbody\n")
drop(tmp_path / "r2", "q200-y.md", "# B\n\nbody\n")
run(tmp_path, round_name="r2")
index = index_of(tmp_path)
assert "references: [N200]" in index
assert "references: [Q200]" in index
assert "?" not in index
@ -165,14 +165,14 @@ def test_a_later_round_can_supersede_an_earlier_one_and_the_index_UPDATES(
) -> None:
# An index that could only be appended to would leave the round-1 entry
# claiming to be current forever.
drop(tmp_path / "r1", "old.md", "---\nnumber: N500\nversion: '2018'\n---\n\n# Old\n")
drop(tmp_path / "r1", "old.md", "---\nnumber: Q500\nversion: '2018'\n---\n\n# Old\n")
run(tmp_path, round_name="r1")
assert "supersedes" not in index_of(tmp_path)
drop(tmp_path / "r2", "new.md", "---\nnumber: N500\nversion: '2021'\n---\n\n# New\n")
drop(tmp_path / "r2", "new.md", "---\nnumber: Q500\nversion: '2021'\n---\n\n# New\n")
run(tmp_path, round_name="r2")
index = index_of(tmp_path)
assert "supersedes: [N500]" in index
assert "supersedes: [Q500]" in index
# ...and the relation is marked as one this library PROPOSED, alongside the
# title it also inferred. Both are heuristics and both say so.
assert "derived: [title, supersedes]" in index
@ -184,9 +184,9 @@ def test_rebuild_from_scratch_and_incremental_update_agree_byte_for_byte(
# The single most load-bearing test in this delivery. If these two ever
# part company, "additive" stops being a property and becomes a hope.
files = {
"n500-x.md": "# Vegbygging\n\nSee N200 and N300.\n",
"n200-y.md": "---\nstatus: gjeldende\n---\n\n# Grunnlag\n\nSee N500.\n",
"n300-z.md": "# Tredje\n\nbody\n",
"q500-x.md": "# Surdeigsbaking\n\nSee Q200 and Q300.\n",
"q200-y.md": "---\nstatus: gjeldende\n---\n\n# Grunnlag\n\nSee Q500.\n",
"q300-z.md": "# Tredje\n\nbody\n",
}
incremental = tmp_path / "incremental"
for round_index, (name, text) in enumerate(files.items(), start=1):
@ -218,13 +218,13 @@ def test_rebuild_from_scratch_and_incremental_update_agree_byte_for_byte(
def test_dropping_the_same_file_twice_yields_one_entry(tmp_path: Path) -> None:
text = "# Vegbygging\n\nSee N200.\n"
text = "# Surdeigsbaking\n\nSee Q200.\n"
for round_name in ("r1", "r2"):
drop(tmp_path / round_name, "n500-x.md", text)
drop(tmp_path / round_name, "q500-x.md", text)
run(tmp_path, round_name=round_name)
index = index_of(tmp_path)
assert index.count("inbox-n500-x.md") == 1
assert index.count("N200") == 1
assert index.count("inbox-q500-x.md") == 1
assert index.count("Q200") == 1
def test_curated_prose_in_the_index_survives_reprojection(tmp_path: Path) -> None:
@ -233,15 +233,15 @@ def test_curated_prose_in_the_index_survives_reprojection(tmp_path: Path) -> Non
bundle = tmp_path / "bundle"
bundle.mkdir(parents=True)
(bundle / "index.md").write_text(
"# Bundle\n\nSome curated prose about inbox-n500-x.md.\n",
"# Bundle\n\nSome curated prose about inbox-q500-x.md.\n",
encoding="utf-8",
newline="",
)
drop(tmp_path / "round", "n500-x.md", "# A\n\nbody\n")
drop(tmp_path / "round", "q500-x.md", "# A\n\nbody\n")
run(tmp_path)
index = index_of(tmp_path)
assert "# Bundle" in index
assert "Some curated prose about inbox-n500-x.md." in index
assert "Some curated prose about inbox-q500-x.md." in index
def test_a_curated_link_is_not_claimed_by_reprojection(tmp_path: Path) -> None:
@ -249,13 +249,13 @@ def test_a_curated_link_is_not_claimed_by_reprojection(tmp_path: Path) -> None:
bundle.mkdir(parents=True)
(bundle / "index.md").write_text("- [Hand written](curated.md)\n", encoding="utf-8", newline="")
(bundle / "curated.md").write_text("# Curated\n", encoding="utf-8", newline="")
drop(tmp_path / "round", "n500-x.md", "# A\n\nbody\n")
drop(tmp_path / "round", "q500-x.md", "# A\n\nbody\n")
run(tmp_path)
assert "- [Hand written](curated.md)\n" in index_of(tmp_path)
def test_reprojection_is_idempotent_when_nothing_new_arrives(tmp_path: Path) -> None:
drop(tmp_path / "r1", "n500-x.md", "# A\n\nSee N200.\n")
drop(tmp_path / "r1", "q500-x.md", "# A\n\nSee Q200.\n")
run(tmp_path, round_name="r1")
first = (tmp_path / "bundle" / "index.md").read_bytes()
(tmp_path / "r2").mkdir()

View file

@ -60,7 +60,7 @@ def run_door_b(tmp_path: Path, *, profile: BundleProfile) -> Path:
inbox.mkdir(parents=True, exist_ok=True)
# Names ascend while numbers descend, so an index left in arrival order is
# the exact reverse of the one the profile asks for.
for name, number in (("alpha.md", "N300"), ("beta.md", "N200"), ("gamma.md", "N100")):
for name, number in (("alpha.md", "Q300"), ("beta.md", "Q200"), ("gamma.md", "Q100")):
(inbox / name).write_text(
f"# {number} {name[:-3]}\n\nBody.\n", encoding="utf-8", newline=""
)
@ -78,7 +78,7 @@ def run_door_b(tmp_path: Path, *, profile: BundleProfile) -> Path:
def run_door_c(tmp_path: Path, *, profile: BundleProfile) -> Path:
source = tmp_path / "source"
for name, number in (("alpha.md", "N300"), ("beta.md", "N200"), ("gamma.md", "N100")):
for name, number in (("alpha.md", "Q300"), ("beta.md", "Q200"), ("gamma.md", "Q100")):
place(source, name, f"---\ntype: dataset\nnumber: {number}\n---\n\nBody.\n")
_, bundle = run_with(tmp_path, StubImportGate(), profile=profile)
return bundle
@ -92,8 +92,8 @@ def test_both_doors_order_by_the_key_the_profile_names(tmp_path: Path) -> None:
door_c = numbers_in(run_door_c(tmp_path / "c", profile=NUMBERED), NUMBERED)
# Both agree with the profile...
assert door_b == ["N100", "N200", "N300"]
assert door_c == ["N100", "N200", "N300"]
assert door_b == ["Q100", "Q200", "Q300"]
assert door_c == ["Q100", "Q200", "Q300"]
# ...and therefore with each other. Stated separately on purpose: a door
# that ignored the field would still produce a parseable index, and the
# arrival order it would produce is the reverse of this one.
@ -156,8 +156,8 @@ def test_the_concept_path_is_the_final_tie_break() -> None:
the concept path, which for Door C is not the generated filename."""
policy: IndexPolicy = NUMBERED.index
entries = [
entry("notes/alpha.md", "import-notes-alpha.md", number="N100"),
entry("notes-beta.md", "import-notes-beta.md", number="N100"),
entry("notes/alpha.md", "import-notes-alpha.md", number="Q100"),
entry("notes-beta.md", "import-notes-beta.md", number="Q100"),
]
for order in (entries, list(reversed(entries))):
@ -170,9 +170,9 @@ def test_the_concept_path_is_the_final_tie_break() -> None:
def test_descending_reverses_the_key_and_not_the_tie_break() -> None:
policy = replace(NUMBERED.index, sort_order="descending")
entries = [
entry("a.md", "import-a.md", number="N100"),
entry("c.md", "import-c.md", number="N300"),
entry("b.md", "import-b.md", number="N300"),
entry("a.md", "import-a.md", number="Q100"),
entry("c.md", "import-c.md", number="Q300"),
entry("b.md", "import-b.md", number="Q300"),
]
assert [item.target for item in policy.sort_entries(entries)] == [
@ -192,9 +192,9 @@ def test_the_missing_group_lands_where_the_policy_says_in_both_directions(
"""
policy = replace(NUMBERED.index, sort_order=order, sort_missing=missing)
entries = [
entry("a.md", "import-a.md", number="N100"),
entry("a.md", "import-a.md", number="Q100"),
entry("b.md", "import-b.md"),
entry("c.md", "import-c.md", number="N300"),
entry("c.md", "import-c.md", number="Q300"),
]
present = (
@ -210,7 +210,7 @@ def test_an_empty_facet_value_counts_as_missing() -> None:
policy = replace(NUMBERED.index, sort_missing="last")
entries = [
entry("a.md", "import-a.md", number=""),
entry("b.md", "import-b.md", number="N300"),
entry("b.md", "import-b.md", number="Q300"),
]
assert [item.target for item in policy.sort_entries(entries)] == [
@ -225,14 +225,14 @@ def test_navigation_entries_stay_last_under_a_sort_key() -> None:
# the file if the grouping were not applied over the ordering.
policy = replace(NUMBERED.index, sort_missing="first")
entries = [
entry("krav/n900.md", "n900.md", number="N900"),
entry("krav/q900.md", "q900.md", number="Q900"),
IndexEntry(label="sub (underkapitler)", target=f"sub/{NUMBERED.index.name}"),
entry("krav/n100.md", "n100.md", number="N100"),
entry("krav/q100.md", "q100.md", number="Q100"),
]
assert [item.target for item in policy.sort_entries(entries)] == [
"n100.md",
"n900.md",
"q100.md",
"q900.md",
f"sub/{NUMBERED.index.name}",
]
@ -251,19 +251,19 @@ def test_door_c_orders_within_a_run_and_never_re_orders_an_earlier_one(
with no test that goes red is not a guarantee.
"""
source = tmp_path / "source"
place(source, "b.md", "---\ntype: dataset\nnumber: N200\n---\n\nBody.\n")
place(source, "c.md", "---\ntype: dataset\nnumber: N300\n---\n\nBody.\n")
place(source, "b.md", "---\ntype: dataset\nnumber: Q200\n---\n\nBody.\n")
place(source, "c.md", "---\ntype: dataset\nnumber: Q300\n---\n\nBody.\n")
_, bundle = run_with(tmp_path, StubImportGate(), profile=NUMBERED)
place(source, "a.md", "---\ntype: dataset\nnumber: N100\n---\n\nBody.\n")
place(source, "a.md", "---\ntype: dataset\nnumber: Q100\n---\n\nBody.\n")
run_with(tmp_path, StubImportGate(), profile=NUMBERED)
# N100 sorts first and lands last, because round one's lines stay put.
assert numbers_in(bundle, NUMBERED) == ["N200", "N300", "N100"]
# Q100 sorts first and lands last, because round one's lines stay put.
assert numbers_in(bundle, NUMBERED) == ["Q200", "Q300", "Q100"]
# The same three concepts merged in ONE run do come out ordered — so the
# difference above is the append bound, not an ordering that failed.
assert numbers_in(run_door_c(tmp_path / "fresh", profile=NUMBERED), NUMBERED) == [
"N100",
"N200",
"N300",
"Q100",
"Q200",
"Q300",
]

View file

@ -1,6 +1,6 @@
"""SPEC section 8's star row is READ, and still never written.
vegnormal-okf, 2026-09-08 (FUNN 2): measured against
A downstream consumer repository, 2026-09-08 (its finding 2), measured against
`SEGMENTED_OKF_V0_2.index.parse_entry`, the row form OKF SPEC section 8 shows
in its own example -- `* [Title](id-x.md) - description` -- returns `None`,
so the walk in section 9.2 reads it as curated prose and every concept behind

View file

@ -98,8 +98,8 @@ def test_every_persisted_concept_carries_the_documents_own_strings(tmp_path: Pat
)
for item in result.persisted:
text = (bundle / item.path).read_text(encoding="utf-8")
assert "Kravspesifikasjon for tunnelbelysning" in text
assert "Årsdøgntrafikk:" in text
assert "Kravspesifikasjon for kjøkkenbelysning" in text
assert "Årsproduksjon:" in text
assert "Klasse C" in text

View file

@ -34,10 +34,10 @@ import okf_consume # noqa: E402
from llm_ingestion_okf.inbox import ENCLOSING_SECTION # noqa: E402
QUESTION = "Hvilke krav gjelder for sikringsbolter?"
QUESTION = "Hvilke krav gjelder for hevekurver?"
_FRONTMATTER = (
"---\ntype: reference\ntitle: {title}\nsource_file: r761.xml\n"
"---\ntype: reference\ntitle: {title}\nsource_file: p761.xml\n"
"source_sha256: {digest}\ningested_at: 2026-09-01T00:00:00Z\n"
"adjudication: proposed\nbundle_id: link-fixture\n"
"verified: [{{ by: process:okf-check, at: 2026-09-01T00:00:00Z }}]\n---\n\n"
@ -45,7 +45,7 @@ _FRONTMATTER = (
#: Exactly what the door writes, built from the door's own constant so a rename
#: there breaks this file rather than silently loosening the rule.
DOOR_LINE = f"{ENCLOSING_SECTION}: [Sikringsbolter](/r761/33-2/sikringsbolter.md)"
DOOR_LINE = f"{ENCLOSING_SECTION}: [Hevekurver](/p761/33-2/hevekurver.md)"
def _bundle(root: Path) -> Path:
@ -55,35 +55,35 @@ def _bundle(root: Path) -> Path:
question's token -- so every lexical hit it has comes from the door's line.
The two known-negatives carry text that merely LOOKS like the line.
"""
(root / "r761").mkdir(parents=True)
(root / "p761").mkdir(parents=True)
(root / "index.md").write_text(
"---\nokf_version: 0.2\nbundle_id: link-fixture\n---\n\n- [r761 (index)](r761/index.md)\n",
"---\nokf_version: 0.2\nbundle_id: link-fixture\n---\n\n- [p761 (index)](p761/index.md)\n",
encoding="utf-8",
)
entries: list[str] = []
def add(slug: str, title: str, body: str) -> None:
entries.append(f"- [{title}]({slug}.md) — adjudication: proposed\n")
(root / "r761" / f"{slug}.md").write_text(
(root / "p761" / f"{slug}.md").write_text(
_FRONTMATTER.format(title=title, digest="1" * 64) + body,
encoding="utf-8",
)
# The shell, as the door leaves it: heading, blank line, one link.
add("skall", "33.212 Boltetype B", f"## 33.212 Boltetype B\n\n{DOOR_LINE}\n")
add("skall", "33.212 Deigtype B", f"## 33.212 Deigtype B\n\n{DOOR_LINE}\n")
# KNOWN-NEGATIVE 1: a human sentence opening with the same two words.
add(
"menneske",
"33.213 Boltetype C",
"## 33.213 Boltetype C\n\nEnclosing section: se kapittelet om sikringsbolter.\n",
"33.213 Deigtype C",
"## 33.213 Deigtype C\n\nEnclosing section: se kapittelet om hevekurver.\n",
)
# KNOWN-NEGATIVE 2: the door's exact form, but not last in the body.
add(
"midt",
"33.214 Boltetype D",
f"## 33.214 Boltetype D\n\n{DOOR_LINE}\n\nDenne teksten staar under lenka.\n",
"33.214 Deigtype D",
f"## 33.214 Deigtype D\n\n{DOOR_LINE}\n\nDenne teksten staar under lenka.\n",
)
(root / "r761" / "index.md").write_text("".join(entries), encoding="utf-8")
(root / "p761" / "index.md").write_text("".join(entries), encoding="utf-8")
return root
@ -127,14 +127,14 @@ def test_the_doors_line_is_the_shells_only_lexical_hit(tmp_path: Path) -> None:
concepts, QUESTION, okf_consume.document_scores(root, QUESTION), link_in_signal=True
)
scored = {concept.concept_id: lexical for concept, _, lexical in ranked}
assert scored["r761/skall"] > 0
assert scored["p761/skall"] > 0
def test_the_instrument_takes_the_line_out_of_the_signal(tmp_path: Path) -> None:
root = _bundle(tmp_path / "bundle")
# Two different numbers, not one predicate read twice.
assert _lexical(root, link_in_signal=True)["r761/skall"] == 1
assert _lexical(root, link_in_signal=False)["r761/skall"] == 0
assert _lexical(root, link_in_signal=True)["p761/skall"] == 1
assert _lexical(root, link_in_signal=False)["p761/skall"] == 0
def test_both_readings_run_on_the_same_concept_objects(tmp_path: Path) -> None:
@ -151,7 +151,7 @@ def test_both_readings_run_on_the_same_concept_objects(tmp_path: Path) -> None:
without = okf_consume.concept_scores(concepts, QUESTION, prior, link_in_signal=False)
assert with_line != without
# The instrument reads; it does not edit what it measures.
shell = next(c for c in concepts if c.concept_id == "r761/skall")
shell = next(c for c in concepts if c.concept_id == "p761/skall")
assert DOOR_LINE in shell.body
@ -162,14 +162,14 @@ def test_a_human_line_opening_with_the_same_two_words_is_not_stripped(tmp_path:
root = _bundle(tmp_path / "bundle")
on = _lexical(root, link_in_signal=False)
off = _lexical(root, link_in_signal=True)
assert off["r761/menneske"] == on["r761/menneske"] > 0
assert off["p761/menneske"] == on["p761/menneske"] > 0
def test_the_doors_form_anywhere_but_last_is_not_stripped(tmp_path: Path) -> None:
root = _bundle(tmp_path / "bundle")
on = _lexical(root, link_in_signal=False)
off = _lexical(root, link_in_signal=True)
assert off["r761/midt"] == on["r761/midt"] > 0
assert off["p761/midt"] == on["p761/midt"] > 0
def test_the_stripper_leaves_a_body_that_never_had_a_link(tmp_path: Path) -> None:
@ -178,8 +178,8 @@ def test_the_stripper_leaves_a_body_that_never_had_a_link(tmp_path: Path) -> Non
def test_the_stripper_removes_the_line_and_the_blank_line_before_it() -> None:
body = f"## 33.212 Boltetype B\n\n{DOOR_LINE}\n"
assert okf_consume.body_without_link_line(body) == "## 33.212 Boltetype B\n"
body = f"## 33.212 Deigtype B\n\n{DOOR_LINE}\n"
assert okf_consume.body_without_link_line(body) == "## 33.212 Deigtype B\n"
# --- The payload is untouched --------------------------------------------------
@ -249,9 +249,8 @@ def _unflagged(root: Path) -> Path:
deterministic, and without a corpus.
"""
_bundle(root)
(root / "r761" / "skall.md").write_text(
_FRONTMATTER.format(title="33.212 Boltetype B", digest="1" * 64)
+ "## 33.212 Boltetype B\n",
(root / "p761" / "skall.md").write_text(
_FRONTMATTER.format(title="33.212 Deigtype B", digest="1" * 64) + "## 33.212 Deigtype B\n",
encoding="utf-8",
)
return root
@ -269,7 +268,7 @@ def test_the_default_reading_takes_the_doors_line_out_of_the_signal(tmp_path: Pa
"""`searchable_text` called with NO parameter: the door's line is gone."""
root = _bundle(tmp_path / "bundle")
texts = _default_texts(root)
assert DOOR_LINE not in texts["r761/skall"]
assert DOOR_LINE not in texts["p761/skall"]
def test_the_default_reading_scores_the_shell_at_zero(tmp_path: Path) -> None:
@ -286,7 +285,7 @@ def test_the_default_reading_scores_the_shell_at_zero(tmp_path: Path) -> None:
concepts, QUESTION, okf_consume.document_scores(root, QUESTION)
)
scored = {concept.concept_id: lexical for concept, _, lexical in ranked}
assert scored["r761/skall"] == 0
assert scored["p761/skall"] == 0
def test_the_default_payload_reads_the_body_without_the_line(tmp_path: Path) -> None:
@ -327,7 +326,7 @@ def test_the_old_reading_is_still_reachable_and_still_does_the_old_thing(
default's. Both halves, because either alone would pass on a no-op.
"""
root = _bundle(tmp_path / "bundle")
assert _lexical(root, link_in_signal=True)["r761/skall"] == 1
assert _lexical(root, link_in_signal=True)["p761/skall"] == 1
default = okf_consume.serialise(okf_consume.build_payload(root, question=QUESTION))
old = okf_consume.serialise(
okf_consume.build_payload(root, question=QUESTION, link_in_signal=True)
@ -342,7 +341,7 @@ def test_the_flagged_bundle_read_by_default_delivers_what_the_unflagged_one_does
The flagged build read with the default (Y) against the unflagged build read
with the default (Z). `spent` can be equal here for one reason and it is the
same reason it was equal on R761: under this reading the shell has no
same reason it was equal on the reference standard: under this reading the shell has no
lexical hit at all, so it is never delivered, and every concept that IS
delivered holds bytes the door never touched.
"""
@ -358,8 +357,8 @@ def test_a_human_line_with_the_same_two_words_survives_the_default_reading(
"""Known-negative 1, restated against the default path."""
root = _bundle(tmp_path / "bundle")
texts = _default_texts(root)
assert DOOR_LINE not in texts["r761/skall"]
assert "Enclosing section: se kapittelet om sikringsbolter." in texts["r761/menneske"]
assert DOOR_LINE not in texts["p761/skall"]
assert "Enclosing section: se kapittelet om hevekurver." in texts["p761/menneske"]
def test_the_doors_form_anywhere_but_last_survives_the_default_reading(
@ -368,9 +367,9 @@ def test_the_doors_form_anywhere_but_last_survives_the_default_reading(
"""Known-negative 2, restated against the default path."""
root = _bundle(tmp_path / "bundle")
texts = _default_texts(root)
assert DOOR_LINE not in texts["r761/skall"]
assert DOOR_LINE in texts["r761/midt"]
assert "Denne teksten staar under lenka." in texts["r761/midt"]
assert DOOR_LINE not in texts["p761/skall"]
assert DOOR_LINE in texts["p761/midt"]
assert "Denne teksten staar under lenka." in texts["p761/midt"]
def test_the_two_readings_differ_in_rank_and_never_in_an_excerpts_bytes(

View file

@ -117,8 +117,8 @@ def test_the_two_synthetic_bundles_share_no_question_token(scratch: Path) -> Non
}
for name, root in bundles.items()
}
shared = words["bridge-notes"] & words["kitchen-notes"]
assert "spennvidde" not in shared
shared = words["garden-notes"] & words["kitchen-notes"]
assert "bedbredde" not in shared
assert "surdeig" not in shared
@ -131,8 +131,8 @@ def test_a_question_set_whose_bytes_moved_is_a_usage_error_not_a_red_row(
payload = {
"sporsmal": [
{
"bundles": ["bridge-notes"],
"atomer": [{"kilde_anker": "spennvidde", "kilde_sitat": "24 meter"}],
"bundles": ["garden-notes"],
"atomer": [{"kilde_anker": "bedbredde", "kilde_sitat": "24 meter"}],
}
]
}
@ -145,7 +145,7 @@ def test_a_question_set_whose_bytes_moved_is_a_usage_error_not_a_red_row(
json.dumps({"versjon": 4, "sha256": {"sporsmal.json": digest}}), encoding="utf-8"
)
anchors = gate.read_anchor_set(questions, freeze, want_version=4)
assert anchors.pairs == (("bridge-notes", "spennvidde", "24 meter"),)
assert anchors.pairs == (("garden-notes", "bedbredde", "24 meter"),)
questions.write_text(json.dumps(payload) + " ", encoding="utf-8")
with pytest.raises(gate.GateUsage, match="is not the file"):
@ -250,16 +250,20 @@ def test_row_twos_denominator_is_the_sets_and_not_what_the_surface_reached(
import hashlib
bundles = gate.corpus(scratch / "base")
# The second reachable pair is the fixture's own second triple, read rather
# than restated, so the test and the corpus cannot drift apart.
second_bundle, second_anchor, second_quote = gate.SYNTHETIC_ANCHORS[1]
assert second_bundle == "garden-notes"
questions = scratch / "sporsmal.json"
questions.write_text(
json.dumps(
{
"sporsmal": [
{
"bundles": ["bridge-notes"],
"bundles": ["garden-notes"],
"atomer": [
{"kilde_anker": "spennvidde", "kilde_sitat": "spennvidde 24 meter"},
{"kilde_anker": "rekkverk", "kilde_sitat": "1,2 meter hoeyt"},
{"kilde_anker": "bedbredde", "kilde_sitat": "bedbredde 24 meter"},
{"kilde_anker": second_anchor, "kilde_sitat": second_quote},
{
"kilde_anker": "finnes-ikke",
"kilde_sitat": "dette staar ingen steder",
@ -288,7 +292,7 @@ def test_row_twos_denominator_is_the_sets_and_not_what_the_surface_reached(
for row in gate.evaluate(
scratch / "run",
anchors=anchors,
real={"bridge-notes": bundles["bridge-notes"]},
real={"garden-notes": bundles["garden-notes"]},
)
}
row = rows[2]

View file

@ -400,7 +400,7 @@ def test_normalise_drops_tokens_under_three_characters() -> None:
def test_normalise_holds_an_identifier_number_as_one_token() -> None:
# MEASURED 2026-09-08 over three vegnormal bundles (446, 1133 and 270
# MEASURED 2026-09-08 over three third-party bundles (446, 1133 and 270
# concepts): `_TOKEN_SPLIT_RE` shatters `10.2-2` into `10`, `2`, `2` and
# `MIN_TOKEN_LENGTH` then drops every piece, so a question naming a
# requirement number reaches the ranker carrying only the word `krav` --
@ -410,7 +410,7 @@ def test_normalise_holds_an_identifier_number_as_one_token() -> None:
assert "10.2-2" in okf_consume.normalise("Krav 10.2\u20142")
assert okf_consume.normalise("3.3.1\u201413") == ("3.3.1-13",)
assert okf_consume.normalise("2.9.2\u201412") == ("2.9.2-12",)
assert okf_consume.normalise("R610.4") == ("r610.4",)
assert okf_consume.normalise("P610.4") == ("p610.4",)
assert okf_consume.normalise("4.2.1") == ("4.2.1",)
@ -1730,7 +1730,7 @@ def test_the_cli_exposes_the_reservation_and_omitting_it_reproduces_the_default_
#: identifier, and the identifier is worth the same as the common word because
#: `_overlap` counts. Synthetic rather than borrowed from a bundle, because a
#: fixture that is a corpus measures that corpus.
RARITY_QUESTION = "Hva krever Krav 10.2-2 i N500? Gjengi det sentrale vilkåret."
RARITY_QUESTION = "Hva krever Krav 10.2-2 i Q500? Gjengi det sentrale vilkåret."
RARITY_GOLD = "krav/c-29"
@ -1765,7 +1765,7 @@ def _rarity_corpus(*, identifier_in_body: bool) -> list[okf_consume.Concept]:
real corpora turned out to have, and it decides whether the weight can do
anything at all -- see the two tests below.
"""
common = "Kravet i N500 gjengir det sentrale vilkåret for anlegget."
common = "Kravet i Q500 gjengir det sentrale vilkåret for anlegget."
concepts = [
_synthetic(f"krav/c-{index:02d}", f"Krav 3.{index}-1 Alminnelig bestemmelse", common)
for index in range(29)
@ -1819,7 +1819,7 @@ def test_counting_leaves_the_identifier_worth_no_more_than_the_common_word() ->
def test_weighting_a_hit_by_its_rarity_lifts_the_identifier_into_the_cut() -> None:
# The arm where the weight can act: the identifier is in the body too, so
# weighting REORDERS the body signal. This is the shape N200 and the K2
# weighting REORDERS the body signal. This is the shape a third-party bundle and the K2
# price sheet have, and both moved (withheld -> delivered rank 8; candidate
# rank 10 -> 2).
concepts = _rarity_corpus(identifier_in_body=True)
@ -1840,7 +1840,7 @@ def test_a_rarity_weight_cannot_move_a_signal_the_gold_already_leads() -> None:
When the identifier is in the title alone, the gold already leads the title
signal by counting, the weight makes its lead larger and its RANK identical,
and the two signals that hold it down cannot see the identifier at all.
Measured on the real bundles: N500 35 -> 35, N100 96 -> 103.
Measured on two real bundles: 35 -> 35 and 96 -> 103.
"""
concepts = _rarity_corpus(identifier_in_body=False)
weights = okf_consume.rarity_weights(
@ -1943,7 +1943,7 @@ def test_a_word_every_concept_carries_weighs_exactly_nothing() -> None:
okf_consume.normalise(RARITY_QUESTION), okf_consume.searchable_text(concepts)
)
assert weights["krav"] == 0.0
assert weights["10.2-2"] > weights["n500"] > 0.0
assert weights["10.2-2"] > weights["q500"] > 0.0
def test_a_question_token_no_concept_carries_is_weighted_but_never_consumed() -> None:
@ -1956,9 +1956,9 @@ def test_a_question_token_no_concept_carries_is_weighted_but_never_consumed() ->
def test_the_weight_is_computed_from_the_bundle_and_not_from_a_constant() -> None:
# Same question, two corpora, two different weights for the same token:
# the number comes from the bundle in hand or it comes from nowhere.
small = okf_consume.rarity_weights(("n500",), ["N500 gjelder", "noe helt annet"])
large = okf_consume.rarity_weights(("n500",), ["N500 gjelder"] + ["noe helt annet"] * 9)
assert large["n500"] > small["n500"]
small = okf_consume.rarity_weights(("q500",), ["Q500 gjelder", "noe helt annet"])
large = okf_consume.rarity_weights(("q500",), ["Q500 gjelder"] + ["noe helt annet"] * 9)
assert large["q500"] > small["q500"]
def test_the_weight_is_off_by_default_and_the_default_payload_is_unmoved() -> None:
@ -2036,15 +2036,15 @@ def test_the_weight_reaches_the_title_signal_and_reorders_it() -> None:
# answers THREE, so counting puts the gold behind all of them; only the
# rarity of the identifier can turn that around, and only if the weight
# reaches the title-and-id signal. Bodies are identical, so the body signal
# decides nothing. This is the N200 shape, where the gold's title-signal
# decides nothing. This is a third-party bundle's shape, where the gold's title-signal
# rank moved 8 -> 4 and the gold went from withheld to delivered.
body = "Denne bestemmelsen gjelder for anlegget."
concepts = [
_synthetic(f"krav/t-{index:02d}", f"Krav 3.{index}-1 N500 gjengir bestemmelsen", body)
_synthetic(f"krav/t-{index:02d}", f"Krav 3.{index}-1 Q500 gjengir bestemmelsen", body)
for index in range(29)
]
concepts.append(_synthetic("krav/t-29", "Krav 10.2-2 Tekniske bygg", body))
question = "Hva krever Krav 10.2-2 i N500? Gjengi det sentrale vilkåret."
question = "Hva krever Krav 10.2-2 i Q500? Gjengi det sentrale vilkåret."
weights = okf_consume.rarity_weights(
okf_consume.normalise(question), okf_consume.searchable_text(concepts)
)
@ -2142,7 +2142,7 @@ def test_a_short_identifier_becomes_reachable_because_equality_has_no_floor() ->
assert okf_consume.tokens_match("9.2", "9.2") is True
assert okf_consume.tokens_match("9.2", "9.3") is False
# The floor still stands for words, which is what it was measured for.
assert okf_consume.tokens_match("veg", "veg") is False
assert okf_consume.tokens_match("ost", "ost") is False
def test_the_identifier_rule_makes_a_unique_number_unique_in_the_document_frequency() -> None:
@ -2162,7 +2162,7 @@ def test_the_identifier_rule_makes_a_unique_number_unique_in_the_document_freque
def test_is_identifier_names_numbers_and_not_words() -> None:
assert okf_consume.is_identifier("3.3.1-13") is True
assert okf_consume.is_identifier("10.2-2") is True
assert okf_consume.is_identifier("r610.4") is True
assert okf_consume.is_identifier("p610.4") is True
assert okf_consume.is_identifier("brannsikring") is False
assert okf_consume.is_identifier("krav") is False
# A bare number is not an identifier: it has no separator, and every page
@ -2273,7 +2273,7 @@ def test_the_lookup_keeps_the_fused_order_among_the_concepts_it_lifts() -> None:
#: and a concept carrying neither address nor locator. It exists because the
#: two real bundles are complementary and neither exercises both forms --
#: measured 2026-09-08, K2 writes `sources` in FLOW form on 629 of 629 concepts
#: and carries `req_number` on 0, while the N500 bundle writes BLOCK form on 270
#: and carries `req_number` on 0, while a third-party bundle writes BLOCK form on 270
#: of 270, carries `req_number` on 269 and carries no locator key at all.
PROVENANCE = PROJECT_ROOT / "tests" / "fixtures" / "consume-provenance"
PROVENANCE_QUESTION = "Hva krever Krav 10.2-2?"
@ -2405,7 +2405,7 @@ def test_every_top_level_source_key_passes_through_including_one_this_library_ne
tmp_path: Path,
) -> None:
# A named list is a list of the producers someone thought of. Measured
# 2026-09-08 on the N500 bundle: 269 of 274 concepts carry
# 2026-09-08 on a third-party bundle: 269 of 274 concepts carry
# `source_element_id`, a locator that repository chose under O3's own rule
# ("the key says what it indexes") and that this library does not write. A
# four-key allowlist drops it, and the excerpt then names a document without
@ -2632,7 +2632,7 @@ def test_a_near_miss_is_named_so_a_reader_can_ask_for_it() -> None:
"""Every entry carries the title and the document, not just the id.
`--withheld-titles` bought the title for all one entry per withheld concept and was off by
measurement (+37.9 % on N500). At twenty entries the same field costs
measurement (+37.9 % on a third-party bundle). At twenty entries the same field costs
nothing and is the one thing that makes the list usable: a reader who sees
a near miss by name can ask for it.
"""

View file

@ -1,8 +1,8 @@
"""A path importer holds the module object, and `sys.modules` is not it.
WHAT WAS REPORTED, AND BY WHOM. `vegnormal-okf` reported after v0.7.0 that the
shim broke a caller importing it with `importlib.util.spec_from_file_location`.
Reproduced here, and it is not a spelling mistake: `sys.modules[__name__] =
WHAT WAS REPORTED, AND BY WHOM. A downstream consumer repository reported
after v0.7.0 that the shim broke a caller importing it with
`importlib.util.spec_from_file_location`. Reproduced here, and it is not a spelling mistake: `sys.modules[__name__] =
_impl` replaces the REGISTRY entry, and a path importer already holds a
different module object -- the one `module_from_spec` made and `exec_module`
ran. That object keeps whatever the file's own globals ended up with, which is

View file

@ -31,7 +31,7 @@ from llm_ingestion_okf.errors import IngestError # noqa: E402
DOCUMENTS = {
"krav.md": (
"## 4 Grunnforhold\n\nGrunnen er morene over berg.\n\n"
"### 4.1 Loesmasser\n\nLoesmassene er telefarlige.\n"
"### 4.1 Loesmasser\n\nLoesmassene er vaate.\n"
),
"notat.md": "Et notat uten overskrift, uten tabell og uten nummerering.\n",
}

View file

@ -42,7 +42,7 @@ CONTRACT = PROJECT_ROOT / "docs" / "consumption-contract.md"
#: shell title -> the section its link and its excerpt name. The same pairs
#: `tests/test_shell_parent.py` holds for the plan.
LINKED = {
"Rekkverk": "Vedlikehold av utstyr",
"Gelender": "Vedlikehold av utstyr",
"Utskifting": "Vedlikehold av utstyr",
"Utskifting av list": "Utskifting av enkeltdeler",
"Utskifting av stolpe": "Utskifting av enkeltdeler",
@ -155,7 +155,7 @@ def test_a_pointer_resolves_inside_its_own_document(tmp_path: Path) -> None:
other = (
FIXTURE.read_bytes()
.replace(b"Fikstur for arvet kontekst", b"Annen fikstur")
.replace(b"alt utstyr langs vegen", b"utstyr i tunnel")
.replace("alt utstyr i kjøkkenet".encode(), b"utstyr i kjeller")
)
bundle = _build(
tmp_path,
@ -164,7 +164,7 @@ def test_a_pointer_resolves_inside_its_own_document(tmp_path: Path) -> None:
"--shell-parent",
)
payload = consume.build_payload(
bundle, question="Utskifting av list og stolpe i rekkverk", k=50
bundle, question="Utskifting av list og stolpe i gelender", k=50
)
carried = [e for e in payload["excerpts"] if "parent" in e] # type: ignore[attr-defined]
documents = {str(e["concept_id"]).split("/")[0] for e in carried}

View file

@ -30,7 +30,7 @@ from llm_ingestion_okf import cli, consume, contract_check, skill
FIXTURE = Path(__file__).parent / "fixtures" / "sts-inherit.xml"
SHELL = "Utskifting av list"
#: The one lettered point the shell's enclosing section states.
INHERITED = "Omfatter utskifting av skadde enkeltdeler i rekkverk."
INHERITED = "Omfatter utskifting av skadde enkeltdeler i gelender."
def _flagged(tmp_path: Path) -> Path:

View file

@ -65,9 +65,9 @@ def test_the_font_reader_is_off_by_default() -> None:
text = _extract(FONT_PDF)
assert text == (
"Generelle tekniske krav\n"
"Utkilingen skal ha helning 1:15.\n"
"Deigkulene skal ha hevetid 1:15.\n"
"Merking\n"
"Kravet gjelder alle veiklasser."
"Kravet gjelder alle bakeformer."
)
assert "#" not in text
@ -77,9 +77,9 @@ def test_font_headings_emit_atx_the_proposer_already_reads() -> None:
text = _extract(FONT_PDF, pdf_headings=True)
assert text == (
"# Generelle tekniske krav\n"
"Utkilingen skal ha helning 1:15.\n"
"Deigkulene skal ha hevetid 1:15.\n"
"## Merking\n"
"Kravet gjelder alle veiklasser."
"Kravet gjelder alle bakeformer."
)
matched = [line for line in text.split("\n") if _ATX.match(line)]
assert matched == ["# Generelle tekniske krav", "## Merking"]
@ -167,12 +167,12 @@ def _fake_engine(lines: tuple[str, ...]) -> types.ModuleType:
def test_ocr_recovers_a_page_that_produced_no_text(monkeypatch: pytest.MonkeyPatch) -> None:
"""Without the flag this document is `extractor_empty_pdf`; with it, text."""
monkeypatch.setitem(sys.modules, "rapidocr", _fake_engine(("Krav til helning", "1:15")))
monkeypatch.setitem(sys.modules, "rapidocr", _fake_engine(("Krav til hevetid", "1:15")))
extract_module._pdf_pages.cache_clear()
with pytest.raises(ExtractionError) as excinfo:
_extract(NO_TEXT_PDF)
assert excinfo.value.code == "extractor_empty_pdf"
assert _extract(NO_TEXT_PDF, ocr=True) == "Krav til helning\n1:15"
assert _extract(NO_TEXT_PDF, ocr=True) == "Krav til hevetid\n1:15"
def test_ocr_leaves_a_page_that_already_has_text_alone(
@ -198,11 +198,11 @@ def test_the_cid_trigger_is_a_share_of_the_page_and_has_a_measured_floor() -> No
"""
assert extract_module.OCR_CID_SHARE == 0.10
assert extract_module.cid_share("(cid:3)(cid:4)(cid:5)") == 1.0
assert extract_module.cid_share("Krav til helning på utkilingen") == 0.0
assert extract_module.cid_share("Krav til hevetid på deigkulene") == 0.0
assert extract_module.cid_share("") == 0.0
assert extract_module.needs_ocr("") is True
assert extract_module.needs_ocr(" \n ") is True
assert extract_module.needs_ocr("Krav til helning") is False
assert extract_module.needs_ocr("Krav til hevetid") is False
# --- both flags, reached from `okf build` ------------------------------------

View file

@ -93,9 +93,10 @@ def test_an_unresolvable_destination_is_dropped_and_counted() -> None:
def test_two_bookmarks_on_one_line_are_counted_not_lost() -> None:
"""The node that shares a line is DROPPED on purpose -- and never silently.
Measured on R761: 2 763 nodes entered the bridge, 2 762 marks came out and
`unresolved` was 0, so one node left no trace anywhere. The line it shared
was line 0, between the tree's own root node and `SVV - Forside`.
Measured on a 701-page reference standard: 2 763 nodes entered the bridge,
2 762 marks came out and `unresolved` was 0, so one node left no trace
anywhere. The line it shared was line 0, between the tree's own root node and
its front-matter node.
Keeping BOTH was measured on that same document and felled: the two
candidates then open at the same offset, and the first closes with an
@ -107,7 +108,7 @@ def test_two_bookmarks_on_one_line_are_counted_not_lost() -> None:
outline = extract.pdf_outline(COLLISION.name, data)
assert [(mark.line, mark.level, mark.title) for mark in outline.marks] == [
(0, 1, "R761 Prosesskoden"),
(0, 1, "P761 Oppskriftsboka"),
]
assert outline.unresolved == 0
assert outline.collided == 1

View file

@ -28,9 +28,9 @@ import pytest
from llm_ingestion_okf import propose as okf_propose_segments
from llm_ingestion_okf.segmentation import parse_segmentation_plan
DOCUMENT = """# N500 Vegbygging
DOCUMENT = """# Q500 Kakebaking
Innledende tekst om vegbygging og dens omfang.
Innledende tekst om kakebaking og dens omfang.
## 3.1 Brannkonsept
@ -84,7 +84,7 @@ To uavhengige roemningsveier fra hver branncelle.
GOLDEN_PROPOSED_AT = "2026-09-03T00:00:00Z"
def write(tmp_path: Path, text: str = DOCUMENT, name: str = "n500.md") -> Path:
def write(tmp_path: Path, text: str = DOCUMENT, name: str = "q500.md") -> Path:
path = tmp_path / name
path.write_text(text, encoding="utf-8", newline="")
return path
@ -420,7 +420,7 @@ LONG_PARAGRAPH = ("Krav til seksjonering av bygget over flere etasjer. " * 40).s
UNSTRUCTURED = "\n\n".join(f"{LONG_PARAGRAPH} Avsnitt {i}." for i in range(12)) + "\n"
STRUCTURED_WITH_A_LONG_TAIL = (
"# N500 Vegbygging\n\nInnledende tekst om vegbygging.\n\n"
"# Q500 Kakebaking\n\nInnledende tekst om kakebaking.\n\n"
"## 3.1 Brannkonsept\n\nKort avsnitt om seksjonering.\n\n"
"## 3.2 Roemning\n\n" + UNSTRUCTURED
)

View file

@ -76,7 +76,7 @@ def test_a_pdf_reports_the_page_a_character_range_came_from() -> None:
units = source_units("three-page-krav.pdf", data, text)
assert units is not None
assert units.unit == "pages"
assert units.covering(0, len("Side en om helning")) == (1, 1)
assert units.covering(0, len("Side en om hevetid")) == (1, 1)
assert units.covering(text.index("Side tre"), len(text)) == (3, 3)
assert units.covering(0, len(text)) == (1, 3)
# The BOUNDARY, and it is the assertion that has to exist: the blank line

View file

@ -1,8 +1,9 @@
"""`okf quality` -- the per-file-type verdict, and what it refuses to say.
`okf check` is a CONTRACT check: it asks whether a payload carries what a claim
must rest on. Measured 2026-09-10 by `vegnormal-okf`, it returned 0 findings and
exit 0 on three arms over one corpus whose hit@k ranged from 6 of 6 to 0 of 6.
must rest on. Measured 2026-09-10 by a downstream consumer repository, it
returned 0 findings and exit 0 on three arms over one corpus whose hit@k ranged
from 6 of 6 to 0 of 6.
This module is the other question -- did the cut find anything worth reading --
and its whole discipline is that it answers it PER FILE TYPE, with the
denominator printed, and never answers PASS for a type it has no measurement
@ -108,7 +109,7 @@ def test_a_healthy_type_without_a_threshold_is_unmeasured_and_never_pass(
def test_concepts_without_a_source_file_are_unmeasured(tmp_path: Path) -> None:
"""The shape three of the four evidence corpora arrive in.
`n100-2023`, `n200-2024` and `n500-2024` carry `source_file` on 0 of 446,
Three bundles built by another producer carry `source_file` on 0 of 446,
0 of 1 133 and 0 of 270 concepts, so a per-file-type gate has no type to
speak about. Measured 2026-09-12; the gate says so instead of passing them.
"""
@ -190,7 +191,7 @@ def test_every_threshold_names_the_corpus_and_the_denominator_it_was_read_off()
def test_a_rate_over_too_few_documents_is_not_a_rate(tmp_path: Path) -> None:
"""FOUND BY RUNNING THE GATE, not by reading it.
`~/repos/vegnormal-okf/build/sk2-bundle-default` is one PDF cut into 2 182
A downstream consumer's default build of one reference PDF is cut into 2 182
concepts. Against the 32-document reference its one-concept share is 0 of 1,
which the first version of this rule read as PASS -- a verdict resting on a
denominator of one, which is the exact failure `MIN_DOCUMENTS_FOR_A_THRESHOLD`
@ -271,9 +272,9 @@ def test_the_normalisation_reproduces_the_fasit_key() -> None:
Measured over the shipped fasit before anything was written: 2 761 of 2 761
rows reproduce, and the real file pins it below where it exists.
"""
assert quality.normalise_title(" 2.1 Hoved Prosesser\n") == "2.1hovedprosesser"
assert quality.normalise_title("1Bruksområder for Prosesskoden") == (
"1bruksområderforprosesskoden"
assert quality.normalise_title(" 2.1 Hoved Kapitler\n") == "2.1hovedkapitler"
assert quality.normalise_title("1Bruksområder for Oppskriftsboka") == (
"1bruksområderforoppskriftsboka"
)

View file

@ -13,9 +13,9 @@ Three rules this suite is written under, all of them the house pattern:
- **Nothing here touches a private corpus or the network.** The real set in
`claude-code-llm-wiki` is never read by a test: its adapter is exercised
against files written in its shape, with invented content, inside
`tmp_path`. The test track built on material tied to the operator's
employer was retired 2026-09-21, and with it the gate's row 9 and two
adapters; nothing below reads, needs or names that material.
`tmp_path`. The earlier test track was retired 2026-09-21, and with it the
gate's row 9 and two adapters; nothing below reads, needs or names that
material.
"""
from __future__ import annotations
@ -601,8 +601,8 @@ def test_the_wiki_adapter_reads_its_own_shape_and_hits_by_source_file(tmp_path:
def test_an_unknown_real_set_name_is_refused(tmp_path: Path) -> None:
path = tmp_path / "x.json"
path.write_text("{}", encoding="utf-8")
# The two retired adapters are unknown names now, like any other.
for name in ("something-else", "r761", "vegnormal"):
# A retired adapter's name is an unknown name now, like any other.
for name in ("something-else", "another-set", "retired"):
with pytest.raises(gate.GateUsage) as refusal:
gate.read_real_set(name, path, gate.sha256_of(path))
assert "unknown real set" in str(refusal.value)

View file

@ -48,13 +48,13 @@ IDENTITY = FIXTURES / "sts-identity.xml"
OPAQUE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.xml"
ADDRESS = "https://normer.example.test/api/sts/900?languageCode=nb"
STATED_SOURCES = f"[{{ resource: {ADDRESS}, title: R900:2024 }}]"
STATED_SOURCES = f"[{{ resource: {ADDRESS}, title: P900:2024 }}]"
# K3-22: the address above carries `?`, which ends a plain scalar inside a
# PyYAML flow mapping, and the guard refuses the quoted form -- so no build may
# write it. The flag's GRAMMAR still takes it (it only splits); the build
# refuses it. A build that writes an address uses this one.
WRITABLE_ADDRESS = "https://normer.example.test/api/sts/900/nb"
WRITABLE_SOURCES = f"[{{ resource: {WRITABLE_ADDRESS}, title: R900:2024 }}]"
WRITABLE_SOURCES = f"[{{ resource: {WRITABLE_ADDRESS}, title: P900:2024 }}]"
MARKDOWN = b"# Innledning\n\nTekst her.\n\n# Omfang\n\nMer tekst her.\n"
@ -98,8 +98,8 @@ def _frontmatter_lines(path: Path) -> list[str]:
def test_the_flag_splits_on_the_first_equals_sign_and_only_there() -> None:
assert cli.frontmatter_from_flags(
[f"sources={STATED_SOURCES}", "utgave=R900:2024", "merknad=a=b"]
) == {"sources": STATED_SOURCES, "utgave": "R900:2024", "merknad": "a=b"}
[f"sources={STATED_SOURCES}", "utgave=P900:2024", "merknad=a=b"]
) == {"sources": STATED_SOURCES, "utgave": "P900:2024", "merknad": "a=b"}
def test_a_flag_without_an_equals_sign_is_refused() -> None:
@ -125,7 +125,7 @@ def test_every_concept_carries_the_stated_keys_once(tmp_path: Path) -> None:
_inbox(tmp_path),
bundle,
"--frontmatter",
"utgave=R900:2024",
"utgave=P900:2024",
"--frontmatter",
f"sources={WRITABLE_SOURCES}",
)
@ -137,7 +137,7 @@ def test_every_concept_carries_the_stated_keys_once(tmp_path: Path) -> None:
)
for path in concepts:
lines = _frontmatter_lines(path)
assert lines.count("utgave: R900:2024") == 1, path
assert lines.count("utgave: P900:2024") == 1, path
# ONE `sources` line, and it is the stated one: the flag REPLACES what
# the door would derive -- the document's own title on the STS file,
# the file name on the markdown one -- and never adds a second.
@ -154,7 +154,7 @@ def test_the_stated_address_reads_back_through_this_packages_own_readers(
for path in _concept_files(bundle):
assert parse_frontmatter(path)["sources"] == WRITABLE_SOURCES
assert read_sources(_frontmatter_lines(path)) == (
({"resource": WRITABLE_ADDRESS, "title": "R900:2024"},),
({"resource": WRITABLE_ADDRESS, "title": "P900:2024"},),
True,
)
@ -164,7 +164,7 @@ def test_the_flag_adds_its_line_and_moves_nothing_else(tmp_path: Path) -> None:
inbox = _inbox(tmp_path)
plain, stamped = tmp_path / "plain", tmp_path / "stamped"
assert _main(inbox, plain) == 0
assert _main(inbox, stamped, "--frontmatter", "utgave=R900:2024") == 0
assert _main(inbox, stamped, "--frontmatter", "utgave=P900:2024") == 0
before = {
p.relative_to(plain).as_posix(): p.read_text(encoding="utf-8")
@ -180,18 +180,18 @@ def test_the_flag_adds_its_line_and_moves_nothing_else(tmp_path: Path) -> None:
moved = [name for name in before if before[name] != after[name]]
assert moved, "the flag must reach at least one concept for this control to mean anything"
for name in moved:
assert after[name].replace("utgave: R900:2024\n", "", 1) == before[name], name
assert after[name].replace("utgave: P900:2024\n", "", 1) == before[name], name
def test_the_unsegmented_route_carries_them_too(tmp_path: Path) -> None:
bundle = tmp_path / "bundle"
assert (
_main(_inbox(tmp_path), bundle, "--segments", "off", "--frontmatter", "utgave=R900:2024")
_main(_inbox(tmp_path), bundle, "--segments", "off", "--frontmatter", "utgave=P900:2024")
== 0
)
concepts = _concept_files(bundle)
assert concepts
assert all(parse_frontmatter(path)["utgave"] == "R900:2024" for path in concepts)
assert all(parse_frontmatter(path)["utgave"] == "P900:2024" for path in concepts)
# --- what is refused, before anything is written -----------------------------
@ -258,7 +258,7 @@ def test_a_value_spanning_lines_is_refused_through_the_api(tmp_path: Path) -> No
tmp_path / "bundle",
bundle_id="run",
okf_version="0.2",
frontmatter={"utgave": "R900\nsources: [{ resource: x }]"},
frontmatter={"utgave": "P900\nsources: [{ resource: x }]"},
)
assert caught.value.code == "run_frontmatter_invalid"
assert not (tmp_path / "bundle").exists()

View file

@ -375,7 +375,7 @@ def test_the_message_names_every_differing_component_not_just_the_first() -> Non
SECTIONS = (
"0 Forord: bakgrunn for konseptet.\n" # 0..34
"1 Brannkonsept: krav til seksjonering.\n" # 34..73
"2 Roemning: to uavhengige veier.\n" # 73..106
"2 Roemning: to uavhengige ruter.\n" # 73..106
"3 Baereevne: R60 for hovedbaeresystem.\n" # 106..145
"4 Slokkeanlegg: sprinkler i hele bygget.\n" # 145..186
)

View file

@ -54,7 +54,7 @@ def drop(inbox: Path, name: str, text: str = DOCUMENT) -> Path:
return path
def _extracted_text_sha256(source_bytes: bytes, filename: str = "n500.md") -> str:
def _extracted_text_sha256(source_bytes: bytes, filename: str = "q500.md") -> str:
return hashlib.sha256(extract_text(filename, source_bytes).encode("utf-8")).hexdigest()
@ -120,8 +120,8 @@ def tree(bundle: Path) -> dict[str, bytes]:
def test_two_documents_claiming_one_segment_path_are_both_refused(tmp_path: Path) -> None:
# Identical bytes, so ONE plan covers both documents and both expand onto
# the same paths. Refused together, before any gate call or write.
drop(tmp_path / "round", "n500.md")
drop(tmp_path / "round", "v720.md")
drop(tmp_path / "round", "q500.md")
drop(tmp_path / "round", "w720.md")
plan = build_plan(
DOCUMENT.encode("utf-8"), ("krav/3-1/brannkonsept.md", "krav/3-2/roemning.md")
)
@ -129,14 +129,14 @@ def test_two_documents_claiming_one_segment_path_are_both_refused(tmp_path: Path
result = run(tmp_path, plan=plan)
assert {entry.error.code for entry in result.failed} == {"inbox_slug_collision"}
assert {entry.source_file for entry in result.failed} == {"n500.md", "v720.md"}
assert {entry.source_file for entry in result.failed} == {"q500.md", "w720.md"}
assert tree(tmp_path / "bundle") == {}
assert result.persisted == ()
def test_the_collision_message_names_the_contested_path(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500.md")
drop(tmp_path / "round", "v720.md")
drop(tmp_path / "round", "q500.md")
drop(tmp_path / "round", "w720.md")
plan = build_plan(DOCUMENT.encode("utf-8"), ("krav/3-1/brannkonsept.md",))
result = run(tmp_path, plan=plan)
assert all("krav/3-1/brannkonsept.md" in str(entry.error) for entry in result.failed)
@ -154,7 +154,7 @@ def test_a_flat_run_still_refuses_two_names_reducing_to_one_slug(tmp_path: Path)
def test_a_single_over_long_component_is_refused(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md")
source = drop(tmp_path / "round", "q500.md")
too_long = "a" * (NAME_MAX_BYTES + 1)
plan = build_plan(source.read_bytes(), (f"krav/{too_long}.md",))
result = run(tmp_path, plan=plan)
@ -166,7 +166,7 @@ def test_a_joined_path_over_name_max_with_legal_components_is_accepted(tmp_path:
# The check the joined measurement gets backwards. Every component here is
# well under NAME_MAX; the joined path is well over it, and the filesystem
# does not care -- NAME_MAX is a per-entry limit.
source = drop(tmp_path / "round", "n500.md")
source = drop(tmp_path / "round", "q500.md")
deep = "/".join(f"niva-{index}-{'x' * 40}" for index in range(6))
target = f"{deep}/krav.md"
assert len(target.encode("utf-8")) > NAME_MAX_BYTES
@ -183,7 +183,7 @@ def test_a_joined_path_over_name_max_with_legal_components_is_accepted(tmp_path:
def test_concepts_holds_one_entry_per_concept_and_persisted_one_per_source(
tmp_path: Path,
) -> None:
source = drop(tmp_path / "round", "n500.md")
source = drop(tmp_path / "round", "q500.md")
plan = build_plan(source.read_bytes(), ("krav/a.md", "krav/b.md", "krav/c.md"))
result = run(tmp_path, plan=plan)
@ -192,19 +192,19 @@ def test_concepts_holds_one_entry_per_concept_and_persisted_one_per_source(
# `persisted` keeps its existing meaning: one entry per SOURCE FILE. A
# consumer reading it sees exactly what it saw before segmentation existed.
assert len(result.persisted) == 1
assert result.persisted[0].source_file == "n500.md"
assert result.persisted[0].source_file == "q500.md"
def test_under_default_the_two_fields_agree(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500.md")
drop(tmp_path / "round", "v720.md", "annet\n")
drop(tmp_path / "round", "q500.md")
drop(tmp_path / "round", "w720.md", "annet\n")
result = run(tmp_path, profile=DEFAULT)
assert len(result.persisted) == 2
assert result.concepts == result.persisted
def test_concepts_names_every_segment_path_it_wrote(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md")
source = drop(tmp_path / "round", "q500.md")
plan = build_plan(source.read_bytes(), ("krav/3-1/a.md", "krav/3-2/b.md"))
result = run(tmp_path, plan=plan)
bundle = tmp_path / "bundle"
@ -212,4 +212,4 @@ def test_concepts_names_every_segment_path_it_wrote(tmp_path: Path) -> None:
"krav/3-1/a.md",
"krav/3-2/b.md",
}
assert all(entry.source_file == "n500.md" for entry in result.concepts)
assert all(entry.source_file == "q500.md" for entry in result.concepts)

View file

@ -5,7 +5,7 @@ A NEW golden directory, never an edit to the four that exist. That is how
were already there -- and it is what makes "additive" a measured claim rather
than an intention. The second test below is the measurement.
The fixture is synthetic throughout. No consumer content, no real N500 or
The fixture is synthetic throughout. No consumer content, no real regulatory or
procurement prose: this repository is public, and a golden is the most durable
place a leak could land.

View file

@ -33,7 +33,7 @@ from llm_ingestion_okf.structure import derive_document_structure
INGESTED_AT = "2026-07-25T12:00:00Z"
DOCUMENT = "# Brannkonsept\n\nSeksjonering etter N500.\n"
DOCUMENT = "# Brannkonsept\n\nSeksjonering etter Q500.\n"
def gate(text: str) -> GateDecision:
@ -72,7 +72,7 @@ def tree(bundle: Path) -> dict[str, bytes]:
def test_default_without_values_writes_what_it_wrote_before(tmp_path: Path) -> None:
drop(tmp_path / "a", "n500-vegbygging.md")
drop(tmp_path / "a", "q500-surdeigsbaking.md")
process_inbox(
tmp_path / "a",
tmp_path / "old",
@ -81,7 +81,7 @@ def test_default_without_values_writes_what_it_wrote_before(tmp_path: Path) -> N
gate=gate,
profile=DEFAULT,
)
drop(tmp_path / "b", "n500-vegbygging.md")
drop(tmp_path / "b", "q500-surdeigsbaking.md")
process_inbox(
tmp_path / "b",
tmp_path / "new",
@ -96,7 +96,7 @@ def test_default_without_values_writes_what_it_wrote_before(tmp_path: Path) -> N
def test_structured_v1_without_values_still_writes_no_frontmatter(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500-vegbygging.md")
drop(tmp_path / "round", "q500-surdeigsbaking.md")
run(tmp_path, profile=STRUCTURED_V1)
assert not (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8").startswith("---")
@ -105,7 +105,7 @@ def test_structured_v1_without_values_still_writes_no_frontmatter(tmp_path: Path
def test_the_root_index_opens_with_the_declared_bundle_id(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500-vegbygging.md")
drop(tmp_path / "round", "q500-surdeigsbaking.md")
run(tmp_path, values={"bundle_id": "b-1"})
body = (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8")
assert body.startswith("---\nbundle_id: b-1\n---\n\n")
@ -115,15 +115,15 @@ def test_the_value_is_written_verbatim_and_unquoted(tmp_path: Path) -> None:
# Read as RAW BYTES: a parsed assertion returns the same string whether or
# not the emitter added quotes, so it would mask exactly the defect a
# consumer's shape gate trips on.
drop(tmp_path / "round", "n500-vegbygging.md")
run(tmp_path, values={"bundle_id": "svv-n500-2026"})
assert b"bundle_id: svv-n500-2026\n" in (tmp_path / "bundle" / "index.md").read_bytes()
drop(tmp_path / "round", "q500-surdeigsbaking.md")
run(tmp_path, values={"bundle_id": "eks-q500-2026"})
assert b"bundle_id: eks-q500-2026\n" in (tmp_path / "bundle" / "index.md").read_bytes()
def test_a_second_round_does_not_double_the_block(tmp_path: Path) -> None:
drop(tmp_path / "one", "n500-vegbygging.md")
drop(tmp_path / "one", "q500-surdeigsbaking.md")
run(tmp_path, values={"bundle_id": "b-1"}, round_name="one")
drop(tmp_path / "two", "v720-tunnel.md")
drop(tmp_path / "two", "w720-kjeller.md")
run(tmp_path, values={"bundle_id": "b-1"}, round_name="two")
body = (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8")
assert body.startswith("---\nbundle_id: b-1\n---\n\n")
@ -131,18 +131,18 @@ def test_a_second_round_does_not_double_the_block(tmp_path: Path) -> None:
def test_the_index_still_carries_its_entries_below_the_block(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500-vegbygging.md")
drop(tmp_path / "round", "q500-surdeigsbaking.md")
run(tmp_path, values={"bundle_id": "b-1"})
body = (tmp_path / "bundle" / "index.md").read_text(encoding="utf-8")
assert "](inbox-n500-vegbygging.md)" in body
assert body.index("bundle_id") < body.index("](inbox-n500-vegbygging.md)")
assert "](inbox-q500-surdeigsbaking.md)" in body
assert body.index("bundle_id") < body.index("](inbox-q500-surdeigsbaking.md)")
# --- fail-fast, before any disk mutation ----------------------------------
def test_a_key_the_policy_does_not_name_is_refused_before_any_write(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500-vegbygging.md")
drop(tmp_path / "round", "q500-surdeigsbaking.md")
before = tree(tmp_path / "bundle")
with pytest.raises(MaterializationError) as excinfo:
run(tmp_path, values={"okf_version": "0.2"})
@ -154,12 +154,12 @@ def test_a_key_the_policy_does_not_name_is_refused_before_any_write(tmp_path: Pa
def test_a_refusal_leaves_an_existing_bundle_untouched(tmp_path: Path) -> None:
drop(tmp_path / "one", "n500-vegbygging.md")
drop(tmp_path / "one", "q500-surdeigsbaking.md")
run(tmp_path, values={"bundle_id": "b-1"}, round_name="one")
before = tree(tmp_path / "bundle")
assert before != {}
drop(tmp_path / "two", "v720-tunnel.md")
drop(tmp_path / "two", "w720-kjeller.md")
with pytest.raises(MaterializationError) as excinfo:
run(tmp_path, values={"okf_version": "0.2"}, round_name="two")
assert excinfo.value.code == "index_root_frontmatter_unexpected"
@ -167,7 +167,7 @@ def test_a_refusal_leaves_an_existing_bundle_untouched(tmp_path: Path) -> None:
def test_values_are_refused_against_a_profile_naming_no_root_keys(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500-vegbygging.md")
drop(tmp_path / "round", "q500-surdeigsbaking.md")
with pytest.raises(MaterializationError) as excinfo:
run(tmp_path, profile=DEFAULT, values={"bundle_id": "b-1"})
assert excinfo.value.code == "index_root_frontmatter_unexpected"
@ -209,7 +209,7 @@ def render(**overrides: object) -> str:
arguments: dict[str, object] = {
"okf_type": "reference",
"title": "Brannkonsept",
"source_file": "n500.md",
"source_file": "q500.md",
"source_bytes": b"raw",
"ingested_at": INGESTED_AT,
"profile": SEGMENTED_V1,
@ -295,7 +295,7 @@ def test_default_without_the_new_parameters_is_byte_identical() -> None:
DOCUMENT,
okf_type="reference",
title="Brannkonsept",
source_file="n500.md",
source_file="q500.md",
source_bytes=b"raw",
ingested_at=INGESTED_AT,
profile=DEFAULT,
@ -380,14 +380,14 @@ def test_a_genuinely_derived_field_is_still_marked_on_a_segment() -> None:
`number` here IS inferred from the body, and nothing about segmentation
makes it declared, so it must survive in `derived`.
"""
body = "# N500 Vegtunneler\n\nSe N100.\n"
derived = structure_of(body, "n500.md").derived
body = "# Q500 Vinkjellere\n\nSe Q100.\n"
derived = structure_of(body, "q500.md").derived
assert {"title", "number"} <= derived # the premise, measured
keys = frontmatter_of(
render(
text=body,
title="Vegtunneler",
structure=structure_of(body, "n500.md"),
title="Vinkjellere",
structure=structure_of(body, "q500.md"),
segment=segment_entry(),
bundle_id="b-1",
)

View file

@ -48,7 +48,7 @@ PLAN_AT = "2026-08-30T09:00:00Z"
DOCUMENT = (
"0 Forord: bakgrunn for konseptet.\n"
"1 Brannkonsept: krav til seksjonering.\n"
"2 Roemning: to uavhengige veier.\n"
"2 Roemning: to uavhengige utganger.\n"
"3 Baereevne: R60 for hovedbaeresystem.\n"
"4 Slokkeanlegg: sprinkler i hele bygget.\n"
)
@ -179,7 +179,7 @@ def frontmatter_of(document: str) -> dict[str, str]:
def test_a_five_entry_plan_yields_exactly_five_concepts(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT))
found = concepts(tmp_path / "bundle")
assert len(found) == 5
@ -190,15 +190,15 @@ def test_a_five_entry_plan_yields_exactly_five_concepts(tmp_path: Path) -> None:
def test_without_a_plan_the_same_fixture_yields_exactly_one_concept(tmp_path: Path) -> None:
# The discriminating negative control. Without it, a test that counts five
# concepts proves nothing about whether the PLAN caused the split.
drop(tmp_path / "round", "n500.md", DOCUMENT)
drop(tmp_path / "round", "q500.md", DOCUMENT)
run(tmp_path, plan=None)
found = concepts(tmp_path / "bundle")
assert len(found) == 1
assert set(found) == {"inbox-n500.md"}
assert set(found) == {"inbox-q500.md"}
def test_each_segment_id_maps_to_the_path_its_entry_declares(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT)
run(tmp_path, plan=plan)
found = concepts(tmp_path / "bundle")
@ -207,7 +207,7 @@ def test_each_segment_id_maps_to_the_path_its_entry_declares(tmp_path: Path) ->
def test_each_body_equals_the_span_its_own_entry_declares(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT)
run(tmp_path, plan=plan)
found = concepts(tmp_path / "bundle")
@ -217,7 +217,7 @@ def test_each_body_equals_the_span_its_own_entry_declares(tmp_path: Path) -> Non
def test_concepts_land_on_nested_paths_across_several_directories(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT))
found = concepts(tmp_path / "bundle")
directories = {str(Path(path).parent) for path in found}
@ -232,7 +232,7 @@ def test_concepts_land_on_nested_paths_across_several_directories(tmp_path: Path
def test_the_plan_timestamp_reaches_every_concept(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT))
for document in concepts(tmp_path / "bundle").values():
keys = frontmatter_of(document)
@ -272,7 +272,7 @@ def test_a_csv_body_equals_the_span_of_the_extracted_text(tmp_path: Path) -> Non
def test_a_plan_against_a_profile_without_the_capability_is_refused(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
with pytest.raises(SegmentationError) as excinfo:
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT), profile=DEFAULT, values={})
assert excinfo.value.code == "segmentation_unsupported_profile"
@ -283,7 +283,7 @@ def test_a_plan_without_a_bundle_id_is_refused_before_any_write(tmp_path: Path)
# The assertion Step 5 deferred to here: `process_inbox` gains its
# `segmentation` parameter at THIS step, so this is the first point at
# which the required-bundle_id branch can be reached at all.
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
with pytest.raises(SegmentationError) as excinfo:
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT), values={})
assert excinfo.value.code == "segmentation_plan_invalid"
@ -292,12 +292,12 @@ def test_a_plan_without_a_bundle_id_is_refused_before_any_write(tmp_path: Path)
def test_a_refused_plan_leaves_an_existing_bundle_untouched(tmp_path: Path) -> None:
source = drop(tmp_path / "one", "n500.md", DOCUMENT)
source = drop(tmp_path / "one", "q500.md", DOCUMENT)
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT), round_name="one")
before = tree(tmp_path / "bundle")
assert before != {}
drop(tmp_path / "two", "v720.md", DOCUMENT)
drop(tmp_path / "two", "w720.md", DOCUMENT)
with pytest.raises(SegmentationError):
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT), values={}, round_name="two")
assert tree(tmp_path / "bundle") == before
@ -307,7 +307,7 @@ def test_a_refused_plan_leaves_an_existing_bundle_untouched(tmp_path: Path) -> N
def test_one_quarantined_segment_persists_nothing_for_that_document(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT)
third = DOCUMENT[plan.entries[2].span[0] : plan.entries[2].span[1]]
@ -321,12 +321,12 @@ def test_one_quarantined_segment_persists_nothing_for_that_document(tmp_path: Pa
result = run(tmp_path, plan=plan, guard=quarantining)
assert concepts(tmp_path / "bundle") == {}
assert len(result.quarantined) == 1
assert result.quarantined[0].source_file == "n500.md"
assert result.quarantined[0].source_file == "q500.md"
assert result.persisted == ()
def test_every_segment_is_gated_before_any_is_written(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT)
calls: list[str] = []
written_when_gated: list[int] = []
@ -348,12 +348,12 @@ def test_a_plan_covering_one_of_two_documents_leaves_the_other_flat(tmp_path: Pa
# files with identical content are both covered by one plan and land every
# segment on the same path -- which is the intra-run collision Step 8's gate
# exists to refuse, not something to demonstrate here.
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
drop(tmp_path / "round", "v720.md", "V720 Tunnel: egne krav.\n")
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
drop(tmp_path / "round", "w720.md", "W720 Kjeller: egne krav.\n")
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT))
found = concepts(tmp_path / "bundle")
assert set(PATHS) <= set(found)
assert "inbox-v720.md" in found
assert "inbox-w720.md" in found
assert len(found) == 6
@ -366,11 +366,11 @@ def test_a_plan_matching_no_dropped_file_is_refused(tmp_path: Path) -> None:
`_plan_covering` selects on content hash, so a mistyped `source_sha256`
matches nothing, every dropped file falls through to the one-concept rule,
and the run reports a perfectly ordinary success. The operator asked for
segmentation and got a flat bundle with no error to read. `vegnormal-okf`
is about to put an N500 corpus through this path; a silent zero there would
segmentation and got a flat bundle with no error to read. A downstream
consumer was about to put a regulatory corpus through this path; a silent zero there would
read as "the corpus has no concepts".
"""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT, source_sha256="0" * 64)
with pytest.raises(SegmentationError) as excinfo:
run(tmp_path, plan=plan)
@ -386,8 +386,8 @@ def test_a_plan_matching_one_of_several_dropped_files_is_not_refused(tmp_path: P
have replaced a silent skip with a refusal of the normal case, so this test
is what keeps the new gate honest rather than merely loud.
"""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
drop(tmp_path / "round", "v720.md", "V720 Tunnel: egne krav.\n")
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
drop(tmp_path / "round", "w720.md", "W720 Kjeller: egne krav.\n")
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT))
assert len(concepts(tmp_path / "bundle")) == 6
@ -402,8 +402,8 @@ def test_an_unreadable_dropped_file_does_not_mask_an_unmatched_plan(tmp_path: Pa
hashed over the unreadable file's OWN bytes, so it is the only drop that
could ever have matched.
"""
drop(tmp_path / "round", "n500.md", DOCUMENT)
other = "V720 Tunnel: egne krav.\n"
drop(tmp_path / "round", "q500.md", DOCUMENT)
other = "W720 Kjeller: egne krav.\n"
unreadable = drop(tmp_path / "round", "locked.md", other)
unreadable.chmod(0o000)
try:
@ -411,7 +411,7 @@ def test_an_unreadable_dropped_file_does_not_mask_an_unmatched_plan(tmp_path: Pa
# would read the file fine and pass for the wrong reason.
with pytest.raises(OSError):
unreadable.read_bytes()
plan = build_plan(other.encode("utf-8"), other, paths=("krav/tunnel.md",))
plan = build_plan(other.encode("utf-8"), other, paths=("krav/kjeller.md",))
with pytest.raises(SegmentationError) as excinfo:
run(tmp_path, plan=plan)
assert excinfo.value.code == "segmentation_plan_unmatched"
@ -429,7 +429,7 @@ def test_a_matched_plan_with_a_refused_path_keeps_its_own_per_file_code(tmp_path
plan that matched nothing. The operator would have been told to check a
hash that was already correct. Coverage is recorded at SELECTION.
"""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT, paths=("krav/" + "a" * 300 + ".md",))
result = run(tmp_path, plan=plan)
assert {entry.error.code for entry in result.failed} == {"inbox_slug_too_long"}
@ -444,7 +444,7 @@ def test_without_the_capability_an_unmatched_plan_is_still_the_earlier_refusal(
Both conditions hold in this call -- no capability AND no matching file --
and the operator's first problem is the profile, not the hash.
"""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT, source_sha256="0" * 64)
with pytest.raises(SegmentationError) as excinfo:
run(tmp_path, plan=plan, profile=DEFAULT, values={})
@ -470,7 +470,7 @@ def test_a_plan_whose_extracted_text_hash_moved_is_refused(tmp_path: Path) -> No
moving the hash rather than the converter, because the property under test
is that the component is COMPARED at all.
"""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT, text_sha256="0" * 64)
result = run(tmp_path, plan=plan)
assert {entry.error.code for entry in result.failed} == {"segmentation_extractor_mismatch"}
@ -480,7 +480,7 @@ def test_a_plan_whose_extracted_text_hash_moved_is_refused(tmp_path: Path) -> No
def test_a_plan_whose_extractor_version_moved_is_refused(tmp_path: Path) -> None:
"""The half of S5b that compared a value with itself."""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT, extractor_version="not-the-one-that-ran")
result = run(tmp_path, plan=plan)
assert {entry.error.code for entry in result.failed} == {"segmentation_extractor_mismatch"}
@ -522,14 +522,14 @@ OTHER_PATHS = ("annen/innledning.md", "annen/virkeomraade.md")
def test_two_documents_with_two_plans_both_segment_in_one_run(tmp_path: Path) -> None:
first = drop(tmp_path / "round", "n500.md", DOCUMENT)
second = drop(tmp_path / "round", "n200.md", OTHER)
first = drop(tmp_path / "round", "q500.md", DOCUMENT)
second = drop(tmp_path / "round", "q200.md", OTHER)
run(
tmp_path,
plan=None,
plans={
"n500.md": build_plan(first.read_bytes(), DOCUMENT),
"n200.md": build_plan(second.read_bytes(), OTHER, paths=OTHER_PATHS),
"q500.md": build_plan(first.read_bytes(), DOCUMENT),
"q200.md": build_plan(second.read_bytes(), OTHER, paths=OTHER_PATHS),
},
)
found = concepts(tmp_path / "bundle")
@ -545,7 +545,7 @@ def test_a_mapping_selects_by_content_not_by_the_name_it_is_keyed_under(
it was adjudicated over. Anything else would make a rename silently produce
a flat bundle.
"""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
run(
tmp_path,
plan=None,
@ -563,13 +563,13 @@ def test_a_plan_in_the_mapping_that_matches_nothing_is_still_refused(
matched would otherwise report success over four segmented documents and
one flat one, which is exactly the silent zero this refusal exists for.
"""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
with pytest.raises(SegmentationError) as excinfo:
run(
tmp_path,
plan=None,
plans={
"n500.md": build_plan(source.read_bytes(), DOCUMENT),
"q500.md": build_plan(source.read_bytes(), DOCUMENT),
"ghost.md": build_plan(source.read_bytes(), DOCUMENT, source_sha256="0" * 64),
},
)
@ -579,17 +579,17 @@ def test_a_plan_in_the_mapping_that_matches_nothing_is_still_refused(
def test_passing_both_forms_at_once_is_refused(tmp_path: Path) -> None:
"""Two ways to say the same thing invite a silent disagreement."""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
plan = build_plan(source.read_bytes(), DOCUMENT)
with pytest.raises(SegmentationError) as excinfo:
run(tmp_path, plan=plan, plans={"n500.md": plan})
run(tmp_path, plan=plan, plans={"q500.md": plan})
assert excinfo.value.code == "segmentation_plan_invalid"
assert tree(tmp_path / "bundle") == {}
def test_two_plans_claiming_the_same_bytes_are_refused(tmp_path: Path) -> None:
"""Which one would have segmented the document is not a coin toss."""
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
with pytest.raises(SegmentationError) as excinfo:
run(
tmp_path,
@ -604,6 +604,6 @@ def test_two_plans_claiming_the_same_bytes_are_refused(tmp_path: Path) -> None:
def test_the_single_plan_form_is_unchanged(tmp_path: Path) -> None:
source = drop(tmp_path / "round", "n500.md", DOCUMENT)
source = drop(tmp_path / "round", "q500.md", DOCUMENT)
run(tmp_path, plan=build_plan(source.read_bytes(), DOCUMENT))
assert set(concepts(tmp_path / "bundle")) == set(PATHS)

View file

@ -64,7 +64,7 @@ def drop(inbox: Path, name: str, text: str = DOCUMENT) -> Path:
return path
def _extracted_text_sha256(source_bytes: bytes, filename: str = "n500.md") -> str:
def _extracted_text_sha256(source_bytes: bytes, filename: str = "q500.md") -> str:
return hashlib.sha256(extract_text(filename, source_bytes).encode("utf-8")).hexdigest()
@ -135,7 +135,7 @@ def run(
def build(tmp: Path, bundle_name: str = "bundle") -> Path:
source = drop(tmp / "round", "n500.md")
source = drop(tmp / "round", "q500.md")
run(tmp, plan=build_plan(source.read_bytes()), bundle_name=bundle_name)
return tmp / bundle_name
@ -263,7 +263,7 @@ def test_two_builds_from_identical_inputs_are_byte_identical(tmp_path: Path) ->
def test_a_second_round_over_the_same_inputs_changes_nothing(tmp_path: Path) -> None:
bundle = build(tmp_path)
before = tree(bundle)
source = drop(tmp_path / "again", "n500.md")
source = drop(tmp_path / "again", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes()), round_name="again")
assert tree(bundle) == before
@ -273,7 +273,7 @@ def test_nested_targets_do_not_confuse_the_entry_matcher(tmp_path: Path) -> None
# `](krav/3-1/a.md)` contains `](3-1/a.md)`. An index recomputed whole with
# an anchored matcher cannot be fooled that way; a substring matcher would
# drop or double an entry here.
source = drop(tmp_path / "round", "n500.md")
source = drop(tmp_path / "round", "q500.md")
plan = build_plan(source.read_bytes(), ("krav/3-1/a.md", "3-1/a.md"))
run(tmp_path, plan=plan)
bundle = tmp_path / "bundle"
@ -288,7 +288,7 @@ def test_nested_targets_do_not_confuse_the_entry_matcher(tmp_path: Path) -> None
def test_default_and_structured_write_one_root_index_only(tmp_path: Path) -> None:
for profile, name in ((DEFAULT, "flat"), (STRUCTURED_V1, "structured")):
drop(tmp_path / name, "n500.md")
drop(tmp_path / name, "q500.md")
run(tmp_path, profile=profile, round_name=name, bundle_name=name + "-bundle")
bundle = tmp_path / (name + "-bundle")
assert [path.relative_to(bundle).as_posix() for path in bundle.rglob("index.md")] == [
@ -327,7 +327,7 @@ def frontmatter_of(path: Path) -> dict[str, str]:
def build_v0_2(tmp: Path, *, adjudicated: tuple[str, ...] = (), bundle_name: str = "bundle"):
source = drop(tmp / "round", "n500.md")
source = drop(tmp / "round", "q500.md")
plan = build_plan(
source.read_bytes(),
entries_override={path: dict(VERDICT) for path in adjudicated},
@ -384,7 +384,7 @@ def test_a_state_outside_the_closed_set_is_refused(tmp_path: Path) -> None:
dropped document's own frontmatter, before this library writes anything.
"""
body = "---\nadjudication: nesten-ferdig\n---\n\n" + DOCUMENT
source = drop(tmp_path / "round", "n500.md", body)
source = drop(tmp_path / "round", "q500.md", body)
# The span covers the block, which is the only way a declared value reaches
# derivation at all: a segment body is a SLICE, so a frontmatter block the
# span misses is simply not part of that concept.

View file

@ -78,14 +78,14 @@ def gate(text: str) -> GateDecision:
return GateDecision(sanitized_text=text, disposition="warn")
def drop(inbox: Path, name: str = "n500.md", text: str = DOCUMENT) -> Path:
def drop(inbox: Path, name: str = "q500.md", text: str = DOCUMENT) -> Path:
inbox.mkdir(parents=True, exist_ok=True)
path = inbox / name
path.write_text(text, encoding="utf-8", newline="")
return path
def _extracted_text_sha256(source_bytes: bytes, filename: str = "n500.md") -> str:
def _extracted_text_sha256(source_bytes: bytes, filename: str = "q500.md") -> str:
return hashlib.sha256(extract_text(filename, source_bytes).encode("utf-8")).hexdigest()

View file

@ -62,7 +62,7 @@ def drop(inbox: Path, name: str, text: str = DOCUMENT) -> Path:
return path
def _extracted_text_sha256(source_bytes: bytes, filename: str = "n500.md") -> str:
def _extracted_text_sha256(source_bytes: bytes, filename: str = "q500.md") -> str:
return hashlib.sha256(extract_text(filename, source_bytes).encode("utf-8")).hexdigest()
@ -153,11 +153,11 @@ ROUND_2 = (
def test_a_resplit_loses_no_concept_id_and_adds_at_least_two(tmp_path: Path) -> None:
source = drop(tmp_path / "one", "n500.md")
source = drop(tmp_path / "one", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_1), round_name="one")
first = concept_ids(tmp_path / "bundle")
drop(tmp_path / "two", "n500.md")
drop(tmp_path / "two", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_2), round_name="two")
second = concept_ids(tmp_path / "bundle")
@ -171,9 +171,9 @@ def test_a_resplit_loses_no_concept_id_and_adds_at_least_two(tmp_path: Path) ->
def test_the_parent_survives_the_resplit_and_its_children_point_at_it(
tmp_path: Path,
) -> None:
source = drop(tmp_path / "one", "n500.md")
source = drop(tmp_path / "one", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_1), round_name="one")
drop(tmp_path / "two", "n500.md")
drop(tmp_path / "two", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_2), round_name="two")
bundle = tmp_path / "bundle"
@ -184,11 +184,11 @@ def test_the_parent_survives_the_resplit_and_its_children_point_at_it(
def test_ids_are_stable_across_a_round_that_changes_nothing(tmp_path: Path) -> None:
source = drop(tmp_path / "one", "n500.md")
source = drop(tmp_path / "one", "q500.md")
plan = build_plan(source.read_bytes(), ROUND_2)
run(tmp_path, plan=plan, round_name="one")
first = concept_ids(tmp_path / "bundle")
drop(tmp_path / "two", "n500.md")
drop(tmp_path / "two", "q500.md")
run(tmp_path, plan=plan, round_name="two")
assert concept_ids(tmp_path / "bundle") == first
@ -199,11 +199,11 @@ def test_ids_are_stable_across_a_round_that_changes_nothing(tmp_path: Path) -> N
def test_a_round_naming_fewer_segments_removes_exactly_the_unnamed_ones(
tmp_path: Path,
) -> None:
source = drop(tmp_path / "one", "n500.md")
source = drop(tmp_path / "one", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_2), round_name="one")
assert len(concept_ids(tmp_path / "bundle")) == 3
drop(tmp_path / "two", "n500.md")
drop(tmp_path / "two", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_1), round_name="two")
# Exactly the un-named ones. An orphan surviving here is invisible to a
@ -213,18 +213,18 @@ def test_a_round_naming_fewer_segments_removes_exactly_the_unnamed_ones(
def test_retirement_leaves_every_other_documents_concepts_alone(tmp_path: Path) -> None:
source = drop(tmp_path / "one", "n500.md")
other = drop(tmp_path / "one", "v720.md", "Tunnelkrav.\n" * 20)
source = drop(tmp_path / "one", "q500.md")
other = drop(tmp_path / "one", "w720.md", "Kjellerkrav.\n" * 20)
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_2), round_name="one")
assert "inbox-v720" in concept_ids(tmp_path / "bundle")
assert "inbox-w720" in concept_ids(tmp_path / "bundle")
drop(tmp_path / "two", "n500.md")
drop(tmp_path / "two", "q500.md")
run(tmp_path, plan=build_plan(source.read_bytes(), ROUND_1), round_name="two")
# v720 was not in round 2's inbox at all. Ownership is per source_file, so
# nothing about n500's re-split may touch it.
assert (tmp_path / "bundle" / "inbox-v720.md").is_file()
assert other.name == "v720.md"
# w720 was not in round 2's inbox at all. Ownership is per source_file, so
# nothing about q500's re-split may touch it.
assert (tmp_path / "bundle" / "inbox-w720.md").is_file()
assert other.name == "w720.md"
# --- the four shipped profiles keep FLAT scans ----------------------------
@ -238,21 +238,21 @@ def test_default_ignores_a_nested_file_when_scanning_for_collisions(
# capability, and this plants a nested file to prove DEFAULT never looks.
bundle = tmp_path / "bundle"
(bundle / "krav").mkdir(parents=True)
(bundle / "krav" / "inbox-n500.md").write_text(
"---\ntype: note\ngenerated: true\nsource_file: n500.md\n---\n\nnested\n",
(bundle / "krav" / "inbox-q500.md").write_text(
"---\ntype: note\ngenerated: true\nsource_file: q500.md\n---\n\nnested\n",
encoding="utf-8",
)
drop(tmp_path / "round", "n500.md", "Flat.\n")
drop(tmp_path / "round", "q500.md", "Flat.\n")
result = run(tmp_path, profile=DEFAULT)
assert result.failed == ()
assert (bundle / "inbox-n500.md").is_file()
assert (bundle / "inbox-q500.md").is_file()
# Untouched: DEFAULT does not own it, does not see it, does not retire it.
assert (bundle / "krav" / "inbox-n500.md").read_text(encoding="utf-8").endswith("nested\n")
assert (bundle / "krav" / "inbox-q500.md").read_text(encoding="utf-8").endswith("nested\n")
def test_default_still_writes_one_flat_concept_per_file(tmp_path: Path) -> None:
drop(tmp_path / "round", "n500.md", "Flat.\n")
drop(tmp_path / "round", "v720.md", "Annet.\n")
drop(tmp_path / "round", "q500.md", "Flat.\n")
drop(tmp_path / "round", "w720.md", "Annet.\n")
run(tmp_path, profile=DEFAULT)
assert concept_ids(tmp_path / "bundle", DEFAULT) == ["inbox-n500", "inbox-v720"]
assert concept_ids(tmp_path / "bundle", DEFAULT) == ["inbox-q500", "inbox-w720"]

View file

@ -46,8 +46,8 @@ BUILD_DEFAULTS = dict(
#: shell title -> title of the nearest ancestor holding text, or None.
EXPECTED = {
"Rekkverk": "Vedlikehold av utstyr",
# Its parent `Rekkverk` is a shell too, so the pointer goes one further up.
"Gelender": "Vedlikehold av utstyr",
# Its parent `Gelender` is a shell too, so the pointer goes one further up.
"Utskifting": "Vedlikehold av utstyr",
"Utskifting av list": "Utskifting av enkeltdeler",
"Utskifting av stolpe": "Utskifting av enkeltdeler",

View file

@ -1,12 +1,12 @@
"""The soft-hyphen normalisation door, in front of the persist gate.
Operator decision 2026-09-18. R761 Prosesskoden:2025 is refused WHOLE by
`llm-ingestion-guard` 1.4.0 -- `output:zero-width-present`, HIGH, an any-tier
Operator decision 2026-09-18. A 701-page reference standard was refused WHOLE
by `llm-ingestion-guard` 1.4.0 -- `output:zero-width-present`, HIGH, an any-tier
carrier and therefore `fail_secure` at every trust level -- because the
publisher's source carries 71 U+00AD SOFT HYPHEN and 0 real zero-width
characters (U+200B, U+200C, U+200D, U+FEFF, U+2060 all 0, measured by PM on
both deliveries). Those 71 are Norwegian hyphenation points inside words:
`ar[SHY]beider`, `bitu[SHY]men`, `asfalt[SHY]betong`. The verdict is formally
both deliveries). Those 71 are Norwegian hyphenation points inside words, of
the kind `ar[SHY]beider` and `surdeigs[SHY]bakst`. The verdict is formally
right and materially a false positive.
Of the three ways out, the operator chose this one: okf removes U+00AD BEFORE
@ -17,9 +17,9 @@ before -- which is the known-negative every test here carries.
The three alternatives and why they are not here: weakening the guard is the
security repo's call and weakens every consumer's screen; delivering a cleaned
corpus fixes one document and no other; and doing nothing leaves a 701-page
process code unreadable for the whole chain.
reference standard unreadable for the whole chain.
U+00A0 NBSP is NOT touched (6 633 of them in R761). The guard has no rule
U+00A0 NBSP is NOT touched (6 633 of them in that document). The guard has no rule
about it -- `_ZERO_WIDTH_CPS` is exactly {200B, 200C, 200D, FEFF, 00AD} on
1.4.0, measured -- so nothing here has to.
"""
@ -41,7 +41,7 @@ ZERO_WIDTH = ""
#: Every character the door must leave alone, each one a way for it to reach
#: further than its own name. The four real zero-width carriers (the guard's
#: set minus U+00AD) and U+2060, which the guard does not screen for at all;
#: U+00A0 NBSP, which R761 ships 6 633 of; the three hyphens a reader would
#: U+00A0 NBSP, which that document ships 6 633 of; the three hyphens a reader would
#: confuse with a soft one -- U+002D HYPHEN-MINUS, U+2010 HYPHEN, U+2011
#: NON-BREAKING HYPHEN; and a combining accent, because the door is the one
#: place in the chain that does NOT normalise to NFC.
@ -60,7 +60,7 @@ UNTOUCHED = (
_MIXED = (
"".join(f"ar{{s}}beider {mark}{{s}}{mark} paa linje {n}\n" for n, mark in enumerate(UNTOUCHED))
+ "asfalt{s}betong\n"
+ "surdeigs{s}bakst\n"
)
@ -69,7 +69,7 @@ def test_the_door_removes_one_character_and_leaves_every_other_one_where_it_was(
README and CLAUDE.md repeat it. Until this test the sentence was held by
nothing: PM's mutant P6 -- a door that ALSO eats U+00A0 -- passed the whole
suite green (2 171 passed, RC 0), and it would have eaten all 6 633 NBSP
in R761 while the log went on claiming otherwise.
in that document while the log went on claiming otherwise.
The invariant is exact rather than a share, and it is stated as the
stronger of the two equivalent forms: instead of putting the removed
@ -134,7 +134,7 @@ def _build(inbox: Path, out: Path, accounting: Path) -> tuple[int, str]:
_SOURCE = (
"# Pro{s}sess 84\n\nAr{s}beider med bitu{s}men og asfalt{s}betong.\n\n"
"# Kapit{s}tel 84\n\nAr{s}beider med sjoko{s}lade og surdeigs{s}bakst.\n\n"
"# Krav\n\nTilsvar{s}ende krav gjelder.\n"
)
@ -209,12 +209,12 @@ def test_the_pdf_page_table_is_measured_over_the_normalised_text(
readings of one document, and a locator built against the other rendering
points a consumer at the wrong page with full confidence."""
pages = (
extract._PdfPage(1, f"Ar{SHY}beider med bitu{SHY}men."),
extract._PdfPage(2, f"Asfalt{SHY}betong."),
extract._PdfPage(1, f"Ar{SHY}beider med sjoko{SHY}lade."),
extract._PdfPage(2, f"Surdeigs{SHY}bakst."),
)
monkeypatch.setattr(extract, "_pdf_pages", lambda *a, **k: iter(pages))
table = extract._pdf_units(b"", False, False)
joined = extract._PDF_PAGE_SEPARATOR.join(extract._pdf_page_text(p) for p in pages)
text, removed = extract.normalise_extracted(joined)
assert removed == 3
assert table.starts[1] == text.index("Asfaltbetong")
assert table.starts[1] == text.index("Surdeigsbakst")

View file

@ -8,7 +8,7 @@ FIRST every time:
word equality prefix share
under (control) 79 172 38.0 %
undersjoisk 0 172 38.0 %
under 0 172 38.0 %
bilateral 0 400 88.3 %
standhaftig 0 219 48.3 %
@ -39,11 +39,11 @@ WHAT DOES SEPARATE THEM. `pris` is a word; `bila` and `stan` are not. The
fourth candidate requires the shared prefix to occur as a token in the bundle's
own concepts, and it is the only one that clears both criteria at once:
bundle bilateral standhaftig undersjoisk control ranks
bundle bilateral standhaftig under control ranks
default-20260912 400 -> 0 219 -> 56 172 -> 162 172 (1,1,1,1,1,None)
armB-20260903 512 -> 0 235 -> 33 174 -> 160 174 (1,1,1,1,1,None)
WHAT REMAINS, AND WHY IT IS NOT A CEILING BUT A DIFFERENT ANSWER. `undersjoisk`
WHAT REMAINS, AND WHY IT IS NOT A CEILING BUT A DIFFERENT ANSWER. `under`
still reaches 162 concepts, because it shares `under` with them and `under` IS
a word in this corpus -- a productive Norwegian prefix. `standhaftig` still
reaches 56 through `stand`. Those are genuine shared morphemes, and a rule that
@ -98,13 +98,13 @@ def test_a_shared_prefix_that_is_a_word_still_matches() -> None:
def test_the_productive_norwegian_prefix_is_kept_and_that_is_the_answer() -> None:
"""`under` is a word, so `undersjoisk` keeps reaching `underbygning`.
"""`under` is a word, so `under…` keeps reaching `underholdning`.
Stated as a decision rather than left as a residual: this rule does not
claim to separate two words that genuinely share a morpheme, and the 162
concepts `undersjoisk` still reaches are that morpheme, not a defect.
concepts `under` still reaches are that morpheme, not a defect.
"""
assert consume.tokens_match("undersjøisk", "underbygning", stems=STEMS) is True
assert consume.tokens_match("undertøy", "underholdning", stems=STEMS) is True
def test_an_identifier_is_untouched_by_the_stem_rule() -> None:

View file

@ -33,20 +33,20 @@ def derive(text: str, source_file: str = "note.md") -> DocumentStructure:
def test_title_from_frontmatter_is_declared() -> None:
doc = derive("---\ntitle: Vegbygging\n---\n\nbody\n", "n500-vegbygging.md")
assert doc.title == "Vegbygging"
doc = derive("---\ntitle: Surdeigsbaking\n---\n\nbody\n", "q500-surdeigsbaking.md")
assert doc.title == "Surdeigsbaking"
assert "title" not in doc.derived
def test_title_from_leading_heading_is_derived() -> None:
doc = derive("# Vegbygging\n\nbody\n", "n500-vegbygging.md")
assert doc.title == "Vegbygging"
doc = derive("# Surdeigsbaking\n\nbody\n", "q500-surdeigsbaking.md")
assert doc.title == "Surdeigsbaking"
assert "title" in doc.derived
def test_title_falls_back_to_the_filename_stem_and_is_derived() -> None:
doc = derive("body with no heading\n", "n500-vegbygging.md")
assert doc.title == "n500-vegbygging"
doc = derive("body with no heading\n", "q500-surdeigsbaking.md")
assert doc.title == "q500-surdeigsbaking"
assert "title" in doc.derived
@ -78,11 +78,11 @@ def test_title_is_nfc_normalised() -> None:
@pytest.mark.parametrize(
("source_file", "expected"),
[
("n500-vegbygging.md", "N500"),
("N500 Vegbygging.md", "N500"),
("v720-something.md", "V720"),
("q500-surdeigsbaking.md", "Q500"),
("Q500 Surdeigsbaking.md", "Q500"),
("w720-something.md", "W720"),
("4.2.1-details.md", "4.2.1"),
("R610.4 note.md", "R610.4"),
("P610.4 note.md", "P610.4"),
],
)
def test_number_is_read_off_the_filename_and_marked_derived(
@ -94,14 +94,14 @@ def test_number_is_read_off_the_filename_and_marked_derived(
def test_a_declared_number_wins_and_is_not_marked_derived() -> None:
doc = derive("---\nnumber: N200\n---\n\nbody\n", "n500-vegbygging.md")
assert doc.number == "N200"
doc = derive("---\nnumber: Q200\n---\n\nbody\n", "q500-surdeigsbaking.md")
assert doc.number == "Q200"
assert "number" not in doc.derived
def test_number_falls_back_to_the_title_when_the_filename_has_none() -> None:
doc = derive("# N500 Vegbygging\n", "dropped-file.md")
assert doc.number == "N500"
doc = derive("# Q500 Surdeigsbaking\n", "dropped-file.md")
assert doc.number == "Q500"
assert "number" in doc.derived
@ -126,8 +126,8 @@ def test_no_number_anywhere_is_none_not_an_invention() -> None:
("number_source", "expected_parent"),
[
("4.2.1-x.md", "4.2"),
("R610.4 x.md", "R610"),
("n500-x.md", None),
("P610.4 x.md", "P610"),
("q500-x.md", None),
# `4.2` would drop to `4`, and a BARE integer is not a document number
# under this module's own grammar. A pointer nothing could ever satisfy
# is not a pointer: it would sit in the unresolved list forever, and an
@ -157,38 +157,38 @@ def test_parent_is_structural_and_carries_the_numbers_confidence() -> None:
def test_number_mentions_in_the_body_are_references() -> None:
doc = derive("See N200 and jf. N300 kap. 4 for details.\n", "n500-x.md")
assert doc.references == ("N200", "N300")
doc = derive("See Q200 and jf. Q300 kap. 4 for details.\n", "q500-x.md")
assert doc.references == ("Q200", "Q300")
assert "references" in doc.derived
def test_a_document_never_references_itself() -> None:
doc = derive("N500 says that N500 applies, see N200.\n", "n500-x.md")
assert doc.references == ("N200",)
doc = derive("Q500 says that Q500 applies, see Q200.\n", "q500-x.md")
assert doc.references == ("Q200",)
def test_references_are_deduplicated_and_ordered_by_first_appearance() -> None:
# Each mention carries a cue, because a number without one is no longer a
# reference at all. What this test pins is the ordering and the dedup.
doc = derive("se N300 then se N200 then se N300 again.\n", "n500-x.md")
assert doc.references == ("N300", "N200")
doc = derive("se Q300 then se Q200 then se Q300 again.\n", "q500-x.md")
assert doc.references == ("Q300", "Q200")
def test_markdown_link_targets_are_references_too() -> None:
doc = derive("See [the other](other-doc.md) and [again](other-doc.md).\n", "n500-x.md")
doc = derive("See [the other](other-doc.md) and [again](other-doc.md).\n", "q500-x.md")
assert "other-doc.md" in doc.references
def test_external_links_are_not_references() -> None:
# A cross-reference is inside the bundle. An http link is somebody else's
# document and resolving it is not this library's job.
doc = derive("See [upstream](https://example.test/a.md).\n", "n500-x.md")
doc = derive("See [upstream](https://example.test/a.md).\n", "q500-x.md")
assert doc.references == ()
def test_declared_references_are_not_marked_derived() -> None:
doc = derive("---\nreferences: [N200, N300]\n---\n\nbody mentioning N400\n", "n500-x.md")
assert doc.references == ("N200", "N300")
doc = derive("---\nreferences: [Q200, Q300]\n---\n\nbody mentioning Q400\n", "q500-x.md")
assert doc.references == ("Q200", "Q300")
assert "references" not in doc.derived
@ -196,8 +196,8 @@ def test_declared_references_are_not_marked_derived() -> None:
def test_supersedes_is_declared_only_never_inferred_per_document() -> None:
doc = derive("---\nsupersedes: [n500-2018]\n---\n\nbody\n", "n500-2021.md")
assert doc.supersedes == ("n500-2018",)
doc = derive("---\nsupersedes: [q500-2018]\n---\n\nbody\n", "q500-2021.md")
assert doc.supersedes == ("q500-2018",)
assert "supersedes" not in doc.derived
@ -205,13 +205,13 @@ def test_no_declared_supersedes_means_none_at_document_level() -> None:
# Whether one document supersedes another is a fact about a PAIR, so a
# single document cannot answer it. The bundle-level resolver may propose
# it; this function must not.
doc = derive("This replaces the 2018 edition.\n", "n500-2021.md")
doc = derive("This replaces the 2018 edition.\n", "q500-2021.md")
assert doc.supersedes == ()
def test_version_is_declared_only() -> None:
assert derive("---\nversion: '2021'\n---\n\nbody\n", "x.md").version == "2021"
assert derive("body\n", "n500-2021.md").version is None
assert derive("body\n", "q500-2021.md").version is None
# --- pass-through of the producer's own keys ------------------------------
@ -231,8 +231,8 @@ def test_a_document_with_no_frontmatter_declares_nothing() -> None:
def test_derivation_is_deterministic() -> None:
text = "---\nstatus: gjeldende\n---\n\n# N500 Vegbygging\n\nSee N200.\n"
assert derive(text, "n500-vegbygging.md") == derive(text, "n500-vegbygging.md")
text = "---\nstatus: gjeldende\n---\n\n# Q500 Surdeigsbaking\n\nSee Q200.\n"
assert derive(text, "q500-surdeigsbaking.md") == derive(text, "q500-surdeigsbaking.md")
# --- bundle-level resolution ----------------------------------------------
@ -258,10 +258,10 @@ def edges_of(bundle: BundleStructure, kind: str) -> list[tuple[str, str, str | N
def test_a_reference_resolves_to_the_document_carrying_that_number() -> None:
bundle = resolve(
doc("inbox-n500", "See N200 for details.\n", "n500-vegbygging.md"),
doc("inbox-n200", "body\n", "n200-grunnlag.md"),
doc("inbox-q500", "See Q200 for details.\n", "q500-surdeigsbaking.md"),
doc("inbox-q200", "body\n", "q200-grunnlag.md"),
)
assert edges_of(bundle, "references") == [("inbox-n500", "N200", "inbox-n200")]
assert edges_of(bundle, "references") == [("inbox-q500", "Q200", "inbox-q200")]
assert bundle.unresolved == ()
@ -269,17 +269,17 @@ def test_a_reference_to_a_document_not_yet_dropped_is_UNRESOLVED_not_dropped() -
# Normal state while a bundle is still being built up. It must be visible
# as unfulfilled: an absence that does not scream is the most dangerous
# state this repo knows.
bundle = resolve(doc("inbox-n500", "See N200.\n", "n500-x.md"))
assert edges_of(bundle, "references") == [("inbox-n500", "N200", None)]
assert [e.subject for e in bundle.unresolved] == ["N200"]
bundle = resolve(doc("inbox-q500", "See Q200.\n", "q500-x.md"))
assert edges_of(bundle, "references") == [("inbox-q500", "Q200", None)]
assert [e.subject for e in bundle.unresolved] == ["Q200"]
def test_a_reference_resolves_once_its_target_arrives_in_a_later_round() -> None:
first = doc("inbox-n500", "See N200.\n", "n500-x.md")
first = doc("inbox-q500", "See Q200.\n", "q500-x.md")
assert resolve(first).unresolved != ()
later = resolve(first, doc("inbox-n200", "body\n", "n200-y.md"))
later = resolve(first, doc("inbox-q200", "body\n", "q200-y.md"))
assert later.unresolved == ()
assert edges_of(later, "references") == [("inbox-n500", "N200", "inbox-n200")]
assert edges_of(later, "references") == [("inbox-q500", "Q200", "inbox-q200")]
def test_a_link_target_resolves_against_the_source_filename() -> None:
@ -287,10 +287,10 @@ def test_a_link_target_resolves_against_the_source_filename() -> None:
# the file arrived as, never at the concept name. Resolving only concept
# names would report every intra-bundle link as dangling.
bundle = resolve(
doc("inbox-a", "See [other](n200-grunnlag.md).\n", "a.md"),
doc("inbox-n200", "body\n", "n200-grunnlag.md"),
doc("inbox-a", "See [other](q200-grunnlag.md).\n", "a.md"),
doc("inbox-q200", "body\n", "q200-grunnlag.md"),
)
assert edges_of(bundle, "references") == [("inbox-a", "n200-grunnlag.md", "inbox-n200")]
assert edges_of(bundle, "references") == [("inbox-a", "q200-grunnlag.md", "inbox-q200")]
def test_parent_resolves_to_the_document_carrying_the_parent_number() -> None:
@ -308,19 +308,19 @@ def test_a_missing_parent_is_unresolved_rather_than_absent() -> None:
def test_declared_supersedes_resolves_and_is_not_derived() -> None:
bundle = resolve(
doc("inbox-new", "---\nsupersedes: [n500-2018]\n---\nbody\n", "n500-2021.md"),
doc("inbox-old", "body\n", "n500-2018.md"),
doc("inbox-new", "---\nsupersedes: [q500-2018]\n---\nbody\n", "q500-2021.md"),
doc("inbox-old", "body\n", "q500-2018.md"),
)
assert edges_of(bundle, "supersedes") == [("inbox-new", "n500-2018", "inbox-old")]
assert edges_of(bundle, "supersedes") == [("inbox-new", "q500-2018", "inbox-old")]
assert [e.derived for e in bundle.edges if e.kind == "supersedes"] == [False]
def test_same_number_with_ordered_versions_yields_a_DERIVED_supersedes_chain() -> None:
# The one pair-level heuristic in the module, and it is marked as such.
bundle = resolve(
doc("inbox-a", "---\nnumber: N500\nversion: '2018'\n---\nbody\n", "a.md"),
doc("inbox-b", "---\nnumber: N500\nversion: '2021'\n---\nbody\n", "b.md"),
doc("inbox-c", "---\nnumber: N500\nversion: '2026'\n---\nbody\n", "c.md"),
doc("inbox-a", "---\nnumber: Q500\nversion: '2018'\n---\nbody\n", "a.md"),
doc("inbox-b", "---\nnumber: Q500\nversion: '2021'\n---\nbody\n", "b.md"),
doc("inbox-c", "---\nnumber: Q500\nversion: '2026'\n---\nbody\n", "c.md"),
)
chain = [(e.source, e.target) for e in bundle.edges if e.kind == "supersedes"]
assert chain == [("inbox-b", "inbox-a"), ("inbox-c", "inbox-b")]
@ -331,16 +331,16 @@ def test_same_number_without_versions_proposes_nothing() -> None:
# Two documents sharing a number and no way to order them is exactly the
# case where a guess would be indistinguishable from a fact.
bundle = resolve(
doc("inbox-a", "---\nnumber: N500\n---\nbody\n", "a.md"),
doc("inbox-b", "---\nnumber: N500\n---\nbody\n", "b.md"),
doc("inbox-a", "---\nnumber: Q500\n---\nbody\n", "a.md"),
doc("inbox-b", "---\nnumber: Q500\n---\nbody\n", "b.md"),
)
assert edges_of(bundle, "supersedes") == []
def test_version_ordering_is_numeric_not_lexicographic() -> None:
bundle = resolve(
doc("inbox-a", "---\nnumber: N500\nversion: '9'\n---\nbody\n", "a.md"),
doc("inbox-b", "---\nnumber: N500\nversion: '10'\n---\nbody\n", "b.md"),
doc("inbox-a", "---\nnumber: Q500\nversion: '9'\n---\nbody\n", "a.md"),
doc("inbox-b", "---\nnumber: Q500\nversion: '10'\n---\nbody\n", "b.md"),
)
assert [(e.source, e.target) for e in bundle.edges if e.kind == "supersedes"] == [
("inbox-b", "inbox-a")
@ -351,14 +351,14 @@ def test_version_ordering_is_numeric_not_lexicographic() -> None:
def test_resolution_does_not_depend_on_the_order_documents_were_added() -> None:
a = doc("inbox-a", "See N200.\n", "n500-x.md")
b = doc("inbox-n200", "See N500.\n", "n200-y.md")
a = doc("inbox-a", "See Q200.\n", "q500-x.md")
b = doc("inbox-q200", "See Q500.\n", "q200-y.md")
assert resolve(a, b) == resolve(b, a)
def test_resolving_the_same_document_twice_yields_one_edge_not_two() -> None:
a = doc("inbox-a", "See N200.\n", "n500-x.md")
b = doc("inbox-n200", "body\n", "n200-y.md")
a = doc("inbox-a", "See Q200.\n", "q500-x.md")
b = doc("inbox-q200", "body\n", "q200-y.md")
once = resolve(a, b)
# A mapping cannot hold the same key twice, which is the point: identity is
# the concept name, so re-dropping a file replaces rather than accumulates.
@ -368,8 +368,8 @@ def test_resolving_the_same_document_twice_yields_one_edge_not_two() -> None:
def test_edges_are_deterministically_ordered() -> None:
bundle = resolve(
doc("inbox-b", "See N100 and N300.\n", "n200-b.md"),
doc("inbox-a", "See N300.\n", "n100-a.md"),
doc("inbox-b", "See Q100 and Q300.\n", "q200-b.md"),
doc("inbox-a", "See Q300.\n", "q100-a.md"),
)
assert list(bundle.edges) == sorted(bundle.edges, key=lambda e: (e.source, e.kind, e.subject))
@ -390,46 +390,46 @@ def test_edges_are_deterministically_ordered() -> None:
# `1.6`), a hash algorithm name (`SHA256`), and tokens lifted out
# of escape sequences in quoted source (`\n60` -> `N60`).
#
# The version class cannot be excluded structurally: corpus A proves `V221` and
# `V240` are genuine document numbers and corpus B proves `V0.3.0` is a
# The version class cannot be excluded structurally: corpus A proves `W221` and
# `W240` are genuine document numbers and corpus B proves `V0.3.0` is a
# software version, and they are the same token shape. So no blacklist can
# work, and a positive cue is the only rule that can be defended against data.
# A missing reference is visible to the reader; a false one is not.
def test_a_package_version_is_not_a_reference() -> None:
doc = derive("Built on pdfplumber 0.11.10 and Python 3.14.0, licence AGPL-3.0.\n", "n500-x.md")
doc = derive("Built on pdfplumber 0.11.10 and Python 3.14.0, licence AGPL-3.0.\n", "q500-x.md")
assert doc.references == ()
def test_a_v_prefixed_version_is_not_a_reference() -> None:
# The reported failure class: a version string carried in a title, read as
# a document number. `V221` in corpus A is a real document; `v0.5.0` here
# a document number. `W221` in corpus A is a real document; `v0.5.0` here
# is not, and only the cue in front of it can tell them apart.
doc = derive("The classifier lags v0.3.0 and the gap is wider now (v0.5.0a2).\n", "n500-x.md")
doc = derive("The classifier lags v0.3.0 and the gap is wider now (v0.5.0a2).\n", "q500-x.md")
assert doc.references == ()
def test_a_number_inside_markup_is_not_a_reference() -> None:
doc = derive('<math id="id-647a093b-a70f-4e38-d277-b139040226a4">x</math>\n', "n500-x.md")
doc = derive('<math id="id-647a093b-a70f-4e38-d277-b139040226a4">x</math>\n', "q500-x.md")
assert doc.references == ()
def test_a_measurement_is_not_a_reference() -> None:
doc = derive("Compressive strength at least 500 kN/m2, used only below 2.5 m.\n", "n500-x.md")
doc = derive("Compressive strength at least 500 kN/m2, used only below 2.5 m.\n", "q500-x.md")
assert doc.references == ()
def test_a_cue_word_makes_a_number_a_reference() -> None:
# KNOWN-POSITIVE. Without it the two tests above pass for the wrong reason:
# a rule that finds nothing at all also finds nothing false.
doc = derive("Laid to depth as se kapittel 2.6.2.2 and tabell 4.8.4-3 give.\n", "n500-x.md")
doc = derive("Laid to depth as se kapittel 2.6.2.2 and tabell 4.8.4-3 give.\n", "q500-x.md")
assert doc.references == ("2.6.2.2", "4.8.4")
assert "references" in doc.derived
def test_a_section_sign_is_a_cue() -> None:
doc = derive("Emitted as one block, in §10.2's own listing order.\n", "n500-x.md")
doc = derive("Emitted as one block, in §10.2's own listing order.\n", "q500-x.md")
assert doc.references == ("10.2",)
@ -437,14 +437,14 @@ def test_a_cue_only_counts_at_a_word_boundary() -> None:
# A Norwegian compound ending in `-klasse` ends in the cue `se`. Matching
# an unanchored cue admitted 86 such class designations on corpus A, none
# of which name a document.
doc = derive("Se også R210; lysklasse C5 gjelder.\n", "n500-x.md")
assert doc.references == ("R210",)
doc = derive("Se også P210; ovnsklasse C5 gjelder.\n", "q500-x.md")
assert doc.references == ("P210",)
def test_a_fragment_only_link_target_is_not_a_reference() -> None:
# An anchor points inside THIS document. It can never resolve to another
# concept, so emitting it states a relation that cannot exist.
doc = derive("See [table pairing](#table-pairing).\n", "n500-x.md")
doc = derive("See [table pairing](#table-pairing).\n", "q500-x.md")
assert doc.references == ()
@ -453,5 +453,5 @@ def test_a_template_placeholder_link_target_is_not_a_reference() -> None:
# occur in a concept name (`reduce_to_id_grammar` emits lowercase
# alphanumerics and hyphens), so such a target can never resolve — and an
# edge that can never resolve is a claim, not a pending pointer.
doc = derive("Managed lines read `- [title](ingest-{id}.md)`.\n", "n500-x.md")
doc = derive("Managed lines read `- [title](ingest-{id}.md)`.\n", "q500-x.md")
assert doc.references == ()

View file

@ -38,13 +38,13 @@ from llm_ingestion_okf.segmentation import parse_segmentation_plan
FIXTURES = Path(__file__).parent / "fixtures"
IDENTITY = FIXTURES / "sts-identity.xml"
FIRST = "Omfatter utskifting av skadde enkeltkomponenter i rekkverk."
SECOND = "Omfatter maling av rekkverk."
FIRST = "Omfatter utskifting av skadde enkeltkomponenter i gelender."
SECOND = "Omfatter maling av gelender."
EXPECTED = {
"Rekkverk": None,
"Gelender": None,
"Utskifting av enkeltkomponenter": FIRST,
"Utskifting av handlist": None,
"Maling av rekkverk": SECOND,
"Maling av gelender": SECOND,
}
STAMP = "2026-09-08T12:00:00Z"
@ -77,10 +77,10 @@ def test_each_titled_section_carries_its_own_first_spec_paragraph() -> None:
marks = extract.xml_outline(IDENTITY.name, IDENTITY.read_bytes())
assert [(mark.title, mark.description) for mark in marks] == [
("88 Rekkverk", None),
("88 Gelender", None),
("88.612 Utskifting av enkeltkomponenter", FIRST),
("88.6121 Utskifting av handlist", None),
("88.613 Maling av rekkverk", SECOND),
("88.613 Maling av gelender", SECOND),
]
@ -108,7 +108,7 @@ def test_a_spec_point_a_yaml_reader_could_not_read_verbatim_is_not_written(
) -> None:
"""Verbatim or not at all, as for the `sources` title.
MEASURED ON R761: 217 of the 2 024 descriptions the first build wrote carry
MEASURED ON THE REFERENCE STANDARD: 217 of the 2 024 descriptions the first build wrote carry
`": "`, and a YAML reader refuses `description: a: b` -- PyYAML's
`safe_load` rejected exactly those 217 concepts' frontmatter, which the
same document's frontmatter had parsed without before the key existed.
@ -118,12 +118,12 @@ def test_a_spec_point_a_yaml_reader_could_not_read_verbatim_is_not_written(
"""
data = (
IDENTITY.read_bytes()
.replace(b"Omfatter maling av rekkverk.", b"Omfatter maling: rekkverk og gjerder.")
.replace(b"Omfatter maling av gelender.", b"Omfatter maling: gelender og gjerder.")
.replace(b"Omfatter utskifting av skadde", b"Omfatter 1:2 utskifting av skadde")
)
concepts = _by_title(_build(tmp_path, IDENTITY.name, data))
assert "description" not in concepts["Maling av rekkverk"]
assert "description" not in concepts["Maling av gelender"]
assert concepts["Utskifting av enkeltkomponenter"]["description"] == FIRST.replace(
"Omfatter utskifting", "Omfatter 1:2 utskifting"
)
@ -155,7 +155,7 @@ def _plan() -> dict[str, object]:
data = IDENTITY.read_bytes()
text = extract.extract_text(IDENTITY.name, data)
return propose.build_plan(
IDENTITY, text, data, okf_type="reference", proposed_at=STAMP, path_prefix="r900"
IDENTITY, text, data, okf_type="reference", proposed_at=STAMP, path_prefix="p900"
)
@ -209,7 +209,7 @@ def test_a_description_the_gate_refuses_is_dropped_and_the_concept_is_kept(
concepts = _door(tmp_path, gate)
assert "description" not in concepts["Utskifting av enkeltkomponenter"]
assert concepts["Maling av rekkverk"]["description"] == SECOND
assert concepts["Maling av gelender"]["description"] == SECOND
def test_the_description_written_is_the_gates_sanitized_text(tmp_path: Path) -> None:

View file

@ -8,8 +8,8 @@ score, that signal's contribution to the fusion is the concepts' own ids in
lexicographic order: a UUID, which is noise, weighted exactly as heavily as
the two signals that did the measuring.
MEASURED 2026-09-08 on the N500 bundle (270 concepts, `feae0c8`), for the
question about `vann- og frostsikring` in a subsea tunnel: the document prior
MEASURED 2026-09-08 on a 270-concept third-party bundle (`feae0c8`), for a
question about `vann- og fuktsikring` in a buried cellar: the document prior
has **two** distinct values over the bundle, and 269 concepts share one of
them. The best covering concept answered **7 of 7** question tokens and led
the body signal at rank 6, and it fused to rank **14** -- outside the cut --
@ -35,7 +35,7 @@ sys.path.insert(0, str(PROJECT_ROOT / "tools"))
import okf_consume # noqa: E402
QUESTION = "Hvilke krav gjelder vann- og frostsikring i undersjoeisk tunnel?"
QUESTION = "Hvilke krav gjelder vann- og fuktsikring i nedgravd kjeller?"
_FRONTMATTER = (
"---\ntype: reference\ntitle: {title}\nsource_file: {slug}.md\n"
@ -51,7 +51,7 @@ def _tie_bundle(root: Path, *, fillers: int = 30) -> Path:
The gold concept's id sorts LAST and the fillers' ids sort first, which is
what makes the degenerate signal's tie-break work against the concept that
answers the question. The fillers answer `krav`, `gjelder`, `vann` and
`tunnel`; only the gold answers `frostsikring` and `undersjoeisk` too.
`kjeller`; only the gold answers `fuktsikring` and `nedgravd` too.
"""
(root / "krav").mkdir(parents=True)
(root / "index.md").write_text(
@ -69,14 +69,14 @@ def _tie_bundle(root: Path, *, fillers: int = 30) -> Path:
add(
"zz-gull",
"Krav om vann- og frostsikring i undersjoeisk tunnel",
"Kravet gjelder vannsikring og frostsikring i undersjoeisk tunnel.\n" * 4,
"Krav om vann- og fuktsikring i nedgravd kjeller",
"Kravet gjelder vannsikring og fuktsikring i nedgravd kjeller.\n" * 4,
)
for number in range(1, fillers + 1):
add(
f"aa-{number:02d}",
f"Krav om tunnel og vann {number:02d}",
"Kravet gjelder tunnel og vann i anlegget.\n" * 4,
f"Krav om kjeller og vann {number:02d}",
"Kravet gjelder kjeller og vann i anlegget.\n" * 4,
)
(root / "krav" / "index.md").write_text("".join(entries), encoding="utf-8")
return root
@ -108,8 +108,8 @@ def test_the_hyphen_and_og_coordination_is_already_resolved_by_the_tokeniser() -
# CHARACTERISATION, green on HEAD. This is the measurement that ruled the
# tokeniser out as the site of the fix: there is no coordination left to
# resolve, so a rule widening it could not have moved the miss.
assert okf_consume.normalise("vann- og frostsikring") == ("vann", "frostsikring")
assert okf_consume.tokens_match("frostsikring", "frostsikringen")
assert okf_consume.normalise("vann- og fuktsikring") == ("vann", "fuktsikring")
assert okf_consume.tokens_match("fuktsikring", "fuktsikringen")
assert okf_consume.tokens_match("vann", "vannsikring")

View file

@ -1,8 +1,8 @@
"""A structure the publisher DECLARED takes the route declared structure takes.
MEASURED, AND THE MEASUREMENT IS THE WHOLE REASON THIS FILE EXISTS. Round 13
gave `.xml` a reader that emits **2 761 of 2 761** ATX lines for R761's titled
`<sec>` elements, with the text preserved exactly. At SHIPPED DEFAULTS the
gave `.xml` a reader that emits **2 761 of 2 761** ATX lines for a reference
standard's titled `<sec>` elements, with the text preserved exactly. At SHIPPED DEFAULTS the
build then delivered **23 concepts and 15 of 2 761 boundaries** -- everything
after the reader ate it. Two steps, each measured on that document and
reproduced in miniature by `sts-container.xml`:
@ -92,7 +92,7 @@ def test_a_declared_section_is_a_rule_of_its_own_and_not_the_bookmark_arms() ->
def test_a_container_section_survives_where_the_orphan_check_deletes_it() -> None:
"""710 of 2 761 on R761; 1 of 9 here, and it is the chapter above the rest."""
"""710 of 2 761 on the reference standard; 1 of 9 here, and it is the chapter above the rest."""
text = extract.extract_text(CONTAINER.name, CONTAINER.read_bytes())
marks = extract.xml_outline(CONTAINER.name, CONTAINER.read_bytes())
@ -117,7 +117,7 @@ def test_a_container_section_survives_where_the_orphan_check_deletes_it() -> Non
def test_the_unit_fold_does_not_eat_a_section_the_publisher_declared() -> None:
"""2 089 -> 23 on R761; 8 -> 3 here. At the SHIPPED defaults, not behind a flag."""
"""2 089 -> 23 on the reference standard; 8 -> 3 here. At the SHIPPED defaults, not behind a flag."""
text = extract.extract_text(CONTAINER.name, CONTAINER.read_bytes())
marks = extract.xml_outline(CONTAINER.name, CONTAINER.read_bytes())

View file

@ -3,9 +3,9 @@
MEASURED OUTSIDE THIS REPOSITORY AND REPRODUCED HERE: the zip a publisher's
own viewer delivers as "Html" holds 0 html, 1 xml and 109 images, and `okf
build` on it gave **110 of 110 unreadable, 0 plans, exit 2** -- `.xml` was in
neither extractor registry. The one xml file IS the whole product: R761
Prosesskoden:2025, the same document round 12 met as a 701-page PDF, in
NISO-STS form. It carries 7 715 `<sec>`, **2 761 of them with a `<title>`**,
neither extractor registry. The one xml file IS the whole product: a large
regulatory reference document, the same one round 12 met as a 701-page PDF,
in NISO-STS form. It carries 7 715 `<sec>`, **2 761 of them with a `<title>`**,
and a `<sec>`-nesting depth distribution of 28/118/500/1141/868/97/9 that is
row-for-row identical to the fasit built from that same file.
@ -111,7 +111,7 @@ def test_the_label_and_the_title_reach_the_proposer_as_one_heading() -> None:
def test_a_lettered_point_is_a_body_line_and_never_a_heading() -> None:
"""4 954 of R761's 7 715 `<sec>` are these. One heading each buries 2 761."""
"""4 954 of the reference standard's 7 715 `<sec>` are these. One heading each buries 2 761."""
text = extract.extract_text(STS.name, STS.read_bytes())
assert "a) Betong skal ha fasthetsklasse B35." in text.split("\n")
@ -154,8 +154,8 @@ def test_generic_xml_keeps_its_text_and_invents_no_structure() -> None:
`<sec>`), so a document that is not STS is not guessed at."""
text = extract.extract_text(GENERIC.name, GENERIC.read_bytes())
assert "Tunnelen er stengt for vedlikehold natt til fredag." in text
assert "Redusert framkommelighet" in text
assert "Kantinen er stengt for vedlikehold natt til fredag." in text
assert "Redusert åpningstid" in text
assert not any(line.startswith("#") for line in text.split("\n"))
assert propose.find_candidates(text) == []
@ -232,7 +232,7 @@ def test_a_bundle_is_built_end_to_end_from_an_sts_document(tmp_path: Path) -> No
def test_a_label_with_no_body_under_it_still_reaches_the_text() -> None:
"""A pending label must not be overwritten by the next one, or by a heading.
MEASURED ON R761, and it is exactly two characters: one `x)` marks a
MEASURED ON THE REFERENCE STANDARD, and it is exactly two characters: one `x)` marks a
`<sec>` that carries a label and nothing else, and the label was held as a
prefix for a body line that never came -- the next thing emitted was the
following section's heading. Non-whitespace preservation was 1 283 393

View file

@ -23,7 +23,7 @@ Three readers must agree on every value written, each for its own reason:
MEASURED before any code moved (PyYAML 6.0.3): the K2 default bundle this
repository pins fails `safe_load` on 41 of its 455 frontmatter blocks, every
one a `title` (a leading `- `, `**` or `*`, a `": "`), and the R761 build on 1
one a `title` (a leading `- `, `**` or `*`, a `": "`), and the reference-standard build on 1
of 2 763 (a title ending in `:`). No `sources` value okf itself wrote failed.
"""
@ -97,11 +97,11 @@ def _concept(title: str, **overrides: object) -> str:
#: Plain today and plain after: a colon with no space, a `#` with no space
#: before it, `=`, `&`. Written byte-for-byte as before -- the known-negative
#: that keeps the fix from quoting what never needed it.
BLOCK_PLAIN = ["N100:2023", "Kap#3", "R761 Prosesskoden:2025", "a=b&c", "1:2 utskifting"]
BLOCK_PLAIN = ["Q100:2023", "Kap#3", "P761 Oppskriftsboka:2025", "a=b&c", "1:2 utskifting"]
#: Each is refused or MISREAD by PyYAML as a plain block scalar.
BLOCK_NOT_PLAIN = [
"N100: 2023",
"Q100: 2023",
"Kap #3",
"Eksempel kontur:",
"- punkt",
@ -147,7 +147,7 @@ def test_a_run_stated_description_with_a_colon_space_reads_back(tmp_path: Path)
"""K3-19's 217: a DERIVED spec point carrying `": "` is still omitted
(`test_sts_description.py`); a STATED one is the caller's words and is
written in a form a YAML reader returns verbatim."""
value = "Omfatter maling: rekkverk og gjerder."
value = "Omfatter maling: gelender og gjerder."
text = _concept(
"T", profile=SEGMENTED_OKF_V0_2, concept_frontmatter_values={"description": value}
)
@ -157,11 +157,11 @@ def test_a_run_stated_description_with_a_colon_space_reads_back(tmp_path: Path)
# --- the `sources` flow mapping ---------------------------------------------
#: Written verbatim inside the flow mapping, and every reader agrees.
FLOW_KEPT = ["N100:2023", "R761 Prosesskoden 2025", "Kap#3", "a=b&c"]
FLOW_KEPT = ["Q100:2023", "P761 Oppskriftsboka 2025", "Kap#3", "a=b&c"]
#: No form both PyYAML and the guard read: plain, PyYAML refuses or misreads
#: it; quoted, the guard refuses it. Refused rather than written.
FLOW_REFUSED = ["N100: 2023", "Kap #3", "slutt:", "a?b", "Vegvesen's", 'Sa "hei"', "*x", "a, b"]
FLOW_REFUSED = ["Q100: 2023", "Kap #3", "slutt:", "a?b", "Forlaget's", 'Sa "hei"', "*x", "a, b"]
@pytest.mark.parametrize("title", FLOW_KEPT)
@ -206,7 +206,7 @@ def test_a_run_stated_sources_with_a_query_string_is_refused() -> None:
def test_a_run_stated_sources_without_one_is_written_verbatim(tmp_path: Path) -> None:
stated = "[{ resource: https://h.no/api/1, title: N100:2023 }]"
stated = "[{ resource: https://h.no/api/1, title: Q100:2023 }]"
text = _concept("T", profile=SEGMENTED_OKF_V0_2, concept_frontmatter_values={"sources": stated})
assert f"\nsources: {stated}\n" in text
_assert_every_reader_agrees(text, tmp_path)
@ -221,8 +221,8 @@ def test_door_a_refuses_a_locator_no_reader_pair_can_share(root: str) -> None:
# --- the readers read both forms ---------------------------------------------
QUOTED = '---\ntitle: "N100: 2023 \\"sitat\\" C:\\\\mappe"\nprosessnr: \'1\'\n---\n\nBody.\n'
UNQUOTED_TITLE = 'N100: 2023 "sitat" C:\\mappe'
QUOTED = '---\ntitle: "Q100: 2023 \\"sitat\\" C:\\\\mappe"\nprosessnr: \'1\'\n---\n\nBody.\n'
UNQUOTED_TITLE = 'Q100: 2023 "sitat" C:\\mappe'
def test_every_flat_reader_unquotes_a_double_quoted_value(tmp_path: Path) -> None:
@ -251,14 +251,14 @@ def test_read_sources_unquotes_in_both_yaml_forms() -> None:
block = [
"sources:",
' - resource: "https://h.no/api/1?languageCode=nb"',
' title: "N100: 2023"',
' title: "Q100: 2023"',
]
flow = ['sources: [{ resource: "https://h.no/a?x=1, y", title: "N100: 2023" }]']
flow = ['sources: [{ resource: "https://h.no/a?x=1, y", title: "Q100: 2023" }]']
assert read_sources(block)[0] == (
{"resource": "https://h.no/api/1?languageCode=nb", "title": "N100: 2023"},
{"resource": "https://h.no/api/1?languageCode=nb", "title": "Q100: 2023"},
)
# A quoted comma is part of the value, not an entry separator.
assert read_sources(flow)[0] == ({"resource": "https://h.no/a?x=1, y", "title": "N100: 2023"},)
assert read_sources(flow)[0] == ({"resource": "https://h.no/a?x=1, y", "title": "Q100: 2023"},)
def _strip_only(path: Path) -> dict[str, str]:
@ -321,7 +321,7 @@ CONSTRUCTED = [
"a{b}c",
"tab\there",
"slutt :",
"Statens vegvesen",
"Forlagets kokebok",
"100 %",
]