`build_plan` gains the keyword-only `table_grid` and threads it into
`find_candidates`, and the `derived` composition becomes an ordered build in
`_derived_names` rather than a two-branch conditional expression. Four
combinations exist now, and the order is itself a claim: the marker, the rule
that OPENED the span, Arm E's join, then Arm C's cut.
The test that earns its keep here is
`test_a_split_part_of_a_joined_block_names_all_three_rules_in_order`. Arm C
rebuilds every part as a fresh frozen `Candidate` from an explicit keyword
list, so a field not copied there is silently defaulted back -- the spans stay
joined, the plan looks right, every diff-based control passes, and only the
rule name is missing. That failure has no other detector.
The module docstring's claim that "each entry names exactly one" rule is
corrected rather than deleted: it is the ORIGIN that is single, not the list.
`tests/test_propose_segments.py`'s `len(rules) == 1` is deliberately left
alone -- it runs on the default fixture, where the original claim is still
true, and widening it would weaken a true assertion about Arm B.
Tests first: 5 red, then green. 1243 -> 1248.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/:
27 files, Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arm E, off by default. `find_candidates` gains a keyword-only `table_grid`
whose branch is not even evaluated when it is False, so the flag-off path is
byte-identical by construction rather than by test.
The defect it addresses is measured. The converter emits pandoc GRID tables,
whose rows are separated by `+---+---+` rule lines that `_TABLE_ROW` cannot
match, so `in_table` resets between every pair of rows and ONE table becomes
one concept per row group. On the K2 corpus that is 33 of Arm D's 709 entries,
on exactly 3 of the 33 documents that produce a plan -- and those three are the
K3 sample positions 5, 10 and 11, all three rated `too fine`.
Three points where this could have gone silently wrong, and what each cost:
- `Candidate` is frozen and `dataclasses.replace` is not imported, so the join
is recorded as a `set[int]` over `marked` and applied at the orphan-check
pass that already rebuilds every candidate. `subdivide` rebuilds them again
from an explicit keyword list, so `grid` is copied there too -- exactly the
trap `split` already has.
- `rule_pending` is cleared on the fall-through together with `in_table` and
`open_block`. A grid table ends with a bottom rule, which sets it; without
the clear, the NEXT table's first row would be recorded as a join although
nothing was joined. `test_two_tables_separated_by_a_blank_line_stay_two_concepts`
is built to catch precisely that: its second table has ONE row group, so it
cannot be joined, and the test asserts `grid is False` on it. A test that
checked only candidate counts would stay green through the defect.
- A rule line can never OPEN a block: it is reached only with `in_table` true.
So no surviving candidate's `start` moves, bodies only grow, and the orphan
check -- which is monotone in the line set -- cannot drop a candidate it
previously kept. Asserted as an offset, not a length.
Measured on the real corpus with this code, reproducing a prediction written
down before it was built: 21 -> 6, 15 -> 3, 2 -> 1 entries, and identical at
--outline-run 0 and 3, so Arm D and Arm E do not interact here.
Tests first: 8 red, then green. 1235 -> 1243.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/:
27 files, Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two constants, no behaviour. `RULE_TABLE_GRID = "rule:table-grid"` joins
`RULE_NAMES`, and `_GRID_RULE` sits beside `_TABLE_ROW` as the grammar of a
pandoc grid-table rule line.
The rule is the author's, not upstream's, and the constant says so: `grep -c -i
"arm" docs/2026-09-02-k3-k4-k5-metode.md` is 0, so the definition was written
for order 20260907T075834Z-18584396-from-.claude. Its axis is a third one --
Arm C names SIZE, Arm D names what the DOCUMENT declared, and this names what
the CONVERTER emitted.
The character class `[-=:+]` is measured, not guessed. Across the three
grid-bearing documents of the K2 corpus, 38 of 38 lines whose stripped form
starts with `+` match this pattern, and those four characters are the complete
set occurring on them. The `:` is pandoc's column-alignment marker and is load
bearing: a first pass with `[-=+]` matched 37 of 38, and through that single
miss read one document as having two tables where it has one. The `\s*` on
both ends mirrors `_TABLE_ROW` because the loop iterates
`splitlines(keepends=True)` -- every line carries its `\n`, and an indented
rule line is a real shape.
Tests first: 2 red, then green. 1233 -> 1235.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/: 17 files,
Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_the_help_attributes_arm_d_inside_its_own_option_chunk` ended with
`assert squeezed.count("not defined upstream") == 1`, with the comment "the
literal belongs to Arm D alone". That was true when it was written and it is a
guard worth keeping -- but it goes red the moment a SECOND arm is attributed
the same way, on an axis that has nothing to do with whether the new arm is
correct. Left as it was, the cheapest way past it during the Arm E round would
have been to weaken Arm E's attribution, which is the opposite of what the
guard is for.
The assertion is now derived from a declaration:
ARM_ATTRIBUTION = {"outline-run": True, "max-segment-chars": False}
Per chunk, an arm either carries the literal or must not; the whole-output
count is `sum(attributed)`. Arm C stays False on purpose -- its help says "not
defined in the K3 method file" instead, and the point of the check is that
each arm's attribution sits in its OWN chunk rather than anywhere in the file.
Semantically identical this commit (1 == 1). It is committed on its own, before
the flag it unblocks exists, so the generalisation is visible as a deliberate
change rather than as collateral inside a feature diff.
Tests first: not applicable -- this commit's product IS the test, and its
correctness is that it stays green against unmodified production code.
1233 -> 1233. ruff check: exit 0. ruff format --check: exit 0. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The K3 Arm E round (order 20260907T075834Z-18584396-from-.claude) measures a
rule that has not been written yet. This commit pins what the code does TODAY,
so the later claim "the default did not move" rests on a committed artifact
rather than on reading a diff.
Three characterization tests and one new golden fixture. Every count they
assert was measured against unmodified code before the test was written:
GRID_TABLE yields 3 candidates, TWO_TABLES_BLANK_SEPARATED yields 3,
TWO_TABLES_RULE_SEPARATED 2, PIPE_TABLE 1, PROSE_WITH_A_STRAY_RULE 1.
The second golden is not duplication. `propose-golden-default.json` is taken
over `OUTLINE_DOCUMENT`, which contains no `|` row and no `+` rule line, so no
table rule -- present or future -- can move its bytes. It is a guard that
cannot fire. `propose-golden-grid-default.json` is taken over a document that
has a pandoc grid table, and it is generated here from PRE-Arm-E code: a
fixture recorded after the change would pin post-change bytes and prove
nothing about the default.
Two fixture shapes are load bearing and say so in the band's comment. The
second table in TWO_TABLES_BLANK_SEPARATED has exactly ONE row group, so it
cannot be joined -- that is the only shape that catches a join whose pending
state is never cleared. And `propose()` is excluded from this band: it pops
`source` and discards every other kwarg, so an Arm E test written through it
would silently measure the default and pass.
Tests first: 1 red (the golden did not exist), then green. 1230 -> 1233.
ruff check: exit 0. ruff format --check: exit 0. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now "run the door over a folder" was a shell loop over two scripts
under `tools/`, with nine flags between them and a `--path-prefix` rule
that lived in a code block in a measurement report. Neither script was
packaged (`pyproject.toml` ships `src/llm_ingestion_okf` only), so the
path the published K1/K2 numbers were measured on was reachable from a
clone and nowhere else.
`okf build <folder> --bundle <dir>` is that path, packaged, declared as a
console script and installed with the wheel. It is orchestration only:
the proposer and the corpus harness MOVED into the package
(`llm_ingestion_okf.propose`, `llm_ingestion_okf.corpus`) and the two
`tools/` scripts became thin entry points to them, so the published
reproduction blocks still run and there is exactly one implementation of
each rule. Neither move adds a dependency or a model call.
Two decisions belong to this layer and are stated where they are made.
A document's proposed paths are scoped by its RELATIVE PATH minus the
extension, not its basename: the door walks recursively now, and two
documents named alike in different folders would otherwise collide on a
path Door B is supposed to make impossible rather than merely detect.
And omitted timestamps do not come from the clock -- `--ingested-at` and
`--proposed-at` default to one shared epoch constant, because a
wall-clock default would put a changing byte in the artifact and take
rebuild-equals-incremental away from every caller who did not pass them.
Arm C and Arm D stay off and are not exposed here.
Measured on the 43-file K2 corpus, one invocation against the two-script
bundle of 2026-09-03: N = 43 computed, merged 39/43, coded rejections
4/43 (`extractor_unknown` 3, `extractor_empty_pdf` 1), K1b 39 + 4 = 43,
exit 0, 779.43 s. 1107 of 1108 files byte-identical. The one that
differs is the root `index.md`, by exactly the `log.md` link a commit
fifteen hours younger than the stored artifact adds -- appending that
line to the stored file reproduces the new one byte for byte. Against
the two scripts at THIS commit the trees agree in full, which is what
the byte-identity test holds.
Suite 1127 passed after `git add` (1113 before), mypy --strict clean,
ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Door B listed `inbox.iterdir()` and kept only top-level files. A file in a
subdirectory was neither ingested nor refused: it appeared in none of the
result's buckets, so a nested drop produced a bundle that was silently short
of what was dropped and no count said so. That broke the K1b identity for any
inbox with folders in it. Operator decision 2026-09-06.
- `walk_inbox` is the ONE walk rule, shared with `tools/okf_corpus_run.py`:
the denominator N is now counted over exactly the set of files the door
ingests, rather than over a second listing that happened to agree.
- Sorted on the whole relative path, not the basename, so the order is a
function of the tree; that is what keeps rebuild-from-scratch byte-equal to
an incremental update.
- A concept's `source_file` is the path relative to the inbox root,
`/`-separated. The concept NAME still comes from the basename, so two
folders holding one basename hit the existing §3 collision refusal instead
of one silently claiming the other's concept.
- Dot-directories and a bundle directory inside the inbox are skipped with a
CODE, in a new `InboxResult.skipped`. Recursion makes the door's own output
reachable as its own input; a silent skip would be the same
absence-without-a-denominator defect one level down.
- `--path-prefix` reduces per component and rejoins with `/`, so the caller
driving a nested corpus can carry the relative directory. Reducing the whole
string folded the separator into a `-` and flattened `sub/sub2`.
`tests/test_inbox_flow.py::test_subdirectories_are_not_walked` asserted the
opposite and is superseded in place, with the reason written down.
Measured on the K2 corpus (flat, N=43): 39/43 merged, 4 coded, K1b holds. The
bundle digest is
`1472e98aec8643c5beee540f4c42b5e437bd26e7c61d69a91bcff799f06a6d13` over 1108
files -- byte-identical to a run of the same corpus at 190086f WITHOUT this
change (`diff -r` exit 0), so recursion costs a flat inbox nothing. It differs
from the stored 2026-09-03 artifact by one line in `index.md`
(`- [Corpus run history](log.md)`), which 95eb271 added 15 hours after that
bundle was built.
Suite 1113 passed, `ruff` clean, `mypy --strict src/ tools/` clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arm C is NOT defined in docs/2026-09-02-k3-k4-k5-metode.md -- that file
contains no occurrence of the word, and neither Arm A nor Arm B is defined
there either. The definition implemented here was written for order
20260904T145630Z and is reported as the author's, never as a ratified one.
Arm C = Arm B's mechanical rules, plus one deterministic rule that cuts
any proposed span longer than a declared cap at the nearest paragraph
boundary at or before it, the whole document counting as one span when
the rules find no boundary at all.
One rule and not two, on purpose. The two failure modes the K2 rebuild
measured -- a PDF with no outline (Bilag 9.1, 217 472 characters) and a PDF
whose headings are its table of contents, so the trailing segment absorbs
the body (Bilag 3.1, Bilag 1) -- are the same failure of SIZE, and a second
rule aimed at each would confound which one moved the number.
`--max-segment-chars` defaults to 0, which is OFF: the artifact is then
byte-identical to Arm B's, pinned by a test that writes both and compares
bytes. The standard profile does not move, and the K2 bundle a consumer is
running against right now is not rebuilt.
What Arm C deliberately does NOT change: the region before the first
candidate is still covered by no segment. That is a real coverage defect --
the K3 baseline's blind rater named it -- and fixing it here would put two
changes behind one measurement.
A part carries TWO rule names in `derived`: the heading rule that opened the
span, and `rule:size-split` for the cut. Dropping the first would make a
part traceable to arithmetic and nothing else.
Tests first: 13 red, then green. 1055 -> 1068.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the K2 corpus 2026-09-03: 11 of 39 documents proposed zero
segments -- overwhelmingly PDFs with no declared structure, which Topic 1b
had already measured at 23 of 33. The proposer wrote an artifact for each
of them and exited 0.
An empty plan cannot be replayed. `process_inbox` refuses one by design,
because a plan naming no entry would persist nothing for a document that
was dropped, so the only thing a zero-entry file can do is fail a run
later -- and it did: the first segmented corpus run stopped on
`segmentation_plan_invalid` before writing a single concept.
Exit 1 with no artifact, distinct from 2, so a driver can tell "this
document lands as one flat concept" from "stop". The orphan check's test
now observes the same property through the status.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the K2 corpus 2026-09-03: 39 documents proposed 618 entries
under 601 distinct paths -- 17 paths were claimed by two documents each.
Section numbering is document-local (`1 Innledning` is in most procurement
documents), so this is structural, not unlucky. Every collision reaches
Door B's gate, which refuses per DOCUMENT, so those documents would land as
coded rejections rather than concepts and a corpus run could not be built
at all.
`--path-prefix` is an argument and not something the tool derives: the
proposer sees ONE document and cannot know what else is in the bundle. It
is reduced to the id grammar before anything is read, and a prefix that
reduces to nothing is refused rather than silently producing the unscoped
paths the caller asked to avoid. Without the flag every artifact already
produced is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>