feat(propose,consume,tools): the type that declares nothing, and the prefix that is not a word
Three of round 9's four measured holes, each closed with a rule chosen on a measurement rather than named as a limit. `rtf` GIVES 0 SEGMENTS -> 6 of 6 AUTHORED TITLES over N = 4. The container has no heading style, so the author's title is bold text. The grammar is markdown, not `rtf`: the converter already writes that title as `**...**` in the same output every office row produces, so no `rtf`-only heading form exists. Three parameters were swept over 47 readable documents and ONE carried -- refusing a line that ends in terminal punctuation takes false-positive lines from 9-12 to 1-2. A maximum title length (unlimited/40/60/80/120) and a must-stand-between-blank-lines clause are both FLAT, so neither is in the rule. The last false positive is closed by G1, the principle `_gate_outline` already carries: recovery yields to declaration. False positives are then 0 of the 31 declaring documents by construction, and 0 of 27 on the corpus. Reach: 2 of 39 corpus documents, both `docx`, 0 of 33 `pdf` and 0 of 2 `xlsx`. Behind `--bold-title`, default OFF pending the hit@8 measurement; the default bundle is byte-identical without it. BOTH ALTERNATIVES THE ORDER NAMED WERE MEASURED AND FELLED. A fourth hand-laid fixture DECLARES heading styles in a stylesheet and the converter discards them, emitting the same bold line -- so "read the declared headings out of the markdown" has nothing to read. `rtf` -> `docx` -> markdown yields 0 ATX headings on that same document, because the loss is in the `rtf` READER before any writer sees the style. Fixtures are hand-laid in `make_k2_office.py` with the fasit written first; they live in their own directory because Door B walks a drop directory recursively and `k2-office/` reads its N off the listing. THE PREFIX OVER-MATCH: THREE CANDIDATES MEASURED, ALL THREE FAILED ON ONE ROW. Re-measured on the pinned 453-concept bundle with the control run first: `under` occurs 79 times by equality and matches 172 by prefix, `undersjoisk` 0 and 172, `bilateral` 0 and 400 of 453, `standhaftig` 0 and 219. The two extra known-negatives were FOUND, not chosen -- every 4-character prefix ranked by document frequency, then a real word taken from the widest. A longer floor (5-8), a coverage share (0.5-0.8) and a long-words-only floor (>= 8) each cost row 1 its rank on the default bundle and the whole row on Arm B. Decomposed: row 1's token `prisene` reaches its gold document through `pris|sammenstilling` on four characters -- 0.57 of one word and 0.22 of the other -- so the over-match and the wanted match are one mechanism. THE FOURTH CANDIDATE IS THE ANSWER: the shared prefix must be a WORD the bundle uses. `pris` is; `bila` and `stan` are not. `bilateral` 400 -> 0 and 512 -> 0, `standhaftig` 219 -> 56 and 235 -> 33, every hit@8 row keeping rank 1 on BOTH bundles. `undersjoisk` stops at 162 because `under` IS a word here -- a genuine Norwegian morpheme, so that residual is a different answer, not a ceiling. ON by default (`--no-stem-prefix`), pinned with its own known-negative on the shipped bytes. THE SHIM: a path importer holds the object `module_from_spec` made, and `sys.modules[__name__] = _impl` never reaches it. Measured under both counting methods -- 3 of 76 public names by `vars()`. One line copies the public names into this file's globals; the dunder filter is load-bearing, because an unfiltered copy overwrites `__name__` before the next line uses it as the alias key. It restores attribute ACCESS and not patch-through, which is why the alias stays. A CHANGELOG note under 0.7.0 and a shim docstring line say so, since what the consumer asked for was the note. Suite 1515 -> 1535. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6ff84d71c8
commit
191de89f41
16 changed files with 1100 additions and 22 deletions
59
tests/fixtures/k2-office-fasit.json
vendored
59
tests/fixtures/k2-office-fasit.json
vendored
|
|
@ -38,5 +38,64 @@
|
|||
"strings": 59,
|
||||
"container": "flat stream: title paragraph, intro paragraph, 20 table rows, caption paragraph, 4 table rows"
|
||||
}
|
||||
},
|
||||
"rtf_variants": {
|
||||
"_note": [
|
||||
"Hand-counted 2026-09-09 from the AUTHORED content in",
|
||||
"make_k2_office.py, BEFORE round 10's bold-title rule was measured",
|
||||
"against it -- never from a converter's output. `authored_titles` is",
|
||||
"what a reader of the source would call a section name; it is NOT a",
|
||||
"count of what any container manages to declare, which is the whole",
|
||||
"point: rtf has no heading style, so the author's title is bold text.",
|
||||
"`bold_runs_that_are_not_titles` is the known-negative denominator."
|
||||
],
|
||||
"counted_by": "hand, from tests/fixtures/make_k2_office.py",
|
||||
"counted_at": "2026-09-09",
|
||||
"documents": {
|
||||
"krav-rikt-tekstformat.rtf": {
|
||||
"authored_titles": 1,
|
||||
"titles": [
|
||||
"Kravspesifikasjon for tunnelbelysning"
|
||||
],
|
||||
"bold_runs_that_are_not_titles": 0,
|
||||
"declares_heading_style": false,
|
||||
"container": "title bold AND at 16pt, then intro, the 20-row table, the caption and the 4x4 grid"
|
||||
},
|
||||
"krav-rikt-tekstformat-fet-alene.rtf": {
|
||||
"authored_titles": 2,
|
||||
"titles": [
|
||||
"Kravspesifikasjon for tunnelbelysning",
|
||||
"Luminanskrav per trafikklasse"
|
||||
],
|
||||
"bold_runs_that_are_not_titles": 0,
|
||||
"declares_heading_style": false,
|
||||
"container": "both titles bold at BODY size, no point-size change anywhere"
|
||||
},
|
||||
"krav-rikt-tekstformat-fet-i-avsnitt.rtf": {
|
||||
"authored_titles": 1,
|
||||
"titles": [
|
||||
"Kravspesifikasjon for tunnelbelysning"
|
||||
],
|
||||
"bold_runs_that_are_not_titles": 1,
|
||||
"declares_heading_style": false,
|
||||
"container": "one bold title, then a sentence carrying one bold phrase that continues past it"
|
||||
},
|
||||
"krav-rikt-tekstformat-stil.rtf": {
|
||||
"authored_titles": 2,
|
||||
"titles": [
|
||||
"Kravspesifikasjon for tunnelbelysning",
|
||||
"Luminanskrav per trafikklasse"
|
||||
],
|
||||
"bold_runs_that_are_not_titles": 0,
|
||||
"declares_heading_style": true,
|
||||
"container": "a stylesheet declaring \\s1 and \\s2, applied to the two title paragraphs"
|
||||
}
|
||||
},
|
||||
"totals": {
|
||||
"documents": 4,
|
||||
"authored_titles": 6,
|
||||
"bold_runs_that_are_not_titles": 1,
|
||||
"documents_declaring_a_heading_style": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
tests/fixtures/k2-rtf-variants/krav-rikt-tekstformat-fet-alene.rtf
vendored
Normal file
5
tests/fixtures/k2-rtf-variants/krav-rikt-tekstformat-fet-alene.rtf
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b Kravspesifikasjon for tunnelbelysning\b0\par
|
||||
\pard\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
|
||||
\pard\sa180\b Luminanskrav per trafikklasse\b0\par
|
||||
\pard\sa180 Terskelluminansen er 145 candela og gjelder i hele overgangssonen.\par
|
||||
}
|
||||
4
tests/fixtures/k2-rtf-variants/krav-rikt-tekstformat-fet-i-avsnitt.rtf
vendored
Normal file
4
tests/fixtures/k2-rtf-variants/krav-rikt-tekstformat-fet-i-avsnitt.rtf
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}\pard\sa180\b Kravspesifikasjon for tunnelbelysning\b0\par
|
||||
\pard\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
|
||||
\pard\sa180 Avvik fra kravene krever soknad om \b fravik\b0 fra byggherren for arbeidet starter.\par
|
||||
}
|
||||
5
tests/fixtures/k2-rtf-variants/krav-rikt-tekstformat-stil.rtf
vendored
Normal file
5
tests/fixtures/k2-rtf-variants/krav-rikt-tekstformat-stil.rtf
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\froman Times New Roman;}}{\stylesheet{\s0 Normal;}{\s1\b\fs32 heading 1;}{\s2\b\fs28 heading 2;}}\pard\s1\b\fs32 Kravspesifikasjon for tunnelbelysning\b0\fs24\par
|
||||
\pard\s0\sa180 Dokumentet samler kravene til belysning i vegtunneler over 500 meter.\par
|
||||
\pard\s2\b\fs28 Luminanskrav per trafikklasse\b0\fs24\par
|
||||
\pard\s0\sa180 Terskelluminansen er 145 candela og gjelder i hele overgangssonen.\par
|
||||
}
|
||||
110
tests/fixtures/make_k2_office.py
vendored
110
tests/fixtures/make_k2_office.py
vendored
|
|
@ -26,6 +26,11 @@ from pathlib import Path
|
|||
|
||||
HERE = Path(__file__).parent
|
||||
OUT = HERE / "k2-office"
|
||||
#: Round 10's rtf variants live in their OWN directory, and that is not tidiness.
|
||||
#: `test_k2_office_fixtures.py` reads N off `k2-office/` by listing it, because
|
||||
#: Door B walks a drop directory RECURSIVELY -- anything parked beside the three
|
||||
#: containers would enter that run and the denominator would stop being three.
|
||||
RTF_OUT = HERE / "k2-rtf-variants"
|
||||
|
||||
TITLE = "Kravspesifikasjon for tunnelbelysning"
|
||||
INTRO = "Dokumentet samler kravene til belysning i vegtunneler over 500 meter."
|
||||
|
|
@ -307,6 +312,95 @@ def _rtf_row(row: tuple[str, ...]) -> str:
|
|||
return "\\trowd\\trgaph108" + borders + "\n" + cells + "\\row\n"
|
||||
|
||||
|
||||
# --- the three rtf VARIANTS round 10 added ------------------------------------
|
||||
#
|
||||
# WHY THEY EXIST. Round 9 measured the `rtf` row on ONE document and it came
|
||||
# back at 0 of 0 declared headings, 0 concepts, 1368 of 1368 characters in no
|
||||
# segment. A repair proposed on N = 1 is a repair fitted to one file, so round
|
||||
# 10's order required N >= 3 before any rule was written. Three lawful sources
|
||||
# were offered; this is the first of them, and it is the one this repository's
|
||||
# committed-fixture policy allows: hand-laid here, in the same file, with the
|
||||
# fasit written BEFORE the measurement.
|
||||
#
|
||||
# WHAT EACH ONE VARIES, and it is one axis each:
|
||||
#
|
||||
# -fet-alene the title is bold at BODY size, with no point-size change
|
||||
# at all. Variant A (`krav-rikt-tekstformat.rtf`) sets its
|
||||
# title bold AND at 16pt, so without this document a rule
|
||||
# reading "bold" could be passing on the size instead.
|
||||
# -fet-i-avsnitt a bold PHRASE inside a running sentence, which must NOT
|
||||
# become a boundary. The document's own known-negative.
|
||||
# -stil the container DECLARES heading styles (`\\s1`, `\\s2`) in a
|
||||
# stylesheet. This is the control for the whole repair, and
|
||||
# it is a control that FAILED in an informative direction:
|
||||
# measured, the vendored converter discards the style and
|
||||
# emits the same bold line, so "read the declared style"
|
||||
# is not a route that exists for `rtf`. The fixture is kept
|
||||
# precisely because it pins that.
|
||||
#
|
||||
# The bodies are deliberately short. These documents answer where a boundary
|
||||
# is, not how much text survives -- variant A already carries the coverage and
|
||||
# fidelity counts for the container.
|
||||
|
||||
RTF_VARIANT_TITLE = "Kravspesifikasjon for tunnelbelysning"
|
||||
RTF_VARIANT_SECTION = "Luminanskrav per trafikklasse"
|
||||
RTF_VARIANT_BODY_ONE = "Dokumentet samler kravene til belysning i vegtunneler over 500 meter."
|
||||
RTF_VARIANT_BODY_TWO = "Terskelluminansen er 145 candela og gjelder i hele overgangssonen."
|
||||
#: The bold RUN in `-fet-i-avsnitt`, set inside a sentence that continues past
|
||||
#: it. Authored as emphasis, never as a title.
|
||||
RTF_VARIANT_EMPHASIS = "fravik"
|
||||
|
||||
|
||||
def _rtf_document(paragraphs: list[str]) -> bytes:
|
||||
"""Wrap hand-written paragraph bodies in the minimal rtf container."""
|
||||
header = "{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0\\froman Times New Roman;}}"
|
||||
return (header + "".join(paragraphs) + "}").encode("ascii")
|
||||
|
||||
|
||||
def rtf_bold_only_bytes() -> bytes:
|
||||
"""Title and section name bold at BODY size -- no point-size change."""
|
||||
return _rtf_document(
|
||||
[
|
||||
"\\pard\\sa180\\b " + _rtf_escape(RTF_VARIANT_TITLE) + "\\b0\\par\n",
|
||||
"\\pard\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_ONE) + "\\par\n",
|
||||
"\\pard\\sa180\\b " + _rtf_escape(RTF_VARIANT_SECTION) + "\\b0\\par\n",
|
||||
"\\pard\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_TWO) + "\\par\n",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def rtf_bold_inside_paragraph_bytes() -> bytes:
|
||||
"""One bold title, and one bold phrase mid-sentence that is not a title."""
|
||||
sentence = (
|
||||
"Avvik fra kravene krever soknad om "
|
||||
+ "\\b "
|
||||
+ _rtf_escape(RTF_VARIANT_EMPHASIS)
|
||||
+ "\\b0 "
|
||||
+ " fra byggherren for arbeidet starter."
|
||||
)
|
||||
return _rtf_document(
|
||||
[
|
||||
"\\pard\\sa180\\b " + _rtf_escape(RTF_VARIANT_TITLE) + "\\b0\\par\n",
|
||||
"\\pard\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_ONE) + "\\par\n",
|
||||
"\\pard\\sa180 " + sentence + "\\par\n",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def rtf_styled_bytes() -> bytes:
|
||||
"""The container DECLARES heading styles. The converter discards them."""
|
||||
stylesheet = "{\\stylesheet{\\s0 Normal;}{\\s1\\b\\fs32 heading 1;}{\\s2\\b\\fs28 heading 2;}}"
|
||||
return _rtf_document(
|
||||
[
|
||||
stylesheet,
|
||||
"\\pard\\s1\\b\\fs32 " + _rtf_escape(RTF_VARIANT_TITLE) + "\\b0\\fs24\\par\n",
|
||||
"\\pard\\s0\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_ONE) + "\\par\n",
|
||||
"\\pard\\s2\\b\\fs28 " + _rtf_escape(RTF_VARIANT_SECTION) + "\\b0\\fs24\\par\n",
|
||||
"\\pard\\s0\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_TWO) + "\\par\n",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def rtf_bytes() -> bytes:
|
||||
body = [
|
||||
"{\\rtf1\\ansi\\ansicpg1252\\deff0",
|
||||
|
|
@ -323,11 +417,15 @@ def rtf_bytes() -> bytes:
|
|||
|
||||
if __name__ == "__main__":
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
RTF_OUT.mkdir(parents=True, exist_ok=True)
|
||||
written = {
|
||||
"krav-presentasjon.pptx": build_container(pptx_parts()),
|
||||
"krav-tekstdokument.odt": build_container(odt_parts(), stored_first="mimetype"),
|
||||
"krav-rikt-tekstformat.rtf": rtf_bytes(),
|
||||
OUT / "krav-presentasjon.pptx": build_container(pptx_parts()),
|
||||
OUT / "krav-tekstdokument.odt": build_container(odt_parts(), stored_first="mimetype"),
|
||||
OUT / "krav-rikt-tekstformat.rtf": rtf_bytes(),
|
||||
RTF_OUT / "krav-rikt-tekstformat-fet-alene.rtf": rtf_bold_only_bytes(),
|
||||
RTF_OUT / "krav-rikt-tekstformat-fet-i-avsnitt.rtf": rtf_bold_inside_paragraph_bytes(),
|
||||
RTF_OUT / "krav-rikt-tekstformat-stil.rtf": rtf_styled_bytes(),
|
||||
}
|
||||
for name, payload in sorted(written.items()):
|
||||
(OUT / name).write_bytes(payload)
|
||||
print(f"wrote {name} ({len(payload)} bytes)")
|
||||
for path, payload in sorted(written.items()):
|
||||
path.write_bytes(payload)
|
||||
print(f"wrote {path.parent.name}/{path.name} ({len(payload)} bytes)")
|
||||
|
|
|
|||
242
tests/test_bold_title.py
Normal file
242
tests/test_bold_title.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""A bold standalone line is a title where the document declares nothing else.
|
||||
|
||||
THE HOLE THIS CLOSES. Round 9 met `rtf` end to end for the first time and the
|
||||
row came back at 0 of 0 declared headings, 0 concepts, 1368 of 1368 characters
|
||||
in no segment, N = 1: the container has no heading style, so the author's title
|
||||
is bold text, the proposer proposes nothing, and the document reaches Door B's
|
||||
inbox as one flat concept. Content preserved, structure zero.
|
||||
|
||||
WHAT THE GRAMMAR READS, AND WHERE IT LIVES. Not `rtf` markup. The converter
|
||||
already emits that bold title as `**Kravspesifikasjon for tunnelbelysning**` in
|
||||
the SAME markdown the other office rows produce, so this rule is a markdown
|
||||
rule and no `rtf`-only grammar exists -- the same shape of decision as the PDF
|
||||
font reader emitting ATX rather than a PDF-only heading form. Bold INSIDE a
|
||||
paragraph is `text **bold** text` and is not a whole line, which is the
|
||||
discriminator the rule rests on.
|
||||
|
||||
THREE PARAMETERS WERE SWEPT AND ONE CARRIES. Over 47 readable documents (3
|
||||
hand-built office fixtures, 5 in the operator's folder, 39 of the 43 corpus
|
||||
files), with the false-positive denominator measured FIRST:
|
||||
|
||||
* a maximum title length (unlimited / 40 / 60 / 80 / 120 characters) is
|
||||
FLAT -- identical counts at every value;
|
||||
* requiring the line to stand alone between blank lines is FLAT -- the
|
||||
converter already writes one paragraph per line;
|
||||
* refusing a line that ends in terminal punctuation carries: false-positive
|
||||
lines fall 9-12 to 1-2, and the four candidates it drops are
|
||||
`er inngatt mellom:`, `Sted og dato:` and their kind -- sentence fragments
|
||||
a contract's cover page sets in bold, never section names.
|
||||
|
||||
A parameter measured flat buys nothing, so neither of the two is in the rule.
|
||||
|
||||
THE GATE IS G1, ALREADY SHIPPED. One false positive survived the punctuation
|
||||
clause: a corpus `docx` that DECLARES two headings of its own and sets one
|
||||
later line (`EGENERKLAERING`) in bold. The repair is not a fourth parameter --
|
||||
it is the principle `_gate_outline` and `fold_units` clause 2 already carry,
|
||||
that recovery yields to declaration. A bold-title candidate is admitted only
|
||||
where the document declares no heading of its own, which takes the
|
||||
false-positive count to 0 of 31 declaring documents BY CONSTRUCTION.
|
||||
|
||||
WHAT WAS MEASURED AND NOT ADDED. `heading_reserve_applies` yields to an
|
||||
admitted Arm D run as well as to a declaration, and that axis was measured
|
||||
here before being left out: over all 4 documents that reach this rule, the
|
||||
outline gate admits 0 runs and the reserve applies on every one. n = 4, which
|
||||
is small; the axis is flat over it, so coupling this rule to `outline_run`
|
||||
would add a knob no measurement asks for.
|
||||
|
||||
HONESTY LIMIT: the `rtf` row rests on hand-built documents, not corpus files.
|
||||
The corpus contains ZERO `rtf`. Its evidence class stays `constructed`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from llm_ingestion_okf import cli
|
||||
from llm_ingestion_okf.extract import extract_text
|
||||
from llm_ingestion_okf.propose import RULE_BOLD_TITLE, RULE_HEADING, find_candidates
|
||||
|
||||
#: Variant A stays with the three-container set; round 10's three variants live
|
||||
#: in their own directory, because `test_k2_office_fixtures.py` reads N off that
|
||||
#: one by listing it and Door B walks a drop directory recursively.
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "k2-office"
|
||||
RTF_VARIANTS = Path(__file__).parent / "fixtures" / "k2-rtf-variants"
|
||||
FASIT_PATH = Path(__file__).parent / "fixtures" / "k2-office-fasit.json"
|
||||
|
||||
DEFAULT = dict(
|
||||
outline_run=cli.DEFAULT_OUTLINE_RUN,
|
||||
table_grid=cli.DEFAULT_TABLE_GRID,
|
||||
unit_fold=cli.DEFAULT_UNIT_FOLD,
|
||||
keep_table_heading=cli.DEFAULT_KEEP_TABLE_HEADING,
|
||||
sheet_section_rows=cli.DEFAULT_SHEET_SECTION_ROWS,
|
||||
drop_wrapped_outline=cli.DEFAULT_DROP_WRAPPED_OUTLINE,
|
||||
outline_gate=cli.DEFAULT_OUTLINE_GATE,
|
||||
first_span_from_zero=cli.DEFAULT_FIRST_SPAN_FROM_ZERO,
|
||||
close_span_gaps=cli.DEFAULT_CLOSE_SPAN_GAPS,
|
||||
contents_name=cli.DEFAULT_CONTENTS_NAME,
|
||||
)
|
||||
|
||||
#: The shape the converter hands back for the `rtf` fixture: a bold title, a
|
||||
#: prose paragraph, then a grid table of label/value pairs.
|
||||
BOLD_TITLED = """**Kravspesifikasjon for tunnelbelysning**
|
||||
|
||||
Dokumentet samler kravene til belysning i vegtunneler over 500 meter.
|
||||
|
||||
----------------------- ------------------------------
|
||||
Dokumentnummer: SVV-2026-0417
|
||||
Tittel: Tunnelbelysning i hovedlopet
|
||||
----------------------- ------------------------------
|
||||
|
||||
**Luminansmatrise**
|
||||
|
||||
Matrisen gjelder per trafikklasse og er bindende.
|
||||
"""
|
||||
|
||||
#: The known-negative for the whole-line clause. `**fet**` here is a phrase
|
||||
#: inside a running sentence, which is what a bold RUN looks like and what the
|
||||
#: rule must never read as a boundary.
|
||||
BOLD_INSIDE_A_PARAGRAPH = """Dokumentet samler kravene, og et **fremhevet** ord
|
||||
staar midt i en setning som fortsetter forbi det.
|
||||
|
||||
Neste avsnitt naevner **ogsaa** noe fremhevet i loepende tekst.
|
||||
"""
|
||||
|
||||
#: The false-positive denominator in miniature: a document that declares its
|
||||
#: own heading AND sets a later line in bold. The bold line must not become a
|
||||
#: boundary here -- recovery yields to declaration.
|
||||
DECLARES_AND_ALSO_BOLDS = """# Konkurransegrunnlag
|
||||
|
||||
Innledende avsnitt som beskriver konkurransen og dens omfang.
|
||||
|
||||
**EGENERKLAERING**
|
||||
|
||||
Leverandoren bekrefter at opplysningene i skjemaet er riktige.
|
||||
"""
|
||||
|
||||
#: The punctuation clause's own material: a contract cover page whose bold
|
||||
#: lines are sentence fragments, not section names.
|
||||
BOLD_SENTENCE_FRAGMENTS = """**er inngaatt mellom:**
|
||||
|
||||
Byggherren og leverandoren, representert ved sine kontaktpersoner.
|
||||
|
||||
**Sted og dato:**
|
||||
|
||||
Oslo, 1. mars 2026, med signatur fra begge parter i to eksemplarer.
|
||||
"""
|
||||
|
||||
|
||||
def _fixture(name: str) -> Path:
|
||||
"""Variant A in the three-container set, the three variants beside it."""
|
||||
return FIXTURES / name if (FIXTURES / name).is_file() else RTF_VARIANTS / name
|
||||
|
||||
|
||||
def _titles(text: str, **overrides: object) -> list[str]:
|
||||
keywords = {**DEFAULT, **overrides}
|
||||
return [c.title for c in find_candidates(text, **keywords) if c.rule == RULE_BOLD_TITLE]
|
||||
|
||||
|
||||
def test_a_bold_standalone_line_becomes_a_title_candidate() -> None:
|
||||
"""The `rtf` row's whole finding: the title the container never declared."""
|
||||
assert _titles(BOLD_TITLED, bold_title=True) == [
|
||||
"Kravspesifikasjon for tunnelbelysning",
|
||||
"Luminansmatrise",
|
||||
]
|
||||
|
||||
|
||||
def test_bold_inside_a_paragraph_is_not_a_title_candidate() -> None:
|
||||
"""The known-negative for the whole-line clause, in the same file."""
|
||||
assert _titles(BOLD_INSIDE_A_PARAGRAPH, bold_title=True) == []
|
||||
|
||||
|
||||
def test_a_declared_heading_closes_the_rule_for_the_whole_document() -> None:
|
||||
"""G1: recovery yields to declaration, so the false positive cannot occur.
|
||||
|
||||
The document's own `# Konkurransegrunnlag` survives; the bold line below it
|
||||
proposes nothing. This is the single false positive measured over the 47
|
||||
documents, reduced to its mechanism.
|
||||
"""
|
||||
assert _titles(DECLARES_AND_ALSO_BOLDS, bold_title=True) == []
|
||||
declared = [
|
||||
c.title
|
||||
for c in find_candidates(DECLARES_AND_ALSO_BOLDS, **{**DEFAULT, "bold_title": True})
|
||||
if c.rule == RULE_HEADING
|
||||
]
|
||||
assert declared == ["Konkurransegrunnlag"], "the gate must not touch the declared heading"
|
||||
|
||||
|
||||
def test_a_bold_line_ending_in_terminal_punctuation_is_not_a_title() -> None:
|
||||
"""The one swept parameter that carried, pinned with its own material."""
|
||||
assert _titles(BOLD_SENTENCE_FRAGMENTS, bold_title=True) == []
|
||||
|
||||
|
||||
def test_the_rule_proposes_nothing_when_the_flag_is_off() -> None:
|
||||
"""The opt-out reproduces the pre-rule bytes on the rule's own material."""
|
||||
assert _titles(BOLD_TITLED, bold_title=False) == []
|
||||
assert find_candidates(BOLD_TITLED, **{**DEFAULT, "bold_title": False}) == find_candidates(
|
||||
BOLD_TITLED, **DEFAULT
|
||||
)
|
||||
|
||||
|
||||
def test_the_rtf_row_recovers_every_authored_title_over_four_documents() -> None:
|
||||
"""The row this round exists to move, pinned against the hand-written fasit.
|
||||
|
||||
Round 9: 0 of 0 declared headings, 0 concepts, 1368 of 1368 characters in
|
||||
no segment, N = 1. Here: 6 of 6 AUTHORED titles over N = 4, 0 false bold
|
||||
titles, 0 of 1994 characters in no segment.
|
||||
|
||||
`authored_titles` is deliberately not a count of what the container
|
||||
declares. `rtf` has no heading style, so a "declared heading" count for
|
||||
this type is 0 by construction and says nothing about whether the document
|
||||
has sections. The fasit counts what a reader of the SOURCE would call a
|
||||
section name, hand-written before this measurement ran.
|
||||
|
||||
The fourth document is a control that failed informatively: it DECLARES
|
||||
`\\s1` and `\\s2` in a stylesheet, and the vendored converter discards the
|
||||
style and emits the same bold line. "Read the declared style" is therefore
|
||||
not a route that exists for this container -- which is why the rule reads
|
||||
markdown and not `rtf`.
|
||||
"""
|
||||
fasit = json.loads(FASIT_PATH.read_text(encoding="utf-8"))["rtf_variants"]
|
||||
recovered = authored = false_titles = unsegmented = total = 0
|
||||
for name, want in sorted(fasit["documents"].items()):
|
||||
path = _fixture(name)
|
||||
text = extract_text(path.name, path.read_bytes())
|
||||
found = find_candidates(text, **{**DEFAULT, "bold_title": True})
|
||||
titles = [c.title for c in found]
|
||||
authored += want["authored_titles"]
|
||||
recovered += sum(1 for t in want["titles"] if t in titles)
|
||||
false_titles += sum(
|
||||
1 for c in found if c.rule == RULE_BOLD_TITLE and c.title not in want["titles"]
|
||||
)
|
||||
total += len(text)
|
||||
unsegmented += len(text) - sum(c.end - c.start for c in found)
|
||||
assert (recovered, authored) == (6, 6), "every authored title, over four containers"
|
||||
assert false_titles == 0, "the bold RUN inside a sentence must not become a title"
|
||||
assert (unsegmented, total) == (0, 1994), "no character left outside a segment"
|
||||
|
||||
|
||||
def test_the_row_is_zero_without_the_rule_which_is_round_nine_reproduced() -> None:
|
||||
"""The known-negative for the row: turn the rule off and the finding returns."""
|
||||
fasit = json.loads(FASIT_PATH.read_text(encoding="utf-8"))["rtf_variants"]
|
||||
for name in sorted(fasit["documents"]):
|
||||
path = _fixture(name)
|
||||
text = extract_text(path.name, path.read_bytes())
|
||||
found = find_candidates(text, **{**DEFAULT, "bold_title": False})
|
||||
assert found == [], f"{name} proposes nothing without the rule"
|
||||
|
||||
|
||||
def test_the_container_declaring_a_heading_style_still_reaches_us_as_bold() -> None:
|
||||
"""The measurement that felled both alternatives the order named.
|
||||
|
||||
Alternative (i) was "read the declared headings out of the converter's
|
||||
markdown". There are none: this document declares `\\s1`/`\\s2` and the
|
||||
converter emits `**...**`. Alternative (ii) was "convert `rtf` to `docx`
|
||||
and let the `docx` grammar work" -- measured separately, that yields 0 ATX
|
||||
headings on this same document, because the loss happens in the `rtf`
|
||||
READER before any writer sees the style.
|
||||
"""
|
||||
path = _fixture("krav-rikt-tekstformat-stil.rtf")
|
||||
text = extract_text(path.name, path.read_bytes())
|
||||
assert "**Kravspesifikasjon for tunnelbelysning**" in text
|
||||
assert [line for line in text.splitlines() if line.startswith("#")] == []
|
||||
|
|
@ -152,3 +152,73 @@ def test_the_reading_default_is_what_holds_row_one_on_these_bytes() -> None:
|
|||
ranks.append(okf_consume_measure.hit_rank(excerpts, entry["gold_document"]))
|
||||
assert ranks[0] == 2, "the known-negative stopped being negative"
|
||||
assert tuple(ranks[1:]) == EXPECTED_RANKS[1:]
|
||||
|
||||
|
||||
@requires_default_bundle
|
||||
def test_the_stem_rule_holds_every_rank_on_the_shipped_bytes() -> None:
|
||||
"""Round 10's reading-side default, pinned the way `--tie-shared-rank` is.
|
||||
|
||||
The rule requires a shared prefix to be a word the bundle uses. It moves a
|
||||
payload with NO bundle changing, which is the same class of change as the
|
||||
tie-break above, so it gets the same treatment: the ranks are pinned WITH
|
||||
it, and the test below pins what it costs when it is off.
|
||||
|
||||
Measured on these bytes, control run first: `under` occurs 79 times by
|
||||
equality and matches 172 concepts by prefix; `bilateral` occurs 0 times and
|
||||
matched 400 of 453 before this rule and 0 after; `standhaftig` 0 and
|
||||
219 -> 56. `undersjoisk` goes 172 -> 162 and stops there, because `under`
|
||||
IS a word here -- a genuine Norwegian morpheme rather than a residual.
|
||||
"""
|
||||
questions = json.loads(GOLD_SET.read_text(encoding="utf-8"))["questions"]
|
||||
ranks = []
|
||||
for entry in questions:
|
||||
payload = okf_consume.build_payload(DEFAULT_BUNDLE, question=entry["question"])
|
||||
excerpts = payload["excerpts"]
|
||||
assert isinstance(excerpts, list)
|
||||
ranks.append(okf_consume_measure.hit_rank(excerpts, entry["gold_document"]))
|
||||
assert tuple(ranks) == EXPECTED_RANKS
|
||||
|
||||
|
||||
@requires_default_bundle
|
||||
def test_the_stem_rule_is_what_keeps_bilateral_out_of_four_hundred_concepts() -> None:
|
||||
"""The known-negative for the stem rule, on the shipped bundle.
|
||||
|
||||
Without it the pin above would be green with no stated cause. The number
|
||||
that names the cause is the one the rule was built for: a word occurring
|
||||
ZERO times reaching 400 of 453 concepts.
|
||||
|
||||
The control is measured FIRST and in the same loop, because round 9's first
|
||||
attempt at this measurement returned 0 for the probe AND for the control --
|
||||
`enumerate_concepts` yields `str`, not `Path` -- and a zero that is really a
|
||||
broken query is indistinguishable from a zero that is a result.
|
||||
"""
|
||||
root_id = okf_consume.root_bundle_id_of(DEFAULT_BUNDLE)
|
||||
concepts = [
|
||||
okf_consume.read_concept(
|
||||
DEFAULT_BUNDLE / f"{concept_id}.md",
|
||||
bundle_root=DEFAULT_BUNDLE,
|
||||
root_bundle_id=root_id,
|
||||
)
|
||||
for concept_id in okf_consume.enumerate_concepts(DEFAULT_BUNDLE)
|
||||
]
|
||||
documents = [okf_consume.normalise(text) for text in okf_consume.searchable_text(concepts)]
|
||||
stems = frozenset(token for document in documents for token in document)
|
||||
|
||||
def reach(word: str, *, stemmed: bool) -> int:
|
||||
token = okf_consume.normalise(word)[0]
|
||||
return sum(
|
||||
1
|
||||
for document in documents
|
||||
for _ in (0,)
|
||||
if any(
|
||||
okf_consume.tokens_match(token, other, stems=stems if stemmed else None)
|
||||
for other in document
|
||||
)
|
||||
)
|
||||
|
||||
assert sum(1 for d in documents if okf_consume.normalise("under")[0] in d) == 79, (
|
||||
"the CONTROL first: `under` really does occur, so the query can find"
|
||||
)
|
||||
assert reach("bilateral", stemmed=False) == 400, "the defect, on these bytes"
|
||||
assert reach("bilateral", stemmed=True) == 0, "and what the rule does to it"
|
||||
assert reach("under", stemmed=True) == 172, "a real word keeps its compounds"
|
||||
|
|
|
|||
105
tests/test_okf_consume_shim.py
Normal file
105
tests/test_okf_consume_shim.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""A path importer holds the module object, and `sys.modules` is not it.
|
||||
|
||||
WHAT WAS REPORTED, AND BY WHOM. `vegnormal-okf` reported after v0.7.0 that the
|
||||
shim broke a caller importing it with `importlib.util.spec_from_file_location`.
|
||||
Reproduced here, and it is not a spelling mistake: `sys.modules[__name__] =
|
||||
_impl` replaces the REGISTRY entry, and a path importer already holds a
|
||||
different module object -- the one `module_from_spec` made and `exec_module`
|
||||
ran. That object keeps whatever the file's own globals ended up with, which is
|
||||
four public names, while the registry entry has ninety.
|
||||
|
||||
TWO COUNTING METHODS, BOTH IN THE TEST. `vars()` gives 4 against 90 and `dir()`
|
||||
gives 3 against 75; the numbers differ because `dir()` on a module is sorted
|
||||
and de-duplicated over a different set. Asserting only that ONE name appears
|
||||
would be green over a nearly empty set -- which is how the defect survived a
|
||||
release -- so the test asserts the COUNT under both methods.
|
||||
|
||||
WHAT THE FIX IS AND WHAT IT IS NOT. One line, copying the implementation's
|
||||
public names into this module's globals BEFORE the alias. It restores attribute
|
||||
ACCESS. It does NOT restore patch-through: a caller who monkeypatches the copy
|
||||
patches a binding the implementation never reads, and that is exactly why the
|
||||
alias exists and why it stays. The dunder filter is load-bearing -- an
|
||||
unfiltered `vars(_impl)` overwrites `__name__` with
|
||||
`llm_ingestion_okf.consume` before the next line reads it, and the module is
|
||||
then aliased under the wrong key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "tools"))
|
||||
|
||||
SHIM = PROJECT_ROOT / "tools" / "okf_consume.py"
|
||||
|
||||
|
||||
def _load_by_path(name: str) -> ModuleType:
|
||||
"""Exactly what the reporting caller does, and nothing else."""
|
||||
spec = importlib.util.spec_from_file_location(name, SHIM)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
saved = sys.modules.get(name)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
if saved is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = saved
|
||||
return module
|
||||
|
||||
|
||||
def _public_by_vars(module: ModuleType) -> set[str]:
|
||||
return {name for name in vars(module) if not name.startswith("_")}
|
||||
|
||||
|
||||
def _public_by_dir(module: ModuleType) -> set[str]:
|
||||
return {name for name in dir(module) if not name.startswith("_")}
|
||||
|
||||
|
||||
def test_a_path_imported_shim_carries_the_implementations_public_names() -> None:
|
||||
"""The defect, stated as the count rather than as one name."""
|
||||
from llm_ingestion_okf import consume
|
||||
|
||||
module = _load_by_path("okf_consume_path_imported")
|
||||
for method in (_public_by_vars, _public_by_dir):
|
||||
held = method(module)
|
||||
registry = method(consume)
|
||||
missing = registry - held
|
||||
assert not missing, (
|
||||
f"{method.__name__}: the path-imported object is missing "
|
||||
f"{len(missing)} of {len(registry)} public names"
|
||||
)
|
||||
|
||||
|
||||
def test_build_payload_is_reachable_on_the_path_imported_object() -> None:
|
||||
"""The specific call the reporting caller makes."""
|
||||
from llm_ingestion_okf import consume
|
||||
|
||||
module = _load_by_path("okf_consume_path_imported_two")
|
||||
assert module.build_payload is consume.build_payload
|
||||
|
||||
|
||||
def test_the_dunder_filter_leaves_the_modules_own_name_alone() -> None:
|
||||
"""The known-negative for the filter: without it the alias key is wrong.
|
||||
|
||||
An unfiltered copy would set `__name__` to `llm_ingestion_okf.consume`,
|
||||
and the very next line uses `__name__` as the `sys.modules` key.
|
||||
"""
|
||||
name = "okf_consume_path_imported_three"
|
||||
module = _load_by_path(name)
|
||||
assert module.__name__ == name
|
||||
assert module.__file__ is not None and module.__file__.endswith("okf_consume.py")
|
||||
|
||||
|
||||
def test_importing_it_by_name_still_hands_back_the_packaged_module() -> None:
|
||||
"""The alias is unchanged: patch-through is what it buys and it stays."""
|
||||
import okf_consume
|
||||
|
||||
from llm_ingestion_okf import consume
|
||||
|
||||
assert okf_consume is consume
|
||||
130
tests/test_stem_prefix.py
Normal file
130
tests/test_stem_prefix.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""The shared prefix has to be a WORD, or `bilateral` reaches 400 of 453.
|
||||
|
||||
THE FINDING, RE-MEASURED. `MIN_SHARED_PREFIX = 4` exists for Norwegian
|
||||
compounding -- `vare|ne` and `vare|mottak` share a stem -- and it has a
|
||||
known-positive in the suite. It also matches on four characters that are not a
|
||||
stem at all. Measured on the pinned 453-concept bundle, with the control run
|
||||
FIRST every time:
|
||||
|
||||
word equality prefix share
|
||||
under (control) 79 172 38.0 %
|
||||
undersjoisk 0 172 38.0 %
|
||||
bilateral 0 400 88.3 %
|
||||
standhaftig 0 219 48.3 %
|
||||
|
||||
The two extra known-negative words were FOUND rather than chosen: every
|
||||
4-character prefix in the bundle was ranked by document frequency, and a real
|
||||
Norwegian word was taken from the widest ones -- `bila` (400 of 453, through
|
||||
`bilag`) and `stan` (219, through `standard`).
|
||||
|
||||
THREE CANDIDATES WERE MEASURED AND ALL THREE FAILED, each on the SAME row.
|
||||
|
||||
a. a longer floor (5, 6, 7, 8) row 1 falls 1 -> 2 on the default
|
||||
bundle and 1 -> None on Arm B
|
||||
b. coverage >= 0.5 / 0.6 / 0.7 / 0.8 every negative to 0, control still
|
||||
88 > 79, and row 1 still falls
|
||||
c. prefix only for words >= 6/8/10/12 only >= 6 holds the ranks, and it
|
||||
fixes `bilateral` alone: 137 and
|
||||
219 remain
|
||||
|
||||
WHY THEY ALL FAIL ON ONE ROW, decomposed rather than guessed. Row 1's question
|
||||
token `prisene` reaches its gold document through `pris|sammenstilling` and
|
||||
`pris|skjema` on the four characters `pris`. That is 0.57 coverage of the
|
||||
question word and 0.22 of the document word -- so a coverage rule at 0.60 cuts
|
||||
exactly the match that finds the price sheet. The over-match and the wanted
|
||||
match are the same mechanism seen from two sides, which is why length and
|
||||
coverage cannot separate them.
|
||||
|
||||
WHAT DOES SEPARATE THEM. `pris` is a word; `bila` and `stan` are not. The
|
||||
fourth candidate requires the shared prefix to occur as a token in the bundle's
|
||||
own concepts, and it is the only one that clears both criteria at once:
|
||||
|
||||
bundle bilateral standhaftig undersjoisk control ranks
|
||||
default-20260912 400 -> 0 219 -> 56 172 -> 162 172 (1,1,1,1,1,None)
|
||||
armB-20260903 512 -> 0 235 -> 33 174 -> 160 174 (1,1,1,1,1,None)
|
||||
|
||||
WHAT REMAINS, AND WHY IT IS NOT A CEILING BUT A DIFFERENT ANSWER. `undersjoisk`
|
||||
still reaches 162 concepts, because it shares `under` with them and `under` IS
|
||||
a word in this corpus -- a productive Norwegian prefix. `standhaftig` still
|
||||
reaches 56 through `stand`. Those are genuine shared morphemes, and a rule that
|
||||
cut them would be cutting the behaviour `MIN_SHARED_PREFIX` was built for. The
|
||||
residual is a statement about Norwegian, not about the implementation.
|
||||
|
||||
HONESTY LIMITS: n = 6 questions, one rater, one gold set, and row 6 misses on
|
||||
every bundle and every configuration measured so far. The three known-negative
|
||||
words were chosen by this session from the bundle's own vocabulary, not by an
|
||||
independent source. The vocabulary is the BUNDLE's, so this rule makes a
|
||||
payload depend on the corpus the way `rarity_weights` already does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from llm_ingestion_okf import consume
|
||||
|
||||
#: The corpus vocabulary in miniature. `pris` and `under` stand on their own;
|
||||
#: `bila` and `stan` never do, which is the whole discriminator.
|
||||
STEMS = frozenset(
|
||||
{
|
||||
"pris",
|
||||
"prisene",
|
||||
"prissammenstilling",
|
||||
"prisskjema",
|
||||
"under",
|
||||
"underbygning",
|
||||
"bilag",
|
||||
"standard",
|
||||
"varemottak",
|
||||
"varene",
|
||||
"vare",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_a_shared_prefix_that_is_not_a_word_no_longer_matches() -> None:
|
||||
"""`bila` is four characters of `bilag` and of `bilateral`, and no word."""
|
||||
assert consume.tokens_match("bilateral", "bilag") is True, "the shipped rule matches"
|
||||
assert consume.tokens_match("bilateral", "bilag", stems=STEMS) is False
|
||||
|
||||
|
||||
def test_a_shared_prefix_that_is_a_word_still_matches() -> None:
|
||||
"""The known-positive the repair must not kill: row 1 depends on it.
|
||||
|
||||
`pris` is 0.57 of `prisene` and 0.22 of `prissammenstilling`, so every
|
||||
coverage rule measured cut it -- and cutting it is what cost row 1 its
|
||||
rank on both bundles.
|
||||
"""
|
||||
assert consume.tokens_match("prisene", "prissammenstilling", stems=STEMS) is True
|
||||
assert consume.tokens_match("varene", "varemottak", stems=STEMS) is True
|
||||
|
||||
|
||||
def test_the_productive_norwegian_prefix_is_kept_and_that_is_the_answer() -> None:
|
||||
"""`under` is a word, so `undersjoisk` keeps reaching `underbygning`.
|
||||
|
||||
Stated as a decision rather than left as a residual: this rule does not
|
||||
claim to separate two words that genuinely share a morpheme, and the 162
|
||||
concepts `undersjoisk` still reaches are that morpheme, not a defect.
|
||||
"""
|
||||
assert consume.tokens_match("undersjøisk", "underbygning", stems=STEMS) is True
|
||||
|
||||
|
||||
def test_an_identifier_is_untouched_by_the_stem_rule() -> None:
|
||||
"""The 2026-09-08 identifier fix stays exactly as it was."""
|
||||
assert consume.tokens_match("3.3.1-13", "3.3.1-13", stems=STEMS) is True
|
||||
assert consume.tokens_match("3.3.1-13", "3.3.1-14", stems=STEMS) is False
|
||||
|
||||
|
||||
def test_equality_never_needs_a_stem() -> None:
|
||||
"""A token always matches itself, whatever the vocabulary says."""
|
||||
assert consume.tokens_match("zzzznotinvocab", "zzzznotinvocab", stems=STEMS) is True
|
||||
|
||||
|
||||
def test_without_stems_the_function_is_byte_for_byte_the_shipped_one() -> None:
|
||||
"""The opt-out reproduces the pre-round-10 matcher on its own material."""
|
||||
for left, right in (
|
||||
("bilateral", "bilag"),
|
||||
("prisene", "prissammenstilling"),
|
||||
("standhaftig", "standard"),
|
||||
("krav", "kraft"),
|
||||
("pris", "pri"),
|
||||
):
|
||||
assert consume.tokens_match(left, right) == consume.tokens_match(left, right, stems=None)
|
||||
Loading…
Add table
Add a link
Reference in a new issue