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>
117 lines
4 KiB
Python
117 lines
4 KiB
Python
"""A large concept is delivered as its RELEVANT PASSAGE (v1.1 order C, C3).
|
|
|
|
The reader receives the place that answers, with the heading it sits under and
|
|
enough surroundings to read alone, plus the concept's name so the whole can be
|
|
fetched. A concept at or under `PASSAGE_CHARS` is delivered whole, as before.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf import consume
|
|
|
|
TOOLS = Path(__file__).resolve().parent.parent / "tools"
|
|
if str(TOOLS) not in sys.path:
|
|
sys.path.insert(0, str(TOOLS))
|
|
|
|
import okf_retrieval_gate as retrieval # noqa: E402
|
|
|
|
ANSWER = "The retention window for archived sessions is ninety days."
|
|
|
|
|
|
def _long_body() -> str:
|
|
filler = "\n".join(
|
|
f"Paragraph {i} describes an unrelated setting in detail." for i in range(300)
|
|
)
|
|
tail = "\n".join(f"Closing note {i} about something else." for i in range(300))
|
|
return f"{filler}\n\n## Session retention\n\n{ANSWER}\n\n{tail}"
|
|
|
|
|
|
def _excerpt(text: str) -> dict[str, object]:
|
|
return {
|
|
"concept_id": "doc/big",
|
|
"text": text,
|
|
"text_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
}
|
|
|
|
|
|
def test_a_short_excerpt_is_delivered_whole() -> None:
|
|
excerpt = _excerpt("A short body.")
|
|
assert consume.as_passage(dict(excerpt), 0) == excerpt
|
|
|
|
|
|
def test_a_long_excerpt_is_cut_to_the_window_with_its_heading() -> None:
|
|
body = _long_body()
|
|
window = body.index(ANSWER)
|
|
out = consume.as_passage(_excerpt(body), window)
|
|
text = out["text"]
|
|
assert isinstance(text, str)
|
|
assert ANSWER in text
|
|
assert "## Session retention" in text
|
|
assert len(text) <= consume.PASSAGE_CHARS + consume.PASSAGE_HEADING_ALLOWANCE
|
|
assert out["text_sha256"] == hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
passage = out["passage"]
|
|
assert isinstance(passage, dict)
|
|
assert passage["of"] == len(body)
|
|
assert 0 < passage["start"] <= window < passage["end"] <= len(body)
|
|
assert body[passage["start"] : passage["end"]] in text
|
|
|
|
|
|
def test_the_heading_is_carried_even_when_it_lies_before_the_span() -> None:
|
|
body = (
|
|
"# Top\n\n## Far heading\n\n" + ("filler line here\n" * 600) + ANSWER + "\n" + "x\n" * 600
|
|
)
|
|
out = consume.as_passage(_excerpt(body), body.index(ANSWER))
|
|
text = out["text"]
|
|
assert isinstance(text, str)
|
|
assert text.startswith("## Far heading\n")
|
|
assert ANSWER in text
|
|
|
|
|
|
def test_the_passage_is_cut_at_line_boundaries() -> None:
|
|
body = _long_body()
|
|
out = consume.as_passage(_excerpt(body), body.index(ANSWER))
|
|
passage = out["passage"]
|
|
assert isinstance(passage, dict)
|
|
assert passage["start"] == 0 or body[passage["start"] - 1] == "\n"
|
|
assert passage["end"] == len(body) or body[passage["end"]] == "\n"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def bundle(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
spec = retrieval.BundleSpec(
|
|
"passage-synthetic",
|
|
(
|
|
retrieval.DocumentSpec(
|
|
"manual",
|
|
"manual.md",
|
|
(retrieval.ConceptSpec(slug="big", title="Operations", body=_long_body()),),
|
|
),
|
|
),
|
|
)
|
|
return retrieval.build_bundle(tmp_path_factory.mktemp("passage") / "bundle", spec)
|
|
|
|
|
|
def test_the_payload_delivers_the_answering_passage_of_a_large_concept(bundle: Path) -> None:
|
|
payload = consume.build_payload(bundle, question="retention window archived sessions")
|
|
excerpts = payload["excerpts"]
|
|
assert isinstance(excerpts, list) and len(excerpts) == 1
|
|
text = excerpts[0]["text"]
|
|
assert ANSWER in text
|
|
assert len(text) <= consume.PASSAGE_CHARS + consume.PASSAGE_HEADING_ALLOWANCE
|
|
assert "passage" in excerpts[0]
|
|
|
|
|
|
def test_the_fusion_ranking_still_delivers_the_whole_concept(bundle: Path) -> None:
|
|
payload = consume.build_payload(
|
|
bundle, question="retention window archived sessions", ranking="fusion"
|
|
)
|
|
excerpts = payload["excerpts"]
|
|
assert isinstance(excerpts, list) and len(excerpts) == 1
|
|
assert "passage" not in excerpts[0]
|
|
assert len(excerpts[0]["text"]) > consume.PASSAGE_CHARS
|