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:
parent
1e7345a401
commit
9d1f4b14ed
174 changed files with 1889 additions and 6512 deletions
18
tests/fixtures/README.md
vendored
18
tests/fixtures/README.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
tests/fixtures/accounting/corpus/figur.html
vendored
4
tests/fixtures/accounting/corpus/figur.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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"/>
|
||||
Binary file not shown.
|
|
@ -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">
|
||||
|
|
@ -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.
BIN
tests/fixtures/accounting/corpus/liste-og-bilde.odt
vendored
BIN
tests/fixtures/accounting/corpus/liste-og-bilde.odt
vendored
Binary file not shown.
2
tests/fixtures/accounting/corpus/logg.txt
vendored
2
tests/fixtures/accounting/corpus/logg.txt
vendored
|
|
@ -1,4 +1,4 @@
|
|||
Driftslogg for tunnelen.
|
||||
Driftslogg for bakerovnen.
|
||||
|
||||
Vask utført i mars.
|
||||
Lys kontrollert i april.
|
||||
|
|
|
|||
10
tests/fixtures/accounting/corpus/notat.md
vendored
10
tests/fixtures/accounting/corpus/notat.md
vendored
|
|
@ -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 |
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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}}
|
||||
|
|
|
|||
12
tests/fixtures/accounting/corpus/side.htm
vendored
12
tests/fixtures/accounting/corpus/side.htm
vendored
|
|
@ -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>
|
||||
|
|
|
|||
16
tests/fixtures/accounting/corpus/sts-rikt.xml
vendored
16
tests/fixtures/accounting/corpus/sts-rikt.xml
vendored
|
|
@ -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><</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><</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>
|
||||
|
|
|
|||
Binary file not shown.
796
tests/fixtures/accounting/inventory.json
vendored
796
tests/fixtures/accounting/inventory.json
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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><</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><</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>
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
]}}
|
||||
]}}
|
||||
]}}
|
||||
]}}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
]}}
|
||||
]}}
|
||||
]}}
|
||||
]}}
|
||||
]}}
|
||||
]}}
|
||||
]}}
|
||||
]
|
||||
}
|
||||
}
|
||||
4
tests/fixtures/font-heading-krav.pdf
vendored
4
tests/fixtures/font-heading-krav.pdf
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
tests/fixtures/generic-feed.xml
vendored
8
tests/fixtures/generic-feed.xml
vendored
|
|
@ -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>
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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"/>
|
||||
Binary file not shown.
|
|
@ -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">
|
||||
12
tests/fixtures/k2-office-fasit.json
vendored
12
tests/fixtures/k2-office-fasit.json
vendored
|
|
@ -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,
|
||||
|
|
|
|||
BIN
tests/fixtures/k2-office/krav-presentasjon.pptx
vendored
BIN
tests/fixtures/k2-office/krav-presentasjon.pptx
vendored
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
BIN
tests/fixtures/k2-office/krav-tekstdokument.odt
vendored
BIN
tests/fixtures/k2-office/krav-tekstdokument.odt
vendored
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
37
tests/fixtures/make_fixtures.py
vendored
37
tests/fixtures/make_fixtures.py
vendored
|
|
@ -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>'
|
||||
|
|
|
|||
48
tests/fixtures/make_image_fixtures.py
vendored
48
tests/fixtures/make_image_fixtures.py
vendored
|
|
@ -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)
|
||||
|
|
|
|||
34
tests/fixtures/make_k2_office.py
vendored
34
tests/fixtures/make_k2_office.py
vendored
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
BIN
tests/fixtures/no-styles-krav.docx
vendored
BIN
tests/fixtures/no-styles-krav.docx
vendored
Binary file not shown.
4
tests/fixtures/numbered-font-krav.pdf
vendored
4
tests/fixtures/numbered-font-krav.pdf
vendored
|
|
@ -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
|
||||
|
|
|
|||
18
tests/fixtures/outline-collision.pdf
vendored
18
tests/fixtures/outline-collision.pdf
vendored
|
|
@ -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
|
||||
|
|
|
|||
14
tests/fixtures/sts-identity.xml
vendored
14
tests/fixtures/sts-identity.xml
vendored
|
|
@ -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>
|
||||
|
|
|
|||
6
tests/fixtures/sts-inherit.xml
vendored
6
tests/fixtures/sts-inherit.xml
vendored
|
|
@ -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>
|
||||
|
|
|
|||
4
tests/fixtures/three-page-krav.pdf
vendored
4
tests/fixtures/three-page-krav.pdf
vendored
|
|
@ -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
|
||||
|
|
|
|||
BIN
tests/fixtures/two-line-krav.docx
vendored
BIN
tests/fixtures/two-line-krav.docx
vendored
Binary file not shown.
2
tests/fixtures/two-line-krav.pdf
vendored
2
tests/fixtures/two-line-krav.pdf
vendored
|
|
@ -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
|
||||
|
|
|
|||
BIN
tests/fixtures/two-line-krav.xlsx
vendored
BIN
tests/fixtures/two-line-krav.xlsx
vendored
Binary file not shown.
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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, (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
"\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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"})"
|
||||
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_æøå",
|
||||
"---",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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("#")] == []
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {}}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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/")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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` ------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 == ()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %",
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue