llm-ingestion-okf/tests/test_subquestions.py
Kjell Tore Guttormsen 80aac93b8f feat(consume): several sub-questions in one call, merged by the product
C2. `consume.build_multi_payload` takes two or more questions, reads the
bundle ONCE (`bm25.prepare` builds the index a question does not depend on),
ranks and cuts each sub-question exactly as `build_payload` would alone, and
interleaves the deliveries: first excerpt of each sub-question in turn, then
the second, a concept already taken skipped, cut at the same `k` and `limit`
one question gets -- so asking four times does not buy a payload four times
the size.

Chose round-robin, not a merge by score, because two questions' BM25 totals
are not on one scale: a merge by score would let the wordiest sub-question
take every place. It is the rule the search gate measured with before the
product had it, moved unchanged.

Shape, and only for two or more questions (one question is `build_payload`'s
payload byte for byte):

- `questions` replaces `question`;
- every excerpt carries `subquestions`, the indices of every sub-question
  whose own delivery named it, the one whose text (passage) it carries first;
- `coverage` holds one block per sub-question (the single shape plus its
  `question`, `unanswered_in_payload` read against what the reader receives),
  `weak_subquestions`, and `weak` true only when EVERY sub-question is weak;
- `withheld` is every concept the merge did not deliver: `below_k` where a
  sub-question delivered it and the merge's cut did not, otherwise the rule
  of the sub-question that ranked it best. `nearest` walks the rankings in the
  delivery's turn order. The contract checker accepts it with 0 findings.

`okf consume --question A --question B` and `okf_ask` with `questions` (both
forms at once is `question_ambiguous`) reach it. A reservation or a fusion
widening acts on ONE cut and is refused with several questions
(`subquestions_flag_conflict`).

The search gate's series (e) and (f) now ask ONE call with every
sub-question; the gate's own merge is gone. Sets and thresholds untouched.
The gate's table for this commit is kept in local state.

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

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

249 lines
11 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"