feat(consume): parent reaches the reader -- excerpt field, body link, checker rule

K3-21 A. `okf consume` resolves a concept's `parent:` pointer -- a
`segment_id`, unique only inside one document's plan -- among the concepts
sharing its `source_file` (`consume.link_parents`, one pass, no file opened
again) and an excerpt carries `parent: { concept_id, title }`. Conditional
like `req_number`: a concept with no `parent` key moves no byte. A pointer
that lands nowhere is named `parent_unresolved: true`, never dropped.

The door writes ONE line into a heading-only body whose entry has a parent:
`Enclosing section: [<title>](/<bundle-relative path>)` (SPEC SS 5.1 lineage
through links, SS 6.1 the recommended absolute form and the kind in the
prose). Only such a body, so the segmented goldens' declared parents -- bodies
holding text -- are untouched. Appended AFTER structure derivation and
screened on its own (`_screened`, the `description` rule): read as body text
the link was derived into a second, unresolved `references` edge, measured on
the fixture. `segmentation.heading_only` is the one predicate the proposer and
the door share.

`okf check` gains its seventeenth rule, `parent_unfollowable`: a `parent`
that is not a concept_id and title, names its own excerpt, or names a concept
in neither `excerpts` nor `withheld` (together every considered concept).
Contract SS 8 point 6 added, the figure carries `parent`, and "additional
members are not read by the checker" now says the checker reads only the
members SS 8 names. The template tells the reader what `parent` is and that
SS 2.2 lets it read that one concept; `skill.CONDITIONAL_FIELDS` gains
`parent`. README and CLAUDE.md say what consume now reads.

Moved on purpose, each named: the SS 7.4 known-positive IS the contract
document, so `budget.known_positive` moves in every payload (13 238 / 12 893
/ 345 -> 14 455 / 14 083 / 372); `skills/okf-consume/` regenerated from the
segmented golden, whose plan declares s1 and s2 under s0 -- its example
payload now carries both parents; `test_bundle_identity` 16 -> 17 rules;
`test_shell_parent`'s byte test also accounts for the link line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-11 12:36:23 +02:00
commit 4f7bd61500
16 changed files with 319 additions and 58 deletions

View file

@ -625,8 +625,18 @@ and fixtures, never code.
The route reads the PLAN, never the row: on R761 it names the ancestor the The route reads the PLAN, never the row: on R761 it names the ancestor the
`<sec>` nesting names on **708 of 710** shells (the 2 misses sit at depth 7, `<sec>` nesting names on **708 of 710** shells (the 2 misses sit at depth 7,
clipped to 6), where reading section numbers gets 686 (`12` begins with clipped to 6), where reading section numbers gets 686 (`12` begins with
`1`). 35 of 710 have no ancestor holding text and get none. Off because `1`). 35 of 710 have no ancestor holding text and get none. **Since K3-21
`okf consume` reads no `parent` key. **The index renders every such parent `okf consume` reads the key** (`consume.link_parents`, resolved among the
concepts of the concept's OWN `source_file`, because `p1` exists in every
document): an excerpt carries `parent: { concept_id, title }`, conditional
like `req_number`, or `parent_unresolved: true` where the pointer lands
nowhere, and a heading-only body gains ONE line `Enclosing section:
[title](/path)` (SPEC SS 5.1 lineage through links, SS 6.1 the recommended
absolute form), appended AFTER structure derivation -- read as body text it
became a second, unresolved `references` edge -- and screened on its own.
`okf check`'s seventeenth rule, `parent_unfollowable`, holds the form.
Whether the build default moves is K3-21 B's measurement, not this line's.
**The index renders every such parent
UNRESOLVED** (`parent: p1?`): `structure` reads `parent` as a document UNRESOLVED** (`parent: p1?`): `structure` reads `parent` as a document
NUMBER, and a segment id answers to none -- the one key with two meanings NUMBER, and a segment id answers to none -- the one key with two meanings
`inbox.py` already names for a declared parent. Report: `inbox.py` already names for a declared parent. Report:
@ -745,7 +755,10 @@ and fixtures, never code.
allowlist, because a list names the producers its author thought of and one allowlist, because a list names the producers its author thought of and one
bundle locates by `source_element_id` on 269 of 274 concepts. A prefix, never bundle locates by `source_element_id` on 269 of 274 concepts. A prefix, never
a substring (`resource_owner` is not a locator). An absent key stays absent a substring (`resource_owner` is not a locator). An absent key stays absent
and an undecodable address is named (`sources_unreadable`). `sources` is READ and an undecodable address is named (`sources_unreadable`). Since K3-21 an
excerpt also carries `parent`, the enclosing concept's `concept_id` and
`title` -- never the raw `segment_id` -- or `parent_unresolved`, and the
contract's SS 8 point 6 says the checker reads it. `sources` is READ
in both YAML forms because the two real bundles disagree (flow 629/629 on one, in both YAML forms because the two real bundles disagree (flow 629/629 on one,
block 270/270 on the other) — reading block is not a licence to write it, the block 270/270 on the other) — reading block is not a licence to write it, the
emission rule is unchanged. Contract § 8 makes `title` a MUST (checker code emission rule is unchanged. Contract § 8 makes `title` a MUST (checker code
@ -769,7 +782,8 @@ and fixtures, never code.
numbers a generic skill can only leave as holes or state falsely, and that numbers a generic skill can only leave as holes or state falsely, and that
argument NEVER rested on conformance, so closing the measurement leaves it argument NEVER rested on conformance, so closing the measurement leaves it
standing. **That half is CLOSED 2026-09-10 by `bundle_mismatch`, the checker's standing. **That half is CLOSED 2026-09-10 by `bundle_mismatch`, the checker's
SIXTEENTH rule** (`RULES` is a tuple and `Report.rules_evaluated` is SIXTEENTH rule** (seventeen since K3-21 added `parent_unfollowable`;
`RULES` is a tuple and `Report.rules_evaluated` is
`len(RULES)`, so every published «15 rules» line is now «16 rules» — a `len(RULES)`, so every published «15 rules» line is now «16 rules» — a
contract change for anyone quoting it). It compares the identity a skill contract change for anyone quoting it). It compares the identity a skill
DECLARES against the identity its payload declares, **both `bundle_id` AND DECLARES against the identity its payload declares, **both `bundle_id` AND
@ -809,7 +823,8 @@ and fixtures, never code.
veiledningen om krav?"` are both required (the payload test asserts bytes for veiledningen om krav?"` are both required (the payload test asserts bytes for
that question), and the checkout prefix is then stripped, because `okf skill` that question), and the checkout prefix is then stripped, because `okf skill`
writes the bundle root and the skill path ABSOLUTE when `--out` is not under writes the bundle root and the skill path ABSOLUTE when `--out` is not under
`.claude/skills/`. The pair is rc 0, 16 rules, 0 findings, and a test holds `.claude/skills/`. The pair is rc 0, 17 rules (16 before K3-21), 0 findings,
and a test holds
the shipped bytes to the generator's. Its frontmatter `name` is now the shipped bytes to the generator's. Its frontmatter `name` is now
`b-golden-segmented-okf-v0-2-consume`: Claude Code takes a project skill's `b-golden-segmented-okf-v0-2-consume`: Claude Code takes a project skill's
COMMAND from the directory name and uses `name` only as a display label, and COMMAND from the directory name and uses `name` only as a display label, and

View file

@ -170,9 +170,12 @@ lets every nested section inherit them: measured on one process code, **710 of
2 761** concepts are heading-only, and the plan's level and order name the same 2 761** concepts are heading-only, and the plan's level and order name the same
ancestor as the document's own nesting on **708 of 710** (the two others sit at ancestor as the document's own nesting on **708 of 710** (the two others sit at
depth 7, which a markdown heading clips to 6, and point one level too high). It depth 7, which a markdown heading clips to 6, and point one level too high). It
is off because `okf consume` does not read `parent`, so no payload ranks was off because `okf consume` did not read `parent`. Since K3-21 it does: an
differently with it, while it moves the bytes of every bundle holding a excerpt carries `parent` as the enclosing concept's `concept_id` and `title`
heading-only section. One cost is known and not repaired: the index projects (resolved inside the concept's own document, never the raw segment id), and a
heading-only body gains one line, `Enclosing section: [title](/path)`, in the
bundle-relative form SPEC § 6.1 recommends. Whether that moves the default is
measured separately. One cost is known and not repaired: the index projects
`parent` as a document number, so a segment id always renders there as `parent` as a document number, so a segment id always renders there as
unresolved (`parent: p1?`), even though the concept it names is in the bundle. unresolved (`parent: p1?`), even though the concept it names is in the bundle.
@ -518,7 +521,10 @@ locators its author thought of, and one real bundle locates by
`source_element_id` on 269 of its 274 concepts. A key the producer did not write `source_element_id` on 269 of its 274 concepts. A key the producer did not write
stays absent rather than arriving empty, and an address this reader cannot stays absent rather than arriving empty, and an address this reader cannot
decode is named (`sources_unreadable`) rather than dropped into the same decode is named (`sources_unreadable`) rather than dropped into the same
silence. The reason is a measurement: with `concept_id` and body text alone, a silence. A concept naming the section that encloses it delivers `parent` — that
concept's `concept_id` and `title` — or `parent_unresolved` when the pointer
lands nowhere; `okf check` (seventeen rules) refuses a `parent` a reader could
not follow. The reason is a measurement: with `concept_id` and body text alone, a
delivered gold concept at rank 1 still left the answer unable to name the delivered gold concept at rank 1 still left the answer unable to name the
document it was quoting. document it was quoting.
`considered == withheld + delivered` closes by construction, and the payload is `considered == withheld + delivered` closes by construction, and the payload is

View file

@ -146,7 +146,7 @@ carries them.
## 8. The payload shape ## 8. The payload shape
The pre-pass MUST emit one JSON object with these members. Additional members The pre-pass MUST emit one JSON object with these members. Additional members
are permitted and are not read by the checker. are permitted; the checker reads only the members this section names.
```json ```json
{ {
@ -170,7 +170,8 @@ are permitted and are not read by the checker.
"title": "<the concept's own title>", "title": "<the concept's own title>",
"req_number": "<the identifier the producer wrote, when there is one>", "req_number": "<the identifier the producer wrote, when there is one>",
"sources": [ { "resource": "<what the concept came from>" } ], "sources": [ { "resource": "<what the concept came from>" } ],
"source_pages": "<a locator into that resource, when the producer wrote one>" "source_pages": "<a locator into that resource, when the producer wrote one>",
"parent": { "concept_id": "<the concept enclosing this one>", "title": "<its title>" }
} }
], ],
"withheld": [ { "concept_id": "<bundle-local id>", "rule": "<why it was cut>" } ] "withheld": [ { "concept_id": "<bundle-local id>", "rule": "<why it was cut>" } ]
@ -206,6 +207,20 @@ are permitted and are not read by the checker.
by that producer under the same rule — and any allowlist drops it, leaving an by that producer under the same rule — and any allowlist drops it, leaving an
excerpt that names a document without naming the place in it. The prefix is a excerpt that names a document without naming the place in it. The prefix is a
prefix and never a substring: `resource_owner` is not a locator. prefix and never a substring: `resource_owner` is not a locator.
6. An excerpt SHOULD carry `parent` when its concept names the section that
encloses it, as the `concept_id` and `title` of the concept that pointer
resolves to in the same bundle — never the producer's own pointer, which a
reader cannot open without enumerating the bundle (§ 9.2). It is conditional
on the producer exactly as point 4's fields are, and § 6.4 forbids reading
its absence as a claim that the source has no enclosing section. A pointer
the pre-pass cannot resolve is named rather than dropped, as
`parent_unresolved` set to `true`, for point 4's reason, and is not a defect:
SPEC § 6.1, "Consumers MUST tolerate broken links". A `parent` names that one concept as reachable
in § 2.2's sense and names nothing else. The checker refuses
(`parent_unfollowable`) a `parent` that is not both members, that names its
own excerpt, or whose `concept_id` is in neither `excerpts` nor `withheld`
together those name every concept the pre-pass considered (§ 5.2), so a
pointer outside them names nothing in the bundle.
## 9. Prohibitions ## 9. Prohibitions

View file

@ -121,6 +121,18 @@ because the producer wrote none — never because the source has none, and never
something to fill in. An excerpt carrying `sources_unreadable` has an address something to fill in. An excerpt carrying `sources_unreadable` has an address
this reader could not decode: say so rather than reporting no address. this reader could not decode: say so rather than reporting no address.
**An excerpt carrying `parent` names the section that encloses it** — the
`concept_id` and `title` of another concept in this bundle. The payload names
that one concept as reachable (§ 2.2), so it is the one file outside the
delivered excerpts you may read: when an excerpt's `text` is its heading alone,
what that section inherits stands in the enclosing concept, whose file is its
`concept_id` plus `.md` under the bundle root. The text links it too, on a line
`Enclosing section: [title](/path)`, where `/` is the bundle root. Cite what you
take from it by that concept's own `(bundle_id, concept_id)`, never by the
excerpt that pointed to it. An excerpt carrying `parent_unresolved` names a
parent this reader could not find in the bundle: say so rather than reporting
that it has none.
| Marking | Use when | | Marking | Use when |
|---|---| |---|---|
| `extracted` | the bundle states it directly | | `extracted` | the bundle states it directly |

View file

@ -132,6 +132,18 @@ because the producer wrote none — never because the source has none, and never
something to fill in. An excerpt carrying `sources_unreadable` has an address something to fill in. An excerpt carrying `sources_unreadable` has an address
this reader could not decode: say so rather than reporting no address. this reader could not decode: say so rather than reporting no address.
**An excerpt carrying `parent` names the section that encloses it** — the
`concept_id` and `title` of another concept in this bundle. The payload names
that one concept as reachable (§ 2.2), so it is the one file outside the
delivered excerpts you may read: when an excerpt's `text` is its heading alone,
what that section inherits stands in the enclosing concept, whose file is its
`concept_id` plus `.md` under the bundle root. The text links it too, on a line
`Enclosing section: [title](/path)`, where `/` is the bundle root. Cite what you
take from it by that concept's own `(bundle_id, concept_id)`, never by the
excerpt that pointed to it. An excerpt carrying `parent_unresolved` names a
parent this reader could not find in the bundle: say so rather than reporting
that it has none.
| Marking | Use when | | Marking | Use when |
|---|---| |---|---|
| `extracted` | the bundle states it directly | | `extracted` | the bundle states it directly |
@ -179,6 +191,7 @@ measurement about the producer, never a fact about the source.
| `verified` | **0 of 3** | no concept in this bundle carries it | that the source document lacks what the field asserts | | `verified` | **0 of 3** | no concept in this bundle carries it | that the source document lacks what the field asserts |
| `req_number` | **0 of 3** | no concept in this bundle carries it | that the source document lacks what the field asserts | | `req_number` | **0 of 3** | no concept in this bundle carries it | that the source document lacks what the field asserts |
| `sources` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts | | `sources` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts |
| `parent` | **2 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts |
| `source_file` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts | | `source_file` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts |
| `source_lines` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts | | `source_lines` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts |
| `source_offset` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts | | `source_offset` | **3 of 3** | the producer wrote none for that concept | that the source document lacks what the field asserts |
@ -195,7 +208,7 @@ carries its denominator.
| Limit | `120000` | | Limit | `120000` |
| Unit | `utf-8 bytes of emitted JSON` | | Unit | `utf-8 bytes of emitted JSON` |
| Instrument | `okf_consume.measure (len of the ensure_ascii=False JSON encoding, utf-8)` | | Instrument | `okf_consume.measure (len of the ensure_ascii=False JSON encoding, utf-8)` |
| Known-positive | `docs/consumption-contract.md, encoded as a JSON string` at `13238` | | Known-positive | `docs/consumption-contract.md, encoded as a JSON string` at `14455` |
The instrument reproduces the known-positive figure before any of its own The instrument reproduces the known-positive figure before any of its own
numbers are believed. Report what the run actually spent. numbers are believed. Report what the run actually spent.
@ -206,10 +219,10 @@ finding requiring a decision — not something to retry with a narrower question
**Scaling. Cost tracks the question, not the corpus.** Measured on this **Scaling. Cost tracks the question, not the corpus.** Measured on this
bundle at generation time, with the question `Hva sier veiledningen om krav?`: the delivered set bundle at generation time, with the question `Hva sier veiledningen om krav?`: the delivered set
was **3 excerpts** costing **2125 utf-8 bytes of emitted JSON**, was **3 excerpts** costing **2289 utf-8 bytes of emitted JSON**,
against a whole bundle that would cost **2092** by the same instrument if against a whole bundle that would cost **2256** by the same instrument if
one answer delivered all 3 concepts — so that answer was about one answer delivered all 3 concepts — so that answer was about
**101.6 %** of the corpus. One question is one measurement: a **101.5 %** of the corpus. One question is one measurement: a
different question moves `spent` and this figure with it. different question moves `spent` and this figure with it.
**The breaking point, stated so it can be observed to have been passed.** **The breaking point, stated so it can be observed to have been passed.**

View file

@ -34,7 +34,7 @@ Why each part is there:
checkout on one machine. The line strips that checkout's prefix and nothing checkout on one machine. The line strips that checkout's prefix and nothing
else, and a test holds the shipped `SKILL.md` to the generator's output with else, and a test holds the shipped `SKILL.md` to the generator's output with
exactly that prefix removed. exactly that prefix removed.
- **`okf check`** should report `conformant: 16 rules over 3 excerpts and 0 - **`okf check`** should report `conformant: 17 rules over 3 excerpts and 0
withheld entries, 0 findings` and exit 0. withheld entries, 0 findings` and exit 0.
The generated `name` is `b-golden-segmented-okf-v0-2-consume` while this The generated `name` is `b-golden-segmented-okf-v0-2-consume` while this

View file

@ -8,13 +8,13 @@
"unit": "utf-8 bytes of emitted JSON", "unit": "utf-8 bytes of emitted JSON",
"instrument": "okf_consume.measure (len of the ensure_ascii=False JSON encoding, utf-8)", "instrument": "okf_consume.measure (len of the ensure_ascii=False JSON encoding, utf-8)",
"limit": 120000, "limit": 120000,
"spent": 2125, "spent": 2289,
"known_positive": { "known_positive": {
"case": "docs/consumption-contract.md, encoded as a JSON string", "case": "docs/consumption-contract.md, encoded as a JSON string",
"expected": 13238, "expected": 14455,
"measured": 13238, "measured": 14455,
"raw_bytes": 12893, "raw_bytes": 14083,
"encoding_delta": 345 "encoding_delta": 372
} }
}, },
"denominators": { "denominators": {
@ -42,6 +42,10 @@
"source_sha256": "6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d", "source_sha256": "6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d",
"source_lines": "[5, 8]", "source_lines": "[5, 8]",
"source_offset": "[94, 176]", "source_offset": "[94, 176]",
"parent": {
"concept_id": "veiledning",
"title": "Veiledning for eksempelbundel"
},
"text_sha256": "08447c690087834883a78fb9f40d474c68b2526ed57d0dafe01c4868db3c04f0", "text_sha256": "08447c690087834883a78fb9f40d474c68b2526ed57d0dafe01c4868db3c04f0",
"text": "\n## 1.1 Foerste krav\n\nEt krav som beskriver den foerste betingelsen i eksempelet.", "text": "\n## 1.1 Foerste krav\n\nEt krav som beskriver den foerste betingelsen i eksempelet.",
"rank": 1 "rank": 1
@ -64,6 +68,10 @@
"source_sha256": "6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d", "source_sha256": "6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d",
"source_lines": "[9, 11]", "source_lines": "[9, 11]",
"source_offset": "[176, 253]", "source_offset": "[176, 253]",
"parent": {
"concept_id": "veiledning",
"title": "Veiledning for eksempelbundel"
},
"text_sha256": "4c181e1debb141d3cb1a7bd47249fdac1c0b2c5db1b47cf255ef8f04f83ec840", "text_sha256": "4c181e1debb141d3cb1a7bd47249fdac1c0b2c5db1b47cf255ef8f04f83ec840",
"text": "\n## 1.2 Andre krav\n\nEt krav som beskriver den andre betingelsen i eksempelet.", "text": "\n## 1.2 Andre krav\n\nEt krav som beskriver den andre betingelsen i eksempelet.",
"rank": 2 "rank": 2

View file

@ -50,7 +50,7 @@ import re
import sys import sys
import unicodedata import unicodedata
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
@ -287,6 +287,58 @@ class Concept:
locators: Mapping[str, str] locators: Mapping[str, str]
frontmatter: Mapping[str, str] frontmatter: Mapping[str, str]
body: str body: str
#: The concept this one's `parent` pointer resolves to, as its `concept_id`
#: and `title`, or `None`. Set by :func:`link_parents` over the whole set
#: and never by :func:`read_concept`: whether a pointer lands is a fact
#: about the bundle, and one file does not know its bundle.
parent: Mapping[str, str] | None = None
#: `True` when the concept carries a `parent` key that resolves to no
#: concept of its own document -- the third state, as for `sources`.
parent_unresolved: bool = False
#: The key a concept names its enclosing section under, and the key that
#: section answers to. Both are the segmented profiles' (`inbox.py` writes a
#: plan entry's `parent_id` as `parent:`, naming the other entry's `segment_id`).
PARENT_KEY = "parent"
SEGMENT_ID_KEY = "segment_id"
def link_parents(concepts: Sequence[Concept]) -> list[Concept]:
"""Every concept, with its `parent` pointer resolved or named unresolved.
A `parent` value is a `segment_id`, and a segment id is unique only inside
the plan of ONE document -- `p1` exists in every document of a
multi-document bundle -- so it is resolved among the concepts sharing this
concept's `source_file`, never across the bundle. An id two concepts of one
document claim resolves to neither, the rule `structure._lookup` applies: an
ambiguous pointer that silently picks a winner is worse than one reported
unresolved.
One pass over concepts already read; no file is opened again. A concept
carrying no `parent` comes back as it went in, which is what keeps every
payload of a bundle without the key byte-identical.
"""
claims: dict[tuple[str, str], list[Concept]] = {}
for concept in concepts:
segment_id = concept.frontmatter.get(SEGMENT_ID_KEY, "").strip()
if segment_id:
claims.setdefault((concept.source_file, segment_id), []).append(concept)
linked: list[Concept] = []
for concept in concepts:
pointer = concept.frontmatter.get(PARENT_KEY, "").strip()
if not pointer:
linked.append(concept)
continue
owners = claims.get((concept.source_file, pointer), [])
if len(owners) == 1 and owners[0] is not concept:
target = owners[0]
linked.append(
replace(concept, parent={"concept_id": target.concept_id, "title": target.title})
)
else:
linked.append(replace(concept, parent_unresolved=True))
return linked
def read_concept(path: Path, *, bundle_root: Path, root_bundle_id: str) -> Concept: def read_concept(path: Path, *, bundle_root: Path, root_bundle_id: str) -> Concept:
@ -617,14 +669,14 @@ KNOWN_POSITIVE_CASE = "docs/consumption-contract.md, encoded as a JSON string"
#: `measure()`'s own answer for that file. Vacuous ALONE -- which is why the #: `measure()`'s own answer for that file. Vacuous ALONE -- which is why the
#: delta below exists. #: delta below exists.
KNOWN_POSITIVE_EXPECTED = 13_238 KNOWN_POSITIVE_EXPECTED = 14_455
#: The second, independent route. `wc -c` reports 12 893 raw bytes for the same #: The second, independent route. `wc -c` reports 14 083 raw bytes for the same
#: file; the difference is this file's JSON quoting and escaping overhead. A #: file; the difference is this file's JSON quoting and escaping overhead. A
#: reader can derive it without running `measure()` at all, and it moves the #: reader can derive it without running `measure()` at all, and it moves the
#: moment `measure()` changes what it counts -- which is what stops #: moment `measure()` changes what it counts -- which is what stops
#: `expected == measured` from proving nothing. #: `expected == measured` from proving nothing.
KNOWN_POSITIVE_ENCODING_DELTA = 345 KNOWN_POSITIVE_ENCODING_DELTA = 372
#: The two places that file can be, resolved in this order. #: The two places that file can be, resolved in this order.
#: #:
@ -1585,6 +1637,13 @@ def excerpt_for(concept: Concept) -> dict[str, object] | None:
1 on three bundles while the model could not name it: the ranking found the 1 on three bundles while the model could not name it: the ranking found the
document and the delivery dropped the key. document and the delivery dropped the key.
Since K3-21, `parent` too, on the same terms: the concept that encloses
this one, as its `concept_id` and `title`, written only when the concept
names one and the pointer resolves (`link_parents`). Never the raw
`segment_id` the producer wrote -- a reader holding `p1977` can open
nothing -- and `parent_unresolved` where the pointer lands nowhere, the
`sources_unreadable` rule for the same reason.
Trailing whitespace is stripped per line: a spreadsheet render is padded to Trailing whitespace is stripped per line: a spreadsheet render is padded to
hundreds of trailing spaces per line, and unstripped, most of a budget goes hundreds of trailing spaces per line, and unstripped, most of a budget goes
on padding. on padding.
@ -1613,6 +1672,10 @@ def excerpt_for(concept: Concept) -> dict[str, object] | None:
# HAS an address and this reader could not decode it. # HAS an address and this reader could not decode it.
excerpt["sources_unreadable"] = True excerpt["sources_unreadable"] = True
excerpt.update(concept.locators) excerpt.update(concept.locators)
if concept.parent is not None:
excerpt["parent"] = dict(concept.parent)
elif concept.parent_unresolved:
excerpt["parent_unresolved"] = True
excerpt["text_sha256"] = hashlib.sha256(text.encode("utf-8")).hexdigest() excerpt["text_sha256"] = hashlib.sha256(text.encode("utf-8")).hexdigest()
excerpt["text"] = text excerpt["text"] = text
return excerpt return excerpt
@ -1864,14 +1927,16 @@ def build_payload(
) )
root_bundle_id = root_bundle_id_of(bundle_root, profile=profile) root_bundle_id = root_bundle_id_of(bundle_root, profile=profile)
concept_ids = enumerate_concepts(bundle_root, profile=profile) concept_ids = enumerate_concepts(bundle_root, profile=profile)
concepts = [ concepts = link_parents(
read_concept( [
bundle_root / f"{concept_id}{profile.paths.concept_suffix}", read_concept(
bundle_root=bundle_root, bundle_root / f"{concept_id}{profile.paths.concept_suffix}",
root_bundle_id=root_bundle_id, bundle_root=bundle_root,
) root_bundle_id=root_bundle_id,
for concept_id in concept_ids )
] for concept_id in concept_ids
]
)
# The bundle's OWN vocabulary, and the reason the rule is a set rather than # The bundle's OWN vocabulary, and the reason the rule is a set rather than
# a threshold: `pris` is a word here and `bila` is not, which is what # a threshold: `pris` is a word here and `bila` is not, which is what
# separates a Norwegian compound from four coincidental characters. One # separates a Norwegian compound from four coincidental characters. One

View file

@ -301,6 +301,61 @@ def rule_excerpt_named(ctx: Context) -> list[Finding]:
] ]
def rule_excerpt_parent(ctx: Context) -> list[Finding]:
"""SS 8.6: an excerpt's `parent`, when it carries one, is a pointer a
reader can follow.
Added 2026-09-11 (K3-21). Round 20 wrote `parent:` onto 675 of 710
heading-only sections of one standard as a `segment_id`, a value a reader
can open nothing with. The pre-pass now resolves it, and this rule holds
the resolved form: a `concept_id` and a `title`, naming a concept other
than the excerpt itself.
**The payload is its own denominator.** `excerpts` and `withheld` together
name every concept the pre-pass considered, which is every concept of the
bundle (SS 5.2), so a `parent.concept_id` in neither names nothing in the
bundle -- and the rule sees that without opening the bundle, the boundary
`rule_bundle_identity` keeps too. A payload lying about both lists at once
passes here and fails `denominator_identity` instead.
**Conditional, like SS 8.4's fields.** An excerpt with no `parent` meets
this rule as it always did. `parent_unresolved` is not a finding: SPEC SS
6.1, "Consumers MUST tolerate broken links", and a pointer named as
unresolved is the honest form of one.
"""
if not ctx.payload_is_mapping:
return []
excerpts = [_mapping(raw) for raw in _sequence(ctx.payload.get("excerpts"))]
considered = {_text(excerpt.get("concept_id")) for excerpt in excerpts} | {
_text(_mapping(raw).get("concept_id")) for raw in _sequence(ctx.payload.get("withheld"))
}
considered.discard("")
findings = []
for position, excerpt in enumerate(excerpts):
if "parent" not in excerpt:
continue
parent = excerpt.get("parent")
target = _text(_mapping(parent).get("concept_id"))
if not isinstance(parent, Mapping) or not target or not _text(parent.get("title")):
reason = "is not a `concept_id` and a `title`, so a reader can neither open nor cite it"
elif target == _text(excerpt.get("concept_id")):
reason = f"names the excerpt itself ({target!r})"
elif target not in considered:
reason = (
f"names {target!r}, which is in neither `excerpts` nor `withheld` and so is "
"no concept of this bundle"
)
else:
continue
findings.append(
Finding(
"parent_unfollowable",
f"excerpt {position}'s `parent` {reason} (SS 8.6)",
)
)
return findings
def rule_excerpt_states(ctx: Context) -> list[Finding]: def rule_excerpt_states(ctx: Context) -> list[Finding]:
if not ctx.payload_is_mapping: if not ctx.payload_is_mapping:
return [] return []
@ -523,6 +578,7 @@ RULES: tuple[Callable[[Context], list[Finding]], ...] = (
rule_bundle_identity, rule_bundle_identity,
rule_excerpt_source_marking, rule_excerpt_source_marking,
rule_excerpt_named, rule_excerpt_named,
rule_excerpt_parent,
rule_excerpt_states, rule_excerpt_states,
rule_denominator_identity, rule_denominator_identity,
rule_denominator_lists, rule_denominator_lists,

View file

@ -52,6 +52,7 @@ from .segmentation import (
SegmentationPlan, SegmentationPlan,
SegmentEntry, SegmentEntry,
assert_plan_applies, assert_plan_applies,
heading_only,
observed_extractor_version, observed_extractor_version,
slice_segments, slice_segments,
) )
@ -101,6 +102,33 @@ def inbox_filename(slug: str, *, profile: BundleProfile = DEFAULT) -> str:
) )
#: The words around the one link a heading-only body gains. SPEC SS 6.1: the
#: kind of relationship "is conveyed by the surrounding prose, not by the link
#: itself", so the line names the relation, in two words, because generic code
#: writes them into a body in the source's own language.
ENCLOSING_SECTION = "Enclosing section"
def _link_enclosing(body: str, parent: SegmentEntry, gate: Gate) -> str:
"""`body` plus one line linking the section that encloses it.
SPEC SS 5.1: "Lineage is expressed through links, not a dedicated field";
the `parent:` key stays beside it as a SS 4.1 extension. Bundle-relative
and absolute, SS 6.1's "recommended form", because the parent sits in
another directory and a relative link would count `..` across a layout the
next round may change.
The line carries the parent's title, which is document text, and joins a
body the gate has already judged -- so it is screened on its own, the rule
`_screened` applies to a `description`, and dropped rather than refused
when the gate would not persist it.
"""
line = _screened(gate, f"{ENCLOSING_SECTION}: [{parent.title}](/{parent.path})")
if line is None:
return body
return f"{body.rstrip(chr(10))}\n\n{line}\n"
def _normalize_body(text: str) -> str: def _normalize_body(text: str) -> str:
# LF-only with exactly one trailing newline is a byte-level guarantee, and # LF-only with exactly one trailing newline is a byte-level guarantee, and
# dropped files legitimately arrive with CRLF — normalising is the # dropped files legitimately arrive with CRLF — normalising is the
@ -820,6 +848,16 @@ def _render_segments(
extractor_version=observed_extractor_version(extractor_id), extractor_version=observed_extractor_version(extractor_id),
) )
sliced = slice_segments(text, plan) sliced = slice_segments(text, plan)
# A body that is its heading alone gains ONE line linking the section its
# `parent` names. Only such a body: one holding text already has something
# to read, and the segmented goldens' declared parents are bodies holding
# text. Decided on the SLICE, the window the proposer's own predicate read.
enclosing = {entry.segment_id: entry for entry in plan.entries}
linked = {
entry.segment_id
for entry, body in sliced
if entry.parent_id is not None and heading_only(body)
}
decisions = [(entry, gate(body)) for entry, body in sliced] decisions = [(entry, gate(body)) for entry, body in sliced]
refused = [ refused = [
@ -837,11 +875,21 @@ def _render_segments(
if profile.index.facets is not None: if profile.index.facets is not None:
structure = derive_document_structure(decision.sanitized_text, source_file=source_file) structure = derive_document_structure(decision.sanitized_text, source_file=source_file)
_validate_facets(structure, profile) _validate_facets(structure, profile)
body = decision.sanitized_text
if entry.segment_id in linked:
# AFTER the structure is derived, and that order is the rule: read
# as body text, the link is a bundle-local target, so derivation
# would restate the `parent` relation as a `references` edge -- one
# relation under two kinds, the second rendered unresolved because
# nothing resolves the absolute form. Measured on the fixture
# before this order was chosen.
assert entry.parent_id is not None
body = _link_enclosing(body, enclosing[entry.parent_id], gate)
outputs.append( outputs.append(
( (
entry.path, entry.path,
render_inbox_concept( render_inbox_concept(
decision.sanitized_text, body,
okf_type=entry.okf_type, okf_type=entry.okf_type,
# DECLARED by the adjudicator, never derived from the # DECLARED by the adjudicator, never derived from the
# segment's own first line: the plan is the record of the # segment's own first line: the plan is the record of the

View file

@ -65,7 +65,7 @@ from .errors import IngestError
from .extract import OutlineMark, extract_text, strip_converter_attribute, xml_outline from .extract import OutlineMark, extract_text, strip_converter_attribute, xml_outline
from .extract import pdf_outline as extract_pdf_outline from .extract import pdf_outline as extract_pdf_outline
from .materialize import reduce_to_id_grammar from .materialize import reduce_to_id_grammar
from .segmentation import observed_extractor_version from .segmentation import heading_only, observed_extractor_version
#: Stamped into every entry's `derived` list. The marker is what keeps a #: Stamped into every entry's `derived` list. The marker is what keeps a
#: proposal from being mistaken for the judgement the run path replays. #: proposal from being mistaken for the judgement the run path replays.
@ -1488,8 +1488,7 @@ def _link_shells(entries: list[dict[str, Any]], levels: Sequence[int], text: str
grew the excerpts past the budget. grew the excerpts past the budget.
""" """
bodied = [ bodied = [
any(line.strip() and not line.startswith("#") for line in text[start:end].split("\n")) not heading_only(text[start:end]) for start, end in (entry["span"] for entry in entries)
for start, end in (entry["span"] for entry in entries)
] ]
for index, entry in enumerate(entries): for index, entry in enumerate(entries):
if bodied[index]: if bodied[index]:

View file

@ -45,6 +45,18 @@ from typing import Any
from .errors import SegmentationError from .errors import SegmentationError
from .materialize import reduce_to_id_grammar from .materialize import reduce_to_id_grammar
def heading_only(text: str) -> bool:
"""`True` when no line of `text` holds anything but a markdown heading.
ONE predicate for two readers that must agree: the proposer gives such a
span a parent (`propose._link_shells`), and the door writes a link into
exactly those bodies. Both read the same window on the same extracted text,
so a second copy of the rule could only ever disagree with the first.
"""
return not any(line.strip() and not line.startswith("#") for line in text.split("\n"))
#: The top-level keys a plan payload must carry. Every one is required: a plan #: The top-level keys a plan payload must carry. Every one is required: a plan
#: missing its extractor identity would still parse, and would then be replayed #: missing its extractor identity would still parse, and would then be replayed
#: against an extraction nobody checked it against. #: against an extraction nobody checked it against.

View file

@ -13,7 +13,8 @@ passes the UNFILLED template against a real payload (exit 0, 15 rules, 0
findings), and passes a skill built for a different bundle against this one's findings), and passes a skill built for a different bundle against this one's
payload. `contract_check.rule_bundle_identity` now compares the identity a payload. `contract_check.rule_bundle_identity` now compares the identity a
skill declares with the identity its payload declares, so all three measured skill declares with the identity its payload declares, so all three measured
pairs are refused at exit 1 with one `bundle_mismatch` finding over 16 rules: pairs are refused at exit 1 with one `bundle_mismatch` finding over 16 rules
(17 since K3-21's `parent_unfollowable`):
a skill against another bundle's payload, the unfilled template against a real a skill against another bundle's payload, the unfilled template against a real
payload, and -- the arm an id comparison would miss -- a payload sharing the payload, and -- the arm an id comparison would miss -- a payload sharing the
skill's `bundle_id` at a foreign `ref`. The right pair is untouched at exit 0 skill's `bundle_id` at a foreign `ref`. The right pair is untouched at exit 0
@ -99,6 +100,7 @@ CONDITIONAL_FIELDS = (
"verified", "verified",
"req_number", "req_number",
"sources", "sources",
"parent",
) )
#: Tokens too short to carry a question. The same floor the pre-pass's own #: Tokens too short to carry a question. The same floor the pre-pass's own
@ -337,14 +339,16 @@ def render(
bundle_id = okf_consume.root_bundle_id_of(bundle_root, profile=profile) bundle_id = okf_consume.root_bundle_id_of(bundle_root, profile=profile)
ref = okf_consume.bundle_ref(bundle_root, profile=profile) ref = okf_consume.bundle_ref(bundle_root, profile=profile)
concept_ids = okf_consume.enumerate_concepts(bundle_root, profile=profile) concept_ids = okf_consume.enumerate_concepts(bundle_root, profile=profile)
concepts = [ concepts = okf_consume.link_parents(
okf_consume.read_concept( [
bundle_root / f"{concept_id}{profile.paths.concept_suffix}", okf_consume.read_concept(
bundle_root=bundle_root, bundle_root / f"{concept_id}{profile.paths.concept_suffix}",
root_bundle_id=bundle_id, bundle_root=bundle_root,
) root_bundle_id=bundle_id,
for concept_id in concept_ids )
] for concept_id in concept_ids
]
)
if not concepts: if not concepts:
raise SkillError( raise SkillError(
f"{bundle_root} has an index but no concept under it; a skill for an " f"{bundle_root} has an index but no concept under it; a skill for an "

View file

@ -156,12 +156,13 @@ def test_a_payload_declaring_no_identity_is_left_to_its_own_rule(tmp_path: Path)
def test_the_rule_count_is_the_denominator_the_report_quotes() -> None: def test_the_rule_count_is_the_denominator_the_report_quotes() -> None:
"""The nevner moves 15 -> 16, and every consumer quoting "15 rules" is """The nevner moved 15 -> 16 with this rule and 16 -> 17 with
`parent_unfollowable` (K3-21), and every consumer quoting the old number is
quoting a number that has changed.""" quoting a number that has changed."""
text, payload = instantiate(BUNDLE_A) text, payload = instantiate(BUNDLE_A)
report = contract_check.check(text, payload) report = contract_check.check(text, payload)
assert report.rules_evaluated == len(contract_check.RULES) == 16 assert report.rules_evaluated == len(contract_check.RULES) == 17
assert "16 rules" in report.render() assert "17 rules" in report.render()
def test_the_generator_writes_an_identity_the_checker_can_read() -> None: def test_the_generator_writes_an_identity_the_checker_can_read() -> None:

View file

@ -170,9 +170,10 @@ def test_a_pointer_resolves_inside_its_own_document(tmp_path: Path) -> None:
documents = {str(e["concept_id"]).split("/")[0] for e in carried} documents = {str(e["concept_id"]).split("/")[0] for e in carried}
assert documents == {"a", "b"} assert documents == {"a", "b"}
for excerpt in carried: for excerpt in carried:
assert str(excerpt["parent"]["concept_id"]).split("/")[0] == str( assert (
excerpt["concept_id"] str(excerpt["parent"]["concept_id"]).split("/")[0]
).split("/")[0] == str(excerpt["concept_id"]).split("/")[0]
)
# --- The checker --------------------------------------------------------------- # --- The checker ---------------------------------------------------------------

View file

@ -168,16 +168,22 @@ def test_a_build_without_the_flag_is_the_build_with_the_opt_out(tmp_path: Path)
assert files == sorted(p.relative_to(opted_out) for p in opted_out.rglob("*") if p.is_file()) assert files == sorted(p.relative_to(opted_out) for p in opted_out.rglob("*") if p.is_file())
for relative in files: for relative in files:
assert (default / relative).read_bytes() == (opted_out / relative).read_bytes() assert (default / relative).read_bytes() == (opted_out / relative).read_bytes()
# The flag adds one `parent:` line to each shell with an ancestor, and the # The flag adds one `parent:` line to each shell with an ancestor, and --
# index -- a projection of the frontmatter -- shows the same key as a facet # since K3-21 -- one link line to that shell's body
# on that shell's entry. Nothing else moves. # (`tests/test_parent_reaches_reader.py`). The index, a projection of the
added = 0 # frontmatter, shows the same key as a facet on that shell's entry. Nothing
# else moves.
added = linked = 0
for relative in files: for relative in files:
before = (default / relative).read_text(encoding="utf-8").split("\n") before = (default / relative).read_text(encoding="utf-8").split("\n")
after = (flagged / relative).read_text(encoding="utf-8").split("\n") text = (flagged / relative).read_text(encoding="utf-8")
if relative.name == "index.md": if relative.name == "index.md":
after = text.split("\n")
assert [re.sub(r"parent: p\d+\?; ", "", line) for line in after] == before assert [re.sub(r"parent: p\d+\?; ", "", line) for line in after] == before
continue continue
text, links = re.subn(r"\n\nEnclosing section: \[[^\]]+\]\(/[^)\s]+\)\n\Z", "\n", text)
linked += links
after = text.split("\n")
assert [line for line in after if not line.startswith("parent: ")] == before assert [line for line in after if not line.startswith("parent: ")] == before
added += sum(1 for line in after if line.startswith("parent: ")) added += sum(1 for line in after if line.startswith("parent: "))
assert added == sum(1 for ancestor in EXPECTED.values() if ancestor is not None) assert added == linked == sum(1 for ancestor in EXPECTED.values() if ancestor is not None)