feat(consume): the payload says when the bundle looks like it does not cover the question

C4. `coverage` gains two keys, and every key it had keeps its bytes:

- `absent_terms`: the question's words the bundle holds in NO form -- not as
  written, and not through a relative it uses (`bm25.query_groups`, the same
  bridge the ranking reads through);
- `weak`: true when one such word exists or nothing was delivered.

A reading with its rule in the open, never a verdict about the bundle. It is
computed for both rankings (`bm25.absent_terms` serves the fusion). The
retrieval gate's `marked` -- the one reading both gates share -- reads `weak`
beside its own bar, never instead of it; the known-negative that strips the
payload's words now strips both readings.

Words that only FRAME a question are stopwords in both languages (`how
often`, `hvor ofte`, `hva står i`, `what does it say`), and so are the
Norwegian function words spelled without their letters (`naar`, `paa`), the way
ASCII-only text writes them. Read as topic words they would be "absent" from
any collection that never uses them, which is what the synthetic sets showed
on three answered questions before the list was extended.

The working method says what to do with it, in one sentence each: the skill
template's step 3 and the MCP server's instructions (1 253 bytes, under the
2 048 a client keeps) -- rephrase in the bundle's words, and if it stays weak,
say the bundle does not cover the question.

The search gate's table for this commit is kept in local state.

Suite on a clean tree after `git add`: 2397 passed, 2 skipped, 4 xfailed.
ruff, ruff format, mypy --strict clean. Retrieval gate unchanged at the rows
the previous commit left red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-21 07:12:43 +02:00
commit ab6e24aa22
12 changed files with 183 additions and 8 deletions

View file

@ -67,7 +67,12 @@ _TOKEN = re.compile(r"[0-9a-zà-öø-ÿ]+(?:[-_][0-9a-zà-öø-ÿ]+)*")
#: English and Norwegian function words. Norwegian because an operator asks in
#: Norwegian against a collection that may be English; a Norwegian function
#: word is then noise that could only ever match by accident.
#: word is then noise that could only ever match by accident. The last line is
#: the Norwegian ones spelled without their letters (`når` as `naar`), the way
#: ASCII-only text writes them, and the words that FRAME a question in either
#: language (`how often`, `hvor ofte`, `hva står i`, `what does it say`) -- they ask about a topic
#: without naming one, so read as topic words they would be "absent" from
#: every collection that never uses them (`coverage.absent_terms`).
STOPWORDS = frozenset(
"""a an and are as at be but by for if in into is it its of on or such that the
their then there these they this to was will with what which how when where who why
@ -78,7 +83,11 @@ again further once here all any both each few nor
og i jeg det at en et den til er som på de med han av ikke der så var meg seg
men ett har om vi min mitt ha hadde hun nå over da ved fra du ut sin dem oss opp man
kan hans hvor eller hva skal selv her alle vil bli ble blitt kunne inn når være kom
noen noe ville dere hvordan gjør gjøre lar la flere""".split()
noen noe ville dere hvordan gjør gjøre lar la flere
paa saa naa naar vaere gjoer gjoere
often many much ofte mange mye lenge hvilken hvilke hvilket hvorfor hvem bare også
ogsaa hver ingen uten mellom fordi før foer etter enn både baade denne dette disse
samme slik sånn saann står staar sier say says""".split()
)
_SUFFIXES = ("ingly", "edly", "ing", "ies", "ied", "ed", "es", "s", "ly", "er", "est")
@ -246,6 +255,8 @@ class Ranking:
ranked: list[tuple[Concept, float, int]]
best_window: dict[str, int]
#: The question's words the collection holds in NO form, in question order.
absent: tuple[str, ...] = ()
def _fuse(fused: dict[str, float], scores: dict[str, float]) -> None:
@ -326,4 +337,28 @@ def rank(
return Ranking(
ranked=[(by_id[key], fused[key], lexical[key]) for key in order],
best_window=best_window,
absent=_absent(query, groups),
)
def _absent(query: Sequence[str], groups: Sequence[frozenset[str]]) -> tuple[str, ...]:
return tuple(
term for term, group in zip(dict.fromkeys(query), groups, strict=True) if not group
)
def absent_terms(
concepts: Sequence[Concept], question: str, *, bodies: Sequence[str]
) -> tuple[str, ...]:
"""The question's words the collection holds in no form -- not as written
and not through a relative (`query_groups`). The same reading `rank`
reports, for a caller ranking some other way."""
shared = _shared_segments([concept.concept_id for concept in concepts])
own_source = len({concept.source_file for concept in concepts}) > 1
vocabulary = frozenset(
term
for concept, text in zip(concepts, bodies, strict=True)
for term in tokens(field_text(concept, text, shared=shared, own_source=own_source))
)
query = tokens(question)
return _absent(query, query_groups(query, vocabulary))

View file

@ -2510,6 +2510,7 @@ def build_payload(
by_id = {concept.concept_id: concept for concept in concepts}
scored = {concept.concept_id: (score, lexical) for concept, score, lexical in result.ranked}
windows: Mapping[str, int] | None = result.best_window
absent = result.absent
ranked = [
(by_id[concept_id], *scored[concept_id])
for concept_id in _partitioned(
@ -2522,6 +2523,11 @@ def build_payload(
]
else:
windows = None
absent = bm25.absent_terms(
concepts,
question,
bodies=[delivered_text(body_without_link_line(concept.body)) for concept in concepts],
)
weights = rarity_weights(normalise(question), texts, stems=stems) if rarity_weight else None
ranked = concept_scores(
concepts,
@ -2625,6 +2631,14 @@ def build_payload(
[normalise(excerpt_text(excerpt)) for excerpt in delivered],
stems=stems,
),
# v1.1 C4: a READING, with its rule in the open, never a verdict.
# `absent_terms` are the question's words this bundle holds in no
# form -- not as written, not through a relative it uses -- and
# `weak` is true when nothing was delivered or one such word
# exists. A reader seeing it rephrases in the bundle's own words
# or says the bundle does not cover the question.
"absent_terms": list(absent),
"weak": not delivered or bool(absent),
},
"excerpts": list(delivered),
# SS 5.1/5.2 on the NUMBERS and SS 5.3 on the names. See this

View file

@ -94,7 +94,9 @@ SERVER_INSTRUCTIONS = (
"what you wanted, that is a fact about the WORDS, not a closed door -- ask "
"again with that concept's own words, or fetch it by name with "
"`okf_fetch`. Several calls are normal and expected; there is no limit and "
"no penalty. Then write ONE answer, ordered by sub-question, in the "
"no penalty. When `coverage.weak` is true, rephrase in the bundle's words, "
"and if it stays weak say the bundle does not cover the question. Then "
"write ONE answer, ordered by sub-question, in the "
"questioner's language and in ordinary prose, citing the document and the "
"section (and the bundle, when you read more than one). Say plainly what "
"the bundles do not cover.\n\n"