fix(consume,propose): hold an identifier number as one token, give an orphaned heading's name to its table
Two consumer-reported defects, one rebuild. The pre-pass could not see a requirement number: `_TOKEN_SPLIT_RE` split `10.2-2` into digit runs and `MIN_TOKEN_LENGTH` removed them, so a question naming a requirement reached the ranker carrying only the word every concept in a standards bundle carries. Measured on three real bundles (446, 1133 and 270 concepts), the named requirement was withheld `below_k` in three of three. Numeric groups joined by `.` or `-` are now held together, dash variants fold to the ASCII hyphen, and the noise floor is unchanged. The gold moves from 160 to 96, 143 to 9 and 100 to 35 -- a large move, and NOT a delivery: it is still `below_k`, because `_overlap` is a count and an exact requirement number is worth no more than a common verb. That weighting is a separate decision. The rule was narrowed by a measurement: a version that joined alphanumeric groups swallowed a document slug whole and cost a hit@8 row. An equality-only variant was measured on all three bundles and falsified -- better on one, worse on two. The orphan gate destroyed a heading's name: a table opening directly below a heading left that heading with an empty body, the orphan check dropped it, and the surviving table block kept the mechanical `Tabell linje <n>`. A table that orphans its heading now takes that heading's title and section number. Conditioned on the drop, on adjacency, and carrying both members -- each of the three measured or mutation-tested. One K2 rebuild for both, from a frozen source tree: 629 concepts, `39 + 4 = 43 = N`, 2 of 629 ids moved and both moved BACK to the names the 2026-09-03 bundle carried, 1106 of 1108 files identical to it. New ref sha256-tree:2f82fcfea91c3bd3f8ef7147f80cd613227d3ca7975c41d88810233f3f79ab4b -- c26eed6a... is superseded. The regression the previous session measured is closed: candidate rank 19 -> 10, and the delivering command is now `--cost-vocabulary --k 12` inside the default budget at 58 907 o200k against 65 912 before. The specific question is unmoved at rank 1. The tokeniser alone leaves the K2 control question byte-identical, measured with the bundle held fixed and both published byte counts reproduced. Report: docs/2026-09-08-kravnummer-tokenisering.md. 8 new tests, red first; 6 mutations, 6 red, one of them only after the survivor was read as code and a missing fixture was added. Suite 1287 -> 1295. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
56ae274246
commit
56c1205ec4
5 changed files with 550 additions and 5 deletions
336
docs/2026-09-08-kravnummer-tokenisering.md
Normal file
336
docs/2026-09-08-kravnummer-tokenisering.md
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
# The requirement number the pre-pass could not see, and the title the orphan gate destroyed, 2026-09-08
|
||||
|
||||
Two consumer-reported defects, one session, one rebuild. The first is a
|
||||
consumption-side defect reported by `vegnormal-okf`: `okf_consume`'s tokeniser
|
||||
shatters a requirement number, so the one concept an engineer names is withheld.
|
||||
The second is a production-side defect this repository measured on itself in the
|
||||
previous session: a table that opens directly under a heading destroys that
|
||||
heading's name.
|
||||
|
||||
---
|
||||
|
||||
## 0. What IS measured, and what is NOT
|
||||
|
||||
**Measured.** The five tokeniser strings the consumer published, reproduced
|
||||
here before anything was changed; where the number is lost, decomposed to both
|
||||
mechanisms; the gold concept's rank on three real bundles, before and after, with
|
||||
denominators; one alternative rule falsified against the same three bundles; the
|
||||
K2 control question as a payload digest with the tokeniser as the only variable;
|
||||
the title axis reproduced from the previous session's number before it was
|
||||
touched; a full K2 rebuild with the conservation identity, the concept count, the
|
||||
number of ids that moved against a denominator of 629, and the new content
|
||||
identity; and six mutations against the new tests.
|
||||
|
||||
**Known-positive, run first.** Nine figures published elsewhere were reproduced
|
||||
on this machine before any of the after-columns were read:
|
||||
|
||||
| # | known-positive | source | measured here |
|
||||
|---|---|---|---|
|
||||
| 1 | `normalise('Krav 10.2—2')` → `['krav']` | consumer § 3.1 | `('krav',)` |
|
||||
| 2 | `normalise('10.2-2')` → `[]` | consumer § 3.1 | `()` |
|
||||
| 3 | `normalise('3.3.1—13')` → `[]` | consumer § 3.1 | `()` |
|
||||
| 4 | `'N500'` → `['n500']`, `'abc123'` → `['abc123']` | consumer § 3.1 | identical |
|
||||
| 5 | considered / withheld / delivered per bundle | consumer § 3 | 446/438/8, 1133/1125/8, 270/262/8 |
|
||||
| 6 | gold position in `withheld` | consumer § 3.1 | 119 of 438, 204 of 1125, 66 of 262 |
|
||||
| 7 | payload tokens (o200k) per bundle | consumer § 3 | 22 138, 53 444, 14 694 |
|
||||
| 8 | priced concept candidate rank 10 → 19 | this repo, 2026-09-08 (O1) § 7 | 10 → 19, and 251 / 1 / 1 for the other three rows |
|
||||
| 9 | K2 payload bytes, two commands | this repo, O1 § 7 | 169 573 B and 172 588 B |
|
||||
|
||||
**NOT measured.** Whether a live model answers *better* with the named
|
||||
requirement in the payload. This document measures a ranking; the consumer
|
||||
measures the answer. Also not measured: that either rule helps a corpus other
|
||||
than the four read here.
|
||||
|
||||
**Not closed.** The gold requirement is still `below_k` on all three bundles
|
||||
after the fix. § 8 states why, with the number.
|
||||
|
||||
---
|
||||
|
||||
## 1. The five strings, and one correction to how they are read
|
||||
|
||||
```
|
||||
PYTHONPATH=… python3 -c "from okf_consume import normalise; …"
|
||||
'Krav 10.2—2' -> ('krav',) 'N500' -> ('n500',)
|
||||
'10.2-2' -> () 'abc123' -> ('abc123',)
|
||||
'3.3.1—13' -> () '10' -> ()
|
||||
'2.9.2—12' -> () '2' -> ()
|
||||
```
|
||||
|
||||
All five reproduce, and so do the two known-positives. So does the gold's place
|
||||
in the `withheld` list: 119 of 438, 204 of 1125, 66 of 262.
|
||||
|
||||
**That place is a list position and not a rank, and the difference matters.**
|
||||
The `withheld` array is emitted in `concept_id` order — measured, not assumed
|
||||
(`sorted(ids) == ids` is `True` on all three payloads) — so it cannot move when
|
||||
the ranking moves. It is a correct number that answers a different question. The
|
||||
instrument that does move is the fused rank, and § 4 uses it.
|
||||
|
||||
One more figure the consumer's mechanism claim predicts, confirmed here: of the
|
||||
262 withheld entries on the smallest bundle, **262 are `below_k` and none is
|
||||
`no_lexical_match`**. Every concept matches, because every concept carries the
|
||||
word the question carries.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where the number is lost: both mechanisms, not one
|
||||
|
||||
The order asked which of the two is responsible. **Both**, and neither alone
|
||||
would lose it:
|
||||
|
||||
```
|
||||
'10.2-2' split-> ['10', '2', '2'] kept-> []
|
||||
'3.3.1—13' split-> ['3', '3', '1', '13'] kept-> []
|
||||
'R610.4' split-> ['r610', '4'] kept-> ['r610']
|
||||
```
|
||||
|
||||
`_TOKEN_SPLIT_RE` (`tools/okf_consume.py:523` before this change) splits on
|
||||
every non-alphanumeric character, which destroys the identifier; the
|
||||
`MIN_TOKEN_LENGTH` filter in `normalise` (line 537) then removes the digit runs
|
||||
that are left. Lowering the floor alone would not recover the number — it would
|
||||
return `10`, `2` and `2`, which match every page number in the corpus. The
|
||||
identifier has to survive the split.
|
||||
|
||||
---
|
||||
|
||||
## 3. The rule, and the alternative that was falsified
|
||||
|
||||
**The rule.** Numeric groups joined by `.` or `-` are held together as one
|
||||
token, with a leading letter run allowed only where it touches its digits
|
||||
without a separator. Dash variants fold to the ASCII hyphen first, so the same
|
||||
number written with an em dash, an en dash or a hyphen is one token.
|
||||
`MIN_TOKEN_LENGTH` is unchanged; the identifier is exempt from it by
|
||||
construction rather than by lowering it for everyone.
|
||||
|
||||
**It was narrowed by a measurement, not by taste.** The first version joined
|
||||
*alphanumeric* groups across a separator. That version swallowed a whole
|
||||
document slug — a slug of the form `…-3-6-…` became ONE token — and that
|
||||
document's stage-one score for a question naming its subject fell from **0.735
|
||||
to 0.0**, costing one row of the repository's own hit@8 measurement. The
|
||||
narrowed rule is purely additive on such a slug: the identifier is added, the
|
||||
words stay.
|
||||
|
||||
**The falsified alternative.** Because `tokens_match` compares a four-character
|
||||
prefix, `10.2-2` matches `10.2-5`: on the smallest bundle exactly **5 of 270**
|
||||
concept titles match the question's identifier, and all five are the same
|
||||
section. Making identifier tokens match only on *equality* is the obvious
|
||||
narrowing, and it was measured before being adopted or rejected:
|
||||
|
||||
| | prefix (shipped) | equality (probe) |
|
||||
|---|---|---|
|
||||
| N100:2023 | 96 | **53** |
|
||||
| N200:2024 | **9** | 26 |
|
||||
| N500:2024 | **35** | 37 |
|
||||
|
||||
It improves one bundle and makes two worse. **Falsified; not built.**
|
||||
|
||||
---
|
||||
|
||||
## 4. The three bundles, before and after
|
||||
|
||||
Command, one per bundle, the questions quoted verbatim from the consumer's § 3:
|
||||
|
||||
```
|
||||
python3 tools/okf_consume.py <bundle> \
|
||||
--question "Hva krever Krav 10.2—2 i N500? Gjengi det sentrale vilkåret."
|
||||
```
|
||||
|
||||
| | N100:2023 | N200:2024 | N500:2024 |
|
||||
|---|---|---|---|
|
||||
| concepts | 446 | 1 133 | 270 |
|
||||
| gold fused rank, **before** | 160 | 143 | 100 |
|
||||
| gold fused rank, **after** | **96** | **9** | **35** |
|
||||
| gold lexical overlap, before → after | 3 → 4 | 4 → 6 | 3 → 4 |
|
||||
| considered / withheld / delivered | 446/438/8 | 1133/1125/8 | 270/262/8 |
|
||||
| budget spent, before → after | 8 977 → 12 095 | 17 818 → 17 818 | 10 517 → 10 517 |
|
||||
| payload o200k, before → after | 22 138 → 23 583 | 53 444 → 53 444 | 14 694 → 14 694 |
|
||||
| `okf_contract_check` | exit 0, 14 rules, 0 findings | idem | idem |
|
||||
| **gold delivered?** | **no, `below_k`** | **no, `below_k`** | **no, `below_k`** |
|
||||
|
||||
Two of the three payloads are byte-identical before and after: the gold moved a
|
||||
long way without reaching the top eight, so the delivered set did not change.
|
||||
|
||||
**The bonus measurement the order asked for.** Three spellings of the same
|
||||
requirement number, on the same bundle:
|
||||
|
||||
| question spelling | identifier token | gold rank |
|
||||
|---|---|---|
|
||||
| `Krav 10.2—2` (em dash) | `10.2-2` | 35 |
|
||||
| `krav 10.2-2` (hyphen, lower case) | `10.2-2` | 35 |
|
||||
| `Krav 10.2–2` (en dash) | `10.2-2` | 35 |
|
||||
|
||||
All three normalise alike and rank alike.
|
||||
|
||||
---
|
||||
|
||||
## 5. The K2 control: the tokeniser alone changes nothing
|
||||
|
||||
The order required the S7 control question to be compared byte for byte with
|
||||
the tokeniser as the only variable, so it was run against ONE unchanged bundle
|
||||
with the committed tool and the new tool:
|
||||
|
||||
| command | before | after |
|
||||
|---|---|---|
|
||||
| default | `sha256 2ae46f683a3b17fb…`, 169 573 B | **identical** |
|
||||
| `--cost-vocabulary --k 12 --limit 160000` | `sha256 8aaf8558369b3d6b…`, 172 588 B | **identical** |
|
||||
|
||||
Byte for byte, both commands. The two byte counts are the ones published in the
|
||||
previous session's report, which is what makes this a control and not a
|
||||
coincidence. The corpus does carry identifiers of other shapes (`NS3935:2019`,
|
||||
`TEK 17`); measured, the rule leaves both untouched, because a colon is not a
|
||||
separator it claims and a space was never one.
|
||||
|
||||
---
|
||||
|
||||
## 6. The title axis in the orphan gate
|
||||
|
||||
Reproduced first, from the previous session's numbers, before the rule was
|
||||
touched — all four rows to the integer:
|
||||
|
||||
| question | vocabulary | O1 measured | reproduced here |
|
||||
|---|---|---|---|
|
||||
| mandate-shaped | off | 251 of 271 | 251 |
|
||||
| mandate-shaped | on | **19** of 280 | **19** |
|
||||
| specific | off | 1 of 233 | 1 |
|
||||
| specific | on | 1 of 248 | 1 |
|
||||
|
||||
**The rule.** When a heading is dropped by the orphan check and the candidate
|
||||
immediately after it is a table block, the table block takes that heading's
|
||||
title *and* its section number instead of the mechanical `Tabell linje <n>`.
|
||||
|
||||
Three properties of that sentence are each a decision:
|
||||
|
||||
1. **Conditioned on the drop.** A heading that keeps its own body is still
|
||||
carried by a live candidate; copying its title onto the table as well would
|
||||
put one name on two concepts and rescue none.
|
||||
2. **Immediately after.** The name goes to the adjacent segment or nowhere.
|
||||
Carried further, a table several candidates away would take the name of a
|
||||
heading it does not contain — an invention rather than a rescue. This is the
|
||||
one property no test caught until a surviving mutation exposed it (§ 9).
|
||||
3. **Number as well as title.** `_segment_path` reads both — the number becomes
|
||||
the directory and is stripped from the stem — so inheriting the title alone
|
||||
would emit `3-1-prissammenstilling.md` at the top level, a name the heading
|
||||
never had.
|
||||
|
||||
It is format-agnostic by construction: the orphan check and the table rule are,
|
||||
so a sheet name in a spreadsheet and a heading in a PDF go the same way.
|
||||
|
||||
---
|
||||
|
||||
## 7. The rebuild: 629 concepts, two ids, a new identity
|
||||
|
||||
One rebuild for both fixes, `okf build` over the same 43-document folder, run
|
||||
from a frozen copy of `src/` so no later edit could reach a measurement already
|
||||
in flight.
|
||||
|
||||
| | value |
|
||||
|---|---|
|
||||
| `merged + coded rejections == N` | `39 + 4 = 43 = N`, exit 0 |
|
||||
| concepts | **629**, unchanged |
|
||||
| concept ids that moved | **2 of 629** (0.32 %) |
|
||||
| new content identity | `sha256-tree:2f82fcfea91c3bd3f8ef7147f80cd613227d3ca7975c41d88810233f3f79ab4b` |
|
||||
|
||||
**The two ids move BACK.** `…/tabell-linje-3` becomes `…/prissammenstilling-sheet-1`
|
||||
and `…/ark1-sheet-1` again — the names they carried in the bundle delivered on
|
||||
2026-09-03. Against that delivered bundle, **1 106 of 1 108 files are now
|
||||
identical**, and the two that differ are the two spreadsheet bodies the previous
|
||||
session deliberately changed. A consumer holding the 2026-09-03 tree gets its
|
||||
file names back; the ref, however, is new, and `c26eed6a…` from the previous
|
||||
session is superseded and stale.
|
||||
|
||||
**The ranking, on the rebuilt bundle:**
|
||||
|
||||
| question | vocabulary | O1 bundle | rebuilt |
|
||||
|---|---|---|---|
|
||||
| mandate-shaped | off | 251 of 271 | 251 |
|
||||
| mandate-shaped | on | 19 of 280 | **10** |
|
||||
| specific | off | 1 of 233 | 1 |
|
||||
| specific | on | 1 of 248 | **1** |
|
||||
|
||||
Rank 10 is exactly where the concept sat before the previous session's form fix.
|
||||
The regression is closed, and the question this repository's own hit@8
|
||||
measurement uses is untouched.
|
||||
|
||||
**What it now costs to deliver that concept**, which is the figure a consumer
|
||||
budgets against:
|
||||
|
||||
| command | bytes | o200k | priced sheet |
|
||||
|---|---|---|---|
|
||||
| default | 169 583 | 53 420 | `below_k` |
|
||||
| `--cost-vocabulary --k 12` (default budget) | 183 798 | **58 907** | **delivered, rank 10** |
|
||||
| `--cost-vocabulary --k 20` (default budget) | 190 438 | 61 955 | delivered, rank 10 |
|
||||
| specific question, default | 108 877 | 37 189 | delivered, rank 1 |
|
||||
|
||||
The previous session's delivering command was `--cost-vocabulary --k 20` at
|
||||
**65 912** o200k. The delivering command is now `--cost-vocabulary --k 12` at
|
||||
**58 907** — 10.6 % cheaper, and it fits the default budget. `okf_contract_check`
|
||||
returns exit 0 with 14 rules and 0 findings on all three payloads, and the
|
||||
identity closes: `8 + 621 = 629`, `12 + 617 = 629`.
|
||||
|
||||
---
|
||||
|
||||
## 8. What did NOT close, and why — stated rather than glossed
|
||||
|
||||
**The requirement question is still `below_k` on all three bundles.** The fix
|
||||
moved the gold from 160 to 96, 143 to 9 and 100 to 35, which is a large move and
|
||||
not a delivery. The remaining blocker was decomposed rather than guessed:
|
||||
|
||||
`_overlap` returns a COUNT. On the smallest bundle the gold's overlap is 4 out
|
||||
of a possible 7, and **70 of 270 concepts also score 4** while 12 score 5.
|
||||
Matching the exact requirement number is worth exactly as much as matching the
|
||||
verb in `Hva **krever** …` or the noun in `… det sentrale **vilkåret**`. The
|
||||
identifier is now visible to the ranker; it is not yet *worth* more than a
|
||||
common word.
|
||||
|
||||
Making it worth more is a weighting decision — a rarity weight, or a declared
|
||||
class of high-specificity tokens — and it is a different change from this one:
|
||||
it moves every ranking in every corpus, and it has no measurement behind it yet.
|
||||
It is not in this order and is not decided here.
|
||||
|
||||
**And the honesty limit that governs all of the above.** This document measures
|
||||
a *ranking*: which concepts a deterministic pre-pass puts in a payload. Whether
|
||||
a live model then answers the engineer's question correctly is the consumer's
|
||||
measurement, not this one. "Delivered" is not "read".
|
||||
|
||||
---
|
||||
|
||||
## 9. Tests, and the mutation that survived
|
||||
|
||||
Eight new tests, each written red first (1 287 -> 1 295). Six
|
||||
mutations, six red, with the unmutated control green after every restore:
|
||||
|
||||
| mutation | what it breaks | result |
|
||||
|---|---|---|
|
||||
| widen the identifier back to alphanumeric groups | the slug regression | 3 red |
|
||||
| drop the dash fold | one spelling finds, two do not | 2 red |
|
||||
| never emit the identifier token | the whole rule | 3 red |
|
||||
| inherit the title but not the number | the emitted path | 1 red |
|
||||
| inherit onto any rule, not only a table | one name on two concepts | 1 red |
|
||||
| never clear the carried name | a distant table takes the name | **survived** |
|
||||
|
||||
The survivor was read as code before it was called equivalent, per this
|
||||
repository's own trap note: it is not equivalent — it lets a heading's name
|
||||
travel arbitrarily far — and the band simply had no fixture where an orphan is
|
||||
followed by something other than a table. One was added, and the mutation now
|
||||
dies.
|
||||
|
||||
Suite: **1 295 passing** (was 1 287), `ruff` clean, `mypy` clean on 17 source
|
||||
files. Both byte-pinned proposer goldens are unchanged, which is what says the
|
||||
title rule does not fire where no heading is being destroyed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Verification log
|
||||
|
||||
| # | claim | how it was checked |
|
||||
|---|---|---|
|
||||
| 1 | the five tokeniser strings | run against the committed tool before any edit; all five identical to the consumer's |
|
||||
| 2 | the gold's `withheld` position is not a rank | `sorted(ids) == ids` on all three payloads → `True` |
|
||||
| 3 | both mechanisms lose the number | split output printed separately from the length filter |
|
||||
| 4 | the wide rule costs a hit@8 row | 5 of 6 → 4 of 6, and the losing row's document score 0.735 → 0.0 |
|
||||
| 5 | equality matching is worse | measured on all three bundles; 1 better, 2 worse |
|
||||
| 6 | the tokeniser alone is byte-stable on K2 | same bundle, two tools, two commands, identical sha256 |
|
||||
| 7 | rank 10 → 19 (O1's regression) | reproduced on O1's own bundles before the rule was touched |
|
||||
| 8 | 2 of 629 ids moved | set difference of the two concept-id sets |
|
||||
| 9 | 1 106 of 1 108 files identical to the delivered bundle | `diff -rq`, and `find -type f | wc -l` for the denominator |
|
||||
| 10 | the rebuild reads frozen source | `import llm_ingestion_okf; __file__` under the build's own `PYTHONPATH` |
|
||||
| 11 | contract conformance | `okf_contract_check.py` exit 0, 14 rules, 0 findings, on six payloads |
|
||||
|
|
@ -454,18 +454,42 @@ def find_candidates(
|
|||
)
|
||||
|
||||
candidates: list[Candidate] = []
|
||||
# The name an orphaned heading leaves behind, and the ONE candidate allowed
|
||||
# to pick it up.
|
||||
#
|
||||
# WHY THIS EXISTS. A table opening on the line below a heading gives that
|
||||
# heading an empty body, so the orphan check drops it, and the table block
|
||||
# keeps its mechanical `Tabell linje <n>`: the section's NAME is destroyed
|
||||
# even though its content survives. Measured on a 629-concept bundle after
|
||||
# a spreadsheet began rendering as pipe rows -- the concept fell from
|
||||
# candidate rank 10 to 19 for a question naming its subject, and restoring
|
||||
# the title alone put it back at 10. The name is not invented here, it is
|
||||
# carried across: it moves to the segment that holds the heading's content.
|
||||
#
|
||||
# CONDITIONED ON THE DROP, deliberately. A heading that keeps its own body
|
||||
# is still carried by a live candidate, so copying its title onto the table
|
||||
# as well would put one name on two concepts and rescue none.
|
||||
orphaned_name: tuple[str, str | None] | None = None
|
||||
for position_in_list, (_, candidate) in enumerate(marked):
|
||||
following = marked[position_in_list + 1 :]
|
||||
end = offsets[following[0][0]] if following else end_of_text
|
||||
body = text[candidate.start : end]
|
||||
# The orphan check: everything after the heading line itself.
|
||||
if not body.splitlines()[1:] or not "".join(body.splitlines()[1:]).strip():
|
||||
orphaned_name = (candidate.title, candidate.number)
|
||||
continue
|
||||
inherited, orphaned_name = orphaned_name, None
|
||||
title, number = candidate.title, candidate.number
|
||||
if inherited is not None and candidate.rule == RULE_TABLE_BLOCK:
|
||||
# Both members, because `_segment_path` reads both: the number
|
||||
# becomes the directory AND is stripped from the stem, so carrying
|
||||
# the title alone would emit a name the heading never had.
|
||||
title, number = inherited
|
||||
candidates.append(
|
||||
Candidate(
|
||||
title=candidate.title,
|
||||
title=title,
|
||||
level=candidate.level,
|
||||
number=candidate.number,
|
||||
number=number,
|
||||
rule=candidate.rule,
|
||||
start=candidate.start,
|
||||
end=end,
|
||||
|
|
|
|||
|
|
@ -378,6 +378,64 @@ def test_normalise_drops_tokens_under_three_characters() -> None:
|
|||
assert okf_consume.normalise("er en pris i et skjema") == ("pris", "skjema")
|
||||
|
||||
|
||||
def test_normalise_holds_an_identifier_number_as_one_token() -> None:
|
||||
# MEASURED 2026-09-08 over three vegnormal bundles (446, 1133 and 270
|
||||
# concepts): `_TOKEN_SPLIT_RE` shatters `10.2-2` into `10`, `2`, `2` and
|
||||
# `MIN_TOKEN_LENGTH` then drops every piece, so a question naming a
|
||||
# requirement number reaches the ranker carrying only the word `krav` --
|
||||
# which every concept in such a bundle also carries. BOTH mechanisms
|
||||
# participate: the split destroys the number, the floor removes the
|
||||
# remains. The gold requirement was `below_k` in three of three.
|
||||
assert "10.2-2" in okf_consume.normalise("Krav 10.2\u20142")
|
||||
assert okf_consume.normalise("3.3.1\u201413") == ("3.3.1-13",)
|
||||
assert okf_consume.normalise("2.9.2\u201412") == ("2.9.2-12",)
|
||||
assert okf_consume.normalise("R610.4") == ("r610.4",)
|
||||
assert okf_consume.normalise("4.2.1") == ("4.2.1",)
|
||||
|
||||
|
||||
def test_an_identifiers_three_spellings_of_its_separator_normalise_alike() -> None:
|
||||
# One requirement number arrives as an em dash from the source viewer, an
|
||||
# en dash from a converter and a plain hyphen from a person typing the
|
||||
# question. NFC folds NONE of the three, so a rule that does not fold them
|
||||
# finds the number only in the spelling it was asked with.
|
||||
hyphen = okf_consume.normalise("10.2-2")
|
||||
assert okf_consume.normalise("10.2\u20142") == hyphen
|
||||
assert okf_consume.normalise("10.2\u20132") == hyphen
|
||||
assert hyphen == ("10.2-2",)
|
||||
|
||||
|
||||
def test_the_identifier_rule_leaves_the_noise_floor_it_was_added_under() -> None:
|
||||
# The known-negative, and the whole reason `MIN_TOKEN_LENGTH` exists: a
|
||||
# bare short number matches every page number, row count and year in a
|
||||
# corpus, and a matcher that scores them ranks every document equally.
|
||||
assert okf_consume.normalise("10") == ()
|
||||
assert okf_consume.normalise("2") == ()
|
||||
assert okf_consume.normalise("er en pris i et skjema") == ("pris", "skjema")
|
||||
# A hyphenated WORD is not an identifier -- no digit stands on either side
|
||||
# of the separator -- so it splits exactly as it always did.
|
||||
assert okf_consume.normalise("skole-anbudet") == ("skole", "anbudet")
|
||||
# And a separator this rule does not claim leaves its token set untouched:
|
||||
# the K2 corpus spells standards this way.
|
||||
assert okf_consume.normalise("NS3935:2019") == ("ns3935", "2019")
|
||||
assert okf_consume.normalise("TEK 17") == ("tek",)
|
||||
|
||||
|
||||
def test_an_identifier_inside_a_slug_does_not_swallow_the_words_around_it() -> None:
|
||||
# MEASURED, and the reason the rule joins DIGIT groups rather than
|
||||
# alphanumeric ones. A first version joined alphanumeric groups across a
|
||||
# separator; a corpus document's slug then became ONE token, because a
|
||||
# `3-6` sits inside it, and that document's score for a question naming its
|
||||
# subject fell from 0.735 to 0.0 -- one hit@8 row lost, on a question
|
||||
# carrying no identifier at all. The slug below has that shape and is not
|
||||
# the corpus's (SS "consumer content stays at form level"). The identifier
|
||||
# is ADDED here; nothing is taken away.
|
||||
tokens = okf_consume.normalise("rapport-iv-vedlegg-3-6-grunnforhold-akustikk")
|
||||
assert "akustikk" in tokens
|
||||
assert "grunnforhold" in tokens
|
||||
assert "vedlegg" in tokens
|
||||
assert "3-6" in tokens
|
||||
|
||||
|
||||
def test_two_tokens_match_on_a_shared_prefix_of_four_and_not_of_three() -> None:
|
||||
# "Stem-substring" is not an implementable rule: neither `prisene` nor
|
||||
# `prissammenstilling` contains the other. Shared prefix does the work --
|
||||
|
|
@ -1057,6 +1115,7 @@ def test_no_corpus_document_name_reaches_any_file_this_work_tracks() -> None:
|
|||
PROJECT_ROOT / "docs" / "2026-09-08-blindsone-below-k-k2.md",
|
||||
PROJECT_ROOT / "docs" / "2026-09-08-blindsone-laas2-budsjett-k2.md",
|
||||
PROJECT_ROOT / "docs" / "2026-09-08-prisform-og-loggen-k2.md",
|
||||
PROJECT_ROOT / "docs" / "2026-09-08-kravnummer-tokenisering.md",
|
||||
PROJECT_ROOT / "README.md",
|
||||
PROJECT_ROOT / "CLAUDE.md",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1187,6 +1187,93 @@ Innledende avsnitt om romskjemaet.
|
|||
"""
|
||||
|
||||
|
||||
HEADING_ORPHANED_BY_ITS_TABLE = """## Prissammenstilling
|
||||
|
||||
| Post | Sum |
|
||||
|-------|-------|
|
||||
| A | 100 |
|
||||
"""
|
||||
|
||||
HEADING_WITH_A_BODY_ABOVE_A_TABLE = """## Prissammenstilling
|
||||
|
||||
Innledende tekst om skjemaet.
|
||||
|
||||
| Post | Sum |
|
||||
|-------|-------|
|
||||
| A | 100 |
|
||||
"""
|
||||
|
||||
ORPHAN_THEN_A_LIVE_HEADING_THEN_A_TABLE = """## Tomt kapittel
|
||||
## Innledning
|
||||
|
||||
Tekst i innledningen.
|
||||
|
||||
| Post | Sum |
|
||||
|-------|-------|
|
||||
| A | 100 |
|
||||
"""
|
||||
|
||||
NUMBERED_HEADING_ORPHANED_BY_ITS_TABLE = """## 3.1 Prissammenstilling
|
||||
|
||||
| Post | Sum |
|
||||
|-------|-------|
|
||||
| A | 100 |
|
||||
"""
|
||||
|
||||
|
||||
def test_a_table_that_orphans_its_heading_takes_that_headings_name() -> None:
|
||||
"""MEASURED, twice, before the rule was written.
|
||||
|
||||
A spreadsheet row now renders as pipe rows, so `rule:table-block` opens on
|
||||
the line after the sheet heading; the heading's own body is then empty and
|
||||
the orphan check drops it; and the only surviving candidate carries the
|
||||
mechanical `Tabell linje <n>`. The sheet's NAME is destroyed -- measured on
|
||||
a 629-concept bundle, the concept fell from candidate rank 10 to 19 for a
|
||||
question naming its subject, and restoring the title alone put it back at
|
||||
10. The title is not lost here: it moves to the segment that holds the
|
||||
heading's content.
|
||||
"""
|
||||
candidates = okf_propose_segments.find_candidates(HEADING_ORPHANED_BY_ITS_TABLE)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].title == "Prissammenstilling"
|
||||
assert candidates[0].rule == okf_propose_segments.RULE_TABLE_BLOCK
|
||||
|
||||
|
||||
def test_a_table_below_a_heading_that_keeps_its_body_is_not_renamed() -> None:
|
||||
"""The known-negative, and the reason the rule is conditioned on the drop.
|
||||
|
||||
Here the heading survives, so its title is still carried by a live
|
||||
candidate. Renaming the table to it as well would put the same name on two
|
||||
concepts and take a name away from neither.
|
||||
"""
|
||||
candidates = okf_propose_segments.find_candidates(HEADING_WITH_A_BODY_ABOVE_A_TABLE)
|
||||
assert [c.title for c in candidates] == ["Prissammenstilling", "Tabell linje 5"]
|
||||
|
||||
|
||||
def test_an_orphaned_headings_section_number_moves_with_its_title() -> None:
|
||||
"""`_segment_path` reads BOTH: the number becomes the directory and is
|
||||
stripped from the stem. Inheriting the title alone would emit
|
||||
`3-1-prissammenstilling.md` at the top level -- a name the heading never
|
||||
had."""
|
||||
candidates = okf_propose_segments.find_candidates(NUMBERED_HEADING_ORPHANED_BY_ITS_TABLE)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].title == "3.1 Prissammenstilling"
|
||||
assert candidates[0].number == "3.1"
|
||||
assert okf_propose_segments._segment_path(candidates[0], set()) == "3-1/prissammenstilling.md"
|
||||
|
||||
|
||||
def test_an_orphaned_name_is_not_carried_past_the_candidate_that_follows_it() -> None:
|
||||
"""The name goes to the ADJACENT segment or nowhere.
|
||||
|
||||
Written because a mutation that never cleared the carried name survived the
|
||||
rest of this band: with the name held indefinitely, the table below would
|
||||
take the title of a heading it does not contain, several candidates away.
|
||||
That is an invention, not a rescue.
|
||||
"""
|
||||
candidates = okf_propose_segments.find_candidates(ORPHAN_THEN_A_LIVE_HEADING_THEN_A_TABLE)
|
||||
assert [c.title for c in candidates] == ["Innledning", "Tabell linje 6"]
|
||||
|
||||
|
||||
def test_todays_grid_table_cuts_one_table_into_one_concept_per_row_group() -> None:
|
||||
"""Characterization. ONE table, three concepts -- the defect Arm E measures.
|
||||
|
||||
|
|
|
|||
|
|
@ -522,9 +522,27 @@ MIN_SHARED_PREFIX = 4
|
|||
|
||||
_TOKEN_SPLIT_RE = re.compile(r"[^0-9a-zà-öø-ÿ]+")
|
||||
|
||||
#: Every dash a source spells an identifier's separator with, folded to the
|
||||
#: ASCII hyphen. NFC folds NONE of them, so `10.2—2` from a document viewer and
|
||||
#: `10.2-2` from a person typing the question are two different tokens until
|
||||
#: this table runs. The set is the Unicode dash block plus the minus sign.
|
||||
_DASH_TO_HYPHEN = str.maketrans(dict.fromkeys("‐‑‒–—―−", "-"))
|
||||
|
||||
#: An identifier: NUMERIC groups joined by `.` or `-`, with an optional letter
|
||||
#: prefix that touches its digits without a separator (`R610.4`).
|
||||
#:
|
||||
#: THE LETTERS ARE THE POINT, and this pattern was narrowed by a measurement
|
||||
#: rather than written this way. A rule that joined alphanumeric groups across
|
||||
#: a separator swallowed a whole document slug -- `...bilag-3-6-premissrapport-
|
||||
#: akustikk` became ONE token because `3-6` sits inside it -- and that
|
||||
#: document's score for a question naming its subject fell from 0.735 to 0.0,
|
||||
#: taking a hit@8 row with it. Only digits may stand on either side of a
|
||||
#: separator, so a hyphenated word keeps its words.
|
||||
_IDENTIFIER_RE = re.compile(r"[a-zà-öø-ÿ]*[0-9]+(?:[.-][0-9]+)+")
|
||||
|
||||
|
||||
def normalise(text: str) -> tuple[str, ...]:
|
||||
"""Text as comparable tokens: NFC first, then casefold, then split.
|
||||
"""Text as comparable tokens: NFC, casefold, dash-fold, then split.
|
||||
|
||||
NFC FIRST is load-bearing and not tidiness. macOS hands filenames over
|
||||
decomposed, so `å` arrives as `a` + U+030A; the combining ring is not a word
|
||||
|
|
@ -532,9 +550,30 @@ def normalise(text: str) -> tuple[str, ...]:
|
|||
the term is silently lost. `æ` and `ø` have no canonical decomposition, so a
|
||||
test built on either passes while the bug is live -- which is why the
|
||||
known-positive for this function uses `å`.
|
||||
|
||||
IDENTIFIERS SURVIVE THE SPLIT. Splitting on every non-alphanumeric turns a
|
||||
requirement number into digit runs, and `MIN_TOKEN_LENGTH` then removes
|
||||
them: `Krav 10.2—2` reached the ranker as `krav` alone, a word every concept
|
||||
in a standards bundle carries, so the ranking became a corpus-wide tie and
|
||||
the named requirement was withheld `below_k` (measured 2026-09-08 on three
|
||||
bundles). The floor stays -- a bare `10` matches every page number in a
|
||||
corpus -- and the identifier is exempted from it rather than the floor
|
||||
lowered for everyone.
|
||||
"""
|
||||
folded = unicodedata.normalize("NFC", text).casefold()
|
||||
return tuple(token for token in _TOKEN_SPLIT_RE.split(folded) if len(token) >= MIN_TOKEN_LENGTH)
|
||||
folded = unicodedata.normalize("NFC", text).casefold().translate(_DASH_TO_HYPHEN)
|
||||
tokens: list[str] = []
|
||||
position = 0
|
||||
for match in _IDENTIFIER_RE.finditer(folded):
|
||||
tokens.extend(_split(folded[position : match.start()]))
|
||||
tokens.append(match.group())
|
||||
position = match.end()
|
||||
tokens.extend(_split(folded[position:]))
|
||||
return tuple(tokens)
|
||||
|
||||
|
||||
def _split(folded: str) -> list[str]:
|
||||
"""The generic split, on text already folded by `normalise`."""
|
||||
return [token for token in _TOKEN_SPLIT_RE.split(folded) if len(token) >= MIN_TOKEN_LENGTH]
|
||||
|
||||
|
||||
def tokens_match(left: str, right: str) -> bool:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue