weft-generate 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ # The donor checkout, reachable for reading and lifting only.
2
+ # Never a build input: see CLAUDE.md and docs/README.md.
3
+ /donor
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ .venv/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+
13
+ # Tooling
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .pyright/
17
+
18
+ # `uv.lock` was here, filed between two caches. It is not a cache: `ci.yml` runs
19
+ # `uv sync --frozen` in three of its four jobs, and every one of them died at that step
20
+ # the first time CI ever ran. It is tracked now — the resolution CI installs and the
21
+ # resolution a developer installs must be one artefact, which is the same argument
22
+ # `docs/README.md` makes about single-sourcing anything two readers can disagree about.
23
+ #
24
+ # It constrains nobody downstream. A lockfile is not a dependency bound; what a consumer
25
+ # resolves is decided by each distribution's `pyproject.toml`.
26
+
27
+ # Session-local attempt counter for the guard_quality_gates.py PreToolUse hook — per-session
28
+ # scratch, not a record anyone should read later. See the hook's module docstring.
29
+ .claude/.gate-attempts.json
30
+
31
+ # Secrets. `.env` holds live provider keys; only the documented, valueless
32
+ # example is tracked. Listed before any tooling rule so a stray `git add -A`
33
+ # cannot reach it.
34
+ .env
35
+ .env.*
36
+ !.env.example
37
+
38
+ # Corpus payload. Every subdirectory of /corpus is a materialised document set and
39
+ # is deliberately untracked; `corpus/manifest.toml` and `scripts/fetch_corpus.py`
40
+ # are the tracked artefact. `09` §4 V1 permits exactly this:
41
+ # a corpus is "either redistributable or fetched by a pinned, checksummed script",
42
+ # and the mRMR papers are published under publisher copyright, so committing them
43
+ # would be redistribution this repository has no right to perform. The manifest
44
+ # carries a sha256 per document, which is what makes the set reproducible without
45
+ # the bytes being here. The pattern is a directory glob rather than a list of names
46
+ # so that scaling the corpus up cannot silently start tracking a paper.
47
+ /corpus/*/
48
+
49
+ # Where a baseline run stages the corpus it indexes and writes the `weft.toml` it measures
50
+ # through (`eval/run_baseline.py`). The staged copies are the same untracked papers one
51
+ # directory over, and the configuration is reproduced by the harness rather than kept — what
52
+ # is tracked is the run it produced, under `eval/baselines/`.
53
+ /.baseline-run/
54
+
55
+ # Working artefacts of a build session — a generated map of the codebase and a design
56
+ # record produced while planning. Untracked on purpose: `docs/README.md` routes every
57
+ # document this project owns, and a design that matters belongs in the `docs/` file
58
+ # that owns its content, not in a root-level file nothing points at.
59
+ /.phase2-*.md
60
+ /.phase3-*.md
61
+ /.phase4-*.md
62
+
63
+ # Gate-session preparation: the Bring lists of `docs/05-grilling-sessions.md`, measured on the day
64
+ # a session is about to run. Untracked for the same reason — the session's outcome belongs in
65
+ # `docs/README.md`'s decision log and in the reference document the decision changes, never here.
66
+ /.gate-brief-*.md
67
+
68
+ # Claude Code local state
69
+ .claude/settings.local.json
70
+
71
+ .DS_Store
72
+
73
+ # The build harness driving Phase 2, alongside its brief, findings and design record.
74
+ /.phase2-build.js
75
+
76
+ # Transient: harvested subagent findings, promoted into docs/lessons.md by implement-ll.
77
+ # Never committed — its content belongs in the queue or nowhere.
78
+ .claude/lessons-spool.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Krysztopa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,24 @@
1
+ Weft
2
+ Copyright (c) 2026 Adam Krysztopa
3
+
4
+ This product is licensed under the MIT License. See the LICENSE file at the root
5
+ of this repository.
6
+
7
+ --------------------------------------------------------------------------------
8
+ Original work
9
+ --------------------------------------------------------------------------------
10
+
11
+ **Weft contains no source text from any other codebase.** Every line here is
12
+ written for this project, against this project's contracts.
13
+
14
+ This is a rule, not a description of the current state: no file may be copied or
15
+ adapted from another project's source, and no third-party source text may be
16
+ pasted into this repository. Where a prior system informed a design, what was
17
+ carried across is understanding — an approach, an ordering, a measurement, a
18
+ reason a guard exists — restated in this project's own words and implemented
19
+ fresh. Copyright does not reach any of that, and nothing in this repository
20
+ depends on a licence granted by anyone else.
21
+
22
+ `docs/04-donor-inventory.md` records what was learned from prior work and what
23
+ was deliberately not taken. It is a design record. Nothing in it authorises a
24
+ copy, because copying is not permitted here at all.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: weft-generate
3
+ Version: 1.0.0
4
+ Summary: First-party generation pack. Publishes the Generator contract and the Answer vocabulary.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ License-File: NOTICE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: weft-kernel<1.0.0,>=0.1.0
10
+ Requires-Dist: weft-llm<2.0.0,>=1.0.0
11
+ Requires-Dist: weft-prompts<2.0.0,>=1.0.0
12
+ Requires-Dist: weft-retrieve<2.0.0,>=1.0.0
13
+ Requires-Dist: weft-store<3.0.0,>=2.0.0
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "weft-generate"
3
+ version = "1.0.0"
4
+ description = "First-party generation pack. Publishes the Generator contract and the Answer vocabulary."
5
+ requires-python = ">=3.12"
6
+ license = "MIT"
7
+ license-files = ["LICENSE", "NOTICE"]
8
+ dependencies = ["weft-kernel>=0.1.0,<1.0.0", "weft-store>=2.0.0,<3.0.0", "weft-retrieve>=1.0.0,<2.0.0", "weft-llm>=1.0.0,<2.0.0", "weft-prompts>=1.0.0,<2.0.0"]
9
+
10
+ # `weft-llm` and `weft-prompts` are new as of task 2.9: `cited-answer` asks a registered
11
+ # prompt through `weft_prompts.contract.Prompt` and answers through `weft_llm.contract.LLM`,
12
+ # exactly the way `weft-retrieve`'s `llm-rerank` does one pack over.
13
+
14
+ # `weft.packs` arrives with this same task: `cited-answer` is the first plugin this
15
+ # distribution registers, so there is finally something for the entry point to name.
16
+ [project.entry-points."weft.packs"]
17
+ generate = "weft_generate:register"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
@@ -0,0 +1,115 @@
1
+ """First-party generation pack.
2
+
3
+ Publishes the `Generator` contract (`contract.py`) and the answer vocabulary
4
+ (`payload.py`). Task **2.4** built the mechanism and registered nothing under it, the
5
+ same way `weft-retrieve` did before task 2.7 — see
6
+ `packages/weft-retrieve/src/weft_retrieve/__init__.py` for the whole reason a `weft.packs`
7
+ entry point waits for a plugin to name. Task **2.9** was the first plugin here:
8
+ `cited-answer`, brought together with its own registered `Prompt` in one `register()`
9
+ call, the same shape `weft_retrieve.register` uses for `llm-rerank` and
10
+ `passage-relevance`. **Task 2.22 is the second**: `contradiction-check`, with its own two
11
+ registered prompts. **Task 2.24 is the third**: `refine-on-uncertainty`, which registers no
12
+ new prompt of its own — its own drafting call reuses `cited-answer`'s already-registered
13
+ `answer-with-citations`, and its uncertainty trigger is `weft_retrieve.contract.Sufficiency`
14
+ (task 2.4's contract; `weft_retrieve.sufficiency`, this same task's other half, is where
15
+ `llm-sufficiency` and `hedge-phrases` register), resolved by name through `StageLookup`
16
+ rather than imported here.
17
+ """
18
+
19
+ from pydantic import BaseModel, ConfigDict
20
+
21
+ from weft_generate.cited_answer import NAME as CITED_ANSWER_NAME
22
+ from weft_generate.cited_answer import CitedAnswer, CitedAnswerConfig, WhenNoEvidence
23
+ from weft_generate.contract import GENERATE_CONTRACT_VERSION, Generator
24
+ from weft_generate.contradiction import NAME as CONTRADICTION_CHECK_NAME
25
+ from weft_generate.contradiction import Agreement, ContradictionCheck, ContradictionCheckConfig
26
+ from weft_generate.payload import Answer, AnswerStance, Citation
27
+ from weft_generate.prompts import (
28
+ ANSWER_WITH_CITATIONS_NAME,
29
+ CONTRADICTION_ANSWER_NAME,
30
+ CONTRADICTION_CRITIC_NAME,
31
+ AnswerWithCitationsPrompt,
32
+ ConflictStatus,
33
+ ContradictionAnswerPrompt,
34
+ ContradictionCriticPrompt,
35
+ )
36
+ from weft_generate.refine import NAME as REFINE_ON_UNCERTAINTY_NAME
37
+ from weft_generate.refine import (
38
+ RefinementStop,
39
+ RefinementTrace,
40
+ RefineOnUncertainty,
41
+ RefineOnUncertaintyConfig,
42
+ refinement_stop,
43
+ )
44
+ from weft_kernel.discovery import PackRegistrar
45
+ from weft_prompts.contract import Prompt
46
+
47
+
48
+ class Settings(BaseModel):
49
+ """`weft-generate` takes no pack settings — an empty model is still the required shape."""
50
+
51
+ model_config = ConfigDict(frozen=True, extra="forbid")
52
+
53
+
54
+ def register(registrar: PackRegistrar, settings: Settings) -> None:
55
+ """Register every `Generator` this pack ships, and the prompts each one asks under
56
+ `Prompt`.
57
+
58
+ Every `Prompt` goes through the same `registrar` as its owning `Generator` —
59
+ `weft_retrieve.__init__`'s own docstring states why a first-party prompt belongs to
60
+ the plugin that asks the question rather than to `weft-prompts`, which registers
61
+ nothing at all.
62
+
63
+ **`Agreement` and `RefinementTrace` are deliberately not passed to `registrar.
64
+ add_ext_model` — task 5.2g's own finding, not an oversight.** Both attach to
65
+ `Answer.ext`, never to `Node.ext`, and only a `Node` is ever handed to a `NodeStore` —
66
+ `weft_store.rehydrate.rehydrate_ext` reconstructs a *node's* `ext` map and is never
67
+ called with an `Answer`'s. Registering both would also collide: they share
68
+ `__namespace__ = "weft-generate"`, so `weft_store.rehydrate.ext_models` — one class
69
+ per namespace, globally — would raise `DuplicateRegistrationError` the moment both
70
+ are active, which `contradiction-check` and `refine-on-uncertainty` together already
71
+ are in this pack's own default pipelines. `docs/lessons.md` L5.20 records this as the
72
+ reason `add_ext_model` is for an `ExtModel` that reaches a `Node`, not for every
73
+ `ExtModel` a pack happens to own — see `weft_retrieve.__init__`'s own module
74
+ docstring for the identical finding against `BooleanPlan`/`CorrectiveTrace`/
75
+ `IterativeRetrievalTrace`.
76
+ """
77
+ del settings
78
+ registrar.add(Generator, CITED_ANSWER_NAME, CitedAnswer)
79
+ registrar.add(Prompt, ANSWER_WITH_CITATIONS_NAME, AnswerWithCitationsPrompt)
80
+ registrar.add(Generator, CONTRADICTION_CHECK_NAME, ContradictionCheck)
81
+ registrar.add(Prompt, CONTRADICTION_CRITIC_NAME, ContradictionCriticPrompt)
82
+ registrar.add(Prompt, CONTRADICTION_ANSWER_NAME, ContradictionAnswerPrompt)
83
+ registrar.add(Generator, REFINE_ON_UNCERTAINTY_NAME, RefineOnUncertainty)
84
+
85
+
86
+ __all__ = [
87
+ "ANSWER_WITH_CITATIONS_NAME",
88
+ "CITED_ANSWER_NAME",
89
+ "CONTRADICTION_ANSWER_NAME",
90
+ "CONTRADICTION_CHECK_NAME",
91
+ "CONTRADICTION_CRITIC_NAME",
92
+ "GENERATE_CONTRACT_VERSION",
93
+ "REFINE_ON_UNCERTAINTY_NAME",
94
+ "Agreement",
95
+ "Answer",
96
+ "AnswerStance",
97
+ "AnswerWithCitationsPrompt",
98
+ "Citation",
99
+ "CitedAnswer",
100
+ "CitedAnswerConfig",
101
+ "ConflictStatus",
102
+ "ContradictionAnswerPrompt",
103
+ "ContradictionCheck",
104
+ "ContradictionCheckConfig",
105
+ "ContradictionCriticPrompt",
106
+ "Generator",
107
+ "RefineOnUncertainty",
108
+ "RefineOnUncertaintyConfig",
109
+ "RefinementStop",
110
+ "RefinementTrace",
111
+ "Settings",
112
+ "WhenNoEvidence",
113
+ "refinement_stop",
114
+ "register",
115
+ ]
@@ -0,0 +1,274 @@
1
+ """`cited-answer` — the one `Generator` this task ships. `Stage[Passages, Answer]`.
2
+
3
+ Task **2.9**, `docs/build-ledger.md`: "an answer carries citations a reader can follow
4
+ back to a passage — not to a document, and not to a paraphrase." `weft_generate.payload.
5
+ Answer._citations_resolve` already makes the *shape* of that unconstructable to violate;
6
+ this module is what builds an `Answer` in the first place, and what keeps the marker a
7
+ model writes and the passage `Answer.used` records honest with each other.
8
+
9
+ **One plugin, two behaviours, configuration — `01` requirement 6's second clause instead
10
+ of a second generator name.** `when_no_evidence` decides what happens when `Passages` is
11
+ empty: `REFUSE` answers `Answer(stance=NOT_IN_CORPUS)` and calls no model at all — the
12
+ line `.phase2-design.md` §3 states outright, "`cited-answer` is the stage that turns
13
+ empty evidence into `Answer(stance=NOT_IN_CORPUS)`" — and `ANSWER_FROM_MEMORY` asks the
14
+ same prompt with no passages shown, which is what `no-retrieval`'s own pipeline sets.
15
+
16
+ **Citations are extracted from the completion, never asked for as a second structured
17
+ field.** `docs/04-donor-inventory.md`'s corrected `CitationManager` row records the
18
+ donor's second responsibility as "extract which sources were actually cited" through
19
+ four ordered, language-specific regexes over freeform text. This build does not need
20
+ them: `weft_retrieve.payload.Passages` already assigns every offered passage an exact,
21
+ known label before a model ever sees one, so "which sources were cited" is answered by a
22
+ literal substring search for `[label]` per offered passage — no language heuristic, and
23
+ no way for the search to disagree with what was actually shown, because the label
24
+ searched for and the label shown are the same string. This also settles a question the
25
+ donor's approach could not: a model is asked for prose, not for a second list of markers
26
+ that could name a passage never offered or drop one it clearly used — the two ways
27
+ `weft_retrieve.rerank`'s judgement-set check refuses a reranker's reply, in a shape this
28
+ plugin cannot have because it never asks for that second list.
29
+
30
+ **Page numbers are `weft_generate.page.page_for`'s, not this module's.** Every citation
31
+ this plugin builds carries whatever that function resolves for the cited node — `None`
32
+ when nothing in the pipeline attached the two facts it needs. See that module for why
33
+ `cited_answer.py` imports neither `weft_chunk` nor `weft_pdf` to get there.
34
+
35
+ **Task 2.31's own obligation, closed here — and it turned out to reach further than a
36
+ `Citation` field.** `.phase2-findings.md` §11: "a hit on any of them resolves through
37
+ lineage to the one parent chunk... which is what makes task 2.9's citations able to follow
38
+ back to a passage rather than to a paraphrase of one." The first attempt patched
39
+ `Citation.node_id` alone and `Answer._citations_resolve` refused it outright: that
40
+ validator requires a citation's `node_id` to be the id of the very passage `used` records
41
+ under the marker naming it, by construction, so a `Citation` cannot point at a different
42
+ node than the `Passage` it is paired with — the renumbering bug it exists to make
43
+ unconstructable is exactly the shape a citation-only patch would have produced. So the
44
+ resolution moves one step earlier: `_resolved_to_citable`, below, substitutes a
45
+ representation's own node for the one it stands in for — via `weft_generate.
46
+ representation.citable_nodes`, the same duck-typed, no-new-dependency read `page_for`
47
+ already sets a precedent for — on `offered` itself, before it becomes `_offer`'s prompt
48
+ text, before it becomes `Answer.used`, and before a `Citation` is built from it. One
49
+ substitution, three consumers agreeing, rather than three places that could disagree.
50
+ This also closes the corollary gap for free: a model asked to answer from a retrieved
51
+ question's own wording would have been shown that wording as "evidence"; now it reads the
52
+ real passage the representation stands in for, the same as everything else it is offered.
53
+
54
+ **What this does not do.** Two representations of the *same* parent occupying two offered
55
+ slots still occupy two — this substitutes identity, node for node, and applies no score
56
+ policy across slots. Collapsing that duplication before it reaches this stage at all is
57
+ `10` §1.2's `collapse-to-parent` row (ledger task 2.33), which owns a `Ranking`-level
58
+ merge policy this plugin's own 1:1 substitution neither needs nor should guess at.
59
+ """
60
+
61
+ from collections.abc import Iterable
62
+ from enum import StrEnum
63
+ from typing import ClassVar
64
+
65
+ from pydantic import BaseModel, ConfigDict, Field
66
+
67
+ from weft_generate.page import page_for
68
+ from weft_generate.payload import Answer, AnswerStance, Citation
69
+ from weft_generate.prompts import ANSWER_WITH_CITATIONS_NAME, AnswerWithCitationsRequest
70
+ from weft_generate.representation import citable_nodes
71
+ from weft_kernel.context import Context
72
+ from weft_kernel.payload import Node, Outcome, Produced, SourceId
73
+ from weft_llm.contract import LLM
74
+ from weft_prompts.contract import Prompt
75
+ from weft_retrieve.contract import StageLookup
76
+ from weft_retrieve.payload import Passage, Passages
77
+ from weft_store.contract import NodeStore, Scored
78
+
79
+ #: The name this generator is registered and selectable under — see `weft_generate.register`.
80
+ NAME = "cited-answer"
81
+
82
+
83
+ class WhenNoEvidence(StrEnum):
84
+ """What `cited-answer` does when `Passages.passages` is empty. `Enum`, project rule."""
85
+
86
+ #: `Answer(stance=NOT_IN_CORPUS)`, no model call — the default, and what "the corpus
87
+ #: does not contain it" means when nothing was ever retrieved to check.
88
+ REFUSE = "refuse"
89
+ #: Ask the model anyway, with no passages shown — what `no-retrieval`'s own pipeline
90
+ #: sets, because the null-retrieval branch is a deliberate choice to answer from
91
+ #: parametric memory (Roberts, Raffel & Shazeer 2020), not a retrieval failure.
92
+ ANSWER_FROM_MEMORY = "answer_from_memory"
93
+
94
+
95
+ class CitedAnswerConfig(BaseModel):
96
+ """`CitedAnswer`'s `with:` config. Every field has a default, per this pack's own rule."""
97
+
98
+ model_config = ConfigDict(frozen=True, extra="forbid")
99
+
100
+ prompt: str = Field(default=ANSWER_WITH_CITATIONS_NAME, min_length=1)
101
+ role: str = Field(default="generate", min_length=1)
102
+ max_passages: int = Field(default=8, ge=1)
103
+ #: Presents the offered evidence grouped under the retriever that found it, rather
104
+ #: than as one flat numbered list — useful once a fan-out or hybrid pipeline feeds
105
+ #: this stage passages `Passage.retrieved_by` disagrees about.
106
+ group_by_retriever: bool = False
107
+ when_no_evidence: WhenNoEvidence = WhenNoEvidence.REFUSE
108
+
109
+
110
+ class CitedAnswer:
111
+ """Turns packed, labelled evidence into a cited answer. Satisfies `contract.Generator`
112
+ structurally.
113
+
114
+ `cost_bound = (0, 1)` — zero when evidence is empty and `when_no_evidence=REFUSE`,
115
+ because `run` returns before resolving anything; one otherwise, the same arithmetic
116
+ `weft_retrieve.rerank.LlmRerank` states for its own single-call ceiling.
117
+ """
118
+
119
+ config_model: ClassVar[type[CitedAnswerConfig]] = CitedAnswerConfig
120
+ cost_bound: ClassVar[tuple[int, int]] = (0, 1)
121
+
122
+ def __init__(self, config: CitedAnswerConfig | None = None) -> None:
123
+ self._config = config if config is not None else CitedAnswerConfig()
124
+
125
+ async def run(self, payload: Passages, ctx: Context) -> Outcome[Answer]:
126
+ """Ask under `origin` alone, never a derived query — `QuerySet.origin`'s own rule,
127
+ applied at the last stage on the query path rather than only the first.
128
+ """
129
+ if not payload.passages:
130
+ if self._config.when_no_evidence is WhenNoEvidence.REFUSE:
131
+ return Produced(
132
+ value=Answer(
133
+ origin=payload.origin,
134
+ text="",
135
+ citations=(),
136
+ used=(),
137
+ stance=AnswerStance.NOT_IN_CORPUS,
138
+ answered_by=NAME,
139
+ )
140
+ )
141
+ offered: tuple[Passage, ...] = ()
142
+ else:
143
+ offered = await _resolved_to_citable(
144
+ payload.passages[: self._config.max_passages], ctx=ctx
145
+ )
146
+
147
+ llm = ctx.require(LLM)
148
+ lookup = ctx.require(StageLookup)
149
+ prompt = await lookup.build_capability(Prompt, self._config.prompt)
150
+ request = AnswerWithCitationsRequest(
151
+ question=payload.origin.text,
152
+ passages=_offer(offered, group_by_retriever=self._config.group_by_retriever),
153
+ )
154
+ rendered = await prompt.render(request, ctx)
155
+ if not isinstance(rendered, Produced):
156
+ # A prompt with nothing to ask is relayed exactly as it answered — the same
157
+ # rule `weft_prompts.cascade.execute` and `weft_retrieve.rerank` both take.
158
+ return rendered
159
+
160
+ completion = await llm.complete(rendered.value, role=self._config.role, ctx=ctx)
161
+ if not isinstance(completion, Produced):
162
+ return completion
163
+ text = completion.value.text
164
+
165
+ citations = await _citations_for(offered, text=text, ctx=ctx)
166
+ return Produced(
167
+ value=Answer(
168
+ origin=payload.origin,
169
+ text=text,
170
+ citations=citations,
171
+ used=offered,
172
+ stance=AnswerStance.ANSWERED,
173
+ answered_by=NAME,
174
+ )
175
+ )
176
+
177
+
178
+ def _offer(passages: tuple[Passage, ...], *, group_by_retriever: bool) -> str:
179
+ """The offered evidence as one string, numbered by each passage's own `label`.
180
+
181
+ Never a re-derived index — see `weft_generate.prompts`' module docstring for why that
182
+ is the one thing this function must not do.
183
+ """
184
+ if not passages:
185
+ return ""
186
+ if not group_by_retriever:
187
+ return "\n\n".join(f"[{passage.label}] {passage.node.content}" for passage in passages)
188
+ groups: dict[str, list[Passage]] = {}
189
+ for passage in passages:
190
+ groups.setdefault(passage.retrieved_by, []).append(passage)
191
+ blocks = [
192
+ f"From {retriever}:\n"
193
+ + "\n\n".join(f"[{passage.label}] {passage.node.content}" for passage in group)
194
+ for retriever, group in sorted(groups.items())
195
+ ]
196
+ return "\n\n".join(blocks)
197
+
198
+
199
+ async def _resolved_to_citable(
200
+ offered: tuple[Passage, ...], *, ctx: Context
201
+ ) -> tuple[Passage, ...]:
202
+ """`offered`, with every representation's own node substituted for the one it stands in
203
+ for — see the module docstring for why this runs once, here, rather than inside
204
+ `_citations_for` alone. `rank`, `retrieved_by` and `label` are untouched; only which
205
+ node the passage carries changes, via a fresh `Scored` around the resolved node and the
206
+ passage's own original `score`.
207
+ """
208
+ if not offered:
209
+ return offered
210
+ store = ctx.require(NodeStore)
211
+ resolved = await citable_nodes((passage.node for passage in offered), store=store)
212
+ return tuple(
213
+ passage
214
+ if resolved[passage.node.id] is passage.node
215
+ else Passage(
216
+ scored=Scored(value=resolved[passage.node.id], score=passage.score),
217
+ rank=passage.rank,
218
+ retrieved_by=passage.retrieved_by,
219
+ label=passage.label,
220
+ )
221
+ for passage in offered
222
+ )
223
+
224
+
225
+ async def _citations_for(
226
+ offered: tuple[Passage, ...], *, text: str, ctx: Context
227
+ ) -> tuple[Citation, ...]:
228
+ """One `Citation` per offered passage whose own bracketed label appears in `text`.
229
+
230
+ A literal substring search, not a regex over prose: the label searched for is the
231
+ exact string shown to the model, so there is nothing for a language-specific pattern
232
+ to get right or wrong that this search does not already get right by construction.
233
+ `offered` has already been resolved to citable nodes by `run`, so `passage.node` here
234
+ is always the node a `Citation` should name.
235
+ """
236
+ cited = [
237
+ (passage, _source_id(passage.node)) for passage in offered if f"[{passage.label}]" in text
238
+ ]
239
+ store = ctx.require(NodeStore)
240
+ uris = await _uris_for((source_id for _, source_id in cited), store=store)
241
+ return tuple(
242
+ Citation(
243
+ marker=passage.label,
244
+ node_id=passage.node.id,
245
+ source_id=source_id,
246
+ uri=uris.get(source_id, "") if source_id is not None else "",
247
+ page=page_for(passage.node),
248
+ )
249
+ for passage, source_id in cited
250
+ )
251
+
252
+
253
+ async def _uris_for(
254
+ source_ids: Iterable[SourceId | None], *, store: NodeStore
255
+ ) -> dict[SourceId, str]:
256
+ """Every distinct known source's `uri`, looked up once each rather than once per citation."""
257
+ wanted = {source_id for source_id in source_ids if source_id is not None}
258
+ uris: dict[SourceId, str] = {}
259
+ for source_id in wanted:
260
+ record = await store.get_source(source_id)
261
+ uris[source_id] = record.uri if record is not None else ""
262
+ return uris
263
+
264
+
265
+ def _source_id(node: Node) -> SourceId | None:
266
+ """`node`'s one source, or `None` when it has none or more than one to choose from.
267
+
268
+ Zero is `Node.synthetic`'s legitimate root case — `Citation.source_id`'s own docstring.
269
+ More than one is a summary node (`Node.combine`): naming one of several documents as
270
+ *the* source would be a citation that misattributes a claim to a document it might not
271
+ actually rest on, which is worse than a citation that says only "this is a summary".
272
+ """
273
+ sources = node.lineage.sources
274
+ return next(iter(sources)) if len(sources) == 1 else None
@@ -0,0 +1,58 @@
1
+ """The `Generator` contract — published here, never by the kernel.
2
+
3
+ Task **2.4**. One `Stage` subtype, closing the query path: `Stage[Passages, Answer]`,
4
+ domain types on both sides, and the last seam `weft_kernel.runner._check_composition`
5
+ checks before a run produces something a reader sees.
6
+
7
+ **Why this is a separate distribution from `weft-retrieve`.** G3 settled that discovery is
8
+ *eager* — installed means `register()` runs on every registry-touching command — so a
9
+ retrieval-only deployment (rerank-and-return enterprise search) must be able to *not
10
+ install* a generator, its prompts and its citation machinery rather than merely not name
11
+ them. `Answer` is also the only Phase 2 type an embedding host application consumes, and
12
+ a host that wants it should not thereby acquire every retrieval technique this project
13
+ ships.
14
+
15
+ `Passages` and `Query` come from `weft-retrieve`, which this pack depends on: dependencies
16
+ flow one way along the pipeline and never back. See `weft_generate.payload`'s module
17
+ docstring for why `Answer` is published *here* even though a retrieval-shaped technique
18
+ wants it.
19
+
20
+ The emptiness rule, the `applies_to` note and the `publishes_property_vocabulary`
21
+ declining argument in `weft_retrieve.contract`'s module docstring all apply verbatim to
22
+ this contract; they are stated there once rather than copied.
23
+ """
24
+
25
+ from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable
26
+
27
+ from weft_generate.payload import Answer
28
+ from weft_kernel.context import Context
29
+ from weft_kernel.payload import Outcome
30
+ from weft_kernel.runner import Stage
31
+ from weft_retrieve.payload import Passages
32
+
33
+ #: Fitness function 6's subject for this contract — see the module docstring.
34
+ GENERATE_CONTRACT_VERSION = "1.0.0"
35
+
36
+
37
+ @runtime_checkable
38
+ class Generator(Stage[Passages, Answer], Protocol):
39
+ """Turns packed, labelled evidence into an answer that cites it.
40
+
41
+ Taking `Passages` rather than a `Ranking` is what makes task 2.9 mechanical: the
42
+ citation labels are already assigned and final by the time a generator sees them, so
43
+ `Answer`'s own validator can refuse a marker that resolves to nothing. A generator
44
+ that decides the evidence does not answer the question returns
45
+ `Answer(stance=NOT_IN_CORPUS)` — `Produced`, with an honest claim inside it — never
46
+ `NothingToProduce`, which would stop the pipeline and leave the caller with no answer
47
+ at all where `09` §4's V2 requires one.
48
+ """
49
+
50
+ if TYPE_CHECKING:
51
+ #: Declared only for a type checker, assigned for real after the class body, so it
52
+ #: never joins `__protocol_attrs__`.
53
+ version: ClassVar[str]
54
+
55
+ async def run(self, payload: Passages, ctx: Context) -> Outcome[Answer]: ...
56
+
57
+
58
+ Generator.version = GENERATE_CONTRACT_VERSION