Commit graph

19 commits

Author SHA1 Message Date
dfaf3cc134 feat(propose): Arm F, one unit fold behind a flag, measured against the operator's worksheet [skip-docs]
Order 20260908T133512Z-139864689-from-.claude. First iteration of the
per-file-type directive (operator 2026-09-08 13:05Z), not the last. No
threshold is set: ratifying a bar is the operator's, and setting one inside the
work that produces the measurement would be fitting the bar to the number.

[skip-docs] covers README.md only, and it follows a precedent re-measured this
round rather than quoted: `grep -c` for outline-run, table-grid, Arm C, Arm D
and Arm E returns 0 in README.md and CHANGELOG.md, while --path-prefix, a real
interface change, has a CHANGELOG entry. The rule is "interface and behaviour
changes yes, arm flags no", and --unit-fold is an arm flag that defaults off.
CLAUDE.md IS updated, because its `okf build` bullet enumerates which arms are
off there and would otherwise become false.

FUNN 1, and step 1 asked for it: the reproduction broke. Arm E on HEAD is
byte-identical to the archive on 31 of 33 plans; the two that differ are 2 of 2
spreadsheets in the corpus. The cause is EXTRACTION, not segmentation --
56ae274 writes a workbook as pipe tables, and the sample's price sheet extracts
to 11 048 characters where the worksheet records 100 694, which is the figure
that commit's own message predicts. The consequence is a segmentation
regression against the reference: K3 position 3 went 3 concepts -> 1 under both
Arm D and Arm E, where the operator wants eleven. The mechanism is the orphan
check dropping the sheet heading once a table opens below it (propose.py:461),
already reported there as a ranking regression. Doors unchanged: 43 .err, 4
FAILED, extractable 39/43.

THE MATCH CRITERION WAS WRITTEN DOWN BEFORE ANY CELL WAS SCORED, and it stalls
at 7/12 on the literal calibration gate after three rounds, each revision
recorded. The five failures are not the criterion's: at every one it agrees
with the operator's own (a), (b) or free text and disagrees only with (c).
Column (c) is a RELATIVE judgement ("closest today"); the four K3 categories
are absolute. The only way to reach 12/12 is to define "correct" as "the
closest arm", which reads (c) back out of itself. The dominance gate, declared
in advance as the second reading, holds at 11/12.

ARM F is one rule with three clauses derived from the operator's three, not
twelve special cases, and it only MERGES or DISCARDS: a run of at least
CONTENTS_RUN same-level page-numbered headings is a contents list and goes; a
heading deeper than the unit level folds into its parent, extending the
parent's span; a table folds back into the shorter heading that introduces it,
keeping the HEADING's name. K3 first rater, n=12: 2 coarse / 5 fine / 0
duplicate / 5 correct -- best of four arms, ceiling was 4, two moved, nothing
regressed anywhere.

THE PAPER MEASUREMENT CAME FIRST AND FALSIFIED THE FIRST VERSION. Clause 2 was
letting rule:outline -- Arm D's RECOVERY of an integer numbering run -- vote on
the unit level, which took K3 positions 1, 7 and 9 to 3, 4 and 7 concepts
instead of 17, 34 and 11. A recovered numbering is a heuristic, not a level a
document declares, and the unit worksheet showed the operator ATX and dotted
headings only. Fixed with its own red test; 11 of 12 predictions correct after.

PER FILE TYPE, which is the directive: docx 3 of 3 (solved on this sample), pdf
2 of 8 (lags, unchanged by Arm F, and the remainder is decomposed per position
rather than left as one number), xlsx 0 of 1 (regressed, see FUNN 1). Outside
the corpus, n=1 each: pptx and odt byte-identical, rtf proposes nothing either
way, txt differs and exposes clause 2's fallback.

CONTENTS_RUN swept 1..5 and off. Distance prefers 1; three ships anyway,
because at 1 the body chapter "... i henhold til TEK 17" is deleted for ending
in a number, and no K3 cell differs between 1 and 4 -- the metric prefers a
value that provably deletes a chapter and cannot see the cost.

Whole corpus, all 43 through arm_run in ascending foreground chunks: 32 plans,
491 entries against Arm E's 679, 14 documents changed, 1 plan disappeared
entirely (three drawing-schedule numbers that clause 1 correctly reads as a
contents run) and that is reported rather than special-cased.

THE okf build MECHANISM IS REPRODUCED AND IT IS NOT DOOR B: cli.py calls the
proposer with no arm flag at all, so the shipped build path is Arm B. On a
tender PDF that means no boundary where Arm D finds nine and the reference says
nine. Largest per-file-type gap this round found; it is a default change and
therefore the operator's.

5 tests red first, 1373 -> 1379. ruff clean, mypy --strict clean on 17 files.
K2 consumer bundle unchanged: 1108 files, digest 9cd74519... with the flag off.
No bundle built, no version bump, no tag, no push.

Report: docs/2026-09-08-k3-arm-f-mot-enhetsarket.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:28:41 +02:00
56c1205ec4 fix(consume,propose): hold an identifier number as one token, give an orphaned heading's name to its table
Two consumer-reported defects, one rebuild.

The pre-pass could not see a requirement number: `_TOKEN_SPLIT_RE` split
`10.2-2` into digit runs and `MIN_TOKEN_LENGTH` removed them, so a question
naming a requirement reached the ranker carrying only the word every concept
in a standards bundle carries. Measured on three real bundles (446, 1133 and
270 concepts), the named requirement was withheld `below_k` in three of three.
Numeric groups joined by `.` or `-` are now held together, dash variants fold
to the ASCII hyphen, and the noise floor is unchanged. The gold moves from
160 to 96, 143 to 9 and 100 to 35 -- a large move, and NOT a delivery: it is
still `below_k`, because `_overlap` is a count and an exact requirement number
is worth no more than a common verb. That weighting is a separate decision.

The rule was narrowed by a measurement: a version that joined alphanumeric
groups swallowed a document slug whole and cost a hit@8 row. An equality-only
variant was measured on all three bundles and falsified -- better on one,
worse on two.

The orphan gate destroyed a heading's name: a table opening directly below a
heading left that heading with an empty body, the orphan check dropped it, and
the surviving table block kept the mechanical `Tabell linje <n>`. A table that
orphans its heading now takes that heading's title and section number.
Conditioned on the drop, on adjacency, and carrying both members -- each of
the three measured or mutation-tested.

One K2 rebuild for both, from a frozen source tree: 629 concepts, `39 + 4 = 43
= N`, 2 of 629 ids moved and both moved BACK to the names the 2026-09-03
bundle carried, 1106 of 1108 files identical to it. New ref
sha256-tree:2f82fcfea91c3bd3f8ef7147f80cd613227d3ca7975c41d88810233f3f79ab4b
-- c26eed6a... is superseded. The regression the previous session measured is
closed: candidate rank 19 -> 10, and the delivering command is now
`--cost-vocabulary --k 12` inside the default budget at 58 907 o200k against
65 912 before. The specific question is unmoved at rank 1.

The tokeniser alone leaves the K2 control question byte-identical, measured
with the bundle held fixed and both published byte counts reproduced.

Report: docs/2026-09-08-kravnummer-tokenisering.md. 8 new tests, red first;
6 mutations, 6 red, one of them only after the survivor was read as code and
a missing fixture was added. Suite 1287 -> 1295.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:43:39 +02:00
c1d0ba237d feat(propose): --table-grid selects Arm E, absent is off [skip-docs]
The flag threads through `run` and `main` and takes no argument. Arm D's gate
is a run LENGTH where 0 means off; Arm E has no numeric parameter, so a boolean
is the honest shape and an integer would only manufacture a sweepable knob that
means nothing. `run` therefore adds no numeric validation, and the help says
why.

Both prose sites that enumerate the arms `okf build` does not expose are
updated: `src/llm_ingestion_okf/cli.py` and `CLAUDE.md`. The second was found
by review, not by grep of the first -- the same claim lives in two files and
only one of them is code.

The generalised attribution test earned itself in this commit. The first draft
of the Arm E help contained "byte-identical to Arm D -- Arm D rather than Arm
B", and argparse's rendering plus the test's ` --` chunk split meant the
attribution fell OUTSIDE the `table-grid` chunk. The test went red with the
truncated chunk printed, which is exactly the failure it exists to catch: a
whole-output grep would have been satisfied and the attribution would have been
unfindable in the option it belongs to. The clause is now parenthesised.

Arm C's marker check in `tests/test_cli_build.py` gains `rule:table-grid` and
is renamed to speak of all three arms, measured on the artifact rather than on
the flag: a flag `okf build` never passes is not evidence about what it emits.

[skip-docs] is the MEASURED precedent, not a convenience. `grep -c` for
"outline-run", "max-segment-chars", "Arm C" and "Arm D" returns 0 in both
README.md and CHANGELOG.md: an arm flag is documented in its constant's `#:`
comment, in `--help`, and in the round's measurement report, and it is off by
default so it makes no promise to a consumer. `--path-prefix`, which is a real
interface change, does have a CHANGELOG entry. The rule this follows is stated
at docs/2026-09-07-k3-arm-d.md: "interface and behaviour changes yes, arm flags
no." Arm E's report is docs/2026-09-07-k3-arm-e.md, later in this round.

Tests first: 3 red, then green (a fourth, the no-argument test, is honest in
its docstring that it is green before the flag exists too, because argparse
rejects an unknown option with the same code; it becomes evidence only once
the flag is real). 1248 -> 1251.
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>
2026-09-07 10:53:59 +02:00
b293977ebf feat(propose): a joined block names the grid rule beside the table rule
`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>
2026-09-07 10:51:16 +02:00
7dae0d1a3a feat(propose): a grid rule line does not close an open table block
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>
2026-09-07 10:49:52 +02:00
dc67f86511 feat(propose): name the grid-rule grammar Arm E measures
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>
2026-09-07 10:47:36 +02:00
45f1013308 test(propose): count the arm attribution per option chunk, not per output
`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>
2026-09-07 10:46:10 +02:00
3fa772e917 test(propose): pin today's grid-table split with a golden that can fire
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>
2026-09-07 10:45:05 +02:00
4ce14ae5dd feat(cli): okf build, one installed command for folder in, bundle out
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>
2026-09-07 05:06:33 +02:00
aa87eb8818 feat(inbox): walk the drop directory recursively
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>
2026-09-07 04:11:00 +02:00
6e2238454c feat(propose): --outline-run selects Arm D, default 0 is off 2026-09-07 01:34:59 +02:00
5080240366 feat(propose): the outline rule proposes boundaries, off by default 2026-09-07 01:32:20 +02:00
47ae1ed7e3 feat(propose): Arm D reads a document's own numbered outline 2026-09-07 01:23:29 +02:00
c97e250f69 test(propose): pin the default artifact with a committed golden 2026-09-07 01:20:31 +02:00
0c7a485c11 feat(propose): Arm C cuts an over-long span at a paragraph boundary
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>
2026-09-04 17:06:58 +02:00
bca722fa85 fix(tools): nothing to propose writes no artifact and is not a failure
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>
2026-09-03 04:17:54 +02:00
a5e129d413 feat(tools): the proposer scopes a document's segments under a caller's prefix
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>
2026-09-03 04:02:40 +02:00
c54e8383df feat(segmentation): quote anchors so a re-extraction costs a re-anchor 2026-09-02 14:42:16 +02:00
2cfb358b76 feat(tools): segmentation proposer emitting adjudicable PROPOSED entries 2026-09-01 00:28:41 +02:00