llm-ingestion-okf/tools/okf_table_measure.py
Kjell Tore Guttormsen b1977c27ac feat(tools): a re-measurable grid-table reach instrument for K3 [skip-docs]
`tools/okf_table_measure.py` answers how far Arm E's join reaches and what it
costs, and it imports `find_candidates`, `_GRID_RULE`, `_TABLE_ROW` and both
rule names from the shipped module rather than carrying a copy.
`tests/test_table_measure.py` pins that with `is`, not `==`: `re.compile`
returns a distinct object for an equal pattern, so equality would be satisfied
by a pasted literal and only identity catches it. `draw_sample` is imported
from `okf_outline_measure` for the same reason -- one K3 draw in the
repository, not two that can disagree.

The column this round actually needs is the `|`-row count, for EVERY file
including the ones the door refuses. It is the ceiling: a document with no
table row cannot be moved by this arm. Asserting the ceiling from entry counts
instead would assume the orphan check kept every table candidate, which nobody
measured -- and one of the K3 sample documents produces no plan at all, so its
zero would be an absence with no denominator.

Constants split the way `okf_outline_measure.py` splits them. 38 grid-rule
lines across 3 documents is a DECLARED expectation measured before the rule was
written; a different value means the shipped grammar is not the measured one
and no figure below it may be read. The Arm D entry totals are REFERENCE
values, printed beside the measured ones and gating nothing.

A file the door refuses becomes a `Row` with its reason named rather than a
missing row, because `extractable / len(rows)` is the door count this round
reports as 39/43 and a silent drop would make it unmeasurable.

Tests first: 7 red (the module did not exist), then green. 1251 -> 1258.
[skip-docs] on the same measured precedent as the flag commit: `grep -c` for
"okf_outline_measure" and "okf_cid_measure" in README.md returns 0. A
measurement instrument is documented by its own docstring and by the round's
report, and it never enters the wheel.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/:
28 files, Success. pytest -q: exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:56:12 +02:00

261 lines
11 KiB
Python

"""Measure how far Arm E's grid-rule join reaches into a corpus. Measure, don't build.
Order 20260907T075834Z-18584396-from-.claude. `docs/2026-09-07-k3-arm-d.md`
moved K3's first-rater row from 8/4/0/0 to 4/5/0/3 and left five documents at
`too fine`. Three of those five are dominated by `rule:table-block`, because the
converter emits pandoc GRID tables whose rows are separated by rule lines the
table grammar cannot match -- so one table becomes one concept per row group.
Arm E stops a rule line closing an open block. This instrument answers how far
that reaches, and what it costs.
It builds nothing. No new rule, no extractor, no bundle, no threshold -- the
threshold is the operator's, and only once an arm moves the number.
**It imports the shipped functions rather than re-implementing them.** An
instrument carrying its own copy of the grammar measures a second definition
that can drift from the tool's without a test going red, and every figure it
publishes would then be about code nobody ships. `tests/test_table_measure.py`
pins the identity with `is` rather than `==`, because `re.compile` returns a
distinct object for an equal pattern and a pasted literal would satisfy `==`.
## What is an expectation here and what is not
**38 grid-rule lines across 3 documents is the declared expectation.** It was
measured on this corpus before the rule was written, and a different value means
the shipped grammar is not the measured one -- no figure below it may be read.
**The `|`-row column is the CEILING, and it is a measurement, not a reference.**
Arm E cannot move a document that carries no table row. Asserting the ceiling
from entry counts alone would assume the orphan check kept every table
candidate, which nobody measured; this column counts rows in the text itself,
for every file including the ones the door refuses.
**Entry counts are reference values**, printed beside the measured ones and
gating nothing.
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from okf_outline_measure import draw_sample # noqa: E402
from llm_ingestion_okf.errors import ExtractionError # noqa: E402
from llm_ingestion_okf.extract import extract_text # noqa: E402
from llm_ingestion_okf.propose import ( # noqa: E402
RULE_TABLE_BLOCK,
RULE_TABLE_GRID,
_GRID_RULE,
_TABLE_ROW,
find_candidates,
)
#: The gate Arm D is measured at, and the value Arm E is measured ALONGSIDE.
#: Declared, not swept -- it belongs to Arm D and is reproduced here so the two
#: arms are compared on the same outline.
RUN_LENGTH = 3
#: The declared expectation, measured on this corpus before the rule existed. A
#: different value means the shipped grammar is not the grammar that was
#: measured, and no figure below it may be read.
EXPECTED_GRID_RULE_LINES = 38
EXPECTED_GRID_DOCUMENTS = 3
#: Reference values only -- the Arm D corpus totals, printed beside the measured
#: ones and never compared against them.
REFERENCE_ARM_D_ENTRIES = 709
REFERENCE_TABLE_BLOCK_ENTRIES = 33
@dataclass(frozen=True)
class TableMeasurement:
"""One document, both sides of the join."""
name: str
#: Lines matching the SHIPPED table grammar. This is the ceiling column: a
#: document with none of these cannot be moved by Arm E, whatever the
#: orphan check later did to its candidates.
pipe_rows: int
#: Lines matching the SHIPPED grid-rule grammar.
grid_rule_lines: int
#: Table-block candidates surviving the orphan check, before and after.
blocks_before: int
blocks_after: int
#: Blocks Arm E ACTUALLY joined -- not blocks whose span contains a rule
#: line. A single-row grid table has a rule line and joins nothing.
joined_blocks: int
#: All candidates, not only table blocks: what the plan would carry.
entries_before: int
entries_after: int
def line(self) -> str:
return (
f"| {self.name} | {self.pipe_rows} | {self.grid_rule_lines} "
f"| {self.blocks_before} | {self.blocks_after} | {self.joined_blocks} "
f"| {self.entries_before} | {self.entries_after} |"
)
@dataclass(frozen=True)
class Row:
"""A corpus file: measured, or a stated reason it was not.
A refused file stays in the list rather than being dropped, because
`extractable / len(rows)` is the door count this round reports and a
silently missing row would make it unmeasurable.
"""
name: str
measurement: TableMeasurement | None
skip_reason: str | None
def line(self) -> str:
if self.measurement is None:
return f"| {self.name} | -- | -- | -- | -- | -- | -- | -- |"
return self.measurement.line()
def measure_document(text: str, name: str, run_length: int = RUN_LENGTH) -> TableMeasurement:
"""Both sides of the join for one document, from the SHIPPED functions."""
lines = text.splitlines(keepends=True)
before = find_candidates(text, outline_run=run_length)
after = find_candidates(text, outline_run=run_length, table_grid=True)
return TableMeasurement(
name=name,
pipe_rows=sum(1 for line in lines if _TABLE_ROW.match(line)),
grid_rule_lines=sum(1 for line in lines if _GRID_RULE.match(line)),
blocks_before=sum(1 for c in before if c.rule == RULE_TABLE_BLOCK),
blocks_after=sum(1 for c in after if c.rule == RULE_TABLE_BLOCK),
joined_blocks=sum(1 for c in after if c.grid),
entries_before=len(before),
entries_after=len(after),
)
def run(corpus: Path, run_length: int = RUN_LENGTH) -> list[Row]:
rows: list[Row] = []
for path in sorted(corpus.iterdir(), key=lambda p: p.name):
if not path.is_file():
continue
try:
text = extract_text(path.name, path.read_bytes())
except ExtractionError as exc:
rows.append(Row(name=path.name, measurement=None, skip_reason=f"{exc.code}: {exc}"))
continue
rows.append(
Row(
name=path.name,
measurement=measure_document(text, path.name, run_length),
skip_reason=None,
)
)
return rows
def render(corpus: Path, rows: list[Row], run_length: int = RUN_LENGTH) -> str:
measured = [row.measurement for row in rows if row.measurement is not None]
extractable = len(measured)
pipe_docs = sum(1 for m in measured if m.pipe_rows > 0)
pipe_rows = sum(m.pipe_rows for m in measured)
grid_docs = sum(1 for m in measured if m.grid_rule_lines > 0)
grid_lines = sum(m.grid_rule_lines for m in measured)
joined_docs = sum(1 for m in measured if m.joined_blocks > 0)
joined = sum(m.joined_blocks for m in measured)
blocks_before = sum(m.blocks_before for m in measured)
blocks_after = sum(m.blocks_after for m in measured)
entries_before = sum(m.entries_before for m in measured)
entries_after = sum(m.entries_after for m in measured)
changed = [m for m in measured if m.entries_before != m.entries_after]
names = [row.name for row in rows]
sample = draw_sample(names)
by_name = {m.name: m for m in measured}
lines = [
f"# Arm E grid-table reach, {corpus}",
"",
f"Outline gate: {run_length} (Arm D's, reproduced so both arms share an outline).",
f"N = {len(rows)} (corpus directory file count). Extractable: {extractable}/{len(rows)}.",
"",
"## The ceiling -- documents Arm E could move at all (MEASURED)",
"",
f"- documents with at least one table row: **{pipe_docs}**/{extractable}",
f"- table rows in total: **{pipe_rows}**",
f"- documents with at least one grid-rule line: **{grid_docs}**/{extractable} "
f"(expected {EXPECTED_GRID_DOCUMENTS})",
f"- grid-rule lines in total: **{grid_lines}** (expected {EXPECTED_GRID_RULE_LINES})",
"",
"A document with no table row cannot be moved by this arm, whatever the",
"orphan check later did to its candidates. That is what makes this the",
"ceiling rather than the entry counts below.",
"",
"## What the join does, and what it costs",
"",
f"- documents where any block was joined: **{joined_docs}**/{extractable}",
f"- blocks joined: **{joined}**",
f"- table-block candidates: **{blocks_before}** -> **{blocks_after}** "
f"(reference {REFERENCE_TABLE_BLOCK_ENTRIES})",
f"- entries, whole corpus: **{entries_before}** -> **{entries_after}** "
f"(delta {entries_after - entries_before:+d}, reference {REFERENCE_ARM_D_ENTRIES})",
f"- documents whose entry count changed: **{len(changed)}**/{extractable}",
"",
"## Per document",
"",
"| file | rows | grid rules | blocks before | blocks after | joined | "
"entries before | entries after |",
"|---|---|---|---|---|---|---|---|",
]
lines.extend(row.line() for row in rows)
lines.extend(["", "## Files the door refused, with their reason", ""])
refused = [row for row in rows if row.measurement is None]
if refused:
lines.extend(f"- {row.name} -- {row.skip_reason}" for row in refused)
else:
lines.append("- none")
lines.extend(["", "## The K3 sample, in canonical draw order", ""])
for position, name in enumerate(sample):
measurement = by_name.get(name)
if measurement is None:
state = "not extractable"
else:
state = (
f"rows {measurement.pipe_rows}, grid rules {measurement.grid_rule_lines}, "
f"entries {measurement.entries_before} -> {measurement.entries_after}"
)
lines.append(f"- {position}: {name} -- {state}")
lines.extend(["", f"Rule names in play: `{RULE_TABLE_BLOCK}`, `{RULE_TABLE_GRID}`.", ""])
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--corpus", type=Path, required=True, help="the directory to measure")
parser.add_argument("--report", type=Path, required=True, help="where to write the report")
parser.add_argument(
"--run-length",
type=int,
default=RUN_LENGTH,
metavar="N",
help="Arm D's outline gate, reproduced so both arms are measured on one outline",
)
args = parser.parse_args(argv)
if not args.corpus.is_dir():
print(
f"okf-table-measure: FAILED - no corpus directory at {args.corpus}",
file=sys.stderr,
)
return 1
rows = run(args.corpus, args.run_length)
args.report.write_text(render(args.corpus, rows, args.run_length), encoding="utf-8", newline="")
print(f"okf-table-measure: wrote {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())