llm-ingestion-okf/tests/test_subquestions.py
Kjell Tore Guttormsen da6faf8776 feat(mcp): the bundle's map, and a working method that reads it first
C5. `bundlemap.build_map` lists a bundle in its own words: one line per
source document -- its name, then the titles of its concepts in document
order -- and documents whose names differ only in their numbers (a changelog
per release, a note per week) as ONE line: the name with every number as `#`,
the count, the first and last by natural order, and the titles across the
series that are words. `SERIES_MIN` = 5, at most `TITLES_PER_LINE` = 24 titles
a line, the lines capped at `MAP_MAX_BYTES` = 48 000 together with
`lines_truncated` counting the rest. Derived on every call, never stored.

The card (`okf card`, `okf_describe`) carries it as `map` and no longer
carries `source_files`: that list named every document a second time with no
series collapsed, a quarter of the reply on a large bundle, for names the map
already carries. Chose removal over keeping both because the describe reply
has to fit a client's tool-reply limit and the map says more.

The working method now reads: take the map first (`okf card`, or
`okf_describe`), write two to four sub-questions in its words, and send them
in ONE call (`--question` repeated, or `okf_ask` `questions`). Changed in the
skill template, the generated `skills/okf-consume`, and the server
instructions (held under the 2 KB a client keeps). The regeneration recipe for
`skills/okf-consume` gains `--for-bundle`: since v1.1 the generator writes the
generic skill by default, so the recipe as published produced the other file.

A test holds a four-sub-question `okf_ask` over concepts far over the passage
size under 50 000 bytes of reply text (25 000 tokens at a pessimistic two
bytes a token). The real-collection measurements are kept in local state.

Suite on a clean tree after `git add`: 2429 passed, 2 skipped, 4 xfailed.
ruff, ruff format, mypy --strict clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:37:12 +02:00

285 lines
12 KiB
Python

"""Several sub-questions in ONE call (v1.1 order C, C2).
A broad question is asked best as two to four narrow ones in the collection's
own words. Until C2 that meant one call per sub-question and a merge done by
whoever was asking -- the search gate did it itself, in its own code. Now
`consume.build_multi_payload` (and `okf consume --question A --question B`,
and `okf_ask` with `questions`) ranks each sub-question on ONE load of the
bundle and interleaves the deliveries: first excerpt of each sub-question in
turn, then the second, duplicates dropped, cut at `k`. Every excerpt says
which sub-questions it answered; the first index is the one whose text it
carries, since a large concept is delivered as the passage its OWN
sub-question found.
The expected merge below is written from the definition, over the SINGLE
payloads, so the product's merge is held against an independent reading of
the same rule and not against itself.
"""
from __future__ import annotations
import json
import sys
from collections.abc import Sequence
from pathlib import Path
import pytest
from llm_ingestion_okf import consume, contract_check, mcp_server
from llm_ingestion_okf import skill as okf_skill
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
HEATING = "How is the cabin heated in winter?"
ENGINE = "How is the boat engine serviced?"
APPLES = "When are the garden apples picked?"
UNCOVERED = "zzqx vvkw"
def _concept(slug: str, title: str, body: str) -> retrieval.ConceptSpec:
return retrieval.ConceptSpec(slug=slug, title=title, body=body)
@pytest.fixture(scope="module")
def bundle(tmp_path_factory: pytest.TempPathFactory) -> Path:
spec = retrieval.BundleSpec(
"subquestions-synthetic",
(
retrieval.DocumentSpec(
"cabin",
"cabin.md",
(
_concept(
"heating", "Heating", "The cabin is heated by a wood stove in winter."
),
_concept("water", "Water", "Water comes from the well; the cabin pipes drain."),
_concept("roof", "Roof", "The cabin roof is cleared of snow in winter."),
),
),
retrieval.DocumentSpec(
"boat",
"boat.md",
(
_concept("engine", "Engine", "The boat engine is serviced every spring."),
_concept("sails", "Sails", "The boat sails are dried before storage."),
_concept("anchor", "Anchor", "The anchor chain of the boat is checked."),
),
),
retrieval.DocumentSpec(
"garden",
"garden.md",
(
_concept("apples", "Apples", "The garden apples are picked in September."),
_concept("roses", "Roses", "The roses in the garden are pruned in March."),
),
),
),
)
return retrieval.build_bundle(tmp_path_factory.mktemp("subquestions") / "bundle", spec)
def _ids(payload: dict[str, object]) -> list[str]:
excerpts = payload["excerpts"]
assert isinstance(excerpts, list)
return [str(excerpt["concept_id"]) for excerpt in excerpts]
def _interleaved(lists: Sequence[Sequence[str]], cap: int) -> list[str]:
"""The rule, written from its definition: position by position, each
sub-question in turn, a concept already taken skipped, stop at `cap`."""
out: list[str] = []
for position in range(max(len(ids) for ids in lists)):
for ids in lists:
if position < len(ids) and ids[position] not in out and len(out) < cap:
out.append(ids[position])
return out
def test_the_interleave_takes_turns_skips_what_is_taken_and_cuts_at_k() -> None:
first = [{"concept_id": "a"}, {"concept_id": "b"}, {"concept_id": "c"}]
second = [{"concept_id": "b"}, {"concept_id": "d"}]
merged = consume.interleave([first, second], k=3, limit=consume.DEFAULT_LIMIT)
assert [(excerpt["concept_id"], named) for excerpt, named in merged] == [
("a", [0]),
("b", [1, 0]),
("d", [1]),
]
assert consume.interleave([], k=5, limit=consume.DEFAULT_LIMIT) == []
def test_the_interleave_never_spends_more_than_the_limit() -> None:
large = {"concept_id": "a", "text": "x" * 2_000}
small = {"concept_id": "b", "text": "y"}
limit = consume.excerpt_weight(small) + 10
merged = consume.interleave([[large], [small]], k=8, limit=limit)
assert [excerpt["concept_id"] for excerpt, _ in merged] == ["b"]
def test_deliveries_are_interleaved_deduplicated_and_cut_at_k(bundle: Path) -> None:
questions = [HEATING, ENGINE, APPLES]
singles = [_ids(consume.build_payload(bundle, question=q, k=3)) for q in questions]
# The premise: each sub-question reaches something the others do not, or
# an interleave and a concatenation could not be told apart.
assert len({ids[0] for ids in singles}) == 3
payload = consume.build_multi_payload(bundle, questions=questions, k=4)
assert _ids(payload) == _interleaved(singles, 4)
def test_every_excerpt_names_the_subquestions_it_answered(bundle: Path) -> None:
questions = [HEATING, ENGINE, "What happens to the cabin in winter?"]
singles = [consume.build_payload(bundle, question=q, k=3) for q in questions]
payload = consume.build_multi_payload(bundle, questions=questions, k=3)
excerpts = payload["excerpts"]
assert isinstance(excerpts, list) and excerpts
shared = 0
for excerpt in excerpts:
named = excerpt["subquestions"]
answered = [i for i, single in enumerate(singles) if excerpt["concept_id"] in _ids(single)]
assert sorted(named) == answered
shared += len(named) > 1
# The text is the one its FIRST sub-question delivered.
placing = singles[named[0]]["excerpts"]
assert isinstance(placing, list)
original = next(e for e in placing if e["concept_id"] == excerpt["concept_id"])
assert {key: value for key, value in excerpt.items() if key != "subquestions"} == original
assert shared >= 1, "the premise: two sub-questions reach one concept"
def test_one_question_is_the_single_payload_byte_for_byte(bundle: Path) -> None:
assert consume.serialise(
consume.build_multi_payload(bundle, questions=[HEATING])
) == consume.serialise(consume.build_payload(bundle, question=HEATING))
def test_the_same_subquestions_give_the_same_bytes(bundle: Path) -> None:
questions = [HEATING, ENGINE, APPLES, UNCOVERED]
first = consume.serialise(consume.build_multi_payload(bundle, questions=questions))
second = consume.serialise(consume.build_multi_payload(bundle, questions=questions))
assert first == second
def test_the_denominators_close_and_the_contract_checker_accepts_it(bundle: Path) -> None:
payload = consume.build_multi_payload(bundle, questions=[HEATING, ENGINE], k=3)
counts = payload["denominators"]
assert isinstance(counts, dict)
assert counts["considered"] == counts["withheld"] + counts["delivered"] == 8
withheld = payload["withheld"]
assert isinstance(withheld, dict)
assert withheld["total"] == counts["withheld"]
assert sum(withheld["by_rule"].values()) == withheld["total"]
report = contract_check.check(okf_skill.render_generic(), payload)
assert report.findings == ()
def test_a_concept_another_subquestion_delivered_and_the_cut_dropped_is_below_k(
bundle: Path,
) -> None:
questions = [HEATING, ENGINE]
singles = [_ids(consume.build_payload(bundle, question=q, k=3)) for q in questions]
payload = consume.build_multi_payload(bundle, questions=questions, k=3)
dropped = {cid for ids in singles for cid in ids} - set(_ids(payload))
assert dropped, "the premise: the merge's cut drops something a sub-question delivered"
withheld = payload["withheld"]
assert isinstance(withheld, dict)
rules = {entry["concept_id"]: entry["rule"] for entry in withheld["nearest"]}
assert all(rules[cid] == "below_k" for cid in dropped)
def test_the_payload_states_each_subquestion_and_its_coverage(bundle: Path) -> None:
payload = consume.build_multi_payload(bundle, questions=[HEATING, UNCOVERED])
assert payload["questions"] == [HEATING, UNCOVERED]
assert "question" not in payload
coverage = payload["coverage"]
assert isinstance(coverage, dict)
per = coverage["subquestions"]
assert [entry["question"] for entry in per] == [HEATING, UNCOVERED]
assert [entry["weak"] for entry in per] == [False, True]
assert per[1]["absent_terms"] == ["zzqx", "vvkw"]
assert coverage["weak_subquestions"] == [1]
# One sub-question the collection covers: the whole is not read as uncovered.
assert coverage["weak"] is False
def test_every_subquestion_weak_makes_the_whole_weak(bundle: Path) -> None:
coverage = consume.build_multi_payload(bundle, questions=[UNCOVERED, "qqzv wwkx"])["coverage"]
assert isinstance(coverage, dict)
assert coverage["weak"] is True
assert coverage["weak_subquestions"] == [0, 1]
def test_no_question_is_refused(bundle: Path) -> None:
with pytest.raises(consume.ConsumeError) as raised:
consume.build_multi_payload(bundle, questions=[])
assert raised.value.code == "question_missing"
with pytest.raises(consume.ConsumeError) as raised:
consume.build_multi_payload(bundle, questions=[HEATING, " "])
assert raised.value.code == "question_missing"
def test_the_command_line_takes_the_question_more_than_once(bundle: Path, tmp_path: Path) -> None:
out = tmp_path / "payload.json"
code = consume.main(
[str(bundle), "--question", HEATING, "--question", ENGINE, "--out", str(out)]
)
assert code == 0
written = out.read_text(encoding="utf-8")
assert written == consume.serialise(
consume.build_multi_payload(bundle, questions=[HEATING, ENGINE])
)
def test_okf_ask_takes_several_questions_in_one_call(bundle: Path) -> None:
surface = mcp_server.build_surface(bundle=bundle, roots=())
result = mcp_server.call_ask(surface, {"questions": [HEATING, ENGINE]})
assert result["questions"] == [HEATING, ENGINE]
payload = result["answers"][0]["payload"]
assert json.dumps(payload, sort_keys=True) == json.dumps(
consume.build_multi_payload(bundle, questions=[HEATING, ENGINE]), sort_keys=True
)
def test_okf_ask_refuses_both_forms_at_once(bundle: Path) -> None:
surface = mcp_server.build_surface(bundle=bundle, roots=())
with pytest.raises(mcp_server.ToolError) as raised:
mcp_server.call_ask(surface, {"question": HEATING, "questions": [ENGINE]})
assert raised.value.code == "question_ambiguous"
def test_four_subquestions_over_large_concepts_stay_under_a_tool_reply(tmp_path: Path) -> None:
"""The worst case the passage cut exists for: every delivered concept is far
over `PASSAGE_CHARS`. A client keeps a tool reply of 25 000 tokens; at a
pessimistic two bytes a token that is 50 000 bytes of text."""
words = ("stove", "engine", "apples", "roof")
spec = retrieval.BundleSpec(
"large-concepts",
tuple(
retrieval.DocumentSpec(
f"doc-{word}",
f"doc-{word}.md",
tuple(
retrieval.ConceptSpec(
slug=f"part-{n}",
title=f"{word.title()} part {n}",
body=f"The {word} is described here in detail. ",
repeat=400,
)
for n in range(3)
),
)
for word in words
),
)
bundle = retrieval.build_bundle(tmp_path / "bundle", spec)
surface = mcp_server.build_surface(bundle=bundle, roots=())
result = mcp_server.call_ask(
surface, {"questions": [f"How is the {w} described?" for w in words]}
)
payload = result["answers"][0]["payload"]
assert len(payload["excerpts"]) == consume.DEFAULT_K
assert all(len(excerpt["text"]) > consume.PASSAGE_CHARS // 2 for excerpt in payload["excerpts"])
text = mcp_server._tool_result(result)["content"][0]["text"]
assert len(text.encode("utf-8")) < 50_000