From 978f7558ac79d06774817467b1d8cd570a1c612f Mon Sep 17 00:00:00 2001 From: Jeremy Rapin Date: Mon, 21 Sep 2026 17:33:12 +0200 Subject: [PATCH 1/4] [WIP] Fix resolved export --- CHANGELOG.md | 2 ++ docs/internal/steps/expansion_design.md | 7 ++++ exca/steps/base.py | 5 +++ exca/steps/helpers.py | 3 +- exca/steps/identity.py | 11 ++++-- exca/steps/test_steps.py | 46 ++++++++++++++++++++++++- exca/steps/utils.py | 25 ++++++++++++++ 7 files changed, 95 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32c9fc7..c01e9898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - `DiscriminatedModel`: optimized look-up. [#313] - `steps`: fixed nested infra claim deadlock. [#323] +- `steps`: `uid.yaml`/`full-uid.yaml`/`config.yaml` and the cache uid all follow `_resolve_step`, sub-steps included. This moves the cache folder of a step resolving to a non-`Chain` step (it was keyed on its unresolved config). +- `steps`: `Parallel` now runs each variant's `_resolve_step` resolution, like every other dispatch (it used to run the unresolved variant). Two variants resolving to the same step now raise (`one batch per step_uid required`) instead of running twice under separate keys. ## 0.5.29 - 26-07-28 diff --git a/docs/internal/steps/expansion_design.md b/docs/internal/steps/expansion_design.md index 52ffa716..47db60a6 100644 --- a/docs/internal/steps/expansion_design.md +++ b/docs/internal/steps/expansion_design.md @@ -106,3 +106,10 @@ return `self` from `_resolve_step()`, so re-resolution is a no-op. - Fast path `None` if `"has_resolve"` not in `_step_flags` - `None` if `_resolve_step()` returns `self` - Otherwise delegates to the returned Step's `_exca_uid_dict_override()` + +This only covers a resolution that is itself a `Chain`, and only `uid.yaml` +(`ConfigExporter` gates overrides on `uid and exclude_defaults`). The cache +key and the three config files instead go through `utils.resolved_tree`, +called by `identity.step_uid` / `identity.write_configs`: it replaces the +step and every sub-step by its resolution, so all three exporters see the +steps that actually run. diff --git a/exca/steps/base.py b/exca/steps/base.py index 6c3f7cd5..4fcc15d0 100644 --- a/exca/steps/base.py +++ b/exca/steps/base.py @@ -199,6 +199,10 @@ def _resolve_step(self) -> Step: Returns: self: normal step behavior (default, no resolution) Step: used directly (return a Chain to control its infra) + + Must return ``self`` until the resolution is final: computing a uid + resolves the step, and the first non-self resolution is memoised and + freezes the step, so later config changes are silently ignored. """ return self @@ -298,6 +302,7 @@ def _make_paths(self, aligned: tp.Sequence[Step]) -> backends.StepPaths: xkutils.mkdir_with_permissions(paths.step_folder, 0o777, root=paths.base_folder) return paths + # only reached by direct exports: step_uid/write_configs pre-resolve the tree def _exca_uid_dict_override(self) -> dict[str, tp.Any] | None: if "has_resolve" not in self._step_flags: return None diff --git a/exca/steps/helpers.py b/exca/steps/helpers.py index 3d68939c..b3c683e1 100644 --- a/exca/steps/helpers.py +++ b/exca/steps/helpers.py @@ -199,7 +199,8 @@ def _run_items(self, batch: items.StepItems) -> items.StepItems: f"a step), got {self.infra!r}" ) cbatches = [] - for child in self.steps: + for step in self.steps: + child = utils.resolved_step(step) # as _dispatch: run what identity keys on uids = [identity.materialize_uid(child, v) for v in batch] child_batch = items.StepItems(source=dict(zip(uids, batch)), uids=uids) cbatches.append(self.infra._prepare(child, child_batch)) diff --git a/exca/steps/identity.py b/exca/steps/identity.py index 38c9a934..6eaa3e4e 100644 --- a/exca/steps/identity.py +++ b/exca/steps/identity.py @@ -61,8 +61,12 @@ def _compress_tail(segments: list[str], budget: int) -> str: def step_uid(steps: tp.Sequence[Step]) -> str: """Slash-joined per-step uid; compressed if over MAX_STEP_UID_LENGTH.""" + from .utils import resolved_tree # lazy — utils imports backends imports identity + opts = {"exclude_defaults": True, "uid": True} - segments = [exca.ConfDict.from_model(s, **opts).to_uid() for s in steps] + segments = [ + exca.ConfDict.from_model(resolved_tree(s), **opts).to_uid() for s in steps + ] full = "/".join(segments) if len(full) <= MAX_STEP_UID_LENGTH: return full @@ -106,5 +110,8 @@ def write_configs( The config is the full computation path (aligned chain), so a chain and its last step write identical configs when sharing a folder. """ + from .utils import resolved_tree # lazy — utils imports backends imports identity + step_folder.mkdir(exist_ok=True, parents=True) - utils.ConfigDump(model=list(aligned_steps)).check_and_write(step_folder, write=write) + resolved = [resolved_tree(s) for s in aligned_steps] + utils.ConfigDump(model=resolved).check_and_write(step_folder, write=write) diff --git a/exca/steps/test_steps.py b/exca/steps/test_steps.py index 083f897d..b81e54dd 100644 --- a/exca/steps/test_steps.py +++ b/exca/steps/test_steps.py @@ -18,7 +18,7 @@ import exca -from . import backends, conftest, identity, items, utils +from . import backends, conftest, helpers, identity, items, utils from .base import Chain, Step # ============================================================================= @@ -450,6 +450,50 @@ def test_resolve_step_uid_consistency() -> None: assert step_uid == chain_uid +class _Indirect(Step): + """Resolves to a plain Step (no Chain in between, so no uid override to rely on).""" + + coeff: float = 2.0 + + def _run(self, value: float) -> float: + return value # wrong on purpose: a dispatch skipping resolution returns it + + def _resolve_step(self) -> Step: + return conftest.Mult(coeff=self.coeff, infra=self.infra) + + +class _Holder(Step): + body: Step + + def _run(self, value: float = 0) -> float: + return value + + +def test_nested_resolution_drives_identity_and_configs(tmp_path: Path) -> None: + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + holder = _Holder(body=_Indirect(coeff=3, infra=infra)) + equivalent = _Holder(body=conftest.Mult(coeff=3, infra=infra)) + assert identity.step_uid([holder]) == identity.step_uid([equivalent]), ( + "container uid must key on the sub-step resolution, not on the declaration" + ) + + folder = tmp_path / "configs" + identity.write_configs(folder, [holder]) + for name in ("uid", "full-uid", "config"): + text = (folder / f"{name}.yaml").read_text("utf8") + assert "Indirect" not in text, f"{name}.yaml kept the unresolved step:\n{text}" + config = (folder / "config.yaml").read_text("utf8") + assert "Cached" in config, f"config.yaml must keep infra:\n{config}" + + +def test_parallel_caches_the_resolved_step_result(tmp_path: Path) -> None: + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + sweep = helpers.Parallel(steps=[_Indirect(coeff=3)], infra=infra) + sweep.run(5.0) + result = sweep.steps[0].lookup(5.0).result() + assert result == 15.0, f"resolved-step folder holds an unresolved run: {result}" + + def test_resolve_step_runtime_checks(tmp_path: Path) -> None: force_infra: tp.Any = {"backend": "Cached", "folder": tmp_path, "mode": "force"} chain_infra: tp.Any = {"backend": "Cached", "folder": tmp_path} diff --git a/exca/steps/utils.py b/exca/steps/utils.py index 621afc7f..41be96c6 100644 --- a/exca/steps/utils.py +++ b/exca/steps/utils.py @@ -126,6 +126,31 @@ def resolved_step(step: base.Step) -> base.Step: return built +def resolved_tree(obj: tp.Any) -> tp.Any: + """``obj`` with every ``Step`` it contains replaced by its resolution + (``obj`` itself when nothing resolves). Identity and config exports must + see the steps that actually run, sub-steps included.""" + from . import base # lazy — avoids circular import at module level + + if isinstance(obj, base.Step): + obj = resolved_step(obj) + if isinstance(obj, pydantic.BaseModel): + update = {} + for name in type(obj).model_fields: + val = getattr(obj, name) + sub = resolved_tree(val) + if sub is not val: + update[name] = sub + return obj.model_copy(update=update) if update else obj + if isinstance(obj, (dict, list, tuple)): + vals = list(obj.values()) if isinstance(obj, dict) else list(obj) + subs = [resolved_tree(v) for v in vals] + if all(s is v for s, v in zip(subs, vals)): + return obj + return type(obj)(zip(obj, subs)) if isinstance(obj, dict) else type(obj)(subs) + return obj + + def nested_steps(step: base.Step) -> dict[str, base.Step]: """Every ``Step`` the step's fields reach without crossing another ``Step``, keyed by the dotted path (field, then keys and indices) it sits at.""" From bc1f66d3ec3a75647889a56f360abd24355cec5a Mon Sep 17 00:00:00 2001 From: Jeremy Rapin Date: Mon, 21 Sep 2026 18:09:28 +0200 Subject: [PATCH 2/4] simplify --- CHANGELOG.md | 4 ++-- docs/internal/steps/expansion_design.md | 15 ++++++--------- exca/steps/base.py | 5 +---- exca/steps/identity.py | 11 ++--------- exca/steps/test_steps.py | 25 +++++++------------------ exca/steps/utils.py | 25 ------------------------- 6 files changed, 18 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c01e9898..524a76c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ - `DiscriminatedModel`: optimized look-up. [#313] - `steps`: fixed nested infra claim deadlock. [#323] -- `steps`: `uid.yaml`/`full-uid.yaml`/`config.yaml` and the cache uid all follow `_resolve_step`, sub-steps included. This moves the cache folder of a step resolving to a non-`Chain` step (it was keyed on its unresolved config). -- `steps`: `Parallel` now runs each variant's `_resolve_step` resolution, like every other dispatch (it used to run the unresolved variant). Two variants resolving to the same step now raise (`one batch per step_uid required`) instead of running twice under separate keys. +- `steps`: a step resolving (`_resolve_step`) to a non-`Chain` step is keyed on its resolution, like one resolving to a `Chain`. Such steps change cache folder. +- `steps`: `Parallel` runs each variant's resolution, as every other dispatch does. ## 0.5.29 - 26-07-28 diff --git a/docs/internal/steps/expansion_design.md b/docs/internal/steps/expansion_design.md index 47db60a6..0090b8d4 100644 --- a/docs/internal/steps/expansion_design.md +++ b/docs/internal/steps/expansion_design.md @@ -103,13 +103,10 @@ return `self` from `_resolve_step()`, so re-resolution is a no-op. ### 5. UID consistency via `_exca_uid_dict_override` `utils.py` is updated to support `None` return (opt-out). Step's override: -- Fast path `None` if `"has_resolve"` not in `_step_flags` - `None` if `_resolve_step()` returns `self` -- Otherwise delegates to the returned Step's `_exca_uid_dict_override()` - -This only covers a resolution that is itself a `Chain`, and only `uid.yaml` -(`ConfigExporter` gates overrides on `uid and exclude_defaults`). The cache -key and the three config files instead go through `utils.resolved_tree`, -called by `identity.step_uid` / `identity.write_configs`: it replaces the -step and every sub-step by its resolution, so all three exporters see the -steps that actually run. +- Otherwise the resolution's uid export + +`ConfigExporter` calls the override on sub-models too, so a nested resolution is +keyed on what runs. Only `uid.yaml` and the cache key: overrides are gated on +`uid and exclude_defaults`, so `full-uid.yaml`/`config.yaml` keep the declared +config. diff --git a/exca/steps/base.py b/exca/steps/base.py index 4fcc15d0..37e584be 100644 --- a/exca/steps/base.py +++ b/exca/steps/base.py @@ -302,14 +302,11 @@ def _make_paths(self, aligned: tp.Sequence[Step]) -> backends.StepPaths: xkutils.mkdir_with_permissions(paths.step_folder, 0o777, root=paths.base_folder) return paths - # only reached by direct exports: step_uid/write_configs pre-resolve the tree def _exca_uid_dict_override(self) -> dict[str, tp.Any] | None: - if "has_resolve" not in self._step_flags: - return None built = utils.resolved_step(self) if built is self: return None - return built._exca_uid_dict_override() + return exca.utils.ConfigExporter(uid=True, exclude_defaults=True).apply(built) def lookup( self, diff --git a/exca/steps/identity.py b/exca/steps/identity.py index 6eaa3e4e..38c9a934 100644 --- a/exca/steps/identity.py +++ b/exca/steps/identity.py @@ -61,12 +61,8 @@ def _compress_tail(segments: list[str], budget: int) -> str: def step_uid(steps: tp.Sequence[Step]) -> str: """Slash-joined per-step uid; compressed if over MAX_STEP_UID_LENGTH.""" - from .utils import resolved_tree # lazy — utils imports backends imports identity - opts = {"exclude_defaults": True, "uid": True} - segments = [ - exca.ConfDict.from_model(resolved_tree(s), **opts).to_uid() for s in steps - ] + segments = [exca.ConfDict.from_model(s, **opts).to_uid() for s in steps] full = "/".join(segments) if len(full) <= MAX_STEP_UID_LENGTH: return full @@ -110,8 +106,5 @@ def write_configs( The config is the full computation path (aligned chain), so a chain and its last step write identical configs when sharing a folder. """ - from .utils import resolved_tree # lazy — utils imports backends imports identity - step_folder.mkdir(exist_ok=True, parents=True) - resolved = [resolved_tree(s) for s in aligned_steps] - utils.ConfigDump(model=resolved).check_and_write(step_folder, write=write) + utils.ConfigDump(model=list(aligned_steps)).check_and_write(step_folder, write=write) diff --git a/exca/steps/test_steps.py b/exca/steps/test_steps.py index b81e54dd..45280201 100644 --- a/exca/steps/test_steps.py +++ b/exca/steps/test_steps.py @@ -451,12 +451,12 @@ def test_resolve_step_uid_consistency() -> None: class _Indirect(Step): - """Resolves to a plain Step (no Chain in between, so no uid override to rely on).""" + """Resolves to a plain Step: no Chain in between, so no uid override to rely on.""" coeff: float = 2.0 def _run(self, value: float) -> float: - return value # wrong on purpose: a dispatch skipping resolution returns it + return value # never multiplies: tells an unresolved run from a resolved one def _resolve_step(self) -> Step: return conftest.Mult(coeff=self.coeff, infra=self.infra) @@ -465,25 +465,14 @@ def _resolve_step(self) -> Step: class _Holder(Step): body: Step - def _run(self, value: float = 0) -> float: + def _run(self, value: float) -> float: return value -def test_nested_resolution_drives_identity_and_configs(tmp_path: Path) -> None: - infra: tp.Any = {"backend": "Cached", "folder": tmp_path} - holder = _Holder(body=_Indirect(coeff=3, infra=infra)) - equivalent = _Holder(body=conftest.Mult(coeff=3, infra=infra)) - assert identity.step_uid([holder]) == identity.step_uid([equivalent]), ( - "container uid must key on the sub-step resolution, not on the declaration" - ) - - folder = tmp_path / "configs" - identity.write_configs(folder, [holder]) - for name in ("uid", "full-uid", "config"): - text = (folder / f"{name}.yaml").read_text("utf8") - assert "Indirect" not in text, f"{name}.yaml kept the unresolved step:\n{text}" - config = (folder / "config.yaml").read_text("utf8") - assert "Cached" in config, f"config.yaml must keep infra:\n{config}" +def test_nested_resolution_drives_uid() -> None: + bodies: list[Step] = [_Indirect(coeff=3), conftest.Mult(coeff=3)] + uids = {identity.step_uid([_Holder(body=body)]) for body in bodies} + assert len(uids) == 1, f"uid keys on the declaration, not the resolution: {uids}" def test_parallel_caches_the_resolved_step_result(tmp_path: Path) -> None: diff --git a/exca/steps/utils.py b/exca/steps/utils.py index 41be96c6..621afc7f 100644 --- a/exca/steps/utils.py +++ b/exca/steps/utils.py @@ -126,31 +126,6 @@ def resolved_step(step: base.Step) -> base.Step: return built -def resolved_tree(obj: tp.Any) -> tp.Any: - """``obj`` with every ``Step`` it contains replaced by its resolution - (``obj`` itself when nothing resolves). Identity and config exports must - see the steps that actually run, sub-steps included.""" - from . import base # lazy — avoids circular import at module level - - if isinstance(obj, base.Step): - obj = resolved_step(obj) - if isinstance(obj, pydantic.BaseModel): - update = {} - for name in type(obj).model_fields: - val = getattr(obj, name) - sub = resolved_tree(val) - if sub is not val: - update[name] = sub - return obj.model_copy(update=update) if update else obj - if isinstance(obj, (dict, list, tuple)): - vals = list(obj.values()) if isinstance(obj, dict) else list(obj) - subs = [resolved_tree(v) for v in vals] - if all(s is v for s, v in zip(subs, vals)): - return obj - return type(obj)(zip(obj, subs)) if isinstance(obj, dict) else type(obj)(subs) - return obj - - def nested_steps(step: base.Step) -> dict[str, base.Step]: """Every ``Step`` the step's fields reach without crossing another ``Step``, keyed by the dotted path (field, then keys and indices) it sits at.""" From 1ab73da78e90336b24fdf0bc4ec3f2e344238d14 Mon Sep 17 00:00:00 2001 From: Jeremy Rapin Date: Tue, 22 Sep 2026 11:32:45 +0200 Subject: [PATCH 3/4] fix --- CHANGELOG.md | 4 ++-- docs/internal/steps/expansion_design.md | 10 ++++------ exca/steps/base.py | 4 ---- exca/steps/helpers.py | 8 ++++---- exca/steps/test_steps.py | 22 ++++++++-------------- 5 files changed, 18 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 524a76c9..33776a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ - `DiscriminatedModel`: optimized look-up. [#313] - `steps`: fixed nested infra claim deadlock. [#323] -- `steps`: a step resolving (`_resolve_step`) to a non-`Chain` step is keyed on its resolution, like one resolving to a `Chain`. Such steps change cache folder. -- `steps`: `Parallel` runs each variant's resolution, as every other dispatch does. +- `steps`: `_resolve_step` cache keys now use the resolved step, preventing distinct resolutions from sharing a folder; affected entries recompute once. +- `steps`: `Parallel` now runs resolved variants, so `parallel.steps[k].lookup(value).result()` finds their cached results. ## 0.5.29 - 26-07-28 diff --git a/docs/internal/steps/expansion_design.md b/docs/internal/steps/expansion_design.md index 0090b8d4..e0a41c2a 100644 --- a/docs/internal/steps/expansion_design.md +++ b/docs/internal/steps/expansion_design.md @@ -51,8 +51,8 @@ class StudyLoader(Step): - **Chain resolution**: When this step appears inside a larger Chain, `with_input()` resolves it so the built chain integrates into the parent chain. -- **UID consistency**: `_exca_uid_dict_override` on Step delegates to the resolved Chain - representation, so `StudyLoader(transforms=[T1])` and `Chain([StudyLoader(), T1])` +- **UID consistency**: `_exca_uid_dict_override` on Step exports the resolution, + so `StudyLoader(transforms=[T1])` and `Chain([StudyLoader(), T1])` produce the same UID. ### How caching works @@ -106,7 +106,5 @@ return `self` from `_resolve_step()`, so re-resolution is a no-op. - `None` if `_resolve_step()` returns `self` - Otherwise the resolution's uid export -`ConfigExporter` calls the override on sub-models too, so a nested resolution is -keyed on what runs. Only `uid.yaml` and the cache key: overrides are gated on -`uid and exclude_defaults`, so `full-uid.yaml`/`config.yaml` keep the declared -config. +`ConfigExporter` applies overrides recursively: nested resolutions affect cache +keys and `uid.yaml`; `full-uid.yaml`/`config.yaml` retain declared configs. diff --git a/exca/steps/base.py b/exca/steps/base.py index 37e584be..10f2c2eb 100644 --- a/exca/steps/base.py +++ b/exca/steps/base.py @@ -199,10 +199,6 @@ def _resolve_step(self) -> Step: Returns: self: normal step behavior (default, no resolution) Step: used directly (return a Chain to control its infra) - - Must return ``self`` until the resolution is final: computing a uid - resolves the step, and the first non-self resolution is memoised and - freezes the step, so later config changes are silently ignored. """ return self diff --git a/exca/steps/helpers.py b/exca/steps/helpers.py index b3c683e1..294c5914 100644 --- a/exca/steps/helpers.py +++ b/exca/steps/helpers.py @@ -199,11 +199,11 @@ def _run_items(self, batch: items.StepItems) -> items.StepItems: f"a step), got {self.infra!r}" ) cbatches = [] - for step in self.steps: - child = utils.resolved_step(step) # as _dispatch: run what identity keys on - uids = [identity.materialize_uid(child, v) for v in batch] + for variant in self.steps: + resolved = utils.resolved_step(variant) + uids = [identity.materialize_uid(resolved, v) for v in batch] child_batch = items.StepItems(source=dict(zip(uids, batch)), uids=uids) - cbatches.append(self.infra._prepare(child, child_batch)) + cbatches.append(self.infra._prepare(resolved, child_batch)) with self.infra._claim(cbatches) as claimed: if claimed.ready: self.infra._execute(claimed.ready) diff --git a/exca/steps/test_steps.py b/exca/steps/test_steps.py index 45280201..84abd530 100644 --- a/exca/steps/test_steps.py +++ b/exca/steps/test_steps.py @@ -450,19 +450,15 @@ def test_resolve_step_uid_consistency() -> None: assert step_uid == chain_uid -class _Indirect(Step): - """Resolves to a plain Step: no Chain in between, so no uid override to rely on.""" - - coeff: float = 2.0 - +class _ResolvesToMult(Step): def _run(self, value: float) -> float: - return value # never multiplies: tells an unresolved run from a resolved one + return value def _resolve_step(self) -> Step: - return conftest.Mult(coeff=self.coeff, infra=self.infra) + return conftest.Mult(infra=self.infra) -class _Holder(Step): +class _StepWithBody(Step): body: Step def _run(self, value: float) -> float: @@ -470,17 +466,15 @@ def _run(self, value: float) -> float: def test_nested_resolution_drives_uid() -> None: - bodies: list[Step] = [_Indirect(coeff=3), conftest.Mult(coeff=3)] - uids = {identity.step_uid([_Holder(body=body)]) for body in bodies} - assert len(uids) == 1, f"uid keys on the declaration, not the resolution: {uids}" + resolving_uid = identity.step_uid([_StepWithBody(body=_ResolvesToMult())]) + assert resolving_uid == identity.step_uid([_StepWithBody(body=conftest.Mult())]) def test_parallel_caches_the_resolved_step_result(tmp_path: Path) -> None: infra: tp.Any = {"backend": "Cached", "folder": tmp_path} - sweep = helpers.Parallel(steps=[_Indirect(coeff=3)], infra=infra) + sweep = helpers.Parallel(steps=[_ResolvesToMult()], infra=infra) sweep.run(5.0) - result = sweep.steps[0].lookup(5.0).result() - assert result == 15.0, f"resolved-step folder holds an unresolved run: {result}" + assert sweep.steps[0].lookup(5.0).result() == 10.0 def test_resolve_step_runtime_checks(tmp_path: Path) -> None: From 9fd7e813a1c6bcdfe2b25cdd627273548ca1f408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Rapin?= Date: Tue, 22 Sep 2026 13:46:55 +0200 Subject: [PATCH 4/4] Apply suggestion from @jrapin --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33776a27..f32c9fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,6 @@ - `DiscriminatedModel`: optimized look-up. [#313] - `steps`: fixed nested infra claim deadlock. [#323] -- `steps`: `_resolve_step` cache keys now use the resolved step, preventing distinct resolutions from sharing a folder; affected entries recompute once. -- `steps`: `Parallel` now runs resolved variants, so `parallel.steps[k].lookup(value).result()` finds their cached results. ## 0.5.29 - 26-07-28