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:
parent
735468f600
commit
ab6e24aa22
12 changed files with 183 additions and 8 deletions
|
|
@ -1542,7 +1542,11 @@ R761 **8** (S1-S6 + KP + KN), vegnormal **32** questions / **43**
|
|||
`--ranking fusion` keeps everything below reachable; the fusion-mechanism
|
||||
tests and the reference-bundle pins ask for it by name. The retrieval gate
|
||||
still measures the DEFAULT, so four of its fusion-built premises are
|
||||
`xfail(strict=True)` until re-measured. What follows describes the fusion.
|
||||
`xfail(strict=True)` until re-measured. **C4:** `coverage.absent_terms`
|
||||
(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.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -827,6 +827,14 @@ the passage that answers, under the nearest heading above it, marked with
|
|||
they widen a signal (`--cost-vocabulary`, `--rarity-weight`) belong to it and
|
||||
are refused without it. The rest of this section describes the fusion.
|
||||
|
||||
**The payload says when the bundle looks like it does not cover a question.**
|
||||
`coverage.absent_terms` lists the question's words the bundle holds in no form
|
||||
— not as written and not through a relative it uses — and `coverage.weak` is
|
||||
true when one such word exists or nothing was delivered. It is a reading with
|
||||
its rule in the open, not a verdict: the reader rephrases in the bundle's own
|
||||
words, and if it stays weak, says the bundle does not cover the question. Words
|
||||
that only frame a question (`how often`, `hva står i`) are not topic words.
|
||||
|
||||
`--cost-vocabulary` is off by default and widens one question class: it lets a
|
||||
declared list of cost/price/quantity terms bridge a question and a document that
|
||||
name money with different words. The gate is the question — one naming no such
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ titles — if one of them is what you were after, that is a signal about the
|
|||
WORDS, not a closed door. Search again with the words that concept uses, or
|
||||
ask for it by name. There is no limit on runs and no penalty for a run that
|
||||
found nothing; a run that found nothing is a measurement, and its denominator
|
||||
is worth carrying.
|
||||
is worth carrying. When `coverage.weak` is true — a word of yours the bundle
|
||||
holds in no form (`coverage.absent_terms`), or nothing came back — rephrase in
|
||||
the bundle's own words, and if it stays weak, say the bundle does not cover it.
|
||||
|
||||
**4. Several bundles, same method.** When more than one bundle could answer,
|
||||
run the same sub-questions against each, and keep track of which bundle each
|
||||
|
|
|
|||
|
|
@ -92,7 +92,9 @@ titles — if one of them is what you were after, that is a signal about the
|
|||
WORDS, not a closed door. Search again with the words that concept uses, or
|
||||
ask for it by name. There is no limit on runs and no penalty for a run that
|
||||
found nothing; a run that found nothing is a measurement, and its denominator
|
||||
is worth carrying.
|
||||
is worth carrying. When `coverage.weak` is true — a word of yours the bundle
|
||||
holds in no form (`coverage.absent_terms`), or nothing came back — rephrase in
|
||||
the bundle's own words, and if it stays weak, say the bundle does not cover it.
|
||||
|
||||
**4. Several bundles, same method.** When more than one bundle could answer,
|
||||
run the same sub-questions against each, and keep track of which bundle each
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@
|
|||
"unanswered_in_payload": [
|
||||
"hva",
|
||||
"sier"
|
||||
]
|
||||
],
|
||||
"absent_terms": [],
|
||||
"weak": false
|
||||
},
|
||||
"excerpts": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -189,3 +189,17 @@ def test_a_concept_named_by_its_number_carries_a_lexical_match() -> None:
|
|||
ranked = bm25.rank(concepts, "Hva staar i punkt 4.2 om kontrollen av hytta?").ranked
|
||||
lexical = {concept.concept_id: count for concept, _, count in ranked}
|
||||
assert lexical["r/vakthold-4-2"] >= 1
|
||||
|
||||
|
||||
def test_a_norwegian_function_word_written_without_its_letters_is_a_stopword() -> None:
|
||||
# ASCII-only text writes `når` as `naar`; it is the same function word and
|
||||
# must not read as a content word the collection lacks.
|
||||
assert bm25.tokens("Naar skjer det paa hytta?") == bm25.tokens("Når skjer det på hytta?")
|
||||
|
||||
|
||||
def test_a_word_that_frames_a_question_is_not_a_topic() -> None:
|
||||
# `how often` / `hvor ofte` asks about a topic without naming one; read as
|
||||
# a topic word it would be "absent" from any collection that never says it.
|
||||
assert bm25.tokens("How often is the battery replaced?") == ["battery", "replac"]
|
||||
assert bm25.tokens("Hvor ofte byttes batteriet?") == ["bytt", "batteriet"]
|
||||
assert bm25.tokens("Hva står i punkt 4.2?") == bm25.tokens("punkt 4.2")
|
||||
|
|
|
|||
86
tests/test_coverage_signal.py
Normal file
86
tests/test_coverage_signal.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""The payload says when the collection looks like it does not cover a question
|
||||
(v1.1 order C, C4).
|
||||
|
||||
`coverage.absent_terms` is the question's words the collection holds in NO form
|
||||
-- not as written, and not through a relative it uses (`bm25.query_groups`).
|
||||
`coverage.weak` is the machine-readable reading: nothing was delivered, or at
|
||||
least one such word exists. A reader seeing `weak` rephrases in the
|
||||
collection's own words, or says the collection does not cover it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bundle(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
spec = retrieval.BundleSpec(
|
||||
"coverage-synthetic",
|
||||
(
|
||||
retrieval.DocumentSpec(
|
||||
"cabin",
|
||||
"cabin.md",
|
||||
(
|
||||
retrieval.ConceptSpec(
|
||||
slug="heating",
|
||||
title="Heating",
|
||||
body="The cabin is heated by a wood stove. Vinterberedskap is checked.",
|
||||
),
|
||||
retrieval.ConceptSpec(
|
||||
slug="water",
|
||||
title="Water",
|
||||
body="Water comes from the well and is drained in autumn.",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return retrieval.build_bundle(tmp_path_factory.mktemp("coverage") / "bundle", spec)
|
||||
|
||||
|
||||
def _coverage(bundle: Path, question: str) -> dict[str, object]:
|
||||
coverage = consume.build_payload(bundle, question=question)["coverage"]
|
||||
assert isinstance(coverage, dict)
|
||||
return coverage
|
||||
|
||||
|
||||
def test_a_covered_question_is_not_weak(bundle: Path) -> None:
|
||||
coverage = _coverage(bundle, "How is the cabin heated?")
|
||||
assert coverage["absent_terms"] == []
|
||||
assert coverage["weak"] is False
|
||||
|
||||
|
||||
def test_a_word_held_in_no_form_is_named_and_makes_the_answer_weak(bundle: Path) -> None:
|
||||
coverage = _coverage(bundle, "How is the cabin sauna heated?")
|
||||
assert coverage["absent_terms"] == ["sauna"]
|
||||
assert coverage["weak"] is True
|
||||
|
||||
|
||||
def test_a_word_held_in_another_form_is_not_absent(bundle: Path) -> None:
|
||||
coverage = _coverage(bundle, "When is vinterberedskapen checked?")
|
||||
assert coverage["absent_terms"] == []
|
||||
assert coverage["weak"] is False
|
||||
|
||||
|
||||
def test_nothing_delivered_is_weak(bundle: Path) -> None:
|
||||
coverage = _coverage(bundle, "zzqx")
|
||||
assert coverage["weak"] is True
|
||||
|
||||
|
||||
def test_the_gates_shared_reading_reads_the_signal(bundle: Path) -> None:
|
||||
payload = consume.build_payload(bundle, question="How is the cabin sauna heated?")
|
||||
assert retrieval.marked(payload)
|
||||
payload = consume.build_payload(bundle, question="How is the cabin heated?")
|
||||
assert not retrieval.marked(payload)
|
||||
|
|
@ -32,7 +32,7 @@ sys.path.insert(0, str(PROJECT_ROOT / "tools"))
|
|||
|
||||
import okf_retrieval_gate as gate # noqa: E402
|
||||
|
||||
from llm_ingestion_okf import consume # noqa: E402
|
||||
from llm_ingestion_okf import bm25, consume # noqa: E402
|
||||
|
||||
FIXTURES = PROJECT_ROOT / "tests" / "fixtures" / "retrieval"
|
||||
|
||||
|
|
@ -326,6 +326,8 @@ def test_row_four_goes_red_again_when_the_payload_stops_saying_what_it_missed(
|
|||
detail line.
|
||||
"""
|
||||
monkeypatch.setattr(consume, "unanswered_terms", lambda *args, **kwargs: [])
|
||||
# And its second reading since v1.1 C4: no word is absent in any form.
|
||||
monkeypatch.setattr(bm25, "_absent", lambda *args, **kwargs: ())
|
||||
row = gate.row_four([_case(tmp_path, "set-controls.json")])
|
||||
assert row.status == gate.RED
|
||||
assert row.m == 6
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,10 @@ def marked(payload: Mapping[str, object]) -> bool:
|
|||
return True
|
||||
coverage = payload.get("coverage")
|
||||
assert isinstance(coverage, dict), "the payload carries no coverage block to read"
|
||||
if coverage.get("weak") is True:
|
||||
# v1.1 C4: the payload's own reading, a word the bundle holds in no
|
||||
# form. Read beside the bar below, never instead of it.
|
||||
return True
|
||||
terms = coverage["question_terms"]
|
||||
unanswered = coverage["unanswered_in_bundle"]
|
||||
assert isinstance(terms, list) and isinstance(unanswered, list)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue