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 +109 -0
- adag/__main__.py +6 -0
- adag/base.py +126 -0
- adag/batch.py +393 -0
- adag/cache_store.py +359 -0
- adag/cli.py +391 -0
- adag/config.py +67 -0
- adag/deferred.py +357 -0
- adag/dir_source.py +75 -0
- adag/dispatch.py +267 -0
- adag/fan_out.py +250 -0
- adag/hashing.py +127 -0
- adag/io.py +171 -0
- adag/item_cache.py +272 -0
- adag/observers.py +140 -0
- adag/output.py +30 -0
- adag/parameter.py +33 -0
- adag/pipeline.py +700 -0
- adag/py.typed +0 -0
- adag/serializable.py +123 -0
- adag/sinks.py +91 -0
- adag/step.py +793 -0
- adag/step_mode.py +16 -0
- adag/visualization.py +1400 -0
- adag-0.1.1.dist-info/METADATA +131 -0
- adag-0.1.1.dist-info/RECORD +29 -0
- adag-0.1.1.dist-info/WHEEL +4 -0
- adag-0.1.1.dist-info/entry_points.txt +2 -0
- adag-0.1.1.dist-info/licenses/LICENSE +21 -0
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
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)
|