From b8b96cd60bff4eb4be7902b7d9d3d47596037e08 Mon Sep 17 00:00:00 2001 From: DVIDIA Date: Tue, 10 Nov 2026 01:30:39 -0500 Subject: [PATCH 1/2] Add local Godot demonstration capture and reviewed action export --- .github/workflows/ci.yml | 4 + README.md | 9 + benchmarks/godot-demonstration-v02/README.md | 77 + .../capture-summary.json | 356 +++ .../fresh-install.json | 25 + .../native-replay.json | 1 + benchmarks/godot-demonstration-v02/tests.json | 26 + .../windowed-client.json | 2727 +++++++++++++++++ .../godot-demonstration-v02/workstation.png | Bin 0 -> 146801 bytes docs/godot-demonstration-v02.md | 132 + integrations/godot-demonstration/.gitignore | 1 + integrations/godot-demonstration/README.md | 57 + integrations/godot-demonstration/main.gd | 949 ++++++ integrations/godot-demonstration/main.tscn | 6 + .../godot-demonstration/project.godot | 23 + .../tests/test_coordinates.gd | 31 + .../tests/test_recording.gd | 60 + pyproject.toml | 2 + scripts/check-operator-lifecycle.py | 57 + scripts/verify-operator-replay.py | 84 + .../godot_demonstration_bridge.py | 346 +++ src/dvidia_training/operator_capture.py | 749 +++++ src/dvidia_training/operator_export.py | 226 ++ tests/test_godot_demonstration_bridge.py | 125 + tests/test_operator_capture.py | 298 ++ tests/test_operator_export.py | 130 + 26 files changed, 6501 insertions(+) create mode 100644 benchmarks/godot-demonstration-v02/README.md create mode 100644 benchmarks/godot-demonstration-v02/capture-summary.json create mode 100644 benchmarks/godot-demonstration-v02/fresh-install.json create mode 100644 benchmarks/godot-demonstration-v02/native-replay.json create mode 100644 benchmarks/godot-demonstration-v02/tests.json create mode 100644 benchmarks/godot-demonstration-v02/windowed-client.json create mode 100644 benchmarks/godot-demonstration-v02/workstation.png create mode 100644 docs/godot-demonstration-v02.md create mode 100644 integrations/godot-demonstration/.gitignore create mode 100644 integrations/godot-demonstration/README.md create mode 100644 integrations/godot-demonstration/main.gd create mode 100644 integrations/godot-demonstration/main.tscn create mode 100644 integrations/godot-demonstration/project.godot create mode 100644 integrations/godot-demonstration/tests/test_coordinates.gd create mode 100644 integrations/godot-demonstration/tests/test_recording.gd create mode 100644 scripts/check-operator-lifecycle.py create mode 100644 scripts/verify-operator-replay.py create mode 100644 src/dvidia_training/godot_demonstration_bridge.py create mode 100644 src/dvidia_training/operator_capture.py create mode 100644 src/dvidia_training/operator_export.py create mode 100644 tests/test_godot_demonstration_bridge.py create mode 100644 tests/test_operator_capture.py create mode 100644 tests/test_operator_export.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43c96c7..3bf5dbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,8 @@ jobs: dvidia-training-benchmark --help dvidia-skillspace-coverage --help dvidia-episode-tape --help + dvidia-demonstrate --help + dvidia-demonstration-export --help dvidia-reaction --help dvidia-motor-memory --help dvidia-observe --help @@ -80,6 +82,8 @@ jobs: dvidia-training-doctor --json - name: Verify pinned learning caches run: python -m unittest tests.test_camera_action_cache tests.test_camera_cache_cli tests.test_camera_action_export tests.test_camera_action_model -v + - name: Verify operator capture, recovery and reviewed exports without physics + run: python -m unittest tests.test_operator_capture tests.test_operator_export tests.test_godot_demonstration_bridge -v - name: Verify exact-reference and initial-head contracts without physics run: | python -B -m unittest tests.test_camera_action_reference_export -v diff --git a/README.md b/README.md index f027851..1a322d6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ [Recorded demo](https://huggingface.co/spaces/Dvidia/dvidia-training) · [Synthetic datasets](https://huggingface.co/datasets/Dvidia/dvidia-training-examples) · [Pilot models](https://huggingface.co/Dvidia/dvidia-training-pilot) +**[Record paired demonstrations in Godot](docs/godot-demonstration-v02.md).** +Control the simulated PiPER's position and gripper, record synchronized native +RGB-D and issued actions, then export explicitly reviewed segments in Terminal. +Interrupted attempts and failed outcomes remain inspectable. A scripted windowed +check reproduced all **10,200 native scalar values exactly** in independent replay. +This is a capture/export prototype; human demonstrations, new policy training and +real-time teleoperation qualification remain ahead. +[Evidence and timing limits](benchmarks/godot-demonstration-v02/README.md). + **[Verified local learning cache](docs/camera-action-export-cache-v01.md).** The **0.1.3.dev5 preview** adds `dvidia-train cache` to reuse verified camera/action features with caller-pinned data, split and source identities. On one 1,000-sample diff --git a/benchmarks/godot-demonstration-v02/README.md b/benchmarks/godot-demonstration-v02/README.md new file mode 100644 index 0000000..9d5e137 --- /dev/null +++ b/benchmarks/godot-demonstration-v02/README.md @@ -0,0 +1,77 @@ +# Godot demonstration capture: integration evidence + +This is a scripted recording/export check, not a manipulation benchmark or a +new learned skill. The final recording contains ten 50 Hz command intervals, +six 320x240 RGB-D pairs, and 200 native 1 ms ticks. It does not attempt a complete +placement; the independent task scorer correctly returns failure, with no +critical contact ticks. No human demonstrations or model fits were performed. + +## Results + +| Check | Result | +|---|---| +| Python regression tests, including existing capture/export contracts | 96 test executions passed | +| Godot coordinate and recording-contract assertions | 7 + 29 passed | +| Actual windowed Godot: Begin, ten steps, Finish | Passed; zero client errors | +| Independent native replay | All 10,200 scalar values exactly matched; maximum difference 0 | +| Independently reconstructed task score | Matched; task unsuccessful | +| Fresh installation outside checkout | Both CLI entry points work; installed source hashes match | +| Reviewed direct-action export and reload | 10 rows x 269 causal features, 10 x 7 issued target labels | +| Learner inputs | Existing feature contract; no teacher calls or native object/contact truth | + +Fault-injection tests cover interrupted intent/outcome writes, fsync before +in-memory journal cursor updates, and truncated trailing records. Finalization +reconstructs the committed prefix before sealing. A truncated journal remains +unsealed and recoverable. Pause/resume/reset/disconnect were also exercised over +real localhost TCP on an earlier source revision; those retained development +captures are not presented as evidence pinned to the final recorder source. +Their final-source behavior remains covered by the Python regressions above. + +## Timing scope + +Final windowed capture: **0.2 simulated seconds in 0.406678 wall seconds** after +the initial handshake. The server reports 201.570 ms aggregate command handling, +including controller, camera/perception, file writing and finalization. Twelve +RPC samples include Begin and Finish; their p95 is 53.432 ms. This small sample +does not establish a tail-latency bound, sustained throughput or 50 Hz real-time +operation. Physics, wall-clock delivery and GUI frame rate are different metrics. + +Host: Apple M5, 10 logical processors, macOS 26.5.2 arm64, Python 3.12.9, NumPy 2.5.3, +MuJoCo 3.15.0, Godot 4.7.2. The standalone viewer uses the compatibility OpenGL +renderer. No GPU-training speed, RAM minimum or physical reaction latency was +measured. Godot headless assertions passed with sandbox log/certificate-access +warnings; the actual windowed run completed without client errors. + +Earlier development windowed receipts measured 0.511076 and 0.595010 seconds +for the same short simulation duration, on earlier recorder/UI revisions. They +remain local alongside the initial graphics-sandbox refusal and a development +capture rejected after the source-identity contract changed. Do not infer a +controlled speedup from these development runs. + +## Evidence and reproduction + +- `tests.json` pins tested source and reports the regression executions. +- `windowed-client.json` retains the scripted request/state trace and client + source hashes. Its local output directory is replaced with a portable label; + other values match the original receipt. +- `native-replay.json` records independently executed native array comparisons. +- `capture-summary.json` retains the independent task score, source identities + and exact original capture inventory. The synthetic pixel/native capture is + retained locally; it is not bundled in this small source/evidence publication. +- `fresh-install.json` records installed source hashes and exported array shapes. +- `workstation.png` is the real final window screenshot, not a design mockup. + +Follow [the capture guide](../../docs/godot-demonstration-v02.md). To reproduce +the short engineering check, prepare with `--duration 0.2`, serve with +`--source-kind simulation_scripted_input --max-episodes 1`, then run Godot with +`--smoke-steps 10 --smoke-output /absolute/fresh/receipt.json` and an optional +`--screenshot /absolute/fresh/workstation.png`. Run the native replay script on +the resulting episode. Use fresh directories; failed attempts are preserved. + +MIT applies to the added DVIDIA source. Upstream arm notices remain under +`third_party/robot-models` and in prepared scene exports. MuJoCo is an external +Apache-2.0 dependency. No credentials, real footage or model weights are included. + +Next: collect actual operator demonstrations, review useful recovery segments, +and register a separate learning comparison on unseen starts. The existing +5/64 learned placement result and its unqualified status remain unchanged. diff --git a/benchmarks/godot-demonstration-v02/capture-summary.json b/benchmarks/godot-demonstration-v02/capture-summary.json new file mode 100644 index 0000000..e2a0a24 --- /dev/null +++ b/benchmarks/godot-demonstration-v02/capture-summary.json @@ -0,0 +1,356 @@ +{ + "schema": "dvidia.operator-integration-evidence/v1", + "source_kind": "simulation_scripted_input", + "status": "complete", + "control_intervals": 10, + "camera_pairs": 6, + "frame_bytes": 2003397, + "strict_success": false, + "score": { + "evidence_scope": "Recomputed simulator-authored numeric evidence; no physics rerun, source attestation or hardware sensing.", + "false_alarms": { + "reason": "Continuous residual policy has no declared discrete alarm events.", + "status": "not_applicable" + }, + "grip_events": [], + "grip_summary": { + "airborne_events": 0, + "loss_events": 0, + "qualification_latency_s": [], + "restored_events": 0, + "supported_events": 0, + "unresolved_events": 0 + }, + "native_diagnostics": { + "premature_open_jaw": { + "count": 0, + "intervals": [], + "maximum_s": 0.0, + "total_s": 0.0 + }, + "support_touchdown_before_destination": { + "count": 0, + "intervals": [], + "maximum_s": 0.0, + "total_s": 0.0 + } + }, + "native_metrics_mismatches": [], + "native_recomputed": { + "carry_complete": false, + "carry_contact_loss_events": 0, + "dwell_seconds": 0.0, + "first_success_time_seconds": null, + "grasp_seen": false, + "lift_dwell_seconds": 0.0, + "lift_seen": false, + "max_carry_contact_loss_seconds": 0.0, + "max_object_lift_m": 6.229478044517123e-11, + "max_pad_normal_force_n": 0.0, + "retention_lost": false, + "success": false, + "unexpected_contact_native_ticks": 0 + }, + "reason": "native_outcome_failed", + "restoration_scope": "Loaded contact restored for 100 ms after sustained loss; does not establish task recovery or airborne regrasp.", + "schema": "dvidia.piper-feedback-score/v1", + "strict_v01": { + "native_metrics_source": "simulator-authored episode record; not authenticated sensor input", + "native_outcome_success": false, + "native_outcome_valid": true, + "reason": "native_outcome_failed", + "reference_xy_m": [ + 0.35, + 0.08 + ], + "required_dwell_s": 0.25, + "required_lift_dwell_s": 0.1, + "sample_interval_s": 0.02, + "success": false, + "terminal_dwell_s": 0.0, + "terminal_reference_success": false, + "terminal_xy_error_m": 0.12083027970892407, + "trace_lift_dwell_s": 0.0, + "trajectory_complete": true + }, + "success": false, + "terminal_only": { + "dwell_s": 0.0, + "scope": "Terminal-only geometry/release/settling/dwell; excludes lift, carry and retention history.", + "success": false, + "terminal_xy_error_m": 0.12083027970892407 + }, + "valid": true + }, + "teacher_queries": 0, + "human_demonstrations": 0, + "training_runs": 0, + "physical_robot_ready": false, + "source_sha256": { + "action_demonstration.py": "439c7cce558571221660cd51e7dba1675203237706ea8ea0f97f2fcf9a6a24c5", + "camera_action_capture.py": "399f512a08087c72bfa37e382157a2250f413e423974b77b1bd3c0a911ff0a1e", + "operator_capture.py": "aa82570a0d1126af4c903713fc29676f0a27ef133677cc2db673c72677171833", + "piper_action_capture.py": "da037b2198cf7d2f3a9aa40fc19a4c0f5cc375a22c5dd75606e2c1856f6efc7f", + "piper_encoder_kinematics.py": "6606bb8aad04e5abb1e9b6d1a8ec628a4114acd167862167184fd4eef1d21c34", + "piper_feedback_env.py": "0dbae423231968a81bdeed52226d8dffaf8df622013ed3a360d7217148f1bcac", + "piper_feedback_score.py": "863a272828208c631de4cf57647e973eb3e4f5e99eb7a1eacb4eaa68dc42eb0e", + "piper_imitation_score.py": "0cd4663abfdd7f14f2f33d93e9a2bd45122b998d6160d45b8afcc880512cba2b", + "piper_rgbd_tracker.py": "43202cfcea404061a3a65124d6ad24aeeec5fae8ffaf863caa245f2f54c46c36", + "piper_task.py": "e66a44a8df4e7a8ee42e4f3866f889f8797cdfb8e2688a146e09b75d2c76664f" + }, + "original_capture_inventory": { + "capture.json": { + "bytes": 3939, + "sha256": "fc38a3400aad363c50766861d6a99ea86b18c4097c172e2537fa3cb83a849ed3" + }, + "commands.jsonl": { + "bytes": 10022, + "sha256": "8142c96e50b2f16d44af39e5968817a136b44d9e6faf1172c58abe59f6982bc7" + }, + "context.json": { + "bytes": 11970, + "sha256": "45d91c1413cf4422621c6f369fdf07e38af0a8a464bda81f6aca86d05d8abe32" + }, + "decisions/0000.json": { + "bytes": 1178, + "sha256": "10ce60595f11035d7775f8184bd48fca3c1e292f99e180a2ca4491aefe5954dd" + }, + "decisions/0001.json": { + "bytes": 1190, + "sha256": "9d0356f275f2df9fa851d4a0863cebdb59a831f62b3dcf5762004160cef87d3d" + }, + "decisions/0002.json": { + "bytes": 1186, + "sha256": "c6f10250bcb8f65b33d1c4160caf7184b0d4bdf2d2b5c87d94436e59d253d616" + }, + "decisions/0003.json": { + "bytes": 1183, + "sha256": "1e5b7a6f54310697d6948e3dcbf3614d0a26001a9176477b9e0bb04833578dca" + }, + "decisions/0004.json": { + "bytes": 1176, + "sha256": "06cd807edeb20ae775d11fc8eed0449620168cb554321db7854fee0c75910f11" + }, + "decisions/0005.json": { + "bytes": 1172, + "sha256": "88ac0fe79b1ba29e10c75d792e8f4e18a440d8aaa4fa8eec60605c3df4943806" + }, + "decisions/0006.json": { + "bytes": 1178, + "sha256": "8cd7e27fe5ec1c1e925199a1a5fa4e389460b1ecb01c4be5fb7f9909fec07d39" + }, + "decisions/0007.json": { + "bytes": 1175, + "sha256": "40f9be55e1ae949ba05f553cef601676844bfe16edd164964b7398c82f552401" + }, + "decisions/0008.json": { + "bytes": 1177, + "sha256": "843fe1fe49553089da75b07f0e681500f768fd618e976f6ee3d9b239cbf6a453" + }, + "decisions/0009.json": { + "bytes": 1175, + "sha256": "1ace428bc5f7348f3e9f0209c0388e5e3804e07c375b98199cad7aa7e5e38b78" + }, + "frames/depth-0000.npy": { + "bytes": 307328, + "sha256": "d982b222370893603aefedd2f5dd1c04a3a1d54f623be6c1b17eb91277895815" + }, + "frames/depth-0001.npy": { + "bytes": 307328, + "sha256": "b8e284558df274f137bfca1919eac1fb2a31e63dd6483ce4c8e9beb6a1fd4425" + }, + "frames/depth-0002.npy": { + "bytes": 307328, + "sha256": "2e0684d6b0b31187b72ed5f2f4430dee8537995649020b935443f4936a191d5f" + }, + "frames/depth-0003.npy": { + "bytes": 307328, + "sha256": "310dcc0fa858d13b3c4e4e92758f6990632d26fee777e3ba6f4880eb91688fa6" + }, + "frames/depth-0004.npy": { + "bytes": 307328, + "sha256": "67c750b90803ce24cff8ece6c815c799a4388ead2122c79f3e0f3e059277e1d3" + }, + "frames/depth-0005.npy": { + "bytes": 307328, + "sha256": "314838d99eee0b4dd3acb0aa3f6818eab9d24d748574d03208bfa2af35dfc137" + }, + "frames/rgb-0000.png": { + "bytes": 26744, + "sha256": "8ff2bb7e5acf584a0cbbaa5f58ee2b8ad213c7c66bc12d13d19cae6e0ee4a218" + }, + "frames/rgb-0001.png": { + "bytes": 26815, + "sha256": "7e33a413d35cb18c14aac35e4b314f9bdce8efb332cdcd3dbc41ea32107a817b" + }, + "frames/rgb-0002.png": { + "bytes": 26640, + "sha256": "128502d03fa8cf915deccebb15ced0921a76b1811f5034c1a73bd53fd2f4a855" + }, + "frames/rgb-0003.png": { + "bytes": 26603, + "sha256": "763e7f679d5b110c81852e5e03c7f06048565e3813d8651fc1c751394f94e866" + }, + "frames/rgb-0004.png": { + "bytes": 26407, + "sha256": "a7320e0a952f195d6421c4f46afd06ba0edf18950b930a440fd2f09adb4a2678" + }, + "frames/rgb-0005.png": { + "bytes": 26220, + "sha256": "0ae34fcd4c628985da159e8ad2d0a87329b15ce0374d518e18b161304fdfb7ef" + }, + "integrity.json": { + "bytes": 7059, + "sha256": "dcb6692a98487ba0d813dc451a22ee723d7a964f718272b94eb74f3c5b40d127" + }, + "native/0000.json": { + "bytes": 3060, + "sha256": "7dca80baba62ddd30a717527f99efd725d66970ba3d678189133f58a924aff0e" + }, + "native/0001.json": { + "bytes": 3344, + "sha256": "39379363829d0220ec73325b1b50fb693f98276a31867253acb509fcd69a3740" + }, + "native/0002.json": { + "bytes": 3338, + "sha256": "2b52f8ee938db4e30950abdaab3650618040bb4cc2e6c1753c6eaefc5eb7d7e0" + }, + "native/0003.json": { + "bytes": 3329, + "sha256": "0ad29bebdbeb1190e0ead9425e83b25989bd9111ef87320aec40ba6d444111a3" + }, + "native/0004.json": { + "bytes": 3338, + "sha256": "dfb93351971d06416763a6883c1871f375aa87110a93070d1911465f806c7d57" + }, + "native/0005.json": { + "bytes": 3305, + "sha256": "81b1b51e932ce8037c43137c4a9fb06fec1a4abe4f5eac3e8cb60c322b90ed45" + }, + "native/0006.json": { + "bytes": 3314, + "sha256": "b1f22c64fd627cce592a4526436bf41dd4af98203a333885730b2fc52765c22f" + }, + "native/0007.json": { + "bytes": 3303, + "sha256": "02f66419369214e456a6dea98f70324b36f0e170ecff277385f275e9229c4086" + }, + "native/0008.json": { + "bytes": 3324, + "sha256": "df35201fa3b6e2d733171acbcc426245b0d239dbef3a362e762f7d485bc1844a" + }, + "native/0009.json": { + "bytes": 3309, + "sha256": "e94cf82c68bdd771063620c11b38a9f3372b460c4f563f8b92086c38b23f0654" + }, + "native/0010.json": { + "bytes": 3315, + "sha256": "091ea5b1ef0e756a2c58e61893819b414cb5c405446b809e5d04997eac8c2ec7" + }, + "native.npz": { + "bytes": 52639, + "sha256": "9c6418c886c5f7cfab93dacbda42a032a84e023182f265551b73b54bc6674e14" + }, + "native_episode.json": { + "bytes": 35430, + "sha256": "23b63a45bbfb1f1900301be0e2b9883d875f2259fd0750148c44aed6e066b88a" + }, + "native_metadata.json": { + "bytes": 7953, + "sha256": "9ec23009b8b2322d77c898872bd2172ba107c04e28aeb0c11700c2bba07f9a3c" + }, + "perception/0000.json": { + "bytes": 2683, + "sha256": "39a4b66ff1ffa57d2e64cd587392529de7343bd22341233ee77a81e923c7a10c" + }, + "perception/0001.json": { + "bytes": 2779, + "sha256": "63dac1953d549e686dfa3f9649f804b16e7234a1e17e1a9f6df3729450c8a2f5" + }, + "perception/0002.json": { + "bytes": 2893, + "sha256": "cca914a804089cd1fb1a46340be20abffa705cc3d67dfa7971c6cd1e120b7b53" + }, + "perception/0003.json": { + "bytes": 2979, + "sha256": "f1d7a5b4b9c32b98de58fc0afd7e294955d8f8f0a78fab3d8e170f45896900c7" + }, + "perception/0004.json": { + "bytes": 2892, + "sha256": "38b3d9cd183d14b67bfa7df8bd84c640231f82f67262c6efee83f869ee29093f" + }, + "perception/0005.json": { + "bytes": 2977, + "sha256": "62d55732ce68ec33b1ca6f57f33d76120f5762a0ce91e15a61eba30bcc4938d7" + }, + "perception/0006.json": { + "bytes": 2892, + "sha256": "9e7fb3838096edeafa166e59a2c7fc295bc15ffa1cee4690160304819a183a13" + }, + "perception/0007.json": { + "bytes": 2976, + "sha256": "8179c95c4bf286bd045c7f3ee12939197b6117046f95c5c6655246fe1e837ca2" + }, + "perception/0008.json": { + "bytes": 2892, + "sha256": "bb83ca63333edddd769bd5168527167f5a4a5d20d0757a951d687db02a991ef9" + }, + "perception/0009.json": { + "bytes": 2977, + "sha256": "4ec1b68ac1bda5cc5fc32029b0a46d0cc836a881faa037f95fae11d0cccb207d" + }, + "perception/0010.json": { + "bytes": 2142, + "sha256": "e47c30dace4f1386f6d191865a39a68e3e878dd7a410ed2e2f691d73aa5d0c54" + }, + "recording.json": { + "bytes": 75741, + "sha256": "aa01f6901cbad1d7b850ec2587329f7de4b378f8f9fc49901df179396f1f0351" + }, + "started.json": { + "bytes": 608, + "sha256": "ecbccf894dc2fe56bf363e5ecc68a87c0a895b8aa66eaa352e6d16e8e2db2806" + }, + "transitions/0000.json": { + "bytes": 6484, + "sha256": "0aa4b1c72d0040d0c98324b58be1436cf09999cca994900514abd67cf74126b0" + }, + "transitions/0001.json": { + "bytes": 6823, + "sha256": "b1eff2fd05a357fbd99bf4acde5c2f2b20734938978c2256283e5097aed084bc" + }, + "transitions/0002.json": { + "bytes": 6889, + "sha256": "0da2a69d6ecc6787bafdf5f5ab2ba3bcaa61c635d0d67902dc3a097dfaa25f9c" + }, + "transitions/0003.json": { + "bytes": 6880, + "sha256": "dbbcdd08eb771ea1e52e19c2326fb04e23f286b3b4bc9161ff43acc7a0b6aa73" + }, + "transitions/0004.json": { + "bytes": 6878, + "sha256": "bac2304735fcb15ee72441e268c9e75809a2a82c114d93f56938746471c8f555" + }, + "transitions/0005.json": { + "bytes": 6848, + "sha256": "1ae6a693701754a18fdcd917009ba866a70c8f7cbd7cd69a6b4356bc726efcb8" + }, + "transitions/0006.json": { + "bytes": 6838, + "sha256": "b9a84987c02d3626c7780892a93d41f9618394a28bc5c799d4fe01dafa135e5e" + }, + "transitions/0007.json": { + "bytes": 6843, + "sha256": "63587c8e0feb02d77923246b6742702ee9ba80b2ac2056b35807d4cb429d6ea7" + }, + "transitions/0008.json": { + "bytes": 6856, + "sha256": "217d758a528535064abb176b3f23caef4d0c3daa285ca6bda9f250927799e3b7" + }, + "transitions/0009.json": { + "bytes": 6850, + "sha256": "6a932fd96f72f274cf4e1c621228bb7b074fc4ba4464e6a230a63bd965f66c36" + } + }, + "scope": "One 0.2 simulated second scripted integration capture. Original pixel/native recording retained locally; inventory binds its bytes. Reproduction scripts supplied. No learned skill or real-time qualification." +} diff --git a/benchmarks/godot-demonstration-v02/fresh-install.json b/benchmarks/godot-demonstration-v02/fresh-install.json new file mode 100644 index 0000000..f81f063 --- /dev/null +++ b/benchmarks/godot-demonstration-v02/fresh-install.json @@ -0,0 +1,25 @@ +{ + "schema": "dvidia.operator-fresh-install/v1", + "installed_source_matches": true, + "source_sha256": { + "godot_demonstration_bridge.py": "6ad0ef1b74b1b07748a02c5367f3bde48e4b3ea77950c4ecdf6e7e30b809e901", + "operator_capture.py": "aa82570a0d1126af4c903713fc29676f0a27ef133677cc2db673c72677171833", + "operator_export.py": "9ff7ac7eeae179df340b0035a982c1672e01bd51d56c1e2dbdfa2e5b0a5455a7", + "camera_action_model.py": "e9a9c4d1d0f16fc7c91cac3ca2e6560d0532370ab78a2af5a4ec84425c23932f" + }, + "feature_shape": [ + 10, + 269 + ], + "target_shape": [ + 10, + 7 + ], + "source_kind": "simulation_scripted_input", + "source_group_count": 1, + "python": "3.12.9", + "numpy": "2.5.3", + "cwd_outside_checkout": true, + "training_started": false, + "physical_robot_ready": false +} diff --git a/benchmarks/godot-demonstration-v02/native-replay.json b/benchmarks/godot-demonstration-v02/native-replay.json new file mode 100644 index 0000000..647e742 --- /dev/null +++ b/benchmarks/godot-demonstration-v02/native-replay.json @@ -0,0 +1 @@ +{"capture_integrity_sha256":"dcb6692a98487ba0d813dc451a22ee723d7a964f718272b94eb74f3c5b40d127","control_intervals":10,"exact_native_arrays":true,"fields":{"applied_jaw_target_m":{"exact":true,"max_abs_error":0.0,"shape":[200]},"arm_actuator_controls_rad":{"exact":true,"max_abs_error":0.0,"shape":[200,6]},"grasp_contact_counts":{"exact":true,"max_abs_error":0.0,"shape":[200,2]},"gripper_joint_position_m":{"exact":true,"max_abs_error":0.0,"shape":[200,2]},"joint_position_rad":{"exact":true,"max_abs_error":0.0,"shape":[200,6]},"native_tick":{"exact":true,"max_abs_error":0.0,"shape":[200]},"normal_rate_limited_jaw_target_m":{"exact":true,"max_abs_error":0.0,"shape":[200]},"object_angular_velocity_rad_s":{"exact":true,"max_abs_error":0.0,"shape":[200,3]},"object_linear_velocity_m_s":{"exact":true,"max_abs_error":0.0,"shape":[200,3]},"object_minus_tcp_local_m":{"exact":true,"max_abs_error":0.0,"shape":[200,3]},"object_position_m":{"exact":true,"max_abs_error":0.0,"shape":[200,3]},"object_quaternion_wxyz":{"exact":true,"max_abs_error":0.0,"shape":[200,4]},"pad_normal_forces_n":{"exact":true,"max_abs_error":0.0,"shape":[200,2]},"pulse_active":{"exact":true,"max_abs_error":0.0,"shape":[200]},"pulse_closing_tail":{"exact":true,"max_abs_error":0.0,"shape":[200]},"pulse_minimum_opening_m":{"exact":true,"max_abs_error":0.0,"shape":[200]},"requested_jaw_target_m":{"exact":true,"max_abs_error":0.0,"shape":[200]},"t_seconds":{"exact":true,"max_abs_error":0.0,"shape":[200]},"table_object_contact_count":{"exact":true,"max_abs_error":0.0,"shape":[200]},"tcp_position_m":{"exact":true,"max_abs_error":0.0,"shape":[200,3]},"tcp_quaternion_wxyz":{"exact":true,"max_abs_error":0.0,"shape":[200,4]},"unexpected_contact_count":{"exact":true,"max_abs_error":0.0,"shape":[200]}},"independent_score_matches":true,"maximum_abs_error":0.0,"native_values_compared":10200,"original_strict_success":false,"physical_robot_ready":false,"recording_sha256":"aa01f6901cbad1d7b850ec2587329f7de4b378f8f9fc49901df179396f1f0351","schema":"dvidia.operator-native-replay/v1","scope":"CPU native command replay including reset; excludes camera, Godot, fitting and hardware. Native parity is separate from successful task completion.","score":{"evidence_scope":"Recomputed simulator-authored numeric evidence; no physics rerun, source attestation or hardware sensing.","false_alarms":{"reason":"Continuous residual policy has no declared discrete alarm events.","status":"not_applicable"},"grip_events":[],"grip_summary":{"airborne_events":0,"loss_events":0,"qualification_latency_s":[],"restored_events":0,"supported_events":0,"unresolved_events":0},"native_diagnostics":{"premature_open_jaw":{"count":0,"intervals":[],"maximum_s":0.0,"total_s":0.0},"support_touchdown_before_destination":{"count":0,"intervals":[],"maximum_s":0.0,"total_s":0.0}},"native_metrics_mismatches":[],"native_recomputed":{"carry_complete":false,"carry_contact_loss_events":0,"dwell_seconds":0.0,"first_success_time_seconds":null,"grasp_seen":false,"lift_dwell_seconds":0.0,"lift_seen":false,"max_carry_contact_loss_seconds":0.0,"max_object_lift_m":6.229478044517123e-11,"max_pad_normal_force_n":0.0,"retention_lost":false,"success":false,"unexpected_contact_native_ticks":0},"reason":"native_outcome_failed","restoration_scope":"Loaded contact restored for 100 ms after sustained loss; does not establish task recovery or airborne regrasp.","schema":"dvidia.piper-feedback-score/v1","strict_v01":{"native_metrics_source":"simulator-authored episode record; not authenticated sensor input","native_outcome_success":false,"native_outcome_valid":true,"reason":"native_outcome_failed","reference_xy_m":[0.35,0.08],"required_dwell_s":0.25,"required_lift_dwell_s":0.1,"sample_interval_s":0.02,"success":false,"terminal_dwell_s":0.0,"terminal_reference_success":false,"terminal_xy_error_m":0.12083027970892407,"trace_lift_dwell_s":0.0,"trajectory_complete":true},"success":false,"terminal_only":{"dwell_s":0.0,"scope":"Terminal-only geometry/release/settling/dwell; excludes lift, carry and retention history.","success":false,"terminal_xy_error_m":0.12083027970892407},"valid":true},"simulated_seconds":0.20000000000000015,"source_kind":"simulation_scripted_input","teacher_queries":0,"wall_seconds":0.34696154098492116} diff --git a/benchmarks/godot-demonstration-v02/tests.json b/benchmarks/godot-demonstration-v02/tests.json new file mode 100644 index 0000000..f4e5017 --- /dev/null +++ b/benchmarks/godot-demonstration-v02/tests.json @@ -0,0 +1,26 @@ +{ + "schema": "dvidia.operator-verification/v1", + "test_modules": [ + "tests.test_operator_capture", + "tests.test_operator_export", + "tests.test_godot_demonstration_bridge", + "tests.test_camera_action_capture", + "tests.test_camera_action_export", + "tests.test_godot_bridge", + "tests.test_action_demonstration" + ], + "test_executions": 96, + "passed": true, + "failures": 0, + "errors": 0, + "output": "................................................................................................\n----------------------------------------------------------------------\nRan 96 tests in 2.825s\n\nOK\n", + "source_sha256": { + "src/dvidia_training/operator_capture.py": "aa82570a0d1126af4c903713fc29676f0a27ef133677cc2db673c72677171833", + "src/dvidia_training/operator_export.py": "9ff7ac7eeae179df340b0035a982c1672e01bd51d56c1e2dbdfa2e5b0a5455a7", + "src/dvidia_training/godot_demonstration_bridge.py": "6ad0ef1b74b1b07748a02c5367f3bde48e4b3ea77950c4ecdf6e7e30b809e901", + "scripts/verify-operator-replay.py": "9acde85ef288f86cf16da08efeb7790af81a3c7cfec0aff3e4c5f104f94f6f2f", + "integrations/godot-demonstration/main.gd": "98aac9a91f93a0389943289aab4f58982d6cc1e13170d5ec71d732c5b05f802f", + "integrations/godot-demonstration/project.godot": "d886d21c17931d0fef863e6f219cb9da10bc4b916d4cd4bbb70012f07f47e556" + }, + "native_physics_executed_by_these_tests": false +} diff --git a/benchmarks/godot-demonstration-v02/windowed-client.json b/benchmarks/godot-demonstration-v02/windowed-client.json new file mode 100644 index 0000000..0317bfd --- /dev/null +++ b/benchmarks/godot-demonstration-v02/windowed-client.json @@ -0,0 +1,2727 @@ +{ + "applied_sim_time_seconds": 0.2, + "client_script_sha256": "98aac9a91f93a0389943289aab4f58982d6cc1e13170d5ec71d732c5b05f802f", + "errors": [], + "expected_model_sha256": "9c12b9437e4825685921a229129e2154a225e308593f227d17042852926e060f", + "godot": "4.7.2-stable (official)", + "initial_sim_time_seconds": 0.0, + "initial_state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0285001685693837, + 8.99494444381412e-07, + 0.406602785364861 + ], + [ + 0.279938606266863, + -1.22010801876944e-06, + 0.394831449851912 + ], + [ + 0.279938606266863, + -1.22010801876944e-06, + 0.394831449851912 + ], + [ + 0.370119754365555, + 0.00141459357745771, + 0.382651036416412 + ], + [ + 0.503934732048841, + -0.0335858519235436, + 0.364577255289232 + ], + [ + 0.50393494030113, + 0.0364141480752759, + 0.364577071719593 + ], + [ + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.70710528118436, + 0.0, + 0.0, + -0.707108281185553 + ], + [ + 0.499797817587225, + -0.499803939752559, + -0.500197982610706, + -0.500200101494439 + ], + [ + 0.113506973362852, + -0.113513180244746, + -0.697937502868791, + -0.697935646745461 + ], + [ + -5.14302108226427e-06, + -0.752944436272245, + 3.06932012868452e-06, + -0.658084094816764 + ], + [ + 0.532409609503324, + -0.532414623999283, + -0.46533454282896, + -0.465336909250043 + ], + [ + -0.658084094823863, + -2.77304662247931e-07, + -0.752944436287289, + 1.94854711149505e-06 + ], + [ + 0.532411855874789, + -0.532412377639585, + -0.465336531348717, + -0.4653349207318 + ], + [ + -0.532414636467881, + -0.532409597034671, + 0.465336894984113, + -0.465334557094891 + ], + [ + 0.999999999999999, + 1.67902365554781e-13, + -3.31279997277328e-14, + -8.1080055409147e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + 0.0, + 1.57, + -1.3485, + 0.0, + 0.0, + 0.0, + 0.035 + ], + "native_time": 0.5, + "object_position": [ + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838 + ], + "position_error_m": 0.0, + "qpos": [ + 4.033464064055e-20, + 1.57, + -1.3485, + -2.85702112257035e-18, + -1.3639173671723e-16, + 9.79662157176411e-18, + 0.035, + -0.035, + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838, + 0.999999999999999, + 1.67902365554781e-13, + -3.31279997277328e-14, + -8.1080055409147e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.489069666284522, + 0.00141419756548088, + 0.366584943104309 + ], + "tcp_quaternion": [ + 0.658084094823863, + 2.77304662247931e-07, + 0.752944436287289, + -1.94854711149505e-06 + ], + "time": 0.0 + }, + "interactive_wall_seconds": 0.406678, + "logical_processors": 10, + "ok": true, + "physics_authority": "MuJoCo only", + "process_frame_interval_ms": { + "p50": 2.737, + "p95": 5.0, + "samples": 146 + }, + "processor": "Apple M5", + "project_sha256": "d886d21c17931d0fef863e6f219cb9da10bc4b916d4cd4bbb70012f07f47e556", + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "local-session/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "finished", + "steps": 10.0 + }, + "render_process_frames": 152, + "requested_steps": 10, + "reset_responses": 0, + "rpc_roundtrip_ms": { + "p50": 9.59, + "p95": 53.432, + "samples": 12 + }, + "scene_integrity_sha256": "603d2ccecd20b890e67a62357dbd811dcbbf33714a90c2fbe2164403fd5c598f", + "schema": "dvidia.godot-demonstration-smoke/v1", + "server_compute_total_ms": 201.569751021452, + "step_responses": 10, + "timing_scope": "Startup-inclusive wall time; ready-to-completion interactive wall; Godot process delta intervals (not GPU render time); socket request-to-parsed-response latency; server compute includes controller, native camera capture and disk writes; excludes IPC and Godot rendering. Samples capped at 10000 per series.", + "trace": [ + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 0.0 + }, + "request": { + "sequence": 1, + "type": "begin" + }, + "rpc_roundtrip_ms": 6.204, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0285001685693837, + 8.99494444381412e-07, + 0.406602785364861 + ], + [ + 0.279938606266863, + -1.22010801876944e-06, + 0.394831449851912 + ], + [ + 0.279938606266863, + -1.22010801876944e-06, + 0.394831449851912 + ], + [ + 0.370119754365555, + 0.00141459357745771, + 0.382651036416412 + ], + [ + 0.503934732048841, + -0.0335858519235436, + 0.364577255289232 + ], + [ + 0.50393494030113, + 0.0364141480752759, + 0.364577071719593 + ], + [ + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.70710528118436, + 0.0, + 0.0, + -0.707108281185553 + ], + [ + 0.499797817587225, + -0.499803939752559, + -0.500197982610706, + -0.500200101494439 + ], + [ + 0.113506973362852, + -0.113513180244746, + -0.697937502868791, + -0.697935646745461 + ], + [ + -5.14302108226427e-06, + -0.752944436272245, + 3.06932012868452e-06, + -0.658084094816764 + ], + [ + 0.532409609503324, + -0.532414623999283, + -0.46533454282896, + -0.465336909250043 + ], + [ + -0.658084094823863, + -2.77304662247931e-07, + -0.752944436287289, + 1.94854711149505e-06 + ], + [ + 0.532411855874789, + -0.532412377639585, + -0.465336531348717, + -0.4653349207318 + ], + [ + -0.532414636467881, + -0.532409597034671, + 0.465336894984113, + -0.465334557094891 + ], + [ + 0.999999999999999, + 1.67902365554781e-13, + -3.31279997277328e-14, + -8.1080055409147e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + 0.0, + 1.57, + -1.3485, + 0.0, + 0.0, + 0.0, + 0.035 + ], + "native_time": 0.5, + "object_position": [ + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838 + ], + "position_error_m": 0.0, + "qpos": [ + 4.033464064055e-20, + 1.57, + -1.3485, + -2.85702112257035e-18, + -1.3639173671723e-16, + 9.79662157176411e-18, + 0.035, + -0.035, + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838, + 0.999999999999999, + 1.67902365554781e-13, + -3.31279997277328e-14, + -8.1080055409147e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.489069666284522, + 0.00141419756548088, + 0.366584943104309 + ], + "tcp_quaternion": [ + 0.658084094823863, + 2.77304662247931e-07, + 0.752944436287289, + -1.94854711149505e-06 + ], + "time": 0.0 + }, + "step_compute_wall_seconds": 7.18749943189323e-05 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 1.0 + }, + "request": { + "jaw": 0.035, + "sequence": 2, + "target": [ + 0.489, + 0.001, + 0.367 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 9.59, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0286269816016938, + 5.95133718401385e-07, + 0.406590012870732 + ], + [ + 0.280073811800612, + -4.18755622307657e-06, + 0.39499933316494 + ], + [ + 0.280073811800612, + -4.18755622307657e-06, + 0.39499933316494 + ], + [ + 0.370252885736409, + 0.00141067108286848, + 0.382803462086439 + ], + [ + 0.50406446936543, + -0.0335908035465138, + 0.364707310517855 + ], + [ + 0.504065263890179, + 0.0364090584648402, + 0.364705980542946 + ], + [ + 0.300000035805163, + -0.0299998185193866, + 0.0798922445501823 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.707101535743915, + 0.0, + 0.0, + -0.707112026590269 + ], + [ + 0.499906990994153, + -0.499918410437661, + -0.500083575920548, + -0.500090992075614 + ], + [ + 0.11325254355777, + -0.113266143903134, + -0.697977636207146, + -0.697976977684576 + ], + [ + -7.54762801003702e-06, + -0.752707978444665, + 8.29304416058116e-06, + -0.658354539028937 + ], + [ + 0.53244780221016, + -0.53245621370258, + -0.465285202275262, + -0.4652949590902 + ], + [ + -0.658019542476942, + 1.95749481338911e-06, + -0.753000850993174, + 1.08701256874671e-05 + ], + [ + 0.532445438686355, + -0.53245857729688, + -0.465292466089051, + -0.465287695179925 + ], + [ + -0.532460836267655, + -0.532443179650227, + 0.465289669245286, + -0.465290492035586 + ], + [ + 0.999999999999999, + 7.91783572674168e-14, + -1.56234331726645e-14, + -8.1080055408736e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.00067376242938471, + 1.57278998236533, + -1.3785, + 0.00035391900033238, + 0.03, + -0.00044326262045094, + 0.035 + ], + "native_time": 0.52, + "object_position": [ + 0.300000035805163, + -0.0299998185193866, + 0.0798922445501823 + ], + "position_error_m": 0.00053610990133981, + "qpos": [ + -1.05936548199137e-05, + 1.57044716019909, + -1.34966563739816, + 3.28930481664867e-06, + 0.00088993700185388, + -1.981600422288e-05, + 0.0349996119934981, + -0.0350002500356295, + 0.300000035805163, + -0.0299998185193866, + 0.0798922445501823, + 0.999999999999999, + 7.91783572674168e-14, + -1.56234331726645e-14, + -8.1080055408736e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.489200041207311, + 0.00140901535446746, + 0.366716973885477 + ], + "tcp_quaternion": [ + 0.658019542476942, + -1.95749481338911e-06, + 0.753000850993174, + -1.08701256874671e-05 + ], + "time": 0.02 + }, + "step_compute_wall_seconds": 0.00474541599396616 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 2.0 + }, + "request": { + "jaw": 0.035, + "sequence": 3, + "target": [ + 0.489, + 0.001, + 0.367 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 53.432, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0292117206537298, + 1.03469035732351e-07, + 0.406530377300983 + ], + [ + 0.280705376667317, + -8.81187261442693e-06, + 0.396004542348998 + ], + [ + 0.280705376667317, + -8.81187261442693e-06, + 0.396004542348998 + ], + [ + 0.370867872507541, + 0.00140455939193972, + 0.383686539354192 + ], + [ + 0.504654583717597, + -0.0335987212747432, + 0.365411637797861 + ], + [ + 0.504655861232432, + 0.0364012812021874, + 0.36540540280713 + ], + [ + 0.300000035805163, + -0.0299998185193849, + 0.0798922445657737 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.707095721187872, + 0.0, + 0.0, + -0.707117841012234 + ], + [ + 0.500418244260536, + -0.500437871934818, + -0.499563746015813, + -0.499579403910132 + ], + [ + 0.111768768212841, + -0.111793849887374, + -0.698214963408376, + -0.69821611443176 + ], + [ + -1.65816607347393e-05, + -0.75131240116242, + 1.03554307610747e-05, + -0.659946721696057 + ], + [ + 0.532756375229171, + -0.532777530163302, + -0.464923062240755, + -0.464935795785229 + ], + [ + -0.657509503021183, + 2.23871770989573e-05, + -0.753446249823068, + 3.95374054386065e-05 + ], + [ + 0.53274011311177, + -0.532793792022592, + -0.464946258910285, + -0.464912597580638 + ], + [ + -0.532796052243562, + -0.532737852653444, + 0.464914570177168, + -0.464944286448229 + ], + [ + 0.999999999999999, + 3.71151732054937e-14, + -7.32147263822263e-15, + -8.1080055405847e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.0006762285352128, + 1.57283723870636, + -1.39432943835374, + 0.00042608316456459, + 0.06, + -0.00053065356165351, + 0.035 + ], + "native_time": 0.54, + "object_position": [ + 0.300000035805163, + -0.0299998185193849, + 0.0798922445657737 + ], + "position_error_m": 0.00099121963190112, + "qpos": [ + -2.70395132369352e-05, + 1.57250929321144, + -1.35596224025916, + -7.62209028380319e-06, + 0.00647868819811825, + -7.96244294816407e-05, + 0.0349992111304108, + -0.0350007916364866, + 0.300000035805163, + -0.0299998185193849, + 0.0798922445657737, + 0.999999999999999, + 3.71151732054937e-14, + -7.32147263822263e-15, + -8.1080055405847e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.489793133230023, + 0.00140094180100793, + 0.367438978029409 + ], + "tcp_quaternion": [ + 0.657509503021183, + -2.23871770989573e-05, + 0.753446249823068, + -3.95374054386065e-05 + ], + "time": 0.04 + }, + "step_compute_wall_seconds": 0.0513655839604326 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 3.0 + }, + "request": { + "jaw": 0.035, + "sequence": 4, + "target": [ + 0.489, + 0.001, + 0.367 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 9.6, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0298193100835146, + -6.38940868816998e-07, + 0.406467120177517 + ], + [ + 0.281371672134388, + -1.5630220121412e-05, + 0.397452619753872 + ], + [ + 0.281371672134388, + -1.5630220121412e-05, + 0.397452619753872 + ], + [ + 0.371532971875034, + 0.00139557125514606, + 0.385125616398494 + ], + [ + 0.505317238349191, + -0.033610930642671, + 0.366838988072615 + ], + [ + 0.505319747993318, + 0.0363889091574758, + 0.36682944819448 + ], + [ + 0.300000035805163, + -0.0299998185193873, + 0.0798922445737758 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.707087175770247, + 0.0, + 0.0, + -0.707126386059279 + ], + [ + 0.50094727058682, + -0.500978942931641, + -0.499021140573319, + -0.499048927552816 + ], + [ + 0.109662134444711, + -0.109704103072042, + -0.698546354774799, + -0.698550081432269 + ], + [ + -2.08065661519418e-05, + -0.749326297355025, + 2.37021901693568e-05, + -0.662200950692086 + ], + [ + 0.53277671028514, + -0.532803688234868, + -0.464886937026602, + -0.464918640799753 + ], + [ + -0.657471826515367, + 3.12744576871064e-05, + -0.753479125375973, + 6.31119188334233e-05 + ], + [ + 0.532746690025101, + -0.532833708232437, + -0.464925901784232, + -0.464879672053628 + ], + [ + -0.532835968481311, + -0.532744429397424, + 0.46488164456379, + -0.464923929461867 + ], + [ + 0.999999999999999, + -1.0529022087227e-13, + 2.25742834529336e-14, + -8.1080051858038e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.00068165443622578, + 1.57314436340865, + -1.39581963530974, + 0.00083762909392562, + 0.0664786881981183, + -0.00100978903138328, + 0.035 + ], + "native_time": 0.56, + "object_position": [ + 0.300000035805163, + -0.0299998185193873, + 0.0798922445737758 + ], + "position_error_m": 0.00239909516735613, + "qpos": [ + -5.12090801667549e-05, + 1.57465247446722, + -1.36411413612583, + 3.9096714043632e-06, + 0.0125874069704359, + -0.00013881659081271, + 0.0349992129297327, + -0.0350006275660999, + 0.300000035805163, + -0.0299998185193873, + 0.0798922445737758, + 0.999999999999999, + -1.0529022087227e-13, + 2.25742834529336e-14, + -8.1080051858038e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.490456607059185, + 0.00138909169039944, + 0.368866162145588 + ], + "tcp_quaternion": [ + 0.657471826515367, + -3.12744576871064e-05, + 0.753479125375973, + -6.31119188334233e-05 + ], + "time": 0.06 + }, + "step_compute_wall_seconds": 0.00454095902387052 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 4.0 + }, + "request": { + "jaw": 0.035, + "sequence": 5, + "target": [ + 0.489, + 0.001, + 0.367 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 30.851, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0301111386282401, + -1.55079322426746e-06, + 0.406436269387151 + ], + [ + 0.281714308689634, + -2.40121566421349e-05, + 0.398973253472369 + ], + [ + 0.281714308689634, + -2.40121566421349e-05, + 0.398973253472369 + ], + [ + 0.371894526016105, + 0.00138459044051064, + 0.386785115225539 + ], + [ + 0.505705766387291, + -0.0336259597594482, + 0.368704388670075 + ], + [ + 0.505710320596847, + 0.0363734777409528, + 0.368694946018334 + ], + [ + 0.300000035805126, + -0.0299998185195589, + 0.0798922445776 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.7070766739446, + 0.0, + 0.0, + -0.707136887146642 + ], + [ + 0.501196695118947, + -0.501243168133914, + -0.498755738211679, + -0.498798429029047 + ], + [ + 0.107497258676292, + -0.107559997243633, + -0.698879708528549, + -0.698886499638591 + ], + [ + -1.4115970465775e-05, + -0.747280840794954, + 5.34187660970048e-05, + -0.664508346018288 + ], + [ + 0.532423697085796, + -0.532440784485719, + -0.465275847753465, + -0.465349549580764 + ], + [ + -0.658051525433935, + 1.98696751094775e-05, + -0.752972897433871, + 7.21755428551339e-05 + ], + [ + 0.532382323107892, + -0.532482160755896, + -0.465327747298391, + -0.46529764464607 + ], + [ + -0.532484419458894, + -0.532380063971709, + 0.465299618861115, + -0.465325773202714 + ], + [ + 0.999999999999999, + -6.0037451821797e-12, + 1.27324292058029e-12, + -8.1079802196943e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.00069241571106076, + 1.57336938420724, + -1.3962053653543, + 0.00125948500218385, + 0.0725874069704359, + -0.00147586201742806, + 0.035 + ], + "native_time": 0.58, + "object_position": [ + 0.300000035805126, + -0.0299998185195589, + 0.0798922445776 + ], + "position_error_m": 0.00415835423854161, + "qpos": [ + -8.09116846130893e-05, + 1.57568202750084, + -1.37131068718782, + 5.39178429775579e-05, + 0.0172151600934759, + -0.00018749359660484, + 0.0349994049457202, + -0.0350000333403492, + 0.300000035805126, + -0.0299998185195589, + 0.0798922445776, + 0.999999999999999, + -6.0037451821797e-12, + 1.27324292058029e-12, + -8.1079802196943e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.490843047362226, + 0.00137468293442635, + 0.370708732814916 + ], + "tcp_quaternion": [ + 0.658051525433935, + -1.98696751094775e-05, + 0.752972897433871, + -7.21755428551339e-05 + ], + "time": 0.0800000000000001 + }, + "step_compute_wall_seconds": 0.0294266249984503 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 5.0 + }, + "request": { + "jaw": 0.025, + "sequence": 6, + "target": [ + 0.5, + 0.026, + 0.342 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 5.62, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0302295285587888, + 9.13029901503005e-06, + 0.40642366696476 + ], + [ + 0.281867802542857, + 7.56713980038748e-05, + 0.400257068664157 + ], + [ + 0.281867802542857, + 7.56713980038748e-05, + 0.400257068664157 + ], + [ + 0.372082660008922, + 0.00151444675829495, + 0.388331638269817 + ], + [ + 0.5059622374329, + -0.0334230973562285, + 0.370675134882842 + ], + [ + 0.505934874437492, + 0.0365051857553913, + 0.370601013449405 + ], + [ + 0.300000035805063, + -0.02999981851986, + 0.0798922445789978 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.707201714351874, + 0.0, + 0.0, + -0.707011835274185 + ], + [ + 0.501389042633652, + -0.501259134338323, + -0.498739691865802, + -0.498605082131049 + ], + [ + 0.105820121765199, + -0.105635596768445, + -0.699173169318857, + -0.699142404542593 + ], + [ + -0.00022467313761863, + -0.745566372585989, + -0.00044592961318185, + -0.666431192800675 + ], + [ + 0.531589554832743, + -0.531911188013896, + -0.466406412018472, + -0.465776869419712 + ], + [ + -0.659153102232377, + 0.0004964493391315, + -0.752008556259562, + 0.0002695698352868 + ], + [ + 0.531560850901095, + -0.531939849169358, + -0.466443673350255, + -0.465739582005335 + ], + [ + -0.531942104387148, + -0.531558594065754, + 0.465741560954852, + -0.466441697379586 + ], + [ + 0.999999999999999, + -1.12687842579006e-11, + 2.38967195811158e-12, + -8.1079441390343e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + 0.0156112200931965, + 1.59312745852293, + -1.38824036995583, + -0.0287405149978162, + 0.06645059684348, + 0.0285241379825719, + 0.034 + ], + "native_time": 0.6, + "object_position": [ + 0.300000035805063, + -0.02999981851986, + 0.0798922445789978 + ], + "position_error_m": 0.0401779347006097, + "qpos": [ + 0.00027277221006016, + 1.57609973656866, + -1.37688065363196, + -0.00093157932632156, + 0.0194394674982354, + -0.00013270386767107, + 0.0349711936164169, + -0.0349571341324095, + 0.300000035805063, + -0.02999981851986, + 0.0798922445789978, + 0.999999999999999, + -1.12687842579006e-11, + 2.38967195811158e-12, + -8.1079441390343e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.491077738925289, + 0.00154433838117561, + 0.372603599607019 + ], + "tcp_quaternion": [ + 0.659153102232377, + -0.0004964493391315, + 0.752008556259562, + -0.0002695698352868 + ], + "time": 0.1 + }, + "step_compute_wall_seconds": 0.00406033301260322 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 6.0 + }, + "request": { + "jaw": 0.025, + "sequence": 7, + "target": [ + 0.5, + 0.026, + 0.342 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 25.549, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0305212599801016, + 5.20770180078405e-05, + 0.406392394840107 + ], + [ + 0.282180885500295, + 0.00047210466809523, + 0.401185787901898 + ], + [ + 0.282180885500295, + 0.00047210466809523, + 0.401185787901898 + ], + [ + 0.372427271619094, + 0.00203064940550061, + 0.389516717542689 + ], + [ + 0.506416859643844, + -0.0325819875299991, + 0.372351833783646 + ], + [ + 0.506270690086894, + 0.037030782401309, + 0.372070983790859 + ], + [ + 0.300000035804978, + -0.0299998185202578, + 0.079892244579499 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.707698068091957, + 0.0, + 0.0, + -0.706514999429532 + ], + [ + 0.5019954992429, + -0.501165596462184, + -0.498833684631164, + -0.497994496696371 + ], + [ + 0.104977503707038, + -0.10381061431792, + -0.699446464252223, + -0.699269421407399 + ], + [ + -0.00100157952445639, + -0.74429027218814, + -0.00235589643483851, + -0.667851358699335 + ], + [ + 0.530347603806195, + -0.531795673872449, + -0.468521063338516, + -0.465201884772322 + ], + [ + -0.660242103398579, + 0.00212033740614086, + -0.751049394938809, + 0.00082184618603808 + ], + [ + 0.530492102412508, + -0.53165214030493, + -0.468361974892695, + -0.465361355788981 + ], + [ + -0.531654390988405, + -0.530489846797822, + 0.465363342877168, + -0.468360000526687 + ], + [ + 0.999999999999999, + -1.38979833836126e-11, + 2.94719275358367e-12, + -8.1079042532261e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + 0.0154549549359366, + 1.5927858930779, + -1.39160630897124, + -0.034532379351101, + 0.0672564050830589, + 0.035406730490284, + 0.0329999999999999 + ], + "native_time": 0.62, + "object_position": [ + 0.300000035804978, + -0.0299998185202578, + 0.079892244579499 + ], + "position_error_m": 0.0408771615604711, + "qpos": [ + 0.00167735458514192, + 1.5771292573069, + -1.3817249707911, + -0.00466134076525013, + 0.0203551427868718, + 0.00060735539837788, + 0.0348243703242873, + -0.0347891196029839, + 0.300000035804978, + -0.0299998185202578, + 0.079892244579499, + 0.999999999999999, + -1.38979833836126e-11, + 2.94719275358367e-12, + -8.1079042532261e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.491467306156427, + 0.00221854171440273, + 0.374133747288841 + ], + "tcp_quaternion": [ + 0.660242103398579, + -0.00212033740614086, + 0.751049394938808, + -0.00082184618603808 + ], + "time": 0.12 + }, + "step_compute_wall_seconds": 0.0239029590156861 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 7.0 + }, + "request": { + "jaw": 0.025, + "sequence": 8, + "target": [ + 0.5, + 0.026, + 0.342 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 5.56, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0309065623276959, + 0.00011566550761452, + 0.406350614085402 + ], + [ + 0.282578326595707, + 0.00104828097035111, + 0.401844441138742 + ], + [ + 0.282578326595707, + 0.00104828097035111, + 0.401844441138742 + ], + [ + 0.372844531969953, + 0.0027830507296888, + 0.390354914888 + ], + [ + 0.506947716215446, + -0.0312857406348366, + 0.373561925603925 + ], + [ + 0.506643451035064, + 0.0377596309800154, + 0.373086162635925 + ], + [ + 0.300000035804874, + -0.0299998185207506, + 0.0798922445796805 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.708417153905061, + 0.0, + 0.0, + -0.705793975642363 + ], + [ + 0.502840603148948, + -0.50099688465327, + -0.499003127813562, + -0.497141154829091 + ], + [ + 0.104716475060703, + -0.102124717300482, + -0.699694606322142, + -0.699308558399553 + ], + [ + -0.00159579434168795, + -0.743350128701834, + -0.0045255438062052, + -0.668885310836352 + ], + [ + 0.529425185469345, + -0.531747133600563, + -0.47059331326765, + -0.464215351325341 + ], + [ + -0.661010607699446, + 0.00393062172319021, + -0.750365412759855, + 0.00112874063031541 + ], + [ + 0.529791446218412, + -0.531385500477217, + -0.470185452230221, + -0.464624702213995 + ], + [ + -0.53138774818806, + -0.52978919173499, + 0.464626697038552, + -0.470183480989555 + ], + [ + 0.999999999999999, + -1.54262258791929e-11, + 3.27127241118971e-12, + -8.1078548984857e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + 0.016277493086202, + 1.59307780009486, + -1.3942928923862, + -0.0382352236003634, + 0.0668183942582019, + 0.036124273869931, + 0.0319999999999999 + ], + "native_time": 0.64, + "object_position": [ + 0.300000035804874, + -0.0299998185207506, + 0.0798922445796805 + ], + "position_error_m": 0.0410893953141293, + "qpos": [ + 0.00371397904524986, + 1.57848957155195, + -1.38586846391674, + -0.00847007559026604, + 0.0210902261155015, + 0.00154773089205861, + 0.034542880319804, + -0.0345048008086781, + 0.300000035804874, + -0.0299998185207506, + 0.0798922445796805, + 0.999999999999999, + -1.54262258791929e-11, + 3.27127241118971e-12, + -8.1078548984857e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.491915236398997, + 0.00320344733655242, + 0.375215842477037 + ], + "tcp_quaternion": [ + 0.661010607699446, + -0.00393062172319021, + 0.750365412759855, + -0.00112874063031541 + ], + "time": 0.14 + }, + "step_compute_wall_seconds": 0.00392633298179134 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 8.0 + }, + "request": { + "jaw": 0.01, + "sequence": 9, + "target": [ + 0.5, + -0.014, + 0.377 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 25.489, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0316279873927258, + 0.00017055058937235, + 0.406270967100376 + ], + [ + 0.283309898125239, + 0.00151868694994693, + 0.402495965857396 + ], + [ + 0.283309898125239, + 0.00151868694994693, + 0.402495965857396 + ], + [ + 0.373573803991159, + 0.00339568002973474, + 0.391010728939648 + ], + [ + 0.507734964390868, + -0.0300559448352075, + 0.374269260068488 + ], + [ + 0.507315502234998, + 0.0382134998135181, + 0.373711752812047 + ], + [ + 0.30000003580475, + -0.0299998185213326, + 0.0798922445797462 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.708999468214857, + 0.0, + 0.0, + -0.705209014456743 + ], + [ + 0.503883005764179, + -0.501220508808913, + -0.498778509510318, + -0.496084586035543 + ], + [ + 0.104277852838843, + -0.100530567275741, + -0.699925428201617, + -0.699374098324581 + ], + [ + -0.00175078620312932, + -0.742367949298123, + -0.00591401927917163, + -0.669964019167101 + ], + [ + 0.529273770798363, + -0.531847385138568, + -0.471593097510588, + -0.463257579372985 + ], + [ + -0.661040934148634, + 0.00500407316803607, + -0.750332443517145, + 0.00103287812458441 + ], + [ + 0.529835918257024, + -0.531294402560158, + -0.470965939500518, + -0.463887099802716 + ], + [ + -0.531296650459682, + -0.529833664160097, + 0.463889097938607, + -0.470963971389228 + ], + [ + 0.999999999999999, + -1.77892221551969e-11, + 3.77235960607874e-12, + -8.1077847592021e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.0137225069137979, + 1.60421007865189, + -1.4242928923862, + -0.00823522360036335, + 0.0810902261155015, + 0.00612427386993095, + 0.0309999999999999 + ], + "native_time": 0.66, + "object_position": [ + 0.30000003580475, + -0.0299998185213326, + 0.0798922445797462 + ], + "position_error_m": 0.0194803259317423, + "qpos": [ + 0.00536476017278135, + 1.58103683801668, + -1.3913208981431, + -0.0105758813524648, + 0.0239155453000586, + 0.00237610477136688, + 0.0341374417945607, + -0.0341355677741591, + 0.30000003580475, + -0.0299998185213326, + 0.0798922445797462, + 0.999999999999999, + -1.77892221551969e-11, + 3.77235960607874e-12, + -8.1077847592021e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.492644915044592, + 0.00400372685972205, + 0.375881231540412 + ], + "tcp_quaternion": [ + 0.661040934148634, + -0.00500407316803607, + 0.750332443517145, + -0.00103287812458441 + ], + "time": 0.16 + }, + "step_compute_wall_seconds": 0.0231787919765338 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "recording", + "steps": 9.0 + }, + "request": { + "jaw": 0.01, + "sequence": 10, + "target": [ + 0.5, + -0.014, + 0.377 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 5.229, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0330630393494723, + 0.00016171293911871, + 0.406107062352568 + ], + [ + 0.284760965174207, + 0.00138414866952468, + 0.403555184140099 + ], + [ + 0.284760965174207, + 0.00138414866952468, + 0.403555184140099 + ], + [ + 0.375009845398444, + 0.00321335189712374, + 0.391944839353081 + ], + [ + 0.509120320293985, + -0.029810036807199, + 0.37496038004465 + ], + [ + 0.508754435162816, + 0.0375299145673385, + 0.374511941934538 + ], + [ + 0.300000035804625, + -0.029999818521925, + 0.0798922445797699 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.708823229195373, + 0.0, + 0.0, + -0.705386156479587 + ], + [ + 0.505014617615303, + -0.502606912864632, + -0.497381434254118, + -0.494932557016478 + ], + [ + 0.102403393328511, + -0.0990045581398797, + -0.700142912174027, + -0.699651016603854 + ], + [ + -0.00150651496386381, + -0.74074279275994, + -0.00525631447974212, + -0.671766489596614 + ], + [ + 0.529772214622521, + -0.532012483850095, + -0.470754146367437, + -0.463351541833484 + ], + [ + -0.660513974400927, + 0.00423889274473392, + -0.750801454270339, + 0.00070546079580858 + ], + [ + 0.530399078191383, + -0.531394523040606, + -0.470052258795777, + -0.464055549193376 + ], + [ + -0.531396773329415, + -0.530396823669676, + 0.464057543452845, + -0.470050289969825 + ], + [ + 0.999999999999999, + -1.91757711603471e-11, + 4.06638981686116e-12, + -8.107706395839e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.0174756551801622, + 1.60440592614704, + -1.4513208981431, + 0.0217647763996366, + 0.0839155453000586, + -0.023875726130069, + 0.0299999999999999 + ], + "native_time": 0.68, + "object_position": [ + 0.300000035804625, + -0.029999818521925, + 0.0798922445797699 + ], + "position_error_m": 0.0187287818096827, + "qpos": [ + 0.00486500227711136, + 1.58610407031877, + -1.40124769989448, + -0.00933489195463268, + 0.0301803876083287, + 0.00265568028127004, + 0.0336377183412144, + -0.033704720134642, + 0.300000035804625, + -0.029999818521925, + 0.0798922445797699, + 0.999999999999999, + -1.91757711603471e-11, + 4.06638981686116e-12, + -8.107706395839e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.494059882769519, + 0.00375833296492821, + 0.376648026038486 + ], + "tcp_quaternion": [ + 0.660513974400927, + -0.00423889274473392, + 0.750801454270339, + -0.00070546079580858 + ], + "time": 0.18 + }, + "step_compute_wall_seconds": 0.00432241702219471 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "finished", + "steps": 10.0 + }, + "request": { + "jaw": 0.01, + "sequence": 11, + "target": [ + 0.5, + -0.014, + 0.377 + ], + "type": "step" + }, + "rpc_roundtrip_ms": 53.056, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0348919103263092, + 8.85923758895418e-05, + 0.405887515138388 + ], + [ + 0.286603290077862, + 0.00071953394569287, + 0.404973451516446 + ], + [ + 0.286603290077862, + 0.00071953394569287, + 0.404973451516446 + ], + [ + 0.376854594680505, + 0.0023497511876582, + 0.393352312092317 + ], + [ + 0.510868997132641, + -0.0304026631669414, + 0.376199853795774 + ], + [ + 0.510690265353495, + 0.0358406693348, + 0.37603218763886 + ], + [ + 0.3000000358045, + -0.0299998185225119, + 0.0798922445797786 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.707993860810236, + 0.0, + 0.0, + -0.706218587304962 + ], + [ + 0.506030967357756, + -0.504793396814417, + -0.495162222440851, + -0.493893369134445 + ], + [ + 0.0993040080966442, + -0.0975492118303743, + -0.700347164819901, + -0.700097646029425 + ], + [ + -0.00041927687469539, + -0.738568382049827, + -0.00230808468231853, + -0.674174489274329 + ], + [ + 0.530625632599135, + -0.531276509109516, + -0.468626497683904, + -0.465371802503861 + ], + [ + -0.660436683391414, + 0.0018487572903669, + -0.750879461574042, + 5.92765350540443e-05 + ], + [ + 0.530911158151972, + -0.530992760218895, + -0.468307528294082, + -0.465690980896065 + ], + [ + -0.530995012680279, + -0.530908905334796, + 0.465692967753252, + -0.468305552529593 + ], + [ + 0.999999999999999, + -1.97095690863489e-11, + 4.17957951805174e-12, + -8.1076203718985e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.0190766571955133, + 1.60658892065518, + -1.46124769989448, + 0.0506651080453673, + 0.0901803876083287, + -0.0538757261300691, + 0.0289999999999998 + ], + "native_time": 0.700000000000001, + "object_position": [ + 0.3000000358045, + -0.0299998185225119, + 0.0798922445797786 + ], + "position_error_m": 0.0171604923329814, + "qpos": [ + 0.00251485916982425, + 1.59256555135791, + -1.41421599979193, + -0.00374339604922114, + 0.0368930668518755, + 0.00120941144102162, + 0.0330703605258883, + -0.0331734252808144, + 0.3000000358045, + -0.0299998185225119, + 0.0798922445797786, + 0.999999999999999, + -1.97095690863489e-11, + 4.17957951805174e-12, + -8.1076203718985e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.495902377383542, + 0.00263217640043909, + 0.378030870597156 + ], + "tcp_quaternion": [ + 0.660436683391414, + -0.0018487572903669, + 0.750879461574042, + -5.92765350540442e-05 + ], + "time": 0.2 + }, + "step_compute_wall_seconds": 0.0521003330359235 + }, + { + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "output_directory": "/Users/roguedev/dvidia.org/dvidia-godot-demonstration-20261110/runs/operator-diagnostic/windowed-reviewed/episode-0001", + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "finished", + "steps": 10.0 + }, + "request": { + "sequence": 12, + "type": "finish" + }, + "rpc_roundtrip_ms": 2.038, + "state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0348919103263092, + 8.85923758895418e-05, + 0.405887515138388 + ], + [ + 0.286603290077862, + 0.00071953394569287, + 0.404973451516446 + ], + [ + 0.286603290077862, + 0.00071953394569287, + 0.404973451516446 + ], + [ + 0.376854594680505, + 0.0023497511876582, + 0.393352312092317 + ], + [ + 0.510868997132641, + -0.0304026631669414, + 0.376199853795774 + ], + [ + 0.510690265353495, + 0.0358406693348, + 0.37603218763886 + ], + [ + 0.3000000358045, + -0.0299998185225119, + 0.0798922445797786 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.707993860810236, + 0.0, + 0.0, + -0.706218587304962 + ], + [ + 0.506030967357756, + -0.504793396814417, + -0.495162222440851, + -0.493893369134445 + ], + [ + 0.0993040080966442, + -0.0975492118303743, + -0.700347164819901, + -0.700097646029425 + ], + [ + -0.00041927687469539, + -0.738568382049827, + -0.00230808468231853, + -0.674174489274329 + ], + [ + 0.530625632599135, + -0.531276509109516, + -0.468626497683904, + -0.465371802503861 + ], + [ + -0.660436683391414, + 0.0018487572903669, + -0.750879461574042, + 5.92765350540443e-05 + ], + [ + 0.530911158151972, + -0.530992760218895, + -0.468307528294082, + -0.465690980896065 + ], + [ + -0.530995012680279, + -0.530908905334796, + 0.465692967753252, + -0.468305552529593 + ], + [ + 0.999999999999999, + -1.97095690863489e-11, + 4.17957951805174e-12, + -8.1076203718985e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + -0.0190766571955133, + 1.60658892065518, + -1.46124769989448, + 0.0506651080453673, + 0.0901803876083287, + -0.0538757261300691, + 0.0289999999999998 + ], + "native_time": 0.700000000000001, + "object_position": [ + 0.3000000358045, + -0.0299998185225119, + 0.0798922445797786 + ], + "position_error_m": 0.0171604923329814, + "qpos": [ + 0.00251485916982425, + 1.59256555135791, + -1.41421599979193, + -0.00374339604922114, + 0.0368930668518755, + 0.00120941144102162, + 0.0330703605258883, + -0.0331734252808144, + 0.3000000358045, + -0.0299998185225119, + 0.0798922445797786, + 0.999999999999999, + -1.97095690863489e-11, + 4.17957951805174e-12, + -8.1076203718985e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.495902377383542, + 0.00263217640043909, + 0.378030870597156 + ], + "tcp_quaternion": [ + 0.660436683391414, + -0.0018487572903669, + 0.750879461574042, + -5.92765350540442e-05 + ], + "time": 0.2 + }, + "step_compute_wall_seconds": 9.17003490030766e-07 + } + ], + "trace_limit": 256, + "wall_seconds": 1.234756 +} diff --git a/benchmarks/godot-demonstration-v02/workstation.png b/benchmarks/godot-demonstration-v02/workstation.png new file mode 100644 index 0000000000000000000000000000000000000000..074278242ff272956e6db55d629684eecf945a16 GIT binary patch literal 146801 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

g|Y<_t?MOtC8Fk2&VwLnBlTIPS_mqwS~ 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 literal 0 HcmV?d00001 diff --git a/docs/godot-demonstration-v02.md b/docs/godot-demonstration-v02.md new file mode 100644 index 0000000..cb92b34 --- /dev/null +++ b/docs/godot-demonstration-v02.md @@ -0,0 +1,132 @@ +# Record demonstrations locally + +This prototype connects a new Godot demonstration UI to the existing PiPER +rigid-block learning scene. A person can command XYZ and gripper opening, +record paired observations/actions, inspect the attempt and export selected +rows for the existing CPU learner. The tool orientation is fixed. + +This release implements capture and export. It does not add hand tracking, +physical teleoperation, a Train button, a newly trained policy or a qualified +skill. The existing learned placement result remains 5/64. The older Godot +rigid-shoe workstation and all previous study evidence remain unchanged. + +## Run + +Use Python 3.12+, the pinned NumPy/MuJoCo dependencies, Godot 4.7.2 and a working +native graphics context. The tested host is Apple M5/macOS; other platforms +require their own rendering checks. Installation alone does not include Godot +or the separately licensed arm assets. Keep the checkout for the Godot project. + +```sh +python -m venv .venv +source .venv/bin/activate +python -m pip install '.[arm]' +dvidia-robot-sim fetch piper --output ./robot-assets +dvidia-demonstrate prepare --assets ./robot-assets --output ./demo-scene +dvidia-demonstrate serve --assets ./robot-assets --scene ./demo-scene --output ./demo-session +``` + +In a second terminal, replace the scene path with your absolute directory: + +```sh +godot --path integrations/godot-demonstration -- --scene /absolute/path/to/demo-scene --port 8431 +``` + +Choose the target and press **Record**. Move the XYZ and gripper controls; +**Pause** holds simulation time, **Finish** seals the current prefix, and +**New episode** preserves the previous recording and resets into a new directory. +Home changes the target, without resetting or erasing the recording. The +default budget is four episodes, each at most 20 simulated seconds. Finishing +early produces an interrupted recording; recording completion and task success +are separate. Closing the client retains the committed prefix. + +The service listens only on localhost, accepts one client and exits on its +disconnect. No physical commands or cloud service are used. Provisioning the +dependencies/assets requires networking; subsequent capture and export are local. +The capacity guard reserves 10 GiB plus the declared capture budget. This small +capture service does not override the older full study's separate disk guard. + +Use `prepare --case case.json` for explicit starts and either existing destination. +The file has `id`, `group_id`, `seed`, `object_position_m`, and `goal_position_m`. +Changing seed alone does not change the object position. The default case is +the original block at [0.30, -0.03, 0.082] and goal [0.35, 0.08, 0.08] metres. +Keep related starts, retries and corrections in the same train/development/test +group. A different case needs its own prepared scene and session. + +## Inspect and export + +```sh +dvidia-demonstrate inspect ./demo-session/episode-0001 +dvidia-demonstration-export --capture ./demo-session/episode-0001 --review ./review.json --output ./reviewed-data +``` + +Example `review.json`: + +```json +{"segments": [[0, 50]], "reason": "Reviewed useful approach segment, before the unsuccessful grasp."} +``` + +Segments are half-open control-row ranges; `[0, 50]` selects one simulated second. +Alternatively supply `indices` and `reason`. Duplicate or overlapping selections +are rejected. Selection is an unauthenticated review assertion, not an automatic +quality certification. Preserve failures, but only select actions that are useful +for the intended learning experiment. The exporter does not train automatically. + +`operator_export.load_dataset(path)` returns the existing camera-action dataset +contract, suitable for its Python training API. It verifies the saved arrays and +provenance. UI/Terminal captures use the same data; the existing fixed-budget +study orchestrator does not silently accept these as authored-teacher episodes. +A new human-demonstration study still needs a registered split, fit budget and +held-out evaluation before a model can be presented as a useful skill. + +After an abrupt process loss, `dvidia-demonstrate recover PATH` reads the +durable committed prefix without changing the directory. It never resumes an +uncertain native interval, fabricates task success or produces a training export +from an unsealed attempt. `inspect` is for normally sealed attempts. + +## Data and clocks + +- MuJoCo alone advances physics: twenty 1 ms ticks per 50 Hz control interval. +- Native RGB-D is 320x240 at 25 Hz, with calibration and frame hashes. The Godot + orbit view is operator visualization, not the learner's camera. +- Missing perception stays missing. Historical observations retain acquisition + times and masks. Future frames, native object/contact truth and current action + labels are excluded from learner inputs. +- The journal distinguishes input requests, the issued seven-channel command, + the final applied reference and measured joints. The gripper command is one + finger-slide target in metres, not measured full aperture or force. +- A separate encoder-only FK/IK component maps the input target to joint + commands. It receives measured joints, target and jaw, never object/contact + truth. Original limits and native rate limits remain active. +- Native evidence independently checks grasp/lift/retention/release/settling/ + retreat. A successful recording can contain a failed task. + +The source kind is `simulation_human_input` by default, a producer declaration +with `human_authenticated=False`. Automated checks explicitly use +`simulation_scripted_input`. Neither is a physical teleoperation recording. +Every package remains `physical_robot_ready=False`. + +Simulation timestamps and monotonic wall timestamps are separate. A slow camera +or disk slows simulation progress; it does not skip physics ticks. The software +does not establish a 50 Hz wall-clock deadline or real-robot reaction latency. +Capture includes durable writes, rendering and perception costs that the older +unrendered throughput benchmark excluded. + +## Verify + +```sh +python -m unittest tests.test_operator_capture tests.test_operator_export tests.test_godot_demonstration_bridge -v +godot --headless --path integrations/godot-demonstration --script tests/test_coordinates.gd +godot --headless --path integrations/godot-demonstration --script tests/test_recording.gd +python scripts/verify-operator-replay.py --assets ./robot-assets --capture ./demo-session/episode-0001 --output ./native-replay.json +``` + +The native replay script independently executes issued commands and compares +dense native arrays plus the independently reconstructed task score. It measures +reproduction of simulation behavior, not successful manipulation or sim-to-real. +Use `--source-kind simulation_scripted_input` when serving a scripted Godot smoke +or TCP lifecycle check; never label those runs human demonstrations. + +No new learning result is claimed by this release. The next milestone is genuine +operator recordings across starts and goals, followed by the bounded, separately +evaluated learning experiment. diff --git a/integrations/godot-demonstration/.gitignore b/integrations/godot-demonstration/.gitignore new file mode 100644 index 0000000..0f10302 --- /dev/null +++ b/integrations/godot-demonstration/.gitignore @@ -0,0 +1 @@ +.godot/ diff --git a/integrations/godot-demonstration/README.md b/integrations/godot-demonstration/README.md new file mode 100644 index 0000000..dd94371 --- /dev/null +++ b/integrations/godot-demonstration/README.md @@ -0,0 +1,57 @@ +# DVIDIA Demonstration studio + +A standalone Godot 4.7 project for recording simulated PiPER block demonstrations. It renders the native MuJoCo geometry and exposes target X/Y/Z and the single-finger jaw target. Tool orientation is fixed. The existing `integrations/godot` workstation is a separate project. + +Prepare the matching scene, start the local recorder, then open the viewer. Use fresh output directories: + +```sh +python -m dvidia_training.godot_demonstration_bridge prepare \ + --assets /absolute/path/to/piper-assets \ + --output /absolute/path/to/demo-scene --duration 20 + +python -m dvidia_training.godot_demonstration_bridge serve \ + --assets /absolute/path/to/piper-assets \ + --scene /absolute/path/to/demo-scene \ + --output /absolute/path/to/demo-session \ + --source-kind simulation_human_input --port 8431 --max-episodes 4 + +# In another terminal: +godot --path integrations/godot-demonstration -- \ + --scene /absolute/path/to/demo-scene --port 8431 +``` + +`simulation_human_input` identifies a session where a person operates the controls. Automated actions must use `simulation_scripted_input`; smoke mode refuses any other source. The interface displays the recorder's source, acknowledged step count, recorded duration, and output directory. + +- **Record** begins the prepared episode. No recording control intervals run before it; preparation performs the native reset and settling. +- **Pause / Resume** changes recording state without advancing simulation time. A click waits for an outstanding step to finish. +- **Finish** finalizes the current episode. Reaching the configured step limit also finalizes it. +- **New episode** finalizes the current episode and resets into a fresh output directory. It is disabled after the session's episode limit. +- **Home** returns the commanded target and jaw to the scene's initial values. Drag empty space to orbit; scroll to zoom. + +The orbiting viewport is an operator view, not the policy camera. The Python recorder captures native RGB-D and action/state records. This client has no training or physical robot controls. + +MuJoCo is the only physics authority; both Godot physics servers are disabled. Each acknowledged step advances one 20 ms control interval comprising 1 ms native physics ticks. The client requests steps at a nominal 50 Hz with exactly one outstanding request. It does not issue catch-up steps after delayed responses, so elapsed wall time can exceed simulated duration. Recorder errors and disconnections remain visible and stop further commands. + +The viewer verifies the exported manifest, each declared file's size and SHA-256, compiled model identity, and simulation clocks. Meshes and transforms use the coordinate rotation `[x, y, z] → [x, z, -y]`. Body transforms are world-space and geom transforms are body-local. The line-delimited JSON protocol is `dvidia.godot-demonstration/v1`; numbered commands start at 1, while hello and heartbeat ping are unnumbered. + +## Checks and scripted smoke + +```sh +godot --headless --path integrations/godot-demonstration --check-only --script main.gd +godot --headless --path integrations/godot-demonstration --script tests/test_coordinates.gd +godot --headless --path integrations/godot-demonstration --script tests/test_recording.gd +``` + +Start a fresh recorder session with `--source-kind simulation_scripted_input`, then run: + +```sh +godot --headless --path integrations/godot-demonstration -- \ + --scene /absolute/path/to/demo-scene --port 8431 \ + --smoke-steps 100 --smoke-output /absolute/path/to/fresh-smoke.json +``` + +The prepared duration must permit the requested number of steps. Smoke sends Begin, bounded scripted XYZ/jaw steps, and Finish, including an idempotent Finish after automatic completion. It exits successfully only after the final acknowledgement. Its diagnostic receipt contains initial state, up to 256 request/state/recording snapshots, final recording metadata, model and scene digests, server compute timing (including native camera capture and disk writes), and socket/process timing. These are diagnostics, not a training dataset or a graphics benchmark. Existing output files are preserved. + +For a windowed smoke run, omit `--headless` and add `--screenshot /absolute/path/to/fresh-image.png`. The screenshot shows the operator viewport; native policy camera images are written by the Python recorder. + +DVIDIA source is MIT licensed. Prepared exports include the original pinned PiPER asset license and attribution alongside DVIDIA's license. diff --git a/integrations/godot-demonstration/main.gd b/integrations/godot-demonstration/main.gd new file mode 100644 index 0000000..905ff0f --- /dev/null +++ b/integrations/godot-demonstration/main.gd @@ -0,0 +1,949 @@ +extends Node3D +## Local renderer, never a robot physics authority. MIT, DVIDIA contributors. + +const SCHEMA := "dvidia.godot-demonstration/v1" +const MAX_FILE := 64 * 1024 * 1024 +const MAX_PACKET := 1024 * 1024 +const INK := Color("25322e") +const MUTED := Color("728079") +const GREEN := Color("4e7562") +const PAPER := Color("f8f8f2") + +var peer := StreamPeerTCP.new() +var scene_data: Dictionary = {} +var body_nodes: Array[Node3D] = [] +var mesh_cache: Dictionary = {} +var incoming := PackedByteArray() +var connected := false +var hello_sent := false +var pending := false +var pending_kind := "" +var finished := false +var errors: Array[String] = [] +var sequence := 1 +var replies := 0 +var resets := 0 +var state_time := 0.0 +var compute_total_ms := 0.0 +var last_compute_ms := 0.0 +var last_request_us := 0 +var next_step_due_us := 0 +var started_us := 0 +var connected_us := 0 +var frame_count := 0 +var target := [0.36, -0.06, 0.27] +var jaw := 0.035 +var sliders: Array[HSlider] = [] +var value_labels: Array[Label] = [] +var status_label: Label +var time_label: Label +var compute_label: Label +var pause_button: Button +var camera: Camera3D +var marker: MeshInstance3D +var camera_yaw := -0.63 +var camera_pitch := 0.42 +var camera_distance := 1.65 +var orbiting := false +var focus := Vector3(0.26, 0.12, 0.0) +var scene_dir := "" +var port := 8431 +var smoke_steps := 0 +var smoke_output := "" +var screenshot_path := "" +var initial_sim_time := 0.0 +var initial_state: Dictionary = {} +var smoke_trace: Array[Dictionary] = [] +var pending_request: Dictionary = {} +var queued_command := "" +var recording: Dictionary = {} +var record_button: Button +var finish_button: Button +var new_button: Button +var home_button: Button +var source_label: Label +var output_label: Label +var recording_label: Label +var gripper_max := 0.035 +var scene_integrity_sha256 := "" +var verified_files: Dictionary = {} +var ready_us := 0 +var completed_us := 0 +var frame_intervals_ms: Array[float] = [] +var rpc_roundtrip_ms: Array[float] = [] + + +func _ready() -> void: + PhysicsServer3D.set_active(false) + PhysicsServer2D.set_active(false) + started_us = Time.get_ticks_usec() + _parse_args() + _build_world() + _build_ui() + if not errors.is_empty(): + _fail(errors[0]) + return + if scene_dir.is_empty(): + _fail("Choose an exported scene: --scene /absolute/path --port 8431") + return + if not _load_scene(): + return + status_label.text = "Connecting to local MuJoCo…" + var result := peer.connect_to_host("127.0.0.1", port) + if result != OK: + _fail("Cannot connect to local MuJoCo (%s)." % result) + connected_us = Time.get_ticks_usec() + + +func _parse_args() -> void: + var args := OS.get_cmdline_user_args() + var index := 0 + while index < args.size(): + var flag := args[index] + if flag not in ["--scene", "--port", "--smoke-steps", "--smoke-output", "--screenshot"] or index + 1 >= args.size(): + errors.append("Unknown or incomplete argument: " + flag) + return + var value := args[index + 1] + match flag: + "--scene": scene_dir = value + "--port": port = int(value) + "--smoke-steps": smoke_steps = int(value) + "--smoke-output": smoke_output = value + "--screenshot": screenshot_path = value + index += 2 + if port < 1024 or port > 65535 or smoke_steps < 0 or smoke_steps > 100000: + errors.append("Port or smoke step count is outside bounds.") + if (not smoke_output.is_empty() and not smoke_output.is_absolute_path()) or (not screenshot_path.is_empty() and not screenshot_path.is_absolute_path()): + errors.append("Smoke output and screenshot paths must be absolute.") + if (not smoke_output.is_empty() and FileAccess.file_exists(smoke_output)) or (not screenshot_path.is_empty() and FileAccess.file_exists(screenshot_path)): + errors.append("Smoke output and screenshot paths must be fresh files.") + + +func _build_world() -> void: + var environment_node := WorldEnvironment.new() + var environment := Environment.new() + environment.background_mode = Environment.BG_COLOR + environment.background_color = Color("e9ebe2") + environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.ambient_light_color = Color("f1f5e9") + # Compatibility rendering is SDR. Keep the light budget below clipping; + # bright HDR-style lighting washes out native RGBA materials in this path. + environment.ambient_light_energy = 0.20 + environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR + environment_node.environment = environment + add_child(environment_node) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-52, -38, 0) + light.light_energy = 0.42 + light.shadow_enabled = true + light.directional_shadow_max_distance = 5.0 + add_child(light) + var fill := DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-32, 130, 0) + fill.light_energy = 0.08 + add_child(fill) + camera = Camera3D.new() + camera.fov = 41 + camera.near = 0.005 + camera.far = 30 + add_child(camera) + camera.make_current() + _position_camera() + marker = MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 0.008 + sphere.height = 0.016 + marker.mesh = sphere + var material := StandardMaterial3D.new() + material.albedo_color = Color("73a27e") + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + marker.material_override = material + add_child(marker) + marker.position = native_vector(target) + + +func _style(background: Color, border: Color = Color.TRANSPARENT) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = background + box.border_color = border + box.set_border_width_all(1) + box.set_corner_radius_all(8) + box.content_margin_left = 16 + box.content_margin_right = 16 + box.content_margin_top = 11 + box.content_margin_bottom = 11 + return box + + +func _label(text: String, size: int = 16, color: Color = INK) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", size) + label.add_theme_color_override("font_color", color) + return label + + +func _button(text: String, action: Callable) -> Button: + var button := Button.new() + button.text = text + button.custom_minimum_size.y = 42 + button.add_theme_font_size_override("font_size", 15) + button.add_theme_color_override("font_color", INK) + button.add_theme_color_override("font_hover_color", INK) + button.add_theme_color_override("font_disabled_color", MUTED) + button.add_theme_stylebox_override("disabled", _style(Color("eef0e8"), Color("e2e6dc"))) + button.add_theme_stylebox_override("normal", _style(Color("eff1e9"), Color("d8ded1"))) + button.add_theme_stylebox_override("hover", _style(Color("e1e8dc"))) + button.add_theme_stylebox_override("pressed", _style(Color("d3dfcd"))) + button.pressed.connect(action) + return button + + +func _build_ui() -> void: + var layer := CanvasLayer.new() + add_child(layer) + var root := Control.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.mouse_filter = Control.MOUSE_FILTER_IGNORE + layer.add_child(root) + var header := PanelContainer.new() + header.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + header.offset_bottom = 72 + header.add_theme_stylebox_override("panel", _style(PAPER, Color("d9ded2"))) + root.add_child(header) + var top := HBoxContainer.new() + top.add_theme_constant_override("separation", 18) + header.add_child(top) + top.add_child(_label("DVIDIA", 23)) + top.add_child(_label("/ Demonstration studio", 16, MUTED)) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(spacer) + top.add_child(_label("MuJoCo SIMULATION · LOCAL", 13, GREEN)) + var panel := PanelContainer.new() + panel.set_anchors_and_offsets_preset(Control.PRESET_RIGHT_WIDE) + panel.offset_left = -370 + panel.offset_right = -22 + panel.offset_top = 94 + panel.offset_bottom = -22 + panel.add_theme_stylebox_override("panel", _style(PAPER, Color("d9ded2"))) + root.add_child(panel) + var scroll := ScrollContainer.new() + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + panel.add_child(scroll) + var column := VBoxContainer.new() + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.add_theme_constant_override("separation", 8) + scroll.add_child(column) + column.add_child(_label("Block demonstrations", 22)) + column.add_child(_label("Position + jaw · Fixed tool orientation", 13, MUTED)) + status_label = _label("Opening scene…", 14, GREEN) + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + status_label.custom_minimum_size = Vector2(294, 32) + column.add_child(status_label) + source_label = _label("Source —", 13, MUTED) + column.add_child(source_label) + for index in range(4): + var row := HBoxContainer.new() + var names := ["Target X", "Target Y", "Target Z", "Jaw opening"] + var caption := _label(names[index], 14) + caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(caption) + var value_label := _label("", 14, MUTED) + row.add_child(value_label) + value_labels.append(value_label) + column.add_child(row) + var slider := HSlider.new() + slider.custom_minimum_size.y = 20 + slider.step = 0.001 + slider.editable = false + slider.value_changed.connect(_slider_changed.bind(index)) + sliders.append(slider) + column.add_child(slider) + var buttons := HBoxContainer.new() + buttons.add_theme_constant_override("separation", 8) + record_button = _button("Record", _record) + record_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + buttons.add_child(record_button) + pause_button = _button("Pause", _toggle_pause) + pause_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + buttons.add_child(pause_button) + finish_button = _button("Finish", _finish_episode) + finish_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + buttons.add_child(finish_button) + column.add_child(buttons) + var secondary := HBoxContainer.new() + secondary.add_theme_constant_override("separation", 8) + home_button = _button("Home", _home) + secondary.add_child(home_button) + new_button = _button("New episode", _reset) + new_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + secondary.add_child(new_button) + column.add_child(secondary) + recording_label = _label("Recorded 0 steps", 15) + time_label = _label("Recorded duration 0.00 s", 14) + compute_label = _label("Server step — ms", 12, MUTED) + column.add_child(recording_label) + column.add_child(time_label) + column.add_child(compute_label) + column.add_child(_label("EPISODE DIRECTORY", 11, MUTED)) + output_label = _label("Waiting for the recorder…", 12, MUTED) + output_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + output_label.custom_minimum_size.x = 294 + column.add_child(output_label) + var note := _label("Simulation only · Operator viewport\nPolicy images come from the native camera.", 12, MUTED) + note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + column.add_child(note) + var hints := _label("Drag empty space to orbit · Scroll to zoom\nGreen point: commanded target · Z is native height", 14, MUTED) + hints.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT) + hints.offset_left = 28 + hints.offset_top = -63 + hints.offset_bottom = -18 + root.add_child(hints) + _refresh_controls() + + +static func native_vector(value: Array) -> Vector3: + return Vector3(float(value[0]), float(value[2]), -float(value[1])) + + +static func native_basis(value: Array) -> Basis: + # A proper coordinate rotation C: [x,y,z] -> [x,z,-y]. Both local + # mesh coordinates and every transform are mapped, so R' = C R C^-1. + return Basis(Quaternion(float(value[1]), float(value[3]), -float(value[2]), float(value[0]))) + + +func _valid_vector(value: Variant, length: int) -> bool: + if not value is Array or value.size() != length: + return false + for number in value: + if not (number is int or number is float) or not is_finite(float(number)): + return false + return true + + +func _valid_quaternion(value: Variant) -> bool: + if not _valid_vector(value, 4): + return false + var norm_squared := 0.0 + for number in value: + norm_squared += float(number) * float(number) + return absf(norm_squared - 1.0) < 0.00001 + + +func _load_scene() -> bool: + if not _verify_scene_files(): + return false + var decoded: Variant = JSON.parse_string(verified_files["scene.json"].get_string_from_utf8()) + if not decoded is Dictionary or decoded.get("schema") != "dvidia.asset-preview-model/v1" or not decoded.has("bridge") or not decoded.has("bodies") or not decoded.has("meshes") or not decoded.has("geoms"): + _fail("Scene metadata is incomplete.") + return false + scene_data = decoded + var bridge: Dictionary = scene_data.bridge + if bridge.get("schema") != SCHEMA or not str(bridge.get("model_sha256", "")).is_valid_hex_number(false) or str(bridge.get("model_sha256", "")).length() != 64: + _fail("Scene bridge identity is invalid.") + return false + if float(bridge.get("physics_dt", -1)) != 0.001 or float(bridge.get("control_dt", -1)) != 0.02 or scene_data.get("source", {}).get("compiled_model_sha256") != bridge.model_sha256: + _fail("Scene model source or pinned simulation clocks differ.") + return false + var info: Dictionary = scene_data.get("geometry_binary", {}) + if info.get("href") != "geometry.bin" or info.get("compression") != "none" or int(info.get("bytes", -1)) < 0 or int(info.get("bytes", MAX_FILE + 1)) > MAX_FILE: + _fail("Scene geometry framing is invalid.") + return false + var bytes: PackedByteArray = verified_files["geometry.bin"] + if bytes.size() != int(info.bytes): + _fail("Geometry file is missing or has changed size.") + return false + var hash := HashingContext.new() + hash.start(HashingContext.HASH_SHA256) + hash.update(bytes) + if hash.finish().hex_encode() != info.get("sha256"): + _fail("Geometry checksum does not match the exported scene.") + return false + if scene_data.bodies.size() < 1 or scene_data.bodies.size() > 128 or scene_data.meshes.size() > 128 or scene_data.geoms.size() > 512: + _fail("Scene inventory exceeds bounds.") + return false + for body in scene_data.bodies: + if int(body.id) != body_nodes.size(): + _fail("Body IDs must be contiguous native IDs.") + return false + var node := Node3D.new() + node.name = str(body.name) + add_child(node) + body_nodes.append(node) + for record in scene_data.meshes: + var mesh := _native_mesh(record, bytes) + if mesh == null: + return false + mesh_cache[int(record.id)] = mesh + for geom in scene_data.geoms: + if not _add_geom(geom): + return false + if not _valid_vector(bridge.get("home_target"), 3) or not bridge.get("bounds") is Array or bridge.bounds.size() != 3: + _fail("Target declaration is invalid.") + return false + target = bridge.home_target.duplicate() + jaw = float(bridge.gripper_initial) + gripper_max = float(bridge.get("gripper_max", 0.035)) + for index in range(4): + if index < 3: + sliders[index].min_value = float(bridge.bounds[index][0]) + sliders[index].max_value = float(bridge.bounds[index][1]) + else: + sliders[index].min_value = 0 + sliders[index].max_value = gripper_max + _set_slider(index, float(target[index]) if index < 3 else jaw) + verified_files.clear() + return true + + +func _verify_scene_files() -> bool: + var seal_file := FileAccess.open(scene_dir.path_join("scene-integrity.json"), FileAccess.READ) + if seal_file == null or seal_file.get_length() > 65536: + _fail("Cannot read a bounded scene-integrity.json.") + return false + var seal_bytes := seal_file.get_buffer(seal_file.get_length()) + scene_integrity_sha256 = _sha256(seal_bytes) + var seal: Variant = JSON.parse_string(seal_bytes.get_string_from_utf8()) + if not seal is Dictionary or seal.get("schema") != "dvidia.godot-scene-integrity/v1" or not seal.get("files") is Dictionary: + _fail("Scene integrity manifest is invalid.") + return false + var expected := ["scene.json", "geometry.bin", "LICENSE", "licenses/agilex_piper/LICENSE", "licenses/agilex_piper/README.md", "README.md"] + if seal.files.size() != expected.size(): + _fail("Scene integrity inventory differs from the pinned PiPER export.") + return false + var total := 0 + for name in expected: + var record: Variant = seal.files.get(name) + if not record is Dictionary or not record.get("bytes") is float and not record.get("bytes") is int: + _fail("Scene integrity file record is invalid.") + return false + var size := int(record.bytes) + var limit := MAX_FILE if name == "geometry.bin" else (12 * 1024 * 1024 if name == "scene.json" else 65536) + var file := FileAccess.open(scene_dir.path_join(name), FileAccess.READ) + if size < 0 or size > limit or file == null or file.get_length() != size: + _fail("Missing or size-mismatched scene artifact: " + name) + return false + var payload := file.get_buffer(size) + if payload.size() != size or _sha256(payload) != record.get("sha256"): + _fail("Scene artifact checksum mismatch: " + name) + return false + total += size + if total > 96 * 1024 * 1024: + _fail("Scene artifact total exceeds bounds.") + return false + if name in ["scene.json", "geometry.bin"]: + verified_files[name] = payload + var metadata: Variant = JSON.parse_string(verified_files["scene.json"].get_string_from_utf8()) + if not metadata is Dictionary or not metadata.get("bridge") is Dictionary or metadata.bridge.get("model_sha256") != seal.get("model_sha256"): + _fail("Scene model identity differs from its integrity manifest.") + return false + return true + + +func _sha256(bytes: PackedByteArray) -> String: + var hash := HashingContext.new() + hash.start(HashingContext.HASH_SHA256) + hash.update(bytes) + return hash.finish().hex_encode() + + +func _native_mesh(record: Dictionary, bytes: PackedByteArray) -> ArrayMesh: + var count := int(record.vertex_count) + var indices := int(record.index_count) + var vertex_offset := int(record.position_byte_offset) + var index_offset := int(record.index_byte_offset) + if count < 1 or indices < 3 or indices % 3 != 0 or vertex_offset < 0 or index_offset < 0 or vertex_offset + count * 12 > bytes.size() or index_offset + indices * 4 > bytes.size(): + _fail("Native mesh byte range is invalid.") + return null + var points := PackedVector3Array() + points.resize(count) + for index in range(count): + var offset := vertex_offset + index * 12 + var value := [bytes.decode_float(offset), bytes.decode_float(offset + 4), bytes.decode_float(offset + 8)] + if not _valid_vector(value, 3): + _fail("Native mesh coordinate is nonfinite.") + return null + points[index] = native_vector(value) + var triangle_indices := PackedInt32Array() + triangle_indices.resize(indices) + for index in range(0, indices, 3): + # MuJoCo triangle winding is counterclockwise; Godot fronts are clockwise. + for corner in range(3): + var source_corner: int = [0, 2, 1][corner] + var vertex := int(bytes.decode_u32(index_offset + (index + source_corner) * 4)) + if vertex < 0 or vertex >= count: + _fail("Native triangle index is outside the mesh.") + return null + triangle_indices[index + corner] = vertex + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = points + arrays[Mesh.ARRAY_INDEX] = triangle_indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var surface := SurfaceTool.new() + surface.create_from(mesh, 0) + surface.generate_normals() + return surface.commit() + + +func _add_geom(geom: Dictionary) -> bool: + if not _valid_vector(geom.get("position"), 3) or not _valid_quaternion(geom.get("quaternion_wxyz")) or not _valid_vector(geom.get("size"), 3) or not _valid_vector(geom.get("rgba"), 4): + _fail("Native geometry transform is invalid.") + return false + var body_id := int(geom.get("body_id", -1)) + if body_id < 0 or body_id >= body_nodes.size(): + _fail("Geometry references an unknown body.") + return false + if float(geom.rgba[3]) <= 0.0 or int(geom.group) > 2: + return true + var size: Array = geom.size + var mesh: Mesh + var scale_value := Vector3.ONE + match geom.type: + "mesh": mesh = mesh_cache.get(int(geom.mesh_id)) + "box": + var box := BoxMesh.new() + box.size = Vector3(2 * size[0], 2 * size[2], 2 * size[1]) + mesh = box + "sphere", "ellipsoid": + var sphere := SphereMesh.new() + sphere.radius = 1.0 if geom.type == "ellipsoid" else float(size[0]) + sphere.height = 2 * sphere.radius + if geom.type == "ellipsoid": + scale_value = Vector3(size[0], size[2], size[1]) + mesh = sphere + "capsule": + var capsule := CapsuleMesh.new() + capsule.radius = size[0] + capsule.height = 2 * (size[0] + size[1]) + mesh = capsule + "cylinder": + var cylinder := CylinderMesh.new() + cylinder.top_radius = size[0] + cylinder.bottom_radius = size[0] + cylinder.height = 2 * size[1] + mesh = cylinder + "plane": + var plane := PlaneMesh.new() + plane.size = Vector2(maxf(1, 2 * size[0]), maxf(1, 2 * size[1])) + mesh = plane + if mesh == null: + _fail("Unsupported or missing native geometry: " + str(geom.type)) + return false + var instance := MeshInstance3D.new() + instance.name = str(geom.name) + instance.mesh = mesh + var rgba: Array = geom.rgba + var material := StandardMaterial3D.new() + material.albedo_color = Color(rgba[0], rgba[1], rgba[2], rgba[3]).linear_to_srgb() + material.roughness = 0.7 + material.metallic = 0.12 if geom.type == "mesh" else 0.0 + material.cull_mode = BaseMaterial3D.CULL_DISABLED + if float(rgba[3]) < 1: + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + instance.material_override = material + instance.transform = Transform3D(native_basis(geom.quaternion_wxyz).scaled_local(scale_value), native_vector(geom.position)) + body_nodes[body_id].add_child(instance) + return true + + +func _process(delta: float) -> void: + frame_count += 1 + if finished or not errors.is_empty(): + return + if connected and frame_intervals_ms.size() < 10000: + frame_intervals_ms.append(delta * 1000.0) + peer.poll() + var status := peer.get_status() + if status == StreamPeerTCP.STATUS_CONNECTED: + if not hello_sent: + hello_sent = true + _send({"type": "hello", "schema": SCHEMA, "model_sha256": scene_data.bridge.model_sha256}) + var available := peer.get_available_bytes() + if available > 0: + if incoming.size() + available > MAX_PACKET: + _fail("Bridge packet exceeds bounds.") + return + var received := peer.get_data(available) + if received[0] != OK: + _fail("Local bridge read failed.") + return + incoming.append_array(received[1]) + while incoming.find(10) >= 0: + var end := incoming.find(10) + var message: Variant = JSON.parse_string(incoming.slice(0, end).get_string_from_utf8()) + incoming = incoming.slice(end + 1) + if not message is Dictionary: + _fail("Bridge returned invalid JSON.") + return + _receive(message) + if finished or not errors.is_empty(): + return + if connected and not pending: + if not queued_command.is_empty(): + var command := queued_command + queued_command = "" + _send({"type": command, "sequence": sequence}) + elif recording.get("status") == "recording" and Time.get_ticks_usec() >= next_step_due_us: + if smoke_steps > 0: + _smoke_target() + # One pending request. Late replies discard elapsed deadlines; + # neither rendering nor a catch-up loop determines physics ticks. + next_step_due_us += 20000 + _send({"type": "step", "sequence": sequence, "target": target, "jaw": jaw}) + elif Time.get_ticks_usec() - last_request_us >= 5000000: + _send({"type": "ping"}) + elif status == StreamPeerTCP.STATUS_ERROR or hello_sent: + _fail("MuJoCo disconnected. Recording stopped; check the episode directory.") + return + if (pending and Time.get_ticks_usec() - last_request_us > 10000000) or (not hello_sent and Time.get_ticks_usec() - connected_us > 10000000): + _fail("Local MuJoCo response timed out. Recording stopped.") + + +func _send(message: Dictionary) -> void: + if pending: + _fail("Refusing a second outstanding recorder request.") + return + pending_kind = str(message.type) + pending_request = message.duplicate(true) + if peer.put_data((JSON.stringify(message) + "\n").to_utf8_buffer()) != OK: + _fail("Local bridge write failed.") + return + pending = true + last_request_us = Time.get_ticks_usec() + _refresh_controls() + + +static func valid_recording(value: Variant) -> bool: + if not value is Dictionary or value.get("status") not in ["ready", "recording", "paused", "finished"]: + return false + for key in ["steps", "max_steps"]: + var number: Variant = value.get(key) + if not (number is int or number is float) or not is_finite(float(number)) or float(number) != floor(float(number)): + return false + if value.steps < 0 or value.max_steps < 1 or value.steps > value.max_steps: + return false + return value.get("source_kind") in ["simulation_human_input", "simulation_scripted_input"] and value.get("output_directory") is String and value.output_directory.is_absolute_path() + + +static func recording_transition_error(previous: Dictionary, current: Dictionary, command: String) -> String: + if not valid_recording(current): + return "Recorder state framing is invalid." + if command in ["hello", "reset"]: + if current.status != "ready" or int(current.steps) != 0: + return "A new episode must be ready with zero recorded steps." + if command == "reset" and current.output_directory == previous.get("output_directory"): + return "New episode did not receive a fresh output directory." + return "" + if previous.is_empty() or current.output_directory != previous.get("output_directory") or current.source_kind != previous.get("source_kind") or current.max_steps != previous.get("max_steps"): + return "Recorder episode identity changed unexpectedly." + var expected_steps := int(previous.steps) + (1 if command == "step" else 0) + if int(current.steps) != expected_steps: + return "Recorded step count differs from acknowledged actions." + var expected_status: String = {"begin": "recording", "resume": "recording", "pause": "paused", "finish": "finished", "step": "recording"}.get(command, "") + if command == "step" and int(current.steps) == int(current.max_steps) and current.status == "finished": + return "" + if current.status != expected_status: + return "Recorder status differs from the acknowledged command." + return "" + + +func _receive(message: Dictionary) -> void: + var received_us := Time.get_ticks_usec() + if message.get("type") == "error": + _fail("Recorder: " + str(message.get("message", "request rejected"))) + return + if pending and pending_kind == "ping" and message.get("type") == "pong" and message.get("model_sha256") == scene_data.bridge.model_sha256 and int(message.get("expected_sequence", -1)) == sequence: + pending = false + pending_kind = "" + _refresh_controls() + return + if not pending or message.get("model_sha256") != scene_data.bridge.model_sha256: + _fail("Unsolicited response or model identity mismatch.") + return + var command := pending_kind + if command == "hello": + if message.get("type") != "ready" or message.get("schema") != SCHEMA or float(message.get("physics_dt", -1)) != float(scene_data.bridge.physics_dt) or float(message.get("control_dt", -1)) != float(scene_data.bridge.control_dt): + _fail("Local MuJoCo handshake differs from the scene.") + return + else: + if message.get("type") != "state" or int(message.get("sequence", -1)) != sequence: + _fail("Bridge response sequence mismatch.") + return + var current: Variant = message.get("recording") + if not current is Dictionary: + _fail("Recorder state is missing.") + return + var transition_error := recording_transition_error(recording, current, command) + if not transition_error.is_empty(): + _fail(transition_error) + return + if smoke_steps > 0 and current.source_kind != "simulation_scripted_input": + _fail("Smoke capture requires a server configured with source_kind simulation_scripted_input.") + return + if command == "hello" and smoke_steps > int(current.max_steps): + _fail("Requested smoke steps exceed the episode limit.") + return + var state: Variant = message.get("state") + if not state is Dictionary or not state.get("body_positions") is Array or not state.get("body_quaternions") is Array or state.body_positions.size() != body_nodes.size() or state.body_quaternions.size() != body_nodes.size(): + _fail("Native body state framing mismatch.") + return + for index in range(body_nodes.size()): + if not _valid_vector(state.body_positions[index], 3) or not _valid_quaternion(state.body_quaternions[index]): + _fail("Native body pose is nonfinite.") + return + var new_time := float(state.get("time", -1)) + if not is_finite(new_time) or new_time < 0: + _fail("Native simulation clock is invalid.") + return + if command in ["hello", "reset"]: + if absf(new_time) > 0.000001: + _fail("New episode simulation time must start at zero.") + return + else: + var expected_delta := float(scene_data.bridge.control_dt) if command == "step" else 0.0 + if absf(new_time - state_time - expected_delta) > 0.000001: + _fail("Native clock changed outside the acknowledged control interval.") + return + var compute_seconds := float(message.get("step_compute_wall_seconds", 0)) + if not is_finite(compute_seconds) or compute_seconds < 0: + _fail("Native compute timing is invalid.") + return + for index in range(body_nodes.size()): + body_nodes[index].transform = Transform3D(native_basis(state.body_quaternions[index]), native_vector(state.body_positions[index])) + state_time = new_time + recording = current.duplicate(true) + if command == "hello": + connected = true + ready_us = received_us + initial_sim_time = new_time + initial_state = state.duplicate(true) + if smoke_steps > 0: + queued_command = "begin" + else: + sequence += 1 + if command == "reset": + resets += 1 + _home() + elif command == "step": + replies += 1 + last_compute_ms = compute_seconds * 1000.0 + compute_total_ms += last_compute_ms + if rpc_roundtrip_ms.size() < 10000: + rpc_roundtrip_ms.append((received_us - last_request_us) / 1000.0) + if command in ["hello", "reset", "begin", "resume"] or (command == "step" and received_us > next_step_due_us): + next_step_due_us = received_us + 20000 + if smoke_steps > 0 and command != "hello" and smoke_trace.size() < 256: + smoke_trace.append({"request": pending_request.duplicate(true), "state": state.duplicate(true), "recording": recording.duplicate(true), "step_compute_wall_seconds": compute_seconds, "rpc_roundtrip_ms": (received_us - last_request_us) / 1000.0}) + pending = false + pending_kind = "" + if recording.status == "finished" and queued_command != "reset": + queued_command = "" + _refresh_controls() + if smoke_steps > 0 and replies >= smoke_steps: + if command == "finish": + _finish_smoke() + else: + _queue_command("finish") + + +func _refresh_controls() -> void: + if record_button == null: + return + var available := connected and errors.is_empty() and not finished + var busy := not queued_command.is_empty() or (pending and pending_kind not in ["step", "ping"]) + var status: String = str(recording.get("status", "")) + record_button.disabled = not available or busy or status != "ready" + pause_button.disabled = not available or busy or status not in ["recording", "paused"] + pause_button.text = "Resume" if status == "paused" else "Pause" + finish_button.disabled = not available or busy or status not in ["recording", "paused"] + var budget_exhausted := recording.has("episode") and recording.has("max_episodes") and int(recording.episode) >= int(recording.max_episodes) + new_button.disabled = not available or busy or budget_exhausted + new_button.tooltip_text = "Episode limit reached. Start a new recorder session." if budget_exhausted else "Finish this episode and prepare a fresh directory." + home_button.disabled = not available or busy or status == "finished" + for slider in sliders: + slider.editable = available and not busy and status != "finished" and smoke_steps == 0 + if not available or recording.is_empty(): + return + var descriptions := {"ready": "Ready · Press Record to begin", "recording": "Recording · MuJoCo connected", "paused": "Paused · No simulation steps", "finished": "Finished · Episode saved"} + status_label.text = descriptions.get(status, status) + if not queued_command.is_empty(): + status_label.text = "Waiting to %s after the current request…" % queued_command + elif busy: + status_label.text = "Recorder: %s…" % pending_kind + var source_name: String = {"simulation_human_input": "Human operator", "simulation_scripted_input": "Scripted smoke"}.get(recording.source_kind, str(recording.source_kind)) + source_label.text = "Source %s" % source_name + source_label.tooltip_text = str(recording.source_kind) + recording_label.text = "Recorded %d / %d steps" % [int(recording.steps), int(recording.max_steps)] + time_label.text = "Recorded duration %.2f s" % (int(recording.steps) * 0.02) + compute_label.text = "Server step %.2f ms · 20 ms simulated" % last_compute_ms + var directory := str(recording.output_directory) + output_label.text = directory.get_base_dir().get_file() + " / " + directory.get_file() + output_label.tooltip_text = str(recording.output_directory) + + +func _queue_command(command: String) -> void: + if not connected or not errors.is_empty() or not queued_command.is_empty(): + return + queued_command = command + _refresh_controls() + + +func _slider_changed(value: float, index: int) -> void: + if index < 3: + target[index] = value + else: + jaw = value + value_labels[index].text = "%.0f mm" % (value * 1000) if index < 3 else "%.0f%% open" % (value / gripper_max * 100) + marker.position = native_vector(target) + + +func _set_slider(index: int, value: float) -> void: + sliders[index].set_value_no_signal(value) + _slider_changed(sliders[index].value, index) + + +func _record() -> void: + if recording.get("status") == "ready": + _queue_command("begin") + + +func _toggle_pause() -> void: + if recording.get("status") in ["recording", "paused"]: + _queue_command("resume" if recording.status == "paused" else "pause") + + +func _finish_episode() -> void: + if recording.get("status") in ["recording", "paused"]: + _queue_command("finish") + + +func _home() -> void: + if not connected: + return + for index in range(3): + _set_slider(index, scene_data.bridge.home_target[index]) + _set_slider(3, scene_data.bridge.gripper_initial) + + +func _reset() -> void: + _queue_command("reset") + + +func _smoke_target() -> void: + # Bounded, deterministic movement and jaw changes verify more than a static + # connection. The trace preserves the exact transmitted action for replay. + var fraction := float(replies) / float(smoke_steps) + var offset := [0.0, 0.0, 0.0] + var opening := 0.035 + if fraction >= 0.333 and fraction < 0.666: + offset = [0.02, 0.025, -0.025] + opening = 0.025 + elif fraction >= 0.666: + offset = [0.035, -0.015, 0.01] + opening = 0.01 + for index in range(3): + _set_slider(index, float(scene_data.bridge.home_target[index]) + float(offset[index])) + _set_slider(3, opening) + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT: + orbiting = event.pressed + elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP: + camera_distance = maxf(0.45, camera_distance * 0.9) + elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + camera_distance = minf(4.5, camera_distance * 1.1) + _position_camera() + elif event is InputEventMouseMotion and orbiting: + camera_yaw -= event.relative.x * 0.006 + camera_pitch = clampf(camera_pitch + event.relative.y * 0.006, 0.08, 1.35) + _position_camera() + + +func _position_camera() -> void: + if camera == null: + return + camera.position = focus + camera_distance * Vector3(cos(camera_pitch) * sin(camera_yaw), sin(camera_pitch), cos(camera_pitch) * cos(camera_yaw)) + camera.look_at(focus) + # Shift the scene away from the overlaid controls without moving its geometry. + camera.h_offset = 0.19 + + +func _fail(message: String) -> void: + if errors.is_empty() or errors[-1] != message: + errors.append(message) + connected = false + queued_command = "" + if status_label != null: + status_label.text = message + status_label.add_theme_color_override("font_color", Color("a14d43")) + for slider in sliders: + slider.editable = false + _refresh_controls() + peer.disconnect_from_host() + push_error(message) + if smoke_steps > 0 or not smoke_output.is_empty(): + _finish_smoke() + + +func _finish_smoke() -> void: + if finished: + return + finished = true + completed_us = Time.get_ticks_usec() + peer.disconnect_from_host() + if not screenshot_path.is_empty() and DisplayServer.get_name() != "headless": + await RenderingServer.frame_post_draw + if FileAccess.file_exists(screenshot_path): + errors.append("Existing screenshot was preserved.") + else: + var saved := get_viewport().get_texture().get_image().save_png(screenshot_path) + if saved != OK: + errors.append("Screenshot could not be written.") + var summary := { + "schema": "dvidia.godot-demonstration-smoke/v1", "ok": errors.is_empty(), + "client_script_sha256": FileAccess.get_sha256("res://main.gd"), + "project_sha256": FileAccess.get_sha256("res://project.godot"), + "processor": OS.get_processor_name(), "logical_processors": OS.get_processor_count(), + "expected_model_sha256": scene_data.get("bridge", {}).get("model_sha256", ""), + "scene_integrity_sha256": scene_integrity_sha256, + "step_responses": replies, "reset_responses": resets, "requested_steps": smoke_steps, + "initial_sim_time_seconds": initial_sim_time, "applied_sim_time_seconds": state_time, + "initial_state": initial_state, "recording": recording, "trace": smoke_trace, "trace_limit": 256, + "wall_seconds": (Time.get_ticks_usec() - started_us) / 1000000.0, + "interactive_wall_seconds": (completed_us - ready_us) / 1000000.0 if ready_us > 0 else 0.0, + "process_frame_interval_ms": _distribution(frame_intervals_ms), + "rpc_roundtrip_ms": _distribution(rpc_roundtrip_ms), + "timing_scope": "Startup-inclusive wall time; ready-to-completion interactive wall; Godot process delta intervals (not GPU render time); socket request-to-parsed-response latency; server compute includes controller, native camera capture and disk writes; excludes IPC and Godot rendering. Samples capped at 10000 per series.", + "server_compute_total_ms": compute_total_ms, "render_process_frames": frame_count, + "physics_authority": "MuJoCo only", "godot": Engine.get_version_info().string, + "errors": errors, + } + if not smoke_output.is_empty(): + if FileAccess.file_exists(smoke_output): + push_error("Existing smoke output was preserved.") + get_tree().quit(1) + return + var output := FileAccess.open(smoke_output, FileAccess.WRITE) + if output == null: + push_error("Cannot write smoke output.") + get_tree().quit(1) + return + output.store_string(JSON.stringify(summary, " ") + "\n") + var compact := summary.duplicate() + compact.erase("initial_state") + compact.erase("trace") + print(JSON.stringify(compact)) + get_tree().quit(0 if errors.is_empty() else 1) + + +func _distribution(values: Array[float]) -> Dictionary: + if values.is_empty(): + return {"samples": 0, "p50": null, "p95": null} + var ordered := values.duplicate() + ordered.sort() + return {"samples": values.size(), "p50": ordered[int(floor((ordered.size() - 1) * 0.5))], "p95": ordered[int(ceil((ordered.size() - 1) * 0.95))]} diff --git a/integrations/godot-demonstration/main.tscn b/integrations/godot-demonstration/main.tscn new file mode 100644 index 0000000..7b3efcb --- /dev/null +++ b/integrations/godot-demonstration/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://main.gd" id="1"] + +[node name="DvidiaDemonstration" type="Node3D"] +script = ExtResource("1") diff --git a/integrations/godot-demonstration/project.godot b/integrations/godot-demonstration/project.godot new file mode 100644 index 0000000..8e0c111 --- /dev/null +++ b/integrations/godot-demonstration/project.godot @@ -0,0 +1,23 @@ +; DVIDIA Godot demonstration recorder. Rendering and interface only; MuJoCo owns physics. +config_version=5 + +[application] +config/name="DVIDIA · Demonstration studio" +run/main_scene="res://main.tscn" + +[display] +window/size/viewport_width=1280 +window/size/viewport_height=800 +window/size/window_width_override=1280 +window/size/window_height_override=800 +window/stretch/mode="canvas_items" + +[rendering] +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +anti_aliasing/quality/msaa_3d=2 +textures/default_filters/use_nearest_mipmap_filter=false +environment/defaults/default_clear_color=Color(0.94, 0.94, 0.90, 1) + +[physics] +common/physics_ticks_per_second=50 diff --git a/integrations/godot-demonstration/tests/test_coordinates.gd b/integrations/godot-demonstration/tests/test_coordinates.gd new file mode 100644 index 0000000..44aa191 --- /dev/null +++ b/integrations/godot-demonstration/tests/test_coordinates.gd @@ -0,0 +1,31 @@ +extends SceneTree +## Run: godot --headless --path integrations/godot-demonstration --script tests/test_coordinates.gd + +const Workstation = preload("res://main.gd") + + +func _init() -> void: + var checks := 0 + for pair in [ + [[1, 0, 0], Vector3.RIGHT], [[0, 1, 0], Vector3(0, 0, -1)], + [[0, 0, 1], Vector3.UP], [[0.3, -0.2, 0.7], Vector3(0.3, 0.7, 0.2)], + ]: + assert(Workstation.native_vector(pair[0]).is_equal_approx(pair[1])) + checks += 1 + var converted := Workstation.native_basis([cos(PI / 4), 0, 0, sin(PI / 4)]) + assert((converted * Vector3.RIGHT).is_equal_approx(Vector3(0, 0, -1))) + assert(is_equal_approx(converted.determinant(), 1.0)) + checks += 2 + # body(R * local + p) must equal C*body(R*local+p), with no double axis + # conversion and no parent-body transformation of an already-world pose. + var native_rotation := Basis(Quaternion(Vector3(0.3, 0.7, -0.2).normalized(), 0.81)) + var q := native_rotation.get_rotation_quaternion() + var mapped_rotation := Workstation.native_basis([q.w, q.x, q.y, q.z]) + var local := Vector3(0.12, -0.03, 0.08) + var position := Vector3(0.26, -0.14, 0.22) + var native_result := native_rotation * local + position + var godot_result := mapped_rotation * Workstation.native_vector([local.x, local.y, local.z]) + Workstation.native_vector([position.x, position.y, position.z]) + assert(godot_result.distance_to(Workstation.native_vector([native_result.x, native_result.y, native_result.z])) < 0.000001) + checks += 1 + print(JSON.stringify({"schema": "dvidia.godot-demonstration-coordinates-test/v1", "passed": checks})) + quit(0) diff --git a/integrations/godot-demonstration/tests/test_recording.gd b/integrations/godot-demonstration/tests/test_recording.gd new file mode 100644 index 0000000..994611f --- /dev/null +++ b/integrations/godot-demonstration/tests/test_recording.gd @@ -0,0 +1,60 @@ +extends SceneTree +## Recorder contract assertions without a socket or an imported robot scene. + +const Demonstration = preload("res://main.gd") + + +func episode(status: String = "ready", steps: int = 0) -> Dictionary: + return {"status": status, "steps": steps, "max_steps": 3, "source_kind": "simulation_scripted_input", "output_directory": "/tmp/dvidia-test/episode-001"} + + +func _init() -> void: + var checks := 0 + assert(Demonstration.SCHEMA == "dvidia.godot-demonstration/v1") + assert(Demonstration.valid_recording(episode())) + checks += 2 + for invalid in [null, {}, {"status": "training"}, episode("ready", -1), episode("recording", 4)]: + assert(not Demonstration.valid_recording(invalid)) + checks += 1 + for replacement in [{"steps": 0.5}, {"steps": NAN}, {"max_steps": 0}, {"source_kind": "human"}, {"output_directory": "relative/episode"}]: + var invalid := episode() + invalid.merge(replacement, true) + assert(not Demonstration.valid_recording(invalid)) + checks += 1 + var ready := episode() + var recording := episode("recording") + var first := episode("recording", 1) + var paused := episode("paused", 1) + var finished := episode("finished", 1) + for transition in [[{}, ready, "hello"], [ready, recording, "begin"], [recording, first, "step"], [first, paused, "pause"], [paused, first, "resume"], [paused, finished, "finish"], [finished, finished, "finish"], [episode("recording", 2), episode("finished", 3), "step"]]: + assert(Demonstration.recording_transition_error(transition[0], transition[1], transition[2]).is_empty()) + checks += 1 + for transition in [[ready, first, "begin"], [first, episode("paused", 2), "pause"], [first, finished, "step"], [first, ready, "reset"]]: + assert(not Demonstration.recording_transition_error(transition[0], transition[1], transition[2]).is_empty()) + checks += 1 + var new_episode := episode() + new_episode.output_directory = "/tmp/dvidia-test/episode-002" + assert(Demonstration.recording_transition_error(finished, new_episode, "reset").is_empty()) + assert(not Demonstration.recording_transition_error(first, new_episode, "step").is_empty()) + checks += 2 + # Programmatic Home/smoke commands must use the slider's clamped value. + var client := Demonstration.new() + var slider := HSlider.new() + slider.min_value = 0.1 + slider.max_value = 0.5 + slider.step = 0.001 + client.sliders.append(slider) + client.value_labels.append(Label.new()) + client.marker = MeshInstance3D.new() + client._set_slider(0, 0.55) + assert(is_equal_approx(client.target[0], 0.5)) + client._set_slider(0, -0.01) + assert(is_equal_approx(client.target[0], 0.1)) + assert(is_equal_approx(client.marker.position.x, 0.1)) + checks += 3 + slider.free() + client.value_labels[0].free() + client.marker.free() + client.free() + print(JSON.stringify({"schema": "dvidia.godot-demonstration-recording-test/v1", "passed": checks})) + quit(0) diff --git a/pyproject.toml b/pyproject.toml index 39e625e..12805ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ dvidia-skill-capsule = "dvidia_training.arm_entrypoints:capsule_main" dvidia-skillspace-coverage = "dvidia_training.coverage:main" dvidia-episode-tape = "dvidia_training.episode_tape:main" dvidia-action-demo = "dvidia_training.action_demonstration:main" +dvidia-demonstrate = "dvidia_training.godot_demonstration_bridge:main" +dvidia-demonstration-export = "dvidia_training.operator_export:main" dvidia-piper-capture = "dvidia_training.piper_action_capture:main" dvidia-reaction = "dvidia_training.reaction_benchmark:main" dvidia-motor-memory = "dvidia_training.motor_benchmark:main" diff --git a/scripts/check-operator-lifecycle.py b/scripts/check-operator-lifecycle.py new file mode 100644 index 0000000..f0853de --- /dev/null +++ b/scripts/check-operator-lifecycle.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Scripted local TCP lifecycle diagnostic; does not impersonate a person.""" +import argparse +import json +from pathlib import Path +import socket + +from dvidia_training import godot_demonstration_bridge as bridge +from dvidia_training import piper_action_capture as files + + +def check(scene,port): + info=json.loads((Path(scene)/'scene.json').read_text())['bridge'] + trace=[] + with socket.create_connection(('127.0.0.1',port),timeout=10) as connection: + stream=connection.makefile('rb') + def exchange(request): + connection.sendall((json.dumps(request)+'\n').encode()) + line=stream.readline(2*1024*1024) + if not line.endswith(b'\n'):raise ValueError('Truncated response.') + response=json.loads(line);trace.append(dict(request=request,response=response));return response + response=exchange(dict(type='hello',schema=bridge.SCHEMA,model_sha256=info['model_sha256'])) + assert response['recording']['source_kind']=='simulation_scripted_input' + target=info['home_target'] + step=lambda seq:dict(type='step',sequence=seq,target=target,jaw=.035) + assert exchange(step(1))['code']=='recording_status' + assert exchange(dict(type='begin',sequence=1))['state']['time']==0. + assert abs(exchange(step(2))['state']['time']-.02)<1e-10 + assert abs(exchange(dict(type='pause',sequence=3))['state']['time']-.02)<1e-10 + assert exchange(dict(type='ping'))['expected_sequence']==4 + assert exchange(step(4))['code']=='recording_status' + assert abs(exchange(dict(type='resume',sequence=4))['state']['time']-.02)<1e-10 + assert abs(exchange(step(5))['state']['time']-.04)<1e-10 + first=exchange(dict(type='finish',sequence=6)) + assert first['recording']['status']=='finished' and first['recording']['steps']==2 + reset=exchange(dict(type='reset',sequence=7)) + assert reset['state']['time']==0. and reset['recording']['status']=='ready' + assert first['recording']['output_directory']!=reset['recording']['output_directory'] + assert exchange(dict(type='begin',sequence=8))['state']['time']==0. + assert abs(exchange(step(9))['state']['time']-.02)<1e-10 + stream.close() + return dict(schema='dvidia.operator-lifecycle-check/v1',ok=True,trace=trace, + teacher_queries=0,human_demonstrations=0,physical_robot_ready=False, + scope='Scripted localhost control, pause/resume/reset and disconnect; no GUI button clicks.') + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--scene',required=True,type=Path) + parser.add_argument('--port',type=int,default=8431) + parser.add_argument('--output',required=True,type=Path) + args=parser.parse_args();result=check(args.scene,args.port) + files._json(args.output,result) + print(json.dumps(dict(ok=result['ok'],exchanges=len(result['trace']),output=str(args.output)))) + + +if __name__=='__main__':main() diff --git a/scripts/verify-operator-replay.py b/scripts/verify-operator-replay.py new file mode 100644 index 0000000..7a26c24 --- /dev/null +++ b/scripts/verify-operator-replay.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Independently execute recorded issued commands; compare native evidence. + +No teacher, camera rendering, fitting or physical commands. A failed task may +replay exactly: parity is not placement success or skill qualification. +""" +import argparse +from copy import deepcopy +from hashlib import sha256 +import json +from pathlib import Path +import time +from types import SimpleNamespace + +import numpy as np + +from dvidia_training import operator_capture as capture +from dvidia_training import camera_action_capture as camera +from dvidia_training import piper_action_capture as files +from dvidia_training.piper_feedback_env import FeedbackPiperTaskEnv +from dvidia_training.piper_feedback_score import score_feedback_episode +from dvidia_training.piper_task import PiperTaskConfig + + +def verify(assets,directory): + root=Path(directory) + inspection=capture.inspect_capture(root) + recording=capture.load_capture(root) + report=capture._read(root,'capture.json') + original=capture._read(root,'native_episode.json') + if report['uncommitted_native_intervals']: + raise ValueError('Only committed-prefix native evidence can be replayed by this verifier.') + config=PiperTaskConfig(**original['config']) + env=FeedbackPiperTaskEnv(assets,config) + began=time.perf_counter() + observation,info=env.reset(report['case']['seed']) + states=[deepcopy(observation)];actions=[] + for step in recording['steps']: + action=deepcopy(step['command']['issued']) + action_row=dict(t_seconds=step['before']['timestamp_seconds'],**action,phase=report['source_kind']) + states[-1].update(issued_joint_targets_rad=action['joint_targets_rad'], + issued_gripper_position_m=action['gripper_position_m'],command=action_row) + observation,info=env.step(action) + actions.append(action_row);states.append(deepcopy(observation)) + replay=camera._native_episode(SimpleNamespace(info=info,env=env,task_config=config),states,actions) + arrays=env.native_arrays();metadata=env.native_metadata() + comparison={};values=0;maximum=0.;exact=True + with np.load(root/'native.npz',allow_pickle=False) as saved: + if set(saved.files)!=set(arrays):raise ValueError('Native evidence field inventory differs.') + for key,actual in arrays.items(): + expected=saved[key] + if actual.shape!=expected.shape:raise ValueError('Native evidence shape differs: '+key) + error=float(np.max(np.abs(actual-expected))) if actual.size else 0. + matched=bool(np.array_equal(actual,expected)) + comparison[key]=dict(shape=list(actual.shape),max_abs_error=error,exact=matched) + values+=actual.size;maximum=max(maximum,error);exact=exact and matched + score=score_feedback_episode(replay,arrays,metadata,report['case']['goal_position_m'][:2]) + matches_score=score==report['score'] + return dict(schema='dvidia.operator-native-replay/v1',exact_native_arrays=exact, + native_values_compared=int(values),maximum_abs_error=maximum,fields=comparison, + independent_score_matches=matches_score,score=score, + original_strict_success=inspection['strict_success'],control_intervals=len(recording['steps']), + simulated_seconds=env.elapsed,wall_seconds=time.perf_counter()-began, + recording_sha256=sha256((root/'recording.json').read_bytes()).hexdigest(), + capture_integrity_sha256=sha256((root/'integrity.json').read_bytes()).hexdigest(), + source_kind=report['source_kind'],physical_robot_ready=False,teacher_queries=0, + scope='CPU native command replay including reset; excludes camera, Godot, fitting and hardware. ' + 'Native parity is separate from successful task completion.') + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--assets',required=True,type=Path) + parser.add_argument('--capture',required=True,type=Path) + parser.add_argument('--output',required=True,type=Path) + args=parser.parse_args() + result=verify(args.assets,args.capture) + files._json(args.output,result) + print(json.dumps({key:result[key] for key in ('exact_native_arrays','native_values_compared', + 'maximum_abs_error','independent_score_matches','control_intervals','simulated_seconds')},indent=2)) + return 0 if result['exact_native_arrays'] and result['independent_score_matches'] else 1 + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/src/dvidia_training/godot_demonstration_bridge.py b/src/dvidia_training/godot_demonstration_bridge.py new file mode 100644 index 0000000..1b0239c --- /dev/null +++ b/src/dvidia_training/godot_demonstration_bridge.py @@ -0,0 +1,346 @@ +"""Local, externally controlled PiPER block recording; no hardware commands. + +Godot is a view/controller. The camera-action native environment owns 1 ms +physics, 50 Hz commands and 25 Hz RGB-D. This protocol is separate from the +2 ms shoe workstation and never relabels its recordings. +""" +from __future__ import annotations + +import argparse +from copy import deepcopy +from hashlib import sha256 +import json +from pathlib import Path +import shutil +import socket +import time + +from . import asset_preview, camera_action_capture as camera, godot_bridge as wire +from . import reference_replay, reference_sim + +SCHEMA = 'dvidia.godot-demonstration/v1' +DEFAULT_PORT = 8431 +BOUNDS = ((.10, .50), (-.25, .25), (.06, .55)) +DEFAULT_CASE = dict(id='operator-block-001', group_id='operator-start-001', seed=0, + object_position_m=[.30, -.03, .082], goal_position_m=[.35, .08, .08]) +SCOPE = ('Simulation demonstration capture only. Fixed-orientation XYZ plus single-finger ' + 'target; 1 ms MuJoCo physics, 50 Hz controls, 25 Hz native RGB-D. ' + 'No hand tracking, trained policy, physical commands or hardware qualification.') +TIMING_SCOPE = 'Paused-simulator computation and capture; not physical reaction latency.' +MAX_EPISODES = 16 +RESERVE_BYTES = 10 * 1024**3 + + +def _case(path): + return camera._case(DEFAULT_CASE if path is None else json.loads(Path(path).read_text())) + + +def _state(native, target): + """Privileged viewer state. It is never an input to the pose controller.""" + env, mj, np = native.env, native.mj, native.np + quaternion = np.empty(4) + mj.mju_mat2Quat(quaternion, env.data.site_xmat[env.tcp]) + return dict(time=float(env.elapsed), native_time=float(env.data.time), + body_positions=env.data.xpos.tolist(), body_quaternions=env.data.xquat.tolist(), + qpos=env.data.qpos.tolist(), ctrl=env.data.ctrl.tolist(), + tcp_position=env.data.site_xpos[env.tcp].tolist(), tcp_quaternion=quaternion.tolist(), + object_position=env.data.xpos[env.block].tolist(), + position_error_m=float(np.linalg.norm(env.data.site_xpos[env.tcp]-target)), + scope='Viewer state includes native truth; excluded from learning inputs.') + + +def prepare(assets, output, *, case=None, duration_s=20.): + case = camera._case(DEFAULT_CASE if case is None else case) + config = camera.RolloutConfig(duration_s=duration_s, seed=case['seed']) + destination = Path(output).absolute() + if destination.exists() or destination.is_symlink() or any(p.is_symlink() for p in destination.parents): + raise ValueError('Prepared scene requires a fresh ordinary directory.') + provenance, notices = asset_preview._licenses('piper', assets) + native = camera._Session(assets, config, case) + try: + header, _, metadata = native.open() + model = native.env.model + digest = reference_sim.model_digest(model) + scene, geometry = asset_preview._scene(model, dict(robot='piper', assets=provenance, + mujoco=native.mj.__version__, compiled_model_sha256=digest)) + home = native.env.data.site_xpos[native.env.tcp].tolist() + if any(not low <= value <= high for value, (low, high) in zip(home, BOUNDS)): + raise ValueError('Initial TCP lies outside the declared operator envelope.') + scene['scope'] = SCOPE + scene['bridge'] = dict(schema=SCHEMA, model_sha256=digest, home_target=home, + bounds=[list(p) for p in BOUNDS], gripper_initial=.035, physics_dt=.001, + control_dt=.02, fixed_orientation_wxyz=list(native.task_config.tool_orientation_wxyz), + case=case, duration_s=duration_s, calibration_sha256=header['binding']['calibration_sha256'], + scope=SCOPE) + scene['geometry_binary'] = dict(href='geometry.bin', compression='none', + bytes=len(geometry), sha256=sha256(geometry).hexdigest()) + files = {'scene.json': wire._json(scene), 'geometry.bin': geometry, + 'LICENSE': reference_replay.MIT_LICENSE.encode(), **notices} + files['README.md'] = ('# DVIDIA demonstration scene\n\n'+SCOPE+'\n\n' + 'Prepared using the native block task reset/settling, not a zero-physics export. ' + 'Training RGB-D comes from the native calibrated camera, not the Godot viewport. ' + 'RGB-D calibration is simulated; it does not qualify physical transfer. ' + 'The upstream PiPER notices are in licenses/. No model weights are included.\n').encode() + seal = dict(schema=wire.INTEGRITY_SCHEMA, model_sha256=digest, + files={name:dict(bytes=len(body), sha256=sha256(body).hexdigest()) for name, body in files.items()}) + files['scene-integrity.json'] = wire._json(seal) + if (any(len(body)>asset_preview.MAX_FILE_BYTES for body in files.values()) + or sum(map(len,files.values()))>asset_preview.MAX_TOTAL_BYTES): + raise ValueError('Prepared geometry exceeds the existing export budget.') + destination.mkdir(parents=True, exist_ok=False) + for name, body in files.items(): + path = destination/name + path.parent.mkdir(parents=True, exist_ok=True) + with path.open('xb') as stream: + stream.write(body) + return dict(schema=SCHEMA, output=str(destination), model_sha256=digest, + bytes=sum(map(len,files.values())), native_reset_performed=True, + task_control_steps=0, physical_robot_ready=False, runtime=metadata['runtime']) + finally: + native.close() + + +class Session: + """One connection, bounded new episodes; no command loop without a client.""" + def __init__(self, factory, controller, *, model_sha256, home_target, max_episodes=4, + state_reader=_state): + if type(max_episodes) is not int or not 1 <= max_episodes <= MAX_EPISODES: + raise ValueError('Episode budget must be in [1,16].') + self.factory, self.controller, self.state_reader = factory, controller, state_reader + self.model_sha256, self.home_target = model_sha256, list(home_target) + self.max_episodes, self.episodes = max_episodes, 0 + self.expected_sequence, self.ready, self.faulted = 1, False, False + self.status, self.capture, self.last_report = 'ready', None, None + self.target, self.jaw = list(home_target), .035 + self.steps, self.max_steps = 0, 0 + self.controller_sources = {name:sha256((Path(__file__).parent/name).read_bytes()).hexdigest() + for name in ('godot_demonstration_bridge.py','piper_encoder_kinematics.py')} + self._new_capture() + + def _new_capture(self): + if self.episodes >= self.max_episodes: + raise wire.ProtocolError('Episode budget exhausted; start a new explicitly bounded session.', 'episode_budget') + capture = self.factory(self.episodes) + try: + opened = capture.open() + if opened['header']['binding']['model_sha256'] != self.model_sha256: + raise ValueError('Prepared and recording model digests differ.') + except BaseException: + capture.finish(reason='initialization_failed') + raise + self.capture = capture + self.episodes += 1 + self.max_steps = round(capture.config.duration_s/.02) + self.steps, self.status, self.last_report = 0, 'ready', None + self.target, self.jaw = list(self.home_target), .035 + self.last_state = self.state_reader(capture.session, self.target) + + def recording(self): + return dict(status=self.status, steps=self.steps, max_steps=self.max_steps, + source_kind=self.capture.source_kind, output_directory=str(self.capture.root), + episode=self.episodes, max_episodes=self.max_episodes, + physical_robot_ready=False, source_authenticated=False) + + def error(self, exc): + return dict(type='error', code=exc.code, message=str(exc), expected_sequence=self.expected_sequence) + + def _finish(self, reason): + report = self.capture.finish(reason=reason) + self.last_report = report + if (report.get('sealed') is not True or report.get('error') is not None + or report.get('native_evidence_saved') is not True): + raise RuntimeError('Capture could not seal complete native evidence; inspect retained files.') + return report + + def process(self, payload): + try: + return self.handle(wire.decode_request(payload)) + except wire.ProtocolError as exc: + return self.error(exc) + + def handle(self, request): + if self.faulted: + raise wire.ProtocolError('Session faulted; retained evidence may include an incomplete interval.', 'session_fault') + kind = request.get('type') + if not self.ready: + wire._fields(request, ('type','schema','model_sha256')) + if kind != 'hello' or request['schema'] != SCHEMA or request['model_sha256'] != self.model_sha256: + raise wire.ProtocolError('Prepared model and demonstration protocol must match.', 'handshake_required') + self.ready = True + return dict(type='ready', schema=SCHEMA, model_sha256=self.model_sha256, + physics_dt=.001, control_dt=.02, state=self.last_state, recording=self.recording(), scope=SCOPE) + if kind == 'ping': + wire._fields(request, ('type',)) + return dict(type='pong', model_sha256=self.model_sha256, expected_sequence=self.expected_sequence) + if kind not in ('begin','step','pause','resume','finish','reset'): + raise wire.ProtocolError('Unknown demonstration command.') + wire._fields(request, ('type','sequence','target','jaw') if kind=='step' else ('type','sequence')) + sequence = request['sequence'] + if type(sequence) is not int or sequence != self.expected_sequence or sequence > wire.MAX_SEQUENCE: + raise wire.ProtocolError('Expected the next command sequence.', 'sequence_mismatch') + allowed = {'begin':('ready',),'step':('recording',),'pause':('recording',), + 'resume':('paused',),'finish':('ready','recording','paused','finished'), + 'reset':('ready','recording','paused','finished')} + if self.status not in allowed[kind]: + raise wire.ProtocolError('Command does not match recording status.', 'recording_status') + if kind=='reset' and self.episodes >= self.max_episodes: + raise wire.ProtocolError('Episode budget exhausted.', 'episode_budget') + began = time.perf_counter() + target, jaw, action = self.target, self.jaw, None + if kind=='step': + if type(request['target']) is not list or len(request['target']) != 3: + raise wire.ProtocolError('Target requires three coordinates.') + target = [wire._number(x,*bounds,'Target') for x,bounds in zip(request['target'],BOUNDS)] + jaw = wire._number(request['jaw'],0.,.035,'Single-finger target') + # Only measured encoder state is provided to this isolated FK/IK + # controller; never the viewer's native object or contact truth. + try: + joints = self.capture.context()['snapshot']['signals']['joint_position_rad']['value'] + action = self.controller(joints,target,jaw) + except ValueError as exc: + raise wire.ProtocolError('Encoder-to-target mapping refused: '+str(exc),'control_refused') from exc + try: + if kind=='begin': + self.capture.record_event('begin') + self.status='recording' + elif kind=='pause': + self.capture.record_event('pause') + self.status='paused' + elif kind=='resume': + self.capture.record_event('resume') + self.status='recording' + elif kind=='step': + result = self.capture.step(action, input_event=dict(schema=SCHEMA, + sequence=sequence, target_world_m=target, gripper_single_finger_m=jaw, + input='fixed_orientation_cartesian', controller='encoder_only_dls', + controller_sources_sha256=self.controller_sources)) + self.steps += 1 + self.target,self.jaw = target,jaw + self.last_state = self.state_reader(self.capture.session,target) + if result['done']: + self._finish('capture_budget_complete') + self.status='finished' + elif kind=='finish': + if self.status!='finished': + self._finish('operator_stopped') + self.status='finished' + elif kind=='reset': + if self.status!='finished': + self.capture.record_event('reset') + self._finish('operator_reset') + self._new_capture() + except BaseException as exc: + self.faulted=True + try: + self.capture.finish(reason='session_fault') + except Exception: + pass # Original prefix remains recoverable; never retry a command. + if isinstance(exc,(KeyboardInterrupt,SystemExit)): + raise + raise wire.ProtocolError('Recording failed; inspect the retained prefix before starting a new episode.', 'recording_fault') from exc + self.expected_sequence += 1 + return dict(type='state',sequence=sequence,model_sha256=self.model_sha256, + state=self.last_state,recording=self.recording(), + step_compute_wall_seconds=time.perf_counter()-began,timing_scope=TIMING_SCOPE) + + def disconnect(self): + if self.status!='finished': + try: + self.capture.record_event('disconnect') + self._finish('client_disconnected') + self.status='finished' + except Exception: + self.faulted=True + self.status='faulted' + self.capture.session.close() + + +def serve(assets, scene, output, *, port=DEFAULT_PORT, duration_s=None, + source_kind='simulation_human_input', max_episodes=4): + from .operator_capture import OperatorCapture, SOURCE_KINDS + from .piper_encoder_kinematics import PiperEncoderKinematics + if type(port) is not int or not 1024 <= port <= 65535: + raise ValueError('Port must be an integer in [1024,65535].') + if source_kind not in SOURCE_KINDS: + raise ValueError('Unknown capture source kind.') + if type(max_episodes) is not int or not 1 <= max_episodes <= MAX_EPISODES: + raise ValueError('Episode budget must be in [1,16].') + scene = Path(scene) + seal = json.loads((scene/'scene-integrity.json').read_text()) + body = (scene/'scene.json').read_bytes() + if seal.get('files',{}).get('scene.json') != dict(bytes=len(body),sha256=sha256(body).hexdigest()): + raise ValueError('Prepared scene identity differs.') + bridge = json.loads(body)['bridge'] + if bridge['schema'] != SCHEMA or bridge['physics_dt'] != .001 or bridge['control_dt'] != .02: + raise ValueError('Unsupported prepared scene protocol or clocks.') + duration_s = bridge['duration_s'] if duration_s is None else duration_s + if duration_s != bridge['duration_s']: + raise ValueError('Duration must match prepared calibration; prepare a fresh scene for a different duration.') + config = camera.RolloutConfig(duration_s=duration_s, seed=bridge['case']['seed']) + root=Path(output).absolute() + if root.exists() or root.is_symlink() or any(p.is_symlink() for p in root.parents): + raise ValueError('Session output must be a fresh ordinary directory.') + parent=root.parent + while not parent.exists(): parent=parent.parent + upper=(round(duration_s/.04)+1)*(320*240*7+4096)+32*1024**2 + if shutil.disk_usage(parent).free < RESERVE_BYTES+max_episodes*upper: + raise ValueError('Capture capacity guard: preserve 10 GiB plus the declared episode budget.') + kinematics=PiperEncoderKinematics(assets) + def controller(joints,target,jaw): + return kinematics.pose_action(joints,target,bridge['fixed_orientation_wxyz'],jaw) + root.mkdir(parents=True,exist_ok=False) + def factory(index): + case=deepcopy(bridge['case']);case['id'] += '-episode-'+str(index+1) + return OperatorCapture(assets,root/f'episode-{index+1:04}',case=case, + duration_s=duration_s,source_kind=source_kind) + session=None + with socket.socket(socket.AF_INET,socket.SOCK_STREAM) as listener: + listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) + listener.bind((wire.HOST,port));listener.listen(1) + print(json.dumps(dict(schema=SCHEMA,status='listening',host=wire.HOST,port=port, + output=str(root),source_kind=source_kind,max_episodes=max_episodes)),flush=True) + try: + connection,_=listener.accept() + with connection: + connection.setsockopt(socket.IPPROTO_TCP,socket.TCP_NODELAY,1) + connection.settimeout(wire.IDLE_TIMEOUT_SECONDS) + session=Session(factory,controller,model_sha256=bridge['model_sha256'], + home_target=bridge['home_target'],max_episodes=max_episodes) + wire.handle_connection(connection,session) + finally: + if session is not None: session.disconnect() + + +def main(argv=None): + parser=argparse.ArgumentParser(description=__doc__) + sub=parser.add_subparsers(dest='command',required=True) + pre=sub.add_parser('prepare');pre.add_argument('--assets',required=True,type=Path) + pre.add_argument('--output',required=True,type=Path);pre.add_argument('--case',type=Path) + pre.add_argument('--duration',type=float,default=20.) + run=sub.add_parser('serve');run.add_argument('--assets',required=True,type=Path) + run.add_argument('--scene',required=True,type=Path);run.add_argument('--output',required=True,type=Path) + run.add_argument('--port',type=int,default=DEFAULT_PORT) + run.add_argument('--source-kind',choices=('simulation_human_input','simulation_scripted_input'),default='simulation_human_input') + run.add_argument('--max-episodes',type=int,default=4) + inspect=sub.add_parser('inspect');inspect.add_argument('directory',type=Path) + recover=sub.add_parser('recover');recover.add_argument('directory',type=Path) + args=parser.parse_args(argv) + try: + if args.command=='prepare': + result=prepare(args.assets,args.output,case=_case(args.case),duration_s=args.duration) + elif args.command=='serve': + serve(args.assets,args.scene,args.output,port=args.port,source_kind=args.source_kind,max_episodes=args.max_episodes) + return 0 + else: + from .operator_capture import inspect_capture,recover_capture + result=(inspect_capture if args.command=='inspect' else recover_capture)(args.directory) + print(json.dumps(result,indent=2)) + except KeyboardInterrupt: + return 0 + except (ValueError,OSError,RuntimeError) as exc: + parser.exit(2,str(exc)+'\n') + return 0 + + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/src/dvidia_training/operator_capture.py b/src/dvidia_training/operator_capture.py new file mode 100644 index 0000000..5976607 --- /dev/null +++ b/src/dvidia_training/operator_capture.py @@ -0,0 +1,749 @@ +"""Stateful external-command recording for the bounded native PiPER task. + +The public ``session`` is available for scene/model display binding only. The +caller supplies every seven-target command; this module never calls a teacher. +Human input is a producer assertion, not authenticated human provenance. +""" +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict +from hashlib import sha256 +import io +import math +import os +from pathlib import Path +import time + +from . import action_demonstration as tape +from . import camera_action_capture as camera +from . import piper_action_capture as base +from . import piper_rgbd_tracker as tracker + +SCHEMA = 'dvidia.operator-capture/v1' +PERCEPTION_SCHEMA = SCHEMA + '/perception' +JOURNAL_SCHEMA = SCHEMA + '/journal' +SOURCE_KINDS = ('simulation_human_input', 'simulation_scripted_input') +MAX_DOCUMENT_BYTES = 64 * 1024 * 1024 +MAX_JOURNAL_BYTES = 16 * 1024 * 1024 +MAX_EVENTS = 10000 +SOURCE_FILES = ('operator_capture.py', 'camera_action_capture.py', 'piper_action_capture.py', + 'action_demonstration.py', 'piper_rgbd_tracker.py', 'piper_feedback_env.py', + 'piper_feedback_score.py', 'piper_task.py', 'piper_imitation_score.py', + 'piper_encoder_kinematics.py') +SCOPE = ('Native simulation with externally issued targets, 1 ms physics, ' + '50 Hz controls and native 25 Hz 320x240 RGB-D. Input provenance is ' + 'a producer assertion; no human authentication or hardware qualification.') + + +def _read(root, name, maximum=MAX_DOCUMENT_BYTES): + return tape._decode(tape._ordinary_bytes(root / str(tape._relative(name)), maximum)) + + +def _reference(root, name, document): + result = base._json(root / name, document) + result['path'] = name + return result + + +def _identity(root, name): + body = tape._ordinary_bytes(root / str(tape._relative(name)), MAX_DOCUMENT_BYTES) + return dict(bytes=len(body), sha256=sha256(body).hexdigest()) + + +def _detail(value): + if value is not None and type(value) is not dict: + raise ValueError('Input/event metadata must be a JSON object or null.') + body = tape.canonical(value) + if len(body) > 4096: + raise ValueError('Input/event metadata exceeds 4096 bytes.') + return tape._decode(body) + + +def _header(header): + if (header.get('schema') != SCHEMA or header.get('control_mode') != 'operator' + or header.get('provenance', {}).get('kind') not in SOURCE_KINDS + or header.get('source_authenticated') is not False + or header.get('human_authenticated') is not False): + raise ValueError('Invalid operator source provenance.') + # Validate unchanged native contracts using the strict v1 header validator. + # This local adapter is never serialized or used as a supervision label. + native = {name: deepcopy(header[name]) for name in tape.HEADER} + native['schema'] = tape.SCHEMA + native['provenance'].pop('case_id', None) + native['provenance']['kind'] = 'simulation_teleoperation' + tape._header(native) + if header.get('camera_cadence_seconds') != .04: + raise ValueError('Operator camera cadence must be 25 Hz.') + tape._sha(header.get('tracker_sha256'), 'tracker hash') + sources = header.get('operator_implementation_sha256') + if type(sources) is not dict or set(sources) != set(SOURCE_FILES): + raise ValueError('Operator implementation source identities are required.') + for name, value in sources.items(): + tape._sha(value, name) + if (header['provenance']['producer_revision'] != sources['operator_capture.py'] + or header['tracker_sha256'] != sources['piper_rgbd_tracker.py']): + raise ValueError('Operator/tracker source identity differs from implementation receipt.') + + +def _snapshot(snapshot, header, index, previous=None): + tape._snapshot(snapshot, header['calibration'], previous) + if not math.isclose(snapshot['timestamp_seconds'], index*tape.DT, + abs_tol=tape.CLOCK_TOL, rel_tol=0): + raise ValueError('Operator boundary must be a complete 20 ms interval.') + if any(snapshot['signals'][name]['status'] != 'missing' for name in tracker.SIGNALS): + raise ValueError('Raw snapshots cannot contain privileged object poses or tracker substitutions.') + frame = snapshot['camera']['value'] + if frame is None or frame['index'] != index//2: + raise ValueError('Camera frame cadence differs from 25 Hz.') + if not math.isclose(snapshot['camera']['captured_at_seconds'], (index//2)*.04, + abs_tol=tape.CLOCK_TOL, rel_tol=0): + raise ValueError('Camera acquisition time differs from native cadence.') + + +def _command(command, header, snapshot): + tape._keys(command, {'issued_at_seconds', 'requested', 'issued', 'authority', + 'operator_correction', 'issue_reason'}, 'operator command') + if (command['authority'] != header['provenance']['kind'] + or command['operator_correction'] is not False or command['issue_reason'] != 'unchanged' + or command['requested'] != command['issued'] + or command['issued_at_seconds'] != snapshot['timestamp_seconds']): + raise ValueError('Operator issued command authority differs.') + tape._action(command['issued'], header, 'issued command') + + +def _perception(root, reference, snapshot, header, index): + expected = {key: reference[key] for key in ('bytes', 'sha256')} + if _identity(root, reference['path']) != expected: + raise ValueError('Perception sidecar byte identity differs.') + item = _read(root, reference['path']) + if (item.get('schema') != PERCEPTION_SCHEMA or item.get('index') != index + or item.get('snapshot_sha256') != tape.digest(snapshot) + or item.get('frame') != snapshot['camera']['value'] + or item.get('tracker_sha256') != header['tracker_sha256'] + or item.get('calibration_sha256') != header['binding']['calibration_sha256'] + or item.get('input_calibration_sha256') != header['binding']['calibration_sha256']): + raise ValueError('Perception source snapshot/frame/calibration binding differs.') + estimate = item['estimate']; sensed = snapshot['camera'] + if (estimate.get('decision_at_seconds') != snapshot['timestamp_seconds'] + or estimate.get('frame_index') != snapshot['vision_frame_index'] + or estimate.get('status') not in ('available', 'missing', 'stale', 'invalid', 'refused')): + raise ValueError('Perception decision time or source frame differs.') + if estimate['status'] == 'available': + for field in ('captured_at_seconds', 'available_at_seconds', 'age_seconds'): + if estimate.get(field) != sensed[field]: + raise ValueError('Perception acquisition timing differs.') + for name in tracker.SIGNALS: + signal = estimate['signals'][name] + tape._availability(signal, snapshot['timestamp_seconds'], name) + expected_value = estimate[name] + if 'uncertainty' in name: + expected_value = [expected_value] + if (signal['status'] != 'available' or signal['value'] != expected_value + or any(signal[field] != estimate[field] for field in + ('captured_at_seconds', 'available_at_seconds', 'age_seconds'))): + raise ValueError('Perception signal value or timing differs.') + tape._vec(signal['value'], tape.SIGNALS[name]['size'], name) + if 'uncertainty' in name and signal['value'][0] < 0: + raise ValueError('Perception uncertainty cannot be negative.') + if 'quaternion' in name and not math.isclose(math.hypot(*signal['value']), 1., abs_tol=1e-6): + raise ValueError('Perception quaternion must be normalized.') + elif any(estimate.get(name) is not None or estimate.get('signals', {}).get(name) != base._missing() + for name in tracker.SIGNALS): + raise ValueError('Unavailable perception must preserve explicit missing masks.') + return item + + +class OperatorCapture: + """One exclusive attempt. ``step`` commits exactly one caller-issued interval. + + ``context`` returns only sensor/perception data, preceding applied targets, + and the declared goal. ``session`` is privileged display access and is not + part of that context. Call ``finish`` on stop/disconnect, including errors. + A failed step seals its committed prefix and re-raises; it never retries. + """ + def __init__(self, assets, output, *, case, duration_s=20., + source_kind='simulation_scripted_input'): + self.case = camera._case(case) + if source_kind not in SOURCE_KINDS: + raise ValueError('Choose simulation_human_input or simulation_scripted_input.') + self.config = camera.RolloutConfig(duration_s=duration_s, seed=self.case['seed']) + self.root = Path(output).absolute() + if any(item.is_symlink() and str(item) not in ('/var', '/tmp') + for item in (self.root, *self.root.parents)): + raise ValueError('Output cannot traverse symlinks.') + self.source_kind = source_kind + self.session = camera._Session(assets, self.config, self.case) + self.header = self.initial = self.snapshot = self.estimate = None + self.previous_applied = self.initial_targets = None + self.steps, self.states, self.actions = [], [], [] + self.started_intervals = self.frame_bytes = self.camera_pairs = 0 + self._journal = None; self._sequence = 0; self._last_hash = None + self._opened = self._sealed = False; self._report = None + self._started_wall = time.monotonic(); self._error = None + self._estimate_timing = {}; self._timings = [] + + @property + def done(self): + return self._sealed or len(self.steps) >= round(self.config.duration_s/tape.DT) + + def _append(self, kind, **payload): + # Reserve one bounded row so event flooding can still seal a refusal. + if self._sequence >= MAX_EVENTS-int(kind != 'finished'): + raise ValueError('Operator journal exceeds its bounded event count.') + record = dict(schema=JOURNAL_SCHEMA, sequence=self._sequence, + previous_sha256=self._last_hash, kind=kind, + simulation_seconds=self.snapshot['timestamp_seconds'] if self.snapshot else 0., + monotonic_wall_seconds=time.monotonic()-self._started_wall, **payload) + body = tape.canonical(record)+b'\n' + if self._journal.tell()+len(body) > MAX_JOURNAL_BYTES-(1024 if kind != 'finished' else 0): + raise ValueError('Operator journal exceeds its byte budget.') + self._journal.write(body); self._journal.flush(); os.fsync(self._journal.fileno()) + self._sequence += 1; self._last_hash = sha256(body).hexdigest() + return record + + def record_event(self, name, detail=None): + if not self._opened or self._sealed: + raise RuntimeError('Events require an open, unfinished capture.') + if type(name) is not str or not name or len(name) > 80: + raise ValueError('Event name must contain 1 to 80 characters.') + return deepcopy(self._append('event', name=name, detail=_detail(detail))) + + def _save_frames(self, frames): + additional = sum(len(body) for body in frames.values()) + if self.frame_bytes+additional > tape.MAX_FRAME_TOTAL: + raise ValueError('Unique native RGB-D frames exceed 512 MiB.') + for name, body in frames.items(): + tape._relative(name) + if not name.startswith('frames/') or len(body) > tape.MAX_FRAME_BYTES: + raise ValueError('Native frame path or size is invalid.') + base._record(self.root/name, body); self.frame_bytes += len(body) + if frames: + self.camera_pairs += 1 + + def _save_perception(self, index): + estimate = camera._estimate_at(self.estimate, self.snapshot['timestamp_seconds']) + return _reference(self.root, f'perception/{index:04}.json', dict( + schema=PERCEPTION_SCHEMA, index=index, snapshot_sha256=tape.digest(self.snapshot), + frame=deepcopy(self.snapshot['camera']['value']), estimate=estimate, + tracker_sha256=self.header['tracker_sha256'], + calibration_sha256=self.header['binding']['calibration_sha256'], + input_calibration_sha256=self._estimate_timing['input_calibration_sha256'])) + + def open(self): + if self._opened or self._sealed: + raise RuntimeError('A capture can only be opened once.') + self.root.mkdir(parents=True, exist_ok=False) + base._json(self.root/'started.json', dict(schema=SCHEMA, status='started', + case=self.case, config=asdict(self.config), source_kind=self.source_kind, scope=SCOPE)) + self._journal = (self.root/'commands.jsonl').open('xb') + self._opened = True + try: + native_header, targets, metadata = self.session.open() + tape._header(native_header) + self.header = deepcopy(native_header) + self.header.update(schema=SCHEMA, control_mode='operator', camera_cadence_seconds=.04, + source_authenticated=False, human_authenticated=False, + tracker_sha256=sha256(Path(tracker.__file__).read_bytes()).hexdigest()) + self.header['provenance'].update(kind=self.source_kind, source_group=self.case['group_id'], + case_id=self.case['id']) + source_hashes = {name: sha256((Path(__file__).parent/name).read_bytes()).hexdigest() + for name in SOURCE_FILES} + self.header['provenance']['producer_revision'] = source_hashes['operator_capture.py'] + self.header['operator_implementation_sha256'] = source_hashes + metadata = deepcopy(metadata) + metadata.update(operator_implementation_sha256=source_hashes, + tracker_implemented=True, operator_corrections_collected=False, + policy_observation_origin='Privileged diagnostics only; excluded from operator context.', + command_origin='External input supplied to OperatorCapture.step; no teacher queries.') + self.header['goal_position_m'] = deepcopy(self.case['goal_position_m']) + self.initial_targets = self.previous_applied = deepcopy(targets) + self.snapshot, frames, diagnostic, timing = self.session.sample(0) + _snapshot(self.snapshot, self.header, 0) + self.initial = deepcopy(self.snapshot) + self.estimate, self._estimate_timing = camera._fresh_estimate( + self.session, self.snapshot, self.header['calibration']) + self._save_frames(frames) + self._perception_ref = self._save_perception(0) + _perception(self.root, self._perception_ref, self.snapshot, self.header, 0) + base._json(self.root/'native/0000.json', diagnostic) + self.states.append(deepcopy(diagnostic['policy_observation'])) + base._json(self.root/'context.json', dict(header=self.header, initial=self.initial, + initial_applied_targets=self.initial_targets, metadata=metadata, case=self.case, + initial_perception=self._perception_ref)) + self._timings.append({**timing, **self._estimate_timing}) + self.record_event('opened') + return dict(context=self.context(), header=deepcopy(self.header), + snapshot=deepcopy(self.snapshot), estimate=deepcopy(self.estimate)) + except (Exception, KeyboardInterrupt) as exc: + self._error = dict(type=type(exc).__name__, message=str(exc)[:500]) + self.finish('open_error') + raise + + def context(self): + if self.snapshot is None or self.estimate is None: + raise RuntimeError('Capture has no initialized sensor boundary.') + return dict(snapshot=deepcopy(self.snapshot), + estimate=camera._estimate_at(self.estimate, self.snapshot['timestamp_seconds']), + previous_applied=deepcopy(self.previous_applied), + goal_position_m=deepcopy(self.case['goal_position_m'])) + + def step(self, action, *, input_event=None): + if not self._opened or self.done: + raise RuntimeError('Capture is not open or has reached its interval budget.') + tape._action(action, self.header, 'operator-issued targets') + action = deepcopy(action); input_event = _detail(input_event) + index = len(self.steps); before = deepcopy(self.snapshot) + command = dict(issued_at_seconds=before['timestamp_seconds'], requested=deepcopy(action), + issued=deepcopy(action), authority=self.source_kind, operator_correction=False, + issue_reason='unchanged') + try: + decision = dict(index=index, command=command, perception=self._perception_ref, + input_event=input_event, snapshot_sha256=tape.digest(before)) + decision_ref = _reference(self.root, f'decisions/{index:04}.json', decision) + self._append('intent', index=index, decision=decision_ref) + self.started_intervals += 1 + applied = self.session.advance(action) + next_index = index+1 + if next_index % 2 == 0: + after, frames, diagnostic, timing = self.session.sample(next_index//2) + estimate, estimate_timing = camera._fresh_estimate( + self.session, after, self.header['calibration']) + else: + after, frames, diagnostic, timing = self.session.measured(before['camera']) + estimate, estimate_timing = self.estimate, self._estimate_timing + _snapshot(after, self.header, next_index, before) + self._save_frames(frames) + tape._action(applied['ramped_targets'], self.header, 'applied targets') + if (applied['interval_start_seconds'] != before['timestamp_seconds'] + or applied['interval_end_seconds'] != after['timestamp_seconds']): + raise ValueError('Native applied interval differs from the observed 20 ms boundary.') + step = dict(index=index, before=before, command=command, applied=deepcopy(applied), + after=deepcopy(after), perception=deepcopy(self._perception_ref), + input_event=input_event, teacher_query=None) + native_ref = _reference(self.root, f'native/{next_index:04}.json', diagnostic) + transition_ref = _reference(self.root, f'transitions/{index:04}.json', step) + # Outcome is the commit marker. A transition without this marker is + # retained as evidence, but is never included in a recovered prefix. + self._append('outcome', index=index, status='committed', + transition=transition_ref, native=native_ref) + self.steps.append(step) + action_row = dict(t_seconds=before['timestamp_seconds'], **action, phase=self.source_kind) + self.states[-1].update(issued_joint_targets_rad=action['joint_targets_rad'], + issued_gripper_position_m=action['gripper_position_m'], command=action_row) + self.actions.append(action_row); self.states.append(deepcopy(diagnostic['policy_observation'])) + self.snapshot = deepcopy(after); self.estimate = estimate; self._estimate_timing = estimate_timing + self.previous_applied = deepcopy(applied['ramped_targets']) + self._timings.append(timing) + self._perception_ref = self._save_perception(next_index) + return dict(snapshot=deepcopy(self.snapshot), estimate=self.context()['estimate'], + applied=deepcopy(applied), done=self.done, context=self.context()) + except (Exception, KeyboardInterrupt) as exc: + self._error = dict(type=type(exc).__name__, message=str(exc)[:500]) + self.finish('producer_interrupted' if isinstance(exc, KeyboardInterrupt) else 'producer_error') + raise + + def _reconcile(self): + """Rebuild counters and native control trace from durable commit markers. + + An interrupt can occur after fsync and before *any* Python assignment, + including the journal sequence/hash cursor. Memory is never the source + of truth for finalization. + """ + self._journal.flush(); os.fsync(self._journal.fileno()) + _, context, steps, intents, _, finished = _prefix(self.root) + entries, _ = _journal(self.root) + body = tape._ordinary_bytes(self.root/'commands.jsonl', MAX_JOURNAL_BYTES) + self._sequence = len(entries) + self._last_hash = sha256(body.splitlines(keepends=True)[-1]).hexdigest() if entries else None + self._journal.seek(0, os.SEEK_END) + self.started_intervals = len(intents) + self.steps = deepcopy(steps) + self.states, self.actions = [], [] + if context is None: + # A sampled frame in memory does not establish an initialized + # recording. Without context there is no bound native initial state. + self.initial = self.snapshot = self.estimate = None + self.initial_targets = self.previous_applied = None + else: + self.header = deepcopy(context['header']); self.initial = deepcopy(context['initial']) + self.initial_targets = deepcopy(context['initial_applied_targets']) + self.states.append(deepcopy(_read(self.root, 'native/0000.json')['policy_observation'])) + outcomes = [entry for entry in entries if entry['kind'] == 'outcome'] + for step, outcome in zip(steps, outcomes): + action = deepcopy(step['command']['issued']) + row = dict(t_seconds=step['before']['timestamp_seconds'], **action, phase=self.source_kind) + self.states[-1].update(issued_joint_targets_rad=action['joint_targets_rad'], + issued_gripper_position_m=action['gripper_position_m'], command=row) + self.actions.append(row) + self.states.append(deepcopy(_bound_document(self.root, outcome['native'])['policy_observation'])) + self.snapshot = deepcopy(steps[-1]['after'] if steps else self.initial) + self.previous_applied = deepcopy(steps[-1]['applied']['ramped_targets'] if steps else self.initial_targets) + perception_path = f'perception/{len(steps):04}.json' + if (self.root/perception_path).exists(): + self.estimate = _read(self.root, perception_path)['estimate'] + else: + sensed = self.snapshot['camera'] + self.estimate = tracker._empty(self.snapshot['vision_frame_index'], + sensed['captured_at_seconds'], sensed['available_at_seconds'], self.snapshot['timestamp_seconds']) + self.estimate['reason'] = 'terminal_perception_not_committed' + frames = [path for path in (self.root/'frames').glob('*') if path.is_file()] + self.frame_bytes = sum(path.stat().st_size for path in frames) + rgb = {path.name.removeprefix('rgb-').split('.',1)[0] for path in frames if path.name.startswith('rgb-')} + depth = {path.name.removeprefix('depth-').split('.',1)[0] for path in frames if path.name.startswith('depth-')} + self.camera_pairs = len(rgb & depth) + return finished + + def _leave_unsealed(self, exc, reason): + """Close resources and retain an explicit recovery requirement on disk.""" + failure = dict(type=type(exc).__name__, message=str(exc)[:500], stage='finish_reconciliation') + try: + self.session.close() + except (Exception, KeyboardInterrupt) as close_error: + failure['renderer_close_error'] = str(close_error)[:500] + try: + self._journal.close() + except (Exception, KeyboardInterrupt) as close_error: + failure['journal_close_error'] = str(close_error)[:500] + report = dict(schema=SCHEMA, status='interrupted', sealed=False, + reason='journal_reconciliation_failed', requested_finish_reason=reason, + error=self._error, finish_error=failure, source_kind=self.source_kind, case=self.case, + recorded_complete_intervals=None, started_intervals=None, + uncommitted_native_intervals=None, recovery_required=True, + source_authenticated=False, human_authenticated=False, physical_robot_ready=False, + teacher_queries=0, score=_no_score(), strict_success=False) + try: + base._json(self.root/'finish_error.json', report) + except (Exception, KeyboardInterrupt) as write_error: + report['finish_error']['receipt_write_error'] = str(write_error)[:500] + self._sealed = True; self._report = report + return deepcopy(report) + + def finish(self, reason='operator_stopped'): + if self._sealed: + return deepcopy(self._report) + if not self._opened: + self.session.close() + self._report = dict(schema=SCHEMA, status='refused', sealed=False, + reason='capture_not_opened', source_kind=self.source_kind, case=self.case, + requested_intervals=round(self.config.duration_s/tape.DT), started_intervals=0, + recorded_complete_intervals=0, uncommitted_native_intervals=0, + source_authenticated=False, human_authenticated=False, physical_robot_ready=False, + teacher_queries=0, score=_no_score(), strict_success=False) + self._sealed = True + return deepcopy(self._report) + if type(reason) is not str or not reason or len(reason) > 200: + raise ValueError('Finish reason must contain 1 to 200 characters.') + try: + already_finished = self._reconcile() + except (Exception, KeyboardInterrupt) as exc: + # Never seal an internally inconsistent journal/recording pair. + # Recovery remains read-only and may inspect a valid flushed prefix. + return self._leave_unsealed(exc, reason) + native_status = {}; native_saved = False + try: + native_status = self.session.status() + if self.initial is not None: + import numpy as np + arrays, metadata = self.session.native_evidence() + buffer = io.BytesIO(); np.savez_compressed(buffer, **arrays) + base._record(self.root/'native.npz', buffer.getvalue()) + base._json(self.root/'native_metadata.json', metadata) + base._json(self.root/'native_episode.json', camera._native_episode(self.session, self.states, self.actions)) + native_saved = True + except Exception as exc: + self._error = self._error or dict(type=type(exc).__name__, message=str(exc)[:500]) + reason = 'native_evidence_error' if self._error['message'] == str(exc)[:500] else reason + finally: + try: + self.session.close() + except Exception as exc: + self._error = self._error or dict(type=type(exc).__name__, message=str(exc)[:500]) + initialized = (self.root/'context.json').exists() + complete = initialized and self._error is None and len(self.steps) == round(self.config.duration_s/tape.DT) + status = 'complete' if complete else 'interrupted' if initialized else 'refused' + if complete: + reason = 'capture_budget_complete' + try: + score = camera._score(self.root, self.case['goal_position_m']) if native_saved else _no_score() + except Exception as exc: + score = dict(valid=False, success=False, reason='native_score_error', error=str(exc)[:500]) + if initialized: + recording = dict(**self.header, initial=self.initial, + initial_applied_targets=self.initial_targets, steps=self.steps, + outcome=dict(status=status, reason=reason, + end_seconds=self.steps[-1]['after']['timestamp_seconds'] if self.steps else 0., + task_result='unverified' if complete else 'unknown')) + base._json(self.root/'recording.json', recording) + if not already_finished: + self._append('finished', reason=reason, status=status) + self._journal.close() + report = dict(schema=SCHEMA, status=status, reason=reason, sealed=True, + error=self._error, source_kind=self.source_kind, case=self.case, scope=SCOPE, + source_authenticated=False, human_authenticated=False, physical_robot_ready=False, + teacher_queries=0, requested_intervals=round(self.config.duration_s/tape.DT), + started_intervals=self.started_intervals, recorded_complete_intervals=len(self.steps), + uncommitted_native_intervals=self.started_intervals-len(self.steps), + uncommitted_interval_scope='Intent without a commit may have advanced zero, partial, or full native ticks.', + camera_pairs=self.camera_pairs, frame_bytes=self.frame_bytes, + initial_anchor_available=bool(initialized and + _read(self.root, 'perception/0000.json')['estimate']['status'] == 'available'), + native_status=native_status, native_evidence_saved=native_saved, + score=score, strict_success=bool(complete and score.get('success')), + timing=dict(monotonic_wall_seconds=time.monotonic()-self._started_wall, + scope='Paused-simulation acquisition; no wall-clock delivery guarantee.')) + base._json(self.root/'capture.json', report) + files = {str(path.relative_to(self.root)): _identity(self.root, str(path.relative_to(self.root))) + for path in sorted(self.root.rglob('*')) if path.is_file()} + base._json(self.root/'integrity.json', dict(schema=SCHEMA+'/integrity', files=files)) + self._sealed = True; self._report = report + return deepcopy(report) + + +def _no_score(): + return dict(valid=False, success=False, reason='native_evidence_unavailable') + + +def _journal(root, *, allow_truncated=False): + body = tape._ordinary_bytes(root/'commands.jsonl', MAX_JOURNAL_BYTES) + lines = body.splitlines(keepends=True); records = []; last_hash = None + tail_bytes = 0 + for line in lines: + if not line.endswith(b'\n'): + if not allow_truncated: + raise ValueError('Command journal has an unflushed final record.') + tail_bytes = len(line); break + item = tape._decode(line) + if (item.get('schema') != JOURNAL_SCHEMA or item.get('sequence') != len(records) + or item.get('previous_sha256') != last_hash or len(records) >= MAX_EVENTS): + raise ValueError('Command journal hash chain or order differs.') + tape._bounded(item.get('monotonic_wall_seconds'), 'journal wall time', 0, 1e9) + tape._time(item.get('simulation_seconds'), 'journal simulation time') + if records and item['monotonic_wall_seconds'] < records[-1]['monotonic_wall_seconds']: + raise ValueError('Journal wall time cannot run backwards.') + last_hash = sha256(line).hexdigest(); records.append(item) + return records, tail_bytes + + +def _bound_document(root, reference): + if _identity(root, reference['path']) != {key: reference[key] for key in ('bytes', 'sha256')}: + raise ValueError('Journal artifact byte identity differs.') + return _read(root, reference['path']) + + +def _prefix(root, *, allow_truncated=False): + started = _read(root, 'started.json') + if started.get('schema') != SCHEMA or started.get('source_kind') not in SOURCE_KINDS: + raise ValueError('Unknown operator recording source.') + config = camera.RolloutConfig(**started['config']) + entries, tail_bytes = _journal(root, allow_truncated=allow_truncated) + context = _read(root, 'context.json') if (root/'context.json').exists() else None + if context is not None: + _header(context['header']) + tape._action(context['initial_applied_targets'], context['header'], 'initial applied targets') + steps = []; intents = []; pending = None; finished = False + for entry in entries: + if finished: + raise ValueError('Journal continues after finish.') + kind = entry['kind'] + if kind == 'intent': + if pending is not None or entry['index'] != len(steps) or context is None: + raise ValueError('Command intents must follow the committed prefix once.') + pending = _bound_document(root, entry['decision']) + if pending['index'] != len(steps): + raise ValueError('Decision index differs from command intent.') + boundary = steps[-1]['after'] if steps else context['initial'] + _command(pending['command'], context['header'], boundary) + _detail(pending['input_event']) + if pending['snapshot_sha256'] != tape.digest(boundary): + raise ValueError('Pending intent differs from its causal sensor boundary.') + _perception(root, pending['perception'], boundary, context['header'], len(steps)) + intents.append(pending) + elif kind == 'outcome': + if pending is None or entry['index'] != len(steps) or entry.get('status') != 'committed': + raise ValueError('Command outcome has no matching pending intent.') + step = _bound_document(root, entry['transition']) + _bound_document(root, entry['native']) + if (step['index'] != len(steps) or step['command'] != pending['command'] + or step['perception'] != pending['perception'] + or step['input_event'] != pending['input_event'] + or tape.digest(step['before']) != pending['snapshot_sha256']): + raise ValueError('Committed transition differs from recorded command intent.') + steps.append(step); pending = None + elif kind == 'event': + if type(entry.get('name')) is not str or not 1 <= len(entry['name']) <= 80: + raise ValueError('Invalid operator event name.') + _detail(entry.get('detail')) + elif kind == 'finished': + finished = True + else: + raise ValueError('Unknown command journal event.') + if len(intents) > round(config.duration_s/tape.DT): + raise ValueError('Operator capture exceeds its scheduled interval budget.') + if context is not None: + header = context['header']; _header(header) + if (header['provenance']['kind'] != started['source_kind'] + or header['provenance']['source_group'] != started['case']['group_id'] + or header['provenance']['case_id'] != started['case']['id']): + raise ValueError('Operator provenance differs from its started attempt.') + previous = context['initial']; _snapshot(previous, header, 0) + _perception(root, context['initial_perception'], previous, header, 0) + for index, step in enumerate(steps): + if step['before'] != previous or step.get('teacher_query') is not None: + raise ValueError('Operator prefix continuity or teacher separation differs.') + _snapshot(step['after'], header, index+1, previous) + command = step['command'] + _command(command, header, step['before']) + applied = step['applied'] + tape._action(applied['ramped_targets'], header, 'applied targets') + controls = tape._vec(applied['native_position_controls'], 7, 'native controls', -10, 10) + tape._action(dict(joint_targets_rad=controls[:6], gripper_position_m=controls[6]), header, 'native controls') + if (applied['interval_start_seconds'] != step['before']['timestamp_seconds'] + or applied['interval_end_seconds'] != step['after']['timestamp_seconds'] + or applied['scope'] != 'final_native_tick_only' or type(applied['rate_limited']) is not bool): + raise ValueError('Applied interval differs from the committed boundary.') + _perception(root, step['perception'], step['before'], header, index) + previous = step['after'] + # Both the initial and final snapshots bind their native frame bytes. + for snapshot in [context['initial'], *[step['after'] for step in steps]]: + for channel in ('rgb', 'depth'): + reference = snapshot['camera']['value'][channel] + if _identity(root, reference['path']) != {key: reference[key] for key in ('bytes', 'sha256')}: + raise ValueError('Camera source byte binding differs.') + return started, context, steps, intents, tail_bytes, finished + + +def recover_capture(directory): + """Read-only recovery of an unsealed committed prefix; never resume or seal. + + An outstanding intent cannot establish how many native ticks ran. Even a + full scheduled prefix remains interrupted until an ordinary finish sealed + its native evidence. Recovered rows cannot be passed to the exporter. + """ + root = Path(directory) + if (root/'integrity.json').exists(): + raise ValueError('Capture is already sealed; inspect or load it normally.') + started, context, steps, intents, tail, _ = _prefix(root, allow_truncated=True) + report = dict(schema=SCHEMA+'/recovery', status='interrupted', sealed=False, + reason='process_interrupted', source_kind=started['source_kind'], + recorded_complete_intervals=len(steps), started_intervals=len(intents), + uncommitted_native_intervals=len(intents)-len(steps), truncated_journal_bytes=tail, + native_progress_of_uncommitted_interval='unknown', strict_success=False, + score=_no_score(), source_authenticated=False, physical_robot_ready=False) + recording = None if context is None else dict(**context['header'], initial=context['initial'], + initial_applied_targets=context['initial_applied_targets'], steps=steps, + outcome=dict(status='interrupted', reason='process_interrupted', task_result='unknown', + end_seconds=steps[-1]['after']['timestamp_seconds'] if steps else 0.)) + return dict(report=report, recording=recording) + + +def _native_bindings(root, steps, report): + """Bind emitted targets, encoder inputs, and applied references to native evidence.""" + if not report['native_evidence_saved']: + return + episode = _read(root, 'native_episode.json') + if 'actions' not in episode: + if report['native_status'].get('native_initialized'): + raise ValueError('Initialized native recording lacks native action evidence.') + return # Explicit non-physics fixtures cannot establish strict success. + import numpy as np + import zipfile + with zipfile.ZipFile(root/'native.npz') as archive: + if sum(item.file_size for item in archive.infolist()) > MAX_DOCUMENT_BYTES: + raise ValueError('Expanded native arrays exceed the bounded evidence budget.') + actions, states = episode['actions'], episode['trajectory'] + if len(actions) != len(steps) or len(states) != len(steps)+1: + raise ValueError('Native action/state trace differs from the committed prefix.') + with np.load(root/'native.npz', allow_pickle=False) as archive: + arrays = {name: archive[name] for name in ('arm_actuator_controls_rad', + 'applied_jaw_target_m', 'normal_rate_limited_jaw_target_m', + 'joint_position_rad', 'gripper_joint_position_m', 'requested_jaw_target_m')} + widths = {'arm_actuator_controls_rad':6, 'joint_position_rad':6, + 'gripper_joint_position_m':2} + for name, values in arrays.items(): + expected_tail = (widths[name],) if name in widths else () + if (values.dtype.kind not in 'fiu' or values.shape[1:] != expected_tail + or not len(steps)*20 <= len(values) <= report['requested_intervals']*20 + or not np.isfinite(values).all()): + raise ValueError('Native array shape or finite-value contract differs: '+name) + for index, step in enumerate(steps): + if {name: actions[index][name] for name in ('joint_targets_rad', 'gripper_position_m')} != step['command']['issued']: + raise ValueError('Issued command differs from native action evidence.') + for position, snapshot in ((index, step['before']), (index+1, step['after'])): + for name in ('joint_position_rad', 'joint_velocity_rad_s', 'gripper_joint_position_m'): + if snapshot['signals'][name]['value'] != states[position][name]: + raise ValueError('Measured input differs from native encoder evidence.') + boundary = (index+1)*20-1 + controls = [*arrays['arm_actuator_controls_rad'][boundary].tolist(), + float(arrays['applied_jaw_target_m'][boundary])] + ramped = dict(joint_targets_rad=controls[:6], + gripper_position_m=float(arrays['normal_rate_limited_jaw_target_m'][boundary])) + if step['applied']['native_position_controls'] != controls or step['applied']['ramped_targets'] != ramped: + raise ValueError('Applied inputs differ from native servo reference evidence.') + for name in ('joint_position_rad', 'gripper_joint_position_m'): + if not np.allclose(arrays[name][boundary], step['after']['signals'][name]['value'], + atol=2e-7, rtol=0): + raise ValueError('After-state encoders differ from native tick evidence.') + if not np.allclose(arrays['requested_jaw_target_m'][index*20:(index+1)*20], + step['command']['issued']['gripper_position_m'], atol=1e-12, rtol=0): + raise ValueError('Issued jaw target differs from native tick evidence.') + + +def inspect_capture(directory): + """Verify the sealed journal, causal rows, byte bindings and independent score.""" + root = Path(directory); manifest = _read(root, 'integrity.json') + if manifest.get('schema') != SCHEMA+'/integrity' or type(manifest.get('files')) is not dict: + raise ValueError('An operator recording requires a sealed integrity manifest.') + observed = {str(path.relative_to(root)) for path in root.rglob('*') + if path.is_file() and path != root/'integrity.json'} + if observed != set(manifest['files']): + raise ValueError('Operator recording inventory differs.') + for name, identity in manifest['files'].items(): + if _identity(root, name) != identity: + raise ValueError('Operator recording byte identity differs: '+name) + frame_identities = [identity for name, identity in manifest['files'].items() if name.startswith('frames/')] + frame_bytes = sum(identity['bytes'] for identity in frame_identities) + if frame_bytes > tape.MAX_FRAME_TOTAL or any(identity['bytes'] > tape.MAX_FRAME_BYTES for identity in frame_identities): + raise ValueError('Native RGB-D frame evidence exceeds its byte budget.') + started, context, steps, intents, _, finished = _prefix(root) + report = _read(root, 'capture.json') + entries, _ = _journal(root) + if (report.get('schema') != SCHEMA or report.get('sealed') is not True or not finished + or report.get('status') not in ('complete', 'interrupted', 'refused') + or report.get('source_kind') != started['source_kind'] + or report.get('teacher_queries') != 0 or report.get('source_authenticated') is not False + or report.get('human_authenticated') is not False + or report.get('physical_robot_ready') is not False + or report.get('case') != started['case'] + or report.get('requested_intervals') != round(started['config']['duration_s']/tape.DT) + or report.get('frame_bytes') != frame_bytes + or report.get('recorded_complete_intervals') != len(steps) + or report.get('started_intervals') != len(intents) + or report.get('uncommitted_native_intervals') != len(intents)-len(steps)): + raise ValueError('Operator report disagrees with the command journal.') + if entries[-1].get('status') != report['status'] or entries[-1].get('reason') != report['reason']: + raise ValueError('Operator report differs from the journal finish record.') + if context is not None: + recording = _read(root, 'recording.json') + if (any(recording.get(key) != value for key, value in context['header'].items()) + or recording.get('initial') != context['initial'] + or recording.get('initial_applied_targets') != context['initial_applied_targets'] + or recording.get('steps') != steps + or recording['outcome']['status'] != report['status']): + raise ValueError('Sealed recording differs from its committed prefix.') + if report['status'] == 'complete' and (len(steps) != round(started['config']['duration_s']/tape.DT) + or len(intents) != len(steps) or report.get('error') is not None): + raise ValueError('Complete recording omits scheduled intervals or contains an error.') + _native_bindings(root, steps, report) + score = camera._score(root, started['case']['goal_position_m']) if report['native_evidence_saved'] else _no_score() + if score != report['score'] or report['strict_success'] != bool(report['status'] == 'complete' and score.get('success')): + raise ValueError('Independent native score differs from the sealed report.') + return dict(schema=SCHEMA+'/inspection', status=report['status'], sealed=True, + files_verified=len(manifest['files']), recorded_complete_intervals=len(steps), + source_authenticated=False, human_authenticated=False, physical_robot_ready=False, + score=score, strict_success=report['strict_success']) + + +def load_capture(directory): + inspect_capture(directory) + return _read(Path(directory), 'recording.json') diff --git a/src/dvidia_training/operator_export.py b/src/dvidia_training/operator_export.py new file mode 100644 index 0000000..4b333dc --- /dev/null +++ b/src/dvidia_training/operator_export.py @@ -0,0 +1,226 @@ +"""Explicitly reviewed external commands as direct camera-action supervision. + +Selections name original control-row indices. History is computed causally from +all preceding sensor rows, then selected; gaps never reset or fabricate history. +Failed attempts remain usable with an explicit review reason. No labels are +inferred from native outcomes, teacher suggestions, or zero residuals. +""" +from __future__ import annotations + +import argparse +from hashlib import sha256 +import io +import json +from pathlib import Path + +import numpy as np + +from . import camera_action_model as model +from . import operator_capture as capture + +STORAGE_SCHEMA = 'dvidia.operator-dataset/v1' +MAX_ARRAY_BYTES = 16 * 1024 * 1024 + + +def _selection(count, reviewed_indices, reviewed_segments): + if (reviewed_indices is None) == (reviewed_segments is None): + raise ValueError('Choose exactly one explicit reviewed row-index or segment selection.') + if reviewed_indices is not None: + if type(reviewed_indices) not in (list, tuple): + raise ValueError('Reviewed indices must be an explicit list of integers.') + selected = list(reviewed_indices) + else: + if type(reviewed_segments) not in (list, tuple) or not reviewed_segments: + raise ValueError('Reviewed segments must be an explicit list of [start, stop) pairs.') + selected = [] + for segment in reviewed_segments: + if (type(segment) not in (list, tuple) or len(segment) != 2 + or any(type(value) is not int for value in segment) + or not 0 <= segment[0] < segment[1] <= count): + raise ValueError('Reviewed segments use bounded, half-open [start, stop) row indices.') + selected.extend(range(*segment)) + if not selected or any(type(index) is not int or not 0 <= index < count for index in selected): + raise ValueError('Every reviewed index must identify a committed control row.') + if len(set(selected)) != len(selected): + raise ValueError('Duplicate reviewed indices or overlapping segments are forbidden.') + return sorted(selected) + + +def export_episode(directory, *, reviewed_indices=None, reviewed_segments=None, + review_reason=None, blind=False): + """Export sealed, explicitly selected rows with exact issued seven-target labels. + + ``reviewed_segments`` uses half-open ``[start, stop)`` index pairs. The + nonempty review reason is retained even for unsuccessful/interrupted runs. + This records the caller's review assertion, not authenticated human review. + """ + if type(review_reason) is not str or not review_reason.strip() or len(review_reason) > 2000: + raise ValueError('An explicit nonempty review reason is required for every export.') + if type(blind) is not bool: + raise ValueError('blind must be boolean.') + root = Path(directory) + recording = capture.load_capture(root) + recording_body = capture.tape._ordinary_bytes(root/'recording.json', capture.MAX_DOCUMENT_BYTES) + if recording_body.strip() != model.canonical(recording): + raise ValueError('Recording changed after operator verification.') + if (recording.get('schema') != capture.SCHEMA or recording.get('control_mode') != 'operator' + or recording.get('provenance', {}).get('kind') not in capture.SOURCE_KINDS): + raise ValueError('Reviewed operator exports require operator source provenance.') + selected = _selection(len(recording['steps']), reviewed_indices, reviewed_segments) + selected_set = set(selected) + encoder = model.CameraActionFeatures(blind=blind) + previous_applied = recording['initial_applied_targets'] + features, targets, perception_hashes, authorities = [], [], [], [] + for index, step in enumerate(recording['steps']): + if index > selected[-1]: + break + item = capture._perception(root, step['perception'], step['before'], recording, index) + feature = encoder(dict(snapshot=step['before'], estimate=item['estimate'], + previous_applied=previous_applied, goal_position_m=recording['goal_position_m'])) + if index in selected_set: + if (step['command']['authority'] != recording['provenance']['kind'] + or step.get('teacher_query') is not None): + raise ValueError('Operator labels cannot impersonate student or teacher authority.') + features.append(feature) + targets.append(model.action_vector(step['command']['issued'])) + perception_hashes.append(step['perception']['sha256']) + authorities.append(step['command']['authority']) + previous_applied = step['applied']['ramped_targets'] + report = capture._read(root, 'capture.json') + group = recording['provenance']['source_group'] + source = dict(source_group=group, case_id=recording['provenance']['case_id'], + source_kind=recording['provenance']['kind'], source_authenticated=False, + recording_sha256=sha256(recording_body).hexdigest(), + integrity_sha256=sha256((root/'integrity.json').read_bytes()).hexdigest(), + calibration_sha256=recording['binding']['calibration_sha256'], + tracker_sha256=recording['tracker_sha256'], label_source='operator_issued', + sample_count=len(selected), reviewed_indices=selected, review_reason=review_reason.strip(), + review_authenticated=False, attempt_status=report['status'], + attempt_strict_success=report['strict_success'], + perception_references_sha256=model.digest(perception_hashes), + actual_authority_sha256=model.digest(authorities)) + sources = [source] + provenance = dict(feature_contract_sha256=model.FEATURE_CONTRACT_SHA256, blind=blind, + episodes=sources, source_identity_sha256=model.digest(sources), + export_source_sha256=sha256(Path(__file__).read_bytes()).hexdigest(), + review_selection_sha256=model.digest(dict(indices=selected, reason=review_reason.strip())), + label_definition='absolute targets actually issued by the declared external operator source') + dataset = dict(schema=model.DATASET_SCHEMA, features=np.asarray(features, dtype=np.float32), + targets=np.asarray(targets, dtype=np.float64), groups=[group]*len(selected), provenance=provenance) + model._dataset(dataset, blind=blind) + return dataset + + +def _validated_dataset(dataset): + if type(dataset) is not dict: + raise ValueError('A reviewed operator dataset dictionary is required.') + provenance = dataset.get('provenance', {}) + if type(provenance.get('blind')) is not bool: + raise ValueError('Operator dataset requires explicit feedback masking provenance.') + x, y, groups, provenance, _ = model._dataset(dataset, blind=provenance['blind']) + sources = provenance.get('episodes') + if (provenance.get('feature_contract_sha256') != model.FEATURE_CONTRACT_SHA256 + or type(sources) is not list or len(sources) != 1 + or provenance.get('source_identity_sha256') != model.digest(sources)): + raise ValueError('Operator dataset requires one bound reviewed source episode.') + source = sources[0] + indices = source.get('reviewed_indices') + if (source.get('source_kind') not in capture.SOURCE_KINDS + or source.get('source_authenticated') is not False + or source.get('review_authenticated') is not False + or source.get('label_source') != 'operator_issued' + or type(source.get('review_reason')) is not str or not source['review_reason'].strip() + or type(indices) is not list or len(indices) != len(groups) + or indices != _selection(1000, indices, None) + or source.get('sample_count') != len(groups) + or any(group != source.get('source_group') for group in groups)): + raise ValueError('Operator dataset review, authority, or source-group provenance differs.') + if provenance.get('review_selection_sha256') != model.digest( + dict(indices=indices, reason=source['review_reason'])): + raise ValueError('Operator review selection binding differs.') + return x, y, groups, provenance + + +def save_dataset(dataset, output): + """Save one reviewed export to a fresh directory, with exclusive array files.""" + x, y, groups, provenance = _validated_dataset(dataset) + root = Path(output).absolute() + if any(item.is_symlink() and str(item) not in ('/var', '/tmp') + for item in (root, *root.parents)): + raise ValueError('Dataset output cannot traverse symlinks.') + buffers = {} + for name, values in (('features', np.asarray(x, dtype=np.float32)), + ('targets', np.asarray(y, dtype=np.float64))): + buffer = io.BytesIO(); np.save(buffer, values, allow_pickle=False) + body = buffer.getvalue() + if len(body) > MAX_ARRAY_BYTES: + raise ValueError('Reviewed dataset array exceeds its bounded storage budget.') + buffers[name] = body + root.mkdir(parents=True, exist_ok=False) + files = {} + for name, body in buffers.items(): + filename = name+'.npy' + files[name] = capture.base._record(root/filename, body) + document = dict(storage_schema=STORAGE_SCHEMA, schema=model.DATASET_SCHEMA, + groups=groups, provenance=provenance, files=files) + capture.base._json(root/'dataset.json', document) + return root/'dataset.json' + + +def load_dataset(directory): + """Read bounded non-pickle arrays and verify reviewed source provenance.""" + root = Path(directory) + if {path.name for path in root.iterdir()} != {'dataset.json','features.npy','targets.npy'}: + raise ValueError('Reviewed dataset inventory differs.') + document = capture._read(root, 'dataset.json', maximum=2*1024*1024) + if (set(document) != {'storage_schema','schema','groups','provenance','files'} + or document['storage_schema'] != STORAGE_SCHEMA or document['schema'] != model.DATASET_SCHEMA + or type(document['groups']) is not list or not 1 <= len(document['groups']) <= 1000 + or type(document['files']) is not dict or set(document['files']) != {'features','targets'}): + raise ValueError('Unknown or unbounded reviewed operator dataset.') + arrays = {} + for name, width in (('features', model.FEATURE_COUNT), ('targets', 7)): + reference = document['files'][name] + if type(reference) is not dict or set(reference) != {'path','bytes','sha256'} or reference['path'] != name+'.npy': + raise ValueError('Dataset arrays require their exact ordinary relative paths.') + body = capture.tape._ordinary_bytes(root/reference['path'], MAX_ARRAY_BYTES) + if reference['bytes'] != len(body) or reference['sha256'] != sha256(body).hexdigest(): + raise ValueError('Dataset array byte identity differs.') + stream = io.BytesIO(body) + version = np.lib.format.read_magic(stream) + reader = {(1,0):np.lib.format.read_array_header_1_0, + (2,0):np.lib.format.read_array_header_2_0}.get(version) + if reader is None: + raise ValueError('Unsupported reviewed array format.') + shape, fortran, dtype = reader(stream) + if (shape != (len(document['groups']), width) or fortran or dtype.kind != 'f' + or dtype.itemsize not in (4,8) or dtype.hasobject + or stream.tell()+int(np.prod(shape))*dtype.itemsize != len(body)): + raise ValueError('Dataset array shape, numeric dtype, or payload size differs.') + arrays[name] = np.load(io.BytesIO(body), allow_pickle=False) + dataset = dict(schema=document['schema'], **arrays, + groups=document['groups'], provenance=document['provenance']) + _validated_dataset(dataset) + return dataset + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Export explicitly reviewed native operator rows.') + parser.add_argument('--capture', required=True, help='Sealed operator capture directory') + parser.add_argument('--review', required=True, help='JSON with indices OR segments, plus reason') + parser.add_argument('--output', required=True, help='Fresh output dataset directory') + parser.add_argument('--blind', action='store_true', help='Mask live feedback using the existing feature contract') + args = parser.parse_args(argv) + review = capture.tape._decode(capture.tape._ordinary_bytes(Path(args.review), 64*1024)) + if type(review) is not dict or set(review) not in ({'indices','reason'}, {'segments','reason'}): + parser.error('Review JSON requires exactly indices OR segments, plus reason.') + dataset = export_episode(args.capture, reviewed_indices=review.get('indices'), + reviewed_segments=review.get('segments'), review_reason=review['reason'], blind=args.blind) + result = save_dataset(dataset, args.output) + print(json.dumps(dict(dataset=str(result), reviewed_rows=len(dataset['groups']), + source_authenticated=False, training_started=False))) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_godot_demonstration_bridge.py b/tests/test_godot_demonstration_bridge.py new file mode 100644 index 0000000..4fe4e54 --- /dev/null +++ b/tests/test_godot_demonstration_bridge.py @@ -0,0 +1,125 @@ +"""Transport/authority tests with test doubles; no native execution.""" +from copy import deepcopy +import json +from pathlib import Path +from types import SimpleNamespace +import unittest + +from dvidia_training import godot_demonstration_bridge as bridge + +DIGEST='a'*64 +HOME=[.3,0.,.25] + + +class Capture: + def __init__(self,index): + self.root=Path('/not-written')/str(index) + self.config=SimpleNamespace(duration_s=.1) + self.source_kind='simulation_scripted_input' + self.session=SimpleNamespace(env=SimpleNamespace(elapsed=0.)) + self.events=[];self.actions=[];self.reasons=[];self.fail=False;self.finish_error=False + def open(self): + return dict(header=dict(binding=dict(model_sha256=DIGEST))) + def context(self): + return dict(snapshot=dict(signals=dict(joint_position_rad=dict(value=[0.]*6))), + native_truth='must not reach controller') + def step(self,action,*,input_event=None): + self.session.env.elapsed+=.02 + if self.fail:raise RuntimeError('partial native advance') + self.actions.append((deepcopy(action),deepcopy(input_event))) + return dict(done=len(self.actions)>=5) + def record_event(self,name,detail=None):self.events.append(name) + def finish(self,reason='operator_stopped'): + self.reasons.append(reason) + return dict(reason=reason,sealed=True,native_evidence_saved=not self.finish_error, + error='disk failure' if self.finish_error else None) + + +class BridgeTests(unittest.TestCase): + def setUp(self): + self.captures=[];self.calls=[] + def factory(index): + capture=Capture(index);self.captures.append(capture);return capture + def controller(joints,target,jaw): + self.calls.append((joints,target,jaw)) + return dict(joint_targets_rad=joints,gripper_position_m=jaw) + self.session=bridge.Session(factory,controller,model_sha256=DIGEST,home_target=HOME, + max_episodes=2,state_reader=lambda native,target:dict(time=native.env.elapsed,target=target)) + def call(self,kind,**kw): + if kind not in ('hello','ping'):kw.setdefault('sequence',self.session.expected_sequence) + return self.session.process(json.dumps(dict(type=kind,**kw)).encode()) + def hello(self):return self.call('hello',schema=bridge.SCHEMA,model_sha256=DIGEST) + def step(self,**kw):return self.call('step',target=kw.pop('target',HOME),jaw=kw.pop('jaw',.035),**kw) + def test_explicit_record_and_causal_controller_inputs(self): + self.assertEqual(self.step()['type'],'error') + self.assertEqual(self.hello()['recording']['status'],'ready') + self.assertEqual(self.step()['code'],'recording_status') + self.call('begin') + response=self.step() + self.assertEqual(response['state']['time'],.02) + self.assertEqual(self.calls,[([0.]*6,HOME,.035)]) + action,event=self.captures[0].actions[0] + self.assertEqual(event['target_world_m'],HOME) + self.assertEqual(action['gripper_position_m'],.035) + def test_bad_requests_never_advance_or_consume_sequence(self): + self.hello();self.call('begin');sequence=self.session.expected_sequence + cases=[dict(target=[.1,.2]),dict(target=[100,0,.2]),dict(jaw=True),dict(jaw=.1),dict(sequence=0)] + for case in cases: + self.assertEqual(self.step(**case)['type'],'error') + self.assertEqual(self.session.expected_sequence,sequence) + for raw in (b'{"type":"step","type":"ping"}',b'[]',b'{"type":NaN}'): + self.assertEqual(self.session.process(raw)['type'],'error') + self.assertEqual(self.captures[0].session.env.elapsed,0.) + self.assertEqual(self.calls,[]) + def test_pause_ping_resume_and_finish_do_not_advance(self): + self.hello();self.call('begin');self.step() + self.call('pause');sequence=self.session.expected_sequence + self.assertEqual(self.step()['code'],'recording_status') + self.assertEqual(self.call('ping')['expected_sequence'],sequence) + self.assertEqual(self.session.expected_sequence,sequence) + self.call('resume');self.call('finish') + self.assertEqual(self.captures[0].session.env.elapsed,.02) + self.assertEqual(self.captures[0].events,['begin','pause','resume']) + self.assertEqual(self.session.status,'finished') + self.call('finish') + self.assertEqual(self.captures[0].reasons,['operator_stopped']) + def test_reset_seals_old_episode_and_budget_refuses_without_mutation(self): + self.hello();self.call('begin');self.step();response=self.call('reset') + self.assertEqual(response['state']['time'],0.) + self.assertEqual(response['recording']['status'],'ready') + self.assertEqual(self.captures[0].reasons,['operator_reset']) + self.assertNotEqual(self.captures[0].root,self.captures[1].root) + self.assertEqual(self.call('reset')['code'],'episode_budget') + self.assertEqual(self.captures[1].reasons,[]) + def test_partial_native_interval_faults_and_never_retries(self): + self.hello();self.call('begin');self.captures[0].fail=True + self.assertEqual(self.step()['code'],'recording_fault') + self.assertEqual(self.step()['code'],'session_fault') + self.assertEqual(self.captures[0].session.env.elapsed,.02) + self.assertEqual(self.captures[0].reasons,['session_fault']) + def test_budget_finish_and_disconnect_have_truthful_authority(self): + self.hello();self.call('begin') + for _ in range(5):self.step() + self.assertEqual(self.session.status,'finished') + self.assertEqual(self.captures[0].reasons,['capture_budget_complete']) + self.session.disconnect() + self.assertEqual(len(self.captures[0].reasons),1) + self.call('reset');self.session.disconnect() + self.assertEqual(self.captures[1].events,['disconnect']) + self.assertEqual(self.captures[1].reasons,['client_disconnected']) + def test_wrong_model_and_protocol_are_rejected(self): + for values in (dict(schema='old',model_sha256=DIGEST),dict(schema=bridge.SCHEMA,model_sha256='b'*64)): + self.assertEqual(self.call('hello',**values)['type'],'error') + self.assertFalse(self.session.ready) + self.assertEqual(self.captures[0].actions,[]) + + def test_failed_sealing_is_not_reported_as_saved_and_reset_does_not_hide_it(self): + self.hello();self.call('begin');self.step() + self.captures[0].finish_error=True + self.assertEqual(self.call('reset')['code'],'recording_fault') + self.assertTrue(self.session.faulted) + self.assertEqual(len(self.captures),1) + self.assertEqual(self.step()['code'],'session_fault') + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_operator_capture.py b/tests/test_operator_capture.py new file mode 100644 index 0000000..5aae499 --- /dev/null +++ b/tests/test_operator_capture.py @@ -0,0 +1,298 @@ +"""Operator recording tests use sensor fixtures, never native physics.""" +from copy import deepcopy +from hashlib import sha256 +import json +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +from dvidia_training import action_demonstration as tape +from dvidia_training import camera_action_capture as camera +from dvidia_training import operator_capture as capture +from dvidia_training import piper_rgbd_tracker as tracker +from tests.test_camera_action_capture import FakeSession, fake_estimate, CASE, STUDENT + + +class OperatorCaptureTests(unittest.TestCase): + def setUp(self): + FakeSession.instances = []; FakeSession.fail_at = None + self.temp = TemporaryDirectory(); self.addCleanup(self.temp.cleanup) + for target, value in (('_Session', FakeSession), ('_fresh_estimate', fake_estimate), + ('_native_episode', lambda *args: {'test_only': True}), + ('_score', lambda *args: dict(valid=False, success=False, reason='fixture_not_physics'))): + self.enterContext(patch.object(camera, target, value)) + + def new(self, name='capture', **kwargs): + return capture.OperatorCapture('unused', Path(self.temp.name)/name, case=CASE, + duration_s=kwargs.pop('duration_s', .2), **kwargs) + + def complete(self, name='capture', **kwargs): + recorder = self.new(name, **kwargs); opened = recorder.open() + while not recorder.done: + recorder.step(deepcopy(STUDENT), input_event={'kind': 'test-key'}) + report = recorder.finish() + return recorder, opened, report + + def test_external_commands_cadence_and_no_teacher(self): + recorder, opened, report = self.complete(source_kind='simulation_human_input') + self.assertEqual(report['status'], 'complete') + self.assertEqual(report['camera_pairs'], 6) + self.assertEqual(report['recorded_complete_intervals'], 10) + self.assertEqual(report['teacher_queries'], 0) + self.assertEqual(recorder.session.teacher_calls, 0) + self.assertTrue(recorder.session.closed) + self.assertFalse(report['human_authenticated']) + recording = capture.load_capture(recorder.root) + for index, step in enumerate(recording['steps']): + self.assertEqual(step['command']['issued'], STUDENT) + self.assertEqual(step['command']['authority'], 'simulation_human_input') + self.assertIsNone(step['teacher_query']) + self.assertAlmostEqual(step['after']['timestamp_seconds'], (index+1)*.02) + self.assertEqual(step['before']['camera']['value']['index'], index//2) + for name in tracker.SIGNALS: + self.assertEqual(step['before']['signals'][name]['status'], 'missing') + self.assertFalse((recorder.root/'teacher').exists()) + self.assertFalse(report['strict_success']) + with self.assertRaises(RuntimeError): + recorder.step(STUDENT) + self.assertEqual(recorder.finish(), report) + + def test_context_is_deep_copied_sensor_whitelist_and_events_do_not_advance(self): + recorder = self.new(); opened = recorder.open() + self.assertEqual(set(opened['context']), {'snapshot','estimate','previous_applied','goal_position_m'}) + self.assertNotIn('policy_observation', json.dumps(opened['context'])) + self.assertNotIn('native_info', json.dumps(opened['context'])) + recorder.record_event('pause', {'reason':'review'}) + recorder.record_event('resume') + self.assertEqual(recorder.session.position, 0) + opened['context']['previous_applied']['joint_targets_rad'][0] = 9. + self.assertNotEqual(recorder.context()['previous_applied']['joint_targets_rad'][0], 9.) + returned = recorder.step(STUDENT) + self.assertEqual(returned['context']['previous_applied'], returned['applied']['ramped_targets']) + report = recorder.finish('operator_stopped') + self.assertEqual(report['status'], 'interrupted') + entries, _ = capture._journal(recorder.root) + self.assertEqual([e['name'] for e in entries if e['kind']=='event'], ['opened','pause','resume']) + capture.inspect_capture(recorder.root) + + def test_partial_native_interval_seals_only_committed_prefix_without_retry(self): + FakeSession.fail_at = 'advance' + recorder = self.new(); recorder.open() + recorder.step(STUDENT); recorder.step(STUDENT) + with self.assertRaisesRegex(RuntimeError, 'partial interval'): + recorder.step(STUDENT) + report = recorder.finish() + self.assertEqual(report['started_intervals'], 3) + self.assertEqual(report['recorded_complete_intervals'], 2) + self.assertEqual(report['uncommitted_native_intervals'], 1) + self.assertEqual(report['status'], 'interrupted') + self.assertEqual(recorder.session.position, 3) + self.assertTrue(recorder.session.closed) + capture.inspect_capture(recorder.root) + + def test_readonly_crash_recovery_does_not_claim_native_completion(self): + recorder = self.new(); recorder.open(); recorder.step(STUDENT) + command = dict(issued_at_seconds=.02, requested=deepcopy(STUDENT), issued=deepcopy(STUDENT), + authority=recorder.source_kind, operator_correction=False, issue_reason='unchanged') + decision = dict(index=1, command=command, perception=recorder._perception_ref, + input_event=None, snapshot_sha256=tape.digest(recorder.snapshot)) + ref = capture._reference(recorder.root, 'decisions/0001.json', decision) + recorder._append('intent', index=1, decision=ref) + recorder._journal.close(); recorder.session.close() + before = {str(p.relative_to(recorder.root)): p.read_bytes() for p in recorder.root.rglob('*') if p.is_file()} + recovered = capture.recover_capture(recorder.root) + self.assertEqual(recovered['report']['recorded_complete_intervals'], 1) + self.assertEqual(recovered['report']['uncommitted_native_intervals'], 1) + self.assertFalse(recovered['report']['sealed']) + self.assertFalse(recovered['report']['strict_success']) + self.assertEqual(len(recovered['recording']['steps']), 1) + after = {str(p.relative_to(recorder.root)): p.read_bytes() for p in recorder.root.rglob('*') if p.is_file()} + self.assertEqual(before, after) + with self.assertRaises(FileNotFoundError): + capture.load_capture(recorder.root) + + def test_recovery_discards_incomplete_final_journal_line(self): + recorder = self.new(); recorder.open(); recorder.step(STUDENT) + recorder._journal.write(b'{"unfinished":'); recorder._journal.flush() + recorder._journal.close(); recorder.session.close() + result = capture.recover_capture(recorder.root) + self.assertEqual(result['report']['recorded_complete_intervals'], 1) + self.assertGreater(result['report']['truncated_journal_bytes'], 0) + + def test_transition_without_journal_commit_is_not_recovered(self): + recorder = self.new(); recorder.open() + append = recorder._append + def abrupt_exit(kind, **payload): + if kind == 'outcome': + raise SystemExit('simulated process loss before commit') + return append(kind, **payload) + with patch.object(recorder, '_append', abrupt_exit), self.assertRaises(SystemExit): + recorder.step(STUDENT) + recorder._journal.close(); recorder.session.close() + self.assertTrue((recorder.root/'transitions/0000.json').exists()) + recovered = capture.recover_capture(recorder.root) + self.assertEqual(recovered['report']['recorded_complete_intervals'], 0) + self.assertEqual(recovered['report']['uncommitted_native_intervals'], 1) + + def test_interrupt_after_durable_outcome_rebuilds_native_trace_before_sealing(self): + recorder = self.new(); recorder.open() + append = recorder._append; trace = [] + def interrupt_after_outcome(kind, **payload): + result = append(kind, **payload) + if kind == 'outcome': + raise KeyboardInterrupt('after durable outcome, before memory update') + return result + def native_episode(session, states, actions): + trace.append((deepcopy(states), deepcopy(actions))) + return {'test_only':True} + with patch.object(recorder, '_append', interrupt_after_outcome), \ + patch.object(camera, '_native_episode', side_effect=native_episode), \ + self.assertRaises(KeyboardInterrupt): + recorder.step(STUDENT) + report = recorder.finish() + self.assertEqual(report['status'], 'interrupted') + self.assertEqual(report['recorded_complete_intervals'], 1) + self.assertEqual(report['started_intervals'], 1) + self.assertEqual(report['uncommitted_native_intervals'], 0) + self.assertEqual(len(trace[0][0]), 2) + self.assertEqual(len(trace[0][1]), 1) + self.assertEqual(trace[0][1][0]['joint_targets_rad'], STUDENT['joint_targets_rad']) + capture.inspect_capture(recorder.root) + + def test_interrupt_after_durable_intent_rebuilds_started_counter(self): + recorder = self.new(); recorder.open() + append = recorder._append + def interrupt_after_intent(kind, **payload): + result = append(kind, **payload) + if kind == 'intent': + raise KeyboardInterrupt('after durable intent, before started counter') + return result + with patch.object(recorder, '_append', interrupt_after_intent), self.assertRaises(KeyboardInterrupt): + recorder.step(STUDENT) + report = recorder.finish() + self.assertEqual(report['started_intervals'],1) + self.assertEqual(report['recorded_complete_intervals'],0) + self.assertEqual(report['uncommitted_native_intervals'],1) + self.assertEqual(recorder.session.position,0) + capture.inspect_capture(recorder.root) + + def test_interrupt_after_journal_fsync_reconciles_cursor_before_finished_append(self): + for kind in ('intent','outcome'): + with self.subTest(kind=kind): + recorder = self.new(kind); recorder.open() + journal_fd = recorder._journal.fileno() + original_fsync = capture.os.fsync + append = recorder._append; armed = [False]; interrupted = [False] + def arm_before_append(actual_kind, **payload): + armed[0] = actual_kind == kind and not interrupted[0] + return append(actual_kind, **payload) + def interrupt_after_fsync(fd): + original_fsync(fd) + if fd == journal_fd and armed[0]: + armed[0] = False; interrupted[0] = True + raise KeyboardInterrupt('journal fsync returned before cursor assignment') + with patch.object(recorder, '_append', arm_before_append), \ + patch.object(capture.os, 'fsync', interrupt_after_fsync), \ + self.assertRaises(KeyboardInterrupt): + recorder.step(STUDENT) + report = recorder.finish() + self.assertTrue(report['sealed']) + self.assertEqual(report['started_intervals'],1) + self.assertEqual(report['recorded_complete_intervals'],int(kind == 'outcome')) + entries, tail = capture._journal(recorder.root) + self.assertEqual(tail,0) + self.assertEqual([entry['sequence'] for entry in entries],list(range(len(entries)))) + self.assertEqual(entries[-1]['kind'],'finished') + capture.inspect_capture(recorder.root) + + def test_truncated_journal_during_failure_remains_unsealed_for_readonly_recovery(self): + recorder = self.new(); recorder.open() + append = recorder._append + def interrupt_mid_outcome(kind, **payload): + if kind == 'outcome': + recorder._journal.write(b'{"partial_outcome":') + recorder._journal.flush() + raise KeyboardInterrupt('mid journal record') + return append(kind, **payload) + with patch.object(recorder, '_append', interrupt_mid_outcome), self.assertRaises(KeyboardInterrupt): + recorder.step(STUDENT) + report = recorder.finish() + self.assertFalse(report['sealed']) + self.assertEqual(report['finish_error']['stage'],'finish_reconciliation') + self.assertTrue(recorder.session.closed) + self.assertTrue((recorder.root/'finish_error.json').exists()) + self.assertFalse((recorder.root/'integrity.json').exists()) + self.assertFalse((recorder.root/'recording.json').exists()) + recovered = capture.recover_capture(recorder.root) + self.assertEqual(recovered['report']['recorded_complete_intervals'],0) + self.assertEqual(recovered['report']['uncommitted_native_intervals'],1) + self.assertFalse(recovered['report']['strict_success']) + + def test_sidecar_tampering_and_missing_masks_rejected(self): + recorder, _, _ = self.complete() + path = recorder.root/'perception/0000.json' + document = json.loads(path.read_text()); document['estimate']['status'] = 'missing' + body = tape.canonical(document)+b'\n'; path.write_bytes(body) + with self.assertRaisesRegex(ValueError, 'byte identity'): + capture.inspect_capture(recorder.root) + with self.assertRaisesRegex(ValueError, 'missing masks'): + capture._perception(recorder.root, + dict(path='perception/0000.json', bytes=len(body), sha256=sha256(body).hexdigest()), + recorder.initial, recorder.header, 0) + + def test_missing_live_camera_estimate_is_retained_without_native_truth(self): + def missing_after_initial(session, snapshot, calibration, negative=None): + return fake_estimate(session, snapshot, calibration, + 'initial_missing' if snapshot['timestamp_seconds'] >= .04 else None) + with patch.object(camera, '_fresh_estimate', missing_after_initial): + recorder, _, _ = self.complete() + recording = capture.load_capture(recorder.root) + item = capture._read(recorder.root, recording['steps'][2]['perception']['path']) + self.assertEqual(item['estimate']['status'], 'missing') + self.assertIsNone(item['estimate']['object_position_estimate_m']) + + def test_validation_and_exclusive_output(self): + for changes in ({'duration_s':21.}, {'duration_s':.11}, {'source_kind':'teacher'}, + {'source_kind':'simulation_teleoperation'}): + with self.assertRaises(ValueError): + self.new(**changes) + recorder = self.new(); recorder.open() + with self.assertRaises(ValueError): + recorder.step({**STUDENT, 'gripper_position_m':.04}) + with self.assertRaises(ValueError): + recorder.record_event('oversized', {'data':'x'*4097}) + self.assertEqual(recorder.session.position, 0) + recorder.finish() + with self.assertRaises(FileExistsError): + self.new().open() + + def test_setup_failure_is_sealed_and_closed(self): + FakeSession.fail_at = 'open'; recorder = self.new() + with self.assertRaises(RuntimeError): + recorder.open() + self.assertEqual(recorder.finish()['status'], 'refused') + self.assertTrue(recorder.session.closed) + capture.inspect_capture(recorder.root) + + def test_estimator_initialization_failure_closes_and_seals_refusal(self): + recorder = self.new() + with patch.object(camera, '_fresh_estimate', side_effect=RuntimeError('estimator initialization')): + with self.assertRaisesRegex(RuntimeError, 'estimator initialization'): + recorder.open() + self.assertTrue(recorder.session.closed) + self.assertEqual(recorder.finish()['status'], 'refused') + self.assertFalse(recorder.finish()['native_evidence_saved']) + self.assertFalse((recorder.root/'native_episode.json').exists()) + capture.inspect_capture(recorder.root) + + def test_finish_before_open_cleans_up_without_creating_an_attempt(self): + recorder = self.new(); report = recorder.finish('initialization_failed') + self.assertTrue(recorder.session.closed) + self.assertFalse(recorder.root.exists()) + self.assertEqual(report['status'], 'refused') + self.assertFalse(report['sealed']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_operator_export.py b/tests/test_operator_export.py new file mode 100644 index 0000000..affd04f --- /dev/null +++ b/tests/test_operator_export.py @@ -0,0 +1,130 @@ +"""Reviewed operator labels and causal feature history, using data fixtures.""" +from copy import deepcopy +import contextlib +import io +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +import numpy as np + +from dvidia_training import camera_action_model as model +from dvidia_training import operator_capture as capture +from dvidia_training import operator_export as export +from tests.test_camera_action_capture import STUDENT, fake_estimate +from tests import test_operator_capture as fixtures + + +class OperatorExportTests(unittest.TestCase): + setUp = fixtures.OperatorCaptureTests.setUp + new = fixtures.OperatorCaptureTests.new + complete = fixtures.OperatorCaptureTests.complete + + def test_reviewed_labels_are_exact_issued_targets_and_keep_source_groups(self): + recorder, _, _ = self.complete() + dataset = export.export_episode(recorder.root, reviewed_indices=[0,2,5], + review_reason='Useful reach despite unsuccessful task.') + self.assertEqual(dataset['features'].shape, (3,269)) + np.testing.assert_array_equal(dataset['targets'], np.tile( + [*STUDENT['joint_targets_rad'], STUDENT['gripper_position_m']], (3,1))) + self.assertEqual(dataset['groups'], ['test-start']*3) + source = dataset['provenance']['episodes'][0] + self.assertEqual(source['reviewed_indices'], [0,2,5]) + self.assertFalse(source['attempt_strict_success']) + self.assertEqual(source['source_kind'], 'simulation_scripted_input') + self.assertEqual(source['label_source'], 'operator_issued') + self.assertFalse(source['review_authenticated']) + model._dataset(dataset, blind=False) + + def test_selection_uses_causal_full_prefix_history_before_subsetting(self): + recorder, _, _ = self.complete() + full = export.export_episode(recorder.root, reviewed_segments=[[0,10]], review_reason='Reviewed all rows.') + subset = export.export_episode(recorder.root, reviewed_segments=[[2,4],[7,9]], review_reason='Reviewed reaches.') + np.testing.assert_array_equal(subset['features'], full['features'][[2,3,7,8]]) + np.testing.assert_array_equal(subset['targets'], full['targets'][[2,3,7,8]]) + blind = export.export_episode(recorder.root, reviewed_indices=[3,7], review_reason='Reviewed.', blind=True) + np.testing.assert_array_equal(blind['features'][:,model.LIVE_FEATURE_START:],0) + + def test_review_selection_is_explicit_and_no_duplicates_or_bad_indices(self): + recorder, _, _ = self.complete() + for selection in ({}, {'reviewed_indices':[]}, {'reviewed_indices':[1,1]}, + {'reviewed_indices':[True]}, {'reviewed_indices':[10]}, + {'reviewed_segments':[[0,3],[2,4]]}, + {'reviewed_segments':[[0,2]],'reviewed_indices':[0]}): + with self.subTest(selection=selection), self.assertRaises(ValueError): + export.export_episode(recorder.root, review_reason='Reviewed.', **selection) + with self.assertRaisesRegex(ValueError, 'review reason'): + export.export_episode(recorder.root, reviewed_indices=[0]) + + def test_interrupted_complete_rows_require_review_but_remain_usable(self): + recorder = self.new(); recorder.open(); recorder.step(STUDENT); recorder.finish('operator_stopped') + dataset = export.export_episode(recorder.root, reviewed_indices=[0], review_reason='Retain useful first reach.') + self.assertEqual(dataset['provenance']['episodes'][0]['attempt_status'],'interrupted') + self.assertEqual(dataset['targets'].shape,(1,7)) + + def test_wrong_provenance_cannot_impersonate_operator_supervision(self): + recorder, _, _ = self.complete() + recording = capture.load_capture(recorder.root) + for change in ('teacher', 'student'): + modified = deepcopy(recording); modified['control_mode'] = change + with patch.object(capture, 'load_capture', return_value=modified): + with self.assertRaises(ValueError): + export.export_episode(recorder.root, reviewed_indices=[0], review_reason='Reviewed.') + + def test_unsealed_recovery_cannot_be_exported(self): + recorder = self.new(); recorder.open(); recorder.step(STUDENT) + recorder._journal.close(); recorder.session.close() + with self.assertRaises(FileNotFoundError): + export.export_episode(recorder.root, reviewed_indices=[0], review_reason='Reviewed.') + + def test_native_diagnostics_and_later_actions_do_not_leak_into_features(self): + recorder, _, _ = self.complete() + before = export.export_episode(recorder.root, reviewed_indices=[0,1,2], review_reason='Reviewed.') + other = self.new('other'); other.open() + for index in range(10): + action = deepcopy(STUDENT) + if index >= 3: + action['joint_targets_rad'][0] += .01 + other.step(action) + other.finish() + after = export.export_episode(other.root, reviewed_indices=[0,1,2], review_reason='Reviewed.') + np.testing.assert_array_equal(before['features'],after['features']) + np.testing.assert_array_equal(before['targets'],after['targets']) + + def test_dataset_roundtrip_and_exclusive_write(self): + recorder, _, _ = self.complete() + dataset = export.export_episode(recorder.root, reviewed_indices=[0,3,5], review_reason='Reviewed reaches.') + output = Path(self.temp.name)/'export' + self.assertEqual(export.save_dataset(dataset, output), output/'dataset.json') + loaded = export.load_dataset(output) + np.testing.assert_array_equal(loaded['features'], dataset['features']) + np.testing.assert_array_equal(loaded['targets'], dataset['targets']) + self.assertEqual(loaded['provenance'], dataset['provenance']) + with self.assertRaises(FileExistsError): + export.save_dataset(dataset, output) + target = output/'targets.npy' + body = target.read_bytes(); target.write_bytes(body[:-1]+bytes([body[-1]^1])) + with self.assertRaisesRegex(ValueError, 'byte identity'): + export.load_dataset(output) + + def test_cli_requires_exact_review_document_and_exports_without_training(self): + recorder, _, _ = self.complete() + review = Path(self.temp.name)/'review.json' + review.write_text(json.dumps(dict(segments=[[0,2]], reason='Reviewed initial reach.'))) + output = Path(self.temp.name)/'cli-export' + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual(export.main(['--capture',str(recorder.root), '--review',str(review), + '--output',str(output)]),0) + receipt = json.loads(stdout.getvalue()) + self.assertEqual(receipt['reviewed_rows'],2) + self.assertFalse(receipt['training_started']) + self.assertEqual(export.load_dataset(output)['features'].shape,(2,269)) + review.write_text(json.dumps(dict(indices=[0], reason='Reviewed.', extra=True))) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + export.main(['--capture',str(recorder.root), '--review',str(review), '--output',str(output)]) + + +if __name__ == '__main__': + unittest.main() From 4ca9804c79e330221106217eac4faca582b4efa2 Mon Sep 17 00:00:00 2001 From: DVIDIA Date: Tue, 10 Nov 2026 01:37:16 -0500 Subject: [PATCH 2/2] Use portable artifact paths for Windows demonstration captures --- benchmarks/godot-demonstration-v02/README.md | 12 + .../portability-checks.json | 534 ++++++++++++++++++ .../portability-tests.json | 24 + src/dvidia_training/operator_capture.py | 13 +- tests/test_operator_capture.py | 15 +- 5 files changed, 594 insertions(+), 4 deletions(-) create mode 100644 benchmarks/godot-demonstration-v02/portability-checks.json create mode 100644 benchmarks/godot-demonstration-v02/portability-tests.json diff --git a/benchmarks/godot-demonstration-v02/README.md b/benchmarks/godot-demonstration-v02/README.md index 9d5e137..846050c 100644 --- a/benchmarks/godot-demonstration-v02/README.md +++ b/benchmarks/godot-demonstration-v02/README.md @@ -6,6 +6,18 @@ six 320x240 RGB-D pairs, and 200 native 1 ms ticks. It does not attempt a comple placement; the independent task scorer correctly returns failure, with no critical contact ticks. No human demonstrations or model fits were performed. +The original evidence below is pinned to commit `b8b96cd`. Windows CI then +identified a local inventory serialization bug: backslash paths were correctly +rejected by the portable recording validator. The follow-up fix uses POSIX +paths for locally generated inventory entries, retaining strict validation of +imported references. `portability-tests.json` records **97 passing test executions** +including Windows/POSIX path cases. `portability-checks.json` records a fresh +windowed capture, exact replay of another 10,200 native values and a reinstall/ +export check after the fix. This second short run took **0.482349 wall seconds +for 0.2 simulated seconds**; it is a compatibility check, not a speedup claim. +Native rendering remains tested on Mac; cross-platform data tests do not +qualify Windows rendering. Original receipts are retained unchanged. + ## Results | Check | Result | diff --git a/benchmarks/godot-demonstration-v02/portability-checks.json b/benchmarks/godot-demonstration-v02/portability-checks.json new file mode 100644 index 0000000..d4de8e0 --- /dev/null +++ b/benchmarks/godot-demonstration-v02/portability-checks.json @@ -0,0 +1,534 @@ +{ + "schema": "dvidia.operator-portability-check/v1", + "operator_implementation_sha256": { + "action_demonstration.py": "439c7cce558571221660cd51e7dba1675203237706ea8ea0f97f2fcf9a6a24c5", + "camera_action_capture.py": "399f512a08087c72bfa37e382157a2250f413e423974b77b1bd3c0a911ff0a1e", + "operator_capture.py": "f1b5b5c5abbdf090342bb18df76e95f1fd518bd05fc5705aedb956f0ce081715", + "piper_action_capture.py": "da037b2198cf7d2f3a9aa40fc19a4c0f5cc375a22c5dd75606e2c1856f6efc7f", + "piper_encoder_kinematics.py": "6606bb8aad04e5abb1e9b6d1a8ec628a4114acd167862167184fd4eef1d21c34", + "piper_feedback_env.py": "0dbae423231968a81bdeed52226d8dffaf8df622013ed3a360d7217148f1bcac", + "piper_feedback_score.py": "863a272828208c631de4cf57647e973eb3e4f5e99eb7a1eacb4eaa68dc42eb0e", + "piper_imitation_score.py": "0cd4663abfdd7f14f2f33d93e9a2bd45122b998d6160d45b8afcc880512cba2b", + "piper_rgbd_tracker.py": "43202cfcea404061a3a65124d6ad24aeeec5fae8ffaf863caa245f2f54c46c36", + "piper_task.py": "e66a44a8df4e7a8ee42e4f3866f889f8797cdfb8e2688a146e09b75d2c76664f" + }, + "native_replay": { + "capture_integrity_sha256": "3b5ca185dc1ca7946064c5eb66d6b7e5dfa7b1f28cf87ffd8259e1d3b78d3ac4", + "control_intervals": 10, + "exact_native_arrays": true, + "fields": { + "applied_jaw_target_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "arm_actuator_controls_rad": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 6 + ] + }, + "grasp_contact_counts": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 2 + ] + }, + "gripper_joint_position_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 2 + ] + }, + "joint_position_rad": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 6 + ] + }, + "native_tick": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "normal_rate_limited_jaw_target_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "object_angular_velocity_rad_s": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 3 + ] + }, + "object_linear_velocity_m_s": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 3 + ] + }, + "object_minus_tcp_local_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 3 + ] + }, + "object_position_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 3 + ] + }, + "object_quaternion_wxyz": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 4 + ] + }, + "pad_normal_forces_n": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 2 + ] + }, + "pulse_active": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "pulse_closing_tail": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "pulse_minimum_opening_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "requested_jaw_target_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "t_seconds": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "table_object_contact_count": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + }, + "tcp_position_m": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 3 + ] + }, + "tcp_quaternion_wxyz": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200, + 4 + ] + }, + "unexpected_contact_count": { + "exact": true, + "max_abs_error": 0.0, + "shape": [ + 200 + ] + } + }, + "independent_score_matches": true, + "maximum_abs_error": 0.0, + "native_values_compared": 10200, + "original_strict_success": false, + "physical_robot_ready": false, + "recording_sha256": "6b03befc86be692505679f0d7685bba56ab4224db7ac5ddbd78422551d95d4e3", + "schema": "dvidia.operator-native-replay/v1", + "scope": "CPU native command replay including reset; excludes camera, Godot, fitting and hardware. Native parity is separate from successful task completion.", + "score": { + "evidence_scope": "Recomputed simulator-authored numeric evidence; no physics rerun, source attestation or hardware sensing.", + "false_alarms": { + "reason": "Continuous residual policy has no declared discrete alarm events.", + "status": "not_applicable" + }, + "grip_events": [], + "grip_summary": { + "airborne_events": 0, + "loss_events": 0, + "qualification_latency_s": [], + "restored_events": 0, + "supported_events": 0, + "unresolved_events": 0 + }, + "native_diagnostics": { + "premature_open_jaw": { + "count": 0, + "intervals": [], + "maximum_s": 0.0, + "total_s": 0.0 + }, + "support_touchdown_before_destination": { + "count": 0, + "intervals": [], + "maximum_s": 0.0, + "total_s": 0.0 + } + }, + "native_metrics_mismatches": [], + "native_recomputed": { + "carry_complete": false, + "carry_contact_loss_events": 0, + "dwell_seconds": 0.0, + "first_success_time_seconds": null, + "grasp_seen": false, + "lift_dwell_seconds": 0.0, + "lift_seen": false, + "max_carry_contact_loss_seconds": 0.0, + "max_object_lift_m": 6.229478044517123e-11, + "max_pad_normal_force_n": 0.0, + "retention_lost": false, + "success": false, + "unexpected_contact_native_ticks": 0 + }, + "reason": "native_outcome_failed", + "restoration_scope": "Loaded contact restored for 100 ms after sustained loss; does not establish task recovery or airborne regrasp.", + "schema": "dvidia.piper-feedback-score/v1", + "strict_v01": { + "native_metrics_source": "simulator-authored episode record; not authenticated sensor input", + "native_outcome_success": false, + "native_outcome_valid": true, + "reason": "native_outcome_failed", + "reference_xy_m": [ + 0.35, + 0.08 + ], + "required_dwell_s": 0.25, + "required_lift_dwell_s": 0.1, + "sample_interval_s": 0.02, + "success": false, + "terminal_dwell_s": 0.0, + "terminal_reference_success": false, + "terminal_xy_error_m": 0.12083027970892407, + "trace_lift_dwell_s": 0.0, + "trajectory_complete": true + }, + "success": false, + "terminal_only": { + "dwell_s": 0.0, + "scope": "Terminal-only geometry/release/settling/dwell; excludes lift, carry and retention history.", + "success": false, + "terminal_xy_error_m": 0.12083027970892407 + }, + "valid": true + }, + "simulated_seconds": 0.20000000000000015, + "source_kind": "simulation_scripted_input", + "teacher_queries": 0, + "wall_seconds": 0.3151376669993624 + }, + "fresh_install": { + "schema": "dvidia.operator-portability-install/v1", + "source_sha256": { + "operator_capture.py": "f1b5b5c5abbdf090342bb18df76e95f1fd518bd05fc5705aedb956f0ce081715", + "operator_export.py": "9ff7ac7eeae179df340b0035a982c1672e01bd51d56c1e2dbdfa2e5b0a5455a7" + }, + "installed_source_matches": true, + "feature_shape": [ + 10, + 269 + ], + "target_shape": [ + 10, + 7 + ], + "source_kind": "simulation_scripted_input", + "base_cli_only": true, + "native_graphics_in_existing_environment": true, + "training_started": false, + "physical_robot_ready": false + }, + "windowed": { + "applied_sim_time_seconds": 0.2, + "client_script_sha256": "98aac9a91f93a0389943289aab4f58982d6cc1e13170d5ec71d732c5b05f802f", + "errors": [], + "expected_model_sha256": "9c12b9437e4825685921a229129e2154a225e308593f227d17042852926e060f", + "godot": "4.7.2-stable (official)", + "initial_sim_time_seconds": 0.0, + "initial_state": { + "body_positions": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0, + 0.0, + 0.123 + ], + [ + 0.0285001685693837, + 8.99494444381412e-07, + 0.406602785364861 + ], + [ + 0.279938606266863, + -1.22010801876944e-06, + 0.394831449851912 + ], + [ + 0.279938606266863, + -1.22010801876944e-06, + 0.394831449851912 + ], + [ + 0.370119754365555, + 0.00141459357745771, + 0.382651036416412 + ], + [ + 0.503934732048841, + -0.0335858519235436, + 0.364577255289232 + ], + [ + 0.50393494030113, + 0.0364141480752759, + 0.364577071719593 + ], + [ + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838 + ], + [ + 0.35, + 0.08, + 0.0605 + ] + ], + "body_quaternions": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.70710528118436, + 0.0, + 0.0, + -0.707108281185553 + ], + [ + 0.499797817587225, + -0.499803939752559, + -0.500197982610706, + -0.500200101494439 + ], + [ + 0.113506973362852, + -0.113513180244746, + -0.697937502868791, + -0.697935646745461 + ], + [ + -5.14302108226427e-06, + -0.752944436272245, + 3.06932012868452e-06, + -0.658084094816764 + ], + [ + 0.532409609503324, + -0.532414623999283, + -0.46533454282896, + -0.465336909250043 + ], + [ + -0.658084094823863, + -2.77304662247931e-07, + -0.752944436287289, + 1.94854711149505e-06 + ], + [ + 0.532411855874789, + -0.532412377639585, + -0.465336531348717, + -0.4653349207318 + ], + [ + -0.532414636467881, + -0.532409597034671, + 0.465336894984113, + -0.465334557094891 + ], + [ + 0.999999999999999, + 1.67902365554781e-13, + -3.31279997277328e-14, + -8.1080055409147e-19 + ], + [ + 1.0, + 0.0, + 0.0, + 0.0 + ] + ], + "ctrl": [ + 0.0, + 1.57, + -1.3485, + 0.0, + 0.0, + 0.0, + 0.035 + ], + "native_time": 0.5, + "object_position": [ + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838 + ], + "position_error_m": 0.0, + "qpos": [ + 4.033464064055e-20, + 1.57, + -1.3485, + -2.85702112257035e-18, + -1.3639173671723e-16, + 9.79662157176411e-18, + 0.035, + -0.035, + 0.300000035805163, + -0.0299998185193901, + 0.0798922445174838, + 0.999999999999999, + 1.67902365554781e-13, + -3.31279997277328e-14, + -8.1080055409147e-19 + ], + "scope": "Viewer state includes native truth; excluded from learning inputs.", + "tcp_position": [ + 0.489069666284522, + 0.00141419756548088, + 0.366584943104309 + ], + "tcp_quaternion": [ + 0.658084094823863, + 2.77304662247931e-07, + 0.752944436287289, + -1.94854711149505e-06 + ], + "time": 0.0 + }, + "interactive_wall_seconds": 0.482349, + "logical_processors": 10, + "ok": true, + "physics_authority": "MuJoCo only", + "process_frame_interval_ms": { + "p50": 8.0, + "p95": 9.17199999999998, + "samples": 58 + }, + "processor": "Apple M5", + "project_sha256": "d886d21c17931d0fef863e6f219cb9da10bc4b916d4cd4bbb70012f07f47e556", + "render_process_frames": 71, + "requested_steps": 10, + "reset_responses": 0, + "rpc_roundtrip_ms": { + "p50": 11.436, + "p95": 56.971, + "samples": 12 + }, + "scene_integrity_sha256": "603d2ccecd20b890e67a62357dbd811dcbbf33714a90c2fbe2164403fd5c598f", + "schema": "dvidia.godot-demonstration-smoke/v1", + "server_compute_total_ms": 183.420040935744, + "step_responses": 10, + "timing_scope": "Startup-inclusive wall time; ready-to-completion interactive wall; Godot process delta intervals (not GPU render time); socket request-to-parsed-response latency; server compute includes controller, native camera capture and disk writes; excludes IPC and Godot rendering. Samples capped at 10000 per series.", + "trace_limit": 256, + "wall_seconds": 1.388024 + }, + "recording": { + "episode": 1.0, + "max_episodes": 1.0, + "max_steps": 10.0, + "physical_robot_ready": false, + "source_authenticated": false, + "source_kind": "simulation_scripted_input", + "status": "finished", + "steps": 10.0 + }, + "task_strict_success": false, + "source_kind": "simulation_scripted_input", + "native_capture_integrity_sha256": "3b5ca185dc1ca7946064c5eb66d6b7e5dfa7b1f28cf87ffd8259e1d3b78d3ac4", + "original_receipts_sha256": { + "windowed-portable-client.json": "20ca61532228acb74dda6777bb5c72d0a88a1a50f364644aaa1b054b4c071077", + "native-replay-portable.json": "1d672119fa7197a8b82a20c3e07e790b52181cd2f6e78ef76429e082847ec8f6", + "install-portable.json": "c7186086f77fd9751fa35550703d2d5cd79e4aaf2cd9259c1950f00e2dc227f3" + }, + "scope": "Short scripted compatibility check, no full placement or model fitting. Raw synthetic capture and full trace retained locally." +} diff --git a/benchmarks/godot-demonstration-v02/portability-tests.json b/benchmarks/godot-demonstration-v02/portability-tests.json new file mode 100644 index 0000000..6a4b9bf --- /dev/null +++ b/benchmarks/godot-demonstration-v02/portability-tests.json @@ -0,0 +1,24 @@ +{ + "schema": "dvidia.operator-portability-verification/v1", + "test_modules": [ + "tests.test_operator_capture", + "tests.test_operator_export", + "tests.test_godot_demonstration_bridge", + "tests.test_camera_action_capture", + "tests.test_camera_action_export", + "tests.test_godot_bridge", + "tests.test_action_demonstration" + ], + "test_executions": 97, + "passed": true, + "failures": 0, + "errors": 0, + "output": ".................................................................................................\n----------------------------------------------------------------------\nRan 97 tests in 2.740s\n\nOK\n", + "source_sha256": { + "src/dvidia_training/operator_capture.py": "f1b5b5c5abbdf090342bb18df76e95f1fd518bd05fc5705aedb956f0ce081715", + "tests/test_operator_capture.py": "a30bdc8522538e1f0cd32f77e0a0ac4c9f14a2f5bc4d04a9c84c683c7fae5ad3" + }, + "prior_evidence_commit": "b8b96cd60bff4eb4be7902b7d9d3d47596037e08", + "change_scope": "Serialize locally discovered inventory paths as POSIX; imported paths remain strict.", + "native_physics_executed_by_these_tests": false +} diff --git a/src/dvidia_training/operator_capture.py b/src/dvidia_training/operator_capture.py index 5976607..450fd63 100644 --- a/src/dvidia_training/operator_capture.py +++ b/src/dvidia_training/operator_capture.py @@ -51,6 +51,13 @@ def _identity(root, name): return dict(bytes=len(body), sha256=sha256(body).hexdigest()) +def _artifact_name(root, path): + """Serialize local inventory paths using the platform-independent tape contract.""" + name = path.relative_to(root).as_posix() + tape._relative(name) + return name + + def _detail(value): if value is not None and type(value) is not dict: raise ValueError('Input/event metadata must be a JSON object or null.') @@ -493,8 +500,8 @@ def finish(self, reason='operator_stopped'): timing=dict(monotonic_wall_seconds=time.monotonic()-self._started_wall, scope='Paused-simulation acquisition; no wall-clock delivery guarantee.')) base._json(self.root/'capture.json', report) - files = {str(path.relative_to(self.root)): _identity(self.root, str(path.relative_to(self.root))) - for path in sorted(self.root.rglob('*')) if path.is_file()} + names = [_artifact_name(self.root, path) for path in sorted(self.root.rglob('*')) if path.is_file()] + files = {name: _identity(self.root, name) for name in names} base._json(self.root/'integrity.json', dict(schema=SCHEMA+'/integrity', files=files)) self._sealed = True; self._report = report return deepcopy(report) @@ -694,7 +701,7 @@ def inspect_capture(directory): root = Path(directory); manifest = _read(root, 'integrity.json') if manifest.get('schema') != SCHEMA+'/integrity' or type(manifest.get('files')) is not dict: raise ValueError('An operator recording requires a sealed integrity manifest.') - observed = {str(path.relative_to(root)) for path in root.rglob('*') + observed = {_artifact_name(root, path) for path in root.rglob('*') if path.is_file() and path != root/'integrity.json'} if observed != set(manifest['files']): raise ValueError('Operator recording inventory differs.') diff --git a/tests/test_operator_capture.py b/tests/test_operator_capture.py index 5aae499..2953bf8 100644 --- a/tests/test_operator_capture.py +++ b/tests/test_operator_capture.py @@ -2,7 +2,7 @@ from copy import deepcopy from hashlib import sha256 import json -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from tempfile import TemporaryDirectory import unittest from unittest.mock import patch @@ -34,6 +34,19 @@ def complete(self, name='capture', **kwargs): report = recorder.finish() return recorder, opened, report + def test_inventory_names_are_portable_without_normalizing_imported_references(self): + windows_root = PureWindowsPath('D:/recordings/operator-run') + posix_root = PurePosixPath('/recordings/operator-run') + for root in (windows_root, posix_root): + name = capture._artifact_name(root, root/'frames'/'rgb-0000.png') + self.assertEqual(name, 'frames/rgb-0000.png') + self.assertEqual(str(tape._relative(name)), name) + self.assertEqual(capture._artifact_name(root, root/'native'/'0001.json'), 'native/0001.json') + with self.assertRaises(ValueError): + capture._artifact_name(windows_root, PureWindowsPath('D:/outside/0001.json')) + with self.assertRaisesRegex(ValueError, 'relative path'): + capture._identity(Path(self.temp.name), r'frames\rgb-0000.png') + def test_external_commands_cadence_and_no_teacher(self): recorder, opened, report = self.complete(source_kind='simulation_human_input') self.assertEqual(report['status'], 'complete')