forkreduce 0.1.0__py3-none-any.whl

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.
forkreduce/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """forkreduce: one frozen prefix, many queries, then a semantic reduce.
2
+
3
+ The framework is built on pydantic-ai. Pass any ``Model``. Nothing in this
4
+ package imports a workflow engine; the optional Temporal adapter lives in
5
+ ``forkreduce.contrib.temporal``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from forkreduce.checks import CheckReport, CitationCheck, CitationRepair
11
+ from forkreduce.context import ContextEstimateWarning, ContextWarning
12
+ from forkreduce.coverage import AcceptUncovered, Provenance, ReduceReport
13
+ from forkreduce.documents import END_MARKER, Block, Document
14
+ from forkreduce.errors import ForkReduceError, PrefixMismatchWarning, UnregisteredOutputError
15
+ from forkreduce.executor import Executor, LocalExecutor
16
+ from forkreduce.failures import Failure, FailureKind
17
+ from forkreduce.followup import AcceptLeads, GapFill, LeadReport, RoundState, Scope
18
+ from forkreduce.models import openai_compatible_model
19
+ from forkreduce.plan import Plan, RoundRecord, RunResult
20
+ from forkreduce.policy import Accept, Budget, BudgetReport, Drop, Fail, Policy, RequireReason, Retry
21
+ from forkreduce.query import CheckFailure, Query, QueryResult
22
+ from forkreduce.reduce import ReduceItem, ReduceOutcome
23
+ from forkreduce.schemas import DiscardExplanation, DiscardExplanations, Traced
24
+ from forkreduce.session import Session, build
25
+ from forkreduce.stats import UsageSummary
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "END_MARKER",
31
+ "Accept",
32
+ "AcceptLeads",
33
+ "AcceptUncovered",
34
+ "Block",
35
+ "Budget",
36
+ "BudgetReport",
37
+ "CheckFailure",
38
+ "CheckReport",
39
+ "ContextEstimateWarning",
40
+ "ContextWarning",
41
+ "CitationCheck",
42
+ "CitationRepair",
43
+ "DiscardExplanation",
44
+ "DiscardExplanations",
45
+ "Document",
46
+ "Drop",
47
+ "Executor",
48
+ "Fail",
49
+ "Failure",
50
+ "FailureKind",
51
+ "ForkReduceError",
52
+ "GapFill",
53
+ "LeadReport",
54
+ "LocalExecutor",
55
+ "Plan",
56
+ "Policy",
57
+ "PrefixMismatchWarning",
58
+ "Provenance",
59
+ "Query",
60
+ "QueryResult",
61
+ "ReduceItem",
62
+ "ReduceOutcome",
63
+ "ReduceReport",
64
+ "RequireReason",
65
+ "Retry",
66
+ "RoundRecord",
67
+ "RoundState",
68
+ "RunResult",
69
+ "Scope",
70
+ "Session",
71
+ "Traced",
72
+ "UnregisteredOutputError",
73
+ "UsageSummary",
74
+ "build",
75
+ "openai_compatible_model",
76
+ ]
forkreduce/checks.py ADDED
@@ -0,0 +1,274 @@
1
+ """Checks and repairs.
2
+
3
+ A check only reports. A repair builds a follow-up query, by default on the same
4
+ branch, and the framework copies only the allowed fields back onto the original
5
+ object. The model may return a full object; fields outside the allow-list are
6
+ ignored in code, not by asking the model nicely.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Any, Protocol
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from forkreduce.documents import DocumentIndex
19
+ from forkreduce.errors import ForkReduceError
20
+ from forkreduce.query import CheckFailure, Query, QueryResult
21
+ from forkreduce.textutil import normalize_whitespace
22
+
23
+ _PATH_TOKEN = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)|\[(\d+)\]")
24
+
25
+
26
+ class Check(Protocol):
27
+ """Report problems with one successful output. Do not call a model."""
28
+
29
+ def __call__(self, output: Any, *, documents: DocumentIndex) -> list[CheckFailure]:
30
+ """Return a failure for each problem. An empty list means the output passed."""
31
+ ...
32
+
33
+
34
+ class Repair(Protocol):
35
+ """Turn check failures into a follow-up query and a field-limited merge."""
36
+
37
+ max_rounds: int
38
+
39
+ def build_query(
40
+ self,
41
+ result: QueryResult[Any],
42
+ failures: Sequence[CheckFailure],
43
+ *,
44
+ round_index: int,
45
+ ) -> Query[Any]:
46
+ """Build the repair query. Default implementations continue ``result.messages``."""
47
+ ...
48
+
49
+ def apply(
50
+ self,
51
+ original: Any,
52
+ updated: Any,
53
+ failures: Sequence[CheckFailure],
54
+ ) -> Any:
55
+ """Return ``original`` with only the allowed fields taken from ``updated``."""
56
+ ...
57
+
58
+
59
+ @dataclass
60
+ class CitationCheck:
61
+ """Locate each quote inside the block it cites.
62
+
63
+ Matching ignores differences in whitespace. The quote and block field names
64
+ default to ``quote`` and ``block_id``. Every nested model that has both
65
+ fields is checked.
66
+ """
67
+
68
+ quote_field: str = "quote"
69
+ block_field: str = "block_id"
70
+
71
+ def __call__(self, output: Any, *, documents: DocumentIndex) -> list[CheckFailure]:
72
+ failures: list[CheckFailure] = []
73
+ for path, node in _iter_citations(output, self.quote_field, self.block_field):
74
+ quote = getattr(node, self.quote_field)
75
+ block_id = getattr(node, self.block_field)
76
+ quote_text = quote if isinstance(quote, str) else ""
77
+ block_text_id = block_id if isinstance(block_id, str) else ""
78
+ if not quote_text.strip():
79
+ failures.append(
80
+ CheckFailure(
81
+ code="empty_quote",
82
+ message=f"empty quote at {path}",
83
+ path=path,
84
+ block_id=block_text_id or None,
85
+ quote=quote_text,
86
+ )
87
+ )
88
+ continue
89
+ if not block_text_id:
90
+ failures.append(
91
+ CheckFailure(
92
+ code="empty_block_id",
93
+ message=f"empty block_id at {path}",
94
+ path=path,
95
+ quote=quote_text,
96
+ )
97
+ )
98
+ continue
99
+ source = documents.block_text(block_text_id)
100
+ if source is None:
101
+ failures.append(
102
+ CheckFailure(
103
+ code="unknown_block",
104
+ message=f"block {block_text_id!r} is not in the frozen documents",
105
+ path=path,
106
+ block_id=block_text_id,
107
+ quote=quote_text,
108
+ )
109
+ )
110
+ continue
111
+ if normalize_whitespace(quote_text) not in normalize_whitespace(source):
112
+ failures.append(
113
+ CheckFailure(
114
+ code="quote_not_found",
115
+ message=f"quote at {path} was not found in block {block_text_id}",
116
+ path=path,
117
+ block_id=block_text_id,
118
+ quote=quote_text,
119
+ )
120
+ )
121
+ return failures
122
+
123
+
124
+ @dataclass
125
+ class CitationRepair:
126
+ """Repair citations by continuing the branch, then copying only allowed fields.
127
+
128
+ ``max_rounds`` defaults to 2. The repair query asks for the same output type
129
+ the original query used, which is already on the frozen tool set. After the
130
+ model returns, :meth:`apply` copies ``allowed_fields`` and nothing else.
131
+ """
132
+
133
+ max_rounds: int = 2
134
+ allowed_fields: tuple[str, ...] = ("quote", "block_id")
135
+
136
+ def __post_init__(self) -> None:
137
+ if self.max_rounds < 0:
138
+ raise ValueError("max_rounds must be >= 0")
139
+ if not self.allowed_fields:
140
+ raise ValueError("allowed_fields must not be empty")
141
+
142
+ def build_query(
143
+ self,
144
+ result: QueryResult[Any],
145
+ failures: Sequence[CheckFailure],
146
+ *,
147
+ round_index: int,
148
+ ) -> Query[Any]:
149
+ if result.output is None:
150
+ raise ForkReduceError("cannot repair a query that has no output")
151
+ output_type = type(result.output)
152
+ lines = [
153
+ "Some citations could not be located in the frozen documents.",
154
+ "Return the same output type.",
155
+ "You may change only these fields: " + ", ".join(self.allowed_fields) + ".",
156
+ "Keep every other field identical to your previous answer.",
157
+ "",
158
+ "Failures:",
159
+ ]
160
+ for failure in failures:
161
+ lines.append(
162
+ f"- path={failure.path} code={failure.code} "
163
+ f"block_id={failure.block_id or ''} quote={failure.quote or ''}"
164
+ )
165
+ return Query(
166
+ tail="\n".join(lines),
167
+ output=output_type,
168
+ id=f"{result.query_id}:repair:{round_index}",
169
+ history=list(result.messages),
170
+ meta={"repair_of": result.query_id, "repair_round": round_index},
171
+ round=result.round,
172
+ )
173
+
174
+ def apply(
175
+ self,
176
+ original: Any,
177
+ updated: Any,
178
+ failures: Sequence[CheckFailure],
179
+ ) -> Any:
180
+ if not isinstance(original, BaseModel) or not isinstance(updated, BaseModel):
181
+ raise ForkReduceError("citation repair requires pydantic models")
182
+ if type(original) is not type(updated):
183
+ raise ForkReduceError(f"repair returned {type(updated).__name__}, expected {type(original).__name__}")
184
+ clone = original.model_copy(deep=True)
185
+ for failure in failures:
186
+ source = _resolve(updated, failure.path)
187
+ target = _resolve(clone, failure.path)
188
+ if not isinstance(source, BaseModel) or not isinstance(target, BaseModel):
189
+ continue
190
+ updates: dict[str, Any] = {}
191
+ for name in self.allowed_fields:
192
+ if name in type(target).model_fields and name in type(source).model_fields:
193
+ updates[name] = getattr(source, name)
194
+ if not updates:
195
+ continue
196
+ replaced = target.model_copy(update=updates)
197
+ clone = replaced if failure.path in {"", "$"} else _replace_at(clone, failure.path, replaced)
198
+ return clone
199
+
200
+
201
+ @dataclass
202
+ class CheckReport:
203
+ """What a check/repair pass did with one query. Kept even when the item is dropped."""
204
+
205
+ query_id: str
206
+ round: int
207
+ failures: list[CheckFailure]
208
+ repair_rounds: int
209
+ action: str
210
+
211
+
212
+ def _iter_citations(
213
+ value: Any,
214
+ quote_field: str,
215
+ block_field: str,
216
+ prefix: str = "",
217
+ ) -> list[tuple[str, BaseModel]]:
218
+ found: list[tuple[str, BaseModel]] = []
219
+ if isinstance(value, BaseModel):
220
+ fields = type(value).model_fields
221
+ path = prefix or "$"
222
+ if quote_field in fields and block_field in fields:
223
+ found.append((path, value))
224
+ for name in fields:
225
+ child = getattr(value, name)
226
+ child_prefix = name if not prefix else f"{prefix}.{name}"
227
+ found.extend(_iter_citations(child, quote_field, block_field, child_prefix))
228
+ elif isinstance(value, list):
229
+ for index, child in enumerate(value):
230
+ found.extend(_iter_citations(child, quote_field, block_field, f"{prefix}[{index}]"))
231
+ return found
232
+
233
+
234
+ def _resolve(value: Any, path: str) -> Any:
235
+ if path in {"", "$"}:
236
+ return value
237
+ current = value
238
+ for match in _PATH_TOKEN.finditer(path):
239
+ name, index = match.group(1), match.group(2)
240
+ current = getattr(current, name) if name is not None else current[int(index)]
241
+ return current
242
+
243
+
244
+ def _replace_at(root: Any, path: str, replacement: Any) -> Any:
245
+ """Return ``root`` with ``replacement`` stored at ``path``."""
246
+ tokens: list[str | int] = []
247
+ for match in _PATH_TOKEN.finditer(path):
248
+ name, index = match.group(1), match.group(2)
249
+ tokens.append(name if name is not None else int(index))
250
+ if not tokens:
251
+ return replacement
252
+ return _assign_tokens(root, tokens, replacement)
253
+
254
+
255
+ def _assign_tokens(current: Any, tokens: list[str | int], replacement: Any) -> Any:
256
+ head, *rest = tokens
257
+ if not rest:
258
+ if isinstance(head, int):
259
+ if not isinstance(current, list):
260
+ raise ForkReduceError(f"expected a list while applying repair path, got {type(current)}")
261
+ current[head] = replacement
262
+ return current
263
+ if not isinstance(current, BaseModel):
264
+ raise ForkReduceError(f"expected a model while applying repair path, got {type(current)}")
265
+ return current.model_copy(update={head: replacement})
266
+ if isinstance(head, int):
267
+ if not isinstance(current, list):
268
+ raise ForkReduceError(f"expected a list while applying repair path, got {type(current)}")
269
+ current[head] = _assign_tokens(current[head], rest, replacement)
270
+ return current
271
+ if not isinstance(current, BaseModel):
272
+ raise ForkReduceError(f"expected a model while applying repair path, got {type(current)}")
273
+ child = _assign_tokens(getattr(current, head), rest, replacement)
274
+ return current.model_copy(update={head: child})
forkreduce/context.py ADDED
@@ -0,0 +1,157 @@
1
+ """Local context estimate.
2
+
3
+ The estimate is ``ceil(characters / 4)`` over the instructions, the tool schemas,
4
+ the documents or continuation history, and the query tail. It is not a provider
5
+ tokenizer, and it never blocks a call or truncates a prompt. When it exceeds the
6
+ configured window, forkreduce warns and still sends the request. Only a
7
+ provider-reported context-length error becomes a ``context_overflow`` failure.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import warnings
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from pydantic_ai.messages import ModelMessage
20
+
21
+ from forkreduce.policy import Policy
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ContextEstimateWarning(UserWarning):
27
+ """The local token estimate is over the configured context window.
28
+
29
+ The request is still sent. The provider decides whether it actually fits.
30
+ """
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ContextWarning:
35
+ """A local estimate that exceeded the configured window.
36
+
37
+ ``where`` is ``prefix`` at build time and ``request`` for one query.
38
+ ``reserve`` is the headroom held back from the prefix; request warnings
39
+ compare against the full ``limit`` and still record ``reserve``.
40
+ """
41
+
42
+ where: str
43
+ estimated_tokens: int
44
+ limit: int
45
+ reserve: int
46
+ message: str
47
+
48
+
49
+ def estimate_tokens(text: str) -> int:
50
+ """Rough token estimate: one token per four characters, rounded up."""
51
+ if not text:
52
+ return 0
53
+ return (len(text) + 3) // 4
54
+
55
+
56
+ def effective_context_reserve(policy: Policy) -> int:
57
+ """Tokens of headroom used when deciding whether a prefix should warn.
58
+
59
+ ``Policy.context_reserve is None`` selects
60
+ ``min(context_limit, max(4096, context_limit // 4))`` when a limit is set,
61
+ and 0 when it is not. An explicit integer, including 0, is used as given.
62
+ """
63
+ if policy.context_reserve is not None:
64
+ return policy.context_reserve
65
+ limit = policy.context_limit
66
+ if limit is None:
67
+ return 0
68
+ return min(limit, max(4096, limit // 4))
69
+
70
+
71
+ def prefix_text(instructions: str, rendered_documents: str, tool_specs: Sequence[dict[str, Any]]) -> str:
72
+ """Text standing in for the frozen prefix, including tool schemas."""
73
+ tools = json.dumps(list(tool_specs), sort_keys=True, default=str)
74
+ return f"{instructions}\n{rendered_documents}\n{tools}"
75
+
76
+
77
+ def history_text(messages: Sequence[ModelMessage]) -> str:
78
+ """Flatten a branch transcript into the characters that count toward the window."""
79
+ chunks: list[str] = []
80
+ for message in messages:
81
+ for part in getattr(message, "parts", []):
82
+ content = getattr(part, "content", None)
83
+ if isinstance(content, str):
84
+ chunks.append(content)
85
+ elif content is not None:
86
+ chunks.append(json.dumps(content, sort_keys=True, default=str))
87
+ return "\n".join(chunks)
88
+
89
+
90
+ def request_text(
91
+ *,
92
+ instructions: str,
93
+ rendered_documents: str,
94
+ tool_specs: Sequence[dict[str, Any]],
95
+ tail: str,
96
+ history: Sequence[ModelMessage] | None,
97
+ ) -> str:
98
+ """Characters for one model call.
99
+
100
+ Instructions and tool schemas are sent on every call even when they are not
101
+ in the message history. Continuation history already contains the documents,
102
+ so those are not added again.
103
+ """
104
+ tools = json.dumps(list(tool_specs), sort_keys=True, default=str)
105
+ body = history_text(history) if history else rendered_documents
106
+ return f"{instructions}\n{tools}\n{body}\n{tail}"
107
+
108
+
109
+ def prefix_context_warning(policy: Policy, estimated_tokens: int) -> ContextWarning | None:
110
+ """Warn when the frozen prefix exceeds ``context_limit - reserve``.
111
+
112
+ ``context_limit is None`` skips the local warning. Equality is inside the window.
113
+ """
114
+ limit = policy.context_limit
115
+ if limit is None:
116
+ return None
117
+ reserve = effective_context_reserve(policy)
118
+ usable = limit - reserve
119
+ if estimated_tokens <= usable:
120
+ return None
121
+ return ContextWarning(
122
+ where="prefix",
123
+ estimated_tokens=estimated_tokens,
124
+ limit=limit,
125
+ reserve=reserve,
126
+ message=(
127
+ f"context estimate: estimated {estimated_tokens} prefix tokens exceeds usable limit "
128
+ f"{usable} (context limit {limit} minus reserve {reserve})"
129
+ ),
130
+ )
131
+
132
+
133
+ def request_context_warning(policy: Policy, estimated_tokens: int) -> ContextWarning | None:
134
+ """Warn when one full request estimate exceeds ``context_limit``.
135
+
136
+ The reserve is not subtracted here: the tail is already part of the estimate.
137
+ It is still recorded on the warning. ``context_limit is None`` skips the warning.
138
+ """
139
+ limit = policy.context_limit
140
+ if limit is None or estimated_tokens <= limit:
141
+ return None
142
+ reserve = effective_context_reserve(policy)
143
+ return ContextWarning(
144
+ where="request",
145
+ estimated_tokens=estimated_tokens,
146
+ limit=limit,
147
+ reserve=reserve,
148
+ message=(
149
+ f"context estimate: estimated {estimated_tokens} tokens exceeds context limit {limit} (reserve {reserve})"
150
+ ),
151
+ )
152
+
153
+
154
+ def emit_context_warning(warning: ContextWarning, *, stacklevel: int = 2) -> None:
155
+ """Emit ``warning`` through ``warnings`` and the ``forkreduce.context`` logger."""
156
+ warnings.warn(warning.message, ContextEstimateWarning, stacklevel=stacklevel)
157
+ logger.warning(warning.message)
@@ -0,0 +1,5 @@
1
+ """Optional integrations.
2
+
3
+ Import ``forkreduce.contrib.temporal`` only when the ``temporal`` extra is installed.
4
+ The core package never imports it.
5
+ """