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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-07 10:53:59 +02:00
commit c1d0ba237d
5 changed files with 87 additions and 10 deletions

View file

@ -245,8 +245,8 @@ and fixtures, never code.
recursively, and two same-named documents in different folders must not
collide); `--ingested-at` and `--proposed-at` default to one shared epoch
constant rather than the clock, because a wall-clock default takes
rebuild-equals-incremental away from anyone who omits them. Arm C and
Arm D are off and not exposed here.
rebuild-equals-incremental away from anyone who omits them. Arm C, Arm D
and Arm E are off and not exposed here.
- Consume a bundle: `python3 tools/okf_consume.py <bundle> --question "<q>"
[--k N] [--limit N] [--out PATH] [--ref IDENTITY]` — the **pre-pass**
`docs/consumption-contract.md` § 1 defines, and the only reading direction

View file

@ -36,10 +36,11 @@ time passes one.
## What it does not decide
Arm C (`--max-segment-chars`) and Arm D (`--outline-run`) are OFF here and are
not exposed: they are measurement arms, both off by default by operator
decision, and a build command is not where an unadjudicated segmentation
heuristic should become one flag away. `tools/` still reaches them.
Arm C (`--max-segment-chars`), Arm D (`--outline-run`) and Arm E
(`--table-grid`) are OFF here and are not exposed: they are measurement arms,
all off by default by operator decision, and a build command is not where an
unadjudicated segmentation heuristic should become one flag away. `tools/`
still reaches them.
"""
from __future__ import annotations

View file

@ -695,6 +695,7 @@ def run(
path_prefix: str = "",
max_segment_chars: int = 0,
outline_run: int = 0,
table_grid: bool = False,
) -> int:
if max_segment_chars < 0:
raise ProposerError(
@ -745,6 +746,7 @@ def run(
path_prefix=scope,
max_segment_chars=max_segment_chars,
outline_run=outline_run,
table_grid=table_grid,
)
# Nothing to propose is an OUTCOME, and it is not an artifact. An empty
# plan cannot be replayed -- `process_inbox` refuses one, because a plan
@ -822,6 +824,22 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
"the K3 method file does not name it either"
),
)
parser.add_argument(
"--table-grid",
action="store_true",
help=(
"Arm E: do not let a pandoc grid-table rule line (`+---+---+`, and "
"`+===+===+` under a header) close an open table block. The table "
"grammar cannot match a rule line, so without this one grid table "
"becomes one concept per row group. Absent (the default) is OFF and "
"leaves the artifact byte-identical to Arm D (Arm D rather than "
"Arm B, because Arm E is defined on top of it). A boolean and not a "
"number: the rule has no parameter to sweep. Arm E is the author's "
"definition, written for order 20260907T075834Z-18584396-from-.claude; "
"it is not defined upstream, and the K3 method file does not name it "
"either"
),
)
parser.add_argument(
"--proposed-at",
default="1970-01-01T00:00:00Z",
@ -841,6 +859,7 @@ def main(argv: list[str] | None = None) -> int:
path_prefix=args.path_prefix,
max_segment_chars=args.max_segment_chars,
outline_run=args.outline_run,
table_grid=args.table_grid,
)
except ProposerError as exc:
print(f"{PROPOSER_ID}: FAILED - {exc}", file=sys.stderr)

View file

@ -238,10 +238,12 @@ def test_the_bundle_carries_the_adjudication_layer_the_plans_produce(tmp_path: P
def test_arm_c_and_arm_d_are_off_unless_asked_for(tmp_path: Path) -> None:
"""Both arms stay off by default -- measured on the artifact, not the flag.
"""Every measurement arm stays off by default -- measured on the artifact.
`rule:size-split` and `rule:outline` are the markers the two arms write
into a plan's `derived` list, so their absence is the arms being off.
`rule:size-split`, `rule:outline` and `rule:table-grid` are the markers the
three arms write into a plan's `derived` list, so their absence is the arms
being off. Named on the artifact rather than on the flag, because a flag
`okf build` never passes is not evidence about what it produces.
"""
inbox = inbox_with_subdirectories(tmp_path)
plans = tmp_path / "plans"
@ -253,6 +255,7 @@ def test_arm_c_and_arm_d_are_off_unless_asked_for(tmp_path: Path) -> None:
text = path.read_text(encoding="utf-8")
assert "rule:size-split" not in text
assert "rule:outline" not in text
assert "rule:table-grid" not in text
def test_segments_off_builds_a_flat_bundle_without_a_bundle_id(tmp_path: Path) -> None:

View file

@ -1061,7 +1061,7 @@ def test_a_non_integer_outline_run_is_refused_by_argparse(
#: Arm C is 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.
ARM_ATTRIBUTION = {"outline-run": True, "max-segment-chars": False}
ARM_ATTRIBUTION = {"outline-run": True, "table-grid": True, "max-segment-chars": False}
def test_each_arm_flag_carries_its_attribution_inside_its_own_option_chunk(
@ -1483,3 +1483,57 @@ def test_a_split_part_of_a_joined_block_names_all_three_rules_in_order(tmp_path:
okf_propose_segments.RULE_TABLE_GRID,
okf_propose_segments.RULE_SIZE_SPLIT,
]
def propose_arm_e(tmp_path: Path, text: str, name: str = "arm-e-cli.md", *flags: str) -> bytes:
"""Arm E through the CLI. Bytes, not a dict: the artifact is the promise."""
source = write(tmp_path, text, name)
out = tmp_path / f"{name}.json"
code = okf_propose_segments.main([str(source), "--out", str(out), *flags])
assert code == 0, f"proposer exited {code}"
return out.read_bytes()
def test_the_flag_absent_is_byte_identical_and_present_changes_the_bytes(
tmp_path: Path,
) -> None:
"""Absent is off; present is not a no-op. Both halves, same text."""
absent = propose_arm_e(tmp_path, GRID_TABLE, "absent.md")
present = propose_arm_e(tmp_path, GRID_TABLE, "present.md", "--table-grid")
assert absent != present
# And absent is the pre-Arm-E artifact, not merely "some other bytes":
# three entries, none of them naming the join.
assert json.loads(absent)["entries"].__len__() == 3
assert "rule:table-grid" not in absent.decode("utf-8")
assert json.loads(present)["entries"].__len__() == 1
def test_arm_d_and_arm_e_are_independent(tmp_path: Path) -> None:
"""Neither arm may quietly imply the other."""
d_only = propose_arm_e(tmp_path, GRID_TABLE, "d.md", "--outline-run", "3")
both = propose_arm_e(tmp_path, GRID_TABLE, "both.md", "--outline-run", "3", "--table-grid")
assert d_only != both
e_only = propose_arm_e(tmp_path, GRID_TABLE, "e.md", "--table-grid")
e_zero = propose_arm_e(tmp_path, GRID_TABLE, "e0.md", "--outline-run", "0", "--table-grid")
assert e_only == e_zero
def test_table_grid_takes_no_argument(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Boolean, not an integer pretending to be one.
Arm D's gate is a run LENGTH and 0 means off; Arm E has no numeric
parameter, so inventing one would create a sweepable knob that means
nothing. `usage:` in stderr is what separates argparse's refusal from the
tool's own `ProposerError` exit, which is also 2.
Honest about its own limits: this test is green BEFORE the flag exists too,
because argparse rejects an unknown option with the same code. It becomes
evidence only once `--table-grid` is a real flag, and what it then pins is
that the flag stayed boolean.
"""
source = write(tmp_path, GRID_TABLE, "noarg.md")
out = tmp_path / "noarg.json"
with pytest.raises(SystemExit) as exit_info:
okf_propose_segments.main([str(source), "--out", str(out), "--table-grid", "3"])
assert exit_info.value.code == 2
assert "usage:" in capsys.readouterr().err