diff --git a/src/buildcompiler/api/compiler.py b/src/buildcompiler/api/compiler.py index 2823293..2a82db0 100644 --- a/src/buildcompiler/api/compiler.py +++ b/src/buildcompiler/api/compiler.py @@ -5,13 +5,13 @@ from dataclasses import dataclass, field from typing import Any +from buildcompiler.planning import FullBuildPlanner + from .options import BuildOptions @dataclass class BuildCompiler: - """Lightweight dependency-injected compiler facade.""" - inventory: Any = None sbol_document: Any = None planner: Any = None @@ -30,43 +30,42 @@ def from_synbiohub( options: BuildOptions | None = None, **kwargs: Any, ) -> "BuildCompiler": - """Factory boundary reserved for future SynBioHub loading/indexing.""" if collections: raise NotImplementedError( - "Automatic SynBioHub collection loading/indexing is not implemented yet. " - "Inject inventory dependencies directly for now." + "Automatic SynBioHub collection loading/indexing is not implemented yet. Inject inventory dependencies directly for now." ) - - return cls( - sbol_document=sbol_doc, - options=options or BuildOptions(), - **kwargs, - ) + return cls(sbol_document=sbol_doc, options=options or BuildOptions(), **kwargs) def plan(self, abstract_designs: Any, options: BuildOptions | None = None) -> Any: - """Plan a build request via injected planner (placeholder in skeleton).""" - if self.planner is None: - raise NotImplementedError( - "Build planning is not implemented in the API skeleton. " - "Inject a planner dependency to use BuildCompiler.plan()." - ) - effective_options = options or self.options - return self.planner.plan(abstract_designs, options=effective_options) + planner = self.planner or FullBuildPlanner(options=effective_options) + return planner.plan(abstract_designs, options=effective_options) def execute(self, plan: Any, options: BuildOptions | None = None) -> Any: - """Execute a build plan via injected executor (placeholder in skeleton).""" - if self.executor is None: - raise NotImplementedError( - "Build execution is not implemented in the API skeleton. " - "Inject an executor dependency to use BuildCompiler.execute()." - ) - effective_options = options or self.options - return self.executor.execute(plan, options=effective_options) + executor = self.executor + if executor is None: + if self.inventory is None: + raise ValueError( + "BuildCompiler.execute requires an inventory when no executor is injected." + ) + if self.sbol_document is None: + raise ValueError( + "BuildCompiler.execute requires an sbol_document when no executor is injected." + ) + from buildcompiler.execution import FullBuildExecutor + + executor = FullBuildExecutor.from_dependencies( + inventory=self.inventory, + sbol_document=self.sbol_document, + options=effective_options, + adapters=self.adapters, + ) + return executor.execute(plan, options=effective_options) - def full_build(self, abstract_designs: Any, options: BuildOptions | None = None) -> Any: - """Convenience skeleton method: plan then execute.""" + def full_build( + self, abstract_designs: Any, options: BuildOptions | None = None + ) -> Any: plan = self.plan(abstract_designs, options=options) return self.execute(plan, options=options) @@ -86,10 +85,13 @@ def full_build( sbol_doc: Any = None, **kwargs: Any, ) -> Any: - """Module-level full-build entry point for the public API skeleton.""" compiler_options = options or BuildOptions() - - if collections is not None or sbh_registry is not None or auth_token is not None or sbol_doc is not None: + if ( + collections is not None + or sbh_registry is not None + or auth_token is not None + or sbol_doc is not None + ): compiler = BuildCompiler.from_synbiohub( collections=collections, sbh_registry=sbh_registry, @@ -105,11 +107,11 @@ def full_build( else: compiler = BuildCompiler( inventory=inventory, + sbol_document=sbol_document, planner=planner, executor=executor, adapters=adapters, options=compiler_options, **kwargs, ) - return compiler.full_build(abstract_designs, options=compiler_options) diff --git a/src/buildcompiler/execution/__init__.py b/src/buildcompiler/execution/__init__.py index 5e19692..a8357fa 100644 --- a/src/buildcompiler/execution/__init__.py +++ b/src/buildcompiler/execution/__init__.py @@ -1 +1,6 @@ -"""Package scaffolding for clean architecture.""" +"""Execution package exports.""" + +from .context import BuildContext +from .executor import FullBuildExecutor + +__all__ = ["BuildContext", "FullBuildExecutor"] diff --git a/src/buildcompiler/execution/context.py b/src/buildcompiler/execution/context.py new file mode 100644 index 0000000..9c17cdf --- /dev/null +++ b/src/buildcompiler/execution/context.py @@ -0,0 +1,20 @@ +"""Execution context contract for full-build orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from buildcompiler.inventory import Inventory +from buildcompiler.sbol import SbolResolver + + +@dataclass +class BuildContext: + sbol: SbolResolver + inventory: Inventory + build_document: Any + options: Any + adapters: Any = None + graph: Any = None + logger: Any = None diff --git a/src/buildcompiler/execution/executor.py b/src/buildcompiler/execution/executor.py new file mode 100644 index 0000000..cad9c4f --- /dev/null +++ b/src/buildcompiler/execution/executor.py @@ -0,0 +1,285 @@ +"""Bounded fixed-point full-build executor.""" + +from __future__ import annotations + +import hashlib +from collections import OrderedDict +from typing import Any + +from buildcompiler.api.options import BuildOptions +from buildcompiler.domain import ( + BuildRequest, + BuildStage, + BuildStatus, + DesignKind, + FullBuildResult, + MissingBuildInput, + StageResult, + StageStatus, +) +from buildcompiler.execution.context import BuildContext +from buildcompiler.inventory import Inventory +from buildcompiler.planning import BuildPlan +from buildcompiler.sbol import SbolResolver +from buildcompiler.stages import ( + AssemblyLvl1Stage, + AssemblyLvl2Stage, + DomesticationStage, +) + + +class FullBuildExecutor: + def __init__( + self, + *, + context: BuildContext, + lvl2_stage: Any | None = None, + lvl1_stage: Any | None = None, + domestication_stage: Any | None = None, + transformation_stage: Any | None = None, + plating_stage: Any | None = None, + ) -> None: + self.context = context + options = context.options + self.lvl2_stage = lvl2_stage or AssemblyLvl2Stage( + inventory=context.inventory, options=options + ) + self.lvl1_stage = lvl1_stage or AssemblyLvl1Stage( + inventory=context.inventory, options=options + ) + self.domestication_stage = domestication_stage or DomesticationStage( + inventory=context.inventory, options=options + ) + self.transformation_stage = transformation_stage + self.plating_stage = plating_stage + + @classmethod + def from_dependencies( + cls, + *, + inventory: Inventory, + sbol_document: Any, + options: BuildOptions, + adapters: Any = None, + graph: Any = None, + logger: Any = None, + **stage_overrides: Any, + ) -> "FullBuildExecutor": + resolver = SbolResolver(sbol_document) + return cls( + context=BuildContext( + sbol=resolver, + inventory=inventory, + build_document=sbol_document, + options=options, + adapters=adapters, + graph=graph, + logger=logger, + ), + **stage_overrides, + ) + + def execute( + self, plan: BuildPlan, *, options: BuildOptions | None = None + ) -> FullBuildResult: + if options is not None: + self.context.options = options + + pending = { + BuildStage.ASSEMBLY_LVL2: OrderedDict( + (r.id, r) for r in plan.lvl2_requests + ), + BuildStage.ASSEMBLY_LVL1: OrderedDict( + (r.id, r) for r in plan.lvl1_requests + ), + BuildStage.DOMESTICATION: OrderedDict( + (r.id, r) for r in plan.domestication_requests + ), + } + completed: set[str] = set() + stage_results: list[StageResult] = [] + final_products: dict[str, Any] = {} + missing_by_key: dict[tuple, MissingBuildInput] = {} + approvals: dict[str, Any] = {} + warnings: list[Any] = list(plan.warnings) + seen_products: set[str] = set() + transformed: set[str] = set() + plated: set[str] = set() + + for _ in range(self.context.options.execution.max_iterations): + progress = False + for stage in ( + BuildStage.ASSEMBLY_LVL2, + BuildStage.ASSEMBLY_LVL1, + BuildStage.DOMESTICATION, + ): + runner = { + BuildStage.ASSEMBLY_LVL2: self.lvl2_stage, + BuildStage.ASSEMBLY_LVL1: self.lvl1_stage, + BuildStage.DOMESTICATION: self.domestication_stage, + }[stage] + for request in list(pending[stage].values()): + result = self._run_stage(runner, request) + stage_results.append(result) + warnings.extend(result.warnings) + for approval in result.required_approvals: + approvals[str(approval)] = approval + if result.status in ( + StageStatus.SUCCESS, + StageStatus.PARTIAL_SUCCESS, + ): + if request.id not in completed: + completed.add(request.id) + progress = True + pending[stage].pop(request.id, None) + progress = ( + self._index_products(result, final_products, seen_products) + or progress + ) + progress = ( + self._chain( + result.products, stage_results, transformed, plated + ) + or progress + ) + else: + for missing in result.missing_inputs: + missing_by_key[self._missing_key(missing)] = missing + promoted = self._promote(request, missing) + if ( + promoted is not None + and promoted.id not in pending[promoted.stage] + and promoted.id not in completed + ): + pending[promoted.stage][promoted.id] = promoted + progress = True + + if not any(pending[s] for s in pending): + break + if not progress: + break + + unresolved = [ + m + for m in missing_by_key.values() + if self._promote(None, m) is None + or self._promote(None, m).id not in completed + ] + products = list(final_products.values()) + status = ( + BuildStatus.SUCCESS + if (not unresolved and not any(pending[s] for s in pending)) + else (BuildStatus.PARTIAL_SUCCESS if products else BuildStatus.FAILED) + ) + return FullBuildResult( + status=status, + plan=plan, + build_document=self.context.build_document, + stage_results=stage_results, + graph=self.context.graph, + final_products=products, + missing_inputs=unresolved, + required_approvals=list(approvals.values()), + warnings=warnings, + ) + + def _run_stage(self, stage: Any, request: BuildRequest) -> StageResult: + source_document = ( + getattr(self.context.sbol, "document", None) or self.context.build_document + ) + try: + return stage.run( + request, + source_document=source_document, + target_document=self.context.build_document, + ) + except Exception: + if not self.context.options.execution.continue_on_error: + raise + return StageResult( + id=f"{request.id}:{request.stage.value}", + stage=request.stage, + status=StageStatus.FAILED, + request_ids=[request.id], + logs=["Unexpected execution error."], + ) + + def _index_products( + self, + result: StageResult, + final_products: dict[str, Any], + seen_products: set[str], + ) -> bool: + progress = False + for product in result.products: + if product.identity not in seen_products: + seen_products.add(product.identity) + self.context.inventory.add_generated_product(product) + final_products[product.identity] = product + progress = True + return progress + + def _promote( + self, request: BuildRequest | None, missing: MissingBuildInput + ) -> BuildRequest | None: + if missing.required_stage not in ( + BuildStage.ASSEMBLY_LVL1, + BuildStage.DOMESTICATION, + ): + return None + prefix = ( + "lvl1" + if missing.required_stage == BuildStage.ASSEMBLY_LVL1 + else "domestication" + ) + digest = hashlib.sha1(missing.missing_identity.encode()).hexdigest()[:12] + return BuildRequest( + id=f"promoted:{prefix}:{digest}", + stage=missing.required_stage, + source_identity=missing.missing_identity, + source_display_id=missing.missing_display_id, + source_kind=DesignKind.COMPONENT_DEFINITION, + parent_group=request.id if request else missing.source_design_identity, + constraints={ + "promoted_from_stage": missing.source_stage.value, + "promoted_from_design_identity": missing.source_design_identity, + "candidates_tried": list(missing.candidates_tried), + }, + ) + + def _missing_key(self, missing: MissingBuildInput) -> tuple: + return ( + missing.source_stage.value, + missing.source_design_identity, + missing.missing_identity, + missing.missing_kind, + str(missing.required_stage), + missing.reason, + ) + + def _chain( + self, + products: list[Any], + stage_results: list[StageResult], + transformed: set[str], + plated: set[str], + ) -> bool: + progress = False + if self.transformation_stage is None: + return False + for product in products: + if product.identity in transformed: + continue + transformed.add(product.identity) + t_result = self.transformation_stage.run(product) + stage_results.append(t_result) + progress = True + if self.plating_stage is None: + continue + for out in t_result.products: + if out.identity in plated: + continue + plated.add(out.identity) + stage_results.append(self.plating_stage.run(out)) + progress = True + return progress diff --git a/src/buildcompiler/inventory/selector.py b/src/buildcompiler/inventory/selector.py index 07ddd31..1b712e9 100644 --- a/src/buildcompiler/inventory/selector.py +++ b/src/buildcompiler/inventory/selector.py @@ -6,7 +6,7 @@ from itertools import permutations from typing import Any -from buildcompiler.api import BuildOptions +from buildcompiler.api.options import BuildOptions from buildcompiler.domain import BuildStage, MaterialState from buildcompiler.inventory.compatibility import Lvl1Route, Lvl2Route, RouteScore, RouteSelection from buildcompiler.inventory.inventory import Inventory diff --git a/src/buildcompiler/planning/combinatorial.py b/src/buildcompiler/planning/combinatorial.py index a94a8bc..101ec02 100644 --- a/src/buildcompiler/planning/combinatorial.py +++ b/src/buildcompiler/planning/combinatorial.py @@ -1,7 +1,7 @@ from __future__ import annotations import itertools import sbol2 -from buildcompiler.api import BuildOptions +from buildcompiler.api.options import BuildOptions from buildcompiler.domain import BuildRequest, BuildStage, BuildWarning, DesignKind from buildcompiler.planning.classifier import request_id_for from buildcompiler.planning.models import UnsupportedPlanningRecord diff --git a/src/buildcompiler/planning/full_build_planner.py b/src/buildcompiler/planning/full_build_planner.py index a4189fa..8814fbf 100644 --- a/src/buildcompiler/planning/full_build_planner.py +++ b/src/buildcompiler/planning/full_build_planner.py @@ -6,7 +6,7 @@ import sbol2 -from buildcompiler.api import BuildOptions +from buildcompiler.api.options import BuildOptions from buildcompiler.planning.classifier import classify_non_combinatorial from buildcompiler.planning.combinatorial import expand_combinatorial_derivation from buildcompiler.planning.models import BuildPlan, UnsupportedPlanningRecord diff --git a/src/buildcompiler/stages/assembly_lvl1.py b/src/buildcompiler/stages/assembly_lvl1.py index 3332eb7..04ada99 100644 --- a/src/buildcompiler/stages/assembly_lvl1.py +++ b/src/buildcompiler/stages/assembly_lvl1.py @@ -8,7 +8,7 @@ import sbol2 from buildcompiler.adapters.pudu import assembly_route_to_pudu_json -from buildcompiler.api import BuildOptions +from buildcompiler.api.options import BuildOptions from buildcompiler.domain import ( BuildRequest, BuildStage, diff --git a/src/buildcompiler/stages/assembly_lvl2.py b/src/buildcompiler/stages/assembly_lvl2.py index 31d6eb6..cdc0050 100644 --- a/src/buildcompiler/stages/assembly_lvl2.py +++ b/src/buildcompiler/stages/assembly_lvl2.py @@ -8,7 +8,7 @@ import sbol2 from buildcompiler.adapters.pudu import assembly_route_to_pudu_json -from buildcompiler.api import BuildOptions +from buildcompiler.api.options import BuildOptions from buildcompiler.domain import ( BuildRequest, BuildStage, diff --git a/src/buildcompiler/stages/domestication.py b/src/buildcompiler/stages/domestication.py index 62e9618..5e729ba 100644 --- a/src/buildcompiler/stages/domestication.py +++ b/src/buildcompiler/stages/domestication.py @@ -4,7 +4,7 @@ import sbol2 -from buildcompiler.api import BuildOptions, ProtocolMode +from buildcompiler.api.options import BuildOptions, ProtocolMode from buildcompiler.domain import ( ApprovalStatus, BuildRequest, diff --git a/tests/unit/api/test_compiler_api.py b/tests/unit/api/test_compiler_api.py index 23a34d6..648d61e 100644 --- a/tests/unit/api/test_compiler_api.py +++ b/tests/unit/api/test_compiler_api.py @@ -80,12 +80,10 @@ def test_from_synbiohub_raises_when_collection_loading_is_requested(): BuildCompiler.from_synbiohub(collections=["https://example.org/collection"]) -def test_plan_execute_raise_without_injected_dependencies(): +def test_execute_raises_clear_error_without_dependencies(): compiler = BuildCompiler() - with pytest.raises(NotImplementedError, match="planning"): - compiler.plan({"x": 1}) - - with pytest.raises(NotImplementedError, match="execution"): + compiler.plan([object()]) + with pytest.raises(ValueError, match="inventory"): compiler.execute({"plan": 1}) diff --git a/tests/unit/execution/test_executor.py b/tests/unit/execution/test_executor.py new file mode 100644 index 0000000..f439dc3 --- /dev/null +++ b/tests/unit/execution/test_executor.py @@ -0,0 +1,147 @@ +from buildcompiler.api import BuildOptions +from buildcompiler.domain import ( + BuildRequest, + BuildStage, + BuildStatus, + DesignKind, + IndexedPlasmid, + MaterialState, + MissingBuildInput, + StageResult, + StageStatus, +) +from buildcompiler.execution import BuildContext, FullBuildExecutor +from buildcompiler.inventory import Inventory +from buildcompiler.planning import BuildPlan +from buildcompiler.sbol import SbolResolver + + +class FakeStage: + def __init__(self, fn): + self.fn = fn + self.calls = [] + + def run(self, request, *, source_document, target_document): + self.calls.append(request.id) + return self.fn(request) + + +def plasmid(identity): + return IndexedPlasmid( + identity=identity, + display_id=identity.split("/")[-1], + state=MaterialState.GENERATED, + ) + + +def test_imports_smoke(): + assert BuildContext + assert FullBuildExecutor + + +def test_promote_and_retry_flow(): + lvl2_count = {"n": 0} + + def lvl2(request): + lvl2_count["n"] += 1 + if lvl2_count["n"] == 1: + return StageResult( + id="r", + stage=BuildStage.ASSEMBLY_LVL2, + status=StageStatus.BLOCKED, + request_ids=[request.id], + missing_inputs=[ + MissingBuildInput( + BuildStage.ASSEMBLY_LVL2, + request.source_identity, + "https://x/region", + "region", + "engineered_region", + BuildStage.ASSEMBLY_LVL1, + "missing", + ) + ], + ) + return StageResult( + id="r", + stage=BuildStage.ASSEMBLY_LVL2, + status=StageStatus.SUCCESS, + request_ids=[request.id], + products=[plasmid("https://x/lvl2")], + ) + + def lvl1(request): + return StageResult( + id="r1", + stage=BuildStage.ASSEMBLY_LVL1, + status=StageStatus.SUCCESS, + request_ids=[request.id], + products=[plasmid("https://x/region")], + ) + + ctx = BuildContext( + sbol=SbolResolver(__import__("sbol2").Document()), + inventory=Inventory(), + build_document=__import__("sbol2").Document(), + options=BuildOptions(), + ) + ex = FullBuildExecutor( + context=ctx, + lvl2_stage=FakeStage(lvl2), + lvl1_stage=FakeStage(lvl1), + domestication_stage=FakeStage( + lambda r: StageResult( + id="d", + stage=BuildStage.DOMESTICATION, + status=StageStatus.BLOCKED, + request_ids=[r.id], + ) + ), + ) + plan = BuildPlan( + lvl2_requests=[ + BuildRequest( + id="req-l2", + stage=BuildStage.ASSEMBLY_LVL2, + source_identity="https://x/mod", + source_display_id="mod", + source_kind=DesignKind.MODULE_DEFINITION, + ) + ] + ) + result = ex.execute(plan) + assert result.status == BuildStatus.SUCCESS + assert any(r.stage == BuildStage.ASSEMBLY_LVL1 for r in result.stage_results) + assert len(result.final_products) == 2 + + +def test_max_iteration_stops(): + options = BuildOptions() + options.execution.max_iterations = 2 + blocked = FakeStage( + lambda r: StageResult( + id="b", stage=r.stage, status=StageStatus.BLOCKED, request_ids=[r.id] + ) + ) + ctx = BuildContext( + sbol=SbolResolver(__import__("sbol2").Document()), + inventory=Inventory(), + build_document=__import__("sbol2").Document(), + options=options, + ) + ex = FullBuildExecutor( + context=ctx, lvl2_stage=blocked, lvl1_stage=blocked, domestication_stage=blocked + ) + plan = BuildPlan( + lvl2_requests=[ + BuildRequest( + id="req", + stage=BuildStage.ASSEMBLY_LVL2, + source_identity="x", + source_display_id="x", + source_kind=DesignKind.MODULE_DEFINITION, + ) + ] + ) + result = ex.execute(plan) + assert result.status == BuildStatus.FAILED