diff --git a/embodichain/__main__.py b/embodichain/__main__.py
index 3b897a3fa..0447031de 100644
--- a/embodichain/__main__.py
+++ b/embodichain/__main__.py
@@ -56,6 +56,11 @@ class Command:
target="embodichain.gen_sim.scene_engine.cli.start:main",
help="Generate a scene export from an input image using gen_sim/.env.",
),
+ Command(
+ name="task-engine",
+ target="embodichain.gen_sim.task_engine.cli:main",
+ help="Run a complete cross-engine task workflow.",
+ ),
Command(
name="preview-scene",
target="embodichain.gen_sim.scene_engine.cli.preview:main",
diff --git a/embodichain/gen_sim/action_engine/ARCHITECTURE.md b/embodichain/gen_sim/action_engine/ARCHITECTURE.md
new file mode 100644
index 000000000..6ead4afa6
--- /dev/null
+++ b/embodichain/gen_sim/action_engine/ARCHITECTURE.md
@@ -0,0 +1,398 @@
+# Action Engine v2 Architecture
+
+Action Engine v2 uses a task-first protocol and executes a direct
+`AtomicAction` graph. The persisted graph is symbolic and coordinate-free;
+simulator geometry is resolved immediately before each action executes.
+
+The Task Engine entry point wraps that existing pipeline with three narrow
+owners:
+
+1. `TaskAgent` produces three scene-independent `TaskDraft` candidates and
+ deterministically derives each `SceneRequest` and `SuccessSpec`.
+2. `SceneAdapter` binds one verified candidate to a read-only existing scene,
+ producing `SceneManifest`, `RoleBindings`, and a complete `BindingReport`.
+3. `ActionAgent` lowers the selected `GroundedTaskPlan` to the existing
+ `action_engine_seed_graph_v3`, performs executable capability preflight,
+ runs it through `ProgramExecutor`, and emits a tensor-free
+ `ExecutionReport`. Report v2 records the episode seed, package and Python
+ versions, Git commit/dirty state when available, and structured runtime
+ arguments alongside the existing plan and graph hashes.
+
+The public CLI is `python -m embodichain.gen_sim.task_engine --mode ...` with
+strict `image`, `image-edit`, `scene`, and `scene-edit` input modes. Every
+invocation runs the complete workflow through real trajectory acceptance and
+publishes an isolated, timestamped child under `--output-root`. Source projects
+are referenced in place and integrity-hashed; Task Engine does not modify them
+or copy them into a scene package store.
+
+## Package Ownership
+
+The cross-engine workflow is owned by Task Engine rather than nested under
+Action Engine:
+
+- `embodichain.gen_sim.task_engine` owns scene-independent interpretation,
+ E1-E9 semantic ontology, `TaskDraft`, `SceneRequest`, `SuccessSpec`, and
+ `TaskAgent`.
+- `embodichain.gen_sim.scene_engine` remains the scene generation subsystem and
+ exposes auditable image-understanding, materialization, edit-understanding,
+ and edit-materialization stages.
+- `embodichain.gen_sim.task_engine.scene` owns Scene Engine adaptation, the
+ richer static manifest, and deterministic scene/action feasibility reports.
+- `embodichain.gen_sim.action_engine.agent` owns `ActionAgent`; Action Engine's
+ existing `domain`, `planning`, and `runtime` packages remain authoritative
+ for graph compilation and execution.
+- `embodichain.gen_sim.task_engine.orchestration` owns cross-engine contracts,
+ read-only source references, scene adaptation, orchestration, and artifacts.
+ `embodichain.gen_sim.task_engine.cli` owns the unified CLI.
+
+The former `scene_bridge`, `collaboration`, and
+`action_engine.collaboration` namespaces were removed; there are no import
+bridges or fallback entry points.
+
+## Data Flow
+
+1. `TaskAgent` or a caller creates a validated `TaskSpec`.
+2. Action Engine emits `SceneRequirements` for the external Scene Engine.
+3. After scene generation, Task Engine's scene adapter preserves geometry, physics,
+ articulation, affordance evidence, and provenance in a versioned
+ `StaticSceneManifest` while the existing redacted manifest remains compatible.
+4. `FeasibilityBroker` intersects the selected task, role bindings, static scene,
+ robot profile, and executable capability catalog without repairing unknowns.
+5. Offline recipes and the online planner independently create complete
+ `SeedGraph` candidates whose nodes are `AtomicAction` calls.
+6. Runtime preflight checks the capability catalog and rejects planning-only
+ actions before simulator motion starts.
+7. `ActionGrounder` reads live robot, object, articulation, and camera state and
+ materializes the typed goal and immutable action options just in time.
+8. `ProgramExecutor` schedules the DAG, executes vectorized action masks, and
+ verifies semantic postconditions from live state.
+
+There is no persisted semantic task graph between `TaskSpec` and `SeedGraph`.
+The standalone TaskAgent v1 planner and compiler remain available to their
+existing callers, but the generation pipeline neither accepts nor publishes
+TaskAgent v1 JSON.
+
+## Protocols
+
+### TaskSpec
+
+`TaskSpec` owns the level, public instruction, E1-E9 task instances,
+dependencies, path-independent success conditions, and a private oracle. The
+online planner receives `public_task_spec(...)`, which removes the oracle and,
+for L4, the hidden reference task instances. Online L4 TaskGroups are inferred
+from the instruction and observations rather than matched to an oracle path.
+
+Levels classify how the task is specified, not action count:
+
+- L1: one E instance.
+- L2: two or more instances of the same E type.
+- L3: two or more different E types explicitly composed.
+- L4: an abstract instruction that requires memory, visual semantics, pattern,
+ logic, common-sense, or constraint reasoning.
+
+Free-language L1-L3 generation uses two structured model calls. The first sees
+only the instruction and E1-E9 catalog and emits typed steps whose scene
+selectors are open natural-language references. The second sees those
+references plus a coordinate-free semantic inventory and may return only
+existing scene UIDs, status, and confidence. Local validation enforces complete
+request coverage, candidate roles, cardinality, confidence, and non-self
+targets; unresolved or ambiguous references fail instead of being guessed.
+Each structured model stage gets at most one bounded repair attempt after an
+invalid response. If repair still fails, or the model call itself fails,
+generation stops before recipe expansion and artifact publication. It never
+switches to keyword or rule-based instruction parsing.
+
+The older public `planning.plan_task` adapter still produces a standalone
+`TaskAgent` from structured LLM output, but it does not reinterpret the
+instruction after that output exists. Axis, orientation, and arm-allocation
+fields come only from the validated model result. It has no keyword fallback.
+
+Generator callers without a configured LLM must provide a validated `TaskSpec`
+with explicit role bindings (or a matching `SceneRequirements` sidecar). This
+path is fully offline and never imports or calls an LLM client.
+
+### SceneRequirements
+
+`SceneRequirements` is the JSON hand-off to the external Scene Engine. It
+declares object roles, counts, categories, affordances, initial states, spatial
+constraints, camera requirements, and distractors. Scene results are never
+silently repaired. Structural contradictions and explicit affordance
+contradictions invalidate the task instance; an absent affordance declaration
+remains unknown and is deferred to runtime physical validation.
+
+The current tabletop importer has one deliberately narrow structural contract:
+exactly one `background` object is the support surface and receives runtime UID
+`table`; every movable object is assumed to begin on that surface. Zero or
+multiple backgrounds are rejected rather than resolved from position, UID, or
+description text. Semantic `category`, `color`, and `attributes` come only from
+their explicit scene fields. Physics `attrs` are not semantic metadata.
+
+For task-first inputs, explicit role bindings are authoritative unless they
+contradict metadata that the scene actually declares. Automatic role binding
+requires a unique match with complete structured category, attribute, state,
+and affordance evidence. Object names and descriptions remain available to the
+LLM grounding call, but deterministic validation never searches them for
+semantic substrings.
+
+### StaticSceneManifest And FeasibilityReport
+
+`StaticSceneManifest` is an additive Scene Bridge artifact. It keeps the legacy
+scene manifest stable while recording initial poses, geometry hashes, physics,
+articulation payloads, structured affordance evidence, and provenance. Legacy
+affordance strings become `declared` evidence; only structural facts derived by
+the adapter are marked `verified`.
+
+`FeasibilityReport` classifies each check as `proven`, `runtime_probe`, `unknown`,
+or `contradicted`. Missing evidence remains unknown. Declared geometric
+affordances require a runtime probe, and unavailable AtomicActions are explicit
+contradictions. A contradicted report publishes an `infeasible` audit result and
+does not invoke graph or bundle generation. Executable preflight remains a
+second authoritative gate before bundle generation.
+
+Scene Bridge reports arm-layout and whole-task pickup, handover,
+target-interaction, and safety-clearance phases as `runtime_probe` evidence.
+Arm-side compatibility is not claimed without live arm-base poses and workspace
+geometry.
+
+### SeedGraph
+
+Every node directly names an `atomic_action`, scene `object_uid`, symbolic
+`target_binding`, actor, control, dependencies, resources, pre/postconditions,
+motion policy, E type, `task_instance_id`, and an Action Contract v2
+`failure_policy`. `task_required` and `safety_required` failures invalidate the
+candidate; `best_effort` failures remain observable but do not erase an already
+verified task and safety result. `TaskGroup` groups all nodes of one E instance
+with `role=primary|recovery`; it is metadata over the same DAG, not a second
+graph.
+
+Validation guarantees:
+
+- node and TaskGroup dependencies are DAGs;
+- every node belongs to exactly one TaskGroup;
+- E groups contain their required core actions;
+- concurrent nodes do not claim the same exclusive arm/object resource;
+- object references resolve to scene UIDs;
+- world poses, qpos, trajectories, grasp poses, and waypoints are rejected
+ recursively;
+- hashes use canonical strict JSON and are stable across processes.
+
+The production loader accepts v3 graphs only. An older graph, whether supplied as
+JSON or an in-memory mapping, receives an explicit regeneration error rather
+than an implicit migration.
+
+## Capability Boundary
+
+`AtomicCapabilityRegistry` is the single runtime catalog. A descriptor declares
+the action/option types, accepted symbolic bindings and controls, resource mode,
+held-object state effect, target and config materializers, verifier, failure
+classifier, retry mode, and runtime availability.
+
+The executable catalog currently contains:
+
+- `PickUp`, `MoveHeldObject`, `MoveEndEffector`, `MoveJoints`, and `Place`
+- `Press`
+- `CoordinatedPickment` and `CoordinatedPlacement`
+- `HandOver`
+
+`Pour`, `PullArticulatedPart`, `PushArticulatedPart`, and `TurnKnob` are
+planning-only until matching lower-level implementations exist. They can be
+generated and statically checked, but preflight fails before any motion with
+the descriptor's unavailable reason.
+
+Adding an executable skill consists of registering its descriptor and reusable
+materializer/verifier hooks plus focused tests. Planner and executor dispatch
+do not maintain a parallel action-class table.
+
+## Offline And Online Planning
+
+Offline recipes deterministically instantiate E1-E9 task instances. Current
+task mappings are:
+
+- `place_relative -> E1`
+- `orient_object -> E2`
+- `coordinated_transport -> E5`
+- every member of `build_stack` and `arrange_line` -> one E1 instance
+
+E5 uses `coordinated_transport` only as the semantic task-group operator. Its
+motion graph contains one `CoordinatedPickment`; a `place` terminal behavior
+adds synchronized left/right `MoveJoints(gripper_open)` nodes. The executor
+clears coordinated hold state only after both grippers are observed open.
+
+The online path first extracts auditable visual facts from multi-view RGB and,
+when available, depth and camera calibration. Facts contain only known UIDs,
+normalized bboxes/keypoints, canonical spatial relations, task predicates, and
+confidence. Spatial relations use a shared ontology and fixed participant
+order. Task-level judgments such as visual or pattern completion are accepted
+only when the current `TaskSpec.success` explicitly requests them. A second
+structured call produces a complete direct `AtomicAction` graph. Prompts
+request facts and graph JSON only; hidden chain-of-thought is neither requested
+nor stored.
+
+Image-space constraints may use normalized keypoints, masks, bboxes, and
+relative relations. The Grounder uses live depth and camera calibration to
+convert them to world targets. The SeedGraph never stores that result.
+
+## JIT Grounding
+
+Each action is grounded again immediately before planning/execution. Grounding
+therefore observes object displacement, current qpos, current held-object
+ownership, articulation state, and fresh camera measurements. Coordinated and
+handover actions are grounded as synchronized execution units. Automatic arm
+selection, collision checks, live arrangement slots, and current predicate
+semantics remain deterministic runtime responsibilities.
+
+Arm allocation uses the live right-to-left arm-base axis and the live table
+center. The preference therefore follows translated and rotated robot
+workspaces; live motion planning remains authoritative for reachability.
+
+Placement support is a relation, not an entity category. Static adaptation
+accepts rigid `physical_entity` targets without requiring a `support_surface`
+affordance. Articulations require a link-level runtime target interface and are
+rejected until that interface is available. Runtime evaluates
+`object_supported_by(payload, support, pose)` from live geometry and center of
+mass, applies the requested `orientation_goal`, and requires low motion across a
+bounded stability window. Successful relations form a per-environment support
+graph that is checked for cycles and revalidated at task completion.
+
+Orientation is compiled into hard `align_axis` or `match_rotation` terms plus a
+separate minimum-rotation planning preference. An omitted orientation request
+adds no hard acceptance term; `preserve` remains an explicit full-rotation
+contract for persisted bundles, while `upright` constrains only the requested
+local axis and declares whether that axis is directed. Grounding and runtime
+verification consume the same compiled contract so reachability search cannot
+silently relax a required terminal orientation. With no hard term, a live
+upright state may still select upright-preserving yaw candidates as a planning
+preference; this follows current state and automatically stops after that state
+is invalidated rather than becoming a sticky success requirement.
+
+Grasp generation keeps support-plane collision filtering as its strict first
+pass. If diagnostics show that this heuristic alone exhausted otherwise
+object-collision-free candidates, Action Engine retries without the heuristic;
+the relaxed candidates still pass through the live robot and scene collision
+planner before execution. This avoids treating a local support-plane proxy as
+a proof of scene-level infeasibility, including for objects already held above
+the support surface.
+
+Grounding samples bounded support-relative placement poses. Planning failures
+try the next pose before release; instability after release requires a fresh
+grasp and an unused pose. The recovery keeps the original actor contract, and
+its edges and failure provenance are recorded separately from the primary
+attempt.
+
+## Mainline Planning Contract
+
+The runtime keeps only an Action Engine-local `ExecutionState` for full-robot
+qpos and held-object relations. Each plan converts that state to the mainline
+`PlanningContext` (`RobotObservation`, `TaskState`, and `SceneSnapshot`) and
+submits an `ActionInvocation` to `AtomicActionEngine`. The returned
+`StateDelta` remains speculative until physical and semantic verification; only
+verified vectorized rows are committed.
+
+`AtomicActionAdapter` accepts a shared `SceneProvider` and otherwise builds a
+`RigidObjectSceneProvider` from live simulation entities. Planning snapshots now
+carry monotonic timestamps and material-change scene/collision revisions. The
+adapter also exposes `start_session(...)` for callers adopting
+`ExecutionSession`; the existing compound and per-arm merged trajectory
+scheduler remains as the compatibility execution path.
+
+Single-arm arm motion uses cuRobo `motion_gen` by default. Hand-only and
+coordinated dual-arm actions use `ik_interp`, because mainline coordinated
+primitives do not support cuRobo motion generation. A failed single-arm cuRobo
+row may fall back to `ik_interp` without replacing successful rows. Generated
+background objects form the static cuRobo collision world; dynamic obstacles
+are an explicit runtime-policy opt-in.
+
+Generated mesh objects carry V-HACD settings in both the current shape-level
+schema and legacy top-level fields. Before antipodal grasp construction, the
+runtime prepares a checksummed V-HACD payload at the shared collision-checker
+cache path so the unchanged mainline checker does not silently recompute CoACD.
+Grasp generation samples multiple deviated approach directions and filters them
+through the existing gripper collision model. Safety retreat planning searches
+a bounded set of live height and baseward targets instead of treating one exact
+height as a geometric reachability certificate.
+
+## A/B Evaluation
+
+Test mode retains both candidates. `run_strict_ab` creates distinct offline and
+online environments with the same task, scene configuration, seed, Grounder,
+verifiers, and retry policy. Both environments reset before execution and a
+digest over robot qpos and object state must match exactly; a mismatch aborts
+before either branch executes.
+
+Artifacts are written under `offline/` and `online/`, with a shared
+`comparison.json`. The comparison records graph hashes/differences, action and
+path lengths, success, retries, recoveries, revisions, latency, record paths,
+and planner/VLM metadata supplied by each candidate.
+
+L4 A/B runs must supply a private-oracle evaluator. The built-in evaluator
+checks memory reconstruction, visual completion, pattern completion, numeric
+selection, functional placement, and stable/unobstructed goals from the final
+state only. The comparison labels whether success came from runtime step
+postconditions or the private oracle.
+
+## Dynamic Recovery
+
+The persisted `SeedGraph` is immutable. `RuntimeGraph` keeps a detached working
+copy and an ordered revision log. One failed `AtomicAction` can be freshly
+grounded and retried twice, for three total attempts, and only while its live
+precondition remains true.
+
+Failures use the bounded taxonomy `search_exhausted`, `plan_failed`,
+`grasp_missed`, `object_fallen`, `object_dropped`, and
+`postcondition_failed`. `search_exhausted` records the blocking edge, planning
+stage, strategy, finite budget, and observed evidence; it does not claim that a
+target is geometrically unreachable. Known recoverable states can insert a
+complete `role=recovery` TaskGroup, such as an E2 upright group. Recovery keeps
+the failed TaskGroup's actor contract, and primary, recovery, and replay events
+are recorded separately. After recovery, the selected route replans only the
+unfinished suffix. Offline and online dynamic replanners are explicit,
+separate modes. Revision, recovery-action, transition, and retry budgets bound
+every loop.
+
+## Selection And Fusion
+
+Product mode statically scores offline and online candidates using schema
+validity, capability availability, UID validity, task coverage, visual
+confidence, and estimated action cost. Exact mature-template matches favor the
+offline route; L4 visual tasks favor sufficiently confident online results.
+
+Fusion is conservative. It may choose only complete `TaskGroup` units, rewires
+dependencies at group boundaries, and rejects unordered state changes to the
+same object. It never splits one E instance across candidates.
+
+## Artifacts
+
+A normal generated bundle contains:
+
+- `task_spec.json`
+- `scene_requirements.json`
+- `seed_task_graph.json`
+- `seed_task_graph.png`
+- `agent_config.json`
+- `fast_gym_config.json`
+
+Strict A/B adds branch-local graph/result artifacts and `comparison.json`.
+Review graphs, runtime records, and videos never become execution inputs.
+
+`prepare` lowers and preflights resolved semantic candidates in selection order.
+A candidate-local lowering, symbolic planning, or preflight error rejects only
+that candidate. If no resolved candidate is executable, the transaction
+publishes `preparation_failure.json` with each attempted draft, verified
+bindings, available grounded plan, failure stage, and exception instead of
+leaving an older successful bundle in place.
+
+## Invariants
+
+- SeedGraph nodes are direct AtomicActions, not E-level operators.
+- Lowering uses original instruction-step order as the stable tie-break among
+ dependency-ready steps. Independent steps remain independent; the contract
+ linker serializes only actual resource conflicts.
+- E labels are subgraph grouping semantics only.
+- Planning artifacts contain no grounded motion coordinates.
+- Online planning never receives the private oracle.
+- Runtime uses one capability registry for preflight, Grounding, config
+ construction, execution, verification policy, and recovery policy.
+- Required arms are never silently replaced.
+- Failed or inactive vectorized rows preserve their last valid state.
+- Current five task families preserve their v1 AtomicAction topology and live
+ Grounding behavior after regeneration.
diff --git a/embodichain/gen_sim/task_engine/__init__.py b/embodichain/gen_sim/task_engine/__init__.py
index 1c74c8371..fb67acb9e 100644
--- a/embodichain/gen_sim/task_engine/__init__.py
+++ b/embodichain/gen_sim/task_engine/__init__.py
@@ -18,6 +18,8 @@
from __future__ import annotations
+from typing import Any
+
from .contracts import (
FORBIDDEN_SEMANTIC_GRAPH_FIELDS,
PLANNER_ROUTES,
@@ -68,6 +70,33 @@
task_contract,
task_success_type,
)
+from .config import (
+ TASK_ENGINE_DEFAULTS_SCHEMA,
+ TaskEngineExecutionCfg,
+ TaskEnginePlanningCfg,
+ TaskEngineWorkflowCfg,
+ load_task_engine_config,
+)
+from .state_machine import (
+ StageStatus,
+ TaskEngineState,
+ WorkflowStage,
+ complete_stage,
+ fail_stage,
+ initial_state,
+ replay_events,
+ skip_stage,
+ start_stage,
+)
+from .workflow_contracts import (
+ TASK_RUN_REQUEST_SCHEMA,
+ SceneInputKind,
+ TaskRunRequest,
+ scene_input_kind,
+ validate_scene_history_root,
+ validate_scene_output_separation,
+ validate_task_run_request,
+)
__all__ = [
"FORBIDDEN_SEMANTIC_GRAPH_FIELDS",
@@ -114,4 +143,62 @@
"task_spec_hash",
"task_success_type",
"validate_planner_projection",
+ "TASK_RUN_REQUEST_SCHEMA",
+ "TASK_ENGINE_DEFAULTS_SCHEMA",
+ "SceneInputKind",
+ "SceneAnalysis",
+ "SceneEngineBackend",
+ "SceneRevision",
+ "StageStatus",
+ "TaskEngineState",
+ "TaskEngineExecutionCfg",
+ "TaskEnginePlanningCfg",
+ "TaskEngineWorkflowCfg",
+ "TaskEngineRunResult",
+ "TaskEngineWorkflow",
+ "TaskRunRequest",
+ "WorkflowStage",
+ "TASK_ENGINE_RUN_MANIFEST_SCHEMA",
+ "SubprocessActionExecutor",
+ "complete_stage",
+ "fail_stage",
+ "initial_state",
+ "load_task_engine_config",
+ "replay_events",
+ "task_contract",
+ "task_spec_hash",
+ "task_success_type",
+ "scene_input_kind",
+ "validate_scene_history_root",
+ "scene_blueprint_objects",
+ "skip_stage",
+ "start_stage",
+ "validate_scene_output_separation",
+ "validate_task_run_request",
]
+
+_SCENE_BACKEND_EXPORTS = {
+ "SceneAnalysis",
+ "SceneEngineBackend",
+ "SceneRevision",
+ "scene_blueprint_objects",
+}
+_WORKFLOW_EXPORTS = {
+ "TASK_ENGINE_RUN_MANIFEST_SCHEMA",
+ "SubprocessActionExecutor",
+ "TaskEngineRunResult",
+ "TaskEngineWorkflow",
+}
+
+
+def __getattr__(name: str) -> Any:
+ """Load orchestration entry points lazily to avoid engine import cycles."""
+ if name in _SCENE_BACKEND_EXPORTS:
+ from . import scene_backend
+
+ return getattr(scene_backend, name)
+ if name in _WORKFLOW_EXPORTS:
+ from . import workflow
+
+ return getattr(workflow, name)
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/embodichain/gen_sim/task_engine/__main__.py b/embodichain/gen_sim/task_engine/__main__.py
new file mode 100644
index 000000000..9e4f06dbe
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/__main__.py
@@ -0,0 +1,27 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Module entry point for Task Engine workflows."""
+
+from __future__ import annotations
+
+from .cli import main
+
+__all__ = ["main"]
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/embodichain/gen_sim/task_engine/_bundle_runner.py b/embodichain/gen_sim/task_engine/_bundle_runner.py
new file mode 100644
index 000000000..5bc80bd9b
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/_bundle_runner.py
@@ -0,0 +1,197 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Private subprocess boundary for executing one prepared Task Engine bundle."""
+
+from __future__ import annotations
+
+import argparse
+from contextlib import contextmanager
+import json
+from pathlib import Path
+import sys
+from typing import Any, Iterator, Sequence
+
+from embodichain.gen_sim.action_engine.agent import ActionAgent
+from embodichain.gen_sim.action_engine.protocol import (
+ AGENT_CONFIG_FILENAME,
+ EXECUTION_PROGRAM_FILENAME,
+ FAST_GYM_CONFIG_FILENAME,
+)
+from embodichain.gen_sim.action_engine.runtime import ExecutionReport
+
+from .orchestration.artifacts import (
+ GROUNDED_TASK_PLAN_FILENAME,
+ STATIC_SCENE_MANIFEST_FILENAME,
+ write_execution_report,
+)
+from .orchestration.contracts import validate_grounded_task_plan
+from .orchestration.scene_source import verify_scene_source_fingerprint
+
+__all__ = ["execute_bundle", "main"]
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Parse the private runner protocol and execute one bundle."""
+ parser = argparse.ArgumentParser(
+ prog="embodichain.gen_sim.task_engine._bundle_runner"
+ )
+ parser.add_argument("--bundle", required=True)
+ args, forwarded = parser.parse_known_args(argv)
+ return execute_bundle(args.bundle, forwarded)
+
+
+def execute_bundle(
+ bundle: str | Path,
+ forwarded: Sequence[str] = (),
+) -> int:
+ """Execute one prepared bundle through the existing Action Engine launcher."""
+ root = Path(bundle).expanduser().resolve()
+ if not root.is_dir():
+ raise FileNotFoundError(f"Bundle directory does not exist: {root}")
+ agent_config = root / AGENT_CONFIG_FILENAME
+ gym_config = root / FAST_GYM_CONFIG_FILENAME
+ for path in (agent_config, gym_config):
+ if not path.is_file():
+ raise FileNotFoundError(f"Bundle is missing required artifact: {path}")
+ task_id = _bundle_task_id(root, agent_config)
+ run_args = list(forwarded)
+ if run_args and run_args[0] == "--":
+ run_args.pop(0)
+ rejection = _preflight_bundle(
+ root,
+ agent_config=agent_config,
+ gym_config=gym_config,
+ forwarded=run_args,
+ )
+ if rejection is not None:
+ write_execution_report(root, rejection)
+ _print_json(rejection.as_mapping())
+ return 2
+ legacy_argv = [
+ "--task_name",
+ task_id,
+ "--gym_config",
+ str(gym_config),
+ "--agent_config",
+ str(agent_config),
+ "--task-engine-report",
+ *run_args,
+ ]
+ from embodichain.gen_sim.action_engine.cli import run_agent
+
+ with _temporary_argv(["run_agent", *legacy_argv]):
+ return int(run_agent.cli() or 0)
+
+
+def _preflight_bundle(
+ bundle: Path,
+ *,
+ agent_config: Path,
+ gym_config: Path,
+ forwarded: Sequence[str],
+) -> ExecutionReport | None:
+ static_manifest_path = bundle / STATIC_SCENE_MANIFEST_FILENAME
+ if static_manifest_path.is_file():
+ static_manifest = _read_json(static_manifest_path)
+ source = static_manifest.get("source", {})
+ if isinstance(source, dict) and isinstance(
+ source.get("source_fingerprint"), dict
+ ):
+ verify_scene_source_fingerprint(source["source_fingerprint"])
+ grounded_path = bundle / GROUNDED_TASK_PLAN_FILENAME
+ if not grounded_path.is_file():
+ return None
+ grounded = validate_grounded_task_plan(_read_json(grounded_path))
+ agent = _read_json(agent_config)
+ graph_value = agent.get("seed_task_graph", EXECUTION_PROGRAM_FILENAME)
+ if not isinstance(graph_value, str) or not graph_value:
+ raise ValueError("Bundle agent_config.seed_task_graph must be a path string.")
+ graph_path = Path(graph_value).expanduser()
+ if not graph_path.is_absolute():
+ graph_path = (bundle / graph_path).resolve()
+ else:
+ graph_path = graph_path.resolve()
+ if graph_path != bundle and bundle not in graph_path.parents:
+ raise ValueError("Bundle SeedGraph path escapes the bundle directory.")
+ if not graph_path.is_file():
+ raise FileNotFoundError(f"Bundle is missing SeedGraph: {graph_path}")
+ action_agent = ActionAgent()
+ try:
+ action_agent.preflight(
+ graph_path,
+ scene_manifest=grounded["scene_manifest"],
+ )
+ except (TypeError, ValueError, OSError) as exc:
+ return action_agent.rejection_report(
+ graph_path,
+ exc,
+ grounded_plan=grounded,
+ environment_count=_environment_count(gym_config, forwarded),
+ )
+ return None
+
+
+def _environment_count(gym_config: Path, forwarded: Sequence[str]) -> int:
+ value: Any = _read_json(gym_config).get("num_envs", 1)
+ for index, argument in enumerate(forwarded):
+ if argument == "--num_envs" and index + 1 < len(forwarded):
+ value = forwarded[index + 1]
+ elif argument.startswith("--num_envs="):
+ value = argument.partition("=")[2]
+ try:
+ return max(1, int(value))
+ except (TypeError, ValueError):
+ return 1
+
+
+def _bundle_task_id(bundle: Path, agent_config: Path) -> str:
+ grounded_path = bundle / GROUNDED_TASK_PLAN_FILENAME
+ if grounded_path.is_file():
+ task_id = _read_json(grounded_path).get("task_id")
+ else:
+ task_id = _read_json(agent_config).get("task_name")
+ if not isinstance(task_id, str) or not task_id.strip():
+ raise ValueError("Bundle does not declare a non-empty task ID.")
+ return task_id.strip()
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise ValueError(f"Unable to read JSON artifact {path}: {exc}") from exc
+ if not isinstance(value, dict):
+ raise ValueError(f"JSON artifact must contain an object: {path}")
+ return value
+
+
+@contextmanager
+def _temporary_argv(arguments: list[str]) -> Iterator[None]:
+ original = sys.argv
+ sys.argv = arguments
+ try:
+ yield
+ finally:
+ sys.argv = original
+
+
+def _print_json(value: Any) -> None:
+ print(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/embodichain/gen_sim/task_engine/cli.py b/embodichain/gen_sim/task_engine/cli.py
new file mode 100644
index 000000000..fec6de86a
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/cli.py
@@ -0,0 +1,246 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Unified CLI for complete Task Engine workflows."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import sys
+from typing import Any, Final, Sequence
+
+from .config import load_task_engine_config
+from .orchestration.scene_adapter import SceneAdapter
+from .run_directory import reserve_run_directory
+from .workflow import SubprocessActionExecutor, TaskEngineWorkflow
+from .workflow_contracts import (
+ TASK_RUN_REQUEST_SCHEMA,
+ validate_scene_history_root,
+ validate_scene_output_separation,
+)
+
+__all__ = ["build_parser", "main"]
+
+
+_ROBOT_PROFILES = (
+ "ur5",
+ "ur10",
+ "dual_ur5",
+ "dual_ur10",
+ "franka",
+ "dual_franka",
+)
+_MODES: Final = ("image", "image-edit", "scene", "scene-edit")
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Build the Task Engine parser."""
+ parser = argparse.ArgumentParser(
+ prog="embodichain task-engine",
+ description="Prepare, run, or complete one Scene and Action workflow.",
+ )
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ prepare_parser = subparsers.add_parser(
+ "prepare", help="Prepare a bundle without simulator execution."
+ )
+ _add_workflow_arguments(prepare_parser)
+ run_all_parser = subparsers.add_parser(
+ "run-all", help="Prepare and execute one complete workflow."
+ )
+ _add_workflow_arguments(run_all_parser)
+ run_parser = subparsers.add_parser(
+ "run", help="Execute an already prepared Task Engine bundle."
+ )
+ run_parser.add_argument("--bundle", required=True)
+ run_parser.add_argument("--output-root", required=True)
+ run_parser.add_argument("--config", default=None)
+ run_parser.add_argument("--seed", type=int, default=0)
+ run_parser.add_argument("--num-envs", type=int, default=None)
+ run_parser.add_argument("--dataset-saving", action="store_true")
+ return parser
+
+
+def _add_workflow_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("--mode", choices=_MODES, required=True)
+ parser.add_argument("--task-id", "--task_id", required=True)
+ instruction = parser.add_mutually_exclusive_group(required=True)
+ instruction.add_argument("--instruction")
+ instruction.add_argument("--task-file", "--task_file")
+ parser.add_argument("--image")
+ parser.add_argument("--scene")
+ parser.add_argument("--scene-edit", "--scene_edit", default=None)
+ parser.add_argument("--output-root", required=True)
+ parser.add_argument("--config", default=None)
+ parser.add_argument("--model", default=None)
+ parser.add_argument("--vlm-model", default=None)
+ parser.add_argument("--base-seed", type=int, default=0)
+ parser.add_argument(
+ "--dataset_saving",
+ action="store_true",
+ help="Opt in to the Gym project's dataset recorder during execution.",
+ )
+ parser.add_argument(
+ "--robot-profile",
+ choices=_ROBOT_PROFILES,
+ default="franka",
+ )
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Dispatch one Task Engine workflow command."""
+ parser = build_parser()
+ arguments = list(sys.argv[1:] if argv is None else argv)
+ if arguments and arguments[0] not in {
+ "prepare",
+ "run",
+ "run-all",
+ "-h",
+ "--help",
+ }:
+ arguments.insert(0, "run-all")
+ args = parser.parse_args(arguments)
+ if args.command == "run":
+ return _run_prepared_bundle(args)
+ return _run_workflow(
+ args,
+ execute=args.command == "run-all",
+ parser=parser,
+ )
+
+
+def _run_workflow(
+ args: argparse.Namespace,
+ *,
+ execute: bool,
+ parser: argparse.ArgumentParser,
+) -> int:
+ try:
+ image, scene, edit = _mode_inputs(args)
+ except ValueError as exc:
+ parser.error(str(exc))
+ if scene is not None:
+ validate_scene_history_root(scene, args.output_root)
+ instruction = _instruction(args)
+ adapter = SceneAdapter(model=args.model, robot_profile=args.robot_profile)
+ workflow = TaskEngineWorkflow(scene_adapter=adapter)
+ with reserve_run_directory(args.output_root) as allocation:
+ if scene is not None:
+ validate_scene_output_separation(scene, allocation.path)
+ result = workflow.run(
+ {
+ "schema_version": TASK_RUN_REQUEST_SCHEMA,
+ "task_id": args.task_id,
+ "task_instruction": instruction,
+ "image_path": image,
+ "gym_project": scene,
+ "scene_edit_prompt": edit,
+ "output_dir": allocation.path.as_posix(),
+ },
+ config_path=args.config,
+ model=args.model,
+ vlm_model=args.vlm_model,
+ base_seed=args.base_seed,
+ dataset_saving=args.dataset_saving,
+ run_id=allocation.run_id,
+ created_at=allocation.created_at,
+ execute=execute,
+ )
+ _print_json(
+ {
+ "run_id": allocation.run_id,
+ "status": result.status,
+ "failure_class": result.failure_class,
+ "output_dir": result.output_dir.as_posix(),
+ "manifest": result.manifest_path.as_posix(),
+ "final_bundle": (
+ None if result.final_bundle is None else result.final_bundle.as_posix()
+ ),
+ }
+ )
+ accepted = result.succeeded if execute else result.status == "prepared"
+ return 0 if accepted else 2
+
+
+def _run_prepared_bundle(args: argparse.Namespace) -> int:
+ _, _, execution_cfg = load_task_engine_config(args.config)
+ num_envs = execution_cfg.num_envs if args.num_envs is None else int(args.num_envs)
+ if num_envs < 1:
+ raise ValueError("num_envs must be positive.")
+ with reserve_run_directory(args.output_root) as allocation:
+ report = SubprocessActionExecutor()(
+ args.bundle,
+ allocation.path,
+ seed=int(args.seed),
+ num_envs=num_envs,
+ dataset_saving=bool(args.dataset_saving),
+ )
+ environments = report.get("environments", ())
+ successes = [
+ bool(item.get("success")) for item in environments if isinstance(item, dict)
+ ]
+ accepted = (
+ str(report.get("status")) not in {"rejected", "aborted"}
+ and len(successes) == num_envs
+ and sum(successes) >= execution_cfg.required_successes
+ )
+ _print_json(
+ {
+ "run_id": allocation.run_id,
+ "status": "succeeded" if accepted else "failed",
+ "output_dir": allocation.path.as_posix(),
+ "execution_report": report,
+ }
+ )
+ return 0 if accepted else 2
+
+
+def _instruction(args: argparse.Namespace) -> str:
+ instruction = (
+ str(args.instruction).strip()
+ if args.instruction is not None
+ else Path(args.task_file).expanduser().read_text(encoding="utf-8").strip()
+ )
+ if not instruction:
+ raise ValueError("Task instruction must not be empty.")
+ return instruction
+
+
+def _mode_inputs(args: argparse.Namespace) -> tuple[str | None, str | None, str | None]:
+ image = None if args.image is None else str(args.image).strip()
+ scene = None if args.scene is None else str(args.scene).strip()
+ edit = None if args.scene_edit is None else str(args.scene_edit).strip()
+ expected = {
+ "image": (True, False, False),
+ "image-edit": (True, False, True),
+ "scene": (False, True, False),
+ "scene-edit": (False, True, True),
+ }[args.mode]
+ actual = (bool(image), bool(scene), bool(edit))
+ if actual != expected:
+ raise ValueError(
+ f"mode={args.mode!r} requires image/scene/edit={expected}, got {actual}."
+ )
+ return image, scene, edit
+
+
+def _print_json(value: Any) -> None:
+ print(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/embodichain/gen_sim/task_engine/config.py b/embodichain/gen_sim/task_engine/config.py
new file mode 100644
index 000000000..60cda86ef
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/config.py
@@ -0,0 +1,190 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Configuration owned by Task Engine orchestration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from importlib.resources import files
+from pathlib import Path
+from typing import Any, Final
+
+import yaml
+
+from embodichain.utils import configclass
+
+__all__ = [
+ "TASK_ENGINE_DEFAULTS_SCHEMA",
+ "TaskEngineExecutionCfg",
+ "TaskEnginePlanningCfg",
+ "TaskEngineWorkflowCfg",
+ "load_task_engine_config",
+]
+
+TASK_ENGINE_DEFAULTS_SCHEMA: Final = "embodichain.task-engine-defaults/v1"
+
+
+@configclass
+class TaskEngineExecutionCfg:
+ """Success policy for vectorized simulator execution."""
+
+ num_envs: int = 1
+ success_policy: str = "any"
+ min_successful_envs: int = 1
+
+ def __post_init__(self) -> None:
+ if (
+ isinstance(self.num_envs, bool)
+ or not isinstance(self.num_envs, int)
+ or self.num_envs < 1
+ ):
+ raise ValueError("num_envs must be a positive integer.")
+ if self.success_policy not in {"any", "all", "at_least"}:
+ raise ValueError("success_policy must be any, all, or at_least.")
+ if (
+ isinstance(self.min_successful_envs, bool)
+ or not isinstance(self.min_successful_envs, int)
+ or not 1 <= self.min_successful_envs <= self.num_envs
+ ):
+ raise ValueError("min_successful_envs must be in [1, num_envs].")
+ if self.success_policy == "any" and self.min_successful_envs != 1:
+ raise ValueError("success_policy=any requires min_successful_envs=1.")
+ if self.success_policy == "all" and self.min_successful_envs != self.num_envs:
+ raise ValueError(
+ "success_policy=all requires min_successful_envs=num_envs."
+ )
+
+ @property
+ def required_successes(self) -> int:
+ """Return the number of successful replicas required for acceptance."""
+ if self.success_policy == "all":
+ return self.num_envs
+ if self.success_policy == "any":
+ return 1
+ return self.min_successful_envs
+
+
+@configclass
+class TaskEngineWorkflowCfg:
+ """Conservative first-version orchestration limits.
+
+ The packaged YAML owns retry limits so deployment testing can tune them
+ without changing the orchestration implementation.
+ """
+
+ max_parallel_workers: int = 2
+ max_scene_attempts: int = 2
+ max_action_attempts: int = 3
+
+ def __post_init__(self) -> None:
+ for field_name in (
+ "max_parallel_workers",
+ "max_scene_attempts",
+ "max_action_attempts",
+ ):
+ value = getattr(self, field_name)
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+ raise ValueError(f"{field_name} must be a positive integer.")
+
+
+@configclass
+class TaskEnginePlanningCfg:
+ """Task interpretation and Action bundle generation defaults."""
+
+ candidate_count: int = 3
+ planning_mode: str = "offline"
+ max_episodes: int = 1
+ max_episode_steps: int = 4000
+
+ def __post_init__(self) -> None:
+ for field_name in (
+ "candidate_count",
+ "max_episodes",
+ "max_episode_steps",
+ ):
+ value = getattr(self, field_name)
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+ raise ValueError(f"{field_name} must be a positive integer.")
+ if self.planning_mode not in {"offline", "ab"}:
+ raise ValueError("planning_mode must be offline or ab.")
+
+
+def load_task_engine_config(
+ path: str | Path | None = None,
+) -> tuple[
+ TaskEngineWorkflowCfg,
+ TaskEnginePlanningCfg,
+ TaskEngineExecutionCfg,
+]:
+ """Load strict Task Engine defaults from YAML.
+
+ Args:
+ path: Optional override YAML. The packaged defaults are used when omitted.
+
+ Returns:
+ Validated workflow, planning, and execution configurations.
+
+ Raises:
+ TypeError: If a configuration section is not a mapping.
+ ValueError: If the YAML schema or fields are invalid.
+ """
+ content = (
+ Path(path).expanduser().resolve().read_text(encoding="utf-8")
+ if path is not None
+ else files(__package__).joinpath("defaults.yaml").read_text(encoding="utf-8")
+ )
+ raw = yaml.safe_load(content)
+ if not isinstance(raw, Mapping):
+ raise TypeError("Task Engine configuration must be a mapping.")
+ expected = {"schema_version", "workflow", "planning", "execution"}
+ if set(raw) != expected:
+ raise ValueError("Task Engine configuration fields are invalid.")
+ if raw.get("schema_version") != TASK_ENGINE_DEFAULTS_SCHEMA:
+ raise ValueError("Task Engine configuration schema_version is invalid.")
+ workflow = _mapping(raw.get("workflow"), "workflow")
+ planning = _mapping(raw.get("planning"), "planning")
+ execution = _mapping(raw.get("execution"), "execution")
+ if set(workflow) != {
+ "max_parallel_workers",
+ "max_scene_attempts",
+ "max_action_attempts",
+ }:
+ raise ValueError("Task Engine workflow configuration fields are invalid.")
+ if set(planning) != {
+ "candidate_count",
+ "planning_mode",
+ "max_episodes",
+ "max_episode_steps",
+ }:
+ raise ValueError("Task Engine planning configuration fields are invalid.")
+ if set(execution) != {
+ "num_envs",
+ "success_policy",
+ "min_successful_envs",
+ }:
+ raise ValueError("Task Engine execution configuration fields are invalid.")
+ return (
+ TaskEngineWorkflowCfg(**workflow),
+ TaskEnginePlanningCfg(**planning),
+ TaskEngineExecutionCfg(**execution),
+ )
+
+
+def _mapping(value: Any, field_name: str) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise TypeError(f"Task Engine {field_name} configuration must be a mapping.")
+ return dict(value)
diff --git a/embodichain/gen_sim/task_engine/defaults.yaml b/embodichain/gen_sim/task_engine/defaults.yaml
new file mode 100644
index 000000000..33169e461
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/defaults.yaml
@@ -0,0 +1,33 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+schema_version: embodichain.task-engine-defaults/v1
+
+workflow:
+ max_parallel_workers: 2
+ max_scene_attempts: 2
+ max_action_attempts: 3
+
+planning:
+ candidate_count: 3
+ planning_mode: offline
+ max_episodes: 1
+ max_episode_steps: 4000
+
+execution:
+ num_envs: 1
+ success_policy: any
+ min_successful_envs: 1
diff --git a/embodichain/gen_sim/task_engine/orchestration/__init__.py b/embodichain/gen_sim/task_engine/orchestration/__init__.py
new file mode 100644
index 000000000..ed3a9539c
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/orchestration/__init__.py
@@ -0,0 +1,109 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Task-owned orchestration across task, scene, and action engines."""
+
+from __future__ import annotations
+
+from embodichain.gen_sim.action_engine.agent import ActionAgent, ActionGraph
+from embodichain.gen_sim.action_engine.runtime import ExecutionReport
+
+from .artifacts import (
+ ArtifactTransaction,
+ CONSERVATIVE_SCENE_GRAPH_FILENAME,
+ TaskEngineArtifactPaths,
+ FEASIBILITY_REPORT_FILENAME,
+ PREPARATION_FAILURE_FILENAME,
+ STATIC_SCENE_MANIFEST_FILENAME,
+ task_engine_artifact_paths,
+ write_execution_report,
+ write_preparation_failure,
+)
+from .contracts import (
+ BINDING_REPORT_SCHEMA,
+ EXECUTION_REPORT_SCHEMA,
+ GROUNDED_TASK_PLAN_SCHEMA,
+ ROLE_BINDINGS_SCHEMA,
+ SCENE_MANIFEST_SCHEMA,
+ BindingReport,
+ GroundedTaskPlan,
+ RoleBindings,
+ SceneManifest,
+)
+from .coordinator import (
+ TaskEngineCoordinator,
+ PreparationResult,
+ build_grounded_task_plan,
+ lower_task_candidate,
+)
+from .scene_adapter import (
+ CandidateSelection,
+ SceneAdaptation,
+ SceneAdapter,
+ SceneAdapterProtocolError,
+)
+from .scene_source import (
+ SceneSourceFingerprint,
+ SceneSourceRef,
+ fingerprint_scene_source,
+ verify_scene_source_fingerprint,
+)
+from .legacy_scene import (
+ LEGACY_SCENE_CONVERSION_SCHEMA,
+ LegacySceneRevision,
+ convert_legacy_gym_project,
+ restore_locked_scene_entities,
+)
+
+__all__ = [
+ "ActionAgent",
+ "ActionGraph",
+ "ArtifactTransaction",
+ "CONSERVATIVE_SCENE_GRAPH_FILENAME",
+ "BINDING_REPORT_SCHEMA",
+ "BindingReport",
+ "TaskEngineArtifactPaths",
+ "TaskEngineCoordinator",
+ "EXECUTION_REPORT_SCHEMA",
+ "ExecutionReport",
+ "FEASIBILITY_REPORT_FILENAME",
+ "GROUNDED_TASK_PLAN_SCHEMA",
+ "GroundedTaskPlan",
+ "PREPARATION_FAILURE_FILENAME",
+ "PreparationResult",
+ "ROLE_BINDINGS_SCHEMA",
+ "RoleBindings",
+ "SCENE_MANIFEST_SCHEMA",
+ "STATIC_SCENE_MANIFEST_FILENAME",
+ "SceneAdaptation",
+ "CandidateSelection",
+ "SceneAdapter",
+ "SceneAdapterProtocolError",
+ "SceneManifest",
+ "SceneSourceFingerprint",
+ "SceneSourceRef",
+ "build_grounded_task_plan",
+ "task_engine_artifact_paths",
+ "fingerprint_scene_source",
+ "LEGACY_SCENE_CONVERSION_SCHEMA",
+ "LegacySceneRevision",
+ "convert_legacy_gym_project",
+ "restore_locked_scene_entities",
+ "verify_scene_source_fingerprint",
+ "lower_task_candidate",
+ "write_execution_report",
+ "write_preparation_failure",
+]
diff --git a/embodichain/gen_sim/task_engine/orchestration/artifacts.py b/embodichain/gen_sim/task_engine/orchestration/artifacts.py
new file mode 100644
index 000000000..b941c86dc
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/orchestration/artifacts.py
@@ -0,0 +1,331 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Transactional publication for Task Engine artifacts."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+import json
+import os
+from pathlib import Path
+import shutil
+import tempfile
+from typing import Any
+
+from embodichain.gen_sim.action_engine.runtime import (
+ EXECUTION_REPORT_FILENAME,
+ write_execution_report as _write_execution_report,
+)
+
+__all__ = [
+ "BINDING_REPORT_FILENAME",
+ "CONSERVATIVE_SCENE_GRAPH_FILENAME",
+ "EXECUTION_REPORT_FILENAME",
+ "GROUNDED_TASK_PLAN_FILENAME",
+ "FEASIBILITY_REPORT_FILENAME",
+ "FINAL_SCENE_INSPECTION_FILENAME",
+ "PREPARATION_FAILURE_FILENAME",
+ "ROLE_BINDINGS_FILENAME",
+ "SCENE_MANIFEST_FILENAME",
+ "STATIC_SCENE_MANIFEST_FILENAME",
+ "SUCCESS_SPEC_FILENAME",
+ "TASK_CANDIDATE_SET_FILENAME",
+ "TASK_DRAFT_FILENAME",
+ "SCENE_REQUEST_FILENAME",
+ "ArtifactTransaction",
+ "TaskEngineArtifactPaths",
+ "task_engine_artifact_paths",
+ "write_task_engine_artifacts",
+ "write_execution_report",
+ "write_preparation_failure",
+]
+
+
+TASK_CANDIDATE_SET_FILENAME = "task_candidate_set.json"
+TASK_DRAFT_FILENAME = "task_draft.json"
+SCENE_REQUEST_FILENAME = "scene_request.json"
+SUCCESS_SPEC_FILENAME = "success_spec.json"
+SCENE_MANIFEST_FILENAME = "scene_manifest.json"
+STATIC_SCENE_MANIFEST_FILENAME = "static_scene_manifest.json"
+CONSERVATIVE_SCENE_GRAPH_FILENAME = "conservative_scene_graph.json"
+ROLE_BINDINGS_FILENAME = "role_bindings.json"
+BINDING_REPORT_FILENAME = "binding_report.json"
+FEASIBILITY_REPORT_FILENAME = "feasibility_report.json"
+FINAL_SCENE_INSPECTION_FILENAME = "final_scene_inspection.json"
+GROUNDED_TASK_PLAN_FILENAME = "grounded_task_plan.json"
+PREPARATION_FAILURE_FILENAME = "preparation_failure.json"
+
+
+@dataclass(frozen=True)
+class TaskEngineArtifactPaths:
+ """Canonical Task Engine paths rooted at one published bundle."""
+
+ root: Path
+ task_candidate_set: Path
+ task_draft: Path
+ scene_request: Path
+ success_spec: Path
+ scene_manifest: Path
+ static_scene_manifest: Path
+ conservative_scene_graph: Path
+ role_bindings: Path
+ binding_report: Path
+ feasibility_report: Path
+ final_scene_inspection: Path
+ grounded_task_plan: Path
+ preparation_failure: Path
+ execution_report: Path
+
+
+def task_engine_artifact_paths(
+ output_dir: str | Path,
+) -> TaskEngineArtifactPaths:
+ """Return all Task Engine paths without creating the directory."""
+ root = Path(output_dir).expanduser().resolve()
+ return TaskEngineArtifactPaths(
+ root=root,
+ task_candidate_set=root / TASK_CANDIDATE_SET_FILENAME,
+ task_draft=root / TASK_DRAFT_FILENAME,
+ scene_request=root / SCENE_REQUEST_FILENAME,
+ success_spec=root / SUCCESS_SPEC_FILENAME,
+ scene_manifest=root / SCENE_MANIFEST_FILENAME,
+ static_scene_manifest=root / STATIC_SCENE_MANIFEST_FILENAME,
+ conservative_scene_graph=root / CONSERVATIVE_SCENE_GRAPH_FILENAME,
+ role_bindings=root / ROLE_BINDINGS_FILENAME,
+ binding_report=root / BINDING_REPORT_FILENAME,
+ feasibility_report=root / FEASIBILITY_REPORT_FILENAME,
+ final_scene_inspection=root / FINAL_SCENE_INSPECTION_FILENAME,
+ grounded_task_plan=root / GROUNDED_TASK_PLAN_FILENAME,
+ preparation_failure=root / PREPARATION_FAILURE_FILENAME,
+ execution_report=root / EXECUTION_REPORT_FILENAME,
+ )
+
+
+class ArtifactTransaction:
+ """Build a complete bundle beside its destination and publish it by rename."""
+
+ def __init__(self, output_dir: str | Path, *, overwrite: bool = False) -> None:
+ raw = Path(output_dir).expanduser()
+ self.output_dir = (
+ (Path.cwd() / raw).resolve() if not raw.is_absolute() else raw.resolve()
+ )
+ self.overwrite = bool(overwrite)
+ self.staging_dir: Path | None = None
+ self._committed = False
+
+ def __enter__(self) -> "ArtifactTransaction":
+ destination = self.output_dir
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if destination.exists() and not self.overwrite:
+ raise FileExistsError(
+ f"Output directory already exists: {destination}. "
+ "Pass overwrite=True to replace it."
+ )
+ self.staging_dir = Path(
+ tempfile.mkdtemp(
+ prefix=f".{destination.name}.staging-",
+ dir=destination.parent,
+ )
+ )
+ return self
+
+ def commit(self) -> Path:
+ """Rewrite staging-local absolute paths, then atomically publish."""
+ if self.staging_dir is None:
+ raise RuntimeError("ArtifactTransaction has not been entered.")
+ if self._committed:
+ raise RuntimeError("ArtifactTransaction has already been committed.")
+ staging = self.staging_dir
+ destination = self.output_dir
+ _relocate_json_paths(staging, destination)
+
+ backup: Path | None = None
+ if destination.exists():
+ if not self.overwrite:
+ raise FileExistsError(
+ f"Output directory already exists: {destination}."
+ )
+ backup = Path(
+ tempfile.mkdtemp(
+ prefix=f".{destination.name}.backup-",
+ dir=destination.parent,
+ )
+ )
+ backup.rmdir()
+ os.replace(destination, backup)
+ try:
+ os.replace(staging, destination)
+ except BaseException:
+ if backup is not None and backup.exists() and not destination.exists():
+ os.replace(backup, destination)
+ raise
+ else:
+ self._committed = True
+ self.staging_dir = None
+ if backup is not None:
+ _remove_path(backup)
+ _fsync_directory(destination.parent)
+ return destination
+
+ def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool:
+ if self.staging_dir is not None and self.staging_dir.exists():
+ shutil.rmtree(self.staging_dir)
+ return False
+
+
+def write_task_engine_artifacts(
+ output_dir: str | Path,
+ *,
+ candidate_set: Mapping[str, Any],
+ scene_manifest: Mapping[str, Any] | None,
+ role_bindings: Mapping[str, Any] | None,
+ binding_report: Mapping[str, Any],
+ grounded_task_plan: Mapping[str, Any] | None = None,
+ static_scene_manifest: Mapping[str, Any] | None = None,
+ conservative_scene_graph: Mapping[str, Any] | None = None,
+ feasibility_report: Mapping[str, Any] | None = None,
+ final_scene_inspection: Mapping[str, Any] | None = None,
+) -> TaskEngineArtifactPaths:
+ """Write Task Engine protocols into an unpublished staging directory.
+
+ An unsuccessful adaptation can omit SceneManifest and RoleBindings rather
+ than publishing protocol filenames whose payloads do not satisfy their
+ schemas.
+ """
+ paths = task_engine_artifact_paths(output_dir)
+ paths.root.mkdir(parents=True, exist_ok=True)
+ _write_json(paths.task_candidate_set, candidate_set)
+ if scene_manifest is not None:
+ _write_json(paths.scene_manifest, scene_manifest)
+ if static_scene_manifest is not None:
+ _write_json(paths.static_scene_manifest, static_scene_manifest)
+ if conservative_scene_graph is not None:
+ _write_json(paths.conservative_scene_graph, conservative_scene_graph)
+ if role_bindings is not None:
+ _write_json(paths.role_bindings, role_bindings)
+ _write_json(paths.binding_report, binding_report)
+ if feasibility_report is not None:
+ _write_json(paths.feasibility_report, feasibility_report)
+ if final_scene_inspection is not None:
+ _write_json(paths.final_scene_inspection, final_scene_inspection)
+
+ if grounded_task_plan is not None:
+ _write_json(paths.grounded_task_plan, grounded_task_plan)
+ _write_json(paths.task_draft, grounded_task_plan["task_draft"])
+ candidate_id = grounded_task_plan["selected_candidate_id"]
+ selected = next(
+ candidate
+ for candidate in candidate_set["candidates"]
+ if candidate["candidate_id"] == candidate_id
+ )
+ _write_json(paths.scene_request, selected["scene_request"])
+ _write_json(paths.success_spec, grounded_task_plan["success_spec"])
+ return paths
+
+
+def write_execution_report(output_dir: str | Path, value: Any) -> Path:
+ """Publish through the Action Engine-owned report boundary."""
+ return _write_execution_report(output_dir, value)
+
+
+def write_preparation_failure(output_dir: str | Path, value: Any) -> Path:
+ """Write a strict-JSON audit for a failed candidate planning transaction."""
+ path = task_engine_artifact_paths(output_dir).preparation_failure
+ path.parent.mkdir(parents=True, exist_ok=True)
+ _write_json(path, value)
+ return path
+
+
+def _write_json(path: Path, value: Any) -> None:
+ try:
+ payload = (
+ json.dumps(
+ value,
+ ensure_ascii=False,
+ indent=2,
+ sort_keys=False,
+ allow_nan=False,
+ )
+ + "\n"
+ ).encode("utf-8")
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"Artifact {path.name} is not strict JSON data.") from exc
+ temporary: Path | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="wb",
+ dir=path.parent,
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ delete=False,
+ ) as stream:
+ temporary = Path(stream.name)
+ stream.write(payload)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ temporary = None
+ finally:
+ if temporary is not None:
+ temporary.unlink(missing_ok=True)
+
+
+def _relocate_json_paths(staging: Path, destination: Path) -> None:
+ """Replace staging-root absolute paths embedded by the legacy generator."""
+ source_prefix = staging.resolve().as_posix()
+ destination_prefix = destination.resolve().as_posix()
+
+ def relocate(value: Any) -> Any:
+ if isinstance(value, str):
+ if value == source_prefix:
+ return destination_prefix
+ if value.startswith(source_prefix + "/"):
+ return destination_prefix + value[len(source_prefix) :]
+ return value
+ if isinstance(value, list):
+ return [relocate(item) for item in value]
+ if isinstance(value, dict):
+ return {key: relocate(item) for key, item in value.items()}
+ return value
+
+ for path in staging.rglob("*.json"):
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Generated artifact is invalid JSON: {path}") from exc
+ relocated = relocate(value)
+ if relocated != value:
+ _write_json(path, relocated)
+
+
+def _remove_path(path: Path) -> None:
+ if path.is_dir() and not path.is_symlink():
+ shutil.rmtree(path)
+ else:
+ path.unlink(missing_ok=True)
+
+
+def _fsync_directory(path: Path) -> None:
+ try:
+ descriptor = os.open(path, os.O_RDONLY)
+ except OSError:
+ return
+ try:
+ os.fsync(descriptor)
+ finally:
+ os.close(descriptor)
diff --git a/embodichain/gen_sim/task_engine/orchestration/contracts.py b/embodichain/gen_sim/task_engine/orchestration/contracts.py
new file mode 100644
index 000000000..5c38f284c
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/orchestration/contracts.py
@@ -0,0 +1,647 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Strict cross-engine contracts for scene binding and orchestration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+import json
+import math
+from typing import Any, TypeAlias
+
+from embodichain.gen_sim.action_engine.domain import (
+ validate_scene_requirements,
+ validate_task_spec,
+)
+from embodichain.gen_sim.action_engine.runtime import (
+ EXECUTION_REPORT_SCHEMA,
+ validate_execution_report,
+)
+from embodichain.gen_sim.task_engine import (
+ SCENE_REQUEST_SCHEMA,
+ SUCCESS_SPEC_SCHEMA,
+ TASK_CANDIDATE_SET_SCHEMA,
+ TASK_DRAFT_SCHEMA,
+ SceneRequest,
+ SuccessSpec,
+ TaskCandidate,
+ TaskCandidateSet,
+ TaskDraft,
+ canonical_hash,
+ task_success_type,
+ validate_scene_request,
+ validate_success_spec,
+ validate_task_candidate,
+ validate_task_candidate_set,
+ validate_task_draft,
+)
+
+__all__ = [
+ "BINDING_REPORT_SCHEMA",
+ "EXECUTION_REPORT_SCHEMA",
+ "GROUNDED_TASK_PLAN_SCHEMA",
+ "ROLE_BINDINGS_SCHEMA",
+ "SCENE_MANIFEST_SCHEMA",
+ "SCENE_REQUEST_SCHEMA",
+ "SUCCESS_SPEC_SCHEMA",
+ "TASK_CANDIDATE_SET_SCHEMA",
+ "TASK_DRAFT_SCHEMA",
+ "BindingReport",
+ "ExecutionReport",
+ "GroundedTaskPlan",
+ "RoleBindings",
+ "SceneManifest",
+ "SceneRequest",
+ "SuccessSpec",
+ "TaskCandidate",
+ "TaskCandidateSet",
+ "TaskDraft",
+ "canonical_hash",
+ "validate_binding_report",
+ "validate_execution_report",
+ "validate_grounded_task_plan",
+ "validate_role_bindings",
+ "validate_scene_manifest",
+ "validate_scene_request",
+ "validate_success_spec",
+ "validate_task_candidate",
+ "validate_task_candidate_set",
+ "validate_task_draft",
+]
+
+SCENE_MANIFEST_SCHEMA = "action_engine_scene_manifest_v1"
+ROLE_BINDINGS_SCHEMA = "action_engine_role_bindings_v1"
+BINDING_REPORT_SCHEMA = "action_engine_binding_report_v1"
+GROUNDED_TASK_PLAN_SCHEMA = "action_engine_grounded_task_plan_v1"
+SceneManifest: TypeAlias = dict[str, Any]
+RoleBindings: TypeAlias = dict[str, Any]
+BindingReport: TypeAlias = dict[str, Any]
+GroundedTaskPlan: TypeAlias = dict[str, Any]
+ExecutionReport: TypeAlias = dict[str, Any]
+
+
+def validate_scene_manifest(value: Mapping[str, Any]) -> SceneManifest:
+ result = _mapping(value, "SceneManifest")
+ _keys(
+ result,
+ {"schema_version", "scene_id", "source_format", "robot_profile", "objects"},
+ "SceneManifest",
+ )
+ _schema(result, SCENE_MANIFEST_SCHEMA, "SceneManifest")
+ for key in ("scene_id", "source_format", "robot_profile"):
+ result[key] = _nonempty(result.get(key), f"SceneManifest.{key}")
+ object_keys = {
+ "uid",
+ "role",
+ "name",
+ "description",
+ "category",
+ "color",
+ "affordances",
+ "initial_state",
+ "attributes",
+ }
+ objects = []
+ for index, raw in enumerate(
+ _sequence(result.get("objects"), "SceneManifest.objects")
+ ):
+ context = f"SceneManifest.objects[{index}]"
+ item = _mapping(raw, context)
+ _keys(item, object_keys, context)
+ item["uid"] = _nonempty(item.get("uid"), f"{context}.uid")
+ for key in ("role", "name", "description", "category"):
+ item[key] = _string(item.get(key), f"{context}.{key}")
+ if item.get("color") is not None:
+ item["color"] = _string(item.get("color"), f"{context}.color")
+ item["affordances"] = _strings(
+ item.get("affordances"), f"{context}.affordances"
+ )
+ item["initial_state"] = _mapping(
+ item.get("initial_state"), f"{context}.initial_state"
+ )
+ item["attributes"] = _mapping(item.get("attributes"), f"{context}.attributes")
+ objects.append(item)
+ _unique([item["uid"] for item in objects], "SceneManifest object UIDs")
+ result["objects"] = objects
+ _json_safe(result, "SceneManifest")
+ return result
+
+
+def validate_role_bindings(value: Mapping[str, Any]) -> RoleBindings:
+ result = _mapping(value, "RoleBindings")
+ _keys(
+ result,
+ {
+ "schema_version",
+ "task_id",
+ "candidate_id",
+ "reference_bindings",
+ "role_bindings",
+ },
+ "RoleBindings",
+ )
+ _schema(result, ROLE_BINDINGS_SCHEMA, "RoleBindings")
+ for key in ("task_id", "candidate_id"):
+ result[key] = _nonempty(result.get(key), f"RoleBindings.{key}")
+ result["reference_bindings"] = _string_lists(
+ result.get("reference_bindings"), "RoleBindings.reference_bindings"
+ )
+ if any(not uids for uids in result["reference_bindings"].values()):
+ raise ValueError("RoleBindings.reference_bindings values must not be empty.")
+ result["role_bindings"] = _string_map(
+ result.get("role_bindings"), "RoleBindings.role_bindings"
+ )
+ return result
+
+
+def validate_binding_report(value: Mapping[str, Any]) -> BindingReport:
+ result = _mapping(value, "BindingReport")
+ _keys(
+ result,
+ {
+ "schema_version",
+ "task_id",
+ "status",
+ "selected_candidate_id",
+ "selection_reason",
+ "candidates",
+ },
+ "BindingReport",
+ )
+ _schema(result, BINDING_REPORT_SCHEMA, "BindingReport")
+ result["task_id"] = _nonempty(result.get("task_id"), "BindingReport.task_id")
+ result["status"] = _enum(
+ result.get("status"),
+ {"bound", "ambiguous", "unsatisfied"},
+ "BindingReport.status",
+ )
+ result["selected_candidate_id"] = _string(
+ result.get("selected_candidate_id"), "BindingReport.selected_candidate_id"
+ )
+ result["selection_reason"] = _string(
+ result.get("selection_reason"), "BindingReport.selection_reason"
+ )
+ if result["status"] == "bound" and not result["selected_candidate_id"]:
+ raise ValueError("A bound BindingReport requires selected_candidate_id.")
+ candidate_keys = {
+ "candidate_id",
+ "semantic_hash",
+ "status",
+ "references",
+ "reasons",
+ }
+ reference_keys = {
+ "reference_id",
+ "status",
+ "confidence",
+ "candidate_uids",
+ "selected_uids",
+ "reasons",
+ }
+ candidates = []
+ for index, raw in enumerate(
+ _sequence(result.get("candidates"), "BindingReport.candidates")
+ ):
+ context = f"BindingReport.candidates[{index}]"
+ candidate = _mapping(raw, context)
+ _keys(candidate, candidate_keys, context)
+ candidate["candidate_id"] = _nonempty(
+ candidate.get("candidate_id"), f"{context}.candidate_id"
+ )
+ candidate["semantic_hash"] = _digest(
+ candidate.get("semantic_hash"), f"{context}.semantic_hash"
+ )
+ candidate["status"] = _enum(
+ candidate.get("status"),
+ {"resolved", "ambiguous", "not_found", "incompatible"},
+ f"{context}.status",
+ )
+ references = []
+ for ref_index, ref_raw in enumerate(
+ _sequence(candidate.get("references"), f"{context}.references")
+ ):
+ ref_context = f"{context}.references[{ref_index}]"
+ reference = _mapping(ref_raw, ref_context)
+ _keys(reference, reference_keys, ref_context)
+ reference["reference_id"] = _nonempty(
+ reference.get("reference_id"), f"{ref_context}.reference_id"
+ )
+ reference["status"] = _enum(
+ reference.get("status"),
+ {"resolved", "ambiguous", "not_found", "incompatible"},
+ f"{ref_context}.status",
+ )
+ reference["confidence"] = _number(
+ reference.get("confidence"),
+ f"{ref_context}.confidence",
+ minimum=0.0,
+ maximum=1.0,
+ )
+ reference["candidate_uids"] = _strings(
+ reference.get("candidate_uids"),
+ f"{ref_context}.candidate_uids",
+ allow_empty=True,
+ )
+ reference["selected_uids"] = _strings(
+ reference.get("selected_uids"),
+ f"{ref_context}.selected_uids",
+ allow_empty=True,
+ )
+ reference["reasons"] = _strings(
+ reference.get("reasons"), f"{ref_context}.reasons", allow_empty=True
+ )
+ selected = set(reference["selected_uids"])
+ candidates_for_reference = set(reference["candidate_uids"])
+ if not selected <= candidates_for_reference:
+ raise ValueError(
+ f"{ref_context}.selected_uids must be a subset of candidate_uids."
+ )
+ if reference["status"] == "resolved" and not selected:
+ raise ValueError(
+ f"{ref_context} status=resolved requires selected_uids."
+ )
+ if reference["status"] != "resolved" and selected:
+ raise ValueError(
+ f"{ref_context} non-resolved status cannot select UIDs."
+ )
+ if reference["status"] == "not_found" and candidates_for_reference:
+ raise ValueError(
+ f"{ref_context} status=not_found cannot carry candidate_uids."
+ )
+ references.append(reference)
+ if not references:
+ raise ValueError(f"{context}.references must not be empty.")
+ _unique(
+ [item["reference_id"] for item in references],
+ f"{context} reference IDs",
+ )
+ expected_status = _candidate_binding_status(references)
+ if candidate["status"] != expected_status:
+ raise ValueError(
+ f"{context}.status must be {expected_status!r} for its references."
+ )
+ candidate["references"] = references
+ candidate["reasons"] = _strings(
+ candidate.get("reasons"), f"{context}.reasons", allow_empty=True
+ )
+ candidates.append(candidate)
+ if not candidates:
+ raise ValueError("BindingReport.candidates must not be empty.")
+ _unique(
+ [item["candidate_id"] for item in candidates], "BindingReport candidate IDs"
+ )
+ if result["selected_candidate_id"] and result["selected_candidate_id"] not in {
+ item["candidate_id"] for item in candidates
+ }:
+ raise ValueError("BindingReport.selected_candidate_id is unknown.")
+ if result["status"] != "bound" and result["selected_candidate_id"]:
+ raise ValueError(
+ "A non-bound BindingReport cannot carry selected_candidate_id."
+ )
+ selected = next(
+ (
+ candidate
+ for candidate in candidates
+ if candidate["candidate_id"] == result["selected_candidate_id"]
+ ),
+ None,
+ )
+ if result["status"] == "bound" and (
+ selected is None or selected["status"] != "resolved"
+ ):
+ raise ValueError(
+ "A bound BindingReport must select a resolved candidate audit."
+ )
+ if result["status"] == "unsatisfied" and any(
+ candidate["status"] in {"resolved", "ambiguous"} for candidate in candidates
+ ):
+ raise ValueError(
+ "An unsatisfied BindingReport cannot contain resolved or ambiguous candidates."
+ )
+ result["candidates"] = candidates
+ return result
+
+
+def validate_grounded_task_plan(value: Mapping[str, Any]) -> GroundedTaskPlan:
+ result = _mapping(value, "GroundedTaskPlan")
+ keys = {
+ "schema_version",
+ "task_id",
+ "instruction",
+ "selected_candidate_id",
+ "task_draft",
+ "task_spec",
+ "scene_requirements",
+ "success_spec",
+ "scene_manifest",
+ "role_bindings",
+ "binding_report",
+ "hashes",
+ }
+ _keys(result, keys, "GroundedTaskPlan")
+ _schema(result, GROUNDED_TASK_PLAN_SCHEMA, "GroundedTaskPlan")
+ task_id = _nonempty(result.get("task_id"), "GroundedTaskPlan.task_id")
+ result["instruction"] = _nonempty(
+ result.get("instruction"), "GroundedTaskPlan.instruction"
+ )
+ result["selected_candidate_id"] = _nonempty(
+ result.get("selected_candidate_id"), "GroundedTaskPlan.selected_candidate_id"
+ )
+ result["task_draft"] = validate_task_draft(result.get("task_draft"))
+ # Candidate SuccessSpec terms use draft step IDs. Once count/all selectors
+ # are lowered, the grounded plan carries one term per concrete TaskSpec
+ # instance instead, and is checked against the v2 recipe below.
+ result["success_spec"] = validate_success_spec(result.get("success_spec"))
+ result["scene_manifest"] = validate_scene_manifest(result.get("scene_manifest"))
+ result["role_bindings"] = validate_role_bindings(result.get("role_bindings"))
+ result["binding_report"] = validate_binding_report(result.get("binding_report"))
+ result["task_spec"] = validate_task_spec(
+ _mapping(result.get("task_spec"), "GroundedTaskPlan.task_spec")
+ )
+ result["scene_requirements"] = validate_scene_requirements(
+ _mapping(
+ result.get("scene_requirements"),
+ "GroundedTaskPlan.scene_requirements",
+ )
+ )
+ hashes = _mapping(result.get("hashes"), "GroundedTaskPlan.hashes")
+ _keys(
+ hashes,
+ {"task_draft", "task_spec", "scene_manifest", "role_bindings", "plan"},
+ "GroundedTaskPlan.hashes",
+ )
+ for key in hashes:
+ hashes[key] = _digest(hashes[key], f"GroundedTaskPlan.hashes.{key}")
+ if any(
+ part["task_id"] != task_id
+ for part in (
+ result["task_draft"],
+ result["task_spec"],
+ result["scene_requirements"],
+ result["success_spec"],
+ result["role_bindings"],
+ result["binding_report"],
+ )
+ ):
+ raise ValueError("GroundedTaskPlan task IDs must agree.")
+ if result["task_draft"]["instruction"] != result["instruction"]:
+ raise ValueError("GroundedTaskPlan instruction must match TaskDraft.")
+ if result["task_spec"]["instruction"] != result["instruction"]:
+ raise ValueError("GroundedTaskPlan instruction must match TaskSpec.")
+ if (
+ result["role_bindings"]["candidate_id"] != result["selected_candidate_id"]
+ or result["binding_report"]["selected_candidate_id"]
+ != result["selected_candidate_id"]
+ ):
+ raise ValueError("GroundedTaskPlan selected candidate IDs must agree.")
+ if result["binding_report"]["status"] != "bound":
+ raise ValueError("GroundedTaskPlan requires a bound BindingReport.")
+ selected_audit = next(
+ candidate
+ for candidate in result["binding_report"]["candidates"]
+ if candidate["candidate_id"] == result["selected_candidate_id"]
+ )
+ if selected_audit["semantic_hash"] != canonical_hash(result["task_draft"]["steps"]):
+ raise ValueError(
+ "GroundedTaskPlan selected candidate hash must match TaskDraft."
+ )
+ task_metadata = result["task_spec"].get("metadata", {})
+ task_oracle = result["task_spec"].get("oracle", {})
+ serialized_bindings = (
+ task_metadata.get("role_bindings")
+ if isinstance(task_metadata, Mapping)
+ and task_metadata.get("role_bindings") is not None
+ else (
+ task_oracle.get("role_bindings")
+ if isinstance(task_oracle, Mapping)
+ else None
+ )
+ )
+ if serialized_bindings != result["role_bindings"]["role_bindings"]:
+ raise ValueError(
+ "GroundedTaskPlan RoleBindings must match the TaskSpec binding hand-off."
+ )
+ requirement_roles = {
+ str(item["role_id"]) for item in result["scene_requirements"]["objects"]
+ }
+ if requirement_roles != set(result["role_bindings"]["role_bindings"]):
+ raise ValueError(
+ "GroundedTaskPlan SceneRequirements roles must match RoleBindings."
+ )
+ manifest_uids = {item["uid"] for item in result["scene_manifest"]["objects"]}
+ missing_uids = sorted(
+ set(result["role_bindings"]["role_bindings"].values()) - manifest_uids
+ )
+ if missing_uids:
+ raise ValueError(
+ f"GroundedTaskPlan RoleBindings reference unknown scene UIDs {missing_uids}."
+ )
+ reference_ids = {
+ f"{step['id']}.{role}"
+ for step in result["task_draft"]["steps"]
+ for role in ("object", "target")
+ if step[role]["kind"] == "scene_ref"
+ }
+ reference_bindings = result["role_bindings"]["reference_bindings"]
+ if set(reference_bindings) != reference_ids:
+ raise ValueError(
+ "GroundedTaskPlan reference bindings must cover every draft scene_ref exactly."
+ )
+ bound_uids = {uid for uids in reference_bindings.values() for uid in uids} | set(
+ result["role_bindings"]["role_bindings"].values()
+ )
+ unknown_uids = sorted(bound_uids - manifest_uids)
+ if unknown_uids:
+ raise ValueError(
+ "GroundedTaskPlan bindings reference unknown SceneManifest UIDs: "
+ f"{unknown_uids}."
+ )
+ audited_bindings = {
+ reference["reference_id"]: reference["selected_uids"]
+ for reference in selected_audit["references"]
+ }
+ if audited_bindings != reference_bindings:
+ raise ValueError(
+ "GroundedTaskPlan RoleBindings must match the selected candidate audit."
+ )
+ ontology_success = [
+ {
+ "step_id": instance["id"],
+ "type": task_success_type(instance["task_type"], instance["params"]),
+ }
+ for instance in result["task_spec"]["task_instances"]
+ ]
+ recipe_success = [
+ {
+ "step_id": term["task_instance_id"],
+ "type": term["type"],
+ }
+ for term in result["task_spec"]["success"]["terms"]
+ ]
+ if recipe_success != ontology_success:
+ raise ValueError(
+ "GroundedTaskPlan TaskSpec success recipe must be derived from "
+ "task_success_type."
+ )
+ if result["success_spec"]["terms"] != ontology_success:
+ raise ValueError(
+ "GroundedTaskPlan SuccessSpec must exactly match the lowered "
+ "TaskSpec success recipe."
+ )
+ expected_hashes = {
+ "task_draft": canonical_hash(result["task_draft"]),
+ "task_spec": canonical_hash(result["task_spec"]),
+ "scene_manifest": canonical_hash(result["scene_manifest"]),
+ "role_bindings": canonical_hash(result["role_bindings"]),
+ }
+ base = {key: value for key, value in result.items() if key != "hashes"}
+ expected_hashes["plan"] = canonical_hash(base)
+ if hashes != expected_hashes:
+ raise ValueError("GroundedTaskPlan hashes do not match their contents.")
+ result["task_id"] = task_id
+ result["hashes"] = hashes
+ _json_safe(result, "GroundedTaskPlan")
+ return result
+
+
+def _mapping(value: Any, context: str) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise ValueError(f"{context} must be a mapping.")
+ return deepcopy(dict(value))
+
+
+def _candidate_binding_status(references: Sequence[Mapping[str, Any]]) -> str:
+ statuses = {str(reference["status"]) for reference in references}
+ if statuses == {"resolved"}:
+ return "resolved"
+ if "ambiguous" in statuses:
+ return "ambiguous"
+ if "incompatible" in statuses:
+ return "incompatible"
+ return "not_found"
+
+
+def _sequence(value: Any, context: str) -> list[Any]:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise ValueError(f"{context} must be a list.")
+ return list(value)
+
+
+def _keys(
+ value: Mapping[str, Any], expected: set[str] | frozenset[str], context: str
+) -> None:
+ if set(value) != set(expected):
+ raise ValueError(
+ f"{context} requires exactly fields {sorted(expected)}; received {sorted(value)}."
+ )
+
+
+def _schema(value: Mapping[str, Any], expected: str, context: str) -> None:
+ if value.get("schema_version") != expected:
+ raise ValueError(f"{context}.schema_version must be {expected!r}.")
+
+
+def _string(value: Any, context: str) -> str:
+ if not isinstance(value, str):
+ raise ValueError(f"{context} must be a string.")
+ return value.strip()
+
+
+def _nonempty(value: Any, context: str) -> str:
+ result = _string(value, context)
+ if not result:
+ raise ValueError(f"{context} must not be empty.")
+ return result
+
+
+def _enum(value: Any, choices: set[str], context: str) -> str:
+ result = _string(value, context)
+ if result not in choices:
+ raise ValueError(f"{context} must be one of {sorted(choices)}.")
+ return result
+
+
+def _integer(
+ value: Any, context: str, *, minimum: int, maximum: int | None = None
+) -> int:
+ if (
+ not isinstance(value, int)
+ or isinstance(value, bool)
+ or value < minimum
+ or (maximum is not None and value > maximum)
+ ):
+ raise ValueError(f"{context} must be an integer in the allowed range.")
+ return value
+
+
+def _number(value: Any, context: str, *, minimum: float, maximum: float) -> float:
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, (int, float))
+ or not math.isfinite(value)
+ or not minimum <= float(value) <= maximum
+ ):
+ raise ValueError(
+ f"{context} must be a finite number between {minimum} and {maximum}."
+ )
+ return float(value)
+
+
+def _strings(value: Any, context: str, *, allow_empty: bool = False) -> list[str]:
+ result = [_string(item, context) for item in _sequence(value, context)]
+ if not allow_empty and any(not item for item in result):
+ raise ValueError(f"{context} values must not be empty.")
+ if len(result) != len(set(result)):
+ raise ValueError(f"{context} values must be unique.")
+ return result
+
+
+def _string_map(value: Any, context: str) -> dict[str, str]:
+ result = _mapping(value, context)
+ return {
+ _nonempty(key, context): _nonempty(item, context)
+ for key, item in result.items()
+ }
+
+
+def _string_lists(value: Any, context: str) -> dict[str, list[str]]:
+ result = _mapping(value, context)
+ return {
+ _nonempty(key, context): _strings(item, context) for key, item in result.items()
+ }
+
+
+def _digest(value: Any, context: str) -> str:
+ result = _string(value, context)
+ if len(result) != 64 or any(
+ character not in "0123456789abcdef" for character in result
+ ):
+ raise ValueError(f"{context} must be a lowercase SHA-256 digest.")
+ return result
+
+
+def _unique(values: Sequence[str], context: str) -> None:
+ if len(values) != len(set(values)):
+ raise ValueError(f"{context} must be unique.")
+
+
+def _json_safe(value: Any, context: str) -> None:
+ try:
+ json.dumps(value, allow_nan=False)
+ except (TypeError, ValueError) as error:
+ raise ValueError(f"{context} must be finite and JSON serializable.") from error
diff --git a/embodichain/gen_sim/task_engine/orchestration/coordinator.py b/embodichain/gen_sim/task_engine/orchestration/coordinator.py
new file mode 100644
index 000000000..c6c9f0282
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/orchestration/coordinator.py
@@ -0,0 +1,862 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""End-to-end Task Engine preparation for an existing scene source."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+from copy import deepcopy
+from dataclasses import dataclass, replace
+import json
+from pathlib import Path
+import shutil
+from typing import Any
+
+from embodichain.gen_sim.action_engine.generation import (
+ GeneratedConfigPaths,
+ generate_action_engine_config,
+)
+from embodichain.gen_sim.action_engine.generation.artifacts import artifact_paths
+from embodichain.gen_sim.action_engine.agent import ActionAgent
+from embodichain.gen_sim.action_engine.unbound import ActionCapabilityError
+from embodichain.gen_sim.action_engine.domain.task_contracts import (
+ TASK_CONTRACTS as ACTION_TASK_CONTRACTS,
+)
+from embodichain.gen_sim.action_engine.tasks import (
+ GroundedTaskSpec,
+ ground_instruction_draft,
+)
+from embodichain.gen_sim.task_engine import (
+ TaskAgent,
+ TaskCandidate,
+ TaskCandidateSet,
+ validate_scene_output_separation,
+ validate_task_candidate,
+ validate_task_candidate_set,
+)
+from embodichain.gen_sim.task_engine.scene import FeasibilityBroker, FeasibilityReport
+
+from .artifacts import (
+ ArtifactTransaction,
+ TaskEngineArtifactPaths,
+ task_engine_artifact_paths,
+ write_task_engine_artifacts,
+ write_preparation_failure,
+)
+from .contracts import (
+ GROUNDED_TASK_PLAN_SCHEMA,
+ GroundedTaskPlan,
+ RoleBindings,
+ canonical_hash,
+ validate_grounded_task_plan,
+ validate_binding_report,
+ validate_role_bindings,
+)
+from .scene_adapter import SceneAdaptation, SceneAdapter
+from .scene_source import SceneSourceRef
+
+__all__ = [
+ "TaskEngineCoordinator",
+ "PreparationResult",
+ "build_grounded_task_plan",
+ "lower_task_candidate",
+]
+
+
+BundleGenerator = Callable[..., GeneratedConfigPaths]
+_PREPARATION_FAILURE_SCHEMA = "action_engine_preparation_failure_v1"
+
+
+def lower_task_candidate(
+ candidate: Mapping[str, Any],
+ reference_bindings: Mapping[str, Any],
+ scene_objects: Sequence[Mapping[str, Any]],
+ robot_profile: str,
+) -> GroundedTaskSpec:
+ """Lower a selected TaskCandidate across the Task/Action boundary."""
+ normalized = validate_task_candidate(candidate)
+ if reference_bindings.get("schema_version") is not None:
+ role_bindings = validate_role_bindings(reference_bindings)
+ if role_bindings["task_id"] != normalized["draft"]["task_id"]:
+ raise ValueError("RoleBindings.task_id must match the TaskCandidate.")
+ if role_bindings["candidate_id"] != normalized["candidate_id"]:
+ raise ValueError("RoleBindings.candidate_id must match the TaskCandidate.")
+ raw_bindings = role_bindings["reference_bindings"]
+ else:
+ raw_bindings = reference_bindings
+ bindings = {
+ str(reference_id): [str(uid) for uid in uids]
+ for reference_id, uids in raw_bindings.items()
+ }
+ grounded = ground_instruction_draft(
+ normalized["draft"]["task_id"],
+ normalized["draft"]["instruction"],
+ {"steps": normalized["draft"]["steps"]},
+ scene_objects,
+ robot_profile=robot_profile,
+ reference_bindings=bindings,
+ )
+ _validate_lowered_success(normalized, bindings, grounded)
+ return grounded
+
+
+@dataclass(frozen=True)
+class PreparationResult:
+ """Published result of one Task -> Scene -> Action preparation attempt."""
+
+ status: str
+ output_dir: Path
+ candidate_set: TaskCandidateSet
+ adaptation: SceneAdaptation
+ artifacts: TaskEngineArtifactPaths
+ grounded_task_plan: GroundedTaskPlan | None = None
+ action_graph: dict[str, Any] | None = None
+ generated_paths: GeneratedConfigPaths | None = None
+ feasibility_report: FeasibilityReport | None = None
+ planning_attempts: tuple[dict[str, Any], ...] = ()
+ unbound_action_plan: dict[str, Any] | None = None
+
+ @property
+ def bound(self) -> bool:
+ return self.status == "bound"
+
+ @property
+ def selected_candidate_id(self) -> str | None:
+ return self.adaptation.selected_candidate_id
+
+
+@dataclass(frozen=True)
+class _PlannedCandidate:
+ adaptation: SceneAdaptation
+ selected: TaskCandidate
+ role_bindings: RoleBindings
+ feasibility_report: FeasibilityReport | None
+ grounded: GroundedTaskSpec
+ grounded_plan: GroundedTaskPlan
+ action_graph: dict[str, Any]
+ unbound_action_plan: dict[str, Any] | None
+
+
+class TaskEngineCoordinator:
+ """Run Task Agent, Scene Adapter, and Action Agent as one transaction."""
+
+ def __init__(
+ self,
+ *,
+ task_agent: TaskAgent | None = None,
+ scene_adapter: SceneAdapter | None = None,
+ action_agent: ActionAgent | None = None,
+ bundle_generator: BundleGenerator = generate_action_engine_config,
+ feasibility_broker: FeasibilityBroker | None = None,
+ ) -> None:
+ self.task_agent = task_agent or TaskAgent()
+ self.scene_adapter = scene_adapter or SceneAdapter()
+ self.action_agent = action_agent or ActionAgent()
+ self.bundle_generator = bundle_generator
+ self.feasibility_broker = feasibility_broker or FeasibilityBroker()
+
+ def prepare(
+ self,
+ task_id: str,
+ instruction: str,
+ source: SceneSourceRef | str | Path,
+ output_dir: str | Path,
+ *,
+ model: str | None = None,
+ candidate_count: int = 3,
+ overwrite: bool = False,
+ planning_mode: str = "offline",
+ vlm_model: str | None = None,
+ max_episodes: int | None = None,
+ max_episode_steps: int | None = None,
+ randomize_scene: bool = False,
+ randomize_table_material: bool = False,
+ candidate_set: TaskCandidateSet | Mapping[str, Any] | None = None,
+ force_most_likely: bool = False,
+ final_inspection: Mapping[str, Any] | None = None,
+ unbound_action_plan: Mapping[str, Any] | None = None,
+ ) -> PreparationResult:
+ """Prepare and atomically publish a Task Engine bundle.
+
+ Ambiguous and unsatisfied scene adaptations are valid terminal results.
+ They publish the complete audit hand-off but never publish a TaskSpec,
+ SeedGraph, Gym configuration, or GroundedTaskPlan.
+ """
+ normalized_source = self._coerce_source(source)
+ validate_scene_output_separation(normalized_source.path, output_dir)
+ with ArtifactTransaction(output_dir, overwrite=overwrite) as transaction:
+ staging_dir = transaction.staging_dir
+ assert staging_dir is not None
+ if candidate_set is None:
+ normalized_candidates = self.task_agent.generate(
+ task_id,
+ instruction,
+ model=model,
+ candidate_count=candidate_count,
+ )
+ else:
+ normalized_candidates = validate_task_candidate_set(candidate_set)
+ if normalized_candidates["task_id"] != str(task_id).strip():
+ raise ValueError("TaskCandidateSet.task_id must match task_id.")
+ if normalized_candidates["instruction"] != str(instruction).strip():
+ raise ValueError(
+ "TaskCandidateSet.instruction must match instruction."
+ )
+ candidate_set = normalized_candidates
+ adaptation_kwargs: dict[str, Any] = {"force_most_likely": force_most_likely}
+ if final_inspection is not None:
+ adaptation_kwargs["final_inspection"] = final_inspection
+ adaptation = self.scene_adapter.adapt(
+ candidate_set,
+ normalized_source,
+ **adaptation_kwargs,
+ )
+ status = str(adaptation.binding_report["status"])
+
+ if status != "bound":
+ write_task_engine_artifacts(
+ staging_dir,
+ candidate_set=candidate_set,
+ scene_manifest=None,
+ role_bindings=None,
+ binding_report=adaptation.binding_report,
+ static_scene_manifest=adaptation.static_scene_manifest,
+ conservative_scene_graph=adaptation.conservative_scene_graph,
+ final_scene_inspection=final_inspection,
+ )
+ published = transaction.commit()
+ return PreparationResult(
+ status=status,
+ output_dir=published,
+ candidate_set=deepcopy(candidate_set),
+ adaptation=adaptation,
+ artifacts=task_engine_artifact_paths(published),
+ planning_attempts=(),
+ )
+
+ selected = adaptation.selected_candidate
+ raw_role_bindings = adaptation.role_bindings
+ if selected is None or raw_role_bindings is None:
+ raise ValueError(
+ "A bound SceneAdaptation must include a selected candidate "
+ "and RoleBindings."
+ )
+ feasibility_report = self._assess_feasibility(
+ selected,
+ raw_role_bindings,
+ adaptation,
+ )
+ if (
+ feasibility_report is not None
+ and feasibility_report["status"] == "contradicted"
+ and feasibility_report["remediation_class"] != "action_capability"
+ ):
+ adaptation, selected, raw_role_bindings, feasibility_report = (
+ self._fallback_feasible_candidate(
+ candidate_set,
+ adaptation,
+ selected,
+ raw_role_bindings,
+ feasibility_report,
+ )
+ )
+ if (
+ feasibility_report is not None
+ and feasibility_report["status"] == "contradicted"
+ ):
+ write_task_engine_artifacts(
+ staging_dir,
+ candidate_set=candidate_set,
+ scene_manifest=adaptation.scene_manifest,
+ role_bindings=raw_role_bindings,
+ binding_report=adaptation.binding_report,
+ static_scene_manifest=adaptation.static_scene_manifest,
+ conservative_scene_graph=adaptation.conservative_scene_graph,
+ feasibility_report=feasibility_report,
+ final_scene_inspection=final_inspection,
+ )
+ published = transaction.commit()
+ return PreparationResult(
+ status="infeasible",
+ output_dir=published,
+ candidate_set=deepcopy(candidate_set),
+ adaptation=adaptation,
+ artifacts=task_engine_artifact_paths(published),
+ feasibility_report=deepcopy(feasibility_report),
+ planning_attempts=(),
+ )
+ robot_profile = str(adaptation.scene_manifest["robot_profile"])
+ planned, planning_failures = self._plan_with_candidate_fallback(
+ candidate_set,
+ adaptation,
+ selected,
+ raw_role_bindings,
+ feasibility_report,
+ robot_profile=robot_profile,
+ unbound_action_plan=unbound_action_plan,
+ )
+ if planned is None:
+ write_task_engine_artifacts(
+ staging_dir,
+ candidate_set=candidate_set,
+ scene_manifest=adaptation.scene_manifest,
+ role_bindings=raw_role_bindings,
+ binding_report=adaptation.binding_report,
+ static_scene_manifest=adaptation.static_scene_manifest,
+ conservative_scene_graph=adaptation.conservative_scene_graph,
+ feasibility_report=feasibility_report,
+ final_scene_inspection=final_inspection,
+ )
+ write_preparation_failure(
+ staging_dir,
+ {
+ "schema_version": _PREPARATION_FAILURE_SCHEMA,
+ "task_id": str(candidate_set["task_id"]),
+ "status": "planning_failed",
+ "selected_candidate_id": str(selected["candidate_id"]),
+ "attempts": planning_failures,
+ },
+ )
+ published = transaction.commit()
+ return PreparationResult(
+ status="planning_failed",
+ output_dir=published,
+ candidate_set=deepcopy(candidate_set),
+ adaptation=adaptation,
+ artifacts=task_engine_artifact_paths(published),
+ feasibility_report=deepcopy(feasibility_report),
+ planning_attempts=tuple(deepcopy(planning_failures)),
+ )
+
+ adaptation = planned.adaptation
+ selected = planned.selected
+ role_bindings = planned.role_bindings
+ feasibility_report = planned.feasibility_report
+ grounded = planned.grounded
+ grounded_plan = planned.grounded_plan
+ action_graph = planned.action_graph
+
+ generator_kwargs: dict[str, Any] = {
+ "task_name": grounded_plan["task_id"],
+ "task_spec": grounded_plan["task_spec"],
+ "robot_profile": robot_profile,
+ "source_scene_z_rotation_degrees": (
+ adaptation.prepared_scene.z_rotation_degrees
+ ),
+ "body_scale_policy": adaptation.prepared_scene.body_scale_policy,
+ "body_scale": adaptation.prepared_scene.body_scale,
+ "overwrite": False,
+ "randomize_scene": randomize_scene,
+ "randomize_table_material": randomize_table_material,
+ "planning_mode": planning_mode,
+ "vlm_model": vlm_model,
+ }
+ if max_episodes is not None:
+ generator_kwargs["max_episodes"] = max_episodes
+ if max_episode_steps is not None:
+ generator_kwargs["max_episode_steps"] = max_episode_steps
+ compatibility_input = staging_dir / ".task_engine_input"
+ compatibility_input.mkdir()
+ task_spec_path = compatibility_input / "task_spec.json"
+ requirements_path = compatibility_input / "scene_requirements.json"
+ _write_compatibility_input(task_spec_path, grounded.task_spec)
+ _write_compatibility_input(
+ requirements_path,
+ grounded.scene_requirements,
+ )
+ generator_kwargs["task_spec"] = task_spec_path
+ try:
+ generated = self.bundle_generator(
+ adaptation.source_config_path,
+ staging_dir,
+ **generator_kwargs,
+ )
+ finally:
+ shutil.rmtree(compatibility_input, ignore_errors=True)
+ _require_matching_generated_graph(generated, action_graph)
+ write_task_engine_artifacts(
+ staging_dir,
+ candidate_set=candidate_set,
+ scene_manifest=adaptation.scene_manifest,
+ role_bindings=role_bindings,
+ binding_report=adaptation.binding_report,
+ grounded_task_plan=grounded_plan,
+ static_scene_manifest=adaptation.static_scene_manifest,
+ conservative_scene_graph=adaptation.conservative_scene_graph,
+ feasibility_report=feasibility_report,
+ final_scene_inspection=final_inspection,
+ )
+ published = transaction.commit()
+ return PreparationResult(
+ status="bound",
+ output_dir=published,
+ candidate_set=deepcopy(candidate_set),
+ adaptation=adaptation,
+ artifacts=task_engine_artifact_paths(published),
+ grounded_task_plan=grounded_plan,
+ action_graph=deepcopy(action_graph),
+ generated_paths=artifact_paths(
+ published,
+ planning_mode=planning_mode,
+ ),
+ feasibility_report=deepcopy(feasibility_report),
+ planning_attempts=tuple(deepcopy(planning_failures)),
+ unbound_action_plan=deepcopy(planned.unbound_action_plan),
+ )
+
+ def _plan_with_candidate_fallback(
+ self,
+ candidate_set: Mapping[str, Any],
+ adaptation: SceneAdaptation,
+ selected: TaskCandidate,
+ role_bindings: RoleBindings,
+ feasibility_report: FeasibilityReport | None,
+ *,
+ robot_profile: str,
+ unbound_action_plan: Mapping[str, Any] | None,
+ ) -> tuple[_PlannedCandidate | None, list[dict[str, Any]]]:
+ """Treat lowering and Action planning failures as candidate-local."""
+ candidates = {
+ str(candidate["candidate_id"]): candidate
+ for candidate in candidate_set.get("candidates", ())
+ if isinstance(candidate, Mapping) and candidate.get("candidate_id")
+ }
+ resolved = {
+ str(audit["candidate_id"])
+ for audit in adaptation.binding_report["candidates"]
+ if audit["status"] == "resolved"
+ }
+ selected_id = str(selected["candidate_id"])
+ ordered_ids = [selected_id] + [
+ candidate_id
+ for candidate_id in candidates
+ if candidate_id != selected_id and candidate_id in resolved
+ ]
+ failures: list[dict[str, Any]] = []
+
+ for candidate_id in ordered_ids:
+ candidate = candidates.get(candidate_id)
+ raw_bindings = (
+ role_bindings
+ if candidate_id == selected_id
+ else adaptation.candidate_bindings.get(candidate_id)
+ )
+ if candidate is None or raw_bindings is None:
+ continue
+ report = (
+ feasibility_report
+ if candidate_id == selected_id
+ else self._assess_feasibility(candidate, raw_bindings, adaptation)
+ )
+ if report is not None and report["status"] == "contradicted":
+ failures.append(
+ _candidate_failure(
+ candidate,
+ raw_bindings,
+ stage="static_feasibility",
+ error_type="FeasibilityContradiction",
+ error_message="Static feasibility contradicted this candidate.",
+ feasibility_report=report,
+ )
+ )
+ continue
+
+ candidate_adaptation = _select_candidate_adaptation(
+ adaptation,
+ candidate,
+ raw_bindings,
+ failures,
+ )
+ grounded: GroundedTaskSpec | None = None
+ grounded_plan: GroundedTaskPlan | None = None
+ candidate_unbound: Mapping[str, Any] | None = None
+ action_graph: Mapping[str, Any] | None = None
+ stage = "lowering"
+ try:
+ grounded = lower_task_candidate(
+ candidate,
+ raw_bindings,
+ adaptation.prepared_scene.planner_objects,
+ robot_profile,
+ )
+ canonical_bindings = validate_role_bindings(
+ {
+ **deepcopy(raw_bindings),
+ "role_bindings": deepcopy(grounded.role_bindings),
+ }
+ )
+ candidate_adaptation = replace(
+ candidate_adaptation,
+ role_bindings=deepcopy(canonical_bindings),
+ )
+ stage = "grounded_plan"
+ grounded_plan = build_grounded_task_plan(
+ candidate=candidate,
+ task_spec=grounded.task_spec,
+ scene_requirements=grounded.scene_requirements,
+ scene_manifest=adaptation.scene_manifest,
+ role_bindings=canonical_bindings,
+ binding_report=candidate_adaptation.binding_report,
+ )
+ stage = "action_planning"
+ bind_and_plan = getattr(self.action_agent, "bind_and_plan", None)
+ if callable(bind_and_plan):
+ candidate_unbound = (
+ unbound_action_plan
+ if unbound_action_plan is not None
+ and str(unbound_action_plan.get("candidate_id")) == candidate_id
+ else self.action_agent.draft(candidate)
+ )
+ action_graph = bind_and_plan(candidate_unbound, grounded_plan)
+ else:
+ action_graph = self.action_agent.plan(grounded_plan)
+ stage = "preflight"
+ preflight = getattr(self.action_agent, "preflight", None)
+ if callable(preflight):
+ preflight(
+ action_graph,
+ scene_manifest=adaptation.scene_manifest,
+ )
+ except ActionCapabilityError:
+ raise
+ except (TypeError, ValueError, OSError) as error:
+ failures.append(
+ _candidate_failure(
+ candidate,
+ raw_bindings,
+ stage=stage,
+ error_type=type(error).__name__,
+ error_message=str(error),
+ feasibility_report=report,
+ grounded_task_plan=grounded_plan,
+ unbound_action_plan=candidate_unbound,
+ action_graph=action_graph,
+ )
+ )
+ continue
+
+ assert grounded is not None and grounded_plan is not None
+ return (
+ _PlannedCandidate(
+ adaptation=candidate_adaptation,
+ selected=deepcopy(candidate),
+ role_bindings=canonical_bindings,
+ feasibility_report=deepcopy(report),
+ grounded=grounded,
+ grounded_plan=grounded_plan,
+ action_graph=deepcopy(action_graph),
+ unbound_action_plan=(
+ None
+ if candidate_unbound is None
+ else deepcopy(dict(candidate_unbound))
+ ),
+ ),
+ failures,
+ )
+ return None, failures
+
+ def _fallback_feasible_candidate(
+ self,
+ candidate_set: Mapping[str, Any],
+ adaptation: SceneAdaptation,
+ selected: TaskCandidate,
+ role_bindings: RoleBindings,
+ report: FeasibilityReport,
+ ) -> tuple[
+ SceneAdaptation,
+ TaskCandidate,
+ RoleBindings,
+ FeasibilityReport | None,
+ ]:
+ """Try other resolved semantic candidates after a static contradiction."""
+ candidates = {
+ str(candidate["candidate_id"]): candidate
+ for candidate in candidate_set.get("candidates", ())
+ if isinstance(candidate, Mapping) and candidate.get("candidate_id")
+ }
+ selected_id = str(selected["candidate_id"])
+ for audit in adaptation.binding_report["candidates"]:
+ candidate_id = str(audit["candidate_id"])
+ if candidate_id == selected_id or audit["status"] != "resolved":
+ continue
+ candidate = candidates.get(candidate_id)
+ alternative_bindings = adaptation.candidate_bindings.get(candidate_id)
+ if candidate is None or alternative_bindings is None:
+ continue
+ alternative_report = self._assess_feasibility(
+ candidate,
+ alternative_bindings,
+ adaptation,
+ )
+ if (
+ alternative_report is not None
+ and alternative_report["status"] == "contradicted"
+ ):
+ continue
+ binding_report = validate_binding_report(
+ {
+ **deepcopy(adaptation.binding_report),
+ "selected_candidate_id": candidate_id,
+ "selection_reason": (
+ "Selected the next resolved candidate after static "
+ f"feasibility contradicted {selected_id}."
+ ),
+ }
+ )
+ chosen = deepcopy(candidate)
+ updated = replace(
+ adaptation,
+ selected_candidate=chosen,
+ role_bindings=deepcopy(alternative_bindings),
+ binding_report=binding_report,
+ )
+ return (
+ updated,
+ chosen,
+ deepcopy(alternative_bindings),
+ alternative_report,
+ )
+ return adaptation, selected, role_bindings, report
+
+ def _assess_feasibility(
+ self,
+ candidate: Mapping[str, Any],
+ role_bindings: Mapping[str, Any],
+ adaptation: SceneAdaptation,
+ ) -> FeasibilityReport | None:
+ """Intersect task requirements with scene and Action Engine capabilities."""
+ manifest = adaptation.static_scene_manifest
+ registry = getattr(self.action_agent, "registry", None)
+ if manifest is None or registry is None:
+ return None
+ catalog = getattr(registry, "catalog", None)
+ if not callable(catalog):
+ return None
+ return self.feasibility_broker.assess(
+ candidate,
+ role_bindings,
+ manifest,
+ capability_catalog=catalog(),
+ task_actions={
+ task_type: contract.core_actions
+ for task_type, contract in ACTION_TASK_CONTRACTS.items()
+ },
+ )
+
+ def _coerce_source(
+ self,
+ source: SceneSourceRef | str | Path,
+ ) -> SceneSourceRef:
+ if isinstance(source, SceneSourceRef):
+ return source
+ path = Path(source).expanduser()
+ return SceneSourceRef(
+ path,
+ robot_profile=self.scene_adapter.robot_profile,
+ )
+
+
+def _select_candidate_adaptation(
+ adaptation: SceneAdaptation,
+ candidate: Mapping[str, Any],
+ role_bindings: Mapping[str, Any],
+ prior_failures: Sequence[Mapping[str, Any]],
+) -> SceneAdaptation:
+ candidate_id = str(candidate["candidate_id"])
+ current_id = adaptation.selected_candidate_id
+ if candidate_id == current_id and not prior_failures:
+ return adaptation
+ failed = ", ".join(
+ f"{failure['candidate_id']} failed {failure['stage']}"
+ for failure in prior_failures
+ )
+ reason = f"Selected {candidate_id} after {failed}."
+ binding_report = validate_binding_report(
+ {
+ **deepcopy(adaptation.binding_report),
+ "selected_candidate_id": candidate_id,
+ "selection_reason": reason,
+ }
+ )
+ return replace(
+ adaptation,
+ selected_candidate=deepcopy(candidate),
+ role_bindings=deepcopy(role_bindings),
+ binding_report=binding_report,
+ )
+
+
+def _candidate_failure(
+ candidate: Mapping[str, Any],
+ role_bindings: Mapping[str, Any],
+ *,
+ stage: str,
+ error_type: str,
+ error_message: str,
+ feasibility_report: Mapping[str, Any] | None = None,
+ grounded_task_plan: Mapping[str, Any] | None = None,
+ unbound_action_plan: Mapping[str, Any] | None = None,
+ action_graph: Mapping[str, Any] | None = None,
+) -> dict[str, Any]:
+ return {
+ "candidate_id": str(candidate["candidate_id"]),
+ "stage": stage,
+ "draft": deepcopy(candidate["draft"]),
+ "bindings": deepcopy(dict(role_bindings)),
+ "grounded_task_plan": (
+ None if grounded_task_plan is None else deepcopy(dict(grounded_task_plan))
+ ),
+ "unbound_action_plan": (
+ None if unbound_action_plan is None else deepcopy(dict(unbound_action_plan))
+ ),
+ "action_graph": None if action_graph is None else deepcopy(dict(action_graph)),
+ "feasibility_report": (
+ None if feasibility_report is None else deepcopy(dict(feasibility_report))
+ ),
+ "error": {"type": error_type, "message": error_message},
+ }
+
+
+# Short public name used in the phase-one design document.
+def build_grounded_task_plan(
+ *,
+ candidate: Mapping[str, Any],
+ task_spec: Mapping[str, Any],
+ scene_requirements: Mapping[str, Any],
+ scene_manifest: Mapping[str, Any],
+ role_bindings: RoleBindings,
+ binding_report: Mapping[str, Any],
+) -> GroundedTaskPlan:
+ """Assemble a validated plan with hashes over every authoritative hand-off."""
+ draft = deepcopy(candidate["draft"])
+ # A scene-ref quantifier may expand one draft step into several concrete
+ # task instances. The grounded plan records the executable success terms,
+ # while the selected TaskCandidate retains the pre-grounding SuccessSpec.
+ success_spec = {
+ **deepcopy(candidate["success_spec"]),
+ "terms": [
+ {
+ "step_id": str(term["task_instance_id"]),
+ "type": str(term["type"]),
+ }
+ for term in task_spec["success"]["terms"]
+ ],
+ }
+ task = deepcopy(dict(task_spec))
+ requirements = deepcopy(dict(scene_requirements))
+ manifest = deepcopy(dict(scene_manifest))
+ bindings = deepcopy(dict(role_bindings))
+ report = deepcopy(dict(binding_report))
+ base = {
+ "schema_version": GROUNDED_TASK_PLAN_SCHEMA,
+ "task_id": draft["task_id"],
+ "instruction": draft["instruction"],
+ "selected_candidate_id": candidate["candidate_id"],
+ "task_draft": draft,
+ "task_spec": task,
+ "scene_requirements": requirements,
+ "success_spec": success_spec,
+ "scene_manifest": manifest,
+ "role_bindings": bindings,
+ "binding_report": report,
+ }
+ plan = {
+ **base,
+ "hashes": {
+ "task_draft": canonical_hash(draft),
+ "task_spec": canonical_hash(task),
+ "scene_manifest": canonical_hash(manifest),
+ "role_bindings": canonical_hash(bindings),
+ "plan": canonical_hash(base),
+ },
+ }
+ return validate_grounded_task_plan(plan)
+
+
+def _validate_lowered_success(
+ candidate: TaskCandidate,
+ bindings: Mapping[str, list[str]],
+ grounded: GroundedTaskSpec,
+) -> None:
+ success_by_step = {
+ term["step_id"]: term["type"] for term in candidate["success_spec"]["terms"]
+ }
+ expected: list[str] = []
+ multiplicity_by_step: dict[str, int] = {}
+ for step in _topological_steps(candidate["draft"]["steps"]):
+ selector = step["object"]
+ multiplicity = 1
+ if selector["kind"] == "scene_ref":
+ multiplicity = len(bindings.get(f"{step['id']}.object", ()))
+ elif selector["kind"] == "step_result":
+ multiplicity = multiplicity_by_step[str(selector["step_id"])]
+ multiplicity_by_step[str(step["id"])] = multiplicity
+ expected.extend([success_by_step[step["id"]]] * multiplicity)
+ actual = [term.get("type") for term in grounded.task_spec["success"]["terms"]]
+ if actual != expected:
+ raise ValueError(
+ "Lowered TaskSpec success terms do not match the expanded SuccessSpec."
+ )
+
+
+def _topological_steps(
+ steps: list[Mapping[str, Any]],
+) -> list[Mapping[str, Any]]:
+ positions = {str(step["id"]): index for index, step in enumerate(steps)}
+ pending = {str(step["id"]): set(step["depends_on"]) for step in steps}
+ result: list[Mapping[str, Any]] = []
+ emitted: set[str] = set()
+ while len(result) < len(steps):
+ ready = [
+ step
+ for step in steps
+ if step["id"] not in emitted and pending[str(step["id"])] <= emitted
+ ]
+ if not ready:
+ raise ValueError("TaskDraft step dependencies contain a cycle.")
+ step = min(ready, key=lambda item: positions[str(item["id"])])
+ result.append(step)
+ emitted.add(str(step["id"]))
+ return result
+
+
+def _require_matching_generated_graph(
+ generated: GeneratedConfigPaths,
+ expected: Mapping[str, Any],
+) -> None:
+ """Catch a compatibility-generator drift before publishing the bundle."""
+ graph_path = getattr(generated, "seed_task_graph", None)
+ if graph_path is None or not Path(graph_path).is_file():
+ # Injected generators used by API consumers may publish by other means.
+ # Task Engine's independently planned graph remains authoritative.
+ return
+ try:
+ actual = json.loads(Path(graph_path).read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise ValueError(f"Generated SeedGraph is unreadable: {graph_path}") from exc
+ if canonical_hash(actual) != canonical_hash(expected):
+ raise ValueError(
+ "Legacy bundle generation produced a SeedGraph different from "
+ "ActionAgent.plan."
+ )
+
+
+def _write_compatibility_input(path: Path, value: Mapping[str, Any]) -> None:
+ path.write_text(
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
+ encoding="utf-8",
+ )
diff --git a/embodichain/gen_sim/task_engine/orchestration/legacy_scene.py b/embodichain/gen_sim/task_engine/orchestration/legacy_scene.py
new file mode 100644
index 000000000..4cb17ff7d
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/orchestration/legacy_scene.py
@@ -0,0 +1,381 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Read-only conversion of legacy Gym projects into editable scene revisions."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+from dataclasses import dataclass
+import json
+from pathlib import Path
+import shutil
+from typing import Any, Final
+
+import numpy as np
+from scipy.spatial.transform import Rotation
+import trimesh
+
+from embodichain.gen_sim.action_engine.generation.source_scene import (
+ prepare_scene,
+ resolve_source_scene,
+)
+
+from .scene_source import (
+ SceneSourceFingerprint,
+ fingerprint_scene_source,
+ verify_scene_source_fingerprint,
+)
+
+__all__ = [
+ "LEGACY_SCENE_CONVERSION_SCHEMA",
+ "LegacySceneRevision",
+ "convert_legacy_gym_project",
+ "restore_locked_scene_entities",
+]
+
+LEGACY_SCENE_CONVERSION_SCHEMA: Final = "embodichain.legacy-scene-conversion/v1"
+_CONVERSION_MANIFEST = "legacy_conversion.json"
+
+
+@dataclass(frozen=True)
+class LegacySceneRevision:
+ """A new editable revision derived without modifying its legacy source."""
+
+ output_root: Path
+ scene_config_path: Path
+ scene_graph_path: Path
+ manifest_path: Path
+ source_fingerprint: SceneSourceFingerprint
+ locked_entity_uids: tuple[str, ...]
+
+
+def convert_legacy_gym_project(
+ source: str | Path,
+ output_root: str | Path,
+) -> LegacySceneRevision:
+ """Convert a supported legacy Gym project into a Scene Engine revision.
+
+ Args:
+ source: Legacy Gym project directory or explicit configuration path.
+ output_root: Empty destination owned by the new scene revision.
+
+ Returns:
+ Paths and provenance for the converted revision.
+
+ Raises:
+ ValueError: If the source is not legacy or the destination already exists.
+ FileNotFoundError: If a referenced source asset is missing.
+ """
+ resolved = resolve_source_scene(source)
+ if resolved.source_format != "legacy_gym_config":
+ raise ValueError("Legacy conversion requires a legacy Gym configuration.")
+ destination = Path(output_root).expanduser().resolve()
+ if destination.exists():
+ if not destination.is_dir() or any(destination.iterdir()):
+ raise ValueError("Legacy scene revision output_root must be empty.")
+ source_fingerprint = fingerprint_scene_source(source)
+ prepared = prepare_scene(source)
+ export_root = destination / "scene_export"
+ assets_root = export_root / "mesh_assets"
+ assets_root.mkdir(parents=True, exist_ok=True)
+ semantics = {str(item.get("uid")): item for item in prepared.planner_objects}
+
+ background = [
+ _editable_entry(item, semantics=semantics, assets_root=assets_root)
+ for item in prepared.background
+ ]
+ rigid_objects = [
+ _editable_entry(item, semantics=semantics, assets_root=assets_root)
+ for item in prepared.rigid_objects
+ ]
+ articulations = [
+ _locked_articulation(
+ item,
+ source_root=resolved.path.parent,
+ destination_root=export_root / "locked_assets",
+ )
+ for item in prepared.articulations
+ ]
+ table = next((item for item in background if item.get("uid") == "table"), None)
+ if table is None:
+ raise ValueError("Legacy conversion requires one table support object.")
+ _measure_support_metadata(table, export_root=export_root)
+ for item in rigid_objects:
+ _measure_center(item, export_root=export_root)
+
+ scene_config = {
+ "format": "embodichain.scene-export/v1",
+ "scene_id": f"legacy-revision-{source_fingerprint.config_sha256[:16]}",
+ "background": background,
+ "rigid_object": rigid_objects,
+ "articulation": articulations,
+ }
+ scene_config_path = export_root / "scene_config.json"
+ _write_json(scene_config_path, scene_config)
+ scene_graph = {
+ "nodes": [
+ {
+ "object_id": "table",
+ "parent_id": None,
+ "parent_relation": None,
+ "table_region": None,
+ "orientation_state": None,
+ },
+ *[
+ {
+ "object_id": str(item["uid"]),
+ "parent_id": "table",
+ "parent_relation": "on",
+ "table_region": None,
+ "orientation_state": None,
+ }
+ for item in rigid_objects
+ ],
+ ],
+ "relations": [],
+ }
+ scene_graph_path = export_root / "scene_graph.json"
+ _write_json(scene_graph_path, scene_graph)
+ locked_uids = tuple(
+ sorted(str(item["uid"]) for item in [*background, *articulations])
+ )
+ manifest = {
+ "schema_version": LEGACY_SCENE_CONVERSION_SCHEMA,
+ "source": source_fingerprint.to_dict(),
+ "scene_config": scene_config_path.as_posix(),
+ "audit_hierarchy": "unknown",
+ "operational_hierarchy": "assumed_on_table",
+ "assumptions": [
+ {
+ "uid": str(item["uid"]),
+ "relation": "on",
+ "parent_uid": "table",
+ "confidence": None,
+ "source": "operational_assumption",
+ }
+ for item in rigid_objects
+ ],
+ "locked_entity_uids": list(locked_uids),
+ "locked_articulations": deepcopy(articulations),
+ "locked_background": deepcopy(
+ [item for item in background if item.get("uid") != "table"]
+ ),
+ }
+ manifest_path = destination / _CONVERSION_MANIFEST
+ _write_json(manifest_path, manifest)
+ verify_scene_source_fingerprint(source_fingerprint.to_dict())
+ return LegacySceneRevision(
+ output_root=destination,
+ scene_config_path=scene_config_path,
+ scene_graph_path=scene_graph_path,
+ manifest_path=manifest_path,
+ source_fingerprint=source_fingerprint,
+ locked_entity_uids=locked_uids,
+ )
+
+
+def restore_locked_scene_entities(revision_root: str | Path) -> Path:
+ """Restore collision-only legacy entities after Scene Engine export.
+
+ Args:
+ revision_root: Converted revision root containing ``legacy_conversion.json``.
+
+ Returns:
+ Updated scene configuration path.
+
+ Raises:
+ FileNotFoundError: If the conversion manifest or scene config is absent.
+ ValueError: If a generated scene attempts to reuse a locked UID.
+ """
+ root = Path(revision_root).expanduser().resolve()
+ manifest_path = root / _CONVERSION_MANIFEST
+ if not manifest_path.is_file():
+ raise FileNotFoundError(
+ f"Legacy conversion manifest not found: {manifest_path}"
+ )
+ manifest = _read_mapping(manifest_path)
+ if manifest.get("schema_version") != LEGACY_SCENE_CONVERSION_SCHEMA:
+ raise ValueError("Legacy conversion manifest schema is invalid.")
+ config_path = root / "scene_export" / "scene_config.json"
+ config = _read_mapping(config_path)
+ existing = {
+ str(item.get("uid"))
+ for section in ("background", "rigid_object", "articulation")
+ for item in config.get(section, ())
+ if isinstance(item, Mapping) and item.get("uid")
+ }
+ for section, key in (
+ ("background", "locked_background"),
+ ("articulation", "locked_articulations"),
+ ):
+ values = manifest.get(key, ())
+ if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
+ raise TypeError(f"Legacy conversion manifest {key} must be a sequence.")
+ target = list(config.get(section, ()))
+ for raw in values:
+ item = deepcopy(dict(raw))
+ uid = str(item.get("uid", ""))
+ if uid in existing:
+ raise ValueError(f"Generated scene reused locked entity UID {uid!r}.")
+ existing.add(uid)
+ target.append(item)
+ config[section] = target
+ _write_json(config_path, config)
+ verify_scene_source_fingerprint(manifest["source"])
+ return config_path
+
+
+def _editable_entry(
+ value: Mapping[str, Any],
+ *,
+ semantics: Mapping[str, Mapping[str, Any]],
+ assets_root: Path,
+) -> dict[str, Any]:
+ item = deepcopy(dict(value))
+ uid = str(item.get("uid", "")).strip()
+ if not uid:
+ raise ValueError("Converted scene entities require a UID.")
+ semantic = semantics.get(uid, {})
+ for key in ("category", "name", "description"):
+ item[key] = str(semantic.get(key) or item.get(key) or uid)
+ shape = item.get("shape")
+ if not isinstance(shape, Mapping):
+ raise ValueError(f"Legacy scene entity {uid!r} has no supported shape.")
+ destination = assets_root / uid / f"{uid}.glb"
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ _shape_to_glb(shape, destination)
+ item["shape"] = {
+ "shape_type": "Mesh",
+ "fpath": destination.relative_to(assets_root.parent).as_posix(),
+ "compute_uv": False,
+ }
+ item.setdefault("body_scale", [1.0, 1.0, 1.0])
+ item.setdefault("init_pos", [0.0, 0.0, 0.0])
+ item.setdefault("init_rot", [0.0, 0.0, 0.0])
+ item.setdefault("attrs", {"mass": 1.0})
+ item.setdefault("body_type", "kinematic" if uid == "table" else "dynamic")
+ item.setdefault("max_convex_hull_num", 1 if uid == "table" else 16)
+ return item
+
+
+def _shape_to_glb(shape: Mapping[str, Any], destination: Path) -> None:
+ shape_type = str(shape.get("shape_type", ""))
+ if shape_type == "Mesh":
+ source = Path(str(shape.get("fpath", ""))).expanduser().resolve()
+ if not source.is_file():
+ raise FileNotFoundError(f"Legacy mesh asset not found: {source}")
+ mesh = trimesh.load(source, force="scene")
+ elif shape_type == "Cube":
+ size = _vector(shape.get("size", [1.0, 1.0, 1.0]), length=3)
+ mesh = trimesh.Scene(trimesh.creation.box(extents=size))
+ elif shape_type == "Sphere":
+ radius = float(shape.get("radius", 1.0))
+ if not np.isfinite(radius) or radius <= 0.0:
+ raise ValueError("Legacy sphere radius must be positive and finite.")
+ mesh = trimesh.Scene(trimesh.creation.icosphere(radius=radius))
+ else:
+ raise ValueError(f"Unsupported legacy shape_type {shape_type!r}.")
+ mesh.export(destination, file_type="glb")
+
+
+def _locked_articulation(
+ value: Mapping[str, Any],
+ *,
+ source_root: Path,
+ destination_root: Path,
+) -> dict[str, Any]:
+ item = deepcopy(dict(value))
+ uid = str(item.get("uid", "")).strip()
+ raw = Path(str(item.get("fpath", ""))).expanduser()
+ source = raw.resolve() if raw.is_absolute() else (source_root / raw).resolve()
+ if not source.is_file():
+ raise FileNotFoundError(f"Legacy articulation asset not found: {source}")
+ target_root = destination_root / uid
+ shutil.copytree(source.parent, target_root, dirs_exist_ok=True)
+ copied = target_root / source.name
+ item["fpath"] = copied.resolve().as_posix()
+ return item
+
+
+def _measure_support_metadata(entry: dict[str, Any], *, export_root: Path) -> None:
+ bounds = _world_bounds(entry, export_root=export_root)
+ entry["support_surface_z"] = float(bounds[1, 2])
+ rectangle = [
+ [float(bounds[0, 0]), float(bounds[0, 1])],
+ [float(bounds[1, 0]), float(bounds[0, 1])],
+ [float(bounds[1, 0]), float(bounds[1, 1])],
+ [float(bounds[0, 0]), float(bounds[1, 1])],
+ ]
+ entry["support_contour_xy"] = rectangle
+ entry["support_optimization_rect_xy"] = deepcopy(rectangle)
+ entry["center_xy"] = [
+ float((bounds[0, 0] + bounds[1, 0]) / 2.0),
+ float((bounds[0, 1] + bounds[1, 1]) / 2.0),
+ ]
+
+
+def _measure_center(entry: dict[str, Any], *, export_root: Path) -> None:
+ bounds = _world_bounds(entry, export_root=export_root)
+ entry["center_xy"] = [
+ float((bounds[0, 0] + bounds[1, 0]) / 2.0),
+ float((bounds[0, 1] + bounds[1, 1]) / 2.0),
+ ]
+
+
+def _world_bounds(entry: Mapping[str, Any], *, export_root: Path) -> np.ndarray:
+ shape = dict(entry["shape"])
+ mesh_path = (export_root / str(shape["fpath"])).resolve()
+ loaded = trimesh.load(mesh_path, force="scene")
+ mesh = loaded.to_geometry()
+ scale = np.asarray(_vector(entry.get("body_scale", [1.0] * 3), length=3))
+ mesh.apply_scale(scale)
+ transform = np.eye(4)
+ transform[:3, :3] = Rotation.from_euler(
+ "XYZ",
+ _vector(entry.get("init_rot", [0.0] * 3), length=3),
+ degrees=True,
+ ).as_matrix()
+ transform[:3, 3] = _vector(entry.get("init_pos", [0.0] * 3), length=3)
+ mesh.apply_transform(transform)
+ return np.asarray(mesh.bounds, dtype=float)
+
+
+def _vector(value: Any, *, length: int) -> list[float]:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise TypeError("Legacy scene vector must be a sequence.")
+ result = [float(item) for item in value]
+ if len(result) != length or not np.all(np.isfinite(result)):
+ raise ValueError(f"Legacy scene vector must contain {length} finite values.")
+ return result
+
+
+def _read_mapping(path: Path) -> dict[str, Any]:
+ if not path.is_file():
+ raise FileNotFoundError(path)
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, Mapping):
+ raise TypeError(f"JSON document must contain an object: {path}")
+ return dict(value)
+
+
+def _write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
+ encoding="utf-8",
+ )
diff --git a/embodichain/gen_sim/task_engine/orchestration/scene_adapter.py b/embodichain/gen_sim/task_engine/orchestration/scene_adapter.py
new file mode 100644
index 000000000..58ee084f8
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/orchestration/scene_adapter.py
@@ -0,0 +1,1049 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Bind Task Agent candidates to a redacted, authoritative scene inventory."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+from copy import deepcopy
+from dataclasses import dataclass, field
+import hashlib
+import json
+from pathlib import Path
+from typing import Any
+
+from embodichain.gen_sim.action_engine.generation.models import PreparedScene
+from embodichain.gen_sim.action_engine.generation.source_scene import (
+ prepare_scene,
+ resolve_source_scene,
+)
+from embodichain.gen_sim.action_engine.tasks.assembly import (
+ SceneInventory,
+ validate_source_compatibility,
+ validate_target_compatibility,
+)
+from embodichain.gen_sim.action_engine.tasks.grounding import (
+ GroundingCaller,
+ ground_scene_references,
+)
+from embodichain.gen_sim.task_engine import TaskCandidate, TaskCandidateSet
+from embodichain.gen_sim.task_engine.interpretation import (
+ _default_instruction_caller,
+)
+from embodichain.gen_sim.task_engine.scene import (
+ ConservativeSceneGraph,
+ SceneEngineV1Adapter,
+ StaticSceneManifest,
+ build_conservative_scene_graph,
+ validate_static_scene_manifest,
+)
+from embodichain.gen_sim.task_engine.scene.final_inspection import (
+ apply_final_inspection,
+ validate_final_scene_inspection,
+)
+
+from .contracts import (
+ BINDING_REPORT_SCHEMA,
+ ROLE_BINDINGS_SCHEMA,
+ SCENE_MANIFEST_SCHEMA,
+ BindingReport,
+ RoleBindings,
+ SceneManifest,
+ validate_binding_report,
+ validate_role_bindings,
+ validate_scene_manifest,
+ validate_task_candidate,
+ validate_task_candidate_set,
+)
+from .scene_source import (
+ SceneSourceRef,
+ fingerprint_scene_source,
+ scene_revision_id,
+)
+
+__all__ = [
+ "Adjudicator",
+ "CandidateSelection",
+ "SceneAdaptation",
+ "SceneAdapter",
+ "SceneAdapterProtocolError",
+]
+
+
+Adjudicator = Callable[..., Mapping[str, Any]]
+
+_REDACTED_KEYS = frozenset(
+ {
+ "absolute_position",
+ "bbox",
+ "bboxes",
+ "bounding_box",
+ "center",
+ "centroid",
+ "coordinates",
+ "dimensions",
+ "extrinsics",
+ "grasp_pose",
+ "init_local_pose",
+ "init_pos",
+ "init_rot",
+ "intrinsics",
+ "joint_positions",
+ "joints",
+ "keypoint",
+ "keypoints",
+ "location",
+ "matrix",
+ "pose",
+ "position",
+ "position_xyz",
+ "qpos",
+ "quaternion",
+ "rotation",
+ "scale",
+ "target_pose",
+ "trajectory",
+ "transform",
+ "translation",
+ "waypoints",
+ "world_x",
+ "world_y",
+ "world_z",
+ "x",
+ "y",
+ "z",
+ }
+)
+
+
+class SceneAdapterProtocolError(ValueError):
+ """The grounding or adjudication transport violated its JSON protocol."""
+
+
+@dataclass(frozen=True)
+class CandidateSelection:
+ """Candidate binding against semantic scene data before materialization."""
+
+ scene_manifest: SceneManifest
+ role_bindings: RoleBindings | None
+ binding_report: BindingReport
+ selected_candidate: TaskCandidate | None
+ candidate_bindings: dict[str, RoleBindings] = field(default_factory=dict)
+
+ @property
+ def selected_candidate_id(self) -> str | None:
+ """Return the chosen candidate identifier, when one was bindable."""
+ return (
+ str(self.selected_candidate["candidate_id"])
+ if self.selected_candidate is not None
+ else None
+ )
+
+
+@dataclass(frozen=True)
+class SceneAdaptation:
+ """Complete Scene Adapter result, including the reusable prepared scene."""
+
+ scene_manifest: SceneManifest
+ role_bindings: RoleBindings | None
+ binding_report: BindingReport
+ selected_candidate: TaskCandidate | None
+ prepared_scene: PreparedScene
+ source_config_path: Path
+ conservative_scene_graph: ConservativeSceneGraph
+ static_scene_manifest: StaticSceneManifest | None = None
+ candidate_bindings: dict[str, RoleBindings] = field(default_factory=dict)
+
+ @property
+ def selected_candidate_id(self) -> str | None:
+ return (
+ str(self.selected_candidate["candidate_id"])
+ if self.selected_candidate is not None
+ else None
+ )
+
+ @property
+ def reference_bindings(self) -> dict[str, list[str]]:
+ if self.role_bindings is None:
+ return {}
+ return deepcopy(self.role_bindings["reference_bindings"])
+
+
+class SceneAdapter:
+ """Adapt one existing or packaged scene to a set of task candidates."""
+
+ def __init__(
+ self,
+ *,
+ model: str | None = None,
+ grounding_caller: GroundingCaller | None = None,
+ adjudicator: Adjudicator | None = None,
+ robot_profile: str = "franka",
+ scene_engine_adapter: SceneEngineV1Adapter | None = None,
+ ) -> None:
+ self.model = model
+ self.grounding_caller = grounding_caller
+ self.adjudicator = adjudicator
+ self.robot_profile = robot_profile
+ self.scene_engine_adapter = scene_engine_adapter or SceneEngineV1Adapter()
+
+ def adapt(
+ self,
+ candidate_set: TaskCandidateSet | Sequence[Mapping[str, Any]],
+ source: SceneSourceRef | str | Path,
+ *,
+ grounding_caller: GroundingCaller | None = None,
+ adjudicator: Adjudicator | None = None,
+ force_most_likely: bool = False,
+ final_inspection: Mapping[str, Any] | None = None,
+ ) -> SceneAdaptation:
+ """Ground all candidates, then deterministically choose a bindable one."""
+ task_id, instruction, candidates = _coerce_candidates(candidate_set)
+ source_ref = self._resolve_source(source)
+ source_fingerprint = fingerprint_scene_source(source_ref)
+ prepared = prepare_scene(
+ source_ref.path,
+ z_rotation_degrees=source_ref.z_rotation_degrees,
+ body_scale_policy=source_ref.body_scale_policy,
+ body_scale=source_ref.body_scale,
+ )
+ if final_inspection is not None:
+ normalized_inspection = validate_final_scene_inspection(final_inspection)
+ if normalized_inspection["scene_revision_id"] != scene_revision_id(
+ source_ref
+ ):
+ raise ValueError(
+ "FinalSceneInspection does not describe the adapted scene revision."
+ )
+ prepared = apply_final_inspection(prepared, normalized_inspection)
+ inventory = SceneInventory(
+ prepared.planner_objects,
+ robot_profile=source_ref.robot_profile,
+ )
+ resolved_source = resolve_source_scene(source_ref.path)
+ manifest = _build_manifest(
+ prepared,
+ inventory,
+ source_format=resolved_source.source_format,
+ )
+ static_manifest = self.scene_engine_adapter.adapt_prepared_scene(
+ prepared,
+ source_format=resolved_source.source_format,
+ robot_profile=inventory.profile,
+ )
+ static_manifest["source"]["source_fingerprint"] = source_fingerprint.to_dict()
+ static_manifest = validate_static_scene_manifest(static_manifest)
+ conservative_scene_graph = build_conservative_scene_graph(
+ prepared,
+ scene_id=static_manifest["scene_id"],
+ )
+ if fingerprint_scene_source(source_ref) != source_fingerprint:
+ raise RuntimeError("Source Gym project changed while it was being adapted.")
+
+ selection = self._select_candidates(
+ task_id,
+ instruction,
+ candidates,
+ manifest=manifest,
+ inventory=inventory,
+ scene_objects=prepared.planner_objects,
+ grounding_caller=grounding_caller,
+ adjudicator=adjudicator,
+ force_most_likely=force_most_likely,
+ )
+ return SceneAdaptation(
+ scene_manifest=manifest,
+ role_bindings=selection.role_bindings,
+ binding_report=selection.binding_report,
+ selected_candidate=selection.selected_candidate,
+ prepared_scene=prepared,
+ source_config_path=prepared.source_config_path,
+ conservative_scene_graph=conservative_scene_graph,
+ static_scene_manifest=static_manifest,
+ candidate_bindings=selection.candidate_bindings,
+ )
+
+ def select_objects(
+ self,
+ candidate_set: TaskCandidateSet | Sequence[Mapping[str, Any]],
+ scene_objects: Sequence[Mapping[str, Any]],
+ *,
+ source_format: str = "embodichain.scene-blueprint/v1",
+ robot_profile: str | None = None,
+ grounding_caller: GroundingCaller | None = None,
+ adjudicator: Adjudicator | None = None,
+ force_most_likely: bool = False,
+ ) -> CandidateSelection:
+ """Bind candidates to semantic objects before assets are generated.
+
+ Args:
+ candidate_set: Validated Task Engine candidate set.
+ scene_objects: Blueprint-level semantic object records.
+ source_format: Provenance label included in the semantic manifest.
+ robot_profile: Optional robot profile override.
+ grounding_caller: Optional structured grounding transport.
+ adjudicator: Optional candidate tie-breaker.
+ force_most_likely: Resolve ranked UID hypotheses instead of rejecting
+ low-confidence or ambiguous responses.
+
+ Returns:
+ Audited candidate selection without requiring generated assets.
+ """
+ task_id, instruction, candidates = _coerce_candidates(candidate_set)
+ inventory = SceneInventory(
+ scene_objects,
+ robot_profile=robot_profile or self.robot_profile,
+ )
+ manifest = _build_semantic_manifest(
+ inventory,
+ source_format=source_format,
+ )
+ return self._select_candidates(
+ task_id,
+ instruction,
+ candidates,
+ manifest=manifest,
+ inventory=inventory,
+ scene_objects=scene_objects,
+ grounding_caller=grounding_caller,
+ adjudicator=adjudicator,
+ force_most_likely=force_most_likely,
+ )
+
+ def _select_candidates(
+ self,
+ task_id: str,
+ instruction: str,
+ candidates: Sequence[TaskCandidate],
+ *,
+ manifest: SceneManifest,
+ inventory: SceneInventory,
+ scene_objects: Sequence[Mapping[str, Any]],
+ grounding_caller: GroundingCaller | None,
+ adjudicator: Adjudicator | None,
+ force_most_likely: bool,
+ ) -> CandidateSelection:
+ invoke = grounding_caller or self.grounding_caller
+ use_default_adjudicator = invoke is None
+ if invoke is None:
+ invoke = _default_grounding_caller()
+ choose = adjudicator or self.adjudicator
+ if choose is None and use_default_adjudicator:
+ choose = _default_adjudicator(model=self.model)
+ audits: list[dict[str, Any]] = []
+ bindings_by_candidate: dict[str, dict[str, tuple[str, ...]]] = {}
+ for candidate in candidates:
+ audit, bindings = _ground_candidate(
+ candidate,
+ instruction=instruction,
+ inventory=inventory,
+ scene_objects=scene_objects,
+ model=self.model,
+ caller=invoke,
+ force_most_likely=force_most_likely,
+ )
+ audits.append(audit)
+ if bindings is not None:
+ bindings_by_candidate[str(candidate["candidate_id"])] = bindings
+
+ selected_id, status, reason = _select_candidate(
+ candidates,
+ audits,
+ manifest=manifest,
+ instruction=instruction,
+ adjudicator=choose,
+ )
+ report = validate_binding_report(
+ {
+ "schema_version": BINDING_REPORT_SCHEMA,
+ "task_id": task_id,
+ "status": status,
+ "selected_candidate_id": selected_id or "",
+ "selection_reason": reason,
+ "candidates": audits,
+ }
+ )
+ selected = next(
+ (
+ deepcopy(candidate)
+ for candidate in candidates
+ if candidate["candidate_id"] == selected_id
+ ),
+ None,
+ )
+ candidate_bindings = {
+ candidate_id: validate_role_bindings(
+ {
+ "schema_version": ROLE_BINDINGS_SCHEMA,
+ "task_id": task_id,
+ "candidate_id": candidate_id,
+ "reference_bindings": {
+ key: list(value) for key, value in sorted(raw_bindings.items())
+ },
+ "role_bindings": {},
+ }
+ )
+ for candidate_id, raw_bindings in bindings_by_candidate.items()
+ }
+ role_bindings = None if selected_id is None else candidate_bindings[selected_id]
+ return CandidateSelection(
+ scene_manifest=manifest,
+ role_bindings=role_bindings,
+ binding_report=report,
+ selected_candidate=selected,
+ candidate_bindings=candidate_bindings,
+ )
+
+ def _resolve_source(
+ self,
+ source: SceneSourceRef | str | Path,
+ ) -> SceneSourceRef:
+ if isinstance(source, SceneSourceRef):
+ return source
+ return SceneSourceRef(source, robot_profile=self.robot_profile)
+
+
+def _coerce_candidates(
+ value: TaskCandidateSet | Sequence[Mapping[str, Any]],
+) -> tuple[str, str, list[TaskCandidate]]:
+ if isinstance(value, Mapping):
+ normalized = validate_task_candidate_set(value)
+ return (
+ normalized["task_id"],
+ normalized["instruction"],
+ normalized["candidates"],
+ )
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
+ candidates = [validate_task_candidate(candidate) for candidate in value]
+ if not candidates:
+ raise ValueError("SceneAdapter requires at least one TaskCandidate.")
+ task_ids = {candidate["draft"]["task_id"] for candidate in candidates}
+ instructions = {candidate["draft"]["instruction"] for candidate in candidates}
+ if len(task_ids) != 1 or len(instructions) != 1:
+ raise ValueError("All TaskCandidates must describe the same task.")
+ return task_ids.pop(), instructions.pop(), candidates
+ raise TypeError("candidate_set must be a TaskCandidateSet or candidate sequence.")
+
+
+def _default_grounding_caller() -> GroundingCaller:
+ # Keep provider setup lazy so package import and offline tests never load an
+ # LLM client. This is the same structured transport used by interpretation.
+ return _default_instruction_caller
+
+
+def _default_adjudicator(*, model: str | None) -> Adjudicator:
+ caller = _default_grounding_caller()
+
+ def adjudicate(**kwargs: Any) -> Mapping[str, Any]:
+ candidates = [
+ {
+ key: deepcopy(candidate[key])
+ for key in (
+ "candidate_id",
+ "draft",
+ "scene_request",
+ "success_spec",
+ "vote_count",
+ )
+ }
+ for candidate in kwargs["candidates"]
+ ]
+ allowed = [str(candidate["candidate_id"]) for candidate in candidates]
+ schema = {
+ "title": "ActionEngineTaskAdjudication",
+ "type": "object",
+ "additionalProperties": False,
+ "required": ["candidate_id"],
+ "properties": {
+ "candidate_id": {"type": "string", "enum": allowed},
+ },
+ }
+ prompt = (
+ "Select exactly one already verified, fully bindable task candidate "
+ "that best matches the instruction and redacted scene manifest. Do "
+ "not alter a candidate or invent a new interpretation. Return only "
+ "candidate_id.\n\n"
+ f"Instruction:\n{kwargs['instruction']}\n\n"
+ "Candidates:\n"
+ f"{json.dumps(candidates, ensure_ascii=False, sort_keys=True)}\n\n"
+ "Redacted scene manifest:\n"
+ f"{json.dumps(kwargs['scene_manifest'], ensure_ascii=False, sort_keys=True)}"
+ )
+ try:
+ return caller(prompt=prompt, schema=schema, model=model)
+ except (TypeError, ValueError) as exc:
+ raise SceneAdapterProtocolError(
+ f"Task adjudication returned invalid structured output: {exc}"
+ ) from exc
+
+ return adjudicate
+
+
+def _ground_candidate(
+ candidate: TaskCandidate,
+ *,
+ instruction: str,
+ inventory: SceneInventory,
+ scene_objects: Sequence[Mapping[str, Any]],
+ model: str | None,
+ caller: GroundingCaller,
+ force_most_likely: bool,
+) -> tuple[dict[str, Any], dict[str, tuple[str, ...]] | None]:
+ responses: list[Any] = []
+
+ def audited_caller(**kwargs: Any) -> Mapping[str, Any]:
+ call_kwargs = dict(kwargs)
+ if force_most_likely:
+ call_kwargs["prompt"] = (
+ f"{kwargs['prompt']}\n\nFINAL BINDING OVERRIDE: do not return "
+ "ambiguous merely because confidence is low. Choose the most "
+ "likely existing UID that satisfies the supplied structured "
+ "role, affordance, state, and attribute metadata. Return "
+ "candidate UIDs in descending likelihood order. Do not invent, "
+ "add, delete, move, or modify any scene object. Use not_found "
+ "when no structurally compatible existing object is plausible."
+ )
+ response = caller(**call_kwargs)
+ responses.append(deepcopy(response))
+ if force_most_likely:
+ return _force_most_likely_response(response, candidate=candidate)
+ return response
+
+ candidate_id = str(candidate["candidate_id"])
+ try:
+ result = ground_scene_references(
+ instruction=instruction,
+ intent=candidate["draft"],
+ inventory=inventory,
+ scene_objects=scene_objects,
+ model=model,
+ caller=audited_caller,
+ )
+ except (TypeError, ValueError) as exc:
+ if responses:
+ audits = _audit_unresolved_response(
+ responses[-1],
+ candidate=candidate,
+ inventory=inventory,
+ error=str(exc),
+ )
+ status = _candidate_status(audits)
+ return (
+ _candidate_audit(candidate, status, audits, [str(exc)]),
+ None,
+ )
+ raise SceneAdapterProtocolError(
+ f"Grounding candidate {candidate_id!r} failed before returning JSON: {exc}"
+ ) from exc
+
+ raw_bindings = result.bindings
+ response_by_id = _response_bindings(responses[-1], candidate=candidate)
+ self_reference_reasons = _self_reference_reasons(candidate["draft"], raw_bindings)
+ reference_audits = []
+ incompatible: set[str] = set()
+ request_by_id = {
+ str(request["reference_id"]): request
+ for request in candidate["scene_request"]["references"]
+ }
+ for reference_id, uids in raw_bindings.items():
+ compatibility_reasons = _compatibility_reasons(
+ request_by_id[reference_id],
+ uids,
+ inventory=inventory,
+ draft=candidate["draft"],
+ )
+ compatibility_reasons.extend(self_reference_reasons.get(reference_id, ()))
+ compatibility_reasons = sorted(set(compatibility_reasons))
+ if compatibility_reasons:
+ incompatible.add(reference_id)
+ response = response_by_id[reference_id]
+ audit_reasons = list(compatibility_reasons)
+ if (
+ force_most_likely
+ and response.get("status") == "ambiguous"
+ and response.get("uids")
+ ):
+ audit_reasons.append(
+ "Forced the highest-ranked structurally compatible UID from an "
+ "ambiguous low-confidence response."
+ )
+ reference_audits.append(
+ {
+ "reference_id": reference_id,
+ "status": ("incompatible" if compatibility_reasons else "resolved"),
+ "confidence": float(response["confidence"]),
+ "candidate_uids": list(response["uids"]),
+ "selected_uids": ([] if compatibility_reasons else list(uids)),
+ "reasons": audit_reasons,
+ }
+ )
+ if incompatible:
+ reasons = [
+ f"Reference {reference_id!r} conflicts with authoritative scene semantics."
+ for reference_id in sorted(incompatible)
+ ]
+ return (
+ _candidate_audit(candidate, "incompatible", reference_audits, reasons),
+ None,
+ )
+ return _candidate_audit(candidate, "resolved", reference_audits, []), dict(
+ raw_bindings
+ )
+
+
+def _force_most_likely_response(
+ response: Mapping[str, Any],
+ *,
+ candidate: TaskCandidate,
+) -> Mapping[str, Any]:
+ """Turn ranked low-confidence UID hypotheses into explicit selections."""
+ if not isinstance(response, Mapping) or set(response) != {"bindings"}:
+ return response
+ requests = {
+ str(item["reference_id"]): item
+ for item in candidate["scene_request"]["references"]
+ }
+ raw_bindings = response.get("bindings")
+ if not isinstance(raw_bindings, Sequence) or isinstance(raw_bindings, (str, bytes)):
+ return response
+ result = deepcopy(dict(response))
+ values = []
+ for raw in raw_bindings:
+ if not isinstance(raw, Mapping):
+ return response
+ item = deepcopy(dict(raw))
+ request = requests.get(str(item.get("reference_id", "")))
+ uids = item.get("uids")
+ if (
+ request is not None
+ and item.get("status") in {"resolved", "ambiguous"}
+ and isinstance(uids, Sequence)
+ and not isinstance(uids, (str, bytes))
+ and uids
+ ):
+ quantifier = str(request["quantifier"])
+ count = int(request["count"])
+ if quantifier == "one":
+ item["uids"] = list(uids[:1])
+ elif quantifier == "count":
+ item["uids"] = list(uids[:count])
+ item["status"] = "resolved"
+ confidence = item.get("confidence")
+ if isinstance(confidence, (int, float)) and not isinstance(
+ confidence, bool
+ ):
+ item["confidence"] = max(0.5, float(confidence))
+ values.append(item)
+ result["bindings"] = values
+ return result
+
+
+def _response_bindings(
+ response: Any,
+ *,
+ candidate: TaskCandidate,
+) -> dict[str, Mapping[str, Any]]:
+ expected = {
+ str(request["reference_id"])
+ for request in candidate["scene_request"]["references"]
+ }
+ if not isinstance(response, Mapping) or set(response) != {"bindings"}:
+ raise SceneAdapterProtocolError(
+ "Grounding response must contain only bindings."
+ )
+ values = response["bindings"]
+ if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
+ raise SceneAdapterProtocolError("Grounding response bindings must be a list.")
+ result: dict[str, Mapping[str, Any]] = {}
+ for raw in values:
+ if not isinstance(raw, Mapping):
+ raise SceneAdapterProtocolError(
+ "Every grounding binding must be a mapping."
+ )
+ reference_id = raw.get("reference_id")
+ if not isinstance(reference_id, str) or reference_id not in expected:
+ raise SceneAdapterProtocolError(
+ "Grounding response contains an unknown reference ID."
+ )
+ if reference_id in result:
+ raise SceneAdapterProtocolError(
+ "Grounding response contains duplicate reference IDs."
+ )
+ result[reference_id] = raw
+ if set(result) != expected:
+ raise SceneAdapterProtocolError(
+ "Grounding response omitted requested reference IDs."
+ )
+ return result
+
+
+def _audit_unresolved_response(
+ response: Any,
+ *,
+ candidate: TaskCandidate,
+ inventory: SceneInventory,
+ error: str,
+) -> list[dict[str, Any]]:
+ by_id = _response_bindings(response, candidate=candidate)
+ audits: list[dict[str, Any]] = []
+ for request in candidate["scene_request"]["references"]:
+ reference_id = str(request["reference_id"])
+ raw = by_id[reference_id]
+ if set(raw) != {"reference_id", "status", "uids", "confidence"}:
+ raise SceneAdapterProtocolError(
+ f"Grounding binding {reference_id!r} has unsupported fields."
+ )
+ status = raw["status"]
+ if status not in {"resolved", "ambiguous", "not_found"}:
+ raise SceneAdapterProtocolError(
+ f"Grounding binding {reference_id!r} has invalid status."
+ )
+ uids = raw["uids"]
+ confidence = raw["confidence"]
+ if (
+ not isinstance(uids, Sequence)
+ or isinstance(uids, (str, bytes))
+ or any(
+ not isinstance(uid, str) or uid not in inventory.by_uid for uid in uids
+ )
+ or len(set(uids)) != len(uids)
+ ):
+ raise SceneAdapterProtocolError(
+ f"Grounding binding {reference_id!r} has invalid candidate UIDs."
+ )
+ if (
+ isinstance(confidence, bool)
+ or not isinstance(confidence, (int, float))
+ or not 0.0 <= float(confidence) <= 1.0
+ ):
+ raise SceneAdapterProtocolError(
+ f"Grounding binding {reference_id!r} has invalid confidence."
+ )
+ audit_status = status
+ reasons: list[str] = []
+ if status == "not_found" and uids:
+ raise SceneAdapterProtocolError(
+ f"Grounding binding {reference_id!r} status=not_found requires no UIDs."
+ )
+ if status == "resolved":
+ audit_status = "incompatible"
+ reasons.append(error)
+ else:
+ reasons.append(f"Grounding returned status={status}.")
+ audits.append(
+ {
+ "reference_id": reference_id,
+ "status": audit_status,
+ "confidence": float(confidence),
+ "candidate_uids": list(uids),
+ "selected_uids": [],
+ "reasons": reasons,
+ }
+ )
+ return audits
+
+
+def _compatibility_reasons(
+ request: Mapping[str, Any],
+ uids: Sequence[str],
+ *,
+ inventory: SceneInventory,
+ draft: Mapping[str, Any],
+) -> list[str]:
+ entities = [inventory.by_uid[uid] for uid in uids]
+ reasons: list[str] = []
+ role = str(request["role"])
+ step = next(item for item in draft["steps"] if item["id"] == request["step_id"])
+ try:
+ if role == "object":
+ validate_source_compatibility(str(step["task_type"]), entities)
+ else:
+ for entity in entities:
+ validate_target_compatibility(
+ str(step["task_type"]),
+ entity,
+ relation=str(step["relation"]),
+ )
+ except ValueError as exc:
+ reasons.append(str(exc))
+
+ expected_structure = str(request["source_structure"])
+ for entity in entities:
+ # Source structure is strict for manipulated objects. Target structure
+ # is relation-dependent and is already checked by
+ # validate_target_compatibility; a table support surface must not be
+ # rejected merely because it is passive rather than a rigid object.
+ if role == "object":
+ if expected_structure == "articulation" and entity.role != "articulation":
+ reasons.append(
+ f"UID {entity.uid!r} is not an articulation as requested."
+ )
+ if expected_structure in {
+ "rigid_object",
+ "movable",
+ } and entity.role not in {
+ "object",
+ "rigid_object",
+ }:
+ reasons.append(
+ f"UID {entity.uid!r} is not a movable rigid object as requested."
+ )
+ required_affordances = set(request["affordances"])
+ if entity.affordances:
+ missing = required_affordances - set(entity.affordances)
+ if missing:
+ reasons.append(
+ f"UID {entity.uid!r} explicitly lacks affordances {sorted(missing)}."
+ )
+ for key, expected in request["initial_state"].items():
+ if key in entity.initial_state and entity.initial_state[key] != expected:
+ reasons.append(
+ f"UID {entity.uid!r} state {key!r} conflicts with the request."
+ )
+ for key, expected in request["attributes"].items():
+ if key in entity.attributes and entity.attributes[key] != expected:
+ reasons.append(
+ f"UID {entity.uid!r} attribute {key!r} conflicts with the request."
+ )
+ return sorted(set(reasons))
+
+
+def _self_reference_reasons(
+ draft: Mapping[str, Any],
+ bindings: Mapping[str, Sequence[str]],
+) -> dict[str, list[str]]:
+ """Reject object/target identity overlap, including step_result selectors."""
+ objects_by_step: dict[str, tuple[str, ...]] = {}
+ reasons: dict[str, list[str]] = {}
+ for step in draft["steps"]:
+ step_id = str(step["id"])
+ object_uids = _selector_uids(
+ step["object"],
+ reference_id=f"{step_id}.object",
+ bindings=bindings,
+ objects_by_step=objects_by_step,
+ )
+ target_uids = _selector_uids(
+ step["target"],
+ reference_id=f"{step_id}.target",
+ bindings=bindings,
+ objects_by_step=objects_by_step,
+ )
+ overlap = sorted(set(object_uids) & set(target_uids))
+ if overlap:
+ reason = (
+ f"Grounding step {step_id!r} uses the same UID as object and "
+ f"target: {overlap}."
+ )
+ for role in ("object", "target"):
+ selector = step[role]
+ if selector["kind"] == "scene_ref":
+ reasons.setdefault(f"{step_id}.{role}", []).append(reason)
+ objects_by_step[step_id] = object_uids
+ return reasons
+
+
+def _selector_uids(
+ selector: Mapping[str, Any],
+ *,
+ reference_id: str,
+ bindings: Mapping[str, Sequence[str]],
+ objects_by_step: Mapping[str, tuple[str, ...]],
+) -> tuple[str, ...]:
+ kind = str(selector["kind"])
+ if kind == "scene_ref":
+ return tuple(str(uid) for uid in bindings[reference_id])
+ if kind == "step_result":
+ return objects_by_step[str(selector["step_id"])]
+ return ()
+
+
+def _candidate_audit(
+ candidate: TaskCandidate,
+ status: str,
+ references: Sequence[Mapping[str, Any]],
+ reasons: Sequence[str],
+) -> dict[str, Any]:
+ return {
+ "candidate_id": candidate["candidate_id"],
+ "semantic_hash": candidate["semantic_hash"],
+ "status": status,
+ "references": [deepcopy(dict(reference)) for reference in references],
+ "reasons": list(reasons),
+ }
+
+
+def _candidate_status(references: Sequence[Mapping[str, Any]]) -> str:
+ statuses = {str(reference["status"]) for reference in references}
+ if statuses == {"resolved"}:
+ return "resolved"
+ if "ambiguous" in statuses:
+ return "ambiguous"
+ if "incompatible" in statuses:
+ return "incompatible"
+ return "not_found"
+
+
+def _select_candidate(
+ candidates: Sequence[TaskCandidate],
+ audits: Sequence[Mapping[str, Any]],
+ *,
+ manifest: SceneManifest,
+ instruction: str,
+ adjudicator: Adjudicator | None,
+) -> tuple[str | None, str, str]:
+ audit_by_id = {str(audit["candidate_id"]): audit for audit in audits}
+ bound = [
+ candidate
+ for candidate in candidates
+ if audit_by_id[str(candidate["candidate_id"])]["status"] == "resolved"
+ ]
+ majority = [candidate for candidate in bound if int(candidate["vote_count"]) >= 2]
+ if len(majority) == 1:
+ return str(majority[0]["candidate_id"]), "bound", "majority_bindable"
+ if not majority and len(bound) == 1:
+ return str(bound[0]["candidate_id"]), "bound", "unique_bindable"
+
+ choices = majority if majority else bound
+ if len(choices) > 1:
+ if adjudicator is None:
+ return None, "ambiguous", "multiple_conflicting_bindable_candidates"
+ raw = adjudicator(
+ instruction=instruction,
+ candidates=deepcopy(list(choices)),
+ scene_manifest=deepcopy(manifest),
+ )
+ if not isinstance(raw, Mapping) or set(raw) != {"candidate_id"}:
+ raise SceneAdapterProtocolError(
+ "Adjudicator response must contain only candidate_id."
+ )
+ selected_id = raw["candidate_id"]
+ allowed = {str(candidate["candidate_id"]) for candidate in choices}
+ if not isinstance(selected_id, str) or selected_id not in allowed:
+ raise SceneAdapterProtocolError(
+ "Adjudicator must select the candidate_id of a verified bindable candidate."
+ )
+ return selected_id, "bound", "adjudicated_bindable"
+ if any(audit["status"] == "ambiguous" for audit in audits):
+ return None, "ambiguous", "no_fully_bound_candidate"
+ return None, "unsatisfied", "no_fully_bound_candidate"
+
+
+def _build_manifest(
+ prepared: PreparedScene,
+ inventory: SceneInventory,
+ *,
+ source_format: str,
+) -> SceneManifest:
+ objects = [
+ {
+ "uid": entity.uid,
+ "role": entity.role,
+ "name": entity.name,
+ "description": entity.description,
+ "category": entity.category,
+ "color": entity.color,
+ "affordances": sorted(entity.affordances),
+ "initial_state": _redact_semantics(entity.initial_state),
+ "attributes": _redact_semantics(entity.attributes),
+ }
+ for entity in sorted(inventory.entities, key=lambda item: item.uid)
+ ]
+ scene_id = _canonical_hash(
+ {
+ "source_format": source_format,
+ "objects": objects,
+ "asset_hashes": prepared.asset_hashes,
+ "rotation": prepared.z_rotation_degrees,
+ "body_scale_policy": prepared.body_scale_policy,
+ "body_scale": prepared.body_scale,
+ }
+ )
+ return validate_scene_manifest(
+ {
+ "schema_version": SCENE_MANIFEST_SCHEMA,
+ "scene_id": scene_id,
+ "source_format": source_format,
+ "robot_profile": inventory.profile,
+ "objects": objects,
+ }
+ )
+
+
+def _build_semantic_manifest(
+ inventory: SceneInventory,
+ *,
+ source_format: str,
+) -> SceneManifest:
+ objects = [
+ {
+ "uid": entity.uid,
+ "role": entity.role,
+ "name": entity.name,
+ "description": entity.description,
+ "category": entity.category,
+ "color": entity.color,
+ "affordances": sorted(entity.affordances),
+ "initial_state": _redact_semantics(entity.initial_state),
+ "attributes": _redact_semantics(entity.attributes),
+ }
+ for entity in sorted(inventory.entities, key=lambda item: item.uid)
+ ]
+ return validate_scene_manifest(
+ {
+ "schema_version": SCENE_MANIFEST_SCHEMA,
+ "scene_id": _canonical_hash(
+ {"source_format": source_format, "objects": objects}
+ ),
+ "source_format": source_format,
+ "robot_profile": inventory.profile,
+ "objects": objects,
+ }
+ )
+
+
+def _redact_semantics(value: Mapping[str, Any]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, child in value.items():
+ name = str(key)
+ normalized = name.strip().lower().replace("-", "_")
+ if normalized in _REDACTED_KEYS:
+ continue
+ if isinstance(child, Mapping):
+ nested = _redact_semantics(child)
+ if nested:
+ result[name] = nested
+ elif isinstance(child, (str, int, float, bool)) and not isinstance(
+ child, complex
+ ):
+ result[name] = child
+ elif isinstance(child, Sequence) and not isinstance(child, (str, bytes)):
+ simple = [item for item in child if isinstance(item, (str, bool))]
+ if len(simple) == len(child):
+ result[name] = simple
+ return result
+
+
+def _canonical_hash(value: Any) -> str:
+ payload = json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
diff --git a/embodichain/gen_sim/task_engine/orchestration/scene_source.py b/embodichain/gen_sim/task_engine/orchestration/scene_source.py
new file mode 100644
index 000000000..54941bf7c
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/orchestration/scene_source.py
@@ -0,0 +1,276 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Read-only references and integrity checks for existing Gym projects."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+import hashlib
+import json
+import os
+from pathlib import Path
+from typing import Any
+from urllib.parse import unquote, urlparse
+import xml.etree.ElementTree as ET
+
+from embodichain.gen_sim.action_engine.generation.source_scene import (
+ resolve_source_scene,
+)
+
+__all__ = [
+ "SceneSourceFingerprint",
+ "SceneSourceRef",
+ "fingerprint_scene_source",
+ "scene_revision_id",
+ "verify_scene_source_fingerprint",
+]
+
+_SCENE_SECTIONS = ("background", "rigid_object", "articulation")
+
+
+@dataclass(frozen=True)
+class SceneSourceRef:
+ """Reference an existing scene without copying or owning its files."""
+
+ path: Path | str
+ robot_profile: str = "franka"
+ z_rotation_degrees: float | None = None
+ body_scale_policy: str = "preserve"
+ body_scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "path", Path(self.path).expanduser())
+
+
+@dataclass(frozen=True)
+class SceneSourceFingerprint:
+ """Content evidence for one externally owned scene source."""
+
+ source_format: str
+ config_path: Path
+ config_sha256: str
+ asset_sha256: dict[str, str]
+
+ def to_dict(self) -> dict[str, Any]:
+ """Return a JSON-safe audit view."""
+ return {
+ "source_format": self.source_format,
+ "config_path": self.config_path.as_posix(),
+ "config_sha256": self.config_sha256,
+ "asset_sha256": dict(sorted(self.asset_sha256.items())),
+ }
+
+
+def fingerprint_scene_source(
+ source: SceneSourceRef | str | Path,
+) -> SceneSourceFingerprint:
+ """Hash a source config and referenced assets without copying either."""
+ source_path = source.path if isinstance(source, SceneSourceRef) else source
+ resolved = resolve_source_scene(source_path)
+ config_bytes = resolved.path.read_bytes()
+ try:
+ config = json.loads(config_bytes)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Scene config is not valid JSON: {resolved.path}") from exc
+ if not isinstance(config, Mapping):
+ raise ValueError(f"Scene config must contain an object: {resolved.path}")
+
+ asset_hashes: dict[str, str] = {}
+ for section in _SCENE_SECTIONS:
+ entries = config.get(section, ())
+ if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)):
+ continue
+ for index, entry in enumerate(entries):
+ if not isinstance(entry, Mapping):
+ continue
+ references: list[tuple[str, Any]] = []
+ shape = entry.get("shape")
+ if isinstance(shape, Mapping) and shape.get("fpath"):
+ references.append(("shape.fpath", shape["fpath"]))
+ if section == "articulation" and entry.get("fpath"):
+ references.append(("fpath", entry["fpath"]))
+ for field_name, reference in references:
+ asset_path = Path(str(reference)).expanduser()
+ if not asset_path.is_absolute():
+ asset_path = resolved.path.parent / asset_path
+ asset_path = asset_path.resolve()
+ if not asset_path.is_file():
+ raise FileNotFoundError(
+ f"Scene asset does not exist: {asset_path} "
+ f"({section}[{index}].{field_name})."
+ )
+ for dependency in _asset_dependency_files(asset_path):
+ asset_hashes[dependency.as_posix()] = _sha256(
+ dependency.read_bytes()
+ )
+ return SceneSourceFingerprint(
+ source_format=resolved.source_format,
+ config_path=resolved.path,
+ config_sha256=_sha256(config_bytes),
+ asset_sha256=asset_hashes,
+ )
+
+
+def verify_scene_source_fingerprint(expected: Mapping[str, Any]) -> None:
+ """Raise when an externally owned source changed after preparation."""
+ required = {"source_format", "config_path", "config_sha256", "asset_sha256"}
+ if set(expected) != required:
+ raise ValueError("Scene source fingerprint fields are invalid.")
+ actual = fingerprint_scene_source(str(expected["config_path"])).to_dict()
+ normalized = {
+ "source_format": str(expected["source_format"]),
+ "config_path": Path(str(expected["config_path"])).resolve().as_posix(),
+ "config_sha256": str(expected["config_sha256"]),
+ "asset_sha256": dict(expected["asset_sha256"]),
+ }
+ if actual != normalized:
+ raise RuntimeError(
+ "Source Gym project changed after Task Engine preparation; "
+ "prepare a new bundle before running it."
+ )
+
+
+def scene_revision_id(source: SceneSourceRef | str | Path) -> str:
+ """Return a location-independent content identity for one scene revision.
+
+ Volatile exporter IDs and absolute asset paths are excluded. Referenced
+ asset content remains part of the identity through SHA-256 placeholders.
+
+ Args:
+ source: Scene project, configuration path, or Task Engine source reference.
+
+ Returns:
+ Stable SHA-256 identity of scene semantics and referenced asset content.
+ """
+ source_path = source.path if isinstance(source, SceneSourceRef) else source
+ resolved = resolve_source_scene(source_path)
+ try:
+ config = json.loads(resolved.path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Scene config is not valid JSON: {resolved.path}") from exc
+ if not isinstance(config, Mapping):
+ raise ValueError(f"Scene config must contain an object: {resolved.path}")
+ normalized = _normalize_revision_value(
+ dict(config),
+ config_root=resolved.path.parent,
+ )
+ normalized.pop("scene_id", None)
+ payload = json.dumps(
+ normalized,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode("utf-8")
+ return _sha256(payload)
+
+
+def _normalize_revision_value(value: Any, *, config_root: Path) -> Any:
+ if isinstance(value, Mapping):
+ result = {
+ str(key): _normalize_revision_value(item, config_root=config_root)
+ for key, item in value.items()
+ }
+ for key in ("fpath",):
+ raw = result.get(key)
+ if not isinstance(raw, str) or not raw:
+ continue
+ path = Path(raw).expanduser()
+ if not path.is_absolute():
+ path = config_root / path
+ path = path.resolve()
+ if path.is_file():
+ files = _asset_dependency_files(path)
+ result[key] = {
+ "sha256": _sha256(path.read_bytes()),
+ "dependency_sha256": {
+ Path(
+ os.path.relpath(dependency, start=path.parent)
+ ).as_posix(): (_sha256(dependency.read_bytes()))
+ for dependency in files
+ if dependency != path
+ },
+ }
+ return result
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
+ return [
+ _normalize_revision_value(item, config_root=config_root) for item in value
+ ]
+ return value
+
+
+def _asset_dependency_files(asset_path: Path) -> tuple[Path, ...]:
+ """Return one asset and every local XML-declared dependency transitively."""
+ pending = [asset_path.resolve()]
+ visited: set[Path] = set()
+ while pending:
+ path = pending.pop()
+ if path in visited:
+ continue
+ if not path.is_file():
+ raise FileNotFoundError(f"Scene asset dependency does not exist: {path}")
+ visited.add(path)
+ if path.suffix.lower() not in {".urdf", ".xml", ".mjcf", ".xacro"}:
+ continue
+ try:
+ root = ET.parse(path).getroot()
+ except ET.ParseError:
+ # Opaque articulation assets remain valid direct dependencies even
+ # when their extension suggests XML.
+ continue
+ for element in root.iter():
+ tag = element.tag.rsplit("}", maxsplit=1)[-1]
+ if tag not in {"mesh", "texture", "include"}:
+ continue
+ for attribute in ("filename", "file", "url"):
+ reference = element.attrib.get(attribute)
+ if reference:
+ pending.append(_resolve_asset_reference(path, reference))
+ return tuple(sorted(visited))
+
+
+def _resolve_asset_reference(owner: Path, reference: str) -> Path:
+ """Resolve a local filesystem or ROS package URI without global state."""
+ parsed = urlparse(reference)
+ if parsed.scheme in {"http", "https", "data"}:
+ raise ValueError(
+ f"Remote scene asset dependencies cannot be integrity-hashed: {reference}"
+ )
+ if parsed.scheme == "file":
+ return Path(unquote(parsed.path)).expanduser().resolve()
+ if parsed.scheme == "package":
+ package_name = parsed.netloc
+ relative = Path(unquote(parsed.path.lstrip("/")))
+ candidates = [
+ ancestor / package_name / relative
+ for ancestor in (owner.parent, *owner.parents)
+ ]
+ candidates.append(owner.parent / relative)
+ for candidate in candidates:
+ if candidate.is_file():
+ return candidate.resolve()
+ raise FileNotFoundError(
+ f"Unable to resolve package asset {reference!r} from {owner}."
+ )
+ if parsed.scheme:
+ raise ValueError(f"Unsupported scene asset URI scheme: {reference}")
+ return (owner.parent / unquote(reference)).expanduser().resolve()
+
+
+def _sha256(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
diff --git a/embodichain/gen_sim/task_engine/run_directory.py b/embodichain/gen_sim/task_engine/run_directory.py
new file mode 100644
index 000000000..0092cffc0
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/run_directory.py
@@ -0,0 +1,87 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Collision-safe allocation of human-readable Task Engine run directories."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Iterator
+
+__all__ = ["RunDirectory", "reserve_run_directory"]
+
+
+@dataclass(frozen=True)
+class RunDirectory:
+ """One reserved run identifier and its not-yet-published destination."""
+
+ run_id: str
+ output_root: Path
+ path: Path
+ created_at: datetime
+
+
+@contextmanager
+def reserve_run_directory(
+ output_root: str | Path,
+ *,
+ now: datetime | None = None,
+) -> Iterator[RunDirectory]:
+ """Reserve a timestamped child name without creating its destination.
+
+ Args:
+ output_root: Persistent task-history directory.
+ now: Optional timezone-aware timestamp used by deterministic tests.
+
+ Yields:
+ A run directory allocation safe to publish through ArtifactTransaction.
+ """
+ created_at = now or datetime.now().astimezone()
+ if created_at.tzinfo is None or created_at.utcoffset() is None:
+ raise ValueError("Task Engine run timestamps must include a timezone.")
+ root = Path(output_root).expanduser().resolve()
+ root.mkdir(parents=True, exist_ok=True)
+ if not root.is_dir():
+ raise NotADirectoryError(root)
+
+ base = created_at.strftime("%Y%m%d_%H%M%S")
+ for collision_index in range(10_000):
+ run_id = base if collision_index == 0 else f"{base}_{collision_index:02d}"
+ destination = root / run_id
+ reservation = root / f".{run_id}.reserve"
+ if destination.exists():
+ continue
+ try:
+ reservation.mkdir()
+ except FileExistsError:
+ continue
+ if destination.exists():
+ reservation.rmdir()
+ continue
+ try:
+ yield RunDirectory(
+ run_id=run_id,
+ output_root=root,
+ path=destination,
+ created_at=created_at,
+ )
+ finally:
+ reservation.rmdir()
+ return
+ raise RuntimeError("Unable to reserve a Task Engine run directory.")
diff --git a/embodichain/gen_sim/task_engine/scene/__init__.py b/embodichain/gen_sim/task_engine/scene/__init__.py
new file mode 100644
index 000000000..40a218b57
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/scene/__init__.py
@@ -0,0 +1,53 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Task Engine ownership of scene adaptation and static feasibility."""
+
+from __future__ import annotations
+
+from .contracts import (
+ ASSESSMENT_STATUSES,
+ FEASIBILITY_REPORT_SCHEMA,
+ STATIC_SCENE_MANIFEST_SCHEMA,
+ FeasibilityReport,
+ StaticSceneManifest,
+ validate_feasibility_report,
+ validate_static_scene_manifest,
+)
+from .feasibility import FeasibilityBroker
+from .scene_engine_v1 import SceneEngineV1Adapter
+from .conservative_graph import (
+ CONSERVATIVE_SCENE_GRAPH_SCHEMA,
+ ConservativeSceneGraph,
+ build_conservative_scene_graph,
+ validate_conservative_scene_graph,
+)
+
+__all__ = [
+ "ASSESSMENT_STATUSES",
+ "CONSERVATIVE_SCENE_GRAPH_SCHEMA",
+ "FEASIBILITY_REPORT_SCHEMA",
+ "STATIC_SCENE_MANIFEST_SCHEMA",
+ "FeasibilityBroker",
+ "FeasibilityReport",
+ "SceneEngineV1Adapter",
+ "StaticSceneManifest",
+ "ConservativeSceneGraph",
+ "build_conservative_scene_graph",
+ "validate_feasibility_report",
+ "validate_static_scene_manifest",
+ "validate_conservative_scene_graph",
+]
diff --git a/embodichain/gen_sim/task_engine/scene/conservative_graph.py b/embodichain/gen_sim/task_engine/scene/conservative_graph.py
new file mode 100644
index 000000000..ac3f2a634
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/scene/conservative_graph.py
@@ -0,0 +1,247 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Conservative hierarchy evidence for imported scenes."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+import json
+from pathlib import Path
+from typing import Any, Final, TypeAlias
+
+__all__ = [
+ "CONSERVATIVE_SCENE_GRAPH_SCHEMA",
+ "ConservativeSceneGraph",
+ "build_conservative_scene_graph",
+ "validate_conservative_scene_graph",
+]
+
+CONSERVATIVE_SCENE_GRAPH_SCHEMA: Final = "embodichain.conservative-scene-graph/v1"
+ConservativeSceneGraph: TypeAlias = dict[str, Any]
+
+
+def build_conservative_scene_graph(
+ prepared_scene: Any,
+ *,
+ scene_id: str,
+) -> ConservativeSceneGraph:
+ """Use exported hierarchy when available and mark every gap as unknown."""
+ source_path = Path(getattr(prepared_scene, "source_config_path")).resolve()
+ uid_map = dict(getattr(prepared_scene, "uid_map", {}) or {})
+ exported = _read_exported_graph(source_path.with_name("scene_graph.json"))
+ operational_assumptions = _legacy_operational_assumption_uids(source_path)
+ exported_nodes = {
+ str(node.get("object_id")): node
+ for node in exported.get("nodes", ())
+ if isinstance(node, Mapping) and node.get("object_id")
+ }
+
+ nodes: list[dict[str, Any]] = []
+ for raw in getattr(prepared_scene, "planner_objects"):
+ uid = str(raw.get("uid", ""))
+ source_uid = str(raw.get("source_uid", uid))
+ known = exported_nodes.get(source_uid) or exported_nodes.get(uid)
+ attributes = raw.get("attributes", {})
+ final_support = (
+ attributes.get("final_support") if isinstance(attributes, Mapping) else None
+ )
+ initial_state = raw.get("initial_state", {})
+ final_orientation = (
+ initial_state.get("orientation")
+ if isinstance(initial_state, Mapping)
+ else None
+ )
+ if uid == "table":
+ node = {
+ "uid": uid,
+ "parent_uid": None,
+ "parent_relation": "root",
+ "orientation": "unknown",
+ "source": "structural_root",
+ }
+ elif isinstance(final_support, Mapping):
+ relation = str(final_support.get("relation", "unknown"))
+ parent_uid = final_support.get("parent_uid", "unknown")
+ node = {
+ "uid": uid,
+ "parent_uid": (
+ str(parent_uid)
+ if isinstance(parent_uid, str) and parent_uid
+ else "unknown"
+ ),
+ "parent_relation": relation if relation == "on" else "unknown",
+ "orientation": (
+ "standing"
+ if final_orientation == "upright"
+ else ("lying" if final_orientation == "fallen" else "unknown")
+ ),
+ "source": "final_inspection",
+ }
+ elif (
+ known is None
+ or uid in operational_assumptions
+ or source_uid in operational_assumptions
+ ):
+ node = {
+ "uid": uid,
+ "parent_uid": "unknown",
+ "parent_relation": "unknown",
+ "orientation": "unknown",
+ "source": "conservative_import",
+ }
+ else:
+ raw_parent = known.get("parent_id")
+ parent_uid = (
+ uid_map.get(str(raw_parent), str(raw_parent))
+ if raw_parent is not None
+ else "unknown"
+ )
+ relation = known.get("parent_relation")
+ orientation = known.get("orientation_state")
+ node = {
+ "uid": uid,
+ "parent_uid": parent_uid,
+ "parent_relation": relation if relation == "on" else "unknown",
+ "orientation": (
+ orientation if orientation in {"standing", "lying"} else "unknown"
+ ),
+ "source": "scene_graph",
+ }
+ nodes.append(node)
+
+ relations = []
+ for raw in exported.get("relations", ()):
+ if not isinstance(raw, Mapping):
+ continue
+ source_uid = uid_map.get(str(raw.get("source_id")), str(raw.get("source_id")))
+ target_uid = uid_map.get(str(raw.get("target_id")), str(raw.get("target_id")))
+ relation = str(raw.get("relation", ""))
+ if source_uid and target_uid and relation:
+ relations.append(
+ {
+ "source_uid": source_uid,
+ "relation": relation,
+ "target_uid": target_uid,
+ "source": "scene_graph",
+ }
+ )
+ return validate_conservative_scene_graph(
+ {
+ "schema_version": CONSERVATIVE_SCENE_GRAPH_SCHEMA,
+ "scene_id": str(scene_id),
+ "nodes": nodes,
+ "relations": relations,
+ }
+ )
+
+
+def _legacy_operational_assumption_uids(source_path: Path) -> set[str]:
+ manifest_path = source_path.parent.parent / "legacy_conversion.json"
+ if not manifest_path.is_file():
+ return set()
+ try:
+ value = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(
+ f"Legacy conversion manifest is invalid JSON: {manifest_path}"
+ ) from exc
+ if not isinstance(value, Mapping):
+ raise ValueError("Legacy conversion manifest must contain an object.")
+ assumptions = value.get("assumptions", ())
+ if not isinstance(assumptions, Sequence) or isinstance(assumptions, (str, bytes)):
+ raise ValueError("Legacy conversion assumptions must be a sequence.")
+ return {
+ str(item["uid"])
+ for item in assumptions
+ if isinstance(item, Mapping) and isinstance(item.get("uid"), str)
+ }
+
+
+def validate_conservative_scene_graph(
+ value: Mapping[str, Any],
+) -> ConservativeSceneGraph:
+ """Validate and detach one conservative graph."""
+ if not isinstance(value, Mapping):
+ raise TypeError("ConservativeSceneGraph must be a mapping.")
+ result = deepcopy(dict(value))
+ expected = {"schema_version", "scene_id", "nodes", "relations"}
+ if set(result) != expected:
+ raise ValueError("ConservativeSceneGraph fields are invalid.")
+ if result.get("schema_version") != CONSERVATIVE_SCENE_GRAPH_SCHEMA:
+ raise ValueError("ConservativeSceneGraph schema version is invalid.")
+ if not isinstance(result.get("scene_id"), str) or not result["scene_id"]:
+ raise ValueError("ConservativeSceneGraph.scene_id must not be empty.")
+ nodes = _sequence(result.get("nodes"), "nodes")
+ normalized_nodes = []
+ for index, raw in enumerate(nodes):
+ if not isinstance(raw, Mapping):
+ raise TypeError(f"ConservativeSceneGraph.nodes[{index}] must be a mapping.")
+ node = dict(raw)
+ if set(node) != {
+ "uid",
+ "parent_uid",
+ "parent_relation",
+ "orientation",
+ "source",
+ }:
+ raise ValueError(
+ f"ConservativeSceneGraph.nodes[{index}] fields are invalid."
+ )
+ if not isinstance(node["uid"], str) or not node["uid"]:
+ raise ValueError(f"ConservativeSceneGraph.nodes[{index}].uid is invalid.")
+ if node["parent_uid"] is not None and not isinstance(node["parent_uid"], str):
+ raise TypeError(
+ f"ConservativeSceneGraph.nodes[{index}].parent_uid is invalid."
+ )
+ if node["parent_relation"] not in {"root", "on", "unknown"}:
+ raise ValueError(
+ f"ConservativeSceneGraph.nodes[{index}].parent_relation is invalid."
+ )
+ if node["orientation"] not in {"standing", "lying", "unknown"}:
+ raise ValueError(
+ f"ConservativeSceneGraph.nodes[{index}].orientation is invalid."
+ )
+ if not isinstance(node["source"], str) or not node["source"]:
+ raise ValueError(
+ f"ConservativeSceneGraph.nodes[{index}].source is invalid."
+ )
+ normalized_nodes.append(node)
+ if len({node["uid"] for node in normalized_nodes}) != len(normalized_nodes):
+ raise ValueError("ConservativeSceneGraph node UIDs must be unique.")
+ result["nodes"] = normalized_nodes
+ result["relations"] = [
+ dict(item) for item in _sequence(result.get("relations"), "relations")
+ ]
+ json.dumps(result, allow_nan=False)
+ return result
+
+
+def _read_exported_graph(path: Path) -> dict[str, Any]:
+ if not path.is_file():
+ return {}
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Scene graph is not valid JSON: {path}") from exc
+ return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _sequence(value: Any, field_name: str) -> list[Any]:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise TypeError(f"ConservativeSceneGraph.{field_name} must be a sequence.")
+ return list(value)
diff --git a/embodichain/gen_sim/task_engine/scene/contracts.py b/embodichain/gen_sim/task_engine/scene/contracts.py
new file mode 100644
index 000000000..974864785
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/scene/contracts.py
@@ -0,0 +1,304 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""JSON contracts owned by the Scene Engine anti-corruption boundary."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+import json
+import math
+from typing import Any, TypeAlias
+
+__all__ = [
+ "ASSESSMENT_STATUSES",
+ "FEASIBILITY_REPORT_SCHEMA",
+ "REMEDIATION_CLASSES",
+ "STATIC_SCENE_MANIFEST_SCHEMA",
+ "FeasibilityReport",
+ "StaticSceneManifest",
+ "validate_feasibility_report",
+ "validate_static_scene_manifest",
+]
+
+
+STATIC_SCENE_MANIFEST_SCHEMA = "embodichain.static-scene-manifest/v1"
+FEASIBILITY_REPORT_SCHEMA = "embodichain.scene-action-feasibility/v2"
+ASSESSMENT_STATUSES = frozenset({"proven", "runtime_probe", "unknown", "contradicted"})
+REMEDIATION_CLASSES = frozenset(
+ {"none", "scene_remediable", "action_capability", "input_conflict", "terminal"}
+)
+_EVIDENCE_STATUSES = frozenset({"declared", "inferred", "verified", "contradicted"})
+
+StaticSceneManifest: TypeAlias = dict[str, Any]
+FeasibilityReport: TypeAlias = dict[str, Any]
+
+
+def validate_static_scene_manifest(value: Mapping[str, Any]) -> StaticSceneManifest:
+ """Validate and detach one static scene manifest."""
+ result = _mapping(value, "StaticSceneManifest")
+ _exact_keys(
+ result,
+ {
+ "schema_version",
+ "scene_id",
+ "source_format",
+ "robot_profile",
+ "source",
+ "adapter_capabilities",
+ "objects",
+ },
+ "StaticSceneManifest",
+ )
+ _schema(result, STATIC_SCENE_MANIFEST_SCHEMA, "StaticSceneManifest")
+ for key in ("scene_id", "source_format", "robot_profile"):
+ result[key] = _nonempty(result.get(key), f"StaticSceneManifest.{key}")
+ result["source"] = _mapping(result.get("source"), "StaticSceneManifest.source")
+ result["adapter_capabilities"] = _bool_mapping(
+ result.get("adapter_capabilities"),
+ "StaticSceneManifest.adapter_capabilities",
+ )
+
+ objects: list[dict[str, Any]] = []
+ for index, raw in enumerate(_sequence(result.get("objects"), "objects")):
+ context = f"StaticSceneManifest.objects[{index}]"
+ item = _mapping(raw, context)
+ _exact_keys(
+ item,
+ {
+ "uid",
+ "source_uid",
+ "role",
+ "name",
+ "description",
+ "category",
+ "color",
+ "geometry",
+ "initial_pose",
+ "physics",
+ "articulation",
+ "affordances",
+ "initial_state",
+ "attributes",
+ "provenance",
+ },
+ context,
+ )
+ item["uid"] = _nonempty(item.get("uid"), f"{context}.uid")
+ item["source_uid"] = _string(item.get("source_uid"), f"{context}.source_uid")
+ item["role"] = _nonempty(item.get("role"), f"{context}.role")
+ for key in ("name", "description", "category"):
+ item[key] = _string(item.get(key), f"{context}.{key}")
+ color = item.get("color")
+ if color is not None:
+ color = _string(color, f"{context}.color")
+ item["color"] = color
+ for key in (
+ "geometry",
+ "initial_pose",
+ "physics",
+ "articulation",
+ "initial_state",
+ "attributes",
+ "provenance",
+ ):
+ item[key] = _mapping(item.get(key), f"{context}.{key}")
+ item["affordances"] = [
+ _validate_affordance(evidence, f"{context}.affordances[{evidence_index}]")
+ for evidence_index, evidence in enumerate(
+ _sequence(item.get("affordances"), f"{context}.affordances")
+ )
+ ]
+ objects.append(item)
+ uids = [item["uid"] for item in objects]
+ if len(set(uids)) != len(uids):
+ raise ValueError("StaticSceneManifest object UIDs must be unique.")
+ result["objects"] = objects
+ _json_safe(result, "StaticSceneManifest")
+ return result
+
+
+def validate_feasibility_report(value: Mapping[str, Any]) -> FeasibilityReport:
+ """Validate and detach one scene/action feasibility report."""
+ result = _mapping(value, "FeasibilityReport")
+ _exact_keys(
+ result,
+ {
+ "schema_version",
+ "task_id",
+ "candidate_id",
+ "scene_id",
+ "status",
+ "remediation_class",
+ "checks",
+ "blockers",
+ "summary",
+ },
+ "FeasibilityReport",
+ )
+ _schema(result, FEASIBILITY_REPORT_SCHEMA, "FeasibilityReport")
+ for key in ("task_id", "candidate_id", "scene_id"):
+ result[key] = _nonempty(result.get(key), f"FeasibilityReport.{key}")
+ result["status"] = _status(result.get("status"), "FeasibilityReport.status")
+ remediation_class = result.get("remediation_class")
+ if remediation_class not in REMEDIATION_CLASSES:
+ raise ValueError(
+ "FeasibilityReport.remediation_class must be one of "
+ f"{sorted(REMEDIATION_CLASSES)}."
+ )
+ result["remediation_class"] = str(remediation_class)
+ if result["status"] != "contradicted" and remediation_class != "none":
+ raise ValueError(
+ "A non-contradicted FeasibilityReport requires remediation_class=none."
+ )
+ checks: list[dict[str, Any]] = []
+ for index, raw in enumerate(_sequence(result.get("checks"), "checks")):
+ context = f"FeasibilityReport.checks[{index}]"
+ item = _mapping(raw, context)
+ _exact_keys(
+ item,
+ {"kind", "subject", "status", "reason", "evidence"},
+ context,
+ )
+ item["kind"] = _nonempty(item.get("kind"), f"{context}.kind")
+ item["subject"] = _nonempty(item.get("subject"), f"{context}.subject")
+ item["status"] = _status(item.get("status"), f"{context}.status")
+ item["reason"] = _nonempty(item.get("reason"), f"{context}.reason")
+ item["evidence"] = _mapping(item.get("evidence"), f"{context}.evidence")
+ checks.append(item)
+ result["checks"] = checks
+ blockers = _sequence(result.get("blockers"), "FeasibilityReport.blockers")
+ if any(not isinstance(item, str) or not item for item in blockers):
+ raise ValueError("FeasibilityReport.blockers must contain non-empty strings.")
+ result["blockers"] = list(blockers)
+ summary = _mapping(result.get("summary"), "FeasibilityReport.summary")
+ expected = set(ASSESSMENT_STATUSES)
+ if set(summary) != expected:
+ raise ValueError(
+ "FeasibilityReport.summary must count every assessment status."
+ )
+ if any(
+ isinstance(value, bool) or not isinstance(value, int) or value < 0
+ for value in summary.values()
+ ):
+ raise ValueError(
+ "FeasibilityReport.summary counts must be non-negative integers."
+ )
+ if sum(summary.values()) != len(checks):
+ raise ValueError("FeasibilityReport.summary must match the check count.")
+ result["summary"] = dict(summary)
+ _json_safe(result, "FeasibilityReport")
+ return result
+
+
+def _validate_affordance(value: Any, context: str) -> dict[str, Any]:
+ item = _mapping(value, context)
+ _exact_keys(
+ item,
+ {
+ "type",
+ "status",
+ "confidence",
+ "source",
+ "link_uid",
+ "frame",
+ "parameters",
+ },
+ context,
+ )
+ item["type"] = _nonempty(item.get("type"), f"{context}.type")
+ status = item.get("status")
+ if status not in _EVIDENCE_STATUSES:
+ raise ValueError(
+ f"{context}.status must be one of {sorted(_EVIDENCE_STATUSES)}."
+ )
+ confidence = item.get("confidence")
+ if confidence is not None:
+ if (
+ isinstance(confidence, bool)
+ or not isinstance(confidence, (int, float))
+ or not math.isfinite(float(confidence))
+ or not 0.0 <= float(confidence) <= 1.0
+ ):
+ raise ValueError(f"{context}.confidence must be null or in [0, 1].")
+ confidence = float(confidence)
+ item["confidence"] = confidence
+ item["source"] = _nonempty(item.get("source"), f"{context}.source")
+ item["link_uid"] = _string(item.get("link_uid"), f"{context}.link_uid")
+ item["frame"] = _mapping(item.get("frame"), f"{context}.frame")
+ item["parameters"] = _mapping(item.get("parameters"), f"{context}.parameters")
+ return item
+
+
+def _mapping(value: Any, context: str) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise TypeError(f"{context} must be a mapping.")
+ return deepcopy(dict(value))
+
+
+def _sequence(value: Any, context: str) -> list[Any]:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise TypeError(f"{context} must be a sequence.")
+ return list(value)
+
+
+def _exact_keys(value: Mapping[str, Any], expected: set[str], context: str) -> None:
+ if set(value) != expected:
+ missing = sorted(expected - set(value))
+ extra = sorted(set(value) - expected)
+ raise ValueError(f"{context} fields differ; missing={missing}, extra={extra}.")
+
+
+def _schema(value: Mapping[str, Any], expected: str, context: str) -> None:
+ if value.get("schema_version") != expected:
+ raise ValueError(f"{context}.schema_version must be {expected!r}.")
+
+
+def _nonempty(value: Any, context: str) -> str:
+ result = _string(value, context).strip()
+ if not result:
+ raise ValueError(f"{context} must not be empty.")
+ return result
+
+
+def _string(value: Any, context: str) -> str:
+ if not isinstance(value, str):
+ raise TypeError(f"{context} must be a string.")
+ return value
+
+
+def _status(value: Any, context: str) -> str:
+ if value not in ASSESSMENT_STATUSES:
+ raise ValueError(f"{context} must be one of {sorted(ASSESSMENT_STATUSES)}.")
+ return str(value)
+
+
+def _bool_mapping(value: Any, context: str) -> dict[str, bool]:
+ result = _mapping(value, context)
+ if any(
+ not isinstance(key, str) or not isinstance(item, bool)
+ for key, item in result.items()
+ ):
+ raise TypeError(f"{context} must map strings to booleans.")
+ return result
+
+
+def _json_safe(value: Any, context: str) -> None:
+ try:
+ json.dumps(value, allow_nan=False)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"{context} must contain strict JSON data.") from exc
diff --git a/embodichain/gen_sim/task_engine/scene/feasibility.py b/embodichain/gen_sim/task_engine/scene/feasibility.py
new file mode 100644
index 000000000..7eaefc162
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/scene/feasibility.py
@@ -0,0 +1,667 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Deterministic task, scene, robot, and action-capability intersection."""
+
+from __future__ import annotations
+
+from collections import Counter
+from collections.abc import Mapping, Sequence
+import math
+from typing import Any
+
+from .contracts import (
+ ASSESSMENT_STATUSES,
+ FEASIBILITY_REPORT_SCHEMA,
+ FeasibilityReport,
+ validate_feasibility_report,
+ validate_static_scene_manifest,
+)
+
+__all__ = ["FeasibilityBroker"]
+
+
+_STATUS_PRIORITY = {
+ "proven": 0,
+ "runtime_probe": 1,
+ "unknown": 2,
+ "contradicted": 3,
+}
+
+
+class FeasibilityBroker:
+ """Produce an auditable compatibility report without repairing inputs."""
+
+ def assess(
+ self,
+ candidate: Mapping[str, Any],
+ role_bindings: Mapping[str, Any],
+ scene_manifest: Mapping[str, Any],
+ *,
+ capability_catalog: Mapping[str, Mapping[str, Any]],
+ task_actions: Mapping[str, Sequence[str]],
+ ) -> FeasibilityReport:
+ """Assess one grounded candidate against static and runtime capabilities."""
+ manifest = validate_static_scene_manifest(scene_manifest)
+ draft = _mapping(candidate.get("draft"), "candidate.draft")
+ scene_request = _mapping(
+ candidate.get("scene_request"), "candidate.scene_request"
+ )
+ bindings = role_bindings.get("reference_bindings", role_bindings)
+ bindings = _mapping(bindings, "role_bindings.reference_bindings")
+ objects = {item["uid"]: item for item in manifest["objects"]}
+ steps = {
+ str(item["id"]): item
+ for item in _sequence(draft.get("steps"), "candidate.draft.steps")
+ }
+ checks: list[dict[str, Any]] = []
+
+ for step_id, step in steps.items():
+ task_type = str(step.get("task_type", ""))
+ actions = task_actions.get(task_type)
+ if not actions:
+ checks.append(
+ _check(
+ "task_capability",
+ step_id,
+ "contradicted",
+ f"Task type {task_type!r} has no registered action recipe.",
+ )
+ )
+ continue
+ for action_name in actions:
+ capability = capability_catalog.get(str(action_name))
+ if capability is None:
+ checks.append(
+ _check(
+ "atomic_capability",
+ f"{step_id}:{action_name}",
+ "contradicted",
+ f"AtomicAction {action_name!r} is not registered.",
+ )
+ )
+ elif not bool(capability.get("runtime_available", False)):
+ checks.append(
+ _check(
+ "atomic_capability",
+ f"{step_id}:{action_name}",
+ "contradicted",
+ str(
+ capability.get("unavailable_reason")
+ or "Action is planning-only."
+ ),
+ evidence={"action": str(action_name)},
+ )
+ )
+ else:
+ checks.append(
+ _check(
+ "atomic_capability",
+ f"{step_id}:{action_name}",
+ "proven",
+ "AtomicAction is registered and executable.",
+ evidence={"action": str(action_name)},
+ )
+ )
+
+ for request in _sequence(
+ scene_request.get("references"), "candidate.scene_request.references"
+ ):
+ reference_id = str(request.get("reference_id", ""))
+ raw_uids = bindings.get(reference_id, ())
+ if not isinstance(raw_uids, Sequence) or isinstance(raw_uids, (str, bytes)):
+ raw_uids = ()
+ uids = [str(uid) for uid in raw_uids]
+ if not uids:
+ checks.append(
+ _check(
+ "binding",
+ reference_id,
+ "contradicted",
+ "Reference has no grounded scene entity.",
+ )
+ )
+ continue
+ for uid in uids:
+ entity = objects.get(uid)
+ if entity is None:
+ checks.append(
+ _check(
+ "binding",
+ f"{reference_id}:{uid}",
+ "contradicted",
+ "Binding references an entity absent from the static manifest.",
+ )
+ )
+ continue
+ checks.extend(self._entity_checks(request, entity, reference_id))
+
+ checks.extend(self._workspace_checks(steps, bindings, objects))
+
+ statuses = Counter(check["status"] for check in checks)
+ status = max(
+ (check["status"] for check in checks),
+ key=_STATUS_PRIORITY.__getitem__,
+ default="unknown",
+ )
+ blockers = sorted(
+ {
+ f"{check['subject']}: {check['reason']}"
+ for check in checks
+ if check["status"] == "contradicted"
+ }
+ )
+ return validate_feasibility_report(
+ {
+ "schema_version": FEASIBILITY_REPORT_SCHEMA,
+ "task_id": str(draft.get("task_id", "")),
+ "candidate_id": str(candidate.get("candidate_id", "")),
+ "scene_id": manifest["scene_id"],
+ "status": status,
+ "remediation_class": _remediation_class(checks),
+ "checks": checks,
+ "blockers": blockers,
+ "summary": {
+ name: int(statuses.get(name, 0))
+ for name in sorted(ASSESSMENT_STATUSES)
+ },
+ }
+ )
+
+ def _entity_checks(
+ self,
+ request: Mapping[str, Any],
+ entity: Mapping[str, Any],
+ reference_id: str,
+ ) -> list[dict[str, Any]]:
+ uid = str(entity["uid"])
+ subject = f"{reference_id}:{uid}"
+ checks = [self._structure_check(request, entity, subject)]
+ evidence_by_type: dict[str, list[Mapping[str, Any]]] = {}
+ for evidence in entity["affordances"]:
+ evidence_by_type.setdefault(str(evidence["type"]), []).append(evidence)
+ for affordance in request.get("affordances", ()):
+ name = str(affordance)
+ checks.append(
+ self._affordance_check(name, evidence_by_type.get(name, ()), subject)
+ )
+ for field_name in ("initial_state", "attributes"):
+ required = request.get(field_name, {})
+ actual = entity.get(field_name, {})
+ if isinstance(required, Mapping) and isinstance(actual, Mapping):
+ for key, expected in required.items():
+ if key not in actual:
+ status = "unknown"
+ reason = f"Required {field_name} field {key!r} is not declared."
+ elif actual[key] != expected:
+ status = "contradicted"
+ reason = f"Required {field_name} field {key!r} conflicts with the scene."
+ else:
+ status = "proven"
+ reason = f"Required {field_name} field {key!r} matches."
+ checks.append(
+ _check(
+ field_name,
+ subject,
+ status,
+ reason,
+ evidence={"field": str(key)},
+ )
+ )
+ if str(request.get("role")) == "object":
+ checks.append(
+ _check(
+ "runtime_reachability",
+ subject,
+ "runtime_probe",
+ "Reachability, collision, and grasp geometry require live planning.",
+ )
+ )
+ if (
+ str(request.get("role")) == "target"
+ and str(request.get("source_structure")) == "physical_entity"
+ ):
+ checks.append(
+ _check(
+ "placement_support",
+ subject,
+ "runtime_probe",
+ "Support depends on the payload, candidate pose, live geometry, "
+ "and post-release stability.",
+ evidence={
+ "runtime_obligations": [
+ "placement_candidates",
+ "object_supported_by",
+ "stable_for",
+ "final_support_revalidation",
+ ]
+ },
+ )
+ )
+ return checks
+
+ def _workspace_checks(
+ self,
+ steps: Mapping[str, Mapping[str, Any]],
+ bindings: Mapping[str, Any],
+ objects: Mapping[str, Mapping[str, Any]],
+ ) -> list[dict[str, Any]]:
+ """Defer arm-side compatibility to the live robot frame."""
+ checks: list[dict[str, Any]] = []
+ object_uids_by_step: dict[str, tuple[str, ...]] = {}
+ phases: list[dict[str, Any]] = []
+ for step_id, step in steps.items():
+ object_uids = _step_selector_uids(
+ step_id,
+ "object",
+ step.get("object"),
+ bindings,
+ object_uids_by_step,
+ )
+ object_uids_by_step[step_id] = object_uids
+ target_uids = _step_selector_uids(
+ step_id,
+ "target",
+ step.get("target"),
+ bindings,
+ object_uids_by_step,
+ )
+ task_type = str(step.get("task_type", ""))
+ required_arm = str(step.get("required_arm", "auto"))
+ if task_type == "E4":
+ required_arm = str(step.get("transfer_arm", "none"))
+ if required_arm in {"left_arm", "right_arm"}:
+ for uid in object_uids:
+ entity = objects.get(uid)
+ position = (
+ entity.get("initial_pose", {}).get("position", ())
+ if isinstance(entity, Mapping)
+ and isinstance(entity.get("initial_pose"), Mapping)
+ else ()
+ )
+ if (
+ not isinstance(position, Sequence)
+ or isinstance(position, (str, bytes, bytearray))
+ or len(position) < 2
+ ):
+ continue
+ checks.append(
+ _check(
+ "arm_layout_risk",
+ f"{step_id}:{uid}",
+ "runtime_probe",
+ "Arm-side compatibility requires live left/right arm-base "
+ "poses and workspace geometry.",
+ evidence={
+ "required_arm": required_arm,
+ "object_world_position": [
+ float(position[0]),
+ float(position[1]),
+ ],
+ "arm_side_frame": "live_robot",
+ "mismatch_risk": None,
+ "geometry_certificate": False,
+ },
+ )
+ )
+
+ phases.extend(
+ _workflow_phases(
+ step_id,
+ task_type,
+ object_uids,
+ target_uids,
+ transfer_arm=str(step.get("transfer_arm", "none")),
+ receive_arm=str(step.get("receive_arm", "none")),
+ )
+ )
+ if phases:
+ checks.append(
+ _check(
+ "task_workspace",
+ "task_workflow",
+ "runtime_probe",
+ "Scene layout must satisfy pickup, transfer, placement, and "
+ "safety-clearance phases across the complete task workflow.",
+ evidence={
+ "arm_side_frame": "live_robot",
+ "phases": phases,
+ "geometry_certificate": False,
+ },
+ )
+ )
+ return checks
+
+ @staticmethod
+ def _structure_check(
+ request: Mapping[str, Any],
+ entity: Mapping[str, Any],
+ subject: str,
+ ) -> dict[str, Any]:
+ expected = str(request.get("source_structure", ""))
+ role = str(entity.get("role", ""))
+ if expected in {"scene_entity", "spatial_reference"}:
+ if role in {"camera", "light", "robot", "sensor"}:
+ return _check(
+ "structure",
+ subject,
+ "contradicted",
+ f"Scene entity role {role!r} cannot be a spatial action target.",
+ evidence={"static_pose": _has_static_pose(entity)},
+ )
+ if not _has_static_pose(entity):
+ return _check(
+ "structure",
+ subject,
+ "unknown",
+ "Static scene evidence does not provide a finite spatial pose.",
+ evidence={"static_pose": False},
+ )
+ if role == "articulation":
+ return _check(
+ "structure",
+ subject,
+ "runtime_probe",
+ "Articulation has a static pose, but live spatial target lookup "
+ "must be validated at runtime.",
+ evidence={
+ "static_pose": True,
+ "runtime_entity_kind": "articulation",
+ },
+ )
+ has_runtime_body = bool(entity.get("physics"))
+ if (
+ role
+ in {
+ "background",
+ "object",
+ "rigid_object",
+ "support_surface",
+ "table",
+ }
+ and has_runtime_body
+ ):
+ return _check(
+ "structure",
+ subject,
+ "proven",
+ "Scene entity has a static pose and a rigid runtime body.",
+ evidence={
+ "static_pose": True,
+ "runtime_entity_kind": "rigid_object",
+ },
+ )
+ return _check(
+ "structure",
+ subject,
+ "runtime_probe",
+ "Scene entity has a static pose, but its live target interface is "
+ "not proven by the static manifest.",
+ evidence={"static_pose": True, "runtime_entity_kind": "unknown"},
+ )
+ if expected == "physical_entity":
+ geometry = entity.get("geometry", {})
+ shape = geometry.get("shape", {}) if isinstance(geometry, Mapping) else {}
+ asset_sha256 = (
+ geometry.get("asset_sha256", "")
+ if isinstance(geometry, Mapping)
+ else ""
+ )
+ physics = entity.get("physics", {})
+ articulation = entity.get("articulation", {})
+ has_physical_geometry = bool(shape) or bool(asset_sha256)
+ has_runtime_body = bool(physics) or bool(articulation)
+ if role in {"camera", "light", "sensor"}:
+ return _check(
+ "structure",
+ subject,
+ "contradicted",
+ f"Scene entity role {role!r} is not a physical collision body.",
+ evidence={"physical_geometry": False, "runtime_body": False},
+ )
+ if role == "articulation" or bool(articulation):
+ return _check(
+ "structure",
+ subject,
+ "contradicted",
+ "Placement on an articulation requires a link-level target "
+ "interface that the current runtime does not provide.",
+ evidence={
+ "physical_geometry": has_physical_geometry,
+ "runtime_body": bool(articulation),
+ "runtime_entity_kind": "articulation",
+ "runtime_target_interface": False,
+ },
+ )
+ if has_physical_geometry and has_runtime_body:
+ return _check(
+ "structure",
+ subject,
+ "proven",
+ "Scene entity has physical geometry and a runtime body.",
+ evidence={"physical_geometry": True, "runtime_body": True},
+ )
+ return _check(
+ "structure",
+ subject,
+ "unknown",
+ "Static scene evidence does not prove physical geometry and a "
+ "runtime body required for placement.",
+ evidence={
+ "physical_geometry": has_physical_geometry,
+ "runtime_body": has_runtime_body,
+ },
+ )
+ accepted_by_structure = {
+ "articulation": {"articulation"},
+ "rigid_object": {"object", "rigid_object"},
+ "movable": {"object", "rigid_object"},
+ "support_surface": {"background", "support_surface", "table"},
+ }
+ accepted = accepted_by_structure.get(expected)
+ if accepted is None:
+ return _check(
+ "structure",
+ subject,
+ "unknown",
+ f"Structure contract {expected!r} is not recognized by the broker.",
+ evidence={"scene_role": role},
+ )
+ if role in accepted:
+ return _check(
+ "structure",
+ subject,
+ "proven",
+ f"Scene role {role!r} satisfies structure {expected!r}.",
+ )
+ return _check(
+ "structure",
+ subject,
+ "contradicted",
+ f"Scene role {role!r} does not satisfy structure {expected!r}.",
+ )
+
+ @staticmethod
+ def _affordance_check(
+ name: str,
+ evidence: Sequence[Mapping[str, Any]],
+ subject: str,
+ ) -> dict[str, Any]:
+ if not evidence:
+ return _check(
+ "affordance",
+ subject,
+ "unknown",
+ f"Affordance {name!r} has no evidence.",
+ evidence={"affordance": name},
+ )
+ statuses = {str(item.get("status")) for item in evidence}
+ if statuses == {"contradicted"}:
+ status = "contradicted"
+ reason = f"Affordance {name!r} is explicitly contradicted."
+ elif "verified" in statuses:
+ status = "proven"
+ reason = f"Affordance {name!r} has verified evidence."
+ else:
+ status = "runtime_probe"
+ reason = (
+ f"Affordance {name!r} is declared but requires physical validation."
+ )
+ return _check(
+ "affordance",
+ subject,
+ status,
+ reason,
+ evidence={
+ "affordance": name,
+ "sources": sorted({str(item.get("source")) for item in evidence}),
+ },
+ )
+
+
+def _has_static_pose(entity: Mapping[str, Any]) -> bool:
+ pose = entity.get("initial_pose")
+ if not isinstance(pose, Mapping):
+ return False
+ position = pose.get("position")
+ if (
+ not isinstance(position, Sequence)
+ or isinstance(position, (str, bytes, bytearray))
+ or len(position) != 3
+ ):
+ return False
+ return all(
+ not isinstance(value, bool)
+ and isinstance(value, (int, float))
+ and math.isfinite(float(value))
+ for value in position
+ )
+
+
+def _step_selector_uids(
+ step_id: str,
+ role: str,
+ selector: Any,
+ bindings: Mapping[str, Any],
+ object_uids_by_step: Mapping[str, tuple[str, ...]],
+) -> tuple[str, ...]:
+ """Resolve direct and prior-step selectors for static workspace advice."""
+ raw = bindings.get(f"{step_id}.{role}", ())
+ if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes, bytearray)):
+ direct = tuple(str(uid) for uid in raw if str(uid))
+ if direct:
+ return direct
+ if not isinstance(selector, Mapping) or selector.get("kind") != "step_result":
+ return ()
+ source_step = str(selector.get("step_id", ""))
+ return tuple(object_uids_by_step.get(source_step, ()))
+
+
+def _workflow_phases(
+ step_id: str,
+ task_type: str,
+ object_uids: Sequence[str],
+ target_uids: Sequence[str],
+ *,
+ transfer_arm: str,
+ receive_arm: str,
+) -> list[dict[str, Any]]:
+ """Describe whole-task layout anchors without inventing geometry bounds."""
+ phases: list[dict[str, Any]] = []
+ if object_uids:
+ phases.append(
+ {
+ "step_id": step_id,
+ "phase": "pickup",
+ "object_uids": list(object_uids),
+ }
+ )
+ if task_type == "E4":
+ phases.append(
+ {
+ "step_id": step_id,
+ "phase": "handover_shared_workspace",
+ "object_uids": list(object_uids),
+ "transfer_arm": transfer_arm,
+ "receive_arm": receive_arm,
+ }
+ )
+ if target_uids:
+ phases.append(
+ {
+ "step_id": step_id,
+ "phase": "target_interaction",
+ "object_uids": list(object_uids),
+ "target_uids": list(target_uids),
+ }
+ )
+ if task_type in {"E1", "E2", "E3", "E4", "E5"}:
+ phases.append(
+ {
+ "step_id": step_id,
+ "phase": "safety_clearance",
+ "object_uids": list(object_uids),
+ }
+ )
+ return phases
+
+
+def _check(
+ kind: str,
+ subject: str,
+ status: str,
+ reason: str,
+ *,
+ evidence: Mapping[str, Any] | None = None,
+) -> dict[str, Any]:
+ return {
+ "kind": kind,
+ "subject": subject,
+ "status": status,
+ "reason": reason,
+ "evidence": dict(evidence or {}),
+ }
+
+
+def _remediation_class(checks: Sequence[Mapping[str, Any]]) -> str:
+ """Classify contradictions by the subsystem capable of changing them."""
+ contradicted = [check for check in checks if check.get("status") == "contradicted"]
+ if not contradicted:
+ return "none"
+ kinds = {str(check.get("kind", "")) for check in contradicted}
+ if kinds.intersection({"task_capability", "atomic_capability"}):
+ return "action_capability"
+ # A new materialization seed can change observed pose/orientation, but it
+ # cannot change task semantics, entity roles, bindings, or declared affordances.
+ if kinds <= {"initial_state"}:
+ return "scene_remediable"
+ if kinds.intersection({"binding", "structure", "affordance", "attributes"}):
+ return "input_conflict"
+ return "terminal"
+
+
+def _mapping(value: Any, context: str) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise TypeError(f"{context} must be a mapping.")
+ return dict(value)
+
+
+def _sequence(value: Any, context: str) -> list[Mapping[str, Any]]:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise TypeError(f"{context} must be a sequence.")
+ if any(not isinstance(item, Mapping) for item in value):
+ raise TypeError(f"{context} must contain mappings.")
+ return list(value)
diff --git a/embodichain/gen_sim/task_engine/scene/final_inspection.py b/embodichain/gen_sim/task_engine/scene/final_inspection.py
new file mode 100644
index 000000000..f7bf3b68f
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/scene/final_inspection.py
@@ -0,0 +1,428 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Geometry-derived evidence from one completed scene revision."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+from dataclasses import replace
+import json
+from pathlib import Path
+from typing import Any, Final, TypeAlias
+
+import numpy as np
+from scipy.spatial.transform import Rotation
+import trimesh
+
+from embodichain.gen_sim.action_engine.generation.models import PreparedScene
+from embodichain.gen_sim.action_engine.generation.source_scene import (
+ prepare_scene,
+ resolve_source_scene,
+)
+
+__all__ = [
+ "FINAL_SCENE_INSPECTION_SCHEMA",
+ "FinalSceneInspection",
+ "apply_final_inspection",
+ "inspect_final_scene",
+ "validate_final_scene_inspection",
+]
+
+FINAL_SCENE_INSPECTION_SCHEMA: Final = "embodichain.final-scene-inspection/v1"
+FinalSceneInspection: TypeAlias = dict[str, Any]
+
+_Y_UP_TO_Z_UP = np.array(
+ [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]],
+ dtype=float,
+)
+
+
+def inspect_final_scene(
+ source: str | Path,
+ *,
+ revision_id: str,
+ contact_tolerance_m: float = 0.03,
+) -> FinalSceneInspection:
+ """Measure final AABBs, orientation, and support from exported geometry.
+
+ Args:
+ source: Completed scene project or configuration path.
+ revision_id: Content identity already assigned to the completed revision.
+ contact_tolerance_m: Maximum support-surface contact gap in meters.
+
+ Returns:
+ Strict geometry-derived final inspection document.
+ """
+ tolerance = float(contact_tolerance_m)
+ if not np.isfinite(tolerance) or tolerance <= 0.0:
+ raise ValueError("contact_tolerance_m must be positive and finite.")
+ normalized_revision_id = str(revision_id)
+ if len(normalized_revision_id) != 64:
+ raise ValueError("revision_id must be a SHA-256 hexadecimal digest.")
+ try:
+ int(normalized_revision_id, 16)
+ except ValueError as exc:
+ raise ValueError("revision_id must be a SHA-256 hexadecimal digest.") from exc
+ resolved = resolve_source_scene(source)
+ prepared = prepare_scene(source)
+ runtime = {
+ str(item.get("uid")): item
+ for item in (
+ *prepared.background,
+ *prepared.rigid_objects,
+ *prepared.articulations,
+ )
+ if isinstance(item, Mapping) and item.get("uid")
+ }
+ measured: dict[str, dict[str, Any]] = {}
+ for raw in prepared.planner_objects:
+ uid = str(raw.get("uid", ""))
+ role = str(raw.get("role", ""))
+ geometry = _measure_geometry(
+ runtime.get(uid, raw),
+ convert_y_up=resolved.is_prompt2scene,
+ )
+ measured[uid] = {
+ "uid": uid,
+ "role": role,
+ "orientation": _orientation(geometry),
+ "support": {
+ "parent_uid": None if uid == "table" else "unknown",
+ "relation": "root" if uid == "table" else "unknown",
+ "confidence": 1.0 if uid == "table" else None,
+ "gap_m": None,
+ "xy_overlap_ratio": None,
+ },
+ "world_aabb": (
+ None
+ if geometry is None
+ else {
+ "min": geometry["bounds"][0].tolist(),
+ "max": geometry["bounds"][1].tolist(),
+ }
+ ),
+ "evidence": {
+ "source": "final_geometry" if geometry is not None else "unmeasured",
+ "method": "world_aabb_and_dominant_axis",
+ },
+ }
+
+ for uid, item in measured.items():
+ child_geometry = _geometry_from_record(item)
+ if uid == "table" or child_geometry is None:
+ continue
+ support = _support_for(
+ uid,
+ child_geometry,
+ measured,
+ tolerance=tolerance,
+ )
+ if support is not None:
+ item["support"] = support
+
+ return validate_final_scene_inspection(
+ {
+ "schema_version": FINAL_SCENE_INSPECTION_SCHEMA,
+ "scene_revision_id": normalized_revision_id,
+ "source_config_path": prepared.source_config_path.as_posix(),
+ "contact_tolerance_m": tolerance,
+ "objects": [measured[uid] for uid in sorted(measured)],
+ }
+ )
+
+
+def apply_final_inspection(
+ prepared_scene: PreparedScene,
+ inspection: Mapping[str, Any],
+) -> PreparedScene:
+ """Return a detached PreparedScene enriched with measured final evidence.
+
+ Args:
+ prepared_scene: Normalized scene to enrich without mutation.
+ inspection: Validated or raw final inspection mapping.
+
+ Returns:
+ Prepared scene whose semantic state reflects measured final geometry.
+ """
+ normalized = validate_final_scene_inspection(inspection)
+ by_uid = {str(item["uid"]): item for item in normalized["objects"]}
+ planner_objects = []
+ for raw in prepared_scene.planner_objects:
+ item = deepcopy(raw)
+ evidence = by_uid.get(str(item.get("uid")))
+ if evidence is not None:
+ initial_state = deepcopy(dict(item.get("initial_state", {})))
+ initial_state.pop("orientation", None)
+ if evidence["orientation"] == "standing":
+ initial_state["orientation"] = "upright"
+ elif evidence["orientation"] == "lying":
+ initial_state["orientation"] = "fallen"
+ attributes = deepcopy(dict(item.get("attributes", {})))
+ attributes["final_support"] = deepcopy(evidence["support"])
+ attributes["final_world_aabb"] = deepcopy(evidence["world_aabb"])
+ item["initial_state"] = initial_state
+ item["attributes"] = attributes
+ planner_objects.append(item)
+ return replace(prepared_scene, planner_objects=tuple(planner_objects))
+
+
+def validate_final_scene_inspection(
+ value: Mapping[str, Any],
+) -> FinalSceneInspection:
+ """Validate and detach one final scene inspection document.
+
+ Args:
+ value: Inspection mapping to validate.
+
+ Returns:
+ Detached, normalized inspection document.
+ """
+ if not isinstance(value, Mapping):
+ raise TypeError("FinalSceneInspection must be a mapping.")
+ result = deepcopy(dict(value))
+ expected = {
+ "schema_version",
+ "scene_revision_id",
+ "source_config_path",
+ "contact_tolerance_m",
+ "objects",
+ }
+ if set(result) != expected:
+ raise ValueError("FinalSceneInspection fields are invalid.")
+ if result.get("schema_version") != FINAL_SCENE_INSPECTION_SCHEMA:
+ raise ValueError("FinalSceneInspection schema version is invalid.")
+ revision_id = result.get("scene_revision_id")
+ if not isinstance(revision_id, str) or len(revision_id) != 64:
+ raise ValueError("FinalSceneInspection.scene_revision_id is invalid.")
+ try:
+ int(revision_id, 16)
+ except ValueError as exc:
+ raise ValueError("FinalSceneInspection.scene_revision_id is invalid.") from exc
+ source_path = result.get("source_config_path")
+ if not isinstance(source_path, str) or not source_path:
+ raise ValueError("FinalSceneInspection.source_config_path is invalid.")
+ tolerance = result.get("contact_tolerance_m")
+ if (
+ isinstance(tolerance, bool)
+ or not isinstance(tolerance, (int, float))
+ or not np.isfinite(float(tolerance))
+ or float(tolerance) <= 0.0
+ ):
+ raise ValueError("FinalSceneInspection.contact_tolerance_m is invalid.")
+ objects = result.get("objects")
+ if not isinstance(objects, Sequence) or isinstance(objects, (str, bytes)):
+ raise TypeError("FinalSceneInspection.objects must be a sequence.")
+ normalized = [_validate_object(item, index) for index, item in enumerate(objects)]
+ if len({item["uid"] for item in normalized}) != len(normalized):
+ raise ValueError("FinalSceneInspection object UIDs must be unique.")
+ result["objects"] = normalized
+ result["contact_tolerance_m"] = float(tolerance)
+ json.dumps(result, ensure_ascii=False, allow_nan=False)
+ return result
+
+
+def _validate_object(value: Any, index: int) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise TypeError(f"FinalSceneInspection.objects[{index}] must be a mapping.")
+ item = deepcopy(dict(value))
+ expected = {"uid", "role", "orientation", "support", "world_aabb", "evidence"}
+ if set(item) != expected:
+ raise ValueError(f"FinalSceneInspection.objects[{index}] fields are invalid.")
+ if not isinstance(item["uid"], str) or not item["uid"]:
+ raise ValueError(f"FinalSceneInspection.objects[{index}].uid is invalid.")
+ if not isinstance(item["role"], str) or not item["role"]:
+ raise ValueError(f"FinalSceneInspection.objects[{index}].role is invalid.")
+ if item["orientation"] not in {"standing", "lying", "unknown"}:
+ raise ValueError(
+ f"FinalSceneInspection.objects[{index}].orientation is invalid."
+ )
+ if not isinstance(item["support"], Mapping) or not isinstance(
+ item["evidence"], Mapping
+ ):
+ raise TypeError("FinalSceneInspection support and evidence must be mappings.")
+ support = deepcopy(dict(item["support"]))
+ if set(support) != {
+ "parent_uid",
+ "relation",
+ "confidence",
+ "gap_m",
+ "xy_overlap_ratio",
+ }:
+ raise ValueError("FinalSceneInspection support fields are invalid.")
+ if support["parent_uid"] is not None and not isinstance(support["parent_uid"], str):
+ raise TypeError("FinalSceneInspection support parent_uid is invalid.")
+ if support["relation"] not in {"root", "on", "unknown"}:
+ raise ValueError("FinalSceneInspection support relation is invalid.")
+ for field_name in ("confidence", "gap_m", "xy_overlap_ratio"):
+ field_value = support[field_name]
+ if field_value is not None and (
+ isinstance(field_value, bool)
+ or not isinstance(field_value, (int, float))
+ or not np.isfinite(float(field_value))
+ ):
+ raise ValueError(f"FinalSceneInspection support {field_name} is invalid.")
+ if (
+ support["confidence"] is not None
+ and not 0.0 <= float(support["confidence"]) <= 1.0
+ ):
+ raise ValueError("FinalSceneInspection support confidence is invalid.")
+ if (
+ support["xy_overlap_ratio"] is not None
+ and not 0.0 <= float(support["xy_overlap_ratio"]) <= 1.0 + 1.0e-6
+ ):
+ raise ValueError("FinalSceneInspection support overlap is invalid.")
+ item["support"] = support
+ aabb = item["world_aabb"]
+ if aabb is not None:
+ if not isinstance(aabb, Mapping) or set(aabb) != {"min", "max"}:
+ raise ValueError("FinalSceneInspection world_aabb is invalid.")
+ if aabb["min"] is None or aabb["max"] is None:
+ raise ValueError("FinalSceneInspection world_aabb vectors are invalid.")
+ minimum = _vector(aabb["min"], default=(0.0, 0.0, 0.0))
+ maximum = _vector(aabb["max"], default=(0.0, 0.0, 0.0))
+ if np.any(np.asarray(maximum) < np.asarray(minimum)):
+ raise ValueError("FinalSceneInspection world_aabb bounds are inverted.")
+ item["world_aabb"] = {"min": minimum, "max": maximum}
+ evidence = deepcopy(dict(item["evidence"]))
+ if set(evidence) != {"source", "method"} or any(
+ not isinstance(evidence[key], str) or not evidence[key] for key in evidence
+ ):
+ raise ValueError("FinalSceneInspection evidence is invalid.")
+ item["evidence"] = evidence
+ return item
+
+
+def _measure_geometry(
+ entry: Mapping[str, Any],
+ *,
+ convert_y_up: bool,
+) -> dict[str, Any] | None:
+ shape = entry.get("shape")
+ if not isinstance(shape, Mapping):
+ return None
+ shape_type = str(shape.get("shape_type", ""))
+ if shape_type == "Mesh":
+ path = Path(str(shape.get("fpath", ""))).expanduser().resolve()
+ if not path.is_file():
+ return None
+ loaded = trimesh.load(path, force="scene")
+ mesh = loaded.to_geometry()
+ elif shape_type == "Cube":
+ mesh = trimesh.creation.box(
+ extents=_vector(shape.get("size"), default=(1, 1, 1))
+ )
+ elif shape_type == "Sphere":
+ radius = float(shape.get("radius", 1.0))
+ mesh = trimesh.creation.icosphere(radius=radius)
+ else:
+ return None
+ scale = np.asarray(_vector(entry.get("body_scale"), default=(1, 1, 1)))
+ mesh.apply_scale(scale)
+ local_extents = np.asarray(mesh.extents, dtype=float)
+ conversion = _Y_UP_TO_Z_UP if convert_y_up else np.eye(3)
+ rotation = Rotation.from_euler(
+ "XYZ",
+ _vector(entry.get("init_rot"), default=(0, 0, 0)),
+ degrees=True,
+ ).as_matrix()
+ transform = np.eye(4)
+ transform[:3, :3] = rotation @ conversion
+ transform[:3, 3] = _vector(entry.get("init_pos"), default=(0, 0, 0))
+ mesh.apply_transform(transform)
+ return {
+ "bounds": np.asarray(mesh.bounds, dtype=float),
+ "local_extents": local_extents,
+ "axis_transform": transform[:3, :3],
+ "shape_type": shape_type,
+ }
+
+
+def _orientation(geometry: Mapping[str, Any] | None) -> str:
+ if geometry is None or geometry["shape_type"] == "Sphere":
+ return "unknown"
+ extents = np.asarray(geometry["local_extents"], dtype=float)
+ ordered = np.sort(extents)
+ if ordered[-1] <= 0.0 or ordered[-1] / max(ordered[-2], 1.0e-9) < 1.2:
+ return "unknown"
+ dominant = int(np.argmax(extents))
+ axis = np.asarray(geometry["axis_transform"], dtype=float)[:, dominant]
+ vertical = abs(float(axis[2])) / max(float(np.linalg.norm(axis)), 1.0e-9)
+ if vertical >= 0.75:
+ return "standing"
+ if vertical <= 0.35:
+ return "lying"
+ return "unknown"
+
+
+def _geometry_from_record(item: Mapping[str, Any]) -> np.ndarray | None:
+ aabb = item.get("world_aabb")
+ if not isinstance(aabb, Mapping):
+ return None
+ return np.asarray([aabb["min"], aabb["max"]], dtype=float)
+
+
+def _support_for(
+ uid: str,
+ child: np.ndarray,
+ objects: Mapping[str, Mapping[str, Any]],
+ *,
+ tolerance: float,
+) -> dict[str, Any] | None:
+ child_bottom = float(child[0, 2])
+ child_area = max(
+ float((child[1, 0] - child[0, 0]) * (child[1, 1] - child[0, 1])),
+ 1.0e-9,
+ )
+ candidates = []
+ for parent_uid, parent_item in objects.items():
+ if parent_uid == uid:
+ continue
+ parent = _geometry_from_record(parent_item)
+ if parent is None:
+ continue
+ overlap_x = max(
+ 0.0, min(child[1, 0], parent[1, 0]) - max(child[0, 0], parent[0, 0])
+ )
+ overlap_y = max(
+ 0.0, min(child[1, 1], parent[1, 1]) - max(child[0, 1], parent[0, 1])
+ )
+ overlap_ratio = float(overlap_x * overlap_y / child_area)
+ gap = child_bottom - float(parent[1, 2])
+ if overlap_ratio >= 0.1 and -tolerance <= gap <= tolerance:
+ candidates.append((overlap_ratio, -abs(gap), parent_uid, gap))
+ if not candidates:
+ return None
+ overlap_ratio, _, parent_uid, gap = max(candidates)
+ confidence = min(1.0, overlap_ratio * max(0.0, 1.0 - abs(gap) / tolerance))
+ return {
+ "parent_uid": parent_uid,
+ "relation": "on",
+ "confidence": float(confidence),
+ "gap_m": float(gap),
+ "xy_overlap_ratio": float(overlap_ratio),
+ }
+
+
+def _vector(value: Any, *, default: tuple[float, float, float]) -> list[float]:
+ raw = default if value is None else value
+ if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
+ raise TypeError("Scene geometry vectors must be sequences.")
+ result = [float(item) for item in raw]
+ if len(result) != 3 or not np.all(np.isfinite(result)):
+ raise ValueError("Scene geometry vectors must contain three finite values.")
+ return result
diff --git a/embodichain/gen_sim/task_engine/scene/scene_engine_v1.py b/embodichain/gen_sim/task_engine/scene/scene_engine_v1.py
new file mode 100644
index 000000000..af0c4a47c
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/scene/scene_engine_v1.py
@@ -0,0 +1,242 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Adapt existing Scene Engine exports without changing their source schema."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+import hashlib
+import json
+from pathlib import Path
+from typing import Any
+
+from .contracts import (
+ STATIC_SCENE_MANIFEST_SCHEMA,
+ StaticSceneManifest,
+ validate_static_scene_manifest,
+)
+
+__all__ = ["SceneEngineV1Adapter"]
+
+
+class SceneEngineV1Adapter:
+ """Convert a normalized Scene Engine v1 export to the neutral manifest."""
+
+ def adapt_prepared_scene(
+ self,
+ prepared_scene: Any,
+ *,
+ source_format: str,
+ robot_profile: str,
+ ) -> StaticSceneManifest:
+ """Adapt the existing prepared-scene view through a duck-typed boundary."""
+ planner_objects = tuple(getattr(prepared_scene, "planner_objects"))
+ runtime_objects = (
+ tuple(getattr(prepared_scene, "background", ()))
+ + tuple(getattr(prepared_scene, "rigid_objects", ()))
+ + tuple(getattr(prepared_scene, "articulations", ()))
+ )
+ runtime_by_uid = {
+ str(item.get("uid")): item
+ for item in runtime_objects
+ if isinstance(item, Mapping) and item.get("uid")
+ }
+ asset_hashes = dict(getattr(prepared_scene, "asset_hashes", {}) or {})
+ objects = [
+ self._object_manifest(
+ raw,
+ runtime=runtime_by_uid.get(str(raw.get("uid")), {}),
+ asset_sha256=str(asset_hashes.get(str(raw.get("uid")), "")),
+ )
+ for raw in planner_objects
+ ]
+ identity = {
+ "source_format": str(source_format),
+ "robot_profile": str(robot_profile),
+ "objects": [_identity_object(item) for item in objects],
+ }
+ source_path = Path(getattr(prepared_scene, "source_config_path"))
+ return validate_static_scene_manifest(
+ {
+ "schema_version": STATIC_SCENE_MANIFEST_SCHEMA,
+ "scene_id": _canonical_hash(identity),
+ "source_format": str(source_format),
+ "robot_profile": str(robot_profile),
+ "source": {
+ "adapter": f"{type(self).__module__}.{type(self).__qualname__}",
+ "config_path": source_path.expanduser().resolve().as_posix(),
+ "config_sha256": _file_hash(source_path),
+ "asset_hashes": asset_hashes,
+ },
+ "adapter_capabilities": {
+ "task_conditioned_generation": False,
+ "structured_affordances": any(
+ bool(item["affordances"]) for item in objects
+ ),
+ "articulation_instances": any(
+ item["role"] == "articulation" for item in objects
+ ),
+ "runtime_scene_observation": False,
+ },
+ "objects": objects,
+ }
+ )
+
+ def _object_manifest(
+ self,
+ raw: Mapping[str, Any],
+ *,
+ runtime: Mapping[str, Any],
+ asset_sha256: str,
+ ) -> dict[str, Any]:
+ uid = str(raw.get("uid", "")).strip()
+ role = str(raw.get("role", "")).strip()
+ shape = raw.get("shape", runtime.get("shape", {}))
+ shape = deepcopy(dict(shape)) if isinstance(shape, Mapping) else {}
+ physics_keys = ("attrs", "body_type", "max_convex_hull_num")
+ physics = {
+ key: deepcopy(runtime[key]) for key in physics_keys if key in runtime
+ }
+ articulation = deepcopy(dict(runtime)) if role == "articulation" else {}
+ affordances = _affordance_evidence(raw.get("affordances", ()))
+ if role in {"background", "table", "support_surface"}:
+ affordances = _with_structural_evidence(
+ affordances,
+ "support_surface",
+ )
+ if role in {"object", "rigid_object"}:
+ affordances = _with_structural_evidence(affordances, "rigid")
+ return {
+ "uid": uid,
+ "source_uid": str(raw.get("source_uid", "")),
+ "role": role,
+ "name": str(raw.get("name", "")),
+ "description": str(raw.get("description", "")),
+ "category": str(raw.get("category", "")),
+ "color": raw.get("color") if isinstance(raw.get("color"), str) else None,
+ "geometry": {
+ "shape": shape,
+ "asset_sha256": asset_sha256,
+ },
+ "initial_pose": {
+ "position": deepcopy(list(raw.get("init_pos", ()))),
+ "rotation": deepcopy(list(raw.get("init_rot", ()))),
+ "scale": deepcopy(list(raw.get("body_scale", ()))),
+ },
+ "physics": physics,
+ "articulation": articulation,
+ "affordances": affordances,
+ "initial_state": _mapping_or_empty(raw.get("initial_state")),
+ "attributes": _mapping_or_empty(raw.get("attributes")),
+ "provenance": {
+ "semantic_source": "scene_export",
+ "geometry_source": "prepared_scene",
+ "physics_source": "prepared_scene_runtime",
+ },
+ }
+
+
+def _affordance_evidence(value: Any) -> list[dict[str, Any]]:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ return []
+ result: list[dict[str, Any]] = []
+ for raw in value:
+ if isinstance(raw, str) and raw.strip():
+ result.append(_evidence(raw.strip(), status="declared"))
+ continue
+ if not isinstance(raw, Mapping):
+ continue
+ affordance_type = str(raw.get("type", raw.get("name", ""))).strip()
+ if not affordance_type:
+ continue
+ status = str(raw.get("status", "declared"))
+ result.append(
+ {
+ "type": affordance_type,
+ "status": status,
+ "confidence": raw.get("confidence"),
+ "source": str(raw.get("source", "scene_export")),
+ "link_uid": str(raw.get("link_uid", "")),
+ "frame": _mapping_or_empty(raw.get("frame")),
+ "parameters": _mapping_or_empty(raw.get("parameters")),
+ }
+ )
+ return sorted(result, key=lambda item: (item["type"], item["source"]))
+
+
+def _with_structural_evidence(
+ evidence: list[dict[str, Any]], affordance_type: str
+) -> list[dict[str, Any]]:
+ if any(item["type"] == affordance_type for item in evidence):
+ return evidence
+ return sorted(
+ [
+ *evidence,
+ _evidence(affordance_type, status="verified", source="adapter_structure"),
+ ],
+ key=lambda item: (item["type"], item["source"]),
+ )
+
+
+def _evidence(
+ affordance_type: str,
+ *,
+ status: str,
+ source: str = "scene_export",
+) -> dict[str, Any]:
+ return {
+ "type": affordance_type,
+ "status": status,
+ "confidence": None,
+ "source": source,
+ "link_uid": "",
+ "frame": {},
+ "parameters": {},
+ }
+
+
+def _mapping_or_empty(value: Any) -> dict[str, Any]:
+ return deepcopy(dict(value)) if isinstance(value, Mapping) else {}
+
+
+def _identity_object(value: Mapping[str, Any]) -> dict[str, Any]:
+ result = deepcopy(dict(value))
+ geometry = result.get("geometry")
+ if isinstance(geometry, dict):
+ shape = geometry.get("shape")
+ if isinstance(shape, dict) and geometry.get("asset_sha256"):
+ shape.pop("fpath", None)
+ return result
+
+
+def _canonical_hash(value: Any) -> str:
+ payload = json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _file_hash(path: Path) -> str:
+ resolved = path.expanduser().resolve()
+ if not resolved.is_file():
+ return ""
+ return hashlib.sha256(resolved.read_bytes()).hexdigest()
diff --git a/embodichain/gen_sim/task_engine/scene_backend.py b/embodichain/gen_sim/task_engine/scene_backend.py
new file mode 100644
index 000000000..dd4a665c5
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/scene_backend.py
@@ -0,0 +1,474 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Task-owned adapter for Scene Engine analysis, revisions, and edits."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from copy import deepcopy
+from dataclasses import dataclass, replace
+from pathlib import Path
+import json
+import shutil
+from typing import Any
+
+from embodichain.gen_sim.action_engine.generation.source_scene import (
+ resolve_source_scene,
+)
+from embodichain.gen_sim.scene_engine.pipeline import (
+ SceneBlueprintPackage,
+ SceneMaterialization,
+ analyze_edit,
+ analyze_image,
+ materialize_blueprint,
+ materialize_edit,
+)
+
+from .orchestration.legacy_scene import (
+ convert_legacy_gym_project,
+ restore_locked_scene_entities,
+)
+from .orchestration.scene_adapter import CandidateSelection, SceneAdapter
+from .orchestration.scene_source import (
+ SceneSourceFingerprint,
+ fingerprint_scene_source,
+ scene_revision_id,
+ verify_scene_source_fingerprint,
+)
+from .scene.final_inspection import FinalSceneInspection, inspect_final_scene
+from .workflow_contracts import TaskRunRequest, scene_input_kind
+
+__all__ = [
+ "SceneRemediableError",
+ "SceneAnalysis",
+ "SceneEngineBackend",
+ "SceneRevision",
+ "scene_blueprint_objects",
+]
+
+_LOCKED_SCENE_MANIFEST = "locked_scene_entities.json"
+
+
+class SceneRemediableError(RuntimeError):
+ """A Scene output failure that permits a fresh materialization attempt."""
+
+
+@dataclass(frozen=True)
+class SceneAnalysis:
+ """Scene semantics available before asset materialization."""
+
+ input_kind: str
+ source: Path
+ blueprint: SceneBlueprintPackage | None
+ source_fingerprint: SceneSourceFingerprint | None
+
+
+@dataclass(frozen=True)
+class SceneRevision:
+ """One immutable scene source selected for final Action preparation."""
+
+ source: Path
+ output_root: Path | None
+ revision_id: str
+ seed: int
+ edit_plan: dict[str, Any] | None
+ source_fingerprint: SceneSourceFingerprint | None
+
+
+class SceneEngineBackend:
+ """Expose Scene Engine stages without giving it workflow ownership."""
+
+ def analyze(
+ self,
+ request: TaskRunRequest,
+ output_root: str | Path,
+ ) -> SceneAnalysis:
+ """Analyze an image or fingerprint an existing read-only project.
+
+ Args:
+ request: Validated Task Engine run request.
+ output_root: Directory for image-understanding artifacts.
+
+ Returns:
+ Scene semantics and immutable source provenance.
+ """
+ root = Path(output_root).expanduser().resolve()
+ if scene_input_kind(request) == "image":
+ image_path = Path(str(request["image_path"])).resolve()
+ blueprint = analyze_image(image_path, root)
+ return SceneAnalysis(
+ input_kind="image",
+ source=image_path,
+ blueprint=blueprint,
+ source_fingerprint=None,
+ )
+ source = Path(str(request["gym_project"])).resolve()
+ return SceneAnalysis(
+ input_kind="gym_project",
+ source=source,
+ blueprint=None,
+ source_fingerprint=fingerprint_scene_source(source),
+ )
+
+ def select(
+ self,
+ analysis: SceneAnalysis,
+ candidate_set: Mapping[str, Any],
+ scene_adapter: SceneAdapter,
+ *,
+ force_most_likely: bool,
+ ) -> CandidateSelection:
+ """Select a task candidate from blueprint or existing-scene semantics.
+
+ Args:
+ analysis: Pre-materialization scene analysis.
+ candidate_set: Task candidates to ground and vote.
+ scene_adapter: Task-owned semantic binding adapter.
+ force_most_likely: Whether ranked UID hypotheses must be resolved.
+
+ Returns:
+ Audited initial candidate selection.
+ """
+ if analysis.blueprint is not None:
+ return scene_adapter.select_objects(
+ candidate_set,
+ scene_blueprint_objects(analysis.blueprint),
+ force_most_likely=force_most_likely,
+ )
+ adaptation = scene_adapter.adapt(
+ candidate_set,
+ analysis.source,
+ force_most_likely=force_most_likely,
+ )
+ return CandidateSelection(
+ scene_manifest=adaptation.scene_manifest,
+ role_bindings=adaptation.role_bindings,
+ binding_report=adaptation.binding_report,
+ selected_candidate=adaptation.selected_candidate,
+ candidate_bindings=adaptation.candidate_bindings,
+ )
+
+ def materialize(
+ self,
+ analysis: SceneAnalysis,
+ request: TaskRunRequest,
+ output_root: str | Path,
+ *,
+ seed: int,
+ ) -> SceneRevision:
+ """Produce a new revision, or return the untouched existing source.
+
+ Args:
+ analysis: Pre-materialization scene analysis.
+ request: Validated Task Engine run request.
+ output_root: Fresh directory for this scene attempt.
+ seed: Attempt seed recorded for recovery audit.
+
+ Returns:
+ Final scene source for binding and Action Engine generation.
+ """
+ root = Path(output_root).expanduser().resolve()
+ edit_prompt = request["scene_edit_prompt"]
+ if analysis.input_kind == "image":
+ assert analysis.blueprint is not None
+ root.mkdir(parents=True, exist_ok=False)
+ blueprint = replace(analysis.blueprint, output_root=root)
+ materialization = materialize_blueprint(blueprint, seed=seed)
+ edit_plan = None
+ if edit_prompt is not None:
+ edit_blueprint = analyze_edit(
+ output_root=root,
+ edit_prompt=str(edit_prompt),
+ )
+ edit_plan = edit_blueprint.scene_edit_plan.to_dict()
+ materialization = materialize_edit(edit_blueprint, seed=seed)
+ revision = _revision(materialization, seed=seed, edit_plan=edit_plan)
+ _write_revision_audit(
+ root,
+ revision_id=revision.revision_id,
+ seed=seed,
+ edit_plan=edit_plan,
+ )
+ return revision
+
+ fingerprint = analysis.source_fingerprint
+ assert fingerprint is not None
+ if edit_prompt is None:
+ verify_scene_source_fingerprint(fingerprint.to_dict())
+ return SceneRevision(
+ source=analysis.source,
+ output_root=None,
+ revision_id=scene_revision_id(analysis.source),
+ seed=seed,
+ edit_plan=None,
+ source_fingerprint=fingerprint,
+ )
+
+ resolved = resolve_source_scene(analysis.source)
+ if resolved.source_format == "legacy_gym_config":
+ converted = convert_legacy_gym_project(analysis.source, root)
+ editable_root = converted.output_root
+ else:
+ editable_root = _copy_scene_export_revision(resolved.path, root)
+ edit_blueprint = analyze_edit(
+ output_root=editable_root,
+ edit_prompt=str(edit_prompt),
+ )
+ edit_plan = edit_blueprint.scene_edit_plan.to_dict()
+ materialization = materialize_edit(edit_blueprint, seed=seed)
+ if resolved.source_format == "legacy_gym_config":
+ restore_locked_scene_entities(editable_root)
+ else:
+ _restore_scene_export_locked_entities(editable_root)
+ verify_scene_source_fingerprint(fingerprint.to_dict())
+ _write_revision_audit(
+ editable_root,
+ revision_id=scene_revision_id(materialization.scene_config_path),
+ seed=seed,
+ edit_plan=edit_plan,
+ source_fingerprint=fingerprint,
+ )
+ return SceneRevision(
+ source=materialization.scene_config_path,
+ output_root=editable_root,
+ revision_id=scene_revision_id(materialization.scene_config_path),
+ seed=seed,
+ edit_plan=edit_plan,
+ source_fingerprint=fingerprint,
+ )
+
+ def inspect(
+ self,
+ revision: SceneRevision,
+ output_path: str | Path,
+ ) -> FinalSceneInspection:
+ """Inspect final geometry and publish support/orientation evidence.
+
+ Args:
+ revision: Completed immutable scene revision.
+ output_path: JSON path receiving the inspection document.
+
+ Returns:
+ Validated final scene inspection.
+ """
+ try:
+ actual_revision_id = scene_revision_id(revision.source)
+ except (OSError, TypeError, ValueError) as exc:
+ raise SceneRemediableError(
+ f"Final scene content could not be hashed: {exc}"
+ ) from exc
+ if actual_revision_id != revision.revision_id:
+ raise RuntimeError("Final scene changed before geometry inspection.")
+ try:
+ inspection = inspect_final_scene(
+ revision.source,
+ revision_id=actual_revision_id,
+ )
+ except (OSError, TypeError, ValueError) as exc:
+ raise SceneRemediableError(
+ f"Final scene assets could not be inspected: {exc}"
+ ) from exc
+ path = Path(output_path).expanduser().resolve()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(inspection, ensure_ascii=False, indent=2, allow_nan=False)
+ + "\n",
+ encoding="utf-8",
+ )
+ return inspection
+
+
+def scene_blueprint_objects(blueprint: SceneBlueprintPackage) -> list[dict[str, Any]]:
+ """Convert image semantics into the redacted grounding inventory shape.
+
+ Args:
+ blueprint: Scene Engine image-understanding package.
+
+ Returns:
+ Semantic objects with unknown physical fields represented conservatively.
+ """
+ nodes = blueprint.scene_graph.node_by_id()
+ result = []
+ for item in blueprint.scene.objects:
+ node = nodes.get(item.id)
+ orientation = None if node is None else node.orientation_state
+ initial_state = {}
+ if orientation == "lying":
+ initial_state["orientation"] = "fallen"
+ elif orientation == "standing":
+ initial_state["orientation"] = "upright"
+ result.append(
+ {
+ "uid": item.id,
+ "source_uid": item.id,
+ "role": "table" if item.kind == "table" else "rigid_object",
+ "name": item.name,
+ "description": item.description,
+ "category": item.category,
+ "color": None,
+ "init_pos": [0.0, 0.0, 0.0],
+ "affordances": [],
+ "initial_state": initial_state,
+ "attributes": {},
+ }
+ )
+ return result
+
+
+def _copy_scene_export_revision(source_config: Path, output_root: Path) -> Path:
+ if output_root.exists():
+ if not output_root.is_dir() or any(output_root.iterdir()):
+ raise ValueError("Scene revision output_root must be empty.")
+ source_root = source_config.parent
+ destination = output_root / "scene_export"
+ shutil.copytree(source_root, destination)
+ config_path = destination / "scene_config.json"
+ config = _read_json_mapping(config_path)
+ background = list(config.get("background", ()))
+ rigid_objects = list(config.get("rigid_object", ()))
+ articulations = list(config.get("articulation", ()))
+ editable_rigid = [
+ item
+ for item in rigid_objects
+ if isinstance(item, Mapping) and _scene_editable_rigid(item)
+ ]
+ locked_rigid = [item for item in rigid_objects if item not in editable_rigid]
+ table = [
+ item
+ for item in background
+ if isinstance(item, Mapping) and item.get("uid") == "table"
+ ]
+ if len(table) != 1:
+ raise ValueError("Scene export revision requires exactly one table.")
+ locked = {
+ "schema_version": "embodichain.locked-scene-entities/v1",
+ "background": [item for item in background if item not in table],
+ "rigid_object": locked_rigid,
+ "articulation": articulations,
+ }
+ config["background"] = table
+ config["rigid_object"] = editable_rigid
+ config["articulation"] = []
+ _write_json_mapping(config_path, config)
+ _write_json_mapping(output_root / _LOCKED_SCENE_MANIFEST, locked)
+ graph_path = destination / "scene_graph.json"
+ if graph_path.is_file():
+ graph = _read_json_mapping(graph_path)
+ editable_uids = {str(item.get("uid")) for item in [*table, *editable_rigid]}
+ graph["nodes"] = [
+ item
+ for item in graph.get("nodes", ())
+ if isinstance(item, Mapping) and item.get("object_id") in editable_uids
+ ]
+ graph["relations"] = [
+ item
+ for item in graph.get("relations", ())
+ if isinstance(item, Mapping)
+ and item.get("source_id") in editable_uids
+ and item.get("target_id") in editable_uids
+ ]
+ _write_json_mapping(graph_path, graph)
+ return output_root
+
+
+def _restore_scene_export_locked_entities(output_root: Path) -> None:
+ manifest = _read_json_mapping(output_root / _LOCKED_SCENE_MANIFEST)
+ if manifest.get("schema_version") != "embodichain.locked-scene-entities/v1":
+ raise ValueError("Locked scene entity manifest schema is invalid.")
+ config_path = output_root / "scene_export" / "scene_config.json"
+ config = _read_json_mapping(config_path)
+ existing = {
+ str(item.get("uid"))
+ for section in ("background", "rigid_object", "articulation")
+ for item in config.get(section, ())
+ if isinstance(item, Mapping) and item.get("uid")
+ }
+ for section in ("background", "rigid_object", "articulation"):
+ target = list(config.get(section, ()))
+ for raw in manifest.get(section, ()):
+ item = deepcopy(dict(raw))
+ uid = str(item.get("uid", ""))
+ if not uid or uid in existing:
+ raise ValueError(f"Scene edit reused locked entity UID {uid!r}.")
+ target.append(item)
+ existing.add(uid)
+ config[section] = target
+ _write_json_mapping(config_path, config)
+
+
+def _scene_editable_rigid(value: Mapping[str, Any]) -> bool:
+ shape = value.get("shape")
+ return (
+ isinstance(shape, Mapping)
+ and shape.get("shape_type") == "Mesh"
+ and isinstance(shape.get("fpath"), str)
+ and bool(shape["fpath"])
+ )
+
+
+def _read_json_mapping(path: Path) -> dict[str, Any]:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, Mapping):
+ raise TypeError(f"JSON artifact must contain an object: {path}")
+ return dict(value)
+
+
+def _write_json_mapping(path: Path, value: Mapping[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
+ encoding="utf-8",
+ )
+
+
+def _revision(
+ value: SceneMaterialization,
+ *,
+ seed: int,
+ edit_plan: dict[str, Any] | None,
+) -> SceneRevision:
+ return SceneRevision(
+ source=value.scene_config_path,
+ output_root=value.output_root,
+ revision_id=scene_revision_id(value.scene_config_path),
+ seed=seed,
+ edit_plan=edit_plan,
+ source_fingerprint=None,
+ )
+
+
+def _write_revision_audit(
+ output_root: Path,
+ *,
+ revision_id: str,
+ seed: int,
+ edit_plan: Mapping[str, Any] | None,
+ source_fingerprint: SceneSourceFingerprint | None = None,
+) -> None:
+ payload = {
+ "schema_version": "embodichain.scene-revision-attempt/v1",
+ "revision_id": revision_id,
+ "seed": int(seed),
+ "edit_plan": None if edit_plan is None else dict(edit_plan),
+ "source_fingerprint": (
+ None if source_fingerprint is None else source_fingerprint.to_dict()
+ ),
+ }
+ (output_root / "scene_revision_attempt.json").write_text(
+ json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
+ encoding="utf-8",
+ )
diff --git a/embodichain/gen_sim/task_engine/state_machine.py b/embodichain/gen_sim/task_engine/state_machine.py
new file mode 100644
index 000000000..ffdaaba15
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/state_machine.py
@@ -0,0 +1,294 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Typed, replayable state transitions for cross-engine orchestration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+from dataclasses import dataclass, field
+from enum import Enum
+from types import MappingProxyType
+from typing import Any
+
+from .workflow_contracts import TaskRunRequest, validate_task_run_request
+
+__all__ = [
+ "StageStatus",
+ "TaskEngineState",
+ "WorkflowStage",
+ "complete_stage",
+ "fail_stage",
+ "initial_state",
+ "replay_events",
+ "skip_stage",
+ "start_stage",
+]
+
+
+class WorkflowStage(str, Enum):
+ """Stable stages shared by all four supported input combinations."""
+
+ INPUT = "input"
+ TASK_CANDIDATES = "task_candidates"
+ SCENE_PREPARATION = "scene_preparation"
+ SCENE_EDIT = "scene_edit"
+ CANDIDATE_SELECTION = "candidate_selection"
+ SCENE_FINALIZATION = "scene_finalization"
+ UNBOUND_ACTION = "unbound_action"
+ FINAL_INSPECTION = "final_inspection"
+ FINAL_BINDING = "final_binding"
+ STATIC_FEASIBILITY = "static_feasibility"
+ GROUNDED_ACTION = "grounded_action"
+ EXECUTION = "execution"
+
+
+class StageStatus(str, Enum):
+ """Lifecycle of one independently schedulable workflow stage."""
+
+ PENDING = "pending"
+ RUNNING = "running"
+ SUCCEEDED = "succeeded"
+ FAILED = "failed"
+ SKIPPED = "skipped"
+
+
+_DEPENDENCIES: dict[WorkflowStage, frozenset[WorkflowStage]] = {
+ WorkflowStage.INPUT: frozenset(),
+ WorkflowStage.TASK_CANDIDATES: frozenset({WorkflowStage.INPUT}),
+ WorkflowStage.SCENE_PREPARATION: frozenset({WorkflowStage.INPUT}),
+ WorkflowStage.SCENE_EDIT: frozenset({WorkflowStage.SCENE_PREPARATION}),
+ WorkflowStage.CANDIDATE_SELECTION: frozenset(
+ {
+ WorkflowStage.TASK_CANDIDATES,
+ WorkflowStage.SCENE_PREPARATION,
+ }
+ ),
+ WorkflowStage.SCENE_FINALIZATION: frozenset(
+ {WorkflowStage.CANDIDATE_SELECTION, WorkflowStage.SCENE_EDIT}
+ ),
+ WorkflowStage.UNBOUND_ACTION: frozenset({WorkflowStage.CANDIDATE_SELECTION}),
+ WorkflowStage.FINAL_INSPECTION: frozenset({WorkflowStage.SCENE_FINALIZATION}),
+ WorkflowStage.FINAL_BINDING: frozenset(
+ {WorkflowStage.FINAL_INSPECTION, WorkflowStage.UNBOUND_ACTION}
+ ),
+ WorkflowStage.STATIC_FEASIBILITY: frozenset({WorkflowStage.FINAL_BINDING}),
+ WorkflowStage.GROUNDED_ACTION: frozenset({WorkflowStage.STATIC_FEASIBILITY}),
+ WorkflowStage.EXECUTION: frozenset({WorkflowStage.GROUNDED_ACTION}),
+}
+
+_SKIPPABLE_STAGES = frozenset({WorkflowStage.SCENE_EDIT})
+
+
+@dataclass(frozen=True)
+class TaskEngineState:
+ """Immutable state snapshot plus an append-only transition audit."""
+
+ request: Mapping[str, Any]
+ stages: Mapping[WorkflowStage, StageStatus]
+ events: tuple[Mapping[str, Any], ...] = field(default_factory=tuple)
+
+ def __post_init__(self) -> None:
+ object.__setattr__(
+ self,
+ "request",
+ MappingProxyType(deepcopy(dict(self.request))),
+ )
+ object.__setattr__(
+ self,
+ "stages",
+ MappingProxyType(dict(self.stages)),
+ )
+ object.__setattr__(
+ self,
+ "events",
+ tuple(MappingProxyType(deepcopy(dict(event))) for event in self.events),
+ )
+
+ @property
+ def terminal(self) -> bool:
+ """Return whether execution succeeded or any stage failed."""
+ return (
+ self.stages[WorkflowStage.EXECUTION] == StageStatus.SUCCEEDED
+ or StageStatus.FAILED in self.stages.values()
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ """Return one JSON-safe audit snapshot."""
+ return {
+ "request": deepcopy(dict(self.request)),
+ "stages": {
+ stage.value: self.stages[stage].value for stage in WorkflowStage
+ },
+ "events": deepcopy([dict(event) for event in self.events]),
+ }
+
+
+def initial_state(request: TaskRunRequest) -> TaskEngineState:
+ """Create a validated state with the optional edit stage resolved."""
+ normalized = validate_task_run_request(request)
+ stages = {stage: StageStatus.PENDING for stage in WorkflowStage}
+ stages[WorkflowStage.INPUT] = StageStatus.SUCCEEDED
+ events = (
+ {
+ "sequence": 1,
+ "stage": WorkflowStage.INPUT.value,
+ "from": StageStatus.PENDING.value,
+ "to": StageStatus.SUCCEEDED.value,
+ },
+ )
+ state = TaskEngineState(request=normalized, stages=stages, events=events)
+ if normalized["scene_edit_prompt"] is None:
+ state = skip_stage(state, WorkflowStage.SCENE_EDIT)
+ return state
+
+
+def start_stage(state: TaskEngineState, stage: WorkflowStage) -> TaskEngineState:
+ """Start a pending stage only after every dependency has completed."""
+ if state.terminal:
+ raise ValueError("A terminal TaskEngineState cannot start another stage.")
+ if state.stages[stage] != StageStatus.PENDING:
+ raise ValueError(f"Stage {stage.value!r} is not pending.")
+ incomplete = [
+ dependency.value
+ for dependency in _DEPENDENCIES[stage]
+ if state.stages[dependency] not in {StageStatus.SUCCEEDED, StageStatus.SKIPPED}
+ ]
+ if incomplete:
+ raise ValueError(
+ f"Stage {stage.value!r} has incomplete dependencies: {incomplete}."
+ )
+ return _transition(state, stage, StageStatus.RUNNING)
+
+
+def complete_stage(state: TaskEngineState, stage: WorkflowStage) -> TaskEngineState:
+ """Complete one running stage."""
+ if state.stages[stage] != StageStatus.RUNNING:
+ raise ValueError(f"Stage {stage.value!r} is not running.")
+ return _transition(state, stage, StageStatus.SUCCEEDED)
+
+
+def fail_stage(
+ state: TaskEngineState,
+ stage: WorkflowStage,
+ *,
+ reason: str,
+) -> TaskEngineState:
+ """Fail a stage, including a later retry of a previously successful stage."""
+ if state.terminal:
+ raise ValueError("A terminal TaskEngineState cannot fail another stage.")
+ if state.stages[stage] not in {
+ StageStatus.PENDING,
+ StageStatus.RUNNING,
+ StageStatus.SUCCEEDED,
+ }:
+ raise ValueError(f"Stage {stage.value!r} cannot be failed now.")
+ normalized_reason = str(reason).strip()
+ if not normalized_reason:
+ raise ValueError("A failed stage requires a non-empty reason.")
+ return _transition(
+ state,
+ stage,
+ StageStatus.FAILED,
+ details={"reason": normalized_reason},
+ )
+
+
+def skip_stage(state: TaskEngineState, stage: WorkflowStage) -> TaskEngineState:
+ """Skip one optional pending stage."""
+ if stage not in _SKIPPABLE_STAGES:
+ raise ValueError("Only the optional scene_edit stage can be skipped.")
+ if state.stages[stage] != StageStatus.PENDING:
+ raise ValueError(f"Stage {stage.value!r} is not pending.")
+ return _transition(state, stage, StageStatus.SKIPPED)
+
+
+def replay_events(
+ request: TaskRunRequest,
+ events: Sequence[Mapping[str, Any]],
+) -> TaskEngineState:
+ """Rebuild a state by validating and applying its transition audit.
+
+ Args:
+ request: Original workflow request used to create the state.
+ events: Complete ordered event audit to validate and replay.
+
+ Returns:
+ The immutable state reconstructed from the supplied audit.
+
+ Raises:
+ TypeError: If the audit is not a sequence of event mappings.
+ ValueError: If any event is missing, altered, or not a valid transition.
+ """
+ if not isinstance(events, Sequence) or isinstance(events, (str, bytes)):
+ raise TypeError("Task Engine events must be a sequence of mappings.")
+ recorded = []
+ for event in events:
+ if not isinstance(event, Mapping):
+ raise TypeError("Each Task Engine event must be a mapping.")
+ recorded.append(deepcopy(dict(event)))
+
+ state = initial_state(request)
+ initial_events = [dict(event) for event in state.events]
+ if recorded[: len(initial_events)] != initial_events:
+ raise ValueError("Replay event does not match the canonical initial state.")
+
+ for expected in recorded[len(initial_events) :]:
+ try:
+ stage = WorkflowStage(expected["stage"])
+ target = StageStatus(expected["to"])
+ if target == StageStatus.RUNNING:
+ replayed = start_stage(state, stage)
+ elif target == StageStatus.SUCCEEDED:
+ replayed = complete_stage(state, stage)
+ elif target == StageStatus.FAILED:
+ replayed = fail_stage(state, stage, reason=expected["reason"])
+ elif target == StageStatus.SKIPPED:
+ replayed = skip_stage(state, stage)
+ else:
+ raise ValueError(f"Unsupported replay target: {target.value!r}.")
+ except (KeyError, TypeError, ValueError) as exc:
+ raise ValueError("Replay event does not match a valid transition.") from exc
+ if dict(replayed.events[-1]) != expected:
+ raise ValueError("Replay event does not match the generated transition.")
+ state = replayed
+ return state
+
+
+def _transition(
+ state: TaskEngineState,
+ stage: WorkflowStage,
+ status: StageStatus,
+ *,
+ details: dict[str, Any] | None = None,
+) -> TaskEngineState:
+ previous = state.stages[stage]
+ stages = dict(state.stages)
+ stages[stage] = status
+ event = {
+ "sequence": len(state.events) + 1,
+ "stage": stage.value,
+ "from": previous.value,
+ "to": status.value,
+ }
+ if details:
+ event.update(deepcopy(details))
+ return TaskEngineState(
+ request=dict(state.request),
+ stages=stages,
+ events=(*state.events, event),
+ )
diff --git a/embodichain/gen_sim/task_engine/workflow.py b/embodichain/gen_sim/task_engine/workflow.py
new file mode 100644
index 000000000..fca9778c4
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/workflow.py
@@ -0,0 +1,1275 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Parallel Task Engine workflow with bounded, fully audited recovery."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+from concurrent.futures import ThreadPoolExecutor
+from copy import deepcopy
+from dataclasses import dataclass, replace
+from datetime import datetime
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+from typing import Any, Final
+
+from embodichain.gen_sim.action_engine.agent import ActionAgent
+from embodichain.gen_sim.action_engine.unbound import ActionCapabilityError
+from embodichain.gen_sim.action_engine.runtime import (
+ EXECUTION_REPORT_FILENAME,
+ validate_execution_report,
+)
+from embodichain.gen_sim.scene_engine.errors import SceneServiceError
+
+from .agent import TaskAgent
+from .config import (
+ TaskEngineExecutionCfg,
+ TaskEnginePlanningCfg,
+ TaskEngineWorkflowCfg,
+ load_task_engine_config,
+)
+from .contracts import canonical_hash
+from .orchestration.artifacts import ArtifactTransaction
+from .orchestration.coordinator import PreparationResult, TaskEngineCoordinator
+from .orchestration.scene_adapter import CandidateSelection, SceneAdapter
+from .orchestration.scene_source import SceneSourceRef
+from .scene_backend import (
+ SceneAnalysis,
+ SceneEngineBackend,
+ SceneRemediableError,
+ SceneRevision,
+)
+from .state_machine import (
+ TaskEngineState,
+ WorkflowStage,
+ complete_stage,
+ fail_stage,
+ initial_state,
+ start_stage,
+)
+from .workflow_contracts import TaskRunRequest, validate_task_run_request
+
+__all__ = [
+ "TASK_ENGINE_RUN_MANIFEST_SCHEMA",
+ "ActionExecutor",
+ "SubprocessActionExecutor",
+ "TaskEngineRunResult",
+ "TaskEngineWorkflow",
+]
+
+TASK_ENGINE_RUN_MANIFEST_SCHEMA: Final = "embodichain.task-engine-run/v1"
+ActionExecutor = Callable[..., Mapping[str, Any]]
+
+
+@dataclass(frozen=True)
+class TaskEngineRunResult:
+ """Published outcome of one isolated cross-engine workflow run."""
+
+ status: str
+ output_dir: Path
+ manifest_path: Path
+ state_path: Path
+ final_bundle: Path | None
+ failure_class: str | None = None
+
+ @property
+ def succeeded(self) -> bool:
+ """Return whether real simulator execution met the configured policy."""
+ return self.status == "succeeded"
+
+
+class SubprocessActionExecutor:
+ """Execute a prepared bundle through Task Engine's private runner."""
+
+ def __call__(
+ self,
+ bundle: str | Path,
+ output_root: str | Path,
+ *,
+ seed: int,
+ num_envs: int,
+ dataset_saving: bool = False,
+ ) -> Mapping[str, Any]:
+ """Run one simulator attempt and preserve its report and trajectory.
+
+ Args:
+ bundle: Prepared Action Engine bundle.
+ output_root: Fresh directory for this execution attempt.
+ seed: Action Engine random seed.
+ num_envs: Number of vectorized scene replicas.
+ dataset_saving: Whether to enable the Gym project's dataset recorder.
+
+ Returns:
+ Validated Action Engine execution report.
+ """
+ bundle_root = Path(bundle).expanduser().resolve()
+ attempt_root = Path(output_root).expanduser().resolve()
+ attempt_root.mkdir(parents=True, exist_ok=False)
+ command = [
+ sys.executable,
+ "-u",
+ "-m",
+ "embodichain.gen_sim.task_engine._bundle_runner",
+ "--bundle",
+ bundle_root.as_posix(),
+ "--num_envs",
+ str(num_envs),
+ "--seed",
+ str(seed),
+ "--headless",
+ ]
+ if not dataset_saving:
+ command.append("--filter_dataset_saving")
+ log_path = attempt_root / "action.log"
+ print(
+ "[Task Engine] Starting "
+ f"{attempt_root.name}: seed={seed}, num_envs={num_envs}, "
+ f"dataset_saving={dataset_saving}",
+ flush=True,
+ )
+ completed = _run_streaming_process(command, log_path)
+ print(
+ f"[Task Engine] Completed {attempt_root.name}: "
+ f"returncode={completed.returncode}",
+ flush=True,
+ )
+ report_path = bundle_root / EXECUTION_REPORT_FILENAME
+ process_record = {
+ "command": command,
+ "returncode": completed.returncode,
+ "combined_log": log_path.name,
+ "stdout": completed.stdout,
+ "stderr": completed.stderr,
+ }
+ _write_json(attempt_root / "process.json", process_record)
+ if not report_path.is_file():
+ raise RuntimeError(
+ "Action execution did not publish execution_report.json; "
+ f"returncode={completed.returncode}."
+ )
+ report = validate_execution_report(_read_json(report_path))
+ shutil.copy2(report_path, attempt_root / EXECUTION_REPORT_FILENAME)
+ trajectory_copy = _copy_trajectory_record(report, attempt_root)
+ if report["action_count"] > 0 and trajectory_copy is None:
+ raise RuntimeError(
+ "Action execution report did not expose a readable trajectory record."
+ )
+ _write_json(
+ attempt_root / "execution_attempt.json",
+ {
+ "seed": seed,
+ "num_envs": num_envs,
+ "dataset_saving": dataset_saving,
+ "returncode": completed.returncode,
+ "trajectory_copy": trajectory_copy,
+ "report": report,
+ },
+ )
+ return report
+
+
+def _run_streaming_process(
+ command: list[str],
+ log_path: str | Path,
+) -> subprocess.CompletedProcess[str]:
+ """Run a child while teeing its combined output to the terminal and disk.
+
+ Args:
+ command: Argument vector passed directly to the child process.
+ log_path: File receiving the exact combined stdout and stderr bytes.
+
+ Returns:
+ Completed process metadata with a decoded copy of the combined output.
+ """
+ resolved_log = Path(log_path).expanduser().resolve()
+ resolved_log.parent.mkdir(parents=True, exist_ok=True)
+ captured = bytearray()
+ process: subprocess.Popen[bytes] | None = None
+ try:
+ with resolved_log.open("wb") as log_stream:
+ process = subprocess.Popen(
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ bufsize=0,
+ )
+ assert process.stdout is not None
+ while True:
+ chunk = process.stdout.read(64 * 1024)
+ if not chunk:
+ break
+ captured.extend(chunk)
+ log_stream.write(chunk)
+ log_stream.flush()
+ _write_terminal_chunk(chunk)
+ returncode = process.wait()
+ except BaseException:
+ if process is not None and process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ raise
+ output = captured.decode("utf-8", errors="replace")
+ return subprocess.CompletedProcess(
+ args=command,
+ returncode=returncode,
+ stdout=output,
+ stderr="",
+ )
+
+
+def _write_terminal_chunk(chunk: bytes) -> None:
+ """Best-effort write of raw child output to the parent terminal."""
+ try:
+ stream = getattr(sys.stdout, "buffer", None)
+ if stream is not None:
+ stream.write(chunk)
+ stream.flush()
+ return
+ sys.stdout.write(chunk.decode("utf-8", errors="replace"))
+ sys.stdout.flush()
+ except (BrokenPipeError, OSError, ValueError):
+ return
+
+
+class TaskEngineWorkflow:
+ """Run Scene and Action work concurrently under Task Engine ownership."""
+
+ def __init__(
+ self,
+ *,
+ task_agent: TaskAgent | None = None,
+ scene_adapter: SceneAdapter | None = None,
+ action_agent: ActionAgent | None = None,
+ coordinator: TaskEngineCoordinator | None = None,
+ scene_backend: SceneEngineBackend | None = None,
+ action_executor: ActionExecutor | None = None,
+ ) -> None:
+ self.task_agent = task_agent or TaskAgent()
+ self.scene_adapter = scene_adapter or SceneAdapter()
+ self.action_agent = action_agent or ActionAgent()
+ self.coordinator = coordinator or TaskEngineCoordinator(
+ task_agent=self.task_agent,
+ scene_adapter=self.scene_adapter,
+ action_agent=self.action_agent,
+ )
+ self.scene_backend = scene_backend or SceneEngineBackend()
+ self.action_executor = action_executor or SubprocessActionExecutor()
+
+ def run(
+ self,
+ request: TaskRunRequest | Mapping[str, Any],
+ *,
+ workflow_cfg: TaskEngineWorkflowCfg | None = None,
+ planning_cfg: TaskEnginePlanningCfg | None = None,
+ execution_cfg: TaskEngineExecutionCfg | None = None,
+ config_path: str | Path | None = None,
+ model: str | None = None,
+ vlm_model: str | None = None,
+ base_seed: int = 0,
+ dataset_saving: bool = False,
+ run_id: str | None = None,
+ created_at: datetime | None = None,
+ overwrite: bool = False,
+ execute: bool = True,
+ ) -> TaskEngineRunResult:
+ """Run all stages and publish success only after simulator acceptance.
+
+ Args:
+ request: One of the four image/project plus optional-edit inputs.
+ workflow_cfg: Optional retry and concurrency configuration.
+ planning_cfg: Optional interpretation and bundle generation defaults.
+ execution_cfg: Optional vectorized success policy.
+ config_path: YAML used for omitted workflow or execution config.
+ model: Optional Task and grounding model override.
+ vlm_model: Optional Action Engine VLM override.
+ base_seed: First audited scene and action attempt seed.
+ dataset_saving: Whether Action attempts may initialize dataset recording.
+ run_id: Optional externally allocated run identifier.
+ created_at: Optional timezone-aware run creation timestamp.
+ overwrite: Whether to atomically replace an existing run directory.
+ execute: Whether to execute the prepared bundle in the simulator.
+
+ Returns:
+ Published run status, manifest, state audit, and final bundle path.
+ """
+ normalized = validate_task_run_request(request)
+ if not isinstance(dataset_saving, bool):
+ raise TypeError("dataset_saving must be a boolean.")
+ if workflow_cfg is None or planning_cfg is None or execution_cfg is None:
+ loaded_workflow, loaded_planning, loaded_execution = (
+ load_task_engine_config(config_path)
+ )
+ workflow_cfg = workflow_cfg or loaded_workflow
+ planning_cfg = planning_cfg or loaded_planning
+ execution_cfg = execution_cfg or loaded_execution
+ effective_candidate_count = planning_cfg.candidate_count
+ effective_run_id = str(run_id or Path(normalized["output_dir"]).name).strip()
+ if not effective_run_id or Path(effective_run_id).name != effective_run_id:
+ raise ValueError("run_id must be one non-empty path component.")
+ effective_created_at = created_at or datetime.now().astimezone()
+ if (
+ effective_created_at.tzinfo is None
+ or effective_created_at.utcoffset() is None
+ ):
+ raise ValueError("created_at must include a timezone.")
+ run_metadata = {
+ "run_id": effective_run_id,
+ "created_at": effective_created_at.isoformat(),
+ "dataset_saving": bool(dataset_saving),
+ }
+ state = initial_state(normalized)
+ attempts: list[dict[str, Any]] = []
+ output_dir = Path(normalized["output_dir"])
+
+ with ArtifactTransaction(output_dir, overwrite=overwrite) as transaction:
+ staging = transaction.staging_dir
+ assert staging is not None
+ analysis_root = staging / "scene_analysis"
+ state = start_stage(state, WorkflowStage.TASK_CANDIDATES)
+ state = start_stage(state, WorkflowStage.SCENE_PREPARATION)
+ with ThreadPoolExecutor(
+ max_workers=workflow_cfg.max_parallel_workers,
+ thread_name_prefix="task-engine-input",
+ ) as executor:
+ candidate_future = executor.submit(
+ self.task_agent.generate,
+ normalized["task_id"],
+ normalized["task_instruction"],
+ model,
+ effective_candidate_count,
+ )
+ analysis_future = executor.submit(
+ self.scene_backend.analyze,
+ normalized,
+ analysis_root,
+ )
+ try:
+ candidate_set = candidate_future.result()
+ except Exception as exc:
+ analysis_future.cancel()
+ state = fail_stage(
+ state,
+ WorkflowStage.TASK_CANDIDATES,
+ reason=str(exc),
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status="failed",
+ failure_class="task_generation",
+ )
+ state = complete_stage(state, WorkflowStage.TASK_CANDIDATES)
+ try:
+ analysis = analysis_future.result()
+ except Exception as exc:
+ state = fail_stage(
+ state,
+ WorkflowStage.SCENE_PREPARATION,
+ reason=str(exc),
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status="failed",
+ failure_class="scene_analysis",
+ )
+ state = complete_stage(state, WorkflowStage.SCENE_PREPARATION)
+
+ state = start_stage(state, WorkflowStage.CANDIDATE_SELECTION)
+ try:
+ selection = self.scene_backend.select(
+ analysis,
+ candidate_set,
+ self.scene_adapter,
+ force_most_likely=True,
+ )
+ except Exception as exc:
+ state = fail_stage(
+ state,
+ WorkflowStage.CANDIDATE_SELECTION,
+ reason=str(exc),
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status="input_conflict",
+ failure_class="candidate_selection",
+ )
+ _write_json(
+ staging / "initial_binding_report.json", selection.binding_report
+ )
+ if selection.selected_candidate is None:
+ if normalized["scene_edit_prompt"] is None:
+ state = fail_stage(
+ state,
+ WorkflowStage.CANDIDATE_SELECTION,
+ reason=str(selection.binding_report["selection_reason"]),
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status="input_conflict",
+ failure_class="unbound_scene_reference",
+ )
+ provisional = _highest_vote_candidate(candidate_set)
+ selection = replace(
+ selection,
+ selected_candidate=deepcopy(provisional),
+ )
+ _write_json(
+ staging / "provisional_candidate.json",
+ {
+ "candidate_id": provisional["candidate_id"],
+ "reason": "explicit_scene_edit_may_materialize_missing_reference",
+ "binding_status": selection.binding_report["status"],
+ },
+ )
+ state = complete_stage(state, WorkflowStage.CANDIDATE_SELECTION)
+
+ state = start_stage(state, WorkflowStage.UNBOUND_ACTION)
+ if normalized["scene_edit_prompt"] is not None:
+ state = start_stage(state, WorkflowStage.SCENE_EDIT)
+ else:
+ state = start_stage(state, WorkflowStage.SCENE_FINALIZATION)
+
+ unbound_plan: Mapping[str, Any] | None = None
+ unbound_failures: list[dict[str, Any]] = []
+ unbound_error: Exception | None = None
+ scene_error: Exception | None = None
+ inspection_error = False
+ preparation_error: Exception | None = None
+ preparation: PreparationResult | None = None
+ scene_attempt_limit = (
+ 1
+ if analysis.input_kind == "gym_project"
+ and normalized["scene_edit_prompt"] is None
+ else workflow_cfg.max_scene_attempts
+ )
+ for scene_index in range(1, scene_attempt_limit + 1):
+ inspection_error = False
+ scene_seed = int(base_seed) + scene_index - 1
+ attempt_root = staging / "attempts" / f"scene_{scene_index:04d}"
+ attempt_root.mkdir(parents=True)
+ attempt = {
+ "scene_attempt": scene_index,
+ "scene_seed": scene_seed,
+ "status": "running",
+ "scene_revision": None,
+ "final_inspection": None,
+ "unbound_action_plan": None,
+ "final_unbound_action_plan": None,
+ "unbound_transition": None,
+ "unbound_failures": [],
+ "preparation": None,
+ "planning_attempts": [],
+ "action_attempts": [],
+ "parallel_errors": [],
+ "error": None,
+ }
+ attempts.append(attempt)
+ revision: SceneRevision | None = None
+ try:
+ if unbound_plan is None:
+ with ThreadPoolExecutor(
+ max_workers=workflow_cfg.max_parallel_workers,
+ thread_name_prefix="task-engine-parallel",
+ ) as executor:
+ scene_future = executor.submit(
+ self.scene_backend.materialize,
+ analysis,
+ normalized,
+ attempt_root / "scene_revision",
+ seed=scene_seed,
+ )
+ draft_future = executor.submit(
+ self._draft_with_fallback,
+ candidate_set,
+ selection,
+ )
+ try:
+ revision = scene_future.result()
+ except Exception as exc:
+ scene_error = exc
+ revision = None
+ try:
+ unbound_plan, unbound_failures = draft_future.result()
+ except Exception as exc:
+ unbound_error = exc
+ if unbound_error is not None:
+ raise unbound_error
+ state = complete_stage(state, WorkflowStage.UNBOUND_ACTION)
+ if scene_error is not None:
+ raise scene_error
+ assert revision is not None
+ else:
+ revision = self.scene_backend.materialize(
+ analysis,
+ normalized,
+ attempt_root / "scene_revision",
+ seed=scene_seed,
+ )
+ scene_error = None
+ except Exception as exc:
+ if revision is not None:
+ attempt["scene_revision"] = _revision_record(revision)
+ state = _complete_materialized_scene(
+ state,
+ has_edit=normalized["scene_edit_prompt"] is not None,
+ )
+ if unbound_error is not None:
+ attempt["status"] = "unbound_action_failed"
+ attempt["error"] = _error_record(unbound_error)
+ if scene_error is not None:
+ attempt["parallel_errors"].append(
+ {
+ "branch": "scene",
+ **_error_record(scene_error),
+ }
+ )
+ _write_json(attempt_root / "attempt.json", attempt)
+ break
+ scene_error = exc
+ if unbound_plan is not None:
+ attempt["unbound_action_plan"] = deepcopy(dict(unbound_plan))
+ attempt["unbound_failures"] = deepcopy(unbound_failures)
+ _write_json(
+ attempt_root / "unbound_action_plan.json", unbound_plan
+ )
+ attempt["status"] = "scene_failed"
+ attempt["error"] = _error_record(exc)
+ _write_json(attempt_root / "attempt.json", attempt)
+ if (
+ scene_index < scene_attempt_limit
+ and _is_scene_remediable_error(exc)
+ ):
+ continue
+ break
+
+ attempt["scene_revision"] = _revision_record(revision)
+ attempt["unbound_action_plan"] = deepcopy(dict(unbound_plan))
+ attempt["unbound_failures"] = deepcopy(unbound_failures)
+ _write_json(attempt_root / "unbound_action_plan.json", unbound_plan)
+ state = _complete_materialized_scene(
+ state,
+ has_edit=normalized["scene_edit_prompt"] is not None,
+ )
+ if state.stages[WorkflowStage.FINAL_INSPECTION].value == "pending":
+ state = start_stage(state, WorkflowStage.FINAL_INSPECTION)
+ try:
+ final_inspection = self.scene_backend.inspect(
+ revision,
+ attempt_root / "final_scene_inspection.json",
+ )
+ except Exception as exc:
+ scene_error = exc
+ inspection_error = True
+ attempt["status"] = "scene_inspection_failed"
+ attempt["error"] = _error_record(exc)
+ _write_json(attempt_root / "attempt.json", attempt)
+ if (
+ scene_index < scene_attempt_limit
+ and _is_scene_remediable_error(exc)
+ ):
+ continue
+ break
+ attempt["final_inspection"] = deepcopy(dict(final_inspection))
+ if state.stages[WorkflowStage.FINAL_INSPECTION].value == "running":
+ state = complete_stage(state, WorkflowStage.FINAL_INSPECTION)
+
+ bundle_root = attempt_root / "bundle"
+ try:
+ preparation = self.coordinator.prepare(
+ normalized["task_id"],
+ normalized["task_instruction"],
+ SceneSourceRef(
+ revision.source,
+ robot_profile=self.scene_adapter.robot_profile,
+ ),
+ bundle_root,
+ model=model,
+ candidate_count=effective_candidate_count,
+ planning_mode=planning_cfg.planning_mode,
+ vlm_model=vlm_model,
+ max_episodes=planning_cfg.max_episodes,
+ max_episode_steps=planning_cfg.max_episode_steps,
+ candidate_set=candidate_set,
+ force_most_likely=True,
+ final_inspection=final_inspection,
+ unbound_action_plan=unbound_plan,
+ )
+ except Exception as exc:
+ preparation_error = exc
+ attempt["status"] = "preparation_error"
+ attempt["error"] = _error_record(exc)
+ _write_json(attempt_root / "attempt.json", attempt)
+ break
+ attempt["preparation"] = preparation.status
+ attempt["planning_attempts"] = deepcopy(
+ list(preparation.planning_attempts)
+ )
+ if preparation.status == "bound":
+ attempt["status"] = "prepared"
+ _write_json(attempt_root / "attempt.json", attempt)
+ break
+ attempt["status"] = "preparation_failed"
+ attempt["error"] = {
+ "type": "PreparationFailure",
+ "message": preparation.status,
+ }
+ _write_json(attempt_root / "attempt.json", attempt)
+ if not _scene_remediable(
+ preparation,
+ analysis=analysis,
+ request=normalized,
+ ):
+ break
+
+ if preparation is None or preparation.status != "bound":
+ failure_class = (
+ "action_capability"
+ if unbound_error is not None
+ or isinstance(preparation_error, ActionCapabilityError)
+ else (
+ "preparation_error"
+ if preparation_error is not None
+ else _preparation_failure_class(
+ preparation,
+ scene_error=scene_error,
+ analysis=analysis,
+ request=normalized,
+ )
+ )
+ )
+ failed_stage = (
+ WorkflowStage.FINAL_INSPECTION
+ if inspection_error
+ else (
+ WorkflowStage.UNBOUND_ACTION
+ if unbound_error is not None
+ else _failure_stage(failure_class, normalized)
+ )
+ )
+ if state.stages[failed_stage].value in {
+ "pending",
+ "running",
+ "succeeded",
+ }:
+ state = fail_stage(
+ state,
+ failed_stage,
+ reason=(
+ str(unbound_error)
+ if unbound_error is not None
+ else (
+ str(preparation_error)
+ if preparation_error is not None
+ else (
+ str(scene_error)
+ if scene_error is not None
+ else (
+ preparation.status
+ if preparation is not None
+ else failure_class
+ )
+ )
+ )
+ ),
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status=(
+ "input_conflict"
+ if failure_class == "input_conflict"
+ else "failed"
+ ),
+ failure_class=failure_class,
+ )
+
+ final_candidate_id = preparation.selected_candidate_id
+ if not isinstance(final_candidate_id, str) or not final_candidate_id:
+ raise ValueError(
+ "A bound preparation must select one non-empty candidate ID."
+ )
+ selected_attempt = attempts[-1]
+ final_unbound = getattr(preparation, "unbound_action_plan", None)
+ if (
+ final_unbound is None
+ and final_candidate_id != unbound_plan["candidate_id"]
+ ):
+ final_candidate = next(
+ item
+ for item in candidate_set["candidates"]
+ if item["candidate_id"] == final_candidate_id
+ )
+ final_unbound = self.action_agent.draft(final_candidate)
+ elif final_unbound is None:
+ final_unbound = unbound_plan
+ if str(final_unbound.get("candidate_id")) != final_candidate_id:
+ raise ValueError(
+ "Final UnboundActionPlan candidate does not match preparation."
+ )
+ selected_attempt["final_unbound_action_plan"] = deepcopy(
+ dict(final_unbound)
+ )
+ selected_attempt["unbound_transition"] = {
+ "initial_candidate_id": str(unbound_plan["candidate_id"]),
+ "initial_hash": canonical_hash(unbound_plan),
+ "final_candidate_id": final_candidate_id,
+ "final_hash": canonical_hash(final_unbound),
+ "changed": final_unbound != unbound_plan,
+ }
+ _write_json(
+ preparation.output_dir.parent / "final_unbound_action_plan.json",
+ final_unbound,
+ )
+ _write_json(
+ preparation.output_dir.parent / "attempt.json",
+ selected_attempt,
+ )
+
+ for stage in (
+ WorkflowStage.FINAL_BINDING,
+ WorkflowStage.STATIC_FEASIBILITY,
+ WorkflowStage.GROUNDED_ACTION,
+ ):
+ state = start_stage(state, stage)
+ state = complete_stage(state, stage)
+ if not execute:
+ final_root = staging / "final"
+ final_bundle = final_root / "bundle"
+ final_root.mkdir()
+ shutil.copytree(preparation.output_dir, final_bundle)
+ selected_attempt["status"] = "prepared"
+ _write_json(
+ preparation.output_dir.parent / "attempt.json",
+ selected_attempt,
+ )
+ _write_json(
+ final_root / "selection.json",
+ {
+ "scene_attempt": selected_attempt["scene_attempt"],
+ "candidate_id": final_candidate_id,
+ "action_attempt": None,
+ "execution_report": None,
+ },
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status="prepared",
+ failure_class=None,
+ final_bundle=final_bundle,
+ )
+ state = start_stage(state, WorkflowStage.EXECUTION)
+
+ successful_report: Mapping[str, Any] | None = None
+ successful_action_root: Path | None = None
+ success_terms = _bundle_success_terms(preparation.output_dir)
+ for action_index in range(1, workflow_cfg.max_action_attempts + 1):
+ action_seed = int(base_seed) + action_index - 1
+ action_root = (
+ preparation.output_dir.parent
+ / "action_attempts"
+ / f"action_{action_index:04d}"
+ )
+ action_record: dict[str, Any] = {
+ "action_attempt": action_index,
+ "seed": action_seed,
+ "status": "running",
+ "successful_environments": 0,
+ "required_successes": execution_cfg.required_successes,
+ "error": None,
+ }
+ try:
+ report = self.action_executor(
+ preparation.output_dir,
+ action_root,
+ seed=action_seed,
+ num_envs=execution_cfg.num_envs,
+ dataset_saving=bool(dataset_saving),
+ )
+ successes = _environment_successes(
+ report,
+ required_semantic_steps=success_terms,
+ )
+ if len(successes) != execution_cfg.num_envs:
+ raise ValueError(
+ "Execution report environment count does not match "
+ "TaskEngineExecutionCfg.num_envs."
+ )
+ action_record["successful_environments"] = sum(successes)
+ accepted = (
+ str(report.get("status")) not in {"rejected", "aborted"}
+ and sum(successes) >= execution_cfg.required_successes
+ )
+ action_record["status"] = "succeeded" if accepted else "failed"
+ _write_json(action_root / "task_engine_attempt.json", action_record)
+ selected_attempt["action_attempts"].append(deepcopy(action_record))
+ if accepted:
+ successful_report = deepcopy(dict(report))
+ successful_action_root = action_root
+ break
+ except Exception as exc:
+ action_record["status"] = "failed"
+ action_record["error"] = _error_record(exc)
+ action_root.mkdir(parents=True, exist_ok=True)
+ _write_json(action_root / "task_engine_attempt.json", action_record)
+ selected_attempt["action_attempts"].append(deepcopy(action_record))
+ _write_json(
+ preparation.output_dir.parent / "attempt.json", selected_attempt
+ )
+
+ if successful_report is None:
+ selected_attempt["status"] = "execution_failed"
+ _write_json(
+ preparation.output_dir.parent / "attempt.json", selected_attempt
+ )
+ state = fail_stage(
+ state,
+ WorkflowStage.EXECUTION,
+ reason="All bounded Action Engine execution attempts failed.",
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status="failed",
+ failure_class="action_execution",
+ )
+
+ selected_attempt["status"] = "succeeded"
+ _write_json(
+ preparation.output_dir.parent / "attempt.json", selected_attempt
+ )
+ state = complete_stage(state, WorkflowStage.EXECUTION)
+ final_root = staging / "final"
+ final_bundle = final_root / "bundle"
+ final_root.mkdir()
+ shutil.copytree(preparation.output_dir, final_bundle)
+ _write_json(
+ final_root / "selection.json",
+ {
+ "scene_attempt": selected_attempt["scene_attempt"],
+ "candidate_id": final_candidate_id,
+ "action_attempt": int(successful_action_root.name.split("_")[-1]),
+ "execution_report": successful_report,
+ "success_spec_steps": list(success_terms),
+ },
+ )
+ return self._publish(
+ transaction,
+ staging,
+ normalized,
+ workflow_cfg,
+ planning_cfg,
+ execution_cfg,
+ run_metadata,
+ state,
+ attempts,
+ status="succeeded",
+ failure_class=None,
+ final_bundle=final_bundle,
+ )
+
+ def _draft_with_fallback(
+ self,
+ candidate_set: Mapping[str, Any],
+ selection: CandidateSelection,
+ ) -> tuple[Mapping[str, Any], list[dict[str, Any]]]:
+ selected_id = selection.selected_candidate_id
+ resolved_ids = {
+ str(item["candidate_id"])
+ for item in selection.binding_report["candidates"]
+ if item["status"] == "resolved"
+ }
+ ordered = [selected_id] + [
+ str(item["candidate_id"])
+ for item in candidate_set["candidates"]
+ if item["candidate_id"] != selected_id
+ and item["candidate_id"] in resolved_ids
+ ]
+ failures = []
+ for candidate_id in ordered:
+ candidate = next(
+ item
+ for item in candidate_set["candidates"]
+ if item["candidate_id"] == candidate_id
+ )
+ try:
+ return self.action_agent.draft(candidate), failures
+ except ActionCapabilityError:
+ raise
+ except (TypeError, ValueError) as exc:
+ failures.append(
+ {
+ "candidate_id": candidate_id,
+ "stage": "unbound_action",
+ "draft": deepcopy(candidate["draft"]),
+ "error": _error_record(exc),
+ }
+ )
+ raise ValueError(
+ "No selected task candidate can be represented by Action Engine."
+ )
+
+ @staticmethod
+ def _publish(
+ transaction: ArtifactTransaction,
+ staging: Path,
+ request: Mapping[str, Any],
+ workflow_cfg: TaskEngineWorkflowCfg,
+ planning_cfg: TaskEnginePlanningCfg,
+ execution_cfg: TaskEngineExecutionCfg,
+ run_metadata: Mapping[str, Any],
+ state: TaskEngineState,
+ attempts: Sequence[Mapping[str, Any]],
+ *,
+ status: str,
+ failure_class: str | None,
+ final_bundle: Path | None = None,
+ ) -> TaskEngineRunResult:
+ state_path = staging / "workflow_state.json"
+ manifest_path = staging / "run_manifest.json"
+ _write_json(state_path, state.to_dict())
+ _write_json(
+ manifest_path,
+ {
+ "schema_version": TASK_ENGINE_RUN_MANIFEST_SCHEMA,
+ "run_id": run_metadata["run_id"],
+ "created_at": run_metadata["created_at"],
+ "output_root": Path(request["output_dir"]).parent.as_posix(),
+ "run_dir": Path(request["output_dir"]).as_posix(),
+ "status": status,
+ "failure_class": failure_class,
+ "request": deepcopy(dict(request)),
+ "configuration": {
+ "workflow": {
+ "max_parallel_workers": workflow_cfg.max_parallel_workers,
+ "max_scene_attempts": workflow_cfg.max_scene_attempts,
+ "max_action_attempts": workflow_cfg.max_action_attempts,
+ },
+ "planning": {
+ "candidate_count": planning_cfg.candidate_count,
+ "planning_mode": planning_cfg.planning_mode,
+ "max_episodes": planning_cfg.max_episodes,
+ "max_episode_steps": planning_cfg.max_episode_steps,
+ },
+ "execution": {
+ "num_envs": execution_cfg.num_envs,
+ "success_policy": execution_cfg.success_policy,
+ "min_successful_envs": execution_cfg.min_successful_envs,
+ "dataset_saving": bool(run_metadata["dataset_saving"]),
+ },
+ },
+ "attempts": deepcopy(list(attempts)),
+ "final_bundle": (
+ None if final_bundle is None else final_bundle.as_posix()
+ ),
+ },
+ )
+ published = transaction.commit()
+ return TaskEngineRunResult(
+ status=status,
+ output_dir=published,
+ manifest_path=published / manifest_path.name,
+ state_path=published / state_path.name,
+ final_bundle=(
+ None if final_bundle is None else published / "final" / "bundle"
+ ),
+ failure_class=failure_class,
+ )
+
+
+def _complete_materialized_scene(
+ state: TaskEngineState,
+ *,
+ has_edit: bool,
+) -> TaskEngineState:
+ if has_edit and state.stages[WorkflowStage.SCENE_EDIT].value == "running":
+ state = complete_stage(state, WorkflowStage.SCENE_EDIT)
+ state = start_stage(state, WorkflowStage.SCENE_FINALIZATION)
+ if state.stages[WorkflowStage.SCENE_FINALIZATION].value == "running":
+ state = complete_stage(state, WorkflowStage.SCENE_FINALIZATION)
+ return state
+
+
+def _scene_remediable(
+ preparation: PreparationResult,
+ *,
+ analysis: SceneAnalysis,
+ request: Mapping[str, Any],
+) -> bool:
+ if preparation.status != "infeasible":
+ return False
+ report = preparation.feasibility_report
+ if not isinstance(report, Mapping) or report.get("remediation_class") != (
+ "scene_remediable"
+ ):
+ return False
+ if analysis.input_kind == "image":
+ return True
+ return request["scene_edit_prompt"] is not None
+
+
+def _is_scene_remediable_error(error: Exception) -> bool:
+ """Return whether one typed Scene failure may create a new attempt."""
+ return isinstance(error, (SceneRemediableError, SceneServiceError))
+
+
+def _preparation_failure_class(
+ preparation: PreparationResult | None,
+ *,
+ scene_error: Exception | None,
+ analysis: SceneAnalysis,
+ request: Mapping[str, Any],
+) -> str:
+ if scene_error is not None:
+ return "scene_materialization"
+ if preparation is None:
+ return "scene_materialization"
+ if preparation.status == "planning_failed":
+ return "action_capability"
+ if preparation.status in {"ambiguous", "unsatisfied"}:
+ return "input_conflict"
+ if preparation.status == "infeasible":
+ report = preparation.feasibility_report
+ remediation = (
+ str(report.get("remediation_class"))
+ if isinstance(report, Mapping)
+ else "terminal"
+ )
+ if remediation == "action_capability":
+ return "action_capability"
+ if remediation == "input_conflict":
+ return "input_conflict"
+ if remediation != "scene_remediable":
+ return "terminal_feasibility"
+ if (
+ analysis.input_kind == "gym_project"
+ and request["scene_edit_prompt"] is None
+ ):
+ return "read_only_scene_infeasible"
+ return "scene_infeasible"
+ return "preparation"
+
+
+def _failure_stage(
+ failure_class: str,
+ request: Mapping[str, Any],
+) -> WorkflowStage:
+ if failure_class == "action_capability":
+ return WorkflowStage.GROUNDED_ACTION
+ if failure_class == "preparation_error":
+ return WorkflowStage.FINAL_BINDING
+ if failure_class == "input_conflict":
+ return WorkflowStage.FINAL_BINDING
+ if failure_class in {
+ "scene_infeasible",
+ "read_only_scene_infeasible",
+ "terminal_feasibility",
+ }:
+ return WorkflowStage.STATIC_FEASIBILITY
+ if failure_class == "scene_materialization":
+ return (
+ WorkflowStage.SCENE_EDIT
+ if request["scene_edit_prompt"] is not None
+ else WorkflowStage.SCENE_FINALIZATION
+ )
+ return WorkflowStage.GROUNDED_ACTION
+
+
+def _environment_successes(
+ report: Mapping[str, Any],
+ *,
+ required_semantic_steps: Sequence[str] = (),
+) -> list[bool]:
+ environments = report.get("environments")
+ if not isinstance(environments, Sequence) or isinstance(environments, (str, bytes)):
+ raise ValueError("Execution report environments must be a sequence.")
+ values = []
+ for item in environments:
+ if not isinstance(item, Mapping) or not isinstance(item.get("success"), bool):
+ raise ValueError("Every execution environment requires boolean success.")
+ success = bool(item["success"])
+ if required_semantic_steps:
+ semantics = item.get("semantic_success")
+ if not isinstance(semantics, Mapping):
+ success = False
+ else:
+ success = success and all(
+ semantics.get(step_id) is True
+ for step_id in required_semantic_steps
+ )
+ values.append(success)
+ if not values:
+ raise ValueError("Execution report must contain at least one environment.")
+ return values
+
+
+def _bundle_success_terms(bundle: Path) -> tuple[str, ...]:
+ path = bundle / "grounded_task_plan.json"
+ if not path.is_file():
+ return ()
+ try:
+ value = _read_json(path)
+ success_spec = value.get("success_spec")
+ terms = success_spec.get("terms") if isinstance(success_spec, Mapping) else None
+ strict = isinstance(value.get("schema_version"), str)
+ if not isinstance(terms, Sequence) or isinstance(terms, (str, bytes)):
+ if strict:
+ raise ValueError("GroundedTaskPlan has no valid SuccessSpec terms.")
+ return ()
+ result = tuple(
+ str(item["step_id"])
+ for item in terms
+ if isinstance(item, Mapping) and isinstance(item.get("step_id"), str)
+ )
+ if len(result) != len(terms) or (strict and not result):
+ if strict:
+ raise ValueError("GroundedTaskPlan SuccessSpec terms are invalid.")
+ return ()
+ return result
+ except OSError:
+ return ()
+
+
+def _highest_vote_candidate(candidate_set: Mapping[str, Any]) -> Mapping[str, Any]:
+ candidates = candidate_set.get("candidates")
+ if not isinstance(candidates, Sequence) or isinstance(candidates, (str, bytes)):
+ raise TypeError("TaskCandidateSet.candidates must be a sequence.")
+ values = [item for item in candidates if isinstance(item, Mapping)]
+ if not values:
+ raise ValueError("TaskCandidateSet requires at least one candidate.")
+ return max(
+ values,
+ key=lambda item: (
+ int(item.get("vote_count", 0)),
+ str(item.get("candidate_id", "")),
+ ),
+ )
+
+
+def _copy_trajectory_record(report: Mapping[str, Any], output_root: Path) -> str | None:
+ raw = report.get("record_dir")
+ if not isinstance(raw, str) or not raw:
+ return None
+ source = Path(raw).expanduser().resolve()
+ if not source.is_dir():
+ return None
+ destination = output_root / "trajectory"
+ if source == destination or destination in source.parents:
+ return source.as_posix()
+ shutil.copytree(source, destination)
+ return destination.as_posix()
+
+
+def _revision_record(revision: SceneRevision) -> dict[str, Any]:
+ return {
+ "source": revision.source.as_posix(),
+ "output_root": (
+ None if revision.output_root is None else revision.output_root.as_posix()
+ ),
+ "revision_id": revision.revision_id,
+ "seed": revision.seed,
+ "edit_plan": deepcopy(revision.edit_plan),
+ "source_fingerprint": (
+ None
+ if revision.source_fingerprint is None
+ else revision.source_fingerprint.to_dict()
+ ),
+ }
+
+
+def _error_record(error: Exception) -> dict[str, str]:
+ return {
+ "type": type(error).__name__,
+ "failure_type": (
+ "scene_remediable" if _is_scene_remediable_error(error) else "terminal"
+ ),
+ "message": str(error),
+ }
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise ValueError(f"JSON artifact must contain an object: {path}")
+ return value
+
+
+def _write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
+ encoding="utf-8",
+ )
diff --git a/embodichain/gen_sim/task_engine/workflow_contracts.py b/embodichain/gen_sim/task_engine/workflow_contracts.py
new file mode 100644
index 000000000..a6b61b04b
--- /dev/null
+++ b/embodichain/gen_sim/task_engine/workflow_contracts.py
@@ -0,0 +1,179 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Strict inputs for Task Engine cross-engine workflows."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from copy import deepcopy
+import json
+from pathlib import Path
+from typing import Any, Final, Literal, TypeAlias
+
+__all__ = [
+ "TASK_RUN_REQUEST_SCHEMA",
+ "SceneInputKind",
+ "TaskRunRequest",
+ "scene_input_kind",
+ "validate_scene_history_root",
+ "validate_scene_output_separation",
+ "validate_task_run_request",
+]
+
+TASK_RUN_REQUEST_SCHEMA: Final = "embodichain.task-engine-run-request/v1"
+TaskRunRequest: TypeAlias = dict[str, Any]
+SceneInputKind = Literal["image", "gym_project"]
+
+_REQUEST_KEYS = frozenset(
+ {
+ "schema_version",
+ "task_id",
+ "task_instruction",
+ "image_path",
+ "gym_project",
+ "scene_edit_prompt",
+ "output_dir",
+ }
+)
+
+
+def validate_task_run_request(value: Mapping[str, Any]) -> TaskRunRequest:
+ """Validate and detach one Task Engine run request.
+
+ Version 1 deliberately has no ``scene_generation_prompt``. Image workflows
+ use the image-only Scene Engine generation behavior and may apply one
+ optional edit after that initial scene has been generated.
+ """
+ if not isinstance(value, Mapping):
+ raise TypeError("TaskRunRequest must be a mapping.")
+ result = deepcopy(dict(value))
+ if set(result) != _REQUEST_KEYS:
+ missing = sorted(_REQUEST_KEYS - set(result))
+ extra = sorted(set(result) - _REQUEST_KEYS)
+ raise ValueError(
+ f"TaskRunRequest fields differ; missing={missing}, extra={extra}."
+ )
+ if result.get("schema_version") != TASK_RUN_REQUEST_SCHEMA:
+ raise ValueError(
+ "TaskRunRequest.schema_version must be " f"{TASK_RUN_REQUEST_SCHEMA!r}."
+ )
+ result["task_id"] = _nonempty(result.get("task_id"), "task_id")
+ result["task_instruction"] = _nonempty(
+ result.get("task_instruction"), "task_instruction"
+ )
+ result["output_dir"] = _path(result.get("output_dir"), "output_dir")
+
+ image_path = _optional_path(result.get("image_path"), "image_path")
+ gym_project = _optional_path(result.get("gym_project"), "gym_project")
+ if (image_path is None) == (gym_project is None):
+ raise ValueError(
+ "TaskRunRequest requires exactly one of image_path or gym_project."
+ )
+ result["image_path"] = image_path
+ result["gym_project"] = gym_project
+ if gym_project is not None:
+ validate_scene_output_separation(gym_project, result["output_dir"])
+
+ edit_prompt = result.get("scene_edit_prompt")
+ if edit_prompt is not None:
+ edit_prompt = _nonempty(edit_prompt, "scene_edit_prompt")
+ result["scene_edit_prompt"] = edit_prompt
+ _json_safe(result)
+ return result
+
+
+def scene_input_kind(request: Mapping[str, Any]) -> SceneInputKind:
+ """Return the selected scene input kind after validating ``request``."""
+ normalized = validate_task_run_request(request)
+ return "image" if normalized["image_path"] is not None else "gym_project"
+
+
+def validate_scene_output_separation(
+ gym_project: str | Path,
+ output_dir: str | Path,
+) -> None:
+ """Reject output paths that could replace or modify a read-only source.
+
+ Args:
+ gym_project: Existing Gym project directory or configuration path.
+ output_dir: Transactional output directory for the Task Engine run.
+
+ Raises:
+ ValueError: If either path contains the other or both paths are equal.
+ """
+ source = Path(gym_project).expanduser().resolve()
+ output = Path(output_dir).expanduser().resolve()
+ if source == output or source in output.parents or output in source.parents:
+ raise ValueError(
+ "Task Engine output_dir and source Gym project must not overlap."
+ )
+
+
+def validate_scene_history_root(
+ gym_project: str | Path,
+ output_root: str | Path,
+) -> None:
+ """Protect a source project before reserving a history-directory child.
+
+ A prior run may live below the same history root because every new run is
+ published to a distinct timestamped child. The inverse remains unsafe:
+ creating the history root at or below the source project would write a
+ reservation and output artifacts into the read-only source tree.
+
+ Args:
+ gym_project: Existing Gym project directory or configuration path.
+ output_root: Parent directory under which a new run will be reserved.
+
+ Raises:
+ ValueError: If the history root is equal to or contained by the source
+ project boundary.
+ """
+ source = Path(gym_project).expanduser().resolve()
+ protected_root = source.parent if source.is_file() else source
+ history_root = Path(output_root).expanduser().resolve()
+ if protected_root == history_root or protected_root in history_root.parents:
+ raise ValueError(
+ "Task Engine output_root must not be inside the read-only source "
+ "Gym project."
+ )
+
+
+def _path(value: Any, field_name: str) -> str:
+ text = _nonempty(value, field_name)
+ return Path(text).expanduser().resolve().as_posix()
+
+
+def _optional_path(value: Any, field_name: str) -> str | None:
+ if value is None:
+ return None
+ return _path(value, field_name)
+
+
+def _nonempty(value: Any, field_name: str) -> str:
+ if not isinstance(value, str):
+ raise TypeError(f"TaskRunRequest.{field_name} must be a string.")
+ result = value.strip()
+ if not result:
+ raise ValueError(f"TaskRunRequest.{field_name} must not be empty.")
+ return result
+
+
+def _json_safe(value: Any) -> None:
+ try:
+ json.dumps(value, ensure_ascii=False, allow_nan=False)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("TaskRunRequest must contain strict JSON data.") from exc
diff --git a/setup.py b/setup.py
index ac131d484..06f687a3a 100644
--- a/setup.py
+++ b/setup.py
@@ -133,7 +133,9 @@ def main():
package_dir=get_package_dir(),
package_data={
"embodichain": ["VERSION"],
+ "embodichain.gen_sim.action_engine.config": ["*.yaml"],
"embodichain.gen_sim.simready_pipeline.configs": ["*.json"],
+ "embodichain.gen_sim.task_engine": ["*.yaml"],
"embodichain_tasks.configs": ["**/*.json", "**/*.yaml", "**/*.yml"],
},
cmdclass=cmdclass,
diff --git a/tests/benchmark/gen_sim/test_e1_e2_benchmark.py b/tests/benchmark/gen_sim/test_e1_e2_benchmark.py
new file mode 100644
index 000000000..7d72f7c67
--- /dev/null
+++ b/tests/benchmark/gen_sim/test_e1_e2_benchmark.py
@@ -0,0 +1,36 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from embodichain.gen_sim.action_engine.evaluation.e1_e2_scene_action import (
+ run_benchmark,
+)
+
+
+def test_e1_e2_contract_benchmark_is_reproducibly_executable(tmp_path: Path) -> None:
+ results, report = run_benchmark(iterations=2, output_dir=tmp_path)
+
+ assert tuple(item.scenario for item in results) == ("E1", "E2")
+ assert all(item.success_rate == 1.0 for item in results)
+ assert all(item.feasibility_status == "runtime_probe" for item in results)
+ assert all(item.action_count >= 3 for item in results)
+ markdown = report.read_text(encoding="utf-8")
+ assert markdown.count("## Time & Memory") == 1
+ assert markdown.count("## Success & Other Metrics") == 1
+ assert markdown.count("## Leaderboard") == 1
diff --git a/tests/gen_sim/task_engine/orchestration/__init__.py b/tests/gen_sim/task_engine/orchestration/__init__.py
new file mode 100644
index 000000000..626fe57e2
--- /dev/null
+++ b/tests/gen_sim/task_engine/orchestration/__init__.py
@@ -0,0 +1,17 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Tests for Task Engine cross-engine orchestration."""
diff --git a/tests/gen_sim/task_engine/orchestration/test_architecture.py b/tests/gen_sim/task_engine/orchestration/test_architecture.py
new file mode 100644
index 000000000..ad3593c0a
--- /dev/null
+++ b/tests/gen_sim/task_engine/orchestration/test_architecture.py
@@ -0,0 +1,90 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import embodichain.gen_sim as gen_sim_package
+from embodichain.gen_sim.action_engine.agent import ActionAgent
+from embodichain.gen_sim.task_engine import TaskAgent
+from embodichain.gen_sim.task_engine import __main__ as task_engine_main
+from embodichain.gen_sim.task_engine import cli as task_engine_cli
+from embodichain.gen_sim.task_engine.orchestration.coordinator import (
+ TaskEngineCoordinator,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_adapter import SceneAdapter
+
+_GEN_SIM_ROOT = Path(gen_sim_package.__file__).resolve().parent
+_PURE_TASK_MODULES = (
+ "agent.py",
+ "config.py",
+ "contracts.py",
+ "interpretation.py",
+ "ontology.py",
+ "state_machine.py",
+ "workflow_contracts.py",
+)
+
+
+def test_task_semantic_core_does_not_import_scene_action_or_orchestration() -> None:
+ forbidden = {
+ "embodichain.gen_sim.action_engine",
+ "embodichain.gen_sim.scene_engine",
+ "embodichain.gen_sim.task_engine.orchestration",
+ "embodichain.gen_sim.task_engine.scene",
+ }
+ offenders: list[str] = []
+ for filename in _PURE_TASK_MODULES:
+ path = _GEN_SIM_ROOT / "task_engine" / filename
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ modules = [alias.name for alias in node.names]
+ elif isinstance(node, ast.ImportFrom):
+ modules = [node.module or ""]
+ else:
+ continue
+ if any(
+ module == prefix or module.startswith(prefix + ".")
+ for module in modules
+ for prefix in forbidden
+ ):
+ offenders.append(filename)
+ break
+ assert offenders == []
+
+
+def test_cross_engine_owners_are_explicit() -> None:
+ assert TaskAgent.__module__ == "embodichain.gen_sim.task_engine.agent"
+ assert ActionAgent.__module__ == "embodichain.gen_sim.action_engine.agent"
+ assert SceneAdapter.__module__.startswith(
+ "embodichain.gen_sim.task_engine.orchestration"
+ )
+ assert TaskEngineCoordinator.__module__.startswith(
+ "embodichain.gen_sim.task_engine.orchestration"
+ )
+
+
+def test_task_engine_owns_its_module_entry_point() -> None:
+ assert task_engine_main.main is task_engine_cli.main
+
+
+def test_legacy_cross_engine_packages_are_deleted() -> None:
+ assert not (_GEN_SIM_ROOT / "scene_bridge").exists()
+ assert not (_GEN_SIM_ROOT / "collaboration").exists()
+ assert not (_GEN_SIM_ROOT / "action_engine" / "collaboration").exists()
diff --git a/tests/gen_sim/task_engine/orchestration/test_coordinator_cli.py b/tests/gen_sim/task_engine/orchestration/test_coordinator_cli.py
new file mode 100644
index 000000000..053b3e93f
--- /dev/null
+++ b/tests/gen_sim/task_engine/orchestration/test_coordinator_cli.py
@@ -0,0 +1,1102 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import replace
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from embodichain.gen_sim.task_engine import cli
+from embodichain.gen_sim.task_engine import _bundle_runner as bundle_runner
+from embodichain.gen_sim.task_engine.orchestration.artifacts import (
+ ArtifactTransaction,
+)
+from embodichain.gen_sim.task_engine.orchestration.contracts import (
+ BINDING_REPORT_SCHEMA,
+ ROLE_BINDINGS_SCHEMA,
+ SCENE_MANIFEST_SCHEMA,
+ SCENE_REQUEST_SCHEMA,
+ SUCCESS_SPEC_SCHEMA,
+ TASK_CANDIDATE_SET_SCHEMA,
+ TASK_DRAFT_SCHEMA,
+ canonical_hash,
+)
+from embodichain.gen_sim.task_engine.orchestration.coordinator import (
+ TaskEngineCoordinator,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_adapter import (
+ SceneAdaptation,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_source import SceneSourceRef
+from embodichain.gen_sim.action_engine.generation.artifacts import artifact_paths
+from embodichain.gen_sim.action_engine.generation.models import PreparedScene
+from embodichain.gen_sim.action_engine.protocol import (
+ AGENT_CONFIG_FILENAME,
+ FAST_GYM_CONFIG_FILENAME,
+)
+from embodichain.gen_sim.action_engine.runtime import (
+ ExecutionReport,
+ build_execution_provenance,
+)
+from embodichain.gen_sim.task_engine.scene import SceneEngineV1Adapter
+
+_UPRIGHT_CAN_INSTRUCTION = "test-instruction"
+
+
+def _candidate_set() -> dict:
+ selector = {
+ "kind": "scene_ref",
+ "step_id": "",
+ "reference": "red can",
+ "quantifier": "one",
+ "count": 0,
+ }
+ none_selector = {
+ "kind": "none",
+ "step_id": "",
+ "reference": "",
+ "quantifier": "one",
+ "count": 0,
+ }
+ step = {
+ "id": "upright",
+ "task_type": "E2",
+ "object": selector,
+ "target": none_selector,
+ "relation": "none",
+ "required_arm": "auto",
+ "transfer_arm": "none",
+ "receive_arm": "none",
+ "orientation_goal": "upright",
+ "target_state": "none",
+ "target_setting": 0,
+ "layout": "none",
+ "axis": "none",
+ "direction": "none",
+ "terminal_behavior": "none",
+ "depends_on": [],
+ }
+ draft = {
+ "schema_version": TASK_DRAFT_SCHEMA,
+ "task_id": "upright_can",
+ "instruction": _UPRIGHT_CAN_INSTRUCTION,
+ "steps": [step],
+ }
+ candidate = {
+ "candidate_id": "candidate_01",
+ "draft": draft,
+ "scene_request": {
+ "schema_version": SCENE_REQUEST_SCHEMA,
+ "task_id": "upright_can",
+ "references": [
+ {
+ "reference_id": "upright.object",
+ "step_id": "upright",
+ "role": "object",
+ "reference": "red can",
+ "quantifier": "one",
+ "count": 0,
+ "source_structure": "rigid_object",
+ "affordances": ["graspable", "orientable"],
+ "initial_state": {"orientation": "fallen"},
+ "attributes": {},
+ }
+ ],
+ },
+ "success_spec": {
+ "schema_version": SUCCESS_SPEC_SCHEMA,
+ "task_id": "upright_can",
+ "op": "all",
+ "terms": [{"step_id": "upright", "type": "object_upright"}],
+ },
+ "semantic_hash": canonical_hash([step]),
+ "vote_count": 1,
+ "attempts": 1,
+ "normalizations": [],
+ }
+ return {
+ "schema_version": TASK_CANDIDATE_SET_SCHEMA,
+ "task_id": "upright_can",
+ "instruction": _UPRIGHT_CAN_INSTRUCTION,
+ "candidates": [candidate],
+ "requested_candidate_count": 1,
+ "valid_response_count": 1,
+ "errors": [],
+ }
+
+
+def _candidate_set_with_alternative() -> dict:
+ candidates = _candidate_set()
+ alternative = deepcopy(candidates["candidates"][0])
+ alternative["candidate_id"] = "candidate_02"
+ alternative["draft"]["steps"][0]["required_arm"] = "left_arm"
+ alternative["semantic_hash"] = canonical_hash(alternative["draft"]["steps"])
+ candidates["candidates"].append(alternative)
+ candidates["requested_candidate_count"] = 2
+ candidates["valid_response_count"] = 2
+ return candidates
+
+
+def _prepared_scene(tmp_path: Path) -> PreparedScene:
+ scene_path = tmp_path / "scene_config.json"
+ scene_path.write_text("{}", encoding="utf-8")
+ scene_object = {
+ "uid": "red_can",
+ "source_uid": "red_can",
+ "role": "rigid_object",
+ "name": "red can",
+ "description": "A red can.",
+ "category": "can",
+ "color": "red",
+ "position": [0.0, 0.0, 0.5],
+ "affordances": ["graspable", "orientable"],
+ "initial_state": {"orientation": "fallen"},
+ "attributes": {},
+ }
+ return PreparedScene(
+ source_config_path=scene_path,
+ scene_dir=tmp_path,
+ planner_objects=(scene_object,),
+ background=(),
+ rigid_objects=(),
+ articulations=(),
+ uid_map={"red_can": "red_can"},
+ table_top_z=None,
+ z_rotation_degrees=0.0,
+ body_scale_policy="preserve",
+ body_scale=(1.0, 1.0, 1.0),
+ asset_hashes={},
+ )
+
+
+def _adaptation(tmp_path: Path, *, status: str = "bound") -> SceneAdaptation:
+ candidates = _candidate_set()
+ candidate = candidates["candidates"][0]
+ selected_id = candidate["candidate_id"] if status == "bound" else ""
+ role_bindings = (
+ {
+ "schema_version": ROLE_BINDINGS_SCHEMA,
+ "task_id": "upright_can",
+ "candidate_id": "candidate_01",
+ "reference_bindings": {"upright.object": ["red_can"]},
+ "role_bindings": {},
+ }
+ if status == "bound"
+ else None
+ )
+ return SceneAdaptation(
+ scene_manifest={
+ "schema_version": SCENE_MANIFEST_SCHEMA,
+ "scene_id": "scene",
+ "source_format": "test",
+ "robot_profile": "dual_franka",
+ "objects": [
+ {
+ "uid": "red_can",
+ "role": "rigid_object",
+ "name": "red can",
+ "description": "A red can.",
+ "category": "can",
+ "color": "red",
+ "affordances": ["graspable", "orientable"],
+ "initial_state": {"orientation": "fallen"},
+ "attributes": {},
+ }
+ ],
+ },
+ role_bindings=role_bindings,
+ binding_report={
+ "schema_version": BINDING_REPORT_SCHEMA,
+ "task_id": "upright_can",
+ "status": status,
+ "selected_candidate_id": selected_id,
+ "selection_reason": "test",
+ "candidates": [
+ {
+ "candidate_id": "candidate_01",
+ "semantic_hash": candidate["semantic_hash"],
+ "status": "resolved" if status == "bound" else status,
+ "references": [
+ {
+ "reference_id": "upright.object",
+ "status": (
+ "resolved" if status == "bound" else "ambiguous"
+ ),
+ "confidence": 1.0,
+ "candidate_uids": ["red_can"],
+ "selected_uids": (["red_can"] if status == "bound" else []),
+ "reasons": [],
+ }
+ ],
+ "reasons": [],
+ }
+ ],
+ },
+ selected_candidate=deepcopy(candidate) if status == "bound" else None,
+ prepared_scene=_prepared_scene(tmp_path),
+ source_config_path=tmp_path / "scene_config.json",
+ conservative_scene_graph={
+ "schema_version": "embodichain.conservative-scene-graph/v1",
+ "scene_id": "scene",
+ "nodes": [
+ {
+ "uid": "red_can",
+ "parent_uid": "unknown",
+ "parent_relation": "unknown",
+ "orientation": "unknown",
+ "source": "test",
+ }
+ ],
+ "relations": [],
+ },
+ )
+
+
+def _adaptation_with_alternative(tmp_path: Path) -> SceneAdaptation:
+ candidate_set = _candidate_set_with_alternative()
+ adaptation = _adaptation(tmp_path)
+ alternative = candidate_set["candidates"][1]
+ alternative_audit = deepcopy(adaptation.binding_report["candidates"][0])
+ alternative_audit["candidate_id"] = "candidate_02"
+ alternative_audit["semantic_hash"] = alternative["semantic_hash"]
+ alternative_bindings = {
+ **deepcopy(adaptation.role_bindings),
+ "candidate_id": "candidate_02",
+ }
+ return replace(
+ adaptation,
+ binding_report={
+ **deepcopy(adaptation.binding_report),
+ "candidates": [
+ *deepcopy(adaptation.binding_report["candidates"]),
+ alternative_audit,
+ ],
+ },
+ candidate_bindings={"candidate_02": alternative_bindings},
+ )
+
+
+def test_artifact_transaction_rolls_back_and_preserves_existing_output(
+ tmp_path: Path,
+) -> None:
+ output = tmp_path / "bundle"
+ output.mkdir()
+ (output / "kept.txt").write_text("old", encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match="fail"):
+ with ArtifactTransaction(output, overwrite=True) as transaction:
+ assert transaction.staging_dir is not None
+ (transaction.staging_dir / "partial.txt").write_text(
+ "partial", encoding="utf-8"
+ )
+ raise RuntimeError("fail before commit")
+
+ assert (output / "kept.txt").read_text(encoding="utf-8") == "old"
+ assert not (output / "partial.txt").exists()
+
+
+def test_prepare_rejects_output_overlapping_read_only_source(tmp_path: Path) -> None:
+ source = tmp_path / "gym_project"
+ source.mkdir()
+ coordinator = TaskEngineCoordinator(
+ task_agent=object(),
+ scene_adapter=SimpleNamespace(robot_profile="franka"),
+ action_agent=object(),
+ feasibility_broker=object(),
+ )
+
+ with pytest.raises(ValueError, match="must not overlap"):
+ coordinator.prepare(
+ "task",
+ "Pick up the object.",
+ source,
+ source / "task_run",
+ overwrite=True,
+ )
+
+
+def test_unbound_prepare_publishes_only_audit_artifacts(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ adaptation = _adaptation(tmp_path, status="ambiguous")
+ task_agent = SimpleNamespace(generate=lambda *args, **kwargs: candidates)
+ scene_adapter = SimpleNamespace(
+ robot_profile="franka",
+ adapt=lambda *args, **kwargs: adaptation,
+ )
+ action_agent = SimpleNamespace(
+ plan=lambda *_args, **_kwargs: pytest.fail("Action Agent must not run")
+ )
+ coordinator = TaskEngineCoordinator(
+ task_agent=task_agent,
+ scene_adapter=scene_adapter,
+ action_agent=action_agent,
+ bundle_generator=lambda *_args, **_kwargs: pytest.fail(
+ "legacy generator must not run"
+ ),
+ )
+
+ result = coordinator.prepare(
+ "upright_can",
+ _UPRIGHT_CAN_INSTRUCTION,
+ tmp_path / "scene_config.json",
+ tmp_path / "bundle",
+ candidate_count=1,
+ )
+
+ assert result.status == "ambiguous"
+ assert (result.output_dir / "task_candidate_set.json").is_file()
+ assert (result.output_dir / "binding_report.json").is_file()
+ assert not (result.output_dir / "scene_manifest.json").exists()
+ assert not (result.output_dir / "role_bindings.json").exists()
+ assert not (result.output_dir / "grounded_task_plan.json").exists()
+ assert not (result.output_dir / FAST_GYM_CONFIG_FILENAME).exists()
+
+
+def test_prepare_reuses_precomputed_candidates_without_rerunning_task_agent(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ adaptation = _adaptation(tmp_path, status="ambiguous")
+ task_agent = SimpleNamespace(
+ generate=lambda *args, **kwargs: pytest.fail("Task Agent must not rerun")
+ )
+ coordinator = TaskEngineCoordinator(
+ task_agent=task_agent,
+ scene_adapter=SimpleNamespace(
+ robot_profile="franka",
+ adapt=lambda *args, **kwargs: adaptation,
+ ),
+ action_agent=object(),
+ )
+
+ result = coordinator.prepare(
+ "upright_can",
+ _UPRIGHT_CAN_INSTRUCTION,
+ tmp_path / "scene_config.json",
+ tmp_path / "candidate-reuse",
+ candidate_set=candidates,
+ force_most_likely=True,
+ )
+
+ assert result.status == "ambiguous"
+ assert result.candidate_set == candidates
+
+
+def test_prepare_inherits_adapter_robot_profile_for_raw_scene_path(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ adaptation = _adaptation(tmp_path, status="ambiguous")
+ captured: dict[str, object] = {}
+
+ def adapt(_candidates, source, **_kwargs):
+ captured["source"] = source
+ return adaptation
+
+ coordinator = TaskEngineCoordinator(
+ task_agent=SimpleNamespace(
+ generate=lambda *args, **kwargs: pytest.fail("Task Agent must not rerun")
+ ),
+ scene_adapter=SimpleNamespace(robot_profile="ur10", adapt=adapt),
+ action_agent=object(),
+ )
+
+ result = coordinator.prepare(
+ "upright_can",
+ _UPRIGHT_CAN_INSTRUCTION,
+ tmp_path / "scene_config.json",
+ tmp_path / "ur10-bundle",
+ candidate_set=candidates,
+ )
+
+ assert result.status == "ambiguous"
+ assert isinstance(captured["source"], SceneSourceRef)
+ assert captured["source"].robot_profile == "ur10"
+
+
+def test_contradicted_feasibility_publishes_audit_without_planning(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ adaptation = _adaptation(tmp_path)
+ static_manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ adaptation.prepared_scene,
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ adaptation = replace(
+ adaptation,
+ static_scene_manifest=static_manifest,
+ )
+ task_agent = SimpleNamespace(generate=lambda *args, **kwargs: candidates)
+ scene_adapter = SimpleNamespace(
+ robot_profile="franka",
+ adapt=lambda *args, **kwargs: adaptation,
+ )
+ registry = SimpleNamespace(
+ catalog=lambda: {
+ name: {
+ "runtime_available": name != "PickUp",
+ "unavailable_reason": (
+ "PickUp disabled for test." if name == "PickUp" else None
+ ),
+ }
+ for name in ("PickUp", "MoveHeldObject", "Place")
+ }
+ )
+ action_agent = SimpleNamespace(
+ registry=registry,
+ plan=lambda *_args, **_kwargs: pytest.fail("Action Agent must not plan"),
+ )
+
+ result = TaskEngineCoordinator(
+ task_agent=task_agent,
+ scene_adapter=scene_adapter,
+ action_agent=action_agent,
+ bundle_generator=lambda *_args, **_kwargs: pytest.fail(
+ "legacy generator must not run"
+ ),
+ ).prepare(
+ "upright_can",
+ _UPRIGHT_CAN_INSTRUCTION,
+ tmp_path / "scene_config.json",
+ tmp_path / "infeasible-bundle",
+ candidate_count=1,
+ )
+
+ assert result.status == "infeasible"
+ assert result.feasibility_report is not None
+ assert result.feasibility_report["status"] == "contradicted"
+ assert result.feasibility_report["remediation_class"] == "action_capability"
+ assert result.artifacts.static_scene_manifest.is_file()
+ assert result.artifacts.feasibility_report.is_file()
+ assert not result.artifacts.grounded_task_plan.exists()
+
+
+def test_feasibility_contradiction_falls_back_to_next_resolved_candidate(
+ tmp_path: Path,
+) -> None:
+ candidate_set = _candidate_set()
+ first = candidate_set["candidates"][0]
+ second = deepcopy(first)
+ second["candidate_id"] = "candidate_02"
+ second["semantic_hash"] = "b" * 64
+ candidate_set["candidates"].append(second)
+ adaptation = _adaptation(tmp_path)
+ second_audit = deepcopy(adaptation.binding_report["candidates"][0])
+ second_audit["candidate_id"] = "candidate_02"
+ second_audit["semantic_hash"] = "b" * 64
+ binding_report = deepcopy(adaptation.binding_report)
+ binding_report["candidates"].append(second_audit)
+ second_bindings = {
+ **deepcopy(adaptation.role_bindings),
+ "candidate_id": "candidate_02",
+ }
+ adaptation = replace(
+ adaptation,
+ binding_report=binding_report,
+ candidate_bindings={"candidate_02": second_bindings},
+ static_scene_manifest={},
+ )
+
+ class _Broker:
+ @staticmethod
+ def assess(candidate, *_args, **_kwargs):
+ return {
+ "status": (
+ "runtime_probe"
+ if candidate["candidate_id"] == "candidate_02"
+ else "contradicted"
+ )
+ }
+
+ registry = SimpleNamespace(catalog=lambda: {})
+ coordinator = TaskEngineCoordinator(
+ action_agent=SimpleNamespace(registry=registry),
+ feasibility_broker=_Broker(),
+ )
+
+ updated, selected, bindings, report = coordinator._fallback_feasible_candidate(
+ candidate_set,
+ adaptation,
+ first,
+ adaptation.role_bindings,
+ {"status": "contradicted"},
+ )
+
+ assert selected["candidate_id"] == "candidate_02"
+ assert bindings["candidate_id"] == "candidate_02"
+ assert report["status"] == "runtime_probe"
+ assert updated.binding_report["selected_candidate_id"] == "candidate_02"
+ assert (
+ "static feasibility contradicted candidate_01"
+ in updated.binding_report["selection_reason"]
+ )
+
+
+def test_bound_prepare_uses_sidecar_and_publishes_complete_bundle(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ adaptation = _adaptation(tmp_path)
+ task_agent = SimpleNamespace(generate=lambda *args, **kwargs: candidates)
+ scene_adapter = SimpleNamespace(
+ robot_profile="franka",
+ adapt=lambda *args, **kwargs: adaptation,
+ )
+ graph = {"graph": "planned"}
+ action_agent = SimpleNamespace(plan=lambda _plan: deepcopy(graph))
+ generator_calls = []
+
+ def generator(_scene, output, **kwargs):
+ generator_calls.append(kwargs)
+ task_spec_path = Path(kwargs["task_spec"])
+ assert task_spec_path.is_file()
+ assert (task_spec_path.parent / "scene_requirements.json").is_file()
+ paths = artifact_paths(output)
+ for path in (
+ paths.gym_config,
+ paths.agent_config,
+ paths.task_spec,
+ paths.scene_requirements,
+ paths.seed_task_graph,
+ ):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ value = graph if path == paths.seed_task_graph else {}
+ path.write_text(json.dumps(value), encoding="utf-8")
+ paths.seed_task_graph_png.write_bytes(b"png")
+ return paths
+
+ result = TaskEngineCoordinator(
+ task_agent=task_agent,
+ scene_adapter=scene_adapter,
+ action_agent=action_agent,
+ bundle_generator=generator,
+ ).prepare(
+ "upright_can",
+ _UPRIGHT_CAN_INSTRUCTION,
+ tmp_path / "scene_config.json",
+ tmp_path / "bundle",
+ candidate_count=1,
+ )
+
+ assert result.bound
+ assert generator_calls
+ assert not (result.output_dir / ".task_engine_input").exists()
+ grounded = json.loads(
+ (result.output_dir / "grounded_task_plan.json").read_text(encoding="utf-8")
+ )
+ assert grounded["success_spec"]["terms"] == [
+ {"step_id": "task_01", "type": "object_upright"}
+ ]
+ assert (result.output_dir / "seed_task_graph.json").is_file()
+
+
+def test_prepare_falls_back_after_candidate_action_planning_failure(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set_with_alternative()
+ adaptation = _adaptation_with_alternative(tmp_path)
+ planned_candidates: list[str] = []
+ graph = {"graph": "planned"}
+
+ def plan(grounded_plan):
+ candidate_id = grounded_plan["selected_candidate_id"]
+ planned_candidates.append(candidate_id)
+ if candidate_id == "candidate_01":
+ raise ValueError(
+ "SeedGraph TaskGroup 'task_04' requires unavailable state "
+ "{'predicate': 'arm_free', 'arm': 'right_arm'}."
+ )
+ return deepcopy(graph)
+
+ def generator(_scene, output, **_kwargs):
+ paths = artifact_paths(output)
+ for path in (
+ paths.gym_config,
+ paths.agent_config,
+ paths.task_spec,
+ paths.scene_requirements,
+ paths.seed_task_graph,
+ ):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ value = graph if path == paths.seed_task_graph else {}
+ path.write_text(json.dumps(value), encoding="utf-8")
+ paths.seed_task_graph_png.write_bytes(b"png")
+ return paths
+
+ result = TaskEngineCoordinator(
+ task_agent=SimpleNamespace(generate=lambda *args, **kwargs: candidates),
+ scene_adapter=SimpleNamespace(
+ robot_profile="franka",
+ adapt=lambda *args, **kwargs: adaptation,
+ ),
+ action_agent=SimpleNamespace(plan=plan),
+ bundle_generator=generator,
+ ).prepare(
+ "upright_can",
+ _UPRIGHT_CAN_INSTRUCTION,
+ tmp_path / "scene_config.json",
+ tmp_path / "fallback-bundle",
+ candidate_count=2,
+ )
+
+ assert result.bound
+ assert result.selected_candidate_id == "candidate_02"
+ assert planned_candidates == ["candidate_01", "candidate_02"]
+ assert (
+ "candidate_01 failed action_planning"
+ in result.adaptation.binding_report["selection_reason"]
+ )
+ assert not result.artifacts.preparation_failure.exists()
+
+
+def test_prepare_publishes_failure_context_when_all_candidates_fail_planning(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set_with_alternative()
+ adaptation = _adaptation_with_alternative(tmp_path)
+ output = tmp_path / "failed-bundle"
+ output.mkdir()
+ (output / "stale.txt").write_text("old", encoding="utf-8")
+
+ result = TaskEngineCoordinator(
+ task_agent=SimpleNamespace(generate=lambda *args, **kwargs: candidates),
+ scene_adapter=SimpleNamespace(
+ robot_profile="franka",
+ adapt=lambda *args, **kwargs: adaptation,
+ ),
+ action_agent=SimpleNamespace(
+ plan=lambda _plan: (_ for _ in ()).throw(
+ ValueError(
+ "SeedGraph TaskGroup 'task_04' requires unavailable state "
+ "{'predicate': 'arm_free', 'arm': 'right_arm'}."
+ )
+ )
+ ),
+ bundle_generator=lambda *_args, **_kwargs: pytest.fail(
+ "bundle generation must not run"
+ ),
+ ).prepare(
+ "upright_can",
+ _UPRIGHT_CAN_INSTRUCTION,
+ tmp_path / "scene_config.json",
+ output,
+ candidate_count=2,
+ overwrite=True,
+ )
+
+ assert result.status == "planning_failed"
+ assert not result.bound
+ assert result.artifacts.preparation_failure.is_file()
+ assert not (result.output_dir / "stale.txt").exists()
+ failure = json.loads(
+ result.artifacts.preparation_failure.read_text(encoding="utf-8")
+ )
+ assert failure["schema_version"] == "action_engine_preparation_failure_v1"
+ assert failure["task_id"] == "upright_can"
+ assert failure["selected_candidate_id"] == "candidate_01"
+ assert [attempt["candidate_id"] for attempt in failure["attempts"]] == [
+ "candidate_01",
+ "candidate_02",
+ ]
+ for index, attempt in enumerate(failure["attempts"]):
+ candidate_id = f"candidate_{index + 1:02d}"
+ assert attempt["stage"] == "action_planning"
+ assert attempt["draft"] == candidates["candidates"][index]["draft"]
+ assert attempt["bindings"]["candidate_id"] == candidate_id
+ assert attempt["grounded_task_plan"]["selected_candidate_id"] == candidate_id
+ assert "unbound_action_plan" in attempt
+ assert "action_graph" in attempt
+ assert attempt["error"]["type"] == "ValueError"
+ assert "arm_free" in attempt["error"]["message"]
+
+
+def test_private_bundle_runner_forwards_arguments_without_leaking_sys_argv(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ bundle = tmp_path / "bundle"
+ bundle.mkdir()
+ (bundle / AGENT_CONFIG_FILENAME).write_text(
+ json.dumps({"task_name": "task"}), encoding="utf-8"
+ )
+ (bundle / FAST_GYM_CONFIG_FILENAME).write_text("{}", encoding="utf-8")
+ captured = []
+
+ def fake_cli() -> None:
+ import sys
+
+ captured.append(list(sys.argv))
+
+ import embodichain.gen_sim.action_engine.cli as legacy_cli
+
+ monkeypatch.setattr(
+ legacy_cli,
+ "run_agent",
+ SimpleNamespace(cli=fake_cli),
+ raising=False,
+ )
+ import sys
+
+ original = sys.argv
+ assert bundle_runner.main(["--bundle", str(bundle), "--seed", "7"]) == 0
+
+ assert sys.argv is original
+ assert captured[0][-2:] == ["--seed", "7"]
+ assert str(bundle / AGENT_CONFIG_FILENAME) in captured[0]
+
+
+@pytest.mark.parametrize(
+ ("mode", "image", "scene", "edit"),
+ [
+ ("image", "input.png", None, None),
+ ("image-edit", "input.png", None, "move the cup left"),
+ ("scene", None, "gym_project", None),
+ ("scene-edit", None, "gym_project", "move the cup left"),
+ ],
+)
+def test_unified_cli_accepts_exactly_four_modes(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ mode: str,
+ image: str | None,
+ scene: str | None,
+ edit: str | None,
+) -> None:
+ captured = {}
+
+ class FakeWorkflow:
+ def __init__(self, **_kwargs) -> None:
+ pass
+
+ def run(self, request, **kwargs):
+ captured["request"] = request
+ captured["kwargs"] = kwargs
+ output = Path(request["output_dir"])
+ return SimpleNamespace(
+ status="succeeded",
+ succeeded=True,
+ failure_class=None,
+ output_dir=output,
+ manifest_path=output / "run_manifest.json",
+ final_bundle=output / "final" / "bundle",
+ )
+
+ monkeypatch.setattr(cli, "SceneAdapter", lambda **_kwargs: object())
+ monkeypatch.setattr(cli, "TaskEngineWorkflow", FakeWorkflow)
+ arguments = [
+ "--mode",
+ mode,
+ "--task-id",
+ "task",
+ "--instruction",
+ "place the cup",
+ "--output-root",
+ str(tmp_path / "history"),
+ "--base-seed",
+ "9",
+ ]
+ if image is not None:
+ arguments.extend(["--image", str(tmp_path / image)])
+ if scene is not None:
+ arguments.extend(["--scene", str(tmp_path / scene)])
+ if edit is not None:
+ arguments.extend(["--scene-edit", edit])
+ if mode == "image":
+ arguments.append("--dataset_saving")
+
+ assert cli.main(arguments) == 0
+
+ request = captured["request"]
+ assert request["image_path"] == (None if image is None else str(tmp_path / image))
+ assert request["gym_project"] == (None if scene is None else str(tmp_path / scene))
+ assert request["scene_edit_prompt"] == edit
+ assert captured["kwargs"]["base_seed"] == 9
+ assert captured["kwargs"]["dataset_saving"] is (mode == "image")
+ assert captured["kwargs"]["execute"] is True
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["status"] == "succeeded"
+ assert payload["run_id"].replace("_", "").isdigit()
+ assert len(payload["run_id"]) == 15
+ assert Path(payload["output_dir"]).parent == tmp_path / "history"
+
+
+def test_unified_cli_reuses_history_root_without_modifying_prior_scene(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ history = tmp_path / "task1008"
+ source = (
+ history
+ / "20260820_105939"
+ / "attempts"
+ / "scene_0001"
+ / "scene_revision"
+ / "scene_export"
+ )
+ source.mkdir(parents=True)
+ marker = source / "scene_config.json"
+ marker.write_text('{"source": "unchanged"}\n', encoding="utf-8")
+ captured = {}
+
+ class FakeWorkflow:
+ def __init__(self, **_kwargs) -> None:
+ pass
+
+ def run(self, request, **_kwargs):
+ captured["request"] = request
+ output = Path(request["output_dir"])
+ return SimpleNamespace(
+ status="succeeded",
+ succeeded=True,
+ failure_class=None,
+ output_dir=output,
+ manifest_path=output / "run_manifest.json",
+ final_bundle=output / "final" / "bundle",
+ )
+
+ monkeypatch.setattr(cli, "SceneAdapter", lambda **_kwargs: object())
+ monkeypatch.setattr(cli, "TaskEngineWorkflow", FakeWorkflow)
+
+ assert (
+ cli.main(
+ [
+ "--mode",
+ "scene",
+ "--task-id",
+ "task1008",
+ "--scene",
+ str(source),
+ "--instruction",
+ "place the cup on the book",
+ "--output-root",
+ str(history),
+ ]
+ )
+ == 0
+ )
+
+ output_dir = Path(captured["request"]["output_dir"])
+ assert output_dir.parent == history
+ assert output_dir != source
+ assert marker.read_text(encoding="utf-8") == '{"source": "unchanged"}\n'
+ assert list(history.glob(".*.reserve")) == []
+
+
+def test_unified_cli_rejects_history_root_inside_source_before_reservation(
+ tmp_path: Path,
+) -> None:
+ source = tmp_path / "scene_export"
+ source.mkdir()
+ output_root = source / "new_runs"
+
+ with pytest.raises(ValueError, match="read-only source"):
+ cli.main(
+ [
+ "--mode",
+ "scene",
+ "--task-id",
+ "task",
+ "--scene",
+ str(source),
+ "--instruction",
+ "place the cup",
+ "--output-root",
+ str(output_root),
+ ]
+ )
+
+ assert not output_root.exists()
+
+
+def test_unified_cli_rejects_mode_input_mismatch(tmp_path: Path) -> None:
+ with pytest.raises(SystemExit, match="2"):
+ cli.main(
+ [
+ "--mode",
+ "image",
+ "--task-id",
+ "task",
+ "--instruction",
+ "place the cup",
+ "--image",
+ str(tmp_path / "input.png"),
+ "--scene",
+ str(tmp_path / "scene"),
+ "--output-root",
+ str(tmp_path / "history"),
+ ]
+ )
+
+
+def test_public_cli_exposes_prepare_run_and_run_all_modes() -> None:
+ parser = cli.build_parser()
+ help_text = parser.format_help()
+ assert "prepare" in help_text
+ assert "run-all" in help_text
+ assert "run" in help_text
+ assert "--overwrite" not in help_text
+ assert "--run-after-prepare" not in help_text
+ arguments = parser.parse_args(
+ [
+ "prepare",
+ "--mode",
+ "image",
+ "--task-id",
+ "task",
+ "--instruction",
+ "place the cup",
+ "--image",
+ "input.png",
+ "--output-root",
+ "history",
+ "--dataset_saving",
+ ]
+ )
+ assert arguments.command == "prepare"
+ assert arguments.dataset_saving is True
+
+
+def test_prepare_cli_stops_before_simulator_execution(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured = {}
+
+ class FakeWorkflow:
+ def __init__(self, **_kwargs) -> None:
+ pass
+
+ def run(self, request, **kwargs):
+ captured.update(kwargs)
+ output = Path(request["output_dir"])
+ return SimpleNamespace(
+ status="prepared",
+ succeeded=False,
+ failure_class=None,
+ output_dir=output,
+ manifest_path=output / "run_manifest.json",
+ final_bundle=output / "final" / "bundle",
+ )
+
+ monkeypatch.setattr(cli, "SceneAdapter", lambda **_kwargs: object())
+ monkeypatch.setattr(cli, "TaskEngineWorkflow", FakeWorkflow)
+
+ result = cli.main(
+ [
+ "prepare",
+ "--mode",
+ "image",
+ "--task-id",
+ "task",
+ "--instruction",
+ "place the cup",
+ "--image",
+ str(tmp_path / "input.png"),
+ "--output-root",
+ str(tmp_path / "history"),
+ ]
+ )
+
+ assert result == 0
+ assert captured["execute"] is False
+
+
+def test_run_cli_executes_an_existing_bundle(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ bundle = tmp_path / "bundle"
+ bundle.mkdir()
+
+ class Executor:
+ def __call__(self, _bundle, output, **kwargs):
+ Path(output).mkdir()
+ assert kwargs["num_envs"] == 2
+ return {
+ "status": "failed",
+ "environments": [
+ {"success": True},
+ {"success": False},
+ ],
+ }
+
+ monkeypatch.setattr(cli, "SubprocessActionExecutor", Executor)
+
+ result = cli.main(
+ [
+ "run",
+ "--bundle",
+ str(bundle),
+ "--output-root",
+ str(tmp_path / "history"),
+ "--num-envs",
+ "2",
+ ]
+ )
+
+ assert result == 0
+
+
+def test_private_bundle_runner_publishes_rejected_preflight_report(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ bundle = tmp_path / "bundle"
+ bundle.mkdir()
+ (bundle / AGENT_CONFIG_FILENAME).write_text(
+ json.dumps({"task_name": "task"}), encoding="utf-8"
+ )
+ (bundle / FAST_GYM_CONFIG_FILENAME).write_text("{}", encoding="utf-8")
+ report = ExecutionReport(
+ task_id="task",
+ plan_hash="0" * 64,
+ action_graph_hash="1" * 64,
+ status="rejected",
+ run_id="preflight",
+ episode_id="0",
+ provenance=build_execution_provenance(),
+ environments=(
+ {
+ "env_id": "0",
+ "success": False,
+ "semantic_success": {},
+ "action_count": 0,
+ "retry_count": 0,
+ "recovery_count": 0,
+ "revision_count": 0,
+ "failures": [],
+ },
+ ),
+ error="ValueError: planning-only action",
+ )
+ monkeypatch.setattr(
+ bundle_runner,
+ "_preflight_bundle",
+ lambda *args, **kwargs: report,
+ )
+
+ assert bundle_runner.main(["--bundle", str(bundle)]) == 2
+ payload = json.loads((bundle / "execution_report.json").read_text(encoding="utf-8"))
+ assert payload["status"] == "rejected"
+ assert payload["action_count"] == 0
diff --git a/tests/gen_sim/task_engine/orchestration/test_legacy_scene.py b/tests/gen_sim/task_engine/orchestration/test_legacy_scene.py
new file mode 100644
index 000000000..286441fb0
--- /dev/null
+++ b/tests/gen_sim/task_engine/orchestration/test_legacy_scene.py
@@ -0,0 +1,164 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import trimesh
+
+from embodichain.gen_sim.action_engine.generation.source_scene import prepare_scene
+from embodichain.gen_sim.task_engine.orchestration.legacy_scene import (
+ convert_legacy_gym_project,
+ restore_locked_scene_entities,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_source import (
+ fingerprint_scene_source,
+ scene_revision_id,
+)
+from embodichain.gen_sim.task_engine.scene import build_conservative_scene_graph
+
+
+def _legacy_project(tmp_path: Path) -> Path:
+ project = tmp_path / "legacy"
+ assets = project / "assets"
+ assets.mkdir(parents=True)
+ trimesh.creation.box(extents=[1.0, 1.0, 0.1]).export(
+ assets / "table.glb", file_type="glb"
+ )
+ trimesh.creation.cylinder(radius=0.03, height=0.12).export(
+ assets / "can.glb", file_type="glb"
+ )
+ trimesh.creation.box(extents=[0.3, 0.2, 0.4]).export(
+ assets / "cabinet.glb", file_type="glb"
+ )
+ (assets / "cabinet.urdf").write_text(
+ ''
+ '\n',
+ encoding="utf-8",
+ )
+ config = {
+ "background": [
+ {
+ "uid": "table_0",
+ "name": "table",
+ "description": "A work table.",
+ "category": "table",
+ "shape": {"shape_type": "Mesh", "fpath": "assets/table.glb"},
+ "init_pos": [0.0, 0.0, 0.0],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ "rigid_object": [
+ {
+ "uid": "can_0",
+ "name": "red can",
+ "description": "A red can.",
+ "category": "can",
+ "shape": {"shape_type": "Mesh", "fpath": "assets/can.glb"},
+ "init_pos": [0.0, 0.1, 0.2],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ "articulation": [
+ {
+ "uid": "cabinet_0",
+ "name": "cabinet",
+ "description": "A fixed cabinet.",
+ "category": "cabinet",
+ "fpath": "assets/cabinet.urdf",
+ "init_pos": [0.5, 0.0, 0.0],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ }
+ (project / "gym_config.json").write_text(json.dumps(config), encoding="utf-8")
+ return project
+
+
+def test_legacy_conversion_is_read_only_and_restores_locked_articulation(
+ tmp_path: Path,
+) -> None:
+ project = _legacy_project(tmp_path)
+ original = fingerprint_scene_source(project)
+
+ revision = convert_legacy_gym_project(project, tmp_path / "revision")
+ converted = json.loads(revision.scene_config_path.read_text(encoding="utf-8"))
+ manifest = json.loads(revision.manifest_path.read_text(encoding="utf-8"))
+
+ assert fingerprint_scene_source(project) == original
+ assert converted["format"] == "embodichain.scene-export/v1"
+ assert converted["background"][0]["uid"] == "table"
+ assert converted["rigid_object"][0]["uid"] == "can"
+ assert converted["articulation"][0]["uid"] == "cabinet"
+ assert manifest["audit_hierarchy"] == "unknown"
+ assert manifest["operational_hierarchy"] == "assumed_on_table"
+ assert set(revision.locked_entity_uids) == {"table", "cabinet"}
+
+ converted["articulation"] = []
+ revision.scene_config_path.write_text(json.dumps(converted), encoding="utf-8")
+ restore_locked_scene_entities(revision.output_root)
+ restored = json.loads(revision.scene_config_path.read_text(encoding="utf-8"))
+
+ assert restored["articulation"][0]["uid"] == "cabinet"
+ assert Path(restored["articulation"][0]["fpath"]).is_file()
+ assert fingerprint_scene_source(project) == original
+
+
+def test_legacy_conversion_separates_audit_and_operational_hierarchy(
+ tmp_path: Path,
+) -> None:
+ revision = convert_legacy_gym_project(
+ _legacy_project(tmp_path),
+ tmp_path / "revision",
+ )
+
+ operational = json.loads(revision.scene_graph_path.read_text(encoding="utf-8"))
+ conservative = build_conservative_scene_graph(
+ prepare_scene(revision.scene_config_path),
+ scene_id="legacy-scene",
+ )
+
+ operational_can = next(
+ node for node in operational["nodes"] if node["object_id"] == "can"
+ )
+ conservative_can = next(
+ node for node in conservative["nodes"] if node["uid"] == "can"
+ )
+ assert operational_can["parent_id"] == "table"
+ assert operational_can["parent_relation"] == "on"
+ assert conservative_can["parent_uid"] == "unknown"
+ assert conservative_can["parent_relation"] == "unknown"
+ assert conservative_can["source"] == "conservative_import"
+
+
+def test_scene_identity_covers_transitive_urdf_meshes(tmp_path: Path) -> None:
+ project = _legacy_project(tmp_path)
+ original_fingerprint = fingerprint_scene_source(project)
+ original_revision = scene_revision_id(project)
+
+ trimesh.creation.box(extents=[0.6, 0.2, 0.4]).export(
+ project / "assets" / "cabinet.glb",
+ file_type="glb",
+ )
+
+ changed_fingerprint = fingerprint_scene_source(project)
+ assert changed_fingerprint.asset_sha256 != original_fingerprint.asset_sha256
+ assert scene_revision_id(project) != original_revision
diff --git a/tests/gen_sim/task_engine/orchestration/test_scene_adapter.py b/tests/gen_sim/task_engine/orchestration/test_scene_adapter.py
new file mode 100644
index 000000000..2ae3b749f
--- /dev/null
+++ b/tests/gen_sim/task_engine/orchestration/test_scene_adapter.py
@@ -0,0 +1,780 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from copy import deepcopy
+import json
+from pathlib import Path
+
+import pytest
+
+import embodichain.gen_sim.task_engine.orchestration.scene_adapter as scene_adapter_module
+from embodichain.gen_sim.task_engine.contracts import (
+ SCENE_REQUEST_SCHEMA,
+ SUCCESS_SPEC_SCHEMA,
+ TASK_CANDIDATE_SET_SCHEMA,
+ TASK_DRAFT_SCHEMA,
+ canonical_hash,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_adapter import (
+ SceneAdapter,
+ SceneAdapterProtocolError,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_source import (
+ SceneSourceRef,
+ fingerprint_scene_source,
+ verify_scene_source_fingerprint,
+)
+from embodichain.gen_sim.task_engine.agent import (
+ derive_scene_request,
+ derive_success_spec,
+)
+
+_UPRIGHT_CAN_INSTRUCTION = "test-instruction"
+
+
+@pytest.fixture
+def scene_export(tmp_path: Path) -> Path:
+ export = tmp_path / "scene_export"
+ assets = export / "meshes"
+ assets.mkdir(parents=True)
+ for name in ("table", "red_can", "blue_can"):
+ (assets / f"{name}.glb").write_bytes(f"mesh:{name}".encode())
+ config = {
+ "format": "embodichain.scene-export/v1",
+ "scene_id": "2026-03-18T10:20:30Z",
+ "background": [
+ {
+ "uid": "table",
+ "name": "table",
+ "description": "A work table.",
+ "category": "table",
+ "affordances": ["support_surface"],
+ "shape": {"shape_type": "Mesh", "fpath": "meshes/table.glb"},
+ "init_pos": [0.0, 0.0, 0.0],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ "rigid_object": [
+ {
+ "uid": f"{color}_can",
+ "name": f"{color} can",
+ "description": f"A {color} soda can.",
+ "category": "can",
+ "attributes": {
+ "color": color,
+ "geometry": {"position": [1.0, 2.0, 3.0]},
+ },
+ "affordances": ["graspable", "orientable", "placeable"],
+ "initial_state": {"orientation": "fallen"},
+ "shape": {
+ "shape_type": "Mesh",
+ "fpath": f"meshes/{color}_can.glb",
+ },
+ "init_pos": [0.0, offset, 0.7],
+ "init_rot": [0.0, 0.0, 90.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ for color, offset in (("red", 0.2), ("blue", -0.2))
+ ],
+ }
+ (export / "scene_config.json").write_text(json.dumps(config), encoding="utf-8")
+ return export
+
+
+def _legacy_gym_project(tmp_path: Path, filename: str) -> Path:
+ project = tmp_path / filename.removesuffix(".json")
+ assets = project / "assets"
+ assets.mkdir(parents=True)
+ for name in ("table.glb", "red_can.glb", "cabinet.urdf"):
+ (assets / name).write_bytes(f"asset:{name}".encode())
+ config = {
+ "background": [
+ {
+ "uid": "table_0",
+ "name": "table",
+ "description": "A work table.",
+ "category": "table",
+ "shape": {"shape_type": "Mesh", "fpath": "assets/table.glb"},
+ "init_pos": [0.0, 0.0, 0.0],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ "rigid_object": [
+ {
+ "uid": "red_can_0",
+ "name": "red can",
+ "description": "A red soda can.",
+ "category": "can",
+ "affordances": ["graspable", "orientable", "placeable"],
+ "initial_state": {"orientation": "fallen"},
+ "shape": {
+ "shape_type": "Mesh",
+ "fpath": "assets/red_can.glb",
+ },
+ "init_pos": [0.0, 0.2, 0.7],
+ "init_rot": [0.0, 0.0, 90.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ "articulation": [
+ {
+ "uid": "cabinet_0",
+ "name": "cabinet",
+ "description": "A fixed articulated cabinet.",
+ "category": "cabinet",
+ "fpath": "assets/cabinet.urdf",
+ "init_pos": [0.4, 0.0, 0.0],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ }
+ (project / filename).write_text(json.dumps(config), encoding="utf-8")
+ return project
+
+
+def _selector(reference: str) -> dict:
+ return {
+ "kind": "scene_ref",
+ "step_id": "",
+ "reference": reference,
+ "quantifier": "one",
+ "count": 0,
+ }
+
+
+def _none_selector() -> dict:
+ return {
+ "kind": "none",
+ "step_id": "",
+ "reference": "",
+ "quantifier": "one",
+ "count": 0,
+ }
+
+
+def _candidate(candidate_id: str, reference: str, *, votes: int = 1) -> dict:
+ step = {
+ "id": "upright",
+ "task_type": "E2",
+ "object": _selector(reference),
+ "target": _none_selector(),
+ "relation": "none",
+ "required_arm": "auto",
+ "transfer_arm": "none",
+ "receive_arm": "none",
+ "orientation_goal": "upright",
+ "target_state": "none",
+ "target_setting": 0,
+ "layout": "none",
+ "axis": "none",
+ "direction": "none",
+ "terminal_behavior": "none",
+ "depends_on": [],
+ }
+ draft = {
+ "schema_version": TASK_DRAFT_SCHEMA,
+ "task_id": "upright_can",
+ "instruction": _UPRIGHT_CAN_INSTRUCTION,
+ "steps": [step],
+ }
+ return {
+ "candidate_id": candidate_id,
+ "draft": draft,
+ "scene_request": {
+ "schema_version": SCENE_REQUEST_SCHEMA,
+ "task_id": "upright_can",
+ "references": [
+ {
+ "reference_id": "upright.object",
+ "step_id": "upright",
+ "role": "object",
+ "reference": reference,
+ "quantifier": "one",
+ "count": 0,
+ "source_structure": "rigid_object",
+ "affordances": ["graspable", "orientable"],
+ "initial_state": {"orientation": "fallen"},
+ "attributes": {},
+ }
+ ],
+ },
+ "success_spec": {
+ "schema_version": SUCCESS_SPEC_SCHEMA,
+ "task_id": "upright_can",
+ "op": "all",
+ "terms": [{"step_id": "upright", "type": "object_upright"}],
+ },
+ "semantic_hash": canonical_hash([step]),
+ "vote_count": votes,
+ "attempts": 1,
+ "normalizations": [],
+ }
+
+
+def _candidate_set(candidates: list[dict]) -> dict:
+ return {
+ "schema_version": TASK_CANDIDATE_SET_SCHEMA,
+ "task_id": "upright_can",
+ "instruction": _UPRIGHT_CAN_INSTRUCTION,
+ "candidates": candidates,
+ "requested_candidate_count": sum(item["vote_count"] for item in candidates),
+ "valid_response_count": sum(item["vote_count"] for item in candidates),
+ "errors": [],
+ }
+
+
+def _placement_candidate(candidate_id: str = "place") -> dict:
+ candidate = _candidate(candidate_id, "red can")
+ step = candidate["draft"]["steps"][0]
+ step.update(
+ {
+ "task_type": "E1",
+ "target": _selector("table"),
+ "relation": "on",
+ "orientation_goal": "preserve",
+ }
+ )
+ candidate["scene_request"]["references"] = [
+ {
+ "reference_id": "upright.object",
+ "step_id": "upright",
+ "role": "object",
+ "reference": "red can",
+ "quantifier": "one",
+ "count": 0,
+ "source_structure": "rigid_object",
+ "affordances": ["graspable", "placeable"],
+ "initial_state": {},
+ "attributes": {},
+ },
+ {
+ "reference_id": "upright.target",
+ "step_id": "upright",
+ "role": "target",
+ "reference": "table",
+ "quantifier": "one",
+ "count": 0,
+ "source_structure": "physical_entity",
+ "affordances": [],
+ "initial_state": {},
+ "attributes": {},
+ },
+ ]
+ candidate["success_spec"]["terms"] = [
+ {"step_id": "upright", "type": "semantic_goal"}
+ ]
+ candidate["semantic_hash"] = canonical_hash([step])
+ return candidate
+
+
+def _grounder(**kwargs) -> dict:
+ prompt = kwargs["prompt"]
+ uid = "blue_can" if '"reference": "blue can"' in prompt else "red_can"
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "resolved",
+ "uids": [uid],
+ "confidence": 0.95,
+ }
+ ]
+ }
+
+
+def test_scene_source_fingerprint_reads_without_copying(scene_export: Path) -> None:
+ before = sorted(path.relative_to(scene_export) for path in scene_export.rglob("*"))
+ fingerprint = fingerprint_scene_source(SceneSourceRef(scene_export))
+ after = sorted(path.relative_to(scene_export) for path in scene_export.rglob("*"))
+
+ assert fingerprint.config_path == scene_export / "scene_config.json"
+ assert len(fingerprint.config_sha256) == 64
+ assert len(fingerprint.asset_sha256) == 3
+ assert after == before
+
+
+@pytest.mark.parametrize("filename", ["gym_config.json", "gym_config_merged.json"])
+def test_scene_adapter_supports_legacy_gym_configs(
+ tmp_path: Path,
+ filename: str,
+) -> None:
+ project = _legacy_gym_project(tmp_path, filename)
+ result = SceneAdapter(grounding_caller=_grounder).adapt(
+ _candidate_set([_candidate("legacy", "red can")]),
+ project,
+ )
+
+ assert result.selected_candidate_id == "legacy"
+ assert result.static_scene_manifest["source_format"] == "legacy_gym_config"
+ assert any(
+ item["role"] == "articulation"
+ for item in result.static_scene_manifest["objects"]
+ )
+ assert (
+ result.prepared_scene.articulations[0]["fpath"]
+ == (project / "assets" / "cabinet.urdf").resolve().as_posix()
+ )
+
+
+def test_scene_source_fingerprint_covers_articulation_fpath(tmp_path: Path) -> None:
+ project = _legacy_gym_project(tmp_path, "gym_config.json")
+ original = fingerprint_scene_source(project)
+ articulation_path = project / "assets" / "cabinet.urdf"
+
+ articulation_path.write_bytes(b"changed articulation")
+ changed = fingerprint_scene_source(project)
+
+ assert articulation_path.resolve().as_posix() in original.asset_sha256
+ assert changed.asset_sha256 != original.asset_sha256
+ with pytest.raises(RuntimeError, match="changed after Task Engine preparation"):
+ verify_scene_source_fingerprint(original.to_dict())
+
+
+def test_scene_adapter_selects_bindable_majority_and_redacts_manifest(
+ scene_export: Path,
+) -> None:
+ red = _candidate("red-majority", "red can", votes=2)
+ blue = _candidate("blue-minority", "blue can")
+ result = SceneAdapter(grounding_caller=_grounder).adapt(
+ _candidate_set([red, blue]),
+ scene_export,
+ )
+
+ hierarchy_by_uid = {
+ node["uid"]: node for node in result.conservative_scene_graph["nodes"]
+ }
+ assert hierarchy_by_uid["red_can"]["parent_uid"] == "unknown"
+ assert hierarchy_by_uid["red_can"]["parent_relation"] == "unknown"
+
+ assert result.binding_report["status"] == "bound"
+ assert result.binding_report["candidates"][0]["status"] == "resolved"
+ assert result.selected_candidate_id == "red-majority"
+ assert result.reference_bindings == {"upright.object": ["red_can"]}
+ assert result.role_bindings["role_bindings"] == {}
+ red_manifest = next(
+ item for item in result.scene_manifest["objects"] if item["uid"] == "red_can"
+ )
+ assert "position" not in json.dumps(red_manifest)
+ assert (
+ result.prepared_scene.source_config_path == scene_export / "scene_config.json"
+ )
+ assert result.static_scene_manifest is not None
+ static_by_uid = {
+ item["uid"]: item for item in result.static_scene_manifest["objects"]
+ }
+ assert static_by_uid["red_can"]["physics"]["body_type"] == "dynamic"
+ assert static_by_uid["red_can"]["geometry"]["asset_sha256"]
+
+
+def test_scene_adapter_returns_report_for_business_level_non_binding(
+ scene_export: Path,
+) -> None:
+ candidate = _candidate("missing", "green can")
+
+ def not_found(**_kwargs):
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "not_found",
+ "uids": [],
+ "confidence": 0.0,
+ }
+ ]
+ }
+
+ result = SceneAdapter(grounding_caller=not_found).adapt(
+ _candidate_set([candidate]),
+ scene_export,
+ )
+
+ assert result.selected_candidate is None
+ assert result.role_bindings is None
+ assert result.binding_report["status"] == "unsatisfied"
+ assert (
+ result.binding_report["candidates"][0]["references"][0]["status"] == "not_found"
+ )
+
+
+def test_semantic_blueprint_selection_forces_ranked_low_confidence_uid() -> None:
+ candidate = _candidate("likely", "the can")
+ scene_objects = [
+ {
+ "uid": "table",
+ "role": "table",
+ "name": "table",
+ "description": "A work table.",
+ "category": "table",
+ "init_pos": [0.0, 0.0, 0.0],
+ "affordances": ["support_surface"],
+ "initial_state": {},
+ "attributes": {},
+ },
+ *[
+ {
+ "uid": f"{color}_can",
+ "role": "rigid_object",
+ "name": f"{color} can",
+ "description": f"A {color} can.",
+ "category": "can",
+ "init_pos": [0.0, offset, 0.1],
+ "affordances": ["graspable", "orientable"],
+ "initial_state": {"orientation": "fallen"},
+ "attributes": {"color": color},
+ }
+ for color, offset in (("red", -0.1), ("blue", 0.1))
+ ],
+ ]
+
+ def ambiguous(**_kwargs):
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "ambiguous",
+ "uids": ["red_can", "blue_can"],
+ "confidence": 0.2,
+ }
+ ]
+ }
+
+ result = SceneAdapter(grounding_caller=ambiguous).select_objects(
+ _candidate_set([candidate]),
+ scene_objects,
+ force_most_likely=True,
+ )
+
+ assert result.selected_candidate_id == "likely"
+ assert result.role_bindings["reference_bindings"] == {"upright.object": ["red_can"]}
+ reference = result.binding_report["candidates"][0]["references"][0]
+ assert reference["confidence"] == 0.2
+ assert reference["candidate_uids"] == ["red_can", "blue_can"]
+ assert reference["selected_uids"] == ["red_can"]
+ assert reference["reasons"] == [
+ "Forced the highest-ranked structurally compatible UID from an "
+ "ambiguous low-confidence response."
+ ]
+
+
+def test_scene_adapter_uses_unique_bindable_then_injected_adjudication(
+ scene_export: Path,
+) -> None:
+ red = _candidate("red", "red can")
+ blue = _candidate("blue", "blue can")
+
+ def one_missing(**kwargs):
+ if '"reference": "red can"' in kwargs["prompt"]:
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "not_found",
+ "uids": [],
+ "confidence": 0.0,
+ }
+ ]
+ }
+ return _grounder(**kwargs)
+
+ unique = SceneAdapter(grounding_caller=one_missing).adapt(
+ _candidate_set([red, blue]),
+ scene_export,
+ )
+ assert unique.selected_candidate_id == "blue"
+ assert unique.binding_report["selection_reason"] == "unique_bindable"
+
+ ambiguous = SceneAdapter(grounding_caller=_grounder).adapt(
+ _candidate_set([red, blue]),
+ scene_export,
+ )
+ assert ambiguous.binding_report["status"] == "ambiguous"
+
+ adjudicated = SceneAdapter(
+ grounding_caller=_grounder,
+ adjudicator=lambda **_kwargs: {"candidate_id": "blue"},
+ ).adapt(_candidate_set([red, blue]), scene_export)
+ assert adjudicated.selected_candidate_id == "blue"
+ assert adjudicated.binding_report["selection_reason"] == "adjudicated_bindable"
+
+
+def test_scene_adapter_runs_one_default_structured_adjudication(
+ scene_export: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ adjudications = 0
+
+ def caller(**kwargs):
+ nonlocal adjudications
+ if kwargs["schema"]["title"] == "ActionEngineTaskAdjudication":
+ adjudications += 1
+ return {"candidate_id": "blue"}
+ return _grounder(**kwargs)
+
+ monkeypatch.setattr(
+ scene_adapter_module, "_default_grounding_caller", lambda: caller
+ )
+ result = SceneAdapter().adapt(
+ _candidate_set([_candidate("red", "red can"), _candidate("blue", "blue can")]),
+ scene_export,
+ )
+
+ assert result.selected_candidate_id == "blue"
+ assert result.binding_report["selection_reason"] == "adjudicated_bindable"
+ assert adjudications == 1
+
+
+def test_scene_adapter_accepts_direct_source_and_rejects_bad_protocol(
+ scene_export: Path,
+) -> None:
+ candidate = _candidate("red", "red can")
+ direct = SceneAdapter(grounding_caller=_grounder).adapt(
+ _candidate_set([candidate]),
+ scene_export,
+ )
+ result = SceneAdapter(grounding_caller=_grounder).adapt(
+ _candidate_set([candidate]),
+ SceneSourceRef(scene_export),
+ )
+ assert result.scene_manifest == direct.scene_manifest
+ assert result.role_bindings == direct.role_bindings
+
+ with pytest.raises(SceneAdapterProtocolError, match="unsupported fields"):
+ SceneAdapter(
+ grounding_caller=lambda **_kwargs: {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "not_found",
+ "uids": [],
+ "confidence": 0.0,
+ "invented": True,
+ }
+ ]
+ }
+ ).adapt(_candidate_set([candidate]), scene_export)
+
+
+def test_explicit_scene_semantic_conflict_is_incompatible(
+ scene_export: Path,
+) -> None:
+ config_path = scene_export / "scene_config.json"
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ config["rigid_object"][0]["initial_state"]["orientation"] = "upright"
+ config_path.write_text(json.dumps(config), encoding="utf-8")
+
+ result = SceneAdapter(grounding_caller=_grounder).adapt(
+ _candidate_set([_candidate("red", "red can")]),
+ scene_export,
+ )
+ reference = result.binding_report["candidates"][0]["references"][0]
+ assert result.binding_report["status"] == "unsatisfied"
+ assert reference["status"] == "incompatible"
+ assert result.binding_report["candidates"][0]["status"] == "incompatible"
+ assert "state 'orientation' conflicts" in reference["reasons"][0]
+
+
+def test_scene_adapter_accepts_passive_support_target_and_rejects_self_reference(
+ scene_export: Path,
+) -> None:
+ candidate = _placement_candidate()
+
+ def place_on_table(**_kwargs):
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "resolved",
+ "uids": ["red_can"],
+ "confidence": 0.95,
+ },
+ {
+ "reference_id": "upright.target",
+ "status": "resolved",
+ "uids": ["table"],
+ "confidence": 0.95,
+ },
+ ]
+ }
+
+ bound = SceneAdapter(grounding_caller=place_on_table).adapt(
+ _candidate_set([candidate]), scene_export
+ )
+ assert bound.binding_report["status"] == "bound"
+ assert bound.reference_bindings["upright.target"] == ["table"]
+
+ def self_reference(**_kwargs):
+ response = place_on_table()
+ response["bindings"][1]["uids"] = ["red_can"]
+ return response
+
+ incompatible = SceneAdapter(grounding_caller=self_reference).adapt(
+ _candidate_set([candidate]), scene_export
+ )
+ assert incompatible.binding_report["status"] == "unsatisfied"
+ assert incompatible.binding_report["candidates"][0]["status"] == "incompatible"
+
+
+def test_scene_adapter_enforces_count_cardinality_in_audit(
+ scene_export: Path,
+) -> None:
+ candidate = _candidate("two", "cans")
+ selector = candidate["draft"]["steps"][0]["object"]
+ selector.update(quantifier="count", count=2)
+ request = candidate["scene_request"]["references"][0]
+ request.update(reference="cans", quantifier="count", count=2)
+ candidate["semantic_hash"] = canonical_hash(candidate["draft"]["steps"])
+
+ def one_only(**_kwargs):
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "resolved",
+ "uids": ["red_can"],
+ "confidence": 0.95,
+ }
+ ]
+ }
+
+ result = SceneAdapter(grounding_caller=one_only).adapt(
+ _candidate_set([candidate]), scene_export
+ )
+
+ assert result.binding_report["status"] == "unsatisfied"
+ audit = result.binding_report["candidates"][0]
+ assert audit["status"] == "incompatible"
+ assert "requires exactly 2 UIDs" in audit["references"][0]["reasons"][0]
+
+
+def test_scene_adapter_binds_all_matching_uids(
+ scene_export: Path,
+) -> None:
+ candidate = _candidate("all", "all cans")
+ candidate["draft"]["steps"][0]["object"].update(quantifier="all")
+ candidate["scene_request"] = derive_scene_request(candidate["draft"])
+ candidate["semantic_hash"] = canonical_hash(candidate["draft"]["steps"])
+
+ def all_cans(**_kwargs):
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "resolved",
+ "uids": ["red_can", "blue_can"],
+ "confidence": 0.95,
+ }
+ ]
+ }
+
+ result = SceneAdapter(grounding_caller=all_cans).adapt(
+ _candidate_set([candidate]), scene_export
+ )
+
+ assert result.binding_report["status"] == "bound"
+ assert result.reference_bindings == {"upright.object": ["red_can", "blue_can"]}
+ assert result.candidate_bindings[candidate["candidate_id"]][
+ "reference_bindings"
+ ] == {"upright.object": ["red_can", "blue_can"]}
+
+
+def test_scene_adapter_rejects_step_result_object_matching_same_step_target(
+ scene_export: Path,
+) -> None:
+ config_path = scene_export / "scene_config.json"
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ config["rigid_object"][0]["affordances"].append("support_surface")
+ config_path.write_text(json.dumps(config), encoding="utf-8")
+
+ candidate = _candidate("self-reference", "red can")
+ second = deepcopy(candidate["draft"]["steps"][0])
+ second.update(
+ {
+ "id": "place_again",
+ "task_type": "E1",
+ "object": {
+ "kind": "step_result",
+ "step_id": "upright",
+ "reference": "",
+ "quantifier": "one",
+ "count": 0,
+ },
+ "target": _selector("red can"),
+ "relation": "on",
+ "orientation_goal": "preserve",
+ "depends_on": ["upright"],
+ }
+ )
+ candidate["draft"]["steps"].append(second)
+ candidate["scene_request"] = derive_scene_request(candidate["draft"])
+ candidate["success_spec"] = derive_success_spec(candidate["draft"])
+ candidate["semantic_hash"] = canonical_hash(candidate["draft"]["steps"])
+
+ def same_uid(**_kwargs):
+ return {
+ "bindings": [
+ {
+ "reference_id": "upright.object",
+ "status": "resolved",
+ "uids": ["red_can"],
+ "confidence": 0.95,
+ },
+ {
+ "reference_id": "place_again.target",
+ "status": "resolved",
+ "uids": ["red_can"],
+ "confidence": 0.95,
+ },
+ ]
+ }
+
+ result = SceneAdapter(grounding_caller=same_uid).adapt(
+ _candidate_set([candidate]), scene_export
+ )
+
+ assert result.binding_report["status"] == "unsatisfied"
+ target_audit = result.binding_report["candidates"][0]["references"][1]
+ assert target_audit["status"] == "incompatible"
+ assert "same UID as object and target" in target_audit["reasons"][0]
+
+
+def test_scene_source_fingerprint_covers_assets_and_config(scene_export: Path) -> None:
+ original = fingerprint_scene_source(scene_export)
+
+ asset_path = scene_export / "meshes" / "red_can.glb"
+ asset_path.write_bytes(b"changed asset")
+ changed_asset = fingerprint_scene_source(scene_export)
+ assert changed_asset.asset_sha256 != original.asset_sha256
+
+ config_path = scene_export / "scene_config.json"
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ config["rigid_object"][0]["body_scale"] = [1.1, 1.0, 1.0]
+ config["rigid_object"][0]["physics"] = {"mass": 0.25}
+ config_path.write_text(json.dumps(config), encoding="utf-8")
+ changed_config = fingerprint_scene_source(scene_export)
+ assert changed_config.config_sha256 != changed_asset.config_sha256
+
+
+def test_scene_source_verification_rejects_later_mutation(scene_export: Path) -> None:
+ expected = fingerprint_scene_source(scene_export).to_dict()
+ (scene_export / "meshes" / "red_can.glb").write_bytes(b"changed later")
+
+ with pytest.raises(RuntimeError, match="changed after Task Engine preparation"):
+ verify_scene_source_fingerprint(expected)
diff --git a/tests/gen_sim/task_engine/scene/__init__.py b/tests/gen_sim/task_engine/scene/__init__.py
new file mode 100644
index 000000000..b1ffd4df2
--- /dev/null
+++ b/tests/gen_sim/task_engine/scene/__init__.py
@@ -0,0 +1,17 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Tests for Task Engine scene adaptation boundaries."""
diff --git a/tests/gen_sim/task_engine/scene/test_final_inspection.py b/tests/gen_sim/task_engine/scene/test_final_inspection.py
new file mode 100644
index 000000000..937a075d9
--- /dev/null
+++ b/tests/gen_sim/task_engine/scene/test_final_inspection.py
@@ -0,0 +1,118 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+import trimesh
+
+from embodichain.gen_sim.task_engine.orchestration.scene_source import (
+ scene_revision_id,
+)
+from embodichain.gen_sim.task_engine.scene.final_inspection import (
+ inspect_final_scene,
+)
+
+
+def _scene_export(root: Path, *, scene_id: str, can_rotation: list[float]) -> Path:
+ export = root / "scene_export"
+ assets = export / "mesh_assets"
+ assets.mkdir(parents=True)
+ trimesh.creation.box(extents=[1.0, 0.1, 1.0]).export(
+ assets / "table.glb", file_type="glb"
+ )
+ can = trimesh.creation.cylinder(radius=0.04, height=0.2)
+ can.apply_transform(
+ trimesh.transformations.rotation_matrix(np.pi / 2.0, [1.0, 0.0, 0.0])
+ )
+ can.export(assets / "can.glb", file_type="glb")
+ config = {
+ "format": "embodichain.scene-export/v1",
+ "scene_id": scene_id,
+ "background": [
+ {
+ "uid": "table",
+ "name": "table",
+ "description": "A support table.",
+ "category": "table",
+ "shape": {"shape_type": "Mesh", "fpath": "mesh_assets/table.glb"},
+ "init_pos": [0.0, 0.0, 0.0],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ "rigid_object": [
+ {
+ "uid": "can",
+ "name": "red can",
+ "description": "A red can.",
+ "category": "can",
+ "shape": {"shape_type": "Mesh", "fpath": "mesh_assets/can.glb"},
+ "init_pos": [0.0, 0.0, 0.15],
+ "init_rot": can_rotation,
+ "body_scale": [1.0, 1.0, 1.0],
+ }
+ ],
+ }
+ path = export / "scene_config.json"
+ path.write_text(json.dumps(config), encoding="utf-8")
+ return path
+
+
+def test_scene_revision_id_ignores_exporter_timestamp_and_location(
+ tmp_path: Path,
+) -> None:
+ first = _scene_export(
+ tmp_path / "first", scene_id="scene-100", can_rotation=[0, 0, 0]
+ )
+ second = _scene_export(
+ tmp_path / "second", scene_id="scene-200", can_rotation=[0, 0, 0]
+ )
+
+ assert scene_revision_id(first) == scene_revision_id(second)
+
+ value = json.loads(second.read_text(encoding="utf-8"))
+ value["rigid_object"][0]["init_pos"][0] = 0.25
+ second.write_text(json.dumps(value), encoding="utf-8")
+ assert scene_revision_id(first) != scene_revision_id(second)
+
+
+def test_final_inspection_recomputes_support_and_orientation(tmp_path: Path) -> None:
+ source = _scene_export(
+ tmp_path / "standing", scene_id="scene", can_rotation=[0.0, 0.0, 0.0]
+ )
+
+ inspection = inspect_final_scene(source, revision_id=scene_revision_id(source))
+
+ can = next(item for item in inspection["objects"] if item["uid"] == "can")
+ assert can["orientation"] == "standing"
+ assert can["support"]["parent_uid"] == "table"
+ assert can["support"]["relation"] == "on"
+ assert can["support"]["xy_overlap_ratio"] > 0.9
+
+
+def test_final_inspection_detects_lying_rotation(tmp_path: Path) -> None:
+ source = _scene_export(
+ tmp_path / "lying", scene_id="scene", can_rotation=[90.0, 0.0, 0.0]
+ )
+
+ inspection = inspect_final_scene(source, revision_id=scene_revision_id(source))
+
+ can = next(item for item in inspection["objects"] if item["uid"] == "can")
+ assert can["orientation"] == "lying"
diff --git a/tests/gen_sim/task_engine/scene/test_scene_boundary.py b/tests/gen_sim/task_engine/scene/test_scene_boundary.py
new file mode 100644
index 000000000..21b1e0273
--- /dev/null
+++ b/tests/gen_sim/task_engine/scene/test_scene_boundary.py
@@ -0,0 +1,534 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from copy import deepcopy
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from embodichain.gen_sim.task_engine.scene import (
+ FeasibilityBroker,
+ SceneEngineV1Adapter,
+)
+from embodichain.gen_sim.task_engine.agent import derive_scene_request
+from embodichain.gen_sim.task_engine.contracts import TASK_DRAFT_SCHEMA
+
+
+def _prepared_scene(tmp_path: Path) -> SimpleNamespace:
+ table = {
+ "uid": "table",
+ "source_uid": "table_0",
+ "role": "background",
+ "name": "table",
+ "description": "A support table.",
+ "category": "table",
+ "color": "brown",
+ "shape": {"shape_type": "Mesh", "fpath": "/assets/table.glb"},
+ "init_pos": [0.0, 0.0, 0.0],
+ "init_rot": [0.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ "attributes": {},
+ "initial_state": {},
+ "affordances": [],
+ }
+ can = {
+ "uid": "red_can",
+ "source_uid": "red_can_0",
+ "role": "rigid_object",
+ "name": "red can",
+ "description": "A fallen red can.",
+ "category": "can",
+ "color": "red",
+ "shape": {"shape_type": "Mesh", "fpath": "/assets/can.glb"},
+ "init_pos": [0.1, 0.0, 0.7],
+ "init_rot": [90.0, 0.0, 0.0],
+ "body_scale": [1.0, 1.0, 1.0],
+ "attributes": {},
+ "initial_state": {"orientation": "fallen"},
+ "affordances": ["graspable", "orientable", "placeable"],
+ }
+ runtime_table = {
+ "uid": "table",
+ "shape": table["shape"],
+ "attrs": {"mass": 10.0},
+ "body_type": "kinematic",
+ }
+ runtime_can = {
+ "uid": "red_can",
+ "shape": can["shape"],
+ "attrs": {"mass": 0.1},
+ "body_type": "dynamic",
+ }
+ return SimpleNamespace(
+ source_config_path=tmp_path / "scene_config.json",
+ planner_objects=(table, can),
+ background=(runtime_table,),
+ rigid_objects=(runtime_can,),
+ articulations=(),
+ asset_hashes={"table": "a" * 64, "red_can": "b" * 64},
+ )
+
+
+def _candidate(task_type: str, affordances: list[str]) -> dict:
+ return {
+ "candidate_id": "candidate_01",
+ "draft": {
+ "task_id": "task",
+ "steps": [{"id": "step_01", "task_type": task_type}],
+ },
+ "scene_request": {
+ "references": [
+ {
+ "reference_id": "step_01.object",
+ "role": "object",
+ "source_structure": "rigid_object",
+ "affordances": affordances,
+ "initial_state": (
+ {"orientation": "fallen"} if task_type == "E2" else {}
+ ),
+ "attributes": {},
+ }
+ ]
+ },
+ }
+
+
+def _catalog(*, pour_available: bool = False) -> dict[str, dict]:
+ return {
+ name: {"runtime_available": True, "unavailable_reason": None}
+ for name in ("PickUp", "MoveHeldObject", "Place")
+ } | {
+ "Pour": {
+ "runtime_available": pour_available,
+ "unavailable_reason": None if pour_available else "Pour is planning-only.",
+ }
+ }
+
+
+def _selector(kind: str, *, reference: str = "") -> dict[str, object]:
+ return {
+ "kind": kind,
+ "step_id": "",
+ "reference": reference,
+ "quantifier": "one",
+ "count": 0,
+ }
+
+
+def _relation_candidate(relation: str) -> dict:
+ draft = {
+ "schema_version": TASK_DRAFT_SCHEMA,
+ "task_id": "place_relative",
+ "instruction": "place the can relative to the target",
+ "steps": [
+ {
+ "id": "step_01",
+ "task_type": "E1",
+ "object": _selector("scene_ref", reference="red can"),
+ "target": _selector("scene_ref", reference="target"),
+ "relation": relation,
+ "required_arm": "auto",
+ "transfer_arm": "none",
+ "receive_arm": "none",
+ "orientation_goal": "preserve",
+ "target_state": "none",
+ "target_setting": 0,
+ "layout": "none",
+ "axis": "none",
+ "direction": "none",
+ "terminal_behavior": "none",
+ "depends_on": [],
+ }
+ ],
+ }
+ return {
+ "candidate_id": "candidate_01",
+ "draft": draft,
+ "scene_request": derive_scene_request(draft),
+ }
+
+
+def _manifest_with_target_kinds(tmp_path: Path) -> dict:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ by_uid = {item["uid"]: item for item in manifest["objects"]}
+ by_uid["red_can"]["affordances"].append(
+ {
+ "type": "container",
+ "status": "declared",
+ "confidence": None,
+ "source": "test",
+ "link_uid": "",
+ "frame": {},
+ "parameters": {},
+ }
+ )
+ articulation = deepcopy(by_uid["red_can"])
+ articulation.update(
+ uid="cabinet",
+ source_uid="cabinet_0",
+ role="articulation",
+ name="cabinet",
+ category="cabinet",
+ physics={},
+ articulation={"runtime_uid": "cabinet"},
+ affordances=[],
+ )
+ manifest["objects"].append(articulation)
+ return manifest
+
+
+def test_scene_engine_v1_adapter_preserves_static_execution_evidence(
+ tmp_path: Path,
+) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="embodichain.scene-export/v1",
+ robot_profile="dual_franka",
+ )
+
+ by_uid = {item["uid"]: item for item in manifest["objects"]}
+ assert manifest["adapter_capabilities"]["task_conditioned_generation"] is False
+ assert by_uid["red_can"]["geometry"]["asset_sha256"] == "b" * 64
+ assert by_uid["red_can"]["physics"]["body_type"] == "dynamic"
+ assert {item["type"] for item in by_uid["table"]["affordances"]} == {
+ "support_surface"
+ }
+ assert (
+ next(
+ item
+ for item in by_uid["red_can"]["affordances"]
+ if item["type"] == "graspable"
+ )["status"]
+ == "declared"
+ )
+
+
+def test_e2_feasibility_requires_runtime_probe_for_geometry(tmp_path: Path) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ report = FeasibilityBroker().assess(
+ _candidate("E2", ["graspable", "orientable"]),
+ {"step_01.object": ["red_can"]},
+ manifest,
+ capability_catalog=_catalog(),
+ task_actions={"E2": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ assert report["status"] == "runtime_probe"
+ assert report["remediation_class"] == "none"
+ assert report["blockers"] == []
+ assert report["summary"]["proven"] > 0
+ assert report["summary"]["runtime_probe"] > 0
+
+
+def test_planning_only_action_is_reported_as_contradicted(tmp_path: Path) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ report = FeasibilityBroker().assess(
+ _candidate("E3", ["graspable", "pourable"]),
+ {"step_01.object": ["red_can"]},
+ manifest,
+ capability_catalog=_catalog(),
+ task_actions={"E3": ("Pour",)},
+ )
+
+ assert report["status"] == "contradicted"
+ assert report["remediation_class"] == "action_capability"
+ assert any("planning-only" in blocker for blocker in report["blockers"])
+
+
+def test_final_orientation_conflict_is_scene_remediable(tmp_path: Path) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ can = next(item for item in manifest["objects"] if item["uid"] == "red_can")
+ can["initial_state"]["orientation"] = "upright"
+
+ report = FeasibilityBroker().assess(
+ _candidate("E2", ["graspable", "orientable"]),
+ {"step_01.object": ["red_can"]},
+ manifest,
+ capability_catalog=_catalog(),
+ task_actions={"E2": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ assert report["status"] == "contradicted"
+ assert report["remediation_class"] == "scene_remediable"
+
+
+def test_missing_affordance_remains_unknown_instead_of_becoming_supported(
+ tmp_path: Path,
+) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ report = FeasibilityBroker().assess(
+ _candidate("E1", ["graspable", "liquid_safe"]),
+ {"step_01.object": ["red_can"]},
+ manifest,
+ capability_catalog=_catalog(),
+ task_actions={"E1": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ assert report["status"] == "unknown"
+ assert any(
+ check["status"] == "unknown" and "liquid_safe" in check["reason"]
+ for check in report["checks"]
+ )
+
+
+def test_physical_object_can_be_a_runtime_support_without_support_affordance(
+ tmp_path: Path,
+) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ candidate = _candidate("E1", ["graspable", "placeable"])
+ candidate["draft"]["steps"][0].update(
+ target={"kind": "scene_ref"},
+ relation="on",
+ )
+ candidate["scene_request"]["references"].append(
+ {
+ "reference_id": "step_01.target",
+ "role": "target",
+ "source_structure": "physical_entity",
+ "affordances": [],
+ "initial_state": {},
+ "attributes": {},
+ }
+ )
+
+ report = FeasibilityBroker().assess(
+ candidate,
+ {
+ "step_01.object": ["red_can"],
+ "step_01.target": ["red_can"],
+ },
+ manifest,
+ capability_catalog=_catalog(),
+ task_actions={"E1": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ structure = next(
+ check
+ for check in report["checks"]
+ if check["kind"] == "structure" and check["subject"] == "step_01.target:red_can"
+ )
+ assert structure["status"] == "proven"
+ support_probe = next(
+ check for check in report["checks"] if check["kind"] == "placement_support"
+ )
+ assert support_probe["status"] == "runtime_probe"
+ assert support_probe["evidence"]["runtime_obligations"] == [
+ "placement_candidates",
+ "object_supported_by",
+ "stable_for",
+ "final_support_revalidation",
+ ]
+ assert report["blockers"] == []
+
+
+@pytest.mark.parametrize(
+ ("relation", "target_uid", "expected_structure", "expected_status"),
+ [
+ ("on", "red_can", "physical_entity", "proven"),
+ ("on", "table", "physical_entity", "proven"),
+ ("on", "cabinet", "physical_entity", "contradicted"),
+ ("inside", "red_can", "rigid_object", "proven"),
+ ("inside", "table", "rigid_object", "contradicted"),
+ ("inside", "cabinet", "rigid_object", "contradicted"),
+ ("behind", "red_can", "spatial_reference", "proven"),
+ ("behind", "table", "spatial_reference", "proven"),
+ ("behind", "cabinet", "spatial_reference", "runtime_probe"),
+ ("front_of", "red_can", "spatial_reference", "proven"),
+ ("front_of", "table", "spatial_reference", "proven"),
+ ("front_of", "cabinet", "spatial_reference", "runtime_probe"),
+ ("left_of", "red_can", "spatial_reference", "proven"),
+ ("left_of", "table", "spatial_reference", "proven"),
+ ("left_of", "cabinet", "spatial_reference", "runtime_probe"),
+ ("right_of", "red_can", "spatial_reference", "proven"),
+ ("right_of", "table", "spatial_reference", "proven"),
+ ("right_of", "cabinet", "spatial_reference", "runtime_probe"),
+ ],
+)
+def test_relation_target_structure_matrix_uses_capability_semantics(
+ tmp_path: Path,
+ relation: str,
+ target_uid: str,
+ expected_structure: str,
+ expected_status: str,
+) -> None:
+ candidate = _relation_candidate(relation)
+ target_request = next(
+ item
+ for item in candidate["scene_request"]["references"]
+ if item["role"] == "target"
+ )
+ report = FeasibilityBroker().assess(
+ candidate,
+ {
+ "step_01.object": ["red_can"],
+ "step_01.target": [target_uid],
+ },
+ _manifest_with_target_kinds(tmp_path),
+ capability_catalog=_catalog(),
+ task_actions={"E1": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ structure = next(
+ check
+ for check in report["checks"]
+ if check["kind"] == "structure"
+ and check["subject"] == f"step_01.target:{target_uid}"
+ )
+ assert target_request["source_structure"] == expected_structure
+ assert structure["status"] == expected_status
+
+
+def test_legacy_scene_entity_target_is_treated_as_an_abstract_structure(
+ tmp_path: Path,
+) -> None:
+ candidate = _relation_candidate("behind")
+ target_request = next(
+ item
+ for item in candidate["scene_request"]["references"]
+ if item["role"] == "target"
+ )
+ target_request["source_structure"] = "scene_entity"
+
+ report = FeasibilityBroker().assess(
+ candidate,
+ {
+ "step_01.object": ["red_can"],
+ "step_01.target": ["red_can"],
+ },
+ _manifest_with_target_kinds(tmp_path),
+ capability_catalog=_catalog(),
+ task_actions={"E1": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ structure = next(
+ check
+ for check in report["checks"]
+ if check["kind"] == "structure" and check["subject"] == "step_01.target:red_can"
+ )
+ assert structure["status"] == "proven"
+ assert report["blockers"] == []
+
+
+def test_unknown_structure_contract_is_not_a_scene_contradiction(
+ tmp_path: Path,
+) -> None:
+ candidate = _relation_candidate("behind")
+ target_request = next(
+ item
+ for item in candidate["scene_request"]["references"]
+ if item["role"] == "target"
+ )
+ target_request["source_structure"] = "future_spatial_capability"
+
+ report = FeasibilityBroker().assess(
+ candidate,
+ {
+ "step_01.object": ["red_can"],
+ "step_01.target": ["red_can"],
+ },
+ _manifest_with_target_kinds(tmp_path),
+ capability_catalog=_catalog(),
+ task_actions={"E1": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ structure = next(
+ check
+ for check in report["checks"]
+ if check["kind"] == "structure" and check["subject"] == "step_01.target:red_can"
+ )
+ assert structure["status"] == "unknown"
+ assert not any("future_spatial_capability" in item for item in report["blockers"])
+
+
+def test_required_arm_side_requires_the_live_robot_frame(
+ tmp_path: Path,
+) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ red_can = next(item for item in manifest["objects"] if item["uid"] == "red_can")
+ red_can["initial_pose"]["position"][1] = -0.20
+ candidate = _candidate("E2", ["graspable", "orientable"])
+ candidate["draft"]["steps"][0]["required_arm"] = "right_arm"
+
+ report = FeasibilityBroker().assess(
+ candidate,
+ {"step_01.object": ["red_can"]},
+ manifest,
+ capability_catalog=_catalog(),
+ task_actions={"E2": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ probe = next(
+ check for check in report["checks"] if check["kind"] == "arm_layout_risk"
+ )
+ assert probe["status"] == "runtime_probe"
+ assert probe["evidence"]["arm_side_frame"] == "live_robot"
+ assert probe["evidence"]["mismatch_risk"] is None
+ assert "expected_arm" not in probe["evidence"]
+ assert probe["evidence"]["geometry_certificate"] is False
+ assert report["blockers"] == []
+
+
+def test_workspace_report_covers_complete_task_phases(tmp_path: Path) -> None:
+ manifest = SceneEngineV1Adapter().adapt_prepared_scene(
+ _prepared_scene(tmp_path),
+ source_format="test",
+ robot_profile="dual_franka",
+ )
+ report = FeasibilityBroker().assess(
+ _candidate("E2", ["graspable", "orientable"]),
+ {"step_01.object": ["red_can"]},
+ manifest,
+ capability_catalog=_catalog(),
+ task_actions={"E2": ("PickUp", "MoveHeldObject", "Place")},
+ )
+
+ workflow = next(
+ check for check in report["checks"] if check["kind"] == "task_workspace"
+ )
+ phases = {item["phase"] for item in workflow["evidence"]["phases"]}
+ assert phases == {"pickup", "safety_clearance"}
+ assert workflow["status"] == "runtime_probe"
diff --git a/tests/gen_sim/task_engine/test_agent.py b/tests/gen_sim/task_engine/test_agent.py
new file mode 100644
index 000000000..cb6ab7cb2
--- /dev/null
+++ b/tests/gen_sim/task_engine/test_agent.py
@@ -0,0 +1,271 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from copy import deepcopy
+import threading
+from time import sleep
+
+import pytest
+
+from embodichain.gen_sim.task_engine.contracts import (
+ SUCCESS_SPEC_SCHEMA,
+ TASK_DRAFT_SCHEMA,
+ validate_success_spec,
+ validate_task_candidate,
+ validate_task_draft,
+)
+from embodichain.gen_sim.task_engine.agent import (
+ TaskAgent,
+ TaskGenerationError,
+ derive_scene_request,
+ derive_success_spec,
+)
+from embodichain.gen_sim.task_engine.interpretation import InstructionDraftResult
+from embodichain.gen_sim.task_engine.orchestration.coordinator import (
+ lower_task_candidate,
+)
+
+_TEST_INSTRUCTION = "test-instruction"
+
+
+def _selector(kind="none", *, step_id="", reference="", quantifier="one", count=0):
+ return {
+ "kind": kind,
+ "step_id": step_id,
+ "reference": reference,
+ "quantifier": quantifier,
+ "count": count,
+ }
+
+
+def _step(step_id="orient", reference="purple can"):
+ return {
+ "id": step_id,
+ "task_type": "E2",
+ "object": _selector("scene_ref", reference=reference),
+ "target": _selector(),
+ "relation": "none",
+ "required_arm": "auto",
+ "transfer_arm": "none",
+ "receive_arm": "none",
+ "orientation_goal": "upright",
+ "target_state": "none",
+ "target_setting": 0,
+ "layout": "none",
+ "axis": "none",
+ "direction": "none",
+ "terminal_behavior": "none",
+ "depends_on": [],
+ }
+
+
+def _result(step):
+ return InstructionDraftResult(
+ intent={"steps": [deepcopy(step)]},
+ model="injected_caller",
+ attempts=1,
+ latency_seconds=0.01,
+ normalizations=(),
+ )
+
+
+def test_task_agent_generates_concurrently_deduplicates_and_counts_votes():
+ barrier = threading.Barrier(3)
+ lock = threading.Lock()
+ assigned = 0
+
+ def interpreter(_instruction, **_kwargs):
+ nonlocal assigned
+ with lock:
+ index = assigned
+ assigned += 1
+ barrier.wait(timeout=2)
+ sleep(0.01)
+ if index < 2:
+ return _result(_step(step_id=f"arbitrary_{index}"))
+ return _result(_step(step_id="different", reference="orange can"))
+
+ result = TaskAgent(interpreter=interpreter).generate("task", _TEST_INSTRUCTION)
+
+ assert result["requested_candidate_count"] == 3
+ assert result["valid_response_count"] == 3
+ assert len(result["candidates"]) == 2
+ assert sorted(item["vote_count"] for item in result["candidates"]) == [1, 2]
+ assert {item["draft"]["steps"][0]["id"] for item in result["candidates"]} == {
+ "step_01"
+ }
+
+
+def test_scene_request_and_success_are_deterministic_contract_derivations():
+ draft = {
+ "schema_version": TASK_DRAFT_SCHEMA,
+ "task_id": "upright",
+ "instruction": _TEST_INSTRUCTION,
+ "steps": [_step(reference="all cans")],
+ }
+ draft["steps"][0]["object"].update(quantifier="all")
+
+ request = derive_scene_request(draft)
+ success = derive_success_spec(draft)
+
+ assert request["references"] == [
+ {
+ "reference_id": "orient.object",
+ "step_id": "orient",
+ "role": "object",
+ "reference": "all cans",
+ "quantifier": "all",
+ "count": 0,
+ "source_structure": "rigid_object",
+ "affordances": ["graspable", "orientable"],
+ "initial_state": {"orientation": "fallen"},
+ "attributes": {},
+ }
+ ]
+ assert success["terms"] == [{"step_id": "orient", "type": "object_upright"}]
+
+
+@pytest.mark.parametrize(
+ ("relation", "expected_structure", "expected_affordances"),
+ [
+ ("on", "physical_entity", []),
+ ("inside", "rigid_object", ["container"]),
+ ("behind", "spatial_reference", []),
+ ("front_of", "spatial_reference", []),
+ ("left_of", "spatial_reference", []),
+ ("right_of", "spatial_reference", []),
+ ],
+)
+def test_target_requirements_describe_capabilities_not_concrete_roles(
+ relation: str,
+ expected_structure: str,
+ expected_affordances: list[str],
+) -> None:
+ step = _step(step_id="place", reference="green can")
+ step.update(
+ task_type="E1",
+ target=_selector("scene_ref", reference="red can"),
+ relation=relation,
+ orientation_goal="preserve",
+ )
+ draft = {
+ "schema_version": TASK_DRAFT_SCHEMA,
+ "task_id": "stack",
+ "instruction": _TEST_INSTRUCTION,
+ "steps": [step],
+ }
+
+ request = derive_scene_request(draft)
+
+ target = next(
+ reference
+ for reference in request["references"]
+ if reference["role"] == "target"
+ )
+ assert target["source_structure"] == expected_structure
+ assert target["affordances"] == expected_affordances
+
+
+def test_lower_task_candidate_expands_success_for_all_binding():
+ def interpreter(_instruction, **_kwargs):
+ step = _step(reference="all cans")
+ step["object"].update(quantifier="all")
+ return _result(step)
+
+ candidate = TaskAgent(interpreter=interpreter).generate(
+ "upright", _TEST_INSTRUCTION, candidate_count=1
+ )["candidates"][0]
+ grounded = lower_task_candidate(
+ candidate,
+ {"step_01.object": ["can_a", "can_b"]},
+ [
+ {"uid": "can_a", "role": "rigid_object", "description": "A can."},
+ {"uid": "can_b", "role": "rigid_object", "description": "A can."},
+ ],
+ "dual_franka",
+ )
+
+ assert grounded.task_spec["level"] == "L2"
+ assert [term["type"] for term in grounded.task_spec["success"]["terms"]] == [
+ "object_upright",
+ "object_upright",
+ ]
+
+
+def test_draft_rejects_grounded_fields_and_task_agent_fails_closed():
+ draft = {
+ "schema_version": TASK_DRAFT_SCHEMA,
+ "task_id": "bad",
+ "instruction": "bad",
+ "steps": [_step()],
+ }
+ draft["steps"][0]["object"]["uid"] = "scene_uid"
+ with pytest.raises(ValueError, match="forbidden|exactly fields"):
+ validate_task_draft(draft)
+
+ def invalid(_instruction, **_kwargs):
+ raise ValueError("invalid draft after repair")
+
+ with pytest.raises(TaskGenerationError, match="All Task Agent candidates"):
+ TaskAgent(interpreter=invalid).generate("bad", "bad")
+
+
+def test_task_candidate_rejects_scene_constraints_not_derived_from_draft():
+ candidate = TaskAgent(
+ interpreter=lambda *_args, **_kwargs: _result(_step())
+ ).generate("upright", _TEST_INSTRUCTION, candidate_count=1)["candidates"][0]
+ candidate["scene_request"]["references"][0]["affordances"] = []
+
+ with pytest.raises(ValueError, match="derived exactly"):
+ validate_task_candidate(candidate)
+
+
+def test_success_spec_rejects_types_outside_task_ontology():
+ with pytest.raises(ValueError, match="must be one of"):
+ validate_success_spec(
+ {
+ "schema_version": SUCCESS_SPEC_SCHEMA,
+ "task_id": "bad_success",
+ "op": "all",
+ "terms": [{"step_id": "step_01", "type": "looks_good"}],
+ }
+ )
+
+
+def test_task_agent_isolates_invalid_interpreter_results():
+ lock = threading.Lock()
+ calls = 0
+
+ def interpreter(_instruction, **_kwargs):
+ nonlocal calls
+ with lock:
+ index = calls
+ calls += 1
+ if index == 0:
+ invalid = _step()
+ invalid["object"]["uid"] = "red_can"
+ return _result(invalid)
+ return _result(_step())
+
+ result = TaskAgent(interpreter=interpreter).generate(
+ "upright", _TEST_INSTRUCTION, candidate_count=2
+ )
+
+ assert result["valid_response_count"] == 1
+ assert len(result["errors"]) == 1
+ assert len(result["candidates"]) == 1
diff --git a/tests/gen_sim/task_engine/test_parallel_workflow.py b/tests/gen_sim/task_engine/test_parallel_workflow.py
new file mode 100644
index 000000000..23aaab655
--- /dev/null
+++ b/tests/gen_sim/task_engine/test_parallel_workflow.py
@@ -0,0 +1,912 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from copy import deepcopy
+import json
+from pathlib import Path
+import sys
+from types import SimpleNamespace
+from threading import Barrier
+
+import pytest
+
+from embodichain.gen_sim.action_engine.unbound import build_unbound_action_plan
+from embodichain.gen_sim.action_engine.unbound import ActionCapabilityError
+from embodichain.gen_sim.action_engine.runtime import (
+ ExecutionReport,
+ build_execution_provenance,
+)
+from embodichain.gen_sim.scene_engine.errors import SceneServiceError
+from embodichain.gen_sim.task_engine.config import (
+ TaskEngineExecutionCfg,
+ TaskEnginePlanningCfg,
+ TaskEngineWorkflowCfg,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_adapter import (
+ CandidateSelection,
+)
+from embodichain.gen_sim.task_engine.orchestration.scene_source import SceneSourceRef
+from embodichain.gen_sim.task_engine.scene_backend import SceneAnalysis, SceneRevision
+from embodichain.gen_sim.task_engine.workflow import (
+ SubprocessActionExecutor,
+ TaskEngineWorkflow,
+ _environment_successes,
+ _run_streaming_process,
+)
+from embodichain.gen_sim.task_engine.workflow_contracts import TASK_RUN_REQUEST_SCHEMA
+
+
+def _candidate_set() -> dict:
+ candidate = {
+ "candidate_id": "candidate_01",
+ "draft": {
+ "task_id": "place_can",
+ "instruction": "Place the can on the table.",
+ "steps": [
+ {
+ "id": "place",
+ "task_type": "E1",
+ "object": {
+ "kind": "scene_ref",
+ "step_id": "",
+ "reference": "the can",
+ "quantifier": "one",
+ "count": 0,
+ },
+ "target": {
+ "kind": "scene_ref",
+ "step_id": "",
+ "reference": "the table",
+ "quantifier": "one",
+ "count": 0,
+ },
+ "depends_on": [],
+ }
+ ],
+ },
+ }
+ return {
+ "task_id": "place_can",
+ "instruction": "Place the can on the table.",
+ "candidates": [candidate],
+ }
+
+
+def _selection(candidate_set: Mapping[str, object]) -> CandidateSelection:
+ candidate = candidate_set["candidates"][0]
+ return CandidateSelection(
+ scene_manifest={},
+ role_bindings={},
+ binding_report={
+ "status": "bound",
+ "selection_reason": "test",
+ "candidates": [{"candidate_id": "candidate_01", "status": "resolved"}],
+ },
+ selected_candidate=candidate,
+ candidate_bindings={"candidate_01": {}},
+ )
+
+
+def _request(tmp_path: Path, *, existing: bool = False, edit: bool = False) -> dict:
+ return {
+ "schema_version": TASK_RUN_REQUEST_SCHEMA,
+ "task_id": "place_can",
+ "task_instruction": "Place the can on the table.",
+ "image_path": None if existing else str(tmp_path / "input.png"),
+ "gym_project": str(tmp_path / "project") if existing else None,
+ "scene_edit_prompt": "Move the can left." if edit else None,
+ "output_dir": str(tmp_path / "run"),
+ }
+
+
+class _TaskAgent:
+ def __init__(self, candidates: dict, barrier: Barrier | None = None) -> None:
+ self.candidates = candidates
+ self.barrier = barrier
+
+ def generate(self, *_args, **_kwargs) -> dict:
+ if self.barrier is not None:
+ self.barrier.wait(timeout=2)
+ return self.candidates
+
+
+class _ActionAgent:
+ def __init__(self, barrier: Barrier | None = None) -> None:
+ self.barrier = barrier
+
+ def draft(self, candidate: Mapping[str, object]) -> dict:
+ if self.barrier is not None:
+ self.barrier.wait(timeout=2)
+ return build_unbound_action_plan(candidate)
+
+
+class _FailingActionAgent:
+ def draft(self, _candidate: Mapping[str, object]) -> dict:
+ raise ActionCapabilityError("missing AtomicAction")
+
+
+class _SceneBackend:
+ def __init__(
+ self,
+ selection: CandidateSelection,
+ *,
+ input_kind: str = "image",
+ input_barrier: Barrier | None = None,
+ materialize_barrier: Barrier | None = None,
+ materialize_failures: int = 0,
+ ) -> None:
+ self.selection = selection
+ self.input_kind = input_kind
+ self.input_barrier = input_barrier
+ self.materialize_barrier = materialize_barrier
+ self.materialize_failures = materialize_failures
+ self.seeds: list[int] = []
+
+ def analyze(self, request, output_root) -> SceneAnalysis:
+ if self.input_barrier is not None:
+ self.input_barrier.wait(timeout=2)
+ return SceneAnalysis(
+ input_kind=self.input_kind,
+ source=Path(request["image_path"] or request["gym_project"]),
+ blueprint=None,
+ source_fingerprint=None,
+ )
+
+ def select(self, *_args, **_kwargs) -> CandidateSelection:
+ return self.selection
+
+ def materialize(
+ self, _analysis, _request, output_root, *, seed: int
+ ) -> SceneRevision:
+ if self.materialize_barrier is not None:
+ self.materialize_barrier.wait(timeout=2)
+ root = Path(output_root)
+ root.mkdir(parents=True)
+ self.seeds.append(seed)
+ if len(self.seeds) <= self.materialize_failures:
+ raise SceneServiceError("scene service failed")
+ source = root / "scene_config.json"
+ source.write_text("{}\n", encoding="utf-8")
+ return SceneRevision(
+ source=source,
+ output_root=root,
+ revision_id="0" * 64,
+ seed=seed,
+ edit_plan=None,
+ source_fingerprint=None,
+ )
+
+ def inspect(self, revision, output_path):
+ value = {
+ "schema_version": "embodichain.final-scene-inspection/v1",
+ "scene_revision_id": revision.revision_id,
+ "source_config_path": revision.source.as_posix(),
+ "contact_tolerance_m": 0.03,
+ "objects": [],
+ }
+ path = Path(output_path)
+ path.write_text(json.dumps(value), encoding="utf-8")
+ return value
+
+
+class _Coordinator:
+ def __init__(
+ self,
+ statuses: list[str],
+ *,
+ infeasible_remediation: str = "scene_remediable",
+ ) -> None:
+ self.statuses = list(statuses)
+ self.infeasible_remediation = infeasible_remediation
+ self.calls = 0
+ self.kwargs: list[dict] = []
+ self.sources: list[object] = []
+
+ def prepare(self, _task_id, _instruction, _source, output_dir, **_kwargs):
+ status = self.statuses[min(self.calls, len(self.statuses) - 1)]
+ self.calls += 1
+ self.kwargs.append(dict(_kwargs))
+ self.sources.append(_source)
+ root = Path(output_dir)
+ root.mkdir(parents=True)
+ for name in (
+ "conservative_scene_graph.json",
+ "seed_task_graph.json",
+ "grounded_task_plan.json",
+ ):
+ (root / name).write_text("{}\n", encoding="utf-8")
+ return SimpleNamespace(
+ status=status,
+ output_dir=root,
+ planning_attempts=(),
+ feasibility_report=(
+ {"remediation_class": self.infeasible_remediation}
+ if status == "infeasible"
+ else None
+ ),
+ selected_candidate_id="candidate_01" if status == "bound" else None,
+ )
+
+
+class _FailingCoordinator:
+ def prepare(self, *_args, **_kwargs):
+ raise RuntimeError("grounding service unavailable")
+
+
+class _RebindingCoordinator(_Coordinator):
+ def __init__(self, final_candidate: Mapping[str, object]) -> None:
+ super().__init__(["bound"])
+ self.final_candidate = final_candidate
+
+ def prepare(self, *args, **kwargs):
+ result = super().prepare(*args, **kwargs)
+ result.selected_candidate_id = str(self.final_candidate["candidate_id"])
+ result.unbound_action_plan = build_unbound_action_plan(self.final_candidate)
+ return result
+
+
+class _InvalidSceneBackend(_SceneBackend):
+ def materialize(self, *_args, **kwargs):
+ self.seeds.append(int(kwargs["seed"]))
+ raise ValueError("invalid deterministic scene input")
+
+
+class _Executor:
+ def __init__(
+ self,
+ successes: list[list[bool]],
+ *,
+ expected_dataset_saving: bool = False,
+ ) -> None:
+ self.successes = successes
+ self.expected_dataset_saving = expected_dataset_saving
+ self.calls = 0
+
+ def __call__(
+ self,
+ _bundle,
+ _output_root,
+ *,
+ seed: int,
+ num_envs: int,
+ dataset_saving: bool = False,
+ ):
+ values = self.successes[min(self.calls, len(self.successes) - 1)]
+ self.calls += 1
+ assert len(values) == num_envs
+ assert dataset_saving is self.expected_dataset_saving
+ return {
+ "status": "succeeded" if all(values) else "failed",
+ "seed": seed,
+ "environments": [
+ {"env_id": str(index), "success": success}
+ for index, success in enumerate(values)
+ ],
+ }
+
+
+@pytest.mark.parametrize("existing", [False, True])
+@pytest.mark.parametrize("edit", [False, True])
+def test_parallel_workflow_supports_all_four_scene_inputs(
+ tmp_path: Path,
+ *,
+ existing: bool,
+ edit: bool,
+) -> None:
+ candidates = _candidate_set()
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=_SceneBackend(
+ _selection(candidates),
+ input_kind="gym_project" if existing else "image",
+ ),
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=_Executor(
+ [[True, False, False, False]],
+ expected_dataset_saving=True,
+ ),
+ )
+
+ result = workflow.run(
+ _request(tmp_path, existing=existing, edit=edit),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ dataset_saving=True,
+ )
+
+ assert result.succeeded
+
+
+def test_parallel_workflow_preserves_requested_robot_profile(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ coordinator = _Coordinator(["bound"])
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_adapter=SimpleNamespace(robot_profile="ur10"),
+ scene_backend=_SceneBackend(_selection(candidates)),
+ action_agent=_ActionAgent(),
+ coordinator=coordinator,
+ action_executor=_Executor([[True]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=1),
+ )
+
+ assert result.succeeded
+ assert isinstance(coordinator.sources[0], SceneSourceRef)
+ assert coordinator.sources[0].robot_profile == "ur10"
+
+
+def test_parallel_workflow_accepts_one_success_and_publishes_all_graphs(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ input_barrier = Barrier(2)
+ work_barrier = Barrier(2)
+ scene = _SceneBackend(
+ _selection(candidates),
+ input_barrier=input_barrier,
+ materialize_barrier=work_barrier,
+ )
+ coordinator = _Coordinator(["bound"])
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates, input_barrier),
+ scene_backend=scene,
+ action_agent=_ActionAgent(work_barrier),
+ coordinator=coordinator,
+ action_executor=_Executor([[False, True, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ planning_cfg=TaskEnginePlanningCfg(
+ candidate_count=3,
+ planning_mode="offline",
+ max_episodes=1,
+ max_episode_steps=4000,
+ ),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ base_seed=11,
+ run_id="20260820_072436",
+ )
+
+ assert result.succeeded
+ assert scene.seeds == [11]
+ assert result.final_bundle is not None
+ assert (result.final_bundle / "conservative_scene_graph.json").is_file()
+ assert (result.final_bundle / "seed_task_graph.json").is_file()
+ assert (result.final_bundle / "grounded_task_plan.json").is_file()
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert manifest["run_id"] == "20260820_072436"
+ assert manifest["configuration"]["planning"] == {
+ "candidate_count": 3,
+ "planning_mode": "offline",
+ "max_episodes": 1,
+ "max_episode_steps": 4000,
+ }
+ assert manifest["configuration"]["execution"]["dataset_saving"] is False
+ assert coordinator.kwargs[0]["max_episode_steps"] == 4000
+ assert coordinator.kwargs[0]["final_inspection"]["scene_revision_id"] == "0" * 64
+ assert (
+ coordinator.kwargs[0]["unbound_action_plan"]["candidate_id"] == "candidate_01"
+ )
+ assert manifest["attempts"][0]["action_attempts"][0]["status"] == "succeeded"
+ assert manifest["attempts"][0]["final_unbound_action_plan"]["candidate_id"] == (
+ "candidate_01"
+ )
+ assert manifest["attempts"][0]["unbound_transition"]["changed"] is False
+ state = json.loads(result.state_path.read_text(encoding="utf-8"))
+ succeeded = [
+ event["stage"] for event in state["events"] if event["to"] == "succeeded"
+ ]
+ assert (
+ succeeded.index("scene_finalization")
+ < succeeded.index("final_inspection")
+ < succeeded.index("final_binding")
+ )
+
+
+def test_prepare_only_publishes_bundle_without_action_execution(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+
+ def fail_execution(*_args, **_kwargs):
+ pytest.fail("prepare-only workflow must not execute Action Engine")
+
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=_SceneBackend(_selection(candidates)),
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=fail_execution,
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ execution_cfg=TaskEngineExecutionCfg(),
+ execute=False,
+ )
+
+ assert result.status == "prepared"
+ assert result.final_bundle is not None
+ assert result.final_bundle.is_dir()
+
+
+def test_final_candidate_rebinding_updates_attempt_unbound_audit(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ final_candidate = deepcopy(candidates["candidates"][0])
+ final_candidate["candidate_id"] = "candidate_02"
+ candidates["candidates"].append(final_candidate)
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=_SceneBackend(_selection(candidates)),
+ action_agent=_ActionAgent(),
+ coordinator=_RebindingCoordinator(final_candidate),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ attempt = manifest["attempts"][0]
+ assert attempt["unbound_action_plan"]["candidate_id"] == "candidate_01"
+ assert attempt["final_unbound_action_plan"]["candidate_id"] == "candidate_02"
+ assert attempt["unbound_transition"]["changed"] is True
+
+
+@pytest.mark.parametrize(
+ ("dataset_saving", "expects_filter"),
+ [(False, True), (True, False)],
+)
+def test_subprocess_executor_controls_dataset_saving_and_copies_trajectory(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ dataset_saving: bool,
+ expects_filter: bool,
+) -> None:
+ bundle = tmp_path / "bundle"
+ bundle.mkdir()
+ trajectory = tmp_path / "trajectory-source"
+ trajectory.mkdir()
+ (trajectory / "episode.json").write_text("{}\n", encoding="utf-8")
+ captured = {}
+ provenance = build_execution_provenance(episode_seed=7)
+
+ def fake_run(command, log_path):
+ captured["command"] = command
+ captured["log_path"] = Path(log_path)
+ Path(log_path).write_text("child output\n", encoding="utf-8")
+ report = ExecutionReport(
+ task_id="place_can",
+ plan_hash="0" * 64,
+ action_graph_hash="1" * 64,
+ status="succeeded",
+ run_id="run",
+ episode_id="0",
+ provenance=provenance,
+ environments=tuple(
+ {
+ "env_id": str(index),
+ "success": True,
+ "semantic_success": {},
+ "action_count": 1,
+ "retry_count": 0,
+ "recovery_count": 0,
+ "revision_count": 0,
+ "failures": [],
+ }
+ for index in range(4)
+ ),
+ action_count=4,
+ record_dir=trajectory.as_posix(),
+ )
+ (bundle / "execution_report.json").write_text(
+ json.dumps(report.as_mapping()), encoding="utf-8"
+ )
+ return SimpleNamespace(returncode=0, stdout="ok", stderr="")
+
+ monkeypatch.setattr(
+ "embodichain.gen_sim.task_engine.workflow._run_streaming_process",
+ fake_run,
+ )
+ attempt = tmp_path / "attempt"
+
+ report = SubprocessActionExecutor()(
+ bundle,
+ attempt,
+ seed=7,
+ num_envs=4,
+ dataset_saving=dataset_saving,
+ )
+
+ assert report["status"] == "succeeded"
+ assert captured["command"][1:5] == [
+ "-u",
+ "-m",
+ "embodichain.gen_sim.task_engine._bundle_runner",
+ "--bundle",
+ ]
+ assert " prepare" not in " ".join(captured["command"])
+ assert " workflow" not in " ".join(captured["command"])
+ assert ("--filter_dataset_saving" in captured["command"]) is expects_filter
+ assert captured["log_path"] == attempt / "action.log"
+ assert (attempt / "action.log").read_text(encoding="utf-8") == "child output\n"
+ assert (attempt / "trajectory" / "episode.json").is_file()
+ process = json.loads((attempt / "process.json").read_text(encoding="utf-8"))
+ assert process["combined_log"] == "action.log"
+ assert process["stdout"] == "ok"
+ assert process["stderr"] == ""
+
+
+def test_streaming_process_tees_combined_binary_output(
+ tmp_path: Path,
+ capfd: pytest.CaptureFixture[str],
+) -> None:
+ log_path = tmp_path / "action.log"
+ script = (
+ "import os; "
+ "os.write(1, b'stdout\\x00'); "
+ "os.write(2, b'stderr\\rprogress\\n'); "
+ "raise SystemExit(7)"
+ )
+
+ completed = _run_streaming_process(
+ [sys.executable, "-c", script],
+ log_path,
+ )
+
+ expected = b"stdout\x00stderr\rprogress\n"
+ assert completed.returncode == 7
+ assert completed.stdout.encode("utf-8") == expected
+ assert completed.stderr == ""
+ assert log_path.read_bytes() == expected
+ terminal = capfd.readouterr().out
+ assert "stdout\x00" in terminal
+ assert "stderr\rprogress" in terminal
+
+
+def test_scene_remediation_changes_seed_before_action_execution(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ scene = _SceneBackend(_selection(candidates))
+ coordinator = _Coordinator(["infeasible", "bound"])
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=coordinator,
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(max_scene_attempts=2),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ base_seed=20,
+ )
+
+ assert result.succeeded
+ assert scene.seeds == [20, 21]
+ assert coordinator.calls == 2
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert [item["status"] for item in manifest["attempts"]] == [
+ "preparation_failed",
+ "succeeded",
+ ]
+
+
+def test_input_conflict_feasibility_does_not_regenerate_scene(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ scene = _SceneBackend(_selection(candidates))
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(
+ ["infeasible", "bound"],
+ infeasible_remediation="input_conflict",
+ ),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(max_scene_attempts=2),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert result.failure_class == "input_conflict"
+ assert scene.seeds == [0]
+
+
+def test_scene_service_retry_keeps_completed_unbound_plan(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ scene = _SceneBackend(_selection(candidates), materialize_failures=1)
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(max_scene_attempts=2),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ base_seed=30,
+ )
+
+ assert result.succeeded
+ assert scene.seeds == [30, 31]
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert manifest["attempts"][0]["unbound_action_plan"] is not None
+
+
+def test_nonremediable_scene_error_does_not_change_scene_attempt_seed(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ scene = _InvalidSceneBackend(_selection(candidates))
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(max_scene_attempts=3),
+ execution_cfg=TaskEngineExecutionCfg(),
+ base_seed=9,
+ )
+
+ assert not result.succeeded
+ assert scene.seeds == [9]
+
+
+def test_execution_acceptance_requires_every_success_spec_term() -> None:
+ report = {
+ "environments": [
+ {
+ "success": True,
+ "semantic_success": {"step_01": True, "step_02": False},
+ },
+ {
+ "success": True,
+ "semantic_success": {"step_01": True, "step_02": True},
+ },
+ ]
+ }
+
+ assert _environment_successes(
+ report,
+ required_semantic_steps=("step_01", "step_02"),
+ ) == [False, True]
+
+
+def test_unbound_failure_retains_completed_parallel_scene(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ scene = _SceneBackend(_selection(candidates))
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_FailingActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert not result.succeeded
+ assert result.failure_class == "action_capability"
+ assert scene.seeds == [0]
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert manifest["attempts"][0]["scene_revision"] is not None
+ state = json.loads(result.state_path.read_text(encoding="utf-8"))
+ assert state["stages"]["scene_finalization"] == "succeeded"
+ assert state["stages"]["unbound_action"] == "failed"
+
+
+def test_preparation_exception_is_published_as_audited_failure(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=_SceneBackend(_selection(candidates)),
+ action_agent=_ActionAgent(),
+ coordinator=_FailingCoordinator(),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert not result.succeeded
+ assert result.failure_class == "preparation_error"
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert manifest["attempts"][0]["status"] == "preparation_error"
+ assert manifest["attempts"][0]["error"]["type"] == "RuntimeError"
+
+
+def test_explicit_edit_may_materialize_initially_missing_reference(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ unresolved = CandidateSelection(
+ scene_manifest={},
+ role_bindings={},
+ binding_report={
+ "status": "unsatisfied",
+ "selection_reason": "the can is not visible before the explicit edit",
+ "candidates": [{"candidate_id": "candidate_01", "status": "unsatisfied"}],
+ },
+ selected_candidate=None,
+ candidate_bindings={"candidate_01": {}},
+ )
+ scene = _SceneBackend(unresolved, input_kind="gym_project")
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path, existing=True, edit=True),
+ workflow_cfg=TaskEngineWorkflowCfg(),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert result.succeeded
+ provisional = json.loads(
+ (result.output_dir / "provisional_candidate.json").read_text(encoding="utf-8")
+ )
+ assert provisional == {
+ "binding_status": "unsatisfied",
+ "candidate_id": "candidate_01",
+ "reason": "explicit_scene_edit_may_materialize_missing_reference",
+ }
+
+
+def test_action_failure_retries_action_only_and_retains_attempts(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ scene = _SceneBackend(_selection(candidates))
+ executor = _Executor([[False, False, False, False]])
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=executor,
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(max_action_attempts=3),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert not result.succeeded
+ assert result.failure_class == "action_execution"
+ assert scene.seeds == [0]
+ assert executor.calls == 3
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert len(manifest["attempts"][0]["action_attempts"]) == 3
+
+
+def test_action_retry_stops_after_first_success(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ executor = _Executor(
+ [
+ [False, False, False, False],
+ [True, True, True, True],
+ [True, True, True, True],
+ ]
+ )
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=_SceneBackend(_selection(candidates)),
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["bound"]),
+ action_executor=executor,
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(max_action_attempts=3),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert result.succeeded
+ assert executor.calls == 2
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert [item["status"] for item in manifest["attempts"][0]["action_attempts"]] == [
+ "failed",
+ "succeeded",
+ ]
+
+
+def test_existing_edit_binding_conflict_does_not_invent_scene_repair(
+ tmp_path: Path,
+) -> None:
+ candidates = _candidate_set()
+ scene = _SceneBackend(_selection(candidates), input_kind="gym_project")
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["unsatisfied"]),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path, existing=True, edit=True),
+ workflow_cfg=TaskEngineWorkflowCfg(max_scene_attempts=2),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert result.status == "input_conflict"
+ assert result.failure_class == "input_conflict"
+ assert scene.seeds == [0]
+
+
+def test_image_binding_conflict_does_not_regenerate_scene(tmp_path: Path) -> None:
+ candidates = _candidate_set()
+ scene = _SceneBackend(_selection(candidates))
+ workflow = TaskEngineWorkflow(
+ task_agent=_TaskAgent(candidates),
+ scene_backend=scene,
+ action_agent=_ActionAgent(),
+ coordinator=_Coordinator(["unsatisfied"]),
+ action_executor=_Executor([[True, False, False, False]]),
+ )
+
+ result = workflow.run(
+ _request(tmp_path),
+ workflow_cfg=TaskEngineWorkflowCfg(max_scene_attempts=2),
+ execution_cfg=TaskEngineExecutionCfg(num_envs=4),
+ )
+
+ assert result.status == "input_conflict"
+ assert result.failure_class == "input_conflict"
+ assert scene.seeds == [0]
diff --git a/tests/gen_sim/task_engine/test_run_directory.py b/tests/gen_sim/task_engine/test_run_directory.py
new file mode 100644
index 000000000..e0305d59e
--- /dev/null
+++ b/tests/gen_sim/task_engine/test_run_directory.py
@@ -0,0 +1,58 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import pytest
+
+from embodichain.gen_sim.task_engine.run_directory import reserve_run_directory
+
+_NOW = datetime(2026, 8, 20, 7, 24, 36, tzinfo=timezone(timedelta(hours=8)))
+
+
+def test_run_directory_uses_local_second_timestamp(tmp_path: Path) -> None:
+ root = tmp_path / "task2_2"
+
+ with reserve_run_directory(root, now=_NOW) as allocation:
+ assert allocation.run_id == "20260820_072436"
+ assert allocation.path == root / "20260820_072436"
+ assert not allocation.path.exists()
+ allocation.path.mkdir()
+
+ assert allocation.path.is_dir()
+ assert not (root / ".20260820_072436.reserve").exists()
+
+
+def test_run_directory_adds_suffix_for_same_second_runs(tmp_path: Path) -> None:
+ root = tmp_path / "task2_2"
+ (root / "20260820_072436").mkdir(parents=True)
+
+ with reserve_run_directory(root, now=_NOW) as first:
+ with reserve_run_directory(root, now=_NOW) as second:
+ assert first.run_id == "20260820_072436_01"
+ assert second.run_id == "20260820_072436_02"
+
+
+def test_run_directory_rejects_naive_timestamp(tmp_path: Path) -> None:
+ with pytest.raises(ValueError, match="timezone"):
+ with reserve_run_directory(
+ tmp_path,
+ now=datetime(2026, 8, 20, 7, 24, 36),
+ ):
+ pass
diff --git a/tests/gen_sim/task_engine/test_scene_backend.py b/tests/gen_sim/task_engine/test_scene_backend.py
new file mode 100644
index 000000000..cc6f85bd9
--- /dev/null
+++ b/tests/gen_sim/task_engine/test_scene_backend.py
@@ -0,0 +1,214 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from embodichain.gen_sim.scene_engine.core.scene import Scene
+from embodichain.gen_sim.scene_engine.core.scene_graph import (
+ SceneGraph,
+ SceneGraphNode,
+)
+from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject
+from embodichain.gen_sim.scene_engine.pipeline.api import (
+ SceneBlueprintPackage,
+ SceneMaterialization,
+)
+import embodichain.gen_sim.task_engine.scene_backend as scene_backend_module
+from embodichain.gen_sim.task_engine.scene_backend import (
+ SceneEngineBackend,
+ scene_blueprint_objects,
+)
+from embodichain.gen_sim.task_engine.workflow_contracts import TASK_RUN_REQUEST_SCHEMA
+
+
+def _request(tmp_path: Path, project: Path, *, edit: str | None) -> dict:
+ return {
+ "schema_version": TASK_RUN_REQUEST_SCHEMA,
+ "task_id": "task",
+ "task_instruction": "Move the cup.",
+ "image_path": None,
+ "gym_project": project.as_posix(),
+ "scene_edit_prompt": edit,
+ "output_dir": (tmp_path / "run").as_posix(),
+ }
+
+
+def _scene_export(tmp_path: Path) -> Path:
+ export = tmp_path / "project" / "scene_export"
+ assets = export / "mesh_assets"
+ assets.mkdir(parents=True)
+ (assets / "table.glb").write_bytes(b"glTF-table")
+ (assets / "cup.glb").write_bytes(b"glTF-cup")
+ (export / "scene_config.json").write_text(
+ json.dumps(
+ {
+ "format": "embodichain.scene-export/v1",
+ "scene_id": "scene",
+ "background": [
+ {
+ "uid": "table",
+ "shape": {
+ "shape_type": "Mesh",
+ "fpath": "mesh_assets/table.glb",
+ },
+ }
+ ],
+ "rigid_object": [
+ {
+ "uid": "cup",
+ "shape": {
+ "shape_type": "Mesh",
+ "fpath": "mesh_assets/cup.glb",
+ },
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ return export.parent
+
+
+def test_blueprint_objects_preserve_semantics_without_geometry(tmp_path: Path) -> None:
+ scene = Scene(
+ objects=[
+ SceneObject("table", "table", "table", "table", "A table."),
+ SceneObject("cup", "asset", "cup", "red cup", "A red cup."),
+ ]
+ )
+ graph = SceneGraph(
+ nodes=[
+ SceneGraphNode("table", None),
+ SceneGraphNode("cup", "table", "on", orientation_state="lying"),
+ ]
+ )
+ package = SceneBlueprintPackage(
+ blueprint_id="blueprint",
+ image_path=tmp_path / "input.png",
+ output_root=tmp_path,
+ manifest_path=tmp_path / "scene_blueprint.json",
+ scene=scene,
+ scene_graph=graph,
+ )
+
+ objects = scene_blueprint_objects(package)
+
+ cup = next(item for item in objects if item["uid"] == "cup")
+ assert cup["description"] == "A red cup."
+ assert cup["initial_state"] == {"orientation": "fallen"}
+ assert cup["affordances"] == []
+ assert cup["init_pos"] == [0.0, 0.0, 0.0]
+
+
+def test_existing_scene_edit_creates_revision_and_never_writes_source(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ project = _scene_export(tmp_path)
+ source_config = project / "scene_export" / "scene_config.json"
+ source_value = json.loads(source_config.read_text(encoding="utf-8"))
+ articulation_path = project / "scene_export" / "cabinet.urdf"
+ articulation_path.write_text(
+ '\n',
+ encoding="utf-8",
+ )
+ source_value["articulation"] = [
+ {
+ "uid": "cabinet",
+ "name": "cabinet",
+ "description": "A fixed cabinet.",
+ "category": "cabinet",
+ "fpath": "cabinet.urdf",
+ }
+ ]
+ source_config.write_text(json.dumps(source_value), encoding="utf-8")
+ original = source_config.read_bytes()
+ prompts: list[str] = []
+
+ def fake_analyze_edit(*, output_root, edit_prompt):
+ prompts.append(edit_prompt)
+ return SimpleNamespace(
+ output_root=Path(output_root),
+ scene_edit_plan=SimpleNamespace(
+ to_dict=lambda: {"operations": [{"op": "move", "object_id": "cup"}]}
+ ),
+ )
+
+ def fake_materialize_edit(blueprint, *, seed=None):
+ assert seed == 7
+ return SceneMaterialization(
+ scene=Scene(),
+ scene_graph=SceneGraph(nodes=[SceneGraphNode("table", None)]),
+ output_root=blueprint.output_root,
+ scene_config_path=blueprint.output_root
+ / "scene_export"
+ / "scene_config.json",
+ )
+
+ monkeypatch.setattr(scene_backend_module, "analyze_edit", fake_analyze_edit)
+ monkeypatch.setattr(scene_backend_module, "materialize_edit", fake_materialize_edit)
+ backend = SceneEngineBackend()
+ request = _request(tmp_path, project, edit="Move the cup left.")
+ analysis = backend.analyze(request, tmp_path / "analysis")
+
+ revision = backend.materialize(
+ analysis,
+ request,
+ tmp_path / "revision",
+ seed=7,
+ )
+
+ assert prompts == ["Move the cup left."]
+ assert revision.source != source_config
+ assert revision.source.is_file()
+ assert len(revision.revision_id) == 64
+ assert revision.edit_plan == {"operations": [{"op": "move", "object_id": "cup"}]}
+ assert source_config.read_bytes() == original
+ revision_config = json.loads(revision.source.read_text(encoding="utf-8"))
+ assert revision_config["articulation"][0]["uid"] == "cabinet"
+ audit = json.loads(
+ (tmp_path / "revision" / "scene_revision_attempt.json").read_text(
+ encoding="utf-8"
+ )
+ )
+ assert audit["seed"] == 7
+ assert audit["revision_id"] == revision.revision_id
+ assert audit["edit_plan"] == revision.edit_plan
+
+
+def test_final_inspection_rejects_scene_changed_after_revision(tmp_path: Path) -> None:
+ project = _scene_export(tmp_path)
+ backend = SceneEngineBackend()
+ request = _request(tmp_path, project, edit=None)
+ revision = backend.materialize(
+ backend.analyze(request, tmp_path / "analysis"),
+ request,
+ tmp_path / "unused",
+ seed=0,
+ )
+ config_path = project / "scene_export" / "scene_config.json"
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ config["rigid_object"][0]["init_pos"] = [0.25, 0.0, 0.0]
+ config_path.write_text(json.dumps(config), encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match="changed before geometry inspection"):
+ backend.inspect(revision, tmp_path / "inspection.json")
diff --git a/tests/gen_sim/task_engine/test_workflow.py b/tests/gen_sim/task_engine/test_workflow.py
new file mode 100644
index 000000000..4a1564606
--- /dev/null
+++ b/tests/gen_sim/task_engine/test_workflow.py
@@ -0,0 +1,337 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from embodichain.gen_sim.task_engine.config import (
+ TaskEngineExecutionCfg,
+ TaskEnginePlanningCfg,
+ TaskEngineWorkflowCfg,
+ load_task_engine_config,
+)
+from embodichain.gen_sim.task_engine.state_machine import (
+ StageStatus,
+ WorkflowStage,
+ complete_stage,
+ fail_stage,
+ initial_state,
+ replay_events,
+ start_stage,
+ skip_stage,
+)
+from embodichain.gen_sim.task_engine.workflow_contracts import (
+ TASK_RUN_REQUEST_SCHEMA,
+ scene_input_kind,
+ validate_scene_history_root,
+ validate_task_run_request,
+)
+
+
+def _request(tmp_path: Path, *, image: bool, edit: bool) -> dict[str, object]:
+ return {
+ "schema_version": TASK_RUN_REQUEST_SCHEMA,
+ "task_id": "pick-cup",
+ "task_instruction": "Pick up the red cup.",
+ "image_path": str(tmp_path / "input.png") if image else None,
+ "gym_project": None if image else str(tmp_path / "gym_project"),
+ "scene_edit_prompt": "Add a tray." if edit else None,
+ "output_dir": str(tmp_path / "output"),
+ }
+
+
+@pytest.mark.parametrize("image", [False, True])
+@pytest.mark.parametrize("edit", [False, True])
+def test_run_request_accepts_all_four_input_combinations(
+ tmp_path: Path,
+ image: bool,
+ edit: bool,
+) -> None:
+ request = validate_task_run_request(_request(tmp_path, image=image, edit=edit))
+ assert scene_input_kind(request) == ("image" if image else "gym_project")
+ assert request["scene_edit_prompt"] == ("Add a tray." if edit else None)
+
+
+def test_run_request_rejects_two_scene_inputs(tmp_path: Path) -> None:
+ request = _request(tmp_path, image=True, edit=False)
+ request["gym_project"] = str(tmp_path / "gym_project")
+ with pytest.raises(ValueError, match="exactly one"):
+ validate_task_run_request(request)
+
+
+def test_run_request_rejects_scene_generation_prompt(tmp_path: Path) -> None:
+ request = _request(tmp_path, image=True, edit=False)
+ request["scene_generation_prompt"] = "Make a kitchen."
+ with pytest.raises(ValueError, match="fields differ"):
+ validate_task_run_request(request)
+
+
+def test_run_request_rejects_output_inside_gym_project(tmp_path: Path) -> None:
+ request = _request(tmp_path, image=False, edit=False)
+ request["output_dir"] = str(tmp_path / "gym_project" / "task_run")
+
+ with pytest.raises(ValueError, match="must not overlap"):
+ validate_task_run_request(request)
+
+
+def test_run_request_rejects_output_containing_explicit_gym_config(
+ tmp_path: Path,
+) -> None:
+ project = tmp_path / "gym_project"
+ project.mkdir()
+ config_path = project / "gym_config.json"
+ config_path.write_text("{}", encoding="utf-8")
+ request = _request(tmp_path, image=False, edit=False)
+ request["gym_project"] = str(config_path)
+ request["output_dir"] = str(project)
+
+ with pytest.raises(ValueError, match="must not overlap"):
+ validate_task_run_request(request)
+
+
+def test_scene_history_root_allows_a_source_from_a_prior_run(
+ tmp_path: Path,
+) -> None:
+ history = tmp_path / "task_history"
+ source = history / "20260820_105939" / "attempts" / "scene_export"
+ source.mkdir(parents=True)
+
+ validate_scene_history_root(source, history)
+
+ request = _request(tmp_path, image=False, edit=False)
+ request["gym_project"] = str(source)
+ request["output_dir"] = str(history / "20260820_130000")
+ assert validate_task_run_request(request)["gym_project"] == source.as_posix()
+
+
+@pytest.mark.parametrize("relative_output", [".", "new_runs", "new_runs/task"])
+def test_scene_history_root_rejects_writes_into_source_project(
+ tmp_path: Path,
+ relative_output: str,
+) -> None:
+ source = tmp_path / "scene_export"
+ source.mkdir()
+ output_root = source / relative_output
+
+ with pytest.raises(ValueError, match="read-only source"):
+ validate_scene_history_root(source, output_root)
+
+
+def test_scene_history_root_resolves_symlinks_before_comparison(
+ tmp_path: Path,
+) -> None:
+ source = tmp_path / "scene_export"
+ source.mkdir()
+ source_link = tmp_path / "scene_link"
+ source_link.symlink_to(source, target_is_directory=True)
+
+ with pytest.raises(ValueError, match="read-only source"):
+ validate_scene_history_root(source_link, source / "new_runs")
+
+
+def test_scene_history_root_protects_explicit_config_parent(tmp_path: Path) -> None:
+ source = tmp_path / "scene_export"
+ source.mkdir()
+ config = source / "scene_config.json"
+ config.write_text("{}\n", encoding="utf-8")
+
+ with pytest.raises(ValueError, match="read-only source"):
+ validate_scene_history_root(config, source)
+
+
+def test_task_and_scene_stages_can_run_concurrently(tmp_path: Path) -> None:
+ state = initial_state(_request(tmp_path, image=True, edit=False))
+ state = start_stage(state, WorkflowStage.TASK_CANDIDATES)
+ state = start_stage(state, WorkflowStage.SCENE_PREPARATION)
+ assert state.stages[WorkflowStage.TASK_CANDIDATES] == StageStatus.RUNNING
+ assert state.stages[WorkflowStage.SCENE_PREPARATION] == StageStatus.RUNNING
+ assert state.stages[WorkflowStage.SCENE_EDIT] == StageStatus.SKIPPED
+
+
+def test_candidate_selection_waits_for_both_branches(tmp_path: Path) -> None:
+ state = initial_state(_request(tmp_path, image=False, edit=False))
+ state = start_stage(state, WorkflowStage.TASK_CANDIDATES)
+ state = complete_stage(state, WorkflowStage.TASK_CANDIDATES)
+ with pytest.raises(ValueError, match="incomplete dependencies"):
+ start_stage(state, WorkflowStage.CANDIDATE_SELECTION)
+
+
+def test_unbound_action_can_run_while_user_scene_edit_is_running(
+ tmp_path: Path,
+) -> None:
+ state = initial_state(_request(tmp_path, image=True, edit=True))
+ for stage in (WorkflowStage.TASK_CANDIDATES, WorkflowStage.SCENE_PREPARATION):
+ state = start_stage(state, stage)
+ state = complete_stage(state, stage)
+ state = start_stage(state, WorkflowStage.CANDIDATE_SELECTION)
+ state = complete_stage(state, WorkflowStage.CANDIDATE_SELECTION)
+ state = start_stage(state, WorkflowStage.SCENE_EDIT)
+ state = start_stage(state, WorkflowStage.UNBOUND_ACTION)
+
+ assert state.stages[WorkflowStage.SCENE_EDIT] == StageStatus.RUNNING
+ assert state.stages[WorkflowStage.UNBOUND_ACTION] == StageStatus.RUNNING
+ with pytest.raises(ValueError, match="incomplete dependencies"):
+ start_stage(state, WorkflowStage.SCENE_FINALIZATION)
+
+
+def test_only_scene_edit_can_be_skipped(tmp_path: Path) -> None:
+ state = initial_state(_request(tmp_path, image=True, edit=True))
+
+ with pytest.raises(ValueError, match="Only the optional scene_edit stage"):
+ skip_stage(state, WorkflowStage.FINAL_BINDING)
+
+
+def test_state_events_replay_to_the_same_snapshot(tmp_path: Path) -> None:
+ request = _request(tmp_path, image=True, edit=False)
+ state = initial_state(request)
+ state = start_stage(state, WorkflowStage.TASK_CANDIDATES)
+ state = start_stage(state, WorkflowStage.SCENE_PREPARATION)
+ state = complete_stage(state, WorkflowStage.TASK_CANDIDATES)
+ state = complete_stage(state, WorkflowStage.SCENE_PREPARATION)
+ state = start_stage(state, WorkflowStage.CANDIDATE_SELECTION)
+ state = complete_stage(state, WorkflowStage.CANDIDATE_SELECTION)
+
+ replayed = replay_events(request, state.events)
+
+ assert replayed.to_dict() == state.to_dict()
+
+
+def test_state_replay_rejects_tampered_transition(tmp_path: Path) -> None:
+ request = _request(tmp_path, image=True, edit=False)
+ state = initial_state(request)
+ events = [dict(event) for event in state.events]
+ events[-1]["stage"] = WorkflowStage.FINAL_BINDING.value
+
+ with pytest.raises(ValueError, match="event does not match"):
+ replay_events(request, events)
+
+
+def test_state_replay_preserves_failure_reason(tmp_path: Path) -> None:
+ request = _request(tmp_path, image=True, edit=False)
+ state = initial_state(request)
+ state = start_stage(state, WorkflowStage.TASK_CANDIDATES)
+ state = fail_stage(state, WorkflowStage.TASK_CANDIDATES, reason="model timeout")
+
+ replayed = replay_events(request, state.events)
+
+ assert replayed.terminal
+ assert replayed.to_dict() == state.to_dict()
+
+
+def test_later_retry_can_fail_a_previously_successful_stage(tmp_path: Path) -> None:
+ request = _request(tmp_path, image=True, edit=False)
+ state = initial_state(request)
+ state = start_stage(state, WorkflowStage.SCENE_PREPARATION)
+ state = complete_stage(state, WorkflowStage.SCENE_PREPARATION)
+ state = fail_stage(
+ state,
+ WorkflowStage.SCENE_PREPARATION,
+ reason="later scene attempt failed",
+ )
+
+ assert state.terminal
+ assert replay_events(request, state.events).to_dict() == state.to_dict()
+
+
+def test_state_snapshot_mappings_are_immutable(tmp_path: Path) -> None:
+ state = initial_state(_request(tmp_path, image=True, edit=False))
+
+ with pytest.raises(TypeError):
+ state.stages[WorkflowStage.TASK_CANDIDATES] = StageStatus.SUCCEEDED
+ with pytest.raises(TypeError):
+ state.request["task_id"] = "changed"
+ with pytest.raises(TypeError):
+ state.events[0]["to"] = StageStatus.FAILED.value
+
+
+def test_workflow_configuration_rejects_non_positive_limits() -> None:
+ with pytest.raises(ValueError, match="max_scene_attempts"):
+ TaskEngineWorkflowCfg(max_scene_attempts=0)
+
+
+def test_packaged_workflow_configuration_uses_recovery_defaults() -> None:
+ workflow, planning, execution = load_task_engine_config()
+
+ assert workflow.max_scene_attempts == 2
+ assert workflow.max_action_attempts == 3
+ assert planning.candidate_count == 3
+ assert planning.planning_mode == "offline"
+ assert planning.max_episodes == 1
+ assert planning.max_episode_steps == 4000
+ assert execution.num_envs == 1
+ assert execution.required_successes == 1
+
+
+def test_workflow_configuration_can_be_tuned_from_yaml(tmp_path: Path) -> None:
+ config = tmp_path / "task_engine.yaml"
+ config.write_text(
+ """\
+schema_version: embodichain.task-engine-defaults/v1
+workflow:
+ max_parallel_workers: 3
+ max_scene_attempts: 4
+ max_action_attempts: 5
+planning:
+ candidate_count: 7
+ planning_mode: offline
+ max_episodes: 2
+ max_episode_steps: 5000
+execution:
+ num_envs: 6
+ success_policy: at_least
+ min_successful_envs: 2
+""",
+ encoding="utf-8",
+ )
+
+ workflow, planning, execution = load_task_engine_config(config)
+
+ assert workflow.max_parallel_workers == 3
+ assert workflow.max_scene_attempts == 4
+ assert workflow.max_action_attempts == 5
+ assert planning.candidate_count == 7
+ assert planning.max_episodes == 2
+ assert planning.max_episode_steps == 5000
+ assert execution.num_envs == 6
+ assert execution.required_successes == 2
+
+
+def test_execution_configuration_validates_success_policy() -> None:
+ assert TaskEngineExecutionCfg().num_envs == 1
+ assert (
+ TaskEngineExecutionCfg(
+ num_envs=4,
+ success_policy="at_least",
+ min_successful_envs=2,
+ ).required_successes
+ == 2
+ )
+ with pytest.raises(ValueError, match="success_policy=all"):
+ TaskEngineExecutionCfg(
+ num_envs=4,
+ success_policy="all",
+ min_successful_envs=1,
+ )
+
+
+def test_planning_configuration_rejects_invalid_values() -> None:
+ with pytest.raises(ValueError, match="candidate_count"):
+ TaskEnginePlanningCfg(candidate_count=0)
+ with pytest.raises(ValueError, match="planning_mode"):
+ TaskEnginePlanningCfg(planning_mode="unsupported")
diff --git a/tests/test_main.py b/tests/test_main.py
index 5466e7c11..0777d549c 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -32,6 +32,7 @@
"preview_lerobot_data",
"run-env",
"scene-engine",
+ "task-engine",
"simready",
"train-rl",
"preview-scene",