From 2517d0960e38b1656d3f8c02a646ea56e42a73b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 11:03:42 -0400 Subject: [PATCH] B1b: scope B1's anchor rule to the runtime domain; keep openhcs: keys resolvable B1 (#1181) changed CallableContract.group_scope_inputs so that a plain callable fed main-flow pixels declared no group-scope inputs. That property also drives compile-time source projection (path planner invocation sources and main_flow_projection), so callables that consume declared source images through artifact inputs lost their source projection. That broke the loaded-pattern-group and invocation source-context tests. The anchor rule now lives in one property used only by RuntimeInvocationDomain: lifecycle_anchor_owner_inputs. group_scope_inputs keeps its meaning. The attempt-recorder tests were never green at 17029deb0 (ImportError). Once C2 (#1176) fixed the import, they showed a production defect: OpenHCSRegistry.metadata_for_declared_callable claimed an "openhcs:" key for a decorated callable in any module, but the catalog that get_function resolves keys through scans only the registry's own modules. Since F1 (#1175) a pipeline is rendered as get_function(key), so the rendered source could not be loaded back. Module membership is now one method, LibraryRegistryBase.owns_module, used by both the local claim and composite_keys_for_declared_callable. A callable outside it renders as a plain import. Co-Authored-By: Claude Opus 5.5 --- openhcs/core/callable_contract.py | 17 +++++++++++++---- openhcs/core/function_patterns.py | 4 ++-- .../backends/lib_registry/openhcs_registry.py | 7 ++++++- .../backends/lib_registry/unified_registry.py | 15 ++++++++++----- tests/unit/test_function_patterns.py | 2 +- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/openhcs/core/callable_contract.py b/openhcs/core/callable_contract.py index ee2ee6350f..4a898083dc 100644 --- a/openhcs/core/callable_contract.py +++ b/openhcs/core/callable_contract.py @@ -1207,8 +1207,6 @@ def group_scope_inputs(self) -> ArtifactSpecCollection: input_specs = self.artifact_inputs source_refs = self.output_group_scope_sources if not source_refs: - if self.main_flow_supplies_primary_image: - return ArtifactSpecCollection(()) return input_specs sources = ArtifactSpecCollection( source_spec @@ -1228,12 +1226,23 @@ def group_scope_inputs(self) -> ArtifactSpecCollection: ) return sources + @property + def lifecycle_anchor_owner_inputs(self) -> ArtifactSpecCollection: + """Inputs whose stored values, not main-flow anchors, drive invocation. + + A callable fed main-flow pixels runs once per main-flow anchor; its + undeclared artifact inputs are context for that anchor. A declared + group-lineage relation still names its owning inputs. + """ + if self.main_flow_supplies_primary_image and not self.output_group_scope_sources: + return ArtifactSpecCollection(()) + return self.group_scope_inputs + @property def main_flow_supplies_primary_image(self) -> bool: """Whether the runtime passes main-flow pixels as the primary argument. - Such a callable runs once per main-flow cohort; its artifact inputs are - context for that cohort, not the owner of its group scope. + Such a callable runs once per main-flow cohort. """ adapter = self.runtime_adapter return self.accepts_implicit_main_flow_input and not ( diff --git a/openhcs/core/function_patterns.py b/openhcs/core/function_patterns.py index 82e8306348..a8a543dc86 100644 --- a/openhcs/core/function_patterns.py +++ b/openhcs/core/function_patterns.py @@ -128,10 +128,10 @@ def from_invocation( for edge in invocation.artifact_input_edges if edge.storage_plan is not None ) - group_scope_refs = invocation.contract.group_scope_inputs.ref_set() + owner_refs = invocation.contract.lifecycle_anchor_owner_inputs.ref_set() return ( cls.ARTIFACT_MANAGED - if group_scope_refs and group_scope_refs <= stored_input_refs + if owner_refs and owner_refs <= stored_input_refs else cls.SOURCE_ANCHORED ) diff --git a/openhcs/processing/backends/lib_registry/openhcs_registry.py b/openhcs/processing/backends/lib_registry/openhcs_registry.py index 5091a6fc31..7e2bb8e63b 100644 --- a/openhcs/processing/backends/lib_registry/openhcs_registry.py +++ b/openhcs/processing/backends/lib_registry/openhcs_registry.py @@ -767,8 +767,13 @@ def metadata_for_declared_callable( return None if _catalog_memory_types(func) is None: return None + registry = cls() + # An ``openhcs:`` key must resolve back through the catalog, which + # only scans this registry's own modules. + if not registry.owns_module(declared.__module__): + return None - return cls()._metadata_for_function( + return registry._metadata_for_function( func.__name__, func, _registry_catalog_module_for_callable(func), diff --git a/openhcs/processing/backends/lib_registry/unified_registry.py b/openhcs/processing/backends/lib_registry/unified_registry.py index 57b4c4b393..f4e4073b1a 100644 --- a/openhcs/processing/backends/lib_registry/unified_registry.py +++ b/openhcs/processing/backends/lib_registry/unified_registry.py @@ -1507,6 +1507,15 @@ def _canonical_metadata_claims( if metadata is not None: yield metadata + def owns_module(self, module_name: str) -> bool: + """Whether ``module_name`` lies inside this registry's declared modules.""" + + return any( + module_name == module_pattern + or module_name.startswith(f"{module_pattern}.") + for module_pattern in self.get_module_patterns() + ) + def composite_keys_for_declared_callable( self, func: Callable, @@ -1518,11 +1527,7 @@ def composite_keys_for_declared_callable( return (metadata.composite_key,) declared = inspect.unwrap(func) - if not any( - declared.__module__ == module_pattern - or declared.__module__.startswith(f"{module_pattern}.") - for module_pattern in self.get_module_patterns() - ): + if not self.owns_module(declared.__module__): return () module_names = ( diff --git a/tests/unit/test_function_patterns.py b/tests/unit/test_function_patterns.py index d7e5331d90..cf64bb2190 100644 --- a/tests/unit/test_function_patterns.py +++ b/tests/unit/test_function_patterns.py @@ -635,7 +635,7 @@ def assemble(image_tiles, positions): group = replace(compiled.default_group, invocations=(invocation,)) assert invocation.contract.main_flow_supplies_primary_image - assert invocation.contract.group_scope_inputs == ArtifactSpecCollection(()) + assert invocation.contract.lifecycle_anchor_owner_inputs == ArtifactSpecCollection(()) assert group.runtime_domain is RuntimeInvocationDomain.SOURCE_ANCHORED anchors = ["z001", "z002", "z003"] assert group.runtime_domain.select_lifecycle_anchors(anchors) == anchors