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 +76 -0
- forkreduce/checks.py +274 -0
- forkreduce/context.py +157 -0
- forkreduce/contrib/__init__.py +5 -0
- forkreduce/contrib/temporal.py +349 -0
- forkreduce/coverage.py +187 -0
- forkreduce/documents.py +173 -0
- forkreduce/errors.py +39 -0
- forkreduce/executor.py +200 -0
- forkreduce/failures.py +239 -0
- forkreduce/followup.py +215 -0
- forkreduce/models.py +48 -0
- forkreduce/plan.py +355 -0
- forkreduce/policy.py +147 -0
- forkreduce/prefix.py +90 -0
- forkreduce/py.typed +0 -0
- forkreduce/query.py +77 -0
- forkreduce/reduce.py +625 -0
- forkreduce/schemas.py +30 -0
- forkreduce/session.py +447 -0
- forkreduce/stats.py +92 -0
- forkreduce/textutil.py +21 -0
- forkreduce-0.1.0.dist-info/METADATA +631 -0
- forkreduce-0.1.0.dist-info/RECORD +26 -0
- forkreduce-0.1.0.dist-info/WHEEL +4 -0
- forkreduce-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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))
|