adag 0.1.1__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.
adag/__init__.py ADDED
@@ -0,0 +1,109 @@
1
+ """adag — content-addressable DAG engine for data pipelines.
2
+
3
+ Core engine:
4
+ Deferred, InputParam, ResolveContext — value abstraction
5
+ PipelineObserver, NullObserver — instrumentation hook on ResolveContext
6
+ LoggingObserver — default observer; logs each step's lifecycle
7
+ RunTreeObserver — renders a run as an indented timing tree
8
+ Executor, LocalExecutor — pluggable step execution on ResolveContext
9
+ AsyncExecutor, LocalAsyncExecutor — the awaitable counterparts (the async resolve path)
10
+ Step — single step base class
11
+ AsyncStep — Step whose body awaits I/O; resolve via Pipeline.aexecute()
12
+ CachedStep — Step with content-addressable caching
13
+ SerializableStep — Step with save/load support
14
+ StepResult — deferred result of a Step
15
+ FileSource, LinesSource, JSONLSource — file leaf Deferreds (text / lines / parsed)
16
+ Config, ItemCache — input/cache types
17
+ Pipeline — DAG executor
18
+ BatchItem, BatchStep — per-item batch processing with ItemCache
19
+
20
+ Backward compatibility:
21
+ Parameter — alias for InputParam
22
+ """
23
+
24
+ __version__ = "0.1.1"
25
+
26
+ from adag.base import CachedStep
27
+ from adag.batch import BatchItem, BatchStep
28
+ from adag.config import Config
29
+ from adag.deferred import (
30
+ AsyncExecutor,
31
+ BranchDispatcher,
32
+ BranchRef,
33
+ Deferred,
34
+ Executor,
35
+ InlineDispatcher,
36
+ InputParam,
37
+ LocalAsyncExecutor,
38
+ LocalExecutor,
39
+ NullObserver,
40
+ PipelineObserver,
41
+ Progress,
42
+ ResolveContext,
43
+ current_observer,
44
+ observing,
45
+ )
46
+ from adag.dir_source import DirSource
47
+ from adag.dispatch import BatchDispatcher, BranchExecutionError, BranchPolicy, SubprocessDispatcher
48
+ from adag.fan_out import FanOutBranchError, FanOutStep
49
+ from adag.io import FileSource, JSONLSource, LinesSource
50
+ from adag.item_cache import ItemCache
51
+ from adag.observers import LoggingObserver, RunTreeObserver
52
+ from adag.parameter import Parameter
53
+ from adag.pipeline import CyclicPipelineError, Pipeline, UnboundInputError
54
+ from adag.serializable import SerializableStep
55
+ from adag.sinks import WriteJSONL
56
+ from adag.step import AsyncStep, Step, StepResult, StepTimeout
57
+ from adag.step_mode import StepMode
58
+ from adag.visualization import SVG, SvgOptions, dag, render_pipeline_svg, walk
59
+
60
+ __all__ = [
61
+ "SVG",
62
+ "AsyncExecutor",
63
+ "AsyncStep",
64
+ "BatchDispatcher",
65
+ "BatchItem",
66
+ "BatchStep",
67
+ "BranchDispatcher",
68
+ "BranchExecutionError",
69
+ "BranchPolicy",
70
+ "BranchRef",
71
+ "CachedStep",
72
+ "Config",
73
+ "CyclicPipelineError",
74
+ "Deferred",
75
+ "DirSource",
76
+ "Executor",
77
+ "FanOutBranchError",
78
+ "FanOutStep",
79
+ "FileSource",
80
+ "InlineDispatcher",
81
+ "InputParam",
82
+ "ItemCache",
83
+ "JSONLSource",
84
+ "LinesSource",
85
+ "LocalAsyncExecutor",
86
+ "LocalExecutor",
87
+ "LoggingObserver",
88
+ "NullObserver",
89
+ "Parameter",
90
+ "Pipeline",
91
+ "PipelineObserver",
92
+ "Progress",
93
+ "ResolveContext",
94
+ "RunTreeObserver",
95
+ "SerializableStep",
96
+ "Step",
97
+ "StepMode",
98
+ "StepResult",
99
+ "StepTimeout",
100
+ "SubprocessDispatcher",
101
+ "SvgOptions",
102
+ "UnboundInputError",
103
+ "WriteJSONL",
104
+ "current_observer",
105
+ "dag",
106
+ "observing",
107
+ "render_pipeline_svg",
108
+ "walk",
109
+ ]
adag/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m adag`` to invoke the CLI."""
2
+
3
+ from adag.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
adag/base.py ADDED
@@ -0,0 +1,126 @@
1
+ """CachedStep — a Step with content-addressable caching.
2
+
3
+ CachedStep extends Step[CT] with direct disk caching using a content hash.
4
+ Subclasses are frozen dataclasses that declare the result type via the
5
+ generic parameter::
6
+
7
+ @dataclass(frozen=True)
8
+ class Tokenize(CachedStep[TokenizedText]):
9
+ model_name: str = "my-model"
10
+
11
+ def run(self, text: str) -> TokenizedText:
12
+ ...
13
+
14
+ Unlike step.Step (which caches through the Deferred/Pipeline system),
15
+ CachedStep provides direct load/save/is_cached methods for use in
16
+ BatchStep workflows and other non-Deferred contexts.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+ from typing import Any, ClassVar, TypeVar, get_args, get_origin
23
+
24
+ import structlog
25
+ from pydantic import BaseModel
26
+
27
+ from adag.io import atomic_write_text
28
+ from adag.step import Step
29
+
30
+ logger = structlog.get_logger()
31
+
32
+ CT = TypeVar("CT", bound=BaseModel)
33
+
34
+
35
+ class CachedStep(Step[CT]):
36
+ """A Step with content-addressable disk caching.
37
+
38
+ result_type is auto-detected from CachedStep[CT] via __init_subclass__.
39
+ cache_key can be pinned to skip the computed hash. Unknown dataclass field
40
+ types raise at hash time — call out "did you forget to register a type?"
41
+ bugs loudly rather than silently skipping the field.
42
+ """
43
+
44
+ strict_content_hash: ClassVar[bool] = True
45
+
46
+ def __init_subclass__(cls, **kwargs: Any) -> None:
47
+ super().__init_subclass__(**kwargs)
48
+ for parent in getattr(cls, "__orig_bases__", []):
49
+ origin = get_origin(parent)
50
+ if origin is CachedStep:
51
+ args = get_args(parent)
52
+ if args and isinstance(args[0], type):
53
+ cls.result_type = args[0]
54
+ cls.return_type = args[0]
55
+ return
56
+
57
+ def output_path(self, cache_dir: Path) -> Path | None:
58
+ """Resolved output path: cache_dir/{hash}. None if blocked."""
59
+ h = self.content_hash()
60
+ if h is None:
61
+ return None
62
+ return cache_dir.joinpath(h)
63
+
64
+ def is_cached(self, cache_dir: Path) -> bool:
65
+ """True if .done sentinel exists AND output passes validation."""
66
+ output = self.output_path(cache_dir)
67
+ if output is None or not output.exists():
68
+ return False
69
+ done = output.parent.joinpath(output.name + ".done")
70
+ if not done.exists():
71
+ return False
72
+ return self.validate(output)
73
+
74
+ def is_partial(self, cache_dir: Path) -> bool:
75
+ """True if output exists but .done is missing."""
76
+ output = self.output_path(cache_dir)
77
+ if output is None:
78
+ return False
79
+ done = output.parent.joinpath(output.name + ".done")
80
+ return output.exists() and not done.exists()
81
+
82
+ def save(self, result: CT, cache_dir: Path) -> None:
83
+ """Write result to output_path, then mark the ``.done`` sentinel.
84
+
85
+ Both writes are atomic so a crash between them leaves either no
86
+ output (next run recomputes) or a complete-but-unmarked output
87
+ (``is_partial()`` reports ``True``; ``is_cached()`` returns False
88
+ and recomputes). The framework's StepResult cache path uses the
89
+ same two-step write at ``step.py:_done_path``; mirroring it here
90
+ means a direct-API caller's cache is detectable via
91
+ ``is_cached()`` just like the Deferred path.
92
+ """
93
+ output = self.output_path(cache_dir)
94
+ if output is None:
95
+ raise ValueError(f"Step '{type(self).__name__}' is BLOCKED: inputs missing.")
96
+ atomic_write_text(output, result.model_dump_json(indent=2))
97
+ done = output.parent.joinpath(output.name + ".done")
98
+ atomic_write_text(done, "")
99
+
100
+ def load(self, cache_dir: Path) -> CT:
101
+ """Read result from output_path. Default: Pydantic JSON."""
102
+ output = self.output_path(cache_dir)
103
+ result_type = self.result_model()
104
+ if output is None or not output.exists():
105
+ raise ValueError(f"Output for '{type(self).__name__}' not found.")
106
+ if result_type is None:
107
+ raise ValueError(f"Step '{type(self).__name__}' has no result_type.")
108
+ return result_type.model_validate_json(output.read_text()) # pyright: ignore[reportReturnType]
109
+
110
+ def validate(self, path: Path) -> bool:
111
+ """Validate cached output. Default: parse with result_type."""
112
+ result_type = self.result_model()
113
+ if result_type is None:
114
+ return False
115
+ try:
116
+ content = path.read_text()
117
+ if not content.strip():
118
+ return True
119
+ result_type.model_validate_json(content)
120
+ return True
121
+ except Exception:
122
+ return False
123
+
124
+ def metadata(self, result: CT) -> dict[str, Any]:
125
+ """Override to add extra fields to the manifest record."""
126
+ return {}
adag/batch.py ADDED
@@ -0,0 +1,393 @@
1
+ """BatchStep — a CachedStep that processes items one at a time with per-item caching.
2
+
3
+ For expensive operations (LLM calls, API requests) where:
4
+ - Each item costs money or time
5
+ - A crash at item 1500/2000 should not lose the first 1500
6
+ - Restarting should skip already-processed items
7
+
8
+ The framework handles: partition cached/uncached, save each result
9
+ as it completes, mark .done only when ALL items are done.
10
+
11
+ Subclasses pick ONE of two override patterns:
12
+
13
+ **Pattern A (recommended) — items_data() + framework-generated keys.**
14
+
15
+ The framework derives the per-item cache key from ``(step.kind, repr(data),
16
+ all relevant step inputs)``. Subclasses only declare WHAT to process, never
17
+ HOW to compose keys::
18
+
19
+ @dataclass(frozen=True)
20
+ class GenerateNotes(BatchStep[NotesResult, NoteRecord]):
21
+ catalog: CachedStep[CatalogResult]
22
+ model: str = "claude-sonnet-4-6"
23
+ concurrency: Config[int] = Config(20)
24
+
25
+ def items_data(self, catalog: CatalogResult, model: str) -> list[Any]:
26
+ return list(catalog.codes)
27
+
28
+ def process_item(self, item: BatchItem, catalog: CatalogResult, model: str) -> NoteRecord:
29
+ return call_llm(item.data, model=model)
30
+
31
+ def assemble(self, results: list[NoteRecord], catalog: CatalogResult, model: str) -> NotesResult:
32
+ return NotesResult(notes=results, n_notes=len(results))
33
+
34
+ This eliminates a whole class of cache-poisoning bugs caused by keys that
35
+ don't reflect every input. When a step's params change, the key changes
36
+ automatically; when an upstream's output changes, the key changes too.
37
+
38
+ **Pattern B — items() override for full control.**
39
+
40
+ When you genuinely need to share a cache across slightly different inputs
41
+ (or other unusual key topology), override ``items()`` directly and build
42
+ ``BatchItem.key`` yourself. The default ``items()`` implementation simply
43
+ calls ``items_data()`` and wraps the framework-generated keys.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import hashlib
49
+ import json
50
+ from abc import abstractmethod
51
+ from dataclasses import dataclass
52
+ from typing import Any, ClassVar, Generic, TypeVar, get_args, get_origin
53
+
54
+ import structlog
55
+ from pydantic import BaseModel
56
+
57
+ from adag.base import CachedStep
58
+ from adag.deferred import ResolveContext
59
+ from adag.item_cache import ItemCache
60
+
61
+ logger = structlog.get_logger()
62
+
63
+ T = TypeVar("T", bound=BaseModel) # final result type
64
+ R = TypeVar("R", bound=BaseModel) # per-item result type
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class BatchItem:
69
+ """A single work unit in a BatchStep.
70
+
71
+ key: cache key for this item (determines cache identity)
72
+ data: arbitrary payload passed to process_item()
73
+ """
74
+
75
+ key: str
76
+ data: Any = None
77
+
78
+
79
+ class BatchStep(CachedStep[T], Generic[T, R]):
80
+ """A step that processes items individually with per-item disk caching.
81
+
82
+ Subclasses implement three methods:
83
+
84
+ 1. ``items(**inputs)`` — declare work units and their cache keys
85
+ 2. ``process_item(item, **inputs)`` — handle one item
86
+ 3. ``assemble(results, **inputs)`` — combine all results into final output
87
+
88
+ The framework automatically:
89
+ - Partitions items into cached/uncached
90
+ - Skips already-processed items
91
+ - Saves each result to disk immediately after processing
92
+ - Logs progress (cached/total, processing each item)
93
+ - Only marks .done when ALL items are complete
94
+
95
+ On crash: restart picks up where it left off — only uncached items run.
96
+
97
+ **Per-item cache key derivation.** The framework auto-derives each
98
+ item's cache key from ``sha256(step_signature + inputs_signature +
99
+ item_data)``. The inputs signature includes every kwarg passed to
100
+ :meth:`execute` EXCEPT those listed in :attr:`items_source_keys` —
101
+ those are treated as containers of items rather than shared
102
+ parameters. Subclasses that take a large input list and extract items
103
+ from it should set ``items_source_keys`` to the kwarg name(s) so that
104
+ adding or removing items doesn't invalidate every other item's cache::
105
+
106
+ class LLMStage2Classify(BatchStep[Stage2Result, Stage2Prediction]):
107
+ items_source_keys = ("shortlists",) # the list of inputs
108
+ # `notes` is NOT here, so changes to notes correctly
109
+ # invalidate all per-item caches.
110
+ """
111
+
112
+ item_type: type[BaseModel] # auto-resolved from BatchStep[T, R]
113
+
114
+ # Names of input kwargs that are CONTAINERS of items rather than
115
+ # shared parameters. Excluded from per-item cache key derivation.
116
+ # Default empty: ALL inputs participate in the key (safe but
117
+ # potentially over-invalidating). Subclasses with a large source-list
118
+ # should override.
119
+ items_source_keys: ClassVar[tuple[str, ...]] = ()
120
+
121
+ def __init_subclass__(cls, **kwargs: Any) -> None:
122
+ super().__init_subclass__(**kwargs)
123
+ for parent in getattr(cls, "__orig_bases__", []):
124
+ origin = get_origin(parent)
125
+ if origin is BatchStep:
126
+ args = get_args(parent)
127
+ if len(args) >= 2: # noqa: PLR2004 (BatchStep[T, Item]: the item type is the 2nd arg)
128
+ cls.item_type = args[1]
129
+ return
130
+
131
+ def items_data(self, **inputs: Any) -> list[Any]:
132
+ """Declare the per-item DATA only — framework derives the cache key.
133
+
134
+ Recommended override path. The default ``items()`` (below) calls
135
+ this and wraps each entry in a ``BatchItem`` with an auto-computed
136
+ key that includes the step's content_hash + the item's repr.
137
+
138
+ Returns the list of data objects (any type) that
139
+ ``process_item(item, ...)`` will receive as ``item.data``.
140
+
141
+ Override THIS method when you don't need custom key topology —
142
+ every cache-poisoning bug from manual key composition becomes
143
+ impossible.
144
+ """
145
+ raise NotImplementedError(
146
+ f"{type(self).__name__} must override either `items_data()` (framework derives keys) or `items()` (full control)."
147
+ )
148
+
149
+ def items(self, **inputs: Any) -> list[BatchItem]:
150
+ """Declare the work units. Called once at the start.
151
+
152
+ Default implementation calls ``items_data(**inputs)`` and assigns
153
+ each item an auto-generated cache key derived from
154
+ ``(step.content_hash, **inputs, item_data)``. The inputs signature
155
+ is included so that step kwargs which AFFECT the per-item LLM call
156
+ (e.g. a notes payload passed alongside the report shortlists)
157
+ invalidate item caches when they change — even though they are
158
+ not step dataclass fields.
159
+
160
+ Override this only when you need custom key topology (e.g.
161
+ deliberately sharing a cached result across two slightly-different
162
+ inputs).
163
+ """
164
+ step_sig = self._step_signature()
165
+ inputs_sig = self._inputs_signature(inputs)
166
+ return [
167
+ BatchItem(key=self._auto_item_key(step_sig, datum, inputs_sig), data=datum)
168
+ for datum in self.items_data(**inputs)
169
+ ]
170
+
171
+ def _step_signature(self) -> str:
172
+ """A short hex digest of the step's identity for use in item keys.
173
+
174
+ Falls back to the class kind only if ``content_hash()`` is
175
+ blocked by an unsatisfied upstream (e.g. a missing FileSource).
176
+ Whenever the step's output semantics change, ``content_hash()``
177
+ changes — and so does every item's auto-generated cache key.
178
+ """
179
+ return self.content_hash() or type(self).kind or type(self).__name__
180
+
181
+ def _inputs_signature(self, inputs: dict[str, Any]) -> str:
182
+ """Hash all BaseModel/list/scalar inputs into a single digest.
183
+
184
+ The point of this hash is to catch upstream-result changes (like a
185
+ notes payload, a scoped item list, a config blob) that affect
186
+ what ``process_item`` produces but are not step dataclass fields.
187
+ Without this, a BatchStep's per-item cache would silently return
188
+ stale results whenever an input kwarg changed but step config and
189
+ per-item data did not.
190
+
191
+ Inputs named in :attr:`items_source_keys` are skipped — those are
192
+ containers from which items are extracted, not shared parameters.
193
+ Including them would invalidate every item's cache when the list
194
+ of items changed (e.g. one new report added).
195
+ """
196
+ skip = set(type(self).items_source_keys)
197
+ h = hashlib.sha256()
198
+ for name in sorted(inputs.keys()):
199
+ if name in skip:
200
+ continue
201
+ val = inputs[name]
202
+ h.update(name.encode())
203
+ h.update(b"=")
204
+ if isinstance(val, BaseModel):
205
+ h.update(val.model_dump_json().encode())
206
+ elif isinstance(val, (str, int, float, bool)) or val is None:
207
+ h.update(repr(val).encode())
208
+ elif isinstance(val, (list, tuple)):
209
+ try:
210
+ h.update(json.dumps(val, sort_keys=True, default=str).encode())
211
+ except (TypeError, ValueError):
212
+ # repr() handles any object; pyright over-narrows the
213
+ # generic params here because dict was dict[str, Any].
214
+ h.update(repr(val).encode()) # pyright: ignore[reportUnknownArgumentType]
215
+ else:
216
+ # Unknown type — fall back to repr for stability.
217
+ h.update(repr(val).encode())
218
+ h.update(b"|")
219
+ return h.hexdigest()[:16]
220
+
221
+ def _auto_item_key(self, step_sig: str, item_data: Any, inputs_sig: str = "") -> str:
222
+ """Derive a stable cache key from ``(step_sig, inputs_sig, item_data)``.
223
+
224
+ ``item_data`` can be any object — we serialize it deterministically
225
+ (Pydantic JSON if possible, then a stable repr fallback). The
226
+ digest is truncated to 16 hex chars to match ItemCache.key().
227
+
228
+ ``inputs_sig`` is the digest of all ``**inputs`` kwargs from
229
+ :meth:`execute`. Included so that changing any input invalidates
230
+ per-item caches — see :meth:`_inputs_signature`.
231
+ """
232
+ h = hashlib.sha256()
233
+ h.update(step_sig.encode())
234
+ h.update(b"|")
235
+ h.update(inputs_sig.encode())
236
+ h.update(b"|")
237
+ if isinstance(item_data, BaseModel):
238
+ h.update(item_data.model_dump_json().encode())
239
+ else:
240
+ try:
241
+ h.update(json.dumps(item_data, sort_keys=True, default=str).encode())
242
+ except (TypeError, ValueError):
243
+ h.update(repr(item_data).encode())
244
+ return h.hexdigest()[:16]
245
+
246
+ @abstractmethod
247
+ def process_item(self, item: BatchItem, **inputs: Any) -> R:
248
+ """Process a single item. Default implementation called by process_batch().
249
+
250
+ Override this for simple one-at-a-time processing.
251
+ Receives the BatchItem plus all loaded input kwargs.
252
+ """
253
+ ...
254
+
255
+ def process_batch(self, items: list[BatchItem], cache: ItemCache[R], **inputs: Any) -> None:
256
+ """Process a batch of uncached items, saving each result to cache.
257
+
258
+ Override this to control batching strategy (concurrent LLM calls,
259
+ chunked API requests, etc.). The default processes one at a time.
260
+
261
+ Each result MUST be saved to cache immediately via ``cache.save()``
262
+ so a crash loses at most one in-flight item.
263
+ """
264
+ for i, item in enumerate(items, start=1):
265
+ logger.info(
266
+ "batch.processing",
267
+ step=type(self).__name__,
268
+ item=item.key[:12],
269
+ progress=f"{i}/{len(items)}",
270
+ )
271
+ # WAL: write the `.inflight` sentinel before the call starts.
272
+ # If `process_item` raises or the process is killed, the
273
+ # marker survives so the next resume can surface "you lost
274
+ # work on these items". `cache.save()` clears it on success.
275
+ cache.mark_inflight(item.key)
276
+ result = self.process_item(item, **inputs)
277
+ cache.save(item.key, result)
278
+
279
+ @abstractmethod
280
+ def assemble(self, results: list[R], **inputs: Any) -> T:
281
+ """Combine all per-item results into the final step result.
282
+
283
+ Called after all items (cached + freshly processed) are available.
284
+ Receives the ordered list of results plus all loaded input kwargs.
285
+ """
286
+ ...
287
+
288
+ def item_cache_namespace(self) -> str:
289
+ """Namespace for the ItemCache. Default: class name in snake_case."""
290
+ name = type(self).__name__
291
+ result: list[str] = []
292
+ for i, c in enumerate(name):
293
+ if c.isupper() and i > 0:
294
+ result.append("_")
295
+ result.append(c.lower())
296
+ return "".join(result)
297
+
298
+ def run(self, **inputs: Any) -> T: # pyright: ignore[reportUnusedParameter]
299
+ """Raise: a BatchStep is driven through ``execute()``, never ``run()``."""
300
+ raise RuntimeError("BatchStep uses execute(context, ...); do not call run() directly.")
301
+
302
+ def execute(self, context: ResolveContext, /, **inputs: Any) -> T:
303
+ """Orchestrate: items → partition → process uncached → assemble.
304
+
305
+ Called by StepResult.resolve with the current ResolveContext so the
306
+ per-item cache can live under the pipeline's cache directory.
307
+ Subclasses implement items/process_item/assemble, not this.
308
+ """
309
+ if context.cache_dir is None:
310
+ raise RuntimeError(
311
+ f"{type(self).__name__} requires a Pipeline cache_dir for per-item caching."
312
+ )
313
+ cache_dir = context.cache_dir
314
+
315
+ namespace = self.item_cache_namespace()
316
+ item_schema: Any = self.item_type
317
+ cache: ItemCache[R] = ItemCache(cache_dir, namespace, item_schema)
318
+
319
+ # Step 1: Declare items
320
+ all_items = self.items(**inputs)
321
+ keys = [item.key for item in all_items]
322
+
323
+ logger.info(
324
+ "batch.start",
325
+ step=type(self).__name__,
326
+ total=len(all_items),
327
+ namespace=namespace,
328
+ )
329
+
330
+ # Step 2: Partition cached/uncached
331
+ _cached, uncached_indices = cache.partition(keys)
332
+ n_cached = len(all_items) - len(uncached_indices)
333
+
334
+ if n_cached > 0:
335
+ logger.info(
336
+ "batch.resuming",
337
+ step=type(self).__name__,
338
+ cached=n_cached,
339
+ remaining=len(uncached_indices),
340
+ )
341
+
342
+ # Step 3: Process uncached items via process_batch()
343
+ # The step decides internally how to batch (concurrent, chunked, etc.)
344
+ uncached_items = [all_items[idx] for idx in uncached_indices]
345
+ if uncached_items:
346
+ self.process_batch(uncached_items, cache, **inputs)
347
+
348
+ # Step 4: Collect all results in order.
349
+ # Tolerate per-item failures: LLM batches in particular routinely have
350
+ # a small percentage fail due to rate limits / transient errors, and
351
+ # the successful majority is worth keeping. Subclasses that need
352
+ # strict behavior should validate the assembled result themselves.
353
+ all_results = cache.collect(keys, allow_missing=True)
354
+
355
+ logger.info(
356
+ "batch.done",
357
+ step=type(self).__name__,
358
+ total=len(all_results),
359
+ processed=len(uncached_indices),
360
+ from_cache=n_cached,
361
+ )
362
+
363
+ # Step 5: Assemble final result
364
+ return self.assemble(all_results, **inputs)
365
+
366
+ def run_concurrent(self, *, cache_dir: Any, **inputs: Any) -> T:
367
+ """Drive a BatchStep outside a Pipeline.resolve() context.
368
+
369
+ Use this when another Step's ``run()`` body needs to invoke a
370
+ BatchStep over derived data (e.g. running Stage-2 classification
371
+ from inside a larger CachedStep). This helper builds a minimal
372
+ ResolveContext rooted at ``cache_dir`` and dispatches through
373
+ :meth:`execute`, which guarantees:
374
+
375
+ * Item cache keys are auto-derived from
376
+ ``sha256(step.content_hash() + item.model_dump_json())[:16]`` —
377
+ meaning ANY change to step configuration (model, max_candidates,
378
+ notes, etc.) OR to a single item's input invalidates that
379
+ item's cache entry. No stale results.
380
+ * Partition / process_batch / collect / assemble run in order.
381
+
382
+ DO NOT build ``BatchItem(key=..., data=...)`` manually and call
383
+ ``process_batch(items, cache, **inputs)`` directly. That bypasses
384
+ the framework's content-aware key derivation. Custom keys that
385
+ encode only part of the inputs (e.g. external_id but not the
386
+ actual data being sent to a model) silently return stale results
387
+ when the un-encoded inputs change. ``run_concurrent`` exists
388
+ specifically to make this footgun harder to find.
389
+ """
390
+ from adag.deferred import ResolveContext
391
+
392
+ context = ResolveContext(cache_dir=cache_dir, params={}, on_progress=None)
393
+ return self.execute(context, **inputs)