C1. `okf consume` and MCP's `okf_ask` now rank with BM25 (`bm25.py`) instead
of the three-signal fusion. Two signals, fused by reciprocal rank:
- passage: every body cut into 500-character windows every 250, a concept
scored by its BEST window -- a narrow question is answered in one place;
- field: title three times, the id path and source name twice, then the body
-- a broad question is answered by what a section is called.
The document prior and the rarity weight are gone from the default: the first
favoured big documents full of common words, the second gave its largest
weight to a word the collection does not hold. Under BM25 such a word weighs
exactly zero. A signal that scores a concept zero adds nothing to it, and ties
share a rank, so alphabetical order lifts nothing either.
Three rules carried over from the fusion, each with its own test, because the
suite showed what BM25 alone lost:
- a directory every concept shares is not read (K3-20's defect, one signal on);
- a number a section is known by (`4.2`, `10.2-2`) is kept as one token, or
a question naming a section by its number matches nothing in it;
- a question word the collection does NOT hold is read as the collection's
words it shares a leading word with (`consume.tokens_match`) -- Norwegian
inflection and compounding -- at that word's idf, never at its own.
The lookup and title-covered partitions are shared with the fusion
(`_partitioned`). `ranking="fusion"` / `--ranking fusion` keeps the old order
reachable; `--cost-vocabulary` and `--rarity-weight` widen only the fusion and
are refused with the default (`ranking_flag_conflict`) rather than ignored.
C3. A concept longer than `PASSAGE_CHARS` (4 000) is delivered as the span
around its best window, snapped to whole lines, under the nearest heading
above it, with `[...]` where text was left out. `passage: {start, end, of}`
says so, `text_sha256` covers what was delivered, and `sha256` stays the
file's, so the whole can be fetched by `concept_id`. 4 000 because eight
excerpts of it stay far under a tool response's limit even with several
sub-questions merged, while a 500-character window keeps 3 500 characters of
surroundings. The budget pays for the passage, not the file.
Tests moved with the default, each stated rather than silenced:
- fusion-mechanism tests (cost vocabulary, rarity weight, reservation, shared
rank, the reference-bundle pins) ask for `ranking="fusion"`, the order they
were measured on; the BM25 reading of the reference bundle is a separate
measurement, kept in local state;
- the retrieval gate still measures the shipped default. Row 1 holds. Four of
its premises were built against the fusion (a concept forced below k that
BM25 now delivers, a quota that no longer decides, mutants patching fusion
code) and are `xfail(strict=True)` until the fixtures are re-measured;
- the shipped example payload is regenerated; the shipped skill is unchanged.
README's Consume section and CLAUDE.md state the new default and that the
flags described after it belong to the fusion.
The search gate's table for this commit is kept in local state: the question
sets belong to a consumer whose content does not go on a public mirror.
Suite on a clean tree after `git add`: 2390 passed, 2 skipped, 4 xfailed.
ruff, ruff format, mypy --strict clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.7 KiB
Python
191 lines
7.7 KiB
Python
"""The BM25 ranking (v1.1 order C, C1): the default reading of `okf consume`.
|
|
|
|
Each test states one property of the mechanism on a synthetic bundle small
|
|
enough to reason about by hand. The measurement against a real collection is
|
|
the search gate's (`tools/okf_soek_gate.py`), not this file's.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from llm_ingestion_okf import bm25, consume
|
|
|
|
|
|
def _concept(concept_id: str, title: str, body: str) -> consume.Concept:
|
|
return consume.Concept(
|
|
path=Path(f"{concept_id}.md"),
|
|
concept_id=concept_id,
|
|
bundle_id="b",
|
|
bundle_id_inherited=True,
|
|
sha256="0" * 64,
|
|
okf_type="concept",
|
|
title=title,
|
|
source_file=f"{concept_id.split('/')[0]}.md",
|
|
adjudication="unknown",
|
|
adjudication_present=False,
|
|
req_number="",
|
|
sources=(),
|
|
sources_present=False,
|
|
locators={},
|
|
frontmatter={},
|
|
body=body,
|
|
)
|
|
|
|
|
|
def test_the_tokeniser_drops_stopwords_and_single_characters_and_folds_case() -> None:
|
|
assert bm25.tokens("What IS the Default model for a teammate?") == [
|
|
"default",
|
|
"model",
|
|
"teammate",
|
|
]
|
|
|
|
|
|
def test_the_tokeniser_keeps_a_norwegian_word_whole() -> None:
|
|
# A letter outside ASCII must not split a word into fragments that can
|
|
# match something unrelated in an English collection.
|
|
assert bm25.tokens("første") == ["første"]
|
|
|
|
|
|
def test_a_word_the_collection_does_not_hold_lifts_nothing() -> None:
|
|
concepts = [
|
|
_concept("a/one", "One", "the alpha feature is described here"),
|
|
_concept("b/two", "Two", "the beta feature is described here"),
|
|
]
|
|
plain = bm25.rank(concepts, "alpha feature")
|
|
padded = bm25.rank(concepts, "alpha feature zzqqxx")
|
|
assert [c.concept_id for c, _, _ in plain.ranked] == [c.concept_id for c, _, _ in padded.ranked]
|
|
assert [score for _, score, _ in plain.ranked] == [score for _, score, _ in padded.ranked]
|
|
|
|
|
|
def test_length_normalisation_prefers_the_short_concept_on_one_shared_term() -> None:
|
|
filler = " ".join(f"word{i}" for i in range(400))
|
|
concepts = [
|
|
_concept("a/long", "Long", f"rotation {filler}"),
|
|
_concept("b/short", "Short", "rotation of keys"),
|
|
]
|
|
ranked = bm25.rank(concepts, "rotation").ranked
|
|
assert ranked[0][0].concept_id == "b/short"
|
|
|
|
|
|
def test_the_title_field_separates_two_equal_bodies() -> None:
|
|
# The passage signal reads bodies only, so two equal bodies tie there and
|
|
# the field signal -- title and path weighted up -- decides.
|
|
concepts = [
|
|
_concept("a/body", "Unrelated", "notes about the sandbox and its settings"),
|
|
_concept("b/title", "Sandbox", "notes about the sandbox and its settings"),
|
|
]
|
|
assert bm25.rank(concepts, "sandbox").ranked[0][0].concept_id == "b/title"
|
|
|
|
|
|
def test_the_best_window_wins_rather_than_the_sum() -> None:
|
|
# Ten scattered mentions sum to more than one dense window; the rule is
|
|
# `max`, so the concept whose ONE window answers the question leads.
|
|
scattered = " ".join(["hooks"] + [f"pad{i}" for i in range(120)]) * 10
|
|
dense = "hooks configure hooks per event, hooks run commands"
|
|
concepts = [
|
|
_concept("a/scattered", "Scattered", scattered),
|
|
_concept("b/dense", "Dense", dense),
|
|
]
|
|
ranking = bm25.rank(concepts, "hooks configure event commands")
|
|
assert ranking.ranked[0][0].concept_id == "b/dense"
|
|
|
|
|
|
def test_the_best_window_offset_points_at_the_answering_text() -> None:
|
|
body = "x " * 2000 + "the answer about retention lives here " + "y " * 2000
|
|
ranking = bm25.rank([_concept("a/doc", "Doc", body)], "retention answer")
|
|
start = ranking.best_window["a/doc"]
|
|
assert "retention" in body[start : start + bm25.WINDOW_CHARS]
|
|
|
|
|
|
def test_a_concept_matching_nothing_carries_zero_lexical_and_sorts_by_id() -> None:
|
|
concepts = [
|
|
_concept("c/none", "C", "nothing relevant"),
|
|
_concept("b/none", "B", "nothing relevant"),
|
|
_concept("a/hit", "A", "the keyword appears"),
|
|
]
|
|
ranked = bm25.rank(concepts, "keyword").ranked
|
|
assert [(c.concept_id, lexical) for c, _, lexical in ranked] == [
|
|
("a/hit", 1),
|
|
("b/none", 0),
|
|
("c/none", 0),
|
|
]
|
|
|
|
|
|
def test_two_rankings_of_the_same_input_are_identical() -> None:
|
|
concepts = [_concept(f"d{i}/c", f"T{i}", f"shared term {i} " * (i + 1)) for i in range(12)]
|
|
first = bm25.rank(concepts, "shared term")
|
|
second = bm25.rank(list(reversed(concepts)), "shared term")
|
|
assert [(c.concept_id, s) for c, s, _ in first.ranked] == [
|
|
(c.concept_id, s) for c, s, _ in second.ranked
|
|
]
|
|
|
|
|
|
def test_the_default_ranking_is_bm25_and_the_fusion_is_still_reachable() -> None:
|
|
assert consume.DEFAULT_RANKING == "bm25"
|
|
assert set(consume.RANKINGS) == {"bm25", "fusion"}
|
|
|
|
|
|
def test_a_directory_every_concept_shares_matches_nothing() -> None:
|
|
# K3-20's defect, one signal over: in a one-document bundle every id
|
|
# carries the document's directory, so a question naming the document
|
|
# would otherwise match every concept.
|
|
concepts = [
|
|
_concept("handbook/intro", "Intro", "welcome to the club"),
|
|
_concept("handbook/fees", "Fees", "membership costs money"),
|
|
]
|
|
ranked = bm25.rank(concepts, "handbook").ranked
|
|
assert [lexical for _, _, lexical in ranked] == [0, 0]
|
|
assert [score for _, score, _ in ranked] == [0.0, 0.0]
|
|
|
|
|
|
def test_a_directory_that_separates_concepts_still_counts() -> None:
|
|
concepts = [
|
|
_concept("billing/overview", "Overview", "general words"),
|
|
_concept("security/overview", "Overview", "general words"),
|
|
]
|
|
assert bm25.rank(concepts, "billing").ranked[0][0].concept_id == "billing/overview"
|
|
|
|
|
|
def test_an_absent_inflection_reaches_the_form_the_collection_holds() -> None:
|
|
# `vinterberedskapen` occurs nowhere; `vinterberedskap` does, and it is the
|
|
# shared prefix -- a WORD of this collection -- that bridges the two
|
|
# (`consume.tokens_match`, the rule the older ranking measured for
|
|
# Norwegian inflection and compounding).
|
|
concepts = [
|
|
_concept("a/winter", "Vinterberedskap", "vinterberedskap kontrolleres hver host"),
|
|
_concept("b/summer", "Sommer", "sommerdrift og vedlikehold"),
|
|
]
|
|
ranked = bm25.rank(concepts, "Når kontrolleres vinterberedskapen?").ranked
|
|
assert ranked[0][0].concept_id == "a/winter"
|
|
assert ranked[0][2] == 2
|
|
|
|
|
|
def test_a_word_the_collection_holds_is_matched_as_itself_only() -> None:
|
|
# `mode` is in the collection, so it is never widened to `model`: the
|
|
# bridge is for a word that is absent, never a second reading of one that
|
|
# is present.
|
|
concepts = [
|
|
_concept("a/mode", "Fast mode", "fast mode speeds output"),
|
|
_concept("b/model", "Model", "model selection and model aliases"),
|
|
]
|
|
ranked = bm25.rank(concepts, "mode").ranked
|
|
assert [(c.concept_id, lexical) for c, _, lexical in ranked] == [("a/mode", 1), ("b/model", 0)]
|
|
|
|
|
|
def test_a_number_a_document_is_known_by_is_kept_whole() -> None:
|
|
# `4.2` split on the dot is two single characters, and single characters
|
|
# are dropped: without the whole token a question naming a section by its
|
|
# number matches nothing in the section it names.
|
|
assert "4.2" in bm25.tokens("Hva staar i punkt 4.2?")
|
|
assert "10.2-2" in bm25.tokens("Krav 10.2—2")
|
|
|
|
|
|
def test_a_concept_named_by_its_number_carries_a_lexical_match() -> None:
|
|
concepts = [
|
|
_concept("r/vakthold-4-2", "Vakthold 4.2", "Vakten gaar fra fredag til soendag."),
|
|
_concept("r/notat", "Notat om ettersyn", "Kontrollen av hytta foeres i skjema."),
|
|
]
|
|
ranked = bm25.rank(concepts, "Hva staar i punkt 4.2 om kontrollen av hytta?").ranked
|
|
lexical = {concept.concept_id: count for concept, _, count in ranked}
|
|
assert lexical["r/vakthold-4-2"] >= 1
|