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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-21 08:04:29 +02:00
commit 80aac93b8f
6 changed files with 918 additions and 173 deletions

View file

@ -1546,7 +1546,12 @@ R761 **8** (S1-S6 + KP + KN), vegnormal **32** questions / **43**
(question words held in NO form, after the bridge) and `coverage.weak`
(one such word, or nothing delivered) -- a reading, not a verdict; the
retrieval gate's `marked` reads it beside its own bar. Words that only frame
a question are stopwords in both languages. What follows describes the fusion.
a question are stopwords in both languages. **C2:** `build_multi_payload`
(`okf consume --question A --question B`, `okf_ask` `questions`) reads the
bundle once (`bm25.prepare`), cuts each sub-question as alone and
interleaves the deliveries round-robin under the same `k`/`limit`; one
question is `build_payload`'s bytes. The search gate's (e)/(f) go through it.
What follows describes the fusion.
- Consume a bundle: `okf consume <bundle> --question "<q>"
[--k N] [--limit N] [--out PATH] [--ref IDENTITY]` — the **pre-pass**
`docs/consumption-contract.md` § 1 defines, and the only reading direction