feat(consume): rank documents from the indexes with stem matching

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-07 09:13:27 +02:00
commit 0550870ae2
2 changed files with 176 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import hashlib
import json
import os
import sys
import unicodedata
from pathlib import Path
import pytest
@ -309,3 +310,73 @@ def test_the_default_limit_admits_a_concept_the_size_of_the_price_form() -> None
def test_the_budget_unit_and_instrument_are_named_rather_than_implied() -> None:
assert "byte" in okf_consume.BUDGET_UNIT
assert "ensure_ascii=False" in okf_consume.BUDGET_INSTRUMENT
# --- Step 5: stage-one document ranking --------------------------------------
def test_normalise_is_nfc_stable_on_the_one_letter_that_decomposes() -> None:
# `NFD("å")` is `a` + U+030A, and the combining ring is not `\w`, so an
# un-normalised split returns `["a", "rlig"]`. `æ` and `ø` have NO canonical
# decomposition, so a test built on `miljø` passes while the bug is live --
# the known-positive here MUST use `å`.
composed = unicodedata.normalize("NFC", "årlig kontroll")
decomposed = unicodedata.normalize("NFD", "årlig kontroll")
assert composed != decomposed, "the control is broken: the two forms are identical"
assert okf_consume.normalise(decomposed) == okf_consume.normalise(composed)
assert "årlig" in okf_consume.normalise(decomposed)
def test_normalise_drops_tokens_under_three_characters() -> None:
assert okf_consume.normalise("er en pris i et skjema") == ("pris", "skjema")
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 --
# `pris|ene` and `pris|sammenstilling` share 4. A 3-character floor
# over-matches Norwegian function words.
assert okf_consume.tokens_match("prisene", "prissammenstilling")
assert okf_consume.tokens_match("kontrollen", "kontroll")
assert not okf_consume.tokens_match("pris", "pri")
assert not okf_consume.tokens_match("krav", "kraft")
def test_a_question_naming_a_directorys_subject_ranks_that_directory_first() -> None:
scores = okf_consume.document_scores(FIXTURE, "Hvordan skal prisene fylles ut?")
assert scores, "no document scored, so 'ranks first' would measure nothing"
assert max(scores, key=lambda key: (scores[key], key)) == "krav"
def test_a_question_about_a_different_subject_ranks_a_different_directory() -> None:
# The control on the test above: without it, a scorer returning "krav"
# unconditionally would pass.
scores = okf_consume.document_scores(FIXTURE, "Hva er omfanget og formaalet?")
assert max(scores, key=lambda key: (scores[key], key)) == "scope"
def test_curated_prose_in_an_index_is_ignored_rather_than_scored(tmp_path: Path) -> None:
root = tmp_path / "bundle"
_copy_bundle(FIXTURE, root)
index = root / "krav" / "index.md"
index.write_text(
"Denne mappen handler om priser og prissammenstilling.\n\n"
+ index.read_text(encoding="utf-8"),
encoding="utf-8",
)
assert (
okf_consume.DEFAULT_PROFILE.index.parse_entry(
"Denne mappen handler om priser og prissammenstilling."
)
is None
)
assert okf_consume.document_scores(root, "Hvordan skal prisene fylles ut?") == (
okf_consume.document_scores(FIXTURE, "Hvordan skal prisene fylles ut?")
)
def test_document_scores_are_identical_across_two_calls() -> None:
question = "Hvordan skal prisene fylles ut?"
assert okf_consume.document_scores(FIXTURE, question) == okf_consume.document_scores(
FIXTURE, question
)

View file

@ -33,8 +33,10 @@ from __future__ import annotations
import hashlib
import json
import re
import sys
from collections.abc import Mapping
import unicodedata
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
@ -472,3 +474,105 @@ def known_positive() -> tuple[str, int, int]:
"""
measured = measure(_KNOWN_POSITIVE_PATH.read_text(encoding="utf-8"))
return KNOWN_POSITIVE_CASE, KNOWN_POSITIVE_EXPECTED, measured
# --- Stage one: which documents are worth opening -----------------------------
#: The shortest token this instrument scores. Two characters in Norwegian are
#: almost always a function word (`og`, `er`, `en`, `av`, `de`), and a matcher
#: that scores them ranks every document equally.
MIN_TOKEN_LENGTH = 3
#: How many leading characters two tokens must share to count as a match.
#:
#: THIS INSTRUMENT'S OWN CONSTANT, and a measurement rather than a preference.
#: Token equality fails on Norwegian compounds: the question word `prisene`
#: equals none of a price-form concept's `title`, `source_file` or path tokens.
#: Plain substring containment does not save it either -- neither `prisene` nor
#: `prissammenstilling` contains the other. A shared prefix does: `pris|ene` and
#: `pris|sammenstilling` share 4.
#:
#: MEASURED HERE, 2026-09-07, over the 629-concept K2 corpus for the question
#: token `prisene`: a 4-character floor matches **3** concepts -- over the
#: concept id alone AND over title + `source_file` + id together, the same 3 --
#: and the price-form gold is among them. The plan this implements recorded 6
#: for the same measurement; 6 is not reproducible with this rule, and the
#: number that is reproducible is the one carried here. A 3-character floor
#: over-matches Norwegian function words.
MIN_SHARED_PREFIX = 4
_TOKEN_SPLIT_RE = re.compile(r"[^0-9a-zà-öø-ÿ]+")
def normalise(text: str) -> tuple[str, ...]:
"""Text as comparable tokens: NFC first, then casefold, 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
character, so an un-normalised split turns `årlig` into `a` and `rlig` and
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 `å`.
"""
folded = unicodedata.normalize("NFC", text).casefold()
return tuple(token for token in _TOKEN_SPLIT_RE.split(folded) if len(token) >= MIN_TOKEN_LENGTH)
def tokens_match(left: str, right: str) -> bool:
"""Whether two tokens share a leading prefix of at least `MIN_SHARED_PREFIX`.
Symmetric, and it degrades to equality for short tokens: two 4-character
tokens match only if they are the same word.
"""
limit = min(len(left), len(right))
if limit < MIN_SHARED_PREFIX:
return False
shared = 0
while shared < limit and left[shared] == right[shared]:
shared += 1
return shared >= MIN_SHARED_PREFIX
def _overlap(question_tokens: Sequence[str], candidate: str) -> int:
"""How many of the question's tokens the candidate text answers to."""
candidate_tokens = normalise(candidate)
return sum(
1
for token in question_tokens
if any(tokens_match(token, other) for other in candidate_tokens)
)
def document_scores(
bundle_root: Path, question: str, *, profile: BundleProfile = DEFAULT_PROFILE
) -> dict[str, float]:
"""One score per top-level document, from the indexes and the path alone.
A "document" is a top-level entry: a directory, or -- per the corpus's own
shape -- a concept sitting at the root, which has no directory to inherit
from and is therefore its own document. Measured on K2, 11 of 629 concepts
are root-level, and they are exactly the 11 carrying neither `adjudication`
nor `bundle_id`; scoring them as members of some parent would put one bug in
three places.
Reads the INDEX TREE only. No directory is enumerated here or anywhere else
in this command (SS 9.2).
"""
question_tokens = normalise(question)
indexes, concepts = _walk_index_tree(bundle_root, profile=profile)
scores: dict[str, float] = {}
for concept_id in concepts:
document = concept_id.split("/", 1)[0]
scores.setdefault(document, 0.0)
scores[document] += float(_overlap(question_tokens, concept_id.replace("/", " ")))
for relative in indexes:
document = relative.split("/", 1)[0]
if document == profile.index.name:
continue
scores.setdefault(document, 0.0)
for line in (bundle_root / relative).read_text(encoding="utf-8").splitlines():
entry = profile.index.parse_entry(line)
if entry is None:
continue
scores[document] += float(_overlap(question_tokens, entry.label))
return scores