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>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""`okf` — the installed command. One subcommand today: `build`.
|
|
|
|
## What it replaces
|
|
|
|
Until this module existed, "run the door over a folder" was a shell loop over
|
|
`tools/okf_propose_segments.py` followed by `tools/okf_corpus_run.py`, with
|
|
nine flags between them and a `--path-prefix` rule that lived in a code block
|
|
in a measurement report. Neither script was packaged, so the whole path was
|
|
reachable only from a clone -- a consumer who installed this library could call
|
|
`process_inbox` but could not run the thing the reports measured.
|
|
|
|
`okf build <inbox> --bundle <dir>` is that path, packaged. It is ORCHESTRATION
|
|
and nothing else: every rule it applies belongs to `propose` or `corpus`, which
|
|
are the same modules `tools/` now calls. There is one implementation of each,
|
|
and it is the packaged one.
|
|
|
|
## The two decisions this layer owns
|
|
|
|
**The scope of a document's proposed paths is its RELATIVE PATH, minus the
|
|
extension.** Section numbering is document-local, so two documents propose the
|
|
same path and Door B refuses both; the loop in the reports passed the BASENAME,
|
|
which was right while the inbox was flat. It stopped being right when the door
|
|
started walking recursively: `a/krav.pdf` and `b/krav.pdf` would both reduce to
|
|
`krav` and collide, which is the collision Door B is supposed to make
|
|
impossible rather than merely detectable. For a flat inbox the relative path IS
|
|
the basename, which is why the published bundles' bytes do not move.
|
|
|
|
**Omitted timestamps do not come from the clock.** `--ingested-at` and
|
|
`--proposed-at` default to `DEFAULT_STAMP`, one constant used for both. A
|
|
wall-clock default would put a changing byte into the artifact and break
|
|
rebuild-equals-incremental (K6) for every caller who did not pass the flags --
|
|
the property the segmented bundle is built on, and the one a convenience
|
|
default is most likely to take away silently. The epoch is deliberate and
|
|
readable as what it is: a stamp nobody set. A caller who wants a real ingest
|
|
time passes one.
|
|
|
|
## What it does not decide
|
|
|
|
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
|
|
|
|
import argparse
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from .corpus import LOG_NAME, CorpusReport, link_log_in_root_index, load_plans, measure
|
|
from .errors import IngestError
|
|
from .inbox import walk_inbox
|
|
from .profiles import SEGMENTED_OKF_V0_2, STRUCTURED_V1, BundleProfile
|
|
from .propose import ProposerError
|
|
from .propose import run as propose_run
|
|
|
|
__all__ = ["DEFAULT_STAMP", "build", "main", "measure"]
|
|
|
|
CLI_ID = "okf build"
|
|
|
|
#: The timestamp written when the caller passes none, for the ingest stamp and
|
|
#: the proposal stamp alike. ONE constant: two independently-defaulted literals
|
|
#: drift, and the drift shows up only as two bundles differing in a field
|
|
#: nobody set.
|
|
DEFAULT_STAMP = "1970-01-01T00:00:00Z"
|
|
|
|
|
|
def _propose_plans(
|
|
inbox: Path, bundle: Path, plans_dir: Path, *, proposed_at: str, okf_type: str
|
|
) -> tuple[int, int, int]:
|
|
"""Propose a plan per dropped file. Returns (written, nothing, failed).
|
|
|
|
The walk is the DOOR's walk, imported rather than restated, so the set of
|
|
documents that get a plan is exactly the set that gets ingested.
|
|
|
|
Neither "nothing to propose" nor "cannot read" stops the loop, because
|
|
neither stops the door: the first lands the document as one flat concept
|
|
and the second is a coded rejection that K1b accounts for. The shell loop
|
|
behaved the same way -- `exit 1` for the eleven with no boundary, `exit 2`
|
|
for the four unreadable -- and a build that aborted on either would refuse
|
|
corpora the two-script path completes.
|
|
"""
|
|
walked, _ = walk_inbox(inbox, exclude=bundle)
|
|
written = nothing = failed = 0
|
|
for position, source in enumerate(walked, start=1):
|
|
relative = source.relative_to(inbox)
|
|
try:
|
|
outcome = propose_run(
|
|
source,
|
|
plans_dir / f"{position:02d}.json",
|
|
okf_type=okf_type,
|
|
proposed_at=proposed_at,
|
|
path_prefix=relative.with_suffix("").as_posix(),
|
|
)
|
|
except ProposerError as exc:
|
|
print(f"{CLI_ID}: {relative.as_posix()}: {exc}", file=sys.stderr)
|
|
failed += 1
|
|
continue
|
|
if outcome == 0:
|
|
written += 1
|
|
else:
|
|
nothing += 1
|
|
return (written, nothing, failed)
|
|
|
|
|
|
def build(
|
|
inbox: Path,
|
|
bundle: Path,
|
|
*,
|
|
ingested_at: str = DEFAULT_STAMP,
|
|
proposed_at: str = DEFAULT_STAMP,
|
|
bundle_id: str | None = None,
|
|
okf_version: str | None = None,
|
|
segments: bool = True,
|
|
plans_dir: Path | None = None,
|
|
okf_type: str = "reference",
|
|
) -> CorpusReport:
|
|
"""Folder in, bundle out. The whole command, minus argument parsing.
|
|
|
|
Keyword-only with defaults, so a caller who takes this as an API keeps a
|
|
source-compatible call when a flag is added.
|
|
"""
|
|
if not segments:
|
|
report = measure(inbox, bundle, ingested_at=ingested_at, profile=STRUCTURED_V1)
|
|
_write_log(bundle, report, profile=STRUCTURED_V1)
|
|
return report
|
|
|
|
if bundle_id is None or okf_version is None:
|
|
missing = ", ".join(
|
|
flag
|
|
for flag, value in (("--bundle-id", bundle_id), ("--okf-version", okf_version))
|
|
if value is None
|
|
)
|
|
raise IngestError(
|
|
f"{missing} is required unless --segments off; a profile names a key and the "
|
|
"caller owns its value",
|
|
code="manifest_invalid",
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="okf-plans-") as scratch:
|
|
target = plans_dir if plans_dir is not None else Path(scratch)
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
written, nothing, failed = _propose_plans(
|
|
inbox, bundle, target, proposed_at=proposed_at, okf_type=okf_type
|
|
)
|
|
print(
|
|
f"{CLI_ID}: proposed {written} plan(s); {nothing} document(s) with no boundary; "
|
|
f"{failed} unreadable",
|
|
file=sys.stderr,
|
|
)
|
|
plans = load_plans(target)
|
|
report = measure(
|
|
inbox,
|
|
bundle,
|
|
ingested_at=ingested_at,
|
|
plans=plans,
|
|
profile=SEGMENTED_OKF_V0_2,
|
|
root_frontmatter_values={"okf_version": okf_version, "bundle_id": bundle_id},
|
|
)
|
|
_write_log(bundle, report, profile=SEGMENTED_OKF_V0_2)
|
|
return report
|
|
|
|
|
|
def _write_log(bundle: Path, report: CorpusReport, *, profile: BundleProfile) -> None:
|
|
"""The section 9 log, into the BUNDLE, and the root index link to it.
|
|
|
|
Lifted verbatim from the harness's own `main` rather than reimplemented:
|
|
the log carries `N`, which is the one fact about a run the bundle cannot
|
|
otherwise recover, and a build that wrote a bundle without it would ship an
|
|
artifact whose conservation identity is uncheckable.
|
|
"""
|
|
bundle.mkdir(parents=True, exist_ok=True)
|
|
(bundle / LOG_NAME).write_text(report.render_log(), encoding="utf-8", newline="")
|
|
link_log_in_root_index(bundle, profile)
|
|
|
|
|
|
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="okf",
|
|
description="OKF bundle tooling. One folder in, one bundle out.",
|
|
)
|
|
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
build_parser = subcommands.add_parser(
|
|
"build",
|
|
help="build an OKF bundle from a folder of documents",
|
|
description=(
|
|
"Walk a folder recursively, propose a segmentation for each document, "
|
|
"replay those proposals through Door B, and write the bundle. Reports "
|
|
"the conservation identity `merged + coded rejections == N` on stdout "
|
|
"and exits non-zero when it does not hold."
|
|
),
|
|
)
|
|
build_parser.add_argument("inbox", type=Path, help="the folder of documents to ingest")
|
|
build_parser.add_argument(
|
|
"--bundle", type=Path, required=True, help="where to write the OKF bundle"
|
|
)
|
|
build_parser.add_argument(
|
|
"--bundle-id",
|
|
default=None,
|
|
help="required unless --segments off: what a consumer joins the concepts on",
|
|
)
|
|
build_parser.add_argument(
|
|
"--okf-version",
|
|
default=None,
|
|
help=(
|
|
"required unless --segments off: the upstream OKF version this bundle "
|
|
"declares. An argument and never a constant -- the VALUE belongs to the "
|
|
"catalog (decision E1)"
|
|
),
|
|
)
|
|
build_parser.add_argument(
|
|
"--ingested-at",
|
|
default=DEFAULT_STAMP,
|
|
help=f"stamped verbatim. Default {DEFAULT_STAMP}: deterministic, never the clock",
|
|
)
|
|
build_parser.add_argument(
|
|
"--proposed-at",
|
|
default=DEFAULT_STAMP,
|
|
help=f"written into every proposal. Default {DEFAULT_STAMP}, for the same reason",
|
|
)
|
|
build_parser.add_argument(
|
|
"--segments",
|
|
choices=("on", "off"),
|
|
default="on",
|
|
help=(
|
|
"on (the default) proposes a segmentation per document with the "
|
|
"mechanical rules and replays it; off ingests each document as one "
|
|
"concept and asks for no root values"
|
|
),
|
|
)
|
|
build_parser.add_argument(
|
|
"--okf-type", default="reference", help="okf_type for every concept and proposal"
|
|
)
|
|
build_parser.add_argument(
|
|
"--plans-dir",
|
|
type=Path,
|
|
default=None,
|
|
help=(
|
|
"keep the proposals here instead of discarding them. Every entry is "
|
|
"PROPOSED, never adjudicated -- this is where an operator reads what "
|
|
"the run replayed"
|
|
),
|
|
)
|
|
build_parser.add_argument("--report", type=Path, default=None, help="also write the report")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(argv)
|
|
if not args.inbox.is_dir():
|
|
print(f"{CLI_ID}: FAILED - no such folder: {args.inbox}", file=sys.stderr)
|
|
return 2
|
|
try:
|
|
report = build(
|
|
args.inbox,
|
|
args.bundle,
|
|
ingested_at=args.ingested_at,
|
|
proposed_at=args.proposed_at,
|
|
bundle_id=args.bundle_id,
|
|
okf_version=args.okf_version,
|
|
segments=args.segments == "on",
|
|
plans_dir=args.plans_dir,
|
|
okf_type=args.okf_type,
|
|
)
|
|
except (IngestError, OSError, ValueError) as exc:
|
|
print(f"{CLI_ID}: FAILED - {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
if args.report is not None:
|
|
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
args.report.write_text(report.render(), encoding="utf-8", newline="")
|
|
print(report.render())
|
|
if report.unaccounted or report.merged + report.rejected != report.n:
|
|
print(
|
|
f"{CLI_ID}: K1b FAILED - merged ({report.merged}) + coded rejections "
|
|
f"({report.rejected}) != N ({report.n}). Unaccounted: "
|
|
f"{', '.join(report.unaccounted) or '(none named)'}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|