Commit graph

184 commits

Author SHA1 Message Date
bc0b4130f1 feat(tools): okf_contract_check reads the contract's checkable half
Fourteen rules, each with its own code: one "invalid" verdict over fourteen
different defects is a diagnostic no caller can act on. The report quotes its
own denominators -- rules run, excerpts and withheld entries examined -- because
a checker that exempted itself from section 5 would be stating the rule it
breaks.

Three exit codes, not two. "The check did not run" and "the check failed" are
different outcomes, and an unread file reported as a failed check is the fourth
face of the verification law.

The shipped template plus its example payload is the known-positive arm, so a
checker that refuses everything cannot be green on the thirteen negative ones.
A last test asserts every literal the checker enforces appears in the contract
document: two copies of a closed set drift, and the copy nobody reads is the
one that goes wrong.

996 -> 1023 tests.
2026-09-02 16:09:27 +02:00
d077e4e064 feat(skills): a consumption-skill template with no defaults
A per-corpus skill copies this directory and fills every <PLACEHOLDER>. None of
them has a default: a corpus's budget, instrument, profile name and conditional
fields are facts about that corpus, and a default would be this repository
asserting one it has not measured.

The section headings are fixed because the checker in the next commit reads
them by name. references/example-payload.json is the known-positive that keeps
a refuse-everything checker from passing.
2026-09-02 16:06:24 +02:00
f9513cdcb4 docs(contract): the generic consumption contract, stated normatively
The plan document decided what the contract is and who owns it, and said of
itself that nothing in it was implemented. This states the same six elements as
numbered paragraphs with MUST and SHOULD, in the form a checker can read, and
adds the three things the plan layer had no way to carry: concept identity as
the (bundle_id, concept_id) tuple with a content digest per excerpt, the closed
adjudication state set with unknown written explicitly rather than collapsed,
and the prohibition on reaching the verdict layer with a query-time retrieval
tool.

The plan document is not rewritten to match. It records why; this binds. Two
copies of the same rules drift, so the plan carries a pointer instead.
2026-09-02 16:05:11 +02:00
94f31ac880 docs(measurement): K3, K4 and K5 numbers against the committed method 2026-09-02 15:55:19 +02:00
a833003f9e docs(measurement): K3, K4 and K5 method with n declared before any verdict 2026-09-02 15:46:03 +02:00
131a5a6827 docs(measurement): K1 and K2 over the K2 corpus 2026-09-02 15:43:45 +02:00
70cf4af268 feat(tools): corpus harness reporting K1 and K2 with denominators 2026-09-02 15:40:06 +02:00
7c6910cb55 feat(tools): re-measurable K2 fidelity instrument with a negative control 2026-09-02 15:08:08 +02:00
a4ffa363fe feat(tools): adjudication command with a switchable model leg 2026-09-02 14:54:20 +02:00
81c6a01c86 feat(inbox): accept one segmentation plan per document 2026-09-02 14:51:53 +02:00
a60312a5f3 feat(inbox): surface adjudication state and its dwell time 2026-09-02 14:49:45 +02:00
233cdc5671 feat(segmentation): parse the adjudication state a plan already carries 2026-09-02 14:44:06 +02:00
c54e8383df feat(segmentation): quote anchors so a re-extraction costs a re-anchor 2026-09-02 14:42:16 +02:00
9e9bb8645d fix(segmentation): hash the extracted text and let the plan key fire 2026-09-02 14:38:20 +02:00
6dce4355be test(examples): byte-pinned golden for SEGMENTED_OKF_V0_2
A sixth golden directory, never an edit to the five that exist. Its only
difference from the SEGMENTED_V1 golden is the added `okf_version` root key --
measured by diffing the two -- which is exactly what the profile is for and
nothing else moved.

Modelled on tests/test_segmented_golden.py rather than test_golden.py's
`materialize_case`, which is Door A only: it drives materialize_bundle from a
manifest and this bundle comes through Door B's inbox. The plan's pointer to an
"existing goldens list" in test_golden.py is stale -- that file holds only the
Door A parametrize, and `ingest-golden-segmented` is not in it either. Door B
goldens carrying their own test file is the established pattern, and this one
asserts the five priors are untouched from its own side.

CROSS-BUNDLE IDENTITY (PM decision B1) is settled BEFORE this pin, not after.
Byte-pinning a bundle carrying `okf_version` is where its concept IDs stop
being adjustable: a concept ID is bundle-local and stable, identity across
bundles is the tuple (bundle_id, concept_id), and there is no cross-bundle link
form in v0.2. Asserted -- every concept carries its bundle id, so the tuple is
readable from one document, and no foreign bundle id appears anywhere.

Two defects in these tests, both found by running them:

- `parse_frontmatter` takes a Path and returns the dict; three call sites had
  it wrong.
- The cross-bundle check used `b-golden-segmented` as the foreign id, which is
  a PREFIX of this bundle's own id. It failed on every file for the wrong
  reason and would have read as "a cross-bundle reference was emitted". Now a
  foreign id that is not a prefix, with a known-positive half asserting the
  bundle does name its own id -- so the check is looking at content that could
  have carried one.

Negative control: changing one byte of the golden's `okf_version` turns the pin
red.

`okf_version` and `bundle_id` are fixture DATA read from the case, never
constants in the test -- the value belongs to catalog (E1).

Suite 931 -> 941.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:24:05 +02:00
ea64b9915d feat(profiles): SEGMENTED_OKF_V0_2 so a segmented bundle can declare its spec
Neither shipped profile could do both: SEGMENTED_V1 names `bundle_id` and not
`okf_version`, OKF_V0_2 names `okf_version` and has no segmentation at all. The
sixth profile is where the two intersect. Additive, as upstream support always
is here -- a new profile, never a migration.

THE INDEX POLICY IS DECIDED, NOT INHERITED, and that is the part that was easy
to get wrong. Measured: OKF_V0_2.index has facets=None and per_directory=False,
while SEGMENTED_V1.index has both. Building on OKF_V0_2's index would have
produced a segmented bundle with no faceted index -- structurally valid, SPEC
conformant, and missing the surface a consumer reads. So the index comes from
SEGMENTED_V1 with both root keys named, and the spec declaration from OKF_V0_2.
The premise is asserted in the suite rather than trusted to stay true.

`SegmentationPolicy.adjudication_key` is the discriminator BETWEEN segmented
profiles. It has to be a field with a value: every 1-to-N branch keys on
`profile.segmentation is not None`, which both segmented profiles satisfy, so a
later step surfacing the adjudication marker on that check would write it into
SEGMENTED_V1 too and move a byte-pinned golden. `None` means the profile does
not surface adjudication state at all.

`okf_version`'s VALUE is not in this module and must never be: a profile names
a key, the caller owns its value (decision E1). A constant here would claim a
decision this library does not own and would be the one thing to chase on every
upstream release. Asserted.

Five existing profiles untouched, each still equal to a freshly constructed
copy, root keys pinned literally, and OKF_LATEST still DEFAULT -- adding a
profile is not a GA event.

Suite 926 -> 931.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:21:15 +02:00
55a09d6c8e feat(profiles): a profile may name a per-suffix renderer
Arm E, capability only. A profile MAY name a renderer per suffix; no
domain-aware renderer is written here, that stays a Non-Goal, and `_RENDERERS`
is empty on purpose so the emptiness reads as a decision rather than an
omission.

THE LAYERING IS THE DESIGN, not an implementation detail. `extract.py` is the
extraction registry and must not import the contract layer, or the dependency
runs backwards and the registry stops standing on its own. So `extract_text`
gains a keyword-only `renderer: Callable[[str], str] | None`, knowing nothing
about profiles, and `inbox.py` -- which already holds the profile at that call
site -- resolves a NAME to a function. A test asserts extract.py still contains
no reference to the profile layer, because that constraint is the whole reason
the parameter is shaped this way.

The renderer runs AFTER extraction, never instead of it, so it never has to
re-implement a reader and the two cannot drift. The default is identity, which
is what keeps the five byte-pinned goldens byte-pinned -- asserted per suffix
rather than once.

An unknown renderer NAME is refused rather than falling back to identity: a
silent fallback would produce a bundle that looks rendered and is not, which is
the failure mode this arm exists to make visible. That needed a registered code
(`unknown_renderer`) and its test -- slightly beyond the step's named files,
but the capability cannot ship without defining what an unknown name does.

`tests/test_profile.py`'s exact-field-set assertion went red, as the plan's risk
table predicted. Updated deliberately with the reason recorded: that assertion
exists so a field cannot arrive without someone deciding it should, and its red
run is the mechanism working.

Suite 917 -> 926. All five goldens byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:19:18 +02:00
0170c526ad docs(extract): the extra now ships an office converter
The README told consumers that `docx` and `xlsx` ship no parser and always fail
fast. True when written; false the moment the converter seam landed -- and
false SILENTLY, because prose has no test. This repository has been bitten by
that exact shape before: a published guarantee is a test obligation.

So the correction comes with `tests/test_docs_promises.py`, which compares the
README's declared format list against the registries it describes and fails on
a format added without touching the README, on the old claim reappearing in any
wording, on an unmeasured row going unnamed, and on the exclusions being
dropped. Negative control: removing one format from the README's marker turns
it red.

The README now states which rows are measured and which are not. Three of the
five office rows have denominator ZERO in the corpus -- they work by
construction and have never met a document anyone wrote. They are not known to
be broken and not known to be right, and a reader should not have to open the
source to learn which.

The CHANGELOG's shipped entry is left as written, because a changelog records
what a release did; the correction is stated at that line instead so a reader
arriving there is not misled.

Suite 913 -> 917.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:15:55 +02:00
66a44f173b test(extract): hand-built office fixtures with frozen extracted text
Three hand-laid OOXML containers, every part written out by hand and zipped
with a fixed date_time so they are byte-reproducible. No converter output
anywhere in them: a .docx written by the converter and read by the converter
proves only that the converter agrees with itself, and would stay green through
any conversion defect that is symmetric -- which is most of them.

  two-line-krav.docx    heading + label/value on one line (the docx mirror of
                        the PDF fixture)
  no-styles-krav.docx   the SAME document without word/styles.xml
  two-line-krav.xlsx    sheet name as heading + label/value on one row

THE FIXTURES FOUND A REAL DEFECT IN THE SEAM THEY WERE MEANT TO PIN. The
converter call used pypandoc's TEXT entry point, which takes an `encoding`
because it treats its source as text -- and that corrupts a zip. The xlsx
fixture failed with `Failed to unpack XLSX archive: not enough bytes` while
reading correctly from disk with the same binary. The docx of the same shape
happened to survive, which is the part worth writing down: the defect is silent
for some inputs and fatal for others, so "it worked on the file I tried" was
never evidence. Input now goes through a temporary file.

Two measurements while building, both the same shape -- structurally valid
input, silently reduced output, exit code 0, no warning:

- Without word/styles.xml the docx extracts as flat prose with no heading. A
  fixture lacking that part would pin the body and pin nothing about structure.
  Committed as a negative control that RUNS rather than a sentence in a README.
- With inline strings rather than a shared string table, the xlsx extracts with
  the sheet name intact and every cell value gone. The fixture uses a dimension
  element and a shared string table instead.

The frozen literals are pinned to a NAMED converter version, asserted beside
them: a frozen literal without one says "these bytes" without saying what
produced them.

Suite 908 -> 913. Fixtures regenerate byte-identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:14:27 +02:00
cd7b792aaf feat(extract): five office formats through a table-driven converter seam
`_PANDOC_FORMATS` names the rows and no others: docx, xlsx, pptx, odt, rtf.
`.html` stays on its stdlib extractor -- routing it through the converter would
buy nothing and would add CVE-2025-51591 (SSRF via an iframe in HTML input),
unpatched in every converter version. `.epub` is out on the "no gain" half of
that.

`_EVIDENCE` records what each row rests on, asserted in the suite rather than
written in a comment: docx and xlsx are `measured`, and pptx, odt and rtf are
`unmeasured` because the corpus contains ZERO files of those types. Three of
five rows therefore leave this step working by construction and never checked
against a document anyone wrote, and the assertion is what keeps that visible.

Three converter arguments, all measured and none of them hygiene:
`--eol=lf --wrap=none` because the defaults produce different bytes (max line
length 75 against 447), and `-t markdown` never `-t plain` because plain
destroys the headings the segment proposer reads -- 15 entries with two real
headings become 13 with none.

`_UNPARSED_OPTIONAL_EXTENSIONS` is now empty and kept rather than deleted: the
branch still raises, and a future type arriving before its reader belongs there
rather than in a new mechanism. This is what the first step was for -- both
tests for `extractor_extra_missing` were repointed at the import probe before
the set emptied under them.

The converter call is isolated behind `_convert_bytes` so the seam's own logic
is testable without the binary; the conversion itself is pinned by frozen-text
fixtures in the next step. Checked live against a hand-laid docx through the
real vendored binary: heading and body both survive.

Suite 895 -> 908.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:09:49 +02:00
b9372ad7e8 feat(extract): resolve the vendored converter and refuse an unpinned version
`_pandoc.py` hands back a converter whose identity is known, or refuses.

The wheel is not enough on its own. pypandoc searches PATH before its own
bundled binary and keeps the highest version found, so on this host the
vendored 3.9 was silently bypassed for the system 3.10.2 -- measured a third
time before writing this. The resolver reads the installed package's own
`files/pandoc` path and asserts the reported version against a frozen
PANDOC_VERSION literal, raising `extractor_binary_version` naming both,
`extractor_binary_missing` when the wheel carries no binary, and
`extractor_extra_missing` when the extra is absent.

A mismatch is refused rather than used with a warning: extraction is
deterministic within a converter version and not across one, and a byte-pinned
fixture cannot tell "a different converter ran" from "we introduced a defect".

Two defects found by measuring rather than by the suite:

1. The first implementation asked `pypandoc.get_pandoc_version()`, which
   answers from a module global that `clean_pandocpath_cache()` does not reset.
   It therefore reported whichever binary was probed FIRST in the process --
   3.10.2 for the bundled 3.9 binary. The suite was green because nothing in it
   probed the host binary first. Now `_get_pandoc_version(path)` probes the
   argument, with no cache and no search in the way, and a regression test
   poisons the cache before resolving. Negative control: that test fails on the
   old mechanism.
2. The module docstring named the process-spawning API in prose, which is
   enough to fail the model-free gate -- the gate is a grep. Reworded. The gate
   now proves the narrower "no model vendor is reachable from src/", stated in
   the module rather than glossed.

os.environ is restored on both the success and the failure path, and a
pre-existing override is put back rather than deleted.

Suite 887 -> 895. mypy --strict clean (pypandoc joins the guard's
ignore_missing_imports override; every value it returns is coerced here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:07:20 +02:00
fbd2f3fde5 build(extract): vendor the converter binary as a pinned wheel
`pypandoc-binary==1.17` joins the `[extract]` extra, and the extra's contents
are now pinned by a test -- `test_packaging.py` asserted `project.dependencies`
only, so a second package could have arrived in the extra unnoticed, which is
precisely where an unexamined transitive tree shows up.

WHY VENDORED RATHER THAN FOUND ON PATH: the xlsx and pptx readers exist only
from pandoc 3.8.3. Debian 12 ships 2.17.1.1 and Ubuntu 24.04 ships 3.1.3, so a
PATH binary cannot deliver two of the five office formats on current stable
distributions. The pin is exact rather than a range for the same reason
pdfminer.six's is: extraction is deterministic within a converter version and
not across one.

The single-runtime-dependency rule is untouched -- it governs
`project.dependencies`, which still names the guard alone.

Measured after installing, on this host:

  bundled binary   pandoc 3.9   (inside the wheel, as intended)
  pypandoc picks   3.10.2       (the host's PATH binary)

That is the third independent measurement of the trap: pypandoc searches PATH
before its own bundled binary and takes the highest version it finds, so
"vendored" buys nothing until something resolves the path explicitly. The
resolver is the next step; until it lands, the vendoring is a pin without an
effect and should not be described as more than that.

Suite 886 -> 887 (the extra is installed, so the packaging pin runs).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:03:23 +02:00
d584186837 feat(errors): register the four converter codes
Registered before any step raises them, so no later step invents a spelling:

- `extractor_binary_missing`   -- the converter binary is absent (distinct
                                  from the extra not being installed)
- `extractor_binary_version`   -- present, but not the pinned version
- `extractor_convert_error`    -- the converter failed on this file
- `extractor_empty_conversion` -- the converter returned no text

Also widened the `extractor_extra_missing` type list, which still read
"pdf/docx/xlsx".

Denominators recounted after the change rather than carried from a note --
the stale 49/48 figure is what made the recount a step requirement:

  code bullets in errors.py        50 -> 54
  distinct codes                   49 -> 53   (one code documented twice)
  test definitions in this suite   56 -> 57   (one parametrized definition,
                                               four cases -- one per code)

The four tests assert only what is true at this step: the code is documented
and an ExtractionError carries it. The resolver and the seam replace each with
a behavioural raise-site test; a code that never gains one stays visible here
as a test that still only reads a docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:01:49 +02:00
5f524f3902 test(extract): reach extra-missing through the import probe
Both tests reached `extractor_extra_missing` through a `.docx`/`.xlsx`
filename, which works only while `_UNPARSED_OPTIONAL_EXTENSIONS` is non-empty.
Those types are about to gain a converter, which empties the set and makes the
membership branch unreachable -- the tests would have gone red for the right
reason at the worst moment, mid-series.

Repointed both at the import probe, the mechanism the pdf gate already uses and
the one path that stays reachable however many types gain parsers.

Measured negative control: without the probe the same call raises
`extractor_pdf_error`, so the probe is load-bearing and the test can still fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:00:24 +02:00
11cde6d0b5 docs(plan): amendments etter PM-dom 02.09
Record the two cross-repo decisions the programme ratified on 2026-09-02, so
they exist on this side of the boundary rather than only in a consumer's plan.

- Concept IDs are bundle-local; identity across bundles is the tuple
  (bundle_id, concept_id); no cross-bundle link form in v0.2. Written down
  before a golden freezes the IDs, because retrofitting would move a pinned
  golden.
- The adjudication state has a fixed wire form: frontmatter key `adjudication`
  with a closed value set, three companion keys when adjudicated, and an
  `adjudication` index facet -- new profile only, SEGMENTED_V1 byte-identical.
  Absence of the key means `unknown`, never `absent`.

The ten amendments from the same judgement are applied to the voyage plan
itself, which is LOCAL-ONLY under this repo's public-remote model and so is
not part of this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:58:21 +02:00
169dfdf550 docs(plan): record the three intake decisions and what measured them
The converter binary is vendored as a pinned wheel in the [extract] extra
rather than resolved from PATH. Three measurements retired the alternative:
the xlsx and pptx readers exist only from pandoc 3.8.3 while Debian 12 ships
2.17.1.1 and Ubuntu 24.04 ships 3.1.3; the docx reader changed its output in
at least 22 of the 3.x releases; and Quarto and pypandoc-binary already made
this call. Two consequences are recorded because they are easy to get wrong:
the single-runtime-dependency rule is untouched, since the extra sits outside
it by construction, and the model-free gate keeps subprocess in its
alternation, since src/ imports a package instead of spawning a process.

One trap is recorded because it is invisible when it fires: pypandoc searches
PATH before its own bundled binary and takes the highest version it finds, so
on a host with a newer system pandoc the vendored wheel is bypassed without a
word and the determinism guarantee goes with it.

Judgement lives in tools/, outside the run path, as versioned data. The
verdict carries its dwell time, because a ratified flag with no per-item time
cannot be falsified. Rejecting is one keystroke and correcting optional,
because requiring a typed correction was measured to reduce flagging. The
model leg is off by default so its value can be measured.

A sixth profile carries both okf_version and bundle_id rather than extending
SEGMENTED_V1, which would have rested on an unverifiable claim about whether
another repository had adopted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:32:24 +02:00
2a63223ff4 docs(intake): write down the boundaries that will not be closed
Three facts about Door B's intake were decided but never stated in a tracked
file, which left them looking like gaps someone would eventually close.

OCR is permanently out of scope (operator, 2026-09-01). The consequence is
stated rather than implied: the vegnormal-okf PDFs are rastered 11 of 11 and
will never enter through the inbox, so "Vegnormalene in => bundle out" is not
a goal this library holds. That premise never described anything that happened
either -- that bundle is built from seven JSON files through Door C.

The id grammar keeps lowercasing every path component. It is stricter than
OKF v0.2 section 2 requires, but it is shared with Door A and pinned by
byte-exact goldens, so softening it on the segmentation path alone would put
two grammars in one codebase. Measured consequence: in vegnormal-okf's B3 run,
267 of 270 concept identities matched case-folded and 0 matched verbatim.

Structured sources are recorded as open work rather than a closed boundary:
a span cuts the canonical extracted text, and for .json that text is raw data
in a fenced block, so 0 of 267 bodies came back byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:51:59 +02:00
7f568b3691 test(index): pin Door C's cross-run ordering bound
`d2a8c43` states in a code comment that Door C's ordering holds within a
run and never re-orders entries an earlier run wrote. That was true and
untested: every test in the new file ran each door exactly once, so the
sentence was prose rather than a pin.

Two imports into one bundle, the second adding the concept whose key
sorts FIRST. It lands last, because `link_in_index` appends what is
absent and leaves what is present. The same three concepts merged in one
run do come out ordered, asserted alongside, so the two assertions cannot
both be trivially true -- the difference is the append bound, not an
ordering that failed.

883 tests.

Co-Authored-By: Claude <claude-opus-5>
2026-09-01 19:58:19 +02:00
d2a8c43d77 feat(index): one ordering helper, called by both doors
An index ordering a profile names must be honoured wherever this library
writes an index. Door B and Door C have separate index writers, so an
ordering built on Door B's `_index_sort_key` seam alone would have been a
profile field Door B obeys and Door C ignores -- silently, because
nothing raises and both files still parse. That is
`IndexPolicy.per_directory` again: a field that reads as global and acts
on one path.

`IndexPolicy` gains `sort_key`, `sort_order` and `sort_missing`. Both
order fields draw from CLOSED sets, and `sort_order` is deliberately not
a caller-supplied callable: a callable cannot be serialised into the
bundle, reproduced from it, or audited by a reader, which is the whole of
what a deterministic bundle claims. A `sort_key` the facet policy does
not name is refused too -- every entry would be missing the key and the
ordering would silently do nothing, which is this row's own defect class.

`IndexPolicy.sort_entries` is the one helper. Four stable passes, so each
is the tie-break of the next: concept path, then the named key, then the
missing group partitioned to whichever end the policy says, then
navigation last. Passes 2 and 3 are separate on purpose -- folding them
into one reversible key tuple would flip the missing group along with the
order, so `sort_missing="last"` would mean "first" under `descending`.

The tie-break is the CONCEPT PATH, not the link target, and that is
measured rather than assumed: `notes-beta.md` precedes `notes/alpha.md`
by concept path and follows it by generated filename, so ordering Door C
on the target would have re-ordered every existing Door C bundle.
`IndexEntry` carries the path for that reason; `parse_entry` leaves it
`None` and the ordering falls back to the target, which costs nothing
because no caller sorts entries it read back off disk.

Door B's two reprojection writers and Door C's index emission all route
through the helper. Door B's unfaceted path is not routed and does not
need to be: `sort_key` requires a facet policy, and a faceted profile
never reaches that writer. Door C's guarantee is bounded and stated in
the code -- `link_in_index` appends what is absent and leaves what is
present, so the order holds within a run and never re-orders entries an
earlier run wrote.

Default ordering, unchanged and now stated: with no `sort_key`, concepts
before navigation, each group ascending by concept path.

TDD, and the red was watched twice. First behaviourally with the fields
inert (both doors emitted the exact reverse of the named order), then
again with Door B routed and Door C not -- the broken world reproduced,
where a Door-B-only test would have passed.

882 tests (868 before). The five byte-pinned goldens are untouched and
green; no shipped profile moved.

Co-Authored-By: Claude <claude-opus-5>
2026-09-01 19:54:45 +02:00
ac6dffe51e test(errors): reunite the segmentation codes with the conformance suite
The registry file says one test per code and that this file IS the
conformance suite. During the 1-to-N voyage the byte-stability pin over
tests/ was the verification mechanism, so editing this file was
forbidden and six new codes were covered in the new segmentation
modules instead. That was right then. The end-to-end gate run released
the pin, so the convention applies again -- a conformance suite split
across two files stops being one quietly, which is why this was carried
as an obligation rather than a preference.

All seven segmentation codes now have a test here, including
segmentation_plan_unmatched from this session. The behavioural tests in
test_segmentation.py and test_segmented_inbox.py stay where they are:
they exercise the parser and the door, a different question from
whether every documented code has a raise site.

Measured after: 49 documented codes, 48 covered in this file. The one
gap, source_reference_unquotable, predates this work and is tested in
tests/test_okf_v0_2_profile.py:351 -- reported, not silently closed.

Also records both defects and this migration as closed in the decision
record's known-gaps list.

Co-Authored-By: Claude <claude-opus-5>
2026-09-01 19:31:51 +02:00
f65f5fc342 fix(inbox): a segment title is declared, never marked derived
A segment's title comes from the plan, so a human adjudicated it. But
structure derivation runs over the segment body, finds no title key and
no usable heading, falls back to a stem, and adds "title" to derived.
The concept then emitted a stated fact under an inferred marker, and a
consumer that distrusts derived fields would distrust exactly the thing
a human decided. An over-marked field is the same defect class as an
unmarked heuristic: the marker is only worth something if it is
accurate in both directions.

Scoped to title alone, and pinned that way by test: number stays in
derived on a segment, because nothing about segmentation makes an
inferred document number declared. Without a segment a derived title is
still marked, so only a plan makes a title declared.

The SEGMENTED_V1 golden moves, which is the intended consequence and
the only golden that may. The four existing goldens are byte-identical
to baseline 770d8d4, measured against the sha rather than inspected.

Co-Authored-By: Claude <claude-opus-5>
2026-09-01 19:29:46 +02:00
8f9b4c8cca fix(inbox): refuse a segmentation plan that matches no dropped file
A plan is selected by content hash, so a mistyped source_sha256 matched
nothing, every dropped file fell through to the one-concept rule, and
process_inbox returned an ordinary success over a flat bundle. The
operator asked for segmentation, got none, and had no error to read --
the silent skip this library refuses everywhere else. vegnormal-okf is
about to run an N500 corpus through this path, where a silent zero
would read as "the corpus has no concepts".

The refusal asks whether a covering plan was FOUND, not whether every
file was examined, so an unreadable drop cannot mask it; and coverage
is recorded at selection, not after path validation, so a matched plan
with a refused entry path still reports its own per-file code. The
first cut got that second question wrong and an existing collision test
caught it; the case is now pinned by its own test, verified red against
the earlier form.

New code segmentation_plan_unmatched, registered in the SegmentationError
docstring register in the same commit. Fail-fast before any disk
mutation. The four existing goldens are byte-identical to baseline.

Co-Authored-By: Claude <claude-opus-5>
2026-09-01 19:27:27 +02:00
36af65c0c9 docs(plan): record the end-to-end gate run and its one failure
The plan's Verification section held twelve gates that cross step
boundaries; /trekexecute checks per-step manifests and never ran them.
Run against the tree at 63565bd with the baseline at 770d8d4, before
any new commit, so the gates measure the delivered work and not
themselves.

Eleven passed. Gate 7 failed: one hunk in tests/test_profile.py, whose
closed field set on BundleProfile gained "segmentation" after
assumption A4 was falsified during execution and the edit ratified.
The gate's mechanism is byte-equality; the property it stands for was
measured directly instead — removing the member makes the test red, so
the assertion is a closed set with one more legitimate term, not a
loosened one. Recorded as failed rather than rewritten to pass.

Two mandatory known-positive controls fired: the zsh :e modifier ate a
path in git cat-file, and an unquoted for-loop did not word-split under
zsh. Both had produced a false absence that read as an answer.

Co-Authored-By: Claude <claude-opus-5>
2026-09-01 19:24:02 +02:00
63565bde98 feat(examples): SEGMENTED_V1 golden fixture and decision record 2026-09-01 00:31:15 +02:00
2cfb358b76 feat(tools): segmentation proposer emitting adjudicable PROPOSED entries 2026-09-01 00:28:41 +02:00
ba6e287585 test(inbox): rebuild equals incremental across a re-split with varying timestamps 2026-09-01 00:25:35 +02:00
894ccdf42f fix(inbox): retire the orphaned index when a directory's last segment goes
Retiring a directory's last concept left its index.md standing, so the
directory survived a rebuild that never creates it -- diff -r reports it as
'Only in ...'. Found by S7 once its fixture was made to actually retire a
segment: with round 2 a superset of round 1, S7 stayed green with retirement
disabled entirely.
2026-09-01 00:25:29 +02:00
91efd92612 feat(inbox): per-directory indexes with parent-to-child navigation 2026-09-01 00:21:05 +02:00
b9d776d1f0 feat(inbox): own concepts by source_file and retire stale segments 2026-09-01 00:17:04 +02:00
34a00b746f feat(inbox): collision gate over segment paths and additive concept reporting 2026-09-01 00:14:13 +02:00
bb79c55f43 feat(inbox): materialize one document into many concepts on hierarchical paths 2026-09-01 00:10:13 +02:00
f6e5ec9305 feat(inbox): mirror bundle_id and segment keys into concept frontmatter 2026-09-01 00:06:05 +02:00
224121f762 feat(inbox): root frontmatter emission at Door B with a caller-owned bundle_id 2026-09-01 00:04:09 +02:00
cd2c7517b0 feat(profiles): SEGMENTED_V1 profile and SegmentationPolicy capability 2026-09-01 00:02:14 +02:00
499253e53b feat(segmentation): slice canonical extracted text by declared spans 2026-08-31 23:59:05 +02:00
832e541fb9 feat(segmentation): key the adjudication cache on the extractor, not the hash alone 2026-08-31 23:57:49 +02:00
0a11c860ce feat(segmentation): segmentation plan data model and typed errors 2026-08-31 23:56:57 +02:00
770d8d4fbf docs(index): assess a consumer-controlled index ordering - accept, reshape
Order 20260827T123301Z-3790625395 asked for a judgement on a profile-named
`index.sort_key`, explicitly not for code. The finding is accepted and so is
the mechanism; the shape needs one correction.

Verified what the order asserts about us: Door C sorts its index on the
concept path and nothing else (`importer.py:421`, a plain `sorted(documents)`),
and no policy object anywhere in the chain can redirect it. `FacetPolicy`
governs what an entry CARRIES; nothing governs how entries are ARRANGED. A
consumer cannot reach this today, exactly as claimed.

The correction: naming a key is necessary and not sufficient, and the
insufficiency is where the requester's own measured gain lives. A sort needs a
key, a value and a COMPARATOR, and D5's formula seats only the first two.
Measured here: sorted() over N100.9/N100.10/N100.2/N100.20/N100.1/N99.1 puts
N100.10 before N100.2 and N99.1 after the whole N100 family. A `sort_key:
req_number` resolved through default ordering would therefore have produced a
different wrong order, not the 100.0% ascending over 24338 pairs that was
measured. The library already knows this one module over -- `_version_key`
(`structure.py:375`) exists for it and its comment says why.

So: three fields on `IndexPolicy`, not one on `FacetPolicy` -- `sort_key`,
`sort_order` from a closed set (`lexicographic` | `natural`, never a
caller-supplied callable, which would make emitted bytes depend on caller code
and break rebuild-equals-incremental), and `sort_missing`. Concept path stays
the final tie-break so the new ordering REFINES the existing total order rather
than replacing it; trading S8b determinism for retrieval quality would be the
worse failure.

Timing: build after the segmentation voyage lands, as a parameter on the
`_index_sort_key` seam Step 10 leaves behind, unifying both doors' index
writers in the same change. That seam is real and manifest-enforced, but it is
Door B's while this order is Door C's -- a profile naming an ordering that one
door honours and the other ignores would repeat the `IndexPolicy.per_directory`
trap.

Not claimed: that ordering explains the whole gap. T8-4 remains unexplained and
we attach no hypothesis to it either.

Order: 20260827T123301Z-3790625395-from-.claude

Co-Authored-By: Claude <claude-opus-5>
2026-08-31 23:35:25 +02:00
2337a328d9 fix(frontmatter): a nested key must not substitute for a top-level one
The line-oriented frontmatter grammar exists in three copies, each with the
duplication documented at its site: `materialize` reads a path, `structure`
needs a character offset, `profiles` returns body lines. All three keyed on
`key.strip()`, which discards the indentation that is the only thing telling
a nested key from a top-level one. An indented `title:` under a `sources:`
block therefore landed in the same flat namespace as the document's own
`title:` and, arriving later, won.

The failure is substitution, not omission. A dropped value is visible to
whoever reads the concept; a substituted one is not -- the document carries a
title that looks entirely right and belongs to something else. Because
`number` derives from `title` and `parent` derives from `number`, one
substitution walks the hierarchy. Measured, not inferred: a document titled
`N100.2` with a nested source titled `N200.7` came back as N200.7 with parent
N200 instead of N100.2 with parent N100.

Measured incidence across the two corpora, denominators stated:
`_okf-canonical` @ ad30107, 54 documents with parsable frontmatter, 49 carry
a nested key colliding with a top-level name (90.7%); `_okf-upstream` @
9a15b13, 66 documents, 58 collide (87.9%). The colliding key is `title`, and
often `resource` with it -- in `acme_retail/tables/orders.md` the concept's
own BigQuery resource pointer was replaced by a nested one. This is a fix
that clears observed damage, not a hardening without a witness.

The fix refuses indented lines; it does not read them. Block form stays
unreadable -- `sources` and `verified` still come back empty -- so D4's
flow-form emission rule is untouched and the structured reader is still D1b.
Two characterization tests that pinned the old behaviour now pin the new: the
block-list family still DROPS its value, and only the key-space pollution is
gone. That family is not otherwise addressed here.

Test first, red before the code was touched, with known-positive controls for
all three parsers so that a parser returning nothing could not pass.

Order: 20260830T000740Z-4733930312-from-.claude

Co-Authored-By: Claude <claude-opus-5>
2026-08-31 23:31:53 +02:00
087be0bc87 chore(gitignore): keep .claude/projects/ local-only
Session briefs and voyage baselines live under .claude/projects/; the
remote is public, so they follow the same local-only rule as STATE.md.
The brief-review round 1 flagged that this protection was sitting
uncommitted while the brief already references corpus filenames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhzrFYGvFMXBz6mF6D7DEn
2026-08-31 12:46:43 +02:00