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.
@@ -0,0 +1,349 @@
1
+ """Temporal adapter.
2
+
3
+ Each query is one activity. The idempotency key is ``prefix_hash + query_id``,
4
+ exposed as :func:`activity_id`. The activity performs a single model attempt and
5
+ raises :class:`temporalio.exceptions.ApplicationError` with ``non_retryable``
6
+ set from the failure class, so Temporal's retry policy is the bound.
7
+
8
+ Build the session inside the worker and :func:`register_session` it. The
9
+ workflow payload carries the prefix hash and the query tail, not the model.
10
+
11
+ Large transcripts should go through a :class:`MessageStore`. Without one, the
12
+ activity inlines the messages and sets ``messages_json``; that is convenient
13
+ for tests and a bad idea for real histories.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from datetime import timedelta
22
+ from typing import Any, Protocol
23
+
24
+ from temporalio import activity, workflow
25
+ from temporalio.common import RetryPolicy
26
+ from temporalio.exceptions import ApplicationError
27
+
28
+ with workflow.unsafe.imports_passed_through():
29
+ from pydantic import BaseModel
30
+ from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter
31
+
32
+ from forkreduce.errors import ForkReduceError
33
+ from forkreduce.failures import Failure
34
+ from forkreduce.policy import Policy
35
+ from forkreduce.query import Query
36
+ from forkreduce.session import Session
37
+
38
+
39
+ class MessageStore(Protocol):
40
+ """Store branch transcripts outside the activity result."""
41
+
42
+ async def put(self, key: str, payload: bytes) -> str:
43
+ """Store ``payload`` and return a reference the worker can resolve."""
44
+ ...
45
+
46
+ async def get(self, ref: str) -> bytes:
47
+ """Load the payload stored under ``ref``."""
48
+ ...
49
+
50
+
51
+ class InMemoryMessageStore:
52
+ """Process-local store. One worker only; not a shared production store."""
53
+
54
+ def __init__(self) -> None:
55
+ self._data: dict[str, bytes] = {}
56
+ self._seq = 0
57
+
58
+ async def put(self, key: str, payload: bytes) -> str:
59
+ self._seq += 1
60
+ ref = f"{key}:{self._seq}"
61
+ self._data[ref] = payload
62
+ return ref
63
+
64
+ async def get(self, ref: str) -> bytes:
65
+ try:
66
+ return self._data[ref]
67
+ except KeyError as exc:
68
+ raise KeyError(f"unknown message ref {ref!r}") from exc
69
+
70
+
71
+ _SESSIONS: dict[str, Session] = {}
72
+ _FINGERPRINTS: dict[str, str] = {}
73
+ _STORE: MessageStore | None = None
74
+
75
+
76
+ def execution_fingerprint(session: Session) -> str:
77
+ """Identity of how a session runs, beyond the frozen prompt prefix.
78
+
79
+ ``prefix_hash`` ignores the model and the retry/budget policy. Two sessions
80
+ can share a hash and still send calls to different places.
81
+ """
82
+ model = session.model
83
+ policy = session.policy
84
+ budget = policy.budget
85
+ payload = {
86
+ "model_type": type(model).__qualname__,
87
+ "model_name": getattr(model, "model_name", None),
88
+ "system": getattr(model, "system", None),
89
+ "output_mode": session.output_mode,
90
+ "warmup": session.warmup,
91
+ "concurrency": policy.concurrency,
92
+ "retries": policy.retries,
93
+ "retry_backoff": policy.retry_backoff,
94
+ "max_backoff": policy.max_backoff,
95
+ "repair_failure": policy.repair_failure,
96
+ "context_limit": policy.context_limit,
97
+ "context_reserve": policy.context_reserve,
98
+ "budget": None
99
+ if budget is None
100
+ else {
101
+ "max_requests": budget.max_requests,
102
+ "max_input_tokens": budget.max_input_tokens,
103
+ "max_output_tokens": budget.max_output_tokens,
104
+ "max_total_tokens": budget.max_total_tokens,
105
+ },
106
+ }
107
+ return json.dumps(payload, sort_keys=True, default=str)
108
+
109
+
110
+ def register_session(session: Session) -> str:
111
+ """Make ``session`` available to activities in this process. Returns its prefix hash.
112
+
113
+ A second registration with the same prefix and the same execution fingerprint
114
+ replaces the session. A different model or policy for that prefix raises
115
+ :class:`forkreduce.errors.ForkReduceError` instead of silently taking over.
116
+ """
117
+ key = session.prefix_hash
118
+ fingerprint = execution_fingerprint(session)
119
+ current = _FINGERPRINTS.get(key)
120
+ if current is not None and current != fingerprint:
121
+ raise ForkReduceError(
122
+ "A session with this prefix_hash is already registered with a different model or policy. "
123
+ "Call unregister_session before registering a different execution config."
124
+ )
125
+ _SESSIONS[key] = session
126
+ _FINGERPRINTS[key] = fingerprint
127
+ return key
128
+
129
+
130
+ def unregister_session(prefix_hash: str) -> None:
131
+ """Drop a process-local registration. Unknown hashes are ignored."""
132
+ _SESSIONS.pop(prefix_hash, None)
133
+ _FINGERPRINTS.pop(prefix_hash, None)
134
+
135
+
136
+ def set_message_store(store: MessageStore | None) -> None:
137
+ """Install the store activities use for transcripts. ``None`` inlines messages."""
138
+ global _STORE
139
+ _STORE = store
140
+
141
+
142
+ def activity_id(prefix_hash: str, query_id: str) -> str:
143
+ """Idempotency key for one query on one frozen prefix."""
144
+ return f"forkreduce:{prefix_hash}:{query_id}"
145
+
146
+
147
+ def to_application_error(failure: Failure) -> ApplicationError:
148
+ """Map a classified failure onto a Temporal application error.
149
+
150
+ Validation and transport failures stay retryable. Permanent failures and
151
+ budget exhaustion are ``non_retryable``.
152
+ """
153
+ return ApplicationError(
154
+ failure.message,
155
+ failure.kind.value,
156
+ type=failure.kind.value,
157
+ non_retryable=not failure.retryable,
158
+ )
159
+
160
+
161
+ def temporal_retry_policy(policy: Policy) -> RetryPolicy:
162
+ """A Temporal retry policy with the same attempt budget as ``Policy.retries``."""
163
+ initial = policy.retry_backoff if policy.retry_backoff > 0 else 0.001
164
+ maximum = policy.max_backoff if policy.max_backoff > 0 else initial
165
+ return RetryPolicy(
166
+ initial_interval=timedelta(seconds=initial),
167
+ backoff_coefficient=2.0,
168
+ maximum_interval=timedelta(seconds=maximum),
169
+ maximum_attempts=policy.retries + 1,
170
+ )
171
+
172
+
173
+ @dataclass
174
+ class QueryActivityInput:
175
+ """Serializable query. The worker looks up the session by ``prefix_hash``.
176
+
177
+ ``max_attempts`` is ``Policy.retries + 1``. :class:`MapQueriesWorkflow` reads
178
+ it instead of assuming three attempts. ``history_json`` continues a branch
179
+ when no :class:`MessageStore` is configured; it is the previous activity's
180
+ ``messages_json``.
181
+ """
182
+
183
+ prefix_hash: str
184
+ query_id: str
185
+ tail: str
186
+ output_name: str
187
+ round: int = 0
188
+ history_ref: str | None = None
189
+ history_json: str | None = None
190
+ meta: dict[str, Any] = field(default_factory=dict)
191
+ max_attempts: int = 1
192
+ retry_backoff_seconds: float = 0.0
193
+ max_backoff_seconds: float = 0.0
194
+
195
+
196
+ def query_activity_input(
197
+ session: Session,
198
+ *,
199
+ query_id: str,
200
+ tail: str,
201
+ output_name: str,
202
+ round: int = 0,
203
+ history_ref: str | None = None,
204
+ history_json: str | None = None,
205
+ meta: dict[str, Any] | None = None,
206
+ ) -> QueryActivityInput:
207
+ """Build an activity payload that carries this session's retry policy."""
208
+ policy = session.policy
209
+ return QueryActivityInput(
210
+ prefix_hash=session.prefix_hash,
211
+ query_id=query_id,
212
+ tail=tail,
213
+ output_name=output_name,
214
+ round=round,
215
+ history_ref=history_ref,
216
+ history_json=history_json,
217
+ meta=dict(meta or {}),
218
+ max_attempts=policy.retries + 1,
219
+ retry_backoff_seconds=policy.retry_backoff,
220
+ max_backoff_seconds=policy.max_backoff,
221
+ )
222
+
223
+
224
+ def activity_retry_policy(payload: QueryActivityInput) -> RetryPolicy:
225
+ """Retry policy for one activity, taken from its payload."""
226
+ initial = payload.retry_backoff_seconds if payload.retry_backoff_seconds > 0 else 0.001
227
+ maximum = payload.max_backoff_seconds if payload.max_backoff_seconds > 0 else initial
228
+ return RetryPolicy(
229
+ initial_interval=timedelta(seconds=initial),
230
+ backoff_coefficient=2.0,
231
+ maximum_interval=timedelta(seconds=maximum),
232
+ maximum_attempts=max(payload.max_attempts, 1),
233
+ )
234
+
235
+
236
+ @dataclass
237
+ class QueryActivityOutput:
238
+ """Activity result. Messages are a ref when a store is configured."""
239
+
240
+ query_id: str
241
+ prefix_hash: str
242
+ output_json: str | None
243
+ output_type: str | None
244
+ cache_hit_tokens: int
245
+ attempts: int
246
+ messages_ref: str | None = None
247
+ messages_json: str | None = None
248
+
249
+
250
+ async def execute_query_activity(payload: QueryActivityInput) -> QueryActivityOutput:
251
+ """Run one query attempt. Temporal activities call this through the defn wrapper."""
252
+ session = _SESSIONS.get(payload.prefix_hash)
253
+ if session is None:
254
+ raise ApplicationError(
255
+ f"No session registered for prefix {payload.prefix_hash}",
256
+ "permanent",
257
+ type="permanent",
258
+ non_retryable=True,
259
+ )
260
+ output_type = session.output_types.get(payload.output_name)
261
+ if output_type is None:
262
+ raise ApplicationError(
263
+ f"Output {payload.output_name!r} is not registered on this session",
264
+ "permanent",
265
+ type="permanent",
266
+ non_retryable=True,
267
+ )
268
+ history: list[ModelMessage] | None = None
269
+ if payload.history_ref is not None and payload.history_json is not None:
270
+ raise ApplicationError(
271
+ "Set only one of history_ref and history_json",
272
+ "permanent",
273
+ type="permanent",
274
+ non_retryable=True,
275
+ )
276
+ if payload.history_json is not None:
277
+ history = list(ModelMessagesTypeAdapter.validate_json(payload.history_json))
278
+ elif payload.history_ref is not None:
279
+ if _STORE is None:
280
+ raise ApplicationError(
281
+ "history_ref was set but this worker has no message store",
282
+ "permanent",
283
+ type="permanent",
284
+ non_retryable=True,
285
+ )
286
+ history = list(ModelMessagesTypeAdapter.validate_json(await _STORE.get(payload.history_ref)))
287
+ query: Query[Any] = Query(
288
+ tail=payload.tail,
289
+ output=output_type,
290
+ id=payload.query_id,
291
+ history=history,
292
+ meta=dict(payload.meta),
293
+ round=payload.round,
294
+ )
295
+ session.prepare_query(query)
296
+ result = await session.run_once(query)
297
+ if result.failure is not None:
298
+ raise to_application_error(result.failure)
299
+ dumped = ModelMessagesTypeAdapter.dump_json(result.messages)
300
+ messages_ref: str | None = None
301
+ messages_json: str | None = None
302
+ if _STORE is not None:
303
+ messages_ref = await _STORE.put(f"{payload.prefix_hash}:{payload.query_id}", dumped)
304
+ else:
305
+ messages_json = dumped.decode()
306
+ output_json: str | None = None
307
+ output_name: str | None = None
308
+ if isinstance(result.output, BaseModel):
309
+ output_json = result.output.model_dump_json()
310
+ output_name = type(result.output).__name__
311
+ return QueryActivityOutput(
312
+ query_id=result.query_id,
313
+ prefix_hash=result.prefix_hash,
314
+ output_json=output_json,
315
+ output_type=output_name,
316
+ cache_hit_tokens=result.cache_hit_tokens,
317
+ attempts=result.attempts,
318
+ messages_ref=messages_ref,
319
+ messages_json=messages_json,
320
+ )
321
+
322
+
323
+ @activity.defn(name="forkreduce.run_query")
324
+ async def run_query_activity(payload: QueryActivityInput) -> QueryActivityOutput:
325
+ """Temporal activity entrypoint. One attempt; raise to retry."""
326
+ return await execute_query_activity(payload)
327
+
328
+
329
+ @workflow.defn(name="forkreduce.map_queries")
330
+ class MapQueriesWorkflow:
331
+ """Fan out query activities. Deterministic: it only schedules activities.
332
+
333
+ Coverage math and other pure decisions can run in the workflow after the
334
+ activities return. Model calls, including reduce and gap-fill, stay in
335
+ activities. Register the session on the worker before starting this workflow.
336
+ """
337
+
338
+ @workflow.run
339
+ async def run(self, inputs: list[QueryActivityInput]) -> list[QueryActivityOutput]:
340
+ async def one(payload: QueryActivityInput) -> QueryActivityOutput:
341
+ return await workflow.execute_activity(
342
+ run_query_activity,
343
+ payload,
344
+ start_to_close_timeout=timedelta(minutes=5),
345
+ activity_id=activity_id(payload.prefix_hash, payload.query_id),
346
+ retry_policy=activity_retry_policy(payload),
347
+ )
348
+
349
+ return list(await asyncio.gather(*(one(payload) for payload in inputs)))
forkreduce/coverage.py ADDED
@@ -0,0 +1,187 @@
1
+ """Coverage reports and provenance.
2
+
3
+ The framework computes which input ids were cited. A caller policy decides what
4
+ to do about the gaps. The default policy accepts the merge and leaves every
5
+ uncovered id on the report, so nothing disappears quietly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from forkreduce.policy import Accept, Action
17
+
18
+
19
+ @dataclass
20
+ class ReduceReport:
21
+ """Coverage of one reduce against a set of input ids.
22
+
23
+ ``covered`` and ``uncovered`` partition the input ids. ``invalid_refs`` are
24
+ source ids that were not in the input. ``untraced`` are output paths whose
25
+ ``source_ids`` were missing or empty. ``reasons`` is filled when a policy
26
+ returns :class:`forkreduce.policy.RequireReason`.
27
+ """
28
+
29
+ covered: list[str]
30
+ uncovered: list[str]
31
+ invalid_refs: list[str]
32
+ untraced: list[str] = field(default_factory=list)
33
+ reasons: list[Any] = field(default_factory=list)
34
+ kind: str = "reduce"
35
+
36
+ @property
37
+ def ok(self) -> bool:
38
+ return not self.uncovered and not self.invalid_refs and not self.untraced
39
+
40
+
41
+ @dataclass
42
+ class TracedNode:
43
+ """One object in a reduce output that carries ``source_ids``."""
44
+
45
+ path: str
46
+ source_ids: list[str]
47
+ value: Any
48
+
49
+
50
+ class ProvenanceCheck(Protocol):
51
+ """Check that an output cites real input ids and does not invent entries."""
52
+
53
+ def evaluate(self, output: Any, input_ids: Sequence[str]) -> ReduceReport:
54
+ """Return coverage of ``output`` against ``input_ids``."""
55
+ ...
56
+
57
+
58
+ class Provenance:
59
+ """Walk ``source_ids`` and compare them to the ids that were actually given.
60
+
61
+ Any pydantic model with a ``source_ids: list`` field counts, including
62
+ subclasses of :class:`forkreduce.schemas.Traced`. An output with no such
63
+ field is reported as untraced rather than treated as a full cover.
64
+ """
65
+
66
+ def evaluate(self, output: Any, input_ids: Sequence[str]) -> ReduceReport:
67
+ allowed = set(input_ids)
68
+ nodes = iter_traced(output)
69
+ if not nodes:
70
+ return ReduceReport(
71
+ covered=[],
72
+ uncovered=list(input_ids),
73
+ invalid_refs=[],
74
+ untraced=["$"],
75
+ )
76
+ cited: list[str] = []
77
+ invalid: list[str] = []
78
+ untraced: list[str] = []
79
+ for node in nodes:
80
+ if not node.source_ids:
81
+ untraced.append(node.path)
82
+ continue
83
+ for source_id in node.source_ids:
84
+ if source_id in allowed:
85
+ cited.append(source_id)
86
+ else:
87
+ invalid.append(source_id)
88
+ cited_set = set(cited)
89
+ covered = [item_id for item_id in input_ids if item_id in cited_set]
90
+ uncovered = [item_id for item_id in input_ids if item_id not in cited_set]
91
+ return ReduceReport(
92
+ covered=covered,
93
+ uncovered=uncovered,
94
+ invalid_refs=_unique(invalid),
95
+ untraced=untraced,
96
+ )
97
+
98
+
99
+ class AcceptUncovered:
100
+ """Default coverage policy: accept, and keep uncovered ids on the report."""
101
+
102
+ def decide(self, report: ReduceReport) -> Action:
103
+ return Accept()
104
+
105
+
106
+ class CoveragePolicy(Protocol):
107
+ """Business decision for a coverage report. The framework executes the action."""
108
+
109
+ def decide(self, report: ReduceReport) -> Action:
110
+ """Return the action to take for ``report``."""
111
+ ...
112
+
113
+
114
+ def iter_traced(value: Any, prefix: str = "") -> list[TracedNode]:
115
+ """Find models that declare ``source_ids``."""
116
+ found: list[TracedNode] = []
117
+ if isinstance(value, BaseModel) and _has_source_ids(value):
118
+ raw = getattr(value, "source_ids") # noqa: B009 - field is optional on BaseModel
119
+ source_ids = [str(item) for item in raw] if isinstance(raw, list) else []
120
+ found.append(TracedNode(prefix or "$", source_ids, value))
121
+ if isinstance(value, BaseModel):
122
+ for name in type(value).model_fields:
123
+ if name == "source_ids":
124
+ continue
125
+ child = getattr(value, name)
126
+ child_prefix = name if not prefix else f"{prefix}.{name}"
127
+ found.extend(iter_traced(child, child_prefix))
128
+ elif isinstance(value, list):
129
+ for index, child in enumerate(value):
130
+ found.extend(iter_traced(child, f"{prefix}[{index}]"))
131
+ return found
132
+
133
+
134
+ def expand_id(source_id: str, alias: dict[str, list[str]], seen: set[str] | None = None) -> list[str]:
135
+ """Expand a batch id back to the original ids it stands for."""
136
+ if source_id not in alias:
137
+ return [source_id]
138
+ seen = set() if seen is None else set(seen)
139
+ if source_id in seen:
140
+ return []
141
+ seen.add(source_id)
142
+ expanded: list[str] = []
143
+ for child in alias[source_id]:
144
+ expanded.extend(expand_id(child, alias, seen))
145
+ return expanded
146
+
147
+
148
+ def rewrite_source_ids(value: Any, alias: dict[str, list[str]]) -> Any:
149
+ """Replace batch ids in every ``source_ids`` list with the original ids."""
150
+ if not alias:
151
+ return value
152
+ if isinstance(value, BaseModel):
153
+ updates: dict[str, Any] = {}
154
+ for name in type(value).model_fields:
155
+ current = getattr(value, name)
156
+ if name == "source_ids" and isinstance(current, list):
157
+ expanded: list[str] = []
158
+ for source_id in current:
159
+ expanded.extend(expand_id(str(source_id), alias))
160
+ deduped = _unique(expanded)
161
+ if deduped != [str(item) for item in current]:
162
+ updates[name] = deduped
163
+ continue
164
+ rewritten = rewrite_source_ids(current, alias)
165
+ if rewritten is not current:
166
+ updates[name] = rewritten
167
+ if updates:
168
+ return value.model_copy(update=updates)
169
+ return value
170
+ if isinstance(value, list):
171
+ rewritten_list = [rewrite_source_ids(item, alias) for item in value]
172
+ if any(new is not old for new, old in zip(rewritten_list, value, strict=False)):
173
+ return rewritten_list
174
+ return value
175
+ return value
176
+
177
+
178
+ def _has_source_ids(value: BaseModel) -> bool:
179
+ field = type(value).model_fields.get("source_ids")
180
+ if field is None:
181
+ return False
182
+ raw = getattr(value, "source_ids") # noqa: B009 - field is optional on BaseModel
183
+ return isinstance(raw, list)
184
+
185
+
186
+ def _unique(items: list[str]) -> list[str]:
187
+ return list(dict.fromkeys(items))