Task Parameters¶
Parameters define the behaviour of a tasks run method, what the task does. Since Stardag tasks are pydantic BaseModels, we can use all pydantic features and patterns/best practices to declare a task's parameters.
As covered in previous sections, we can also pass other (arbitrarily nested) task instances as parameters; since they are also pydantic BaseModels, this nesting is natural and results in a well-defined JSON schema.
Polymorphism and TaskLoads[...]¶
A central feature that Stardag adds on top of standard pydantic is support for generalized polymorphism. Consider the example below:
class TrainedModel(sd.Task[MyModel]):
config: MyModelConfig # A regular pydantic model
dataset: Dataset # A specific Stardag Task
def requires(self):
return self.dataset
def run(self):
training_data = self.dataset.load()
model = MyModel(config)
model.fit(training_data)
self._save(model)
# ...
Here, we have declared that the dataset must be a specific task of type Dataset. This could be fine, but we typically want to be able to compare different training and test datasets from different sources with different pre-processing etc. and this is typically best reflected by differently composed tasks/DAGs.
Looking closer at the run method, we actually only care about the data type of training_data in:
We can express this by instead using:
MyDataType = ... # For example a pandas DataFrame with a pandera schema
class TrainedModel(sd.Task[MyModel]):
config: MyModelConfig # A regular pydantic model
dataset: sd.TaskLoads[MyDataType] # *Any* task, which .load() -> MyDataType.
TaskLoads[<Type>] is short for any Stardag task for which the return type of .load() is <Type>.
Two identities¶
Every task has two identities, and it matters which one a given piece of code needs.
| Identity | Hashes | Answers |
|---|---|---|
task.id |
namespace, name, version, every significant field |
"Is this output done?" — global, across every build |
task.instance_hash |
the same, plus every non-significant field — i.e. all of them | "How exactly was this task constructed?" — per scope |
task.id (the task id) is a promise about output. It is what
completion and the execution claim are keyed on, it is what names the
target, and it is global: any build, anywhere, that constructs a task with
the same task id is asking for the same thing.
task.instance_hash is the hash of the task's full instance body —
every field, defaults included, nested tasks embedded as their own full
bodies. The registry stores a construction of a task under a deterministic
scope (a deployment and its settings) as an
instance: an (deployment, settings, instance_hash) row holding that
body. Two vocabulary words worth keeping straight, because the docs and
the UI use them precisely:
- An instance is that registry row.
- The Python object you construct is a task object.
One task object planned under two scopes is two instances; one instance
rehydrates into any number of task objects. instance_hash on its own is
never how you address an instance — it is only meaningful together with
the scope it was registered under, and the CLI/UI/API always address an
instance by its row id or by the full scope triple.
Two task objects can share a task id while differing in their non-significant fields — two ways of asking for one completion. Globally the registry stores as many of those as show up; within one plan there may be only one, so two builds that construct the same task id differently, in the same scope, at the same time, is a conflict raised at the point of discovery.
Significant and non-significant fields¶
In short
Only fields that change the output should be significant — the
default. A field that changes only how the work is done, or which
upstreams are required or yielded, but never the output, is
sd.StardagField(significant=False). Both kinds are ordinary
constructor arguments. The how-to:
Evolve a DAG Safely.
Every field is one of two kinds, declared with
sd.StardagField(significant: bool = True):
| Kind | significant |
Affects |
|---|---|---|
| Significant (default) | True |
the output — part of task.id |
| Non-significant | False |
how the work is done, or which upstreams it has — never the output |
from typing import Annotated
class Aggregate(sd.Task[Summary]):
__namespace__ = "reports"
period: str # significant
partition_size: Annotated[int, sd.StardagField(significant=False)] = 100
num_threads: Annotated[int, sd.StardagField(significant=False)] = 4
def requires(self):
return ListExportFiles(period=self.period)
def run(self):
files = self.requires().load() # the period's export: a list of file names
chunks = [
ChunkStats(files=files[i : i + self.partition_size])
for i in range(0, len(files), self.partition_size)
]
yield chunks # the slicing depends on partition_size, the summary does not
summary = merge((c.load() for c in chunks), threads=self.num_threads)
self._save(summary)
The file list is loaded from a static upstream; partition_size only
decides how it is sliced into ChunkStats tasks, and num_threads only
decides how fast the merge runs. Slicing 1,000 files by 100 or by 500
yields different chunk tasks but the same merged summary, so
partition_size and num_threads are both non-significant, even though
one affects structure and the other only execution — the model needs only
this one distinction; the API does not ask which of the two a
non-significant field is for.
Both kinds of field are ordinary parameters: passed at init like any
other, stored on the instance body, and rehydrated from it. There is no
"never at init, only from a build-wide config" restriction any more — a
non-significant field's value simply does not affect task.id, so two
task objects that differ only in a non-significant field share one
completion.
Only significant output matters for reuse across builds: if two builds construct the task with the same significant fields, they are asking for the same output, whatever their non-significant fields say. If they construct it with the same significant fields but different non-significant fields in the same scope, that is the one-instance- per-plan conflict above.
The three rules on what may affect what:
- Output is a function of the significant fields and nothing else —
not the code version, not an environment variable, not
settings. That is the task-id promise, and
keeping it is the user's job (bump
__version__or add a significant field when it changes). - Structure — which upstreams a task requires or yields — may depend
on the code and its deployment, and on settings, but on nothing else
from the environment. Within one scope (deployment + settings) it is
therefore deterministic: reading an arbitrary environment variable from
requires()is a contract breach, and the failure mode is over-gating (a stricter frontier than intended), never wrong output. - Execution — how the work is done — may depend on anything: settings, the environment, wall-clock time, whatever you like.
compat_default: adding a field without re-keying every downstream¶
compat_default is valid only on a significant field (a non-significant
one is not in task.id, so there is nothing to keep stable). A
significant field whose value equals its compat_default is dropped from
the task.id hash, so adding the field to an existing task class does not
change the id of every instance that would otherwise get the default —
only instances that set a non-default value get a new id. It also lets a
missing field on rehydration take the compat_default rather than
failing.
Supply it in the field's validated Python form, not its serialized form — both the hashing and the rehydration side compare against the raw value.
Serialization stability¶
The instance body has no user-facing hash mode: it is simply the ordinary
pydantic serialization of every field, canonicalised (sorted keys, sorted
sets — including a set nested inside a list, dict or Any field —
compact separators, UTF-8). What the framework asks of your fields is one
thing: stability, a fixed point under its own round trip —
dump(validate(dump(x))) == dump(x). At registration, the driver runs
that round trip once per distinct instance and raises
UnstableSerializationError, naming the field that moved, rather than
letting two processes disagree about one construction later.
Instabilities worth knowing about, because they are the ones the check exists for:
- Sets iterate in a randomised, per-process order — sorted in every dump, so this is handled for you, but a custom serializer that bypasses the ordinary dump path can reintroduce it.
- Floats are stable when the value is (
repris the shortest round-trip form).-0.0round-trips fine — it is simply a different, intentionally distinct value from0.0for hashing purposes — and a numpy scalar on a typed field is coerced to a plain Python value before it is ever dumped. Only a non-finite float (NaN, an infinity) is actually rejected — not merely unstable, but refused outright, since it is not valid JSON. A float computed non-deterministically in__init__is the real risk: nothing catches that until the round trip disagrees with itself. - Datetimes: naive vs aware, or a custom serializer that drops precision, fail the round trip.
- Defaults: the instance body includes every field, set at init or
not, so a changed class default under a new deployment produces a new
instance_hash— correct, since it is a new scope anyway, but worth remembering when you compare instance hashes across a redeploy.
The Task ID¶
Every task has an id property:
from uuid import UUID
@sd.task
def add(a: int, b: int) -> int:
return a + b
task = add(a=1, b=2)
assert task.id == UUID("fa9b74b1-1cde-5676-8650-dbcf755a2699") # UUID-5
The task ID is derived from:
- Task name (class name or function name, unless overridden)
- Task namespace
- Task version
- Every significant parameter value (recursively hashed; a nested task appears by its own task id, not its full body — that id already reflects only its significant fields, recursively, so the outer id inherits the nested task's significant identity but not its non-significant fields)
This recursive hashing ensures that:
- Changes to an upstream's significant parameters change downstream ids
- The full DAG lineage, at the significant level, is captured in the hash
"I want to fix its output" means a new task id. Changing what a task
produces is a change of promise: bump __version__ or add/change a
significant parameter. That flows into every downstream id on its own,
because a downstream that takes the task as a parameter hashes its own id
from it — there is no other way to change a task's output for one build
without affecting every build that shares the completion.
Output URIs¶
The task ID should typically determine the output URI, and does so automatically when using the Decorator API or Task:
task = add(a=1, b=2)
print(task.target().uri)
# /path/to/.stardag/local-target-roots/default/add/fa/9b/fa9b74b1-1cde-5676-8650-dbcf755a2699.json
The default path structure is:
The id[0:2]/id[2:4] directory structure prevents having too many files in a single directory (facilitate file browsing in some filesystems).
🚧 Work in progress 🚧
This documentation is still taking shape. It should soon cover:
task.instance_hashin more detail, and how a scope reuses it- Customizing hash behaviour
- Task versioning
- Best practices (examples for experimental ML and model hyperparameters)