feat(inbox): point every concept at the document it came from, with a locator per format

A concept named its source file by basename and, when segmented, carried a
`source_offset` into the text THIS LIBRARY extracted. Following that pointer
needed the corpus directory, the extractor and its exact transitive version --
none of which the bundle carries. Hand-walked on a real K2 concept: six steps,
four of them requiring knowledge from outside the bundle, to learn that a
requirement sits on pages 12-13 of a 20-page document.

The address is spec's: `sources: [{ resource, title }]`, where `resource` is
the dropped file's inbox-relative path (SPEC v0.2 5.1:303-306 -- "an absolute
URL, a bundle-relative path, or a path into a `references/` subdirectory").
The locator is ours, and it has to be: 5.1 has no field for a place within a
resource, and the pinned guard (1.3.0) rejects every route to putting one
inside a `sources` entry -- a non-allowlisted key by name, a nested flow list
as "scalar leaves only", and quoting as an unsupported form. So the locator is
top-level keys shaped like `source_offset`, and a path carrying a flow
terminator is refused fail-fast rather than mangled.

The unit table is built AT EXTRACTION, where the extracted text and the
original's structure are known to agree: pdf -> `source_pages` from
pdfplumber's own page numbers (a page that yielded no text does not renumber
the ones after it), xlsx -> `source_sheet` + `source_rows`, everything else ->
`source_lines`. `source_offset` stays.

Two measurements changed the design before it shipped. A `paragraphs` key for
docx would name a number the document does not have: `<w:p>` counts of
108/27/65/176/57 against converted-markdown lines of 75/33/67/144/63, not one
pair agreeing -- so the key is `source_lines` and says what it indexes. And an
empty spreadsheet row renders exactly like a table separator: the content-based
rule ate 8 empty rows on the K2 price sheet and reported its last row as 92
against a workbook that says 100. The separator is now found by position, and
`tomrad.xlsx` keeps that red.

One profile moves. `provenance` is a policy object, `None` everywhere but
`SEGMENTED_OKF_V0_2`; the other five shipped profiles are byte-identical.

K2 rebuilt from a frozen src copy: 629 concepts, 1108 files, name set identical,
0 ids moved, 479 files byte-identical, 629 changed and 0 lines removed anywhere.
629/629 now carry an address and a locator. New ref
`sha256-tree:665563a2f74423fcbcc8e4f0b0954ee73b73985ac0418de4f6987bd162a1f7c8`;
`2f82fcfe...` is stale. The pre-pass payload does not grow by one byte
(209 092 B before and after, 18 changed lines: the ref and eight per-concept
digests) -- because an excerpt carries the body, not the frontmatter, which is
also why the consumer still cannot cite "file X page 12" from a payload alone.

Report: docs/2026-09-08-proveniens-k2.md. 1339 tests, ruff and mypy clean.

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-08 14:39:24 +02:00
commit b6a8c8bd89
16 changed files with 1301 additions and 26 deletions

View file

@ -35,7 +35,20 @@ one boundary rule:
title (leading heading → `title` key → `path.stem`), document number, title (leading heading → `title` key → `path.stem`), document number,
hierarchy, and cross-references — writes it into the concept frontmatter, and hierarchy, and cross-references — writes it into the concept frontmatter, and
projects it into a faceted index entry. Every inferred field is named in a projects it into a faceted index entry. Every inferred field is named in a
`derived` list; an unmarked heuristic is worse than none. The index is a `derived` list; an unmarked heuristic is worse than none.
Under the SEGMENTED v0.2 profile a concept additionally POINTS BACK at the
original: `sources: [{ resource, title }]` in SPEC §5.1's form (`resource` is
the inbox-relative path), plus a locator per format — `source_pages`,
`source_sheet`+`source_rows`, else `source_lines`. The locator keys are OURS
and must stay top-level: §5.1 has no field for a place within a resource, and
the pinned guard rejects every route to putting one inside a `sources` entry
(non-allowlisted key, nested flow list, quoted scalar), so a locator in the
entry would emit bundles Door C could never read back. The unit table is
built AT EXTRACTION — a page number cannot be recovered from joined text —
and `source_offset` stays. `source_lines` indexes the EXTRACTED text, never
the original's paragraphs: measured, docx `<w:p>` counts and converted-line
counts do not agree on a single one of five documents. Record:
`docs/2026-09-08-proveniens-k2.md`. The index is a
PROJECTION recomputed from the whole bundle each round, which is what makes PROJECTION recomputed from the whole bundle each round, which is what makes
rebuild-from-scratch equal an incremental update byte for byte. `DEFAULT` is rebuild-from-scratch equal an incremental update byte for byte. `DEFAULT` is
untouched and byte-identical. Record: `docs/plan/structure-derivation.md`. untouched and byte-identical. Record: `docs/plan/structure-derivation.md`.

View file

@ -201,6 +201,16 @@ bundle:
root as its `source_file`, while dot-directories and a bundle directory root as its `source_file`, while dot-directories and a bundle directory
sitting inside the inbox are skipped with a reported code. sitting inside the inbox are skipped with a reported code.
Under the segmented v0.2 profile a concept also points back at the document
it was extracted from, so an agent citing it can open the original at the
right place: `sources: [{ resource, title }]` in the spec's own §5.1 form,
where `resource` is the inbox-relative path, plus a locator per format —
`source_pages` for a PDF, `source_sheet` and `source_rows` for a
spreadsheet, `source_lines` otherwise. The locator keys are this library's
own, because §5.1 has no field for a place *within* a resource; the line
numbers index the extracted text and say so. Measurements:
[`docs/2026-09-08-proveniens-k2.md`](docs/2026-09-08-proveniens-k2.md).
<!-- extract-formats: .md, .txt, .csv, .json, .html, .htm, .pdf, .docx, .xlsx, .pptx, .odt, .rtf --> <!-- extract-formats: .md, .txt, .csv, .json, .html, .htm, .pdf, .docx, .xlsx, .pptx, .odt, .rtf -->
3. **External bundle import.** Import and merge of third-party OKF bundles: 3. **External bundle import.** Import and merge of third-party OKF bundles:
each concept is assessed via the security gate, and only concepts that each concept is assessed via the security gate, and only concepts that

View file

@ -0,0 +1,420 @@
# Provenance to the original: an address and a locator per format, measured on 629 concepts
**Date:** 2026-09-08 · **Order:** `20260908T104812Z-6143923560-from-.claude` ·
**Instrument:** `src/llm_ingestion_okf/{extract,inbox,profiles}.py` plus the
`SEGMENTED_OKF_V0_2` profile · **Corpus:** the same 43-document public tender
folder every K2 measurement in this repository runs on.
A consuming agent is expected to falsify a hypothesis against the ORIGINAL
document. Before this step it could not: a concept named its source file by
basename, digested it, and — when segmented — carried a `source_offset` that
indexes the text THIS LIBRARY extracted, not the document anybody wrote. The
corpus directory, the extractor and its exact version were all required to
follow that pointer, and none of the three is in the bundle.
The corpus is external and no document body from it appears here. Documents are
named by shape, and everything else is a count.
---
## 0. What IS measured, and what is NOT
**Measured.** The provenance keys present on all 629 K2 concepts before and
after, per source format with a denominator; the three questions the order asks
per format ("can a consumer find the file, verify it, open the right place?");
one hand-walked known-positive from `source_offset` back to a page of the
original, counted in steps; the guard's verdict on seven candidate `sources`
forms, known-positive first; a full K2 rebuild with the file-set,
byte-diff and content identity; the payload a consumer receives; and the
viewer's own route table.
**Known-positive, run first.** The rebuild reproduces the bundle this
repository has been measuring on all week before anything else is read from it:
`merged + coded rejections = 39 + 4 = 43 = N`, exit 0; **629 concepts**;
**1 108 files, and the file-set is identical name for name** — 0 concept ids
moved. The 479 files that are not concepts (478 `index.md` plus `log.md`) are
byte-identical to the previous bundle.
**NOT measured.**
- **That a locator is CORRECT for any document not in this corpus.** The page
numbers are the extractor's own page numbers and the sheet rows were checked
against the workbook XML; the line numbers for the converted formats are
lines of OUR extraction and are labelled as such (§ 4).
- **That any model reads the new keys.** Whether an agent handed a payload
actually opens the original is `portfolio-optimiser`'s measurement, not this
one — and it cannot be, because the payload does not carry these keys at all
today (§ 6).
- **The viewer's per-requirement anchor.** One route table and one JS bundle
were read (§ 7). Whether `?reference=` positions a requirement needs a
browser, and the app lazy-loads chunks this measurement did not fetch.
- **A `.doc`, `.zip` or `.smc` row.** Those three types are rejected before any
concept exists, so they have no provenance to carry and no denominator here.
---
## 1. Before: what a concept said, per format, with denominators
Measured on the bundle built at `56c1205` (`/tmp/okf-kravnummer-20260908/k2-new`),
which is what HEAD produced — the following commit `116d3e1` changed the
consumption side only.
```
python3 - <<'PY' # frontmatter key presence over every non-index .md
... # the script is /tmp/o3/measure1.py; it parses the line-oriented block
PY
```
| | concepts | `source_file` | `source_sha256` | `source_offset` | `sources` |
|---|---|---|---|---|---|
| `.pdf` (32 of 33 files) | 586 | 586 | 586 | 576 | **0** |
| `.docx` (5 of 5) | 41 | 41 | 41 | 40 | **0** |
| `.xlsx` (2 of 2) | 2 | 2 | 2 | 2 | **0** |
| `.doc` / `.zip` / `.smc` | 0 | — | — | — | — |
| **total** | **629** | 629 | 629 | 618 | **0** |
The three questions the order asks, per format:
| format | (a) find the original file? | (b) verify it? | (c) open the right PLACE? |
|---|---|---|---|
| `.pdf` | **no** — a bare basename, no directory and no URL | **yes**`source_sha256` over the dropped bytes | **no**`source_offset` is a character position in the joined page text; `_extract_pdf` joins pages with a blank line and drops empty ones, so the page number is gone |
| `.docx` | **no** — same | **yes** | **no** — offset only; no paragraph, no heading path |
| `.xlsx` | **no** — same | **yes** | **partly** — the sheet name survives inside the concept TITLE (`Prissammenstilling {#sheet-1}`), as prose; no row, and no key a consumer can read |
Column (b) is a yes with a caveat worth stating: the digest verifies a file you
have already found by other means. It cannot help you find it.
### The known-positive: one hand-walk from an offset back to a page
Taken on a real segmented concept — a fire-safety requirement, `source_offset:
[24966, 25869]`, `source_sha256: 973e9490…` — using a script that shares no
code with the bundle builder:
1. Know the corpus directory. **Not in the bundle.** (`log.md` names it once,
for the run, not per concept — and `log.md` is a run log, not a concept.)
2. Read the file's bytes; confirm `sha256` matches. ✔
3. Know that the extractor is `pdfplumber` behind `[extract]`. **Not in the
bundle.**
4. Know its transitive text pin, `pdfminer.six==20260107`. **Not in the
bundle**, and the extracted text is pinned to it by a frozen literal in
`tests/test_extract.py`.
5. Re-extract, slice `[24966:25869]`. The requirement text comes back. ✔
6. To learn the PAGE, re-open the PDF page by page, rebuild the same join, and
bisect the offset. **Pages 1213.**
**Six steps, four of which need knowledge the bundle does not carry.** The
document has 20 pages; nothing in the concept says which two.
---
## 2. What the spec has, and what it does not
SPEC v0.2 § 5.1:303-306, verbatim:
> `resource`: REQUIRED within an entry. Names either a concrete artifact a
> consumer can follow (an absolute URL, a bundle-relative path, or a path
> into a `references/` subdirectory, §6) or a population or scope descriptor
> it cannot (for example `all queries in BigQuery project X`).
A dropped file's inbox-relative path is precisely "a bundle-relative path", so
the ADDRESS needs no key of ours. `docs/okf-nokkelinventar.md` had already
named that re-alignment for `source_file` (Del B, `sources[].resource` as a
bundle-relative or `references/` path, § 5.1:303-305 + § 6.3); this step
executes it.
§ 5.1 lists exactly six entry keys — `resource`, `id`, `title`, `author`,
`usage_count`, `last_modified` — and **none of them is a location within the
resource.** A page, a sheet row and a line have no spec key, at any level.
### Why the locator cannot live inside a `sources` entry
Not a preference — a measured gate. Against the pinned guard (1.3.0), with a
known-positive first:
```
.venv/bin/python -c 'from llm_ingestion_guard import okf; okf.parse_frontmatter(DOC)'
```
| candidate | verdict |
|---|---|
| `sources: [{ id: a, resource: fixture }]` (our Door A golden) | **PASS** — known-positive |
| `sources: [{ resource: a.pdf, title: A }]` | **PASS** |
| `sources: [{ resource: a.pdf, sha256: abc }]` | REJECT — `mapping key 'sha256' is not on the OKF mapping allowlist under 'sources'` |
| `sources: [{ resource: a.pdf, pages: [1, 2] }]` | REJECT — `a flow mapping admits scalar leaves only, not '['` |
| `sources: [{ resource: a.xlsx, sheet: Ark1, rows: [1, 9] }]` | REJECT — same |
| `sources: [{ resource: "Del II, Bilag.pdf" }]` | REJECT — `a quoted scalar inside a flow mapping is not a supported form` |
| `sources: [{ resource: Del II Bilag 3.3.1 - Brannkonsept.pdf }]` | **PASS** — spaces are fine unquoted |
Two of those rows decide the design. A locator inside the entry has **no
expressible form** that survives the gate, so it would produce bundles this
project emits and could never read back through Door C. And because quoting is
also refused, a path containing a flow terminator cannot be escaped — it is
refused fail-fast instead (`inbox_source_file_unaddressable`), which is this
library's standing rule of validation over repair.
**The spec deviation, in four parts.**
| | |
|---|---|
| **spec says** | `sources[].resource` is the address (§ 5.1:303-306); there is no field for a place within it |
| **we do** | write the address in spec form, and the locator as top-level keys of ours — `source_pages`, `source_sheet`, `source_rows`, `source_lines` — in the shape `source_offset` already had |
| **measurement** | the guard rejects every route to putting a locator inside the entry (table above), and § 5.1's six keys contain no locator to reuse |
| **re-align** | if upstream ever gives `sources` a location field, these four keys move into the entry and become a rename. Until then they are ours, named as ours, and they say what they index |
---
## 3. The rule
**A unit table is built AT EXTRACTION**, where the extracted text and the
original's structure are the only moment they are known to agree, and it is
stored as `(start offset, original unit number, scope)` triples. A locator is
then a lookup, never a guess about text whose page breaks are already gone.
- `.pdf`**`source_pages: [from, to]`**, from `pdfplumber`'s own page numbers.
A page that yielded no text leaves the table without renumbering the pages
after it, so the number is the document's, not "the n-th page that produced
text".
- `.xlsx`**`source_sheet: <name>` + `source_rows: [from, to]`**. The sheet
comes from the heading the converter writes per sheet; the row restarts at
every sheet and skips the separator line **by position, never by content**
(§ 4).
- `.docx` and every text type → **`source_lines: [from, to]`**, lines of the
extracted text (§ 4).
- `source_offset` **stays**, unchanged. It is what an existing consumer joins
on, and a locator that replaced it would break them in order to help them.
`sources` is written whether or not a locator can be computed: the address
answers "which document", the locator answers "where in it", and a consumer is
owed the first even when the second is unavailable.
**One profile moves.** `provenance` is a policy object on `BundleProfile`,
defaulted to `None`, and set on `SEGMENTED_OKF_V0_2` alone. `sources` is a v0.2
key so a v0.1 profile must not name it; `DEFAULT` and `STRICT_V1` state
contracts owned in other repositories; `OKF_V0_2` is Door A's, where `sources`
already comes from the manifest. Five shipped profiles are byte-identical, and
a test asserts the field set so a sixth cannot arrive unnoticed.
---
## 4. Two things the measurement CHANGED before they shipped
Both were found by running the rule against the real corpus, and both would
have shipped a confident wrong number.
**A `paragraphs` key for `.docx` would have named a number the document does
not have.** The order asks for a paragraph or heading index. Measured on the
five K2 `.docx` files — `<w:p>` elements against converted-markdown lines:
| document | `<w:p>` | md lines | headings |
|---|---|---|---|
| 1 | 108 | 75 | 2 |
| 2 | 27 | 33 | 0 |
| 3 | 65 | 67 | 4 |
| 4 | 176 | 144 | 2 |
| 5 | 57 | 63 | 0 |
Not one pair agrees, and the heading-path alternative is empty for 2 of the 5.
So the key is `source_lines` and it says what it indexes: lines of the text
this library extracted. For `md`/`txt` that text IS the dropped file, and the
number is the original's own line.
**An empty spreadsheet row renders exactly like a table separator.** The first
implementation skipped the separator by matching the LINE (`^\|[-|: ]+\|$`).
On the K2 price sheet that rule also ate **8 empty rows**, and reported the
sheet's last row as **92** against a workbook that says **100** — every row
after the first blank one shifted, silently, for the whole sheet. The rule is
now positional: the second table line of a sheet is the separator, and nothing
else is. A new hand-laid fixture (`tomrad.xlsx`, four rows with the third
empty) is what keeps that red.
After the fix, against the workbook XML:
| spreadsheet | locator rows | `<row>` elements | contiguous from 1 |
|---|---|---|---|
| K2 document list | 139 | 39 | yes |
| K2 price sheet | 1100 | 100 | yes |
Exact, both. The honesty limit: this holds as far as one converted line per
`<row>` holds. A sheet whose XML omits a row entirely would be numbered from
the converted table, and nothing here can see that.
---
## 5. After: the rebuild
`okf build` over the same 43-document folder, same arguments
(`--ingested-at 2026-09-03T00:00:00Z --bundle-id k2-trinn1-20260903
--okf-version 0.2`), run from a frozen copy of `src/` on `PYTHONPATH` so no
later edit could reach a measurement in flight — the log's first line names
that path.
| | before | after |
|---|---|---|
| `merged + coded rejections == N` | `39 + 4 = 43` | `39 + 4 = 43`, exit 0 |
| concepts | 629 | **629** |
| files in the bundle | 1 108 | **1 108**, identical name set |
| concept ids that moved | — | **0** |
| files byte-identical | — | **479 of 1 108** (every `index.md`, plus `log.md`) |
| files changed | — | **629 — every concept, and nothing else** |
| lines REMOVED across all 629 | — | **0** |
The diff is additive by measurement, not by intention: every changed file gained
`sources` plus exactly one locator shape, and no file lost a line or changed a
body.
| added keys | concepts | matches |
|---|---|---|
| `sources`, `source_pages` | 586 | every `.pdf` concept |
| `sources`, `source_lines` | 41 | every `.docx` concept |
| `sources`, `source_sheet`, `source_rows` | 2 | every `.xlsx` concept |
**The three questions, after:**
| format | (a) find | (b) verify | (c) open the right place |
|---|---|---|---|
| `.pdf` | **yes** | **yes** | **yes**`source_pages` |
| `.docx` | **yes** | **yes** | **partly**`source_lines` locates the extraction, not the document |
| `.xlsx` | **yes** | **yes** | **yes**`source_sheet` + `source_rows` |
**629 of 629 carry an address and a locator.** For `.pdf` and `.xlsx`, all
three columns are yes; for `.docx` the third is honest rather than yes.
**The known-positive, walked again.** The same fire-safety concept now reads
`source_pages: [12, 13]` — the two pages the six-step hand-walk in § 1 arrived
at, computed by a script that shares no code with the builder. The walk is now
zero steps and needs nothing the bundle does not carry.
### The new content identity
**`sha256-tree:665563a2f74423fcbcc8e4f0b0954ee73b73985ac0418de4f6987bd162a1f7c8`.**
The previous ref, `sha256-tree:2f82fcfe…`, is superseded and stale. It was
recomputed from the previous bundle in the same run, as the known-positive, and
it matched — an instrument that has not reproduced a known figure has not been
shown to count.
The bundle on disk grows **97 011 B, 1 943 406 → 2 040 417 (+4.99 %)**, which is
**154 B per concept** — one address line plus one locator line.
### The payload regression
The order asks whether the pre-pass payload's token count moves now that the
frontmatter is bigger. Measured, same question, same `k`, both bundles through
the same tool:
| | before | after |
|---|---|---|
| payload bytes | 209 092 | **209 092** |
| delivered excerpts | 8 | 8 |
| budget spent (`BUDGET_UNIT`) | 118 409 | 118 409 |
| concept ids delivered | — | identical |
| excerpt texts, `text_sha256`, `withheld`, `denominators` | — | identical |
| changed lines in the whole payload | — | **18** |
The 18 are the bundle `ref` and the eight per-concept `sha256` values. **The
payload does not grow by one byte**, and the reason is § 6's finding stated from
the other side: an excerpt carries the concept's BODY, so frontmatter growth is
invisible to it. The frontmatter got bigger and the thing a consumer is billed
for did not move.
There is no o200k figure here. `tiktoken` is not installed on this machine, so
the byte count and the payload digest are the measurement; repeating a published
token number would be quoting, not measuring.
---
## 6. The consumer side: measured, not fixed
`tools/okf_consume.py` builds one excerpt per delivered concept, and
`excerpt_for` names its members explicitly. Measured on a real payload:
```
python3 -c "import json; d=json.load(open('…/k2-new-default.json')); \
print(sorted({k for x in d['excerpts'] for k in x}))"
# -> ['adjudication', 'bundle_id', 'bundle_id_inherited', 'concept_id',
# 'rank', 'sha256', 'text', 'text_sha256', 'trust_tier']
```
**No. Nine members, and not one of them is a provenance field** — not
`source_file`, not `sources`, not a locator, not even the concept's `title`.
An agent holding this payload cannot say "file X, page 12". It can say
`concept_id`, which is a path INTO THE BUNDLE, so the provenance is one file
read away — but only for a consumer that also has the bundle.
What it would take: `excerpt_for` carries `sources` and the locator keys
through from the concept's frontmatter. That is a payload-shape change, the
consumption contract § 8 enumerates an excerpt's members, and the shape is
`portfolio-optimiser`'s to decide. **Not done here**, deliberately, and this
paragraph is the whole of the fix's cost.
---
## 7. The viewer anchor, for `vegnormal-okf`
`https://viewers.vegnorm.vegvesen.no/product/859990/nb` answers **200** with an
**863-byte SPA shell** — no content, so the page itself decides nothing. The
route table and the query keys come from the app's own bundle
(`/assets/index-D1Z6iWLz.js`, 3 407 504 B):
- The route is **`/product/:id/:languageCode?/:parameter?`**. Read in the app,
`:parameter` is compared against exactly two literals — `"selection"` and
`"preview"`. **It is not a per-requirement anchor.**
- The app parses a QUERY STRING into `filePath`, `organization`,
`publicationUrn`, `tenantId`, **`reference`**, `isPrint`, `theme`, and passes
them to its content API call. **`?reference=` is the only candidate** for a
per-requirement address.
- **0 occurrences of `scrollIntoView`** in that bundle, so there is no
fragment-anchor scroll in it. `location.hash` appears 3 times, all inside the
router.
**Denominator, stated:** ONE bundle file, fetched once. The app lazy-loads
further chunks (`_mapDeps([20, 21])`) that this measurement did not fetch, and
what `reference` does to the rendered document needs a browser. So: a
per-requirement URL is **plausible via `?reference=<value>`** and **not
demonstrated**. Either way the value is the producer's to choose, and
`sources[].resource` is where it belongs — `vegnormal-okf` owns `sources` for
those bundles.
---
## 8. Honesty limits
1. **`source_lines` is not a paragraph number**, and for `.docx`, `.pptx`,
`.odt`, `.rtf`, `.csv`, `.json` and `.html` it indexes our extraction rather
than the document. § 4 has the numbers that make that the honest answer
rather than a shortcut.
2. **A row number holds only while one converted line means one `<row>`.**
Verified exact on both K2 spreadsheets and all three fixtures, all
contiguous from row 1. A sheet with a genuinely missing row would number
from the converted table.
3. **The address is inbox-relative, not absolute.** It resolves against the
folder that was dropped, and the bundle still does not say where that folder
is. Naming an absolute path would leak an operator's filesystem into a
published bundle; naming a URL is available whenever the source HAS one, and
for a dropped file it does not.
4. **The original is still not in the bundle.** § 6.3's `references/`
convention would put it there; copying binaries into a bundle is a decision
about size and licensing that nobody has taken, and it is not this order's.
5. **The rebuild is one corpus.** Every number above is 43 documents of one
public tender.
6. **`sources` carries no `id`.** § 5.1 says it SHOULD be present when the body
cites the source, and no body here cites with a `[^id]` footnote — measured
0 across the corpus in `docs/okf-nokkelinventar.md`. When one does, the key
is available and unwritten.
---
## 9. Verification log
| claim | command |
|---|---|
| 629 concepts, per-format key counts, before and after | `python3 /tmp/o3/measure1.py <bundle>` |
| the file set and the byte diff | a `rglob` set comparison plus `read_bytes()` equality over 1 108 files |
| every diff is additive | `difflib.unified_diff` per concept; 0 removed lines over 629 files |
| the guard's verdict on seven `sources` forms | `okf.parse_frontmatter` on each, known-positive first |
| docx paragraphs ≠ markdown lines | `<w:p>` count from `word/document.xml` against `extract_text(...).split("\n")` |
| spreadsheet rows against the workbook | `<row>` elements from `xl/worksheets/sheet1.xml` against `source_units(...).covering(0, len(text))` |
| the known-positive page walk | an independent script re-extracting and bisecting page offsets: pages 1213, matching the emitted `source_pages` |
| the viewer's route table | `curl` the SPA shell and its one JS asset; `path:"…"` and query-key extraction |
| the payload members | `json.load` over a shipped K2 payload |
| suite, lint, types | `pytest -q`, `ruff check src tests tools`, `ruff format --check`, `mypy --strict src` |

View file

@ -5,10 +5,12 @@ source_file: veiledning.md
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
ingested_at: 2026-08-30T09:00:00Z ingested_at: 2026-08-30T09:00:00Z
generated: true generated: true
sources: [{ resource: veiledning.md, title: veiledning.md }]
adjudication: proposed adjudication: proposed
bundle_id: b-golden-segmented-okf-v0-2 bundle_id: b-golden-segmented-okf-v0-2
parent: s0 parent: s0
segment_id: s1 segment_id: s1
source_lines: [5, 8]
source_offset: [94, 176] source_offset: [94, 176]
--- ---

View file

@ -5,10 +5,12 @@ source_file: veiledning.md
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
ingested_at: 2026-08-30T09:00:00Z ingested_at: 2026-08-30T09:00:00Z
generated: true generated: true
sources: [{ resource: veiledning.md, title: veiledning.md }]
adjudication: proposed adjudication: proposed
bundle_id: b-golden-segmented-okf-v0-2 bundle_id: b-golden-segmented-okf-v0-2
parent: s0 parent: s0
segment_id: s2 segment_id: s2
source_lines: [9, 11]
source_offset: [176, 253] source_offset: [176, 253]
--- ---

View file

@ -5,9 +5,11 @@ source_file: veiledning.md
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
ingested_at: 2026-08-30T09:00:00Z ingested_at: 2026-08-30T09:00:00Z
generated: true generated: true
sources: [{ resource: veiledning.md, title: veiledning.md }]
adjudication: proposed adjudication: proposed
bundle_id: b-golden-segmented-okf-v0-2 bundle_id: b-golden-segmented-okf-v0-2
segment_id: s0 segment_id: s0
source_lines: [1, 4]
source_offset: [0, 94] source_offset: [0, 94]
--- ---

View file

@ -2,7 +2,7 @@
"contract": "okf-consumption/1", "contract": "okf-consumption/1",
"bundle": { "bundle": {
"bundle_id": "b-golden-segmented-okf-v0-2", "bundle_id": "b-golden-segmented-okf-v0-2",
"ref": "sha256-tree:e272be79295bdeaa19127cd7d8ee76b8a02f05b7f6b2feeb1c48ffb55e87b230" "ref": "sha256-tree:f85646c1f77982a69b97afca08209f369b79cd39c217f6f2c074c2dc5bac23fe"
}, },
"budget": { "budget": {
"unit": "utf-8 bytes of emitted JSON", "unit": "utf-8 bytes of emitted JSON",
@ -27,7 +27,7 @@
{ {
"bundle_id": "b-golden-segmented-okf-v0-2", "bundle_id": "b-golden-segmented-okf-v0-2",
"concept_id": "krav/1-1/foerste-krav", "concept_id": "krav/1-1/foerste-krav",
"sha256": "29ac5dfe604b0d7bd50182a68b18f614f1388913c7487adcd95c4997f5ef4b35", "sha256": "84145512284a8bbc1a8905c73d0de3f0e0cb7081cf517f778fe88a610d2b4f62",
"adjudication": "proposed", "adjudication": "proposed",
"trust_tier": "unverified", "trust_tier": "unverified",
"bundle_id_inherited": false, "bundle_id_inherited": false,
@ -38,7 +38,7 @@
{ {
"bundle_id": "b-golden-segmented-okf-v0-2", "bundle_id": "b-golden-segmented-okf-v0-2",
"concept_id": "veiledning", "concept_id": "veiledning",
"sha256": "85d54e499a9d6499418994dbdbc472568cf5a14fea1ecc9179fd8983b61a31c1", "sha256": "8f1f4d940887b96b39496d0aceb7a4fa212553510165917427e4f7e9efb98b03",
"adjudication": "proposed", "adjudication": "proposed",
"trust_tier": "unverified", "trust_tier": "unverified",
"bundle_id_inherited": false, "bundle_id_inherited": false,
@ -49,7 +49,7 @@
{ {
"bundle_id": "b-golden-segmented-okf-v0-2", "bundle_id": "b-golden-segmented-okf-v0-2",
"concept_id": "krav/1-2/andre-krav", "concept_id": "krav/1-2/andre-krav",
"sha256": "c40b32d60f2488d22c73c06966174eb01a0971d4c7e0f8267b2d942e70f23038", "sha256": "4f8a869aa4e898ec1a30595951b3e198e09091d76c41078a52e821000eb9e22c",
"adjudication": "proposed", "adjudication": "proposed",
"trust_tier": "unverified", "trust_tier": "unverified",
"bundle_id_inherited": false, "bundle_id_inherited": false,

View file

@ -32,6 +32,7 @@ import warnings
import zipfile import zipfile
from xml.etree import ElementTree from xml.etree import ElementTree
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from dataclasses import dataclass
from html.parser import HTMLParser from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
@ -236,6 +237,45 @@ def _extra_missing(suffix: str) -> ExtractionError:
) )
# How `_extract_pdf` joins its pages, named because the locator below has to
# reproduce the exact same arithmetic to turn a character offset back into a
# page number. Two constants that must agree, written once.
_PDF_PAGE_SEPARATOR = "\n\n"
@functools.lru_cache(maxsize=1)
def _pdf_pages(data: bytes) -> tuple[tuple[int, str], ...]:
"""Every page that produced text, as `(page number, text)`, in page order.
The page NUMBER is 1-based and comes from the document, so a page that
yielded nothing removes itself from the sequence without renumbering the
ones after it -- which is the difference between "the third page that
produced text" and "page 3", and the whole reason a locator is worth
writing down.
Memoised on the bytes with room for exactly one document: extraction and
location are two calls about the same file, back to back, and parsing it
twice would double the PDF cost of every corpus run for nothing. Anything
larger would hold whole documents in memory for no gain, since the caller
never returns to an earlier file.
"""
try:
import pdfplumber
except ImportError as exc:
raise _extra_missing(".pdf") from exc
try:
with pdfplumber.open(io.BytesIO(data)) as pdf:
pages = [(page.extract_text() or "").rstrip() for page in pdf.pages]
except ExtractionError:
raise
except Exception as exc: # noqa: BLE001 - third-party parser, wrapped never leaked
raise ExtractionError(
f"the PDF parser failed on this file: {exc}", code="extractor_pdf_error"
) from exc
return tuple((number, page) for number, page in enumerate(pages, start=1) if page)
def _extract_pdf(data: bytes) -> str: def _extract_pdf(data: bytes) -> str:
"""`pdf`: page text via `pdfplumber`, in page order, pages separated by a """`pdf`: page text via `pdfplumber`, in page order, pages separated by a
blank line. blank line.
@ -252,22 +292,8 @@ def _extract_pdf(data: bytes) -> str:
and pymupdf each emit all labels then all values. Re-pairing those is and pymupdf each emit all labels then all values. Re-pairing those is
guesswork, and in a requirements document a wrong pairing looks right. guesswork, and in a requirements document a wrong pairing looks right.
""" """
try: pages = _pdf_pages(data)
import pdfplumber text = _PDF_PAGE_SEPARATOR.join(page for _, page in pages)
except ImportError as exc:
raise _extra_missing(".pdf") from exc
try:
with pdfplumber.open(io.BytesIO(data)) as pdf:
pages = [(page.extract_text() or "").rstrip() for page in pdf.pages]
except ExtractionError:
raise
except Exception as exc: # noqa: BLE001 - third-party parser, wrapped never leaked
raise ExtractionError(
f"the PDF parser failed on this file: {exc}", code="extractor_pdf_error"
) from exc
text = "\n\n".join(page for page in pages if page)
if not text: if not text:
raise ExtractionError( raise ExtractionError(
"the PDF yielded no text on any page; a scanned or image-only " "the PDF yielded no text on any page; a scanned or image-only "
@ -420,6 +446,195 @@ _OPTIONAL_EXTRACTORS: dict[str, Callable[[bytes], str]] = {
} }
# --- provenance: a character range of the extracted text -> a place in the
# original document ---------------------------------------------------------
#
# `source_offset` alone is a position in OUR extraction, so following it back
# needs the corpus directory, the extractor and its exact version -- none of
# which a bundle carries. A unit table is that mapping, saved AT EXTRACTION
# where the two are known to agree, rather than guessed afterwards from text
# whose page breaks are gone.
#
# THE UNIT IS PER FORMAT AND IS NAMED, never assumed:
#
# pages a PDF page number, from the document itself.
# rows a spreadsheet row, within the sheet named by `scope_of`.
# lines a line of the EXTRACTED text. For `md`/`txt` that text is the
# dropped file, so the number is the original's own line; for the
# converted formats it is not, and the key says `lines` rather than
# `paragraphs` for exactly that reason. Measured on the five K2
# `.docx` documents: `<w:p>` counts 108/27/65/176/57 against
# converted-markdown line counts 75/33/67/144/63 -- not one pair
# agrees, so a `paragraphs` key would name a number the original does
# not have.
#
# The heading a spreadsheet's sheet becomes, as the converter writes it:
# `## <sheet name> {#sheet-<n>}`. Anchored to the line start so a pipe cell
# containing a `#` cannot be read as a sheet.
_SHEET_HEADING = re.compile(r"^#{1,6} (?P<name>.*?) \{#sheet-\d+\}$")
# A line the converter wrote as part of a pipe table. Whether one of them is
# the table's SEPARATOR is decided by POSITION, never by content: an empty
# spreadsheet row renders as `| | |` and a separator as `|----|----|`, and
# every content rule that tells those apart also swallows a data row that
# happens to hold only dashes. Measured on the K2 price sheet: a content rule
# ate 8 empty rows and reported the sheet's last row as 92 against a workbook
# that says 100.
_TABLE_LINE = "|"
@dataclass(frozen=True)
class SourceUnits:
"""Where in the ORIGINAL each stretch of the extracted text came from.
`starts[i]` is the character offset in the extracted text at which unit
`numbers[i]` begins, and `scopes[i]` is the sheet that unit belongs to (or
`None` for a format that has no sheets). The three tuples are parallel and
`starts` ascends, which is what lets `covering` be a bisection rather than
a scan.
`numbers` is separate from the index on purpose. A PDF page that yielded no
text is not in this table, and a pipe table's separator line is a row of
nothing -- in both cases the position in the table and the number in the
original have already parted company, and an index standing in for a number
is the off-by-one this whole object exists to prevent.
"""
unit: str
starts: tuple[int, ...]
numbers: tuple[int, ...]
scopes: tuple[str | None, ...] = ()
def __post_init__(self) -> None:
if len(self.starts) != len(self.numbers):
raise ValueError("a unit table needs one number per start offset")
if self.scopes and len(self.scopes) != len(self.starts):
raise ValueError("a unit table needs one scope per start offset, or none at all")
def _index(self, offset: int) -> int:
"""The table row covering `offset`, clamped to the table's own ends."""
low, high = 0, len(self.starts) - 1
while low < high:
middle = (low + high + 1) // 2
if self.starts[middle] <= offset:
low = middle
else:
high = middle - 1
return low
def covering(self, start: int, end: int) -> tuple[int, int]:
"""The first and last original unit the half-open `[start, end)` touches.
`end` is exclusive, so a range ending exactly where the next unit
begins does not claim that unit -- a segment that stops at a page
boundary is on the page it was written on.
"""
if not self.starts:
raise ValueError("an empty unit table locates nothing")
first = self._index(start)
last = self._index(max(start, end - 1))
return self.numbers[first], self.numbers[last]
def scope_of(self, offset: int) -> str | None:
"""The sheet `offset` falls in, or `None` for a format without sheets."""
if not self.scopes:
return None
return self.scopes[self._index(offset)]
def scopes_covering(self, start: int, end: int) -> tuple[str | None, ...]:
"""Every distinct scope the range touches, in order, without repeats."""
if not self.scopes:
return ()
first = self._index(start)
last = self._index(max(start, end - 1))
seen: list[str | None] = []
for scope in self.scopes[first : last + 1]:
if not seen or seen[-1] != scope:
seen.append(scope)
return tuple(seen)
def _line_units(text: str) -> SourceUnits:
starts: list[int] = []
offset = 0
for line in text.split("\n"):
starts.append(offset)
offset += len(line) + 1
return SourceUnits("lines", tuple(starts), tuple(range(1, len(starts) + 1)))
def _pdf_units(data: bytes) -> SourceUnits:
starts: list[int] = []
numbers: list[int] = []
offset = 0
for number, page in _pdf_pages(data):
starts.append(offset)
numbers.append(number)
offset += len(page) + len(_PDF_PAGE_SEPARATOR)
return SourceUnits("pages", tuple(starts), tuple(numbers))
def _spreadsheet_units(text: str) -> SourceUnits | None:
"""Sheet and row for a converted spreadsheet, or `None` if it is not one.
The converter writes one heading per sheet and then one pipe-table line per
source row, with a separator line after the first. Row numbering therefore
restarts at every heading and skips that one line by POSITION.
The row number is the ORIGINAL sheet's, and that holds exactly as far as
one converted line per `<row>` element holds. Measured on the two K2
spreadsheets and both fixtures: 39 rows for 39, 100 for 100, 4 for 4, 6 for
6 and 3 for 3 -- every one contiguous from row 1. A sheet whose XML omits a
row entirely would number from the converted table instead, and nothing
here can see that.
"""
starts: list[int] = []
numbers: list[int] = []
scopes: list[str | None] = []
sheet: str | None = None
seen = 0
offset = 0
for line in text.split("\n"):
heading = _SHEET_HEADING.match(line)
if heading is not None:
sheet = heading.group("name")
seen = 0
elif sheet is not None and line.startswith(_TABLE_LINE):
seen += 1
# The SECOND table line of a sheet is the separator the converter
# writes under the header, and it is a row of no spreadsheet. Every
# line after it is one row further on than its position suggests.
if seen != 2:
starts.append(offset)
numbers.append(seen if seen == 1 else seen - 1)
scopes.append(sheet)
offset += len(line) + 1
if not starts:
return None
return SourceUnits("rows", tuple(starts), tuple(numbers), tuple(scopes))
def source_units(filename: str, data: bytes, text: str) -> SourceUnits | None:
"""The unit table for one dropped file, or `None` when it has none.
`text` must be what `extract_text` returned for these exact bytes: the
table indexes that string, and a table built against a different rendering
would point a consumer at the wrong place with full confidence.
`None` is a measurement, not a failure -- a spreadsheet the converter wrote
no table for has no rows to name, and the caller writes the address without
a locator rather than inventing one.
"""
suffix = Path(filename).suffix.lower()
if suffix == ".pdf":
return _pdf_units(data)
if suffix == ".xlsx":
return _spreadsheet_units(text)
if suffix in _CORE_EXTRACTORS or suffix in _PANDOC_FORMATS:
return _line_units(text)
return None
def extract_text( def extract_text(
filename: str, data: bytes, *, renderer: Callable[[str], str] | None = None filename: str, data: bytes, *, renderer: Callable[[str], str] | None = None
) -> str: ) -> str:

View file

@ -27,7 +27,7 @@ from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from .errors import IngestError, MaterializationError, SegmentationError, SourceError from .errors import IngestError, MaterializationError, SegmentationError, SourceError
from .extract import extract_text from .extract import SourceUnits, extract_text, source_units
from .materialize import ( from .materialize import (
_render_root_frontmatter, _render_root_frontmatter,
check_filename_length, check_filename_length,
@ -37,7 +37,7 @@ from .materialize import (
validate_ingested_at, validate_ingested_at,
write_bytes, write_bytes,
) )
from .profiles import DEFAULT, BundleProfile, IndexEntry from .profiles import DEFAULT, BundleProfile, IndexEntry, ProvenancePolicy
from .segmentation import ( from .segmentation import (
SegmentationPlan, SegmentationPlan,
SegmentEntry, SegmentEntry,
@ -111,6 +111,8 @@ def render_inbox_concept(
structure: DocumentStructure | None = None, structure: DocumentStructure | None = None,
segment: SegmentEntry | None = None, segment: SegmentEntry | None = None,
bundle_id: str | None = None, bundle_id: str | None = None,
units: SourceUnits | None = None,
span: tuple[int, int] | None = None,
) -> str: ) -> str:
"""Frame extracted text as an inbox concept file with its provenance layer. """Frame extracted text as an inbox concept file with its provenance layer.
@ -119,6 +121,12 @@ def render_inbox_concept(
`ingested_at`, on the reserved verdict layer, and on a title or `ingested_at`, on the reserved verdict layer, and on a title or
`source_file` that would break an index link or inject frontmatter lines. `source_file` that would break an index link or inject frontmatter lines.
`units` and `span` carry the provenance locator and are read ONLY when the
profile declares that capability. `span` defaults to the segment's own when
the concept is segmented; a whole-document concept must supply it, because
the text arriving here is the SANITIZED text and its length is not
necessarily the extracted text's.
`segment` and `bundle_id` carry the 1-to-N identity layer and are read ONLY `segment` and `bundle_id` carry the 1-to-N identity layer and are read ONLY
when the profile declares the segmentation capability. A concept the plan when the profile declares the segmentation capability. A concept the plan
does not cover keeps today's rule verbatim, and the four shipped profiles does not cover keeps today's rule verbatim, and the four shipped profiles
@ -208,9 +216,78 @@ def render_inbox_concept(
frontmatter["adjudicated_by"] = verdict.adjudicated_by frontmatter["adjudicated_by"] = verdict.adjudicated_by
frontmatter["adjudicated_at"] = verdict.adjudicated_at frontmatter["adjudicated_at"] = verdict.adjudicated_at
frontmatter["adjudication_dwell_s"] = str(verdict.adjudication_dwell_s) frontmatter["adjudication_dwell_s"] = str(verdict.adjudication_dwell_s)
if profile.provenance is not None:
# A segment's own span is the default, but only where the segment is
# being READ -- `segmented` is the same discriminator the identity
# layer above uses, so a profile with provenance and no segmentation
# cannot silently locate by a span it is ignoring everywhere else.
located = span
if located is None and segmented:
assert segment is not None
located = segment.span
frontmatter.update(
_provenance_frontmatter(
profile.provenance,
source_file=source_file,
units=units,
span=located,
)
)
return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}" return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
# The characters that would end a YAML flow mapping early, so a path carrying
# one would produce a `sources` list that parses as something other than what
# was written. The guard refuses a quoted scalar inside a flow mapping (1.3.0,
# measured), so escaping is not on the table -- validation is.
_FLOW_TERMINATORS = ",{}[]"
def _provenance_frontmatter(
policy: ProvenancePolicy,
*,
source_file: str,
units: SourceUnits | None,
span: tuple[int, int] | None,
) -> dict[str, str]:
"""The address, and the locator when one is available.
The address is written whether or not a locator is: `sources` answers
"which document", the locator answers "where in it", and a consumer is owed
the first even when the second cannot be computed.
"""
bad = [char for char in _FLOW_TERMINATORS if char in source_file]
if bad:
raise MaterializationError(
f"source_file {source_file!r} contains {bad[0]!r}, which would end the "
"`sources` flow mapping early; this profile writes an address a "
"consumer can follow, and a path it cannot express is refused rather "
"than mangled",
code="inbox_source_file_unaddressable",
)
values = {
policy.sources_key: (
f"[{{ resource: {source_file}, title: {PurePosixPath(source_file).name} }}]"
)
}
if units is None or span is None:
return values
first, last = units.covering(*span)
if units.unit == "pages":
values[policy.pages_key] = _render_flow_list([str(first), str(last)])
elif units.unit == "rows":
scopes = units.scopes_covering(*span)
# A row number means nothing until a sheet is named, so a range that
# crosses sheets gets neither key. An absence, never a first-sheet
# guess: a guess here reads exactly like a fact.
if len(scopes) == 1 and scopes[0] is not None:
values[policy.sheet_key] = scopes[0]
values[policy.rows_key] = _render_flow_list([str(first), str(last)])
else:
values[policy.lines_key] = _render_flow_list([str(first), str(last)])
return values
# --- the guard seam ------------------------------------------------------- # --- the guard seam -------------------------------------------------------
# The guard's non-blocking floor. `Disposition` is a `str, Enum` in # The guard's non-blocking floor. `Disposition` is a `str, Enum` in
@ -542,6 +619,7 @@ def _render_segments(
profile: BundleProfile, profile: BundleProfile,
bundle_id: str, bundle_id: str,
source_file: str, source_file: str,
units: SourceUnits | None,
) -> BlockedFile | None: ) -> BlockedFile | None:
"""Render every segment, or refuse the WHOLE document. """Render every segment, or refuse the WHOLE document.
@ -607,6 +685,7 @@ def _render_segments(
structure=structure, structure=structure,
segment=entry, segment=entry,
bundle_id=bundle_id, bundle_id=bundle_id,
units=units,
), ),
decision.reasons, decision.reasons,
) )
@ -879,6 +958,16 @@ def process_inbox(
source_bytes, source_bytes,
renderer=_resolve_renderer(profile, path.name), renderer=_resolve_renderer(profile, path.name),
) )
# Computed from the SAME text the plan's offsets index, so the
# locator and the offset can never disagree about which rendering
# they describe. `None` when the profile names no provenance:
# building a unit table nobody writes would re-parse every PDF for
# a key that is never emitted.
units = (
source_units(source_name(path), source_bytes, text)
if profile.provenance is not None
else None
)
covering = _plan_covering(plans, source_bytes) covering = _plan_covering(plans, source_bytes)
if covering is not None: if covering is not None:
blocked = _render_segments( blocked = _render_segments(
@ -891,6 +980,7 @@ def process_inbox(
profile=profile, profile=profile,
bundle_id=(root_frontmatter_values or {})[_bundle_id_key(profile)], bundle_id=(root_frontmatter_values or {})[_bundle_id_key(profile)],
source_file=source_name(path), source_file=source_name(path),
units=units,
) )
if blocked is not None: if blocked is not None:
if blocked.disposition == _DISPOSITION_QUARANTINE: if blocked.disposition == _DISPOSITION_QUARANTINE:
@ -937,6 +1027,12 @@ def process_inbox(
ingested_at=ingested_at, ingested_at=ingested_at,
profile=profile, profile=profile,
structure=structure, structure=structure,
units=units,
# The EXTRACTED text's span, never the sanitized
# text's: the unit table indexes the former, and a
# gate that removed a character would shift every
# unit boundary after it.
span=(0, len(text)),
), ),
decision.reasons, decision.reasons,
) )

View file

@ -905,6 +905,39 @@ class SegmentationPolicy:
adjudication_key: str | None = None adjudication_key: str | None = None
@dataclass(frozen=True)
class ProvenancePolicy:
"""Whether a concept carries an address back to the document it came from.
Two layers, and the split is load-bearing rather than tidy.
The ADDRESS is SPEC's. §5.1:303-306 makes `sources[].resource` REQUIRED
within an entry and lets it be "an absolute URL, a bundle-relative path, or
a path into a `references/` subdirectory (§6)" -- which is exactly what a
dropped file's inbox-relative path is. No new key is invented where the
spec already has one.
The LOCATOR is OURS, and it has to be. §5.1 has no field for a page, a
sheet row or a line, and the guard's frontmatter grammar (1.3.0, measured)
refuses every route to putting one inside a `sources` entry: a key outside
its `sources` allowlist is rejected by name, and a nested flow list is
rejected as "a flow mapping admits scalar leaves only". So a locator inside
the entry would be a bundle we emit and could never read back through Door
C. Top-level keys, in the shape `source_offset` already uses.
Every field NAMES a key and none supplies a value, like every other policy
here. The presence of this object IS the capability: a profile that names
no provenance writes none, which is what keeps the five shipped profiles
that do not name it byte-identical.
"""
sources_key: str = "sources"
pages_key: str = "source_pages"
sheet_key: str = "source_sheet"
rows_key: str = "source_rows"
lines_key: str = "source_lines"
@dataclass(frozen=True) @dataclass(frozen=True)
class BundleProfile: class BundleProfile:
"""One bundle contract: types, frontmatter, filenames, index.""" """One bundle contract: types, frontmatter, filenames, index."""
@ -926,6 +959,11 @@ class BundleProfile:
# package; writing one is a Non-Goal and is named here as unassigned so the # package; writing one is a Non-Goal and is named here as unassigned so the
# absence is deliberate rather than an oversight. # absence is deliberate rather than an oversight.
renderers: Mapping[str, str] | None = None renderers: Mapping[str, str] | None = None
# Defaulted to `None` for the same reason `segmentation` is: `None` is not
# "provenance off", it is the profile not having the capability, which is
# what the door's `is not None` check reads. Five of the six shipped
# profiles leave it unset and keep their bytes.
provenance: ProvenancePolicy | None = None
# The ingest-spec + Phase 2 contract. Every value here was a constant in # The ingest-spec + Phase 2 contract. Every value here was a constant in
@ -1281,6 +1319,15 @@ SEGMENTED_OKF_V0_2 = BundleProfile(
# attribute is typed `| None`, and the equality is asserted in the suite so # attribute is typed `| None`, and the equality is asserted in the suite so
# this stays a fresh copy of the same policy plus the discriminator. # this stays a fresh copy of the same policy plus the discriminator.
segmentation=SegmentationPolicy(adjudication_key="adjudication"), segmentation=SegmentationPolicy(adjudication_key="adjudication"),
# O3, and set on THIS profile alone. `sources` is a v0.2 key, so a profile
# stating v0.1 must not name it; `DEFAULT` and `STRICT_V1` state contracts
# owned in other repositories, so adding a key to either from here would be
# this repository editing someone else's contract (O2); and `OKF_V0_2` is
# Door A's, where `sources` is already written from the manifest. What is
# left is the segmented v0.2 profile -- the one whose concepts come from a
# dropped binary document and therefore the only one with an original to
# point at.
provenance=ProvenancePolicy(),
) )

View file

@ -17,6 +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. | | `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. | | `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. |
| `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 office fixtures ## The office fixtures
@ -35,6 +36,7 @@ symmetric — which is most of them.
| `two-line-krav.docx` | A heading plus one requirement row with label and value on the **same line** — the docx mirror of `two-line-krav.pdf`. | | `two-line-krav.docx` | A heading plus one requirement row with label and value on the **same line** — the docx mirror of `two-line-krav.pdf`. |
| `no-styles-krav.docx` | The **same document without `word/styles.xml`**. A negative control: the body survives and the heading marker does not, which is what proves the styles part is load-bearing rather than decoration. | | `no-styles-krav.docx` | The **same document without `word/styles.xml`**. A negative control: the body survives and the heading marker does not, which is what proves the styles part is load-bearing rather than decoration. |
| `two-line-krav.xlsx` | A sheet name that becomes a heading, plus a label/value pair on one row. | | `two-line-krav.xlsx` | A sheet name that becomes a heading, plus a label/value pair on one row. |
| `tomrad.xlsx` | Four rows with the **third one empty**. The converter renders an empty row as a pipe line of nothing but spaces, which is what a pipe table's own separator line also looks like — so a rule that reads the line rather than its position swallows the row and renumbers every row after it. Found on the K2 price sheet (8 empty rows, last row reported as 92 against a workbook that says 100); this fixture is what keeps it red. |
Two things were measured while building these, and both are the same shape — Two things were measured while building these, and both are the same shape —
structurally valid input, silently reduced output, exit code 0 and no warning: structurally valid input, silently reduced output, exit code 0 and no warning:

View file

@ -37,16 +37,57 @@ KRAV_CONTENT = (
NO_TEXT_CONTENT = b"20 20 160 160 re S\n" NO_TEXT_CONTENT = b"20 20 160 160 re S\n"
# One line of text per page, so the page a stretch of extracted text came from
# is decidable by reading the text alone. THREE pages rather than two, and the
# MIDDLE one carries no text operators: a two-page fixture cannot tell a page
# INDEX from a page NUMBER, and without a blank page in the middle it cannot
# tell either of them from a count of the pages that produced text. The
# 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"20 20 160 160 re S\n",
b"BT /F1 12 Tf 20 160 Td (Side tre om utkiling) Tj ET\n",
)
def build_pdf(content: bytes) -> bytes: def build_pdf(content: bytes) -> bytes:
"""Assemble a one-page PDF around `content` as the page content stream.""" """Assemble a one-page PDF around `content` as the page content stream."""
return build_paged_pdf((content,))
def build_paged_pdf(contents: tuple[bytes, ...]) -> bytes:
"""Assemble a PDF with one page per entry of `contents`.
The object numbering is laid out first and the xref offsets computed from
the emitted bytes, exactly as the single-page form did -- the fixture stays
a hand-written statement about the format rather than a library's output.
"""
count = len(contents)
# 1 catalog, 2 pages, then one page object and one content stream per page,
# and the shared font last.
page_numbers = [3 + 2 * index for index in range(count)]
font_number = 3 + 2 * count
kids = b" ".join(str(number).encode() + b" 0 R" for number in page_numbers)
objects = [ objects = [
b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /Type /Pages /Kids [" + kids + b"] /Count " + str(count).encode() + b" >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] "
b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"endstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
] ]
for index, content in enumerate(contents):
stream_number = page_numbers[index] + 1
objects.append(
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents "
+ str(stream_number).encode()
+ b" 0 R /Resources << /Font << /F1 "
+ str(font_number).encode()
+ b" 0 R >> >> >>"
)
objects.append(
b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"endstream"
)
objects.append(
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
)
out = bytearray(b"%PDF-1.4\n") out = bytearray(b"%PDF-1.4\n")
offsets = [] offsets = []
@ -264,6 +305,38 @@ _PRISARK_PARTS = {
} }
# A sheet with an EMPTY ROW IN THE MIDDLE, which the converter renders as a
# pipe row of nothing but spaces. That row looks exactly like a table's own
# separator line to any rule that reads the line rather than its position --
# and swallowing it renumbers every row after it, silently, for the whole
# sheet. Measured on the K2 price sheet before this fixture existed: 8 empty
# rows, and the last row reported as 92 when the workbook says 100.
_TOMRAD_STRINGS = ("Rad en", "Rad to", "Rad fire")
_TOMRAD_SHEET = (
'<row r="1"><c r="A1" t="s"><v>0</v></c></row>'
'<row r="2"><c r="A2" t="s"><v>1</v></c></row>'
'<row r="3"><c r="A3"/></row>'
'<row r="4"><c r="A4" t="s"><v>2</v></c></row>'
)
_TOMRAD_PARTS = {
"[Content_Types].xml": _XLSX_PARTS["[Content_Types].xml"],
"_rels/.rels": _XLSX_PARTS["_rels/.rels"],
"xl/_rels/workbook.xml.rels": _XLSX_PARTS["xl/_rels/workbook.xml.rels"],
"xl/workbook.xml": _XML
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
+ ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
+ '<sheets><sheet name="Tomrad" sheetId="1" r:id="rId1"/></sheets></workbook>',
"xl/sharedStrings.xml": _XML
+ '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
+ f' count="{len(_TOMRAD_STRINGS)}" uniqueCount="{len(_TOMRAD_STRINGS)}">'
+ "".join(f"<si><t>{value}</t></si>" for value in _TOMRAD_STRINGS)
+ "</sst>",
"xl/worksheets/sheet1.xml": _sheet("A1:A4", _TOMRAD_SHEET),
}
def build_ooxml(parts: dict[str, str]) -> bytes: def build_ooxml(parts: dict[str, str]) -> bytes:
"""Zip the parts with a fixed timestamp and no compression variance. """Zip the parts with a fixed timestamp and no compression variance.
@ -289,11 +362,15 @@ if __name__ == "__main__":
(HERE / name).write_bytes(build_pdf(content)) (HERE / name).write_bytes(build_pdf(content))
print(f"wrote {name}") print(f"wrote {name}")
(HERE / "three-page-krav.pdf").write_bytes(build_paged_pdf(PAGED_CONTENTS))
print("wrote three-page-krav.pdf")
for name, parts in ( for name, parts in (
("two-line-krav.docx", _DOCX_PARTS), ("two-line-krav.docx", _DOCX_PARTS),
("no-styles-krav.docx", _DOCX_NO_STYLES_PARTS), ("no-styles-krav.docx", _DOCX_NO_STYLES_PARTS),
("two-line-krav.xlsx", _XLSX_PARTS), ("two-line-krav.xlsx", _XLSX_PARTS),
("prisark.xlsx", _PRISARK_PARTS), ("prisark.xlsx", _PRISARK_PARTS),
("tomrad.xlsx", _TOMRAD_PARTS),
): ):
(HERE / name).write_bytes(build_ooxml(parts)) (HERE / name).write_bytes(build_ooxml(parts))
print(f"wrote {name}") print(f"wrote {name}")

54
tests/fixtures/three-page-krav.pdf vendored Normal file
View file

@ -0,0 +1,54 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 9 0 R >> >> >>
endobj
4 0 obj
<< /Length 50 >>
stream
BT /F1 12 Tf 20 160 Td (Side en om helning) Tj ET
endstream
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>
endobj
6 0 obj
<< /Length 19 >>
stream
20 20 160 160 re S
endstream
endobj
7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 8 0 R /Resources << /Font << /F1 9 0 R >> >> >>
endobj
8 0 obj
<< /Length 52 >>
stream
BT /F1 12 Tf 20 160 Td (Side tre om utkiling) Tj ET
endstream
endobj
9 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
xref
0 10
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000127 00000 n
0000000253 00000 n
0000000352 00000 n
0000000478 00000 n
0000000546 00000 n
0000000672 00000 n
0000000773 00000 n
trailer
<< /Size 10 /Root 1 0 R >>
startxref
870
%%EOF

BIN
tests/fixtures/tomrad.xlsx vendored Normal file

Binary file not shown.

View file

@ -234,4 +234,10 @@ def test_a_profile_is_assembled_from_its_policies() -> None:
# profile does not have the capability; every shipped profile still # profile does not have the capability; every shipped profile still
# constructs unchanged and no golden moved. # constructs unchanged and no golden moved.
"renderers", "renderers",
# O3's capability, and the same deliberate update again: a concept that
# points back at the document it came from is a contract question, so
# the alternative to a policy on the object is a door branching on which
# profile it was handed. Set on `SEGMENTED_OKF_V0_2` alone; `None`
# everywhere else, so five shipped profiles construct unchanged.
"provenance",
} }

329
tests/test_provenance.py Normal file
View file

@ -0,0 +1,329 @@
"""Provenance to the original: `sources` plus a per-format locator (O3).
A concept says what it was extracted FROM. Before this step it said so with a
`source_file` basename, a `source_sha256`, and when segmented a
`source_offset` that indexes the EXTRACTED text rather than the original, so a
consumer could not open the original at the right place without knowing the
corpus directory and re-running the extractor.
Two layers, and the split is the whole design:
- the ADDRESS is spec's, `sources[].resource` (SPEC v0.2 §5.1:303-306: "an
absolute URL, a bundle-relative path, or a path into a `references/`
subdirectory"), emitted in flow form because this library's parser cannot
read a block one back;
- the LOCATOR is ours, because §5.1 has no field for a page, a sheet row or a
line, and the guard's frontmatter grammar refuses both a non-allowlisted key
inside a `sources` entry and a nested flow list measured here, so the
choice is a recorded constraint rather than a preference.
"""
from __future__ import annotations
import importlib.util
import warnings
from pathlib import Path
import pytest
from llm_ingestion_okf.extract import extract_text, source_units
from llm_ingestion_okf.inbox import render_inbox_concept
from llm_ingestion_okf.profiles import (
DEFAULT,
SEGMENTED_OKF_V0_2,
SEGMENTED_V1,
STRICT_V1,
STRUCTURED_V1,
)
from llm_ingestion_okf.segmentation import SegmentEntry
FIXTURES = Path(__file__).parent / "fixtures"
requires_extract = pytest.mark.skipif(
importlib.util.find_spec("pdfplumber") is None,
reason="the optional [extract] extra is not installed",
)
def _extract(name: str) -> tuple[bytes, str]:
data = (FIXTURES / name).read_bytes()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return data, extract_text(name, data)
def _segment(span: tuple[int, int]) -> SegmentEntry:
return SegmentEntry(
segment_id="p1",
path="a/b.md",
title="A segment",
okf_type="reference",
span=span,
ingested_at="2026-09-08T00:00:00Z",
)
# --- the unit table: a character range -> a place in the ORIGINAL ---------
@requires_extract
def test_a_pdf_reports_the_page_a_character_range_came_from() -> None:
# The middle page carries no text and the extractor drops it, so the third
# page's text is page 3 and not page 2. A fixture without that gap could
# not tell a page NUMBER from a count of the pages that produced text.
data, text = _extract("three-page-krav.pdf")
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(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
# the extractor joins pages with belongs to neither page's text, so an
# offset inside it is still the page before. A table built without the
# separator's own length passes every assertion above and fails this one,
# because the drift is two characters per page and only shows up where a
# page begins.
last_page_begins = text.index("Side tre")
assert units.covering(last_page_begins - 1, last_page_begins) == (1, 1)
@requires_extract
def test_a_spreadsheet_reports_the_sheet_and_the_rows_of_the_original() -> None:
# `prisark.xlsx` is hand-laid: sheet 1 spans A1:C6, sheet 2 spans A1:A3.
# The converter renders each sheet as a heading plus a pipe table, and
# numbering restarts per sheet — so the last row of the file is row 3 of
# sheet 2, not row 9 of the document.
data, text = _extract("prisark.xlsx")
units = source_units("prisark.xlsx", data, text)
assert units is not None
assert units.unit == "rows"
first_row = text.index("| Prisskjema")
assert units.covering(first_row, first_row + 1) == (1, 1)
assert units.scope_of(first_row) == "Prisark"
assert units.covering(0, len(text)) == (1, 3)
assert units.scopes_covering(0, len(text)) == ("Prisark", "Enkeltkolonne")
@requires_extract
def test_a_row_locator_counts_the_separator_line_as_no_row() -> None:
# The pipe table's `|----|` line is a row of no spreadsheet. Counting it
# would push every row after it up by one, silently, for the whole sheet.
data, text = _extract("prisark.xlsx")
units = source_units("prisark.xlsx", data, text)
assert units is not None
header = text.index("| Prisskjema")
second = text.index("| Post ")
assert units.covering(header, second + 1) == (1, 2)
def test_an_empty_row_still_counts_as_a_row_of_the_sheet() -> None:
# `tomrad.xlsx` is four rows with the THIRD empty. The converter renders an
# empty row as a pipe line of nothing but spaces, which is what a table's
# own separator line also looks like — so a rule that reads the LINE rather
# than its POSITION swallows the empty row and renumbers every row after
# it, for the whole sheet, silently.
#
# Found on the K2 price sheet, not here: 8 empty rows, and the last row
# reported as 92 against a workbook that says 100.
data, text = _extract("tomrad.xlsx")
units = source_units("tomrad.xlsx", data, text)
assert units is not None
assert units.numbers == (1, 2, 3, 4)
assert units.covering(text.index("Rad fire"), len(text)) == (4, 4)
def test_a_text_file_reports_line_numbers_of_the_original() -> None:
data = b"first\nsecond\nthird\n"
text = extract_text("note.md", data)
units = source_units("note.md", data, text)
assert units is not None
assert units.unit == "lines"
assert units.covering(0, 5) == (1, 1)
assert units.covering(text.index("third"), len(text)) == (3, 3)
assert units.covering(0, len(text)) == (1, 3)
def test_a_docx_locator_is_lines_because_paragraphs_do_not_survive() -> None:
# Measured on the five K2 `.docx` documents: `<w:p>` counts of 108, 27, 65,
# 176 and 57 against converted-markdown line counts of 75, 33, 67, 144 and
# 63. Not one pair agrees, so a `paragraphs` key would name a number the
# original does not have. `lines` says what it indexes.
data, text = _extract("two-line-krav.docx") if _has_converter() else (b"", "")
if not text:
pytest.skip("the optional [extract] extra is not installed")
units = source_units("two-line-krav.docx", data, text)
assert units is not None
assert units.unit == "lines"
def _has_converter() -> bool:
return importlib.util.find_spec("pypandoc") is not None
# --- the frontmatter a concept carries ------------------------------------
@requires_extract
def test_a_segmented_concept_points_at_the_original_and_its_pages() -> None:
data, text = _extract("three-page-krav.pdf")
units = source_units("three-page-krav.pdf", data, text)
document = render_inbox_concept(
text[20:],
okf_type="reference",
title="Side tre",
source_file="mappe/three-page-krav.pdf",
source_bytes=data,
ingested_at="2026-09-08T00:00:00Z",
profile=SEGMENTED_OKF_V0_2,
segment=_segment((20, len(text))),
bundle_id="b1",
units=units,
)
assert "sources: [{ resource: mappe/three-page-krav.pdf, title: three-page-krav.pdf }]\n" in (
document
)
assert "source_pages: [3, 3]\n" in document
# The offset stays: it is what an existing consumer joins on, and a
# locator that replaced it would break them to fix them.
assert "source_offset: [20, 40]\n" in document
@requires_extract
def test_a_whole_document_concept_gets_the_pages_it_spans() -> None:
# The order's known-negative: a concept the plan does not cover is the
# WHOLE document, so its locator is every page that produced text.
data, text = _extract("three-page-krav.pdf")
units = source_units("three-page-krav.pdf", data, text)
document = render_inbox_concept(
text,
okf_type="reference",
title="Hele",
source_file="three-page-krav.pdf",
source_bytes=data,
ingested_at="2026-09-08T00:00:00Z",
profile=SEGMENTED_OKF_V0_2,
units=units,
span=(0, len(text)),
)
assert "source_pages: [1, 3]\n" in document
assert "source_offset" not in document
@requires_extract
def test_a_spreadsheet_concept_names_the_sheet_and_its_rows() -> None:
data, text = _extract("prisark.xlsx")
units = source_units("prisark.xlsx", data, text)
document = render_inbox_concept(
text,
okf_type="reference",
title="Hele arket",
source_file="prisark.xlsx",
source_bytes=data,
ingested_at="2026-09-08T00:00:00Z",
profile=SEGMENTED_OKF_V0_2,
units=units,
span=(0, text.index("| Post ")),
)
assert "source_sheet: Prisark\n" in document
assert "source_rows: [1, 1]\n" in document
@requires_extract
def test_a_range_spanning_two_sheets_names_no_sheet_and_no_rows() -> None:
# A row number is only a place in the original once a sheet is named. A
# range covering two sheets has no single sheet, so it gets no row
# locator either — an absence, never a first-sheet guess.
data, text = _extract("prisark.xlsx")
units = source_units("prisark.xlsx", data, text)
document = render_inbox_concept(
text,
okf_type="reference",
title="Begge ark",
source_file="prisark.xlsx",
source_bytes=data,
ingested_at="2026-09-08T00:00:00Z",
profile=SEGMENTED_OKF_V0_2,
units=units,
span=(0, len(text)),
)
assert "source_sheet" not in document
assert "source_rows" not in document
assert "sources: [{ resource: prisark.xlsx, title: prisark.xlsx }]\n" in document
def test_the_address_is_written_even_when_no_locator_is_available() -> None:
# `sources` answers "which file", the locator answers "where in it". The
# first must not depend on the second: a caller with no unit table still
# owes a consumer the address.
document = render_inbox_concept(
"body\n",
okf_type="reference",
title="T",
source_file="sub/dir/note.md",
source_bytes=b"body\n",
ingested_at="2026-09-08T00:00:00Z",
profile=SEGMENTED_OKF_V0_2,
)
assert "sources: [{ resource: sub/dir/note.md, title: note.md }]\n" in document
assert "source_pages" not in document
assert "source_lines" not in document
# --- what must not move ----------------------------------------------------
@pytest.mark.parametrize("profile", [DEFAULT, STRUCTURED_V1, STRICT_V1, SEGMENTED_V1])
def test_a_profile_that_names_no_provenance_writes_none(profile: object) -> None:
# Support is additive: five shipped profiles keep their bytes, and only the
# profile the order targets moves. A key written unconditionally here would
# churn every golden in the suite.
assert getattr(profile, "provenance") is None
def test_the_shipped_profiles_that_move_are_exactly_one() -> None:
assert SEGMENTED_OKF_V0_2.provenance is not None
for profile in (DEFAULT, STRUCTURED_V1, STRICT_V1, SEGMENTED_V1):
assert profile.provenance is None
def test_a_source_file_that_would_break_the_flow_mapping_is_refused() -> None:
# Validation, not repair, and not silence: the emitted value is a YAML flow
# mapping, so a comma or a brace in the path would terminate the entry
# early and produce a `sources` list that parses as something else.
from llm_ingestion_okf.errors import MaterializationError
with pytest.raises(MaterializationError) as excinfo:
render_inbox_concept(
"body\n",
okf_type="reference",
title="T",
source_file="Del II, Bilag.pdf",
source_bytes=b"body\n",
ingested_at="2026-09-08T00:00:00Z",
profile=SEGMENTED_OKF_V0_2,
)
assert excinfo.value.code == "inbox_source_file_unaddressable"
def test_the_guard_parses_the_sources_form_this_door_emits() -> None:
# The published promise this test exists to keep red-able: what Door B
# writes must survive the guard's own frontmatter grammar, or a bundle we
# emit could never be read back through Door C.
okf = pytest.importorskip("llm_ingestion_guard.okf")
document = render_inbox_concept(
"body\n",
okf_type="reference",
title="T",
source_file="Del II Bilag 3.3.1 - Brannkonsept.pdf",
source_bytes=b"body\n",
ingested_at="2026-09-08T00:00:00Z",
profile=SEGMENTED_OKF_V0_2,
)
frontmatter, _ = okf.parse_frontmatter(document)
assert frontmatter["sources"] == [
{
"resource": "Del II Bilag 3.3.1 - Brannkonsept.pdf",
"title": "Del II Bilag 3.3.1 - Brannkonsept.pdf",
}
]