From e64874fac581914f30bbaee56c8e6ae4ea206cd6 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 11 Sep 2026 03:38:59 +0000 Subject: [PATCH 1/5] feat(task-spec): add canonical contracts and explicit plan provenance --- agent_context/MAP.yaml | 20 +- agent_context/topics/gen-sim/gen-sim.md | 9 + agent_context/topics/task-spec/task-spec.md | 59 +++ docs/source/api_reference/public_api.rst | 4 + docs/source/api_reference/task_spec.rst | 78 +++ .../gen_sim/task_engine/_bundle_runner.py | 7 +- .../gen_sim/task_engine/semantic_graph.py | 29 +- .../gen_sim/task_engine/semantic_planner.py | 64 ++- .../task_engine/task_program_bundle.py | 5 + embodichain/task_spec/IMPLEMENTATION.md | 58 +++ embodichain/task_spec/README.md | 163 ++++++ embodichain/task_spec/__init__.py | 69 +++ embodichain/task_spec/_json.py | 92 ++++ embodichain/task_spec/canonicalization.py | 169 +++++++ embodichain/task_spec/contracts.py | 477 ++++++++++++++++++ embodichain/task_spec/expressions.py | 145 ++++++ embodichain/task_spec/registry.py | 105 ++++ embodichain/task_spec/validation.py | 116 +++++ .../task_engine/test_task_spec_planning.py | 213 ++++++++ tests/task_spec/test_contracts.py | 325 ++++++++++++ tests/task_spec/test_task_spec.py | 366 ++++++++++++++ tests/test_agent_context_map.py | 4 + 22 files changed, 2571 insertions(+), 6 deletions(-) create mode 100644 agent_context/topics/task-spec/task-spec.md create mode 100644 docs/source/api_reference/task_spec.rst create mode 100644 embodichain/task_spec/IMPLEMENTATION.md create mode 100644 embodichain/task_spec/README.md create mode 100644 embodichain/task_spec/__init__.py create mode 100644 embodichain/task_spec/_json.py create mode 100644 embodichain/task_spec/canonicalization.py create mode 100644 embodichain/task_spec/contracts.py create mode 100644 embodichain/task_spec/expressions.py create mode 100644 embodichain/task_spec/registry.py create mode 100644 embodichain/task_spec/validation.py create mode 100644 tests/gen_sim/task_engine/test_task_spec_planning.py create mode 100644 tests/task_spec/test_contracts.py create mode 100644 tests/task_spec/test_task_spec.py diff --git a/agent_context/MAP.yaml b/agent_context/MAP.yaml index 2745240de..ca89fe848 100644 --- a/agent_context/MAP.yaml +++ b/agent_context/MAP.yaml @@ -241,6 +241,21 @@ topics: tests/lab/task_program/, tests/lab/task_program/semantics/] related_topics: [atomic-actions, env-framework, simulation-system, motion-planning, sensor-system, data-pipeline] status: active +- id: task-spec + title: TaskSpec Semantic Protocol + aliases: [TaskSpec, TaskTemplate, 任务语义协议, 任务模板身份] + keywords: [SceneInstance, ActionWitness, ExpansionManifest, ValidationCertificate, legacy_plan_hash, + certificate_passed, canonical_template, scene_instance_hash] + paths: [topics/task-spec/task-spec.md] + source_of_truth: + - embodichain/task_spec/contracts.py + - embodichain/task_spec/validation.py + - embodichain/task_spec/expressions.py + - embodichain/task_spec/canonicalization.py + - embodichain/task_spec/registry.py + watch_paths: [embodichain/task_spec/, tests/task_spec/] + related_topics: [gen-sim, task-programs] + status: active - id: gen-sim title: Gen Sim aliases: [gen sim, Scene Engine, SimReady, scene generation, scene editing, 场景生成, 场景编辑, 生成式仿真] @@ -256,8 +271,11 @@ topics: - embodichain/gen_sim/simready_pipeline/pipeline/ingest.py - embodichain/gen_sim/gradio_ui/app_processes.py - embodichain/gen_sim/gradio_ui/app_env.py + - embodichain/gen_sim/task_engine/semantic_planner.py + - embodichain/gen_sim/task_engine/semantic_graph.py + - embodichain/gen_sim/task_engine/task_program_bundle.py watch_paths: [embodichain/gen_sim/, embodichain/cli/main.py, tests/gen_sim/] - related_topics: [simulation-system, data-assets, env-framework] + related_topics: [simulation-system, data-assets, env-framework, task-spec] status: active - id: data-assets title: Data Assets diff --git a/agent_context/topics/gen-sim/gen-sim.md b/agent_context/topics/gen-sim/gen-sim.md index 7152f624c..4c8e9db07 100644 --- a/agent_context/topics/gen-sim/gen-sim.md +++ b/agent_context/topics/gen-sim/gen-sim.md @@ -28,6 +28,15 @@ Edit: import export → validate graph/typed edit plan → generate additions Read [pipeline details](pipeline-details.md) for stage contracts, parser resume behavior, Gradio artifact ownership and focused failure diagnosis. +Task Engine lives in `task_engine/`: TaskAgent produces legacy candidates, +SemanticTaskPlanner expands E1–E5 recipes into candidate graphs, and +`task_program_bundle.py` composes the Task Program deployment. Explicit +TaskSpec template/instance inputs to the planner produce graph/v2 provenance; +the existing no-TaskSpec path remains graph/v1. v2 bundle export/execution is gated until +final task evaluation is integrated before data submission and reset. Follow +[TaskSpec](../task-spec/task-spec.md) for semantic identity, evidence and the +current planning-only boundary. + ## Durable scene boundary The `scene_export/` directory contains `scene.json`, `scene_config.json`, diff --git a/agent_context/topics/task-spec/task-spec.md b/agent_context/topics/task-spec/task-spec.md new file mode 100644 index 000000000..6b9f781cd --- /dev/null +++ b/agent_context/topics/task-spec/task-spec.md @@ -0,0 +1,59 @@ +# TaskSpec semantic protocol + +`embodichain/task_spec/` owns pure JSON task meaning, canonical task identity +and content/evidence references. It depends only on the standard library. +It does not own LLM calls, scene creation, check execution, simulation +lifecycle, retries, dataset submission or persistence. + +## Owning entry points + +- `validation.py` validates template fields, role references and capability + vocabulary; `expressions.py` validates bounded predicate AST and quantities. +- `registry.py` returns a detached semantic-version/predicate-revision snapshot. + This declares semantics, not installed checkers or Semantic Calls. +- `canonicalization.py` canonicalizes roles, inverse relations and SI units. + Top-level self hash is excluded; unknown execution fields are rejected. +- `contracts.py` validates the five records, computes exact instance content + identity, checks optional template/instance owners and applies certificate + required-check acceptance. URI/digest references are not resolved here. + +Template v0.1 permits six roles, 128 predicate occurrences and depth 16. +Canonical quantity values are exact decimal strings; accepted units are +m/cm/mm, s/ms and rad. Role relabeling is independent of spelling/order, +including equal role declarations. Temporal sequence order remains normative. +These are restricted equivalences, not arbitrary formula equivalence. + +SceneInstance owns grounded roles, assets, scene/embodiment and observed +initial-state references. Instance roles are canonical IDs; use +`canonical_role_map` to rekey author grounding and evaluate the corresponding +`canonical_template`. This prevents ambiguous physical meaning under role +alpha-renaming. ActionWitness owns graph/program/integration/policy/ +constraints and execution evidence; candidate execution is null. Existing +integration_fingerprint is reused. Certificate checks bind template, instance, +witness and one env/episode, with explicit versions, metrics and evidence. +Missing/not_run/unavailable/unsupported/failed required checks do not pass. +Hosts must verify evidence content before accepting report statuses. + +## GenSim boundary + +`SemanticTaskPlanner.plan(task_template=..., scene_instance=...)` consumes +paired explicit inputs and emits a candidate `semantic_task_graph/v2`. +It verifies physical-object bindings and records template_hash, instance_hash +and legacy_plan_hash. The existing recipe remains a proposed solution and +does not redefine or prove the normative goal. + +Without TaskSpec inputs, TaskCandidate and graph/v1 behavior is unchanged. +`task_program_bundle.py` and `_bundle_runner.py` refuse v2 export/execution before writing artifacts until +final evaluation before data submission/reset exists. This is a planning +provenance entry, not the complete template-driven generation/runtime route. +TaskAgent migration, template-derived scene requests, actual-state capture, +shared evaluator, Workflow certificates and expansion hosting remain future +work in their current owners. See [GenSim](../gen-sim/gen-sim.md). + +## Focused validation + +Run `tests/task_spec/` for identity, schema, quantity and evidence boundaries. +Run `tests/gen_sim/task_engine/test_task_spec_planning.py` and existing +`test_agent.py`/`test_semantic_graph.py` for the paired planning entry, +graph versions and legacy compatibility. These CPU tests do not certify +physical rollout or installed predicate observers. diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst index 50f1637cf..5e20d51be 100644 --- a/docs/source/api_reference/public_api.rst +++ b/docs/source/api_reference/public_api.rst @@ -2176,3 +2176,7 @@ embodichain_tasks.utils.importer .. autosummary:: import_packages +.. toctree:: + :hidden: + + task_spec diff --git a/docs/source/api_reference/task_spec.rst b/docs/source/api_reference/task_spec.rst new file mode 100644 index 000000000..afcea07ed --- /dev/null +++ b/docs/source/api_reference/task_spec.rst @@ -0,0 +1,78 @@ +TaskSpec semantic contracts +=========================== + +TaskSpec v0.1 separates normative task identity from grounded instances and +candidate or executed solutions. All decoders return detached JSON records, +reject unknown fields and avoid simulator, execution and filesystem ownership. + +Task identity includes roles, initial/final conditions, invariants, explicit +temporal sequences and capability requirements. It excludes concrete assets, +robots, plans and trajectories by rejecting them at the template boundary. +Canonical quantities use exact decimal strings in SI units; supported units are +m/cm/mm, s/ms and rad. Role renaming, inverse spatial relations and commutative +sorting are supported; arbitrary logical equivalence is not. + +A certificate is a report record, not a verifier. Required checks must exist +and pass with matching input identities, checker/predicate versions and +evidence references. Hosts must verify referenced content and observations. +Unavailable/not-run/unsupported checks cannot be accepted as passes. + +Public package interface +------------------------ + +.. automodule:: embodichain.task_spec + :members: + :imported-members: + +Canonicalization +---------------- + +Templates are bounded to six roles, 128 predicate occurrences and logical depth +16. Canonicalization compares all relabelings within equal role declarations, +preserving normative temporal order. The stored semantic hash is excluded only +at the top level, so hashing an already sealed template is stable. + +.. automodule:: embodichain.task_spec.canonicalization + :members: + +Records and acceptance +---------------------- + +Instances own grounded asset/component and observed initial-state references. +Instance bindings use canonical role IDs, obtained through canonical_role_map, +and checkers consume canonical_template against these bindings. Author labels +cannot be used directly: equivalent templates may permute their meanings. +Witnesses own program/integration/policy/constraint and execution references. +An expansion records lineage and invalidated checks without scheduling them. +A certificate binds checks to one template, instance, witness and episode. + +.. automodule:: embodichain.task_spec.contracts + :members: + +Expression and vocabulary boundaries +------------------------------------- + +Predicate quantities are explicit, dimensional and nonnegative except signed +revolute-joint positions. The coordinate convention is scene +X right, +Y front, ++Z up. The vocabulary describes bounded geometry and attachment observations; +it does not assert installed evaluators, liquid transfer, force support or +continuous-contact proof. + +.. automodule:: embodichain.task_spec.expressions + :members: + +.. automodule:: embodichain.task_spec.registry + :members: + +.. automodule:: embodichain.task_spec.validation + :members: + +GenSim candidate graph version +------------------------------ + +The optional TaskSpec planner entry preserves the legacy candidate step hash +as provenance, emits v2 candidate graphs and retains the v1 path unchanged. +Bundle export and execution reject v2 until final task evaluation is integrated before data +submission and reset. This interface is not a certified rollout path. + +.. autodata:: embodichain.gen_sim.task_engine.semantic_graph.TASK_SPEC_GRAPH_SCHEMA diff --git a/embodichain/gen_sim/task_engine/_bundle_runner.py b/embodichain/gen_sim/task_engine/_bundle_runner.py index fbd17957a..d8549e826 100644 --- a/embodichain/gen_sim/task_engine/_bundle_runner.py +++ b/embodichain/gen_sim/task_engine/_bundle_runner.py @@ -71,7 +71,6 @@ def execute_bundle( if execution_output is None else Path(execution_output).expanduser().resolve() ) - output.mkdir(parents=True, exist_ok=True) deployment_path = root / "task_program_deployment.yaml" program_path = root / "task_program/program.yaml" graph_path = root / "semantic_task_graph.json" @@ -81,6 +80,12 @@ def execute_bundle( raise FileNotFoundError(f"Bundle is missing required artifact: {path}") _verify_source(root) graph = validate_semantic_task_graph(_read_json(graph_path)) + if "task_spec" in graph: + raise ValueError( + "TaskSpec execution requires final task evaluation before data " + "submission; that runtime integration is not yet available." + ) + output.mkdir(parents=True, exist_ok=True) fingerprint = _read_json(fingerprint_path) deployment = _verify_integration_fingerprint( root, deployment_path, graph, fingerprint diff --git a/embodichain/gen_sim/task_engine/semantic_graph.py b/embodichain/gen_sim/task_engine/semantic_graph.py index 219469d65..2264c1aaa 100644 --- a/embodichain/gen_sim/task_engine/semantic_graph.py +++ b/embodichain/gen_sim/task_engine/semantic_graph.py @@ -30,12 +30,14 @@ __all__ = [ "SEMANTIC_TASK_GRAPH_SCHEMA", + "TASK_SPEC_GRAPH_SCHEMA", "SemanticTaskGraph", "semantic_task_graph_hash", "validate_semantic_task_graph", ] SEMANTIC_TASK_GRAPH_SCHEMA: Final = "semantic_task_graph/v1" +TASK_SPEC_GRAPH_SCHEMA: Final = "semantic_task_graph/v2" SemanticTaskGraph: TypeAlias = dict[str, Any] _GRAPH_KEYS = frozenset( @@ -108,12 +110,35 @@ def validate_semantic_task_graph(value: Mapping[str, Any]) -> SemanticTaskGraph: ValueError: If topology, schema, or a Semantic Call is invalid. """ graph = _json_snapshot(value) - _exact_keys(graph, _GRAPH_KEYS, "SemanticTaskGraph") - if graph["schema_version"] != SEMANTIC_TASK_GRAPH_SCHEMA: + is_task_spec = graph.get("schema_version") == TASK_SPEC_GRAPH_SCHEMA + _exact_keys( + graph, + _GRAPH_KEYS | ({"task_spec"} if is_task_spec else set()), + "SemanticTaskGraph", + ) + if graph["schema_version"] not in { + SEMANTIC_TASK_GRAPH_SCHEMA, + TASK_SPEC_GRAPH_SCHEMA, + }: raise ValueError( "SemanticTaskGraph.schema_version must be " f"{SEMANTIC_TASK_GRAPH_SCHEMA!r}." ) + if is_task_spec: + provenance = graph["task_spec"] + if type(provenance) is not dict: + raise ValueError("SemanticTaskGraph.task_spec must be an object.") + _exact_keys( + provenance, + {"template_hash", "instance_hash", "legacy_plan_hash", "status"}, + "task_spec", + ) + for key in ("template_hash", "instance_hash", "legacy_plan_hash"): + digest = _nonempty(provenance[key], f"task_spec.{key}") + if _FINGERPRINT.fullmatch(digest) is None: + raise ValueError(f"task_spec.{key} must be a lowercase SHA-256 digest.") + if provenance["status"] != "candidate": + raise ValueError("TaskSpec graph is a candidate, not an executed witness.") for field in ("task_id", "instruction", "planner_route"): graph[field] = _nonempty(graph[field], f"SemanticTaskGraph.{field}") fingerprint = _nonempty( diff --git a/embodichain/gen_sim/task_engine/semantic_planner.py b/embodichain/gen_sim/task_engine/semantic_planner.py index e1acb92a5..174a919ba 100644 --- a/embodichain/gen_sim/task_engine/semantic_planner.py +++ b/embodichain/gen_sim/task_engine/semantic_planner.py @@ -80,6 +80,8 @@ def plan( *, planner_route: str = "offline", integration_fingerprint: str = "0" * 64, + task_template: Mapping[str, Any] | None = None, + scene_instance: Mapping[str, Any] | None = None, ) -> SemanticTaskGraph: """Build one semantic graph without importing or materializing actions. @@ -90,9 +92,14 @@ def plan( planner_route: Candidate route provenance. integration_fingerprint: Exact integration fingerprint, or the all-zero placeholder used before bundle preflight. + task_template: Optional explicit normative TaskSpec. The recipe + remains a candidate solution; this does not prove its goal. + scene_instance: Observed, content-addressed instance for the template. + Both TaskSpec inputs must be provided together. Returns: - A validated ``semantic_task_graph/v1`` value. + A validated v1 graph, or a v2 candidate with TaskSpec references. + TaskSpec candidate graphs cannot yet be exported for execution. """ selected: TaskCandidate = validate_task_candidate(candidate) bindings: RoleBindings = validate_role_bindings(role_bindings) @@ -106,6 +113,13 @@ def plan( for item in scene_objects if str(item.get("runtime_uid", item.get("uid", ""))).strip() } + task_spec = self._task_spec_provenance( + selected, + bindings, + objects, + task_template, + scene_instance, + ) steps = selected["draft"]["steps"] steps_by_id = {str(step["id"]): step for step in steps} result_objects: dict[str, str] = {} @@ -595,7 +609,10 @@ def plan( return validate_semantic_task_graph( { - "schema_version": "semantic_task_graph/v1", + "schema_version": ( + "semantic_task_graph/v2" if task_spec else "semantic_task_graph/v1" + ), + **({"task_spec": task_spec} if task_spec else {}), "task_id": selected["draft"]["task_id"], "instruction": selected["draft"]["instruction"], "planner_route": str(planner_route), @@ -610,6 +627,49 @@ def plan( } ) + @staticmethod + def _task_spec_provenance( + candidate: TaskCandidate, + bindings: RoleBindings, + objects: Mapping[str, Any], + template: Mapping[str, Any] | None, + instance: Mapping[str, Any] | None, + ) -> dict[str, str] | None: + if template is None and instance is None: + return None + if template is None or instance is None: + raise ValueError( + "task_template and scene_instance must be supplied together." + ) + from embodichain.task_spec import ( + canonical_template, + validate_scene_instance, + validate_task_template, + ) + + task = validate_task_template(template) + scene = validate_scene_instance(instance, template=task) + canonical_roles = canonical_template(task)["roles"] + bound_entities = { + uid for uids in bindings["reference_bindings"].values() for uid in uids + } + for role, grounding in scene["roles"].items(): + if canonical_roles[role]["kind"] not in {"object", "container", "support"}: + raise UnsupportedSemanticCapabilityError( + "TaskSpec planning currently binds physical object roles only." + ) + uid = grounding["entity_id"] + if uid not in objects or uid not in bound_entities: + raise ValueError( + f"TaskSpec role {role!r} disagrees with scene binding." + ) + return { + "template_hash": task["semantic_hash"], + "instance_hash": scene["content_hash"], + "legacy_plan_hash": candidate["semantic_hash"], + "status": "candidate", + } + def _resolve_step_entity( self, step: Mapping[str, Any], diff --git a/embodichain/gen_sim/task_engine/task_program_bundle.py b/embodichain/gen_sim/task_engine/task_program_bundle.py index f5142788b..78f640d9a 100644 --- a/embodichain/gen_sim/task_engine/task_program_bundle.py +++ b/embodichain/gen_sim/task_engine/task_program_bundle.py @@ -145,6 +145,11 @@ def generate_task_program_bundle( integration cannot be composed and preflighted. """ selected_graph = validate_semantic_task_graph(graph) + if "task_spec" in selected_graph: + raise ValueError( + "TaskSpec bundle export requires final task evaluation before data " + "submission; that runtime integration is not yet available." + ) unsupported = sorted( {node["task_type"] for node in selected_graph["nodes"]} - {"E1", "E2", "E3", "E4", "E5"} diff --git a/embodichain/task_spec/IMPLEMENTATION.md b/embodichain/task_spec/IMPLEMENTATION.md new file mode 100644 index 000000000..45000c8e6 --- /dev/null +++ b/embodichain/task_spec/IMPLEMENTATION.md @@ -0,0 +1,58 @@ +# TaskSpec E2 implementation plan + +Spec: user-supplied GenSim architecture / TaskSpec design for PR #531, +plus the accepted next-step plan in the conversation. + +## Global Constraints + +- Keep one public Task Program / Atomic Skills executor and existing Gym lifecycle. +- TaskTemplate owns normative goals; legacy plan hashes keep their meaning. +- Preserve v1 bundles and candidates. Version all new protocol paths explicitly. +- Only evidence-backed required checks pass. Never certify unavailable checks. +- Observe and freeze final task evaluation before successful data submission/reset. +- Keep pure numerical algorithms in compute, provider reads in lab, and task recipes in GenSim. +- Keep source changes in the existing isolated worktree; do not alter main or PR #531 directly. +- Create one PR targeting ljd/action_engine_refactor after focused tests and review. +- Do not claim GPU/physical qualification without a measured run. + +### Task 1: Public execution safety prerequisites + +Work in lab/sim/atomic_actions, lab/task_program compiler/integrations, +gen_sim/task_engine/_task_program/services.py (only phase protection declarations), +and associated tests/context. Do not edit GenSim coordinator, workflow, bundle, +runner or TaskSpec/evaluation modules. + +Fix recovery continuity when no previous tracking ownership was established. +Test first empty -> valid, valid -> changed ownership rejected, repeated empty -> failure/no commands. +Introduce a controlled typed registered-lowerer phase-protection declaration, +validated and bound by the public compiler to existing gates/guards. Reuse +current verification policy and runtime; do not infer protection from skill name. +Bind GenSim registered Pick/Place/held-move protection where required for E2. +CPU failure-injection must exercise missing acquisition, held loss and +release-before-retreat guards. Preserve existing built-in HandOver protections. +Keep this a focused commit; black==26.3.1 . before committing, stage only owned files. +Report paths, commits, test commands/results and known physical limits. + +### Task 2: E2 template and measured episode acceptance + +Use TaskSpec explicit input plus an E2 seed adapter (upright with explicit threshold, +fallen/not-upright init). Reject unsupported legacy semantics without dropping them. +Derive scene requirements in current owner, carry template through coordinator and +bundle, and capture observed instance after reset/settling. Validate initial goal +and reject trivial/invalid episodes. Freeze final upright evaluation after cleanup +and before data submission/reset, preserving separate program/task results. +Reuse a shared compute tilt measurement from existing stability policies. +Only the bounded E2 contract can open v2 execution; unsupported predicates, +invariants, temporal constraints or task families stay gated. +Assemble content-addressed instance, witness and single-episode certificate references. +Keep failure evidence and do not alter public reset/retry ownership. +Validate wrong-but-stable orientation, row-local failure, stale evidence/fingerprints, +and success-data submission refusal on failed evaluation with CPU tests. + +### Task 3: Feasibility reporting, review and PR + +Invoke existing FeasibilityBroker in preparation; report static evidence separately +from provider-free preflight and physical execution. Do not unconditionally mark +unperformed static checks complete. Run focused tests, Black, API docs/context gates, +and available physical smoke only with verified prerequisites. Review full branch, +fix findings, commit/push own branch, and create PR targeting #531 head branch. diff --git a/embodichain/task_spec/README.md b/embodichain/task_spec/README.md new file mode 100644 index 000000000..2900a009c --- /dev/null +++ b/embodichain/task_spec/README.md @@ -0,0 +1,163 @@ +# TaskSpec v0.1 + +TaskSpec owns normative task meaning and evidence references. It imports only +the Python standard library. It does not read assets, invoke checkers, plan, +step, reset, retry, write files, or commit datasets. + +## Author a template + +```python +from embodichain.task_spec import semantic_hash, validate_task_template + +template = { + "schema_version": "taskspec/template/v0.1", + "semantic_version": "0.1", + "roles": { + "item": {"kind": "object", "capabilities": ["graspable"]}, + "reference": {"kind": "object"}, + }, + "init": [], + "goal": [{ + "predicate": "relative_position", + "object": "item", + "reference": "reference", + "relation": "left_of", + "margin": {"value": 10, "unit": "cm"}, + }], + "invariants": [], + "requirements": [], +} +template["semantic_hash"] = semantic_hash(template) +template = validate_task_template(template) +``` + +All records are detached JSON dictionaries. Unknown fields and unsupported +versions are rejected. A present digest is excluded during hash calculation +and verified by the corresponding complete-record validator. No other field +is silently dropped: plan, trajectory, seed and robot model are invalid +template fields. The old GenSim TaskCandidate.semantic_hash keeps its existing +step-hash meaning. + +Role names are binders. Alpha-renaming changes role keys and only declared +role references, never arbitrary strings. At most six roles and 128 predicate +occurrences are supported in a template. Logical depth is at most 16; the JSON +decoder also bounds nesting and total nodes. Equal role declarations are +relabelled exhaustively to choose a stable minimum, including symmetric roles. +Instances must bind canonical IDs (role_0, role_1, ...), using +`canonical_role_map(template)` to rekey author-name grounding. Checkers consume +`canonical_template(template)` against these bindings. Binding author labels +directly is rejected: equivalent templates can otherwise exchange author-role +meanings while retaining the same task and instance identities. + +Canonicalization supports inverse left/right and front/behind relations, SI +unit scaling, sorting conjunction/disjunction arguments and top-level condition +lists. It does not claim general logical equivalence, simplify negations, +flatten boolean trees, or deduplicate conditions. Optional temporal defaults +to an empty list. Each temporal entry is an ordered +`{"op": "sequence", "args": [expression, expression, ...]}`, requiring observations +in that order at strictly increasing sample times. Multiple entries are +conjunctive; each entry's argument order remains normative and changes identity. + +Every quantity is `{"value": ..., "unit": ...}`. Values accept integers, finite +floats, or finite decimal strings. Canonical values are exact fixed decimal +strings (with trailing fractional zeros removed); this avoids float collisions +between distinct success thresholds and dependence on process decimal context. +v0.1 accepts m/cm/mm, s/ms and rad. It rejects degrees and other units until +their conversion semantics are versioned. Decimal tokens/canonical values +are bounded to 128 characters and parsed exponent to ±256. + +## Predicate vocabulary + +The semantic version pins the complete registry snapshot and predicate +revisions. There is no executable evaluator registry. The snapshot describes +what observations a host would need; knowing a predicate name does not prove +that any simulator supplies them. + +| Predicate | Required roles | Required quantities | +| --- | --- | --- | +| object_at_target | object, target (abstract target role) | tolerance (length) | +| relative_position | object, reference | margin (length); relation enum | +| upright | object | max_tilt (angle) | +| stack_supported | object, support | max_tilt (angle), max_gap, max_offset (length) | +| held_stable | object, holder (manipulator) | position_tolerance, angular_tolerance, duration | +| released | object, holder (manipulator) | none | +| joint_position | joint | position, tolerance (angle) | + +Quantities are mandatory. Tolerances are nonnegative, held duration is +positive, and a revolute joint's normative position may be signed. +Object roles may be object/container/support; repeated roles within a binary +predicate are rejected. The frame is right-handed scene coordinates: +X right, ++Y front, +Z up; it is not Scene Engine's internal edit frame. + +Conditions have bounded and/or/not forms with `args`; not has exactly one +argument. There are no callables, module paths, eval strings, liquid transfer, +force proof, visual visibility, quantifiers, or prismatic-joint predicates. +Unknown predicates are rejected when authoring a template. Evidence records +may record unsupported/unavailable predicates but cannot pass them. + +## Identity and artifact ownership + +| Record | Required payload besides schema_version | +| --- | --- | +| TaskTemplate | semantic_version, roles, init, goal, invariants, requirements, semantic_hash; optional temporal | +| SceneInstance | template_hash, roles, scene, embodiment, initial_state, evidence, content_hash | +| ActionWitness | template_hash, instance_hash, status, plan, execution, evidence | +| ExpansionManifest | parent, changes, operator, seed, semantic_effect, invalidates | +| ValidationCertificate | template_hash, instance_hash, witness_id, checks, policy | + +A content reference is exactly `{"uri": "...", "content_hash": ""}`. +URIs are inert identifiers; hosts must resolve them and verify referenced bytes. +Each instance role binds `{"entity_id": "...", "asset": content_reference}`. +Scene, embodiment and observed initial_state are content references; evidence +is a nonempty list of references. Use scene_instance_hash to calculate identity. +Passing template= to validate_scene_instance checks complete role membership +and semantic identity; passing instance= to validate_action_witness checks its +owner identity. + +A witness plan references graph/program/integration/execution_policy/constraints +and reuses integration_fingerprint. Candidate status requires execution=null. +For succeeded/failed, execution includes env_id, episode_id, program_success, +task_success, runtime_result, task_evaluation and trajectory references. +Succeeded requires both success booleans plus execution evidence and trajectory. +This checks record consistency; it does not independently confirm report truth. + +Expansion changes name a scope (language/scene/embodiment/trajectory/episode) +and child content reference. semantic_effect is preserve/mutate/unknown, +independent of scope. The manifest includes operator name/version/parameters, +nonnegative seed and invalidated check scopes. The host enforces invalidation; +the record does not execute expansion. + +Each certificate check includes id, status, scope, inputs, checker, predicate, +parameters, metrics, env_id, episode_id and evidence. Inputs bind template, +instance and witness content identities. Checker/predicate records identify +name and version; predicate may be null for non-predicate checks. +A pass requires evidence and supported predicate revision where applicable. +A certificate cannot combine different env/episode rows. Policy contains id, +version and a nonempty required_checks list. certificate_passed returns false +for any absent, failed, not_run, unavailable or unsupported required check. +It never invokes a checker. The host must establish evidence authenticity, +versions and evaluation-policy applicability before accepting its result. +Cross-seed robustness aggregation remains future work, not inferred from this +single-episode policy. + +## Current GenSim integration and remaining work + +SemanticTaskPlanner.plan accepts optional task_template and scene_instance +together. It checks their identities and physical-object bindings, preserves +the existing E-recipes, and emits semantic_task_graph/v2 with template_hash, +instance_hash, legacy_plan_hash and status=candidate. The template is an +explicit normative input, not automatically inferred from a chosen recipe. +Only physical object/container/support roles are wired at this entry today. +Callers are responsible for supplying the actual observed instance. + +Without those inputs, the existing candidate and graph/v1 behavior is unchanged. +v2 is a provenance-carrying plan candidate: it does not claim recipe satisfaction +of the template. The bundle builder and runner refuse v2 before creating output, +because final task evaluation is not yet wired before dataset submission/reset. + +Next work belongs in the existing owners: TaskAgent seed migration and +template-derived SceneRequest, scene adapter observation/grounding evidence, +shared predicate measurements, final/step evaluation, and Workflow certificate +assembly. Public registered phase protection/recovery (P0), full P2/P3 execution, +and motion expansion integration remain separate changes. No runtime or physical +task certification is claimed by this package. diff --git a/embodichain/task_spec/__init__.py b/embodichain/task_spec/__init__.py new file mode 100644 index 000000000..02d9749d0 --- /dev/null +++ b/embodichain/task_spec/__init__.py @@ -0,0 +1,69 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Pure TaskSpec contracts, canonical task identity and evidence references.""" + +from __future__ import annotations + +from .canonicalization import canonical_role_map, canonical_template, semantic_hash +from .contracts import ( + ACTION_WITNESS_SCHEMA, + EXPANSION_MANIFEST_SCHEMA, + SCENE_INSTANCE_SCHEMA, + TASK_TEMPLATE_SCHEMA, + VALIDATION_CERTIFICATE_SCHEMA, + ActionWitness, + ExpansionManifest, + SceneInstance, + TaskTemplate, + ValidationCertificate, + certificate_passed, + scene_instance_hash, + validate_action_witness, + validate_expansion_manifest, + validate_scene_instance, + validate_task_template, + validate_validation_certificate, +) +from .expressions import validate_expression +from .registry import registry_snapshot +from .validation import validate_template_structure + +__all__ = [ + "ACTION_WITNESS_SCHEMA", + "EXPANSION_MANIFEST_SCHEMA", + "SCENE_INSTANCE_SCHEMA", + "TASK_TEMPLATE_SCHEMA", + "VALIDATION_CERTIFICATE_SCHEMA", + "ActionWitness", + "ExpansionManifest", + "SceneInstance", + "TaskTemplate", + "ValidationCertificate", + "certificate_passed", + "scene_instance_hash", + "validate_action_witness", + "validate_expansion_manifest", + "validate_scene_instance", + "validate_task_template", + "validate_validation_certificate", + "canonical_template", + "canonical_role_map", + "semantic_hash", + "validate_expression", + "registry_snapshot", + "validate_template_structure", +] diff --git a/embodichain/task_spec/_json.py b/embodichain/task_spec/_json.py new file mode 100644 index 000000000..fd6171342 --- /dev/null +++ b/embodichain/task_spec/_json.py @@ -0,0 +1,92 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Bounded JSON validation shared by pure protocol decoders.""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from typing import Any + + +def json_copy(value: object) -> Any: + """Copy JSON data, rejecting cycles, non-finite numbers and oversized trees.""" + budget = [16384] + + def visit(item: object, depth: int) -> Any: + budget[0] -= 1 + if depth > 64 or budget[0] < 0: + raise ValueError("JSON structure exceeds TaskSpec depth/node limit") + if item is None or type(item) in (str, bool, int): + return item + if type(item) is float and math.isfinite(item): + return item + if type(item) is list: + return [visit(child, depth + 1) for child in item] + if type(item) is dict and all(type(key) is str for key in item): + return {key: visit(child, depth + 1) for key, child in item.items()} + raise ValueError("TaskSpec requires finite, string-keyed JSON data") + + return visit(value, 0) + + +def fields( + value: object, required: set[str], optional: set[str], path: str +) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{path} must be an object") + missing = required - value.keys() + unknown = value.keys() - required - optional + if missing or unknown: + raise ValueError( + f"{path} invalid fields: missing={sorted(missing)}, unknown={sorted(unknown)}" + ) + return value + + +def text(value: object, path: str) -> str: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise ValueError(f"{path} must be a nonempty, trimmed string") + return value + + +def digest(value: object, path: str) -> str: + if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None: + raise ValueError(f"{path} must be a lowercase SHA-256 digest") + return value + + +def items(value: object, path: str, *, nonempty: bool = False) -> list[Any]: + if not isinstance(value, list) or (nonempty and not value): + raise ValueError(f"{path} must be {'a nonempty' if nonempty else 'a'} list") + return value + + +def dumps(value: object) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def hash_json(value: object) -> str: + return hashlib.sha256(dumps(value).encode("utf-8")).hexdigest() diff --git a/embodichain/task_spec/canonicalization.py b/embodichain/task_spec/canonicalization.py new file mode 100644 index 000000000..04ceb7205 --- /dev/null +++ b/embodichain/task_spec/canonicalization.py @@ -0,0 +1,169 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Restricted alpha-equivalence, inverse relations and SI task identity.""" + +from __future__ import annotations + +from collections.abc import Mapping +from itertools import permutations, product +from typing import Any + +from ._json import dumps, hash_json +from .expressions import _expression +from .registry import registry_snapshot +from .validation import validate_template_structure + +__all__ = ["canonical_template", "canonical_role_map", "semantic_hash"] +_ROLE_FIELDS = { + name: tuple(spec["roles"]) + for name, spec in registry_snapshot()["predicates"].items() +} + + +def _canonical_expression( + value: dict[str, Any], labels: dict[str, str] +) -> dict[str, Any]: + if "op" in value: + args = [_canonical_expression(arg, labels) for arg in value["args"]] + if value["op"] in {"and", "or"}: + args.sort(key=dumps) + return {"op": value["op"], "args": args} + result = dict(value) + for key in _ROLE_FIELDS[result["predicate"]]: + result[key] = labels[result[key]] + if result["predicate"] == "relative_position": + inverse = {"right_of": "left_of", "behind": "in_front_of"} + if result["relation"] in inverse: + result["relation"] = inverse[result["relation"]] + result["object"], result["reference"] = ( + result["reference"], + result["object"], + ) + return result + + +def _canonicalize(template: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, str]]: + source = validate_template_structure(template) + roles = { + name: { + "kind": role["kind"], + "capabilities": sorted(role.get("capabilities", [])), + } + for name, role in source["roles"].items() + } + groups: dict[str, list[str]] = {} + for name, role in roles.items(): + groups.setdefault(dumps(role), []).append(name) + ordered_groups = [sorted(groups[key]) for key in sorted(groups)] + normalized = { + field: [_expression(expr, source["roles"]) for expr in source[field]] + for field in ("init", "goal", "invariants") + } + temporal = [ + { + "op": "sequence", + "args": [_expression(expr, source["roles"]) for expr in item["args"]], + } + for item in source.get("temporal", []) + ] + best = None + best_key = None + best_labels = None + for ordering in product(*(permutations(group) for group in ordered_groups)): + names = [name for group in ordering for name in group] + labels = {name: f"role_{i}" for i, name in enumerate(names)} + candidate = { + "schema_version": source["schema_version"], + "semantic_version": source["semantic_version"], + "roles": {labels[name]: roles[name] for name in names}, + **{ + field: sorted( + (_canonical_expression(expr, labels) for expr in exprs), key=dumps + ) + for field, exprs in normalized.items() + }, + "requirements": sorted( + ( + dict(item, role=labels[item["role"]]) + for item in source["requirements"] + ), + key=dumps, + ), + "temporal": sorted( + ( + { + "op": "sequence", + "args": [ + _canonical_expression(expr, labels) for expr in item["args"] + ], + } + for item in temporal + ), + key=dumps, + ), + } + key = dumps(candidate) + if best_key is None or key < best_key: + best, best_key = candidate, key + best_labels = labels + assert best is not None and best_labels is not None + return best, best_labels + + +def canonical_template(template: Mapping[str, Any]) -> dict[str, Any]: + """Return a validated canonical semantic payload with no self digest. + + Args: + template: Normative TaskTemplate data, optionally including its hash. + + Returns: + Canonical roles and predicates. Only role renaming, inverse spatial + relations, SI scaling and commutative sorting are supported. Temporal + argument order is preserved. Instance roles bind this payload. + + Raises: + ValueError: Unsupported or invalid bounded template. + """ + return _canonicalize(template)[0] + + +def canonical_role_map(template: Mapping[str, Any]) -> dict[str, str]: + """Map author role names to the winning canonical template's role IDs. + + Args: + template: Normative template used to select physical role grounding. + + Returns: + Mapping used to rekey instance roles before computing content identity. + Symmetric ties are resolved by author name order. Evaluators consume + canonical_template with the canonical instance binding, never author + predicates with canonical IDs. + """ + return _canonicalize(template)[1] + + +def semantic_hash(template: Mapping[str, Any]) -> str: + """Hash canonical task semantics, excluding only the top-level self digest. + + Args: + template: Valid normative template data; execution fields are rejected. + + Returns: + SHA-256 of the canonical JSON payload. A present semantic_hash is + excluded to support authoring; validate_task_template verifies it. + """ + return hash_json(canonical_template(template)) diff --git a/embodichain/task_spec/contracts.py b/embodichain/task_spec/contracts.py new file mode 100644 index 000000000..3ceeb6937 --- /dev/null +++ b/embodichain/task_spec/contracts.py @@ -0,0 +1,477 @@ +# ---------------------------------------------------------------------------- +# 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 data records and evidence acceptance; no execution or storage ownership.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeAlias + +from ._json import digest, fields, hash_json, items, json_copy, text +from .canonicalization import canonical_template, semantic_hash +from .registry import registry_snapshot +from .validation import validate_template_structure + +__all__ = [ + "TASK_TEMPLATE_SCHEMA", + "SCENE_INSTANCE_SCHEMA", + "ACTION_WITNESS_SCHEMA", + "EXPANSION_MANIFEST_SCHEMA", + "VALIDATION_CERTIFICATE_SCHEMA", + "TaskTemplate", + "SceneInstance", + "ActionWitness", + "ExpansionManifest", + "ValidationCertificate", + "validate_task_template", + "scene_instance_hash", + "validate_scene_instance", + "validate_action_witness", + "validate_expansion_manifest", + "validate_validation_certificate", + "certificate_passed", +] + +TASK_TEMPLATE_SCHEMA = "taskspec/template/v0.1" +SCENE_INSTANCE_SCHEMA = "taskspec/scene_instance/v0.1" +ACTION_WITNESS_SCHEMA = "taskspec/action_witness/v0.1" +EXPANSION_MANIFEST_SCHEMA = "taskspec/expansion/v0.1" +VALIDATION_CERTIFICATE_SCHEMA = "taskspec/certificate/v0.1" + +TaskTemplate: TypeAlias = dict[str, Any] +SceneInstance: TypeAlias = dict[str, Any] +ActionWitness: TypeAlias = dict[str, Any] +ExpansionManifest: TypeAlias = dict[str, Any] +ValidationCertificate: TypeAlias = dict[str, Any] + +_SCOPES = frozenset( + { + "initial", + "preflight", + "planning", + "segment", + "task_goal", + "invariant", + "robustness", + "execution", + "capability", + "grounding", + } +) +_INSTANCE_FIELDS = { + "template_hash", + "roles", + "scene", + "embodiment", + "initial_state", + "evidence", +} + + +def _record( + value: object, schema: str, required: set[str], optional: set[str] | None = None +) -> dict[str, Any]: + result = fields( + json_copy(value), {"schema_version"} | required, optional or set(), schema + ) + if result["schema_version"] != schema: + raise ValueError(f"unsupported schema_version: expected {schema}") + return result + + +def _reference(value: object, path: str) -> None: + result = fields(value, {"uri", "content_hash"}, set(), path) + text(result["uri"], f"{path}.uri") + digest(result["content_hash"], f"{path}.content_hash") + + +def _references(value: object, path: str, *, nonempty: bool = False) -> None: + for index, ref in enumerate(items(value, path, nonempty=nonempty)): + _reference(ref, f"{path}[{index}]") + + +def _version(value: object, path: str) -> None: + result = fields(value, {"name", "version"}, set(), path) + text(result["name"], f"{path}.name") + text(result["version"], f"{path}.version") + + +def _unique_strings(value: object, path: str, *, nonempty: bool = False) -> list[str]: + result = [text(item, path) for item in items(value, path, nonempty=nonempty)] + if len(set(result)) != len(result): + raise ValueError(f"{path} must not contain duplicates") + return result + + +def _episode(value: dict[str, Any], path: str, *, required: bool = True) -> None: + env_id, episode_id = value["env_id"], value["episode_id"] + if not required and env_id is None and episode_id is None: + return + if type(env_id) is not int or env_id < 0: + raise ValueError(f"{path}.env_id must be a nonnegative integer") + text(episode_id, f"{path}.episode_id") + + +def validate_task_template(value: Mapping[str, Any]) -> TaskTemplate: + """Validate normative semantics and their stored identity. + + Args: + value: Complete TaskTemplate including semantic_hash. + + Returns: + Detached, validated record preserving author labels and input units. + + Raises: + ValueError: Invalid semantics, absent or stale semantic hash. + """ + result = validate_template_structure(value) + digest(result.get("semantic_hash"), "TaskTemplate.semantic_hash") + if result["semantic_hash"] != semantic_hash(result): + raise ValueError( + "TaskTemplate.semantic_hash does not match canonical semantics" + ) + return result + + +def _instance(value: Mapping[str, Any]) -> SceneInstance: + result = _record(value, SCENE_INSTANCE_SCHEMA, _INSTANCE_FIELDS, {"content_hash"}) + digest(result["template_hash"], "SceneInstance.template_hash") + roles = result["roles"] + if not isinstance(roles, dict) or not roles: + raise ValueError("SceneInstance.roles must be a nonempty object") + if len(roles) > 6 or set(roles) != {f"role_{i}" for i in range(len(roles))}: + raise ValueError("SceneInstance.roles must use contiguous canonical role IDs") + entities = [] + for role, binding in roles.items(): + text(role, "SceneInstance role") + fields(binding, {"entity_id", "asset"}, set(), "role binding") + entities.append(text(binding["entity_id"], "role binding.entity_id")) + _reference(binding["asset"], "role binding.asset") + if len(set(entities)) != len(entities): + raise ValueError("SceneInstance roles must bind distinct entities") + for field in ("scene", "embodiment", "initial_state"): + _reference(result[field], f"SceneInstance.{field}") + _references(result["evidence"], "SceneInstance.evidence", nonempty=True) + if "content_hash" in result: + digest(result["content_hash"], "SceneInstance.content_hash") + return result + + +def scene_instance_hash(value: Mapping[str, Any]) -> str: + """Return the exact content identity of an instance manifest. + + Args: + value: Valid instance payload with optional content_hash. + + Returns: + SHA-256 covering grounding, asset and component references, observed + initial-state and evidence references, excluding the top-level hash. + Referenced bytes must still be verified by the consuming host. + """ + result = _instance(value) + result.pop("content_hash", None) + return hash_json(result) + + +def validate_scene_instance( + value: Mapping[str, Any], + *, + template: Mapping[str, Any] | None = None, +) -> SceneInstance: + """Validate instance grounding, content references and stored identity. + + Args: + value: SceneInstance JSON with a content_hash. + template: Optional owner used to verify semantic identity and complete roles. + + Returns: + Detached instance manifest. No file is read and no initial state is + inferred from requested generator poses. + """ + result = _instance(value) + digest(result.get("content_hash"), "SceneInstance.content_hash") + if result["content_hash"] != scene_instance_hash(result): + raise ValueError("SceneInstance.content_hash does not match its content") + if template is not None: + owner = validate_task_template(template) + if owner["semantic_hash"] != result["template_hash"]: + raise ValueError("SceneInstance template identity does not match its owner") + if result["roles"].keys() != canonical_template(owner)["roles"].keys(): + raise ValueError("SceneInstance roles must exactly match its template") + return result + + +def validate_action_witness( + value: Mapping[str, Any], + *, + instance: Mapping[str, Any] | None = None, +) -> ActionWitness: + """Validate one candidate or executed solution's artifact references. + + Args: + value: Witness JSON; candidate execution must be null. + instance: Optional owner used to verify instance and template identities. + + Returns: + Detached record. Succeeded status requires affirmative program/task + results and execution/evaluation evidence references; this structural + validation does not certify the referenced reports' truth. + """ + result = _record( + value, + ACTION_WITNESS_SCHEMA, + { + "template_hash", + "instance_hash", + "status", + "plan", + "execution", + "evidence", + }, + ) + for key in ("template_hash", "instance_hash"): + digest(result[key], f"ActionWitness.{key}") + if instance is not None: + owner = validate_scene_instance(instance) + if ( + result["instance_hash"] != owner["content_hash"] + or result["template_hash"] != owner["template_hash"] + ): + raise ValueError( + "ActionWitness instance/template identities do not match its owner" + ) + status = text(result["status"], "ActionWitness.status") + if status not in {"candidate", "succeeded", "failed"}: + raise ValueError("unsupported ActionWitness status") + plan_refs = {"graph", "program", "integration", "execution_policy", "constraints"} + plan = fields( + result["plan"], + plan_refs | {"integration_fingerprint"}, + set(), + "ActionWitness.plan", + ) + for key in plan_refs: + _reference(plan[key], f"ActionWitness.plan.{key}") + digest(plan["integration_fingerprint"], "integration_fingerprint") + _references( + result["evidence"], "ActionWitness.evidence", nonempty=status != "candidate" + ) + if status == "candidate": + if result["execution"] is not None: + raise ValueError("candidate must not claim an execution result") + return result + execution = fields( + result["execution"], + { + "env_id", + "episode_id", + "program_success", + "task_success", + "runtime_result", + "task_evaluation", + "trajectory", + }, + set(), + "ActionWitness.execution", + ) + _episode(execution, "ActionWitness.execution") + for key in ("program_success", "task_success"): + if type(execution[key]) is not bool: + raise ValueError(f"execution.{key} must be a boolean") + for key in ("runtime_result", "task_evaluation"): + _reference(execution[key], f"ActionWitness.execution.{key}") + _references( + execution["trajectory"], + "ActionWitness.execution.trajectory", + nonempty=status == "succeeded", + ) + succeeded = execution["program_success"] and execution["task_success"] + if (status == "succeeded") != succeeded: + raise ValueError("witness status disagrees with program/task success") + return result + + +def validate_expansion_manifest(value: Mapping[str, Any]) -> ExpansionManifest: + """Validate lineage with independent change scope and semantic effect. + + Args: + value: Expansion record with parent/child content references. + + Returns: + Detached manifest. Invalidation declarations remain evidence to be + enforced by the host; this decoder neither expands nor runs checks. + """ + result = _record( + value, + EXPANSION_MANIFEST_SCHEMA, + { + "parent", + "changes", + "operator", + "seed", + "semantic_effect", + "invalidates", + }, + ) + _reference(result["parent"], "ExpansionManifest.parent") + for change in items(result["changes"], "changes", nonempty=True): + fields(change, {"scope", "child"}, set(), "change") + scope = text(change["scope"], "change.scope") + if scope not in {"language", "scene", "embodiment", "trajectory", "episode"}: + raise ValueError("unsupported expansion scope") + _reference(change["child"], "change.child") + operator = fields( + result["operator"], {"name", "version", "parameters"}, set(), "operator" + ) + _version({key: operator[key] for key in ("name", "version")}, "operator") + if not isinstance(operator["parameters"], dict): + raise ValueError("operator.parameters must be an object") + if type(result["seed"]) is not int or result["seed"] < 0: + raise ValueError("seed must be a nonnegative integer") + if text(result["semantic_effect"], "semantic_effect") not in { + "preserve", + "mutate", + "unknown", + }: + raise ValueError("unsupported semantic_effect") + for scope in _unique_strings(result["invalidates"], "invalidates"): + if scope not in _SCOPES: + raise ValueError(f"unsupported invalidated check scope {scope!r}") + return result + + +def validate_validation_certificate(value: Mapping[str, Any]) -> ValidationCertificate: + """Validate evidence records without promoting unrun checks to passes. + + Args: + value: Certificate with explicit required check IDs and evidence. + + Returns: + Detached certificate, including failed, unavailable and absent checks. + Use certificate_passed for policy acceptance, after the host verifies + referenced content. No checker or simulator is invoked here. + """ + result = _record( + value, + VALIDATION_CERTIFICATE_SCHEMA, + { + "template_hash", + "instance_hash", + "witness_id", + "checks", + "policy", + }, + ) + for key in ("template_hash", "instance_hash"): + digest(result[key], key) + _reference(result["witness_id"], "witness_id") + policy = fields( + result["policy"], {"id", "version", "required_checks"}, set(), "policy" + ) + text(policy["id"], "policy.id") + text(policy["version"], "policy.version") + _unique_strings(policy["required_checks"], "required_checks", nonempty=True) + ids = set() + episodes = set() + for check in items(result["checks"], "checks"): + fields( + check, + { + "id", + "status", + "scope", + "inputs", + "checker", + "predicate", + "parameters", + "metrics", + "env_id", + "episode_id", + "evidence", + }, + set(), + "check", + ) + identifier = text(check["id"], "check.id") + if identifier in ids: + raise ValueError("duplicate certificate check id") + ids.add(identifier) + status = text(check["status"], "check.status") + scope = text(check["scope"], "check.scope") + if status not in {"pass", "failed", "not_run", "unavailable", "unsupported"}: + raise ValueError("unsupported check status") + if scope not in _SCOPES: + raise ValueError("unsupported check scope") + inputs = check["inputs"] + if ( + not isinstance(inputs, dict) + or not {"template", "instance", "witness"} <= inputs.keys() + ): + raise ValueError( + "check.inputs requires template, instance and witness identities" + ) + for name, content in inputs.items(): + text(name, "input name") + digest(content, "check input content hash") + if ( + inputs["template"] != result["template_hash"] + or inputs["instance"] != result["instance_hash"] + or inputs["witness"] != result["witness_id"]["content_hash"] + ): + raise ValueError("check inputs disagree with certificate identities") + _version(check["checker"], "check.checker") + if check["predicate"] is not None: + _version(check["predicate"], "check.predicate") + if status == "pass": + spec = registry_snapshot()["predicates"].get(check["predicate"]["name"]) + if spec is None or spec["revision"] != check["predicate"]["version"]: + raise ValueError("pass requires a supported predicate revision") + elif status == "pass" and scope in {"initial", "task_goal", "invariant"}: + raise ValueError("predicate checks require a predicate revision") + for key in ("parameters", "metrics"): + if not isinstance(check[key], dict): + raise ValueError(f"check.{key} must be an object") + _episode( + check, + "check", + required=scope + in {"initial", "segment", "task_goal", "invariant", "execution"} + and status == "pass", + ) + if check["episode_id"] is not None: + episodes.add((check["env_id"], check["episode_id"])) + _references(check["evidence"], "check.evidence", nonempty=status == "pass") + if len(episodes) > 1: + raise ValueError("one certificate cannot mix env/episode evidence") + return result + + +def certificate_passed(value: Mapping[str, Any]) -> bool: + """Apply the certificate's explicit required-check acceptance policy. + + Args: + value: Certificate with valid input/checker/evidence references. + + Returns: + True only if every required check exists and has status pass. This is + report acceptance, not independent verification of referenced bytes. + """ + certificate = validate_validation_certificate(value) + statuses = {check["id"]: check["status"] for check in certificate["checks"]} + return all( + statuses.get(key) == "pass" for key in certificate["policy"]["required_checks"] + ) diff --git a/embodichain/task_spec/expressions.py b/embodichain/task_spec/expressions.py new file mode 100644 index 000000000..6ee67385a --- /dev/null +++ b/embodichain/task_spec/expressions.py @@ -0,0 +1,145 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Closed predicate AST with explicit thresholds and SI normalization.""" + +from __future__ import annotations + +from decimal import Decimal +import re +from typing import Any + +from ._json import fields, items, json_copy, text +from .registry import registry_snapshot + +__all__ = ["validate_expression"] + +_UNITS = { + "m": ("length", 0, "m"), + "cm": ("length", -2, "m"), + "mm": ("length", -3, "m"), + "s": ("time", 0, "s"), + "ms": ("time", -3, "s"), + "rad": ("angle", 0, "rad"), +} +_SPECS = registry_snapshot()["predicates"] +_DECIMAL = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?") + + +def _quantity( + value: object, dimension: str, path: str, *, signed: bool = False +) -> dict[str, Any]: + quantity = fields(value, {"value", "unit"}, set(), path) + number = quantity["value"] + unit = text(quantity["unit"], f"{path}.unit") + if type(number) not in (int, float, str): + raise ValueError(f"{path}.value must be a decimal quantity") + token = str(number) + if len(token) > 128 or _DECIMAL.fullmatch(token) is None: + raise ValueError(f"{path}.value must be a bounded finite decimal") + exponent_token = token.lower().partition("e")[2] + if exponent_token and not -256 <= int(exponent_token) <= 256: + raise ValueError(f"{path}.value exceeds decimal exponent limit") + decimal = Decimal(token) + if not signed and decimal < 0: + raise ValueError(f"{path}.value must be nonnegative") + if not -256 <= decimal.as_tuple().exponent <= 256: + raise ValueError(f"{path}.value exceeds decimal exponent limit") + if unit not in _UNITS or _UNITS[unit][0] != dimension: + raise ValueError(f"{path} unsupported {dimension} unit {unit!r}") + _, shift, si = _UNITS[unit] + sign, digits, exponent = decimal.as_tuple() + # Constructing a tuple is exact and independent of the ambient context. + normalized = Decimal((sign, digits, exponent + shift)) + if normalized.is_zero(): + token = "0" + else: + token = format(normalized, "f") + if "." in token: + token = token.rstrip("0").rstrip(".") + if len(token) > 128: + raise ValueError(f"{path}.value exceeds canonical decimal length limit") + return {"value": token, "unit": si} + + +def _expression( + value: object, roles: dict[str, Any] | None, depth: int = 0 +) -> dict[str, Any]: + if depth > 16: + raise ValueError("expression exceeds TaskSpec depth limit") + if not isinstance(value, dict): + raise ValueError("expression must be an object") + if "op" in value: + fields(value, {"op", "args"}, set(), "expression") + op = text(value["op"], "expression.op") + if op not in {"and", "or", "not"}: + raise ValueError(f"unsupported logical operator {op!r}") + args = items(value["args"], "expression.args", nonempty=True) + if op == "not" and len(args) != 1: + raise ValueError("not requires exactly one argument") + return {"op": op, "args": [_expression(arg, roles, depth + 1) for arg in args]} + name = text(value.get("predicate"), "expression.predicate") + spec = _SPECS.get(name) + if spec is None: + raise ValueError(f"unsupported predicate {name!r}") + required = {"predicate"} | spec["roles"].keys() | spec["quantities"].keys() + if name == "relative_position": + required |= {"relation"} + fields(value, required, set(), f"predicate {name}") + result: dict[str, Any] = {"predicate": name} + for key, kinds in spec["roles"].items(): + role = text(value[key], f"{name}.{key}") + if roles is not None and ( + role not in roles or roles[role]["kind"] not in kinds + ): + raise ValueError( + f"{name}.{key} has an unknown or incompatible role {role!r}" + ) + result[key] = role + if len(set(result[key] for key in spec["roles"])) != len(spec["roles"]): + raise ValueError(f"{name} requires distinct role references") + for key, dimension in spec["quantities"].items(): + result[key] = _quantity( + value[key], dimension, f"{name}.{key}", signed=key == "position" + ) + if key == "duration" and Decimal(result[key]["value"]) <= 0: + raise ValueError("held_stable.duration must be positive") + if name == "relative_position": + relation = text(value["relation"], "relative_position.relation") + if relation not in {"left_of", "right_of", "in_front_of", "behind"}: + raise ValueError(f"unsupported relative_position relation {relation!r}") + result["relation"] = relation + return result + + +def validate_expression(value: object, path: str = "expression") -> dict[str, Any]: + """Validate and normalize one bounded expression, without evaluating it. + + Args: + value: JSON predicate or and/or/not expression with explicit quantities. + path: Diagnostic context included in validation errors. + + Returns: + Detached expression with lengths in meters, time in seconds and angles + in radians. Role membership is checked by the template decoder. + + Raises: + ValueError: Unknown fields, predicates, units, malformed or oversized data. + """ + try: + return _expression(json_copy(value), None) + except ValueError as error: + raise ValueError(f"{path}: {error}") from error diff --git a/embodichain/task_spec/registry.py b/embodichain/task_spec/registry.py new file mode 100644 index 000000000..5a5676a7f --- /dev/null +++ b/embodichain/task_spec/registry.py @@ -0,0 +1,105 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Versioned semantic vocabulary; this module registers no execution services.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +__all__ = ["registry_snapshot"] + +_OBJECTS = ["object", "container", "support"] +_PREDICATES = { + "object_at_target": { + "roles": {"object": _OBJECTS, "target": ["target"]}, + "quantities": {"tolerance": "length"}, + "meaning": "Object origin is within tolerance of a bound target origin.", + }, + "relative_position": { + "roles": {"object": _OBJECTS, "reference": _OBJECTS}, + "quantities": {"margin": "length"}, + "meaning": "Origin separation along scene X (right) or Y (front), at least margin; Z is up.", + }, + "upright": { + "roles": {"object": _OBJECTS}, + "quantities": {"max_tilt": "angle"}, + "meaning": "Object local +Z tilt from scene +Z is at most max_tilt.", + }, + "stack_supported": { + "roles": {"object": _OBJECTS, "support": _OBJECTS}, + "quantities": { + "max_tilt": "angle", + "max_gap": "length", + "max_offset": "length", + }, + "meaning": "Upright bounding-box support approximation, not contact or force proof.", + }, + "held_stable": { + "roles": {"object": _OBJECTS, "holder": ["manipulator"]}, + "quantities": { + "position_tolerance": "length", + "angular_tolerance": "angle", + "duration": "time", + }, + "meaning": "Observed held-relative pose drift over duration, not continuous contact proof.", + }, + "released": { + "roles": {"object": _OBJECTS, "holder": ["manipulator"]}, + "quantities": {}, + "meaning": "Observed detachment from the named holder, not global absence of contact.", + }, + "joint_position": { + "roles": {"joint": ["joint"]}, + "quantities": {"position": "angle", "tolerance": "angle"}, + "meaning": "Revolute joint position within tolerance, without angle wrapping.", + }, +} + + +def registry_snapshot() -> dict[str, Any]: + """Return a detached semantic vocabulary snapshot. + + Returns: + Closed role, asset-capability and predicate definitions for semantic + version 0.1. Predicate revisions identify meaning, not installed + checkers; hosts must report unavailable when observations are absent. + """ + return { + "semantic_version": "0.1", + "max_roles": 6, + "role_kinds": [ + "object", + "container", + "support", + "target", + "manipulator", + "joint", + ], + "capabilities": [ + "graspable", + "placeable", + "orientable", + "handover", + "dual_graspable", + "rigid", + ], + "predicates": { + name: dict(deepcopy(spec), revision="1") + for name, spec in _PREDICATES.items() + }, + } diff --git a/embodichain/task_spec/validation.py b/embodichain/task_spec/validation.py new file mode 100644 index 000000000..2491a2733 --- /dev/null +++ b/embodichain/task_spec/validation.py @@ -0,0 +1,116 @@ +# ---------------------------------------------------------------------------- +# 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 template structure and role-reference checks without simulator imports.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ._json import digest, fields, items, json_copy, text +from .expressions import _expression +from .registry import registry_snapshot + +__all__ = ["validate_template_structure"] + + +def validate_template_structure(value: Mapping[str, Any]) -> dict[str, Any]: + """Validate normative template data without verifying its optional digest. + + Args: + value: TaskTemplate JSON; semantic_hash may be absent during authoring. + + Returns: + Detached JSON preserving author role names and quantity units. + + Raises: + ValueError: Invalid schema, fields, roles, capabilities or expressions. + """ + result = fields( + json_copy(value), + { + "schema_version", + "semantic_version", + "roles", + "init", + "goal", + "invariants", + "requirements", + }, + {"semantic_hash", "temporal"}, + "TaskTemplate", + ) + + def predicate_count(item: object) -> int: + if isinstance(item, dict): + return int("predicate" in item) + sum( + predicate_count(child) for child in item.values() + ) + if isinstance(item, list): + return sum(predicate_count(child) for child in item) + return 0 + + if ( + sum( + predicate_count(result.get(field, [])) + for field in ("init", "goal", "invariants", "temporal") + ) + > 128 + ): + raise ValueError("TaskTemplate exceeds the 128 predicate limit") + snapshot = registry_snapshot() + if ( + result["schema_version"] != "taskspec/template/v0.1" + or result["semantic_version"] != snapshot["semantic_version"] + ): + raise ValueError("unsupported TaskTemplate schema/semantic version") + if "semantic_hash" in result: + digest(result["semantic_hash"], "TaskTemplate.semantic_hash") + roles = result["roles"] + if not isinstance(roles, dict) or not 1 <= len(roles) <= snapshot["max_roles"]: + raise ValueError("TaskTemplate requires between one and six roles") + for name, role in roles.items(): + text(name, "role name") + fields(role, {"kind"}, {"capabilities"}, f"roles.{name}") + if role["kind"] not in snapshot["role_kinds"]: + raise ValueError(f"unsupported role kind for {name}") + capabilities = items(role.get("capabilities", []), f"roles.{name}.capabilities") + for capability in capabilities: + if capability not in snapshot["capabilities"]: + raise ValueError(f"unsupported capability {capability!r}") + if len(set(capabilities)) != len(capabilities): + raise ValueError(f"duplicate capabilities for role {name}") + for field in ("init", "goal", "invariants"): + for expression in items(result[field], field, nonempty=field == "goal"): + _expression(expression, roles) + for requirement in items(result["requirements"], "requirements"): + fields(requirement, {"role", "capability"}, set(), "requirement") + role = text(requirement["role"], "requirement.role") + if role not in roles: + raise ValueError("requirement references unknown role") + if requirement["capability"] not in snapshot["capabilities"]: + raise ValueError("unsupported requirement capability") + for temporal in items(result.get("temporal", []), "temporal"): + fields(temporal, {"op", "args"}, set(), "temporal") + if temporal["op"] != "sequence": + raise ValueError("unsupported temporal operator") + args = items(temporal["args"], "temporal.args", nonempty=True) + if len(args) < 2: + raise ValueError("temporal sequence requires at least two observations") + for expression in args: + _expression(expression, roles) + return result diff --git a/tests/gen_sim/task_engine/test_task_spec_planning.py b/tests/gen_sim/task_engine/test_task_spec_planning.py new file mode 100644 index 000000000..a608e38bc --- /dev/null +++ b/tests/gen_sim/task_engine/test_task_spec_planning.py @@ -0,0 +1,213 @@ +# ---------------------------------------------------------------------------- +# 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 pytest + +from embodichain.task_spec import semantic_hash, scene_instance_hash +from embodichain.gen_sim.task_engine.agent import TaskAgent +from embodichain.gen_sim.task_engine.interpretation import InstructionDraftResult +from embodichain.gen_sim.task_engine.orchestration.contracts import ROLE_BINDINGS_SCHEMA +from embodichain.gen_sim.task_engine.semantic_planner import SemanticTaskPlanner +from embodichain.gen_sim.task_engine.semantic_graph import validate_semantic_task_graph +from embodichain.gen_sim.task_engine.task_program_bundle import ( + generate_task_program_bundle, +) + + +def planning_inputs(arm="auto"): + selector = { + "kind": "none", + "step_id": "", + "reference": "", + "quantifier": "one", + "count": 0, + } + step = { + "id": "orient", + "task_type": "E2", + "object": dict(selector, kind="scene_ref", reference="can"), + "target": selector, + "relation": "none", + "required_arm": arm, + "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": [], + } + candidate = TaskAgent( + interpreter=lambda *_args, **_kwargs: InstructionDraftResult( + intent={"steps": [step]}, + model="injected", + attempts=1, + latency_seconds=0, + normalizations=(), + ) + ).generate("orient", "Make the can upright", candidate_count=1)["candidates"][0] + binding = { + "schema_version": ROLE_BINDINGS_SCHEMA, + "task_id": "orient", + "candidate_id": candidate["candidate_id"], + "reference_bindings": {"step_01.object": ["can"]}, + "role_bindings": {}, + } + objects = [{"runtime_uid": "can", "init_pos": [0, -0.2, 0.7]}] + template = { + "schema_version": "taskspec/template/v0.1", + "semantic_version": "0.1", + "roles": {"item": {"kind": "object"}}, + "init": [], + "invariants": [], + "requirements": [], + "goal": [ + { + "predicate": "upright", + "object": "item", + "max_tilt": {"value": 0.1, "unit": "rad"}, + } + ], + } + template["semantic_hash"] = semantic_hash(template) + + def ref(uri): + return {"uri": uri, "content_hash": "a" * 64} + + instance = { + "schema_version": "taskspec/scene_instance/v0.1", + "template_hash": template["semantic_hash"], + "roles": {"role_0": {"entity_id": "can", "asset": ref("can.glb")}}, + "scene": ref("scene.yaml"), + "embodiment": ref("robot.yaml"), + "initial_state": ref("observed_state.json"), + "evidence": [ref("initial_check.json")], + } + instance["content_hash"] = scene_instance_hash(instance) + return candidate, binding, objects, template, instance + + +def test_planner_keeps_legacy_graph_and_adds_explicit_task_spec_provenance(): + candidate, binding, objects, template, instance = planning_inputs() + planner = SemanticTaskPlanner() + legacy = planner.plan(candidate, binding, objects) + graph = planner.plan( + candidate, binding, objects, task_template=template, scene_instance=instance + ) + assert legacy["schema_version"] == "semantic_task_graph/v1" + assert "task_spec" not in legacy + assert graph["schema_version"] == "semantic_task_graph/v2" + assert graph["task_spec"] == { + "template_hash": template["semantic_hash"], + "instance_hash": instance["content_hash"], + "legacy_plan_hash": candidate["semantic_hash"], + "status": "candidate", + } + assert graph["nodes"] == legacy["nodes"] + assert validate_semantic_task_graph(graph) == graph + changed = deepcopy(graph) + changed["task_spec"]["status"] = "succeeded" + with pytest.raises(ValueError): + validate_semantic_task_graph(changed) + + +def test_two_recipe_choices_share_task_identity_but_keep_legacy_plan_hash(): + graphs = [] + for arm in ("left_arm", "right_arm"): + candidate, binding, objects, template, instance = planning_inputs(arm) + graphs.append( + SemanticTaskPlanner().plan( + candidate, + binding, + objects, + task_template=template, + scene_instance=instance, + ) + ) + assert ( + graphs[0]["task_spec"]["template_hash"] + == graphs[1]["task_spec"]["template_hash"] + ) + assert ( + graphs[0]["task_spec"]["legacy_plan_hash"] + != graphs[1]["task_spec"]["legacy_plan_hash"] + ) + assert graphs[0]["nodes"] != graphs[1]["nodes"] + + +def test_task_spec_requires_paired_inputs_and_matching_physical_binding(): + candidate, binding, objects, template, instance = planning_inputs() + with pytest.raises(ValueError): + SemanticTaskPlanner().plan(candidate, binding, objects, task_template=template) + instance["roles"]["role_0"]["entity_id"] = "other_can" + instance["content_hash"] = scene_instance_hash(instance) + with pytest.raises(ValueError, match="binding"): + SemanticTaskPlanner().plan( + candidate, binding, objects, task_template=template, scene_instance=instance + ) + + +def test_task_spec_bundle_export_is_gated_before_creating_artifacts(tmp_path): + candidate, binding, objects, template, instance = planning_inputs() + graph = SemanticTaskPlanner().plan( + candidate, + binding, + objects, + task_template=template, + scene_instance=instance, + ) + destination = tmp_path / "bundle" + with pytest.raises(ValueError, match="evaluation"): + generate_task_program_bundle( + graph, None, destination, robot_profile="dual_franka" + ) + assert not destination.exists() + + +def test_imported_task_spec_graph_cannot_bypass_runtime_evaluation_gate(tmp_path): + import json + from embodichain.gen_sim.task_engine._bundle_runner import execute_bundle + + candidate, binding, objects, template, instance = planning_inputs() + graph = SemanticTaskPlanner().plan( + candidate, + binding, + objects, + task_template=template, + scene_instance=instance, + ) + bundle = tmp_path / "imported_bundle" + (bundle / "task_program").mkdir(parents=True) + (bundle / "semantic_task_graph.json").write_text( + json.dumps(graph), encoding="utf-8" + ) + # V2 must be rejected before even attempting to decode deployment files. + for name in ( + "task_program_deployment.yaml", + "task_program/program.yaml", + "integration_fingerprint.json", + ): + (bundle / name).write_text("{}", encoding="utf-8") + output = tmp_path / "execution" + with pytest.raises(ValueError, match="evaluation"): + execute_bundle(bundle, execution_output=output) + assert not output.exists() diff --git a/tests/task_spec/test_contracts.py b/tests/task_spec/test_contracts.py new file mode 100644 index 000000000..b1a0e2e36 --- /dev/null +++ b/tests/task_spec/test_contracts.py @@ -0,0 +1,325 @@ +# ---------------------------------------------------------------------------- +# 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 pytest + +from embodichain.task_spec import ( + semantic_hash, + validate_task_template, + validate_scene_instance, + validate_action_witness, + validate_expansion_manifest, + validate_validation_certificate, +) +from embodichain.task_spec._json import hash_json + +_DIGEST = "a" * 64 + + +def ref(name): + return {"uri": name, "content_hash": _DIGEST} + + +def instance(): + value = { + "schema_version": "taskspec/scene_instance/v0.1", + "template_hash": _DIGEST, + "roles": {"role_0": {"entity_id": "cube_01", "asset": ref("cube.glb")}}, + "scene": ref("scene.yaml"), + "embodiment": ref("robot.yaml"), + "initial_state": ref("observed_initial_state.json"), + "evidence": [ref("initial_inspection.json")], + } + value["content_hash"] = hash_json(value) + return value + + +def witness(status="candidate"): + return { + "schema_version": "taskspec/action_witness/v0.1", + "template_hash": _DIGEST, + "instance_hash": instance()["content_hash"], + "status": status, + "plan": { + "graph": ref("graph.json"), + "program": ref("program.yaml"), + "integration": ref("integration.yaml"), + "execution_policy": ref("execution_policy.yaml"), + "constraints": ref("constraints.json"), + "integration_fingerprint": _DIGEST, + }, + "execution": None, + "evidence": [], + } + + +def check(status="pass"): + return { + "id": "final_goal", + "status": status, + "scope": "task_goal", + "inputs": { + "template": _DIGEST, + "instance": instance()["content_hash"], + "witness": _DIGEST, + }, + "checker": {"name": "goal_checker", "version": "1"}, + "predicate": {"name": "relative_position", "version": "1"}, + "parameters": {"margin_m": 0.1}, + "metrics": {"distance_m": 0.12}, + "env_id": 0, + "episode_id": "episode_1", + "evidence": [ref("task_evaluation.json")], + } + + +def certificate(status="pass"): + return { + "schema_version": "taskspec/certificate/v0.1", + "template_hash": _DIGEST, + "instance_hash": instance()["content_hash"], + "witness_id": ref("witness.json"), + "checks": [check(status)], + "policy": {"id": "demo", "version": "1", "required_checks": ["final_goal"]}, + } + + +def test_instance_recomputes_content_identity_and_owns_copied_grounding(): + value = instance() + decoded = validate_scene_instance(value) + decoded["roles"]["role_0"]["entity_id"] = "another" + assert value["roles"]["role_0"]["entity_id"] == "cube_01" + for field in ("roles", "scene", "embodiment", "initial_state", "template_hash"): + changed = instance() + if field == "roles": + changed[field]["role_0"]["entity_id"] = "cube_02" + elif field == "template_hash": + changed[field] = "b" * 64 + else: + changed[field]["content_hash"] = "b" * 64 + with pytest.raises(ValueError, match="hash"): + validate_scene_instance(changed) + + +@pytest.mark.parametrize( + "field", ["template_hash", "scene", "roles", "initial_state", "evidence"] +) +def test_instance_requires_typed_references_and_grounding(field): + value = instance() + value[field] = None + with pytest.raises(ValueError): + validate_scene_instance(value) + + +def test_two_candidates_share_task_identity_without_sharing_program(): + first = witness() + second = deepcopy(first) + second["plan"]["program"] = {"uri": "alternate.yaml", "content_hash": "b" * 64} + decoded = [validate_action_witness(item) for item in (first, second)] + assert decoded[0]["template_hash"] == decoded[1]["template_hash"] + assert decoded[0]["plan"]["program"] != decoded[1]["plan"]["program"] + + +def test_successful_witness_requires_actual_execution_and_task_success(): + value = witness("succeeded") + with pytest.raises(ValueError): + validate_action_witness(value) + value["execution"] = { + "env_id": 0, + "episode_id": "episode_1", + "program_success": True, + "task_success": True, + "runtime_result": ref("runtime.json"), + "task_evaluation": ref("goal.json"), + "trajectory": [ref("trajectory.npz")], + } + value["evidence"] = [ref("execution_report.json")] + assert validate_action_witness(value)["status"] == "succeeded" + value["execution"]["task_success"] = False + with pytest.raises(ValueError): + validate_action_witness(value) + + +@pytest.mark.parametrize("status", ["not_run", "unavailable", "failed", "unsupported"]) +def test_nonpass_required_checks_are_preserved_as_nonpass(status): + from embodichain.task_spec.contracts import certificate_passed + + value = validate_validation_certificate(certificate(status)) + assert value["checks"][0]["status"] == status + assert not certificate_passed(value) + + +def test_missing_required_checks_and_evidence_cannot_be_pass(): + from embodichain.task_spec.contracts import certificate_passed + + assert certificate_passed(certificate()) + value = certificate() + value["checks"] = [] + assert not certificate_passed(value) + for field in ( + "checker", + "inputs", + "predicate", + "evidence", + "parameters", + "metrics", + "env_id", + "episode_id", + ): + value = certificate() + value["checks"][0].pop(field) + with pytest.raises(ValueError): + validate_validation_certificate(value) + value = certificate() + value["checks"][0]["evidence"] = [] + with pytest.raises(ValueError): + validate_validation_certificate(value) + + +def test_duplicate_checks_and_mismatched_input_identity_are_rejected(): + value = certificate() + value["checks"].append(deepcopy(value["checks"][0])) + with pytest.raises(ValueError): + validate_validation_certificate(value) + value = certificate() + value["checks"][0]["inputs"]["instance"] = "b" * 64 + with pytest.raises(ValueError): + validate_validation_certificate(value) + + +def expansion(): + return { + "schema_version": "taskspec/expansion/v0.1", + "parent": ref("parent.json"), + "changes": [{"scope": "trajectory", "child": ref("child.json")}], + "operator": {"name": "retime", "version": "1", "parameters": {"factor": 1.2}}, + "seed": 7, + "semantic_effect": "preserve", + "invalidates": ["planning", "execution", "task_goal"], + } + + +def test_expansion_keeps_scope_and_semantic_effect_independent(): + assert validate_expansion_manifest(expansion())["semantic_effect"] == "preserve" + for field, bad in [ + ("seed", True), + ("semantic_effect", "semantic_preserving"), + ("changes", [{"scope": "invalid", "child": ref("child")}]), + ]: + value = expansion() + value[field] = bad + with pytest.raises(ValueError): + validate_expansion_manifest(value) + + +def test_json_safe_parameters_reject_nested_callables(): + value = expansion() + value["operator"]["parameters"]["callback"] = lambda: True + with pytest.raises(ValueError): + validate_expansion_manifest(value) + + +def test_certificate_checks_bind_witness_and_cannot_mix_environment_rows(): + value = certificate() + value["checks"][0]["inputs"]["witness"] = "b" * 64 + with pytest.raises(ValueError, match="identit"): + validate_validation_certificate(value) + value = certificate() + another = deepcopy(value["checks"][0]) + another.update(id="other_goal", env_id=1) + value["checks"].append(another) + with pytest.raises(ValueError, match="env/episode"): + validate_validation_certificate(value) + + +def test_instance_and_witness_can_be_validated_against_their_owners(): + from embodichain.task_spec.contracts import scene_instance_hash + + template = { + "schema_version": "taskspec/template/v0.1", + "semantic_version": "0.1", + "roles": {"cube": {"kind": "object"}}, + "init": [], + "requirements": [], + "invariants": [], + "goal": [ + { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": 0.1, "unit": "rad"}, + } + ], + } + template["semantic_hash"] = semantic_hash(template) + scene = instance() + scene["template_hash"] = template["semantic_hash"] + scene["content_hash"] = scene_instance_hash(scene) + assert validate_scene_instance(scene, template=template) == scene + action = witness() + action.update( + template_hash=scene["template_hash"], instance_hash=scene["content_hash"] + ) + assert validate_action_witness(action, instance=scene) == action + action["instance_hash"] = "b" * 64 + with pytest.raises(ValueError, match="instance"): + validate_action_witness(action, instance=scene) + scene["roles"]["role_1"] = {"entity_id": "extra", "asset": ref("extra.glb")} + scene["content_hash"] = scene_instance_hash(scene) + with pytest.raises(ValueError, match="roles"): + validate_scene_instance(scene, template=template) + + +def test_canonical_role_binding_distinguishes_opposite_grounded_goals(): + from embodichain.task_spec import canonical_role_map, scene_instance_hash + + first = { + "schema_version": "taskspec/template/v0.1", + "semantic_version": "0.1", + "roles": {"a": {"kind": "object"}, "b": {"kind": "object"}}, + "init": [], + "invariants": [], + "requirements": [], + "goal": [ + { + "predicate": "relative_position", + "object": "a", + "reference": "b", + "relation": "left_of", + "margin": {"value": 0.1, "unit": "m"}, + } + ], + } + second = deepcopy(first) + second["goal"][0].update(object="b", reference="a") + assert semantic_hash(first) == semantic_hash(second) + scenes = [] + physical = {"a": "physical_A", "b": "physical_B"} + for task in (first, second): + task["semantic_hash"] = semantic_hash(task) + labels = canonical_role_map(task) + scene = instance() + scene["template_hash"] = task["semantic_hash"] + scene["roles"] = { + labels[author]: {"entity_id": entity, "asset": ref(entity + ".glb")} + for author, entity in physical.items() + } + scene["content_hash"] = scene_instance_hash(scene) + scenes.append(validate_scene_instance(scene, template=task)) + assert scenes[0]["content_hash"] != scenes[1]["content_hash"] + assert scenes[0]["roles"] != scenes[1]["roles"] diff --git a/tests/task_spec/test_task_spec.py b/tests/task_spec/test_task_spec.py new file mode 100644 index 000000000..28774b783 --- /dev/null +++ b/tests/task_spec/test_task_spec.py @@ -0,0 +1,366 @@ +# ---------------------------------------------------------------------------- +# 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 +import pytest + +from embodichain.task_spec import ( + canonical_template, + semantic_hash, + validate_task_template, +) +from embodichain.task_spec.expressions import validate_expression + + +def template(): + return { + "schema_version": "taskspec/template/v0.1", + "semantic_version": "0.1", + "roles": {"cube": {"kind": "object"}, "tray": {"kind": "container"}}, + "init": [], + "goal": [ + { + "predicate": "relative_position", + "object": "cube", + "reference": "tray", + "relation": "left_of", + "margin": {"value": 10, "unit": "cm"}, + } + ], + "invariants": [], + "requirements": [], + } + + +def seal(value): + result = deepcopy(value) + result["semantic_hash"] = semantic_hash(result) + return result + + +def test_roundtrip_and_repeated_hashing_are_stable(): + value = seal(template()) + assert semantic_hash(value) == value["semantic_hash"] + assert validate_task_template(json.loads(json.dumps(value))) == value + canonical = canonical_template(value) + assert canonical_template(canonical) == canonical + + +def test_renaming_roles_does_not_depend_on_alphabetical_order(): + first = template() + renamed = template() + renamed["roles"] = {"zz": {"kind": "object"}, "aa": {"kind": "container"}} + renamed["goal"][0].update(object="zz", reference="aa") + assert semantic_hash(first) == semantic_hash(renamed) + + +def test_identical_role_kinds_and_commutative_terms_are_canonical(): + first = template() + first["roles"]["tray"]["kind"] = "object" + first["goal"].append( + { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": 0.1, "unit": "rad"}, + } + ) + renamed = template() + renamed["roles"] = {"right": {"kind": "object"}, "left": {"kind": "object"}} + renamed["goal"][0].update(object="right", reference="left") + renamed["goal"].insert( + 0, + { + "predicate": "upright", + "object": "right", + "max_tilt": {"value": 0.1, "unit": "rad"}, + }, + ) + assert semantic_hash(first) == semantic_hash(renamed) + + +@pytest.mark.parametrize( + "relation,inverse", [("left_of", "right_of"), ("in_front_of", "behind")] +) +def test_inverse_relations_and_si_units(relation, inverse): + first = template() + first["goal"][0]["relation"] = relation + second = deepcopy(first) + second["goal"][0].update( + object="tray", + reference="cube", + relation=inverse, + margin={"value": 0.1, "unit": "m"}, + ) + assert semantic_hash(first) == semantic_hash(second) + + +def test_normative_order_and_threshold_changes_change_identity(): + first = template() + first["temporal"] = [ + { + "op": "sequence", + "args": [ + { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": 0.1, "unit": "rad"}, + }, + deepcopy(first["goal"][0]), + ], + } + ] + changed = deepcopy(first) + changed["temporal"][0]["args"].reverse() + assert semantic_hash(first) != semantic_hash(changed) + changed = deepcopy(first) + changed["goal"][0]["margin"]["value"] = 20 + assert semantic_hash(first) != semantic_hash(changed) + + +@pytest.mark.parametrize( + "field", ["plan", "trajectory", "witness", "seed", "robot_model"] +) +def test_execution_details_are_rejected_at_template_boundary(field): + value = template() + value[field] = "not task semantics" + with pytest.raises(ValueError, match="fields"): + semantic_hash(value) + + +@pytest.mark.parametrize( + "expression", + [ + {"predicate": "pour_volume", "object": "cube"}, + {"predicate": "upright", "object": "cube"}, + { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": True, "unit": "rad"}, + }, + { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": -1, "unit": "rad"}, + }, + { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": 1, "unit": "m"}, + }, + { + "predicate": "released", + "object": "cube", + "holder": "arm", + "module": "os.system", + }, + {"op": "not", "args": []}, + { + "op": "and", + "args": [{"predicate": "released", "object": "cube", "holder": "arm"}], + "extra": 1, + }, + {"predicate": ["upright"]}, + {"op": "or", "args": [lambda: True]}, + ], +) +def test_restricted_ast_rejects_invalid_or_executable_values(expression): + with pytest.raises(ValueError): + validate_expression(expression) + + +def test_missing_roles_unknown_capabilities_and_invalid_versions_are_rejected(): + value = template() + value["goal"][0]["object"] = "missing" + with pytest.raises(ValueError, match="role"): + semantic_hash(value) + value = template() + value["roles"]["cube"]["capabilities"] = ["liquid_volume_observation"] + with pytest.raises(ValueError, match="capabilit"): + semantic_hash(value) + value = template() + value["semantic_version"] = "999" + with pytest.raises(ValueError, match="version"): + semantic_hash(value) + + +def test_nonfinite_cyclic_and_excessively_deep_inputs_are_rejected(): + for invalid in [float("nan"), float("inf"), -float("inf"), lambda: None]: + value = template() + value["goal"][0]["margin"]["value"] = invalid + with pytest.raises(ValueError): + semantic_hash(value) + cyclic = {"op": "not"} + cyclic["args"] = [cyclic] + with pytest.raises(ValueError): + validate_expression(cyclic) + expression = {"predicate": "released", "object": "cube", "holder": "arm"} + for _ in range(40): + expression = {"op": "not", "args": [expression]} + with pytest.raises(ValueError): + validate_expression(expression) + + +def test_validator_returns_detached_data_and_rejects_stale_hash(): + value = seal(template()) + decoded = validate_task_template(value) + decoded["roles"]["cube"]["kind"] = "support" + assert value["roles"]["cube"]["kind"] == "object" + value["goal"][0]["margin"]["value"] = 20 + with pytest.raises(ValueError, match="hash"): + validate_task_template(value) + + +def test_canonical_quantities_are_exact_and_independent_of_decimal_context(): + from decimal import localcontext + + first = template() + first["goal"][0]["margin"] = {"value": 0.123456789, "unit": "m"} + expected = semantic_hash(first) + with localcontext() as context: + context.prec = 6 + assert semantic_hash(first) == expected + second = deepcopy(first) + first["goal"][0]["margin"]["value"] = 2**53 + second["goal"][0]["margin"]["value"] = 2**53 + 1 + assert semantic_hash(first) != semantic_hash(second) + first["goal"][0]["margin"]["value"] = 10**400 + with pytest.raises(ValueError): + semantic_hash(first) + + +def test_decimal_units_preserve_exact_threshold_and_zero(): + first = template() + second = template() + first["goal"][0]["margin"] = {"value": "0.123456789123456789", "unit": "m"} + second["goal"][0]["margin"] = {"value": "12.3456789123456789", "unit": "cm"} + assert semantic_hash(first) == semantic_hash(second) + second["goal"][0]["margin"]["value"] = "12.3456789123456788" + assert semantic_hash(first) != semantic_hash(second) + first["goal"][0]["margin"]["value"] = "-0.00" + second["goal"][0]["margin"] = {"value": 0, "unit": "m"} + assert semantic_hash(first) == semantic_hash(second) + + +def test_si_normalization_remains_closed_at_small_decimal_boundaries(): + value = template() + value["goal"][0]["margin"] = {"value": "1e-100", "unit": "mm"} + canonical = canonical_template(value) + assert canonical_template(canonical) == canonical + + +def test_bounded_template_rejects_excessive_predicate_count_before_hashing(): + value = template() + value["goal"] = [ + { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": i, "unit": "rad"}, + } + for i in range(129) + ] + with pytest.raises(ValueError, match="predicate"): + semantic_hash(value) + + +@pytest.mark.parametrize( + "value", ["1e" + "9" * 100, "1e-9999", "NaN", "Infinity", True] +) +def test_invalid_decimal_quantities_raise_protocol_errors(value): + expression = { + "predicate": "upright", + "object": "cube", + "max_tilt": {"value": value, "unit": "rad"}, + } + with pytest.raises(ValueError): + validate_expression(expression) + + +@pytest.mark.parametrize( + "expression", + [ + { + "predicate": "object_at_target", + "object": "item", + "target": "site", + "tolerance": {"value": 2, "unit": "mm"}, + }, + { + "predicate": "stack_supported", + "object": "item", + "support": "base", + "max_tilt": {"value": 0.1, "unit": "rad"}, + "max_gap": {"value": 0.2, "unit": "cm"}, + "max_offset": {"value": 5, "unit": "mm"}, + }, + { + "predicate": "held_stable", + "object": "item", + "holder": "arm", + "position_tolerance": {"value": 2, "unit": "mm"}, + "angular_tolerance": {"value": 0.1, "unit": "rad"}, + "duration": {"value": 3000, "unit": "ms"}, + }, + {"predicate": "released", "object": "item", "holder": "arm"}, + { + "predicate": "joint_position", + "joint": "hinge", + "position": {"value": -0.5, "unit": "rad"}, + "tolerance": {"value": 0.1, "unit": "rad"}, + }, + ], +) +def test_whitelisted_predicates_roundtrip_without_executing_observers(expression): + normalized = validate_expression(expression) + assert validate_expression(normalized) == normalized + logical = {"op": "not", "args": [{"op": "or", "args": [expression, expression]}]} + assert validate_expression(logical)["op"] == "not" + + +def test_snapshot_mutation_does_not_change_supported_vocabulary(): + from embodichain.task_spec import registry_snapshot + + snapshot = registry_snapshot() + snapshot["predicates"]["upright"]["roles"]["object"].append("liquid") + snapshot["capabilities"].append("arbitrary") + assert ( + "liquid" not in registry_snapshot()["predicates"]["upright"]["roles"]["object"] + ) + assert "arbitrary" not in registry_snapshot()["capabilities"] + + +def test_pure_package_imports_without_optional_dependencies(): + from pathlib import Path + import subprocess + import sys + + root = Path(__file__).resolve().parents[2] + subprocess.run( + [ + sys.executable, + "-I", + "-S", + "-c", + "import sys; sys.path.insert(0, sys.argv[1]); import embodichain.task_spec; " + "assert not any(m.startswith(('torch', 'numpy', 'embodichain.lab', 'embodichain.gen_sim')) for m in sys.modules)", + str(root), + ], + check=True, + capture_output=True, + text=True, + ) diff --git a/tests/test_agent_context_map.py b/tests/test_agent_context_map.py index 2c4169047..bf3ec797d 100644 --- a/tests/test_agent_context_map.py +++ b/tests/test_agent_context_map.py @@ -65,6 +65,7 @@ def test_map_registers_the_supported_context_domains() -> None: "randomization", "atomic-actions", "task-programs", + "task-spec", "gen-sim", "data-assets", "data-pipeline", @@ -77,6 +78,7 @@ def test_map_registers_the_supported_context_domains() -> None: def test_new_topics_cover_their_owning_packages() -> None: topics = _topics_by_id() expected_source_prefixes = { + "task-spec": "embodichain/task_spec/", "gen-sim": "embodichain/gen_sim/", "data-assets": "embodichain/data/", "data-pipeline": "embodichain/data_pipeline/", @@ -99,6 +101,8 @@ def test_representative_queries_route_against_the_repository_map() -> None: helper = _load_helper() data = helper.load_map(_REPOSITORY_ROOT) expected_routes = { + "TaskSpec 任务语义协议在哪里?": ["task-spec"], + "SceneInstance 的内容身份如何验证?": ["task-spec"], "参考 env-framework 上下文,查 target_control_frequency 的配置优先级": [ "env-framework" ], From 8be7051b89a8cdbd293c88fc10027ef5139567c2 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 11 Sep 2026 03:58:11 +0000 Subject: [PATCH 2/5] fix(task-program): bind registered phase protections and recover empty plans --- .../topics/atomic-actions/execution.md | 5 + .../topics/task-programs/execution.md | 16 ++ .../task_engine/_task_program/configured.py | 16 +- .../task_engine/_task_program/services.py | 53 +++- .../lab/sim/atomic_actions/execution.py | 3 + .../lab/task_program/compiler/lowering.py | 229 +++++++++++++++- .../integrations/_configured_services.py | 16 ++ .../lab/task_program/runtime/executor.py | 18 +- .../task_engine/test_task_program_services.py | 47 ++++ .../task_program/test_semantic_compiler.py | 247 ++++++++++++++++++ .../task_program/test_semantic_executor.py | 41 ++- .../sim/atomic_actions/test_engine_per_env.py | 25 ++ 12 files changed, 700 insertions(+), 16 deletions(-) diff --git a/agent_context/topics/atomic-actions/execution.md b/agent_context/topics/atomic-actions/execution.md index ab286e432..b8a5721e8 100644 --- a/agent_context/topics/atomic-actions/execution.md +++ b/agent_context/topics/atomic-actions/execution.md @@ -105,6 +105,11 @@ Scene-relative goals declare the exact entity poses they consume. The session compares dependency revisions against fresh snapshots and replans only within the selected `RecoveryPolicy`. +A failed initial empty plan owns no command targets or feedback routes; its +first successful retry may establish both. Once established, runtime target +addresses and tracking source/projector ownership remain fixed across recovery. +An intervening empty failed replan does not erase that ownership. + `PlanningContext.control_dt` is the authoritative control grid. Every emitted trajectory or endpoint command must align to it; integrations must not silently resample fractional durations. diff --git a/agent_context/topics/task-programs/execution.md b/agent_context/topics/task-programs/execution.md index 1164a344b..535e29841 100644 --- a/agent_context/topics/task-programs/execution.md +++ b/agent_context/topics/task-programs/execution.md @@ -66,6 +66,22 @@ its call ID, revision, and target Atomic Skill descriptor. Use this extension to expose shared Atomic Skills; do not place task-local motion generators in a lowerer. +Registered lowerers can declare a typed `RegisteredPhaseProtectionKind` and +return a matching `RegisteredPhaseProtection` from `SemanticLowering`. +The compiler checks effect ownership and binds the declared endpoint/object +through the preset's existing measured-effect monitor. Acquisition gates motion +on attachment and guards subsequent held phases; release guards the input hold +and gates retreat on detachment. Release gates omit terminal geometric separation, +which can only be observed after retreat. Retention guards verified task state +without adding a terminal effect or changing symbolic-state ownership. Guard-only +calls validate their declared segment names against each active plan before +dispatch. Projected presets do not install measured protections. + +GenSim registered Pick and relative Place use these phase declarations. Its +configured held-move service opts in with +`phase_protection: held_object_v1` and a matching monitor mapping; omitted +configuration keeps legacy exported bundles on their existing behavior. + ## MLLM boundary `embodichain.agents.mllm.task_program` accepts untrusted JSON, reuses the diff --git a/embodichain/gen_sim/task_engine/_task_program/configured.py b/embodichain/gen_sim/task_engine/_task_program/configured.py index 82da0b443..0de571c1e 100644 --- a/embodichain/gen_sim/task_engine/_task_program/configured.py +++ b/embodichain/gen_sim/task_engine/_task_program/configured.py @@ -192,7 +192,12 @@ def decode_task_lowerer(value: object, *, path: str) -> Any: ) return make_pick_factory(tuple(routes), call_id) if kind == "move_held_object": - config = _mapping(value, path=path, required=frozenset({"kind", "routes"})) + config = _mapping( + value, + path=path, + required=frozenset({"kind", "routes"}), + optional=frozenset({"phase_protection"}), + ) routes: list[_MoveHeldObjectRoute] = [] for index, raw in enumerate(_sequence(config["routes"], path=f"{path}.routes")): route_path = f"{path}.routes[{index}]" @@ -212,7 +217,14 @@ def decode_task_lowerer(value: object, *, path: str) -> Any: pose=_decode_goal_pose(route["pose"], path=f"{route_path}.pose"), ) ) - return _MoveHeldObjectLowererFactory(tuple(routes)) + return _MoveHeldObjectLowererFactory( + tuple(routes), + phase_protection=( + _identifier(config["phase_protection"], path=f"{path}.phase_protection") + if "phase_protection" in config + else None + ), + ) if kind in {"coordinated_transport", "coordinated_hold"}: config = _mapping( value, diff --git a/embodichain/gen_sim/task_engine/_task_program/services.py b/embodichain/gen_sim/task_engine/_task_program/services.py index f87e97ade..a5eaa6591 100644 --- a/embodichain/gen_sim/task_engine/_task_program/services.py +++ b/embodichain/gen_sim/task_engine/_task_program/services.py @@ -55,6 +55,8 @@ ) from embodichain.lab.task_program.compiler.lowering import ( RegisteredHeldObjectEffect, + RegisteredPhaseProtection, + RegisteredPhaseProtectionKind, RegisteredSemanticLowerer, RegisteredSemanticEffect, SemanticLowering, @@ -237,6 +239,9 @@ class _PickLowerer(RegisteredSemanticLowerer): call_id: ClassVar[str] = _PICK_CALL_ID target_descriptor: ClassVar[SkillDescriptor] = PickUp.descriptor() effect_contract_kind: ClassVar[SemanticEffectKind] = SemanticEffectKind.ATTACH + phase_protection_kind: ClassVar[RegisteredPhaseProtectionKind] = ( + RegisteredPhaseProtectionKind.ACQUIRE + ) def __init__( self, @@ -310,6 +315,17 @@ def lower( ) return SemanticLowering( goal=GraspGoal(semantics=semantics), + phase_protection=RegisteredPhaseProtection( + kind=RegisteredPhaseProtectionKind.ACQUIRE, + held_object=RegisteredHeldObjectEffect( + expectation_id="primary", + relation=HeldObjectRelation.ATTACHED, + object_id=route.object_id, + slot_id="primary", + ), + active_segments=("lift",), + gate_segment="lift", + ), registered_effect=RegisteredSemanticEffect( effect_kind=SemanticEffectKind.ATTACH, held_objects=( @@ -470,7 +486,21 @@ def lower( ) route = self._route(call) return SemanticLowering( - goal=HeldObjectPoseGoal(_configured_goal_pose(route.pose)) + goal=HeldObjectPoseGoal(_configured_goal_pose(route.pose)), + phase_protection=( + RegisteredPhaseProtection( + kind=RegisteredPhaseProtectionKind.RETAIN, + held_object=RegisteredHeldObjectEffect( + expectation_id="primary", + relation=HeldObjectRelation.ATTACHED, + object_id=route.object_id, + slot_id="primary", + ), + active_segments=("transport",), + ) + if self.phase_protection_kind is not None + else None + ), ) def pick_lookahead_targets( @@ -497,6 +527,14 @@ def pick_lookahead_targets( ) +class _ProtectedMoveHeldObjectLowerer(_MoveHeldObjectLowerer): + """Opt-in transport with a compiler-bound measured held-object guard.""" + + phase_protection_kind: ClassVar[RegisteredPhaseProtectionKind] = ( + RegisteredPhaseProtectionKind.RETAIN + ) + + @dataclass(frozen=True, slots=True) class _MoveHeldObjectLowererFactory(RegisteredSemanticLowererFactory): """Validate canonical references for configured transport goals.""" @@ -505,8 +543,14 @@ class _MoveHeldObjectLowererFactory(RegisteredSemanticLowererFactory): revision: ClassVar[str] = "2" target_descriptor: ClassVar[SkillDescriptor] = MoveHeldObject.descriptor() routes: tuple[_MoveHeldObjectRoute, ...] + phase_protection: str | None = None def __post_init__(self) -> None: + if self.phase_protection is not None and ( + type(self.phase_protection) is not str + or self.phase_protection != "held_object_v1" + ): + raise ValueError("phase_protection must be 'held_object_v1' or omitted.") if ( type(self.routes) is not tuple or not self.routes @@ -534,7 +578,12 @@ def create( scene_registry.resolve(route.object_id, expected_type=SceneObjectRef) if type(route.pose) is _SceneEntityTarget: scene_registry.lookup(route.pose.entity_id) - return _MoveHeldObjectLowerer(self.routes) + lowerer_type = ( + _ProtectedMoveHeldObjectLowerer + if self.phase_protection is not None + else _MoveHeldObjectLowerer + ) + return lowerer_type(self.routes) @dataclass(frozen=True, slots=True) diff --git a/embodichain/lab/sim/atomic_actions/execution.py b/embodichain/lab/sim/atomic_actions/execution.py index 62b2967df..dba4e1a64 100644 --- a/embodichain/lab/sim/atomic_actions/execution.py +++ b/embodichain/lab/sim/atomic_actions/execution.py @@ -1351,6 +1351,9 @@ def _validate_tracking_continuity( return previous_routes = self._active_tracking_routes replacement_routes = self._tracking_routes(plan) + if not previous_routes and not self._active_targets: + # Failed initial planning has not established feedback ownership. + return if ( event_kind is ExecutionEventKind.REPLANNED and not plan.commands.targets diff --git a/embodichain/lab/task_program/compiler/lowering.py b/embodichain/lab/task_program/compiler/lowering.py index 1f9b6993f..3207ac3c6 100644 --- a/embodichain/lab/task_program/compiler/lowering.py +++ b/embodichain/lab/task_program/compiler/lowering.py @@ -504,6 +504,71 @@ def __post_init__(self) -> None: object.__setattr__(self, "held_objects", held_objects) +class RegisteredPhaseProtectionKind(str, Enum): + """Controlled held-object phase contracts for registered lowerers.""" + + ACQUIRE = "acquire" + RELEASE = "release" + RETAIN = "retain" + + +@dataclass(frozen=True, slots=True) +class RegisteredPhaseProtection: + """Declare held phases and a blocking boundary without binding evidence. + + Acquisition observes the declared attachment before ``gate_segment`` and + guards it during ``active_segments``. Release guards the verified input + hold and observes detachment before its gate. Retention only guards the + verified input hold, without introducing a terminal symbolic effect. + + Args: + kind: Compiler-controlled acquisition, release, or retention contract. + held_object: Endpoint slot and object relation to protect. + active_segments: Unique plan segments requiring the object to be held. + gate_segment: Segment blocked until acquisition or release is measured; + omitted for retention. + """ + + kind: RegisteredPhaseProtectionKind + held_object: RegisteredHeldObjectEffect + active_segments: tuple[str, ...] + gate_segment: str | None = None + + def __post_init__(self) -> None: + if type(self.kind) is not RegisteredPhaseProtectionKind: + raise TypeError("kind must be a RegisteredPhaseProtectionKind.") + if type(self.held_object) is not RegisteredHeldObjectEffect: + raise TypeError("held_object must be a RegisteredHeldObjectEffect.") + if type(self.active_segments) is not tuple or not self.active_segments: + raise TypeError("active_segments must be a non-empty exact tuple.") + for segment in self.active_segments: + _validate_identifier(segment, field_name="active segment") + if len(set(self.active_segments)) != len(self.active_segments): + raise ValueError("active_segments must be unique.") + expected_relation = ( + HeldObjectRelation.DETACHED + if self.kind is RegisteredPhaseProtectionKind.RELEASE + else HeldObjectRelation.ATTACHED + ) + if self.held_object.relation is not expected_relation: + raise ValueError("Protection kind and held-object relation disagree.") + if self.held_object.allow_missing_detached_baseline: + raise ValueError("Phase protection requires a verified release baseline.") + if self.kind is RegisteredPhaseProtectionKind.RETAIN: + if self.gate_segment is not None: + raise ValueError("Retention protection cannot declare an effect gate.") + else: + _validate_identifier(self.gate_segment, field_name="gate_segment") + if self.kind is RegisteredPhaseProtectionKind.ACQUIRE and ( + self.gate_segment not in self.active_segments + ): + raise ValueError("Acquisition gate must begin a guarded segment.") + if self.kind is RegisteredPhaseProtectionKind.RELEASE and ( + self.gate_segment in self.active_segments + ): + raise ValueError("Release gate cannot begin a held-object segment.") + + @dataclass(frozen=True, slots=True) class SemanticLowering: """Registered-lowerer output wrapped by compiler-owned invocation policy.""" @@ -514,6 +579,7 @@ class SemanticLowering: default_factory=ActionControlOverrides ) registered_effect: RegisteredSemanticEffect | None = None + phase_protection: RegisteredPhaseProtection | None = None def __post_init__(self) -> None: if self.skill_options is not None and not isinstance( @@ -529,6 +595,10 @@ def __post_init__(self) -> None: raise TypeError( "registered_effect must be a RegisteredSemanticEffect or None." ) + if self.phase_protection is not None and ( + type(self.phase_protection) is not RegisteredPhaseProtection + ): + raise TypeError("phase_protection must be a RegisteredPhaseProtection.") class RegisteredSemanticLowerer(ABC): @@ -537,6 +607,7 @@ class RegisteredSemanticLowerer(ABC): call_id: ClassVar[str] target_descriptor: ClassVar[SkillDescriptor] effect_contract_kind: ClassVar[SemanticEffectKind | None] = None + phase_protection_kind: ClassVar[RegisteredPhaseProtectionKind | None] = None preserves_symbolic_state: ClassVar[bool] = False """Whether the call leaves compiler-owned symbolic ``TaskState`` unchanged. @@ -835,8 +906,11 @@ def __post_init__(self) -> None: guard_ids = [value.guard_id for value in guards] if len(set(guard_ids)) != len(guard_ids): raise ValueError("Grounded held-object guard IDs must be unique.") - if guards and self.effect_spec is None: - raise ValueError("Held-object guards require a terminal effect spec.") + if self.effect_spec is None and any( + guard.baseline is not HeldObjectGuardBaseline.VERIFIED_TASK_STATE + for guard in guards + ): + raise ValueError("Guard-only calls require verified task-state baselines.") object.__setattr__(self, "effect_guards", guards) gates = tuple(self.effect_gates) if not all(type(value) is GroundedPhaseEffectGate for value in gates): @@ -947,6 +1021,22 @@ def __init__( f"Lowerer {call_id!r} cannot both preserve symbolic state and " "declare an effect contract." ) + protection_kind = lowerer.phase_protection_kind + if protection_kind is not None: + if type(protection_kind) is not RegisteredPhaseProtectionKind: + raise TypeError("phase_protection_kind must be typed.") + required_effect = { + RegisteredPhaseProtectionKind.ACQUIRE: SemanticEffectKind.ATTACH, + RegisteredPhaseProtectionKind.RELEASE: SemanticEffectKind.RELEASE, + RegisteredPhaseProtectionKind.RETAIN: None, + }[protection_kind] + if lowerer.effect_contract_kind is not required_effect or ( + protection_kind is RegisteredPhaseProtectionKind.RETAIN + and not preserves_symbolic_state + ): + raise ValueError( + "Phase protection disagrees with effect ownership." + ) lowerers[call_id] = lowerer if isinstance(relation_grounders, (str, bytes)): raise TypeError("relation_grounders must be an iterable of grounders.") @@ -1465,6 +1555,16 @@ def ground( effect_spec, path=(*path, call_index, "effect_gates"), ) + if lowering.phase_protection is not None: + effect_guards, effect_gates = self._ground_registered_phase_protection( + analyzed, + invocation, + lowering.phase_protection, + effect_spec, + context, + eligible, + path=(*path, call_index, "phase_protection"), + ) if effect_gates: invocation = replace( invocation, @@ -1634,7 +1734,10 @@ def _effect_monitor_ref( ) if type(bound.linked.call) is RegisteredSemanticCall: lowerer = self._registered_lowerers.get(bound.linked.call.call_id) - if lowerer is not None and lowerer.effect_contract_kind is not None: + if lowerer is not None and ( + lowerer.effect_contract_kind is not None + or lowerer.phase_protection_kind is not None + ): raise _diagnostic( "missing_effect_monitor", path, @@ -1644,7 +1747,10 @@ def _effect_monitor_ref( return None if type(bound.linked.call) is RegisteredSemanticCall: lowerer = self._registered_lowerers.get(bound.linked.call.call_id) - if lowerer is None or lowerer.effect_contract_kind is None: + if lowerer is None or ( + lowerer.effect_contract_kind is None + and lowerer.phase_protection_kind is None + ): raise _diagnostic( "registered_effect_contract_not_installed", path, @@ -1979,6 +2085,17 @@ def _lower_registered( raise TypeError( f"Lowerer {call.call_id!r} produced an incompatible effect kind." ) + protection = lowering.phase_protection + actual_kind = None if protection is None else protection.kind + if actual_kind is not lowerer.phase_protection_kind: + raise TypeError("Lowered phase protection does not match its declaration.") + if protection is not None and actual_kind is not ( + RegisteredPhaseProtectionKind.RETAIN + ): + if actual_effect is None or protection.held_object not in ( + actual_effect.held_objects + ): + raise ValueError("Phase protection must reference a declared effect.") return replace(lowering, skill_options=deepcopy(option_template)) @staticmethod @@ -2085,6 +2202,11 @@ def _ground_effect_spec( elif type(call) is RegisteredSemanticCall: contract = lowering.registered_effect if contract is None: + if lowering.phase_protection is not None and ( + lowering.phase_protection.kind + is RegisteredPhaseProtectionKind.RETAIN + ): + return None raise _diagnostic( "registered_effect_contract_not_grounded", path, @@ -2148,6 +2270,105 @@ def _validate_registered_held_effects( path=(*path, "registered_effect", item.expectation_id), ) + def _ground_registered_phase_protection( + self, + analyzed: AnalyzedSemanticCall, + invocation: ActionInvocation, + protection: RegisteredPhaseProtection, + terminal_spec: SemanticEffectSpec | None, + context: PlanningContext, + eligible: torch.Tensor, + *, + path: tuple[PathPart, ...], + ) -> tuple[ + tuple[GroundedHeldObjectGuard, ...], tuple[GroundedPhaseEffectGate, ...] + ]: + """Bind registered declarations through the canonical evidence policy.""" + monitor_ref = analyzed.effect_monitor_ref + if monitor_ref is None: + return (), () + item = protection.held_object + acquired = protection.kind is RegisteredPhaseProtectionKind.ACQUIRE + if not acquired: + self._require_held_object( + analyzed, + context, + eligible, + slot_id=item.slot_id, + object_id=item.object_id, + path=path, + ) + if protection.kind is RegisteredPhaseProtectionKind.RETAIN: + expectation, clauses = self._ground_held_effect( + analyzed, + expectation_id=item.expectation_id, + relation=HeldObjectRelation.ATTACHED, + slot_id=item.slot_id, + object_id=item.object_id, + context=context, + path=path, + ) + guard_spec = SemanticEffectSpec( + semantic_id=analyzed.call.semantic_id, + effect_kind=SemanticEffectKind.ATTACH, + skill_id=invocation.skill_id, + invocation_id=invocation.invocation_id, + invocation_revision=invocation.revision, + env_ids=context.env_ids, + state_expectations=(expectation,), + clauses=clauses, + ) + else: + assert terminal_spec is not None + guard_spec = self._attached_guard_effect_spec( + terminal_spec, expectation_id=item.expectation_id + ) + expectation = self._held_expectation(guard_spec, item.expectation_id) + if not acquired: + self._validate_guard_verified_baseline(expectation, context) + try: + guard = GroundedHeldObjectGuard( + guard_id=f"{item.expectation_id}_attached", + active_segments=protection.active_segments, + baseline=( + HeldObjectGuardBaseline.PLANNED_EFFECT + if acquired + else HeldObjectGuardBaseline.VERIFIED_TASK_STATE + ), + effect_spec=guard_spec, + effect_monitor=self._effect_monitor_registry.create( + guard_spec, monitor_ref + ), + invalidation_task_state_keys=(expectation.task_state_key,), + retry_action=acquired, + ) + gates = () + if protection.gate_segment is not None: + assert terminal_spec is not None + gate_spec = self._single_held_expectation_effect_spec( + terminal_spec, + expectation_id=item.expectation_id, + relation=item.relation, + ) + gates = ( + GroundedPhaseEffectGate( + gate_id=f"{item.expectation_id}_{protection.kind.value}", + segment_name=protection.gate_segment, + effect_spec=gate_spec, + effect_monitor=self._effect_monitor_registry.create( + gate_spec, monitor_ref + ), + retry_action=acquired, + ), + ) + except (KeyError, TypeError, ValueError) as exc: + raise _diagnostic( + "phase_protection_monitor_creation_failed", + path, + f"Could not bind registered phase protection: {exc}", + ) from exc + return (guard,), gates + def _ground_phase_effect_gates( self, analyzed: AnalyzedSemanticCall, diff --git a/embodichain/lab/task_program/integrations/_configured_services.py b/embodichain/lab/task_program/integrations/_configured_services.py index 314eb26fa..70dde3118 100644 --- a/embodichain/lab/task_program/integrations/_configured_services.py +++ b/embodichain/lab/task_program/integrations/_configured_services.py @@ -92,6 +92,8 @@ ) from embodichain.lab.task_program.compiler.lowering import ( RegisteredHeldObjectEffect, + RegisteredPhaseProtection, + RegisteredPhaseProtectionKind, RegisteredSemanticLowerer, RegisteredSemanticEffect, SemanticLowering, @@ -764,6 +766,9 @@ class _RelativePlaceLowerer(RegisteredSemanticLowerer): call_id: ClassVar[str] = _PLACE_RELATIVE_CALL_ID target_descriptor: ClassVar[SkillDescriptor] = Place.descriptor() effect_contract_kind: ClassVar[SemanticEffectKind] = SemanticEffectKind.RELEASE + phase_protection_kind: ClassVar[RegisteredPhaseProtectionKind] = ( + RegisteredPhaseProtectionKind.RELEASE + ) def __init__(self, routes: tuple[_RelativePlaceRoute, ...]) -> None: if type(routes) is not tuple or not routes: @@ -849,6 +854,17 @@ def lower( ), ), ), + phase_protection=RegisteredPhaseProtection( + kind=RegisteredPhaseProtectionKind.RELEASE, + held_object=RegisteredHeldObjectEffect( + expectation_id="primary", + relation=HeldObjectRelation.DETACHED, + object_id=route.object_id, + slot_id="primary", + ), + active_segments=("approach",), + gate_segment="retract", + ), ) def pick_lookahead_targets( diff --git a/embodichain/lab/task_program/runtime/executor.py b/embodichain/lab/task_program/runtime/executor.py index 8cbfae7df..75dc16c56 100644 --- a/embodichain/lab/task_program/runtime/executor.py +++ b/embodichain/lab/task_program/runtime/executor.py @@ -961,8 +961,11 @@ def _prepare_grounded_call( "Grounded effect_guards must contain exact " "GroundedHeldObjectGuard values." ) - if effect_guards and effect_spec is None: - raise ValueError("Grounded held-object guards require an effect spec.") + if effect_spec is None and any( + guard.baseline is not HeldObjectGuardBaseline.VERIFIED_TASK_STATE + for guard in effect_guards + ): + raise ValueError("Guard-only calls require verified task-state baselines.") if not all(type(value) is GroundedPhaseEffectGate for value in effect_gates): raise TypeError( "Grounded effect_gates must contain exact " @@ -1247,6 +1250,16 @@ def _held_object_guard_verifier( return None grounded = self._require_grounded() guards = grounded.effect_guards + session = self._require_runner().session + if grounded.effect_spec is None: + segment_names = {segment.name for segment in session.active_plan.segments} + for guard in guards: + missing = set(guard.active_segments) - segment_names + if missing: + raise ValueError( + f"Held-object guard {guard.guard_id!r} references missing " + f"plan segment names: {sorted(missing)}." + ) active = tuple( guard for guard in guards if request.segment_name in guard.active_segments ) @@ -1259,7 +1272,6 @@ def _held_object_guard_verifier( f"{[guard.guard_id for guard in active]}." ) guard = active[0] - session = self._require_runner().session if guard.baseline is HeldObjectGuardBaseline.VERIFIED_TASK_STATE: candidate = session.task_state.get_held_object(guard.task_state_key) else: diff --git a/tests/gen_sim/task_engine/test_task_program_services.py b/tests/gen_sim/task_engine/test_task_program_services.py index 49e372d64..5b8e40238 100644 --- a/tests/gen_sim/task_engine/test_task_program_services.py +++ b/tests/gen_sim/task_engine/test_task_program_services.py @@ -308,6 +308,53 @@ def test_configured_pick_keeps_baseline_goal_and_preset_ownership() -> None: assert options.pick_object_part == "bottom" assert options.downstream_object_target_poses == () assert lowering.registered_effect.effect_kind is SemanticEffectKind.ATTACH + assert lowering.phase_protection.gate_segment == "lift" + assert lowering.phase_protection.active_segments == ("lift",) + + +@pytest.mark.parametrize("protected", (False, True)) +def test_held_move_phase_protection_is_an_explicit_service_opt_in( + protected: bool, +) -> None: + config = { + "kind": "move_held_object", + "routes": [ + { + "object_id": "part", + "target_id": "inspection", + "pose": { + "kind": "pose", + "position": [0.0, 0.0, 1.0], + "quaternion_wxyz": [1.0, 0.0, 0.0, 0.0], + }, + } + ], + } + if protected: + config["phase_protection"] = "held_object_v1" + factory = decode_task_lowerer(config, path="runtime_services") + robot = object() + registry = SimpleNamespace(resolve=lambda *args, **kwargs: SceneObjectRef("part")) + lowerer = factory.create( + simulation=None, + robot=robot, + scene_registry=registry, + engine=SimpleNamespace(robot=robot), + ) + lowered = lowerer.lower( + RegisteredSemanticCall( + call_id="simulation.move_held_object", + arguments={"object": "part", "target": "inspection"}, + ), + context=None, + bound=None, + option_template=MoveHeldObjectOptions(), + ) + assert (lowered.phase_protection is not None) is protected + if protected: + assert lowered.phase_protection.active_segments == ("transport",) + assert lowered.phase_protection.gate_segment is None + assert lowered.registered_effect is None def test_pick_decoder_rejects_removed_runtime_option_declarations() -> None: diff --git a/tests/lab/task_program/test_semantic_compiler.py b/tests/lab/task_program/test_semantic_compiler.py index 7746de8e1..00285058b 100644 --- a/tests/lab/task_program/test_semantic_compiler.py +++ b/tests/lab/task_program/test_semantic_compiler.py @@ -19,6 +19,7 @@ from __future__ import annotations from types import MethodType +from dataclasses import replace from typing import ClassVar from unittest.mock import Mock @@ -35,6 +36,7 @@ ControlPartCommandProfile, DynamicCollisionMode, EntityState, + EffectVerificationRequest, FORWARD_KINEMATICS_CAPABILITY, GRASP_CAPABILITY, GraspGoal, @@ -52,6 +54,7 @@ SceneEntityPose, SkillDescriptor, TaskState, + StateDelta, ) from embodichain.lab.sim.atomic_actions.tracking import ( JointPositionTrackingMetric, @@ -73,6 +76,8 @@ HandOverPoseTargets, HeldObjectGuardBaseline, RegisteredHeldObjectEffect, + RegisteredPhaseProtection, + RegisteredPhaseProtectionKind, RegisteredSemanticLowerer, RegisteredSemanticEffect, RelationTargetGrounder, @@ -85,6 +90,7 @@ ) from embodichain.lab.task_program.semantics.effects import ( BinaryEffectClause, + BinaryEffectEvidenceBatch, BinaryEvidenceKind, COMPOSITE_EFFECT_MONITOR_ID, COMPOSITE_EFFECT_MONITOR_REVISION, @@ -96,6 +102,7 @@ HeldObjectRelation, HeldObjectStateExpectation, PoseRelationClause, + PoseRelationEvidenceBatch, PoseRelationExpectation, SemanticEffectKind, SemanticEffectSpec, @@ -1353,6 +1360,246 @@ def test_registered_effect_contract_is_grounded_by_compiler() -> None: assert grounded.effect_monitor is not None +@pytest.mark.parametrize("mode", ("acquire", "release", "retain")) +def test_registered_phase_protection_binds_gate_and_held_guard(mode: str) -> None: + """Registered declarations bind independent, phase-scoped monitors.""" + + class ProtectedLowerer(_EffectfulInspectLowerer): + phase_protection_kind = RegisteredPhaseProtectionKind(mode) + effect_contract_kind = { + "acquire": SemanticEffectKind.ATTACH, + "release": SemanticEffectKind.RELEASE, + "retain": None, + }[mode] + preserves_symbolic_state = mode == "retain" + + def lower(self, *args: object, **kwargs: object) -> SemanticLowering: + lowering = super().lower(*args, **kwargs) + item = replace( + lowering.registered_effect.held_objects[0], + relation=( + HeldObjectRelation.DETACHED + if mode == "release" + else HeldObjectRelation.ATTACHED + ), + ) + return replace( + lowering, + registered_effect=( + None + if mode == "retain" + else RegisteredSemanticEffect( + effect_kind=self.effect_contract_kind, held_objects=(item,) + ) + ), + phase_protection=RegisteredPhaseProtection( + kind=self.phase_protection_kind, + held_object=item, + active_segments=( + { + "acquire": ("lift",), + "release": ("approach",), + "retain": ("transport",), + }[mode] + ), + gate_segment={ + "acquire": "lift", + "release": "retract", + "retain": None, + }[mode], + ), + ) + + registry, _ = _scene_registry() + profile = _profile( + preset=_preset( + "safe", + registered=True, + effect_monitors={ + "vendor.inspect": EffectMonitorRef( + COMPOSITE_EFFECT_MONITOR_ID, + COMPOSITE_EFFECT_MONITOR_REVISION, + ) + }, + ) + ) + compiler, _ = _compiler( + registry, + registered=True, + registered_lowerers=(ProtectedLowerer(),), + profile=profile, + ) + workflow = compiler.analyze((RegisteredSemanticCall(call_id="vendor.inspect"),)) + context = _context(registry) + if mode != "acquire": + semantics = ObjectSemantics( + affordance=AntipodalAffordance(), geometry={}, entity_id="cube" + ) + context = _held_context(registry, semantics, torch.eye(4).repeat(2, 1, 1)) + grounded = compiler.ground(workflow, 0, context) + assert len(grounded.effect_gates) == (0 if mode == "retain" else 1) + assert ( + grounded.effect_guards[0].active_segments + == {"acquire": ("lift",), "release": ("approach",), "retain": ("transport",)}[ + mode + ] + ) + assert grounded.effect_guards[0].baseline is ( + HeldObjectGuardBaseline.PLANNED_EFFECT + if mode == "acquire" + else HeldObjectGuardBaseline.VERIFIED_TASK_STATE + ) + if mode == "retain": + assert grounded.effect_spec is None + assert grounded.effect_monitor is None + else: + assert ( + grounded.effect_gates[0].segment_name + == {"acquire": "lift", "release": "retract"}[mode] + ) + assert grounded.invocation.phase_effect_gates == ( + grounded.effect_gates[0].requirement, + ) + if mode == "release": + assert not any( + isinstance(clause, PoseRelationClause) + for clause in grounded.effect_gates[0].effect_spec.clauses + ) + + # Inject missing acquisition, loss during held transport, or a gripper + # still holding at the release-before-retreat boundary. These are CPU + # evidence tests, not physical task qualification. + protection = ( + grounded.effect_guards[0] if mode == "retain" else grounded.effect_gates[0] + ) + held = HeldObjectState( + semantics=ObjectSemantics( + affordance=AntipodalAffordance(), geometry={}, entity_id="cube" + ), + object_to_eef=torch.eye(4).repeat(2, 1, 1), + grasp_xpos=torch.eye(4).repeat(2, 1, 1), + env_mask=torch.ones(2, dtype=torch.bool), + ) + request = EffectVerificationRequest( + verification_id=0, + skill_id=grounded.invocation.skill_id, + invocation_id=grounded.invocation.invocation_id, + invocation_revision=0, + invocation_index=0, + attempt_generation=0, + terminal_segment=( + protection.active_segments[0] + if mode == "retain" + else protection.segment_name + ), + requested_at=0.0, + deadline=10.0, + env_mask=torch.ones(2, dtype=torch.bool), + expected_effects=StateDelta( + held_object_updates={"manipulator": None if mode == "release" else held} + ), + ) + evidence = {} + for clause in protection.effect_spec.clauses: + common = dict( + evidence_id=clause.clause_id, + valid=torch.ones(2, dtype=torch.bool), + acquisition_errors=(None, None), + timestamp=0.0, + env_ids=context.env_ids, + observation_revision=0, + ) + evidence[clause.clause_id] = ( + PoseRelationEvidenceBatch( + object_to_endpoint=torch.eye(4).repeat(2, 1, 1), **common + ) + if isinstance(clause, PoseRelationClause) + else BinaryEffectEvidenceBatch( + evidence_kind=clause.evidence_kind, + values=torch.full((2,), mode == "release", dtype=torch.bool), + **common, + ) + ) + decision = protection.effect_monitor.observe(request, evidence) + assert not decision.failure_mask.any() + decision = protection.effect_monitor.observe( + replace(request, verification_id=1, requested_at=0.1), + { + key: replace(value, timestamp=0.1, observation_revision=1) + for key, value in evidence.items() + }, + ) + assert decision.failure_mask.tolist() == [True, True] + assert decision.success_mask.tolist() == [False, False] + + +@pytest.mark.parametrize( + "invalid", ("untyped", "empty_segments", "wrong_relation", "gate_overlap") +) +def test_registered_phase_protection_rejects_invalid_declarations(invalid: str) -> None: + item = RegisteredHeldObjectEffect( + expectation_id="primary", + relation=HeldObjectRelation.DETACHED, + object_id="cube", + slot_id="primary", + ) + kwargs = dict( + kind=RegisteredPhaseProtectionKind.RELEASE, + held_object=item, + active_segments=("approach",), + gate_segment="retract", + ) + if invalid == "untyped": + kwargs["kind"] = "release" + elif invalid == "empty_segments": + kwargs["active_segments"] = () + elif invalid == "wrong_relation": + kwargs["held_object"] = replace(item, relation=HeldObjectRelation.ATTACHED) + else: + kwargs["gate_segment"] = "approach" + with pytest.raises((TypeError, ValueError)): + RegisteredPhaseProtection(**kwargs) + + +def test_registered_retention_requires_a_monitor_during_analysis() -> None: + class RetainingLowerer(_StatePreservingInspectLowerer): + phase_protection_kind = RegisteredPhaseProtectionKind.RETAIN + + registry, _ = _scene_registry() + compiler, _ = _compiler( + registry, registered=True, registered_lowerers=(RetainingLowerer(),) + ) + with pytest.raises(SemanticValidationError) as error: + compiler.analyze((RegisteredSemanticCall(call_id="vendor.inspect"),)) + assert error.value.diagnostic.code == "missing_effect_monitor" + + +def test_registered_lowerer_cannot_omit_declared_phase_protection() -> None: + class IncompleteLowerer(_EffectfulInspectLowerer): + phase_protection_kind = RegisteredPhaseProtectionKind.ACQUIRE + + registry, _ = _scene_registry() + compiler, _ = _compiler( + registry, + registered=True, + registered_lowerers=(IncompleteLowerer(),), + profile=_profile( + preset=_preset( + "safe", + registered=True, + effect_monitors={ + "vendor.inspect": EffectMonitorRef( + COMPOSITE_EFFECT_MONITOR_ID, COMPOSITE_EFFECT_MONITOR_REVISION + ) + }, + ) + ), + ) + workflow = compiler.analyze((RegisteredSemanticCall(call_id="vendor.inspect"),)) + with pytest.raises(TypeError, match="does not match its declaration"): + compiler.ground(workflow, 0, _context(registry)) + + def test_registered_release_requires_object_held_in_every_eligible_row() -> None: """Registered release calls cannot consume inactive held-state rows.""" registry, _ = _scene_registry() diff --git a/tests/lab/task_program/test_semantic_executor.py b/tests/lab/task_program/test_semantic_executor.py index 9d894d076..e9d647b05 100644 --- a/tests/lab/task_program/test_semantic_executor.py +++ b/tests/lab/task_program/test_semantic_executor.py @@ -1664,10 +1664,16 @@ def test_terminal_failure_policy_only_retains_strongly_proven_source_attachment( assert torch.equal(retry, expected_retry) -def test_in_flight_guard_collects_live_evidence_and_builds_loss_reconciliation() -> ( - None -): - system = _system((EffectMonitorDecision(_mask(True, True), _mask(False, False)),)) +@pytest.mark.parametrize("segment_declared", (True, False)) +def test_in_flight_guard_collects_live_evidence_and_builds_loss_reconciliation( + segment_declared: bool, +) -> None: + system = _system( + (EffectMonitorDecision(_mask(True, True), _mask(False, False)),), + effectless_action=True, + install_effect_monitor=False, + effect_assurance=EffectAssurance.VERIFIED, + ) semantics = ObjectSemantics( affordance=Affordance(), geometry={}, @@ -1740,12 +1746,33 @@ def test_in_flight_guard_collects_live_evidence_and_builds_loss_reconciliation() invalidation_task_state_keys=("arm",), retry_action=False, ) + original_ground = system.compiler.ground + + def ground_with_guard(*args: object, **kwargs: object) -> _Grounded: + grounded = original_ground(*args, **kwargs) + return replace(grounded, effect_guards=(guard,)) + + system.compiler.ground = ground_with_guard + system.runtime.adopt_verified_task_state(task_state) + completed = system.runtime.run(_call("guard_only")) + assert completed.status is SemanticExecutionStatus.COMPLETED + retained = completed.task_state.get_held_object("arm") + assert retained is not None + assert retained.env_mask.tolist() == [True, True] system.runtime._grounded = SimpleNamespace( analyzed=SimpleNamespace(effect_monitor_ref=None), effect_guards=(guard,), + effect_spec=None, ) system.runtime._runner = SimpleNamespace( - session=SimpleNamespace(task_state=task_state) + session=SimpleNamespace( + task_state=task_state, + active_plan=SimpleNamespace( + segments=( + SimpleNamespace(name="carry" if segment_declared else "different"), + ) + ), + ) ) system.runtime._current_call_index = 0 context = system.observation.observe(task_state) @@ -1764,6 +1791,10 @@ def test_in_flight_guard_collects_live_evidence_and_builds_loss_reconciliation() deadline=10.0, ) + if not segment_declared: + with pytest.raises(ValueError, match="missing plan segment"): + system.runtime._held_object_guard_verifier(context, request) + return result = system.runtime._held_object_guard_verifier(context, request) assert result is not None diff --git a/tests/sim/atomic_actions/test_engine_per_env.py b/tests/sim/atomic_actions/test_engine_per_env.py index d4910fe1a..8e50bf223 100644 --- a/tests/sim/atomic_actions/test_engine_per_env.py +++ b/tests/sim/atomic_actions/test_engine_per_env.py @@ -1860,6 +1860,31 @@ def test_recovery_replan_rejects_runtime_destination_change() -> None: assert action.plan_count == 2 +def test_initial_empty_plan_can_establish_tracking_on_retry() -> None: + engine, action = _destination_engine((None, "first")) + initial = _context(0.0, 0.0, 0.1, 0) + session = engine.start((_destination_invocation(engine),), initial) + + first = session.tick(initial) + assert first.command is None + resumed = session.tick(_context(0.1, 0.0, 0.1, 0)) + assert resumed.command is not None + assert resumed.command.commands[0].target.target_id == "arm_a" + assert action.plan_count == 2 + + +def test_repeated_initial_empty_plans_fail_without_commands() -> None: + engine, action = _destination_engine((None, None, None, None)) + initial = _context(0.0, 0.0, 0.1, 0) + session = engine.start((_destination_invocation(engine),), initial) + for index in range(6): + tick = session.tick(_context(index * 0.1, 0.0, 0.1, 0)) + assert tick.command is None + if tick.status is ExecutionStatus.FAILED: + break + assert tick.status is ExecutionStatus.FAILED + + def test_empty_failed_replan_preserves_destination_for_same_target_retry() -> None: engine, action = _destination_engine(("first", None, "first")) invocation = _destination_invocation(engine) From 7e5809d4dbee1c792176621524f17d650d6af266 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 11 Sep 2026 04:08:07 +0000 Subject: [PATCH 3/5] feat(gen-sim): evaluate bounded TaskSpec goals before episode acceptance --- agent_context/MAP.yaml | 6 +- .../topics/env-framework/env-framework.md | 6 + agent_context/topics/gen-sim/gen-sim.md | 13 +- agent_context/topics/task-spec/task-spec.md | 26 +- docs/source/api_reference/public_api.rst | 4 + docs/source/api_reference/task_evaluation.rst | 17 ++ embodichain/compute/task_predicates.py | 39 +++ .../gen_sim/task_engine/_bundle_runner.py | 51 +++- .../task_engine/_task_program/stability.py | 17 +- embodichain/gen_sim/task_engine/_task_spec.py | 229 ++++++++++++++++++ embodichain/gen_sim/task_engine/cli.py | 18 +- .../task_engine/orchestration/coordinator.py | 45 ++++ .../gen_sim/task_engine/scene/feasibility.py | 19 +- .../gen_sim/task_engine/state_machine.py | 8 +- .../task_engine/task_program_bundle.py | 40 ++- embodichain/gen_sim/task_engine/workflow.py | 18 ++ embodichain/lab/gym/envs/demo.py | 39 +++ embodichain/lab/task_evaluation.py | 138 +++++++++++ embodichain/task_spec/IMPLEMENTATION.md | 21 ++ embodichain/task_spec/README.md | 48 +++- .../orchestration/test_coordinator_cli.py | 40 +++ .../task_engine/scene/test_scene_boundary.py | 16 ++ .../task_engine/test_semantic_graph.py | 36 +++ .../task_engine/test_task_spec_planning.py | 98 ++++++++ .../task_engine/test_task_spec_runner.py | 146 +++++++++++ tests/gen_sim/task_engine/test_workflow.py | 11 +- tests/gym/envs/test_demo.py | 34 +++ tests/lab/test_task_evaluation.py | 103 ++++++++ 28 files changed, 1253 insertions(+), 33 deletions(-) create mode 100644 docs/source/api_reference/task_evaluation.rst create mode 100644 embodichain/compute/task_predicates.py create mode 100644 embodichain/gen_sim/task_engine/_task_spec.py create mode 100644 embodichain/lab/task_evaluation.py create mode 100644 tests/gen_sim/task_engine/test_task_spec_runner.py create mode 100644 tests/lab/test_task_evaluation.py diff --git a/agent_context/MAP.yaml b/agent_context/MAP.yaml index ca89fe848..13fd9ffce 100644 --- a/agent_context/MAP.yaml +++ b/agent_context/MAP.yaml @@ -253,7 +253,11 @@ topics: - embodichain/task_spec/expressions.py - embodichain/task_spec/canonicalization.py - embodichain/task_spec/registry.py - watch_paths: [embodichain/task_spec/, tests/task_spec/] + - embodichain/lab/task_evaluation.py + - embodichain/compute/task_predicates.py + - embodichain/gen_sim/task_engine/_task_spec.py + watch_paths: [embodichain/task_spec/, tests/task_spec/, embodichain/lab/task_evaluation.py, + embodichain/compute/task_predicates.py, tests/lab/test_task_evaluation.py] related_topics: [gen-sim, task-programs] status: active - id: gen-sim diff --git a/agent_context/topics/env-framework/env-framework.md b/agent_context/topics/env-framework/env-framework.md index e9d824ab2..87f3b3b4b 100644 --- a/agent_context/topics/env-framework/env-framework.md +++ b/agent_context/topics/env-framework/env-framework.md @@ -72,6 +72,12 @@ dataset persistence are separate contracts; see [Task Programs](../task-programs/task-programs.md) and [data pipeline](../data-pipeline/data-pipeline.md). +`execute_demo_episode(final_acceptance=...)` optionally lets the host freeze +whole-task evidence after segments/cleanup and before episode metadata is +finalized. It requires an exact per-row boolean tuple and only rejects existing +program success; the callback must not step, reset or persist dataset samples. +The caller still owns the eventual save/reset transaction. + ## Change sites and focused validation | Change | Validation surface | diff --git a/agent_context/topics/gen-sim/gen-sim.md b/agent_context/topics/gen-sim/gen-sim.md index 4c8e9db07..3f89ab92f 100644 --- a/agent_context/topics/gen-sim/gen-sim.md +++ b/agent_context/topics/gen-sim/gen-sim.md @@ -32,10 +32,17 @@ Task Engine lives in `task_engine/`: TaskAgent produces legacy candidates, SemanticTaskPlanner expands E1–E5 recipes into candidate graphs, and `task_program_bundle.py` composes the Task Program deployment. Explicit TaskSpec template/instance inputs to the planner produce graph/v2 provenance; -the existing no-TaskSpec path remains graph/v1. v2 bundle export/execution is gated until -final task evaluation is integrated before data submission and reset. Follow +the existing no-TaskSpec path remains graph/v1. v2 bundle export/execution stays +gated on measured instance/witness qualification. CLI `--task-template` provides +a bounded E2 observed-goal acceptance route using a strict sidecar with +fingerprint/v3, a legacy executable graph and a pre-metadata Gym final hook. +It does not claim full certification. CLI defaults to dual_franka and rejects +other executable profiles before generation. Preparation invokes the existing +FeasibilityBroker on a static manifest; unknown/runtime-probe results skip the +static feasibility stage instead of claiming successful physical validation. +Follow [TaskSpec](../task-spec/task-spec.md) for semantic identity, evidence and the -current planning-only boundary. +current qualification boundary. ## Durable scene boundary diff --git a/agent_context/topics/task-spec/task-spec.md b/agent_context/topics/task-spec/task-spec.md index 6b9f781cd..932a1a229 100644 --- a/agent_context/topics/task-spec/task-spec.md +++ b/agent_context/topics/task-spec/task-spec.md @@ -43,12 +43,26 @@ and legacy_plan_hash. The existing recipe remains a proposed solution and does not redefine or prove the normative goal. Without TaskSpec inputs, TaskCandidate and graph/v1 behavior is unchanged. -`task_program_bundle.py` and `_bundle_runner.py` refuse v2 export/execution before writing artifacts until -final evaluation before data submission/reset exists. This is a planning -provenance entry, not the complete template-driven generation/runtime route. -TaskAgent migration, template-derived scene requests, actual-state capture, -shared evaluator, Workflow certificates and expansion hosting remain future -work in their current owners. See [GenSim](../gen-sim/gen-sim.md). +`task_program_bundle.py` and `_bundle_runner.py` refuse v2 export/execution +until measured instance/witness evidence qualification exists. + +An independent opt-in E2 acceptance route passes an explicit template through +CLI `--task-template`, Workflow and bundle generation. It keeps graph/v1 as the +executable recipe and writes a strict template/binding sidecar referenced by +fingerprint/v3. `lab/task_evaluation.py` accepts only one local-+Z upright goal +and optional initially-not-upright constraint; other requirements fail early. +`compute/task_predicates.py` owns shared angle measurement. GenSim stability +policies derive their upright threshold from the explicit template. + +The runner captures actual reset/settled poses and refuses invalid, unavailable +or trivial initial batches. Its final hook evaluates simultaneous final poses +after cleanup and before Gym metadata/commit/reset. Program and task results +are separate. The host owns evidence files, not the evaluator. The result is +observed-goal-only with certificate_status=unavailable: it does not construct +a qualified SceneInstance/ActionWitness or assert asset/whole-process/robustness +certification. TaskAgent migration, template-derived scene generation, other +predicate checkers, Workflow certificates and expansion hosting remain future +work. See [GenSim](../gen-sim/gen-sim.md). ## Focused validation diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst index 5e20d51be..1e3b3c0ec 100644 --- a/docs/source/api_reference/public_api.rst +++ b/docs/source/api_reference/public_api.rst @@ -2180,3 +2180,7 @@ embodichain_tasks.utils.importer :hidden: task_spec +.. toctree:: + :hidden: + + task_evaluation diff --git a/docs/source/api_reference/task_evaluation.rst b/docs/source/api_reference/task_evaluation.rst new file mode 100644 index 000000000..dd870a715 --- /dev/null +++ b/docs/source/api_reference/task_evaluation.rst @@ -0,0 +1,17 @@ +Task observation evaluation +=========================== + +These measurements and evaluators do not own simulation stepping, reset, +retries or persistence. The initial implementation qualifies instantaneous +local-+Z upright observations only; process and unsupported requirements are +rejected explicitly, and invalid pose rows are unavailable rather than passing. + +.. autosummary:: + + embodichain.compute.task_predicates.axis_tilt + embodichain.lab.task_evaluation.UprightTaskEvaluator + +.. autofunction:: embodichain.compute.task_predicates.axis_tilt + +.. autoclass:: embodichain.lab.task_evaluation.UprightTaskEvaluator + :members: diff --git a/embodichain/compute/task_predicates.py b/embodichain/compute/task_predicates.py new file mode 100644 index 000000000..ad6e557b4 --- /dev/null +++ b/embodichain/compute/task_predicates.py @@ -0,0 +1,39 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + + +"""Stateless batched measurements used by task policies and final evaluation.""" + +from __future__ import annotations +import torch + +__all__ = ["axis_tilt"] + + +def axis_tilt(pose: torch.Tensor, local_axis: torch.Tensor) -> torch.Tensor: + """Measure tilt with atan2, preserving small angles in float32 observations. + + Args: + pose: Batched rigid transforms, shaped ``(N, 4, 4)``. + local_axis: Unit local axis, shaped ``(3,)``. + + Returns: + Tilt in radians as float64, shaped ``(N,)``. Invalid numbers propagate. + """ + if pose.ndim != 3 or pose.shape[-2:] != (4, 4) or local_axis.shape != (3,): + raise ValueError("Expected poses (N, 4, 4) and one local axis (3,).") + axis = pose[:, :3, :3].double() @ local_axis.double() + return torch.atan2(torch.linalg.vector_norm(axis[:, :2], dim=-1), axis[:, 2]) diff --git a/embodichain/gen_sim/task_engine/_bundle_runner.py b/embodichain/gen_sim/task_engine/_bundle_runner.py index d8549e826..0ef3dbcf8 100644 --- a/embodichain/gen_sim/task_engine/_bundle_runner.py +++ b/embodichain/gen_sim/task_engine/_bundle_runner.py @@ -87,6 +87,13 @@ def execute_bundle( ) output.mkdir(parents=True, exist_ok=True) fingerprint = _read_json(fingerprint_path) + from ._task_spec import read_binding + + task_binding = read_binding(root, graph, fingerprint) + if task_binding is not None: + from ._task_spec import require_fresh_evidence_output + + require_fresh_evidence_output(output) deployment = _verify_integration_fingerprint( root, deployment_path, graph, fingerprint ) @@ -106,6 +113,7 @@ def execute_bundle( terminal_reasons = ["runtime_not_started"] * int(args.num_envs) failure: dict[str, Any] | None = None env: Any = None + initial: dict[str, Any] | None = None try: import gymnasium @@ -144,7 +152,28 @@ def configure_environment(value: dict[str, Any]) -> None: deployment.integration.registration.catalog.preflight(env_cfg.task_program) env = gymnasium.make(id=gym_config["id"], cfg=env_cfg, **action_config) env.reset(seed=args.seed, options={"save_data": False}) - result = execute_demo_episode(env, episode_index=0, attempt_id=0) + acceptance = None + if task_binding is not None: + from ._task_spec import final_acceptance, observe, write_evidence + + initial = observe(env, task_binding, scope="initial", output=output) + write_evidence(output / "initial_evaluation.json", initial) + if any(status != "pass" for status in initial["status"]) or any( + initial["goal_satisfied"] + ): + raise ValueError( + "TaskSpec initial state is unavailable, invalid, or already satisfies the goal." + ) + + def acceptance(program_result: Any) -> tuple[bool, ...]: + return final_acceptance( + env, task_binding, initial, program_result, output + ) + + episode_kwargs = {} if acceptance is None else {"final_acceptance": acceptance} + result = execute_demo_episode( + env, episode_index=0, attempt_id=0, **episode_kwargs + ) result_metadata = result.to_metadata() row_success = [bool(value) for value in result.success] terminal_reasons = list(result.terminal_reasons) or [ @@ -162,6 +191,20 @@ def configure_environment(value: dict[str, Any]) -> None: except Exception as exc: failure = _exception_metadata(exc) if env is not None: + if task_binding is not None: + from ._task_spec import record_failure + + try: + record_failure( + env, + task_binding, + initial, + output, + failure, + num_envs=int(args.num_envs), + ) + except Exception as evidence_error: + failure["task_evidence_error"] = _exception_metadata(evidence_error) try: _preserve_failed_execution_recording( env, @@ -347,7 +390,11 @@ def _verify_integration_fingerprint( from ._task_program.assembly import ADAPTER_CONTRACT, load_deployment if ( - fingerprint.get("schema_version") != "semantic_integration_fingerprint/v2" + fingerprint.get("schema_version") + not in { + "semantic_integration_fingerprint/v2", + "semantic_integration_fingerprint/v3", + } or fingerprint.get("adapter_contract") != ADAPTER_CONTRACT ): raise ValueError( diff --git a/embodichain/gen_sim/task_engine/_task_program/stability.py b/embodichain/gen_sim/task_engine/_task_program/stability.py index f35c37ac8..ad41ab50e 100644 --- a/embodichain/gen_sim/task_engine/_task_program/stability.py +++ b/embodichain/gen_sim/task_engine/_task_program/stability.py @@ -18,6 +18,8 @@ from __future__ import annotations +from embodichain.compute.task_predicates import axis_tilt + from collections.abc import Iterator, Mapping from copy import deepcopy from dataclasses import dataclass, fields @@ -283,8 +285,9 @@ def actions( valid = torch.isfinite(pose).all(dim=-1).all(dim=-1) measurements: dict[str, Any] = {} if cfg.local_axis is not None: - alignment = (pose[:, :3, :3] @ pose.new_tensor(cfg.local_axis))[:, 2] - valid &= alignment >= cfg.minimum_alignment + tilt = axis_tilt(pose, pose.new_tensor(cfg.local_axis)) + alignment = torch.cos(tilt) + valid &= tilt <= math.acos(cfg.minimum_alignment) measurements["alignment"] = alignment.tolist() reference = self._pose(cfg.reference) if cfg.reference is not None else None target = None @@ -302,9 +305,9 @@ def actions( and reference_anchor is not None and cfg.reference_axis is not None ) - alignment = ( - reference[:, :3, :3] @ pose.new_tensor(cfg.reference_axis) - )[:, 2] + reference_tilt = axis_tilt( + reference, pose.new_tensor(cfg.reference_axis) + ) gap = ( pose[:, 2, 3] + cfg.object_bottom @@ -312,7 +315,7 @@ def actions( - cfg.reference_top ) delta = pose[:, :2, 3] - reference[:, :2, 3] - valid &= (alignment >= cfg.minimum_alignment) & ( + valid &= (reference_tilt <= math.acos(cfg.minimum_alignment)) & ( gap.abs() <= cfg.support_tolerance ) valid &= ( @@ -326,7 +329,7 @@ def actions( ) measurements.update( support_gap=gap.tolist(), - reference_alignment=alignment.tolist(), + reference_alignment=torch.cos(reference_tilt).tolist(), reference_translation_drift=reference_translation.tolist(), reference_rotation_drift=reference_rotation.tolist(), ) diff --git a/embodichain/gen_sim/task_engine/_task_spec.py b/embodichain/gen_sim/task_engine/_task_spec.py new file mode 100644 index 000000000..a0be59f46 --- /dev/null +++ b/embodichain/gen_sim/task_engine/_task_spec.py @@ -0,0 +1,229 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + + +"""GenSim's bounded TaskSpec binding and evidence adapter (no execution loop).""" + +from __future__ import annotations + +from copy import deepcopy +import hashlib +import json +import math +from pathlib import Path +from typing import Any + +from embodichain.lab.task_evaluation import UprightTaskEvaluator +from embodichain.task_spec import validate_task_template +from .semantic_graph import semantic_task_graph_hash + +__all__: list[str] = [] +SCHEMA = "gen_sim.taskspec_e2/v1" + + +def binding_for_graph( + template: dict[str, Any], graph: dict[str, Any] +) -> dict[str, Any]: + """Bind one explicit template to an existing E2 recipe, not its action order.""" + evaluator = UprightTaskEvaluator(template) + if not graph["nodes"] or any(node["task_type"] != "E2" for node in graph["nodes"]): + raise ValueError( + "TaskSpec execution currently supports only a single E2 object." + ) + entities = { + str(node["call"].get("arguments", node["call"])["object"]) + for node in graph["nodes"] + if "object" in node["call"].get("arguments", node["call"]) + } + if len(entities) != 1 or len(graph["task_groups"]) != 1: + raise ValueError( + "TaskSpec execution currently supports only a single E2 object." + ) + return { + "schema_version": SCHEMA, + "template": validate_task_template(template), + "entity_id": next(iter(entities)), + "graph_hash": semantic_task_graph_hash(graph), + "template_hash": evaluator.template_hash, + } + + +def read_binding( + root: Path, graph: dict[str, Any], fingerprint: dict[str, Any] +) -> dict[str, Any] | None: + """Verify the new sidecar before simulator imports; legacy bundles stay v2.""" + path = root / "task_spec_binding.json" + if fingerprint.get("schema_version") != "semantic_integration_fingerprint/v3": + if path.exists() or "task_spec_binding" in fingerprint: + raise ValueError("TaskSpec sidecar requires fingerprint schema v3.") + return None + reference = fingerprint.get("task_spec_binding") + if ( + type(reference) is not dict + or set(reference) != {"uri", "content_hash"} + or reference["uri"] != path.name + ): + raise ValueError("TaskSpec bundle must declare its binding content reference.") + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != reference["content_hash"]: + raise ValueError("TaskSpec binding content fingerprint drifted.") + binding = json.loads(data) + if type(binding) is not dict or set(binding) != { + "schema_version", + "template", + "entity_id", + "graph_hash", + "template_hash", + }: + raise ValueError("Invalid TaskSpec binding fields.") + expected = binding_for_graph(binding["template"], graph) + if binding != expected: + raise ValueError("TaskSpec binding does not match its graph/template.") + return binding + + +def write_evidence(path: Path, value: dict[str, Any]) -> dict[str, str]: + """Write one frozen host artifact and return its exact byte identity.""" + data = ( + json.dumps(value, sort_keys=True, indent=2, allow_nan=False) + "\n" + ).encode() + path.write_bytes(data) + return {"uri": path.name, "content_hash": hashlib.sha256(data).hexdigest()} + + +def observe( + env: Any, binding: dict[str, Any], *, scope: str, output: Path +) -> dict[str, Any]: + """Read actual local poses without advancing or resetting the simulation.""" + target = getattr(env, "unwrapped", env) + obj = target.sim.get_rigid_object(binding["entity_id"]) + if obj is None: + raise ValueError("TaskSpec bound object has no simulation observation.") + poses = obj.get_local_pose(to_matrix=True).detach().clone() + if len(poses) != int(target.num_envs): + raise ValueError("TaskSpec observation environment count mismatch.") + result = UprightTaskEvaluator(binding["template"]).evaluate(poses, scope=scope) + raw = poses.cpu().tolist() + + # Invalid observations remain inspectable without non-standard JSON NaN. + def finite(value: Any) -> Any: + if isinstance(value, list): + return [finite(item) for item in value] + return value if math.isfinite(value) else None + + state = { + "schema_version": "gen_sim.observed_pose/v1", + "scope": scope, + "entity_id": binding["entity_id"], + "frame": "scene_z_up", + "unit": "m", + "env_ids": list(range(len(poses))), + "episode_id": 0, + "poses": finite(raw), + } + result["evidence"] = [write_evidence(output / f"{scope}_state.json", state)] + result["env_ids"] = list(range(len(poses))) + result["episode_id"] = 0 + return result + + +def final_acceptance( + env: Any, + binding: dict[str, Any], + initial: dict[str, Any], + program_result: Any, + output: Path, +) -> tuple[bool, ...]: + """Freeze the independent goal check before Gym finalizes episode metadata.""" + final = observe(env, binding, scope="task_goal", output=output) + task_success = tuple(status == "pass" for status in final["status"]) + accepted = tuple( + ok + and initial["status"][index] == "pass" + and not initial["goal_satisfied"][index] + and program_result.success[index] + for index, ok in enumerate(task_success) + ) + program = write_evidence( + output / "program_execution.json", program_result.to_metadata() + ) + write_evidence( + output / "task_evaluation.json", + { + "schema_version": "gen_sim.task_evaluation/v1", + "template_hash": binding["template_hash"], + "initial": deepcopy(initial), + "final": final, + "program_success": list(program_result.success), + "task_success": list(task_success), + "accepted": list(accepted), + "program_result": program, + "qualification": "observed_goal_only", + "certificate_status": "unavailable", + "qualification_limits": [ + "No asset-content, full-process or robustness certificate.", + "Instantaneous upright goal; segment stability remains an execution check.", + ], + }, + ) + return accepted + + +def record_failure( + env: Any, + binding: dict[str, Any], + initial: dict[str, Any] | None, + output: Path, + failure: dict[str, Any], + *, + num_envs: int, +) -> None: + """Freeze failed-attempt observations before host reset; never imply acceptance.""" + try: + final = observe(env, binding, scope="task_goal", output=output) + except Exception as exc: + final = { + "status": ["unavailable"] * num_envs, + "error": {"type": type(exc).__name__, "message": str(exc)}, + } + write_evidence( + output / "task_evaluation.json", + { + "schema_version": "gen_sim.task_evaluation/v1", + "template_hash": binding["template_hash"], + "initial": initial, + "final": final, + "execution_failure": failure, + "accepted": [False] * num_envs, + "certificate_status": "unavailable", + "qualification": "failed_attempt", + }, + ) + + +def require_fresh_evidence_output(output: Path) -> None: + """Reject reused TaskSpec attempt evidence instead of mixing episode identities.""" + names = ( + "initial_state.json", + "task_goal_state.json", + "initial_evaluation.json", + "program_execution.json", + "task_evaluation.json", + ) + if any((output / name).exists() for name in names): + raise ValueError( + "TaskSpec execution requires a fresh evidence output directory." + ) diff --git a/embodichain/gen_sim/task_engine/cli.py b/embodichain/gen_sim/task_engine/cli.py index b4f12f003..50937bf29 100644 --- a/embodichain/gen_sim/task_engine/cli.py +++ b/embodichain/gen_sim/task_engine/cli.py @@ -90,6 +90,11 @@ def _add_workflow_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--output-root", required=True) parser.add_argument("--config", default=None) parser.add_argument("--model", default=None) + parser.add_argument( + "--task-template", + default=None, + help="Explicit bounded E2 TaskSpec JSON; enables measured initial/final checks.", + ) parser.add_argument("--base-seed", type=int, default=0) parser.add_argument( "--dataset_saving", @@ -99,7 +104,7 @@ def _add_workflow_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--robot-profile", choices=_ROBOT_PROFILES, - default="franka", + default="dual_franka", ) _add_failure_policy_argument(parser) @@ -159,6 +164,16 @@ def _run_workflow( if scene is not None: validate_scene_history_root(scene, args.output_root) instruction = _instruction(args) + if args.robot_profile != "dual_franka": + parser.error( + "Executable Task Engine bundles currently support only dual_franka." + ) + task_template = None + if args.task_template is not None: + from embodichain.lab.task_evaluation import UprightTaskEvaluator + + task_template = json.loads(Path(args.task_template).read_text()) + UprightTaskEvaluator(task_template) adapter = SceneAdapter(model=args.model, robot_profile=args.robot_profile) workflow = TaskEngineWorkflow(scene_adapter=adapter) workflow_cfg, planning_cfg, execution_cfg = load_task_engine_config(args.config) @@ -187,6 +202,7 @@ def _run_workflow( run_id=allocation.run_id, created_at=allocation.created_at, execute=execute, + **({"task_template": task_template} if task_template is not None else {}), ) _print_json( { diff --git a/embodichain/gen_sim/task_engine/orchestration/coordinator.py b/embodichain/gen_sim/task_engine/orchestration/coordinator.py index b0868cb43..b4d4a0351 100644 --- a/embodichain/gen_sim/task_engine/orchestration/coordinator.py +++ b/embodichain/gen_sim/task_engine/orchestration/coordinator.py @@ -53,6 +53,7 @@ ) from .scene_adapter import SceneAdaptation, SceneAdapter from .scene_source import SceneSourceRef +from ..scene.feasibility import FeasibilityBroker __all__ = ["PreparationResult", "TaskEngineCoordinator"] @@ -122,6 +123,7 @@ def prepare( force_most_likely: bool = False, final_inspection: Mapping[str, Any] | None = None, unbound_action_plan: Mapping[str, Any] | None = None, + task_template: dict[str, Any] | None = None, ) -> PreparationResult: """Publish a graph and configured Task Program as one transaction. @@ -138,6 +140,10 @@ def prepare( randomize_scene, randomize_table_material, ) + if task_template is not None: + from embodichain.lab.task_evaluation import UprightTaskEvaluator + + UprightTaskEvaluator(task_template) normalized_source = self._coerce_source(source) validate_scene_output_separation(normalized_source.path, output_dir) with ArtifactTransaction(output_dir, overwrite=overwrite) as transaction: @@ -196,6 +202,38 @@ def prepare( "planner_route": str(planning_mode), "status": "running", } + feasibility_report = None + if adaptation.static_scene_manifest is not None: + feasibility_report = FeasibilityBroker().assess( + selected, + role_bindings, + adaptation.static_scene_manifest, + ) + _write_json(staging / "feasibility_report.json", feasibility_report) + if feasibility_report["status"] == "contradicted": + published = transaction.commit() + return PreparationResult( + status="infeasible", + output_dir=published, + candidate_set=deepcopy(normalized_candidates), + adaptation=adaptation, + artifacts=task_engine_artifact_paths(published), + feasibility_report=feasibility_report, + ) + _write_json( + staging / "validation_status.json", + { + "schema_version": "gen_sim.preparation_validation/v1", + "static_assessment": ( + "not_run" + if feasibility_report is None + else feasibility_report["status"] + ), + "physical_execution": "not_run", + "ik": "not_run", + "collision_path": "not_run", + }, + ) try: graph = self.semantic_planner.plan( selected, @@ -210,6 +248,11 @@ def prepare( robot_profile=str(adaptation.scene_manifest["robot_profile"]), max_episodes=max_episodes, max_episode_steps=max_episode_steps, + **( + {"task_template": task_template} + if task_template is not None + else {} + ), ) except (TypeError, ValueError, UnsupportedSemanticCapabilityError) as exc: planning_attempt["status"] = "failed" @@ -232,6 +275,7 @@ def prepare( adaptation=adaptation, artifacts=task_engine_artifact_paths(published), planning_attempts=(deepcopy(planning_attempt),), + feasibility_report=feasibility_report, ) planning_attempt.update( @@ -252,6 +296,7 @@ def prepare( adaptation=adaptation, artifacts=task_engine_artifact_paths(published), semantic_task_graph=deepcopy(graph), + feasibility_report=feasibility_report, generated_paths=_published_paths(generated, published), planning_attempts=(deepcopy(planning_attempt),), unbound_action_plan=( diff --git a/embodichain/gen_sim/task_engine/scene/feasibility.py b/embodichain/gen_sim/task_engine/scene/feasibility.py index db2a7a69d..bb8e39fae 100644 --- a/embodichain/gen_sim/task_engine/scene/feasibility.py +++ b/embodichain/gen_sim/task_engine/scene/feasibility.py @@ -53,10 +53,12 @@ def assess( role_bindings: Mapping[str, Any], scene_manifest: Mapping[str, Any], *, - capability_catalog: Mapping[str, Mapping[str, Any]], - task_actions: Mapping[str, Sequence[str]], + capability_catalog: Mapping[str, Mapping[str, Any]] | None = None, + task_actions: Mapping[str, Sequence[str]] | None = None, ) -> FeasibilityReport: """Assess one grounded candidate against static and runtime capabilities.""" + if (capability_catalog is None) != (task_actions is None): + raise ValueError("Capability catalog and action recipes must be paired.") manifest = validate_static_scene_manifest(scene_manifest) draft = _mapping(candidate.get("draft"), "candidate.draft") scene_request = _mapping( @@ -73,6 +75,19 @@ def assess( for step_id, step in steps.items(): task_type = str(step.get("task_type", "")) + if task_actions is None: + checks.append( + _check( + "atomic_capability", + step_id, + "unknown", + "Runtime capability assessment was not run; semantic " + "preflight and physical execution are separate checks.", + evidence={"status": "not_run"}, + ) + ) + continue + assert capability_catalog is not None actions = task_actions.get(task_type) if not actions: checks.append( diff --git a/embodichain/gen_sim/task_engine/state_machine.py b/embodichain/gen_sim/task_engine/state_machine.py index ffdaaba15..68389fcb1 100644 --- a/embodichain/gen_sim/task_engine/state_machine.py +++ b/embodichain/gen_sim/task_engine/state_machine.py @@ -91,7 +91,9 @@ class StageStatus(str, Enum): WorkflowStage.EXECUTION: frozenset({WorkflowStage.GROUNDED_ACTION}), } -_SKIPPABLE_STAGES = frozenset({WorkflowStage.SCENE_EDIT}) +_SKIPPABLE_STAGES = frozenset( + {WorkflowStage.SCENE_EDIT, WorkflowStage.STATIC_FEASIBILITY} +) @dataclass(frozen=True) @@ -211,7 +213,9 @@ def fail_stage( 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.") + raise ValueError( + "Only scene_edit or unproven static_feasibility 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) diff --git a/embodichain/gen_sim/task_engine/task_program_bundle.py b/embodichain/gen_sim/task_engine/task_program_bundle.py index 78f640d9a..9d611e831 100644 --- a/embodichain/gen_sim/task_engine/task_program_bundle.py +++ b/embodichain/gen_sim/task_engine/task_program_bundle.py @@ -124,6 +124,7 @@ def generate_task_program_bundle( robot_profile: str, max_episodes: int | None = None, max_episode_steps: int | None = None, + task_template: dict[str, Any] | None = None, ) -> tuple[SemanticTaskGraph, TaskProgramBundlePaths]: """Write, compose, and provider-free preflight one semantic deployment. @@ -136,6 +137,8 @@ def generate_task_program_bundle( only the canonical dual-Franka embodiment. max_episodes: Optional Gym episode limit. max_episode_steps: Optional Gym step limit. + task_template: Explicit bounded upright template for independent E2 + acceptance. Legacy graphs keep their original step-hash semantics. Returns: Final fingerprint-bound graph and all generated paths. @@ -145,6 +148,11 @@ def generate_task_program_bundle( integration cannot be composed and preflighted. """ selected_graph = validate_semantic_task_graph(graph) + task_binding = None + if task_template is not None: + from ._task_spec import binding_for_graph + + task_binding = binding_for_graph(task_template, selected_graph) if "task_spec" in selected_graph: raise ValueError( "TaskSpec bundle export requires final task evaluation before data " @@ -222,6 +230,20 @@ def generate_task_program_bundle( program_id = _program_identifier(selected_graph["task_id"]) scene_contract = f"{program_id}_scene_v1" stability = _task_stability_payload(selected_graph, scene, embodiment_payload) + if task_binding is not None: + from embodichain.lab.task_evaluation import UprightTaskEvaluator + + minimum_alignment = math.cos(UprightTaskEvaluator(task_template).max_tilt) + for preset in stability["presets"].values(): + if ( + preset.get("entity") == task_binding["entity_id"] + and "local_axis" in preset + ): + if list(preset["local_axis"]) != [0.0, 0.0, 1.0]: + raise ValueError( + "TaskSpec upright requires the asset local +Z axis." + ) + preset["minimum_alignment"] = minimum_alignment save_config( paths.program, _program_payload( @@ -323,6 +345,15 @@ def generate_task_program_bundle( validation_context=deployment.integration.registration.catalog, ) deployment.integration.registration.catalog.preflight(program) + if task_binding is not None: + from ._task_spec import binding_for_graph, write_evidence + + binding = binding_for_graph(task_template, selected_graph) + reference = write_evidence(root / "task_spec_binding.json", binding) + fingerprint_payload = json.loads(paths.integration_fingerprint.read_text()) + fingerprint_payload["schema_version"] = "semantic_integration_fingerprint/v3" + fingerprint_payload["task_spec_binding"] = reference + _write_json(paths.integration_fingerprint, fingerprint_payload) return selected_graph, paths @@ -1091,6 +1122,7 @@ def coordinated_lowerer_routes( "simulation.coordinated_transport", _COORDINATED_HOLD_CALL_ID, _AXIS_ALIGN_CALL_ID, + _MOVE_HELD_OBJECT_CALL_ID, *pick_routes, _PLACE_RELATIVE_CALL_ID, ) @@ -1133,7 +1165,13 @@ def coordinated_lowerer_routes( else [] ), *( - [{"kind": "move_held_object", "routes": move_held_routes}] + [ + { + "kind": "move_held_object", + "routes": move_held_routes, + "phase_protection": "held_object_v1", + } + ] if move_held_routes else [] ), diff --git a/embodichain/gen_sim/task_engine/workflow.py b/embodichain/gen_sim/task_engine/workflow.py index 3fdcda234..3ccc3217a 100644 --- a/embodichain/gen_sim/task_engine/workflow.py +++ b/embodichain/gen_sim/task_engine/workflow.py @@ -66,6 +66,7 @@ fail_stage, initial_state, start_stage, + skip_stage, ) from .workflow_contracts import TaskRunRequest, validate_task_run_request @@ -307,6 +308,7 @@ def run( created_at: datetime | None = None, overwrite: bool = False, execute: bool = True, + task_template: dict[str, Any] | None = None, ) -> TaskEngineRunResult: """Run all stages and publish success only after simulator acceptance. @@ -326,11 +328,16 @@ def run( 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. + task_template: Optional explicit bounded E2 task definition. Returns: Published run status, manifest, state audit, and final bundle path. """ normalized = validate_task_run_request(request) + if task_template is not None: + from embodichain.lab.task_evaluation import UprightTaskEvaluator + + UprightTaskEvaluator(task_template) if not isinstance(dataset_saving, bool): raise TypeError("dataset_saving must be a boolean.") if not isinstance(open_window, bool): @@ -685,6 +692,11 @@ def run( force_most_likely=True, final_inspection=final_inspection, unbound_action_plan=unbound_plan, + **( + {"task_template": task_template} + if task_template is not None + else {} + ), ) except Exception as exc: preparation_error = exc @@ -829,6 +841,12 @@ def run( WorkflowStage.STATIC_FEASIBILITY, WorkflowStage.GROUNDED_ACTION, ): + if stage == WorkflowStage.STATIC_FEASIBILITY and ( + preparation.feasibility_report is None + or preparation.feasibility_report.get("status") != "proven" + ): + state = skip_stage(state, stage) + continue state = start_stage(state, stage) state = complete_stage(state, stage) if not execute: diff --git a/embodichain/lab/gym/envs/demo.py b/embodichain/lab/gym/envs/demo.py index 6414bf8ea..e066e7409 100644 --- a/embodichain/lab/gym/envs/demo.py +++ b/embodichain/lab/gym/envs/demo.py @@ -678,6 +678,7 @@ def execute_demo_episode( attempt_id: int = 0, should_stop: StopPredicate | None = None, progress: ProgressWrapper | None = None, + final_acceptance: Callable[[DemoEpisodeResult], tuple[bool, ...]] | None = None, **plan_kwargs: Any, ) -> DemoEpisodeResult: """Plan and execute every segment in one environment episode. @@ -695,6 +696,10 @@ def execute_demo_episode( attempt_id: Zero-based identifier for this collection attempt. should_stop: Optional callback checked before every action. progress: Optional wrapper such as ``tqdm`` for action iterables. + final_acceptance: Optional host evaluation after all segments and cleanup, + before episode metadata is finalized. Returns exact per-row booleans; + it can reject program successes but cannot promote failed execution. + The callback must not step, reset, or commit the environment. **plan_kwargs: Arguments forwarded to the task's planning method. Returns: @@ -1162,6 +1167,40 @@ def publish_active_mask() -> None: execution_mode=execution_cfg.mode, attempt_id=attempt_id, ) + if final_acceptance is not None: + accepted = final_acceptance(result) + if ( + type(accepted) is not tuple + or len(accepted) != num_envs + or any(type(value) is not bool for value in accepted) + ): + raise ValueError("final_acceptance must return one boolean per row.") + final_success = tuple( + original and allowed + for original, allowed in zip(result.success, accepted, strict=True) + ) + reasons = tuple( + "final_acceptance_failed" if original and not allowed else reason + for original, allowed, reason in zip( + result.success, accepted, result.terminal_reasons, strict=True + ) + ) + result = replace( + result, + success=final_success, + completed=result.completed and all(final_success), + completed_by_env=tuple( + done and allowed + for done, allowed in zip( + result.completed_by_env, accepted, strict=True + ) + ), + terminal_reasons=reasons, + terminal_reason=next( + (reason for reason in reasons if reason != "success"), + result.terminal_reason, + ), + ) if end_episode is not None: end_episode(result=result) return result diff --git a/embodichain/lab/task_evaluation.py b/embodichain/lab/task_evaluation.py new file mode 100644 index 000000000..11870f4bd --- /dev/null +++ b/embodichain/lab/task_evaluation.py @@ -0,0 +1,138 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + + +"""Bounded TaskSpec observation evaluation, without a simulation lifecycle.""" + +from __future__ import annotations + +from collections.abc import Mapping +import math +from typing import Any +import torch + +from embodichain.compute.task_predicates import axis_tilt +from embodichain.task_spec import canonical_template, validate_task_template + +__all__ = ["UprightTaskEvaluator"] + + +class UprightTaskEvaluator: + """Evaluate one local-+Z upright goal, optionally initially not upright. + + This deliberately rejects process, capability, duration and other predicate + requirements. It does not infer attachment or support from object posture, + and owns no stepping, retry, reset, data submission or persistence. + + Args: + template: Validated TaskSpec v0.1 record with a single upright goal. + """ + + def __init__(self, template: Mapping[str, Any]) -> None: + source = validate_task_template(template) + value = canonical_template(source) + goal = value["goal"] + if ( + len(value["roles"]) != 1 + or value["roles"]["role_0"]["kind"] not in {"object", "container"} + or value["roles"]["role_0"].get("capabilities") + or value["invariants"] + or value["requirements"] + or value.get("temporal") + or len(goal) != 1 + or goal[0].get("predicate") != "upright" + ): + raise ValueError( + "Only bounded single-object upright TaskSpec is supported." + ) + self.max_tilt = float(goal[0]["max_tilt"]["value"]) + if not 0 < self.max_tilt < math.pi / 2: + raise ValueError("Upright max_tilt must be between zero and pi/2.") + negative = {"op": "not", "args": [goal[0]]} + if value["init"] not in ([], [negative]): + raise ValueError( + "Only bounded initially-not-upright constraints are supported." + ) + self.require_initially_fallen = bool(value["init"]) + self.template_hash = source["semantic_hash"] + + def evaluate(self, poses: torch.Tensor, *, scope: str) -> dict[str, Any]: + """Evaluate a detached simultaneous pose batch in metres, scene Z-up. + + Missing, nonfinite, or nonrigid rows are unavailable, including under + negation. Results describe an instantaneous goal, not a stable window. + + Args: + poses: Simultaneous observed transforms, shaped ``(N, 4, 4)``. + scope: Either ``initial`` or ``task_goal``. + + Returns: + Detached JSON-compatible statuses, angles and checker identity. + """ + if scope not in {"initial", "task_goal"}: + raise ValueError("Expected initial or task_goal evaluation scope.") + if not isinstance(poses, torch.Tensor) or not poses.is_floating_point(): + raise ValueError("Observed poses must be a floating point tensor.") + if poses.ndim != 3 or poses.shape[-2:] != (4, 4) or not len(poses): + raise ValueError("Observed poses must have shape (N, 4, 4).") + pose = poses.detach().clone() + rotation = pose[:, :3, :3] + valid = torch.isfinite(pose).all(dim=-1).all(dim=-1) + valid &= ( + torch.isclose( + rotation.transpose(-1, -2) @ rotation, + torch.eye(3, device=pose.device, dtype=pose.dtype), + atol=1e-4, + rtol=0, + ) + .all(dim=-1) + .all(dim=-1) + ) + valid &= torch.isclose( + torch.linalg.det(rotation), pose.new_tensor(1.0), atol=1e-4, rtol=0 + ) + valid &= torch.isclose( + pose[:, 3, :], pose.new_tensor([0.0, 0.0, 0.0, 1.0]), atol=1e-5, rtol=0 + ).all(-1) + tilt = axis_tilt(pose, pose.new_tensor([0.0, 0.0, 1.0])) + upright = tilt <= self.max_tilt + accepted = upright + if scope == "initial": + accepted = ( + ~upright if self.require_initially_fallen else torch.ones_like(upright) + ) + return { + "schema_version": "taskspec/upright_observation/v0.1", + "template_hash": self.template_hash, + "checker": {"name": "upright_pose", "version": "1"}, + "predicate": {"name": "upright", "version": "1"}, + "scope": scope, + "frame": "scene_z_up", + "unit": "m", + "max_tilt_rad": self.max_tilt, + "status": [ + "unavailable" if not ok else "pass" if passed else "failed" + for ok, passed in zip(valid.tolist(), accepted.tolist(), strict=True) + ], + "goal_satisfied": [ + bool(ok and satisfied) + for ok, satisfied in zip(valid.tolist(), upright.tolist(), strict=True) + ], + "tilt_rad": [ + float(angle) if ok else None + for ok, angle in zip(valid.tolist(), tilt.tolist(), strict=True) + ], + } diff --git a/embodichain/task_spec/IMPLEMENTATION.md b/embodichain/task_spec/IMPLEMENTATION.md index 45000c8e6..36f3a1d11 100644 --- a/embodichain/task_spec/IMPLEMENTATION.md +++ b/embodichain/task_spec/IMPLEMENTATION.md @@ -56,3 +56,24 @@ from provider-free preflight and physical execution. Do not unconditionally mark unperformed static checks complete. Run focused tests, Black, API docs/context gates, and available physical smoke only with verified prerequisites. Review full branch, fix findings, commit/push own branch, and create PR targeting #531 head branch. + +## First-PR delivery boundary + +The first PR delivers P0, pure P1, explicit planning provenance, truthful +preparation reporting and a bounded **observed-goal acceptance** increment. +Task 2's full instance/witness/certificate exit condition remains open. + +Implementation inspection found that E2's longest-axis recipe is not generally +equivalent to TaskSpec's local-+Z upright predicate. Therefore the opt-in runtime +acceptance path is restricted to matching +Z assets and a single explicit upright +template. It uses the existing graph/v1 plus a versioned TaskSpec sidecar and +fingerprint/v3. Provenance graph/v2 remains gated, rather than pretending that +the prepared scene is already an observed SceneInstance. Full asset content +closure, measured instance identity, automatic seed migration, template-derived +scene generation and successful witness/certificate assembly are deferred to the +next dependent increment. Reports explicitly say certificate unavailable. + +CPU acceptance tests establish initial/final observation, per-row final goals, +metadata/save/reset order and failure evidence. They do not establish a physical +success witness. Public phase protection uses measured evidence; missing or +pending required evidence cannot become successful protected execution. diff --git a/embodichain/task_spec/README.md b/embodichain/task_spec/README.md index 2900a009c..22102e522 100644 --- a/embodichain/task_spec/README.md +++ b/embodichain/task_spec/README.md @@ -152,12 +152,48 @@ Callers are responsible for supplying the actual observed instance. Without those inputs, the existing candidate and graph/v1 behavior is unchanged. v2 is a provenance-carrying plan candidate: it does not claim recipe satisfaction -of the template. The bundle builder and runner refuse v2 before creating output, -because final task evaluation is not yet wired before dataset submission/reset. +of the template. The bundle builder and runner still refuse v2, because a full +measured SceneInstance / witness evidence qualification is not implemented. + +### Opt-in bounded E2 acceptance + +`task-engine prepare` and `run-all` accept `--task-template template.json`. +The template must contain one object/container role, one `upright` goal with +`0 < max_tilt < pi/2` radians, no capabilities/process constraints, and either +empty init or the exact negation of the goal as init. The selected E2 recipe +must bind one object whose upright axis is local +Z. Unsupported templates are +rejected before generation. Unsupported asset-axis bindings fail preparation. +The CLI defaults to dual_franka and rejects other profiles before generation. + +The existing graph/v1 remains the executable recipe. A new strict +`gen_sim.taskspec_e2/v1` sidecar owns the explicit template and binding, bound to +the graph and referenced by exact bytes from fingerprint schema v3. Legacy +bundles without TaskSpec remain fingerprint v2. Segment upright thresholds +are derived from the template; their extra settling time remains execution +policy. No second normative success specification is authored. + +After reset/settling the runner records actual poses, evaluates init, and +rejects already-satisfied goals as a **demo acceptance policy**, not task +semantics. This first host aborts the entire batch if any initial row is +invalid/unavailable/trivial, preserving per-row initial evidence; no row is +executed or saved in that case. Final goal checks are independent per-row. +Gym's `final_acceptance` hook freezes goal evidence after all segments/cleanup +and before episode metadata finalization. A successful program cannot override +a failed goal. A failed final batch is not committed as successful training data. + +`initial_state.json`, `task_goal_state.json`, `initial_evaluation.json`, +`program_execution.json` and `task_evaluation.json` separate observed state, +program completion and task acceptance. Evidence references hash exact bytes. +The report deliberately records `certificate_status=unavailable`: asset-content, +full-process, actual SceneInstance identity and robustness qualification remain +unimplemented. It is not a successful certified ActionWitness. Invalid pose +rows remain unavailable, including under negation. No physical rollout or +robustness claim follows from CPU tests. Next work belongs in the existing owners: TaskAgent seed migration and template-derived SceneRequest, scene adapter observation/grounding evidence, -shared predicate measurements, final/step evaluation, and Workflow certificate -assembly. Public registered phase protection/recovery (P0), full P2/P3 execution, -and motion expansion integration remain separate changes. No runtime or physical -task certification is claimed by this package. +additional shared predicate measurements, step monitoring, and Workflow +certificate assembly. Full P2/P3 instance/witness qualification, E1/E4/E5 +template execution, Gradio migration, decoder extension consolidation and motion +expansion hosting remain subsequent increments. Public registered protection +and empty-plan recovery are implemented in their existing execution owners. diff --git a/tests/gen_sim/task_engine/orchestration/test_coordinator_cli.py b/tests/gen_sim/task_engine/orchestration/test_coordinator_cli.py index c490907b4..1df9934aa 100644 --- a/tests/gen_sim/task_engine/orchestration/test_coordinator_cli.py +++ b/tests/gen_sim/task_engine/orchestration/test_coordinator_cli.py @@ -520,6 +520,46 @@ def generate_bundle(planned_graph, _scene, output, **kwargs): assert not (result.output_dir / "seed_task_graph.json").exists() +def test_prepare_invokes_broker_and_stops_contradicted_scene_before_planning( + tmp_path, monkeypatch +): + from dataclasses import replace + from embodichain.gen_sim.task_engine.scene.feasibility import FeasibilityBroker + + adaptation = replace(_adaptation(tmp_path), static_scene_manifest={"present": True}) + calls = [] + report = {"status": "contradicted", "remediation_class": "scene_remediable"} + + def assess(self, candidate, binding, manifest): + calls.append((candidate, binding, manifest)) + return report + + def forbidden_plan(*args, **kwargs): + raise AssertionError("Contradicted scene must not reach planner/preflight.") + + monkeypatch.setattr(FeasibilityBroker, "assess", assess) + coordinator = TaskEngineCoordinator( + task_agent=SimpleNamespace(generate=lambda *args, **kwargs: _candidate_set()), + scene_adapter=SimpleNamespace( + robot_profile="dual_franka", adapt=lambda *args, **kwargs: adaptation + ), + semantic_planner=SimpleNamespace(plan=forbidden_plan), + ) + result = coordinator.prepare( + "upright_can", + _UPRIGHT_CAN_INSTRUCTION, + tmp_path / "scene_config.json", + tmp_path / "bundle", + ) + assert result.status == "infeasible" + assert len(calls) == 1 + assert result.feasibility_report == report + assert ( + json.loads((result.output_dir / "feasibility_report.json").read_text()) + == report + ) + + def test_prepare_publishes_semantic_planning_failure_context( tmp_path: Path, ) -> None: diff --git a/tests/gen_sim/task_engine/scene/test_scene_boundary.py b/tests/gen_sim/task_engine/scene/test_scene_boundary.py index 5321f83dc..9068f4ba8 100644 --- a/tests/gen_sim/task_engine/scene/test_scene_boundary.py +++ b/tests/gen_sim/task_engine/scene/test_scene_boundary.py @@ -239,6 +239,22 @@ def test_e2_feasibility_requires_runtime_probe_for_geometry(tmp_path: Path) -> N assert report["status"] == "runtime_probe" assert report["remediation_class"] == "none" + + +def test_static_assessment_without_runtime_catalog_is_not_proven( + 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, + ) + assert report["status"] != "proven" + assert any(item["kind"] == "structure" for item in report["checks"]) + assert any(item["evidence"].get("status") == "not_run" for item in report["checks"]) assert report["blockers"] == [] assert report["summary"]["proven"] > 0 assert report["summary"]["runtime_probe"] > 0 diff --git a/tests/gen_sim/task_engine/test_semantic_graph.py b/tests/gen_sim/task_engine/test_semantic_graph.py index 4fa32735c..050e0adb0 100644 --- a/tests/gen_sim/task_engine/test_semantic_graph.py +++ b/tests/gen_sim/task_engine/test_semantic_graph.py @@ -17,6 +17,7 @@ from __future__ import annotations from copy import deepcopy +import math from pathlib import Path import numpy as np @@ -1058,8 +1059,10 @@ def test_coordinated_bundle_composes_against_unmodified_public_options( ) +@pytest.mark.parametrize("with_task_spec", [False, True]) def test_generated_e2_bundle_shares_release_route_with_axis_acceptance( tmp_path: Path, + with_task_spec: bool, ) -> None: """The merged E2 route passes real configured composition and preflight.""" scene = _prepared_axis_scene(tmp_path) @@ -1134,13 +1137,46 @@ def test_generated_e2_bundle_shares_release_route_with_axis_acceptance( } ] + template = { + "schema_version": "taskspec/template/v0.1", + "semantic_version": "0.1", + "roles": {"item": {"kind": "object"}}, + "init": [], + "invariants": [], + "requirements": [], + "goal": [ + { + "predicate": "upright", + "object": "item", + "max_tilt": {"value": "0.1", "unit": "rad"}, + } + ], + } + from embodichain.task_spec import semantic_hash + + template["semantic_hash"] = semantic_hash(template) generated, paths = generate_task_program_bundle( graph, scene, tmp_path / "bundle", robot_profile="dual_franka", + **({"task_template": template} if with_task_spec else {}), ) + if with_task_spec: + from embodichain.gen_sim.task_engine._task_spec import read_binding + + fingerprint = load_config(paths.integration_fingerprint) + binding = read_binding(paths.root, generated, fingerprint) + assert binding["template_hash"] == template["semantic_hash"] + assert binding["entity_id"] == "bottle" + constraints = load_config(paths.root / "task_program/constraints.json") + assert all( + value["minimum_alignment"] == pytest.approx(math.cos(0.1)) + for value in constraints["presets"].values() + if value.get("local_axis") + ) + _verify_program_projection(paths.program, generated) integration = load_config(paths.integration) segments = load_config(paths.program)["program"]["items"] diff --git a/tests/gen_sim/task_engine/test_task_spec_planning.py b/tests/gen_sim/task_engine/test_task_spec_planning.py index a608e38bc..68ac4014b 100644 --- a/tests/gen_sim/task_engine/test_task_spec_planning.py +++ b/tests/gen_sim/task_engine/test_task_spec_planning.py @@ -17,6 +17,104 @@ from __future__ import annotations from copy import deepcopy + + +def test_failed_task_spec_attempt_freezes_unavailable_evidence_and_rejects_reuse( + tmp_path, +): + from types import SimpleNamespace + import json + from embodichain.gen_sim.task_engine._task_spec import ( + binding_for_graph, + record_failure, + require_fresh_evidence_output, + ) + + candidate, bindings, objects, template, _ = planning_inputs() + binding = binding_for_graph( + template, SemanticTaskPlanner().plan(candidate, bindings, objects) + ) + failure = {"type": "RuntimeError", "message": "planner failed after safe-stop"} + env = SimpleNamespace(sim=SimpleNamespace(get_rigid_object=lambda uid: None)) + require_fresh_evidence_output(tmp_path) + record_failure(env, binding, None, tmp_path, failure, num_envs=2) + evidence = json.loads((tmp_path / "task_evaluation.json").read_text()) + assert evidence["accepted"] == [False, False] + assert evidence["final"]["status"] == ["unavailable", "unavailable"] + assert evidence["execution_failure"] == failure + with pytest.raises(ValueError, match="fresh evidence"): + require_fresh_evidence_output(tmp_path) + + +def test_task_spec_observes_live_final_state_and_records_failure_before_acceptance( + tmp_path, +): + from types import SimpleNamespace + import json + import torch + from embodichain.gen_sim.task_engine._task_spec import ( + binding_for_graph, + observe, + final_acceptance, + ) + + candidate, bindings, objects, template, instance = planning_inputs() + graph = SemanticTaskPlanner().plan(candidate, bindings, objects) + binding = binding_for_graph(template, graph) + poses = torch.eye(4).repeat(2, 1, 1) + fallen = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]]) + poses[:, :3, :3] = fallen + obj = SimpleNamespace(get_local_pose=lambda **kwargs: poses) + env = SimpleNamespace( + num_envs=2, sim=SimpleNamespace(get_rigid_object=lambda uid: obj) + ) + initial = observe(env, binding, scope="initial", output=tmp_path) + assert initial["goal_satisfied"] == [False, False] + # Both program rows succeeded, but the second object's final goal did not. + poses[0] = torch.eye(4) + result = SimpleNamespace( + success=(True, True), to_metadata=lambda: {"success": [True, True]} + ) + accepted = final_acceptance(env, binding, initial, result, tmp_path) + assert accepted == (True, False) + report = json.loads((tmp_path / "task_evaluation.json").read_text()) + assert report["task_success"] == [True, False] + assert report["program_success"] == [True, True] + assert report["certificate_status"] == "unavailable" + assert report["initial"]["goal_satisfied"] == [False, False] + + +def test_task_spec_binding_rejects_drift_and_orphaned_sidecars(tmp_path): + from embodichain.gen_sim.task_engine._task_spec import ( + binding_for_graph, + read_binding, + write_evidence, + ) + + candidate, bindings, objects, template, _ = planning_inputs() + graph = SemanticTaskPlanner().plan(candidate, bindings, objects) + binding = binding_for_graph(template, graph) + path = tmp_path / "task_spec_binding.json" + ref = write_evidence(path, binding) + fingerprint = { + "schema_version": "semantic_integration_fingerprint/v3", + "task_spec_binding": ref, + } + assert read_binding(tmp_path, graph, fingerprint) == binding + with pytest.raises(ValueError, match="schema v3"): + read_binding( + tmp_path, graph, {"schema_version": "semantic_integration_fingerprint/v2"} + ) + changed = deepcopy(binding) + changed["entity_id"] = "another_can" + write_evidence(path, changed) + with pytest.raises(ValueError, match="drifted"): + read_binding(tmp_path, graph, fingerprint) + path.unlink() + with pytest.raises(FileNotFoundError): + read_binding(tmp_path, graph, fingerprint) + + import pytest from embodichain.task_spec import semantic_hash, scene_instance_hash diff --git a/tests/gen_sim/task_engine/test_task_spec_runner.py b/tests/gen_sim/task_engine/test_task_spec_runner.py new file mode 100644 index 000000000..d8bf58d9d --- /dev/null +++ b/tests/gen_sim/task_engine/test_task_spec_runner.py @@ -0,0 +1,146 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + + +"""CPU host regressions: final TaskSpec evidence precedes reset and submission.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +import pytest +import torch + +from embodichain.gen_sim.task_engine import _bundle_runner as runner +from embodichain.gen_sim.task_engine._task_spec import binding_for_graph +from embodichain.gen_sim.task_engine.semantic_planner import SemanticTaskPlanner +from embodichain.lab.gym.envs.demo import DemoSegment +from .test_task_spec_planning import planning_inputs + + +@pytest.mark.parametrize("mode", ["goal_failed", "execution_error", "succeeded"]) +def test_runner_freezes_task_evidence_before_reset(tmp_path, monkeypatch, mode): + import gymnasium + from embodichain.lab.gym.utils import gym_utils, registration + from embodichain.gen_sim.task_engine._task_program import assembly + from embodichain.lab.task_program import language + from embodichain.lab.sim.sim_manager import SimulationManager + from embodichain.gen_sim.task_engine import _task_spec + + candidate, bindings, objects, template, _ = planning_inputs() + graph = SemanticTaskPlanner().plan(candidate, bindings, objects) + binding = binding_for_graph(template, graph) + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "task_program").mkdir() + for name in ( + "task_program_deployment.yaml", + "task_program/program.yaml", + "semantic_task_graph.json", + "integration_fingerprint.json", + ): + (bundle / name).write_text("{}") + output = tmp_path / "execution" + pose = torch.eye(4).unsqueeze(0) + pose[0, :3, :3] = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]]) + calls = [] + + class Env: + num_envs = 1 + sim = SimpleNamespace( + get_rigid_object=lambda uid: SimpleNamespace( + get_local_pose=lambda **kwargs: pose + ) + ) + + def reset(self, **kwargs): + calls.append(("reset", kwargs)) + if len([call for call in calls if call[0] == "reset"]) == 2: + report = json.loads((output / "task_evaluation.json").read_text()) + assert report["accepted"] == [mode == "succeeded"] + if mode != "succeeded": + assert kwargs["options"] == {"save_data": False} + # Destroy old state; a post-reset observer would read this instead. + pose[:] = float("nan") + + def create_demo_segments(self): + if mode == "execution_error": + raise RuntimeError("injected planning failure") + yield DemoSegment(actions=(1,), name="recipe") + + def step(self, action): + if mode == "succeeded": + pose[0] = torch.eye(4) + return ( + None, + torch.zeros(1), + torch.zeros(1, dtype=torch.bool), + torch.zeros(1, dtype=torch.bool), + {}, + ) + + def is_task_success(self): + return torch.ones(1, dtype=torch.bool) + + def _end_demo_episode_recording(self, result): + calls.append(("metadata", result.success)) + assert (output / "task_evaluation.json").exists() + assert result.success == (mode == "succeeded",) + + def close(self, **kwargs): + pass + + catalog = SimpleNamespace(preflight=lambda program: None) + deployment = SimpleNamespace( + selection=None, + integration=SimpleNamespace(registration=SimpleNamespace(catalog=catalog)), + ) + monkeypatch.setattr(runner, "_verify_source", lambda root: None) + monkeypatch.setattr(runner, "validate_semantic_task_graph", lambda value: graph) + monkeypatch.setattr(_task_spec, "read_binding", lambda *args: binding) + monkeypatch.setattr( + runner, "_verify_integration_fingerprint", lambda *args: deployment + ) + monkeypatch.setattr(runner, "_verify_program_projection", lambda *args: None) + monkeypatch.setattr( + runner, "load_config", lambda path: {"id": "test", "max_episode_steps": 10} + ) + monkeypatch.setattr(assembly, "register_deployment", lambda *args, **kwargs: None) + monkeypatch.setattr(registration, "discover_task_packages", lambda: None) + monkeypatch.setattr(registration, "execute_init_hooks", lambda: None) + monkeypatch.setattr( + gym_utils, + "build_env_cfg_from_args", + lambda *args, **kwargs: (SimpleNamespace(), {"id": "test"}, {}), + ) + monkeypatch.setattr(language, "load_task_program", lambda *args, **kwargs: None) + monkeypatch.setattr(gymnasium, "make", lambda **kwargs: Env()) + monkeypatch.setattr( + runner, "_preserve_failed_execution_recording", lambda *args, **kwargs: None + ) + monkeypatch.setattr(SimulationManager, "flush_cleanup_queue", lambda: None) + monkeypatch.setattr( + runner, + "_build_execution_report", + lambda *args, **kwargs: { + "status": "succeeded" if mode == "succeeded" else "failed" + }, + ) + monkeypatch.setattr(runner, "write_execution_report", lambda *args: None) + assert runner.execute_bundle(bundle, execution_output=output) == ( + 0 if mode == "succeeded" else 2 + ) + assert len([call for call in calls if call[0] == "reset"]) == 2 diff --git a/tests/gen_sim/task_engine/test_workflow.py b/tests/gen_sim/task_engine/test_workflow.py index fe216c8bb..f78dfa59d 100644 --- a/tests/gen_sim/task_engine/test_workflow.py +++ b/tests/gen_sim/task_engine/test_workflow.py @@ -190,11 +190,18 @@ def test_unbound_action_can_run_while_user_scene_edit_is_running( start_stage(state, WorkflowStage.SCENE_FINALIZATION) -def test_only_scene_edit_can_be_skipped(tmp_path: Path) -> None: +def test_only_optional_edit_or_unproven_static_check_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"): + with pytest.raises( + ValueError, match="Only scene_edit or unproven static_feasibility" + ): skip_stage(state, WorkflowStage.FINAL_BINDING) + state = skip_stage(state, WorkflowStage.STATIC_FEASIBILITY) + assert state.stages[WorkflowStage.STATIC_FEASIBILITY] == StageStatus.SKIPPED + assert replay_events(state.request, state.events).stages == state.stages def test_state_events_replay_to_the_same_snapshot(tmp_path: Path) -> None: diff --git a/tests/gym/envs/test_demo.py b/tests/gym/envs/test_demo.py index 5b5c3fa5a..1a7ae8885 100644 --- a/tests/gym/envs/test_demo.py +++ b/tests/gym/envs/test_demo.py @@ -142,6 +142,40 @@ def test_demo_execution_cfg_rejects_failed_fragment_policy_in_continuous_mode() DemoExecutionCfg(save_failed_fragments=True) +def test_final_acceptance_rejects_one_row_before_recording_metadata() -> None: + env = _StaggeredVectorEnv() + recorded = [] + env._end_demo_episode_recording = lambda result: recorded.append(result) + + def final_acceptance(result): + assert result.success == (True, True) + assert not recorded + assert env._demo_no_auto_reset + return (True, False) + + result = execute_demo_episode(env, final_acceptance=final_acceptance) + assert result.success == (True, False) + assert result.terminal_reasons == ("success", "final_acceptance_failed") + assert not result.completed + assert recorded == [result] + + +def test_final_acceptance_cannot_promote_failed_program_execution() -> None: + env = _SegmentedEnv() + env.is_task_success = lambda: torch.tensor([False]) + result = execute_demo_episode(env, final_acceptance=lambda program: (True,)) + assert result.success == (False,) + assert not result.completed + + +@pytest.mark.parametrize("mask", [(True,), (True, 1), (True, True, True)]) +def test_final_acceptance_rejects_malformed_masks(mask) -> None: + env = _StaggeredVectorEnv() + with pytest.raises(ValueError, match="final_acceptance"): + execute_demo_episode(env, final_acceptance=lambda result: mask) + assert not env._demo_no_auto_reset + + def _controller_action_env() -> EmbodiedEnv: env = object.__new__(EmbodiedEnv) env._num_envs = 2 diff --git a/tests/lab/test_task_evaluation.py b/tests/lab/test_task_evaluation.py new file mode 100644 index 000000000..9f039cb5b --- /dev/null +++ b/tests/lab/test_task_evaluation.py @@ -0,0 +1,103 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + + +"""CPU checks for the bounded, observed upright TaskSpec evaluator.""" + +from __future__ import annotations + +from copy import deepcopy +import math +import pytest +import torch + +from embodichain.task_spec import semantic_hash +from embodichain.lab.task_evaluation import UprightTaskEvaluator + + +def template(): + value = { + "schema_version": "taskspec/template/v0.1", + "semantic_version": "0.1", + "roles": {"item": {"kind": "object"}}, + "init": [], + "invariants": [], + "requirements": [], + "goal": [ + { + "predicate": "upright", + "object": "item", + "max_tilt": {"value": "0.1", "unit": "rad"}, + } + ], + } + value["semantic_hash"] = semantic_hash(value) + return value + + +def test_final_observation_rechecks_all_rows_independently(): + evaluator = UprightTaskEvaluator(template()) + poses = torch.eye(4, dtype=torch.float64).repeat(2, 1, 1) + poses[1, :3, :3] = torch.tensor( + [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]] + ) + result = evaluator.evaluate(poses, scope="task_goal") + assert result["status"] == ["pass", "failed"] + assert result["tilt_rad"] == pytest.approx([0, math.pi / 2]) + poses[0, :3, :3] = poses[1, :3, :3] + assert evaluator.evaluate(poses, scope="task_goal")["status"] == [ + "failed", + "failed", + ] + + +def test_missing_or_invalid_observation_never_passes_even_under_not(): + value = template() + value["init"] = [{"op": "not", "args": [deepcopy(value["goal"][0])]}] + value["semantic_hash"] = semantic_hash(value) + evaluator = UprightTaskEvaluator(value) + poses = torch.eye(4).repeat(2, 1, 1) + poses[0, 0, 0] = float("nan") + poses[1, :3, :3] = 0 + result = evaluator.evaluate(poses, scope="initial") + assert result["status"] == ["unavailable", "unavailable"] + assert result["tilt_rad"] == [None, None] + + +def test_unobserved_process_conditions_are_rejected_before_execution(): + value = template() + value["invariants"] = deepcopy(value["goal"]) + value["semantic_hash"] = semantic_hash(value) + with pytest.raises(ValueError, match="bounded"): + UprightTaskEvaluator(value) + + +def test_small_float32_tilt_is_not_rounded_to_a_false_pass(): + value = template() + value["goal"][0]["max_tilt"]["value"] = "0.0001" + value["semantic_hash"] = semantic_hash(value) + pose = torch.eye(4).unsqueeze(0) + angle = 0.0002 + pose[0, :3, :3] = torch.tensor( + [ + [1.0, 0.0, 0.0], + [0.0, math.cos(angle), -math.sin(angle)], + [0.0, math.sin(angle), math.cos(angle)], + ] + ) + result = UprightTaskEvaluator(value).evaluate(pose, scope="task_goal") + assert result["status"] == ["failed"] + assert result["tilt_rad"] == pytest.approx([angle]) From d0fed43999d2e73b296567cdbc53647617019051 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 11 Sep 2026 04:12:52 +0000 Subject: [PATCH 4/5] fix(task-program): hold retention motion until evidence is verified --- .../topics/atomic-actions/execution.md | 6 + .../topics/task-programs/execution.md | 8 +- .../lab/sim/atomic_actions/execution.py | 18 ++ embodichain/lab/sim/atomic_actions/runner.py | 1 - .../lab/sim/atomic_actions/verification.py | 16 ++ .../lab/task_program/runtime/executor.py | 17 +- .../task_program/test_semantic_executor.py | 213 +++++++++++++++++- .../sim/atomic_actions/test_engine_per_env.py | 36 +++ 8 files changed, 307 insertions(+), 8 deletions(-) diff --git a/agent_context/topics/atomic-actions/execution.md b/agent_context/topics/atomic-actions/execution.md index b8a5721e8..083d6454f 100644 --- a/agent_context/topics/atomic-actions/execution.md +++ b/agent_context/topics/atomic-actions/execution.md @@ -87,6 +87,12 @@ Held-object guards and phase-effect gates are observational: - gates can hold a named plan segment until evidence proves a transition; and - neither mechanism creates constraints, freezes objects, or overwrites poses. +`HeldObjectGuardResult.pending_mask` can hold the shared command cursor while +an invariant remains unresolved. Pending rows cannot overlap failed rows and +must belong to the active request. The runner issues an observed hold and polls +fresh evidence; unresolved results past the request deadline are rejected. +Omitting the mask preserves the historical loss-only guard contract. + Pick gates attachment before lift. Place gates detachment before retract. HandOver owns independent source/destination transfer boundaries. diff --git a/agent_context/topics/task-programs/execution.md b/agent_context/topics/task-programs/execution.md index 535e29841..1eef34ca3 100644 --- a/agent_context/topics/task-programs/execution.md +++ b/agent_context/topics/task-programs/execution.md @@ -74,8 +74,12 @@ on attachment and guards subsequent held phases; release guards the input hold and gates retreat on detachment. Release gates omit terminal geometric separation, which can only be observed after retreat. Retention guards verified task state without adding a terminal effect or changing symbolic-state ownership. Guard-only -calls validate their declared segment names against each active plan before -dispatch. Projected presets do not install measured protections. +and registered calls validate their declared segment names against each active +plan before dispatch. Guard-only retention requires affirmative current evidence: +unavailable or not-yet-stable evidence holds the command cursor while the existing +consecutive-sample policy accumulates fresh observations. Reaching the action +deadline fails the call and removes the stale held relation. Projected presets +do not install measured protections. GenSim registered Pick and relative Place use these phase declarations. Its configured held-move service opts in with diff --git a/embodichain/lab/sim/atomic_actions/execution.py b/embodichain/lab/sim/atomic_actions/execution.py index dba4e1a64..fdf918f04 100644 --- a/embodichain/lab/sim/atomic_actions/execution.py +++ b/embodichain/lab/sim/atomic_actions/execution.py @@ -826,6 +826,14 @@ def tick( if self._status is not ExecutionStatus.RUNNING: return self._tick_result(command=None, events=events) assert self._plan is not None + if (held_object_guard_result.pending_mask & self._pending).any(): + return self._tick_result( + command=None, + events=events, + hold_targets=tuple( + target.snapshot() for target in self._active_targets.values() + ), + ) if not self._pending.any(): return self._finish_action_tick(self._pending, None, events) @@ -2255,7 +2263,17 @@ def _apply_held_object_guard_result( result.retry_mask, "held_object_guard_result.retry_mask", ) + pending_mask = self._normalize_mask( + result.pending_mask, + "held_object_guard_result.pending_mask", + ) request_mask = request.env_mask.to(self._eligible.device) + if (pending_mask & ~request_mask).any(): + raise ValueError( + "Held-object guard pending_mask must be a subset of request env_mask." + ) + if pending_mask.any() and self._context.robot.timestamp >= request.deadline: + raise ValueError("Held-object guard remains unresolved after its deadline.") if (failure_mask & ~request_mask).any(): raise ValueError( "Held-object guard failure_mask must be a subset of the active " diff --git a/embodichain/lab/sim/atomic_actions/runner.py b/embodichain/lab/sim/atomic_actions/runner.py index 6d7a3f702..d9a6bb39b 100644 --- a/embodichain/lab/sim/atomic_actions/runner.py +++ b/embodichain/lab/sim/atomic_actions/runner.py @@ -630,7 +630,6 @@ def step( if ( held_object_guard_verifier is not None and held_object_guard_request is not None - and context.robot.timestamp <= held_object_guard_request.deadline ): try: held_object_guard_result = held_object_guard_verifier( diff --git a/embodichain/lab/sim/atomic_actions/verification.py b/embodichain/lab/sim/atomic_actions/verification.py index 5a78f53c5..4959b1756 100644 --- a/embodichain/lab/sim/atomic_actions/verification.py +++ b/embodichain/lab/sim/atomic_actions/verification.py @@ -591,6 +591,8 @@ class HeldObjectGuardResult: ``state_invalidation`` may only remove single-resource or coordinated held-object relations. It is applied to ``failure_mask`` before recovery planning, so a retry always observes reconciled symbolic state. + ``pending_mask`` pauses the shared command cursor while evidence is + unresolved. Omission preserves the historical loss-only guard contract. """ verification_id: int @@ -602,6 +604,7 @@ class HeldObjectGuardResult: state_invalidation: StateDelta retry_mask: torch.Tensor message: str = "" + pending_mask: torch.Tensor | None = None def __post_init__(self) -> None: if type(self.verification_id) is not int or self.verification_id < 0: @@ -628,6 +631,19 @@ def __post_init__(self) -> None: raise ValueError("failure_mask and retry_mask must use the same device.") if (self.retry_mask & ~self.failure_mask).any(): raise ValueError("retry_mask must be a subset of failure_mask.") + pending = self.pending_mask + if pending is None: + pending = torch.zeros_like(self.failure_mask) + if not isinstance(pending, torch.Tensor) or pending.dtype != torch.bool: + raise TypeError("pending_mask must be a bool tensor or None.") + if ( + pending.shape != self.failure_mask.shape + or pending.device != self.failure_mask.device + ): + raise ValueError("pending_mask must match failure_mask shape and device.") + if (pending & self.failure_mask).any(): + raise ValueError("pending_mask and failure_mask must not overlap.") + object.__setattr__(self, "pending_mask", pending.clone()) if not isinstance(self.state_invalidation, StateDelta): raise TypeError("state_invalidation must be a StateDelta.") if any( diff --git a/embodichain/lab/task_program/runtime/executor.py b/embodichain/lab/task_program/runtime/executor.py index 75dc16c56..4b90daed8 100644 --- a/embodichain/lab/task_program/runtime/executor.py +++ b/embodichain/lab/task_program/runtime/executor.py @@ -66,6 +66,7 @@ HandOver, Pick, Place, + RegisteredSemanticCall, SemanticCallSpec, ) from embodichain.lab.task_program.semantics.effects import ( @@ -1246,12 +1247,13 @@ def _held_object_guard_verifier( Correlated row-local loss decision, or ``None`` when this named action segment has no held-object invariant. """ - if context.robot.timestamp > request.deadline: - return None grounded = self._require_grounded() guards = grounded.effect_guards + guard_only = grounded.effect_spec is None + if context.robot.timestamp > request.deadline and not guard_only: + return None session = self._require_runner().session - if grounded.effect_spec is None: + if guard_only or type(grounded.analyzed.call) is RegisteredSemanticCall: segment_names = {segment.name for segment in session.active_plan.segments} for guard in guards: missing = set(guard.active_segments) - segment_names @@ -1297,6 +1299,10 @@ def _held_object_guard_verifier( covered.zero_() observed_mask = request.env_mask & covered failure_mask = request.env_mask & ~covered + pending_mask = torch.zeros_like(request.env_mask) + if guard_only and context.robot.timestamp >= request.deadline: + failure_mask = request.env_mask.clone() + observed_mask.zero_() if observed_mask.any(): assert isinstance(candidate, HeldObjectState) verification_id = self._next_guard_verification_id @@ -1326,6 +1332,10 @@ def _held_object_guard_verifier( segment_name=request.segment_name, ) failure_mask |= decision.failure_mask + if guard_only: + pending_mask = observed_mask & ~( + decision.success_mask | decision.failure_mask + ) invalidation = self._held_object_invalidation( guard.invalidation_task_state_keys, failure_mask, @@ -1345,6 +1355,7 @@ def _held_object_guard_verifier( failure_mask=failure_mask, state_invalidation=invalidation, retry_mask=retry_mask, + pending_mask=pending_mask, message=( f"Held-object invariant {guard.guard_id!r} failed during " f"segment {request.segment_name!r}." diff --git a/tests/lab/task_program/test_semantic_executor.py b/tests/lab/task_program/test_semantic_executor.py index e9d647b05..3ba1e532a 100644 --- a/tests/lab/task_program/test_semantic_executor.py +++ b/tests/lab/task_program/test_semantic_executor.py @@ -1665,8 +1665,10 @@ def test_terminal_failure_policy_only_retains_strongly_proven_source_attachment( @pytest.mark.parametrize("segment_declared", (True, False)) +@pytest.mark.parametrize("terminal_effect", (True, False)) def test_in_flight_guard_collects_live_evidence_and_builds_loss_reconciliation( segment_declared: bool, + terminal_effect: bool, ) -> None: system = _system( (EffectMonitorDecision(_mask(True, True), _mask(False, False)),), @@ -1760,9 +1762,11 @@ def ground_with_guard(*args: object, **kwargs: object) -> _Grounded: assert retained is not None assert retained.env_mask.tolist() == [True, True] system.runtime._grounded = SimpleNamespace( - analyzed=SimpleNamespace(effect_monitor_ref=None), + analyzed=SimpleNamespace( + effect_monitor_ref=None, call=_call("registered_release") + ), effect_guards=(guard,), - effect_spec=None, + effect_spec=spec if terminal_effect else None, ) system.runtime._runner = SimpleNamespace( session=SimpleNamespace( @@ -1809,6 +1813,211 @@ def ground_with_guard(*args: object, **kwargs: object) -> _Grounded: assert torch.equal(system.collector.calls[0][2], torch.tensor([0, 1])) +@pytest.mark.parametrize( + "evidence_mode", ("unavailable", "recovered", "lost_after_send") +) +def test_guard_only_motion_waits_for_fresh_evidence_and_invalidates_on_timeout( + monkeypatch: pytest.MonkeyPatch, evidence_mode: str +) -> None: + """Real command frames cannot complete through an unresolved held guard.""" + from embodichain.lab.sim.atomic_actions import ( + EndpointCommand, + JointPositionPayload, + RuntimeCommandFrame, + SkillEndpointRequirement, + SkillResourceSlot, + ) + from embodichain.lab.task_program.semantics.effects import ( + BinaryEffectEvidenceBatch, + CompositeEffectMonitor, + CompositeEffectMonitorCfg, + ) + + monkeypatch.setattr( + _EffectlessAction, + "binding_contract", + SkillBindingContract( + slots=( + SkillResourceSlot( + slot_id="primary", + endpoints=(SkillEndpointRequirement(endpoint_id="motion"),), + ), + ), + ), + ) + monkeypatch.setattr( + _EffectlessAction, "_scene_dependencies", lambda self, request: () + ) + + def plan( + self: _EffectlessAction, + request: ResolvedActionRequest, + context: PlanningContext, + ) -> ActionPlan: + target = request.binding.endpoint("primary", "motion").target + frame = RuntimeCommandFrame( + commands=( + EndpointCommand( + target, JointPositionPayload(torch.ones(BATCH_SIZE, 1)) + ), + ), + active_mask=_mask(True, True), + env_ids=context.env_ids, + hold_duration=torch.zeros(BATCH_SIZE), + ) + return self.build_command_plan( + request, + context, + success=True, + commands=TimedCommandSequence((frame, frame, frame), context.env_ids), + segment_lengths={"carry": 3}, + replannable=False, + ) + + monkeypatch.setattr(_EffectlessAction, "_plan", plan) + system = _system( + (EffectMonitorDecision(_mask(True, True), _mask(False, False)),), + effectless_action=True, + install_effect_monitor=False, + effect_assurance=EffectAssurance.VERIFIED, + ) + binding = ActionBinding( + owner_id=system.engine.binding_owner_id, + endpoints=( + EndpointBinding( + slot_id="primary", + endpoint_id="motion", + resource_id="arm", + adapter_id="test", + target=JointPositionTarget("virtual", (0,)), + task_state_key="arm", + joint_ids=(0,), + ), + ), + ) + monkeypatch.setattr( + system.engine, "bind_control_parts", lambda *args, **kwargs: binding + ) + original_ground = system.compiler.ground + + def ground(*args: object, **kwargs: object) -> _Grounded: + grounded = original_ground(*args, **kwargs) + invocation = replace( + grounded.invocation, + recovery_policy=RecoveryPolicy( + max_action_retries=0, + max_replans=0, + action_timeout=8.0, + ), + ) + spec = SemanticEffectSpec( + semantic_id=grounded.analyzed.call.semantic_id, + effect_kind=SemanticEffectKind.ATTACH, + skill_id=invocation.skill_id, + invocation_id=invocation.invocation_id, + invocation_revision=0, + env_ids=torch.arange(BATCH_SIZE), + state_expectations=( + HeldObjectStateExpectation( + expectation_id="source", + relation=HeldObjectRelation.ATTACHED, + object_id="cube", + slot_id="primary", + resource_id="arm", + task_state_key="arm", + ), + ), + clauses=( + BinaryEffectClause( + clause_id="source.constraint", + expectation_id="source", + source=EffectEvidenceSourceRef( + "test.provider", + "1", + ControlPartEvidenceAddress("virtual", "constraint"), + ), + evidence_kind=BinaryEvidenceKind.CONSTRAINT, + expected=True, + ), + ), + ) + guard = GroundedHeldObjectGuard( + guard_id="source_attached", + active_segments=("carry",), + baseline=HeldObjectGuardBaseline.VERIFIED_TASK_STATE, + effect_spec=spec, + effect_monitor=CompositeEffectMonitor( + spec, CompositeEffectMonitorCfg(consecutive_samples=2) + ), + invalidation_task_state_keys=("arm",), + retry_action=False, + ) + return replace(grounded, invocation=invocation, effect_guards=(guard,)) + + monkeypatch.setattr(system.compiler, "ground", ground) + observations: list[tuple[bool, int]] = [] + + def collect( + spec: SemanticEffectSpec, + *, + timestamp: float, + observation_revision: int, + env_ids: torch.Tensor, + ) -> dict[str, EffectEvidenceBatch]: + valid = evidence_mode == "recovered" and bool(observations) + if evidence_mode == "lost_after_send": + valid = system.sink.sent == 0 + observations.append((valid, system.sink.sent)) + return { + "source.constraint": BinaryEffectEvidenceBatch( + evidence_id="source.constraint", + evidence_kind=BinaryEvidenceKind.CONSTRAINT, + values=torch.ones(BATCH_SIZE, dtype=torch.bool), + valid=torch.full((BATCH_SIZE,), valid), + acquisition_errors=( + (None, None) if valid else ("unavailable", "unavailable") + ), + timestamp=timestamp, + env_ids=env_ids, + observation_revision=observation_revision, + ) + } + + monkeypatch.setattr(system.collector, "collect", collect) + poses = torch.eye(4).repeat(BATCH_SIZE, 1, 1) + held = HeldObjectState( + semantics=ObjectSemantics( + affordance=Affordance(), geometry={}, entity_id="cube" + ), + object_to_eef=poses, + grasp_xpos=poses, + env_mask=_mask(True, True), + ) + system.runtime.adopt_verified_task_state( + TaskState( + batch_size=BATCH_SIZE, + device="cpu", + held_objects={"arm": held}, + ) + ) + result = system.runtime.run(_call("guard_only_motion")) + if evidence_mode == "recovered": + assert result.status is SemanticExecutionStatus.COMPLETED + assert system.sink.sent == 3 + assert observations[:3] == [(False, 0), (True, 0), (True, 0)] + assert result.task_state.get_held_object("arm").env_mask.tolist() == [ + True, + True, + ] + else: + assert result.status is SemanticExecutionStatus.FAILED + assert system.sink.sent == (1 if evidence_mode == "lost_after_send" else 0) + assert system.sink.cancelled == 1 + remaining = result.task_state.get_held_object("arm") + assert remaining is None or not remaining.env_mask.any() + assert system.sink.held > 0 + + def test_phase_effect_gate_uses_independent_monitor_and_records_boundary_trace() -> ( None ): diff --git a/tests/sim/atomic_actions/test_engine_per_env.py b/tests/sim/atomic_actions/test_engine_per_env.py index 8e50bf223..2d8fda644 100644 --- a/tests/sim/atomic_actions/test_engine_per_env.py +++ b/tests/sim/atomic_actions/test_engine_per_env.py @@ -1141,6 +1141,42 @@ def test_stale_phase_effect_gate_result_is_rejected_after_unresolved_poll() -> N ) +def test_unresolved_held_guard_holds_command_cursor_until_verified() -> None: + engine, _ = _engine() + initial = _with_held_object(_context(0.0, 0.0, 0.2, 0)) + session = engine.start((_invocation(engine),), initial) + request = session.held_object_guard_request + assert request is not None + unresolved = HeldObjectGuardResult( + verification_id=request.verification_id, + object_id="object", + attempt_generation=request.attempt_generation, + invocation_index=request.invocation_index, + next_waypoint_index=request.next_waypoint_index, + failure_mask=torch.tensor([False]), + retry_mask=torch.tensor([False]), + state_invalidation=StateDelta(), + pending_mask=torch.tensor([True]), + ) + tick = session.tick(initial, held_object_guard_result=unresolved) + assert tick.command is None + assert tick.hold_targets + next_request = session.held_object_guard_request + assert next_request is not None + assert next_request.next_waypoint_index == 0 + assert next_request.verification_id != request.verification_id + resumed = session.tick( + _with_held_object(_context(0.1, 0.0, 0.2, 0)), + held_object_guard_result=replace( + unresolved, + verification_id=next_request.verification_id, + pending_mask=torch.tensor([False]), + ), + ) + assert resumed.command is not None + assert session.held_object_guard_request.next_waypoint_index == 1 + + def test_held_object_loss_retries_only_failed_row_with_reconciled_state() -> None: engine, _ = _engine(batch_size=2) initial = _with_held_object( From ac32028ef804e4287ff82c4e70b486a3818fe6ea Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 11 Sep 2026 04:13:36 +0000 Subject: [PATCH 5/5] docs(gen-sim): clarify measured witness qualification gate --- embodichain/gen_sim/task_engine/_bundle_runner.py | 5 +++-- embodichain/gen_sim/task_engine/task_program_bundle.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/embodichain/gen_sim/task_engine/_bundle_runner.py b/embodichain/gen_sim/task_engine/_bundle_runner.py index 0ef3dbcf8..d22306f62 100644 --- a/embodichain/gen_sim/task_engine/_bundle_runner.py +++ b/embodichain/gen_sim/task_engine/_bundle_runner.py @@ -82,8 +82,9 @@ def execute_bundle( graph = validate_semantic_task_graph(_read_json(graph_path)) if "task_spec" in graph: raise ValueError( - "TaskSpec execution requires final task evaluation before data " - "submission; that runtime integration is not yet available." + "TaskSpec v2 execution requires final task evaluation bound to " + "qualified measured instance/witness evidence; that v2 " + "qualification is not yet available." ) output.mkdir(parents=True, exist_ok=True) fingerprint = _read_json(fingerprint_path) diff --git a/embodichain/gen_sim/task_engine/task_program_bundle.py b/embodichain/gen_sim/task_engine/task_program_bundle.py index 9d611e831..87628bdc1 100644 --- a/embodichain/gen_sim/task_engine/task_program_bundle.py +++ b/embodichain/gen_sim/task_engine/task_program_bundle.py @@ -155,8 +155,9 @@ def generate_task_program_bundle( task_binding = binding_for_graph(task_template, selected_graph) if "task_spec" in selected_graph: raise ValueError( - "TaskSpec bundle export requires final task evaluation before data " - "submission; that runtime integration is not yet available." + "TaskSpec v2 bundle export requires final task evaluation bound to " + "qualified measured instance/witness evidence; that v2 " + "qualification is not yet available." ) unsupported = sorted( {node["task_type"] for node in selected_graph["nodes"]}