`uv sync --frozen` resolved ruff 0.15.22 and the tree read clean. A loose install resolves 0.16.6, under which the SAME untouched code reports 148 findings -- 4 more than round 9 counted, because this round added four files. All of them are new rules rather than new defects: 0.16 widened the default rule set to whole families (YTT, ASYNC, PL, ISC, C4, UP, B, SIM, FURB, ...). (`[skip-docs]` is for CLAUDE.md, which a lint-configuration change does not reach. README's developer section IS updated in this commit.) THE DEFECT IS NOT THE 148, IT IS THAT NOBODY CHOSE THEM. `[tool.ruff]` set only `line-length` and `target-version`, so the acceptance was ruff's default, and the tree stayed green only as long as the lockfile froze an old ruff. `select` is now written down: `E4`, `E7`, `E9`, `F` (the historical default), `I` because this tree already keeps imports sorted, and `RUF100` so a `noqa` that has stopped meaning anything is caught rather than left as decoration. Pin `ruff>=0.9` -> `ruff>=0.16.6,<0.17`. Per rule, before -> after: RUF100 50 -> 0, I001 20 -> 0, ISC004 19, PLW1510 8, C408 8, EXE001 6, RUF007 5, PLE2515 4, UP031 3, B017 3, and fourteen more with 2 or fewer -- the families out of the declared set are 0 by selection, and 148 is the number to start from if they are adopted, which is a separate decision and not one to take inside a version-pin commit. 57 were auto-fixed; one E402 was reintroduced by the import-sorting fix merging a block away from its `noqa`, and got the directive back rather than a bare one. `S` IS MEASURED OUT, NOT ASSUMED OUT: it reports 2657 `S101` on a suite whose every assertion is an `assert`, and `S603` flags 19 subprocess calls of which one was ever marked -- selecting it buys 18 suppressions and no defect. Two `noqa` directives naming non-selected rules were dropped with that reason recorded in the configuration instead. THE TWO FILES 0.16 WOULD REFORMAT ARE MARKDOWN, NOT PYTHON: `README.md` and `docs/2026-09-08-blindsone-below-k-k2.md`. 0.16 formats fenced Python inside markdown, and both blocks are RECORDS -- the second is a quotation of `COST_VOCABULARY` as it stood when that measurement was taken. Reformatting a quotation makes it stop being one, so markdown is excluded from the formatter and `ruff format --check .` stays in the acceptance over `.py`. `tools/okf_consume_measure.py` is fenced by the order as run-not-edited, so its three findings are exempted by path with the reason and the debt named, and its bytes are untouched. THE LOCKFILE TRAP IS CLOSED, NOT AVOIDED. `uv.lock` predated the `[ocr]` extra, so any unlocked resolve wrote that extra's transitive tree back into it -- 681 insertions over 4 deletions, twice now, and round 9 recorded the cause as `uv run` OUTSIDE the project when it is `uv run` without `--frozen` INSIDE it. The relock is complete for every declared extra (703 insertions, 26 deletions), and measured after it, an unfrozen `uv run` leaves the file alone. `ruff check src tests tools`, `ruff format --check .` (0.16.6), `mypy src` over 21 files and 1535 tests, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""Measure what survives conversion: the source's own strings, and their pairing.
|
|
|
|
The instrument behind K2. It exists because the arm A report of 2026-08-29
|
|
published `docx` 193/196 and `xlsx` 193/193 without shipping the command that
|
|
produced them, so those figures could not be re-measured -- against a new
|
|
converter version, against the product path, or at all.
|
|
|
|
Two questions, both of them the source document's own:
|
|
|
|
- **coverage** -- of the strings the file itself stores (a `docx` paragraph, an
|
|
`xlsx` cell, a `pptx` or `odt` paragraph, an `rtf` cell), how many appear in
|
|
the converted text. Not character count: a
|
|
converter emitting 2.7x more characters can carry FEWER source strings, which
|
|
arm A measured directly.
|
|
- **pairing** -- of the source rows carrying a label and a value, how many keep
|
|
both on one output line. That is the criterion a requirement table is read
|
|
by, and the one that separates a usable conversion from a pile of words.
|
|
|
|
Comparison is normalised (whitespace collapsed, case folded) because a
|
|
converter is entitled to re-wrap; it is not entitled to lose a string.
|
|
|
|
Run standalone and product side by side. A fall from one to the other is a
|
|
question this repository has to answer, not a number to publish: a product
|
|
figure alone cannot tell a converter limit from a defect we introduced.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
|
from llm_ingestion_okf.extract import extract_text
|
|
|
|
#: A source string shorter than this is not evidence either way -- a lone digit
|
|
#: or a stray bullet appears in almost any output by accident, so counting it
|
|
#: would inflate coverage for every converter equally and measure nothing.
|
|
MIN_STRING = 3
|
|
|
|
_WS = re.compile(r"\s+")
|
|
_XML_TEXT = re.compile(r"<(?:w:t|a:t|t)(?:\s[^>]*)?>(.*?)</(?:w:t|a:t|t)>", re.DOTALL)
|
|
_XML_PARA = re.compile(r"<w:p[\s>].*?</w:p>", re.DOTALL)
|
|
_XML_SI = re.compile(r"<si>(.*?)</si>", re.DOTALL)
|
|
#: A presentation's paragraph. A table cell holds one, so cells and body text
|
|
#: are read by the same rule rather than by two that could disagree.
|
|
_PPTX_PARA = re.compile(r"<a:p[\s>].*?</a:p>", re.DOTALL)
|
|
#: A slide part. Sorted NUMERICALLY on the trailing index, because the
|
|
#: lexicographic order of `slide2` and `slide10` is not the deck's order.
|
|
_PPTX_SLIDE = re.compile(r"^ppt/slides/slide(\d+)\.xml$")
|
|
#: An ODF paragraph or heading. A table cell holds a `text:p`, so the same rule
|
|
#: covers cells; the inner markup (`text:span` and friends) is stripped rather
|
|
#: than parsed, since the question is which STRING the file stores.
|
|
_ODF_PARA = re.compile(r"<text:(p|h)(?:\s[^>]*)?>(.*?)</text:\1>", re.DOTALL)
|
|
_TAGS = re.compile(r"<[^>]*>")
|
|
_ENTITIES = (("&", "&"), ("<", "<"), (">", ">"), (""", '"'), ("'", "'"))
|
|
|
|
|
|
#: Stripped from both sides before comparison. These are the converter's own
|
|
#: MARKUP, not the document's content: markdown escapes a `[`, wraps a bold run
|
|
#: in `**`, and writes an en dash as `--`. Measured on the corpus -- without
|
|
#: this, `docx` coverage reads 149/196 while every one of the 47 "missing"
|
|
#: strings is present in the output verbatim, wearing an escape. The question
|
|
#: is whether the STRING survived, not whether the markup matches.
|
|
_MARKUP = str.maketrans({char: None for char in "\\*_`"})
|
|
_DASHES = str.maketrans({"\u2013": "-", "\u2014": "-", "\u00a0": " ", "\u2019": "'"})
|
|
|
|
|
|
def normalise(text: str) -> str:
|
|
for entity, char in _ENTITIES:
|
|
text = text.replace(entity, char)
|
|
text = text.translate(_MARKUP).translate(_DASHES)
|
|
text = text.replace("--", "-")
|
|
return _WS.sub(" ", text).strip().casefold()
|
|
|
|
|
|
def _runs(fragment: str) -> str:
|
|
return "".join(match.group(1) for match in _XML_TEXT.finditer(fragment))
|
|
|
|
|
|
# --- the rtf stream ---------------------------------------------------------
|
|
#
|
|
# RTF is not a package, so there is no XML part to read the document's own
|
|
# strings out of: the stream itself is the format. What follows reads exactly
|
|
# the constructs the fixture set uses -- groups, destinations, control words,
|
|
# the `\'hh` byte escape and the `\uN` unicode escape -- and it is stated as a
|
|
# limit rather than sold as an RTF parser. That limit matters for what the
|
|
# measurement can claim: for `pptx` and `odt` the fasit comes from a package
|
|
# format neither we nor the converter defined, and for `rtf` it comes from a
|
|
# reader written here against a stream written here. The `rtf` row is the
|
|
# weakest of the three on that axis and the report says so.
|
|
|
|
#: Control words that END a paragraph or a cell. Everything the document is
|
|
#: divided into for the purpose of "did this string survive".
|
|
_RTF_BOUNDARIES = frozenset({"par", "cell", "row", "line", "sect", "nestcell", "nestrow"})
|
|
|
|
#: Groups whose contents are the file's own bookkeeping rather than its text.
|
|
#: Reading them would count a font name as a document string.
|
|
_RTF_DESTINATIONS = frozenset(
|
|
{
|
|
"fonttbl",
|
|
"colortbl",
|
|
"stylesheet",
|
|
"info",
|
|
"pict",
|
|
"listtable",
|
|
"generator",
|
|
"filetbl",
|
|
}
|
|
)
|
|
|
|
#: A control word plus its optional numeric parameter. The trailing space is
|
|
#: the word's DELIMITER and is consumed with it -- that single rule is what
|
|
#: makes `\u248 ?` read as one character followed by a discardable `?`, and it
|
|
#: is the rule the vendored converter applies differently (see the fixture
|
|
#: README).
|
|
_RTF_CONTROL = re.compile(r"\\([a-z]+)(-?[0-9]+)? ?")
|
|
|
|
|
|
def rtf_paragraphs(raw: bytes) -> list[str]:
|
|
"""The strings an RTF stream stores, one per paragraph or table cell."""
|
|
text = raw.decode("cp1252", "replace")
|
|
end = len(text)
|
|
out: list[str] = []
|
|
buf: list[str] = []
|
|
position = 0
|
|
depth = 0
|
|
skip_depth: int | None = None
|
|
pending_replacements = 0
|
|
|
|
while position < end:
|
|
char = text[position]
|
|
if char == "{":
|
|
depth += 1
|
|
position += 1
|
|
continue
|
|
if char == "}":
|
|
if skip_depth is not None and depth <= skip_depth:
|
|
skip_depth = None
|
|
depth -= 1
|
|
position += 1
|
|
continue
|
|
if char == "\\":
|
|
control = _RTF_CONTROL.match(text, position)
|
|
if control is not None:
|
|
word, parameter = control.group(1), control.group(2)
|
|
position = control.end()
|
|
if skip_depth is not None:
|
|
continue
|
|
if word == "u" and parameter is not None:
|
|
buf.append(chr(int(parameter)))
|
|
pending_replacements = 1
|
|
continue
|
|
pending_replacements = 0
|
|
if word in _RTF_BOUNDARIES:
|
|
out.append("".join(buf))
|
|
buf = []
|
|
elif word in _RTF_DESTINATIONS:
|
|
skip_depth = depth
|
|
continue
|
|
symbol = text[position + 1] if position + 1 < end else ""
|
|
if symbol == "'":
|
|
if skip_depth is None:
|
|
buf.append(chr(int(text[position + 2 : position + 4] or "3f", 16)))
|
|
position += 4
|
|
continue
|
|
if symbol == "*":
|
|
skip_depth = depth
|
|
position += 2
|
|
continue
|
|
if skip_depth is None:
|
|
buf.append(symbol)
|
|
position += 2
|
|
continue
|
|
position += 1
|
|
if char in "\r\n" or skip_depth is not None:
|
|
continue
|
|
if pending_replacements:
|
|
pending_replacements -= 1
|
|
continue
|
|
buf.append(char)
|
|
|
|
out.append("".join(buf))
|
|
return out
|
|
|
|
|
|
def source_strings(path: Path) -> tuple[str, ...]:
|
|
"""The strings the FILE itself stores, deduplicated and in document order.
|
|
|
|
Read from the package's own XML rather than from any converter's output:
|
|
a fasit derived from one converter would score that converter on its own
|
|
homework.
|
|
"""
|
|
suffix = path.suffix.lower()
|
|
if suffix == ".rtf":
|
|
return _dedupe(rtf_paragraphs(path.read_bytes()))
|
|
with zipfile.ZipFile(path) as archive:
|
|
names = set(archive.namelist())
|
|
if suffix == ".docx":
|
|
body = archive.read("word/document.xml").decode("utf-8", "replace")
|
|
found = [_runs(match.group(0)) for match in _XML_PARA.finditer(body)]
|
|
elif suffix == ".xlsx":
|
|
found = []
|
|
if "xl/sharedStrings.xml" in names:
|
|
shared = archive.read("xl/sharedStrings.xml").decode("utf-8", "replace")
|
|
found = [_runs(match.group(1)) for match in _XML_SI.finditer(shared)]
|
|
elif suffix == ".pptx":
|
|
found = []
|
|
slides = [
|
|
(int(match.group(1)), match.group(0))
|
|
for match in (_PPTX_SLIDE.match(name) for name in names)
|
|
if match is not None
|
|
]
|
|
for _, name in sorted(slides):
|
|
slide = archive.read(name).decode("utf-8", "replace")
|
|
found.extend(_runs(match.group(0)) for match in _PPTX_PARA.finditer(slide))
|
|
elif suffix == ".odt":
|
|
body = archive.read("content.xml").decode("utf-8", "replace")
|
|
found = [_TAGS.sub("", match.group(2)) for match in _ODF_PARA.finditer(body)]
|
|
else:
|
|
raise ValueError(f"no source-string reader for {suffix!r}")
|
|
|
|
return _dedupe(found)
|
|
|
|
|
|
def _dedupe(found: list[str]) -> tuple[str, ...]:
|
|
seen: dict[str, None] = {}
|
|
for item in found:
|
|
value = normalise(item)
|
|
if len(value) >= MIN_STRING:
|
|
seen.setdefault(value, None)
|
|
return tuple(seen)
|
|
|
|
|
|
def label_value_rows(strings: tuple[str, ...]) -> tuple[str, ...]:
|
|
"""Source strings that read as a label expecting a value beside it.
|
|
|
|
A row is usable for pairing when its own text ends in a colon or is a short
|
|
noun phrase followed by a value elsewhere on the row. Approximated by the
|
|
colon rule alone, which is stated rather than tuned: a threshold fitted to
|
|
make a number look good is not a measurement.
|
|
"""
|
|
return tuple(item for item in strings if item.endswith(":"))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fidelity:
|
|
path: str
|
|
covered: int
|
|
total: int
|
|
paired: int
|
|
pairable: int
|
|
|
|
def line(self) -> str:
|
|
share = 100.0 * self.covered / self.total if self.total else 0.0
|
|
pair = 100.0 * self.paired / self.pairable if self.pairable else 0.0
|
|
return (
|
|
f"{self.path}: coverage {self.covered}/{self.total} ({share:.1f} %), "
|
|
f"paired {self.paired}/{self.pairable} ({pair:.1f} %)"
|
|
)
|
|
|
|
|
|
def score(path: Path, text: str) -> Fidelity:
|
|
strings = source_strings(path)
|
|
haystack = normalise(text)
|
|
lines = [normalise(line) for line in text.splitlines()]
|
|
covered = sum(1 for item in strings if item in haystack)
|
|
pairable = label_value_rows(strings)
|
|
paired = sum(
|
|
1 for label in pairable if any(label in line and len(line) > len(label) for line in lines)
|
|
)
|
|
return Fidelity(
|
|
path=path.name, covered=covered, total=len(strings), paired=paired, pairable=len(pairable)
|
|
)
|
|
|
|
|
|
def product_text(path: Path) -> str:
|
|
"""The text the LIBRARY produces -- the same call the door makes."""
|
|
return extract_text(path.name, path.read_bytes())
|
|
|
|
|
|
def standalone_text(path: Path) -> str:
|
|
"""The text the converter produces on its own, at the pinned version."""
|
|
import pypandoc
|
|
|
|
from llm_ingestion_okf._pandoc import converter_path
|
|
|
|
formats = {".docx": "docx", ".xlsx": "xlsx", ".pptx": "pptx", ".odt": "odt", ".rtf": "rtf"}
|
|
with converter_path():
|
|
return str(
|
|
pypandoc.convert_file(
|
|
str(path),
|
|
"markdown",
|
|
format=formats[path.suffix.lower()],
|
|
extra_args=["--eol=lf", "--wrap=none"],
|
|
)
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("files", nargs="+", type=Path)
|
|
args = parser.parse_args(argv)
|
|
for path in args.files:
|
|
print(f"--- {path.name}")
|
|
print(f" standalone {score(path, standalone_text(path)).line()}")
|
|
print(f" product {score(path, product_text(path)).line()}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|