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/followup.py ADDED
@@ -0,0 +1,215 @@
1
+ """The hook between rounds, and the built-in gap-fill strategy.
2
+
3
+ ``followup`` receives the round's merged output and query results. It may call
4
+ the model (another query on the same prefix) or stay a pure function. An empty
5
+ list ends the run, as does reaching ``max_rounds``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable, Sequence
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from forkreduce.coverage import ReduceReport
17
+ from forkreduce.failures import Failure, FailureKind
18
+ from forkreduce.policy import Accept, Action, Drop, Fail
19
+ from forkreduce.query import Query, QueryResult
20
+
21
+ FollowupFn = Callable[["RoundState", Any], Awaitable[list[Query[Any]]] | list[Query[Any]]]
22
+
23
+
24
+ @dataclass
25
+ class RoundState:
26
+ """What a followup hook can see after one map/check/reduce round.
27
+
28
+ ``side_results`` collects model calls the hook makes (gap-fill queries, for
29
+ example) so their usage is not dropped. ``failures`` is the same idea for
30
+ errors the hook observed.
31
+ """
32
+
33
+ round: int
34
+ results: list[QueryResult[Any]]
35
+ outputs: list[Any]
36
+ merged: Any
37
+ report: ReduceReport | None
38
+ failures: list[Failure] = field(default_factory=list)
39
+ side_results: list[QueryResult[Any]] = field(default_factory=list)
40
+
41
+ def add_result(self, result: QueryResult[Any]) -> None:
42
+ self.side_results.append(result)
43
+ if result.failure is not None:
44
+ self.failures.append(result.failure)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Scope:
49
+ """One slice of the document a gap-fill query is responsible for."""
50
+
51
+ name: str
52
+ responsibility: str
53
+
54
+
55
+ @dataclass
56
+ class LeadReport:
57
+ """Positions a gap-fill pass already covered, and the new leads it found."""
58
+
59
+ kind: str
60
+ new_positions: list[str]
61
+ duplicate_positions: list[str]
62
+ leads: list[Any]
63
+
64
+
65
+ class LeadPolicy(Protocol):
66
+ """Decide which new leads become the next round's queries."""
67
+
68
+ def decide(self, report: LeadReport) -> Action:
69
+ """Return an action for ``report``."""
70
+ ...
71
+
72
+
73
+ class AcceptLeads:
74
+ """Default lead policy: keep every lead whose position is not already covered."""
75
+
76
+ def decide(self, report: LeadReport) -> Action:
77
+ return Accept()
78
+
79
+
80
+ @dataclass
81
+ class GapFill:
82
+ """One gap-fill query per scope, then a query per new lead.
83
+
84
+ Leads are deduped by position (``block_id`` by default) against the merged
85
+ output and against each other. The gap-fill query itself runs inside the
86
+ hook, on the same session, so it shares the prefix cache. Its
87
+ :class:`QueryResult` is attached to the round state.
88
+
89
+ ``lead_output`` and ``item_output`` must both be registered on the session.
90
+ """
91
+
92
+ lead_output: type[BaseModel]
93
+ item_output: type[BaseModel]
94
+ scopes: Sequence[Scope] | None = None
95
+ instructions: str = (
96
+ "Look for requirements in your scope that the merged list does not already cover. "
97
+ "Return leads only for those gaps."
98
+ )
99
+ policy: LeadPolicy | None = None
100
+ lead_items: Callable[[Any], Sequence[Any]] | None = None
101
+ lead_position: Callable[[Any], str] | None = None
102
+ covered_positions: Callable[[Any], Sequence[str]] | None = None
103
+ query_for_lead: Callable[[Any, RoundState], Query[Any]] | None = None
104
+
105
+ async def __call__(self, state: RoundState, session: Any) -> list[Query[Any]]:
106
+ scopes = list(self.scopes) if self.scopes else [Scope("all", "the whole document")]
107
+ covered = set(self._covered(state.merged))
108
+ fresh: list[Any] = []
109
+ duplicates: list[str] = []
110
+ for scope in scopes:
111
+ query = Query(
112
+ tail=self._gap_tail(state.merged, scope),
113
+ output=self.lead_output,
114
+ id=f"gapfill-r{state.round}-{scope.name}",
115
+ round=state.round,
116
+ )
117
+ result = await session.ask(query)
118
+ state.add_result(result)
119
+ if result.failure is not None or result.output is None:
120
+ continue
121
+ for lead in self._leads(result.output):
122
+ position = self._position(lead)
123
+ if not position or position in covered:
124
+ duplicates.append(position or "")
125
+ continue
126
+ covered.add(position)
127
+ fresh.append(lead)
128
+ report = LeadReport(
129
+ kind="leads",
130
+ new_positions=[self._position(lead) for lead in fresh],
131
+ duplicate_positions=[item for item in duplicates if item],
132
+ leads=list(fresh),
133
+ )
134
+ action = (self.policy or AcceptLeads()).decide(report)
135
+ if isinstance(action, Fail):
136
+ state.failures.append(
137
+ Failure(
138
+ kind=FailureKind.PERMANENT,
139
+ message=action.reason,
140
+ retryable=False,
141
+ code="leads_rejected",
142
+ )
143
+ )
144
+ return []
145
+ if isinstance(action, Drop):
146
+ banned = set(action.items)
147
+ fresh = [lead for lead in fresh if self._position(lead) not in banned]
148
+ elif not isinstance(action, Accept):
149
+ state.failures.append(
150
+ Failure(
151
+ kind=FailureKind.PERMANENT,
152
+ message=f"lead policy returned {type(action).__name__}, which gap-fill cannot run",
153
+ retryable=False,
154
+ code="bad_action",
155
+ )
156
+ )
157
+ return []
158
+ return [self._to_query(lead, state) for lead in fresh]
159
+
160
+ def _leads(self, output: Any) -> Sequence[Any]:
161
+ if self.lead_items is not None:
162
+ return list(self.lead_items(output))
163
+ for name in ("leads", "items"):
164
+ value = getattr(output, name, None)
165
+ if isinstance(value, list):
166
+ return value
167
+ return []
168
+
169
+ def _position(self, lead: Any) -> str:
170
+ if self.lead_position is not None:
171
+ return self.lead_position(lead)
172
+ block_id = getattr(lead, "block_id", None)
173
+ if isinstance(block_id, str):
174
+ return block_id
175
+ return ""
176
+
177
+ def _covered(self, merged: Any) -> list[str]:
178
+ if self.covered_positions is not None:
179
+ return list(self.covered_positions(merged))
180
+ return sorted(_collect_block_ids(merged))
181
+
182
+ def _to_query(self, lead: Any, state: RoundState) -> Query[Any]:
183
+ if self.query_for_lead is not None:
184
+ return self.query_for_lead(lead, state)
185
+ question = getattr(lead, "question", None) or getattr(lead, "text", None) or str(lead)
186
+ position = self._position(lead) or "lead"
187
+ return Query(
188
+ id=f"gap-r{state.round}-{position}",
189
+ tail=str(question),
190
+ output=self.item_output,
191
+ meta={"block_id": position, "gap": True},
192
+ round=state.round + 1,
193
+ )
194
+
195
+ def _gap_tail(self, merged: Any, scope: Scope) -> str:
196
+ if isinstance(merged, BaseModel):
197
+ rendered = merged.model_dump_json()
198
+ else:
199
+ rendered = "null" if merged is None else str(merged)
200
+ return f"Scope {scope.name}: {scope.responsibility}\n{self.instructions.strip()}\n\nAlready merged:\n{rendered}"
201
+
202
+
203
+ def _collect_block_ids(value: Any) -> set[str]:
204
+ found: set[str] = set()
205
+ if isinstance(value, BaseModel):
206
+ if "block_id" in type(value).model_fields:
207
+ block_id = getattr(value, "block_id") # noqa: B009 - field is optional on BaseModel
208
+ if isinstance(block_id, str) and block_id:
209
+ found.add(block_id)
210
+ for name in type(value).model_fields:
211
+ found |= _collect_block_ids(getattr(value, name))
212
+ elif isinstance(value, list):
213
+ for item in value:
214
+ found |= _collect_block_ids(item)
215
+ return found
forkreduce/models.py ADDED
@@ -0,0 +1,48 @@
1
+ """OpenAI-compatible models.
2
+
3
+ forkreduce itself accepts any pydantic-ai ``Model``. This helper is only for the
4
+ common case of an OpenAI-style chat endpoint. Retries stay in forkreduce:
5
+ the OpenAI client is constructed with ``max_retries=0``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from pydantic_ai.models.openai import OpenAIChatModel
13
+
14
+
15
+ def openai_compatible_model(
16
+ model_name: str | None = None,
17
+ *,
18
+ api_key: str | None = None,
19
+ base_url: str | None = None,
20
+ ) -> OpenAIChatModel:
21
+ """Build an ``OpenAIChatModel`` from arguments or environment variables.
22
+
23
+ Environment variables, when the matching argument is omitted:
24
+
25
+ * ``OPENAI_MODEL`` (or ``FORKREDUCE_MODEL``) for the model name
26
+ * ``OPENAI_API_KEY``
27
+ * ``OPENAI_BASE_URL``
28
+ """
29
+ from openai import AsyncOpenAI
30
+ from pydantic_ai.providers.openai import OpenAIProvider
31
+
32
+ from forkreduce.errors import ForkReduceError
33
+
34
+ name = model_name or os.environ.get("OPENAI_MODEL") or os.environ.get("FORKREDUCE_MODEL")
35
+ if not name:
36
+ raise ForkReduceError("Pass model_name or set OPENAI_MODEL")
37
+ key = api_key if api_key is not None else os.environ.get("OPENAI_API_KEY")
38
+ url = base_url if base_url is not None else os.environ.get("OPENAI_BASE_URL")
39
+ # A local OpenAI-compatible server often has no key. The client still wants
40
+ # a non-empty string when base_url is set.
41
+ resolved_key = key or ("api-key-not-set" if url else None)
42
+ if url and resolved_key is not None:
43
+ client = AsyncOpenAI(api_key=resolved_key, base_url=url, max_retries=0)
44
+ elif resolved_key is not None:
45
+ client = AsyncOpenAI(api_key=resolved_key, max_retries=0)
46
+ else:
47
+ client = AsyncOpenAI(max_retries=0)
48
+ return OpenAIChatModel(name, provider=OpenAIProvider(openai_client=client))
forkreduce/plan.py ADDED
@@ -0,0 +1,355 @@
1
+ """Chained map / check / filter / reduce / followup plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable, Sequence
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import Any
9
+
10
+ from pydantic_ai.usage import RunUsage
11
+
12
+ from forkreduce.checks import Check, CheckReport, Repair
13
+ from forkreduce.context import ContextWarning
14
+ from forkreduce.coverage import ReduceReport
15
+ from forkreduce.failures import Failure, FailureKind
16
+ from forkreduce.followup import FollowupFn, RoundState
17
+ from forkreduce.query import Query, QueryResult
18
+ from forkreduce.reduce import (
19
+ ReduceItem,
20
+ annotate_round,
21
+ flatten_merged,
22
+ reduce_items,
23
+ )
24
+ from forkreduce.session import Session
25
+ from forkreduce.stats import UsageSummary, add_usage, summarize
26
+
27
+ FilterFn = Callable[[Any], bool]
28
+
29
+
30
+ @dataclass
31
+ class RoundRecord:
32
+ """One map/check/reduce round."""
33
+
34
+ round: int
35
+ results: list[QueryResult[Any]]
36
+ outputs: list[Any]
37
+ merged: Any
38
+ report: ReduceReport | None
39
+ failures: list[Failure]
40
+ check_reports: list[CheckReport] = field(default_factory=list)
41
+
42
+
43
+ @dataclass
44
+ class RunResult:
45
+ """The full plan: merged output, per-round detail, usage, and every failure."""
46
+
47
+ merged: Any
48
+ rounds: list[RoundRecord]
49
+ results: list[QueryResult[Any]]
50
+ usage: UsageSummary
51
+ failures: list[Failure]
52
+ reports: list[Any]
53
+ context_warning: ContextWarning | None = None
54
+
55
+ @property
56
+ def cache_hit_rate(self) -> float:
57
+ return self.usage.cache_hit_rate
58
+
59
+ @property
60
+ def context_warnings(self) -> list[ContextWarning]:
61
+ """Prefix warning from the session, then each query warning, in run order."""
62
+ found: list[ContextWarning] = []
63
+ if self.context_warning is not None:
64
+ found.append(self.context_warning)
65
+ for result in self.results:
66
+ if result.context_warning is not None:
67
+ found.append(result.context_warning)
68
+ return found
69
+
70
+
71
+ class Plan:
72
+ """A lazy pipeline. Nothing runs until :meth:`run`."""
73
+
74
+ def __init__(self, session: Session, queries: list[Query[Any]]) -> None:
75
+ self._session = session
76
+ self._queries = queries
77
+ self._check: Check | None = None
78
+ self._repair: Repair | None = None
79
+ self._failure_mode = session.policy.repair_failure
80
+ self._filters: list[FilterFn] = []
81
+ self._reduce_output: type[Any] | None = None
82
+ self._reduce_instructions = ""
83
+ self._reduce_check: Any = None
84
+ self._reduce_max_batch = 200
85
+ self._reduce_coverage: Any = None
86
+ self._reduce_max_rounds = 3
87
+ self._followup: FollowupFn | None = None
88
+ self._max_rounds = 1
89
+
90
+ def check(
91
+ self,
92
+ check: Check,
93
+ *,
94
+ repair: Repair | None = None,
95
+ on_failure: str | None = None,
96
+ ) -> Plan:
97
+ """Validate each output. ``repair`` may continue the branch to fix it.
98
+
99
+ ``on_failure`` overrides :attr:`Policy.repair_failure` for items that
100
+ are still failing when repair stops: ``needs_review``, ``drop``, or
101
+ ``fail``.
102
+ """
103
+ if on_failure is not None and on_failure not in {"needs_review", "drop", "fail"}:
104
+ raise ValueError("on_failure must be 'needs_review', 'drop', or 'fail'")
105
+ self._check = check
106
+ self._repair = repair
107
+ if on_failure is not None:
108
+ self._failure_mode = on_failure
109
+ return self
110
+
111
+ def filter(self, fn: FilterFn) -> Plan:
112
+ """Keep outputs for which ``fn`` returns true. Others stay on the run, not in reduce."""
113
+ self._filters.append(fn)
114
+ return self
115
+
116
+ def reduce(
117
+ self,
118
+ *,
119
+ output: type[Any],
120
+ instructions: str = "",
121
+ check: Any = None,
122
+ max_batch: int = 200,
123
+ coverage: Any = None,
124
+ max_rounds: int = 3,
125
+ ) -> Plan:
126
+ """Semantically merge the kept outputs. ``output`` must be registered."""
127
+ self._reduce_output = output
128
+ self._reduce_instructions = instructions
129
+ self._reduce_check = check
130
+ self._reduce_max_batch = max_batch
131
+ self._reduce_coverage = coverage
132
+ self._reduce_max_rounds = max_rounds
133
+ return self
134
+
135
+ def followup(self, fn: FollowupFn, *, max_rounds: int = 2) -> Plan:
136
+ """Call ``fn`` between rounds. ``max_rounds`` counts map rounds, including the first."""
137
+ if max_rounds < 1:
138
+ raise ValueError("max_rounds must be >= 1")
139
+ self._followup = fn
140
+ self._max_rounds = max_rounds
141
+ return self
142
+
143
+ async def run(self) -> RunResult:
144
+ """Execute the pipeline."""
145
+ rounds: list[RoundRecord] = []
146
+ all_results: list[QueryResult[Any]] = []
147
+ all_failures: list[Failure] = []
148
+ reports: list[Any] = []
149
+ merged: Any = None
150
+ carried: list[ReduceItem] = []
151
+ queries = list(self._queries)
152
+ fatal = False
153
+
154
+ for round_index in range(1, self._max_rounds + 1):
155
+ if not queries:
156
+ break
157
+ for query in queries:
158
+ query.round = round_index
159
+ results = await self._session.fork(queries)
160
+ checked, check_reports, round_failures, stop = await self._apply_checks(results)
161
+ all_failures.extend(round_failures)
162
+ reports.extend(check_reports)
163
+ kept: list[QueryResult[Any]] = []
164
+ new_items: list[ReduceItem] = []
165
+ for result in checked:
166
+ if round_index > 0 and result.output is not None:
167
+ result.output = annotate_round(result.output, round_index)
168
+ if result.dropped or result.output is None:
169
+ continue
170
+ if result.failure is not None and not result.needs_review:
171
+ continue
172
+ if not self._keep(result.output):
173
+ continue
174
+ kept.append(result)
175
+ new_items.append(ReduceItem(id=result.query_id, value=result.output, round=round_index))
176
+ report: ReduceReport | None = None
177
+ reduce_calls: list[QueryResult[Any]] = []
178
+ reduce_failure: Failure | None = None
179
+ if self._reduce_output is not None and (new_items or carried) and not stop:
180
+ outcome = await reduce_items(
181
+ self._session,
182
+ [*carried, *new_items],
183
+ output=self._reduce_output,
184
+ instructions=self._reduce_instructions,
185
+ check=self._reduce_check,
186
+ max_batch=self._reduce_max_batch,
187
+ coverage=self._reduce_coverage,
188
+ max_rounds=self._reduce_max_rounds,
189
+ round_index=round_index,
190
+ )
191
+ merged = outcome.output
192
+ report = outcome.report
193
+ reports.append(report)
194
+ reduce_calls = list(outcome.calls)
195
+ if outcome.failure is not None:
196
+ all_failures.append(outcome.failure)
197
+ reduce_failure = outcome.failure
198
+ if outcome.fatal:
199
+ fatal = True
200
+ carried = flatten_merged(merged, round_index=round_index)
201
+ round_results = [*checked, *reduce_calls]
202
+ all_results.extend(round_results)
203
+ rounds.append(
204
+ RoundRecord(
205
+ round=round_index,
206
+ results=round_results,
207
+ outputs=[item.value for item in new_items],
208
+ merged=merged,
209
+ report=report,
210
+ failures=[*round_failures, *([reduce_failure] if reduce_failure is not None else [])],
211
+ check_reports=check_reports,
212
+ )
213
+ )
214
+ if fatal or stop or self._followup is None or round_index >= self._max_rounds:
215
+ break
216
+ state = RoundState(
217
+ round=round_index,
218
+ results=checked,
219
+ outputs=[item.value for item in new_items],
220
+ merged=merged,
221
+ report=report,
222
+ )
223
+ nxt = self._followup(state, self._session)
224
+ if inspect.isawaitable(nxt):
225
+ nxt = await nxt
226
+ all_results.extend(state.side_results)
227
+ all_failures.extend(state.failures)
228
+ # Side results were not part of this round record yet.
229
+ if rounds:
230
+ rounds[-1].results.extend(state.side_results)
231
+ rounds[-1].failures.extend(state.failures)
232
+ queries = list(nxt)
233
+ if not queries:
234
+ break
235
+
236
+ return RunResult(
237
+ merged=merged,
238
+ rounds=rounds,
239
+ results=all_results,
240
+ usage=summarize(all_results),
241
+ failures=all_failures,
242
+ reports=reports,
243
+ context_warning=self._session.context_warning,
244
+ )
245
+
246
+ async def _apply_checks(
247
+ self,
248
+ results: list[QueryResult[Any]],
249
+ ) -> tuple[list[QueryResult[Any]], list[CheckReport], list[Failure], bool]:
250
+ if self._check is None:
251
+ return results, [], [result.failure for result in results if result.failure], False
252
+ updated: list[QueryResult[Any]] = []
253
+ reports: list[CheckReport] = []
254
+ failures: list[Failure] = []
255
+ stop = False
256
+ for result in results:
257
+ if result.failure is not None or result.output is None:
258
+ if result.failure is not None:
259
+ failures.append(result.failure)
260
+ updated.append(result)
261
+ continue
262
+ parts = [result]
263
+ output = result.output
264
+ messages = result.messages
265
+ problems = self._check(output, documents=self._session.documents)
266
+ repair_rounds = 0
267
+ while problems and self._repair is not None and repair_rounds < self._repair.max_rounds:
268
+ repair_rounds += 1
269
+ carrier = replace(result, output=output, messages=messages, query_id=result.query_id)
270
+ repaired = await self._session.ask(
271
+ self._repair.build_query(carrier, problems, round_index=repair_rounds)
272
+ )
273
+ parts.append(repaired)
274
+ if repaired.failure is not None or repaired.output is None:
275
+ if repaired.failure is not None:
276
+ failures.append(repaired.failure)
277
+ break
278
+ try:
279
+ output = self._repair.apply(output, repaired.output, problems)
280
+ except Exception as exc:
281
+ failures.append(
282
+ Failure(
283
+ kind=FailureKind.PERMANENT,
284
+ message=f"repair apply failed: {exc}",
285
+ retryable=False,
286
+ code="repair_apply",
287
+ )
288
+ )
289
+ break
290
+ messages = repaired.messages
291
+ problems = self._check(output, documents=self._session.documents)
292
+ usage, cache, attempts, elapsed = _fold_usage(parts)
293
+ notice = result.context_warning
294
+ if notice is None:
295
+ for part in parts:
296
+ if part.context_warning is not None:
297
+ notice = part.context_warning
298
+ break
299
+ current = replace(
300
+ result,
301
+ output=output,
302
+ messages=messages,
303
+ usage=usage,
304
+ cache_hit_tokens=cache,
305
+ attempts=attempts,
306
+ elapsed_ms=elapsed,
307
+ failure=None,
308
+ check_failures=list(problems),
309
+ context_warning=notice,
310
+ )
311
+ if not problems:
312
+ action = "repaired" if repair_rounds else "ok"
313
+ else:
314
+ action = self._failure_mode
315
+ if self._failure_mode == "needs_review":
316
+ current.needs_review = True
317
+ elif self._failure_mode == "drop":
318
+ current.dropped = True
319
+ else:
320
+ failure = Failure(
321
+ kind=FailureKind.PERMANENT,
322
+ message="; ".join(item.message for item in problems),
323
+ retryable=False,
324
+ code="check_failed",
325
+ )
326
+ current.failure = failure
327
+ failures.append(failure)
328
+ stop = True
329
+ reports.append(
330
+ CheckReport(
331
+ query_id=result.query_id,
332
+ round=result.round,
333
+ failures=list(problems),
334
+ repair_rounds=repair_rounds,
335
+ action=action,
336
+ )
337
+ )
338
+ updated.append(current)
339
+ return updated, reports, failures, stop
340
+
341
+ def _keep(self, output: Any) -> bool:
342
+ return all(fn(output) for fn in self._filters)
343
+
344
+
345
+ def _fold_usage(parts: Sequence[QueryResult[Any]]) -> tuple[RunUsage, int, int, float]:
346
+ usage = RunUsage()
347
+ cache = 0
348
+ attempts = 0
349
+ elapsed = 0.0
350
+ for part in parts:
351
+ usage = add_usage(usage, part.usage)
352
+ cache += part.cache_hit_tokens
353
+ attempts += part.attempts
354
+ elapsed += part.elapsed_ms
355
+ return usage, cache, attempts, elapsed