From a7e199b0378c1de356a865345532f4209122cca6 Mon Sep 17 00:00:00 2001 From: AnikethCheluva Date: Fri, 23 Oct 2026 17:57:10 -0400 Subject: [PATCH] [Preparation 1/2] remove committed tests and retain configuration audits --- .github/workflows/ci.yml | 13 +- AGENTS.md | 15 +- docs/VERIFICATION_POLICY.md | 27 + docs/YAM_RUNTIME.md | 3 + docs/integration/DATA_TOOLS.md | 3 + egomimic/eval/AGENTS.md | 5 +- egomimic/rldb/AGENTS.md | 5 +- egomimic/rldb/zarr/test_dataset_filter.py | 135 -- egomimic/robot/AGENTS.md | 3 +- egomimic/robot/eva/test_mink_solver.py | 853 ------- egomimic/robot/test.py | 10 - egomimic/utils/aws/test_s3_write.py | 87 - egomimic/utils/aws/test_sql_access.py | 54 - .../.github/workflows/nightly-tests.yml | 77 - external/lerobot/.github/workflows/test.yml | 139 -- external/lerobot/Makefile | 185 +- external/lerobot/tests/__init__.py | 0 external/lerobot/tests/conftest.py | 126 -- external/lerobot/tests/fixtures/constants.py | 29 - .../tests/fixtures/dataset_factories.py | 396 ---- external/lerobot/tests/fixtures/files.py | 114 - external/lerobot/tests/fixtures/hub.py | 105 - .../lerobot/common/datasets/test_utils.py | 38 - external/lerobot/tests/mock_cv2.py | 83 - external/lerobot/tests/mock_dynamixel_sdk.py | 94 - external/lerobot/tests/mock_pyrealsense2.py | 135 -- external/lerobot/tests/mock_scservo_sdk.py | 103 - .../scripts/save_dataset_to_safetensors.py | 90 - .../save_image_transforms_to_safetensors.py | 86 - .../scripts/save_policy_to_safetensors.py | 133 -- external/lerobot/tests/test_available.py | 65 - external/lerobot/tests/test_cameras.py | 191 -- external/lerobot/tests/test_control_robot.py | 448 ---- external/lerobot/tests/test_datasets.py | 416 ---- .../lerobot/tests/test_delta_timestamps.py | 256 --- external/lerobot/tests/test_envs.py | 69 - external/lerobot/tests/test_examples.py | 147 -- .../lerobot/tests/test_image_transforms.py | 302 --- external/lerobot/tests/test_image_writer.py | 359 --- external/lerobot/tests/test_motors.py | 144 -- external/lerobot/tests/test_online_buffer.py | 282 --- external/lerobot/tests/test_policies.py | 468 ---- .../lerobot/tests/test_push_dataset_to_hub.py | 370 --- external/lerobot/tests/test_robots.py | 143 -- external/lerobot/tests/test_sampler.py | 90 - external/lerobot/tests/test_utils.py | 79 - .../lerobot/tests/test_visualize_dataset.py | 33 - external/lerobot/tests/utils.py | 387 ---- pyproject.toml | 4 - scripts/audit_components.py | 2 +- scripts/local_cpu_storage_guard.py | 20 - .../qwen3_embedding_06b_config.json | 0 scripts/run_local_cpu_validation.sh | 38 - tests/__init__.py | 1 - tests/fixtures/__init__.py | 0 tests/fixtures/e1_source_metrics.json | 209 -- tests/fixtures/legacy_alignment_reference.py | 75 - tests/fixtures/legacy_hpt_trunk/metadata.json | 46 - tests/fixtures/legacy_hpt_trunk/reference.pt | Bin 41018 -> 0 bytes tests/fixtures/legacy_hpt_trunk/trunk.pth | Bin 36175 -> 0 bytes tests/fixtures/legacy_language_schedule.json | 2011 ----------------- .../aria-cartesian-camframe.json | 26 - .../aria-cartesian-camframe.npz | Bin 38565 -> 0 bytes .../aria-cartesian-eef_frame.json | 26 - .../aria-cartesian-eef_frame.npz | Bin 37688 -> 0 bytes .../aria-keypoints-camframe.json | 26 - .../aria-keypoints-camframe.npz | Bin 71866 -> 0 bytes .../aria-keypoints-eef_frame.json | 26 - .../aria-keypoints-eef_frame.npz | Bin 71997 -> 0 bytes .../eva-cartesian-camframe.json | 27 - .../eva-cartesian-camframe.npz | Bin 93192 -> 0 bytes .../eva-cartesian-eef_frame.json | 27 - .../eva-cartesian-eef_frame.npz | Bin 93216 -> 0 bytes .../mecka-cartesian-camframe.json | 26 - .../mecka-cartesian-camframe.npz | Bin 38565 -> 0 bytes .../mecka-cartesian-eef_frame.json | 26 - .../mecka-cartesian-eef_frame.npz | Bin 37688 -> 0 bytes .../scale-cartesian-camframe.json | 26 - .../scale-cartesian-camframe.npz | Bin 38565 -> 0 bytes .../scale-cartesian-eef_frame.json | 26 - .../scale-cartesian-eef_frame.npz | Bin 37688 -> 0 bytes .../libero_native_scontrol_408787.txt | 1 - .../libero_native_suite_corpus_authority.json | 36 - .../pusht_config_before_parent_cleanup.json | 88 - tests/fixtures/synthetic_episodes.py | 146 -- tests/libero_ddp_resume_worker.py | 64 - tests/oat_reference_fixtures.py | 34 - tests/test_abc_yam.py | 229 -- tests/test_action_chunk_transforms.py | 656 ------ tests/test_action_flow_affine_configs.py | 138 -- tests/test_action_flow_av_norm.py | 261 --- tests/test_action_flow_candidate_codec.py | 151 -- tests/test_action_flow_candidate_configs.py | 181 -- tests/test_action_flow_candidate_gates.py | 295 --- tests/test_action_flow_candidate_stages.py | 246 -- tests/test_action_flow_configs.py | 151 -- tests/test_action_flow_diagnostics.py | 929 -------- tests/test_action_flow_diffusion.py | 202 -- ...on_flow_jacobian_checkpoint_performance.py | 555 ----- tests/test_action_flow_launcher.py | 314 --- tests/test_action_flow_likelihood.py | 435 ---- tests/test_action_flow_manual4919_config.py | 132 -- tests/test_action_flow_model_wrapper.py | 801 ------- tests/test_action_flow_modules.py | 220 -- tests/test_action_flow_smoke_schedule.py | 336 --- tests/test_action_flow_stages.py | 539 ----- tests/test_action_flow_training_behavior.py | 988 -------- tests/test_action_flow_unite_recipe.py | 268 --- tests/test_action_latent_vfm.py | 474 ---- tests/test_alignment_parity.py | 189 -- tests/test_arc_duration_rollout_bridge.py | 51 - tests/test_arc_eval_detokenize.py | 421 ---- tests/test_arc_length_tokenizer.py | 844 ------- tests/test_arc_metrics.py | 686 ------ tests/test_arc_oat.py | 402 ---- tests/test_batch_signal_relay.py | 161 -- tests/test_bound_inference.py | 198 -- tests/test_bounds_check_gate.py | 148 -- tests/test_c12_compatibility.py | 224 -- tests/test_capture_runtime_lock.py | 38 - tests/test_chain_gripper_points.py | 130 -- tests/test_check_checkpoint_storage.py | 150 -- tests/test_config_graph.py | 249 -- tests/test_consolidated_core_configs.py | 91 - tests/test_current_family_profiles.py | 220 -- tests/test_data_context_contract.py | 386 ---- tests/test_data_tool_parity.py | 176 -- tests/test_dataloader_rng_state.py | 54 - tests/test_deployment_data_requirements.py | 130 -- .../test_dp_action_flow_observation_parity.py | 198 -- tests/test_e1_arc_codec.py | 106 - tests/test_e1_campaign_configs.py | 97 - tests/test_e1_data_adapters.py | 107 - tests/test_e1_graph_evaluation.py | 193 -- tests/test_ema_resume.py | 66 - tests/test_episode_limited_validation.py | 74 - tests/test_episode_selection.py | 69 - tests/test_eval_overlay_annotations.py | 44 - tests/test_gaussian_paraboloid_dataset.py | 40 - tests/test_gaussian_torus_dataset.py | 63 - tests/test_gello_calibration.py | 130 -- tests/test_gello_teleop.py | 1078 --------- tests/test_hpt_configs.py | 173 -- tests/test_hpt_cores.py | 176 -- tests/test_hpt_qwen_configs.py | 75 - tests/test_hpt_stages.py | 470 ---- tests/test_human_arc.py | 320 --- tests/test_hydra_data_config.py | 97 - tests/test_hydra_override.py | 118 - .../test_ice_checkpoint_archive_inventory.py | 216 -- tests/test_ice_checkpoint_mirror.py | 420 ---- tests/test_ice_checkpoint_mirror_pool.py | 310 --- tests/test_ice_dp_campaign_contract.py | 28 - tests/test_ice_gpu_probe.py | 50 - tests/test_ice_planar_bc_launcher.py | 110 - tests/test_ice_requeue_runner.py | 1476 ------------ .../test_imported_recipe_framework_parity.py | 68 - tests/test_inference_config.py | 298 --- tests/test_inference_contract_boundaries.py | 132 -- tests/test_installed_package.py | 102 - tests/test_legacy_data_metric_parity.py | 86 - tests/test_legacy_hpt_trunk_parity.py | 93 - tests/test_libero_action_flow_artifacts.py | 153 -- tests/test_libero_action_flow_boundary.py | 240 -- tests/test_libero_active_recipes.py | 74 - tests/test_libero_arc.py | 177 -- tests/test_libero_arc_global.py | 295 --- tests/test_libero_arc_sweep.py | 329 --- tests/test_libero_arc_timed.py | 216 -- tests/test_libero_basis_scaling_audit.py | 113 - tests/test_libero_benchmark.py | 286 --- tests/test_libero_cluster.py | 750 ------ tests/test_libero_decoded_replay.py | 140 -- tests/test_libero_evaluate.py | 266 --- tests/test_libero_multi_embodiment_native.py | 1286 ----------- tests/test_libero_native_binding_parser.py | 75 - tests/test_libero_native_checkpoint_format.py | 160 -- ...est_libero_native_half_factory_identity.py | 26 - tests/test_libero_native_launch_contract.py | 102 - .../test_libero_native_scheduler_contract.py | 101 - tests/test_libero_native_smoke_contract.py | 54 - tests/test_libero_native_suite_profiles.py | 857 ------- tests/test_libero_native_velocity_ablation.py | 276 --- tests/test_libero_native_whole_launcher.py | 417 ---- tests/test_libero_performance.py | 313 --- tests/test_libero_replay.py | 356 --- tests/test_libero_saved_hydra_context.py | 341 --- tests/test_libero_saved_native_state.py | 159 -- tests/test_libero_training_checks.py | 85 - tests/test_libero_tuned_configs.py | 79 - ...est_likelihood_training_behavior_parity.py | 171 -- tests/test_load_hook_runtime_compatibility.py | 32 - tests/test_local_cpu_storage_guard.py | 25 - tests/test_local_cpu_validation_guard.py | 40 - tests/test_local_episode_resolver_override.py | 36 - tests/test_logging_run_provenance.py | 108 - tests/test_matched_cotrain_dp.py | 288 --- tests/test_measured_speed_removed.py | 76 - tests/test_model_wrapper_metrics.py | 166 -- tests/test_mot_action_codec.py | 438 ---- tests/test_multiplier_condition.py | 255 --- tests/test_multiplier_training_launch.py | 93 - tests/test_native_diffusion_transformer.py | 71 - .../test_native_final_validation_lifecycle.py | 135 -- tests/test_nested_val_groups.py | 182 -- tests/test_oat_cotrain_boundary.py | 73 - tests/test_oat_diffusion.py | 196 -- tests/test_oat_native.py | 381 ---- tests/test_oat_step_budget.py | 214 -- tests/test_oat_training.py | 203 -- tests/test_oat_yam_observation_boundary.py | 218 -- tests/test_offline_constructor_inputs.py | 119 - tests/test_offline_static_buffer_audit.py | 51 - tests/test_open_loop_sim_eval.py | 336 --- ...riginal_size_action_flow_cotrain_config.py | 72 - tests/test_osmo_gate.py | 122 - tests/test_partial_normalization_recovery.py | 65 - tests/test_pi05_action_encoding.py | 121 - tests/test_pi05_graph.py | 263 --- tests/test_pi05_norm_rot6d.py | 716 ------ tests/test_pi_camera_slots.py | 97 - tests/test_pi_prompts.py | 58 - tests/test_pi_source_patch.py | 50 - tests/test_pipeline_algo.py | 273 --- tests/test_pipeline_arc.py | 702 ------ tests/test_pipeline_core.py | 123 - tests/test_pipeline_flow.py | 372 --- tests/test_pipeline_initialization.py | 118 - tests/test_pipeline_observation.py | 228 -- tests/test_pipeline_only_surface.py | 51 - tests/test_pipeline_scheduler.py | 24 - tests/test_pipeline_standard_dp.py | 292 --- tests/test_planar_arc.py | 192 -- tests/test_planar_configs.py | 290 --- tests/test_planar_native_sample_axes.py | 131 -- tests/test_planar_runtime.py | 549 ----- tests/test_proportional_batch_sampler.py | 69 - tests/test_pusht_parent_cleanup.py | 91 - tests/test_rank_synthetic_trajectory_nn.py | 207 -- tests/test_ravens_contract.py | 236 -- tests/test_ravens_rollout.py | 261 --- tests/test_recursive_config_audit.py | 114 - tests/test_retained_hpt_variants.py | 151 -- tests/test_retained_language.py | 134 -- tests/test_retained_pi_parity.py | 140 -- tests/test_retained_recipe_steps.py | 164 -- tests/test_retiming_loader.py | 117 - tests/test_retiming_multiplier_condition.py | 174 -- tests/test_robot_arc_campaigns.py | 79 - tests/test_robot_graph_policy.py | 703 ------ tests/test_robot_retiming.py | 84 - tests/test_robot_runtime.py | 918 -------- tests/test_rollout_dashboard.py | 1323 ----------- tests/test_rotation_modes.py | 156 -- tests/test_shared_cartesian_dp.py | 83 - tests/test_shared_robot_components.py | 97 - tests/test_slurm_save_only_checkpoint.py | 271 --- tests/test_speed_config.py | 77 - tests/test_speed_retiming_diagnostic.py | 123 - tests/test_standard_dp_active_dimensions.py | 148 -- tests/test_standard_dp_launch_args.py | 16 - tests/test_standard_dp_pid_transport.py | 169 -- .../test_stationary_causal_history_recipe.py | 74 - tests/test_sync_runtime_script.py | 29 - tests/test_synthetic_action_adapter_flow.py | 234 -- ...est_synthetic_multi_action_adapter_flow.py | 125 - tests/test_synthetic_shared_latent_flow.py | 55 - tests/test_synthetic_trainer_resume.py | 188 -- tests/test_synthetic_trainer_signals.py | 181 -- tests/test_synthetic_trajectory_eval.py | 120 - tests/test_temporal_action_codec.py | 111 - tests/test_text_encoders.py | 20 - tests/test_three_source_reconciliation.py | 209 -- tests/test_token_diagnostics.py | 297 --- .../test_train_hydra_checkpoint_provenance.py | 33 - tests/test_train_hydra_slurm_environment.py | 271 --- tests/test_trainer_pid_signal.py | 200 -- tests/test_unite_action_velocity.py | 184 -- tests/test_unite_diagnostics.py | 117 - tests/test_unite_energy.py | 697 ------ .../test_unite_fixed_step_requeue_boundary.py | 121 - tests/test_unite_ice_contract.py | 106 - tests/test_unite_pinned_latent_norm.py | 66 - tests/test_unite_register.py | 651 ------ tests/test_unite_split_tokenizer.py | 178 -- tests/test_unite_training.py | 507 ----- tests/test_validate_action_flow_config.py | 421 ---- tests/test_validate_lightning_checkpoint.py | 501 ---- tests/test_validate_planar_dataset.py | 241 -- tests/test_validate_slurm_job_contract.py | 240 -- .../test_verify_action_flow_training_smoke.py | 1026 --------- tests/test_video_contract.py | 204 -- tests/test_virtual_gello_teleop.py | 127 -- tests/test_visual_core.py | 64 - tests/test_wandb_health.py | 120 - tests/test_wrist6d_roundtrip.py | 523 ----- tests/test_yam_action_flow_eval.py | 95 - tests/test_yam_gpu_uuid_contract.py | 67 - ...st_yam_human_keypoints_action_flow_eval.py | 99 - tests/test_yam_human_launch_contract.py | 85 - ...test_yam_human_lightning_sampler_resume.py | 89 - tests/test_yam_human_open_loop_eval.py | 108 - tests/test_yam_human_phase_composition.py | 136 -- ...est_yam_human_refreshed_corpus_contract.py | 193 -- .../test_yam_human_shared_gradient_routes.py | 110 - tests/test_yam_human_speed_contract.py | 211 -- ...st_yam_human_validation_loader_topology.py | 80 - tests/test_yam_native_dit_half.py | 144 -- tests/test_yam_packaging.py | 96 - tests/test_zarr_content_manifest.py | 212 -- tools/test_libero_maintained_dispatch_v1.py | 132 -- 311 files changed, 53 insertions(+), 63656 deletions(-) create mode 100644 docs/VERIFICATION_POLICY.md delete mode 100644 egomimic/rldb/zarr/test_dataset_filter.py delete mode 100644 egomimic/robot/eva/test_mink_solver.py delete mode 100644 egomimic/robot/test.py delete mode 100644 egomimic/utils/aws/test_s3_write.py delete mode 100644 egomimic/utils/aws/test_sql_access.py delete mode 100644 external/lerobot/.github/workflows/nightly-tests.yml delete mode 100644 external/lerobot/.github/workflows/test.yml delete mode 100644 external/lerobot/tests/__init__.py delete mode 100644 external/lerobot/tests/conftest.py delete mode 100644 external/lerobot/tests/fixtures/constants.py delete mode 100644 external/lerobot/tests/fixtures/dataset_factories.py delete mode 100644 external/lerobot/tests/fixtures/files.py delete mode 100644 external/lerobot/tests/fixtures/hub.py delete mode 100644 external/lerobot/tests/lerobot/common/datasets/test_utils.py delete mode 100644 external/lerobot/tests/mock_cv2.py delete mode 100644 external/lerobot/tests/mock_dynamixel_sdk.py delete mode 100644 external/lerobot/tests/mock_pyrealsense2.py delete mode 100644 external/lerobot/tests/mock_scservo_sdk.py delete mode 100644 external/lerobot/tests/scripts/save_dataset_to_safetensors.py delete mode 100644 external/lerobot/tests/scripts/save_image_transforms_to_safetensors.py delete mode 100644 external/lerobot/tests/scripts/save_policy_to_safetensors.py delete mode 100644 external/lerobot/tests/test_available.py delete mode 100644 external/lerobot/tests/test_cameras.py delete mode 100644 external/lerobot/tests/test_control_robot.py delete mode 100644 external/lerobot/tests/test_datasets.py delete mode 100644 external/lerobot/tests/test_delta_timestamps.py delete mode 100644 external/lerobot/tests/test_envs.py delete mode 100644 external/lerobot/tests/test_examples.py delete mode 100644 external/lerobot/tests/test_image_transforms.py delete mode 100644 external/lerobot/tests/test_image_writer.py delete mode 100644 external/lerobot/tests/test_motors.py delete mode 100644 external/lerobot/tests/test_online_buffer.py delete mode 100644 external/lerobot/tests/test_policies.py delete mode 100644 external/lerobot/tests/test_push_dataset_to_hub.py delete mode 100644 external/lerobot/tests/test_robots.py delete mode 100644 external/lerobot/tests/test_sampler.py delete mode 100644 external/lerobot/tests/test_utils.py delete mode 100644 external/lerobot/tests/test_visualize_dataset.py delete mode 100644 external/lerobot/tests/utils.py delete mode 100644 scripts/local_cpu_storage_guard.py rename {tests/fixtures => scripts/resources}/qwen3_embedding_06b_config.json (100%) delete mode 100644 scripts/run_local_cpu_validation.sh delete mode 100644 tests/__init__.py delete mode 100644 tests/fixtures/__init__.py delete mode 100644 tests/fixtures/e1_source_metrics.json delete mode 100644 tests/fixtures/legacy_alignment_reference.py delete mode 100644 tests/fixtures/legacy_hpt_trunk/metadata.json delete mode 100644 tests/fixtures/legacy_hpt_trunk/reference.pt delete mode 100644 tests/fixtures/legacy_hpt_trunk/trunk.pth delete mode 100644 tests/fixtures/legacy_language_schedule.json delete mode 100644 tests/fixtures/legacy_pipeline/aria-cartesian-camframe.json delete mode 100644 tests/fixtures/legacy_pipeline/aria-cartesian-camframe.npz delete mode 100644 tests/fixtures/legacy_pipeline/aria-cartesian-eef_frame.json delete mode 100644 tests/fixtures/legacy_pipeline/aria-cartesian-eef_frame.npz delete mode 100644 tests/fixtures/legacy_pipeline/aria-keypoints-camframe.json delete mode 100644 tests/fixtures/legacy_pipeline/aria-keypoints-camframe.npz delete mode 100644 tests/fixtures/legacy_pipeline/aria-keypoints-eef_frame.json delete mode 100644 tests/fixtures/legacy_pipeline/aria-keypoints-eef_frame.npz delete mode 100644 tests/fixtures/legacy_pipeline/eva-cartesian-camframe.json delete mode 100644 tests/fixtures/legacy_pipeline/eva-cartesian-camframe.npz delete mode 100644 tests/fixtures/legacy_pipeline/eva-cartesian-eef_frame.json delete mode 100644 tests/fixtures/legacy_pipeline/eva-cartesian-eef_frame.npz delete mode 100644 tests/fixtures/legacy_pipeline/mecka-cartesian-camframe.json delete mode 100644 tests/fixtures/legacy_pipeline/mecka-cartesian-camframe.npz delete mode 100644 tests/fixtures/legacy_pipeline/mecka-cartesian-eef_frame.json delete mode 100644 tests/fixtures/legacy_pipeline/mecka-cartesian-eef_frame.npz delete mode 100644 tests/fixtures/legacy_pipeline/scale-cartesian-camframe.json delete mode 100644 tests/fixtures/legacy_pipeline/scale-cartesian-camframe.npz delete mode 100644 tests/fixtures/legacy_pipeline/scale-cartesian-eef_frame.json delete mode 100644 tests/fixtures/legacy_pipeline/scale-cartesian-eef_frame.npz delete mode 100644 tests/fixtures/libero_native_scontrol_408787.txt delete mode 100644 tests/fixtures/libero_native_suite_corpus_authority.json delete mode 100644 tests/fixtures/pusht_config_before_parent_cleanup.json delete mode 100644 tests/fixtures/synthetic_episodes.py delete mode 100644 tests/libero_ddp_resume_worker.py delete mode 100644 tests/oat_reference_fixtures.py delete mode 100644 tests/test_abc_yam.py delete mode 100644 tests/test_action_chunk_transforms.py delete mode 100644 tests/test_action_flow_affine_configs.py delete mode 100644 tests/test_action_flow_av_norm.py delete mode 100644 tests/test_action_flow_candidate_codec.py delete mode 100644 tests/test_action_flow_candidate_configs.py delete mode 100644 tests/test_action_flow_candidate_gates.py delete mode 100644 tests/test_action_flow_candidate_stages.py delete mode 100644 tests/test_action_flow_configs.py delete mode 100644 tests/test_action_flow_diagnostics.py delete mode 100644 tests/test_action_flow_diffusion.py delete mode 100644 tests/test_action_flow_jacobian_checkpoint_performance.py delete mode 100644 tests/test_action_flow_launcher.py delete mode 100644 tests/test_action_flow_likelihood.py delete mode 100644 tests/test_action_flow_manual4919_config.py delete mode 100644 tests/test_action_flow_model_wrapper.py delete mode 100644 tests/test_action_flow_modules.py delete mode 100644 tests/test_action_flow_smoke_schedule.py delete mode 100644 tests/test_action_flow_stages.py delete mode 100644 tests/test_action_flow_training_behavior.py delete mode 100644 tests/test_action_flow_unite_recipe.py delete mode 100644 tests/test_action_latent_vfm.py delete mode 100644 tests/test_alignment_parity.py delete mode 100644 tests/test_arc_duration_rollout_bridge.py delete mode 100644 tests/test_arc_eval_detokenize.py delete mode 100644 tests/test_arc_length_tokenizer.py delete mode 100644 tests/test_arc_metrics.py delete mode 100644 tests/test_arc_oat.py delete mode 100644 tests/test_batch_signal_relay.py delete mode 100644 tests/test_bound_inference.py delete mode 100644 tests/test_bounds_check_gate.py delete mode 100644 tests/test_c12_compatibility.py delete mode 100644 tests/test_capture_runtime_lock.py delete mode 100644 tests/test_chain_gripper_points.py delete mode 100644 tests/test_check_checkpoint_storage.py delete mode 100644 tests/test_config_graph.py delete mode 100644 tests/test_consolidated_core_configs.py delete mode 100644 tests/test_current_family_profiles.py delete mode 100644 tests/test_data_context_contract.py delete mode 100644 tests/test_data_tool_parity.py delete mode 100644 tests/test_dataloader_rng_state.py delete mode 100644 tests/test_deployment_data_requirements.py delete mode 100644 tests/test_dp_action_flow_observation_parity.py delete mode 100644 tests/test_e1_arc_codec.py delete mode 100644 tests/test_e1_campaign_configs.py delete mode 100644 tests/test_e1_data_adapters.py delete mode 100644 tests/test_e1_graph_evaluation.py delete mode 100644 tests/test_ema_resume.py delete mode 100644 tests/test_episode_limited_validation.py delete mode 100644 tests/test_episode_selection.py delete mode 100644 tests/test_eval_overlay_annotations.py delete mode 100644 tests/test_gaussian_paraboloid_dataset.py delete mode 100644 tests/test_gaussian_torus_dataset.py delete mode 100644 tests/test_gello_calibration.py delete mode 100644 tests/test_gello_teleop.py delete mode 100644 tests/test_hpt_configs.py delete mode 100644 tests/test_hpt_cores.py delete mode 100644 tests/test_hpt_qwen_configs.py delete mode 100644 tests/test_hpt_stages.py delete mode 100644 tests/test_human_arc.py delete mode 100644 tests/test_hydra_data_config.py delete mode 100644 tests/test_hydra_override.py delete mode 100644 tests/test_ice_checkpoint_archive_inventory.py delete mode 100644 tests/test_ice_checkpoint_mirror.py delete mode 100644 tests/test_ice_checkpoint_mirror_pool.py delete mode 100644 tests/test_ice_dp_campaign_contract.py delete mode 100644 tests/test_ice_gpu_probe.py delete mode 100644 tests/test_ice_planar_bc_launcher.py delete mode 100644 tests/test_ice_requeue_runner.py delete mode 100644 tests/test_imported_recipe_framework_parity.py delete mode 100644 tests/test_inference_config.py delete mode 100644 tests/test_inference_contract_boundaries.py delete mode 100644 tests/test_installed_package.py delete mode 100644 tests/test_legacy_data_metric_parity.py delete mode 100644 tests/test_legacy_hpt_trunk_parity.py delete mode 100644 tests/test_libero_action_flow_artifacts.py delete mode 100644 tests/test_libero_action_flow_boundary.py delete mode 100644 tests/test_libero_active_recipes.py delete mode 100644 tests/test_libero_arc.py delete mode 100644 tests/test_libero_arc_global.py delete mode 100644 tests/test_libero_arc_sweep.py delete mode 100644 tests/test_libero_arc_timed.py delete mode 100644 tests/test_libero_basis_scaling_audit.py delete mode 100644 tests/test_libero_benchmark.py delete mode 100644 tests/test_libero_cluster.py delete mode 100644 tests/test_libero_decoded_replay.py delete mode 100644 tests/test_libero_evaluate.py delete mode 100644 tests/test_libero_multi_embodiment_native.py delete mode 100644 tests/test_libero_native_binding_parser.py delete mode 100644 tests/test_libero_native_checkpoint_format.py delete mode 100644 tests/test_libero_native_half_factory_identity.py delete mode 100644 tests/test_libero_native_launch_contract.py delete mode 100644 tests/test_libero_native_scheduler_contract.py delete mode 100644 tests/test_libero_native_smoke_contract.py delete mode 100644 tests/test_libero_native_suite_profiles.py delete mode 100644 tests/test_libero_native_velocity_ablation.py delete mode 100644 tests/test_libero_native_whole_launcher.py delete mode 100644 tests/test_libero_performance.py delete mode 100644 tests/test_libero_replay.py delete mode 100644 tests/test_libero_saved_hydra_context.py delete mode 100644 tests/test_libero_saved_native_state.py delete mode 100644 tests/test_libero_training_checks.py delete mode 100644 tests/test_libero_tuned_configs.py delete mode 100644 tests/test_likelihood_training_behavior_parity.py delete mode 100644 tests/test_load_hook_runtime_compatibility.py delete mode 100644 tests/test_local_cpu_storage_guard.py delete mode 100644 tests/test_local_cpu_validation_guard.py delete mode 100644 tests/test_local_episode_resolver_override.py delete mode 100644 tests/test_logging_run_provenance.py delete mode 100644 tests/test_matched_cotrain_dp.py delete mode 100644 tests/test_measured_speed_removed.py delete mode 100644 tests/test_model_wrapper_metrics.py delete mode 100644 tests/test_mot_action_codec.py delete mode 100644 tests/test_multiplier_condition.py delete mode 100644 tests/test_multiplier_training_launch.py delete mode 100644 tests/test_native_diffusion_transformer.py delete mode 100644 tests/test_native_final_validation_lifecycle.py delete mode 100644 tests/test_nested_val_groups.py delete mode 100644 tests/test_oat_cotrain_boundary.py delete mode 100644 tests/test_oat_diffusion.py delete mode 100644 tests/test_oat_native.py delete mode 100644 tests/test_oat_step_budget.py delete mode 100644 tests/test_oat_training.py delete mode 100644 tests/test_oat_yam_observation_boundary.py delete mode 100644 tests/test_offline_constructor_inputs.py delete mode 100644 tests/test_offline_static_buffer_audit.py delete mode 100644 tests/test_open_loop_sim_eval.py delete mode 100644 tests/test_original_size_action_flow_cotrain_config.py delete mode 100644 tests/test_osmo_gate.py delete mode 100644 tests/test_partial_normalization_recovery.py delete mode 100644 tests/test_pi05_action_encoding.py delete mode 100644 tests/test_pi05_graph.py delete mode 100644 tests/test_pi05_norm_rot6d.py delete mode 100644 tests/test_pi_camera_slots.py delete mode 100644 tests/test_pi_prompts.py delete mode 100644 tests/test_pi_source_patch.py delete mode 100644 tests/test_pipeline_algo.py delete mode 100644 tests/test_pipeline_arc.py delete mode 100644 tests/test_pipeline_core.py delete mode 100644 tests/test_pipeline_flow.py delete mode 100644 tests/test_pipeline_initialization.py delete mode 100644 tests/test_pipeline_observation.py delete mode 100644 tests/test_pipeline_only_surface.py delete mode 100644 tests/test_pipeline_scheduler.py delete mode 100644 tests/test_pipeline_standard_dp.py delete mode 100644 tests/test_planar_arc.py delete mode 100644 tests/test_planar_configs.py delete mode 100644 tests/test_planar_native_sample_axes.py delete mode 100644 tests/test_planar_runtime.py delete mode 100644 tests/test_proportional_batch_sampler.py delete mode 100644 tests/test_pusht_parent_cleanup.py delete mode 100644 tests/test_rank_synthetic_trajectory_nn.py delete mode 100644 tests/test_ravens_contract.py delete mode 100644 tests/test_ravens_rollout.py delete mode 100644 tests/test_recursive_config_audit.py delete mode 100644 tests/test_retained_hpt_variants.py delete mode 100644 tests/test_retained_language.py delete mode 100644 tests/test_retained_pi_parity.py delete mode 100644 tests/test_retained_recipe_steps.py delete mode 100644 tests/test_retiming_loader.py delete mode 100644 tests/test_retiming_multiplier_condition.py delete mode 100644 tests/test_robot_arc_campaigns.py delete mode 100644 tests/test_robot_graph_policy.py delete mode 100644 tests/test_robot_retiming.py delete mode 100644 tests/test_robot_runtime.py delete mode 100644 tests/test_rollout_dashboard.py delete mode 100644 tests/test_rotation_modes.py delete mode 100644 tests/test_shared_cartesian_dp.py delete mode 100644 tests/test_shared_robot_components.py delete mode 100644 tests/test_slurm_save_only_checkpoint.py delete mode 100644 tests/test_speed_config.py delete mode 100644 tests/test_speed_retiming_diagnostic.py delete mode 100644 tests/test_standard_dp_active_dimensions.py delete mode 100644 tests/test_standard_dp_launch_args.py delete mode 100644 tests/test_standard_dp_pid_transport.py delete mode 100644 tests/test_stationary_causal_history_recipe.py delete mode 100644 tests/test_sync_runtime_script.py delete mode 100644 tests/test_synthetic_action_adapter_flow.py delete mode 100644 tests/test_synthetic_multi_action_adapter_flow.py delete mode 100644 tests/test_synthetic_shared_latent_flow.py delete mode 100644 tests/test_synthetic_trainer_resume.py delete mode 100644 tests/test_synthetic_trainer_signals.py delete mode 100644 tests/test_synthetic_trajectory_eval.py delete mode 100644 tests/test_temporal_action_codec.py delete mode 100644 tests/test_text_encoders.py delete mode 100644 tests/test_three_source_reconciliation.py delete mode 100644 tests/test_token_diagnostics.py delete mode 100644 tests/test_train_hydra_checkpoint_provenance.py delete mode 100644 tests/test_train_hydra_slurm_environment.py delete mode 100644 tests/test_trainer_pid_signal.py delete mode 100644 tests/test_unite_action_velocity.py delete mode 100644 tests/test_unite_diagnostics.py delete mode 100644 tests/test_unite_energy.py delete mode 100644 tests/test_unite_fixed_step_requeue_boundary.py delete mode 100644 tests/test_unite_ice_contract.py delete mode 100644 tests/test_unite_pinned_latent_norm.py delete mode 100644 tests/test_unite_register.py delete mode 100644 tests/test_unite_split_tokenizer.py delete mode 100644 tests/test_unite_training.py delete mode 100644 tests/test_validate_action_flow_config.py delete mode 100644 tests/test_validate_lightning_checkpoint.py delete mode 100644 tests/test_validate_planar_dataset.py delete mode 100644 tests/test_validate_slurm_job_contract.py delete mode 100644 tests/test_verify_action_flow_training_smoke.py delete mode 100644 tests/test_video_contract.py delete mode 100644 tests/test_virtual_gello_teleop.py delete mode 100644 tests/test_visual_core.py delete mode 100644 tests/test_wandb_health.py delete mode 100644 tests/test_wrist6d_roundtrip.py delete mode 100644 tests/test_yam_action_flow_eval.py delete mode 100644 tests/test_yam_gpu_uuid_contract.py delete mode 100644 tests/test_yam_human_keypoints_action_flow_eval.py delete mode 100644 tests/test_yam_human_launch_contract.py delete mode 100644 tests/test_yam_human_lightning_sampler_resume.py delete mode 100644 tests/test_yam_human_open_loop_eval.py delete mode 100644 tests/test_yam_human_phase_composition.py delete mode 100644 tests/test_yam_human_refreshed_corpus_contract.py delete mode 100644 tests/test_yam_human_shared_gradient_routes.py delete mode 100644 tests/test_yam_human_speed_contract.py delete mode 100644 tests/test_yam_human_validation_loader_topology.py delete mode 100644 tests/test_yam_native_dit_half.py delete mode 100644 tests/test_yam_packaging.py delete mode 100644 tests/test_zarr_content_manifest.py delete mode 100644 tools/test_libero_maintained_dispatch_v1.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 891e1a076b..783bd432b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: with: python-version: "3.11" - run: uv lock --check - - run: uvx ruff@${RUFF_VERSION} check egomimic tests scripts tools - - run: uvx ruff@${RUFF_VERSION} format --check egomimic tests scripts tools + - run: uvx ruff@${RUFF_VERSION} check egomimic scripts tools + - run: uvx ruff@${RUFF_VERSION} format --check egomimic scripts tools - cpu-and-wheel: + configuration-audit: runs-on: ubuntu-latest timeout-minutes: 40 steps: @@ -45,13 +45,6 @@ jobs: source .venv/bin/activate python scripts/audit_hydra_configs.py --output config-audit.json HF_HUB_OFFLINE=1 python -m scripts.audit_components --output component-audit.json - - name: CPU functional and isolated installed-wheel gates - env: - HF_HUB_OFFLINE: "1" - WANDB_MODE: disabled - run: | - source .venv/bin/activate - pytest tests -q - uses: actions/upload-artifact@v4 if: always() with: diff --git a/AGENTS.md b/AGENTS.md index 123ed21fe4..1b8319f851 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,8 @@ # Repo Agent Rules +See [the verification policy](docs/VERIFICATION_POLICY.md): keep tests and +verification fixtures outside this repository; never commit them. + ## Shell / Command Execution To run commands in the interactive shell, source `emimic/bin/activate` when it exists. In worktrees where `emimic` is absent and the checked project @@ -72,12 +75,12 @@ not imply a separate runtime for that task or model. | Dataset filters, task prompts, camera/frame choices and experiment parameters | `egomimic/hydra_configs/`; calibration matrices in `hydra_configs/calibration/` | | Shared Eva/Yam collection, local graph rollout, Zarr replay and upload | [robot/AGENTS.md](egomimic/robot/AGENTS.md), [docs/YAM_RUNTIME.md](docs/YAM_RUNTIME.md) | | Offline normalization export for any model | [scripts/data/precompute_norm_stats.py](scripts/data/precompute_norm_stats.py), or `trainHydra.py norm_stats_only=true` | -| Focused CPU regression checks | `tests/test_pipeline*.py`, `test_hpt*.py`, `test_arc*.py`, `test_e1*.py`, `test_pi05*.py`, `test_wrist6d_roundtrip.py`, `test_robot_runtime.py`, `test_robot_graph_policy.py` | +| Offline configuration audits | `scripts/audit_hydra_configs.py`, `scripts/audit_components.py` | Useful searches from the repository root: ```bash -rg --files egomimic/pipeline egomimic/rldb egomimic/eval egomimic/robot tests +rg --files egomimic/pipeline egomimic/rldb egomimic/eval egomimic/robot rg -n '_target_:|action_mode:|coord_frame:|rotation_mode:' egomimic/hydra_configs rg -n 'class BimanualCartesianEval|class PI05Stage|class MultiDataset' egomimic ``` @@ -100,9 +103,11 @@ rg -n 'class BimanualCartesianEval|class PI05Stage|class MultiDataset' egomimic - `egomimic/campaigns/pi05/`, `e1_fold.py`, and `pi05_graph_eval.py` retain old import names. Their implementations now live at the canonical paths above. Use current YAML examples when migrating older serialized configuration APIs. -- Read the relevant tests before changing normalization, pose conventions or - checkpoint loading. Verify behavior with small CPU fixtures before a cluster - run; hardware and pretrained-model validation are separate from those tests. +- Do not commit tests, test fixtures, or local verification scripts. Keep any + regression checks outside the repository and report their results in the PR. + Verify normalization, pose conventions and checkpoint loading with local CPU + checks before a cluster run; hardware and pretrained-model validation remain + separate. Historical test sources remain available in Git history. ## Preserve work and verify the target diff --git a/docs/VERIFICATION_POLICY.md b/docs/VERIFICATION_POLICY.md new file mode 100644 index 0000000000..54c636178d --- /dev/null +++ b/docs/VERIFICATION_POLICY.md @@ -0,0 +1,27 @@ +# Verification without committed tests + +The rebuilt stack removes the tracked regression suites, their fixtures and +test-only runners, including the vendored LeRobot suite. Do not commit new +tests, fixtures, or local verification scripts. Run focused regression checks +outside the application repository and include their results and limitations +in each pull request. + +CI retains locked-environment checks, Ruff, recursive Hydra composition and +offline component construction. It no longer runs pytest or the former +installed-wheel regression gate. Passing CI therefore does not establish +behavioral parity, wheel-install compatibility, hardware safety, or successful +training. Those require separate evidence. + +The Qwen architecture metadata used by the constructor audit is retained at +`scripts/resources/qwen3_embedding_06b_config.json` with the same checksum. +This is audit input, not a test suite. Existing dependency pins, runtime guards, +training/validation evaluators, and hardware-control examples are unchanged. +The vendored ARX examples named `test_*` are operational robot programs, not +automated regression tests. + +Historical test references and validation receipts in older documents describe +the source revision that produced them. Their sources can be recovered from Git +history (for example the pre-rebuild main commit +`a525d73897bec64397048e7c8897fb886555567b`); they are no longer commands for the +current checkout. Do not rewrite historical provenance or weaken checkpoint +compatibility guards to accommodate a source cleanup. diff --git a/docs/YAM_RUNTIME.md b/docs/YAM_RUNTIME.md index 8584f3bd42..60243cbe6f 100644 --- a/docs/YAM_RUNTIME.md +++ b/docs/YAM_RUNTIME.md @@ -438,6 +438,9 @@ directory. `--list` performs no upload and needs no AWS credentials. ## Validation scope +The regression sources referenced below are historical and no longer shipped; +see [the verification policy](VERIFICATION_POLICY.md). Keep new checks local. + `tests/test_robot_runtime.py` checks collection, format compatibility, world-frame clutch control, stale tracking/cameras, previews, Eva/Yam interfaces, cleanup and Zarr replay. `tests/test_robot_graph_policy.py` checks strict checkpoint loading, diff --git a/docs/integration/DATA_TOOLS.md b/docs/integration/DATA_TOOLS.md index 90688f3d5d..fb084e40c9 100644 --- a/docs/integration/DATA_TOOLS.md +++ b/docs/integration/DATA_TOOLS.md @@ -81,6 +81,9 @@ require SQL, cloud packages or a policy backend. ## Evidence and remaining gates +The following regression evidence is historical; test sources are no longer +shipped. See [the verification policy](../VERIFICATION_POLICY.md). + `tests/test_data_tool_parity.py` converts an actual HDF5 fixture, checks unchanged source bytes and overwrite rejection, imports with cloud/model modules blocked, encodes a complete annotated episode, validates video frame count/FPS and saved diff --git a/egomimic/eval/AGENTS.md b/egomimic/eval/AGENTS.md index f4537688ce..1f8417d2f0 100644 --- a/egomimic/eval/AGENTS.md +++ b/egomimic/eval/AGENTS.md @@ -29,6 +29,5 @@ are YAML settings, including the `train_viz` group. Frame reversion must match the data configuration. Rotation encodings and gripper padding affect both actions and proprio; test both sides of the round -trip. Focused regression tests are `test_shared_robot_components.py`, -`test_eval_overlay_annotations.py`, `test_wrist6d_roundtrip.py`, -`test_pi05_graph.py`, and the root ARC/tempo evaluation tests. +trip. Keep regression checks outside the repository and report local results; +do not commit tests or their fixtures. diff --git a/egomimic/rldb/AGENTS.md b/egomimic/rldb/AGENTS.md index 6e2415f8a4..647fa4d732 100644 --- a/egomimic/rldb/AGENTS.md +++ b/egomimic/rldb/AGENTS.md @@ -33,6 +33,5 @@ embodiment IDs when loading a cache. Live rollouts require the full `normalizer_state`, not just numeric bounds. Do not change stored episode metadata to rename an embodiment. Canonicalize the -metadata at the loader boundary. Test geometric changes with -`test_action_chunk_transforms.py`, `test_wrist6d_roundtrip.py`, `test_human_arc.py`, -`test_abc_yam.py`, and the relevant ARC/PI numeric tests in the root `tests/`. +metadata at the loader boundary. Verify geometric and ARC/PI changes with local +CPU checks outside the repository; do not commit tests or their fixtures. diff --git a/egomimic/rldb/zarr/test_dataset_filter.py b/egomimic/rldb/zarr/test_dataset_filter.py deleted file mode 100644 index 7afdf4c6fe..0000000000 --- a/egomimic/rldb/zarr/test_dataset_filter.py +++ /dev/null @@ -1,135 +0,0 @@ -import pandas as pd -import pytest -import zarr - -from egomimic.rldb.filters import DatasetFilter -from egomimic.rldb.zarr import zarr_dataset_multi -from egomimic.scripts.data_download.sync_s3 import parse_dataset_filter_key - - -def _write_episode(root, name: str, **attrs) -> None: - group = zarr.open_group(str(root / f"{name}.zarr"), mode="w") - group.attrs.update(attrs) - - -def test_dataset_filter_matches_rows_and_excludes_deleted_by_default() -> None: - filters = DatasetFilter( - filter_lambdas=["lambda row: row['episode_hash'] == 'episode-1'"] - ) - - assert filters.matches({"episode_hash": "episode-1"}) - assert not filters.matches({"episode_hash": "episode-1", "is_deleted": True}) - assert not filters.matches({"episode_hash": "episode-2"}) - - -def test_dataset_filter_empty_list_matches_all_non_deleted_rows() -> None: - filters = DatasetFilter() - - assert filters.matches({"episode_hash": "episode-1"}) - assert not filters.matches({"episode_hash": "episode-1", "is_deleted": True}) - - -def test_dataset_filter_init_rejects_invalid_filter_and_prints_it(capsys) -> None: - with pytest.raises(ValueError, match="Invalid filter"): - DatasetFilter(filter_lambdas=["lambda row:"]) - - captured = capsys.readouterr() - assert "Invalid filter: lambda row:" in captured.err - - -def test_dataset_filter_matches_requires_bool_result() -> None: - filters = DatasetFilter(filter_lambdas=["lambda row: 1"]) - - with pytest.raises(TypeError, match="Filter must return bool"): - filters.matches({"episode_hash": "episode-1"}) - - -def test_s3_resolver_filters_dataframe_with_dataset_filter(monkeypatch) -> None: - df = pd.DataFrame( - [ - { - "episode_hash": "match", - "zarr_processed_path": "s3://rldb/processed/match/", - "task": "fold_clothes", - "embodiment": "human_bimanual", - "is_deleted": False, - }, - { - "episode_hash": "fallback", - "zarr_processed_path": "s3://rldb/processed/fallback/", - "task": "fold_clothes", - "embodiment": "human_bimanual", - "is_deleted": False, - }, - { - "episode_hash": "deleted", - "zarr_processed_path": "s3://rldb/processed/deleted/", - "task": "fold_clothes", - "embodiment": "human_bimanual", - "is_deleted": True, - }, - { - "episode_hash": "empty-path", - "zarr_processed_path": "", - "task": "fold_clothes", - "embodiment": "human_bimanual", - "is_deleted": False, - }, - ] - ) - monkeypatch.setattr(zarr_dataset_multi, "create_default_engine", lambda: object()) - monkeypatch.setattr(zarr_dataset_multi, "episode_table_to_df", lambda engine: df) - - filters = DatasetFilter( - filter_lambdas=[ - "lambda row: row['embodiment'] == 'human_bimanual'", - "lambda row: row['task'] == 'fold_clothes'", - ] - ) - - paths = zarr_dataset_multi.S3EpisodeResolver._get_filtered_paths(filters=filters) - - assert paths == [ - ("s3://rldb/processed/match/", "match"), - ("s3://rldb/processed/fallback/", "fallback"), - ] - - -def test_local_resolver_filters_local_metadata_with_dataset_filter(tmp_path) -> None: - _write_episode( - tmp_path, "episode_a", embodiment="human_bimanual", task="fold_clothes" - ) - _write_episode( - tmp_path, - "episode_c", - embodiment="human_bimanual", - task="fold_clothes", - is_deleted=True, - ) - _write_episode( - tmp_path, "episode_d", embodiment="eva_bimanual", task="fold_clothes" - ) - - filters = DatasetFilter( - filter_lambdas=["lambda row: row['embodiment'] == 'human_bimanual'"] - ) - - paths = zarr_dataset_multi.LocalEpisodeResolver._get_local_filtered_paths( - tmp_path, - filters=filters, - ) - - assert [episode_hash for _, episode_hash in paths] == ["episode_a"] - - -def test_sync_s3_parser_accepts_named_filter_key() -> None: - filters = parse_dataset_filter_key("aria-fold-clothes") - - assert isinstance(filters, DatasetFilter) - assert filters.matches({"embodiment": "aria", "task": "fold_clothes"}) - assert not filters.matches({"embodiment": "human_bimanual", "task": "fold_clothes"}) - - -def test_sync_s3_parser_rejects_unknown_filter_key() -> None: - with pytest.raises(ValueError, match="Available filter keys"): - parse_dataset_filter_key("does-not-exist") diff --git a/egomimic/robot/AGENTS.md b/egomimic/robot/AGENTS.md index 62862336ef..ac91d63802 100644 --- a/egomimic/robot/AGENTS.md +++ b/egomimic/robot/AGENTS.md @@ -70,8 +70,7 @@ Use the same graph, data normalization and evaluation components for HPT and PI. Do not introduce an embodiment-specific inference server or upstream runtime launcher. Do not deprecate Eva when adding another robot implementation. -Run `tests/test_gello_teleop.py`, `tests/test_robot_runtime.py`, and -`tests/test_robot_graph_policy.py` for CPU validation. `--help`, GELLO +Keep CPU verification outside the repository; do not commit tests. `--help`, GELLO `--check-config`/`--list-ports`, and uploader `--list` open no devices. Collection/rollout commands open hardware; operate a physical station only when the user requests that operation. Preserve source branches, user worktrees and diff --git a/egomimic/robot/eva/test_mink_solver.py b/egomimic/robot/eva/test_mink_solver.py deleted file mode 100644 index 82eeaa39ac..0000000000 --- a/egomimic/robot/eva/test_mink_solver.py +++ /dev/null @@ -1,853 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for EvaMinkKinematicsSolver. - -This script demonstrates how to use the mink-based IK solver for the Eva robot -and compares TracIK vs Mink (MuJoCo) solvers to quantify FK/IK, cross gaps, -and diagnose the remaining configuration-dependent residuals. -""" - -import os -from pathlib import Path - -import numpy as np -from scipy.spatial.transform import Rotation as R - -import egomimic -from egomimic.robot.eva.eva_kinematics import ( - EvaMinkKinematicsSolver, -) - -EVA_XML_PATH = os.path.join( - os.path.dirname(egomimic.__file__), "resources/model_x5.xml" -) - -# -------------------- Helpers (robust to Rotation vs ndarray) -------------------- - - -def _as_rotation(x): - """Return a scipy Rotation from either Rotation or 3x3 ndarray.""" - return x if isinstance(x, R) else R.from_matrix(np.asarray(x)) - - -def _as_matrix(x): - """Return a 3x3 ndarray from either Rotation or 3x3 ndarray.""" - return x.as_matrix() if isinstance(x, R) else np.asarray(x) - - -def _rot_geodesic(a, b): - """Return geodesic angle [rad] between two rotations (Rotation or ndarray).""" - if isinstance(a, np.ndarray): - a = R.from_matrix(a) - if isinstance(b, np.ndarray): - b = R.from_matrix(b) - return (a.inv() * b).magnitude() - - -# -------------------- Original single-solver test (slightly hardened) -------------------- - - -def test_eva_mink_ik(): - """Test Eva mink IK solver.""" - # Path to MuJoCo XML scene (you'll need to create this) - xml_path = Path(EVA_XML_PATH) - - if not xml_path.exists(): - print(f"Error: Scene file not found at {xml_path}") - print("Please create a MuJoCo XML scene file for the X5 robot.") - return - - print("=" * 60) - print("Testing EvaMinkKinematicsSolver") - print("=" * 60) - - # Initialize solver - print("\n1. Initializing solver...") - solver = EvaMinkKinematicsSolver( - model_path=str(xml_path), - eef_link_name="tcp_match_trac", - eef_frame_type="site", - max_iterations=100, - position_tolerance=1e-3, - orientation_tolerance=1e-3, - ) - print(" [OK] Solver initialized successfully") - - # Test forward kinematics - print("\n2. Testing forward kinematics...") - home_joints = np.array([0.0, 1.57, 1.57, 0.0, 0.0, 0.0]) - pos, rot = solver.fk(home_joints) - rot = _as_rotation(rot) - print(f" Home position: {pos}") - print(f" Home orientation (euler): {rot.as_euler('xyz', degrees=True)}") - - # Test inverse kinematics - print("\n3. Testing inverse kinematics...") - - # Target: Move 10cm forward from home - target_pos = pos + np.array([0.1, 0.0, 0.0]) - target_rot = rot.as_matrix() - - print(f" Target position: {target_pos}") - - solution = solver.ik(target_pos, target_rot, home_joints) - - if solution is not None: - print(" [OK] IK converged!") - print(f" Solution joints: {solution}") - - # Verify with FK - achieved_pos, achieved_rot = solver.fk(solution) - achieved_rot = _as_rotation(achieved_rot) - pos_error = np.linalg.norm(achieved_pos - target_pos) - rot_error = _rot_geodesic(achieved_rot, target_rot) - - print(f" Achieved position: {achieved_pos}") - print(f" Position error: {pos_error:.6f} m") - print(f" Rotation error: {rot_error:.6f} rad") - else: - print(" [FAILED] IK did not converge") - - # Test multiple targets - print("\n4. Testing multiple target poses...") - test_targets = [ - ("Move up", pos + np.array([0.0, 0.0, 0.1])), - ("Move right", pos + np.array([0.0, 0.1, 0.0])), - ("Move diagonal", pos + np.array([0.05, 0.05, 0.05])), - ] - - for name, target_pos_i in test_targets: - print(f"\n {name}: {target_pos_i}") - solution_i = solver.ik(target_pos_i, target_rot, home_joints) - - if solution_i is not None: - achieved_pos_i, _ = solver.fk(solution_i) - error = np.linalg.norm(achieved_pos_i - target_pos_i) - print(f" [OK] Converged (error: {error:.6f} m)") - else: - print(" [FAILED] Failed to converge") - - # Test IK with retries - print("\n5. Testing IK with retries...") - difficult_target = pos + np.array([0.15, 0.15, 0.0]) - solution_retry = solver.ik_with_retries( - difficult_target, target_rot, home_joints, num_retries=3 - ) - - if solution_retry is not None: - print(" [OK] IK with retries succeeded!") - achieved_pos_r, _ = solver.fk(solution_retry) - error = np.linalg.norm(achieved_pos_r - difficult_target) - print(f" Position error: {error:.6f} m") - else: - print(" [FAILED] IK with retries failed") - - print("\n" + "=" * 60) - print("Testing completed!") - print("=" * 60) - - -# -------------------- TracIK vs Mink comparison with FK/IK/CROSS metrics -------------------- - - -# def compare_solvers(): -# """Compare TracIK solver vs Mink solver and quantify FK/IK/cross gaps.""" -# urdf_path = ( -# Path(__file__).parent.parent / "eva" / "stanford_repo" / "models" / "X5.urdf" -# ) -# xml_path = Path(EVA_XML_PATH) -# if not urdf_path.exists() or not xml_path.exists(): -# print("Error: URDF or XML file not found") -# print(f" URDF: {urdf_path.exists()} @ {urdf_path}") -# print(f" XML : {xml_path.exists()} @ {xml_path}") -# return - -# print("\n" + "=" * 60) -# print("Comparing TracIK vs Mink Solver (with FK/IK/cross-gap metrics)") -# print("=" * 60) - -# # Initialize both solvers -# print("\nInitializing solvers...") -# trac_solver = EvaKinematicsSolver(str(urdf_path)) -# mink_solver = EvaMinkKinematicsSolver( -# model_path=str(xml_path), -# eef_link_name="tcp_match_trac", -# eef_frame_type="site", -# max_iterations=100, -# position_tolerance=1e-3, -# orientation_tolerance=1e-3, -# ) -# print("[OK] Both solvers initialized") - -# # Test configuration -# home_joints = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) - -# # ---------------------------------------------------------------------- -# # 1) FK GAP at the same joint config(s) -# # ---------------------------------------------------------------------- -# print("\n[1] FK GAP @ home_joints") -# pos_t, rot_t = trac_solver.fk(home_joints) -# rot_t = _as_rotation(rot_t) -# pos_m, rot_m = mink_solver.fk(home_joints) -# rot_m = _as_rotation(rot_m) - -# fk_pos_err = np.linalg.norm(pos_t - pos_m) -# fk_rot_err = _rot_geodesic(rot_t, rot_m) -# print( -# f" Trac FK @ home: pos={pos_t}, euler(deg)={rot_t.as_euler('xyz', degrees=True)}" -# ) -# print( -# f" Mink FK @ home: pos={pos_m}, euler(deg)={rot_m.as_euler('xyz', degrees=True)}" -# ) -# print(f" FK position gap [m]: {fk_pos_err:.6e}") -# print(f" FK rotation gap [rad]: {fk_rot_err:.6e}") - -# # ---------------------------------------------------------------------- -# # 2) IK GAP to a shared target (10 cm forward from TRAC FK pose) -# # ---------------------------------------------------------------------- -# print("\n[2] IK GAP to a shared target (10 cm forward from Trac FK pose)") -# target_pos = pos_t + np.array([0.1, 0.0, 0.0]) -# target_rot = rot_t # keep orientation identical to Trac FK - -# print(f" Target position (TRAC-based): {target_pos}") -# start = time.time() -# trac_solution = trac_solver.ik(target_pos, _as_matrix(target_rot), home_joints) -# trac_time = (time.time() - start) * 1000.0 - -# start = time.time() -# mink_solution = mink_solver.ik(target_pos, _as_matrix(target_rot), home_joints) -# mink_time = (time.time() - start) * 1000.0 - -# print(f" TracIK time: {trac_time:.2f} ms") -# print(f" Mink time: {mink_time:.2f} ms") - -# if trac_solution is not None: -# pA, rA = trac_solver.fk(trac_solution) -# rA = _as_rotation(rA) -# pos_errA = np.linalg.norm(pA - target_pos) -# rot_errA = _rot_geodesic(rA, target_rot) -# print( -# f" TracIK achieved pos: {pA} | pos_err={pos_errA:.6e} m | rot_err={rot_errA:.6e} rad" -# ) -# else: -# print(" TracIK failed on TRAC-based shared target") - -# if mink_solution is not None: -# pB, rB = mink_solver.fk(mink_solution) -# rB = _as_rotation(rB) -# pos_errB = np.linalg.norm(pB - target_pos) -# rot_errB = _rot_geodesic(rB, target_rot) -# print( -# f" Mink achieved pos: {pB} | pos_err={pos_errB:.6e} m | rot_err={rot_errB:.6e} rad" -# ) -# else: -# print(" Mink failed on TRAC-based shared target") - -# if trac_solution is not None and mink_solution is not None: -# jdelta = np.linalg.norm(trac_solution - mink_solution) -# print( -# f" Joint-space Δ between solvers (TRAC-based target) [L2]: {jdelta:.6e}" -# ) - -# # ---------------------------------------------------------------------- -# # 2b) IK GAP to a shared target (10 cm forward from MINK FK pose) -# # ---------------------------------------------------------------------- -# print("\n[2b] IK GAP to a shared target (10 cm forward from MINK FK pose)") -# target_pos2 = pos_m + np.array([0.1, 0.0, 0.0]) -# target_rot2 = rot_m # keep orientation identical to MINK FK - -# print(f" Target position (MINK-based): {target_pos2}") -# start = time.time() -# trac_solution2 = trac_solver.ik(target_pos2, _as_matrix(target_rot2), home_joints) -# trac_time2 = (time.time() - start) * 1000.0 - -# start = time.time() -# mink_solution2 = mink_solver.ik(target_pos2, _as_matrix(target_rot2), home_joints) -# mink_time2 = (time.time() - start) * 1000.0 - -# print(f" TracIK time: {trac_time2:.2f} ms") -# print(f" Mink time: {mink_time2:.2f} ms") - -# if trac_solution2 is not None: -# pA2, rA2 = trac_solver.fk(trac_solution2) -# rA2 = _as_rotation(rA2) -# pos_errA2 = np.linalg.norm(pA2 - target_pos2) -# rot_errA2 = _rot_geodesic(rA2, target_rot2) -# print( -# f" TracIK achieved pos: {pA2} | pos_err={pos_errA2:.6e} m | rot_err={rot_errA2:.6e} rad" -# ) -# else: -# print(" TracIK failed on MINK-based shared target") - -# if mink_solution2 is not None: -# pB2, rB2 = mink_solver.fk(mink_solution2) -# rB2 = _as_rotation(rB2) -# pos_errB2 = np.linalg.norm(pB2 - target_pos2) -# rot_errB2 = _rot_geodesic(rB2, target_rot2) -# print( -# f" Mink achieved pos: {pB2} | pos_err={pos_errB2:.6e} m | rot_err={rot_errB2:.6e} rad" -# ) -# else: -# print(" Mink failed on MINK-based shared target") - -# if trac_solution2 is not None and mink_solution2 is not None: -# jdelta2 = np.linalg.norm(trac_solution2 - mink_solution2) -# print( -# f" Joint-space Δ between solvers (MINK-based target) [L2]: {jdelta2:.6e}" -# ) - -# # ---------------------------------------------------------------------- -# # 3) CROSS-GAP: “FK of one” → “IK of the other” (both directions). -# # ---------------------------------------------------------------------- -# print("\n[3] CROSS-GAP (targets from each solver’s FK, solved by the other)") - -# # 3a) Use TRAC FK pose as the target → solve with MINK -# print("\n [3a] TRAC FK pose as target → MINK IK") -# trac_target_pos, trac_target_rot = pos_t, rot_t -# mink_on_trac = mink_solver.ik( -# trac_target_pos, _as_matrix(trac_target_rot), home_joints -# ) -# if mink_on_trac is not None: -# p_m_on_t, r_m_on_t = mink_solver.fk(mink_on_trac) -# r_m_on_t = _as_rotation(r_m_on_t) -# pos_err_mt = np.linalg.norm(p_m_on_t - trac_target_pos) -# rot_err_mt = _rot_geodesic(r_m_on_t, trac_target_rot) -# print( -# f" Mink→(Trac target) pos_err={pos_err_mt:.6e} m | rot_err={rot_err_mt:.6e} rad" -# ) -# else: -# print(" Mink failed to hit Trac FK pose") - -# # 3b) Use MINK FK pose as the target → solve with TRAC -# print("\n [3b] MINK FK pose as target → TRAC IK") -# mink_target_pos, mink_target_rot = pos_m, rot_m -# trac_on_mink = trac_solver.ik( -# mink_target_pos, _as_matrix(mink_target_rot), home_joints -# ) -# if trac_on_mink is not None: -# p_t_on_m, r_t_on_m = trac_solver.fk(trac_on_mink) -# r_t_on_m = _as_rotation(r_t_on_m) -# pos_err_tm = np.linalg.norm(p_t_on_m - mink_target_pos) -# rot_err_tm = _rot_geodesic(r_t_on_m, mink_target_rot) -# print( -# f" Trac→(Mink target) pos_err={pos_err_tm:.6e} m | rot_err={rot_err_tm:.6e} rad" -# ) -# else: -# print(" Trac failed to hit Mink FK pose") - -# # ---------------------------------------------------------------------- -# # 4) Additional shared targets (from TRAC FK pose) -# # ---------------------------------------------------------------------- -# print("\n[4] Additional shared targets (up/right/diagonal from TRAC FK pose)") -# extra_targets = [ -# ("Move up", pos_t + np.array([0.0, 0.0, 0.1])), -# ("Move right", pos_t + np.array([0.0, 0.1, 0.0])), -# ("Move diagonal", pos_t + np.array([0.05, 0.05, 0.05])), -# ] - -# for name, tpos in extra_targets: -# print(f"\n {name}: {tpos}") -# tA = trac_solver.ik(tpos, _as_matrix(rot_t), home_joints) -# tB = mink_solver.ik(tpos, _as_matrix(rot_t), home_joints) - -# if tA is not None: -# pA, rA = trac_solver.fk(tA) -# rA = _as_rotation(rA) -# eposA = np.linalg.norm(pA - tpos) -# erotA = _rot_geodesic(rA, rot_t) -# print( -# f" Trac achieved pos_err={eposA:.6e} m | rot_err={erotA:.6e} rad" -# ) -# else: -# print(" Trac failed") - -# if tB is not None: -# pB, rB = mink_solver.fk(tB) -# rB = _as_rotation(rB) -# eposB = np.linalg.norm(pB - tpos) -# erotB = _rot_geodesic(rB, rot_t) -# print( -# f" Mink achieved pos_err={eposB:.6e} m | rot_err={erotB:.6e} rad" -# ) -# else: -# print(" Mink failed") - -# if tA is not None and tB is not None: -# jdelta = np.linalg.norm(tA - tB) -# print(f" Joint-space Δ (same target) [L2]: {jdelta:.6e}") - -# # ---------------------------------------------------------------------- -# # 4b) Additional shared targets (from MINK FK pose) ← reverse sweep -# # ---------------------------------------------------------------------- -# print("\n[4b] Additional shared targets (up/right/diagonal from MINK FK pose)") -# extra_targets_mink = [ -# ("Move up", pos_m + np.array([0.0, 0.0, 0.1])), -# ("Move right", pos_m + np.array([0.0, 0.1, 0.0])), -# ("Move diagonal", pos_m + np.array([0.05, 0.05, 0.05])), -# ] - -# for name, tpos in extra_targets_mink: -# print(f"\n {name}: {tpos}") -# tA = trac_solver.ik(tpos, _as_matrix(rot_m), home_joints) -# tB = mink_solver.ik(tpos, _as_matrix(rot_m), home_joints) - -# if tA is not None: -# pA, rA = trac_solver.fk(tA) -# rA = _as_rotation(rA) -# eposA = np.linalg.norm(pA - tpos) -# erotA = _rot_geodesic(rA, rot_m) -# print( -# f" Trac achieved pos_err={eposA:.6e} m | rot_err={erotA:.6e} rad" -# ) -# else: -# print(" Trac failed") - -# if tB is not None: -# pB, rB = mink_solver.fk(tB) -# rB = _as_rotation(rB) -# eposB = np.linalg.norm(pB - tpos) -# erotB = _rot_geodesic(rB, rot_m) -# print( -# f" Mink achieved pos_err={eposB:.6e} m | rot_err={erotB:.6e} rad" -# ) -# else: -# print(" Mink failed") - -# if tA is not None and tB is not None: -# jdelta = np.linalg.norm(tA - tB) -# print(f" Joint-space Δ (same target) [L2]: {jdelta:.6e}") - -# # ---------------------------------------------------------------------- -# # 5) Estimate a constant Trac→Mink SE(3) and validate it -# # ---------------------------------------------------------------------- -# R_off, t_off = estimate_and_validate_offset( -# trac_solver, -# mink_solver, -# pos_t, -# rot_t, # TRAC FK at home -# pos_m, -# rot_m, # MINK FK at home -# home_joints, -# ) - -# # ---------------------------------------------------------------------- -# # 6) Deep-dive diagnostics: variance, PCA, joint correlations, Jacobians -# # ---------------------------------------------------------------------- -# run_gap_diagnostics(trac_solver, mink_solver, R_off, t_off, seed=3) - -# print("\n" + "=" * 60) -# print("Summary / Reading the metrics") -# print("=" * 60) -# print( -# "- FK GAP: pure forward mismatch at identical joints; near-constant bias ⇒ frame/site/tool offset." -# ) -# print( -# "- IK GAP ([2] TRAC-based, [2b] MINK-based): how close each gets to the same Cartesian target; compare joint Δ too." -# ) -# print( -# "- CROSS-GAP ([3]): using one solver’s FK as target for the other isolates frame/orientation convention differences." -# ) -# print( -# "- [4] and [4b]: directional consistency checks around each solver’s native FK pose." -# ) -# print( -# "- [5],[6]: estimate constant SE(3) and diagnose residual variance to find which joints/links cause the gap." -# ) - - -# -------------------- SE(3) estimation + validation -------------------- - - -def _estimate_se3_offset(trac_solver, mink_solver, n_samples=60, rng_seed=0): - """ - Estimate a fixed SE(3) transform T_off = (R_off, t_off) such that: - R_M ≈ R_off * R_T - p_M ≈ R_off * p_T + t_off - using random joint samples. Returns (R_off: Rotation, t_off: (3,), stats: dict). - """ - rng = np.random.default_rng(rng_seed) - - # Conservative joint limits if your solver doesn't expose them: - # (Eva is 6-DoF; adjust if needed) - lim = np.deg2rad(170.0) - q_min = -lim * np.ones(6) - q_max = lim * np.ones(6) - Q = q_min + (q_max - q_min) * rng.random((n_samples, 6)) - - rotvecs = [] - pts_T, pts_M = [], [] - - for q in Q: - pT, RT = trac_solver.fk(q) - RT = _as_rotation(RT) - pM, RM = mink_solver.fk(q) - RM = _as_rotation(RM) - - # Relative rotation RT^T * RM - R_rel = RT.inv() * RM - rotvecs.append(R_rel.as_rotvec()) - - pts_T.append(np.asarray(pT, float)) - pts_M.append(np.asarray(pM, float)) - - rotvecs = np.asarray(rotvecs) # (N,3) - pts_T = np.asarray(pts_T) # (N,3) - pts_M = np.asarray(pts_M) # (N,3) - - # Rotation averaging in Lie algebra: mean of rotation vectors → exp - r_mean = rotvecs.mean(axis=0) - R_off = R.from_rotvec(r_mean) - - # Translation from least-squares (here: simple mean, since constant offset) - # p_M ≈ R_off p_T + t_off ⇒ t_off ≈ mean_i (p_Mi - R_off p_Ti) - t_off = (pts_M - R_off.apply(pts_T)).mean(axis=0) - - # Residuals with/without compensation (for sanity) - pre_pos = np.linalg.norm(pts_M - pts_T, axis=1) - pre_rot = np.linalg.norm(rotvecs, axis=1) # angle magnitude of RT^T RM - - pts_T_comp = R_off.apply(pts_T) + t_off - RM_pred = [R_off * _as_rotation(trac_solver.fk(q)[1]) for q in Q] - rotvecs_post = [] - for i, q in enumerate(Q): - # true RM: - _, RM_true = mink_solver.fk(q) - RM_true = _as_rotation(RM_true) - rotvecs_post.append((RM_pred[i].inv() * RM_true).as_rotvec()) - rotvecs_post = np.asarray(rotvecs_post) - - post_pos = np.linalg.norm(pts_M - pts_T_comp, axis=1) - post_rot = np.linalg.norm(rotvecs_post, axis=1) - - stats = { - "pre_pos_mean": pre_pos.mean(), - "pre_pos_med": np.median(pre_pos), - "post_pos_mean": post_pos.mean(), - "post_pos_med": np.median(post_pos), - "pre_rot_mean": pre_rot.mean(), - "pre_rot_med": np.median(pre_rot), - "post_rot_mean": post_rot.mean(), - "post_rot_med": np.median(post_rot), - } - return R_off, t_off, stats - - -def _apply_trac_to_mink_target(p_T, R_T, R_off, t_off): - """ - Map a Trac target (p_T, R_T) to an equivalent Mink-frame target: - p_M = R_off p_T + t_off - R_M = R_off R_T - """ - R_T = _as_rotation(R_T) - p_M = R_off.apply(np.asarray(p_T, float)) + t_off - R_M = R_off * R_T - return p_M, R_M - - -def _apply_mink_to_trac_target(p_M, R_M, R_off, t_off): - """ - Map a Mink target (p_M, R_M) to an equivalent Trac-frame target: - p_T = R_off^T (p_M - t_off) - R_T = R_off^T R_M - """ - R_M = _as_rotation(R_M) - R_off_inv = R_off.inv() - p_T = R_off_inv.apply(np.asarray(p_M, float) - t_off) - R_T = R_off_inv * R_M - return p_T, R_T - - -def estimate_and_validate_offset( - trac_solver, mink_solver, pos_t, rot_t, pos_m, rot_m, home_joints -): - """ - Convenience wrapper: - - estimates T_off - - prints residual stats before/after - - validates by transforming your TRAC-based and MINK-based shared targets - into the other frame and solving IK there. - """ - print("\n[5] Estimating fixed SE(3) offset between TRAC EEF and MINK EEF") - R_off, t_off, stats = _estimate_se3_offset( - trac_solver, mink_solver, n_samples=80, rng_seed=1 - ) - - print(" Pre/Post residuals over random FK samples:") - print( - f" pos mean (pre→post): {stats['pre_pos_mean']:.6f} → {stats['post_pos_mean']:.6f} m" - ) - print( - f" pos median (pre→post): {stats['pre_pos_med']:.6f} → {stats['post_pos_med']:.6f} m" - ) - print( - f" rot mean (pre→post): {stats['pre_rot_mean']:.6f} → {stats['post_rot_mean']:.6f} rad" - ) - print( - f" rot median (pre→post): {stats['pre_rot_med']:.6f} → {stats['post_rot_med']:.6f} rad" - ) - - # Pretty-print T_off - T = np.eye(4) - T[:3, :3] = R_off.as_matrix() - T[:3, 3] = t_off - print(" Estimated T_off (Trac → Mink):") - np.set_printoptions(precision=6, suppress=True) - print(T) - - # ---- Validate on your two shared targets ([2] and [2b]) ---- - print( - "\n Validation: transform TRAC-based shared target into MINK frame and solve:" - ) - target_pos_T = pos_t + np.array([0.1, 0.0, 0.0]) - target_rot_T = rot_t - targM_pos, targM_rot = _apply_trac_to_mink_target( - target_pos_T, target_rot_T, R_off, t_off - ) - mink_sol_adj = mink_solver.ik(targM_pos, _as_matrix(targM_rot), home_joints) - if mink_sol_adj is not None: - pM_hit, RM_hit = mink_solver.fk(mink_sol_adj) - RM_hit = _as_rotation(RM_hit) - print( - f" Mink (transformed target) pos_err={np.linalg.norm(pM_hit - targM_pos):.6e} m | rot_err={_rot_geodesic(RM_hit, targM_rot):.6e} rad" - ) - else: - print(" Mink failed even after transforming the TRAC target.") - - print( - "\n Validation: transform MINK-based shared target into TRAC frame and solve:" - ) - target_pos_M = pos_m + np.array([0.1, 0.0, 0.0]) - target_rot_M = rot_m - targT_pos, targT_rot = _apply_mink_to_trac_target( - target_pos_M, target_rot_M, R_off, t_off - ) - trac_sol_adj = trac_solver.ik(targT_pos, _as_matrix(targT_rot), home_joints) - if trac_sol_adj is not None: - pT_hit, RT_hit = trac_solver.fk(trac_sol_adj) - RT_hit = _as_rotation(RT_hit) - print( - f" Trac (transformed target) pos_err={np.linalg.norm(pT_hit - targT_pos):.6e} m | rot_err={_rot_geodesic(RT_hit, targT_rot):.6e} rad" - ) - else: - print(" Trac failed even after transforming the MINK target.") - - return R_off, t_off - - -# -------------------- Diagnostics: variance, PCA, joint correlations, Jacobians -------------------- - - -def _sample_qs(n=200, seed=0, dof=6, deg_lim=170.0): - rng = np.random.default_rng(seed) - lim = np.deg2rad(deg_lim) - qmin = -lim * np.ones(dof) - qmax = lim * np.ones(dof) - return qmin + (qmax - qmin) * rng.random((n, dof)) - - -def _collect_fk_pairs(trac_solver, mink_solver, Q): - """Return arrays: pT (N,3), RT (N Rotation), pM (N,3), RM (N Rotation).""" - pT, pM, RT, RM = [], [], [], [] - for q in Q: - p_t, r_t = trac_solver.fk(q) - r_t = _as_rotation(r_t) - p_m, r_m = mink_solver.fk(q) - r_m = _as_rotation(r_m) - pT.append(np.asarray(p_t, float)) - RT.append(r_t) - pM.append(np.asarray(p_m, float)) - RM.append(r_m) - return np.asarray(pT), RT, np.asarray(pM), RM - - -def _pca_dir(X): - """Principal direction (unit vec) and explained variances of 3D samples.""" - Xc = X - X.mean(axis=0, keepdims=True) - C = Xc.T @ Xc / max(len(Xc) - 1, 1) - vals, vecs = np.linalg.eigh(C) - idx = np.argsort(vals)[::-1] - vals = vals[idx] - vecs = vecs[:, idx] - return vecs[:, 0], vals # principal axis, variances - - -def _spearman(xs, ys): - # tiny, dependency-free Spearman (ranks + Pearson) - xr = np.argsort(np.argsort(xs)) - yr = np.argsort(np.argsort(ys)) - xr = (xr - xr.mean()) / (xr.std() + 1e-12) - yr = (yr - yr.mean()) / (yr.std() + 1e-12) - return float(np.dot(xr, yr) / (len(xs) - 1)) - - -def _numeric_jacobian_fk_pos(solver, q, eps=1e-6): - """Numeric Jacobian of EEF position wrt joints: Jpos (3,dof).""" - q = np.asarray(q, float) - dof = q.size - J = np.zeros((3, dof)) - p0, _ = solver.fk(q) - p0 = np.asarray(p0, float) - for j in range(dof): - dq = np.zeros_like(q) - dq[j] = eps - p1, _ = solver.fk(q + dq) - p1 = np.asarray(p1, float) - J[:, j] = (p1 - p0) / eps - return J - - -def _numeric_jacobian_fk_rotvec(solver, q, eps=1e-6): - """Numeric Jacobian of orientation (as rotvec) wrt joints: Jrot (3,dof).""" - q = np.asarray(q, float) - dof = q.size - J = np.zeros((3, dof)) - _, R0 = solver.fk(q) - R0 = _as_rotation(R0) - for j in range(dof): - dq = np.zeros_like(q) - dq[j] = eps - _, R1 = solver.fk(q + dq) - R1 = _as_rotation(R1) - dR = R0.inv() * R1 - J[:, j] = dR.as_rotvec() / eps - return J - - -def _jacobian_gap_stats(trac_solver, mink_solver, Q, report_k=5): - """Compare Trac vs Mink numeric Jacobians; return per-joint norms & a quick print.""" - pos_diffs = [] - rot_diffs = [] - for q in Q: - Jt_p = _numeric_jacobian_fk_pos(trac_solver, q) - Jm_p = _numeric_jacobian_fk_pos(mink_solver, q) - Jt_r = _numeric_jacobian_fk_rotvec(trac_solver, q) - Jm_r = _numeric_jacobian_fk_rotvec(mink_solver, q) - pos_diffs.append(np.linalg.norm(Jt_p - Jm_p, axis=0)) # (dof,) - rot_diffs.append(np.linalg.norm(Jt_r - Jm_r, axis=0)) - pos_diffs = np.asarray(pos_diffs) # (N,dof) - rot_diffs = np.asarray(rot_diffs) - - pos_mean = pos_diffs.mean(axis=0) # per-joint averages - rot_mean = rot_diffs.mean(axis=0) - - print( - "\n[Diag] Mean ||ΔJ_pos|| per joint:", - " ".join(f"j{j + 1}:{v:.4f}" for j, v in enumerate(pos_mean)), - ) - print( - "[Diag] Mean ||ΔJ_rot|| per joint:", - " ".join(f"j{j + 1}:{v:.4f}" for j, v in enumerate(rot_mean)), - ) - - # Biggest gaps - jp = np.argsort(-pos_mean)[:report_k] - jr = np.argsort(-rot_mean)[:report_k] - print( - f"[Diag] Top-{report_k} J_pos gaps at joints:", - [int(i + 1) for i in jp], - "values:", - [float(pos_mean[i]) for i in jp], - ) - print( - f"[Diag] Top-{report_k} J_rot gaps at joints:", - [int(i + 1) for i in jr], - "values:", - [float(rot_mean[i]) for i in jr], - ) - return pos_mean, rot_mean - - -def _fit_joint_zero_offsets_on_rot(trac_solver, mink_solver, Q): - """ - Quick test: do constant joint angle offsets (delta_q) reduce orientation gap? - Linearize orientation residual in rotvec space: r ≈ J_rot * delta_q. - Solve least squares for delta_q. Report pre/post orientation residual norms. - """ - _ = Q.shape[1] - Rs = [] - Jall = [] - for q in Q: - _, RT = trac_solver.fk(q) - RT = _as_rotation(RT) - _, RM = mink_solver.fk(q) - RM = _as_rotation(RM) - Rrel = (RT.inv() * RM).as_rotvec() # desired ~ J * delta_q - Jt_r = _numeric_jacobian_fk_rotvec(trac_solver, q) # (3,dof) - Rs.append(Rrel) - Jall.append(Jt_r) - r = np.concatenate(Rs, axis=0) # (3N,) - J = np.concatenate(Jall, axis=0) # (3N,dof) - - # Solve min ||J * dq - r||_2 - dq, *_ = np.linalg.lstsq(J, r, rcond=None) - # Evaluate post residual - r_post = r - J @ dq - pre = np.linalg.norm(r) / np.sqrt(len(r)) - post = np.linalg.norm(r_post) / np.sqrt(len(r_post)) - print("\n[Diag] Joint zero-offset LS on orientation:") - print(f" pre rms(rotvec)={pre:.6e} → post rms(rotvec)={post:.6e}") - print(f" estimated delta_q (deg): {np.rad2deg(dq)}") - return dq, pre, post - - -def run_gap_diagnostics(trac_solver, mink_solver, R_off, t_off, seed=2): - """ - Master diagnostic: variance, PCA, correlations, Jacobians, and LS zero-offsets. - """ - print( - "\n[6] Residual variance, PCA, joint correlations, Jacobian & zero-offset tests" - ) - - Q = _sample_qs(n=200, seed=seed) - pT, RT, pM, RM = _collect_fk_pairs(trac_solver, mink_solver, Q) - - # Residuals pre- and post- SE3 compensation - # Pre: - dP_pre = pM - pT - _ = np.array([(RT[i].inv() * RM[i]).as_rotvec() for i in range(len(Q))]) # (N,3) - # Post (apply Trac→Mink offset): - pT_comp = R_off.apply(pT) + t_off - dP_post = pM - pT_comp - _ = np.array([((R_off * RT[i]).inv() * RM[i]).as_rotvec() for i in range(len(Q))]) - - # Variance & PCA (position part) - pre_var = dP_pre.var(axis=0) - post_var = dP_post.var(axis=0) - vdir_pre, evals_pre = _pca_dir(dP_pre) - vdir_post, evals_post = _pca_dir(dP_post) - print(f" Pos var pre: {pre_var} (sum={pre_var.sum():.6f})") - print(f" Pos var post: {post_var} (sum={post_var.sum():.6f})") - print(f" PCA pre: principal dir ~ {vdir_pre} eigenvals={evals_pre}") - print(f" PCA post: principal dir ~ {vdir_post} eigenvals={evals_post}") - - # Norm residuals and per-joint correlations - r_pre = np.linalg.norm(dP_pre, axis=1) # (N,) - r_post = np.linalg.norm(dP_post, axis=1) - - print("\n Spearman corr(residual, q_j) per joint (pre / post):") - for j in range(Q.shape[1]): - c_pre = _spearman(Q[:, j], r_pre) - c_post = _spearman(Q[:, j], r_post) - print(f" j{j + 1}: {c_pre:+.3f} / {c_post:+.3f}") - - # Simple linear regression of residual magnitude on joints (pre/post) - X = np.c_[Q, np.ones(len(Q))] # add bias - w_pre, *_ = np.linalg.lstsq(X, r_pre, rcond=None) - w_post, *_ = np.linalg.lstsq(X, r_post, rcond=None) - print("\n Linear reg weights on residual magnitude (deg units helpful to read):") - print(" pre :", [f"{wi:.4f}" for wi in w_pre[:-1]], "bias", f"{w_pre[-1]:.4f}") - print( - " post:", [f"{wi:.4f}" for wi in w_post[:-1]], "bias", f"{w_post[-1]:.4f}" - ) - - # Jacobian comparison (which joint’s kinematics disagree?) - _jacobian_gap_stats(trac_solver, mink_solver, Q) - - # Orientation-only: can constant joint zero-offsets explain any rot gap? - _fit_joint_zero_offsets_on_rot(trac_solver, mink_solver, Q) - - -if __name__ == "__main__": - test_eva_mink_ik() - # Uncomment to compare solvers: - # compare_solvers() diff --git a/egomimic/robot/test.py b/egomimic/robot/test.py deleted file mode 100644 index b0703f7937..0000000000 --- a/egomimic/robot/test.py +++ /dev/null @@ -1,10 +0,0 @@ -from scipy.spatial.transform import Rotation as R - -rot = R.random() -rpy = rot.as_euler("XYZ", degrees=False) -ypr = rot.as_euler("ZYX", degrees=False) - -mat1 = R.from_euler("ZYX", rpy, degrees=False) -mat2 = R.from_euler("XYZ", ypr, degrees=False) - -breakpoint() diff --git a/egomimic/utils/aws/test_s3_write.py b/egomimic/utils/aws/test_s3_write.py deleted file mode 100644 index 08c05d16dd..0000000000 --- a/egomimic/utils/aws/test_s3_write.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import socket -from datetime import datetime, timezone - -import boto3 -import cloudpathlib - -from egomimic.utils.aws.aws_data_utils import _uses_r2_endpoint, load_env - - -def build_client() -> tuple[cloudpathlib.S3Client, str]: - load_env() - - endpoint_url = ( - os.environ.get("R2_ENDPOINT_URL") - or os.environ.get("S3_ENDPOINT_URL") - or os.environ.get("AWS_ENDPOINT_URL_S3") - ) - access_key_id = os.environ.get("R2_ACCESS_KEY_ID") or os.environ.get( - "AWS_ACCESS_KEY_ID" - ) - secret_access_key = os.environ.get("R2_SECRET_ACCESS_KEY") or os.environ.get( - "AWS_SECRET_ACCESS_KEY" - ) - session_token = os.environ.get("R2_SESSION_TOKEN") or os.environ.get( - "AWS_SESSION_TOKEN" - ) - - if not access_key_id or not secret_access_key: - raise RuntimeError("Missing R2/AWS access key credentials after load_env().") - if not endpoint_url: - raise RuntimeError("Missing R2 endpoint URL after load_env().") - region_name = os.environ.get("AWS_DEFAULT_REGION", "auto") - if _uses_r2_endpoint(endpoint_url): - session_token = None - region_name = "auto" - - boto3_session = boto3.session.Session( - region_name=region_name, - aws_access_key_id=access_key_id, - aws_secret_access_key=secret_access_key, - aws_session_token=session_token, - ) - return ( - cloudpathlib.S3Client( - endpoint_url=endpoint_url, - boto3_session=boto3_session, - ), - endpoint_url, - ) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Attempt to write a small object to S3/R2 using ~/.egoverse_env." - ) - parser.add_argument( - "target", - nargs="?", - default="s3://rldb/test_object", - help="Destination object URI. Defaults to s3://rldb/test_object", - ) - args = parser.parse_args() - - client, endpoint_url = build_client() - target = cloudpathlib.S3Path(args.target, client=client) - payload = "\n".join( - [ - "EgoVerse cloudpath write test", - f"timestamp_utc={datetime.now(timezone.utc).isoformat()}", - f"host={socket.gethostname()}", - f"target={args.target}", - ] - ) - - print(f"Attempting cloudpathlib write to {args.target}") - print(f"Endpoint: {endpoint_url}") - target.write_text(payload) - print(f"Write succeeded: {args.target}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/egomimic/utils/aws/test_sql_access.py b/egomimic/utils/aws/test_sql_access.py deleted file mode 100644 index 28b4562949..0000000000 --- a/egomimic/utils/aws/test_sql_access.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import argparse - -from sqlalchemy import text - -from egomimic.utils.aws.aws_sql import create_default_engine - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Probe SQL read access and no-op write permission via create_default_engine()." - ) - parser.add_argument( - "--table", - default="app.episodes", - help="Fully-qualified table to probe. Defaults to app.episodes.", - ) - args = parser.parse_args() - - engine = create_default_engine() - - select_sql = text(f"SELECT count(*) FROM {args.table}") - update_sql = text(f"UPDATE {args.table} SET num_frames = num_frames WHERE false") - - with engine.connect() as conn: - row_count = conn.execute(select_sql).scalar_one() - print({"select_ok": True, "table": args.table, "row_count": row_count}) - - try: - with engine.begin() as conn: - result = conn.execute(update_sql) - print( - { - "noop_update_ok": True, - "table": args.table, - "rows_affected": result.rowcount, - } - ) - except Exception as exc: - print( - { - "noop_update_ok": False, - "table": args.table, - "error_type": type(exc).__name__, - "error": str(exc), - } - ) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/external/lerobot/.github/workflows/nightly-tests.yml b/external/lerobot/.github/workflows/nightly-tests.yml deleted file mode 100644 index bbee19a17e..0000000000 --- a/external/lerobot/.github/workflows/nightly-tests.yml +++ /dev/null @@ -1,77 +0,0 @@ -# Inspired by -# https://github.com/huggingface/peft/blob/main/.github/workflows/nightly.yml -name: Nightly - -on: - workflow_dispatch: - schedule: - - cron: "0 2 * * *" - -# env: - # SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} -jobs: - run_all_tests_cpu: - name: CPU - strategy: - fail-fast: false - runs-on: - group: aws-general-8-plus - container: - image: huggingface/lerobot-cpu:latest - options: --shm-size "16gb" - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - defaults: - run: - shell: bash - working-directory: /lerobot - steps: - - name: Tests - run: pytest -v --cov=./lerobot --disable-warnings tests - - - name: Tests end-to-end - run: make test-end-to-end - - - run_all_tests_single_gpu: - name: GPU - strategy: - fail-fast: false - runs-on: - group: aws-g6-4xlarge-plus - env: - CUDA_VISIBLE_DEVICES: "0" - TEST_TYPE: "single_gpu" - container: - image: huggingface/lerobot-gpu:latest - options: --gpus all --shm-size "16gb" - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - defaults: - run: - shell: bash - working-directory: /lerobot - steps: - - name: Nvidia-smi - run: nvidia-smi - - - name: Test - run: pytest -v --cov=./lerobot --cov-report=xml --disable-warnings tests - # TODO(aliberts): Link with HF Codecov account - # - name: Upload coverage reports to Codecov with GitHub Action - # uses: codecov/codecov-action@v4 - # with: - # files: ./coverage.xml - # verbose: true - - name: Tests end-to-end - env: - DEVICE: cuda - run: make test-end-to-end - - # - name: Generate Report - # if: always() - # run: | - # pip install slack_sdk tabulate - # python scripts/log_reports.py >> $GITHUB_STEP_SUMMARY diff --git a/external/lerobot/.github/workflows/test.yml b/external/lerobot/.github/workflows/test.yml deleted file mode 100644 index 53b37466a2..0000000000 --- a/external/lerobot/.github/workflows/test.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: Tests - -on: - pull_request: - branches: - - main - paths: - - "lerobot/**" - - "tests/**" - - "examples/**" - - ".github/**" - - "poetry.lock" - - "Makefile" - - ".cache/**" - push: - branches: - - main - paths: - - "lerobot/**" - - "tests/**" - - "examples/**" - - ".github/**" - - "poetry.lock" - - "Makefile" - - ".cache/**" - -jobs: - pytest: - name: Pytest - runs-on: ubuntu-latest - env: - MUJOCO_GL: egl - steps: - - uses: actions/checkout@v4 - with: - lfs: true # Ensure LFS files are pulled - - - name: Install apt dependencies - # portaudio19-dev is needed to install pyaudio - run: | - sudo apt-get update && \ - sudo apt-get install -y libegl1-mesa-dev ffmpeg portaudio19-dev - - - name: Install poetry - run: | - pipx install poetry && poetry config virtualenvs.in-project true - echo "${{ github.workspace }}/.venv/bin" >> $GITHUB_PATH - - # TODO(rcadene, aliberts): python 3.12 seems to be used in the tests, not python 3.10 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - cache: "poetry" - - - name: Install poetry dependencies - run: | - poetry install --all-extras - - - name: Test with pytest - run: | - pytest tests -v --cov=./lerobot --durations=0 \ - -W ignore::DeprecationWarning:imageio_ffmpeg._utils:7 \ - -W ignore::UserWarning:torch.utils.data.dataloader:558 \ - -W ignore::UserWarning:gymnasium.utils.env_checker:247 \ - && rm -rf tests/outputs outputs - - pytest-minimal: - name: Pytest (minimal install) - runs-on: ubuntu-latest - env: - MUJOCO_GL: egl - steps: - - uses: actions/checkout@v4 - with: - lfs: true # Ensure LFS files are pulled - - - name: Install apt dependencies - run: sudo apt-get update && sudo apt-get install -y ffmpeg - - - name: Install poetry - run: | - pipx install poetry && poetry config virtualenvs.in-project true - echo "${{ github.workspace }}/.venv/bin" >> $GITHUB_PATH - - # TODO(rcadene, aliberts): python 3.12 seems to be used in the tests, not python 3.10 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install poetry dependencies - run: | - poetry install --extras "test" - - - name: Test with pytest - run: | - pytest tests -v --cov=./lerobot --durations=0 \ - -W ignore::DeprecationWarning:imageio_ffmpeg._utils:7 \ - -W ignore::UserWarning:torch.utils.data.dataloader:558 \ - -W ignore::UserWarning:gymnasium.utils.env_checker:247 \ - && rm -rf tests/outputs outputs - - # TODO(aliberts, rcadene): redesign after v2 migration / removing hydra - # end-to-end: - # name: End-to-end - # runs-on: ubuntu-latest - # env: - # MUJOCO_GL: egl - # steps: - # - uses: actions/checkout@v4 - # with: - # lfs: true # Ensure LFS files are pulled - - # - name: Install apt dependencies - # # portaudio19-dev is needed to install pyaudio - # run: | - # sudo apt-get update && \ - # sudo apt-get install -y libegl1-mesa-dev portaudio19-dev - - # - name: Install poetry - # run: | - # pipx install poetry && poetry config virtualenvs.in-project true - # echo "${{ github.workspace }}/.venv/bin" >> $GITHUB_PATH - - # - name: Set up Python 3.10 - # uses: actions/setup-python@v5 - # with: - # python-version: "3.10" - # cache: "poetry" - - # - name: Install poetry dependencies - # run: | - # poetry install --all-extras - - # - name: Test end-to-end - # run: | - # make test-end-to-end \ - # && rm -rf outputs diff --git a/external/lerobot/Makefile b/external/lerobot/Makefile index f6517497e1..3b5771ef32 100644 --- a/external/lerobot/Makefile +++ b/external/lerobot/Makefile @@ -1,190 +1,7 @@ -.PHONY: tests - -PYTHON_PATH := $(shell which python) - -# If Poetry is installed, redefine PYTHON_PATH to use the Poetry-managed Python -POETRY_CHECK := $(shell command -v poetry) -ifneq ($(POETRY_CHECK),) - PYTHON_PATH := $(shell poetry run which python) -endif - -export PATH := $(dir $(PYTHON_PATH)):$(PATH) - -DEVICE ?= cpu +.PHONY: build-cpu build-gpu build-cpu: docker build -t lerobot:latest -f docker/lerobot-cpu/Dockerfile . build-gpu: docker build -t lerobot:latest -f docker/lerobot-gpu/Dockerfile . - -test-end-to-end: - ${MAKE} DEVICE=$(DEVICE) test-act-ete-train - ${MAKE} DEVICE=$(DEVICE) test-act-ete-eval - ${MAKE} DEVICE=$(DEVICE) test-act-ete-train-amp - ${MAKE} DEVICE=$(DEVICE) test-act-ete-eval-amp - ${MAKE} DEVICE=$(DEVICE) test-diffusion-ete-train - ${MAKE} DEVICE=$(DEVICE) test-diffusion-ete-eval - ${MAKE} DEVICE=$(DEVICE) test-tdmpc-ete-train - ${MAKE} DEVICE=$(DEVICE) test-tdmpc-ete-train-with-online - ${MAKE} DEVICE=$(DEVICE) test-tdmpc-ete-eval - ${MAKE} DEVICE=$(DEVICE) test-default-ete-eval - ${MAKE} DEVICE=$(DEVICE) test-act-pusht-tutorial - -test-act-ete-train: - python lerobot/scripts/train.py \ - policy=act \ - policy.dim_model=64 \ - env=aloha \ - wandb.enable=False \ - training.offline_steps=2 \ - training.online_steps=0 \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - device=$(DEVICE) \ - training.save_checkpoint=true \ - training.save_freq=2 \ - policy.n_action_steps=20 \ - policy.chunk_size=20 \ - training.batch_size=2 \ - training.image_transforms.enable=true \ - hydra.run.dir=tests/outputs/act/ - -test-act-ete-eval: - python lerobot/scripts/eval.py \ - -p tests/outputs/act/checkpoints/000002/pretrained_model \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=8 \ - device=$(DEVICE) \ - -test-act-ete-train-amp: - python lerobot/scripts/train.py \ - policy=act \ - policy.dim_model=64 \ - env=aloha \ - wandb.enable=False \ - training.offline_steps=2 \ - training.online_steps=0 \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - device=$(DEVICE) \ - training.save_checkpoint=true \ - training.save_freq=2 \ - policy.n_action_steps=20 \ - policy.chunk_size=20 \ - training.batch_size=2 \ - hydra.run.dir=tests/outputs/act_amp/ \ - training.image_transforms.enable=true \ - use_amp=true - -test-act-ete-eval-amp: - python lerobot/scripts/eval.py \ - -p tests/outputs/act_amp/checkpoints/000002/pretrained_model \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=8 \ - device=$(DEVICE) \ - use_amp=true - -test-diffusion-ete-train: - python lerobot/scripts/train.py \ - policy=diffusion \ - policy.down_dims=\[64,128,256\] \ - policy.diffusion_step_embed_dim=32 \ - policy.num_inference_steps=10 \ - env=pusht \ - wandb.enable=False \ - training.offline_steps=2 \ - training.online_steps=0 \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - device=$(DEVICE) \ - training.save_checkpoint=true \ - training.save_freq=2 \ - training.batch_size=2 \ - training.image_transforms.enable=true \ - hydra.run.dir=tests/outputs/diffusion/ - -test-diffusion-ete-eval: - python lerobot/scripts/eval.py \ - -p tests/outputs/diffusion/checkpoints/000002/pretrained_model \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=8 \ - device=$(DEVICE) \ - -test-tdmpc-ete-train: - python lerobot/scripts/train.py \ - policy=tdmpc \ - env=xarm \ - env.task=XarmLift-v0 \ - dataset_repo_id=lerobot/xarm_lift_medium \ - wandb.enable=False \ - training.offline_steps=2 \ - training.online_steps=0 \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=2 \ - device=$(DEVICE) \ - training.save_checkpoint=true \ - training.save_freq=2 \ - training.batch_size=2 \ - training.image_transforms.enable=true \ - hydra.run.dir=tests/outputs/tdmpc/ - -test-tdmpc-ete-train-with-online: - python lerobot/scripts/train.py \ - env=pusht \ - env.gym.obs_type=environment_state_agent_pos \ - policy=tdmpc_pusht_keypoints \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=10 \ - device=$(DEVICE) \ - training.offline_steps=2 \ - training.online_steps=20 \ - training.save_checkpoint=false \ - training.save_freq=10 \ - training.batch_size=2 \ - training.online_rollout_n_episodes=2 \ - training.online_rollout_batch_size=2 \ - training.online_steps_between_rollouts=10 \ - training.online_buffer_capacity=15 \ - eval.use_async_envs=true \ - hydra.run.dir=tests/outputs/tdmpc_online/ - - -test-tdmpc-ete-eval: - python lerobot/scripts/eval.py \ - -p tests/outputs/tdmpc/checkpoints/000002/pretrained_model \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=8 \ - device=$(DEVICE) \ - -test-default-ete-eval: - python lerobot/scripts/eval.py \ - --config lerobot/configs/default.yaml \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=8 \ - device=$(DEVICE) \ - -test-act-pusht-tutorial: - cp examples/advanced/1_train_act_pusht/act_pusht.yaml lerobot/configs/policy/created_by_Makefile.yaml - python lerobot/scripts/train.py \ - policy=created_by_Makefile.yaml \ - env=pusht \ - wandb.enable=False \ - training.offline_steps=2 \ - eval.n_episodes=1 \ - eval.batch_size=1 \ - env.episode_length=2 \ - device=$(DEVICE) \ - training.save_model=true \ - training.save_freq=2 \ - training.batch_size=2 \ - training.image_transforms.enable=true \ - hydra.run.dir=tests/outputs/act_pusht/ - rm lerobot/configs/policy/created_by_Makefile.yaml diff --git a/external/lerobot/tests/__init__.py b/external/lerobot/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/external/lerobot/tests/conftest.py b/external/lerobot/tests/conftest.py deleted file mode 100644 index 2075c2aa65..0000000000 --- a/external/lerobot/tests/conftest.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import traceback - -import pytest -from serial import SerialException - -from lerobot import available_cameras, available_motors, available_robots -from lerobot.common.utils.utils import init_hydra_config -from tests.utils import DEVICE, ROBOT_CONFIG_PATH_TEMPLATE, make_camera, make_motors_bus - -# Import fixture modules as plugins -pytest_plugins = [ - "tests.fixtures.dataset_factories", - "tests.fixtures.files", - "tests.fixtures.hub", -] - - -def pytest_collection_finish(): - print(f"\nTesting with {DEVICE=}") - - -@pytest.fixture -def is_robot_available(robot_type): - if robot_type not in available_robots: - raise ValueError( - f"The robot type '{robot_type}' is not valid. Expected one of these '{available_robots}" - ) - - try: - from lerobot.common.robot_devices.robots.factory import make_robot - - config_path = ROBOT_CONFIG_PATH_TEMPLATE.format(robot=robot_type) - robot_cfg = init_hydra_config(config_path) - robot = make_robot(robot_cfg) - robot.connect() - del robot - return True - - except Exception as e: - print(f"\nA {robot_type} robot is not available.") - - if isinstance(e, ModuleNotFoundError): - print(f"\nInstall module '{e.name}'") - elif isinstance(e, SerialException): - print("\nNo physical motors bus detected.") - else: - traceback.print_exc() - - return False - - -@pytest.fixture -def is_camera_available(camera_type): - if camera_type not in available_cameras: - raise ValueError( - f"The camera type '{camera_type}' is not valid. Expected one of these '{available_cameras}" - ) - - try: - camera = make_camera(camera_type) - camera.connect() - del camera - return True - - except Exception as e: - print(f"\nA {camera_type} camera is not available.") - - if isinstance(e, ModuleNotFoundError): - print(f"\nInstall module '{e.name}'") - elif isinstance(e, ValueError) and "camera_index" in e.args[0]: - print("\nNo physical camera detected.") - else: - traceback.print_exc() - - return False - - -@pytest.fixture -def is_motor_available(motor_type): - if motor_type not in available_motors: - raise ValueError( - f"The motor type '{motor_type}' is not valid. Expected one of these '{available_motors}" - ) - - try: - motors_bus = make_motors_bus(motor_type) - motors_bus.connect() - del motors_bus - return True - - except Exception as e: - print(f"\nA {motor_type} motor is not available.") - - if isinstance(e, ModuleNotFoundError): - print(f"\nInstall module '{e.name}'") - elif isinstance(e, SerialException): - print("\nNo physical motors bus detected.") - else: - traceback.print_exc() - - return False - - -@pytest.fixture -def patch_builtins_input(monkeypatch): - def print_text(text=None): - if text is not None: - print(text) - - monkeypatch.setattr("builtins.input", print_text) diff --git a/external/lerobot/tests/fixtures/constants.py b/external/lerobot/tests/fixtures/constants.py deleted file mode 100644 index bfe6c339b9..0000000000 --- a/external/lerobot/tests/fixtures/constants.py +++ /dev/null @@ -1,29 +0,0 @@ -from lerobot.common.datasets.lerobot_dataset import LEROBOT_HOME - -LEROBOT_TEST_DIR = LEROBOT_HOME / "_testing" -DUMMY_REPO_ID = "dummy/repo" -DUMMY_ROBOT_TYPE = "dummy_robot" -DUMMY_MOTOR_FEATURES = { - "action": { - "dtype": "float32", - "shape": (6,), - "names": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"], - }, - "state": { - "dtype": "float32", - "shape": (6,), - "names": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"], - }, -} -DUMMY_CAMERA_FEATURES = { - "laptop": {"shape": (480, 640, 3), "names": ["height", "width", "channels"], "info": None}, - "phone": {"shape": (480, 640, 3), "names": ["height", "width", "channels"], "info": None}, -} -DEFAULT_FPS = 30 -DUMMY_VIDEO_INFO = { - "video.fps": DEFAULT_FPS, - "video.codec": "av1", - "video.pix_fmt": "yuv420p", - "video.is_depth_map": False, - "has_audio": False, -} diff --git a/external/lerobot/tests/fixtures/dataset_factories.py b/external/lerobot/tests/fixtures/dataset_factories.py deleted file mode 100644 index c28a11653a..0000000000 --- a/external/lerobot/tests/fixtures/dataset_factories.py +++ /dev/null @@ -1,396 +0,0 @@ -import random -from pathlib import Path -from unittest.mock import patch - -import datasets -import numpy as np -import PIL.Image -import pytest -import torch - -from lerobot.common.datasets.lerobot_dataset import CODEBASE_VERSION, LeRobotDataset, LeRobotDatasetMetadata -from lerobot.common.datasets.utils import ( - DEFAULT_CHUNK_SIZE, - DEFAULT_FEATURES, - DEFAULT_PARQUET_PATH, - DEFAULT_VIDEO_PATH, - get_hf_features_from_features, - hf_transform_to_torch, -) -from tests.fixtures.constants import ( - DEFAULT_FPS, - DUMMY_CAMERA_FEATURES, - DUMMY_MOTOR_FEATURES, - DUMMY_REPO_ID, - DUMMY_ROBOT_TYPE, - DUMMY_VIDEO_INFO, -) - - -def get_task_index(task_dicts: dict, task: str) -> int: - tasks = {d["task_index"]: d["task"] for d in task_dicts} - task_to_task_index = {task: task_idx for task_idx, task in tasks.items()} - return task_to_task_index[task] - - -@pytest.fixture(scope="session") -def img_tensor_factory(): - def _create_img_tensor(height=100, width=100, channels=3, dtype=torch.float32) -> torch.Tensor: - return torch.rand((channels, height, width), dtype=dtype) - - return _create_img_tensor - - -@pytest.fixture(scope="session") -def img_array_factory(): - def _create_img_array(height=100, width=100, channels=3, dtype=np.uint8) -> np.ndarray: - if np.issubdtype(dtype, np.unsignedinteger): - # Int array in [0, 255] range - img_array = np.random.randint(0, 256, size=(height, width, channels), dtype=dtype) - elif np.issubdtype(dtype, np.floating): - # Float array in [0, 1] range - img_array = np.random.rand(height, width, channels).astype(dtype) - else: - raise ValueError(dtype) - return img_array - - return _create_img_array - - -@pytest.fixture(scope="session") -def img_factory(img_array_factory): - def _create_img(height=100, width=100) -> PIL.Image.Image: - img_array = img_array_factory(height=height, width=width) - return PIL.Image.fromarray(img_array) - - return _create_img - - -@pytest.fixture(scope="session") -def features_factory(): - def _create_features( - motor_features: dict = DUMMY_MOTOR_FEATURES, - camera_features: dict = DUMMY_CAMERA_FEATURES, - use_videos: bool = True, - ) -> dict: - if use_videos: - camera_ft = { - key: {"dtype": "video", **ft, **DUMMY_VIDEO_INFO} for key, ft in camera_features.items() - } - else: - camera_ft = {key: {"dtype": "image", **ft} for key, ft in camera_features.items()} - return { - **motor_features, - **camera_ft, - **DEFAULT_FEATURES, - } - - return _create_features - - -@pytest.fixture(scope="session") -def info_factory(features_factory): - def _create_info( - codebase_version: str = CODEBASE_VERSION, - fps: int = DEFAULT_FPS, - robot_type: str = DUMMY_ROBOT_TYPE, - total_episodes: int = 0, - total_frames: int = 0, - total_tasks: int = 0, - total_videos: int = 0, - total_chunks: int = 0, - chunks_size: int = DEFAULT_CHUNK_SIZE, - data_path: str = DEFAULT_PARQUET_PATH, - video_path: str = DEFAULT_VIDEO_PATH, - motor_features: dict = DUMMY_MOTOR_FEATURES, - camera_features: dict = DUMMY_CAMERA_FEATURES, - use_videos: bool = True, - ) -> dict: - features = features_factory(motor_features, camera_features, use_videos) - return { - "codebase_version": codebase_version, - "robot_type": robot_type, - "total_episodes": total_episodes, - "total_frames": total_frames, - "total_tasks": total_tasks, - "total_videos": total_videos, - "total_chunks": total_chunks, - "chunks_size": chunks_size, - "fps": fps, - "splits": {}, - "data_path": data_path, - "video_path": video_path if use_videos else None, - "features": features, - } - - return _create_info - - -@pytest.fixture(scope="session") -def stats_factory(): - def _create_stats( - features: dict[str] | None = None, - ) -> dict: - stats = {} - for key, ft in features.items(): - shape = ft["shape"] - dtype = ft["dtype"] - if dtype in ["image", "video"]: - stats[key] = { - "max": np.full((3, 1, 1), 1, dtype=np.float32).tolist(), - "mean": np.full((3, 1, 1), 0.5, dtype=np.float32).tolist(), - "min": np.full((3, 1, 1), 0, dtype=np.float32).tolist(), - "std": np.full((3, 1, 1), 0.25, dtype=np.float32).tolist(), - } - else: - stats[key] = { - "max": np.full(shape, 1, dtype=dtype).tolist(), - "mean": np.full(shape, 0.5, dtype=dtype).tolist(), - "min": np.full(shape, 0, dtype=dtype).tolist(), - "std": np.full(shape, 0.25, dtype=dtype).tolist(), - } - return stats - - return _create_stats - - -@pytest.fixture(scope="session") -def tasks_factory(): - def _create_tasks(total_tasks: int = 3) -> int: - tasks_list = [] - for i in range(total_tasks): - task_dict = {"task_index": i, "task": f"Perform action {i}."} - tasks_list.append(task_dict) - return tasks_list - - return _create_tasks - - -@pytest.fixture(scope="session") -def episodes_factory(tasks_factory): - def _create_episodes( - total_episodes: int = 3, - total_frames: int = 400, - tasks: dict | None = None, - multi_task: bool = False, - ): - if total_episodes <= 0 or total_frames <= 0: - raise ValueError("num_episodes and total_length must be positive integers.") - if total_frames < total_episodes: - raise ValueError("total_length must be greater than or equal to num_episodes.") - - if not tasks: - min_tasks = 2 if multi_task else 1 - total_tasks = random.randint(min_tasks, total_episodes) - tasks = tasks_factory(total_tasks) - - if total_episodes < len(tasks) and not multi_task: - raise ValueError("The number of tasks should be less than the number of episodes.") - - # Generate random lengths that sum up to total_length - lengths = np.random.multinomial(total_frames, [1 / total_episodes] * total_episodes).tolist() - - tasks_list = [task_dict["task"] for task_dict in tasks] - num_tasks_available = len(tasks_list) - - episodes_list = [] - remaining_tasks = tasks_list.copy() - for ep_idx in range(total_episodes): - num_tasks_in_episode = random.randint(1, min(3, num_tasks_available)) if multi_task else 1 - tasks_to_sample = remaining_tasks if remaining_tasks else tasks_list - episode_tasks = random.sample(tasks_to_sample, min(num_tasks_in_episode, len(tasks_to_sample))) - if remaining_tasks: - for task in episode_tasks: - remaining_tasks.remove(task) - - episodes_list.append( - { - "episode_index": ep_idx, - "tasks": episode_tasks, - "length": lengths[ep_idx], - } - ) - - return episodes_list - - return _create_episodes - - -@pytest.fixture(scope="session") -def hf_dataset_factory(features_factory, tasks_factory, episodes_factory, img_array_factory): - def _create_hf_dataset( - features: dict | None = None, - tasks: list[dict] | None = None, - episodes: list[dict] | None = None, - fps: int = DEFAULT_FPS, - ) -> datasets.Dataset: - if not tasks: - tasks = tasks_factory() - if not episodes: - episodes = episodes_factory() - if not features: - features = features_factory() - - timestamp_col = np.array([], dtype=np.float32) - frame_index_col = np.array([], dtype=np.int64) - episode_index_col = np.array([], dtype=np.int64) - task_index = np.array([], dtype=np.int64) - for ep_dict in episodes: - timestamp_col = np.concatenate((timestamp_col, np.arange(ep_dict["length"]) / fps)) - frame_index_col = np.concatenate((frame_index_col, np.arange(ep_dict["length"], dtype=int))) - episode_index_col = np.concatenate( - (episode_index_col, np.full(ep_dict["length"], ep_dict["episode_index"], dtype=int)) - ) - ep_task_index = get_task_index(tasks, ep_dict["tasks"][0]) - task_index = np.concatenate((task_index, np.full(ep_dict["length"], ep_task_index, dtype=int))) - - index_col = np.arange(len(episode_index_col)) - - robot_cols = {} - for key, ft in features.items(): - if ft["dtype"] == "image": - robot_cols[key] = [ - img_array_factory(height=ft["shapes"][1], width=ft["shapes"][0]) - for _ in range(len(index_col)) - ] - elif ft["shape"][0] > 1 and ft["dtype"] != "video": - robot_cols[key] = np.random.random((len(index_col), ft["shape"][0])).astype(ft["dtype"]) - - hf_features = get_hf_features_from_features(features) - dataset = datasets.Dataset.from_dict( - { - **robot_cols, - "timestamp": timestamp_col, - "frame_index": frame_index_col, - "episode_index": episode_index_col, - "index": index_col, - "task_index": task_index, - }, - features=hf_features, - ) - dataset.set_transform(hf_transform_to_torch) - return dataset - - return _create_hf_dataset - - -@pytest.fixture(scope="session") -def lerobot_dataset_metadata_factory( - info_factory, - stats_factory, - tasks_factory, - episodes_factory, - mock_snapshot_download_factory, -): - def _create_lerobot_dataset_metadata( - root: Path, - repo_id: str = DUMMY_REPO_ID, - info: dict | None = None, - stats: dict | None = None, - tasks: list[dict] | None = None, - episodes: list[dict] | None = None, - local_files_only: bool = False, - ) -> LeRobotDatasetMetadata: - if not info: - info = info_factory() - if not stats: - stats = stats_factory(features=info["features"]) - if not tasks: - tasks = tasks_factory(total_tasks=info["total_tasks"]) - if not episodes: - episodes = episodes_factory( - total_episodes=info["total_episodes"], total_frames=info["total_frames"], tasks=tasks - ) - - mock_snapshot_download = mock_snapshot_download_factory( - info=info, - stats=stats, - tasks=tasks, - episodes=episodes, - ) - with ( - patch( - "lerobot.common.datasets.lerobot_dataset.get_hub_safe_version" - ) as mock_get_hub_safe_version_patch, - patch( - "lerobot.common.datasets.lerobot_dataset.snapshot_download" - ) as mock_snapshot_download_patch, - ): - mock_get_hub_safe_version_patch.side_effect = lambda repo_id, version: version - mock_snapshot_download_patch.side_effect = mock_snapshot_download - - return LeRobotDatasetMetadata(repo_id=repo_id, root=root, local_files_only=local_files_only) - - return _create_lerobot_dataset_metadata - - -@pytest.fixture(scope="session") -def lerobot_dataset_factory( - info_factory, - stats_factory, - tasks_factory, - episodes_factory, - hf_dataset_factory, - mock_snapshot_download_factory, - lerobot_dataset_metadata_factory, -): - def _create_lerobot_dataset( - root: Path, - repo_id: str = DUMMY_REPO_ID, - total_episodes: int = 3, - total_frames: int = 150, - total_tasks: int = 1, - multi_task: bool = False, - info: dict | None = None, - stats: dict | None = None, - tasks: list[dict] | None = None, - episode_dicts: list[dict] | None = None, - hf_dataset: datasets.Dataset | None = None, - **kwargs, - ) -> LeRobotDataset: - if not info: - info = info_factory( - total_episodes=total_episodes, total_frames=total_frames, total_tasks=total_tasks - ) - if not stats: - stats = stats_factory(features=info["features"]) - if not tasks: - tasks = tasks_factory(total_tasks=info["total_tasks"]) - if not episode_dicts: - episode_dicts = episodes_factory( - total_episodes=info["total_episodes"], - total_frames=info["total_frames"], - tasks=tasks, - multi_task=multi_task, - ) - if not hf_dataset: - hf_dataset = hf_dataset_factory(tasks=tasks, episodes=episode_dicts, fps=info["fps"]) - - mock_snapshot_download = mock_snapshot_download_factory( - info=info, - stats=stats, - tasks=tasks, - episodes=episode_dicts, - hf_dataset=hf_dataset, - ) - mock_metadata = lerobot_dataset_metadata_factory( - root=root, - repo_id=repo_id, - info=info, - stats=stats, - tasks=tasks, - episodes=episode_dicts, - local_files_only=kwargs.get("local_files_only", False), - ) - with ( - patch("lerobot.common.datasets.lerobot_dataset.LeRobotDatasetMetadata") as mock_metadata_patch, - patch( - "lerobot.common.datasets.lerobot_dataset.snapshot_download" - ) as mock_snapshot_download_patch, - ): - mock_metadata_patch.return_value = mock_metadata - mock_snapshot_download_patch.side_effect = mock_snapshot_download - - return LeRobotDataset(repo_id=repo_id, root=root, **kwargs) - - return _create_lerobot_dataset diff --git a/external/lerobot/tests/fixtures/files.py b/external/lerobot/tests/fixtures/files.py deleted file mode 100644 index 5fe8a3148e..0000000000 --- a/external/lerobot/tests/fixtures/files.py +++ /dev/null @@ -1,114 +0,0 @@ -import json -from pathlib import Path - -import datasets -import jsonlines -import pyarrow.compute as pc -import pyarrow.parquet as pq -import pytest - -from lerobot.common.datasets.utils import EPISODES_PATH, INFO_PATH, STATS_PATH, TASKS_PATH - - -@pytest.fixture(scope="session") -def info_path(info_factory): - def _create_info_json_file(dir: Path, info: dict | None = None) -> Path: - if not info: - info = info_factory() - fpath = dir / INFO_PATH - fpath.parent.mkdir(parents=True, exist_ok=True) - with open(fpath, "w") as f: - json.dump(info, f, indent=4, ensure_ascii=False) - return fpath - - return _create_info_json_file - - -@pytest.fixture(scope="session") -def stats_path(stats_factory): - def _create_stats_json_file(dir: Path, stats: dict | None = None) -> Path: - if not stats: - stats = stats_factory() - fpath = dir / STATS_PATH - fpath.parent.mkdir(parents=True, exist_ok=True) - with open(fpath, "w") as f: - json.dump(stats, f, indent=4, ensure_ascii=False) - return fpath - - return _create_stats_json_file - - -@pytest.fixture(scope="session") -def tasks_path(tasks_factory): - def _create_tasks_jsonl_file(dir: Path, tasks: list | None = None) -> Path: - if not tasks: - tasks = tasks_factory() - fpath = dir / TASKS_PATH - fpath.parent.mkdir(parents=True, exist_ok=True) - with jsonlines.open(fpath, "w") as writer: - writer.write_all(tasks) - return fpath - - return _create_tasks_jsonl_file - - -@pytest.fixture(scope="session") -def episode_path(episodes_factory): - def _create_episodes_jsonl_file(dir: Path, episodes: list | None = None) -> Path: - if not episodes: - episodes = episodes_factory() - fpath = dir / EPISODES_PATH - fpath.parent.mkdir(parents=True, exist_ok=True) - with jsonlines.open(fpath, "w") as writer: - writer.write_all(episodes) - return fpath - - return _create_episodes_jsonl_file - - -@pytest.fixture(scope="session") -def single_episode_parquet_path(hf_dataset_factory, info_factory): - def _create_single_episode_parquet( - dir: Path, ep_idx: int = 0, hf_dataset: datasets.Dataset | None = None, info: dict | None = None - ) -> Path: - if not info: - info = info_factory() - if hf_dataset is None: - hf_dataset = hf_dataset_factory() - - data_path = info["data_path"] - chunks_size = info["chunks_size"] - ep_chunk = ep_idx // chunks_size - fpath = dir / data_path.format(episode_chunk=ep_chunk, episode_index=ep_idx) - fpath.parent.mkdir(parents=True, exist_ok=True) - table = hf_dataset.data.table - ep_table = table.filter(pc.equal(table["episode_index"], ep_idx)) - pq.write_table(ep_table, fpath) - return fpath - - return _create_single_episode_parquet - - -@pytest.fixture(scope="session") -def multi_episode_parquet_path(hf_dataset_factory, info_factory): - def _create_multi_episode_parquet( - dir: Path, hf_dataset: datasets.Dataset | None = None, info: dict | None = None - ) -> Path: - if not info: - info = info_factory() - if hf_dataset is None: - hf_dataset = hf_dataset_factory() - - data_path = info["data_path"] - chunks_size = info["chunks_size"] - total_episodes = info["total_episodes"] - for ep_idx in range(total_episodes): - ep_chunk = ep_idx // chunks_size - fpath = dir / data_path.format(episode_chunk=ep_chunk, episode_index=ep_idx) - fpath.parent.mkdir(parents=True, exist_ok=True) - table = hf_dataset.data.table - ep_table = table.filter(pc.equal(table["episode_index"], ep_idx)) - pq.write_table(ep_table, fpath) - return dir / "data" - - return _create_multi_episode_parquet diff --git a/external/lerobot/tests/fixtures/hub.py b/external/lerobot/tests/fixtures/hub.py deleted file mode 100644 index 351768c041..0000000000 --- a/external/lerobot/tests/fixtures/hub.py +++ /dev/null @@ -1,105 +0,0 @@ -from pathlib import Path - -import datasets -import pytest -from huggingface_hub.utils import filter_repo_objects - -from lerobot.common.datasets.utils import EPISODES_PATH, INFO_PATH, STATS_PATH, TASKS_PATH -from tests.fixtures.constants import LEROBOT_TEST_DIR - - -@pytest.fixture(scope="session") -def mock_snapshot_download_factory( - info_factory, - info_path, - stats_factory, - stats_path, - tasks_factory, - tasks_path, - episodes_factory, - episode_path, - single_episode_parquet_path, - hf_dataset_factory, -): - """ - This factory allows to patch snapshot_download such that when called, it will create expected files rather - than making calls to the hub api. Its design allows to pass explicitly files which you want to be created. - """ - - def _mock_snapshot_download_func( - info: dict | None = None, - stats: dict | None = None, - tasks: list[dict] | None = None, - episodes: list[dict] | None = None, - hf_dataset: datasets.Dataset | None = None, - ): - if not info: - info = info_factory() - if not stats: - stats = stats_factory(features=info["features"]) - if not tasks: - tasks = tasks_factory(total_tasks=info["total_tasks"]) - if not episodes: - episodes = episodes_factory( - total_episodes=info["total_episodes"], total_frames=info["total_frames"], tasks=tasks - ) - if not hf_dataset: - hf_dataset = hf_dataset_factory(tasks=tasks, episodes=episodes, fps=info["fps"]) - - def _extract_episode_index_from_path(fpath: str) -> int: - path = Path(fpath) - if path.suffix == ".parquet" and path.stem.startswith("episode_"): - episode_index = int(path.stem[len("episode_") :]) # 'episode_000000' -> 0 - return episode_index - else: - return None - - def _mock_snapshot_download( - repo_id: str, - local_dir: str | Path | None = None, - allow_patterns: str | list[str] | None = None, - ignore_patterns: str | list[str] | None = None, - *args, - **kwargs, - ) -> str: - if not local_dir: - local_dir = LEROBOT_TEST_DIR - - # List all possible files - all_files = [] - meta_files = [INFO_PATH, STATS_PATH, TASKS_PATH, EPISODES_PATH] - all_files.extend(meta_files) - - data_files = [] - for episode_dict in episodes: - ep_idx = episode_dict["episode_index"] - ep_chunk = ep_idx // info["chunks_size"] - data_path = info["data_path"].format(episode_chunk=ep_chunk, episode_index=ep_idx) - data_files.append(data_path) - all_files.extend(data_files) - - allowed_files = filter_repo_objects( - all_files, allow_patterns=allow_patterns, ignore_patterns=ignore_patterns - ) - - # Create allowed files - for rel_path in allowed_files: - if rel_path.startswith("data/"): - episode_index = _extract_episode_index_from_path(rel_path) - if episode_index is not None: - _ = single_episode_parquet_path(local_dir, episode_index, hf_dataset, info) - if rel_path == INFO_PATH: - _ = info_path(local_dir, info) - elif rel_path == STATS_PATH: - _ = stats_path(local_dir, stats) - elif rel_path == TASKS_PATH: - _ = tasks_path(local_dir, tasks) - elif rel_path == EPISODES_PATH: - _ = episode_path(local_dir, episodes) - else: - pass - return str(local_dir) - - return _mock_snapshot_download - - return _mock_snapshot_download_func diff --git a/external/lerobot/tests/lerobot/common/datasets/test_utils.py b/external/lerobot/tests/lerobot/common/datasets/test_utils.py deleted file mode 100644 index f484e1aefa..0000000000 --- a/external/lerobot/tests/lerobot/common/datasets/test_utils.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from huggingface_hub import DatasetCard - -from lerobot.common.datasets.utils import create_lerobot_dataset_card - - -def test_default_parameters(): - card = create_lerobot_dataset_card() - assert isinstance(card, DatasetCard) - assert card.data.tags == ["LeRobot"] - assert card.data.task_categories == ["robotics"] - assert card.data.configs == [ - { - "config_name": "default", - "data_files": "data/*/*.parquet", - } - ] - - -def test_with_tags(): - tags = ["tag1", "tag2"] - card = create_lerobot_dataset_card(tags=tags) - assert card.data.tags == ["LeRobot", "tag1", "tag2"] diff --git a/external/lerobot/tests/mock_cv2.py b/external/lerobot/tests/mock_cv2.py deleted file mode 100644 index 3f3f9e3434..0000000000 --- a/external/lerobot/tests/mock_cv2.py +++ /dev/null @@ -1,83 +0,0 @@ -from functools import cache - -import numpy as np - -CAP_PROP_FPS = 5 -CAP_PROP_FRAME_WIDTH = 3 -CAP_PROP_FRAME_HEIGHT = 4 -COLOR_RGB2BGR = 4 -COLOR_BGR2RGB = 4 - -ROTATE_90_COUNTERCLOCKWISE = 2 -ROTATE_90_CLOCKWISE = 0 -ROTATE_180 = 1 - - -@cache -def _generate_image(width: int, height: int): - return np.random.randint(0, 256, size=(height, width, 3), dtype=np.uint8) - - -def cvtColor(color_image, color_convertion): # noqa: N802 - if color_convertion in [COLOR_RGB2BGR, COLOR_BGR2RGB]: - return color_image[:, :, [2, 1, 0]] - else: - raise NotImplementedError(color_convertion) - - -def rotate(color_image, rotation): - if rotation is None: - return color_image - elif rotation == ROTATE_90_CLOCKWISE: - return np.rot90(color_image, k=1) - elif rotation == ROTATE_180: - return np.rot90(color_image, k=2) - elif rotation == ROTATE_90_COUNTERCLOCKWISE: - return np.rot90(color_image, k=3) - else: - raise NotImplementedError(rotation) - - -class VideoCapture: - def __init__(self, *args, **kwargs): - self._mock_dict = { - CAP_PROP_FPS: 30, - CAP_PROP_FRAME_WIDTH: 640, - CAP_PROP_FRAME_HEIGHT: 480, - } - self._is_opened = True - - def isOpened(self): # noqa: N802 - return self._is_opened - - def set(self, propId: int, value: float) -> bool: # noqa: N803 - if not self._is_opened: - raise RuntimeError("Camera is not opened") - self._mock_dict[propId] = value - return True - - def get(self, propId: int) -> float: # noqa: N803 - if not self._is_opened: - raise RuntimeError("Camera is not opened") - value = self._mock_dict[propId] - if value == 0: - if propId == CAP_PROP_FRAME_HEIGHT: - value = 480 - elif propId == CAP_PROP_FRAME_WIDTH: - value = 640 - return value - - def read(self): - if not self._is_opened: - raise RuntimeError("Camera is not opened") - h = self.get(CAP_PROP_FRAME_HEIGHT) - w = self.get(CAP_PROP_FRAME_WIDTH) - ret = True - return ret, _generate_image(width=w, height=h) - - def release(self): - self._is_opened = False - - def __del__(self): - if self._is_opened: - self.release() diff --git a/external/lerobot/tests/mock_dynamixel_sdk.py b/external/lerobot/tests/mock_dynamixel_sdk.py deleted file mode 100644 index a790dff05b..0000000000 --- a/external/lerobot/tests/mock_dynamixel_sdk.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Mocked classes and functions from dynamixel_sdk to allow for continuous integration -and testing code logic that requires hardware and devices (e.g. robot arms, cameras) - -Warning: These mocked versions are minimalist. They do not exactly mock every behaviors -from the original classes and functions (e.g. return types might be None instead of boolean). -""" - -# from dynamixel_sdk import COMM_SUCCESS - -DEFAULT_BAUDRATE = 9_600 -COMM_SUCCESS = 0 # tx or rx packet communication success - - -def convert_to_bytes(value, bytes): - # TODO(rcadene): remove need to mock `convert_to_bytes` by implemented the inverse transform - # `convert_bytes_to_value` - del bytes # unused - return value - - -def get_default_motor_values(motor_index): - return { - # Key (int) are from X_SERIES_CONTROL_TABLE - 7: motor_index, # ID - 8: DEFAULT_BAUDRATE, # Baud_rate - 10: 0, # Drive_Mode - 64: 0, # Torque_Enable - # Set 2560 since calibration values for Aloha gripper is between start_pos=2499 and end_pos=3144 - # For other joints, 2560 will be autocorrected to be in calibration range - 132: 2560, # Present_Position - } - - -class PortHandler: - def __init__(self, port): - self.port = port - # factory default baudrate - self.baudrate = DEFAULT_BAUDRATE - - def openPort(self): # noqa: N802 - return True - - def closePort(self): # noqa: N802 - pass - - def setPacketTimeoutMillis(self, timeout_ms): # noqa: N802 - del timeout_ms # unused - - def getBaudRate(self): # noqa: N802 - return self.baudrate - - def setBaudRate(self, baudrate): # noqa: N802 - self.baudrate = baudrate - - -class PacketHandler: - def __init__(self, protocol_version): - del protocol_version # unused - # Use packet_handler.data to communicate across Read and Write - self.data = {} - - -class GroupSyncRead: - def __init__(self, port_handler, packet_handler, address, bytes): - self.packet_handler = packet_handler - - def addParam(self, motor_index): # noqa: N802 - # Initialize motor default values - if motor_index not in self.packet_handler.data: - self.packet_handler.data[motor_index] = get_default_motor_values(motor_index) - - def txRxPacket(self): # noqa: N802 - return COMM_SUCCESS - - def getData(self, index, address, bytes): # noqa: N802 - return self.packet_handler.data[index][address] - - -class GroupSyncWrite: - def __init__(self, port_handler, packet_handler, address, bytes): - self.packet_handler = packet_handler - self.address = address - - def addParam(self, index, data): # noqa: N802 - # Initialize motor default values - if index not in self.packet_handler.data: - self.packet_handler.data[index] = get_default_motor_values(index) - self.changeParam(index, data) - - def txPacket(self): # noqa: N802 - return COMM_SUCCESS - - def changeParam(self, index, data): # noqa: N802 - self.packet_handler.data[index][self.address] = data diff --git a/external/lerobot/tests/mock_pyrealsense2.py b/external/lerobot/tests/mock_pyrealsense2.py deleted file mode 100644 index 5a39fc2bfb..0000000000 --- a/external/lerobot/tests/mock_pyrealsense2.py +++ /dev/null @@ -1,135 +0,0 @@ -import enum - -import numpy as np - - -class stream(enum.Enum): # noqa: N801 - color = 0 - depth = 1 - - -class format(enum.Enum): # noqa: N801 - rgb8 = 0 - z16 = 1 - - -class config: # noqa: N801 - def enable_device(self, device_id: str): - self.device_enabled = device_id - - def enable_stream(self, stream_type: stream, width=None, height=None, color_format=None, fps=None): - self.stream_type = stream_type - # Overwrite default values when possible - self.width = 848 if width is None else width - self.height = 480 if height is None else height - self.color_format = format.rgb8 if color_format is None else color_format - self.fps = 30 if fps is None else fps - - -class RSColorProfile: - def __init__(self, config): - self.config = config - - def fps(self): - return self.config.fps - - def width(self): - return self.config.width - - def height(self): - return self.config.height - - -class RSColorStream: - def __init__(self, config): - self.config = config - - def as_video_stream_profile(self): - return RSColorProfile(self.config) - - -class RSProfile: - def __init__(self, config): - self.config = config - - def get_stream(self, color_format): - del color_format # unused - return RSColorStream(self.config) - - -class pipeline: # noqa: N801 - def __init__(self): - self.started = False - self.config = None - - def start(self, config): - self.started = True - self.config = config - return RSProfile(self.config) - - def stop(self): - if not self.started: - raise RuntimeError("You need to start the camera before stop.") - self.started = False - self.config = None - - def wait_for_frames(self, timeout_ms=50000): - del timeout_ms # unused - return RSFrames(self.config) - - -class RSFrames: - def __init__(self, config): - self.config = config - - def get_color_frame(self): - return RSColorFrame(self.config) - - def get_depth_frame(self): - return RSDepthFrame(self.config) - - -class RSColorFrame: - def __init__(self, config): - self.config = config - - def get_data(self): - data = np.ones((self.config.height, self.config.width, 3), dtype=np.uint8) - # Create a difference between rgb and bgr - data[:, :, 0] = 2 - return data - - -class RSDepthFrame: - def __init__(self, config): - self.config = config - - def get_data(self): - return np.ones((self.config.height, self.config.width), dtype=np.uint16) - - -class RSDevice: - def __init__(self): - pass - - def get_info(self, camera_info) -> str: - del camera_info # unused - # return fake serial number - return "123456789" - - -class context: # noqa: N801 - def __init__(self): - pass - - def query_devices(self): - return [RSDevice()] - - -class camera_info: # noqa: N801 - # fake name - name = "Intel RealSense D435I" - - def __init__(self, serial_number): - del serial_number - pass diff --git a/external/lerobot/tests/mock_scservo_sdk.py b/external/lerobot/tests/mock_scservo_sdk.py deleted file mode 100644 index 596978c006..0000000000 --- a/external/lerobot/tests/mock_scservo_sdk.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Mocked classes and functions from dynamixel_sdk to allow for continuous integration -and testing code logic that requires hardware and devices (e.g. robot arms, cameras) - -Warning: These mocked versions are minimalist. They do not exactly mock every behaviors -from the original classes and functions (e.g. return types might be None instead of boolean). -""" - -# from dynamixel_sdk import COMM_SUCCESS - -DEFAULT_BAUDRATE = 1_000_000 -COMM_SUCCESS = 0 # tx or rx packet communication success - - -def convert_to_bytes(value, bytes): - # TODO(rcadene): remove need to mock `convert_to_bytes` by implemented the inverse transform - # `convert_bytes_to_value` - del bytes # unused - return value - - -def get_default_motor_values(motor_index): - return { - # Key (int) are from SCS_SERIES_CONTROL_TABLE - 5: motor_index, # ID - 6: DEFAULT_BAUDRATE, # Baud_rate - 10: 0, # Drive_Mode - 21: 32, # P_Coefficient - 22: 32, # D_Coefficient - 23: 0, # I_Coefficient - 40: 0, # Torque_Enable - 41: 254, # Acceleration - 31: -2047, # Offset - 33: 0, # Mode - 55: 1, # Lock - # Set 2560 since calibration values for Aloha gripper is between start_pos=2499 and end_pos=3144 - # For other joints, 2560 will be autocorrected to be in calibration range - 56: 2560, # Present_Position - 58: 0, # Present_Speed - 69: 0, # Present_Current - 85: 150, # Maximum_Acceleration - } - - -class PortHandler: - def __init__(self, port): - self.port = port - # factory default baudrate - self.baudrate = DEFAULT_BAUDRATE - - def openPort(self): # noqa: N802 - return True - - def closePort(self): # noqa: N802 - pass - - def setPacketTimeoutMillis(self, timeout_ms): # noqa: N802 - del timeout_ms # unused - - def getBaudRate(self): # noqa: N802 - return self.baudrate - - def setBaudRate(self, baudrate): # noqa: N802 - self.baudrate = baudrate - - -class PacketHandler: - def __init__(self, protocol_version): - del protocol_version # unused - # Use packet_handler.data to communicate across Read and Write - self.data = {} - - -class GroupSyncRead: - def __init__(self, port_handler, packet_handler, address, bytes): - self.packet_handler = packet_handler - - def addParam(self, motor_index): # noqa: N802 - # Initialize motor default values - if motor_index not in self.packet_handler.data: - self.packet_handler.data[motor_index] = get_default_motor_values(motor_index) - - def txRxPacket(self): # noqa: N802 - return COMM_SUCCESS - - def getData(self, index, address, bytes): # noqa: N802 - return self.packet_handler.data[index][address] - - -class GroupSyncWrite: - def __init__(self, port_handler, packet_handler, address, bytes): - self.packet_handler = packet_handler - self.address = address - - def addParam(self, index, data): # noqa: N802 - if index not in self.packet_handler.data: - self.packet_handler.data[index] = get_default_motor_values(index) - self.changeParam(index, data) - - def txPacket(self): # noqa: N802 - return COMM_SUCCESS - - def changeParam(self, index, data): # noqa: N802 - self.packet_handler.data[index][self.address] = data diff --git a/external/lerobot/tests/scripts/save_dataset_to_safetensors.py b/external/lerobot/tests/scripts/save_dataset_to_safetensors.py deleted file mode 100644 index 4331f05024..0000000000 --- a/external/lerobot/tests/scripts/save_dataset_to_safetensors.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -This script provides a utility for saving a dataset as safetensors files for the purpose of testing backward compatibility -when updating the data format. It uses the `PushtDataset` to create a DataLoader and saves selected frame from the -dataset into a corresponding safetensors file in a specified output directory. - -If you know that your change will break backward compatibility, you should write a shortlived test by modifying -`tests/test_datasets.py::test_backward_compatibility` accordingly, and make sure this custom test pass. Your custom test -doesnt need to be merged into the `main` branch. Then you need to run this script and update the tests artifacts. - -Example usage: - `python tests/scripts/save_dataset_to_safetensors.py` -""" - -import shutil -from pathlib import Path - -from safetensors.torch import save_file - -from lerobot.common.datasets.lerobot_dataset import LeRobotDataset - - -def save_dataset_to_safetensors(output_dir, repo_id="lerobot/pusht"): - repo_dir = Path(output_dir) / repo_id - - if repo_dir.exists(): - shutil.rmtree(repo_dir) - - repo_dir.mkdir(parents=True, exist_ok=True) - dataset = LeRobotDataset( - repo_id=repo_id, - ) - - # save 2 first frames of first episode - i = dataset.episode_data_index["from"][0].item() - save_file(dataset[i], repo_dir / f"frame_{i}.safetensors") - save_file(dataset[i + 1], repo_dir / f"frame_{i + 1}.safetensors") - - # save 2 frames at the middle of first episode - i = int((dataset.episode_data_index["to"][0].item() - dataset.episode_data_index["from"][0].item()) / 2) - save_file(dataset[i], repo_dir / f"frame_{i}.safetensors") - save_file(dataset[i + 1], repo_dir / f"frame_{i + 1}.safetensors") - - # save 2 last frames of first episode - i = dataset.episode_data_index["to"][0].item() - save_file(dataset[i - 2], repo_dir / f"frame_{i - 2}.safetensors") - save_file(dataset[i - 1], repo_dir / f"frame_{i - 1}.safetensors") - - # TODO(rcadene): Enable testing on second and last episode - # We currently cant because our test dataset only contains the first episode - - # # save 2 first frames of second episode - # i = dataset.episode_data_index["from"][1].item() - # save_file(dataset[i], repo_dir / f"frame_{i}.safetensors") - # save_file(dataset[i + 1], repo_dir / f"frame_{i+1}.safetensors") - - # # save 2 last frames of second episode - # i = dataset.episode_data_index["to"][1].item() - # save_file(dataset[i - 2], repo_dir / f"frame_{i-2}.safetensors") - # save_file(dataset[i - 1], repo_dir / f"frame_{i-1}.safetensors") - - # # save 2 last frames of last episode - # i = dataset.episode_data_index["to"][-1].item() - # save_file(dataset[i - 2], repo_dir / f"frame_{i-2}.safetensors") - # save_file(dataset[i - 1], repo_dir / f"frame_{i-1}.safetensors") - - -if __name__ == "__main__": - for dataset in [ - "lerobot/pusht", - "lerobot/aloha_sim_insertion_human", - "lerobot/xarm_lift_medium", - "lerobot/nyu_franka_play_dataset", - "lerobot/cmu_stretch", - ]: - save_dataset_to_safetensors("tests/data/save_dataset_to_safetensors", repo_id=dataset) diff --git a/external/lerobot/tests/scripts/save_image_transforms_to_safetensors.py b/external/lerobot/tests/scripts/save_image_transforms_to_safetensors.py deleted file mode 100644 index 1fa194e50a..0000000000 --- a/external/lerobot/tests/scripts/save_image_transforms_to_safetensors.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from pathlib import Path - -import torch -from safetensors.torch import save_file - -from lerobot.common.datasets.lerobot_dataset import LeRobotDataset -from lerobot.common.datasets.transforms import get_image_transforms -from lerobot.common.utils.utils import init_hydra_config, seeded_context -from tests.test_image_transforms import ARTIFACT_DIR, DATASET_REPO_ID -from tests.utils import DEFAULT_CONFIG_PATH - - -def save_default_config_transform(original_frame: torch.Tensor, output_dir: Path): - cfg = init_hydra_config(DEFAULT_CONFIG_PATH) - cfg_tf = cfg.training.image_transforms - default_tf = get_image_transforms( - brightness_weight=cfg_tf.brightness.weight, - brightness_min_max=cfg_tf.brightness.min_max, - contrast_weight=cfg_tf.contrast.weight, - contrast_min_max=cfg_tf.contrast.min_max, - saturation_weight=cfg_tf.saturation.weight, - saturation_min_max=cfg_tf.saturation.min_max, - hue_weight=cfg_tf.hue.weight, - hue_min_max=cfg_tf.hue.min_max, - sharpness_weight=cfg_tf.sharpness.weight, - sharpness_min_max=cfg_tf.sharpness.min_max, - max_num_transforms=cfg_tf.max_num_transforms, - random_order=cfg_tf.random_order, - ) - - with seeded_context(1337): - img_tf = default_tf(original_frame) - - save_file({"default": img_tf}, output_dir / "default_transforms.safetensors") - - -def save_single_transforms(original_frame: torch.Tensor, output_dir: Path): - transforms = { - "brightness": [(0.5, 0.5), (2.0, 2.0)], - "contrast": [(0.5, 0.5), (2.0, 2.0)], - "saturation": [(0.5, 0.5), (2.0, 2.0)], - "hue": [(-0.25, -0.25), (0.25, 0.25)], - "sharpness": [(0.5, 0.5), (2.0, 2.0)], - } - - frames = {"original_frame": original_frame} - for transform, values in transforms.items(): - for min_max in values: - kwargs = { - f"{transform}_weight": 1.0, - f"{transform}_min_max": min_max, - } - tf = get_image_transforms(**kwargs) - key = f"{transform}_{min_max[0]}_{min_max[1]}" - frames[key] = tf(original_frame) - - save_file(frames, output_dir / "single_transforms.safetensors") - - -def main(): - dataset = LeRobotDataset(DATASET_REPO_ID, image_transforms=None) - output_dir = Path(ARTIFACT_DIR) - output_dir.mkdir(parents=True, exist_ok=True) - original_frame = dataset[0][dataset.meta.camera_keys[0]] - - save_single_transforms(original_frame, output_dir) - save_default_config_transform(original_frame, output_dir) - - -if __name__ == "__main__": - main() diff --git a/external/lerobot/tests/scripts/save_policy_to_safetensors.py b/external/lerobot/tests/scripts/save_policy_to_safetensors.py deleted file mode 100644 index 29d0ae19de..0000000000 --- a/external/lerobot/tests/scripts/save_policy_to_safetensors.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import shutil -from pathlib import Path - -import torch -from safetensors.torch import save_file - -from lerobot.common.datasets.factory import make_dataset -from lerobot.common.policies.factory import make_policy -from lerobot.common.utils.utils import init_hydra_config, set_global_seed -from lerobot.scripts.train import make_optimizer_and_scheduler -from tests.utils import DEFAULT_CONFIG_PATH - - -def get_policy_stats(env_name, policy_name, extra_overrides): - cfg = init_hydra_config( - DEFAULT_CONFIG_PATH, - overrides=[ - f"env={env_name}", - f"policy={policy_name}", - "device=cpu", - ] - + extra_overrides, - ) - set_global_seed(1337) - dataset = make_dataset(cfg) - policy = make_policy(cfg, dataset_stats=dataset.meta.stats) - policy.train() - optimizer, _ = make_optimizer_and_scheduler(cfg, policy) - - dataloader = torch.utils.data.DataLoader( - dataset, - num_workers=0, - batch_size=cfg.training.batch_size, - shuffle=False, - ) - - batch = next(iter(dataloader)) - output_dict = policy.forward(batch) - output_dict = {k: v for k, v in output_dict.items() if isinstance(v, torch.Tensor)} - loss = output_dict["loss"] - - loss.backward() - grad_stats = {} - for key, param in policy.named_parameters(): - if param.requires_grad: - grad_stats[f"{key}_mean"] = param.grad.mean() - grad_stats[f"{key}_std"] = ( - param.grad.std() if param.grad.numel() > 1 else torch.tensor(float(0.0)) - ) - - optimizer.step() - param_stats = {} - for key, param in policy.named_parameters(): - param_stats[f"{key}_mean"] = param.mean() - param_stats[f"{key}_std"] = param.std() if param.numel() > 1 else torch.tensor(float(0.0)) - - optimizer.zero_grad() - policy.reset() - - # HACK: We reload a batch with no delta_timestamps as `select_action` won't expect a timestamps dimension - dataset.delta_timestamps = None - batch = next(iter(dataloader)) - obs = {} - for k in batch: - if k.startswith("observation"): - obs[k] = batch[k] - - if "n_action_steps" in cfg.policy: - actions_queue = cfg.policy.n_action_steps - else: - actions_queue = cfg.policy.n_action_repeats - - actions = {str(i): policy.select_action(obs).contiguous() for i in range(actions_queue)} - return output_dict, grad_stats, param_stats, actions - - -def save_policy_to_safetensors(output_dir, env_name, policy_name, extra_overrides, file_name_extra): - env_policy_dir = Path(output_dir) / f"{env_name}_{policy_name}{file_name_extra}" - - if env_policy_dir.exists(): - print(f"Overwrite existing safetensors in '{env_policy_dir}':") - print(f" - Validate with: `git add {env_policy_dir}`") - print(f" - Revert with: `git checkout -- {env_policy_dir}`") - shutil.rmtree(env_policy_dir) - - env_policy_dir.mkdir(parents=True, exist_ok=True) - output_dict, grad_stats, param_stats, actions = get_policy_stats(env_name, policy_name, extra_overrides) - save_file(output_dict, env_policy_dir / "output_dict.safetensors") - save_file(grad_stats, env_policy_dir / "grad_stats.safetensors") - save_file(param_stats, env_policy_dir / "param_stats.safetensors") - save_file(actions, env_policy_dir / "actions.safetensors") - - -if __name__ == "__main__": - env_policies = [ - # ("xarm", "tdmpc", ["policy.use_mpc=false"], "use_policy"), - # ("xarm", "tdmpc", ["policy.use_mpc=true"], "use_mpc"), - # ( - # "pusht", - # "diffusion", - # [ - # "policy.n_action_steps=8", - # "policy.num_inference_steps=10", - # "policy.down_dims=[128, 256, 512]", - # ], - # "", - # ), - # ("aloha", "act", ["policy.n_action_steps=10"], ""), - # ("aloha", "act", ["policy.n_action_steps=1000", "policy.chunk_size=1000"], "_1000_steps"), - # ("dora_aloha_real", "act_real", ["policy.n_action_steps=10"], ""), - # ("dora_aloha_real", "act_real_no_state", ["policy.n_action_steps=10"], ""), - ] - if len(env_policies) == 0: - raise RuntimeError("No policies were provided!") - for env, policy, extra_overrides, file_name_extra in env_policies: - save_policy_to_safetensors( - "tests/data/save_policy_to_safetensors", env, policy, extra_overrides, file_name_extra - ) diff --git a/external/lerobot/tests/test_available.py b/external/lerobot/tests/test_available.py deleted file mode 100644 index f4f9d4de69..0000000000 --- a/external/lerobot/tests/test_available.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import importlib - -import gymnasium as gym -import pytest - -import lerobot -from lerobot.common.policies.act.modeling_act import ACTPolicy -from lerobot.common.policies.diffusion.modeling_diffusion import DiffusionPolicy -from lerobot.common.policies.tdmpc.modeling_tdmpc import TDMPCPolicy -from lerobot.common.policies.vqbet.modeling_vqbet import VQBeTPolicy -from tests.utils import require_env - - -@pytest.mark.parametrize("env_name, task_name", lerobot.env_task_pairs) -@require_env -def test_available_env_task(env_name: str, task_name: list): - """ - This test verifies that all environments listed in `lerobot/__init__.py` can - be successfully imported — if they're installed — and that their - `available_tasks_per_env` are valid. - """ - package_name = f"gym_{env_name}" - importlib.import_module(package_name) - gym_handle = f"{package_name}/{task_name}" - assert gym_handle in gym.envs.registry, gym_handle - - -def test_available_policies(): - """ - This test verifies that the class attribute `name` for all policies is - consistent with those listed in `lerobot/__init__.py`. - """ - policy_classes = [ - ACTPolicy, - DiffusionPolicy, - TDMPCPolicy, - VQBeTPolicy, - ] - policies = [pol_cls.name for pol_cls in policy_classes] - assert set(policies) == set(lerobot.available_policies), policies - - -def test_print(): - print(lerobot.available_envs) - print(lerobot.available_tasks_per_env) - print(lerobot.available_datasets) - print(lerobot.available_datasets_per_env) - print(lerobot.available_real_world_datasets) - print(lerobot.available_policies) - print(lerobot.available_policies_per_env) diff --git a/external/lerobot/tests/test_cameras.py b/external/lerobot/tests/test_cameras.py deleted file mode 100644 index 67512779ad..0000000000 --- a/external/lerobot/tests/test_cameras.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -Tests for physical cameras and their mocked versions. -If the physical camera is not connected to the computer, or not working, -the test will be skipped. - -Example of running a specific test: -```bash -pytest -sx tests/test_cameras.py::test_camera -``` - -Example of running test on a real camera connected to the computer: -```bash -pytest -sx 'tests/test_cameras.py::test_camera[opencv-False]' -pytest -sx 'tests/test_cameras.py::test_camera[intelrealsense-False]' -``` - -Example of running test on a mocked version of the camera: -```bash -pytest -sx 'tests/test_cameras.py::test_camera[opencv-True]' -pytest -sx 'tests/test_cameras.py::test_camera[intelrealsense-True]' -``` -""" - -import numpy as np -import pytest - -from lerobot.common.robot_devices.utils import RobotDeviceAlreadyConnectedError, RobotDeviceNotConnectedError -from tests.utils import TEST_CAMERA_TYPES, make_camera, require_camera - -# Maximum absolute difference between two consecutive images recored by a camera. -# This value differs with respect to the camera. -MAX_PIXEL_DIFFERENCE = 25 - - -def compute_max_pixel_difference(first_image, second_image): - return np.abs(first_image.astype(float) - second_image.astype(float)).max() - - -@pytest.mark.parametrize("camera_type, mock", TEST_CAMERA_TYPES) -@require_camera -def test_camera(request, camera_type, mock): - """Test assumes that `camera.read()` returns the same image when called multiple times in a row. - So the environment should not change (you shouldnt be in front of the camera) and the camera should not be moving. - - Warning: The tests worked for a macbookpro camera, but I am getting assertion error (`np.allclose(color_image, async_color_image)`) - for my iphone camera and my LG monitor camera. - """ - # TODO(rcadene): measure fps in nightly? - # TODO(rcadene): test logs - - if camera_type == "opencv" and not mock: - pytest.skip("TODO(rcadene): fix test for opencv physical camera") - - # Test instantiating - camera = make_camera(camera_type, mock=mock) - - # Test reading, async reading, disconnecting before connecting raises an error - with pytest.raises(RobotDeviceNotConnectedError): - camera.read() - with pytest.raises(RobotDeviceNotConnectedError): - camera.async_read() - with pytest.raises(RobotDeviceNotConnectedError): - camera.disconnect() - - # Test deleting the object without connecting first - del camera - - # Test connecting - camera = make_camera(camera_type, mock=mock) - camera.connect() - assert camera.is_connected - assert camera.fps is not None - assert camera.width is not None - assert camera.height is not None - - # Test connecting twice raises an error - with pytest.raises(RobotDeviceAlreadyConnectedError): - camera.connect() - - # Test reading from the camera - color_image = camera.read() - assert isinstance(color_image, np.ndarray) - assert color_image.ndim == 3 - h, w, c = color_image.shape - assert c == 3 - assert w > h - - # Test read and async_read outputs similar images - # ...warming up as the first frames can be black - for _ in range(30): - camera.read() - color_image = camera.read() - async_color_image = camera.async_read() - error_msg = ( - "max_pixel_difference between read() and async_read()", - compute_max_pixel_difference(color_image, async_color_image), - ) - # TODO(rcadene): properly set `rtol` - np.testing.assert_allclose( - color_image, async_color_image, rtol=1e-5, atol=MAX_PIXEL_DIFFERENCE, err_msg=error_msg - ) - - # Test disconnecting - camera.disconnect() - assert camera.camera is None - assert camera.thread is None - - # Test disconnecting with `__del__` - camera = make_camera(camera_type, mock=mock) - camera.connect() - del camera - - # Test acquiring a bgr image - camera = make_camera(camera_type, color_mode="bgr", mock=mock) - camera.connect() - assert camera.color_mode == "bgr" - bgr_color_image = camera.read() - np.testing.assert_allclose( - color_image, bgr_color_image[:, :, [2, 1, 0]], rtol=1e-5, atol=MAX_PIXEL_DIFFERENCE, err_msg=error_msg - ) - del camera - - # Test acquiring a rotated image - camera = make_camera(camera_type, mock=mock) - camera.connect() - ori_color_image = camera.read() - del camera - - for rotation in [None, 90, 180, -90]: - camera = make_camera(camera_type, rotation=rotation, mock=mock) - camera.connect() - - if mock: - import tests.mock_cv2 as cv2 - else: - import cv2 - - if rotation is None: - manual_rot_img = ori_color_image - assert camera.rotation is None - elif rotation == 90: - manual_rot_img = np.rot90(color_image, k=1) - assert camera.rotation == cv2.ROTATE_90_CLOCKWISE - elif rotation == 180: - manual_rot_img = np.rot90(color_image, k=2) - assert camera.rotation == cv2.ROTATE_180 - elif rotation == -90: - manual_rot_img = np.rot90(color_image, k=3) - assert camera.rotation == cv2.ROTATE_90_COUNTERCLOCKWISE - - rot_color_image = camera.read() - - np.testing.assert_allclose( - rot_color_image, manual_rot_img, rtol=1e-5, atol=MAX_PIXEL_DIFFERENCE, err_msg=error_msg - ) - del camera - - # TODO(rcadene): Add a test for a camera that doesnt support fps=60 and raises an OSError - # TODO(rcadene): Add a test for a camera that supports fps=60 - - # Test width and height can be set - camera = make_camera(camera_type, fps=30, width=1280, height=720, mock=mock) - camera.connect() - assert camera.fps == 30 - assert camera.width == 1280 - assert camera.height == 720 - color_image = camera.read() - h, w, c = color_image.shape - assert h == 720 - assert w == 1280 - assert c == 3 - del camera - - # Test not supported width and height raise an error - camera = make_camera(camera_type, fps=30, width=0, height=0, mock=mock) - with pytest.raises(OSError): - camera.connect() - del camera - - -@pytest.mark.parametrize("camera_type, mock", TEST_CAMERA_TYPES) -@require_camera -def test_save_images_from_cameras(tmpdir, request, camera_type, mock): - # TODO(rcadene): refactor - if camera_type == "opencv": - from lerobot.common.robot_devices.cameras.opencv import save_images_from_cameras - elif camera_type == "intelrealsense": - from lerobot.common.robot_devices.cameras.intelrealsense import save_images_from_cameras - - # Small `record_time_s` to speedup unit tests - save_images_from_cameras(tmpdir, record_time_s=0.02, mock=mock) diff --git a/external/lerobot/tests/test_control_robot.py b/external/lerobot/tests/test_control_robot.py deleted file mode 100644 index 8df1089463..0000000000 --- a/external/lerobot/tests/test_control_robot.py +++ /dev/null @@ -1,448 +0,0 @@ -""" -Tests for physical robots and their mocked versions. -If the physical robots are not connected to the computer, or not working, -the test will be skipped. - -Example of running a specific test: -```bash -pytest -sx tests/test_control_robot.py::test_teleoperate -``` - -Example of running test on real robots connected to the computer: -```bash -pytest -sx 'tests/test_control_robot.py::test_teleoperate[koch-False]' -pytest -sx 'tests/test_control_robot.py::test_teleoperate[koch_bimanual-False]' -pytest -sx 'tests/test_control_robot.py::test_teleoperate[aloha-False]' -``` - -Example of running test on a mocked version of robots: -```bash -pytest -sx 'tests/test_control_robot.py::test_teleoperate[koch-True]' -pytest -sx 'tests/test_control_robot.py::test_teleoperate[koch_bimanual-True]' -pytest -sx 'tests/test_control_robot.py::test_teleoperate[aloha-True]' -``` -""" - -import multiprocessing -from pathlib import Path -from unittest.mock import patch - -import pytest - -from lerobot.common.logger import Logger -from lerobot.common.policies.factory import make_policy -from lerobot.common.utils.utils import init_hydra_config -from lerobot.scripts.control_robot import calibrate, record, replay, teleoperate -from lerobot.scripts.train import make_optimizer_and_scheduler -from tests.test_robots import make_robot -from tests.utils import DEFAULT_CONFIG_PATH, DEVICE, TEST_ROBOT_TYPES, mock_calibration_dir, require_robot - - -@pytest.mark.parametrize("robot_type, mock", TEST_ROBOT_TYPES) -@require_robot -def test_teleoperate(tmpdir, request, robot_type, mock): - if mock and robot_type != "aloha": - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - # and avoid writing calibration files in user .cache/calibration folder - tmpdir = Path(tmpdir) - calibration_dir = tmpdir / robot_type - mock_calibration_dir(calibration_dir) - overrides = [f"calibration_dir={calibration_dir}"] - else: - # Use the default .cache/calibration folder when mock=False - overrides = None - - robot = make_robot(robot_type, overrides=overrides, mock=mock) - teleoperate(robot, teleop_time_s=1) - teleoperate(robot, fps=30, teleop_time_s=1) - teleoperate(robot, fps=60, teleop_time_s=1) - del robot - - -@pytest.mark.parametrize("robot_type, mock", TEST_ROBOT_TYPES) -@require_robot -def test_calibrate(tmpdir, request, robot_type, mock): - if mock: - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - tmpdir = Path(tmpdir) - calibration_dir = tmpdir / robot_type - overrides_calibration_dir = [f"calibration_dir={calibration_dir}"] - - robot = make_robot(robot_type, overrides=overrides_calibration_dir, mock=mock) - calibrate(robot, arms=robot.available_arms) - del robot - - -@pytest.mark.parametrize("robot_type, mock", TEST_ROBOT_TYPES) -@require_robot -def test_record_without_cameras(tmpdir, request, robot_type, mock): - # Avoid using cameras - overrides = ["~cameras"] - - if mock and robot_type != "aloha": - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - # and avoid writing calibration files in user .cache/calibration folder - calibration_dir = Path(tmpdir) / robot_type - mock_calibration_dir(calibration_dir) - overrides.append(f"calibration_dir={calibration_dir}") - - repo_id = "lerobot/debug" - root = Path(tmpdir) / "data" / repo_id - single_task = "Do something." - - robot = make_robot(robot_type, overrides=overrides, mock=mock) - record( - robot, - fps=30, - root=root, - repo_id=repo_id, - single_task=single_task, - warmup_time_s=1, - episode_time_s=1, - num_episodes=2, - run_compute_stats=False, - push_to_hub=False, - video=False, - play_sounds=False, - ) - - -@pytest.mark.parametrize("robot_type, mock", TEST_ROBOT_TYPES) -@require_robot -def test_record_and_replay_and_policy(tmpdir, request, robot_type, mock): - tmpdir = Path(tmpdir) - - if mock and robot_type != "aloha": - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - # and avoid writing calibration files in user .cache/calibration folder - calibration_dir = tmpdir / robot_type - mock_calibration_dir(calibration_dir) - overrides = [f"calibration_dir={calibration_dir}"] - else: - # Use the default .cache/calibration folder when mock=False or for aloha - overrides = None - - env_name = "koch_real" - policy_name = "act_koch_real" - - repo_id = "lerobot/debug" - root = tmpdir / "data" / repo_id - single_task = "Do something." - - robot = make_robot(robot_type, overrides=overrides, mock=mock) - dataset = record( - robot, - root, - repo_id, - single_task, - fps=1, - warmup_time_s=0.5, - episode_time_s=1, - reset_time_s=1, - num_episodes=2, - push_to_hub=False, - # TODO(rcadene, aliberts): test video=True - video=False, - # TODO(rcadene): display cameras through cv2 sometimes crashes on mac - display_cameras=False, - play_sounds=False, - ) - assert dataset.meta.total_episodes == 2 - assert len(dataset) == 2 - - replay(robot, episode=0, fps=1, root=root, repo_id=repo_id, play_sounds=False, local_files_only=True) - - # TODO(rcadene, aliberts): rethink this design - if robot_type == "aloha": - env_name = "aloha_real" - policy_name = "act_aloha_real" - elif robot_type in ["koch", "koch_bimanual"]: - env_name = "koch_real" - policy_name = "act_koch_real" - elif robot_type == "so100": - env_name = "so100_real" - policy_name = "act_so100_real" - elif robot_type == "moss": - env_name = "moss_real" - policy_name = "act_moss_real" - else: - raise NotImplementedError(robot_type) - - overrides = [ - f"env={env_name}", - f"policy={policy_name}", - f"device={DEVICE}", - ] - - if robot_type == "koch_bimanual": - overrides += ["env.state_dim=12", "env.action_dim=12"] - - overrides += ["wandb.enable=false"] - overrides += ["env.fps=1"] - - cfg = init_hydra_config( - DEFAULT_CONFIG_PATH, - overrides=overrides, - ) - - policy = make_policy(hydra_cfg=cfg, dataset_stats=dataset.meta.stats) - optimizer, lr_scheduler = make_optimizer_and_scheduler(cfg, policy) - out_dir = tmpdir / "logger" - logger = Logger(cfg, out_dir, wandb_job_name="debug") - logger.save_checkpoint( - 0, - policy, - optimizer, - lr_scheduler, - identifier=0, - ) - pretrained_policy_name_or_path = out_dir / "checkpoints/last/pretrained_model" - - # In `examples/9_use_aloha.md`, we advise using `num_image_writer_processes=1` - # during inference, to reach constent fps, so we test this here. - if robot_type == "aloha": - num_image_writer_processes = 1 - - # `multiprocessing.set_start_method("spawn", force=True)` avoids a hanging issue - # before exiting pytest. However, it outputs the following error in the log: - # Traceback (most recent call last): - # File "", line 1, in - # File "/Users/rcadene/miniconda3/envs/lerobot/lib/python3.10/multiprocessing/spawn.py", line 116, in spawn_main - # exitcode = _main(fd, parent_sentinel) - # File "/Users/rcadene/miniconda3/envs/lerobot/lib/python3.10/multiprocessing/spawn.py", line 126, in _main - # self = reduction.pickle.load(from_parent) - # File "/Users/rcadene/miniconda3/envs/lerobot/lib/python3.10/multiprocessing/synchronize.py", line 110, in __setstate__ - # self._semlock = _multiprocessing.SemLock._rebuild(*state) - # FileNotFoundError: [Errno 2] No such file or directory - # TODO(rcadene, aliberts): fix FileNotFoundError in multiprocessing - multiprocessing.set_start_method("spawn", force=True) - else: - num_image_writer_processes = 0 - - eval_repo_id = "lerobot/eval_debug" - eval_root = tmpdir / "data" / eval_repo_id - - dataset = record( - robot, - eval_root, - eval_repo_id, - single_task, - pretrained_policy_name_or_path, - warmup_time_s=1, - episode_time_s=1, - reset_time_s=1, - num_episodes=2, - run_compute_stats=False, - push_to_hub=False, - video=False, - display_cameras=False, - play_sounds=False, - num_image_writer_processes=num_image_writer_processes, - ) - - assert dataset.num_episodes == 2 - assert len(dataset) == 2 - - del robot - - -@pytest.mark.parametrize("robot_type, mock", [("koch", True)]) -@require_robot -def test_resume_record(tmpdir, request, robot_type, mock): - if mock and robot_type != "aloha": - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - # and avoid writing calibration files in user .cache/calibration folder - calibration_dir = tmpdir / robot_type - mock_calibration_dir(calibration_dir) - overrides = [f"calibration_dir={calibration_dir}"] - else: - # Use the default .cache/calibration folder when mock=False or for aloha - overrides = [] - - robot = make_robot(robot_type, overrides=overrides, mock=mock) - - repo_id = "lerobot/debug" - root = Path(tmpdir) / "data" / repo_id - single_task = "Do something." - - record_kwargs = { - "robot": robot, - "root": root, - "repo_id": repo_id, - "single_task": single_task, - "fps": 1, - "warmup_time_s": 0, - "episode_time_s": 1, - "push_to_hub": False, - "video": False, - "display_cameras": False, - "play_sounds": False, - "run_compute_stats": False, - "local_files_only": True, - "num_episodes": 1, - } - - dataset = record(**record_kwargs) - assert len(dataset) == 1, f"`dataset` should contain 1 frame, not {len(dataset)}" - - with pytest.raises(FileExistsError): - # Dataset already exists, but resume=False by default - record(**record_kwargs) - - dataset = record(**record_kwargs, resume=True) - assert len(dataset) == 2, f"`dataset` should contain 2 frames, not {len(dataset)}" - - -@pytest.mark.parametrize("robot_type, mock", [("koch", True)]) -@require_robot -def test_record_with_event_rerecord_episode(tmpdir, request, robot_type, mock): - if mock and robot_type != "aloha": - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - # and avoid writing calibration files in user .cache/calibration folder - calibration_dir = tmpdir / robot_type - mock_calibration_dir(calibration_dir) - overrides = [f"calibration_dir={calibration_dir}"] - else: - # Use the default .cache/calibration folder when mock=False or for aloha - overrides = [] - - robot = make_robot(robot_type, overrides=overrides, mock=mock) - with patch("lerobot.scripts.control_robot.init_keyboard_listener") as mock_listener: - mock_events = {} - mock_events["exit_early"] = True - mock_events["rerecord_episode"] = True - mock_events["stop_recording"] = False - mock_listener.return_value = (None, mock_events) - - repo_id = "lerobot/debug" - root = Path(tmpdir) / "data" / repo_id - single_task = "Do something." - - dataset = record( - robot, - root, - repo_id, - single_task, - fps=1, - warmup_time_s=0, - episode_time_s=1, - num_episodes=1, - push_to_hub=False, - video=False, - display_cameras=False, - play_sounds=False, - run_compute_stats=False, - ) - - assert not mock_events["rerecord_episode"], "`rerecord_episode` wasn't properly reset to False" - assert not mock_events["exit_early"], "`exit_early` wasn't properly reset to False" - assert len(dataset) == 1, "`dataset` should contain only 1 frame" - - -@pytest.mark.parametrize("robot_type, mock", [("koch", True)]) -@require_robot -def test_record_with_event_exit_early(tmpdir, request, robot_type, mock): - if mock: - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - # and avoid writing calibration files in user .cache/calibration folder - calibration_dir = tmpdir / robot_type - mock_calibration_dir(calibration_dir) - overrides = [f"calibration_dir={calibration_dir}"] - else: - # Use the default .cache/calibration folder when mock=False or for aloha - overrides = [] - - robot = make_robot(robot_type, overrides=overrides, mock=mock) - with patch("lerobot.scripts.control_robot.init_keyboard_listener") as mock_listener: - mock_events = {} - mock_events["exit_early"] = True - mock_events["rerecord_episode"] = False - mock_events["stop_recording"] = False - mock_listener.return_value = (None, mock_events) - - repo_id = "lerobot/debug" - root = Path(tmpdir) / "data" / repo_id - single_task = "Do something." - - dataset = record( - robot, - fps=2, - root=root, - single_task=single_task, - repo_id=repo_id, - warmup_time_s=0, - episode_time_s=1, - num_episodes=1, - push_to_hub=False, - video=False, - display_cameras=False, - play_sounds=False, - run_compute_stats=False, - ) - - assert not mock_events["exit_early"], "`exit_early` wasn't properly reset to False" - assert len(dataset) == 1, "`dataset` should contain only 1 frame" - - -@pytest.mark.parametrize( - "robot_type, mock, num_image_writer_processes", [("koch", True, 0), ("koch", True, 1)] -) -@require_robot -def test_record_with_event_stop_recording(tmpdir, request, robot_type, mock, num_image_writer_processes): - if mock: - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - # and avoid writing calibration files in user .cache/calibration folder - calibration_dir = tmpdir / robot_type - mock_calibration_dir(calibration_dir) - overrides = [f"calibration_dir={calibration_dir}"] - else: - # Use the default .cache/calibration folder when mock=False or for aloha - overrides = [] - - robot = make_robot(robot_type, overrides=overrides, mock=mock) - with patch("lerobot.scripts.control_robot.init_keyboard_listener") as mock_listener: - mock_events = {} - mock_events["exit_early"] = True - mock_events["rerecord_episode"] = False - mock_events["stop_recording"] = True - mock_listener.return_value = (None, mock_events) - - repo_id = "lerobot/debug" - root = Path(tmpdir) / "data" / repo_id - single_task = "Do something." - - dataset = record( - robot, - root, - repo_id, - single_task=single_task, - fps=1, - warmup_time_s=0, - episode_time_s=1, - num_episodes=2, - push_to_hub=False, - video=False, - display_cameras=False, - play_sounds=False, - run_compute_stats=False, - num_image_writer_processes=num_image_writer_processes, - ) - - assert not mock_events["exit_early"], "`exit_early` wasn't properly reset to False" - assert len(dataset) == 1, "`dataset` should contain only 1 frame" diff --git a/external/lerobot/tests/test_datasets.py b/external/lerobot/tests/test_datasets.py deleted file mode 100644 index 9e35f81676..0000000000 --- a/external/lerobot/tests/test_datasets.py +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import json -import logging -from copy import deepcopy -from itertools import chain -from pathlib import Path - -import einops -import pytest -import torch -from datasets import Dataset -from huggingface_hub import HfApi -from safetensors.torch import load_file - -import lerobot -from lerobot.common.datasets.compute_stats import ( - aggregate_stats, - compute_stats, - get_stats_einops_patterns, -) -from lerobot.common.datasets.factory import make_dataset -from lerobot.common.datasets.lerobot_dataset import ( - LeRobotDataset, - MultiLeRobotDataset, -) -from lerobot.common.datasets.utils import ( - create_branch, - flatten_dict, - hf_transform_to_torch, - unflatten_dict, -) -from lerobot.common.utils.utils import init_hydra_config, seeded_context -from tests.fixtures.constants import DUMMY_REPO_ID -from tests.utils import DEFAULT_CONFIG_PATH, DEVICE, make_robot - - -def test_same_attributes_defined(lerobot_dataset_factory, tmp_path): - """ - Instantiate a LeRobotDataset both ways with '__init__()' and 'create()' and verify that instantiated - objects have the same sets of attributes defined. - """ - # Instantiate both ways - robot = make_robot("koch", mock=True) - root_create = tmp_path / "create" - dataset_create = LeRobotDataset.create(repo_id=DUMMY_REPO_ID, fps=30, robot=robot, root=root_create) - - root_init = tmp_path / "init" - dataset_init = lerobot_dataset_factory(root=root_init) - - # Access the '_hub_version' cached_property in both instances to force its creation - _ = dataset_init.meta._hub_version - _ = dataset_create.meta._hub_version - - init_attr = set(vars(dataset_init).keys()) - create_attr = set(vars(dataset_create).keys()) - - assert init_attr == create_attr - - -def test_dataset_initialization(lerobot_dataset_factory, tmp_path): - kwargs = { - "repo_id": DUMMY_REPO_ID, - "total_episodes": 10, - "total_frames": 400, - "episodes": [2, 5, 6], - } - dataset = lerobot_dataset_factory(root=tmp_path, **kwargs) - - assert dataset.repo_id == kwargs["repo_id"] - assert dataset.meta.total_episodes == kwargs["total_episodes"] - assert dataset.meta.total_frames == kwargs["total_frames"] - assert dataset.episodes == kwargs["episodes"] - assert dataset.num_episodes == len(kwargs["episodes"]) - assert dataset.num_frames == len(dataset) - - -# TODO(aliberts): -# - [ ] test various attributes & state from init and create -# - [ ] test init with episodes and check num_frames -# - [ ] test add_frame -# - [ ] test add_episode -# - [ ] test consolidate -# - [ ] test push_to_hub -# - [ ] test smaller methods - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@pytest.mark.parametrize( - "env_name, repo_id, policy_name", - lerobot.env_dataset_policy_triplets - + [("aloha", ["lerobot/aloha_sim_insertion_human", "lerobot/aloha_sim_transfer_cube_human"], "act")], -) -def test_factory(env_name, repo_id, policy_name): - """ - Tests that: - - we can create a dataset with the factory. - - for a commonly used set of data keys, the data dimensions are correct. - """ - cfg = init_hydra_config( - DEFAULT_CONFIG_PATH, - overrides=[ - f"env={env_name}", - f"dataset_repo_id={repo_id}", - f"policy={policy_name}", - f"device={DEVICE}", - ], - ) - dataset = make_dataset(cfg) - delta_timestamps = dataset.delta_timestamps - camera_keys = dataset.meta.camera_keys - - item = dataset[0] - - keys_ndim_required = [ - ("action", 1, True), - ("episode_index", 0, True), - ("frame_index", 0, True), - ("timestamp", 0, True), - # TODO(rcadene): should we rename it agent_pos? - ("observation.state", 1, True), - ("next.reward", 0, False), - ("next.done", 0, False), - ] - - # test number of dimensions - for key, ndim, required in keys_ndim_required: - if key not in item: - if required: - assert key in item, f"{key}" - else: - logging.warning(f'Missing key in dataset: "{key}" not in {dataset}.') - continue - - if delta_timestamps is not None and key in delta_timestamps: - assert item[key].ndim == ndim + 1, f"{key}" - assert item[key].shape[0] == len(delta_timestamps[key]), f"{key}" - else: - assert item[key].ndim == ndim, f"{key}" - - if key in camera_keys: - assert item[key].dtype == torch.float32, f"{key}" - # TODO(rcadene): we assume for now that image normalization takes place in the model - assert item[key].max() <= 1.0, f"{key}" - assert item[key].min() >= 0.0, f"{key}" - - if delta_timestamps is not None and key in delta_timestamps: - # test t,c,h,w - assert item[key].shape[1] == 3, f"{key}" - else: - # test c,h,w - assert item[key].shape[0] == 3, f"{key}" - - if delta_timestamps is not None: - # test missing keys in delta_timestamps - for key in delta_timestamps: - assert key in item, f"{key}" - - -# TODO(alexander-soare): If you're hunting for savings on testing time, this takes about 5 seconds. -@pytest.mark.skip("TODO after v2 migration / removing hydra") -def test_multilerobotdataset_frames(): - """Check that all dataset frames are incorporated.""" - # Note: use the image variants of the dataset to make the test approx 3x faster. - # Note: We really do need three repo_ids here as at some point this caught an issue with the chaining - # logic that wouldn't be caught with two repo IDs. - repo_ids = [ - "lerobot/aloha_sim_insertion_human_image", - "lerobot/aloha_sim_transfer_cube_human_image", - "lerobot/aloha_sim_insertion_scripted_image", - ] - sub_datasets = [LeRobotDataset(repo_id) for repo_id in repo_ids] - dataset = MultiLeRobotDataset(repo_ids) - assert len(dataset) == sum(len(d) for d in sub_datasets) - assert dataset.num_frames == sum(d.num_frames for d in sub_datasets) - assert dataset.num_episodes == sum(d.num_episodes for d in sub_datasets) - - # Run through all items of the LeRobotDatasets in parallel with the items of the MultiLerobotDataset and - # check they match. - expected_dataset_indices = [] - for i, sub_dataset in enumerate(sub_datasets): - expected_dataset_indices.extend([i] * len(sub_dataset)) - - for expected_dataset_index, sub_dataset_item, dataset_item in zip( - expected_dataset_indices, chain(*sub_datasets), dataset, strict=True - ): - dataset_index = dataset_item.pop("dataset_index") - assert dataset_index == expected_dataset_index - assert sub_dataset_item.keys() == dataset_item.keys() - for k in sub_dataset_item: - assert torch.equal(sub_dataset_item[k], dataset_item[k]) - - -# TODO(aliberts, rcadene): Refactor and move this to a tests/test_compute_stats.py -@pytest.mark.skip("TODO after v2 migration / removing hydra") -def test_compute_stats_on_xarm(): - """Check that the statistics are computed correctly according to the stats_patterns property. - - We compare with taking a straight min, mean, max, std of all the data in one pass (which we can do - because we are working with a small dataset). - """ - dataset = LeRobotDataset("lerobot/xarm_lift_medium") - - # reduce size of dataset sample on which stats compute is tested to 10 frames - dataset.hf_dataset = dataset.hf_dataset.select(range(10)) - - # Note: we set the batch size to be smaller than the whole dataset to make sure we are testing batched - # computation of the statistics. While doing this, we also make sure it works when we don't divide the - # dataset into even batches. - computed_stats = compute_stats(dataset, batch_size=int(len(dataset) * 0.25), num_workers=0) - - # get einops patterns to aggregate batches and compute statistics - stats_patterns = get_stats_einops_patterns(dataset) - - # get all frames from the dataset in the same dtype and range as during compute_stats - dataloader = torch.utils.data.DataLoader( - dataset, - num_workers=0, - batch_size=len(dataset), - shuffle=False, - ) - full_batch = next(iter(dataloader)) - - # compute stats based on all frames from the dataset without any batching - expected_stats = {} - for k, pattern in stats_patterns.items(): - full_batch[k] = full_batch[k].float() - expected_stats[k] = {} - expected_stats[k]["mean"] = einops.reduce(full_batch[k], pattern, "mean") - expected_stats[k]["std"] = torch.sqrt( - einops.reduce((full_batch[k] - expected_stats[k]["mean"]) ** 2, pattern, "mean") - ) - expected_stats[k]["min"] = einops.reduce(full_batch[k], pattern, "min") - expected_stats[k]["max"] = einops.reduce(full_batch[k], pattern, "max") - - # test computed stats match expected stats - for k in stats_patterns: - assert torch.allclose(computed_stats[k]["mean"], expected_stats[k]["mean"]) - assert torch.allclose(computed_stats[k]["std"], expected_stats[k]["std"]) - assert torch.allclose(computed_stats[k]["min"], expected_stats[k]["min"]) - assert torch.allclose(computed_stats[k]["max"], expected_stats[k]["max"]) - - # load stats used during training which are expected to match the ones returned by computed_stats - loaded_stats = dataset.meta.stats # noqa: F841 - - # TODO(rcadene): we can't test this because expected_stats is computed on a subset - # # test loaded stats match expected stats - # for k in stats_patterns: - # assert torch.allclose(loaded_stats[k]["mean"], expected_stats[k]["mean"]) - # assert torch.allclose(loaded_stats[k]["std"], expected_stats[k]["std"]) - # assert torch.allclose(loaded_stats[k]["min"], expected_stats[k]["min"]) - # assert torch.allclose(loaded_stats[k]["max"], expected_stats[k]["max"]) - - -# TODO(aliberts): Move to more appropriate location -def test_flatten_unflatten_dict(): - d = { - "obs": { - "min": 0, - "max": 1, - "mean": 2, - "std": 3, - }, - "action": { - "min": 4, - "max": 5, - "mean": 6, - "std": 7, - }, - } - - original_d = deepcopy(d) - d = unflatten_dict(flatten_dict(d)) - - # test equality between nested dicts - assert json.dumps(original_d, sort_keys=True) == json.dumps(d, sort_keys=True), f"{original_d} != {d}" - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@pytest.mark.parametrize( - "repo_id", - [ - "lerobot/pusht", - "lerobot/aloha_sim_insertion_human", - "lerobot/xarm_lift_medium", - # (michel-aractingi) commenting the two datasets from openx as test is failing - # "lerobot/nyu_franka_play_dataset", - # "lerobot/cmu_stretch", - ], -) -# TODO(rcadene, aliberts): all these tests fail locally on Mac M1, but not on Linux -def test_backward_compatibility(repo_id): - """The artifacts for this test have been generated by `tests/scripts/save_dataset_to_safetensors.py`.""" - - dataset = LeRobotDataset(repo_id) - - test_dir = Path("tests/data/save_dataset_to_safetensors") / repo_id - - def load_and_compare(i): - new_frame = dataset[i] # noqa: B023 - old_frame = load_file(test_dir / f"frame_{i}.safetensors") # noqa: B023 - - # ignore language instructions (if exists) in language conditioned datasets - # TODO (michel-aractingi): transform language obs to langauge embeddings via tokenizer - new_frame.pop("language_instruction", None) - old_frame.pop("language_instruction", None) - - new_keys = set(new_frame.keys()) - old_keys = set(old_frame.keys()) - assert new_keys == old_keys, f"{new_keys=} and {old_keys=} are not the same" - - for key in new_frame: - assert torch.isclose(new_frame[key], old_frame[key]).all(), ( - f"{key=} for index={i} does not contain the same value" - ) - - # test2 first frames of first episode - i = dataset.episode_data_index["from"][0].item() - load_and_compare(i) - load_and_compare(i + 1) - - # test 2 frames at the middle of first episode - i = int((dataset.episode_data_index["to"][0].item() - dataset.episode_data_index["from"][0].item()) / 2) - load_and_compare(i) - load_and_compare(i + 1) - - # test 2 last frames of first episode - i = dataset.episode_data_index["to"][0].item() - load_and_compare(i - 2) - load_and_compare(i - 1) - - # TODO(rcadene): Enable testing on second and last episode - # We currently cant because our test dataset only contains the first episode - - # # test 2 first frames of second episode - # i = dataset.episode_data_index["from"][1].item() - # load_and_compare(i) - # load_and_compare(i + 1) - - # # test 2 last frames of second episode - # i = dataset.episode_data_index["to"][1].item() - # load_and_compare(i - 2) - # load_and_compare(i - 1) - - # # test 2 last frames of last episode - # i = dataset.episode_data_index["to"][-1].item() - # load_and_compare(i - 2) - # load_and_compare(i - 1) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -def test_aggregate_stats(): - """Makes 3 basic datasets and checks that aggregate stats are computed correctly.""" - with seeded_context(0): - data_a = torch.rand(30, dtype=torch.float32) - data_b = torch.rand(20, dtype=torch.float32) - data_c = torch.rand(20, dtype=torch.float32) - - hf_dataset_1 = Dataset.from_dict( - {"a": data_a[:10], "b": data_b[:10], "c": data_c[:10], "index": torch.arange(10)} - ) - hf_dataset_1.set_transform(hf_transform_to_torch) - hf_dataset_2 = Dataset.from_dict({"a": data_a[10:20], "b": data_b[10:], "index": torch.arange(10)}) - hf_dataset_2.set_transform(hf_transform_to_torch) - hf_dataset_3 = Dataset.from_dict({"a": data_a[20:], "c": data_c[10:], "index": torch.arange(10)}) - hf_dataset_3.set_transform(hf_transform_to_torch) - dataset_1 = LeRobotDataset.from_preloaded("d1", hf_dataset=hf_dataset_1) - dataset_1.stats = compute_stats(dataset_1, batch_size=len(hf_dataset_1), num_workers=0) - dataset_2 = LeRobotDataset.from_preloaded("d2", hf_dataset=hf_dataset_2) - dataset_2.stats = compute_stats(dataset_2, batch_size=len(hf_dataset_2), num_workers=0) - dataset_3 = LeRobotDataset.from_preloaded("d3", hf_dataset=hf_dataset_3) - dataset_3.stats = compute_stats(dataset_3, batch_size=len(hf_dataset_3), num_workers=0) - stats = aggregate_stats([dataset_1, dataset_2, dataset_3]) - for data_key, data in zip(["a", "b", "c"], [data_a, data_b, data_c], strict=True): - for agg_fn in ["mean", "min", "max"]: - assert torch.allclose(stats[data_key][agg_fn], einops.reduce(data, "n -> 1", agg_fn)) - assert torch.allclose(stats[data_key]["std"], torch.std(data, correction=0)) - - -@pytest.mark.skip("Requires internet access") -def test_create_branch(): - api = HfApi() - - repo_id = "cadene/test_create_branch" - repo_type = "dataset" - branch = "test" - ref = f"refs/heads/{branch}" - - # Prepare a repo with a test branch - api.delete_repo(repo_id, repo_type=repo_type, missing_ok=True) - api.create_repo(repo_id, repo_type=repo_type) - create_branch(repo_id, repo_type=repo_type, branch=branch) - - # Make sure the test branch exists - branches = api.list_repo_refs(repo_id, repo_type=repo_type).branches - refs = [branch.ref for branch in branches] - assert ref in refs - - # Overwrite it - create_branch(repo_id, repo_type=repo_type, branch=branch) - - # Clean - api.delete_repo(repo_id, repo_type=repo_type) diff --git a/external/lerobot/tests/test_delta_timestamps.py b/external/lerobot/tests/test_delta_timestamps.py deleted file mode 100644 index 3c2e307f8a..0000000000 --- a/external/lerobot/tests/test_delta_timestamps.py +++ /dev/null @@ -1,256 +0,0 @@ -import pytest -import torch -from datasets import Dataset - -from lerobot.common.datasets.utils import ( - calculate_episode_data_index, - check_delta_timestamps, - check_timestamps_sync, - get_delta_indices, - hf_transform_to_torch, -) -from tests.fixtures.constants import DUMMY_MOTOR_FEATURES - - -@pytest.fixture(scope="module") -def synced_hf_dataset_factory(hf_dataset_factory): - def _create_synced_hf_dataset(fps: int = 30) -> Dataset: - return hf_dataset_factory(fps=fps) - - return _create_synced_hf_dataset - - -@pytest.fixture(scope="module") -def unsynced_hf_dataset_factory(synced_hf_dataset_factory): - def _create_unsynced_hf_dataset(fps: int = 30, tolerance_s: float = 1e-4) -> Dataset: - hf_dataset = synced_hf_dataset_factory(fps=fps) - features = hf_dataset.features - df = hf_dataset.to_pandas() - dtype = df["timestamp"].dtype # This is to avoid pandas type warning - # Modify a single timestamp just outside tolerance - df.at[30, "timestamp"] = dtype.type(df.at[30, "timestamp"] + (tolerance_s * 1.1)) - unsynced_hf_dataset = Dataset.from_pandas(df, features=features) - unsynced_hf_dataset.set_transform(hf_transform_to_torch) - return unsynced_hf_dataset - - return _create_unsynced_hf_dataset - - -@pytest.fixture(scope="module") -def slightly_off_hf_dataset_factory(synced_hf_dataset_factory): - def _create_slightly_off_hf_dataset(fps: int = 30, tolerance_s: float = 1e-4) -> Dataset: - hf_dataset = synced_hf_dataset_factory(fps=fps) - features = hf_dataset.features - df = hf_dataset.to_pandas() - dtype = df["timestamp"].dtype # This is to avoid pandas type warning - # Modify a single timestamp just inside tolerance - df.at[30, "timestamp"] = dtype.type(df.at[30, "timestamp"] + (tolerance_s * 0.9)) - unsynced_hf_dataset = Dataset.from_pandas(df, features=features) - unsynced_hf_dataset.set_transform(hf_transform_to_torch) - return unsynced_hf_dataset - - return _create_slightly_off_hf_dataset - - -@pytest.fixture(scope="module") -def valid_delta_timestamps_factory(): - def _create_valid_delta_timestamps(fps: int = 30, keys: list = DUMMY_MOTOR_FEATURES) -> dict: - delta_timestamps = {key: [i * (1 / fps) for i in range(-10, 10)] for key in keys} - return delta_timestamps - - return _create_valid_delta_timestamps - - -@pytest.fixture(scope="module") -def invalid_delta_timestamps_factory(valid_delta_timestamps_factory): - def _create_invalid_delta_timestamps( - fps: int = 30, tolerance_s: float = 1e-4, keys: list = DUMMY_MOTOR_FEATURES - ) -> dict: - delta_timestamps = valid_delta_timestamps_factory(fps, keys) - # Modify a single timestamp just outside tolerance - for key in keys: - delta_timestamps[key][3] += tolerance_s * 1.1 - return delta_timestamps - - return _create_invalid_delta_timestamps - - -@pytest.fixture(scope="module") -def slightly_off_delta_timestamps_factory(valid_delta_timestamps_factory): - def _create_slightly_off_delta_timestamps( - fps: int = 30, tolerance_s: float = 1e-4, keys: list = DUMMY_MOTOR_FEATURES - ) -> dict: - delta_timestamps = valid_delta_timestamps_factory(fps, keys) - # Modify a single timestamp just inside tolerance - for key in delta_timestamps: - delta_timestamps[key][3] += tolerance_s * 0.9 - delta_timestamps[key][-3] += tolerance_s * 0.9 - return delta_timestamps - - return _create_slightly_off_delta_timestamps - - -@pytest.fixture(scope="module") -def delta_indices(keys: list = DUMMY_MOTOR_FEATURES) -> dict: - return {key: list(range(-10, 10)) for key in keys} - - -def test_check_timestamps_sync_synced(synced_hf_dataset_factory): - fps = 30 - tolerance_s = 1e-4 - synced_hf_dataset = synced_hf_dataset_factory(fps) - episode_data_index = calculate_episode_data_index(synced_hf_dataset) - result = check_timestamps_sync( - hf_dataset=synced_hf_dataset, - episode_data_index=episode_data_index, - fps=fps, - tolerance_s=tolerance_s, - ) - assert result is True - - -def test_check_timestamps_sync_unsynced(unsynced_hf_dataset_factory): - fps = 30 - tolerance_s = 1e-4 - unsynced_hf_dataset = unsynced_hf_dataset_factory(fps, tolerance_s) - episode_data_index = calculate_episode_data_index(unsynced_hf_dataset) - with pytest.raises(ValueError): - check_timestamps_sync( - hf_dataset=unsynced_hf_dataset, - episode_data_index=episode_data_index, - fps=fps, - tolerance_s=tolerance_s, - ) - - -def test_check_timestamps_sync_unsynced_no_exception(unsynced_hf_dataset_factory): - fps = 30 - tolerance_s = 1e-4 - unsynced_hf_dataset = unsynced_hf_dataset_factory(fps, tolerance_s) - episode_data_index = calculate_episode_data_index(unsynced_hf_dataset) - result = check_timestamps_sync( - hf_dataset=unsynced_hf_dataset, - episode_data_index=episode_data_index, - fps=fps, - tolerance_s=tolerance_s, - raise_value_error=False, - ) - assert result is False - - -def test_check_timestamps_sync_slightly_off(slightly_off_hf_dataset_factory): - fps = 30 - tolerance_s = 1e-4 - slightly_off_hf_dataset = slightly_off_hf_dataset_factory(fps, tolerance_s) - episode_data_index = calculate_episode_data_index(slightly_off_hf_dataset) - result = check_timestamps_sync( - hf_dataset=slightly_off_hf_dataset, - episode_data_index=episode_data_index, - fps=fps, - tolerance_s=tolerance_s, - ) - assert result is True - - -def test_check_timestamps_sync_single_timestamp(): - single_timestamp_hf_dataset = Dataset.from_dict({"timestamp": [0.0], "episode_index": [0]}) - single_timestamp_hf_dataset.set_transform(hf_transform_to_torch) - episode_data_index = {"to": torch.tensor([1]), "from": torch.tensor([0])} - fps = 30 - tolerance_s = 1e-4 - result = check_timestamps_sync( - hf_dataset=single_timestamp_hf_dataset, - episode_data_index=episode_data_index, - fps=fps, - tolerance_s=tolerance_s, - ) - assert result is True - - -# TODO(aliberts): Change behavior of hf_transform_to_torch so that it can work with empty dataset -@pytest.mark.skip("TODO: fix") -def test_check_timestamps_sync_empty_dataset(): - fps = 30 - tolerance_s = 1e-4 - empty_hf_dataset = Dataset.from_dict({"timestamp": [], "episode_index": []}) - empty_hf_dataset.set_transform(hf_transform_to_torch) - episode_data_index = { - "to": torch.tensor([], dtype=torch.int64), - "from": torch.tensor([], dtype=torch.int64), - } - result = check_timestamps_sync( - hf_dataset=empty_hf_dataset, - episode_data_index=episode_data_index, - fps=fps, - tolerance_s=tolerance_s, - ) - assert result is True - - -def test_check_delta_timestamps_valid(valid_delta_timestamps_factory): - fps = 30 - tolerance_s = 1e-4 - valid_delta_timestamps = valid_delta_timestamps_factory(fps) - result = check_delta_timestamps( - delta_timestamps=valid_delta_timestamps, - fps=fps, - tolerance_s=tolerance_s, - ) - assert result is True - - -def test_check_delta_timestamps_slightly_off(slightly_off_delta_timestamps_factory): - fps = 30 - tolerance_s = 1e-4 - slightly_off_delta_timestamps = slightly_off_delta_timestamps_factory(fps, tolerance_s) - result = check_delta_timestamps( - delta_timestamps=slightly_off_delta_timestamps, - fps=fps, - tolerance_s=tolerance_s, - ) - assert result is True - - -def test_check_delta_timestamps_invalid(invalid_delta_timestamps_factory): - fps = 30 - tolerance_s = 1e-4 - invalid_delta_timestamps = invalid_delta_timestamps_factory(fps, tolerance_s) - with pytest.raises(ValueError): - check_delta_timestamps( - delta_timestamps=invalid_delta_timestamps, - fps=fps, - tolerance_s=tolerance_s, - ) - - -def test_check_delta_timestamps_invalid_no_exception(invalid_delta_timestamps_factory): - fps = 30 - tolerance_s = 1e-4 - invalid_delta_timestamps = invalid_delta_timestamps_factory(fps, tolerance_s) - result = check_delta_timestamps( - delta_timestamps=invalid_delta_timestamps, - fps=fps, - tolerance_s=tolerance_s, - raise_value_error=False, - ) - assert result is False - - -def test_check_delta_timestamps_empty(): - delta_timestamps = {} - fps = 30 - tolerance_s = 1e-4 - result = check_delta_timestamps( - delta_timestamps=delta_timestamps, - fps=fps, - tolerance_s=tolerance_s, - ) - assert result is True - - -def test_delta_indices(valid_delta_timestamps_factory, delta_indices): - fps = 30 - delta_timestamps = valid_delta_timestamps_factory(fps) - expected_delta_indices = delta_indices - actual_delta_indices = get_delta_indices(delta_timestamps, fps) - assert expected_delta_indices == actual_delta_indices diff --git a/external/lerobot/tests/test_envs.py b/external/lerobot/tests/test_envs.py deleted file mode 100644 index aec9999da3..0000000000 --- a/external/lerobot/tests/test_envs.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import importlib - -import gymnasium as gym -import pytest -import torch -from gymnasium.utils.env_checker import check_env - -import lerobot -from lerobot.common.envs.factory import make_env -from lerobot.common.envs.utils import preprocess_observation -from lerobot.common.utils.utils import init_hydra_config - -from .utils import DEFAULT_CONFIG_PATH, DEVICE, require_env - -OBS_TYPES = ["state", "pixels", "pixels_agent_pos"] - - -@pytest.mark.parametrize("obs_type", OBS_TYPES) -@pytest.mark.parametrize("env_name, env_task", lerobot.env_task_pairs) -@require_env -def test_env(env_name, env_task, obs_type): - if env_name == "aloha" and obs_type == "state": - pytest.skip("`state` observations not available for aloha") - - package_name = f"gym_{env_name}" - importlib.import_module(package_name) - env = gym.make(f"{package_name}/{env_task}", obs_type=obs_type) - check_env(env.unwrapped, skip_render_check=True) - env.close() - - -@pytest.mark.parametrize("env_name", lerobot.available_envs) -@require_env -def test_factory(env_name): - cfg = init_hydra_config( - DEFAULT_CONFIG_PATH, - overrides=[f"env={env_name}", f"device={DEVICE}"], - ) - - env = make_env(cfg, n_envs=1) - obs, _ = env.reset() - obs = preprocess_observation(obs) - - # test image keys are float32 in range [0,1] - for key in obs: - if "image" not in key: - continue - img = obs[key] - assert img.dtype == torch.float32 - # TODO(rcadene): we assume for now that image normalization takes place in the model - assert img.max() <= 1.0 - assert img.min() >= 0.0 - - env.close() diff --git a/external/lerobot/tests/test_examples.py b/external/lerobot/tests/test_examples.py deleted file mode 100644 index f3b7948c76..0000000000 --- a/external/lerobot/tests/test_examples.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import io -import subprocess -import sys -from pathlib import Path - -import pytest - -from tests.fixtures.constants import DUMMY_REPO_ID -from tests.utils import require_package - - -def _find_and_replace(text: str, finds_and_replaces: list[tuple[str, str]]) -> str: - for f, r in finds_and_replaces: - assert f in text - text = text.replace(f, r) - return text - - -# TODO(aliberts): Remove usage of subprocess calls and patch code with fixtures -def _run_script(path): - subprocess.run([sys.executable, path], check=True) - - -def _read_file(path): - with open(path) as file: - return file.read() - - -@pytest.mark.skip("TODO Fix and remove subprocess / excec calls") -def test_example_1(tmp_path, lerobot_dataset_factory): - _ = lerobot_dataset_factory(root=tmp_path, repo_id=DUMMY_REPO_ID) - path = "examples/1_load_lerobot_dataset.py" - file_contents = _read_file(path) - file_contents = _find_and_replace( - file_contents, - [ - ('repo_id = "lerobot/pusht"', f'repo_id = "{DUMMY_REPO_ID}"'), - ( - "LeRobotDataset(repo_id", - f"LeRobotDataset(repo_id, root='{str(tmp_path)}', local_files_only=True", - ), - ], - ) - exec(file_contents, {}) - assert Path("outputs/examples/1_load_lerobot_dataset/episode_0.mp4").exists() - - -@pytest.mark.skip("TODO Fix and remove subprocess / excec calls") -@require_package("gym_pusht") -def test_examples_basic2_basic3_advanced1(): - """ - Train a model with example 3, check the outputs. - Evaluate the trained model with example 2, check the outputs. - Calculate the validation loss with advanced example 1, check the outputs. - """ - - ### Test example 3 - file_contents = _read_file("examples/3_train_policy.py") - - # Do fewer steps, use smaller batch, use CPU, and don't complicate things with dataloader workers. - file_contents = _find_and_replace( - file_contents, - [ - ("training_steps = 5000", "training_steps = 1"), - ("num_workers=4", "num_workers=0"), - ('device = torch.device("cuda")', 'device = torch.device("cpu")'), - ("batch_size=64", "batch_size=1"), - ], - ) - - # Pass empty globals to allow dictionary comprehension https://stackoverflow.com/a/32897127/4391249. - exec(file_contents, {}) - - for file_name in ["model.safetensors", "config.json"]: - assert Path(f"outputs/train/example_pusht_diffusion/{file_name}").exists() - - ### Test example 2 - file_contents = _read_file("examples/2_evaluate_pretrained_policy.py") - - # Do fewer evals, use CPU, and use the local model. - file_contents = _find_and_replace( - file_contents, - [ - ( - 'pretrained_policy_path = Path(snapshot_download("lerobot/diffusion_pusht"))', - "", - ), - ( - '# pretrained_policy_path = Path("outputs/train/example_pusht_diffusion")', - 'pretrained_policy_path = Path("outputs/train/example_pusht_diffusion")', - ), - ('device = torch.device("cuda")', 'device = torch.device("cpu")'), - ("step += 1", "break"), - ], - ) - - exec(file_contents, {}) - - assert Path("outputs/eval/example_pusht_diffusion/rollout.mp4").exists() - - ## Test example 4 - file_contents = _read_file("examples/advanced/2_calculate_validation_loss.py") - - # Run on a single example from the last episode, use CPU, and use the local model. - file_contents = _find_and_replace( - file_contents, - [ - ( - 'pretrained_policy_path = Path(snapshot_download("lerobot/diffusion_pusht"))', - "", - ), - ( - '# pretrained_policy_path = Path("outputs/train/example_pusht_diffusion")', - 'pretrained_policy_path = Path("outputs/train/example_pusht_diffusion")', - ), - ("train_episodes = episodes[:num_train_episodes]", "train_episodes = [0]"), - ("val_episodes = episodes[num_train_episodes:]", "val_episodes = [1]"), - ("num_workers=4", "num_workers=0"), - ('device = torch.device("cuda")', 'device = torch.device("cpu")'), - ("batch_size=64", "batch_size=1"), - ], - ) - - # Capture the output of the script - output_buffer = io.StringIO() - sys.stdout = output_buffer - exec(file_contents, {}) - printed_output = output_buffer.getvalue() - # Restore stdout to its original state - sys.stdout = sys.__stdout__ - assert "Average loss on validation set" in printed_output diff --git a/external/lerobot/tests/test_image_transforms.py b/external/lerobot/tests/test_image_transforms.py deleted file mode 100644 index fac041abfb..0000000000 --- a/external/lerobot/tests/test_image_transforms.py +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from pathlib import Path - -import pytest -import torch -from safetensors.torch import load_file -from torchvision.transforms import v2 -from torchvision.transforms.v2 import functional as F # noqa: N812 - -from lerobot.common.datasets.transforms import RandomSubsetApply, SharpnessJitter, get_image_transforms -from lerobot.common.utils.utils import init_hydra_config, seeded_context -from lerobot.scripts.visualize_image_transforms import visualize_transforms -from tests.utils import DEFAULT_CONFIG_PATH, require_x86_64_kernel - -ARTIFACT_DIR = Path("tests/data/save_image_transforms_to_safetensors") -DATASET_REPO_ID = "lerobot/aloha_mobile_shrimp" - - -@pytest.fixture -def color_jitters(): - return [ - v2.ColorJitter(brightness=0.5), - v2.ColorJitter(contrast=0.5), - v2.ColorJitter(saturation=0.5), - ] - - -@pytest.fixture -def single_transforms(): - return load_file(ARTIFACT_DIR / "single_transforms.safetensors") - - -@pytest.fixture -def default_transforms(): - return load_file(ARTIFACT_DIR / "default_transforms.safetensors") - - -def test_get_image_transforms_no_transform(img_tensor_factory): - img_tensor = img_tensor_factory() - tf_actual = get_image_transforms(brightness_min_max=(0.5, 0.5), max_num_transforms=0) - torch.testing.assert_close(tf_actual(img_tensor), img_tensor) - - -@pytest.mark.parametrize("min_max", [(0.5, 0.5), (2.0, 2.0)]) -def test_get_image_transforms_brightness(img_tensor_factory, min_max): - img_tensor = img_tensor_factory() - tf_actual = get_image_transforms(brightness_weight=1.0, brightness_min_max=min_max) - tf_expected = v2.ColorJitter(brightness=min_max) - torch.testing.assert_close(tf_actual(img_tensor), tf_expected(img_tensor)) - - -@pytest.mark.parametrize("min_max", [(0.5, 0.5), (2.0, 2.0)]) -def test_get_image_transforms_contrast(img_tensor_factory, min_max): - img_tensor = img_tensor_factory() - tf_actual = get_image_transforms(contrast_weight=1.0, contrast_min_max=min_max) - tf_expected = v2.ColorJitter(contrast=min_max) - torch.testing.assert_close(tf_actual(img_tensor), tf_expected(img_tensor)) - - -@pytest.mark.parametrize("min_max", [(0.5, 0.5), (2.0, 2.0)]) -def test_get_image_transforms_saturation(img_tensor_factory, min_max): - img_tensor = img_tensor_factory() - tf_actual = get_image_transforms(saturation_weight=1.0, saturation_min_max=min_max) - tf_expected = v2.ColorJitter(saturation=min_max) - torch.testing.assert_close(tf_actual(img_tensor), tf_expected(img_tensor)) - - -@pytest.mark.parametrize("min_max", [(-0.25, -0.25), (0.25, 0.25)]) -def test_get_image_transforms_hue(img_tensor_factory, min_max): - img_tensor = img_tensor_factory() - tf_actual = get_image_transforms(hue_weight=1.0, hue_min_max=min_max) - tf_expected = v2.ColorJitter(hue=min_max) - torch.testing.assert_close(tf_actual(img_tensor), tf_expected(img_tensor)) - - -@pytest.mark.parametrize("min_max", [(0.5, 0.5), (2.0, 2.0)]) -def test_get_image_transforms_sharpness(img_tensor_factory, min_max): - img_tensor = img_tensor_factory() - tf_actual = get_image_transforms(sharpness_weight=1.0, sharpness_min_max=min_max) - tf_expected = SharpnessJitter(sharpness=min_max) - torch.testing.assert_close(tf_actual(img_tensor), tf_expected(img_tensor)) - - -def test_get_image_transforms_max_num_transforms(img_tensor_factory): - img_tensor = img_tensor_factory() - tf_actual = get_image_transforms( - brightness_min_max=(0.5, 0.5), - contrast_min_max=(0.5, 0.5), - saturation_min_max=(0.5, 0.5), - hue_min_max=(0.5, 0.5), - sharpness_min_max=(0.5, 0.5), - random_order=False, - ) - tf_expected = v2.Compose( - [ - v2.ColorJitter(brightness=(0.5, 0.5)), - v2.ColorJitter(contrast=(0.5, 0.5)), - v2.ColorJitter(saturation=(0.5, 0.5)), - v2.ColorJitter(hue=(0.5, 0.5)), - SharpnessJitter(sharpness=(0.5, 0.5)), - ] - ) - torch.testing.assert_close(tf_actual(img_tensor), tf_expected(img_tensor)) - - -@require_x86_64_kernel -def test_get_image_transforms_random_order(img_tensor_factory): - out_imgs = [] - img_tensor = img_tensor_factory() - tf = get_image_transforms( - brightness_min_max=(0.5, 0.5), - contrast_min_max=(0.5, 0.5), - saturation_min_max=(0.5, 0.5), - hue_min_max=(0.5, 0.5), - sharpness_min_max=(0.5, 0.5), - random_order=True, - ) - with seeded_context(1337): - for _ in range(10): - out_imgs.append(tf(img_tensor)) - - for i in range(1, len(out_imgs)): - with pytest.raises(AssertionError): - torch.testing.assert_close(out_imgs[0], out_imgs[i]) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@pytest.mark.parametrize( - "transform, min_max_values", - [ - ("brightness", [(0.5, 0.5), (2.0, 2.0)]), - ("contrast", [(0.5, 0.5), (2.0, 2.0)]), - ("saturation", [(0.5, 0.5), (2.0, 2.0)]), - ("hue", [(-0.25, -0.25), (0.25, 0.25)]), - ("sharpness", [(0.5, 0.5), (2.0, 2.0)]), - ], -) -def test_backward_compatibility_torchvision(img_tensor_factory, transform, min_max_values, single_transforms): - img_tensor = img_tensor_factory() - for min_max in min_max_values: - kwargs = { - f"{transform}_weight": 1.0, - f"{transform}_min_max": min_max, - } - tf = get_image_transforms(**kwargs) - actual = tf(img_tensor) - key = f"{transform}_{min_max[0]}_{min_max[1]}" - expected = single_transforms[key] - torch.testing.assert_close(actual, expected) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@require_x86_64_kernel -def test_backward_compatibility_default_config(img_tensor_factory, default_transforms): - img_tensor = img_tensor_factory() - cfg = init_hydra_config(DEFAULT_CONFIG_PATH) - cfg_tf = cfg.training.image_transforms - default_tf = get_image_transforms( - brightness_weight=cfg_tf.brightness.weight, - brightness_min_max=cfg_tf.brightness.min_max, - contrast_weight=cfg_tf.contrast.weight, - contrast_min_max=cfg_tf.contrast.min_max, - saturation_weight=cfg_tf.saturation.weight, - saturation_min_max=cfg_tf.saturation.min_max, - hue_weight=cfg_tf.hue.weight, - hue_min_max=cfg_tf.hue.min_max, - sharpness_weight=cfg_tf.sharpness.weight, - sharpness_min_max=cfg_tf.sharpness.min_max, - max_num_transforms=cfg_tf.max_num_transforms, - random_order=cfg_tf.random_order, - ) - - with seeded_context(1337): - actual = default_tf(img_tensor) - - expected = default_transforms["default"] - - torch.testing.assert_close(actual, expected) - - -@pytest.mark.parametrize("p", [[0, 1], [1, 0]]) -def test_random_subset_apply_single_choice(img_tensor_factory, p): - img_tensor = img_tensor_factory() - flips = [v2.RandomHorizontalFlip(p=1), v2.RandomVerticalFlip(p=1)] - random_choice = RandomSubsetApply(flips, p=p, n_subset=1, random_order=False) - actual = random_choice(img_tensor) - - p_horz, _ = p - if p_horz: - torch.testing.assert_close(actual, F.horizontal_flip(img_tensor)) - else: - torch.testing.assert_close(actual, F.vertical_flip(img_tensor)) - - -def test_random_subset_apply_random_order(img_tensor_factory): - img_tensor = img_tensor_factory() - flips = [v2.RandomHorizontalFlip(p=1), v2.RandomVerticalFlip(p=1)] - random_order = RandomSubsetApply(flips, p=[0.5, 0.5], n_subset=2, random_order=True) - # We can't really check whether the transforms are actually applied in random order. However, - # horizontal and vertical flip are commutative. Meaning, even under the assumption that the transform - # applies them in random order, we can use a fixed order to compute the expected value. - actual = random_order(img_tensor) - expected = v2.Compose(flips)(img_tensor) - torch.testing.assert_close(actual, expected) - - -def test_random_subset_apply_valid_transforms(img_tensor_factory, color_jitters): - img_tensor = img_tensor_factory() - transform = RandomSubsetApply(color_jitters) - output = transform(img_tensor) - assert output.shape == img_tensor.shape - - -def test_random_subset_apply_probability_length_mismatch(color_jitters): - with pytest.raises(ValueError): - RandomSubsetApply(color_jitters, p=[0.5, 0.5]) - - -@pytest.mark.parametrize("n_subset", [0, 5]) -def test_random_subset_apply_invalid_n_subset(color_jitters, n_subset): - with pytest.raises(ValueError): - RandomSubsetApply(color_jitters, n_subset=n_subset) - - -def test_sharpness_jitter_valid_range_tuple(img_tensor_factory): - img_tensor = img_tensor_factory() - tf = SharpnessJitter((0.1, 2.0)) - output = tf(img_tensor) - assert output.shape == img_tensor.shape - - -def test_sharpness_jitter_valid_range_float(img_tensor_factory): - img_tensor = img_tensor_factory() - tf = SharpnessJitter(0.5) - output = tf(img_tensor) - assert output.shape == img_tensor.shape - - -def test_sharpness_jitter_invalid_range_min_negative(): - with pytest.raises(ValueError): - SharpnessJitter((-0.1, 2.0)) - - -def test_sharpness_jitter_invalid_range_max_smaller(): - with pytest.raises(ValueError): - SharpnessJitter((2.0, 0.1)) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@pytest.mark.parametrize( - "repo_id, n_examples", - [ - ("lerobot/aloha_sim_transfer_cube_human", 3), - ], -) -def test_visualize_image_transforms(repo_id, n_examples): - cfg = init_hydra_config(DEFAULT_CONFIG_PATH, overrides=[f"dataset_repo_id={repo_id}"]) - output_dir = Path(__file__).parent / "outputs" / "image_transforms" - visualize_transforms(cfg, output_dir=output_dir, n_examples=n_examples) - output_dir = output_dir / repo_id.split("/")[-1] - - # Check if the original frame image exists - assert (output_dir / "original_frame.png").exists(), "Original frame image was not saved." - - # Check if the transformed images exist for each transform type - transforms = ["brightness", "contrast", "saturation", "hue", "sharpness"] - for transform in transforms: - transform_dir = output_dir / transform - assert transform_dir.exists(), f"{transform} directory was not created." - assert any(transform_dir.iterdir()), f"No transformed images found in {transform} directory." - - # Check for specific files within each transform directory - expected_files = [f"{i}.png" for i in range(1, n_examples + 1)] + ["min.png", "max.png", "mean.png"] - for file_name in expected_files: - assert (transform_dir / file_name).exists(), ( - f"{file_name} was not found in {transform} directory." - ) - - # Check if the combined transforms directory exists and contains the right files - combined_transforms_dir = output_dir / "all" - assert combined_transforms_dir.exists(), "Combined transforms directory was not created." - assert any(combined_transforms_dir.iterdir()), ( - "No transformed images found in combined transforms directory." - ) - for i in range(1, n_examples + 1): - assert (combined_transforms_dir / f"{i}.png").exists(), ( - f"Combined transform image {i}.png was not found." - ) diff --git a/external/lerobot/tests/test_image_writer.py b/external/lerobot/tests/test_image_writer.py deleted file mode 100644 index f51e86b470..0000000000 --- a/external/lerobot/tests/test_image_writer.py +++ /dev/null @@ -1,359 +0,0 @@ -import queue -import time -from multiprocessing import queues -from unittest.mock import MagicMock, patch - -import numpy as np -import pytest -from PIL import Image - -from lerobot.common.datasets.image_writer import ( - AsyncImageWriter, - image_array_to_image, - safe_stop_image_writer, - write_image, -) - -DUMMY_IMAGE = "test_image.png" - - -def test_init_threading(): - writer = AsyncImageWriter(num_processes=0, num_threads=2) - try: - assert writer.num_processes == 0 - assert writer.num_threads == 2 - assert isinstance(writer.queue, queue.Queue) - assert len(writer.threads) == 2 - assert len(writer.processes) == 0 - assert all(t.is_alive() for t in writer.threads) - finally: - writer.stop() - - -def test_init_multiprocessing(): - writer = AsyncImageWriter(num_processes=2, num_threads=2) - try: - assert writer.num_processes == 2 - assert writer.num_threads == 2 - assert isinstance(writer.queue, queues.JoinableQueue) - assert len(writer.threads) == 0 - assert len(writer.processes) == 2 - assert all(p.is_alive() for p in writer.processes) - finally: - writer.stop() - - -def test_zero_threads(): - with pytest.raises(ValueError): - AsyncImageWriter(num_processes=0, num_threads=0) - - -def test_image_array_to_image_rgb(img_array_factory): - img_array = img_array_factory(100, 100) - result_image = image_array_to_image(img_array) - assert isinstance(result_image, Image.Image) - assert result_image.size == (100, 100) - assert result_image.mode == "RGB" - - -def test_image_array_to_image_pytorch_format(img_array_factory): - img_array = img_array_factory(100, 100).transpose(2, 0, 1) - result_image = image_array_to_image(img_array) - assert isinstance(result_image, Image.Image) - assert result_image.size == (100, 100) - assert result_image.mode == "RGB" - - -@pytest.mark.skip("TODO: implement") -def test_image_array_to_image_single_channel(img_array_factory): - img_array = img_array_factory(channels=1) - result_image = image_array_to_image(img_array) - assert isinstance(result_image, Image.Image) - assert result_image.size == (100, 100) - assert result_image.mode == "L" - - -def test_image_array_to_image_float_array(img_array_factory): - img_array = img_array_factory(dtype=np.float32) - result_image = image_array_to_image(img_array) - assert isinstance(result_image, Image.Image) - assert result_image.size == (100, 100) - assert result_image.mode == "RGB" - assert np.array(result_image).dtype == np.uint8 - - -def test_image_array_to_image_out_of_bounds_float(): - # Float array with values out of [0, 1] - img_array = np.random.uniform(-1, 2, size=(100, 100, 3)).astype(np.float32) - result_image = image_array_to_image(img_array) - assert isinstance(result_image, Image.Image) - assert result_image.size == (100, 100) - assert result_image.mode == "RGB" - assert np.array(result_image).dtype == np.uint8 - assert np.array(result_image).min() >= 0 and np.array(result_image).max() <= 255 - - -def test_write_image_numpy(tmp_path, img_array_factory): - image_array = img_array_factory() - fpath = tmp_path / DUMMY_IMAGE - write_image(image_array, fpath) - assert fpath.exists() - saved_image = np.array(Image.open(fpath)) - assert np.array_equal(image_array, saved_image) - - -def test_write_image_image(tmp_path, img_factory): - image_pil = img_factory() - fpath = tmp_path / DUMMY_IMAGE - write_image(image_pil, fpath) - assert fpath.exists() - saved_image = Image.open(fpath) - assert list(saved_image.getdata()) == list(image_pil.getdata()) - assert np.array_equal(image_pil, saved_image) - - -def test_write_image_exception(tmp_path): - image_array = "invalid data" - fpath = tmp_path / DUMMY_IMAGE - with patch("builtins.print") as mock_print: - write_image(image_array, fpath) - mock_print.assert_called() - assert not fpath.exists() - - -def test_save_image_numpy(tmp_path, img_array_factory): - writer = AsyncImageWriter() - try: - image_array = img_array_factory() - fpath = tmp_path / DUMMY_IMAGE - fpath.parent.mkdir(parents=True, exist_ok=True) - writer.save_image(image_array, fpath) - writer.wait_until_done() - assert fpath.exists() - saved_image = np.array(Image.open(fpath)) - assert np.array_equal(image_array, saved_image) - finally: - writer.stop() - - -def test_save_image_numpy_multiprocessing(tmp_path, img_array_factory): - writer = AsyncImageWriter(num_processes=2, num_threads=2) - try: - image_array = img_array_factory() - fpath = tmp_path / DUMMY_IMAGE - writer.save_image(image_array, fpath) - writer.wait_until_done() - assert fpath.exists() - saved_image = np.array(Image.open(fpath)) - assert np.array_equal(image_array, saved_image) - finally: - writer.stop() - - -def test_save_image_torch(tmp_path, img_tensor_factory): - writer = AsyncImageWriter() - try: - image_tensor = img_tensor_factory() - fpath = tmp_path / DUMMY_IMAGE - fpath.parent.mkdir(parents=True, exist_ok=True) - writer.save_image(image_tensor, fpath) - writer.wait_until_done() - assert fpath.exists() - saved_image = np.array(Image.open(fpath)) - expected_image = (image_tensor.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8) - assert np.array_equal(expected_image, saved_image) - finally: - writer.stop() - - -def test_save_image_torch_multiprocessing(tmp_path, img_tensor_factory): - writer = AsyncImageWriter(num_processes=2, num_threads=2) - try: - image_tensor = img_tensor_factory() - fpath = tmp_path / DUMMY_IMAGE - writer.save_image(image_tensor, fpath) - writer.wait_until_done() - assert fpath.exists() - saved_image = np.array(Image.open(fpath)) - expected_image = (image_tensor.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8) - assert np.array_equal(expected_image, saved_image) - finally: - writer.stop() - - -def test_save_image_pil(tmp_path, img_factory): - writer = AsyncImageWriter() - try: - image_pil = img_factory() - fpath = tmp_path / DUMMY_IMAGE - fpath.parent.mkdir(parents=True, exist_ok=True) - writer.save_image(image_pil, fpath) - writer.wait_until_done() - assert fpath.exists() - saved_image = Image.open(fpath) - assert list(saved_image.getdata()) == list(image_pil.getdata()) - finally: - writer.stop() - - -def test_save_image_pil_multiprocessing(tmp_path, img_factory): - writer = AsyncImageWriter(num_processes=2, num_threads=2) - try: - image_pil = img_factory() - fpath = tmp_path / DUMMY_IMAGE - writer.save_image(image_pil, fpath) - writer.wait_until_done() - assert fpath.exists() - saved_image = Image.open(fpath) - assert list(saved_image.getdata()) == list(image_pil.getdata()) - finally: - writer.stop() - - -def test_save_image_invalid_data(tmp_path): - writer = AsyncImageWriter() - try: - image_array = "invalid data" - fpath = tmp_path / DUMMY_IMAGE - fpath.parent.mkdir(parents=True, exist_ok=True) - with patch("builtins.print") as mock_print: - writer.save_image(image_array, fpath) - writer.wait_until_done() - mock_print.assert_called() - assert not fpath.exists() - finally: - writer.stop() - - -def test_save_image_after_stop(tmp_path, img_array_factory): - writer = AsyncImageWriter() - writer.stop() - image_array = img_array_factory() - fpath = tmp_path / DUMMY_IMAGE - writer.save_image(image_array, fpath) - time.sleep(1) - assert not fpath.exists() - - -def test_stop(): - writer = AsyncImageWriter(num_processes=0, num_threads=2) - writer.stop() - assert not any(t.is_alive() for t in writer.threads) - - -def test_stop_multiprocessing(): - writer = AsyncImageWriter(num_processes=2, num_threads=2) - writer.stop() - assert not any(p.is_alive() for p in writer.processes) - - -def test_multiple_stops(): - writer = AsyncImageWriter() - writer.stop() - writer.stop() # Should not raise an exception - assert not any(t.is_alive() for t in writer.threads) - - -def test_multiple_stops_multiprocessing(): - writer = AsyncImageWriter(num_processes=2, num_threads=2) - writer.stop() - writer.stop() # Should not raise an exception - assert not any(t.is_alive() for t in writer.threads) - - -def test_wait_until_done(tmp_path, img_array_factory): - writer = AsyncImageWriter(num_processes=0, num_threads=4) - try: - num_images = 100 - image_arrays = [img_array_factory(height=500, width=500) for _ in range(num_images)] - fpaths = [tmp_path / f"frame_{i:06d}.png" for i in range(num_images)] - for image_array, fpath in zip(image_arrays, fpaths, strict=True): - fpath.parent.mkdir(parents=True, exist_ok=True) - writer.save_image(image_array, fpath) - writer.wait_until_done() - for i, fpath in enumerate(fpaths): - assert fpath.exists() - saved_image = np.array(Image.open(fpath)) - assert np.array_equal(saved_image, image_arrays[i]) - finally: - writer.stop() - - -def test_wait_until_done_multiprocessing(tmp_path, img_array_factory): - writer = AsyncImageWriter(num_processes=2, num_threads=2) - try: - num_images = 100 - image_arrays = [img_array_factory() for _ in range(num_images)] - fpaths = [tmp_path / f"frame_{i:06d}.png" for i in range(num_images)] - for image_array, fpath in zip(image_arrays, fpaths, strict=True): - fpath.parent.mkdir(parents=True, exist_ok=True) - writer.save_image(image_array, fpath) - writer.wait_until_done() - for i, fpath in enumerate(fpaths): - assert fpath.exists() - saved_image = np.array(Image.open(fpath)) - assert np.array_equal(saved_image, image_arrays[i]) - finally: - writer.stop() - - -def test_exception_handling(tmp_path, img_array_factory): - writer = AsyncImageWriter() - try: - image_array = img_array_factory() - with ( - patch.object(writer.queue, "put", side_effect=queue.Full("Queue is full")), - pytest.raises(queue.Full) as exc_info, - ): - writer.save_image(image_array, tmp_path / "test.png") - assert str(exc_info.value) == "Queue is full" - finally: - writer.stop() - - -def test_with_different_image_formats(tmp_path, img_array_factory): - writer = AsyncImageWriter() - try: - image_array = img_array_factory() - formats = ["png", "jpeg", "bmp"] - for fmt in formats: - fpath = tmp_path / f"test_image.{fmt}" - write_image(image_array, fpath) - assert fpath.exists() - finally: - writer.stop() - - -def test_safe_stop_image_writer_decorator(): - class MockDataset: - def __init__(self): - self.image_writer = MagicMock(spec=AsyncImageWriter) - - @safe_stop_image_writer - def function_that_raises_exception(dataset=None): - raise Exception("Test exception") - - dataset = MockDataset() - - with pytest.raises(Exception) as exc_info: - function_that_raises_exception(dataset=dataset) - - assert str(exc_info.value) == "Test exception" - dataset.image_writer.stop.assert_called_once() - - -def test_main_process_time(tmp_path, img_tensor_factory): - writer = AsyncImageWriter() - try: - image_tensor = img_tensor_factory() - fpath = tmp_path / DUMMY_IMAGE - start_time = time.perf_counter() - writer.save_image(image_tensor, fpath) - end_time = time.perf_counter() - time_spent = end_time - start_time - # Might need to adjust this threshold depending on hardware - assert time_spent < 0.01, f"Main process time exceeded threshold: {time_spent}s" - writer.wait_until_done() - assert fpath.exists() - finally: - writer.stop() diff --git a/external/lerobot/tests/test_motors.py b/external/lerobot/tests/test_motors.py deleted file mode 100644 index 2f668926c4..0000000000 --- a/external/lerobot/tests/test_motors.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Tests for physical motors and their mocked versions. -If the physical motors are not connected to the computer, or not working, -the test will be skipped. - -Example of running a specific test: -```bash -pytest -sx tests/test_motors.py::test_find_port -pytest -sx tests/test_motors.py::test_motors_bus -``` - -Example of running test on real dynamixel motors connected to the computer: -```bash -pytest -sx 'tests/test_motors.py::test_motors_bus[dynamixel-False]' -``` - -Example of running test on a mocked version of dynamixel motors: -```bash -pytest -sx 'tests/test_motors.py::test_motors_bus[dynamixel-True]' -``` -""" - -# TODO(rcadene): measure fps in nightly? -# TODO(rcadene): test logs -# TODO(rcadene): test calibration -# TODO(rcadene): add compatibility with other motors bus - -import time - -import numpy as np -import pytest - -from lerobot.common.robot_devices.utils import RobotDeviceAlreadyConnectedError, RobotDeviceNotConnectedError -from lerobot.scripts.find_motors_bus_port import find_port -from tests.utils import TEST_MOTOR_TYPES, make_motors_bus, require_motor - - -@pytest.mark.parametrize("motor_type, mock", TEST_MOTOR_TYPES) -@require_motor -def test_find_port(request, motor_type, mock): - if mock: - request.getfixturevalue("patch_builtins_input") - with pytest.raises(OSError): - find_port() - else: - find_port() - - -@pytest.mark.parametrize("motor_type, mock", TEST_MOTOR_TYPES) -@require_motor -def test_configure_motors_all_ids_1(request, motor_type, mock): - if mock: - request.getfixturevalue("patch_builtins_input") - - if motor_type == "dynamixel": - # see X_SERIES_BAUDRATE_TABLE - smaller_baudrate = 9_600 - smaller_baudrate_value = 0 - elif motor_type == "feetech": - # see SCS_SERIES_BAUDRATE_TABLE - smaller_baudrate = 19_200 - smaller_baudrate_value = 7 - else: - raise ValueError(motor_type) - - input("Are you sure you want to re-configure the motors? Press enter to continue...") - # This test expect the configuration was already correct. - motors_bus = make_motors_bus(motor_type, mock=mock) - motors_bus.connect() - motors_bus.write("Baud_Rate", [smaller_baudrate_value] * len(motors_bus.motors)) - - motors_bus.set_bus_baudrate(smaller_baudrate) - motors_bus.write("ID", [1] * len(motors_bus.motors)) - del motors_bus - - # Test configure - motors_bus = make_motors_bus(motor_type, mock=mock) - motors_bus.connect() - assert motors_bus.are_motors_configured() - del motors_bus - - -@pytest.mark.parametrize("motor_type, mock", TEST_MOTOR_TYPES) -@require_motor -def test_motors_bus(request, motor_type, mock): - if mock: - request.getfixturevalue("patch_builtins_input") - - motors_bus = make_motors_bus(motor_type, mock=mock) - - # Test reading and writting before connecting raises an error - with pytest.raises(RobotDeviceNotConnectedError): - motors_bus.read("Torque_Enable") - with pytest.raises(RobotDeviceNotConnectedError): - motors_bus.write("Torque_Enable", 1) - with pytest.raises(RobotDeviceNotConnectedError): - motors_bus.disconnect() - - # Test deleting the object without connecting first - del motors_bus - - # Test connecting - motors_bus = make_motors_bus(motor_type, mock=mock) - motors_bus.connect() - - # Test connecting twice raises an error - with pytest.raises(RobotDeviceAlreadyConnectedError): - motors_bus.connect() - - # Test disabling torque and reading torque on all motors - motors_bus.write("Torque_Enable", 0) - values = motors_bus.read("Torque_Enable") - assert isinstance(values, np.ndarray) - assert len(values) == len(motors_bus.motors) - assert (values == 0).all() - - # Test writing torque on a specific motor - motors_bus.write("Torque_Enable", 1, "gripper") - - # Test reading torque from this specific motor. It is now 1 - values = motors_bus.read("Torque_Enable", "gripper") - assert len(values) == 1 - assert values[0] == 1 - - # Test reading torque from all motors. It is 1 for the specific motor, - # and 0 on the others. - values = motors_bus.read("Torque_Enable") - gripper_index = motors_bus.motor_names.index("gripper") - assert values[gripper_index] == 1 - assert values.sum() == 1 # gripper is the only motor to have torque 1 - - # Test writing torque on all motors and it is 1 for all. - motors_bus.write("Torque_Enable", 1) - values = motors_bus.read("Torque_Enable") - assert (values == 1).all() - - # Test ordering the motors to move slightly (+1 value among 4096) and this move - # can be executed and seen by the motor position sensor - values = motors_bus.read("Present_Position") - motors_bus.write("Goal_Position", values + 1) - # Give time for the motors to move to the goal position - time.sleep(1) - new_values = motors_bus.read("Present_Position") - assert (new_values == values).all() diff --git a/external/lerobot/tests/test_online_buffer.py b/external/lerobot/tests/test_online_buffer.py deleted file mode 100644 index db53808d2a..0000000000 --- a/external/lerobot/tests/test_online_buffer.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License.d -from copy import deepcopy -from uuid import uuid4 - -import numpy as np -import pytest -import torch - -from lerobot.common.datasets.online_buffer import OnlineBuffer, compute_sampler_weights - -# Some constants for OnlineBuffer tests. -data_key = "data" -data_shape = (2, 3) # just some arbitrary > 1D shape -buffer_capacity = 100 -fps = 10 - - -def make_new_buffer( - write_dir: str | None = None, delta_timestamps: dict[str, list[float]] | None = None -) -> tuple[OnlineBuffer, str]: - if write_dir is None: - write_dir = f"/tmp/online_buffer_{uuid4().hex}" - buffer = OnlineBuffer( - write_dir, - data_spec={data_key: {"shape": data_shape, "dtype": np.dtype("float32")}}, - buffer_capacity=buffer_capacity, - fps=fps, - delta_timestamps=delta_timestamps, - ) - return buffer, write_dir - - -def make_spoof_data_frames(n_episodes: int, n_frames_per_episode: int) -> dict[str, np.ndarray]: - new_data = { - data_key: np.arange(n_frames_per_episode * n_episodes * np.prod(data_shape)).reshape(-1, *data_shape), - OnlineBuffer.INDEX_KEY: np.arange(n_frames_per_episode * n_episodes), - OnlineBuffer.EPISODE_INDEX_KEY: np.repeat(np.arange(n_episodes), n_frames_per_episode), - OnlineBuffer.FRAME_INDEX_KEY: np.tile(np.arange(n_frames_per_episode), n_episodes), - OnlineBuffer.TIMESTAMP_KEY: np.tile(np.arange(n_frames_per_episode) / fps, n_episodes), - } - return new_data - - -def test_non_mutate(): - """Checks that the data provided to the add_data method is copied rather than passed by reference. - - This means that mutating the data in the buffer does not mutate the original data. - - NOTE: If this test fails, it means some of the other tests may be compromised. For example, we can't trust - a success case for `test_write_read`. - """ - buffer, _ = make_new_buffer() - new_data = make_spoof_data_frames(2, buffer_capacity // 4) - new_data_copy = deepcopy(new_data) - buffer.add_data(new_data) - buffer._data[data_key][:] += 1 - assert all(np.array_equal(new_data[k], new_data_copy[k]) for k in new_data) - - -def test_index_error_no_data(): - buffer, _ = make_new_buffer() - with pytest.raises(IndexError): - buffer[0] - - -def test_index_error_with_data(): - buffer, _ = make_new_buffer() - n_frames = buffer_capacity // 2 - new_data = make_spoof_data_frames(1, n_frames) - buffer.add_data(new_data) - with pytest.raises(IndexError): - buffer[n_frames] - with pytest.raises(IndexError): - buffer[-n_frames - 1] - - -@pytest.mark.parametrize("do_reload", [False, True]) -def test_write_read(do_reload: bool): - """Checks that data can be added to the buffer and read back. - - If do_reload we delete the buffer object and load the buffer back from disk before reading. - """ - buffer, write_dir = make_new_buffer() - n_episodes = 2 - n_frames_per_episode = buffer_capacity // 4 - new_data = make_spoof_data_frames(n_episodes, n_frames_per_episode) - buffer.add_data(new_data) - - if do_reload: - del buffer - buffer, _ = make_new_buffer(write_dir) - - assert len(buffer) == n_frames_per_episode * n_episodes - for i, item in enumerate(buffer): - assert all(isinstance(item[k], torch.Tensor) for k in item) - assert np.array_equal(item[data_key].numpy(), new_data[data_key][i]) - - -def test_read_data_key(): - """Tests that data can be added to a buffer and all data for a. specific key can be read back.""" - buffer, _ = make_new_buffer() - n_episodes = 2 - n_frames_per_episode = buffer_capacity // 4 - new_data = make_spoof_data_frames(n_episodes, n_frames_per_episode) - buffer.add_data(new_data) - - data_from_buffer = buffer.get_data_by_key(data_key) - assert isinstance(data_from_buffer, torch.Tensor) - assert np.array_equal(data_from_buffer.numpy(), new_data[data_key]) - - -def test_fifo(): - """Checks that if data is added beyond the buffer capacity, we discard the oldest data first.""" - buffer, _ = make_new_buffer() - n_frames_per_episode = buffer_capacity // 4 - n_episodes = 3 - new_data = make_spoof_data_frames(n_episodes, n_frames_per_episode) - buffer.add_data(new_data) - n_more_episodes = 2 - # Developer sanity check (in case someone changes the global `buffer_capacity`). - assert (n_episodes + n_more_episodes) * n_frames_per_episode > buffer_capacity, ( - "Something went wrong with the test code." - ) - more_new_data = make_spoof_data_frames(n_more_episodes, n_frames_per_episode) - buffer.add_data(more_new_data) - assert len(buffer) == buffer_capacity, "The buffer should be full." - - expected_data = {} - for k in new_data: - # Concatenate, left-truncate, then roll, to imitate the cyclical FIFO pattern in OnlineBuffer. - expected_data[k] = np.roll( - np.concatenate([new_data[k], more_new_data[k]])[-buffer_capacity:], - shift=len(new_data[k]) + len(more_new_data[k]) - buffer_capacity, - axis=0, - ) - - for i, item in enumerate(buffer): - assert all(isinstance(item[k], torch.Tensor) for k in item) - assert np.array_equal(item[data_key].numpy(), expected_data[data_key][i]) - - -def test_delta_timestamps_within_tolerance(): - """Check that getting an item with delta_timestamps within tolerance succeeds. - - Note: Copied from `test_datasets.py::test_load_previous_and_future_frames_within_tolerance`. - """ - # Sanity check on global fps as we are assuming it is 10 here. - assert fps == 10, "This test assumes fps==10" - buffer, _ = make_new_buffer(delta_timestamps={"index": [-0.2, 0, 0.139]}) - new_data = make_spoof_data_frames(n_episodes=1, n_frames_per_episode=5) - buffer.add_data(new_data) - buffer.tolerance_s = 0.04 - item = buffer[2] - data, is_pad = item["index"], item[f"index{OnlineBuffer.IS_PAD_POSTFIX}"] - assert torch.allclose(data, torch.tensor([0, 2, 3])), "Data does not match expected values" - assert not is_pad.any(), "Unexpected padding detected" - - -def test_delta_timestamps_outside_tolerance_inside_episode_range(): - """Check that getting an item with delta_timestamps outside of tolerance fails. - - We expect it to fail if and only if the requested timestamps are within the episode range. - - Note: Copied from - `test_datasets.py::test_load_previous_and_future_frames_outside_tolerance_inside_episode_range` - """ - # Sanity check on global fps as we are assuming it is 10 here. - assert fps == 10, "This test assumes fps==10" - buffer, _ = make_new_buffer(delta_timestamps={"index": [-0.2, 0, 0.141]}) - new_data = make_spoof_data_frames(n_episodes=1, n_frames_per_episode=5) - buffer.add_data(new_data) - buffer.tolerance_s = 0.04 - with pytest.raises(AssertionError): - buffer[2] - - -def test_delta_timestamps_outside_tolerance_outside_episode_range(): - """Check that copy-padding of timestamps outside of the episode range works. - - Note: Copied from - `test_datasets.py::test_load_previous_and_future_frames_outside_tolerance_outside_episode_range` - """ - # Sanity check on global fps as we are assuming it is 10 here. - assert fps == 10, "This test assumes fps==10" - buffer, _ = make_new_buffer(delta_timestamps={"index": [-0.3, -0.24, 0, 0.26, 0.3]}) - new_data = make_spoof_data_frames(n_episodes=1, n_frames_per_episode=5) - buffer.add_data(new_data) - buffer.tolerance_s = 0.04 - item = buffer[2] - data, is_pad = item["index"], item["index_is_pad"] - assert torch.equal(data, torch.tensor([0, 0, 2, 4, 4])), "Data does not match expected values" - assert torch.equal(is_pad, torch.tensor([True, False, False, True, True])), ( - "Padding does not match expected values" - ) - - -# Arbitrarily set small dataset sizes, making sure to have uneven sizes. -@pytest.mark.parametrize("offline_dataset_size", [1, 6]) -@pytest.mark.parametrize("online_dataset_size", [0, 4]) -@pytest.mark.parametrize("online_sampling_ratio", [0.0, 1.0]) -def test_compute_sampler_weights_trivial( - lerobot_dataset_factory, - tmp_path, - offline_dataset_size: int, - online_dataset_size: int, - online_sampling_ratio: float, -): - offline_dataset = lerobot_dataset_factory(tmp_path, total_episodes=1, total_frames=offline_dataset_size) - online_dataset, _ = make_new_buffer() - if online_dataset_size > 0: - online_dataset.add_data( - make_spoof_data_frames(n_episodes=2, n_frames_per_episode=online_dataset_size // 2) - ) - - weights = compute_sampler_weights( - offline_dataset, online_dataset=online_dataset, online_sampling_ratio=online_sampling_ratio - ) - if offline_dataset_size == 0 or online_dataset_size == 0: - expected_weights = torch.ones(offline_dataset_size + online_dataset_size) - elif online_sampling_ratio == 0: - expected_weights = torch.cat([torch.ones(offline_dataset_size), torch.zeros(online_dataset_size)]) - elif online_sampling_ratio == 1: - expected_weights = torch.cat([torch.zeros(offline_dataset_size), torch.ones(online_dataset_size)]) - expected_weights /= expected_weights.sum() - assert torch.allclose(weights, expected_weights) - - -def test_compute_sampler_weights_nontrivial_ratio(lerobot_dataset_factory, tmp_path): - # Arbitrarily set small dataset sizes, making sure to have uneven sizes. - offline_dataset = lerobot_dataset_factory(tmp_path, total_episodes=1, total_frames=4) - online_dataset, _ = make_new_buffer() - online_dataset.add_data(make_spoof_data_frames(n_episodes=4, n_frames_per_episode=2)) - online_sampling_ratio = 0.8 - weights = compute_sampler_weights( - offline_dataset, online_dataset=online_dataset, online_sampling_ratio=online_sampling_ratio - ) - assert torch.allclose( - weights, torch.tensor([0.05, 0.05, 0.05, 0.05, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]) - ) - - -def test_compute_sampler_weights_nontrivial_ratio_and_drop_last_n(lerobot_dataset_factory, tmp_path): - # Arbitrarily set small dataset sizes, making sure to have uneven sizes. - offline_dataset = lerobot_dataset_factory(tmp_path, total_episodes=1, total_frames=4) - online_dataset, _ = make_new_buffer() - online_dataset.add_data(make_spoof_data_frames(n_episodes=4, n_frames_per_episode=2)) - weights = compute_sampler_weights( - offline_dataset, online_dataset=online_dataset, online_sampling_ratio=0.8, online_drop_n_last_frames=1 - ) - assert torch.allclose( - weights, torch.tensor([0.05, 0.05, 0.05, 0.05, 0.2, 0.0, 0.2, 0.0, 0.2, 0.0, 0.2, 0.0]) - ) - - -def test_compute_sampler_weights_drop_n_last_frames(lerobot_dataset_factory, tmp_path): - """Note: test copied from test_sampler.""" - offline_dataset = lerobot_dataset_factory(tmp_path, total_episodes=1, total_frames=2) - online_dataset, _ = make_new_buffer() - online_dataset.add_data(make_spoof_data_frames(n_episodes=4, n_frames_per_episode=2)) - - weights = compute_sampler_weights( - offline_dataset, - offline_drop_n_last_frames=1, - online_dataset=online_dataset, - online_sampling_ratio=0.5, - online_drop_n_last_frames=1, - ) - assert torch.allclose(weights, torch.tensor([0.5, 0, 0.125, 0, 0.125, 0, 0.125, 0, 0.125, 0])) diff --git a/external/lerobot/tests/test_policies.py b/external/lerobot/tests/test_policies.py deleted file mode 100644 index 89644e784e..0000000000 --- a/external/lerobot/tests/test_policies.py +++ /dev/null @@ -1,468 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import inspect -from copy import deepcopy -from pathlib import Path - -import einops -import pytest -import torch -from huggingface_hub import PyTorchModelHubMixin -from safetensors.torch import load_file - -from lerobot import available_policies -from lerobot.common.datasets.factory import make_dataset -from lerobot.common.datasets.utils import cycle -from lerobot.common.envs.factory import make_env -from lerobot.common.envs.utils import preprocess_observation -from lerobot.common.policies.act.modeling_act import ACTTemporalEnsembler -from lerobot.common.policies.factory import ( - _policy_cfg_from_hydra_cfg, - get_policy_and_config_classes, - make_policy, -) -from lerobot.common.policies.normalize import Normalize, Unnormalize -from lerobot.common.policies.policy_protocol import Policy -from lerobot.common.utils.utils import init_hydra_config, seeded_context -from lerobot.scripts.train import make_optimizer_and_scheduler -from tests.scripts.save_policy_to_safetensors import get_policy_stats -from tests.utils import DEFAULT_CONFIG_PATH, DEVICE, require_cpu, require_env, require_x86_64_kernel - - -@pytest.mark.parametrize("policy_name", available_policies) -def test_get_policy_and_config_classes(policy_name: str): - """Check that the correct policy and config classes are returned.""" - policy_cls, config_cls = get_policy_and_config_classes(policy_name) - assert policy_cls.name == policy_name - assert issubclass(config_cls, inspect.signature(policy_cls.__init__).parameters["config"].annotation) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@pytest.mark.parametrize( - "env_name,policy_name,extra_overrides", - [ - ("xarm", "tdmpc", ["policy.use_mpc=true", "dataset_repo_id=lerobot/xarm_lift_medium"]), - ("pusht", "diffusion", []), - ("pusht", "vqbet", []), - ("aloha", "act", ["env.task=AlohaInsertion-v0", "dataset_repo_id=lerobot/aloha_sim_insertion_human"]), - ( - "aloha", - "act", - ["env.task=AlohaInsertion-v0", "dataset_repo_id=lerobot/aloha_sim_insertion_scripted"], - ), - ( - "aloha", - "act", - ["env.task=AlohaTransferCube-v0", "dataset_repo_id=lerobot/aloha_sim_transfer_cube_human"], - ), - ( - "aloha", - "act", - ["env.task=AlohaTransferCube-v0", "dataset_repo_id=lerobot/aloha_sim_transfer_cube_scripted"], - ), - # Note: these parameters also need custom logic in the test function for overriding the Hydra config. - ( - "aloha", - "diffusion", - ["env.task=AlohaInsertion-v0", "dataset_repo_id=lerobot/aloha_sim_insertion_human"], - ), - # Note: these parameters also need custom logic in the test function for overriding the Hydra config. - ("pusht", "act", ["env.task=PushT-v0", "dataset_repo_id=lerobot/pusht"]), - ("dora_aloha_real", "act_real", []), - ("dora_aloha_real", "act_real_no_state", []), - ], -) -@require_env -def test_policy(env_name, policy_name, extra_overrides): - """ - Tests: - - Making the policy object. - - Checking that the policy follows the correct protocol and subclasses nn.Module - and PyTorchModelHubMixin. - - Updating the policy. - - Using the policy to select actions at inference time. - - Test the action can be applied to the policy - - Note: We test various combinations of policy and dataset. The combinations are by no means exhaustive, - and for now we add tests as we see fit. - """ - cfg = init_hydra_config( - DEFAULT_CONFIG_PATH, - overrides=[ - f"env={env_name}", - f"policy={policy_name}", - f"device={DEVICE}", - ] - + extra_overrides, - ) - - # Additional config override logic. - if env_name == "aloha" and policy_name == "diffusion": - for keys in [ - ("training", "delta_timestamps"), - ("policy", "input_shapes"), - ("policy", "input_normalization_modes"), - ]: - dct = dict(cfg[keys[0]][keys[1]]) - dct["observation.images.top"] = dct["observation.image"] - del dct["observation.image"] - cfg[keys[0]][keys[1]] = dct - cfg.override_dataset_stats = None - - # Additional config override logic. - if env_name == "pusht" and policy_name == "act": - for keys in [ - ("policy", "input_shapes"), - ("policy", "input_normalization_modes"), - ]: - dct = dict(cfg[keys[0]][keys[1]]) - dct["observation.image"] = dct["observation.images.top"] - del dct["observation.images.top"] - cfg[keys[0]][keys[1]] = dct - cfg.override_dataset_stats = None - - # Check that we can make the policy object. - dataset = make_dataset(cfg) - policy = make_policy(hydra_cfg=cfg, dataset_stats=dataset.meta.stats) - # Check that the policy follows the required protocol. - assert isinstance(policy, Policy), ( - f"The policy does not follow the required protocol. Please see {Policy.__module__}.{Policy.__name__}." - ) - assert isinstance(policy, torch.nn.Module) - assert isinstance(policy, PyTorchModelHubMixin) - - # Check that we run select_actions and get the appropriate output. - env = make_env(cfg, n_envs=2) - - dataloader = torch.utils.data.DataLoader( - dataset, - num_workers=0, - batch_size=2, - shuffle=True, - pin_memory=DEVICE != "cpu", - drop_last=True, - ) - dl_iter = cycle(dataloader) - - batch = next(dl_iter) - - for key in batch: - batch[key] = batch[key].to(DEVICE, non_blocking=True) - - # Test updating the policy (and test that it does not mutate the batch) - batch_ = deepcopy(batch) - policy.forward(batch) - assert set(batch) == set(batch_), "Batch keys are not the same after a forward pass." - assert all(torch.equal(batch[k], batch_[k]) for k in batch), ( - "Batch values are not the same after a forward pass." - ) - - # reset the policy and environment - policy.reset() - observation, _ = env.reset(seed=cfg.seed) - - # apply transform to normalize the observations - observation = preprocess_observation(observation) - - # send observation to device/gpu - observation = {key: observation[key].to(DEVICE, non_blocking=True) for key in observation} - - # get the next action for the environment (also check that the observation batch is not modified) - observation_ = deepcopy(observation) - with torch.inference_mode(): - action = policy.select_action(observation).cpu().numpy() - assert set(observation) == set(observation_), ( - "Observation batch keys are not the same after a forward pass." - ) - assert all(torch.equal(observation[k], observation_[k]) for k in observation), ( - "Observation batch values are not the same after a forward pass." - ) - - # Test step through policy - env.step(action) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -def test_act_backbone_lr(): - """ - Test that the ACT policy can be instantiated with a different learning rate for the backbone. - """ - cfg = init_hydra_config( - DEFAULT_CONFIG_PATH, - overrides=[ - "env=aloha", - "policy=act", - f"device={DEVICE}", - "training.lr_backbone=0.001", - "training.lr=0.01", - ], - ) - assert cfg.training.lr == 0.01 - assert cfg.training.lr_backbone == 0.001 - - dataset = make_dataset(cfg) - policy = make_policy(hydra_cfg=cfg, dataset_stats=dataset.meta.stats) - optimizer, _ = make_optimizer_and_scheduler(cfg, policy) - assert len(optimizer.param_groups) == 2 - assert optimizer.param_groups[0]["lr"] == cfg.training.lr - assert optimizer.param_groups[1]["lr"] == cfg.training.lr_backbone - assert len(optimizer.param_groups[0]["params"]) == 133 - assert len(optimizer.param_groups[1]["params"]) == 20 - - -@pytest.mark.parametrize("policy_name", available_policies) -def test_policy_defaults(policy_name: str): - """Check that the policy can be instantiated with defaults.""" - policy_cls, _ = get_policy_and_config_classes(policy_name) - policy_cls() - - -@pytest.mark.parametrize( - "env_name,policy_name", - [ - ("xarm", "tdmpc"), - ("pusht", "diffusion"), - ("aloha", "act"), - ], -) -def test_yaml_matches_dataclass(env_name: str, policy_name: str): - """Check that dataclass configs match their respective yaml configs.""" - hydra_cfg = init_hydra_config(DEFAULT_CONFIG_PATH, overrides=[f"env={env_name}", f"policy={policy_name}"]) - _, policy_cfg_cls = get_policy_and_config_classes(policy_name) - policy_cfg_from_hydra = _policy_cfg_from_hydra_cfg(policy_cfg_cls, hydra_cfg) - policy_cfg_from_dataclass = policy_cfg_cls() - assert policy_cfg_from_hydra == policy_cfg_from_dataclass - - -@pytest.mark.parametrize("policy_name", available_policies) -def test_save_and_load_pretrained(policy_name: str): - policy_cls, _ = get_policy_and_config_classes(policy_name) - policy: Policy = policy_cls() - save_dir = "/tmp/test_save_and_load_pretrained_{policy_cls.__name__}" - policy.save_pretrained(save_dir) - policy_ = policy_cls.from_pretrained(save_dir) - assert all(torch.equal(p, p_) for p, p_ in zip(policy.parameters(), policy_.parameters(), strict=True)) - - -@pytest.mark.parametrize("insert_temporal_dim", [False, True]) -def test_normalize(insert_temporal_dim): - """ - Test that normalize/unnormalize can run without exceptions when properly set up, and that they raise - an exception when the forward pass is called without the stats having been provided. - - TODO(rcadene, alexander-soare): This should also test that the normalization / unnormalization works as - expected. - """ - - input_shapes = { - "observation.image": [3, 96, 96], - "observation.state": [10], - } - output_shapes = { - "action": [5], - } - - normalize_input_modes = { - "observation.image": "mean_std", - "observation.state": "min_max", - } - unnormalize_output_modes = { - "action": "min_max", - } - - dataset_stats = { - "observation.image": { - "mean": torch.randn(3, 1, 1), - "std": torch.randn(3, 1, 1), - "min": torch.randn(3, 1, 1), - "max": torch.randn(3, 1, 1), - }, - "observation.state": { - "mean": torch.randn(10), - "std": torch.randn(10), - "min": torch.randn(10), - "max": torch.randn(10), - }, - "action": { - "mean": torch.randn(5), - "std": torch.randn(5), - "min": torch.randn(5), - "max": torch.randn(5), - }, - } - - bsize = 2 - input_batch = { - "observation.image": torch.randn(bsize, 3, 96, 96), - "observation.state": torch.randn(bsize, 10), - } - output_batch = { - "action": torch.randn(bsize, 5), - } - - if insert_temporal_dim: - tdim = 4 - - for key in input_batch: - # [2,3,96,96] -> [2,tdim,3,96,96] - input_batch[key] = torch.stack([input_batch[key]] * tdim, dim=1) - - for key in output_batch: - output_batch[key] = torch.stack([output_batch[key]] * tdim, dim=1) - - # test without stats - normalize = Normalize(input_shapes, normalize_input_modes, stats=None) - with pytest.raises(AssertionError): - normalize(input_batch) - - # test with stats - normalize = Normalize(input_shapes, normalize_input_modes, stats=dataset_stats) - normalize(input_batch) - - # test loading pretrained models - new_normalize = Normalize(input_shapes, normalize_input_modes, stats=None) - new_normalize.load_state_dict(normalize.state_dict()) - new_normalize(input_batch) - - # test without stats - unnormalize = Unnormalize(output_shapes, unnormalize_output_modes, stats=None) - with pytest.raises(AssertionError): - unnormalize(output_batch) - - # test with stats - unnormalize = Unnormalize(output_shapes, unnormalize_output_modes, stats=dataset_stats) - unnormalize(output_batch) - - # test loading pretrained models - new_unnormalize = Unnormalize(output_shapes, unnormalize_output_modes, stats=None) - new_unnormalize.load_state_dict(unnormalize.state_dict()) - unnormalize(output_batch) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@pytest.mark.parametrize( - "env_name, policy_name, extra_overrides, file_name_extra", - [ - # TODO(alexander-soare): `policy.use_mpc=false` was previously the default in the config yaml but it - # was changed to true. For some reason, tests would pass locally, but not in CI. So here we override - # to test with `policy.use_mpc=false`. - ("xarm", "tdmpc", ["policy.use_mpc=false"], "use_policy"), - # ("xarm", "tdmpc", ["policy.use_mpc=true"], "use_mpc"), - ( - "pusht", - "diffusion", - ["policy.n_action_steps=8", "policy.num_inference_steps=10", "policy.down_dims=[128, 256, 512]"], - "", - ), - ("aloha", "act", ["policy.n_action_steps=10"], ""), - ("aloha", "act", ["policy.n_action_steps=1000", "policy.chunk_size=1000"], "_1000_steps"), - ("dora_aloha_real", "act_aloha_real", ["policy.n_action_steps=10"], ""), - ], -) -# As artifacts have been generated on an x86_64 kernel, this test won't -# pass if it's run on another platform due to floating point errors -@require_x86_64_kernel -@require_cpu -def test_backward_compatibility(env_name, policy_name, extra_overrides, file_name_extra): - """ - NOTE: If this test does not pass, and you have intentionally changed something in the policy: - 1. Inspect the differences in policy outputs and make sure you can account for them. Your PR should - include a report on what changed and how that affected the outputs. - 2. Go to the `if __name__ == "__main__"` block of `tests/scripts/save_policy_to_safetensors.py` and - add the policies you want to update the test artifacts for. - 3. Run `python tests/scripts/save_policy_to_safetensors.py`. The test artifact - should be updated. - 4. Check that this test now passes. - 5. Remember to restore `tests/scripts/save_policy_to_safetensors.py` to its original state. - 6. Remember to stage and commit the resulting changes to `tests/data`. - """ - env_policy_dir = ( - Path("tests/data/save_policy_to_safetensors") / f"{env_name}_{policy_name}{file_name_extra}" - ) - saved_output_dict = load_file(env_policy_dir / "output_dict.safetensors") - saved_grad_stats = load_file(env_policy_dir / "grad_stats.safetensors") - saved_param_stats = load_file(env_policy_dir / "param_stats.safetensors") - saved_actions = load_file(env_policy_dir / "actions.safetensors") - - output_dict, grad_stats, param_stats, actions = get_policy_stats(env_name, policy_name, extra_overrides) - - for key in saved_output_dict: - assert torch.isclose(output_dict[key], saved_output_dict[key], rtol=0.1, atol=1e-7).all() - for key in saved_grad_stats: - assert torch.isclose(grad_stats[key], saved_grad_stats[key], rtol=0.1, atol=1e-7).all() - for key in saved_param_stats: - assert torch.isclose(param_stats[key], saved_param_stats[key], rtol=50, atol=1e-7).all() - for key in saved_actions: - assert torch.isclose(actions[key], saved_actions[key], rtol=0.1, atol=1e-7).all() - - -def test_act_temporal_ensembler(): - """Check that the online method in ACTTemporalEnsembler matches a simple offline calculation.""" - temporal_ensemble_coeff = 0.01 - chunk_size = 100 - episode_length = 101 - ensembler = ACTTemporalEnsembler(temporal_ensemble_coeff, chunk_size) - # An batch of arbitrary sequences of 1D actions we wish to compute the average over. We'll keep the - # "action space" in [-1, 1]. Apart from that, there is no real reason for the numbers chosen. - with seeded_context(0): - # Dimension is (batch, episode_length, chunk_size, action_dim(=1)) - # Stepping through the episode_length dim is like running inference at each rollout step and getting - # a different action chunk. - batch_seq = torch.stack( - [ - torch.rand(episode_length, chunk_size) * 0.05 - 0.6, - torch.rand(episode_length, chunk_size) * 0.02 - 0.01, - torch.rand(episode_length, chunk_size) * 0.2 + 0.3, - ], - dim=0, - ).unsqueeze(-1) # unsqueeze for action dim - batch_size = batch_seq.shape[0] - # Exponential weighting (normalized). Unsqueeze once to match the position of the `episode_length` - # dimension of `batch_seq`. - weights = torch.exp(-temporal_ensemble_coeff * torch.arange(chunk_size)).unsqueeze(-1) - - # Simulate stepping through a rollout and computing a batch of actions with model on each step. - for i in range(episode_length): - # Mock a batch of actions. - actions = torch.zeros(size=(batch_size, chunk_size, 1)) + batch_seq[:, i] - online_avg = ensembler.update(actions) - # Simple offline calculation: avg = Σ(aᵢ*wᵢ) / Σ(wᵢ). - # Note: The complicated bit here is the slicing. Think about the (episode_length, chunk_size) grid. - # What we want to do is take diagonal slices across it starting from the left. - # eg: chunk_size=4, episode_length=6 - # ┌───────┐ - # │0 1 2 3│ - # │1 2 3 4│ - # │2 3 4 5│ - # │3 4 5 6│ - # │4 5 6 7│ - # │5 6 7 8│ - # └───────┘ - chunk_indices = torch.arange(min(i, chunk_size - 1), -1, -1) - episode_step_indices = torch.arange(i + 1)[-len(chunk_indices) :] - seq_slice = batch_seq[:, episode_step_indices, chunk_indices] - offline_avg = ( - einops.reduce(seq_slice * weights[: i + 1], "b s 1 -> b 1", "sum") / weights[: i + 1].sum() - ) - # Sanity check. The average should be between the extrema. - assert torch.all(einops.reduce(seq_slice, "b s 1 -> b 1", "min") <= offline_avg) - assert torch.all(offline_avg <= einops.reduce(seq_slice, "b s 1 -> b 1", "max")) - # Selected atol=1e-4 keeping in mind actions in [-1, 1] and excepting 0.01% error. - assert torch.allclose(online_avg, offline_avg, atol=1e-4) - - -if __name__ == "__main__": - test_act_temporal_ensembler() diff --git a/external/lerobot/tests/test_push_dataset_to_hub.py b/external/lerobot/tests/test_push_dataset_to_hub.py deleted file mode 100644 index ff630ab66c..0000000000 --- a/external/lerobot/tests/test_push_dataset_to_hub.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -This file contains generic tests to ensure that nothing breaks if we modify the push_dataset_to_hub API. -Also, this file contains backward compatibility tests. Because they are slow and require to download the raw datasets, -we skip them for now in our CI. - -Example to run backward compatiblity tests locally: -``` -python -m pytest --run-skipped tests/test_push_dataset_to_hub.py::test_push_dataset_to_hub_pusht_backward_compatibility -``` -""" - -from pathlib import Path - -import numpy as np -import pytest -import torch - -from lerobot.common.datasets.lerobot_dataset import LeRobotDataset -from lerobot.common.datasets.push_dataset_to_hub.utils import save_images_concurrently -from lerobot.common.datasets.video_utils import encode_video_frames -from lerobot.scripts.push_dataset_to_hub import push_dataset_to_hub -from tests.utils import require_package_arg - - -def _mock_download_raw_pusht(raw_dir, num_frames=4, num_episodes=3): - import zarr - - raw_dir.mkdir(parents=True, exist_ok=True) - zarr_path = raw_dir / "pusht_cchi_v7_replay.zarr" - store = zarr.DirectoryStore(zarr_path) - zarr_data = zarr.group(store=store) - - zarr_data.create_dataset( - "data/action", shape=(num_frames, 1), chunks=(num_frames, 1), dtype=np.float32, overwrite=True - ) - zarr_data.create_dataset( - "data/img", - shape=(num_frames, 96, 96, 3), - chunks=(num_frames, 96, 96, 3), - dtype=np.uint8, - overwrite=True, - ) - zarr_data.create_dataset( - "data/n_contacts", shape=(num_frames, 2), chunks=(num_frames, 2), dtype=np.float32, overwrite=True - ) - zarr_data.create_dataset( - "data/state", shape=(num_frames, 5), chunks=(num_frames, 5), dtype=np.float32, overwrite=True - ) - zarr_data.create_dataset( - "data/keypoint", shape=(num_frames, 9, 2), chunks=(num_frames, 9, 2), dtype=np.float32, overwrite=True - ) - zarr_data.create_dataset( - "meta/episode_ends", shape=(num_episodes,), chunks=(num_episodes,), dtype=np.int32, overwrite=True - ) - - zarr_data["data/action"][:] = np.random.randn(num_frames, 1) - zarr_data["data/img"][:] = np.random.randint(0, 255, size=(num_frames, 96, 96, 3), dtype=np.uint8) - zarr_data["data/n_contacts"][:] = np.random.randn(num_frames, 2) - zarr_data["data/state"][:] = np.random.randn(num_frames, 5) - zarr_data["data/keypoint"][:] = np.random.randn(num_frames, 9, 2) - zarr_data["meta/episode_ends"][:] = np.array([1, 3, 4]) - - store.close() - - -def _mock_download_raw_umi(raw_dir, num_frames=4, num_episodes=3): - import zarr - - raw_dir.mkdir(parents=True, exist_ok=True) - zarr_path = raw_dir / "cup_in_the_wild.zarr" - store = zarr.DirectoryStore(zarr_path) - zarr_data = zarr.group(store=store) - - zarr_data.create_dataset( - "data/camera0_rgb", - shape=(num_frames, 96, 96, 3), - chunks=(num_frames, 96, 96, 3), - dtype=np.uint8, - overwrite=True, - ) - zarr_data.create_dataset( - "data/robot0_demo_end_pose", - shape=(num_frames, 5), - chunks=(num_frames, 5), - dtype=np.float32, - overwrite=True, - ) - zarr_data.create_dataset( - "data/robot0_demo_start_pose", - shape=(num_frames, 5), - chunks=(num_frames, 5), - dtype=np.float32, - overwrite=True, - ) - zarr_data.create_dataset( - "data/robot0_eef_pos", shape=(num_frames, 5), chunks=(num_frames, 5), dtype=np.float32, overwrite=True - ) - zarr_data.create_dataset( - "data/robot0_eef_rot_axis_angle", - shape=(num_frames, 5), - chunks=(num_frames, 5), - dtype=np.float32, - overwrite=True, - ) - zarr_data.create_dataset( - "data/robot0_gripper_width", - shape=(num_frames, 5), - chunks=(num_frames, 5), - dtype=np.float32, - overwrite=True, - ) - zarr_data.create_dataset( - "meta/episode_ends", shape=(num_episodes,), chunks=(num_episodes,), dtype=np.int32, overwrite=True - ) - - zarr_data["data/camera0_rgb"][:] = np.random.randint(0, 255, size=(num_frames, 96, 96, 3), dtype=np.uint8) - zarr_data["data/robot0_demo_end_pose"][:] = np.random.randn(num_frames, 5) - zarr_data["data/robot0_demo_start_pose"][:] = np.random.randn(num_frames, 5) - zarr_data["data/robot0_eef_pos"][:] = np.random.randn(num_frames, 5) - zarr_data["data/robot0_eef_rot_axis_angle"][:] = np.random.randn(num_frames, 5) - zarr_data["data/robot0_gripper_width"][:] = np.random.randn(num_frames, 5) - zarr_data["meta/episode_ends"][:] = np.array([1, 3, 4]) - - store.close() - - -def _mock_download_raw_xarm(raw_dir, num_frames=4): - import pickle - - dataset_dict = { - "observations": { - "rgb": np.random.randint(0, 255, size=(num_frames, 3, 84, 84), dtype=np.uint8), - "state": np.random.randn(num_frames, 4), - }, - "actions": np.random.randn(num_frames, 3), - "rewards": np.random.randn(num_frames), - "masks": np.random.randn(num_frames), - "dones": np.array([False, True, True, True]), - } - - raw_dir.mkdir(parents=True, exist_ok=True) - pkl_path = raw_dir / "buffer.pkl" - with open(pkl_path, "wb") as f: - pickle.dump(dataset_dict, f) - - -def _mock_download_raw_aloha(raw_dir, num_frames=6, num_episodes=3): - import h5py - - for ep_idx in range(num_episodes): - raw_dir.mkdir(parents=True, exist_ok=True) - path_h5 = raw_dir / f"episode_{ep_idx}.hdf5" - with h5py.File(str(path_h5), "w") as f: - f.create_dataset("action", data=np.random.randn(num_frames // num_episodes, 14)) - f.create_dataset("observations/qpos", data=np.random.randn(num_frames // num_episodes, 14)) - f.create_dataset("observations/qvel", data=np.random.randn(num_frames // num_episodes, 14)) - f.create_dataset( - "observations/images/top", - data=np.random.randint( - 0, 255, size=(num_frames // num_episodes, 480, 640, 3), dtype=np.uint8 - ), - ) - - -def _mock_download_raw_dora(raw_dir, num_frames=6, num_episodes=3, fps=30): - from datetime import datetime, timedelta, timezone - - import pandas - - def write_parquet(key, timestamps, values): - data = { - "timestamp_utc": timestamps, - key: values, - } - df = pandas.DataFrame(data) - raw_dir.mkdir(parents=True, exist_ok=True) - df.to_parquet(raw_dir / f"{key}.parquet", engine="pyarrow") - - episode_indices = [None, None, -1, None, None, -1, None, None, -1] - episode_indices_mapping = [0, 0, 0, 1, 1, 1, 2, 2, 2] - frame_indices = [0, 1, -1, 0, 1, -1, 0, 1, -1] - - cam_key = "observation.images.cam_high" - timestamps = [] - actions = [] - states = [] - frames = [] - # `+ num_episodes`` for buffer frames associated to episode_index=-1 - for i, frame_idx in enumerate(frame_indices): - t_utc = datetime.now(timezone.utc) + timedelta(seconds=i / fps) - action = np.random.randn(21).tolist() - state = np.random.randn(21).tolist() - ep_idx = episode_indices_mapping[i] - frame = [{"path": f"videos/{cam_key}_episode_{ep_idx:06d}.mp4", "timestamp": frame_idx / fps}] - timestamps.append(t_utc) - actions.append(action) - states.append(state) - frames.append(frame) - - write_parquet(cam_key, timestamps, frames) - write_parquet("observation.state", timestamps, states) - write_parquet("action", timestamps, actions) - write_parquet("episode_index", timestamps, episode_indices) - - # write fake mp4 file for each episode - for ep_idx in range(num_episodes): - imgs_array = np.random.randint(0, 255, size=(num_frames // num_episodes, 480, 640, 3), dtype=np.uint8) - - tmp_imgs_dir = raw_dir / "tmp_images" - save_images_concurrently(imgs_array, tmp_imgs_dir) - - fname = f"{cam_key}_episode_{ep_idx:06d}.mp4" - video_path = raw_dir / "videos" / fname - encode_video_frames(tmp_imgs_dir, video_path, fps, vcodec="libx264") - - -def _mock_download_raw(raw_dir, repo_id): - if "wrist_gripper" in repo_id: - _mock_download_raw_dora(raw_dir) - elif "aloha" in repo_id: - _mock_download_raw_aloha(raw_dir) - elif "pusht" in repo_id: - _mock_download_raw_pusht(raw_dir) - elif "xarm" in repo_id: - _mock_download_raw_xarm(raw_dir) - elif "umi" in repo_id: - _mock_download_raw_umi(raw_dir) - else: - raise ValueError(repo_id) - - -def test_push_dataset_to_hub_invalid_repo_id(tmpdir): - with pytest.raises(ValueError): - push_dataset_to_hub(Path(tmpdir), "raw_format", "invalid_repo_id") - - -def test_push_dataset_to_hub_out_dir_force_override_false(tmpdir): - tmpdir = Path(tmpdir) - out_dir = tmpdir / "out" - raw_dir = tmpdir / "raw" - # mkdir to skip download - raw_dir.mkdir(parents=True, exist_ok=True) - with pytest.raises(ValueError): - push_dataset_to_hub( - raw_dir=raw_dir, - raw_format="some_format", - repo_id="user/dataset", - local_dir=out_dir, - force_override=False, - ) - - -@pytest.mark.skip("TODO after v2 migration / removing hydra") -@pytest.mark.parametrize( - "required_packages, raw_format, repo_id, make_test_data", - [ - (["gym_pusht"], "pusht_zarr", "lerobot/pusht", False), - (["gym_pusht"], "pusht_zarr", "lerobot/pusht", True), - (None, "xarm_pkl", "lerobot/xarm_lift_medium", False), - (None, "aloha_hdf5", "lerobot/aloha_sim_insertion_scripted", False), - (["imagecodecs"], "umi_zarr", "lerobot/umi_cup_in_the_wild", False), - (None, "dora_parquet", "cadene/wrist_gripper", False), - ], -) -@require_package_arg -def test_push_dataset_to_hub_format(required_packages, tmpdir, raw_format, repo_id, make_test_data): - num_episodes = 3 - tmpdir = Path(tmpdir) - - raw_dir = tmpdir / f"{repo_id}_raw" - _mock_download_raw(raw_dir, repo_id) - - local_dir = tmpdir / repo_id - - lerobot_dataset = push_dataset_to_hub( - raw_dir=raw_dir, - raw_format=raw_format, - repo_id=repo_id, - push_to_hub=False, - local_dir=local_dir, - force_override=False, - cache_dir=tmpdir / "cache", - tests_data_dir=tmpdir / "tests/data" if make_test_data else None, - encoding={"vcodec": "libx264"}, - ) - - # minimal generic tests on the local directory containing LeRobotDataset - assert (local_dir / "meta_data" / "info.json").exists() - assert (local_dir / "meta_data" / "stats.safetensors").exists() - assert (local_dir / "meta_data" / "episode_data_index.safetensors").exists() - for i in range(num_episodes): - for cam_key in lerobot_dataset.camera_keys: - assert (local_dir / "videos" / f"{cam_key}_episode_{i:06d}.mp4").exists() - assert (local_dir / "train" / "dataset_info.json").exists() - assert (local_dir / "train" / "state.json").exists() - assert len(list((local_dir / "train").glob("*.arrow"))) > 0 - - # minimal generic tests on the item - item = lerobot_dataset[0] - assert "index" in item - assert "episode_index" in item - assert "timestamp" in item - for cam_key in lerobot_dataset.camera_keys: - assert cam_key in item - - if make_test_data: - # Check that only the first episode is selected. - test_dataset = LeRobotDataset(repo_id=repo_id, root=tmpdir / "tests/data") - num_frames = sum( - i == lerobot_dataset.hf_dataset["episode_index"][0] - for i in lerobot_dataset.hf_dataset["episode_index"] - ).item() - assert ( - test_dataset.hf_dataset["episode_index"] - == lerobot_dataset.hf_dataset["episode_index"][:num_frames] - ) - for k in ["from", "to"]: - assert torch.equal(test_dataset.episode_data_index[k], lerobot_dataset.episode_data_index[k][:1]) - - -@pytest.mark.parametrize( - "raw_format, repo_id", - [ - # TODO(rcadene): add raw dataset test artifacts - ("pusht_zarr", "lerobot/pusht"), - ("xarm_pkl", "lerobot/xarm_lift_medium"), - ("aloha_hdf5", "lerobot/aloha_sim_insertion_scripted"), - ("umi_zarr", "lerobot/umi_cup_in_the_wild"), - ("dora_parquet", "cadene/wrist_gripper"), - ], -) -@pytest.mark.skip( - "Not compatible with our CI since it downloads raw datasets. Run with `python -m pytest --run-skipped tests/test_push_dataset_to_hub.py::test_push_dataset_to_hub_pusht_backward_compatibility`" -) -def test_push_dataset_to_hub_pusht_backward_compatibility(tmpdir, raw_format, repo_id): - _, dataset_id = repo_id.split("/") - - tmpdir = Path(tmpdir) - raw_dir = tmpdir / f"{dataset_id}_raw" - local_dir = tmpdir / repo_id - - push_dataset_to_hub( - raw_dir=raw_dir, - raw_format=raw_format, - repo_id=repo_id, - push_to_hub=False, - local_dir=local_dir, - force_override=False, - cache_dir=tmpdir / "cache", - episodes=[0], - ) - - ds_actual = LeRobotDataset(repo_id, root=tmpdir) - ds_reference = LeRobotDataset(repo_id) - - assert len(ds_reference.hf_dataset) == len(ds_actual.hf_dataset) - - def check_same_items(item1, item2): - assert item1.keys() == item2.keys(), "Keys mismatch" - - for key in item1: - if isinstance(item1[key], torch.Tensor) and isinstance(item2[key], torch.Tensor): - assert torch.equal(item1[key], item2[key]), f"Mismatch found in key: {key}" - else: - assert item1[key] == item2[key], f"Mismatch found in key: {key}" - - for i in range(len(ds_reference.hf_dataset)): - item_reference = ds_reference.hf_dataset[i] - item_actual = ds_actual.hf_dataset[i] - check_same_items(item_reference, item_actual) diff --git a/external/lerobot/tests/test_robots.py b/external/lerobot/tests/test_robots.py deleted file mode 100644 index 05966ff15d..0000000000 --- a/external/lerobot/tests/test_robots.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Tests for physical robots and their mocked versions. -If the physical robots are not connected to the computer, or not working, -the test will be skipped. - -Example of running a specific test: -```bash -pytest -sx tests/test_robots.py::test_robot -``` - -Example of running test on real robots connected to the computer: -```bash -pytest -sx 'tests/test_robots.py::test_robot[koch-False]' -pytest -sx 'tests/test_robots.py::test_robot[koch_bimanual-False]' -pytest -sx 'tests/test_robots.py::test_robot[aloha-False]' -``` - -Example of running test on a mocked version of robots: -```bash -pytest -sx 'tests/test_robots.py::test_robot[koch-True]' -pytest -sx 'tests/test_robots.py::test_robot[koch_bimanual-True]' -pytest -sx 'tests/test_robots.py::test_robot[aloha-True]' -``` -""" - -from pathlib import Path - -import pytest -import torch - -from lerobot.common.robot_devices.robots.manipulator import ManipulatorRobot -from lerobot.common.robot_devices.utils import RobotDeviceAlreadyConnectedError, RobotDeviceNotConnectedError -from tests.utils import TEST_ROBOT_TYPES, make_robot, mock_calibration_dir, require_robot - - -@pytest.mark.parametrize("robot_type, mock", TEST_ROBOT_TYPES) -@require_robot -def test_robot(tmpdir, request, robot_type, mock): - # TODO(rcadene): measure fps in nightly? - # TODO(rcadene): test logs - # TODO(rcadene): add compatibility with other robots - robot_kwargs = {"robot_type": robot_type} - - if robot_type == "aloha" and mock: - # To simplify unit test, we do not rerun manual calibration for Aloha mock=True. - # Instead, we use the files from '.cache/calibration/aloha_default' - overrides_calibration_dir = None - else: - if mock: - request.getfixturevalue("patch_builtins_input") - - # Create an empty calibration directory to trigger manual calibration - tmpdir = Path(tmpdir) - calibration_dir = tmpdir / robot_type - overrides_calibration_dir = [f"calibration_dir={calibration_dir}"] - mock_calibration_dir(calibration_dir) - robot_kwargs["calibration_dir"] = calibration_dir - - # Test connecting without devices raises an error - robot = ManipulatorRobot(**robot_kwargs) - with pytest.raises(ValueError): - robot.connect() - del robot - - # Test using robot before connecting raises an error - robot = ManipulatorRobot(**robot_kwargs) - with pytest.raises(RobotDeviceNotConnectedError): - robot.teleop_step() - with pytest.raises(RobotDeviceNotConnectedError): - robot.teleop_step(record_data=True) - with pytest.raises(RobotDeviceNotConnectedError): - robot.capture_observation() - with pytest.raises(RobotDeviceNotConnectedError): - robot.send_action(None) - with pytest.raises(RobotDeviceNotConnectedError): - robot.disconnect() - - # Test deleting the object without connecting first - del robot - - # Test connecting (triggers manual calibration) - robot = make_robot(robot_type, overrides=overrides_calibration_dir, mock=mock) - robot.connect() - assert robot.is_connected - - # Test connecting twice raises an error - with pytest.raises(RobotDeviceAlreadyConnectedError): - robot.connect() - - # TODO(rcadene, aliberts): Test disconnecting with `__del__` instead of `disconnect` - # del robot - robot.disconnect() - - # Test teleop can run - robot = make_robot(robot_type, overrides=overrides_calibration_dir, mock=mock) - if overrides_calibration_dir is not None: - robot.calibration_dir = calibration_dir - robot.connect() - robot.teleop_step() - - # Test data recorded during teleop are well formated - observation, action = robot.teleop_step(record_data=True) - # State - assert "observation.state" in observation - assert isinstance(observation["observation.state"], torch.Tensor) - assert observation["observation.state"].ndim == 1 - dim_state = sum(len(robot.follower_arms[name].motors) for name in robot.follower_arms) - assert observation["observation.state"].shape[0] == dim_state - # Cameras - for name in robot.cameras: - assert f"observation.images.{name}" in observation - assert isinstance(observation[f"observation.images.{name}"], torch.Tensor) - assert observation[f"observation.images.{name}"].ndim == 3 - # Action - assert "action" in action - assert isinstance(action["action"], torch.Tensor) - assert action["action"].ndim == 1 - dim_action = sum(len(robot.follower_arms[name].motors) for name in robot.follower_arms) - assert action["action"].shape[0] == dim_action - # TODO(rcadene): test if observation and action data are returned as expected - - # Test capture_observation can run and observation returned are the same (since the arm didnt move) - captured_observation = robot.capture_observation() - assert set(captured_observation.keys()) == set(observation.keys()) - for name in captured_observation: - if "image" in name: - # TODO(rcadene): skipping image for now as it's challenging to assess equality between two consecutive frames - continue - assert torch.allclose(captured_observation[name], observation[name], atol=1) - assert captured_observation[name].shape == observation[name].shape - - # Test send_action can run - robot.send_action(action["action"]) - - # Test disconnecting - robot.disconnect() - assert not robot.is_connected - for name in robot.follower_arms: - assert not robot.follower_arms[name].is_connected - for name in robot.leader_arms: - assert not robot.leader_arms[name].is_connected - for name in robot.cameras: - assert not robot.cameras[name].is_connected diff --git a/external/lerobot/tests/test_sampler.py b/external/lerobot/tests/test_sampler.py deleted file mode 100644 index ee143f3765..0000000000 --- a/external/lerobot/tests/test_sampler.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from datasets import Dataset - -from lerobot.common.datasets.push_dataset_to_hub.utils import calculate_episode_data_index -from lerobot.common.datasets.sampler import EpisodeAwareSampler -from lerobot.common.datasets.utils import ( - hf_transform_to_torch, -) - - -def test_drop_n_first_frames(): - dataset = Dataset.from_dict( - { - "timestamp": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - "index": [0, 1, 2, 3, 4, 5], - "episode_index": [0, 0, 1, 2, 2, 2], - }, - ) - dataset.set_transform(hf_transform_to_torch) - episode_data_index = calculate_episode_data_index(dataset) - sampler = EpisodeAwareSampler(episode_data_index, drop_n_first_frames=1) - assert sampler.indices == [1, 4, 5] - assert len(sampler) == 3 - assert list(sampler) == [1, 4, 5] - - -def test_drop_n_last_frames(): - dataset = Dataset.from_dict( - { - "timestamp": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - "index": [0, 1, 2, 3, 4, 5], - "episode_index": [0, 0, 1, 2, 2, 2], - }, - ) - dataset.set_transform(hf_transform_to_torch) - episode_data_index = calculate_episode_data_index(dataset) - sampler = EpisodeAwareSampler(episode_data_index, drop_n_last_frames=1) - assert sampler.indices == [0, 3, 4] - assert len(sampler) == 3 - assert list(sampler) == [0, 3, 4] - - -def test_episode_indices_to_use(): - dataset = Dataset.from_dict( - { - "timestamp": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - "index": [0, 1, 2, 3, 4, 5], - "episode_index": [0, 0, 1, 2, 2, 2], - }, - ) - dataset.set_transform(hf_transform_to_torch) - episode_data_index = calculate_episode_data_index(dataset) - sampler = EpisodeAwareSampler(episode_data_index, episode_indices_to_use=[0, 2]) - assert sampler.indices == [0, 1, 3, 4, 5] - assert len(sampler) == 5 - assert list(sampler) == [0, 1, 3, 4, 5] - - -def test_shuffle(): - dataset = Dataset.from_dict( - { - "timestamp": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - "index": [0, 1, 2, 3, 4, 5], - "episode_index": [0, 0, 1, 2, 2, 2], - }, - ) - dataset.set_transform(hf_transform_to_torch) - episode_data_index = calculate_episode_data_index(dataset) - sampler = EpisodeAwareSampler(episode_data_index, shuffle=False) - assert sampler.indices == [0, 1, 2, 3, 4, 5] - assert len(sampler) == 6 - assert list(sampler) == [0, 1, 2, 3, 4, 5] - sampler = EpisodeAwareSampler(episode_data_index, shuffle=True) - assert sampler.indices == [0, 1, 2, 3, 4, 5] - assert len(sampler) == 6 - assert set(sampler) == {0, 1, 2, 3, 4, 5} diff --git a/external/lerobot/tests/test_utils.py b/external/lerobot/tests/test_utils.py deleted file mode 100644 index 8880d28c37..0000000000 --- a/external/lerobot/tests/test_utils.py +++ /dev/null @@ -1,79 +0,0 @@ -import random -from typing import Callable -from uuid import uuid4 - -import numpy as np -import pytest -import torch -from datasets import Dataset - -from lerobot.common.datasets.push_dataset_to_hub.utils import calculate_episode_data_index -from lerobot.common.datasets.utils import ( - hf_transform_to_torch, -) -from lerobot.common.utils.utils import ( - get_global_random_state, - init_hydra_config, - seeded_context, - set_global_random_state, - set_global_seed, -) - -# Random generation functions for testing the seeding and random state get/set. -rand_fns = [ - random.random, - np.random.random, - lambda: torch.rand(1).item(), -] -if torch.cuda.is_available(): - rand_fns.append(lambda: torch.rand(1, device="cuda")) - - -@pytest.mark.parametrize("rand_fn", rand_fns) -def test_seeding(rand_fn: Callable[[], int]): - set_global_seed(0) - a = rand_fn() - with seeded_context(1337): - c = rand_fn() - b = rand_fn() - set_global_seed(0) - a_ = rand_fn() - b_ = rand_fn() - # Check that `set_global_seed` lets us reproduce a and b. - assert a_ == a - # Additionally, check that the `seeded_context` didn't interrupt the global RNG. - assert b_ == b - set_global_seed(1337) - c_ = rand_fn() - # Check that `seeded_context` and `global_seed` give the same reproducibility. - assert c_ == c - - -def test_get_set_random_state(): - """Check that getting the random state, then setting it results in the same random number generation.""" - random_state_dict = get_global_random_state() - rand_numbers = [rand_fn() for rand_fn in rand_fns] - set_global_random_state(random_state_dict) - rand_numbers_ = [rand_fn() for rand_fn in rand_fns] - assert rand_numbers_ == rand_numbers - - -def test_calculate_episode_data_index(): - dataset = Dataset.from_dict( - { - "timestamp": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - "index": [0, 1, 2, 3, 4, 5], - "episode_index": [0, 0, 1, 2, 2, 2], - }, - ) - dataset.set_transform(hf_transform_to_torch) - episode_data_index = calculate_episode_data_index(dataset) - assert torch.equal(episode_data_index["from"], torch.tensor([0, 2, 3])) - assert torch.equal(episode_data_index["to"], torch.tensor([2, 3, 6])) - - -def test_init_hydra_config_empty(): - test_file = f"/tmp/test_init_hydra_config_empty_{uuid4().hex}.yaml" - with open(test_file, "w") as f: - f.write("\n") - init_hydra_config(test_file) diff --git a/external/lerobot/tests/test_visualize_dataset.py b/external/lerobot/tests/test_visualize_dataset.py deleted file mode 100644 index 303342e3c0..0000000000 --- a/external/lerobot/tests/test_visualize_dataset.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import pytest - -from lerobot.scripts.visualize_dataset import visualize_dataset - - -@pytest.mark.skip("TODO: add dummy videos") -def test_visualize_local_dataset(tmp_path, lerobot_dataset_factory): - root = tmp_path / "dataset" - output_dir = tmp_path / "outputs" - dataset = lerobot_dataset_factory(root=root) - rrd_path = visualize_dataset( - dataset, - episode_index=0, - batch_size=32, - save=True, - output_dir=output_dir, - ) - assert rrd_path.exists() diff --git a/external/lerobot/tests/utils.py b/external/lerobot/tests/utils.py deleted file mode 100644 index f24b3551c7..0000000000 --- a/external/lerobot/tests/utils.py +++ /dev/null @@ -1,387 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import json -import os -import platform -from copy import copy -from functools import wraps -from pathlib import Path - -import pytest -import torch - -from lerobot import available_cameras, available_motors, available_robots -from lerobot.common.robot_devices.cameras.utils import Camera -from lerobot.common.robot_devices.motors.utils import MotorsBus -from lerobot.common.robot_devices.robots.factory import make_robot as make_robot_from_cfg -from lerobot.common.robot_devices.robots.utils import Robot -from lerobot.common.utils.import_utils import is_package_available -from lerobot.common.utils.utils import init_hydra_config - -DEVICE = "cuda" if torch.cuda.is_available() else "cpu" - -# Pass this as the first argument to init_hydra_config. -DEFAULT_CONFIG_PATH = "lerobot/configs/default.yaml" - -ROBOT_CONFIG_PATH_TEMPLATE = "lerobot/configs/robot/{robot}.yaml" - -TEST_ROBOT_TYPES = [] -for robot_type in available_robots: - TEST_ROBOT_TYPES += [(robot_type, True), (robot_type, False)] - -TEST_CAMERA_TYPES = [] -for camera_type in available_cameras: - TEST_CAMERA_TYPES += [(camera_type, True), (camera_type, False)] - -TEST_MOTOR_TYPES = [] -for motor_type in available_motors: - TEST_MOTOR_TYPES += [(motor_type, True), (motor_type, False)] - -# Camera indices used for connecting physical cameras -OPENCV_CAMERA_INDEX = int(os.environ.get("LEROBOT_TEST_OPENCV_CAMERA_INDEX", 0)) -INTELREALSENSE_CAMERA_INDEX = int(os.environ.get("LEROBOT_TEST_INTELREALSENSE_CAMERA_INDEX", 128422271614)) - -DYNAMIXEL_PORT = os.environ.get("LEROBOT_TEST_DYNAMIXEL_PORT", "/dev/tty.usbmodem575E0032081") -DYNAMIXEL_MOTORS = { - "shoulder_pan": [1, "xl430-w250"], - "shoulder_lift": [2, "xl430-w250"], - "elbow_flex": [3, "xl330-m288"], - "wrist_flex": [4, "xl330-m288"], - "wrist_roll": [5, "xl330-m288"], - "gripper": [6, "xl330-m288"], -} - -FEETECH_PORT = os.environ.get("LEROBOT_TEST_FEETECH_PORT", "/dev/tty.usbmodem585A0080971") -FEETECH_MOTORS = { - "shoulder_pan": [1, "sts3215"], - "shoulder_lift": [2, "sts3215"], - "elbow_flex": [3, "sts3215"], - "wrist_flex": [4, "sts3215"], - "wrist_roll": [5, "sts3215"], - "gripper": [6, "sts3215"], -} - - -def require_x86_64_kernel(func): - """ - Decorator that skips the test if plateform device is not an x86_64 cpu. - """ - from functools import wraps - - @wraps(func) - def wrapper(*args, **kwargs): - if platform.machine() != "x86_64": - pytest.skip("requires x86_64 plateform") - return func(*args, **kwargs) - - return wrapper - - -def require_cpu(func): - """ - Decorator that skips the test if device is not cpu. - """ - from functools import wraps - - @wraps(func) - def wrapper(*args, **kwargs): - if DEVICE != "cpu": - pytest.skip("requires cpu") - return func(*args, **kwargs) - - return wrapper - - -def require_cuda(func): - """ - Decorator that skips the test if cuda is not available. - """ - from functools import wraps - - @wraps(func) - def wrapper(*args, **kwargs): - if not torch.cuda.is_available(): - pytest.skip("requires cuda") - return func(*args, **kwargs) - - return wrapper - - -def require_env(func): - """ - Decorator that skips the test if the required environment package is not installed. - As it need 'env_name' in args, it also checks whether it is provided as an argument. - If 'env_name' is None, this check is skipped. - """ - - @wraps(func) - def wrapper(*args, **kwargs): - # Determine if 'env_name' is provided and extract its value - arg_names = func.__code__.co_varnames[: func.__code__.co_argcount] - if "env_name" in arg_names: - # Get the index of 'env_name' and retrieve the value from args - index = arg_names.index("env_name") - env_name = args[index] if len(args) > index else kwargs.get("env_name") - else: - raise ValueError("Function does not have 'env_name' as an argument.") - - # Perform the package check - package_name = f"gym_{env_name}" - if env_name is not None and not is_package_available(package_name): - pytest.skip(f"gym-{env_name} not installed") - - return func(*args, **kwargs) - - return wrapper - - -def require_package_arg(func): - """ - Decorator that skips the test if the required package is not installed. - This is similar to `require_env` but more general in that it can check any package (not just environments). - As it need 'required_packages' in args, it also checks whether it is provided as an argument. - If 'required_packages' is None, this check is skipped. - """ - - @wraps(func) - def wrapper(*args, **kwargs): - # Determine if 'required_packages' is provided and extract its value - arg_names = func.__code__.co_varnames[: func.__code__.co_argcount] - if "required_packages" in arg_names: - # Get the index of 'required_packages' and retrieve the value from args - index = arg_names.index("required_packages") - required_packages = args[index] if len(args) > index else kwargs.get("required_packages") - else: - raise ValueError("Function does not have 'required_packages' as an argument.") - - if required_packages is None: - return func(*args, **kwargs) - - # Perform the package check - for package in required_packages: - if not is_package_available(package): - pytest.skip(f"{package} not installed") - - return func(*args, **kwargs) - - return wrapper - - -def require_package(package_name): - """ - Decorator that skips the test if the specified package is not installed. - """ - - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - if not is_package_available(package_name): - pytest.skip(f"{package_name} not installed") - return func(*args, **kwargs) - - return wrapper - - return decorator - - -def require_robot(func): - """ - Decorator that skips the test if a robot is not available - - The decorated function must have two arguments `request` and `robot_type`. - - Example of usage: - ```python - @pytest.mark.parametrize( - "robot_type", ["koch", "aloha"] - ) - @require_robot - def test_require_robot(request, robot_type): - pass - ``` - """ - - @wraps(func) - def wrapper(*args, **kwargs): - # Access the pytest request context to get the is_robot_available fixture - request = kwargs.get("request") - robot_type = kwargs.get("robot_type") - mock = kwargs.get("mock") - - if robot_type is None: - raise ValueError("The 'robot_type' must be an argument of the test function.") - if request is None: - raise ValueError("The 'request' fixture must be an argument of the test function.") - if mock is None: - raise ValueError("The 'mock' variable must be an argument of the test function.") - - # Run test with a real robot. Skip test if robot connection fails. - if not mock and not request.getfixturevalue("is_robot_available"): - pytest.skip(f"A {robot_type} robot is not available.") - - return func(*args, **kwargs) - - return wrapper - - -def require_camera(func): - @wraps(func) - def wrapper(*args, **kwargs): - # Access the pytest request context to get the is_camera_available fixture - request = kwargs.get("request") - camera_type = kwargs.get("camera_type") - mock = kwargs.get("mock") - - if request is None: - raise ValueError("The 'request' fixture must be an argument of the test function.") - if camera_type is None: - raise ValueError("The 'camera_type' must be an argument of the test function.") - if mock is None: - raise ValueError("The 'mock' variable must be an argument of the test function.") - - if not mock and not request.getfixturevalue("is_camera_available"): - pytest.skip(f"A {camera_type} camera is not available.") - - return func(*args, **kwargs) - - return wrapper - - -def require_motor(func): - @wraps(func) - def wrapper(*args, **kwargs): - # Access the pytest request context to get the is_motor_available fixture - request = kwargs.get("request") - motor_type = kwargs.get("motor_type") - mock = kwargs.get("mock") - - if request is None: - raise ValueError("The 'request' fixture must be an argument of the test function.") - if motor_type is None: - raise ValueError("The 'motor_type' must be an argument of the test function.") - if mock is None: - raise ValueError("The 'mock' variable must be an argument of the test function.") - - if not mock and not request.getfixturevalue("is_motor_available"): - pytest.skip(f"A {motor_type} motor is not available.") - - return func(*args, **kwargs) - - return wrapper - - -def mock_calibration_dir(calibration_dir): - # TODO(rcadene): remove this hack - # calibration file produced with Moss v1, but works with Koch, Koch bimanual and SO-100 - example_calib = { - "homing_offset": [-1416, -845, 2130, 2872, 1950, -2211], - "drive_mode": [0, 0, 1, 1, 1, 0], - "start_pos": [1442, 843, 2166, 2849, 1988, 1835], - "end_pos": [2440, 1869, -1106, -1848, -926, 3235], - "calib_mode": ["DEGREE", "DEGREE", "DEGREE", "DEGREE", "DEGREE", "LINEAR"], - "motor_names": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"], - } - Path(str(calibration_dir)).mkdir(parents=True, exist_ok=True) - with open(calibration_dir / "main_follower.json", "w") as f: - json.dump(example_calib, f) - with open(calibration_dir / "main_leader.json", "w") as f: - json.dump(example_calib, f) - with open(calibration_dir / "left_follower.json", "w") as f: - json.dump(example_calib, f) - with open(calibration_dir / "left_leader.json", "w") as f: - json.dump(example_calib, f) - with open(calibration_dir / "right_follower.json", "w") as f: - json.dump(example_calib, f) - with open(calibration_dir / "right_leader.json", "w") as f: - json.dump(example_calib, f) - - -def make_robot(robot_type: str, overrides: list[str] | None = None, mock=False) -> Robot: - if mock: - overrides = [] if overrides is None else copy(overrides) - - # Explicitely add mock argument to the cameras and set it to true - # TODO(rcadene, aliberts): redesign when we drop hydra - if robot_type in ["koch", "so100", "moss"]: - overrides.append("+leader_arms.main.mock=true") - overrides.append("+follower_arms.main.mock=true") - if "~cameras" not in overrides: - overrides.append("+cameras.laptop.mock=true") - overrides.append("+cameras.phone.mock=true") - - elif robot_type == "koch_bimanual": - overrides.append("+leader_arms.left.mock=true") - overrides.append("+leader_arms.right.mock=true") - overrides.append("+follower_arms.left.mock=true") - overrides.append("+follower_arms.right.mock=true") - if "~cameras" not in overrides: - overrides.append("+cameras.laptop.mock=true") - overrides.append("+cameras.phone.mock=true") - - elif robot_type == "aloha": - overrides.append("+leader_arms.left.mock=true") - overrides.append("+leader_arms.right.mock=true") - overrides.append("+follower_arms.left.mock=true") - overrides.append("+follower_arms.right.mock=true") - if "~cameras" not in overrides: - overrides.append("+cameras.cam_high.mock=true") - overrides.append("+cameras.cam_low.mock=true") - overrides.append("+cameras.cam_left_wrist.mock=true") - overrides.append("+cameras.cam_right_wrist.mock=true") - - else: - raise NotImplementedError(robot_type) - - config_path = ROBOT_CONFIG_PATH_TEMPLATE.format(robot=robot_type) - robot_cfg = init_hydra_config(config_path, overrides) - robot = make_robot_from_cfg(robot_cfg) - return robot - - -def make_camera(camera_type, **kwargs) -> Camera: - if camera_type == "opencv": - from lerobot.common.robot_devices.cameras.opencv import OpenCVCamera - - camera_index = kwargs.pop("camera_index", OPENCV_CAMERA_INDEX) - return OpenCVCamera(camera_index, **kwargs) - - elif camera_type == "intelrealsense": - from lerobot.common.robot_devices.cameras.intelrealsense import IntelRealSenseCamera - - camera_index = kwargs.pop("camera_index", INTELREALSENSE_CAMERA_INDEX) - return IntelRealSenseCamera(camera_index, **kwargs) - - else: - raise ValueError(f"The camera type '{camera_type}' is not valid.") - - -def make_motors_bus(motor_type: str, **kwargs) -> MotorsBus: - if motor_type == "dynamixel": - from lerobot.common.robot_devices.motors.dynamixel import DynamixelMotorsBus - - port = kwargs.pop("port", DYNAMIXEL_PORT) - motors = kwargs.pop("motors", DYNAMIXEL_MOTORS) - return DynamixelMotorsBus(port, motors, **kwargs) - - elif motor_type == "feetech": - from lerobot.common.robot_devices.motors.feetech import FeetechMotorsBus - - port = kwargs.pop("port", FEETECH_PORT) - motors = kwargs.pop("motors", FEETECH_MOTORS) - return FeetechMotorsBus(port, motors, **kwargs) - - else: - raise ValueError(f"The motor type '{motor_type}' is not valid.") diff --git a/pyproject.toml b/pyproject.toml index 4027a84059..ae250ac8d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,3 @@ egomimic = [ "robot/rollout_dashboard_static/*.js", "robot/rollout_dashboard_static/*.css", ] - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["."] diff --git a/scripts/audit_components.py b/scripts/audit_components.py index 9038d3014f..22fb8d492a 100644 --- a/scripts/audit_components.py +++ b/scripts/audit_components.py @@ -31,7 +31,7 @@ FRAGMENTS = { "model/pi0.5_base.yaml": "Select a concrete domains/ac_keys recipe; owner: graph integration" } -QWEN_METADATA = ROOT / "tests/fixtures/qwen3_embedding_06b_config.json" +QWEN_METADATA = ROOT / "scripts/resources/qwen3_embedding_06b_config.json" QWEN_METADATA_SHA256 = ( "b5bf1f51fc45be473a54718cef92448d90a1be001bf9b9a44b8c7f10a19feaa9" ) diff --git a/scripts/local_cpu_storage_guard.py b/scripts/local_cpu_storage_guard.py deleted file mode 100644 index b0b28ba75f..0000000000 --- a/scripts/local_cpu_storage_guard.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Fail before each local test when retained synthetic checkpoints fill disk.""" - -import os -import shutil -import tempfile - -import pytest - - -def pytest_runtest_setup(item): - del item - floor_kib = int(os.environ.get("LOCAL_CPU_TEST_MIN_FREE_KIB", "4194304")) - if floor_kib < 4194304: - raise pytest.UsageError("Local CPU storage floor cannot be below 4 GiB") - if shutil.disk_usage(tempfile.gettempdir()).free < floor_kib * 1024: - raise pytest.UsageError( - "Local CPU validation blocked before test: insufficient temporary " - "storage; preserve results, collect completed synthetic fixtures, " - "then resume unfinished tests only" - ) diff --git a/tests/fixtures/qwen3_embedding_06b_config.json b/scripts/resources/qwen3_embedding_06b_config.json similarity index 100% rename from tests/fixtures/qwen3_embedding_06b_config.json rename to scripts/resources/qwen3_embedding_06b_config.json diff --git a/scripts/run_local_cpu_validation.sh b/scripts/run_local_cpu_validation.sh deleted file mode 100644 index 26d31ec8f2..0000000000 --- a/scripts/run_local_cpu_validation.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# Local CPU regression runner, not a cluster launcher or exact-runtime proof. -set -euo pipefail - -# Large synthetic lifecycle fixtures write real full-state checkpoints. Fail -# before optimizer work rather than turn exhausted local storage into a model -# failure. Callers may raise (but not lower) this floor for larger suites. -required_kib=${LOCAL_CPU_TEST_MIN_FREE_KIB:-4194304} -[[ "$required_kib" =~ ^[0-9]+$ ]] && (( required_kib >= 4194304 )) || { - echo "Local CPU validation requires at least 4 GiB free-space floor" >&2 - exit 64 -} -available_kib=$(df -Pk "${TMPDIR:-/tmp}" | awk 'NR == 2 {print $4}') -[[ "$available_kib" =~ ^[0-9]+$ ]] && (( available_kib >= required_kib )) || { - echo "Local CPU validation blocked: insufficient temporary-storage headroom" >&2 - exit 64 -} - -# The Rosetta Torch/OpenMP validation environment stalled inside libiomp5 with -# multiple threads. Set these before Python imports and emit stalled-test stacks. -# No training config, GPU runtime, source identity or approval is changed here. -diagnostic_timeout=60 -for argument in "$@"; do - case "$argument" in - tests/test_recursive_config_audit.py|tests/test_recursive_config_audit.py::*) - # This intentionally long, all-YAML test takes >60s. Native-arm - # CPython's concurrent frame dump stalled in dump_frame and held - # pytest's cancellation lock. Preserve diagnostics at its own - # bounded 10-minute threshold rather than dump during healthy work. - diagnostic_timeout=600 - ;; - esac -done -exec env OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 \ - NUMBA_NUM_THREADS=1 NUMBA_THREADING_LAYER=workqueue \ - "${LOCAL_CPU_TEST_PYTHON:-python3}" -m pytest \ - -p scripts.local_cpu_storage_guard \ - -o "faulthandler_timeout=$diagnostic_timeout" "$@" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 956d686c7f..0000000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Repository test package for stable intra-suite imports.""" diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/fixtures/e1_source_metrics.json b/tests/fixtures/e1_source_metrics.json deleted file mode 100644 index 01ffee5e80..0000000000 --- a/tests/fixtures/e1_source_metrics.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "source_commit": "e8aa10fa40701f46bae8f26ab38765aa2e095013", - "cases": { - "time-none": { - "variant": "time", - "n_chunks": 1, - "n_partial_arm_chunks": 0, - "h_match_frames": 40, - "D": 0.4, - "M": 100, - "e_time": 0.0, - "e_arc": 0.0, - "d_clock": 7.718096473865639e-17, - "e_time_prog": 0.0, - "paired_mse": 0.0, - "paired_mse_hmatch": 0.0, - "xyz_mse": 0.0, - "xyz_mse_hmatch": 0.0, - "n_paired": 1, - "arcmatch_paired_mse": 0.0, - "arcmatch_withvel_paired_mse": 0.0, - "arcmatch_xyz_mse": 0.0, - "arcmatch_span_m": 0.6148003179493703, - "arcmatch_travel_ratio": 1.0, - "arcmatch_gtspan_paired_mse": 0.0, - "arcmatch_gtspan_xyz_mse": 0.0, - "arcmatch_gtspan_span_m": 0.4, - "arcmatch_gt_span_m": 0.4, - "arcmatch_degenerate_frac": 0.0, - "arcmatch_points": 100, - "prog_horizon_m": 0.19, - "progress_smooth_hz": null, - "prog_frames_p50": 32.0, - "e_time_p50": 0.0, - "e_time_p90": 0.0, - "e_time_p99": 0.0 - }, - "time-short": { - "variant": "time", - "n_chunks": 1, - "n_partial_arm_chunks": 2, - "h_match_frames": 40, - "D": 0.4, - "M": 100, - "e_time": 0.07735759798353259, - "e_arc": 0.032639799108644105, - "d_clock": 1.0491419960480717, - "e_time_prog": 0.062033077783659574, - "paired_mse": 0.011462623911967203, - "paired_mse_hmatch": 0.0018526467138072211, - "xyz_mse": 0.01224322074624064, - "xyz_mse_hmatch": 0.0019947326552606152, - "n_paired": 1, - "arcmatch_paired_mse": 0.00026643599299954195, - "arcmatch_withvel_paired_mse": 0.0002986575317364406, - "arcmatch_xyz_mse": 0.0003551188286175482, - "arcmatch_span_m": 0.27666014317486987, - "arcmatch_travel_ratio": 0.45000000015883734, - "arcmatch_gtspan_paired_mse": 0.001694210223724696, - "arcmatch_gtspan_xyz_mse": 0.001848478545200494, - "arcmatch_gtspan_span_m": 0.4, - "arcmatch_gt_span_m": 0.4, - "arcmatch_degenerate_frac": 0.0, - "arcmatch_points": 100, - "prog_horizon_m": 0.19, - "progress_smooth_hz": null, - "prog_frames_p50": 32.0, - "e_time_p50": 0.07735759798353259, - "e_time_p90": 0.07735759798353259, - "e_time_p99": 0.07735759798353259 - }, - "time-offset": { - "variant": "time", - "n_chunks": 1, - "n_partial_arm_chunks": 0, - "h_match_frames": 40, - "D": 0.4, - "M": 100, - "e_time": 0.04949747445956481, - "e_arc": 0.049497474970376924, - "d_clock": 1.7757881956322314e-09, - "e_time_prog": 0.04949747435100786, - "paired_mse": 0.0006125000026993807, - "paired_mse_hmatch": 0.0006124999944688177, - "xyz_mse": 0.0008166666702658411, - "xyz_mse_hmatch": 0.0008166666592917569, - "n_paired": 1, - "arcmatch_paired_mse": 0.000612500003187742, - "arcmatch_withvel_paired_mse": 0.0006064409905273916, - "arcmatch_xyz_mse": 0.0008166666709169895, - "arcmatch_span_m": 0.6148003179493703, - "arcmatch_travel_ratio": 1.000000000242483, - "arcmatch_gtspan_paired_mse": 0.0006125000071107723, - "arcmatch_gtspan_xyz_mse": 0.0008166666761476967, - "arcmatch_gtspan_span_m": 0.4, - "arcmatch_gt_span_m": 0.4, - "arcmatch_degenerate_frac": 0.0, - "arcmatch_points": 100, - "prog_horizon_m": 0.19, - "progress_smooth_hz": null, - "prog_frames_p50": 32.0, - "e_time_p50": 0.04949747445956481, - "e_time_p90": 0.04949747445956481, - "e_time_p99": 0.04949747445956481 - }, - "arcmean-none": { - "variant": "arcmean", - "n_chunks": 1, - "n_partial_arm_chunks": 2, - "h_match_frames": 40, - "D": 0.4, - "M": 100, - "e_time": 0.0010207610158137355, - "e_arc": 1.6881098759261212e-06, - "d_clock": 0.011090551811035899, - "e_time_prog": 0.0006889702323181911, - "paired_mse": 0.0017338100947300484, - "paired_mse_hmatch": 3.2405923699848507e-07, - "xyz_mse": 0.0018701074663587754, - "xyz_mse_hmatch": 3.4731768380169654e-07, - "n_paired": 1, - "arcmatch_paired_mse": 2.2170648419505377e-11, - "arcmatch_withvel_paired_mse": 1.2940317633968752e-05, - "arcmatch_xyz_mse": 2.8873918263547578e-11, - "arcmatch_span_m": 0.3999944521696827, - "arcmatch_travel_ratio": 0.6506087269177744, - "arcmatch_gtspan_paired_mse": 1.8821013273943135e-11, - "arcmatch_gtspan_xyz_mse": 2.5039497893441853e-11, - "arcmatch_gtspan_span_m": 0.4, - "arcmatch_gt_span_m": 0.4, - "arcmatch_degenerate_frac": 0.0, - "arcmatch_points": 100, - "prog_horizon_m": 0.19, - "progress_smooth_hz": null, - "prog_frames_p50": 32.0, - "e_time_p50": 0.0010207610158137355, - "e_time_p90": 0.0010207610158137355, - "e_time_p99": 0.0010207610158137355 - }, - "arclogdur-none": { - "variant": "arclogdur", - "n_chunks": 1, - "n_partial_arm_chunks": 2, - "h_match_frames": 40, - "D": 0.4, - "M": 100, - "e_time": 8.74531575000484e-06, - "e_arc": 1.6881098759261212e-06, - "d_clock": 1.7716546508559998e-06, - "e_time_prog": 7.859874734426696e-06, - "paired_mse": 0.0017329457743949803, - "paired_mse_hmatch": 1.9171966398935006e-11, - "xyz_mse": 0.0018691751683653998, - "xyz_mse_hmatch": 2.5493515855760912e-11, - "n_paired": 1, - "arcmatch_paired_mse": 1.8008684440080184e-11, - "arcmatch_withvel_paired_mse": 1.2940367476193014e-05, - "arcmatch_xyz_mse": 2.336260866290959e-11, - "arcmatch_span_m": 0.39999475435533527, - "arcmatch_travel_ratio": 0.6506092184361483, - "arcmatch_gtspan_paired_mse": 1.489694318331742e-11, - "arcmatch_gtspan_xyz_mse": 1.9839171950885146e-11, - "arcmatch_gtspan_span_m": 0.4, - "arcmatch_gt_span_m": 0.4, - "arcmatch_degenerate_frac": 0.0, - "arcmatch_points": 100, - "prog_horizon_m": 0.19, - "progress_smooth_hz": null, - "prog_frames_p50": 32.0, - "e_time_p50": 8.74531575000484e-06, - "e_time_p90": 8.74531575000484e-06, - "e_time_p99": 8.74531575000484e-06 - }, - "arcdur-none": { - "variant": "arcdur", - "n_chunks": 1, - "n_partial_arm_chunks": 2, - "h_match_frames": 40, - "D": 0.4, - "M": 100, - "e_time": 8.742660737310497e-06, - "e_arc": 1.6881098759261212e-06, - "d_clock": 9.028148532413952e-06, - "e_time_prog": 7.914462636420122e-06, - "paired_mse": 0.0017329457736994913, - "paired_mse_hmatch": 1.910852989875363e-11, - "xyz_mse": 0.001869175167558258, - "xyz_mse_hmatch": 2.547803892257017e-11, - "n_paired": 1, - "arcmatch_paired_mse": 1.8007573820116945e-11, - "arcmatch_withvel_paired_mse": 1.2940367489865588e-05, - "arcmatch_xyz_mse": 2.336123193104626e-11, - "arcmatch_span_m": 0.39999475443805754, - "arcmatch_travel_ratio": 0.6506092185706998, - "arcmatch_gtspan_paired_mse": 1.4896053704947692e-11, - "arcmatch_gtspan_xyz_mse": 1.9837984949135727e-11, - "arcmatch_gtspan_span_m": 0.4, - "arcmatch_gt_span_m": 0.4, - "arcmatch_degenerate_frac": 0.0, - "arcmatch_points": 100, - "prog_horizon_m": 0.19, - "progress_smooth_hz": null, - "prog_frames_p50": 32.0, - "e_time_p50": 8.742660737310497e-06, - "e_time_p90": 8.742660737310497e-06, - "e_time_p99": 8.742660737310497e-06 - } - } -} diff --git a/tests/fixtures/legacy_alignment_reference.py b/tests/fixtures/legacy_alignment_reference.py deleted file mode 100644 index 1358433a33..0000000000 --- a/tests/fixtures/legacy_alignment_reference.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Verbatim methods from EgoVerse ec5c903c HPTModel, for parity only.""" - -import torch -from geomloss import SamplesLoss - - -class LegacyAlignment: - def resume_from_depth(self, block_outputs, depth): - """ - Detach at trunk depth and resume trunk forward pass. - Gradients will only flow from depth upward. - """ - cut_tokens = block_outputs[depth - 1].detach() - - blocks = self.trunk["trunk"].blocks - for blk in list(blocks)[depth:]: - cut_tokens = blk(cut_tokens, attn_mask=None) - - if self.trunk["trunk"].post_transformer_layer is not None: - cut_tokens = self.trunk["trunk"].post_transformer_layer(cut_tokens) - - return self.postprocess_tokens(cut_tokens) - - def make_custom_cost(self, scaling_mask): - def custom_cost(x, y): - cost = 0.5 * (((x.unsqueeze(1) - y.unsqueeze(0)) ** 2).sum(dim=-1)) - return cost * scaling_mask - - return custom_cost - - def compute_ot( - self, tokens1, tokens2, emb1_actions, emb2_actions, supervised, lambd - ): - tokens1 = tokens1.reshape(tokens1.shape[0], -1) - tokens2 = tokens2.reshape(tokens1.shape[0], -1) - - if not supervised: - ot_loss_fn = SamplesLoss("sinkhorn", p=2, blur=0.05, truncate=18) - ot_loss = ot_loss_fn(tokens2, tokens1) - avg_feature_dist = torch.norm(tokens2 - tokens1, dim=-1).mean() - return ot_loss, avg_feature_dist - else: - B = tokens1.shape[0] - if not self.ot_6dof: - emb1_actions = emb1_actions[..., :3] - emb2_actions = emb2_actions[..., :3] - if self.use_dtw: - emb2_delta = emb2_actions - emb1_delta = emb1_actions - emb2_expand = emb2_delta.unsqueeze(1).expand(B, B, -1, -1) - emb1_expand = emb1_delta.unsqueeze(0).expand(B, B, -1, -1) - pairwise_dist = self.dtw( - emb2_expand.reshape(B * B, *emb2_actions.shape[1:]), - emb1_expand.reshape(B * B, *emb1_actions.shape[1:]), - ).view(B, B) - else: - emb2_expand = emb2_actions.unsqueeze(1) # (B, 1, T, D) - emb1_expand = emb1_actions.unsqueeze(0) # (1, B, T, D) - pairwise_dist = ((emb2_expand - emb1_expand) ** 2).mean( - dim=(2, 3) - ) # (B, B) #changed - - labels = torch.argmin(pairwise_dist, dim=1) - W = torch.ones(B, B).to(self.device) - W[torch.arange(B), labels] = lambd - - custom_cost_fn = self.make_custom_cost(W) - - ot_loss_fn = SamplesLoss( - loss="sinkhorn", p=2, blur=0.05, cost=custom_cost_fn, truncate=18 - ) - - ot_loss = ot_loss_fn(tokens2, tokens1) - avg_feature_dist = torch.norm(tokens2 - tokens1, dim=-1).mean() - return ot_loss, avg_feature_dist diff --git a/tests/fixtures/legacy_hpt_trunk/metadata.json b/tests/fixtures/legacy_hpt_trunk/metadata.json deleted file mode 100644 index bb81e01d3e..0000000000 --- a/tests/fixtures/legacy_hpt_trunk/metadata.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "constructor": { - "embed_dim": 16, - "num_blocks": 2, - "num_heads": 2, - "observation_horizon": 1, - "action_horizon": 3 - }, - "seed": 9226, - "torch_version": "2.7.1", - "source_namespace": "trunk.", - "weights_sha256": "a2b5a6e44028d34195be7de5de62caa1a2bb8a3f6eceb34eec5e9201234e1786", - "reference_sha256": "bb550af3d1ce85a546dd5597c17acf8633663ea0707bd71f662e280829dd8bbd", - "keys": [ - "trunk.blocks.0.attn.in_proj_weight", - "trunk.blocks.0.attn.in_proj_bias", - "trunk.blocks.0.attn.bias_k", - "trunk.blocks.0.attn.bias_v", - "trunk.blocks.0.attn.out_proj.weight", - "trunk.blocks.0.attn.out_proj.bias", - "trunk.blocks.0.norm_1.weight", - "trunk.blocks.0.norm_1.bias", - "trunk.blocks.0.mlp.fc1.weight", - "trunk.blocks.0.mlp.fc1.bias", - "trunk.blocks.0.mlp.fc2.weight", - "trunk.blocks.0.mlp.fc2.bias", - "trunk.blocks.0.norm_2.weight", - "trunk.blocks.0.norm_2.bias", - "trunk.blocks.1.attn.in_proj_weight", - "trunk.blocks.1.attn.in_proj_bias", - "trunk.blocks.1.attn.bias_k", - "trunk.blocks.1.attn.bias_v", - "trunk.blocks.1.attn.out_proj.weight", - "trunk.blocks.1.attn.out_proj.bias", - "trunk.blocks.1.norm_1.weight", - "trunk.blocks.1.norm_1.bias", - "trunk.blocks.1.mlp.fc1.weight", - "trunk.blocks.1.mlp.fc1.bias", - "trunk.blocks.1.mlp.fc2.weight", - "trunk.blocks.1.mlp.fc2.bias", - "trunk.blocks.1.norm_2.weight", - "trunk.blocks.1.norm_2.bias" - ], - "scope": "actual legacy HPTModel trunk factory/loader, masked forward, loss and gradients; generated CPU parameters, not pretrained weights or a policy score" -} diff --git a/tests/fixtures/legacy_hpt_trunk/reference.pt b/tests/fixtures/legacy_hpt_trunk/reference.pt deleted file mode 100644 index 5da2a0afac75d5ab573484146d7c0ddd8e898a15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41018 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 diff --git a/tests/fixtures/legacy_language_schedule.json b/tests/fixtures/legacy_language_schedule.json deleted file mode 100644 index 6b0a695279..0000000000 --- a/tests/fixtures/legacy_language_schedule.json +++ /dev/null @@ -1,2011 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "source_path": "egomimic/utils/scheduler_utils.py", - "warmup_epochs": 100, - "total_epochs": 2000, - "eta_min": 1e-05, - "peak_lr": 0.0002, - "values": [ - 2.0000000000000002e-07, - 2.1980000000000004e-06, - 4.196e-06, - 6.194e-06, - 8.192e-06, - 1.019e-05, - 1.2188000000000001e-05, - 1.4186e-05, - 1.6184e-05, - 1.8181999999999996e-05, - 2.0179999999999997e-05, - 2.2177999999999997e-05, - 2.4175999999999994e-05, - 2.617399999999999e-05, - 2.8171999999999987e-05, - 3.0169999999999987e-05, - 3.216799999999999e-05, - 3.4165999999999984e-05, - 3.616399999999998e-05, - 3.8161999999999985e-05, - 4.015999999999998e-05, - 4.2157999999999985e-05, - 4.415599999999998e-05, - 4.615399999999998e-05, - 4.815199999999998e-05, - 5.014999999999998e-05, - 5.214799999999998e-05, - 5.4145999999999986e-05, - 5.614399999999999e-05, - 5.814199999999999e-05, - 6.014e-05, - 6.213800000000001e-05, - 6.413600000000001e-05, - 6.613400000000001e-05, - 6.813200000000002e-05, - 7.013000000000002e-05, - 7.212800000000002e-05, - 7.412600000000003e-05, - 7.612400000000002e-05, - 7.812200000000002e-05, - 8.012000000000003e-05, - 8.211800000000003e-05, - 8.411600000000003e-05, - 8.611400000000004e-05, - 8.811200000000004e-05, - 9.011000000000003e-05, - 9.210800000000003e-05, - 9.410600000000004e-05, - 9.610400000000004e-05, - 9.810200000000003e-05, - 0.00010010000000000003, - 0.00010209800000000004, - 0.00010409600000000004, - 0.00010609400000000005, - 0.00010809200000000004, - 0.00011009000000000004, - 0.00011208800000000003, - 0.00011408600000000002, - 0.00011608400000000004, - 0.00011808200000000005, - 0.00012008000000000006, - 0.00012207800000000005, - 0.00012407600000000004, - 0.00012607400000000003, - 0.00012807200000000002, - 0.00013007000000000003, - 0.00013206800000000002, - 0.00013406600000000001, - 0.000136064, - 0.00013806200000000002, - 0.00014006000000000004, - 0.00014205800000000003, - 0.00014405600000000005, - 0.00014605400000000004, - 0.00014805200000000003, - 0.00015005000000000004, - 0.00015204800000000006, - 0.00015404600000000005, - 0.00015604400000000007, - 0.00015804200000000008, - 0.00016004000000000007, - 0.0001620380000000001, - 0.00016403600000000008, - 0.00016603400000000007, - 0.0001680320000000001, - 0.00017003000000000008, - 0.0001720280000000001, - 0.0001740260000000001, - 0.00017602400000000013, - 0.00017802200000000012, - 0.00018002000000000014, - 0.00018201800000000013, - 0.00018401600000000014, - 0.00018601400000000013, - 0.00018801200000000012, - 0.00019001000000000014, - 0.00019200800000000013, - 0.00019400600000000015, - 0.00019600400000000017, - 0.00019800200000000018, - 0.0002, - 0.00019999987013681378, - 0.0001999994805476102, - 0.00019999883123345428, - 0.00019999792219612135, - 0.00019999675343809658, - 0.00019999532496257536, - 0.0001999936367734631, - 0.0001999916888753752, - 0.0001999894812736372, - 0.00019998701397428453, - 0.00019998428698406274, - 0.0001999813003104273, - 0.00019997805396154366, - 0.00019997454794628723, - 0.0001999707822742433, - 0.0001999667569557071, - 0.00019996247200168362, - 0.00019995792742388786, - 0.00019995312323474449, - 0.00019994805944738796, - 0.0001999427360756625, - 0.00019993715313412194, - 0.0001999313106380299, - 0.00019992520860335957, - 0.00019991884704679357, - 0.00019991222598572423, - 0.00019990534543825328, - 0.00019989820542319183, - 0.00019989080596006042, - 0.0001998831470690889, - 0.00019987522877121643, - 0.00019986705108809128, - 0.00019985861404207096, - 0.000199849917656222, - 0.00019984096195432, - 0.00019983174696084954, - 0.00019982227270100403, - 0.00019981253920068573, - 0.00019980254648650566, - 0.00019979229458578352, - 0.00019978178352654763, - 0.00019977101333753478, - 0.0001997599840481903, - 0.00019974869568866783, - 0.00019973714828982935, - 0.00019972534188324493, - 0.0001997132765011929, - 0.00019970095217665952, - 0.00019968836894333904, - 0.00019967552683563356, - 0.00019966242588865288, - 0.0001996490661382145, - 0.00019963544762084345, - 0.00019962157037377225, - 0.00019960743443494074, - 0.00019959303984299612, - 0.00019957838663729255, - 0.00019956347485789138, - 0.00019954830454556083, - 0.000199532875741776, - 0.00019951718848871863, - 0.00019950124282927707, - 0.0001994850388070461, - 0.00019946857646632694, - 0.00019945185585212703, - 0.00019943487701015982, - 0.00019941763998684484, - 0.0001994001448293075, - 0.00019938239158537885, - 0.0001993643803035956, - 0.00019934611103319987, - 0.0001993275838241392, - 0.00019930879872706621, - 0.0001992897557933387, - 0.00019927045507501926, - 0.0001992508966248754, - 0.00019923108049637908, - 0.0001992110067437069, - 0.00019919067542173975, - 0.00019917008658606263, - 0.00019914924029296467, - 0.00019912813659943884, - 0.00019910677556318182, - 0.00019908515724259386, - 0.00019906328169677862, - 0.00019904114898554304, - 0.00019901875916939708, - 0.00019899611230955361, - 0.00019897320846792835, - 0.0001989500477071395, - 0.0001989266300905077, - 0.00019890295568205578, - 0.0001988790245465087, - 0.00019885483674929332, - 0.00019883039235653807, - 0.00019880569143507302, - 0.00019878073405242956, - 0.0001987555202768402, - 0.00019873005017723844, - 0.00019870432382325859, - 0.00019867834128523546, - 0.0001986521026342044, - 0.00019862560794190083, - 0.0001985988572807602, - 0.00019857185072391786, - 0.00019854458834520867, - 0.00019851707021916693, - 0.00019848929642102617, - 0.00019846126702671883, - 0.00019843298211287628, - 0.00019840444175682834, - 0.0001983756460366033, - 0.0001983465950309275, - 0.00019831728881922536, - 0.0001982877274816189, - 0.0001982579110989277, - 0.00019822783975266854, - 0.00019819751352505543, - 0.00019816693249899908, - 0.00019813609675810686, - 0.00019810500638668248, - 0.00019807366146972583, - 0.00019804206209293276, - 0.00019801020834269472, - 0.00019797810030609865, - 0.00019794573807092669, - 0.00019791312172565598, - 0.00019788025135945836, - 0.0001978471270622001, - 0.00019781374892444188, - 0.00019778011703743817, - 0.00019774623149313724, - 0.00019771209238418093, - 0.00019767769980390425, - 0.0001976430538463352, - 0.0001976081546061945, - 0.00019757300217889532, - 0.00019753759666054312, - 0.00019750193814793517, - 0.00019746602673856049, - 0.0001974298625305995, - 0.0001973934456229238, - 0.00019735677611509576, - 0.0001973198541073685, - 0.00019728267970068532, - 0.00019724525299667968, - 0.0001972075740976747, - 0.00019716964310668306, - 0.00019713146012740666, - 0.00019709302526423628, - 0.00019705433862225142, - 0.00019701540030721986, - 0.00019697621042559746, - 0.00019693676908452785, - 0.00019689707639184215, - 0.00019685713245605867, - 0.00019681693738638265, - 0.0001967764912927058, - 0.00019673579428560627, - 0.00019669484647634805, - 0.00019665364797688092, - 0.00019661219889984002, - 0.00019657049935854553, - 0.00019652854946700243, - 0.00019648634933990005, - 0.00019644389909261197, - 0.0001964011988411956, - 0.00019635824870239168, - 0.00019631504879362435, - 0.00019627159923300046, - 0.00019622790013930946, - 0.000196183951632023, - 0.00019613975383129466, - 0.0001960953068579595, - 0.00019605061083353387, - 0.00019600566588021494, - 0.00019596047212088057, - 0.00019591502967908873, - 0.00019586933867907734, - 0.00019582339924576383, - 0.00019577721150474488, - 0.00019573077558229598, - 0.0001956840916053712, - 0.0001956371597016027, - 0.00019558997999930054, - 0.00019554255262745226, - 0.00019549487771572245, - 0.0001954469553944525, - 0.0001953987857946602, - 0.00019535036904803942, - 0.00019530170528695969, - 0.00019525279464446585, - 0.00019520363725427777, - 0.00019515423325078984, - 0.00019510458276907072, - 0.00019505468594486298, - 0.00019500454291458257, - 0.00019495415381531864, - 0.00019490351878483309, - 0.0001948526379615601, - 0.00019480151148460598, - 0.00019475013949374852, - 0.00019469852212943678, - 0.00019464665953279066, - 0.00019459455184560052, - 0.0001945421992103268, - 0.00019448960177009963, - 0.00019443675966871835, - 0.00019438367305065128, - 0.00019433034206103517, - 0.00019427676684567494, - 0.0001942229475510432, - 0.0001941688843242798, - 0.0001941145773131916, - 0.0001940600266662518, - 0.00019400523253259986, - 0.00019395019506204087, - 0.00019389491440504513, - 0.0001938393907127478, - 0.00019378362413694864, - 0.00019372761483011123, - 0.00019367136294536297, - 0.00019361486863649432, - 0.00019355813205795857, - 0.00019350115336487137, - 0.00019344393271301027, - 0.00019338647025881435, - 0.00019332876615938376, - 0.00019327082057247932, - 0.00019321263365652203, - 0.0001931542055705927, - 0.00019309553647443154, - 0.00019303662652843756, - 0.00019297747589366832, - 0.0001929180847318394, - 0.00019285845320532398, - 0.00019279858147715236, - 0.00019273846971101156, - 0.00019267811807124487, - 0.00019261752672285136, - 0.00019255669583148545, - 0.0001924956255634565, - 0.0001924343160857282, - 0.00019237276756591847, - 0.00019231098017229848, - 0.0001922489540737927, - 0.00019218668943997801, - 0.00019212418644108366, - 0.0001920614452479904, - 0.00019199846603223025, - 0.00019193524896598602, - 0.0001918717942220907, - 0.0001918081019740272, - 0.00019174417239592765, - 0.0001916800056625731, - 0.00019161560194939293, - 0.00019155096143246445, - 0.00019148608428851233, - 0.00019142097069490822, - 0.00019135562082967024, - 0.00019129003487146237, - 0.00019122421299959416, - 0.00019115815539402003, - 0.00019109186223533904, - 0.0001910253337047941, - 0.0001909585699842717, - 0.00019089157125630124, - 0.00019082433770405474, - 0.00019075686951134613, - 0.00019068916686263096, - 0.00019062122994300556, - 0.00019055305893820693, - 0.00019048465403461202, - 0.0001904160154192372, - 0.00019034714327973782, - 0.00019027803780440766, - 0.00019020869918217852, - 0.00019013912760261947, - 0.0001900693232559366, - 0.0001899992863329723, - 0.00018992901702520487, - 0.00018985851552474791, - 0.00018978778202434984, - 0.00018971681671739327, - 0.00018964561979789473, - 0.00018957419146050383, - 0.00018950253190050295, - 0.0001894306413138066, - 0.00018935851989696086, - 0.0001892861678471429, - 0.00018921358536216055, - 0.00018914077264045148, - 0.00018906772988108294, - 0.00018899445728375104, - 0.00018892095504878024, - 0.00018884722337712283, - 0.0001887732624703584, - 0.00018869907253069327, - 0.00018862465376095987, - 0.0001885500063646163, - 0.00018847513054574567, - 0.0001884000265090556, - 0.00018832469445987773, - 0.0001882491346041669, - 0.00018817334714850095, - 0.00018809733230007984, - 0.0001880210902667253, - 0.00018794462125688012, - 0.00018786792547960767, - 0.00018779100314459123, - 0.00018771385446213359, - 0.00018763647964315625, - 0.00018755887889919905, - 0.00018748105244241941, - 0.00018740300048559195, - 0.0001873247232421077, - 0.00018724622092597366, - 0.0001871674937518122, - 0.00018708854193486037, - 0.00018700936569096942, - 0.0001869299652366042, - 0.00018685034078884247, - 0.00018677049256537453, - 0.00018669042078450228, - 0.00018661012566513897, - 0.00018652960742680837, - 0.00018644886628964424, - 0.00018636790247438981, - 0.00018628671620239703, - 0.00018620530769562608, - 0.0001861236771766447, - 0.00018604182486862763, - 0.00018595975099535596, - 0.0001858774557812165, - 0.0001857949394512012, - 0.00018571220223090662, - 0.0001856292443465331, - 0.00018554606602488437, - 0.0001854626674933668, - 0.0001853790489799887, - 0.00018529521071336002, - 0.0001852111529226913, - 0.00018512687583779332, - 0.00018504237968907644, - 0.0001849576647075499, - 0.00018487273112482124, - 0.0001847875791730956, - 0.00018470220908517522, - 0.00018461662109445857, - 0.00018453081543494005, - 0.000184444792341209, - 0.00018435855204844927, - 0.00018427209479243852, - 0.00018418542080954762, - 0.0001840985303367399, - 0.0001840114236115706, - 0.00018392410087218622, - 0.00018383656235732376, - 0.0001837488083063102, - 0.00018366083895906171, - 0.00018357265455608317, - 0.00018348425533846736, - 0.0001833956415478944, - 0.00018330681342663095, - 0.00018321777121752973, - 0.00018312851516402874, - 0.00018303904551015063, - 0.00018294936250050201, - 0.00018285946638027279, - 0.00018276935739523558, - 0.0001826790357917448, - 0.0001825885018167364, - 0.0001824977557177267, - 0.00018240679774281213, - 0.0001823156281406683, - 0.00018222424716054941, - 0.00018213265505228758, - 0.00018204085206629216, - 0.00018194883845354897, - 0.00018185661446561976, - 0.00018176418035464135, - 0.00018167153637332507, - 0.00018157868277495607, - 0.0001814856198133925, - 0.00018139234774306495, - 0.00018129886681897569, - 0.00018120517729669803, - 0.0001811112794323755, - 0.00018101717348272136, - 0.00018092285970501757, - 0.00018082833835711447, - 0.00018073360969742978, - 0.0001806386739849481, - 0.00018054353147921998, - 0.00018044818244036145, - 0.00018035262712905309, - 0.00018025686580653948, - 0.00018016089873462842, - 0.0001800647261756903, - 0.0001799683483926571, - 0.00017987176564902206, - 0.00017977497820883872, - 0.00017967798633672022, - 0.00017958079029783863, - 0.00017948339035792424, - 0.00017938578678326474, - 0.0001792879798407046, - 0.00017918996979764427, - 0.00017909175692203943, - 0.0001789933414824004, - 0.00017889472374779113, - 0.00017879590398782886, - 0.0001786968824726829, - 0.00017859765947307441, - 0.00017849823526027523, - 0.00017839861010610737, - 0.0001782987842829422, - 0.0001781987580636997, - 0.00017809853172184777, - 0.00017799810553140137, - 0.00017789747976692182, - 0.0001777966547035162, - 0.00017769563061683635, - 0.0001775944077830783, - 0.00017749298647898136, - 0.00017739136698182752, - 0.00017728954956944062, - 0.00017718753452018553, - 0.00017708532211296757, - 0.00017698291262723152, - 0.00017688030634296098, - 0.0001767775035406777, - 0.0001766745045014405, - 0.00017657130950684492, - 0.00017646791883902207, - 0.00017636433278063812, - 0.00017626055161489338, - 0.00017615657562552163, - 0.00017605240509678918, - 0.00017594804031349434, - 0.0001758434815609664, - 0.00017573872912506508, - 0.00017563378329217948, - 0.00017552864434922748, - 0.00017542331258365497, - 0.00017531778828343492, - 0.00017521207173706684, - 0.00017510616323357565, - 0.00017500006306251116, - 0.00017489377151394723, - 0.00017478728887848082, - 0.0001746806154472315, - 0.00017457375151184028, - 0.00017446669736446906, - 0.00017435945329779987, - 0.0001742520196050338, - 0.00017414439657989055, - 0.00017403658451660732, - 0.00017392858370993816, - 0.00017382039445515318, - 0.00017371201704803764, - 0.00017360345178489127, - 0.00017349469896252732, - 0.00017338575887827188, - 0.00017327663182996298, - 0.0001731673181159498, - 0.00017305781803509185, - 0.00017294813188675813, - 0.00017283825997082647, - 0.00017272820258768245, - 0.00017261796003821875, - 0.00017250753262383427, - 0.0001723969206464334, - 0.00017228612440842498, - 0.00017217514421272176, - 0.00017206398036273934, - 0.00017195263316239543, - 0.00017184110291610904, - 0.00017172938992879958, - 0.00017161749450588616, - 0.0001715054169532865, - 0.00017139315757741638, - 0.00017128071668518868, - 0.0001711680945840125, - 0.00017105529158179236, - 0.00017094230798692732, - 0.00017082914410831025, - 0.00017071580025532687, - 0.00017060227673785497, - 0.00017048857386626345, - 0.0001703746919514117, - 0.00017026063130464844, - 0.00017014639223781117, - 0.00017003197506322513, - 0.0001699173800937025, - 0.0001698026076425416, - 0.00016968765802352585, - 0.00016957253155092317, - 0.0001694572285394849, - 0.00016934174930444514, - 0.00016922609416151961, - 0.00016911026342690514, - 0.0001689942574172785, - 0.0001688780764497957, - 0.0001687617208420911, - 0.0001686451909122764, - 0.00016852848697894004, - 0.0001684116093611461, - 0.00016829455837843354, - 0.00016817733435081528, - 0.00016805993759877734, - 0.00016794236844327792, - 0.00016782462720574663, - 0.0001677067142080835, - 0.00016758862977265819, - 0.00016747037422230903, - 0.00016735194788034216, - 0.00016723335107053067, - 0.00016711458411711373, - 0.00016699564734479566, - 0.00016687654107874506, - 0.00016675726564459388, - 0.00016663782136843664, - 0.0001665182085768294, - 0.00016639842759678903, - 0.00016627847875579205, - 0.0001661583623817741, - 0.0001660380788031287, - 0.00016591762834870658, - 0.0001657970113478147, - 0.00016567622813021534, - 0.00016555527902612516, - 0.00016543416436621446, - 0.00016531288448160602, - 0.00016519143970387446, - 0.00016506983036504514, - 0.00016494805679759337, - 0.00016482611933444345, - 0.00016470401830896768, - 0.00016458175405498568, - 0.00016445932690676318, - 0.00016433673719901142, - 0.00016421398526688592, - 0.00016409107144598574, - 0.00016396799607235263, - 0.00016384475948246995, - 0.00016372136201326184, - 0.00016359780400209225, - 0.00016347408578676407, - 0.00016335020770551816, - 0.00016322617009703247, - 0.0001631019733004211, - 0.00016297761765523335, - 0.00016285310350145282, - 0.00016272843117949644, - 0.00016260360103021354, - 0.00016247861339488501, - 0.00016235346861522226, - 0.00016222816703336638, - 0.00016210270899188708, - 0.00016197709483378186, - 0.00016185132490247503, - 0.00016172539954181677, - 0.00016159931909608225, - 0.00016147308390997053, - 0.00016134669432860382, - 0.0001612201506975264, - 0.00016109345336270375, - 0.00016096660267052155, - 0.00016083959896778468, - 0.00016071244260171648, - 0.0001605851339199576, - 0.00016045767327056508, - 0.00016033006100201147, - 0.00016020229746318388, - 0.00016007438300338293, - 0.00015994631797232184, - 0.00015981810272012553, - 0.00015968973759732962, - 0.0001595612229548794, - 0.00015943255914412907, - 0.00015930374651684055, - 0.00015917478542518262, - 0.00015904567622173, - 0.00015891641925946232, - 0.0001587870148917632, - 0.0001586574634724192, - 0.00015852776535561894, - 0.0001583979208959522, - 0.0001582679304484087, - 0.00015813779436837737, - 0.00015800751301164534, - 0.0001578770867343968, - 0.00015774651589321221, - 0.00015761580084506732, - 0.00015748494194733204, - 0.00015735393955776958, - 0.00015722279403453554, - 0.00015709150573617672, - 0.00015696007502163033, - 0.00015682850225022293, - 0.00015669678778166944, - 0.00015656493197607218, - 0.0001564329351939199, - 0.0001563007977960868, - 0.0001561685201438314, - 0.0001560361025987958, - 0.0001559035455230045, - 0.00015577084927886347, - 0.0001556380142291592, - 0.00015550504073705764, - 0.00015537192916610326, - 0.000155238679880218, - 0.00015510529324370024, - 0.00015497176962122404, - 0.00015483810937783787, - 0.00015470431287896376, - 0.00015457038049039614, - 0.00015443631257830112, - 0.00015430210950921524, - 0.00015416777165004454, - 0.00015403329936806366, - 0.0001538986930309146, - 0.000153763953006606, - 0.00015362907966351191, - 0.00015349407337037095, - 0.0001533589344962851, - 0.00015322366341071893, - 0.00015308826048349841, - 0.00015295272608481, - 0.0001528170605851995, - 0.00015268126435557128, - 0.000152545337767187, - 0.00015240928119166478, - 0.0001522730950009781, - 0.00015213677956745481, - 0.00015200033526377611, - 0.00015186376246297548, - 0.00015172706153843775, - 0.00015159023286389806, - 0.00015145327681344076, - 0.0001513161937614985, - 0.00015117898408285104, - 0.00015104164815262443, - 0.0001509041863462899, - 0.00015076659903966272, - 0.00015062888660890138, - 0.00015049104943050637, - 0.0001503530878813193, - 0.00015021500233852178, - 0.00015007679317963438, - 0.00014993846078251567, - 0.00014980000552536116, - 0.0001496614277867022, - 0.00014952272794540512, - 0.00014938390638066988, - 0.0001492449634720294, - 0.00014910589959934832, - 0.00014896671514282188, - 0.00014882741048297512, - 0.00014868798600066162, - 0.00014854844207706262, - 0.00014840877909368588, - 0.00014826899743236463, - 0.00014812909747525667, - 0.00014798907960484307, - 0.00014784894420392738, - 0.0001477086916556344, - 0.0001475683223434093, - 0.0001474278366510164, - 0.00014728723496253816, - 0.00014714651766237422, - 0.00014700568513524042, - 0.00014686473776616738, - 0.0001467236759404999, - 0.00014658250004389567, - 0.0001464412104623241, - 0.00014629980758206558, - 0.00014615829178971014, - 0.00014601666347215656, - 0.0001458749230166113, - 0.0001457330708105873, - 0.00014559110724190306, - 0.00014544903269868162, - 0.00014530684756934933, - 0.00014516455224263488, - 0.0001450221471075683, - 0.00014487963255347978, - 0.00014473700896999863, - 0.0001445942767470523, - 0.00014445143627486528, - 0.00014430848794395792, - 0.00014416543214514551, - 0.00014402226926953714, - 0.00014387899970853466, - 0.00014373562385383156, - 0.00014359214209741194, - 0.00014344855483154948, - 0.00014330486244880627, - 0.0001431610653420318, - 0.00014301716390436185, - 0.0001428731585292175, - 0.0001427290496103039, - 0.00014258483754160935, - 0.0001424405227174041, - 0.00014229610553223942, - 0.00014215158638094638, - 0.00014200696565863478, - 0.0001418622437606922, - 0.0001417174210827827, - 0.0001415724980208461, - 0.00014142747497109638, - 0.00014128235233002107, - 0.00014113713049437992, - 0.0001409918098612039, - 0.00014084639082779406, - 0.00014070087379172052, - 0.00014055525915082124, - 0.0001404095473032011, - 0.00014026373864723076, - 0.00014011783358154552, - 0.00013997183250504422, - 0.0001398257358168882, - 0.00013967954391650033, - 0.00013953325720356363, - 0.00013938687607802035, - 0.00013924040094007094, - 0.00013909383219017283, - 0.0001389471702290394, - 0.00013880041545763882, - 0.00013865356827719305, - 0.00013850662908917668, - 0.0001383595982953158, - 0.00013821247629758703, - 0.00013806526349821626, - 0.00013791796029967774, - 0.00013777056710469266, - 0.0001376230843162285, - 0.00013747551233749748, - 0.00013732785157195585, - 0.0001371801024233024, - 0.0001370322652954778, - 0.000136884340592663, - 0.0001367363287192785, - 0.00013658823007998313, - 0.00013644004507967292, - 0.00013629177412348, - 0.0001361434176167715, - 0.00013599497596514845, - 0.00013584644957444466, - 0.0001356978388507256, - 0.0001355491442002873, - 0.00013540036602965527, - 0.0001352515047455834, - 0.00013510256075505264, - 0.0001349535344652703, - 0.00013480442628366844, - 0.00013465523661790324, - 0.0001345059658758535, - 0.00013435661446561974, - 0.000134207182795523, - 0.0001340576712741038, - 0.00013390808031012085, - 0.00013375841031255026, - 0.00013360866169058398, - 0.00013345883485362905, - 0.0001333089302113063, - 0.00013315894817344934, - 0.00013300888915010334, - 0.0001328587535515239, - 0.00013270854178817603, - 0.000132558254270733, - 0.00013240789141007506, - 0.00013225745361728858, - 0.00013210694130366473, - 0.00013195635488069844, - 0.00013180569476008725, - 0.00013165496135373014, - 0.00013150415507372654, - 0.00013135327633237502, - 0.00013120232554217226, - 0.00013105130311581205, - 0.00013090020946618386, - 0.00013074904500637202, - 0.00013059781014965432, - 0.00013044650530950116, - 0.0001302951308995741, - 0.0001301436873337251, - 0.00012999217502599502, - 0.00012984059439061276, - 0.00012968894584199396, - 0.00012953722979473998, - 0.00012938544666363667, - 0.00012923359686365334, - 0.00012908168080994156, - 0.000128929698917834, - 0.00012877765160284333, - 0.00012862553928066117, - 0.0001284733623671567, - 0.00012832112127837586, - 0.00012816881643053996, - 0.00012801644824004467, - 0.00012786401712345873, - 0.00012771152349752308, - 0.0001275589677791494, - 0.00012740635038541924, - 0.00012725367173358268, - 0.00012710093224105738, - 0.00012694813232542725, - 0.00012679527240444145, - 0.00012664235289601308, - 0.00012648937421821837, - 0.0001263363367892951, - 0.00012618324102764176, - 0.00012603008735181636, - 0.00012587687618053516, - 0.00012572360793267165, - 0.00012557028302725537, - 0.00012541690188347078, - 0.00012526346492065606, - 0.000125109972558302, - 0.00012495642521605086, - 0.00012480282331369522, - 0.00012464916727117681, - 0.0001244954575085854, - 0.0001243416944461576, - 0.00012418787850427572, - 0.0001240340101034667, - 0.00012388008966440086, - 0.00012372611760789082, - 0.00012357209435489032, - 0.00012341802032649296, - 0.00012326389594393138, - 0.00012310972162857568, - 0.00012295549780193255, - 0.00012280122488564406, - 0.00012264690330148647, - 0.0001224925334713691, - 0.00012233811581733323, - 0.00012218365076155068, - 0.00012202913872632315, - 0.00012187458013408058, - 0.00012171997540738028, - 0.00012156532496890566, - 0.00012141062924146514, - 0.00012125588864799089, - 0.00012110110361153782, - 0.00012094627455528228, - 0.00012079140190252103, - 0.00012063648607666995, - 0.00012048152750126302, - 0.00012032652659995108, - 0.00012017148379650068, - 0.00012001639951479283, - 0.00011986127417882218, - 0.00011970610821269536, - 0.00011955090204063027, - 0.00011939565608695457, - 0.00011924037077610488, - 0.00011908504653262522, - 0.00011892968378116621, - 0.0001187742829464836, - 0.00011861884445343741, - 0.00011846336872699054, - 0.00011830785619220763, - 0.00011815230727425404, - 0.00011799672239839464, - 0.00011784110198999245, - 0.00011768544647450775, - 0.00011752975627749677, - 0.00011737403182461062, - 0.00011721827354159392, - 0.00011706248185428392, - 0.00011690665718860912, - 0.00011675079997058826, - 0.00011659491062632893, - 0.00011643898958202677, - 0.00011628303726396383, - 0.0001161270540985079, - 0.0001159710405121109, - 0.0001158149969313081, - 0.00011565892378271665, - 0.0001155028214930346, - 0.0001153466904890396, - 0.00011519053119758795, - 0.00011503434404561308, - 0.00011487812946012477, - 0.00011472188786820771, - 0.00011456561969702045, - 0.0001144093253737942, - 0.00011425300532583165, - 0.00011409665998050589, - 0.0001139402897652591, - 0.00011378389510760146, - 0.00011362747643511001, - 0.00011347103417542745, - 0.00011331456875626091, - 0.00011315808060538084, - 0.00011300157015061996, - 0.00011284503781987182, - 0.00011268848404108985, - 0.00011253190924228605, - 0.00011237531385153003, - 0.00011221869829694753, - 0.00011206206300671958, - 0.00011190540840908098, - 0.0001117487349323195, - 0.00011159204300477441, - 0.00011143533305483544, - 0.00011127860551094167, - 0.00011112186080158016, - 0.000110965099355285, - 0.00011080832160063595, - 0.00011065152796625746, - 0.0001104947188808173, - 0.00011033789477302554, - 0.00011018105607163328, - 0.0001100242032054316, - 0.00010986733660325015, - 0.00010971045669395633, - 0.00010955356390645376, - 0.00010939665866968136, - 0.00010923974141261204, - 0.00010908281256425159, - 0.00010892587255363746, - 0.00010876892180983773, - 0.00010861196076194966, - 0.00010845498983909879, - 0.00010829800947043762, - 0.0001081410200851445, - 0.00010798402211242238, - 0.0001078270159814978, - 0.00010767000212161947, - 0.00010751298096205728, - 0.00010735595293210107, - 0.00010719891846105954, - 0.00010704187797825888, - 0.00010688483191304183, - 0.00010672778069476626, - 0.00010657072475280428, - 0.00010641366451654079, - 0.00010625660041537248, - 0.00010609953287870663, - 0.00010594246233595989, - 0.00010578538921655709, - 0.00010562831394993017, - 0.0001054712369655169, - 0.00010531415869275978, - 0.00010515707956110476, - 0.00010500000000000023, - 0.0001048429204388957, - 0.00010468584130724072, - 0.00010452876303448355, - 0.0001043716860500703, - 0.00010421461078344338, - 0.0001040575376640406, - 0.00010390046712129383, - 0.00010374339958462799, - 0.00010358633548345969, - 0.00010342927524719621, - 0.00010327221930523419, - 0.00010311516808695866, - 0.00010295812202174159, - 0.00010280108153894093, - 0.00010264404706789938, - 0.0001024870190379432, - 0.000102329997878381, - 0.00010217298401850264, - 0.00010201597788757803, - 0.00010185897991485595, - 0.00010170199052956281, - 0.00010154501016090167, - 0.0001013880392380508, - 0.00010123107819016275, - 0.00010107412744636295, - 0.00010091718743574887, - 0.0001007602585873884, - 0.0001006033413303191, - 0.00010044643609354666, - 0.0001002895433060441, - 0.00010013266339675026, - 9.997579679456885e-05, - 9.981894392836712e-05, - 9.96621052269749e-05, - 9.950528111918314e-05, - 9.934847203374297e-05, - 9.919167839936447e-05, - 9.903490064471545e-05, - 9.887813919842027e-05, - 9.872139448905876e-05, - 9.856466694516498e-05, - 9.840795699522605e-05, - 9.825126506768094e-05, - 9.809459159091948e-05, - 9.79379369932809e-05, - 9.778130170305294e-05, - 9.762468614847044e-05, - 9.74680907577144e-05, - 9.731151595891065e-05, - 9.715496218012867e-05, - 9.699842984938049e-05, - 9.684191939461959e-05, - 9.668543124373957e-05, - 9.6528965824573e-05, - 9.637252356489044e-05, - 9.6216104892399e-05, - 9.605971023474136e-05, - 9.590334001949454e-05, - 9.57469946741688e-05, - 9.559067462620626e-05, - 9.543438030298e-05, - 9.527811213179273e-05, - 9.512187053987569e-05, - 9.496565595438737e-05, - 9.480946880241252e-05, - 9.465330951096081e-05, - 9.449717850696585e-05, - 9.434107621728378e-05, - 9.418500306869233e-05, - 9.402895948788953e-05, - 9.387294590149257e-05, - 9.371696273603661e-05, - 9.356101041797369e-05, - 9.340508937367149e-05, - 9.324920002941221e-05, - 9.30933428113913e-05, - 9.293751814571654e-05, - 9.278172645840654e-05, - 9.262596817538988e-05, - 9.247024372250367e-05, - 9.231455352549273e-05, - 9.215889801000804e-05, - 9.200327760160585e-05, - 9.184769272574638e-05, - 9.169214380779284e-05, - 9.153663127300994e-05, - 9.138115554656304e-05, - 9.122571705351685e-05, - 9.107031621883429e-05, - 9.091495346737525e-05, - 9.075962922389559e-05, - 9.060434391304587e-05, - 9.044909795937023e-05, - 9.029389178730512e-05, - 9.01387258211783e-05, - 8.998360048520761e-05, - 8.982851620349982e-05, - 8.967347340004938e-05, - 8.951847249873743e-05, - 8.936351392333052e-05, - 8.920859809747946e-05, - 8.905372544471818e-05, - 8.889889638846267e-05, - 8.874411135200959e-05, - 8.858937075853538e-05, - 8.843467503109482e-05, - 8.828002459262023e-05, - 8.812541986591992e-05, - 8.797086127367735e-05, - 8.781634923844979e-05, - 8.766188418266728e-05, - 8.750746652863135e-05, - 8.735309669851397e-05, - 8.719877511435638e-05, - 8.70445021980679e-05, - 8.689027837142476e-05, - 8.673610405606905e-05, - 8.658197967350744e-05, - 8.642790564511014e-05, - 8.627388239210958e-05, - 8.611991033559955e-05, - 8.59659898965337e-05, - 8.581212149572469e-05, - 8.565830555384278e-05, - 8.550454249141496e-05, - 8.535083272882353e-05, - 8.519717668630514e-05, - 8.504357478394947e-05, - 8.489002744169834e-05, - 8.473653507934427e-05, - 8.458309811652954e-05, - 8.442971697274494e-05, - 8.42763920673287e-05, - 8.412312381946516e-05, - 8.396991264818392e-05, - 8.38167589723585e-05, - 8.366366321070519e-05, - 8.351062578178188e-05, - 8.335764710398713e-05, - 8.32047275955588e-05, - 8.305186767457298e-05, - 8.289906775894282e-05, - 8.274632826641749e-05, - 8.259364961458095e-05, - 8.244103222085076e-05, - 8.228847650247707e-05, - 8.213598287654138e-05, - 8.198355175995546e-05, - 8.183118356946013e-05, - 8.167887872162425e-05, - 8.15266376328434e-05, - 8.137446071933897e-05, - 8.122234839715674e-05, - 8.10703010821661e-05, - 8.091831919005853e-05, - 8.076640313634674e-05, - 8.061455333636338e-05, - 8.04627702052601e-05, - 8.03110541580061e-05, - 8.015940560938728e-05, - 8.0007824974005e-05, - 7.985631266627493e-05, - 7.970486910042588e-05, - 7.955349469049884e-05, - 7.940218985034565e-05, - 7.925095499362798e-05, - 7.90997905338161e-05, - 7.89486968841879e-05, - 7.879767445782768e-05, - 7.864672366762496e-05, - 7.849584492627338e-05, - 7.834503864626977e-05, - 7.819430523991267e-05, - 7.804364511930147e-05, - 7.789305869633517e-05, - 7.774254638271133e-05, - 7.759210858992487e-05, - 7.744174572926695e-05, - 7.729145821182386e-05, - 7.714124644847602e-05, - 7.699111084989659e-05, - 7.684105182655057e-05, - 7.669106978869361e-05, - 7.65411651463709e-05, - 7.639133830941596e-05, - 7.624158968744966e-05, - 7.609191968987902e-05, - 7.594232872589613e-05, - 7.57928172044769e-05, - 7.564338553438016e-05, - 7.54940341241464e-05, - 7.534476338209666e-05, - 7.519557371633143e-05, - 7.504646553472962e-05, - 7.489743924494724e-05, - 7.474849525441653e-05, - 7.45996339703446e-05, - 7.44508557997126e-05, - 7.43021611492743e-05, - 7.415355042555527e-05, - 7.400502403485144e-05, - 7.38565823832284e-05, - 7.37082258765199e-05, - 7.355995492032698e-05, - 7.341176992001677e-05, - 7.326367128072145e-05, - 7.311565940733694e-05, - 7.296773470452212e-05, - 7.281989757669748e-05, - 7.267214842804409e-05, - 7.252448766250243e-05, - 7.237691568377144e-05, - 7.222943289530726e-05, - 7.208203970032223e-05, - 7.193473650178363e-05, - 7.178752370241289e-05, - 7.164040170468411e-05, - 7.149337091082326e-05, - 7.134643172280685e-05, - 7.119958454236108e-05, - 7.10528297709605e-05, - 7.090616780982707e-05, - 7.075959905992896e-05, - 7.061312392197957e-05, - 7.046674279643631e-05, - 7.032045608349958e-05, - 7.017426418311172e-05, - 7.002816749495576e-05, - 6.988216641845445e-05, - 6.973626135276916e-05, - 6.959045269679885e-05, - 6.944474084917872e-05, - 6.929912620827944e-05, - 6.915360917220585e-05, - 6.900819013879603e-05, - 6.886286950562e-05, - 6.871764766997887e-05, - 6.857252502890357e-05, - 6.842750197915387e-05, - 6.828257891721722e-05, - 6.813775623930776e-05, - 6.799303434136516e-05, - 6.784841361905358e-05, - 6.770389446776053e-05, - 6.755947728259585e-05, - 6.741516245839062e-05, - 6.727095038969606e-05, - 6.712684147078242e-05, - 6.698283609563805e-05, - 6.683893465796811e-05, - 6.669513755119365e-05, - 6.655144516845041e-05, - 6.640785790258798e-05, - 6.626437614616837e-05, - 6.612100029146528e-05, - 6.597773073046279e-05, - 6.583456785485445e-05, - 6.569151205604204e-05, - 6.554856372513468e-05, - 6.540572325294766e-05, - 6.526299103000132e-05, - 6.51203674465202e-05, - 6.497785289243165e-05, - 6.483544775736506e-05, - 6.469315243065065e-05, - 6.455096730131837e-05, - 6.440889275809691e-05, - 6.426692918941272e-05, - 6.41250769833887e-05, - 6.398333652784345e-05, - 6.384170821028987e-05, - 6.370019241793445e-05, - 6.355878953767594e-05, - 6.341749995610435e-05, - 6.327632405950011e-05, - 6.313526223383267e-05, - 6.299431486475965e-05, - 6.285348233762582e-05, - 6.271276503746191e-05, - 6.257216334898368e-05, - 6.243167765659073e-05, - 6.229130834436562e-05, - 6.215105579607266e-05, - 6.201092039515698e-05, - 6.187090252474336e-05, - 6.17310025676354e-05, - 6.159122090631417e-05, - 6.145155792293747e-05, - 6.131201399933846e-05, - 6.117258951702499e-05, - 6.1033284857178234e-05, - 6.089410040065178e-05, - 6.0755036527970675e-05, - 6.061609361933023e-05, - 6.047727205459498e-05, - 6.033857221329789e-05, - 6.0199994474638934e-05, - 6.0061539217484445e-05, - 5.992320682036572e-05, - 5.978499766147833e-05, - 5.96469121186808e-05, - 5.9508950569493744e-05, - 5.9371113391098714e-05, - 5.923340096033739e-05, - 5.909581365371019e-05, - 5.8958351847375664e-05, - 5.8821015917149046e-05, - 5.868380623850161e-05, - 5.854672318655934e-05, - 5.8409767136102034e-05, - 5.827293846156237e-05, - 5.813623753702468e-05, - 5.799966473622403e-05, - 5.786322043254533e-05, - 5.772690499902202e-05, - 5.759071880833536e-05, - 5.745466223281315e-05, - 5.731873564442885e-05, - 5.718293941480063e-05, - 5.704727391519016e-05, - 5.691173951650171e-05, - 5.677633658928117e-05, - 5.6641065503715017e-05, - 5.650592662962919e-05, - 5.637092033648818e-05, - 5.6236046993394116e-05, - 5.610130696908551e-05, - 5.596670063193644e-05, - 5.583222834995554e-05, - 5.569789049078483e-05, - 5.556368742169895e-05, - 5.542961950960391e-05, - 5.5295687121036317e-05, - 5.5161890622162184e-05, - 5.5028230378776036e-05, - 5.4894706756299826e-05, - 5.4761320119782116e-05, - 5.46280708338968e-05, - 5.449495926294241e-05, - 5.436198577084084e-05, - 5.4229150721136586e-05, - 5.40964544769956e-05, - 5.396389740120427e-05, - 5.383147985616869e-05, - 5.3699202203913304e-05, - 5.356706480608015e-05, - 5.343506802392789e-05, - 5.330321221833061e-05, - 5.317149774977712e-05, - 5.303992497836973e-05, - 5.290849426382332e-05, - 5.277720596546451e-05, - 5.264606044223047e-05, - 5.2515058052668014e-05, - 5.2384199154932744e-05, - 5.225348410678782e-05, - 5.212291326560327e-05, - 5.199248698835471e-05, - 5.1862205631622704e-05, - 5.1732069551591366e-05, - 5.160207910404785e-05, - 5.14722346443811e-05, - 5.134253652758087e-05, - 5.121298510823684e-05, - 5.1083580740537716e-05, - 5.0954323778270003e-05, - 5.08252145748174e-05, - 5.069625348315948e-05, - 5.056744085587093e-05, - 5.0438777045120604e-05, - 5.0310262402670425e-05, - 5.0181897279874485e-05, - 5.005368202767819e-05, - 4.9925616996617074e-05, - 4.9797702536816114e-05, - 4.966993899798853e-05, - 4.954232672943491e-05, - 4.941486608004237e-05, - 4.928755739828352e-05, - 4.9160401032215306e-05, - 4.903339732947847e-05, - 4.890654663729622e-05, - 4.877984930247357e-05, - 4.865330567139617e-05, - 4.852691609002944e-05, - 4.8400680903917706e-05, - 4.82746004581832e-05, - 4.814867509752493e-05, - 4.802290516621807e-05, - 4.789729100811286e-05, - 4.7771832966633576e-05, - 4.764653138477771e-05, - 4.752138660511496e-05, - 4.739639896978642e-05, - 4.727156882050357e-05, - 4.714689649854715e-05, - 4.702238234476658e-05, - 4.689802669957885e-05, - 4.677382990296751e-05, - 4.664979229448181e-05, - 4.652591421323592e-05, - 4.640219599790771e-05, - 4.627863798673815e-05, - 4.6155240517530006e-05, - 4.603200392764729e-05, - 4.59089285540142e-05, - 4.578601473311404e-05, - 4.566326280098849e-05, - 4.554067309323671e-05, - 4.541824594501422e-05, - 4.529598169103218e-05, - 4.517388066555648e-05, - 4.505194320240652e-05, - 4.4930169634954756e-05, - 4.4808560296125436e-05, - 4.468711551839388e-05, - 4.456583563378545e-05, - 4.444472097387471e-05, - 4.432377186978452e-05, - 4.4202988652185185e-05, - 4.4082371651293287e-05, - 4.3961921196871166e-05, - 4.384163761822578e-05, - 4.372152124420782e-05, - 4.3601572403210874e-05, - 4.3481791423170454e-05, - 4.3362178631563205e-05, - 4.324273435540601e-05, - 4.312345892125483e-05, - 4.300435265520421e-05, - 4.288541588288617e-05, - 4.276664892946925e-05, - 4.264805211965776e-05, - 4.252962577769091e-05, - 4.241137022734172e-05, - 4.229328579191645e-05, - 4.217537279425332e-05, - 4.2057631556722013e-05, - 4.1940062401222605e-05, - 4.182266564918466e-05, - 4.1705441621566384e-05, - 4.158839063885384e-05, - 4.147151302105991e-05, - 4.135480908772357e-05, - 4.12382791579089e-05, - 4.1121923550204244e-05, - 4.100574258272146e-05, - 4.088973657309483e-05, - 4.077390583848033e-05, - 4.065825069555484e-05, - 4.054277146051504e-05, - 4.042746844907679e-05, - 4.031234197647412e-05, - 4.019739235745835e-05, - 4.008261990629741e-05, - 3.996802493677484e-05, - 3.985360776218878e-05, - 3.973936869535153e-05, - 3.962530804858827e-05, - 3.95114261337365e-05, - 3.9397723262145015e-05, - 3.928419974467307e-05, - 3.917085589168968e-05, - 3.9057692013072654e-05, - 3.894470841820761e-05, - 3.883190541598744e-05, - 3.871928331481127e-05, - 3.860684242258358e-05, - 3.8494583046713477e-05, - 3.838250549411384e-05, - 3.8270610071200376e-05, - 3.815889708389098e-05, - 3.8047366837604574e-05, - 3.793601963726066e-05, - 3.782485578727825e-05, - 3.771387559157504e-05, - 3.7603079353566624e-05, - 3.7492467376165736e-05, - 3.7382039961781235e-05, - 3.727179741231754e-05, - 3.716174002917352e-05, - 3.7051868113241836e-05, - 3.6942181964908154e-05, - 3.68326818840502e-05, - 3.672336817003699e-05, - 3.66142411217281e-05, - 3.650530103747265e-05, - 3.639654821510872e-05, - 3.628798295196235e-05, - 3.617960554484681e-05, - 3.60714162900618e-05, - 3.5963415483392694e-05, - 3.585560342010944e-05, - 3.574798039496619e-05, - 3.5640546702200116e-05, - 3.55333026355309e-05, - 3.542624848815973e-05, - 3.5319384552768486e-05, - 3.521271112151912e-05, - 3.510622848605278e-05, - 3.4999936937488824e-05, - 3.489383676642433e-05, - 3.4787928262933145e-05, - 3.468221171656503e-05, - 3.4576687416345054e-05, - 3.447135565077253e-05, - 3.43662167078205e-05, - 3.426127087493493e-05, - 3.415651843903357e-05, - 3.405195968650564e-05, - 3.3947594903210826e-05, - 3.3843424374478376e-05, - 3.373944838510661e-05, - 3.363566721936188e-05, - 3.353208116097791e-05, - 3.342869049315507e-05, - 3.332549549855949e-05, - 3.322249645932229e-05, - 3.311969365703899e-05, - 3.301708737276847e-05, - 3.291467788703243e-05, - 3.281246547981447e-05, - 3.27104504305594e-05, - 3.2608633018172484e-05, - 3.2507013521018675e-05, - 3.240559221692172e-05, - 3.230436938316366e-05, - 3.22033452964838e-05, - 3.210252023307819e-05, - 3.200189446859869e-05, - 3.190146827815225e-05, - 3.180124193630029e-05, - 3.170121571705783e-05, - 3.1601389893892635e-05, - 3.150176473972476e-05, - 3.1402340526925595e-05, - 3.1303117527317104e-05, - 3.120409601217118e-05, - 3.110527625220884e-05, - 3.1006658517599575e-05, - 3.090824307796054e-05, - 3.081003020235569e-05, - 3.0712020159295335e-05, - 3.0614213216735204e-05, - 3.051660964207573e-05, - 3.0419209702161324e-05, - 3.0322013663279762e-05, - 3.022502179116125e-05, - 3.0128234350977934e-05, - 3.0031651607342883e-05, - 2.9935273824309673e-05, - 2.9839101265371512e-05, - 2.9743134193460485e-05, - 2.964737287094687e-05, - 2.955181755963852e-05, - 2.9456468520779957e-05, - 2.936132601505186e-05, - 2.9266390302570178e-05, - 2.917166164288551e-05, - 2.907714029498243e-05, - 2.8982826517278678e-05, - 2.8888720567624494e-05, - 2.879482270330201e-05, - 2.8701133181024336e-05, - 2.86076522569351e-05, - 2.8514380186607563e-05, - 2.8421317225043974e-05, - 2.8328463626674938e-05, - 2.823581964535871e-05, - 2.8143385534380284e-05, - 2.805116154645106e-05, - 2.795914793370788e-05, - 2.786734494771246e-05, - 2.7775752839450655e-05, - 2.7684371859331767e-05, - 2.7593202257187918e-05, - 2.750224428227338e-05, - 2.7411498183263677e-05, - 2.732096420825524e-05, - 2.7230642604764514e-05, - 2.7140533619727284e-05, - 2.7050637499498083e-05, - 2.696095448984945e-05, - 2.687148483597132e-05, - 2.6782228782470374e-05, - 2.669318657336915e-05, - 2.660435845210569e-05, - 2.651574466153272e-05, - 2.642734544391693e-05, - 2.633916104093839e-05, - 2.625119169368993e-05, - 2.616343764267633e-05, - 2.6075899127813907e-05, - 2.59885763884295e-05, - 2.590146966326021e-05, - 2.5814579190452517e-05, - 2.5727905207561597e-05, - 2.5641447951550874e-05, - 2.555520765879116e-05, - 2.5469184565060083e-05, - 2.5383378905541523e-05, - 2.529779091482493e-05, - 2.521242082690451e-05, - 2.5127268875178898e-05, - 2.5042335292450212e-05, - 2.4957620310923695e-05, - 2.4873124162206826e-05, - 2.4788847077308847e-05, - 2.4704789286640097e-05, - 2.4620951020011424e-05, - 2.453733250663335e-05, - 2.445393397511575e-05, - 2.437075565346703e-05, - 2.428779776909353e-05, - 2.4205060548798956e-05, - 2.4122544218783662e-05, - 2.404024900464418e-05, - 2.3958175131372522e-05, - 2.3876322823355436e-05, - 2.3794692304374045e-05, - 2.371328379760311e-05, - 2.363209752561035e-05, - 2.3551133710355896e-05, - 2.347039257319179e-05, - 2.3389874334861157e-05, - 2.330957921549787e-05, - 2.322950743462562e-05, - 2.314965921115764e-05, - 2.307003476339596e-05, - 2.299063430903075e-05, - 2.2911458065139792e-05, - 2.2832506248187967e-05, - 2.2753779074026473e-05, - 2.2675276757892457e-05, - 2.2596999514408216e-05, - 2.2518947557580736e-05, - 2.2441121100801117e-05, - 2.236352035684391e-05, - 2.2286145537866558e-05, - 2.2208996855408906e-05, - 2.213207452039247e-05, - 2.205537874312e-05, - 2.1978909733274825e-05, - 2.190266769992026e-05, - 2.1826652851499143e-05, - 2.1750865395833207e-05, - 2.1675305540122366e-05, - 2.1599973490944466e-05, - 2.1524869454254394e-05, - 2.144999363538377e-05, - 2.1375346239040193e-05, - 2.130092746930678e-05, - 2.1226737529641624e-05, - 2.1152776622877234e-05, - 2.1079044951219805e-05, - 2.1005542716248975e-05, - 2.0932270118917064e-05, - 2.085922735954852e-05, - 2.0786414637839468e-05, - 2.0713832152857093e-05, - 2.0641480103039153e-05, - 2.0569358686193427e-05, - 2.049746809949704e-05, - 2.0425808539496133e-05, - 2.035438020210526e-05, - 2.028318328260672e-05, - 2.0212217975650166e-05, - 2.0141484475252074e-05, - 2.0070982974795096e-05, - 2.0000713667027687e-05, - 1.993067674406339e-05, - 1.9860872397380515e-05, - 1.9791300817821486e-05, - 1.9721962195592336e-05, - 1.9652856720262194e-05, - 1.9583984580762825e-05, - 1.951534596538799e-05, - 1.9446941061793063e-05, - 1.9378770056994478e-05, - 1.9310833137369088e-05, - 1.9243130488653877e-05, - 1.917566229594529e-05, - 1.9108428743698775e-05, - 1.904143001572834e-05, - 1.8974666295205897e-05, - 1.8908137764660935e-05, - 1.884184460597994e-05, - 1.8775787000405825e-05, - 1.8709965128537588e-05, - 1.864437917032973e-05, - 1.8579029305091746e-05, - 1.8513915711487652e-05, - 1.8449038567535537e-05, - 1.838439805060702e-05, - 1.8319994337426875e-05, - 1.8255827604072303e-05, - 1.8191898025972746e-05, - 1.812820577790924e-05, - 1.8064751034013946e-05, - 1.800153396776969e-05, - 1.793855475200957e-05, - 1.7875813558916298e-05, - 1.7813310560021967e-05, - 1.7751045926207302e-05, - 1.76890198277015e-05, - 1.7627232434081526e-05, - 1.756568391427177e-05, - 1.75043744365435e-05, - 1.7443304168514532e-05, - 1.73824732771486e-05, - 1.7321881928755075e-05, - 1.7261530288988383e-05, - 1.720141852284757e-05, - 1.7141546794675948e-05, - 1.7081915268160542e-05, - 1.7022524106331624e-05, - 1.6963373471562405e-05, - 1.6904463525568425e-05, - 1.684579442940726e-05, - 1.6787366343477974e-05, - 1.6729179427520695e-05, - 1.6671233840616247e-05, - 1.6613529741185683e-05, - 1.6556067286989747e-05, - 1.649884663512863e-05, - 1.644186794204141e-05, - 1.638513136350566e-05, - 1.6328637054637013e-05, - 1.6272385169888727e-05, - 1.6216375863051337e-05, - 1.6160609287252176e-05, - 1.6105085594954873e-05, - 1.60498049379591e-05, - 1.5994767467400096e-05, - 1.593997333374817e-05, - 1.5885422686808397e-05, - 1.5831115675720174e-05, - 1.5777052448956777e-05, - 1.572323315432503e-05, - 1.5669657938964807e-05, - 1.56163269493487e-05, - 1.5563240331281616e-05, - 1.551039822990034e-05, - 1.545780078967313e-05, - 1.5405448154399413e-05, - 1.535334046720928e-05, - 1.5301477870563166e-05, - 1.5249860506251423e-05, - 1.5198488515393948e-05, - 1.5147362038439817e-05, - 1.5096481215166858e-05, - 1.5045846184681296e-05, - 1.4995457085417375e-05, - 1.4945314055136963e-05, - 1.489541723092921e-05, - 1.4845766749210105e-05, - 1.4796362745722175e-05, - 1.4747205355534067e-05, - 1.4698294713040261e-05, - 1.4649630951960514e-05, - 1.4601214205339733e-05, - 1.4553044605547445e-05, - 1.4505122284277506e-05, - 1.445744737254769e-05, - 1.4410020000699399e-05, - 1.4362840298397263e-05, - 1.431590839462879e-05, - 1.4269224417703989e-05, - 1.4222788495255078e-05, - 1.4176600754236111e-05, - 1.4130661320922593e-05, - 1.4084970320911206e-05, - 1.4039527879119367e-05, - 1.3994334119784972e-05, - 1.3949389166466085e-05, - 1.3904693142040434e-05, - 1.3860246168705272e-05, - 1.3816048367976926e-05, - 1.3772099860690487e-05, - 1.3728400766999503e-05, - 1.3684951206375628e-05, - 1.3641751297608287e-05, - 1.3598801158804419e-05, - 1.3556100907388011e-05, - 1.3513650660099939e-05, - 1.3471450532997587e-05, - 1.3429500641454454e-05, - 1.338780110015997e-05, - 1.3346352023119073e-05, - 1.3305153523651944e-05, - 1.3264205714393723e-05, - 1.3223508707294186e-05, - 1.3183062613617335e-05, - 1.3142867543941295e-05, - 1.3102923608157822e-05, - 1.3063230915472133e-05, - 1.302378957440252e-05, - 1.2984599692780111e-05, - 1.2945661377748526e-05, - 1.2906974735763676e-05, - 1.2868539872593322e-05, - 1.283035689331692e-05, - 1.2792425902325284e-05, - 1.2754747003320302e-05, - 1.2717320299314639e-05, - 1.2680145892631462e-05, - 1.2643223884904177e-05, - 1.2606554377076191e-05, - 1.2570137469400482e-05, - 1.2533973261439498e-05, - 1.249806185206482e-05, - 1.2462403339456867e-05, - 1.242699782110464e-05, - 1.239184539380548e-05, - 1.2356946153664776e-05, - 1.232230019609573e-05, - 1.2287907615819052e-05, - 1.2253768506862737e-05, - 1.2219882962561836e-05, - 1.2186251075558115e-05, - 1.215287293779987e-05, - 1.2119748640541646e-05, - 1.208687827434402e-05, - 1.2054261929073317e-05, - 1.2021899693901367e-05, - 1.19897916573053e-05, - 1.1957937907067259e-05, - 1.1926338530274176e-05, - 1.1894993613317536e-05, - 1.1863903241893152e-05, - 1.1833067501000918e-05, - 1.1802486474944566e-05, - 1.1772160247331445e-05, - 1.1742088901072316e-05, - 1.1712272518381085e-05, - 1.168271118077462e-05, - 1.1653404969072462e-05, - 1.1624353963396685e-05, - 1.159555824317164e-05, - 1.1567017887123709e-05, - 1.1538732973281154e-05, - 1.1510703578973834e-05, - 1.1482929780833054e-05, - 1.1455411654791319e-05, - 1.1428149276082123e-05, - 1.1401142719239768e-05, - 1.1374392058099152e-05, - 1.1347897365795578e-05, - 1.1321658714764514e-05, - 1.1295676176741409e-05, - 1.126994982276155e-05, - 1.1244479723159811e-05, - 1.1219265947570456e-05, - 1.1194308564926982e-05, - 1.116960764346195e-05, - 1.1145163250706704e-05, - 1.1120975453491292e-05, - 1.1097044317944236e-05, - 1.1073369909492333e-05, - 1.104995229286053e-05, - 1.102679153207167e-05, - 1.1003887690446406e-05, - 1.0981240830602968e-05, - 1.0958851014457006e-05, - 1.0936718303221404e-05, - 1.0914842757406183e-05, - 1.089322443681821e-05, - 1.0871863400561175e-05, - 1.085075970703534e-05, - 1.082991341393738e-05, - 1.0809324578260273e-05, - 1.0788993256293097e-05, - 1.0768919503620928e-05, - 1.0749103375124634e-05, - 1.0729544924980744e-05, - 1.0710244206661316e-05, - 1.0691201272933795e-05, - 1.0672416175860808e-05, - 1.0653888966800111e-05, - 1.0635619696404393e-05, - 1.0617608414621115e-05, - 1.0599855170692465e-05, - 1.0582360013155102e-05, - 1.0565122989840136e-05, - 1.0548144147872937e-05, - 1.0531423533672998e-05, - 1.0514961192953843e-05, - 1.0498757170722896e-05, - 1.0482811511281315e-05, - 1.0467124258223927e-05, - 1.0451695454439093e-05, - 1.0436525142108568e-05, - 1.0421613362707412e-05, - 1.0406960157003853e-05, - 1.0392565565059195e-05, - 1.037842962622771e-05, - 1.036455237915651e-05, - 1.0350933861785459e-05, - 1.0337574111347066e-05, - 1.0324473164366373e-05, - 1.0311631056660878e-05, - 1.02990478233404e-05, - 1.0286723498807036e-05, - 1.0274658116755e-05, - 1.0262851710170606e-05, - 1.02513043113321e-05, - 1.0240015951809633e-05, - 1.0228986662465152e-05, - 1.021821647345233e-05, - 1.0207705414216424e-05, - 1.0197453513494281e-05, - 1.0187460799314207e-05, - 1.0177727298995905e-05, - 1.016825303915038e-05, - 1.015903804567991e-05, - 1.0150082343777922e-05, - 1.0141385957928984e-05, - 1.0132948911908665e-05, - 1.0124771228783522e-05, - 1.0116852930911042e-05, - 1.0109194039939557e-05, - 1.0101794576808155e-05, - 1.0094654561746707e-05, - 1.0087774014275745e-05, - 1.0081152953206404e-05, - 1.0074791396640419e-05, - 1.0068689361970052e-05, - 1.006284686587803e-05, - 1.0057263924337509e-05, - 1.0051940552612027e-05, - 1.0046876765255499e-05, - 1.0042072576112127e-05, - 1.0037527998316364e-05, - 1.0033243044292918e-05, - 1.0029217725756705e-05, - 1.0025452053712766e-05, - 1.0021946038456325e-05, - 1.0018699689572687e-05, - 1.0015713015937242e-05, - 1.0012986025715443e-05, - 1.0010518726362785e-05, - 1.0008311124624773e-05, - 1.0006363226536888e-05, - 1.0004675037424615e-05, - 1.0003246561903414e-05, - 1.0002077803878662e-05, - 1.0001168766545706e-05, - 1.0000519452389823e-05, - 1.0000129863186222e-05, - 1e-05 - ] -} diff --git a/tests/fixtures/legacy_pipeline/aria-cartesian-camframe.json b/tests/fixtures/legacy_pipeline/aria-cartesian-camframe.json deleted file mode 100644 index 75990205f8..0000000000 --- a/tests/fixtures/legacy_pipeline/aria-cartesian-camframe.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "aria", - "representation": "cartesian", - "frame": "camframe", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_cartesian", - "observations.images.front_img_1", - "observations.state.ee_pose" - ], - "api": "legacy", - "transform_arguments": { - "mode": "cartesian", - "stride": 3 - }, - "normalization_frames": 54, - "archive_sha256": "7aaf6dc3c82a95a2c844e76918334ad2762dd0e0ced038fe117baf928b5cfde6" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/aria-cartesian-camframe.npz b/tests/fixtures/legacy_pipeline/aria-cartesian-camframe.npz deleted file mode 100644 index 0529be4357c39392b696a4afe21014aa9a38b41b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38565 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 diff --git a/tests/fixtures/legacy_pipeline/aria-cartesian-eef_frame.json b/tests/fixtures/legacy_pipeline/aria-cartesian-eef_frame.json deleted file mode 100644 index 6cac58d2a1..0000000000 --- a/tests/fixtures/legacy_pipeline/aria-cartesian-eef_frame.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "aria", - "representation": "cartesian", - "frame": "eef_frame", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_cartesian", - "observations.images.front_img_1", - "observations.state.ee_pose" - ], - "api": "legacy", - "transform_arguments": { - "mode": "cartesian_wristframe_ypr", - "stride": 3 - }, - "normalization_frames": 54, - "archive_sha256": "d456f93c3dec0d8eac559f46a5f56cabc0c42f813d2e4feac78fbf34d92fe65c" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/aria-cartesian-eef_frame.npz b/tests/fixtures/legacy_pipeline/aria-cartesian-eef_frame.npz deleted file mode 100644 index 0e34574bf1b4628029cc34392176a93591c43884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37688 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 I!18bZ2RXEHumAu6 diff --git a/tests/fixtures/legacy_pipeline/aria-keypoints-camframe.json b/tests/fixtures/legacy_pipeline/aria-keypoints-camframe.json deleted file mode 100644 index 5f43a2dfa3..0000000000 --- a/tests/fixtures/legacy_pipeline/aria-keypoints-camframe.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "aria", - "representation": "keypoints", - "frame": "camframe", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_keypoints", - "observations.images.front_img_1", - "observations.state.keypoints" - ], - "api": "legacy", - "transform_arguments": { - "mode": "keypoints_headframe_ypr", - "stride": 3 - }, - "normalization_frames": 54, - "archive_sha256": "4df87e74e7b8e88aa69fa4f41ca3325ca65c0c2a4a0e8b6dc0ee987c2160088f" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/aria-keypoints-camframe.npz b/tests/fixtures/legacy_pipeline/aria-keypoints-camframe.npz deleted file mode 100644 index 08607bb708171e57502eae00d929c14406e6f649..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71866 zcmeFYS67o;^#7}(qS91UKuUC@u$3kv9g?l6sI;vpMM~(|N~oceq9Pz&WYbG@t02-r zdI>dvK%!DYXn}-IAPFI)LXP8KF3!z)1LwEKSmkD2tof|x9oYQE4=02kJQ{c-?Zh7sL+G!@z6AQbHk*Gj%~lM*9rWHTKIyTF=c|hsJP#jJ z!X9uBY_f{EapPj~imb7u$qn`%eC{pz$nh2LKknVjJK}l`Vy0$ze((66!wO44FJPL0 zFwgsE9!DSsBkgZn-?HggS|bGMPr?ZLrAkl{SjvJjr1j!&l5J|0vX^0aSp z6xH|duiOk9+7OTZeJvw9yoDzzxLH*YYbZn*d7&EB#3Mhd^Suc<6H5FWZHDxs;npXc zb)XjJQuT)g@~2w=Xdcu+w&6x4j;7F9OaV`x+jVZJpI=NuSwpg^;wfywC%3=ic{{8L z(}n%r{u8N%j_7m#b28`Jb+1ITUuBOn#PWL*0$5jNr}94KCpEvA5=B*xz&4p7ALTFW1so*XtDT~B_E@X z$7tX~xWO58b~g1s$%0SXwf+;#yA!s+4*!&0WO5QX7jFGXf3-AZsT%Pi@EP0E;+fto zhZ8$EXlQH3o(j9a4+b3v`I}<)aejgTDYbcZ%EF-X_MnW;)&!NHj zX%IfaLolm$(#Y983iTDiTsQW^*@aqc=wSERltD+ll%$5a(dEzFc(FOB zg5zl>de*~i{(OA)a!iHa+7OWQsMvuPm>RV5@ueWlR zQQ4`$0|=#>P6pM{IgS&?awPA&kH#OL@h{L;CDI+tg0Y{RZZTr*y|$Sr-Z_Duo=h#+ z^Y8lZv~^GOdBJJD&8K;f8@H@@ZB6QyzSFtlJd^vt>FXxt+_?s?=kYl+x?A7h%0)!X zkC^U1`g@95^1*xa`zsgGmCI5}mEy)pdMX8h-N#k`XZRl*{Lel3pJ(v@DFuQ6LpeCE z;~FFLP7rMH`($&QrwKpTb}f}ewJfVLIoWZ(-N2gb<){!|4up@h=OQ-qb%PLmmk*z3 zyCeFzx-|`-ufX@K)+4-n_ZW-5{O%tbO!CySE`#%K`PzH``|uJdLVbTKo&E!n?Gl4EooS9xu_ke=P zww46#R4IReo`hZHE)3<;*K#h_*v3B$3V(*K zUYz~FsJHG5!pRi&G;dgt0e}u&$$z%w?etyc!KE5WX#}v+#CXaZF=OjDx_GN&q;?q8 zBx3moCk)p3BMRy2yhoAuSeZ%x@B!{nz#b1}w71K=UNXmG&r(#dRrZp{{QAwYWc|DS zbExQxFviP=HGKLsWz^|w&u+VYzj@D?!NwTqI@m4~s~w(}*l!UXmtmgdfqqCK*8yCW zZjG-tG3cL~qP`#bU`se2L`?pVLnQHIrN{3u>bbV$v6ATcGN<_7^jJc_^paw?Vvm1n z?$?BxU}*o3k6{tiou4B?xAEP7Bk8}*Uvnw=|MN2a|3__vDC>QRzyDuw`p;huYX28b zRsRp126(`{gTvha7oloC^K*xI!!&(@1N=k4&-@@@?f;9aFX;apQI);^_nbK!H0f>s zM2TMZS?}||#d@E|io>3l-p*?%{ljXceZ|`L!RWcofB);tT7UDhIE#Fy)GBkAd%IT~ zYv6Hy^o_wEn*=?L8 z*PYnkD_=B?34#9d6m+hsWeHggjT$7_ z`q{;DV)ZqJ?2iDKeY!Hhaeuy>H56W8@@?h=nm{R7_Z@&q43+EV{k8%!T3HuT$aZF}f| z_9*^(0Dowj6d~m6lY+Jvu{tzf&B$(+aDOGmo12%7fnOZ0LiDL+{XUiPx!Ox$wve7r zvUGLYo}O)$B{jCM|LTh%(c4j;{5%?`Y`PA_#5d4{EL7`Wv9wG8a{s01ITU71bRW0X z9H5JxWmSC*3rGCKPDLR3AL7sL?axXf{)rrK-2c2;)y{6$4f87y>|$y!=LfW{6)!0Y zSI_~N1#G{@;#~}eP45{`4dPOF0fOX0b#RklaW-5F1S27a>@igDA%Y_2AMV7ozw+pM z42s0fvnlO}ibI3L1)OCRiEn8zDVW(~YlZKxKDpcjj;7U*ld6my#+mMKOmG5D7(Yed z6d69kb!mm@V|FT6G#6=+jeBXBrdf9wY*b&cR;Z~(9OSliPgPbd*^`)Uv*CCmlCCdo zM`lsod{r^A!~Oyu=njT8yF(+8*d6cq!X{RNlW?9~CMlUkL(Z1?voifr^E+!2a$L3! z8-d$3!!QAjt-yeA#OVHR32EP^!Pt1R^RNPP!w>yGwm>JN`xPpu$Pp8jU9I z8&7vVsMRGJJa-T%xIc4T2Epfw0t498{w?TE@Z@CY>u36$EA%ZUmm+lmcy^b&Ary ztM?(G616CWsl{4^f%j_DNbC5m{Uf6D2?5PZS$CYS!PZ;RZI{jbnzAgrvCm(lklZ1aLHr(t4r+#hnHJozrE0ZCSN-&a@Sps7S5u zwQi`|!(onQ%SZeXJvl}I!&fj@`LE;)~vKZ5Px>IF-!tCCe%lgNHgbkc}@Hm+fcF%_E`+bGWzw z@0dQPBD`JD>{i_!Fj1;M{&5739g7TB8Z4mmu`c0OY)gSiXw6Y!FDU^SmToU*5|Cx#CL~2Z`xZlPl&8{aU2Z z>FxW?{cPS!6#PIVX@A|G)&aVUc2a1k^pV{uJ?2~O+7u^fu z6-#-k6!RFZS$-ZRB@;PB6X(SH1Al{8Ux4~5u9<3|-f{;x1c_qYTR<*=am}ceKBC* zF2HpA9#M3!_JfP~-;8tU&r%Ueka-P-L?$s&j}=Mqcfo6N{@k3mtT{EDDAS9mJ}6V& zG5REBynbu^s3Q-@qUN|He*UJcB5j)pOSUh#jm8!$h9&iIZUW;GO-gngFQYCkYE~iH z)u=Ct+6U{?@wf@M2&3MlcG)+ZqgMwxvm4>gJ?5jLs7~uXC?|%x@W!%Neyc0r>rdRT zR0iehw!U3Zm8?<~r?43h+3E@lPZ2U=mMf!%hvyzND#$x)4)7Z0nXTks+^m ze#5%Rmvq@*JG{C2RKU8t>nAhH1t-Ek`F=HL6)xa>Hg;^5d6FU#QdxaXO;2Z5kh{lx z6F%bXO|&XsK0_zmVJNG=86{rU?4~sVidblqSu^Bs)h7Ha+-!a@c=~Q&B5fcIRU+@8 zch310=$mrt5{PKvq=#}t@sgDLR2O@XuF92=Pw&T>^eTl|6jzhwiiRo?0Uckg^iqe` zq*C1!0J*}Dwia!F?Js!uG;5`l@qmhE)xuSoy?5y~3*o%p&($YZc2YkQk15P+=#?oN zjLEdbC>E$GUD9c*LU{gCX`_S+K7#zr&TcFm^s6%t!`|iGWa!TRIwG88zG$&`YKkA( zBc<&()}F;rrPPERmAUpxC+MR1&-kXBmbYnwllgvHG8NrvArytljz4YuQ43^ z4iR8rL&HdwTcQA7j0#lI>pTd(haM2K8$_q{sLge=sx1Km8Kg37S7qXC&yQC8LQl?K zk0TFTOcamyfO(l!ML6=|`p!M89EWXIB%GMZvMlJ0=V-!mGko=Gl8nD+*PdmFIK6HC zQBu_NuT?@&a#uas+LOSZB*Z^x;EcoXCLguflF=ed)zskojG%9a`))y9sUJ!3KNS=A zf?^E1WI5$7PQEiQe1nCXxUdrM98-3 zwEzbqH6q}BX`l`YvXw1hEw7`0f z`a-LdS){hSqmUDOgmHrJ4uPg?1|DzWt@mzic4JIqVx1x|XsCl_mPGrG)6y=919(&xYkKHZ1_eQ7w18pY>Fp~ymB!zJ< zATv@qnuv?aO=s2Qq~xlP?8~Qdi}0MKa7?SgstblwHH4i10jkNw1RZv%HAaj!L}Xc| zYms6|j0$|pLoh2J^U>V-fjL>t;G(?jJZ18-_<}6bLEJ2!cYlu8rO7#B5Uc9kk54dU z)NZUD@@E_#Zovue$a}q#TK!j@2;g=I%7hMF--3}iRvJ3i*x~h)HcNeK{1#o=+Cxpy zF?nD9+AR?0FZ66x;jpx1Wp_MhD3p9%MRCZ zU1}k5Hg0y&UMF3FUm{`)qcxH7+F^@IyC+aoQCiPwbSi#T4mg-*k}=xe@M?6!C0ZeL1k{5Q*#}>sckxYyHm`f6)w0 z(W|jvPPexoJ~%}8hgNs1xW9B>eOt8y-7C=vRyAXXDMf17Er=06h5dGXA$KtmM`XG) zWN){UUk%eLs|;nE88qlr#Jo)9WFj{KIj>XmM&YCaPH@t77)%t)t#Oon2|#6FAY>gA zNCD>_fOC(Xbh%GnU4LUDXl2qpY1l;Rh zRF>v(IN}E3#lwz=$#`>$q$dOJS9fa9Z3uc7T7syNPo_KyrpqA~6pbJ@bVVmjz%!w( z;FbQ4Ox&sn;hhwGK~$KW-Zm-Ht_l%se>Y8>-ieobVH$)ACPMU}ZzZ`892RZdUKGJ4 zMM5s^>$u9^SZ>2KRl?CtqM9so)WU%*VmV%gbYZ{eKD|^6eqw7>70n=#?c4h8=T-ff zIw5Ov)Lh|vRENDh`Rw%@=y{o-52(eiiF)&Z5HSGnu*FX7%0L$FT(&PLXwi_S(-?wt zrR(JW&`Ew*M`CChpABe{%p#tZ?>1b-pWAt|MT7}+|^ON)q@G}oFOW#evptZws zy}HZ#STpeQWu8&(i;KbiI?${NS)IxlPvnpA9uaD;^Nu_%(UJ3yk&1jrjJh$zzO5vQ$H?yv^oj=Vso-mx*Gs~~5}m%S zM1@&lz4~b9rAUVZmj581HStsDJWhU1Sxd(plX3kbn+0FHHFJ;_e7qXhxL~ul+wm8! z3j)Q<4%p;Rd4^w$<(r_bY)tNjWyI+C1jPv?G*>XI$BMX7+r2i`e&rqgGMp#Lo7_sK z0tJGf@+9%P*q0#RzDA0dccmEIS6>MLrWMUsth5iciFDuhfONbW`FX_!OB!geY+zdd z3+;Q>^rSq7nKJ4iuv*@E&>wN?C_SDNBbY2g%IdT#M;CY@*TdnKjXbQ>P}%%nZ1M?a z{~{b<3xfu^&3!0b3qMfr`jH$Cl(wmZ?A})uWb9X)Ds-X~<^VV$4&0s%$S0Cbf7XyL<*Y;@3&R+DKM-wIKTf**D#zxqDxwC zmq8Ov-^Cgn*=60ChO4%(3;Tl)Id&Pt6LpgNn^psZ)r*dck^O^T0m8aLyl3jmX3N#m zkn>uRvM+ZBGpn%u&_kl3CK&8s_4fc~I9w9i(}of(FKR3!4l2CMV00e>t7Uhco76E$ z2nf8?%*h9Pw6@#%SV=)GTRm>({M2i!Ge*AhUrTPw$gg54_Q~gxX6x2;#Vl5=^&=!3 zCNT|&qGqP@Wf46htS8aTS+#hqVPiF4SYlS&PYm%$El1JSG$8=qaZz~rKKUu5Bv^yFYq2INcT6nxrapl@drGI$e`ld)l0{i1u*cqf zcmgS?1;W@{e$N{Qyp!5X(ET<7VZ?fzqx9Z~W>%=OBmaYITE3=fRti-2m-(ytT8l}X zOO0VQe?CAP!d9o>^ED|~gE7CCK4GLE?CV%bGzq%OxCxzDm(BTndGTVcB zANkpl;T3o<|BfY{HX721#*UL`-Z62bk{=lsre13eS={CaatObbhPjC<~B(b=hw|HF71jRdoUvm6`om0f*!K z8SJTs#o=o0)&iH*dQbB1Xi;O7;!tZ@esx+3uSaK%H?jwkP(LM|Jf*I6mCz%n)*^3l zErS^u!eyCW;|M3}I|W|;_td`jBAcp3veEU65L%Au=;Y+3)Ns?b?j!s>-Iw%OOvZFu z)AkD>q%81~q|MxN{bfkRdnuSA>=KSe34{bzBJin-}jG<(3cs zu#cZQs{o=|OM}`Pp~-%UOi1B}=)N;sGWzp(9Nf@k3w)eu*nKM_a2X# zS`Md12MlbdDMrJ#;IZk5l)7`huVopw|L~k8#y=Ul!P0i>OaeJ(oFlK?N6ww;eeth&n085%CBm{<6j3^$64E-1=A#bH9zMp!vYfZz)gaZ22K8JY_4Kr|vJ?I-WWhIyle4woUir3}!mks_+(E~L=5J*-z)@5%9lA-j zo_Q%s&lMPm{}L;w{8ivJY;XuiJPUo7&~yO33dkpmE(GlO7exH#tAiaqY{yoFBe4Pa zqXI&SEw5`SCI`2i|4?C8)y(rSd(`oy(f`6*@jsBuUb#*UHZ2C+%QF=?42Q9$e0zEPXH zus#>^U1w9SF`J@J!SUA3bivr0WVNUrV`CZVbTE}$yB$+!_f~U3495jiF7z*gmB9N) zEBE{h;L?Del4|4K_n3%B(0k^Bw}z6)OKJWj9y5_PDU;h2?)94euFh&^1E%no)Rdkp zFjc6k<$O;c+i9O55||NWU36}1@POXJZGrce2!x*SG4P^ww_Mz-AA;(8;BkUc z$Joe*P~?E|Hm3klsU37?i2n9>Ubfg$0I_UiGB0we4RjJs_II{|kZ(r0mr-A9Vyk|r z73OQb)7I?LB9Ac~r|!FQ9WRTaHgxzWX>>MnJ;NZ*l5@zRc+^p?o93;A`X(rb>pIzJ zuG$V^llwS(Te)7Uz@FSG50b!af}>E*2%MOkc^v5+d@a?S!e{ED?-#Le^*w1)xI6q1 z;`bFux1pYF7@RX$Dj5X02DWllkh!{;kYcH(V;z%MF&4mO10bbj^$IhaFwu5@I--Xy z_%oGZY{*qAnm?v_TB`D@s%UgX4n|qj^9&@xn|K58%9buR{mR(HMl*6^El+j9*;9<5 zaE16w*(v_v-j4w0U(6}$<*4V}Gus~F%bs#&f z{c<2u%i9;7)&N>wR{ObmzM~j}SVcM^1Rs~ISlKJayC|t={yN;@)y0B0qdO!)c#(M{ zS$41Y8FUsd>hDwa0z(`wAM6B$yabP&C7$BG!zkzoel$XSB*;I#x5sw=qmh-6Qw2E{ zgE0}yAAOaJTq8CO15D9HeeVaa09%`0JF{Yd?oL45-nG7S-;8Oyfh9rt(Vd6D#h_eD z$7igz;f)A_h-=c&16_KatN&Pt3efr4nDL~>r>dlx+9WCx}S)XGpEC*YD& zWR(_6;99EH@<2oWX`Ho?3r+@a`PMYON#z2x%ABil!_Ynn5aP56emvUN6)ZuKjnPHk2N0}eJ zCocU+>u9zy#_IATR}0YR^OftUAY*5m?7*8ggujBON^vmcTOgbk4tJYNAUKOgFPc|kNzudKkA6F1?_s8(dhVw^^3lw7-!hIZ zDCirS{>t3VemYpn5tWWO?Ujr8&#_KJG*N`3Q5*Z28e=l~_H5gBtRN1F=lv6zkQ-fV zd;JupN4=eggF8BZ#3blQeckh~@^EvwsJ#8stl$I;TZsXF4BvbvmKN605qyMaeYEI< z1W?9?ZS?b(p=MY5HBV=QJE=#oBY+(l)y2Yvn+t^_??jv~Lxv&f`@3X~w`I*}*?HKxhXf zWCBV##QaMbmExQdzDx7=+ZeLYtL(#*LwLPRXcvT&ip1G9g>QU&-wF zZuis0^{YEo_Qv$lgRu(?R~bjySBBbuB)?T|C!g9=)oF59ZOYVoaR(oYVN{Da7ws8U zePJX}xWhdAs^P2uE2bl>fWSnFB>7AjJUHTaBFE5xbw2L z*RWLWNSif@22*?JM*tNz@o^i5eb=6orUA$fMW? zwW5^1P3%S27iQWTnr>Adc1wB$wiAv-T`UatF`YG?{~l#NJfuRL=Z6&;$toQ7h-N6f(`4bC4~| zEPtpBsAb9R{Ud@}_=Fp=$5~gtXNGzQq=$cPRF5>@Ej>HrYldDEsruQ>IRU5x6%6ezT<1%H9f1mSJ^>fxyRPsCK)(gq z+yrYAwLG7MV1S@MfbcrKU0G&t4ac9)-^qxm&FJ}^U8KpC0{`cl&xZ*xh^0n2R4H;F2=9XukM zNMRV_x=V>M3~C;!3EbwcKvwqLO?EVXptabKNxuo*qWVnW$$3-+Dr!((B_#!<=kIVG zGNHfyz%QK&i@zB0!)}_{K=}7ef3&7`yz1^?hns_VuS`N@N!6+W!`9C%3Izj^J}z z(|~S!+Q;>*AERTDgB>GD`jl)psk`%MeQNRZ)T5GX*5JY1{UCMpZJ%YII?Y5>NCmJ< zC;vT+hdYlSKW{xBzg4$SjyWsUJhP+^c-$ZlY2Q8-aSCagz2#=v@dw`#-9C)` zw>;kXA;0qX^K%h3XUK)04X<%xSk8&L+RYpvDpJnEvD4I)KG1M`cDQ^}x8LvJ(xN43 zP)Bhr%6z+%(7d}(zRMerH(7CsJ<@Qu5}LOO5ppWL^+Se=qd_6{~mbB3-3{2;g-Qy2G0@`~?MQroBfp6BEIhtK^^nRZ`pTR7Ji zi}o=UHMFd>Lj~6U_G~b9;lXL_e^#(F$ddrFs?m#zMS0x#RDJov5IoT)w<*(^h=Jtc zYrxy%D!3ri{nxfYjSb!-5exMffSo58MykVY^g`-z;NMjh>VWIb$0`3{>U?0-+oV0s zyD_3Lg#4(?DC9uyU5x^@beFZ9e#{TC3SBUykq-&Q5xEw~A+GmqF+q$m_*PuY}h zF3PWpth~+Lb+ZC?!*3NGD~hV{3aX_Mzy4-R9nA)_O6pA^$G2tR=@0uYg7Uk;a;q5d zWY-FIH8=1}8W18fg$*j&;LR`3ewL~^?EG%6ysl@jRDO5=JM164fH3K!s9Wx{x717q zAjr%~U8gv($veStsnx_9rZnV6{iT^9{na!^ZrXYCxl_Q$k!N5x5x?yBy!1Imt}5m> zLN9aC?bC}Btn@@J2ra8)BMHf$fwe3@a1WW=t3Flh(ZhK*G3O21Gq;fP@=!8E+eVuk zUsIEMq4fyAqL_(3DV;8w8kbLHM%*2ddY1(M6*iGh_JrjTVT%ENmLs61%i!%B)(&Hm zO3V5?83Em<@T4ZsT`HZshy$hT%e|5BDGbn}cKyBLch&xPA_yS+L2B2^F62twsg-}i z=OPoV`rx)J8oF^st_<bq;&F_?A=es9VZJ+;Pt4P`pfSXO4ZO`g(Y9JONFQ3ob49i}J7?f$%vIK(6i+ z??$-ZY{}fRnJz$o9~U{nf4Yf3-&m<{&(2Urjq}%ikrx~Ccz3EV6#XHMe7f{&gH}Q2x1;Z+|!@$zunxeFT z0lWN+(GuI;l>os_(zTR+=PIzE812#NYTG)oJSO`|ZN?^Sh;@|tQq;~7*M3xI z60p{`yipgnYnT~ewfANGfpElZ;3#c)CR?#f36#`m zjIocNRKcZ|lZb5Wspg}=0^ynP956*J=cRPB;U*4Q>)QW! z$Bla2Tm$|yuazV^)`mCm!Tcy!d;Nrobl5dCwUGeuK0umYou%lL?pF*aB{3J$@$MGb zf$$&o7>0XQN%=knNr(V-3b!|Y*Rc$okMnWK5e}Ty>J-nVoFmMuw}gXctY=Mkx27Cr z5~LPyPZ}Bag-L$bn`Dg1df8Vm@yao%_Yuk)Ra>Q#@sjh_RD!OdUf1H~*zmBPhPquy z{eKqMp(t0_(1Xc=GGS$mk*Hw5_3UU{ zF~Q=y&^9e~5~R|S5Ryt8sY~|W4%|5b-aZ7icH$g4HyLA)VDb&bdOT1FUN_A-tCeWG zlO6B?xHW$jcAE2JcT7#T^@R;SEmiV)YVSEy9o-B5;93xEY8zwQ|M&izk(IsK1y|po z`|TF-;hWXbwb%7(jssPF6&3@4$LwZ6OcZ9LY`4V~vUFmkdG)ZWP2~r~wu-MOe%#fT zA+O#wOV#>am_|3=NbPNHI_ zpM*IPj2B>2G;8ctN9~mR3iYkx;9i%8NfVCqO$-A+&ZXz?{O1QWn>o-Q^267&;dfpJ z#6RA5W2!fu(*twQrv~;?E8=RXo(~WXMivrF(L?BwD0yeC4!v_hvWSHPHOiURY-O11L48(4y(&H^` zM9b0V72qF|{4Q>L+-LYP<4%a+CF9+3oQe~pVrAPDGd&3JXD*!isj00@3+=T3F z(Lj$xl~OtP!@86PheD@3R-mWsLsBx=Re{$qGCjl54!gRc%s$KImce$@%Ay25s|R+v zM9F!{(Bo)Cj;>mW= zX!?2EPzIyobwVGw{g9Q8RvI(;gMS!(oiY`oQBTgMvRleMF0~Bi?TkOo$5sXuiP4&3 zM#$3IExXYb+d%NDQNs*P|9RzGQ!ueAr=C=Hw!JfVvXNwFhZ?xP@x}g0okUgcP}&^J z_c>XDR%d&?KZbV@zvJt_T+*gYG81x?a0^HKBlK`K62jq_)mL-Ng?|wmCRc9uk*VKx z7(t#*@Uns;-&J*Zk)id(d*^A^w!h}q9~;zzPp{f7vsbe*d92%HHN%72C#}==ijPWi zL&R%_9c4J*L+j$jcIyDq_3w;*!b=V5P2~8>@ydq35I|o$=YIV)N}K*m5oeuxS4frV zzSxB8ShV3Ij7Z5dz%t`kuf($V)EN;crLA*Ss?nN3XYy??!T&koU&6&q8{t7VMS)^ z(J}%*1DD&G_W#nZg~wTWo|JCI$qrwMLfjnrIUDaw=TiZ(;m&`lHUXPAKK?Gq5sf-y z{+3-}tfTvxRY8D0QSx|B>J9=LyN0c+B2NirRFgJH!INm+1(u})${TlK52Er zOlnF#u(ok?Kt;FSci*Mv4mKo|dl_~sBEK1iQ;IYXs+>7p*{5a;u(15PS$^Eo6)}PR z$4n6C>7FS$X;|PbG1k>#ozrw8U@d#)d)eFBw)~$fW0&vE-VIW6Sp%5V8BgAKj;5SO z!`gy7xAI|!uz>T@1xcoX)qGPJuKg#Fnm{Yg5{C~@{R4E2t`@W4St2t&o+J#nYU7nf z^hVB&lszI*;hQ?W{n+=4HEUx|IYu>+HjJN%zQIV_=f}w4UBi0Ck}QA1?3A^_%Z*7H z^px(aF{P_=-6!);mApNxej8<;O|)N`e+>{vFZ;1DfkVNKsTG;!)ifT;AmFdG^=#Zq4}?*qAR+L6jo8 z3kHblx;~G0I#&g z&x({XQV<1fi%|WZmoT5KZH%8CO8Pmh$Zv;pMRzZyZp+PeN-z2sJy-Ym?H$y<5$aog zM^v~rE)vL_*2z{J!}^vH);kOw8uV+-e4+!sxtKIL+h*1Wf*$VgG{W58hyJTc6fY`3 zWWkgycj=hoYyZ&|$1pfokO`XA5uZJ#ZPYO+ z2aA6IvL*c8T8fJLMwV`P869*2k#Rh7)fn>uLw4nca*DIZ3?eKT7aW-}9r-KaR;{eD zSj0E?H>x*ei-qp;zv)ff`=~i(j=@ZN11iV z)h=3mSzV63eX8_|JhYmysF|Jk(sT&4`yz(>C(}|pBd960d{QeHKXXI(PJoL_xJ`DE zRTfoljynXzlubbXk>YTZRs9G`3H);Ve%Izo-1fw~2j} z-gficQAe(^D1A@7^h4e0=cj+Day#3qto%@@68}yv`6)ikZ}&TRlHgOs#yY~>xvqqk}3izV|fn{)S>6dRI!DD$#3RI5&P~$&*iu(O?Bo9 zHQu>CUUje#y0|d^p3s*inLHvO!ib5@WxJDYD|K=;C!iY;kKESfZBu=hE6MY{KG|8~eOSRI{!aUTIIv{rt{vwbdbx12t016y zq^NfzYs~OVUBpCSXLye9e6-+xwj;_Rv~y!`s^H}>*r#`=#Jv<33qPYGH4w3VRC#=h z{0tQ6hWyD2R0|LXOr2wMObLLp<}VJJ=p0y=3jRHb9-HRU4`#4J62nacCXDNxIArD{ zK6Q&cN;K2v8~K2|)dbCX}99Wj8K+75in^Zlb}OB3rjTc=c$ z_yENd;U_dP8A33rbHx!vOT#MWiY9No!Q#1RwV z%9XD-d!ts?;eZ+q;3WWjEU2kI$~!4=4%tSa7m>WuHkzNR73qZ5qQ~ob!}f_AtK$1J z`YbJ?=I{tGuqmbr7#~Js6AhK{a&|RrKDygF z;YzGRk|Xto_gDYM3`XJ9l>@Y!A^mz=#E7Fx@q|*oxxMX5oL{Y93spzMw<4=aT&lBU zG7mj)9^EDa*{IoM2NYKd3u=z41-c8o3fPbJ{{`hnY6}>CaJwqf7tZ08VWk~IdkM(4 zw(Y3VO1457JE4?`@LN0W^KDQoK_fY4gf8QO2>XnNVK4iS6z!S8&cQad$Fn+c$R{0- z6wFz zBC6Km9>Xhw{i~^#JMuKqeB*?d@Pc!?z97PG_sm!K0$nYGzo-NI3*i%rJ%v71}vll35;76E=9}-#YM3u`arJ?=qvGL zQUuqI_nsLEj#KT$)MaNBcHP3gX$v;y+Y&4BtCpbEQ+f=2ZqG%4ZfJwE4T*PC)E1qj zbwdv{IMXy}Uhd}VG56Z{+q2YTL(6W}2Psg=s<)Yw$*95Hm+@Eii)qyJTGpmF8?Ni_ zZA)_VYD)~o3gN+7yw9-I=#h(@=<9A$rTu~I>-=o)Eo7Fs`+NM~RSe41B8LX9J;!rc z$k?*2TBhd}4gBJ8Pw$;Uca2c{wYP}T&$hO5cT`fB!Uw7-FKP*ul$Ny79xW}$WyNFG zZdxvQFJu0?aS6spo+lxe&|a67pkAW%ttP2;={KC5b54$Tzo0wdKTR7s6VP>S|3>2r zPh?*N_aWX(4BoICWfjtQqCK^Fv0#$&&tNG*JYZS;6K5`_){}zX!_>{i06vLr=R}6# zdWLzlN?#|FBBpt&jE^%wxN~pm1w&7W?Vmr2U}Oje&)N^#uCzZ||JE7ueBRXAP}|6& zus;!%jnh>h+^n!8|AAj)OgKy8(?GADAzmvA4uE!^As6?F`Q5_w=-nG&`+aahY?*2m zkbm)Xw)1FxD6Pt*z4_QyzC#jt+jOSe12>CkBEVdlFM$#Nd6X8cg+sWH2w2rwJ3|+a zjCp5Y0l}i5iVbztXSI>9VUp0vIx9aFuK1shuel$YJ_f^-1l#YJBlnk8$>w(XK!V5?8hJ+6I6!3}j+)cQrFXIBz^WWoec*`neyQq~tR)wP7Z)hO5+Yroe%#I`G{ z-1y^3s*U(X|Ga`r`ySxsOoJaB{$5mj+I@-gR^Rrr{d{JN%-z7EoF~yRJJtMnU5Eeh zK>7C*!!~dlFu2PwFuR3Q#_H{1R)##U>rr>XxjqDnhWAYL&XtabH<9F*3KI>jakbnw zauko9`V#N<5mC&&?&}&Q?2fZGMLTmV3w#T7+}3WQT7~=wPn^nfeqdmv{v_HyJNvfxf3T zHK%c{ZRKS-wA%jbki#~n5kLnL%d9|3%+^*^OAcHmTIFzBiE{^0Y-^M=DUTn{eR=JB zgWD-cvueFz(PP5|u^#U~Z2U-{{GEKj=xnwP zu71OR%=tBxC5@u%EDiz}#B6FL3SzZZ3(+rK1QK<`)=c4~4^GGM3DeRjpXIf9a~Y;t zzOu$%oLI5^o3o(I{Zkej6D%47YwJY!)X|8xgzY?2KqhT8fODEK%$4A1@xRv*Vl_fc z8=@LV{7rfchW8dRJj*q5ZUHyaYGis4mt!hcB`>YGtyK*QeZiXL5R)(~Gip3(>0!sO zgDiA;y@??~Q@5ZGd+ip`0~+kTVQ{e6jkH(Sa3X6&$oXSWFZ)N;BD-wDfvX*K&azdq zV_6Y`L4A!>MX6PGneNSe=Pw`)Z#wtKTe=2qXFy$sznlBaj?3?^XxCkC`K4vh=jeqa zuv~ks2=}UJ{N(kgwgbRcJ@;|#VzXK{v7DtRlVD@uXz)s!1JNlI`>F#P2*&BG=DGDv z*Tp&iKkU6%RMYReEv%xV(p0235drBzd&NN-U(gcb;(B1L+U9*9Vj7KBKL z&`T%@NDVDC6M82Q>S2%d{nxkG+Gm`z*VWkuJf+&=W>ZkQ9X1) z3%)%Dz>05V@1k{OB#EgxgwcU*KaY0O3>>@a>-Fa?PU$>-Lo&I#d_~_-1YXNTXj3aF zs$bpt1~y|>#Nez50&_NQUpJrxvu&5uuMG!|0U#dN0SE5x(b?+~l9{RXTs4H_5K7Gu z$2L3?&a;tGDoV>ObdY-f0nzQe(q}L!`|{+Yp?GjA)@yp{T?Sk1c-pAb5j3ql)F{7w zgxpaa8mKcHc6=al`nVS$-HD5q@X`)n8w&oc8BjAi?PVHOoG=?R(|u59&@FkCw8+J@ znQSCgXB@{RJTO&Ee=9$%!SC#CpNsF5)1Rk`aAz`w40X@pr~Cu^Q<@|Tcw>;Z*OdhE zG%((vz1>L6WF?&B&`N|}JbjOW!{z&N6#kV~v4~Q`(}+lV9{ejvIgG{grG$WUS_Ok+ z0z7d;XKGxv2i88ZVW_DB=A_cG(6zd(OfQ2}IeRnwdwyaw3>Bf>wr4W~a2z|CyDi_* z?9aDi#_u?B7(HEOu)~Axq^~Z)ZVLxqQk^9RA+){w{m`OfXI}zw2`1u!a-eg!s|5_H zaz2%J=$oQ&;Yi;zBJArUnnAlX6b<>%gq_dPqY6()V+As_q60@XwP-0^mi-QnlX!{j z=4CI0y==)6@-|0+1cf7|a=bk8TX`#>^fS$X6b=+)pAq;H+*$lgQz-!v3iJv|U+mO% z>LY7pW*Gl8bm50fJES2&Co_W394z#bGNWc3%j8dOCjxs>P{F9VUt9aNj_2k(qFwcD z%DDD}Rdix&?24P$MqK(~ob7(cGN+BRk-IQxtirw!>{ct#)2!ih9L z&wtuq-8ayCJz=K~VUxViu%A@2Qnb?6-q2WeEMhJL4mh#p5m(ySHo*q=$2jylxYA5E z338W)ajo(EMIgv)!{1S6pv$NfQk{di6;uX?;~pd}A5}GbWe3ym%S%`D*D` zsb`sQ$}TJC>;rltFuQSUO)zD@5tzd_C2KS%oGA&0`za>?nziLnUFKFSnxl74P_ zplK~w@&Ac2qUAF8`poanX+5pU#kbwt;Do}uqvz4!0H+jCwVAY26 zQ7F{Zo~8as9CjRDHs3oV*K`a-&4C;6u-5D9pp~dO>sgQ=Kj?y+iY#nw1j@F%+FHHWJbT z=qJKY)s*Q~?Mye&6>hyOFQJ72u~8d*r*}(>uPaodS*x)^97z9DRr9VCnwIr?-8_Em zEv6QmW20sHUQ{6roPN0XrD58D6a;c*PEVi2jlz^kQnJB16&n~ z1XxbrJ<@VpUbxd90L+H7y9O@bRBRYgfCRvW%Fee;)0=3d=$PoerkaZKQ1^Os5Icp0 zD67zdqZR&)B_=?VfKdVs$`%BTIe4S|3Hzb7U?OaE0WcGtQU0tlUY@v71nM((@NHH1 z5G18anICva4(e{x@6UfR@(XupGxCU)e>BQyV8JlDt-HD8cV-u;Il?+^j?z_J;PhpT zX$mYYDK%m4Eqi>z-w4=GEVZUH@^j6yQe~FmO7To_4L6+qYC33GIy&jmTb1EWUGUh` zh5X_mQul31+tbXC-iN~I!dA5aNaNNcT=rHtuL)dQIc5WS4N;Wxegu(wx}4hA7Cxr} zZ9QY=H!Y)NUSYlJASV+rd5y|v^37&LnNHt*$$8m6&_T%7iEGrv468bZy9#w+dX_&z8U0N)FyE_f*tT`u6I-uhiJZFzzAVYp zv)RFR1L(iLC?jnPEcM3NhjIiwUkx=})UDf%qBzzL$?QC}%ebD&09+7G0IlNFTVG*X zl*S0uVd=GMAWRzJZ0V}qE{E=%nOjug+%y`u%>!lh594qr3`=!*k;>GqHbA1LZ*!(= zb`lVSL>Mr!B7h5+ZaK9fd?RpIde6LzQqINZFxEJ75~~lbDyCg zV}|{6;gtTCg|*8IWf@R%iymnU^@|{Z3`kxbyJd<&#iJWqZ@j&uu=z}!n=#ITaAxrl zw%2zeE(>a2mId^Y!{(^*5Vg`^0bCPTBg3lAh8srr?34e=#rZ+vR>VRVErBD(Z}Vct z(%EWRjsw{N3czAmYligZBZwUQ3f-w7t^!YHWS}8jZ*bw~o~LxDr6IJIy}KCy&3uh; zGH)$N6bHSXlaNF!lB12iWBq>+1-%tKDM2R|L>)m|iBj2s?RiihN_|8f_F(7fEuK<`J8(zllo!NhirO{K-rkoJll~a})kRX4M|Fz^V@vN*8ijbFSMlJ`6uVtU zWS)Dp7G6VGrGi<;018w(YUTA%0DXTC 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 K*&koK_J080bC)>) diff --git a/tests/fixtures/legacy_pipeline/aria-keypoints-eef_frame.json b/tests/fixtures/legacy_pipeline/aria-keypoints-eef_frame.json deleted file mode 100644 index 5ce0ccb599..0000000000 --- a/tests/fixtures/legacy_pipeline/aria-keypoints-eef_frame.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "aria", - "representation": "keypoints", - "frame": "eef_frame", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_keypoints", - "observations.images.front_img_1", - "observations.state.keypoints" - ], - "api": "legacy", - "transform_arguments": { - "mode": "keypoints_wristframe_ypr", - "stride": 3 - }, - "normalization_frames": 54, - "archive_sha256": "fac7145706aa87b6e20b008833488d659c82b0ba70c9d9eed9aee29ff91f2894" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/aria-keypoints-eef_frame.npz b/tests/fixtures/legacy_pipeline/aria-keypoints-eef_frame.npz deleted file mode 100644 index b834ed1c8762dac3e27bcc49313d0e6a16512b7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71997 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 diff --git a/tests/fixtures/legacy_pipeline/eva-cartesian-camframe.json b/tests/fixtures/legacy_pipeline/eva-cartesian-camframe.json deleted file mode 100644 index 3b11468aa6..0000000000 --- a/tests/fixtures/legacy_pipeline/eva-cartesian-camframe.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "eva", - "representation": "cartesian", - "frame": "camframe", - "identity": 6, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_cartesian", - "observations.images.front_img_1", - "observations.images.left_wrist_img", - "observations.images.right_wrist_img", - "observations.state.ee_pose" - ], - "api": "legacy", - "transform_arguments": { - "mode": "cartesian" - }, - "normalization_frames": 54, - "archive_sha256": "a4908f0de8cae703a2b5281f9da4e8ae61f30a0817c874000c8c7c056899ecac" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/eva-cartesian-camframe.npz b/tests/fixtures/legacy_pipeline/eva-cartesian-camframe.npz deleted file mode 100644 index a1e4d5b0799a177593742082426d5a233bc16a0b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 93192 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 z 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 KwB<*|rvC+^jDVy7 diff --git a/tests/fixtures/legacy_pipeline/mecka-cartesian-camframe.json b/tests/fixtures/legacy_pipeline/mecka-cartesian-camframe.json deleted file mode 100644 index ce6a59f921..0000000000 --- a/tests/fixtures/legacy_pipeline/mecka-cartesian-camframe.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "mecka", - "representation": "cartesian", - "frame": "camframe", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_cartesian", - "observations.images.front_img_1", - "observations.state.ee_pose" - ], - "api": "legacy", - "transform_arguments": { - "mode": "cartesian", - "stride": 1 - }, - "normalization_frames": 54, - "archive_sha256": "7aaf6dc3c82a95a2c844e76918334ad2762dd0e0ced038fe117baf928b5cfde6" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/mecka-cartesian-camframe.npz b/tests/fixtures/legacy_pipeline/mecka-cartesian-camframe.npz deleted file mode 100644 index 0529be4357c39392b696a4afe21014aa9a38b41b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38565 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 diff --git a/tests/fixtures/legacy_pipeline/mecka-cartesian-eef_frame.json b/tests/fixtures/legacy_pipeline/mecka-cartesian-eef_frame.json deleted file mode 100644 index 07a13e6bda..0000000000 --- a/tests/fixtures/legacy_pipeline/mecka-cartesian-eef_frame.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "mecka", - "representation": "cartesian", - "frame": "eef_frame", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_cartesian", - "observations.images.front_img_1", - "observations.state.ee_pose" - ], - "api": "legacy", - "transform_arguments": { - "mode": "cartesian_wristframe_ypr", - "stride": 1 - }, - "normalization_frames": 54, - "archive_sha256": "d456f93c3dec0d8eac559f46a5f56cabc0c42f813d2e4feac78fbf34d92fe65c" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/mecka-cartesian-eef_frame.npz b/tests/fixtures/legacy_pipeline/mecka-cartesian-eef_frame.npz deleted file mode 100644 index 0e34574bf1b4628029cc34392176a93591c43884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37688 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 I!18bZ2RXEHumAu6 diff --git a/tests/fixtures/legacy_pipeline/scale-cartesian-camframe.json b/tests/fixtures/legacy_pipeline/scale-cartesian-camframe.json deleted file mode 100644 index b10df9e19c..0000000000 --- a/tests/fixtures/legacy_pipeline/scale-cartesian-camframe.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "scale", - "representation": "cartesian", - "frame": "camframe", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_cartesian", - "observations.images.front_img_1", - "observations.state.ee_pose" - ], - "api": "legacy", - "transform_arguments": { - "mode": "cartesian", - "stride": 1 - }, - "normalization_frames": 54, - "archive_sha256": "7aaf6dc3c82a95a2c844e76918334ad2762dd0e0ced038fe117baf928b5cfde6" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/scale-cartesian-camframe.npz b/tests/fixtures/legacy_pipeline/scale-cartesian-camframe.npz deleted file mode 100644 index 0529be4357c39392b696a4afe21014aa9a38b41b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38565 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 diff --git a/tests/fixtures/legacy_pipeline/scale-cartesian-eef_frame.json b/tests/fixtures/legacy_pipeline/scale-cartesian-eef_frame.json deleted file mode 100644 index 5f33ed50f7..0000000000 --- a/tests/fixtures/legacy_pipeline/scale-cartesian-eef_frame.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "source_commit": "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2", - "vendor": "scale", - "representation": "cartesian", - "frame": "eef_frame", - "identity": 3, - "indices": [ - 0, - 7, - 29, - 47, - 50 - ], - "model_keys": [ - "actions_cartesian", - "observations.images.front_img_1", - "observations.state.ee_pose" - ], - "api": "legacy", - "transform_arguments": { - "mode": "cartesian_wristframe_ypr", - "stride": 1 - }, - "normalization_frames": 54, - "archive_sha256": "d456f93c3dec0d8eac559f46a5f56cabc0c42f813d2e4feac78fbf34d92fe65c" -} \ No newline at end of file diff --git a/tests/fixtures/legacy_pipeline/scale-cartesian-eef_frame.npz b/tests/fixtures/legacy_pipeline/scale-cartesian-eef_frame.npz deleted file mode 100644 index 0e34574bf1b4628029cc34392176a93591c43884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37688 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 I!18bZ2RXEHumAu6 diff --git a/tests/fixtures/libero_native_scontrol_408787.txt b/tests/fixtures/libero_native_scontrol_408787.txt deleted file mode 100644 index 9b80c1cc7d..0000000000 --- a/tests/fixtures/libero_native_scontrol_408787.txt +++ /dev/null @@ -1 +0,0 @@ -JobId=408787 JobName=af-noaug-exact-gpu-v2 UserId=paphiwetsa3(41003) GroupId=loaner(41000) MCS_label=N/A Priority=10000 Nice=0 Account=loaner QOS=loaner-normal JobState=RUNNING Reason=None Dependency=(null) Requeue=0 Restarts=0 BatchFlag=1 Reboot=0 ExitCode=0:0 DerivedExitCode=0:0 RunTime=00:39:27 TimeLimit=02:00:00 TimeMin=N/A SubmitTime=2026-10-07T23:17:33 EligibleTime=2026-10-07T23:17:33 AccrueTime=2026-10-07T23:17:33 StartTime=2026-10-07T23:17:33 EndTime=2026-10-08T01:17:33 Deadline=N/A PreemptEligibleTime=2026-10-07T23:22:33 PreemptTime=None SuspendTime=None SecsPreSuspend=0 LastSchedEval=2026-10-07T23:17:33 Scheduler=Main Partition=rl2 AllocNode:Sid=loaner-gateway:61717 ReqNodeList=gpu19 ExcNodeList=(null) NodeList=gpu19 BatchHost=gpu19 NumNodes=1 NumCPUs=8 NumTasks=1 CPUs/Task=8 ReqB:S:C:T=0:0:*:* ReqTRES=cpu=8,mem=128G,node=1,billing=8 AllocTRES=cpu=8,mem=128G,node=1,billing=8 Socks/Node=* NtasksPerN:B:S:C=0:0:*:* CoreSpec=* JOB_GRES=gpu:h100:1 Nodes=gpu19 CPU_IDs=32-35,144-147 Mem=131072 GRES=gpu:h100:1(IDX:3) MinCPUsNode=8 MinMemoryNode=128G MinTmpDiskNode=0 Features=(null) DelayBoot=00:00:00 OverSubscribe=OK Contiguous=0 Licenses=(null) Network=(null) Command=/workspace/users/paphiwetsa3/tasks/cotrain-velocity-first-wave-20261007/rows/usocket-noaug/noaug_exact_gpu_v2.sbatch WorkDir=/workspace/users/paphiwetsa3/tasks/cotrain-velocity-first-wave-20261007/rows/usocket-noaug StdErr=/workspace/users/paphiwetsa3/tasks/cotrain-velocity-first-wave-20261007/rows/usocket-noaug/logs/noaug-exact-gpu-v2-408787.err StdIn=/dev/null StdOut=/workspace/users/paphiwetsa3/tasks/cotrain-velocity-first-wave-20261007/rows/usocket-noaug/logs/noaug-exact-gpu-v2-408787.out Power= MemPerTres=gpu:256000 TresPerNode=gres/gpu:h100:1 TresPerTask=cpu:8 diff --git a/tests/fixtures/libero_native_suite_corpus_authority.json b/tests/fixtures/libero_native_suite_corpus_authority.json deleted file mode 100644 index 814ee77b94..0000000000 --- a/tests/fixtures/libero_native_suite_corpus_authority.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "recorded_authority_sha256": "559b6b3884eed7e39bdbdef12af67f4b033030af355a408db7c62354de23c31f", - "schema": "libero-native-suite-corpus-authority/v1", - "suites": { - "libero10": { - "dataset_logical_sha256": "3ed2b86cf97eaa27045af81e6d6c4834c8659d15eda69a2394d835e602014e1d", - "historical_split_sha256": "c588c831ef0deab61a59a21840ea9e11dafe92a170d9023a87e0acee831df236", - "training_seeds": [ - 42 - ] - }, - "libero_goal": { - "dataset_logical_sha256": "c190385dfc8a7d05b966a4b5f94651826b8a906b1e63d8b348edc70df52c1f60", - "historical_split_sha256": "207c24115104bf0545e7f165abb91cf052eb99f6568446f11e835f9a3e4c6af8", - "training_seeds": [ - 42, - 43 - ] - }, - "libero_object": { - "dataset_logical_sha256": "40b5e3ccf4febcbae470690755adc8aa6a39471a4fcb7fa585a9bdb8e59c325d", - "historical_split_sha256": "5ade2a86fad7552457e093944fd60efec8b51fbe9dfd20831f627d1c56e9b5d7", - "training_seeds": [ - 42, - 43 - ] - }, - "libero_spatial": { - "dataset_logical_sha256": "ee28d02e8c6cd28fb0a235fab2ccc649b662e6f3e10eb096d89be082d2871860", - "historical_split_sha256": "15640eada8102c7071dcfcd153ef438cb98c61835474a2fb2c3c9415cf6d714e", - "training_seeds": [ - 42 - ] - } - } -} diff --git a/tests/fixtures/pusht_config_before_parent_cleanup.json b/tests/fixtures/pusht_config_before_parent_cleanup.json deleted file mode 100644 index ff2c1db81e..0000000000 --- a/tests/fixtures/pusht_config_before_parent_cleanup.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "hash_contract": "SHA256 sorted compact JSON of unresolved composed config excluding hydra metadata; integer keys normalize through JSON", - "recipes": { - "action_flow_bc_usocket_bridge_likelihood_s42": "ff73c1ecfe60c29af17c6ae6806fb797984f22064af6656b7c40f745915caf56", - "action_flow_bc_usocket_graph_section_s42": "4c7284b1f6cf021e6baccc8b3846b1155d73587c2162fe5606e798abe6851d74", - "action_flow_bc_usocket_latent_fm_sg_recon1_200m_adamw_lr1e5_s42": "4f586952aa5862aadd5fc5fc55387e1e33aeca0255b4cbd7082634fc6e2fdfd5", - "action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42": "3e2c27eb94568c5adb00c02b05e2a13ee700a598c53ed4224deecfdf1c370a09", - "action_flow_bc_usocket_latent_fm_sg_recon1_s42": "bb976a267641d6db9d6d1bfcff5341dbec9e140463987e6517e53f73d040a493", - "action_flow_bc_usocket_recon100_s42": "12617b5c6a477a08b1e35f5c0e079fe6dcca9c17b032439afc0f800dde4a420b", - "action_flow_bc_usocket_recon10_s42": "d31d062bb28d81066265b958eb8da7a97a48fbffaf3797a29e7157f4cd2e72ab", - "action_flow_bc_usocket_recon10_warmup10k_s42": "99e22eb4a39538e0fc05a4fab9fd123d2cf8fcb2ecc1dd57b18586baa01f2e59", - "action_flow_bc_usocket_recon1_s42": "dd4f13fc095b59ed10fa7f29cd3b2e3e25cbd4e8ef4048c3c564438cd2f248fb", - "action_flow_chain_latent_fm_sg_unite_h384_sum14_cfg4_val8_s42": "cc754f3f742641463584355992a5ab9a436da9bde736f961802a0ffd2c6176b2", - "action_flow_cotrain_uc_multiplier_interpolation": "9755ee842621fc3e5aa483d373ff24348159045453d5ac2b258e82c2f0707af7", - "action_flow_usocket_candidate_common": "5e4faf536d9e6d33d6c2e82b0a80c37bf4beb08954e4cdaf637afd7df7318f9d", - "action_flow_usocket_latent_fm_sg_unite_denoiser90m_sum14_cfg4_finalval1_s42": "62150360dbb2e8daa02af64cf1405835e03e012c3ab30c45fe759a3fe66f0a7c", - "action_latent_vfm_usocket_val01_h16": "c47bd51273b01fa5b516ec781ebe119e5d6e9774697990b6d3172705aca0b839", - "planar_uc_manual4919_dp_261m_af_obs_multiplier": "e25f5f9f69b205cb10206e41a896922d4eeb5700a04597d6b086a4d3b44914a2", - "planar_usocket_chain_manual4919_standard_dp_retimed_masked": "699dc867a101146e0fc6c6162b3c30514037d802dcf39f195bb9d4c29b0263ba", - "planar_v2_base": "c9e7771e7f023a9742fff0e662693be6877372c736b3c1e8da75f32a3e2ee060", - "planar_v2_chain_arc_bc": "da9b94f023d8bd098fcee001c8011a3af099c19982de203c62b6388a160fd068", - "planar_v2_chain_base": "3ce62f0b980eabc2ce0b3228f9a11b2fbb73ca8ee1fd12eaac777b9ec3bc18da", - "planar_v2_chain_direct_bc": "4d49cdd26c3066cd04f4a4632008369e7229dcf4562862a1356616fe966c21f1", - "planar_v2_chain_dp_paper": "811fb66e1e7c25138c693251de0c698d437f579f26393e87a4fb89f86c942fd1", - "planar_v2_chain_dp_standard": "bdf14917b32f5d1440417ffaca4321ff5531c284d60e46db29081236c8c8ba49", - "planar_v2_cotrain_clean_dp_standard": "868969c07806c29e50846bbf8de0022437d54e4882ef6b0c3228f90b94737a2d", - "planar_v2_usocket_arc_bc": "6085104c0e08534e8540282a5d33c5d01b247c2596f5f4281b10418e44a306a5", - "planar_v2_usocket_arc_graph_tok": "8f019000f4a449dcbc890519682fb7156add1be19396a1d85857c9a81d4bfdda", - "planar_v2_usocket_arc_hybrid_D40_M16_R24deg_bc": "752d6dda74daaebf2932ae57d544a7ed3c788fb83f1501993a8be51147f665db", - "planar_v2_usocket_arc_paper_curvature_D40_M16_R24deg": "d464be794e7c2e700362aa3c9dbd448bdf53127057d37187d2874c99a77e7d53", - "planar_v2_usocket_arc_paper_uniform_D40_M16_R24deg": "1874d622ce05170c9cc9456a3742a849bea7b86b24886d230d3455ae07af93ea", - "planar_v2_usocket_base": "72885fa5bff3ef2b48c9a0a1a2fdee2fc75b7f87af3ba6fe834b4c191deb7f44", - "planar_v2_usocket_direct_bc": "64b297d8bd5103de726f69c3c666f75fa6ce014b715448db092cb4edfd887923", - "planar_v2_usocket_dp_paper": "79f87ad8c8fa6cc0835c82ef297222b4310b00d705bd895d350b41174523cc42", - "planar_v2_usocket_dp_standard": "c7f54de8e746a66027e92bf9263e839fa1ce2d5db2a3825485f8b161c6a3d990", - "unite_cotrain_usocket_chain_val01_h16": "56b9e9e6fdc1c8ad38d81148d7f7ba7713b2c817e54ba1dd14d4a15707a52d8b", - "unite_usocket_register_sweep_val01_h16": "9823d8dd3107639ebe9a4755bba27b5f38757baa2c8b10e6e9395ef05082c84f" - }, - "retired_data": [ - "action_flow_chain_points6_frozen4920_val01_h16", - "action_flow_chain_points6_manual3000_val01_h16", - "action_flow_chain_points6_manual4919_val01_h16", - "action_flow_cotrain_usocket_chain_manual3000_val01_h16", - "action_flow_cotrain_usocket_chain_manual4919_val01_h16", - "action_flow_cotrain_usocket_chain_points6_frozen4920_val01_h16" - ], - "retired_recipes": { - "action_flow_bc_usocket_latent_fm_sg_recon1_codec98k_s42": "7e1bba2ed089e54b2d74c2ddb34cb3472b77c8a2e79c9cdf9f560402edc8792d", - "action_flow_bc_usocket_recon10_warmup10k_flow001_s42": "4f5bf734d0d8d2d72282fef6da53ffac86bbb76bb76a233793ca8923a2dca21e", - "action_flow_chain_manual3000_avln_80k_s42": "07c2daf65519e44e0ae96d84343bcc68b16b384eb600035d68d8bfc8fb4eae34", - "action_flow_chain_manual4919_avln_80k_s42": "3332315e2d95057076f5df303bfa5b8252c9f3fc5d185200ba12fb07cc128c42", - "action_flow_chain_points6_latent_fm_sg_unite_h512d14h16_sum14_cfg4_val10k_s42": "e31546f683f40d60ffc719fe80dda8dee375826d4295a23ce488c648d57d97dd", - "action_flow_cotrain_uc_latent_fm_sg_unite_h384d12h12_sum14_cfg4_val10k_s42": "79cbdbdd4c0221940f0fbd3574aab33ce5efc9f22b937fdca31126b1cf1a68f9", - "action_flow_cotrain_uc_latent_fm_sg_unite_h512d14h16_sum14_cfg4_val10k_s42": "88040dd95a75a085058fd48adbc081ea6f00ec8485d6496b25b83ddc181f43b8", - "action_flow_cotrain_uc_manual3000_avln_80k_s42": "ea67ff06292e4d080af6cd514ce642dc08830865f0ed25837a9979f534247dce", - "action_flow_cotrain_uc_manual4919_avln_80k_s42": "978b5d06e0db777a8e3367cee1bd8b744ed2812f0749c35187f393ce4e018916", - "action_flow_usocket_latent_fm_sg_unite_h384_s42": "3bbbb4f247acb88e2e74e96e20ce1617a18c2ee076d55e714f0d93a938a756d2", - "action_flow_usocket_latent_fm_sg_unite_h384_sum14_cfg4_val8_deterministic_s42": "5f23aed86062a0827250927bba4b7433c450135688d58eeabf1da8b3acb819df", - "action_flow_usocket_latent_fm_sg_unite_h384_sum14_cfg4_val8_s42": "51d39070058c361b37a0235eb72d7dea0f27163d30b16ea254a44c6c861a293e", - "action_flow_usocket_latent_fm_sg_unite_h384_sum14_cfg4_val8_scale1_s42": "cd4eef7c8927a649e6c5fde911836275b3f1c76c64951265ced3df566d0d4796", - "action_flow_usocket_latent_fm_sg_unite_h512d14h16_sum14_cfg4_val10k_s42": "afd8576ab5448604096f961b8a284d4a21f6fd8b05a83cbf59b355d5befe777a", - "action_flow_usocket_refactored_c12_compat_s42": "a62734de32fedf721e3d1eda16e67e3deba5c9d03ec6451a164720ac2b7dfa22", - "planar_chain_manual4919_standard_dp_retimed": "eb2ae443c790984c01a5159ca1f880169982a30e6bfe1c97b761449a44a4ab7e", - "action_flow_cotrain_uc_speed_interpolation": "643f68448e85aff1d57273a2d2211689a3f2bb580f75c3d4c987bce7e981c045" - }, - "retired_data": [ - "action_flow_chain_points6_frozen4920_val01_h16", - "action_flow_chain_points6_manual3000_val01_h16", - "action_flow_chain_points6_manual4919_val01_h16", - "action_flow_cotrain_usocket_chain_manual3000_val01_h16", - "action_flow_cotrain_usocket_chain_manual4919_val01_h16", - "action_flow_cotrain_usocket_chain_points6_frozen4920_val01_h16" - ], - "parent_identity_metadata": { - "parents/action_flow_usocket_h384_sum14": { - "name": "action_flow_usocket_latent_fm_sg_unite_h384_sum14_cfg4_val8_s42", - "description": "Action Flow H384 with released-UNITE flow aggregation, CFG, and validation coverage" - }, - "parents/action_flow_usocket_h384": { - "name": "action_flow_usocket_latent_fm_sg_unite_h384_s42", - "description": "Action Flow stopgrad/JVP objective on separate released-UNITE H384 modules" - }, - "parents/action_flow_cotrain_uc_manual4919": { - "name": "action_flow_cotrain_uc_manual4919_avln_80k_s42", - "description": "Manual U-Socket 2999 + ChainGripper 3000 standard/1919 obstacle recollections Action Flow AV-LN" - } - } -} diff --git a/tests/fixtures/synthetic_episodes.py b/tests/fixtures/synthetic_episodes.py deleted file mode 100644 index aa2270939a..0000000000 --- a/tests/fixtures/synthetic_episodes.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Tiny synthetic zarr episodes per vendor, written through the real ZarrWriter. - -Vendor differences that matter to the data pipeline are reproduced (camera set, -stride, head pose); everything else is random but non-degenerate so norm stats -are finite. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import numpy as np -import zarr -from scipy.spatial.transform import Rotation as R - -from egomimic.rldb.embodiment.eva import Eva -from egomimic.rldb.zarr.zarr_writer import ZarrWriter -from egomimic.utils.pose_utils import _matrix_to_xyzwxyz, _xyzwxyz_to_matrix - - -@dataclass(frozen=True) -class Vendor: - embodiment: str - stride: int - cameras: tuple[str, ...] # zarr image keys without the "images." prefix - has_head_pose: bool - - -VENDORS: dict[str, Vendor] = { - "eva": Vendor("eva_bimanual", 1, ("front_1", "right_wrist", "left_wrist"), False), - "aria": Vendor("human_bimanual", 3, ("front_1",), True), - "mecka": Vendor("human_bimanual", 1, ("front_1",), True), - "scale": Vendor("human_bimanual", 1, ("front_1",), True), -} - - -def _quat_wxyz(T: int, rng: np.random.Generator) -> np.ndarray: - q = R.from_rotvec(rng.normal(0, 0.05, (T, 3))).as_quat() # xyzw - return np.concatenate([q[:, 3:4], q[:, :3]], axis=1) - - -def _pose(T: int, rng: np.random.Generator) -> np.ndarray: - """One random pose held constant for all T frames (xyz + wxyz). - - Constant-in-time on purpose: the loader's outlier check compares every - action-chunk cell against per-timestep 99.99% quantiles of the norm-stat - sample. With only a few hundred synthetic samples, white-noise - trajectories make nearly every sample the maximum of some cell and the - whole dataset gets rejected; per-episode constants tie at the extremes - instead, while three episodes still give non-degenerate norm stats.""" - one = np.concatenate([rng.normal(0, 0.1, (1, 3)), _quat_wxyz(1, rng)], axis=1) - return np.repeat(one, T, axis=0).astype(np.float64) - - -def _scalar(T: int, rng: np.random.Generator) -> np.ndarray: - """One uniform(0,1) value held constant for all T frames, shape (T, 1).""" - return np.full((T, 1), rng.uniform(0.0, 1.0)) - - -def _pose_in_frame(T: int, rng: np.random.Generator, frame: np.ndarray) -> np.ndarray: - """A small random pose expressed in the world frame *through* ``frame`` - (4x4). The eva pipeline re-expresses cmd/obs poses relative to the real - camera extrinsics; poses composed this way come out near identity there, - so the ypr representation stays away from the +-pi wrap that the - quantile bounds check would otherwise reject.""" - mats = _xyzwxyz_to_matrix(_pose(T, rng)) - return _matrix_to_xyzwxyz(frame[None] @ mats) - - -def write_episode( - root: Path, vendor: str, *, T: int = 48, H: int = 64, W: int = 64, seed: int = 0 -) -> Path: - v = VENDORS[vendor] - rng = np.random.default_rng(seed) - K = np.array( - [[100.0, 0.0, W / 2, 0.0], [0.0, 100.0, H / 2, 0.0], [0.0, 0.0, 1.0, 0.0]] - ) - images = { - f"images.{c}": rng.integers(0, 255, (T, H, W, 3), dtype=np.uint8) - for c in v.cameras - } - numeric = {"left.obs_ee_pose": _pose(T, rng), "right.obs_ee_pose": _pose(T, rng)} - extrinsics = None - if v.embodiment == "eva_bimanual": - ext_l, ext_r = Eva.EXTRINSICS["left"], Eva.EXTRINSICS["right"] - numeric.update( - { - "left.obs_ee_pose": _pose_in_frame(T, rng, ext_l), - "right.obs_ee_pose": _pose_in_frame(T, rng, ext_r), - "left.cmd_ee_pose": _pose_in_frame(T, rng, ext_l), - "right.cmd_ee_pose": _pose_in_frame(T, rng, ext_r), - "left.obs_gripper": _scalar(T, rng), - "right.obs_gripper": _scalar(T, rng), - "left.cmd_gripper": _scalar(T, rng), - "right.cmd_gripper": _scalar(T, rng), - } - ) - extrinsics = { - "left": ext_l, - "right": ext_r, - } # what the poses were composed through - if v.has_head_pose: - numeric["obs_head_pose"] = _pose(T, rng) - for side in ("left", "right"): - numeric[f"{side}.obs_wrist_pose"] = numeric[f"{side}.obs_ee_pose"].copy() - keypoints = rng.normal(0, 0.01, (1, 21, 3)) - keypoints += numeric[f"{side}.obs_wrist_pose"][0, :3] - numeric[f"{side}.obs_keypoints"] = np.repeat( - keypoints.reshape(1, 63), T, axis=0 - ) - return ZarrWriter.create_and_write( - root / f"{vendor}_{seed:02d}.zarr", - numeric_data=numeric, - image_data=images, - embodiment=v.embodiment, - fps=30, - task_name="synthetic", - annotations=[ - ("pick up the red cube", 0, T // 2), - ("place it in the bin", T // 2, T - 1), - ], - intrinsics={"front_1": K}, - extrinsics=extrinsics, - ) - - -def write_moving_episode(root: Path, vendor: str, *, seed: int): - """Deterministic translating/rotating poses for old/new preprocessing parity.""" - path = write_episode(root, vendor, T=48, H=32, W=32, seed=seed) - group = zarr.open_group(path, mode="r+") - for key in group.array_keys(): - if "pose" not in key: - continue - values = group[key][:] - if values.shape != (48, 7): - continue - step = np.arange(48)[:, None] - speed = 0.002 if ".cmd_" in key else 0.0007 - values[:, :3] += step * np.array([[speed, -speed * 0.6, speed * 0.2]]) - rotations = R.from_quat(values[:, [4, 5, 6, 3]]) * R.from_rotvec( - step * np.array([[0.0002, 0.0003, -0.0001]]) - ) - values[:, 3:] = rotations.as_quat()[:, [3, 0, 1, 2]] - group[key][:] = values - return path diff --git a/tests/libero_ddp_resume_worker.py b/tests/libero_ddp_resume_worker.py deleted file mode 100644 index 278453ecab..0000000000 --- a/tests/libero_ddp_resume_worker.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Subprocess fixture for a real one-process to two-process Lightning resume.""" - -import argparse -import json -from pathlib import Path - -import torch -from omegaconf import OmegaConf, open_dict - -from egomimic.trainHydra import train -from tests.test_libero_benchmark import make_replay -from tests.test_oat_training import config_for - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--root", type=Path, required=True) - parser.add_argument("--resume", action="store_true") - args = parser.parse_args() - torch.set_num_threads(1) - args.root.mkdir(parents=True, exist_ok=True) - replay = args.root / "replay.zarr" - if not args.resume: - make_replay(replay) - cfg = config_for("libero_oattok", replay, args.root / "train") - spec = cfg.model.pipeline.stages[0].tokenizer - spec.emb_dim, spec.head_dim = 32, 8 - spec.encoder_depth, spec.decoder_depth, spec.num_registers = 1, 1, 4 - cfg.trainer.limit_train_batches = 1.0 - cfg.trainer.accumulate_grad_batches = 2 if args.resume else 4 - cfg.trainer.devices = 2 if args.resume else 1 - cfg.trainer.max_epochs = 2 if args.resume else 1 - cfg.callbacks.batch_budget = OmegaConf.create( - { - "_target_": "egomimic.pl_utils.oat_training.OATBatchBudgetCallback", - "global_batch_size": 8, - } - ) - with open_dict(cfg.trainer): - cfg.trainer.enable_progress_bar = False - if args.resume: - cfg.trainer.strategy = "ddp_find_unused_parameters_true" - if args.resume: - cfg.ckpt_path = str(args.root / "train/checkpoints/last.ckpt") - _, objects = train(cfg) - if objects["trainer"].is_global_zero: - payload = torch.load( - args.root / "train/checkpoints/last.ckpt", - map_location="cpu", - weights_only=False, - ) - report = { - "global_step": payload["global_step"], - "ema_num_updates": payload["ema_num_updates"], - "training_budget": payload["training_budget"], - "optimizer_states": bool(payload["optimizer_states"]), - } - (args.root / ("resumed.json" if args.resume else "initial.json")).write_text( - json.dumps(report) - ) - - -if __name__ == "__main__": - main() diff --git a/tests/oat_reference_fixtures.py b/tests/oat_reference_fixtures.py deleted file mode 100644 index 750bfc333e..0000000000 --- a/tests/oat_reference_fixtures.py +++ /dev/null @@ -1,34 +0,0 @@ -"""One shared, hash-verified upstream fixture; independent of test collection.""" - -import hashlib -import importlib -import json -import os -import sys -from pathlib import Path - -import pytest - - -@pytest.fixture(scope="module") -def reference(): - root = os.environ.get("OAT_REFERENCE_ROOT") - if not root: - pytest.skip( - "Set OAT_REFERENCE_ROOT to the pinned OAT checkout for source parity" - ) - root = Path(root) - manifest = json.loads( - (Path(__file__).parents[1] / "egomimic/models/oat/UPSTREAM.json").read_text() - ) - for relative, expected in manifest["files"].items(): - assert ( - hashlib.sha256((root / "oat" / relative).read_bytes()).hexdigest() - == expected - ) - previous = list(sys.path) - sys.path.insert(0, str(root)) - try: - yield lambda name: importlib.import_module("oat." + name) - finally: - sys.path[:] = previous diff --git a/tests/test_abc_yam.py b/tests/test_abc_yam.py deleted file mode 100644 index 61cb64d35c..0000000000 --- a/tests/test_abc_yam.py +++ /dev/null @@ -1,229 +0,0 @@ -"""YAM embodiment, ABC image/intrinsics harmonisation, and the ABC graph config.""" - -import importlib.util -from pathlib import Path - -import numpy as np -import pytest - -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_io import ActionTargetBuilder -from egomimic.rldb.embodiment.embodiment import ( - EMBODIMENT, - get_embodiment, - get_embodiment_id, -) -from egomimic.rldb.embodiment.yam import Yam -from egomimic.rldb.zarr.action_chunk_transforms import ( - BatchQuaternionPoseTo6D, - QuaternionPoseTo6D, - XYZWXYZ_to_XYZRot6D, - XYZWXYZ_to_XYZYPR, -) -from egomimic.rldb.zarr.zarr_dataset_multi import ( - _image_hw_of, - _resize_images, - _scale_intrinsics, -) - -_REPO = Path(__file__).resolve().parents[1] -_SPEC = importlib.util.spec_from_file_location( - "config_graph_abc", _REPO / "tools/config_graph.py" -) -config_graph = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(config_graph) - - -# -- embodiment registration ------------------------------------------------ - - -def test_yam_bimanual_is_registered_and_round_trips(): - assert get_embodiment_id("yam_bimanual") == EMBODIMENT.YAM_BIMANUAL.value - assert get_embodiment(EMBODIMENT.YAM_BIMANUAL.value) == "YAM_BIMANUAL" - - -def test_yam_carries_no_class_intrinsics(): - # Two station types with different calibration ship in ABC-130k, so K is - # per-episode; a class constant would silently apply one station's K to both. - assert Yam.INTRINSICS is None - - -# -- transform axes --------------------------------------------------------- - - -@pytest.mark.parametrize("coord_frame", ["camframe", "world", "eef_frame"]) -@pytest.mark.parametrize("rotation_mode", ["euler", "quat", "6D"]) -def test_yam_builds_every_frame_and_rotation_combination(coord_frame, rotation_mode): - transforms = Yam.get_transform_list( - action_mode="cartesian", coord_frame=coord_frame, rotation_mode=rotation_mode - ) - assert transforms - kinds = {type(t) for t in transforms} - assert (XYZWXYZ_to_XYZRot6D in kinds) == (rotation_mode == "6D") - assert (XYZWXYZ_to_XYZYPR in kinds) == (rotation_mode == "euler") - - -def test_yam_rejects_unknown_axes(): - with pytest.raises(ValueError, match="unknown coord_frame"): - Yam.get_transform_list(coord_frame="headframe") - with pytest.raises(ValueError, match="unknown action_mode"): - Yam.get_transform_list(action_mode="keypoints") - - -def test_yam_keymap_covers_all_three_cameras_and_both_arms(): - keymap = Yam.get_keymap(keymap_mode="cartesian") - zarr_keys = {spec["zarr_key"] for spec in keymap.values()} - assert {"images.front_1", "images.right_wrist", "images.left_wrist"} <= zarr_keys - assert {"right.obs_ee_pose", "left.obs_ee_pose"} <= zarr_keys - - -# -- 6D pose transforms ----------------------------------------------------- - - -def _pose(n): - pose = np.zeros((n, 7)) - pose[:, 3] = 1.0 # identity quaternion, wxyz - pose[:, :3] = np.arange(3 * n).reshape(n, 3) - return pose - - -def test_batch_quaternion_pose_to_6d_emits_identity_columns(): - out = BatchQuaternionPoseTo6D(pose_key="p", output_key="q").transform( - {"p": _pose(2)} - ) - assert out["q"].shape == (2, 9) - # Identity rotation -> first two columns of I3. - np.testing.assert_allclose(out["q"][:, 3:9], np.tile([1, 0, 0, 0, 1, 0], (2, 1))) - - -def test_single_quaternion_pose_to_6d_emits_nine_values(): - out = QuaternionPoseTo6D(pose_key="p", output_key="q").transform({"p": _pose(1)[0]}) - assert out["q"].shape == (9,) - - -def test_quaternion_pose_to_6d_rejects_a_batch(): - with pytest.raises(ValueError, match=r"shape \(7,\)"): - QuaternionPoseTo6D(pose_key="p", output_key="q").transform({"p": _pose(3)}) - - -def test_batch_quaternion_pose_to_6d_rejects_a_single_pose(): - with pytest.raises(ValueError, match=r"shape \(N, 7\)"): - BatchQuaternionPoseTo6D(pose_key="p", output_key="q").transform( - {"p": _pose(1)[0]} - ) - - -# -- image harmonisation ---------------------------------------------------- - - -def test_image_hw_reads_channels_first_without_confusing_channels_for_height(): - assert _image_hw_of(np.zeros((3, 480, 640))) == (480, 640) - assert _image_hw_of(np.zeros((480, 640, 3))) == (480, 640) - - -def test_image_hw_handles_a_window_in_both_layouts(): - assert _image_hw_of(np.zeros((4, 3, 480, 640))) == (480, 640) - assert _image_hw_of(np.zeros((4, 480, 640, 3))) == (480, 640) - - -def test_resize_preserves_layout_and_dtype(): - chw = np.zeros((3, 360, 640), dtype=np.float32) - assert _resize_images(chw, (480, 640)).shape == (3, 480, 640) - hwc = np.zeros((360, 640, 3), dtype=np.uint8) - out = _resize_images(hwc, (480, 640)) - assert out.shape == (480, 640, 3) and out.dtype == np.uint8 - - -def test_resize_is_a_no_op_at_the_target_size(): - arr = np.zeros((3, 480, 640), dtype=np.float32) - assert _resize_images(arr, (480, 640)) is arr - - -def test_resize_maps_a_window_frame_by_frame(): - assert _resize_images(np.zeros((2, 3, 360, 640)), (480, 640)).shape == ( - 2, - 3, - 480, - 640, - ) - - -def test_intrinsics_scale_per_axis_not_by_one_shared_factor(): - K = np.array( - [[500.0, 0.0, 320.0, 0.0], [0.0, 500.0, 180.0, 0.0], [0.0, 0.0, 1.0, 0.0]], - dtype=np.float32, - ) - out = _scale_intrinsics(K, (360, 640), (480, 640)) - # Width is unchanged, height grows 4/3: row 0 must not move, row 1 must. - np.testing.assert_allclose(out[0], K[0]) - np.testing.assert_allclose(out[1], K[1] * (480 / 360)) - - -def test_intrinsics_scaling_tolerates_a_degenerate_source_size(): - K = np.eye(3, 4, dtype=np.float32) - np.testing.assert_allclose(_scale_intrinsics(K, (0, 0), (480, 640)), K) - - -# -- the graph itself ------------------------------------------------------- - - -def test_action_target_builder_reads_the_configured_action_key(): - stage = ActionTargetBuilder(action_key="actions_cartesian") - assert stage.contract("train") == (("actions_cartesian",), ("target",)) - out = stage.forward({"actions_cartesian": 7}) - assert out["target"] == 7 and "actions_cartesian" not in out - - -def test_action_target_builder_still_defaults_to_actions(): - assert ActionTargetBuilder().contract("train") == (("actions",), ("target",)) - - -def test_action_target_builder_names_the_key_it_wanted(): - with pytest.raises(ValueError, match="actions_cartesian"): - ActionTargetBuilder(action_key="actions_cartesian").forward({"actions": 1}) - - -def test_action_target_builder_is_excluded_from_the_inference_graph(): - pipeline = Pipeline([ActionTargetBuilder(action_key="actions_cartesian")]) - runnable, excluded = pipeline.plan(["actions_cartesian"], mode="inference") - assert not runnable and excluded[0][1] == [""] - - -@pytest.mark.parametrize("mode", ["train", "inference"]) -def test_abc_experiment_graph_lints_clean(mode): - graph = config_graph.build_graph( - _REPO / "egomimic/hydra_configs/experiment/abc/yam_fstshirt_dp.yaml", mode=mode - ) - assert graph["lint"] == [] - assert graph["seed_keys_by_source"]["yam_bimanual"] - - -def test_abc_train_graph_has_the_five_dp_stages_and_a_loss(): - graph = config_graph.build_graph( - _REPO / "egomimic/hydra_configs/experiment/abc/yam_fstshirt_dp.yaml", - mode="train", - ) - assert [node["t"] for node in graph["nodes"]] == [ - "FusedObsEncoder", - "ActionTargetBuilder", - "DiffusionNoisingStage", - "DiffusionDenoiserStage", - "DiffusionEpsilonLossStage", - ] - assert any( - key.startswith("loss/") for node in graph["nodes"] for key in node["out"] - ) - - -def test_abc_inference_graph_drops_the_training_only_stages(): - graph = config_graph.build_graph( - _REPO / "egomimic/hydra_configs/experiment/abc/yam_fstshirt_dp.yaml", - mode="inference", - ) - skipped = {entry["t"] for entry in graph["skipped_stages"]} - assert skipped == { - "ActionTargetBuilder", - "DiffusionNoisingStage", - "DiffusionEpsilonLossStage", - } - assert "pred_action" in {key for node in graph["nodes"] for key in node["out"]} diff --git a/tests/test_action_chunk_transforms.py b/tests/test_action_chunk_transforms.py deleted file mode 100644 index 463c4152ec..0000000000 --- a/tests/test_action_chunk_transforms.py +++ /dev/null @@ -1,656 +0,0 @@ -import numpy as np -import pytest -from scipy.spatial.transform import Rotation as R - -from egomimic.rldb.embodiment.eva import _build_eva_bimanual_transform_list -from egomimic.rldb.embodiment.human import ( - _build_human_cartesian_bimanual_transform_list, -) -from egomimic.rldb.zarr.action_chunk_transforms import ( - ActionChunkCoordinateFrameTransform, - CartesianWithGripperCoordinateTransform, - ConcatKeys, - InterpolatePose, - XYZWXYZ_to_XYZYPR, -) -from egomimic.utils.pose_utils import _xyzwxyz_to_matrix - - -def _shape_map(batch: dict) -> dict[str, tuple]: - return {k: tuple(np.asarray(v).shape) for k, v in batch.items()} - - -def _run_and_capture(transform_list, batch: dict): - snapshots = [] - data = {k: np.asarray(v).copy() for k, v in batch.items()} - for transform in transform_list: - data = transform.transform(data) - snapshots.append( - (transform.__class__.__name__, set(data.keys()), _shape_map(data)) - ) - return snapshots - - -def _assert_snapshot( - snapshots, - idx: int, - expected_name: str, - expected_keys: set[str], - expected_shapes: dict[str, tuple], -) -> None: - name, keys, shapes = snapshots[idx] - assert ( - name == expected_name - ), f"step {idx}: transform mismatch; expected {expected_name}, got {name}" - - missing = expected_keys - keys - extra = keys - expected_keys - assert not missing and not extra, ( - f"step {idx} ({name}): key set mismatch; missing={sorted(missing)}, " - f"extra={sorted(extra)}" - ) - - shape_mismatch = { - k: (expected_shapes[k], shapes.get(k)) - for k in expected_shapes - if shapes.get(k) != expected_shapes[k] - } - assert not shape_mismatch, f"step {idx} ({name}): shape mismatch {shape_mismatch}" - - -def test_xyzwxyz_to_matrix_converts_wxyz_quaternion() -> None: - yaw = np.pi / 2.0 - qw = np.cos(yaw / 2.0) - qz = np.sin(yaw / 2.0) - - poses = np.array( - [ - [1.0, 2.0, 3.0, 1.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, qw, 0.0, 0.0, qz], - ], - dtype=np.float64, - ) - - mats = _xyzwxyz_to_matrix(poses) - - np.testing.assert_allclose(mats[0, :3, 3], np.array([1.0, 2.0, 3.0])) - np.testing.assert_allclose(mats[0, :3, :3], np.eye(3), atol=1e-7) - - expected_rot = R.from_euler("Z", yaw, degrees=False).as_matrix() - np.testing.assert_allclose(mats[1, :3, :3], expected_rot, atol=1e-7) - np.testing.assert_allclose(mats[1, :3, 3], np.zeros(3), atol=1e-7) - - -def test_action_chunk_coordinate_frame_transform_accepts_quat_wxyz_input() -> None: - yaw = np.pi / 2.0 - qw = np.cos(yaw / 2.0) - qz = np.sin(yaw / 2.0) - - transform = ActionChunkCoordinateFrameTransform( - target_world="target_world", - chunk_world="chunk_world", - transformed_key_name="chunk_target", - mode="xyzwxyz", - ) - - batch = { - "target_world": np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64), - "chunk_world": np.array( - [ - [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, qw, 0.0, 0.0, qz], - ], - dtype=np.float64, - ), - } - - out = transform.transform(batch) - chunk_target = np.asarray(out["chunk_target"]) - - assert chunk_target.shape == (2, 7) - expected = np.array( - [ - [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, qw, 0.0, 0.0, qz], - ], - dtype=np.float64, - ) - np.testing.assert_allclose(chunk_target, expected, atol=1e-6) - - -def test_cartesian_bimanual_transform_applies_nonidentity_targets() -> None: - transform = CartesianWithGripperCoordinateTransform( - left_target_world="left_target", - right_target_world="right_target", - chunk_world="chunk_world", - transformed_key_name="chunk_target", - ) - batch = { - "left_target": np.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0]), - "right_target": np.array([0.0, 2.0, 0.0, 0.0, 0.0, 0.0]), - "chunk_world": np.array( - [ - [2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.2], - ], - dtype=np.float64, - ), - } - - out = transform.transform(batch) - expected = np.array( - [[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.2]], - dtype=np.float64, - ) - np.testing.assert_allclose(out["chunk_target"], expected, atol=1e-6) - - -def test_action_chunk_coordinate_frame_transform_ypr_mode_invalid_chunk_shape_raises() -> ( - None -): - transform = ActionChunkCoordinateFrameTransform( - target_world="target_world", - chunk_world="chunk_world", - transformed_key_name="chunk_target", - mode="xyzypr", - ) - batch = { - "target_world": np.zeros(6, dtype=np.float64), - "chunk_world": np.zeros((2, 7), dtype=np.float64), - } - - with pytest.raises(ValueError, match=r"Expected \(B, 6\) array"): - transform.transform(batch) - - -def test_action_chunk_coordinate_frame_transform_quat_mode_invalid_chunk_shape_raises() -> ( - None -): - transform = ActionChunkCoordinateFrameTransform( - target_world="target_world", - chunk_world="chunk_world", - transformed_key_name="chunk_target", - mode="xyzwxyz", - ) - batch = { - "target_world": np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float64), - "chunk_world": np.zeros((2, 6), dtype=np.float64), - } - - with pytest.raises(ValueError, match=r"Expected \(B, 7\) array"): - transform.transform(batch) - - -def test_action_chunk_coordinate_frame_transform_invalid_target_shape_raises() -> None: - transform = ActionChunkCoordinateFrameTransform( - target_world="target_world", - chunk_world="chunk_world", - transformed_key_name="chunk_target", - mode="xyzypr", - ) - batch = { - "target_world": np.zeros((2, 6), dtype=np.float64), - "chunk_world": np.zeros((2, 6), dtype=np.float64), - } - - with pytest.raises(ValueError, match=r"Expected \(B, 6\) array"): - transform.transform(batch) - - -def test_interpolate_pose_quat_wxyz_slerp_happy_path() -> None: - yaw = np.pi / 2.0 - qw = np.cos(yaw / 2.0) - qz = np.sin(yaw / 2.0) - - transform = InterpolatePose( - new_chunk_length=5, - action_key="actions", - output_action_key="actions_out", - mode="xyzwxyz", - ) - batch = { - "actions": np.array( - [ - [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], - [1.0, 0.0, 0.0, qw, 0.0, 0.0, qz], - ], - dtype=np.float64, - ) - } - - out = np.asarray(transform.transform(batch)["actions_out"]) - assert out.shape == (5, 7) - - quat_norms = np.linalg.norm(out[:, 3:7], axis=1) - np.testing.assert_allclose(quat_norms, np.ones(5), atol=1e-7) - np.testing.assert_allclose(out[0], batch["actions"][0], atol=1e-7) - np.testing.assert_allclose(out[-1], batch["actions"][-1], atol=1e-7) - - yaws = R.from_quat(out[:, [4, 5, 6, 3]]).as_euler("ZYX", degrees=False)[:, 0] - np.testing.assert_allclose(yaws[0], 0.0, atol=1e-7) - np.testing.assert_allclose(yaws[-1], yaw, atol=1e-7) - assert np.all(np.diff(yaws) >= -1e-7) - - -def test_interpolate_pose_quat_wxyz_invalid_shape_raises() -> None: - transform = InterpolatePose( - new_chunk_length=4, - action_key="actions", - output_action_key="actions_out", - mode="xyzwxyz", - ) - batch = {"actions": np.zeros((2, 6), dtype=np.float64)} - with pytest.raises(ValueError, match=r"InterpolatePose expects \(T, 7\)"): - transform.transform(batch) - - -def test_xyzwxyz_to_xyzypr_single_pose_conversion() -> None: - yaw = np.pi / 2.0 - qw = np.cos(yaw / 2.0) - qz = np.sin(yaw / 2.0) - - transform = XYZWXYZ_to_XYZYPR(keys=["pose"]) - batch = {"pose": np.array([1.0, 2.0, 3.0, qw, 0.0, 0.0, qz], dtype=np.float64)} - - out = np.asarray(transform.transform(batch)["pose"]) - assert out.shape == (6,) - np.testing.assert_allclose(out[:3], np.array([1.0, 2.0, 3.0]), atol=1e-7) - np.testing.assert_allclose(out[3:], np.array([yaw, 0.0, 0.0]), atol=1e-6) - - -def test_xyzwxyz_to_xyzypr_chunk_conversion() -> None: - yaw = np.pi / 2.0 - qw = np.cos(yaw / 2.0) - qz = np.sin(yaw / 2.0) - - transform = XYZWXYZ_to_XYZYPR(keys=["poses"]) - batch = { - "poses": np.array( - [ - [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, qw, 0.0, 0.0, qz], - ], - dtype=np.float64, - ) - } - - out = np.asarray(transform.transform(batch)["poses"]) - assert out.shape == (2, 6) - np.testing.assert_allclose(out[0, 3:], np.zeros(3), atol=1e-7) - np.testing.assert_allclose(out[1, 3:], np.array([yaw, 0.0, 0.0]), atol=1e-6) - - -def test_xyzwxyz_to_xyzypr_strict_shape_raises() -> None: - transform = XYZWXYZ_to_XYZYPR(keys=["poses"]) - batch = {"poses": np.zeros((2, 6), dtype=np.float64)} - with pytest.raises( - ValueError, match=r"XYZWXYZ_to_XYZYPR expects key 'poses' to have shape" - ): - transform.transform(batch) - - -def test_eva_builder_orders_xyzwxyz_to_xyzypr_after_interpolate_before_concat() -> None: - transform_list = _build_eva_bimanual_transform_list(rotation_mode="euler") - converter_indices = [ - i for i, t in enumerate(transform_list) if isinstance(t, XYZWXYZ_to_XYZYPR) - ] - interpolate_indices = [ - i for i, t in enumerate(transform_list) if isinstance(t, InterpolatePose) - ] - concat_indices = [ - i for i, t in enumerate(transform_list) if isinstance(t, ConcatKeys) - ] - - assert len(converter_indices) == 1 - converter_idx = converter_indices[0] - assert converter_idx > max(interpolate_indices) - assert converter_idx < min(concat_indices) - assert set(transform_list[converter_idx].keys) == { - "left.cmd_ee_pose_camframe", - "right.cmd_ee_pose_camframe", - "left.obs_ee_pose", - "right.obs_ee_pose", - } - - -def test_human_builder_orders_xyzwxyz_to_xyzypr_after_interpolate_before_concat() -> ( - None -): - transform_list = _build_human_cartesian_bimanual_transform_list( - target_world_is_quat=True - ) - converter_indices = [ - i for i, t in enumerate(transform_list) if isinstance(t, XYZWXYZ_to_XYZYPR) - ] - interpolate_indices = [ - i for i, t in enumerate(transform_list) if isinstance(t, InterpolatePose) - ] - concat_indices = [ - i for i, t in enumerate(transform_list) if isinstance(t, ConcatKeys) - ] - - assert len(converter_indices) == 1 - converter_idx = converter_indices[0] - assert converter_idx > max(interpolate_indices) - assert converter_idx < min(concat_indices) - assert set(transform_list[converter_idx].keys) == { - "left.action_ee_pose_headframe", - "right.action_ee_pose_headframe", - "left.obs_ee_pose_headframe", - "right.obs_ee_pose_headframe", - } - - -def test_eva_transform_list_stepwise_keys_and_shapes() -> None: - transform_list = _build_eva_bimanual_transform_list( - chunk_length=4, stride=1, rotation_mode="euler" - ) - cmd_pose = np.zeros((5, 7), dtype=np.float64) - cmd_pose[:, 3] = 1.0 - obs_pose = np.zeros((7,), dtype=np.float64) - obs_pose[3] = 1.0 - batch = { - "left_extrinsics_pose": np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]), - "right_extrinsics_pose": np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]), - "left.cmd_ee_pose": cmd_pose.copy(), - "right.cmd_ee_pose": cmd_pose.copy(), - "left.obs_ee_pose": obs_pose.copy(), - "right.obs_ee_pose": obs_pose.copy(), - "left.cmd_gripper": np.zeros((5, 1), dtype=np.float64), - "right.cmd_gripper": np.zeros((5, 1), dtype=np.float64), - "left.obs_gripper": np.zeros((1,), dtype=np.float64), - "right.obs_gripper": np.zeros((1,), dtype=np.float64), - } - snapshots = _run_and_capture(transform_list, batch) - - expected_names = [ - "ActionChunkCoordinateFrameTransform", - "ActionChunkCoordinateFrameTransform", - "PoseCoordinateFrameTransform", - "PoseCoordinateFrameTransform", - "InterpolatePose", - "InterpolatePose", - "InterpolateLinear", - "InterpolateLinear", - "XYZWXYZ_to_XYZYPR", - "ConcatKeys", - "ConcatKeys", - "DeleteKeys", - "NumpyToTensor", - ] - assert [name for name, _, _ in snapshots] == expected_names - - base_keys = { - "left_extrinsics_pose", - "right_extrinsics_pose", - "left.cmd_ee_pose", - "right.cmd_ee_pose", - "left.obs_ee_pose", - "right.obs_ee_pose", - "left.cmd_gripper", - "right.cmd_gripper", - "left.obs_gripper", - "right.obs_gripper", - } - - _assert_snapshot( - snapshots, - 0, - "ActionChunkCoordinateFrameTransform", - base_keys | {"left.cmd_ee_pose_camframe"}, - { - "left_extrinsics_pose": (7,), - "right_extrinsics_pose": (7,), - "left.cmd_ee_pose": (5, 7), - "right.cmd_ee_pose": (5, 7), - "left.obs_ee_pose": (7,), - "right.obs_ee_pose": (7,), - "left.cmd_gripper": (5, 1), - "right.cmd_gripper": (5, 1), - "left.obs_gripper": (1,), - "right.obs_gripper": (1,), - "left.cmd_ee_pose_camframe": (5, 7), - }, - ) - _assert_snapshot( - snapshots, - 1, - "ActionChunkCoordinateFrameTransform", - base_keys | {"left.cmd_ee_pose_camframe", "right.cmd_ee_pose_camframe"}, - { - "left.cmd_ee_pose_camframe": (5, 7), - "right.cmd_ee_pose_camframe": (5, 7), - "left.obs_ee_pose": (7,), - "right.obs_ee_pose": (7,), - }, - ) - _assert_snapshot( - snapshots, - 4, - "InterpolatePose", - base_keys | {"left.cmd_ee_pose_camframe", "right.cmd_ee_pose_camframe"}, - { - "left.cmd_ee_pose_camframe": (4, 7), - "right.cmd_ee_pose_camframe": (5, 7), - }, - ) - _assert_snapshot( - snapshots, - 5, - "InterpolatePose", - base_keys | {"left.cmd_ee_pose_camframe", "right.cmd_ee_pose_camframe"}, - { - "left.cmd_ee_pose_camframe": (4, 7), - "right.cmd_ee_pose_camframe": (4, 7), - }, - ) - _assert_snapshot( - snapshots, - 8, - "XYZWXYZ_to_XYZYPR", - base_keys | {"left.cmd_ee_pose_camframe", "right.cmd_ee_pose_camframe"}, - { - "left.cmd_ee_pose_camframe": (4, 6), - "right.cmd_ee_pose_camframe": (4, 6), - "left.obs_ee_pose": (6,), - "right.obs_ee_pose": (6,), - }, - ) - _assert_snapshot( - snapshots, - 9, - "ConcatKeys", - { - "actions_cartesian", - "left_extrinsics_pose", - "right_extrinsics_pose", - "left.cmd_ee_pose", - "right.cmd_ee_pose", - "left.obs_ee_pose", - "right.obs_ee_pose", - "left.obs_gripper", - "right.obs_gripper", - }, - { - "actions_cartesian": (4, 14), - "left.obs_ee_pose": (6,), - "right.obs_ee_pose": (6,), - }, - ) - _assert_snapshot( - snapshots, - 10, - "ConcatKeys", - { - "actions_cartesian", - "observations.state.ee_pose", - "left_extrinsics_pose", - "right_extrinsics_pose", - "left.cmd_ee_pose", - "right.cmd_ee_pose", - }, - { - "actions_cartesian": (4, 14), - "observations.state.ee_pose": (14,), - }, - ) - _assert_snapshot( - snapshots, - 11, - "DeleteKeys", - {"actions_cartesian", "observations.state.ee_pose"}, - { - "actions_cartesian": (4, 14), - "observations.state.ee_pose": (14,), - }, - ) - - -def test_human_transform_list_stepwise_keys_and_shapes() -> None: - transform_list = _build_human_cartesian_bimanual_transform_list( - chunk_length=4, - stride=2, - target_world_is_quat=True, - left_action_world="left.action_ee_pose", - right_action_world="right.action_ee_pose", - ) - action_pose = np.zeros((6, 7), dtype=np.float64) - action_pose[:, 3] = 1.0 - obs_pose = np.zeros((7,), dtype=np.float64) - obs_pose[3] = 1.0 - batch = { - "obs_head_pose": np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]), - "left.action_ee_pose": action_pose.copy(), - "right.action_ee_pose": action_pose.copy(), - "left.obs_ee_pose": obs_pose.copy(), - "right.obs_ee_pose": obs_pose.copy(), - } - snapshots = _run_and_capture(transform_list, batch) - - expected_names = [ - "ActionChunkCoordinateFrameTransform", - "ActionChunkCoordinateFrameTransform", - "PoseCoordinateFrameTransform", - "PoseCoordinateFrameTransform", - "InterpolatePose", - "InterpolatePose", - "XYZWXYZ_to_XYZYPR", - "ConcatKeys", - "ConcatKeys", - "DeleteKeys", - ] - assert [name for name, _, _ in snapshots] == expected_names - - base_keys = { - "obs_head_pose", - "left.action_ee_pose", - "right.action_ee_pose", - "left.obs_ee_pose", - "right.obs_ee_pose", - } - _assert_snapshot( - snapshots, - 0, - "ActionChunkCoordinateFrameTransform", - base_keys | {"left.action_ee_pose_headframe"}, - { - "left.action_ee_pose_headframe": (6, 7), - "left.obs_ee_pose": (7,), - "right.obs_ee_pose": (7,), - }, - ) - _assert_snapshot( - snapshots, - 1, - "ActionChunkCoordinateFrameTransform", - base_keys | {"left.action_ee_pose_headframe", "right.action_ee_pose_headframe"}, - { - "left.action_ee_pose_headframe": (6, 7), - "right.action_ee_pose_headframe": (6, 7), - }, - ) - _assert_snapshot( - snapshots, - 4, - "InterpolatePose", - base_keys - | { - "left.action_ee_pose_headframe", - "right.action_ee_pose_headframe", - "left.obs_ee_pose_headframe", - "right.obs_ee_pose_headframe", - }, - { - "left.action_ee_pose_headframe": (4, 7), - "right.action_ee_pose_headframe": (6, 7), - }, - ) - _assert_snapshot( - snapshots, - 5, - "InterpolatePose", - base_keys - | { - "left.action_ee_pose_headframe", - "right.action_ee_pose_headframe", - "left.obs_ee_pose_headframe", - "right.obs_ee_pose_headframe", - }, - { - "left.action_ee_pose_headframe": (4, 7), - "right.action_ee_pose_headframe": (4, 7), - }, - ) - _assert_snapshot( - snapshots, - 6, - "XYZWXYZ_to_XYZYPR", - base_keys - | { - "left.action_ee_pose_headframe", - "right.action_ee_pose_headframe", - "left.obs_ee_pose_headframe", - "right.obs_ee_pose_headframe", - }, - { - "left.action_ee_pose_headframe": (4, 6), - "right.action_ee_pose_headframe": (4, 6), - "left.obs_ee_pose_headframe": (6,), - "right.obs_ee_pose_headframe": (6,), - }, - ) - _assert_snapshot( - snapshots, - 7, - "ConcatKeys", - base_keys - | { - "left.obs_ee_pose_headframe", - "right.obs_ee_pose_headframe", - "actions_cartesian", - }, - { - "actions_cartesian": (4, 12), - "left.obs_ee_pose_headframe": (6,), - "right.obs_ee_pose_headframe": (6,), - }, - ) - _assert_snapshot( - snapshots, - 8, - "ConcatKeys", - base_keys | {"actions_cartesian", "observations.state.ee_pose"}, - { - "actions_cartesian": (4, 12), - "observations.state.ee_pose": (12,), - }, - ) - _assert_snapshot( - snapshots, - 9, - "DeleteKeys", - {"actions_cartesian", "observations.state.ee_pose"}, - { - "actions_cartesian": (4, 12), - "observations.state.ee_pose": (12,), - }, - ) diff --git a/tests/test_action_flow_affine_configs.py b/tests/test_action_flow_affine_configs.py deleted file mode 100644 index 5c192ab8b2..0000000000 --- a/tests/test_action_flow_affine_configs.py +++ /dev/null @@ -1,138 +0,0 @@ -import hashlib -import json -import subprocess -import sys -from pathlib import Path - - -def test_builder_emits_frozen_eleven_variant_three_seed_matrix(tmp_path): - source = Path(__file__).parents[1] - output = tmp_path / "config" - experiment = tmp_path / "experiment" - training_dataset = tmp_path / "train.npz" - evaluation_dataset = tmp_path / "eval.npz" - training_dataset.write_bytes(b"frozen-training-data") - evaluation_dataset.write_bytes(b"frozen-evaluation-data") - subprocess.run( - [ - sys.executable, - str(source / "scripts/experiments/build_action_flow_affine_configs.py"), - "--output-dir", - str(output), - "--experiment-root", - str(experiment), - "--training-dataset", - str(training_dataset), - "--evaluation-dataset", - str(evaluation_dataset), - ], - check=True, - ) - configs = sorted(output.glob("gaussian-torus-*.json")) - assert len(configs) == 33 - resolved = [json.loads(path.read_text()) for path in configs] - assert {config["seed"] for config in resolved} == {42, 43, 44} - assert {config["checkpoint_every"] for config in resolved} == {50_000} - assert {config["evaluation_particles"] for config in resolved} == {2048} - assert {config["evaluation_dataset"] for config in resolved} == { - str(evaluation_dataset) - } - action_configs = [ - config for config in resolved if config["architecture"] == "action_adapter_flow" - ] - assert len(action_configs) == 30 - assert {config["model"]["latent_dim"] for config in action_configs} == {8} - manifest = json.loads((output / "manifest.json").read_text()) - assert manifest["primary_metric"] == "validation_generation_symmetric_nn_mse" - assert manifest["pass_threshold"] is None - assert len(manifest["variants"]) == 11 - assert manifest["datasets"]["training"] == { - "path": str(training_dataset.resolve()), - "sha256": hashlib.sha256(training_dataset.read_bytes()).hexdigest(), - } - assert manifest["datasets"]["evaluation"] == { - "path": str(evaluation_dataset.resolve()), - "sha256": hashlib.sha256(evaluation_dataset.read_bytes()).hexdigest(), - } - - for index, config in enumerate(resolved): - run = Path(config["output_dir"]) - run.mkdir(parents=True) - (run / "summary.json").write_text( - json.dumps( - { - "validation_generation_symmetric_nn_mse": float(index + 1), - "validation_generation_energy_distance": float(index + 2), - } - ) - ) - summary_json = tmp_path / "summary.json" - summary_markdown = tmp_path / "summary.md" - subprocess.run( - [ - sys.executable, - str(source / "scripts/experiments/summarize_action_flow_affine.py"), - "--manifest", - str(output / "manifest.json"), - "--output-json", - str(summary_json), - "--output-markdown", - str(summary_markdown), - ], - check=True, - ) - aggregate = json.loads(summary_json.read_text()) - assert len(aggregate["rows"]) == 11 - assert "mean" in aggregate["rows"][0][manifest["primary_metric"]] - assert "Symmetric NN MSE" in summary_markdown.read_text() - - e_configs = [config for config in resolved if config["variant"].startswith("e-")] - g_configs = [config for config in resolved if config["variant"].startswith("g-")] - assert {config["lambda_reconstruction"] for config in e_configs} == { - 1.0, - 10.0, - 100.0, - } - assert {config["lambda_reconstruction"] for config in g_configs} == { - 1.0, - 10.0, - 100.0, - } - assert {config["lambda_action_velocity"] for config in e_configs} == {0.0} - assert {config["lambda_action_velocity"] for config in g_configs} == {1.0} - - -def test_builder_run_suffix_produces_distinct_retry_identities(tmp_path): - source = Path(__file__).parents[1] - training_dataset = tmp_path / "train.npz" - evaluation_dataset = tmp_path / "eval.npz" - training_dataset.write_bytes(b"train") - evaluation_dataset.write_bytes(b"eval") - output = tmp_path / "config" - subprocess.run( - [ - sys.executable, - str(source / "scripts/experiments/build_action_flow_affine_configs.py"), - "--output-dir", - str(output), - "--experiment-root", - str(tmp_path / "experiment"), - "--training-dataset", - str(training_dataset), - "--evaluation-dataset", - str(evaluation_dataset), - "--seeds", - "42", - "--run-suffix", - "smoke-retry1", - ], - check=True, - ) - config = json.loads( - ( - output / "gaussian-torus-f-nonlinear-path-seed42-smoke-retry1.json" - ).read_text() - ) - assert config["wandb"]["id"].endswith("-smoke-retry1") - assert config["wandb"]["dir"] == str(tmp_path / "experiment" / "wandb") - assert config["output_dir"].endswith("-smoke-retry1") diff --git a/tests/test_action_flow_av_norm.py b/tests/test_action_flow_av_norm.py deleted file mode 100644 index 3b56a2ff24..0000000000 --- a/tests/test_action_flow_av_norm.py +++ /dev/null @@ -1,261 +0,0 @@ -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -from hydra import compose, initialize_config_dir -from torch import nn - -from egomimic.pipeline import stages_action_flow -from egomimic.pipeline.action_flow_av_norm import ( - AVOnlyLayerNormCallback, - differentiable_layer_norm, - install_av_only, -) -from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - ContentDecoderStage, - LatentBridgeStage, - RoutedContentDecoderStage, - RoutedContentEncoderStage, -) - - -class _TinyDecoder(nn.Module): - def __init__(self, output_dim): - super().__init__() - self.norms = nn.ModuleList(nn.LayerNorm(2) for _ in range(25)) - self.projection = nn.Linear(2, output_dim) - - def forward(self, value): - # Residuals avoid the artificial collapse caused by stacking 25 tiny - # width-two norms directly. Every norm still participates in the JVP. - for norm in self.norms: - value = value + 0.01 * norm(value) - return self.projection(value) - - -def _decoder(output_dim): - return _TinyDecoder(output_dim) - - -def _batch(route): - return { - "embodiment": torch.full((2,), route), - "action_flow/clean_latent": torch.randn(2, 3, 2, requires_grad=True), - "action_flow/state": torch.randn(2, 3, 2, requires_grad=True), - "action_flow/velocity_residual": torch.randn(2, 3, 2, requires_grad=True), - } - - -@pytest.mark.parametrize("mapped_base", [False, True]) -def test_routed_av_norm_only_touches_selected_jvp_and_restores_everything(mapped_base): - us, chain = _decoder(4), _decoder(6) - if mapped_base: - stage = ContentDecoderStage( - decoders={"us": us, "chain": chain}, - selector_aliases={19: "us", 20: "chain"}, - ) - else: - stage = RoutedContentDecoderStage( - decoders={"us": us, "chain": chain}, - route_key="embodiment", - route_aliases={19: "us", 20: "chain"}, - ) - original_jvp = stages_action_flow.jvp - original_forwards = [ - m.forward - for m in (*us.modules(), *chain.modules()) - if isinstance(m, nn.LayerNorm) - ] - install_av_only(stage) - - for route, selected, other, action_dim in ( - (19, us, chain, 4), - (20, chain, us, 6), - ): - batch = _batch(route) - stage._forward_train(batch) - assert batch[stage.reconstruction_key].shape == (2, 3, action_dim) - residual = batch[stage.decoded_residual_key] - assert residual.shape == (2, 3, action_dim) - selected_parameters = tuple(selected.parameters()) - other_parameters = tuple(other.parameters()) - grads = torch.autograd.grad( - residual.square().mean(), - selected_parameters + other_parameters, - allow_unused=True, - ) - active = grads[: len(selected_parameters)] - inactive = grads[len(selected_parameters) :] - # A synthetic stack of LayerNorms can cancel some affine directions. - # Require a real, finite AV gradient on the selected route, not a - # gradient for every mathematically unused fixture parameter. - assert any( - g is not None and torch.isfinite(g).all() and g.abs().sum() > 0 - for g in active - ) - assert all(g is None or torch.isfinite(g).all() for g in active) - assert all(g is None for g in inactive) - assert stages_action_flow.jvp is original_jvp - current = [ - m.forward - for m in (*us.modules(), *chain.modules()) - if isinstance(m, nn.LayerNorm) - ] - assert current == original_forwards - assert all( - m.forward.__func__ is not differentiable_layer_norm - for m in (*us.modules(), *chain.modules()) - if isinstance(m, nn.LayerNorm) - ) - - with pytest.raises(ValueError, match="homogeneous"): - bad = _batch(19) - bad["embodiment"] = torch.tensor([19, 20]) - stage._forward_train(bad) - assert stages_action_flow.jvp is original_jvp - - -def test_routed_av_norm_restores_on_jvp_failure(monkeypatch): - us, chain = _decoder(4), _decoder(6) - stage = RoutedContentDecoderStage( - decoders={"us": us, "chain": chain}, - route_key="embodiment", - route_aliases={19: "us", 20: "chain"}, - ) - original_forwards = [m.forward for m in us.modules() if isinstance(m, nn.LayerNorm)] - - def fail(*args, **kwargs): - raise RuntimeError("injected JVP failure") - - original_jvp = stages_action_flow.jvp - monkeypatch.setattr(stages_action_flow, "jvp", fail) - install_av_only(stage) - with pytest.raises(RuntimeError, match="injected JVP failure"): - stage._forward_train(_batch(19)) - assert stages_action_flow.jvp is fail - assert [ - m.forward for m in us.modules() if isinstance(m, nn.LayerNorm) - ] == original_forwards - monkeypatch.setattr(stages_action_flow, "jvp", original_jvp) - - -def test_callback_installs_only_for_one_routed_stage(): - stage = RoutedContentDecoderStage( - decoders={"us": _decoder(4), "chain": _decoder(6)}, - route_key="embodiment", - route_aliases={19: "us", 20: "chain"}, - ) - module = SimpleNamespace( - model=SimpleNamespace(pipeline=SimpleNamespace(stages=[stage])) - ) - callback = AVOnlyLayerNormCallback() - callback.setup(None, module, "validate") - assert not getattr(stage, "_av_norm_fix_installed", False) - callback.setup(None, module, "fit") - assert stage._av_norm_fix_installed - with pytest.raises(AssertionError, match="already installed"): - callback.setup(None, module, "fit") - - -def test_cotrain_av_norm_is_opt_in_and_hydra_reachable(monkeypatch): - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", "/verified/usocket") - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", "/verified/chain") - config_dir = Path(__file__).parents[1] / "egomimic" / "hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(config_dir.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=pusht/parents/action_flow_cotrain_uc_manual4919", - "callbacks=action_flow_avln_cotrain", - "hydra/launcher=basic", - "++paths.root_dir=.", - ], - ) - assert cfg.callbacks.av_only_layer_norm._target_.endswith( - "action_flow_av_norm.AVOnlyLayerNormCallback" - ) - assert cfg.callbacks.av_only_layer_norm.require_routed is True - assert cfg.callbacks.model_checkpoint.save_top_k == -1 - - -class _TinyField(nn.Module): - def __init__(self): - super().__init__() - self.projection = nn.Linear(2, 2) - self.condition = nn.Linear(5, 2) - - def forward(self, value, time, condition, *, condition_drop_mask=None): - assert condition_drop_mask is not None - return ( - self.projection(value) - + self.condition(condition)[:, None, :] - + time[:, None, None] - ) - - -def test_joint_optimizer_step_reaches_shared_field_and_selected_private_codecs(): - torch.manual_seed(42) - encoders = {"us": nn.Linear(4, 2), "chain": nn.Linear(6, 2)} - decoders = {"us": _decoder(4), "chain": _decoder(6)} - field = _TinyField() - encoder = RoutedContentEncoderStage( - encoders=encoders, - route_key="embodiment", - route_aliases={19: "us", 20: "chain"}, - ) - decoder = RoutedContentDecoderStage( - decoders=decoders, - route_key="embodiment", - route_aliases={19: "us", 20: "chain"}, - ) - install_av_only(decoder) - stages = ( - encoder, - LatentBridgeStage(samples_per_content=2, condition_dropout_probability=0.0), - ConditionalVelocityStage(field, num_inference_steps=3), - decoder, - ActionFlowObjectiveStage(), - ) - optimizer = torch.optim.AdamW( - list(encoder.parameters()) - + list(field.parameters()) - + list(decoder.parameters()), - lr=1e-3, - ) - for route, selected, other, action_dim in ( - (19, "us", "chain", 4), - (20, "chain", "us", 6), - ): - optimizer.zero_grad(set_to_none=True) - output = { - "embodiment": torch.full((2,), route), - "target": torch.randn(2, 3, action_dim), - "condition": torch.randn(2, 5), - "sampler/noise": torch.randn(2, 3, 2), - } - for stage in stages: - output = stage(output) - loss = output["loss/action_flow"] - assert torch.isfinite(loss) - assert all( - torch.isfinite(output[key]) - for key in ( - "log/action_flow_fm", - "log/action_flow_reconstruction", - "log/action_flow_action_velocity", - ) - ) - loss.backward() - for module in (encoder.encoder[selected], field, decoder.decoder[selected]): - assert any( - p.grad is not None - and torch.isfinite(p.grad).all() - and p.grad.abs().sum() > 0 - for p in module.parameters() - ) - for module in (encoder.encoder[other], decoder.decoder[other]): - assert all(p.grad is None for p in module.parameters()) - optimizer.step() diff --git a/tests/test_action_flow_candidate_codec.py b/tests/test_action_flow_candidate_codec.py deleted file mode 100644 index ef6a7a8e7d..0000000000 --- a/tests/test_action_flow_candidate_codec.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Exact-section and JVP behavior of the restricted graph diagnostic.""" - -import pytest -import torch -import torch.nn as nn -from torch.func import jvp - -from egomimic.models.action_flow_codec import GraphSectionSequenceCodec -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, - LatentBridgeStage, -) - - -class _EncoderView(nn.Module): - def __init__(self, codec): - super().__init__() - self.graph = codec.graph - - def forward(self, content): - return torch.cat((content, self.graph(content)), dim=-1) - - -class _Field(nn.Module): - def __init__(self): - super().__init__() - self.linear = nn.Linear(8, 8) - self.condition = nn.Linear(3, 8) - - def forward(self, value, time, condition, *, condition_drop_mask): - condition = condition.masked_fill(condition_drop_mask[:, None], 0) - return self.linear(value).tanh() + self.condition(condition)[:, None] - - -def _codec(): - return GraphSectionSequenceCodec(action_dim=4, latent_dim=8, horizon=4) - - -def test_graph_section_identity_survives_joint_optimizer_updates_and_shared_ownership(): - torch.manual_seed(46) - codec = _codec().double() - encoder = ContentEncoderStage(_EncoderView(codec)) - decoder = ContentDecoderStage(codec) - field = ConditionalVelocityStage(_Field().double()) - objective = ActionFlowObjectiveStage( - reconstruction_weight=0.0, residual_key="action_flow/fm_velocity_residual" - ) - pipeline = Pipeline( - [encoder, LatentBridgeStage(samples_per_content=2), field, decoder, objective] - ) - parameters = list(pipeline.parameters()) - assert len({id(parameter) for parameter in parameters}) == len(parameters) - assert encoder.encoder.graph is decoder.decoder.graph - optimizer = torch.optim.AdamW(parameters, lr=1e-3) - assert len(optimizer.param_groups[0]["params"]) == len(parameters) - snapshots = { - name: [parameter.detach().clone() for parameter in module.parameters()] - for name, module in (("graph", codec.graph), ("residual", codec.residual)) - } - target = torch.randn(2, 4, 4, dtype=torch.float64) - for _ in range(2): - latent = codec.encode(target) - torch.testing.assert_close(latent[..., :4], target, rtol=0, atol=0) - torch.testing.assert_close(codec(latent), target, rtol=0, atol=1e-12) - batch = pipeline( - { - "target": target, - "sampler/noise": torch.randn(2, 4, 8, dtype=torch.float64), - "condition": torch.randn(2, 3, dtype=torch.float64), - } - ) - torch.testing.assert_close( - batch["loss/action_flow"], - batch["log/action_flow_fm"] + batch["log/action_flow_action_velocity"], - rtol=0, - atol=0, - ) - optimizer.zero_grad() - batch["loss/action_flow"].backward() - for module in (codec.graph, codec.residual, field.field): - gradients = [p.grad for p in module.parameters() if p.grad is not None] - assert gradients and all(torch.isfinite(g).all() for g in gradients) - assert sum(g.abs().sum() for g in gradients) > 0 - optimizer.step() - torch.testing.assert_close(codec(codec.encode(target)), target, rtol=0, atol=1e-12) - for name, module in (("graph", codec.graph), ("residual", codec.residual)): - assert any( - not torch.equal(old, new) - for old, new in zip(snapshots[name], module.parameters()) - ) - - -def test_graph_section_jvp_matches_finite_difference_with_trainable_graph(): - torch.manual_seed(7) - codec = _codec().double() - latent = torch.randn(2, 4, 8, dtype=torch.float64, requires_grad=True) - tangent = torch.randn_like(latent, requires_grad=True) - _, predicted = jvp(codec, (latent,), (tangent,)) - delta = 1e-6 - expected = (codec(latent + delta * tangent) - codec(latent - delta * tangent)) / ( - 2 * delta - ) - torch.testing.assert_close(predicted, expected, rtol=2e-5, atol=2e-6) - gradients = torch.autograd.grad( - predicted.square().mean(), (latent, tangent, *codec.graph.parameters()) - ) - assert all(torch.isfinite(gradient).all() for gradient in gradients) - assert sum(gradient.abs().sum() for gradient in gradients[2:]) > 0 - - -def test_graph_section_context_free_shape_and_small_private_networks(): - codec = GraphSectionSequenceCodec(action_dim=4, latent_dim=8, horizon=16) - for module in (codec.graph, codec.residual): - assert module.depth == 2 - assert sum(parameter.numel() for parameter in module.parameters()) < 12_000 - with pytest.raises(ValueError, match="expected sequence shape"): - codec(torch.randn(2, 15, 8)) - with pytest.raises(ValueError, match="expected sequence shape"): - codec.encode(torch.randn(2, 16, 3)) - - -def test_graph_section_mixed_precision_identity_and_jvp_backward_are_finite(): - torch.manual_seed(19) - codec = _codec() - content = torch.randn(2, 4, 4) - latent = torch.randn(2, 4, 8, requires_grad=True) - tangent = torch.randn_like(latent, requires_grad=True) - with torch.autocast("cpu", dtype=torch.bfloat16): - reconstructed = codec(codec.encode(content)) - _, decoded_tangent = jvp(codec, (latent,), (tangent,)) - loss = decoded_tangent.square().mean() - torch.testing.assert_close(reconstructed, content, rtol=0, atol=0) - assert torch.isfinite(decoded_tangent).all() - loss.backward() - for module in (codec.graph, codec.residual): - gradients = [p.grad for p in module.parameters() if p.grad is not None] - assert gradients and all(torch.isfinite(g).all() for g in gradients) - assert sum(g.abs().sum() for g in gradients) > 0 - - -@pytest.mark.parametrize( - "kwargs", [{"action_dim": 8}, {"latent_dim": 3}, {"dropout": 0.1}] -) -def test_graph_section_rejects_invalid_or_stochastic_section(kwargs): - options = {"action_dim": 4, "latent_dim": 8, "horizon": 4, **kwargs} - with pytest.raises(ValueError): - GraphSectionSequenceCodec(**options) diff --git a/tests/test_action_flow_candidate_configs.py b/tests/test_action_flow_candidate_configs.py deleted file mode 100644 index ed031bb098..0000000000 --- a/tests/test_action_flow_candidate_configs.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Exercise the maintained Hydra rows and their candidate-specific telemetry.""" - -from pathlib import Path - -import hydra -import pytest -import torch -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from egomimic.eval.action_flow_diagnostics import ActionFlowDiagnostics -from egomimic.pl_utils.pl_model_action_flow import ActionFlowModelWrapper - -CONFIG_DIR = Path(__file__).parents[1] / "egomimic/hydra_configs" -ROWS = [ - ("latent_fm_sg_recon1", "latent_fm_stopgrad", 1.0), - ("graph_section", "graph_section_diagnostic", 0.0), -] - - -def _compose(row): - with initialize_config_dir(version_base=None, config_dir=str(CONFIG_DIR.resolve())): - return compose( - config_name="train_zarr_cartesian", - overrides=[ - f"+experiment=pusht/action_flow_bc_usocket_{row}_s42", - "++paths.root_dir=.", - ], - ) - - -@pytest.mark.parametrize("row,method,reconstruction_weight", ROWS) -def test_candidate_row_instantiates_exact_dimensions_and_shared_codec( - row, method, reconstruction_weight -): - cfg = _compose(row) - assert cfg.model.action_flow_method == cfg.train.action_flow_method == method - assert cfg.run_provenance.objective.method == method - assert cfg.model.action_horizon == 16 - assert cfg.model.latent_dim == 8 - assert cfg.model.action_dim == 4 - assert cfg.model.reconstruction_weight == reconstruction_weight - assert cfg.run_provenance.objective.decoded_noise_scale_weight == 0 - assert cfg.model.flow_samples_per_content == 14 - assert cfg.model.condition_dropout_probability == 0.3 - assert cfg.model.optimizer.lr == 3e-5 - - algo = hydra.utils.instantiate(cfg.model.pipeline, device="cpu") - stages = algo.pipeline.stages - encoder, field, decoder, objective = stages[3], stages[5], stages[6], stages[7] - assert field.field.horizon == 16 - assert field.field.input_dim == field.field.output_dim == 8 - assert field.field.condition_dim == 67 - assert field.field.depth == 12 - assert 39_000_000 <= sum(p.numel() for p in field.parameters()) <= 41_000_000 - assert objective.reconstruction_weight == reconstruction_weight - assert stages[1].num_tokens == 16 and stages[1].latent_dim == 8 - parameters = list(algo.nets.parameters()) - assert len(parameters) == len({id(parameter) for parameter in parameters}) - if method == "latent_fm_stopgrad": - assert field.flow_clean_gradient_mode == "all_stopgrad" - assert objective.residual_key == field.flow_residual_key - assert decoder.residual_key == field.residual_key - assert field.flow_residual_key != field.residual_key - assert encoder.encoder.depth == decoder.decoder.depth == 2 - else: - assert field.flow_clean_gradient_mode == "full" - assert encoder.encoder.graph is decoder.decoder.graph - encoder_parameters = {id(parameter) for parameter in encoder.parameters()} - residual_parameters = { - id(parameter) for parameter in decoder.decoder.residual.parameters() - } - assert encoder_parameters.isdisjoint(residual_parameters) - assert decoder.decoder.latent_dim - decoder.decoder.action_dim == 4 - assert not cfg.run_provenance.objective.reconstruction_is_optimizer_objective - assert cfg.run_provenance.requirements.r6 == "FAIL_restricted_diagnostic" - assert not cfg.evaluator.action_flow_diagnostics.capture_activations - target = torch.randn(2, 16, 4) - torch.testing.assert_close( - decoder.decoder(encoder.encoder(target)), target, rtol=0, atol=1e-6 - ) - - -@pytest.mark.parametrize("row,method,reconstruction_weight", ROWS) -def test_candidate_wrapper_gradient_routes_and_compute_contract( - monkeypatch, row, method, reconstruction_weight -): - cfg = _compose(row) - # Preserve the real Hydra codec/factory/stage wiring and all sequence - # dimensions; replace only the expensive shared field width/depth. Inputs - # are already encoded observations, so image/data nodes are not exercised. - pipeline_cfg = OmegaConf.create( - OmegaConf.to_container(cfg.model.pipeline, resolve=True) - ) - pipeline_cfg.stages = list(pipeline_cfg.stages)[3:] - field_config = pipeline_cfg.stages[2].field - field_config.hidden_dim = 32 - field_config.depth = 2 - field_config.num_heads = 4 - field_config.feedforward_dim = 64 - field_config.time_embedding_dim = 32 - algo = hydra.utils.instantiate(pipeline_cfg, device="cpu") - wrapper = ActionFlowModelWrapper(pipeline=algo, gradient_telemetry_cadence=1) - logged = {} - monkeypatch.setattr( - wrapper, "log", lambda key, value, **kwargs: logged.update({key: value}) - ) - torch.manual_seed(51) - loss = wrapper.training_step( - { - "fixture": { - "target": torch.randn(2, 16, 4), - "condition": torch.randn(2, 67), - "sampler/noise": torch.randn(2, 16, 8), - } - }, - 0, - ) - assert torch.isfinite(loss) - loss.backward() - assert wrapper._gradient_route_manifest is not None - routes = wrapper._gradient_route_manifest["routes"] - intersections = wrapper._gradient_route_manifest["intersections"] - assert routes["FM"] and routes["ActionVelocity"] - assert intersections["FM__ActionVelocity"] - assert all( - torch.isfinite(p.grad).all() for p in wrapper.parameters() if p.grad is not None - ) - calls = 2 if method == "latent_fm_stopgrad" else 1 - assert logged["Train/ActionFlow/Compute/FieldForwardCallsPerStep"] == calls - assert ( - logged["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"] == calls * 14 - ) - if method == "latent_fm_stopgrad": - assert intersections["FM__Reconstruction"] == [] - assert logged["Train/ActionFlow/GradientCosineDefined/FM__Reconstruction"] == 0 - assert ( - logged[ - "Train/ActionFlow/GradientIntersectionParameterCount/FM__Reconstruction" - ] - == 0 - ) - assert routes["Reconstruction"] - else: - assert "Reconstruction" not in routes - assert "Train/ActionFlow/GradientNorm/Reconstruction" not in logged - torch.testing.assert_close( - loss, - logged["Train/ActionFlow/FlowMatchingLoss"] - + logged["Train/ActionFlow/ActionVelocityLoss"], - ) - - -def test_graph_factory_rejects_independent_encoder_configuration(): - cfg = _compose("graph_section") - pipeline_cfg = OmegaConf.create( - OmegaConf.to_container(cfg.model.pipeline, resolve=True) - ) - pipeline_cfg.stages = [pipeline_cfg.stages[3], pipeline_cfg.stages[6]] - pipeline_cfg.stages[0].encoder = {"_target_": "torch.nn.Identity"} - with pytest.raises(hydra.errors.InstantiationException, match="second codec"): - hydra.utils.instantiate(pipeline_cfg, device="cpu") - - -@pytest.mark.parametrize("row,method,reconstruction_weight", ROWS) -def test_candidate_diagnostic_config_constructs_without_stale_activation_settings( - tmp_path, row, method, reconstruction_weight -): - cfg = _compose(row) - diagnostics = cfg.evaluator.action_flow_diagnostics - diagnostics.noise_seed_bank_path = str( - CONFIG_DIR / "evaluator/energy_score_seed_bank_k32_v1.json" - ) - diagnostics.artifact_root = str(tmp_path / "diagnostics") - config = OmegaConf.to_container(diagnostics, resolve=True) - instance = ActionFlowDiagnostics(config) - assert instance.jacobian_samples == 2 - assert instance.capture_activations == (method == "latent_fm_stopgrad") - if method == "graph_section_diagnostic": - assert instance.activation_layer_map == () - assert instance.cknna_k == 0 diff --git a/tests/test_action_flow_candidate_gates.py b/tests/test_action_flow_candidate_gates.py deleted file mode 100644 index a37e727f14..0000000000 --- a/tests/test_action_flow_candidate_gates.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Method-specific gates: real configs plus tiny, honest telemetry receipts.""" - -import hashlib -import re -import subprocess - -import pytest -import torch -from hydra.core.hydra_config import HydraConfig -from omegaconf import OmegaConf, open_dict - -from tests.test_verify_action_flow_training_smoke import HEAD, MODULE, _history_row -from tools.validate_action_flow_config import ( - CHECKPOINT_METHOD_CONTRACTS, - DEFAULT_CONFIG_ROOT, - GRAPH_METHOD, - LIKELIHOOD_METHOD, - STOPGRAD_METHOD, - STOPGRAD_UNITE_METHOD, - PreflightError, - compose_experiment, - validate_experiment, - validate_method_contract, -) - -# Historical method identities are not selectable recipes. Exercise every current -# recipe with the full gates, and require explicit rejection for removed ones. -CURRENT_RECIPE_METHODS = { - recipe: method - for recipe, method in CHECKPOINT_METHOD_CONTRACTS.items() - if (DEFAULT_CONFIG_ROOT / "experiment" / f"{recipe}.yaml").is_file() -} -RETIRED_METHOD_RECIPES = set(CHECKPOINT_METHOD_CONTRACTS) - set(CURRENT_RECIPE_METHODS) - - -@pytest.mark.parametrize("experiment", sorted(RETIRED_METHOD_RECIPES)) -def test_retired_method_identity_cannot_select_a_training_recipe(experiment): - with pytest.raises(PreflightError, match="absent or retired"): - compose_experiment(experiment) - - -@pytest.fixture(autouse=True) -def offline_campaign_roots(monkeypatch, tmp_path): - # Config composition only: no dataset reads or launch receipts are fabricated. - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", str(tmp_path / "usocket")) - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", str(tmp_path / "chain")) - - -@pytest.mark.parametrize("experiment", CURRENT_RECIPE_METHODS) -def test_real_candidate_config_and_parameter_manifest(experiment): - if ( - "chain_points6_latent_fm_sg_unite_h512" in experiment - or "cotrain_uc_latent_fm_sg_unite_h512" in experiment - ): - # Campaign-owned manifests are deliberately absent from the shipped YAML; - # this unit test must verify the gate, not invent production receipts. - with pytest.raises(PreflightError, match="manifest.*missing"): - validate_experiment(experiment) - return - report, _ = validate_experiment(experiment) - assert report["status"] == "PASS" - assert report["action_flow_method"] == CURRENT_RECIPE_METHODS[experiment] - assert report["topology"]["shared_field_instance"] - topology = report["topology"] - if "private_codec_routes" in topology: - assert set(topology["private_codec_routes"]) == set( - report["dimensions"]["actions"] - ) - assert "shared_decoder_instance" not in topology - else: - assert topology["shared_decoder_instance"] - expected_stages = ( - 9 if CURRENT_RECIPE_METHODS[experiment] == STOPGRAD_UNITE_METHOD else 8 - ) - assert len(topology["train_order"]) == expected_stages - assert ( - sum( - v["total"] for k, v in report["parameters"].items() if k != "pipeline_total" - ) - == report["parameters"]["pipeline_total"]["total"] - ) - - -@pytest.mark.parametrize("experiment", CURRENT_RECIPE_METHODS) -def test_candidate_two_optimizer_update_config_gate(experiment, tmp_path, monkeypatch): - if experiment not in MODULE.APPROVED_EXPERIMENTS: - # A shipped candidate config is not automatically an approved smoke recipe. - with pytest.raises( - MODULE.SmokeVerificationError, match="unapproved experiment" - ): - MODULE._validate_config( - config_path=tmp_path / "unused.yaml", - experiment=experiment, - run_dir=tmp_path, - expected_head=HEAD, - expected_config_sha256=None, - expected_split_sha256=None, - expected_normalization_sha256=None, - ) - return - cfg = compose_experiment(experiment) - normalization = tmp_path / "norm_stats.json" - normalization.write_text("{}") - norm_hash = hashlib.sha256(normalization.read_bytes()).hexdigest() - with open_dict(cfg): - cfg.trainer.max_steps = 2 - cfg.trainer.val_check_interval = ( - 2 if CURRENT_RECIPE_METHODS[experiment] == STOPGRAD_UNITE_METHOD else 1 - ) - cfg.trainer.limit_val_batches = 1 - cfg.trainer.precision = "bf16" - cfg.callbacks.model_checkpoint.every_n_train_steps = 1 - cfg.model.gradient_telemetry_cadence = 2 - cfg.norm_stats.precomputed_norm_path = str(normalization) - cfg.run_provenance.source_commit = HEAD - cfg.run_provenance.normalization_sha256 = norm_hash - cfg.evaluator.artifact_root = str(tmp_path / "energy") - if CURRENT_RECIPE_METHODS[experiment] != LIKELIHOOD_METHOD: - cfg.evaluator.action_flow_diagnostics.artifact_root = str( - tmp_path / "diagnostics" - ) - cfg.hydra.runtime.cwd = str(MODULE.REPOSITORY_ROOT) - cfg.hydra.runtime.output_dir = str(tmp_path) - HydraConfig.instance().set_config(cfg) - selected = OmegaConf.masked_copy(cfg, [key for key in cfg if key != "hydra"]) - path = tmp_path / "config.yaml" - OmegaConf.save( - OmegaConf.create(OmegaConf.to_container(selected, resolve=True)), path - ) - monkeypatch.setattr(MODULE, "_git_head", lambda: HEAD) - MODULE._validate_config( - config_path=path, - experiment=experiment, - run_dir=tmp_path, - expected_head=HEAD, - expected_config_sha256=None, - expected_split_sha256=None, - expected_normalization_sha256=norm_hash, - ) - - -@pytest.mark.parametrize( - "experiment,path,value", - [ - ( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_s42", - "model.pipeline.stages.5.flow_clean_gradient_mode", - "attached", - ), - ( - "pusht/action_flow_bc_usocket_graph_section_s42", - "model.reconstruction_weight", - 1.0, - ), - ( - "pusht/action_flow_bc_usocket_bridge_likelihood_s42", - "run_provenance.inference.sampler", - "reverse_euler", - ), - ( - "pusht/action_flow_bc_usocket_bridge_likelihood_s42", - "model.pipeline.stages.7.tau", - 0.01, - ), - ], -) -def test_method_contract_rejects_scientific_identity_drift(experiment, path, value): - cfg = compose_experiment(experiment) - OmegaConf.update(cfg, path, value) - with pytest.raises(PreflightError): - validate_method_contract(cfg, experiment) - - -def test_stopgrad_telemetry_requires_empty_fm_reconstruction_intersection(): - row = _history_row() - for key in list(row): - if key.endswith("/FM__Reconstruction"): - row[key] = 0.0 - row["Train/ActionFlow/Compute/FieldForwardCallsPerStep"] = 2.0 - row["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"] = 28.0 - MODULE._validate_history({1: row}, method=STOPGRAD_METHOD) - row["Train/ActionFlow/GradientIntersectionParameterCount/FM__Reconstruction"] = 1.0 - with pytest.raises(MODULE.SmokeVerificationError, match="reachability"): - MODULE._validate_history({1: row}, method=STOPGRAD_METHOD) - - -def test_graph_telemetry_does_not_invent_reconstruction_gradients(): - row = { - key: value - for key, value in _history_row().items() - if not ("/Gradient" in key and "Reconstruction" in key) - and "/Alignment/CK" not in key - } - row["Train/ActionFlow/TotalLoss_step"] = 2.5 - row[f"Train/ActionFlow/TotalLoss/{MODULE.SOURCE_LABEL}_step"] = 2.5 - row["Valid/ActionFlow/TotalLoss"] = 3.0 - MODULE._validate_history({1: row}, method=GRAPH_METHOD, reconstruction_weight=0.0) - - -def test_likelihood_history_requires_real_nll_and_common_validation_only(): - row = { - key: value - for key, value in _history_row().items() - if "/ActionFlow/" not in key or "/Compute/" in key - } - for component in ("InteriorBridgeNLL", "BoundaryNLL", "TotalLoss"): - value = 2.5 if component == "TotalLoss" else 1.25 - row[f"Train/ActionFlow/{component}_step"] = value - row[f"Train/ActionFlow/{component}/{MODULE.SOURCE_LABEL}_step"] = value - row[f"Valid/ActionFlow/{component}"] = value - for label in ("InteriorBridgeNLL", "BoundaryNLL"): - row[f"Train/ActionFlow/GradientNorm/{label}"] = 0.5 - row[f"Train/ActionFlow/GradientParameterCount/{label}"] = 100.0 - pair = "InteriorBridgeNLL__BoundaryNLL" - row[f"Train/ActionFlow/GradientCosine/{pair}"] = 0.25 - row[f"Train/ActionFlow/GradientCosineDefined/{pair}"] = 1.0 - row[f"Train/ActionFlow/GradientIntersectionParameterCount/{pair}"] = 50.0 - row["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"] = 15.0 - row["Train/ActionFlow/Compute/DecoderJVPCallsPerStep"] = 0.0 - MODULE._validate_history({1: row}, method=LIKELIHOOD_METHOD) - del row["Valid/EnergyScore@32"] - with pytest.raises(MODULE.SmokeVerificationError): - MODULE._validate_history({1: row}, method=LIKELIHOOD_METHOD) - - -@pytest.mark.parametrize("method", [STOPGRAD_METHOD, GRAPH_METHOD, LIKELIHOOD_METHOD]) -def test_checkpoint_gradient_receipt_uses_exact_method_routes(method): - named = [ - (f"nets.pipeline.stages.{stage}.weight", torch.nn.Parameter(torch.ones(2))) - for stage in (0, 3, 5, 6) - ] - groups = { - "FM": [0, 2] if method == STOPGRAD_METHOD else [0, 1, 2], - "Reconstruction": [1, 3], - "ActionVelocity": [0, 1, 2, 3], - } - if method == GRAPH_METHOD: - del groups["Reconstruction"] - elif method == LIKELIHOOD_METHOD: - groups = {"InteriorBridgeNLL": [0, 1, 2], "BoundaryNLL": [0, 1, 2, 3]} - routes = { - label: [ - {"name": named[i][0], "shape": [2], "numel": 2, "dtype": "torch.float32"} - for i in indices - ] - for label, indices in groups.items() - } - labels = list(groups) - core = { - "schema_version": 1, - "routes": routes, - "route_sha256": { - k: MODULE._canonical_json_sha256(v) for k, v in routes.items() - }, - "intersections": { - f"{left}__{right}": [ - named[i][0] for i in groups[left] if i in groups[right] - ] - for index, left in enumerate(labels) - for right in labels[index + 1 :] - }, - } - manifest = {**core, "manifest_sha256": MODULE._canonical_json_sha256(core)} - MODULE._validate_gradient_route_manifest(manifest, named, method=method) - - -@pytest.mark.parametrize( - "requested,selected,accepted", - [ - ("gpu-h100,gpu-h200", "gpu-h100", True), - ("gpu-h100,gpu-h200", "gpu-h200", True), - ("ice-gpu", "ice-gpu", True), - ("gpu-h100,gpu-h200", "gpu", False), - ("gpu-h100", "gpu-h200", False), - ("gpu-h100,", "gpu-h100", False), - ], -) -def test_portable_launcher_accepts_only_explicit_partition_members( - requested, selected, accepted -): - source = ( - MODULE.REPOSITORY_ROOT / "scripts/train/launch_action_flow_usocket.sbatch" - ).read_text() - function = re.search(r"partition_allowed\(\) \{.*?\n\}", source, re.S).group() - result = subprocess.run( - [ - "bash", - "-c", - function + '\npartition_allowed "$1" "$2"', - "test", - requested, - selected, - ] - ) - assert (result.returncode == 0) is accepted diff --git a/tests/test_action_flow_candidate_stages.py b/tests/test_action_flow_candidate_stages.py deleted file mode 100644 index 7bf20cf6d9..0000000000 --- a/tests/test_action_flow_candidate_stages.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Gradient routes for the separately approved latent-FM-only SG candidate.""" - -import copy - -import pytest -import torch -import torch.nn as nn - -from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, - LatentBridgeStage, -) - - -class _Field(nn.Module): - def __init__(self): - super().__init__() - self.weight = nn.Parameter(torch.tensor(0.4, dtype=torch.float64)) - self.condition = nn.Linear(3, 8).double() - self.calls = [] - - def forward(self, state, time, condition, *, condition_drop_mask): - self.calls.append((state, time, condition, condition_drop_mask)) - masked = condition.masked_fill(condition_drop_mask[:, None], 0) - return ( - self.weight * state.tanh() - + self.condition(masked)[:, None] - + time[:, None, None] - ) - - -def _forward( - mode, encoder, decoder, field, target, noise, condition, execution="separate" -): - stages = [ - ContentEncoderStage(encoder), - LatentBridgeStage(samples_per_content=3, condition_dropout_probability=0.3), - ConditionalVelocityStage( - field, flow_clean_gradient_mode=mode, fm_field_execution=execution - ), - ContentDecoderStage(decoder), - ActionFlowObjectiveStage(residual_key="action_flow/fm_velocity_residual"), - ] - batch = {"target": target, "sampler/noise": noise, "condition": condition} - torch.manual_seed(72) - for stage in stages: - batch = stage(batch) - return batch - - -def _models_and_inputs(): - torch.manual_seed(41) - return ( - nn.Sequential(nn.Linear(4, 8), nn.Tanh()).double(), - nn.Sequential(nn.Linear(8, 6), nn.SiLU(), nn.Linear(6, 4)).double(), - _Field(), - torch.randn(3, 4, 4, dtype=torch.float64), - torch.randn(3, 4, 8, dtype=torch.float64), - torch.randn(3, 3, dtype=torch.float64, requires_grad=True), - ) - - -def test_latent_only_stopgrad_blocks_both_clean_fm_routes_not_shared_condition(): - encoder, decoder, field, target, noise, condition = _models_and_inputs() - batch = _forward("all_stopgrad", encoder, decoder, field, target, noise, condition) - fm = batch["log/action_flow_fm"] - gradients = torch.autograd.grad( - fm, tuple(encoder.parameters()), allow_unused=True, retain_graph=True - ) - assert all( - gradient is None or torch.count_nonzero(gradient) == 0 for gradient in gradients - ) - field_gradients = torch.autograd.grad( - fm, tuple(field.parameters()), retain_graph=True - ) - assert all(torch.isfinite(gradient).all() for gradient in field_gradients) - assert sum(gradient.abs().sum() for gradient in field_gradients) > 0 - condition_gradient = torch.autograd.grad(fm, condition)[0] - assert torch.isfinite(condition_gradient).all() - assert condition_gradient.abs().sum() > 0 - - attached, detached = field.calls - assert attached[0].requires_grad and not detached[0].requires_grad - torch.testing.assert_close(attached[0], detached[0], rtol=0, atol=0) - # No new bridge noise, time, observation encoding, or dropout mask draw. - for index in (1, 2, 3): - assert attached[index] is detached[index] - torch.testing.assert_close( - batch["action_flow/fm_velocity_residual"], - batch["action_flow/velocity_residual"], - rtol=0, - atol=0, - ) - - -def test_shared_forward_matches_two_forward_reference_gradients(): - encoder, decoder, field, target, noise, condition = _models_and_inputs() - reference_encoder, reference_decoder, reference_field = ( - copy.deepcopy(encoder), - copy.deepcopy(decoder), - copy.deepcopy(field), - ) - reference_condition = condition.detach().clone().requires_grad_() - - output = _forward( - "all_stopgrad", - encoder, - decoder, - field, - target, - noise, - condition, - execution="shared", - ) - torch.manual_seed(72) - clean = reference_encoder(target) - bridge = LatentBridgeStage( - samples_per_content=3, - condition_dropout_probability=0.3, - )( - { - "action_flow/clean_latent": clean, - "sampler/noise": noise, - "condition": reference_condition, - } - ) - state = bridge["action_flow/state"] - time = bridge["action_flow/time"] - repeated_condition = bridge["action_flow/condition"] - drop_mask = bridge["action_flow/condition_drop_mask"] - target_velocity = bridge["action_flow/target_velocity"] - action_prediction = reference_field( - state, - time, - repeated_condition, - condition_drop_mask=drop_mask, - ) - flow_prediction = reference_field( - state.detach(), - time, - repeated_condition, - condition_drop_mask=drop_mask, - ) - reference_batch = ContentDecoderStage(reference_decoder)( - { - **bridge, - "target": target, - "action_flow/velocity_residual": action_prediction - target_velocity, - } - ) - reference_batch["action_flow/fm_velocity_residual"] = ( - flow_prediction - target_velocity.detach() - ) - reference_output = ActionFlowObjectiveStage( - residual_key="action_flow/fm_velocity_residual" - )(reference_batch) - - parameters = ( - *encoder.parameters(), - *decoder.parameters(), - *field.parameters(), - condition, - ) - reference_parameters = ( - *reference_encoder.parameters(), - *reference_decoder.parameters(), - *reference_field.parameters(), - reference_condition, - ) - gradients = torch.autograd.grad(output["loss/action_flow"], parameters) - reference_gradients = torch.autograd.grad( - reference_output["loss/action_flow"], reference_parameters - ) - for actual, expected in zip(gradients, reference_gradients): - torch.testing.assert_close(actual, expected, rtol=1.0e-10, atol=1.0e-12) - assert len(field.calls) == 1 - assert len(reference_field.calls) == 2 - - -def test_latent_only_sg_preserves_all_action_flow_gradients_and_diagnostics(): - originals = _models_and_inputs() - results = [] - for mode in ("full", "all_stopgrad"): - encoder, decoder, field = [copy.deepcopy(module) for module in originals[:3]] - target, noise = originals[3:5] - condition = originals[5].detach().clone().requires_grad_() - batch = _forward(mode, encoder, decoder, field, target, noise, condition) - params = ( - *encoder.parameters(), - *decoder.parameters(), - *field.parameters(), - condition, - ) - gradients = torch.autograd.grad( - batch["log/action_flow_action_velocity"], - params, - allow_unused=True, - retain_graph=True, - ) - # The encoder must still receive generative supervision through the - # bridge state, target tangent, and decoder Jacobian evaluation point. - encoder_gradients = gradients[: len(tuple(encoder.parameters()))] - assert all(gradient is not None for gradient in encoder_gradients) - assert sum(gradient.abs().sum() for gradient in encoder_gradients) > 0 - if mode == "full": - fm_gradients = torch.autograd.grad( - batch["log/action_flow_fm"], tuple(encoder.parameters()) - ) - assert sum(gradient.abs().sum() for gradient in fm_gradients) > 0 - assert len(field.calls) == 1 - assert ( - batch["action_flow/fm_velocity_residual"] - is batch["action_flow/velocity_residual"] - ) - results.append((batch, gradients)) - for key in ( - "action_flow/state", - "action_flow/target_velocity", - "action_flow/predicted_velocity", - "action_flow/velocity_residual", - "action_flow/decoded_velocity_residual", - ): - torch.testing.assert_close( - results[0][0][key], results[1][0][key], rtol=0, atol=0 - ) - for full, isolated in zip(results[0][1], results[1][1]): - if full is None: - assert isolated is None - else: - torch.testing.assert_close(full, isolated, rtol=0, atol=0) - - -@pytest.mark.parametrize("mode", ["target_stopgrad", "all", None]) -def test_candidate_rejects_unsupported_gradient_modes(mode): - with pytest.raises(ValueError, match="flow_clean_gradient_mode"): - ConditionalVelocityStage(_Field(), flow_clean_gradient_mode=mode) - - -def test_fm_key_cannot_clobber_action_flow_residual(): - with pytest.raises(ValueError, match="must be distinct"): - ConditionalVelocityStage( - _Field(), flow_residual_key="action_flow/velocity_residual" - ) diff --git a/tests/test_action_flow_configs.py b/tests/test_action_flow_configs.py deleted file mode 100644 index 364b98940e..0000000000 --- a/tests/test_action_flow_configs.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import numpy as np -import pytest -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from egomimic.rldb.embodiment.pushshapes import ( - get_usocket_rotvec_action_transform_list, -) - -CONFIG_DIR = Path(__file__).parents[1] / "egomimic" / "hydra_configs" - - -def _compose(row: str): - with initialize_config_dir(version_base=None, config_dir=str(CONFIG_DIR.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=pusht/{row}", "++paths.root_dir=."], - ) - return cfg - - -def test_action_only_adapter_preserves_native_state_and_encodes_target_angle(): - state = np.array([1.0, -2.0, 0.7], dtype=np.float32) - actions = np.array([[3.0, 4.0, 0.0], [5.0, 6.0, np.pi / 2]], dtype=np.float32) - sample = {"state_agent_obj": state.copy(), "actions": actions.copy()} - - for transform in get_usocket_rotvec_action_transform_list(): - sample = transform.transform(sample) - - np.testing.assert_array_equal(sample["state_agent_obj"], state) - assert sample["actions"].shape == (2, 4) - np.testing.assert_allclose(sample["actions"][:, :2], actions[:, :2]) - np.testing.assert_allclose( - sample["actions"][:, 2:], [[1.0, 0.0], [0.0, 1.0]], atol=1e-6 - ) - - -@pytest.mark.parametrize( - ("row", "reconstruction_weight"), - [ - ("action_flow_bc_usocket_recon1_s42", 1.0), - ("action_flow_bc_usocket_recon10_s42", 10.0), - ("action_flow_bc_usocket_recon100_s42", 100.0), - ], -) -def test_action_flow_pair_composes_to_exact_shared_contract( - row: str, reconstruction_weight: float -): - cfg = _compose(row) - stages = cfg.model.pipeline.stages - names = [stage._target_.rsplit(".", 1)[-1] for stage in stages] - - assert names == [ - "FusedObsEncoder", - "GaussianLatentNoise", - "ActionTargetBuilder", - "ContentEncoderStage", - "LatentBridgeStage", - "ConditionalVelocityStage", - "ContentDecoderStage", - "ActionFlowObjectiveStage", - ] - assert cfg.model._target_ == "egomimic.pl_utils.pl_model.ModelWrapper" - assert cfg.model.training_behavior._target_ == ( - "egomimic.pl_utils.training_behavior_action_flow.ActionFlowTrainingBehavior" - ) - assert cfg.model.action_horizon == 16 - assert cfg.model.action_dim == 4 - assert cfg.model.latent_dim == 8 - assert cfg.model.condition_dim == 67 - assert cfg.model.flow_samples_per_content == 14 - assert cfg.model.condition_dropout_probability == pytest.approx(0.3) - assert cfg.model.num_inference_steps == 16 - assert cfg.model.reconstruction_weight == reconstruction_weight - assert stages[-1].flow_weight == 1.0 - assert stages[-1].reconstruction_weight == reconstruction_weight - assert stages[-1].action_velocity_weight == 1.0 - - field = stages[5].field - assert field.horizon == 16 - assert field.input_dim == field.output_dim == 8 - assert field.condition_dim == 67 - assert field.hidden_dim == 512 - assert field.depth == 12 - assert field.num_heads == 8 - assert field.feedforward_dim == 2048 - assert field.time_scale == pytest.approx(1000.0) - assert field.condition_dropout_probability == pytest.approx(0.3) - - encoder = stages[3].encoder - decoder = stages[6].decoder - assert encoder.horizon == decoder.horizon == 16 - assert encoder.input_dim == decoder.output_dim == 4 - assert encoder.latent_dim == decoder.latent_dim == 8 - assert encoder.hidden_dim == decoder.hidden_dim == 20 - assert encoder.depth == decoder.depth == 2 - - dataset = cfg.data.train_datasets.pushshapes_sim_u_socket - valid = cfg.data.valid_datasets.pushshapes_sim_u_socket - assert dataset.resolver.key_map._target_.endswith("get_planar_keymap") - assert dataset.resolver.transform_list._target_.endswith( - "get_usocket_rotvec_action_transform_list" - ) - assert dataset.valid_ratio == valid.valid_ratio == 0.01 - assert dataset.split_seed == valid.split_seed == 42 - assert dataset.expected_train_episode_count == 2970 - assert dataset.expected_valid_episode_count == 29 - assert cfg.run_provenance.split_manifest_sha256 == ( - "3683e3461596eef8df2432fa865779b3c77b2a2057dabd0fea125595729cf313" - ) - - -def test_only_reconstruction_weight_differs_across_sweep(): - reference = OmegaConf.to_container( - _compose("action_flow_bc_usocket_recon1_s42").model, resolve=True - ) - for suffix, expected_weight in (("10", 10.0), ("100", 100.0)): - candidate = OmegaConf.to_container( - _compose(f"action_flow_bc_usocket_recon{suffix}_s42").model, - resolve=True, - ) - assert candidate["reconstruction_weight"] == expected_weight - candidate["reconstruction_weight"] = reference["reconstruction_weight"] - candidate["pipeline"]["stages"][-1]["reconstruction_weight"] = reference[ - "pipeline" - ]["stages"][-1]["reconstruction_weight"] - assert candidate == reference - - -def test_recon10_warmup_row_changes_only_the_objective_schedule(): - reference_cfg = _compose("action_flow_bc_usocket_recon10_s42") - candidate_cfg = _compose("action_flow_bc_usocket_recon10_warmup10k_s42") - reference = OmegaConf.to_container(reference_cfg, resolve=False) - candidate = OmegaConf.to_container(candidate_cfg, resolve=False) - - assert candidate["model"]["reconstruction_only_warmup_steps"] == 10000 - assert ( - candidate_cfg.run_provenance.objective.reconstruction_only_warmup_steps == 10000 - ) - del candidate["model"]["reconstruction_only_warmup_steps"] - assert ( - candidate["run_provenance"]["objective"].pop("reconstruction_only_warmup_steps") - == "${model.reconstruction_only_warmup_steps}" - ) - candidate["name"] = reference["name"] - candidate["description"] = reference["description"] - assert candidate == reference diff --git a/tests/test_action_flow_diagnostics.py b/tests/test_action_flow_diagnostics.py deleted file mode 100644 index 33e2bb2c44..0000000000 --- a/tests/test_action_flow_diagnostics.py +++ /dev/null @@ -1,929 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -import torch.nn as nn -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from egomimic.eval.action_flow_diagnostic_forward import ( - _decoder_singular_values, - _fixed_decoder_singular_values, - collect_action_flow_diagnostics, -) -from egomimic.eval.action_flow_diagnostics import ActionFlowDiagnostics -from egomimic.eval.pipeline_diagnostics import ActionFlowDiagnosticProvider -from egomimic.eval.planar_action_eval import ( - USOCKET_NATIVE_ERROR_CONFIG, - PlanarActionEval, -) -from egomimic.models.unite_action_decoder import UniteActionDecoder -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.pushshapes import USocketRotVecNativeDecoder -from egomimic.pipeline.stages_action_flow import ( - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, - RoutedContentDecoderStage, - RoutedContentEncoderStage, -) -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.pl_utils.training_behavior_action_flow import ActionFlowTrainingBehavior - -_CONFIG_DIR = Path(__file__).parents[1] / "egomimic/hydra_configs" - - -def test_chunked_decoder_jacobian_singular_values_match_full_jacrev(): - torch.manual_seed(29) - decoder = nn.Sequential(nn.Linear(3, 12), nn.Tanh(), nn.Linear(12, 10)) - values = torch.randn(2, 5, 3) - actual = _decoder_singular_values(decoder, values, sample_count=2) - expected = [] - for value in values: - jacobian = torch.func.jacrev( - lambda item: decoder(item.unsqueeze(0)).squeeze(0) - )(value) - matrix = jacobian.float().reshape(jacobian.numel() // value.numel(), -1) - expected.append(torch.linalg.svdvals(matrix)) - torch.testing.assert_close(actual, torch.stack(expected)) - - -def test_forward_math_decoder_jacobian_matches_reverse_for_transformer(): - torch.manual_seed(31) - decoder = UniteActionDecoder( - latent_dim=4, - action_dim=2, - num_latent_tokens=2, - action_horizon=3, - hidden_dim=24, - depth=2, - num_heads=4, - dropout=0.0, - gradient_checkpointing=False, - ).eval() - values = torch.randn(2, 2, 4) - forward = _decoder_singular_values( - decoder, values, sample_count=2, jacobian_method="forward_math_chunk4" - ) - reverse = _decoder_singular_values( - decoder, values, sample_count=2, jacobian_method="reverse_chunked" - ) - torch.testing.assert_close(forward, reverse, rtol=1e-4, atol=1e-5) - - -def _endpoint_decoder(): - return UniteActionDecoder( - latent_dim=4, - action_dim=2, - num_latent_tokens=2, - action_horizon=3, - hidden_dim=24, - depth=2, - num_heads=4, - dropout=0.0, - gradient_checkpointing=False, - ).eval() - - -def test_exact_endpoint_reuse_preserves_spectra_state_and_rng(monkeypatch): - import egomimic.eval.action_flow_diagnostic_forward as owner - - torch.manual_seed(51) - decoder = _endpoint_decoder() - clean, noise = torch.randn(2, 2, 4), torch.randn(2, 2, 4) - levels = (0.0, 0.25, 0.5, 0.75, 1.0) - fixed = torch.stack([(1 - t) * clean + t * noise for t in levels]) - method = "forward_math_chunk4" - clean_s = _decoder_singular_values(decoder, clean, 2, jacobian_method=method) - noise_s = _decoder_singular_values(decoder, noise, 2, jacobian_method=method) - expected = torch.stack( - [ - _decoder_singular_values(decoder, value, 2, jacobian_method=method) - for value in fixed - ] - ) - weights = {k: v.clone() for k, v in decoder.state_dict().items()} - flags = [p.requires_grad for p in decoder.parameters()] - rng = torch.get_rng_state().clone() - calls = [] - - def counted(*args, **kwargs): - calls.append(True) - return _decoder_singular_values(*args, **kwargs) - - monkeypatch.setattr(owner, "_decoder_singular_values", counted) - actual = _fixed_decoder_singular_values( - decoder, - fixed, - clean, - noise, - clean_s, - noise_s, - 2, - jacobian_method=method, - ) - assert len(calls) == 3 - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - assert torch.equal(rng, torch.get_rng_state()) - assert flags == [p.requires_grad for p in decoder.parameters()] - assert all(torch.equal(v, weights[k]) for k, v in decoder.state_dict().items()) - assert all(p.grad is None for p in decoder.parameters()) - - -@pytest.mark.parametrize( - "unsupported", ["training", "child_training", "hook", "subclass", "other"] -) -def test_endpoint_reuse_keeps_legacy_calls_for_unproven_decoders( - monkeypatch, unsupported -): - import egomimic.eval.action_flow_diagnostic_forward as owner - - decoder = _endpoint_decoder() - if unsupported == "training": - decoder.train() - elif unsupported == "child_training": - next(iter(decoder.children())).train() - elif unsupported == "hook": - decoder.register_forward_hook(lambda module, inputs, output: output) - elif unsupported == "subclass": - - class CustomDecoder(UniteActionDecoder): - pass - - decoder.__class__ = CustomDecoder - else: - decoder = nn.Identity().eval() - clean, noise = torch.ones(2, 2, 4), torch.zeros(2, 2, 4) - fixed = torch.stack([clean, 0.5 * clean, noise]) - calls = [] - - def counted(_decoder, _value, count, **kwargs): - calls.append(True) - return torch.ones(count, 2) - - monkeypatch.setattr(owner, "_decoder_singular_values", counted) - _fixed_decoder_singular_values( - decoder, - fixed, - clean, - noise, - torch.ones(2, 2), - torch.ones(2, 2), - 2, - jacobian_method="forward_math_chunk4", - ) - assert len(calls) == 3 - - -def test_endpoint_reuse_requires_exact_selected_input_bytes(monkeypatch): - import egomimic.eval.action_flow_diagnostic_forward as owner - - decoder = _endpoint_decoder() - clean, noise = torch.ones(2, 2, 4), torch.zeros(2, 2, 4) - changed = clean.clone() - changed[0, 0, 0] = torch.nextafter(changed[0, 0, 0], torch.tensor(2.0)) - negative_zero = noise.clone() - negative_zero[0, 0, 0] = -0.0 - unsampled_change = clean.clone() - unsampled_change[1] += 1 - calls = [] - - def counted(_decoder, _value, count, **kwargs): - calls.append(True) - return torch.ones(count, 2) - - monkeypatch.setattr(owner, "_decoder_singular_values", counted) - _fixed_decoder_singular_values( - decoder, - torch.stack([changed, negative_zero, unsampled_change]), - clean, - noise, - torch.ones(1, 2), - torch.ones(1, 2), - 1, - jacobian_method="forward_math_chunk4", - ) - assert len(calls) == 2 # Only the unchanged sampled row can reuse its spectrum. - - -@pytest.fixture(autouse=True) -def _isolate_scheduler_execution_identity(monkeypatch): - """Keep artifact-path tests independent of the scheduler running pytest.""" - monkeypatch.delenv("SLURM_JOB_ID", raising=False) - monkeypatch.delenv("SLURM_RESTART_COUNT", raising=False) - - -class _IdentityNormalizer: - @staticmethod - def unnormalize(values, _selector): - return values - - -def _seed_bank(tmp_path, seeds=(101, 202, 303, 404)): - path = tmp_path / "diagnostic-seeds.json" - payload = json.dumps({"seeds": list(seeds)}, sort_keys=True).encode() - path.write_bytes(payload) - return path, hashlib.sha256(payload).hexdigest() - - -def _config(tmp_path, **overrides): - path, digest = _seed_bank(tmp_path) - config = { - "noise_seed_bank_path": str(path), - "noise_seed_bank_sha256": digest, - "raw_noise_levels": [0.0, 0.5, 1.0], - "max_batches_per_rank": 1, - "max_samples": None, - "jacobian_samples": 2, - "capture_activations": True, - "activation_layer_map": {0: 0, 1: 2}, - "cknna_k": 2, - "artifact_root": str(tmp_path / "action-flow-artifacts"), - "validation_view": { - "definition": "first_fixed_validation_batch_per_rank", - "per_rank_batch_size": 6, - "world_size": 1, - "split_manifest_sha256": "split-sha", - }, - "provenance": { - "source_commit": "0123456789abcdef", - "resolved_config_sha256": "config-sha", - "normalization_sha256": "normalization-sha", - "sampler": "reverse_euler", - "sampler_steps": 2, - }, - } - config.update(overrides) - return config - - -def _decode(latent, action_dim=2): - if action_dim == 4: - return torch.cat( - ( - latent[..., :2], - torch.cos(latent[..., 2:3]), - torch.sin(latent[..., 2:3]), - ), - dim=-1, - ) - return latent[..., :action_dim] - - -def _diagnostic( - seed=101, - *, - nonfinite=False, - include_activations=True, - action_dim=2, - latent_tokens=2, - action_horizon=None, - zero_clean=False, -): - batch_size, horizon, latent_dim = 6, latent_tokens, 3 - action_horizon = horizon if action_horizon is None else int(action_horizon) - if action_horizon % horizon: - raise ValueError("test action_horizon must be a multiple of latent_tokens") - - def decode_action(value): - decoded = _decode(value, action_dim) - return decoded.repeat_interleave(action_horizon // horizon, dim=-2) - - levels = torch.tensor([0.0, 0.5, 1.0]) - clean = ( - torch.arange(batch_size * horizon * latent_dim, dtype=torch.float32) - .reshape(batch_size, horizon, latent_dim) - .div(10.0) - .add(0.25) - ) - if zero_clean: - clean = torch.zeros_like(clean) - noise = torch.flip(clean, dims=(0,)).neg().sub(0.5) - fixed_states = torch.stack( - [(1.0 - level) * clean + level * noise for level in levels] - ) - predicted_clean = torch.stack([clean + float(level) * 0.03 for level in levels]) - fixed_final = torch.stack([clean + float(level) * 0.01 for level in levels]) - generated = fixed_final[-1] - middle = 0.5 * (noise + generated) - trajectory = torch.stack((noise, middle, generated)) - target = decode_action(clean) - diagnostic = { - "schema": "action-flow-validation-diagnostics/v1", - "source": "validation/usocket", - "noise_seed": seed, - "returned_samples": batch_size, - "jacobian_samples": 2, - "integration_semantics": ( - "reverse_euler_partial_to_lower_canonical_grid_then_uniform_to_zero" - ), - "noise_levels": levels, - "num_inference_steps": 2, - "target": target, - "condition": torch.arange(batch_size * 4, dtype=torch.float32).reshape( - batch_size, 4 - ), - "latent/clean": clean, - "latent/noise": noise, - "latent/generated": generated, - "latent/fixed_states": fixed_states, - "latent/predicted_clean": predicted_clean, - "latent/fixed_final": fixed_final, - "field/predicted_velocity": noise.unsqueeze(0).repeat(3, 1, 1, 1), - "field/velocity_residual": torch.full_like(fixed_states, 0.125), - "latent/trajectory": trajectory, - "decoded/reconstruction": target, - "decoded/noise": decode_action(noise), - "decoded/generated": decode_action(generated), - "decoded/fixed_states": decode_action(fixed_states), - "decoded/predicted_clean": decode_action(predicted_clean), - "decoded/fixed_final": decode_action(fixed_final), - "decoded/trajectory": decode_action(trajectory), - "fixed_level_field_evaluations": torch.tensor([0, 1, 2]), - "decoder_jacobian/clean_singular_values": torch.tensor( - [[2.0, 1.0, 0.5, 0.25], [1.8, 0.9, 0.4, 0.2]] - ), - "decoder_jacobian/noise_singular_values": torch.tensor( - [[1.5, 0.75, 0.3, 0.1], [1.4, 0.7, 0.25, 0.08]] - ), - "decoder_jacobian/fixed_singular_values": torch.tensor( - [ - [[2.0, 1.0, 0.5, 0.25], [1.8, 0.9, 0.4, 0.2]], - [[1.7, 0.8, 0.4, 0.2], [1.6, 0.7, 0.3, 0.1]], - [[1.5, 0.7, 0.3, 0.1], [1.4, 0.6, 0.2, 0.08]], - ] - ), - } - if include_activations: - row = torch.arange(batch_size, dtype=torch.float32) - base = torch.stack((row, row.square(), row.sin(), row.cos()), dim=-1) - base = base[:, None, :].repeat(1, horizon, 1) - encoder = torch.stack((base, 2.0 * base + 0.1)) - field = torch.empty(3, 3, batch_size, horizon, 4) - for level in range(3): - field[level, 0] = encoder[0] * (1.0 + level) - field[level, 1] = base.roll(1, dims=0) - field[level, 2] = encoder[1] * (1.0 + 0.5 * level) - diagnostic.update( - { - "activation/encoder_blocks": encoder, - "activation/encoder_block_indices": torch.tensor([0, 1]), - "activation/field_blocks": field, - "activation/field_block_indices": torch.tensor([0, 1, 2]), - } - ) - if nonfinite: - diagnostic["latent/generated"] = generated.clone() - diagnostic["latent/generated"][0, 0, 0] = float("nan") - return diagnostic - - -class _DiagnosticModel: - def __init__(self, diagnostic): - self.diagnostic = diagnostic - self.calls = [] - - @staticmethod - def forward_eval(batch): - return { - source: {"pred_action": source_batch["actions"]} - for source, source_batch in batch.items() - } - - def run_diagnostic(self, capability, batch, **kwargs): - assert capability == "action_flow" - assert not torch.is_inference_mode_enabled() - self.calls.append(kwargs) - return {source: self.diagnostic for source in batch} - - -class _TwoBlockCodec(nn.Module): - def __init__(self): - super().__init__() - self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) - - def forward(self, value): - for block in self.blocks: - value = torch.tanh(block(value)) - return value - - -class _TwoBlockField(nn.Module): - def __init__(self): - super().__init__() - self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) - - def forward(self, value, time, condition, *, condition_drop_mask=None): - if condition_drop_mask is None: - raise AssertionError("diagnostics must supply an explicit drop mask") - effective = torch.where( - condition_drop_mask[:, None], torch.zeros_like(condition), condition - ) - value = value + time[:, None, None] + effective[:, None, :] - for block in self.blocks: - value = torch.tanh(block(value)) - return value - - -def _batch(target): - return { - "validation/usocket": { - "actions": target, - "embodiment": torch.full((target.shape[0],), 19), - } - } - - -def test_planar_evaluator_logs_and_hashes_action_flow_diagnostics(tmp_path): - diagnostic = _diagnostic() - config = _config(tmp_path) - evaluator = PlanarActionEval( - energy_score_enabled=False, - action_flow_diagnostics={"enabled": True, **config}, - ) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - evaluator.model = _DiagnosticModel(diagnostic) - logged = {} - evaluator.trainer = SimpleNamespace( - current_epoch=3, - global_step=41, - global_rank=0, - precision="bf16-mixed", - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.update(metrics) - ), - ) - evaluator.on_validation_start() - torch.manual_seed(8675309) - rng_state = torch.random.get_rng_state().clone() - - evaluator.on_validation_step(_batch(diagnostic["target"]), batch_idx=7) - - assert torch.equal(torch.random.get_rng_state(), rng_state) - assert evaluator.model.calls == [ - { - "raw_noise_levels": [0.0, 0.5, 1.0], - "noise_seed": 101, - "max_samples": None, - "jacobian_samples": 2, - "capture_activations": True, - } - ] - required_metrics = ( - "Valid/ActionFlow/Latent/clean/RMS", - "Valid/ActionFlow/Latent/generated/EffectiveRank", - "Valid/ActionFlow/Latent/clean/CovarianceEigenvalue/eig_00", - "Valid/ActionFlow/DecoderJacobian/clean/spectral_norm_mean", - "Valid/ActionFlow/DecodedFullNoise/token_radius_mean", - "Valid/ActionFlow/Compute/InferenceFieldEvaluations", - "Valid/ActionFlow/DenoisingTrajectory/LatentMSE/t1000", - "Valid/ActionFlow/DenoisingTrajectory/DecodedMSE/t0000", - "Valid/ActionFlow/FixedLevel/final_latent_mse/t0500", - "Valid/ActionFlow/Alignment/FinalLatentCosine/t1000", - "Valid/ActionFlow/Alignment/CKA/encoder_00__field_00/t0500", - "Valid/ActionFlow/Alignment/CKNNA/encoder_01__field_02/t1000", - ) - for name in required_metrics: - assert name in logged - assert logged[name].ndim == 0 - assert bool(torch.isfinite(logged[name])) - assert f"{name}/pushshapes_sim_u_socket" in logged - assert float( - logged["Valid/ActionFlow/Alignment/CKA/encoder_00__field_00/t0500"] - ) == pytest.approx(1.0, abs=1.0e-5) - - artifact = tmp_path / "action-flow-artifacts/epoch-3-step-41/rank-0-batch-7.pt" - sidecar = artifact.with_name(f"{artifact.name}.sha256") - assert artifact.is_file() and sidecar.is_file() - sidecar_payload = json.loads(sidecar.read_text()) - assert ( - sidecar_payload["sha256"] == hashlib.sha256(artifact.read_bytes()).hexdigest() - ) - payload = torch.load(artifact, map_location="cpu", weights_only=False) - assert payload["metric"] == "ActionFlowValidationDiagnostics" - assert ( - payload["identity_sha256"] == evaluator._action_flow_diagnostics.identity_sha256 - ) - assert payload["noise_seed"] == 101 - assert payload["sampler"]["name"] == "reverse_euler" - assert payload["statistics"]["trajectory_error"] == ( - "paired_diagnostic_not_distributional_score" - ) - source = payload["sources"]["pushshapes_sim_u_socket"] - assert source["computed"]["final_latent_cosine_by_condition"].shape == (3, 6) - assert source["computed"]["latent_statistics"]["clean"][ - "covariance_eigenvalues" - ].shape == (3,) - - evaluator.on_validation_start() - with pytest.raises(FileExistsError, match="refusing to overwrite"): - evaluator.on_validation_step(_batch(diagnostic["target"]), batch_idx=7) - - -def test_action_flow_diagnostics_allow_latent_tokens_to_differ_from_action_horizon( - tmp_path, -): - diagnostic = _diagnostic(latent_tokens=2, action_horizon=4) - config = _config(tmp_path) - config["provenance"]["latent_shape"] = [2, 3] - evaluator = PlanarActionEval( - energy_score_enabled=False, - action_flow_diagnostics={"enabled": True, **config}, - ) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - evaluator.model = _DiagnosticModel(diagnostic) - logged = {} - evaluator.trainer = SimpleNamespace( - current_epoch=0, - global_step=1, - global_rank=0, - precision="bf16-mixed", - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.update(metrics) - ), - ) - evaluator.on_validation_start() - - evaluator.on_validation_step(_batch(diagnostic["target"]), batch_idx=0) - - assert "Valid/ActionFlow/CleanReconstructionMSE" in logged - artifact = tmp_path / "action-flow-artifacts/epoch-0-step-1/rank-0-batch-0.pt" - payload = torch.load(artifact, map_location="cpu", weights_only=False) - saved = payload["sources"]["pushshapes_sim_u_socket"]["diagnostic"] - assert saved["latent/clean"].shape == (6, 2, 3) - assert saved["target"].shape == (6, 4, 2) - - -def test_action_flow_diagnostics_reject_undefined_zero_target_cosine(tmp_path): - diagnostic = _diagnostic(zero_clean=True) - runner = ActionFlowDiagnostics(_config(tmp_path)) - - with pytest.raises(ValueError, match="zero clean-latent norm"): - runner.run( - model=_DiagnosticModel(diagnostic), - batch=_batch(diagnostic["target"]), - batch_idx=0, - rank=0, - epoch=0, - global_step=1, - precision="bf16-mixed", - source_labels={"validation/usocket": "usocket"}, - ) - - -def test_action_flow_artifacts_preserve_slurm_attempts_and_same_attempt_refusal( - tmp_path, monkeypatch -): - diagnostic = _diagnostic() - legacy = tmp_path / "action-flow-artifacts/epoch-3-step-41/rank-0-batch-7.pt" - legacy.parent.mkdir(parents=True) - legacy.write_bytes(b"historical diagnostic") - for job_id, restart in (("5714540", 0), ("5714540", 1), ("5715500", 0)): - monkeypatch.setenv("SLURM_JOB_ID", job_id) - monkeypatch.setenv("SLURM_RESTART_COUNT", str(restart)) - evaluator = PlanarActionEval( - energy_score_enabled=False, - action_flow_diagnostics={"enabled": True, **_config(tmp_path)}, - ) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - evaluator.model = _DiagnosticModel(diagnostic) - evaluator.trainer = SimpleNamespace( - current_epoch=3, - global_step=41, - global_rank=0, - precision="32-true", - lightning_module=SimpleNamespace(log_dict=lambda *_args, **_kwargs: None), - ) - evaluator.on_validation_start() - evaluator.on_validation_step(_batch(diagnostic["target"]), batch_idx=7) - artifact = ( - legacy.parent.parent - / f"job-{job_id}-restart-{restart}" - / legacy.parent.name - / legacy.name - ) - payload = torch.load(artifact, map_location="cpu", weights_only=False) - assert payload["execution"] == { - "slurm_job_id": job_id, - "slurm_restart_count": restart, - } - assert ( - payload["identity_sha256"] - == evaluator._action_flow_diagnostics.identity_sha256 - ) - sidecar = json.loads(Path(f"{artifact}.sha256").read_text()) - assert sidecar["sha256"] == hashlib.sha256(artifact.read_bytes()).hexdigest() - evaluator.on_validation_start() - with pytest.raises(FileExistsError, match="refusing to overwrite"): - evaluator.on_validation_step(_batch(diagnostic["target"]), batch_idx=7) - assert legacy.read_bytes() == b"historical diagnostic" - - -def test_planar_action_flow_diagnostics_emit_native_circular_errors(tmp_path): - diagnostic = _diagnostic(action_dim=4) - config = _config(tmp_path, native_error=USOCKET_NATIVE_ERROR_CONFIG) - evaluator = PlanarActionEval( - energy_score_enabled=False, - native_decoder=USocketRotVecNativeDecoder(), - action_flow_diagnostics={"enabled": True, **config}, - ) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - evaluator.model = _DiagnosticModel(diagnostic) - logged = {} - evaluator.trainer = SimpleNamespace( - current_epoch=0, - global_step=2, - global_rank=0, - precision="bf16-mixed", - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.update(metrics) - ), - ) - evaluator.on_validation_start() - - evaluator.on_validation_step(_batch(diagnostic["target"]), batch_idx=0) - - required = ( - "Valid/ActionFlow/CleanReconstructionNativeMSE", - "Valid/ActionFlow/DenoisingTrajectory/DecodedNativeMSE/t1000", - "Valid/ActionFlow/DenoisingTrajectory/DecodedNativeMSE/t0500", - "Valid/ActionFlow/DenoisingTrajectory/DecodedNativeMSE/t0000", - "Valid/ActionFlow/FixedLevel/final_decoded_native_mse/t0500", - ) - for name in required: - assert name in logged - assert f"{name}/pushshapes_sim_u_socket" in logged - assert bool(torch.isfinite(logged[name])) - assert float(logged["Valid/ActionFlow/CleanReconstructionNativeMSE"]) == 0.0 - - artifact = tmp_path / "action-flow-artifacts/epoch-0-step-2/rank-0-batch-0.pt" - payload = torch.load(artifact, map_location="cpu", weights_only=False) - assert payload["identity"]["native_error"] == USOCKET_NATIVE_ERROR_CONFIG - assert payload["statistics"]["native_action_error"] == (USOCKET_NATIVE_ERROR_CONFIG) - computed = payload["sources"]["pushshapes_sim_u_socket"]["computed"] - assert computed["clean_reconstruction_native_mse_by_condition"].shape == (6,) - assert computed["trajectory_decoded_native_mse_by_condition"].shape == (3, 6) - - -def test_native_action_flow_error_wraps_theta_at_pi_boundary(): - target_theta = torch.full((2, 3), torch.pi - 0.01) - predicted_theta = torch.full((2, 3), -torch.pi + 0.01) - - def rotvec(theta): - return torch.stack( - ( - torch.zeros_like(theta), - torch.zeros_like(theta), - torch.cos(theta), - torch.sin(theta), - ), - dim=-1, - ) - - decoder = USocketRotVecNativeDecoder() - values = PlanarActionEval._native_mse_by_condition( - decoder.decode(rotvec(predicted_theta)), - decoder.decode(rotvec(target_theta)), - decoder, - ) - - expected = torch.full((2,), (0.02**2) / 3.0) - torch.testing.assert_close(values, expected, atol=1.0e-7, rtol=0.0) - - -def test_action_flow_consumer_matches_real_wrapper_schema(tmp_path): - torch.manual_seed(91) - wrapper = ModelWrapper( - pipeline=PipelineAlgo( - stages=[ - ContentEncoderStage(_TwoBlockCodec()), - ConditionalVelocityStage(_TwoBlockField(), num_inference_steps=2), - ContentDecoderStage(_TwoBlockCodec()), - ], - device="cpu", - ), - training_behavior=ActionFlowTrainingBehavior(gradient_telemetry_cadence=0), - diagnostic_provider=ActionFlowDiagnosticProvider(), - ) - wrapper.eval() - runner = ActionFlowDiagnostics(_config(tmp_path, activation_layer_map={0: 0, 1: 1})) - batch = { - "opaque_source": { - "target": torch.randn(6, 2, 2) + 0.25, - "condition": torch.randn(6, 2), - } - } - - metrics = runner.run( - model=wrapper, - batch=batch, - batch_idx=0, - rank=0, - epoch=0, - global_step=0, - precision="32-true", - source_labels={"opaque_source": "opaque"}, - ) - - assert "Valid/ActionFlow/Latent/clean/RMS" in metrics - assert "Valid/ActionFlow/Alignment/CKA/encoder_01__field_01/t1000" in metrics - assert all(bool(torch.isfinite(value)) for value in metrics.values()) - - -def test_action_flow_diagnostic_forward_selects_private_routed_codecs(): - torch.manual_seed(92) - model = PipelineAlgo( - stages=[ - RoutedContentEncoderStage( - encoders={"route_a": _TwoBlockCodec(), "route_b": _TwoBlockCodec()}, - route_key="route", - ), - ConditionalVelocityStage(_TwoBlockField(), num_inference_steps=2), - RoutedContentDecoderStage( - decoders={"route_a": _TwoBlockCodec(), "route_b": _TwoBlockCodec()}, - route_key="route", - ), - ], - device="cpu", - ) - batch = { - route: { - "target": torch.randn(3, 2, 2), - "condition": torch.randn(3, 2), - "route": route, - } - for route in ("route_a", "route_b") - } - - diagnostics = collect_action_flow_diagnostics( - model, - batch, - raw_noise_levels=(0.0, 1.0), - noise_seed=101, - max_samples=3, - jacobian_samples=1, - capture_activations=True, - already_processed=True, - ) - - assert tuple(diagnostics) == ("route_a", "route_b") - for diagnostic in diagnostics.values(): - assert diagnostic["provenance/encoder_class"] == "_TwoBlockCodec" - assert diagnostic["provenance/decoder_class"] == "_TwoBlockCodec" - assert diagnostic["activation/encoder_blocks"].shape[:2] == (2, 3) - assert diagnostic["decoded/generated"].shape == (3, 2, 2) - - -@pytest.mark.parametrize( - "experiment", - ( - "action_flow_bc_usocket_recon1_s42", - "action_flow_bc_usocket_recon10_s42", - ), -) -def test_real_action_flow_config_constructs_strict_diagnostics(tmp_path, experiment): - with initialize_config_dir( - version_base=None, config_dir=str(_CONFIG_DIR.resolve()) - ): - config = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=pusht/{experiment}", "++paths.root_dir=."], - ) - diagnostics = config.evaluator.action_flow_diagnostics - diagnostics.noise_seed_bank_path = str( - _CONFIG_DIR / "evaluator/energy_score_seed_bank_k32_v1.json" - ) - diagnostics.artifact_root = str(tmp_path / experiment) - diagnostics = OmegaConf.to_container(diagnostics, resolve=True) - - evaluator = PlanarActionEval( - energy_score_enabled=False, - action_flow_diagnostics=diagnostics, - ) - - runner = evaluator._action_flow_diagnostics - assert evaluator.action_flow_diagnostics_enabled - assert runner.noise_levels == [0.0, 0.25, 0.5, 0.75, 1.0] - assert runner.max_samples == 16 - assert runner.jacobian_samples == 2 - assert runner.activation_layer_map == ((0, 0), (1, 11)) - assert runner.cknna_k == 10 - - -def test_action_flow_diagnostics_fail_closed_without_requested_activations(tmp_path): - runner = ActionFlowDiagnostics(_config(tmp_path)) - diagnostic = _diagnostic(include_activations=False) - model = _DiagnosticModel(diagnostic) - batch = _batch(diagnostic["target"]) - - with pytest.raises(TypeError, match="activation/encoder_blocks"): - runner.run( - model=model, - batch=batch, - batch_idx=0, - rank=0, - epoch=0, - global_step=0, - precision="32-true", - source_labels={"validation/usocket": "usocket"}, - ) - assert not list((tmp_path / "action-flow-artifacts").rglob("*.pt")) - - -def test_action_flow_diagnostics_reject_nonfinite_model_outputs(tmp_path): - runner = ActionFlowDiagnostics(_config(tmp_path)) - diagnostic = _diagnostic(nonfinite=True) - - with pytest.raises(ValueError, match="non-finite.*latent/generated"): - runner.run( - model=_DiagnosticModel(diagnostic), - batch=_batch(diagnostic["target"]), - batch_idx=0, - rank=0, - epoch=0, - global_step=0, - precision=None, - source_labels={"validation/usocket": "usocket"}, - ) - - -@pytest.mark.parametrize( - ("overrides", "match"), - ( - ({"jacobian_samples": 0}, "jacobian_samples must be positive"), - ({"max_samples": 2, "jacobian_samples": 3}, "cannot exceed max_samples"), - ({"capture_activations": True, "activation_layer_map": {}}, "non-empty"), - ({"cknna_k": 6}, "k < diagnostic batch size"), - ({"raw_noise_levels": [0.5, 0.5]}, "strictly increase"), - ), -) -def test_action_flow_diagnostic_config_fails_closed(tmp_path, overrides, match): - with pytest.raises(ValueError, match=match): - ActionFlowDiagnostics(_config(tmp_path, **overrides)) - - -@pytest.mark.parametrize("restart", [None, 0, 1]) -def test_existing_latent_diagnostic_native_conversion_uses_decoder_argument( - tmp_path, - monkeypatch, - restart, -): - monkeypatch.delenv("SLURM_JOB_ID", raising=False) - if restart is not None: - monkeypatch.setenv("SLURM_JOB_ID", "5714540") - monkeypatch.setenv("SLURM_RESTART_COUNT", str(restart)) - evaluator = PlanarActionEval(energy_score_enabled=False) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - evaluator.unite_diagnostics = {"artifact_root": str(tmp_path / "legacy-diagnostic")} - evaluator._unite_noise_seeds = [101] - evaluator._unite_noise_seed_sha256 = "seed-sha" - evaluator._unite_noise_levels = [0.0, 1.0] - evaluator._unite_noise_labels = ["t000", "t1000"] - evaluator._unite_cknna_k = 2 - target = _diagnostic()["target"] - clean = torch.cat((target, torch.ones_like(target[..., :1])), dim=-1) - feature = torch.arange(1, 7, dtype=torch.float32)[:, None, None].repeat(1, 2, 3) - evaluator.model = SimpleNamespace( - run_diagnostic=lambda capability, _batch, raw_noise_levels: { - "validation/usocket": { - "clean_latent": clean, - "sampler_latents": torch.stack((clean + 1.0, clean)), - "decoded_actions_normalized": torch.stack((target + 1.0, target)), - "noise_level_final_predictions": torch.stack((clean, clean + 0.1)), - "tokenization_activations": {"layer": feature}, - "denoising_activations": { - "layer": torch.stack((feature, feature * 2.0)) - }, - } - } - if capability == "unite" - else None - ) - evaluator.trainer = SimpleNamespace( - current_epoch=1, - global_step=2, - global_rank=0, - ) - batch = _batch(target) - - metrics = evaluator._unite_metrics_and_artifact(batch, batch_idx=0) - - assert "Valid/DenoisingTrajectory/DecodedNativeMSE/step_0" in metrics - root = tmp_path / "legacy-diagnostic" - if restart is not None: - root = root / f"job-5714540-restart-{restart}" - artifact = root / "epoch-1-step-2/rank-0-batch-0.pt" - assert artifact.is_file() - payload = torch.load(artifact, map_location="cpu", weights_only=False) - assert payload["execution"] == ( - None - if restart is None - else {"slurm_job_id": "5714540", "slurm_restart_count": restart} - ) - with pytest.raises(FileExistsError, match="refusing to overwrite"): - evaluator._unite_metrics_and_artifact(batch, batch_idx=0) diff --git a/tests/test_action_flow_diffusion.py b/tests/test_action_flow_diffusion.py deleted file mode 100644 index 79e2bf379e..0000000000 --- a/tests/test_action_flow_diffusion.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Latent epsilon/DDIM contract, sampling identity and coupled gradient regressions.""" - -from types import SimpleNamespace - -import pytest -import torch -from diffusers.schedulers.scheduling_ddim import DDIMScheduler -from torch import nn - -from egomimic.pipeline.stages_action_flow import ( - ConditionalEpsilonDDIMStage, - LatentDiffusionBridgeStage, - LatentDiffusionSchedule, -) - - -class Field(nn.Module): - def __init__(self): - super().__init__() - self.weight = nn.Parameter(torch.tensor(0.1)) - self.calls = 0 - - def forward(self, state, time, condition, condition_drop_mask): - self.calls += 1 - return self.weight * state + condition[:, :1].reshape(-1, 1, 1) - - -def batch(): - return { - "action_flow/clean_latent": torch.randn(2, 3, 4, requires_grad=True), - "sampler/noise": torch.randn(2, 3, 4), - "condition": torch.randn(2, 5), - } - - -def bridge(): - return LatentDiffusionBridgeStage( - samples_per_content=2, - independent_noise_per_sample=True, - condition_dropout_probability=0.1, - ) - - -def test_corruption_matches_exact_dp_scheduler(): - schedule = LatentDiffusionSchedule() - dp = DDIMScheduler( - num_train_timesteps=100, - beta_schedule="squaredcos_cap_v2", - clip_sample=True, - set_alpha_to_one=True, - steps_offset=0, - prediction_type="epsilon", - ) - assert torch.equal(schedule.scheduler.alphas_cumprod, dp.alphas_cumprod) - t = torch.arange(1, 101).float() / 100 - a, s, _, _ = schedule.coefficients(t, 2) - torch.testing.assert_close(a[:, 0], dp.alphas_cumprod.sqrt(), atol=1e-6, rtol=1e-5) - torch.testing.assert_close( - s[:, 0], (1 - dp.alphas_cumprod).sqrt(), atol=1e-6, rtol=1e-5 - ) - assert schedule.scheduler.config.clip_sample is False - - -def test_noise_is_independent_and_corruption_attached(): - b = batch() - stage = bridge() - stage(b) - clean = b["action_flow/clean_latent"].index_select(0, b["action_flow/base_index"]) - noise = b["action_flow/diffusion_target_epsilon"] - a, s, da, ds = stage.diffusion.coefficients(b["action_flow/time"], 3) - torch.testing.assert_close(b["action_flow/state"], a * clean + s * noise) - torch.testing.assert_close( - b["action_flow/target_velocity"], da * clean + ds * noise - ) - assert not noise.requires_grad and b["action_flow/state"].requires_grad - assert not torch.equal(noise[0], noise[1]) - - -def test_epsilon_detaches_only_regression_clean_route(): - torch.manual_seed(7) - b = batch() - bridge()(b) - field = Field() - stage = ConditionalEpsilonDDIMStage( - field=field, - flow_clean_gradient_mode="all_stopgrad", - cfg_scale=1, - num_inference_steps=10, - ) - stage.execute(b, mode="train") - loss = b["action_flow/fm_velocity_residual"].square().mean() - grad = torch.autograd.grad( - loss, - (field.weight, b["action_flow/clean_latent"]), - allow_unused=True, - retain_graph=True, - ) - assert grad[0].isfinite() and grad[0].abs() > 0 and grad[1] is None - # A nonlinear decoder's JVP must train both codec and shared denoiser. - decoder_scale = torch.tensor(0.7, requires_grad=True) - state, residual = b["action_flow/state"], b["action_flow/velocity_residual"] - jvp_residual = 2 * decoder_scale * state * residual - gradients = torch.autograd.grad( - jvp_residual.square().mean(), - (field.weight, b["action_flow/clean_latent"], decoder_scale), - ) - assert all(g.isfinite().all() and g.abs().sum() > 0 for g in gradients) - - -def test_ddim10_cfg1_uses_ten_conditional_calls_and_native_steps(): - field = Field() - stage = ConditionalEpsilonDDIMStage( - field=field, cfg_scale=1, num_inference_steps=10 - ) - b = batch() - stage.execute(b, mode="inference") - dp = DDIMScheduler( - num_train_timesteps=100, beta_schedule="squaredcos_cap_v2", clip_sample=False - ) - dp.set_timesteps(10) - state = b["sampler/noise"].float() - condition = b["condition"] - for i in dp.timesteps: - epsilon = field.weight * state + condition[:, :1].reshape(-1, 1, 1) - state = dp.step(epsilon, i, state, eta=0).prev_sample - assert field.calls == 10 - torch.testing.assert_close(b["action_flow/generated_latent"], state) - assert torch.equal(stage.diffusion.scheduler.timesteps, dp.timesteps) - assert b["action_flow/trajectory"].shape[0] == 11 - - -def test_oracle_epsilon_ddim_recovers_clean_latent(): - clean = torch.randn(2, 3, 4) - schedule = LatentDiffusionSchedule() - - class Oracle(nn.Module): - def forward(self, state, time, condition, condition_drop_mask): - a, s, _, _ = schedule.coefficients(time, state.ndim) - return (state - a * clean) / s - - stage = ConditionalEpsilonDDIMStage( - field=Oracle(), cfg_scale=1, num_inference_steps=10 - ) - b = {"sampler/noise": torch.randn_like(clean), "condition": torch.zeros(2, 5)} - stage.execute(b, mode="inference") - torch.testing.assert_close( - b["action_flow/generated_latent"], clean, atol=2e-4, rtol=2e-4 - ) - - -def test_dit_half_accepts_exact_diffusion_pair_but_rejects_mixed_topology(): - from egomimic.utils.libero_dit_half import resolve_backbones - - names = [ - "OATObservationStage", - "ActionTargetBuilder", - "GaussianLatentNoise", - "ContentEncoderStage", - "LatentDiffusionBridgeStage", - "ConditionalEpsilonDDIMStage", - "ContentDecoderStage", - "ActionFlowObjectiveStage", - ] - stages = [type(name, (), {})() for name in names] - encoder, field = object(), object() - stages[3].encoder = SimpleNamespace(backbone=encoder) - stages[5].field = SimpleNamespace(backbone=field) - module = SimpleNamespace( - model=SimpleNamespace(pipeline=SimpleNamespace(stages=stages)) - ) - assert resolve_backbones(module) == {"encoder": encoder, "velocity": field} - stages[4] = type("LatentBridgeStage", (), {})() - with pytest.raises(ValueError, match="topology"): - resolve_backbones(module) - - -@pytest.mark.parametrize("embodied", [False, True]) -def test_dit_half_integration_retains_velocity_topologies(embodied): - from egomimic.utils.libero_dit_half import resolve_backbones - - names = [ - "EmbodimentOATObservationStage" if embodied else "OATObservationStage", - "EmbodimentActionTargetBuilder" if embodied else "ActionTargetBuilder", - "GaussianLatentNoise", - "ContentEncoderStage", - "LatentBridgeStage", - "ConditionalVelocityStage", - "ContentDecoderStage", - "ActionFlowObjectiveStage", - ] - stages = [type(name, (), {})() for name in names] - encoder, field = object(), object() - stages[3].encoder = SimpleNamespace(backbone=encoder) - stages[5].field = SimpleNamespace(backbone=field) - module = SimpleNamespace( - model=SimpleNamespace(pipeline=SimpleNamespace(stages=stages)) - ) - assert resolve_backbones(module) == {"encoder": encoder, "velocity": field} - if embodied: - stages[1] = type("ActionTargetBuilder", (), {})() - with pytest.raises(ValueError, match="topology"): - resolve_backbones(module) diff --git a/tests/test_action_flow_jacobian_checkpoint_performance.py b/tests/test_action_flow_jacobian_checkpoint_performance.py deleted file mode 100644 index 5408c6ad14..0000000000 --- a/tests/test_action_flow_jacobian_checkpoint_performance.py +++ /dev/null @@ -1,555 +0,0 @@ -"""Opt-in, read-only checkpoint equivalence/timing test for exact endpoint reuse. - -Set ACTION_FLOW_JACOBIAN_PROFILE_REQUEST to a JSON request outside the checkout. -Ordinary CPU suites skip the GPU benchmark. No optimizer or policy sampler runs. -""" - -from __future__ import annotations - -import copy -import gc -import hashlib -import json -import os -import statistics -import time -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -import numpy as np -import pytest -import torch -from hydra.utils import instantiate -from omegaconf import OmegaConf - -import egomimic.eval.action_flow_diagnostic_forward as owner - - -def prepare_training_profile(request): - """Restore native data/model state on CPU; capture one real training batch.""" - from torch.utils.data import default_collate - - from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset - - assert _digest(request["split"]) == request["split_sha256"] - split = json.loads(Path(request["split"]).read_text()) - for domain in ("yam", "human"): - for part in ("train", "valid"): - os.environ[f"{domain.upper()}_{part.upper()}_IDS_SHA256"] = split[domain][ - "summary" - ][f"{part}_ids_sha256"] - assert _digest(request["checkpoint"]) == request["checkpoint_sha256"] - payload = torch.load( - request["checkpoint"], map_location="cpu", mmap=True, weights_only=False - ) - assert payload["global_step"] == request["checkpoint_step"] - cfg = OmegaConf.load(request["config"]) - assert payload["hyper_parameters"]["config_tree"][ - "model" - ] == OmegaConf.to_container(cfg.model, resolve=True) - assert [int(cfg.model.num_latent_tokens), int(cfg.model.latent_dim)] == request[ - "latent_shape" - ] - assert ( - int(cfg.data.train_dataloader_params[request["source_key"]].batch_size) - == request["batch_size"] - ) - state = copy.deepcopy(payload["data_context"]["normalizer_state"]) - for field in ("key_types", "zarr_keys", "shapes", "norm_stats"): - state[field] = {int(k): v for k, v in state[field].items()} - normalizer = MultiDataset.from_state(state) - fixture_path = Path(request["fixture"]) - if fixture_path.exists(): - if request.get("fixture_sha256"): - assert _digest(fixture_path) == request["fixture_sha256"] - fixture = torch.load(fixture_path, weights_only=False) - assert fixture["checkpoint"] == request.get( - "fixture_checkpoint", request["checkpoint"] - ) - assert fixture["source_key"] == request["source_key"] - else: - dataset = instantiate(cfg.data.train_datasets[request["source_key"]]) - dataset.set_norm_stats_from(normalizer) - indices = np.linspace( - 0, len(dataset) - 1, request["batch_size"], dtype=int - ).tolist() - flat = default_collate([dataset[index] for index in indices]) - fixture = { - "checkpoint": request["checkpoint"], - "source_key": request["source_key"], - "indices": indices, - "batch": flat, - } - torch.save(fixture, fixture_path) - algo = instantiate(cfg.model.pipeline, device="cpu") - algo.bind_data_context(normalizer=normalizer) - weights = { - k.removeprefix("nets."): v - for k, v in payload["state_dict"].items() - if k.startswith("nets.") - } - algo.nets.load_state_dict(weights, strict=True) - return cfg, payload, algo, fixture - - -def _cpu_tree(value): - if torch.is_tensor(value): - return value.detach().cpu().clone() - if isinstance(value, dict): - return {k: _cpu_tree(v) for k, v in value.items()} - if isinstance(value, (tuple, list)): - return type(value)(_cpu_tree(v) for v in value) - return copy.deepcopy(value) - - -def _compare_training_tree(actual, expected): - """Compare every tensor; retain exact mismatch counts and largest errors.""" - mismatches, tensor_count = [], 0 - - def visit(a, b, path): - nonlocal tensor_count - if torch.is_tensor(a): - tensor_count += 1 - if not (torch.is_tensor(b) and a.shape == b.shape and a.dtype == b.dtype): - mismatches.append( - {"path": path, "max_abs": 0.0, "contract_changed": True} - ) - return - if not torch.equal(a, b): - diff = (a.double() - b.double()).abs() - mismatches.append( - { - "path": path, - "max_abs": float(diff.max()), - "different_elements": int(torch.count_nonzero(diff)), - "relative_rms": float( - torch.linalg.vector_norm(diff) - / torch.linalg.vector_norm(b.double()).clamp_min(1e-30) - ), - } - ) - elif isinstance(a, dict): - assert a.keys() == b.keys(), path - for k in a: - visit(a[k], b[k], path + "/" + str(k)) - elif isinstance(a, (tuple, list)): - assert len(a) == len(b), path - for k, (x, y) in enumerate(zip(a, b)): - visit(x, y, path + "/" + str(k)) - elif torch.is_tensor(b) or a != b: - mismatches.append( - {"path": path, "actual": str(a), "expected": str(b), "max_abs": 0.0} - ) - - visit(actual, expected, "") - return { - "bit_exact": not mismatches, - "tensor_count": tensor_count, - "mismatch_count": len(mismatches), - "largest_errors": sorted(mismatches, key=lambda x: x["max_abs"], reverse=True)[ - :12 - ], - } - - -@pytest.mark.skipif( - not os.environ.get("ACTION_FLOW_TRAINING_PROFILE_REQUEST"), - reason="Opt-in native checkpoint training test", -) -def test_checkpoint_training_execution_equivalence_and_timing(): - """Test existing execution options without changing/adopting model weights.""" - from egomimic.utils.libero_dit_half import LiberoDiTHalf - - assert torch.cuda.is_available() and torch.cuda.device_count() == 1 - request = json.loads( - Path(os.environ["ACTION_FLOW_TRAINING_PROFILE_REQUEST"]).read_text() - ) - out = Path(request["output_root"]) - out.mkdir(parents=True, exist_ok=True) - cfg, payload, initial, fixture = prepare_training_profile(request) - assert str(cfg.trainer.precision) == "bf16-mixed" - baseline, records = [], [] - initial_state = _cpu_tree(initial.nets.state_dict()) - del initial - for case_index, variant in enumerate(request["variants"]): - algo = instantiate(cfg.model.pipeline, device="cpu") - algo.nets.load_state_dict(initial_state, strict=True) - holder = torch.nn.Module() - holder.nets = algo.nets - holder.cuda().train() - algo.device = torch.device("cuda") - optimizer = instantiate( - cfg.model.optimizer, named_params=tuple(holder.named_parameters()) - )() - scheduler = instantiate(cfg.model.scheduler, optimizer=optimizer)() - optimizer.load_state_dict(copy.deepcopy(payload["optimizer_states"][0])) - scheduler.load_state_dict(copy.deepcopy(payload["lr_schedulers"][0])) - ema = instantiate(cfg.callbacks.ema) - ema.on_load_checkpoint(None, holder, payload) - ema.on_fit_start(SimpleNamespace(global_step=payload["global_step"]), holder) - policy = LiberoDiTHalf( - source_key=request["source_key"], - checkpoint_periods={ - "encoder": 2, - "velocity": { - "third_flow_recomputation": 3, - "quarter_flow_recomputation": 4, - }[variant], - } - if variant in {"third_flow_recomputation", "quarter_flow_recomputation"} - else None, - ) - policy.on_fit_start(None, SimpleNamespace(model=algo)) - encoder, field, decoder = ( - algo.pipeline.stages[3].encoder, - algo.pipeline.stages[5], - algo.pipeline.stages[6].decoder, - ) - if variant == "no_dit_recomputation": - encoder.backbone.gradient_checkpointing = False - field.field.backbone.gradient_checkpointing = False - elif variant == "no_reconstruction_recomputation": - decoder.gradient_checkpointing = False - elif variant == "shared_field_prediction": - field.fm_field_execution = "shared" - else: - assert variant in { - "baseline", - "third_flow_recomputation", - "quarter_flow_recomputation", - } - batch = algo.process_batch_for_training( - {request["source_key"]: fixture["batch"]} - ) - timings, comparisons = [], [] - failure = None - try: - for step in range(request["steps"]): - torch.manual_seed(request["seed"] + step) - torch.cuda.synchronize() - torch.cuda.reset_peak_memory_stats() - started = time.perf_counter() - optimizer.zero_grad(set_to_none=True) - with torch.autocast("cuda", dtype=torch.bfloat16): - prediction = algo.forward_training(batch) - loss = algo.compute_losses(prediction, batch)["loss"] - assert bool(torch.isfinite(loss)) - loss.backward() - torch.cuda.synchronize() - backward_done = time.perf_counter() - gradients = _cpu_tree( - {name: p.grad for name, p in holder.named_parameters()} - ) - assert all( - value is None or bool(torch.isfinite(value).all()) - for value in gradients.values() - ) - components = _cpu_tree( - { - k: v - for k, v in prediction[request["source_key"]].items() - if k.startswith("log/action_flow_") or k == "loss/action_flow" - } - ) - # Copying proof tensors is deliberately outside measured GPU work. - optimizer_start = time.perf_counter() - clip = float(cfg.trainer.get("gradient_clip_val", 0) or 0) - if clip: - assert cfg.trainer.get("gradient_clip_algorithm", "norm") == "norm" - torch.nn.utils.clip_grad_norm_(holder.parameters(), clip) - optimizer.step() - scheduler.step() - ema._update(holder) - torch.cuda.synchronize() - ended = time.perf_counter() - timings.append( - { - "step": step, - "forward_backward_seconds": backward_done - started, - "optimizer_scheduler_ema_seconds": ended - optimizer_start, - "seconds": backward_done - started + ended - optimizer_start, - "peak_cuda_bytes": torch.cuda.max_memory_allocated(), - } - ) - actual = { - "gradients": gradients, - "components": components, - "state": _cpu_tree(holder.state_dict()), - "optimizer": _cpu_tree(optimizer.state_dict()), - "scheduler": _cpu_tree(scheduler.state_dict()), - "ema": _cpu_tree(ema._shadow), - "rng_cpu": torch.get_rng_state(), - "rng_cuda": torch.cuda.get_rng_state(), - } - if case_index == 0: - baseline.append(actual) - else: - comparisons.append( - { - section: _compare_training_tree( - value, baseline[step][section] - ) - for section, value in actual.items() - } - ) - print( - "TRAINING_PROFILE_STEP", - variant, - json.dumps(timings[-1]), - flush=True, - ) - del prediction, loss, actual, gradients, components - except (torch.cuda.OutOfMemoryError, RuntimeError) as exc: - failure = {"type": type(exc).__name__, "message": str(exc)} - if case_index == 0: - raise - finally: - policy.on_fit_end(None, None) - record = { - "variant": variant, - "timings": timings, - "comparisons": comparisons, - "failure": failure, - "bit_exact": failure is None - and all(all(s["bit_exact"] for s in step.values()) for step in comparisons), - "median_seconds": statistics.median(t["seconds"] for t in timings[1:]) - if len(timings) > 1 - else None, - } - records.append(record) - (out / "TRAINING_PROGRESS.json").write_text(json.dumps(records, indent=2)) - prediction = loss = actual = gradients = components = None - del ( - prediction, - loss, - actual, - gradients, - components, - algo, - holder, - optimizer, - scheduler, - ema, - batch, - policy, - encoder, - field, - decoder, - ) - gc.collect() - torch.cuda.empty_cache() - result = { - "status": "COMPLETE", - "checkpoint_step": request["checkpoint_step"], - "batch_size": request["batch_size"], - "latent_shape": request["latent_shape"], - "precision": "bf16-mixed_native_FP32_decoder_JVP", - "torch": torch.__version__, - "gpu": torch.cuda.get_device_name(), - "records": records, - "scope": "native full pipeline forward/backward/optimizer/scheduler/EMA; frozen real training batch; excludes data-loader, telemetry and proof-copy overhead; no weights adopted", - } - (out / "TRAINING_RESULT.json").write_text(json.dumps(result, indent=2)) - - -def _digest(path): - result = hashlib.sha256() - with Path(path).open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - result.update(chunk) - return result.hexdigest() - - -def load_profile_components(request, device="cpu"): - """Strict component restoration; also used by the scheduled CPU preflight.""" - assert _digest(request["checkpoint"]) == request["checkpoint_sha256"] - cfg = OmegaConf.load(request["config"]) - state = torch.load( - request["checkpoint"], map_location="cpu", mmap=True, weights_only=False - ) - assert state["global_step"] == request["checkpoint_step"] - assert state["hyper_parameters"]["config_tree"]["model"] == OmegaConf.to_container( - cfg.model, resolve=True - ) - result = {} - for name, spec in request["components"].items(): - module = instantiate(OmegaConf.select(cfg, spec["config_key"])) - prefix = spec["state_prefix"] - weights = { - k[len(prefix) :]: v - for k, v in state["state_dict"].items() - if k.startswith(prefix) - } - assert weights, f"Checkpoint component prefix absent: {prefix}" - module.load_state_dict(weights, strict=True) - module.eval().float().to(device) - for parameter in module.parameters(): - parameter.requires_grad_(False) - result[name] = module - assert int(cfg.model.num_latent_tokens) == request["latent_shape"][0] - assert int(cfg.model.latent_dim) == request["latent_shape"][1] - assert int(cfg.model.action_horizon) == request["action_shape"][0] - assert int(cfg.model.action_dim) == request["action_shape"][1] - return result - - -@pytest.mark.skipif( - not os.environ.get("ACTION_FLOW_JACOBIAN_PROFILE_REQUEST"), - reason="Opt-in checkpoint GPU benchmark", -) -def test_checkpoint_endpoint_reuse_equivalence_and_timing(): - assert torch.cuda.is_available() and torch.cuda.device_count() == 1 - request_path = Path(os.environ["ACTION_FLOW_JACOBIAN_PROFILE_REQUEST"]) - request = json.loads(request_path.read_text()) - request_sha = _digest(request_path) - out = Path(request["output_root"]) - out.mkdir(parents=True, exist_ok=True) - torch.backends.cuda.matmul.allow_tf32 = False - torch.backends.cudnn.allow_tf32 = False - modules = load_profile_components(request, "cuda") - encoder, decoder = modules["encoder"], modules["decoder"] - assert _digest(request["targets"]) == request["targets_sha256"] - with np.load(request["targets"], allow_pickle=False) as artifact: - target = ( - torch.from_numpy(artifact["target"][request["target_indices"]].copy()) - .float() - .cuda() - ) - assert target.shape[1:] == tuple(request["action_shape"]) - torch.manual_seed(request["seed"]) - with torch.no_grad(): - clean = encoder(target) - assert clean.shape[1:] == tuple(request["latent_shape"]) - generator = torch.Generator(device=clean.device).manual_seed(request["seed"]) - noise = torch.randn( - clean.shape, device=clean.device, dtype=clean.dtype, generator=generator - ) - fixed = torch.stack( - [(1.0 - level) * clean + level * noise for level in request["noise_levels"]] - ) - count = request["jacobian_samples"] - assert count <= len(target) - method = "forward_math_chunk4" - native = owner._decoder_singular_values - parameters = {k: v.detach().cpu().clone() for k, v in decoder.state_dict().items()} - rng_cpu, rng_cuda = torch.get_rng_state(), torch.cuda.get_rng_state() - flags = [p.requires_grad for p in decoder.parameters()] - - def measure(kind): - with patch.object(owner, "_decoder_singular_values", wraps=native) as calls: - clean_s = owner._decoder_singular_values( - decoder, clean, count, jacobian_method=method - ) - noise_s = owner._decoder_singular_values( - decoder, noise, count, jacobian_method=method - ) - if kind == "baseline": - fixed_s = torch.stack( - [ - owner._decoder_singular_values( - decoder, value, count, jacobian_method=method - ) - for value in fixed - ] - ) - else: - fixed_s = owner._fixed_decoder_singular_values( - decoder, - fixed, - clean, - noise, - clean_s, - noise_s, - count, - jacobian_method=method, - ) - assert calls.call_count == ( - 2 + len(fixed) if kind == "baseline" else len(fixed) - ) - return { - "clean": clean_s.cpu(), - "noise": noise_s.cpu(), - "fixed": fixed_s.cpu(), - }, calls.call_count - - records, reference = [], None - # Alternate order to reduce first-case/allocator bias; completed cases are reusable. - order = [ - kind - for repeat in range(request["repeats"]) - for kind in ( - ("baseline", "optimized") if repeat % 2 == 0 else ("optimized", "baseline") - ) - ] - for index, kind in enumerate(order): - artifact = out / f"case-{index:02d}-{kind}.pt" - if artifact.exists(): - payload = torch.load(artifact, map_location="cpu", weights_only=False) - assert payload["request_sha256"] == request_sha - else: - torch.cuda.synchronize() - torch.cuda.reset_peak_memory_stats() - started = time.perf_counter() - spectra, calls = measure(kind) - torch.cuda.synchronize() - payload = { - "request_sha256": request_sha, - "kind": kind, - "seconds": time.perf_counter() - started, - "full_jacobians": calls * count, - "peak_cuda_bytes": torch.cuda.max_memory_allocated(), - "spectra": spectra, - } - temporary = artifact.with_suffix(".partial") - torch.save(payload, temporary) - temporary.replace(artifact) - if reference is None: - reference = payload["spectra"] - maximum = 0.0 - bit_exact = True - for key, actual in payload["spectra"].items(): - torch.testing.assert_close(actual, reference[key], rtol=1e-5, atol=1e-6) - maximum = max(maximum, float((actual - reference[key]).abs().max())) - bit_exact = bit_exact and torch.equal(actual, reference[key]) - record = {k: v for k, v in payload.items() if k != "spectra"} - record.update( - case=index, max_spectrum_absolute_difference=maximum, bit_exact=bit_exact - ) - records.append(record) - print("JACOBIAN_PROFILE_CASE", json.dumps(record), flush=True) - (out / "progress.json").write_text( - json.dumps({"status": "RUNNING", "cases": records}, indent=2) - ) - - assert torch.equal(rng_cpu, torch.get_rng_state()) - assert torch.equal(rng_cuda, torch.cuda.get_rng_state()) - assert flags == [p.requires_grad for p in decoder.parameters()] - assert all( - torch.equal(v.cpu(), parameters[k]) for k, v in decoder.state_dict().items() - ) - assert all(p.grad is None for p in decoder.parameters()) - baseline = statistics.median( - r["seconds"] for r in records if r["kind"] == "baseline" - ) - optimized = statistics.median( - r["seconds"] for r in records if r["kind"] == "optimized" - ) - result = { - "status": "PASS", - "request_sha256": request_sha, - "checkpoint_step": request["checkpoint_step"], - "weights": "raw_strict_components", - "precision": "FP32_math_SDPA_noTF32", - "torch": torch.__version__, - "gpu": torch.cuda.get_device_name(), - "cases": records, - "baseline_median_seconds": baseline, - "optimized_median_seconds": optimized, - "speedup": baseline / optimized, - "parameter_state_unchanged": True, - "rng_unchanged": True, - "all_singular_values_checked": True, - } - (out / "RESULT.json").write_text(json.dumps(result, indent=2)) - print("JACOBIAN_PROFILE_PASS", json.dumps(result), flush=True) diff --git a/tests/test_action_flow_launcher.py b/tests/test_action_flow_launcher.py deleted file mode 100644 index 0087cb70e2..0000000000 --- a/tests/test_action_flow_launcher.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -import os -import re -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[1] -LAUNCHER = ROOT / "scripts" / "train" / "launch_action_flow_usocket.sbatch" -DATASET_VALIDATOR = ROOT / "scripts" / "ice" / "validate_planar_dataset.py" - - -def _source() -> str: - return LAUNCHER.read_text() - - -def test_launcher_has_valid_shell_syntax(): - completed = subprocess.run( - ["bash", "-n", str(LAUNCHER)], - check=False, - capture_output=True, - text=True, - ) - assert completed.returncode == 0, completed.stderr - assert "#SBATCH --kill-on-invalid-dep=yes" in _source() - - -def test_launcher_uses_the_maintained_dataset_validator_success_token(): - validator_source = DATASET_VALIDATOR.read_text() - match = re.search(r'"status":\s*"([A-Z_]+)"', validator_source) - assert match is not None - assert f'dataset_report["status"] == "{match.group(1)}"' in _source() - - -def test_launcher_is_one_portable_fail_closed_contract(): - source = _source() - assert "AF_EXPECTED_HEAD" in source - assert "AF_EXPECTED_LAUNCHER_SHA256" in source - assert "AF_EXPECTED_SPLIT_MANIFEST_SHA256" in source - assert "AF_EXPECTED_CONTENT_MANIFEST_SHA256" in source - assert "AF_EXPECTED_DATASET_CONTENT_AGGREGATE_SHA256" in source - assert "AF_EXPECTED_NORM_SHA256" in source - assert "AF_PREFLIGHT_RESULT" in source - assert "AF_EXPECTED_PREFLIGHT_SHA256" in source - assert "validate_action_flow_config.py" in source - assert "capture_runtime_lock.py" in source - assert "dataset_loader_probe.json" in source - assert "dataset = instantiate(dataset_cfg)" in source - assert "validate_slurm_job_contract.py" in source - assert "check_checkpoint_storage.py" in source - assert "--gres=gpu:1 --constraint='H100|H200'" in source - assert '--allowed-gpu-name "NVIDIA H100 80GB HBM3"' in source - assert '--allowed-gpu-name "NVIDIA H200"' in source - assert "AF_EXPECTED_GPU_CONSTRAINT" in source - assert '--expected-constraint "$AF_EXPECTED_GPU_CONSTRAINT"' in source - assert "trainer.devices=1" in source - assert "trainer.strategy=auto" in source - assert "#SBATCH --requeue" in source - assert "#SBATCH --signal=B:USR1@600" in source - assert "/coc/" not in source - assert "/storage/ice" not in source - assert "/storage/project" not in source - assert 'absolute_path AF_EXTRA_PYTHONPATH "$AF_EXTRA_PYTHONPATH"' in source - assert ( - 'AF_EXTRA_PYTHONPATH=${AF_EXTRA_PYTHONPATH:-$(dirname "$AF_CONFIG_VALIDATOR")}' - in source - ) - assert ( - 'PYTHONPATH="$AF_REPO:${AF_RESUME_HELPER_DIR:+$AF_RESUME_HELPER_DIR:}' - '$(dirname "$AF_CONFIG_VALIDATOR")${AF_EXTRA_PYTHONPATH:+:$AF_EXTRA_PYTHONPATH}"' - in source - ) - - -def test_launcher_accepts_only_the_approved_sweep_and_pins_training_semantics(): - source = _source() - assert "pusht/action_flow_bc_usocket_recon1_s42" in source - assert "pusht/action_flow_bc_usocket_recon10_s42" in source - assert "pusht/action_flow_bc_usocket_recon100_s42" in source - assert ( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42" in source - ) - assert ( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_adamw_lr1e5_s42" - in source - ) - assert "AF_EXPECTED_CONFIG_NAME=action_flow_bc_usocket_recon1_s42" in source - assert "AF_EXPECTED_CONFIG_NAME=action_flow_bc_usocket_recon10_s42" in source - assert "AF_EXPECTED_CONFIG_NAME=action_flow_bc_usocket_recon100_s42" in source - assert "expected_name = {" not in source - assert "AF_FULL_MAX_STEPS=240000" in source - assert "AF_FULL_VALIDATE_EVERY=10000" in source - assert "AF_FULL_CHECKPOINT_EVERY=40000" in source - assert "AF_FULL_MAX_STEPS=150000" in source - assert "TELEMETRY_EVERY=100" in source - assert "data.train_dataloader_params.$AF_SOURCE.batch_size=32" in source - assert ( - "data.valid_dataloader_params.$AF_SOURCE.batch_size=$AF_VALID_BATCH_SIZE" - in source - ) - assert "AF_FULL_LIMIT_VAL_BATCHES=8" in source - assert "AF_VALID_BATCH_SIZE=32" in source - assert ( - "cfg.data.valid_dataloader_params[source].batch_size == valid_batch_size" - in source - ) - assert ( - "diagnostics.validation_view.per_rank_batch_size == valid_batch_size" in source - ) - assert "cfg.trainer.val_check_interval == full_validate_every" in source - assert ( - "cfg.callbacks.model_checkpoint.every_n_train_steps == full_checkpoint_every" - in source - ) - assert "ckpt_path=null" in source - assert "norm_stats.precomputed_norm_path=$AF_NORM_STATS_PATH" in source - assert "++run_provenance.source_commit=$AF_EXPECTED_HEAD" in source - assert "++run_provenance.content_manifest_sha256=" in source - assert "++run_provenance.dataset_content_aggregate_sha256=" in source - assert "++run_provenance.normalization_sha256=$AF_EXPECTED_NORM_SHA256" in source - assert ( - "++run_provenance.preflight_result_sha256=$AF_EXPECTED_PREFLIGHT_SHA256" - in source - ) - assert '"runtime_lock_sha256": digest(runtime_lock_path)' in source - assert 'current["installed_distributions"]["canonical_sha256"]' in source - assert "[run-preflight] PASS kind={run_kind} parameters={count}" in source - assert "expected_count = 50_725_221" in source - assert "50_801_685" in source - assert "expected_count = 199_754_837" in source - assert "AF_EXPECTED_PARAMETER_COUNT=97956100" in source - assert "ReleasedUniteCompositeOptimizer" in source - assert 'optimizer.muon_adjust_lr_fn == "match_rms_adamw"' in source - assert "AF_SECOND_DATASET_DIR" in source - assert "AF_SECOND_SPLIT_MANIFEST" in source - assert "AF_EXPECTED_SECOND_DATASET_CONTENT_AGGREGATE_SHA256" in source - assert "RoutedContentEncoderStage" in source - assert "RoutedContentDecoderStage" in source - assert "cfg.model.flow_mini_batch == 14" in source - assert "activation_layer_map.items()" in source - - -def test_smoke_runs_optimizer_validation_checkpoint_and_verifier(): - source = _source() - assert "MAX_STEPS=2" in source - assert "VALIDATE_EVERY=2" in source - assert "LIMIT_VAL_BATCHES=1" in source - assert "CHECKPOINT_EVERY=1" in source - assert "TELEMETRY_EVERY=2" in source - assert "verify_action_flow_training_smoke.py" in source - assert "--planned-checkpoint-count 14" in source - assert '--expected-constraint "$AF_EXPECTED_GPU_CONSTRAINT"' in source - assert "--expected-reconstruction-weight" in source - assert "--expected-config-sha256" in source - assert ( - '--expected-config-sha256 "$(sha256 ' - '"$AF_OUTPUT_DIR/.hydra/config.yaml")"' in source - ) - assert "--expected-normalization-sha256" in source - assert "--expected-content-manifest-sha256" in source - assert "--expected-dataset-content-aggregate-sha256" in source - assert "--expected-preflight-sha256" in source - assert "callbacks.model_checkpoint.save_last=link" in source - assert "single-gpu,$AF_RUN_KIND" in source - - -def test_full_and_smoke_share_the_requeue_and_strict_checkpoint_path(): - source = _source() - assert 'source "$AF_REPO/scripts/train/action_flow_run_child.sh"' in source - # The canonical runner arguments live in the shared child entry point. - child = (ROOT / "scripts/train/action_flow_run_child.sh").read_text() - assert "ice_requeue_runner.py" in source - assert "validate_lightning_checkpoint.py" in source - assert '--checkpoint-validator "$AF_CHECKPOINT_VALIDATOR"' in child - assert "--requeue-owner runner" in child - assert "--confirm-child-requeue-disabled" in child - assert "runtime.slurm_requeue_owner=runner" in source - assert "runtime.slurm_save_signal=SIGUSR2" in source - - -def test_full_mode_requires_the_exact_passed_smoke_gate(): - source = _source() - assert "required AF_SMOKE_RESULT" in source - assert "required AF_EXPECTED_SMOKE_SHA256" in source - assert 'payload["status"] == "PASS"' in source - assert 'payload["experiment"] == experiment' in source - assert 'identities["repo_head"] == head' in source - assert 'identities["split_manifest_sha256"] == split_sha' in source - assert 'identities["normalization_sha256"] == norm_sha' in source - assert 'identities["content_manifest_sha256"] == content_sha' in source - assert 'payload["preflight"]["sha256"] == preflight_sha' in source - assert "++run_provenance.smoke_result_sha256=$AF_EXPECTED_SMOKE_SHA256" in source - assert 'cp -- "$AF_SMOKE_RESULT" "$ATTEMPT/SMOKE_RESULT.json"' in source - - -def test_second_preflight_can_reuse_the_first_train_only_normalization(): - source = _source() - assert 'if test -z "${AF_NORM_STATS_PATH:-}"; then' in source - assert 'test -s "$EFFECTIVE_NORM_FILE"' in source - assert "normalization SHA-256 mismatch" in source - - -def test_preflight_reuses_hashed_dataset_evidence_and_removes_logger_group(): - source = _source() - assert "AF_CACHED_DATASET_VALIDATION" in source - assert "AF_EXPECTED_CACHED_DATASET_VALIDATION_SHA256" in source - assert 'payload["status"] == "DATASET_VALIDATED"' in source - assert "'~logger'" in source - assert "logger=null" not in source - assert "++logger.wandb.offline=false" in source - assert "++logger.wandb.name=$AF_WANDB_NAME" in source - assert "++logger.wandb.resume=allow" in source - for key in ("entity", "project", "group", "id", "tags"): - assert f"++logger.wandb.{key}=" in source - - -@pytest.mark.parametrize( - "selector", - [ - "pusht/action_flow_chain_manual4919_avln_80k_s42", - "pusht/parents/action_flow_usocket_h384", - "pusht_historical/action_flow_cotrain_uc_speed_interpolation", - ], -) -def test_retired_and_parent_selectors_fail_before_runtime(selector): - source = _source() - required = source.split("for variable in ", 1)[1].split("; do", 1)[0] - variables = required.replace("\\", " ").split() - env = {**os.environ, **dict.fromkeys(variables, "fixture")} - env.update(AF_REPO=str(ROOT), AF_EXPERIMENT=selector, AF_PYTHON="/must-not-execute") - completed = subprocess.run( - ["bash", str(LAUNCHER)], env=env, capture_output=True, text=True - ) - assert completed.returncode != 0 - assert "retired" in completed.stderr or "historical config" in completed.stderr - assert "must-not-execute" not in completed.stderr - - -@pytest.mark.parametrize( - "run_kind,expected", - [("smoke", ["evaluator.limit_val_batches=1"]), ("full", [])], -) -def test_raw_multiplier_smoke_bounds_actual_evaluator_override(run_kind, expected): - from omegaconf import OmegaConf - - from egomimic.eval.planar_action_eval import PlanarActionEval - - source = _source() - start = source.index("# Bound the evaluator too:") - end = source.index("fi\n\n", start) + len("fi\n\n") - block = source[start:end] - command = ( - "BASE_OVERRIDES=(); " - + block - + '\nfor item in "${BASE_OVERRIDES[@]}"; do printf "%s\\n" "$item"; done' - ) - completed = subprocess.run( - ["bash", "-c", command], - env={ - **os.environ, - "AF_RUN_KIND": run_kind, - "AF_SPEED_DIAGNOSTIC": "true", - "LIMIT_VAL_BATCHES": "1" if run_kind == "smoke" else "8", - }, - capture_output=True, - text=True, - check=True, - ) - overrides = completed.stdout.splitlines() - assert overrides == expected - limit = ( - OmegaConf.from_dotlist(overrides).evaluator.limit_val_batches - if overrides - else 1.0 - ) - evaluator = PlanarActionEval(limit_val_batches=limit, energy_score_enabled=False) - actual = evaluator.trainer_overrides()["limit_val_batches"] - assert type(actual) is (int if run_kind == "smoke" else float) - assert actual == (1 if run_kind == "smoke" else 1.0) - - -@pytest.mark.parametrize( - "mode,step,message", - [ - ("schema", "0", "typed preflight"), - ("run", "extern", "numeric scheduled step"), - ("run", "0", "AF_JOINT_PHASE_INPUTS"), - ], -) -def test_joint_canonical_route_rejects_missing_operational_identity_before_runtime( - mode, step, message -): - required = _source().split("for variable in ", 1)[1].split("; do", 1)[0] - variables = required.replace("\\", " ").split() - env = {key: value for key, value in os.environ.items() if not key.startswith("AF_")} - env.update(dict.fromkeys(variables, "fixture")) - env.update( - AF_REPO=str(ROOT), - AF_EXPERIMENT="libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - AF_PYTHON="/must-not-execute", - AF_LAUNCH_MODE=mode, - AF_RUN_KIND="smoke", - AF_EXPECTED_MEMORY="128G", - AF_EXPECTED_TIME_LIMIT="1:00:00", - SLURM_STEP_ID=step, - ) - result = subprocess.run( - ["bash", str(LAUNCHER)], env=env, capture_output=True, text=True - ) - assert result.returncode != 0 - assert message in result.stderr - assert "must-not-execute" not in result.stderr diff --git a/tests/test_action_flow_likelihood.py b/tests/test_action_flow_likelihood.py deleted file mode 100644 index a0eac32e81..0000000000 --- a/tests/test_action_flow_likelihood.py +++ /dev/null @@ -1,435 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -from pathlib import Path - -import hydra -import pytest -import torch -from hydra import compose, initialize_config_dir -from torch import nn - -from egomimic.models.action_flow_codec import ContextFreeSequenceDecoder -from egomimic.models.action_flow_likelihood import ( - GaussianBridgeSchedule, - TimeDependentSequenceMean, -) -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_action_flow_likelihood import ( - ConditionalReverseMeanStage, - GaussianBridgeNoisingStage, - GaussianBridgeObjectiveStage, - LikelihoodDecoderStage, - LikelihoodReferenceStage, -) -from egomimic.pl_utils.pl_model_action_flow_likelihood import ( - ActionFlowLikelihoodModelWrapper, -) - - -class TinyField(nn.Module): - def __init__(self, latent_dim=3, condition_dim=2): - super().__init__() - self.linear = nn.Linear(latent_dim + condition_dim + 1, latent_dim) - self.calls = [] - - def forward(self, state, time, condition, *, condition_drop_mask): - self.calls.append(time.detach().clone()) - condition = torch.where(condition_drop_mask[:, None], 0, condition) - features = torch.cat( - ( - state, - time[:, None, None].expand(-1, state.shape[1], 1), - condition[:, None, :].expand(-1, state.shape[1], -1), - ), - -1, - ) - return self.linear(features) - - -def stages(num_levels=4, samples=3, dropout=0.0): - return [ - LikelihoodReferenceStage( - TimeDependentSequenceMean( - input_dim=2, - latent_dim=3, - horizon=2, - hidden_dim=8, - depth=1, - num_heads=2, - feedforward_dim=16, - ), - num_levels=num_levels, - interior_samples_per_content=samples, - ), - GaussianBridgeNoisingStage( - num_levels=num_levels, condition_dropout_probability=dropout - ), - ConditionalReverseMeanStage(TinyField(), num_levels=num_levels), - LikelihoodDecoderStage( - ContextFreeSequenceDecoder( - latent_dim=3, - output_dim=2, - horizon=2, - hidden_dim=8, - depth=1, - num_heads=2, - feedforward_dim=16, - ) - ), - GaussianBridgeObjectiveStage(num_levels=num_levels), - ] - - -def batch(): - return { - "target": torch.randn(2, 2, 2), - "condition": torch.randn(2, 2, requires_grad=True), - "sampler/noise": torch.randn(2, 2, 3), - } - - -def test_terminal_mean_is_exact_zero_and_codec_is_context_free(): - mean = TimeDependentSequenceMean() - action = torch.randn(2, 16, 4) - assert torch.count_nonzero(mean(action, torch.ones(2))) == 0 - assert mean(action, torch.full((2,), 1 / 32)).shape == (2, 16, 8) - with pytest.raises(TypeError): - mean(action, torch.ones(2), condition=torch.ones(2, 67)) - assert sum(p.numel() for p in mean.parameters()) < 15000 - - -def test_reference_noising_exact_targets_independent_samples_and_shared_drop_mask(): - torch.manual_seed(7) - modules = stages(samples=14, dropout=0.3) - result = modules[1](modules[0](batch())) - p = "likelihood/" - levels, noise = result[p + "levels"], result[p + "interior_noise"] - schedule = modules[1].schedule - assert levels.shape == (28,) and bool(((levels >= 2) & (levels <= 4)).all()) - assert not torch.equal(noise[0], noise[1]) - expected_state = ( - result[p + "current_mean"] + schedule.sigmas[levels - 1, None, None] * noise - ) - expected_target = ( - result[p + "previous_mean"] - + 0.95 * schedule.sigmas[levels - 2, None, None] * noise - ) - torch.testing.assert_close(result[p + "state"][:28], expected_state) - torch.testing.assert_close(result[p + "posterior_target"], expected_target) - torch.testing.assert_close( - result[p + "posterior_variance"], - (1 - 0.95**2) * schedule.sigmas[levels - 2].square(), - ) - masks = result[p + "condition_drop_mask"] - assert torch.equal(masks[:28].view(2, 14), masks[28:, None].expand(2, 14)) - assert result[p + "posterior_target"].requires_grad - - -def test_likelihood_reduction_sums_full_chunk_not_toy_coefficient(): - stage = GaussianBridgeObjectiveStage(num_levels=32, tau=0.02) - values = { - "likelihood/interior_prediction": torch.ones(28, 16, 8), - "likelihood/posterior_target": torch.zeros(28, 16, 8), - "likelihood/posterior_variance": torch.ones(28), - "likelihood/boundary_prediction": torch.ones(2, 16, 4), - "target": torch.zeros(2, 16, 4), - } - result = stage(values) - torch.testing.assert_close( - result["log/ActionFlow/InteriorBridgeNLL"], torch.tensor(31 * 128 / 2) - ) - torch.testing.assert_close( - result["log/ActionFlow/BoundaryNLL"], torch.tensor(80000.0) - ) - torch.testing.assert_close(result["log/MSE"], torch.tensor(1.0)) - assert result["loss/likelihood"] == result["log/ActionFlow/TotalLoss"] - assert not any("recon" in k.lower() or "fm" in k.lower() for k in result) - - -def test_both_likelihood_terms_update_private_means_and_shared_conditioned_field(): - torch.manual_seed(5) - modules = stages() - pipeline = Pipeline(modules) - value = batch() - result = pipeline(value) - mean = modules[0].mean_encoder.network.output_projection.weight - field = modules[2].field.linear.weight - decoder = modules[3].decoder.output_projection.weight - targets = (mean, field, decoder, value["condition"]) - interior = torch.autograd.grad( - result["log/ActionFlow/InteriorBridgeNLL"], - targets, - retain_graph=True, - allow_unused=True, - ) - boundary = torch.autograd.grad( - result["log/ActionFlow/BoundaryNLL"], - targets, - retain_graph=True, - allow_unused=True, - ) - assert interior[2] is None - assert all( - g is not None and torch.count_nonzero(g) > 0 - for i, g in enumerate(interior) - if i != 2 - ) - assert all(g is not None and torch.count_nonzero(g) > 0 for g in boundary) - assert len(modules[2].field.calls) == 1 - assert modules[2].field.calls[0].shape == (2 * (3 + 1),) - optimizer = torch.optim.AdamW(pipeline.parameters(), lr=1e-4) - optimizer.zero_grad() - result["loss/likelihood"].backward() - optimizer.step() - assert all(torch.isfinite(p).all() for p in pipeline.parameters()) - - -def test_inference_uses_all_discrete_calls_innovations_and_output_noise(monkeypatch): - modules = stages(num_levels=32) - pipeline = Pipeline(modules).eval() - shapes = [] - - def noise_like(value, **kwargs): - shapes.append(tuple(value.shape)) - return torch.ones_like(value, **kwargs) - - monkeypatch.setattr(torch, "randn_like", noise_like) - value = batch() - result = pipeline.execute( - {"condition": value["condition"], "sampler/noise": value["sampler/noise"]}, - mode="inference", - ) - calls = modules[2].field.calls - assert len(calls) == 32 - torch.testing.assert_close( - torch.stack(calls)[:, 0], torch.arange(32, 0, -1).float() / 32 - ) - assert shapes == [(2, 2, 3)] * 31 + [(2, 2, 2)] - assert result["likelihood/trajectory"].shape == (33, 2, 2, 3) - torch.testing.assert_close( - result["pred_action"], result["likelihood/action_mean"] + 0.02 - ) - assert result["log/ActionFlow/SamplerCalls"] == 32 - assert result["log/ActionFlow/LatentInnovations"] == 31 - assert "loss/likelihood" not in result and "likelihood/boundary_mean" not in result - - -def test_seeded_actual_sampler_replays_including_output_noise(): - pipeline = Pipeline(stages()).eval() - value = batch() - torch.manual_seed(42) - first = pipeline.execute(value, mode="inference")["pred_action"] - torch.manual_seed(42) - second = pipeline.execute(value, mode="inference")["pred_action"] - torch.testing.assert_close(first, second, rtol=0, atol=0) - torch.manual_seed(43) - assert not torch.equal( - first, pipeline.execute(value, mode="inference")["pred_action"] - ) - - -def test_wrapper_uses_likelihood_labels_and_records_observed_gradient_routes( - monkeypatch, -): - torch.manual_seed(3) - algo = PipelineAlgo(stages(), device="cpu") - model = ActionFlowLikelihoodModelWrapper( - pipeline=algo, enable_grad_norm=False, gradient_telemetry_cadence=1 - ) - recorded = {} - monkeypatch.setattr( - model, "log", lambda name, value, **kwargs: recorded.__setitem__(name, value) - ) - loss = model.training_step({"example": batch()}, 0) - assert torch.isfinite(loss) and loss.requires_grad - assert { - "Train/MSE", - "Train/ActionFlow/InteriorBridgeNLL", - "Train/ActionFlow/BoundaryNLL", - "Train/ActionFlow/TotalLoss", - } <= set(recorded) - assert not any( - "FlowMatchingLoss" in name or "ReconstructionLoss" in name for name in recorded - ) - checkpoint = {} - model.on_save_checkpoint(checkpoint) - manifest = checkpoint["action_flow_gradient_route_manifest"] - assert set(manifest["routes"]) == {"InteriorBridgeNLL", "BoundaryNLL"} - interior = [x["name"] for x in manifest["routes"]["InteriorBridgeNLL"]] - boundary = [x["name"] for x in manifest["routes"]["BoundaryNLL"]] - assert any("mean_encoder" in name for name in interior) - assert any("field" in name for name in interior) - assert not any("decoder" in name for name in interior) - assert any("decoder" in name for name in boundary) - core = {k: v for k, v in manifest.items() if k != "manifest_sha256"} - assert ( - hashlib.sha256( - json.dumps( - core, sort_keys=True, separators=(",", ":"), ensure_ascii=True - ).encode() - ).hexdigest() - == manifest["manifest_sha256"] - ) - assert checkpoint["action_flow_likelihood_contract"]["latent_fm_objective"] is False - - -def test_two_update_smoke_captures_gradient_routes_on_second_update(monkeypatch): - model = ActionFlowLikelihoodModelWrapper( - pipeline=PipelineAlgo(stages(), device="cpu"), - enable_grad_norm=False, - gradient_telemetry_cadence=2, - ) - recorded = {} - monkeypatch.setattr( - model, "log", lambda name, value, **kwargs: recorded.__setitem__(name, value) - ) - current_step = [0] - monkeypatch.setattr( - ActionFlowLikelihoodModelWrapper, - "global_step", - property(lambda self: current_step[0]), - ) - model.training_step({"example": batch()}, 0) - assert model._gradient_route_manifest is None - current_step[0] = 1 - loss = model.training_step({"example": batch()}, 1) - loss.backward() - model.on_after_backward() - model.on_before_optimizer_step(None) - assert model._gradient_route_manifest is not None - assert "Train/ActionFlow/GradientNorm/TotalPreclip" in recorded - assert recorded["Train/ActionFlow/Compute/PeakAllocatedBytes"] == 0 - - -def test_validation_step_emits_real_likelihood_and_preserves_evaluator_rng(monkeypatch): - algo = PipelineAlgo(stages(dropout=0.3), device="cpu") - calls, predictions, logged = [], [], {} - - class Evaluator: - def on_validation_start(self): - calls.append("start") - - def on_validation_step(self, processed, index, loader_index): - calls.append((index, loader_index)) - # Exercise the actual stochastic inference path delegated to the - # evaluator, which owns normalized/native MSE and EnergyScore32. - self.generated = algo.forward_eval(processed) - - def on_validation_end(self): - calls.append("end") - - evaluator = Evaluator() - wrapper = ActionFlowLikelihoodModelWrapper( - pipeline=algo, evaluator=evaluator, enable_grad_norm=False - ).eval() - real_forward = algo.forward_training - - def capture_forward(processed): - assert not torch.is_grad_enabled() and not wrapper.training - result = real_forward(processed) - predictions.append(result) - return result - - monkeypatch.setattr(algo, "forward_training", capture_forward) - monkeypatch.setattr( - wrapper, "log", lambda name, value, **kw: logged.update({name: (value, kw)}) - ) - value = {"first": batch(), "second": batch()} - with torch.random.fork_rng(), torch.inference_mode(): - torch.manual_seed(17) - expected = algo.forward_eval(value) - expected_rng = torch.random.get_rng_state() - torch.manual_seed(17) - wrapper.on_validation_start() - with torch.inference_mode(): - wrapper.validation_step({**value, "inactive": None}, 3, 1) - wrapper.on_validation_end() - assert calls == ["start", (3, 1), "end"] - assert len(predictions) == 1 - assert torch.equal(torch.random.get_rng_state(), expected_rng) - for source in value: - torch.testing.assert_close( - evaluator.generated[source]["pred_action"], - expected[source]["pred_action"], - rtol=0, - atol=0, - ) - assert "target" in value[source] # loss graph cannot consume caller input - assert set(logged) == { - "Valid/ActionFlow/InteriorBridgeNLL", - "Valid/ActionFlow/BoundaryNLL", - "Valid/ActionFlow/TotalLoss", - } - for name, (actual, options) in logged.items(): - key = "log/" + name.removeprefix("Valid/") - expected_loss = torch.stack([r[key] for r in predictions[0].values()]).mean() - torch.testing.assert_close(actual, expected_loss) - assert torch.isfinite(actual) and not actual.requires_grad - assert options == dict( - on_step=False, - on_epoch=True, - batch_size=4, - sync_dist=True, - add_dataloader_idx=False, - ) - assert all(parameter.grad is None for parameter in wrapper.parameters()) - - -def test_validation_step_rejects_nonfinite_likelihood_component(monkeypatch): - algo = PipelineAlgo(stages(), device="cpu") - wrapper = ActionFlowLikelihoodModelWrapper( - pipeline=algo, enable_grad_norm=False - ).eval() - real_forward = algo.forward_training - - def nonfinite_forward(value): - result = real_forward(value) - result["example"]["log/ActionFlow/BoundaryNLL"] = torch.tensor(float("nan")) - return result - - monkeypatch.setattr(algo, "forward_training", nonfinite_forward) - with pytest.raises(RuntimeError, match="Non-finite pipeline metric"): - wrapper.validation_step({"example": batch()}, 0) - - -def test_full_hydra_likelihood_graph_has_unblocked_inference_plan(): - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(config_dir.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=pusht/action_flow_bc_usocket_bridge_likelihood_s42", - "++paths.root_dir=.", - ], - ) - assert cfg.train.action_flow_method == cfg.model.action_flow_method - assert not cfg.evaluator.action_flow_diagnostics.enabled - algo = hydra.utils.instantiate(cfg.model.pipeline, device="cpu") - pipeline = algo.pipeline - runnable, excluded = pipeline.plan( - ["front_img_1", "state_agent_obj"], mode="inference" - ) - assert [type(s).__name__ for s in runnable] == [ - "FusedObsEncoder", - "GaussianLatentNoise", - "ConditionalReverseMeanStage", - "LikelihoodDecoderStage", - ] - assert all(missing == [""] for _, missing in excluded) - assert sum(p.numel() for p in pipeline.stages[0].parameters()) == 11197088 - assert sum(p.numel() for p in pipeline.stages[3].parameters()) == 10768 - assert sum(p.numel() for p in pipeline.stages[5].parameters()) == 39506641 - assert sum(p.numel() for p in pipeline.stages[6].parameters()) == 10744 - # PipelineAlgo is the orchestration adapter; registered parameters live in nets. - assert sum(p.numel() for p in algo.nets.parameters()) == 50725241 - - -@pytest.mark.parametrize( - "kwargs", [{"num_levels": 1}, {"rho": 1}, {"sigma_min": 0}, {"sigma_max": 2}] -) -def test_invalid_reference_schedule_fails_closed(kwargs): - with pytest.raises(ValueError): - GaussianBridgeSchedule(**kwargs) diff --git a/tests/test_action_flow_manual4919_config.py b/tests/test_action_flow_manual4919_config.py deleted file mode 100644 index 1d0a200cec..0000000000 --- a/tests/test_action_flow_manual4919_config.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Guard the corrected manual standard + obstacle-gen ChainGripper recipes.""" - -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import OmegaConf - -from egomimic.eval.energy_score import USOCKET_ENERGY_DISTANCE_CONFIG -from egomimic.eval.planar_action_eval import PlanarActionEval -from egomimic.pipeline.pushshapes import ChainGripperPointsNativeDecoder - -CONFIG_DIR = Path(__file__).parents[1] / "egomimic" / "hydra_configs" -ROWS = ("parents/action_flow_cotrain_uc_manual4919",) - - -@pytest.mark.parametrize("row", ROWS) -def test_manual4919_recipe_uses_effective_split_and_cadence(monkeypatch, row): - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", "/verified/usocket-clean2999") - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", "/verified/manual4919-view") - with initialize_config_dir( - version_base="1.3", config_dir=str(CONFIG_DIR.resolve()) - ): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=pusht/{row}"], - ) - chain = cfg.data.train_datasets.pushshapes_sim_chain_gripper - valid = cfg.data.valid_datasets.pushshapes_sim_chain_gripper - assert "data" not in cfg.data - assert set(cfg.data.train_datasets) == ( - {"pushshapes_sim_chain_gripper"} - if row.startswith("action_flow_chain_") - else {"pushshapes_sim_u_socket", "pushshapes_sim_chain_gripper"} - ) - for dataset in (chain, valid): - assert dataset.resolver.folder_path == "/verified/manual4919-view" - assert dataset.resolver.expected_episode_count == 4919 - assert dataset.expected_train_episode_count == 4870 - assert dataset.expected_valid_episode_count == 49 - assert dataset.valid_ratio == 0.01 - assert dataset.split_seed == 42 - assert cfg.run_provenance.chain_collection.standard_manual_episodes == 3000 - assert cfg.run_provenance.chain_collection.obstacle_gen_manual_episodes == 1919 - assert cfg.run_provenance.chain_collection.excluded_frozen_mixed_view - assert cfg.trainer.max_steps == 80000 - assert cfg.trainer.val_check_interval == 15000 - assert cfg.val_at_end - assert cfg.callbacks.model_checkpoint.every_n_train_steps == 5000 - assert cfg.callbacks.model_checkpoint.save_top_k == -1 - assert cfg.evaluator.action_flow_diagnostics.native_error is None - - -@pytest.mark.parametrize("row", ROWS) -def test_manual4919_native_evaluator_decoder_instantiates(monkeypatch, row): - """Catch inherited Hydra kwargs before an allocated GPU smoke starts.""" - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", "/verified/usocket-clean2999") - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", "/verified/manual4919-view") - with initialize_config_dir( - version_base="1.3", config_dir=str(CONFIG_DIR.resolve()) - ): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=pusht/{row}"], - ) - decoder_cfg = ( - cfg.evaluator.native_decoder - if row.startswith("action_flow_chain_") - else cfg.evaluator.native_decoders.pushshapes_sim_chain_gripper - ) - decoder = instantiate(decoder_cfg) - assert isinstance(decoder, ChainGripperPointsNativeDecoder) - if row.startswith("action_flow_chain_"): - assert decoder_cfg.action_horizon == 16 - assert decoder_cfg.native_action_dim == 4 - - -def test_chain_native_decoder_rejects_wrong_native_shape(): - with pytest.raises(ValueError, match="native_action_dim=4"): - ChainGripperPointsNativeDecoder(native_action_dim=6) - - -def test_manual4919_cotrain_resolves_per_domain_energy_distance(monkeypatch): - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", "/verified/usocket-clean2999") - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", "/verified/manual4919-view") - with initialize_config_dir( - version_base="1.3", config_dir=str(CONFIG_DIR.resolve()) - ): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=["+experiment=pusht/parents/action_flow_cotrain_uc_manual4919"], - ) - assert cfg.evaluator.energy_score_distance is None - distances = OmegaConf.to_container( - cfg.evaluator.energy_score_distances_by_embodiment, resolve=True - ) - assert distances == { - "pushshapes_sim_u_socket": { - **USOCKET_ENERGY_DISTANCE_CONFIG, - "complete_normalized_chunk_shape": [16, 4], - "normalized_translation_indices": [0, 1], - }, - "pushshapes_sim_chain_gripper": None, - } - assert ( - OmegaConf.to_container( - cfg.evaluator.energy_score_provenance.distance_contract, resolve=True - ) - == distances - ) - assert ( - OmegaConf.to_container( - cfg.run_provenance.energy_score_contract.distance, resolve=True - ) - == distances - ) - assert cfg.evaluator.action_flow_diagnostics.native_error is None - evaluator = PlanarActionEval( - energy_score_enabled=False, - native_decoders={ - label: instantiate(decoder) - for label, decoder in cfg.evaluator.native_decoders.items() - }, - semantic_blocks_by_embodiment=OmegaConf.to_container( - cfg.evaluator.semantic_blocks_by_embodiment, resolve=True - ), - energy_score_distances_by_embodiment=distances, - ) - assert set(evaluator.energy_score_distance_metadata["by_embodiment"]) == set( - distances - ) diff --git a/tests/test_action_flow_model_wrapper.py b/tests/test_action_flow_model_wrapper.py deleted file mode 100644 index 09b98bfaee..0000000000 --- a/tests/test_action_flow_model_wrapper.py +++ /dev/null @@ -1,801 +0,0 @@ -import hashlib -import json -from collections import OrderedDict -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -import torch.nn as nn - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.core import Stage -from egomimic.pipeline.stages_action_flow import ( - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, -) -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.pl_utils.pl_model_action_flow import ActionFlowModelWrapper - - -class _ToyPipeline(nn.Module): - def __init__(self): - super().__init__() - self.anchor = nn.Parameter(torch.ones(())) - - -class _ToyAlgo: - def __init__( - self, *, reconstruction_weight=1.0, flow_weight=1.0, total_metric_delta=0.0 - ): - self.nets = nn.ModuleDict({"pipeline": _ToyPipeline()}) - self.device = torch.device("cpu") - self.reconstruction_weight = float(reconstruction_weight) - self.flow_weight = float(flow_weight) - self.action_velocity_weight = 1.0 - self.total_metric_delta = float(total_metric_delta) - self.process_count = 0 - - def process_batch_for_training(self, batch): - self.process_count += 1 - return OrderedDict((source, dict(values)) for source, values in batch.items()) - - def forward_training(self, batch): - anchor = self.nets["pipeline"].anchor - results = OrderedDict() - for source, values in batch.items(): - fm = anchor * anchor.new_tensor(values["fm"]) - reconstruction = anchor * anchor.new_tensor(values["reconstruction"]) - reconstruction_l1 = anchor * anchor.new_tensor(values["reconstruction_l1"]) - action_velocity = anchor * anchor.new_tensor(values["action_velocity"]) - total = ( - self.flow_weight * fm - + self.reconstruction_weight * reconstruction - + action_velocity - ) - results[source] = { - "target": values["target"], - "loss/action_flow": total, - "log/action_flow_total": total + self.total_metric_delta, - "log/action_flow_fm": fm, - "log/action_flow_reconstruction": reconstruction, - "log/action_flow_reconstruction_l1": reconstruction_l1, - "log/action_flow_action_velocity": action_velocity, - "log/latent_rms": anchor.detach(), - } - return results - - @torch.inference_mode() - def forward_eval(self, batch): - anchor = self.nets["pipeline"].anchor - return OrderedDict( - (source, {"prediction": values["condition"] * anchor}) - for source, values in batch.items() - ) - - -class _ActionFlowHead(Stage): - reads = ("target",) - writes = ( - "loss/action_flow", - "log/action_flow_total", - "log/action_flow_fm", - "log/action_flow_reconstruction", - "log/action_flow_reconstruction_l1", - "log/action_flow_action_velocity", - ) - reads_by_mode = {"inference": ("condition",)} - writes_by_mode = {"inference": ("prediction",)} - - def __init__(self): - super().__init__() - self.anchor = nn.Parameter(torch.ones(())) - - def execute(self, batch, *, mode): - if mode == "inference": - batch["prediction"] = self.anchor.expand( - int(batch["condition"].shape[0]), 2, 3 - ) - return batch - fm = self.anchor.square() - reconstruction = (self.anchor - batch["target"]).square().mean() - reconstruction_l1 = (self.anchor - batch["target"]).abs().mean() - action_velocity = (2.0 * self.anchor).square() - total = fm + reconstruction + action_velocity - batch.update( - { - "loss/action_flow": total, - "log/action_flow_total": total, - "log/action_flow_fm": fm, - "log/action_flow_reconstruction": reconstruction, - "log/action_flow_reconstruction_l1": reconstruction_l1, - "log/action_flow_action_velocity": action_velocity, - } - ) - return batch - - def forward(self, batch): - return self.execute(batch, mode="train") - - -def _batch(): - return OrderedDict( - source_a={ - "target": torch.zeros(1, 2, 3), - "condition": torch.zeros(1, 3), - "fm": 1.0, - "reconstruction": 2.0, - "reconstruction_l1": 4.0, - "action_velocity": 3.0, - }, - source_b={ - "target": torch.zeros(3, 2, 3), - "condition": torch.zeros(3, 3), - "fm": 5.0, - "reconstruction": 6.0, - "reconstruction_l1": 8.0, - "action_velocity": 7.0, - }, - ) - - -def _capture_logs(monkeypatch, wrapper): - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **kwargs: logged.__setitem__(name, (value, kwargs)), - ) - return logged - - -def test_action_flow_wrapper_uses_only_explicit_optimizer_total(monkeypatch): - wrapper = ActionFlowModelWrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0), - gradient_telemetry_cadence=0, - ) - logged = _capture_logs(monkeypatch, wrapper) - - loss = wrapper.training_step(_batch(), batch_idx=0) - - # Sample-weighted components are fm=4, reconstruction=5, action=6, so the - # configured optimizer total is 4 + 10*5 + 6 = 60. Summing every metric and - # the total would incorrectly return 75. - assert float(loss) == pytest.approx(60.0) - assert float(loss) != pytest.approx(75.0) - expected = { - "Train/ActionFlow/TotalLoss": 60.0, - "Train/ActionFlow/FlowMatchingLoss": 4.0, - "Train/ActionFlow/ReconstructionLoss": 5.0, - "Train/ActionFlow/ReconstructionL1": 7.0, - "Train/ActionFlow/ActionVelocityLoss": 6.0, - } - for name, expected_value in expected.items(): - value, kwargs = logged[name] - assert float(value) == pytest.approx(expected_value) - assert kwargs == { - "on_step": True, - "on_epoch": True, - "sync_dist": False, - "batch_size": 4, - } - assert "Train/action_flow_total" not in logged - assert "Train/latent_rms" in logged - assert float(logged["Train/ActionFlow/TotalLoss/source_a"][0]) == pytest.approx( - 24.0 - ) - assert float(logged["Train/ActionFlow/TotalLoss/source_b"][0]) == pytest.approx( - 72.0 - ) - assert float(logged["Train/MSE"][0]) == pytest.approx(5.0) - assert float(logged["Train/MSE/source_a"][0]) == pytest.approx(2.0) - assert float(logged["Train/MSE/source_b"][0]) == pytest.approx(6.0) - - -def test_reconstruction_only_warmup_then_joint_objective(monkeypatch): - wrapper = ActionFlowModelWrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0), - gradient_telemetry_cadence=0, - reconstruction_only_warmup_steps=2, - ) - logged = _capture_logs(monkeypatch, wrapper) - - warmup_loss = wrapper.training_step(_batch(), batch_idx=0) - assert float(warmup_loss) == pytest.approx(50.0) - assert float(logged["Train/ActionFlow/FlowMatchingLoss"][0]) == pytest.approx(4.0) - assert float(logged["Train/ActionFlow/ActionVelocityLoss"][0]) == pytest.approx(6.0) - assert float(logged["Train/ActionFlow/Schedule/ReconstructionOnly"][0]) == 1.0 - assert float(logged["Train/ActionFlow/Schedule/EffectiveFlowWeight"][0]) == 0.0 - - predictions = wrapper.model.forward_training( - wrapper.model.process_batch_for_training(_batch()) - ) - assert not wrapper._apply_reconstruction_only_warmup(predictions, optimizer_step=2) - _, components, joint_loss, _ = wrapper._source_values(predictions) - assert float(joint_loss) == pytest.approx(60.0) - assert float(components["TotalLoss"]) == pytest.approx(60.0) - - -def test_reconstruction_only_warmup_is_loaded_from_training_config_tree(monkeypatch): - monkeypatch.setattr( - ActionFlowModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(reconstruction_weight=10.0), - ) - wrapper = ActionFlowModelWrapper( - config_tree={ - "model": { - "pipeline": {}, - "reconstruction_only_warmup_steps": 2, - } - }, - gradient_telemetry_cadence=0, - ) - - assert wrapper.reconstruction_only_warmup_steps == 2 - checkpoint = {} - wrapper.on_save_checkpoint(checkpoint) - assert checkpoint["action_flow_loss_schedule"] == { - "joint_objective_begins_at_global_step": 2, - "reconstruction_only_optimizer_steps": 2, - "joint_flow_weight": 1.0, - "joint_reconstruction_weight": 10.0, - "joint_action_velocity_weight": 1.0, - "schema_version": 1, - } - - -def test_flow_mini_batch_is_bound_to_full_parallel_flow_set(monkeypatch): - monkeypatch.setattr( - ActionFlowModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(), - ) - wrapper = ActionFlowModelWrapper( - config_tree={ - "model": { - "pipeline": {}, - "flow_samples_per_content": 14, - "flow_mini_batch": 14, - } - }, - gradient_telemetry_cadence=0, - ) - - assert wrapper.flow_samples_per_content == 14 - assert wrapper.flow_mini_batch == 14 - - -@pytest.mark.parametrize("value", [0, -1, 1.5, True]) -def test_flow_mini_batch_rejects_invalid_values(monkeypatch, value): - monkeypatch.setattr( - ActionFlowModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(), - ) - with pytest.raises(ValueError, match="flow_mini_batch must be a positive integer"): - ActionFlowModelWrapper( - config_tree={ - "model": { - "pipeline": {}, - "flow_samples_per_content": 14, - "flow_mini_batch": value, - } - }, - gradient_telemetry_cadence=0, - ) - - -def test_flow_mini_batch_rejects_partial_flow_chunking(monkeypatch): - monkeypatch.setattr( - ActionFlowModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(), - ) - with pytest.raises(ValueError, match="must equal flow_samples_per_content"): - ActionFlowModelWrapper( - config_tree={ - "model": { - "pipeline": {}, - "flow_samples_per_content": 14, - "flow_mini_batch": 7, - } - }, - gradient_telemetry_cadence=0, - ) - - -def test_joint_flow_weight_is_applied_and_logged(monkeypatch): - wrapper = ActionFlowModelWrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0, flow_weight=0.01), - gradient_telemetry_cadence=0, - ) - logged = _capture_logs(monkeypatch, wrapper) - - loss = wrapper.training_step(_batch(), batch_idx=0) - - assert float(loss) == pytest.approx(56.04) - assert float( - logged["Train/ActionFlow/Schedule/EffectiveFlowWeight"][0] - ) == pytest.approx(0.01) - - -@pytest.mark.parametrize("value", [-1, 1.5, True]) -def test_reconstruction_only_warmup_rejects_invalid_steps(value): - with pytest.raises(ValueError, match="nonnegative integer"): - ActionFlowModelWrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0), - gradient_telemetry_cadence=0, - reconstruction_only_warmup_steps=value, - ) - - -def test_action_flow_wrapper_rejects_total_metric_drift(monkeypatch): - wrapper = ActionFlowModelWrapper( - pipeline=_ToyAlgo(total_metric_delta=1.0), - gradient_telemetry_cadence=0, - ) - _capture_logs(monkeypatch, wrapper) - - with pytest.raises(RuntimeError, match="optimizer loss and total metric disagree"): - wrapper.training_step(_batch(), batch_idx=0) - - -def test_action_flow_wrapper_rejects_non_finite_components(monkeypatch): - wrapper = ActionFlowModelWrapper(pipeline=_ToyAlgo(), gradient_telemetry_cadence=0) - _capture_logs(monkeypatch, wrapper) - batch = _batch() - batch["source_a"]["fm"] = float("nan") - - with pytest.raises(RuntimeError, match="Non-finite Action Flow value"): - wrapper.training_step(batch, batch_idx=0) - - -def test_action_flow_wrapper_measures_component_gradient_intersections(monkeypatch): - wrapper = ActionFlowModelWrapper(pipeline=_ToyAlgo(), gradient_telemetry_cadence=1) - wrapper.flow_samples_per_content = 14 - wrapper.flow_mini_batch = 14 - logged = _capture_logs(monkeypatch, wrapper) - batch = OrderedDict( - source={ - "target": torch.zeros(2, 2, 3), - "fm": 1.0, - "reconstruction": 2.0, - "reconstruction_l1": 2.0, - "action_velocity": 3.0, - } - ) - - loss = wrapper.training_step(batch, batch_idx=0) - - assert loss.requires_grad - for name, expected in ( - ("Train/ActionFlow/GradientNorm/FM", 1.0), - ("Train/ActionFlow/GradientNorm/Reconstruction", 2.0), - ("Train/ActionFlow/GradientNorm/ActionVelocity", 3.0), - ( - "Train/ActionFlow/GradientCosine/FM__Reconstruction", - 1.0, - ), - ( - "Train/ActionFlow/GradientCosine/FM__ActionVelocity", - 1.0, - ), - ( - "Train/ActionFlow/GradientCosine/Reconstruction__ActionVelocity", - 1.0, - ), - ): - assert float(logged[name][0]) == pytest.approx(expected) - assert float( - logged[ - "Train/ActionFlow/GradientIntersectionParameterCount/FM__ActionVelocity" - ][0] - ) == pytest.approx(1.0) - assert float( - logged["Train/ActionFlow/Compute/FieldForwardCallsPerStep"][0] - ) == pytest.approx(1.0) - assert float( - logged["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"][0] - ) == pytest.approx(14.0) - assert float( - logged["Train/ActionFlow/Compute/FlowMiniBatchPerStep"][0] - ) == pytest.approx(14.0) - assert float( - logged["Train/ActionFlow/Compute/DecoderJVPCallsPerStep"][0] - ) == pytest.approx(1.0) - - manifest = wrapper._gradient_route_manifest - assert manifest is not None - assert tuple(manifest["routes"]) == ("FM", "Reconstruction", "ActionVelocity") - assert manifest["intersections"]["FM__ActionVelocity"] == ["nets.pipeline.anchor"] - core = {key: value for key, value in manifest.items() if key != "manifest_sha256"} - assert ( - manifest["manifest_sha256"] - == hashlib.sha256( - json.dumps( - core, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - ).encode("utf-8") - ).hexdigest() - ) - checkpoint = {} - wrapper.on_save_checkpoint(checkpoint) - assert checkpoint["action_flow_gradient_route_manifest"] == manifest - - -def test_action_flow_wrapper_exposes_generic_inference(): - wrapper = ActionFlowModelWrapper(pipeline=_ToyAlgo(), gradient_telemetry_cadence=0) - condition = torch.arange(6, dtype=torch.float32).reshape(2, 3) - - result = wrapper.forward_eval({"opaque_source": {"condition": condition}}) - - assert tuple(result) == ("opaque_source",) - torch.testing.assert_close(result["opaque_source"]["prediction"], condition) - - -def test_action_flow_wrapper_executes_real_pipeline_algo(monkeypatch): - wrapper = ActionFlowModelWrapper( - pipeline=PipelineAlgo(stages=[_ActionFlowHead()], device="cpu"), - gradient_telemetry_cadence=0, - ) - _capture_logs(monkeypatch, wrapper) - - loss = wrapper.training_step( - {"opaque_source": {"target": torch.zeros(2, 2, 3)}}, batch_idx=0 - ) - assert float(loss) == pytest.approx(6.0) - loss.backward() - anchor = wrapper.nets["pipeline"].stages[0].anchor - assert float(anchor.grad) == pytest.approx(12.0) - - result = wrapper.forward_eval({"opaque_source": {"condition": torch.zeros(2, 4)}}) - assert result["opaque_source"]["prediction"].shape == (2, 2, 3) - - -def test_action_flow_wrapper_uses_standard_optimizer_config(monkeypatch): - monkeypatch.setattr( - ActionFlowModelWrapper, - "_instantiate_model", - lambda _self, _config_tree: _ToyAlgo(), - ) - wrapper = ActionFlowModelWrapper( - config_tree={ - "model": { - "pipeline": {}, - "optimizer": { - "_target_": "torch.optim.AdamW", - "lr": 3.0e-5, - "betas": [0.9, 0.999], - "eps": 1.0e-8, - "weight_decay": 1.0e-4, - }, - "gradient_telemetry_cadence": 0, - } - }, - enable_grad_norm=False, - ) - wrapper._trainer = SimpleNamespace(model=wrapper) - - configured = wrapper.configure_optimizers() - - assert ( - ActionFlowModelWrapper.configure_optimizers is ModelWrapper.configure_optimizers - ) - optimizer = configured["optimizer"] - assert isinstance(optimizer, torch.optim.AdamW) - assert optimizer.param_groups[0]["lr"] == pytest.approx(3.0e-5) - assert optimizer.param_groups[0]["weight_decay"] == pytest.approx(1.0e-4) - - -def test_action_flow_wrapper_logs_composite_optimizer_family_rates(monkeypatch): - wrapper = ActionFlowModelWrapper( - pipeline=_ToyAlgo(), - gradient_telemetry_cadence=0, - ) - wrapper._trainer = SimpleNamespace( - optimizers=[ - SimpleNamespace( - adamw=SimpleNamespace(param_groups=[{"lr": 2.5e-5}]), - muon=SimpleNamespace(param_groups=[{"lr": 7.5e-5}]), - ) - ] - ) - logged = _capture_logs(monkeypatch, wrapper) - - wrapper._log_composite_optimizer_learning_rates() - - assert float(logged["Optimizer/LR/AdamW"][0]) == pytest.approx(2.5e-5) - assert float(logged["Optimizer/LR/Muon"][0]) == pytest.approx(7.5e-5) - for _, kwargs in logged.values(): - assert kwargs == {"on_step": True, "on_epoch": False, "sync_dist": False} - - -def test_action_flow_wrapper_has_no_specialized_route_or_model_imports(): - root = Path(__file__).parents[1] - sources = ( - root / "egomimic/pl_utils/pl_model_action_flow.py", - root / "egomimic/pl_utils/action_flow_diagnostic_forward.py", - ) - for path in sources: - source = path.read_text().lower() - assert "unite" not in source - for forbidden in ("usocket", "robot", "embodiment", "domain"): - assert forbidden not in source - - -class _LifecycleEvaluator: - action_flow_diagnostics_enabled = True - - def __init__(self): - self.model = None - self.started = 0 - self.ended = 0 - self.prediction = None - - def on_validation_start(self): - self.started += 1 - - def on_validation_step(self, batch, batch_idx, dataloader_idx=0): - del batch_idx, dataloader_idx - self.prediction = self.model.forward_eval(batch) - - def on_validation_end(self): - self.ended += 1 - - -def test_action_flow_validation_logs_components_and_processes_once(monkeypatch): - algo = _ToyAlgo(reconstruction_weight=10.0) - evaluator = _LifecycleEvaluator() - wrapper = ActionFlowModelWrapper( - pipeline=algo, - evaluator=evaluator, - gradient_telemetry_cadence=0, - ) - logged = {} - monkeypatch.setattr( - wrapper, - "log_dict", - lambda values, **kwargs: logged.update(values), - ) - - wrapper.on_validation_start() - wrapper.validation_step(_batch(), batch_idx=0) - wrapper.on_validation_epoch_end() - wrapper.on_validation_end() - - assert evaluator.model is wrapper - assert evaluator.started == 1 - assert evaluator.ended == 1 - # validation_step moves the batch once; the bound wrapper recognizes the - # active processed mapping when the evaluator requests ordinary inference. - assert algo.process_count == 1 - assert tuple(evaluator.prediction) == ("source_a", "source_b") - assert float(logged["Valid/ActionFlow/TotalLoss"]) == pytest.approx(60.0) - assert float(logged["Valid/ActionFlow/FlowMatchingLoss"]) == pytest.approx(4.0) - assert float(logged["Valid/ActionFlow/ReconstructionLoss"]) == pytest.approx(5.0) - assert float(logged["Valid/ActionFlow/ReconstructionL1"]) == pytest.approx(7.0) - assert float(logged["Valid/ActionFlow/ActionVelocityLoss"]) == pytest.approx(6.0) - - -def test_action_flow_validation_preserves_ordinary_evaluator_model(monkeypatch): - algo = _ToyAlgo() - evaluator = _LifecycleEvaluator() - evaluator.action_flow_diagnostics_enabled = False - evaluator.model = algo - wrapper = ActionFlowModelWrapper( - pipeline=algo, - evaluator=evaluator, - gradient_telemetry_cadence=0, - ) - monkeypatch.setattr(wrapper, "log_dict", lambda *_args, **_kwargs: None) - - wrapper.on_validation_start() - wrapper.validation_step(_batch(), batch_idx=0) - - assert evaluator.model is algo - assert algo.process_count == 1 - assert tuple(evaluator.prediction) == ("source_a", "source_b") - - -class _TinySequenceModule(nn.Module): - def __init__(self): - super().__init__() - self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) - - def forward(self, value): - for block in self.blocks: - value = torch.tanh(block(value)) - return value - - -class _TinyField(nn.Module): - def __init__(self): - super().__init__() - self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) - - def forward( - self, - value, - time, - condition, - *, - condition_drop_mask=None, - ): - if condition_drop_mask is None: - condition_drop_mask = torch.zeros( - value.shape[0], dtype=torch.bool, device=value.device - ) - effective = torch.where( - condition_drop_mask.unsqueeze(-1), - torch.zeros_like(condition), - condition, - ) - hidden = value + time[:, None, None] + effective[:, None, :2] - for block in self.blocks: - hidden = torch.tanh(block(hidden)) - return hidden - - -class _BatchSensitiveDecoder(_TinySequenceModule): - """Expose accidental coalescing of diagnostic outer axes into the batch.""" - - def forward(self, value): - decoded = super().forward(value) - return decoded + float(value.shape[0]) * 1.0e-3 - - -def _diagnostic_wrapper(): - torch.manual_seed(9) - encoder = _TinySequenceModule() - field = _TinyField() - decoder = _TinySequenceModule() - wrapper = ActionFlowModelWrapper( - pipeline=PipelineAlgo( - stages=[ - ContentEncoderStage(encoder=encoder), - ConditionalVelocityStage(field=field, num_inference_steps=2), - ContentDecoderStage(decoder=decoder), - ], - device="cpu", - ), - gradient_telemetry_cadence=0, - ) - wrapper.eval() - return wrapper, encoder, field, decoder - - -@torch.inference_mode() -def _inference_mode_diagnostics(wrapper, batch): - return wrapper.forward_action_flow_diagnostics( - batch, - raw_noise_levels=[0.0, 0.5, 0.75, 1.0], - noise_seed=1234, - max_samples=None, - jacobian_samples=1, - capture_activations=True, - ) - - -def test_action_flow_diagnostics_are_deterministic_complete_and_ad_safe(): - wrapper, encoder, field, decoder = _diagnostic_wrapper() - batch = { - "opaque_source": { - "target": torch.randn(3, 2, 2), - "condition": torch.randn(3, 2), - } - } - - first = _inference_mode_diagnostics(wrapper, batch)["opaque_source"] - second = _inference_mode_diagnostics(wrapper, batch)["opaque_source"] - - assert wrapper.encoder_e is encoder - assert wrapper.field_v is field - assert wrapper.decoder_g is decoder - assert first["schema"] == "action-flow-validation-diagnostics/v1" - assert first["returned_samples"] == 3 - assert first["jacobian_samples"] == 1 - assert first["num_inference_steps"] == 2 - torch.testing.assert_close( - first["noise_levels"], torch.tensor([0.0, 0.5, 0.75, 1.0]) - ) - torch.testing.assert_close( - first["fixed_level_field_evaluations"], torch.tensor([0, 1, 2, 2]) - ) - torch.testing.assert_close(first["latent/noise"], second["latent/noise"]) - torch.testing.assert_close( - first["latent/fixed_final"], second["latent/fixed_final"] - ) - torch.testing.assert_close(first["latent/fixed_final"][0], first["latent/clean"]) - - expected_shapes = { - "target": (3, 2, 2), - "condition": (3, 2), - "latent/clean": (3, 2, 2), - "latent/noise": (3, 2, 2), - "latent/generated": (3, 2, 2), - "latent/fixed_states": (4, 3, 2, 2), - "latent/predicted_clean": (4, 3, 2, 2), - "latent/fixed_final": (4, 3, 2, 2), - "latent/trajectory": (3, 3, 2, 2), - "field/predicted_velocity": (4, 3, 2, 2), - "field/velocity_residual": (4, 3, 2, 2), - "decoded/reconstruction": (3, 2, 2), - "decoded/noise": (3, 2, 2), - "decoded/generated": (3, 2, 2), - "decoded/fixed_states": (4, 3, 2, 2), - "decoded/predicted_clean": (4, 3, 2, 2), - "decoded/fixed_final": (4, 3, 2, 2), - "decoded/trajectory": (3, 3, 2, 2), - "activation/encoder_blocks": (2, 3, 2, 2), - "activation/field_blocks": (4, 2, 3, 2, 2), - "decoder_jacobian/clean_singular_values": (1, 4), - "decoder_jacobian/noise_singular_values": (1, 4), - "decoder_jacobian/fixed_singular_values": (4, 1, 4), - } - for key, shape in expected_shapes.items(): - assert first[key].shape == shape, key - assert bool(torch.isfinite(first[key]).all()), key - assert not first[key].requires_grad, key - torch.testing.assert_close( - first["activation/encoder_block_indices"], torch.tensor([0, 1]) - ) - torch.testing.assert_close( - first["activation/field_block_indices"], torch.tensor([0, 1]) - ) - torch.testing.assert_close( - first["decoder_jacobian/fixed_singular_values"][0], - first["decoder_jacobian/clean_singular_values"], - ) - torch.testing.assert_close( - first["decoder_jacobian/fixed_singular_values"][-1], - first["decoder_jacobian/noise_singular_values"], - ) - - -def test_action_flow_diagnostic_decoding_preserves_logical_batch_geometry(): - wrapper, _, _, _ = _diagnostic_wrapper() - decoder = _BatchSensitiveDecoder() - wrapper.model.pipeline.stages[2].decoder = decoder - result = wrapper.forward_action_flow_diagnostics( - { - "opaque_source": { - "target": torch.randn(3, 2, 2), - "condition": torch.randn(3, 2), - } - }, - raw_noise_levels=[0.0, 0.5, 1.0], - noise_seed=7, - jacobian_samples=1, - capture_activations=False, - )["opaque_source"] - - torch.testing.assert_close( - result["decoded/fixed_states"][0], result["decoded/reconstruction"] - ) - torch.testing.assert_close(result["decoded/trajectory"][0], result["decoded/noise"]) - - -def test_action_flow_diagnostic_sample_caps_are_independent(): - wrapper, _, _, _ = _diagnostic_wrapper() - result = wrapper.forward_action_flow_diagnostics( - { - "opaque_source": { - "target": torch.randn(5, 2, 2), - "condition": torch.randn(5, 2), - } - }, - raw_noise_levels=[0.25, 1.0], - noise_seed=1, - max_samples=4, - jacobian_samples=2, - )["opaque_source"] - - assert result["returned_samples"] == 4 - assert result["target"].shape[0] == 4 - assert result["activation/encoder_blocks"].shape[1] == 4 - assert result["activation/field_blocks"].shape[2] == 4 - assert result["decoder_jacobian/clean_singular_values"].shape[0] == 2 diff --git a/tests/test_action_flow_modules.py b/tests/test_action_flow_modules.py deleted file mode 100644 index 015f4d8bad..0000000000 --- a/tests/test_action_flow_modules.py +++ /dev/null @@ -1,220 +0,0 @@ -import inspect - -import pytest -import torch -from torch.func import jvp - -from egomimic.models.action_flow_codec import ( - ContextFreeSequenceDecoder, - ContextFreeSequenceEncoder, -) -from egomimic.models.action_flow_transformer import ( - AdaLNSequenceField, - _sinusoidal_time_embedding, -) - - -def _encoder() -> ContextFreeSequenceEncoder: - return ContextFreeSequenceEncoder( - input_dim=4, - latent_dim=8, - horizon=16, - hidden_dim=20, - depth=2, - num_heads=4, - feedforward_dim=80, - dropout=0.0, - ) - - -def _decoder() -> ContextFreeSequenceDecoder: - return ContextFreeSequenceDecoder( - latent_dim=8, - output_dim=4, - horizon=16, - hidden_dim=20, - depth=2, - num_heads=4, - feedforward_dim=80, - dropout=0.0, - ) - - -def _small_field(**overrides) -> AdaLNSequenceField: - arguments = { - "input_dim": 8, - "output_dim": 8, - "horizon": 16, - "condition_dim": 7, - "hidden_dim": 32, - "depth": 2, - "num_heads": 4, - "feedforward_dim": 64, - "time_embedding_dim": 32, - "dropout": 0.0, - "condition_dropout_probability": 0.3, - } - arguments.update(overrides) - return AdaLNSequenceField(**arguments) - - -def test_context_free_codec_shapes_signatures_and_parameter_budgets(): - encoder = _encoder() - decoder = _decoder() - action = torch.randn(3, 16, 4) - latent = encoder(action) - reconstruction = decoder(latent) - - assert latent.shape == (3, 16, 8) - assert reconstruction.shape == (3, 16, 4) - assert tuple(inspect.signature(encoder.forward).parameters) == ("content",) - assert tuple(inspect.signature(decoder.forward).parameters) == ("content",) - assert sum(parameter.numel() for parameter in encoder.parameters()) == 10_748 - assert sum(parameter.numel() for parameter in decoder.parameters()) == 10_744 - assert sum(parameter.numel() for parameter in encoder.parameters()) <= 12_000 - assert sum(parameter.numel() for parameter in decoder.parameters()) <= 12_000 - - -def test_context_free_codec_rejects_wrong_sequence_contract(): - encoder = _encoder() - decoder = _decoder() - with pytest.raises(ValueError, match="expected sequence shape"): - encoder(torch.randn(2, 15, 4)) - with pytest.raises(ValueError, match="expected sequence shape"): - decoder(torch.randn(2, 16, 7)) - - -def test_decoder_jvp_matches_finite_difference_and_has_higher_order_gradients(): - torch.manual_seed(11) - decoder = _decoder().double() - latent = torch.randn(2, 16, 8, dtype=torch.float64, requires_grad=True) - tangent = torch.randn_like(latent, requires_grad=True) - - _, decoded_tangent = jvp(decoder, (latent,), (tangent,)) - step = 1.0e-6 - finite_difference = ( - decoder(latent + step * tangent) - decoder(latent - step * tangent) - ) / (2.0 * step) - torch.testing.assert_close( - decoded_tangent, finite_difference, rtol=2.0e-5, atol=2.0e-6 - ) - - loss = decoded_tangent.square().mean() - named_parameters = tuple(decoder.named_parameters()) - parameter_gradients = torch.autograd.grad( - loss, - tuple(parameter for _, parameter in named_parameters), - allow_unused=True, - retain_graph=True, - ) - missing = { - name - for (name, _), gradient in zip(named_parameters, parameter_gradients) - if gradient is None - } - # An additive output bias cannot affect a Jacobian-vector product. Every - # other decoder parameter must remain reachable through the JVP objective. - assert missing == {"output_projection.bias"} - assert all( - bool(torch.isfinite(gradient).all()) - for gradient in parameter_gradients - if gradient is not None - ) - latent_gradient, tangent_gradient = torch.autograd.grad(loss, (latent, tangent)) - assert bool(torch.isfinite(latent_gradient).all()) - assert bool(torch.isfinite(tangent_gradient).all()) - - -def test_decoder_bfloat16_jvp_tracks_float32_reference(): - torch.manual_seed(17) - decoder = _decoder().eval() - latent = torch.randn(2, 16, 8) - tangent = torch.randn_like(latent) - _, float32_jvp = jvp(decoder, (latent,), (tangent,)) - - with torch.autocast("cpu", dtype=torch.bfloat16): - _, bfloat16_jvp = jvp(decoder, (latent,), (tangent,)) - - difference_rms = (float32_jvp - bfloat16_jvp.float()).square().mean().sqrt() - reference_rms = float32_jvp.square().mean().sqrt() - assert bool(torch.isfinite(bfloat16_jvp).all()) - assert float(difference_rms / reference_rms) < 0.02 - - -def test_adaln_field_shapes_condition_dropout_and_input_gradients(): - torch.manual_seed(13) - field = _small_field().train() - value = torch.randn(4, 16, 8, requires_grad=True) - time = torch.rand(4, 1) - condition = torch.randn(4, 7, requires_grad=True) - drop_mask = torch.tensor([False, True, True, False]) - - effective, returned_mask = field.apply_condition_dropout( - condition, drop_mask=drop_mask - ) - torch.testing.assert_close(effective[~drop_mask], condition[~drop_mask]) - torch.testing.assert_close(effective[drop_mask], field.null_condition.expand(2, -1)) - torch.testing.assert_close(returned_mask, drop_mask) - - output = field( - value, - time, - condition, - condition_drop_mask=drop_mask, - ) - assert output.shape == value.shape - output.square().mean().backward() - assert value.grad is not None and bool(torch.isfinite(value.grad).all()) - assert condition.grad is not None - assert bool(torch.isfinite(condition.grad).all()) - assert field.null_condition.grad is not None - assert bool(torch.isfinite(field.null_condition.grad).all()) - - -def test_normalized_time_embedding_resolves_endpoints_and_euler_steps(): - times = torch.tensor([0.0, 1.0 / 16.0, 0.5, 9.0 / 16.0, 1.0]) - embedding = _sinusoidal_time_embedding(times, 512, time_scale=1_000.0) - - endpoint_rms = (embedding[0] - embedding[-1]).square().mean().sqrt() - first_step_rms = (embedding[0] - embedding[1]).square().mean().sqrt() - middle_step_rms = (embedding[2] - embedding[3]).square().mean().sqrt() - assert float(endpoint_rms) > 0.75 - assert float(first_step_rms) > 0.6 - assert float(middle_step_rms) > 0.6 - - field = _small_field().eval() - value = torch.randn(1, 16, 8).expand(2, -1, -1) - condition = torch.randn(1, 7).expand(2, -1) - output = field(value, torch.tensor([0.0, 1.0]), condition) - assert not torch.allclose(output[0], output[1]) - - -def test_adaln_field_default_manifest_is_about_forty_million_parameters(): - field = AdaLNSequenceField( - input_dim=8, - output_dim=8, - horizon=16, - condition_dim=67, - hidden_dim=512, - depth=12, - num_heads=8, - feedforward_dim=2048, - time_embedding_dim=512, - dropout=0.0, - condition_dropout_probability=0.3, - ) - parameter_count = sum(parameter.numel() for parameter in field.parameters()) - assert 39_000_000 <= parameter_count <= 41_000_000 - - -def test_adaln_field_eval_uses_condition_unless_mask_is_explicit(): - field = _small_field().eval() - condition = torch.randn(2, 7) - effective, mask = field.apply_condition_dropout(condition) - torch.testing.assert_close(effective, condition) - assert not bool(mask.any()) - - explicit_mask = torch.ones(2, dtype=torch.bool) - effective, mask = field.apply_condition_dropout(condition, drop_mask=explicit_mask) - torch.testing.assert_close(effective, field.null_condition.expand_as(condition)) - assert bool(mask.all()) diff --git a/tests/test_action_flow_smoke_schedule.py b/tests/test_action_flow_smoke_schedule.py deleted file mode 100644 index af19c23258..0000000000 --- a/tests/test_action_flow_smoke_schedule.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Regressions for the actual Phoenix 12935484/12935488 verifier failures.""" - -from types import SimpleNamespace - -import pytest -import torch -from omegaconf import OmegaConf - -from tests.test_verify_action_flow_training_smoke import MODULE, _checkpoint_payload -from tools.validate_action_flow_config import ( - GRAPH_METHOD, - STOPGRAD_METHOD, - compose_experiment, -) - -# Exact six-key schedules printed by the failed final verifiers. The jobs' -# training steps exited 0; both experiments intentionally train jointly at 0. -OBSERVED_SCHEDULES = ( - ( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_s42", - STOPGRAD_METHOD, - { - "joint_objective_begins_at_global_step": 0, - "reconstruction_only_optimizer_steps": 0, - "joint_flow_weight": 1.0, - "joint_reconstruction_weight": 1.0, - "joint_action_velocity_weight": 1.0, - "schema_version": 1, - }, - ), - ( - "pusht/action_flow_bc_usocket_graph_section_s42", - GRAPH_METHOD, - { - "joint_objective_begins_at_global_step": 0, - "reconstruction_only_optimizer_steps": 0, - "joint_flow_weight": 1.0, - "joint_reconstruction_weight": 0.0, - "joint_action_velocity_weight": 1.0, - "schema_version": 1, - }, - ), -) - - -@pytest.mark.parametrize("experiment,method,schedule", OBSERVED_SCHEDULES) -@pytest.mark.parametrize("restored_warmup", (0, 1)) -def test_observed_joint_schedule_and_strict_reload( - tmp_path, monkeypatch, experiment, method, schedule, restored_warmup -): - config = compose_experiment(experiment, overrides=["hydra/launcher=basic"]) - checkpoint_dir = tmp_path / "checkpoints" - checkpoint_dir.mkdir() - payload = _checkpoint_payload() - payload["action_flow_loss_schedule"] = schedule - immutable = checkpoint_dir / "epoch-0-step-2.ckpt" - torch.save(payload, immutable) - (checkpoint_dir / "last.ckpt").symlink_to(immutable.name) - - class Wrapper: - reconstruction_only_warmup_steps = restored_warmup - encoder_e, field_v, decoder_g = object(), object(), object() - model = SimpleNamespace(pipeline=SimpleNamespace(stages=[])) - nets = SimpleNamespace(named_parameters=lambda **kwargs: []) - - @classmethod - def load_from_checkpoint(cls, *args, **kwargs): - assert kwargs["strict"] is True - return cls() - - def parameters(self): - return [SimpleNamespace(numel=lambda: MODULE.EXPECTED_PARAMETER_COUNT)] - - monkeypatch.setattr(MODULE, "ActionFlowModelWrapper", Wrapper) - monkeypatch.setattr(MODULE, "_validate_dimensions_and_modules", lambda *args: None) - monkeypatch.setattr(MODULE, "_validate_gradient_route_manifest", lambda *args: {}) - kwargs = dict( - reconstruction_weight=schedule["joint_reconstruction_weight"], - flow_weight=1.0, - method=method, - config=config, - ) - if restored_warmup: - with pytest.raises(MODULE.SmokeVerificationError, match="strict reload lost"): - MODULE._validate_checkpoint(tmp_path, **kwargs) - else: - result = MODULE._validate_checkpoint(tmp_path, **kwargs) - assert result["loss_schedule"] == schedule - assert result["strict_checkpoint_reload"] == "passed" - - -@pytest.mark.parametrize("key", tuple(OBSERVED_SCHEDULES[0][2])) -def test_every_schedule_field_remains_checked(key): - experiment, _, schedule = OBSERVED_SCHEDULES[0] - changed = {**schedule, key: schedule[key] + 1} - with pytest.raises(MODULE.SmokeVerificationError, match="unexpected.*schedule"): - MODULE._validate_checkpoint_loss_schedule( - changed, - compose_experiment(experiment, overrides=["hydra/launcher=basic"]), - reconstruction_weight=1, - flow_weight=1, - ) - - -@pytest.mark.parametrize("flow_weight", (1.0, 0.01)) -def test_actual_warmup_smoke_still_requires_configured_step_one(flow_weight): - config = compose_experiment( - "pusht/action_flow_bc_usocket_recon10_warmup10k_s42", - overrides=["hydra/launcher=basic"], - ) - # Exercise both verifier weights without reviving the retired flow001 leaf. - OmegaConf.update(config, "model.flow_weight", flow_weight) - # The maintained launcher reduces this experiment's full 10k warmup to one - # update for the real two-update warmup smoke. - OmegaConf.update(config, "model.reconstruction_only_warmup_steps", 1) - schedule = { - "joint_objective_begins_at_global_step": 1, - "reconstruction_only_optimizer_steps": 1, - "joint_flow_weight": flow_weight, - "joint_reconstruction_weight": 10.0, - "joint_action_velocity_weight": 1.0, - "schema_version": 1, - } - assert ( - MODULE._validate_checkpoint_loss_schedule( - schedule, config, reconstruction_weight=10, flow_weight=flow_weight - ) - == 1 - ) - schedule["joint_objective_begins_at_global_step"] = 0 - schedule["reconstruction_only_optimizer_steps"] = 0 - with pytest.raises(MODULE.SmokeVerificationError, match="unexpected.*schedule"): - MODULE._validate_checkpoint_loss_schedule( - schedule, config, reconstruction_weight=10, flow_weight=flow_weight - ) - - -def test_schedule_cannot_be_validated_without_resolved_config(): - with pytest.raises(MODULE.SmokeVerificationError, match="exact config"): - MODULE._validate_checkpoint_loss_schedule( - OBSERVED_SCHEDULES[0][2], None, reconstruction_weight=1, flow_weight=1 - ) - - -def test_joint_checkpoint_owners_reject_codec_and_parameter_aliases(): - from egomimic.pipeline.stages_action_flow import ( - RoutedContentDecoderStage, - RoutedContentEncoderStage, - ) - - def stages_with(encoders, decoders, field): - stages = [None] * 8 - stages[4] = ( - RoutedContentEncoderStage(encoders, route_key="embodiment") - if encoders - else SimpleNamespace(encoder={}) - ) - stages[6] = SimpleNamespace(field=field) - stages[7] = RoutedContentDecoderStage(decoders, route_key="embodiment") - return stages - - names = ("libero_panda", "libero_panda_sharpa") - encoders = {name: torch.nn.Linear(2, 2) for name in names} - decoders = {name: torch.nn.Linear(2, 2) for name in names} - field = torch.nn.Linear(2, 2) - assert ( - len(MODULE._joint_checkpoint_owners(stages_with(encoders, decoders, field))) - == 5 - ) - decoders[names[0]].weight = encoders[names[0]].weight - with pytest.raises(MODULE.SmokeVerificationError, match="share parameters"): - MODULE._joint_checkpoint_owners(stages_with(encoders, decoders, field)) - decoders[names[0]] = encoders[names[0]] - with pytest.raises(MODULE.SmokeVerificationError, match="aliased"): - MODULE._joint_checkpoint_owners(stages_with(encoders, decoders, field)) - with pytest.raises(MODULE.SmokeVerificationError, match="coverage"): - MODULE._joint_checkpoint_owners(stages_with({}, decoders, field)) - - -def test_joint_smoke_uses_unique_immutable_step_eight_without_rolling_last(tmp_path): - checkpoint = tmp_path / "epoch-0000-step-000000008.ckpt" - with pytest.raises(MODULE.SmokeVerificationError, match="one immutable"): - MODULE._joint_smoke_checkpoint_path(tmp_path) - checkpoint.write_bytes(b"path-contract-only") - assert MODULE._joint_smoke_checkpoint_path(tmp_path) == checkpoint - duplicate = tmp_path / "epoch-0001-step-000000008.ckpt" - duplicate.write_bytes(b"path-contract-only") - with pytest.raises(MODULE.SmokeVerificationError, match="one immutable"): - MODULE._joint_smoke_checkpoint_path(tmp_path) - duplicate.unlink() - (tmp_path / "last.ckpt").symlink_to(checkpoint.name) - with pytest.raises(MODULE.SmokeVerificationError, match="rolling"): - MODULE._joint_smoke_checkpoint_path(tmp_path) - - -def test_joint_history_requires_both_source_rows_at_step_seven(): - from tests.test_verify_action_flow_training_smoke import _history_row - - row = _history_row() - for key, value in list(row.items()): - if MODULE.SOURCE_LABEL in key: - for label in ("libero_panda", "libero_panda_sharpa"): - row[key.replace(MODULE.SOURCE_LABEL, label)] = value - row["Train/ActionFlow/Compute/FieldForwardCallsPerStep"] = 2.0 - row["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"] = 28.0 - for prefix in ( - "GradientCosine", - "GradientCosineDefined", - "GradientIntersectionParameterCount", - ): - row[f"Train/ActionFlow/{prefix}/FM__Reconstruction"] = 0.0 - row["Optimizer/LR/AdamW"] = row["Optimizer/LR/Muon"] = 2.5e-8 - options = dict( - method=MODULE.STOPGRAD_UNITE_METHOD, - source_labels=("libero_panda", "libero_panda_sharpa"), - minimum_step=7, - ) - assert MODULE._validate_history({7: row}, **options)["valid_step"] == 7 - with pytest.raises(MODULE.SmokeVerificationError, match="gradient telemetry"): - MODULE._validate_history({1: row}, **options) - del row["Train/MSE/libero_panda_sharpa"] - with pytest.raises(MODULE.SmokeVerificationError, match="gradient telemetry"): - MODULE._validate_history({7: row}, **options) - - -def test_joint_ema_reload_updates_every_alias_and_rejects_wrong_updates(): - from scripts.train.verify_libero_native_action_flow_smoke import ( - validate_joint_ema_reload, - ) - - model = torch.nn.Module() - model.model = torch.nn.Sequential(torch.nn.Linear(3, 2)) - model.nets = model.model - # The native normalizer rebuilds its ParameterDict during strict reload. - from egomimic.models.oat.model.common.dict_of_tensor_mixin import DictOfTensorMixin - - model.model.add_module( - "normalizer", - DictOfTensorMixin( - torch.nn.ParameterDict( - {"scale": torch.nn.Parameter(torch.ones(2), requires_grad=False)} - ) - ), - ) - settings = dict( - decay=0.9978, - use_warmup=False, - update_after_step=0, - inv_gamma=1.0, - power=0.75, - min_decay=0.0, - ) - cfg = dict(callbacks=dict(ema=settings)) - ema = { - name: torch.full_like(parameter, 7) - for name, parameter in model.named_parameters() - } - payload = dict( - global_step=8, ema_num_updates=8, ema_config=settings, ema_state_dict=ema - ) - result = validate_joint_ema_reload(model, payload, cfg) - assert result["state_parameter_aliases"] > result["canonical_parameters"] - assert all( - torch.equal(value, torch.full_like(value, 7)) - for value in model.state_dict().values() - ) - payload["ema_num_updates"] = 7 - with pytest.raises(ValueError, match="update count"): - validate_joint_ema_reload(model, payload, cfg) - payload["ema_num_updates"] = 8 - ema[next(iter(ema))].fill_(float("nan")) - with pytest.raises(ValueError, match="EMA tensor differs"): - validate_joint_ema_reload(model, payload, cfg) - - -def test_joint_smoke_cli_dispatch_is_explicit_and_requires_all_hashes( - tmp_path, monkeypatch -): - from scripts.train import verify_libero_native_action_flow_smoke as native - - experiment = "libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42" - args = MODULE._parser().parse_args( - [str(tmp_path), "--experiment", experiment, "--expected-head", "a" * 40] - ) - assert args.experiment == experiment - # Missing identities must fail before source inspection, checkpoint loads or API reads. - with pytest.raises(ValueError, match="every explicit expected SHA"): - MODULE.verify_smoke(tmp_path, experiment=experiment, expected_head="a" * 40) - captured = {} - - def routed(**kwargs): - captured.update(kwargs) - return {"dispatch_only": True} - - monkeypatch.setattr(native, "verify_joint_smoke", routed) - assert MODULE.verify_smoke( - tmp_path, - experiment=experiment, - expected_head="a" * 40, - expected_config_sha256="b" * 64, - expected_split_sha256="c" * 64, - expected_normalization_sha256="d" * 64, - expected_preflight_sha256="e" * 64, - ) == {"dispatch_only": True} - assert captured["expected_preflight_sha256"] == "e" * 64 - assert captured["run_dir"] == tmp_path - - -@pytest.mark.parametrize("transport", [None, "slurm-steps", "trainer-pidfd"]) -def test_joint_shared_child_rejects_unbound_signal_transport(transport): - import os - import subprocess - from pathlib import Path - - env = { - key: value - for key, value in os.environ.items() - if not key.startswith(("AF_", "ICE_")) - } - env["AF_EXPERIMENT"] = ( - "libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42" - ) - if transport is not None: - env["AF_CHECKPOINT_TRANSPORT"] = transport - child = ( - Path(__file__).resolve().parents[1] / "scripts/train/action_flow_run_child.sh" - ) - result = subprocess.run( - ["bash", str(child)], env=env, capture_output=True, text=True - ) - assert result.returncode == 2 - assert ( - "requires audited trainer-pidfd" - if transport != "trainer-pidfd" - else "PIDfd binding missing" - ) in result.stderr diff --git a/tests/test_action_flow_stages.py b/tests/test_action_flow_stages.py deleted file mode 100644 index 241c8c7031..0000000000 --- a/tests/test_action_flow_stages.py +++ /dev/null @@ -1,539 +0,0 @@ -from pathlib import Path - -import pytest -import torch -import torch.nn as nn - -from egomimic.pipeline.core import Pipeline, sum_losses -from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, - LatentBridgeStage, - RoutedContentDecoderStage, - RoutedContentEncoderStage, -) -from egomimic.pipeline.stages_sampler import GaussianLatentNoise - - -class _LastDimLinear(nn.Module): - def __init__(self, input_dim: int, output_dim: int): - super().__init__() - self.linear = nn.Linear(input_dim, output_dim) - - def forward(self, value): - return self.linear(value) - - -class _TinyField(nn.Module): - def __init__(self, latent_dim: int, condition_dim: int): - super().__init__() - self.state_weight = nn.Parameter(torch.tensor(0.25)) - self.time_weight = nn.Parameter(torch.tensor(0.5)) - self.condition_projection = nn.Linear(condition_dim, latent_dim) - self.null_condition = nn.Parameter(torch.zeros(condition_dim)) - self.seen_masks = [] - - def forward(self, value, time, condition, *, condition_drop_mask=None): - if condition_drop_mask is None: - raise AssertionError("the caller must supply the shared mask") - self.seen_masks.append(condition_drop_mask.clone()) - effective_condition = torch.where( - condition_drop_mask[:, None], - self.null_condition[None].expand_as(condition), - condition, - ) - projected = self.condition_projection(effective_condition) - return ( - self.state_weight * value - + self.time_weight * time[:, None, None] - + projected[:, None, :] - ) - - -def test_routed_codecs_are_private_and_select_exactly_one_route(): - us_encoder = _LastDimLinear(4, 3) - chain_encoder = _LastDimLinear(6, 3) - encoder = RoutedContentEncoderStage( - encoders={"usocket": us_encoder, "chain": chain_encoder}, - route_key="embodiment", - route_aliases={19: "usocket", 20: "chain"}, - ) - assert encoder.encoder["usocket"] is us_encoder - assert encoder.encoder["chain"] is chain_encoder - assert encoder.encoder["usocket"] is not encoder.encoder["chain"] - - us_batch = {"target": torch.randn(2, 5, 4), "embodiment": torch.tensor([19, 19])} - chain_batch = { - "target": torch.randn(2, 5, 6), - "embodiment": torch.tensor([20, 20]), - } - assert encoder(us_batch)["action_flow/clean_latent"].shape == (2, 5, 3) - assert encoder(chain_batch)["action_flow/clean_latent"].shape == (2, 5, 3) - - us_decoder = _LastDimLinear(3, 4) - chain_decoder = _LastDimLinear(3, 6) - decoder = RoutedContentDecoderStage( - decoders={"usocket": us_decoder, "chain": chain_decoder}, - route_key="embodiment", - route_aliases={19: "usocket", 20: "chain"}, - ) - assert decoder.decoder["usocket"] is not decoder.decoder["chain"] - latent = torch.randn(2, 5, 3) - assert decoder.execute( - {"action_flow/generated_latent": latent, "embodiment": torch.tensor([19, 19])}, - mode="inference", - )["pred_action"].shape == (2, 5, 4) - assert decoder.execute( - {"action_flow/generated_latent": latent, "embodiment": torch.tensor([20, 20])}, - mode="inference", - )["pred_action"].shape == (2, 5, 6) - - -def test_routed_codecs_reject_mixed_or_unknown_routes(): - stage = RoutedContentEncoderStage( - encoders={"usocket": _LastDimLinear(4, 3)}, - route_key="embodiment", - route_aliases={19: "usocket"}, - ) - with pytest.raises(ValueError, match="homogeneous"): - stage({"target": torch.randn(2, 5, 4), "embodiment": torch.tensor([19, 20])}) - with pytest.raises(KeyError, match="No routed module"): - stage({"target": torch.randn(2, 5, 4), "embodiment": torch.tensor([20, 20])}) - - -class _ConstantField(nn.Module): - def __init__(self, value: float): - super().__init__() - self.value = float(value) - self.seen_times = [] - self.seen_masks = [] - - def forward(self, value, time, condition, *, condition_drop_mask=None): - self.seen_times.append(time.clone()) - self.seen_masks.append(condition_drop_mask.clone()) - return torch.full_like(value, self.value) - - -def test_bridge_reuses_one_base_noise_and_drop_mask_across_independent_times(): - batch_size, count, horizon, latent_dim = 3, 5, 4, 2 - clean = torch.linspace( - -1.0, - 1.0, - batch_size * horizon * latent_dim, - ).reshape(batch_size, horizon, latent_dim) - clean.requires_grad_() - noise = clean.new_tensor( - [ - [[2.0, -2.0]] * horizon, - [[3.0, -3.0]] * horizon, - [[4.0, -4.0]] * horizon, - ] - ) - condition = torch.randn(batch_size, 7) - stage = LatentBridgeStage( - samples_per_content=count, - condition_dropout_probability=0.5, - ) - - torch.manual_seed(114) - output = stage( - { - "action_flow/clean_latent": clean, - "sampler/noise": noise, - "condition": condition, - } - ) - - base_index = torch.arange(batch_size).repeat_interleave(count) - assert torch.equal(output["action_flow/base_index"], base_index) - assert torch.equal(output["action_flow/noise"], noise[base_index]) - assert torch.equal(output["action_flow/condition"], condition[base_index]) - assert torch.equal( - output["action_flow/condition_drop_mask"], - output["action_flow/base_condition_drop_mask"][base_index], - ) - assert output["action_flow/condition_drop_mask"].dtype == torch.bool - - time = output["action_flow/time"] - assert time.shape == (batch_size * count,) - assert bool(((0.0 <= time) & (time < 1.0)).all()) - reshaped_time = time.reshape(batch_size, count) - assert all(int(torch.unique(row).numel()) > 1 for row in reshaped_time) - - clean_many = clean[base_index] - noise_many = noise[base_index] - expected_state = (1.0 - time[:, None, None]) * clean_many + time[ - :, None, None - ] * noise_many - torch.testing.assert_close(output["action_flow/state"], expected_state) - torch.testing.assert_close( - output["action_flow/target_velocity"], noise_many - clean_many - ) - - output["action_flow/state"].sum().backward() - assert clean.grad is not None - assert bool(torch.isfinite(clean.grad).all()) - assert float(clean.grad.abs().sum()) > 0.0 - - -def test_bridge_repeated_clean_and_condition_use_original_index_select_backward(): - clean = torch.randn(2, 3, 4, requires_grad=True) - condition = torch.randn(2, 5, requires_grad=True) - noise = torch.randn_like(clean) - stage = LatentBridgeStage( - samples_per_content=14, - condition_dropout_probability=0.0, - ) - torch.manual_seed(42) - output = stage( - { - "action_flow/clean_latent": clean, - "sampler/noise": noise, - "condition": condition, - } - ) - index = output["action_flow/base_index"] - direct_clean = clean.index_select(0, index) - direct_condition = condition.index_select(0, index) - torch.testing.assert_close(output["action_flow/condition"], direct_condition) - assert type(output["action_flow/condition"].grad_fn) is type( - direct_condition.grad_fn - ) - - # The bridge state and target velocity both depend on the repeated clean - # latent. Verify their full gradient route against the original gather. - time = output["action_flow/time"].reshape(-1, 1, 1) - direct_noise = noise.index_select(0, index) - direct_state = (1.0 - time) * direct_clean + time * direct_noise - direct_velocity = direct_noise - direct_clean - state_weights = torch.randn_like(direct_state) - velocity_weights = torch.randn_like(direct_velocity) - condition_weights = torch.randn_like(direct_condition) - actual_loss = ( - (output["action_flow/state"] * state_weights).sum() - + (output["action_flow/target_velocity"] * velocity_weights).sum() - + (output["action_flow/condition"] * condition_weights).sum() - ) - direct_loss = ( - (direct_state * state_weights).sum() - + (direct_velocity * velocity_weights).sum() - + (direct_condition * condition_weights).sum() - ) - actual_gradients = torch.autograd.grad( - actual_loss, (clean, condition), retain_graph=True - ) - direct_gradients = torch.autograd.grad(direct_loss, (clean, condition)) - for actual, direct in zip(actual_gradients, direct_gradients): - torch.testing.assert_close(actual, direct, rtol=0, atol=0) - - -def test_base_noise_and_bridge_times_are_resampled_online(): - sampler = GaussianLatentNoise(num_tokens=4, latent_dim=3) - bridge = LatentBridgeStage( - samples_per_content=3, - condition_dropout_probability=0.0, - ) - condition = torch.randn(2, 5) - clean = torch.randn(2, 4, 3) - - torch.manual_seed(71) - first_noise = sampler({"condition": condition})["sampler/noise"].clone() - first = bridge( - { - "condition": condition, - "sampler/noise": first_noise, - "action_flow/clean_latent": clean, - } - ) - second_noise = sampler({"condition": condition})["sampler/noise"].clone() - second = bridge( - { - "condition": condition, - "sampler/noise": second_noise, - "action_flow/clean_latent": clean, - } - ) - - assert not torch.equal(first_noise, second_noise) - assert not torch.equal(first["action_flow/time"], second["action_flow/time"]) - for output, base_noise in ((first, first_noise), (second, second_noise)): - base_index = output["action_flow/base_index"] - assert torch.equal(output["action_flow/noise"], base_noise[base_index]) - - -def test_training_objective_preserves_all_joint_gradient_routes(): - encoder = _LastDimLinear(4, 3) - field = _TinyField(latent_dim=3, condition_dim=5) - decoder = _LastDimLinear(3, 4) - stages = ( - ContentEncoderStage(encoder), - LatentBridgeStage( - samples_per_content=4, - condition_dropout_probability=0.0, - ), - ConditionalVelocityStage(field, num_inference_steps=3), - ContentDecoderStage(decoder), - ActionFlowObjectiveStage( - flow_weight=1.0, - reconstruction_weight=10.0, - action_velocity_weight=1.0, - ), - ) - target = torch.randn(2, 6, 4) - condition = torch.randn(2, 5, requires_grad=True) - output = { - "target": target, - "condition": condition, - "sampler/noise": torch.randn(2, 6, 3), - } - - torch.manual_seed(9) - for stage in stages: - output = stage(output) - - component_keys = ( - "log/action_flow_fm", - "log/action_flow_reconstruction", - "log/action_flow_reconstruction_l1", - "log/action_flow_action_velocity", - ) - assert all(output[key].requires_grad for key in component_keys) - assert torch.equal(sum_losses(output), output["loss/action_flow"]) - output["loss/action_flow"].backward() - - for module in (encoder, field, decoder): - gradients = [parameter.grad for parameter in module.parameters()] - assert any( - gradient is not None - and bool(torch.isfinite(gradient).all()) - and float(gradient.abs().sum()) > 0.0 - for gradient in gradients - ) - assert condition.grad is not None - assert float(condition.grad.abs().sum()) > 0.0 - - -@pytest.mark.parametrize("jvp_checkpointing", [False, True]) -def test_decoder_jvp_matches_explicit_linear_jacobian_and_is_differentiable( - jvp_checkpointing, -): - decoder = _LastDimLinear(2, 3) - with torch.no_grad(): - decoder.linear.weight.copy_( - torch.tensor([[1.0, 2.0], [-3.0, 4.0], [0.5, -0.25]]) - ) - decoder.linear.bias.copy_(torch.tensor([7.0, 8.0, 9.0])) - stage = ContentDecoderStage(decoder, jvp_activation_checkpointing=jvp_checkpointing) - clean = torch.randn(2, 4, 2) - state = torch.randn(6, 4, 2, requires_grad=True) - residual = torch.randn(6, 4, 2, requires_grad=True) - - output = stage( - { - "action_flow/clean_latent": clean, - "action_flow/state": state, - "action_flow/velocity_residual": residual, - } - ) - expected = torch.einsum("...d,od->...o", residual, decoder.linear.weight) - torch.testing.assert_close( - output["action_flow/decoded_velocity_residual"], expected - ) - - output["action_flow/decoded_velocity_residual"].square().mean().backward() - assert residual.grad is not None - assert decoder.linear.weight.grad is not None - assert float(decoder.linear.weight.grad.abs().sum()) > 0.0 - - -def test_checkpointed_nonlinear_decoder_jvp_matches_value_and_gradients(): - torch.manual_seed(23) - decoder = nn.Sequential(nn.Linear(3, 8), nn.Tanh(), nn.Linear(8, 4)) - stage = ContentDecoderStage(decoder) - clean = torch.randn(2, 5, 3) - initial_state = torch.randn(6, 5, 3) - initial_residual = torch.randn(6, 5, 3) - - def run(checkpointed): - stage.jvp_activation_checkpointing = checkpointed - decoder.zero_grad(set_to_none=True) - state = initial_state.clone().requires_grad_() - residual = initial_residual.clone().requires_grad_() - result = stage( - { - "action_flow/clean_latent": clean, - "action_flow/state": state, - "action_flow/velocity_residual": residual, - } - )["action_flow/decoded_velocity_residual"] - result.square().mean().backward() - return ( - result.detach().clone(), - state.grad.detach().clone(), - residual.grad.detach().clone(), - tuple( - None if p.grad is None else p.grad.detach().clone() - for p in decoder.parameters() - ), - ) - - direct = run(False) - recomputed = run(True) - for expected, actual in zip(direct[:3], recomputed[:3]): - torch.testing.assert_close(actual, expected) - for expected, actual in zip(direct[3], recomputed[3]): - if expected is None or actual is None: - assert expected is None and actual is None - else: - torch.testing.assert_close(actual, expected) - - -def test_objective_averages_k_samples_and_applies_only_declared_weights(): - target = torch.zeros(2, 3, 4) - reconstruction = torch.ones_like(target) - residual = torch.full((28, 3, 8), 2.0) - decoded_residual = torch.full((28, 3, 4), 3.0) - values = { - "target": target, - "action_flow/reconstruction": reconstruction, - "action_flow/velocity_residual": residual, - "action_flow/decoded_velocity_residual": decoded_residual, - } - - rec1 = ActionFlowObjectiveStage(reconstruction_weight=1.0)(dict(values)) - rec10 = ActionFlowObjectiveStage(reconstruction_weight=10.0)(dict(values)) - - assert float(rec1["log/action_flow_fm"]) == pytest.approx(4.0) - assert float(rec1["log/action_flow_reconstruction"]) == pytest.approx(1.0) - assert float(rec1["log/action_flow_action_velocity"]) == pytest.approx(9.0) - assert float(rec1["loss/action_flow"]) == pytest.approx(14.0) - assert float(rec10["loss/action_flow"]) == pytest.approx(23.0) - - -def test_objective_can_sum_fourteen_flow_sample_means_like_released_unite(): - values = { - "target": torch.zeros(2, 3, 4), - "action_flow/reconstruction": torch.ones(2, 3, 4), - "action_flow/velocity_residual": torch.full((28, 3, 8), 2.0), - "action_flow/decoded_velocity_residual": torch.full((28, 3, 4), 3.0), - } - output = ActionFlowObjectiveStage( - flow_aggregation="sum_samples", - flow_samples_per_content=14, - )(values) - assert float(output["log/action_flow_fm"]) == pytest.approx(56.0) - assert float(output["loss/action_flow"]) == pytest.approx(66.0) - - -def test_reverse_euler_is_unguided_and_uses_the_same_field_and_decoder(): - field = _ConstantField(2.0) - velocity = ConditionalVelocityStage(field, num_inference_steps=4) - decoder = nn.Identity() - content = ContentDecoderStage(decoder) - noise = torch.full((2, 3, 2), 3.0) - condition = torch.randn(2, 5) - - output = velocity.execute( - {"sampler/noise": noise, "condition": condition}, - mode="inference", - ) - output = content.execute(output, mode="inference") - - torch.testing.assert_close( - output["action_flow/generated_latent"], torch.ones_like(noise) - ) - torch.testing.assert_close(output["pred_action"], torch.ones_like(noise)) - assert output["action_flow/trajectory"].shape == (5, 2, 3, 2) - expected_times = (1.0, 0.75, 0.5, 0.25) - assert len(field.seen_times) == len(expected_times) - for seen, expected in zip(field.seen_times, expected_times): - torch.testing.assert_close(seen, torch.full((2,), expected)) - assert all(not bool(mask.any()) for mask in field.seen_masks) - assert velocity.field is field - assert content.decoder is decoder - - -def test_mode_contracts_form_one_static_train_and_inference_graph(): - encoder = ContentEncoderStage(_LastDimLinear(4, 3)) - bridge = LatentBridgeStage(samples_per_content=2) - velocity = ConditionalVelocityStage(_TinyField(3, 5), num_inference_steps=2) - decoder = ContentDecoderStage(_LastDimLinear(3, 4)) - objective = ActionFlowObjectiveStage() - pipeline = Pipeline([encoder, bridge, velocity, decoder, objective]) - - training, training_excluded = pipeline.plan( - ["target", "condition", "sampler/noise"], mode="train" - ) - inference, inference_excluded = pipeline.plan( - ["condition", "sampler/noise"], mode="inference" - ) - - assert training == [encoder, bridge, velocity, decoder, objective] - assert training_excluded == [] - assert inference == [velocity, decoder] - assert inference_excluded == [ - (encoder, [""]), - (bridge, [""]), - (objective, [""]), - ] - assert velocity.contract("inference") == ( - ("sampler/noise", "condition"), - ( - "action_flow/generated_latent", - "action_flow/trajectory", - "log/action_flow_inference_steps", - ), - ) - assert decoder.contract("inference") == ( - ("action_flow/generated_latent",), - ("pred_action",), - ) - - -def test_stage_source_keeps_the_pipeline_boundary_generic(): - source = ( - (Path(__file__).parents[1] / "egomimic/pipeline/stages_action_flow.py") - .read_text() - .lower() - ) - for forbidden in ( - "yam_bimanual", - "human_bimanual", - "pushshapes_sim", - "libero", - "ac_key", - "action_key", - "egomimic.models", - "unite", - "monotonic", - "scale_loss", - "noisy_reconstruction", - ): - assert forbidden not in source - - -@pytest.mark.parametrize( - "kwargs,match", - [ - ({"samples_per_content": 0}, "positive"), - ({"condition_dropout_probability": -0.1}, r"\[0, 1\]"), - ({"condition_dropout_probability": 1.1}, r"\[0, 1\]"), - ], -) -def test_bridge_rejects_invalid_sampling_contract(kwargs, match): - with pytest.raises(ValueError, match=match): - LatentBridgeStage(**kwargs) - - -def test_bridge_rejects_noise_that_does_not_match_the_clean_latent(): - stage = LatentBridgeStage(samples_per_content=2) - with pytest.raises(ValueError, match="noise must match"): - stage( - { - "action_flow/clean_latent": torch.randn(2, 4, 3), - "sampler/noise": torch.randn(2, 4, 4), - "condition": torch.randn(2, 5), - } - ) diff --git a/tests/test_action_flow_training_behavior.py b/tests/test_action_flow_training_behavior.py deleted file mode 100644 index f5d12405f2..0000000000 --- a/tests/test_action_flow_training_behavior.py +++ /dev/null @@ -1,988 +0,0 @@ -import copy -import gc -import hashlib -import json -import weakref -from collections import OrderedDict -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -import torch.nn as nn - -from egomimic.eval.pipeline_diagnostics import ActionFlowDiagnosticProvider -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.core import Stage -from egomimic.pipeline.stages_action_flow import ( - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, -) -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.pl_utils.training_behavior_action_flow import ( - ActionFlowTrainingBehavior, -) - - -def _action_flow_wrapper(*, gradient_telemetry_cadence=0, **kwargs): - warmup_steps = kwargs.pop("reconstruction_only_warmup_steps", None) - return ModelWrapper( - training_behavior=ActionFlowTrainingBehavior( - gradient_telemetry_cadence=gradient_telemetry_cadence, - reconstruction_only_warmup_steps=warmup_steps, - ), - diagnostic_provider=ActionFlowDiagnosticProvider(), - **kwargs, - ) - - -def test_training_behavior_context_is_explicit_non_owning_and_stateless(): - wrapper = _action_flow_wrapper(pipeline=_ToyAlgo()) - behavior = wrapper.training_behavior - assert behavior.context is wrapper - assert wrapper.diagnostic_provider.capability == "action_flow" - with pytest.raises(AttributeError): - _ = behavior.model - with pytest.raises(TypeError, match="cannot own modules"): - behavior.accidental_model = nn.Linear(1, 1) - - copied = copy.deepcopy(wrapper) - assert copied.training_behavior.context is copied - assert copied.training_behavior is not behavior - - wrapper_ref = weakref.ref(wrapper) - del wrapper - gc.collect() - assert wrapper_ref() is None - with pytest.raises(RuntimeError, match="not bound"): - _ = behavior.context - - -class _ToyPipeline(nn.Module): - def __init__(self): - super().__init__() - self.anchor = nn.Parameter(torch.ones(())) - - -class _ToyAlgo: - def __init__( - self, *, reconstruction_weight=1.0, flow_weight=1.0, total_metric_delta=0.0 - ): - self.nets = nn.ModuleDict({"pipeline": _ToyPipeline()}) - self.device = torch.device("cpu") - self.reconstruction_weight = float(reconstruction_weight) - self.flow_weight = float(flow_weight) - self.action_velocity_weight = 1.0 - self.total_metric_delta = float(total_metric_delta) - self.process_count = 0 - - def process_batch_for_training(self, batch): - self.process_count += 1 - return OrderedDict((source, dict(values)) for source, values in batch.items()) - - def forward_training(self, batch): - anchor = self.nets["pipeline"].anchor - results = OrderedDict() - for source, values in batch.items(): - fm = anchor * anchor.new_tensor(values["fm"]) - reconstruction = anchor * anchor.new_tensor(values["reconstruction"]) - reconstruction_l1 = anchor * anchor.new_tensor(values["reconstruction_l1"]) - action_velocity = anchor * anchor.new_tensor(values["action_velocity"]) - total = ( - self.flow_weight * fm - + self.reconstruction_weight * reconstruction - + action_velocity - ) - results[source] = { - "target": values["target"], - "loss/action_flow": total, - "log/action_flow_total": total + self.total_metric_delta, - "log/action_flow_fm": fm, - "log/action_flow_reconstruction": reconstruction, - "log/action_flow_reconstruction_l1": reconstruction_l1, - "log/action_flow_action_velocity": action_velocity, - "log/action_flow_decoded_noise_moments": anchor.new_zeros(()), - "log/action_flow_decoded_noise_mean_penalty": anchor.new_zeros(()), - "log/action_flow_decoded_noise_covariance_penalty": anchor.new_zeros( - () - ), - "log/latent_rms": anchor.detach(), - } - return results - - @torch.inference_mode() - def forward_eval(self, batch): - anchor = self.nets["pipeline"].anchor - return OrderedDict( - (source, {"prediction": values["condition"] * anchor}) - for source, values in batch.items() - ) - - -class _ActionFlowHead(Stage): - reads = ("target",) - writes = ( - "loss/action_flow", - "log/action_flow_total", - "log/action_flow_fm", - "log/action_flow_reconstruction", - "log/action_flow_reconstruction_l1", - "log/action_flow_action_velocity", - ) - reads_by_mode = {"inference": ("condition",)} - writes_by_mode = {"inference": ("prediction",)} - - def __init__(self): - super().__init__() - self.anchor = nn.Parameter(torch.ones(())) - - def execute(self, batch, *, mode): - if mode == "inference": - batch["prediction"] = self.anchor.expand( - int(batch["condition"].shape[0]), 2, 3 - ) - return batch - fm = self.anchor.square() - reconstruction = (self.anchor - batch["target"]).square().mean() - reconstruction_l1 = (self.anchor - batch["target"]).abs().mean() - action_velocity = (2.0 * self.anchor).square() - total = fm + reconstruction + action_velocity - batch.update( - { - "loss/action_flow": total, - "log/action_flow_total": total, - "log/action_flow_fm": fm, - "log/action_flow_reconstruction": reconstruction, - "log/action_flow_reconstruction_l1": reconstruction_l1, - "log/action_flow_action_velocity": action_velocity, - "log/action_flow_decoded_noise_moments": self.anchor.new_zeros(()), - "log/action_flow_decoded_noise_mean_penalty": self.anchor.new_zeros(()), - "log/action_flow_decoded_noise_covariance_penalty": self.anchor.new_zeros( - () - ), - } - ) - return batch - - def forward(self, batch): - return self.execute(batch, mode="train") - - -def _batch(): - return OrderedDict( - source_a={ - "target": torch.zeros(1, 2, 3), - "condition": torch.zeros(1, 3), - "fm": 1.0, - "reconstruction": 2.0, - "reconstruction_l1": 4.0, - "action_velocity": 3.0, - }, - source_b={ - "target": torch.zeros(3, 2, 3), - "condition": torch.zeros(3, 3), - "fm": 5.0, - "reconstruction": 6.0, - "reconstruction_l1": 8.0, - "action_velocity": 7.0, - }, - ) - - -def test_training_metrics_match_action_flow_objective_stage_outputs(): - assert ActionFlowTrainingBehavior._metric_specs == ( - ("TotalLoss", "log/action_flow_total"), - ("FlowMatchingLoss", "log/action_flow_fm"), - ("ReconstructionLoss", "log/action_flow_reconstruction"), - ("ReconstructionL1", "log/action_flow_reconstruction_l1"), - ("ActionVelocityLoss", "log/action_flow_action_velocity"), - ) - - -def _capture_logs(monkeypatch, wrapper): - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **kwargs: logged.__setitem__(name, (value, kwargs)), - ) - return logged - - -def test_action_flow_wrapper_uses_only_explicit_optimizer_total(monkeypatch): - wrapper = _action_flow_wrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0), - gradient_telemetry_cadence=0, - ) - logged = _capture_logs(monkeypatch, wrapper) - - loss = wrapper.training_step(_batch(), batch_idx=0) - - # Sample-weighted components are fm=4, reconstruction=5, action=6, so the - # configured optimizer total is 4 + 10*5 + 6 = 60. Summing every metric and - # the total would incorrectly return 75. - assert float(loss) == pytest.approx(60.0) - assert float(loss) != pytest.approx(75.0) - expected = { - "Train/ActionFlow/TotalLoss": 60.0, - "Train/ActionFlow/FlowMatchingLoss": 4.0, - "Train/ActionFlow/ReconstructionLoss": 5.0, - "Train/ActionFlow/ReconstructionL1": 7.0, - "Train/ActionFlow/ActionVelocityLoss": 6.0, - } - for name, expected_value in expected.items(): - value, kwargs = logged[name] - assert float(value) == pytest.approx(expected_value) - assert kwargs == { - "on_step": True, - "on_epoch": True, - "sync_dist": False, - "batch_size": 4, - } - assert "Train/action_flow_total" not in logged - assert "Train/latent_rms" in logged - assert float(logged["Train/ActionFlow/TotalLoss/source_a"][0]) == pytest.approx( - 24.0 - ) - assert float(logged["Train/ActionFlow/TotalLoss/source_b"][0]) == pytest.approx( - 72.0 - ) - assert float(logged["Train/MSE"][0]) == pytest.approx(5.0) - assert float(logged["Train/MSE/source_a"][0]) == pytest.approx(2.0) - assert float(logged["Train/MSE/source_b"][0]) == pytest.approx(6.0) - - -def test_reconstruction_only_warmup_then_joint_objective(monkeypatch): - wrapper = _action_flow_wrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0), - gradient_telemetry_cadence=0, - reconstruction_only_warmup_steps=2, - ) - logged = _capture_logs(monkeypatch, wrapper) - - warmup_loss = wrapper.training_step(_batch(), batch_idx=0) - assert float(warmup_loss) == pytest.approx(50.0) - assert float(logged["Train/ActionFlow/FlowMatchingLoss"][0]) == pytest.approx(4.0) - assert float(logged["Train/ActionFlow/ActionVelocityLoss"][0]) == pytest.approx(6.0) - assert float(logged["Train/ActionFlow/Schedule/ReconstructionOnly"][0]) == 1.0 - assert float(logged["Train/ActionFlow/Schedule/EffectiveFlowWeight"][0]) == 0.0 - - predictions = wrapper.model.forward_training( - wrapper.model.process_batch_for_training(_batch()) - ) - behavior = wrapper.training_behavior - assert not behavior._apply_reconstruction_only_warmup(predictions, optimizer_step=2) - _, components, joint_loss, _ = behavior._source_values(predictions) - assert float(joint_loss) == pytest.approx(60.0) - assert float(components["TotalLoss"]) == pytest.approx(60.0) - - -def test_reconstruction_only_warmup_is_loaded_from_training_config_tree(monkeypatch): - monkeypatch.setattr( - ModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(reconstruction_weight=10.0), - ) - wrapper = _action_flow_wrapper( - config_tree={ - "model": { - "pipeline": {}, - "reconstruction_only_warmup_steps": 2, - } - }, - gradient_telemetry_cadence=0, - ) - - assert wrapper.training_behavior.reconstruction_only_warmup_steps == 2 - checkpoint = {} - wrapper.on_save_checkpoint(checkpoint) - assert checkpoint["action_flow_loss_schedule"] == { - "joint_objective_begins_at_global_step": 2, - "reconstruction_only_optimizer_steps": 2, - "joint_flow_weight": 1.0, - "joint_reconstruction_weight": 10.0, - "joint_action_velocity_weight": 1.0, - "schema_version": 1, - } - - -def test_flow_mini_batch_is_bound_to_full_parallel_flow_set(monkeypatch): - monkeypatch.setattr( - ModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(), - ) - wrapper = _action_flow_wrapper( - config_tree={ - "model": { - "pipeline": {}, - "flow_samples_per_content": 14, - "flow_mini_batch": 14, - } - }, - gradient_telemetry_cadence=0, - ) - - assert wrapper.training_behavior.flow_samples_per_content == 14 - assert wrapper.training_behavior.flow_mini_batch == 14 - - -@pytest.mark.parametrize("value", [0, -1, 1.5, True]) -def test_flow_mini_batch_rejects_invalid_values(monkeypatch, value): - monkeypatch.setattr( - ModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(), - ) - with pytest.raises(ValueError, match="flow_mini_batch must be a positive integer"): - _action_flow_wrapper( - config_tree={ - "model": { - "pipeline": {}, - "flow_samples_per_content": 14, - "flow_mini_batch": value, - } - }, - gradient_telemetry_cadence=0, - ) - - -def test_flow_mini_batch_rejects_partial_flow_chunking(monkeypatch): - monkeypatch.setattr( - ModelWrapper, - "_instantiate_model", - lambda self, config_tree: _ToyAlgo(), - ) - with pytest.raises(ValueError, match="must equal flow_samples_per_content"): - _action_flow_wrapper( - config_tree={ - "model": { - "pipeline": {}, - "flow_samples_per_content": 14, - "flow_mini_batch": 7, - } - }, - gradient_telemetry_cadence=0, - ) - - -def test_joint_flow_weight_is_applied_and_logged(monkeypatch): - wrapper = _action_flow_wrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0, flow_weight=0.01), - gradient_telemetry_cadence=0, - ) - logged = _capture_logs(monkeypatch, wrapper) - - loss = wrapper.training_step(_batch(), batch_idx=0) - - assert float(loss) == pytest.approx(56.04) - assert float( - logged["Train/ActionFlow/Schedule/EffectiveFlowWeight"][0] - ) == pytest.approx(0.01) - - -@pytest.mark.parametrize("value", [-1, 1.5, True]) -def test_reconstruction_only_warmup_rejects_invalid_steps(value): - with pytest.raises(ValueError, match="nonnegative integer"): - _action_flow_wrapper( - pipeline=_ToyAlgo(reconstruction_weight=10.0), - gradient_telemetry_cadence=0, - reconstruction_only_warmup_steps=value, - ) - - -def test_action_flow_wrapper_rejects_total_metric_drift(monkeypatch): - wrapper = _action_flow_wrapper( - pipeline=_ToyAlgo(total_metric_delta=1.0), - gradient_telemetry_cadence=0, - ) - _capture_logs(monkeypatch, wrapper) - - with pytest.raises(RuntimeError, match="optimizer loss and total metric disagree"): - wrapper.training_step(_batch(), batch_idx=0) - - -def test_action_flow_wrapper_rejects_non_finite_components(monkeypatch): - wrapper = _action_flow_wrapper(pipeline=_ToyAlgo(), gradient_telemetry_cadence=0) - _capture_logs(monkeypatch, wrapper) - batch = _batch() - batch["source_a"]["fm"] = float("nan") - - with pytest.raises(RuntimeError, match="Non-finite metric"): - wrapper.training_step(batch, batch_idx=0) - - -def test_distributed_gradient_makes_strided_autograd_values_contiguous(monkeypatch): - gradient = torch.arange(12, dtype=torch.float32).reshape(3, 4).T - original = gradient.clone() - observed = {} - - monkeypatch.setattr(torch.distributed, "is_available", lambda: True) - monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True) - monkeypatch.setattr(torch.distributed, "get_world_size", lambda: 2) - - def fake_all_reduce(value, op): - observed["contiguous"] = value.is_contiguous() - observed["op"] = op - value.mul_(2) - - monkeypatch.setattr(torch.distributed, "all_reduce", fake_all_reduce) - - reduced = ActionFlowTrainingBehavior._distributed_gradient(gradient) - - assert observed == { - "contiguous": True, - "op": torch.distributed.ReduceOp.SUM, - } - assert reduced.is_contiguous() - assert torch.equal(reduced, original) - assert torch.equal(gradient, original) - - -def test_action_flow_wrapper_measures_component_gradient_intersections(monkeypatch): - wrapper = _action_flow_wrapper(pipeline=_ToyAlgo(), gradient_telemetry_cadence=1) - wrapper.training_behavior.flow_samples_per_content = 14 - wrapper.training_behavior.flow_mini_batch = 14 - monkeypatch.setattr( - wrapper.training_behavior, "_fm_endpoint_detached", lambda: True - ) - logged = _capture_logs(monkeypatch, wrapper) - batch = OrderedDict( - source={ - "target": torch.zeros(2, 2, 3), - "fm": 1.0, - "reconstruction": 2.0, - "reconstruction_l1": 2.0, - "action_velocity": 3.0, - } - ) - - loss = wrapper.training_step(batch, batch_idx=0) - - assert loss.requires_grad - for name, expected in ( - ("Train/ActionFlow/GradientNorm/FM", 1.0), - ("Train/ActionFlow/GradientNorm/Reconstruction", 2.0), - ("Train/ActionFlow/GradientNorm/ActionVelocity", 3.0), - ( - "Train/ActionFlow/GradientCosine/FM__Reconstruction", - 1.0, - ), - ( - "Train/ActionFlow/GradientCosine/FM__ActionVelocity", - 1.0, - ), - ( - "Train/ActionFlow/GradientCosine/Reconstruction__ActionVelocity", - 1.0, - ), - ): - assert float(logged[name][0]) == pytest.approx(expected) - assert float( - logged[ - "Train/ActionFlow/GradientIntersectionParameterCount/FM__ActionVelocity" - ][0] - ) == pytest.approx(1.0) - assert float( - logged["Train/ActionFlow/Compute/FieldForwardCallsPerStep"][0] - ) == pytest.approx(2.0) - assert float( - logged["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"][0] - ) == pytest.approx(28.0) - assert float( - logged["Train/ActionFlow/Compute/FieldBackwardVJPCallsPerStep"][0] - ) == pytest.approx(2.0) - assert float( - logged["Train/ActionFlow/Compute/FlowMiniBatchPerStep"][0] - ) == pytest.approx(14.0) - assert float( - logged["Train/ActionFlow/Compute/DecoderJVPCallsPerStep"][0] - ) == pytest.approx(1.0) - - manifest = wrapper.training_behavior._gradient_route_manifest - assert manifest is not None - assert tuple(manifest["routes"]) == ("FM", "Reconstruction", "ActionVelocity") - assert manifest["intersections"]["FM__ActionVelocity"] == ["nets.pipeline.anchor"] - core = {key: value for key, value in manifest.items() if key != "manifest_sha256"} - assert ( - manifest["manifest_sha256"] - == hashlib.sha256( - json.dumps( - core, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - ).encode("utf-8") - ).hexdigest() - ) - checkpoint = {} - wrapper.on_save_checkpoint(checkpoint) - assert checkpoint["action_flow_gradient_route_manifest"] == manifest - - -def test_action_flow_component_telemetry_preserves_accumulated_grad_with_reentrant_checkpoint(): - from torch.utils.checkpoint import checkpoint as activation_checkpoint - - parameter = torch.nn.Parameter(torch.tensor(2.0)) - input_value = torch.tensor(3.0, requires_grad=True) - loss = activation_checkpoint( - lambda value: parameter * value, - input_value, - use_reentrant=True, - ) - accumulated = torch.tensor(7.0) - parameter.grad = accumulated - behavior = ActionFlowTrainingBehavior(gradient_telemetry_cadence=1) - behavior._checkpointed_jvp_telemetry = True - - gradients = behavior._component_gradients(loss, (("parameter", parameter),), "FM") - - assert float(gradients[0]) == pytest.approx(3.0) - assert parameter.grad is accumulated - assert float(parameter.grad) == pytest.approx(7.0) - torch.autograd.backward(loss) - assert float(parameter.grad) == pytest.approx(10.0) - - -def test_checkpointed_gradient_telemetry_deduplicates_optimizer_step_not_batch_index(): - behavior = ActionFlowTrainingBehavior(gradient_telemetry_cadence=3) - behavior.gradient_telemetry_cadence = 3 - behavior._checkpointed_jvp_telemetry = True - behavior._last_gradient_telemetry_step = None - - # Lightning can report the first microbatch of step three at a batch - # index that is not divisible by the accumulation count. It must still - # exercise the diagnostic exactly once for that optimizer step. - assert not behavior._should_log_gradient_telemetry(2) - assert behavior._should_log_gradient_telemetry(3) - behavior._last_gradient_telemetry_step = 3 - assert not behavior._should_log_gradient_telemetry(3) - assert not behavior._should_log_gradient_telemetry(4) - - -def test_action_flow_wrapper_exposes_generic_inference(): - wrapper = _action_flow_wrapper(pipeline=_ToyAlgo(), gradient_telemetry_cadence=0) - condition = torch.arange(6, dtype=torch.float32).reshape(2, 3) - - result = wrapper.forward_eval({"opaque_source": {"condition": condition}}) - - assert tuple(result) == ("opaque_source",) - torch.testing.assert_close(result["opaque_source"]["prediction"], condition) - - -def test_action_flow_wrapper_executes_real_pipeline_algo(monkeypatch): - wrapper = _action_flow_wrapper( - pipeline=PipelineAlgo(stages=[_ActionFlowHead()], device="cpu"), - gradient_telemetry_cadence=0, - ) - _capture_logs(monkeypatch, wrapper) - - loss = wrapper.training_step( - {"opaque_source": {"target": torch.zeros(2, 2, 3)}}, batch_idx=0 - ) - assert float(loss) == pytest.approx(6.0) - loss.backward() - anchor = wrapper.nets["pipeline"].stages[0].anchor - assert float(anchor.grad) == pytest.approx(12.0) - - result = wrapper.forward_eval({"opaque_source": {"condition": torch.zeros(2, 4)}}) - assert result["opaque_source"]["prediction"].shape == (2, 2, 3) - - -def test_action_flow_wrapper_uses_standard_optimizer_config(monkeypatch): - monkeypatch.setattr( - ModelWrapper, - "_instantiate_model", - lambda _self, _config_tree: _ToyAlgo(), - ) - wrapper = _action_flow_wrapper( - config_tree={ - "model": { - "pipeline": {}, - "optimizer": { - "_target_": "torch.optim.AdamW", - "lr": 3.0e-5, - "betas": [0.9, 0.999], - "eps": 1.0e-8, - "weight_decay": 1.0e-4, - }, - "gradient_telemetry_cadence": 0, - } - }, - enable_grad_norm=False, - ) - wrapper._trainer = SimpleNamespace(model=wrapper) - - configured = wrapper.configure_optimizers() - - assert type(wrapper) is ModelWrapper - optimizer = configured["optimizer"] - assert isinstance(optimizer, torch.optim.AdamW) - assert optimizer.param_groups[0]["lr"] == pytest.approx(3.0e-5) - assert optimizer.param_groups[0]["weight_decay"] == pytest.approx(1.0e-4) - - -def test_action_flow_wrapper_logs_composite_optimizer_family_rates(monkeypatch): - wrapper = _action_flow_wrapper( - pipeline=_ToyAlgo(), - gradient_telemetry_cadence=0, - ) - wrapper._trainer = SimpleNamespace( - optimizers=[ - SimpleNamespace( - adamw=SimpleNamespace(param_groups=[{"lr": 2.5e-5}]), - muon=SimpleNamespace(param_groups=[{"lr": 7.5e-5}]), - ) - ] - ) - logged = _capture_logs(monkeypatch, wrapper) - - wrapper.training_behavior._log_composite_optimizer_learning_rates() - - assert float(logged["Optimizer/LR/AdamW"][0]) == pytest.approx(2.5e-5) - assert float(logged["Optimizer/LR/Muon"][0]) == pytest.approx(7.5e-5) - for _, kwargs in logged.values(): - assert kwargs == {"on_step": True, "on_epoch": False, "sync_dist": False} - - -def test_action_flow_wrapper_has_no_specialized_route_or_model_imports(): - root = Path(__file__).parents[1] - sources = ( - root / "egomimic/pl_utils/training_behavior_action_flow.py", - root / "egomimic/eval/action_flow_diagnostic_forward.py", - root / "egomimic/pipeline/action_flow_topology.py", - root / "egomimic/utils/tensor_tree.py", - ) - for path in sources: - source = path.read_text().lower() - assert "unite" not in source - for forbidden in ("usocket", "robot", "embodiment", "domain"): - assert forbidden not in source - - -class _LifecycleEvaluator: - action_flow_diagnostics_enabled = True - - def __init__(self): - self.model = None - self.started = 0 - self.ended = 0 - self.prediction = None - - def on_validation_start(self): - self.started += 1 - - def on_validation_step(self, batch, batch_idx, dataloader_idx=0): - del batch_idx, dataloader_idx - self.prediction = self.model.forward_eval(batch) - - def on_validation_end(self): - self.ended += 1 - - -@pytest.mark.parametrize("diagnostics,requires_context", [(True, False), (False, True)]) -def test_action_flow_validation_logs_components_and_processes_once( - monkeypatch, diagnostics, requires_context -): - algo = _ToyAlgo(reconstruction_weight=10.0) - evaluator = _LifecycleEvaluator() - evaluator.action_flow_diagnostics_enabled = diagnostics - evaluator.requires_lightning_context = requires_context - wrapper = _action_flow_wrapper( - pipeline=algo, - evaluator=evaluator, - gradient_telemetry_cadence=0, - ) - logged = {} - monkeypatch.setattr( - wrapper, - "log_dict", - lambda values, **kwargs: logged.update(values), - ) - - wrapper.on_validation_start() - wrapper.validation_step(_batch(), batch_idx=0) - wrapper.on_validation_epoch_end() - wrapper.on_validation_end() - - assert evaluator.model is wrapper - assert evaluator.started == 1 - assert evaluator.ended == 1 - # validation_step moves the batch once; the bound wrapper recognizes the - # active processed mapping when the evaluator requests ordinary inference. - assert algo.process_count == 1 - assert tuple(evaluator.prediction) == ("source_a", "source_b") - assert float(logged["Valid/ActionFlow/TotalLoss"]) == pytest.approx(60.0) - assert float(logged["Valid/ActionFlow/FlowMatchingLoss"]) == pytest.approx(4.0) - assert float(logged["Valid/ActionFlow/ReconstructionLoss"]) == pytest.approx(5.0) - assert float(logged["Valid/ActionFlow/ReconstructionL1"]) == pytest.approx(7.0) - assert float(logged["Valid/ActionFlow/ActionVelocityLoss"]) == pytest.approx(6.0) - - -def test_action_flow_validation_preserves_ordinary_evaluator_model(monkeypatch): - algo = _ToyAlgo() - evaluator = _LifecycleEvaluator() - evaluator.action_flow_diagnostics_enabled = False - evaluator.model = algo - wrapper = _action_flow_wrapper( - pipeline=algo, - evaluator=evaluator, - gradient_telemetry_cadence=0, - ) - monkeypatch.setattr(wrapper, "log_dict", lambda *_args, **_kwargs: None) - - wrapper.on_validation_start() - wrapper.validation_step(_batch(), batch_idx=0) - - assert evaluator.model is algo - assert algo.process_count == 1 - assert tuple(evaluator.prediction) == ("source_a", "source_b") - - -class _TinySequenceModule(nn.Module): - def __init__(self): - super().__init__() - self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) - - def forward(self, value): - for block in self.blocks: - value = torch.tanh(block(value)) - return value - - -class _TinyField(nn.Module): - def __init__(self): - super().__init__() - self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) - - def forward( - self, - value, - time, - condition, - *, - condition_drop_mask=None, - ): - if condition_drop_mask is None: - condition_drop_mask = torch.zeros( - value.shape[0], dtype=torch.bool, device=value.device - ) - effective = torch.where( - condition_drop_mask.unsqueeze(-1), - torch.zeros_like(condition), - condition, - ) - hidden = value + time[:, None, None] + effective[:, None, :2] - for block in self.blocks: - hidden = torch.tanh(block(hidden)) - return hidden - - -class _BatchSensitiveDecoder(_TinySequenceModule): - """Expose accidental coalescing of diagnostic outer axes into the batch.""" - - def forward(self, value): - decoded = super().forward(value) - return decoded + float(value.shape[0]) * 1.0e-3 - - -def _diagnostic_wrapper(): - torch.manual_seed(9) - encoder = _TinySequenceModule() - field = _TinyField() - decoder = _TinySequenceModule() - wrapper = _action_flow_wrapper( - pipeline=PipelineAlgo( - stages=[ - ContentEncoderStage(encoder=encoder), - ConditionalVelocityStage(field=field, num_inference_steps=2), - ContentDecoderStage(decoder=decoder), - ], - device="cpu", - ), - gradient_telemetry_cadence=0, - ) - wrapper.eval() - return wrapper, encoder, field, decoder - - -def test_action_flow_diagnostics_route_private_codecs_through_one_field(): - torch.manual_seed(9) - encoder_yam, encoder_human = _TinySequenceModule(), _TinySequenceModule() - decoder_yam, decoder_human = _TinySequenceModule(), _TinySequenceModule() - field = _TinyField() - wrapper = _action_flow_wrapper( - pipeline=PipelineAlgo( - stages=[ - ContentEncoderStage( - encoders={"yam": encoder_yam, "human": encoder_human} - ), - ConditionalVelocityStage(field=field, num_inference_steps=2), - ContentDecoderStage( - decoders={"yam": decoder_yam, "human": decoder_human} - ), - ], - device="cpu", - ), - gradient_telemetry_cadence=0, - ) - wrapper.eval() - results = _inference_mode_diagnostics( - wrapper, - { - "yam": { - "target": torch.randn(2, 2, 2), - "condition": torch.randn(2, 2), - "embodiment": ["yam", "yam"], - }, - "human": { - "target": torch.randn(2, 2, 2), - "condition": torch.randn(2, 2), - "embodiment": ["human", "human"], - }, - }, - ) - assert set(results) == {"yam", "human"} - assert wrapper.training_behavior.field_v is field - assert wrapper.training_behavior.encoder_e["yam"] is encoder_yam - assert wrapper.training_behavior.encoder_e["human"] is encoder_human - assert wrapper.training_behavior.decoder_g["yam"] is decoder_yam - assert wrapper.training_behavior.decoder_g["human"] is decoder_human - assert all(result["latent/clean"].shape == (2, 2, 2) for result in results.values()) - - -@torch.inference_mode() -def _inference_mode_diagnostics(wrapper, batch): - return wrapper.run_diagnostic( - "action_flow", - batch, - raw_noise_levels=[0.0, 0.5, 0.75, 1.0], - noise_seed=1234, - max_samples=None, - jacobian_samples=1, - capture_activations=True, - ) - - -def test_action_flow_diagnostics_are_deterministic_complete_and_ad_safe(): - wrapper, encoder, field, decoder = _diagnostic_wrapper() - batch = { - "opaque_source": { - "target": torch.randn(3, 2, 2), - "condition": torch.randn(3, 2), - } - } - - first = _inference_mode_diagnostics(wrapper, batch)["opaque_source"] - second = _inference_mode_diagnostics(wrapper, batch)["opaque_source"] - - assert wrapper.training_behavior.encoder_e is encoder - assert wrapper.training_behavior.field_v is field - assert wrapper.training_behavior.decoder_g is decoder - assert first["schema"] == "action-flow-validation-diagnostics/v1" - assert first["returned_samples"] == 3 - assert first["jacobian_samples"] == 1 - assert first["num_inference_steps"] == 2 - torch.testing.assert_close( - first["noise_levels"], torch.tensor([0.0, 0.5, 0.75, 1.0]) - ) - torch.testing.assert_close( - first["fixed_level_field_evaluations"], torch.tensor([0, 1, 2, 2]) - ) - torch.testing.assert_close(first["latent/noise"], second["latent/noise"]) - torch.testing.assert_close( - first["latent/fixed_final"], second["latent/fixed_final"] - ) - torch.testing.assert_close(first["latent/fixed_final"][0], first["latent/clean"]) - - expected_shapes = { - "target": (3, 2, 2), - "condition": (3, 2), - "latent/clean": (3, 2, 2), - "latent/noise": (3, 2, 2), - "latent/generated": (3, 2, 2), - "latent/fixed_states": (4, 3, 2, 2), - "latent/predicted_clean": (4, 3, 2, 2), - "latent/fixed_final": (4, 3, 2, 2), - "latent/trajectory": (3, 3, 2, 2), - "field/predicted_velocity": (4, 3, 2, 2), - "field/velocity_residual": (4, 3, 2, 2), - "decoded/reconstruction": (3, 2, 2), - "decoded/noise": (3, 2, 2), - "decoded/generated": (3, 2, 2), - "decoded/fixed_states": (4, 3, 2, 2), - "decoded/predicted_clean": (4, 3, 2, 2), - "decoded/fixed_final": (4, 3, 2, 2), - "decoded/trajectory": (3, 3, 2, 2), - "activation/encoder_blocks": (2, 3, 2, 2), - "activation/field_blocks": (4, 2, 3, 2, 2), - "decoder_jacobian/clean_singular_values": (1, 4), - "decoder_jacobian/noise_singular_values": (1, 4), - "decoder_jacobian/fixed_singular_values": (4, 1, 4), - } - for key, shape in expected_shapes.items(): - assert first[key].shape == shape, key - assert bool(torch.isfinite(first[key]).all()), key - assert not first[key].requires_grad, key - torch.testing.assert_close( - first["activation/encoder_block_indices"], torch.tensor([0, 1]) - ) - torch.testing.assert_close( - first["activation/field_block_indices"], torch.tensor([0, 1]) - ) - torch.testing.assert_close( - first["decoder_jacobian/fixed_singular_values"][0], - first["decoder_jacobian/clean_singular_values"], - ) - torch.testing.assert_close( - first["decoder_jacobian/fixed_singular_values"][-1], - first["decoder_jacobian/noise_singular_values"], - ) - - -def test_action_flow_diagnostic_decoding_preserves_logical_batch_geometry(): - wrapper, _, _, _ = _diagnostic_wrapper() - decoder = _BatchSensitiveDecoder() - wrapper.model.pipeline.stages[2].decoder = decoder - result = wrapper.run_diagnostic( - "action_flow", - { - "opaque_source": { - "target": torch.randn(3, 2, 2), - "condition": torch.randn(3, 2), - } - }, - raw_noise_levels=[0.0, 0.5, 1.0], - noise_seed=7, - jacobian_samples=1, - capture_activations=False, - )["opaque_source"] - - torch.testing.assert_close( - result["decoded/fixed_states"][0], result["decoded/reconstruction"] - ) - torch.testing.assert_close(result["decoded/trajectory"][0], result["decoded/noise"]) - - -def test_action_flow_diagnostic_sample_caps_are_independent(): - wrapper, _, _, _ = _diagnostic_wrapper() - result = wrapper.run_diagnostic( - "action_flow", - { - "opaque_source": { - "target": torch.randn(5, 2, 2), - "condition": torch.randn(5, 2), - } - }, - raw_noise_levels=[0.25, 1.0], - noise_seed=1, - max_samples=4, - jacobian_samples=2, - )["opaque_source"] - - assert result["returned_samples"] == 4 - assert result["target"].shape[0] == 4 - assert result["activation/encoder_blocks"].shape[1] == 4 - assert result["activation/field_blocks"].shape[2] == 4 - assert result["decoder_jacobian/clean_singular_values"].shape[0] == 2 diff --git a/tests/test_action_flow_unite_recipe.py b/tests/test_action_flow_unite_recipe.py deleted file mode 100644 index 91cc4b9dbd..0000000000 --- a/tests/test_action_flow_unite_recipe.py +++ /dev/null @@ -1,268 +0,0 @@ -from __future__ import annotations - -import sys -from types import SimpleNamespace - -import torch - -from egomimic.models.action_flow_unite import ( - UniteActionFlowContentEncoder, - UniteActionFlowVelocityField, -) -from egomimic.models.unite_action_decoder import UniteActionDecoder -from egomimic.models.unite_dit import UniteDiTBackbone -from egomimic.pipeline.stages_action_flow import ( - ConditionalVelocityStage, - ContentDecoderStage, - LatentBridgeStage, -) -from egomimic.utils.unite_optim import released_unite_two_stage_scheduler - - -def test_released_warmup_first_optimizer_update_has_zero_lr(): - """An optimizer-step-1 smoke cannot test zero-initialized latent projections.""" - - parameter = torch.nn.Parameter(torch.tensor(0.0)) - optimizer = torch.optim.AdamW([parameter], lr=1.0e-4) - scheduler = released_unite_two_stage_scheduler( - optimizer, - warmup_steps=8000, - decay_start_1_steps=12000, - decay_end_1_steps=20000, - decay_start_2_steps=1200000, - decay_end_2_steps=1200000, - base_lr_1=1.0e-4, - base_lr_2=5.0e-5, - final_lr=5.0e-5, - ) - assert optimizer.param_groups[0]["lr"] == 0.0 - parameter.grad = torch.ones_like(parameter) - optimizer.step() - scheduler.step() - assert parameter.item() == 0.0 - assert optimizer.param_groups[0]["lr"] > 0.0 - parameter.grad = torch.ones_like(parameter) - optimizer.step() - assert parameter.item() != 0.0 - - -def _backbone(*, horizon: int = 2) -> UniteDiTBackbone: - return UniteDiTBackbone( - input_dim=4, - output_dim=4, - horizon=horizon, - condition_dim=8, - max_condition_tokens=1, - max_content_tokens=4, - hidden_dim=32, - depth=2, - num_heads=4, - mlp_ratio=2.0, - qk_norm=True, - use_rope=True, - use_rmsnorm=True, - use_swiglu=True, - block_norm=True, - time_fourier_dim=8, - context_adaln_summary=False, - in_context_start=1, - in_context_len=4, - trainable_position_embeddings=False, - trainable_register_position_embeddings=True, - trainable_content_position_embeddings=False, - trainable_condition_position_embeddings=False, - trainable_in_context_position_embeddings=True, - gradient_checkpointing=False, - ) - - -def test_unite_action_flow_adapters_preserve_compact_register_contract(): - encoder = UniteActionFlowContentEncoder( - backbone=_backbone(), - action_dim=3, - action_horizon=4, - latent_dim=4, - num_latent_tokens=2, - condition_dim=8, - ) - field = UniteActionFlowVelocityField( - backbone=_backbone(), - input_dim=4, - output_dim=4, - horizon=2, - condition_dim=8, - condition_dropout_probability=0.1, - ) - latent = encoder(torch.randn(3, 4, 3)) - velocity = field( - latent, - torch.rand(3), - torch.randn(3, 8), - condition_drop_mask=torch.tensor([False, True, False]), - ) - assert latent.shape == velocity.shape == (3, 2, 4) - assert encoder.backbone is not field.backbone - assert encoder.blocks is encoder.backbone.blocks - assert field.blocks is field.backbone.blocks - - -def test_zero_initialized_content_encoder_activates_after_reconstruction_step(): - """An early diagnostic must not assume a nonzero latent before step one.""" - torch.manual_seed(19) - encoder = UniteActionFlowContentEncoder( - backbone=_backbone(), - action_dim=3, - action_horizon=4, - latent_dim=4, - num_latent_tokens=2, - condition_dim=8, - ) - decoder = UniteActionDecoder( - latent_dim=4, - action_dim=3, - num_latent_tokens=2, - action_horizon=4, - hidden_dim=32, - depth=1, - num_heads=4, - gradient_checkpointing=False, - ) - actions = torch.randn(3, 4, 3) - optimizer = torch.optim.AdamW( - list(encoder.parameters()) + list(decoder.parameters()), lr=1e-3 - ) - initial = encoder(actions) - torch.testing.assert_close(initial, torch.zeros_like(initial)) - loss = (decoder(initial) - actions).square().mean() - loss.backward() - gradient = encoder.backbone.proj_d.weight.grad - assert gradient is not None and torch.isfinite(gradient).all() - assert torch.count_nonzero(gradient) > 0 - optimizer.step() - updated = encoder(actions) - assert torch.isfinite(updated).all() - assert torch.linalg.vector_norm(updated) > 0 - - -def test_unite_diagnostic_activations_align_registers_after_context_insertion(): - encoder = UniteActionFlowContentEncoder( - backbone=_backbone(), - action_dim=3, - action_horizon=4, - latent_dim=4, - num_latent_tokens=2, - condition_dim=8, - ) - before = torch.randn(3, 6, 32) - after = torch.randn(3, 10, 32) - assert encoder.canonicalize_diagnostic_block_output(before, 0).shape == (3, 2, 32) - torch.testing.assert_close( - encoder.canonicalize_diagnostic_block_output(after, 1), - after[:, 4:6], - ) - - -def test_unite_bridge_maps_shifted_clean_fraction_to_action_flow_time(): - torch.manual_seed(7) - stage = LatentBridgeStage( - samples_per_content=128, - condition_dropout_probability=0.1, - time_sampling="lognormal_shifted", - lognorm_mu=0.0, - lognorm_sigma=1.0, - timestep_shift_alpha=0.5, - independent_noise_per_sample=True, - independent_condition_dropout_per_sample=True, - ) - clean = torch.zeros(2, 2, 4) - batch = stage( - { - "action_flow/clean_latent": clean, - "sampler/noise": torch.zeros_like(clean), - "condition": torch.randn(2, 8), - } - ) - time = batch["action_flow/time"] - noise = batch["action_flow/noise"] - assert time.shape == (256,) - assert bool(((time > 0.0) & (time < 1.0)).all()) - assert not torch.equal(noise[0], noise[1]) - assert batch["action_flow/condition_drop_mask"].shape == (256,) - - -class _ConstantField(torch.nn.Module): - def forward(self, value, time, condition, condition_drop_mask=None): - return torch.ones_like(value) - - -class _MaskField(torch.nn.Module): - def forward(self, value, time, condition, condition_drop_mask=None): - shape = (value.shape[0],) + (1,) * (value.ndim - 1) - return condition_drop_mask.to(value.dtype).reshape(shape).expand_as(value) - - -def test_dopri5_integrates_from_gaussian_t1_to_clean_t0(monkeypatch): - captured = {} - - def fake_odeint(function, initial, times, *, method, atol, rtol): - captured["times"] = times - derivative = function(times[0], initial) - endpoint = initial + (times[-1] - times[0]) * derivative - return torch.stack((initial, endpoint)) - - monkeypatch.setitem(sys.modules, "torchdiffeq", SimpleNamespace(odeint=fake_odeint)) - stage = ConditionalVelocityStage( - field=_ConstantField(), - num_inference_steps=50, - inference_method="dopri5", - timestep_shift_alpha=0.5, - ) - result = stage.execute( - { - "sampler/noise": torch.ones(2, 2, 4), - "condition": torch.randn(2, 8), - }, - mode="inference", - ) - torch.testing.assert_close(captured["times"][[0, -1]], torch.tensor([1.0, 0.0])) - torch.testing.assert_close( - result["action_flow/generated_latent"], torch.zeros(2, 2, 4) - ) - - -def test_cfg_scale_four_matches_released_unite_guidance_formula(): - stage = ConditionalVelocityStage( - field=_MaskField(), - num_inference_steps=2, - inference_method="euler", - cfg_scale=4.0, - cfg_interval=(0.0, 1.0), - ) - noise = torch.zeros(2, 2, 4) - result = stage.execute( - {"sampler/noise": noise, "condition": torch.randn(2, 8)}, - mode="inference", - ) - # t=1 is outside the released interval and contributes zero. At t=0.5, - # conditioned=0 and unconditioned=1, so CFG4 gives 1+4*(0-1)=-3. - torch.testing.assert_close( - result["action_flow/generated_latent"], torch.full_like(noise, 1.5) - ) - - -def test_reconstruction_noising_start_one_is_exactly_clean(): - decoder = torch.nn.Identity() - stage = ContentDecoderStage( - decoder=decoder, - reconstruction_noising_start=1.0, - reconstruction_noising_probability=1.0, - ) - clean = torch.randn(2, 2, 4) - result = stage( - { - "action_flow/clean_latent": clean, - "action_flow/state": clean, - "action_flow/velocity_residual": torch.zeros_like(clean), - } - ) - torch.testing.assert_close(result["action_flow/reconstruction"], clean) diff --git a/tests/test_action_latent_vfm.py b/tests/test_action_latent_vfm.py deleted file mode 100644 index 74cf082b63..0000000000 --- a/tests/test_action_latent_vfm.py +++ /dev/null @@ -1,474 +0,0 @@ -from collections import OrderedDict -from pathlib import Path - -import torch -import torch.nn as nn -from omegaconf import OmegaConf - -from egomimic.eval.planar_action_eval import PlanarActionEval -from egomimic.models.adaln_backbone import AdaLNBackbone -from egomimic.models.unite_action_decoder import UniteActionDecoder -from egomimic.pipeline.stages_action_latent_vfm import ( - ActionLatentDecoderStage, - ExpectedMonotonicNoisingStage, - ExpectedMonotonicRankingObjectiveStage, - FlowBridgeNoisingStage, - LatentVelocityFieldStage, - ReconstructionBridgeNoisingStage, - TinyActionLatentEncoder, - VelocityEndpointStage, - VelocityFlowObjectiveStage, -) -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.pl_utils.training_behavior_action_latent_vfm import ( - ActionLatentVFMTrainingBehavior, -) -from egomimic.trainHydra import _instantiate_model_wrapper -from tools.config_graph import build_graph - -ROOT = Path(__file__).resolve().parents[1] -EXPERIMENT = ( - ROOT - / "egomimic/hydra_configs/experiment/pusht/action_latent_vfm_usocket_val01_h16.yaml" -) - - -class _RecordingVelocity(nn.Module): - def __init__(self): - super().__init__() - self.scale = nn.Parameter(torch.tensor(1.0)) - self.conditions = [] - - def forward(self, latent, time, condition): - self.conditions.append(condition.detach().clone()) - return self.scale * latent - - -class _MetricPipeline(nn.Module): - def __init__(self): - super().__init__() - self.velocity = LatentVelocityFieldStage( - denoising_module=_RecordingVelocity(), - condition_dim=6, - ) - self.stages = nn.ModuleList((self.velocity,)) - - -class _MetricAlgo: - def __init__(self): - self.nets = nn.ModuleDict({"pipeline": _MetricPipeline()}) - self.device = torch.device("cpu") - - @property - def pipeline(self): - return self.nets["pipeline"] - - def process_batch_for_training(self, batch): - return batch - - def forward_training(self, batch): - weight = self.pipeline.velocity.denoising_module.scale - results = OrderedDict() - for source, values in batch.items(): - results[source] = { - "target": weight.new_zeros((values["count"], 1, 1)), - "loss/action_latent_reconstruction": (weight - 0.0).square(), - "loss/action_latent_fm": (weight - 2.0).square(), - "loss/action_latent_monotonic": (weight - 1.0).square(), - "log/action_latent_reconstruction_l1": weight.detach().abs(), - } - return results - - -def test_action_encoder_maps_h16_actions_to_n16_d8_latent(): - encoder = TinyActionLatentEncoder( - action_dim=4, - action_horizon=16, - num_latent_tokens=16, - latent_dim=8, - hidden_dim=32, - depth=2, - num_heads=4, - ) - actions = torch.randn(3, 16, 4) - output = encoder(actions) - assert output.shape == (3, 16, 8) - torch.testing.assert_close(output, encoder(actions)) - - -def test_general_adaln_backbone_has_no_content_or_sampler_contract(): - backbone = AdaLNBackbone( - input_dim=8, - output_dim=8, - horizon=16, - condition_dim=128, - hidden_dim=32, - depth=2, - num_heads=4, - gradient_checkpointing=False, - ) - output = backbone(torch.randn(3, 16, 8), torch.rand(3), torch.randn(3, 128)) - assert output.shape == (3, 16, 8) - names = dict(backbone.named_parameters()) - assert not any("content" in name for name in names) - assert not any("sampler" in name or "velocity" in name for name in names) - - -def test_fixed_euler_diagnostics_capture_real_denoiser_layers(): - backbone = AdaLNBackbone( - input_dim=8, - output_dim=8, - horizon=16, - condition_dim=6, - hidden_dim=32, - depth=4, - num_heads=4, - gradient_checkpointing=False, - ) - stage = LatentVelocityFieldStage( - denoising_module=backbone, - condition_dim=6, - num_inference_steps=2, - cfg_scale=1.0, - ).eval() - states, predictions, activations = stage.diagnostic_rollout( - clean_latent=torch.randn(3, 16, 8), - noise=torch.randn(3, 16, 8), - condition=torch.randn(3, 6), - raw_noise_levels=[0.0, 0.5, 1.0], - ) - assert states.shape == (3, 3, 16, 8) - assert predictions.shape == (3, 3, 16, 8) - assert tuple(activations) == tuple(f"block_{index:02d}" for index in range(4)) - assert all(value.shape == (3, 3, 16, 32) for value in activations.values()) - - -def test_fm_detaches_clean_target_but_reconstruction_preserves_encoder_gradient(): - clean = torch.randn(2, 16, 8, requires_grad=True) - fm = FlowBridgeNoisingStage(samples_per_reconstruction=14)({"latent/clean": clean}) - assert fm["fm/noisy_latent"].shape == (28, 16, 8) - assert not fm["fm/noisy_latent"].requires_grad - assert not fm["fm/target_velocity"].requires_grad - - reconstruction = ReconstructionBridgeNoisingStage(0.5, 1.0)({"latent/clean": clean}) - assert reconstruction["reconstruction/noisy_latent"].requires_grad - - -def test_monotonic_noising_orders_severity_and_shares_noise(): - clean = torch.randn(8, 16, 8, requires_grad=True) - batch = ExpectedMonotonicNoisingStage(0.0, 0.5)({"latent/clean": clean}) - time_less = batch["monotonic/time_less"] - time_more = batch["monotonic/time_more"] - assert torch.all(time_less >= time_more) - assert torch.all(time_less <= 1.0) - assert torch.all(time_more >= 0.5) - assert time_less.unique().numel() == 1 - assert time_more.unique().numel() == 1 - less_scale = (1.0 - time_less).reshape(-1, 1, 1) - more_scale = (1.0 - time_more).reshape(-1, 1, 1) - residual_less = batch["monotonic/noisy_less"] - time_less.reshape(-1, 1, 1) * clean - residual_more = batch["monotonic/noisy_more"] - time_more.reshape(-1, 1, 1) * clean - torch.testing.assert_close( - residual_less * more_scale, - residual_more * less_scale, - atol=2e-5, - rtol=2e-5, - ) - assert not batch["monotonic/noisy_less"].requires_grad - - -def test_monotonic_objective_ranks_expected_risk_not_each_sample(): - less = torch.tensor([2.0, 0.0], requires_grad=True).reshape(2, 1, 1) - more = torch.tensor([0.0, 3.0], requires_grad=True).reshape(2, 1, 1) - less.retain_grad() - more.retain_grad() - batch = ExpectedMonotonicRankingObjectiveStage(margin=0.0, weight=1.0)( - { - "monotonic/pred_action_less": less, - "monotonic/pred_action_more": more, - "target": torch.zeros(2, 1, 1), - } - ) - # Sample zero violates the ordering, but the expected risks satisfy it. - assert float(batch["log/action_latent_monotonic_risk_less"]) == 2.0 - assert float(batch["log/action_latent_monotonic_risk_more"]) == 4.5 - assert float(batch["loss/action_latent_monotonic"]) == 0.0 - - violating = ExpectedMonotonicRankingObjectiveStage(margin=0.5, weight=2.0)( - { - "monotonic/pred_action_less": more, - "monotonic/pred_action_more": less, - "target": torch.zeros(2, 1, 1), - } - ) - assert float(violating["loss/action_latent_monotonic"]) == 6.0 - violating["loss/action_latent_monotonic"].backward() - assert less.grad is not None - assert more.grad is not None - - -def test_monotonic_decoder_path_updates_latent_but_not_decoder_parameters(): - decoder = UniteActionDecoder( - latent_dim=3, - action_dim=2, - num_latent_tokens=4, - action_horizon=5, - hidden_dim=16, - depth=2, - num_heads=4, - gradient_checkpointing=False, - ) - stage = ActionLatentDecoderStage(decoder) - reconstruction_latent = torch.randn(2, 4, 3, requires_grad=True) - less = torch.randn(2, 4, 3, requires_grad=True) - more = torch.randn(2, 4, 3, requires_grad=True) - output = stage.execute( - { - "reconstruction/pred_clean_latent": reconstruction_latent, - "monotonic/pred_clean_less": less, - "monotonic/pred_clean_more": more, - }, - mode="train", - ) - monotonic_loss = ( - output["monotonic/pred_action_less"].square().mean() - + output["monotonic/pred_action_more"].square().mean() - ) - monotonic_loss.backward() - assert less.grad is not None - assert more.grad is not None - assert reconstruction_latent.grad is None - assert all(parameter.grad is None for parameter in decoder.parameters()) - - -def test_velocity_endpoint_and_reconstruction_loss_update_local_prediction(): - noisy = torch.randn(2, 16, 8) - velocity = torch.randn(2, 16, 8, requires_grad=True) - time = torch.tensor([0.5, 0.75]) - monotonic_less = torch.randn(2, 16, 8) - monotonic_more = torch.randn(2, 16, 8) - monotonic_velocity_less = torch.randn(2, 16, 8) - monotonic_velocity_more = torch.randn(2, 16, 8) - monotonic_time_less = torch.tensor([0.8, 0.9]) - monotonic_time_more = torch.tensor([0.6, 0.7]) - batch = VelocityEndpointStage()( - { - "reconstruction/noisy_latent": noisy, - "reconstruction/time": time, - "reconstruction/pred_velocity": velocity, - "monotonic/noisy_less": monotonic_less, - "monotonic/time_less": monotonic_time_less, - "monotonic/pred_velocity_less": monotonic_velocity_less, - "monotonic/noisy_more": monotonic_more, - "monotonic/time_more": monotonic_time_more, - "monotonic/pred_velocity_more": monotonic_velocity_more, - } - ) - expected = noisy + (1.0 - time.reshape(-1, 1, 1)) * velocity - assert torch.allclose(batch["reconstruction/pred_clean_latent"], expected) - torch.testing.assert_close( - batch["monotonic/pred_clean_less"], - monotonic_less - + (1.0 - monotonic_time_less.reshape(-1, 1, 1)) * monotonic_velocity_less, - ) - - -def test_native_action_l1_wraps_angular_residual(): - prediction = torch.tensor([[[0.0, 0.0, -torch.pi + 0.1]]]) - target = torch.tensor([[[0.0, 0.0, torch.pi - 0.1]]]) - residual = PlanarActionEval._native_residual(prediction, target, decoder=object()) - torch.testing.assert_close( - residual[..., 2], torch.tensor([[0.2]]), atol=1e-6, rtol=0 - ) - torch.testing.assert_close( - PlanarActionEval._native_l1(prediction, target, decoder=object()), - torch.tensor(0.2 / 3), - atol=1e-6, - rtol=0, - ) - - -def test_native_trajectory_metric_expands_one_target_across_sampler_steps(): - target = torch.randn(2, 16, 3) - predictions = target.unsqueeze(0).expand(17, -1, -1, -1).clone() - metric = PlanarActionEval._trajectory_native_mse( - predictions, target, decoder=object() - ) - assert metric.shape == (17, 2) - torch.testing.assert_close(metric, torch.zeros_like(metric)) - - -def test_flow_objective_sums_fourteen_full_mean_terms(): - batch = VelocityFlowObjectiveStage(samples_per_reconstruction=14)( - { - "fm/pred_velocity": torch.ones(28, 16, 8), - "fm/target_velocity": torch.zeros(28, 16, 8), - } - ) - assert float(batch["loss/action_latent_fm"]) == 14.0 - - -def test_condition_dropout_replaces_condition_but_not_noisy_latent(): - denoiser = _RecordingVelocity() - stage = LatentVelocityFieldStage( - denoising_module=denoiser, - condition_dim=6, - condition_dropout_probability=1.0, - cfg_scale=4.0, - ) - stage.train() - condition = torch.randn(2, 6) - fm_latent = torch.randn(4, 16, 8) - reconstruction_latent = torch.randn(2, 16, 8) - monotonic_less = torch.randn(2, 16, 8) - monotonic_more = torch.randn(2, 16, 8) - output = stage.execute( - { - "condition": condition, - "fm/noisy_latent": fm_latent, - "fm/time": torch.rand(4), - "fm/condition_repeat": 2, - "reconstruction/noisy_latent": reconstruction_latent, - "reconstruction/time": torch.rand(2), - "monotonic/noisy_less": monotonic_less, - "monotonic/time_less": torch.rand(2), - "monotonic/noisy_more": monotonic_more, - "monotonic/time_more": torch.rand(2), - }, - mode="train", - ) - assert torch.equal(output["fm/pred_velocity"], fm_latent) - assert torch.equal(output["reconstruction/pred_velocity"], reconstruction_latent) - assert torch.equal(output["monotonic/pred_velocity_less"], monotonic_less) - assert torch.equal(output["monotonic/pred_velocity_more"], monotonic_more) - for observed in denoiser.conditions: - assert torch.allclose(observed, stage._null_condition_like(observed)) - assert float(output["log/action_latent_fm_condition_drop_fraction"]) == 1.0 - assert ( - float(output["log/action_latent_reconstruction_condition_drop_fraction"]) == 1.0 - ) - assert float(output["log/action_latent_monotonic_condition_drop_fraction"]) == 1.0 - - -def test_wrapper_weights_components_and_uses_shared_denoiser_telemetry(monkeypatch): - wrapper = ModelWrapper( - pipeline=_MetricAlgo(), - training_behavior=ActionLatentVFMTrainingBehavior(), - ) - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **kwargs: logged.setdefault(name, (value, kwargs)), - ) - batch = OrderedDict( - ( - ("small", {"count": 1}), - ("large", {"count": 3}), - ) - ) - loss = wrapper.training_step(batch, 0) - assert float(loss) == 2.0 - assert float(logged["Train/ActionLatentVFM/TotalLoss"][0]) == 2.0 - - behavior = wrapper.training_behavior - parameter = behavior._velocity_stage().denoising_module.scale - parameter.grad = torch.tensor(7.0) - before = parameter.grad.clone() - behavior._measure_gradient_conflict( - (parameter - 0.0).square(), (parameter - 2.0).square() - ) - assert float(logged["log/unite_gradient_cosine"][0]) == -1.0 - assert "log/unite_recon_grad_norm" in logged - assert "log/unite_denoise_grad_norm" in logged - torch.testing.assert_close(parameter.grad, before) - - -def test_training_entry_uses_resolved_gradient_telemetry_cadence(monkeypatch): - monkeypatch.setattr( - ModelWrapper, - "_instantiate_model", - lambda self, config_tree: _MetricAlgo(), - ) - cfg = OmegaConf.create( - { - "model": { - "_target_": "egomimic.pl_utils.pl_model.ModelWrapper", - "training_behavior": { - "_target_": ( - "egomimic.pl_utils.training_behavior_action_latent_vfm." - "ActionLatentVFMTrainingBehavior" - ) - }, - "pipeline": {}, - "gradient_telemetry_cadence": 1, - "enable_grad_norm": False, - } - } - ) - - wrapper = _instantiate_model_wrapper(cfg) - - assert wrapper.training_behavior.gradient_telemetry_cadence == 1 - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **kwargs: logged.setdefault(name, (value, kwargs)), - ) - wrapper.training_step(OrderedDict((("source", {"count": 2}),)), 0) - assert "log/unite_gradient_cosine" in logged - assert "log/unite_recon_grad_norm" in logged - assert "log/unite_denoise_grad_norm" in logged - - -def test_graph_is_lint_clean_and_declares_condition_dropout(): - graphs = { - mode: build_graph( - EXPERIMENT, - mode=mode, - overrides=("model=bf/us_action_latent_vfm_nt16_d8_h512_s42",), - ) - for mode in ("train", "inference") - } - assert not graphs["train"]["lint"] - assert not graphs["inference"]["lint"] - velocity = next( - node - for node in graphs["train"]["nodes"] - if node["t"] == "LatentVelocityFieldStage" - ) - assert velocity["p"]["condition_dropout_probability"] == 0.3 - assert ( - velocity["p"]["denoising_module"]["_target_"] - == "egomimic.models.adaln_backbone.AdaLNBackbone" - ) - assert "max_condition_tokens" not in velocity["p"]["denoising_module"] - assert "max_content_tokens" not in velocity["p"]["denoising_module"] - assert velocity["p"]["denoising_module"]["hidden_dim"] == 512 - assert velocity["p"]["denoising_module"]["depth"] == 12 - monotonic_nodes = { - node["t"]: node - for node in graphs["train"]["nodes"] - if node["t"].startswith("ExpectedMonotonic") - } - assert set(monotonic_nodes) == { - "ExpectedMonotonicNoisingStage", - "ExpectedMonotonicRankingObjectiveStage", - } - assert not any( - node["t"].startswith("ExpectedMonotonic") - for node in graphs["inference"]["nodes"] - ) - ranking = monotonic_nodes["ExpectedMonotonicRankingObjectiveStage"] - assert ranking["p"]["margin"] == 0.0 - assert ranking["p"]["weight"] == 1.0 - decoder = next( - node - for node in graphs["train"]["nodes"] - if node["t"] == "ActionLatentDecoderStage" - ) - assert decoder["p"]["decoder"]["gradient_checkpointing"] is False - experiment = OmegaConf.load(EXPERIMENT) - assert experiment.callbacks.model_checkpoint.every_n_train_steps == 40000 - assert experiment.evaluator.unite_diagnostics.enabled is True diff --git a/tests/test_alignment_parity.py b/tests/test_alignment_parity.py deleted file mode 100644 index b76a4be95c..0000000000 --- a/tests/test_alignment_parity.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Numerical parity with immutable source methods and configured graph training.""" - -from copy import deepcopy -from functools import partial - -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate - -from egomimic.models.cores.hpt_transformer import MultiheadAttention, SimpleTransformer -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_alignment import ( - RepresentationAlignment, - ScheduledLossReduction, -) -from egomimic.pipeline.stages_hpt import HPTTrunkStage -from tests.test_retained_recipe_steps import CONFIGS, small_cpu_model - -pytest.importorskip("geomloss") -SoftDTWLossPyTorch = pytest.importorskip("tslearn.metrics").SoftDTWLossPyTorch -LegacyAlignment = pytest.importorskip( - "tests.fixtures.legacy_alignment_reference" -).LegacyAlignment - - -@pytest.mark.parametrize("supervision", [None, "mse", "soft_dtw"]) -def test_sinkhorn_loss_and_gradients_match_source(supervision): - torch.manual_seed(121) - left = torch.randn(3, 4, 8, requires_grad=True) - right = torch.randn(3, 4, 8, requires_grad=True) - actions = torch.randn(3, 5, 6) - other_actions = actions.flip(0) + 0.01 - reference = LegacyAlignment() - reference.device = torch.device("cpu") - reference.ot_6dof = False - reference.use_dtw = supervision == "soft_dtw" - reference.dtw = SoftDTWLossPyTorch(gamma=0.1) - expected, expected_distance = reference.compute_ot( - left, right, actions, other_actions, supervision is not None, 0.5 - ) - expected_grads = torch.autograd.grad(expected, (left, right)) - stage = RepresentationAlignment( - "left", - "right", - supervision=supervision, - cost_layout="legacy_broadcast", - left_actions="actions", - right_actions="other_actions", - left_action_indices=[0, 1, 2], - right_action_indices=[0, 1, 2], - ) - result = stage( - { - "left": left, - "right": right, - "actions": actions, - "other_actions": other_actions, - } - ) - torch.testing.assert_close(result["alignment/loss"], expected) - torch.testing.assert_close(result["log/feature_distance"], expected_distance) - for actual, expected_gradient in zip( - torch.autograd.grad(result["alignment/loss"], (left, right)), expected_grads - ): - torch.testing.assert_close(actual, expected_gradient) - - -def test_freeze_boundary_preserves_alignment_gradients(): - torch.manual_seed(42) - trunk = SimpleTransformer( - partial(MultiheadAttention, embed_dim=8, num_heads=2, batch_first=True), - embed_dim=8, - num_blocks=3, - ) - stage = HPTTrunkStage( - trunk, - 8, - action_token_count=2, - squeeze_action_token=False, - representation_block=1, - detach_condition_before_block=2, - ) - result = stage({"hpt/tokens": torch.randn(2, 3, 8)}) - result["condition"].square().sum().backward(retain_graph=True) - assert trunk.blocks[0].attn.in_proj_weight.grad is None - assert trunk.blocks[1].attn.in_proj_weight.grad is None - assert trunk.blocks[2].attn.in_proj_weight.grad.abs().sum() > 0 - stage.zero_grad() - result["hpt/representation"].square().sum().backward() - assert trunk.blocks[0].attn.in_proj_weight.grad.abs().sum() > 0 - assert trunk.blocks[1].attn.in_proj_weight.grad.abs().sum() > 0 - assert trunk.blocks[2].attn.in_proj_weight.grad is None - # The resumed path is the source implementation, with unchanged weights. - _, blocks = trunk(torch.randn(2, 5, 8)) - reference = LegacyAlignment() - reference.trunk = {"trunk": trunk} - reference.postprocess_tokens = lambda value: value - torch.testing.assert_close( - trunk.resume_from_block(blocks, 2), reference.resume_from_depth(blocks, 2) - ) - assert "hpt/representation" not in stage.execute( - {"hpt/tokens": torch.randn(2, 3, 8)}, mode="inference" - ) - - -def test_loss_schedule_and_resume_use_training_batches(): - def graph(): - return Pipeline( - [ - ScheduledLossReduction( - { - "source_losses": {"start_batch": 2}, - "alignment": {"weight": 0.7, "start_batch": 3}, - }, - denominator_key="source_count", - ) - ] - ) - - reducer = graph() - batch = { - "source_losses": torch.tensor([2.0, 4.0]), - "alignment": torch.tensor(10.0), - "source_count": 2, - } - assert reducer(batch)["loss/total"] == 0 - assert reducer(batch)["loss/total"] == 3 - restored = graph() - restored.load_state_dict(deepcopy(reducer.state_dict()), strict=True) - assert restored(batch)["loss/total"] == pytest.approx(6.5) - assert restored(batch)["log/training_batches"] == 4 - - -def test_egobridge_two_source_optimizer_and_independent_feature_passes(): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - config = compose( - config_name="train_zarr_cartesian", overrides=["model=egobridge"] - ) - cpu = small_cpu_model(config, "hpt") - cpu.model.pipeline.stages[1].representation_block = 0 - cpu.model.pipeline.loss_pipeline.stages[0].supervision = "mse" - model = instantiate(cpu.model.pipeline, device="cpu") - calls = [] - hook = model.pipeline.stage_by_id("stems").register_forward_hook( - lambda *args: calls.append(1) - ) - batch = {} - for name, identity, width in [("eva_bimanual", 6, 14), ("human_bimanual", 3, 12)]: - batch[name] = { - "embodiment": torch.tensor([identity, identity]), - "actions_cartesian": torch.randn(2, 100, width), - "observations.state.ee_pose": torch.randn(2, width), - **{ - f"observations.images.{key}": torch.rand(2, 3, 32, 32) - for key in ("front_img_1", "left_wrist_img", "right_wrist_img") - }, - } - optimizer = torch.optim.Adam(model.nets.parameters(), lr=1e-4) - for _ in range(2): - optimizer.zero_grad() - predictions = model.forward_training(batch) - losses = model.compute_losses(predictions, batch) - expected = ( - losses["source_0_loss"] - + losses["source_1_loss"] - + 0.7 * losses["log_alignment_loss"] - ) / 2 - torch.testing.assert_close(losses["loss"], expected) - losses["loss"].backward() - assert model.pipeline.stage_by_id("trunk").action_token.grad.abs().sum() > 0 - optimizer.step() - hook.remove() - assert ( - len(calls) == 8 - ), "Two sources, two steps, independent BC and OT feature passes" - clone = instantiate(cpu.model.pipeline, device="cpu") - clone.nets.load_state_dict(deepcopy(model.nets.state_dict()), strict=True) - assert clone.nets["loss_pipeline"].stages[1].training_batches == 2 - clone.nets.eval() - assert clone.forward_eval({"human_bimanual": batch["human_bimanual"]})[ - "human_bimanual" - ]["pred_action"].shape == (2, 100, 12) - - -def test_alignment_rejects_unmatched_batches(): - stage = RepresentationAlignment("left", "right") - with pytest.raises(ValueError, match="matching"): - stage({"left": torch.zeros(2, 3), "right": torch.zeros(3, 3)}) diff --git a/tests/test_arc_duration_rollout_bridge.py b/tests/test_arc_duration_rollout_bridge.py deleted file mode 100644 index 4a1e996d26..0000000000 --- a/tests/test_arc_duration_rollout_bridge.py +++ /dev/null @@ -1,51 +0,0 @@ -import numpy as np - -from egomimic.eval.core.ckpt_loading import _chunk_seam_event - - -def test_duration_arc_chunk_seam_aligns_previous_unused_tail_to_fresh_head(): - old_chunk = np.arange(120, dtype=np.float32).reshape(40, 3) - fresh_chunk = (1000 + np.arange(120, dtype=np.float32)).reshape(40, 3) - - event = _chunk_seam_event( - old_chunk, - consumed=8, - new_chunk=fresh_chunk, - timestep=8, - embodiment_id=19, - episode_index=2, - ) - - assert event is not None - assert event["t"] == 8 - assert event["embodiment_id"] == 19 - assert event["episode_index"] == 2 - np.testing.assert_array_equal(event["previous_tail"], old_chunk[8:]) - np.testing.assert_array_equal(event["new_head"], fresh_chunk[:32]) - np.testing.assert_array_equal(event["executed_head"], fresh_chunk[:32]) - - -def test_duration_arc_chunk_seam_skips_the_initial_or_exhausted_chunk(): - fresh_chunk = np.zeros((40, 3), dtype=np.float32) - assert ( - _chunk_seam_event( - None, - consumed=0, - new_chunk=fresh_chunk, - timestep=0, - embodiment_id=19, - episode_index=0, - ) - is None - ) - assert ( - _chunk_seam_event( - fresh_chunk, - consumed=40, - new_chunk=fresh_chunk, - timestep=40, - embodiment_id=19, - episode_index=0, - ) - is None - ) diff --git a/tests/test_arc_eval_detokenize.py b/tests/test_arc_eval_detokenize.py deleted file mode 100644 index abac404e71..0000000000 --- a/tests/test_arc_eval_detokenize.py +++ /dev/null @@ -1,421 +0,0 @@ -"""The arc evaluator must detokenize before anything treats rows as poses.""" - -from pathlib import Path - -import numpy as np -import pytest -import torch - -from egomimic.eval.arc_bimanual_cartesian_eval import ArcBimanualCartesianEval -from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval -from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, -) - -_REPO = Path(__file__).resolve().parents[1] - -_D = 0.40 -_M = 100 -_H = 100 - - -def _evaluator(): - """Bare instance: __init__ needs no trainer, but skip the heavy base setup.""" - ev = ArcBimanualCartesianEval.__new__(ArcBimanualCartesianEval) - ev.action_key = "actions_cartesian" - ev.min_distance_unit = _D - ev.resampled_vector_length = _M - # These cases build mean-mode tokens ((M+1, 14)). - ev.velocity_mode = "mean" - ev.action_horizon = _H - ev._tokenizer = TokenizeBimanualArcLengthCartesian( - action_key="actions_cartesian", - output_action_key="actions_cartesian", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - ) - return ev - - -def _raw_chunk(steps: int = 200, seed: int = 0) -> np.ndarray: - """A (T, 14) bimanual cartesian chunk that actually travels.""" - del seed # the profile is deterministic; the arg keeps call sites uniform - chunk = np.zeros((steps, 14), dtype=np.float64) - t = np.linspace(0.0, 1.0, steps) - for base in (0, 7): # left arm, right arm - chunk[:, base + 0] = t * 0.6 # x sweeps 0.6 m - chunk[:, base + 1] = 0.1 * np.sin(2 * np.pi * t) # y wiggles - chunk[:, base + 2] = 0.05 * t - chunk[:, base + 3] = 0.4 * t # yaw - chunk[:, base + 6] = t # gripper opens - return chunk - - -def _token(steps: int = 200, seed: int = 0) -> np.ndarray: - tok = TokenizeBimanualArcLengthCartesian( - action_key="a", - output_action_key="a", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - ) - return np.asarray(tok.transform({"a": _raw_chunk(steps, seed=seed)})["a"]) - - -# -- the token itself ------------------------------------------------------- - - -def test_tokenizer_emits_m_waypoints_plus_a_velocity_row(): - assert _token().shape == (_M + 1, 14) - - -def test_velocity_row_is_not_distinguishable_by_magnitude(): - # This is WHY the detokenize-first ordering matters: nothing about the - # velocity row's scale marks it as a non-pose, so a revert applied to the - # whole token corrupts it invisibly. - t = _token() - # A mid-token waypoint, not row 0: the synthetic chunk starts at the origin - # so row 0 is all zeros. On real ABC data both scales are ~0.16. - waypoint_scale = float(np.abs(t[_M // 2]).mean()) - velocity_scale = float(np.abs(t[_M]).mean()) - assert 0.05 < velocity_scale / max(waypoint_scale, 1e-9) < 20.0 - - -# -- the evaluator seam ----------------------------------------------------- - - -def test_base_viz_source_is_identity(): - ev = BimanualCartesianEval.__new__(BimanualCartesianEval) - x = torch.arange(2 * 3 * 14, dtype=torch.float32).reshape(2, 3, 14) - assert ev._viz_source(x, 7) is x - - -def test_arc_viz_source_converts_tokens_to_pose_rows(): - ev = _evaluator() - batch = torch.from_numpy(np.stack([_token(), _token(seed=1)])).float() - out = ev._viz_source(batch, 7) - assert out.shape == (2, _H, 14) - assert out.dtype == batch.dtype - - -def test_arc_viz_source_drops_the_velocity_row(): - # The output must be poses only: M+1 rows in, action_horizon rows out, and - # no row of the result equal to the velocity token. - ev = _evaluator() - token = _token() - out = ev._viz_source(torch.from_numpy(token[None]).float(), 7)[0].numpy() - velocity = token[_M] - assert not any(np.allclose(row, velocity, atol=1e-6) for row in out) - - -def test_arc_viz_source_output_is_monotone_along_the_travelled_axis(): - # x sweeps forward in the source chunk, so the reconstruction should too. - ev = _evaluator() - out = ev._viz_source(torch.from_numpy(_token()[None]).float(), 7)[0].numpy() - dx = np.diff(out[:, 0]) - assert (dx >= -1e-6).all(), "reconstruction moved backwards along x" - assert out[-1, 0] > out[0, 0] - - -def test_arc_viz_source_passes_a_time_indexed_chunk_through(): - """This evaluator deliberately serves baseline runs too. - - It used to reject a pose chunk as a misconfiguration. That is now the - supported path -- both arms of the ablation share one evaluator -- so a - chunk whose row count is not an arc token's is treated as poses and left - alone. - """ - ev = _evaluator() - chunk = torch.zeros(2, _H, 14) - assert ev._viz_source(chunk, 7).shape == chunk.shape - - -def test_arc_viz_source_rejects_the_other_velocity_modes_row_count(): - """A mode mismatch stays a hard error, unlike a baseline chunk. - - Row counts that belong to the OTHER velocity mode mean the evaluator and - the data config disagree. Passing those through would score arc tokens as - if they were poses, which reads plausibly and is therefore worse than - crashing. - """ - ev = _evaluator() # mean mode -> M + 1 rows - with pytest.raises(ValueError, match="disagree"): - ev._viz_source(torch.zeros(2, 2 * _M, 14), 7) - - -def test_arc_viz_source_rejects_a_non_bimanual_width(): - ev = _evaluator() - with pytest.raises(ValueError, match=r"\(B, T, 14\)"): - ev._viz_source(torch.zeros(2, 26, 7), 7) - - -def test_arc_round_trip_recovers_the_span_the_token_covers(): - # Detokenize is a reconstruction, not an inverse, but it must land in the - # right place: the decoded path should cover ~D of travel per arm. - ev = _evaluator() - out = ev._viz_source(torch.from_numpy(_token()[None]).double(), 7)[0].numpy() - travelled = float(np.linalg.norm(np.diff(out[:, :3], axis=0), axis=-1).sum()) - assert 0.5 * _D < travelled < 2.0 * _D, travelled - - -# -- config consistency ----------------------------------------------------- -# -# The detokenizer derives replay duration from D and the velocity token, so if -# the evaluator's D/M drift from the data config's the reconstruction runs at -# the wrong speed while every shape still matches. Nothing at runtime catches -# it, so pin it here. - - -def _compose(experiment: str): - from hydra import compose, initialize_config_dir - - root = str(_REPO / "egomimic/hydra_configs") - with initialize_config_dir(version_base=None, config_dir=root): - return compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment={experiment}", "++paths.root_dir=."], - ) - - -def test_arc_experiment_evaluator_matches_its_data_tokenizer(): - cfg = _compose("abc_arc/abc_fstshirt_arc_bc") - tok = cfg.data.train_datasets.yam_bimanual.resolver.transform_list - assert cfg.evaluator.min_distance_unit == tok.min_distance_unit - assert cfg.evaluator.resampled_vector_length == tok.resampled_vector_length - - -def test_arc_experiment_model_horizon_matches_the_token_row_count(): - from egomimic.rldb.zarr.arc_length_tokenizer import bimanual_arc_token_rows - - cfg = _compose("abc_arc/abc_fstshirt_arc_bc") - tok = cfg.data.train_datasets.yam_bimanual.resolver.transform_list - # Row count follows the velocity mode, so derive it rather than assume M+1. - expected_rows = bimanual_arc_token_rows( - int(tok.resampled_vector_length), cfg.abc.arc_velocity_mode - ) - assert cfg.abc.arc_token_rows == expected_rows - # All three diffusion stages must agree, or training aborts on batch one. - for stage in cfg.model.pipeline.stages: - if "action_horizon" in stage: - assert int(stage.action_horizon) == expected_rows - - -def test_arc_experiment_uses_the_arc_evaluator_not_the_baseline_one(): - cfg = _compose("abc_arc/abc_fstshirt_arc_bc") - assert cfg.evaluator._target_.endswith("ArcBimanualCartesianEval") - - -def test_baseline_shares_the_arc_evaluator_so_the_arms_are_comparable(): - """The baseline deliberately uses the SAME evaluator as the arc twin. - - It reaches the metrics by its own path -- its prediction is already a pose - chunk, so the shape predicate routes it away from the detokenizer and - through de-interpolation instead -- but the arcmatch settings must be - identical or the two arms are not measured in the same space. - """ - arc = _compose("abc_arc/abc_fstshirt_arc_bc") - baseline = _compose("abc_arc/abc_fstshirt_bc") - assert baseline.evaluator._target_ == arc.evaluator._target_ - for field in ( - "min_distance_unit", - "resampled_vector_length", - "arcmatch_points", - "arc_chunk_rows", - "velocity_mode", - ): - assert baseline.evaluator[field] == arc.evaluator[field], field - assert baseline.evaluator.arc_metrics is True - - -# -- per-waypoint velocity mode --------------------------------------------- - - -def _granular_evaluator(): - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - ) - - ev = ArcBimanualCartesianEval.__new__(ArcBimanualCartesianEval) - ev.action_key = "actions_cartesian" - ev.min_distance_unit = _D - ev.resampled_vector_length = _M - ev.action_horizon = _H - ev.velocity_mode = "per_waypoint" - ev._tokenizer = TokenizeBimanualArcLengthCartesian( - action_key="actions_cartesian", - output_action_key="actions_cartesian", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode="per_waypoint", - ) - return ev - - -def _granular_token(steps: int = 200) -> np.ndarray: - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - ) - - tok = TokenizeBimanualArcLengthCartesian( - action_key="a", - output_action_key="a", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode="per_waypoint", - ) - return np.asarray(tok.transform({"a": _raw_chunk(steps)})["a"]) - - -def test_granular_token_has_two_m_rows(): - from egomimic.rldb.zarr.arc_length_tokenizer import bimanual_arc_token_rows - - assert _granular_token().shape == (bimanual_arc_token_rows(_M, "per_waypoint"), 14) - assert _granular_token().shape[0] == 2 * _M - - -def test_granular_viz_source_converts_to_pose_rows(): - out = _granular_evaluator()._viz_source( - torch.from_numpy(_granular_token()[None]).float(), 7 - ) - assert out.shape == (1, _H, 14) - - -def test_granular_viz_source_rejects_a_mean_mode_token(): - # The two layouts are not interchangeable; catch the mismatch loudly. - with pytest.raises(ValueError, match="arc tokens"): - _granular_evaluator()._viz_source(torch.zeros(1, _M + 1, 14), 7) - - -def test_mean_viz_source_rejects_a_granular_token(): - with pytest.raises(ValueError, match="arc tokens"): - _evaluator()._viz_source(torch.zeros(1, 2 * _M, 14), 7) - - -def test_granular_beats_mean_on_a_decelerating_chunk(): - """The reason to switch: one token-wide rate mistimes non-uniform motion.""" - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - ) - - steps = 200 - t = np.linspace(0.0, 1.0, steps) - chunk = np.zeros((steps, 14)) - profile = np.sqrt(t) # front-loaded travel - for base in (0, 7): - chunk[:, base + 0] = profile * 0.6 - chunk[:, base + 3] = profile * 0.5 - chunk[:, base + 6] = profile - truth = chunk[:_H, :3] - - def err(mode): - tk = TokenizeBimanualArcLengthCartesian( - action_key="a", - output_action_key="a", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode=mode, - ) - token = np.asarray(tk.transform({"a": chunk.copy()})["a"]) - out = tk.detokenize(token, _H) - return float(np.linalg.norm(out[:, :3] - truth, axis=-1).mean()) - - assert err("per_waypoint") < err("mean") - - -def test_duration_beats_mean_on_a_decelerating_chunk(): - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - ) - - steps = 200 - t = np.linspace(0.0, 1.0, steps) - chunk = np.zeros((steps, 14)) - profile = np.sqrt(t) - for base in (0, 7): - chunk[:, base + 0] = profile * 0.6 - chunk[:, base + 3] = profile * 0.5 - chunk[:, base + 6] = profile - truth = chunk[:_H, :3] - - def err(mode): - tk = TokenizeBimanualArcLengthCartesian( - action_key="a", - output_action_key="a", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode=mode, - ) - token = np.asarray(tk.transform({"a": chunk.copy()})["a"]) - out = tk.detokenize(token, _H) - return float(np.linalg.norm(out[:, :3] - truth, axis=-1).mean()) - - assert err("duration") < err("mean") - - -def test_duration_token_has_two_m_rows_and_positive_dts(): - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - bimanual_arc_token_rows, - ) - - tok = TokenizeBimanualArcLengthCartesian( - action_key="a", - output_action_key="a", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode="duration", - ) - token = np.asarray(tok.transform({"a": _raw_chunk()})["a"]) - assert token.shape == (bimanual_arc_token_rows(_M, "duration"), 14) - # Per-arm Δt in first column of each arm block. - assert (token[_M:, 0] >= 0).all() - assert (token[_M:, 7] >= 0).all() - assert (token[_M:-1, 0] > 0).any() - - -def test_duration_viz_source_converts_to_pose_rows(): - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - ) - - ev = ArcBimanualCartesianEval.__new__(ArcBimanualCartesianEval) - ev.action_key = "actions_cartesian" - ev.min_distance_unit = _D - ev.resampled_vector_length = _M - ev.action_horizon = _H - ev.velocity_mode = "duration" - ev._tokenizer = TokenizeBimanualArcLengthCartesian( - action_key="actions_cartesian", - output_action_key="actions_cartesian", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode="duration", - ) - tok = TokenizeBimanualArcLengthCartesian( - action_key="a", - output_action_key="a", - min_distance_unit=_D, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode="duration", - ) - token = np.asarray(tok.transform({"a": _raw_chunk()})["a"]) - out = ev._viz_source(torch.from_numpy(token[None]).float(), 7) - assert out.shape == (1, _H, 14) - - -def test_experiment_wires_one_velocity_mode_across_data_and_evaluator(): - cfg = _compose("abc_arc/abc_fstshirt_arc_bc") - mode = cfg.abc.arc_velocity_mode - assert cfg.evaluator.velocity_mode == mode - for split in ("train_datasets", "valid_datasets"): - assert ( - cfg.data[split].yam_bimanual.resolver.transform_list.velocity_mode == mode - ) diff --git a/tests/test_arc_length_tokenizer.py b/tests/test_arc_length_tokenizer.py deleted file mode 100644 index 92f9873c2c..0000000000 --- a/tests/test_arc_length_tokenizer.py +++ /dev/null @@ -1,844 +0,0 @@ -"""Unit tests for the bimanual arc-length tokenizer. - -Covers, in order: -- core single-arm tokenizer: arc-length math, chunking, uniform resampling, - SLERP rotation handling, velocity payloads, zero tokens, input validation; -- bimanual layer: (T, 14) -> (M, 16) shapes, per-arm independent arc length, - stationary-arm zero velocity, invalid-pose sentinel; -- round-trip (deploy inverse): tokenize -> detokenize recovers the original - time-indexed chunk for constant-velocity motion, holds pose for stationary - tokens, and stays on-path for curved motion; -- the Transform-style pipeline adapter. - -Adapted from the GR00T arc-length tokenizer tests -(gr00t branch ``rpunamiya/arc-length-tokenizer``, -``tests/core/data/state_action/test_arc_length_*.py``). -""" - -from __future__ import annotations - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation as R -from scipy.spatial.transform import Slerp - -from egomimic.rldb.zarr.arc_length_tokenizer import ( - ARC_BIMANUAL_DIM, - ARM_DIM, - BIMANUAL_CARTESIAN_DIM, - INVALID_POSE_FILL, - ArcLengthTokenizer, - BimanualArcLengthConfig, - BimanualArcLengthTokenizer, - TokenizeBimanualArcLength, - VelocityMode, - cumulative_arc_length, - velocity_dim, -) - -T_IN = 40 # source action horizon (time steps) -DT = 1.0 / 30.0 - - -# --------------------------------------------------------------------------- # -# Helpers -# --------------------------------------------------------------------------- # -def _rotation_angle(ypr_a: np.ndarray, ypr_b: np.ndarray) -> float: - """Geodesic angle (rad) between two ypr rotations — robust to euler wrap.""" - ra = R.from_euler("ZYX", np.asarray(ypr_a).reshape(1, 3)) - rb = R.from_euler("ZYX", np.asarray(ypr_b).reshape(1, 3)) - return float((ra.inv() * rb).magnitude()[0]) - - -def _point_to_polyline_dist(p: np.ndarray, poly: np.ndarray) -> float: - """Min distance from point p (3,) to the polyline through poly (N, 3).""" - a, b = poly[:-1], poly[1:] - ab = b - a - t = np.clip( - ((p[None] - a) * ab).sum(-1) / np.maximum((ab * ab).sum(-1), 1e-12), 0.0, 1.0 - ) - proj = a + t[:, None] * ab - return float(np.linalg.norm(proj - p[None], axis=-1).min()) - - -def _make_arm_line( - T: int, total_dist: float, seed: int, rot_magnitude: float = 0.3 -) -> np.ndarray: - """(T, 7) arm chunk: constant-speed straight line, uniform SLERP rotation, - linear gripper 0.2 -> 0.8.""" - rng = np.random.default_rng(seed) - direction = rng.normal(size=3) - direction /= np.linalg.norm(direction) - start = rng.normal(size=3) * 0.1 - s = np.linspace(0.0, total_dist, T) - pos = start[None, :] + s[:, None] * direction[None, :] - - r0 = R.identity() - r1 = R.from_rotvec(rng.normal(size=3) * rot_magnitude) - slerp = Slerp([0.0, 1.0], R.concatenate([r0, r1])) - ypr = slerp(np.linspace(0.0, 1.0, T)).as_euler("ZYX", degrees=False) - - gripper = np.linspace(0.2, 0.8, T)[:, None] - return np.concatenate([pos, ypr, gripper], axis=-1) - - -def _make_arm_static(T: int, seed: int) -> np.ndarray: - """(T, 7) arm chunk holding one random pose with gripper 0.5.""" - rng = np.random.default_rng(seed) - pos = rng.normal(size=3) * 0.1 - ypr = R.from_rotvec(rng.normal(size=3) * 0.3).as_euler("ZYX", degrees=False) - arm = np.concatenate([pos, ypr, [0.5]]) - return np.repeat(arm[None, :], T, axis=0) - - -def _bimanual(left: np.ndarray, right: np.ndarray) -> np.ndarray: - assert left.shape == right.shape and left.shape[1] == ARM_DIM - return np.concatenate([left, right], axis=-1) - - -def _axis_line_arm( - T: int, speed: float, axis: int = 0, offset_z: float = 0.0 -) -> np.ndarray: - """(T, 7) arm moving along one axis at constant speed, identity rotation.""" - t = np.arange(T) * DT - pos = np.zeros((T, 3)) - pos[:, axis] = speed * t - pos[:, 2] += offset_z - ypr = np.zeros((T, 3)) - gripper = np.linspace(0.0, 1.0, T)[:, None] - return np.concatenate([pos, ypr, gripper], axis=-1) - - -def _cfg( - M: int = 20, mode: str = "mean_scalar", unit: float = 0.05 -) -> BimanualArcLengthConfig: - return BimanualArcLengthConfig( - min_distance_unit=unit, - resampled_vector_length=M, - mode=mode, - dt=DT, - ) - - -# --------------------------------------------------------------------------- # -# Core: arc-length math -# --------------------------------------------------------------------------- # -def test_cumulative_arc_length_known_path() -> None: - pos = np.array([[0, 0, 0], [1, 0, 0], [1, 2, 0], [1, 2, 2]], dtype=np.float64) - np.testing.assert_allclose(cumulative_arc_length(pos), [0.0, 1.0, 3.0, 5.0]) - - -def test_tokenize_chunk_count_and_distance() -> None: - """A 0.2 m straight line with a 0.05 m unit splits into 4 motion tokens.""" - arm = _axis_line_arm(T=61, speed=0.1) # 60 steps * 0.1/30 m = 0.2 m - tok = ArcLengthTokenizer(min_distance_unit=0.05, resampled_vector_length=10, dt=DT) - tokens = tok.tokenize(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7]) - assert len(tokens) == 4 - for token in tokens: - assert token.kind == "motion" - np.testing.assert_allclose(token.chunk_distance, 0.05, atol=1e-9) - # Tokens tile the trajectory contiguously. - assert tokens[0].start_idx == 0 - for prev, cur in zip(tokens, tokens[1:]): - assert cur.start_idx >= prev.start_idx - - -def test_tokenize_stationary_trajectory_returns_no_tokens() -> None: - """Full-trajectory chunking of a static arm covers zero arc length.""" - arm = _make_arm_static(T=10, seed=0) - tok = ArcLengthTokenizer(min_distance_unit=0.05, resampled_vector_length=10) - assert tok.tokenize(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7]) == [] - - -def test_waypoints_uniform_in_arc_length() -> None: - """Waypoints are uniform in arc length even when input timing is not.""" - # Straight line along +x with wildly non-uniform time sampling. - s = np.concatenate([np.linspace(0.0, 0.01, 15), np.linspace(0.012, 0.08, 10)]) - pos = np.stack([s, np.zeros_like(s), np.zeros_like(s)], axis=-1) - ypr = np.zeros((len(s), 3)) - tok = ArcLengthTokenizer(min_distance_unit=0.05, resampled_vector_length=20, dt=DT) - token = tok.tokenize_at(pos, ypr, t=0) - assert token.kind == "motion" - spacing = np.linalg.norm(np.diff(token.pos, axis=0), axis=-1) - np.testing.assert_allclose(spacing, spacing[0], atol=1e-9) - np.testing.assert_allclose(spacing.sum(), 0.05, atol=1e-9) - - -def test_rotation_slerp_midpoint() -> None: - """A 90-degree yaw sweep across one token SLERPs to 45 degrees mid-chunk.""" - T = 21 - s = np.linspace(0.0, 0.05, T) - pos = np.stack([s, np.zeros(T), np.zeros(T)], axis=-1) - yaw = np.linspace(0.0, np.pi / 2, T) - ypr = np.stack([yaw, np.zeros(T), np.zeros(T)], axis=-1) - tok = ArcLengthTokenizer(min_distance_unit=0.05, resampled_vector_length=21, dt=DT) - token = tok.tokenize_at(pos, ypr, t=0) - mid = token.ypr[len(token.ypr) // 2] - expected_mid = np.array([np.pi / 4, 0.0, 0.0]) - assert _rotation_angle(mid, expected_mid) < 1e-6 - # Endpoints match the trajectory's endpoints exactly. - assert _rotation_angle(token.ypr[0], ypr[0]) < 1e-9 - assert _rotation_angle(token.ypr[-1], ypr[-1]) < 1e-6 - - -def test_velocity_mean_scalar_broadcast_and_magnitude() -> None: - """mean_scalar velocity is a single value ~= true speed for straight motion.""" - speed = 0.1 - arm = _axis_line_arm(T=T_IN, speed=speed) - tok = ArcLengthTokenizer( - min_distance_unit=0.05, resampled_vector_length=20, mode="mean_scalar", dt=DT - ) - token = tok.tokenize_at(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7], t=0) - assert token.trans_vel.shape == (1,) - # Whole-step timing quantization bounds the payload within one step of truth. - assert speed <= token.trans_vel[0] < speed * 1.15 - - -def test_velocity_per_step_scalar_constant_for_straight_line() -> None: - """per_step_scalar velocity is per-waypoint and constant for uniform motion.""" - speed = 0.1 - arm = _axis_line_arm(T=T_IN, speed=speed) - M = 20 - tok = ArcLengthTokenizer( - min_distance_unit=0.05, resampled_vector_length=M, mode="per_step_scalar", dt=DT - ) - token = tok.tokenize_at(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7], t=0) - assert token.trans_vel.shape == (M,) - np.testing.assert_allclose(token.trans_vel, token.trans_vel[0], rtol=1e-9) - assert speed * 0.85 < token.trans_vel[0] < speed * 1.15 - - -def test_stationary_produces_zero_token() -> None: - arm = _make_arm_static(T=T_IN, seed=3) - tok = ArcLengthTokenizer(min_distance_unit=0.05, resampled_vector_length=20, dt=DT) - token = tok.tokenize_at(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7], t=0) - assert token.kind == "zero" - assert token.zero_reason == "distance_below_epsilon" - np.testing.assert_allclose(token.trans_vel, 0.0) - np.testing.assert_allclose(token.pos, np.broadcast_to(arm[0, 0:3], token.pos.shape)) - - -def test_input_validation() -> None: - tok = ArcLengthTokenizer() - with pytest.raises(ValueError, match=r"pos must have shape"): - tok.tokenize(np.zeros((5, 2)), np.zeros((5, 3))) - with pytest.raises(ValueError, match=r"ypr must have shape"): - tok.tokenize(np.zeros((5, 3)), np.zeros((5, 4))) - with pytest.raises(ValueError, match=r"same length"): - tok.tokenize(np.zeros((5, 3)), np.zeros((4, 3))) - with pytest.raises(ValueError, match=r"at least 2"): - tok.tokenize(np.zeros((1, 3)), np.zeros((1, 3))) - with pytest.raises(ValueError, match=r"resampled_vector_length"): - ArcLengthTokenizer(resampled_vector_length=1) - with pytest.raises(ValueError): - ArcLengthTokenizer(mode="not_a_mode") - with pytest.raises(ValueError, match=r"t must be in"): - tok.tokenize_at(np.zeros((5, 3)), np.zeros((5, 3)), t=7) - - -# --------------------------------------------------------------------------- # -# Bimanual layer: shapes and per-arm independence -# --------------------------------------------------------------------------- # -@pytest.mark.parametrize("M", [10, 20]) -@pytest.mark.parametrize( - "mode,expected_dim", - [ - ("mean_scalar", 16), - ("per_step_scalar", 16), - ("mean_per_dim", 20), - ("per_step_per_dim", 20), - ], -) -@pytest.mark.parametrize("unit", [0.05, 0.10]) -def test_bimanual_output_shapes( - M: int, mode: str, expected_dim: int, unit: float -) -> None: - chunk = _bimanual( - _make_arm_line(T_IN, total_dist=0.13, seed=1), - _make_arm_line(T_IN, total_dist=0.07, seed=2), - ) - tokenizer = BimanualArcLengthTokenizer(_cfg(M=M, mode=mode, unit=unit)) - assert tokenizer.arc_dim == expected_dim - out = tokenizer.tokenize(chunk) - assert out.shape == (M, expected_dim) - assert np.all(np.isfinite(out)) - - -def test_bimanual_first_waypoint_anchored_and_unit_span() -> None: - """Waypoint 0 equals the input pose at t=0; the token spans one distance - unit of each arm's own arc length.""" - left = _axis_line_arm(T_IN, speed=0.10) - right = _axis_line_arm(T_IN, speed=0.05, axis=1, offset_z=0.5) - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20)) - out = tokenizer.tokenize(_bimanual(left, right)) - - left_out, right_out = out[:, 0:8], out[:, 8:16] - np.testing.assert_allclose(left_out[0, 0:7], left[0], atol=1e-9) - np.testing.assert_allclose(right_out[0, 0:7], right[0], atol=1e-9) - # Straight lines: chord == arc == one distance unit, per arm. - assert abs(np.linalg.norm(left_out[-1, 0:3] - left_out[0, 0:3]) - 0.05) < 1e-9 - assert abs(np.linalg.norm(right_out[-1, 0:3] - right_out[0, 0:3]) - 0.05) < 1e-9 - - -def test_bimanual_independent_arm_parameterizations() -> None: - """Each arm resamples along its own line at its own speed.""" - left = _axis_line_arm(T_IN, speed=0.10, axis=0) - right = _axis_line_arm(T_IN, speed=0.05, axis=1, offset_z=0.5) - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20)) - out = tokenizer.tokenize(_bimanual(left, right)) - - left_pos, right_pos = out[:, 0:3], out[:, 8:11] - # Left moves along +x only; right along +y at z=0.5 only. - np.testing.assert_allclose(left_pos[:, 1:], 0.0, atol=1e-12) - np.testing.assert_allclose(right_pos[:, 0], 0.0, atol=1e-12) - np.testing.assert_allclose(right_pos[:, 2], 0.5, atol=1e-12) - assert np.all(np.diff(left_pos[:, 0]) > 0) - assert np.all(np.diff(right_pos[:, 1]) > 0) - # Velocity channels reflect each arm's own speed (fast left > slow right). - vel_left, vel_right = out[:, 7], out[:, 15] - np.testing.assert_allclose(vel_left, vel_left[0], atol=1e-12) - np.testing.assert_allclose(vel_right, vel_right[0], atol=1e-12) - assert vel_left[0] > vel_right[0] > 0.0 - assert abs(vel_left[0] - 0.10) / 0.10 < 0.15 - assert abs(vel_right[0] - 0.05) / 0.05 < 0.15 - - -def test_bimanual_stationary_arm_zero_velocity() -> None: - """A static right arm yields a zero token: held pose, zero velocity — - while the left arm tokenizes normally.""" - left = _axis_line_arm(T_IN, speed=0.10) - right = _make_arm_static(T_IN, seed=7) - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20)) - out = tokenizer.tokenize(_bimanual(left, right)) - - np.testing.assert_allclose(out[:, 15], 0.0, atol=1e-12) # right trans_vel - np.testing.assert_allclose( # held pos - out[:, 8:11], np.broadcast_to(right[0, 0:3], (20, 3)), atol=1e-12 - ) - assert np.all(out[:, 7] > 0.0) # left still moving - - -def test_bimanual_invalid_pose_sentinel() -> None: - chunk = _bimanual( - _make_arm_line(T_IN, total_dist=0.13, seed=1), - _make_arm_line(T_IN, total_dist=0.07, seed=2), - ) - chunk[3, 2] = 1e9 # one invalid sample poisons the chunk - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20)) - out = tokenizer.tokenize(chunk) - assert out.shape == (20, ARC_BIMANUAL_DIM) - np.testing.assert_allclose(out, INVALID_POSE_FILL) - - -def test_bimanual_validation() -> None: - tokenizer = BimanualArcLengthTokenizer(_cfg()) - with pytest.raises(ValueError, match=r"\(T, 14\)"): - tokenizer.tokenize(np.zeros((10, 12))) - with pytest.raises(ValueError, match=r"\(M, 16\)"): - tokenizer.detokenize(np.zeros((20, 14)), action_horizon=10) - per_step = BimanualArcLengthTokenizer(_cfg(mode="per_step_scalar")) - with pytest.raises(NotImplementedError, match="mean_scalar"): - per_step.detokenize(np.zeros((20, ARC_BIMANUAL_DIM)), action_horizon=10) - - -# --------------------------------------------------------------------------- # -# Round trip: tokenize -> detokenize (deploy inverse) -# --------------------------------------------------------------------------- # -def test_roundtrip_constant_velocity_recovers_chunk() -> None: - """For constant-velocity motion the mean-scalar timing is (near-)exact, so - the round trip recovers the original time-indexed chunk to within the - whole-step timing quantization of one waypoint spacing.""" - T = 20 - cfg = _cfg(M=20) - # Each arm travels exactly one distance unit over the T-step chunk. - left = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=1) - right = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=2) - chunk = _bimanual(left, right) - - tokenizer = BimanualArcLengthTokenizer(cfg) - arc = tokenizer.tokenize(chunk) - rec = tokenizer.detokenize(arc, action_horizon=T) - assert rec.shape == (T, BIMANUAL_CARTESIAN_DIM) - - waypoint_spacing = cfg.min_distance_unit / (cfg.resampled_vector_length - 1) - for arm_in, arm_start in ((left, 0), (right, ARM_DIM)): - arm_rec = rec[:, arm_start : arm_start + ARM_DIM] - # PATH fidelity: every emitted position lies on the original path. - max_off_path = max( - _point_to_polyline_dist(p, arm_in[:, 0:3]) for p in arm_rec[:, 0:3] - ) - assert max_off_path < 5e-4 - # STEP-wise recovery within the timing-quantization bound. - pos_err = np.abs(arm_rec[:, 0:3] - arm_in[:, 0:3]).max() - assert pos_err < 1.5 * waypoint_spacing - rot_err = max( - _rotation_angle(arm_rec[i, 3:6], arm_in[i, 3:6]) for i in range(T) - ) - assert rot_err < 0.05 - grip_err = np.abs(arm_rec[:, 6] - arm_in[:, 6]).max() - assert grip_err < 0.1 - - -@pytest.mark.parametrize("H", [5, 20, 50]) -def test_detokenize_emits_exact_horizon(H: int) -> None: - cfg = _cfg(M=20) - chunk = _bimanual( - _make_arm_line(20, total_dist=cfg.min_distance_unit, seed=3), - _make_arm_line(20, total_dist=cfg.min_distance_unit, seed=4), - ) - tokenizer = BimanualArcLengthTokenizer(cfg) - rec = tokenizer.detokenize(tokenizer.tokenize(chunk), action_horizon=H) - assert rec.shape == (H, BIMANUAL_CARTESIAN_DIM) - assert np.all(np.isfinite(rec)) - - -def test_roundtrip_zero_motion_holds_pose() -> None: - T = 20 - cfg = _cfg(M=20) - left = _make_arm_static(T, seed=5) - right = _make_arm_static(T, seed=6) - tokenizer = BimanualArcLengthTokenizer(cfg) - rec = tokenizer.detokenize( - tokenizer.tokenize(_bimanual(left, right)), action_horizon=T - ) - for arm_in, arm_start in ((left, 0), (right, ARM_DIM)): - arm_rec = rec[:, arm_start : arm_start + ARM_DIM] - np.testing.assert_allclose( - arm_rec[:, 0:3], np.broadcast_to(arm_in[0, 0:3], (T, 3)), atol=1e-9 - ) - rot_err = max( - _rotation_angle(arm_rec[i, 3:6], arm_in[0, 3:6]) for i in range(T) - ) - assert rot_err < 1e-9 - np.testing.assert_allclose(arm_rec[:, 6], arm_in[0, 6], atol=1e-9) - - -def test_roundtrip_curved_path_fidelity() -> None: - """A sharp quarter-circle token stays ON the predicted path within ~mm. - - mean_scalar velocity is chord-based, so per-chunk *progress* may - under-shoot on sharp curvature (recovered by closed-loop re-inference) — - but every emitted waypoint must lie on the path.""" - T = 24 - cfg = _cfg(M=20) - r = cfg.min_distance_unit / (np.pi / 2) # quarter circle of arc 0.05 m - theta = np.linspace(0.0, np.pi / 2, T) - pos = np.stack([r * np.cos(theta), r * np.sin(theta), np.zeros(T)], axis=-1) - arm = np.concatenate( - [pos, np.zeros((T, 3)), np.linspace(0, 1, T)[:, None]], axis=-1 - ) - tokenizer = BimanualArcLengthTokenizer(cfg) - rec = tokenizer.detokenize( - tokenizer.tokenize(_bimanual(arm, arm)), action_horizon=T - ) - max_off_path = max(_point_to_polyline_dist(p, pos) for p in rec[:, 0:3]) - assert max_off_path < 1e-3 - - -def test_roundtrip_gentle_curve_recovered_tightly() -> None: - """A gentle curve (representative of real ~5 cm EEF motion) recovers - tightly: chord ~= arc, so timing is near-exact.""" - T = 20 - cfg = _cfg(M=20) - r = 0.5 # 0.05 m of arc on a 0.5 m radius circle -> nearly straight - theta = np.linspace(0.0, cfg.min_distance_unit / r, T) - pos = np.stack([r * np.sin(theta), r * (1 - np.cos(theta)), np.zeros(T)], axis=-1) - arm = np.concatenate( - [pos, np.zeros((T, 3)), np.linspace(0, 1, T)[:, None]], axis=-1 - ) - tokenizer = BimanualArcLengthTokenizer(cfg) - rec = tokenizer.detokenize( - tokenizer.tokenize(_bimanual(arm, arm)), action_horizon=T - ) - assert np.abs(rec[:, 0:3] - pos).max() < 5e-4 - - -def test_mean_vs_per_step_velocity_diverges_for_curved_motion() -> None: - """mean_scalar measures chord/time; per_step_scalar measures per-segment - arc/time. They agree for straight lines and must diverge on tight curves.""" - t = np.arange(T_IN) * DT - r, omega = 0.02, 6.0 # ~143 degrees of a small circle within one 5 cm token - theta = omega * t - pos = np.stack([r * np.cos(theta), r * np.sin(theta), np.zeros_like(t)], axis=-1) - curved_arm = np.concatenate( - [pos, np.zeros((T_IN, 3)), np.linspace(0, 1, T_IN)[:, None]], axis=-1 - ) - chunk = _bimanual(curved_arm, _axis_line_arm(T_IN, speed=0.1)) - - vels = {} - for mode in ("mean_scalar", "per_step_scalar"): - out = BimanualArcLengthTokenizer(_cfg(M=20, mode=mode)).tokenize(chunk) - vels[mode] = out[:, 7] - assert not np.isclose(vels["mean_scalar"][0], vels["per_step_scalar"][0], rtol=1e-3) - - -# --------------------------------------------------------------------------- # -# Transform-style pipeline adapter -# --------------------------------------------------------------------------- # -def test_transform_adapter() -> None: - chunk = _bimanual( - _make_arm_line(T_IN, total_dist=0.13, seed=8), - _make_arm_line(T_IN, total_dist=0.07, seed=9), - ) - batch = {"actions_cartesian": chunk.copy(), "unrelated": np.arange(3)} - transform = TokenizeBimanualArcLength( - action_key="actions_cartesian", - output_action_key="actions_arc", - config=_cfg(M=20), - ) - out = transform.transform(batch) - assert out is batch - assert out["actions_arc"].shape == (20, ARC_BIMANUAL_DIM) - np.testing.assert_allclose(out["actions_cartesian"], chunk) # input untouched - np.testing.assert_allclose(out["unrelated"], np.arange(3)) - # Adapter output matches calling the tokenizer directly. - direct = BimanualArcLengthTokenizer(_cfg(M=20)).tokenize(chunk) - np.testing.assert_allclose(out["actions_arc"], direct) - - -def test_velocity_mode_enum_values() -> None: - assert VelocityMode("mean_scalar") is VelocityMode.MEAN_SCALAR - assert VelocityMode("mean_per_dim") is VelocityMode.MEAN_PER_DIM - assert VelocityMode("per_step_scalar") is VelocityMode.PER_STEP_SCALAR - assert VelocityMode("per_step_per_dim") is VelocityMode.PER_STEP_PER_DIM - assert velocity_dim("mean_scalar") == velocity_dim("per_step_scalar") == 1 - assert velocity_dim("mean_per_dim") == velocity_dim("per_step_per_dim") == 3 - with pytest.raises(ValueError): - VelocityMode("not_a_mode") - - -# --------------------------------------------------------------------------- # -# Per-dim velocity modes -# --------------------------------------------------------------------------- # -def test_velocity_mean_per_dim_payload() -> None: - """mean_per_dim payload is the displacement/time vector: for motion along - +x it points along +x with near-zero other components.""" - speed = 0.1 - arm = _axis_line_arm(T=T_IN, speed=speed) - tok = ArcLengthTokenizer( - min_distance_unit=0.05, resampled_vector_length=20, mode="mean_per_dim", dt=DT - ) - token = tok.tokenize_at(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7], t=0) - assert token.trans_vel.shape == (3,) - assert speed <= token.trans_vel[0] < speed * 1.15 - np.testing.assert_allclose(token.trans_vel[1:], 0.0, atol=1e-12) - - -def test_velocity_per_step_per_dim_payload() -> None: - """per_step_per_dim payload is (M, 3), constant rows for uniform motion.""" - speed = 0.1 - arm = _axis_line_arm(T=T_IN, speed=speed) - M = 20 - tok = ArcLengthTokenizer( - min_distance_unit=0.05, - resampled_vector_length=M, - mode="per_step_per_dim", - dt=DT, - ) - token = tok.tokenize_at(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7], t=0) - assert token.trans_vel.shape == (M, 3) - np.testing.assert_allclose( - token.trans_vel, np.broadcast_to(token.trans_vel[0], (M, 3)), atol=1e-9 - ) - assert speed * 0.85 < token.trans_vel[0, 0] < speed * 1.15 - np.testing.assert_allclose(token.trans_vel[:, 1:], 0.0, atol=1e-12) - - -def test_per_dim_zero_velocity_shapes() -> None: - """Stationary trajectories produce per-dim zero payloads of the right shape.""" - arm = _make_arm_static(T=T_IN, seed=3) - for mode, shape in (("mean_per_dim", (3,)), ("per_step_per_dim", (20, 3))): - tok = ArcLengthTokenizer( - min_distance_unit=0.05, resampled_vector_length=20, mode=mode, dt=DT - ) - token = tok.tokenize_at(arm[:, 0:3], arm[:, 3:6], arm[:, 6:7], t=0) - assert token.kind == "zero" - assert token.trans_vel.shape == shape - np.testing.assert_allclose(token.trans_vel, 0.0) - - -def test_bimanual_per_dim_layout_and_values() -> None: - """mean_per_dim: (M, 20) layout with per-arm 3-vector velocity columns that - point along each arm's own motion direction.""" - left = _axis_line_arm(T_IN, speed=0.10, axis=0) - right = _axis_line_arm(T_IN, speed=0.05, axis=1, offset_z=0.5) - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20, mode="mean_per_dim")) - out = tokenizer.tokenize(_bimanual(left, right)) - assert out.shape == (20, 20) - - vel_left, vel_right = out[:, 7:10], out[:, 17:20] - np.testing.assert_allclose(vel_left, np.broadcast_to(vel_left[0], (20, 3))) - np.testing.assert_allclose(vel_right, np.broadcast_to(vel_right[0], (20, 3))) - # Left moves along +x, right along +y — each velocity vector matches. - assert 0.10 <= vel_left[0, 0] < 0.10 * 1.15 - np.testing.assert_allclose(vel_left[0, 1:], 0.0, atol=1e-12) - assert 0.05 <= vel_right[0, 1] < 0.05 * 1.15 - np.testing.assert_allclose(vel_right[0, [0, 2]], 0.0, atol=1e-12) - - -def test_roundtrip_mean_per_dim_recovers_chunk() -> None: - """The mean_per_dim round trip matches the mean_scalar one: the timing - speed is the norm of the velocity vector.""" - T = 20 - cfg = _cfg(M=20, mode="mean_per_dim") - left = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=1) - right = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=2) - tokenizer = BimanualArcLengthTokenizer(cfg) - arc = tokenizer.tokenize(_bimanual(left, right)) - rec = tokenizer.detokenize(arc, action_horizon=T) - assert rec.shape == (T, BIMANUAL_CARTESIAN_DIM) - - waypoint_spacing = cfg.min_distance_unit / (cfg.resampled_vector_length - 1) - for arm_in, arm_start in ((left, 0), (right, ARM_DIM)): - arm_rec = rec[:, arm_start : arm_start + ARM_DIM] - assert np.abs(arm_rec[:, 0:3] - arm_in[:, 0:3]).max() < 1.5 * waypoint_spacing - rot_err = max( - _rotation_angle(arm_rec[i, 3:6], arm_in[i, 3:6]) for i in range(T) - ) - assert rot_err < 0.05 - assert np.abs(arm_rec[:, 6] - arm_in[:, 6]).max() < 0.1 - - -def test_core_detokenize_per_step_modes() -> None: - """Core full-trajectory detokenize supports the per-step modes: a straight - line reconstructs on-path with matching endpoints.""" - arm = _axis_line_arm(T=61, speed=0.1) # 0.2 m total - pos_in = arm[:, 0:3] - for mode in ("per_step_scalar", "per_step_per_dim"): - tok = ArcLengthTokenizer( - min_distance_unit=0.05, resampled_vector_length=10, mode=mode, dt=DT - ) - tokens = tok.tokenize(pos_in, arm[:, 3:6], arm[:, 6:7]) - pos_out, ypr_out, grip_out = tok.detokenize(tokens) - np.testing.assert_allclose(pos_out[0], pos_in[0], atol=1e-9) - np.testing.assert_allclose(pos_out[-1], pos_in[-1], atol=1e-9) - max_off_path = max(_point_to_polyline_dist(p, pos_in) for p in pos_out) - assert max_off_path < 1e-9 - # Progress is monotone and the step count is near the input's: each of - # the M-1 waypoint segments per chunk rounds its step count up (ceil), - # so the reconstruction can overshoot by at most M-1 steps per chunk. - assert np.all(np.diff(pos_out[:, 0]) >= -1e-12) - M = tok.M - assert abs(len(pos_out) - len(pos_in)) <= len(tokens) * (M - 1) - - -# --------------------------------------------------------------------------- # -# Joint resampling -# --------------------------------------------------------------------------- # -def _linear_joints(T: int, J: int = 6, lo: float = 0.0, hi: float = 1.0) -> np.ndarray: - return np.linspace(lo, hi, T)[:, None] * np.ones(J)[None, :] - - -def test_tokenize_with_joints_shapes_and_anchor() -> None: - """Joints resample to (M, J) along each arm's own arc length; the first - waypoint is anchored at the input joints at t=0, and an arm covering - exactly one distance unit ends at the input's final joints.""" - T = 20 - cfg = _cfg(M=20) - left = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=1) - right = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=2) - joints_left = _linear_joints(T) - joints_right = _linear_joints(T, lo=1.0, hi=-1.0) - tokenizer = BimanualArcLengthTokenizer(cfg) - actions, jl, jr = tokenizer.tokenize_with_joints( - _bimanual(left, right), joints_left, joints_right - ) - assert actions.shape == (20, tokenizer.arc_dim) - assert jl.shape == (20, 6) and jr.shape == (20, 6) - np.testing.assert_allclose(jl[0], joints_left[0], atol=1e-12) - np.testing.assert_allclose(jr[0], joints_right[0], atol=1e-12) - np.testing.assert_allclose(jl[-1], joints_left[-1], atol=1e-9) - np.testing.assert_allclose(jr[-1], joints_right[-1], atol=1e-9) - # Constant-speed line: joints linear in time == linear in arc length, so - # the resampled joints are uniformly spaced across waypoints. - steps = np.diff(jl[:, 0]) - np.testing.assert_allclose(steps, steps[0], atol=1e-9) - - -def test_tokenize_without_joints_returns_none() -> None: - chunk = _bimanual( - _make_arm_line(T_IN, total_dist=0.13, seed=1), - _make_arm_line(T_IN, total_dist=0.07, seed=2), - ) - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20)) - actions, jl, jr = tokenizer.tokenize_with_joints(chunk) - assert jl is None and jr is None - np.testing.assert_allclose(actions, tokenizer.tokenize(chunk)) - - -def test_tokenize_with_joints_zero_arm_holds_initial() -> None: - """A stationary arm's joints are held at their initial value (zero-token - semantics), while the moving arm's joints resample normally.""" - left = _axis_line_arm(T_IN, speed=0.10) - right = _make_arm_static(T_IN, seed=7) - joints_left = _linear_joints(T_IN) - joints_right = _linear_joints(T_IN) # vary in time, but arm is static - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20)) - _, jl, jr = tokenizer.tokenize_with_joints( - _bimanual(left, right), joints_left, joints_right - ) - np.testing.assert_allclose(jr, np.broadcast_to(joints_right[0], (20, 6))) - assert jl[-1, 0] > jl[0, 0] # moving arm's joints actually progress - - -def test_detokenize_with_joints_roundtrip() -> None: - """Joints survive the tokenize -> detokenize round trip within the same - timing-quantization bound as the gripper channel.""" - T = 20 - cfg = _cfg(M=20) - left = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=1) - right = _make_arm_line(T, total_dist=cfg.min_distance_unit, seed=2) - joints_left = _linear_joints(T) - joints_right = _linear_joints(T, lo=0.5, hi=-0.5) - tokenizer = BimanualArcLengthTokenizer(cfg) - arc, jl, jr = tokenizer.tokenize_with_joints( - _bimanual(left, right), joints_left, joints_right - ) - rec, jl_rec, jr_rec = tokenizer.detokenize_with_joints( - arc, action_horizon=T, joints_left=jl, joints_right=jr - ) - assert jl_rec.shape == (T, 6) and jr_rec.shape == (T, 6) - assert np.abs(jl_rec - joints_left).max() < 0.1 - assert np.abs(jr_rec - joints_right).max() < 0.1 - - -def test_detokenize_with_joints_stationary_holds() -> None: - T = 20 - cfg = _cfg(M=20) - left = _make_arm_static(T, seed=5) - right = _make_arm_static(T, seed=6) - joints = _linear_joints(T) - tokenizer = BimanualArcLengthTokenizer(cfg) - arc, jl, jr = tokenizer.tokenize_with_joints(_bimanual(left, right), joints, joints) - _, jl_rec, jr_rec = tokenizer.detokenize_with_joints( - arc, action_horizon=T, joints_left=jl, joints_right=jr - ) - np.testing.assert_allclose(jl_rec, np.broadcast_to(joints[0], (T, 6)), atol=1e-12) - np.testing.assert_allclose(jr_rec, np.broadcast_to(joints[0], (T, 6)), atol=1e-12) - - -def test_joints_validation_and_sentinel() -> None: - chunk = _bimanual( - _make_arm_line(T_IN, total_dist=0.13, seed=1), - _make_arm_line(T_IN, total_dist=0.07, seed=2), - ) - tokenizer = BimanualArcLengthTokenizer(_cfg(M=20)) - with pytest.raises(ValueError, match="joints_left"): - tokenizer.tokenize_with_joints(chunk, joints_left=_linear_joints(T_IN + 1)) - with pytest.raises(ValueError, match="joints_right"): - tokenizer.detokenize_with_joints( - np.zeros((20, 16)), action_horizon=5, joints_right=np.zeros((19, 6)) - ) - # Sentinel chunks poison the joints output too. - bad = chunk.copy() - bad[0, 0] = 1e9 - actions, jl, jr = tokenizer.tokenize_with_joints( - bad, _linear_joints(T_IN), _linear_joints(T_IN) - ) - np.testing.assert_allclose(actions, INVALID_POSE_FILL) - np.testing.assert_allclose(jl, INVALID_POSE_FILL) - np.testing.assert_allclose(jr, INVALID_POSE_FILL) - - -def test_transform_adapter_with_joints() -> None: - chunk = _bimanual( - _make_arm_line(T_IN, total_dist=0.13, seed=8), - _make_arm_line(T_IN, total_dist=0.07, seed=9), - ) - batch = { - "actions_cartesian": chunk.copy(), - "left.cmd_joint_pos": _linear_joints(T_IN), - "right.cmd_joint_pos": _linear_joints(T_IN, lo=1.0, hi=0.0), - } - transform = TokenizeBimanualArcLength( - action_key="actions_cartesian", - output_action_key="actions_arc", - config=_cfg(M=20), - left_joint_key="left.cmd_joint_pos", - right_joint_key="right.cmd_joint_pos", - ) - out = transform.transform(batch) - assert out["actions_arc"].shape == (20, 16) - assert out["left.cmd_joint_pos_arc"].shape == (20, 6) - assert out["right.cmd_joint_pos_arc"].shape == (20, 6) - # Matches the direct tokenizer call. - _, jl, jr = BimanualArcLengthTokenizer(_cfg(M=20)).tokenize_with_joints( - chunk, batch["left.cmd_joint_pos"], batch["right.cmd_joint_pos"] - ) - np.testing.assert_allclose(out["left.cmd_joint_pos_arc"], jl) - np.testing.assert_allclose(out["right.cmd_joint_pos_arc"], jr) - - -# --------------------------------------------------------------------------- -# Vectorized arc-length resampling: equivalence with the per-target helpers -# --------------------------------------------------------------------------- - - -def _scalar_resample(pos, ypr, grip, cumdist, targets): - """Reference: the original per-target implementation.""" - from egomimic.rldb.zarr.arc_length_tokenizer import ( - _interp_linear_at_s, - _interp_pos_at_s, - _interp_ypr_at_s, - ) - - return ( - np.stack([_interp_pos_at_s(pos, cumdist, float(s)) for s in targets]), - np.stack([_interp_ypr_at_s(ypr, cumdist, float(s)) for s in targets]), - np.stack([_interp_linear_at_s(grip, cumdist, float(s)) for s in targets]), - ) - - -def _random_track(rng, n): - """A wandering track with rotation and a gripper channel.""" - step = rng.normal(scale=0.02, size=(n - 1, 3)) - pos = np.concatenate([np.zeros((1, 3)), np.cumsum(step, axis=0)]) - ypr = np.cumsum(rng.normal(scale=0.05, size=(n, 3)), axis=0) - grip = rng.uniform(size=(n, 1)) - return pos, ypr, grip - - -@pytest.mark.parametrize("n,k", [(2, 5), (7, 20), (60, 100), (200, 100)]) -def test_resample_at_arc_lengths_matches_per_target_helpers(n, k): - """The vectorized path reproduces the per-target helpers it replaces.""" - from egomimic.rldb.zarr.arc_length_tokenizer import ( - cumulative_arc_length, - resample_at_arc_lengths, - ) - - rng = np.random.default_rng(0) - pos, ypr, grip = _random_track(rng, n) - cumdist = cumulative_arc_length(pos) - # Interior targets plus both clamped ends and an exact knot. - targets = np.concatenate( - [ - np.linspace(0.0, float(cumdist[-1]), k), - [-1.0, float(cumdist[-1]) + 1.0, float(cumdist[min(1, n - 1)])], - ] - ) - got = resample_at_arc_lengths(pos, ypr, grip, cumdist, targets) - want = _scalar_resample(pos, ypr, grip, cumdist, targets) - np.testing.assert_array_equal(got[0], want[0]) # linear interp: bit-identical - np.testing.assert_array_equal(got[2], want[2]) - # Rotation goes through one batched geodesic step instead of one Slerp per - # target; same geodesic, float-precision difference only. - np.testing.assert_allclose(got[1], want[1], atol=1e-12, rtol=0) - - -def test_resample_at_arc_lengths_handles_stationary_clusters(): - """Zero-length segments (a held pose) must not divide by zero.""" - from egomimic.rldb.zarr.arc_length_tokenizer import ( - cumulative_arc_length, - resample_at_arc_lengths, - ) - - pos = np.array([[0.0, 0, 0], [0, 0, 0], [0, 0, 0], [0.1, 0, 0], [0.2, 0, 0]]) - ypr = np.array([[0.0, 0, 0], [0.1, 0, 0], [0.2, 0, 0], [0.3, 0, 0], [0.4, 0, 0]]) - grip = np.zeros((5, 1)) - cumdist = cumulative_arc_length(pos) - targets = np.linspace(0.0, float(cumdist[-1]), 9) - got = resample_at_arc_lengths(pos, ypr, grip, cumdist, targets) - want = _scalar_resample(pos, ypr, grip, cumdist, targets) - assert np.all(np.isfinite(got[0])) and np.all(np.isfinite(got[1])) - np.testing.assert_array_equal(got[0], want[0]) - np.testing.assert_allclose(got[1], want[1], atol=1e-12, rtol=0) diff --git a/tests/test_arc_metrics.py b/tests/test_arc_metrics.py deleted file mode 100644 index f2c43e9d9d..0000000000 --- a/tests/test_arc_metrics.py +++ /dev/null @@ -1,686 +0,0 @@ -"""Arc-matched, DTW and time-domain metric families.""" - -import numpy as np -import pytest - -from egomimic.eval.arc_metrics import ( - ARM_XYZ, - PAIRED_COLS, - arcmatch_metrics, - arm_travel, - chunk_metrics, - clip_to_distance, - dtw_metrics, - dtw_path, - geodesic_deg, - match_spans, - mse, - pose_err_m, - shared_spans, - tokenize_span, -) - -_DT = 1.0 / 30.0 -_M = 32 -_LEVER = 0.1 - - -def _path(n: int = 200, span: float = 0.6) -> np.ndarray: - """One fixed bimanual curve: forward sweep plus a lateral wiggle.""" - t = np.linspace(0.0, 1.0, n) - chunk = np.zeros((n, 14)) - for base in (0, 7): - chunk[:, base + 0] = t * span - chunk[:, base + 1] = 0.05 * np.sin(2 * np.pi * t) - chunk[:, base + 3] = t * 0.4 - chunk[:, base + 6] = t - return chunk - - -# -- travel and spans ------------------------------------------------------- - - -def test_arm_travel_is_per_arm_and_in_metres(): - travel = arm_travel(_path()) - assert travel.shape == (2,) - # Both arms follow the same curve here, so they must agree. - assert travel[0] == pytest.approx(travel[1]) - assert travel[0] > 0.6 # the sweep plus the wiggle exceeds the x extent - - -def test_match_spans_takes_the_shorter_travel_per_arm(): - full = _path() - short = full[: len(full) // 2] - spans = match_spans(short, full) - np.testing.assert_allclose(spans, arm_travel(short), rtol=1e-9) - - -def test_shared_spans_also_clamps_by_D(): - full = _path(span=0.6) - spans = shared_spans(full, full, min_distance_unit=0.4) - np.testing.assert_allclose(spans, np.full(2, 0.4), rtol=1e-9) - - -def test_arcmatch_with_D_caps_reported_span(): - full = _path(span=0.6) - metrics = arcmatch_metrics( - [full], [full], num_points=_M, dt=_DT, lever_m=_LEVER, min_distance_unit=0.4 - ) - assert metrics["arcmatch_span_m"] == pytest.approx(0.4, abs=1e-6) - - -def test_arcmatch_keeps_samples_with_one_idle_arm(): - """A held arm (span 0) must not drop the whole sample from arcmatch.""" - moving = _path(80, span=0.5) - idle_right = moving.copy() - idle_right[:, 7:14] = idle_right[0:1, 7:14] # right arm stationary - metrics = arcmatch_metrics( - [idle_right], - [idle_right], - num_points=_M, - dt=_DT, - lever_m=_LEVER, - min_distance_unit=0.4, - ) - assert metrics, "idle-arm sample was dropped" - assert metrics["arcmatch_xyz_mse"] == pytest.approx(0.0, abs=1e-12) - - -def test_arm_travel_rejects_a_bad_shape(): - with pytest.raises(ValueError, match=r"\(T, 14\)"): - arm_travel(np.zeros((10, 7))) - - -def test_arm_travel_rejects_a_single_timestep(): - with pytest.raises(ValueError, match="at least two"): - arm_travel(np.zeros((1, 14))) - - -# -- the defining property: arcmatch divides travel out --------------------- - - -@pytest.mark.parametrize("fraction", [0.5, 0.75, 1.0]) -def test_arcmatch_is_travel_invariant_along_the_same_path(fraction): - """Same path, less of it travelled: shape error must stay zero. - - This is the whole point of the matched span. Without the cut, a prediction - that stops short would be scored against ground truth it never reached. - """ - full = _path() - pred = full[: max(2, int(len(full) * fraction))] - metrics = arcmatch_metrics([pred], [full], num_points=_M, dt=_DT, lever_m=_LEVER) - assert metrics["arcmatch_xyz_mse"] == pytest.approx(0.0, abs=1e-12) - assert metrics["arcmatch_travel_ratio"] == pytest.approx(fraction, abs=0.02) - - -def test_dtw_does_see_a_travel_mismatch_where_arcmatch_does_not(): - """The reason both families exist.""" - full = _path() - short = full[: len(full) // 2] - arc = arcmatch_metrics([short], [full], num_points=_M, dt=_DT, lever_m=_LEVER) - warped = dtw_metrics([short], [full]) - assert arc["arcmatch_xyz_mse"] == pytest.approx(0.0, abs=1e-12) - assert warped["dtw_xyz_l2_m"] > 0.01 - - -def test_a_perfect_prediction_scores_zero_on_every_family(): - full = _path() - arc = arcmatch_metrics([full], [full], num_points=_M, dt=_DT, lever_m=_LEVER) - warped = dtw_metrics([full], [full]) - plain = chunk_metrics(full[None], full[None], lever_m=_LEVER) - for name, value in {**arc, **warped, **plain}.items(): - if "span" in name or "ratio" in name: - continue - assert value == pytest.approx(0.0, abs=1e-12), name - - -def test_arcmatch_reports_span_and_ratio_for_reading_alongside(): - full = _path() - metrics = arcmatch_metrics( - [full[: len(full) // 2]], [full], num_points=_M, dt=_DT, lever_m=_LEVER - ) - assert metrics["arcmatch_span_m"] > 0 - assert 0.4 < metrics["arcmatch_travel_ratio"] < 0.6 - - -def test_arcmatch_withvel_differs_once_timing_differs(): - """The gap between the two variants is the timing error.""" - full = _path() - # Same path, traversed at half the rate: identical waypoints, different - # velocity row. - slow = np.repeat(full, 2, axis=0) - metrics = arcmatch_metrics([slow], [full], num_points=_M, dt=_DT, lever_m=_LEVER) - assert metrics["arcmatch_paired_mse"] == pytest.approx(0.0, abs=1e-12) - assert metrics["arcmatch_withvel_paired_mse"] > metrics["arcmatch_paired_mse"] - - -def test_arcmatch_returns_nothing_for_an_empty_batch(): - assert arcmatch_metrics([], [], num_points=_M, dt=_DT, lever_m=_LEVER) == {} - - -# -- tokenize_span ---------------------------------------------------------- - - -def test_tokenize_span_emits_the_requested_points_and_a_velocity_row(): - waypoints, velocity = tokenize_span(_path(), arm_travel(_path()), _M, _DT) - assert waypoints.shape == (_M, 14) - assert velocity.shape == (14,) - - -def test_tokenize_span_starts_at_the_chunk_origin(): - full = _path() - waypoints, _ = tokenize_span(full, arm_travel(full), _M, _DT) - np.testing.assert_allclose(waypoints[0, :3], full[0, :3], atol=1e-9) - - -def test_tokenize_span_velocity_is_zero_for_a_stationary_arm(): - still = np.zeros((50, 14)) - _, velocity = tokenize_span(still, np.zeros(2), _M, _DT) - assert np.isfinite(velocity).all() - np.testing.assert_allclose(velocity, 0.0) - - -@pytest.mark.parametrize("bad", [{"num_points": 1}, {"dt": 0.0}]) -def test_tokenize_span_rejects_degenerate_settings(bad): - full = _path() - kwargs = {"num_points": _M, "dt": _DT, **bad} - with pytest.raises(ValueError): - tokenize_span(full, arm_travel(full), kwargs["num_points"], kwargs["dt"]) - - -# -- dtw -------------------------------------------------------------------- - - -def test_dtw_path_aligns_identical_sequences_one_to_one(): - seq = _path(40)[:, :3] - ia, ib = dtw_path(seq, seq) - np.testing.assert_array_equal(ia, ib) - - -def test_dtw_path_absorbs_a_pure_time_stretch(): - seq = _path(30)[:, :3] - stretched = np.repeat(seq, 2, axis=0) - ia, ib = dtw_path(stretched, seq) - # Warping should match every stretched frame to its original. - residual = np.linalg.norm(stretched[ia] - seq[ib], axis=-1).mean() - assert residual == pytest.approx(0.0, abs=1e-9) - - -def test_dtw_path_endpoints_are_anchored(): - a, b = _path(20)[:, :3], _path(25)[:, :3] - ia, ib = dtw_path(a, b) - assert (ia[0], ib[0]) == (0, 0) - assert (ia[-1], ib[-1]) == (len(a) - 1, len(b) - 1) - - -def test_dtw_path_rejects_mismatched_widths(): - with pytest.raises(ValueError, match="equal width"): - dtw_path(np.zeros((5, 3)), np.zeros((5, 2))) - - -def test_dtw_metrics_caps_the_samples_it_warps(): - full = _path(60) - many = [full] * 20 - # Should not raise or hang; the cap keeps it affordable in a val loop. - assert dtw_metrics(many, many, max_samples=2) - - -def test_clip_to_distance_truncates_and_keeps_at_least_two_rows(): - full = _path() - clipped = clip_to_distance(full, 0.1) - assert 2 <= len(clipped) < len(full) - assert clip_to_distance(full, 1e-9).shape[0] >= 2 - - -# -- rotation and the combined pose error ----------------------------------- - - -def test_geodesic_is_zero_for_equal_rotations_and_positive_otherwise(): - full = _path() - assert geodesic_deg(full, full) == pytest.approx(0.0, abs=1e-9) - rotated = full.copy() - rotated[:, 3] += 0.1 - rotated[:, 10] += 0.1 - assert geodesic_deg(rotated, full) > 0 - - -def test_geodesic_is_invariant_to_a_shared_frame_change(): - """Conjugation preserves the geodesic angle; a per-axis ypr diff would not.""" - from scipy.spatial.transform import Rotation as R - - rng = np.random.default_rng(0) - pred, gt = np.zeros((4, 14)), np.zeros((4, 14)) - pred[:, 3:6] = rng.normal(size=(4, 3)) * 0.2 - gt[:, 3:6] = rng.normal(size=(4, 3)) * 0.2 - pred[:, 10:13], gt[:, 10:13] = pred[:, 3:6], gt[:, 3:6] - before = geodesic_deg(pred, gt) - - shift = R.from_euler("ZYX", [0.3, -0.2, 0.5]) - for cols in ((3, 4, 5), (10, 11, 12)): - for chunk in (pred, gt): - rot = R.from_euler("ZYX", chunk[:, list(cols)]) - chunk[:, list(cols)] = (shift * rot).as_euler("ZYX") - assert geodesic_deg(pred, gt) == pytest.approx(before, rel=1e-6) - - -def test_pose_err_matches_its_closed_form_for_a_pure_rotation(): - """theta radians at lever_m costs exactly lever_m * theta metres.""" - theta, lever = 0.2, 0.25 - pred, gt = np.zeros((4, 14)), np.zeros((4, 14)) - pred[:, 3] = theta - pred[:, 10] = theta - assert pose_err_m(pred, gt, lever) == pytest.approx(lever * theta, rel=1e-9) - - -def test_pose_err_matches_its_closed_form_for_a_pure_translation(): - pred, gt = np.zeros((4, 14)), np.zeros((4, 14)) - pred[:, 0] = 0.03 - pred[:, 7] = 0.03 - assert pose_err_m(pred, gt, 0.1) == pytest.approx(0.03, rel=1e-9) - - -def test_pose_err_lever_zero_ignores_rotation_entirely(): - pred, gt = np.zeros((4, 14)), np.zeros((4, 14)) - pred[:, 3] = 0.5 - pred[:, 10] = 0.5 - assert pose_err_m(pred, gt, 0.0) == pytest.approx(0.0, abs=1e-12) - - -def test_pose_err_rejects_a_negative_lever(): - with pytest.raises(ValueError, match="lever_m"): - pose_err_m(np.zeros((2, 14)), np.zeros((2, 14)), -0.1) - - -# -- chunk family ----------------------------------------------------------- - - -def test_chunk_metrics_splits_by_component(): - full = _path() - shifted = full.copy() - shifted[:, 6] += 0.2 # left gripper only - metrics = chunk_metrics(shifted[None], full[None], lever_m=_LEVER) - assert metrics["chunk_grip_mse"] > 0 - assert metrics["chunk_xyz_mse"] == pytest.approx(0.0, abs=1e-12) - assert metrics["chunk_ypr_mse"] == pytest.approx(0.0, abs=1e-12) - - -def test_chunk_metrics_requires_matching_shapes(): - with pytest.raises(ValueError, match="matching shapes"): - chunk_metrics(np.zeros((1, 10, 14)), np.zeros((1, 12, 14)), lever_m=_LEVER) - - -def test_paired_columns_exclude_rotation(): - """Rotation must never be summed with metres in a paired MSE.""" - ypr = {c for arm in ((3, 4, 5), (10, 11, 12)) for c in arm} - assert not (set(PAIRED_COLS) & ypr) - - -def test_mse_respects_a_column_subset(): - a = np.zeros((2, 4, 14)) - b = np.zeros((2, 4, 14)) - b[..., list(ARM_XYZ[0])] = 1.0 - assert mse(a, b) > 0 - assert mse(a, b, [6, 13]) == pytest.approx(0.0) - - -# -- metrics must be logged on the compute device --------------------------- - - -def test_extra_metrics_land_on_the_prediction_device(): - """log_dict(sync_dist=True) all-reduces these over NCCL. - - NCCL has no CPU support, so a CPU tensor raises "No backend type - associated with device type cpu" the moment a GPU run tries to sync. The - metrics are computed in numpy on the host, so the cast back to a tensor is - the one place device placement matters. - """ - import torch - - from egomimic.eval.arc_bimanual_cartesian_eval import ArcBimanualCartesianEval - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - ) - - evaluator = ArcBimanualCartesianEval.__new__(ArcBimanualCartesianEval) - evaluator.action_key = "actions_cartesian" - evaluator.untokenized_action_key = "actions_cartesian_untokenized" - evaluator.min_distance_unit = 0.4 - evaluator.resampled_vector_length = _M - evaluator.action_horizon = 32 - evaluator.velocity_mode = "mean" - evaluator.arc_metrics = True - evaluator.include_reconstruction_loss = False - evaluator.arcmatch_points = 8 - evaluator.rot_lever_m = _LEVER - evaluator.dtw_max_samples = 2 - evaluator.metric_dt = _DT - evaluator.arcmatch_distance = 0.4 - evaluator.arc_chunk_rows = 45 - evaluator._tokenizer = TokenizeBimanualArcLengthCartesian( - action_key="actions_cartesian", - output_action_key="actions_cartesian", - min_distance_unit=0.4, - resampled_vector_length=_M, - preserve_action_key=None, - ) - - class _Passthrough: - def unnormalize(self, mapping, embodiment_id): - del embodiment_id - return mapping - - evaluator.normalizer = _Passthrough() - - gt = torch.from_numpy(np.stack([_path(60)])) - token = torch.zeros(1, _M + 1, 14, dtype=torch.float64) - token[0, :_M, 0] = torch.linspace(0.0, 0.4, _M) - token[0, :_M, 7] = torch.linspace(0.0, 0.4, _M) - token[0, _M, 0] = 0.3 - token[0, _M, 7] = 0.3 - - metrics = evaluator._extra_metrics( - label="yam_bimanual", - embodiment_id=7, - source_batch={"actions_cartesian_untokenized": gt}, - prediction=token, - target=None, - ) - assert metrics, "no arc metrics were produced" - for name, value in metrics.items(): - assert value.device == token.device, name - assert torch.isfinite(value), name - assert any("arcmatch" in k for k in metrics) - - -def test_extra_metrics_are_skipped_without_the_preserved_ground_truth(): - """No untokenized chunk means no scoring against a reconstruction.""" - from egomimic.eval.arc_bimanual_cartesian_eval import ArcBimanualCartesianEval - - evaluator = ArcBimanualCartesianEval.__new__(ArcBimanualCartesianEval) - evaluator.arc_metrics = True - evaluator.untokenized_action_key = "actions_cartesian_untokenized" - evaluator.action_key = "actions_cartesian" - assert ( - evaluator._extra_metrics( - label="x", - embodiment_id=7, - source_batch={}, - prediction=None, - target=None, - ) - == {} - ) - - -def test_extra_metrics_can_be_turned_off(): - from egomimic.eval.arc_bimanual_cartesian_eval import ArcBimanualCartesianEval - - evaluator = ArcBimanualCartesianEval.__new__(ArcBimanualCartesianEval) - evaluator.arc_metrics = False - assert ( - evaluator._extra_metrics( - label="x", embodiment_id=7, source_batch={}, prediction=None, target=None - ) - == {} - ) - - -# -- one evaluator, both arms of the ablation ------------------------------- -# -# The baseline and arc runs must land on the SAME arcmatch charts or the -# comparison is meaningless. Shared-D prep: baseline keeps tokenizer-resolution -# poses; ARC uses token waypoints unless L_gt < D (then detok). Overlay still -# always detokenizes ARC for viz. - - -def _arc_evaluator(velocity_mode="mean", action_horizon=45): - from egomimic.eval.arc_bimanual_cartesian_eval import ArcBimanualCartesianEval - from egomimic.rldb.zarr.arc_length_tokenizer import ( - TokenizeBimanualArcLengthCartesian, - ) - - ev = ArcBimanualCartesianEval.__new__(ArcBimanualCartesianEval) - ev.action_key = "actions_cartesian" - ev.untokenized_action_key = "actions_cartesian_untokenized" - ev.min_distance_unit = 0.4 - ev.resampled_vector_length = _M - ev.action_horizon = action_horizon - ev.velocity_mode = velocity_mode - ev.arc_metrics = True - ev.include_reconstruction_loss = False - ev.arcmatch_points = 8 - ev.arcmatch_distance = 0.4 - ev.arc_chunk_rows = 45 - ev.rot_lever_m = _LEVER - ev.dtw_max_samples = 2 - ev.metric_dt = _DT - ev._tokenizer = TokenizeBimanualArcLengthCartesian( - action_key="actions_cartesian", - output_action_key="actions_cartesian", - min_distance_unit=0.4, - resampled_vector_length=_M, - preserve_action_key=None, - velocity_mode=velocity_mode, - ) - - class _Passthrough: - def unnormalize(self, mapping, embodiment_id): - del embodiment_id - return mapping - - ev.normalizer = _Passthrough() - return ev - - -def _arc_token(velocity_mode="mean"): - import torch - - rows = _M + 1 if velocity_mode == "mean" else 2 * _M - token = torch.zeros(1, rows, 14, dtype=torch.float64) - token[0, :_M, 0] = torch.linspace(0.0, 0.4, _M) - token[0, :_M, 7] = torch.linspace(0.0, 0.4, _M) - if velocity_mode == "mean": - token[0, _M, 0] = token[0, _M, 7] = 0.3 - else: - token[0, _M:, 0] = token[0, _M:, 7] = 0.3 - return token - - -@pytest.mark.parametrize("velocity_mode", ["mean", "per_waypoint"]) -def test_the_predicate_recognises_an_arc_token_of_either_layout(velocity_mode): - evaluator = _arc_evaluator(velocity_mode) - assert evaluator._is_arc(_arc_token(velocity_mode)) - - -def test_the_predicate_rejects_a_baseline_pose_chunk(): - import torch - - evaluator = _arc_evaluator() - assert not evaluator._is_arc(torch.zeros(1, 100, 14)) - assert not evaluator._is_arc(torch.zeros(1, 45, 14)) - - -def test_the_predicate_rejects_the_other_velocity_modes_row_count(): - evaluator = _arc_evaluator("mean") - assert not evaluator._is_arc(_arc_token("per_waypoint")) - - -def test_the_same_evaluator_scores_a_baseline_chunk_too(): - """A baseline prediction goes down the pose path, not the detokenizer.""" - import torch - - evaluator = _arc_evaluator() - gt = torch.from_numpy(np.stack([_path(100)])) - pred = torch.from_numpy(np.stack([_path(100, span=0.5)])) # pose chunk - metrics = evaluator._extra_metrics( - label="yam_bimanual", - embodiment_id=7, - source_batch={"actions_cartesian_untokenized": gt}, - prediction=pred, - target=None, - ) - assert metrics, "the baseline path produced no metrics" - assert any("arcmatch" in k for k in metrics) - - -def test_both_run_types_emit_the_same_metric_names(): - """Same keys means the two arms overlay on one chart.""" - import torch - - evaluator = _arc_evaluator() - gt = torch.from_numpy(np.stack([_path(100)])) - arc = evaluator._extra_metrics( - label="x", - embodiment_id=7, - source_batch={"actions_cartesian_untokenized": gt}, - prediction=_arc_token(), - target=None, - ) - baseline = evaluator._extra_metrics( - label="x", - embodiment_id=7, - source_batch={"actions_cartesian_untokenized": gt}, - prediction=torch.from_numpy(np.stack([_path(100, span=0.5)])), - target=None, - ) - assert set(arc) == set(baseline) - - -def test_arcmatch_uses_arc_waypoints_when_gt_travel_reaches_D(): - """L_gt >= D: keep token waypoints; do not detok for arcmatch.""" - evaluator = _arc_evaluator(action_horizon=100) - gt = np.stack([_path(100, span=0.6)]) # travel > D=0.4 - pred = _arc_token() - out = evaluator._arc_pred_for_arcmatch(pred, gt, 7) - assert isinstance(out, list) - assert out[0].shape == (_M, 14) - - -def test_include_reconstruction_loss_always_detoks_arc_for_arcmatch(): - """Flag on: detok even when L_gt >= D (reconstruction baked in).""" - evaluator = _arc_evaluator(action_horizon=100) - evaluator.include_reconstruction_loss = True - gt = np.stack([_path(100, span=0.6)]) - out = evaluator._arc_pred_for_arcmatch(_arc_token(), gt, 7) - assert out.shape == (1, 100, 14) - - -def test_arcmatch_detoks_arc_when_gt_travel_is_shorter_than_D(): - """L_gt < D: detok so both sides re-tokenize over the shorter shared span.""" - evaluator = _arc_evaluator(action_horizon=100) - gt = np.stack([_path(100, span=0.2)]) # travel < D=0.4 - pred = _arc_token() - out = evaluator._arc_pred_for_arcmatch(pred, gt, 7) - assert isinstance(out, list) - assert out[0].shape == (100, 14) - - -def test_arcmatch_detoks_when_only_one_arm_is_shorter_than_D(): - """Mixed arms: any arm with L_gt < D forces detok (intentional).""" - evaluator = _arc_evaluator(action_horizon=100) - gt = _path(100, span=0.6) - gt[:, 7:14] = gt[0:1, 7:14] # right idle → L_right ≈ 0 < D - # Give right a short non-zero travel still < D - gt[:, 7] = np.linspace(0.0, 0.15, 100) - out = evaluator._arc_pred_for_arcmatch(_arc_token(), gt[None], 7) - assert out[0].shape == (100, 14) - - -def test_arc_evaluator_rejects_mismatched_arcmatch_and_codec_D(): - from egomimic.eval.arc_bimanual_cartesian_eval import ArcBimanualCartesianEval - - with pytest.raises(ValueError, match="arcmatch_distance"): - ArcBimanualCartesianEval( - min_distance_unit=0.40, - resampled_vector_length=_M, - arcmatch_distance=0.50, - ) - - -def test_gt_and_baseline_arcmatch_prep_are_not_deinterpolated(): - """Shared-D uses tokenizer resolution, not arc_chunk_rows=45.""" - import torch - - evaluator = _arc_evaluator() - gt_t = torch.from_numpy(np.stack([_path(100, span=0.5)])) - gt = evaluator._arc_gt_time_indexed( - {"actions_cartesian_untokenized": gt_t}, embodiment_id=7 - ) - pred = evaluator._arc_pred_time_indexed(gt_t, embodiment_id=7) - assert gt.shape == (1, 100, 14) - assert pred.shape == (1, 100, 14) - - -def test_deinterpolation_reduces_rows_without_inventing_samples(): - from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval - - chunk = _path(100)[None] - reduced = BimanualCartesianEval._deinterpolate(chunk, 45) - assert reduced.shape == (1, 45, 14) - # Every retained row must be one of the originals, not an interpolant. - for row in reduced[0]: - assert np.any(np.all(np.isclose(chunk[0], row), axis=-1)) - - -def test_deinterpolation_is_a_noop_when_already_short_enough(): - from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval - - chunk = _path(30)[None] - assert BimanualCartesianEval._deinterpolate(chunk, 45) is chunk - - -def test_deinterpolation_matters_for_arc_length(): - """Why it exists: arc length on an interpolated path reads differently.""" - from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval - - dense = _path(400) - reduced = BimanualCartesianEval._deinterpolate(dense[None], 45)[0] - # A coarser sampling chord-cuts the wiggle, so it measures shorter. - assert arm_travel(reduced)[0] < arm_travel(dense)[0] - - -# -- the overlay path needs the same predicate as the metrics path ---------- -# -# Regression: _is_arc was wired into _arc_pred_time_indexed but not into -# _viz_source, so a baseline run reached an arc-only shape check and died with -# "expects (B, 200, D) arc tokens ... got (32, 100, 14)". Both paths route on -# the run type, so both need the predicate. - - -def test_viz_source_passes_a_baseline_chunk_through(): - import torch - - evaluator = _arc_evaluator("per_waypoint") - chunk = torch.zeros(4, 100, 14) - out = evaluator._viz_source(chunk, 7) - assert out.shape == chunk.shape - - -def test_viz_source_still_detokenizes_an_arc_token(): - evaluator = _arc_evaluator("per_waypoint") - out = evaluator._viz_source(_arc_token("per_waypoint"), 7) - assert out.shape == (1, evaluator.action_horizon, 14) - - -def test_viz_source_rejects_a_non_bimanual_width(): - """A wrong width IS a misconfiguration, unlike a baseline row count.""" - import torch - - evaluator = _arc_evaluator("per_waypoint") - with pytest.raises(ValueError, match=r"\(B, T, 14\)"): - evaluator._viz_source(torch.zeros(4, 100, 7), 7) - - -def test_both_paths_agree_on_the_run_type(): - """metrics and overlay must never disagree about what they were handed.""" - import torch - - evaluator = _arc_evaluator("per_waypoint") - for actions, is_arc in ( - (_arc_token("per_waypoint"), True), - (torch.zeros(1, 100, 14), False), - ): - assert evaluator._is_arc(actions) is is_arc - # The overlay path returns detokenized rows only for an arc token. - out = evaluator._viz_source(actions, 7) - assert (out.shape[1] == evaluator.action_horizon) or not is_arc diff --git a/tests/test_arc_oat.py b/tests/test_arc_oat.py deleted file mode 100644 index 933432c612..0000000000 --- a/tests/test_arc_oat.py +++ /dev/null @@ -1,402 +0,0 @@ -"""Learn OAT codes of ARC supports, retaining the dense action interface.""" - -from copy import deepcopy - -import pytest -import torch - -from egomimic.models.oat.checkpoint import validate_input_representation -from egomimic.models.oat.factory import make_tokenizer -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_libero_arc import LiberoArcStage -from egomimic.pipeline.stages_oat import OATTokenizerStage -from tests.test_libero_benchmark import make_replay -from tests.test_oat_training import config_for - - -def tokenizer_graph(mode): - settings = dict( - arc_mode=mode, num_waypoints=6, horizon=8, velocity_norm_bound=3**0.5 - ) - tokenizer = make_tokenizer( - action_dim=12, - horizon=6, - num_registers=4, - emb_dim=32, - head_dim=8, - encoder_depth=1, - decoder_depth=1, - ) - return Pipeline( - [ - LiberoArcStage(**settings, operation="encode", encode_inference=True), - OATTokenizerStage( - tokenizer, action_key="target", prediction_key="pred_arc" - ), - LiberoArcStage(**settings, operation="decode"), - ] - ) - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -def test_tokenizer_loss_is_on_arc_supports_and_prefixes_decode_dense_actions(mode): - graph = tokenizer_graph(mode) - actions = torch.randn(3, 8, 7, requires_grad=True) * 0.15 - # Include stationary motion and a gripper-only transition. - actions = actions.detach() - actions[0] = 0 - actions[1, :, :6] = 0 - actions[1, 4:, 6] = -1 - target = graph.stages[0].execute({"actions": actions}, mode="train")["target"] - assert target.shape == (3, 6, 12) - torch.manual_seed(42) - actual = graph.execute({"actions": actions}, mode="train")[ - "loss/oat_reconstruction" - ] - torch.manual_seed(42) - expected = graph.stages[1].tokenizer({"action": target}) - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - actual.backward() - assert any( - p.grad is not None and p.grad.abs().sum() > 0 for p in graph.parameters() - ) - graph.eval() - for keep in (1, 2, 4): - graph.stages[1].use_k_tokens = keep - result = graph.execute({"actions": actions}, mode="inference") - assert result["pred_arc"].shape == (3, 6, 12) - assert result["pred_action"].shape == (3, 8, 7) - assert torch.isfinite(result["pred_action"]).all() - representation = validate_input_representation(graph.stages) - assert representation["mode"] == mode - assert representation["num_waypoints"] == 6 - # Same tensor dimensions must not allow a different physical representation. - wrong = deepcopy(representation) - wrong["mode"] = "dur" if mode == "stk" else "stk" - with pytest.raises(ValueError, match="input representations differ"): - validate_input_representation(graph.stages, {"oat_input_representation": wrong}) - with pytest.raises(ValueError, match="input representations differ"): - validate_input_representation(graph.stages, {}) - graph.stages[-1].codec.max_translation = 0.4 - with pytest.raises(ValueError, match="encoder and decoder representations differ"): - validate_input_representation(graph.stages) - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -@pytest.mark.parametrize("precision", ["32-true", "bf16-mixed"]) -def test_arc_tokenizer_then_policy_train_resume_and_self_contained_inference( - tmp_path, mode, precision -): - from egomimic.benchmarks.libero.rollout import load_policy - from egomimic.models.oat.factory import load_tokenizer - from egomimic.trainHydra import train - - replay = tmp_path / "replay.zarr" - make_replay(replay) - cfg = config_for("libero_arc_oattok", replay, tmp_path / "tokenizer") - cfg.benchmark.arc_mode = mode - cfg.benchmark.arc_waypoints = 6 - cfg.trainer.precision = precision - spec = cfg.model.pipeline.stages[1].tokenizer - spec.emb_dim, spec.head_dim = 32, 8 - spec.encoder_depth, spec.decoder_depth, spec.num_registers = 1, 1, 4 - metrics, objects = train(cfg) - assert objects["trainer"].global_step == 2 - assert torch.isfinite(metrics["Valid/reconst_mse"]) - checkpoint = tmp_path / "tokenizer/checkpoints/last.ckpt" - tokenizer = load_tokenizer(checkpoint) - assert tokenizer.decoder.sample_dim == 12 - assert tokenizer._training_input_representation["mode"] == mode - assert all(not p.requires_grad for p in tokenizer.parameters()) - if precision == "32-true": - from argparse import Namespace - - from egomimic.benchmarks.libero.cli import reconstruction - - reconstruction_result = reconstruction( - Namespace( - checkpoint=checkpoint, - online=False, - device="cpu", - suite="libero10", - dataset=replay, - tokens=[1, 2, 4], - arc_modes=[mode], - waypoints=[6], - batch_size=8, - limit=None, - ) - ) - assert reconstruction_result["complete_validation"] - assert set(reconstruction_result["metrics"]) == { - "arc_only", - "arc_oat_k1", - "arc_oat_k2", - "arc_oat_k4", - } - assert ( - reconstruction_result["representation_sizes"]["arc_only"]["float32_scalars"] - == 6 * 12 - ) - - policy_cfg = config_for("libero_arc_oatpolicy", replay, tmp_path / "policy") - policy_cfg.benchmark.arc_mode = mode - policy_cfg.benchmark.arc_waypoints = 6 - policy_cfg.benchmark.tokenizer_checkpoint = str(checkpoint) - policy_cfg.trainer.precision = precision - policy_spec = policy_cfg.model.pipeline.stages[1].policy - policy_spec.embed_dim, policy_spec.n_layers, policy_spec.n_heads = 16, 1, 2 - policy_spec.temperature = 0 - _, trained = train(policy_cfg) - assert trained["trainer"].global_step == 2 - model = trained["model"].model.pipeline.stages[1].policy - assert model.action_dim == 12 - assert all(p.grad is None for p in model.action_tokenizer.parameters()) - policy_checkpoint = tmp_path / "policy/checkpoints/last.ckpt" - policy_cfg.ckpt_path = str(policy_checkpoint) - policy_cfg.trainer.max_epochs = 2 - _, resumed = train(policy_cfg) - assert resumed["trainer"].global_step == 4 - payload = torch.load(policy_checkpoint, map_location="cpu", weights_only=False) - assert payload["ema_num_updates"] == 4 - assert payload["oat_input_representation"]["mode"] == mode - assert payload["oat_tokenizer_config"]["action_dim"] == 12 - - # Loading must work after the separately trained tokenizer is unavailable. - checkpoint.unlink() - restored, protocol = load_policy(policy_checkpoint, device="cpu", use_k_tokens=2) - assert protocol["action_representation"] == "arc_oat" - values = trained["datamodule"].train_datasets["libero_panda"][0] - values.pop("actions") - batch = {k: v.unsqueeze(0) for k, v in values.items() if torch.is_tensor(v)} - result = restored.algo.forward_eval({"libero_panda": batch})["libero_panda"] - assert result["pred_action"].shape == (1, 8, 7) - assert result["pred_arc"].shape == (1, 6, 12) - assert torch.isfinite(result["pred_action"]).all() - - damaged = deepcopy(payload) - damaged["oat_input_representation"]["max_translation"] = 0.4 - changed = tmp_path / "wrong-representation.ckpt" - torch.save(damaged, changed) - with pytest.raises(ValueError, match="input representations differ"): - load_policy(changed, device="cpu") - - -@pytest.mark.parametrize("method", ["tokenizer", "oat"]) -def test_hybrid_cluster_arguments_preserve_global_training_budget(method, tmp_path): - from pathlib import Path - - from hydra import compose, initialize_config_dir - - from egomimic.benchmarks.libero.cluster import training_arguments - - args = training_arguments( - method, - "libero_spatial", - tmp_path / "data", - tmp_path, - "full", - 5001, - gpus=4, - oat_on_arc=True, - ) - with initialize_config_dir( - version_base=None, - config_dir=str(Path(__file__).parents[1] / "egomimic/hydra_configs"), - ): - cfg = compose(config_name="train_zarr_cartesian", overrides=args[3:]) - assert cfg.model.benchmark_protocol.action_representation == "arc_oat" - assert cfg.trainer.max_epochs == 5001 - assert ( - cfg.trainer.devices - * cfg.benchmark.batch_size - * cfg.trainer.accumulate_grad_batches - == 1024 - ) - assert cfg.model.pipeline.stages[1].action_key == "target" - - -def test_policy_rejects_tokenizer_from_another_arc_clock(): - from egomimic.models.oat.policy.oatpolicy import OATPolicy - from egomimic.pipeline.stages_oat import OATPolicyStage - from tests.test_oat_native import TinyObservation - - source = tokenizer_graph("stk") - tokenizer = source.stages[1].tokenizer - tokenizer._training_input_representation = validate_input_representation( - source.stages - ) - policy = OATPolicy( - {"action": {"shape": [12]}, "obs": {"state": {"shape": [3], "type": "state"}}}, - TinyObservation(), - tokenizer, - 4, - 2, - 16, - 1, - 2, - ) - settings = dict( - arc_mode="dur", num_waypoints=6, horizon=8, velocity_norm_bound=3**0.5 - ) - stages = [ - LiberoArcStage(**settings), - OATPolicyStage(policy, action_key="target", prediction_key="pred_arc"), - LiberoArcStage(**settings, operation="decode"), - ] - with pytest.raises( - ValueError, match="Tokenizer and policy ARC representations differ" - ): - validate_input_representation(stages) - - -@pytest.mark.parametrize( - "mode,profile,replay", - [ - ("stk", "stk_2", "arc-five-v2-20260921-stk2-libero-spatial-replay"), - ("dur", "dur_2", "arc-five-v2-20260921-dur2-libero-spatial-replay"), - ], -) -def test_workflow_runs_hybrid_with_existing_replay_and_distinct_run_id( - mode, profile, replay -): - import json - - from scripts.benchmarks.launch_libero_osmo import arc_oat_workflow - - result = arc_oat_workflow( - "a" * 40, - "arc-oat-hybrid-test", - "libero_spatial", - arc_mode=mode, - profile=profile, - replay_run=replay, - oat_reference_run="oat-evaluation", - ) - workflow = result["workflow"] - env = workflow["tasks"][0]["environment"] - assert workflow["resources"]["default"]["gpu"] == 4 - assert env["RUN_KIND"] == "arc_oat" - assert env["EPOCHS"] == "5001" - assert json.loads(env["ARC_REPLAY_RUNS_JSON"]) == {mode: replay} - assert env["RESUME_FROM_RUN"] == "" - with pytest.raises(ValueError, match="5001 epochs"): - arc_oat_workflow( - "a" * 40, - "bad-budget", - "libero_spatial", - arc_mode=mode, - profile=profile, - replay_run=replay, - epochs=50, - ) - - -def test_hybrid_full_checkpoint_gate_rejects_missing_representation_and_partial_budget( - tmp_path, -): - from egomimic.benchmarks.libero.arc_oat import ( - input_representation, - verify_checkpoint, - ) - from egomimic.benchmarks.libero.arc_sweep import profile_settings - - settings = profile_settings("dur_2", "dur") - payload = { - "oat_input_representation": input_representation(settings), - "benchmark_data_context": {"suite": "libero_spatial"}, - "hyper_parameters": { - "config_tree": { - "model": { - "benchmark_protocol": { - "suite": "libero_spatial", - "action_representation": "arc_oat", - "horizon": 32, - "n_obs_steps": 0, - "n_action_steps": 16, - } - } - } - }, - "loops": {"fit_loop": {"epoch_progress": {"current": {"completed": 5001}}}}, - "ema_num_updates": 270054, - "global_step": 270054, - "ema_state_dict": {"weight": torch.ones(1)}, - "training_budget": { - "epochs": 5001, - "global_batch_size": 1024, - "total_optimizer_steps": 270054, - }, - } - path = tmp_path / "tokenizer.ckpt" - args = dict( - settings=settings, - suite="libero_spatial", - epochs=5001, - mode="full", - method="tokenizer", - ) - torch.save(payload, path) - assert verify_checkpoint(path, **args)["global_step"] == 270054 - payload["ema_num_updates"] = payload["global_step"] = 270000 - torch.save(payload, path) - with pytest.raises(ValueError, match="optimizer budget is incomplete"): - verify_checkpoint(path, **args) - assert verify_checkpoint(path, complete=False, **args)["global_step"] == 270000 - payload.pop("oat_input_representation") - torch.save(payload, path) - with pytest.raises(ValueError, match=r"different ARC\+OAT representation"): - verify_checkpoint(path, complete=False, **args) - - -def test_hybrid_comparison_requires_explicit_method_and_still_rejects_state_mismatch( - tmp_path, -): - import json - - from egomimic.benchmarks.libero.report import compare_runs - from egomimic.benchmarks.libero.rollout import rollout_plan, run_rollouts - from tests.test_libero_benchmark import FakeEnvironment, FakePolicy - - metadata = { - "suite": "libero_spatial", - "horizon": 32, - "n_obs_steps": 2, - "n_action_steps": 16, - "data_context": {"dataset_sha256": "same"}, - "observations_sha256": "same", - "use_ema": True, - } - for method in ("arc_oat", "oat"): - run_rollouts( - FakePolicy(), - rollout_plan("libero_spatial", trials_per_task=1, repetitions=1), - tmp_path / method, - env_factory=FakeEnvironment, - metadata={ - **metadata, - "method": method, - "representation": {"arc": {"mode": "stk"}}, - }, - ) - with pytest.raises(ValueError, match="requires native"): - compare_runs(tmp_path / "arc_oat", tmp_path / "oat", require_full=False) - result = compare_runs( - tmp_path / "arc_oat", - tmp_path / "oat", - require_full=False, - arc_method="arc_oat", - arc_mode="stk", - ) - assert result["paired_success_difference"] == 0 - path = tmp_path / "arc_oat/episodes.jsonl" - records = [json.loads(line) for line in path.read_text().splitlines()] - records[0]["initial_state_sha256"] = "wrong-state" - path.write_text("\n".join(json.dumps(row) for row in records) + "\n") - with pytest.raises(ValueError, match="Initial states differ"): - compare_runs( - tmp_path / "arc_oat", - tmp_path / "oat", - require_full=False, - arc_method="arc_oat", - ) diff --git a/tests/test_batch_signal_relay.py b/tests/test_batch_signal_relay.py deleted file mode 100644 index 35870aed7f..0000000000 --- a/tests/test_batch_signal_relay.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Real bash/child regression for Slurm batch-shell-only signal delivery.""" - -from __future__ import annotations - -import json -import os -import shlex -import signal -import subprocess -import sys -import time -from pathlib import Path - -import pytest - -ROOT = Path(__file__).parents[1] -HELPER = ROOT / "scripts/ice/relay_batch_signals.sh" -CHILD = r""" -import json, os, pathlib, signal, sys, time -root = pathlib.Path(sys.argv[1]) -root.joinpath("started").write_text(str(os.getpid())) -if sys.argv[2] == "early": - raise SystemExit(int(sys.argv[3])) -time.sleep(float(sys.argv[4])) -def handle(received, frame): - root.joinpath("received").write_text(signal.Signals(received).name) - if received == signal.SIGUSR1 and sys.argv[2] != "boundary": - root.joinpath("COMPLETE.json").write_text("complete") - time.sleep(.05) - raise SystemExit(0 if received == signal.SIGUSR1 else 128 + received) -for received in (signal.SIGUSR1, signal.SIGTERM, signal.SIGINT): - signal.signal(received, handle) -pathlib.Path(os.environ["ICE_RUNNER_SIGNAL_READY_FILE"]).write_text(str(os.getpid())) -if sys.argv[2] == "normal": - raise SystemExit(int(sys.argv[3])) -time.sleep(5) -""" - - -def wait_file(path): - deadline = time.monotonic() + 3 - while time.monotonic() < deadline: - if path.exists() and path.stat().st_size: - return - time.sleep(0.01) - raise AssertionError(f"child readiness timeout: {path}") - - -def launch(root, mode="signal", exit_code=0, delay=0): - argv = [sys.executable, "-c", CHILD, str(root), mode, str(exit_code), str(delay)] - command = ( - f"set -euo pipefail; source {shlex.quote(str(HELPER))}; " - f"ice_run_with_signal_relay {shlex.quote(str(root / 'ready'))} " - f"{shlex.join(argv)}; " - f'if test "$ICE_BATCH_BOUNDARY_FORWARDED" = 1 && test ! -s {shlex.quote(str(root / "COMPLETE.json"))}; ' - "then printf 'REQUEUE_ACCEPTED\\n'; exit 0; fi; " - "printf 'POST_RUNNER_VERIFIER\\n'" - ) - return subprocess.Popen( - ["bash", "-c", command], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - -def assert_reaped(root): - pid = int((root / "started").read_text()) - with pytest.raises(ProcessLookupError): - os.kill(pid, 0) - - -@pytest.mark.parametrize("received", [signal.SIGUSR1, signal.SIGTERM, signal.SIGINT]) -@pytest.mark.parametrize("during_startup", [False, True]) -def test_exact_signal_reaches_child_and_wait_preserves_status( - tmp_path, received, during_startup -): - child = launch(tmp_path, delay=0.2 if during_startup else 0) - try: - wait_file(tmp_path / ("started" if during_startup else "ready")) - child.send_signal(received) - output, errors = child.communicate(timeout=4) - expected = 0 if received == signal.SIGUSR1 else 128 + received - assert child.returncode == expected, (output, errors) - assert (tmp_path / "received").read_text() == received.name - assert ("POST_RUNNER_VERIFIER" in output) == (expected == 0) - assert_reaped(tmp_path) - finally: - if child.poll() is None: - child.kill() - child.wait(timeout=3) - - -@pytest.mark.parametrize("mode", ["normal", "early"]) -@pytest.mark.parametrize("exit_code", [0, 7]) -def test_early_and_normal_exit_status_is_not_replaced_by_wait( - tmp_path, mode, exit_code -): - child = launch(tmp_path, mode=mode, exit_code=exit_code) - output, errors = child.communicate(timeout=4) - assert child.returncode == exit_code, (output, errors) - assert ("POST_RUNNER_VERIFIER" in output) == (exit_code == 0) - assert_reaped(tmp_path) - - -def test_existing_readiness_marker_refuses_before_launch(tmp_path): - (tmp_path / "ready").write_text("stale") - child = launch(tmp_path) - output, errors = child.communicate(timeout=3) - assert child.returncode == 73, (output, errors) - assert not (tmp_path / "started").exists() - assert "POST_RUNNER_VERIFIER" not in output - - -def test_successful_requeue_without_completion_does_not_run_final_verifier(tmp_path): - child = launch(tmp_path, mode="boundary") - wait_file(tmp_path / "ready") - child.send_signal(signal.SIGUSR1) - output, errors = child.communicate(timeout=4) - assert child.returncode == 0, (output, errors) - assert "REQUEUE_ACCEPTED" in output - assert "POST_RUNNER_VERIFIER" not in output - assert_reaped(tmp_path) - - -def test_actual_runner_publishes_readiness_before_command_validation(tmp_path): - ready = tmp_path / "ready.json" - environment = {**os.environ, "ICE_RUNNER_SIGNAL_READY_FILE": str(ready)} - result = subprocess.run( - [ - sys.executable, - str(ROOT / "scripts/ice/ice_requeue_runner.py"), - "--state-dir", - str(tmp_path / "state"), - "--checkpoint-glob", - str(tmp_path / "*.ckpt"), - "--checkpoint-signal", - "USR2", - "--checkpoint-forwarding", - "slurm-steps", - "--requeue-owner", - "runner", - ], - env=environment, - capture_output=True, - text=True, - timeout=3, - ) - assert result.returncode != 0 - payload = json.loads(ready.read_text()) - assert payload["signal_handlers_ready"] is True - assert payload["pid"] > 0 - launcher = (ROOT / "scripts/train/launch_action_flow_usocket.sbatch").read_text() - assert ( - "AF_SIGNAL_RELAY=${AF_SIGNAL_RELAY:-$AF_TOOL_DIR/relay_batch_signals.sh}" - in launcher - ) - assert 'source "$AF_REPO/scripts/train/action_flow_run_child.sh"' in launcher - child = (ROOT / "scripts/train/action_flow_run_child.sh").read_text() - assert 'source "$AF_SIGNAL_RELAY"' in child - assert 'ice_run_with_signal_relay "$ATTEMPT/runner-signal-ready.json"' in child diff --git a/tests/test_bound_inference.py b/tests/test_bound_inference.py deleted file mode 100644 index 0a22edb0c0..0000000000 --- a/tests/test_bound_inference.py +++ /dev/null @@ -1,198 +0,0 @@ -"""A deployment bundle is inseparable from its model and complete data state.""" - -import json -from copy import deepcopy - -import pytest -import torch -from omegaconf import OmegaConf - -from egomimic.pipeline.construction import checkpoint_construction, restoring_parameters -from egomimic.pipeline.inference_config import write_inference_config -from egomimic.pipeline.inference_session import InferenceSession, load_bound_graph -from egomimic.pl_utils.data_context import DataContext, serializable_state -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.rldb.zarr.data_module import _digest, load_data_context -from tests.test_robot_graph_policy import ( - ACTION, - PROPRIO, - declare_test_model, - normalizer, -) - - -def bundle(tmp_path, *, initialization=False): - training = declare_test_model( - OmegaConf.create( - { - "model": { - "pipeline": { - "_target_": "egomimic.pipeline.algo.PipelineAlgo", - "device": "cpu", - "stages": [ - {"_target_": "tests.test_robot_graph_policy.EchoStage"} - ], - } - }, - "data": {"_target_": "must.never.be.instantiated"}, - } - ) - ) - if initialization: - training.model.pipeline.initialization = [ - { - "stage_id": "sampler", - "source": str(tmp_path / "unavailable.pt"), - "sha256": "0" * 64, - } - ] - norm = normalizer() - state = { - "kind": "zarr-normalizer-v1", - "normalizer_state": norm.to_state(), - "sha256": _digest(norm.to_state()), - "preprocessing": {"7": {"frame": "fixture_frame", "source_fps": 30}}, - } - context = DataContext(norm, norm.shapes, (), state) - with checkpoint_construction(): - wrapper = ModelWrapper(config_tree=training) - context.bind(wrapper.model) - wrapper.data_context = context - checkpoint = {"state_dict": wrapper.state_dict()} - wrapper.on_save_checkpoint(checkpoint) - path = tmp_path / "model.ckpt" - torch.save(checkpoint, path) - context_path = tmp_path / "data-context.json" - context_path.write_text(json.dumps(serializable_state(state))) - artifact_path = tmp_path / "inference-config.yaml" - write_inference_config(training, artifact_path, data_context=context) - paths = dict( - checkpoint_path=path, context_path=context_path, artifact_path=artifact_path - ) - return training, context, checkpoint, paths - - -def test_bound_sequence_inference_and_robot_share_checkpoint_without_hardware(tmp_path): - training, context, checkpoint, paths = bundle(tmp_path, initialization=True) - session = InferenceSession.load(training, identity=7, **paths) - observations = { - PROPRIO: torch.full((1, 14), 4.0), - "front": torch.zeros(1, 3, 2, 3), - "embodiment": torch.tensor([7]), - } - prediction = session.predict(observations) - torch.testing.assert_close( - prediction, - context.normalizer.unnormalize({ACTION: torch.zeros(1, 2, 14)}, 7)[ACTION], - ) - torch.testing.assert_close( - session.graph.pipeline.stages[0].seen, torch.ones(1, 14), atol=1e-6, rtol=1e-6 - ) - assert session.execution_plan(prediction).shape == (1, 1, 14) - session.apply_inference_overrides({"replan_every": 2}) - assert session.execution_plan(prediction).shape == (1, 2, 14) - with pytest.raises((ValueError, TypeError)): - session.apply_inference_overrides({"replan_every": True}) - assert session.replan_every == 2 - assert not restoring_parameters() - from egomimic.robot.graph_policy import load_graph_policy - from tests.test_robot_runtime import FakeRobot - - config_path = tmp_path / "training.yaml" - OmegaConf.save(training, config_path) - robot_policy = load_graph_policy( - { - "training_config": str(config_path), - "checkpoint": str(paths["checkpoint_path"]), - "normalizer_path": str(paths["context_path"]), - "device": "cpu", - "adapter": { - "_target_": "egomimic.robot.graph_policy.CartesianGraphAdapter", - "base_T_model": { - arm: torch.eye(4).tolist() for arm in ("left", "right") - }, - "camera_keys": {"front_img_1": "front"}, - "embodiment_id": 7, - "rotation_mode": "euler", - "action_frame": "model_frame", - "image_hw": [2, 3], - }, - } - ) - torch.testing.assert_close( - torch.from_numpy(robot_policy.predict(FakeRobot().get_obs())).float(), - prediction[0], - ) - - -@pytest.mark.parametrize( - "damage", - [ - "weights_model", - "stats", - "preprocessing", - "missing_binding", - "unbound_artifact", - "checkpoint_context", - ], -) -def test_corrupt_or_mismatched_bundle_fails_before_model_construction( - tmp_path, monkeypatch, damage -): - training, context, checkpoint, paths = bundle(tmp_path) - if damage == "weights_model": - checkpoint["inference_binding"]["model_pipeline_sha256"] = "0" * 64 - elif damage == "missing_binding": - del checkpoint["inference_binding"] - elif damage == "checkpoint_context": - checkpoint["data_context"]["preprocessing"]["7"]["frame"] = "other" - elif damage == "unbound_artifact": - paths["artifact_path"] = tmp_path / "unbound.yaml" - write_inference_config(training, paths["artifact_path"]) - else: - changed = serializable_state(context.snapshot()) - if damage == "stats": - changed["normalizer_state"]["norm_stats"]["7"][ACTION]["mean"][0] += 1 - changed["sha256"] = _digest(changed["normalizer_state"]) - else: - changed["preprocessing"]["7"]["frame"] = "other" - paths["context_path"].write_text(json.dumps(changed)) - torch.save(checkpoint, paths["checkpoint_path"]) - import egomimic.pipeline.inference_session as module - - original = module.instantiate - - def guarded(config, **kwargs): - assert ( - config.get("_target_") == "egomimic.rldb.zarr.data_module.load_data_context" - ), "Constructed a model before validating its binding" - return original(config, **kwargs) - - monkeypatch.setattr(module, "instantiate", guarded) - with pytest.raises(ValueError, match="binding|bound|verified"): - load_bound_graph(training, **paths) - - -def test_resume_verifies_full_context_and_context_serialization_is_exact(tmp_path): - training, context, checkpoint, paths = bundle(tmp_path) - assert ( - context.fingerprint() == load_data_context(paths["context_path"]).fingerprint() - ) - wrapper = ModelWrapper(config_tree=training) - wrapper.data_context = context - wrapper.on_load_checkpoint(checkpoint) - changed = deepcopy(checkpoint) - changed["data_context"]["preprocessing"]["7"]["frame"] = "other" - with pytest.raises(ValueError, match="data context"): - wrapper.on_load_checkpoint(changed) - - -def test_restore_construction_is_nested_and_resets_after_failure(): - assert not restoring_parameters() - with pytest.raises(RuntimeError), checkpoint_construction(): - assert restoring_parameters() - with checkpoint_construction(enabled=False): - assert not restoring_parameters() - assert restoring_parameters() - raise RuntimeError("deliberate") - assert not restoring_parameters() diff --git a/tests/test_bounds_check_gate.py b/tests/test_bounds_check_gate.py deleted file mode 100644 index befc991655..0000000000 --- a/tests/test_bounds_check_gate.py +++ /dev/null @@ -1,148 +0,0 @@ -"""`bounds_check` gates the per-sample quantile screen in MultiDataset. - -The flag was already present in nine data configs but read by nothing: it fell -into **kwargs and was discarded, so the screen ran unconditionally on every -sample, train and validation alike. A violation does not raise -- it swaps in a -different sample via get_fallback_idx -- so a run could quietly train on a -different distribution than its config described. -""" - -import random - -import numpy as np -import torch - -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset - -_EMB = 7 -_KEY = "actions_cartesian" - - -class _Leaf(torch.utils.data.Dataset): - """A two-sample leaf whose second sample is out of bounds.""" - - def __init__(self, bad_value: float = 1e6): - self.episode_path = "/tmp/ep_0000.zarr" - self.bad_value = bad_value - self.served = [] - - def __len__(self): - return 2 - - def __getitem__(self, index): - self.served.append(index) - value = 0.0 if index == 0 else self.bad_value - return { - "embodiment": _EMB, - _KEY: np.full((4, 2), value, dtype=np.float32), - } - - -def _dataset(*, bounds_check: bool, bad_value: float = 1e6) -> tuple: - leaf = _Leaf(bad_value=bad_value) - ds = MultiDataset( - datasets={"src": leaf}, - mode="total", - norm_mode="quantile", - bounds_check=bounds_check, - ) - # Stats that put the bad sample far outside the quantile band. - ds.norm_stats = { - _EMB: { - _KEY: { - "quantile_1": np.zeros((4, 2), dtype=np.float32), - "quantile_99": np.ones((4, 2), dtype=np.float32), - "mean": np.zeros((4, 2), dtype=np.float32), - "std": np.ones((4, 2), dtype=np.float32), - "min": np.zeros((4, 2), dtype=np.float32), - "max": np.ones((4, 2), dtype=np.float32), - } - } - } - ds.zarr_keys = {_EMB: {_KEY: _KEY}} - ds.key_types = {_EMB: {_KEY: "action_keys"}} - ds.shapes = {_EMB: {_KEY: (4, 2)}} - ds.embodiments = {_EMB} - return ds, leaf - - -def test_the_flag_defaults_to_on_so_existing_behaviour_is_unchanged(): - ds, _ = _dataset(bounds_check=True) - assert ds.bounds_check is True - assert MultiDataset.bounds_check is True - - -def test_the_flag_is_actually_stored_rather_than_swallowed_by_kwargs(): - # This is the regression: it used to land in **kwargs and vanish. - ds, _ = _dataset(bounds_check=False) - assert ds.bounds_check is False - - -def test_a_state_only_instance_still_has_the_attribute(): - # Deploy-mode construction returns early; __getitem__ must not - # AttributeError if it is ever reached. - assert MultiDataset(state={}, norm_mode="quantile").bounds_check is True - - -def test_with_the_screen_on_an_out_of_bounds_sample_is_substituted(): - ds, leaf = _dataset(bounds_check=True) - ds[1] - # The bad index was read, rejected, and another index served in its place. - assert leaf.served[0] == 1 - assert len(leaf.served) > 1, "no fallback substitution happened" - - -def test_with_the_screen_off_the_sample_is_returned_as_read(): - ds, leaf = _dataset(bounds_check=False) - out = ds[1] - assert leaf.served == [1], "the screen still substituted a sample" - assert out is not None - - -def test_with_the_screen_off_nan_is_passed_through_rather_than_swapped(): - # NaN is the other thing the screen rejects; off means off. - ds, leaf = _dataset(bounds_check=False, bad_value=float("nan")) - ds[1] - assert leaf.served == [1] - - -def test_with_the_screen_on_nan_is_substituted(): - ds, leaf = _dataset(bounds_check=True, bad_value=float("nan")) - ds[1] - assert len(leaf.served) > 1 - - -def test_an_in_bounds_sample_is_unaffected_either_way(): - for flag in (True, False): - ds, leaf = _dataset(bounds_check=flag) - ds[0] - assert leaf.served == [0], flag - - -def test_the_flag_reaches_the_dataset_through_from_resolver_kwargs(): - """_from_resolver forwards **kwargs to __init__, which is how the configs - set this.""" - import inspect - - signature = inspect.signature(MultiDataset.__init__) - assert "bounds_check" in signature.parameters - assert signature.parameters["bounds_check"].default is True - - -def test_deterministic_fallback_is_independent_of_python_rng_state(): - ds, _ = _dataset(bounds_check=True) - ds.fallback_policy = "deterministic_hash" - ds.fallback_seed = 42 - - random.seed(1) - first = ds._next_after_failure(1, "src", None, reason="bad", origin_idx=1) - random.seed(999_999) - second = ds._next_after_failure(1, "src", None, reason="bad", origin_idx=1) - assert first == second - - -def test_legacy_bounds_semantics_checks_rotation_channels(): - ds, _ = _dataset(bounds_check=True) - assert ds.bounds_semantics == "rotation_aware" - ds.bounds_semantics = "legacy_full_vector" - assert ds.bounds_semantics == "legacy_full_vector" diff --git a/tests/test_c12_compatibility.py b/tests/test_c12_compatibility.py deleted file mode 100644 index 6f1b0e9cfb..0000000000 --- a/tests/test_c12_compatibility.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Historical behavior contracts, not a claim of full training equivalence.""" - -import ast -import os -import random -from pathlib import Path - -import numpy as np -import pytest -import torch - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pl_utils.pl_data_utils import MultiDataModuleWrapper -from egomimic.rldb.zarr import zarr_dataset_multi as dm -from tests.test_bounds_check_gate import _dataset - - -def module(mode): - return MultiDataModuleWrapper({}, {}, {}, {}, compatibility_mode=mode) - - -def test_invalid_mode_fails_closed(): - for make in ( - module, - lambda mode: PipelineAlgo([], device="cpu", compatibility_mode=mode), - lambda mode: dm.MultiDataset(state={}, compatibility_mode=mode), - ): - with pytest.raises(ValueError, match="compatibility_mode"): - make("typo") - - -def test_bounds_false_is_explicitly_overridden_only_in_legacy_mode(): - ds = dm.MultiDataset(state={}, bounds_check=False, compatibility_mode="legacy_c12") - assert ds.bounds_check is True - assert ds.bounds_semantics == "legacy_full_vector" - assert ds.fallback_policy == "legacy_random" - assert dm.MultiDataset(state={}, bounds_check=False).bounds_check is False - - -@pytest.mark.parametrize("bad_value", [1e6, float("nan"), float("inf"), -1e-7]) -def test_legacy_filter_rejects_nonfinite_and_strict_outliers(bad_value): - ds, leaf = _dataset(bounds_check=False, bad_value=bad_value) - # Constructor-derived settings, transplanted onto a tiny normalized fixture. - settings = dm.MultiDataset( - state={}, bounds_check=False, compatibility_mode="legacy_c12" - ) - for key in ( - "bounds_check", - "bounds_semantics", - "fallback_policy", - "compatibility_mode", - ): - setattr(ds, key, getattr(settings, key)) - ds[1] - assert leaf.served == [1, 0] - - -def test_loader_matches_default_shuffle_worker_seed_and_global_rng(): - legacy = module("legacy_c12") - for workers in (0, 2): - results = [] - for generator in (None, legacy._loader_generator("train", "source")): - torch.manual_seed(42) - loader = torch.utils.data.DataLoader( - torch.arange(64), - batch_size=8, - shuffle=True, - num_workers=workers, - generator=generator, - ) - iterator = iter(loader) - worker_seed = iterator._base_seed - batches = torch.cat(list(iterator)) - results.append((worker_seed, batches, torch.get_rng_state().clone())) - assert results[0][0] == results[1][0] - assert torch.equal(results[0][1], results[1][1]) - assert torch.equal(results[0][2], results[1][2]) - assert not legacy._loader_generators - - -def test_checkpoint_cannot_silently_change_loader_semantics(): - for left, right in (("current", "legacy_c12"), ("legacy_c12", "current")): - with pytest.raises(ValueError, match="compatibility mode"): - module(right).load_state_dict(module(left).state_dict()) - legacy = module("legacy_c12") - legacy.load_state_dict(legacy.state_dict()) - - -def test_input_dtype_preserved_recursively_only_in_legacy_mode(): - value = torch.tensor([1.0000000001], dtype=torch.float64) - for mode, expected in (("current", torch.float32), ("legacy_c12", torch.float64)): - algo = PipelineAlgo([], device="cpu", compatibility_mode=mode) - batch = algo._move_value({"a": [value, (value,)], "i": torch.tensor([1])}) - assert batch["a"][0].dtype == expected - assert batch["a"][1][0].dtype == expected - assert batch["i"].dtype == torch.int64 - if mode == "legacy_c12": - assert torch.equal(batch["a"][0], value) - - -def test_retry_against_pinned_historical_source(): - path = os.environ.get("C12_DATASET_SOURCE") - if not path: - pytest.skip("set C12_DATASET_SOURCE to the pinned historical dataset module") - tree = ast.parse(Path(path).read_text()) - helper = next( - n - for n in tree.body - if isinstance(n, ast.FunctionDef) and n.name == "get_fallback_idx" - ) - cls = next( - n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MultiDataset" - ) - method = next( - n - for n in cls.body - if isinstance(n, ast.FunctionDef) and n.name == "_next_after_failure" - ) - ns = dict(vars(dm)) - exec( - compile(ast.Module(body=[helper, method], type_ignores=[]), str(path), "exec"), - ns, - ) - ds = dm.MultiDataset(state={}, compatibility_mode="legacy_c12") - ds.index_map = [("a", i) for i in range(64)] + [("b", i) for i in range(4)] - ds._global_indices_by_dataset = {"a": list(range(64)), "b": list(range(64, 68))} - original_rng = random.getstate() - try: - for seed in (0, 42, 999): - for attempt in (None, 0, 24, 25, 26, 62, 63, 999): - results = [] - for method in ( - ns["_next_after_failure"], - dm.MultiDataset._next_after_failure, - ): - random.seed(seed) - try: - result = method(ds, 0, "a", attempt, reason="fixture") - except RuntimeError as exc: - result = (type(exc).__name__, str(exc)) - results.append((result, random.getstate())) - assert results[0] == results[1], (seed, attempt) - finally: - random.setstate(original_rng) - - -@pytest.mark.parametrize("dtype", [torch.float32, torch.float64, torch.bfloat16]) -def test_metric_dtype_matches_historical_reducer_without_changing_gradient(dtype): - from collections import OrderedDict - - from egomimic.pl_utils.training_behavior_action_flow import ( - ActionFlowTrainingBehavior, - ) - from egomimic.pl_utils.training_metrics import reduce_component_means - - path = os.environ.get("C12_DATASET_SOURCE") - if not path: - pytest.skip("set C12_DATASET_SOURCE to the pinned historical dataset module") - source = Path(path).parents[2] / "pl_utils/pl_model_action_flow.py" - tree = ast.parse(source.read_text()) - cls = next( - n - for n in tree.body - if isinstance(n, ast.ClassDef) and n.name == "ActionFlowModelWrapper" - ) - method = next( - n - for n in cls.body - if isinstance(n, ast.FunctionDef) and n.name == "_reduce_component_means" - ) - method.decorator_list = [] - ns = {"torch": torch, "OrderedDict": OrderedDict, "Mapping": dict} - exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), "exec"), ns) - value = torch.tensor(0.123456789, dtype=dtype, requires_grad=True) - state = torch.get_rng_state().clone() - expected, count = ns["_reduce_component_means"]({"loss": value}, 17) - actual, actual_count = reduce_component_means( - {"loss": value}, 17, label="fixture", preserve_input_dtype=True - ) - assert count == actual_count - assert actual["loss"].dtype == expected["loss"].dtype - assert torch.equal(actual["loss"], expected["loss"]) - assert value.grad is None and not actual["loss"].requires_grad - assert torch.equal(state, torch.get_rng_state()) - assert ( - ActionFlowTrainingBehavior(compatibility_mode="legacy_c12").compatibility_mode - == "legacy_c12" - ) - with pytest.raises(ValueError, match="compatibility_mode"): - ActionFlowTrainingBehavior(compatibility_mode="invalid") - - -def test_normalization_collection_matches_historical_precision(): - path = os.environ.get("C12_DATASET_SOURCE") - if not path: - pytest.skip("set C12_DATASET_SOURCE to the pinned historical dataset module") - tree = ast.parse(Path(path).read_text()) - cls = next( - n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MultiDataset" - ) - method = next( - n - for n in cls.body - if isinstance(n, ast.FunctionDef) and n.name == "_collect_norm_samples" - ) - ns = dict(vars(dm)) - exec(compile(ast.Module(body=[method], type_ignores=[]), str(path), "exec"), ns) - for dtype in (torch.float32, torch.float64): - samples = torch.tensor( - [[1.0000000001], [1.0000000003], [1.0000000009]], dtype=dtype - ) - ds = dm.MultiDataset(state={}, compatibility_mode="legacy_c12") - ds.keyname_to_zarr_key = lambda key, embodiment: key - args = ([{"action": samples}], ["action"], "fixture", 2, 3, 0) - expected = ns["_collect_norm_samples"](ds, *args)["action"][0] - actual = ds._collect_norm_samples(*args)["action"][0] - assert actual.dtype == expected.dtype - np.testing.assert_array_equal(actual, expected) - for key, value in ds._compute_stats_for_array(expected).items(): - np.testing.assert_array_equal( - ds._compute_stats_for_array(actual)[key], value - ) - ds.compatibility_mode = "current" - assert ds._collect_norm_samples(*args)["action"][0].dtype == np.float32 diff --git a/tests/test_capture_runtime_lock.py b/tests/test_capture_runtime_lock.py deleted file mode 100644 index 9dc5eec1b7..0000000000 --- a/tests/test_capture_runtime_lock.py +++ /dev/null @@ -1,38 +0,0 @@ -import importlib.util -import json -from pathlib import Path - -import pytest - -MODULE_PATH = Path(__file__).parents[1] / "scripts" / "ice" / "capture_runtime_lock.py" -SPEC = importlib.util.spec_from_file_location("capture_runtime_lock", MODULE_PATH) -assert SPEC is not None and SPEC.loader is not None -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -def test_canonical_bytes_are_stable_and_newline_terminated(): - first = MODULE.canonical_bytes({"z": 1, "a": [2, 3]}) - second = MODULE.canonical_bytes({"a": [2, 3], "z": 1}) - - assert first == second - assert first.endswith(b"\n") - assert json.loads(first) == {"a": [2, 3], "z": 1} - - -def test_atomic_create_refuses_overwrite(tmp_path): - output = tmp_path / "locks" / "runtime.json" - MODULE.atomic_create(output, b"first\n") - - with pytest.raises(FileExistsError, match="refusing to overwrite"): - MODULE.atomic_create(output, b"second\n") - - assert output.read_bytes() == b"first\n" - - -def test_runtime_lock_source_has_no_deployment_specific_paths(): - text = MODULE_PATH.read_text() - - assert "/coc/" not in text - assert "/storage/" not in text - assert "paphiwetsa3" not in text diff --git a/tests/test_chain_gripper_points.py b/tests/test_chain_gripper_points.py deleted file mode 100644 index dc61c07d19..0000000000 --- a/tests/test_chain_gripper_points.py +++ /dev/null @@ -1,130 +0,0 @@ -"""ChainGripper six-point action space: FK/IK, load-time transforms, native decoder.""" - -from __future__ import annotations - -import numpy as np -import pytest -import torch - -from egomimic.pipeline.pushshapes import ChainGripperPointsNativeDecoder -from egomimic.rldb.embodiment.pushshapes import ( - get_chain_gripper_paper_points_transform_list, - get_chain_gripper_points_action_state_transform_list, -) -from egomimic.rldb.zarr import chain_gripper_points as cg -from egomimic.rldb.zarr.action_chunk_transforms import ( - ChainGripperNative4ToPoints6, - ChainGripperPoints6ToNative4, -) - - -def _controls(n: int, seed: int = 0) -> np.ndarray: - rng = np.random.default_rng(seed) - return np.column_stack( - [ - rng.uniform(0.0, 512.0, (n, 2)), - rng.uniform(-np.pi, np.pi, n), - rng.uniform(0.0, 1.0, n), - ] - ) - - -def test_fk_layout_and_geometry(): - control = np.array([[256.0, 128.0, 0.0, 0.0]]) - points = cg.pose_control_to_points(control) - assert points.shape == (1, 6) - left, center, right = points[0, 0:2], points[0, 2:4], points[0, 4:6] - np.testing.assert_allclose(center, control[0, :2]) - radius = 2.0 * cg.CHAIN_GRIPPER_LINK_LEN * np.cos(cg.CHAIN_GRIPPER_OPEN_ANGLE / 2) - np.testing.assert_allclose(np.linalg.norm(left - center), radius) - np.testing.assert_allclose(np.linalg.norm(right - center), radius) - # left tip lies at theta - phi, right at theta + phi: positive chirality. - left_ray, right_ray = center - left, right - center - assert left_ray[0] * right_ray[1] - left_ray[1] * right_ray[0] > 0 - - -def test_exact_inverse_roundtrip(): - control = _controls(500) - points = cg.pose_control_to_points(control) - projection = cg.project_points_to_pose_control(points) - assert projection.used_exact_inverse.all() - np.testing.assert_allclose( - projection.control, cg.canonicalize_pose_control(control), atol=1e-9 - ) - - -def test_projection_recovers_noisy_points_approximately(): - control = _controls(200, seed=1) - points = cg.pose_control_to_points(control) + np.random.default_rng(2).normal( - 0.0, 1.0, (200, 6) - ) - projection = cg.project_points_to_pose_control(points, previous_control=control) - assert not projection.used_exact_inverse.any() - assert float(np.max(projection.point_rmse)) < 5.0 - canonical = cg.canonicalize_pose_control(control) - assert np.abs(projection.control[:, :2] - canonical[:, :2]).max() < 5.0 - theta_err = np.angle(np.exp(1j * (projection.control[:, 2] - canonical[:, 2]))) - assert np.abs(theta_err).max() < 0.35 - - -def test_native4_to_points6_transform_matches_fk_and_keeps_dtype(): - control = _controls(16, seed=3) - batch = {"actions": torch.from_numpy(control).float()} - out = ChainGripperNative4ToPoints6(keys=["actions"]).transform(batch)["actions"] - assert out.shape == (16, 6) and out.dtype == torch.float32 - np.testing.assert_allclose( - out.numpy(), cg.pose_control_to_points(control), rtol=0, atol=1e-3 - ) - with pytest.raises(ValueError): - ChainGripperNative4ToPoints6(keys=["actions"]).transform( - {"actions": torch.zeros(16, 3)} - ) - - -def test_points6_to_native4_uses_context_state_for_seed(): - control = _controls(2 * 16, seed=4).reshape(2, 16, 4) - points = cg.pose_control_to_points(control) - # Duplicate the middle point onto the left tip so the pair is degenerate at t=0; - # the previous control (from the context state) must carry the orientation. - degenerate = points.copy() - degenerate[:, 0, 0:2] = degenerate[:, 0, 2:4] - state = np.zeros((2, 6)) - state[:, :3] = control[:, 0, :3] - transform = ChainGripperPoints6ToNative4(keys=["actions"]) - out = transform.transform({"actions": degenerate, "state_agent_obj": state})[ - "actions" - ] - assert out.shape == (2, 16, 4) - diagnostics = transform.last_projection_diagnostics - assert diagnostics["degenerate_count"] == 2 - np.testing.assert_allclose(out[:, 0, 2], cg.wrap_angle(control[:, 0, 2]), atol=1e-9) - np.testing.assert_allclose( - out[:, 1:], cg.canonicalize_pose_control(control)[:, 1:], atol=1e-9 - ) - - -def test_decoder_and_embodiment_transform_lists(): - control = _controls(16, seed=5) - state = np.zeros((16, 6)) - state[:, :3] = control[:, :3] - batch = { - "actions": torch.from_numpy(control).float(), - "state_agent_model": torch.from_numpy(state).float(), - } - for transform in get_chain_gripper_points_action_state_transform_list(): - batch = transform.transform(batch) - assert tuple(batch["actions"].shape) == (16, 6) - assert tuple(batch["state_agent_model"].shape) == (16, 4) - decoder = ChainGripperPointsNativeDecoder() - native = decoder.decode(batch["actions"][None]) - assert tuple(native.shape) == (1, 16, 4) - np.testing.assert_allclose( - native[0].numpy(), cg.canonicalize_pose_control(control), atol=1e-3 - ) - paper = get_chain_gripper_paper_points_transform_list( - action_horizon=16, action_target_offset=1 - ) - aligned = {"actions": np.asarray(_controls(17, seed=6))} - for transform in paper: - aligned = transform.transform(aligned) - assert aligned["actions"].shape == (16, 6) diff --git a/tests/test_check_checkpoint_storage.py b/tests/test_check_checkpoint_storage.py deleted file mode 100644 index 8aebbea1b1..0000000000 --- a/tests/test_check_checkpoint_storage.py +++ /dev/null @@ -1,150 +0,0 @@ -import importlib.util -import json -import os -import subprocess -import sys -from pathlib import Path -from types import SimpleNamespace - -ROOT = Path(__file__).parents[1] -SCRIPT = ROOT / "scripts" / "train" / "check_checkpoint_storage.py" - - -def _load_module(): - spec = importlib.util.spec_from_file_location("check_checkpoint_storage", SCRIPT) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _filesystem(*, available_blocks=1000, free_blocks=1100, read_only=False): - return SimpleNamespace( - f_frsize=4096, - f_bavail=available_blocks, - f_bfree=free_blocks, - f_blocks=2000, - f_flag=int(getattr(os, "ST_RDONLY", 1)) if read_only else 0, - ) - - -def test_storage_budget_uses_larger_measured_or_expected_size(tmp_path, monkeypatch): - module = _load_module() - monkeypatch.setattr(module.os, "statvfs", lambda _: _filesystem()) - report, failures = module.storage_evidence( - tmp_path, - planned_checkpoint_count=3, - measured_checkpoint_bytes=100, - expected_checkpoint_bytes=120, - safety_reserve_bytes=500, - ) - assert failures == [] - assert report["bytes_per_checkpoint_budget"] == 120 - assert report["checkpoint_budget_bytes"] == 360 - assert report["required_available_bytes"] == 860 - assert report["filesystem_available_bytes"] == 1000 * 4096 - - -def test_storage_budget_reports_shortfall_and_read_only(tmp_path, monkeypatch): - module = _load_module() - monkeypatch.setattr( - module.os, - "statvfs", - lambda _: _filesystem(available_blocks=1, free_blocks=2, read_only=True), - ) - report, failures = module.storage_evidence( - tmp_path, - planned_checkpoint_count=3, - measured_checkpoint_bytes=2000, - expected_checkpoint_bytes=None, - safety_reserve_bytes=1000, - ) - assert report["required_available_bytes"] == 7000 - assert {failure["field"] for failure in failures} == { - "filesystem_read_only", - "filesystem_available_bytes", - } - - -def test_storage_cli_writes_pass_evidence(tmp_path): - output = tmp_path / "storage.json" - subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--target", - str(tmp_path), - "--planned-checkpoint-count", - "1", - "--measured-checkpoint-bytes", - "1", - "--expected-checkpoint-bytes", - "2", - "--safety-reserve-bytes", - "1", - "--output", - str(output), - ], - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - payload = json.loads(output.read_text()) - assert payload["status"] == "CHECKPOINT_STORAGE_VALIDATED" - assert payload["failures"] == [] - assert payload["storage"]["target"] == str(tmp_path.resolve()) - - -def test_storage_cli_writes_failure_evidence_for_insufficient_space(tmp_path): - available = os.statvfs(tmp_path).f_bavail * os.statvfs(tmp_path).f_frsize - output = tmp_path / "storage.json" - completed = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--target", - str(tmp_path), - "--planned-checkpoint-count", - "2", - "--expected-checkpoint-bytes", - str(available + 1), - "--safety-reserve-bytes", - "1", - "--output", - str(output), - ], - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert completed.returncode == 1 - payload = json.loads(output.read_text()) - assert payload["status"] == "CHECKPOINT_STORAGE_FAILED" - failure = payload["failures"][0] - assert failure["field"] == "filesystem_available_bytes" - assert failure["shortfall_bytes"] > 0 - - -def test_storage_cli_requires_checkpoint_size(tmp_path): - completed = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--target", - str(tmp_path), - "--planned-checkpoint-count", - "1", - "--safety-reserve-bytes", - "1", - "--output", - str(tmp_path / "storage.json"), - ], - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert completed.returncode != 0 - assert "one or both" in completed.stderr diff --git a/tests/test_config_graph.py b/tests/test_config_graph.py deleted file mode 100644 index 61fd2e9f47..0000000000 --- a/tests/test_config_graph.py +++ /dev/null @@ -1,249 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -from egomimic.pipeline.stages_sampler import GaussianLatentNoise - -_TOOL = Path(__file__).resolve().parents[1] / "tools/config_graph.py" -_SPEC = importlib.util.spec_from_file_location("config_graph_under_test", _TOOL) -assert _SPEC is not None and _SPEC.loader is not None -config_graph = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(config_graph) - - -def _model_yaml(note: str = "") -> str: - return f""" -_target_: egomimic.pl_utils.pl_model.ModelWrapper -pipeline: - _target_: egomimic.pipeline.algo.PipelineAlgo - stages: - - _target_: egomimic.pipeline.stages_sampler.KeyedFeatureProjection - selector_key: route - input_key: features - output_key: condition - output_dim: 3 - selector_aliases: {{1: image}} - projections: - image: - input_dim: 2 - hidden_dim: 4 - semantic: {json.dumps(note)} - - _target_: egomimic.pipeline.stages_sampler.GaussianLatentNoise - num_tokens: 4 - latent_dim: 3 - - _target_: egomimic.pipeline.stages_io.ActionTargetBuilder -""".lstrip() - - -def _data_yaml(*, second_source_has_route: bool = True) -> str: - second_batch_keys = "[route]" if second_source_has_route else "[]" - return f""" -train_datasets: - opaque/source-A: - batch_keys: [route] - key_map: &keymap - features: {{zarr_key: observations.features, key_type: proprio_keys}} - actions: {{zarr_key: actions, key_type: action_keys}} - "42": - batch_keys: {second_batch_keys} - key_map: - <<: *keymap -""".lstrip() - - -def _write_full_config( - path: Path, *, note: str = "", second_source_has_route: bool = True -) -> None: - model = "\n".join(f" {line}" for line in _model_yaml(note).splitlines()) - data = "\n".join( - f" {line}" - for line in _data_yaml( - second_source_has_route=second_source_has_route - ).splitlines() - ) - path.write_text(f"model:\n{model}\ndata:\n{data}\n") - - -def test_clean_pipeline_contracts_and_nested_params_are_preserved( - tmp_path: Path, monkeypatch -) -> None: - selected = tmp_path / "selected.yaml" - note = "nested-stage-parameter-" * 30 - _write_full_config(selected, note=note) - calls = [] - original = GaussianLatentNoise.contract - - def record_contract(self, mode="train"): - calls.append(mode) - return original(self, mode) - - monkeypatch.setattr(GaussianLatentNoise, "contract", record_contract) - train = config_graph.build_graph(selected, mode="train") - inference = config_graph.build_graph(selected, mode="inference") - - assert calls == ["train", "inference"] - assert train["lint"] == inference["lint"] == [] - assert train["seed_keys_by_source"] == { - "opaque/source-A": ["actions", "features", "route"], - "42": ["actions", "features", "route"], - } - assert inference["seed_keys"] == ["features", "route"] - assert [node["t"] for node in inference["nodes"]] == [ - "KeyedFeatureProjection", - "GaussianLatentNoise", - ] - assert inference["skipped_stages"] == [ - { - "source_i": 2, - "t": "ActionTargetBuilder", - "target": "egomimic.pipeline.stages_io.ActionTargetBuilder", - "reason": "train-only", - } - ] - assert train["edges"] == [{"a": 0, "b": 1, "k": "condition", "s": "shared"}] - params = train["nodes"][0]["p"] - assert params["projections"]["image"]["semantic"] == note - assert len(params["projections"]["image"]["semantic"]) > 400 - - -def test_source_names_are_opaque_and_missing_keymap_inputs_are_linted( - tmp_path: Path, -) -> None: - selected = tmp_path / "missing.yaml" - _write_full_config(selected, second_source_has_route=False) - - graph = config_graph.build_graph(selected) - - assert set(graph["seed_keys_by_source"]) == {"opaque/source-A", "42"} - assert any( - "source '42'" in problem and "reads 'route'" in problem - for problem in graph["lint"] - ) - assert not any( - "source 'opaque/source-A'" in problem and "reads 'route'" in problem - for problem in graph["lint"] - ) - - -def test_hydra_experiment_accepts_exact_model_override(tmp_path: Path) -> None: - root = tmp_path / "hydra_configs" - (root / "model").mkdir(parents=True) - (root / "data").mkdir() - (root / "experiment").mkdir() - (root / "train_zarr_cartesian.yaml").write_text( - "defaults:\n - model: null\n - data: null\n - _self_\n" - ) - (root / "model/selected.yaml").write_text(_model_yaml()) - (root / "data/selected.yaml").write_text(_data_yaml()) - experiment = root / "experiment/selected.yaml" - experiment.write_text( - "# @package _global_\n" - "defaults:\n" - " - override /data: selected\n" - " - _self_\n" - ) - - overrides = ["model=selected", "++run_tag=exact-row"] - graph = config_graph.build_graph(experiment, overrides=overrides) - - assert graph["lint"] == [] - assert graph["seed_key_source"] == "selected-data-keymap" - assert graph["component_sources"]["config_root"] == str(root) - assert graph["component_sources"]["overrides"] == overrides - - -def test_legacy_model_key_is_not_considered(tmp_path: Path) -> None: - selected = tmp_path / "legacy.yaml" - selected.write_text( - "robomimic_model:\n" - " stages:\n" - " - _target_: egomimic.pipeline.stages_io.ActionTargetBuilder\n" - ) - - with pytest.raises(ValueError, match=r"model\.pipeline\.stages"): - config_graph.build_graph(selected) - - -def test_lint_reports_duplicate_concrete_writers_and_cycles() -> None: - graph = { - "nodes": [ - {"i": 0, "t": "First", "in": ["b"], "out": ["a"]}, - {"i": 1, "t": "Second", "in": ["a"], "out": ["b", "a"]}, - ], - "edges": [ - {"a": 1, "b": 0, "k": "b", "s": "shared"}, - {"a": 0, "b": 1, "k": "a", "s": "shared"}, - ], - "seed_keys_by_source": {"anything": []}, - } - - problems = config_graph.lint(graph) - - assert any("reads 'b' before" in problem for problem in problems) - assert any("duplicate writer for 'a'" in problem for problem in problems) - assert any("dependency cycle" in problem for problem in problems) - - -def test_cli_emits_renderer_compatible_both_mode_json( - tmp_path: Path, monkeypatch -) -> None: - selected = tmp_path / "selected.yaml" - output = tmp_path / "graph.json" - _write_full_config(selected) - - instantiations = 0 - original = config_graph._stages - - def count_instantiations(pipeline_config): - nonlocal instantiations - instantiations += 1 - return original(pipeline_config) - - monkeypatch.setattr(config_graph, "_stages", count_instantiations) - result = config_graph.main([str(output), str(selected), "--mode", "both", "--lint"]) - payload = json.loads(output.read_text()) - - assert result == 0 - assert instantiations == 1 - assert set(payload) == {"selected [train]", "selected [inference]"} - for graph in payload.values(): - assert isinstance(graph["nodes"], list) - assert isinstance(graph["edges"], list) - assert all({"i", "t", "in", "out", "p"} <= set(node) for node in graph["nodes"]) - assert all({"a", "b", "k", "s"} <= set(edge) for edge in graph["edges"]) - - -def test_cli_resolves_repo_imports_without_pythonpath(tmp_path: Path) -> None: - output = tmp_path / "action-flow.graph.json" - experiment = ( - _TOOL.parents[1] / "egomimic/hydra_configs/experiment/pusht/" - "action_flow_bc_usocket_recon1_s42.yaml" - ) - environment = os.environ.copy() - environment.pop("PYTHONPATH", None) - completed = subprocess.run( - [ - sys.executable, - str(_TOOL), - str(output), - str(experiment), - "--mode", - "both", - "--lint", - ], - cwd=tmp_path, - check=False, - capture_output=True, - text=True, - env=environment, - ) - - assert completed.returncode == 0, completed.stderr - assert output.is_file() diff --git a/tests/test_consolidated_core_configs.py b/tests/test_consolidated_core_configs.py deleted file mode 100644 index 0db2267cdb..0000000000 --- a/tests/test_consolidated_core_configs.py +++ /dev/null @@ -1,91 +0,0 @@ -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -CONFIG_DIR = Path(__file__).parents[1] / "egomimic" / "hydra_configs" - - -def _compose(row, *extra): - with initialize_config_dir(version_base=None, config_dir=str(CONFIG_DIR.resolve())): - return compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=pusht/{row}", "++paths.root_dir=.", *extra], - ) - - -@pytest.mark.parametrize( - "row", - ( - "action_flow_bc_usocket_latent_fm_sg_recon1_200m_adamw_lr1e5_s42", - "action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42", - "action_flow_usocket_latent_fm_sg_unite_denoiser90m_sum14_cfg4_finalval1_s42", - "action_flow_chain_latent_fm_sg_unite_h384_sum14_cfg4_val8_s42", - ), -) -def test_action_flow_core_rows_compose_with_portable_dataset_root(row, monkeypatch): - root = "/verified/cluster/datasets/Tsim_v2" - monkeypatch.setenv("PUSHSHAPES_DATA_ROOT", root) - - cfg = _compose(row) - - for split in (cfg.data.train_datasets, cfg.data.valid_datasets): - for dataset in split.values(): - assert str(dataset.resolver.folder_path).startswith(f"{root}/") - assert cfg.model._target_ == "egomimic.pl_utils.pl_model.ModelWrapper" - assert cfg.model.training_behavior._target_.endswith("ActionFlowTrainingBehavior") - assert cfg.model.action_horizon == 16 - assert cfg.model.flow_samples_per_content == 14 - - -def test_action_flow_denoiser90m_changes_only_field_capacity(monkeypatch): - monkeypatch.setenv("PUSHSHAPES_DATA_ROOT", "/verified/cluster/datasets/Tsim_v2") - cfg = _compose( - "action_flow_usocket_latent_fm_sg_unite_denoiser90m_sum14_cfg4_finalval1_s42" - ) - - encoder = cfg.model.pipeline.stages[4].encoder.backbone - field = cfg.model.pipeline.stages[6].field.backbone - decoder = cfg.model.pipeline.stages[7].decoder - - assert (encoder.hidden_dim, encoder.depth, encoder.num_heads) == (384, 12, 12) - assert (field.hidden_dim, field.depth, field.num_heads) == (640, 12, 16) - assert (decoder.hidden_dim, decoder.depth, decoder.num_heads) == (384, 12, 12) - assert cfg.model.gradient_checkpointing is False - assert encoder.gradient_checkpointing is False - assert field.gradient_checkpointing is False - assert decoder.gradient_checkpointing is False - assert cfg.model.flow_samples_per_content == 14 - assert cfg.model.flow_loss_aggregation == "sum_samples" - assert cfg.model.action_flow_method == "latent_fm_stopgrad_unite" - assert cfg.trainer.val_check_interval == 150_000 - assert cfg.trainer.limit_val_batches == 0 - assert cfg.run_provenance.validation.full_run_enabled is False - assert cfg.run_provenance.validation.smoke_limit_batches == 1 - assert cfg.run_provenance.architecture.denoiser.parameters == 90_396_000 - assert cfg.run_provenance.architecture.total_parameters == 155_626_932 - - -def test_released_unite_cotrain_core_row_composes(monkeypatch): - root = "/verified/cluster/datasets/Tsim_v2" - monkeypatch.setenv("PUSHSHAPES_DATA_ROOT", root) - - cfg = _compose( - "unite_cotrain_usocket_chain_val01_h16", - "model=bf/ct_unite_register_separate_nt8_h384_s42", - ) - - assert tuple(cfg.data.train_datasets) == ( - "pushshapes_sim_u_socket", - "pushshapes_sim_chain_gripper", - ) - for split in (cfg.data.train_datasets, cfg.data.valid_datasets): - for dataset in split.values(): - assert str(dataset.resolver.folder_path).startswith(f"{root}/") - assert cfg.model.num_latent_tokens == 8 - assert cfg.model.hidden_dim == 384 - assert cfg.model.action_horizon == 16 - assert cfg.model._target_ == "egomimic.pl_utils.pl_model.ModelWrapper" - assert cfg.model.training_behavior._target_.endswith( - "ReleasedUniteTrainingBehavior" - ) diff --git a/tests/test_current_family_profiles.py b/tests/test_current_family_profiles.py deleted file mode 100644 index bd2300598a..0000000000 --- a/tests/test_current_family_profiles.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Guard owner-selected contracts without launching or reading a training corpus.""" - -import runpy -import subprocess -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -ROOT = Path(__file__).resolve().parents[1] -SUITES = [ - ("libero10", 605121), - ("libero_spatial", 270054), - ("libero_object", 325065), - ("libero_goal", 280056), -] - - -@pytest.mark.parametrize("suite,steps", SUITES) -def test_libero_current_and_historical_profiles_remain_distinct(suite, steps): - with initialize_config_dir( - config_dir=str(ROOT / "egomimic/hydra_configs"), version_base="1.3" - ): - af = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=libero/action_flow_{suite}_oat_dp_matched_s42"], - ) - dp = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=libero/dp_{suite}_oat_batch16_keep_steps_s42"], - ) - old = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=libero_historical/dp_{suite}_oat_dp_matched_s42"], - ) - assert ( - af.model.action_horizon, - af.model.num_latent_tokens, - af.model.latent_dim, - ) == (32, 16, 16) - assert (af.benchmark.n_obs_steps, af.benchmark.n_action_steps) == (2, 16) - assert af.model.pipeline.stages[0]._target_.endswith("OATObservationStage") - assert af.model.pipeline.stages[-1].action_velocity_weight == 1.0 - assert af.model.pipeline.stages[5].inference_method == "euler" - assert (af.model.num_inference_steps, af.model.cfg_scale, af.model.hidden_dim) == ( - 50, - 4.0, - 240, - ) - assert af.data.train_datasets.libero_panda.valid_ratio == 0.1 - assert af.data.valid_datasets.libero_panda.valid_ratio == 0.1 - assert ( - af.benchmark.batch_size, - af.trainer.accumulate_grad_batches, - af.trainer.gradient_clip_val, - ) == (32, 1, 3.0) - assert af.callbacks.ema._target_.endswith("ActionFlowFixedEMACallback") - assert af.callbacks.ema.decay == 0.9978 and af.callbacks.ema.use_warmup is False - assert af.model.optimizer._target_.endswith("ReleasedUniteCompositeOptimizer") - assert ( - dp.benchmark.batch_size, - dp.trainer.accumulate_grad_batches, - dp.trainer.max_steps, - ) == (16, 1, steps) - assert dp.benchmark.original_optimizer_step_target == steps - assert dp.callbacks.batch_budget.global_batch_size == 16 - assert dp.norm_stats.precomputed_norm_path is None - assert dp.callbacks.model_checkpoint.save_top_k == -1 - assert old.benchmark.batch_size * old.trainer.accumulate_grad_batches == 1024 - - -def test_dp_multiplier_actual_phase_arguments(monkeypatch): - for k, v in dict( - DP_COTRAIN_STANDARD="usocket_chain_manual4919_af_obs_multiplier_261m_v1", - DP_REPO=str(ROOT), - DP_WANDB_ID="fixture", - DP_EXPECTED_HEAD="a" * 40, - DP_NORM_SHA256="b" * 64, - DP_SPLIT_SHA256="c" * 64, - DP_CONTENT_MANIFEST="/fixture/content.json", - DP_CONTENT_SHA256="d" * 64, - DP_CONTENT_AGGREGATE_SHA256="e" * 64, - ).items(): - monkeypatch.setenv(k, v) - monkeypatch.delenv("DP_SINGLE_SOURCE_STANDARD", raising=False) - api = runpy.run_path(str(ROOT / "scripts/train/standard_dp_launch.py")) - for phase in ("normalize", "smoke", "full"): - argv = api["arguments"](phase, "/fixture/" + phase, "/fixture/norm") - with initialize_config_dir( - config_dir=str(ROOT / "egomimic/hydra_configs"), version_base="1.3" - ): - cfg = compose(config_name="train_zarr_cartesian", overrides=argv[1:]) - assert cfg.name == "planar_uc_manual4919_dp_261m_af_obs_multiplier" - assert ( - cfg.planar.batch_size == 32 - and cfg.model.pipeline.stages[5].condition_input_dim == 128 - ) - assert cfg.model.pipeline.stages[2].conditioning_input == "retiming_multiplier" - assert cfg.model.pipeline.stages[2].get("speed_reference") is None - assert cfg.callbacks.get("ema") is None - assert cfg.model.optimizer._target_ == "torch.optim.AdamW" - assert cfg.model.scheduler.warmup_steps == 500 - assert cfg.norm_stats.sample_frac == 1.0 - - -@pytest.mark.parametrize( - "cotrain,single", - [ - ("unknown", ""), - ("", "unknown"), - ( - "usocket_chain_manual4919_af_obs_multiplier_261m_v1", - "chain_manual4919_retimed_v1", - ), - ], -) -def test_unrecognized_or_conflicting_profiles_fail_before_launch( - monkeypatch, cotrain, single -): - monkeypatch.setenv("DP_COTRAIN_STANDARD", cotrain) - monkeypatch.setenv("DP_SINGLE_SOURCE_STANDARD", single) - api = runpy.run_path(str(ROOT / "scripts/train/standard_dp_launch.py")) - with pytest.raises(ValueError): - api["recipe"]() - env = dict(__import__("os").environ) - result = subprocess.run( - [ - "bash", - str(ROOT / "scripts/train/planar_v2_dp_cotrain_clean_skynet_v1.sbatch"), - ], - env=env, - capture_output=True, - ) - assert result.returncode == 64 - - -def test_current_pair_helper_composes_all_eight_without_claiming_split_proof(tmp_path): - api = runpy.run_path(str(ROOT / "tools/libero_oat_pair_config.py")) - result = api["compose_all"](ROOT, tmp_path) - assert len(result["rows"]) == 8 - assert result["recipe_contract"] == "current" - assert ( - result["episode_split_match"] - == "UNVERIFIED_REQUIRES_MATERIALIZED_EPISODE_LISTS" - ) - assert result["full_training_ready"] is False - import json - - for suite, steps in SUITES: - af = json.loads((tmp_path / f"action_flow-{suite}.json").read_text()) - dp = json.loads((tmp_path / f"dp-{suite}.json").read_text()) - assert af["trainer"]["max_steps"] == 120000 - assert dp["trainer"]["max_steps"] == steps - assert api["validate"](dp, "dp", suite) == 16 - with pytest.raises(AssertionError): - api["validate"](dp, "dp", suite, recipe_contract="historical-v7") - af["trainer"]["max_steps"] = 80000 - with pytest.raises(AssertionError): - api["validate"](af, "action_flow", suite) - assert ( - api["validate"](af, "action_flow", suite, recipe_contract="historical-v7") - == 32 - ) - - -def test_historical_native_names_preserve_aliases_and_reject_current_recipe(): - from egomimic.benchmarks.libero.native_launch_profiles import ( - HISTORICAL_NATIVE_PROFILES, - PROFILES, - historical_profile_for_suite, - profile_for_experiment, - profile_for_suite, - ) - - assert PROFILES is HISTORICAL_NATIVE_PROFILES - assert profile_for_suite is historical_profile_for_suite - for suite, _ in SUITES: - assert historical_profile_for_suite(suite).experiment.startswith( - "libero_historical/" - ) - with pytest.raises(ValueError): - profile_for_experiment(f"libero/action_flow_{suite}_oat_dp_matched_s42") - - -@pytest.mark.parametrize("receipt", [None, {"recipe_contract": "historical-v7"}]) -def test_current_full_proof_rejects_legacy_gate_before_creating_output( - monkeypatch, tmp_path, receipt -): - import json - import sys - - monkeypatch.syspath_prepend(str(ROOT / "tools")) - api = runpy.run_path(str(ROOT / "tools/libero_oat_training_proof.py")) - monkeypatch.setenv("SLURM_STEP_ID", "fixture") - monkeypatch.setattr( - api["subprocess"], - "check_output", - lambda argv, **kwargs: "a" * 40 if argv[1] == "rev-parse" else "", - ) - argv = [ - "proof", - "--root", - str(tmp_path), - "--source-commit", - "a" * 40, - "--index", - "0", - "--phase", - "full", - "--attempt", - "v1", - ] - if receipt is not None: - path = tmp_path / "legacy.json" - path.write_text(json.dumps(receipt)) - argv += ["--readiness-receipt", str(path)] - monkeypatch.setattr(sys, "argv", argv) - with pytest.raises((ValueError, AssertionError)): - api["main"]() - assert not (tmp_path / "full-v1").exists() diff --git a/tests/test_data_context_contract.py b/tests/test_data_context_contract.py deleted file mode 100644 index 277e55758c..0000000000 --- a/tests/test_data_context_contract.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Data/evaluator boundaries work with opaque sources and immutable state.""" - -import subprocess -import sys -from copy import deepcopy - -import numpy as np -import pytest -import torch -from omegaconf import OmegaConf -from torch.utils.data import Dataset - -from egomimic.eval.eval import EvaluationDataRequirements, validation_trainer_overrides -from egomimic.pl_utils.data_context import DataContext -from egomimic.pl_utils.pl_data_utils import MultiDataModuleWrapper, annotation_collate -from egomimic.rldb.zarr.data_module import ZarrDataModule, _digest -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset - - -def test_model_context_requirements_are_generic_exact_subsets(): - context = DataContext( - None, - {}, - (), - {"opaque": {"frame": "camera", "rate": 30, "shape": [2, 7], "extra": 1}}, - ) - context.validate_requirements({"opaque": {"frame": "camera", "shape": [2, 7]}}) - for expected, path in ( - ({"frame": "wrist"}, "frame"), - ({"rate": 15}, "rate"), - ({"shape": [7, 2]}, "shape"), - ({"units": "metres"}, "units"), - ): - with pytest.raises(ValueError, match=f"opaque.{path}"): - context.validate_requirements({"opaque": expected}) - - -class TinyDataset(Dataset): - def __init__(self): - self.norm_stats = None - - def __len__(self): - return 4 - - def __getitem__(self, index): - return { - "embodiment": 7, - "action": torch.zeros(2, 3), - "record": "a" if index < 2 else "b", - "frame": index % 2, - } - - def episode_id_at(self, index): - return "a" if index < 2 else "b" - - def frame_index_at(self, index): - return index % 2 - - def episode_length_at(self, index): - return 2 - - def set_norm_stats_from(self, owner): - self.norm_stats = owner.norm_stats - - -def must_not_open_training(): - raise AssertionError("Standalone evaluation reopened the original training corpus") - - -def _state(): - return { - "norm_mode": "quantile", - "embodiments": [7], - "key_types": {7: {"action": "action_keys"}}, - "zarr_keys": {7: {"action": "action"}}, - "shapes": {7: {"action": [2, 3]}}, - "norm_stats": { - 7: { - "action": { - "quantile_1": np.zeros((2, 3)), - "quantile_99": np.ones((2, 3)), - } - } - }, - } - - -def _module(): - return ZarrDataModule( - train_datasets=OmegaConf.create( - {"source": {"_target_": __name__ + ".must_not_open_training"}} - ), - valid_datasets=OmegaConf.create( - {"unregistered_source": {"_target_": __name__ + ".TinyDataset"}} - ), - train_dataloader_params={}, - valid_dataloader_params={"unregistered_source": {"batch_size": 2}}, - ) - - -def test_eval_restores_context_without_constructing_training_dataset(): - state = _state() - module = _module() - context = module.prepare_context( - mode="eval", - normalization={"norm_mode": "quantile"}, - restored_state={ - "kind": "zarr-normalizer-v1", - "normalizer_state": state, - "sha256": _digest(state), - }, - ) - assert not module.train_datasets - assert ( - module.valid_datasets["unregistered_source"].norm_stats - is context.normalizer.norm_stats - ) - assert context.validation_groups == ("valid",) - assert context.snapshot()["sha256"] == _digest(state) - - -def test_eval_requires_context_and_rejects_corruption_or_mode_drift(): - with pytest.raises(ValueError, match="requires checkpoint data_context"): - _module().prepare_context(mode="eval", normalization={}) - state = _state() - saved = { - "kind": "zarr-normalizer-v1", - "normalizer_state": state, - "sha256": _digest(state), - } - corrupted = deepcopy(saved) - corrupted["normalizer_state"]["norm_stats"][7]["action"]["quantile_1"][0, 0] = 3 - with pytest.raises(ValueError, match="hash mismatch"): - _module().prepare_context( - mode="eval", normalization={}, restored_state=corrupted - ) - with pytest.raises(ValueError, match="normalization mode differs"): - _module().prepare_context( - mode="eval", normalization={"norm_mode": "zscore"}, restored_state=saved - ) - - -def test_preprocessing_contract_preserves_frames_but_allows_new_evaluation_locations(): - from egomimic.rldb.zarr.data_module import _preprocessing_contract - - config = { - "resolver": { - "folder_path": "/original/train", - "key_map": {"action_mode": "cartesian"}, - "transform_list": {"coord_frame": "camframe", "stride": 3}, - }, - "filters": {"episode_hashes": ["train"]}, - } - expected = _preprocessing_contract(config, 30) - relocated = deepcopy(config) - relocated["resolver"]["folder_path"] = "/elsewhere/validation" - relocated["filters"]["episode_hashes"] = ["heldout"] - assert _preprocessing_contract(relocated, 30) == expected - relocated["resolver"]["transform_list"]["coord_frame"] = "eef_frame" - assert _preprocessing_contract(relocated, 30) != expected - assert _preprocessing_contract(config, 60) != expected - - state = _state() - with pytest.raises(ValueError, match="preprocessing/frame"): - _module().prepare_context( - mode="eval", - normalization={}, - restored_state={ - "kind": "zarr-normalizer-v1", - "normalizer_state": state, - "sha256": _digest(state), - "preprocessing": {"7": expected}, - }, - ) - - -def test_evaluator_capabilities_force_order_and_limit_complete_episodes(): - module = MultiDataModuleWrapper( - {}, - {"opaque": TinyDataset()}, - {}, - {"opaque": {"batch_size": 3, "shuffle": True}}, - ) - module.configure_evaluation( - EvaluationDataRequirements( - ordered=True, - complete_episodes=True, - max_episodes=1, - sample_id_key="record", - frame_index_key="frame", - ) - ) - assert len(module.valid_datasets["opaque"]) == 2 - loader = module.val_dataloader().iterables["opaque"] - assert next(iter(loader))["frame"].tolist() == [0, 1] - - -def test_evaluator_preflight_rejects_missing_metadata_and_dropped_episode_tails(): - module = MultiDataModuleWrapper( - {}, - {"opaque": TinyDataset()}, - {}, - {"opaque": {"batch_size": 3, "drop_last": True}}, - ) - with pytest.raises(ValueError, match="lacks metadata"): - module.configure_evaluation(EvaluationDataRequirements(sample_id_key="missing")) - with pytest.raises(ValueError, match="drop_last=False"): - module.configure_evaluation(EvaluationDataRequirements(complete_episodes=True)) - - -def test_evaluator_cannot_change_cluster_policy(): - class Bad: - def trainer_overrides(self): - return {"devices": 8} - - with pytest.raises(ValueError, match="validation-loop settings"): - validation_trainer_overrides(Bad()) - - -@pytest.mark.parametrize("interval", [8, 15000]) -def test_actual_evaluator_preserves_explicit_cross_epoch_schedule(interval): - from egomimic.eval.planar_action_eval import PlanarActionEval - - evaluator = PlanarActionEval(energy_score_enabled=False, limit_val_batches=1.0) - options = OmegaConf.create( - {"check_val_every_n_epoch": None, "val_check_interval": interval} - ) - options.update(validation_trainer_overrides(evaluator, trainer_options=options)) - assert options.check_val_every_n_epoch is None - assert options.val_check_interval == interval - assert options.limit_val_batches == 1.0 - assert evaluator.trainer_overrides()["check_val_every_n_epoch"] == 1 - - -@pytest.mark.parametrize( - "options", - [ - {}, - {"check_val_every_n_epoch": 2}, - {"check_val_every_n_epoch": None, "val_check_interval": 1.0}, - ], -) -def test_evaluator_default_epoch_schedule_is_preserved(options): - from egomimic.eval.planar_action_eval import PlanarActionEval - - assert ( - validation_trainer_overrides( - PlanarActionEval(energy_score_enabled=False, limit_val_batches=1.0), - trainer_options=options, - )["check_val_every_n_epoch"] - == 1 - ) - - -@pytest.mark.parametrize( - "interval,epoch_batches,steps,validation_steps", - [(5, 3, 10, [5, 10]), (15000, 3491, 1, [])], -) -def test_lightning_validation_crosses_short_epoch_boundary( - tmp_path, interval, epoch_batches, steps, validation_steps -): - import lightning.pytorch as pl - from torch.utils.data import DataLoader, TensorDataset - - from egomimic.eval.planar_action_eval import PlanarActionEval - - class Tiny(pl.LightningModule): - def __init__(self): - super().__init__() - self.weight = torch.nn.Parameter(torch.ones(())) - self.validation_steps = [] - - def training_step(self, batch, batch_idx): - return self.weight.square() - - def validation_step(self, batch, batch_idx): - self.validation_steps.append(self.global_step) - - def configure_optimizers(self): - return torch.optim.SGD(self.parameters(), lr=0.01) - - options = {"check_val_every_n_epoch": None, "val_check_interval": interval} - options.update( - validation_trainer_overrides( - PlanarActionEval(energy_score_enabled=False, limit_val_batches=1.0), - trainer_options=options, - ) - ) - trainer = pl.Trainer( - accelerator="cpu", - devices=1, - max_steps=steps, - max_epochs=-1, - logger=False, - enable_checkpointing=False, - enable_progress_bar=False, - enable_model_summary=False, - num_sanity_val_steps=0, - default_root_dir=tmp_path, - **options, - ) - model = Tiny() - loader = DataLoader(TensorDataset(torch.zeros(epoch_batches, 1)), batch_size=1) - trainer.fit(model, train_dataloaders=loader, val_dataloaders=loader) - assert sorted(set(model.validation_steps)) == validation_steps - assert trainer.global_step == steps - - -def test_annotation_collation_does_not_mutate_dataset_cached_samples(): - sample = {"text": ["pick", "place"], "action": torch.zeros(2)} - for _ in range(2): - batch = annotation_collate([sample]) - assert batch["text"] == [["pick", "place"]] - assert sample["text"] == ["pick", "place"] - - -def test_local_training_imports_do_not_require_cloud_or_pi_backends(): - code = """ -import importlib.abc, sys -class Block(importlib.abc.MetaPathFinder): - def find_spec(self, fullname, path=None, target=None): - if fullname.split('.')[0] in {'boto3', 'botocore', 'cloudpathlib', 'sqlalchemy', 'psycopg', 'openpi'}: - raise AssertionError('Unexpected optional backend import: ' + fullname) -sys.meta_path.insert(0, Block()) -import egomimic.trainHydra -from egomimic.rldb.zarr.zarr_dataset_multi import LocalEpisodeResolver -""" - result = subprocess.run( - [sys.executable, "-c", code], capture_output=True, text=True, timeout=60 - ) - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize("values", [[float("nan")], [float("inf")], []]) -def test_normalization_rejects_a_source_with_no_finite_rows(values): - with pytest.raises(ValueError, match="finite"): - MultiDataset._compute_stats_for_array(np.asarray(values).reshape(-1, 1)) - - -def test_normalization_excludes_bad_rows_without_poisoning_other_samples(): - stats = MultiDataset._compute_stats_for_array( - np.array([[1.0, 2.0], [3.0, 4.0], [np.nan, 9.0]]) - ) - assert np.array_equal(stats["mean"], [2.0, 3.0]) - assert all(np.isfinite(value).all() for value in stats.values()) - - -@pytest.mark.parametrize( - "options,mode", - [ - ({"limit_val_batches": 1}, "eval"), - ({"limit_val_batches": 0.5}, "eval"), - ({"fast_dev_run": True}, "eval"), - ({"overfit_batches": 1}, "eval"), - ({"num_sanity_val_steps": 2}, "train"), - ], -) -def test_complete_episode_preflight_rejects_truncated_trainer(options, mode): - from egomimic.eval.eval import validate_validation_loop - - with pytest.raises(ValueError, match="Complete-episode"): - validate_validation_loop( - EvaluationDataRequirements(complete_episodes=True), options, mode=mode - ) - - -def test_complete_episode_preflight_accepts_full_loop_and_disabled_sanity(): - from egomimic.eval.eval import validate_validation_loop - - validate_validation_loop( - EvaluationDataRequirements(complete_episodes=True), - {"limit_val_batches": 1.0, "num_sanity_val_steps": 0, "devices": 8}, - mode="train", - ) - - -def test_complete_episode_preflight_rejects_missing_tail(): - class MissingTail(TinyDataset): - def __len__(self): - return 3 - - module = MultiDataModuleWrapper( - {}, {"source": MissingTail()}, {}, {"source": {"batch_size": 1}} - ) - with pytest.raises(ValueError, match="missing its tail"): - module.configure_evaluation(EvaluationDataRequirements(complete_episodes=True)) diff --git a/tests/test_data_tool_parity.py b/tests/test_data_tool_parity.py deleted file mode 100644 index 1597cd9cb5..0000000000 --- a/tests/test_data_tool_parity.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Actual HDF5/Zarr and no-model recorded-video workflows.""" - -import hashlib -import json -import subprocess -import sys -from copy import deepcopy - -import av -import h5py -import numpy as np -import pytest -import zarr -from hydra import compose, initialize_config_dir -from omegaconf import open_dict - -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset -from egomimic.scripts.eva_process.eva_to_zarr import convert_episode -from egomimic.scripts.viz_language import visualize_data -from tests.fixtures.synthetic_episodes import write_episode -from tests.test_retained_recipe_steps import CONFIGS - - -def test_hdf5_conversion_preserves_source_and_refuses_overwrite(tmp_path): - source = tmp_path / "1780000000000.hdf5" - actions = np.tile(np.array([0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0.7] * 2), (4, 1)) - with h5py.File(source, "w") as handle: - for root in ("observations", "actions"): - handle[f"{root}/eepose"] = actions - handle[f"{root}/joints"] = actions - for camera in ("front_img_1", "left_wrist_img", "right_wrist_img"): - handle[f"observations/images/{camera}"] = np.full( - (4, 64, 64, 3), 127, np.uint8 - ) - before = hashlib.sha256(source.read_bytes()).hexdigest() - target, preview = convert_episode(source, tmp_path / "out", "episode", "both", 30) - assert preview is None - assert hashlib.sha256(source.read_bytes()).hexdigest() == before - data = zarr.open(str(target), mode="r") - assert data.attrs["total_frames"] == 4 - assert data.attrs["embodiment"] == "eva_bimanual" - assert data["left.cmd_ee_pose"].shape[-1] == 7 - np.testing.assert_allclose(data["right.cmd_gripper"][:4], 0.7) - assert np.isfinite(data["left.cmd_ee_pose"][:4]).all() - assert set(data.attrs["intrinsics"]) == {"front_1"} - assert len(bytes(data["images.front_1"][0])) > 0 - metadata = (target / "zarr.json").read_bytes() - with pytest.raises(FileExistsError, match="overwrite"): - convert_episode(source, tmp_path / "out", "episode", "both", 30) - assert (target / "zarr.json").read_bytes() == metadata - - -def test_local_conversion_imports_without_cloud_or_models(): - code = """ -import importlib.abc, sys -class Block(importlib.abc.MetaPathFinder): - def find_spec(self, fullname, *args): - if fullname.split('.')[0] in {'boto3','botocore','sqlalchemy','openpi'} or fullname.startswith('egomimic.models'): - raise AssertionError('Unnecessary dependency: ' + fullname) -sys.meta_path.insert(0, Block()) -from egomimic.scripts.eva_process.eva_to_zarr import convert_episode -from egomimic.rldb.zarr.hdf5_to_zarr import convert_hdf5_to_zarr -from egomimic.scripts.viz_language import visualize_data -""" - subprocess.run( - [sys.executable, "-c", code], check=True, capture_output=True, text=True - ) - - -def test_recorded_visualization_uses_native_units_and_complete_videos( - tmp_path, monkeypatch -): - root = tmp_path / "episodes" - for i in range(2): - write_episode(root, "eva", T=8, H=64, W=64, seed=i) - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose(config_name="viz_language", overrides=["data=eva_pi_lang"]) - with open_dict(cfg): - cfg.output_dir = str(tmp_path / "visualization") - cfg.evaluator.max_episodes = 1 - cfg.evaluator.sample_every = 2 - ds = deepcopy(cfg.data.train_datasets.eva_bimanual) - ds.resolver._target_ = ( - "egomimic.rldb.zarr.zarr_dataset_multi.LocalEpisodeResolver" - ) - ds.resolver.folder_path = str(root) - ds.resolver.key_map.annotation_key = "annotations" - ds.filters = None - ds.mode = "total" - ds.bounds_check = False - cfg.data.valid_datasets = {"eva_bimanual": ds} - cfg.data.valid_dataloader_params = { - "eva_bimanual": {"batch_size": 3, "num_workers": 0} - } - cfg.data.train_datasets = { - "must_not_be_opened": {"_target_": "builtins.int", "invalid": "error"} - } - - def no_fitting(*args, **kwargs): - raise AssertionError( - "Recorded-data visualization must not fit a model normalizer" - ) - - monkeypatch.setattr(MultiDataset, "infer_norm_from_dataset", no_fitting) - import egomimic.rldb.embodiment.embodiment as renderer - - painted = [] - original = renderer._viz_annotations - - def paint(*args, **kwargs): - painted.append(kwargs["annotations"]) - return original(*args, **kwargs) - - monkeypatch.setattr(renderer, "_viz_annotations", paint) - receipt = json.loads(visualize_data(cfg).read_text()) - assert len(receipt["videos"]) == 1 - with av.open(receipt["videos"][0]) as video: - frames = list(video.decode(video=0)) - assert len(frames) == 8 - assert float(video.streams.video[0].average_rate) == 30 - assert len(receipt["samples"]) == 4 - assert {row["frame"] for row in receipt["samples"]} == {0, 2, 4, 6} - assert any("pick up the red cube" in str(text) for text in painted) - assert any("place it in the bin" in str(text) for text in painted) - with np.load(receipt["samples"][0]["arrays"], allow_pickle=False) as arrays: - assert arrays["actions_cartesian"].shape == (100, 14) - assert np.isfinite(arrays["actions_cartesian"]).all() - with pytest.raises(FileExistsError): - visualize_data(cfg) - - -def test_data_audit_records_exact_frames_and_configured_slices(tmp_path): - from types import SimpleNamespace - - import torch - - from egomimic.eval.data_field_audit import DataFieldAudit - - audit = DataFieldAudit( - { - "opaque": { - "first_zero": {"key": "a", "condition": "all_zero", "columns": [0, 2]}, - "invalid": {"key": "a", "condition": "nonfinite"}, - } - } - ) - values = torch.ones(3, 5, 4) - values[0, :, :2] = 0 - values[1, 2, 2] = float("nan") - audit.trainer = SimpleNamespace(default_root_dir=str(tmp_path)) - audit.set_validation_group("group") - audit.on_validation_start() - audit.on_validation_step( - { - "opaque": { - "a": values, - "episode_hash": ["a", "b", "c"], - "frame_index": [7, 11, 13], - } - }, - 0, - ) - audit.on_validation_end() - rows = [ - json.loads(row) - for row in (tmp_path / "data-audit-rows.jsonl").read_text().splitlines() - ] - assert {(r["check"], r["episode"], r["frame"]) for r in rows} == { - ("first_zero", "a", 7), - ("invalid", "b", 11), - } - receipt = json.loads((tmp_path / "data-audit.json").read_text()) - assert receipt["counts"]["group"]["opaque"]["first_zero"] == { - "frames": 3, - "matches": 1, - } diff --git a/tests/test_dataloader_rng_state.py b/tests/test_dataloader_rng_state.py deleted file mode 100644 index 45ca5b9ad7..0000000000 --- a/tests/test_dataloader_rng_state.py +++ /dev/null @@ -1,54 +0,0 @@ -import torch - -from egomimic.pl_utils.pl_data_utils import MultiDataModuleWrapper - - -def _module(seed=42): - return MultiDataModuleWrapper( - train_datasets={}, - valid_datasets={}, - train_dataloader_params={}, - valid_dataloader_params={}, - seed=seed, - ) - - -def test_loader_generators_are_stable_and_source_scoped(): - left = _module() - right = _module() - assert torch.equal( - left._loader_generator("train", "u_socket").get_state(), - right._loader_generator("train", "u_socket").get_state(), - ) - assert not torch.equal( - left._loader_generator("train", "u_socket").get_state(), - left._loader_generator("train", "chain").get_state(), - ) - - -def test_loader_generator_state_round_trips_through_datamodule_checkpoint(): - source = _module() - generator = source._loader_generator("train", "u_socket") - torch.rand(17, generator=generator) - checkpoint = source.state_dict() - - restored = _module() - restored.load_state_dict(checkpoint) - restored_generator = restored._loader_generator("train", "u_socket") - assert torch.equal(generator.get_state(), restored_generator.get_state()) - assert torch.equal( - torch.rand(8, generator=generator), - torch.rand(8, generator=restored_generator), - ) - - -def test_loader_checkpoint_rejects_a_different_seed(): - source = _module(seed=42) - source._loader_generator("train", "u_socket") - restored = _module(seed=43) - try: - restored.load_state_dict(source.state_dict()) - except ValueError as error: - assert "seed differs" in str(error) - else: - raise AssertionError("different loader seed was accepted") diff --git a/tests/test_deployment_data_requirements.py b/tests/test_deployment_data_requirements.py deleted file mode 100644 index 88313cfd1f..0000000000 --- a/tests/test_deployment_data_requirements.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Selected data must agree with model-owned frame, time and tokenizer semantics.""" - -from copy import deepcopy - -import pytest -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from egomimic.pipeline.inference_config import ( - build_inference_config, - validate_model_data_context, -) -from egomimic.pl_utils.data_context import DataContext -from egomimic.rldb.zarr.data_module import _preprocessing_contract -from scripts.audit_hydra_configs import CONFIGS, compose_for_audit - -PI_CASES = [ - ("pi0.5_bc_eva", "eva_pi_lang"), - ("pi0.5_bc_abc_eva_6d", "pi05/abc_eva_pi_6d"), - ("pi0.5_ft_abc_eva_6d", "pi05/abc_eva_pi_6d"), - ("pi0.5_bc_mecka_6d", "pi05/mecka_all_pi_6d"), - ("pi0.5_bc_mecka_abc_6d", "pi05/mecka_abc_pi_6d"), - ("pi0.5_cotrain_eva_aria_6d", "pi05/cotrain_pi_lang_6d"), - ("pi0.5_ft_aria_fold_grip", "pi05/aria_fold_pi_6d_grip"), -] -EXPERIMENTS = [ - "abc/yam_fstshirt_dp", - "abc/yam_fstshirt_hpt", - "abc_arc/abc_fstshirt_arc_bc", - "abc_arc/abc_lang_fstshirt_mecka_freefold_cotrain_baseline", - "abc_arc/abc_lang_fstshirt_mecka_freefold_cotrain_arcD40M100", - "e1/fold_time", - "e1/fold_arcvel", - "e1/fold_arcdur", - "e1/fold_arclogdur", - "e1/abc_time", - "e1/abc_arcdur", - "e1/abcs_time", - "e1/abcs_arclogdur", -] -SOURCE_IDENTITIES = {"human_bimanual": "3", "eva_bimanual": "6", "yam_bimanual": "7"} - - -def declared_data_context(cfg): - """Read data-side configuration, independently of the model requirements. - - No normalization or training claim: actual transforms are covered by the - source fixtures and real dataset/optimizer tests. This gate catches YAML - composition mismatches before requiring any cloud episode. - """ - state = { - "preprocessing": { - SOURCE_IDENTITIES[name]: _preprocessing_contract( - OmegaConf.to_container(ds, resolve=True), cfg.data.source_fps - ) - for name, ds in cfg.data.train_datasets.items() - if ds is not None - } - } - return DataContext(None, {}, (), state) - - -def check_selected_contract(cfg): - context = declared_data_context(cfg) - validate_model_data_context(cfg, context) - artifact = build_inference_config(cfg) - assert artifact["status"] == "ready" - graph = artifact["inference_graph"] - assert graph["native_output"]["frame"] == graph["output"]["frame"] - for source in context.state["preprocessing"]: - wrong = deepcopy(context.state) - wrong["preprocessing"][source]["source_fps"] = 15 - with pytest.raises(ValueError, match="source_fps"): - validate_model_data_context(cfg, DataContext(None, {}, (), wrong)) - wrong = deepcopy(context.state) - transform = wrong["preprocessing"][source]["transforms"] - # Timing variants may have equal tensor dimensions and still differ. - key = "variant" if "variant" in transform else "coord_frame" - transform[key] = "incompatible" - with pytest.raises(ValueError, match=key): - validate_model_data_context(cfg, DataContext(None, {}, (), wrong)) - - -@pytest.mark.parametrize("model,data", PI_CASES) -def test_pi_selected_frame_rotation_and_stride(model, data): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose( - config_name="train_zarr_cartesian_pi", - overrides=[ - f"model=pi05/{model}", - f"data={data}", - "pi05.pretrained_weights=/unused/no-weight-download", - ], - ) - check_selected_contract(cfg) - - -@pytest.mark.parametrize("experiment", EXPERIMENTS) -def test_yam_arc_and_tempo_selected_contracts(experiment): - with compose_for_audit(CONFIGS / f"experiment/{experiment}.yaml") as cfg: - check_selected_contract(cfg) - - -def test_every_shipped_ready_model_declares_data_semantics(): - for path in sorted((CONFIGS / "model").rglob("*.yaml")): - with compose_for_audit(path) as cfg: - if build_inference_config(cfg)["status"] == "ready": - assert OmegaConf.select(cfg, "model.data_requirements"), path - graph = build_inference_config(cfg)["inference_graph"] - assert graph["native_output"]["frame"] in ("camframe", "eef_frame") - assert graph["output"]["frame"] == graph["native_output"]["frame"] - - -def test_changing_data_alone_cannot_rewrite_pi_model_frame(): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose( - config_name="train_zarr_cartesian_pi", - overrides=[ - "pi05.pretrained_weights=/unused/no-weight-download", - "data.train_datasets.eva_bimanual.resolver.transform_list.coord_frame=camframe", - ], - ) - assert cfg.model.coordinate_frame == "eef_frame" - with pytest.raises(ValueError, match="coord_frame"): - validate_model_data_context(cfg, declared_data_context(cfg)) - # An intentional new protocol can choose the matching model-owned frame. - cfg.model.coordinate_frame = "camframe" - validate_model_data_context(cfg, declared_data_context(cfg)) - graph = build_inference_config(cfg)["inference_graph"] - assert graph["profiles"]["default"]["adapter"]["action_frame"] == "model_frame" diff --git a/tests/test_dp_action_flow_observation_parity.py b/tests/test_dp_action_flow_observation_parity.py deleted file mode 100644 index ea7f222ea6..0000000000 --- a/tests/test_dp_action_flow_observation_parity.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Reject mismatched proprio, condition widths, masks and multiplier inputs.""" - -from pathlib import Path - -import numpy as np -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate - -from egomimic.models.denoising_nets import ConditionalUnet1D -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_speed import SharedSpeedCondition -from egomimic.rldb.embodiment.pushshapes_retiming import ( - get_standard_dp_retimed_keymap, - get_standard_dp_retimed_transform_list, -) - - -def config(monkeypatch): - for name in ( - "DP_U_DATASET_DIR", - "DP_DATASET_DIR", - "DP_SPLIT_MANIFEST", - "DP_SPLIT_SHA256", - "DP_CHAIN_SPLIT_MANIFEST", - "DP_CHAIN_SPLIT_SHA256", - ): - monkeypatch.setenv(name, "/unused/" + name) - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(config_dir=str(root), version_base="1.3"): - return compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=pusht/planar_uc_manual4919_dp_261m_af_obs_multiplier", - ], - ) - - -def test_resolved_observation_capacity_and_training_contract(monkeypatch): - c = config(monkeypatch) - stages = c.model.pipeline.stages - assert stages[0].input_key == "state_agent_model" - assert stages[0].output_dim == 64 - assert all(x.input_dim == 4 for x in stages[0].projections.values()) - assert stages[1].n_obs_steps == 1 - assert stages[1].encoder.obs_specs.proprio_condition.input_dim == 64 - visual = stages[1].encoder.img_encoders.front_img_1 - assert (visual.image_size, visual.crop_height, visual.crop_width) == (96, 84, 84) - assert visual.pretrained is False and visual.norm_layer == "group" - assert visual.pool_type == "spatial_softmax" and visual.feature_dimension == 64 - assert stages[2].conditioning_input == "retiming_multiplier" - assert stages[2].output_key == "condition" - denoiser = next( - s for s in stages if s["_target_"].endswith("DiffusionDenoiserStage") - ) - assert denoiser.condition_input_dim == denoiser.policy.model.cond_dim == 128 - assert denoiser.action_dim == 5 and denoiser.action_horizon == 16 - assert list(denoiser.policy.model.down_dims) == [648, 1296, 2592] - assert c.planar.active_action_dims.pushshapes_sim_u_socket == 4 - assert c.planar.active_action_dims.pushshapes_sim_chain_gripper == 5 - assert c.callbacks.get("ema") is None - assert c.norm_stats.norm_mode == "minmax" - assert c.norm_stats.sample_frac == 1.0 - assert c.norm_stats.precomputed_norm_path is None - assert c.planar.batch_size == 32 - for group in (c.data.train_datasets, c.data.valid_datasets): - for item in group.values(): - assert item.resolver.key_map.model_proprio - assert item.resolver.transform_list.model_proprio - - -@pytest.mark.parametrize("width", (3, 4)) -def test_train_data_preserves_native_metadata_and_common5_targets(width): - keymap = get_standard_dp_retimed_keymap((1.0, 2.0), model_proprio=True) - assert keymap["state_agent_obj"]["key_type"] == "metadata_keys" - assert keymap["state_agent_model"]["key_type"] == "proprio_keys" - state = np.array([10.0, 20.0, np.pi, 30.0, 40.0, 0.7]) - batch = { - "state_agent_obj": state.copy(), - "state_agent_model": state.copy(), - "actions": np.zeros((31, width)), - "_retiming_view": 1, - } - for t in get_standard_dp_retimed_transform_list((1.0, 2.0), model_proprio=True): - batch = t.transform(batch) - np.testing.assert_allclose( - batch["state_agent_model"], [10.0, 20.0, -1.0, 0.0], atol=1e-6 - ) - np.testing.assert_array_equal(batch["state_agent_obj"], state) - assert batch["actions"].shape == (16, 5) - assert batch["retiming_rate"].item() == 2.0 - - -@pytest.mark.parametrize("embodiment", (19, 20)) -def test_observation_and_multiplier_gradients_in_both_graph_modes( - monkeypatch, embodiment -): - c = config(monkeypatch) - projection = instantiate(c.model.pipeline.stages[0]) - obs = instantiate(c.model.pipeline.stages[1]) - multiplier = SharedSpeedCondition(condition_dim=128, output_key="condition") - torch.nn.init.constant_(multiplier.mlp[-1].weight, 0.1) - graph = Pipeline([projection, obs, multiplier]) - for mode in ("train", "inference"): - state = torch.randn(2, 4, requires_grad=True) - result = graph.execute( - { - "state_agent_model": state, - "front_img_1": torch.randn(2, 3, 96, 96), - "embodiment": torch.tensor([embodiment, embodiment]), - "retiming_rate": torch.tensor([[1.0], [2.0]]), - }, - mode=mode, - ) - assert result["condition"].shape == (2, 128) - result["condition"].square().mean().backward() - assert torch.isfinite(state.grad).all() and state.grad.abs().sum() > 0 - assert multiplier.mlp[0].weight.grad.abs().sum() > 0 - assert next(obs.parameters()).grad.abs().sum() > 0 - - -def test_real_module_parameter_budget_without_weight_allocation(monkeypatch): - c = config(monkeypatch) - with torch.device("meta"): - projection = instantiate(c.model.pipeline.stages[0]) - visual = instantiate( - c.model.pipeline.stages[1].encoder.img_encoders.front_img_1 - ) - multiplier = instantiate(c.model.pipeline.stages[2]) - unet = ConditionalUnet1D( - input_dim=5, - cond_dim=128, - ac_latent_seq=1, - diffusion_step_embed_dim=256, - down_dims=[648, 1296, 2592], - kernel_size=3, - ) - count = sum( - p.numel() - for m in (projection, visual, multiplier, unet) - for p in m.parameters() - ) - assert count == 261382885 - - -def test_real_small_dp_uses_128_condition_and_preserves_u_gripper_mask(): - from egomimic.models.ddim_scheduler import DDIMScheduler - from egomimic.models.diffusion_policy import DiffusionPolicy - from egomimic.pipeline.stages_diffusion import DiffusionEpsilonLossStage - - multiplier = SharedSpeedCondition(condition_dim=128, output_key="condition") - condition = multiplier( - { - "condition": torch.randn(2, 128), - "retiming_rate": torch.tensor([[1.0], [2.0]]), - } - )["condition"] - unet = ConditionalUnet1D( - input_dim=5, - cond_dim=128, - ac_latent_seq=1, - diffusion_step_embed_dim=16, - down_dims=[16, 32, 64], - kernel_size=3, - ) - prediction = unet(torch.randn(2, 16, 5), torch.tensor([1, 50]), condition) - prediction.retain_grad() - loss_stage = DiffusionEpsilonLossStage( - active_action_dims_by_embodiment={ - "pushshapes_sim_u_socket": 4, - "pushshapes_sim_chain_gripper": 5, - } - ) - loss = loss_stage( - { - "diffusion/predicted_noise": prediction, - "diffusion/noise_target": torch.randn_like(prediction), - "target": torch.zeros_like(prediction), - "embodiment": torch.tensor([19, 19]), - } - )["loss/diffusion_noise"] - loss.backward() - assert torch.isfinite(loss) and torch.count_nonzero(prediction.grad[..., 4]) == 0 - assert multiplier.mlp[-1].weight.grad.abs().sum() > 0 - policy = DiffusionPolicy( - model=unet, - action_horizon=16, - num_inference_steps=2, - noise_scheduler=DDIMScheduler( - num_train_timesteps=100, - beta_schedule="squaredcos_cap_v2", - prediction_type="epsilon", - ), - ) - sample = policy.sample_action(condition.detach(), action_dim=5) - assert sample.shape == (2, 16, 5) and torch.isfinite(sample).all() diff --git a/tests/test_e1_arc_codec.py b/tests/test_e1_arc_codec.py deleted file mode 100644 index 9f523091b3..0000000000 --- a/tests/test_e1_arc_codec.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Compatibility checks for the E1 codecs carried onto the graph ARC stack.""" - -import numpy as np -import pytest - -from egomimic.rldb.embodiment.bimanual_arc import get_transform_list -from egomimic.rldb.zarr.arc_length_tokenizer import TokenizeBimanualArcLengthCartesian -from egomimic.rldb.zarr.e1_arc_tokenizer import ( - TokenizeBimanualArcLengthE1, - durations_to_clock_abs, -) - - -def _codec(mode, **kwargs): - return TokenizeBimanualArcLengthE1( - min_distance_unit=0.4, - resampled_vector_length=61, - dt=1 / 30, - velocity_mode=mode, - **kwargs, - ) - - -def _chunk(*, curved=False, right_idle=False): - t = np.arange(181) / 30 - chunk = np.zeros((len(t), 14)) - for offset in (0, 7): - if offset == 7 and right_idle: - continue - chunk[:, offset] = 0.1 * t - chunk[:, offset + 6] = 0.05 * t - if curved: - chunk[:, offset + 1] = 0.04 * np.sin(t) - chunk[:, offset + 3] = 0.08 * t - return chunk - - -@pytest.mark.parametrize("mode", ["mean", "profile", "logdur", "dur"]) -def test_e1_layouts_recover_a_constant_speed_trajectory(mode): - codec = _codec(mode) - chunk = _chunk() - token = codec.transform({"actions_cartesian": chunk.copy()})["actions_cartesian"] - assert token.shape == ((62, 14) if mode == "mean" else (61, 16)) - decoded = codec.detokenize(token, action_horizon=40) - np.testing.assert_allclose(decoded, chunk[:40], atol=1e-10, rtol=0) - - -def test_e1_chord_tokens_and_decoder_match_the_current_lab_codec(): - parent = TokenizeBimanualArcLengthCartesian( - min_distance_unit=0.4, resampled_vector_length=61, dt=1 / 30 - ) - e1 = _codec("mean", velocity_norm="chord") - chunk = _chunk(curved=True) - reference = parent.transform({"actions_cartesian": chunk.copy()})[ - "actions_cartesian" - ] - token = e1.transform({"actions_cartesian": chunk.copy()})["actions_cartesian"] - np.testing.assert_allclose(token, reference, atol=1e-10, rtol=0) - np.testing.assert_allclose( - e1.detokenize(token, action_horizon=40), - parent.detokenize(reference, action_horizon=40), - atol=1e-10, - rtol=0, - ) - - -@pytest.mark.parametrize("fixed_spacing", [False, True]) -@pytest.mark.parametrize("right_idle", [False, True]) -def test_absolute_and_log_durations_preserve_the_same_clock(fixed_spacing, right_idle): - # Vary tempo along a straight path: its resampled polyline has exactly the - # source span, so this isolates the timing representation from geometric - # discretization error on curved paths. - chunk = _chunk(right_idle=right_idle) - for offset in (0, 7): - if offset != 7 or not right_idle: - chunk[:, offset] = 0.6 * np.linspace(0, 1, len(chunk)) ** 1.25 - decoders = [_codec(mode, fixed_spacing=fixed_spacing) for mode in ("dur", "logdur")] - tokens = [ - c.transform({"actions_cartesian": chunk.copy()})["actions_cartesian"] - for c in decoders - ] - decoded = [c.detokenize(t, action_horizon=40) for c, t in zip(decoders, tokens)] - np.testing.assert_allclose(decoded[0], decoded[1], atol=1e-10, rtol=0) - assert all(np.isfinite(t).all() for t in tokens) - if right_idle: - np.testing.assert_array_equal(decoded[0][:, 7:], 0.0) - - -def test_absolute_clock_ignores_padding_and_negative_durations(): - clock = durations_to_clock_abs( - np.array([0.1, 0.2, -0.5, 999.0]), np.array([0.0, 0.1, 0.2, 0.2]) - ) - np.testing.assert_allclose(clock, [0.0, 0.1, 0.3, 0.3], atol=1e-12) - - -@pytest.mark.parametrize("embodiment", ["human", "yam"]) -@pytest.mark.parametrize( - "variant", ["time", "arcmean", "arcvel", "arclogdur", "arcdur"] -) -def test_e1_transforms_compose_with_the_current_embodiments(embodiment, variant): - transforms = get_transform_list(variant, chunk_length=200, embodiment=embodiment) - codecs = [t for t in transforms if isinstance(t, TokenizeBimanualArcLengthE1)] - assert len(codecs) == (0 if variant == "time" else 1) - copy = [t for t in transforms if getattr(t, "dst", None) == "actions_time"] - assert len(copy) == 1 - assert copy[0].n_rows == 100 diff --git a/tests/test_e1_campaign_configs.py b/tests/test_e1_campaign_configs.py deleted file mode 100644 index 708707920e..0000000000 --- a/tests/test_e1_campaign_configs.py +++ /dev/null @@ -1,97 +0,0 @@ -from pathlib import Path - -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import OmegaConf - -CONFIGS = Path(__file__).parents[1] / "egomimic/hydra_configs" -EXPERIMENTS = sorted( - path.stem - for path in (CONFIGS / "experiment/e1").glob("*.yaml") - # This is the BimanualTempo campaign's codec contract, not every experiment - # sharing the e1 directory. Stationary/keypoint recipes have distinct - # evaluators and are covered by their own matched/native contract tests. - if path.stem.startswith(("fold_", "abc_", "abcs_", "abcst_")) - and not path.stem.endswith("_base") -) - - -def config(experiment): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - return compose( - config_name="train_zarr_cartesian", - overrides=[ - f"+experiment=e1/{experiment}", - "e1.spread=smoke", - "e1.train_root=/tmp/e1/train", - "e1.valid_root=/tmp/e1/valid", - "evaluator.results_path=/tmp/e1/results.json", - "e1.image_weights=null", - ], - ) - - -@pytest.mark.parametrize("experiment", EXPERIMENTS) -def test_campaign_model_matches_codec(experiment): - cfg = config(experiment) - model = OmegaConf.to_container(cfg.model, resolve=True) - evaluator = instantiate(cfg.evaluator) - assert evaluator.variant == cfg.e1.variant - expected = ( - (101, 14) - if cfg.e1.variant == "arcmean" - else (100, 14 if cfg.e1.variant == "time" else 16) - ) - noising = model["pipeline"]["stages"][3] - head = model["pipeline"]["stages"][4]["model"] - assert (noising["action_horizon"], noising["action_dim"]) == expected - assert (head["act_seq"], head["act_dim"]) == expected - for split in (cfg.data.train_datasets, cfg.data.valid_datasets): - for dataset in split.values(): - instantiate(dataset.resolver.key_map) - instantiate(dataset.resolver.transform_list) - - -@pytest.mark.parametrize( - "experiment,embodiment", - [("fold_time", 3), ("abc_arcmean", 7), ("abcs_arclogdur", 7)], -) -def test_graph_training_and_inference(experiment, embodiment): - torch.set_num_threads(1) - cfg = config(experiment) - cfg.hpt.num_blocks = 1 - cfg.hpt.embed_dim = 32 - cfg.hpt.stem_specs.cross_attn.crossattn_latent = 2 - cfg.hpt.stem_specs.cross_attn.crossattn_dim_head = 8 - cfg.model.pipeline.stages[4].num_inference_steps = 2 - cfg.model.pipeline.stages[4].model.nblocks = 1 - policy = instantiate(cfg.model.pipeline) - row = { - "embodiment": torch.tensor([embodiment, embodiment]), - "observations.state.ee_pose": torch.randn(2, 1, 14), - "observations.images.front_img_1": torch.rand(2, 1, 3, 32, 32), - "actions_cartesian": torch.randn(2, cfg.e1.action_horizon, cfg.e1.action_dim), - } - if experiment.startswith("abcs_"): - for camera in ["left_wrist_img", "right_wrist_img"]: - row["observations.images." + camera] = torch.rand(2, 1, 3, 32, 32) - batch = {"opaque-source": row} - result = policy.forward_training(batch) - loss = policy.compute_losses(result, batch)["loss"] - assert torch.isfinite(loss) - loss.backward() - policy.nets.eval() - prediction = policy.forward_eval(batch)["opaque-source"]["pred_action"] - assert prediction.shape == row["actions_cartesian"].shape - assert torch.isfinite(prediction).all() - - -def test_full_validation_fraction_is_preserved(): - cfg = config("abc_time") - cfg.evaluator.limit_val_batches = 1.0 - assert ( - type(instantiate(cfg.evaluator).trainer_overrides()["limit_val_batches"]) - is float - ) diff --git a/tests/test_e1_data_adapters.py b/tests/test_e1_data_adapters.py deleted file mode 100644 index 7f7a0f2576..0000000000 --- a/tests/test_e1_data_adapters.py +++ /dev/null @@ -1,107 +0,0 @@ -"""The campaign adapters preserve domain routing and graph loader behavior.""" - -from types import SimpleNamespace - -import numpy as np -import torch -import zarr - -from egomimic.pl_utils.pl_data_utils import MultiDataModuleWrapper -from egomimic.rldb.zarr.e1_anchor_sampler import anchor_weights -from egomimic.rldb.zarr.e1_resolvers import ( - LocalFolderEpisodeResolverWithEmbodimentOverride, - S3EpisodeResolverWithEmbodimentOverride, -) -from egomimic.rldb.zarr.zarr_dataset_multi import S3EpisodeResolver - - -def test_local_resolver_keeps_image_size_and_overrides_stale_embodiment(tmp_path): - (tmp_path / "episode.zarr").mkdir() - - def leaf(path, **kwargs): - return SimpleNamespace(episode_path=path, embodiment="eva_bimanual", **kwargs) - - resolver = LocalFolderEpisodeResolverWithEmbodimentOverride( - tmp_path, embodiment_override="yam_bimanual", image_hw=(480, 640) - ) - resolver._dataset_class = leaf - datasets = resolver.resolve() - assert set(datasets) == {"episode"} - assert datasets["episode"].embodiment == "yam_bimanual" - assert datasets["episode"].image_hw == (480, 640) - - -def test_s3_override_changes_only_the_resolved_domain(tmp_path, monkeypatch): - leaf = SimpleNamespace(embodiment="eva_bimanual", image_hw=(480, 640)) - monkeypatch.setattr( - S3EpisodeResolver, "resolve", lambda *_args, **_kwargs: {"episode": leaf} - ) - resolver = S3EpisodeResolverWithEmbodimentOverride( - tmp_path, embodiment_override="yam_bimanual" - ) - assert resolver.resolve()["episode"] is leaf - assert leaf.embodiment == "yam_bimanual" - assert leaf.image_hw == (480, 640) - - -def test_progress_weights_reduce_slow_episode_overrepresentation(tmp_path): - class Leaf: - def __init__(self, path): - self.episode_path = path - - def __len__(self): - return 80 - - leaves = {} - for name, speed in (("slow", 0.1), ("fast", 0.3)): - path = tmp_path / name - group = zarr.open_group(path, mode="w") - pose = np.zeros((80, 7)) - pose[:, 0] = speed * np.arange(80) / 30 - pose[:, 3] = 1 - for hand in ("left", "right"): - group.create_array(f"{hand}.obs_wrist_pose", data=pose) - leaves[name] = Leaf(path) - dataset = SimpleNamespace( - datasets=leaves, - index_map=[(name, i) for name in leaves for i in range(80)], - ) - uniform = anchor_weights(dataset, alpha=1, fc_hz=3, fs_hz=30, pose_key="wrist") - np.testing.assert_array_equal(uniform, np.ones(160)) - progress = anchor_weights(dataset, alpha=0, fc_hz=3, fs_hz=30, pose_key="wrist") - np.testing.assert_allclose( - progress[80:].mean() / progress[:80].mean(), 3.0, rtol=1e-6 - ) - - -def test_anchor_sampler_uses_graph_collation_without_changing_config(monkeypatch): - dataset = [{"x": torch.tensor([i]), "annotation_keys": [str(i)]} for i in range(4)] - sampler = torch.utils.data.SequentialSampler(dataset) - monkeypatch.setattr( - "egomimic.rldb.zarr.e1_anchor_sampler.build_anchor_sampler", - lambda dataset, **_kwargs: sampler, - ) - params = { - "train": { - "batch_size": 2, - "num_workers": 0, - "shuffle": True, - "sampler": { - "_target_": "egomimic.rldb.zarr.e1_anchor_sampler.build_anchor_sampler", - "alpha": 0.2, - }, - } - } - module = MultiDataModuleWrapper( - train_datasets={"train": dataset}, - valid_datasets={"human_bimanual": dataset}, - train_dataloader_params=params, - valid_dataloader_params={"human_bimanual": {"batch_size": 2}}, - ) - loader = module.train_dataloader().iterables["train"] - assert loader.sampler is sampler - batch = next(iter(loader)) - assert batch["annotation_keys"] == [["0"], ["1"]] - torch.testing.assert_close(batch["x"], torch.tensor([[0], [1]])) - assert params["train"]["shuffle"] is True - assert params["train"]["sampler"]["alpha"] == 0.2 diff --git a/tests/test_e1_graph_evaluation.py b/tests/test_e1_graph_evaluation.py deleted file mode 100644 index 5333af1b81..0000000000 --- a/tests/test_e1_graph_evaluation.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Regression coverage for the graph adapter and the original E1 metric values.""" - -import json -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -import torch - -from egomimic.eval.bimanual_tempo_eval import BimanualTempoEval -from egomimic.rldb.zarr.e1_arc_tokenizer import TokenizeBimanualArcLengthE1 - - -def example(variant="time", perturbation="none"): - progress = np.linspace(0, 0.6, 100) - truth = np.zeros((1, 100, 14)) - for offset in (0, 7): - truth[0, :, offset] = progress - truth[0, :, offset + 1] = 0.04 * np.sin(progress * 8) - truth[0, :, offset + 6] = progress / 2 - pred = truth.copy() - if perturbation == "short": - pred *= 0.45 - elif perturbation == "offset": - pred[:, :, 1] += 0.07 - if variant != "time": - codec = TokenizeBimanualArcLengthE1( - min_distance_unit=0.4, - resampled_vector_length=100, - dt=1 / 30, - velocity_norm="path", - velocity_mode={ - "arcmean": "mean", - "arcvel": "profile", - "arclogdur": "logdur", - "arcdur": "dur", - }[variant], - ) - pred = codec.transform({"actions_cartesian": pred[0]})["actions_cartesian"][ - None - ] - return torch.tensor(pred, dtype=torch.float32), torch.tensor( - truth, dtype=torch.float32 - ) - - -class Normalizer: - def __init__(self, scale=1): - self.scale = scale - - def unnormalize(self, values, _embodiment): - return {key: value * self.scale for key, value in values.items()} - - -def evaluator(pred, variant="time", **kwargs): - result = BimanualTempoEval(variant=variant, **kwargs) - result.model = SimpleNamespace( - forward_eval=lambda batch: {source: {"pred_action": pred} for source in batch} - ) - result.bind_data_context(normalizer=Normalizer()) - return result - - -def batch(truth): - return {"opaque-source": {"embodiment": torch.tensor([7]), "actions_time": truth}} - - -@pytest.mark.parametrize( - "case", - [ - "time-none", - "time-short", - "time-offset", - "arcmean-none", - "arclogdur-none", - "arcdur-none", - ], -) -def test_graph_evaluation_matches_frozen_source_campaign_values(case): - variant, perturbation = case.split("-") - pred, truth = example(variant, perturbation) - obj = evaluator(pred, variant) - obj.on_validation_step(batch(truth), 0) - actual = obj.on_validation_end() - fixture = json.loads( - (Path(__file__).parent / "fixtures/e1_source_metrics.json").read_text() - ) - expected = fixture["cases"][case] - for key, value in expected.items(): - if isinstance(value, (int, float)): - assert actual[key] == pytest.approx(value, rel=1e-9, abs=1e-12), key - else: - assert actual[key] == value, key - - -def test_graph_predictions_and_time_targets_are_unnormalized_once(tmp_path): - pred, truth = example() - obj = evaluator(pred / 2, results_path=tmp_path / "result.json") - obj.bind_data_context(normalizer=Normalizer(2)) - metrics = obj.on_validation_step(batch(truth / 2), 0) - result = obj.on_validation_end() - assert result["paired_mse"] == 0 - assert metrics["Valid/E1/E_time/yam_bimanual"].device == pred.device - assert json.loads((tmp_path / "result.json").read_text())["n_chunks"] == 1 - - -def test_validation_groups_keep_independent_accumulators(): - pred, truth = example() - obj = evaluator(pred) - obj.set_validation_group("perfect") - obj.on_validation_step(batch(truth), 0) - obj.set_validation_group("offset") - obj.on_validation_step(batch(truth + 0.1), 0) - result = obj.on_validation_end()["groups"] - assert result["perfect"]["yam_bimanual"]["paired_mse"] == 0 - assert result["offset"]["yam_bimanual"]["paired_mse"] > 0 - assert result["perfect"]["yam_bimanual"]["n_chunks"] == 1 - - -def test_distributed_results_merge_counts_and_sums(monkeypatch): - from egomimic.eval import e1_fold_tempo_eval as module - - pred, truth = example("time", "offset") - obj = evaluator(pred) - obj.on_validation_step(batch(truth), 0) - local = obj.on_validation_end() - monkeypatch.setattr(module.dist, "is_initialized", lambda: True) - monkeypatch.setattr(module.dist, "get_world_size", lambda: 2) - monkeypatch.setattr(module.dist, "get_rank", lambda: 0) - monkeypatch.setattr( - module.dist, "broadcast_object_list", lambda *args, **kwargs: None - ) - monkeypatch.setattr( - module.dist, - "all_gather_object", - lambda output, value: output.__setitem__(slice(None), [value, value]), - ) - merged = obj.on_validation_end() - assert merged["n_chunks"] == 2 - assert merged["paired_mse"] == pytest.approx(local["paired_mse"]) - assert merged["e_time_p90"] == pytest.approx(local["e_time_p90"]) - - -def test_tempo_finishes_shared_episode_video(tmp_path): - import av - - pred, truth = example("time", "offset") - obj = evaluator(pred) - obj.trainer = SimpleNamespace( - default_root_dir=str(tmp_path), - current_epoch=0, - is_global_zero=True, - lightning_module=SimpleNamespace(log_dict=lambda *args, **kwargs: None), - ) - obj.on_validation_start() - obj.on_validation_step(batch(truth), 0) - obj._record_video_frames( - ("valid", "source"), - torch.zeros(3, 16, 16, 3, dtype=torch.uint8), - {"episode_hash": ["e"] * 3, "frame_index": [0, 1, 2]}, - ) - assert obj.on_validation_end()["n_chunks"] == 1 - [path] = list(tmp_path.rglob("e.mp4")) - with av.open(str(path)) as container: - assert len(list(container.decode(video=0))) == 3 - - -def test_missing_time_targets_fail_instead_of_writing_empty_metrics(): - pred, _ = example() - obj = evaluator(pred) - with pytest.raises(KeyError, match="actions_time"): - obj.on_validation_step({"source": {"embodiment": torch.tensor([7])}}, 0) - with pytest.raises(RuntimeError, match="no prediction"): - obj.on_validation_end() - - -def test_validation_logs_through_lightning_and_writes_rescore_schema(tmp_path): - pred, truth = example() - obj = evaluator(pred, results_path=tmp_path / "e1_tempo.json") - logged = [] - obj.trainer = SimpleNamespace( - is_global_zero=True, - lightning_module=SimpleNamespace( - log_dict=lambda values, **kwargs: logged.append(values) - ), - ) - obj.on_validation_step(batch(truth), 0) - obj.on_validation_end() - assert len(logged) == 1 - assert logged[0]["Valid/E1/E_time/yam_bimanual"] == 0 - result = json.loads((tmp_path / "eval_metrics.json").read_text()) - assert result["results"][0]["Valid/E1/paired_mse/yam_bimanual"] == 0 diff --git a/tests/test_ema_resume.py b/tests/test_ema_resume.py deleted file mode 100644 index 7151956a99..0000000000 --- a/tests/test_ema_resume.py +++ /dev/null @@ -1,66 +0,0 @@ -from types import SimpleNamespace - -import pytest -import torch -from lightning import LightningModule - -from egomimic.utils.ema_callback import EMACallback - - -class _TinyModule(LightningModule): - def __init__(self): - super().__init__() - self.layer = torch.nn.Linear(2, 1, bias=False) - - def log(self, *_args, **_kwargs): - pass - - -def _resume_checkpoint(module, *, global_step=20_000, ema_num_updates=20_000): - return { - "global_step": global_step, - "ema_config": { - "decay": 0.9978, - "use_warmup": False, - "update_after_step": 0, - "inv_gamma": 1.0, - "power": 0.75, - "min_decay": 0.0, - }, - "ema_num_updates": ema_num_updates, - "ema_state_dict": { - name: parameter.detach().clone() - for name, parameter in module.named_parameters() - }, - } - - -def test_ema_resume_baselines_before_lightning_restores_loop_progress(): - module = _TinyModule() - callback = EMACallback(decay=0.9978) - callback.on_load_checkpoint( - SimpleNamespace(global_step=0), - module, - _resume_checkpoint(module), - ) - - # Lightning can invoke fit-start hooks while trainer.global_step is still - # zero, then restore loop progress before the next train-batch-end hook. - trainer = SimpleNamespace(global_step=0) - callback.on_fit_start(trainer, module) - trainer.global_step = 20_001 - callback.on_train_batch_end(trainer, module, None, None, 0) - - assert callback.num_updates == 20_001 - - -def test_ema_resume_rejects_update_count_drift(): - module = _TinyModule() - callback = EMACallback(decay=0.9978) - - with pytest.raises(RuntimeError, match="must equal checkpoint global_step"): - callback.on_load_checkpoint( - SimpleNamespace(global_step=0), - module, - _resume_checkpoint(module, ema_num_updates=19_999), - ) diff --git a/tests/test_episode_limited_validation.py b/tests/test_episode_limited_validation.py deleted file mode 100644 index 07de5dbf39..0000000000 --- a/tests/test_episode_limited_validation.py +++ /dev/null @@ -1,74 +0,0 @@ -from pathlib import Path - -from torch.utils.data import Dataset, SequentialSampler - -from egomimic.pl_utils.pl_data_utils import ( - EpisodeLimitedDataset, - MultiDataModuleWrapper, -) - - -class _Leaf(Dataset): - def __init__(self, episode: str, length: int): - self.episode_path = Path(f"{episode}.zarr") - self.length = length - - def __len__(self): - return self.length - - def episode_id_at(self, index): - return self.episode_path.stem - - def __getitem__(self, index): - return { - "episode_hash": self.episode_path.stem, - "frame_index": index, - } - - -class _Multi(Dataset): - def __init__(self): - self.datasets = { - "episode-b": _Leaf("episode-b", 2), - "episode-a": _Leaf("episode-a", 3), - } - self.index_map = [ - (name, index) - for name, dataset in self.datasets.items() - for index in range(len(dataset)) - ] - - def __len__(self): - return len(self.index_map) - - def episode_id_at(self, index): - name, local_index = self.index_map[index] - return self.datasets[name].episode_id_at(local_index) - - def __getitem__(self, index): - name, local_index = self.index_map[index] - return self.datasets[name][local_index] - - -def test_episode_limit_keeps_complete_ordered_prefix(): - limited = EpisodeLimitedDataset(_Multi(), max_episodes=1) - assert len(limited) == 2 - assert [limited[index]["episode_hash"] for index in range(len(limited))] == [ - "episode-b", - "episode-b", - ] - - -def test_open_loop_datamodule_forces_ordered_validation(): - dataset = _Multi() - datamodule = MultiDataModuleWrapper( - train_datasets={}, - valid_datasets={"human_bimanual": dataset}, - train_dataloader_params={}, - valid_dataloader_params={"human_bimanual": {"batch_size": 2, "shuffle": True}}, - valid_episode_limit=1, - force_valid_order=True, - ) - loader = datamodule.val_dataloader().iterables["human_bimanual"] - assert isinstance(loader.sampler, SequentialSampler) - assert len(datamodule.valid_datasets["human_bimanual"]) == 2 diff --git a/tests/test_episode_selection.py b/tests/test_episode_selection.py deleted file mode 100644 index af092e0641..0000000000 --- a/tests/test_episode_selection.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Selection stays in the data adapter and cannot change pinned membership.""" - -from copy import deepcopy - -import pytest -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import open_dict - -from egomimic.rldb.filters import DatasetFilter -from egomimic.rldb.zarr.selection import selected_episodes -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset -from tests.fixtures.synthetic_episodes import write_episode -from tests.test_retained_recipe_steps import CONFIGS - - -@pytest.mark.parametrize("mode", ["random", "pairs", "custom"]) -def test_selected_sources_restore_data_context_and_real_frame_indices(mode, tmp_path): - for vendor in ("eva", "aria"): - for i in range(3): - write_episode(tmp_path / vendor, vendor, T=8, H=32, W=32, seed=i) - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=["data=cotrain_pi_latent", f"data.selection.mode={mode}"], - ) - cfg.data.selection.pair_hashes.eva = ["eva_00", "eva_01"] - cfg.data.selection.pair_hashes.aria = ["aria_00", "aria_01"] - cfg.data.selection.custom_hashes = deepcopy(cfg.data.selection.pair_hashes) - cfg.data.selection.frames_per_episode = 3 - for domain, vendor in [("eva_bimanual", "eva"), ("human_bimanual", "aria")]: - ds = cfg.data.train_datasets[domain] - with open_dict(ds): - ds.resolver._target_ = ( - "egomimic.rldb.zarr.zarr_dataset_multi.LocalEpisodeResolver" - ) - del ds.resolver.require_annotations - ds.resolver.folder_path = str(tmp_path / vendor) - ds.filters = None - ds.bounds_check = False - dm = instantiate(cfg.data, _recursive_=False) - context = dm.prepare_context(mode="train", normalization={"num_workers": 0}) - for ds in dm.train_datasets.values(): - assert ds.norm_stats is context.normalizer.norm_stats - vendor = "eva" if ds[0]["embodiment"] == 6 else "aria" - count = 3 if mode == "random" else 2 - assert set(ds.datasets) == {f"{vendor}_{i:02d}" for i in range(count)} - if mode != "random": - assert len(ds) == 6 - assert ds.base.norm_stats is context.normalizer.norm_stats - assert [ds.frame_index_at(i) for i in range(3)] == [0, 4, 7] - assert not isinstance(ds, MultiDataset) - - -def test_pins_reject_incomplete_resolver_results_and_empty_selection(): - class IncompleteResolver: - def resolve(self, filters): - return {} - - with pytest.raises(ValueError, match="Pinned episodes did not load"): - MultiDataset._from_resolver( - IncompleteResolver(), - filters=DatasetFilter(episode_hashes=["corrupt"]), - mode="total", - ) - with pytest.raises(ValueError, match="nonempty"): - selected_episodes( - IncompleteResolver(), selection_mode="pairs", episode_hashes=[] - ) diff --git a/tests/test_eval_overlay_annotations.py b/tests/test_eval_overlay_annotations.py deleted file mode 100644 index 09d440d88d..0000000000 --- a/tests/test_eval_overlay_annotations.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Val overlay must forward language annotations into viz_gt_preds.""" - -from functools import partial - -import numpy as np - -from egomimic.eval.bimanual_cartesian_eval import ( - overlay_annotation_fields, - viz_annotation_key, -) -from egomimic.utils.viz_utils import _viz_annotations - - -def _viz(*, annotation_key=None): - def _fn(*, predictions, batch): - del predictions, batch - return None - - return partial(_fn, annotation_key=annotation_key) - - -def test_viz_annotation_key_reads_partial_keywords(): - assert viz_annotation_key(_viz(annotation_key="annotations")) == "annotations" - assert viz_annotation_key(_viz(annotation_key=None)) is None - assert viz_annotation_key(_viz(annotation_key="null")) is None - - -def test_overlay_annotation_fields_copies_list_valued_prompts(): - batch = {"annotations": [["fold the shirt"], ["align the sleeves"]]} - copied = overlay_annotation_fields(_viz(annotation_key="annotations"), batch) - assert copied == {"annotations": [["fold the shirt"], ["align the sleeves"]]} - - -def test_overlay_annotation_fields_skips_when_key_missing_or_unset(): - batch = {"annotations": [["fold the shirt"]]} - assert overlay_annotation_fields(_viz(annotation_key=None), batch) == {} - assert overlay_annotation_fields(_viz(annotation_key="annotations"), {}) == {} - - -def test_viz_annotations_paints_text_on_the_frame(): - blank = np.zeros((120, 240, 3), dtype=np.uint8) - drawn = _viz_annotations(blank, ["fold the long sleeve shirt"]) - assert drawn.shape == blank.shape - assert int((drawn != blank).sum()) > 0 diff --git a/tests/test_gaussian_paraboloid_dataset.py b/tests/test_gaussian_paraboloid_dataset.py deleted file mode 100644 index 6c18929a0b..0000000000 --- a/tests/test_gaussian_paraboloid_dataset.py +++ /dev/null @@ -1,40 +0,0 @@ -import torch - -from egomimic.synthetic import ( - GaussianParaboloidDataset, - generate_gaussian_paraboloid, -) - - -def test_gaussian_paraboloid_is_deterministic_and_on_surface(): - first = generate_gaussian_paraboloid(128, seed=7, curvature=0.25) - second = generate_gaussian_paraboloid(128, seed=7, curvature=0.25) - torch.testing.assert_close(first.source_latent, second.source_latent) - torch.testing.assert_close(first.source_gaussian_3d, second.source_gaussian_3d) - torch.testing.assert_close(first.target_3d, second.target_3d) - torch.testing.assert_close(first.target_3d[:, :2], first.source_2d) - torch.testing.assert_close( - first.target_3d[:, 2], 0.25 * first.source_2d.square().sum(dim=-1) - ) - - -def test_latent_four_preserves_paired_problem_and_adds_nuisance_dimensions(): - wide = generate_gaussian_paraboloid(64, seed=9, source_dim=4) - reference = generate_gaussian_paraboloid(64, seed=9, source_dim=2) - assert wide.source_latent.shape == (64, 4) - torch.testing.assert_close(wide.source_latent[:, :2], reference.source_latent) - torch.testing.assert_close(wide.target_3d, reference.target_3d) - torch.testing.assert_close(wide.source_gaussian_3d, reference.source_gaussian_3d) - - -def test_dataset_exposes_exact_linear_bridge(): - dataset = GaussianParaboloidDataset(32, seed=11) - indices = torch.tensor([0, 3, 7]) - time = torch.tensor([0.0, 0.5, 1.0]) - state, velocity = dataset.cfm_state_velocity(indices, time) - source = dataset.data.source_3d[indices] - target = dataset.data.target_3d[indices] - torch.testing.assert_close(velocity, target - source) - torch.testing.assert_close(state[0], source[0]) - torch.testing.assert_close(state[1], 0.5 * (source[1] + target[1])) - torch.testing.assert_close(state[2], target[2]) diff --git a/tests/test_gaussian_torus_dataset.py b/tests/test_gaussian_torus_dataset.py deleted file mode 100644 index 6591cb47d7..0000000000 --- a/tests/test_gaussian_torus_dataset.py +++ /dev/null @@ -1,63 +0,0 @@ -import math -import subprocess -import sys -from pathlib import Path - -import torch - -from egomimic.synthetic import GaussianTorusDataset, generate_gaussian_torus - - -def test_generator_script_resolves_the_checkout_outside_repo_cwd(tmp_path): - source = Path(__file__).parents[1] - output = tmp_path / "generated.npz" - subprocess.run( - [ - sys.executable, - str(source / "scripts/data/generate_gaussian_torus.py"), - "--output", - str(output), - "--count", - "32", - "--source-dim", - "8", - ], - cwd=tmp_path, - check=True, - ) - assert output.is_file() - - -def test_gaussian_torus_is_deterministic_and_on_surface(): - first = generate_gaussian_torus(128, seed=7) - second = generate_gaussian_torus(128, seed=7) - assert torch.equal(first.source_2d, second.source_2d) - assert torch.equal(first.source_gaussian_3d, second.source_gaussian_3d) - assert torch.equal(first.target_3d, second.target_3d) - radial = first.target_3d[:, :2].norm(dim=-1) - residual = (radial - 2.0).square() + first.target_3d[:, 2].square() - assert torch.allclose(residual, torch.full_like(residual, 0.65**2), atol=2e-6) - assert torch.all((first.angles >= 0) & (first.angles <= 2 * math.pi)) - - -def test_linear_cfm_bridge_endpoints_and_velocity(): - dataset = GaussianTorusDataset(16, seed=11) - indices = torch.tensor([1, 4, 9]) - start, velocity = dataset.cfm_state_velocity(indices, torch.zeros(3)) - end, velocity_end = dataset.cfm_state_velocity(indices, torch.ones(3)) - assert torch.equal(start, dataset.data.source_3d[indices]) - assert torch.allclose(end, dataset.data.target_3d[indices]) - assert torch.equal(velocity, velocity_end) - assert torch.allclose(end - start, velocity) - - -def test_four_dimensional_source_keeps_same_torus_coupling(): - batch = generate_gaussian_torus(64, seed=9, source_dim=4) - assert batch.source_latent.shape == (64, 4) - assert torch.equal(batch.source_latent[:, :2], batch.source_2d) - reference = generate_gaussian_torus(64, seed=9, source_dim=2) - assert torch.equal(batch.source_2d, reference.source_2d) - assert torch.equal(batch.target_3d, reference.target_3d) - assert torch.equal(batch.source_gaussian_3d, reference.source_gaussian_3d) - assert batch.source_gaussian_latent.shape == (64, 4) - assert torch.equal(batch.source_gaussian_3d, batch.source_gaussian_latent[:, :3]) diff --git a/tests/test_gello_calibration.py b/tests/test_gello_calibration.py deleted file mode 100644 index 518c8f3287..0000000000 --- a/tests/test_gello_calibration.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Hardware-free checks for the leader-only GELLO calibration utility.""" - -from pathlib import Path - -import numpy as np -import pytest -import yaml - -from egomimic.robot import calibrate_gello - -PROFILE = Path("egomimic/hydra_configs/robot/yam_rl2_gello_collect.yaml") - - -def profile(): - return yaml.safe_load(PROFILE.read_text()) - - -def session(arm="left"): - return calibrate_gello.GelloCalibration(arm, profile()["gello"]["leaders"][arm]) - - -def test_calibration_captures_raw_offsets_endpoints_and_signs(): - calibration = session() - raw = np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) - calibration.update(raw) - calibration.capture_joint_offsets() - calibration.capture_gripper_open() - calibration.update(np.r_[raw[:6], -0.2]) - calibration.capture_gripper_closed() - initial_signs = np.asarray(profile()["gello"]["leaders"]["left"]["joint_signs"]) - calibration.toggle_joint_sign(1) - - snippet = yaml.safe_load(calibration.yaml_text()) - leader = snippet["gello"]["leaders"]["left"] - assert leader["joint_offsets_rad"] == pytest.approx(raw[:6]) - assert leader["gripper_open_rad"] == pytest.approx(0.7) - assert leader["gripper_closed_rad"] == pytest.approx(-0.2) - expected_signs = initial_signs.copy() - expected_signs[1] *= -1 - assert leader["joint_signs"] == expected_signs.tolist() - - -def test_calibration_refuses_incomplete_or_overwrite(tmp_path): - calibration = session() - calibration.update(np.arange(7, dtype=float)) - with pytest.raises(RuntimeError, match="Capture joint zero"): - calibration.yaml_text() - calibration.capture_joint_offsets() - calibration.capture_gripper_open() - calibration.update(np.r_[np.arange(6, dtype=float), 9.0]) - calibration.capture_gripper_closed() - path = calibration.write_yaml(tmp_path / "left.yaml") - assert path.exists() - with pytest.raises(FileExistsError, match="Refusing to overwrite"): - calibration.write_yaml(path) - - -def test_incomplete_export_reports_an_error_without_ending_session(): - calibration = session() - messages = [] - assert not calibrate_gello._handle_key("p", calibration, None, messages.append) - assert "Calibration not exported" in messages[-1] - - -def test_check_config_opens_no_leader_or_other_hardware(monkeypatch, capsys): - def forbidden(*args, **kwargs): - raise AssertionError("calibration config check touched a device") - - monkeypatch.setattr(calibrate_gello, "DynamixelPositionBus", forbidden) - monkeypatch.setattr(calibrate_gello, "validate_serial_ports", forbidden) - assert calibrate_gello.main(["--config", str(PROFILE), "--check-config"]) == 0 - assert "no devices were opened" in capsys.readouterr().out - - -def test_interactive_calibration_requires_tty_before_opening_leader(monkeypatch): - def forbidden(*args, **kwargs): - raise AssertionError("leader opened before interactive terminal validation") - - monkeypatch.setattr(calibrate_gello, "validate_serial_ports", lambda ports: ports) - monkeypatch.setattr(calibrate_gello, "DynamixelPositionBus", forbidden) - monkeypatch.setattr( - calibrate_gello, - "TerminalKeyView", - lambda: (_ for _ in ()).throw(RuntimeError("interactive TTY required")), - ) - with pytest.raises(RuntimeError, match="interactive TTY required"): - calibrate_gello.main(["--config", str(PROFILE)]) - - -def test_run_calibration_captures_and_writes_without_a_yam_robot(tmp_path): - class Bus: - def __init__(self): - self.samples = iter( - ( - np.array([0, 1, 2, 3, 4, 5, 6], dtype=float), - np.array([0, 1, 2, 3, 4, 5, 7], dtype=float), - np.array([0, 1, 2, 3, 4, 5, 8], dtype=float), - np.array([0, 1, 2, 3, 4, 5, 8], dtype=float), - ) - ) - - def read_radians(self): - return next(self.samples) - - class View: - def __init__(self): - self.keys = iter(("z", "o", "c", "w")) - - def update(self, obs, recording=False): - return next(self.keys) - - output = tmp_path / "left.yaml" - calibration = session() - messages = [] - assert ( - calibrate_gello.run_calibration( - Bus(), - calibration, - View(), - rate_hz=100, - output=output, - max_steps=4, - sleep_fn=lambda delay: None, - time_fn=lambda: 1.0, - emit=lambda *args, **kwargs: messages.append(args[0] if args else ""), - ) - == 4 - ) - assert output.exists() - assert "Wrote" in "\n".join(messages) diff --git a/tests/test_gello_teleop.py b/tests/test_gello_teleop.py deleted file mode 100644 index a481501e75..0000000000 --- a/tests/test_gello_teleop.py +++ /dev/null @@ -1,1078 +0,0 @@ -"""Hardware-free checks for the USB/Dynamixel GELLO teleop path.""" - -import copy -import time -from pathlib import Path -from threading import Event -from types import SimpleNamespace - -import h5py -import numpy as np -import pytest -import yaml - -from egomimic.robot import collect_gello -from egomimic.robot.interface import ARM_OFFSET -from egomimic.robot.teleop_dashboard import delete_demo, list_demos -from egomimic.robot.yam.gello import ( - BimanualGelloReader, - DynamixelGelloLeader, - DynamixelPositionBus, - GelloInputError, - GelloSample, - GelloTeleopControl, - list_serial_ports, - validate_can_interfaces, - validate_serial_ports, -) - -PROFILE = Path("egomimic/hydra_configs/robot/yam_rl2_gello_collect.yaml") - - -class FakeFollower: - def __init__(self, arms=("left", "right")): - self.arms = list(arms) - self.q = np.zeros(14) - self.q[[6, 13]] = 0.5 - self.commands = [] - self.camera_res = {} - self.homed = False - self.home = {arm: np.zeros(7) for arm in self.arms} - for arm in self.arms: - self.home[arm][6] = 0.5 - - def get_obs(self): - return {"joint_positions": self.q.copy(), "ee_poses": self.q.copy()} - - def set_joints(self, command, arm): - command = np.asarray(command).copy() - self.commands.append((arm, command)) - offset = ARM_OFFSET[arm] - self.q[offset : offset + 7] = command - - def forward_kinematics(self, joints, arm): - return np.asarray(joints).copy() - - def set_home(self): - self.homed = True - - -def leader_sample(*, left=None, right=None, timestamp=1.0): - joints = {} - if left is not None: - joints["left"] = np.asarray(left, dtype=float) - if right is not None: - joints["right"] = np.asarray(right, dtype=float) - return GelloSample(joints, {arm: timestamp for arm in joints}) - - -def control(robot, **kwargs): - return GelloTeleopControl( - robot, - frequency=10, - max_input_age=0.25, - max_joint_velocity=1.0, - max_alignment_velocity=0.5, - **kwargs, - ) - - -class FakePortHandler: - def __init__(self, port): - self.port = port - self.baudrate = None - self.closed = False - - def openPort(self): - return True - - def setBaudRate(self, baudrate): - self.baudrate = baudrate - return True - - def closePort(self): - self.closed = True - - -class FakePacketHandler: - def __init__(self, protocol): - self.protocol = protocol - self.torque_writes = [] - - def write1ByteTxRx(self, port, servo_id, address, value): - self.torque_writes.append((servo_id, address, value)) - return 0, 0 - - def getTxRxResult(self, result): - return f"error {result}" - - -class FakeGroupRead: - def __init__(self, port, packet, address, length, values): - self.address, self.length, self.values = address, length, values - self.ids = [] - - def addParam(self, servo_id): - self.ids.append(servo_id) - return True - - def txRxPacket(self): - return 0 - - def isAvailable(self, servo_id, address, length): - return servo_id in self.values and (address, length) == ( - self.address, - self.length, - ) - - def getData(self, servo_id, address, length): - return self.values[servo_id] - - -def fake_sdk(values): - sdk = SimpleNamespace(COMM_SUCCESS=0) - sdk.port = None - sdk.packet = None - sdk.group = None - - def port_handler(port): - sdk.port = FakePortHandler(port) - return sdk.port - - def packet_handler(protocol): - sdk.packet = FakePacketHandler(protocol) - return sdk.packet - - def group_read(port, packet, address, length): - sdk.group = FakeGroupRead(port, packet, address, length, values) - return sdk.group - - sdk.PortHandler = port_handler - sdk.PacketHandler = packet_handler - sdk.GroupSyncRead = group_read - return sdk - - -def test_dynamixel_bus_uses_present_position_132_and_disables_all_torque(): - sdk = fake_sdk({1: 0, 2: 2048, 7: 0xFFFFFFFF}) - bus = DynamixelPositionBus("/dev/fake", (1, 2, 7), sdk=sdk) - assert sdk.group.address == 132 and sdk.group.length == 4 - assert sdk.packet.torque_writes == [(1, 64, 0), (2, 64, 0), (7, 64, 0)] - np.testing.assert_allclose(bus.read_radians(), [0, np.pi, -2 * np.pi / 4096]) - bus.close() - bus.close() - assert sdk.port.closed - with pytest.raises(GelloInputError, match="closed"): - bus.read_radians() - - -def test_dynamixel_leader_maps_sign_offset_and_opening_without_commands(): - requested = [] - bus = SimpleNamespace( - read_radians=lambda: np.array([1, 2, 3, 4, 5, 6, 0.75]), - close=lambda: None, - ) - - def factory(**kwargs): - requested.append(kwargs) - return bus - - leader = DynamixelGelloLeader( - port="/dev/serial/by-id/test", - joint_ids=[1, 2, 3, 4, 5, 6], - gripper_id=7, - joint_signs=[-1, 1, 1, 1, 1, 1], - joint_offsets_rad=[0.5, 1, 1, 1, 1, 1], - gripper_open_rad=1.0, - gripper_closed_rad=0.0, - bus_factory=factory, - time_fn=lambda: 12.5, - ) - joints, timestamp = leader.read() - np.testing.assert_allclose(joints, [-0.5, 1, 2, 3, 4, 5, 0.75]) - assert timestamp == 12.5 - assert requested[0]["ids"] == (1, 2, 3, 4, 5, 6, 7) - leader.close() - with pytest.raises(GelloInputError, match="closed"): - leader.read() - - -def test_bimanual_reader_closes_first_leader_if_second_open_fails(): - opened = [] - - class Leader: - def __init__(self, **kwargs): - if kwargs["port"] == "right": - raise RuntimeError("right leader unavailable") - self.closed = False - opened.append(self) - - def close(self): - self.closed = True - - with pytest.raises(RuntimeError, match="right leader unavailable"): - BimanualGelloReader( - ("left", "right"), - {"left": {"port": "left"}, "right": {"port": "right"}}, - leader_factory=Leader, - ) - assert len(opened) == 1 and opened[0].closed - - -def test_bimanual_reader_reads_independent_serial_buses_concurrently(): - class Leader: - def __init__(self, port, **kwargs): - self.value = 1 if port == "left" else 2 - - def read(self): - return np.r_[np.full(6, self.value), 0.5], 12.5 - - def close(self): - pass - - reader = BimanualGelloReader( - ("left", "right"), - {"left": {"port": "left"}, "right": {"port": "right"}}, - leader_factory=Leader, - ) - deadline = time.monotonic() + 1.0 - while True: - try: - sample = reader.read() - break - except GelloInputError as error: - assert "Waiting for first GELLO sample" in str(error) - if time.monotonic() >= deadline: - raise - time.sleep(0.001) - assert sample.timestamps == {"left": 12.5, "right": 12.5} - np.testing.assert_allclose(sample.joints["right"][:6], 2) - reader.close() - - -def test_bimanual_reader_returns_cached_samples_while_serial_reads_continue(): - release = Event() - reading_again = {"left": Event(), "right": Event()} - - class Leader: - def __init__(self, port, **kwargs): - self.arm = port - self.calls = 0 - - def read(self): - self.calls += 1 - if self.calls > 1: - reading_again[self.arm].set() - release.wait(1.0) - value = 1 if self.arm == "left" else 2 - return np.r_[np.full(6, value), 0.5], 12.5 - - def close(self): - pass - - reader = BimanualGelloReader( - ("left", "right"), - {"left": {"port": "left"}, "right": {"port": "right"}}, - leader_factory=Leader, - ) - try: - assert all(event.wait(1.0) for event in reading_again.values()) - started = time.monotonic() - sample = reader.read() - assert time.monotonic() - started < 0.02 - np.testing.assert_allclose(sample.joints["left"][:6], 1) - np.testing.assert_allclose(sample.joints["right"][:6], 2) - finally: - release.set() - reader.close() - - -def test_serial_listing_and_preflight_open_no_devices(tmp_path): - device_a, device_b = tmp_path / "ttyA", tmp_path / "ttyB" - device_a.touch() - device_b.touch() - by_id = tmp_path / "by-id" - by_id.mkdir() - left, right = by_id / "left", by_id / "right" - left.symlink_to(device_a) - right.symlink_to(device_b) - assert list_serial_ports(by_id) == (str(left), str(right)) - assert validate_serial_ports((str(left), str(right))) == (str(left), str(right)) - with pytest.raises(RuntimeError, match="unavailable"): - validate_serial_ports((str(left), str(by_id / "missing"))) - alias = by_id / "alias" - alias.symlink_to(device_a) - with pytest.raises(ValueError, match="same device"): - validate_serial_ports((str(left), str(alias))) - - -def test_can_preflight_is_read_only_and_checks_both_followers(): - calls = [] - - def runner(command, **kwargs): - calls.append((command, kwargs)) - name = command[-1] - return SimpleNamespace( - returncode=0, - stdout=f"7: {name}: mtu 16\n link/can", - stderr="", - ) - - channels = ("can_follower_l", "can_follower_r") - assert validate_can_interfaces(channels, runner=runner) == channels - assert all( - call[0][:5] == ["ip", "-details", "link", "show", "dev"] for call in calls - ) - assert all(call[1]["check"] is False for call in calls) - - def down(command, **kwargs): - return SimpleNamespace( - returncode=0, - stdout=f"7: {command[-1]}: mtu 16\n link/can", - stderr="", - ) - - with pytest.raises(RuntimeError, match=r"can_follower_l \(down\)"): - validate_can_interfaces(("can_follower_l",), runner=down) - - -def test_relative_activation_offsets_arm_joints_but_maps_gripper_directly(): - robot = FakeFollower(("right",)) - robot.q[7:14] = [0.4, -0.3, 0.2, 0.1, 0, -0.2, 0.5] - gello = control(robot) - leader = np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.2]) - - result = gello.step( - leader_sample(right=leader), - robot.get_obs(), - toggle_arms=("right",), - now=1.0, - ) - assert result.active_arms == {"right"} - np.testing.assert_allclose( - robot.commands[-1][1], [0.4, -0.3, 0.2, 0.1, 0, -0.2, 0.2] - ) - - moved = leader.copy() - moved[0] += 0.5 - moved[6] = 0.0 - result = gello.step(leader_sample(right=moved), robot.get_obs(), now=1.0) - assert result.joints[7] == pytest.approx(0.5) - assert result.joints[13] == pytest.approx(0.0) - - -def test_arms_toggle_independently_and_start_disarmed(): - robot = FakeFollower() - gello = control(robot) - joints = np.r_[np.zeros(6), 0.5] - result = gello.step( - leader_sample(left=joints, right=joints), robot.get_obs(), now=1.0 - ) - assert not result.active_arms and not robot.commands - result = gello.step( - leader_sample(left=joints, right=joints), - robot.get_obs(), - toggle_arms=("left",), - now=1.0, - ) - assert result.active_arms == {"left"} - assert [arm for arm, _ in robot.commands] == ["left"] - before = len(robot.commands) - result = gello.step( - leader_sample(left=joints, right=joints), - robot.get_obs(), - toggle_arms=("left",), - now=1.0, - ) - assert not result.active_arms and len(robot.commands) == before - - -def test_bimanual_arming_requires_a_fresh_simultaneous_trigger_squeeze(): - robot = FakeFollower() - gello = control(robot, activation_gripper_threshold=0.9) - released = np.r_[np.zeros(6), 1.0] - pressed = np.r_[np.zeros(6), 0.8] - - result = gello.step( - leader_sample(left=released, right=released), - robot.get_obs(), - toggle_bimanual_arming=True, - now=1.0, - ) - assert result.phase == "armed: press both GELLO triggers" - assert not result.active_arms and not robot.commands - - result = gello.step( - leader_sample(left=pressed, right=released), robot.get_obs(), now=1.0 - ) - assert result.phase == "armed: press both GELLO triggers" - assert not result.active_arms and not robot.commands - - result = gello.step( - leader_sample(left=pressed, right=pressed), robot.get_obs(), now=1.0 - ) - assert result.phase == "active" - assert result.active_arms == {"left", "right"} - assert [arm for arm, _ in robot.commands] == ["left", "right"] - - -def test_stale_input_disarms_and_requires_explicit_reactivation(): - robot = FakeFollower(("right",)) - gello = control(robot) - joints = np.r_[np.zeros(6), 0.5] - gello.step( - leader_sample(right=joints), - robot.get_obs(), - toggle_arms=("right",), - now=1.0, - ) - commands = len(robot.commands) - with pytest.raises(GelloInputError, match="right sample age"): - gello.step( - leader_sample(right=joints, timestamp=1.0), - robot.get_obs(), - now=2.0, - ) - assert not gello.active_arms and len(robot.commands) == commands - result = gello.step( - leader_sample(right=joints, timestamp=2.0), robot.get_obs(), now=2.0 - ) - assert not result.active_arms - result = gello.step( - leader_sample(right=joints, timestamp=2.0), - robot.get_obs(), - toggle_arms=("right",), - now=2.0, - ) - assert result.active_arms == {"right"} - - -def test_absolute_alignment_is_slow(): - robot = FakeFollower(("right",)) - gello = control(robot, alignment="absolute") - joints = np.r_[np.ones(6), 0.5] - result = gello.step( - leader_sample(right=joints), - robot.get_obs(), - toggle_arms=("right",), - now=1.0, - ) - np.testing.assert_allclose(result.joints[7:13], np.full(6, 0.05)) - - -def test_absolute_alignment_slews_by_gello_delta_from_home(): - robot = FakeFollower(("right",)) - robot.home = {"right": np.array([0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0])} - gello = control(robot, alignment="absolute") - leader = np.array([0.4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5]) - result = gello.step( - leader_sample(right=leader), - robot.get_obs(), - toggle_arms=("right",), - now=1.0, - ) - # Target is home + GELLO delta from calibrated zero (0.6); 0.5 rad/s at 10 Hz. - np.testing.assert_allclose(result.joints[7], 0.05) - np.testing.assert_allclose(result.joints[8:13], 0.0) - - -def test_absolute_arming_waits_for_the_trigger_gate_then_slews_from_home(): - robot = FakeFollower() - robot.home = { - arm: np.array([0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) for arm in robot.arms - } - gello = control(robot, alignment="absolute") - released = np.r_[np.zeros(6), 1.0] - pressed = np.r_[np.zeros(6), 0.8] - result = gello.step( - leader_sample(left=released, right=released), - robot.get_obs(), - toggle_bimanual_arming=True, - now=1.0, - ) - assert result.phase == "armed: press both GELLO triggers" - assert not result.active_arms and not robot.commands - - result = gello.step( - leader_sample(left=pressed, right=pressed), robot.get_obs(), now=1.0 - ) - assert result.phase == "active" - assert result.active_arms == {"left", "right"} - np.testing.assert_allclose(result.joints[0], 0.05) - np.testing.assert_allclose(result.joints[7], 0.05) - - -def test_fk_failure_prevents_either_active_arm_command(): - robot = FakeFollower() - - def fail_on_right(joints, arm): - if arm == "right": - raise ValueError("bad FK") - return np.asarray(joints) - - robot.forward_kinematics = fail_on_right - gello = control(robot) - joints = np.r_[np.zeros(6), 0.5] - with pytest.raises(ValueError, match="bad FK"): - gello.step( - leader_sample(left=joints, right=joints), - robot.get_obs(), - toggle_arms=("left", "right"), - now=1.0, - ) - assert not robot.commands - assert not gello.active_arms - - -def test_follower_write_failure_disarms_control(): - robot = FakeFollower(("right",)) - robot.set_joints = lambda command, arm: (_ for _ in ()).throw( - RuntimeError("follower write failed") - ) - gello = control(robot) - joints = np.r_[np.zeros(6), 0.5] - with pytest.raises(RuntimeError, match="follower write failed"): - gello.step( - leader_sample(right=joints), - robot.get_obs(), - toggle_arms=("right",), - now=1.0, - ) - assert not gello.active_arms - - -def test_rl2_profile_is_self_contained_and_requires_physical_calibration(): - profile = yaml.safe_load(PROFILE.read_text()) - assert profile["robot"]["channels"] == { - "left": "can_follower_l", - "right": "can_follower_r", - } - assert profile["gello"]["driver"] == "ROBOTIS Dynamixel SDK" - assert profile["gello"]["sdk_version"] == "4.0.5" - assert profile["gello"]["bus"]["present_position_address"] == 132 - assert profile["gello"]["alignment"] == "absolute" - assert isinstance(profile["gello"]["calibrated"], bool) - assert profile["robot"]["gripper_kp"] == 20.0 - assert profile["robot"]["gripper_force_limit"] == 50.0 - assert profile["gello"]["input_filter_alpha"] == 0.9 - assert profile["frequency"] == 60 - assert profile["recording"]["rate_hz"] == 30 - assert profile["recording"]["episode_length"] is None - assert profile["keys"]["record"] == "b" - assert "teleop_kinematics" not in profile["robot"] - uncalibrated = copy.deepcopy(profile) - uncalibrated["gello"]["calibrated"] = False - collect_gello.validate_gello_config(uncalibrated) - with pytest.raises(RuntimeError, match="calibration is incomplete"): - collect_gello.validate_gello_config(uncalibrated, require_calibrated=True) - - -def test_check_config_opens_no_serial_can_camera_or_robot( - monkeypatch, capsys, tmp_path -): - def forbidden(*args, **kwargs): - raise AssertionError("configuration check touched a device") - - monkeypatch.setattr(collect_gello, "validate_serial_ports", forbidden) - monkeypatch.setattr(collect_gello, "validate_can_interfaces", forbidden) - monkeypatch.setattr(collect_gello, "validate_camera_devices", forbidden) - monkeypatch.setattr(collect_gello, "BimanualGelloReader", forbidden) - monkeypatch.setattr(collect_gello, "create_robot", forbidden) - profile = yaml.safe_load(PROFILE.read_text()) - profile["gello"]["calibrated"] = False - config = tmp_path / "uncalibrated.yaml" - config.write_text(yaml.safe_dump(profile)) - assert collect_gello.main(["--config", str(config), "--check-config"]) == 0 - output = capsys.readouterr().out - assert "no devices were opened" in output and "calibration: required" in output - - -def test_terminal_key_view_reads_without_camera_window_and_restores_tty(): - class Stream: - def isatty(self): - return True - - def fileno(self): - return 9 - - def read(self, length): - assert length == 1 - return "g" - - calls = [] - terminal = SimpleNamespace( - TCSADRAIN=1, - tcgetattr=lambda fd: calls.append(("get", fd)) or ["saved"], - tcsetattr=lambda fd, when, settings: calls.append( - ("restore", fd, when, settings) - ), - ) - tty = SimpleNamespace(setcbreak=lambda fd: calls.append(("cbreak", fd))) - stream = Stream() - view = collect_gello.TerminalKeyView( - stream=stream, - termios_module=terminal, - tty_module=tty, - select_fn=lambda read, write, error, timeout: (read, write, error), - ) - assert view.update({}, recording=False) == "g" - view.close() - view.close() - assert calls == [ - ("get", 9), - ("cbreak", 9), - ("restore", 9, 1, ["saved"]), - ] - - -def test_normal_launch_rejects_incomplete_calibration_before_preflight( - monkeypatch, tmp_path -): - def forbidden(*args, **kwargs): - raise AssertionError("incomplete config reached a device preflight") - - monkeypatch.setattr(collect_gello, "validate_serial_ports", forbidden) - monkeypatch.setattr(collect_gello, "validate_can_interfaces", forbidden) - monkeypatch.setattr(collect_gello, "validate_camera_devices", forbidden) - profile = yaml.safe_load(PROFILE.read_text()) - profile["gello"]["calibrated"] = False - config = tmp_path / "uncalibrated.yaml" - config.write_text(yaml.safe_dump(profile)) - with pytest.raises(RuntimeError, match="calibration is incomplete"): - collect_gello.main(["--config", str(config)]) - - -class SequenceView: - def __init__(self, keys): - self.keys = iter(keys) - self.closed = False - self.recording = [] - self.statuses = [] - self.episodes = [] - self.episode_limits = [] - - def update(self, obs, recording=False): - self.recording.append(recording) - return next(self.keys) - - def set_status(self, status): - self.statuses.append(status) - - def set_episode(self, episode_id, state="next"): - self.episodes.append((episode_id, state)) - - def set_episode_length(self, episode_length, recorded_frames, rate_hz): - self.episode_limits.append((episode_length, recorded_frames, rate_hz)) - - def close(self): - self.closed = True - - -class CameraRecoveryView(SequenceView): - """Request RGB recovery once, then use the normal GELLO arm gate.""" - - def __init__(self): - super().__init__([None, "g", None, "q"]) - self.requests = iter((True, False, False, False)) - - def take_camera_reconnect_request(self): - return next(self.requests) - - -class SafeReader: - def read(self): - joints = np.r_[np.zeros(6), 0.5] - return leader_sample(left=joints, right=joints, timestamp=time.monotonic()) - - -class TriggerReader: - """Open both grippers, then squeeze, matching the arming gate.""" - - def __init__(self, joints=None, squeeze_after=2): - self.reads = 0 - self.squeeze_after = int(squeeze_after) - self.joints = np.zeros(6) if joints is None else np.asarray(joints, dtype=float) - - def read(self): - opening = 1.0 if self.reads < self.squeeze_after else 0.8 - self.reads += 1 - pose = np.r_[self.joints, opening] - return leader_sample(left=pose, right=pose, timestamp=time.monotonic()) - - -def test_record_homes_then_starts_after_the_trigger_gate(tmp_path, monkeypatch): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_length=10) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - robot = FakeFollower() - robot.home = { - arm: np.array([0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) for arm in robot.arms - } - view = SequenceView(["b", None, None, None, None, "q"]) - steps = collect_gello.run_collection( - robot, - TriggerReader(joints=[0.4, 0, 0, 0, 0, 0]), - profile, - view=view, - max_steps=6, - ) - assert steps == 5 and view.closed and robot.homed - assert view.recording[:3] == [False, False, False] - assert view.recording[3:] == [True, True, True] - assert any(status.startswith("armed") for status in view.statuses) - assert any(status == "active" for status in view.statuses) - assert (0, "queued") in view.episodes - assert (0, "recording") in view.episodes - paths = list(tmp_path.glob("*.hdf5")) - assert len(paths) == 1 - with h5py.File(paths[0]) as episode: - assert episode["action"].shape == (2, 14) - assert not episode.attrs["complete"] - # First recorded command has started the slow move toward home + GELLO delta. - assert episode["action"][0, 0] > 0 - assert episode["action"][0, 7] > 0 - - -def test_g_arms_without_starting_an_episode(tmp_path, monkeypatch): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_length=10) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - robot = FakeFollower() - view = SequenceView(["g", None, None, "q"]) - collect_gello.run_collection( - robot, TriggerReader(squeeze_after=1), profile, view=view, max_steps=4 - ) - assert not list(tmp_path.glob("*.hdf5")) - assert any(status.startswith("armed") for status in view.statuses) - assert any(status == "active" for status in view.statuses) - assert [arm for arm, _ in robot.commands][:2] == ["left", "right"] - - -def test_camera_reconnect_disarms_and_requires_a_fresh_gello_gate( - tmp_path, monkeypatch -): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_length=10) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - robot = FakeFollower() - robot.camera_reconnects = 0 - - def reconnect_cameras(): - robot.camera_reconnects += 1 - - robot.reconnect_cameras = reconnect_cameras - view = CameraRecoveryView() - steps = collect_gello.run_collection( - robot, - TriggerReader(squeeze_after=2), - profile, - view=view, - max_steps=4, - ) - - assert steps == 3 and view.closed - assert robot.camera_reconnects == 1 - # The recovery tick cannot command a follower. Motion begins only after g - # has armed the system and the following squeeze passes the trigger gate. - assert [arm for arm, _ in robot.commands] == ["left", "right"] - assert any("Reconnecting RGB cameras" in status for status in view.statuses) - assert any("reconnected — press g" in status for status in view.statuses) - - -def test_completed_recording_advances_dashboard_episode_id(tmp_path, monkeypatch): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_length=1) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - view = SequenceView(["b", None, None, "q"]) - collect_gello.run_collection( - FakeFollower(), TriggerReader(), profile, view=view, max_steps=4 - ) - assert view.episodes[-1] == (1, "next") - with h5py.File(tmp_path / "demo_0.hdf5") as episode: - assert episode.attrs["complete"] - - -@pytest.mark.parametrize("limit", [None, 1, 3000]) -def test_episode_limit_config_accepts_unlimited_or_positive_frames(limit): - profile = yaml.safe_load(PROFILE.read_text()) - profile["recording"]["episode_length"] = limit - collect_gello.validate_gello_config(profile) - - -@pytest.mark.parametrize("limit", [0, -1, True, 1.5, "3000"]) -def test_episode_limit_config_rejects_invalid_frame_counts(limit): - profile = yaml.safe_load(PROFILE.read_text()) - profile["recording"]["episode_length"] = limit - with pytest.raises(ValueError, match="positive integer or null"): - collect_gello.validate_gello_config(profile) - - -def test_unlimited_recording_passes_3000_frames_and_saves_manually( - tmp_path, monkeypatch -): - writers = [] - - class CountingWriter: - def __init__(self, path, cameras): - self.path = path - self.frames = 0 - self.complete = None - writers.append(self) - - def append(self, obs, joints, ee_poses): - self.frames += 1 - - def close(self, complete=True): - self.complete = complete - - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update( - directory=str(tmp_path), episode_length=None, rate_hz=profile["frequency"] - ) - monkeypatch.setattr(time, "sleep", lambda delay: None) - monkeypatch.setattr(collect_gello, "EpisodeWriter", CountingWriter) - view = SequenceView(["b", None, None, *([None] * 3001), "b", "q"]) - collect_gello.run_collection(FakeFollower(), TriggerReader(), profile, view=view) - assert len(writers) == 1 - assert writers[0].frames == 3002 - assert writers[0].complete is True - assert view.episodes[-1] == (1, "next") - assert (None, 3002, profile["frequency"]) in view.episode_limits - - -@pytest.mark.parametrize( - "initial,events,expected_frames", - [ - (None, ["b", None, None, None, {"episode_length": 2}, "q"], 2), - (2, ["b", None, None, {"episode_length": None}, None, None, None, "b", "q"], 3), - (10, ["b", None, None, None, None, {"episode_length": 1}, "q"], 2), - (None, ["b", None, None, {"episode_length": 0}, None, "b", "q"], 2), - ], -) -def test_episode_limit_changes_apply_during_recording( - initial, events, expected_frames, tmp_path, monkeypatch -): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_length=initial) - monkeypatch.setattr(time, "sleep", lambda delay: None) - view = SequenceView(events) - collect_gello.run_collection(FakeFollower(), TriggerReader(), profile, view=view) - assert view.episodes[-1] == (1, "next") - with h5py.File(tmp_path / "demo_0.hdf5") as episode: - assert episode.attrs["complete"] - assert len(episode["action"]) == expected_frames - - -def test_dashboard_episode_limit_websocket_roundtrip(tmp_path): - import asyncio - import socket - - from aiohttp import ClientSession - - with socket.socket() as probe: - probe.bind(("127.0.0.1", 0)) - port = probe.getsockname()[1] - profile = yaml.safe_load(PROFILE.read_text()) - dashboard = collect_gello.TeleopDashboard( - ("front_img_1",), - profile["keys"], - tmp_path, - port=port, - open_browser=False, - ) - dashboard.set_episode_length(None, 3001, 30) - - async def exercise(): - async with ClientSession() as session: - async with session.get(dashboard.url) as response: - assert 'id="episode-limit-enabled"' in await response.text() - async with session.ws_connect(dashboard.url + "/ws") as websocket: - config = await websocket.receive_json(timeout=2) - assert config["type"] == "config" and config["episode_length"] is None - frame = await websocket.receive_json(timeout=2) - assert frame["recorded_frames"] == 3001 - assert frame["recording_rate_hz"] == 30 - for limit in (4000, None): - await websocket.send_json({"episode_length": limit}) - for _ in range(100): - event = dashboard.update({}) - if event is not None: - break - await asyncio.sleep(0.01) - assert event == {"episode_length": limit} - dashboard.set_episode_length(limit, 3001, 30) - for _ in range(30): - frame = await websocket.receive_json(timeout=2) - if frame["episode_length"] == limit: - break - assert frame["episode_length"] == limit - await websocket.send_json({"episode_length": 0}) - await asyncio.sleep(0.05) - assert dashboard.update({}) is None - - try: - asyncio.run(exercise()) - finally: - dashboard.close() - - -def test_stop_discards_active_take_without_advancing_episode_id(tmp_path, monkeypatch): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_length=10) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - view = SequenceView(["b", None, None, "x", "q"]) - collect_gello.run_collection( - FakeFollower(), TriggerReader(), profile, view=view, max_steps=5 - ) - assert view.episodes[-1] == (0, "next") - assert not list(tmp_path.glob("*.hdf5")) - - -def test_episode_override_rejects_an_existing_demo(tmp_path, monkeypatch): - with h5py.File(tmp_path / "demo_7.hdf5", "w") as episode: - episode.attrs["complete"] = True - episode.create_dataset("action", shape=(1, 14), dtype="f4") - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_start=0) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - view = SequenceView([{"episode": 7}, {"episode": 4}, "q"]) - collect_gello.run_collection( - FakeFollower(), SafeReader(), profile, view=view, max_steps=3 - ) - assert view.episodes[-1] == (4, "next") - - -def test_episode_override_is_editable_while_recording_is_queued(tmp_path, monkeypatch): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_start=0) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - view = SequenceView(["b", {"episode": 3}, "q"]) - collect_gello.run_collection( - FakeFollower(), SafeReader(), profile, view=view, max_steps=3 - ) - assert (3, "queued") in view.episodes - assert not list(tmp_path.glob("*.hdf5")) - - -def test_demo_sidebar_helpers_sort_and_delete_only_completed_files(tmp_path): - for demo_id, complete in ((10, True), (2, True), (1, False)): - with h5py.File(tmp_path / f"demo_{demo_id}.hdf5", "w") as episode: - episode.attrs["complete"] = complete - episode.create_dataset("action", shape=(demo_id, 14), dtype="f4") - - assert list_demos(tmp_path) == [ - {"id": 2, "frames": 2}, - {"id": 10, "frames": 10}, - ] - assert delete_demo(tmp_path, 2) == {"deleted": 2} - assert not (tmp_path / "demo_2.hdf5").exists() - with pytest.raises(ValueError, match="completed"): - delete_demo(tmp_path, 1) - with pytest.raises(FileNotFoundError, match="does not exist"): - delete_demo(tmp_path, 99) - - -def test_dashboard_places_episode_and_demo_controls_below_cameras(): - static = Path("egomimic/robot/teleop_dashboard_static") - html = (static / "index.html").read_text() - css = (static / "style.css").read_text() - javascript = (static / "app.js").read_text() - assert html.index('id="episode-number"') < html.index('id="demos"') - assert ( - html.index('id="status"') - < html.index('class="command-row"') - < html.index('id="cameras"') - < html.index('class="bottom-bar"') - < html.index('id="open-demos"') - < html.index('id="episode-number"') - ) - assert 'class="demo-sidebar"' in html - assert 'class="demo-button"' in html - assert 'id="previous-frame"' in html - assert 'id="next-frame"' in html - assert 'id="playback-time"' in html - assert ".bottom-bar { display: grid" in css - assert ".demo-button { padding: 11px 18px" in css - assert ".review-cameras { display: grid" in css - assert "pendingEpisodeFrames" in javascript - assert "message.episode_state === 'recording'" in javascript - assert "editable until recording starts" in javascript - assert "openDemo(demos[0])" in javascript - assert ".command-row { display: flex" in css - assert "overflow-y: auto" in css - assert "method: 'DELETE'" in javascript - assert 'id="episode-form"' in html - assert 'type="submit"' in html - assert "episodeForm.onsubmit = submitEpisode" in javascript - assert "event.preventDefault()" in javascript - assert "was not accepted; choose an unused ID" in javascript - assert 'id="reconnect-cameras"' in html - assert "function reconnectCameras()" in javascript - assert "reconnect_cameras: true" in javascript - assert 'id="episode-limit-enabled"' in html - assert 'id="episode-length"' in html - assert 'id="episode-length-status"' in html - assert "function submitEpisodeLength(event)" in javascript - assert "episodeLengthDirty" in javascript - assert "JSON.stringify({episode_length: limit})" in javascript - assert ".episode-limit-control { grid-column: 1" in css - assert 'id="episode-limit-enabled" type="checkbox" disabled' in html - assert 'id="set-episode-length" type="submit" disabled' in html - assert "if (!Object.hasOwn(message, 'episode_length')) return;" in javascript - assert "if ($('episode-limit-enabled').disabled) return;" in javascript - - -def test_pending_recording_creates_no_file_until_triggers_activate( - tmp_path, monkeypatch -): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["recording"].update(directory=str(tmp_path), episode_length=10) - profile["preview"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - robot = FakeFollower() - view = SequenceView(["b", None, "q"]) - assert ( - collect_gello.run_collection( - robot, SafeReader(), profile, view=view, max_steps=3 - ) - == 2 - ) - assert not list(tmp_path.glob("*.hdf5")) - assert any( - status == "armed: release both GELLO triggers" for status in view.statuses - ) - assert view.recording == [False, False, False] - - -def test_stop_key_disarms_followers_without_an_extra_command(monkeypatch): - profile = yaml.safe_load(PROFILE.read_text()) - profile["robot"]["cameras"] = {} - profile["preview"]["enabled"] = False - profile["recording"]["enabled"] = False - monkeypatch.setattr(time, "sleep", lambda delay: None) - robot = FakeFollower() - view = SequenceView(["g", None, "x"]) - assert ( - collect_gello.run_collection( - robot, TriggerReader(squeeze_after=1), profile, view=view, max_steps=3 - ) - == 3 - ) - assert [arm for arm, _ in robot.commands] == ["left", "right"] diff --git a/tests/test_hpt_configs.py b/tests/test_hpt_configs.py deleted file mode 100644 index ac5cd20916..0000000000 --- a/tests/test_hpt_configs.py +++ /dev/null @@ -1,173 +0,0 @@ -"""The configured HPT graph: composition, contracts and a real forward pass.""" - -import importlib.util -from pathlib import Path - -import hydra -import pytest -import torch -from hydra import compose, initialize_config_dir - -_REPO = Path(__file__).resolve().parents[1] -_CONFIGS = _REPO / "egomimic/hydra_configs" -_EXPERIMENT = "abc/yam_fstshirt_hpt" - -_SPEC = importlib.util.spec_from_file_location( - "config_graph_hpt", _REPO / "tools/config_graph.py" -) -config_graph = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(config_graph) - -_EXPERIMENT_YAML = _CONFIGS / "experiment" / "abc" / "yam_fstshirt_hpt.yaml" - - -def _cfg(): - with initialize_config_dir(version_base=None, config_dir=str(_CONFIGS)): - return compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment={_EXPERIMENT}", "++paths.root_dir=."], - ) - - -@pytest.fixture(scope="module") -def cfg(): - return _cfg() - - -@pytest.fixture(scope="module") -def algo(cfg): - return hydra.utils.instantiate(cfg.model.pipeline) - - -def test_the_stage_list_is_the_decomposed_hpt_dataflow(algo): - assert [type(s).__name__ for s in algo.pipeline.stages] == [ - "HPTStemStage", - "HPTTrunkStage", - "ActionTargetBuilder", - "FlowNoisingStage", - "FlowDenoiserStage", - "FlowVelocityLossStage", - ] - - -def test_embed_dim_is_one_shared_bus_width(cfg): - """Every stem, the trunk and the head must read the same width. - - A mismatch is the failure this config is most likely to grow, and the - stem stage only catches it at the first batch. - """ - width = cfg.hpt.embed_dim - stems = cfg.model.pipeline.stages[0].stems - for name, stem in stems.items(): - assert stem.output_dim == width, name - assert stem.specs.cross_attn.modality_embed_dim == width, name - trunk_stage = cfg.model.pipeline.stages[1] - assert trunk_stage.embed_dim == width - assert trunk_stage.trunk.embed_dim == width - assert trunk_stage.trunk.attn_target.embed_dim == width - denoiser = cfg.model.pipeline.stages[4] - assert denoiser.condition_input_dim == width - assert denoiser.model.cond_dim == width - - -def test_action_shape_agrees_across_the_head(cfg): - horizon, dim = cfg.hpt.action_horizon, cfg.hpt.action_dim - noising, denoiser = cfg.model.pipeline.stages[3], cfg.model.pipeline.stages[4] - assert (noising.action_horizon, noising.action_dim) == (horizon, dim) - assert (denoiser.action_horizon, denoiser.action_dim) == (horizon, dim) - assert denoiser.model.act_seq == horizon - assert denoiser.model.act_dim == dim - - -def test_the_head_is_told_the_condition_needs_a_token_axis(cfg): - # CrossTransformer cross-attends over the condition; the trunk emits a - # pooled vector, so this flag is what reconciles them. - assert cfg.model.pipeline.stages[4].condition_as_tokens is True - - -def test_action_key_matches_what_the_yam_transform_list_emits(cfg): - assert cfg.model.pipeline.stages[2].action_key == "actions_cartesian" - - -@pytest.mark.parametrize("mode", ["train", "inference"]) -def test_the_configured_graph_lints_clean(mode): - assert config_graph.build_graph(_EXPERIMENT_YAML, mode=mode)["lint"] == [] - - -def test_inference_drops_the_training_only_stages(): - graph = config_graph.build_graph(_EXPERIMENT_YAML, mode="inference") - assert {e["t"] for e in graph["skipped_stages"]} == { - "ActionTargetBuilder", - "FlowNoisingStage", - "FlowVelocityLossStage", - } - assert "pred_action" in {k for n in graph["nodes"] for k in n["out"]} - - -def test_the_encoder_survives_into_inference(): - """Stems and trunk must NOT be mode-restricted: sampling needs them.""" - graph = config_graph.build_graph(_EXPERIMENT_YAML, mode="inference") - names = [n["t"] for n in graph["nodes"]] - assert names[:2] == ["HPTStemStage", "HPTTrunkStage"] - - -def _batch(cfg, size: int = 2) -> dict: - return { - "observations.images.front_img_1": torch.rand(size, 3, 480, 640), - "observations.images.left_wrist_img": torch.rand(size, 3, 480, 640), - "observations.images.right_wrist_img": torch.rand(size, 3, 480, 640), - "observations.state.ee_pose": torch.rand(size, cfg.hpt.action_dim), - "actions_cartesian": torch.rand( - size, cfg.hpt.action_horizon, cfg.hpt.action_dim - ), - "embodiment": 7, - } - - -def test_a_real_batch_trains_and_reaches_every_parameter(cfg, algo): - algo.device = torch.device("cpu") - algo.nets.to("cpu") - batch = {"yam_bimanual": _batch(cfg)} - out = algo.forward_training(batch) - loss = algo.compute_losses(out, batch)["loss"] - assert torch.isfinite(loss) - loss.backward() - trainable = [p for p in algo.nets.parameters() if p.requires_grad] - assert trainable and all( - p.grad is not None for p in trainable - ), "some HPT parameters got no gradient, so a stage is detached" - - -def test_the_token_count_is_modalities_times_latents_plus_the_action_token(cfg, algo): - algo.device = torch.device("cpu") - algo.nets.to("cpu") - out = algo.forward_training({"yam_bimanual": _batch(cfg)})["yam_bimanual"] - latents = cfg.hpt.stem_specs.cross_attn.crossattn_latent - modalities = len(cfg.model.pipeline.stages[0].stems) - assert out["hpt/tokens"].shape == (2, modalities * latents, cfg.hpt.embed_dim) - assert out["condition"].shape == (2, cfg.hpt.embed_dim) - - -def test_inference_samples_an_action_chunk(cfg, algo): - algo.device = torch.device("cpu") - algo.nets.to("cpu") - observations = {k: v for k, v in _batch(cfg).items() if k != "actions_cartesian"} - out = algo.forward_eval({"yam_bimanual": observations})["yam_bimanual"] - assert out["pred_action"].shape == (2, cfg.hpt.action_horizon, cfg.hpt.action_dim) - assert torch.isfinite(out["pred_action"]).all() - - -def test_the_dp_counterpart_still_uses_diffusion(cfg): - """The two abc experiments must stay distinct architectures.""" - with initialize_config_dir(version_base=None, config_dir=str(_CONFIGS)): - dp = compose( - config_name="train_zarr_cartesian", - overrides=["+experiment=abc/yam_fstshirt_dp", "++paths.root_dir=."], - ) - dp_stages = [s._target_.rsplit(".", 1)[-1] for s in dp.model.pipeline.stages] - hpt_stages = [s._target_.rsplit(".", 1)[-1] for s in cfg.model.pipeline.stages] - assert "DiffusionDenoiserStage" in dp_stages - assert "FlowDenoiserStage" in hpt_stages - assert "HPTStemStage" not in dp_stages - # Same data, so a comparison between them isolates the architecture. - assert dp.data._target_ == cfg.data._target_ diff --git a/tests/test_hpt_cores.py b/tests/test_hpt_cores.py deleted file mode 100644 index d0faf48fbd..0000000000 --- a/tests/test_hpt_cores.py +++ /dev/null @@ -1,176 +0,0 @@ -"""HPT trunk primitives and the tensor helpers this fork had to re-add.""" - -from functools import partial - -import numpy as np -import pytest -import torch - -from egomimic.models.cores.hpt_transformer import ( - CrossAttention, - MultiheadAttention, - SimpleTransformer, -) -from egomimic.models.cores.hpt_utils import EinOpsRearrange, get_sinusoid_encoding_table - -_DIM = 64 -_HEADS = 4 - - -def _trunk(num_blocks: int = 2, embed_dim: int = _DIM, **kwargs) -> SimpleTransformer: - return SimpleTransformer( - attn_target=partial( - MultiheadAttention, - embed_dim=embed_dim, - num_heads=_HEADS, - batch_first=True, - ), - embed_dim=embed_dim, - num_blocks=num_blocks, - **kwargs, - ) - - -# -- trunk ------------------------------------------------------------------ - - -def test_trunk_preserves_token_shape_and_returns_per_block_outputs(): - tokens = torch.randn(2, 10, _DIM) - out, block_outputs = _trunk(num_blocks=3)(tokens) - assert out.shape == tokens.shape - # The second return value is what a hierarchical head would consume; the - # graph stage only forwards the final tokens, so pin the contract. - assert len(block_outputs) == 3 - assert all(b.shape == tokens.shape for b in block_outputs) - - -def test_trunk_accepts_any_sequence_length(): - trunk = _trunk() - for length in (1, 7, 64): - out, _ = trunk(torch.randn(2, length, _DIM)) - assert out.shape == (2, length, _DIM) - - -def test_trunk_actually_mixes_across_tokens(): - # Self-attention must couple positions; without it a per-token MLP would - # leave an isolated perturbation local. - torch.manual_seed(0) - trunk = _trunk().eval() - a = torch.randn(1, 8, _DIM) - b = a.clone() - b[0, 0] += 5.0 - with torch.no_grad(): - out_a, _ = trunk(a) - out_b, _ = trunk(b) - moved = (out_a - out_b).abs().mean(dim=-1)[0] - assert moved[0] > 0 - assert moved[1:].max() > 0, "perturbing one token left the others unchanged" - - -def test_trunk_gradient_checkpointing_matches_the_plain_path(): - torch.manual_seed(0) - trunk = _trunk(num_blocks=4).eval() - tokens = torch.randn(2, 6, _DIM) - with torch.no_grad(): - plain, _ = trunk(tokens) - checkpointed, _ = trunk(tokens, use_checkpoint=True) - torch.testing.assert_close(plain, checkpointed) - - -def test_trunk_rejects_an_unknown_drop_path_type(): - with pytest.raises(ValueError, match="Unknown drop_path_type"): - _trunk(drop_path_type="sometimes") - - -def test_trunk_attention_mask_changes_the_output(): - torch.manual_seed(0) - trunk = _trunk().eval() - tokens = torch.randn(1, 5, _DIM) - causal = torch.triu(torch.ones(5, 5, dtype=torch.bool), diagonal=1) - with torch.no_grad(): - unmasked, _ = trunk(tokens) - masked, _ = trunk(tokens, attn_mask=causal) - assert not torch.allclose(unmasked, masked) - - -# -- cross attention (used by the stems) ------------------------------------ - - -def test_cross_attention_maps_queries_to_the_query_dim(): - attn = CrossAttention(query_dim=_DIM, heads=_HEADS, dim_head=16) - out = attn(torch.randn(2, 8, _DIM), torch.randn(2, 12, _DIM)) - assert out.shape == (2, 8, _DIM) - - -def test_cross_attention_output_depends_on_the_context(): - torch.manual_seed(0) - attn = CrossAttention(query_dim=_DIM, heads=_HEADS, dim_head=16).eval() - query = torch.randn(1, 4, _DIM) - with torch.no_grad(): - a = attn(query, torch.randn(1, 6, _DIM)) - b = attn(query, torch.randn(1, 6, _DIM)) - assert not torch.allclose(a, b) - - -def test_cross_attention_latent_count_sets_the_output_length(): - # This is how the stems compress a variable-length modality to a fixed - # number of latents before the trunk sees them. - attn = CrossAttention(query_dim=_DIM, heads=_HEADS, dim_head=16) - for latents in (1, 16): - out = attn(torch.randn(2, latents, _DIM), torch.randn(2, 9, _DIM)) - assert out.shape == (2, latents, _DIM) - - -# -- tensor helpers --------------------------------------------------------- - - -def test_sinusoid_table_shape_broadcasts_onto_a_token_sequence(): - table = get_sinusoid_encoding_table(0, 10, _DIM) - assert table.shape == (1, 10, _DIM) - assert torch.isfinite(table).all() - - -def test_sinusoid_table_is_bounded_and_position_dependent(): - table = get_sinusoid_encoding_table(0, 16, _DIM)[0] - assert table.abs().max() <= 1.0 - assert not torch.allclose(table[0], table[1]) - - -def test_sinusoid_table_respects_a_nonzero_start(): - full = get_sinusoid_encoding_table(0, 20, _DIM)[0] - offset = get_sinusoid_encoding_table(5, 20, _DIM)[0] - assert offset.shape == (15, _DIM) - torch.testing.assert_close(offset, full[5:]) - - -def test_sinusoid_table_handles_an_odd_hidden_size(): - # The cosine half is one column short when d_hid is odd; that slice is why. - table = get_sinusoid_encoding_table(0, 4, 65) - assert table.shape == (1, 4, 65) - assert torch.isfinite(table).all() - - -@pytest.mark.parametrize("bad", [(5, 5), (7, 3)]) -def test_sinusoid_table_rejects_an_empty_range(bad): - with pytest.raises(ValueError, match="position_end"): - get_sinusoid_encoding_table(bad[0], bad[1], _DIM) - - -def test_sinusoid_table_rejects_a_nonpositive_hidden_size(): - with pytest.raises(ValueError, match="d_hid"): - get_sinusoid_encoding_table(0, 4, 0) - - -def test_einops_rearrange_module_transposes_as_declared(): - module = EinOpsRearrange("b t d -> b d t") - assert module(torch.zeros(2, 10, _DIM)).shape == (2, _DIM, 10) - - -def test_einops_rearrange_module_composes_inside_a_sequential(): - model = torch.nn.Sequential(EinOpsRearrange("b t d -> b (t d)")) - assert model(torch.zeros(2, 3, 4)).shape == (2, 12) - - -def test_einops_rearrange_module_rejects_a_non_tensor(): - with pytest.raises(TypeError, match="expects a tensor"): - EinOpsRearrange("b t d -> b d t")(np.zeros((2, 3, 4))) diff --git a/tests/test_hpt_qwen_configs.py b/tests/test_hpt_qwen_configs.py deleted file mode 100644 index 6c2c3717d8..0000000000 --- a/tests/test_hpt_qwen_configs.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Compose the language HPT experiments without downloading Qwen. - -``tools/config_graph.py`` instantiates every stage, which would pull -Qwen/Qwen3-Embedding-0.6B. These tests only compose and inspect the yaml. -""" - -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -_REPO = Path(__file__).resolve().parents[1] -_CONFIGS = _REPO / "egomimic/hydra_configs" - -_LANG_EXPERIMENTS = ( - "abc_arc/abc_lang_fstshirt_mecka_freefold_cotrain_baseline", - "abc_arc/abc_lang_fstshirt_mecka_freefold_cotrain_arcD40M100", - "abc_arc/abc_lang_mecka_fold_multitask_cotrain_baseline", - "abc_arc/abc_lang_mecka_fold_multitask_cotrain_arcD40M100", -) - -_QWEN_STEM = "egomimic.models.stems.text_encoders.QwenPooledEncoder" -_PROMPT_STAGE = "egomimic.pipeline.stages_hpt.AnnotationPromptStage" - - -def _compose(experiment: str): - with initialize_config_dir(version_base=None, config_dir=str(_CONFIGS)): - return compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment={experiment}", "++paths.root_dir=."], - ) - - -@pytest.mark.parametrize("experiment", _LANG_EXPERIMENTS) -def test_lang_experiments_compose(experiment): - cfg = _compose(experiment) - stages = cfg.model.pipeline.stages - assert stages[0]._target_ == _PROMPT_STAGE - stems = stages[1].stems - assert stems["observations.annotation"]._target_ == _QWEN_STEM - assert cfg.hpt.embed_dim == 840 - assert cfg.hpt.qwen.model_name == "Qwen/Qwen3-Embedding-0.6B" - assert ( - cfg.data.train_datasets.yam_bimanual.resolver.key_map.annotation_key - == "annotations" - ) - assert "annotations" in cfg.data.train_datasets.yam_bimanual.batch_keys - assert cfg.evaluator.viz_func.yam_bimanual.annotation_key == "annotations" - assert cfg.evaluator.viz_func.human_bimanual.annotation_key == "annotations" - - -def test_baseline_horizon_is_time_indexed(): - cfg = _compose("abc_arc/abc_lang_fstshirt_mecka_freefold_cotrain_baseline") - assert cfg.hpt.action_horizon == 100 - assert cfg.model.pipeline.stages[4].action_horizon == 100 - assert cfg.model.pipeline.stages[5].model.act_seq == 100 - - -def test_arc_horizon_follows_per_waypoint_token_rows(): - cfg = _compose("abc_arc/abc_lang_fstshirt_mecka_freefold_cotrain_arcD40M100") - assert cfg.abc.arc_token_rows == 200 - assert cfg.hpt.action_horizon == 200 - assert cfg.model.pipeline.stages[4].action_horizon == 200 - assert cfg.model.pipeline.stages[5].model.act_seq == 200 - - -def test_baseline_and_arc_share_the_qwen_architecture(): - base = _compose("abc_arc/abc_lang_mecka_fold_multitask_cotrain_baseline") - arc = _compose("abc_arc/abc_lang_mecka_fold_multitask_cotrain_arcD40M100") - assert ( - base.model.pipeline.stages[1].stems["observations.annotation"]._target_ - == arc.model.pipeline.stages[1].stems["observations.annotation"]._target_ - ) - assert base.hpt.embed_dim == arc.hpt.embed_dim == 840 - assert base.hpt.num_blocks == arc.hpt.num_blocks == 19 diff --git a/tests/test_hpt_stages.py b/tests/test_hpt_stages.py deleted file mode 100644 index f18536a429..0000000000 --- a/tests/test_hpt_stages.py +++ /dev/null @@ -1,470 +0,0 @@ -"""HPT stems and trunk as graph stages.""" - -import random -from functools import partial - -import pytest -import torch -import torch.nn as nn -from omegaconf import OmegaConf - -from egomimic.models.cores.hpt_transformer import MultiheadAttention, SimpleTransformer -from egomimic.models.stems.hpt_stems import MLPPolicyStem, PolicyStem -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_hpt import ( - AnnotationPromptStage, - HPTStemStage, - HPTTrunkStage, - sample_annotation_prompt, -) - -_DIM = 64 -_LATENTS = 8 -_POSE_KEY = "observations.state.ee_pose" -_IMG_KEY = "observations.images.front_img_1" - - -def _spec(embed_dim: int = _DIM, latents: int = _LATENTS): - return OmegaConf.create( - { - "cross_attn": { - "crossattn_latent": latents, - "crossattn_heads": 4, - "crossattn_dim_head": 16, - "crossattn_modality_dropout": 0.0, - "modality_embed_dim": embed_dim, - } - } - ) - - -def _stem(input_dim: int, embed_dim: int = _DIM, latents: int = _LATENTS): - return MLPPolicyStem( - input_dim=input_dim, - output_dim=embed_dim, - widths=[embed_dim], - specs=_spec(embed_dim, latents), - ) - - -def _trunk(embed_dim: int = _DIM, num_blocks: int = 2): - return SimpleTransformer( - attn_target=partial( - MultiheadAttention, embed_dim=embed_dim, num_heads=4, batch_first=True - ), - embed_dim=embed_dim, - num_blocks=num_blocks, - ) - - -# -- stems ------------------------------------------------------------------ - - -def test_stem_stage_declares_its_modalities_as_reads(): - stage = HPTStemStage(stems={_POSE_KEY: _stem(14)}) - assert stage.contract("train") == ((_POSE_KEY,), ("hpt/tokens",)) - - -def test_stem_stage_compresses_a_modality_to_the_configured_latents(): - stage = HPTStemStage(stems={_POSE_KEY: _stem(14)}) - out = stage.forward({_POSE_KEY: torch.zeros(4, 14)}) - assert out["hpt/tokens"].shape == (4, _LATENTS, _DIM) - - -def test_stem_stage_concatenates_modalities_along_the_token_axis(): - # The point of the cross-attention pooling: a 14-D pose and a 512-D image - # feature both become `latents` tokens of the same width. - stage = HPTStemStage(stems={_POSE_KEY: _stem(14), _IMG_KEY: _stem(512)}) - out = stage.forward({_POSE_KEY: torch.zeros(3, 14), _IMG_KEY: torch.zeros(3, 512)}) - assert out["hpt/tokens"].shape == (3, 2 * _LATENTS, _DIM) - - -def test_stem_stage_order_is_sorted_not_dict_insertion(): - """Token layout must not depend on dict ordering, or DDP ranks diverge.""" - forward = HPTStemStage(stems={_POSE_KEY: _stem(14), _IMG_KEY: _stem(512)}) - reverse = HPTStemStage(stems={_IMG_KEY: _stem(512), _POSE_KEY: _stem(14)}) - assert ( - forward.shared_keys - == reverse.shared_keys - == tuple(sorted([_POSE_KEY, _IMG_KEY])) - ) - - -def test_stem_stage_handles_dotted_batch_keys(): - # ModuleDict rejects "." so the stage sanitizes; the reads must still be - # the real batch keys. - stage = HPTStemStage(stems={_POSE_KEY: _stem(14)}) - assert _POSE_KEY in stage.reads - assert stage.forward({_POSE_KEY: torch.zeros(2, 14)})["hpt/tokens"].shape[0] == 2 - - -def test_stem_stage_rejects_stems_that_disagree_on_width(): - stage = HPTStemStage( - stems={_POSE_KEY: _stem(14, embed_dim=_DIM), _IMG_KEY: _stem(512, embed_dim=32)} - ) - with pytest.raises(ValueError, match="disagree on embed dim"): - stage.forward({_POSE_KEY: torch.zeros(2, 14), _IMG_KEY: torch.zeros(2, 512)}) - - -def test_stem_stage_needs_at_least_one_stem(): - with pytest.raises(ValueError, match="at least one stem"): - HPTStemStage(stems={}) - - -def test_stem_stage_accepts_a_plain_dict_cross_attn_spec(): - # Hydra gives DictConfig, hand-built stems may give dict; both must work. - stem = MLPPolicyStem( - input_dim=14, - output_dim=_DIM, - widths=[_DIM], - specs={ - "cross_attn": { - "crossattn_latent": 4, - "crossattn_heads": 2, - "crossattn_dim_head": 8, - "crossattn_modality_dropout": 0.0, - "modality_embed_dim": _DIM, - } - }, - ) - out = HPTStemStage(stems={_POSE_KEY: stem}).forward({_POSE_KEY: torch.zeros(2, 14)}) - assert out["hpt/tokens"].shape == (2, 4, _DIM) - - -# -- per-domain stems ------------------------------------------------------- - - -def test_domain_stems_add_tokens_and_read_the_selector(): - stage = HPTStemStage( - stems={_POSE_KEY: _stem(14)}, - domain_stems={"yam_bimanual": {_IMG_KEY: _stem(512)}}, - selector_aliases={"7": "yam_bimanual"}, - ) - assert "embodiment" in stage.reads - out = stage.forward( - {_POSE_KEY: torch.zeros(2, 14), _IMG_KEY: torch.zeros(2, 512), "embodiment": 7} - ) - assert out["hpt/tokens"].shape == (2, 2 * _LATENTS, _DIM) - - -def test_domain_stems_reject_an_unconfigured_domain(): - stage = HPTStemStage( - stems={_POSE_KEY: _stem(14)}, - domain_stems={"yam_bimanual": {_IMG_KEY: _stem(512)}}, - ) - with pytest.raises(KeyError, match="no stems for domain"): - stage.forward( - { - _POSE_KEY: torch.zeros(2, 14), - _IMG_KEY: torch.zeros(2, 512), - "embodiment": 3, - } - ) - - -def test_a_batched_selector_resolves_to_one_domain(): - stage = HPTStemStage( - stems={_POSE_KEY: _stem(14)}, - domain_stems={"yam_bimanual": {_IMG_KEY: _stem(512)}}, - selector_aliases={"7": "yam_bimanual"}, - ) - out = stage.forward( - { - _POSE_KEY: torch.zeros(2, 14), - _IMG_KEY: torch.zeros(2, 512), - "embodiment": torch.tensor([7, 7]), - } - ) - assert out["hpt/tokens"].shape[1] == 2 * _LATENTS - - -# -- trunk ------------------------------------------------------------------ - - -def test_trunk_stage_writes_the_same_condition_key_the_heads_read(): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM) - assert stage.contract("train") == (("hpt/tokens",), ("condition",)) - - -@pytest.mark.parametrize("pooling", ["action_token", "mean", "last"]) -def test_trunk_stage_pools_to_one_vector_per_sample(pooling): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM, token_postprocessing=pooling) - out = stage.forward({"hpt/tokens": torch.zeros(5, _LATENTS, _DIM)}) - assert out["condition"].shape == (5, _DIM) - - -def test_trunk_stage_rejects_an_unknown_pooling(): - with pytest.raises(ValueError, match="token_postprocessing"): - HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM, token_postprocessing="cls") - - -def test_action_token_pooling_prepends_a_learned_token(): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM) - assert stage.action_token is not None and stage.action_token.requires_grad - # mean pooling has no such parameter - assert ( - HPTTrunkStage( - trunk=_trunk(), embed_dim=_DIM, token_postprocessing="mean" - ).action_token - is None - ) - - -def test_trunk_stage_rejects_a_width_mismatch_with_the_stems(): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM) - with pytest.raises(ValueError, match="embed_dim is"): - stage.forward({"hpt/tokens": torch.zeros(2, _LATENTS, _DIM * 2)}) - - -def test_trunk_stage_rejects_a_non_token_tensor(): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM) - with pytest.raises(ValueError, match=r"\(B, tokens,"): - stage.forward({"hpt/tokens": torch.zeros(2, _DIM)}) - - -def test_position_embedding_is_added_and_bounded_by_max_tokens(): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM, max_tokens=_LATENTS + 1) - stage.forward({"hpt/tokens": torch.zeros(2, _LATENTS, _DIM)}) - with pytest.raises(ValueError, match="max_tokens"): - stage.forward({"hpt/tokens": torch.zeros(2, _LATENTS * 4, _DIM)}) - - -def test_position_embedding_can_be_disabled(): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM, use_position_embedding=False) - assert stage.position_embedding is None - assert stage.forward({"hpt/tokens": torch.zeros(2, _LATENTS, _DIM)})[ - "condition" - ].shape - - -def test_position_embedding_is_not_a_trained_parameter(): - stage = HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM) - assert "position_embedding" not in dict(stage.named_parameters()) - - -def test_domain_embedding_shifts_the_condition_per_domain(): - torch.manual_seed(0) - stage = HPTTrunkStage( - trunk=_trunk(), - embed_dim=_DIM, - domains=["yam_bimanual", "human_bimanual"], - use_domain_embedding=True, - selector_aliases={"7": "yam_bimanual", "3": "human_bimanual"}, - ).eval() - tokens = torch.randn(1, _LATENTS, _DIM) - with torch.no_grad(): - a = stage.forward({"hpt/tokens": tokens, "embodiment": 7})["condition"] - b = stage.forward({"hpt/tokens": tokens, "embodiment": 3})["condition"] - assert not torch.allclose(a, b), "domain embedding had no effect" - - -def test_domain_embedding_requires_a_domain_list(): - with pytest.raises(ValueError, match="non-empty domains"): - HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM, use_domain_embedding=True) - - -def test_domain_embedding_rejects_an_unlisted_domain(): - stage = HPTTrunkStage( - trunk=_trunk(), - embed_dim=_DIM, - domains=["yam_bimanual"], - use_domain_embedding=True, - ) - with pytest.raises(KeyError, match="no domain embedding"): - stage.forward({"hpt/tokens": torch.zeros(1, _LATENTS, _DIM), "embodiment": 99}) - - -# -- the two together in a graph ------------------------------------------- - - -def test_stems_and_trunk_compose_into_a_runnable_subgraph(): - pipeline = Pipeline( - [ - HPTStemStage(stems={_POSE_KEY: _stem(14)}), - HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM), - ] - ) - runnable, excluded = pipeline.plan([_POSE_KEY], mode="train") - assert len(runnable) == 2 and not excluded - out = pipeline.execute({_POSE_KEY: torch.zeros(2, 14)}, mode="train") - assert out["condition"].shape == (2, _DIM) - - -def test_the_subgraph_runs_in_inference_too(): - """Neither stage is mode-restricted: HPT encodes identically either way.""" - pipeline = Pipeline( - [ - HPTStemStage(stems={_POSE_KEY: _stem(14)}), - HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM), - ] - ) - out = pipeline.execute({_POSE_KEY: torch.zeros(2, 14)}, mode="inference") - assert out["condition"].shape == (2, _DIM) - - -def test_a_missing_modality_blocks_the_graph_rather_than_running_partially(): - pipeline = Pipeline( - [HPTStemStage(stems={_POSE_KEY: _stem(14), _IMG_KEY: _stem(512)})] - ) - with pytest.raises(RuntimeError, match="blocked stages"): - pipeline.execute({_POSE_KEY: torch.zeros(2, 14)}, mode="train") - - -def test_condition_is_differentiable_back_to_the_stems(): - pipeline = Pipeline( - [ - HPTStemStage(stems={_POSE_KEY: _stem(14)}), - HPTTrunkStage(trunk=_trunk(), embed_dim=_DIM), - ] - ) - out = pipeline.execute({_POSE_KEY: torch.randn(2, 14)}, mode="train") - out["condition"].sum().backward() - grads = [ - p.grad for p in pipeline.parameters() if p.requires_grad and p.grad is not None - ] - assert grads, "no gradient reached the stems or trunk" - - -# -- dtype robustness ------------------------------------------------------- -# -# Zarr stamps poses as float64 and torch.from_numpy keeps that. Autocast never -# downcasts Double, so a Double input reaches a stem's Linear unchanged and the -# matmul raises. The stage casts to its own parameter dtype rather than relying -# on an upstream normalisation that a given branch may not carry. - - -def test_stem_stage_accepts_a_float64_modality(): - stage = HPTStemStage(stems={_POSE_KEY: _stem(14)}) - out = stage.forward({_POSE_KEY: torch.zeros(2, 14, dtype=torch.float64)}) - assert out["hpt/tokens"].shape == (2, _LATENTS, _DIM) - assert out["hpt/tokens"].dtype == torch.float32 - - -def test_stem_stage_leaves_a_matching_dtype_untouched(): - stage = HPTStemStage(stems={_POSE_KEY: _stem(14)}) - out = stage.forward({_POSE_KEY: torch.zeros(2, 14, dtype=torch.float32)}) - assert out["hpt/tokens"].dtype == torch.float32 - - -def test_stem_stage_handles_mixed_input_dtypes_across_modalities(): - # The real ABC batch: float32 images beside a float64 pose. - stage = HPTStemStage(stems={_POSE_KEY: _stem(14), _IMG_KEY: _stem(512)}) - out = stage.forward( - { - _POSE_KEY: torch.zeros(2, 14, dtype=torch.float64), - _IMG_KEY: torch.zeros(2, 512, dtype=torch.float32), - } - ) - assert out["hpt/tokens"].shape == (2, 2 * _LATENTS, _DIM) - - -def test_a_half_precision_stem_casts_its_input_to_match(): - stage = HPTStemStage(stems={_POSE_KEY: _stem(14)}).half() - out = stage.forward({_POSE_KEY: torch.zeros(2, 14, dtype=torch.float32)}) - assert out["hpt/tokens"].dtype == torch.float16 - - -# -- language prompts ------------------------------------------------------- - - -_ANN_KEY = "annotations" -_PROMPT_KEY = "observations.annotation" - - -class _ListStem(PolicyStem): - """Stand-in for a text stem: consumes list[str], no HuggingFace download.""" - - def __init__(self, embed_dim: int = _DIM, latents: int = _LATENTS): - super().__init__(specs=_spec(embed_dim, latents)) - self.dummy = nn.Parameter(torch.zeros(1)) - - def forward(self, prompts): - return torch.ones(len(prompts), 1, _DIM) - - def compute_latent(self, prompts): - feat = self(prompts) - stem_tokens = self.tokens.repeat(feat.shape[0], 1, 1) - return self.cross_attention(stem_tokens, feat) - - -def test_sample_annotation_prompt_falls_back_when_empty(): - assert sample_annotation_prompt([], default_prompt="idle") == "idle" - assert sample_annotation_prompt(["", " "], default_prompt="idle") == "idle" - - -def test_sample_annotation_prompt_takes_first_at_eval(): - item = ["fold the shirt", "grab the sleeve"] - assert ( - sample_annotation_prompt(item, sampling_mode="random", training=False) - == "fold the shirt" - ) - assert sample_annotation_prompt(item, sampling_mode="first") == "fold the shirt" - - -def test_sample_annotation_prompt_can_be_seeded(): - item = ["a", "b", "c"] - rng = random.Random(0) - picked = {sample_annotation_prompt(item, training=True, rng=rng) for _ in range(20)} - assert picked <= set(item) - assert len(picked) > 1 - - -def test_annotation_prompt_stage_writes_one_string_per_sample(): - stage = AnnotationPromptStage(sampling_mode="first") - assert stage.contract("train") == ((_ANN_KEY,), (_PROMPT_KEY,)) - out = stage.execute( - {_ANN_KEY: [["fold the shirt", "smooth"], [], "already a string"]}, - mode="train", - ) - assert out[_PROMPT_KEY] == ["fold the shirt", "", "already a string"] - - -def test_annotation_prompt_stage_is_deterministic_in_inference(): - stage = AnnotationPromptStage(sampling_mode="random") - batch = {_ANN_KEY: [["aaa", "bbb"], ["ccc", "ddd"]]} - first = stage.execute(dict(batch), mode="inference")[_PROMPT_KEY] - second = stage.execute(dict(batch), mode="inference")[_PROMPT_KEY] - assert first == second == ["aaa", "ccc"] - - -def test_stem_stage_routes_list_valued_prompts_without_dtype_cast(): - stage = HPTStemStage(stems={_PROMPT_KEY: _ListStem()}) - out = stage.forward({_PROMPT_KEY: ["fold the shirt", "smooth the hem"]}) - assert out["hpt/tokens"].shape == (2, _LATENTS, _DIM) - - -def test_prompt_stage_and_list_stem_compose(): - pipeline = Pipeline( - [ - AnnotationPromptStage(sampling_mode="first"), - HPTStemStage(stems={_PROMPT_KEY: _ListStem()}), - ] - ) - out = pipeline.execute( - {_ANN_KEY: [["fold the shirt"], ["pick up the cup"]]}, mode="train" - ) - assert out["hpt/tokens"].shape == (2, _LATENTS, _DIM) - - -def test_domain_only_keys_are_not_required_on_every_source(): - """Yam wrists must not block a human batch that has no wrist cameras.""" - stage = HPTStemStage( - stems={_POSE_KEY: _stem(14)}, - domain_stems={ - "yam_bimanual": {_IMG_KEY: _stem(512)}, - "human_bimanual": {"observations.state.gripper": _stem(2)}, - }, - selector_aliases={"7": "yam_bimanual", "3": "human_bimanual"}, - ) - assert _IMG_KEY not in stage.reads - assert "embodiment" in stage.reads - pipeline = Pipeline([stage]) - out = pipeline.execute( - { - _POSE_KEY: torch.zeros(2, 14), - "observations.state.gripper": torch.zeros(2, 2), - "embodiment": 3, - }, - mode="train", - ) - assert out["hpt/tokens"].shape == (2, 2 * _LATENTS, _DIM) diff --git a/tests/test_human_arc.py b/tests/test_human_arc.py deleted file mode 100644 index 4c54046c23..0000000000 --- a/tests/test_human_arc.py +++ /dev/null @@ -1,320 +0,0 @@ -"""Human's arc-tokenizer action mode, the counterpart of Yam's.""" - -import pytest - -from egomimic.rldb.embodiment.human import Human -from egomimic.rldb.zarr.action_chunk_transforms import PadGripperZeros - -_ARC = "arc_tokenizer_cartesian_gripper_padded" -_D = 0.40 -_M = 100 - - -def _transforms(**kwargs): - params = dict( - action_mode=_ARC, - coord_frame="eef_frame", - rotation_mode="euler", - stride=1, - min_distance_unit=_D, - resampled_vector_length=_M, - ) - params.update(kwargs) - return Human.get_transform_list(**params) - - -def _tokenizer(transforms): - found = [t for t in transforms if "ArcLength" in type(t).__name__] - assert len(found) == 1, f"expected one tokenizer, got {len(found)}" - return found[0] - - -# -- the mode exists and is wired ------------------------------------------- - - -def test_arc_mode_appends_exactly_one_tokenizer(): - assert _tokenizer(_transforms()) - - -def test_gripper_padding_runs_before_the_tokenizer(): - """Human has no gripper signal. - - The tokenizer's layout routes gripper into slot 6 per arm, so the zero - column must already exist when it runs -- otherwise the chunk is 12D and - the 14D layout cannot be built. - """ - transforms = _transforms() - pads = [t for t in transforms if isinstance(t, PadGripperZeros)] - assert len(pads) == 2 # action chunk and proprio - assert transforms.index(pads[0]) < transforms.index(_tokenizer(transforms)) - - -def test_bare_arc_cartesian_is_rejected_with_a_pointer_to_the_padded_mode(): - """12D in, 14D needed: fail at config time, not at the first batch.""" - with pytest.raises(ValueError, match="gripper_padded"): - Human.get_transform_list(action_mode="arc_tokenizer_cartesian") - - -def test_plain_cartesian_modes_are_untouched(): - for mode in ("cartesian", "cartesian_gripper_padded"): - transforms = Human.get_transform_list(action_mode=mode, stride=1) - assert not [t for t in transforms if "ArcLength" in type(t).__name__] - - -# -- the part that is easy to get wrong: dt tracks stride ------------------- - - -@pytest.mark.parametrize("stride, expected_dt", [(1, 1 / 30), (2, 2 / 30), (3, 3 / 30)]) -def test_tokenizer_dt_follows_the_stride(stride, expected_dt): - """The chunk is subsampled by actions[::stride]. - - Consecutive samples are stride/30 s apart, so leaving the tokenizer's 1/30 - default inflates the velocity channel by exactly `stride`. It cancels - inside tokenize -> detokenize, but it is what the model learns and what a - deployed policy would command. - """ - tokenizer = _tokenizer(_transforms(stride=stride)) - assert tokenizer.tokenizer.config.dt == pytest.approx(expected_dt) - - -def test_yam_keeps_the_unstrided_default_for_contrast(): - from egomimic.rldb.embodiment.yam import Yam - - transforms = Yam.get_transform_list( - action_mode="arc_tokenizer_cartesian", - coord_frame="eef_frame", - min_distance_unit=_D, - resampled_vector_length=_M, - ) - assert _tokenizer(transforms).tokenizer.config.dt == pytest.approx(1 / 30) - - -# -- the raw window --------------------------------------------------------- - - -def test_arc_keymap_widens_the_raw_action_window(): - """Arc needs room to reach D before the padded tail begins.""" - plain = Human.get_keymap(keymap_mode="cartesian") - arc = Human.get_keymap(keymap_mode="arc_tokenizer_cartesian") - - def horizons(keymap): - return { - v.get("horizon") - for v in keymap.values() - if v.get("key_type") == "action_keys" - } - - assert horizons(plain) == {Human.ACTION_HORIZON} - assert horizons(arc) == {Human.ARC_TOK_ACTION_HORIZON} - assert Human.ARC_TOK_ACTION_HORIZON > Human.ACTION_HORIZON - - -def test_the_arc_keymap_is_otherwise_identical_to_cartesian(): - """Only the horizon differs, so the same transform list works on both.""" - plain = Human.get_keymap(keymap_mode="cartesian") - arc = Human.get_keymap(keymap_mode="arc_tokenizer_cartesian") - assert set(plain) == set(arc) - for key in plain: - assert plain[key]["zarr_key"] == arc[key]["zarr_key"], key - assert plain[key]["key_type"] == arc[key]["key_type"], key - - -def test_arc_does_not_resample_before_tokenizing(): - """chunk_length defaults to the RAW window for arc modes. - - Interpolating to 100 first would decimate the human window, and arc length - measured on a decimated path reads systematically short. - """ - from egomimic.rldb.zarr.action_chunk_transforms import InterpolatePose - - transforms = _transforms() - lengths = { - t.new_chunk_length - for t in transforms - if isinstance(t, InterpolatePose) and hasattr(t, "new_chunk_length") - } - assert lengths == {Human.ARC_TOK_ACTION_HORIZON}, lengths - - -def test_an_explicit_chunk_length_overrides_the_arc_default(): - from egomimic.rldb.zarr.action_chunk_transforms import InterpolatePose - - transforms = _transforms(chunk_length=200) - lengths = { - t.new_chunk_length - for t in transforms - if isinstance(t, InterpolatePose) and hasattr(t, "new_chunk_length") - } - assert lengths == {200} - - -# -- the configs that were blocked on this --------------------------------- - - -@pytest.mark.parametrize( - "experiment", - [ - "abc_arc/abc_fstshirt_mecka_freefold_cotrain_arcD40M100", - "abc_arc/abc_mecka_fold_multitask_cotrain_arcD40M100", - ], -) -def test_the_arc_cotrain_configs_instantiate_their_transform_lists(experiment): - """These failed outright before Human had an arc mode.""" - from pathlib import Path - - import hydra - from hydra import compose, initialize_config_dir - - root = str(Path(__file__).resolve().parents[1] / "egomimic/hydra_configs") - with initialize_config_dir(version_base=None, config_dir=root): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment={experiment}", "++paths.root_dir=."], - ) - assert set(cfg.data.train_datasets) == {"human_bimanual", "yam_bimanual"} - for source in cfg.data.train_datasets: - transforms = hydra.utils.instantiate( - cfg.data.train_datasets[source].resolver.transform_list - ) - assert [t for t in transforms if "ArcLength" in type(t).__name__], source - - -def test_both_embodiments_agree_on_d_and_m_in_a_cotrain_config(): - """A cotrain run must tokenize both sources the same way.""" - from pathlib import Path - - from hydra import compose, initialize_config_dir - - root = str(Path(__file__).resolve().parents[1] / "egomimic/hydra_configs") - with initialize_config_dir(version_base=None, config_dir=root): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=abc_arc/abc_fstshirt_mecka_freefold_cotrain_arcD40M100", - "++paths.root_dir=.", - ], - ) - specs = [ - cfg.data.train_datasets[src].resolver.transform_list - for src in cfg.data.train_datasets - ] - assert len({s.min_distance_unit for s in specs}) == 1 - assert len({s.resampled_vector_length for s in specs}) == 1 - - -def test_cotrain_arc_row_count_follows_the_velocity_mode(): - """101 (M+1, odd) crashed ConditionalUnet1D's skip concat. - - Two downsample stages need the row count divisible by four, so the - per_waypoint layout's 2*M = 200 is what makes these configs trainable at - all. The model must read the knob rather than a hardcoded literal. - """ - from pathlib import Path - - from hydra import compose, initialize_config_dir - from omegaconf import OmegaConf - - from egomimic.rldb.zarr.arc_length_tokenizer import bimanual_arc_token_rows - - root = str(Path(__file__).resolve().parents[1] / "egomimic/hydra_configs") - for experiment in ( - "abc_arc/abc_fstshirt_mecka_freefold_cotrain_arcD40M100", - "abc_arc/abc_mecka_fold_multitask_cotrain_arcD40M100", - ): - with initialize_config_dir(version_base=None, config_dir=root): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment={experiment}", "++paths.root_dir=."], - ) - mode = cfg.abc.arc_velocity_mode - specs = [ - cfg.data.train_datasets[src].resolver.transform_list - for src in cfg.data.train_datasets - ] - # every source tokenizes the same way - assert {OmegaConf.select(s, "velocity_mode") for s in specs} == {mode} - expected = bimanual_arc_token_rows(int(specs[0].resampled_vector_length), mode) - assert cfg.abc.arc_token_rows == expected, experiment - assert expected % 4 == 0, "ConditionalUnet1D needs rows divisible by four" - horizons = { - int(s.action_horizon) - for s in cfg.model.pipeline.stages - if "action_horizon" in s - } - assert horizons == {expected}, (experiment, horizons) - - -def test_the_cotrain_arc_unet_accepts_the_token_width(): - """The check that 101 failed: run a real tensor through the denoiser.""" - from pathlib import Path - - import hydra - import torch - from hydra import compose, initialize_config_dir - - root = str(Path(__file__).resolve().parents[1] / "egomimic/hydra_configs") - with initialize_config_dir(version_base=None, config_dir=root): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=abc_arc/abc_fstshirt_mecka_freefold_cotrain_arcD40M100", - "++paths.root_dir=.", - ], - ) - algo = hydra.utils.instantiate(cfg.model.pipeline) - denoiser = next( - s for s in algo.pipeline.stages if type(s).__name__ == "DiffusionDenoiserStage" - ) - rows = int(cfg.abc.arc_token_rows) - out = denoiser.policy.model( - torch.zeros(2, rows, denoiser.action_dim), - torch.zeros(2, dtype=torch.long), - torch.zeros(2, denoiser.condition_input_dim), - ) - assert tuple(out.shape) == (2, rows, denoiser.action_dim) - - -def test_original_abc_cotrain_study_shares_one_arcmatch_configuration(): - """All six arms must score in the same space or none of it is comparable. - - This is the regression that shipped twice: the two cotrain baselines kept - the plain evaluator (no arc metrics at all), and the two arc cotrains had - `evaluator: null`, so four of six runs produced zero arcmatch keys while - looking like they had succeeded. - """ - from pathlib import Path - - from hydra import compose, initialize_config_dir - - root = Path(__file__).resolve().parents[1] / "egomimic/hydra_configs" - experiments = sorted( - f"abc_arc/{p.stem}" - for p in (root / "experiment/abc_arc").glob("abc_*.yaml") - # Stationery is a separate source campaign with controller-facing - # reconstruction metrics, covered by test_robot_arc_campaigns.py. - if not p.stem.startswith("abc_stationery_") - ) - assert len(experiments) == 10, experiments - - settings = {} - for experiment in experiments: - with initialize_config_dir(version_base=None, config_dir=str(root)): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment={experiment}", "++paths.root_dir=."], - ) - evaluator = cfg.evaluator - assert evaluator is not None, f"{experiment} has no evaluator" - assert evaluator._target_.endswith("ArcBimanualCartesianEval"), experiment - assert evaluator.arc_metrics is True, experiment - assert evaluator.include_reconstruction_loss is False, experiment - settings[experiment] = ( - float(evaluator.min_distance_unit), - int(evaluator.resampled_vector_length), - int(evaluator.arcmatch_points), - int(evaluator.arc_chunk_rows), - str(evaluator.velocity_mode), - bool(evaluator.include_reconstruction_loss), - ) - # One shared tuple across all arms. - assert len(set(settings.values())) == 1, settings diff --git a/tests/test_hydra_data_config.py b/tests/test_hydra_data_config.py deleted file mode 100644 index c32b1193ec..0000000000 --- a/tests/test_hydra_data_config.py +++ /dev/null @@ -1,97 +0,0 @@ -from pathlib import Path - -import pytest -from omegaconf import OmegaConf - -from egomimic.utils.hydra_utils import ( - instantiate_dataset_splits_from_path, - load_data_config_from_path, -) - - -def _write(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text) - - -def _config_tree(tmp_path: Path) -> Path: - config_dir = tmp_path / "hydra_configs" - _write( - config_dir / "root.yaml", - """\ -defaults: - - paths: default - - data: null - - _self_ -""", - ) - _write( - config_dir / "paths" / "default.yaml", - "dataset_dir: /composed/datasets\n", - ) - _write( - config_dir / "data" / "base.yaml", - """\ -train_datasets: - active_train: - _target_: builtins.dict - location: ${paths.dataset_dir}/train - null_train: null -valid_datasets: - active_valid: - _target_: builtins.dict - location: ${paths.dataset_dir}/valid - null_valid: null -""", - ) - config_path = config_dir / "data" / "example.yaml" - _write( - config_path, - """\ -defaults: - - base - - _self_ -""", - ) - return config_path - - -def test_data_config_loader_resolves_defaults_and_root_path_context(tmp_path): - config_path = _config_tree(tmp_path) - - data = load_data_config_from_path(config_path, root_config_name="root") - - assert data.train_datasets.active_train.location == "/composed/datasets/train" - assert data.valid_datasets.active_valid.location == "/composed/datasets/valid" - - -def test_dataset_split_instantiation_skips_null_entries_and_accepts_overrides( - tmp_path, -): - config_path = _config_tree(tmp_path) - - train, valid = instantiate_dataset_splits_from_path( - config_path, - overrides=["paths.dataset_dir=/override"], - root_config_name="root", - ) - - assert train == {"active_train": {"location": "/override/train"}} - assert valid == {"active_valid": {"location": "/override/valid"}} - - -def test_data_config_loader_rejects_non_data_group_path(tmp_path): - config_dir = tmp_path / "hydra_configs" - config_path = config_dir / "model" / "example.yaml" - _write(config_path, "value: 1\n") - - with pytest.raises(ValueError, match="Expected a config below"): - load_data_config_from_path(config_path, root_config_name="root") - - -def test_repository_data_configs_resolve_in_root_context(): - config_dir = Path(__file__).parents[1] / "egomimic" / "hydra_configs" / "data" - - for config_path in sorted(config_dir.glob("*.yaml")): - data = load_data_config_from_path(config_path) - OmegaConf.to_container(data, resolve=True) diff --git a/tests/test_hydra_override.py b/tests/test_hydra_override.py deleted file mode 100644 index 91cd694395..0000000000 --- a/tests/test_hydra_override.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from egomimic.utils.hydra_override import ( - encode_hydra_string_override, - quote_hydra_string, -) - -REPO_ROOT = Path(__file__).parents[1] - - -@pytest.mark.parametrize( - "value", - [ - "", - "plain checkpoint", - "/scratch/run=name with spaces,part[4]{raw}/'single'/\"double\"", - "internal\\backslash", - 'backslash-before-quote\\"and-trailing\\', - "café/雪", - ], -) -def test_string_override_round_trips_through_hydra_and_omegaconf( - tmp_path: Path, value: str -) -> None: - (tmp_path / "config.yaml").write_text("target: original\n", encoding="utf-8") - token = encode_hydra_string_override("target", value) - - with initialize_config_dir(config_dir=str(tmp_path), version_base="1.3"): - cfg = compose(config_name="config", overrides=[token]) - - assert OmegaConf.select(cfg, "target") == value - - -def test_encoder_uses_one_double_quoted_override_token() -> None: - value = "/scratch/checkpoint=epoch 4,step[80]{ema}.ckpt" - - assert encode_hydra_string_override("paths.ckpt", value) == ( - 'paths.ckpt="/scratch/checkpoint=epoch 4,step[80]{ema}.ckpt"' - ) - - -@pytest.mark.parametrize( - "value", - [ - "${unsafe}", - "prefix/${oc.env:HOME}/suffix", - "nul\x00byte", - "line\nbreak", - "tab\tcharacter", - "delete\x7fcharacter", - "c1\x85control", - "line\u2028separator", - "paragraph\u2029separator", - ], -) -def test_quote_rejects_interpolation_and_control_characters(value: str) -> None: - with pytest.raises(ValueError): - quote_hydra_string(value) - - -@pytest.mark.parametrize( - "key", - ["", "white space", "two=assignments", "group/name", "${unsafe}", "-flag"], -) -def test_encoder_rejects_non_scalar_keys(key: str) -> None: - with pytest.raises(ValueError, match="dot-separated scalar key"): - encode_hydra_string_override(key, "value") - - -def test_cli_prints_exactly_one_round_trippable_token(tmp_path: Path) -> None: - value = 'C:\\scratch\\run=a b,[c]{d}\\"quoted"\\' - completed = subprocess.run( - [ - sys.executable, - "-m", - "egomimic.utils.hydra_override", - "++resume.ckpt_path", - value, - ], - cwd=REPO_ROOT, - check=True, - capture_output=True, - text=True, - ) - - assert completed.stderr == "" - assert completed.stdout == ( - encode_hydra_string_override("++resume.ckpt_path", value) + "\n" - ) - assert completed.stdout.count("\n") == 1 - - -def test_cli_rejects_unsafe_interpolation_without_stdout() -> None: - completed = subprocess.run( - [ - sys.executable, - "-m", - "egomimic.utils.hydra_override", - "resume.ckpt_path", - "${oc.env:HOME}", - ], - cwd=REPO_ROOT, - check=False, - capture_output=True, - text=True, - ) - - assert completed.returncode == 2 - assert completed.stdout == "" - assert "must not contain interpolation" in completed.stderr diff --git a/tests/test_ice_checkpoint_archive_inventory.py b/tests/test_ice_checkpoint_archive_inventory.py deleted file mode 100644 index 5c4fff60d4..0000000000 --- a/tests/test_ice_checkpoint_archive_inventory.py +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import hashlib -import importlib.util -import json -import sys -import tempfile -import unittest -from pathlib import Path - -ICE_DIR = Path(__file__).parents[1] / "scripts" / "ice" -INVENTORY_PATH = ICE_DIR / "ice_checkpoint_archive_inventory.py" -sys.path.insert(0, str(ICE_DIR)) -SPEC = importlib.util.spec_from_file_location( - "ice_checkpoint_archive_inventory", INVENTORY_PATH -) -INVENTORY = importlib.util.module_from_spec(SPEC) -assert SPEC.loader is not None -sys.modules[SPEC.name] = INVENTORY -SPEC.loader.exec_module(INVENTORY) - - -def make_validator(root: Path) -> Path: - validator = root / "validate.py" - validator.write_text("#!/bin/sh\nexit 0\n") - validator.chmod(0o700) - return validator - - -def write_complete(run: Path, checkpoint: Path, step: int) -> None: - (run / "COMPLETE.json").write_text( - json.dumps( - { - "schema_version": 1, - "status": "COMPLETE", - "checkpoint": { - "global_step": step, - "sha256": hashlib.sha256(checkpoint.read_bytes()).hexdigest(), - }, - } - ) - ) - - -class ArchiveInventoryTest(unittest.TestCase): - def test_distinguishes_completed_archived_pending_and_unregistered(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) / "scratch" / "campaign" - state_dir = root / "service" / "state" - state_dir.mkdir(parents=True) - validator = make_validator(Path(raw)) - - archived = root / "archived" - archived_ckpt = archived / "checkpoints" / "step-10.ckpt" - archived_ckpt.parent.mkdir(parents=True) - archived_ckpt.write_text("archived") - write_complete(archived, archived_ckpt, 10) - - pending = root / "pending" - pending_ckpt = pending / "checkpoints" / "step-20.ckpt" - pending_ckpt.parent.mkdir(parents=True) - pending_ckpt.write_text("pending") - write_complete(pending, pending_ckpt, 20) - - unknown = root / "unknown" - unknown_ckpt = unknown / "checkpoints" / "step-30.ckpt" - unknown_ckpt.parent.mkdir(parents=True) - unknown_ckpt.write_text("unknown") - - manifest = Path(raw) / "manifest.json" - manifest.write_text( - json.dumps( - { - "schema_version": 1, - "runs": [ - { - "id": "archived", - "local_root": str(archived), - "checkpoint_glob": "checkpoints/*.ckpt", - "validator": str(validator), - "remote_dir": "/remote/archive/archived", - }, - { - "id": "pending", - "local_root": str(pending), - "checkpoint_glob": "checkpoints/*.ckpt", - "validator": str(validator), - "remote_dir": "/remote/archive/pending", - }, - ], - } - ) - ) - (state_dir / "mirror-state.json").write_text( - json.dumps( - { - "schema_version": 2, - "files": { - str(archived_ckpt.resolve()): { - "run_id": "archived", - "remote_verified": True, - "global_step": 10, - "sha256": hashlib.sha256( - archived_ckpt.read_bytes() - ).hexdigest(), - } - }, - } - ) - ) - output = state_dir / "archive-inventory.json" - code = INVENTORY.main( - [ - "--search-root", - str(root), - "--manifest", - str(manifest), - "--state-dir", - str(state_dir), - "--output", - str(output), - "--require-clear", - ] - ) - self.assertEqual(code, 1) - report = json.loads(output.read_text()) - statuses = { - Path(row["run_root"]).name: row["status"] for row in report["runs"] - } - self.assertEqual(statuses["archived"], "complete_archived") - self.assertEqual(statuses["pending"], "complete_needs_transfer") - self.assertEqual(statuses["unknown"], "unregistered") - - def test_slurm_like_metadata_does_not_imply_completion(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) / "scratch" / "campaign" - run = root / "run" - checkpoint = run / "checkpoints" / "step.ckpt" - checkpoint.parent.mkdir(parents=True) - checkpoint.write_text("checkpoint") - (run / "slurm-COMPLETED.txt").write_text("COMPLETED") - report = INVENTORY.build_inventory( - root, - {"schema_version": 1, "runs": []}, - {"schema_version": 2, "files": {}}, - 4, - ) - self.assertFalse(report["runs"][0]["complete"]) - - def test_completed_run_with_missing_checkpoint_is_not_invisible(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) / "scratch" / "campaign" - run = root / "run" - run.mkdir(parents=True) - (run / "COMPLETE.json").write_text( - json.dumps( - { - "schema_version": 1, - "status": "COMPLETE", - "checkpoint": {"global_step": 10, "sha256": "a" * 64}, - } - ) - ) - report = INVENTORY.build_inventory( - root, - {"schema_version": 1, "runs": []}, - {"schema_version": 2, "files": {}}, - 4, - ) - self.assertEqual(report["runs"][0]["status"], "unregistered") - self.assertTrue(report["runs"][0]["complete"]) - self.assertEqual(report["runs"][0]["checkpoint_count"], 0) - - def test_registered_complete_run_with_missing_checkpoint_requires_attention(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) / "scratch" / "campaign" - run = root / "run" - run.mkdir(parents=True) - sentinel = run / "COMPLETE.json" - sentinel.write_text( - json.dumps( - { - "schema_version": 1, - "status": "COMPLETE", - "checkpoint": {"global_step": 10, "sha256": "a" * 64}, - } - ) - ) - report = INVENTORY.build_inventory( - root, - { - "schema_version": 1, - "runs": [ - { - "id": "run", - "local_root": str(run), - "checkpoint_glob": "checkpoints/*.ckpt", - "validator": "/unused/by_inventory", - "remote_dir": "/remote/archive/run", - "completion_sentinel": str(sentinel), - } - ], - }, - {"schema_version": 2, "files": {}}, - 4, - ) - self.assertEqual( - report["runs"][0]["status"], "complete_missing_checkpoints" - ) - self.assertEqual(report["attention_required"], [str(run.resolve())]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ice_checkpoint_mirror.py b/tests/test_ice_checkpoint_mirror.py deleted file mode 100644 index 88f69ee049..0000000000 --- a/tests/test_ice_checkpoint_mirror.py +++ /dev/null @@ -1,420 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import hashlib -import importlib.util -import json -import os -import subprocess -import sys -import tempfile -import time -import unittest -from pathlib import Path -from unittest import mock - -MODULE_PATH = Path(__file__).parents[1] / "scripts" / "ice" / "ice_checkpoint_mirror.py" -SBATCH_PATH = ( - Path(__file__).parents[1] / "scripts" / "ice" / "ice_checkpoint_mirror.sbatch" -) -SPEC = importlib.util.spec_from_file_location("ice_checkpoint_mirror", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -assert SPEC.loader is not None -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -def make_validator(root: Path) -> Path: - validator = root / "validate_checkpoint.py" - validator.write_text( - "#!/usr/bin/env python3\n" - "import json,sys\n" - "try:\n" - " data=json.load(open(sys.argv[1]))\n" - "except Exception:\n" - " raise SystemExit(2)\n" - "if not data.get('valid', True): raise SystemExit(3)\n" - "print(json.dumps({'global_step':data['global_step'],'run_id':data.get('run_id','run')}))\n" - ) - validator.chmod(0o700) - return validator - - -def write_checkpoint(path: Path, step: int, *, valid: bool = True) -> None: - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text( - json.dumps({"global_step": step, "valid": valid, "run_id": "run"}) - ) - os.replace(temporary, path) - - -def write_manifest( - path: Path, - run: Path, - validator: Path, - *, - retain: int = 2, - sentinel: Path | None = None, -) -> None: - row = { - "id": "run", - "local_root": str(run), - "checkpoint_glob": "*.ckpt", - "validator": str(validator), - "remote_dir": "/remote/archive/run", - "retain_local": retain, - } - if sentinel is not None: - row["completion_sentinel"] = str(sentinel) - path.write_text(json.dumps({"schema_version": 1, "runs": [row]})) - - -def mirror_args(manifest: Path, state: Path, scratch: Path) -> list[str]: - return [ - "--manifest", - str(manifest), - "--state-dir", - str(state), - "--scratch-root", - str(scratch), - "--quota-bytes", - "1000000", - "--remote-host", - "Skynet", - "--prune-after-verify", - "--once", - ] - - -class CheckpointMetadataTest(unittest.TestCase): - def test_cpu_wrapper_forwards_configured_du_timeout(self): - wrapper = SBATCH_PATH.read_text() - self.assertIn('"${ICE_MIRROR_PYTHON}" "${ICE_MIRROR_SCRIPT}"', wrapper) - self.assertIn( - 'export PATH="$(dirname "${ICE_MIRROR_PYTHON}"):${PATH}"', wrapper - ) - self.assertIn( - 'export PYTHONPATH="${ICE_MIRROR_PYTHONPATH}${PYTHONPATH:+:${PYTHONPATH}}"', - wrapper, - ) - self.assertIn('if [[ -n "${ICE_MIRROR_DU_TIMEOUT_SECONDS:-}" ]]', wrapper) - self.assertIn( - 'ARGS+=(--du-timeout-seconds "${ICE_MIRROR_DU_TIMEOUT_SECONDS}")', - wrapper, - ) - - def test_stable_validation_requires_json_global_step(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - checkpoint = root / "x.ckpt" - write_checkpoint(checkpoint, 7) - validator = make_validator(root) - info = MODULE.stable_valid_info(checkpoint.resolve(), validator) - self.assertIsNotNone(info) - self.assertEqual(info.global_step, 7) - self.assertEqual( - info.sha256, hashlib.sha256(checkpoint.read_bytes()).hexdigest() - ) - bad_validator = root / "bad_validator" - bad_validator.write_text("#!/bin/sh\nprintf 'not-json\\n'\n") - bad_validator.chmod(0o700) - rejection = {} - self.assertIsNone( - MODULE.stable_valid_info( - checkpoint.resolve(), - bad_validator, - rejection=rejection, - ) - ) - self.assertEqual( - rejection["reason"], "invalid_metadata_or_unreadable_checkpoint" - ) - - def test_validator_failure_preserves_bounded_stderr_and_returncode(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - checkpoint = root / "x.ckpt" - write_checkpoint(checkpoint, 7) - validator = root / "bad_validator" - validator.write_text( - "#!/bin/sh\nprintf '%s' '" - + "x" * 3000 - + "ModuleNotFoundError: No module named torch' >&2\nexit 9\n" - ) - validator.chmod(0o700) - rejection = {} - self.assertIsNone( - MODULE.stable_valid_info(checkpoint, validator, rejection=rejection) - ) - self.assertEqual(rejection["reason"], "validator_failed") - self.assertEqual(rejection["validator_returncode"], 9) - self.assertEqual(len(rejection["validator_stderr_tail"]), 2048) - self.assertIn("No module named torch", rejection["validator_stderr_tail"]) - self.assertTrue(checkpoint.exists()) - - def test_changing_checkpoint_remains_deferred_with_explicit_reason(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - checkpoint = root / "x.ckpt" - write_checkpoint(checkpoint, 7) - validator = make_validator(root) - - def changing_validator(*args, **kwargs): - checkpoint.write_text(json.dumps({"global_step": 777777})) - return subprocess.CompletedProcess(args[0], 0, '{"global_step": 7}', "") - - rejection = {} - with mock.patch.object( - MODULE.subprocess, "run", side_effect=changing_validator - ): - self.assertIsNone( - MODULE.stable_valid_info(checkpoint, validator, rejection=rejection) - ) - self.assertEqual( - rejection, {"reason": "checkpoint_changed_during_validation"} - ) - self.assertTrue(checkpoint.exists()) - - def test_specific_absolute_rejects_relative_path(self): - with self.assertRaisesRegex(SystemExit, "specific absolute"): - MODULE.specific_absolute(Path("relative/run/path"), "test") - - -class MirrorSafetyTest(unittest.TestCase): - def test_invalid_newer_files_do_not_count_toward_two_valid_retained(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - scratch = root / "scratch" - run = scratch / "project" / "run" - state = scratch / "state" / "mirror" - run.mkdir(parents=True) - state.mkdir(parents=True) - validator = make_validator(root) - first = run / "step-1.ckpt" - second = run / "step-2.ckpt" - corrupt_a = run / "step-100.ckpt" - corrupt_b = run / "step-101.ckpt" - write_checkpoint(first, 1) - write_checkpoint(second, 2) - write_checkpoint(corrupt_a, 100, valid=False) - write_checkpoint(corrupt_b, 101, valid=False) - now = time.time() - os.utime(corrupt_a, (now + 10, now + 10)) - os.utime(corrupt_b, (now + 20, now + 20)) - manifest = root / "manifest.json" - write_manifest(manifest, run, validator) - remote: dict[str, str] = {} - - def fake_mirror(path, digest, remote_dir, host, ssh, rsync, rsync_rsh): - destination = f"/remote/{digest}" - remote[destination] = digest - return destination - - with ( - mock.patch.object(MODULE, "mirror_one", side_effect=fake_mirror), - mock.patch.object( - MODULE, - "remote_sha", - side_effect=lambda ssh, host, path: remote.get(path), - ), - mock.patch.object(MODULE, "scratch_bytes", return_value=100), - ): - code = MODULE.main(mirror_args(manifest, state, scratch)) - self.assertEqual( - code, 1, "invalid candidates must keep completion unresolved" - ) - self.assertTrue(first.exists()) - self.assertTrue(second.exists()) - self.assertTrue(corrupt_a.exists()) - self.assertTrue(corrupt_b.exists()) - pressure = json.loads((state / "storage-pressure.json").read_text()) - self.assertEqual(pressure["cycle_errors"], 2) - rejected = [ - json.loads(line) - for line in (state / "mirror-events.jsonl").read_text().splitlines() - if json.loads(line)["event"] == "checkpoint_not_stably_valid" - ] - self.assertEqual(len(rejected), 2) - self.assertTrue( - all(event["validator_returncode"] == 3 for event in rejected) - ) - self.assertTrue( - all(event["reason"] == "validator_failed" for event in rejected) - ) - - def test_prune_rechecks_remote_sha_immediately(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - scratch = root / "scratch" - run = scratch / "project" / "run" - state = scratch / "state" / "mirror" - run.mkdir(parents=True) - state.mkdir(parents=True) - validator = make_validator(root) - checkpoints = [] - for step in (1, 2, 3): - path = run / f"step-{step}.ckpt" - write_checkpoint(path, step) - checkpoints.append(path) - manifest = root / "manifest.json" - write_manifest(manifest, run, validator) - files = {} - remote = {} - for path in checkpoints: - digest = hashlib.sha256(path.read_bytes()).hexdigest() - remote_path = f"/remote/{digest}" - files[str(path.resolve())] = { - "sha256": digest, - "run_id": "run", - "remote_path": remote_path, - "remote_verified": True, - } - remote[remote_path] = digest - (state / "mirror-state.json").write_text( - json.dumps({"schema_version": 1, "files": files}) - ) - oldest_remote = files[str(checkpoints[0].resolve())]["remote_path"] - calls = {oldest_remote: 0} - - def fake_remote_sha(ssh, host, path): - if path == oldest_remote: - calls[path] += 1 - return remote[path] if calls[path] == 1 else None - return remote.get(path) - - with ( - mock.patch.object( - MODULE, - "mirror_one", - side_effect=AssertionError("no upload expected"), - ), - mock.patch.object(MODULE, "remote_sha", side_effect=fake_remote_sha), - mock.patch.object(MODULE, "scratch_bytes", return_value=100), - ): - code = MODULE.main(mirror_args(manifest, state, scratch)) - self.assertEqual(code, 1) - self.assertTrue( - checkpoints[0].exists(), - "local file was pruned after remote disappeared", - ) - final_state = json.loads((state / "mirror-state.json").read_text()) - self.assertFalse( - final_state["files"][str(checkpoints[0].resolve())]["remote_verified"] - ) - - def test_du_failure_is_recorded_without_uncaught_exit(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - scratch = root / "scratch" - run = scratch / "project" / "run" - state = scratch / "state" / "mirror" - run.mkdir(parents=True) - state.mkdir(parents=True) - validator = make_validator(root) - manifest = root / "manifest.json" - write_manifest(manifest, run, validator) - with mock.patch.object( - MODULE, "scratch_bytes", side_effect=RuntimeError("du failed") - ): - code = MODULE.main(mirror_args(manifest, state, scratch)) - self.assertEqual(code, 1) - pressure = json.loads((state / "storage-pressure.json").read_text()) - self.assertEqual(pressure["pressure"], "unknown") - self.assertIn("du failed", pressure["error"]) - - def test_deterministic_resumable_temp_and_full_digest_name(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - checkpoint = root / "step-9.ckpt" - checkpoint.write_bytes(b"checkpoint") - digest = hashlib.sha256(checkpoint.read_bytes()).hexdigest() - remote_commands = [] - local_commands = [] - sha_results = iter((None, digest, digest)) - - def fake_remote_command(ssh, host, argv, capture=False): - remote_commands.append(argv) - return "" - - def fake_run_checked(command, capture=False, timeout=None): - local_commands.append(command) - return subprocess.CompletedProcess(command, 0, "", "") - - with ( - mock.patch.object( - MODULE, "remote_sha", side_effect=lambda *args: next(sha_results) - ), - mock.patch.object( - MODULE, "remote_command", side_effect=fake_remote_command - ), - mock.patch.object(MODULE, "run_checked", side_effect=fake_run_checked), - ): - final = MODULE.mirror_one( - checkpoint, - digest, - "/remote/archive/run", - "Skynet", - "ssh", - "rsync", - None, - ) - self.assertIn(digest, final) - self.assertEqual(local_commands[0][-1], f"Skynet:{final}.partial") - self.assertNotRegex(local_commands[0][-1], r"\.partial\.\d+$") - self.assertIn( - ["mv", "-n", "--", f"{final}.partial", final], remote_commands - ) - - def test_completion_sentinel_stops_monitor_after_final_mirror(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - scratch = root / "scratch" - run = scratch / "project" / "run" - state = scratch / "state" / "mirror" - run.mkdir(parents=True) - state.mkdir(parents=True) - validator = make_validator(root) - checkpoint = run / "step-10.ckpt" - write_checkpoint(checkpoint, 10) - sentinel = run / "COMPLETE.json" - sentinel.write_text( - json.dumps( - { - "schema_version": 1, - "status": "COMPLETE", - "checkpoint": { - "global_step": 10, - "sha256": hashlib.sha256( - checkpoint.read_bytes() - ).hexdigest(), - }, - } - ) - ) - manifest = root / "manifest.json" - write_manifest(manifest, run, validator, sentinel=sentinel) - remote = {} - - def fake_mirror(path, digest, remote_dir, host, ssh, rsync, rsync_rsh): - destination = f"/remote/{digest}" - remote[destination] = digest - return destination - - with ( - mock.patch.object(MODULE, "mirror_one", side_effect=fake_mirror), - mock.patch.object( - MODULE, - "remote_sha", - side_effect=lambda ssh, host, path: remote.get(path), - ), - mock.patch.object(MODULE, "scratch_bytes", return_value=100), - ): - code = MODULE.main(mirror_args(manifest, state, scratch)) - self.assertEqual(code, 0) - complete = json.loads((state / "mirror-complete.json").read_text()) - self.assertEqual(complete["status"], "COMPLETE") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ice_checkpoint_mirror_pool.py b/tests/test_ice_checkpoint_mirror_pool.py deleted file mode 100644 index beed452522..0000000000 --- a/tests/test_ice_checkpoint_mirror_pool.py +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import hashlib -import importlib.util -import json -import sys -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -ICE_DIR = Path(__file__).parents[1] / "scripts" / "ice" -POOL_PATH = ICE_DIR / "ice_checkpoint_mirror_pool.py" -SBATCH_PATH = ICE_DIR / "ice_checkpoint_mirror_pool.sbatch" -sys.path.insert(0, str(ICE_DIR)) -SPEC = importlib.util.spec_from_file_location("ice_checkpoint_mirror_pool", POOL_PATH) -POOL = importlib.util.module_from_spec(SPEC) -assert SPEC.loader is not None -sys.modules[SPEC.name] = POOL -SPEC.loader.exec_module(POOL) - - -def make_validator(root: Path) -> Path: - validator = root / "validate_checkpoint.py" - validator.write_text( - "#!/usr/bin/env python3\n" - "import json,sys\n" - "data=json.load(open(sys.argv[1]))\n" - "print(json.dumps({'global_step':data['global_step']}))\n" - ) - validator.chmod(0o700) - return validator - - -def make_layout(root: Path) -> tuple[Path, Path, Path, Path, Path]: - scratch = root / "scratch" - run = scratch / "project" / "run" - state = scratch / "state" / "mirror" - run.mkdir(parents=True) - state.mkdir(parents=True) - validator = make_validator(root) - checkpoint = run / "step-10.ckpt" - checkpoint.write_text(json.dumps({"global_step": 10})) - sentinel = run / "COMPLETE.json" - sentinel.write_text( - json.dumps( - { - "schema_version": 1, - "status": "COMPLETE", - "checkpoint": { - "global_step": 10, - "sha256": hashlib.sha256(checkpoint.read_bytes()).hexdigest(), - }, - } - ) - ) - manifest = root / "manifest.json" - manifest.write_text( - json.dumps( - { - "schema_version": 1, - "runs": [ - { - "id": "run", - "local_root": str(run), - "checkpoint_glob": "*.ckpt", - "validator": str(validator), - "remote_dir": "/remote/archive/run", - "retain_local": 2, - "completion_sentinel": str(sentinel), - } - ], - } - ) - ) - return scratch, run, state, checkpoint, manifest - - -def worker_args( - manifest: Path, state: Path, scratch: Path, index: int = 0 -) -> list[str]: - return [ - "--manifest", - str(manifest), - "--state-dir", - str(state), - "--scratch-root", - str(scratch), - "--quota-bytes", - "1000000", - "--remote-host", - "Skynet", - "--worker-index", - str(index), - "--worker-count", - "2", - "--once", - ] - - -class MirrorPoolTest(unittest.TestCase): - def test_wrapper_requires_matching_array_and_distinct_nodes(self): - wrapper = SBATCH_PATH.read_text() - self.assertIn("#SBATCH --exclusive", wrapper) - self.assertIn("SLURM_ARRAY_TASK_COUNT", wrapper) - self.assertIn("SLURM_ARRAY_TASK_ID", wrapper) - self.assertIn("ICE_MIRROR_POOL_SIZE", wrapper) - self.assertIn('"${ICE_MIRROR_PYTHON}" "${ICE_MIRROR_POOL_SCRIPT}"', wrapper) - self.assertIn( - 'export PATH="$(dirname "${ICE_MIRROR_PYTHON}"):${PATH}"', wrapper - ) - self.assertIn( - 'export PYTHONPATH="${ICE_MIRROR_PYTHONPATH}${PYTHONPATH:+:${PYTHONPATH}}"', - wrapper, - ) - self.assertIn("ICE_MIRROR_INVENTORY_ROOT", wrapper) - - def test_nonblocking_claim_allows_only_one_worker(self): - with tempfile.TemporaryDirectory() as raw: - state = Path(raw) / "shared" / "state" - checkpoint = Path(raw) / "shared" / "run" / "step.ckpt" - checkpoint.parent.mkdir(parents=True) - checkpoint.touch() - with POOL.claim(state, checkpoint) as first: - self.assertIsNotNone(first) - with POOL.claim(state, checkpoint) as second: - self.assertIsNone(second) - with POOL.claim(state, checkpoint) as third: - self.assertIsNotNone(third) - - def test_pool_mirrors_and_publishes_completion(self): - with tempfile.TemporaryDirectory() as raw: - scratch, _run, state, checkpoint, manifest = make_layout(Path(raw)) - remote: dict[str, str] = {} - - def fake_mirror( - path, - digest, - remote_dir, - host, - ssh, - rsync, - rsync_rsh, - temporary_tag=None, - ): - self.assertEqual(temporary_tag, POOL.task_key(path)[:24]) - destination = f"/remote/{digest}" - remote[destination] = digest - return destination - - with ( - mock.patch.object(POOL.core, "mirror_one", side_effect=fake_mirror), - mock.patch.object( - POOL.core, - "remote_sha", - side_effect=lambda ssh, host, path: remote.get(path), - ), - mock.patch.object(POOL.core, "scratch_bytes", return_value=100), - ): - code = POOL.main(worker_args(manifest, state, scratch)) - self.assertEqual(code, 0) - saved = json.loads((state / "mirror-state.json").read_text()) - entry = saved["files"][str(checkpoint.resolve())] - self.assertTrue(entry["remote_verified"]) - complete = json.loads((state / "mirror-complete.json").read_text()) - self.assertEqual(complete["worker_count"], 2) - - def test_nonmaintenance_worker_never_prunes_or_completes(self): - with tempfile.TemporaryDirectory() as raw: - scratch, _run, state, _checkpoint, manifest = make_layout(Path(raw)) - remote: dict[str, str] = {} - - def fake_mirror( - path, - digest, - remote_dir, - host, - ssh, - rsync, - rsync_rsh, - temporary_tag=None, - ): - destination = f"/remote/{digest}" - remote[destination] = digest - return destination - - with ( - mock.patch.object(POOL.core, "mirror_one", side_effect=fake_mirror), - mock.patch.object( - POOL.core, - "remote_sha", - side_effect=lambda ssh, host, path: remote.get(path), - ), - mock.patch.object( - POOL, - "maintain", - side_effect=AssertionError("worker one ran maintenance"), - ), - ): - code = POOL.main(worker_args(manifest, state, scratch, index=1)) - self.assertEqual(code, 0) - self.assertFalse((state / "mirror-complete.json").exists()) - - def test_rejected_candidate_is_reported_and_blocks_completion(self): - for worker_index in (0, 1): - with ( - self.subTest(worker_index=worker_index), - tempfile.TemporaryDirectory() as raw, - ): - scratch, run, state, checkpoint, manifest = make_layout(Path(raw)) - invalid = run / "step-100.ckpt" - invalid.write_text("incomplete checkpoint") - remote: dict[str, str] = {} - - def fake_mirror(path, digest, *args, **kwargs): - destination = f"/remote/{digest}" - remote[destination] = digest - return destination - - with ( - mock.patch.object(POOL.core, "mirror_one", side_effect=fake_mirror), - mock.patch.object( - POOL.core, - "remote_sha", - side_effect=lambda ssh, host, path: remote.get(path), - ), - mock.patch.object(POOL.core, "scratch_bytes", return_value=100), - ): - code = POOL.main( - worker_args(manifest, state, scratch, index=worker_index) - ) - self.assertEqual(code, 1) - self.assertTrue(checkpoint.exists()) - self.assertTrue(invalid.exists()) - self.assertFalse((state / "mirror-complete.json").exists()) - status = json.loads( - (state / f"worker-{worker_index}-status.json").read_text() - ) - self.assertEqual(status["cycle_errors"], 1) - events = [ - json.loads(line) - for line in (state / "mirror-events.jsonl").read_text().splitlines() - ] - rejected = [ - event - for event in events - if event["event"] == "checkpoint_not_stably_valid" - ] - self.assertEqual(len(rejected), 1) - self.assertEqual(rejected[0]["reason"], "validator_failed") - self.assertEqual(rejected[0]["validator_returncode"], 1) - self.assertIn("JSONDecodeError", rejected[0]["validator_stderr_tail"]) - if worker_index == 0: - pressure = json.loads((state / "storage-pressure.json").read_text()) - self.assertEqual(pressure["cycle_errors"], 1) - - def test_completion_requires_exact_terminal_checkpoint(self): - with tempfile.TemporaryDirectory() as raw: - scratch, run, state, checkpoint, manifest = make_layout(Path(raw)) - sentinel = run / "COMPLETE.json" - payload = json.loads(sentinel.read_text()) - payload["checkpoint"]["global_step"] = 999 - payload["checkpoint"]["sha256"] = "a" * 64 - sentinel.write_text(json.dumps(payload)) - remote: dict[str, str] = {} - - def fake_mirror( - path, - digest, - remote_dir, - host, - ssh, - rsync, - rsync_rsh, - temporary_tag=None, - ): - destination = f"/remote/{digest}" - remote[destination] = digest - return destination - - with ( - mock.patch.object(POOL.core, "mirror_one", side_effect=fake_mirror), - mock.patch.object( - POOL.core, - "remote_sha", - side_effect=lambda ssh, host, path: remote.get(path), - ), - mock.patch.object(POOL.core, "scratch_bytes", return_value=100), - ): - code = POOL.main(worker_args(manifest, state, scratch)) - self.assertEqual(code, 0) - self.assertFalse((state / "mirror-complete.json").exists()) - saved = json.loads((state / "mirror-state.json").read_text()) - self.assertTrue( - saved["files"][str(checkpoint.resolve())]["remote_verified"] - ) - - def test_worker_count_and_index_are_fail_closed(self): - with tempfile.TemporaryDirectory() as raw: - scratch, _run, state, _checkpoint, manifest = make_layout(Path(raw)) - args = worker_args(manifest, state, scratch) - args[args.index("--worker-count") + 1] = "1" - with self.assertRaisesRegex(SystemExit, "worker-count"): - POOL.main(args) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ice_dp_campaign_contract.py b/tests/test_ice_dp_campaign_contract.py deleted file mode 100644 index c8e40ffc01..0000000000 --- a/tests/test_ice_dp_campaign_contract.py +++ /dev/null @@ -1,28 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).parents[1] -LAUNCHER = ROOT / "scripts/ice/launch_planar_v2_dp_cotrain_clean.sbatch" -README = ROOT / "scripts/ice/README.md" - - -def test_dp_ice_launcher_has_one_entrypoint_and_fresh_run_guards(): - text = LAUNCHER.read_text() - assert "MODE=${MODE:-preflight}" in text - assert 'case "$MODE" in preflight|smoke|full)' in text - assert "ckpt_path=null" in text - assert 'test -z "${ICE_INITIAL_CHECKPOINT:-}"' in text - assert 'test -z "${ICE_INITIAL_CHECKPOINT_SHA256:-}"' in text - assert 'test -z "${CKPT_PATH:-}"' in text - assert "PASSED_SMOKE_DIR" in text - assert "trainer.max_steps=$max_steps" in text - assert "FULL_STEPS=240000" in text - assert "VALIDATE_EVERY=10000" in text - assert "CHECKPOINT_EVERY=20000" in text - assert "NVIDIA H100 80GB HBM3" in text and "NVIDIA H200" in text - - -def test_dp_ice_docs_distinguish_smoke_full_and_requeue_from_restore(): - text = README.read_text() - assert "Do not infer full-run completion from a completed smoke" in text - assert "scheduler-generated\nHPC checkpoint" in text - assert "H100 and H200 are\ninterchangeable" in text diff --git a/tests/test_ice_gpu_probe.py b/tests/test_ice_gpu_probe.py deleted file mode 100644 index b3fb1b4fec..0000000000 --- a/tests/test_ice_gpu_probe.py +++ /dev/null @@ -1,50 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - -MODULE_PATH = Path(__file__).parents[1] / "scripts" / "ice" / "ice_gpu_probe.py" -SPEC = importlib.util.spec_from_file_location("ice_gpu_probe", MODULE_PATH) -assert SPEC is not None and SPEC.loader is not None -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -def test_parse_ecc_health_accepts_zero_counters_and_clear_flags(): - report = """ - Volatile Uncorrectable ECC : 0 - Aggregate Uncorrectable ECC : 0 - Pending Page Blacklist : No - Reset Required : No - Remapping Failure Occurred : No - """ - - health = MODULE.parse_ecc_health(report) - - assert len(health["uncorrectable_ecc_counters"]) == 2 - assert health["unhealthy_flags"] == [] - - -@pytest.mark.parametrize( - "report", - ( - "Volatile Uncorrectable ECC : 1\nReset Required : No\n", - "Volatile Uncorrectable ECC : 0\nReset Required : Yes\n", - "Reset Required : No\n", - "Volatile Uncorrectable ECC : unknown\nReset Required : No\n", - ), -) -def test_parse_ecc_health_fails_closed(report): - with pytest.raises(RuntimeError): - MODULE.parse_ecc_health(report) - - -def test_probe_exercises_backward_and_one_rank_nccl_lifecycle(): - text = MODULE_PATH.read_text() - - assert "loss.backward()" in text - assert "dist.init_process_group(" in text - assert 'backend="nccl"' in text - assert "dist.all_reduce(collective)" in text - assert "dist.destroy_process_group()" in text - assert '"world_size": world_size' in text diff --git a/tests/test_ice_planar_bc_launcher.py b/tests/test_ice_planar_bc_launcher.py deleted file mode 100644 index ea169aefc2..0000000000 --- a/tests/test_ice_planar_bc_launcher.py +++ /dev/null @@ -1,110 +0,0 @@ -import re -import subprocess -from pathlib import Path - -ROOT = Path(__file__).parents[1] -LAUNCHER = ROOT / "scripts" / "ice" / "launch_planar_bc.sbatch" - - -def test_launcher_is_valid_bash_and_has_single_gpu_requeue_contract(): - subprocess.run(["bash", "-n", str(LAUNCHER)], check=True) - text = LAUNCHER.read_text() - - assert "#SBATCH --account=coc" in text - assert "#SBATCH --partition=ice-gpu" in text - assert "#SBATCH --qos=coc-ice" in text - assert "#SBATCH --nodes=1" in text - assert "#SBATCH --ntasks-per-node=1" in text - assert "#SBATCH --gres=gpu:1" in text - assert "#SBATCH --constraint=H100|H200" in text - assert "#SBATCH --requeue" in text - assert "#SBATCH --signal=B:USR1@600" in text - assert "trainer.strategy=auto" in text - assert "trainer.devices=1" in text - assert "trainer.num_nodes=1" in text - assert "trainer.precision=$ICE_PRECISION" in text - assert "ICE_EXPECTED_GPU_NAME" not in text - assert text.count("--allowed-gpu-name") == 2 - - -def test_launcher_uses_strict_external_runner_and_completion_sentinel(): - text = LAUNCHER.read_text() - - assert "runtime.slurm_requeue_owner=runner" in text - assert "runtime.slurm_save_signal=SIGUSR2" in text - assert "--checkpoint-validator" in text - assert "--checkpoint-signal USR2" in text - assert "--checkpoint-forwarding slurm-steps" in text - assert "--requeue-owner runner" in text - assert "--confirm-child-requeue-disabled" in text - assert "ICE_OUTPUT_DIR/COMPLETE.json" in text - assert "allow-unvalidated" not in text - assert "ICE_RESUME_CHECKPOINT" in text - assert "ICE_RESUME_CHECKPOINT_SHA256" in text - assert "ICE_RESUME_GLOBAL_STEP" in text - - -def test_parent_runner_inherits_exact_repo_pythonpath_for_checkpoint_validation(): - text = LAUNCHER.read_text() - - parent_export = text.index("export PYTHONPATH=$ICE_REPO") - preflight_runner = text.index('"$ICE_PYTHON" "$ICE_REQUEUE_RUNNER"') - final_runner = text.rindex('exec "$ICE_PYTHON" "$ICE_REQUEUE_RUNNER"') - child_definition = text.index("read -r -d '' CHILD_CODE") - - assert parent_export < preflight_runner < child_definition < final_runner - - -def test_launcher_exposes_supported_bc_configs_and_explicit_training_limits(): - text = LAUNCHER.read_text() - - assert "pusht/planar_v2_usocket_direct_bc" in text - assert "pusht/planar_v2_usocket_arc_bc" in text - assert "pusht/planar_v2_usocket_arc_hybrid_D40_M16_R24deg_bc" in text - assert "pusht/planar_v2_usocket_arc_paper_uniform_D40_M16_R24deg" in text - assert "ICE_VALIDATION_DEFERRED" in text - assert "ICE_LIMIT_VAL_BATCHES must be zero when validation is deferred" in text - assert '"++run_provenance.validation_deferred=$ICE_VALIDATION_DEFERRED"' in text - assert ( - text.count("data.train_datasets.pushshapes_sim_u_socket.resolver.folder_path=") - == 1 - ) - assert ( - text.count("data.valid_datasets.pushshapes_sim_u_socket.resolver.folder_path=") - == 1 - ) - for value in ( - "trainer.max_steps=$ICE_MAX_STEPS", - "trainer.val_check_interval=$ICE_VAL_CHECK_INTERVAL", - "trainer.limit_train_batches=$ICE_LIMIT_TRAIN_BATCHES", - "trainer.limit_val_batches=$ICE_LIMIT_VAL_BATCHES", - "+callbacks.model_checkpoint.every_n_train_steps=$ICE_CHECKPOINT_EVERY_N_STEPS", - ): - assert value in text - assert "unique ICE_OUTPUT_DIR already exists" in text - assert "ICE_LAUNCH_MODE" in text - assert "--dry-run" in text - assert "validate_planar_dataset.py" in text - assert "ICE_EXPECTED_SPLIT_MANIFEST_SHA256" in text - assert "--expected-manifest-sha256" in text - assert '"++paths.root_dir=$ICE_REPO"' in text - assert '"norm_stats.save_cache_dir=$ICE_OUTPUT_DIR"' in text - assert '"norm_stats.save_cache_dir=$ICE_OUTPUT_DIR/norm_stats"' not in text - assert "$ICE_OUTPUT_DIR/norm_stats/norm_stats.json" in text - assert "requeue requires the original run's norm_stats/norm_stats.json" in text - assert "norm_stats.save_cache_dir=null" in text - - -def test_launcher_contains_no_deployment_identity_or_credentials(): - text = LAUNCHER.read_text() - - forbidden = ( - "/coc/", - "/storage/", - "paphiwetsa3", - "WANDB_API_KEY=", - "wandb.ai/", - ) - assert not any(value in text for value in forbidden) - assert re.search(r"\b[0-9]{7}\b", text) is None - assert "BASH_SOURCE" not in text diff --git a/tests/test_ice_requeue_runner.py b/tests/test_ice_requeue_runner.py deleted file mode 100644 index c652e216f3..0000000000 --- a/tests/test_ice_requeue_runner.py +++ /dev/null @@ -1,1476 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import fcntl -import hashlib -import importlib.util -import json -import os -import shlex -import signal -import subprocess -import sys -import tempfile -import time -import unittest -from pathlib import Path -from unittest import mock - -MODULE_PATH = Path(__file__).parents[1] / "scripts" / "ice" / "ice_requeue_runner.py" -SPEC = importlib.util.spec_from_file_location("ice_requeue_runner", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -assert SPEC.loader is not None -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -def make_validator(root: Path) -> Path: - validator = root / "validate_checkpoint.py" - validator.write_text( - "#!/usr/bin/env python3\n" - "import json,os,sys\n" - "log=os.environ.get('VALIDATOR_LOG')\n" - "if log:\n" - " with open(log,'a') as stream: stream.write(os.path.realpath(sys.argv[1])+'\\n')\n" - "try:\n" - " data=json.load(open(sys.argv[1]))\n" - "except Exception:\n" - " raise SystemExit(2)\n" - "if not data.get('valid', True): raise SystemExit(3)\n" - "metadata={'global_step': data['global_step']}\n" - "for key in ('run_id','wandb_run_id','config_sha256','source_commit'):\n" - " if key in data: metadata[key]=data[key]\n" - "print(json.dumps(metadata, sort_keys=True))\n" - ) - validator.chmod(0o700) - return validator - - -def write_checkpoint( - path: Path, step: int, *, valid: bool = True, run_id: str = "run" -) -> None: - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text( - json.dumps( - { - "global_step": step, - "valid": valid, - "run_id": run_id, - "wandb_run_id": "wandb-run", - } - ) - ) - os.replace(temporary, path) - - -def make_signaling_scancel(root: Path) -> Path: - executable = root / "scancel" - executable.write_text( - "#!/bin/sh\n" - 'printf \'%s\\n\' "$*" > "$ICE_TEST_SCANCEL_LOG"\n' - 'kill -USR2 "$(cat "$CHILD_PID_FILE")"\n' - ) - executable.chmod(0o700) - return executable - - -def base_args( - state_dir: Path, - checkpoint_glob: str, - validator: Path, - command: list[str], - *, - owner: str = "child", - initial_checkpoint: Path | None = None, - scancel: Path | str = "/usr/bin/true", -) -> list[str]: - result = [ - "--state-dir", - str(state_dir), - ] - if initial_checkpoint is not None: - result.extend(["--initial-checkpoint", str(initial_checkpoint)]) - result.extend( - [ - "--checkpoint-glob", - checkpoint_glob, - "--checkpoint-validator", - str(validator), - "--checkpoint-signal", - "USR2", - "--checkpoint-forwarding", - "slurm-steps", - "--requeue-owner", - owner, - "--scancel", - str(scancel), - "--poll-seconds", - "0.05", - ] - ) - if owner == "runner": - result.append("--confirm-child-requeue-disabled") - result.extend(["--", *command]) - return result - - -class CheckpointSelectionTest(unittest.TestCase): - def test_semantic_global_step_beats_newer_mtime(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - validator = make_validator(root) - step_60 = root / "epoch-6-step-60000.ckpt" - step_40 = root / "staged-step-40000.ckpt" - write_checkpoint(step_60, 60_000) - write_checkpoint(step_40, 40_000) - now = time.time() - os.utime(step_60, (now - 20, now - 20)) - os.utime(step_40, (now, now)) - selected = MODULE.select_checkpoint("*.ckpt", root, validator) - self.assertIsNotNone(selected) - self.assertEqual(selected.path, step_60.resolve()) - self.assertEqual(selected.global_step, 60_000) - - def test_invalid_and_partial_candidates_are_ignored(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - validator = make_validator(root) - valid = root / "step-10.ckpt" - invalid = root / "step-20.ckpt" - partial = root / "step-30.ckpt.partial" - write_checkpoint(valid, 10) - write_checkpoint(invalid, 20, valid=False) - write_checkpoint(partial, 30) - selected = MODULE.select_checkpoint("*.ckpt*", root, validator) - self.assertEqual(selected.path, valid.resolve()) - - def test_multiple_roots_select_highest_semantic_step(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - source = staged / "step-40000.ckpt" - resumed = live / "step-42000.ckpt" - write_checkpoint(source, 40_000) - write_checkpoint(resumed, 42_000) - selected = MODULE.select_checkpoint( - [str(staged / "*.ckpt"), str(live / "*.ckpt")], - root, - validator, - ) - self.assertEqual(selected.path, resumed.resolve()) - - def test_fresh_checkpoint_requires_strictly_higher_global_step(self): - def info(path: str, step: int, digest: str) -> MODULE.CheckpointInfo: - return MODULE.CheckpointInfo( - path=Path(path), - sha256=digest, - global_step=step, - size=1, - mtime_ns=1, - inode=1, - metadata={"global_step": step}, - ) - - baseline = info("/tmp/run/a.ckpt", 10, "a" * 64) - self.assertFalse(MODULE.is_fresh(None, baseline)) - self.assertFalse( - MODULE.is_fresh(info("/tmp/run/b.ckpt", 9, "b" * 64), baseline) - ) - self.assertFalse( - MODULE.is_fresh(info("/tmp/run/b.ckpt", 10, "a" * 64), baseline) - ) - self.assertFalse( - MODULE.is_fresh(info("/tmp/run/a.ckpt", 10, "b" * 64), baseline) - ) - self.assertTrue( - MODULE.is_fresh(info("/tmp/run/a.ckpt", 11, "a" * 64), baseline) - ) - - def test_semantic_maximum_same_step_fork_is_rejected(self): - def info(path: str, digest: str) -> MODULE.CheckpointInfo: - return MODULE.CheckpointInfo( - path=Path(path), - sha256=digest, - global_step=10, - size=1, - mtime_ns=1, - inode=1, - metadata={"global_step": 10}, - ) - - with self.assertRaisesRegex(SystemExit, "semantic-maximum checkpoints"): - MODULE.newest_checkpoint( - info("/tmp/run/a.ckpt", "a" * 64), - info("/tmp/run/b.ckpt", "b" * 64), - ) - - def test_checkpoint_forwarding_is_exact_scancel_and_never_killpg(self): - completed = subprocess.CompletedProcess([], 0, stdout="", stderr="") - with mock.patch.dict( - os.environ, - {"SCANCEL_BATCH": "1", "SCANCEL_FULL": "1", "SLURM_CLUSTERS": "wrong"}, - ): - with ( - mock.patch.object( - MODULE.subprocess, "run", return_value=completed - ) as run, - mock.patch.object(MODULE.os, "killpg") as killpg, - ): - result = MODULE.forward_checkpoint_to_slurm_steps( - "/mock/bin/scancel", - "12345", - signal.SIGUSR2, - ) - self.assertIs(result, completed) - run.assert_called_once() - positional, keyword = run.call_args - self.assertEqual(positional, (["/mock/bin/scancel", "--signal=USR2", "12345"],)) - self.assertEqual(keyword["text"], True) - self.assertIs(keyword["stdout"], subprocess.PIPE) - self.assertIs(keyword["stderr"], subprocess.PIPE) - self.assertEqual(keyword["check"], False) - self.assertNotIn("SCANCEL_BATCH", keyword["env"]) - self.assertNotIn("SCANCEL_FULL", keyword["env"]) - self.assertNotIn("SLURM_CLUSTERS", keyword["env"]) - killpg.assert_not_called() - - -class RunnerSafetyTest(unittest.TestCase): - def test_signal_handlers_are_installed_before_child_spawn(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - checkpoint_path = root / "step-1.ckpt" - write_checkpoint(checkpoint_path, 1) - checkpoint = MODULE.stable_checkpoint_info( - checkpoint_path.resolve(), validator - ) - self.assertIsNotNone(checkpoint) - installed: list[signal.Signals] = [] - gate_readers: list[int] = [] - - class FinishedChild: - pid = 9876 - - @staticmethod - def poll() -> int: - while gate_readers: - os.close(gate_readers.pop()) - return 0 - - def install(received: signal.Signals, _handler: object) -> object: - installed.append(received) - return signal.SIG_DFL - - def spawn(*_args: object, **kwargs: object) -> FinishedChild: - self.assertEqual( - installed, - [signal.SIGUSR1, signal.SIGTERM, signal.SIGINT], - ) - pass_fds = kwargs["pass_fds"] - self.assertIsInstance(pass_fds, tuple) - gate_readers.append(os.dup(pass_fds[0])) - return FinishedChild() - - def validate(*_args: object, **_kwargs: object) -> MODULE.CheckpointInfo: - self.assertEqual( - installed, - [signal.SIGUSR1, signal.SIGTERM, signal.SIGINT], - ) - return checkpoint - - args = base_args(state, str(root / "*.ckpt"), validator, ["unused-child"]) - with ( - mock.patch.dict( - os.environ, - {"SLURM_JOB_ID": "990", "SLURM_RESTART_COUNT": "0"}, - clear=False, - ), - mock.patch.object(MODULE.signal, "signal", side_effect=install), - mock.patch.object( - MODULE, "stable_checkpoint_info", side_effect=validate - ), - mock.patch.object( - MODULE.subprocess, "Popen", side_effect=spawn - ) as popen, - ): - self.assertEqual(MODULE.main(args), 0) - popen.assert_called_once() - - def test_usr1_during_strict_discovery_requeues_without_starting_work(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - checkpoint = root / "step-1.ckpt" - write_checkpoint(checkpoint, 1) - early_signal_marker = root / "early-signal-sent" - validator = root / "early_signal_validator.py" - validator.write_text( - "#!/usr/bin/env python3\n" - "import json,os,signal,sys,time\n" - "marker=os.environ['EARLY_SIGNAL_MARKER']\n" - "if not os.path.exists(marker):\n" - " open(marker,'w').write('sent')\n" - " os.kill(os.getppid(),signal.SIGUSR1)\n" - " time.sleep(0.1)\n" - "with open(sys.argv[1]) as stream: data=json.load(stream)\n" - "print(json.dumps({'global_step':data['global_step'],'run_id':data['run_id']}))\n" - ) - validator.chmod(0o700) - child_started = root / "child-started" - child = root / "child.py" - child.write_text( - "import os\n" "open(os.environ['CHILD_STARTED'],'w').write('unsafe')\n" - ) - attempt = state / "requeue" / "job-12400-restart-000" / "attempt.json" - scancel_log = root / "scancel.log" - fake_scancel = root / "scancel" - fake_scancel.write_text( - "#!/bin/sh\n" 'printf \'%s\\n\' "$*" > "$ICE_TEST_SCANCEL_LOG"\n' - ) - fake_scancel.chmod(0o700) - scontrol_log = root / "scontrol.log" - fake_scontrol = root / "scontrol" - fake_scontrol.write_text( - "#!/bin/sh\n" 'printf \'%s\\n\' "$*" > "$SCONTROL_LOG"\n' - ) - fake_scontrol.chmod(0o700) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "12400", - "SLURM_RESTART_COUNT": "0", - "EARLY_SIGNAL_MARKER": str(early_signal_marker), - "CHILD_STARTED": str(child_started), - "ICE_TEST_SCANCEL_LOG": str(scancel_log), - "SCONTROL_LOG": str(scontrol_log), - } - ) - args = base_args( - state, - str(root / "*.ckpt"), - validator, - [sys.executable, str(child)], - owner="runner", - scancel=fake_scancel, - ) - args[args.index("--") : args.index("--")] = [ - "--scontrol", - str(fake_scontrol), - ] - result = subprocess.run( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - capture_output=True, - timeout=10, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(early_signal_marker.is_file()) - self.assertFalse(child_started.exists()) - self.assertFalse(scancel_log.exists()) - self.assertEqual(scontrol_log.read_text().strip(), "requeue 12400") - final = json.loads(attempt.read_text()) - self.assertEqual(final["status"], "EARLY_BOUNDARY_REQUEUE_ACCEPTED") - self.assertNotIn("child_pid", final) - proof_path = Path(final["early_boundary_proof"]) - proof = json.loads(proof_path.read_text()) - self.assertEqual( - final["early_boundary_proof_sha256"], - hashlib.sha256(proof_path.read_bytes()).hexdigest(), - ) - self.assertEqual( - proof["status"], - "EARLY_BOUNDARY_NO_WORK_CHECKPOINT_AUTHENTICATED", - ) - self.assertFalse(proof["child_spawned"]) - self.assertFalse(proof["optimizer_work_performed"]) - self.assertFalse(proof["checkpoint_signal_forwarded"]) - self.assertEqual(proof["checkpoint"]["global_step"], 1) - events = [ - json.loads(line) - for line in (attempt.parent / "events.jsonl").read_text().splitlines() - ] - self.assertEqual( - [event["event"] for event in events].count( - "early_boundary_no_work_checkpoint_authenticated" - ), - 1, - ) - - def test_incremental_restart_revalidates_high_water_and_only_new_candidates(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - checkpoints = [root / f"step-{step}.ckpt" for step in (10, 20, 30)] - for step, checkpoint in zip((10, 20, 30), checkpoints): - write_checkpoint(checkpoint, step) - log = root / "validator.log" - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(args.index("--"), "--dry-run") - - with mock.patch.dict( - os.environ, - {"SLURM_RESTART_COUNT": "0", "VALIDATOR_LOG": str(log)}, - clear=False, - ): - self.assertEqual(MODULE.main(args), 0) - first = log.read_text().splitlines() - self.assertEqual( - set(first), {str(path.resolve()) for path in checkpoints} - ) - self.assertEqual(len(first), 3) - - log.write_text("") - self.assertEqual(MODULE.main(args), 0) - self.assertEqual( - log.read_text().splitlines(), [str(checkpoints[-1].resolve())] - ) - - newer = root / "step-40.ckpt" - write_checkpoint(newer, 40) - log.write_text("") - self.assertEqual(MODULE.main(args), 0) - self.assertEqual( - set(log.read_text().splitlines()), - {str(checkpoints[-1].resolve()), str(newer.resolve())}, - ) - - log.write_text("") - self.assertEqual(MODULE.main(args), 0) - self.assertEqual(log.read_text().splitlines(), [str(newer.resolve())]) - - def test_unbound_observation_index_forces_validator_authoritative_rescan(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - checkpoints = [root / f"step-{step}.ckpt" for step in (10, 20, 30)] - for step, checkpoint in zip((10, 20, 30), checkpoints): - write_checkpoint(checkpoint, step) - log = root / "validator.log" - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(args.index("--"), "--dry-run") - with mock.patch.dict( - os.environ, - {"SLURM_RESTART_COUNT": "0", "VALIDATOR_LOG": str(log)}, - clear=False, - ): - self.assertEqual(MODULE.main(args), 0) - index = state / MODULE.OBSERVATION_INDEX_NAME - index.write_text(index.read_text() + "\n") - log.write_text("") - self.assertEqual(MODULE.main(args), 0) - self.assertEqual( - set(log.read_text().splitlines()), - {str(path.resolve()) for path in checkpoints}, - ) - - def test_validator_change_invalidates_observation_index(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - checkpoints = [root / f"step-{step}.ckpt" for step in (10, 20)] - for step, checkpoint in zip((10, 20), checkpoints): - write_checkpoint(checkpoint, step) - log = root / "validator.log" - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(args.index("--"), "--dry-run") - with mock.patch.dict( - os.environ, - {"SLURM_RESTART_COUNT": "0", "VALIDATOR_LOG": str(log)}, - clear=False, - ): - self.assertEqual(MODULE.main(args), 0) - validator.write_text(validator.read_text() + "# authority revision\n") - log.write_text("") - self.assertEqual(MODULE.main(args), 0) - self.assertEqual( - set(log.read_text().splitlines()), - {str(path.resolve()) for path in checkpoints}, - ) - - def test_validator_change_during_validation_refuses_state_commit(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - checkpoint = root / "step-10.ckpt" - write_checkpoint(checkpoint, 10) - marker = root / "validator-mutated" - validator = root / "mutating_validator.py" - validator.write_text( - "#!/usr/bin/env python3\n" - "import json,os,sys\n" - "marker=os.environ['VALIDATOR_MUTATION_MARKER']\n" - "if not os.path.exists(marker):\n" - " open(marker,'w').write('mutated')\n" - " with open(__file__,'a') as stream: stream.write('# changed\\n')\n" - "with open(sys.argv[1]) as stream: data=json.load(stream)\n" - "print(json.dumps({'global_step':data['global_step'],'run_id':data['run_id']}))\n" - ) - validator.chmod(0o700) - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(args.index("--"), "--dry-run") - with mock.patch.dict( - os.environ, - { - "SLURM_RESTART_COUNT": "0", - "VALIDATOR_MUTATION_MARKER": str(marker), - }, - clear=False, - ): - with self.assertRaisesRegex( - SystemExit, "validator changed after validation" - ): - MODULE.main(args) - self.assertTrue(marker.is_file()) - self.assertFalse((state / MODULE.OBSERVATION_INDEX_NAME).exists()) - self.assertFalse((state / "checkpoint-high-water.json").exists()) - - def test_failed_candidate_is_not_cached_when_sidecar_appears(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = root / "sidecar_validator.py" - validator.write_text( - "#!/usr/bin/env python3\n" - "import json,os,sys\n" - "with open(os.environ['VALIDATOR_LOG'],'a') as stream:\n" - " stream.write(os.path.realpath(sys.argv[1])+'\\n')\n" - "if not os.path.isfile(sys.argv[1]+'.proof'): raise SystemExit(4)\n" - "with open(sys.argv[1]) as stream: data=json.load(stream)\n" - "print(json.dumps({'global_step':data['global_step'],'run_id':data['run_id']}))\n" - ) - validator.chmod(0o700) - lower = root / "step-10.ckpt" - higher = root / "step-20.ckpt" - write_checkpoint(lower, 10) - write_checkpoint(higher, 20) - Path(str(lower) + ".proof").write_text("ready") - original_higher_stat = higher.stat() - log = root / "validator.log" - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(args.index("--"), "--dry-run") - with mock.patch.dict( - os.environ, - {"SLURM_RESTART_COUNT": "0", "VALIDATOR_LOG": str(log)}, - clear=False, - ): - self.assertEqual(MODULE.main(args), 0) - first = json.loads( - ( - state / "requeue" / "job-dry-run-restart-000" / "attempt.json" - ).read_text() - ) - self.assertEqual(first["checkpoint"]["global_step"], 10) - - Path(str(higher) + ".proof").write_text("ready") - self.assertEqual( - MODULE.stat_identity(higher.stat()), - MODULE.stat_identity(original_higher_stat), - ) - log.write_text("") - self.assertEqual(MODULE.main(args), 0) - self.assertIn(str(higher.resolve()), log.read_text().splitlines()) - final = json.loads( - ( - state / "requeue" / "job-dry-run-restart-000" / "attempt.json" - ).read_text() - ) - self.assertEqual(final["checkpoint"]["global_step"], 20) - - def test_atomic_replacement_with_preserved_size_and_mtime_is_revalidated(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - historical = root / "step-10.ckpt" - high_water = root / "step-20.ckpt" - write_checkpoint(historical, 10) - write_checkpoint(high_water, 20) - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(args.index("--"), "--dry-run") - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "0"}, clear=False): - self.assertEqual(MODULE.main(args), 0) - - original = historical.stat() - replacement = root / "replacement.ckpt" - write_checkpoint(replacement, 30) - self.assertEqual(replacement.stat().st_size, original.st_size) - os.utime(replacement, ns=(original.st_atime_ns, original.st_mtime_ns)) - os.replace(replacement, historical) - replaced = historical.stat() - self.assertEqual(replaced.st_size, original.st_size) - self.assertEqual(replaced.st_mtime_ns, original.st_mtime_ns) - self.assertNotEqual( - (replaced.st_dev, replaced.st_ino, replaced.st_ctime_ns), - (original.st_dev, original.st_ino, original.st_ctime_ns), - ) - - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "1"}, clear=False): - self.assertEqual(MODULE.main(args), 0) - final = json.loads( - ( - state / "requeue" / "job-dry-run-restart-001" / "attempt.json" - ).read_text() - ) - self.assertEqual(final["checkpoint"]["global_step"], 30) - - def test_runner_ownership_requires_explicit_child_disable_confirmation(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - args = base_args( - state, - str(root / "*.ckpt"), - validator, - ["/bin/true"], - owner="runner", - ) - args.remove("--confirm-child-requeue-disabled") - args.insert(args.index("--"), "--dry-run") - with self.assertRaisesRegex(SystemExit, "disable framework auto-requeue"): - MODULE.main(args) - - def test_required_initial_checkpoint_is_refused(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(-2, "--require-initial-checkpoint") - args.insert(-2, "--dry-run") - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "0"}, clear=False): - with self.assertRaisesRegex( - SystemExit, "requires an exact --initial-checkpoint" - ): - MODULE.main(args) - - def test_exact_initial_pins_first_attempt_then_live_wins_restart(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - initial = staged / "step-40000.ckpt" - newer_live = live / "step-60000.ckpt" - write_checkpoint(initial, 40_000) - write_checkpoint(newer_live, 60_000) - args = base_args( - state, - str(live / "*.ckpt"), - validator, - ["/bin/true"], - initial_checkpoint=initial, - ) - args.insert(args.index("--"), "--require-initial-checkpoint") - args.insert(args.index("--"), "--dry-run") - - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "0"}, clear=False): - self.assertEqual(MODULE.main(args), 0) - first = json.loads( - ( - state / "requeue" / "job-dry-run-restart-000" / "attempt.json" - ).read_text() - ) - self.assertEqual(first["checkpoint"]["global_step"], 40_000) - self.assertEqual(first["checkpoint"]["path"], str(initial.resolve())) - - initial.unlink() - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "1"}, clear=False): - self.assertEqual(MODULE.main(args), 0) - restarted = json.loads( - ( - state / "requeue" / "job-dry-run-restart-001" / "attempt.json" - ).read_text() - ) - self.assertEqual(restarted["checkpoint"]["global_step"], 60_000) - self.assertEqual(restarted["checkpoint"]["path"], str(newer_live.resolve())) - - def test_explicit_missing_initial_checkpoint_fails_closed(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - args = base_args( - state, - str(root / "live" / "*.ckpt"), - validator, - ["/bin/true"], - initial_checkpoint=root / "staged" / "missing.ckpt", - ) - args.insert(args.index("--"), "--dry-run") - with self.assertRaisesRegex( - SystemExit, "initial checkpoint is unavailable" - ): - MODULE.main(args) - - def test_restart_without_checkpoint_is_always_refused(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(-2, "--dry-run") - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "1"}, clear=False): - with self.assertRaisesRegex(SystemExit, "required resume checkpoint"): - MODULE.main(args) - - def test_high_water_refuses_rollback(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - newest = root / "step-60000.ckpt" - write_checkpoint(newest, 60_000) - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(-2, "--dry-run") - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "0"}, clear=False): - self.assertEqual(MODULE.main(args), 0) - newest.unlink() - write_checkpoint(root / "step-40000.ckpt", 40_000) - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "1"}, clear=False): - with self.assertRaisesRegex(SystemExit, "rollback refused"): - MODULE.main(args) - - def test_high_water_refuses_newer_same_step_different_sha(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - checkpoint = root / "step-60000.ckpt" - write_checkpoint(checkpoint, 60_000) - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(-2, "--dry-run") - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "0"}, clear=False): - self.assertEqual(MODULE.main(args), 0) - time.sleep(0.01) - checkpoint.unlink() - checkpoint.write_text( - json.dumps( - { - "global_step": 60_000, - "valid": True, - "run_id": "run", - "wandb_run_id": "wandb-run", - "different_payload": True, - } - ) - ) - with mock.patch.dict(os.environ, {"SLURM_RESTART_COUNT": "1"}, clear=False): - with self.assertRaisesRegex(SystemExit, "identity fork refused"): - MODULE.main(args) - - def test_state_directory_lock_refuses_duplicate_runner(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - state.mkdir(parents=True) - validator = make_validator(root) - write_checkpoint(root / "step-1.ckpt", 1) - lock = (state / "runner.lock").open("a+") - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - try: - args = base_args(state, str(root / "*.ckpt"), validator, ["/bin/true"]) - args.insert(args.index("--"), "--dry-run") - command = [ - sys.executable, - str(MODULE_PATH), - *args, - ] - result = subprocess.run( - command, text=True, capture_output=True, check=False - ) - finally: - lock.close() - self.assertNotEqual(result.returncode, 0) - self.assertIn("another requeue runner", result.stderr) - - def test_exports_checkpoint_path_sha_step_and_metadata(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - checkpoint = root / "step-12.ckpt" - write_checkpoint(checkpoint, 12) - output = root / "environment.json" - child = root / "child.py" - child.write_text( - "import json,os\n" - "keys=['ICE_RESUME_CHECKPOINT','ICE_RESUME_CHECKPOINT_SHA256'," - "'ICE_RESUME_GLOBAL_STEP','ICE_RESUME_CHECKPOINT_METADATA_JSON'," - "'ICE_REQUEUE_OWNER']\n" - "open(os.environ['OUTPUT'],'w').write(json.dumps({k:os.environ[k] for k in keys}))\n" - ) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "991", - "SLURM_RESTART_COUNT": "0", - "OUTPUT": str(output), - } - ) - command = [ - sys.executable, - str(MODULE_PATH), - *base_args( - state, str(root / "*.ckpt"), validator, [sys.executable, str(child)] - ), - ] - result = subprocess.run( - command, env=env, text=True, capture_output=True, check=False - ) - self.assertEqual(result.returncode, 0, result.stderr) - exported = json.loads(output.read_text()) - self.assertEqual( - exported["ICE_RESUME_CHECKPOINT"], str(checkpoint.resolve()) - ) - self.assertEqual(exported["ICE_RESUME_GLOBAL_STEP"], "12") - self.assertEqual( - exported["ICE_RESUME_CHECKPOINT_SHA256"], - hashlib.sha256(checkpoint.read_bytes()).hexdigest(), - ) - metadata = json.loads(exported["ICE_RESUME_CHECKPOINT_METADATA_JSON"]) - self.assertEqual(metadata["global_step"], 12) - self.assertEqual(metadata["checkpoint_path"], str(checkpoint.resolve())) - self.assertEqual(exported["ICE_REQUEUE_OWNER"], "child") - - def test_child_signal_has_shell_exit_status_without_requeue(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - write_checkpoint(root / "step-12.ckpt", 12) - scontrol_log = root / "scontrol.log" - fake_scontrol = root / "scontrol" - fake_scontrol.write_text( - "#!/bin/sh\n" f"touch {shlex.quote(str(scontrol_log))}\n" - ) - fake_scontrol.chmod(0o700) - env = os.environ.copy() - env.update({"SLURM_JOB_ID": "993", "SLURM_RESTART_COUNT": "0"}) - args = base_args( - state, - str(root / "*.ckpt"), - validator, - [ - sys.executable, - "-c", - "import os,signal; os.kill(os.getpid(), signal.SIGTERM)", - ], - owner="runner", - ) - args[args.index("--") : args.index("--")] = [ - "--scontrol", - str(fake_scontrol), - ] - result = subprocess.run( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - capture_output=True, - check=False, - timeout=10, - ) - self.assertEqual(result.returncode, 128 + signal.SIGTERM, result.stderr) - self.assertFalse(scontrol_log.exists()) - attempt = json.loads( - (state / "requeue" / "job-993-restart-000" / "attempt.json").read_text() - ) - self.assertEqual(attempt["status"], "CHILD_FAILED_NO_REQUEUE") - self.assertEqual(attempt["child_exit_code"], -signal.SIGTERM) - self.assertEqual(attempt["child_exit_signal"], "SIGTERM") - - def test_signal_baseline_waits_for_post_signal_checkpoint(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - initial = staged / "step-1.ckpt" - write_checkpoint(initial, 1) - ready = root / "ready" - child_pid = root / "child.pid" - resume_seen = root / "resume-seen" - child = root / "child.py" - child.write_text( - "import json,os,signal,time\n" - "root=os.environ['ROOT']\n" - "def write(step):\n" - " p=os.path.join(root,f'step-{step}.ckpt'); t=p+'.tmp'\n" - " open(t,'w').write(json.dumps({'global_step':step,'run_id':'run','wandb_run_id':'wandb-run'}))\n" - " os.replace(t,p)\n" - "def save(*_):\n" - " time.sleep(0.6); write(3); raise SystemExit(0)\n" - "signal.signal(signal.SIGUSR2,save)\n" - "open(os.environ['CHILD_PID_FILE'],'w').write(str(os.getpid()))\n" - "open(os.environ['RESUME_SEEN'],'w').write(os.environ['ICE_RESUME_CHECKPOINT'])\n" - "write(2); open(os.environ['READY'],'w').write('ready')\n" - "while True: time.sleep(0.05)\n" - ) - scancel_log = root / "scancel.log" - fake_scancel = make_signaling_scancel(root) - scontrol_log = root / "scontrol.log" - fake_scontrol = root / "scontrol" - fake_scontrol.write_text( - '#!/bin/sh\nprintf \'%s\\n\' "$*" > "$SCONTROL_LOG"\n' - ) - fake_scontrol.chmod(0o700) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "12345", - "SLURM_RESTART_COUNT": "0", - "ROOT": str(live), - "READY": str(ready), - "CHILD_PID_FILE": str(child_pid), - "RESUME_SEEN": str(resume_seen), - "ICE_TEST_SCANCEL_LOG": str(scancel_log), - "SCONTROL_LOG": str(scontrol_log), - } - ) - args = base_args( - state, - str(live / "*.ckpt"), - validator, - [sys.executable, str(child)], - owner="runner", - initial_checkpoint=initial, - scancel=fake_scancel, - ) - args[args.index("--poll-seconds") : args.index("--poll-seconds") + 2] = [ - "--poll-seconds", - "0.02", - ] - args[args.index("--") : args.index("--")] = [ - "--checkpoint-grace-seconds", - "4", - "--scontrol", - str(fake_scontrol), - ] - command = [sys.executable, str(MODULE_PATH), *args] - process = subprocess.Popen( - command, - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - attempt = state / "requeue" / "job-12345-restart-000" / "attempt.json" - deadline = time.time() + 5 - while time.time() < deadline: - if ( - ready.exists() - and attempt.exists() - and json.loads(attempt.read_text()).get("status") == "RUNNING" - ): - break - time.sleep(0.02) - else: - process.kill() - self.fail("runner/child did not become ready") - process.send_signal(signal.SIGUSR1) - time.sleep(0.2) - self.assertFalse( - scontrol_log.exists(), "runner requeued before post-signal save" - ) - stdout, stderr = process.communicate(timeout=10) - self.assertEqual(process.returncode, 0, f"{stdout}\n{stderr}") - self.assertEqual(resume_seen.read_text(), str(initial.resolve())) - self.assertEqual(scancel_log.read_text().strip(), "--signal=USR2 12345") - self.assertEqual(scontrol_log.read_text().strip(), "requeue 12345") - final = json.loads(attempt.read_text()) - self.assertEqual(final["checkpoint"]["global_step"], 3) - self.assertTrue(final["fresh_checkpoint_after_signal"]) - events = [ - json.loads(line) - for line in (attempt.parent / "events.jsonl").read_text().splitlines() - ] - signal_event = next(event for event in events if event["event"] == "signal") - self.assertEqual(signal_event["baseline"]["global_step"], 2) - - def test_runner_refuses_requeue_without_fresh_post_signal_checkpoint(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - initial = staged / "step-1.ckpt" - write_checkpoint(initial, 1) - ready = root / "ready" - child_pid = root / "child.pid" - child = root / "child.py" - child.write_text( - "import os,signal,time\n" - "signal.signal(signal.SIGUSR2, lambda *_: (_ for _ in ()).throw(SystemExit(0)))\n" - "open(os.environ['CHILD_PID_FILE'],'w').write(str(os.getpid()))\n" - "open(os.environ['READY'],'w').write('ready')\n" - "while True: time.sleep(0.05)\n" - ) - scancel_log = root / "scancel.log" - fake_scancel = make_signaling_scancel(root) - called = root / "scontrol-called" - fake_scontrol = root / "scontrol" - fake_scontrol.write_text('#!/bin/sh\ntouch "$SCONTROL_CALLED"\n') - fake_scontrol.chmod(0o700) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "12348", - "SLURM_RESTART_COUNT": "0", - "READY": str(ready), - "CHILD_PID_FILE": str(child_pid), - "ICE_TEST_SCANCEL_LOG": str(scancel_log), - "SCONTROL_CALLED": str(called), - } - ) - args = base_args( - state, - str(live / "*.ckpt"), - validator, - [sys.executable, str(child)], - owner="runner", - initial_checkpoint=initial, - scancel=fake_scancel, - ) - args[args.index("--") : args.index("--")] = [ - "--checkpoint-grace-seconds", - "1", - "--scontrol", - str(fake_scontrol), - ] - process = subprocess.Popen( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - attempt = state / "requeue" / "job-12348-restart-000" / "attempt.json" - deadline = time.time() + 5 - while time.time() < deadline: - if ( - ready.exists() - and attempt.exists() - and json.loads(attempt.read_text()).get("status") == "RUNNING" - ): - break - time.sleep(0.02) - else: - process.kill() - self.fail("runner/child did not become ready") - process.send_signal(signal.SIGUSR1) - stdout, stderr = process.communicate(timeout=10) - self.assertEqual(process.returncode, 74, f"{stdout}\n{stderr}") - self.assertFalse(called.exists()) - self.assertEqual(scancel_log.read_text().strip(), "--signal=USR2 12348") - final = json.loads(attempt.read_text()) - self.assertEqual(final["status"], "REQUEUE_REFUSED_NO_FRESH_CHECKPOINT") - self.assertFalse(final["fresh_checkpoint_after_signal"]) - - def test_child_requeue_owner_never_calls_scontrol(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - initial = staged / "step-1.ckpt" - write_checkpoint(initial, 1) - child_pid = root / "child.pid" - child = root / "child.py" - child.write_text( - "import json,os,signal,time\n" - "def save(*_):\n" - " p=os.path.join(os.environ['ROOT'],'step-2.ckpt'); t=p+'.tmp'\n" - " open(t,'w').write(json.dumps({'global_step':2,'run_id':'run','wandb_run_id':'wandb-run'}))\n" - " os.replace(t,p); raise SystemExit(0)\n" - "signal.signal(signal.SIGUSR2,save)\n" - "open(os.environ['CHILD_PID_FILE'],'w').write(str(os.getpid()))\n" - "while True: time.sleep(0.05)\n" - ) - scancel_log = root / "scancel.log" - fake_scancel = make_signaling_scancel(root) - called = root / "scontrol-called" - fake_scontrol = root / "scontrol" - fake_scontrol.write_text('#!/bin/sh\ntouch "$SCONTROL_CALLED"\n') - fake_scontrol.chmod(0o700) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "12346", - "SLURM_RESTART_COUNT": "0", - "ROOT": str(live), - "CHILD_PID_FILE": str(child_pid), - "ICE_TEST_SCANCEL_LOG": str(scancel_log), - "SCONTROL_CALLED": str(called), - } - ) - args = base_args( - state, - str(live / "*.ckpt"), - validator, - [sys.executable, str(child)], - owner="child", - initial_checkpoint=initial, - scancel=fake_scancel, - ) - args[args.index("--") : args.index("--")] = [ - "--checkpoint-grace-seconds", - "3", - "--scontrol", - str(fake_scontrol), - ] - process = subprocess.Popen( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - attempt = state / "requeue" / "job-12346-restart-000" / "attempt.json" - deadline = time.time() + 5 - while time.time() < deadline: - if ( - child_pid.exists() - and attempt.exists() - and json.loads(attempt.read_text()).get("status") == "RUNNING" - ): - break - time.sleep(0.02) - else: - process.kill() - self.fail("runner did not become ready") - process.send_signal(signal.SIGUSR1) - stdout, stderr = process.communicate(timeout=10) - self.assertEqual(process.returncode, 0, f"{stdout}\n{stderr}") - self.assertFalse(called.exists()) - self.assertEqual(scancel_log.read_text().strip(), "--signal=USR2 12346") - final = json.loads(attempt.read_text()) - self.assertEqual(final["status"], "COMPLETE") - - def test_duplicate_usr1_boundary_invokes_scancel_only_once(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - initial = staged / "step-1.ckpt" - write_checkpoint(initial, 1) - ready = root / "ready" - saved = root / "saved" - child_pid = root / "child.pid" - child = root / "child.py" - child.write_text( - "import json,os,signal,time\n" - "def save(*_):\n" - " p=os.path.join(os.environ['ROOT'],'step-2.ckpt'); t=p+'.tmp'\n" - " open(t,'w').write(json.dumps({'global_step':2,'run_id':'run','wandb_run_id':'wandb-run'}))\n" - " os.replace(t,p); open(os.environ['SAVED'],'w').write('saved')\n" - "signal.signal(signal.SIGUSR2,save)\n" - "open(os.environ['CHILD_PID_FILE'],'w').write(str(os.getpid()))\n" - "open(os.environ['READY'],'w').write('ready')\n" - "while True: time.sleep(0.05)\n" - ) - scancel_log = root / "scancel.log" - fake_scancel = root / "scancel" - fake_scancel.write_text( - "#!/bin/sh\n" - 'printf \'%s\\n\' "$*" >> "$ICE_TEST_SCANCEL_LOG"\n' - 'kill -USR2 "$(cat "$CHILD_PID_FILE")"\n' - ) - fake_scancel.chmod(0o700) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "12350", - "SLURM_RESTART_COUNT": "0", - "ROOT": str(live), - "READY": str(ready), - "SAVED": str(saved), - "CHILD_PID_FILE": str(child_pid), - "ICE_TEST_SCANCEL_LOG": str(scancel_log), - } - ) - args = base_args( - state, - str(live / "*.ckpt"), - validator, - [sys.executable, str(child)], - owner="child", - initial_checkpoint=initial, - scancel=fake_scancel, - ) - process = subprocess.Popen( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - attempt = state / "requeue" / "job-12350-restart-000" / "attempt.json" - deadline = time.time() + 5 - while time.time() < deadline: - if ready.exists() and attempt.exists(): - break - time.sleep(0.02) - else: - process.kill() - self.fail("runner/child did not become ready") - process.send_signal(signal.SIGUSR1) - deadline = time.time() + 5 - while time.time() < deadline: - if ( - saved.exists() - and json.loads(attempt.read_text()).get("status") - == "CHILD_REQUEUE_OWNER_CHECKPOINT_READY" - ): - break - time.sleep(0.02) - else: - process.kill() - self.fail("first checkpoint boundary did not complete") - process.send_signal(signal.SIGUSR1) - time.sleep(0.2) - self.assertEqual( - scancel_log.read_text().splitlines(), ["--signal=USR2 12350"] - ) - process.send_signal(signal.SIGTERM) - stdout, stderr = process.communicate(timeout=10) - self.assertEqual( - process.returncode, 128 + signal.SIGTERM, f"{stdout}\n{stderr}" - ) - - def test_scancel_failure_is_persisted_and_never_requeues(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - initial = staged / "step-1.ckpt" - write_checkpoint(initial, 1) - ready = root / "ready" - child_pid = root / "child.pid" - child = root / "child.py" - child.write_text( - "import os,time\n" - "open(os.environ['CHILD_PID_FILE'],'w').write(str(os.getpid()))\n" - "open(os.environ['READY'],'w').write('ready')\n" - "while True: time.sleep(0.05)\n" - ) - scancel_log = root / "scancel.log" - fake_scancel = root / "scancel" - fake_scancel.write_text( - "#!/bin/sh\n" - 'printf \'%s\\n\' "$*" > "$ICE_TEST_SCANCEL_LOG"\n' - 'kill -TERM "$(cat "$CHILD_PID_FILE")"\n' - "echo signal-refused >&2\n" - "exit 9\n" - ) - fake_scancel.chmod(0o700) - scontrol_called = root / "scontrol-called" - fake_scontrol = root / "scontrol" - fake_scontrol.write_text('#!/bin/sh\ntouch "$SCONTROL_CALLED"\n') - fake_scontrol.chmod(0o700) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "12349", - "SLURM_RESTART_COUNT": "0", - "READY": str(ready), - "CHILD_PID_FILE": str(child_pid), - "ICE_TEST_SCANCEL_LOG": str(scancel_log), - "SCONTROL_CALLED": str(scontrol_called), - } - ) - args = base_args( - state, - str(live / "*.ckpt"), - validator, - [sys.executable, str(child)], - owner="runner", - initial_checkpoint=initial, - scancel=fake_scancel, - ) - args[args.index("--") : args.index("--")] = [ - "--scontrol", - str(fake_scontrol), - ] - process = subprocess.Popen( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - attempt = state / "requeue" / "job-12349-restart-000" / "attempt.json" - deadline = time.time() + 5 - while time.time() < deadline: - if ready.exists() and attempt.exists(): - break - time.sleep(0.02) - else: - process.kill() - self.fail("runner/child did not become ready") - process.send_signal(signal.SIGUSR1) - stdout, stderr = process.communicate(timeout=10) - self.assertEqual(process.returncode, 9, f"{stdout}\n{stderr}") - self.assertEqual(scancel_log.read_text().strip(), "--signal=USR2 12349") - self.assertFalse(scontrol_called.exists()) - final = json.loads(attempt.read_text()) - self.assertEqual(final["status"], "CHECKPOINT_SIGNAL_FORWARD_FAILED") - self.assertEqual(final["checkpoint_signal_returncode"], 9) - self.assertEqual( - final["checkpoint_signal_argv"], - [str(fake_scancel.resolve()), "--signal=USR2", "12349"], - ) - self.assertIn("signal-refused", final["checkpoint_signal_stderr"]) - - def test_scontrol_failure_is_persisted(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - staged = root / "staged" - live = root / "live" - staged.mkdir() - live.mkdir() - validator = make_validator(root) - initial = staged / "step-1.ckpt" - write_checkpoint(initial, 1) - child_pid = root / "child.pid" - child = root / "child.py" - child.write_text( - "import json,os,signal,time\n" - "def save(*_):\n" - " p=os.path.join(os.environ['ROOT'],'step-2.ckpt'); t=p+'.tmp'\n" - " open(t,'w').write(json.dumps({'global_step':2,'run_id':'run','wandb_run_id':'wandb-run'}))\n" - " os.replace(t,p); raise SystemExit(0)\n" - "signal.signal(signal.SIGUSR2,save)\n" - "open(os.environ['CHILD_PID_FILE'],'w').write(str(os.getpid()))\n" - "while True: time.sleep(0.05)\n" - ) - scancel_log = root / "scancel.log" - fake_scancel = make_signaling_scancel(root) - fake_scontrol = root / "scontrol" - fake_scontrol.write_text("#!/bin/sh\necho refused >&2\nexit 7\n") - fake_scontrol.chmod(0o700) - env = os.environ.copy() - env.update( - { - "SLURM_JOB_ID": "12347", - "SLURM_RESTART_COUNT": "0", - "ROOT": str(live), - "CHILD_PID_FILE": str(child_pid), - "ICE_TEST_SCANCEL_LOG": str(scancel_log), - } - ) - args = base_args( - state, - str(live / "*.ckpt"), - validator, - [sys.executable, str(child)], - owner="runner", - initial_checkpoint=initial, - scancel=fake_scancel, - ) - args[args.index("--") : args.index("--")] = [ - "--checkpoint-grace-seconds", - "3", - "--scontrol", - str(fake_scontrol), - ] - process = subprocess.Popen( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - attempt = state / "requeue" / "job-12347-restart-000" / "attempt.json" - deadline = time.time() + 5 - while time.time() < deadline: - if ( - child_pid.exists() - and attempt.exists() - and json.loads(attempt.read_text()).get("status") == "RUNNING" - ): - break - time.sleep(0.02) - else: - process.kill() - self.fail("runner did not become ready") - process.send_signal(signal.SIGUSR1) - stdout, stderr = process.communicate(timeout=10) - self.assertEqual(process.returncode, 7, f"{stdout}\n{stderr}") - final = json.loads(attempt.read_text()) - self.assertEqual(final["status"], "REQUEUE_FAILED") - self.assertEqual(final["scontrol_returncode"], 7) - self.assertIn("refused", final["scontrol_stderr"]) - - def test_completion_sentinel_contains_terminal_checkpoint(self): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - state = root / "state" / "run" - validator = make_validator(root) - write_checkpoint(root / "step-5.ckpt", 5) - sentinel = root / "run" / "COMPLETE.json" - child = root / "child.py" - child.write_text( - "import json,os\n" - "p=os.path.join(os.environ['ROOT'],'step-6.ckpt'); t=p+'.tmp'\n" - "open(t,'w').write(json.dumps({'global_step':6,'run_id':'run','wandb_run_id':'wandb-run'}))\n" - "os.replace(t,p)\n" - ) - env = os.environ.copy() - env.update( - {"SLURM_JOB_ID": "992", "SLURM_RESTART_COUNT": "0", "ROOT": str(root)} - ) - args = base_args( - state, str(root / "*.ckpt"), validator, [sys.executable, str(child)] - ) - args[args.index("--") : args.index("--")] = [ - "--completion-sentinel", - str(sentinel), - ] - result = subprocess.run( - [sys.executable, str(MODULE_PATH), *args], - env=env, - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - payload = json.loads(sentinel.read_text()) - self.assertEqual(payload["status"], "COMPLETE") - self.assertEqual(payload["checkpoint"]["global_step"], 6) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_imported_recipe_framework_parity.py b/tests/test_imported_recipe_framework_parity.py deleted file mode 100644 index 475fac391f..0000000000 --- a/tests/test_imported_recipe_framework_parity.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Imported recipes retain the exact source7c scientific payload. - -Hashes were extracted from immutable7c253e5 YAML after removing only the -framework target. This remains executable in shallow CI without source fetches. -""" - -import hashlib -import json -from pathlib import Path - -import pytest -import yaml - - -@pytest.mark.parametrize( - "name,expected", - [ - ( - "us_action_flow_bc_latent_fm_sg_200m_adamw", - "a2007390f6497a04198308190bb259983eee4d75a9beed810926152e5c2f2fb9", - ), - ( - "us_action_flow_bc_graph_section", - "bca57f3f847f55fb4c06e7b53af8ddd22e6d7e9252d288177a18c148e8d91100", - ), - ( - "us_action_flow_bc_latent_fm_sg_200m_muon", - "0d7e4b03b9c5e1321ee009b78a0a89ad5ba9b954402f75833430a3bc5fb0239e", - ), - ( - "us_action_flow_bc_latent_fm_sg", - "4cce3e22ad1a379ec2dfca757631f60d320e903e0a86d2c5cde02def3ab249e3", - ), - ( - "us_action_flow_bc_nt16_d8_h512", - "d909b72505196be3c46d7dfb78b37894e55a9f4f6b812c61af07bb86325a9b00", - ), - ( - "cotrain_uc_action_flow_latent_fm_sg_unite_h384d12h12", - "caba2d2a9432ef36876405a4affa76dc3d32c93ad8728de66cf58d7b9c4fb615", - ), - ], -) -def test_framework_port_keeps_every_scientific_field(name, expected): - root = Path(__file__).resolve().parents[1] - cfg = yaml.safe_load( - (root / "egomimic/hydra_configs/model/bf" / (name + ".yaml")).read_text() - ) - assert cfg.pop("_target_") == "egomimic.pl_utils.pl_model.ModelWrapper" - assert cfg.pop("training_behavior") == { - "_target_": "egomimic.pl_utils.training_behavior_action_flow.ActionFlowTrainingBehavior" - } - assert cfg.pop("diagnostic_provider") == { - "_target_": "egomimic.eval.pipeline_diagnostics.ActionFlowDiagnosticProvider" - } - if name in { - "us_action_flow_bc_latent_fm_sg_200m_adamw", - "us_action_flow_bc_latent_fm_sg_200m_muon", - }: - # Main adds an explicit non-deployable declaration, not a new adapter. - assert cfg.pop("inference") == { - "status": "unsupported", - "reason": "This simulation recipe has no deployment observation/action adapter. Use its configured simulator evaluator; declare an inference adapter before deployment.", - } - actual = hashlib.sha256( - json.dumps(cfg, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() - assert actual == expected diff --git a/tests/test_inference_config.py b/tests/test_inference_config.py deleted file mode 100644 index ff78f9431a..0000000000 --- a/tests/test_inference_config.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Model-owned inference artifact generation and compatibility checks.""" - -from __future__ import annotations - -from copy import deepcopy -from pathlib import Path - -import pytest -from omegaconf import OmegaConf - -from egomimic.pipeline.inference_config import ( - build_inference_config, - checkpoint_run_prefix, - find_inference_config, - load_inference_config, - validate_inference_config, - write_inference_config, -) - -FLOW_DENOISER = "arbitrary_plugin.flow.Sampler" -DIFFUSION_DENOISER = "arbitrary_plugin.diffusion.Sampler" - - -def training_config( - *, - family="flow", - variant=None, - horizon=100, - action_dim=14, - history_length=1, -): - denoiser = { - "_target_": FLOW_DENOISER, - "action_horizon": horizon, - "action_dim": action_dim, - "num_inference_steps": 50, - } - if family == "diffusion": - denoiser = { - "_target_": DIFFUSION_DENOISER, - "action_horizon": horizon, - "action_dim": action_dim, - "policy": { - "num_inference_steps": 100, - "noise_scheduler": {"num_train_timesteps": 100}, - }, - } - payload = { - "model": { - "pipeline": { - "_target_": "egomimic.pipeline.algo.PipelineAlgo", - "stage_ids": {"sampler": 2}, - "stages": [ - { - "_target_": "egomimic.pipeline.stages_sampler.FusedObsEncoder", - "n_obs_steps": history_length, - }, - { - "_target_": "egomimic.pipeline.stages_io.ActionTargetBuilder", - "action_key": "actions_cartesian", - }, - denoiser, - ], - } - }, - "evaluator": {"dt": 1 / 30}, - "inference_config": { - "flow_inference_steps": 10, - "diffusion_inference_steps": None, - "max_flow_inference_steps": 100, - "replan_every": 30, - "action_dt": None, - }, - } - if variant is not None: - payload["e1"] = { - "variant": variant, - "D": 0.4, - "M": 100, - "time_rows": 100, - # The source window is intentionally independent of model output. - "chunk_length": 400, - } - shape = [horizon, action_dim] - temporal = {"kind": "control_period", "temporally_resolved": True, "dt": 1 / 30} - decoder = None - if variant in {"arcvel", "arcdur", "arclogdur"}: - decoder = { - "_target_": "egomimic.robot.arc_decoder.BimanualArcDecoder", - "token_layout": { - "arcvel": "e1_profile", - "arcdur": "e1_dur", - "arclogdur": "e1_logdur", - }[variant], - "min_distance_unit": "${e1.D}", - "resampled_vector_length": "${e1.M}", - "dt": 1 / 30, - "action_horizon": "${e1.time_rows}", - } - payload["model"]["inference"] = { - "input": { - "history_length": history_length, - "keys": ["observation"], - "history_keys": ["observation"], - }, - "native_output": { - "key": "pred_action", - "shape": shape, - "representation": "configured-native", - "timing": temporal, - }, - "output": { - "key": "actions", - "shape": [horizon, 14], - "representation": "cartesian", - "timing": temporal, - }, - "compatibility": { - "normalizer_schema": {"actions": shape}, - "tokenizer": decoder, - }, - "profiles": { - "default": { - "stage_id": "sampler", - "native_shape": shape, - "adapter": {"decoder": decoder}, - "overrides": { - "inference_steps": { - "label": "Sampling steps", - "type": "integer", - "min": 1, - "max": 100, - "default": 100 if family == "diffusion" else 10, - "target": { - "kind": "stage_attribute", - "stage_id": "sampler", - "attribute_path": "policy.num_inference_steps" - if family == "diffusion" - else "num_inference_steps", - }, - }, - "replan_every": { - "label": "Actions before replanning", - "type": "integer", - "min": 1, - "max": horizon, - "default": min(30, horizon), - "target": { - "kind": "policy_attribute", - "attribute_path": "replan_every", - }, - }, - }, - } - }, - } - if variant == "arcmean": - payload["model"]["inference"] = { - "status": "unsupported", - "reason": "token-wide mean timing cannot reconstruct intervals; use an interval representation", - } - return OmegaConf.create(payload) - - -def test_time_flow_artifact_owns_history_output_and_runtime_controls(): - artifact = build_inference_config(training_config(history_length=2)) - - assert artifact["status"] == "ready" - graph = artifact["inference_graph"] - assert graph["input"]["history_length"] == 2 - assert graph["output"]["representation"] == "cartesian" - assert graph["output"]["shape"] == [100, 14] - profile = graph["profiles"]["default"] - assert profile["native_shape"] == [100, 14] - assert profile["adapter"] == {"decoder": None} - assert profile["overrides"]["inference_steps"]["default"] == 10 - assert profile["overrides"]["replan_every"]["default"] == 30 - - -@pytest.mark.parametrize( - ("variant", "layout"), - [ - ("arcvel", "e1_profile"), - ("arcdur", "e1_dur"), - ("arclogdur", "e1_logdur"), - ], -) -def test_arc_artifact_decodes_native_tokens_to_fixed_cartesian_output(variant, layout): - artifact = build_inference_config(training_config(variant=variant, action_dim=16)) - - assert artifact["status"] == "ready" - graph = artifact["inference_graph"] - assert graph["output"]["shape"] == [100, 14] - profile = graph["profiles"]["default"] - assert profile["native_shape"] == [100, 16] - assert profile["adapter"]["decoder"] == { - "_target_": "egomimic.robot.arc_decoder.BimanualArcDecoder", - "token_layout": layout, - "min_distance_unit": 0.4, - "resampled_vector_length": 100, - "dt": 1 / 30, - "action_horizon": 100, - } - - -def test_diffusion_artifact_targets_the_diffusion_policy_not_flow(): - artifact = build_inference_config(training_config(family="diffusion")) - - assert artifact["status"] == "ready" - profile = artifact["inference_graph"]["profiles"]["default"] - control = profile["overrides"]["inference_steps"] - assert control["label"] == "Sampling steps" - assert control["default"] == 100 - assert control["target"] == { - "kind": "stage_attribute", - "stage_id": "sampler", - "attribute_path": "policy.num_inference_steps", - } - - -def test_legacy_mean_timing_and_noncartesian_models_fail_closed(): - mean = build_inference_config( - training_config(variant="arcmean", horizon=101, action_dim=14) - ) - assert mean["status"] == "unsupported" - assert "token-wide mean timing" in mean["reason"] - - planar = training_config() - planar.model.pipeline.stages[1].action_key = "actions" - # A new action key or stage family needs no exporter Python change. - artifact = build_inference_config(planar) - assert artifact["status"] == "ready" - del planar.model.inference - artifact = build_inference_config(planar) - assert artifact["status"] == "unsupported" - assert "model.inference" in artifact["reason"] - - -def test_artifact_is_bound_to_exact_pipeline_and_graph_content(): - training = training_config() - artifact = build_inference_config(training) - assert validate_inference_config(artifact, training)["output"]["shape"] == [ - 100, - 14, - ] - - changed_training = deepcopy(training) - changed_training.model.pipeline.stages[-1].action_horizon = 99 - with pytest.raises(ValueError, match="does not match"): - validate_inference_config(artifact, changed_training) - - arc_training = training_config(variant="arcdur", action_dim=16) - arc_artifact = build_inference_config(arc_training) - changed_codec = deepcopy(arc_training) - changed_codec.e1.D = 0.75 - with pytest.raises(ValueError, match="codec and inference defaults"): - validate_inference_config(arc_artifact, changed_codec) - - changed_artifact = deepcopy(artifact) - changed_artifact["inference_graph"]["output"]["shape"] = [99, 14] - with pytest.raises(ValueError, match="content hash"): - validate_inference_config(changed_artifact, training) - - -def test_writer_is_idempotent_but_never_overwrites_another_model(tmp_path): - path = tmp_path / "inference-config.yaml" - training = training_config() - - write_inference_config(training, path) - write_inference_config(training, path) - assert load_inference_config(path, training)["output"]["shape"] == [100, 14] - - changed = training_config(horizon=50) - with pytest.raises(RuntimeError, match="Refusing to overwrite"): - write_inference_config(changed, path) - - -def test_checkpoint_prefixed_artifact_takes_precedence_over_generic(tmp_path): - checkpoint = tmp_path / "run_name__epoch-2399-step-240000__sha256-abcd.ckpt" - checkpoint.touch() - generic = tmp_path / "inference-config.yaml" - generic.touch() - prefixed = tmp_path / "run_name.inference-config.yaml" - prefixed.touch() - - assert find_inference_config(checkpoint) == prefixed.resolve() - assert checkpoint_run_prefix(checkpoint) == "run_name" - - -def test_train_defaults_emit_artifact_beside_checkpoints(): - path = ( - Path(__file__).parents[1] / "egomimic/hydra_configs/train_zarr_cartesian.yaml" - ) - config = OmegaConf.load(path) - settings = OmegaConf.to_container(config.inference_config, resolve=False) - - assert settings["enabled"] is True - assert settings["output_path"].endswith("/checkpoints/inference-config.yaml") diff --git a/tests/test_inference_contract_boundaries.py b/tests/test_inference_contract_boundaries.py deleted file mode 100644 index 374e0eb71c..0000000000 --- a/tests/test_inference_contract_boundaries.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Deployment declarations preserve duration, source width and typed settings.""" - -from copy import deepcopy -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import OmegaConf - -from egomimic.pipeline.inference_config import ( - build_inference_config, - validate_input_constants, -) -from egomimic.robot.graph_policy import configure_profile_controls - -CONFIGS = Path(__file__).parents[1] / "egomimic/hydra_configs" - - -def compose_recipe(**kwargs): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - return compose(**kwargs) - - -def test_resampled_yam_contract_restores_native_duration(): - cfg = compose_recipe( - config_name="train_zarr_cartesian", - overrides=["+experiment=abc/yam_fstshirt_dp"], - ) - artifact = build_inference_config(cfg) - assert artifact["status"] == "ready" - graph = artifact["inference_graph"] - assert graph["native_output"]["shape"] == [100, 14] - assert graph["output"]["shape"] == [45, 14] - decoder = instantiate(graph["profiles"]["default"]["adapter"]["decoder"]) - actions = np.zeros((1, 100, 14)) - actions[0, :, 0] = np.linspace(0, 44, 100) - np.testing.assert_allclose(decoder(actions)[0, :, 0], np.arange(45), atol=1e-10) - with pytest.raises(ValueError, match="input profile"): - validate_input_constants(graph, {"embodiment": 6}) - - -def test_chunk_mean_timing_is_non_deployable_even_with_same_model_shape(): - cfg = compose_recipe( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=abc_arc/abc_fstshirt_arc_bc", - "abc.arc_velocity_mode=mean", - ], - ) - artifact = build_inference_config(cfg) - assert artifact["status"] == "unsupported" - assert "timing cannot reconstruct intervals" in artifact["reason"] - - -@pytest.mark.parametrize( - "domain, native_width, output_shape", - [ - ("human_bimanual", 18, [30, 12]), - ("eva_bimanual", 20, [45, 14]), - ], -) -def test_pi_mixed_source_contract_selects_exact_width_and_time( - domain, native_width, output_shape -): - cfg = compose_recipe( - config_name="train_zarr_cartesian_pi", - overrides=[ - "model=pi05/pi0.5_bc_mecka_abc_6d", - "pi05.pretrained_weights=/unused/weights", - f"model.deployment_domain={domain}", - ], - ) - artifact = build_inference_config(cfg) - assert artifact["status"] == "ready" - graph = artifact["inference_graph"] - assert graph["native_output"]["shape"] == [100, native_width] - assert graph["output"]["shape"] == output_shape - decoder = instantiate(graph["profiles"]["default"]["adapter"]["decoder"]) - native = np.zeros((1, 100, native_width)) - for offset in (0, native_width // 2): - native[..., offset + 3] = 1 - native[..., offset + 7] = 1 - native[0, :, 0] = np.linspace(0, output_shape[0] - 1, 100) - decoded = decoder(native) - assert list(decoded.shape[1:]) == output_shape - np.testing.assert_allclose(decoded[0, :, 0], np.arange(output_shape[0]), atol=1e-10) - - -def test_boolean_and_enum_settings_bind_atomically(): - owner = SimpleNamespace(enabled=False, method="first") - graph = SimpleNamespace(pipeline=SimpleNamespace(stage_by_id=lambda _: owner)) - training = OmegaConf.create( - {"model": {"pipeline": {"stages": [{}], "stage_ids": {"backend": 0}}}} - ) - controls = { - "enabled": { - "label": "Enabled", - "type": "boolean", - "default": True, - "target": { - "kind": "stage_attribute", - "stage_id": "backend", - "attribute_path": "enabled", - }, - }, - "method": { - "label": "Method", - "type": "enum", - "choices": ["first", "second"], - "default": "second", - "target": { - "kind": "stage_attribute", - "stage_id": "backend", - "attribute_path": "method", - }, - }, - } - profile = {"default": {"stage_id": "backend", "overrides": controls}} - bad = deepcopy(profile) - bad["default"]["overrides"]["method"]["target"]["attribute_path"] = "absent" - with pytest.raises(ValueError, match="does not exist"): - configure_profile_controls(graph, training, bad) - assert owner.enabled is False and owner.method == "first" - bindings = configure_profile_controls(graph, training, profile) - assert owner.enabled is True and owner.method == "second" - with pytest.raises(ValueError, match="boolean"): - bindings[0].validate(1) - with pytest.raises(ValueError, match="one of"): - bindings[1].validate("third") diff --git a/tests/test_installed_package.py b/tests/test_installed_package.py deleted file mode 100644 index 9ba6a94ecf..0000000000 --- a/tests/test_installed_package.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Build independently and check wheel resources without checkout fallbacks.""" - -import json -import os -import shutil -import subprocess -import sys -import zipfile -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[1] - - -@pytest.fixture(scope="module") -def installed_wheel(tmp_path_factory): - work = tmp_path_factory.mktemp("package") - source = work / "source" - source.mkdir() - shutil.copytree( - ROOT / "egomimic", - source / "egomimic", - ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), - ) - for name in ("pyproject.toml", "README.md", "LICENSE"): - if (ROOT / name).exists(): - shutil.copy2(ROOT / name, source / name) - proc = subprocess.run( - ["uv", "build", "--wheel", "--out-dir", str(work / "dist")], - cwd=source, - text=True, - capture_output=True, - timeout=240, - ) - assert proc.returncode == 0, proc.stderr[-4000:] - wheel = next((work / "dist").glob("*.whl")) - with zipfile.ZipFile(wheel) as archive: - names = archive.namelist() - subprocess.run( - ["uv", "venv", str(work / "env"), "--python", sys.executable], - check=True, - capture_output=True, - ) - python = work / "env/bin/python" - subprocess.run( - ["uv", "pip", "install", "--python", str(python), "--no-deps", str(wheel)], - check=True, - capture_output=True, - ) - return work, python, names - - -def test_all_declared_resources_are_in_wheel(installed_wheel): - _, _, names = installed_wheel - required = set() - for folder in ( - "hydra_configs", - "resources", - "robot/teleop_dashboard_static", - "robot/rollout_dashboard_static", - ): - required.update( - str(path.relative_to(ROOT)) - for path in (ROOT / "egomimic" / folder).rglob("*") - if path.suffix - in {".yaml", ".json", ".xml", ".urdf", ".html", ".css", ".js"} - ) - required.update( - str(path.relative_to(ROOT)) - for path in (ROOT / "egomimic/robot/eva/stanford_repo/models/meshes/X5").glob( - "*.STL" - ) - ) - assert required <= set(names), sorted(required - set(names)) - assert not any(name.startswith(("tests/", "external/")) for name in names) - - -def test_fresh_install_loads_resources_outside_checkout(installed_wheel): - work, python, names = installed_wheel - resources = [name for name in names if name.startswith("egomimic/resources/")] - code = """ -import egomimic, json, pathlib, sys, xml.etree.ElementTree as ET -root = pathlib.Path(egomimic.__file__).parent -assert 'site-packages' in str(root), root -for name in json.loads(sys.argv[1]): - path = root.parent / name - ET.parse(path) -tree = ET.parse(root / 'resources/model_x5.xml') -meshdir = root / 'resources' / tree.find('compiler').attrib['meshdir'] -for mesh in tree.findall('asset/mesh'): - assert (meshdir / mesh.attrib['file']).is_file(), mesh.attrib -""" - env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"} - result = subprocess.run( - [str(python), "-c", code, json.dumps(resources)], - cwd=work, - env=env, - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr diff --git a/tests/test_legacy_data_metric_parity.py b/tests/test_legacy_data_metric_parity.py deleted file mode 100644 index dcf4d86833..0000000000 --- a/tests/test_legacy_data_metric_parity.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Numerical references executed in the immutable EgoVerse source checkout.""" - -import hashlib -import json -from pathlib import Path - -import numpy as np -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate - -from scripts.integration.reference_probe import probe -from tests.fixtures.synthetic_episodes import write_moving_episode - -ROOT = Path(__file__).parents[1] -REFERENCES = Path(__file__).parent / "fixtures/legacy_pipeline" -CASES = [ - (v, "cartesian", f) - for v in ("eva", "aria", "mecka", "scale") - for f in ("camframe", "eef_frame") -] + [("aria", "keypoints", f) for f in ("camframe", "eef_frame")] - - -@pytest.fixture(scope="module") -def episodes(tmp_path_factory): - root = tmp_path_factory.mktemp("moving-legacy-fixtures") - for vendor in ("eva", "aria", "mecka", "scale"): - for seed in range(3): - write_moving_episode(root / vendor, vendor, seed=seed) - return root - - -@pytest.mark.parametrize("vendor,representation,frame", CASES) -def test_preprocessing_normalization_and_headline_metrics_match_legacy( - episodes, vendor, representation, frame -): - name = f"{vendor}-{representation}-{frame}" - archive = REFERENCES / (name + ".npz") - reference = json.loads(archive.with_suffix(".json").read_text()) - assert reference["source_commit"] == "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2" - assert ( - hashlib.sha256(archive.read_bytes()).hexdigest() == reference["archive_sha256"] - ) - actual, metadata = probe(episodes, ROOT, vendor, representation, frame, "graph") - assert metadata["model_keys"] == reference["model_keys"] - assert metadata["normalization_frames"] == reference["normalization_frames"] - with np.load(archive, allow_pickle=False) as expected: - assert set(actual) == set(expected.files) - for key, value in actual.items(): - assert value.shape == expected[key].shape, key - if "observations.images." in key: - # JPEG decoder rounding can differ between platform wheels; - # compare routing/shape and pixels to one quantization unit. - np.testing.assert_allclose( - value, expected[key], rtol=0, atol=1 / 255, err_msg=key - ) - else: - np.testing.assert_allclose( - value, expected[key], rtol=2e-5, atol=2e-6, err_msg=key - ) - - -@pytest.mark.parametrize("kind", ["pooled", "pertoken"]) -def test_retained_language_schedule_matches_all_source_epochs(kind): - expected = json.loads( - (Path(__file__).parent / "fixtures/legacy_language_schedule.json").read_text() - ) - with initialize_config_dir( - version_base=None, config_dir=str(ROOT / "egomimic/hydra_configs") - ): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"model=hpt_bc_pickplace_qwen_{kind}"], - ) - assert cfg.model.scheduler_interval == "epoch" - optimizer = instantiate(cfg.model.optimizer)( - params=[torch.nn.Parameter(torch.ones(1))] - ) - scheduler = instantiate(cfg.model.scheduler)(optimizer=optimizer) - values = [optimizer.param_groups[0]["lr"]] - for _ in range(expected["total_epochs"]): - optimizer.step() - scheduler.step() - values.append(optimizer.param_groups[0]["lr"]) - assert values == pytest.approx(expected["values"], rel=1e-12, abs=1e-15) diff --git a/tests/test_legacy_hpt_trunk_parity.py b/tests/test_legacy_hpt_trunk_parity.py deleted file mode 100644 index 6c77aac535..0000000000 --- a/tests/test_legacy_hpt_trunk_parity.py +++ /dev/null @@ -1,93 +0,0 @@ -"""The generic initializer retains the source HPT trunk namespace and numerics.""" - -import hashlib -import json -from pathlib import Path - -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.stages_hpt import HPTTrunkStage -from scripts.audit_hydra_configs import CONFIGS - -FIXTURE = Path(__file__).parent / "fixtures/legacy_hpt_trunk" - - -@pytest.mark.parametrize("transport", ["local", "huggingface"]) -def test_legacy_trunk_loading_outputs_and_gradients(transport, monkeypatch): - metadata = json.loads((FIXTURE / "metadata.json").read_text()) - assert metadata["source_commit"] == "ec5c903c067bf1b29bbff781bc414c6df2bcf1f2" - for filename, key in ( - ("trunk.pth", "weights_sha256"), - ("reference.pt", "reference_sha256"), - ): - assert ( - hashlib.sha256((FIXTURE / filename).read_bytes()).hexdigest() - == metadata[key] - ) - reference = torch.load(FIXTURE / "reference.pt", weights_only=True) - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose( - config_name="train_zarr_cartesian", overrides=["model=hpt_bc_flow_eva"] - ) - core = cfg.model.pipeline.stages[1].trunk - core.embed_dim = core.attn_target.embed_dim = 16 - core.num_blocks = core.attn_target.num_heads = 2 - stage = HPTTrunkStage(instantiate(core), embed_dim=16) - source = str(FIXTURE / "trunk.pth") - downloads = [] - if transport == "huggingface": - # Substitute only transport: tensor loading/validation and the actual - # source HPT namespace remain the same as the local-file case. - source = { - "repo_id": "fixture/hpt", - "revision": "a" * 40, - "filename": "trunk.pth", - } - - def fetch(**kwargs): - downloads.append(kwargs) - return str(FIXTURE / "trunk.pth") - - monkeypatch.setattr("huggingface_hub.hf_hub_download", fetch) - graph = PipelineAlgo( - [stage], - device="cpu", - stage_ids={"trunk": 0}, - initialization=[ - { - "stage_id": "trunk", - "module_path": "trunk", - "source": source, - "source_prefix": metadata["source_namespace"], - "sha256": metadata["weights_sha256"], - } - ], - ) - assert graph.initialization_receipts[0]["sha256"] == metadata["weights_sha256"] - assert downloads == ([source] if transport == "huggingface" else []) - trunk = graph.pipeline.stage_by_id("trunk").trunk.eval() - inputs = reference["input"].clone().requires_grad_() - output, blocks = trunk(inputs, attn_mask=reference["mask"]) - loss = (output * reference["target"]).mean() + 0.1 * output.square().mean() - loss.backward() - torch.testing.assert_close(output, reference["output"], rtol=2e-5, atol=2e-6) - torch.testing.assert_close(loss, reference["loss"], rtol=2e-5, atol=2e-6) - torch.testing.assert_close( - inputs.grad, reference["input_grad"], rtol=2e-5, atol=2e-6 - ) - for actual, expected in zip(blocks, reference["blocks"], strict=True): - torch.testing.assert_close(actual, expected, rtol=2e-5, atol=2e-6) - assert {"trunk." + key for key, _ in trunk.named_parameters()} == set( - reference["parameter_grad"] - ) - for name, parameter in trunk.named_parameters(): - torch.testing.assert_close( - parameter.grad, - reference["parameter_grad"]["trunk." + name], - rtol=2e-5, - atol=2e-6, - ) diff --git a/tests/test_libero_action_flow_artifacts.py b/tests/test_libero_action_flow_artifacts.py deleted file mode 100644 index 24d6287ce1..0000000000 --- a/tests/test_libero_action_flow_artifacts.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Tiny pure-metadata validation; no model or data construction.""" - -import pytest - -from egomimic.benchmarks.libero.action_flow_artifacts import ( - REQUIRED, - SEED_SHA, - canonical_sha, - validate_payload, -) - - -def fixture(): - identity = { - "suite": "libero10", - "source": "libero_panda", - "action_dim": 7, - "action_horizon": 16, - "seed": 42, - "sample_count": 32, - "energy_seed_bank_sha256": SEED_SHA, - "inference_method": "euler", - "inference_steps": 50, - "normalization_scope": "training_episodes_only", - "effective_batch_size": 32, - "homogeneous": "not_applicable_single_source", - "source_commit": "a" * 40, - "resolved_config_sha256": "b" * 64, - "split_sha256": "c" * 64, - "normalizer_state_sha256": "d" * 64, - "dataset_logical_sha256": "e" * 64, - } - tensor_metadata = { - name: { - "shape": [32, 16, 7], - "axes": ["batch", "horizon", "action"], - "finite": True, - "sha256": "f" * 64, - } - for name in ( - "normalized_prediction", - "native_prediction", - "normalized_target", - "native_target", - ) - } - for name in ("normalized_samples", "native_samples"): - tensor_metadata[name] = { - "shape": [32, 32, 16, 7], - "axes": ["sample", "batch", "horizon", "action"], - "finite": True, - "sha256": "f" * 64, - } - for name in ("diagnostic/latent/clean", "diagnostic/decoded/reconstruction"): - tensor_metadata[name] = {"finite": True, "sha256": "f" * 64} - return { - "schema": "libero-native-action-flow-metrics/v2", - "identity": identity, - "identity_sha256": canonical_sha(identity), - "source": "libero_panda", - "global_step": 2, - "batches": [ - { - "batch_index": 0, - "batch_size": 32, - "normalized_target_sha256": "f" * 64, - "metrics": {key: 1.0 for key in REQUIRED}, - # Pure metadata fixture: validates reference contracts only, - # never proves payload bytes, actual episodes or a run PASS. - "tensor_payload": { - "schema": "libero-native-tensors/v1", - "filename": "fixture.pt", - "sha256": "f" * 64, - "tensors": tensor_metadata, - "alignment": {"fixture": "explicit-synthetic-metadata"}, - "shared_analysis": { - "sha256": "f" * 64, - "identity_sha256": "f" * 64, - "path": "fixture-analysis.pt", - "global_step": 2, - }, - }, - } - ], - } - - -def test_native_complete(): - assert validate_payload(fixture()) - - -def test_legacy_metric_only_schema_does_not_certify_native_tensors(): - payload = fixture() - payload["schema"] = "libero-native-action-flow-metrics/v1" - with pytest.raises(ValueError, match="wrong native artifact schema"): - validate_payload(payload) - - -def test_reference_rejects_wrong_sample_axes(): - payload = fixture() - payload["batches"][0]["tensor_payload"]["tensors"]["native_samples"]["axes"] = [ - "batch", - "sample", - "horizon", - "action", - ] - with pytest.raises(ValueError, match="K32 tensor contract"): - validate_payload(payload) - - -@pytest.mark.parametrize("key", REQUIRED) -def test_reject_nonfinite(key): - payload = fixture() - payload["batches"][0]["metrics"][key] = float("nan") - with pytest.raises(ValueError): - validate_payload(payload) - - -@pytest.mark.parametrize( - "key", - ( - "source_commit", - "resolved_config_sha256", - "split_sha256", - "normalizer_state_sha256", - "dataset_logical_sha256", - "energy_seed_bank_sha256", - "normalization_scope", - "inference_method", - "inference_steps", - "effective_batch_size", - ), -) -def test_reject_identity(key): - payload = fixture() - payload["identity"][key] = "wrong" - payload["identity_sha256"] = canonical_sha(payload["identity"]) - with pytest.raises(ValueError): - validate_payload(payload) - - -def test_missing_first_batch(): - payload = fixture() - payload["batches"][0]["batch_index"] = 1 - with pytest.raises(ValueError): - validate_payload(payload) - - -def test_no_implicit_checkpoint_completion(): - payload = fixture() - payload["global_step"] = 0 - with pytest.raises(ValueError): - validate_payload(payload) diff --git a/tests/test_libero_action_flow_boundary.py b/tests/test_libero_action_flow_boundary.py deleted file mode 100644 index b11cb55321..0000000000 --- a/tests/test_libero_action_flow_boundary.py +++ /dev/null @@ -1,240 +0,0 @@ -import hashlib -import json -import weakref -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -from hydra import compose, initialize_config_dir - -from egomimic.eval.libero_action_flow_eval import LiberoActionFlowEvaluator -from egomimic.pipeline.libero_action_flow import LiberoActionFlowObservationAdapter -from egomimic.pl_utils.training_behavior_action_flow import ActionFlowTrainingBehavior -from egomimic.rldb.zarr.libero_action_flow import LiberoActionFlowDataset - - -def _native_profile(seed_file, tmp_path): - """Explicit synthetic constructor inputs, not a native run receipt.""" - from egomimic.benchmarks.libero.native_diagnostic_config import ( - build_native_diagnostic_config, - ) - - return build_native_diagnostic_config( - { - "energy_seed_bank_sha256": hashlib.sha256( - seed_file.read_bytes() - ).hexdigest(), - "split_sha256": "synthetic-boundary-test", - }, - str(tmp_path / "diagnostics"), - str(seed_file), - { - "num_latent_tokens": 8, - "latent_dim": 16, - "num_inference_steps": 50, - "pipeline": { - "stages": [ - { - "_target_": "fixture.ContentEncoderStage", - "encoder": {"backbone": {"depth": 12}}, - }, - { - "_target_": "fixture.ConditionalVelocityStage", - "field": {"backbone": {"depth": 12}}, - "inference_method": "euler", - }, - ] - }, - }, - ) - - -def test_libero_action_flow_recipe_selects_effective_train_and_validation_data( - monkeypatch, -): - monkeypatch.setenv("LIBERO10_REPLAY_ROOT", "/tmp/libero10-replay-for-compose") - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(config_dir=str(config_dir), version_base="1.3"): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=libero_historical/action_flow_libero10_avln_80k_s42" - ], - ) - assert "data" not in cfg.data - assert cfg.data.train_datasets.libero_panda._target_.endswith( - "libero_action_flow.LiberoActionFlowDataset._from_resolver" - ) - assert cfg.data.valid_datasets.libero_panda._target_.endswith( - "libero_action_flow.LiberoActionFlowDataset._from_resolver" - ) - assert cfg.data.train_datasets.libero_panda.valid_ratio == 0.01 - assert cfg.data.valid_datasets.libero_panda.valid_ratio == 0.01 - assert cfg.data.train_datasets.libero_panda.split_seed == 42 - assert cfg.data.valid_datasets.libero_panda.split_seed == 42 - - -def test_logical_replay_keys_are_normalized_and_invertible(): - dataset = LiberoActionFlowDataset.__new__(LiberoActionFlowDataset) - dataset.norm_stats = { - 21: { - "actions": { - "scale": [2.0] * 7, - "offset": [-1.0] * 7, - } - } - } - raw = torch.full((2, 16, 7), 0.25) - task = torch.tensor([[2.0], [5.0]]) - normalized = dataset.normalize({"actions": raw, "task_uid": task}, 21) - assert torch.allclose(normalized["actions"], torch.full_like(raw, -0.5)) - assert torch.equal(normalized["task_uid"], task) - recovered = dataset.unnormalize(normalized, 21) - assert torch.allclose(recovered["actions"], raw) - - -def test_libero_observation_adapter_shapes_and_integer_task_identity(): - adapter = LiberoActionFlowObservationAdapter() - batch = { - "agentview_rgb": torch.zeros(2, 1, 128, 128, 3), - "robot0_eye_in_hand_rgb": torch.zeros(2, 1, 128, 128, 3), - "robot0_eef_pos": torch.zeros(2, 1, 3), - "robot0_eef_quat": torch.zeros(2, 1, 4), - "robot0_gripper_qpos": torch.zeros(2, 1, 2), - "task_uid": torch.tensor([[1.0], [2.0]]), - } - result = adapter(batch) - assert result["front_img_1"].shape == (2, 3, 96, 96) - assert result["front_img_2"].shape == (2, 3, 96, 96) - assert result["proprio_condition"].shape == (2, 64) - batch["task_uid"] = torch.tensor([[1.5], [2.0]]) - try: - adapter(batch) - except ValueError as error: - assert "unnormalized integer IDs" in str(error) - else: - raise AssertionError("fractional task IDs must fail closed") - - -def test_libero_validation_dispatches_generic_action_flow_diagnostic( - tmp_path, monkeypatch -): - seed_file = tmp_path / "seeds.json" - seed_file.write_text(json.dumps({"seeds": list(range(32))})) - - class IdentityNormalizer: - def unnormalize(self, values, embodiment): - del embodiment - return values - - class GenericModel: - global_rank = current_epoch = global_step = 0 - trainer = SimpleNamespace(precision="32-true") - - def __init__(self): - self.logged = [] - self.diagnostic_calls = [] - - def forward_eval(self, batch): - return { - source: {"pred_action": values["actions"]} - for source, values in batch.items() - } - - def run_diagnostic(self, capability, batch, **kwargs): - self.diagnostic_calls.append((capability, kwargs)) - return { - source: { - "schema": "action-flow-validation-diagnostics/v1", - "latent/clean": torch.ones(2, 8, 16), - "decoded/reconstruction": torch.ones(2, 16, 7), - } - for source in batch - } - - def log(self, name, value, **kwargs): - self.logged.append((name, value, kwargs)) - - evaluator = LiberoActionFlowEvaluator( - energy_seed_bank_path=seed_file, - energy_seed_bank_sha256=hashlib.sha256(seed_file.read_bytes()).hexdigest(), - native_diagnostic_config=_native_profile(seed_file, tmp_path), - ) - # This test isolates wrapper/evaluator dispatch; it does not certify the - # native model's owner topology. Numeric analyzer tests are separate. - owner_calls = [] - monkeypatch.setattr( - "egomimic.benchmarks.libero.native_diagnostic_config.assert_native_diagnostic_owners", - owner_calls.append, - ) - analyses = [] - - def analyze(**kwargs): - analyses.append(kwargs) - return {"metrics": {}, "artifact": {}} - - monkeypatch.setattr(evaluator.shared_diagnostics, "analyze_precomputed", analyze) - model = GenericModel() - evaluator.model = model - evaluator.bind_data_context(normalizer=IdentityNormalizer()) - evaluator.on_validation_step({"libero": {"actions": torch.zeros(2, 16, 7)}}, 0) - assert len(model.diagnostic_calls) == 1 - assert model.diagnostic_calls[0][0] == "action_flow" - assert owner_calls == [model] - assert len(analyses) == 1 - assert analyses[0]["native_error_fns"]["libero"]( - torch.zeros(2, 16, 7), torch.ones(2, 16, 7) - ).tolist() == [1.0, 1.0] - assert any( - name == "Valid/energy_score32_native_equal_components" - for name, _, _ in model.logged - ) - assert any( - name == "Valid/diagnostic_clean_latent_rms" for name, _, _ in model.logged - ) - assert any( - name == "Valid/diagnostic_clean_decoded_action_normalized_rms" - for name, _, _ in model.logged - ) - - -def test_libero_diagnostic_binds_wrapper_not_inner_pipeline(tmp_path): - seed_file = tmp_path / "seeds.json" - seed_file.write_text(json.dumps({"seeds": list(range(32))})) - evaluator = LiberoActionFlowEvaluator( - energy_seed_bank_path=seed_file, - energy_seed_bank_sha256=hashlib.sha256(seed_file.read_bytes()).hexdigest(), - native_diagnostic_config=_native_profile(seed_file, tmp_path), - ) - inner_pipeline = SimpleNamespace(device=None) - evaluator.model = inner_pipeline - - class Wrapper: - def __init__(self): - self.model = inner_pipeline - self.device = torch.device("cpu") - self.evaluator = evaluator - - wrapper = Wrapper() - behavior = ActionFlowTrainingBehavior() - behavior._context_ref = weakref.ref(wrapper) - behavior._validation_metrics = SimpleNamespace(reset=lambda: None) - behavior.on_validation_start() - assert evaluator.model is wrapper - assert inner_pipeline.device == wrapper.device - - -def test_libero_diagnostic_profile_is_not_implicitly_waived(tmp_path): - seed_file = tmp_path / "seeds.json" - seed_file.write_text(json.dumps({"seeds": list(range(32))})) - kwargs = { - "energy_seed_bank_path": seed_file, - "energy_seed_bank_sha256": hashlib.sha256(seed_file.read_bytes()).hexdigest(), - } - with pytest.raises(ValueError, match="explicit native same-pass"): - LiberoActionFlowEvaluator(**kwargs) - profile = _native_profile(seed_file, tmp_path) - profile["max_samples"] = 7 - with pytest.raises(ValueError, match="native8/k2/all12"): - LiberoActionFlowEvaluator(**kwargs, native_diagnostic_config=profile) diff --git a/tests/test_libero_active_recipes.py b/tests/test_libero_active_recipes.py deleted file mode 100644 index 56de56fb90..0000000000 --- a/tests/test_libero_active_recipes.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Keep active LIBERO choices unambiguous without loading data or checkpoints.""" - -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -ROOT = Path(__file__).resolve().parents[1] -CONFIG = ROOT / "egomimic/hydra_configs" -TARGETS = { - "libero10": 605121, - "libero_spatial": 270054, - "libero_object": 325065, - "libero_goal": 280056, -} - - -def test_active_directory_contains_selected_eight_and_explicit_joint_recipe(): - expected = { - f"{family}_{suite}_{suffix}_s42.yaml" - for suite in TARGETS - for family, suffix in ( - ("action_flow", "oat_dp_matched"), - ("dp", "oat_batch16_keep_steps"), - ) - } - # The joint experiment is separate from the original four AF/DP pairs. - expected.add("action_flow_libero_spatial_sharpa_gripper_keypoints_s42.yaml") - expected.add("action_flow_libero_spatial_sharpa_mot_s42.yaml") - assert {p.name for p in (CONFIG / "experiment/libero").glob("*.yaml")} == expected - - -@pytest.mark.parametrize("suite,steps", TARGETS.items()) -def test_active_pair_preserves_selected_scientific_contract(suite, steps): - with initialize_config_dir(config_dir=str(CONFIG), version_base="1.3"): - af = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=libero/action_flow_{suite}_oat_dp_matched_s42"], - ) - dp = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=libero/dp_{suite}_oat_batch16_keep_steps_s42"], - ) - assert (af.model.num_latent_tokens, af.model.latent_dim) == (16, 16) - assert af.model.pipeline.stages[-1].action_velocity_weight == 1 - assert (af.trainer.max_steps, af.trainer.val_check_interval) == (120000, 20000) - assert af.trainer.check_val_every_n_epoch is None - assert af.callbacks.ema.decay == 0.9978 - assert af.trainer.gradient_clip_val == 3 - assert af.benchmark.batch_size == 32 - assert ( - dp.benchmark.batch_size, - dp.trainer.accumulate_grad_batches, - dp.trainer.max_steps, - ) == (16, 1, steps) - assert dp.callbacks.batch_budget.global_batch_size == 16 - if suite == "libero_spatial": - assert dp.callbacks.model_checkpoint.every_n_train_steps == 15000 - assert dp.callbacks.model_checkpoint.every_n_epochs is None - assert af.model.pipeline.stages[0] == dp.model.pipeline.stages[0] - for cfg in (af, dp): - assert cfg.data.train_datasets.libero_panda.valid_ratio == 0.1 - assert cfg.data.valid_datasets.libero_panda.valid_ratio == 0.1 - - -@pytest.mark.parametrize( - "recipe", sorted((CONFIG / "experiment/libero_historical").glob("*.yaml")) -) -def test_historical_profiles_remain_composable(recipe): - with initialize_config_dir(config_dir=str(CONFIG), version_base="1.3"): - compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=libero_historical/{recipe.stem}"], - ) diff --git a/tests/test_libero_arc.py b/tests/test_libero_arc.py deleted file mode 100644 index 7ef5c3ffc1..0000000000 --- a/tests/test_libero_arc.py +++ /dev/null @@ -1,177 +0,0 @@ -import numpy as np -import pytest - -from egomimic.rldb.zarr.libero_arc import LiberoArcCodec - - -@pytest.mark.parametrize( - "motion", ["translation", "rotation", "gripper", "mixed", "hold", "loop"] -) -def test_dense_arc_roundtrip_all_seven_control_channels(motion): - n = 32 - codec = LiberoArcCodec(num_waypoints=n + 1, horizon=n) - actions = np.zeros((n, 7)) - actions[:, 6] = -1 - if motion in ("translation", "mixed"): - actions[:, :3] = np.random.default_rng(3).uniform(-0.3, 0.3, (n, 3)) - if motion in ("rotation", "mixed"): - actions[:, 3:6] = np.random.default_rng(4).uniform(-0.5, 0.5, (n, 3)) - if motion in ("gripper", "mixed"): - actions[12:, 6] = 1 - if motion == "loop": - phase = np.linspace(0, 2 * np.pi, n + 1) - actions[:, :2] = np.diff( - np.column_stack((np.cos(phase), np.sin(phase))), axis=0 - ) - tokens = codec.encode(actions) - assert np.isfinite(tokens).all() - assert tokens[:-1, 10].sum() == pytest.approx(n * codec.dt) - np.testing.assert_allclose(codec.decode(tokens), actions, atol=8e-6) - - -def test_holds_keep_duration_and_zero_command_intervals(): - codec = LiberoArcCodec(num_waypoints=8) - actions = np.zeros((32, 7)) - actions[:, 6] = -1 - actions[8:16, 0] = 0.1 - actions[24:28, 0] = 0.1 - decoded = codec.decode(codec.encode(actions)) - np.testing.assert_allclose(decoded[:8, :6], 0, atol=2e-7) - np.testing.assert_allclose(decoded[16:24, :6], 0, atol=2e-7) - np.testing.assert_allclose(decoded[28:, :6], 0, atol=2e-7) - np.testing.assert_allclose(decoded[:, 0].sum(), actions[:, 0].sum(), atol=2e-6) - - -def test_pure_rotation_crosses_pi_and_preserves_direction(): - codec = LiberoArcCodec(num_waypoints=16) - actions = np.zeros((32, 7)) - actions[:, 5] = 0.25 - decoded = codec.decode(codec.encode(actions)) - np.testing.assert_allclose(decoded[:, :3], 0, atol=1e-7) - np.testing.assert_allclose(decoded[:, 3:6], actions[:, 3:6], atol=2e-6) - - -def test_time_scale_changes_duration_without_changing_arc_geometry(): - actions = np.random.default_rng(71).uniform(-0.2, 0.2, (32, 7)) - actions[:, 6] = -1 - actions[16:, 6] = 1 - original = LiberoArcCodec(num_waypoints=16, dt=0.05) - slower = LiberoArcCodec(num_waypoints=16, dt=0.1) - tokens, slow_tokens = original.encode(actions), slower.encode(actions) - np.testing.assert_allclose(slow_tokens[:, :10], tokens[:, :10], atol=1e-7) - np.testing.assert_allclose(slow_tokens[:, 10], 2 * tokens[:, 10], atol=1e-7) - np.testing.assert_allclose( - slower.decode(slow_tokens), original.decode(tokens), atol=1e-6 - ) - - -def test_gripper_only_transitions_and_short_prefixes_are_distinct(): - codec = LiberoArcCodec(num_waypoints=8) - actions = np.zeros((32, 7)) - actions[:, 6] = -1 - actions[10:, 6] = 1 - np.testing.assert_allclose(codec.decode(codec.encode(actions)), actions, atol=2e-6) - - -def test_predicted_degenerate_rotation_and_negative_duration_are_finite(): - codec = LiberoArcCodec(num_waypoints=4) - tokens = np.zeros((4, 11)) - tokens[:, 10] = [-1, 0, 0.2, 0] - assert np.isfinite(codec.decode(tokens)).all() - - -def test_arc_stage_decodes_bfloat16_predictions(): - import torch - - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - stage = LiberoArcStage(operation="decode", num_waypoints=4) - predicted = torch.zeros(2, 4, 11, dtype=torch.bfloat16) - predicted[:, :, 10] = 0.25 - result = stage.execute({"pred_arc": predicted}, mode="inference") - assert result["pred_action"].shape == (2, 32, 7) - assert torch.isfinite(result["pred_action"]).all() - - -@pytest.mark.parametrize("bad", [np.nan, np.inf]) -def test_bad_commands_fail_without_replacement(bad): - actions = np.zeros((32, 7)) - actions[2, 3] = bad - with pytest.raises(ValueError, match="finite"): - LiberoArcCodec().encode(actions) - - -def test_batch_stage_unnormalizes_once_and_restores_normalized_actions(): - from types import SimpleNamespace - - import torch - - from egomimic.pipeline.algo import PipelineAlgo - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - from egomimic.rldb.zarr.libero_dataset import EMBODIMENT - - scale, offset = ( - np.arange(1, 8, dtype=np.float32), - np.arange(7, dtype=np.float32) / 5, - ) - norm = SimpleNamespace( - norm_stats={EMBODIMENT: {"actions": {"scale": scale, "offset": offset}}}, - to_state=lambda: {}, - tokenizer_context=lambda: {}, - ) - algo = PipelineAlgo( - [LiberoArcStage(num_waypoints=33, reconstruction=True)], device="cpu" - ) - algo.bind_data_context(normalizer=norm) - native = torch.randn(2, 32, 7) / 5 - normalized = native * torch.tensor(scale) + torch.tensor(offset) - result = algo.forward_eval({"libero_panda": {"actions": normalized}}) - torch.testing.assert_close( - result["libero_panda"]["pred_action"], normalized, atol=1e-5, rtol=1e-5 - ) - - -def test_distance_cap_keeps_original_clock_and_fractional_final_command(): - codec = LiberoArcCodec(num_waypoints=36, max_translation=0.055) - actions = np.zeros((32, 7)) - actions[:, 0] = 0.2 # 1 cm per control interval. - actions[:, 6] = -1 - tokens = codec.encode(actions) - assert tokens[:, 10].sum() == pytest.approx(0.275) - decoded = codec.decode(tokens) - np.testing.assert_allclose(decoded[:5, 0], 0.2, atol=1e-6) - assert decoded[5, 0] == pytest.approx(0.1, abs=1e-6) - np.testing.assert_allclose(decoded[6:, :6], 0, atol=1e-6) - np.testing.assert_allclose(decoded[:, 6], -1) - - -def test_rotation_horizon_is_degrees_and_works_without_translation(): - actions = np.zeros((32, 7)) - actions[:, 5] = np.deg2rad(3) / 0.5 - codec = LiberoArcCodec(num_waypoints=36, max_rotation_degrees=12) - tokens = codec.encode(actions) - assert tokens[:, 10].sum() == pytest.approx(4 * codec.dt) - decoded = codec.decode(tokens) - np.testing.assert_allclose(decoded[:4, :6], actions[:4, :6], atol=2e-6) - np.testing.assert_allclose(decoded[4:, :6], 0, atol=2e-6) - - -def test_first_budget_crossing_preserves_leading_hold(): - actions = np.zeros((32, 7)) - actions[8:, 0] = 0.2 - actions[8:, 5] = np.deg2rad(4) / 0.5 - codec = LiberoArcCodec( - num_waypoints=36, max_translation=0.02, max_rotation_degrees=12 - ) - tokens = codec.encode(actions) - assert tokens[:, 10].sum() == pytest.approx(10 * codec.dt) - decoded = codec.decode(tokens) - np.testing.assert_allclose(decoded[:10, :6], actions[:10, :6], atol=2e-6) - np.testing.assert_allclose(decoded[10:, :6], 0, atol=2e-6) - - -@pytest.mark.parametrize("parameter", ["max_translation", "max_rotation_degrees"]) -@pytest.mark.parametrize("value", [0, -1, np.nan, np.inf]) -def test_invalid_horizon_budgets_rejected(parameter, value): - with pytest.raises(ValueError, match="budgets"): - LiberoArcCodec(**{parameter: value}) diff --git a/tests/test_libero_arc_global.py b/tests/test_libero_arc_global.py deleted file mode 100644 index 2038b3f159..0000000000 --- a/tests/test_libero_arc_global.py +++ /dev/null @@ -1,295 +0,0 @@ -import numpy as np -import pytest - -from egomimic.rldb.zarr.arc_global_basis import evaluate_curve, fit_curve -from egomimic.rldb.zarr.libero_arc_global import LiberoArcGlobalCodec -from egomimic.rldb.zarr.libero_arc_timed import ( - codec_source_files, - make_libero_arc_codec, -) - - -@pytest.mark.parametrize("basis", ["chebyshev", "fourier", "cosine"]) -def test_shared_global_basis_is_anchored_and_roundtrips_coefficients(basis): - coefficients = np.random.default_rng(12).normal(size=(16, 6)) - grid = np.linspace(0, 1, 257) - values = evaluate_curve(coefficients, grid, basis) - np.testing.assert_allclose(fit_curve(values, 16, basis), coefficients, atol=1e-11) - np.testing.assert_allclose(values[0], coefficients[0], atol=1e-12) - - -@pytest.mark.parametrize("basis", ["uniform", "chebyshev", "fourier"]) -@pytest.mark.parametrize("motion", ["translation", "rotation", "stationary"]) -def test_constants_and_pi_crossing(basis, motion): - actions = np.zeros((32, 7)) - actions[:, 6] = -1 - actions[11:23, 6] = 1 - if motion == "translation": - actions[:, :3] = [0.1, -0.2, 0.05] - if motion == "rotation": - actions[:, 5] = 0.25 # Accumulated rotation crosses pi. - codec = LiberoArcGlobalCodec(basis=basis) - token = codec.encode(actions) - assert token.shape == (32, 12) - decoded = codec.decode(token) - np.testing.assert_allclose(decoded[:, :3], actions[:, :3], atol=5e-6) - np.testing.assert_allclose(decoded[:, 6], actions[:, 6], atol=2e-6) - assert np.mean((decoded[:, 3:6] - actions[:, 3:6]) ** 2) < 2e-4 - assert codec.represented_seconds(token) == pytest.approx(1.6, abs=1e-6) - - -def test_open_fourier_curve_is_not_forced_closed(): - grid = np.linspace(0, 1, 257) - values = np.column_stack((2 * grid, np.sin(2 * np.pi * grid))) - coefficients = fit_curve(values, 16, "fourier") - np.testing.assert_allclose( - evaluate_curve(coefficients, grid, "fourier"), values, atol=1e-12 - ) - - -def test_high_order_chebyshev_requires_stable_actual_sample_grid(): - coefficients = np.random.default_rng(5).normal(size=(128, 2)) / 128 - uniform = np.linspace(0, 1, 257) - with pytest.raises(ValueError, match="Ill-conditioned"): - fit_curve(evaluate_curve(coefficients, uniform, "chebyshev"), 128, "chebyshev") - lobatto = (1 - np.cos(np.linspace(0, np.pi, 257))) / 2 - fitted = fit_curve( - evaluate_curve(coefficients, lobatto, "chebyshev"), - 128, - "chebyshev", - sample_grid=lobatto, - ) - np.testing.assert_allclose(fitted, coefficients, atol=1e-12) - with pytest.raises(ValueError, match="sample grid"): - fit_curve(np.zeros((257, 2)), 128, "chebyshev", sample_grid=lobatto[::-1]) - - -def test_high_order_chebyshev_codec_lobatto_roundtrip_and_context(): - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - codec = LiberoArcGlobalCodec( - basis="chebyshev", geometry=128, geometry_fit_grid="chebyshev_lobatto" - ) - actions = np.zeros((32, 7)) - actions[:, :3] = [0.1, -0.05, 0.2] - tokens = codec.encode(actions) - assert tokens.shape == (104, 12) and abs(tokens).max() < 2 - np.testing.assert_allclose(codec.decode(tokens), actions, atol=1e-5) - assert ( - LiberoArcStage(codec=codec).representation_context()["geometry_fit_grid"] - == "chebyshev_lobatto" - ) - with pytest.raises(ValueError, match="only supported"): - LiberoArcGlobalCodec(basis="uniform", geometry_fit_grid="chebyshev_lobatto") - - -@pytest.mark.parametrize("basis", ["uniform", "fourier", "chebyshev"]) -def test_stationary_rotation_tail_endpoint_roundoff_is_safe(monkeypatch, basis): - codec = LiberoArcGlobalCodec(basis=basis) - original = codec._stream - - def rounded_endpoint(*args): - frames, progress, clock, duration = original(*args) - progress[progress >= 1 - 1e-14] = np.nextafter(1.0, 0.0) - return frames, progress, clock, duration - - monkeypatch.setattr(codec, "_stream", rounded_endpoint) - actions = np.zeros((32, 7)) - actions[:16, 5] = 0.1 - assert np.isfinite(codec.decode(codec.encode(actions))).all() - - -def test_geometry_is_the_only_difference_between_arms(): - actions = np.random.default_rng(4).uniform(-0.2, 0.2, (32, 7)) - outputs = [ - LiberoArcGlobalCodec(basis=b).encode(actions).ravel() - for b in ("uniform", "fourier", "chebyshev") - ] - for other in outputs[1:]: - np.testing.assert_array_equal(other[9 * 32 :], outputs[0][9 * 32 :]) - - -@pytest.mark.parametrize("basis", ["uniform", "chebyshev", "fourier"]) -def test_independent_pauses_and_time_scaling(basis): - actions = np.zeros((32, 7)) - actions[8:16, 0] = 0.2 - actions[24:28, 0] = 0.2 - actions[4:12, 5] = 0.2 - actions[20:24, 5] = -0.2 - actions[:, 6] = -1 - actions[16:, 6] = 1 - fast, slow = (LiberoArcGlobalCodec(basis=basis, dt=dt) for dt in (0.05, 0.1)) - a, b = fast.encode(actions), slow.encode(actions) - np.testing.assert_array_equal(a.ravel()[:-2], b.ravel()[:-2]) - np.testing.assert_allclose(b.ravel()[-2:], a.ravel()[-2:] * 2) - decoded = fast.decode(a) - np.testing.assert_allclose(slow.decode(b), decoded, atol=1e-6) - assert np.mean((decoded - actions) ** 2) < 5e-4 - assert abs(decoded[1, 0]) < 0.003 - assert abs(decoded[18, 0]) < 0.003 - np.testing.assert_array_equal(np.sign(decoded[:, 6]), actions[:, 6]) - - -def test_fractional_independent_budgets_and_invalid_predictions(): - actions = np.zeros((32, 7)) - actions[:, 0] = 0.2 - actions[:, 5] = np.deg2rad(3) / 0.5 - codec = LiberoArcGlobalCodec( - basis="uniform", max_translation=0.055, max_rotation_degrees=13.5 - ) - decoded = codec.decode(codec.encode(actions)) - np.testing.assert_allclose(decoded[:5, 0], 0.2, atol=4e-6) - assert decoded[5, 0] == pytest.approx(0.1, abs=4e-6) - np.testing.assert_allclose(decoded[6:, 0], 0, atol=4e-6) - assert decoded[4, 5] == pytest.approx(actions[4, 5] / 2, abs=4e-6) - np.testing.assert_allclose(codec.decode(np.zeros((32, 12))), 0, atol=1e-12) - with pytest.raises(ValueError, match="nonfinite"): - codec.decode(np.full((32, 12), np.nan)) - with pytest.raises(ValueError, match="rows differ"): - LiberoArcGlobalCodec(num_waypoints=16) - - -def test_factory_source_gate_and_graph_normalization_once(): - import torch - - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - assert make_libero_arc_codec(mode="global_basis").mode == "global_basis" - assert "egomimic/rldb/zarr/arc_global_basis.py" in codec_source_files( - "global_basis" - ) - stage = LiberoArcStage( - arc_mode="global_basis", reconstruction=True, encode_cache_size=2 - ) - stage.action_scale.copy_(torch.arange(1, 8)) - stage.action_offset.copy_(torch.arange(7) / 5) - native = torch.zeros(2, 32, 7) - native[:, :, 0] = 0.1 - normalized = native * stage.action_scale + stage.action_offset - result = stage.execute({"actions": normalized}, mode="inference") - torch.testing.assert_close(result["pred_action"], normalized, atol=1e-5, rtol=1e-5) - decoder = LiberoArcStage(arc_mode="global_basis", operation="decode") - assert torch.isfinite( - decoder.execute({"pred_arc": result["target"].bfloat16()}, mode="inference")[ - "pred_action" - ] - ).all() - original = stage._encode(native.numpy()).copy() - stage.codec.basis = "fourier" - changed = stage._encode(native.numpy()) - assert not np.array_equal(original, changed) - assert stage.representation_context()["basis"] == "fourier" - - -def test_matched_configs_and_real_network_dimensions(): - from pathlib import Path - - from hydra import compose, initialize_config_dir - from hydra.utils import instantiate - from omegaconf import OmegaConf - - resolved = [] - with initialize_config_dir( - version_base=None, - config_dir=str(Path(__file__).parents[1] / "egomimic/hydra_configs"), - ): - for basis in ("uniform", "fourier", "chebyshev"): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - f"+experiment=oat/libero_arc_global_{basis}", - "benchmark.dataset=/unused-for-construction", - "hydra/launcher=basic", - ], - ) - graph = instantiate(cfg.model.pipeline, device="cpu") - stages = graph.pipeline.stages - assert ( - stages[1].representation_context() - == stages[-1].representation_context() - ) - assert sum(p.numel() for p in graph.nets.parameters()) == 27_185_044 - assert ( - sum(p.numel() for p in graph.nets.parameters() if p.requires_grad) - == 27_184_972 - ) - assert stages[1].codec.scalars == 384 - assert not cfg.model.pipeline.stages[3].policy.noise_scheduler.clip_sample - model = OmegaConf.to_container(cfg.model, resolve=True) - for index in (1, -1): - assert model["pipeline"]["stages"][index]["codec"].pop("basis") == basis - resolved.append(model) - assert resolved[0] == resolved[1] == resolved[2] - - -def test_fixed_replay_keeps_all_three_arms_and_native_reconstruction_path(): - from pathlib import Path - - import yaml - - from egomimic.benchmarks.libero.replay import ( - candidates_from_spec, - reconstruct_episode, - validate_spec, - ) - - spec = yaml.safe_load( - ( - Path(__file__).parents[1] - / "egomimic/hydra_configs/benchmark/libero_arc_global_replay.yaml" - ).read_text() - ) - validate_spec(spec) - candidates = candidates_from_spec(spec) - assert {c["basis"] for c in candidates.values()} == { - "uniform", - "fourier", - "chebyshev", - } - actions = np.zeros((73, 7)) - actions[:, 0] = 0.1 - actions[:, 6] = -1 - for candidate in candidates.values(): - decoded, metrics = reconstruct_episode(actions, candidate, spec) - np.testing.assert_allclose(decoded, actions, atol=5e-6) - assert metrics["execution_coverage"] == pytest.approx(1) - - -def test_local_replay_never_initializes_object_store(tmp_path, monkeypatch): - import importlib.metadata - import json - from pathlib import Path - - from egomimic.benchmarks.libero import replay - - spec = ( - Path(__file__).parents[1] - / "egomimic/hydra_configs/benchmark/libero_arc_global_replay.yaml" - ) - monkeypatch.setenv("SOURCE_COMMIT", "a" * 40) - monkeypatch.setattr(replay.subprocess, "check_output", lambda *a, **kw: "a" * 40) - monkeypatch.setattr(importlib.metadata, "version", lambda name: "fixture") - - def forbidden(*args, **kwargs): - raise AssertionError("Local-only replay must not touch object storage") - - monkeypatch.setattr(replay, "ArtifactUploader", forbidden) - monkeypatch.setattr(replay, "calibrate", lambda *a, **kw: {"confirmed": True}) - monkeypatch.setattr( - "sys.argv", - [ - "replay", - "--root", - str(tmp_path), - "--suite", - "libero_10", - "--run-id", - "test", - "--spec", - str(spec), - "--local-only", - ], - ) - replay.main() - runtime = json.loads((tmp_path / "evidence/runtime.json").read_text()) - assert runtime["local_only"] and runtime["artifact_prefix"] is None diff --git a/tests/test_libero_arc_sweep.py b/tests/test_libero_arc_sweep.py deleted file mode 100644 index a579acef99..0000000000 --- a/tests/test_libero_arc_sweep.py +++ /dev/null @@ -1,329 +0,0 @@ -import importlib.util -import json -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest -import yaml - -from egomimic.benchmarks.libero.arc_sweep import fixed_replay_spec, profile_settings -from egomimic.benchmarks.libero.replay import candidates_from_spec, validate_spec - - -@pytest.mark.parametrize( - "profile,mode", - [ - ("stk_1", "stk"), - ("stk_2", "stk"), - ("dur_1", "dur"), - ("dur_2", "dur"), - ("shared", "stk"), - ("shared", "dur"), - ], -) -def test_frozen_sweep_replay_preserves_choice_and_uses_fresh_confirmation( - profile, mode -): - settings = profile_settings(profile, mode) - spec = fixed_replay_spec(profile, mode) - candidates = candidates_from_spec(spec) - assert len(candidates) == 1 - candidate = next(iter(candidates.values())) - assert candidate == { - "mode": mode, - "num_waypoints": settings["arc_waypoints"], - "max_rotation_degrees": settings["arc_max_rotation_degrees"], - "max_translation": settings["arc_max_translation"], - } - assert spec["calibration_demos"] == [0, 1] - assert spec["selection_demos"] == list(range(2, 10)) - assert spec["confirmation_demos"] == list(range(10, 25)) - assert not set(spec["confirmation_demos"]) & set(range(30, 50)) - assert spec["minimum_execution_coverage"] == 0.99 - assert spec["max_raw_repeat_state_error"] == 0 - assert spec["max_dense_action_mse"] == 1e-12 - - -def test_profile_rejects_wrong_mode_and_workflow_omissions(): - with pytest.raises(ValueError, match="incompatible"): - profile_settings("stk_1", "dur") - path = Path(__file__).parents[1] / "scripts/benchmarks/launch_libero_osmo.py" - spec = importlib.util.spec_from_file_location("sweep_osmo", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - args = dict( - commit="a" * 40, - run_id="sweep-stk1-10", - suite="libero_10", - mode="full", - arc_modes=["stk"], - arc_profile="stk_1", - ) - with pytest.raises(ValueError, match="requires calibration"): - module.workflow(**args) - result = module.workflow( - **args, calibration_parent="parent", oat_reference_run="baseline" - )["workflow"] - env = result["tasks"][0]["environment"] - assert env["RUN_KIND"] == "arc_sweep" and env["ARC_PROFILE"] == "stk_1" - assert env["OAT_REFERENCE_RUN"] == "baseline" - assert result["resources"]["default"]["gpu"] == 1 - assert result["resources"]["default"]["memory"] == "128Gi" - assert result["resources"]["default"]["cpu"] == 20 - - -def test_fresh_backbone_run_reuses_audited_replay_without_resuming_weights( - tmp_path, monkeypatch -): - from egomimic.benchmarks.libero import arc_sweep, cluster - from scripts.benchmarks.launch_libero_osmo import workflow - - result = workflow( - "a" * 40, - "arc-dp-stk1-spatial", - "libero_spatial", - mode="full", - arc_modes=["stk"], - arc_profile="stk_1", - arc_backbone="oat_dp", - arc_replay_runs={"stk": "audited-replay"}, - oat_reference_run="baseline", - gpus=4, - )["workflow"] - env = result["tasks"][0]["environment"] - assert env["ARC_BACKBONE"] == "oat_dp" - assert env["RESUME_FROM_RUN"] == "" - assert result["resources"]["default"]["gpu"] == 4 - for key, value in env.items(): - monkeypatch.setenv(key, value) - calls = [] - monkeypatch.setattr(cluster, "execute", lambda args, log: calls.append(args)) - monkeypatch.setattr( - sys, - "argv", - [ - "sweep", - "--root", - str(tmp_path), - "--suite", - "libero_spatial", - "--run-id", - "arc-dp-stk1-spatial", - "--profile", - "stk_1", - "--arc-mode", - "stk", - ], - ) - arc_sweep.main() - assert len(calls) == 1 and calls[0][2].endswith(".cluster") - assert json.loads(__import__("os").environ["ARC_REPLAY_RUNS_JSON"]) == { - "stk": "audited-replay" - } - - -@pytest.mark.parametrize("replay_failure", [False, True]) -def test_policy_launch_follows_replay_and_stops_on_preflight_failure( - tmp_path, monkeypatch, replay_failure -): - from egomimic.benchmarks.libero import arc_sweep, cluster - - calls = [] - - def execute(argv, log): - calls.append(argv) - if argv[2].endswith(".replay"): - # Exercise exactly the disk format read in the real subprocess. - loaded = yaml.safe_load(Path(argv[argv.index("--spec") + 1]).read_text()) - validate_spec(loaded) - assert loaded["max_dense_action_mse"] == 1e-12 - if replay_failure: - raise RuntimeError("preflight failed") - - monkeypatch.setattr(cluster, "execute", execute) - monkeypatch.setenv("REPLAY_CALIBRATION_PARENT", "parent-stk") - monkeypatch.setattr( - sys, - "argv", - [ - "sweep", - "--root", - str(tmp_path), - "--suite", - "libero_10", - "--run-id", - "sweep-stk1-10", - "--profile", - "stk_1", - "--arc-mode", - "stk", - ], - ) - if replay_failure: - with pytest.raises(RuntimeError, match="preflight failed"): - arc_sweep.main() - assert len(calls) == 1 - else: - arc_sweep.main() - assert len(calls) == 2 and "--arc-only" in calls[1] - assert calls[0][2].endswith(".replay") and calls[1][2].endswith(".cluster") - assert ( - yaml.safe_load((tmp_path / "fixed-replay-spec.yaml").read_text())[ - "frozen_profile" - ] - == "stk_1" - ) - - -@pytest.mark.parametrize( - "run_mode,bad_replay,gpu_type,backbone", - [ - ("smoke", False, "L40S", "unet"), - ("full", False, "L40S", "unet"), - ("full", True, "L40S", "unet"), - ("full", False, "H100", "unet"), - ("smoke", False, "L40S", "oat_dp"), - ("full", False, "H100", "oat_dp"), - ], -) -def test_arc_only_never_trains_oat_and_checks_frozen_profile( - tmp_path, monkeypatch, run_mode, bad_replay, gpu_type, backbone -): - import torch - - from egomimic.benchmarks.libero import cluster - from egomimic.benchmarks.libero.rollout import rollout_plan, run_rollouts - from tests.test_libero_benchmark import FakeEnvironment, FakePolicy, make_replay - - monkeypatch.setenv("SOURCE_COMMIT", "a" * 40) - monkeypatch.setenv("BENCHMARK_GPU_TYPE", gpu_type) - monkeypatch.setenv("ARC_BACKBONE", backbone) - for name in ( - "CAMPAIGN_ID", - "CAMPAIGN_RUNS_JSON", - "RESUME_FROM_RUN", - "EVALUATE_FROM_RUN", - "ARC_REPLAY_RUN", - ): - monkeypatch.delenv(name, raising=False) - monkeypatch.setenv("ARC_MODES_JSON", '["stk"]') - monkeypatch.setenv("ARC_REPLAY_RUNS_JSON", '{"stk":"verified-replay"}') - monkeypatch.setattr(torch.cuda, "is_available", lambda: True) - monkeypatch.setattr(torch.cuda, "device_count", lambda: 1) - monkeypatch.setattr(torch.cuda, "get_device_name", lambda _: gpu_type) - monkeypatch.setattr(cluster.subprocess, "check_output", lambda *a, **k: "a" * 40) - - def no_op(*a, **k): - pass - - monkeypatch.setattr(torch.cuda, "synchronize", no_op) - - class Uploader: - def __init__(self, *a): - self.client = None - self.prefix = "test/" - self.thread = SimpleNamespace(start=no_op, join=no_op) - self.stop = SimpleNamespace(set=no_op) - - upload = no_op - - monkeypatch.setattr(cluster, "ArtifactUploader", Uploader) - replay_path = tmp_path / "data.zarr" - make_replay(replay_path, suite="libero_10") - monkeypatch.setattr(cluster, "stage_dataset", lambda *a: replay_path) - monkeypatch.setattr(cluster, "configure_simulator", no_op) - settings = profile_settings("stk_1", "stk") - monkeypatch.setattr( - cluster, - "load_arc_calibration", - lambda *a, **k: settings | ({"arc_waypoints": 16} if bad_replay else {}), - ) - calls = [] - - def execute(argv, log): - calls.append(argv) - if "egomimic.trainHydra" in argv: - expected_recipe = ( - "libero_arc_oat_dp_policy" - if backbone == "oat_dp" - else "libero_arc_stk_policy" - ) - assert f"+experiment=oat/{expected_recipe}" in argv - assert "benchmark.arc_waypoints=36" in argv - checkpoint = tmp_path / "evidence/training/arc_stk/checkpoints/last.ckpt" - checkpoint.parent.mkdir(parents=True) - checkpoint.write_bytes(b"checkpoint") - if "rollout" in argv: - output = Path(argv[argv.index("--output") + 1]) - run_rollouts( - FakePolicy(), - rollout_plan("libero_10") - if run_mode == "full" - else rollout_plan("libero_10", trials_per_task=1, repetitions=1), - output, - max_episode_steps=550 if run_mode == "full" else 4, - env_factory=FakeEnvironment, - metadata={ - "suite": "libero_10", - "method": "arc", - "representation": { - "mode": "stk", - "waypoints": 36, - "max_translation": 1.6, - "max_rotation_degrees": 192, - }, - "horizon": 32, - "n_obs_steps": 2, - "n_action_steps": 16, - "use_ema": True, - }, - ) - - from egomimic.benchmarks.libero import evaluate - - parallel_calls = [] - - def parallel_rollouts(checkpoint, evidence, method, suite): - parallel_calls.append((method, suite)) - execute(["rollout", "--output", str(evidence / method / suite)], None) - - monkeypatch.setattr(evaluate, "parallel_rollouts", parallel_rollouts) - monkeypatch.setattr(cluster, "execute", execute) - monkeypatch.setattr( - sys, - "argv", - [ - "cluster", - "--root", - str(tmp_path), - "--suite", - "libero_10", - "--run-id", - "sweep", - "--mode", - run_mode, - "--arc-only", - "--arc-profile", - "stk_1", - ], - ) - if bad_replay: - with pytest.raises(ValueError, match="differs from frozen"): - cluster.main() - assert not any("egomimic.trainHydra" in c for c in calls) - else: - cluster.main() - assert parallel_calls == ( - [("arc_stk", "libero_10")] - if backbone == "oat_dp" and run_mode == "full" - else [] - ) - assert sum("egomimic.trainHydra" in c for c in calls) == 1 - assert not any("reconstruct" in c for c in calls) - result = json.loads((tmp_path / "evidence/arc-results.json").read_text()) - assert set(result["variants"]) == {"stk"} - assert result["paired_oat_comparison_complete"] is False - assert json.loads((tmp_path / "evidence/status.json").read_text())["state"] == ( - "ARC_POLICIES_COMPLETE" if run_mode == "full" else "ARC_SMOKE_PASSED" - ) diff --git a/tests/test_libero_arc_timed.py b/tests/test_libero_arc_timed.py deleted file mode 100644 index 5c93838ed7..0000000000 --- a/tests/test_libero_arc_timed.py +++ /dev/null @@ -1,216 +0,0 @@ -import numpy as np -import pytest - -from egomimic.rldb.zarr.libero_arc_timed import LiberoArcTimedCodec, _support_frames - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -@pytest.mark.parametrize("motion", ["translation", "rotation", "mixed", "stationary"]) -def test_constant_controls_use_independent_stream_clocks(mode, motion): - actions = np.zeros((32, 7)) - actions[:, 6] = -1 - if motion in {"translation", "mixed"}: - actions[:, :3] = [0.2, -0.1, 0.05] - if motion in {"rotation", "mixed"}: - actions[:, 5] = 0.25 # Crosses pi in world-frame accumulated SO(3). - codec = LiberoArcTimedCodec(mode=mode, num_waypoints=16) - tokens = codec.encode(actions) - assert tokens.shape == (16, 12) - np.testing.assert_allclose(codec.decode(tokens), actions, atol=3e-6) - assert codec.represented_seconds(tokens) == pytest.approx(1.6, abs=1e-6) - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -def test_rotation_budget_does_not_shorten_translation_or_grip(mode): - actions = np.zeros((32, 7)) - actions[:, 0] = 0.2 - actions[:, 5] = np.deg2rad(3) / 0.5 - actions[:, 6] = -1 - codec = LiberoArcTimedCodec( - mode=mode, num_waypoints=16, max_translation=0.2, max_rotation_degrees=12 - ) - tokens = codec.encode(actions) - t_clock, r_clock = codec.clocks(tokens) - assert t_clock[-1] == pytest.approx(1.0, abs=1e-6) - assert r_clock[-1] == pytest.approx(0.2, abs=1e-6) - decoded = codec.decode(tokens) - np.testing.assert_allclose(decoded[:20, 0], 0.2, atol=3e-6) - np.testing.assert_allclose(decoded[20:, 0], 0, atol=3e-6) - np.testing.assert_allclose(decoded[:4, 5], actions[:4, 5], atol=3e-6) - np.testing.assert_allclose(decoded[4:, 5], 0, atol=3e-6) - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -def test_noncommuting_rotations_use_world_frame_osc_convention(mode): - # Equal angular increments make the uniform arc supports coincide with - # control frames; changing axes detects reversed composition conventions. - actions = np.zeros((16, 7)) - actions[:, 0] = 0.1 - for index in range(16): - actions[index, 3 + index % 3] = 0.2 - actions[:, 6] = -1 - actions[8:, 6] = 1 - codec = LiberoArcTimedCodec(mode=mode, horizon=16, num_waypoints=17) - np.testing.assert_allclose(codec.decode(codec.encode(actions)), actions, atol=3e-6) - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -def test_each_budget_preserves_its_fractional_final_command(mode): - actions = np.zeros((32, 7)) - actions[:, 0] = 0.2 - actions[:, 5] = np.deg2rad(3) / 0.5 - codec = LiberoArcTimedCodec( - mode=mode, num_waypoints=16, max_translation=0.055, max_rotation_degrees=13.5 - ) - decoded = codec.decode(codec.encode(actions)) - np.testing.assert_allclose(decoded[:5, 0], 0.2, atol=3e-6) - assert decoded[5, 0] == pytest.approx(0.1, abs=3e-6) - np.testing.assert_allclose(decoded[6:, 0], 0, atol=3e-6) - np.testing.assert_allclose(decoded[:4, 5], actions[:4, 5], atol=3e-6) - assert decoded[4, 5] == pytest.approx(actions[4, 5] / 2, abs=3e-6) - np.testing.assert_allclose(decoded[5:, 5], 0, atol=3e-6) - - -def test_duration_preserves_independent_stop_and_go_intervals(): - actions = np.zeros((32, 7)) - actions[:, 6] = -1 - actions[8:16, 0] = 0.2 - actions[24:28, 0] = 0.2 - actions[4:12, 5] = 0.2 - actions[20:24, 5] = -0.2 - codec = LiberoArcTimedCodec(mode="dur", num_waypoints=16) - np.testing.assert_allclose(codec.decode(codec.encode(actions)), actions, atol=3e-6) - - -def test_velocity_cannot_encode_grip_only_dwell_but_duration_can(): - actions = np.zeros((32, 7)) - actions[:, 6] = -1 - actions[16:, 6] = 1 - duration = LiberoArcTimedCodec(mode="dur", num_waypoints=33) - velocity = LiberoArcTimedCodec(mode="stk", num_waypoints=33) - np.testing.assert_allclose( - duration.decode(duration.encode(actions)), actions, atol=3e-6 - ) - tokens = velocity.encode(actions) - assert velocity.represented_seconds(tokens) == 0 - assert np.mean(velocity.decode(tokens)[:, 6] != actions[:, 6]) > 0 - - -@pytest.mark.parametrize("mode,scale", [("dur", 2.0), ("stk", 0.5)]) -def test_time_scaling_changes_only_timing_channels(mode, scale): - actions = np.random.default_rng(33).uniform(-0.2, 0.2, (32, 7)) - fast = LiberoArcTimedCodec(mode=mode, dt=0.05) - slow = LiberoArcTimedCodec(mode=mode, dt=0.1) - a, b = fast.encode(actions), slow.encode(actions) - np.testing.assert_allclose( - b[:, [0, 1, 2, 4, 5, 6, 7, 8, 9, 11]], a[:, [0, 1, 2, 4, 5, 6, 7, 8, 9, 11]] - ) - np.testing.assert_allclose(b[:, [3, 10]], a[:, [3, 10]] * scale) - np.testing.assert_allclose(slow.decode(b), fast.decode(a), atol=3e-6) - - -def test_uniform_sampling_and_longest_dwell_reservation_match_native_contract(): - arc = np.array([0, 0, 0, 1, 2, 3, 3, 3, 3, 4], dtype=float) - np.testing.assert_allclose(_support_frames(arc, 4, 5, "stk"), [0, 3, 4, 5, 9]) - # Four supports reserve the longest hold; five can retain both holds. - np.testing.assert_allclose(_support_frames(arc, 4, 4, "dur"), [0, 5, 8, 9]) - np.testing.assert_allclose(_support_frames(arc, 4, 5, "dur"), [0, 2, 5, 8, 9]) - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -def test_degenerate_predicted_tokens_stall_instead_of_teleport(mode): - codec = LiberoArcTimedCodec(mode=mode, num_waypoints=4) - tokens = np.zeros((4, 12)) - tokens[1:, 0] = 1 - decoded = codec.decode(tokens) - assert np.isfinite(decoded).all() - # A missing clock is longer than the output horizon, not an immediate jump. - assert abs(decoded[0, 0]) < 1 - assert decoded[:, 0].sum() * codec.translation_scale < 1 - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -def test_native_graph_normalizes_each_variant_and_decodes_bfloat16(mode): - import torch - - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - stage = LiberoArcStage(arc_mode=mode, num_waypoints=16, reconstruction=True) - stage.action_scale.copy_(torch.arange(1, 8)) - stage.action_offset.copy_(torch.arange(7) / 5) - native = torch.zeros(2, 32, 7) - native[:, :, 0] = 0.1 - native[:, :, 5] = 0.2 - native[:, :, 6] = -1 - normalized = native * stage.action_scale + stage.action_offset - result = stage.execute({"actions": normalized}, mode="inference") - torch.testing.assert_close(result["pred_action"], normalized, atol=1e-5, rtol=1e-5) - predicted = result["target"].to(torch.bfloat16) - decoder = LiberoArcStage(arc_mode=mode, num_waypoints=16, operation="decode") - output = decoder.execute({"pred_arc": predicted}, mode="inference")["pred_action"] - assert output.shape == (2, 32, 7) - assert torch.isfinite(output).all() - - -@pytest.mark.parametrize("mode", ["stk", "dur"]) -def test_mode_specific_replay_grid_and_policy_shape(mode): - import torch - - from egomimic.benchmarks.libero.replay import ( - candidates_from_spec, - reconstruct_episode, - ) - from egomimic.models.denoising_nets import ConditionalUnet1D - - spec = dict( - arc_mode=mode, - horizon=32, - execute_steps=16, - dt=0.05, - rotation_degrees=[12], - translation_metres=[0.4], - waypoints=[4, 8, 16, 24, 32, 36], - ) - candidates = candidates_from_spec(spec) - model = ConditionalUnet1D( - input_dim=12, - cond_dim=276, - ac_latent_seq=1, - diffusion_step_embed_dim=16, - down_dims=[8, 16, 32], - ) - for name, candidate in candidates.items(): - assert name.startswith(mode + "_") - inputs = torch.randn(2, candidate["num_waypoints"], 12, requires_grad=True) - output = model(inputs, torch.tensor([1, 3]), torch.randn(2, 276)) - assert output.shape == inputs.shape - output.square().mean().backward() - assert torch.isfinite(inputs.grad).all() - actions = np.zeros((73, 7)) - actions[:, 0] = 0.1 - decoded, metrics = reconstruct_episode(actions, candidate, spec) - np.testing.assert_allclose(decoded, actions, atol=3e-6) - assert metrics["execution_coverage"] == pytest.approx(1) - - -def test_velocity_targets_fit_diffusion_clipping_for_diagonal_osc_commands(): - import torch - - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - actions = torch.ones(1, 32, 7) - stage = LiberoArcStage( - arc_mode="stk", num_waypoints=33, velocity_norm_bound=np.sqrt(3) - ) - tokens = stage.execute({"actions": actions}, mode="train")["target"] - assert float(tokens.abs().max()) <= 1 + 1e-6 - # Clipping to the scheduler's [-1,1] range must retain all legal diagonal - # speeds. A per-component scale instead clips these magnitudes by sqrt(3). - decoder = LiberoArcStage( - arc_mode="stk", - num_waypoints=33, - operation="decode", - velocity_norm_bound=np.sqrt(3), - ) - decoded = decoder.execute({"pred_arc": tokens.clamp(-1, 1)}, mode="inference") - torch.testing.assert_close(decoded["pred_action"], actions, atol=5e-6, rtol=5e-6) diff --git a/tests/test_libero_basis_scaling_audit.py b/tests/test_libero_basis_scaling_audit.py deleted file mode 100644 index 5b14747014..0000000000 --- a/tests/test_libero_basis_scaling_audit.py +++ /dev/null @@ -1,113 +0,0 @@ -import numpy as np -import pytest - -from egomimic.rldb.zarr.libero_arc_global import LiberoArcGlobalCodec -from scripts.benchmarks.compare_libero_arc_bases import fit_bounded_scaling, noise_audit - - -def test_scaling_is_train_only_bounded_and_reversible(): - rng = np.random.default_rng(8) - values = rng.normal(size=(8, 32, 12)) - values[:, 0, 0] = 4 - mask = np.arange(8) < 4 - center, scale, std = fit_bounded_scaling(values, mask, 32) - changed = values.copy() - changed[~mask] += 1000 - for left, right in zip( - (center, scale, std), fit_bounded_scaling(changed, mask, 32) - ): - np.testing.assert_array_equal(left, right) - assert np.isfinite(scale).all() and scale.min() >= 1e-4 - assert std[0, 0] == 0 - np.testing.assert_allclose((values - center) / scale * scale + center, values) - with pytest.raises(ValueError): - fit_bounded_scaling(values, np.zeros(8, dtype=bool), 32) - - -@pytest.mark.parametrize("basis", ["fourier", "chebyshev"]) -def test_noise_audit_is_deterministic_and_does_not_change_codec(basis): - codec = LiberoArcGlobalCodec(basis=basis) - raw = np.random.default_rng(3).uniform(-0.1, 0.1, (4, 32, 7)).astype(np.float32) - tokens = np.stack([codec.encode(row) for row in raw]) - frozen = tokens.copy() - identities = [{"split": "train" if i < 2 else "valid"} for i in range(4)] - first = noise_audit(codec, tokens, raw, identities, sigmas=(0.0, 0.001)) - assert first == noise_audit(codec, tokens, raw, identities, sigmas=(0.0, 0.001)) - np.testing.assert_array_equal(tokens, frozen) - for variant in first["variants"].values(): - assert variant["perturbations"]["0.0"]["decoded_delta_mse_mean"] < 1e-12 - assert variant["perturbations"]["0.001"]["decoded_delta_mse_mean"] > 0 - - -def test_optional_affine_stage_roundtrip_strict_state_and_identity(): - import copy - - import torch - - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - codec = LiberoArcGlobalCodec(basis="fourier", geometry=64) - raw = np.random.default_rng(1).uniform(-0.1, 0.1, (6, 32, 7)).astype(np.float32) - tokens = np.stack([codec.encode(row) for row in raw]) - audit = noise_audit( - codec, - tokens, - raw, - [{"split": "train" if i < 4 else "valid"} for i in range(6)], - sigmas=(0.0,), - ) - affine = audit["affine"] - stage = LiberoArcStage(codec=codec, token_affine=affine, reconstruction=True) - stage.action_scale.copy_(torch.arange(1, 8)) - stage.action_offset.copy_(torch.arange(7) / 5) - actions = torch.from_numpy(raw) * stage.action_scale + stage.action_offset - result = stage.execute({"actions": actions}, mode="inference") - expected = np.stack([codec.decode(row) for row in tokens]) - torch.testing.assert_close( - result["pred_action"], - torch.from_numpy(expected) * stage.action_scale + stage.action_offset, - atol=2e-5, - rtol=1e-5, - ) - restored = LiberoArcStage(codec=codec, token_affine=affine, operation="decode") - restored.load_state_dict(stage.state_dict(), strict=True) - torch.testing.assert_close( - restored.execute({"pred_arc": result["target"]}, mode="inference")[ - "pred_action" - ], - result["pred_action"], - ) - assert torch.isfinite( - restored.execute({"pred_arc": result["target"].bfloat16()}, mode="inference")[ - "pred_action" - ] - ).all() - assert stage.representation_context() == restored.representation_context() - wrong = copy.deepcopy(affine) - wrong["geometry"] = 128 - with pytest.raises(ValueError, match="identity differ"): - LiberoArcStage(codec=codec, token_affine=wrong) - wrong = copy.deepcopy(affine) - wrong["scale"][0][0] = 0 - with pytest.raises(ValueError, match="affine arrays"): - LiberoArcStage(codec=codec, token_affine=wrong) - - -def test_default_uniform_has_no_affine_state_or_numeric_change(): - import torch - - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - stage = LiberoArcStage(codec=LiberoArcGlobalCodec(basis="uniform")) - raw = torch.from_numpy( - np.random.default_rng(2).uniform(-0.1, 0.1, (2, 32, 7)).astype(np.float32) - ) - expected = torch.tensor( - np.stack([stage.codec.encode(a) for a in raw.numpy()]) - ) / torch.tensor(stage.codec.token_scale()) - assert not any("token_" in key for key in stage.state_dict()) - assert "token_affine" not in stage.representation_context() - assert "geometry_fit_grid" not in stage.representation_context() - assert torch.equal( - stage.execute({"actions": raw}, mode="train")["target"], expected - ) diff --git a/tests/test_libero_benchmark.py b/tests/test_libero_benchmark.py deleted file mode 100644 index d44d1e6d2c..0000000000 --- a/tests/test_libero_benchmark.py +++ /dev/null @@ -1,286 +0,0 @@ -import json -from pathlib import Path - -import numpy as np -import pytest -import torch -import zarr - -from egomimic.benchmarks.libero.catalog import TASK_IDS, TASKS, get_tasks -from egomimic.benchmarks.libero.report import compare_runs, read_run -from egomimic.benchmarks.libero.rollout import rollout_plan, run_rollouts -from egomimic.rldb.zarr.libero_dataset import ( - EMBODIMENT, - LiberoDataset, - LiberoNormalizer, - LiberoReplayResolver, - keymap, - validation_mask, -) - - -def make_replay(path, suite="libero10", episode_length=5): - tasks = get_tasks(suite) - ids = np.repeat( - [TASK_IDS[task][2] for task in tasks for _ in range(2)], episode_length - ) - n = len(ids) - group = zarr.open_group(str(path), mode="w", zarr_format=2) - data = group.create_group("data") - rng = np.random.default_rng(5) - arrays = { - "action": rng.uniform(-1, 1, (n, 7)).astype(np.float32), - "robot0_eef_pos": rng.normal(size=(n, 3)).astype(np.float32), - "robot0_eef_quat": np.tile(np.array([0, 0, 0, 1], dtype=np.float32), (n, 1)), - "robot0_gripper_qpos": rng.normal(size=(n, 2)).astype(np.float32), - "task_uid": ids[:, None], - "agentview_rgb": rng.integers(0, 256, (n, 128, 128, 3), dtype=np.uint8), - "robot0_eye_in_hand_rgb": rng.integers( - 0, 256, (n, 128, 128, 3), dtype=np.uint8 - ), - } - for key, values in arrays.items(): - data.create_array(key, data=values) - group.create_group("meta").create_array( - "episode_ends", data=np.arange(episode_length, n + 1, episode_length) - ) - return arrays - - -def dataset_and_normalizer(path, n_obs_steps=2, horizon=8): - resolver = LiberoReplayResolver(path, keymap(n_obs_steps, horizon), "libero10") - dataset = LiberoDataset._from_resolver(resolver) - normalizer = LiberoNormalizer() - normalizer.populate_from_datasets({"libero_panda": dataset}) - normalizer.infer_shapes_from_batch(dataset[0]) - normalizer.infer_norm_from_dataset(dataset, "libero_panda") - return dataset, normalizer - - -def test_catalog_covers_every_upstream_task_and_preserves_uid_order(): - assert {suite: len(tasks) for suite, tasks in TASKS.items()} == { - "libero_spatial": 10, - "libero_object": 10, - "libero_goal": 10, - "libero_10": 10, - "libero_90": 90, - } - assert len(TASK_IDS) == 130 - assert TASK_IDS[get_tasks("libero10")[0]][2] == 30 - assert TASK_IDS[get_tasks("libero90")[0]][2] == 40 - assert sum(len(rollout_plan(suite)) for suite in TASKS) == 32500 - with pytest.raises(ValueError, match="Unknown"): - get_tasks("not_a_task") - - -def test_loader_alignment_padding_split_and_normalizer_match_oat(tmp_path): - from egomimic.models.oat.model.common.normalizer import LinearNormalizer - - path = tmp_path / "replay.zarr" - arrays = make_replay(path) - dataset, normalizer = dataset_and_normalizer(path) - mask = validation_mask(20) - expected = np.zeros(20, bool) - expected[np.random.default_rng(42).choice(20, 2, replace=False)] = True - np.testing.assert_array_equal(mask, expected) - valid = LiberoDataset._from_resolver(dataset.resolver, mode="valid") - assert not set(dataset.datasets) & set(valid.datasets) - assert set(dataset.datasets) | set(valid.datasets) == { - f"episode_{i:06d}" for i in range(20) - } - leaf = next(iter(dataset.datasets.values())) - sample = leaf[0] - np.testing.assert_array_equal( - sample["robot0_eef_pos"], - np.repeat(arrays["robot0_eef_pos"][leaf.start : leaf.start + 1], 2, axis=0), - ) - np.testing.assert_array_equal(sample["actions"][0], arrays["action"][leaf.start]) - np.testing.assert_array_equal(sample["actions"][-1], arrays["action"][leaf.end - 1]) - sample = leaf[4] - np.testing.assert_array_equal( - sample["robot0_eef_pos"], arrays["robot0_eef_pos"][leaf.end - 2 : leaf.end] - ) - dataset.set_norm_stats_from(normalizer) - assert dataset.norm_stats is normalizer.norm_stats - raw, normalized = leaf[0], dataset[0] - source = LinearNormalizer() - source.fit( - {"actions" if key == "action" else key: value for key, value in arrays.items()} - ) - for key in keymap(): - torch.testing.assert_close( - normalized[key], source[key].normalize(raw[key]), rtol=0, atol=0 - ) - restored = LiberoNormalizer(state=normalizer.to_state()) - for key, value in restored.unnormalize(normalized, EMBODIMENT).items(): - if torch.is_tensor(value): - torch.testing.assert_close(value, raw[key], atol=2e-5, rtol=1e-5) - restored.assert_tokenizer_context(normalizer.tokenizer_context()) - - -def test_tokenizer_and_policy_normalizers_share_action_stats(tmp_path): - path = tmp_path / "replay.zarr" - make_replay(path) - _, tokenizer = dataset_and_normalizer(path, n_obs_steps=0) - _, policy = dataset_and_normalizer(path, n_obs_steps=2) - policy.assert_tokenizer_context(tokenizer.tokenizer_context()) - wrong = tokenizer.tokenizer_context() | {"split_seed": 123} - with pytest.raises(ValueError, match="differ"): - policy.assert_tokenizer_context(wrong) - - -class FakeEnvironment: - def __init__(self, task): - self.task = task - self.closed = False - - def reset(self, seed): - self.steps = 0 - self.initial_state_sha256 = f"state-{self.task}-{seed}" - return {"frame": 0} - - def step(self, action): - self.steps += 1 - return {"frame": self.steps}, self.steps == 3, False - - def close(self): - self.closed = True - - -class FakePolicy: - def reset(self, observation): - self.seen = [observation["frame"]] - - def observe(self, observation): - self.seen.append(observation["frame"]) - - def predict(self): - return np.zeros((16, 7)) - - -def test_rollouts_stop_inside_chunk_and_compare_all_expected_records(tmp_path): - plan = rollout_plan("libero10", trials_per_task=1, repetitions=2) - assert len(plan) == 20 and len({spec.seed for spec in plan}) == 20 - metadata = { - "suite": "libero10", - "horizon": 32, - "n_obs_steps": 2, - "n_action_steps": 16, - "data_context": {"dataset_sha256": "same"}, - "observations_sha256": "same-observations", - "use_ema": True, - "checkpoint_sha256": "checkpoint", - } - for method in ("arc", "oat"): - environments = [] - - def factory(task): - env = FakeEnvironment(task) - environments.append(env) - return env - - records = run_rollouts( - FakePolicy(), - plan, - tmp_path / method, - env_factory=factory, - metadata={**metadata, "method": method}, - ) - assert len(environments) == 10 and all(env.closed for env in environments) - assert all(record["steps"] == 3 and record["success"] for record in records) - result = compare_runs(tmp_path / "arc", tmp_path / "oat", require_full=False) - assert result["paired_success_difference"] == 0 - assert result["arc"]["mean_success_rate"] == 1 - protocol_file = tmp_path / "arc/protocol.json" - protocol = json.loads(protocol_file.read_text()) - protocol["representation"] = {"mode": "stk"} - protocol_file.write_text(json.dumps(protocol)) - with pytest.raises(ValueError, match="representation mode"): - compare_runs( - tmp_path / "arc", tmp_path / "oat", require_full=False, arc_mode="dur" - ) - assert compare_runs( - tmp_path / "arc", tmp_path / "oat", require_full=False, arc_mode="stk" - )["arc"]["episodes"] == len(plan) - with pytest.raises(ValueError, match="Full benchmark"): - compare_runs(tmp_path / "arc", tmp_path / "oat") - file = tmp_path / "arc/episodes.jsonl" - original_records = file.read_text() - records = [json.loads(line) for line in original_records.splitlines()] - records[0]["initial_state_sha256"] = "different-state" - file.write_text("".join(json.dumps(record) + "\n" for record in records)) - with pytest.raises(ValueError, match="Initial states differ"): - compare_runs(tmp_path / "arc", tmp_path / "oat", require_full=False) - file.write_text(original_records) - protocol_file = tmp_path / "arc/protocol.json" - protocol = json.loads(protocol_file.read_text()) - protocol["observations_sha256"] = "different-cameras" - protocol_file.write_text(json.dumps(protocol)) - with pytest.raises(ValueError, match="observations_sha256"): - compare_runs(tmp_path / "arc", tmp_path / "oat", require_full=False) - file.write_text(original_records + original_records.splitlines()[0] + "\n") - with pytest.raises(ValueError, match="duplicate"): - read_run(tmp_path / "arc") - file.write_text(original_records.splitlines()[0] + "\n") - with pytest.raises(ValueError, match="Missing"): - read_run(tmp_path / "arc") - - -def test_conversion_matches_source_image_orientation_and_quaternion(tmp_path): - import h5py - from scipy.spatial.transform import Rotation - - from egomimic.benchmarks.libero.convert import convert_suite - - task = get_tasks("libero10")[0] - root = tmp_path / "raw" - root.mkdir() - with h5py.File(root / f"{task}_demo.hdf5", "w") as handle: - data = handle.create_group("data") - data.attrs["bddl_file_name"] = f"/bddl/{task}.bddl" - data.attrs["problem_info"] = json.dumps({"language_instruction": "test"}) - for index in range(2): - demo = data.create_group(f"demo_{index}") - demo["actions"] = np.zeros((4, 7), np.float32) - obs = demo.create_group("obs") - pixels = np.zeros((4, 4, 4, 3), np.uint8) - pixels[:, 0] = 200 - obs["agentview_rgb"] = pixels - obs["eye_in_hand_rgb"] = pixels - obs["ee_pos"] = np.zeros((4, 3), np.float32) - obs["ee_ori"] = np.tile([0, 0, 0.5], (4, 1)) - obs["gripper_states"] = np.zeros((4, 2), np.float32) - output = tmp_path / "converted.zarr" - manifest = convert_suite(root, output, task) - array = zarr.open_group(str(output), mode="r")["data"] - assert np.all(array["agentview_rgb"][:, -1] == 200) - np.testing.assert_allclose( - array["robot0_eef_quat"][0], - Rotation.from_rotvec([0, 0, 0.5]).as_quat(), - atol=1e-7, - ) - assert manifest["complete"] and len(manifest["episodes"]) == 2 - with pytest.raises(FileExistsError): - convert_suite(root, output, task) - - -@pytest.mark.parametrize( - "experiment", ["libero_oattok", "libero_oatpolicy", "libero_arc_policy"] -) -def test_complete_benchmark_recipes_compose(experiment): - from hydra import compose, initialize_config_dir - - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(root)): - for suite in TASKS: - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - f"+experiment=oat/{experiment}", - f"benchmark.suite={suite}", - "benchmark.dataset=/tmp/libero.zarr", - ], - ) - assert cfg.benchmark.horizon == 32 and cfg.benchmark.n_action_steps == 16 - assert cfg.trainer.max_epochs == 5001 - assert cfg.model._target_ == "egomimic.pl_utils.pl_model.ModelWrapper" diff --git a/tests/test_libero_cluster.py b/tests/test_libero_cluster.py deleted file mode 100644 index 7081db389f..0000000000 --- a/tests/test_libero_cluster.py +++ /dev/null @@ -1,750 +0,0 @@ -import importlib.util -import json -import zipfile -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -from egomimic.benchmarks.libero.cluster import ( - ArtifactUploader, - CalibrationPending, - campaign_sources, - digest, - extract_replay, - load_arc_calibration, - publish_campaign, - restore_checkpoints, - stage_dataset, - training_arguments, - validate_gpu_allocation, -) - - -@pytest.mark.parametrize("mode", ["smoke", "full"]) -@pytest.mark.parametrize("method", ["tokenizer", "oat", "arc", "arc_dur", "arc_stk"]) -def test_cluster_arguments_compose_native_graph(mode, method, tmp_path): - args = training_arguments( - method, "libero_10", tmp_path / "replay.zarr", tmp_path, mode, 5001 - ) - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(config_dir=str(config_dir), version_base="1.3"): - cfg = compose(config_name="train_zarr_cartesian", overrides=args[3:]) - assert cfg.model.pipeline._target_ == "egomimic.pipeline.algo.PipelineAlgo" - assert cfg.trainer.devices == 1 and cfg.trainer.precision == "bf16-mixed" - assert cfg.benchmark.batch_size == (4 if mode == "smoke" else 256) - assert cfg.trainer.max_epochs == (1 if mode == "smoke" else 5001) - if mode == "full": - assert cfg.logger.csv._target_ == "lightning.pytorch.loggers.CSVLogger" - assert cfg.trainer.accumulate_grad_batches == 4 - assert cfg.callbacks.batch_budget.global_batch_size == 1024 - else: - assert cfg.trainer.accumulate_grad_batches == 1 - assert cfg.callbacks.batch_budget is None - assert cfg.callbacks.ema.final_checkpoint_path.endswith("checkpoints/last.ckpt") - if method == "oat": - assert cfg.benchmark.tokenizer_checkpoint.endswith( - "tokenizer/checkpoints/last.ckpt" - ) - if method in {"arc_dur", "arc_stk"}: - mode = method.removeprefix("arc_") - assert cfg.benchmark.arc_mode == mode - assert cfg.benchmark.arc_action_dim == 12 - stages = cfg.model.pipeline.stages - assert stages[1].arc_mode == stages[-1].arc_mode == mode - assert ( - stages[1].velocity_norm_bound - == stages[-1].velocity_norm_bound - == pytest.approx(3**0.5) - ) - assert stages[2].action_dim == stages[3].policy.model.input_dim == 12 - - -def test_workflow_pins_source_and_requests_one_gpu(): - path = Path(__file__).parents[1] / "scripts/benchmarks/launch_libero_osmo.py" - spec = importlib.util.spec_from_file_location("libero_osmo", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - workflow = module.workflow("a" * 40, "libero-smoke-test", "libero_10")["workflow"] - assert workflow["resources"]["default"]["gpu"] == 1 - assert workflow["resources"]["default"]["platform"] == "ovx-l40s" - assert workflow["tasks"][0]["environment"]["BENCHMARK_GPU_TYPE"] == "L40S" - assert workflow["tasks"][0]["environment"]["SOURCE_COMMIT"] == "a" * 40 - assert json.loads(workflow["tasks"][0]["environment"]["ARC_MODES_JSON"]) == [ - "dur", - "stk", - ] - with pytest.raises(ValueError, match="immutable"): - module.workflow("main", "libero-smoke-test", "libero_10") - h100 = module.workflow( - "a" * 40, - "h100-resume", - "libero_10", - mode="full", - resume_from_run="prior-run", - gpus=8, - gpu_type="H100", - )["workflow"] - assert h100["resources"]["default"]["platform"] == "dgx-h100" - assert h100["resources"]["default"]["gpu"] == 8 - assert h100["resources"]["default"]["cpu"] == 80 - h100_env = h100["tasks"][0]["environment"] - assert h100_env["BENCHMARK_GPU_TYPE"] == "H100" - assert h100_env["TRAINING_GPUS"] == "8" - assert h100_env["EPOCHS"] == "5001" - assert h100_env["RESUME_FROM_RUN"] == "prior-run" - with pytest.raises(ValueError, match="GPU type"): - module.workflow("a" * 40, "invalid-gpu", "libero_10", gpu_type="A100") - full = module.workflow( - "a" * 40, "study-libero-10", "libero_10", mode="full", campaign_id="study" - )["workflow"] - assert full["timeout"]["exec_timeout"] == "60d" - assert full["tasks"][0]["environment"]["CAMPAIGN_ID"] == "study" - replay = module.workflow( - "a" * 40, - "replay-refine", - "libero_90", - replay=True, - replay_spec="libero_arc_replay_refine", - )["workflow"] - assert replay["resources"]["default"]["cpu"] >= 32 - assert replay["resources"]["default"]["gpu"] == 1 - assert replay["resources"]["default"]["memory"] == "192Gi" - replacements = campaign_sources("study", "a" * 40) - replacements["libero_90"] = { - "run_id": "recovered-libero-90", - "source_commit": "b" * 40, - } - replacement = module.workflow( - "b" * 40, - "recovered-libero-90", - "libero_90", - mode="full", - campaign_id="study", - campaign_runs=replacements, - )["workflow"] - assert ( - json.loads(replacement["tasks"][0]["environment"]["CAMPAIGN_RUNS_JSON"]) - == replacements - ) - with pytest.raises(ValueError, match="Campaign"): - module.workflow( - "a" * 40, - "recovered-libero-90", - "libero_90", - mode="full", - campaign_id="study", - campaign_runs=replacements, - ) - with pytest.raises(ValueError, match="Campaign"): - module.workflow( - "a" * 40, "unrelated-run", "libero_10", mode="full", campaign_id="study" - ) - variants = module.workflow( - "a" * 40, - "both-modes", - "libero_10", - mode="full", - arc_replay_runs={"dur": "dur-replay", "stk": "stk-replay"}, - )["workflow"] - assert json.loads(variants["tasks"][0]["environment"]["ARC_REPLAY_RUNS_JSON"]) == { - "dur": "dur-replay", - "stk": "stk-replay", - } - with pytest.raises(ValueError, match="mode-specific"): - module.workflow( - "a" * 40, - "ambiguous-mode", - "libero_10", - arc_modes=["dur", "stk"], - arc_replay_run="legacy-run", - ) - - -@pytest.mark.parametrize( - "devices,gpu_type,error", - [ - (["NVIDIA L40S"] * 8, "L40S", None), - (["NVIDIA H100 80GB HBM3"] * 8, "H100", None), - (["NVIDIA L40S"] * 8, "H100", RuntimeError), - (["NVIDIA H100 80GB HBM3"] * 7, "H100", RuntimeError), - (["NVIDIA H100 80GB HBM3"] * 7 + ["NVIDIA L40S"], "H100", RuntimeError), - ([], "H100", RuntimeError), - (["NVIDIA A100"] * 8, "A100", ValueError), - ], -) -def test_gpu_allocation_rejects_missing_mixed_or_wrong_devices( - monkeypatch, devices, gpu_type, error -): - import torch - - monkeypatch.setattr(torch.cuda, "is_available", lambda: bool(devices)) - monkeypatch.setattr(torch.cuda, "device_count", lambda: len(devices)) - monkeypatch.setattr(torch.cuda, "get_device_name", devices.__getitem__) - if error: - with pytest.raises(error): - validate_gpu_allocation(8, gpu_type) - else: - validate_gpu_allocation(8, gpu_type) - - -@pytest.mark.parametrize( - "condition", ["complete", "missing", "running", "source", "budget"] -) -def test_campaign_only_publishes_complete_matching_results(condition): - import io - - from botocore.exceptions import ClientError - - from egomimic.benchmarks.libero.catalog import TASKS - - class Storage: - report = None - - def get_object(self, Bucket, Key): - suite = next( - suite for suite in TASKS if f"study-{suite.replace('_', '-')}/" in Key - ) - if suite == "libero_90" and condition == "missing": - raise ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject") - payload = { - "status.json": { - "state": "TRAINING" if condition == "running" else "SUITE_COMPLETE" - }, - "runtime.json": { - "source_commit": "b" * 40 if condition == "source" else "a" * 40, - "mode": "full", - "epochs": 1 if condition == "budget" else 5001, - "global_batch_size": 1024, - "suite": suite, - }, - "comparison.json": {"suite": suite, "complete_protocol": True}, - }[Key.rsplit("/", 1)[1]] - return {"Body": io.BytesIO(json.dumps(payload).encode())} - - def put_object(self, **kwargs): - self.report = json.loads(kwargs["Body"]) - - storage = Storage() - if condition in {"source", "budget"}: - with pytest.raises(ValueError, match="Campaign"): - publish_campaign(storage, "study", commit="a" * 40, epochs=5001) - else: - assert publish_campaign(storage, "study", commit="a" * 40, epochs=5001) == ( - condition == "complete" - ) - if condition == "complete": - assert storage.report["unique_tasks"] == 130 - assert set(storage.report["suites"]) == set(TASKS) - assert storage.report["full_released_training_budget"] is True - else: - assert storage.report is None - - -def test_released_replay_archive_detection_and_traversal_rejection(tmp_path): - archive = tmp_path / "replay.zip" - with zipfile.ZipFile(archive, "w") as handle: - handle.writestr("libero10_N500.zarr/.zgroup", "{}") - handle.writestr("libero10_N500.zarr/meta/episode_ends/.zarray", "{}") - assert ( - extract_replay(archive, tmp_path / "out") == tmp_path / "out/libero10_N500.zarr" - ) - with zipfile.ZipFile(archive, "w") as handle: - handle.writestr("../outside", "bad") - with pytest.raises(ValueError, match="Unsafe"): - extract_replay(archive, tmp_path / "bad") - assert not (tmp_path / "outside").exists() - - -@pytest.mark.parametrize("bad_source", [False, True]) -def test_campaign_can_pin_a_replacement_without_restarting_other_suites(bad_source): - import io - - from egomimic.benchmarks.libero.catalog import TASKS - - pinned = campaign_sources("study", "a" * 40) - pinned["libero_90"] = {"run_id": "recovered-libero-90", "source_commit": "b" * 40} - - class Storage: - report = None - - def get_object(self, Bucket, Key): - suite = next(s for s, r in pinned.items() if f"/{r['run_id']}/" in Key) - row = { - "status.json": {"state": "SUITE_COMPLETE"}, - "runtime.json": { - "source_commit": "c" * 40 - if bad_source - else pinned[suite]["source_commit"], - "suite": suite, - "mode": "full", - "epochs": 5001, - "global_batch_size": 1024, - }, - "comparison.json": {"suite": suite, "complete_protocol": True}, - }[Key.rsplit("/", 1)[1]] - return {"Body": io.BytesIO(json.dumps(row).encode())} - - def put_object(self, **kwargs): - self.report = json.loads(kwargs["Body"]) - - storage = Storage() - if bad_source: - with pytest.raises(ValueError, match="source"): - publish_campaign( - storage, "study", commit="b" * 40, epochs=5001, run_sources=pinned - ) - assert storage.report is None - else: - assert publish_campaign( - storage, "study", commit="b" * 40, epochs=5001, run_sources=pinned - ) - assert storage.report["source_commit"] is None - assert storage.report["source_commits"]["libero_90"] == "b" * 40 - assert set(storage.report["suites"]) == set(TASKS) - with pytest.raises(ValueError, match="all suites"): - campaign_sources("study", "a" * 40, {"libero_90": pinned["libero_90"]}) - - -@pytest.mark.parametrize("missing_mode", [False, True]) -def test_dual_mode_campaign_requires_every_variant(missing_mode): - import io - - from egomimic.benchmarks.libero.catalog import TASKS - - class Storage: - report = None - - def get_object(self, Bucket, Key): - suite = next(s for s in TASKS if f"study-{s.replace('_', '-')}/" in Key) - variants = { - mode: {"suite": suite, "complete_protocol": True} - for mode in ("dur", "stk") - } - if missing_mode and suite == "libero_90": - del variants["stk"] - value = { - "runtime.json": { - "suite": suite, - "mode": "full", - "epochs": 5001, - "global_batch_size": 1024, - "source_commit": "a" * 40, - "arc_modes": ["dur", "stk"], - }, - "status.json": {"state": "SUITE_COMPLETE"}, - "comparison.json": { - "suite": suite, - "complete_protocol": True, - "variants": variants, - }, - }[Key.rsplit("/", 1)[1]] - return {"Body": io.BytesIO(json.dumps(value).encode())} - - def put_object(self, **kwargs): - self.report = json.loads(kwargs["Body"]) - - storage = Storage() - if missing_mode: - with pytest.raises(ValueError, match="every ARC mode"): - publish_campaign( - storage, "study", commit="a" * 40, epochs=5001, arc_modes=["dur", "stk"] - ) - assert storage.report is None - else: - assert publish_campaign( - storage, "study", commit="a" * 40, epochs=5001, arc_modes=["dur", "stk"] - ) - assert storage.report["arc_modes"] == ["dur", "stk"] - - -def test_campaign_plan_shares_oat_and_schedules_both_arc_variants(): - from egomimic.benchmarks.libero.campaign import campaign_plan - from egomimic.benchmarks.libero.catalog import TASKS - - plan = campaign_plan("/data", "/results") - jobs = {job["id"]: job for job in plan["jobs"]} - assert len(jobs) == len(plan["jobs"]) == 42 - for suite in TASKS: - assert jobs[f"{suite}/oat"]["requires"] == [f"{suite}/tokenizer"] - for mode in ("dur", "stk"): - assert ( - f"+experiment=oat/libero_arc_{mode}_policy" - in jobs[f"{suite}/arc_{mode}"]["argv"] - ) - assert ( - f"{suite}/arc_{mode}_rollout" - in jobs[f"complete_comparison_{mode}"]["requires"] - ) - - -def test_full_training_converts_only_verified_cached_demonstrations( - tmp_path, monkeypatch -): - import hashlib - import io - - from egomimic.benchmarks.libero import cluster, convert - - raw = tmp_path / "cache/libero_90/task_demo.hdf5" - raw.parent.mkdir(parents=True) - raw.write_bytes(b"official demonstration") - metadata = { - "sha": cluster.DATA_REVISION, - "siblings": [ - { - "rfilename": "libero_90/task_demo.hdf5", - "lfs": {"sha256": hashlib.sha256(raw.read_bytes()).hexdigest()}, - } - ], - } - monkeypatch.setenv("LIBERO_RAW_CACHE", str(raw.parent.parent)) - monkeypatch.setattr(cluster, "TASKS", {"libero_90": ["task"]}) - monkeypatch.setattr( - cluster.urllib.request, - "urlopen", - lambda *a, **k: io.BytesIO(json.dumps(metadata).encode()), - ) - monkeypatch.setattr( - cluster, "download", lambda *a, **k: pytest.fail("Cache must not download") - ) - calls = [] - monkeypatch.setattr(convert, "convert_suite", lambda *args: calls.append(args)) - evidence = tmp_path / "evidence" - evidence.mkdir() - result = stage_dataset(tmp_path / "root", "libero_90", evidence) - assert calls == [(raw.parent, result, "libero_90")] - raw.write_bytes(b"corrupt") - with pytest.raises(ValueError, match="checksum"): - stage_dataset(tmp_path / "root", "libero_90", evidence) - assert raw.read_bytes() == b"corrupt" and len(calls) == 1 - - -def test_checkpoint_snapshots_are_content_addressed_and_receipted( - tmp_path, monkeypatch -): - import boto3 - - class Storage: - def __init__(self): - self.objects = {} - - def list_objects_v2(self, **kwargs): - return {"KeyCount": 0} - - def upload_file(self, source, bucket, key, ExtraArgs): - self.objects[key] = Path(source).read_bytes() - assert ExtraArgs["Metadata"]["sha256"] == digest(source) - - storage = Storage() - monkeypatch.setattr(boto3, "client", lambda *args, **kwargs: storage) - for name in ("R2_ENDPOINT_URL", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"): - monkeypatch.setenv(name, "test") - evidence = tmp_path / "evidence" - evidence.mkdir() - path = evidence / "last.ckpt" - path.write_bytes(b"first checkpoint") - uploader = ArtifactUploader(evidence, "test") - uploader.upload(final=True) - first_hash = digest(path) - path.write_bytes(b"final checkpoint") - uploader.upload(final=True) - uploader.upload(final=True) - receipts = json.loads((evidence / "checkpoint-receipts.json").read_text()) - assert receipts["last.ckpt"]["sha256"] == digest(path) - assert any(first_hash in key for key in storage.objects) - assert any(digest(path) in key for key in storage.objects) - - -@pytest.mark.parametrize("invalid", [None, "budget", "hash", "incomplete", "outside"]) -def test_recovery_verifies_checkpoints_and_complete_training(tmp_path, invalid): - import io - import shutil - - import torch - - source = tmp_path / "source.ckpt" - torch.save( - { - "global_step": 2, - "loops": { - "fit_loop": { - "epoch_progress": { - "current": {"completed": 0 if invalid == "incomplete" else 1} - } - } - }, - }, - source, - ) - prefix = "experiments/arc-oat-20260919/source-run/" - runtime = { - "suite": "libero_10", - "mode": "full" if invalid == "budget" else "smoke", - "epochs": 1, - } - receipt = { - "sha256": "bad" if invalid == "hash" else digest(source), - "bytes": source.stat().st_size, - "uri": "s3://rldb/" - + ("another-run/" if invalid == "outside" else prefix + "checkpoints/") - + "last.ckpt", - } - receipts = { - f"training/{method}/checkpoints/last.ckpt": receipt - for method in ("tokenizer", "oat", "arc") - } - - class Storage: - def get_object(self, Bucket, Key): - value = runtime if Key.endswith("runtime.json") else receipts - return {"Body": io.BytesIO(json.dumps(value).encode())} - - def download_file(self, bucket, key, destination): - shutil.copyfile(source, destination) - - evidence = tmp_path / "evidence" - if invalid is not None: - with pytest.raises(ValueError): - restore_checkpoints( - Storage(), - "source-run", - evidence, - suite="libero_10", - mode="smoke", - epochs=5001, - ) - else: - restore_checkpoints( - Storage(), - "source-run", - evidence, - suite="libero_10", - mode="smoke", - epochs=5001, - ) - assert ( - len( - json.loads((evidence / "recovered-training.json").read_text())[ - "checkpoints" - ] - ) - == 3 - ) - - -@pytest.mark.parametrize("invalid", [None, "optimizer", "normalizer", "budget"]) -@pytest.mark.parametrize( - "methods", [("tokenizer", "oat", "arc"), ("tokenizer", "oat", "arc_dur", "arc_stk")] -) -def test_partial_resume_preserves_only_existing_verified_checkpoints( - tmp_path, invalid, methods -): - import io - import shutil - - import torch - - payload = { - "global_step": 140, - "loops": {"fit_loop": {"epoch_progress": {"current": {"completed": 2}}}}, - "training_budget": { - "global_batch_size": 256 if invalid == "budget" else 1024, - "epochs": 5001, - }, - "optimizer_states": [{"state": {}}], - "normalizer_state": {}, - } - if invalid == "optimizer": - del payload["optimizer_states"] - if invalid == "normalizer": - del payload["normalizer_state"] - source = tmp_path / "partial.ckpt" - torch.save(payload, source) - runtime = { - "suite": "libero_10", - "mode": "full", - "epochs": 5001, - "global_batch_size": 1024, - } - receipts = { - "training/tokenizer/checkpoints/last.ckpt": { - "sha256": digest(source), - "bytes": source.stat().st_size, - "uri": "s3://rldb/experiments/arc-oat-20260919/source-run/checkpoints/partial.ckpt", - } - } - - class Storage: - def get_object(self, Bucket, Key): - value = runtime if Key.endswith("runtime.json") else receipts - return {"Body": io.BytesIO(json.dumps(value).encode())} - - def download_file(self, bucket, key, destination): - shutil.copyfile(source, destination) - - if invalid: - with pytest.raises(ValueError, match="Partial checkpoint"): - restore_checkpoints( - Storage(), - "source-run", - tmp_path / "evidence", - suite="libero_10", - mode="full", - epochs=5001, - allow_partial=True, - methods=methods, - ) - else: - restored = restore_checkpoints( - Storage(), - "source-run", - tmp_path / "evidence", - suite="libero_10", - mode="full", - epochs=5001, - allow_partial=True, - methods=methods, - ) - assert set(restored) == {"tokenizer"} - assert restored["tokenizer"]["global_step"] == 140 - assert restored["tokenizer"]["complete"] is False - assert not (tmp_path / "evidence/training/oat").exists() - - -@pytest.mark.parametrize( - "invalid", - [ - None, - "pending", - "suite", - "protocol", - "spec", - "codec", - "retention", - "incomplete", - "evaluated", - "unpermitted_gap", - "mode", - ], -) -@pytest.mark.parametrize("arc_mode", ["joint_dur", "stk", "dur"]) -def test_arc_training_requires_matching_confirmed_replay( - tmp_path, monkeypatch, invalid, arc_mode -): - import hashlib - import io - import subprocess - - import yaml - - from egomimic.benchmarks.libero.replay import candidate_id - - root = Path(__file__).parents[1] - spec = yaml.safe_load( - (root / "egomimic/hydra_configs/benchmark/libero_arc_replay.yaml").read_text() - ) - if invalid in {"evaluated", "unpermitted_gap"}: - spec.update( - selection_objective="success_then_tokens", - maximum_success_rate_drop=1.0, - allow_reference_gap=invalid == "evaluated", - ) - codec = {"num_waypoints": 16, "max_translation": 0.2, "max_rotation_degrees": 48} - if arc_mode != "joint_dur": - codec["mode"] = spec["arc_mode"] = arc_mode - selected = candidate_id(codec) - result = { - "confirmed": invalid not in {"pending", "evaluated", "unpermitted_gap"}, - "confirmation_complete": True, - "arc_mode": "unmatched" if invalid == "mode" else arc_mode, - "suite": "libero_10", - "codec": codec, - "candidate_id": selected, - "selected_before_confirmation": True, - "spec_sha256": hashlib.sha256( - (json.dumps(spec, indent=2) + "\n").encode() - ).hexdigest(), - "confirmation": { - "raw": {"success_rate": 1, "episodes": 50}, - "raw_repeat": {"max_state_l2_vs_raw_mean": 0, "episodes": 50}, - "dense": {"retention": 1, "max_action_mse": 0, "episodes": 50}, - selected: { - "retention": 0.9 if invalid == "retention" else 1, - "success_rate": 0.9 - if invalid in {"retention", "evaluated", "unpermitted_gap"} - else 1, - "raw_successes": 50, - "episodes": 50, - "successes": 50, - "action_mse": 0.1, - }, - }, - } - runtime = { - "suite": "libero_goal" if invalid == "suite" else "libero_10", - "source_commit": "a" * 40, - } - if invalid == "protocol": - spec["execute_steps"] = 8 - if invalid == "spec": - result["spec_sha256"] = "wrong" - if invalid == "incomplete": - result["confirmation"][selected]["episodes"] = 49 - content = (root / "egomimic/rldb/zarr/libero_arc.py").read_bytes() - monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: None) - monkeypatch.setattr( - subprocess, - "check_output", - lambda argv, **kwargs: ( - b"different codec" - if invalid == "codec" - else (root / argv[-1].split(":", 1)[1]).read_bytes() - ), - ) - - class Storage: - def get_object(self, Bucket, Key): - value = { - "status.json": { - "state": "REPLAY_EVALUATED" - if invalid in {"evaluated", "unpermitted_gap"} - else "CONFIRMED" - }, - "result.json": result, - "runtime.json": runtime, - "spec.json": spec, - }[Key.rsplit("/", 1)[-1]] - return {"Body": io.BytesIO(json.dumps(value).encode())} - - if invalid and invalid != "evaluated": - with pytest.raises((ValueError, CalibrationPending)): - load_arc_calibration( - Storage(), "replay-run", tmp_path, suite="libero_10", arc_mode=arc_mode - ) - assert not (tmp_path / "arc-calibration.json").exists() - else: - overrides = load_arc_calibration( - Storage(), "replay-run", tmp_path, suite="libero_10", arc_mode=arc_mode - ) - expected = { - "arc_waypoints": 16, - "arc_max_translation": 0.2, - "arc_max_rotation_degrees": 48, - } - if arc_mode != "joint_dur": - expected.update(arc_mode=arc_mode, arc_action_dim=12) - if arc_mode == "stk": - expected["arc_velocity_norm_bound"] = 3**0.5 - assert overrides == expected - receipt = ( - "arc-calibration.json" - if arc_mode == "joint_dur" - else f"arc-{arc_mode}-calibration.json" - ) - assert ( - json.loads((tmp_path / receipt).read_text())["verified_codec_sha256"] - == hashlib.sha256(content).hexdigest() - ) - with pytest.raises(CalibrationPending, match="requires"): - load_arc_calibration(Storage(), None, tmp_path, suite="libero_10") diff --git a/tests/test_libero_decoded_replay.py b/tests/test_libero_decoded_replay.py deleted file mode 100644 index 14c72b5cdb..0000000000 --- a/tests/test_libero_decoded_replay.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Decoded replay must preserve the benchmark while sharing physical data.""" - -import multiprocessing -import pickle -from concurrent.futures import ProcessPoolExecutor - -import numpy as np -import pytest -import torch -import zarr -from torch.utils.data import DataLoader - -from egomimic.rldb.zarr.decoded_replay import prepare_decoded_replay -from egomimic.rldb.zarr.libero_dataset import ( - LiberoDataset, - LiberoNormalizer, - LiberoReplayResolver, - keymap, -) -from tests.test_libero_benchmark import make_replay - - -def build_dataset(path, *, cached, mode="train", n_obs_steps=2, horizon=32): - resolver = LiberoReplayResolver( - path, keymap(n_obs_steps, horizon), "libero10", decoded_cache=cached - ) - dataset = LiberoDataset._from_resolver(resolver, mode=mode) - normalizer = LiberoNormalizer() - normalizer.populate_from_datasets({"libero_panda": dataset}) - normalizer.infer_shapes_from_batch(dataset[0]) - normalizer.infer_norm_from_dataset(dataset, "libero_panda") - dataset.set_norm_stats_from(normalizer) - return dataset, normalizer - - -def assert_sample_equal(actual, expected): - assert actual.keys() == expected.keys() - for key, value in expected.items(): - if torch.is_tensor(value): - torch.testing.assert_close(actual[key], value, rtol=0, atol=0) - else: - assert actual[key] == value - - -@pytest.mark.parametrize("n_obs_steps", [0, 2, 8]) -def test_all_frames_padding_normalization_and_batch_parity(tmp_path, n_obs_steps): - path = tmp_path / "replay.zarr" - make_replay(path) - reference, old_norm = build_dataset(path, cached=False, n_obs_steps=n_obs_steps) - cached, new_norm = build_dataset(path, cached=True, n_obs_steps=n_obs_steps) - assert old_norm.context == new_norm.context - assert old_norm.tokenizer_context() == new_norm.tokenizer_context() - assert reference.index_map == cached.index_map - for i in range(len(reference)): - assert_sample_equal(cached[i], reference[i]) - for actual, expected in zip( - DataLoader(cached, batch_size=7), DataLoader(reference, batch_size=7) - ): - assert_sample_equal(actual, expected) - valid, _ = build_dataset(path, cached=True, mode="valid", n_obs_steps=n_obs_steps) - assert not set(valid.datasets) & set(cached.datasets) - arrays = cached.resolver.open_arrays() - assert all( - isinstance(a, np.memmap) and not a.flags.writeable for a in arrays.values() - ) - leaf = next(iter(cached.datasets.values())) - original = leaf[0] - changed = leaf[0] - changed["actions"].zero_() - assert_sample_equal(leaf[0], original) - with pytest.raises(IndexError): - leaf[len(leaf)] - - -def _prepare_worker(path): - cache = prepare_decoded_replay(path, ["action", "agentview_rgb"]) - return str(cache.directory), cache.open()["action"].copy() - - -def test_concurrent_ranks_publish_one_cache_and_workers_reopen_it(tmp_path): - path = tmp_path / "replay.zarr" - expected = make_replay(path) - with ProcessPoolExecutor( - 2, mp_context=multiprocessing.get_context("spawn") - ) as pool: - results = list(pool.map(_prepare_worker, [str(path)] * 2)) - assert results[0][0] == results[1][0] - for _, actions in results: - np.testing.assert_array_equal(actions, expected["action"]) - dataset, _ = build_dataset(path, cached=True) - encoded = pickle.dumps(dataset) - assert len(encoded) < 100_000 # Never serialize decoded images into each worker. - restored = pickle.loads(encoded) - assert restored.resolver._decoded._arrays is None - for actual, expected_batch in zip( - DataLoader( - restored, batch_size=9, num_workers=2, multiprocessing_context="spawn" - ), - DataLoader(dataset, batch_size=9), - ): - assert_sample_equal(actual, expected_batch) - - -def test_source_changes_cannot_reuse_old_decoded_values(tmp_path): - path = tmp_path / "replay.zarr" - arrays = make_replay(path) - old = prepare_decoded_replay(path, ["action"]) - old_values = old.open()["action"].copy() - data = zarr.open_group(str(path), mode="r+")["data"] - data["action"][0] = arrays["action"][0] + 0.1 - new = prepare_decoded_replay(path, ["action"]) - assert new.directory != old.directory - np.testing.assert_array_equal(new.open()["action"][0], data["action"][0]) - np.testing.assert_array_equal(old.open()["action"], old_values) - - -def test_failed_build_is_not_published_and_truncated_cache_is_rejected(tmp_path): - path = tmp_path / "replay.zarr" - arrays = make_replay(path) - data = zarr.open_group(str(path), mode="r+")["data"] - data["action"][0, 0] = float("nan") - with pytest.raises(ValueError, match="Non-finite"): - prepare_decoded_replay(path, ["action"]) - root = path.with_name(path.name + ".decoded") - assert not list(root.glob("*/manifest.json")) - assert not list(root.glob(".building-*")) - data["action"][:] = arrays["action"] - cache = prepare_decoded_replay(path, ["action"]) - target = cache.directory / cache.manifest["arrays"]["action"]["file"] - with target.open("r+b") as handle: - handle.truncate(100) - with pytest.raises(ValueError, match="Truncated"): - prepare_decoded_replay(path, ["action"]) - - -def test_cache_location_must_not_pollute_source_identity(tmp_path): - path = tmp_path / "replay.zarr" - make_replay(path) - with pytest.raises(ValueError, match="outside"): - prepare_decoded_replay(path, ["action"], cache_root=path / "cache") diff --git a/tests/test_libero_evaluate.py b/tests/test_libero_evaluate.py deleted file mode 100644 index 5a2a7a235c..0000000000 --- a/tests/test_libero_evaluate.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Completed-checkpoint isolation and exact full-protocol evaluation routing.""" - -import copy -import io -import json -import shutil - -import pytest -import torch - -from egomimic.benchmarks.libero.cluster import digest -from egomimic.benchmarks.libero.evaluate import ( - checkpoint_completion, - restore_policy, - validate_request, -) -from scripts.benchmarks.launch_libero_osmo import evaluation_workflow - - -@pytest.fixture -def checkpoint(tmp_path): - payload = { - "global_step": 270054, - "ema_num_updates": 270054, - "ema_state_dict": {"weight": torch.ones(1)}, - "normalizer_state": {}, - "oat_tokenizer_config": {"_target_": "native.OAT"}, - "benchmark_data_context": {"suite": "libero_spatial"}, - "hyper_parameters": { - "config_tree": { - "model": {"benchmark_protocol": {"suite": "libero_spatial"}}, - } - }, - "loops": {"fit_loop": {"epoch_progress": {"current": {"completed": 5001}}}}, - "training_budget": { - "epochs": 5001, - "global_batch_size": 1024, - "total_optimizer_steps": 270054, - }, - } - path = tmp_path / "source.ckpt" - torch.save(payload, path) - sha = digest(path) - request = { - "source_run": "oat-source", - "source_commit": "a" * 40, - "method": "oat", - "suite": "libero_spatial", - "epochs": 5001, - "total_optimizer_steps": 270054, - "checkpoint": { - "sha256": sha, - "bytes": path.stat().st_size, - "uri": f"s3://rldb/experiments/arc-oat-20260919/oat-source/checkpoints/{sha}/last.ckpt", - }, - } - return payload, path, request - - -@pytest.mark.parametrize("partial", ["epochs", "optimizer"]) -def test_completion_waits_for_both_final_epoch_and_optimizer(checkpoint, partial): - payload, _, request = checkpoint - if partial == "epochs": - payload["loops"]["fit_loop"]["epoch_progress"]["current"]["completed"] = 5000 - else: - payload["global_step"] -= 1 - assert checkpoint_completion(payload, request) is None - - -@pytest.mark.parametrize( - "invalid", - ["ema", "weights", "budget", "suite", "tokenizer", "normalizer", "overrun"], -) -def test_completion_rejects_incompatible_checkpoint(checkpoint, invalid): - payload, _, request = checkpoint - if invalid == "ema": - payload["ema_num_updates"] -= 1 - elif invalid == "weights": - del payload["ema_state_dict"] - elif invalid == "budget": - payload["training_budget"]["global_batch_size"] = 256 - elif invalid == "suite": - payload["benchmark_data_context"]["suite"] = "libero_object" - elif invalid == "tokenizer": - del payload["oat_tokenizer_config"] - elif invalid == "normalizer": - del payload["normalizer_state"] - else: - payload["global_step"] += 1 - with pytest.raises(ValueError): - checkpoint_completion(payload, request) - - -@pytest.mark.parametrize("invalid", [None, "hash", "runtime", "partial"]) -def test_restores_only_pinned_complete_policy_without_training_data( - tmp_path, checkpoint, invalid -): - payload, source, request = checkpoint - runtime = { - "source_commit": "a" * 40, - "suite": "libero_spatial", - "epochs": 5001, - "global_batch_size": 1024, - "mode": "full", - } - if invalid == "hash": - source.write_bytes(b"corrupt checkpoint") - elif invalid == "runtime": - runtime["source_commit"] = "b" * 40 - elif invalid == "partial": - payload["global_step"] -= 1 - torch.save(payload, source) - request["checkpoint"].update(sha256=digest(source), bytes=source.stat().st_size) - request["checkpoint"]["uri"] = request["checkpoint"]["uri"].replace( - request["checkpoint"]["uri"].split("/")[-2], digest(source) - ) - - class Storage: - def get_object(self, *, Bucket, Key): - assert Key == "experiments/arc-oat-20260919/oat-source/runtime.json" - return {"Body": io.BytesIO(json.dumps(runtime).encode())} - - def download_file(self, bucket, key, destination): - assert key == request["checkpoint"]["uri"].removeprefix("s3://rldb/") - shutil.copyfile(source, destination) - - root, evidence = tmp_path / "worker", tmp_path / "worker/evidence" - if invalid: - with pytest.raises(ValueError): - restore_policy(Storage(), request, root, evidence) - else: - path = restore_policy(Storage(), request, root, evidence) - assert digest(path) == request["checkpoint"]["sha256"] - proof = json.loads((evidence / "recovered-training.json").read_text()) - assert proof["global_step"] == proof["ema_num_updates"] == 270054 - assert not (root / "data").exists() - assert not list(evidence.rglob("*.ckpt")) - - -def test_request_cannot_point_to_other_run_or_unpinned_checkpoint(checkpoint): - _, _, request = checkpoint - for uri in ( - "s3://rldb/another/checkpoint.ckpt", - request["checkpoint"]["uri"].replace("oat-source", "other-run"), - ): - invalid = copy.deepcopy(request) - invalid["checkpoint"]["uri"] = uri - with pytest.raises(ValueError): - validate_request(invalid) - - -def test_evaluation_routes_directly_to_native_rollouts_on_one_gpu(checkpoint): - _, _, request = checkpoint - result = evaluation_workflow("b" * 40, "oat-evaluation", request)["workflow"] - assert result["resources"]["default"]["gpu"] == 1 - task = result["tasks"][0] - assert task["environment"]["RUN_KIND"] == "policy_evaluation" - assert task["environment"]["SOURCE_COMMIT"] == "b" * 40 - files = {item["path"]: item["contents"] for item in task["files"]} - assert json.loads(files["/tmp/evaluation-request.json"]) == request - entry = files["/tmp/entry.sh"] - branch = entry.split("== policy_evaluation ]]; then")[1].split("elif")[0] - assert "egomimic.benchmarks.libero.evaluate" in branch - assert "trainHydra" not in branch and "stage_dataset" not in branch - - -def test_parallel_repetitions_keep_every_original_seed_once(): - from egomimic.benchmarks.libero.rollout import rollout_plan - - complete = rollout_plan("libero_spatial") - shards = [rollout_plan("libero_spatial", repetition_index=i) for i in range(5)] - assert [entry for shard in shards for entry in shard] == complete - assert all(len(shard) == 500 for shard in shards) - with pytest.raises(ValueError, match="outside"): - rollout_plan("libero_spatial", repetition_index=5) - - -@pytest.mark.parametrize( - "invalid", [None, "missing", "duplicate", "checkpoint", "repetition", "seed"] -) -def test_parallel_merge_requires_complete_identical_policy_protocol(tmp_path, invalid): - from dataclasses import asdict - - from egomimic.benchmarks.libero.catalog import LIBERO_COMMIT, OAT_COMMIT - from egomimic.benchmarks.libero.evaluate import merge_repetitions - from egomimic.benchmarks.libero.report import ( - read_run, - summarize, - validate_full_protocol, - ) - from egomimic.benchmarks.libero.rollout import rollout_plan - - directories = [] - for index in range(5): - directory = tmp_path / str(index) - directory.mkdir() - directories.append(directory) - plan = [ - asdict(s) for s in rollout_plan("libero_spatial", repetition_index=index) - ] - protocol = { - "plan": plan, - "suite": "libero_spatial", - "horizon": 32, - "n_obs_steps": 2, - "n_action_steps": 16, - "max_episode_steps": 550, - "oat_commit": OAT_COMMIT, - "libero_commit": LIBERO_COMMIT, - "use_ema": True, - "checkpoint_sha256": "a" * 64, - "evaluation_repetition": index, - } - if index == 2 and invalid == "checkpoint": - protocol["checkpoint_sha256"] = "b" * 64 - if index == 2 and invalid == "repetition": - protocol["evaluation_repetition"] = 1 - if index == 2 and invalid == "seed": - plan[0]["seed"] += 1 - records = [ - { - **s, - "success": True, - "steps": 1, - "initial_state_sha256": str(s["seed"]), - "inference_seconds": [0.01], - } - for s in plan - ] - if index == 2 and invalid == "missing": - records.pop() - if index == 2 and invalid == "duplicate": - records.append(records[0]) - if index == 0: - (directory / "rollout_000000.mp4").write_bytes(b"video") - records[0]["video"] = "rollout_000000.mp4" - (directory / "protocol.json").write_text(json.dumps(protocol)) - (directory / "episodes.jsonl").write_text( - "".join(json.dumps(r) + "\n" for r in records) - ) - output = tmp_path / "combined" - if invalid: - with pytest.raises(ValueError): - merge_repetitions(directories, output) - assert not output.exists() - else: - merge_repetitions(directories, output) - protocol, records = read_run(output) - validate_full_protocol(protocol) - result = summarize(records) - assert result["episodes"] == 2500 and result["mean_success_rate"] == 1 - assert len(result["per_task_success"]) == 10 - assert len(result["per_repetition_success"]) == 5 - assert (output / "repetition_0_rollout_000000.mp4").read_bytes() == b"video" - - -def test_parallel_workflow_reserves_cpus_and_still_uses_one_gpu(checkpoint): - _, _, request = checkpoint - result = evaluation_workflow("b" * 40, "parallel-evaluation", request, workers=5)[ - "workflow" - ] - assert result["resources"]["default"]["gpu"] == 1 - assert result["resources"]["default"]["cpu"] == 15 - assert result["tasks"][0]["environment"]["EVALUATION_WORKERS"] == "5" - with pytest.raises(ValueError): - evaluation_workflow("b" * 40, "invalid-workers", request, workers=8) diff --git a/tests/test_libero_multi_embodiment_native.py b/tests/test_libero_multi_embodiment_native.py deleted file mode 100644 index 98feabd1c5..0000000000 --- a/tests/test_libero_multi_embodiment_native.py +++ /dev/null @@ -1,1286 +0,0 @@ -import json - -import h5py -import numpy as np -import pytest -import torch -import zarr - -from egomimic.benchmarks.libero.catalog import TASK_IDS, get_tasks -from egomimic.benchmarks.libero.convert import ( - convert_collection_manifest, - native_episode_values, -) -from egomimic.pipeline.stages_sampler import KeyedFeatureProjection -from egomimic.rldb.zarr.libero_dataset import ( - LiberoDataset, - LiberoMultiEmbodimentNormalizer, - LiberoReplayResolver, - keymap, -) - - -def replay(path, action_dim, hand_dim, demos_per_task=2): - tasks = get_tasks("libero_spatial") - ids = np.repeat([TASK_IDS[t][2] for t in tasks for _ in range(demos_per_task)], 2) - n = len(ids) - rng = np.random.default_rng(4) - group = zarr.open_group(str(path), mode="w", zarr_format=2) - data = group.create_group("data") - for key, values in { - "action": rng.uniform(-1, 1, (n, action_dim)).astype("float32"), - "robot0_gripper_qpos": rng.uniform(0, 1, (n, hand_dim)).astype("float32"), - "robot0_eef_pos": rng.uniform(0, 1, (n, 3)).astype("float32"), - "robot0_eef_quat": np.tile(np.array([0, 0, 0, 1], dtype="float32"), (n, 1)), - "task_uid": ids[:, None], - "agentview_rgb": rng.integers(0, 255, (n, 2, 2, 3), dtype="uint8"), - "robot0_eye_in_hand_rgb": rng.integers(0, 255, (n, 2, 2, 3), dtype="uint8"), - }.items(): - data.create_array(key, data=values) - group.create_group("meta").create_array("episode_ends", data=np.arange(2, n + 1, 2)) - return group - - -def resolver(path, sharpa=False): - return LiberoReplayResolver( - path, - keymap(2, 32), - "libero_spatial", - **( - dict(action_dim=28, gripper_state_dim=22, embodiment="libero_panda_sharpa") - if sharpa - else {} - ), - ) - - -def test_distinct_native_widths_and_reject_sharpa_as_original(tmp_path): - path = tmp_path / "sharpa.zarr" - replay(path, 28, 22) - with pytest.raises(ValueError, match="action must"): - resolver(path).resolve() - sample = next(iter(resolver(path, True).resolve().values()))[0] - assert sample["embodiment"] == 22 - assert sample["actions"].shape == (32, 28) - assert sample["robot0_gripper_qpos"].shape == (2, 22) - assert torch.equal(sample["actions"][1], sample["actions"][-1]) - with pytest.raises(ValueError, match="Sharpa contract"): - LiberoReplayResolver( - path, - keymap(), - "libero_spatial", - action_dim=7, - gripper_state_dim=22, - embodiment="libero_panda_sharpa", - ) - - -def test_configured_replay_split_proves_rows_and_rejects_leakage(tmp_path): - from egomimic.rldb.zarr.libero_saved_state import make_replay_split_proof - - path = tmp_path / "replay.zarr" - replay(path, 7, 2) - r = resolver(path) - train = LiberoDataset._from_resolver(r, mode="train", valid_ratio=0.1) - valid = LiberoDataset._from_resolver(r, mode="valid", valid_ratio=0.1) - kwargs = dict(expected_episodes=20, valid_ratio=0.1, seed=42) - proof = make_replay_split_proof(train, valid, **kwargs) - assert len(proof["train"]) == 18 and len(proof["valid"]) == 2 - rows = sorted(proof["train"] + proof["valid"], key=lambda row: row["start"]) - assert [(row["start"], row["end"]) for row in rows] == [ - (i, i + 2) for i in range(0, 40, 2) - ] - assert set(row["physical_id"] for row in proof["train"]).isdisjoint( - row["physical_id"] for row in proof["valid"] - ) - with pytest.raises(ValueError, match="coverage"): - make_replay_split_proof(train, valid, **{**kwargs, "expected_episodes": 21}) - key = next(iter(valid.datasets)) - valid.datasets[key] = next(iter(train.datasets.values())) - with pytest.raises(ValueError, match="physical rows"): - make_replay_split_proof(train, valid, **kwargs) - - -def test_joint_operational_split_materializes_450_50_and_rejects_wrong_binding( - tmp_path, monkeypatch -): - import copy - from pathlib import Path - - import hydra - from hydra.core.hydra_config import HydraConfig - from omegaconf import OmegaConf - - from egomimic.benchmarks.libero.native_launch_profiles import ( - SPATIAL_HAND_JOINT_PROFILE, - ) - from scripts.train.libero_native_launch_contract import ( - joint_split_contract, - normalize_joint_context, - sha, - ) - - root = Path(__file__).resolve().parents[1] - manifests = {} - for name, width, hand in (("libero_panda", 7, 2), ("libero_panda_sharpa", 69, 22)): - path = tmp_path / name - replay(path, width, hand, demos_per_task=50) - manifest = dict(complete=True, action_dim=width) - if width == 69: - manifest.update( - action_representation="wrist_hand_keypoints", - keypoint_frame="wrist_relative_Quest_XYZ_m", - observation_alignment="causal_pre_action_raw_lossless", - ) - (path / "benchmark_manifest.json").write_text(json.dumps(manifest)) - manifests[name] = sha(path / "benchmark_manifest.json") - monkeypatch.setenv("LIBERO_ORIGINAL_SPATIAL_ROOT", str(tmp_path / "libero_panda")) - monkeypatch.setenv( - "LIBERO_SHARPA_SPATIAL_ROOT", str(tmp_path / "libero_panda_sharpa") - ) - monkeypatch.setenv("LIBERO_DIAGNOSTIC_SEED_BANK", str(tmp_path / "seeds.json")) - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(root / "egomimic/hydra_configs") - ): - cfg = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=[ - "hydra/launcher=basic", - "+experiment=" + SPATIAL_HAND_JOINT_PROFILE.experiment, - "trainer.precision=bf16-mixed", - "trainer.devices=1", - ], - ) - cfg.hydra.runtime.output_dir = str(tmp_path / "output") - HydraConfig.instance().set_config(cfg) - resolved = OmegaConf.to_container( - OmegaConf.masked_copy(cfg, [k for k in cfg if k != "hydra"]), resolve=True - ) - result = joint_split_contract(resolved, manifests, expected_episodes=500) - assert result["status"] == "PASS" and result["launch_ready"] is False - for proof in result["sources"].values(): - assert len(proof["train"]) == 450 and len(proof["valid"]) == 50 - assert proof["frames"] == 1000 and proof["complete_episode_and_frame_coverage"] - with pytest.raises(ValueError, match="manifest SHA"): - joint_split_contract( - resolved, {**manifests, "libero_panda": "0" * 64}, expected_episodes=500 - ) - wrong = copy.deepcopy(resolved) - for mode in ("train", "valid"): - wrong["data"][mode + "_datasets"]["libero_panda_sharpa"]["resolver"][ - "folder_path" - ] = str(tmp_path / "libero_panda") - with pytest.raises(ValueError, match="distinct"): - joint_split_contract(wrong, manifests, expected_episodes=500) - # Real operational handoff: physical proof from a YAML config -> configured - # DataModule fitting -> complete context, with no model or trainer. - original_config = tmp_path / "split-config.yaml" - OmegaConf.save(OmegaConf.create(resolved), original_config) - result["resolved_config"] = dict( - path=str(original_config), sha256=sha(original_config) - ) - split_path = tmp_path / "split.json" - split_path.write_text(json.dumps(result)) - fit = copy.deepcopy(resolved) - fit["norm_stats_only"] = True - fit["norm_stats"].update( - precomputed_norm_path=None, - save_cache_dir=str(tmp_path / "stats"), - export_context_path=str(tmp_path / "context.json"), - ) - fit["run_provenance"].update( - split_manifest_path=str(split_path), split_manifest_sha256=sha(split_path) - ) - fit["data"]["run_provenance"] = copy.deepcopy(fit["run_provenance"]) - norm = normalize_joint_context(fit) - assert norm["status"] == "PASS" and norm["launch_ready"] is False - context = json.loads((tmp_path / "context.json").read_text())["data_context"] - assert set(context["normalizer_state"]["norm_stats"]) == {"21", "22"} - assert ( - len(context["normalizer_state"]["norm_stats"]["22"]["actions"]["scale"]) == 69 - ) - # The checkpoint verifier must restore the same complete state without data IO. - from egomimic.pipeline.checkpoint_binding import checkpoint_binding - from egomimic.rldb.zarr.data_module import load_data_context - from scripts.train.verify_libero_native_action_flow_smoke import ( - validate_joint_checkpoint_context, - ) - - checkpoint_config = copy.deepcopy(fit) - checkpoint_config["norm_stats"]["precomputed_norm_path"] = str( - tmp_path / "context.json" - ) - checkpoint_config["run_provenance"].update( - source_commit="1" * 40, normalization_sha256=sha(tmp_path / "context.json") - ) - restored_context = load_data_context(tmp_path / "context.json") - checkpoint = dict( - hyper_parameters=dict(config_tree=checkpoint_config), - data_context=restored_context.snapshot(), - inference_binding=checkpoint_binding(checkpoint_config, restored_context), - ) - assert ( - validate_joint_checkpoint_context(checkpoint, checkpoint_config)[ - "checkpoint_binding" - ] - == checkpoint["inference_binding"] - ) - # Native checkpoints keep integer embodiment keys/tuples; JSON exports do not. - native_context = copy.deepcopy(checkpoint["data_context"]) - native_context["normalizer_state"] = restored_context.normalizer.to_state() - assert native_context != checkpoint["data_context"] - checkpoint["data_context"] = native_context - assert ( - validate_joint_checkpoint_context(checkpoint, checkpoint_config)[ - "checkpoint_binding" - ] - == checkpoint["inference_binding"] - ) - wrong_checkpoint = copy.deepcopy(checkpoint) - wrong_checkpoint["data_context"].pop("preprocessing") - with pytest.raises(ValueError, match="full data context differs"): - validate_joint_checkpoint_context(wrong_checkpoint, checkpoint_config) - # Consume genuine fixture context/split plus explicitly synthetic constructor/runtime metadata. - from scripts.train.libero_native_launch_contract import ( - contract_sha, - joint_preflight_evidence, - reference, - ) - - pre = copy.deepcopy(checkpoint_config) - pre["norm_stats_only"] = False - pre["trainer"]["accelerator"] = "cpu" - pre["run_provenance"]["run_kind"] = "preflight" - config_path = tmp_path / "preflight.yaml" - OmegaConf.save(OmegaConf.create(pre), config_path) - constructor_path = tmp_path / "synthetic-constructor.json" - constructor = dict( - status="PASS", - full_model_constructor=True, - parameter_count=78182614, - source_commit="1" * 40, - resolved_config_sha256=sha(config_path), - model_contract_sha256=contract_sha(pre), - ) - constructor_path.write_text(json.dumps(constructor)) - runtime_path = tmp_path / "synthetic-runtime.json" - runtime_path.write_text( - json.dumps( - { - "python": {}, - "installed_distributions": {}, - "core_distribution_versions": {}, - "dependency_inputs": {}, - "torch_runtime": {}, - "source": {"head": "1" * 40, "dirty": False}, - } - ) - ) - proof = joint_preflight_evidence( - pre, - config_path, - constructor_ref=reference(constructor_path), - runtime_ref=reference(runtime_path), - source_head="1" * 40, - ) - assert proof["launch_ready"] is False - assert proof["complete_context"]["sha256"] == sha(tmp_path / "context.json") - constructor["source_commit"] = "0" * 40 - constructor_path.write_text(json.dumps(constructor)) - with pytest.raises(ValueError, match="never relabel cached"): - joint_preflight_evidence( - pre, - config_path, - constructor_ref=reference(constructor_path), - runtime_ref=reference(runtime_path), - source_head="1" * 40, - ) - with pytest.raises(ValueError, match="fresh absolute"): - normalize_joint_context(fit) - fit["norm_stats"]["export_context_path"] = str(tmp_path / "other-context.json") - result["sources"]["libero_panda"]["episodes"] = 499 - split_path.write_text(json.dumps(result)) - fit["run_provenance"]["split_manifest_sha256"] = sha(split_path) - with pytest.raises(ValueError, match="split/content binding"): - normalize_joint_context(fit) - fit["data"]["train_datasets"]["libero_panda"]["valid_ratio"] = 0.01 - with pytest.raises(ValueError, match="episode split differs"): - normalize_joint_context(fit) - - -@pytest.mark.parametrize( - "action", ["joint-normalize", "joint-preflight", "joint-compose", "joint-prepare"] -) -@pytest.mark.parametrize("step", ["batch", "extern"]) -def test_joint_data_cli_rejects_non_numeric_scheduler_step( - tmp_path, monkeypatch, step, action -): - import sys - - from scripts.train.libero_native_launch_contract import main - - monkeypatch.setenv("SLURM_JOB_ID", "1234") - monkeypatch.setenv("SLURM_STEP_ID", step) - monkeypatch.setattr( - sys, - "argv", - [ - "contract", - "--action", - action, - "--repo", - str(tmp_path), - "--output", - str(tmp_path / "out"), - ], - ) - with pytest.raises(ValueError, match="scheduled numeric srun"): - main() - assert not (tmp_path / "out").exists() - - -def test_train_only_limits_separate_bodies_and_roundtrip(tmp_path): - datasets = {} - for name, width, hand in [("libero_panda", 7, 2), ("libero_panda_sharpa", 28, 22)]: - path = tmp_path / (name + ".zarr") - group = replay(path, width, hand) - r = resolver(path, width == 28) - train = LiberoDataset._from_resolver(r, mode="train", valid_ratio=0.1) - valid = LiberoDataset._from_resolver(r, mode="valid", valid_ratio=0.1) - assert not set(train.datasets) & set(valid.datasets) - for episode in valid.datasets.values(): - group["data/action"][episode.start : episode.end] = 1000 - datasets[name] = train - norm = LiberoMultiEmbodimentNormalizer() - norm.populate_from_datasets(datasets) - for name, dataset in datasets.items(): - norm.infer_shapes_from_batch(dataset[0]) - norm.infer_norm_from_dataset(dataset, name) - ident = dataset.resolver.embodiment_id - assert norm.norm_stats[ident]["actions"]["max"].max() < 1 - dataset.set_norm_stats_from(norm) - sample = dataset[0] - raw = next(iter(dataset.datasets.values()))[0] - restored = norm.unnormalize({"actions": sample["actions"]}, ident) - torch.testing.assert_close(restored["actions"], raw["actions"]) - assert norm.norm_stats[21]["actions"]["scale"].shape == (7,) - assert norm.norm_stats[22]["actions"]["scale"].shape == (28,) - restored = LiberoMultiEmbodimentNormalizer.from_state(norm.to_state()) - assert restored.tokenizer_context() == norm.tokenizer_context() - valid = LiberoDataset._from_resolver( - datasets["libero_panda"].resolver, mode="valid" - ) - with pytest.raises(ValueError, match="training episodes"): - LiberoMultiEmbodimentNormalizer().infer_norm_from_dataset(valid, "invalid") - - -def test_complete_joint_context_export_restores_without_refitting( - tmp_path, monkeypatch -): - import copy - - from omegaconf import OmegaConf - - from egomimic.rldb.zarr.data_module import ZarrDataModule - - train, valid = {}, {} - for name, width, hand in (("libero_panda", 7, 2), ("libero_panda_sharpa", 69, 22)): - path = tmp_path / (name + ".zarr") - replay(path, width, hand) - if width == 69: - (path / "benchmark_manifest.json").write_text( - json.dumps( - dict( - complete=True, - action_dim=69, - action_representation="wrist_hand_keypoints", - keypoint_frame="wrist_relative_Quest_XYZ_m", - observation_alignment="causal_pre_action_raw_lossless", - ) - ) - ) - train[name] = dict( - _target_="egomimic.rldb.zarr.libero_dataset.LiberoDataset._from_resolver", - resolver=dict( - _target_="egomimic.rldb.zarr.libero_dataset.LiberoReplayResolver", - folder_path=str(path), - suite="libero_spatial", - embodiment=name, - action_dim=width, - gripper_state_dim=hand, - action_representation="native" - if width == 7 - else "wrist_hand_keypoints", - key_map=dict( - _target_="egomimic.rldb.zarr.libero_dataset.keymap", - n_obs_steps=2, - horizon=32, - ), - ), - mode="train", - valid_ratio=0.1, - split_seed=42, - ) - valid[name] = {**copy.deepcopy(train[name]), "mode": "valid"} - - def module(): - return ZarrDataModule(OmegaConf.create(train), OmegaConf.create(valid), {}, {}) - - normalizer = OmegaConf.create( - dict( - _target_="egomimic.rldb.zarr.libero_dataset.LiberoMultiEmbodimentNormalizer" - ) - ) - exported = tmp_path / "complete-context.json" - context = module().prepare_context( - mode="normalization", - normalizer=normalizer, - normalization=dict( - norm_mode="libero_train_limits", - save_cache_dir=str(tmp_path / "stats"), - export_context_path=str(exported), - ), - ) - payload = json.loads(exported.read_text())["data_context"] - assert payload["normalizer_config"] == OmegaConf.to_container(normalizer) - assert set(payload["preprocessing"]) == {"21", "22"} - assert ( - payload["normalizer_state"]["native_embodiment_contexts"]["22"]["action_dim"] - == 69 - ) - - def forbidden(*args, **kwargs): - raise AssertionError("A complete-context cache must not refit normalization") - - monkeypatch.setattr( - LiberoMultiEmbodimentNormalizer, "infer_norm_from_dataset", forbidden - ) - restored = module().prepare_context( - mode="train", - normalizer=normalizer, - normalization=dict( - norm_mode="libero_train_limits", precomputed_norm_path=str(exported) - ), - ) - assert ( - restored.normalizer.tokenizer_context() - == context.normalizer.tokenizer_context() - ) - assert restored.fingerprint() == context.fingerprint() - # A changed export target is a hard collision, not an overwrite or refit. - exported.write_text('{"different": true}\n') - with pytest.raises(ValueError, match="Refusing to replace"): - module().prepare_context( - mode="normalization", - normalizer=normalizer, - restored_state=payload, - normalization=dict( - norm_mode="libero_train_limits", - save_cache_dir=str(tmp_path / "other-stats"), - export_context_path=str(exported), - ), - ) - assert exported.read_text() == '{"different": true}\n' - assert not list(tmp_path.glob(".context-*")) - - -def test_original_state_is_identity_and_sharpa_projector_gets_gradient(): - stage = KeyedFeatureProjection( - selector_key="embodiment", - input_key=["eef", "quat", "hand", "task"], - output_key="proprio_condition", - output_dim=10, - selector_aliases={21: "panda", 22: "sharpa"}, - projections={ - "panda": {"input_dim": 10, "identity": True}, - "sharpa": {"input_dim": 30, "hidden_dim": 32}, - }, - ) - for ident, hand_width in [(21, 2), (22, 22)]: - batch = { - "embodiment": torch.tensor([ident, ident]), - "eef": torch.randn(2, 2, 3), - "quat": torch.randn(2, 2, 4), - "hand": torch.randn(2, 2, hand_width), - "task": torch.randn(2, 2, 1), - } - expected = torch.cat([batch[k] for k in ("eef", "quat", "hand", "task")], -1) - result = stage(batch)["proprio_condition"] - assert result.shape == (2, 2, 10) - if ident == 21: - assert torch.equal(result, expected) - else: - result.square().mean().backward() - assert all( - p.grad is not None and torch.isfinite(p.grad).all() - for p in stage.projections["sharpa"].parameters() - ) - with pytest.raises(ValueError, match="must match"): - KeyedFeatureProjection( - "body", - "x", - "y", - {"panda": {"input_dim": 9, "identity": True}}, - output_dim=10, - ) - - -def test_native_collection_keeps_upright_rows_quaternion_and_timestamps(tmp_path): - root = tmp_path / "raw" - root.mkdir() - records = [] - image = np.zeros((2, 2, 2, 3), dtype="uint8") - image[:, 0, :, 0] = 240 - image[:, 1, :, 2] = 170 - for i, task in enumerate(get_tasks("libero_spatial")): - filename = f"task-{i}.hdf5" - with h5py.File(root / filename, "w") as handle: - handle.attrs["metadata_json"] = json.dumps( - {"suite": "libero_spatial", "task": task, "embodiment": "sharpa"} - ) - handle.attrs["automatic_success"] = True - data = handle.create_group("data") - data.attrs["rgb_row_transform"] = ( - "vertical_flip_from_raw_opengl_to_original_zarr_upright" - ) - demo = data.create_group("demo_0") - demo.attrs["observation_alignment"] = "after_action; raw timeline retained" - demo.create_dataset("actions", data=np.zeros((2, 28), dtype="float32")) - demo.create_dataset("action_host_ns", data=[10, 30]) - demo.create_dataset("observation_host_ns", data=[20, 40]) - obs = demo.create_group("obs") - obs.create_dataset("agentview_rgb", data=image) - obs.create_dataset("eye_in_hand_rgb", data=image) - obs.create_dataset("ee_pos", data=np.zeros((2, 3))) - obs.create_dataset("ee_quat", data=[[0, 0, 0, 1]] * 2) - obs.create_dataset("ee_ori", data=np.zeros((2, 3))) - obs.create_dataset("gripper_states", data=np.zeros((2, 22))) - legacy_view = native_episode_values(demo, task, action_dim=28, hand_dim=22) - np.testing.assert_array_equal(legacy_view["agentview_rgb"], image[:, ::-1]) - records.append( - { - "path": filename, - "task": task, - "suite": "libero_spatial", - "action_dimension": 28, - "automatic_success": True, - } - ) - manifest = { - "suite": "libero_spatial", - "embodiment": "custom_Panda_SharpaWaveRight_LIBERO", - "episodes": records, - "episode_count": 10, - "per_task": {r["task"]: 1 for r in records}, - } - m = root / "manifest.json" - m.write_text(json.dumps(manifest)) - output = tmp_path / "converted.zarr" - receipt = convert_collection_manifest(root, output, m) - arrays = zarr.open_group(str(output), mode="r")["data"] - np.testing.assert_array_equal(arrays["agentview_rgb"][:2], image) - np.testing.assert_array_equal(arrays["robot0_eef_quat"][:2], [[0, 0, 0, 1]] * 2) - np.testing.assert_array_equal(arrays["action_host_ns"][:2], [10, 30]) - np.testing.assert_array_equal(arrays["observation_host_ns"][:2], [20, 40]) - assert receipt["complete"] and not receipt["split_assigned"] - assert len(receipt["episodes"]) == 10 - manifest["episodes"][0]["path"] = "../outside.hdf5" - m.write_text(json.dumps(manifest)) - with pytest.raises(ValueError, match="outside root"): - convert_collection_manifest(root, tmp_path / "rejected.zarr", m) - assert not (tmp_path / "rejected.zarr").exists() - - -def test_keypoint_alignment_rejects_asynchronous_latest_guess(tmp_path): - from egomimic.benchmarks.libero.convert import aligned_hand_keypoint_actions - - metadata = { - "hand_calibration": { - "operator_wrist_position_quest_m": [0, 0, 0], - "position_world_m": [0, 0, 0], - }, - "world_from_quest": np.eye(3).tolist(), - "control_settings": {"translation_gain": 1.0, "vertical_gain": 1.0}, - } - points = np.arange(63).reshape(21, 3) * 0.001 - packets = [ - { - "source": "quest_webxr", - "hand": "right", - "joint_layout": "mediapipe21.v2", - "tracking_valid": True, - "joint_valid": [True] * 21, - "sequence": i, - "wrist": {"position_m": [x, 0, 0]}, - "joints_m": (points + x).tolist(), - } - for i, x in enumerate((0.01, 0.02)) - ] - with h5py.File(tmp_path / "record.hdf5", "w") as h: - h.create_dataset("packet_json", data=[json.dumps(p) for p in packets]) - h.create_dataset("packet_receive_ns", data=[10, 20]) - h.create_dataset("action_host_ns", data=[30, 40]) - h.create_dataset( - "intent_target_position_world_m", data=[[0.01, 0, 0], [0.02, 0, 0]] - ) - h.create_dataset("actions", data=np.zeros((2, 28))) - h.create_dataset("tracking_valid", data=[True, True]) - actions, start, sequence = aligned_hand_keypoint_actions(h, metadata) - assert actions.shape == (2, 69) and start == 0 - assert sequence.tolist() == [ - 0, - 1, - ] # Latest-at-action would wrongly choose 1 twice. - np.testing.assert_allclose( - actions[0, 6:], (points - points[0]).reshape(63), atol=1e-8 - ) - h["intent_target_position_world_m"][1] = [0.03, 0, 0] - with pytest.raises(ValueError, match="correspondence at step 1"): - aligned_hand_keypoint_actions(h, metadata) - h["intent_target_position_world_m"][1] = [0.02, 0, 0] - # Recorded IK failure pauses the control after applying a valid-hand - # command; this is not a missing operator hand target. - metadata["action_contract"] = { - "arm_control": {"name": "mink_direct_joint_servo_v4", "mode": "mink"} - } - h.create_dataset("control_reason", data=[b"active", b"arm_ik_infeasible"]) - h["tracking_valid"][1] = False - repaired, _, _ = aligned_hand_keypoint_actions(h, metadata) - np.testing.assert_array_equal(repaired, actions) - h["control_reason"][1] = b"tracking_lost" - with pytest.raises(ValueError, match="hand tracking target"): - aligned_hand_keypoint_actions(h, metadata) - h["control_reason"][1] = b"arm_ik_infeasible" - metadata["action_contract"]["arm_control"]["name"] = "unbound_controller" - with pytest.raises(ValueError, match="hand tracking target"): - aligned_hand_keypoint_actions(h, metadata) - metadata["action_contract"]["arm_control"]["name"] = ( - "mink_direct_joint_servo_v4" - ) - h.attrs["recenter_events_json"] = "[{}]" - with pytest.raises(ValueError, match="recenter calibration"): - aligned_hand_keypoint_actions(h, metadata) - - -def test_keypoint_resolver_requires_declared_frame_manifest(tmp_path): - path = tmp_path / "sharpa69.zarr" - replay(path, 69, 22) - r = LiberoReplayResolver( - path, - keymap(2, 32), - "libero_spatial", - action_dim=69, - gripper_state_dim=22, - embodiment="libero_panda_sharpa", - action_representation="wrist_hand_keypoints", - ) - manifest = path / "benchmark_manifest.json" - manifest.write_text( - json.dumps( - { - "complete": True, - "action_dim": 69, - "action_representation": "wrist_hand_keypoints", - "keypoint_frame": "wrist_relative_Quest_XYZ_m", - "observation_alignment": "causal_pre_action_raw_lossless", - } - ) - ) - assert next(iter(r.resolve().values()))[0]["actions"].shape == (32, 69) - manifest.write_text(json.dumps({"complete": True, "action_dim": 69})) - with pytest.raises(ValueError, match="frame manifest"): - r.resolve() - - -def test_sharpa_raw_pre_action_views_cannot_leak_post_action_frames(tmp_path): - from egomimic.benchmarks.libero.convert import causal_collection_episode_values - - with h5py.File(tmp_path / "timeline.hdf5", "w") as h: - h.create_dataset("actions", data=np.zeros((3, 28))) - h.create_dataset("observation_host_ns", data=[10, 30, 50, 70]) - h.create_dataset("action_host_ns", data=[20, 40, 60]) - data = h.create_group("data") - data.attrs["rgb_row_transform"] = ( - "vertical_flip_from_raw_opengl_to_original_zarr_upright" - ) - rgb = h.create_group("rgb") - pixels = np.zeros((4, 2, 2, 3), dtype=np.uint8) - for t in range(4): - pixels[t, 0, :, 0] = 10 + t - pixels[t, 1, :, 2] = 100 + t - rgb.create_dataset("agentview", data=pixels) - rgb.create_dataset("robot0_eye_in_hand", data=pixels) - obs = h.create_group("numeric_observations") - obs.create_dataset("robot0_eef_pos", data=np.arange(12).reshape(4, 3)) - obs.create_dataset("robot0_eef_quat", data=[[0, 0, 0, 1]] * 4) - obs.create_dataset( - "robot0_gripper_qpos", data=np.repeat(np.arange(4)[:, None], 22, axis=1) - ) - meta = {"observation_order": "before_action_and_terminal"} - result = causal_collection_episode_values( - h, - meta, - get_tasks("libero_spatial")[0], - np.ones((2, 69), dtype=np.float32), - 1, - np.array([2, 3]), - ) - np.testing.assert_array_equal(result["agentview_rgb"], pixels[1:3, ::-1]) - np.testing.assert_array_equal( - result["robot0_eef_pos"], np.arange(12).reshape(4, 3)[1:3] - ) - np.testing.assert_array_equal(result["observation_host_ns"], [30, 50]) - np.testing.assert_array_equal(result["action_host_ns"], [40, 60]) - assert np.all(result["observation_host_ns"] < result["action_host_ns"]) - assert result["robot0_gripper_qpos"][:, 0].tolist() == [1, 2] - h.create_dataset( - "control_reason", data=[b"operator_stop", b"stale_3d_view", b"active"] - ) - resumed_target = np.ones((1, 69), dtype=np.float32) - with pytest.raises(ValueError, match="resume proof"): - causal_collection_episode_values( - h, - meta, - get_tasks("libero_spatial")[0], - resumed_target, - 2, - np.array([3]), - ) - proof = { - "status": "PASS_UNIQUE_SOURCE_BACKED_RESUME_CALIBRATION", - "source_start_step": 2, - "source_path": h.filename, - "solutions": [{"recorded_anchor": True}], - "no_later_gap_discarded": True, - } - resumed = causal_collection_episode_values( - h, - meta, - get_tasks("libero_spatial")[0], - resumed_target, - 2, - np.array([3]), - resume_proof=proof, - ) - np.testing.assert_array_equal(resumed["observation_host_ns"], [50]) - h["control_reason"][0] = b"active" - with pytest.raises(ValueError, match="discard active"): - causal_collection_episode_values( - h, - meta, - get_tasks("libero_spatial")[0], - resumed_target, - 2, - np.array([3]), - resume_proof=proof, - ) - h["observation_host_ns"][1] = 45 - with pytest.raises(ValueError, match="not causal"): - causal_collection_episode_values( - h, - meta, - get_tasks("libero_spatial")[0], - np.ones((2, 69), dtype=np.float32), - 1, - np.array([2, 3]), - ) - - -def test_routed_dit_half_preserves_three_owner_gradients_and_batch_contract(): - from copy import deepcopy - from types import SimpleNamespace - - from torch.utils.checkpoint import checkpoint - - from egomimic.utils.libero_dit_half import HalfCheckpointScope, LiberoDiTHalf - - torch.manual_seed(4) - backbones = { - name: SimpleNamespace( - blocks=torch.nn.ModuleList([torch.nn.Linear(4, 4), torch.nn.Linear(4, 4)]), - gradient_checkpointing=True, - ) - for name in ("encoder/libero_panda", "encoder/libero_panda_sharpa", "velocity") - } - baseline = deepcopy(backbones) - native = SimpleNamespace(checkpoint=checkpoint) - scope = HalfCheckpointScope(native, backbones, expected_depth=2) - inputs = torch.randn(2, 4) - - def evaluate(owners, dispatcher): - x = inputs.clone().requires_grad_() - values = [] - for owner in owners.values(): - value = x - for block in owner.blocks: - value = dispatcher(block, value, use_reentrant=False) - values.append(value) - output = sum(values) - output.square().sum().backward() - return output, x.grad - - expected, expected_gradient = evaluate(baseline, checkpoint) - scope.install() - try: - actual, actual_gradient = evaluate(backbones, native.checkpoint) - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - torch.testing.assert_close(actual_gradient, expected_gradient, rtol=0, atol=0) - for name in backbones: - for actual_block, reference_block in zip( - backbones[name].blocks, baseline[name].blocks - ): - torch.testing.assert_close( - actual_block.weight.grad, - reference_block.weight.grad, - rtol=0, - atol=0, - ) - assert all(count > 0 for count in scope.counts.values()) - callback = LiberoDiTHalf({"libero_panda": 16, "libero_panda_sharpa": 16}) - callback.scope = scope - batch = { - name: {"actions": torch.zeros(16, 32, width)} - for name, width in [("libero_panda", 7), ("libero_panda_sharpa", 69)] - } - callback.on_train_batch_start(None, None, batch, 0) - batch["libero_panda_sharpa"]["actions"] = torch.zeros(32, 32, 69) - with pytest.raises(ValueError, match="per-source batch"): - callback.on_train_batch_start(None, None, batch, 0) - finally: - scope.restore() - assert native.checkpoint is checkpoint - - -def test_collection_image_chunk_layout_keeps_pixels_actions_and_temporal_reads_exact( - tmp_path, -): - from egomimic.benchmarks.libero.convert import append_replay - - rng = np.random.default_rng(7) - values = { - "action": rng.normal(size=(19, 69)).astype(np.float32), - "agentview_rgb": rng.integers(0, 256, (19, 3, 4, 3), dtype=np.uint8), - "robot0_eye_in_hand_rgb": rng.integers(0, 256, (19, 3, 4, 3), dtype=np.uint8), - } - outputs = [] - for chunk in (1, 8): - group = zarr.open_group(str(tmp_path / str(chunk)), mode="w", zarr_format=2) - offset = 0 - for start, end in ((0, 3), (3, 11), (11, 19)): - offset = append_replay( - group, - {k: v[start:end] for k, v in values.items()}, - offset, - image_chunk_frames=chunk, - ) - assert offset == 19 and group["agentview_rgb"].chunks[0] == chunk - for key, expected in values.items(): - np.testing.assert_array_equal(group[key][:], expected) - np.testing.assert_array_equal(group[key][7:9], expected[7:9]) - outputs.append(group) - for key in values: - np.testing.assert_array_equal(outputs[0][key][:], outputs[1][key][:]) - with pytest.raises(ValueError, match="positive frame count"): - append_replay(outputs[0], values, 19, image_chunk_frames=0) - - -def test_hand_target_cache_reuses_verified_targets_and_rejects_source_or_cache_drift( - tmp_path, -): - import hashlib - - from egomimic.benchmarks.libero.convert import ( - cached_hand_keypoint_actions, - verified_hand_target_cache, - ) - - source = tmp_path / "episode.hdf5" - with h5py.File(source, "w") as h: - h.create_dataset("actions", data=np.zeros((3, 28))) - records = [{"path": source.name, "task": "task"}] - manifest = tmp_path / "manifest.json" - manifest.write_text(json.dumps({"episodes": records})) - cache = tmp_path / "targets.npz" - targets = np.arange(2 * 69, dtype=np.float32).reshape(2, 69) - np.savez_compressed( - cache, - episode_000000_actions=targets, - episode_000000_sequence=np.array([10, 11], dtype=np.int64), - ) - stat = source.stat() - row = { - "index": 0, - **records[0], - "source_bytes": stat.st_size, - "source_mtime_ns": stat.st_mtime_ns, - "source_start_step": 1, - "source_control_count": 3, - "target_shape": [2, 69], - "cache_key": "episode_000000", - } - audit = { - "status": "PASS_ALL_500_EXACT_CAUSAL_HAND_TARGETS", - "source_episode_count": 1, - "passed_episode_count": 1, - "failed_episode_count": 0, - "source_manifest_sha256": hashlib.sha256(manifest.read_bytes()).hexdigest(), - "target_cache": str(cache), - "target_cache_sha256": hashlib.sha256(cache.read_bytes()).hexdigest(), - "passed": [row], - } - audit_path = tmp_path / "audit.json" - audit_path.write_text(json.dumps(audit)) - _, rows = verified_hand_target_cache(audit_path, manifest, tmp_path, records) - with np.load(cache, allow_pickle=False) as arrays, h5py.File(source, "r") as h: - actual, start, sequence = cached_hand_keypoint_actions(arrays, rows[0], h) - np.testing.assert_array_equal(actual, targets) - assert start == 1 and sequence.tolist() == [10, 11] - with source.open("ab") as stream: - stream.write(b"changed") - with pytest.raises(ValueError, match="source identity changed"): - verified_hand_target_cache(audit_path, manifest, tmp_path, records) - with cache.open("ab") as stream: - stream.write(b"changed") - with pytest.raises(ValueError, match="cache SHA differs"): - verified_hand_target_cache(audit_path, manifest, tmp_path, records) - - -def test_joint_energy_verifier_consumes_real_generic_saved_artifact( - tmp_path, monkeypatch -): - import copy - from pathlib import Path - from types import SimpleNamespace - - import hydra - from hydra.core.hydra_config import HydraConfig - from omegaconf import OmegaConf - - from egomimic.eval.energy_score import energy_score - from egomimic.eval.planar_action_eval import PlanarActionEval - from scripts.train.verify_libero_native_action_flow_smoke import ( - validate_joint_energy_artifact, - ) - - root = Path(__file__).resolve().parents[1] - monkeypatch.setenv("LIBERO_ORIGINAL_SPATIAL_ROOT", str(tmp_path / "original")) - monkeypatch.setenv("LIBERO_SHARPA_SPATIAL_ROOT", str(tmp_path / "sharpa")) - monkeypatch.setenv( - "LIBERO_DIAGNOSTIC_SEED_BANK", - str( - root / "egomimic/hydra_configs/evaluator/energy_score_seed_bank_k32_v1.json" - ), - ) - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(root / "egomimic/hydra_configs") - ): - cfg = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=[ - "hydra/launcher=basic", - "+experiment=libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - ], - ) - cfg.hydra.runtime.output_dir = str(tmp_path) - HydraConfig.instance().set_config(cfg) - cfg.evaluator.seed_bank_path = str( - root / "egomimic/hydra_configs/evaluator/energy_score_seed_bank_k32_v1.json" - ) - cfg.evaluator.artifact_root = str(tmp_path / "energy") - cfg.run_provenance.source_commit = "1" * 40 - cfg.run_provenance.normalization_sha256 = "2" * 64 - cfg.run_provenance.split_manifest_sha256 = "3" * 64 - resolved = OmegaConf.to_container( - OmegaConf.masked_copy(cfg, [k for k in cfg if k != "hydra"]), resolve=True - ) - options = copy.deepcopy(resolved["evaluator"]) - options.pop("_target_") - options["action_flow_diagnostics"] = None - evaluator = PlanarActionEval(**options) - evaluator.trainer = SimpleNamespace( - global_step=8, current_epoch=0, global_rank=0, precision="bf16-mixed" - ) - samples, scores, batch = {}, {}, {} - generator = torch.Generator().manual_seed(42) - for name, identity, width in ( - ("libero_panda", 21, 7), - ("libero_panda_sharpa", 22, 69), - ): - samples[name] = torch.randn(32, 16, 32, width, generator=generator) - target = torch.randn(16, 32, width, generator=generator) - batch[name] = dict(embodiment=torch.full((16,), identity), actions=target) - scores[name] = energy_score( - samples[name], - target, - resolved["evaluator"]["semantic_blocks_by_embodiment"][name], - ) - evaluator._save_artifact(0, samples, scores, batch) - path = next((tmp_path / "energy").rglob("*.pt")) - from tests.test_verify_action_flow_training_smoke import MODULE - - selected, payload = MODULE._step_artifact( - tmp_path / "energy", label="joint EnergyScore", expected_step=8 - ) - assert selected == path - with pytest.raises(MODULE.SmokeVerificationError, match="one step-2"): - MODULE._step_two_artifact(tmp_path / "energy", label="joint EnergyScore") - result = validate_joint_energy_artifact(payload, resolved) - assert set(result["sources"]) == {"libero_panda", "libero_panda_sharpa"} - payload["domains"]["libero_panda_sharpa"]["score_by_condition"][0] += 1 - with pytest.raises(ValueError, match="public math differs"): - validate_joint_energy_artifact(payload, resolved) - - # Exercise the real immutable diagnostic writer with synthetic captured tensors. - from egomimic.eval.action_flow_diagnostics import ActionFlowDiagnostics - from scripts.train.verify_libero_native_action_flow_smoke import ( - validate_joint_diagnostic_artifact, - ) - - diagnostic_seeds = tmp_path / "diagnostic-seeds.json" - diagnostic_seeds.write_text( - json.dumps({"seeds": [730001 + 7919 * index for index in range(32)]}, indent=2) - + "\n" - ) - resolved["evaluator"]["action_flow_diagnostics"]["noise_seed_bank_path"] = str( - diagnostic_seeds - ) - inspector = ActionFlowDiagnostics(resolved["evaluator"]["action_flow_diagnostics"]) - diagnostic = dict( - schema_version=1, - metric="ActionFlowValidationDiagnostics", - global_step=8, - epoch=0, - rank=0, - batch_idx=0, - precision="bf16-mixed", - identity=inspector.identity, - identity_sha256=inspector.identity_sha256, - provenance=inspector.provenance, - validation_view=inspector.validation_view, - noise_seed_bank_sha256=inspector.noise_seed_bank_sha256, - noise_seed=inspector.noise_seeds[0], - raw_noise_levels=inspector.noise_levels, - sampler=dict(name="reverse_euler"), - sources={}, - ) - for name, width in (("libero_panda", 7), ("libero_panda_sharpa", 69)): - raw = dict( - source="validation/" + name, - schema=inspector.schema, - noise_seed=inspector.noise_seeds[0], - num_inference_steps=50, - returned_samples=8, - ) - for key, shape in ( - ("target", (8, 32, width)), - ("condition", (8, 276)), - ("latent/clean", (8, 16, 16)), - ("latent/noise", (8, 16, 16)), - ("latent/generated", (8, 16, 16)), - ("decoded/reconstruction", (8, 32, width)), - ("decoded/generated", (8, 32, width)), - ("latent/trajectory", (51, 8, 16, 16)), - ("decoded/trajectory", (51, 8, 32, width)), - ): - raw[key] = torch.zeros(shape) - diagnostic["sources"][name] = dict( - source_id=raw["source"], - diagnostic=raw, - computed=dict(clean_reconstruction_mse_by_condition=torch.zeros(8)), - ) - diagnostic_path = tmp_path / "diagnostics.pt" - inspector._write_immutable_artifact(diagnostic_path, diagnostic) - assert validate_joint_diagnostic_artifact(diagnostic_path, diagnostic, resolved)[ - "sha256" - ] - sidecar = Path(str(diagnostic_path) + ".sha256") - sidecar.write_text(sidecar.read_text().replace(inspector.identity_sha256, "0" * 64)) - with pytest.raises(ValueError, match="sidecar differs"): - validate_joint_diagnostic_artifact(diagnostic_path, diagnostic, resolved) - - # Metadata-only producer fixture: real saved tensor files, synthetic runtime/preflight. - (tmp_path / "checkpoints").mkdir() - (tmp_path / "checkpoints/epoch-0000-step-000000008.ckpt").write_bytes(b"fixture") - from scripts.train.libero_native_launch_contract import ( - contract_sha, - joint_smoke_evidence, - reference, - ) - - resolved["run_provenance"]["run_kind"] = "smoke" - resolved["trainer"]["max_steps"] = 8 - artifact_root = tmp_path / "diag-envelope" - resolved["evaluator"]["action_flow_diagnostics"]["artifact_root"] = str( - artifact_root - ) - nested = artifact_root / "epoch-0-step-8/rank-0-batch-0.pt" - nested.parent.mkdir(parents=True) - inspector._write_immutable_artifact(nested, diagnostic) - (tmp_path / ".hydra").mkdir() - OmegaConf.save(OmegaConf.create(resolved), tmp_path / ".hydra/config.yaml") - runtime = { - key: {} - for key in ( - "python", - "installed_distributions", - "core_distribution_versions", - "dependency_inputs", - "source", - "torch_runtime", - ) - } - runtime_path = tmp_path / "runtime-lock.json" - runtime_path.write_text(json.dumps(runtime)) - synthetic_preflight = dict( - schema="libero-joint-launch-preflight/v1", - status="PASS", - profile="libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - model_contract_sha256=contract_sha(resolved), - runtime_lock=reference(runtime_path), - **{ - key: resolved["run_provenance"][key] - for key in ( - "source_commit", - "split_manifest_sha256", - "normalization_sha256", - ) - }, - ) - preflight_path = tmp_path / "synthetic-preflight.json" - preflight_path.write_text(json.dumps(synthetic_preflight)) - envelope = joint_smoke_evidence( - resolved, - tmp_path, - preflight_ref=reference(preflight_path), - runtime_ref=reference(runtime_path), - shared=MODULE, - ) - assert envelope["energy_artifact"] == reference(path) - assert envelope["diagnostic_artifact"] == reference(nested) - assert ( - "status" not in envelope - ) # An envelope never claims smoke or launch acceptance. - resolved["run_provenance"]["run_kind"] = "full" - with pytest.raises(ValueError, match="eight-update smoke"): - joint_smoke_evidence( - resolved, - tmp_path, - preflight_ref=reference(preflight_path), - runtime_ref=reference(runtime_path), - shared=MODULE, - ) - resolved["run_provenance"]["run_kind"] = "smoke" - runtime["source"] = {"wrong": True} - different_runtime = tmp_path / "wrong-runtime.json" - different_runtime.write_text(json.dumps(runtime)) - with pytest.raises(AssertionError, match="native runtime differs"): - joint_smoke_evidence( - resolved, - tmp_path, - preflight_ref=reference(preflight_path), - runtime_ref=reference(different_runtime), - shared=MODULE, - ) - - from scripts.train.verify_libero_native_action_flow_smoke import ( - validate_joint_wandb_records, - ) - from tests.test_verify_action_flow_training_smoke import _history_row - - row = _history_row() - for key, value in list(row.items()): - if MODULE.SOURCE_LABEL in key: - for label in ("libero_panda", "libero_panda_sharpa"): - row[key.replace(MODULE.SOURCE_LABEL, label)] = value - row["Train/ActionFlow/Compute/FieldForwardCallsPerStep"] = 2.0 - row["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"] = 28.0 - for prefix in ( - "GradientCosine", - "GradientCosineDefined", - "GradientIntersectionParameterCount", - ): - row[f"Train/ActionFlow/{prefix}/FM__Reconstruction"] = 0.0 - row["Optimizer/LR/AdamW"] = row["Optimizer/LR/Muon"] = 2.5e-8 - for owner in ("encoder/libero_panda", "encoder/libero_panda_sharpa", "velocity"): - for mode in ("direct", "checkpoint"): - row["Train/Execution/DiTHalf/" + owner + "/" + mode] = 6.0 - resolved["logger"] = dict(wandb=dict(mode="online", id="synthetic-history-fixture")) - histories = dict( - offline={7: row}, - online={7: row}, - online_config=resolved, - online_state="finished", - cfg=resolved, - shared=MODULE, - ) - assert validate_joint_wandb_records(**histories)["online"]["valid_step"] == 7 - import copy - - logged = copy.deepcopy(resolved) - logged["model"] = json.loads(json.dumps(logged["model"])) - logged["model"]["action_horizon"] = "${libero_blueprint.action_horizon}" - histories["online_config"] = logged - assert validate_joint_wandb_records(**histories)["online"]["valid_step"] == 7 - logged["model"]["action_horizon"] = 999 - with pytest.raises(ValueError, match="online W&B model differs"): - validate_joint_wandb_records(**histories) - histories["online_config"] = resolved - histories["online_state"] = "running" - with pytest.raises(ValueError, match="finished online"): - validate_joint_wandb_records(**histories) - histories["online_state"] = "finished" - row["Train/Execution/DiTHalf/encoder/libero_panda_sharpa/checkpoint"] = 0.0 - with pytest.raises(ValueError, match="path inactive"): - validate_joint_wandb_records(**histories) - - -def test_joint_constructor_explicit_waiver_keeps_cached_evidence_immutable(tmp_path): - import hashlib - - from scripts.train.libero_native_launch_contract import validate_joint_constructor - - original = tmp_path / "original.json" - original.write_text( - json.dumps( - dict( - status="PASS_ACTUAL_CPU_MODEL_CONSTRUCTION_NOT_TRAINING_SMOKE", - total_parameters=78182614, - private_codecs_per_embodiment=True, - single_shared_velocity_stage=True, - all_parameters_finite=True, - ) - ) - ) - before = original.read_bytes() - waiver = tmp_path / "waiver.json" - data = dict( - schema="libero-joint-constructor-provenance-waiver/v1", - authorization="explicit_human_override", - waived_gate="cached_constructor_source_config_binding", - source_commit="a" * 40, - human_message="bypass the provenance gate", - original_constructor=dict( - path=str(original), sha256=hashlib.sha256(before).hexdigest() - ), - ) - - def check(): - waiver.write_text(json.dumps(data)) - ref = dict( - path=str(waiver), sha256=hashlib.sha256(waiver.read_bytes()).hexdigest() - ) - return validate_joint_constructor( - ref, source_head="a" * 40, resolved_sha="b" * 64, fingerprint="c" * 64 - ) - - assert check() == "HUMAN_WAIVED_SOURCE_CONFIG_BINDING" - assert original.read_bytes() == before - data["source_commit"] = "d" * 40 - with pytest.raises(ValueError, match="authorization/source"): - check() - data["source_commit"] = "a" * 40 - data["authorization"] = "" - with pytest.raises(ValueError, match="authorization/source"): - check() diff --git a/tests/test_libero_native_binding_parser.py b/tests/test_libero_native_binding_parser.py deleted file mode 100644 index 96973f9df6..0000000000 --- a/tests/test_libero_native_binding_parser.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Real Hydra grammar regression; no model construction or smoke claims.""" - -import importlib.util -import unittest -from pathlib import Path - -spec = importlib.util.spec_from_file_location( - "native_schema", - Path(__file__).parents[1] / "scripts/train/libero_native_schema_contract.py", -) -module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(module) -KEYS = ( - "path", - "file_sha256", - "normalizer_module_sha256", - "source_commit", - "dataset_receipt_path", - "dataset_receipt_sha256", - "split_receipt_path", - "split_receipt_sha256", - "data_root", - "normalizer_target", - "physical_proof_path", - "physical_proof_sha256", -) - - -class BindingParserTests(unittest.TestCase): - def test_real_twelve_field_hydra_mapping(self): - expected = { - k: ( - "/workspace/" + k - if k.endswith("path") or k == "data_root" - else "abc123" - ) - for k in KEYS - } - arg = ( - "++norm_stats.native_saved_state_binding={" - + ",".join(k + ":" + v for k, v in expected.items()) - + "}" - ) - self.assertEqual(module.parse_native_saved_state_binding([arg]), expected) - - def test_malformed_and_incomplete(self): - for value in ("{path:/workspace/x}", "{path:/workspace/x", "null", "[x,y]"): - with self.subTest(value=value), self.assertRaises(Exception): - module.parse_native_saved_state_binding( - ["++norm_stats.native_saved_state_binding=" + value] - ) - - def test_sweep_and_delete(self): - arg = ( - "++norm_stats.native_saved_state_binding={" - + ",".join(k + ":abc" for k in KEYS) - + "}" - ) - for anomaly in ("trainer.max_steps=2,3", "~trainer.max_steps"): - with self.subTest(anomaly=anomaly), self.assertRaises(ValueError): - module.parse_native_saved_state_binding([arg, anomaly]) - - def test_missing_and_duplicate(self): - arg = ( - "++norm_stats.native_saved_state_binding={" - + ",".join(k + ":abc" for k in KEYS) - + "}" - ) - for argv in ([], [arg, arg], [arg, arg.replace("++", "", 1)]): - with self.subTest(argv=argv), self.assertRaises(ValueError): - module.parse_native_saved_state_binding(argv) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_libero_native_checkpoint_format.py b/tests/test_libero_native_checkpoint_format.py deleted file mode 100644 index 3845321876..0000000000 --- a/tests/test_libero_native_checkpoint_format.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Actual native checkpoint-hook format and external normalization identities.""" - -import ast -import importlib.util -import json -from pathlib import Path -from types import SimpleNamespace - -import pytest -from omegaconf import OmegaConf - -R = Path(__file__).parents[1] -S = R / "scripts/train/verify_libero_native_action_flow_smoke.py" -spec = importlib.util.spec_from_file_location("native_verifier", S) -native = importlib.util.module_from_spec(spec) -spec.loader.exec_module(native) - - -def native_format(): - # Execute the real native save hook without constructing a model/checkpoint. - tree = ast.parse( - (R / "egomimic/pl_utils/training_behavior_action_flow.py").read_text() - ) - owner = next( - n - for n in tree.body - if isinstance(n, ast.ClassDef) and n.name == "ActionFlowTrainingBehavior" - ) - hook = next( - n - for n in owner.body - if isinstance(n, ast.FunctionDef) and n.name == "on_save_checkpoint" - ) - namespace = {"Any": object} - exec( - compile( - ast.Module(body=[hook], type_ignores=[]), "actual-native-save-hook", "exec" - ), - namespace, - ) - context = SimpleNamespace( - reconstruction_only_warmup_steps=0, - _objective_weight=lambda key, default=None: 1.0, - _gradient_route_manifest={"actual_hook_marker": True}, - ) - payload = {} - namespace["on_save_checkpoint"](context, payload) - model = json.loads( - (R / "assets/libero/historical_af27m_scientific_contract_v1.json").read_text() - )["historical_model"] - binding = { - "source_commit": "a" * 40, - "file_sha256": "b" * 64, - "split_receipt_sha256": "c" * 64, - } - provenance = { - "source_commit": binding["source_commit"], - "normalization_sha256": binding["file_sha256"], - "split_manifest_sha256": binding["split_receipt_sha256"], - "dataset_sha256": "d" * 64, - } - cfg = {"model": model, "run_provenance": provenance} - train_tree = ast.parse((R / "egomimic/trainHydra.py").read_text()) - builder = next( - n - for n in train_tree.body - if isinstance(n, ast.FunctionDef) and n.name == "_build_model_config_tree" - ) - builder.returns = None - for argument in builder.args.args: - argument.annotation = None - namespace = {"OmegaConf": OmegaConf} - exec( - compile( - ast.Module(body=[builder], type_ignores=[]), - "actual-native-hparams-builder", - "exec", - ), - namespace, - ) - actual_cfg = OmegaConf.create( - {**cfg, "norm_stats": {"native_saved_state_binding": binding}} - ) - recorded = OmegaConf.to_container( - namespace["_build_model_config_tree"](actual_cfg), resolve=True - ) - assert set(recorded) == {"model", "run_provenance"} - payload["hyper_parameters"] = {"config_tree": recorded} - return payload, cfg, binding - - -def test_real_native_save_hook_has_no_embedded_normalizer_requirement(): - payload, cfg, binding = native_format() - assert "normalizer_state" not in payload - assert ( - "action_flow_loss_schedule" in payload - and "action_flow_gradient_route_manifest" in payload - ) - assert ( - native.validate_native_checkpoint_context(payload, cfg, binding) - == cfg["run_provenance"] - ) - - -@pytest.mark.parametrize( - "key", - [ - "source_commit", - "normalization_sha256", - "split_manifest_sha256", - "dataset_sha256", - ], -) -def test_recorded_native_checkpoint_identity_drift_rejected(key): - payload, cfg, binding = native_format() - payload["hyper_parameters"]["config_tree"]["run_provenance"][key] = "bad" - with pytest.raises(ValueError): - native.validate_native_checkpoint_context(payload, cfg, binding) - - -def test_native_checkpoint_model_drift_rejected(): - payload, cfg, binding = native_format() - payload["hyper_parameters"]["config_tree"]["model"]["hidden_dim"] = 384 - with pytest.raises(ValueError): - native.validate_native_checkpoint_context(payload, cfg, binding) - - -def test_native_verifier_uses_contextual_sidecar_and_actual_dataset_boundary(): - source = S.read_text() - tree = ast.parse(source) - method = next( - n - for n in tree.body - if isinstance(n, ast.FunctionDef) and n.name == "verify_native_smoke" - ) - calls = [ast.unparse(n.func) for n in ast.walk(method) if isinstance(n, ast.Call)] - for name in [ - "instantiate_metadata_dataset", - "bind_saved_native_state", - "normalizer.to_state", - "dataset.set_norm_stats_from", - "restored.model.bind_data_context", - ]: - assert name in calls - assert 'payload.get("normalizer_state")' not in source - compares = [ - n - for n in ast.walk(method) - if isinstance(n, ast.Call) - and ast.unparse(n.func) == "torch.testing.assert_close" - ] - assert len(compares) == 1 - assert [ast.unparse(x) for x in compares[0].args] == [ - "parameter.detach().cpu()", - "ema[key].detach().cpu()", - ] - assert {k.arg: ast.literal_eval(k.value) for k in compares[0].keywords} == { - "rtol": 0, - "atol": 0, - } diff --git a/tests/test_libero_native_half_factory_identity.py b/tests/test_libero_native_half_factory_identity.py deleted file mode 100644 index f63c645f6e..0000000000 --- a/tests/test_libero_native_half_factory_identity.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Historical half-ResNet factory identity; no torch import for static guard.""" - -import ast -import hashlib -from pathlib import Path - - -def test_native_half_factory_matches_historical_architecture_and_initialization(): - path = Path(__file__).parents[1] / "egomimic/models/stems/visual_core.py" - source = path.read_text() - tree = ast.parse(source) - branches = [ - n - for n in ast.walk(tree) - if isinstance(n, ast.If) - and ast.unparse(n.test) == "resnet_model == 'resnet18_half'" - ] - assert len(branches) == 1, "native historical half-ResNet factory unavailable" - assert ( - # Pinned from training source7c253e5, including the non-half else arm. - # Ignore whitespace only; retain every call, initializer and argument. - hashlib.sha256( - ast.dump(branches[0], include_attributes=False).encode() - ).hexdigest() - == "9521e4594c50d2e98b25fc9c07ef2d0c9aac42c83b57e0d0f55c8128056d8a75" - ) diff --git a/tests/test_libero_native_launch_contract.py b/tests/test_libero_native_launch_contract.py deleted file mode 100644 index d595828a2d..0000000000 --- a/tests/test_libero_native_launch_contract.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Focused fingerprint invariants only; not native constructor or smoke proof.""" - -import copy -import unittest - -from scripts.train.libero_native_launch_contract import contract_sha - - -class Contract(unittest.TestCase): - def setUp(self): - self.cfg = dict( - model={ - "pipeline": { - "stages": [ - { - "flow_aggregation": "sum_samples", - "inference_method": "euler", - "num_inference_steps": 50, - } - ] - }, - "gradient_telemetry_cadence": 100, - }, - normalizer={"_target_": "native"}, - norm_stats={"native_saved_state_binding": {"source_commit": "fixed"}}, - data={ - "train_datasets": {"libero_panda": {"split_seed": 42}}, - "valid_datasets": {"libero_panda": {"split_seed": 42}}, - "train_dataloader_params": {"libero_panda": {"batch_size": 32}}, - "valid_dataloader_params": {"libero_panda": {"batch_size": 32}}, - }, - trainer={ - "precision": "bf16-mixed", - "accumulate_grad_batches": 1, - "max_steps": 80000, - }, - seed=42, - callbacks={ - "ema": {"validate_with_ema": True}, - "dit_half": {"_target_": "native"}, - }, - evaluator={ - "energy_sample_count": 32, - "energy_seed_bank_sha256": "fixed", - "artifact_root": "/out/a", - "artifact_identity": { - "_target_": "factory", - "config_path": "/out/a/.hydra/config.yaml", - }, - "native_diagnostic_config": { - "_target_": "diagfactory", - "config_path": "/out/a/.hydra/config.yaml", - "artifact_root": "/out/a/diagnostic", - }, - }, - ) - - def test_phase_only_equivalent(self): - cfg = copy.deepcopy(self.cfg) - cfg["trainer"]["max_steps"] = 2 - cfg["model"]["gradient_telemetry_cadence"] = 2 - self.assertEqual(contract_sha(cfg), contract_sha(self.cfg)) - - def test_output_only_equivalent(self): - cfg = copy.deepcopy(self.cfg) - cfg["evaluator"]["artifact_root"] = "/out/b" - cfg["evaluator"]["artifact_identity"]["config_path"] = ( - "/out/b/.hydra/config.yaml" - ) - cfg["evaluator"]["native_diagnostic_config"]["artifact_root"] = ( - "/out/b/diagnostic" - ) - self.assertEqual(contract_sha(cfg), contract_sha(self.cfg)) - - def test_mean_rejected(self): - cfg = copy.deepcopy(self.cfg) - cfg["model"]["pipeline"]["stages"][0]["flow_aggregation"] = "mean" - self.assertNotEqual(contract_sha(cfg), contract_sha(self.cfg)) - - def test_seedbank_rejected(self): - cfg = copy.deepcopy(self.cfg) - cfg["evaluator"]["energy_seed_bank_sha256"] = "changed" - self.assertNotEqual(contract_sha(cfg), contract_sha(self.cfg)) - - def test_k_changed_rejected(self): - cfg = copy.deepcopy(self.cfg) - cfg["evaluator"]["energy_sample_count"] = 16 - self.assertNotEqual(contract_sha(cfg), contract_sha(self.cfg)) - - def test_factory_target_rejected(self): - cfg = copy.deepcopy(self.cfg) - cfg["evaluator"]["native_diagnostic_config"]["_target_"] = "otherfactory" - self.assertNotEqual(contract_sha(cfg), contract_sha(self.cfg)) - - def test_sampling_rejected(self): - cfg = copy.deepcopy(self.cfg) - cfg["model"]["pipeline"]["stages"][0]["num_inference_steps"] = 8 - self.assertNotEqual(contract_sha(cfg), contract_sha(self.cfg)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_libero_native_scheduler_contract.py b/tests/test_libero_native_scheduler_contract.py deleted file mode 100644 index 8d370afa6f..0000000000 --- a/tests/test_libero_native_scheduler_contract.py +++ /dev/null @@ -1,101 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - -spec = importlib.util.spec_from_file_location( - "guard", Path(__file__).parents[1] / "scripts/train/validate_slurm_job_contract.py" -) -m = importlib.util.module_from_spec(spec) -spec.loader.exec_module(m) -PROFILE = "libero_historical/action_flow_libero10_h240_euler50_dithalf_80k_s42" - - -def inputs(): - fields = m.parse_scontrol_record( - ( - Path(__file__).parent / "fixtures/libero_native_scontrol_408787.txt" - ).read_text() - ) - args = dict( - expected_job_id="408787", - expected_account="loaner", - expected_partition="rl2", - expected_qos="loaner-normal", - expected_cpus=8, - expected_memory="128G", - expected_time_limit="02:00:00", - expected_constraint="(null)", - native_profile=PROFILE, - gpu_probe=dict( - status="PASSED", - gpu_name="NVIDIA H100 80GB HBM3", - world_size=1, - rank=0, - local_rank=0, - bf16_supported=True, - bf16_forward_backward=dict(finite=True), - ), - ) - return fields, args - - -def test_native_actual_h100_no_constraint(): - f, a = inputs() - assert not m.evaluate_contract(f, **a)[2] - - -@pytest.mark.parametrize( - "change", - [ - "wrongprofile", - "missingprobe", - "wronggpu", - "nonfinite", - "wrongallocation", - "constraint", - "planar", - ], -) -def test_reject_contract_drift(change): - f, a = inputs() - if change == "wrongprofile": - a["native_profile"] = "planar" - if change == "missingprobe": - a["gpu_probe"] = None - if change == "wronggpu": - a["gpu_probe"]["gpu_name"] = "NVIDIA H200" - if change == "nonfinite": - a["gpu_probe"]["bf16_forward_backward"]["finite"] = False - if change == "wrongallocation": - f["GRES"] = "gpu:a40:1(IDX:3)" - if change == "constraint": - f["Features"] = "H100" - if change == "planar": - a["native_profile"] = None - try: - failures = m.evaluate_contract(f, **a)[2] - except m.ContractError: - return - assert failures - - -@pytest.mark.parametrize( - "field,value", - [ - ("JOB_GRES", "gpu:h100:2"), - ("TresPerNode", "gres/gpu:h200:1"), - ("Nodes", "gpu18"), - ("GRES", "gpu:h100:1"), - ("AllocTRES", "cpu=4,mem=128G,node=1"), - ("ReqTRES", "cpu=8,mem=128G,node=1,gres/gpu=1"), - ], -) -def test_actual_allocation_drift(field, value): - f, a = inputs() - f[field] = value - try: - failures = m.evaluate_contract(f, **a)[2] - except m.ContractError: - return - assert failures diff --git a/tests/test_libero_native_smoke_contract.py b/tests/test_libero_native_smoke_contract.py deleted file mode 100644 index bb1164383c..0000000000 --- a/tests/test_libero_native_smoke_contract.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Focused pure metadata tests; never a full-model smoke proof.""" - -import unittest - -from scripts.train.verify_libero_native_action_flow_smoke import ( - PARAMETER_COUNT, - PROFILE, - validate_dit_counts, - validate_preflight, -) - - -class Contract(unittest.TestCase): - def test_missing_preflight(self): - with self.assertRaises(KeyError): - validate_preflight({}, {}) - - def test_tiny_count_not_accepted(self): - self.assertEqual(PARAMETER_COUNT, 39750391) - self.assertEqual( - PROFILE, - "libero_historical/action_flow_libero10_h240_euler50_dithalf_80k_s42", - ) - - def test_preparation_rejected(self): - with self.assertRaisesRegex(ValueError, "did not pass"): - validate_preflight( - {"schema": "libero-native-launch-preflight/v1", "status": "PREPARED"}, - {}, - ) - - def test_wrong_profile_rejected(self): - with self.assertRaisesRegex(ValueError, "profile"): - validate_preflight( - { - "schema": "libero-native-launch-preflight/v1", - "status": "PASS", - "profile": "invented", - }, - {}, - ) - - def test_dit_inactive_rejected(self): - class Shared: - @staticmethod - def _complete_row(rows, required, **kw): - return 1, {name: 0 for name in required} - - with self.assertRaisesRegex(ValueError, "inactive"): - validate_dit_counts({}, Shared) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_libero_native_suite_profiles.py b/tests/test_libero_native_suite_profiles.py deleted file mode 100644 index 580045d459..0000000000 --- a/tests/test_libero_native_suite_profiles.py +++ /dev/null @@ -1,857 +0,0 @@ -"""Suite/seed isolation and portable allocated-resource contracts, without models.""" - -import copy -import importlib.util -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(ROOT / "tools")) -from libero_maintained_dispatch_v1 import ( # noqa: E402 - task tool path initialized above - validate_data_receipt, -) - - -def test_joint_schedule_reuse_hashes_exact_git_diff_and_rejects_model_changes(tmp_path): - import hashlib - import json - import subprocess - - from scripts.train.libero_native_launch_contract import ( - joint_smoke_schedule_reuse, - reference, - ) - - repo = tmp_path / "source" - repo.mkdir() - - def git(*args): - return subprocess.check_output( - [ - "git", - "-C", - str(repo), - "-c", - "user.name=fixture", - "-c", - "user.email=fixture@example.invalid", - *args, - ] - ) - - git("init", "-q") - source = repo / "egomimic/eval/eval.py" - source.parent.mkdir(parents=True) - source.write_text("old schedule\n") - git("add", ".") - git("commit", "-qm", "old") - old = git("rev-parse", "HEAD").decode().strip() - source.write_text("repaired schedule\n") - git("commit", "-qam", "repair") - head = git("rev-parse", "HEAD").decode().strip() - preflight_path = tmp_path / "preflight.json" - preflight_path.write_text(json.dumps({"source_commit": old})) - smoke_path = tmp_path / "smoke.json" - smoke_path.write_text( - json.dumps( - { - "identities": {"source_commit": old}, - "preflight": reference(preflight_path), - } - ) - ) - regression = tmp_path / "regression.log" - regression.write_text("actual CPU evidence fixture\n") - proof = dict( - schema="libero-joint-smoke-schedule-reuse/v1", - scope="explicit_cross_epoch_validation_schedule", - from_source_commit=old, - to_source_commit=head, - target_repo=str(repo), - smoke=reference(smoke_path), - cpu_regression_status="PASS", - changed_paths=["egomimic/eval/eval.py"], - audited_diff_sha256=hashlib.sha256( - git("diff", "--no-ext-diff", "--binary", old, head) - ).hexdigest(), - cpu_regression=reference(regression), - ) - proof_path = tmp_path / "proof.json" - - def check(): - proof_path.write_text(json.dumps(proof)) - return joint_smoke_schedule_reuse( - reference(proof_path), - smoke_ref=reference(smoke_path), - target_source=proof["to_source_commit"], - ) - - assert check()[0] == old - proof["audited_diff_sha256"] = "0" * 64 - with pytest.raises(ValueError, match="audited diff differs"): - check() - model = repo / "egomimic/model.py" - model.write_text("changed model\n") - git("add", ".") - git("commit", "-qm", "forbidden") - proof["to_source_commit"] = git("rev-parse", "HEAD").decode().strip() - proof["changed_paths"] = ( - git("diff", "--name-only", old, "HEAD").decode().splitlines() - ) - proof["audited_diff_sha256"] = hashlib.sha256( - git("diff", "--no-ext-diff", "--binary", old, "HEAD") - ).hexdigest() - with pytest.raises(ValueError, match="unaudited source paths"): - check() - - -from egomimic.benchmarks.libero.native_launch_profiles import ( # noqa: E402 - task tool path initialized above - PROFILES, - profile_for_argv, - profile_for_config, -) - - -def cfg(suite): - p = PROFILES[suite] - return dict( - name=p.name, - benchmark=dict(suite=suite), - data={ - mode + "_datasets": {"libero_panda": {"resolver": {"suite": suite}}} - for mode in ("train", "valid") - }, - ) - - -@pytest.mark.parametrize("suite", list(PROFILES)) -def test_typed_suite_and_receipt_isolation(suite): - p = PROFILES[suite] - c = cfg(suite) - assert profile_for_config(c) == p - assert profile_for_argv(["+experiment=" + p.experiment]) == p - receipt = dict( - suite=suite, - replay_path="/replay", - logical_dataset_sha256="a" * 64, - episodes=500, - valid_ratio=0.01, - split_seed=42, - train_episodes=495, - valid_episodes=5, - action_dim=7, - task_uids=list(p.task_uids), - ) - # Use exact maintained receipt schema field names. - receipt.update( - dataset_logical_sha256="a" * 64, - frames=5000, - action_shape=[5000, 7], - train_episode_indices=list(range(495)), - valid_episode_indices=list(range(495, 500)), - ) - assert ( - validate_data_receipt( - receipt, - replay_path="/replay", - expected_logical_sha="a" * 64, - profile=p.experiment, - )["id_overlap"] - == 0 - ) - for other in PROFILES: - if other == suite: - continue - wrong = copy.deepcopy(c) - wrong["benchmark"]["suite"] = other - with pytest.raises(ValueError): - profile_for_config(wrong) - with pytest.raises((ValueError, KeyError)): - validate_data_receipt( - receipt, - replay_path="/replay", - expected_logical_sha="a" * 64, - profile=PROFILES[other].experiment, - ) - - -@pytest.mark.parametrize("suite", ["libero_spatial", "libero_goal", "libero_object"]) -def test_recipe_only_changes_suite_identity(suite): - text = ( - ROOT - / "egomimic/hydra_configs/experiment/libero_historical" - / (PROFILES[suite].name + ".yaml") - ).read_text() - assert "action_flow_libero10_h240_euler50_dithalf_80k_s42" in text - assert "suite: " + suite in text - assert PROFILES[suite].replay_environment in text - assert all( - key not in text - for key in ("model:", "trainer:", "normalizer:", "optimizer:", "\nseed:") - ) - - -def test_catalog_task_uids_match_registry(): - from egomimic.benchmarks.libero.catalog import TASK_IDS, get_tasks - - for suite, p in PROFILES.items(): - assert tuple(TASK_IDS[t][2] for t in get_tasks(suite)) == p.task_uids - - -def scheduler(): - spec = importlib.util.spec_from_file_location( - "scheduler_suite", ROOT / "scripts/train/validate_slurm_job_contract.py" - ) - m = importlib.util.module_from_spec(spec) - spec.loader.exec_module(m) - return m - - -@pytest.mark.parametrize( - "constraint,gpu", - [ - ("H100", "NVIDIA H100 80GB HBM3"), - ("H200", "NVIDIA H200"), - ("H100|H200", "NVIDIA H200"), - ], -) -@pytest.mark.parametrize( - "profile", - [ - PROFILES["libero_spatial"].experiment, - "libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - ], -) -def test_ice_native_preserves_real_resource_bindings(constraint, gpu, profile): - m = scheduler() - fields = dict( - JobId="123", - Account="account", - Partition="partition", - QOS="qos", - NumNodes="1", - NumTasks="1", - NumCPUs="8", - MinCPUsNode="8", - MinMemoryNode="128G", - Features=constraint, - TimeLimit="1-00:00:00", - ReqTRES="cpu=8,mem=128G,node=1,gres/gpu=1", - ) - fields["CPUs/Task"] = "8" - args = dict( - expected_job_id="123", - expected_account="account", - expected_partition="partition", - expected_qos="qos", - expected_cpus=8, - expected_memory="128G", - expected_time_limit="1-00:00:00", - expected_constraint=constraint, - native_profile=profile, - gpu_probe=dict( - status="PASSED", - gpu_name=gpu, - world_size=1, - rank=0, - local_rank=0, - bf16_supported=True, - bf16_forward_backward=dict(finite=True), - ), - ) - assert m.evaluate_contract(fields, **args)[2] == [] - for field in ("Account", "Partition", "QOS", "Features", "NumCPUs"): - bad = dict(fields) - bad[field] = "wrong" if field != "NumCPUs" else "4" - assert m.evaluate_contract(bad, **args)[2] - args["gpu_probe"]["bf16_forward_backward"]["finite"] = False - with pytest.raises(m.ContractError): - m.evaluate_contract(fields, **args) - - -@pytest.mark.parametrize("suite", list(PROFILES)) -@pytest.mark.parametrize("seed", [42, 43]) -def test_metric_training_seed_is_typed_and_not_split_seed(suite, seed): - from egomimic.benchmarks.libero.action_flow_artifacts import ( - SEED_SHA, - validate_identity, - ) - - identity = dict( - suite=suite, - source="libero_panda", - action_dim=7, - action_horizon=16, - seed=seed, - sample_count=32, - energy_seed_bank_sha256=SEED_SHA, - inference_method="euler", - inference_steps=50, - normalization_scope="training_episodes_only", - effective_batch_size=32, - homogeneous="not_applicable_single_source", - source_commit="a" * 40, - resolved_config_sha256="a" * 64, - split_sha256="a" * 64, - normalizer_state_sha256="a" * 64, - dataset_logical_sha256="a" * 64, - ) - if seed == 43 and suite not in ("libero_goal", "libero_object"): - with pytest.raises(ValueError): - validate_identity(identity) - else: - assert validate_identity(identity) - - -def test_late_preflight_cross_suite_rejected_before_evidence_reads(): - from scripts.train.verify_libero_native_action_flow_smoke import validate_preflight - - pf = dict( - schema="libero-native-launch-preflight/v1", - status="PASS", - profile=PROFILES["libero10"].experiment, - ) - for suite in ("libero_spatial", "libero_goal", "libero_object"): - with pytest.raises(ValueError, match="native profile mismatch"): - validate_preflight(pf, {}, PROFILES[suite].experiment) - - -@pytest.mark.parametrize( - "suite", ["libero_spatial", "libero_goal", "libero_object", "libero10"] -) -def test_actual_hydra_recipe_composes_same_science_and_fixed_split(monkeypatch, suite): - from hydra import compose, initialize_config_dir - from omegaconf import OmegaConf - - p = PROFILES[suite] - monkeypatch.setenv(p.replay_environment, "/replay") - for seed in [42, 43] if suite in ("libero_goal", "libero_object") else [42]: - with initialize_config_dir( - version_base="1.3", config_dir=str(ROOT / "egomimic/hydra_configs") - ): - c = compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=" + p.experiment, - "seed=" + str(seed), - ], - ) - assert c.name == p.name and c.benchmark.suite == suite and c.seed == seed - assert c.data.train_datasets.libero_panda.split_seed == 42 - assert c.data.valid_datasets.libero_panda.split_seed == 42 - assert ( - c.model.hidden_dim == 240 - and c.model.action_horizon == 16 - and c.model.action_dim == 7 - ) - assert c.data.train_dataloader_params.libero_panda.batch_size == 32 - assert c.model.pipeline.stages[6].inference_method == "euler" - monkeypatch.setenv("LIBERO10_REPLAY_ROOT", "/reference") - with initialize_config_dir( - version_base="1.3", config_dir=str(ROOT / "egomimic/hydra_configs") - ): - reference = compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=" + PROFILES["libero10"].experiment, - ], - ) - for field in ("model", "normalizer", "trainer", "callbacks", "evaluator"): - assert OmegaConf.to_container( - c[field], resolve=False - ) == OmegaConf.to_container(reference[field], resolve=False) - - -def test_explicit_joint_profile_and_contract_reject_single_source_or_semantic_drift( - tmp_path, monkeypatch -): - import hydra - from hydra.core.hydra_config import HydraConfig - from omegaconf import OmegaConf - - from egomimic.benchmarks.libero.native_launch_profiles import ( - joint_profile_for_config, - ) - - spec = importlib.util.spec_from_file_location( - "joint_contract_test", ROOT / "scripts/train/libero_native_launch_contract.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - monkeypatch.setenv("LIBERO_ORIGINAL_SPATIAL_ROOT", str(tmp_path / "original")) - monkeypatch.setenv("LIBERO_SHARPA_SPATIAL_ROOT", str(tmp_path / "sharpa")) - monkeypatch.setenv("LIBERO_DIAGNOSTIC_SEED_BANK", str(tmp_path / "SEEDS.json")) - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(ROOT / "egomimic/hydra_configs") - ): - config = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=[ - "hydra/launcher=basic", - "+experiment=libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - "paths.output_dir=" + str(tmp_path), - "paths.work_dir=" + str(ROOT), - ], - ) - config.hydra.runtime.output_dir = str(tmp_path) - HydraConfig.instance().set_config(config) - c = OmegaConf.to_container( - OmegaConf.masked_copy(config, [k for k in config if k != "hydra"]), - resolve=True, - ) - assert joint_profile_for_config(c).sources[1] == ("libero_panda_sharpa", 22, 69, 22) - missing_latent = copy.deepcopy(c) - missing_latent["evaluator"]["action_flow_diagnostics"]["provenance"].pop( - "latent_shape" - ) - with pytest.raises(ValueError, match="diagnostic latent shape"): - joint_profile_for_config(missing_latent) - with pytest.raises(ValueError, match="unsupported native"): - profile_for_config(c) - contract = module.model_contract(c) - assert contract["schema"] == "libero-joint-scientific-contract/v1" - assert contract["train_batch"] == {"libero_panda": 16, "libero_panda_sharpa": 16} - assert module.contract_sha(c) == module.contract_sha(copy.deepcopy(c)) - relocated = copy.deepcopy(c) - relocated["callbacks"]["ema"]["final_checkpoint_path"] = ( - "/other/output/checkpoints/last.ckpt" - ) - relocated["evaluator"]["action_flow_diagnostics"]["artifact_root"] = ( - "/other/output/diagnostics" - ) - assert module.contract_sha(c) == module.contract_sha(relocated) - changed_ema = copy.deepcopy(c) - changed_ema["callbacks"]["ema"]["decay"] = 0.99 - assert module.contract_sha(c) != module.contract_sha(changed_ema) - changes = [ - ( - "model.pipeline.stages.4.encoders.libero_panda_sharpa.backbone.hidden_dim", - 384, - ), - ("model.pipeline.stages.6.field.backbone.depth", 24), - ("model.pipeline.stages.4.encoders.libero_panda_sharpa.action_dim", 7), - ("model.pipeline.stages.7.decoders.libero_panda_sharpa.condition_dim", 276), - ("model.pipeline.stages.6.experts", {}), - ("model.pipeline.stages.3.num_tokens", 8), - ("data.train_dataloader_params.libero_panda_sharpa.batch_size", 32), - ("data.train_datasets.libero_panda_sharpa.valid_ratio", 0.01), - ( - "data.train_datasets.libero_panda_sharpa.resolver.action_representation", - "native", - ), - ("model.pipeline.stages.6.num_inference_steps", 8), - ("normalizer._target_", "egomimic.rldb.zarr.libero_dataset.LiberoNormalizer"), - ] - for path, replacement in changes: - wrong = copy.deepcopy(c) - keys = path.split(".") - node = wrong - for key in keys[:-1]: - node = node[int(key)] if isinstance(node, list) else node[key] - node[keys[-1]] = replacement - with pytest.raises(ValueError, match="joint LIBERO"): - module.contract_sha(wrong) - - -@pytest.mark.parametrize("phase", ["normalization", "preflight", "smoke", "full"]) -def test_joint_phase_factory_composes_actual_paths_and_private_shared_contract( - tmp_path, monkeypatch, phase -): - import hydra - from hydra.core.hydra_config import HydraConfig - from omegaconf import OmegaConf - - from egomimic.benchmarks.libero.native_launch_profiles import ( - joint_phase_argv, - joint_profile_for_config, - ) - - # These env values must be replaced by the exact typed arguments, including - # spaces and '=' which must survive the shell/Hydra boundary literally. - for key in ( - "LIBERO_ORIGINAL_SPATIAL_ROOT", - "LIBERO_SHARPA_SPATIAL_ROOT", - "LIBERO_DIAGNOSTIC_SEED_BANK", - ): - monkeypatch.setenv(key, "/wrong/unselected") - original = str(tmp_path / "original data=1") - sharpa = str(tmp_path / "hand data=2") - args = dict( - source_root=str(ROOT), - original_root=original, - sharpa_root=sharpa, - output_root=str(tmp_path / phase), - context_path=str(tmp_path / "context.json"), - split_path=str(tmp_path / "split.json"), - split_sha256="a" * 64, - normalization_sha256=None if phase == "normalization" else "b" * 64, - source_commit="c" * 40, - diagnostic_seed_bank=str(tmp_path / "seeds.json"), - wandb_entity="test", - wandb_project="test", - wandb_run_id="joint-" + phase, - ) - argv = joint_phase_argv(phase=phase, **args) - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(ROOT / "egomimic/hydra_configs") - ): - cfg = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=argv[1:], - ) - cfg.hydra.runtime.output_dir = str(tmp_path / phase) - HydraConfig.instance().set_config(cfg) - resolved = OmegaConf.to_container( - OmegaConf.masked_copy(cfg, [k for k in cfg if k != "hydra"]), resolve=True - ) - joint_profile_for_config(resolved) - assert resolved["evaluator"]["action_flow_diagnostics"]["provenance"][ - "latent_shape" - ] == [16, 16] - assert resolved["trainer"]["log_every_n_steps"] == (1 if phase == "smoke" else 10) - if phase != "normalization": - from scripts.train import libero_native_launch_contract as adapter - - def compose_exact(command, *, cwd, text): - assert command[1:3] == ["-m", "egomimic.trainHydra"] - assert command[3:-3] == argv - assert command[-3:] == ["--cfg", "job", "--resolve"] - assert cwd == ROOT and text is True - return OmegaConf.to_yaml(OmegaConf.create(resolved)) - - monkeypatch.setattr(adapter.subprocess, "check_output", compose_exact) - destination = tmp_path / (phase + "-composition") - destination.mkdir() - composed = adapter.compose_joint_phase( - args, phase=phase, output=destination, repo=ROOT - ) - assert composed["launch_ready"] is False - assert (destination / "exact-phase.argv0").read_bytes() == b"\0".join( - arg.encode() for arg in argv - ) + b"\0" - with pytest.raises(FileExistsError): - adapter.compose_joint_phase( - args, phase=phase, output=destination, repo=ROOT - ) - if phase in {"smoke", "full"}: - import json - - from scripts.train.libero_native_launch_contract import ( - contract_sha, - joint_prepare_evidence, - reference, - ) - - runtime = { - key: {} - for key in ( - "python", - "installed_distributions", - "core_distribution_versions", - "dependency_inputs", - "source", - "torch_runtime", - ) - } - runtime["dependency_inputs"] = [] - runtime_path = tmp_path / "synthetic-runtime.json" - runtime_path.write_text(json.dumps(runtime)) - identity = { - key: resolved["run_provenance"][key] - for key in ( - "source_commit", - "normalization_sha256", - "split_manifest_sha256", - ) - } - preflight = dict( - schema="libero-joint-launch-preflight/v1", - status="PASS", - profile="libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - model_contract_sha256=contract_sha(resolved), - runtime_lock=reference(runtime_path), - **identity, - ) - preflight_path = tmp_path / "synthetic-preflight.json" - preflight_path.write_text(json.dumps(preflight)) - kwargs = dict( - phase=phase, - preflight_ref=reference(preflight_path), - runtime_ref=reference(runtime_path), - ) - if phase == "full": - with pytest.raises(ValueError, match="genuine eight-update"): - joint_prepare_evidence(resolved, **kwargs) - smoke = dict( - schema="libero-joint-action-flow-smoke/v1", - status="PASS", - profile=preflight["profile"], - model_contract_sha256=contract_sha(resolved), - identities=identity, - preflight=reference(preflight_path), - strict_online_and_alias_safe_ema_reload=True, - checkpoint=dict( - parameter_count=78182614, - global_step=8, - joint_ema=dict(alias_safe_strict_reload=True), - ), - ) - smoke_path = tmp_path / "synthetic-smoke.json" - smoke_path.write_text(json.dumps(smoke)) - kwargs["smoke_ref"] = reference(smoke_path) - assert joint_prepare_evidence(resolved, **kwargs)["launch_ready"] is False - if phase == "full": - from scripts.train import libero_native_launch_contract as adapter - - original_smoke = copy.deepcopy(smoke) - previous = dict(preflight, source_commit="d" * 40) - previous["model_contract_sha256"] = adapter.joint_contract_at_source( - resolved, "d" * 40 - ) - previous_runtime = dict(runtime, source={"head": "d" * 40, "dirty": False}) - old_runtime_path = tmp_path / "previous-runtime.json" - old_runtime_path.write_text(json.dumps(previous_runtime)) - previous["runtime_lock"] = reference(old_runtime_path) - runtime["source"] = {"head": identity["source_commit"], "dirty": False} - runtime_path.write_text(json.dumps(runtime)) - preflight["runtime_lock"] = reference(runtime_path) - preflight_path.write_text(json.dumps(preflight)) - kwargs["runtime_ref"] = reference(runtime_path) - kwargs["preflight_ref"] = reference(preflight_path) - previous_path = tmp_path / "previous-preflight.json" - previous_path.write_text(json.dumps(previous)) - smoke["identities"] = dict(identity, source_commit="d" * 40) - smoke["model_contract_sha256"] = previous["model_contract_sha256"] - smoke["preflight"] = reference(previous_path) - smoke_path.write_text(json.dumps(smoke)) - kwargs["smoke_ref"] = reference(smoke_path) - with pytest.raises(ValueError, match="smoke proof differs"): - joint_prepare_evidence(resolved, **kwargs) - repair_ref = {"path": "separate-repair-proof", "sha256": "e" * 64} - monkeypatch.setattr( - adapter, - "joint_smoke_schedule_reuse", - lambda *a, **k: ("d" * 40, previous), - ) - reused = joint_prepare_evidence( - resolved, **kwargs, smoke_source_repair_ref=repair_ref - ) - assert reused["smoke_source_repair"] == repair_ref - assert ( - json.loads(smoke_path.read_text())["identities"]["source_commit"] - == "d" * 40 - ) - # Source-local assets move with immutable checkout roots. Compare - # their actual bytes against the configured SHA before rebinding - # this one path, keeping every other scientific field exact. - asset = Path(resolved["evaluator"]["seed_bank_path"]) - payload = asset.read_bytes() - old_repo, new_repo = tmp_path / "old-source", tmp_path / "new-source" - old_repo.mkdir() - new_repo.mkdir() - old_seed, new_seed = old_repo / "seeds.json", new_repo / "seeds.json" - old_seed.write_bytes(payload) - new_seed.write_bytes(payload) - before = copy.deepcopy(resolved) - before["evaluator"]["seed_bank_path"] = str(old_seed) - expected = adapter.joint_contract_at_source(before, "d" * 40) - after = copy.deepcopy(resolved) - after["evaluator"]["seed_bank_path"] = str(new_seed) - rebound = dict(source_repo=old_repo, current_repo=new_repo) - assert ( - adapter.joint_contract_at_source(after, "d" * 40, **rebound) == expected - ) - assert after["evaluator"]["seed_bank_path"] == str(new_seed) - for changed in (old_seed, new_seed): - changed.write_bytes(b"changed seed bank") - with pytest.raises(ValueError, match="seed asset SHA differs"): - adapter.joint_contract_at_source(after, "d" * 40, **rebound) - changed.write_bytes(payload) - after["model"]["cfg_scale"] += 1 - assert ( - adapter.joint_contract_at_source(after, "d" * 40, **rebound) != expected - ) - previous["model_contract_sha256"] = "wrong" - with pytest.raises(ValueError, match="scientific contract differs"): - joint_prepare_evidence( - resolved, **kwargs, smoke_source_repair_ref=repair_ref - ) - smoke = original_smoke - smoke["checkpoint"]["global_step"] = 2 - smoke_path.write_text(json.dumps(smoke)) - kwargs["smoke_ref"] = reference(smoke_path) - with pytest.raises(ValueError, match="smoke proof differs"): - joint_prepare_evidence(resolved, **kwargs) - if phase == "preflight": - from scripts.train.libero_native_launch_contract import contract_sha - - for other_phase in ("smoke", "full"): - other_args = { - **args, - "output_root": str(tmp_path / other_phase), - "wandb_run_id": "joint-" + other_phase, - } - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(ROOT / "egomimic/hydra_configs") - ): - other = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=joint_phase_argv(phase=other_phase, **other_args)[1:], - ) - other.hydra.runtime.output_dir = other_args["output_root"] - HydraConfig.instance().set_config(other) - other_resolved = OmegaConf.to_container( - OmegaConf.masked_copy(other, [k for k in other if k != "hydra"]), - resolve=True, - ) - assert contract_sha(resolved) == contract_sha(other_resolved) - if phase == "smoke": - from scripts.train.libero_native_launch_contract import joint_prepare_evidence - - assert resolved["model"]["gradient_telemetry_cadence"] == 1 - missing_telemetry = copy.deepcopy(resolved) - missing_telemetry["model"]["gradient_telemetry_cadence"] = 100 - with pytest.raises(ValueError, match="per-step gradient telemetry"): - joint_prepare_evidence( - missing_telemetry, phase="smoke", preflight_ref={}, runtime_ref={} - ) - - from scripts.train.verify_libero_native_action_flow_smoke import ( - validate_joint_smoke_config, - ) - - assert ( - validate_joint_smoke_config(resolved, args["output_root"], "c" * 40)[ - "optimizer_updates" - ] - == 8 - ) - for key, value in ( - ("trainer.max_steps", 2), - ("trainer.accumulate_grad_batches", 2), - ("run_provenance.run_kind", "full"), - ("logger.wandb.mode", "offline"), - ("callbacks.model_checkpoint.save_last", True), - ("run_provenance.source_commit", None), - ): - altered = OmegaConf.create(copy.deepcopy(resolved)) - OmegaConf.update(altered, key, value) - with pytest.raises( - ValueError, match="joint (smoke config mismatch|LIBERO:)" - ): - validate_joint_smoke_config(altered, args["output_root"], "c" * 40) - for mode in ("train", "valid"): - for source, root in ( - ("libero_panda", original), - ("libero_panda_sharpa", sharpa), - ): - assert ( - resolved["data"][mode + "_datasets"][source]["resolver"]["folder_path"] - == root - ) - assert resolved["callbacks"]["model_checkpoint"]["save_last"] is False - assert resolved["trainer"]["default_root_dir"] == args["output_root"] - assert ( - resolved["callbacks"]["model_checkpoint"]["dirpath"] - == args["output_root"] + "/checkpoints" - ) - assert ( - resolved["callbacks"]["ema"]["final_checkpoint_path"] - == args["output_root"] + "/checkpoints/last.ckpt" - ) - assert resolved["runtime"]["slurm_requeue_owner"] == "runner" - assert resolved["callbacks"]["early_recovery"]["first_step"] == 1000 - assert resolved["trainer"]["max_steps"] == (8 if phase == "smoke" else 120000) - if phase == "normalization": - assert "logger" not in resolved - # The experiment itself extends the evaluator body; group deletion - # would leave an invalid target-less partial mapping. Normalization-only - # trainHydra returns before model/trainer construction or evaluation. - assert resolved["evaluator"]["_target_"].endswith(".PlanarActionEval") - assert resolved["norm_stats"]["precomputed_norm_path"] is None - assert resolved["norm_stats_only"] is True - assert resolved["norm_stats"]["export_context_path"] == args["context_path"] - assert resolved["run_provenance"]["normalization_sha256"] is None - else: - assert resolved["norm_stats"]["precomputed_norm_path"] == args["context_path"] - assert resolved["logger"]["wandb"]["id"] == args["wandb_run_id"] - assert resolved["logger"]["wandb"]["save_dir"] == args["output_root"] - assert resolved["trainer"]["val_check_interval"] == ( - 8 if phase == "smoke" else 15000 - ) - with pytest.raises(ValueError, match="normalization_sha256"): - joint_phase_argv(phase=phase, **{**args, "normalization_sha256": None}) - for key, bad_value in ( - ("original_root", "relative/root"), - ("sharpa_root", "/path\nwith-newline"), - ("source_commit", "unknown"), - ("wandb_run_id", "unsafe id"), - ): - with pytest.raises(ValueError): - joint_phase_argv(phase=phase, **{**args, key: bad_value}) - - -def test_joint_phase_factory_data_only_snapshot_never_authorizes_training(tmp_path): - import hydra - from hydra.core.hydra_config import HydraConfig - from omegaconf import OmegaConf - - from egomimic.benchmarks.libero.native_launch_profiles import ( - joint_phase_argv, - joint_profile_for_config, - ) - - args = dict( - source_root=str(ROOT), - original_root=str(tmp_path / "original"), - sharpa_root=str(tmp_path / "sharpa"), - output_root=str(tmp_path / "out"), - context_path=str(tmp_path / "context.json"), - split_path=str(tmp_path / "split.json"), - split_sha256="a" * 64, - normalization_sha256=None, - source_commit=None, - diagnostic_seed_bank=str(tmp_path / "bank.json"), - wandb_entity="test", - wandb_project="test", - wandb_run_id="data-only", - data_preparation_source_sha256="d" * 64, - ) - argv = joint_phase_argv(phase="normalization", **args) - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(ROOT / "egomimic/hydra_configs") - ): - cfg = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=argv[1:], - ) - cfg.hydra.runtime.output_dir = str(tmp_path / "out") - HydraConfig.instance().set_config(cfg) - resolved = OmegaConf.to_container( - OmegaConf.masked_copy(cfg, [k for k in cfg if k != "hydra"]), resolve=True - ) - joint_profile_for_config(resolved) - assert ( - resolved["norm_stats_only"] is True - and resolved["run_provenance"]["source_commit"] is None - ) - assert ( - resolved["run_provenance"]["data_preparation_source_snapshot_sha256"] - == "d" * 64 - ) - for phase in ("preflight", "smoke", "full"): - with pytest.raises(ValueError, match="data-only source snapshots"): - joint_phase_argv(phase=phase, **args) - with pytest.raises(ValueError, match="exact SHA"): - joint_phase_argv( - phase="normalization", **{**args, "data_preparation_source_sha256": None} - ) diff --git a/tests/test_libero_native_velocity_ablation.py b/tests/test_libero_native_velocity_ablation.py deleted file mode 100644 index 79879d24b0..0000000000 --- a/tests/test_libero_native_velocity_ablation.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Explicit objective identity and single-variable resolved recipe regression.""" - -import copy -import sys -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir -from hydra.core.hydra_config import HydraConfig -from omegaconf import OmegaConf - -ROOT = Path(__file__).resolve().parents[1] -sys.path[:0] = [str(ROOT), str(ROOT / "tools")] -from typed_libero_profile_v3 import ( # noqa: E402 - task tool path initialized above - COMMON, - PHASES, - validate_flat_config, -) - -from egomimic.benchmarks.libero.native_launch_profiles import ( # noqa: E402 - task tool path initialized above - AV0_PROFILES, - PROFILES, - profile_for_argv, - profile_for_config, -) - - -def recipe(profile, monkeypatch): - monkeypatch.setenv(profile.replay_environment, "/unchanged/replay") - with initialize_config_dir( - version_base="1.3", config_dir=str(ROOT / "egomimic/hydra_configs") - ): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=["hydra/launcher=basic", "+experiment=" + profile.experiment], - return_hydra_config=True, - ) - cfg.hydra.runtime.output_dir = "/unchanged/output" - HydraConfig.instance().set_config(cfg) - OmegaConf.set_struct(cfg, False) - del cfg["hydra"] - return OmegaConf.to_container(cfg, resolve=True) - - -@pytest.mark.parametrize("suite", list(PROFILES)) -def test_only_resolved_model_velocity_weight_changes(suite, monkeypatch): - base = recipe(PROFILES[suite], monkeypatch) - ablated = recipe(AV0_PROFILES[suite], monkeypatch) - assert profile_for_config(base) == PROFILES[suite] - assert profile_for_config(ablated) == AV0_PROFILES[suite] - assert ( - profile_for_argv(["+experiment=" + AV0_PROFILES[suite].experiment]) - == AV0_PROFILES[suite] - ) - expected = copy.deepcopy(base["model"]) - expected["pipeline"]["stages"][8]["action_velocity_weight"] = 0.0 - assert ablated["model"] == expected - expected_data = copy.deepcopy(base["data"]) - expected_data["run_provenance"]["objective"]["action_velocity_weight"] = 0.0 - expected_data["run_provenance"]["ablation"] = "action_velocity_off" - assert ablated["data"] == expected_data - for key in ("trainer", "normalizer", "callbacks", "evaluator", "seed"): - assert ablated[key] == base[key], key - assert ablated["run_provenance"]["objective"] == { - **base["run_provenance"]["objective"], - "action_velocity_weight": 0.0, - } - bad = copy.deepcopy(ablated) - bad["run_provenance"]["objective"]["action_velocity_weight"] = 1.0 - with pytest.raises(ValueError, match="objective provenance"): - profile_for_config(bad) - bad = copy.deepcopy(base) - bad["run_provenance"]["ablation"] = "action_velocity_off" - with pytest.raises(ValueError, match="ablation provenance"): - profile_for_config(bad) - - -@pytest.mark.parametrize("weight", [0.0, 1.0]) -def test_typed_flat_guard_rejects_opposite_objective(weight): - flat = { - **COMMON, - **PHASES["smoke"], - "model.pipeline.stages.8.action_velocity_weight": weight, - } - for idx, owner in [(4, "encoder"), (6, "field")]: - for key, value in [("hidden_dim", 240), ("depth", 12), ("num_heads", 8)]: - flat[f"model.pipeline.stages.{idx}.{owner}.backbone.{key}"] = value - flat.update( - { - "data.train_source_names": ["libero_panda"], - "data.valid_source_names": ["libero_panda"], - "typed_profile.velocity_augmentation": False, - "typed_profile.homogeneous": "not_applicable_single_source", - "typed_profile.checkpoint_policy": "dit-half", - } - ) - assert validate_flat_config(flat, "smoke", action_velocity_weight=weight) - with pytest.raises(ValueError, match="resolved profile mismatch"): - validate_flat_config(flat, "smoke", action_velocity_weight=1.0 - weight) - - -def verifier_functions(): - # Execute exact pure verifier functions without importing GPU/model dependencies. - import ast - import math - from collections.abc import Mapping, Sequence - from typing import Any - - source = ast.parse( - (ROOT / "scripts/train/verify_action_flow_training_smoke.py").read_text() - ) - names = { - "SmokeVerificationError", - "_require", - "_metric", - "_complete_row", - "_validate_history", - "_validate_checkpoint_loss_schedule", - } - constants = { - "LEGACY_METHOD", - "GRAPH_METHOD", - "LIKELIHOOD_METHOD", - "STOPGRAD_METHOD", - "STOPGRAD_UNITE_METHOD", - "SOURCE_LABEL", - } - nodes = [ - ast.ImportFrom( - module="__future__", names=[ast.alias(name="annotations")], level=0 - ) - ] - namespace = dict( - math=math, Any=Any, Mapping=Mapping, Sequence=Sequence, OmegaConf=OmegaConf - ) - for node in ast.parse( - (ROOT / "tools/validate_action_flow_config.py").read_text() - ).body: - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name) and target.id in constants: - namespace[target.id] = ast.literal_eval(node.value) - for node in source.body: - if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in names: - nodes.append(node) - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name) and target.id in constants: - namespace[target.id] = ast.literal_eval(node.value) - exec( - compile( - ast.fix_missing_locations(ast.Module(body=nodes, type_ignores=[])), - str(ROOT / "scripts/train/verify_action_flow_training_smoke.py"), - "exec", - ), - namespace, - ) - fixture = ast.parse( - (ROOT / "tests/test_verify_action_flow_training_smoke.py").read_text() - ) - import types - - namespace["MODULE"] = types.SimpleNamespace(**namespace) - row_node = next( - node - for node in fixture.body - if isinstance(node, ast.FunctionDef) and node.name == "_history_row" - ) - exec( - compile( - ast.fix_missing_locations(ast.Module(body=[row_node], type_ignores=[])), - "existing_history_fixture", - "exec", - ), - namespace, - ) - return namespace - - -def test_disabled_velocity_must_be_zero_weight_in_schedule_and_total(): - v = verifier_functions() - row = v["_history_row"]() - row["Train/ActionFlow/Schedule/EffectiveActionVelocityWeight"] = 0.0 - for suffix in ("", "/" + v["SOURCE_LABEL"]): - row[f"Train/ActionFlow/TotalLoss{suffix}_step"] = 2.5 - row["Valid/ActionFlow/TotalLoss"] = 3.0 - assert ( - v["_validate_history"]({2: row}, action_velocity_weight=0.0)["train_step"] == 2 - ) - bad = dict(row) - bad["Train/ActionFlow/TotalLoss_step"] = 3.75 - with pytest.raises(v["SmokeVerificationError"], match="weighted total"): - v["_validate_history"]({2: bad}, action_velocity_weight=0.0) - bad = dict(row) - bad["Train/ActionFlow/Schedule/EffectiveActionVelocityWeight"] = 1.0 - with pytest.raises(v["SmokeVerificationError"], match="joint smoke step"): - v["_validate_history"]({2: bad}, action_velocity_weight=0.0) - stages = [{} for _ in range(9)] - stages[8] = {"action_velocity_weight": 0.0} - cfg = OmegaConf.create( - { - "name": AV0_PROFILES["libero10"].name, - "benchmark": {"suite": "libero10"}, - "data": { - mode + "_datasets": { - "libero_panda": {"resolver": {"suite": "libero10"}} - } - for mode in ("train", "valid") - }, - "run_provenance": { - "ablation": "action_velocity_off", - "objective": {"action_velocity_weight": 0.0}, - }, - "model": { - "reconstruction_only_warmup_steps": 0, - "pipeline": {"stages": stages}, - }, - } - ) - schedule = { - "joint_objective_begins_at_global_step": 0, - "reconstruction_only_optimizer_steps": 0, - "joint_flow_weight": 1.0, - "joint_reconstruction_weight": 1.0, - "joint_action_velocity_weight": 0.0, - "schema_version": 1, - } - assert ( - v["_validate_checkpoint_loss_schedule"]( - schedule, cfg, reconstruction_weight=1.0, flow_weight=1.0 - ) - == 0 - ) - with pytest.raises(v["SmokeVerificationError"], match="loss schedule"): - v["_validate_checkpoint_loss_schedule"]( - {**schedule, "joint_action_velocity_weight": 1.0}, - cfg, - reconstruction_weight=1.0, - flow_weight=1.0, - ) - - # A nonnative config cannot weaken the historical checkpoint guard by setting stage7=0. - nonnative = OmegaConf.create( - { - "name": "unrelated", - "model": { - "reconstruction_only_warmup_steps": 0, - "pipeline": {"stages": [{}] * 7 + [{"action_velocity_weight": 0.0}]}, - }, - } - ) - with pytest.raises( - v["_validate_checkpoint_loss_schedule"].__globals__["SmokeVerificationError"], - match="loss schedule", - ): - v["_validate_checkpoint_loss_schedule"]( - schedule, nonnative, reconstruction_weight=1.0, flow_weight=1.0 - ) - assert ( - v["_validate_checkpoint_loss_schedule"]( - {**schedule, "joint_action_velocity_weight": 1.0}, - nonnative, - reconstruction_weight=1.0, - flow_weight=1.0, - ) - == 0 - ) - baseline = OmegaConf.create(OmegaConf.to_container(cfg, resolve=True)) - baseline.name = PROFILES["libero10"].name - baseline.run_provenance.objective.action_velocity_weight = 1.0 - baseline.run_provenance.ablation = None - baseline.model.pipeline.stages[8].action_velocity_weight = 1.0 - with pytest.raises(v["SmokeVerificationError"], match="loss schedule"): - v["_validate_checkpoint_loss_schedule"]( - schedule, baseline, reconstruction_weight=1.0, flow_weight=1.0 - ) diff --git a/tests/test_libero_native_whole_launcher.py b/tests/test_libero_native_whole_launcher.py deleted file mode 100644 index c1f09a6b52..0000000000 --- a/tests/test_libero_native_whole_launcher.py +++ /dev/null @@ -1,417 +0,0 @@ -"""Execute the complete real Bash launcher through safe native tool handoff. - -No model, data, Python tool, GPU, scheduler, credential or training execution. -Every preceding guard/hash/path/clean-Git check and actual array remains real. -""" - -import hashlib -import json -import os -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - -R = Path(__file__).parents[1] -PROFILE = "libero_historical/action_flow_libero10_h240_euler50_dithalf_80k_s42" -PLANAR = "pusht/action_flow_bc_usocket_recon1_s42" - - -def sha(p): - return hashlib.sha256(Path(p).read_bytes()).hexdigest() - - -class WholeLauncher(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.root = Path(self.temp.name) - self.repo = self.root / "source" - self.repo.mkdir() - files = [ - "scripts/train/launch_action_flow_usocket.sbatch", - "scripts/train/libero_native_launch_phases.sh", - "scripts/train/libero_native_schema_contract.py", - "scripts/train/verify_action_flow_training_smoke.py", - "scripts/train/validate_slurm_job_contract.py", - "scripts/train/check_checkpoint_storage.py", - "scripts/ice/ice_gpu_probe.py", - "scripts/ice/ice_requeue_runner.py", - "scripts/ice/relay_batch_signals.sh", - "scripts/ice/validate_lightning_checkpoint.py", - "scripts/ice/capture_runtime_lock.py", - "tools/libero_maintained_dispatch_v1.py", - "tools/validate_action_flow_config.py", - "scripts/ice/validate_planar_dataset.py", - "egomimic/hydra_configs/experiment/" + PROFILE + ".yaml", - "egomimic/hydra_configs/experiment/" + PLANAR + ".yaml", - "egomimic/hydra_configs/experiment/libero_historical/action_flow_libero_spatial_h240_euler50_dithalf_80k_s42.yaml", - "egomimic/hydra_configs/experiment/libero_historical/action_flow_libero_goal_h240_euler50_dithalf_80k_s42.yaml", - "egomimic/hydra_configs/experiment/libero_historical/action_flow_libero_object_h240_euler50_dithalf_80k_s42.yaml", - ] - from egomimic.benchmarks.libero.native_launch_profiles import AV0_PROFILES - - files += [ - "egomimic/hydra_configs/experiment/" + p.experiment + ".yaml" - for p in AV0_PROFILES.values() - ] - for rel in files: - p = self.repo / rel - p.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(R / rel, p) - self.launcher = self.repo / files[0] - for name in ["data", "output-parent"]: - (self.root / name).mkdir() - self.split = self.root / "split.json" - self.split.write_text("{}\n") - self.receipt = self.root / "receipt.json" - self.receipt.write_text("{}\n") - self.norm = self.root / "norm.json" - self.norm.write_text("{}\n") - self.preflight = self.root / "preflight.json" - self.preflight.write_text("{}\n") - self.override = self.root / "binding.override" - self.override.write_text( - '++norm_stats.native_saved_state_binding={test:"fixture-only-no-execution"}\n' - ) - self.mock = self.repo / "safe-tools/mock-python" - self.mock.parent.mkdir() - self.mock.write_text("""#!/bin/bash -set -Eeuo pipefail -case "$1" in - */capture_runtime_lock.py) - while test "$#" -gt 0; do - if test "$1" = --output; then shift; printf '{"scope":"mocked-runtime-no-python-execution"}\\n' > "$1"; break; fi - shift - done ;; - */libero_native_launch_contract.py) - printf 'NATIVE_DISPATCH_PREPARE_STOP\\n' - printf '%s\\n' "$*" > "$SAFE_HANDOFF_LOG" - replay_variable=${SAFE_REPLAY_VARIABLE:-LIBERO_SPATIAL_REPLAY_ROOT} - printf '%s\\n' "${!replay_variable:-}" > "$SAFE_HANDOFF_LOG.replay" - exit 91 ;; - *) printf 'UNEXPECTED_EXTERNAL_TOOL\\n' >&2; exit 92 ;; -esac -""") - self.mock.chmod(0o755) - cp = self.repo / "scripts/ice/validate_lightning_checkpoint.py" - cp.chmod(0o755) - subprocess.run(["git", "init", "-q", str(self.repo)], check=True) - subprocess.run(["git", "-C", str(self.repo), "add", "."], check=True) - subprocess.run( - [ - "git", - "-C", - str(self.repo), - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "commit", - "-qm", - "safe launcher fixture", - ], - check=True, - ) - head = subprocess.check_output( - ["git", "-C", str(self.repo), "rev-parse", "HEAD"], text=True - ).strip() - env = { - k: v - for k, v in os.environ.items() - if not k.startswith(("AF_", "ICE_", "SLURM_", "WANDB_", "LIBERO_")) - } - env.update( - AF_REPO=str(self.repo), - AF_EXPECTED_HEAD=head, - AF_PYTHON=str(self.mock), - AF_DATASET_DIR=str(self.root / "data"), - AF_OUTPUT_DIR=str(self.root / "output-parent/run"), - AF_EXPERIMENT=PROFILE, - AF_WANDB_ENTITY="rl2-group", - AF_WANDB_PROJECT="pushshapes-action-flow", - AF_WANDB_RUN_ID="lib10-noaug-smoke-ce2c-20261007", - AF_WANDB_GROUP="cotrain-velocity-first-wave-20261007", - AF_WANDB_TAGS="[libero10,noaug,action-flow,h240,dithalf,bf16,euler50,smoke]", - AF_EXPECTED_SPLIT_MANIFEST_SHA256=sha(self.split), - AF_EXPECTED_LAUNCHER_SHA256=sha(self.launcher), - AF_CONTENT_MANIFEST=str(self.receipt), - AF_EXPECTED_CONTENT_MANIFEST_SHA256=sha(self.receipt), - AF_EXPECTED_DATASET_CONTENT_AGGREGATE_SHA256="3ed2b86cf97eaa27045af81e6d6c4834c8659d15eda69a2394d835e602014e1d", - AF_NATIVE_DATASET_SHA256="3ed2b86cf97eaa27045af81e6d6c4834c8659d15eda69a2394d835e602014e1d", - AF_NATIVE_HISTORICAL_SPLIT_SHA256="c588c831ef0deab61a59a21840ea9e11dafe92a170d9023a87e0acee831df236", - AF_CLUSTER_LABEL="lambda", - AF_EXPECTED_ACCOUNT="loaner", - AF_EXPECTED_PARTITION="rl2", - AF_EXPECTED_QOS="loaner-normal", - AF_SPLIT_MANIFEST=str(self.split), - AF_NORM_STATS_PATH=str(self.norm), - AF_EXPECTED_NORM_SHA256=sha(self.norm), - AF_NATIVE_SCHEMA_DRIVER=str( - self.repo / "scripts/train/libero_native_schema_contract.py" - ), - AF_NATIVE_SCHEMA_DRIVER_SHA256=sha( - self.repo / "scripts/train/libero_native_schema_contract.py" - ), - AF_NATIVE_BINDING_OVERRIDE=str(self.override), - AF_NATIVE_BINDING_OVERRIDE_SHA256=sha(self.override), - AF_NATIVE_DATASET_VALIDATOR=str( - self.repo / "tools/libero_maintained_dispatch_v1.py" - ), - AF_NATIVE_CONFIG_VALIDATOR=str( - self.repo / "tools/libero_maintained_dispatch_v1.py" - ), - AF_NATIVE_SMOKE_VERIFIER=str( - self.repo / "scripts/train/verify_action_flow_training_smoke.py" - ), - AF_NATIVE_PHASE="smoke", - AF_PRECISION="bf16-mixed", - AF_LAUNCH_MODE="run", - AF_RUN_KIND="smoke", - AF_MAX_RESTARTS="0", - AF_EXPECTED_GPU_CONSTRAINT="(null)", - AF_EXPECTED_MEMORY="128G", - AF_EXPECTED_TIME_LIMIT="02:00:00", - AF_PREFLIGHT_RESULT=str(self.preflight), - AF_EXPECTED_PREFLIGHT_SHA256=sha(self.preflight), - AF_NATIVE_SCHEMA_ALLOW_PENDING_PROOFS="false", - SLURM_STEP_ID="fixture-step", - SLURM_JOB_ID="123456", - SLURM_JOB_ACCOUNT="loaner", - SLURM_JOB_PARTITION="rl2", - SLURM_JOB_QOS="loaner-normal", - SLURM_JOB_NUM_NODES="1", - SLURM_NTASKS="1", - SLURM_CPUS_PER_TASK="8", - SLURM_RESTART_COUNT="0", - SAFE_HANDOFF_LOG=str(self.root / "handoff.log"), - ) - self.env = env - - def tearDown(self): - self.temp.cleanup() - - def invoke(self, **changes): - env = dict(self.env) - env.update(changes) - return subprocess.run( - ["bash", str(self.launcher)], - env=env, - capture_output=True, - text=True, - timeout=15, - ) - - def assertRejected(self, result, text): - self.assertEqual(result.returncode, 64, result.stdout + result.stderr) - self.assertIn(text, result.stderr) - self.assertFalse((self.root / "handoff.log").exists()) - - def test_exact_v7_native_smoke_envelope_reaches_real_native_dispatch(self): - result = self.invoke() - self.assertEqual(result.returncode, 91, result.stdout + result.stderr) - self.assertIn("NATIVE_DISPATCH_PREPARE_STOP", result.stdout) - self.assertIn("--action prepare", (self.root / "handoff.log").read_text()) - argv = ( - self.root / "output-parent/run/provenance/restart-0/exact-phase.argv0" - ).read_bytes() - self.assertIn(b"trainer.max_steps=2\0", argv) - self.assertIn(b"trainer.precision=bf16-mixed\0", argv) - self.assertIn(b"++run_provenance.preflight_result_sha256=", argv) - - def suite_values(self, suite, seed): - record = json.loads( - (R / "tests/fixtures/libero_native_suite_corpus_authority.json").read_text() - )["suites"][suite] - return dict( - AF_EXPERIMENT="libero_historical/action_flow_" - + suite - + "_h240_euler50_dithalf_80k_s42", - AF_SEED=str(seed), - AF_NATIVE_DATASET_SHA256=record["dataset_logical_sha256"], - AF_EXPECTED_DATASET_CONTENT_AGGREGATE_SHA256=record[ - "dataset_logical_sha256" - ], - AF_NATIVE_HISTORICAL_SPLIT_SHA256=record["historical_split_sha256"], - AF_EXPECTED_GPU_CONSTRAINT="H100|H200" - if suite == "libero_spatial" - else "(null)", - AF_CLUSTER_LABEL="ice" if suite == "libero_spatial" else "lambda", - ) - - def test_actual_goal_object_corpus_and_both_training_seeds_reach_dispatch(self): - for suite in ("libero_goal", "libero_object"): - for seed in (42, 43): - with self.subTest(suite=suite, seed=seed): - values = self.suite_values(suite, seed) - values["AF_OUTPUT_DIR"] = str( - self.root / "output-parent" / (suite + "-" + str(seed)) - ) - result = self.invoke(**values) - self.assertEqual( - result.returncode, 91, result.stdout + result.stderr - ) - argv = ( - Path(values["AF_OUTPUT_DIR"]) - / "provenance/restart-0/exact-phase.argv0" - ).read_bytes() - self.assertIn(("seed=" + str(seed) + "\0").encode(), argv) - self.assertIn( - ( - "++run_provenance.dataset_content_aggregate_sha256=" - + values["AF_NATIVE_DATASET_SHA256"] - + "\0" - ).encode(), - argv, - ) - self.assertIn( - ( - "++run_provenance.historical_split_manifest_sha256=" - + values["AF_NATIVE_HISTORICAL_SPLIT_SHA256"] - + "\0" - ).encode(), - argv, - ) - - def test_cross_suite_corpus_and_historical_split_rejected(self): - for suite in ("libero_spatial", "libero_goal", "libero_object"): - for field, message in [ - ("AF_NATIVE_DATASET_SHA256", "native dataset identity mismatch"), - ( - "AF_NATIVE_HISTORICAL_SPLIT_SHA256", - "historical split identity mismatch", - ), - ( - "AF_EXPECTED_DATASET_CONTENT_AGGREGATE_SHA256", - "native dataset aggregate identity mismatch", - ), - ]: - with self.subTest(suite=suite, field=field): - values = self.suite_values(suite, 42) - values[field] = self.env[field] - self.assertRejected(self.invoke(**values), message) - - def test_spatial_ice_actual_whole_path_constrained_dispatch(self): - result = self.invoke(**self.suite_values("libero_spatial", 42)) - self.assertEqual(result.returncode, 91, result.stdout + result.stderr) - argv = ( - self.root / "output-parent/run/provenance/restart-0/exact-phase.argv0" - ).read_bytes() - self.assertIn( - b"+experiment=libero_historical/action_flow_libero_spatial_h240_euler50_dithalf_80k_s42\0", - argv, - ) - self.assertEqual( - (self.root / "handoff.log.replay").read_text().strip(), - str(self.root / "data"), - ) - - def test_spatial_native_actual_whole_path_null_reaches_hardware_guard(self): - result = self.invoke( - **{ - **self.suite_values("libero_spatial", 42), - "AF_EXPECTED_GPU_CONSTRAINT": "(null)", - } - ) - self.assertEqual(result.returncode, 91, result.stdout + result.stderr) - - def test_native_full_positive_restart_cap_and_null_constraint_reach_dispatch(self): - result = self.invoke( - AF_RUN_KIND="full", - AF_MAX_RESTARTS="4", - AF_SMOKE_RESULT=str(self.preflight), - AF_EXPECTED_SMOKE_SHA256=sha(self.preflight), - ) - self.assertEqual(result.returncode, 91, result.stdout + result.stderr) - self.assertIn("--phase full", (self.root / "handoff.log").read_text()) - argv = ( - self.root / "output-parent/run/provenance/restart-0/exact-phase.argv0" - ).read_bytes() - self.assertIn(b"trainer.max_steps=80000\0", argv) - self.assertIn(b"trainer.val_check_interval=15000\0", argv) - self.assertIn(b"++callbacks.model_checkpoint.every_n_train_steps=5000\0", argv) - - def test_native_unsupported_gpu_constraint_rejected(self): - self.assertRejected( - self.invoke(AF_EXPECTED_GPU_CONSTRAINT="RTX4090"), - "AF_EXPECTED_GPU_CONSTRAINT must be H100", - ) - - def test_native_invalid_restart_counter_rejected(self): - self.assertRejected( - self.invoke(SLURM_RESTART_COUNT="bad"), "invalid SLURM_RESTART_COUNT" - ) - - def test_native_smoke_requeue_cap_must_be_zero(self): - self.assertRejected( - self.invoke(AF_MAX_RESTARTS="1"), "native smoke requires AF_MAX_RESTARTS=0" - ) - - def test_negative_restart_cap_rejected(self): - self.assertRejected( - self.invoke(AF_MAX_RESTARTS="-1"), "native smoke requires AF_MAX_RESTARTS=0" - ) - - def test_native_restart_attempt_rejected(self): - self.assertRejected( - self.invoke(SLURM_RESTART_COUNT="1"), "restart limit exceeded" - ) - - def test_native_h100_constraint_reaches_hardware_guard(self): - result = self.invoke(AF_EXPECTED_GPU_CONSTRAINT="H100") - self.assertEqual(result.returncode, 91, result.stdout + result.stderr) - - def test_native_wrong_precision_rejected(self): - self.assertRejected( - self.invoke(AF_PRECISION="bf16"), "native BF16-mixed required" - ) - - def test_native_wrong_cpu_envelope_rejected_before_tool(self): - self.assertRejected( - self.invoke(SLURM_CPUS_PER_TASK="16"), "exact eight CPUs required" - ) - - def planar(self, **changes): - params = dict( - AF_EXPERIMENT=PLANAR, - AF_PRECISION="bf16", - AF_MAX_RESTARTS="4", - AF_EXPECTED_CONTENT_MANIFEST_SHA256="a1c81fb0ce8967aba795383a293180f9ba08a0ecfdd6f4a878afb20b39733761", - AF_EXPECTED_DATASET_CONTENT_AGGREGATE_SHA256="80f835ad37c3d5c5b7b2d5c3e1656c307ee567a1f63f51081165bf404b8ceb52", - ) - params.update(changes) - return self.invoke(**params) - - def test_av0_suites_reach_same_canonical_phase_with_distinct_identity(self): - from egomimic.benchmarks.libero.native_launch_profiles import AV0_PROFILES - - for suite, profile in AV0_PROFILES.items(): - with self.subTest(suite=suite): - values = self.suite_values(suite, 42) - values["AF_EXPERIMENT"] = profile.experiment - values["SAFE_REPLAY_VARIABLE"] = profile.replay_environment - output = self.root / "output-parent" / ("run-" + suite) - values["AF_OUTPUT_DIR"] = str(output) - result = self.invoke(**values) - self.assertEqual(result.returncode, 91, result.stdout + result.stderr) - argv = (output / "provenance/restart-0/exact-phase.argv0").read_bytes() - self.assertIn( - ("+experiment=" + profile.experiment + "\0").encode(), argv - ) - self.assertEqual( - (self.root / "handoff.log.replay").read_text().strip(), - str(self.root / "data"), - ) - - def test_general_zero_restart_gate_preserved(self): - self.assertRejected(self.planar(AF_MAX_RESTARTS="0"), "invalid AF_MAX_RESTARTS") - - def test_general_null_gpu_constraint_gate_preserved(self): - self.assertRejected(self.planar(), "AF_EXPECTED_GPU_CONSTRAINT must be H100") - - def test_native_full_zero_restart_gate_unchanged(self): - self.assertRejected(self.invoke(AF_RUN_KIND="full"), "invalid AF_MAX_RESTARTS") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_libero_performance.py b/tests/test_libero_performance.py deleted file mode 100644 index 8a2aa92672..0000000000 --- a/tests/test_libero_performance.py +++ /dev/null @@ -1,313 +0,0 @@ -"""Performance paths must preserve exact targets, data and training budgets.""" - -import importlib.util -import json -import os -import pickle -import subprocess -import sys -from pathlib import Path - -import numpy as np -import pytest -import torch -from hydra import compose, initialize_config_dir - -from egomimic.benchmarks.libero.cluster import ( - arc_checkpoint_settings, - digest, - restore_checkpoints, - training_arguments, - training_layout, -) -from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - -@pytest.mark.parametrize("mode", ["joint_dur", "stk", "dur"]) -def test_target_cache_is_exact_bounded_and_invalidated_by_codec_changes(mode): - options = dict( - arc_mode=mode, - num_waypoints=24, - max_translation=0.8, - max_rotation_degrees=192, - velocity_norm_bound=3**0.5, - ) - cached = LiberoArcStage(**options, encode_cache_size=2) - reference = LiberoArcStage(**options) - actions = torch.from_numpy( - np.random.default_rng(5).uniform(-1, 1, (3, 32, 7)).astype(np.float32) - ) - actions[0, 4:12, :6] = 0 # Exercise duration dwells and velocity limitations. - calls = [] - original = cached.codec.encode - - def count(row): - calls.append(row.copy()) - return original(row) - - cached.codec.encode = count - for indices, count_expected in [([0, 1, 0], 2), ([1, 0], 2), ([2, 0], 3), ([1], 4)]: - batch = actions[indices] - expected = reference.execute({"actions": batch}, mode="train")["target"] - actual = cached.execute({"actions": batch}, mode="train")["target"] - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - assert len(calls) == count_expected - assert len(cached._encode_cache) <= 2 - actual.zero_() # Returned values must never alias cached physical tokens. - cached.codec.max_translation = reference.codec.max_translation = 0.01 - actual = cached.execute({"actions": actions[1:2]}, mode="train")["target"] - expected = reference.execute({"actions": actions[1:2]}, mode="train")["target"] - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - assert len(calls) == 5 - assert cached.state_dict().keys() == reference.state_dict().keys() - with pytest.raises(ValueError, match="batch"): - cached._encode(actions[1:2].numpy().reshape(1, 7, 32)) - - -def test_target_cache_preserves_normalizer_changes_and_rejects_nonfinite(): - cached = LiberoArcStage(arc_mode="stk", encode_cache_size=8) - reference = LiberoArcStage(arc_mode="stk") - actions = torch.ones(1, 32, 7) * 0.25 - cached.execute({"actions": actions}, mode="train") - for stage in (cached, reference): - stage.action_scale.fill_(2) - stage.action_offset.fill_(0.1) - torch.testing.assert_close( - cached.execute({"actions": actions}, mode="train")["target"], - reference.execute({"actions": actions}, mode="train")["target"], - rtol=0, - atol=0, - ) - assert len(cached._encode_cache) == 2 - with pytest.raises(ValueError, match="finite"): - cached.execute( - {"actions": torch.full_like(actions, float("nan"))}, mode="train" - ) - - -def test_replay_array_handles_survive_repeated_reads_and_reset_on_spawn( - tmp_path, monkeypatch -): - import zarr - - from egomimic.rldb.zarr.libero_dataset import LiberoReplayResolver, keymap - from tests.test_libero_benchmark import make_replay - - path = tmp_path / "replay.zarr" - make_replay(path) - leaf = next( - iter(LiberoReplayResolver(path, keymap(), "libero10").resolve().values()) - ) - expected = leaf[0] - original = zarr.Group.__getitem__ - calls = [] - - def tracked(group, key): - calls.append(key) - return original(group, key) - - monkeypatch.setattr(zarr.Group, "__getitem__", tracked) - actual = leaf[0] - assert calls == [] - restored = pickle.loads(pickle.dumps(leaf)) - assert restored._arrays is None - restored_sample = restored[0] - assert calls - for key, value in expected.items(): - if torch.is_tensor(value): - torch.testing.assert_close(actual[key], value, rtol=0, atol=0) - torch.testing.assert_close(restored_sample[key], value, rtol=0, atol=0) - - -@pytest.mark.parametrize("gpus", [1, 2, 4, 8]) -@pytest.mark.parametrize("mode", ["full", "smoke"]) -def test_distributed_arguments_preserve_global_batch(gpus, mode, tmp_path): - args = training_arguments( - "arc_stk", "libero_10", tmp_path / "replay", tmp_path, mode, 5001, gpus=gpus - ) - with initialize_config_dir( - version_base=None, - config_dir=str(Path(__file__).parents[1] / "egomimic/hydra_configs"), - ): - cfg = compose(config_name="train_zarr_cartesian", overrides=args[3:]) - assert cfg.trainer.devices == gpus - effective = cfg.benchmark.batch_size * gpus * cfg.trainer.accumulate_grad_batches - assert effective == (1024 if mode == "full" else 4 * gpus) - if mode == "full": - assert cfg.callbacks.batch_budget.global_batch_size == effective - assert cfg.trainer.max_epochs == 5001 - if gpus > 1: - assert cfg.trainer.strategy == "ddp_find_unused_parameters_true" - with pytest.raises(ValueError, match="GPUs"): - training_layout(3, mode) - - -def test_sweep_resume_reuses_confirmed_replay_and_resources(tmp_path, monkeypatch): - from egomimic.benchmarks.libero import arc_sweep, cluster - - path = Path(__file__).parents[1] / "scripts/benchmarks/launch_libero_osmo.py" - spec = importlib.util.spec_from_file_location("ddp_launcher", path) - launcher = importlib.util.module_from_spec(spec) - spec.loader.exec_module(launcher) - args = dict( - commit="a" * 40, - run_id="ddp-stk1", - suite="libero_10", - mode="full", - arc_modes=["stk"], - arc_profile="stk_1", - resume_from_run="old-stk1", - oat_reference_run="baseline", - gpus=4, - ) - with pytest.raises(ValueError, match="completed mode-specific replay"): - launcher.workflow(**args) - workflow = launcher.workflow(**args, arc_replay_runs={"stk": "confirmed-stk1"})[ - "workflow" - ] - assert workflow["resources"]["default"]["gpu"] == 4 - assert workflow["resources"]["default"]["cpu"] == 48 - assert workflow["resources"]["default"]["memory"] == "256Gi" - env = workflow["tasks"][0]["environment"] - for name, value in env.items(): - monkeypatch.setenv(name, value) - assert env["TRAINING_GPUS"] == "4" - calls = [] - monkeypatch.setattr(cluster, "execute", lambda argv, log: calls.append(argv)) - monkeypatch.setattr( - sys, - "argv", - [ - "sweep", - "--root", - str(tmp_path), - "--suite", - "libero_10", - "--run-id", - "ddp-stk1", - "--profile", - "stk_1", - "--arc-mode", - "stk", - ], - ) - arc_sweep.main() - assert len(calls) == 1 and calls[0][2].endswith(".cluster") - assert "--arc-only" in calls[0] - assert os.environ["ARC_REPLAY_RUNS_JSON"] == '{"stk": "confirmed-stk1"}' - - -def test_resume_changes_world_size_without_resetting_optimizer_or_ema(tmp_path): - script = Path(__file__).with_name("libero_ddp_resume_worker.py") - env = dict( - os.environ, OMP_NUM_THREADS="1", MKL_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1" - ) - # Execute against this audited checkout, not a globally installed package or - # the worker script directory. This also survives a task-local test venv. - env["PYTHONPATH"] = os.pathsep.join( - [str(Path(__file__).resolve().parents[1]), env.get("PYTHONPATH", "")] - ) - for suffix in ([], ["--resume"]): - result = subprocess.run( - [sys.executable, str(script), "--root", str(tmp_path), *suffix], - env=env, - capture_output=True, - text=True, - timeout=180, - ) - assert result.returncode == 0, result.stdout[-8000:] + result.stderr[-8000:] - initial = json.loads((tmp_path / "initial.json").read_text()) - resumed = json.loads((tmp_path / "resumed.json").read_text()) - assert initial["global_step"] == initial["ema_num_updates"] == 11 - assert resumed["global_step"] == resumed["ema_num_updates"] == 22 - assert resumed["optimizer_states"] - budget = resumed["training_budget"] - assert budget["world_size"] == budget["gradient_accumulation"] == 2 - assert budget["global_batch_size"] == 8 - assert budget["optimizer_steps_per_epoch"] == 11 - - -@pytest.mark.parametrize("method", ["arc", "arc_stk", "arc_dur"]) -@pytest.mark.parametrize("backbone", ["unet", "oat_dp"]) -def test_arc_resume_reads_actual_saved_graph_config(tmp_path, method, backbone): - import io - import shutil - - from egomimic.trainHydra import _build_model_config_tree - - args = training_arguments( - method, - "libero_10", - tmp_path, - tmp_path, - "full", - 5001, - arc_backbone=backbone, - ) - with initialize_config_dir( - version_base=None, - config_dir=str(Path(__file__).parents[1] / "egomimic/hydra_configs"), - ): - cfg = compose(config_name="train_zarr_cartesian", overrides=args[3:]) - config = _build_model_config_tree(cfg) - assert "benchmark" not in config # Actual trainHydra checkpoint format. - source = tmp_path / "source.ckpt" - torch.save( - { - "global_step": 140, - "loops": {"fit_loop": {"epoch_progress": {"current": {"completed": 2}}}}, - "training_budget": {"global_batch_size": 1024, "epochs": 5001}, - "optimizer_states": [{"state": {}}], - "normalizer_state": {}, - "hyper_parameters": {"config_tree": config}, - }, - source, - ) - runtime = { - "suite": "libero_10", - "mode": "full", - "epochs": 5001, - "global_batch_size": 1024, - } - receipts = { - f"training/{method}/checkpoints/last.ckpt": { - "sha256": digest(source), - "bytes": source.stat().st_size, - "uri": "s3://rldb/experiments/arc-oat-20260919/source/checkpoints/last.ckpt", - } - } - - class Storage: - def get_object(self, Bucket, Key): - value = runtime if Key.endswith("runtime.json") else receipts - return {"Body": io.BytesIO(json.dumps(value).encode())} - - def download_file(self, bucket, key, destination): - shutil.copyfile(source, destination) - - restored = restore_checkpoints( - Storage(), - "source", - tmp_path / "evidence", - suite="libero_10", - mode="full", - epochs=5001, - allow_partial=True, - methods=[method], - )[method] - assert restored["global_step"] == 140 - assert restored["benchmark"].get("arc_backbone", "unet") == backbone - for key, value in restored["benchmark"].items(): - assert value == cfg.benchmark[key] - with pytest.raises(ValueError, match="protocol"): - arc_checkpoint_settings(config, suite="libero_spatial") - from omegaconf import open_dict - - with open_dict(config.model.benchmark_protocol): - config.model.benchmark_protocol.arc_backbone = "unknown" - with pytest.raises(ValueError, match="unknown backbone"): - arc_checkpoint_settings(config, suite="libero_10") - config.model.benchmark_protocol.arc_backbone = backbone - config.model.pipeline.stages[-1].num_waypoints += 1 - with pytest.raises(ValueError, match="parameters differ"): - arc_checkpoint_settings(config, suite="libero_10") diff --git a/tests/test_libero_replay.py b/tests/test_libero_replay.py deleted file mode 100644 index 09d0a06e74..0000000000 --- a/tests/test_libero_replay.py +++ /dev/null @@ -1,356 +0,0 @@ -import copy -from pathlib import Path - -import numpy as np -import pytest -import yaml - -from egomimic.benchmarks.libero.replay import ( - candidate_id, - candidates_from_spec, - demo_task_definition, - load_calibration_parent, - rank_candidates, - read_demo, - rebase_demo_xml, - reconstruct_episode, - run_jobs, - stage_raw_dataset, - summarize, - validate_controls, - validate_spec, -) - - -@pytest.fixture -def spec(): - path = ( - Path(__file__).parents[1] - / "egomimic/hydra_configs/benchmark/libero_arc_replay.yaml" - ) - return yaml.safe_load(path.read_text()) - - -def test_splits_are_disjoint_and_all_candidates_match_policy_shape(spec): - validate_spec(spec) - assert len(candidates_from_spec(spec)) == 336 - spec["confirmation_demos"].append(spec["calibration_demos"][0]) - with pytest.raises(ValueError, match="overlap"): - validate_spec(spec) - - -def test_replay_preserves_episode_length_and_dense_commands(spec): - actions = np.random.default_rng(3).uniform(-0.2, 0.2, (73, 7)) - actions[:, 6] = -1 - actions[24:, 6] = 1 - candidate = dict(num_waypoints=36, max_translation=None, max_rotation_degrees=None) - decoded, metrics = reconstruct_episode(actions, candidate, spec) - np.testing.assert_allclose(decoded, actions, atol=3e-6) - assert decoded.shape == actions.shape - assert metrics["execution_coverage"] == 1 - assert metrics["waypoints_per_executed_action"] == pytest.approx(5 * 36 / 73) - - -def test_short_horizon_cannot_cheat_by_replanning_early(spec): - actions = np.zeros((32, 7)) - actions[:, 0] = 0.2 - candidate = dict(num_waypoints=16, max_translation=0.02, max_rotation_degrees=None) - decoded, metrics = reconstruct_episode(actions, candidate, spec) - # 2 represented commands in each unchanged 16-command execution interval. - assert metrics["execution_coverage"] == pytest.approx(4 / 32) - np.testing.assert_allclose(decoded[[0, 1, 16, 17], 0], 0.2, atol=1e-6) - np.testing.assert_allclose(decoded[2:16, 0], 0, atol=1e-6) - - -def test_selection_reports_paired_losses_and_can_require_strict_retention(spec): - candidates = { - "small": {"num_waypoints": 8}, - "large": {"num_waypoints": 16}, - } - base = {"eef_rmse_vs_raw_metres": 0, "action_mse": 0} - rows = [ - { - "candidates": { - key: {**base, "success": success} for key, success in outcome.items() - } - } - for outcome in [ - {"raw": True, "small": False, "large": True}, - {"raw": False, "small": True, "large": False}, - ] - ] - summary = summarize(rows, ["raw", *candidates]) - assert summary["small"]["success_rate"] == summary["large"]["success_rate"] - assert summary["small"]["lost_raw_successes"] == 1 - assert summary["small"]["gained_successes"] == 1 - assert rank_candidates(summary, candidates, spec) == ["small", "large"] - spec["minimum_retention"] = 1.0 - assert rank_candidates(summary, candidates, spec) == ["large"] - with pytest.raises(RuntimeError, match="Raw demonstration"): - validate_controls(summary, spec) - - -def test_fidelity_is_secondary_to_retention_and_token_rate(spec): - summary = { - "small": { - "retention": 1, - "successes": 10, - "success_rate": 1, - "raw_successes": 10, - "episodes": 10, - "action_mse": 0.01, - } - } - summary["large"] = {**summary["small"], "action_mse": 0.001} - assert rank_candidates( - summary, {"small": {"num_waypoints": 8}, "large": {"num_waypoints": 16}}, spec - ) == ["small", "large"] - controls = { - "raw": {"success_rate": 1}, - "raw_repeat": {"max_state_l2_vs_raw_mean": 0}, - "dense": {"retention": 0.8, "max_action_mse": 1.0e-4}, - } - with pytest.raises(RuntimeError, match="Dense ARC"): - validate_controls(controls, spec) - - -def test_numerical_control_distinguishes_roundoff_from_reset_nondeterminism(spec): - controls = { - "raw": {"success_rate": 1}, - "raw_repeat": {"max_state_l2_vs_raw_mean": 0}, - "dense": {"retention": 0.8, "max_action_mse": 1.0e-14}, - } - validate_controls(controls, spec) - controls["raw_repeat"]["max_state_l2_vs_raw_mean"] = 1.0e-8 - with pytest.raises(RuntimeError, match="not repeatable"): - validate_controls(controls, spec) - - -def test_replay_commands_use_training_precision_and_preserve_source(tmp_path): - import h5py - - path = tmp_path / "task_demo.hdf5" - actions = np.full((7, 7), 0.1, dtype=np.float64) - with h5py.File(path, "w") as handle: - data = handle.create_group("data") - data.attrs["bddl_file_name"] = "task.bddl" - demo = data.create_group("demo_0") - demo.create_dataset("actions", data=actions) - demo.create_dataset("states", data=np.zeros((7, 10))) - demo.attrs["model_file"] = "" - demo = read_demo(path, 0) - assert demo["actions"].dtype == np.float32 - np.testing.assert_array_equal(demo["actions"], actions.astype(np.float32)) - np.testing.assert_array_equal(demo["source_actions"], actions) - assert not np.array_equal(demo["source_actions"], demo["actions"]) - - -@pytest.mark.parametrize("waypoints", [4, 8, 16, 24, 32, 36]) -def test_candidate_support_shapes_allow_policy_forward_and_backward(waypoints): - import torch - - from egomimic.models.denoising_nets import ConditionalUnet1D - - model = ConditionalUnet1D( - input_dim=11, - cond_dim=276, - ac_latent_seq=1, - diffusion_step_embed_dim=16, - down_dims=[8, 16, 32], - ) - inputs = torch.randn(2, waypoints, 11, requires_grad=True) - output = model(inputs, torch.tensor([1, 3]), torch.randn(2, 276)) - assert output.shape == inputs.shape - output.square().mean().backward() - assert torch.isfinite(inputs.grad).all() - - -def test_model_relocation_changes_only_asset_paths(tmp_path): - assets = tmp_path / "assets" - assets.mkdir() - (assets / "texture.png").write_bytes(b"texture") - suite = tmp_path / "robosuite" - suite.mkdir() - (suite / "robot.stl").write_bytes(b"mesh") - xml = '' - updated = rebase_demo_xml(xml, suite, assets) - assert 'timestep="0.002"' in updated - assert str(assets / "texture.png") in updated - assert str(suite / "robot.stl") in updated - with pytest.raises(FileNotFoundError): - rebase_demo_xml(xml.replace("robot.stl", "missing.stl"), suite, assets) - - -def test_legacy_task_binding_preserves_goal_and_saved_physics(): - bddl = """(:objects new_salad_dressing_1 - new_salad_dressing) - (:goal (And (In new_salad_dressing_1 wooden_tray_1_contain_region))) - ; region: table_new_salad_dressing_init_region - """ - xml = '' - updated, aliases = demo_task_definition(bddl, xml) - assert updated == bddl.replace("new_salad_dressing_1", "salad_dressing_1").replace( - "- new_salad_dressing)", "- salad_dressing)" - ) - assert aliases == { - "new_salad_dressing_1": "salad_dressing_1", - "new_salad_dressing": "salad_dressing", - } - # Current recordings retain their original task definition unchanged. - current_xml = xml.replace("salad_dressing_1_main", "new_salad_dressing_1_main") - assert demo_task_definition(bddl, current_xml) == (bddl, {}) - assert demo_task_definition(bddl, "") == (bddl, {}) - - -def test_candidate_names_distinguish_rotation_horizon_and_metric_radius(spec): - candidate = dict(num_waypoints=16, max_translation=0.1, max_rotation_degrees=24) - assert candidate_id(candidate) == "R24_D0.1_M16" - bad = copy.deepcopy(spec) - bad["waypoints"] = [33] - with pytest.raises(ValueError, match="multiple of four"): - candidates_from_spec(bad) - - -def test_success_first_prefers_more_successes_before_fewer_tokens(spec): - summary = { - "small": { - "retention": 1, - "successes": 10, - "success_rate": 0.5, - "raw_successes": 10, - "episodes": 20, - "action_mse": 0.01, - } - } - summary["large"] = {**summary["small"], "successes": 11, "success_rate": 0.55} - candidates = {"small": {"num_waypoints": 8}, "large": {"num_waypoints": 16}} - assert rank_candidates(summary, candidates, spec) == ["small", "large"] - spec["selection_objective"] = "success_then_tokens" - assert rank_candidates(summary, candidates, spec) == ["large", "small"] - - -@pytest.mark.parametrize( - "invalid", [None, "suite", "codec", "dependency", "precision", "incomplete"] -) -def test_refinement_reuses_only_compatible_calibration( - tmp_path, monkeypatch, spec, invalid -): - import importlib.metadata - import io - import json - - from egomimic.benchmarks.libero.cluster import DATA_REVISION, digest - - root = Path(__file__).parents[1] - candidate = dict(num_waypoints=16, max_translation=None, max_rotation_degrees=None) - key = candidate_id(candidate) - summary = { - "raw": {"success_rate": 1, "episodes": 20}, - "raw_repeat": {"max_state_l2_vs_raw_mean": 0, "episodes": 20}, - "dense": {"max_action_mse": 0, "episodes": 20}, - key: {"episodes": 19 if invalid == "incomplete" else 20}, - } - versions = { - name: "1.0.0" for name in ("numpy", "scipy", "mujoco", "robosuite", "libero") - } - monkeypatch.setattr( - importlib.metadata, - "version", - lambda name: "2.0.0" if invalid == "dependency" else "1.0.0", - ) - parent_spec = copy.deepcopy(spec) - if invalid == "precision": - parent_spec["action_dtype"] = "float64" - values = { - "runtime.json": {"suite": "libero_goal" if invalid == "suite" else "libero_10"}, - "spec.json": parent_spec, - "environment-audit.json": { - "codec_sha256": "wrong" - if invalid == "codec" - else digest(root / "egomimic/rldb/zarr/libero_arc.py"), - "versions": versions, - }, - "data-source.json": {"revision": DATA_REVISION}, - "screen.json": { - "candidates": {key: candidate}, - "execution_coverage": {key: 1.0}, - }, - "calibration.json": summary, - } - requested = [] - - class Storage: - def get_object(self, Bucket, Key): - name = Key.rsplit("/", 1)[1] - requested.append(name) - return {"Body": io.BytesIO(json.dumps(values[name]).encode())} - - if invalid: - with pytest.raises(ValueError): - load_calibration_parent( - Storage(), "parent-run", "libero_10", spec, tmp_path - ) - else: - eligible, recovered = load_calibration_parent( - Storage(), "parent-run", "libero_10", spec, tmp_path - ) - assert eligible == {key: candidate} and recovered == summary - assert json.loads((tmp_path / "calibration-parent.json").read_text())[ - "reused_only_calibration" - ] - assert not {"selection.json", "confirmation.json", "result.json"} & set(requested) - - -def _pool_probe_task(index): - import os - - return {"task": "probe", "demo": index, "pid": os.getpid()} - - -def test_recycling_workers_retains_every_episode_once(tmp_path): - import multiprocessing - - with multiprocessing.get_context("spawn").Pool(2, maxtasksperchild=1) as pool: - rows = run_jobs(pool, _pool_probe_task, list(range(4)), tmp_path, "probe") - assert [row["demo"] for row in rows] == list(range(4)) - assert len({row["pid"] for row in rows}) == 4 - assert len(list((tmp_path / "probe").glob("*.json"))) == 4 - - -def test_raw_cache_is_verified_and_never_overwritten(tmp_path, monkeypatch): - import hashlib - import io - import json - - from egomimic.benchmarks.libero import replay - - cache = tmp_path / "cache" - source = cache / "libero_10/task_demo.hdf5" - source.parent.mkdir(parents=True) - source.write_bytes(b"official demonstration") - metadata = { - "sha": replay.DATA_REVISION, - "siblings": [ - { - "rfilename": "libero_10/task_demo.hdf5", - "lfs": {"sha256": hashlib.sha256(source.read_bytes()).hexdigest()}, - } - ], - } - monkeypatch.setenv("LIBERO_RAW_CACHE", str(cache)) - monkeypatch.setattr(replay, "TASKS", {"libero_10": ["task"]}) - monkeypatch.setattr( - replay.urllib.request, - "urlopen", - lambda *a, **k: io.BytesIO(json.dumps(metadata).encode()), - ) - monkeypatch.setattr( - replay, "download", lambda *a, **k: pytest.fail("Cache must not download") - ) - evidence = tmp_path / "evidence" - evidence.mkdir() - assert stage_raw_dataset(tmp_path / "root", "libero_10", evidence) == source.parent - assert not (tmp_path / "root").exists() - source.write_bytes(b"corrupt cached data") - with pytest.raises(ValueError, match="checksum"): - stage_raw_dataset(tmp_path / "root", "libero_10", evidence) - assert source.read_bytes() == b"corrupt cached data" diff --git a/tests/test_libero_saved_hydra_context.py b/tests/test_libero_saved_hydra_context.py deleted file mode 100644 index e622c66ab2..0000000000 --- a/tests/test_libero_saved_hydra_context.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Real Hydra/OmegaConf restoration, no model/data/cluster execution.""" - -import hashlib -import json -import os -from pathlib import Path -from unittest.mock import patch - -import pytest -from hydra import __version__ as hydra_version -from hydra.conf import HydraConf -from hydra.core.hydra_config import HydraConfig -from omegaconf import OmegaConf - -from egomimic.benchmarks.libero.native_launch_profiles import PROFILES -from egomimic.benchmarks.libero.saved_hydra_context import load_saved_native_config - - -def setup(tmp_path, suite): - repo = tmp_path / "source" - repo.mkdir() - run = tmp_path / "run" - (run / ".hydra").mkdir(parents=True) - p = PROFILES[suite] - receipt = tmp_path / "receipt.json" - logical_sha256 = "a" * 64 - receipt.write_text( - json.dumps( - dict( - suite=suite, - replay_path="/replay", - dataset_logical_sha256=logical_sha256, - ) - ) - ) - proof = tmp_path / "physical.json" - proof.write_text("{}") - - def sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - binding = dict( - source_commit="head", - data_root="/replay", - dataset_receipt_path=str(receipt), - dataset_receipt_sha256=sha(receipt), - physical_proof_path=str(proof), - physical_proof_sha256=sha(proof), - ) - config = dict( - name=p.name, - benchmark=dict(suite=suite, dataset="${oc.env:" + p.replay_environment + "}"), - run_provenance=dict( - source_commit="head", - dataset_sha256=logical_sha256, - dataset_content_aggregate_sha256=logical_sha256, - ), - norm_stats=dict(native_saved_state_binding=binding), - data={ - group: { - "libero_panda": { - "resolver": dict( - suite="${benchmark.suite}", - folder_path="${oc.env:" + p.replay_environment + "}", - ) - } - } - for group in ("train_datasets", "valid_datasets") - }, - evaluator=dict( - energy_seed_bank_path="${hydra:runtime.cwd}/egomimic/hydra_configs/evaluator/energy_score_seed_bank_k32_v1.json" - ), - ) - OmegaConf.save(OmegaConf.create(config), run / ".hydra/config.yaml") - hydra = OmegaConf.structured(HydraConf) - hydra.runtime.cwd = str(repo) - hydra.runtime.output_dir = str(run) - hydra.runtime.version = hydra_version - saved = OmegaConf.to_container(hydra, resolve=False) - saved["env"] = {} - OmegaConf.save(OmegaConf.create({"hydra": saved}), run / ".hydra/hydra.yaml") - return repo, run, p - - -@pytest.mark.parametrize("suite", list(PROFILES)) -def test_saved_context_preserves_all_fields_and_missing_env( - tmp_path, monkeypatch, suite -): - repo, run, p = setup(tmp_path, suite) - monkeypatch.delenv(p.replay_environment, raising=False) - with patch("subprocess.check_output", return_value="head\n"): - cfg = load_saved_native_config(run, repo) - assert os.environ[p.replay_environment] == "/replay" - assert cfg.benchmark.dataset == "/replay" - assert OmegaConf.get_type(HydraConfig.get()) is HydraConf - assert dict(HydraConfig.get().env) == {} - saved = OmegaConf.to_container( - OmegaConf.load(run / ".hydra/hydra.yaml").hydra, resolve=False, enum_to_str=True - ) - actual = OmegaConf.to_container(HydraConfig.get(), resolve=False, enum_to_str=True) - assert all(actual[k] == v for k, v in saved.items()) - - -@pytest.mark.parametrize( - "fault", - ["conflict", "unknown_env", "cwd", "output", "source", "receipt", "resolver"], -) -def test_context_rejects_identity_drift(tmp_path, monkeypatch, fault): - repo, run, p = setup(tmp_path, "libero_spatial") - monkeypatch.delenv(p.replay_environment, raising=False) - cfg = OmegaConf.load(run / ".hydra/config.yaml") - saved = OmegaConf.load(run / ".hydra/hydra.yaml") - if fault == "conflict": - monkeypatch.setenv(p.replay_environment, "/wrong") - if fault == "unknown_env": - cfg.extra = "${oc.env:UNEXPECTED}" - if fault == "cwd": - saved.hydra.runtime.cwd = "/wrong" - if fault == "output": - saved.hydra.runtime.output_dir = "/wrong" - if fault == "source": - cfg.run_provenance.source_commit = "wrong" - if fault == "receipt": - Path(cfg.norm_stats.native_saved_state_binding.dataset_receipt_path).write_text( - "{}" - ) - if fault == "resolver": - cfg.data.valid_datasets.libero_panda.resolver.folder_path = "/wrong" - OmegaConf.save(cfg, run / ".hydra/config.yaml") - OmegaConf.save(saved, run / ".hydra/hydra.yaml") - with ( - patch("subprocess.check_output", return_value="head\n"), - pytest.raises(ValueError), - ): - load_saved_native_config(run, repo) - - -@pytest.mark.parametrize("fault", [None, "context", "source", "resolver"]) -def test_joint_saved_context_uses_complete_context_without_historical_binding( - tmp_path, monkeypatch, fault -): - import hydra - - from egomimic.benchmarks.libero.native_launch_profiles import joint_phase_argv - - repo = Path(__file__).resolve().parents[1] - run = tmp_path / "joint" - (run / ".hydra").mkdir(parents=True) - context = tmp_path / "context.json" - split = tmp_path / "split.json" - context.write_text("{}") - split.write_text("{}") - - def sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - args = dict( - source_root=str(repo), - original_root=str(tmp_path / "original"), - sharpa_root=str(tmp_path / "sharpa"), - output_root=str(run), - context_path=str(context), - split_path=str(split), - split_sha256=sha(split), - normalization_sha256=sha(context), - source_commit="c" * 40, - diagnostic_seed_bank=str(tmp_path / "seeds.json"), - wandb_entity="test", - wandb_project="test", - wandb_run_id="joint", - ) - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(repo / "egomimic/hydra_configs") - ): - cfg = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=joint_phase_argv(phase="smoke", **args)[1:] - + ["hydra/launcher=basic"], - ) - cfg.hydra.runtime.cwd = str(repo) - cfg.hydra.runtime.output_dir = str(run) - HydraConfig.instance().set_config(cfg) - payload = OmegaConf.to_container( - OmegaConf.masked_copy(cfg, [k for k in cfg if k != "hydra"]), resolve=True - ) - saved = OmegaConf.to_container(cfg.hydra, resolve=False) - if fault == "context": - context.write_text("changed") - if fault == "source": - payload["run_provenance"]["source_commit"] = "wrong" - if fault == "resolver": - payload["data"]["valid_datasets"]["libero_panda_sharpa"]["resolver"][ - "folder_path" - ] = "/wrong" - OmegaConf.save(OmegaConf.create(payload), run / ".hydra/config.yaml") - OmegaConf.save(OmegaConf.create({"hydra": saved}), run / ".hydra/hydra.yaml") - with patch("subprocess.check_output", return_value="c" * 40 + "\n"): - if fault: - with pytest.raises(ValueError): - load_saved_native_config(run, repo) - else: - loaded = load_saved_native_config(run, repo) - assert OmegaConf.to_container(loaded, resolve=True) == payload - - -@pytest.mark.parametrize("fault", [None, "rolling", "wrong_callback", "wrong_path"]) -def test_joint_checkpoint_distinguishes_configured_terminal_from_rolling_alias( - tmp_path, fault -): - from scripts.train.verify_action_flow_training_smoke import ( - _joint_smoke_checkpoint_path, - ) - - cadence = tmp_path / "epoch-0000-step-000000008.ckpt" - terminal = tmp_path / "last.ckpt" - cadence.write_bytes(b"cadence") - terminal.write_bytes(b"terminal") - cfg = OmegaConf.create( - { - "callbacks": { - "model_checkpoint": {"save_last": False}, - "ema": { - "_target_": "egomimic.pl_utils.oat_matched_training.ActionFlowFixedEMACallback", - "final_checkpoint_path": str(terminal), - }, - } - } - ) - if fault == "rolling": - cfg.callbacks.model_checkpoint.save_last = True - if fault == "wrong_callback": - cfg.callbacks.ema._target_ = "unknown.Callback" - if fault == "wrong_path": - cfg.callbacks.ema.final_checkpoint_path = "/wrong" - if fault: - with pytest.raises(Exception, match="rolling last"): - _joint_smoke_checkpoint_path(tmp_path, cfg) - else: - assert _joint_smoke_checkpoint_path(tmp_path, cfg) == cadence - assert terminal.read_bytes() == b"terminal" - with pytest.raises(Exception, match="rolling last"): - _joint_smoke_checkpoint_path(tmp_path) - - -def test_separate_verifier_finalizes_through_same_loaded_helpers(tmp_path, monkeypatch): - from egomimic.benchmarks.libero import saved_hydra_context as loader - from scripts.train import libero_native_launch_contract as producer - from scripts.train import verify_action_flow_training_smoke as shared - - training, verifier, run = [tmp_path / p for p in ("training", "verifier", "run")] - for p in (training, verifier, run): - p.mkdir() - preflight = tmp_path / "preflight.json" - preflight.write_text("{}") - runtime = tmp_path / "runtime.json" - runtime.write_text("{}") - monkeypatch.setattr(shared, "REPOSITORY_ROOT", verifier) - monkeypatch.setattr(shared, "VERIFIER_PROVENANCE", None) - monkeypatch.setattr( - shared, - "_git_head", - lambda repository_root=None: "a" * 40 - if repository_root == verifier - else "b" * 40, - ) - monkeypatch.setattr(shared.subprocess, "check_output", lambda *a, **k: "") - monkeypatch.setattr(shared.subprocess, "run", lambda *a, **k: None) - monkeypatch.setenv("SLURM_STEP_ID", "0") - monkeypatch.setenv("AF_PREFLIGHT_RESULT", str(preflight)) - calls = [] - - def load(root, repo): - assert root == run and repo == training - return OmegaConf.create({"original": True}) - - def evidence(cfg, root, **kwargs): - assert isinstance(cfg, dict) and cfg["original"] and root == run - json.dumps(cfg, allow_nan=False) - assert kwargs["shared"] is shared - calls.append("finalize") - return {"actual": True} - - monkeypatch.setattr(loader, "load_saved_native_config", load) - monkeypatch.setattr(producer, "joint_smoke_evidence", evidence) - monkeypatch.setattr(producer, "write", lambda path, value: calls.append("write")) - monkeypatch.setattr( - shared, - "verify_smoke", - lambda *a, **k: calls.append("verify") or {"run_dir": str(run)}, - ) - assert ( - shared.main( - [ - str(run), - "--experiment", - "libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - "--expected-head", - "b" * 40, - "--training-repo", - str(training), - "--expected-verifier-head", - "a" * 40, - "--finalize-runtime-lock", - str(runtime), - "--expected-preflight-sha256", - hashlib.sha256(preflight.read_bytes()).hexdigest(), - ] - ) - == 0 - ) - assert calls == ["finalize", "write", "verify"] - - -def test_joint_artifacts_bind_cadence_epoch_and_preserve_postfit(tmp_path, monkeypatch): - import torch - - from scripts.train import verify_action_flow_training_smoke as shared - - monkeypatch.delenv("SLURM_JOB_ID", raising=False) - run = tmp_path / "run" - checkpoint = run / "checkpoints/epoch-0000-step-000000008.ckpt" - checkpoint.parent.mkdir(parents=True) - checkpoint.write_bytes(b"filename-only-selection") - root = run / "artifacts" - for epoch in (0, 1): - path = root / f"epoch-{epoch}-step-8/rank-0-batch-0.pt" - path.parent.mkdir(parents=True) - torch.save({"global_step": 8, "epoch": epoch}, path) - epoch = shared._joint_smoke_artifact_epoch(run, None) - assert epoch == 0 - selected, payload = shared._step_artifact( - root, label="joint", expected_step=8, expected_epoch=epoch - ) - assert selected.parent.name == "epoch-0-step-8" and payload["epoch"] == 0 - assert (root / "epoch-1-step-8/rank-0-batch-0.pt").is_file() - with pytest.raises(shared.SmokeVerificationError, match="expected one step-8"): - shared._step_artifact(root, label="joint", expected_step=8) - torch.save({"global_step": 8, "epoch": 1}, selected) - with pytest.raises(shared.SmokeVerificationError, match="epoch differs"): - shared._step_artifact(root, label="joint", expected_step=8, expected_epoch=0) diff --git a/tests/test_libero_saved_native_state.py b/tests/test_libero_saved_native_state.py deleted file mode 100644 index 464c04821f..0000000000 --- a/tests/test_libero_saved_native_state.py +++ /dev/null @@ -1,159 +0,0 @@ -import hashlib -import json -import tempfile -import unittest -from pathlib import Path -from types import SimpleNamespace - -from egomimic.rldb.zarr.libero_saved_state import validate_receipt_contract - - -class Guards(unittest.TestCase): - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - root = Path(self.tmp.name).resolve() - data = dict( - replay_path=str(root), - suite="libero10", - split_seed=42, - valid_ratio=0.01, - dataset_logical_sha256="d" * 64, - train_episode_indices=[0, 1], - valid_episode_indices=[2], - episodes=3, - frames=30, - ) - split = { - k: data[k] - for k in ( - "train_episode_indices", - "valid_episode_indices", - "split_seed", - "valid_ratio", - ) - } - split.update( - schema_version=1, - artifact_kind="libero_native_episode_split", - historical_split_manifest_sha256="h" * 64, - serialization_replacement_requires_parent_review=True, - dataset_logical_sha256="d" * 64, - replay_path=str(root), - suite="libero10", - episodes=3, - recipe="test fixture", - train_episode_ids=["episode_000000", "episode_000001"], - valid_episode_ids=["episode_000002"], - physical_frame_range_proof="OPEN", - ) - split["dataset_receipt_sha256"] = hashlib.sha256( - json.dumps(data).encode() - ).hexdigest() - self.binding = dict( - path="unneeded", - file_sha256="n" * 64, - normalizer_module_sha256="m" * 64, - source_commit="s" * 40, - data_root=str(root), - normalizer_target="egomimic.rldb.zarr.libero_action_flow.LiberoActionFlowNormalizer", - ) - for name, value in (("dataset", data), ("split", split)): - file = root / (name + ".json") - file.write_text(json.dumps(value)) - self.binding[name + "_receipt_path"] = str(file) - self.binding[name + "_receipt_sha256"] = hashlib.sha256( - file.read_bytes() - ).hexdigest() - proof = dict( - schema_version=1, - status="PASS", - split_manifest_sha256=self.binding["split_receipt_sha256"], - dataset_receipt_sha256=self.binding["dataset_receipt_sha256"], - data_root=str(root), - episode_ends_sha256="e" * 64, - episode_count=3, - frames=30, - zero_frame_overlap=True, - complete_frame_coverage=True, - actual_selected_train_ids=["episode_000000", "episode_000001"], - train_ranges=[[0, 0, 10], [1, 10, 20]], - valid_ranges=[[2, 20, 30]], - ) - pf = root / "physical.json" - pf.write_text(json.dumps(proof)) - self.binding["physical_proof_path"] = str(pf) - self.binding["physical_proof_sha256"] = hashlib.sha256( - pf.read_bytes() - ).hexdigest() - self.prov = dict( - historical_split_manifest_sha256="h" * 64, - source_commit="s" * 40, - normalization_sha256="n" * 64, - split_manifest_sha256=self.binding["split_receipt_sha256"], - dataset_sha256="d" * 64, - train_episode_count=2, - valid_episode_count=1, - union_episode_count=3, - ) - self.ds = SimpleNamespace( - resolver=SimpleNamespace(folder_path=str(root), suite="libero10"), - split_seed=42, - val_ratio=0.01, - datasets={"episode_000000": None, "episode_000001": None}, - ) - - def tearDown(self): - self.tmp.cleanup() - - def call(self): - return validate_receipt_contract( - self.binding, self.prov, self.ds, source_head="s" * 40 - ) - - def test_good(self): - self.assertEqual(self.call()["training_episode_count"], 2) - - def test_source(self): - self.prov["source_commit"] = "x" - with self.assertRaisesRegex(ValueError, "source"): - self.call() - - def test_receipt_hash(self): - self.binding["dataset_receipt_sha256"] = "0" * 64 - with self.assertRaisesRegex(ValueError, "identity"): - self.call() - - def test_actual_selection(self): - self.ds.datasets = {"episode_000002": None} - with self.assertRaisesRegex(ValueError, "actual selected"): - self.call() - - def test_path(self): - self.ds.resolver.folder_path = "/wrong" - with self.assertRaisesRegex(ValueError, "path"): - self.call() - - def test_missing_field(self): - del self.binding["data_root"] - with self.assertRaisesRegex(ValueError, "schema"): - self.call() - - def test_open_physical_proof(self): - pf = Path(self.binding["physical_proof_path"]) - q = json.loads(pf.read_text()) - q["status"] = "OPEN" - pf.write_text(json.dumps(q)) - self.binding["physical_proof_sha256"] = hashlib.sha256( - pf.read_bytes() - ).hexdigest() - with self.assertRaisesRegex(ValueError, "nonpassing"): - self.call() - - def test_missing_physical_proof(self): - del self.binding["physical_proof_path"] - with self.assertRaisesRegex(ValueError, "schema"): - self.call() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_libero_training_checks.py b/tests/test_libero_training_checks.py deleted file mode 100644 index f8b9f4ffbd..0000000000 --- a/tests/test_libero_training_checks.py +++ /dev/null @@ -1,85 +0,0 @@ -import copy -import json -from types import SimpleNamespace - -import numpy as np -import pytest -from omegaconf import OmegaConf - -from egomimic.benchmarks.libero.training_checks import ( - StepTiming, - check_weights_only_payload, -) -from egomimic.trainHydra import _build_model_config_tree - - -def config(): - return OmegaConf.create( - { - "model": {"pipeline": {"stages": [{"codec": {"basis": "uniform"}}]}}, - "run_provenance": {"basis": "uniform", "source_commit": "exact"}, - "logger": {"wandb": {"id": "exact-run"}}, - "paths": {"output_dir": "/not-the-callback-path"}, - "trainer": {"devices": 4, "max_steps": 16}, - "callbacks": {"batch_budget": {"global_batch_size": 1024}}, - } - ) - - -def test_timing_accepts_actual_minimal_checkpoint_config(tmp_path): - saved = _build_model_config_tree(config()) - assert set(saved) == {"model", "run_provenance"} and "paths" not in saved - model = SimpleNamespace(hparams=SimpleNamespace(config_tree=saved)) - trainer = SimpleNamespace( - global_step=1, global_rank=0, world_size=4, sanity_checking=False - ) - callback = StepTiming(tmp_path) - callback.on_train_batch_start(trainer, model, None, 0) - callback.on_train_batch_end(trainer, model, None, None, 0) - callback.on_validation_start(trainer, model) - callback.on_validation_end(trainer, model) - callback.on_train_end(trainer, model) - receipt = json.loads((tmp_path / "timing-rank-0.json").read_text()) - assert receipt["global_step"] == 1 and receipt["validations"][0]["step"] == 1 - trainer.global_step = 0 - with pytest.raises(AssertionError): - callback.on_validation_start(trainer, model) - - -@pytest.mark.parametrize( - "bad", [None, "optimizer", "step", "ema", "basis", "run", "norm", "budget"] -) -def test_weights_only_identity_uses_real_schema(bad): - cfg = config() - norm = {"benchmark_context": {"data": "exact"}, "stats": {"scale": np.ones(7)}} - payload = { - "global_step": 16, - "ema_num_updates": 16, - "ema_state_dict": {}, - "training_budget": { - "world_size": 4, - "global_batch_size": 1024, - "total_optimizer_steps": 16, - }, - "hyper_parameters": {"config_tree": _build_model_config_tree(cfg)}, - "normalizer_state": copy.deepcopy(norm), - } - if bad == "optimizer": - payload["optimizer_states"] = [] - elif bad in ("step", "ema"): - payload["global_step" if bad == "step" else "ema_num_updates"] = 8 - elif bad == "basis": - payload["hyper_parameters"]["config_tree"].model.pipeline.stages[ - 0 - ].codec.basis = "fourier" - elif bad == "run": - payload["hyper_parameters"]["config_tree"].run_provenance.run_id = "wrong" - elif bad == "norm": - payload["normalizer_state"]["stats"]["scale"][0] = 2 - elif bad == "budget": - payload["training_budget"]["global_batch_size"] = 512 - if bad is None: - check_weights_only_payload(payload, cfg, 16, norm) - else: - with pytest.raises(AssertionError): - check_weights_only_payload(payload, cfg, 16, norm) diff --git a/tests/test_libero_tuned_configs.py b/tests/test_libero_tuned_configs.py deleted file mode 100644 index 21b0371c38..0000000000 --- a/tests/test_libero_tuned_configs.py +++ /dev/null @@ -1,79 +0,0 @@ -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import OmegaConf - -from egomimic.benchmarks.libero.cluster import ( - configure_global_basis_candidate, - global_basis_training_arguments, -) - - -def composed(basis, tmp_path): - args = global_basis_training_arguments( - basis, tmp_path / "data", tmp_path, "full", "test-run", gpus=4 - ) - with initialize_config_dir( - config_dir=str(Path(__file__).parents[1] / "egomimic/hydra_configs"), - version_base="1.3", - ): - return compose( - config_name="train_zarr_cartesian", - overrides=args[3:] + [f"paths.work_dir={tmp_path}"], - ) - - -def test_uniform_config_is_unchanged_and_rejects_tuning(tmp_path): - cfg = composed("uniform", tmp_path) - original = OmegaConf.to_container(cfg, resolve=True) - assert configure_global_basis_candidate(cfg) is cfg - assert OmegaConf.to_container(cfg, resolve=True) == original - for override in ({"geometry": 64}, {"causal_attn": False}, {"token_affine": {}}): - with pytest.raises(ValueError, match="frozen"): - configure_global_basis_candidate(cfg, **override) - - -@pytest.mark.parametrize("basis", ["fourier", "chebyshev"]) -def test_tuned_dims_attention_and_self_contained_normalization(tmp_path, basis): - grid = "chebyshev_lobatto" if basis == "chebyshev" else "uniform" - affine = { - "kind": "bounded_train_std_v1", - "basis": basis, - "geometry": 128, - "clock": 31, - "geometry_fit_grid": grid, - "fit_split": "train_only", - "training_windows_sha256": "a" * 64, - "center": [[0.0] * 12] * 104, - "scale": [[0.01] * 12] * 104, - } - cfg = configure_global_basis_candidate( - composed(basis, tmp_path), - geometry=128, - geometry_fit_grid=grid, - causal_attn=False, - token_affine=affine, - ) - model = instantiate(cfg.model.pipeline, device="cpu") - stages = model.pipeline.stages - assert stages[1].representation_context() == stages[-1].representation_context() - assert stages[1].codec.num_waypoints == 104 - assert stages[2].action_horizon == stages[3].action_horizon == 104 - # GraphDiffusionTransformer wraps the maintained OAT Transformer. - assert cfg.model.pipeline.stages[3].policy.model.causal_attn is False - assert sum(p.numel() for p in model.nets.parameters()) == 27_203_476 - assert ( - sum(p.numel() for p in model.nets.parameters() if p.requires_grad) == 27_203_404 - ) - assert cfg.model.benchmark_protocol.horizon == 32 - assert cfg.model.benchmark_protocol.n_action_steps == 16 - path = tmp_path / f"{basis}.yaml" - OmegaConf.save(cfg, path, resolve=True) - loaded = OmegaConf.load(path) - assert ( - loaded.model.pipeline.stages[1].token_affine - == loaded.model.pipeline.stages[-1].token_affine - ) - assert "${" not in path.read_text() diff --git a/tests/test_likelihood_training_behavior_parity.py b/tests/test_likelihood_training_behavior_parity.py deleted file mode 100644 index f15916de2c..0000000000 --- a/tests/test_likelihood_training_behavior_parity.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Compare likelihood mechanics against the retained source-bound wrapper.""" - -import ast -import copy -import hashlib -import json -from pathlib import Path - -import pytest -import torch -import yaml - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.pl_utils.pl_model_action_flow_likelihood import ( - ActionFlowLikelihoodModelWrapper, -) -from egomimic.pl_utils.training_behavior_action_flow_likelihood import ( - ActionFlowLikelihoodTrainingBehavior, -) -from tests.test_action_flow_likelihood import batch, stages - - -def test_likelihood_recipe_changes_only_framework_selection(): - root = Path(__file__).resolve().parents[1] - path = "egomimic/hydra_configs/model/bf/us_action_flow_bc_bridge_likelihood.yaml" - candidate = yaml.safe_load((root / path).read_text()) - assert candidate.pop("_target_") == "egomimic.pl_utils.pl_model.ModelWrapper" - assert candidate.pop("training_behavior") == { - "_target_": "egomimic.pl_utils.training_behavior_action_flow_likelihood.ActionFlowLikelihoodTrainingBehavior" - } - # Canonical YAML payload (without the framework target), independently - # extracted from exact training source7c253e5. No full-history checkout is - # required in shallow CI, and every scientific field is included. - digest = hashlib.sha256( - json.dumps(candidate, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() - assert digest == "e55a71bec35d62e694119ea3c42fed1a8e7855f2d97fc3e7310c21a37831e033" - - -def test_port_preserves_historical_tensor_and_checkpoint_algorithms(): - root = Path(__file__).resolve().parents[1] / "egomimic/pl_utils" - - def methods(path, name): - tree = ast.parse((root / path).read_text()) - cls = next( - node - for node in tree.body - if isinstance(node, ast.ClassDef) and node.name == name - ) - return { - node.name: node for node in cls.body if isinstance(node, ast.FunctionDef) - } - - historical = methods( - "pl_model_action_flow_likelihood.py", "ActionFlowLikelihoodModelWrapper" - ) - candidate = methods( - "training_behavior_action_flow_likelihood.py", - "ActionFlowLikelihoodTrainingBehavior", - ) - - class ContextToHistorical(ast.NodeTransformer): - def visit_Attribute(self, node): - node = self.generic_visit(node) - if ( - isinstance(node.value, ast.Attribute) - and node.value.attr == "context" - and isinstance(node.value.value, ast.Name) - and node.value.value.id == "self" - ): - node.value = ast.Name(id="self", ctx=ast.Load()) - return node - - for name in ( - "_log_telemetry", - "_capture_gradient_routes", - "_log_prediction_metrics", - "validation_step", - "on_save_checkpoint", - ): - port_name = ( - "log_prediction_metrics" if name == "_log_prediction_metrics" else name - ) - port = ContextToHistorical().visit(copy.deepcopy(candidate[port_name])) - port.name = name - assert ast.dump(port, include_attributes=False) == ast.dump( - historical[name], include_attributes=False - ), name - - -@pytest.mark.parametrize("cadence", [0, 1]) -def test_training_validation_checkpoint_and_rng_match_historical(monkeypatch, cadence): - def observe(shared): - torch.manual_seed(73) - algo = PipelineAlgo(stages(dropout=0.3), device="cpu") - wrapper = ( - ModelWrapper( - pipeline=algo, - enable_grad_norm=False, - training_behavior=ActionFlowLikelihoodTrainingBehavior( - gradient_telemetry_cadence=cadence - ), - ) - if shared - else ActionFlowLikelihoodModelWrapper( - pipeline=algo, - enable_grad_norm=False, - gradient_telemetry_cadence=cadence, - ) - ) - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **kw: logged.update( - {name: (torch.as_tensor(value).detach().clone(), kw)} - ), - ) - optimizer = torch.optim.SGD(wrapper.parameters(), lr=0.001) - losses, gradients = [], [] - for index in range(2): - optimizer.zero_grad() - loss = wrapper.training_step({"example": batch()}, index) - losses.append(loss.detach().clone()) - loss.backward() - gradients.append( - [ - None if p.grad is None else p.grad.detach().clone() - for p in wrapper.parameters() - ] - ) - wrapper.on_after_backward() - wrapper.on_before_optimizer_step(optimizer) - optimizer.step() - wrapper.eval() - before = torch.get_rng_state().clone() - with torch.inference_mode(): - wrapper.validation_step({"example": batch(), "inactive": None}, 2, 1) - # batch() constructs random data, so compare resulting RNG across sources. - after = torch.get_rng_state().clone() - checkpoint = {} - wrapper.on_save_checkpoint(checkpoint) - return ( - copy.deepcopy(wrapper.nets.state_dict()), - losses, - gradients, - {k: v for k, v in logged.items() if not k.startswith("Timing/")}, - before, - after, - {k: v for k, v in checkpoint.items() if k.startswith("action_flow_")}, - ) - - historical, candidate = observe(False), observe(True) - assert historical[0].keys() == candidate[0].keys() - for key in historical[0]: - assert torch.equal(historical[0][key], candidate[0][key]), key - for a, b in zip(historical[1], candidate[1]): - assert torch.equal(a, b) - for left, right in zip(historical[2], candidate[2]): - for a, b in zip(left, right): - assert (a is None) == (b is None) - if a is not None: - assert torch.equal(a, b) - assert historical[3].keys() == candidate[3].keys() - for key, (value, options) in historical[3].items(): - assert torch.equal(value, candidate[3][key][0]), key - assert options == candidate[3][key][1], key - assert torch.equal(historical[4], candidate[4]) - assert torch.equal(historical[5], candidate[5]) - assert historical[6] == candidate[6] diff --git a/tests/test_load_hook_runtime_compatibility.py b/tests/test_load_hook_runtime_compatibility.py deleted file mode 100644 index 6630f54d40..0000000000 --- a/tests/test_load_hook_runtime_compatibility.py +++ /dev/null @@ -1,32 +0,0 @@ -from egomimic.utils.runtime_compatibility import register_module_load_pre_hook - - -def test_public_module_aware_hook_api_remains_primary(): - class Modern: - def register_load_state_dict_pre_hook(self, hook): - self.hook = hook - return "public-handle" - - module = Modern() - hook = object() - assert register_module_load_pre_hook(module, hook) == "public-handle" - assert module.hook is hook - - -def test_legacy_api_explicitly_requests_module_argument(): - class Legacy: - def _register_load_state_dict_pre_hook(self, hook, *, with_module): - self.binding = hook, with_module - return "legacy-handle" - - module = Legacy() - hook = object() - assert register_module_load_pre_hook(module, hook) == "legacy-handle" - assert module.binding == (hook, True) - - -def test_unsupported_api_fails_closed(): - import pytest - - with pytest.raises(TypeError, match="module-aware"): - register_module_load_pre_hook(object(), object()) diff --git a/tests/test_local_cpu_storage_guard.py b/tests/test_local_cpu_storage_guard.py deleted file mode 100644 index dd11a6d4d2..0000000000 --- a/tests/test_local_cpu_storage_guard.py +++ /dev/null @@ -1,25 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from scripts.local_cpu_storage_guard import pytest_runtest_setup - - -@pytest.mark.parametrize("free_gib", [3, 4, 8]) -def test_per_test_headroom_gate(monkeypatch, free_gib): - monkeypatch.delenv("LOCAL_CPU_TEST_MIN_FREE_KIB", raising=False) - monkeypatch.setattr( - "scripts.local_cpu_storage_guard.shutil.disk_usage", - lambda path: SimpleNamespace(free=free_gib * 1024**3), - ) - if free_gib < 4: - with pytest.raises(pytest.UsageError, match="blocked before test"): - pytest_runtest_setup(None) - else: - pytest_runtest_setup(None) - - -def test_per_test_floor_cannot_be_lowered(monkeypatch): - monkeypatch.setenv("LOCAL_CPU_TEST_MIN_FREE_KIB", "1") - with pytest.raises(pytest.UsageError, match="cannot be below"): - pytest_runtest_setup(None) diff --git a/tests/test_local_cpu_validation_guard.py b/tests/test_local_cpu_validation_guard.py deleted file mode 100644 index 3b17dc3fa0..0000000000 --- a/tests/test_local_cpu_validation_guard.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Local validation limits cannot be lowered into misleading storage failures.""" - -import os -import subprocess -from pathlib import Path - -import pytest - - -@pytest.mark.parametrize("value", ["0", "4194303", "invalid", "999999999999"]) -def test_low_invalid_or_unavailable_space_blocks_before_python(value): - script = Path(__file__).resolve().parents[1] / "scripts/run_local_cpu_validation.sh" - env = dict( - os.environ, - LOCAL_CPU_TEST_MIN_FREE_KIB=value, - LOCAL_CPU_TEST_PYTHON="/does-not-exist", - ) - result = subprocess.run( - ["bash", str(script), "--version"], env=env, capture_output=True, text=True - ) - assert result.returncode == 64 - assert "Local CPU validation" in result.stderr - assert "/does-not-exist" not in result.stderr - - -@pytest.mark.parametrize( - ("target", "timeout"), - [("tests/test_recursive_config_audit.py", 600), ("tests/test_oat_native.py", 60)], -) -def test_long_constructor_audit_has_its_own_diagnostic_threshold(target, timeout): - script = Path(__file__).resolve().parents[1] / "scripts/run_local_cpu_validation.sh" - # Print the runner's invocation, without executing or skipping any real test. - result = subprocess.run( - ["bash", str(script), target], - env=dict(os.environ, LOCAL_CPU_TEST_PYTHON="/bin/echo"), - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr - assert f"faulthandler_timeout={timeout}" in result.stdout diff --git a/tests/test_local_episode_resolver_override.py b/tests/test_local_episode_resolver_override.py deleted file mode 100644 index 05d62e86b9..0000000000 --- a/tests/test_local_episode_resolver_override.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from egomimic.rldb.zarr import zarr_dataset_multi as dataset_module - - -class _FakeZarrEpisode: - def __init__(self, _path): - self.metadata = { - "total_frames": 1, - "embodiment": "PUSHSHAPES_SIM", - "features": {}, - } - - def _collect_keys(self): - return [] - - @property - def intrinsics(self): - return None - - -def test_local_resolver_applies_override_before_leaf_initialization( - tmp_path, monkeypatch -): - monkeypatch.setattr(dataset_module, "ZarrEpisode", _FakeZarrEpisode) - episode = tmp_path / "episode.zarr" - episode.mkdir() - resolver = dataset_module.LocalEpisodeResolverWithEmbodimentOverride( - folder_path=tmp_path, - key_map={}, - embodiment_override="pushshapes_sim_u_socket", - ) - - datasets = resolver._load_zarr_datasets(tmp_path, {"episode"}) - - assert datasets["episode"].embodiment == "pushshapes_sim_u_socket" diff --git a/tests/test_logging_run_provenance.py b/tests/test_logging_run_provenance.py deleted file mode 100644 index 5a6968b495..0000000000 --- a/tests/test_logging_run_provenance.py +++ /dev/null @@ -1,108 +0,0 @@ -import json -from types import SimpleNamespace - -import pytest -from lightning_utilities.core.rank_zero import rank_zero_only -from omegaconf import OmegaConf - -from egomimic.utils.logging_utils import configure_runner_wandb, log_hyperparameters - - -def test_logger_keeps_resolved_manifest_provenance(monkeypatch): - monkeypatch.setattr(rank_zero_only, "rank", 0, raising=False) - recorded = [] - logger = SimpleNamespace(log_hyperparams=recorded.append) - trainer = SimpleNamespace(logger=logger, loggers=[logger]) - cfg = OmegaConf.create( - { - "model": {}, - "data": {}, - "trainer": {}, - "seed": 42, - "run_provenance": { - "source_commit": "exact-source", - "split_seed": "${seed}", - }, - } - ) - log_hyperparameters( - { - "cfg": cfg, - "model": SimpleNamespace(parameters=lambda: []), - "trainer": trainer, - } - ) - assert recorded[0]["run_provenance"] == { - "source_commit": "exact-source", - "split_seed": 42, - } - - -def wandb_config(): - return OmegaConf.create( - { - "logger": { - "wandb": { - "_target_": "lightning.pytorch.loggers.wandb.WandbLogger", - "id": "exact-run", - "entity": "team", - "project": "project", - "resume": "allow", - } - } - } - ) - - -def test_actual_automatic_restart_requires_matching_run_and_must_resume(): - cfg = wandb_config() - env = { - "ICE_REQUEUE_OWNER": "runner", - "SLURM_RESTART_COUNT": "1", - "ICE_RESUME_CHECKPOINT": "/run/step-40000.ckpt", - "ICE_RESUME_CHECKPOINT_METADATA_JSON": json.dumps( - { - "wandb_run_id": "exact-run", - "run_id": "exact-run", - "wandb_entity": "team", - "wandb_project": "project", - } - ), - } - configure_runner_wandb(cfg, env) - assert cfg.logger.wandb.resume == "must" - cfg.logger.wandb.id = "accidental-new-run" - with pytest.raises(ValueError, match="identity"): - configure_runner_wandb(cfg, env) - - -def test_fresh_start_rejects_collision_but_initial_pretrained_new_run_is_not_autorequeue(): - cfg = wandb_config() - configure_runner_wandb( - cfg, {"ICE_REQUEUE_OWNER": "runner", "SLURM_RESTART_COUNT": "0"} - ) - assert cfg.logger.wandb.resume == "never" - cfg = wandb_config() - configure_runner_wandb( - cfg, - { - "ICE_REQUEUE_OWNER": "runner", - "SLURM_RESTART_COUNT": "0", - "ICE_RESUME_CHECKPOINT": "/pretrained/different-run.ckpt", - "ICE_RESUME_CHECKPOINT_METADATA_JSON": '{"run_id":"pretrained"}', - }, - ) - assert cfg.logger.wandb.resume == "allow" - - -def test_debug_unchanged_and_automatic_restart_cannot_hide_missing_identity(): - debug = OmegaConf.create({"logger": None}) - configure_runner_wandb( - debug, {"ICE_REQUEUE_OWNER": "runner", "SLURM_RESTART_COUNT": "1"} - ) - assert debug.logger is None - cfg = wandb_config() - with pytest.raises(ValueError, match="runner-selected checkpoint"): - configure_runner_wandb( - cfg, {"ICE_REQUEUE_OWNER": "runner", "SLURM_RESTART_COUNT": "1"} - ) diff --git a/tests/test_matched_cotrain_dp.py b/tests/test_matched_cotrain_dp.py deleted file mode 100644 index e69eccb4c0..0000000000 --- a/tests/test_matched_cotrain_dp.py +++ /dev/null @@ -1,288 +0,0 @@ -from pathlib import Path - -import torch -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from egomimic.pipeline.stages_matched_dp import SharedNativeDPStage - - -def test_shared_native_dp_both_domains_train_infer_and_gradients(): - torch.set_num_threads(1) - stage = SharedNativeDPStage( - down_dims=(8, 16, 32), - step_embed_dim=8, - n_groups=4, - inference_steps=2, - condition_dropout_probability=0, - ) - robot = stage.branches["yam_bimanual"].stages[1].policy.model - human = stage.branches["human_bimanual"].stages[1].policy.model - assert robot.mid_modules is human.mid_modules - assert robot.down_modules[1] is human.down_modules[1] - assert robot.down_modules[0][0] is not human.down_modules[0][0] - assert robot.final_conv[1] is not human.final_conv[1] - for eid, width in ((7, 14), (3, 138)): - stage.zero_grad(set_to_none=True) - condition = torch.randn(1, 256, requires_grad=True) - out = stage.execute( - { - "embodiment": torch.tensor([eid]), - "condition": condition, - "target": torch.randn(1, 100, width), - }, - mode="train", - ) - loss = out["loss/diffusion_noise"] - assert torch.isfinite(loss) - loss.backward() - assert condition.grad.abs().sum() > 0 - assert ( - sum( - p.grad.abs().sum() - for p in robot.mid_modules.parameters() - if p.grad is not None - ) - > 0 - ) - with torch.no_grad(): - pred = stage.execute( - {"embodiment": torch.tensor([eid]), "condition": condition.detach()}, - mode="inference", - )["pred_action"] - assert pred.shape == (1, 100, width) and torch.isfinite(pred).all() - - -def comparable(value): - return ( - OmegaConf.to_container(value, resolve=False) - if OmegaConf.is_config(value) - else value - ) - - -def test_matched_configs(): - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(config_dir=str(root), version_base="1.3"): - a, d = [ - compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=e1/yam_human_matched_" + name, - ], - ) - for name in ("af64_multiplier", "dp_noaug") - ] - for key in ("hpt", "e1", "seed"): - assert comparable(OmegaConf.select(a, key)) == comparable( - OmegaConf.select(d, key) - ), key - for x, y in zip(a.model.pipeline.stages[:3], d.model.pipeline.stages[:3]): - assert comparable(x) == comparable(y) - assert d.model.num_latent_tokens is None and d.model.latent_dim is None - assert d.run_provenance.objective.epsilon_samples_per_content == 1 - assert a.evaluator.action_flow_diagnostics.provenance.latent_shape == [64, 16] - assert ( - a.evaluator.action_flow_diagnostics.provenance.sampler - == a.model.pipeline.flow_inference_method - ) - assert ( - a.model.pipeline.flow_inference_method == "euler" - and d.model.pipeline.dp_inference_steps == 100 - ) - assert (a.model.num_latent_tokens, a.model.latent_dim, a.model.action_horizon) == ( - 64, - 16, - 100, - ) - assert a.data.train_datasets.yam_bimanual.expected_train_episode_count == 375 - assert a.data.train_datasets.human_bimanual.expected_train_episode_count == 37 - assert a.stationary_speed.human_rates == [0.2, 0.4, 0.6, 0.8, 1.0] - assert d.stationary_speed.human_rates == [1.0] - assert d.model.pipeline.conditioning_input == "none" - assert d.run_provenance.speed_augmentation.enabled is False - for domain in ("yam_bimanual", "human_bimanual"): - aug = a.data.train_datasets[domain] - native = d.data.train_datasets[domain] - assert comparable(aug.resolver.transform_list.native_transforms) == comparable( - native.resolver.transform_list.native_transforms - ) - assert native.resolver.transform_list.window_transform._target_.endswith( - "CompleteNativeWindow" - ) - for key in ( - "mode", - "valid_ratio", - "split_seed", - "expected_train_episode_count", - "expected_valid_episode_count", - ): - assert aug[key] == native[key] - assert ( - comparable(aug.resolver)["folder_path"] - == comparable(native.resolver)["folder_path"] - ) - assert a.stationary_speed.yam_rates == d.stationary_speed.yam_rates == [1.0] - assert a.stationary_speed.sample_views_per_source is None - for group, domain, views in ( - ("yam", "yam_bimanual", 1), - ("human", "human_bimanual", 5), - ): - assert ( - a.data.train_datasets[ - domain - ].resolver.transform_list.window_transform.sample_views - == views - ) - assert ( - a.data.valid_datasets[group][ - domain - ].resolver.transform_list.window_transform.sample_views - == views - ) - assert a.stationary_speed.sample_views_by_source[domain] == views - assert d.stationary_speed.sample_views_by_source[domain] == 1 - assert ( - comparable(a.run_provenance)["split_manifest_sha256"] - == "${oc.env:MATCHED_AF_SPLIT_SHA256}" - ) - - -def test_native_af_and_dp_optimizers_and_schedule_contracts(): - from hydra.utils import instantiate - - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - schedules = [] - with initialize_config_dir(config_dir=str(root), version_base="1.3"): - for name in ("af64_multiplier", "dp_noaug"): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=e1/yam_human_matched_" + name, - ], - ) - if name == "af64_multiplier": - assert ( - cfg.model.optimizer._target_ - == "egomimic.utils.unite_optim.ReleasedUniteCompositeOptimizer" - ) - assert cfg.model.optimizer_named_parameters is True - assert cfg.model.optimizer.muon_momentum == 0.95 - continue - opt = instantiate( - cfg.model.optimizer, params=[torch.nn.Parameter(torch.zeros(1))] - )() - assert isinstance(opt, torch.optim.AdamW) - schedule = instantiate(cfg.model.scheduler, optimizer=opt) - if callable(schedule): - schedule = schedule() - assert opt.defaults["weight_decay"] == 1e-6 - assert opt.defaults["eps"] == 1e-8 - assert cfg.model.scheduler.warmup_steps == 500 - assert cfg.model.pipeline.dp_condition_dropout_probability == 0 - assert cfg.callbacks.ema is None and cfg.eval_checkpoint.use_ema is False - assert cfg.trainer.gradient_clip_val == 0 - assert cfg.data.proportional_train_batch_size == 16 - schedules.append(schedule) - assert len(schedules) == 1 - - -def test_strict_dp_checkpoint_preserves_shared_core(): - args = dict(down_dims=(8, 16, 32), step_embed_dim=8, n_groups=4, inference_steps=2) - old = SharedNativeDPStage(**args) - new = SharedNativeDPStage(**args) - new.load_state_dict(old.state_dict(), strict=True) - a = new.branches["yam_bimanual"].stages[1].policy.model - b = new.branches["human_bimanual"].stages[1].policy.model - assert a.mid_modules is b.mid_modules - - -def test_unaugmented_window_guard_preserves_values_and_complete_anchors(): - import numpy as np - - from egomimic.rldb.zarr.episode_split import complete_window_count - from egomimic.rldb.zarr.physical_retiming import CompleteNativeWindow - - for horizon in (30, 100): - guard = CompleteNativeWindow(horizon, ["action"]) - guard.bind_episode({}, {"action": {"horizon": horizon}}) - action = np.arange(horizon)[:, None] - batch = {"action": action, "_retiming_view": 0} - result = guard.transform(batch) - assert result["action"] is action - assert "retiming_rate" not in result and "requested_speed" not in result - assert guard.sample_views == 1 - assert complete_window_count(200, guard.required_frames) == 201 - horizon - - -def test_validation_uses_same_episode_and_native_preprocessing_contract(): - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(config_dir=str(root), version_base="1.3"): - a, d = [ - compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=e1/yam_human_matched_" + name, - ], - ) - for name in ("af64_multiplier", "dp_noaug") - ] - for group, domain in (("yam", "yam_bimanual"), ("human", "human_bimanual")): - for cfg in (a, d): - train = cfg.data.train_datasets[domain] - valid = cfg.data.valid_datasets[group][domain] - assert train.mode == "train" and valid.mode == "valid" - for key in ( - "valid_ratio", - "split_seed", - "expected_train_episode_names_sha256", - "expected_valid_episode_names_sha256", - "expected_train_episode_count", - "expected_valid_episode_count", - "resolver", - ): - assert comparable(train)[key] == comparable(valid)[key] - aug = a.data.valid_datasets[group][domain] - native = d.data.valid_datasets[group][domain] - assert comparable(aug.resolver.transform_list.native_transforms) == comparable( - native.resolver.transform_list.native_transforms - ) - assert ( - aug.resolver.transform_list.window_transform.horizon - == native.resolver.transform_list.window_transform.horizon - ) - assert ( - comparable(d.run_provenance)["split_manifest_sha256"] - == "${oc.env:MATCHED_DP_SPLIT_SHA256}" - ) - assert ( - comparable(d.norm_stats)["precomputed_norm_path"] == "${oc.env:MATCHED_DP_NORM}" - ) - - -def test_composed_dp_window_transforms_instantiate_and_reject_augmentation(): - import pytest - from hydra.utils import instantiate - - from egomimic.rldb.zarr.physical_retiming import CompleteNativeWindow - - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(config_dir=str(root), version_base="1.3"): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=e1/yam_human_matched_dp_noaug", - ], - ) - datasets = list(cfg.data.train_datasets.values()) - for group in cfg.data.valid_datasets.values(): - datasets.extend(group.values()) - for dataset in datasets: - window = instantiate(dataset.resolver.transform_list.window_transform) - assert isinstance(window, CompleteNativeWindow) and window.sample_views == 1 - with pytest.raises(ValueError, match="exactly one"): - CompleteNativeWindow(100, ["actions"], sample_views=5) diff --git a/tests/test_measured_speed_removed.py b/tests/test_measured_speed_removed.py deleted file mode 100644 index 387c844836..0000000000 --- a/tests/test_measured_speed_removed.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Retired measured-speed paths must never become selectable again.""" - -from pathlib import Path - -import pytest -from omegaconf import OmegaConf - -from egomimic.pipeline.stages_speed import ( - SharedSpeedCondition, - build_speed_conditioned_pipeline, - requested_rollout_condition, -) -from egomimic.rldb.zarr.physical_retiming import PhysicalWindowRetiming - -ROOT = Path(__file__).parents[1] - - -@pytest.mark.parametrize("mode", ["native_speed", "physical_speed", "unknown"]) -def test_conditioner_and_factory_reject_retired_modes(mode): - with pytest.raises(ValueError, match="removed"): - SharedSpeedCondition(conditioning_input=mode) - with pytest.raises(ValueError, match="removed"): - build_speed_conditioned_pipeline([], conditioning_input=mode) - - -def test_default_is_direct_multiplier_and_reference_cannot_select_old_mode(): - model = SharedSpeedCondition() - assert model.speed_key == "retiming_rate" - assert model.conditioning_input == "retiming_multiplier" - assert "speed_reference" not in model.state_dict() - with pytest.raises(ValueError, match="removed"): - SharedSpeedCondition(1.0) - with pytest.raises(ValueError, match="retiming_rate"): - SharedSpeedCondition(speed_key="requested_speed") - - -def test_retimer_rejects_retired_mode_before_loading_data(): - with pytest.raises(ValueError, match="removed"): - PhysicalWindowRetiming( - [1.0], - {"left": "pose_wxyz", "right": "pose_wxyz"}, - ["left", "right"], - 30, - timestamp_key="clock", - conditioning_input="native_speed", - ) - - -@pytest.mark.parametrize("value", [None, "native_speed"]) -def test_rollout_never_falls_back_to_measured_speed(value): - pipeline = { - "_target_": "egomimic.pipeline.stages_speed.build_speed_conditioned_pipeline" - } - if value is not None: - pipeline["conditioning_input"] = value - cfg = OmegaConf.create( - {"model": {"pipeline": pipeline}, "deployment": {"requested_speed": 1.0}} - ) - with pytest.raises(ValueError): - requested_rollout_condition(cfg) - - -def test_configs_and_launchers_have_no_retired_routes(): - retired = ( - "human_speed_v1", - "action_flow_cotrain_uc_speed_interpolation", - "yam_human_keypoints_speed_h816_private512_s42", - "AF_STATIONARY_SPEED_REFERENCE", - "AF_SPEED_REFERENCE", - ) - for root in (ROOT / "egomimic/hydra_configs", ROOT / "scripts/train"): - for file in root.rglob("*"): - if file.suffix not in {".yaml", ".sh", ".sbatch", ".py"}: - continue - body = file.read_text() - assert not any(name in body for name in retired), file diff --git a/tests/test_model_wrapper_metrics.py b/tests/test_model_wrapper_metrics.py deleted file mode 100644 index e4c02a2ad3..0000000000 --- a/tests/test_model_wrapper_metrics.py +++ /dev/null @@ -1,166 +0,0 @@ -import csv -from collections import OrderedDict - -import pytest -import torch -from lightning import Trainer -from lightning.pytorch.loggers import CSVLogger -from omegaconf import OmegaConf -from torch.utils.data import DataLoader - -from egomimic.pipeline.stages_planar import PlanarActionMSELoss -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.trainHydra import _instantiate_model_wrapper - - -class _MetricPipeline: - def __init__(self): - self.nets = torch.nn.ModuleDict({"anchor": torch.nn.Linear(1, 1)}) - - @staticmethod - def process_batch_for_training(batch): - return batch - - @staticmethod - def forward_training(batch): - return OrderedDict( - ( - source, - { - "loss/test": torch.tensor(1.0, requires_grad=True), - "log/MSE": values["metric"], - "prediction": values.get("prediction", torch.ones(2)), - }, - ) - for source, values in batch.items() - ) - - @staticmethod - def compute_losses(predictions, _batch): - return { - "loss": torch.stack( - [result["loss/test"] for result in predictions.values()] - ).mean() - } - - @staticmethod - def log_info(_info): - return {} - - -def _wrapper_with_log_capture(monkeypatch): - wrapper = ModelWrapper(pipeline=_MetricPipeline()) - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **kwargs: logged.setdefault(name, (value, kwargs)), - ) - return wrapper, logged - - -def test_training_logs_each_opaque_source_and_equal_source_macro(monkeypatch): - wrapper, logged = _wrapper_with_log_capture(monkeypatch) - batch = OrderedDict( - ( - ("pushshapes_sim_u_socket", {"metric": torch.tensor(2.0)}), - ("another_source", {"metric": 6.0}), - ) - ) - - wrapper.training_step(batch, batch_idx=0) - - expected = { - "Train/MSE/pushshapes_sim_u_socket": 2.0, - "Train/MSE/another_source": 6.0, - "Train/MSE": 4.0, - } - assert expected.keys() <= logged.keys() - for name, expected_value in expected.items(): - value, kwargs = logged[name] - assert float(value) == pytest.approx(expected_value) - assert kwargs == { - "sync_dist": True, - "on_step": False, - "on_epoch": True, - } - - -def test_training_rejects_non_scalar_log_metric_but_ignores_other_predictions( - monkeypatch, -): - wrapper, _logged = _wrapper_with_log_capture(monkeypatch) - batch = {"source": {"metric": torch.tensor([1.0, 2.0])}} - - with pytest.raises(TypeError, match="must be scalar"): - wrapper.training_step(batch, batch_idx=0) - - -def test_training_rejects_non_finite_log_metric(monkeypatch): - wrapper, _logged = _wrapper_with_log_capture(monkeypatch) - batch = {"source": {"metric": torch.tensor(float("nan"))}} - - with pytest.raises(RuntimeError, match="Non-finite pipeline metric"): - wrapper.training_step(batch, batch_idx=0) - - -def test_planar_action_loss_exports_canonical_mse_metric(): - stage = PlanarActionMSELoss() - result = stage( - { - "pred_action": torch.zeros(2, 3, 5), - "target": torch.ones(2, 3, 5), - } - ) - - assert stage.writes == ("loss/action", "log/MSE") - assert float(result["loss/action"]) == pytest.approx(1.0) - assert float(result["log/MSE"]) == pytest.approx(1.0) - assert "log/action_mse" not in result - - -@pytest.mark.parametrize("on_step", [None, False, True]) -def test_entrypoint_logging_control_reaches_real_csv_rows( - tmp_path, monkeypatch, on_step -): - """Config-only assertions miss controls dropped by wrapper construction.""" - - class TrainMetricPipeline(_MetricPipeline): - def forward_training(self, batch): - loss = self.nets["anchor"](batch).square().mean() - return {"source": {"loss/test": loss, "log/MSE": loss.detach()}} - - monkeypatch.setattr( - ModelWrapper, "_instantiate_model", lambda *args: TrainMetricPipeline() - ) - cfg = OmegaConf.create( - { - "model": { - "pipeline": {}, - "enable_grad_norm": False, - "optimizer": {"_target_": "torch.optim.SGD", "lr": 0.01}, - } - } - ) - if on_step is not None: - cfg.model.train_log_on_step = on_step - wrapper = _instantiate_model_wrapper(cfg) - assert wrapper.train_log_on_step is (on_step is True) - logger = CSVLogger(tmp_path, name="metrics") - trainer = Trainer( - accelerator="cpu", - devices=1, - max_steps=16, - max_epochs=1, - logger=logger, - log_every_n_steps=1, - enable_checkpointing=False, - enable_model_summary=False, - enable_progress_bar=False, - ) - trainer.fit(wrapper, DataLoader(torch.ones(16, 1), batch_size=1)) - with (tmp_path / "metrics/version_0/metrics.csv").open() as handle: - rows = [r for r in csv.DictReader(handle) if r.get("Train/MSE")] - assert trainer.global_step == 16 - assert [int(r["step"]) for r in rows] == (list(range(16)) if on_step else [15]) - assert all(torch.isfinite(torch.tensor(float(r["Train/MSE"]))) for r in rows) diff --git a/tests/test_mot_action_codec.py b/tests/test_mot_action_codec.py deleted file mode 100644 index a5f5fe611c..0000000000 --- a/tests/test_mot_action_codec.py +++ /dev/null @@ -1,438 +0,0 @@ -import copy - -import pytest -import torch - -from egomimic.models.mot_action_codec import make_mot_codecs -from egomimic.pipeline.stages_action_flow import ( - RoutedContentDecoderStage, - RoutedContentEncoderStage, -) - - -def codecs(role): - return make_mot_codecs( - action_dims={"gripper": 7, "sharpa": 69}, - role=role, - horizon=4, - tokens=2, - latent_dim=4, - width=16, - depth=2, - heads=2, - ) - - -@pytest.mark.parametrize("role", ["encoder", "decoder"]) -def test_two_embodiments_share_latent_parameters_but_not_action_parameters(role): - bank = codecs(role) - assert bank["gripper"].shared is bank["sharpa"].shared - assert bank["gripper"].blocks[0].latent is bank["sharpa"].blocks[0].latent - assert bank["gripper"].blocks[0].action is not bank["sharpa"].blocks[0].action - for route, dim in [("gripper", 7), ("sharpa", 69)]: - bank.zero_grad(set_to_none=True) - value = torch.randn(2, 4, dim) if role == "encoder" else torch.randn(2, 2, 4) - result = bank[route](value) - assert result.shape == ((2, 2, 4) if role == "encoder" else (2, 4, dim)) - result.square().mean().backward() - shared_gradient = bank[route].shared.layers[0].qkv.weight.grad - assert torch.isfinite(shared_gradient).all() and shared_gradient.abs().sum() > 0 - other = "sharpa" if route == "gripper" else "gripper" - assert bank[other].blocks[0].action.qkv.weight.grad is None - assert len(list(bank.parameters())) == len({id(p) for p in bank.parameters()}) - replacement = codecs(role) - replacement.load_state_dict(copy.deepcopy(bank.state_dict()), strict=True) - assert replacement["gripper"].shared is replacement["sharpa"].shared - for name, parameter in bank.named_parameters(): - assert torch.equal(parameter, dict(replacement.named_parameters())[name]) - - -def test_actual_routed_stages_and_decoder_higher_order_jvp(): - encoder = RoutedContentEncoderStage(encoders=codecs("encoder"), route_key="route") - decoder = RoutedContentDecoderStage(decoders=codecs("decoder"), route_key="route") - assert not ( - {id(p) for p in encoder.parameters()} & {id(p) for p in decoder.parameters()} - ) - for route, dim in [("gripper", 7), ("sharpa", 69)]: - batch = encoder({"route": route, "target": torch.randn(2, 4, dim)}) - latent = batch["action_flow/clean_latent"] - selected = decoder.decoder_for(batch) - output, tangent = torch.func.jvp( - selected, (latent,), (torch.randn_like(latent),) - ) - assert output.shape == tangent.shape == (2, 4, dim) - assert torch.isfinite(tangent).all() - (output.square().mean() + tangent.square().mean()).backward() - assert selected.shared.layers[0].qkv.weight.grad.abs().sum() > 0 - - -@pytest.mark.parametrize("role", ["encoder", "decoder"]) -@pytest.mark.parametrize("depth", [1, 2]) -def test_every_selected_codec_parameter_receives_finite_gradient(role, depth): - torch.manual_seed(42) - bank = make_mot_codecs( - action_dims={"gripper": 7, "sharpa": 69}, - role=role, - horizon=4, - tokens=2, - latent_dim=4, - width=16, - depth=depth, - heads=2, - ) - for route, dim in [("gripper", 7), ("sharpa", 69)]: - bank.zero_grad(set_to_none=True) - value = torch.randn(2, 4, dim) if role == "encoder" else torch.randn(2, 2, 4) - output = bank[route](value) - (output - torch.randn_like(output)).square().mean().backward() - for name, parameter in bank[route].named_parameters(): - assert parameter.grad is not None, name - assert torch.isfinite(parameter.grad).all(), name - assert parameter.grad.abs().sum() > 0, name - inactive = "sharpa" if route == "gripper" else "gripper" - active_ids = {id(p) for p in bank[route].parameters()} - for name, parameter in bank[inactive].named_parameters(): - if id(parameter) not in active_ids: - assert parameter.grad is None, name - context = bank["gripper"].blocks[-1] - context = context.action if role == "encoder" else context.latent - assert hasattr(context, "kv") - assert not hasattr(context, "qkv") and not hasattr(context, "ffn") - - -@pytest.mark.parametrize( - "kwargs", [{"role": "invalid"}, {"action_dims": {"only": 7}}, {"heads": 3}] -) -def test_invalid_configuration_rejected(kwargs): - options = dict( - action_dims={"gripper": 7, "sharpa": 69}, - role="encoder", - horizon=4, - tokens=2, - latent_dim=4, - width=16, - depth=2, - heads=2, - ) - options.update(kwargs) - with pytest.raises(ValueError): - make_mot_codecs(**options) - - -def test_mot_native_half_checkpoint_scope_and_diagnostic_capture(): - from types import SimpleNamespace - - from egomimic.eval.action_flow_diagnostic_forward import _block_outputs - from egomimic.models import unite_dit - from egomimic.models.action_flow_unite import UniteActionFlowVelocityField - from egomimic.models.unite_dit import UniteDiTBackbone - from egomimic.pipeline.stages_action_flow import ConditionalVelocityStage - from egomimic.utils.libero_dit_half import HalfCheckpointScope, resolve_backbones - - bank = codecs("encoder") - for codec in bank.values(): - codec.gradient_checkpointing = True - encoder = RoutedContentEncoderStage(encoders=bank, route_key="route") - backbone = UniteDiTBackbone( - input_dim=4, - output_dim=4, - horizon=2, - condition_dim=5, - max_condition_tokens=1, - max_content_tokens=4, - hidden_dim=16, - depth=2, - num_heads=2, - in_context_start=None, - in_context_len=0, - gradient_checkpointing=True, - ) - velocity = ConditionalVelocityStage( - UniteActionFlowVelocityField(backbone, 4, 4, 2, 5) - ) - module = SimpleNamespace( - model=SimpleNamespace(pipeline=SimpleNamespace(stages=[encoder, velocity])) - ) - scope = HalfCheckpointScope( - unite_dit, resolve_backbones(module), expected_depth=2 - ).install() - try: - for route, dim in [("gripper", 7), ("sharpa", 69)]: - result = encoder({"route": route, "target": torch.randn(2, 4, dim)}) - field = backbone( - result["action_flow/clean_latent"], - torch.ones(2), - condition=torch.randn(2, 1, 5), - ) - field.square().mean().backward() - assert all(count > 0 for count in scope.counts.values()) - finally: - scope.restore() - with torch.no_grad(): - result, captured, indexes = _block_outputs( - bank["gripper"], - lambda: bank["gripper"](torch.randn(2, 4, 7)), - label="encoder", - required=True, - ) - assert captured.shape == (2, 2, 2, 16) - assert indexes.tolist() == [0, 1] - assert result.shape == (2, 2, 4) - - -def test_mot_av_callback_and_original_flow_objective_reach_shared_banks(): - from torch import nn - - from egomimic.pipeline.action_flow_av_norm import install_av_only - from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - LatentBridgeStage, - ) - from egomimic.utils.unite_optim import ReleasedUniteCompositeOptimizer - - class Field(nn.Module): - def __init__(self): - super().__init__() - self.projection = nn.Linear(4, 4) - self.condition = nn.Linear(5, 4) - - def forward(self, value, time, condition, *, condition_drop_mask=None): - return ( - self.projection(value) - + self.condition(condition)[:, None, :] - + time[:, None, None] - ) - - encoder = RoutedContentEncoderStage(encoders=codecs("encoder"), route_key="route") - decoder = RoutedContentDecoderStage(decoders=codecs("decoder"), route_key="route") - install_av_only(decoder, expected_norms_per_decoder=8) - field = Field() - owners = nn.ModuleDict(dict(encoder=encoder, decoder=decoder, field=field)) - optimizer = ReleasedUniteCompositeOptimizer( - named_params=tuple(owners.named_parameters()) - ) - adamw = optimizer.group_manifest["adamw_parameter_names"] - muon = optimizer.group_manifest["muon_parameter_names"] - assert any("content_projection.weight" in name for name in adamw) - assert any("shared.proj_d.weight" in name for name in adamw) - assert not any("content_projection.weight" in name for name in muon) - params = [p for group in optimizer.param_groups for p in group["params"]] - assert len(params) == len({id(p) for p in params}) == len(list(owners.parameters())) - stages = [ - encoder, - LatentBridgeStage(samples_per_content=2, condition_dropout_probability=0), - ConditionalVelocityStage(field, num_inference_steps=3), - decoder, - ActionFlowObjectiveStage(), - ] - for route, dim in [("gripper", 7), ("sharpa", 69)]: - optimizer.zero_grad(set_to_none=True) - batch = { - "route": route, - "target": torch.randn(2, 4, dim), - "condition": torch.randn(2, 5), - "sampler/noise": torch.randn(2, 2, 4), - } - for stage in stages: - batch = stage(batch) - loss = batch["loss/action_flow"] - assert torch.isfinite(loss) - loss.backward() - for owner in [ - encoder.encoder[route].shared, - decoder.decoder[route].shared, - field, - ]: - assert any( - p.grad is not None - and torch.isfinite(p.grad).all() - and p.grad.abs().sum() > 0 - for p in owner.parameters() - ) - optimizer.step() - - -def test_existing_alias_safe_ema_verifier_covers_mot_shared_aliases(): - from torch import nn - - from scripts.train.verify_libero_native_action_flow_smoke import ( - validate_joint_ema_reload, - ) - - class Wrapper(nn.Module): - def __init__(self): - super().__init__() - self.nets = nn.ModuleDict( - dict(encoder=codecs("encoder"), decoder=codecs("decoder")) - ) - - module = Wrapper() - config = dict( - decay=0.9978, - use_warmup=False, - update_after_step=0, - inv_gamma=1.0, - power=2 / 3, - min_decay=0.0, - ) - payload = dict( - global_step=8, - ema_num_updates=8, - ema_config=config, - ema_state_dict={ - name: torch.full_like(p, 0.125) for name, p in module.named_parameters() - }, - ) - result = validate_joint_ema_reload(module, payload, {"callbacks": {"ema": config}}) - assert result["alias_safe_strict_reload"] - assert result["state_parameter_aliases"] > result["canonical_parameters"] - for p in module.parameters(): - assert torch.equal(p, torch.full_like(p, 0.125)) - - -@pytest.mark.parametrize("phase", ["normalization", "preflight", "smoke", "full"]) -def test_actual_mot_phase_factory_and_profile_are_bound(tmp_path, monkeypatch, phase): - from pathlib import Path - - import hydra - from hydra.core.hydra_config import HydraConfig - from omegaconf import OmegaConf - - from egomimic.benchmarks.libero.native_launch_profiles import ( - SPATIAL_MOT_JOINT_PROFILE, - joint_phase_argv, - joint_profile_for_config, - ) - - root = Path(__file__).parents[1] - for name in [ - "LIBERO_ORIGINAL_SPATIAL_ROOT", - "LIBERO_SHARPA_SPATIAL_ROOT", - "LIBERO_DIAGNOSTIC_SEED_BANK", - ]: - monkeypatch.setenv(name, "/wrong/unselected") - args = dict( - source_root=str(root), - original_root=str(tmp_path / "original"), - sharpa_root=str(tmp_path / "sharpa"), - output_root=str(tmp_path / phase), - context_path=str(tmp_path / "context.json"), - split_path=str(tmp_path / "split.json"), - split_sha256="a" * 64, - normalization_sha256=None if phase == "normalization" else "b" * 64, - source_commit="c" * 40, - diagnostic_seed_bank=str(tmp_path / "seeds.json"), - wandb_entity="test", - wandb_project="test", - wandb_run_id="mot-" + phase, - profile_name=SPATIAL_MOT_JOINT_PROFILE.name, - ) - argv = joint_phase_argv(phase=phase, **args) - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(root / "egomimic/hydra_configs") - ): - cfg = hydra.compose( - config_name="train_zarr_cartesian", - return_hydra_config=True, - overrides=[*argv[1:], "hydra/launcher=basic"], - ) - cfg.hydra.runtime.output_dir = str(tmp_path) - HydraConfig.instance().set_config(cfg) - c = OmegaConf.to_container( - OmegaConf.masked_copy(cfg, [k for k in cfg if k != "hydra"]), resolve=True - ) - assert joint_profile_for_config(c) == SPATIAL_MOT_JOINT_PROFILE - if phase == "preflight": - from scripts.train.libero_native_launch_contract import construct_joint_model - - with pytest.raises(ValueError, match="source/config identity"): - construct_joint_model(c, tmp_path / "unopened.yaml", source_head="d" * 40) - gpu = copy.deepcopy(c) - gpu["trainer"]["accelerator"] = "gpu" - with pytest.raises(ValueError, match="CPU preflight"): - construct_joint_model(gpu, tmp_path / "unopened.yaml", source_head="c" * 40) - - assert c["callbacks"]["av_only_layer_norm"]["expected_norms_per_decoder"] == 48 - changed = copy.deepcopy(c) - changed["model"]["pipeline"]["stages"][4]["encoders"]["action_dims"][ - "libero_panda_sharpa" - ] = 7 - with pytest.raises(ValueError, match="MoT source widths"): - joint_profile_for_config(changed) - changed = copy.deepcopy(c) - changed["model"]["pipeline"]["stages"][7]["decoders"]["condition_dim"] = 276 - with pytest.raises(ValueError, match="context-free"): - joint_profile_for_config(changed) - - -def test_checkpoint_owner_guard_allows_only_declared_mot_aliases(): - from types import SimpleNamespace - - from torch import nn - - from scripts.train.verify_action_flow_training_smoke import _joint_checkpoint_owners - - def bank(role): - return make_mot_codecs( - action_dims={"libero_panda": 7, "libero_panda_sharpa": 69}, - role=role, - horizon=4, - tokens=2, - latent_dim=4, - width=16, - depth=2, - heads=2, - ) - - enc, dec = bank("encoder"), bank("decoder") - stages = [None] * 9 - stages[4] = RoutedContentEncoderStage(encoders=enc) - stages[7] = RoutedContentDecoderStage(decoders=dec, route_key="embodiment") - stages[6] = SimpleNamespace(field=nn.Linear(4, 4)) - assert len(_joint_checkpoint_owners(stages)) == 5 - enc["libero_panda_sharpa"].blocks[0].action = enc["libero_panda"].blocks[0].action - with pytest.raises(RuntimeError, match="escape shared latent"): - _joint_checkpoint_owners(stages) - - -def test_phase_cli_rejects_wrong_experiment_before_model_or_composition( - tmp_path, monkeypatch -): - import hashlib - import json - import sys - - from scripts.train import libero_native_launch_contract as adapter - - path = tmp_path / "inputs.json" - path.write_text( - json.dumps({"profile_name": "action_flow_libero_spatial_sharpa_mot_s42"}) - ) - monkeypatch.setenv("SLURM_JOB_ID", "123") - monkeypatch.setenv("SLURM_STEP_ID", "0") - monkeypatch.setenv( - "AF_EXPERIMENT", - "libero/action_flow_libero_spatial_sharpa_gripper_keypoints_s42", - ) - monkeypatch.setattr( - sys, - "argv", - [ - "adapter", - "--action", - "joint-compose", - "--repo", - str(tmp_path), - "--output", - str(tmp_path), - "--phase", - "preflight", - "--phase-inputs", - str(path), - "--expected-phase-inputs-sha256", - hashlib.sha256(path.read_bytes()).hexdigest(), - ], - ) - with pytest.raises(ValueError, match="experiment/environment mismatch"): - adapter.main() diff --git a/tests/test_multiplier_condition.py b/tests/test_multiplier_condition.py deleted file mode 100644 index b5ba439a46..0000000000 --- a/tests/test_multiplier_condition.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Multiplier semantics must not depend on physical speed or future actions.""" - -import torch - -from egomimic.pipeline import stages_speed as m - - -def test_multiplier_input_contract(): - c = m.SharedSpeedCondition( - None, condition_dim=256, conditioning_input="retiming_multiplier" - ) - # Exercise learned dependence rather than only the zero-initialized branch. - with torch.no_grad(): - c.mlp[0].weight.fill_(1) - c.mlp[0].bias.zero_() - c.mlp[-1].weight.fill_(0.1) - obs = torch.randn(5, 256, requires_grad=True) - rates = torch.tensor([0.2, 0.4, 0.6, 0.8, 1.0]) - a = c( - { - "condition": obs, - "retiming_rate": rates[:, None], - "requested_speed": torch.ones(5, 1), - } - )["speed_condition"] - b = c( - { - "condition": obs, - "retiming_rate": rates[:, None], - "requested_speed": torch.ones(5, 1) * 999, - } - )["speed_condition"] - assert a.shape == (5, 256) and torch.equal(a, b) - assert not torch.equal(a[0] - obs[0], a[-1] - obs[-1]) - a.sum().backward() - assert obs.grad is not None and c.mlp[0].weight.grad.abs().sum() > 0 - assert c({"condition": obs.detach()[:, None], "retiming_rate": rates[:, None]})[ - "speed_condition" - ].shape == (5, 1, 256) - for bad in ( - torch.zeros(5), - -rates, - torch.ones(5) * float("nan"), - torch.ones(5) * float("inf"), - torch.ones(5, 2), - ): - try: - c({"condition": obs, "retiming_rate": bad}) - except ValueError: - pass - else: - raise AssertionError("invalid multiplier accepted") - try: - c({"condition": obs, "requested_speed": torch.ones(5, 1)}) - except KeyError: - pass - else: - raise AssertionError("physical-speed fallback accepted") - - -def test_strict_reload_rejects_physical_speed_weights(): - import pytest - - new = m.SharedSpeedCondition(condition_dim=256) - old_state = dict(new.state_dict()) - old_state.pop("retiming_multiplier_contract") - old_state["speed_reference"] = torch.tensor(1.0) - with pytest.raises(RuntimeError): - new.load_state_dict(old_state, strict=True) - new.load_state_dict(new.state_dict(), strict=True) - with pytest.raises(ValueError, match="removed"): - m.SharedSpeedCondition(1.0) - with pytest.raises(ValueError, match="removed"): - m.SharedSpeedCondition(conditioning_input="native_speed") - - -def test_rollout_requires_explicit_multiplier(): - import pytest - from omegaconf import OmegaConf - - cfg = OmegaConf.create( - { - "model": { - "pipeline": { - "_target_": "egomimic.pipeline.stages_speed.build_speed_conditioned_pipeline", - "conditioning_input": "retiming_multiplier", - } - }, - "deployment": {"requested_multiplier": 0.4}, - } - ) - assert m.requested_rollout_condition(cfg) == ("retiming_rate", 0.4) - cfg.deployment.requested_speed = 12.0 - with pytest.raises(ValueError, match="requested_speed"): - m.requested_rollout_condition(cfg) - - -def test_multiplier_reaches_shared_field_in_train_and_inference(): - from egomimic.pipeline.core import Pipeline - from egomimic.pipeline.stages_action_flow import ( - ConditionalVelocityStage, - LatentBridgeStage, - ) - from egomimic.pipeline.stages_speed import SharedSpeedCondition - - class Field(torch.nn.Module): - def forward(self, x, t, condition, **kwargs): - return condition[:, : x.shape[-1]].unsqueeze(1).expand_as(x) - - condition = SharedSpeedCondition( - None, condition_dim=16, conditioning_input="retiming_multiplier" - ) - for layer in (condition.mlp[0], condition.mlp[-1]): - torch.nn.init.constant_(layer.weight, 0.1) - torch.nn.init.zeros_(layer.bias) - graph = Pipeline( - [ - condition, - LatentBridgeStage( - samples_per_content=1, - condition_key="speed_condition", - condition_dropout_probability=0, - ), - ConditionalVelocityStage( - Field(), - num_inference_steps=2, - inference_method="euler", - inference_condition_key="speed_condition", - ), - ] - ) - for mode in ("train", "inference"): - result = graph.execute( - { - "condition": torch.zeros(2, 16), - "retiming_rate": torch.tensor([[1.0], [2.0]]), - "action_flow/clean_latent": torch.zeros(2, 8, 16), - "sampler/noise": torch.zeros(2, 8, 16), - }, - mode=mode, - ) - key = ( - "action_flow/predicted_velocity" - if mode == "train" - else "action_flow/generated_latent" - ) - assert not torch.equal(result[key][0], result[key][1]) - if mode == "train": - result[key].square().mean().backward() - assert condition.mlp[0].weight.grad.abs().sum() > 0 - - -def test_stationary_retimer_emits_multiplier_without_speed(): - import numpy as np - - from egomimic.rldb.zarr.physical_retiming import PhysicalWindowRetiming - - rates = [0.2, 0.4, 0.6, 0.8, 1.0] - keys = ["left.pose", "right.pose"] - for view, rate in enumerate(rates): - tf = PhysicalWindowRetiming( - rates, - dict.fromkeys(keys, "pose_wxyz"), - keys, - 30, - stride=3, - timestamp_key="clock", - conditioning_input="retiming_multiplier", - ) - km = {k: {"horizon": 30} for k in keys + ["clock"]} - tf.bind_episode({"fps": 30}, km) - pose = np.zeros((30, 7)) - pose[:, 0] = np.arange(30) - pose[:, 3] = 1 - raw = {k: pose.copy() for k in keys} - raw.update(clock=np.arange(30, dtype=np.int64) * 33333333, _retiming_view=view) - out = tf.transform(raw) - assert out["retiming_rate"].shape == (1,) - np.testing.assert_allclose(out["retiming_rate"], [rate]) - assert "requested_speed" not in out and "requested_speed_value" not in out - - -def test_stationary_recipe_keeps_selected_rates_and_dimensions(): - from pathlib import Path - - from omegaconf import OmegaConf - - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - cfg = OmegaConf.load( - root / "experiment/e1/yam_human_keypoints_multiplier_h816_private512_s42.yaml" - ) - assert list(cfg.stationary_speed.human_rates) == [0.2, 0.4, 0.6, 0.8, 1.0] - assert list(cfg.stationary_speed.yam_rates) == [1.0] - assert cfg.model.pipeline.conditioning_input == "retiming_multiplier" - assert cfg.model.pipeline.speed_reference is None - data = OmegaConf.load( - root / "data/e1/yam_human_keypoints_multiplier_proportional_val01.yaml" - ) - for source in ( - data.train_datasets.yam_bimanual, - data.train_datasets.human_bimanual, - data.valid_datasets.yam.yam_bimanual, - data.valid_datasets.human.human_bimanual, - ): - win = source.resolver.transform_list.window_transform - assert ( - OmegaConf.to_container(win, resolve=False)["conditioning_input"] - == "${stationary_speed.conditioning_input}" - ) - - -def test_multiplier_semantics_preserve_normalization_inputs(): - import numpy as np - - from egomimic.rldb.zarr.physical_retiming import PhysicalWindowRetiming - - for domain, horizon, rates in ( - ("human", 30, [0.2, 0.4, 0.6, 0.8, 1.0]), - ("robot", 100, [1.0]), - ): - fields = {"left": "pose_wxyz", "right": "pose_wxyz", "articulation": "linear"} - transform = PhysicalWindowRetiming( - rates, - fields, - ["left", "right"], - horizon, - embodiment=domain, - sample_views=5, - timestamp_key="clock" if domain == "human" else None, - ) - km = {key: {"horizon": horizon} for key in fields} - if domain == "human": - km["clock"] = {"horizon": horizon} - transform.bind_episode({"fps": 30}, km) - pose = np.zeros((horizon, 7)) - pose[:, 0] = np.arange(horizon) * 0.01 - pose[:, 3] = 1 - for view in range(5): - rate = rates[view % len(rates)] - values = np.arange(horizon * 126, dtype=float).reshape(horizon, 126) - batch = { - "left": pose.copy(), - "right": pose.copy(), - "articulation": values.copy(), - "_retiming_view": view, - } - if domain == "human": - batch["clock"] = np.arange(horizon, dtype=np.int64) * 33333333 - out = transform.transform(batch) - for key in ("left", "right"): - np.testing.assert_allclose(out[key][:, 0], pose[:, 0] * rate) - np.testing.assert_array_equal(out[key][:, 3:], pose[:, 3:]) - expected = values[0] + (values - values[0]) * rate - np.testing.assert_allclose(out["articulation"], expected) - assert "requested_speed" not in out and out["retiming_rate"].shape == (1,) diff --git a/tests/test_multiplier_training_launch.py b/tests/test_multiplier_training_launch.py deleted file mode 100644 index 343ff70f5d..0000000000 --- a/tests/test_multiplier_training_launch.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Typed multiplier route preserves native per-source execution and full batches.""" - -import runpy -import subprocess -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -from egomimic.pipeline.stages_speed import configure_dit_checkpoint_policy - - -def test_multiplier_training_contract(): - root = Path(__file__).resolve().parents[1] - with initialize_config_dir( - config_dir=str(root / "egomimic/hydra_configs"), version_base="1.3" - ): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=pusht/action_flow_cotrain_uc_multiplier_interpolation", - "trainer.precision=bf16", - "trainer.devices=1", - "trainer.num_nodes=1", - "model.pipeline.stages.6.inference_method=euler", - "model.pipeline.stages.6.num_inference_steps=50", - ], - ) - validator = runpy.run_path( - str(root / "scripts/train/validate_multiplier_config.py") - ) - validator["validate_speed_contract"](cfg, "scalar") - assert cfg.callbacks.ema.decay == 0.9978 - assert cfg.callbacks.ema.use_warmup is False - assert "homogeneous_dithalf" not in cfg.callbacks - assert cfg.model.pipeline.dit_checkpoint_policy == "dit_half" - assert not (root / "egomimic/pl_utils/homogeneous_training/callback.py").exists() - assert cfg.norm_stats.sample_frac == 1.0 - assert cfg.norm_stats.norm_mode == "minmax" - # The typed validator above requires one full batch32 per source. - assert len(cfg.data.train_datasets) == 2 - - -def _owners(): - def owner(): - return { - "backbone": { - "_target_": "egomimic.models.unite_dit.UniteDiTBackbone", - "gradient_checkpointing": True, - "depth": 12, - } - } - - return {"encoders": {"u": owner(), "chain": owner()}}, {"field": owner()} - - -def test_native_dit_half_does_not_change_image_or_decoder_execution(): - encoder, field = _owners() - configure_dit_checkpoint_policy(encoder, field, "dit_half") - for owner in [*encoder["encoders"].values(), field["field"]]: - assert owner["backbone"]["checkpoint_policy"] == "dit_half" - assert set(encoder) == {"encoders"} - assert set(field) == {"field"} - - -@pytest.mark.parametrize("invalid", ["disabled", "odd_depth", "wrong_owner", "unknown"]) -def test_native_dit_half_rejects_mismatch_atomically(invalid): - encoder, field = _owners() - backbone = field["field"]["backbone"] - policy = "dit_half" - if invalid == "disabled": - backbone["gradient_checkpointing"] = False - elif invalid == "odd_depth": - backbone["depth"] = 11 - elif invalid == "wrong_owner": - backbone["_target_"] = "unreviewed.Backbone" - else: - policy = "guess" - with pytest.raises(ValueError): - configure_dit_checkpoint_policy(encoder, field, policy) - assert all( - "checkpoint_policy" not in owner["backbone"] - for owner in [*encoder["encoders"].values(), field["field"]] - ) - - -def test_launcher_child_helper_exists_and_parses(): - root = Path(__file__).resolve().parents[1] - helper = root / "scripts/train/action_flow_run_child.sh" - assert helper.is_file() - subprocess.run(["bash", "-n", str(helper)], check=True) - assert "--cpu-bind=none" in helper.read_text() diff --git a/tests/test_native_diffusion_transformer.py b/tests/test_native_diffusion_transformer.py deleted file mode 100644 index 023bf083da..0000000000 --- a/tests/test_native_diffusion_transformer.py +++ /dev/null @@ -1,71 +0,0 @@ -import pytest -import torch - -from egomimic.pipeline.stages_matched_dp import ( - SharedNativeDPStage, - shared_native_transformers, -) - - -def test_co_training_shares_attention_but_keeps_native_io_private(): - models = shared_native_transformers(16, 144, 1, 8, 0.0) - robot, human = models.values() - assert robot.layers is human.layers - assert robot.pos_emb is human.pos_emb - assert robot.proj_u is not human.proj_u - assert robot.proj_d is not human.proj_d - for width, model in ((14, robot), (138, human)): - model.zero_grad(set_to_none=True) - output = model( - torch.randn(2, 100, width), torch.tensor([0, 99]), torch.randn(2, 16) - ) - assert output.shape == (2, 100, width) - output.square().mean().backward() - gradient = robot.layers[0].mha.in_proj_weight.grad - assert ( - gradient is not None - and torch.isfinite(gradient).all() - and gradient.abs().sum() > 0 - ) - - -def test_native_diffusion_training_and_ddim_sampling_work_for_both_embodiments(): - stage = SharedNativeDPStage( - condition_dim=16, - inference_steps=2, - denoiser="transformer", - transformer_width=144, - transformer_depth=1, - transformer_heads=8, - transformer_dropout=0.0, - ) - optimizer = torch.optim.AdamW(stage.parameters(), lr=1e-4) - for embodiment, width in ((7, 14), (3, 138)): - stage.train() - optimizer.zero_grad(set_to_none=True) - output = stage.execute( - { - "embodiment": embodiment, - "condition": torch.randn(2, 16), - "target": torch.randn(2, 100, width), - }, - mode="train", - ) - loss = output["loss/diffusion_noise"] - assert torch.isfinite(loss) - loss.backward() - optimizer.step() - stage.eval() - with torch.no_grad(): - prediction = stage.execute( - {"embodiment": embodiment, "condition": torch.randn(2, 16)}, - mode="inference", - )["pred_action"] - assert prediction.shape == (2, 100, width) - assert torch.isfinite(prediction).all() - - -def test_rejects_wrong_native_input_shape(): - model = shared_native_transformers(16, 144, 1, 8, 0.0)["human_bimanual"] - with pytest.raises(ValueError, match="native_action_dim"): - model(torch.zeros(1, 100, 14), torch.tensor([1]), torch.zeros(1, 16)) diff --git a/tests/test_native_final_validation_lifecycle.py b/tests/test_native_final_validation_lifecycle.py deleted file mode 100644 index d1a0392d5d..0000000000 --- a/tests/test_native_final_validation_lifecycle.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Execute maintained lifecycle methods without model/data/runtime imports.""" - -import ast -import sys -import types -import unittest -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] - - -def functions(path, names): - tree = ast.parse((ROOT / path).read_text()) - nodes = [] - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name in names: - node.decorator_list = [] - nodes.append(node) - ns = {} - exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), ns) - return ns - - -F = functions("egomimic/trainHydra.py", {"_needs_final_validation"})[ - "_needs_final_validation" -] -N = functions( - "egomimic/eval/libero_action_flow_eval.py", - {"on_validation_end", "has_completed_validation"}, -) - - -class Native: - on_validation_end = N["on_validation_end"] - has_completed_validation = N["has_completed_validation"] - - def __init__(self, group="valid", batches=None, sanity=False): - self.artifact_root = "unused" - self.artifact_identity = {} - self.group = group - self.artifact_batches = [{"batch_index": 0}] if batches is None else batches - self.model = types.SimpleNamespace(global_step=2) - self.trainer = types.SimpleNamespace(sanity_checking=sanity) - self._completed_validation_step = None - - -class Lifecycle(unittest.TestCase): - def publication(self, e, error=None): - module = types.ModuleType("egomimic.benchmarks.libero.action_flow_artifacts") - module.canonical_sha = lambda identity: "identity" - - def writer(root, payload): - if error: - raise error - - module.write_artifact = writer - with patch.dict(sys.modules, {module.__name__: module}): - e.on_validation_end() - - def test_periodic_terminal_completion_skips_only_duplicate(self): - e = Native() - self.assertTrue(F(e, 2)) - self.publication(e) - self.assertFalse(F(e, 2)) - self.assertTrue(F(e, 3)) - self.assertTrue(F(e, 80000)) - - def test_missing_generic_and_nonboolean_capability_keep_final(self): - for e in ( - None, - object(), - types.SimpleNamespace(has_completed_validation=lambda step: 1), - ): - self.assertTrue(F(e, 2)) - - def test_empty_sanity_and_other_group_keep_final(self): - for e in (Native(batches=[]), Native(sanity=True), Native(group="train_viz")): - self.publication(e) - self.assertTrue(F(e, 2)) - - def test_new_empty_or_failed_pass_invalidates_prior_completion(self): - tree = ast.parse( - (ROOT / "egomimic/eval/libero_action_flow_eval.py").read_text() - ) - method = next( - n - for n in ast.walk(tree) - if isinstance(n, ast.FunctionDef) and n.name == "on_validation_start" - ) - e = Native() - e._completed_validation_step = 2 - exec( - compile( - ast.Module(body=method.body[:1], type_ignores=[]), - "native-start", - "exec", - ), - {"self": e}, - ) - e.artifact_batches = [] - self.publication(e) - self.assertTrue(F(e, 2)) - - def test_failed_immutable_publication_does_not_complete(self): - e = Native() - with self.assertRaises(FileExistsError): - self.publication(e, FileExistsError("immutable collision")) - self.assertTrue(F(e, 2)) - self.assertTrue(e.artifact_batches) - - def test_completion_reset_precedes_replayed_valid_batch(self): - tree = ast.parse( - (ROOT / "egomimic/eval/libero_action_flow_eval.py").read_text() - ) - method = next( - n - for n in ast.walk(tree) - if isinstance(n, ast.FunctionDef) and n.name == "on_validation_step" - ) - # Execute the actual initial statements, stopping before model inference. - statements = method.body[:2] - e = Native() - e._completed_validation_step = 2 - exec( - compile( - ast.Module(body=statements, type_ignores=[]), "native-reset", "exec" - ), - {"self": e, "batch_idx": 0, "dataloader_idx": 0}, - ) - self.assertTrue(F(e, 2)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_nested_val_groups.py b/tests/test_nested_val_groups.py deleted file mode 100644 index 63feeeb899..0000000000 --- a/tests/test_nested_val_groups.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Nested validation dataset groups in MultiDataModuleWrapper.""" - -import pytest -import torch -from lightning.pytorch.utilities.combined_loader import CombinedLoader -from torch.utils.data import Dataset - -from egomimic.pl_utils.pl_data_utils import ( - DEFAULT_VALID_GROUP, - MultiDataModuleWrapper, - as_valid_groups, -) -from egomimic.pl_utils.pl_model import _unwrap_combined_loader_batch - -_SOURCE_A = "pushshapes_sim_u_socket" -_SOURCE_B = "pushshapes_sim_chain_gripper" - - -class _Tiny(Dataset): - def __init__(self, n: int = 4): - self.n = n - self.norm_stats = None - - def __len__(self): - return self.n - - def __getitem__(self, index): - return {"actions": torch.zeros(2, 3)} - - def set_norm_stats_from(self, owner): - self.norm_stats = owner.norm_stats - - -def _params(*sources): - return {source: {"batch_size": 2, "num_workers": 0} for source in sources} - - -def _module(valid_datasets, valid_params=None): - return MultiDataModuleWrapper( - train_datasets={_SOURCE_A: _Tiny()}, - valid_datasets=valid_datasets, - train_dataloader_params=_params(_SOURCE_A), - valid_dataloader_params=valid_params or _params(_SOURCE_A, _SOURCE_B), - ) - - -def test_flat_valid_datasets_become_the_default_group(): - assert as_valid_groups({_SOURCE_A: "ds"}) == { - DEFAULT_VALID_GROUP: {_SOURCE_A: "ds"} - } - - -def test_grouped_valid_datasets_keep_their_labels_and_order(): - grouped = {"newtask": {_SOURCE_A: "x"}, "valid": {_SOURCE_B: "y"}} - assert list(as_valid_groups(grouped)) == ["newtask", "valid"] - - -def test_mixing_group_keys_with_source_keys_is_rejected(): - with pytest.raises(ValueError, match="mixes dataset leaves"): - as_valid_groups({_SOURCE_A: "ds", "newtask": {_SOURCE_B: "ds"}}) - - -def test_extra_group_nesting_is_rejected(): - with pytest.raises(ValueError, match="nested groups"): - as_valid_groups({"newtask": {"deeper": {_SOURCE_A: "ds"}}}) - - -def test_source_names_are_opaque_and_explicit_grouped_shape_is_checked(): - dataset = _Tiny() - assert as_valid_groups({"unregistered_source": dataset}) == { - DEFAULT_VALID_GROUP: {"unregistered_source": dataset} - } - with pytest.raises(ValueError, match="must map source -> dataset"): - as_valid_groups({"newtask": 5}, layout="grouped") - - -def test_empty_valid_datasets_yields_no_groups(): - assert as_valid_groups({}) == {} - - -def test_none_slots_are_dropped_and_emptied_groups_disappear(): - module = _module({"a_group": {_SOURCE_A: None}, "b_group": {_SOURCE_B: _Tiny()}}) - assert module.valid_group_names == ["b_group"] - - -def test_flat_config_returns_a_bare_loader_so_dataloader_idx_stays_zero(): - module = _module({_SOURCE_A: _Tiny()}) - assert module.valid_group_names == [DEFAULT_VALID_GROUP] - assert isinstance(module.val_dataloader(), CombinedLoader) - - -def test_multiple_groups_return_one_loader_each_in_declaration_order(): - module = _module({"valid": {_SOURCE_A: _Tiny()}, "newtask": {_SOURCE_B: _Tiny()}}) - loaders = module.val_dataloader() - assert isinstance(loaders, list) and len(loaders) == 2 - assert module.valid_group_names == ["valid", "newtask"] - - -def test_valid_datasets_alias_prefers_the_default_group(): - module = _module({"newtask": {_SOURCE_B: _Tiny()}, "valid": {_SOURCE_A: _Tiny()}}) - assert list(module.valid_datasets) == [_SOURCE_A] - - -def test_valid_datasets_alias_falls_back_to_the_first_group(): - module = _module({"newtask": {_SOURCE_B: _Tiny()}, "other": {_SOURCE_A: _Tiny()}}) - assert list(module.valid_datasets) == [_SOURCE_B] - - -def test_iter_valid_datasets_reaches_every_group_the_alias_skips(): - module = _module({"valid": {_SOURCE_A: _Tiny()}, "newtask": {_SOURCE_B: _Tiny()}}) - seen = [(group, source) for group, source, _ in module.iter_valid_datasets()] - assert seen == [("valid", _SOURCE_A), ("newtask", _SOURCE_B)] - # This is the regression the alias caused: iterating it covers only one. - assert len(list(module.valid_datasets)) < len(seen) - - -def test_per_group_dataloader_params_are_selected_by_group_name(): - module = _module( - {"valid": {_SOURCE_A: _Tiny()}, "newtask": {_SOURCE_A: _Tiny()}}, - valid_params={ - "valid": {_SOURCE_A: {"batch_size": 2, "num_workers": 0}}, - "newtask": {_SOURCE_A: {"batch_size": 1, "num_workers": 0}}, - }, - ) - loaders = module.val_dataloader() - assert loaders[0].flattened[0].batch_size == 2 - assert loaders[1].flattened[0].batch_size == 1 - - -def test_source_keyed_params_are_shared_by_every_group(): - module = _module({"valid": {_SOURCE_A: _Tiny()}, "newtask": {_SOURCE_A: _Tiny()}}) - loaders = module.val_dataloader() - assert [loader.flattened[0].batch_size for loader in loaders] == [2, 2] - - -def test_a_group_missing_dataloader_params_names_the_group_it_failed_in(): - module = _module({"newtask": {_SOURCE_B: _Tiny()}}, valid_params=_params(_SOURCE_A)) - with pytest.raises(ValueError, match="in val group 'newtask'"): - module.val_dataloader() - - -def test_unwrap_drops_the_extra_tuple_lightning_leaks_for_nested_groups(): - batch = {_SOURCE_A: {"actions": torch.zeros(1)}} - assert _unwrap_combined_loader_batch((batch, 0, 1)) is batch - assert _unwrap_combined_loader_batch(batch) is batch - - -def test_unwrap_leaves_a_non_triple_tuple_alone(): - pair = ({"a": 1}, 0) - assert _unwrap_combined_loader_batch(pair) is pair - - -def _bare_evaluator(): - """A PlanarActionEval instance without running its heavy __init__.""" - from egomimic.eval.planar_action_eval import PlanarActionEval - - evaluator = PlanarActionEval.__new__(PlanarActionEval) - return evaluator - - -def test_default_group_keeps_the_bare_valid_prefix(): - evaluator = _bare_evaluator() - evaluator.set_validation_group(DEFAULT_VALID_GROUP) - metrics = {"Valid/MSE": 1.0, "Train/Loss": 2.0} - assert evaluator._namespaced(metrics) == metrics - - -def test_a_named_group_namespaces_only_the_valid_metrics(): - evaluator = _bare_evaluator() - evaluator.set_validation_group("newtask") - out = evaluator._namespaced({"Valid/MSE": 1.0, "Valid/MSE/eva": 2.0, "Other": 3.0}) - assert out == { - "Valid_newtask/MSE": 1.0, - "Valid_newtask/MSE/eva": 2.0, - "Other": 3.0, - } - - -def test_no_group_set_leaves_metrics_untouched(): - evaluator = _bare_evaluator() - metrics = {"Valid/MSE": 1.0} - assert evaluator._namespaced(metrics) == metrics diff --git a/tests/test_oat_cotrain_boundary.py b/tests/test_oat_cotrain_boundary.py deleted file mode 100644 index 0d7bec690f..0000000000 --- a/tests/test_oat_cotrain_boundary.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Native routed observations preserve YAM and cannot mix embodiment batches.""" - -import pytest -import torch - -from egomimic.pipeline.stages_oat import ( - EmbodimentOATObservationStage, - OATObservationStage, -) - - -class Encoder(torch.nn.Module): - def __init__(self, width): - super().__init__() - self.weight = torch.nn.Parameter(torch.ones(width)) - - def forward(self, obs): - return obs["state"] * self.weight - - -def stage(): - return EmbodimentOATObservationStage( - branches={ - "yam": OATObservationStage(Encoder(6), ["robot"], {"robot": "state"}), - "human": OATObservationStage(Encoder(4), ["hand"], {"hand": "state"}), - }, - condition_dim=6, - selector_aliases={"7": "yam", "3": "human"}, - ) - - -@pytest.mark.parametrize("identity,key,width", [(7, "robot", 6), (3, "hand", 4)]) -def test_route_padding_and_private_gradient(identity, key, width): - s = stage() - x = torch.randn(2, width) - out = s({"embodiment": torch.tensor([identity] * 2), key: x})["condition"] - torch.testing.assert_close(out[:, :width], x) - assert torch.equal(out[:, width:], torch.zeros(2, 6 - width)) - out.sum().backward() - active = "yam" if identity == 7 else "human" - assert s.branches[active].encoder.weight.grad is not None - assert s.branches["human" if active == "yam" else "yam"].encoder.weight.grad is None - - -def test_reject_mixed_batch_and_unknown_source(): - s = stage() - with pytest.raises(ValueError, match="homogeneous"): - s({"embodiment": torch.tensor([3, 7])}) - with pytest.raises(ValueError, match="No OAT observation"): - s({"embodiment": torch.tensor([99, 99])}) - - -def test_reject_condition_truncation(): - s = stage() - s.condition_dim = 3 - with pytest.raises(ValueError, match="exceed"): - s({"embodiment": torch.tensor([7, 7]), "robot": torch.ones(2, 6)}) - - -def test_human_horizon_is_typed_and_metadata_matches(): - from egomimic.eval.yam_human_keypoints_action_flow_eval import ( - YamHumanKeypointsActionFlowEval, - ) - - with pytest.raises(ValueError, match="Human action horizon"): - YamHumanKeypointsActionFlowEval(human_action_horizon=0) - # An invalid stochastic chunk fails before accessing normalization state. - evaluator = object.__new__(YamHumanKeypointsActionFlowEval) - evaluator.human_action_horizon = 32 - with pytest.raises(ValueError, match="configured-horizon"): - evaluator._energy_values( - torch.zeros(32, 1, 100, 138), torch.zeros(1, 100, 138), 3, "human_bimanual" - ) diff --git a/tests/test_oat_diffusion.py b/tests/test_oat_diffusion.py deleted file mode 100644 index 2e00685c8c..0000000000 --- a/tests/test_oat_diffusion.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Released diffusion-source parity and ARC graph training/checkpoint coverage.""" - -from pathlib import Path - -import pytest -import torch -from diffusers import DDIMScheduler - -from egomimic.models.diffusion_policy import DiffusionPolicy -from egomimic.models.oat.diffusion import GraphDiffusionTransformer - -pytest_plugins = ("tests.oat_reference_fixtures",) - - -def dimensions(channels=12, horizon=32): - return dict( - input_dim=channels, - output_dim=channels, - horizon=horizon, - n_obs_steps=2, - cond_dim=138, - n_layer=4, - n_head=4, - n_emb=256, - p_drop_emb=0.1, - p_drop_attn=0.1, - causal_attn=True, - time_as_cond=True, - obs_as_cond=True, - n_cond_layers=0, - ) - - -def scheduler(): - return DDIMScheduler( - num_train_timesteps=100, - beta_start=0.0001, - beta_end=0.02, - beta_schedule="squaredcos_cap_v2", - clip_sample=True, - set_alpha_to_one=True, - steps_offset=0, - prediction_type="epsilon", - ) - - -@pytest.mark.parametrize("horizon", [24, 32, 36]) -def test_released_backbone_noise_loss_gradients_and_sampling(reference, horizon): - original_class = reference( - "model.diffusion.transformer_for_diffusion" - ).TransformerForDiffusion - native = GraphDiffusionTransformer(**dimensions(horizon=horizon)) - original = original_class(**dimensions(horizon=horizon)) - original.load_state_dict(native.state_dict(), strict=True) - condition = torch.randn(2, 2, 138) - actions, noise = torch.randn(2, horizon, 12), torch.randn(2, horizon, 12) - timesteps = torch.tensor([13, 87]) - noiser = scheduler() - noisy = noiser.add_noise(actions, noise, timesteps) - torch.manual_seed(98) - predicted = native(noisy, timesteps, condition.flatten(1)) - torch.manual_seed(98) - expected = original(noisy, timesteps, condition) - torch.testing.assert_close(predicted, expected, rtol=0, atol=0) - ((predicted - noise) ** 2).mean().backward() - ((expected - noise) ** 2).mean().backward() - for (name, actual), (other_name, wanted) in zip( - native.named_parameters(), original.named_parameters() - ): - assert name == other_name - if wanted.grad is not None: - torch.testing.assert_close(actual.grad, wanted.grad, rtol=0, atol=0) - native.eval() - original.eval() - native_policy = DiffusionPolicy(native, scheduler(), horizon, 10) - upstream_scheduler = scheduler() - upstream_scheduler.set_timesteps(10) - with torch.no_grad(): - sampled = native_policy.inference(noise.clone(), condition.flatten(1)) - expected = noise.clone() - for timestep in upstream_scheduler.timesteps: - expected = upstream_scheduler.step( - original(expected, timestep, condition), timestep, expected - ).prev_sample - torch.testing.assert_close(sampled, expected, rtol=0, atol=0) - - -def test_released_config_and_parameter_counts(reference): - import os - - import yaml - from hydra import compose, initialize_config_dir - - upstream = yaml.safe_load( - ( - Path(os.environ["OAT_REFERENCE_ROOT"]) / "oat/config/train_diffpolicy.yaml" - ).read_text() - ) - original = reference( - "model.diffusion.transformer_for_diffusion" - ).TransformerForDiffusion(**dimensions(channels=7)) - assert sum(p.numel() for p in original.parameters()) == 4_788_231 - with initialize_config_dir( - version_base=None, - config_dir=str(Path(__file__).parents[1] / "egomimic/hydra_configs"), - ): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=["+experiment=oat/libero_arc_oat_dp_policy"], - ) - denoiser = cfg.model.pipeline.stages[3].policy - model = denoiser.model - for native, upstream_key in ( - ("n_emb", "embed_dim"), - ("n_layer", "n_layers"), - ("n_head", "n_heads"), - ("p_drop_emb", "dropout"), - ("p_drop_attn", "dropout"), - ): - assert model[native] == upstream["policy"][upstream_key] - for key, value in upstream["policy"]["noise_scheduler"].items(): - assert denoiser.noise_scheduler[key] == value - assert denoiser.num_inference_steps == upstream["policy"]["num_inference_steps"] - assert cfg.trainer.max_epochs == upstream["training"]["num_epochs"] - for key, upstream_key in ( - ("learning_rate", "policy_lr"), - ("obs_enc_lr", "obs_enc_lr"), - ("weight_decay", "weight_decay"), - ("betas", "betas"), - ): - assert cfg.model.training_behavior[key] == upstream["optimizer"][upstream_key] - for horizon, expected in ((24, 4_788_748), (32, 4_790_796), (36, 4_791_820)): - adapted = GraphDiffusionTransformer(**dimensions(horizon=horizon)) - assert sum(p.numel() for p in adapted.parameters()) == expected - - -@pytest.mark.parametrize("mode,precision", [("stk", "32-true"), ("dur", "bf16-mixed")]) -def test_arc_dp_train_optimizer_ema_reload_and_resume(tmp_path, mode, precision): - from egomimic.benchmarks.libero.rollout import load_policy - from egomimic.trainHydra import train - from tests.test_libero_benchmark import make_replay - from tests.test_oat_training import config_for - - path = tmp_path / "replay.zarr" - make_replay(path) - cfg = config_for("libero_arc_oat_dp_policy", path, tmp_path / "training") - cfg.trainer.precision = precision - cfg.benchmark.arc_mode = mode - cfg.benchmark.arc_action_dim = 12 - cfg.benchmark.arc_waypoints = 8 - # Real graph/checkpoint path with a smaller trunk; full-size parity is above. - model = cfg.model.pipeline.stages[3].policy.model - model.n_emb, model.n_layer = 32, 1 - _, objects = train(cfg) - trainer = objects["trainer"] - assert trainer.global_step == 2 - stages = objects["model"].model.pipeline.stages - optimizer = trainer.optimizers[0] - learning_rates = { - id(p): group["lr"] for group in optimizer.param_groups for p in group["params"] - } - assert all( - learning_rates[id(p)] == 5e-5 for p in stages[3].policy.model.parameters() - ) - assert all(learning_rates[id(p)] == 1e-5 for p in stages[0].encoder.parameters()) - checkpoint = tmp_path / "training/checkpoints/last.ckpt" - cfg.ckpt_path = str(checkpoint) - cfg.trainer.max_epochs = 2 - _, resumed = train(cfg) - assert resumed["trainer"].global_step == 4 - policy, protocol = load_policy(checkpoint, device="cpu") - assert protocol["arc_backbone"] == "oat_dp" - values = objects["datamodule"].train_datasets["libero_panda"][0] - values.pop("actions") - batch = { - key: value.unsqueeze(0) - for key, value in values.items() - if torch.is_tensor(value) - } - output = policy.algo.forward_eval({"libero_panda": batch}) - action = output["libero_panda"]["pred_action"] - assert action.shape == (1, 8, 7) and torch.isfinite(action).all() - - -def test_training_launcher_selects_backbone_and_keeps_legacy_default(): - from egomimic.benchmarks.libero.cluster import training_arguments - - args = ("arc_stk", "libero_spatial", "data.zarr", "out", "full", 5001) - matched = training_arguments(*args, gpus=4, arc_backbone="oat_dp") - assert "+experiment=oat/libero_arc_oat_dp_policy" in matched - assert "trainer.devices=4" in matched - assert "benchmark.batch_size=256" in matched - assert "trainer.accumulate_grad_batches=1" in matched - assert "+experiment=oat/libero_arc_stk_policy" in training_arguments(*args) - with pytest.raises(ValueError, match="Unknown ARC backbone"): - training_arguments(*args, arc_backbone="invalid") diff --git a/tests/test_oat_native.py b/tests/test_oat_native.py deleted file mode 100644 index 2969d1711e..0000000000 --- a/tests/test_oat_native.py +++ /dev/null @@ -1,381 +0,0 @@ -"""Numerical parity with the pinned upstream, plus native graph contracts.""" - -import os -from pathlib import Path - -import pytest -import torch - -from egomimic.models.oat.factory import make_tokenizer -from egomimic.models.oat.model.autoregressive.transformer_cache import ( - AutoregressiveModel, -) -from egomimic.models.oat.tokenizer.oat.encoder.register_encoder import ( - create_causal_last_mask, -) -from egomimic.models.oat.tokenizer.oat.quantizer.fsq import FSQ -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.stages_oat import OATPolicyStage, OATTokenizerStage - -pytest_plugins = ("tests.oat_reference_fixtures",) - -SMALL = dict( - horizon=8, - emb_dim=32, - head_dim=8, - encoder_depth=1, - decoder_depth=1, - num_registers=4, - levels=(3, 4), - dropout=0.0, -) - - -@pytest.mark.parametrize("component", ["tokenizer", "observation_encoder"]) -def test_cpu_checkpoint_preserves_normalizer_device(component): - from egomimic.models.oat.factory import make_obs_encoder - from egomimic.models.oat.model.common.normalizer import LinearNormalizer - - network = ( - make_tokenizer(**SMALL) if component == "tokenizer" else make_obs_encoder() - ) - checkpoint = network.state_dict() - # Meta reproduces the cross-device load contract on CPU-only CI. The real - # CUDA inference path is additionally checked by the L40S rollout workflow. - network.to("meta") - network.load_state_dict(checkpoint) - normalizers = [ - module for module in network.modules() if isinstance(module, LinearNormalizer) - ] - assert normalizers - assert all( - parameter.device.type == "meta" - for module in normalizers - for parameter in module.parameters() - ) - - -def reference_tokenizer(reference, config=None): - config = SMALL if config is None else config - enc = reference("tokenizer.oat.encoder.register_encoder").RegisterEncoder( - 7, - config["horizon"], - config["emb_dim"], - config["head_dim"], - config["encoder_depth"], - config["dropout"], - len(config["levels"]), - config["num_registers"], - ) - dec = reference("tokenizer.oat.decoder.single_pass_decoder").SinglePassDecoder( - sample_dim=7, - sample_horizon=config["horizon"], - emb_dim=config["emb_dim"], - head_dim=config["head_dim"], - depth=config["decoder_depth"], - pdropout=config["dropout"], - token_dropout_mode="pow2", - use_causal_decoder=True, - latent_dim=len(config["levels"]), - latent_horizon=config["num_registers"], - ) - quant = reference("tokenizer.oat.quantizer.fsq").FSQ(list(config["levels"])) - tok = reference("tokenizer.oat.tokenizer").OATTok(enc, dec, quant) - norm_module = reference("model.common.normalizer") - norm = norm_module.LinearNormalizer() - norm["action"] = norm_module.SingleFieldLinearNormalizer.create_identity() - tok.set_normalizer(norm) - return tok - - -def test_released_tokenizer_defaults_match_source_for_all_four_prefixes(reference): - config = dict( - horizon=32, - emb_dim=256, - head_dim=64, - encoder_depth=2, - decoder_depth=4, - dropout=0.1, - levels=(8, 5, 5, 5), - num_registers=8, - ) - native = make_tokenizer() - original = reference_tokenizer(reference, config) - original.load_state_dict(native.state_dict(), strict=True) - actions = torch.randn(2, 32, 7) - for tokenizer in (native, original): - tokenizer.eval() - with torch.no_grad(): - tokens = native.tokenize(actions) - assert tokens.shape == (2, 8) - assert torch.equal(tokens, original.tokenize(actions)) - for keep in (1, 2, 4, 8): - torch.testing.assert_close( - native.detokenize(tokens[:, :keep]), - original.detokenize(tokens[:, :keep]), - rtol=0, - atol=0, - ) - - -def test_task_catalog_is_exact_pinned_libero_source(reference): - import ast - - from egomimic.benchmarks.libero.catalog import TASKS - - source = Path(os.environ["OAT_REFERENCE_ROOT"]) / ( - "third_party/LIBERO/libero/libero/benchmark/libero_suite_task_map.py" - ) - tree = ast.parse(source.read_text()) - assignment = next(node for node in tree.body if isinstance(node, ast.Assign)) - assert list(ast.literal_eval(assignment.value).items()) == list(TASKS.items()) - - -def test_reference_tokenizer_loss_gradients_codes_and_prefixes(reference): - torch.manual_seed(41) - native = make_tokenizer(**SMALL) - original = reference_tokenizer(reference) - original.load_state_dict(native.state_dict(), strict=True) - actions = torch.randn(3, 8, 7) - torch.manual_seed(53) - loss = native({"action": actions}) - loss.backward() - torch.manual_seed(53) - reference_loss = original({"action": actions}) - reference_loss.backward() - torch.testing.assert_close(loss, reference_loss, rtol=0, atol=0) - for (name, p), (other_name, q) in zip( - native.named_parameters(), original.named_parameters() - ): - assert name == other_name - if p.grad is not None: - torch.testing.assert_close(p.grad, q.grad, rtol=0, atol=0) - native.eval() - original.eval() - tokens = native.tokenize(actions) - assert torch.equal(tokens, original.tokenize(actions)) - for keep in (1, 2, 4): - torch.testing.assert_close( - native.detokenize(tokens[:, :keep]), - original.detokenize(tokens[:, :keep]), - rtol=0, - atol=0, - ) - torch.testing.assert_close( - native.autoencode(actions, [1, 2, 4]), - original.autoencode(actions, [1, 2, 4]), - rtol=0, - atol=0, - ) - - -def test_reference_autoregression_logits_gradients_and_cached_sampling(reference): - kwargs = dict( - vocab_size=13, - max_seq_len=5, - max_cond_len=2, - cond_dim=6, - n_layer=2, - n_head=2, - n_emb=16, - p_drop_emb=0, - p_drop_attn=0, - ) - native = AutoregressiveModel(**kwargs).eval() - original = ( - reference("model.autoregressive.transformer_cache") - .AutoregressiveModel(**kwargs) - .eval() - ) - original.load_state_dict(native.state_dict()) - tokens, cond = torch.randint(0, 13, (2, 4)), torch.randn(2, 2, 6) - torch.testing.assert_close( - native(tokens, cond), original(tokens, cond), rtol=0, atol=0 - ) - for temperature in (0, 1): - torch.manual_seed(81) - actual = native.generate( - tokens[:, :1], cond, 4, temperature=temperature, top_k=3 - ) - torch.manual_seed(81) - expected = original.generate( - tokens[:, :1], cond, 4, temperature=temperature, top_k=3 - ) - assert torch.equal(actual, expected) - # Cached greedy generation must equal repeated full-prefix inference. - expected = tokens[:, :1] - for _ in range(4): - expected = torch.cat( - (expected, native(expected, cond)[:, -1].argmax(-1, keepdim=True)), dim=1 - ) - assert torch.equal(native.generate(tokens[:, :1], cond, 4, temperature=0), expected) - - -def test_fsq_full_codebook_and_straight_through_gradients(): - fsq = FSQ([8, 5, 5, 5]) - ids = torch.arange(1000) - assert torch.equal(fsq.codes_to_indices(fsq.indices_to_embedding(ids)).long(), ids) - values = torch.randn(2, 8, 4, requires_grad=True) - quantized, indices = fsq(values) - assert indices.dtype == torch.long and indices.min() >= 0 and indices.max() < 1000 - quantized.sum().backward() - assert torch.isfinite(values.grad).all() and values.grad.abs().sum() > 0 - - -def test_registers_are_causal_and_actions_cannot_read_registers(): - mask = create_causal_last_mask(8, 4, "cpu") - assert mask[:8, :8].all() and not mask[:8, 8:].any() - assert mask[8:, :8].all() - assert torch.equal(mask[8:, 8:], torch.ones(4, 4, dtype=torch.bool).tril()) - tok = make_tokenizer(**SMALL).eval() - x = torch.randn(2, 8, 7) - before = tok.encoder(x) - with torch.no_grad(): - tok.encoder.registers[2:] += 3 - torch.testing.assert_close(tok.encoder(x)[:, :2], before[:, :2]) - - -def test_dropped_suffix_cannot_change_prefix_reconstruction(): - tok = make_tokenizer(**SMALL).eval() - tokens = tok.tokenize(torch.randn(2, 8, 7)) - latent = tok.quantizer.indices_to_embedding(tokens) - changed = latent.clone() - changed[:, 2:] += 100 - torch.testing.assert_close(tok.decode(latent, [2, 2]), tok.decode(changed, [2, 2])) - - -class TinyObservation(torch.nn.Module): - def __init__(self): - super().__init__() - self.projection = torch.nn.Linear(3, 6) - - def modalities(self): - return ["state"] - - def output_feature_dim(self): - return 6 - - def forward(self, obs): - return self.projection(obs["state"]) - - -def small_policy(tokenizer=None): - from egomimic.models.oat.policy.oatpolicy import OATPolicy - - return OATPolicy( - {"action": {"shape": [7]}, "obs": {"state": {"shape": [3], "type": "state"}}}, - TinyObservation(), - tokenizer or make_tokenizer(**SMALL), - n_action_steps=4, - n_obs_steps=2, - embed_dim=16, - n_layers=1, - n_heads=2, - dropout=0.1, - topk=1, - ) - - -def test_policy_graph_freezes_tokenizer_and_infers_without_actions(): - policy = small_policy() - stage = OATPolicyStage(policy) - algo = PipelineAlgo([stage], device="cpu") - algo.nets.train() - assert policy.model.training and policy.obs_encoder.training - assert all(not module.training for module in policy.action_tokenizer.modules()) - batch = { - "libero_panda": {"state": torch.randn(2, 2, 3), "actions": torch.randn(2, 8, 7)} - } - outputs = algo.forward_training(batch) - algo.compute_losses(outputs, batch)["loss"].backward() - assert policy.obs_encoder.projection.weight.grad is not None - assert all( - parameter.grad is None for parameter in policy.action_tokenizer.parameters() - ) - algo.nets.eval() - inference = algo.forward_eval( - {"libero_panda": {"state": batch["libero_panda"]["state"]}} - ) - assert inference["libero_panda"]["pred_action"].shape == (2, 8, 7) - - -def test_tokenizer_graph_checkpoint_roundtrip(): - algo = PipelineAlgo([OATTokenizerStage(make_tokenizer(**SMALL))], device="cpu") - data = {"libero_panda": {"actions": torch.randn(2, 8, 7)}} - output = algo.forward_training(data) - algo.compute_losses(output, data)["loss"].backward() - restored = PipelineAlgo([OATTokenizerStage(make_tokenizer(**SMALL))], device="cpu") - restored.nets.load_state_dict(algo.nets.state_dict(), strict=True) - algo.nets.eval() - restored.nets.eval() - torch.testing.assert_close( - algo.forward_eval(data)["libero_panda"]["pred_action"], - restored.forward_eval(data)["libero_panda"]["pred_action"], - ) - - -def test_reference_real_vision_encoder_and_policy_loss(reference): - from omegaconf import OmegaConf - - from egomimic.models.oat.factory import libero_shape_meta, make_obs_encoder - from egomimic.models.oat.policy.oatpolicy import OATPolicy - - meta = libero_shape_meta() - native_encoder = make_obs_encoder(meta) - original_encoder = reference( - "perception.fused_obs_encoder" - ).FusedObservationEncoder( - meta, - vision_encoder=OmegaConf.create( - { - "_target_": "oat.perception.robomimic_vision_encoder.RobomimicRgbEncoder", - "crop_shape": [76, 76], - } - ), - state_encoder=OmegaConf.create( - { - "_target_": "oat.perception.state_encoder.ProjectionStateEncoder", - "out_dim": None, - } - ), - ) - original_encoder.set_normalizer(native_encoder.vision_encoder.normalizer) - original_encoder.load_state_dict(native_encoder.state_dict(), strict=True) - obs = {key: torch.randn(1, 2, *spec["shape"]) for key, spec in meta["obs"].items()} - for mode in (False, True): - native_encoder.train(mode) - original_encoder.train(mode) - torch.manual_seed(8) - actual = native_encoder(obs) - torch.manual_seed(8) - expected = original_encoder(obs) - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - native = OATPolicy( - meta, native_encoder, make_tokenizer(**SMALL), 4, 2, 16, 1, 2, 0.1 - ) - original = reference("policy.oatpolicy").OATPolicy( - meta, original_encoder, reference_tokenizer(reference), 4, 2, 16, 1, 2, 0.1 - ) - original.load_state_dict(native.state_dict(), strict=True) - stage = OATPolicyStage(native).train() - original.train() - original.action_tokenizer.eval() - actions = torch.randn(1, 8, 7) - torch.manual_seed(81) - actual = stage.execute({**obs, "actions": actions}, mode="train")[ - "loss/oat_token_ce" - ] - torch.manual_seed(81) - expected = original({"obs": obs, "action": actions}) - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - - -def test_ema_decay_matches_upstream_counter(reference): - from egomimic.pl_utils.oat_training import OATEMACallback - - callback = OATEMACallback(use_warmup=True, power=0.75) - source = reference("model.diffusion.ema_model").EMAModel( - torch.nn.Linear(2, 2), power=0.75 - ) - for update in (1, 2, 3, 101, 10001): - assert callback._schedule(update) == source.get_decay(update - 1) diff --git a/tests/test_oat_step_budget.py b/tests/test_oat_step_budget.py deleted file mode 100644 index 45b12d0023..0000000000 --- a/tests/test_oat_step_budget.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Step-limited pilot provenance and weights-only callback metadata.""" - -from types import SimpleNamespace - -import pytest -import torch - -from egomimic.pl_utils.oat_training import ( - OATBatchBudgetCallback, - OATEMACallback, - OATTrainingBehavior, -) - - -@pytest.mark.parametrize( - "epochs,max_steps,expected", - [(3, -1, 30), (3, 17, 17), (-1, 17, 17), (3, 45, 30)], -) -def test_step_budget_honors_first_limit(epochs, max_steps, expected): - trainer = SimpleNamespace( - datamodule=SimpleNamespace( - train_datasets={"x": range(83)}, - train_dataloader_params={"x": {"batch_size": 2, "drop_last": True}}, - ), - accumulate_grad_batches=4, - world_size=1, - limit_train_batches=1.0, - max_epochs=epochs, - max_steps=max_steps, - is_global_zero=False, - ) - callback = OATBatchBudgetCallback(global_batch_size=8) - callback.on_fit_start(trainer, None) - assert callback.budget["total_optimizer_steps"] == expected - assert callback.budget["max_steps"] == max_steps - assert callback.budget["dropped_examples_per_epoch"] == 3 - assert trainer.limit_train_batches == 40 - - -def test_joint_budget_matches_actual_cycling_loader_and_preserves_both_sources(): - from lightning.pytorch.utilities.combined_loader import CombinedLoader - from torch.utils.data import DataLoader - - datasets = {"original": range(83), "sharpa": range(37)} - params = {name: {"batch_size": 16, "drop_last": True} for name in datasets} - trainer = SimpleNamespace( - datamodule=SimpleNamespace( - train_datasets=datasets, - train_dataloader_params=params, - train_loader_mode="max_size_cycle", - ), - accumulate_grad_batches=1, - world_size=1, - limit_train_batches=1.0, - max_epochs=-1, - max_steps=120000, - is_global_zero=False, - ) - callback = OATBatchBudgetCallback( - global_batch_size=32, expected_source_batches={"original": 16, "sharpa": 16} - ) - callback.on_fit_start(trainer, None) - loader = CombinedLoader( - {name: DataLoader(data, **params[name]) for name, data in datasets.items()}, - "max_size_cycle", - ) - seen = list(loader) - assert trainer.limit_train_batches == len(seen) == 5 - assert all( - {name: len(batch) for name, batch in item[0].items()} - == {"original": 16, "sharpa": 16} - for item in seen - ) - assert callback.budget["total_optimizer_steps"] == 120000 - assert callback.budget["global_batch_size"] == 32 - params["sharpa"]["batch_size"] = 8 - trainer.limit_train_batches = 1.0 - with pytest.raises(ValueError, match="preserve batch size"): - callback.on_fit_start(trainer, None) - - -@pytest.mark.parametrize("weights_only", [True, False]) -def test_weights_only_retains_ema_and_budget_without_optimizer( - monkeypatch, weights_only -): - from egomimic.models.oat import checkpoint as contract - - monkeypatch.setattr(contract, "validate_input_representation", lambda stages: {}) - stage = SimpleNamespace(normalizer_state={"scale": 1}, data_context={"split": 42}) - - class Model(torch.nn.Module): - def __init__(self): - super().__init__() - self.weight = torch.nn.Parameter(torch.tensor([2.0])) - self.model = SimpleNamespace(pipeline=SimpleNamespace(stages=[stage])) - - model = Model() - budget = OATBatchBudgetCallback() - budget.budget = {"total_optimizer_steps": 10000} - ema = OATEMACallback() - trainer = SimpleNamespace(callbacks=[budget, ema], global_step=0) - model.trainer = trainer - ema.on_fit_start(trainer, model) - ema._num_updates = 3 - ema._shadow["weight"].fill_(1.0) - behavior = OATTrainingBehavior() - behavior.bind(model) - checkpoint = {"state_dict": model.state_dict(), "global_step": 3} - if not weights_only: - checkpoint["optimizer_states"] = [{"sentinel": True}] - for callback in trainer.callbacks: - callback.on_save_checkpoint(trainer, model, checkpoint) - # Full-state model hook must not call callbacks a second time. - monkeypatch.setattr( - ema, "on_save_checkpoint", lambda *args: pytest.fail("twice") - ) - behavior.on_save_checkpoint(checkpoint) - assert checkpoint["ema_num_updates"] == 3 - assert checkpoint["ema_state_dict"]["weight"].item() == 1.0 - assert checkpoint["state_dict"]["weight"].item() == 2.0 - assert checkpoint["training_budget"]["total_optimizer_steps"] == 10000 - assert checkpoint["normalizer_state"] == {"scale": 1} - assert ("optimizer_states" not in checkpoint) == weights_only - - -@pytest.mark.parametrize("basis", ["uniform", "fourier", "chebyshev"]) -@pytest.mark.parametrize("mode", ["smoke", "full"]) -@pytest.mark.parametrize("gpus", [1, 4]) -def test_pilot_arguments_compose_matched_budget_and_checkpoint_policy( - tmp_path, basis, mode, gpus -): - from pathlib import Path - - from hydra import compose, initialize_config_dir - from hydra.utils import instantiate - - from egomimic.benchmarks.libero.cluster import global_basis_training_arguments - - args = global_basis_training_arguments( - basis, tmp_path / "data.zarr", tmp_path, mode, f"test-{basis}-{mode}", gpus=gpus - ) - with initialize_config_dir( - config_dir=str(Path(__file__).parents[1] / "egomimic/hydra_configs"), - version_base="1.3", - ): - cfg = compose(config_name="train_zarr_cartesian", overrides=args[3:]) - assert cfg.benchmark.arc_basis == basis - assert cfg.trainer.max_steps == (16 if mode == "smoke" else 10000) - assert cfg.trainer.max_epochs == -1 - assert cfg.trainer.devices == gpus - assert cfg.model.train_log_on_step - assert cfg.trainer.check_val_every_n_epoch is None - cp = cfg.callbacks.model_checkpoint - assert cp.every_n_train_steps == (8 if mode == "smoke" else 2500) - assert ( - cfg.trainer.val_check_interval - == cp.every_n_train_steps * cfg.trainer.accumulate_grad_batches - ) - assert cp.every_n_epochs == 0 - assert cp.save_weights_only and not cp.save_last and cp.save_top_k == -1 - assert not cp.save_on_train_epoch_end - assert cfg.callbacks.ema.final_checkpoint_path is None - assert "{epoch:" in cp.filename and "{step:" in cp.filename - instantiate(cp) # Real callback argument validation, no training. - expected_batch = 4 * gpus if mode == "smoke" else 1024 - assert cfg.callbacks.batch_budget.global_batch_size == expected_batch - assert ( - cfg.benchmark.batch_size * gpus * cfg.trainer.accumulate_grad_batches - == expected_batch - ) - assert cfg.logger.wandb.resume == "never" and not cfg.logger.wandb.offline - - -def test_early_recovery_save_has_full_state_and_refuses_overwrite(tmp_path): - import json - from types import SimpleNamespace - - import torch - - from egomimic.pl_utils.oat_training import EarlyRecoveryCheckpoint - - callback = EarlyRecoveryCheckpoint(str(tmp_path), first_step=2) - state = { - "global_step": 2, - "ema_num_updates": 2, - "state_dict": {"weight": torch.ones(2)}, - "optimizer_states": [{"state": {0: {"moment": torch.zeros(2)}}}], - "lr_schedulers": [{"last_epoch": 2}], - "data_context": {"bound": True}, - } - - def save(path, weights_only): - assert weights_only is False - torch.save(state, path) - - trainer = SimpleNamespace( - global_step=1, current_epoch=0, world_size=1, save_checkpoint=save - ) - callback.on_train_batch_end(trainer, None, None, None, 0) - assert not list(tmp_path.iterdir()) - trainer.global_step = 2 - callback.on_train_batch_end(trainer, None, None, None, 1) - path = next((tmp_path / "checkpoints").glob("*.ckpt")) - proof = json.loads(path.with_suffix(".ckpt.recovery.json").read_text()) - assert ( - proof["global_step"] == 2 - and proof["full_state"] - and proof["bytes"] == path.stat().st_size - ) - callback.on_train_batch_end(trainer, None, None, None, 1) - with pytest.raises(FileExistsError, match="overwrite"): - EarlyRecoveryCheckpoint(str(tmp_path), 2).on_train_batch_end( - trainer, None, None, None, 1 - ) diff --git a/tests/test_oat_training.py b/tests/test_oat_training.py deleted file mode 100644 index a3727b2200..0000000000 --- a/tests/test_oat_training.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Exercise the real shared training entrypoint and self-contained checkpoints.""" - -from pathlib import Path - -import pytest -import torch - -from tests.test_libero_benchmark import make_replay - - -def config_for(experiment, path, output): - from hydra import compose, initialize_config_dir - - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(root)): - return compose( - config_name="train_zarr_cartesian", - overrides=[ - f"+experiment=oat/{experiment}", - f"benchmark.dataset={path}", - "benchmark.batch_size=2", - "benchmark.horizon=8", - "benchmark.n_action_steps=4", - f"paths.output_dir={output}", - f"paths.work_dir={output}", - f"norm_stats.save_cache_dir={output}", - "trainer.accelerator=cpu", - "trainer.precision=32-true", - "trainer.devices=1", - "trainer.max_epochs=1", - "trainer.limit_train_batches=2", - "trainer.limit_val_batches=1", - "trainer.accumulate_grad_batches=1", - "callbacks.batch_budget=null", - "trainer.check_val_every_n_epoch=1", - "callbacks.model_checkpoint.every_n_epochs=1", - "data.train_dataloader_params.libero_panda.num_workers=0", - "data.train_dataloader_params.libero_panda.persistent_workers=false", - "data.valid_dataloader_params.libero_panda.num_workers=0", - "data.valid_dataloader_params.libero_panda.persistent_workers=false", - ], - ) - - -@pytest.mark.parametrize("precision", ["32-true", "bf16-mixed"]) -def test_shared_training_tokenizer_then_policy_reload_and_resume(tmp_path, precision): - from egomimic.benchmarks.libero.rollout import load_policy - from egomimic.models.oat.factory import load_tokenizer - from egomimic.trainHydra import train - - path = tmp_path / "replay.zarr" - make_replay(path) - tokenizer_cfg = config_for("libero_oattok", path, tmp_path / "tokenizer") - tokenizer_cfg.trainer.precision = precision - spec = tokenizer_cfg.model.pipeline.stages[0].tokenizer - spec.emb_dim, spec.head_dim = 32, 8 - spec.encoder_depth, spec.decoder_depth, spec.num_registers = 1, 1, 4 - metrics, objects = train(tokenizer_cfg) - assert objects["trainer"].global_step == 2 - checkpoint = tmp_path / "tokenizer/checkpoints/last.ckpt" - tokenizer = load_tokenizer(checkpoint) - assert tokenizer.latent_horizon == 4 - assert "Valid/reconst_mse" in metrics - policy_cfg = config_for("libero_oatpolicy", path, tmp_path / "policy") - policy_cfg.trainer.precision = precision - policy_cfg.benchmark.tokenizer_checkpoint = str(checkpoint) - policy_cfg.model.pipeline.stages[0].policy.embed_dim = 16 - policy_cfg.model.pipeline.stages[0].policy.n_layers = 1 - policy_cfg.model.pipeline.stages[0].policy.n_heads = 2 - _, policy_objects = train(policy_cfg) - assert policy_objects["trainer"].global_step == 2 - policy_checkpoint = tmp_path / "policy/checkpoints/last.ckpt" - # Resume through shared Lightning, including optimizer/EMA state. - policy_cfg.ckpt_path = str(policy_checkpoint) - policy_cfg.trainer.max_epochs = 2 - _, resumed = train(policy_cfg) - assert resumed["trainer"].global_step == 4 - checkpoint.unlink() - restored, protocol = load_policy(policy_checkpoint, device="cpu") - assert protocol["horizon"] == 8 - observation = { - "agentview_rgb": torch.zeros(128, 128, 3).numpy(), - "robot0_eye_in_hand_rgb": torch.zeros(128, 128, 3).numpy(), - "robot0_eef_pos": torch.zeros(3).numpy(), - "robot0_eef_quat": torch.tensor([0, 0, 0, 1]).numpy(), - "robot0_gripper_qpos": torch.zeros(2).numpy(), - "task_uid": torch.tensor([30]).numpy(), - } - restored.reset(observation) - actions = restored.predict() - assert actions.shape == (4, 7) - assert torch.isfinite(torch.from_numpy(actions)).all() - - -@pytest.mark.parametrize("precision", ["32-true", "bf16-mixed"]) -@pytest.mark.parametrize("arc_mode", ["joint_dur", "dur", "stk"]) -def test_arc_policy_uses_shared_training_graph_and_can_infer_without_targets( - tmp_path, precision, arc_mode -): - from egomimic.benchmarks.libero.rollout import load_policy - from egomimic.trainHydra import _load_eval_checkpoint, train - - path = tmp_path / "replay.zarr" - make_replay(path) - experiment = ( - "libero_arc_policy" - if arc_mode == "joint_dur" - else f"libero_arc_{arc_mode}_policy" - ) - cfg = config_for(experiment, path, tmp_path / "arc") - cfg.trainer.precision = precision - cfg.benchmark.arc_waypoints = 8 - denoiser = cfg.model.pipeline.stages[3] - denoiser.policy.model.down_dims = [32, 64] - denoiser.policy.model.diffusion_step_embed_dim = 32 - denoiser.policy.num_inference_steps = 2 - _, objects = train(cfg) - assert objects["trainer"].global_step == 2 - policy, _ = load_policy(tmp_path / "arc/checkpoints/last.ckpt", device="cpu") - assert policy.algo.pipeline.stages[1].arc_mode == arc_mode - assert policy.algo.pipeline.stages[-1].arc_mode == arc_mode - assert policy.algo.pipeline.stages[1].velocity_norm_bound == pytest.approx(3**0.5) - assert policy.algo.pipeline.stages[-1].velocity_norm_bound == pytest.approx(3**0.5) - dataset = objects["datamodule"].train_datasets["libero_panda"] - values = dataset[0] - values.pop("actions") - batch = { - key: value.unsqueeze(0) - for key, value in values.items() - if torch.is_tensor(value) - } - result = policy.algo.forward_eval({"libero_panda": batch}) - assert result["libero_panda"]["pred_action"].shape == (1, 8, 7) - payload = torch.load( - tmp_path / "arc/checkpoints/last.ckpt", map_location="cpu", weights_only=False - ) - _load_eval_checkpoint(objects["model"], payload, cfg) - context = payload["normalizer_state"]["benchmark_context"] - context["observations_sha256"] = "changed-camera-replay" - with pytest.raises(ValueError, match="different benchmark"): - objects["model"].on_load_checkpoint(payload) - with pytest.raises(ValueError, match="different benchmark"): - _load_eval_checkpoint(objects["model"], payload, cfg) - - -def test_final_checkpoint_includes_epoch_after_periodic_checkpoint(tmp_path): - from egomimic.trainHydra import train - - path = tmp_path / "replay.zarr" - make_replay(path) - cfg = config_for("libero_oattok", path, tmp_path / "tokenizer") - spec = cfg.model.pipeline.stages[0].tokenizer - spec.emb_dim, spec.head_dim = 32, 8 - spec.encoder_depth, spec.decoder_depth, spec.num_registers = 1, 1, 4 - cfg.trainer.max_epochs = 3 - cfg.callbacks.model_checkpoint.every_n_epochs = 2 - _, objects = train(cfg) - payload = torch.load( - tmp_path / "tokenizer/checkpoints/last.ckpt", - map_location="cpu", - weights_only=False, - ) - assert payload["global_step"] == objects["trainer"].global_step == 6 - assert payload["ema_num_updates"] == 6 - assert payload["loops"]["fit_loop"]["epoch_progress"]["current"]["completed"] == 3 - - -def test_accumulation_preserves_global_batch_drop_last_steps_and_ema(tmp_path): - from omegaconf import OmegaConf - - from egomimic.trainHydra import train - - path = tmp_path / "replay.zarr" - make_replay(path) # 90 train examples: 11 full batches of 8, two dropped. - cfg = config_for("libero_oattok", path, tmp_path / "tokenizer") - spec = cfg.model.pipeline.stages[0].tokenizer - spec.emb_dim, spec.head_dim = 32, 8 - spec.encoder_depth, spec.decoder_depth, spec.num_registers = 1, 1, 4 - cfg.trainer.limit_train_batches = 1.0 - cfg.trainer.accumulate_grad_batches = 4 - cfg.callbacks.batch_budget = OmegaConf.create( - { - "_target_": "egomimic.pl_utils.oat_training.OATBatchBudgetCallback", - "global_batch_size": 8, - "report_path": str(tmp_path / "training-budget.json"), - } - ) - _, objects = train(cfg) - payload = torch.load( - tmp_path / "tokenizer/checkpoints/last.ckpt", - map_location="cpu", - weights_only=False, - ) - assert objects["trainer"].num_training_batches == 44 - assert payload["global_step"] == payload["ema_num_updates"] == 11 - assert payload["training_budget"]["total_optimizer_steps"] == 11 - assert payload["training_budget"]["dropped_examples_per_epoch"] == 2 - cfg.ckpt_path = str(tmp_path / "tokenizer/checkpoints/last.ckpt") - cfg.trainer.max_epochs = 2 - _, resumed = train(cfg) - payload = torch.load(cfg.ckpt_path, map_location="cpu", weights_only=False) - assert resumed["trainer"].global_step == payload["ema_num_updates"] == 22 - assert payload["training_budget"]["total_optimizer_steps"] == 22 diff --git a/tests/test_oat_yam_observation_boundary.py b/tests/test_oat_yam_observation_boundary.py deleted file mode 100644 index e84f32b2bf..0000000000 --- a/tests/test_oat_yam_observation_boundary.py +++ /dev/null @@ -1,218 +0,0 @@ -import pytest -import torch - -from egomimic.pipeline.stages_oat import OATObservationStage - - -class Recorder(torch.nn.Module): - def forward(self, obs): - self.obs = obs - return torch.cat([v.flatten(2) for v in obs.values()], -1) - - -def test_historical_default_unchanged(): - encoder = Recorder() - stage = OATObservationStage(encoder, ["state"]) - x = torch.arange(12.0).reshape(2, 2, 3) - batch = {"state": x} - stage(batch) - assert encoder.obs["state"] is x - assert torch.equal(batch["condition"], x.flatten(1)) - - -def test_native_yam_alias_layout_range_and_three_views(): - encoder = Recorder() - keys = ["front", "left", "right"] - bindings = {k: k + "_rgb" for k in keys} - bindings["pose"] = "ee_pose" - stage = OATObservationStage( - encoder, - keys + ["pose"], - key_bindings=bindings, - chw_image_keys=keys, - image_scale=2, - image_offset=-1, - native_data_context=True, - ) - batch = {k: torch.linspace(0, 1, 48).reshape(1, 2, 3, 2, 4) for k in keys} - batch["pose"] = torch.randn(1, 2, 14) - original = {k: v.clone() for k, v in batch.items()} - stage(batch) - for k in keys: - assert encoder.obs[k + "_rgb"].shape == (1, 2, 2, 4, 3) - assert torch.equal( - encoder.obs[k + "_rgb"], original[k].permute(0, 1, 3, 4, 2) * 2 - 1 - ) - assert torch.equal(batch[k], original[k]) - assert encoder.obs["ee_pose"] is batch["pose"] - assert batch["condition"].shape == (1, 172) - - -def test_bad_layout_fails_closed(): - stage = OATObservationStage(Recorder(), ["front"], chw_image_keys=["front"]) - with pytest.raises(ValueError): - stage({"front": torch.zeros(1, 2, 4, 4, 3)}) - - -def test_native_jpeg_float64_becomes_float32_without_mutating_input(): - encoder = Recorder() - stage = OATObservationStage( - encoder, ["front"], chw_image_keys=["front"], image_scale=2, image_offset=-1 - ) - image = torch.ones(1, 2, 3, 2, 4, dtype=torch.float64) - stage({"front": image}) - assert encoder.obs["front"].dtype == torch.float32 - assert image.dtype == torch.float64 - - -def test_yam_energy_score_declared_horizon32_and_old100_guard(): - from egomimic.eval.yam_action_flow_eval import YamCartesianActionFlowEval - - class Norm: - def unnormalize(self, data, embodiment_id): - return data - - value = YamCartesianActionFlowEval( - yam_action_horizon=32, energy_score_enabled=False - ) - value.normalizer = Norm() - samples = torch.zeros(32, 2, 32, 14) - target = torch.zeros(2, 32, 14) - answer = value._energy_values(samples, target, 7) - assert all(torch.isfinite(x).all() and not x.any() for x in answer.values()) - assert value.energy_score_distance_metadata["complete_normalized_chunk_shape"] == [ - 32, - 14, - ] - old = YamCartesianActionFlowEval(energy_score_enabled=False) - old.normalizer = Norm() - with pytest.raises(ValueError, match="100,14"): - old._energy_values(samples, target, 7) - - -def test_yam_openloop_horizon32_preserves_native_backend(): - from egomimic.eval.yam_human_open_loop_eval import YamHumanOpenLoopEval - - value = YamHumanOpenLoopEval( - robot_prediction_horizon=32, limit_val_batches=1.0, energy_score_enabled=False - ) - assert value.robot_prediction_horizon == 32 and value.yam_action_horizon == 32 - assert value.energy_score_distance_metadata["yam_bimanual"][ - "complete_normalized_chunk_shape" - ] == [32, 14] - - -def test_single_source_checkpoint_callback_yam_and_legacy_identity(): - from types import SimpleNamespace - - from egomimic.utils.libero_dit_half import LiberoDiTHalf - - old = LiberoDiTHalf() - assert old.state_dict() == { - "policy": "dit-half", - "homogeneous": "not_applicable_single_source", - } - yam = LiberoDiTHalf(source_key="yam_bimanual") - yam.scope = SimpleNamespace(counts={"encoder/direct": 0}) - yam.on_train_batch_start(None, None, {"yam_bimanual": {}}, 0) - assert yam.before == {"encoder/direct": 0} - yam.load_state_dict(yam.state_dict()) - with pytest.raises(ValueError, match="single source"): - yam.on_train_batch_start(None, None, {"libero_panda": {}}, 0) - with pytest.raises(ValueError, match="identity mismatch"): - yam.load_state_dict(old.state_dict()) - - -@pytest.mark.parametrize("routed", [False, True]) -def test_selective_checkpoint_periods_preserve_values_gradients_and_rng(routed): - import copy - from types import SimpleNamespace - - from torch.utils.checkpoint import checkpoint - - from egomimic.utils.libero_dit_half import HalfCheckpointScope, LiberoDiTHalf - - torch.manual_seed(7) - original = { - name: SimpleNamespace( - gradient_checkpointing=True, - blocks=torch.nn.ModuleList( - [ - torch.nn.Sequential(torch.nn.Linear(8, 8), torch.nn.Dropout(0.1)) - for _ in range(12) - ] - ), - ) - for name in ( - ("encoder:yam", "encoder:human", "velocity") - if routed - else ("encoder", "velocity") - ) - } - outputs = [] - for periods in (None, {"encoder": 2, "velocity": 4}): - backbones = copy.deepcopy(original) - native = SimpleNamespace(checkpoint=checkpoint) - scope = HalfCheckpointScope( - native, backbones, checkpoint_periods=periods - ).install() - torch.manual_seed(17) - loss = 0 - for backbone in backbones.values(): - value = torch.ones(2, 8, requires_grad=True) - for block in backbone.blocks: - value = native.checkpoint(block, value, use_reentrant=False) - loss = loss + value.square().sum() - loss.backward() - outputs.append( - ( - loss.detach(), - [p.grad for b in backbones.values() for p in b.blocks.parameters()], - torch.get_rng_state(), - ) - ) - for name in original: - if name.startswith("encoder"): - assert scope.counts[f"{name}/checkpoint"] == 6 - assert scope.counts["velocity/checkpoint"] == (6 if periods is None else 3) - scope.restore() - assert native.checkpoint is checkpoint - for a, b in zip(outputs[0], outputs[1]): - if isinstance(a, list): - for x, y in zip(a, b): - torch.testing.assert_close(x, y, rtol=0, atol=0) - else: - torch.testing.assert_close(a, b, rtol=0, atol=0) - candidate = LiberoDiTHalf(checkpoint_periods={"encoder": 2, "velocity": 4}) - candidate.load_state_dict(candidate.state_dict()) - with pytest.raises(ValueError, match="identity mismatch"): - candidate.load_state_dict(LiberoDiTHalf().state_dict()) - - -@pytest.mark.parametrize( - "periods", - [ - {"encoder": 2}, - {"encoder": 2, "velocity": True}, - {"encoder": 2, "velocity": 5}, - {"encoder": 2, "velocity": 1}, - ], -) -def test_selective_checkpoint_periods_reject_invalid_settings(periods): - from egomimic.utils.libero_dit_half import LiberoDiTHalf - - with pytest.raises(ValueError, match="checkpoint periods"): - LiberoDiTHalf(checkpoint_periods=periods) - - -def test_observation_affine_constructor_supports_meta_and_rejects_nonfinite(): - with torch.device("meta"): - stage = OATObservationStage( - Recorder(), ["state"], image_scale=2, image_offset=-1 - ) - assert stage.image_scale == 2 and stage.image_offset == -1 - for value in (float("nan"), float("inf"), -float("inf")): - with pytest.raises(ValueError, match="affine must be finite"): - OATObservationStage(Recorder(), ["state"], image_scale=value) - with pytest.raises(ValueError, match="affine must be finite"): - OATObservationStage(Recorder(), ["state"], image_offset=value) diff --git a/tests/test_offline_constructor_inputs.py b/tests/test_offline_constructor_inputs.py deleted file mode 100644 index b2eae6c1dc..0000000000 --- a/tests/test_offline_constructor_inputs.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Structural fixtures cannot become learned initialization or run evidence.""" - -import json - -import pytest -import torch -from hydra.utils import instantiate -from omegaconf import OmegaConf - -from scripts.audit_components import offline_static_buffer_construction -from scripts.audit_constructor_inputs import evaluator_inputs, model_inputs -from scripts.audit_hydra_configs import CONFIGS, compose_for_audit - - -def test_native_fixture_is_explicit_and_does_not_read_runtime_snapshot(monkeypatch): - monkeypatch.setattr( - "egomimic.benchmarks.libero.native_artifact_identity._snapshot", - lambda *args: pytest.fail("offline constructor read a runtime snapshot"), - ) - path = ( - CONFIGS - / "experiment/libero_historical/action_flow_libero10_h240_euler50_dithalf_80k_s42.yaml" - ) - with compose_for_audit(path) as cfg: - before = OmegaConf.to_container(cfg.evaluator, resolve=True) - inputs, evidence = evaluator_inputs(cfg.evaluator) - evaluator = instantiate(inputs) - assert evaluator.shared_diagnostics.max_samples == 8 - assert evaluator.shared_diagnostics.activation_layer_map == tuple( - (i, i) for i in range(12) - ) - assert evidence["external_binding"].startswith("UNVERIFIED") - assert OmegaConf.to_container(cfg.evaluator, resolve=True) == before - invalid = OmegaConf.create(before) - invalid.artifact_identity._target_ = "not.a.canonical.factory" - with pytest.raises(ValueError, match="canonical descriptors"): - evaluator_inputs(invalid) - - -@pytest.mark.parametrize( - ("recipe", "dimension", "horizon"), - [ - ("libero_oatpolicy", 7, 32), - # Use the declared ARC recipe geometry, not the native-action horizon. - ("libero_arc_oatpolicy", 12, 32), - ], -) -def test_meta_tokenizer_comes_from_declared_template_and_scope_restores( - recipe, dimension, horizon -): - from egomimic.models.oat import factory - - original = factory.load_tokenizer - with compose_for_audit(CONFIGS / f"experiment/oat/{recipe}.yaml") as cfg: - before = json.dumps(OmegaConf.to_container(cfg, resolve=True), sort_keys=True) - with ( - torch.device("meta"), - offline_static_buffer_construction(), - model_inputs(cfg, CONFIGS) as evidence, - ): - tokenizer = factory.load_tokenizer(None) - assert tokenizer.decoder.sample_dim == dimension - assert tokenizer.decoder.sample_horizon == horizon - assert all( - p.device.type == "meta" and not p.requires_grad - for p in tokenizer.parameters() - ) - assert evidence["external_binding"].startswith("UNVERIFIED") - assert not hasattr(tokenizer, "_training_data_context") - if recipe == "libero_arc_oatpolicy": - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - codec = instantiate(cfg.model.pipeline.stages[0]) - assert isinstance(codec, LiberoArcStage) - assert ( - tokenizer._training_input_representation - == codec.representation_context() - ) - else: - assert tokenizer._training_input_representation is None - assert factory.load_tokenizer is original - - assert ( - json.dumps(OmegaConf.to_container(cfg, resolve=True), sort_keys=True) - == before - ) - with pytest.raises(ValueError, match="trained OAT tokenizer"): - factory.load_tokenizer(None) - - -def test_initializer_rejects_cpu_use_and_explicit_checkpoint_and_restores(): - from egomimic.models.oat import factory - - original = factory.load_tokenizer - with compose_for_audit(CONFIGS / "experiment/oat/libero_oatpolicy.yaml") as cfg: - with pytest.raises(ValueError, match="restricted to meta"): - with model_inputs(cfg, CONFIGS): - factory.load_tokenizer(None) - assert factory.load_tokenizer is original - cfg.benchmark.tokenizer_checkpoint = "/real/run/tokenizer.ckpt" - with pytest.raises(ValueError, match="explicit learned checkpoint"): - with model_inputs(cfg, CONFIGS): - pytest.fail("explicit checkpoint replaced") - assert factory.load_tokenizer is original - - -def test_arc_structural_metadata_preserves_real_values_rng_and_method(): - from egomimic.pipeline.stages_libero_arc import LiberoArcStage - - original = LiberoArcStage.representation_context - with torch.device("cpu"): - expected = LiberoArcStage(num_waypoints=32, horizon=32).representation_context() - rng = torch.get_rng_state().clone() - with torch.device("meta"), offline_static_buffer_construction(): - codec = LiberoArcStage(num_waypoints=32, horizon=32) - assert codec.action_scale.device.type == "meta" - assert codec.representation_context() == expected - assert LiberoArcStage.representation_context is original - assert torch.equal(torch.get_rng_state(), rng) diff --git a/tests/test_offline_static_buffer_audit.py b/tests/test_offline_static_buffer_audit.py deleted file mode 100644 index a45daddc55..0000000000 --- a/tests/test_offline_static_buffer_audit.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest -import torch - -from egomimic.models.oat.tokenizer.oat.quantizer.fsq import FSQ -from scripts.audit_components import offline_static_buffer_construction - - -@pytest.mark.parametrize("levels", [(8, 5, 5, 5), (3, 4)]) -def test_static_lookup_buffers_match_real_constructor_and_rng(levels): - original = FSQ.__init__ - baseline = FSQ(list(levels)) - before = torch.get_rng_state().clone() - with offline_static_buffer_construction(): - candidate = FSQ(list(levels)) - with torch.device("meta"): - meta = FSQ(list(levels)) - assert FSQ.__init__ is original - assert torch.equal(before, torch.get_rng_state()) - assert candidate.codebook_size == meta.codebook_size == baseline.codebook_size - assert not tuple(candidate.parameters()) - for name, value in baseline.named_buffers(): - actual = dict(candidate.named_buffers())[name] - metadata = dict(meta.named_buffers())[name] - assert torch.equal(value, actual) - assert metadata.device.type == "meta" - assert metadata.shape == value.shape and metadata.dtype == value.dtype - - -def test_scope_restores_constructor_on_failure(): - original = FSQ.__init__ - linspace = torch.linspace - with pytest.raises(RuntimeError, match="fixture failure"): - with offline_static_buffer_construction(): - raise RuntimeError("fixture failure") - assert FSQ.__init__ is original - assert torch.linspace is linspace - - -def test_full_tokenizer_layout_and_scalar_schedule_scope(): - from egomimic.models.oat.factory import make_tokenizer - - baseline = make_tokenizer() - before = torch.get_rng_state().clone() - with offline_static_buffer_construction(), torch.device("meta"): - meta = make_tokenizer() - assert torch.linspace(0, 1, 3).device.type == "meta" - assert torch.equal(before, torch.get_rng_state()) - assert baseline.state_dict().keys() == meta.state_dict().keys() - for name, value in baseline.state_dict().items(): - candidate = meta.state_dict()[name] - assert candidate.shape == value.shape and candidate.dtype == value.dtype diff --git a/tests/test_open_loop_sim_eval.py b/tests/test_open_loop_sim_eval.py deleted file mode 100644 index bbd8e0b57b..0000000000 --- a/tests/test_open_loop_sim_eval.py +++ /dev/null @@ -1,336 +0,0 @@ -import sys -from types import SimpleNamespace - -import numpy as np -import pytest -import torch - -from egomimic.eval.open_loop_sim import ( - OpenLoopSimEval, - executed_control_steps, - truncate_arc_token, -) -from egomimic.eval.video import EvalVideo - - -def _baseline_evaluator(execute_steps=2): - evaluator = OpenLoopSimEval.__new__(OpenLoopSimEval) - evaluator.execute_fraction = 0.5 - evaluator.control_horizon = 4 - evaluator.control_dt = 1.0 / 30.0 - evaluator.execute_steps = execute_steps - evaluator.action_mode = "baseline" - evaluator.resampled_vector_length = 4 - evaluator.velocity_mode = "mean" - evaluator.min_distance_unit = 0.4 - evaluator._arc_tokenizer = None - evaluator.require_episode_start = True - evaluator.limit_val_episodes = None - evaluator.configure_video( - source_fps=30.0, sample_id_key="episode_hash", frame_index_key="frame_index" - ) - return evaluator - - -def test_execute_fraction_is_a_control_frequency_prefix(): - assert executed_control_steps(100, 0.01) == 1 - assert executed_control_steps(100, 0.25) == 25 - assert executed_control_steps(100, 1.0) == 100 - with pytest.raises(ValueError, match="execute_fraction"): - executed_control_steps(100, 0.0) - - -@pytest.mark.parametrize( - ("mode", "rows", "expected_rows"), - [("mean", 101, 26), ("per_waypoint", 200, 50), ("duration", 200, 50)], -) -def test_truncate_arc_token_keeps_matching_timing_rows(mode, rows, expected_rows): - token = np.arange(rows * 14, dtype=np.float64).reshape(rows, 14) - truncated = truncate_arc_token(token, 0.25, mode) - assert truncated.shape == (expected_rows, 14) - M = rows - 1 if mode == "mean" else rows // 2 - K = 25 - np.testing.assert_array_equal(truncated[:K], token[:K]) - if mode == "mean": - np.testing.assert_array_equal(truncated[K:], token[M : M + 1]) - else: - np.testing.assert_array_equal(truncated[K:], token[M : M + K]) - - -def test_open_loop_sim_walks_an_episode_in_executed_prefixes(): - evaluator = _baseline_evaluator() - records = [] - for frame in range(6): - records.append( - { - "source": "human_bimanual", - "label": "human_bimanual", - "episode": "episode-a", - "frame": frame, - "prediction": np.ones((4, 14)), - "ground_truth": np.zeros((4, 14)), - } - ) - - result = evaluator._score_episode(records) - assert result["executed_steps"] == 6 - assert result["segments"] == 3 - assert result["coverage"] == pytest.approx(1.0) - assert result["metrics"]["mse"] == pytest.approx(1.0) - assert result["metrics"]["xyz_mse"] == pytest.approx(1.0) - - -def test_open_loop_sim_requires_complete_episode_frames(): - evaluator = _baseline_evaluator() - records = [ - { - "source": "human_bimanual", - "label": "human_bimanual", - "episode": "episode-a", - "frame": frame, - "prediction": np.zeros((4, 14)), - "ground_truth": np.zeros((4, 14)), - } - for frame in (0, 1, 3) - ] - with pytest.raises(RuntimeError, match="missing 1 frames"): - evaluator._score_episode(records) - - -def test_open_loop_sim_keeps_validation_groups_separate(): - evaluator = _baseline_evaluator() - records = [] - for group, offset in (("valid", 0.0), ("nested", 1.0)): - for frame in range(2): - records.append( - { - "group": group, - "source": "human_bimanual", - "label": "human_bimanual", - "episode": f"episode-{group}", - "frame": frame, - "prediction": np.full((4, 14), offset + 1.0), - "ground_truth": np.full((4, 14), offset), - } - ) - - result = evaluator._compute_results(records) - assert result["groups"] == ["nested", "valid"] - assert result["per_group"]["valid"]["micro"]["mse"] == pytest.approx(1.0) - assert result["per_group"]["nested"]["micro"]["mse"] == pytest.approx(1.0) - - -def test_open_loop_sim_limits_complete_episodes_not_batches(): - evaluator = _baseline_evaluator() - evaluator.limit_val_episodes = 1 - records = [] - for episode in ("episode-b", "episode-a"): - for frame in range(2): - records.append( - { - "group": "valid", - "source": "human_bimanual", - "label": "human_bimanual", - "episode": episode, - "frame": frame, - "prediction": np.ones((4, 14)), - "ground_truth": np.zeros((4, 14)), - } - ) - - result = evaluator._compute_results(records) - assert result["episodes"] == 1 - assert result["episode_results"][0]["episode"] == "episode-a" - - -def test_open_loop_sim_detokenizes_only_the_executed_arc_prefix(): - evaluator = OpenLoopSimEval.__new__(OpenLoopSimEval) - evaluator.execute_fraction = 0.25 - evaluator.execute_steps = 2 - evaluator.action_mode = "auto" - evaluator.resampled_vector_length = 4 - evaluator.velocity_mode = "duration" - evaluator.min_distance_unit = 0.4 - evaluator.control_dt = 1.0 / 30.0 - evaluator._arc_tokenizer = None - - token = np.zeros((8, 14), dtype=np.float64) - token[:4, 0] = np.linspace(0.0, 0.3, 4) - token[:4, 7] = np.linspace(0.0, 0.3, 4) - token[4:, 0] = 0.1 - token[4:, 7] = 0.1 - decoded = evaluator._decode_prediction(token) - assert decoded.shape == (2, 14) - assert np.isfinite(decoded).all() - - -def test_open_loop_sim_logs_directly_through_trainer_logger(): - evaluator = _baseline_evaluator() - evaluator._metric_device = torch.device("cpu") - evaluator.results_path = None - evaluator._records = [ - { - "group": "valid", - "source": "human_bimanual", - "label": "human_bimanual", - "episode": "episode-a", - "frame": frame, - "prediction": np.ones((4, 14)), - "ground_truth": np.zeros((4, 14)), - } - for frame in range(2) - ] - logged = [] - evaluator.trainer = SimpleNamespace( - is_global_zero=True, - global_step=17, - logger=SimpleNamespace( - log_metrics=lambda metrics, **kwargs: logged.append((metrics, kwargs)) - ), - ) - - evaluator.on_validation_end() - - assert len(logged) == 1 - metrics, kwargs = logged[0] - assert kwargs == {"step": 17} - assert metrics["Valid/open_loop_sim/MSE"] == pytest.approx(1.0) - assert not any("dataloader_idx" in key for key in metrics) - - -def test_open_loop_video_writes_one_full_episode_mp4(tmp_path, monkeypatch): - evaluator = _baseline_evaluator() - evaluator.video_output_dir = tmp_path - evaluator.video_chunk_frames = 1000 - evaluator.max_episode_frames = 100 - evaluator.viz_every_n_epochs = 1 - evaluator.viz_max_batches = None - evaluator.trainer = SimpleNamespace( - is_global_zero=True, - current_epoch=0, - world_size=1, - default_root_dir=str(tmp_path), - logger=None, - ) - written = [] - - def fake_write_video(path, frames, **kwargs): - written.append((path, tuple(frames.shape), kwargs)) - - monkeypatch.setattr("egomimic.eval.video.tvio.write_video", fake_write_video) - EvalVideo.on_validation_start(evaluator) - buf_key = ("valid", "yam_bimanual") - out_dir = evaluator._group_video_dir(*buf_key) - frame = torch.zeros((4, 4, 3), dtype=torch.uint8) - evaluator._buffer_per_episode( - buf_key, out_dir, [frame, frame, frame], ["episode-a"] * 3 - ) - assert written == [] - evaluator._buffer_per_episode(buf_key, out_dir, [frame], ["episode-b"]) - EvalVideo.on_validation_end(evaluator) - - assert [item[0].rsplit("/", 1)[-1] for item in written] == [ - "episode-a.mp4", - "episode-b.mp4", - ] - assert [item[1][0] for item in written] == [3, 1] - - -def test_open_loop_video_uploads_only_first_episode_per_panel(monkeypatch): - evaluator = _baseline_evaluator() - logged = [] - experiment = SimpleNamespace( - id="run-id", - log=lambda payload, **kwargs: logged.append((payload, kwargs)), - ) - evaluator.trainer = SimpleNamespace( - is_global_zero=True, - world_size=1, - global_step=23, - logger=SimpleNamespace(experiment=experiment), - ) - evaluator._written_paths = [ - ("valid", "yam_bimanual", "/tmp/episode-a.mp4"), - ("valid", "yam_bimanual", "/tmp/episode-b.mp4"), - ("valid", "human_bimanual", "/tmp/episode-c.mp4"), - ("nested", "yam_bimanual", "/tmp/episode-d.mp4"), - ] - fake_wandb = SimpleNamespace(Video=lambda path, **kwargs: {"path": path, **kwargs}) - monkeypatch.setitem(sys.modules, "wandb", fake_wandb) - - OpenLoopSimEval._log_wandb_videos(evaluator) - - assert len(logged) == 1 - payload, kwargs = logged[0] - assert kwargs == {"step": 23} - assert sorted(payload) == [ - "Val_video/human_bimanual", - "Val_video/yam_bimanual", - "Val_video_nested/yam_bimanual", - ] - assert payload["Val_video/yam_bimanual"]["path"] == "/tmp/episode-a.mp4" - - -def test_nonzero_rank_finishes_video_collectives_before_return(monkeypatch): - evaluator = _baseline_evaluator() - evaluator._video_enabled = True - monkeypatch.setattr(evaluator, "_all_records", lambda: []) - monkeypatch.setattr( - evaluator, "_compute_results", lambda records: {"sentinel": True} - ) - calls = [] - monkeypatch.setattr( - EvalVideo, "on_validation_end", lambda self: calls.append("gather") - ) - monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True) - monkeypatch.setattr(torch.distributed, "get_rank", lambda: 1) - assert evaluator.on_validation_end() == {"sentinel": True} - assert calls == ["gather"] - - -def test_causal_history_video_uses_current_pose_and_current_image(): - from functools import partial - - evaluator = _baseline_evaluator() - evaluator._video_enabled = True - evaluator._validation_group = None - evaluator.obs_pose_key = "observations.state.ee_pose" - evaluator.image_key = "observations.images.front_img_1" - evaluator.action_key = "actions_cartesian" - evaluator.ground_truth_action_key = "actions_cartesian" - poses, rendered = [], [] - - def revert(*, actions, obs_pose, embodiment_name): - poses.append(obs_pose) - return actions - - def draw(*, predictions, batch): - rendered.append(batch[evaluator.image_key]) - return np.zeros((1, 4, 4, 3), dtype=np.uint8) - - evaluator.viz_func = {"yam_bimanual": partial(draw)} - evaluator._decoded_video_predictions = lambda value, embodiment_id: value - evaluator._native_key = lambda value, key, embodiment_id: value - evaluator._native_pose = lambda value, embodiment_id: value - evaluator._revert_to_camframe = revert - evaluator._record_video_frames = lambda *args: None - batch = { - evaluator.obs_pose_key: torch.stack( - [torch.zeros(1, 14), torch.ones(1, 14)], dim=1 - ), - evaluator.image_key: torch.stack( - [torch.zeros(1, 3, 4, 4), torch.ones(1, 3, 4, 4)], dim=1 - ), - evaluator.action_key: torch.zeros(1, 4, 14), - "embodiment": torch.tensor([7]), - "observation_frame_indices": torch.tensor([[2, 3]]), - } - evaluator._maybe_log_open_loop_video( - source_id="yam", - source_batch=batch, - prediction=torch.zeros(1, 2, 14), - embodiment_id=7, - embodiment_name="yam_bimanual", - ) - assert len(poses) == 2 and all(pose.equal(torch.ones(1, 14)) for pose in poses) - assert len(rendered) == 1 and rendered[0].equal(torch.ones(1, 4, 4, 3)) diff --git a/tests/test_original_size_action_flow_cotrain_config.py b/tests/test_original_size_action_flow_cotrain_config.py deleted file mode 100644 index 3118e4e95e..0000000000 --- a/tests/test_original_size_action_flow_cotrain_config.py +++ /dev/null @@ -1,72 +0,0 @@ -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -CONFIG_DIR = Path(__file__).parents[1] / "egomimic" / "hydra_configs" -ROW = "parents/action_flow_cotrain_uc_manual4919" - - -def test_original_size_cotrain_contract(monkeypatch): - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", "/verified/usocket") - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", "/verified/chain") - with initialize_config_dir(version_base=None, config_dir=str(CONFIG_DIR.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - # Component compatibility fixture, not a selectable training recipe. - # Keep the historical schedule assertions explicit after leaf retirement. - overrides=[ - f"+experiment=pusht/{ROW}", - "++paths.root_dir=.", - "trainer.max_steps=150000", - "trainer.val_check_interval=10000", - "callbacks.model_checkpoint.every_n_train_steps=30000", - ], - ) - - stages = cfg.model.pipeline.stages - encoder = next( - s for s in stages if s._target_.endswith("RoutedContentEncoderStage") - ) - decoder = next( - s for s in stages if s._target_.endswith("RoutedContentDecoderStage") - ) - fields = [s for s in stages if s._target_.endswith("ConditionalVelocityStage")] - field = fields[0] - objective = stages[-1] - routes = {"pushshapes_sim_u_socket", "pushshapes_sim_chain_gripper"} - - assert cfg.model._target_ == "egomimic.pl_utils.pl_model.ModelWrapper" - assert cfg.model.training_behavior._target_.endswith("ActionFlowTrainingBehavior") - assert cfg.model.hidden_dim == 384 - assert cfg.model.flow_samples_per_content == 14 - assert cfg.model.flow_mini_batch == 14 - assert cfg.model.cfg_scale == pytest.approx(4.0) - assert cfg.model.num_inference_steps == 50 - assert field.inference_method == "dopri5" - assert field.field.backbone.hidden_dim == 384 - assert field.field.backbone.depth == 12 - assert field.field.backbone.num_heads == 12 - assert len(fields) == 1 - assert set(encoder.encoders) == routes - assert set(decoder.decoders) == routes - assert encoder.encoders.pushshapes_sim_u_socket.action_dim == 4 - assert encoder.encoders.pushshapes_sim_chain_gripper.action_dim == 6 - assert decoder.decoders.pushshapes_sim_u_socket.action_dim == 4 - assert decoder.decoders.pushshapes_sim_chain_gripper.action_dim == 6 - assert objective.flow_aggregation == "sum_samples" - assert objective.action_velocity_weight == pytest.approx(1.0) - assert cfg.model.optimizer.lr == pytest.approx(1e-4) - assert cfg.model.scheduler.base_lr_1 == pytest.approx(1e-4) - assert cfg.model.scheduler.base_lr_2 == pytest.approx(5e-5) - assert cfg.model.scheduler.final_lr == pytest.approx(5e-5) - assert cfg.trainer.max_steps == 150_000 - assert cfg.trainer.val_check_interval == 10_000 - assert cfg.callbacks.model_checkpoint.every_n_train_steps == 30_000 - assert cfg.data.train_dataloader_params.pushshapes_sim_u_socket.batch_size == 32 - assert ( - cfg.data.train_dataloader_params.pushshapes_sim_chain_gripper.batch_size == 32 - ) - assert cfg.run_provenance.objective.domain_aggregation == ( - "equal_mean_from_one_batch32_per_domain" - ) diff --git a/tests/test_osmo_gate.py b/tests/test_osmo_gate.py deleted file mode 100644 index f87599913f..0000000000 --- a/tests/test_osmo_gate.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Exercise the same five-update/save/strict-reload harness before OSMO allocation.""" - -import hashlib -import io -import json - -import pytest -from omegaconf import OmegaConf, open_dict - -from scripts.integration import run_gate -from scripts.integration.prepare_inputs import copy_stream, relative_path -from tests.fixtures.synthetic_episodes import write_episode -from tests.test_retained_recipe_steps import small_cpu_model - - -def test_input_staging_checks_hashes_and_never_replaces_existing_data(tmp_path): - path = tmp_path / "verified.bin" - value = b"immutable-input" - sha = hashlib.sha256(value).hexdigest() - receipt = copy_stream( - io.BytesIO(value), path, expected_bytes=len(value), expected_sha256=sha - ) - assert receipt["sha256"] == sha and path.read_bytes() == value - with pytest.raises(FileExistsError): - copy_stream( - io.BytesIO(value), path, expected_bytes=len(value), expected_sha256=sha - ) - assert path.read_bytes() == value - with pytest.raises(ValueError, match="SHA-256"): - copy_stream( - io.BytesIO(b"bad"), - tmp_path / "changed.bin", - expected_bytes=3, - expected_sha256=sha, - ) - assert not (tmp_path / "changed.bin").exists() - with pytest.raises(ValueError, match="relative"): - relative_path("../../outside") - - -def harness_inputs(monkeypatch, tmp_path): - inputs, output = tmp_path / "inputs", tmp_path / "result" - output.mkdir() - data = inputs / "data/eva" - episodes = [] - for index in range(3): - write_episode(data, "eva", T=8, H=32, W=32, seed=index) - episodes.append( - { - "source": "eva", - "split": "train" if index < 2 else "valid", - "episode_hash": f"eva_{index:02}", - } - ) - (inputs / "data-manifest.json").write_text(json.dumps({"episodes": episodes})) - (inputs / "input-receipt.json").write_text('{"scope":"synthetic CPU fixture"}') - monkeypatch.setenv("SMOKE_INPUT_ROOT", str(inputs)) - matrix = OmegaConf.load(run_gate.ROOT / "scripts/integration/matrix.yaml") - matrix.trainer.accelerator = "cpu" - matrix.trainer.precision = "32-true" - # Tiny 32px fixtures collapse ResNet's last feature map to 1x1; BatchNorm - # still needs two samples. Real GPU cases retain their full image sizes. - matrix.cases["hpt-eva"].batch_size = 2 - matrix_path = tmp_path / "matrix.yaml" - OmegaConf.save(matrix, matrix_path) - return inputs, output, matrix_path - - -@pytest.mark.parametrize("export", [True, False]) -def test_wrong_target_frame_rejected_before_network_construction( - monkeypatch, tmp_path, export -): - import egomimic.trainHydra as entrypoint - - inputs, output, matrix_path = harness_inputs(monkeypatch, tmp_path) - cfg, _, _ = run_gate.configured_case(matrix_path, "hpt-eva", inputs, output) - cfg.inference_config.enabled = export - for split in ("train_datasets", "valid_datasets"): - ds = cfg.data[split].eva_bimanual - with open_dict(ds): - ds.bounds_check = False - ds.resolver.transform_list.coord_frame = "eef_frame" - - def forbidden(*args, **kwargs): - raise AssertionError("Network was constructed before frame preflight") - - monkeypatch.setattr(entrypoint, "_instantiate_model_wrapper", forbidden) - with pytest.raises(ValueError, match="coord_frame.*camframe.*eef_frame"): - entrypoint.train(cfg) - assert not (output / "checkpoints/inference-config.yaml").exists() - - -def test_training_harness_runs_five_updates_validation_and_bound_reload( - monkeypatch, tmp_path -): - inputs, output, matrix_path = harness_inputs(monkeypatch, tmp_path) - original = run_gate.configured_case - - def configured(*args, **kwargs): - cfg, _, spec = original(*args, **kwargs) - cfg = small_cpu_model(cfg, "hpt") - with open_dict(cfg): - cfg.model.pipeline.device = "cpu" - for split in ("train_datasets", "valid_datasets"): - cfg.data[split].eva_bimanual.bounds_check = False - saved = OmegaConf.masked_copy(cfg, [key for key in cfg if key != "hydra"]) - saved = OmegaConf.create(OmegaConf.to_container(saved, resolve=True)) - return cfg, saved, spec - - monkeypatch.setattr(run_gate, "configured_case", configured) - run_gate.training_gate(matrix_path, "hpt-eva", inputs, output) - receipt = json.loads((output / "training-receipt.json").read_text()) - assert receipt["steps"] == 5 and len(receipt["gradients"]) == 5 - assert receipt["gpu"] is None - assert ( - receipt["validation"] - and receipt["initial_parameter_probe"] != receipt["final_parameter_probe"] - ) - run_gate.verification_gate(output, device="cpu") - verified = json.loads((output / "inference-receipt.json").read_text()) - assert verified["checkpoint_sha256"] == receipt["checkpoint_sha256"] - assert [case["executed_shape"][1] for case in verified["cases"]] == [1, 2] diff --git a/tests/test_partial_normalization_recovery.py b/tests/test_partial_normalization_recovery.py deleted file mode 100644 index ea6a43caec..0000000000 --- a/tests/test_partial_normalization_recovery.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Recovery uses native provenance checks and computes only missing embodiments.""" - -import json - -import numpy as np -import pytest - -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset - - -def fixture(tmp_path): - path = tmp_path / "partial.json" - path.write_text( - json.dumps( - { - "stats": {"3": {"actions": {"min": [0.0], "max": [1.0]}}, "7": {}}, - "provenance": {"norm_mode": "minmax"}, - } - ) - ) - normalizer = MultiDataset(state={}, norm_mode="minmax") - normalizer.key_types = { - 3: {"actions": "action_keys"}, - 7: {"actions": "action_keys"}, - } - return normalizer, path - - -def test_recovery_reuses_completed_and_computes_missing(tmp_path, monkeypatch): - normalizer, path = fixture(tmp_path) - calls = [] - - def collect(loader, keys, embodiment, *args): - calls.append(embodiment) - return {"actions": [np.array([[0.0], [1.0]], dtype=np.float32)]} - - monkeypatch.setattr(normalizer, "_collect_norm_samples", collect) - normalizer.infer_norm_from_dataset( - [0, 1], 3, sample_frac=1.0, num_workers=0, resume_partial_norm_path=str(path) - ) - assert calls == [] - assert normalizer.norm_stats[3]["actions"]["max"] == [1.0] - normalizer.infer_norm_from_dataset( - [0, 1], 7, sample_frac=1.0, num_workers=0, resume_partial_norm_path=str(path) - ) - assert calls == [7] - assert normalizer.norm_stats[7]["actions"]["max"].tolist() == [1.0] - - -def test_recovery_rejects_conflict_and_wrong_provenance(tmp_path): - normalizer, path = fixture(tmp_path) - with pytest.raises(ValueError, match="conflicts"): - normalizer.infer_norm_from_dataset( - [0, 1], - 3, - precomputed_norm_path=str(path), - resume_partial_norm_path=str(path), - ) - payload = json.loads(path.read_text()) - payload["provenance"]["norm_mode"] = "zscore" - path.write_text(json.dumps(payload)) - with pytest.raises(ValueError, match="norm_mode"): - normalizer.infer_norm_from_dataset( - [0, 1], 3, resume_partial_norm_path=str(path) - ) diff --git a/tests/test_pi05_action_encoding.py b/tests/test_pi05_action_encoding.py deleted file mode 100644 index 562ad3f2b9..0000000000 --- a/tests/test_pi05_action_encoding.py +++ /dev/null @@ -1,121 +0,0 @@ -import pytest -import torch - -from egomimic.utils.action_encoding import ( - BaseActionConverter, - RobotBimanualCartesianEuler, -) - - -def _stats_for() -> dict[str, torch.Tensor]: - # Deliberately asymmetric per-dim ranges exercise non-rotation normalization. - q1 = torch.tensor( - [-1.0, -2.0, -3.0, -torch.pi, -1.0, -1.0, 0.0] * 2, - dtype=torch.float32, - ) - q99 = torch.tensor( - [1.0, 2.0, 3.0, torch.pi, 1.0, 1.0, 1.0] * 2, - dtype=torch.float32, - ) - return { - "mean": torch.zeros_like(q1), - "std": torch.ones_like(q1), - "min": q1, - "max": q99, - "quantile_1": q1, - "quantile_99": q99, - } - - -def _normalize( - raw: torch.Tensor, stats: dict[str, torch.Tensor], norm_mode: str -) -> torch.Tensor: - if norm_mode == "zscore": - return (raw - stats["mean"]) / (stats["std"] + 1e-6) - if norm_mode == "minmax": - return 2.0 * ((raw - stats["min"]) / (stats["max"] - stats["min"] + 1e-6)) - 1.0 - if norm_mode == "quantile": - return ( - 2.0 - * ( - (raw - stats["quantile_1"]) - / (stats["quantile_99"] - stats["quantile_1"] + 1e-6) - ) - - 1.0 - ) - raise AssertionError(f"unexpected norm mode {norm_mode}") - - -@pytest.mark.parametrize("norm_mode", ["zscore", "minmax", "quantile"]) -def test_raw_rotation_encoding_round_trips_robot_bimanual_actions(norm_mode): - converter = RobotBimanualCartesianEuler() - raw = torch.tensor( - [ - [ - [ - 0.25, - -0.5, - 1.0, - 0.3, - -0.2, - 0.1, - 0.75, - -0.2, - 0.4, - -1.2, - -0.4, - 0.25, - -0.15, - 0.2, - ] - ] - ], - dtype=torch.float32, - ) - stats = _stats_for() - - packed = converter.to32_raw_rotation(raw, stats=stats, norm_mode=norm_mode) - decoded = converter.from32_raw_rotation( - packed, - stats=stats, - norm_mode=norm_mode, - unnormalize_non_rotation=True, - ) - - torch.testing.assert_close(decoded, raw, atol=1e-5, rtol=1e-5) - - -def test_raw_rotation_encoding_preserves_yaw_wrap_continuity(): - converter = RobotBimanualCartesianEuler() - eps = 1e-4 - raw = torch.zeros(1, 2, 14, dtype=torch.float32) - raw[:, 0, 3] = torch.pi - eps - raw[:, 1, 3] = -torch.pi + eps - stats = _stats_for() - normalized = _normalize(raw, stats, "quantile") - - legacy_packed = converter.to32(normalized) - fixed_packed = converter.to32_raw_rotation( - raw, - normalized_actions=normalized, - stats=stats, - norm_mode="quantile", - ) - - legacy_rot_distance = torch.linalg.norm( - legacy_packed[0, 0, 3:9] - legacy_packed[0, 1, 3:9] - ) - fixed_rot_distance = torch.linalg.norm( - fixed_packed[0, 0, 3:9] - fixed_packed[0, 1, 3:9] - ) - - assert legacy_rot_distance > 2.0 - assert fixed_rot_distance < 1e-3 - - -def test_base_converter_rejects_raw_rotation_encoding(): - converter = BaseActionConverter() - actions = torch.zeros(1, 1, 14) - - with pytest.raises(NotImplementedError, match="raw-rotation action encoding"): - converter.to32_raw_rotation(actions) diff --git a/tests/test_pi05_graph.py b/tests/test_pi05_graph.py deleted file mode 100644 index 1e2401fedc..0000000000 --- a/tests/test_pi05_graph.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Graph integration with the actual campaign adapter and a tiny OpenPI backend. - -Weights, tokenizer downloads and robot/data access are deliberately unnecessary. -""" - -import hashlib -import importlib -import sys -from pathlib import Path -from types import ModuleType, SimpleNamespace - -import numpy as np -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import open_dict - -from egomimic.eval.checkpoint_loading import strict_load_pipeline_checkpoint -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset - -CONFIGS = Path(__file__).parents[1] / "egomimic/hydra_configs" - - -def config(overrides=()): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - return compose( - config_name="train_zarr_cartesian_pi", - overrides=["pi05.pretrained_weights=null", *overrides], - ) - - -@pytest.mark.parametrize( - "group,name", - [ - (group, path.stem) - for group in ["model", "data", "evaluator"] - for path in sorted((CONFIGS / group / "pi05").glob("*.yaml")) - ], -) -def test_pi_configs_resolve_without_openpi(group, name): - cfg = config([f"{group}=pi05/{name}"]) - instantiate(cfg.model.pipeline) # backend remains deferred - instantiate(cfg.evaluator) - for ds in cfg.data.train_datasets.values(): - if ds is None: - continue - instantiate(ds.resolver.key_map) - instantiate(ds.resolver.transform_list) - - -@pytest.fixture -def tiny_openpi(monkeypatch): - modules = {} - for name in [ - "openpi", - "openpi.models", - "openpi.models.pi0_config", - "openpi.models_pytorch", - "openpi.models_pytorch.pi0_pytorch", - "openpi.shared", - "openpi.shared.image_tools", - ]: - module = ModuleType(name) - monkeypatch.setitem(sys.modules, name, module) - modules[name] = module - if "." in name: - parent, child = name.rsplit(".", 1) - setattr(modules[parent], child, module) - modules["openpi.models.pi0_config"].Pi0Config = lambda **kwargs: SimpleNamespace( - **kwargs - ) - - class Network(torch.nn.Module): - def __init__(self, config): - super().__init__() - self.weight = torch.nn.Parameter(torch.tensor(0.25)) - self.config = config - self.checkpointing_enabled = False - - def gradient_checkpointing_enable(self): - self.checkpointing_enabled = True - - def forward(self, observation, action): - assert not torch.is_inference_mode_enabled() - assert action.shape[-1] == 32 - assert observation.tokenized_prompt.shape[0] == len(action) - return (action - self.weight).square().mean() - - def sample_actions(self, device, observation, noise, num_steps): - assert not torch.is_inference_mode_enabled() - return torch.zeros( - len(observation.state), self.config.action_horizon, 32, device=device - ) - - modules["openpi.models_pytorch.pi0_pytorch"].PI0Pytorch = Network - modules["openpi.shared.image_tools"].resize_with_pad_torch = lambda image, h, w: ( - torch.nn.functional.interpolate(image, (h, w)) - ) - - class Tokenizer: - def __call__(self, prompts, **kwargs): - return { - "input_ids": torch.ones(len(prompts), 8, dtype=torch.long), - "attention_mask": torch.ones(len(prompts), 8, dtype=torch.long), - } - - # Reload so this fixture cannot leave a previous fixture's backend attached. - sys.modules.pop("egomimic.models.pi05.policy", None) - module = importlib.import_module("egomimic.models.pi05.policy") - monkeypatch.setattr( - module.AutoTokenizer, "from_pretrained", lambda *args, **kwargs: Tokenizer() - ) - yield - sys.modules.pop("egomimic.models.pi05.policy", None) - - -def normalizer(): - norm = MultiDataset(state={}, norm_mode="quantile") - kinds = { - "actions_cartesian": "action_keys", - "observations.state.ee_pose": "proprio_keys", - "observations.images.front_img_1": "camera_keys", - "annotations": "annotation_keys", - } - norm.key_types = {6: kinds} - norm.zarr_keys = {6: {k: k for k in kinds}} - norm.shapes = { - 6: {"actions_cartesian": (100, 20), "observations.state.ee_pose": (20,)} - } - norm.norm_stats = { - 6: { - key: {"quantile_1": np.zeros(shape), "quantile_99": np.full(shape, 10.0)} - for key, shape in norm.shapes[6].items() - } - } - norm.embodiments = {6} - return norm - - -def batch(): - return { - "opaque-input": { - "embodiment": torch.tensor([6, 6]), - "observations.images.front_img_1": torch.rand(2, 3, 32, 32), - "observations.state.ee_pose": torch.zeros(2, 20), - "actions_cartesian": torch.zeros(2, 100, 20), - "annotations": [["Sort pens"], ["Sort rulers"]], - } - } - - -@pytest.mark.parametrize("checkpointing", [False, True]) -def test_pi_training_inference_and_strict_checkpoint( - tiny_openpi, tmp_path, checkpointing -): - cfg = config() - with open_dict(cfg.model.pipeline.stages[0].policy): - cfg.model.pipeline.stages[0].policy.gradient_checkpointing = checkpointing - cfg.model.pipeline.stages[0].policy.compile_sampler = not checkpointing - graph = instantiate(cfg.model.pipeline) - with pytest.raises(RuntimeError, match="Bind PI05Stage"): - graph.forward_training(batch()) - norm = normalizer() - graph.bind_data_context(normalizer=norm) - stage = graph.pipeline.stages[0] - assert stage.backend.model.checkpointing_enabled == checkpointing - params = list(graph.nets.parameters()) - assert params and params[0] is stage.backend.model.weight - wrapper = ModelWrapper(pipeline=graph) - train = graph.forward_training(batch()) - loss = graph.compute_losses(train, batch())["loss"] - loss.backward() - assert params[0].grad is not None and torch.isfinite(params[0].grad) - inference = batch() - inference["opaque-input"].pop("actions_cartesian") - result = graph.forward_eval(inference)["opaque-input"]["pred_action"] - assert result.shape == (2, 100, 20) - torch.testing.assert_close(result, torch.zeros_like(result)) - saved = {"state_dict": wrapper.state_dict()} - restored = instantiate(cfg.model.pipeline) - restored.bind_data_context(normalizer=norm) - strict_load_pipeline_checkpoint(restored, saved) - torch.testing.assert_close(next(restored.nets.parameters()), params[0]) - path = tmp_path / "source.ckpt" - torch.save( - { - "state_dict": { - "nets." + k: v for k, v in stage.policy_nets.state_dict().items() - } - }, - path, - ) - digest = hashlib.sha256(path.read_bytes()).hexdigest() - stage.load_initial_weights(path, sha256=digest, source_prefix="nets.") - torch.save({"state_dict": {}}, path) - with pytest.raises(ValueError, match="hash mismatch"): - stage.load_initial_weights(path, sha256=digest, source_prefix="nets.") - with pytest.raises(ValueError, match="namespace mismatch"): - stage.load_initial_weights( - path, - sha256=hashlib.sha256(path.read_bytes()).hexdigest(), - source_prefix="nets.", - ) - with pytest.raises(RuntimeError, match="different normalizer"): - graph.bind_data_context(normalizer=normalizer()) - - -def test_pi_strict_restore_does_not_open_original_pretraining_files( - tiny_openpi, tmp_path -): - from egomimic.pipeline.construction import checkpoint_construction - - cfg = config() - original = instantiate(cfg.model.pipeline) - original.bind_data_context(normalizer=normalizer()) - checkpoint = {"state_dict": ModelWrapper(pipeline=original).state_dict()} - cfg.model.pipeline.stages[0].policy.config.pytorch_weight_path = str( - tmp_path / "absent" - ) - with checkpoint_construction(): - restored = instantiate(cfg.model.pipeline) - restored.bind_data_context(normalizer=normalizer()) - strict_load_pipeline_checkpoint(restored, checkpoint) - for name, expected in original.nets.state_dict().items(): - torch.testing.assert_close(restored.nets.state_dict()[name], expected) - - -def test_pi_evaluator_works_through_lightning_wrapper(tiny_openpi, tmp_path): - cfg = config() - norm = normalizer() - graph = instantiate(cfg.model.pipeline) - graph.bind_data_context(normalizer=norm) - graph.pipeline.stages[0].backend.rkl_samples = 1 - wrapper = ModelWrapper(pipeline=graph) - logged = [] - logger = SimpleNamespace( - device=torch.device("cpu"), - log_dict=lambda metrics, **kwargs: logged.append(metrics), - ) - evaluator = instantiate(cfg.evaluator) - evaluator.trainer = SimpleNamespace( - current_epoch=0, - default_root_dir=str(tmp_path), - is_global_zero=True, - world_size=1, - lightning_module=logger, - ) - evaluator.model = wrapper - evaluator.bind_data_context(normalizer=norm) - evaluator.on_validation_start() - # Zero normalized output and targets must give zero native MSE. Revert lists - # are disabled here; their numeric round-trip is covered by the source tests. - evaluator.revert_transforms = {} - evaluator.rkl_samples = 1 - values = evaluator.on_validation_step(batch(), 0) - assert values["Valid/eva_bimanual_actions_cartesian_paired_mse_avg"] == 0 - assert len(logged) == 1 - evaluator.set_validation_group("train_viz") - train_values = evaluator.on_validation_step(batch(), 0) - assert all(key.startswith("Valid_train_viz/") for key in train_values) - evaluator.on_validation_end() diff --git a/tests/test_pi05_norm_rot6d.py b/tests/test_pi05_norm_rot6d.py deleted file mode 100644 index e6e21512e3..0000000000 --- a/tests/test_pi05_norm_rot6d.py +++ /dev/null @@ -1,716 +0,0 @@ -"""Round-trip tests for the normalized continuous-6D rotation encoding. - -Covers the data transform (ypr <-> 6D) and the converter 32D packers -(``to32_norm_6d`` / ``from32_norm_6d``) for both the robot bimanual (14D ypr / -20D 6D, with gripper) and human bimanual (12D ypr / 18D 6D, no gripper) layouts, -plus the proprio ee_pose: the 6D transform modes convert the proprio too (a -single pose vector, same per-arm layout as one action row), and the 6D revert -lists convert it back before the eef-frame revert reads it. -""" - -import math - -import numpy as np -import pytest -import torch - -from egomimic.rldb.zarr.action_chunk_transforms import ( - CartesianRot6DToYPR, - CartesianYPRToRot6D, -) -from egomimic.utils.action_encoding import ( - BaseActionConverter, - HumanBimanualCartesianEuler, - RobotBimanualCartesianEuler, -) -from egomimic.utils.pose_utils import _rot6d_to_ypr, _ypr_to_rot6d - - -def _eva_ypr_chunk(T: int = 5) -> np.ndarray: - # [L xyz ypr g, R xyz ypr g]; moderate angles to avoid gimbal/wrap ambiguity. - rng = np.random.default_rng(0) - xyz = rng.uniform(-1.0, 1.0, size=(T, 3)) - ypr = rng.uniform(-1.0, 1.0, size=(T, 3)) # radians, well inside (-pi, pi) - g = rng.uniform(0.0, 1.0, size=(T, 1)) - arm = np.concatenate([xyz, ypr, g], axis=-1) - return np.concatenate([arm, arm], axis=-1) # 14D - - -def _aria_ypr_chunk(T: int = 5) -> np.ndarray: - rng = np.random.default_rng(1) - xyz = rng.uniform(-1.0, 1.0, size=(T, 3)) - ypr = rng.uniform(-1.0, 1.0, size=(T, 3)) - arm = np.concatenate([xyz, ypr], axis=-1) - return np.concatenate([arm, arm], axis=-1) # 12D - - -def test_ypr_rot6d_helpers_round_trip(): - ypr = np.random.default_rng(2).uniform(-1.0, 1.0, size=(7, 3)) - six = _ypr_to_rot6d(ypr) - assert six.shape == (7, 6) - np.testing.assert_allclose(_rot6d_to_ypr(six), ypr, atol=1e-6) - - -@pytest.mark.parametrize( - "chunk_fn,ypr_dim,six_dim", - [(_eva_ypr_chunk, 14, 20), (_aria_ypr_chunk, 12, 18)], -) -def test_cartesian_ypr_rot6d_transform_round_trips(chunk_fn, ypr_dim, six_dim): - ypr = chunk_fn() - assert ypr.shape[-1] == ypr_dim - - fwd = CartesianYPRToRot6D(action_key="actions_cartesian") - rev = CartesianRot6DToYPR(action_key="actions_cartesian") - - batch = {"actions_cartesian": ypr.copy()} - batch = fwd.transform(batch) - assert batch["actions_cartesian"].shape[-1] == six_dim - - batch = rev.transform(batch) - np.testing.assert_allclose(batch["actions_cartesian"], ypr, atol=1e-6) - - -def test_transform_preserves_tensor_type(): - ypr = torch.from_numpy(_eva_ypr_chunk()) - out = CartesianYPRToRot6D().transform({"actions_cartesian": ypr})[ - "actions_cartesian" - ] - assert isinstance(out, torch.Tensor) - assert out.shape[-1] == 20 - - -def test_robot_bimanual_norm_6d_pack_round_trips(): - converter = RobotBimanualCartesianEuler() - six = torch.from_numpy(_eva_ypr_chunk()).float() - six6d = torch.from_numpy( - CartesianYPRToRot6D().transform({"actions_cartesian": six.numpy()})[ - "actions_cartesian" - ] - ).float()[None] # (1, T, 20) - - packed = converter.to32_norm_6d(six6d) - assert packed.shape[-1] == 32 - decoded = converter.from32_norm_6d(packed) - torch.testing.assert_close(decoded, six6d, atol=1e-6, rtol=1e-6) - - -def test_human_bimanual_norm_6d_pack_round_trips_and_zeros_gripper(): - converter = HumanBimanualCartesianEuler() - six6d = torch.from_numpy( - CartesianYPRToRot6D().transform({"actions_cartesian": _aria_ypr_chunk()})[ - "actions_cartesian" - ] - ).float()[None] # (1, T, 18) - - packed = converter.to32_norm_6d(six6d) - assert packed.shape[-1] == 32 - # gripper slots (9, 19) must be zero for human (no gripper signal). - torch.testing.assert_close(packed[..., 9], torch.zeros_like(packed[..., 9])) - torch.testing.assert_close(packed[..., 19], torch.zeros_like(packed[..., 19])) - - decoded = converter.from32_norm_6d(packed) - torch.testing.assert_close(decoded, six6d, atol=1e-6, rtol=1e-6) - - -@pytest.mark.parametrize( - "chunk_fn,ypr_dim,six_dim", - [(_eva_ypr_chunk, 14, 20), (_aria_ypr_chunk, 12, 18)], -) -def test_proprio_pose_vector_round_trips(chunk_fn, ypr_dim, six_dim): - # The proprio ee_pose is a single pose vector (D,) with the same per-arm - # layout as one action row; the same transforms must handle it. - pose = chunk_fn(T=1)[0] - assert pose.shape == (ypr_dim,) - - fwd = CartesianYPRToRot6D(action_key="observations.state.ee_pose") - rev = CartesianRot6DToYPR(action_key="observations.state.ee_pose") - - batch = {"observations.state.ee_pose": pose.copy()} - batch = fwd.transform(batch) - assert batch["observations.state.ee_pose"].shape == (six_dim,) - - batch = rev.transform(batch) - np.testing.assert_allclose(batch["observations.state.ee_pose"], pose, atol=1e-6) - - -def _keys_of(transforms, cls): - return {t.action_key for t in transforms if isinstance(t, cls)} - - -@pytest.mark.parametrize("frame", ["camframe", "eef_frame"]) -def test_6d_modes_share_graph_rotation_transforms(frame): - from egomimic.rldb.embodiment.eva import Eva - from egomimic.rldb.embodiment.human import Human - - # Numeric action/proprio equivalence is exercised by test_wrist6d_roundtrip. - for cls in (Eva, Human): - assert cls.get_transform_list(coord_frame=frame, rotation_mode="6D") - - -def test_6d_revert_lists_revert_proprio(): - from egomimic.rldb.embodiment.eva import ( - _build_eva_cartesian_revert_6d_transform_list, - _build_eva_cartesian_revert_6d_wristframe_transform_list, - ) - from egomimic.rldb.embodiment.human import ( - _build_human_cartesian_revert_6d_transform_list, - _build_human_cartesian_revert_6d_wristframe_transform_list, - ) - - for build in ( - _build_eva_cartesian_revert_6d_transform_list, - _build_eva_cartesian_revert_6d_wristframe_transform_list, - _build_human_cartesian_revert_6d_transform_list, - _build_human_cartesian_revert_6d_wristframe_transform_list, - ): - transform_list = build() - assert _keys_of(transform_list, CartesianRot6DToYPR) == { - "actions_cartesian", - "observations.state.ee_pose", - }, f"{build.__name__} must revert both action and proprio to ypr" - - -def _bounds_check_dataset(key: str, width: int): - """Minimal MultiDataset shell exposing _check_bounds with ±1 quantile - bounds on ``key`` for embodiment 0.""" - from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset as MultiDataset - - md = MultiDataset.__new__(MultiDataset) - md.norm_stats = { - 0: { - key: { - "quantile_1": np.full(width, -1.0, dtype=np.float32), - "quantile_99": np.full(width, 1.0, dtype=np.float32), - } - } - } - md.zarr_keys = {0: {key: key}} - md._warned_violations = set() - return md - - -@pytest.mark.parametrize("key", ["actions_cartesian", "observations.state.ee_pose"]) -@pytest.mark.parametrize("width,rot_idx,xyz_idx", [(14, 3, 0), (20, 4, 0), (18, 5, 9)]) -def test_bounds_check_ignores_rotation_channels(key, width, rot_idx, xyz_idx): - # Rotation channels (Euler wraps at ±π; 6D columns are ~[-1, 1]) must be - # excluded from quantile bounds checking — matching the remote pipeline — - # while translation/gripper channels are still checked and NaN/Inf still - # rejects the full vector. - md = _bounds_check_dataset(key, width) - arr = np.zeros((5, width), dtype=np.float32) - - arr[2, rot_idx] = 50.0 # far outside ±1, but a rotation channel - assert md._check_bounds({"embodiment": 0, key: arr.copy()}, None, 0, "ep") is None - - bad = arr.copy() - bad[2, xyz_idx] = 50.0 # translation channel out of bounds -> violation - assert md._check_bounds({"embodiment": 0, key: bad}, None, 0, "ep") is not None - - nan = arr.copy() - nan[2, rot_idx] = np.nan # NaN anywhere (even rotation) -> violation - assert md._check_bounds({"embodiment": 0, key: nan}, None, 0, "ep") is not None - - -def test_bounds_check_full_vector_for_other_keys(): - # Keys without the bimanual cartesian layout (or unrecognized widths) keep - # the full-vector check. - md = _bounds_check_dataset("some_other_key", 20) - arr = np.zeros((5, 20), dtype=np.float32) - arr[2, 4] = 50.0 - assert ( - md._check_bounds({"embodiment": 0, "some_other_key": arr}, None, 0, "ep") - is not None - ) - - md16 = _bounds_check_dataset("actions_cartesian", 16) - arr16 = np.zeros((5, 16), dtype=np.float32) - arr16[2, 4] = 50.0 - assert ( - md16._check_bounds({"embodiment": 0, "actions_cartesian": arr16}, None, 0, "ep") - is not None - ) - - -def test_rotate_local_frame_flips_left_wrist_convention(): - # Right-multiplying by Rz(180°) must flip the pose's own x/y axes, keep z - # (knuckle-forward) and the position, skip zero-quat padding rows, and - # handle both (7,) poses and (T, 7) chunks. - from scipy.spatial.transform import Rotation as R - - from egomimic.rldb.zarr.action_chunk_transforms import RotateLocalFrame - - rng = np.random.default_rng(4) - q = R.random(3, random_state=5) - chunk = np.zeros((4, 7)) - chunk[:3, :3] = rng.uniform(-1, 1, size=(3, 3)) - chunk[:3, 3:] = q.as_quat()[:, [3, 0, 1, 2]] # wxyz; row 3 stays zero-padded - - t = RotateLocalFrame(keys=["k"], quat_wxyz=[0, 0, 0, 1]) - out = t.transform({"k": chunk.copy()})["k"] - - np.testing.assert_allclose(out[:, :3], chunk[:, :3]) # positions unchanged - np.testing.assert_allclose(out[3], np.zeros(7)) # padding untouched - R_old = q.as_matrix() - R_new = R.from_quat(out[:3, [4, 5, 6, 3]]).as_matrix() - np.testing.assert_allclose(R_new[:, :, 0], -R_old[:, :, 0], atol=1e-12) # x flip - np.testing.assert_allclose(R_new[:, :, 1], -R_old[:, :, 1], atol=1e-12) # y flip - np.testing.assert_allclose(R_new[:, :, 2], R_old[:, :, 2], atol=1e-12) # z kept - - single = t.transform({"k": chunk[0].copy()})["k"] - np.testing.assert_allclose(single, out[0], atol=1e-12) - - -def test_fix_mecka_left_wrist_flag_prepends_correction(): - from egomimic.rldb.embodiment.human import Human - from egomimic.rldb.zarr.action_chunk_transforms import RotateLocalFrame - - tl = Human.get_transform_list( - "cartesian", - coord_frame="eef_frame", - rotation_mode="6D", - stride=1, - local_frame_rotations={ - "left.action_ee_pose": [0, 0, 0, 1], - "left.obs_ee_pose": [0, 0, 0, 1], - }, - ) - assert isinstance(tl[0], RotateLocalFrame) - assert {key for transform in tl[:2] for key in transform.keys} == { - "left.action_ee_pose", - "left.obs_ee_pose", - } - # default off — other vendors' data must be untouched - tl_off = Human.get_transform_list( - "cartesian", coord_frame="eef_frame", rotation_mode="6D", stride=1 - ) - assert not isinstance(tl_off[0], RotateLocalFrame) - with pytest.raises(ValueError, match="keypoints"): - Human.get_transform_list( - "keypoints", - local_frame_rotations={ - "left.action_ee_pose": [0, 0, 0, 1], - "left.obs_ee_pose": [0, 0, 0, 1], - }, - ) - - -def test_vendor_embodiment_names_collapse_to_human(): - # Mirror episodes written by the vendor-split registry carry names like - # MECKA_BIMANUAL in their zarr metadata; locally all human demo data is - # one embodiment, so these must resolve to the HUMAN_* ids. - from egomimic.rldb.embodiment.embodiment import EMBODIMENT, get_embodiment_id - - for vendor in ("mecka", "scale", "aria", "lightwheel"): - assert ( - get_embodiment_id(f"{vendor}_bimanual") == EMBODIMENT.HUMAN_BIMANUAL.value - ) - assert ( - get_embodiment_id(f"{vendor}_right_arm") == EMBODIMENT.HUMAN_RIGHT_ARM.value - ) - assert ( - get_embodiment_id(f"{vendor}_left_arm") == EMBODIMENT.HUMAN_LEFT_ARM.value - ) - assert get_embodiment_id("human_bimanual") == EMBODIMENT.HUMAN_BIMANUAL.value - assert get_embodiment_id("eva_bimanual") == EMBODIMENT.EVA_BIMANUAL.value - assert get_embodiment_id("yam_bimanual") == EMBODIMENT.YAM_BIMANUAL.value - - -def test_base_converter_rejects_norm_6d_encoding(): - converter = BaseActionConverter() - with pytest.raises(NotImplementedError, match="normalized-rot6d"): - converter.to32_norm_6d(torch.zeros(1, 1, 20)) - with pytest.raises(NotImplementedError, match="normalized-rot6d"): - converter.from32_norm_6d(torch.zeros(1, 1, 32)) - - -def test_unpad_gripper_zeros_inverts_pad_and_noops_unpadded(): - from egomimic.rldb.zarr.action_chunk_transforms import ( - PadGripperZeros, - UnpadGripperZeros, - ) - - rng = np.random.default_rng(6) - for width in (12, 18): - v = rng.uniform(-1, 1, size=(width,)) - padded = PadGripperZeros(action_key="k", pose_dim=width // 2).transform( - {"k": v.copy()} - )["k"] - assert padded.shape == (width + 2,) - back = UnpadGripperZeros(action_key="k").transform({"k": padded.copy()})["k"] - np.testing.assert_allclose(back, v) - # no-op on already-unpadded widths - same = UnpadGripperZeros(action_key="k").transform({"k": v.copy()})["k"] - np.testing.assert_allclose(same, v) - - -def test_human_6d_reverts_unpad_proprio(): - from egomimic.rldb.embodiment.human import ( - _build_human_cartesian_revert_6d_transform_list, - _build_human_cartesian_revert_6d_wristframe_transform_list, - ) - from egomimic.rldb.zarr.action_chunk_transforms import UnpadGripperZeros - - for build in ( - _build_human_cartesian_revert_6d_transform_list, - _build_human_cartesian_revert_6d_wristframe_transform_list, - ): - assert any(isinstance(t, UnpadGripperZeros) for t in build()), build.__name__ - - -def test_fallback_widens_to_global_after_local_attempts(): - # A wholly-bad episode must not exhaust the sampler: retries stay inside - # the failing episode for GLOBAL_FALLBACK_ATTEMPTS, then widen to the full - # index space, and only a systemic failure (MAX_FALLBACK_ATTEMPTS) raises. - from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset as MultiDataset - - md = MultiDataset.__new__(MultiDataset) - md.index_map = [("bad", i) for i in range(10)] + [("good", i) for i in range(1000)] - md._global_indices_by_dataset = { - "bad": list(range(10)), - "good": list(range(10, 1010)), - } - - attempts = None - seen_local, seen_global = set(), set() - for _ in range(md.GLOBAL_FALLBACK_ATTEMPTS): - idx, attempts = md._next_after_failure(0, "bad", attempts, reason="r") - seen_local.add(md.index_map[idx][0]) - assert seen_local == {"bad"}, "early retries must stay within the episode" - - for _ in range(200): - idx, attempts = md._next_after_failure(idx, "bad", attempts, reason="r") - seen_global.add(md.index_map[idx][0]) - assert "good" in seen_global, "post-threshold retries must sample globally" - - with pytest.raises(RuntimeError, match="consecutive bad samples"): - while True: - idx, attempts = md._next_after_failure(idx, "bad", attempts, reason="r") - - -def test_wrap_aware_mse_handles_pi_boundary(): - # A yaw of +pi-eps vs -pi+eps is physically ~perfect: wrapped MSE ~0, - # unwrapped ~(2pi)^2 on that dim. xyz errors must pass through untouched, - # and 6D-rotation widths (18) must not be wrapped at all. - import torch - - from egomimic.eval.cartesian_metrics import _wrap_aware_mse - - eps = 1e-3 - gt = torch.zeros(2, 12) - pred = torch.zeros(2, 12) - pred[:, 3] = math.pi - eps # L yaw - gt[:, 3] = -math.pi + eps - wrapped, nowrap = _wrap_aware_mse(pred, gt) - assert wrapped < 1e-4, f"wrap failed: {wrapped}" - assert nowrap > 3.0, f"nowrap should show inflation: {nowrap}" - - # translation error passes through identically - pred2 = torch.zeros(2, 12) - pred2[:, 0] = 0.5 - w2, nw2 = _wrap_aware_mse(pred2, torch.zeros(2, 12)) - assert torch.isclose(w2, nw2) and torch.isclose(w2, torch.tensor(0.25 / 12)) - - # 6D width: no angle dims -> wrapped == unwrapped even for large values - pred3 = torch.full((2, 18), 4.0) - w3, nw3 = _wrap_aware_mse(pred3, torch.zeros(2, 18)) - assert torch.isclose(w3, nw3) - - -def test_video_fps_comes_from_declared_source_rate(): - from types import SimpleNamespace - - from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval - - ev = BimanualCartesianEval(source_fps=24.0) - for world in (1, 2, 4, 8): - ev.trainer = SimpleNamespace(world_size=world) - assert ev._video_fps() == 24.0 - - -def test_frechet_and_reverse_kl_helpers(): - from egomimic.eval.distribution_metrics import ( - frechet_gaussian_over_time, - reverse_kl_from_samples, - ) - - B, T, D = 4, 10, 6 - torch.manual_seed(0) - pred = torch.randn(B, T, D) - fd = frechet_gaussian_over_time(pred, pred.clone()) - assert fd.shape == (B,) - assert fd.max() < 1e-2, "identical distributions must score ~0" - fd_shift = frechet_gaussian_over_time(pred + 5.0, pred) - assert (fd_shift > fd).all(), "mean shift must increase the distance" - - samples = torch.randn(3, B, T, D) - rkl = reverse_kl_from_samples(samples, pred) - assert rkl.ndim == 0 and torch.isfinite(rkl) - - -def test_validation_groups_share_video_namespace_and_sampling_options(): - from types import SimpleNamespace - - from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval - - ev = BimanualCartesianEval( - rkl_samples=8, group_options={"train_viz": {"rkl_samples": 1}} - ) - ev.trainer = SimpleNamespace(default_root_dir="/tmp/run", current_epoch=0) - ev.set_validation_group("train_viz") - assert ev._namespaced({"Valid/x": 1}) == {"Valid_train_viz/x": 1} - assert ev._group_video_dir("train_viz", "human_bimanual").endswith( - "train_viz/human_bimanual" - ) - assert ev.rkl_samples == 8 - - -def test_dtw_distance_matches_bruteforce_and_tolerates_shift(): - from egomimic.eval.distribution_metrics import dtw_distance - - def _dtw_ref(x, y): - t1, t2 = len(x), len(y) - cost = np.linalg.norm(x[:, None, :] - y[None, :, :], axis=-1) - acc = np.full((t1 + 1, t2 + 1), np.inf) - acc[0, 0] = 0.0 - for i in range(1, t1 + 1): - for j in range(1, t2 + 1): - acc[i, j] = cost[i - 1, j - 1] + min( - acc[i - 1, j], acc[i, j - 1], acc[i - 1, j - 1] - ) - return acc[t1, t2] - - torch.manual_seed(3) - pred = torch.randn(3, 9, 4) - tgt = torch.randn(3, 9, 4) - got = dtw_distance(pred, tgt, normalize=False) - for b in range(3): - ref = _dtw_ref(pred[b].numpy(), tgt[b].numpy()) - assert abs(got[b].item() - ref) < 1e-4, (b, got[b].item(), ref) - - # identical trajectories -> 0 - assert dtw_distance(pred, pred.clone()).max() < 1e-6 - - # a time-shifted copy of the same smooth trajectory: DTW must forgive the - # shift (score far below the paired per-step distance of the shifted pair) - t = torch.linspace(0, 6.28, 40) - traj = torch.stack([torch.sin(t), torch.cos(t)], dim=-1)[None] # (1, 40, 2) - shifted = torch.roll(traj, shifts=3, dims=1) - dtw_shift = dtw_distance(traj, shifted, normalize=False).item() - paired = (traj - shifted).norm(dim=-1).sum().item() - assert dtw_shift < 0.5 * paired, (dtw_shift, paired) - - -def test_resize_image_keys_unifies_mixed_resolutions(): - # abc eva ships 480x640 / 480x848 / 720x1280 episodes; the collate-level - # resize must unify camera images (both dataset-style "images" keys and - # PI-style "*_rgb" keys) so default_collate can stack, while leaving - # non-image tensors untouched. - from torch.utils.data._utils.collate import default_collate - - from egomimic.rldb.zarr.zarr_dataset_multi import _resize_image_keys - - batch = [ - { - "base_0_rgb": torch.rand(3, 480, 640), - "observations.images.left_wrist_img": torch.rand(3, 480, 848), - "actions_cartesian": torch.rand(100, 20), - }, - { - "base_0_rgb": torch.rand(3, 720, 1280), - "observations.images.left_wrist_img": torch.rand(3, 480, 640), - "actions_cartesian": torch.rand(100, 20), - }, - ] - _resize_image_keys(batch) - for sample in batch: - assert sample["base_0_rgb"].shape == (3, 480, 640) - assert sample["observations.images.left_wrist_img"].shape == (3, 480, 640) - assert sample["actions_cartesian"].shape == (100, 20), "non-image resized!" - stacked = default_collate(batch) - assert stacked["base_0_rgb"].shape == (2, 3, 480, 640) - - # (T, C, H, W) temporal stacks resize only the trailing spatial dims, - # and dtype is preserved. - b2 = [ - {"images.front_1": torch.randint(0, 255, (4, 3, 480, 848), dtype=torch.uint8)} - ] - _resize_image_keys(b2) - assert b2[0]["images.front_1"].shape == (4, 3, 480, 640) - assert b2[0]["images.front_1"].dtype == torch.uint8 - - -def _hand(pinch_m, curl_ratio): - # Synthetic 21-kp hand with controlled pinch distance and curl ratio: - # wrist at origin, middle MCP 10cm out (hand size 0.1). - kp = np.zeros((21, 3)) - kp[9] = [0.10, 0.0, 0.0] - kp[5], kp[13], kp[17] = [0.09, 0.02, 0], [0.09, -0.02, 0], [0.08, -0.04, 0] - palm = np.mean([kp[5], kp[9], kp[13], kp[17]], axis=0) - for t in (8, 12, 16, 20): # fingertips at the target curl radius - kp[t] = palm + np.array([curl_ratio * 0.1, 0, 0]) - kp[4] = kp[8] + np.array([0, pinch_m, 0]) # thumb tip at pinch distance - return kp - - -def test_keypoints_to_gripper_pinch_and_curl(): - from egomimic.rldb.zarr.action_chunk_transforms import KeypointsToGripper - - t = KeypointsToGripper(chunk_length=10, stride=1) - # open hand: wide pinch + extended fingers - assert t._openness(_hand(0.12, 1.5)) == 1.0 - # pinch closed even with extended fingers - assert t._openness(_hand(0.005, 1.5)) == 0.0 - # power grasp: fist curls while pinch stays wide - assert t._openness(_hand(0.12, 0.65)) == 0.0 - # untracked hand reads fully open - assert t._openness(np.zeros((21, 3))) == 1.0 - - # end-to-end: horizon series + obs frame -> chunked grip + scalar - batch = { - "left.action_grip_keypoints": np.stack([_hand(0.12, 1.5)] * 6).reshape(6, 63), - "left.obs_grip_keypoints": _hand(0.005, 1.5).reshape(63), - } - out = t.transform(batch) - assert out["left.action_grip"].shape == (10, 1) - np.testing.assert_allclose(out["left.action_grip"], 1.0) - assert out["left.obs_grip"].shape == (1,) and out["left.obs_grip"][0] == 0.0 - assert "left.action_grip_keypoints" not in out - - -def test_insert_gripper_channels_layout(): - from egomimic.rldb.zarr.action_chunk_transforms import InsertGripperChannels - from egomimic.utils.pose_utils import bimanual_cartesian_layout - - T = 4 - actions = np.arange(T * 18, dtype=np.float64).reshape(T, 18) - batch = { - "actions_cartesian": actions.copy(), - "left.action_grip": np.full((T, 1), 0.25), - "right.action_grip": np.full((T, 1), 0.75), - } - out = InsertGripperChannels( - action_key="actions_cartesian", - left_grip_key="left.action_grip", - right_grip_key="right.action_grip", - ).transform(batch) - a = out["actions_cartesian"] - assert a.shape == (T, 20) - grip_idx = bimanual_cartesian_layout(20)["grip"] - np.testing.assert_allclose(a[:, grip_idx[0]], 0.25) - np.testing.assert_allclose(a[:, grip_idx[1]], 0.75) - # non-grip channels preserved in order - keep = [i for i in range(20) if i not in grip_idx] - np.testing.assert_allclose(a[:, keep], actions) - assert "left.action_grip" not in out - - -def test_keypoint_gripper_transform_list_wiring(): - from egomimic.rldb.embodiment.human import ( - Human, - _build_human_cartesian_revert_6d_wristframe_grip_transform_list, - ) - from egomimic.rldb.zarr.action_chunk_transforms import ( - InsertGripperChannels, - KeypointsToGripper, - UnpadGripperZeros, - ) - - tl = Human.get_transform_list( - "cartesian", coord_frame="eef_frame", rotation_mode="6D", keypoint_gripper=True - ) - assert isinstance(tl[0], KeypointsToGripper), "grip extraction must run first" - inserts = [t for t in tl if isinstance(t, InsertGripperChannels)] - assert {t.action_key for t in inserts} == { - "actions_cartesian", - "observations.state.ee_pose", - } - with pytest.raises(ValueError): - Human.get_transform_list( - "cartesian", - coord_frame="eef_frame", - rotation_mode="6D", - keypoint_gripper=True, - pad_proprio_gripper=True, - ) - - rl = _build_human_cartesian_revert_6d_wristframe_grip_transform_list() - assert isinstance(rl[0], UnpadGripperZeros) - assert rl[0].action_key == "actions_cartesian" - - km = Human.get_keymap("cartesian", include_grip_keypoints=True) - assert km["left.action_grip_keypoints"]["horizon"] == Human.ACTION_HORIZON - assert km["right.obs_grip_keypoints"]["zarr_key"] == "right.obs_keypoints" - - -def test_split_mse_is_stateless_and_matches_manual(): - from egomimic.eval.cartesian_metrics import _paired_mse, _split_mse - - rng = np.random.default_rng(21) - pred = torch.from_numpy(rng.normal(size=(4, 10, 18))).float() - gt = torch.from_numpy(rng.normal(size=(4, 10, 18))).float() - xyz_idx = [0, 1, 2, 9, 10, 11] - rot_idx = [i for i in range(18) if i not in xyz_idx] - xyz, rot = _split_mse(pred, gt) - torch.testing.assert_close( - xyz, (pred[..., xyz_idx] - gt[..., xyz_idx]).pow(2).mean() - ) - torch.testing.assert_close( - rot, (pred[..., rot_idx] - gt[..., rot_idx]).pow(2).mean() - ) - # stateless: a second, unrelated call is unaffected by the first - a, b = torch.zeros(2, 3, 18), torch.ones(2, 3, 18) - torch.testing.assert_close(_split_mse(a, b)[0], torch.tensor(1.0)) - torch.testing.assert_close(_paired_mse(a, b), torch.tensor(1.0)) - assert _split_mse(torch.zeros(2, 3, 7), torch.zeros(2, 3, 7)) == (None, None) - - -def test_rot_geodesic_error_matches_angle_and_survives_gimbal_lock(): - from scipy.spatial.transform import Rotation as R - - from egomimic.eval.cartesian_metrics import _rot_geodesic_error, _wrap_aware_mse - from egomimic.utils.pose_utils import _ypr_to_rot6d - - rng = np.random.default_rng(22) - ypr = rng.uniform(-1.0, 1.0, size=(6, 5, 3)) - theta = 0.3 - # rotate every pose by theta about a random axis -> geodesic error == theta - axes = rng.normal(size=(6, 5, 3)) - axes /= np.linalg.norm(axes, axis=-1, keepdims=True) - Rg = R.from_euler("ZYX", ypr.reshape(-1, 3)) - Rp = R.from_rotvec(theta * axes.reshape(-1, 3)) * Rg - ypr_p = Rp.as_euler("ZYX").reshape(6, 5, 3) - - # 12-dim ypr layout (both arms the same pose) - gt12 = torch.from_numpy(np.concatenate([ypr, ypr], -1)).float() - gt12 = torch.cat( - [torch.zeros(6, 5, 3), gt12[..., :3], torch.zeros(6, 5, 3), gt12[..., 3:]], -1 - ) - pr12 = torch.from_numpy(np.concatenate([ypr_p, ypr_p], -1)).float() - pr12 = torch.cat( - [torch.zeros(6, 5, 3), pr12[..., :3], torch.zeros(6, 5, 3), pr12[..., 3:]], -1 - ) - assert abs(_rot_geodesic_error(pr12, gt12).item() - theta) < 1e-4 - assert _rot_geodesic_error(gt12, gt12).item() < 1e-5 - - # 18-dim 6D layout, same rotations -> same answer - def to18(y): - six = _ypr_to_rot6d(y) - arm = np.concatenate([np.zeros(y.shape[:-1] + (3,)), six], -1) - return torch.from_numpy(np.concatenate([arm, arm], -1)).float() - - assert abs(_rot_geodesic_error(to18(ypr_p), to18(ypr)).item() - theta) < 1e-4 - assert _rot_geodesic_error(torch.zeros(2, 3, 7), torch.zeros(2, 3, 7)) is None - - # Gimbal lock: pitch ≈ π/2, pred = gt rotated 0.004 rad about local y. - # The ypr MSE (even wrap-aware) explodes because yaw and roll trade off; - # the geodesic error reports the true 0.004. - gt = np.array([0.3, np.pi / 2 - 0.002, 0.2]) - Rgt = R.from_euler("ZYX", gt) - Rpr = Rgt * R.from_rotvec([0.0, 0.004, 0.0]) - pr = Rpr.as_euler("ZYX") - v_gt = torch.tensor([[0, 0, 0, *gt, 0, 0, 0, *gt]], dtype=torch.float32) - v_pr = torch.tensor([[0, 0, 0, *pr, 0, 0, 0, *pr]], dtype=torch.float32) - wrapped, _ = _wrap_aware_mse(v_pr, v_gt) - geo = _rot_geodesic_error(v_pr, v_gt).item() - assert abs(geo - 0.004) < 1e-3, geo - assert wrapped.item() > 0.1, wrapped # the Euler metric is fooled here diff --git a/tests/test_pi_camera_slots.py b/tests/test_pi_camera_slots.py deleted file mode 100644 index 73dc25cdd7..0000000000 --- a/tests/test_pi_camera_slots.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Pi owns the mapping from dataset camera keys to openpi's fixed camera -slots (base_0_rgb, *_wrist_0_rgb). Datasets emit one naming for every algo.""" - -import pytest -import torch - -from egomimic.models.pi05.observations import PI_CAMERA_SLOTS, gather_pi_images -from egomimic.rldb.embodiment.eva import Eva -from egomimic.rldb.embodiment.human import Human - -CPU = torch.device("cpu") - - -def _img(seed: float) -> torch.Tensor: - return torch.full((2, 3, 8, 8), seed) - - -def test_slot_map_covers_the_three_openpi_slots(): - assert tuple(PI_CAMERA_SLOTS) == ( - "base_0_rgb", - "left_wrist_0_rgb", - "right_wrist_0_rgb", - ) - assert PI_CAMERA_SLOTS["base_0_rgb"] == Eva.VIZ_IMAGE_KEY == Human.VIZ_IMAGE_KEY - - -def test_dataset_names_map_onto_slots(): - batch = { - "observations.images.front_img_1": _img(1.0), - "observations.images.left_wrist_img": _img(2.0), - "observations.images.right_wrist_img": _img(3.0), - "actions_cartesian": torch.zeros(2, 100, 14), - } - images, present = gather_pi_images(batch, PI_CAMERA_SLOTS, CPU) - assert tuple(images) == tuple(PI_CAMERA_SLOTS) - assert images["base_0_rgb"][0, 0, 0, 0] == 1.0 - assert images["left_wrist_0_rgb"][0, 0, 0, 0] == 2.0 - assert images["right_wrist_0_rgb"][0, 0, 0, 0] == 3.0 - assert present == {k: True for k in PI_CAMERA_SLOTS} - - -def test_legacy_slot_names_still_accepted(): - # Robot rollout and pre-remap checkpoints hand the wrapper openpi names. - batch = {"base_0_rgb": _img(1.0), "right_wrist_0_rgb": _img(3.0)} - images, present = gather_pi_images(batch, PI_CAMERA_SLOTS, CPU) - assert images["base_0_rgb"][0, 0, 0, 0] == 1.0 - assert images["right_wrist_0_rgb"][0, 0, 0, 0] == 3.0 - assert present == { - "base_0_rgb": True, - "left_wrist_0_rgb": False, - "right_wrist_0_rgb": True, - } - - -def test_dataset_name_wins_over_legacy_alias(): - batch = {"observations.images.front_img_1": _img(1.0), "base_0_rgb": _img(9.0)} - images, _ = gather_pi_images(batch, PI_CAMERA_SLOTS, CPU) - assert images["base_0_rgb"][0, 0, 0, 0] == 1.0 - - -def test_missing_wrists_are_duplicated_and_flagged_absent(): - # Human data has only the front camera; openpi still wants three slots. - batch = {"observations.images.front_img_1": _img(1.0)} - images, present = gather_pi_images(batch, PI_CAMERA_SLOTS, CPU) - assert tuple(images) == tuple(PI_CAMERA_SLOTS) - for k in ("left_wrist_0_rgb", "right_wrist_0_rgb"): - assert torch.equal(images[k], images["base_0_rgb"]) - assert present[k] is False - assert present["base_0_rgb"] is True - - -def test_bhwc_images_are_normalised_to_bchw(): - batch = {"observations.images.front_img_1": torch.zeros(2, 8, 8, 3)} - images, _ = gather_pi_images(batch, PI_CAMERA_SLOTS, CPU) - assert images["base_0_rgb"].shape == (2, 3, 8, 8) - - -def test_no_camera_at_all_is_an_error(): - with pytest.raises(ValueError, match="base_0_rgb"): - gather_pi_images( - {"actions_cartesian": torch.zeros(2, 100, 14)}, PI_CAMERA_SLOTS, CPU - ) - - -@pytest.mark.parametrize("cls", [Eva, Human]) -def test_pi_keymap_mode_is_a_deprecated_alias(cls): - # Saved configs from pre-remap runs still say cartesian_pi; they must rebuild. - with pytest.warns(FutureWarning, match="cartesian_pi"): - km = cls.get_keymap(keymap_mode="cartesian_pi") - assert km == cls.get_keymap(keymap_mode="cartesian") - - -@pytest.mark.parametrize("cls", [Eva, Human]) -def test_cartesian_keymap_emits_dataset_front_key(cls): - km = cls.get_keymap(keymap_mode="cartesian") - assert cls.VIZ_IMAGE_KEY in km - assert "base_0_rgb" not in km diff --git a/tests/test_pi_prompts.py b/tests/test_pi_prompts.py deleted file mode 100644 index 70c508804a..0000000000 --- a/tests/test_pi_prompts.py +++ /dev/null @@ -1,58 +0,0 @@ -"""PI._build_prompts warns when samples fall back to default_prompt, loudly when -that prompt is empty, instead of silently training on it.""" - -import logging - -import pytest - -pi_mod = pytest.importorskip("egomimic.models.pi05.policy") - - -def _pi(annotation_key="annotations", default_prompt=""): - pi = object.__new__(pi_mod.PI) - pi.annotation_key = annotation_key - pi.default_prompt = default_prompt - pi.sampling_mode = "first" - pi.proprio_in_prompt = False - pi.embodiment_label = False - pi.control_mode = None - pi._empty_prompt_warned = set() - return pi - - -def _fallback_warnings(caplog): - return [r for r in caplog.records if "PI prompt fallback" in r.getMessage()] - - -def test_missing_annotation_key_warns_once_per_embodiment(caplog): - pi = _pi() - with caplog.at_level(logging.WARNING, logger=pi_mod.__name__): - for _ in range(3): - assert pi._build_prompts({}, "eva_bimanual", 2) == ["", ""] - pi._build_prompts({}, "aria_bimanual", 2) - warnings = _fallback_warnings(caplog) - assert len(warnings) == 2 - assert "not in the batch" in warnings[0].getMessage() - assert "EMPTY prompt" in warnings[0].getMessage() - - -def test_empty_annotation_sample_warns(caplog): - pi = _pi() - with caplog.at_level(logging.WARNING, logger=pi_mod.__name__): - prompts = pi._build_prompts({"annotations": [["pick"], []]}, "eva", 2) - assert prompts == ["pick", ""] - assert len(_fallback_warnings(caplog)) == 1 - - -def test_annotated_batch_does_not_warn(caplog): - pi = _pi() - with caplog.at_level(logging.WARNING, logger=pi_mod.__name__): - pi._build_prompts({"annotations": [["pick"], ["place"]]}, "eva", 2) - assert _fallback_warnings(caplog) == [] - - -def test_no_annotation_key_with_explicit_prompt_does_not_warn(caplog): - pi = _pi(annotation_key=None, default_prompt="fold the towel") - with caplog.at_level(logging.WARNING, logger=pi_mod.__name__): - assert pi._build_prompts({}, "eva", 1) == ["fold the towel"] - assert _fallback_warnings(caplog) == [] diff --git a/tests/test_pi_source_patch.py b/tests/test_pi_source_patch.py deleted file mode 100644 index 9cf7d3b088..0000000000 --- a/tests/test_pi_source_patch.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Pinned OpenPI patching must never modify another environment's hardlinks.""" - -import hashlib -import os - -import pytest - -from scripts.install_pi05_source import apply_transformers_patch - - -def test_verified_patch_preserves_cache_hardlink_and_original(tmp_path): - cache = tmp_path / "cached.py" - cache.write_bytes(b"original code") - root = tmp_path / "transformers" - root.mkdir() - target = root / "model.py" - os.link(cache, target) - content = b"pinned patched code" - manifest = {"model.py": hashlib.sha256(content).hexdigest()} - receipt = apply_transformers_patch( - root, manifest, lambda path: content, tmp_path / "backups" - ) - assert target.read_bytes() == content - assert cache.read_bytes() == b"original code" - assert target.stat().st_ino != cache.stat().st_ino - from pathlib import Path - - assert Path(receipt[0]["backup"]).read_bytes() == b"original code" - assert ( - apply_transformers_patch( - root, manifest, lambda path: content, tmp_path / "backups" - )[0]["sha256"] - == receipt[0]["sha256"] - ) - - -def test_every_hash_is_preflighted_before_patch_mutation(tmp_path): - root = tmp_path / "transformers" - root.mkdir() - (root / "a.py").write_bytes(b"original") - expected = hashlib.sha256(b"valid").hexdigest() - with pytest.raises(ValueError, match="hash mismatch"): - apply_transformers_patch( - root, - {"a.py": expected, "b.py": "wrong"}, - lambda path: b"valid", - tmp_path / "backups", - ) - assert (root / "a.py").read_bytes() == b"original" - assert not (root / "b.py").exists() diff --git a/tests/test_pipeline_algo.py b/tests/test_pipeline_algo.py deleted file mode 100644 index e782bb536c..0000000000 --- a/tests/test_pipeline_algo.py +++ /dev/null @@ -1,273 +0,0 @@ -import inspect -from collections import OrderedDict -from pathlib import Path - -import pytest -import torch - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.core import Stage -from egomimic.pipeline.stages_io import ActionTargetBuilder -from egomimic.pipeline.stages_sampler import FusedObsEncoder -from egomimic.pl_utils.pl_model import ModelWrapper - - -class _PackedState(torch.nn.Module): - def forward_packed(self, *, obs_packed, **_context): - return obs_packed["state"] - - -class _TinyHead(Stage): - reads = ["condition", "target", "selector"] - writes = ["loss/fit", "log/fit", "log/MSE"] - reads_by_mode = {"inference": ["condition", "selector"]} - writes_by_mode = {"inference": ["prediction"]} - - def __init__(self): - super().__init__() - self.scale = torch.nn.Parameter(torch.tensor(0.5)) - self.seen_keys = None - - def execute(self, batch, *, mode): - self.seen_keys = set(batch) - if mode == "train": - loss = (batch["target"] * self.scale).square().mean() - batch["loss/fit"] = loss - batch["log/fit"] = loss.detach() - batch["log/MSE"] = loss.detach() - elif mode == "inference": - batch_size = batch["condition"].shape[0] - batch["prediction"] = self.scale.expand(batch_size, 3, 2) - else: # pragma: no cover - Pipeline validates modes first - raise ValueError(mode) - return batch - - def forward(self, batch): - return self.execute(batch, mode="train") - - -class _ImageHead(Stage): - reads = ["pixels", "reference"] - writes = ["loss/reconstruction"] - reads_by_mode = {"inference": ["pixels"]} - writes_by_mode = {"inference": ["reconstruction"]} - - def execute(self, batch, *, mode): - if mode == "train": - batch["loss/reconstruction"] = ( - (batch["pixels"] - batch["reference"]).square().mean() - ) - elif mode == "inference": - batch["reconstruction"] = batch["pixels"] - else: # pragma: no cover - Pipeline validates modes first - raise ValueError(mode) - return batch - - def forward(self, batch): - return self.execute(batch, mode="train") - - -def _algo(): - return PipelineAlgo( - stages=[ - FusedObsEncoder( - encoder=_PackedState(), - inputs={"state": "state"}, - n_obs_steps=1, - ), - ActionTargetBuilder(), - _TinyHead(), - ], - device="cpu", - ) - - -def _raw_batch(scale=1.0): - return OrderedDict( - source_a={ - "state": torch.randn(2, 4), - "actions": torch.full((2, 3, 2), float(scale)), - "selector": "arm_a", - "ignored": torch.ones(2), - } - ) - - -def test_pipeline_algo_preserves_opaque_sources_and_reduces_losses(): - algo = _algo() - processed = algo.process_batch_for_training(_raw_batch()) - - assert list(processed) == ["source_a"] - assert set(processed["source_a"]) == { - "state", - "actions", - "selector", - "ignored", - } - predictions = algo.forward_training(processed) - losses = algo.compute_losses(predictions, processed) - - assert isinstance(predictions, OrderedDict) - assert list(predictions) == ["source_a"] - assert losses["loss"].ndim == 0 - assert torch.equal(losses["loss"], losses["source_0_loss"]) - losses["loss"].backward() - assert algo.pipeline.stages[2].scale.grad is not None - logged = algo.log_info({"losses": losses}) - assert logged["Loss"] == pytest.approx(losses["loss"].item()) - - -def test_pipeline_algo_equal_weights_multiple_sources(): - algo = _algo() - raw = _raw_batch(scale=1.0) - raw["another_source"] = _raw_batch(scale=3.0)["source_a"] - processed = algo.process_batch_for_training(raw) - - predictions = algo.forward_training(processed) - losses = algo.compute_losses(predictions, processed) - - expected = torch.stack( - [predictions[source]["loss/fit"] for source in predictions] - ).mean() - torch.testing.assert_close(losses["loss"], expected) - - -def test_pipeline_algo_inference_excludes_training_nodes_without_filtering_metadata(): - algo = _algo() - processed = algo.process_batch_for_training(_raw_batch()) - algo.pipeline.train() - - results = algo.forward_eval(processed) - - expected = torch.full((2, 3, 2), 0.5) - assert torch.equal(results["source_a"]["prediction"], expected) - assert algo.pipeline.stages[2].seen_keys == { - "state", - "actions", - "selector", - "ignored", - "condition", - } - assert results["source_a"]["actions"] is processed["source_a"]["actions"] - assert "target" not in results["source_a"] - assert "target" not in processed["source_a"] - - -def test_pipeline_algo_is_not_specific_to_control_batches(): - algo = PipelineAlgo(stages=[_ImageHead()], device="cpu") - pixels = torch.randn(2, 3, 8, 8) - batch = {"images": {"pixels": pixels, "reference": pixels + 1}} - processed = algo.process_batch_for_training(batch) - - train_results = algo.forward_training(processed) - losses = algo.compute_losses(train_results, processed) - inference_results = algo.forward_eval(processed) - - assert losses["loss"].item() == pytest.approx(1.0) - assert torch.equal(inference_results["images"]["reconstruction"], pixels) - assert inference_results["images"]["reference"] is processed["images"]["reference"] - - -def test_pipeline_algo_constructor_and_source_are_route_agnostic(): - parameters = inspect.signature(PipelineAlgo).parameters - assert tuple(parameters) == ( - "stages", - "device", - "stage_ids", - "initialization", - "trainability", - "loss_pipeline", - "training_passes", - "compatibility_mode", - ) - assert parameters["compatibility_mode"].default == "current" - with pytest.raises(TypeError): - PipelineAlgo(stages=[], domains=["anything"]) - - root = Path(__file__).parents[1] - source = "\n".join( - (root / relative).read_text().lower() - for relative in ("egomimic/pipeline/core.py", "egomimic/pipeline/algo.py") - ) - for forbidden in ( - "domain", - "embodiment", - "ac_key", - "action_key", - "norm_stats", - "rollout_adapter", - "action_horizon", - "rollout", - ): - assert forbidden not in source - - -def test_pipeline_algo_rejects_malformed_grouped_input(): - algo = PipelineAlgo(stages=[], device="cpu") - with pytest.raises(TypeError, match="mapping of flat batches"): - algo.process_batch_for_training([{"pixels": torch.zeros(1)}]) - with pytest.raises(TypeError, match="source values"): - algo.process_batch_for_training({"source": torch.zeros(1)}) - - -def test_pipeline_algo_downcasts_float64_but_keeps_integer_dtypes(): - """float64 -> float32 on move; integer metadata is left alone. - - Zarr stamps poses as float64 and autocast does not downcast Double, so a - float64 tensor would reach a bf16 Linear unchanged and raise. The downcast - is deliberate; only floating tensors are touched, so index-like metadata - keeps its width. - """ - algo = PipelineAlgo(stages=[], device="cpu") - processed = algo.process_batch_for_training( - { - "source": { - "value": torch.ones(2, dtype=torch.float64), - "metadata": {"index": torch.ones(2, dtype=torch.int16)}, - } - } - ) - - assert processed["source"]["value"].dtype == torch.float32 - assert processed["source"]["metadata"]["index"].dtype == torch.int16 - - -def test_model_wrapper_keeps_data_provenance_out_of_pipeline(): - config_tree = { - "model": { - "pipeline": { - "_target_": "egomimic.pipeline.algo.PipelineAlgo", - "stages": [], - "device": "cpu", - } - } - } - wrapper = ModelWrapper(config_tree=config_tree) - - assert isinstance(wrapper.model, PipelineAlgo) - with pytest.raises(TypeError, match="norm_stats_state"): - ModelWrapper(config_tree=config_tree, norm_stats_state={"legacy": True}) - - -def test_model_wrapper_accepts_generic_pipeline_loss(monkeypatch): - wrapper = ModelWrapper(pipeline=_algo()) - monkeypatch.setattr(wrapper, "log", lambda *args, **kwargs: None) - - loss = wrapper.training_step(_raw_batch(), batch_idx=0) - - assert loss.ndim == 0 - assert torch.isfinite(loss) - - -def test_model_wrapper_logs_metric_aliases_declared_by_stage(monkeypatch): - wrapper = ModelWrapper(pipeline=_algo()) - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **_kwargs: logged.__setitem__(name, value), - ) - loss = wrapper.training_step(_raw_batch(), batch_idx=0) - - assert torch.equal(logged["Train/MSE/source_a"], loss) - assert torch.equal(logged["Train/MSE"], loss) diff --git a/tests/test_pipeline_arc.py b/tests/test_pipeline_arc.py deleted file mode 100644 index f326008c94..0000000000 --- a/tests/test_pipeline_arc.py +++ /dev/null @@ -1,702 +0,0 @@ -"""Arc tokenize / detokenize graph nodes and the inference-only restriction.""" - -import importlib.util -import math -from pathlib import Path - -import numpy as np -import pytest -import torch - -from egomimic.pipeline.core import Pipeline, Stage -from egomimic.pipeline.stages_arc import ArcDetokenizeStage, ArcTokenizeStage -from egomimic.rldb.zarr.planar_arc import ( - PLANAR_ACTION_DIM, - TokenizePlanarArcLength, -) - -_REPO = Path(__file__).resolve().parents[1] -_EXPERIMENT = ( - _REPO - / "egomimic/hydra_configs/experiment/pusht/planar_v2_usocket_arc_graph_tok.yaml" -) -_SPEC = importlib.util.spec_from_file_location( - "config_graph_arc", _REPO / "tools/config_graph.py" -) -config_graph = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(config_graph) - -_M = 8 -_DT = 1.0 / 30.0 - - -def _straight_line(steps: int = 20, span: float = 100.0) -> torch.Tensor: - actions = torch.zeros(2, steps, 4, dtype=torch.float64) - actions[:, :, 0] = torch.linspace(0.0, span, steps) - return actions - - -def _tokenizer(**kwargs) -> ArcTokenizeStage: - params = dict( - min_distance_unit=50.0, - resampled_vector_length=_M, - dt=_DT, - velocity_mode="mean", - ) - params.update(kwargs) - return ArcTokenizeStage(**params) - - -def _detokenizer(**kwargs) -> ArcDetokenizeStage: - params = dict( - resampled_vector_length=_M, - action_horizon=16, - dt=_DT, - velocity_mode="mean", - ) - params.update(kwargs) - return ArcDetokenizeStage(**params) - - -# -- contracts -------------------------------------------------------------- - - -def test_tokenize_writes_target_and_is_the_only_writer_of_it(): - stage = _tokenizer() - assert stage.contract("train") == (("actions",), ("target",)) - - -def test_tokenize_reads_the_configured_action_key(): - assert _tokenizer(action_key="actions_native").reads == ("actions_native",) - - -def test_tokenize_is_dropped_from_the_inference_graph(): - _, excluded = Pipeline([_tokenizer()]).plan(["actions"], mode="inference") - assert excluded[0][1] == [""] - - -def test_detokenize_is_dropped_from_the_train_graph(): - _, excluded = Pipeline([_detokenizer()]).plan(["pred_action"], mode="train") - assert excluded[0][1] == [""] - - -def test_an_inference_only_stage_does_not_block_a_train_graph(): - # The runner raises on a blocked stage, so this is what the flag buys. - pipeline = Pipeline([_detokenizer()]) - assert pipeline.execute({"unrelated": 1}, mode="train") == {"unrelated": 1} - - -def test_explain_names_the_inference_only_exclusion(): - text = Pipeline([_detokenizer()]).explain(["pred_action"], mode="train") - assert "EXCLUDED: inference-only" in text - - -def test_a_genuinely_blocked_stage_still_raises(): - class NeedsMissing(Stage): - reads = ("absent",) - writes = ("out",) - - def forward(self, batch): # pragma: no cover - never runs - return batch - - with pytest.raises(RuntimeError, match="blocked stages"): - Pipeline([NeedsMissing()]).execute({"present": 1}, mode="train") - - -# -- tokenize --------------------------------------------------------------- - - -def test_tokenize_emits_m_waypoints_plus_one_timing_row(): - out = _tokenizer().forward({"actions": _straight_line()}) - assert out["target"].shape == (2, _M + 1, PLANAR_ACTION_DIM) - - -def test_tokenize_consumes_the_action_key_it_read(): - out = _tokenizer().forward({"actions": _straight_line()}) - assert "actions" not in out - - -def test_tokenize_matches_the_loader_side_transform_exactly(): - # A graph-side and a dataset-side tokenizer configured alike must agree, - # or switching a run between them silently changes the target. - actions = _straight_line() - graph = _tokenizer().forward({"actions": actions})["target"][0].numpy() - loader = TokenizePlanarArcLength( - min_distance_unit=50.0, - resampled_vector_length=_M, - dt=_DT, - velocity_mode="mean", - ).tokenize(actions[0].numpy().astype(np.float64)) - np.testing.assert_allclose(graph, loader) - - -def test_tokenize_preserves_dtype_and_device_of_its_input(): - actions = _straight_line().to(torch.float32) - out = _tokenizer().forward({"actions": actions})["target"] - assert out.dtype == torch.float32 and out.device == actions.device - - -def test_tokenize_anchors_the_first_waypoint_on_the_current_step(): - actions = _straight_line() - token = _tokenizer().forward({"actions": actions})["target"] - np.testing.assert_allclose(token[0, 0, :2].numpy(), actions[0, 0, :2].numpy()) - - -def test_tokenize_rejects_an_unbatched_chunk(): - with pytest.raises(ValueError, match=r"\(B, T, D\)"): - _tokenizer().forward({"actions": torch.zeros(20, 4)}) - - -def test_tokenize_rejects_a_non_tensor(): - with pytest.raises(TypeError, match="must be a tensor"): - _tokenizer().forward({"actions": np.zeros((2, 20, 4))}) - - -# -- detokenize ------------------------------------------------------------- - - -def test_detokenize_returns_the_requested_horizon_and_native_width(): - token = _tokenizer().forward({"actions": _straight_line()})["target"] - out = _detokenizer(action_horizon=12, native_action_dim=3).forward( - {"pred_action": token} - ) - assert out["pred_action_native"].shape == (2, 12, 3) - - -def test_detokenize_recovers_uniform_spacing_on_a_straight_line(): - # The 50-unit window is traversed at 150 units/s, i.e. 5 units per step, so - # 11 steps land exactly on the end and every step is the same size. - token = _tokenizer().forward({"actions": _straight_line()})["target"] - native = _detokenizer(action_horizon=11).forward({"pred_action": token})[ - "pred_action_native" - ] - steps = torch.diff(native[0, :, 0]) - assert torch.allclose(steps, steps[0].expand_as(steps), atol=1e-6) - assert steps[0] > 0 - assert native[0, -1, 0].item() == pytest.approx(50.0) - - -def test_detokenize_step_size_is_speed_times_dt(): - token = _tokenizer().forward({"actions": _straight_line()})["target"] - speed = token[0, _M, 0].item() - native = _detokenizer().forward({"pred_action": token})["pred_action_native"] - assert torch.diff(native[0, :, 0])[0].item() == pytest.approx(speed * _DT, rel=1e-6) - - -def test_detokenize_holds_position_for_a_zero_speed_token(): - token = torch.zeros(1, _M + 1, PLANAR_ACTION_DIM, dtype=torch.float64) - token[0, :_M, 0] = torch.linspace(0.0, 5.0, _M) - token[0, :_M, 2] = 1.0 # cos(theta) = 1 - token[0, _M, 0] = 0.0 # mean arc speed - native = _detokenizer().forward({"pred_action": token})["pred_action_native"] - assert torch.allclose(native[0, :, 0], torch.zeros(16, dtype=torch.float64)) - - -def test_detokenize_clamps_at_the_end_of_the_polyline(): - # A horizon long enough to outrun the window must saturate, not extrapolate. - token = _tokenizer().forward({"actions": _straight_line()})["target"] - native = _detokenizer(action_horizon=400).forward({"pred_action": token})[ - "pred_action_native" - ] - final = native[0, -1, 0] - assert torch.allclose(native[0, -5:, 0], final.expand(5)) - - -def test_detokenize_recovers_heading_through_the_pi_seam(): - # cos/sin interpolation exists so a chunk that WRAPS past +pi does not - # unwind through zero. The headings advance monotonically past the seam: - # pi - 0.2 ... pi + 0.2, which is -pi + 0.2 once wrapped. - token = torch.zeros(1, _M + 1, PLANAR_ACTION_DIM, dtype=torch.float64) - token[0, :_M, 0] = torch.linspace(0.0, 10.0, _M) - thetas = torch.linspace(math.pi - 0.2, math.pi + 0.2, _M, dtype=torch.float64) - token[0, :_M, 2] = torch.cos(thetas) - token[0, :_M, 3] = torch.sin(thetas) - token[0, _M, 0] = 30.0 - native = _detokenizer().forward({"pred_action": token})["pred_action_native"] - # Every decoded heading stays near the seam; none swings back through 0. - assert torch.all(native[0, :, 2].abs() > math.pi - 0.3) - - -def test_detokenize_logs_the_window_it_decoded(): - token = _tokenizer().forward({"actions": _straight_line()})["target"] - out = _detokenizer().forward({"pred_action": token}) - assert out["log/ArcSpeed"].ndim == 0 and out["log/ArcChunkDistance"].ndim == 0 - - -def test_detokenize_rejects_a_token_of_the_wrong_width(): - with pytest.raises(ValueError, match="ArcDetokenizeStage expects"): - _detokenizer().forward({"pred_action": torch.zeros(2, _M, PLANAR_ACTION_DIM)}) - - -@pytest.mark.parametrize( - "kwargs", - [{"resampled_vector_length": 1}, {"action_horizon": 0}, {"native_action_dim": 7}], -) -def test_detokenize_rejects_impossible_settings(kwargs): - with pytest.raises(ValueError): - _detokenizer(**kwargs) - - -# -- the configured graph --------------------------------------------------- - - -@pytest.mark.parametrize("mode", ["train", "inference"]) -def test_arc_graph_experiment_lints_clean(mode): - assert config_graph.build_graph(_EXPERIMENT, mode=mode)["lint"] == [] - - -def test_train_graph_tokenizes_and_omits_the_detokenizer(): - graph = config_graph.build_graph(_EXPERIMENT, mode="train") - names = [node["t"] for node in graph["nodes"]] - assert "ArcTokenizeStage" in names and "ArcDetokenizeStage" not in names - assert {"t": "ArcDetokenizeStage"}.items() <= next( - entry for entry in graph["skipped_stages"] if entry["t"] == "ArcDetokenizeStage" - ).items() - - -def test_inference_graph_detokenizes_and_omits_the_tokenizer(): - graph = config_graph.build_graph(_EXPERIMENT, mode="inference") - names = [node["t"] for node in graph["nodes"]] - assert "ArcDetokenizeStage" in names and "ArcTokenizeStage" not in names - assert "pred_action_native" in { - key for node in graph["nodes"] for key in node["out"] - } - - -def test_tokenizer_replaces_the_plain_target_builder_rather_than_following_it(): - graph = config_graph.build_graph(_EXPERIMENT, mode="train") - names = [node["t"] for node in graph["nodes"]] - assert "ActionTargetBuilder" not in names - writers = [n["t"] for n in graph["nodes"] if "target" in n["out"]] - assert writers == ["ArcTokenizeStage"] - - -# -- the token's speed is an SE(2) rate, not a translational one ------------- -# -# Ported from the main repo's `fix(arc): detokenize walked arc length at a -# chord rate`. There the token carried a chord rate walked against arc -# positions; here the token carries an SE(2) rate (translation + lambda * -# rotation) and the decoder must walk the same metric. Same defect class: -# advancing a rate defined in one metric against positions accumulated in -# another, which silently rescales how fast the chunk replays. - -_RR = 30.0 - - -def _rotating_window(steps: int = 40, span: float = 60.0, sweep: float = 2.5): - actions = torch.zeros(1, steps, 4, dtype=torch.float64) - actions[0, :, 0] = torch.linspace(0.0, span, steps) - actions[0, :, 2] = torch.linspace(0.0, sweep, steps) - return actions - - -def _rotating_token(waypoints: int = 100): - stage = ArcTokenizeStage( - min_distance_unit=200.0, - resampled_vector_length=waypoints, - rotation_radius=_RR, - dt=_DT, - velocity_mode="mean", - ) - return stage.forward({"actions": _rotating_window()})["target"] - - -def _saturation_step(native: torch.Tensor) -> int: - moved = torch.diff(native[0, :, 0]).abs() > 1e-9 - return int(moved.sum().item()) - - -def test_detokenize_walks_the_se2_metric_the_token_speed_is_expressed_in(): - # The 40-step window is 39 intervals; the reconstruction should need about - # that many steps, not a third of them. - token = _rotating_token() - native = ArcDetokenizeStage( - resampled_vector_length=100, - action_horizon=60, - dt=_DT, - rotation_radius=_RR, - velocity_mode="mean", - ).forward({"pred_action": token})["pred_action_native"] - assert _saturation_step(native) == pytest.approx(39, abs=2) - - -def test_ignoring_the_rotation_term_replays_a_rotating_path_too_fast(): - # Pins the defect itself: at radius 0 the decoder measures translation only, - # so it outruns an SE(2) rate and saturates early. - token = _rotating_token() - fast = ArcDetokenizeStage( - resampled_vector_length=100, - action_horizon=60, - dt=_DT, - rotation_radius=0.0, - velocity_mode="mean", - ).forward({"pred_action": token})["pred_action_native"] - correct = ArcDetokenizeStage( - resampled_vector_length=100, - action_horizon=60, - dt=_DT, - rotation_radius=_RR, - velocity_mode="mean", - ).forward({"pred_action": token})["pred_action_native"] - assert _saturation_step(fast) < _saturation_step(correct) / 2 - - -def test_translation_only_and_se2_agree_when_there_is_no_rotation_weight(): - # lambda == 0 makes the two metrics identical, so a straight-line token - # must decode the same either way. - token = _tokenizer().forward({"actions": _straight_line()})["target"] - a = _detokenizer(rotation_radius=0.0).forward({"pred_action": token}) - b = _detokenizer(rotation_radius=_RR).forward({"pred_action": token}) - torch.testing.assert_close(a["pred_action_native"], b["pred_action_native"]) - - -def test_arc_positions_match_the_tokenizers_own_step_metric(): - from egomimic.rldb.zarr.planar_arc import lambda_for_radius, planar_step_distance - - token = _rotating_token() - stage = ArcDetokenizeStage( - resampled_vector_length=100, action_horizon=60, dt=_DT, rotation_radius=_RR - ) - waypoints = token[:, :100] - decoded = stage._arc_positions(waypoints)[0, -1].item() - xy = waypoints[0, :, :2].numpy() - theta = np.arctan2(waypoints[0, :, 3].numpy(), waypoints[0, :, 2].numpy()) - expected = planar_step_distance(xy, theta, lambda_for_radius(_RR)).sum() - assert decoded == pytest.approx(float(expected), rel=1e-9) - - -def test_detokenize_rejects_a_negative_rotation_radius(): - with pytest.raises(ValueError, match="rotation_radius must be non-negative"): - _detokenizer(rotation_radius=-1.0) - - -def test_configured_graph_gives_both_arc_nodes_the_same_rotation_radius(): - # A tokenizer and decoder that disagree on lambda is the whole defect, and - # nothing in the shapes would reveal it. - graph = config_graph.build_graph(_EXPERIMENT, mode="train") - tokenize = next(n for n in graph["nodes"] if n["t"] == "ArcTokenizeStage") - decode = next( - entry for entry in graph["skipped_stages"] if entry["t"] == "ArcDetokenizeStage" - ) - assert decode["reason"] == "inference-only" - inference = config_graph.build_graph(_EXPERIMENT, mode="inference") - decode_node = next(n for n in inference["nodes"] if n["t"] == "ArcDetokenizeStage") - assert tokenize["p"]["rotation_radius"] == decode_node["p"]["rotation_radius"] - assert tokenize["p"]["dt"] == decode_node["p"]["dt"] - assert ( - tokenize["p"]["resampled_vector_length"] - == decode_node["p"]["resampled_vector_length"] - ) - - -# -- the graph tokenizes the RAW window, never a pre-decimated one ----------- -# -# Ported from `feat(arc): tokenize the raw window directly, stop -# pre-decimating it`. The main repo interpolated a 200-frame window down to 100 -# rows before tokenizing, so arc length read short and the hardcoded dt was 2x -# too large. Tokenizing in the graph structurally avoids that -- the loader's -# dense transform only pads -- and these pin it. - - -def _experiment_config(): - config, _ = config_graph._load_selected(_EXPERIMENT) - return config - - -def test_the_loader_hands_over_the_full_raw_window(): - from omegaconf import OmegaConf - - config = _experiment_config() - raw = OmegaConf.select(config, "planar.raw_action_horizon") - for dataset in OmegaConf.select(config, "data.train_datasets").values(): - assert OmegaConf.select(dataset, "resolver.key_map.action_horizon") == raw - - -def test_the_dense_transform_list_does_not_resample_before_tokenizing(): - from hydra.utils import instantiate - from omegaconf import OmegaConf - - from egomimic.rldb.zarr.action_chunk_transforms import ( - InterpolateLinear, - InterpolatePose, - ) - - config = _experiment_config() - dataset = next(iter(OmegaConf.select(config, "data.train_datasets").values())) - transforms = instantiate(OmegaConf.select(dataset, "resolver.transform_list")) - assert transforms - for transform in transforms: - assert not isinstance(transform, (InterpolatePose, InterpolateLinear)) - # And no tokenizer either: tokenization is the graph's job in this config. - assert not any(isinstance(t, TokenizePlanarArcLength) for t in transforms) - - -def test_dt_matches_the_raw_capture_rate(): - from omegaconf import OmegaConf - - config = _experiment_config() - # Rows reaching the tokenizer are real 30 Hz frames, so dt is 1/30. If the - # loader ever resampled, this constant would silently be wrong. - assert OmegaConf.select(config, "planar.arc_dt") == pytest.approx(1.0 / 30.0) - - -# -- velocity_mode: granular per-waypoint rates ------------------------------ -# -# `mean` carries one arc speed for the whole chunk, which is exact only when -# the chunk is traversed at constant speed. `per_waypoint` carries a local rate -# per interval, recovered from the bracketing source frames, so a chunk that -# accelerates, decelerates or dwells replays at its original pace. - -from egomimic.rldb.zarr.planar_arc import ( # noqa: E402 - VELOCITY_MODES, - arc_token_rows, - validate_velocity_mode, -) - - -def _shaped(kind: str, steps: int = 120) -> torch.Tensor: - """(1, T, 3) native planar actions with a chosen speed profile.""" - t = np.linspace(0.0, 1.0, steps) - a = np.zeros((steps, 3)) - if kind == "constant": - a[:, 0] = t * 150.0 - elif kind == "accelerating": - a[:, 0] = (t**2) * 150.0 - elif kind == "decelerating": - a[:, 0] = np.sqrt(t) * 150.0 - elif kind == "dwell_then_move": - a[:, 0] = np.clip((t - 0.5) / 0.5, 0.0, 1.0) * 150.0 - else: - raise ValueError(kind) - a[:, 2] = t * 1.2 - return torch.from_numpy(a[None]) - - -def _round_trip(kind: str, mode: str, *, horizon: int = 16): - tok = ArcTokenizeStage( - min_distance_unit=200.0, - resampled_vector_length=32, - dt=_DT, - rotation_radius=_RR, - velocity_mode=mode, - ) - det = ArcDetokenizeStage( - resampled_vector_length=32, - action_horizon=horizon, - dt=_DT, - rotation_radius=_RR, - native_action_dim=3, - velocity_mode=mode, - ) - actions = _shaped(kind) - token = tok.forward({"actions": actions.clone()})["target"] - decoded = det.forward({"pred_action": token})["pred_action_native"][0].numpy() - return token, decoded, actions[0, :horizon].numpy() - - -def _errors(decoded, truth): - xy = float(np.linalg.norm(decoded[:, :2] - truth[:, :2], axis=-1).mean()) - d = decoded[:, 2] - truth[:, 2] - ang = float(np.abs(np.arctan2(np.sin(d), np.cos(d))).mean()) - return xy, ang - - -def test_arc_token_rows_is_the_single_source_of_truth(): - assert arc_token_rows(32, "mean") == 33 - assert arc_token_rows(32, "per_waypoint") == 64 - assert arc_token_rows(32, "duration") == 64 - - -def test_arc_token_rows_rejects_an_unknown_mode(): - with pytest.raises(ValueError, match="velocity_mode must be one of"): - arc_token_rows(32, "per_step") - - -def test_validate_velocity_mode_accepts_every_declared_mode(): - for mode in VELOCITY_MODES: - assert validate_velocity_mode(mode) == mode - - -def test_duration_is_the_default_timing_contract(): - assert TokenizePlanarArcLength().velocity_mode == "duration" - assert ArcTokenizeStage().velocity_mode == "duration" - assert ArcDetokenizeStage().velocity_mode == "duration" - assert arc_token_rows(32) == 64 - - -@pytest.mark.parametrize("mode", VELOCITY_MODES) -def test_token_width_follows_the_mode(mode): - token, _, _ = _round_trip("constant", mode) - assert tuple(token.shape[1:]) == (arc_token_rows(32, mode), PLANAR_ACTION_DIM) - - -def test_both_modes_are_exact_on_constant_speed(): - # A single mean rate is a complete description here, so the granular mode - # must not be WORSE -- that would mean its rate recovery is broken. - _, mean_out, truth = _round_trip("constant", "mean") - _, gran_out, _ = _round_trip("constant", "per_waypoint") - _, dur_out, _ = _round_trip("constant", "duration") - mean_xy, _ = _errors(mean_out, truth) - gran_xy, _ = _errors(gran_out, truth) - dur_xy, _ = _errors(dur_out, truth) - # Tolerance is relative to the 150-unit path: both are exact to ~1e-5. - assert mean_xy < 1e-3 and gran_xy < 1e-3 and dur_xy < 1e-3 - - -@pytest.mark.parametrize("kind", ["accelerating", "decelerating", "dwell_then_move"]) -@pytest.mark.parametrize("granular_mode", ["per_waypoint", "duration"]) -def test_granular_beats_mean_on_non_uniform_motion(kind, granular_mode): - _, mean_out, truth = _round_trip(kind, "mean") - _, gran_out, _ = _round_trip(kind, granular_mode) - mean_xy, mean_ang = _errors(mean_out, truth) - gran_xy, gran_ang = _errors(gran_out, truth) - assert gran_xy < mean_xy, f"{kind}/{granular_mode}: {gran_xy} !< {mean_xy}" - assert gran_ang <= mean_ang + 1e-6 - - -def test_granular_recovers_a_decelerating_chunk_the_mean_mode_mangles(): - # The widest gap measured: one mean rate overshoots badly when the chunk - # front-loads its travel. - _, mean_out, truth = _round_trip("decelerating", "mean") - _, gran_out, _ = _round_trip("decelerating", "per_waypoint") - _, dur_out, _ = _round_trip("decelerating", "duration") - mean_xy, _ = _errors(mean_out, truth) - gran_xy, _ = _errors(gran_out, truth) - dur_xy, _ = _errors(dur_out, truth) - assert mean_xy > 5.0 - assert gran_xy < 1.0 - assert dur_xy < 1.0 - - -def test_granular_rate_rows_are_populated_and_non_negative(): - token, _, _ = _round_trip("accelerating", "per_waypoint") - rates = token[0, 32:, 0].numpy() - assert (rates >= 0).all() - assert (rates > 0).any() - # Reserved columns stay zero so the block keeps the waypoint width. - assert np.allclose(token[0, 32:, 1:].numpy(), 0.0) - - -def test_duration_rows_are_populated_and_non_negative(): - token, _, _ = _round_trip("accelerating", "duration") - durations = token[0, 32:, 0].numpy() - assert (durations >= 0).all() - assert (durations > 0).any() - assert np.allclose(token[0, 32:, 1:].numpy(), 0.0) - - -def test_duration_and_per_waypoint_agree_on_round_trip(): - # Same source-frame times; rate vs Δt are inverse encodings of the same clock. - _, rate_out, truth = _round_trip("decelerating", "per_waypoint") - _, dur_out, _ = _round_trip("decelerating", "duration") - del truth - assert np.allclose(rate_out, dur_out, atol=1e-5) - - -def test_granular_rates_track_the_speed_profile(): - accel = _round_trip("accelerating", "per_waypoint")[0][0, 32:, 0].numpy() - decel = _round_trip("decelerating", "per_waypoint")[0][0, 32:, 0].numpy() - # Accelerating chunks end faster than they start; decelerating, the reverse. - assert accel[-2] > accel[0] - assert decel[-2] < decel[0] - - -def test_a_stalled_interval_cannot_traverse_the_path(): - # A nonzero-distance interval with a zero predicted rate gets more time than - # the whole horizon, so sampling stays inside that first interval. It creeps - # rather than freezing -- what the guard rules out is the jump to the end. - det = ArcDetokenizeStage( - resampled_vector_length=32, - action_horizon=16, - dt=_DT, - rotation_radius=0.0, - native_action_dim=3, - velocity_mode="per_waypoint", - ) - token = torch.zeros(1, 64, PLANAR_ACTION_DIM, dtype=torch.float64) - token[0, :32, 0] = torch.linspace(0.0, 50.0, 32) # real travel - token[0, :32, 2] = 1.0 # cos(theta)=1 - token[0, 32:, 0] = 0.0 # every rate zero - out = det.forward({"pred_action": token})["pred_action_native"][0].numpy() - travelled = float(out[-1, 0] - out[0, 0]) - one_interval = 50.0 / 31.0 - assert 0.0 <= travelled <= one_interval, travelled - assert travelled < 0.05 * 50.0 # a few percent of the path, not all of it - - -def test_duration_stalled_interval_cannot_traverse_the_path(): - det = ArcDetokenizeStage( - resampled_vector_length=32, - action_horizon=16, - dt=_DT, - rotation_radius=0.0, - native_action_dim=3, - velocity_mode="duration", - ) - token = torch.zeros(1, 64, PLANAR_ACTION_DIM, dtype=torch.float64) - token[0, :32, 0] = torch.linspace(0.0, 50.0, 32) - token[0, :32, 2] = 1.0 - token[0, 32:, 0] = 0.0 # every duration zero - out = det.forward({"pred_action": token})["pred_action_native"][0].numpy() - travelled = float(out[-1, 0] - out[0, 0]) - one_interval = 50.0 / 31.0 - assert 0.0 <= travelled <= one_interval, travelled - assert travelled < 0.05 * 50.0 - - -def test_detokenize_rejects_the_other_modes_token_width(): - mean_det = ArcDetokenizeStage( - resampled_vector_length=32, - action_horizon=16, - velocity_mode="mean", - ) - with pytest.raises(ValueError, match="velocity_mode='mean'"): - mean_det.forward({"pred_action": torch.zeros(1, 64, PLANAR_ACTION_DIM)}) - gran_det = ArcDetokenizeStage( - resampled_vector_length=32, action_horizon=16, velocity_mode="per_waypoint" - ) - with pytest.raises(ValueError, match="velocity_mode='per_waypoint'"): - gran_det.forward({"pred_action": torch.zeros(1, 33, PLANAR_ACTION_DIM)}) - - -@pytest.mark.parametrize("mode", VELOCITY_MODES) -def test_native_decoder_sizes_itself_from_the_mode(mode): - from egomimic.pipeline.pushshapes import PlanarArcWaypointZeroNativeDecoder - - decoder = PlanarArcWaypointZeroNativeDecoder( - resampled_vector_length=32, native_action_dim=3, velocity_mode=mode - ) - rows = arc_token_rows(32, mode) - assert decoder.decode(torch.zeros(2, rows, PLANAR_ACTION_DIM)).shape == (2, 1, 3) - with pytest.raises(ValueError, match="velocity_mode"): - decoder.decode(torch.zeros(2, rows + 1, PLANAR_ACTION_DIM)) - - -def test_experiment_token_rows_match_its_declared_velocity_mode(): - """`planar.arc_token_rows` must equal what the mode implies. - - The diffusion stages validate the target's width against this value, and - the repo registers no arithmetic resolver, so the row count is written out - by hand. This is the guard that catches it drifting from the mode -- a - mismatch otherwise surfaces as a shape error on the first training batch. - """ - from omegaconf import OmegaConf - - config, _ = config_graph._load_selected(_EXPERIMENT) - planar = OmegaConf.select(config, "planar") - assert planar.arc_token_rows == arc_token_rows( - planar.arc_waypoints, planar.arc_velocity_mode - ) - - -def test_experiment_wires_one_velocity_mode_everywhere(): - """Tokenizer, decoder and eval decoder must read the same knob.""" - from omegaconf import OmegaConf - - config, _ = config_graph._load_selected(_EXPERIMENT) - mode = OmegaConf.select(config, "planar.arc_velocity_mode") - stages = OmegaConf.select(config, "model.pipeline.stages") - seen = [s.velocity_mode for s in stages if "velocity_mode" in s] - assert len(seen) == 2, "expected the tokenize and detokenize nodes" - assert set(seen) == {mode} - assert OmegaConf.select(config, "planar.eval_native_decoder.velocity_mode") == mode diff --git a/tests/test_pipeline_core.py b/tests/test_pipeline_core.py deleted file mode 100644 index 0fe22b8e32..0000000000 --- a/tests/test_pipeline_core.py +++ /dev/null @@ -1,123 +0,0 @@ -import numpy as np -import pytest -import torch - -from egomimic.pipeline.core import ( - Pipeline, - Stage, - resolve_homogeneous_scalar, - sum_losses, -) - - -class _FeatureStage(Stage): - reads = ["obs/*"] - writes = ["feature/*"] - - def forward(self, batch): - batch["feature/x"] = batch["obs/x"] + 1 - return batch - - -class _LossStage(Stage): - train_only = True - reads = ["feature/x"] - writes = ["loss/value"] - - def forward(self, batch): - batch["loss/value"] = batch["feature/x"].square().mean() - return batch - - -class _ModeStage(Stage): - reads = ["target"] - writes = ["loss/mode"] - reads_by_mode = {"inference": ["condition"]} - writes_by_mode = {"inference": ["prediction"]} - - -def test_pipeline_registers_and_executes_stages(): - pipeline = Pipeline([_FeatureStage(), _LossStage()]) - source = torch.tensor([2.0], requires_grad=True) - - output = pipeline({"obs/x": source}) - total = sum_losses(output) - total.backward() - - assert total.item() == 9.0 - assert source.grad.item() == 6.0 - - -def test_pipeline_plan_resolves_wildcards_and_train_only_stages(): - feature = _FeatureStage() - loss = _LossStage() - pipeline = Pipeline([feature, loss]) - - runnable, excluded = pipeline.plan(["obs/x"], mode="train") - assert runnable == [feature, loss] - assert excluded == [] - - runnable, excluded = pipeline.plan(["obs/x"], mode="inference") - assert runnable == [feature] - assert excluded == [(loss, [""])] - - -def test_stage_mode_contract_and_explain_are_exact(): - stage = _ModeStage() - pipeline = Pipeline([stage]) - - assert stage.contract("train") == (("target",), ("loss/mode",)) - assert stage.contract("inference") == (("condition",), ("prediction",)) - assert "EXCLUDED: missing target" in pipeline.explain([], mode="train") - assert "prediction" in pipeline.explain(["condition"], mode="inference") - - with pytest.raises(ValueError, match="train\\|inference"): - stage.contract("validation") - - -def test_sum_losses_rejects_graph_without_an_objective(): - with pytest.raises(RuntimeError, match=r"no loss/\* keys"): - sum_losses({"log/value": torch.tensor(1.0)}) - - -@pytest.mark.parametrize("value", [1.0, torch.ones(2)]) -def test_sum_losses_requires_scalar_tensors(value): - with pytest.raises(TypeError, match="must be a scalar tensor"): - sum_losses({"loss/value": value}) - - -@pytest.mark.parametrize( - "value,expected", - [ - ("image", "image"), - (torch.tensor(3), 3), - (torch.tensor([3, 3]), 3), - (np.asarray("image"), "image"), - (np.asarray([4, 4]), 4), - (["image", "image"], "image"), - ((torch.tensor(5), torch.tensor(5)), 5), - ], -) -def test_resolve_homogeneous_scalar(value, expected): - assert resolve_homogeneous_scalar(value) == expected - - -@pytest.mark.parametrize( - "value", - [ - [], - torch.tensor([]), - torch.tensor([1, 2]), - torch.zeros(1, 1), - np.asarray(["left", "right"]), - np.zeros((1, 1)), - ], -) -def test_resolve_homogeneous_scalar_rejects_ambiguous_values(value): - with pytest.raises(ValueError): - resolve_homogeneous_scalar(value) - - -def test_resolve_homogeneous_scalar_rejects_non_scalar_objects(): - with pytest.raises(TypeError, match="must be a scalar"): - resolve_homogeneous_scalar({"selector": "image"}) diff --git a/tests/test_pipeline_flow.py b/tests/test_pipeline_flow.py deleted file mode 100644 index 19ebb06069..0000000000 --- a/tests/test_pipeline_flow.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Flow-matching stages: the head HPT trains with, as graph nodes.""" - -import pytest -import torch -import torch.nn as nn - -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_flow import ( - FlowDenoiserStage, - FlowNoisingStage, - FlowVelocityLossStage, -) - -_H, _D, _C = 8, 5, 16 - - -class _Field(nn.Module): - """Tiny velocity field: sees the point, the time and the condition.""" - - def __init__(self, action_dim: int = _D, cond_dim: int = _C): - super().__init__() - self.net = nn.Linear(action_dim + cond_dim + 1, action_dim) - - def forward(self, x, t, cond): - batch, horizon, _ = x.shape - cond = cond[:, None, :].expand(batch, horizon, cond.shape[-1]) - time = t[:, None, None].expand(batch, horizon, 1) - return self.net(torch.cat([x, cond, time], dim=-1)) - - -def _pipeline(steps: int = 4) -> Pipeline: - return Pipeline( - [ - FlowNoisingStage(action_horizon=_H, action_dim=_D), - FlowDenoiserStage( - model=_Field(), - action_horizon=_H, - action_dim=_D, - condition_input_dim=_C, - num_inference_steps=steps, - ), - FlowVelocityLossStage(), - ] - ) - - -def _batch(size: int = 3) -> dict: - return { - "condition": torch.randn(size, _C), - "target": torch.randn(size, _H, _D), - } - - -# -- noising ---------------------------------------------------------------- - - -def test_noising_declares_the_flow_namespace_it_writes(): - reads, writes = FlowNoisingStage(action_horizon=_H, action_dim=_D).contract("train") - assert reads == ("target",) - assert set(writes) == {"flow/noisy_action", "flow/velocity_target", "flow/time"} - - -def test_noising_is_train_only(): - stage = FlowNoisingStage(action_horizon=_H, action_dim=_D) - _, excluded = Pipeline([stage]).plan(["target"], mode="inference") - assert excluded[0][1] == [""] - - -def test_velocity_target_is_the_straight_line_field(): - """u_t = noise - action, and x_t = t*noise + (1-t)*action. - - Both are recoverable from the outputs, so this pins the identity rather - than just the shapes: action = x_t - t * u_t. - """ - stage = FlowNoisingStage(action_horizon=_H, action_dim=_D) - batch = _batch() - target = batch["target"].clone() - out = stage.forward(batch) - t = out["flow/time"][:, None, None] - recovered = out["flow/noisy_action"] - t * out["flow/velocity_target"] - torch.testing.assert_close(recovered, target, atol=1e-5, rtol=1e-5) - - -def test_sampled_time_stays_strictly_inside_the_unit_interval(): - stage = FlowNoisingStage(action_horizon=_H, action_dim=_D) - times = torch.cat([stage.forward(_batch(64))["flow/time"] for _ in range(8)]) - assert times.min() > 0.0 and times.max() < 1.0 - - -def test_beta_time_leans_toward_the_noisy_end(): - # beta(1.5, 1) is what HPT uses; it should put more mass above 0.5 than a - # uniform draw does, which is the reason to prefer it. - beta = FlowNoisingStage(action_horizon=_H, action_dim=_D, time_dist="beta") - uniform = FlowNoisingStage(action_horizon=_H, action_dim=_D, time_dist="uniform") - torch.manual_seed(0) - beta_mean = torch.cat( - [beta.forward(_batch(256))["flow/time"] for _ in range(4)] - ).mean() - uniform_mean = torch.cat( - [uniform.forward(_batch(256))["flow/time"] for _ in range(4)] - ).mean() - assert beta_mean > uniform_mean - - -def test_noising_rejects_an_unknown_time_distribution(): - with pytest.raises(ValueError, match="time_dist"): - FlowNoisingStage(action_horizon=_H, action_dim=_D, time_dist="cosine") - - -@pytest.mark.parametrize("kwargs", [{"beta_alpha": 0.0}, {"beta_beta": -1.0}]) -def test_noising_rejects_degenerate_beta_parameters(kwargs): - with pytest.raises(ValueError, match="beta_"): - FlowNoisingStage(action_horizon=_H, action_dim=_D, **kwargs) - - -def test_noising_rejects_a_target_of_the_wrong_shape(): - stage = FlowNoisingStage(action_horizon=_H, action_dim=_D) - with pytest.raises(ValueError, match="Flow target must be"): - stage.forward({"target": torch.zeros(2, _H + 1, _D)}) - - -def test_noising_keeps_the_working_dtype_it_was_given(): - # float32 in, float32 out. A float64 target is normalised instead, which - # test_noising_normalises_a_float64_target covers. - stage = FlowNoisingStage(action_horizon=_H, action_dim=_D) - out = stage.forward({"target": torch.zeros(2, _H, _D, dtype=torch.float32)}) - assert out["flow/noisy_action"].dtype == torch.float32 - - -# -- denoiser --------------------------------------------------------------- - - -def test_denoiser_contracts_differ_by_mode_like_the_diffusion_one(): - stage = FlowDenoiserStage( - model=_Field(), action_horizon=_H, action_dim=_D, condition_input_dim=_C - ) - assert stage.contract("train") == ( - ("condition", "flow/noisy_action", "flow/time"), - ("flow/predicted_velocity",), - ) - assert stage.contract("inference") == (("condition",), ("pred_action", "log/*")) - - -def test_denoiser_predicts_a_field_of_the_action_shape(): - out = _pipeline().execute(_batch(), mode="train") - assert out["flow/predicted_velocity"].shape == (3, _H, _D) - - -def test_denoiser_integrates_to_an_action_at_inference(): - out = _pipeline(steps=6).execute( - {"condition": torch.randn(4, _C)}, mode="inference" - ) - assert out["pred_action"].shape == (4, _H, _D) - assert torch.isfinite(out["pred_action"]).all() - - -def test_inference_needs_no_noising_stage_present(): - """The whole point of the mode split: sampling starts from its own noise.""" - pipeline = _pipeline() - runnable, excluded = pipeline.plan(["condition"], mode="inference") - assert [type(s).__name__ for s in runnable] == ["FlowDenoiserStage"] - assert {e[1][0] for e in excluded} == {""} - - -def test_more_inference_steps_change_the_integration_result(): - torch.manual_seed(0) - field = _Field() - - def sample(steps): - torch.manual_seed(1) - stage = FlowDenoiserStage( - model=field, - action_horizon=_H, - action_dim=_D, - condition_input_dim=_C, - num_inference_steps=steps, - ) - return stage.execute({"condition": torch.zeros(2, _C)}, mode="inference")[ - "pred_action" - ] - - assert not torch.allclose(sample(2), sample(32)) - - -def test_denoiser_rejects_a_condition_of_the_wrong_width(): - stage = FlowDenoiserStage( - model=_Field(), action_horizon=_H, action_dim=_D, condition_input_dim=_C - ) - with pytest.raises(ValueError, match="Flow condition must have shape"): - stage.execute({"condition": torch.zeros(2, _C + 1)}, mode="inference") - - -def test_denoiser_rejects_a_three_dimensional_condition(): - # HPT pools to (B, D); a leftover token axis is a wiring bug, not input. - stage = FlowDenoiserStage( - model=_Field(), action_horizon=_H, action_dim=_D, condition_input_dim=_C - ) - with pytest.raises(ValueError, match="Flow condition"): - stage.execute({"condition": torch.zeros(2, 4, _C)}, mode="inference") - - -@pytest.mark.parametrize("steps", [0, -1]) -def test_denoiser_rejects_a_nonpositive_step_count(steps): - with pytest.raises(ValueError, match="num_inference_steps"): - FlowDenoiserStage( - model=_Field(), - action_horizon=_H, - action_dim=_D, - condition_input_dim=_C, - num_inference_steps=steps, - ) - - -# -- loss ------------------------------------------------------------------- - - -def test_loss_writes_the_summed_namespace_and_diagnostics(): - out = _pipeline().execute(_batch(), mode="train") - assert out["loss/flow_velocity"].ndim == 0 - assert "log/flow_target_rms" in out - - -def test_loss_is_zero_for_a_perfect_prediction(): - stage = FlowVelocityLossStage() - field = torch.randn(2, _H, _D) - out = stage.forward( - {"flow/predicted_velocity": field, "flow/velocity_target": field.clone()} - ) - assert float(out["loss/flow_velocity"]) == pytest.approx(0.0) - - -def test_loss_rejects_mismatched_shapes(): - with pytest.raises(ValueError, match="Flow loss shape mismatch"): - FlowVelocityLossStage().forward( - { - "flow/predicted_velocity": torch.zeros(2, _H, _D), - "flow/velocity_target": torch.zeros(2, _H + 1, _D), - } - ) - - -def test_loss_is_train_only(): - _, excluded = Pipeline([FlowVelocityLossStage()]).plan( - ["flow/predicted_velocity", "flow/velocity_target"], mode="inference" - ) - assert excluded[0][1] == [""] - - -# -- the objective actually learns ----------------------------------------- - - -def test_the_flow_objective_converges_on_a_fixed_target(): - """End to end sanity: the graph must be able to fit something. - - A constant action given a constant condition. If the interpolation, the - velocity target or the loss were wrong, this would not come down. - """ - torch.manual_seed(0) - pipeline = _pipeline(steps=16) - optimizer = torch.optim.Adam(pipeline.parameters(), lr=1e-2) - condition = torch.ones(16, _C) - target = torch.full((16, _H, _D), 0.5) - - first = None - for step in range(150): - out = pipeline.execute( - {"condition": condition, "target": target.clone()}, mode="train" - ) - loss = out["loss/flow_velocity"] - if first is None: - first = float(loss) - optimizer.zero_grad() - loss.backward() - optimizer.step() - assert float(loss) < first * 0.7, f"{first} -> {float(loss)}" - - sampled = pipeline.execute({"condition": condition}, mode="inference")[ - "pred_action" - ] - # Sampling should land nearer the target than pure noise would. - assert (sampled - target).abs().mean() < 1.0 - - -# -- token-shaped conditions ------------------------------------------------ - - -class _TokenCondField(nn.Module): - """A denoiser that cross-attends, so it needs a (B, T, C) condition.""" - - def __init__(self, action_dim: int = _D, cond_dim: int = _C): - super().__init__() - self.net = nn.Linear(action_dim + cond_dim, action_dim) - - def forward(self, x, t, cond): - if cond.ndim != 3: - raise AssertionError(f"expected a token condition, got {tuple(cond.shape)}") - pooled = cond.mean(dim=1)[:, None, :].expand(-1, x.shape[1], -1) - return self.net(torch.cat([x, pooled], dim=-1)) - - -def test_condition_as_tokens_adds_a_token_axis_for_cross_attending_denoisers(): - stage = FlowDenoiserStage( - model=_TokenCondField(), - action_horizon=_H, - action_dim=_D, - condition_input_dim=_C, - num_inference_steps=2, - condition_as_tokens=True, - ) - out = stage.execute({"condition": torch.randn(2, _C)}, mode="inference") - assert out["pred_action"].shape == (2, _H, _D) - - -def test_condition_stays_pooled_by_default(): - # The default must keep feeding (B, C), which is what ConditionalUnet1D - # and the existing diffusion path expect. - stage = FlowDenoiserStage( - model=_TokenCondField(), - action_horizon=_H, - action_dim=_D, - condition_input_dim=_C, - num_inference_steps=2, - ) - with pytest.raises(AssertionError, match="expected a token condition"): - stage.execute({"condition": torch.randn(2, _C)}, mode="inference") - - -def test_condition_validation_runs_before_any_reshape(): - stage = FlowDenoiserStage( - model=_TokenCondField(), - action_horizon=_H, - action_dim=_D, - condition_input_dim=_C, - condition_as_tokens=True, - ) - with pytest.raises(ValueError, match="Flow condition must have shape"): - stage.execute({"condition": torch.zeros(2, _C + 3)}, mode="inference") - - -# -- working precision ------------------------------------------------------ - - -def test_noising_normalises_a_float64_target(): - """Zarr actions arrive as Double; autocast never downcasts that.""" - stage = FlowNoisingStage(action_horizon=_H, action_dim=_D) - batch = {"target": torch.zeros(2, _H, _D, dtype=torch.float64)} - out = stage.forward(batch) - for key in ("flow/noisy_action", "flow/velocity_target", "flow/time"): - assert out[key].dtype == torch.float32, key - # `target` is rewritten too, so the loss and any later stage agree. - assert out["target"].dtype == torch.float32 - - -def test_a_float64_target_runs_the_whole_graph(): - pipeline = _pipeline() - batch = { - "condition": torch.randn(3, _C), - "target": torch.randn(3, _H, _D, dtype=torch.float64), - } - out = pipeline.execute(batch, mode="train") - assert torch.isfinite(out["loss/flow_velocity"]) - - -def test_an_explicit_working_dtype_is_honoured(): - stage = FlowNoisingStage(action_horizon=_H, action_dim=_D, dtype="float64") - out = stage.forward({"target": torch.zeros(2, _H, _D, dtype=torch.float32)}) - assert out["flow/noisy_action"].dtype == torch.float64 - - -def test_noising_rejects_a_non_floating_dtype(): - with pytest.raises(ValueError, match="floating torch dtype"): - FlowNoisingStage(action_horizon=_H, action_dim=_D, dtype="int64") diff --git a/tests/test_pipeline_initialization.py b/tests/test_pipeline_initialization.py deleted file mode 100644 index 5d10aa6f40..0000000000 --- a/tests/test_pipeline_initialization.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Explicit module initialization never partial-loads or guesses a namespace.""" - -import hashlib - -import pytest -import torch -from torch import nn - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.core import Stage -from egomimic.pipeline.initialization import configure_trainability, initialize_weights - - -class Projection(Stage): - reads = ("x",) - writes = ("pred_action",) - - def __init__(self): - super().__init__() - self.projection = nn.Linear(3, 2) - - def forward(self, batch): - batch["pred_action"] = self.projection(batch["x"]) - return batch - - -def spec(tmp_path, value): - path = tmp_path / "weights.pt" - torch.save(value, path) - return { - "source": str(path), - "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), - "stage_id": "head", - "module_path": "projection", - } - - -def test_hash_verified_initialization_and_explicit_freeze(tmp_path): - source = nn.Linear(3, 2) - graph = PipelineAlgo( - [Projection()], - device="cpu", - stage_ids={"head": 0}, - initialization=[spec(tmp_path, source.state_dict())], - trainability=[{"stage_id": "head", "trainable": False}], - ) - torch.testing.assert_close( - graph.pipeline.stages[0].projection.weight, source.weight - ) - assert not any(p.requires_grad for p in graph.nets.parameters()) - configure_trainability(graph.pipeline, [{"stage_id": "head", "trainable": True}]) - assert all(p.requires_grad for p in graph.nets.parameters()) - - -@pytest.mark.parametrize("failure", ["hash", "missing", "dtype", "shape", "nan"]) -def test_rejected_initialization_leaves_all_parameters_unchanged(tmp_path, failure): - graph = PipelineAlgo([Projection()], device="cpu", stage_ids={"head": 0}) - before = {k: v.clone() for k, v in graph.nets.state_dict().items()} - state = nn.Linear(3, 2).state_dict() - if failure == "missing": - state.pop("bias") - if failure == "dtype": - state["bias"] = state["bias"].double() - if failure == "shape": - state["bias"] = torch.zeros(3) - if failure == "nan": - state["bias"][0] = float("nan") - setting = spec(tmp_path, state) - if failure == "hash": - setting["sha256"] = "0" * 64 - with pytest.raises(ValueError): - initialize_weights(graph.pipeline, [setting]) - for k, v in graph.nets.state_dict().items(): - torch.testing.assert_close(v, before[k]) - - -def test_trainability_schedule_replays_on_resume_and_preserves_optimizer_membership(): - from egomimic.pl_utils.trainability_behavior import TrainabilitySchedule - - graph = PipelineAlgo([Projection()], device="cpu", stage_ids={"head": 0}) - - class Context: - model = graph - global_step = 0 - - def _default_training_step(self, batch, batch_idx): - return self.global_step - - context = Context() - behavior = TrainabilitySchedule( - [ - {"at_step": 0, "targets": [{"stage_id": "head", "trainable": False}]}, - {"at_step": 2, "targets": [{"stage_id": "head", "trainable": True}]}, - ] - ) - behavior.bind(context) - optimizer = torch.optim.Adam(graph.nets.parameters()) - assert not any(p.requires_grad for p in graph.nets.parameters()) - context.global_step = 2 - assert behavior.training_step({}, 0) == 2 - assert all(p.requires_grad for p in graph.nets.parameters()) - assert {id(p) for p in optimizer.param_groups[0]["params"]} == { - id(p) for p in graph.nets.parameters() - } - behavior.on_load_checkpoint({"global_step": 1}) - assert not any(p.requires_grad for p in graph.nets.parameters()) - behavior.on_load_checkpoint({"global_step": 2}) - assert all(p.requires_grad for p in graph.nets.parameters()) - - -def test_overlapping_initializers_fail_atomically(tmp_path): - graph = PipelineAlgo([Projection()], device="cpu", stage_ids={"head": 0}) - before = {k: v.clone() for k, v in graph.nets.state_dict().items()} - setting = spec(tmp_path, nn.Linear(3, 2).state_dict()) - with pytest.raises(ValueError, match="overlap"): - initialize_weights(graph.pipeline, [setting, setting]) - for name, expected in before.items(): - torch.testing.assert_close(graph.nets.state_dict()[name], expected) diff --git a/tests/test_pipeline_observation.py b/tests/test_pipeline_observation.py deleted file mode 100644 index 9ec0e687e8..0000000000 --- a/tests/test_pipeline_observation.py +++ /dev/null @@ -1,228 +0,0 @@ -import pytest -import torch -import torch.nn as nn - -from egomimic.pipeline.stages_io import ActionTargetBuilder -from egomimic.pipeline.stages_sampler import ( - DPStyleObsEncoder, - FusedObsEncoder, - GaussianLatentNoise, - KeyedFeatureProjection, -) - - -class _PackedState(nn.Module): - def __init__(self): - super().__init__() - self.crop_scope = "episode" - self.seen = None - - def forward_packed( - self, - *, - obs_packed, - cu_seqlens, - T_total, - device, - dtype, - embodiment_id=None, - ): - self.seen = { - "cu": cu_seqlens.tolist(), - "T_total": T_total, - "device": device, - "dtype": dtype, - "embodiment": embodiment_id, - } - return obs_packed["state"] - - -class _MalformedPackedState(_PackedState): - def forward_packed(self, **kwargs): - return super().forward_packed(**kwargs).unsqueeze(0) - - -def test_dp_style_encoder_concatenates_sliced_state_and_image_features(): - image_encoder = nn.Linear(3, 2, bias=False) - encoder = DPStyleObsEncoder( - obs_specs={"state": {"input_dim": 2, "input_slice": [1, 3]}}, - img_encoders={"image": image_encoder}, - ) - state = torch.arange(16, dtype=torch.float32).reshape(4, 4) - image = torch.ones(4, 3) - - output = encoder.forward_packed( - obs_packed={"state": state, "image": image}, T_total=4 - ) - - assert output.shape == (4, 4) - assert torch.equal(output[:, :2], state[:, 1:3]) - assert torch.equal(output[:, 2:], image_encoder(image)) - - -def test_fused_encoder_ignores_unrelated_metadata_and_action_target(): - encoder = _PackedState() - stage = FusedObsEncoder( - encoder=encoder, - inputs={"state": "state"}, - n_obs_steps=2, - ) - state = torch.arange(24, dtype=torch.float32).reshape(3, 2, 4) - actions = torch.randn(3, 5, 6) - - output = stage({"state": state, "actions": actions, "selector": "arm_a"}) - - assert output["condition"].shape == (3, 8) - assert output["actions"] is actions - assert "target" not in output - assert encoder.seen == { - "cu": [0, 2, 4, 6], - "T_total": 6, - "device": state.device, - "dtype": state.dtype, - "embodiment": None, - } - assert encoder._episode_cu.tolist() == [0, 2, 4, 6] - - -def test_fused_encoder_rejects_malformed_encoder_output(): - stage = FusedObsEncoder( - encoder=_MalformedPackedState(), - inputs={"state": "state"}, - n_obs_steps=2, - ) - - with pytest.raises(ValueError, match="output must have shape"): - stage({"state": torch.randn(3, 2, 4)}) - - -def test_action_target_builder_is_a_separate_train_only_node(): - actions = torch.randn(3, 5, 6) - stage = ActionTargetBuilder() - - output = stage({"actions": actions}) - - assert output == {"target": actions} - assert stage.train_only is True - assert stage.contract("train") == (("actions",), ("target",)) - - -def test_fused_encoder_single_step_shape_does_not_depend_on_execution_mode(): - stage = FusedObsEncoder( - encoder=_PackedState(), - inputs={"state": "state"}, - n_obs_steps=1, - ) - value = torch.randn(2, 4) - output = stage({"state": value, "unrelated": "metadata"}) - assert output["condition"].shape == (2, 4) - assert "target" not in output - - -def test_explicit_inputs_are_reflected_in_mode_contracts(): - stage = FusedObsEncoder( - encoder=_PackedState(), - inputs={"state": "state_tensor", "image": "camera_tensor"}, - n_obs_steps=1, - ) - assert stage.contract("train")[0] == ( - "state_tensor", - "camera_tensor", - ) - assert stage.contract("inference")[0] == stage.contract("train")[0] - - -def test_fused_encoder_does_not_require_embodiment_metadata(): - encoder = _PackedState() - stage = FusedObsEncoder( - encoder=encoder, - inputs={"state": "state"}, - n_obs_steps=1, - ) - - output = stage({"state": torch.randn(2, 4)}) - - assert output["condition"].shape == (2, 4) - assert encoder.seen["embodiment"] is None - - -def test_fused_encoder_declares_only_explicit_forward_context(): - encoder = _PackedState() - stage = FusedObsEncoder( - encoder=encoder, - inputs={"state": "state"}, - n_obs_steps=1, - forward_context={"route": "embodiment_id"}, - ) - - output = stage({"state": torch.randn(2, 4), "route": "arm_a"}) - - assert output["condition"].shape == (2, 4) - assert encoder.seen["embodiment"] == "arm_a" - assert stage.contract("train")[0] == ("state", "route") - - -@pytest.mark.parametrize( - "forward_context,match", - [ - ({"left": "route", "right": "route"}, "must be unique"), - ({"route": "obs_packed"}, "cannot replace encoder inputs"), - ], -) -def test_fused_encoder_rejects_ambiguous_forward_context(forward_context, match): - with pytest.raises(ValueError, match=match): - FusedObsEncoder( - encoder=_PackedState(), - inputs={"state": "state"}, - n_obs_steps=1, - forward_context=forward_context, - ) - - -def test_gaussian_noise_uses_explicit_token_count(): - condition = torch.zeros(2, 7) - assert GaussianLatentNoise(num_tokens=4, latent_dim=3)({"condition": condition})[ - "sampler/noise" - ].shape == (2, 4, 3) - - -def test_keyed_projection_resolves_homogeneous_selector_alias(): - stage = KeyedFeatureProjection( - selector_key="model_selector", - input_key="features", - output_key="projected", - output_dim=3, - projections={"image": {"input_dim": 2, "hidden_dim": 4}}, - selector_aliases={19: "image"}, - ) - batch = { - "model_selector": torch.tensor([19, 19]), - "features": torch.randn(2, 2), - } - - output = stage(batch) - - assert output["projected"].shape == (2, 3) - assert set(stage.state_dict()) == { - "projections.image.0.weight", - "projections.image.0.bias", - "projections.image.2.weight", - "projections.image.2.bias", - } - - -def test_keyed_projection_rejects_mixed_selector_batch(): - stage = KeyedFeatureProjection( - selector_key="model_selector", - input_key="features", - output_key="projected", - projections={"image": {"input_dim": 2}}, - ) - - with pytest.raises(ValueError, match="homogeneous"): - stage( - { - "model_selector": torch.tensor([1, 2]), - "features": torch.randn(2, 2), - } - ) diff --git a/tests/test_pipeline_only_surface.py b/tests/test_pipeline_only_surface.py deleted file mode 100644 index f183f89b74..0000000000 --- a/tests/test_pipeline_only_surface.py +++ /dev/null @@ -1,51 +0,0 @@ -import tomllib -from pathlib import Path - -ROOT = Path(__file__).parents[1] - - -def test_legacy_first_party_policy_surface_is_removed(): - removed = [ - "egomimic/models/act_nets.py", - "egomimic/models/hpt_nets.py", - "egomimic/models/preprocess_pi_obs.py", - "egomimic/models/denoising_policy.py", - "egomimic/models/fm_policy.py", - "egomimic/eval/eval_act.py", - "egomimic/eval/eval_hpt.py", - "egomimic/eval/eval_pi.py", - "egomimic/eval/eval_latent.py", - "egomimic/eval/eval_video.py", - "egomimic/eval/latent_dataset.py", - "egomimic/utils/hydra_resolvers.py", - "egomimic/utils/scheduler_utils.py", - "egomimic/utils/tensor_utils.py", - "external/openpi", - ] - leftovers = [relative for relative in removed if (ROOT / relative).exists()] - assert leftovers == [] - assert list((ROOT / "egomimic/algo").glob("*.py")) == [] - - -def test_surviving_model_configs_are_pipeline_only(): - config_dir = ROOT / "egomimic/hydra_configs/model" - configs = sorted(config_dir.rglob("*.yaml")) - for config in configs: - text = config.read_text() - assert "egomimic.algo." not in text, config - assert "robomimic_model" not in text, config - - -def test_distribution_discovers_only_the_egomimic_package(): - project = tomllib.loads((ROOT / "pyproject.toml").read_text()) - package_find = project["tool"]["setuptools"]["packages"]["find"] - assert package_find["include"] == ["egomimic*"] - assert not (ROOT / "setup.py").exists() - - -def test_training_entrypoint_has_one_strict_evaluation_path(): - entrypoint = (ROOT / "egomimic/trainHydra.py").read_text() - assert 'hasattr(eval_obj, "run")' not in entrypoint - assert 'hasattr(eval_obj, "bind_data_context")' not in entrypoint - assert "strict=False" not in entrypoint - assert "register_new_resolver" not in entrypoint diff --git a/tests/test_pipeline_scheduler.py b/tests/test_pipeline_scheduler.py deleted file mode 100644 index dca6a9e105..0000000000 --- a/tests/test_pipeline_scheduler.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -import torch - -from egomimic.utils.schedulers import warmup_cosine_scheduler - - -def test_warmup_cosine_scheduler_reaches_peak_then_eta_min(): - parameter = torch.nn.Parameter(torch.tensor(1.0)) - optimizer = torch.optim.SGD([parameter], lr=1.0) - scheduler = warmup_cosine_scheduler( - optimizer, - max_steps=6, - warmup_steps=2, - warmup_start_factor=0.1, - eta_min=0.2, - ) - rates = [optimizer.param_groups[0]["lr"]] - for _ in range(6): - optimizer.step() - scheduler.step() - rates.append(optimizer.param_groups[0]["lr"]) - - assert rates[:3] == pytest.approx([0.1, 0.55, 1.0]) - assert rates[-1] == pytest.approx(0.2) diff --git a/tests/test_pipeline_standard_dp.py b/tests/test_pipeline_standard_dp.py deleted file mode 100644 index b52f0a3027..0000000000 --- a/tests/test_pipeline_standard_dp.py +++ /dev/null @@ -1,292 +0,0 @@ -import inspect -from copy import deepcopy - -import pytest -import torch -import torch.nn.functional as F - -from egomimic.models.ddim_scheduler import DDIMScheduler -from egomimic.models.denoising_nets import ConditionalUnet1D -from egomimic.models.diffusion_policy import DiffusionPolicy -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_diffusion import ( - DiffusionDenoiserStage, - DiffusionEpsilonLossStage, - DiffusionNoisingStage, -) -from egomimic.pipeline.stages_io import ActionTargetBuilder -from egomimic.pipeline.stages_sampler import FusedObsEncoder - - -def _tiny_scheduler(prediction_type: str = "epsilon", timesteps: int = 8): - return DDIMScheduler( - num_train_timesteps=timesteps, - beta_schedule="squaredcos_cap_v2", - prediction_type=prediction_type, - ) - - -def _tiny_policy(action_dim: int, prediction_type: str = "epsilon"): - return DiffusionPolicy( - model=ConditionalUnet1D( - input_dim=action_dim, - cond_dim=5, - ac_latent_seq=1, - diffusion_step_embed_dim=16, - down_dims=[8, 16], - kernel_size=3, - n_groups=4, - cond_predict_scale=True, - ), - noise_scheduler=_tiny_scheduler(prediction_type), - action_horizon=4, - num_inference_steps=2, - ) - - -def _factorized_stages(action_dim: int = 6): - return ( - ActionTargetBuilder(), - DiffusionNoisingStage( - noise_scheduler=_tiny_scheduler(), - action_dim=action_dim, - action_horizon=4, - ), - DiffusionDenoiserStage( - policy=_tiny_policy(action_dim), - action_horizon=4, - action_dim=action_dim, - condition_input_dim=5, - ), - DiffusionEpsilonLossStage(), - ) - - -def test_factorized_training_uses_epsilon_loss_and_backpropagates(): - stages = _factorized_stages() - output = { - "condition": torch.randn(2, 5), - "actions": torch.randn(2, 4, 6), - } - for stage in stages: - output = stage.train()(output) - - assert output["diffusion/noisy_action"].shape == (2, 4, 6) - assert output["diffusion/timestep"].shape == (2,) - assert output["diffusion/predicted_noise"].shape == (2, 4, 6) - assert output["loss/diffusion_noise"].ndim == 0 - assert torch.isfinite(output["loss/diffusion_noise"]) - assert torch.equal(output["log/MSE"], output["loss/diffusion_noise"].detach()) - output["loss/diffusion_noise"].backward() - denoiser = stages[2] - assert any( - parameter.grad is not None and torch.isfinite(parameter.grad).all() - for parameter in denoiser.parameters() - ) - - -def test_factorized_training_matches_direct_scheduler_and_model_computation(): - policy = _tiny_policy(6) - reference_policy = deepcopy(policy) - target = torch.randn(2, 4, 6) - condition = torch.randn(2, 5) - - torch.manual_seed(123) - expected_noise = torch.randn(target.shape, device=target.device) - expected_timesteps = torch.randint( - 0, - reference_policy.noise_scheduler.num_train_timesteps, - (target.shape[0],), - device=target.device, - ).long() - expected_noisy_action = reference_policy.noise_scheduler.add_noise( - target, expected_noise, expected_timesteps - ) - expected_prediction = reference_policy.model( - expected_noisy_action, expected_timesteps, condition - ) - expected_loss = F.mse_loss(expected_prediction, expected_noise) - - stages = ( - DiffusionNoisingStage( - noise_scheduler=_tiny_scheduler(), - action_dim=6, - action_horizon=4, - ), - DiffusionDenoiserStage( - policy=policy, - action_horizon=4, - action_dim=6, - condition_input_dim=5, - ), - DiffusionEpsilonLossStage(), - ) - torch.manual_seed(123) - output = {"condition": condition, "target": target} - for stage in stages: - output = stage.train()(output) - - assert torch.equal(output["diffusion/predicted_noise"], expected_prediction) - assert torch.equal(output["diffusion/noise_target"], expected_noise) - assert torch.equal(output["loss/diffusion_noise"], expected_loss) - - -@pytest.mark.parametrize("width", [6, 4]) -def test_denoiser_inference_samples_the_configured_width(width): - stage = DiffusionDenoiserStage( - policy=_tiny_policy(width), - action_horizon=4, - action_dim=width, - condition_input_dim=5, - ) - with torch.inference_mode(): - output = stage.execute({"condition": torch.randn(2, 5)}, mode="inference") - - assert output["pred_action"].shape == (2, 4, width) - assert torch.isfinite(output["pred_action"]).all() - assert output["log/diffusion_inference_steps"] == 2.0 - - -def test_nodes_reject_wrong_objective_width_and_scheduler_pair(): - with pytest.raises(ValueError, match="epsilon noise objective"): - DiffusionNoisingStage( - noise_scheduler=_tiny_scheduler("sample"), - action_dim=6, - action_horizon=4, - ) - - target, noising, denoiser, _ = _factorized_stages(4) - with pytest.raises(ValueError, match="must be"): - noising( - target( - { - "actions": torch.randn(2, 4, 6), - } - ) - ) - - noising = DiffusionNoisingStage( - noise_scheduler=_tiny_scheduler(timesteps=7), - action_dim=6, - action_horizon=4, - ) - mismatched = noising({"target": torch.randn(2, 4, 6)}) - with pytest.raises(ValueError, match="schedulers differ"): - denoiser = DiffusionDenoiserStage( - policy=_tiny_policy(6), - action_horizon=4, - action_dim=6, - condition_input_dim=5, - ) - denoiser({**mismatched, "condition": torch.randn(2, 5)}) - - -def test_diffusion_pipeline_contracts_plan_factorized_train_and_inference(): - encoder = FusedObsEncoder( - torch.nn.Identity(), - inputs={"state": "state"}, - n_obs_steps=1, - ) - target, noising, denoiser, loss = _factorized_stages() - pipeline = Pipeline([encoder, target, noising, denoiser, loss]) - - assert encoder.contract("train") == ( - ("state",), - ("condition",), - ) - assert denoiser.contract("inference") == ( - ("condition",), - ("pred_action", "log/*"), - ) - train, train_excluded = pipeline.plan(["state", "actions"], mode="train") - inference, inference_excluded = pipeline.plan(["state"], mode="inference") - assert train == [encoder, target, noising, denoiser, loss] - assert train_excluded == [] - assert inference == [encoder, denoiser] - assert inference_excluded == [ - (target, [""]), - (noising, [""]), - (loss, [""]), - ] - - -def test_diffusion_pipeline_mode_controls_topology_not_module_training_flag(): - pipeline = Pipeline(list(_factorized_stages())) - initial_keys = tuple(pipeline.state_dict()) - - pipeline.eval() - training = pipeline.execute( - { - "condition": torch.randn(2, 5), - "actions": torch.randn(2, 4, 6), - }, - mode="train", - ) - assert "loss/diffusion_noise" in training - - pipeline.train() - inference = pipeline.execute( - {"condition": torch.randn(2, 5)}, - mode="inference", - ) - assert inference["pred_action"].shape == (2, 4, 6) - assert "target" not in inference - assert tuple(pipeline.state_dict()) == initial_keys - - -def test_generic_diffusion_nodes_ignore_unrelated_route_metadata(): - target = torch.randn(2, 4, 6) - condition = torch.randn(2, 5) - first = _factorized_stages() - second = deepcopy(first) - - torch.manual_seed(91) - first_output = {"target": target, "condition": condition, "selector": "first"} - for stage in first[1:]: - first_output = stage.train()(first_output) - - torch.manual_seed(91) - second_output = { - "target": target, - "condition": condition, - "selector": "arbitrary-unused-metadata", - } - for stage in second[1:]: - second_output = stage.train()(second_output) - - assert torch.equal( - first_output["loss/diffusion_noise"], - second_output["loss/diffusion_noise"], - ) - - -def test_generic_denoiser_uses_explicit_width_without_a_route_map(): - policy = _tiny_policy(6) - assert "infer_ac_dims" not in inspect.signature(DiffusionPolicy).parameters - assert "infer_ac_dims" not in vars(policy) - stage = DiffusionDenoiserStage( - policy=policy, - action_horizon=4, - action_dim=6, - condition_input_dim=5, - ) - - output = stage.execute({"condition": torch.randn(2, 5)}, mode="inference") - - assert output["pred_action"].shape == (2, 4, 6) - - -def test_generic_denoiser_uses_singular_policy_state_prefix(): - stage = DiffusionDenoiserStage( - policy=_tiny_policy(6), - action_horizon=4, - action_dim=6, - condition_input_dim=5, - ) - keys = set(stage.state_dict()) - assert keys - assert all(key.startswith("policy.") for key in keys) - - -def test_unet_has_one_conditioning_mechanism(): - assert "feature_concatenate" not in inspect.signature(ConditionalUnet1D).parameters diff --git a/tests/test_planar_arc.py b/tests/test_planar_arc.py deleted file mode 100644 index 60e48a745d..0000000000 --- a/tests/test_planar_arc.py +++ /dev/null @@ -1,192 +0,0 @@ -import math - -import numpy as np -import pytest -import torch - -from egomimic.pipeline.pushshapes import ( - PlanarArcTrajectoryNativeDecoder, - PlanarArcWaypointZeroNativeDecoder, - PlanarCommon5NativeDecoder, -) -from egomimic.rldb.embodiment.pushshapes import get_planar_arc_length_transform_list -from egomimic.rldb.zarr.planar_arc import ( - PadPlanarAction, - TokenizePlanarArcLength, - curvature_adaptive_curve_samples, - lambda_for_radius, - planar_step_distance, -) - - -@pytest.mark.parametrize("width", [2, 3, 4]) -def test_pad_planar_action_has_common_layout(width): - native = np.arange(3 * width, dtype=np.float32).reshape(3, width) / 10 - output = PadPlanarAction().transform({"actions": native.copy()})["actions"] - assert output.shape == (3, 5) - np.testing.assert_allclose(output[:, :2], native[:, :2]) - theta = native[:, 2] if width >= 3 else np.zeros(3) - grip = native[:, 3] if width == 4 else np.zeros(3) - np.testing.assert_allclose(output[:, 2], np.cos(theta)) - np.testing.assert_allclose(output[:, 3], np.sin(theta)) - np.testing.assert_allclose(output[:, 4], grip) - - -def test_planar_arc_shape_anchor_and_timing(): - action = np.column_stack( - ( - np.arange(6, dtype=np.float32), - np.zeros(6, dtype=np.float32), - np.linspace(0, math.pi / 2, 6, dtype=np.float32), - np.linspace(0, 1, 6, dtype=np.float32), - ) - ) - transform = TokenizePlanarArcLength( - min_distance_unit=3, - resampled_vector_length=4, - dt=0.5, - rotation_radius=0, - ) - token = transform.transform({"actions": action})["actions"] - assert token.shape == (8, 5) - np.testing.assert_allclose(token[0, :2], action[0, :2]) - np.testing.assert_allclose(token[0, 2:4], [1, 0], atol=1e-6) - assert token[0, 4] == action[0, 3] - np.testing.assert_allclose(token[4:, 0], 0.5, atol=1e-6) - np.testing.assert_allclose(token[4:, 1:], 0.0, atol=1e-6) - - -def test_rotation_radius_adds_metric_distance(): - xy = np.zeros((3, 2)) - theta = np.array([0, math.pi / 2, math.pi]) - assert planar_step_distance(xy).sum() == 0 - weighted = planar_step_distance(xy, theta, lambda_for_radius(30)) - assert np.all(weighted > 0) - - -def test_hybrid_rotation_budget_caps_a_shared_cartesian_window(): - """A small angular budget shortens the common arc window, not a stream.""" - action = np.array([[0.0, 0.0, 0.0], [10.0, 0.0, math.pi], [20.0, 0.0, math.pi]]) - token = TokenizePlanarArcLength( - min_distance_unit=20, - resampled_vector_length=3, - rotation_radius=0, - hybrid_rotation_unit=0.25, - ).transform({"actions": action})["actions"] - - # The first pi rotation has unit chordal metric, so its 0.25 budget keeps - # only the first quarter of the 20-unit Cartesian future: x=5. - np.testing.assert_allclose(token[2, :2], [5.0, 0.0], atol=1e-6) - - -def test_hybrid_rotation_budget_does_not_shorten_translation_only_motion(): - action = np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0]]) - legacy = TokenizePlanarArcLength( - min_distance_unit=12, resampled_vector_length=3 - ).transform({"actions": action})["actions"] - hybrid = TokenizePlanarArcLength( - min_distance_unit=12, - resampled_vector_length=3, - hybrid_rotation_unit=0.25, - ).transform({"actions": action})["actions"] - np.testing.assert_allclose(hybrid, legacy) - - -@pytest.mark.parametrize("budget", [0.0, -1.0, float("nan")]) -def test_hybrid_rotation_budget_must_be_positive_and_finite(budget): - with pytest.raises(ValueError, match="hybrid_rotation_unit"): - TokenizePlanarArcLength(hybrid_rotation_unit=budget) - - -def test_zero_motion_holds_pose_and_grip(): - action = np.repeat(np.array([[4.0, 7.0, 0.5, 0.75]]), 5, axis=0) - token = TokenizePlanarArcLength(resampled_vector_length=3).transform( - {"actions": action} - )["actions"] - np.testing.assert_allclose(token[:3, :2], [[4, 7]] * 3) - np.testing.assert_allclose(token[:3, 4], 0.75) - assert token[-1, 0] == 0 - - -def test_curvature_sampling_allocates_denser_support_on_a_bend(): - straight_x = np.linspace(0.0, 10.0, 61) - straight = np.column_stack((straight_x, np.zeros_like(straight_x))) - angle = np.linspace(-math.pi / 2, 0.0, 41)[1:] - bend = np.column_stack((10.0 + 2.0 * np.cos(angle), 2.0 + 2.0 * np.sin(angle))) - xy = np.concatenate((straight, bend), axis=0) - cumulative = np.concatenate((np.zeros(1), np.cumsum(planar_step_distance(xy)))) - - _, targets = curvature_adaptive_curve_samples( - xy, cumulative, float(cumulative[-1]), 16, dense_samples=513 - ) - straight_end = cumulative[len(straight) - 1] - bend_gaps = np.diff(targets[targets >= straight_end]) - straight_gaps = np.diff(targets[targets <= straight_end]) - assert len(bend_gaps) >= 2 - assert np.median(bend_gaps) < np.median(straight_gaps) - - -def test_duration_decoder_restores_the_full_control_rate_trajectory(): - raw = np.column_stack((np.arange(40, dtype=np.float32), np.zeros(40), np.zeros(40))) - token = TokenizePlanarArcLength( - min_distance_unit=40.0, - resampled_vector_length=16, - dt=1.0 / 30.0, - ).tokenize(raw) - decoder = PlanarArcTrajectoryNativeDecoder( - resampled_vector_length=16, - native_action_dim=3, - raw_action_horizon=40, - ) - trajectory = decoder.decode(token) - assert trajectory.shape == (1, 40, 3) - np.testing.assert_allclose(trajectory[0], raw, atol=1e-5) - with pytest.raises(ValueError, match="requires per-point timing"): - PlanarArcTrajectoryNativeDecoder(16, 3, 40, velocity_mode="mean") - - -def test_arc_transform_aligns_the_native_target_before_tokenization(): - """Hobs=2 must encode a_(t+1)..a_(t+40), never the 41-row loader window.""" - loader_window = np.column_stack( - (np.arange(41, dtype=np.float32), np.zeros(41), np.zeros(41)) - ) - transforms = get_planar_arc_length_transform_list( - action_horizon=32, - raw_action_horizon=40, - action_target_offset=1, - min_distance_unit=40.0, - resampled_vector_length=16, - dt=1.0 / 30.0, - velocity_mode="duration", - ) - batch = {"actions": loader_window.copy()} - for transform in transforms: - batch = transform.transform(batch) - - assert batch["actions"].shape == (32, 5) - decoded = PlanarArcTrajectoryNativeDecoder( - resampled_vector_length=16, - native_action_dim=3, - raw_action_horizon=40, - ).decode(batch["actions"]) - np.testing.assert_allclose(decoded[0], loader_window[1:], atol=1e-5) - - -@pytest.mark.parametrize("native_dim", [2, 3, 4]) -def test_common_and_arc_adapters_decode_same_anchor(native_dim): - token = torch.tensor([[[2.0, 3.0, 0.0, 1.0, 0.4], [9.0, 8.0, 1.0, 0.0, 0.0]]]) - dense = PlanarCommon5NativeDecoder(2, native_dim).decode(token) - arc_input = torch.cat((token, torch.zeros(1, 1, 5)), dim=1) - arc = PlanarArcWaypointZeroNativeDecoder( - 2, native_dim, velocity_mode="mean" - ).decode(arc_input) - assert dense.shape == (1, 2, native_dim) - torch.testing.assert_close(arc, dense[:, :1]) - - -def test_arc_rejects_nonfinite_or_short_input(): - transform = TokenizePlanarArcLength() - with pytest.raises(ValueError): - transform.transform({"actions": np.zeros((1, 3))}) - with pytest.raises(ValueError): - transform.transform({"actions": np.array([[0, 0], [np.nan, 1]])}) diff --git a/tests/test_planar_configs.py b/tests/test_planar_configs.py deleted file mode 100644 index 38dc6c4bcb..0000000000 --- a/tests/test_planar_configs.py +++ /dev/null @@ -1,290 +0,0 @@ -import hashlib -import json -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -ROWS = { - "usocket_arc_bc": ("pushshapes_sim_u_socket", 200, 1, "PlanarFlowSampler"), - "usocket_arc_hybrid_D40_M16_R24deg_bc": ( - "pushshapes_sim_u_socket", - 32, - 1, - "PlanarFlowSampler", - ), - "chain_arc_bc": ("pushshapes_sim_chain_gripper", 200, 1, "PlanarFlowSampler"), - "usocket_direct_bc": ("pushshapes_sim_u_socket", 16, 1, "PlanarFlowSampler"), - "chain_direct_bc": ("pushshapes_sim_chain_gripper", 16, 1, "PlanarFlowSampler"), - "usocket_dp_standard": ("pushshapes_sim_u_socket", 16, 1, "ConditionalUnet1D"), - "chain_dp_standard": ("pushshapes_sim_chain_gripper", 16, 1, "ConditionalUnet1D"), - "usocket_dp_paper": ("pushshapes_sim_u_socket", 16, 2, "PaperConditionalUnet1D"), - "chain_dp_paper": ("pushshapes_sim_chain_gripper", 16, 2, "PaperConditionalUnet1D"), -} - -DATASETS = { - "pushshapes_sim_u_socket": { - "root": "/coc/flash7/paphiwetsa3/datasets/Tsim_v2/u_socket_3000_v2_clean", - "total": 2999, - "train": 2970, - "valid": 29, - "manifest": "planar_v2_usocket_dp_3k_split_seed42_v1.json", - "manifest_sha256": "3683e3461596eef8df2432fa865779b3c77b2a2057dabd0fea125595729cf313", - }, - "pushshapes_sim_chain_gripper": { - "root": "/coc/flash7/paphiwetsa3/datasets/Tsim_v2/chain_gripper_3000_v2", - "total": 3000, - "train": 2970, - "valid": 30, - "manifest": "planar_v2_chain_gripper_dp_3k_split_seed42_v1.json", - "manifest_sha256": "3ced944ea3af8e875ea88fc5c2df3a5d2865a9f95223d109fb4bd28c8be7cf69", - }, -} - -DP_DOMAIN_BASES = { - "chain_dp_standard": "chain_base", - "chain_dp_paper": "chain_base", - "usocket_dp_standard": "usocket_base", - "usocket_dp_paper": "usocket_base", -} - -DOMAIN_BASE_DATA = { - "chain_base": "chain_gripper", - "usocket_base": "usocket", -} - - -@pytest.mark.parametrize("row,expected", ROWS.items()) -def test_planar_row_composes_without_pipeline_routing_metadata(row, expected): - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(config_dir.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[f"+experiment=pusht/planar_v2_{row}", "++paths.root_dir=."], - ) - for section in (cfg.planar, cfg.model, cfg.data): - OmegaConf.resolve(section) - domain, horizon, observation_horizon, implementation = expected - assert list(cfg.data.train_datasets) == [domain] - assert not { - "action_horizon", - "domains", - "ac_keys", - "rollout_adapter", - "rollout_adapters", - "rollout_observation_adapters", - "norm_stats", - } & set(cfg.model.pipeline) - dataset = cfg.data.train_datasets[domain] - assert dataset.valid_ratio == cfg.data.valid_datasets[domain].valid_ratio == 0.01 - assert dataset.split_seed == cfg.seed == 42 - expected_data = DATASETS[domain] - assert dataset.resolver.folder_path == expected_data["root"] - assert dataset.expected_train_episode_count == expected_data["train"] - assert dataset.expected_valid_episode_count == expected_data["valid"] - assert dataset.resolver.expected_episode_count == expected_data["total"] - assert cfg.run_provenance.split_manifest_sha256 == expected_data["manifest_sha256"] - assert cfg.run_provenance.dataset_observation_alignment == "pre_step" - assert cfg.planar.observation_horizon == observation_horizon - decoder = cfg.planar.eval_native_decoder - if "arc_bc" in row: - assert decoder._target_.endswith("PlanarArcWaypointZeroNativeDecoder") - assert "action_horizon" not in decoder - elif "arc_hybrid" in row: - assert decoder._target_.endswith("PlanarArcWaypointZeroNativeDecoder") - assert decoder.resampled_vector_length == 16 - assert cfg.planar.arc_distance == 40.0 - assert cfg.planar.arc_rotation_radius == 0.0 - assert cfg.planar.hybrid_rotation_unit == 0.14776 - assert dataset.resolver.transform_list.rotation_radius == 0.0 - assert dataset.resolver.transform_list.hybrid_rotation_unit == 0.14776 - else: - assert decoder._target_.endswith("PlanarCommon5NativeDecoder") - assert decoder.action_horizon == 16 - if "arc_hybrid" not in row: - assert dataset.resolver.transform_list.rotation_radius == 30.0 - assert dataset.resolver.transform_list.hybrid_rotation_unit is None - model_yaml = OmegaConf.to_yaml(cfg.model) - assert implementation in model_yaml - stage_names = [ - stage._target_.rsplit(".", 1)[-1] for stage in cfg.model.pipeline.stages - ] - assert stage_names[0:2] == ["FusedObsEncoder", "ActionTargetBuilder"] - assert cfg.model.pipeline.stages[0].inputs == { - "front_img_1": "front_img_1", - "state_agent_obj": "state_agent_obj", - } - sampler = cfg.model.pipeline.stages[3] - assert sampler.action_horizon == horizon - if "dp_paper" not in row: - assert "eval_checkpoint" not in cfg - if "dp_paper" in row: - assert cfg.callbacks.ema.use_warmup is True - assert cfg.eval_checkpoint.use_ema is True - assert "DDPMScheduler" in model_yaml - assert stage_names == [ - "FusedObsEncoder", - "ActionTargetBuilder", - "DiffusionNoisingStage", - "DiffusionDenoiserStage", - "DiffusionEpsilonLossStage", - ] - assert cfg.planar.action_target_offset == 1 - assert cfg.model.pipeline.stages[3].action_dim == 5 - assert OmegaConf.to_container(cfg.run_provenance.action_contract) == { - "observation_alignment": "pre_step", - **( - {"representation": "x_y_cos_theta_sin_theta"} - if row == "usocket_dp_paper" - else {} - ), - "observation_horizon": 2, - "training_action_target_offset": 1, - "prediction_horizon": 16, - "rollout_action_chunk_start_index": 0, - "replan_every": 8, - "execution_slice": "[0,8)", - } - elif "dp_standard" in row: - assert "DDIMScheduler" in model_yaml - assert "ema" not in cfg.callbacks - assert cfg.model.pipeline.stages[3].action_dim == 5 - assert stage_names == [ - "FusedObsEncoder", - "ActionTargetBuilder", - "DiffusionNoisingStage", - "DiffusionDenoiserStage", - "DiffusionEpsilonLossStage", - ] - - -@pytest.mark.parametrize("row,domain_base", DP_DOMAIN_BASES.items()) -def test_dp_rows_inherit_model_neutral_domain_base(row, domain_base): - experiment_dir = ( - Path(__file__).parents[1] - / "egomimic" - / "hydra_configs" - / "experiment" - / "pusht" - ) - experiment = OmegaConf.load(experiment_dir / f"planar_v2_{row}.yaml") - defaults = OmegaConf.to_container(experiment.defaults) - assert defaults[0] == f"/experiment/pusht/planar_v2_{domain_base}" - assert all("direct_bc" not in str(item) for item in defaults) - - base = OmegaConf.load(experiment_dir / f"planar_v2_{domain_base}.yaml") - base_defaults = OmegaConf.to_container(base.defaults) - assert base_defaults[0] == "/experiment/pusht/planar_v2_base" - assert base_defaults[1] == { - "override /data": f"pusht/planar_v2_{DOMAIN_BASE_DATA[domain_base]}" - } - assert "model" not in base - - -def test_standard_dp_clean_cotrain_composes_both_domains_without_obstacles(): - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(config_dir.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=pusht/planar_v2_cotrain_clean_dp_standard", - "++paths.root_dir=.", - ], - ) - for section in (cfg.planar, cfg.model, cfg.data, cfg.run_provenance): - OmegaConf.resolve(section) - expected_domains = { - "pushshapes_sim_u_socket", - "pushshapes_sim_chain_gripper", - } - assert set(cfg.data.train_datasets) == expected_domains - assert set(cfg.data.valid_datasets) == expected_domains - assert set(cfg.evaluator.native_decoders) == expected_domains - assert cfg.run_provenance.obstacle_data is False - assert all( - "obstacle" not in dataset.resolver.folder_path.lower() - for dataset in cfg.data.train_datasets.values() - ) - assert all( - dataset.valid_ratio == 0.01 and dataset.split_seed == 42 - for dataset in cfg.data.train_datasets.values() - ) - assert cfg.model.pipeline.stages[3].action_dim == 5 - - -def test_diffusion_mse_is_visible_during_long_training_epochs(monkeypatch): - import torch - - from egomimic.pl_utils.pl_model import ModelWrapper - from tests.test_model_wrapper_metrics import _MetricPipeline - - wrapper = ModelWrapper(pipeline=_MetricPipeline(), train_log_on_step=True) - logged = {} - monkeypatch.setattr( - wrapper, "log", lambda key, value, **kw: logged.setdefault(key, kw) - ) - wrapper.training_step({"source": {"metric": torch.tensor(2.0)}}, 0) - for key in ("Train/MSE", "Train/MSE/source"): - assert logged[key]["on_step"] is True - assert logged[key]["on_epoch"] is False - - -def test_duration_arc_explicitly_separates_raw_and_token_horizons(): - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(config_dir.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=pusht/planar_v2_usocket_arc_paper_uniform_D40_M16_R24deg", - "++paths.root_dir=.", - ], - ) - OmegaConf.resolve(cfg.data) - dataset = cfg.data.train_datasets.pushshapes_sim_u_socket - transform = dataset.resolver.transform_list - assert dataset.resolver.key_map.action_horizon == 40 - assert transform.action_horizon == 32 - assert transform.raw_action_horizon == 40 - assert transform.action_target_offset == 1 - - -def test_ddp_device_count_is_per_node_without_eval_resolver(): - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(config_dir.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=pusht/planar_v2_cotrain_clean_dp_standard", - "launch_params.gpus_per_node=2", - "launch_params.nodes=3", - "++paths.root_dir=.", - ], - ) - assert cfg.trainer.devices == 2 - assert cfg.trainer.num_nodes == 3 - assert "${eval:" not in OmegaConf.to_yaml(cfg.trainer, resolve=False) - - -@pytest.mark.parametrize("domain,expected", DATASETS.items()) -def test_three_thousand_episode_split_manifest_is_exact(domain, expected): - data_dir = ( - Path(__file__).parents[1] / "egomimic" / "hydra_configs" / "data" / "pusht" - ) - path = data_dir / expected["manifest"] - payload = path.read_bytes() - manifest = json.loads(payload) - split = manifest["domains"][domain] - - assert hashlib.sha256(payload).hexdigest() == expected["manifest_sha256"] - assert split["folder_path"] == expected["root"] - assert split["total_count"] == expected["total"] - assert split["train_count"] == expected["train"] - assert split["valid_count"] == expected["valid"] - assert split["id_overlap_count"] == 0 - assert split["resolved_path_overlap_count"] == 0 - - -def test_runtime_targets_are_declared_dependencies(): - pyproject = (Path(__file__).parents[1] / "pyproject.toml").read_text() - assert '"diffusers==0.30.3"' in pyproject diff --git a/tests/test_planar_native_sample_axes.py b/tests/test_planar_native_sample_axes.py deleted file mode 100644 index 71082856c3..0000000000 --- a/tests/test_planar_native_sample_axes.py +++ /dev/null @@ -1,131 +0,0 @@ -import torch - -from egomimic.eval.planar_action_eval import PlanarActionEval -from egomimic.pipeline.pushshapes import PlanarCommon5NativeDecoder - - -def test_native_sample_batch_axes_match_serial_decoding(): - class AffineNormalizer: - def unnormalize(self, values, embodiment_id): - assert embodiment_id == 20 - return { - key: value * 2 + value.new_tensor([1, -3, 0, 0, 0.5]) - for key, value in values.items() - } - - evaluator = PlanarActionEval(energy_score_enabled=False) - evaluator.bind_data_context(normalizer=AffineNormalizer()) - decoder = PlanarCommon5NativeDecoder(action_horizon=16, native_action_dim=4) - generator = torch.Generator().manual_seed(42) - predictions = torch.randn(32, 16, 16, 5, generator=generator) - unnormalized = AffineNormalizer().unnormalize({"actions": predictions}, 20)[ - "actions" - ] - expected = torch.stack([decoder.decode(sample) for sample in unnormalized]) - actual = evaluator._native(predictions, 20, decoder) - assert actual.shape == (32, 16, 16, 4) - torch.testing.assert_close(actual, expected, rtol=0, atol=0) - torch.testing.assert_close( - evaluator._native(predictions[0], 20, decoder), expected[0], rtol=0, atol=0 - ) - - -def test_chain_es32_native_artifact_preserves_axes_and_exact_identity(tmp_path): - import hashlib - import json - from pathlib import Path - from types import SimpleNamespace - - def sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - config = tmp_path / ".hydra/config.yaml" - config.parent.mkdir() - config.write_text("native_chain_dp: true\n") - content = tmp_path / "content.json" - content.write_text(json.dumps({"aggregate_sha256": "d" * 64})) - seedbank = ( - Path(__file__).parents[1] - / "egomimic/hydra_configs/evaluator/energy_score_seed_bank_k32_v1.json" - ) - evaluator = PlanarActionEval( - artifact_root=str(tmp_path / "artifacts"), - seed_bank_path=str(seedbank), - seed_bank_sha256=sha(seedbank), - native_decoder=PlanarCommon5NativeDecoder(16, 4), - energy_score_validation_view={"split_manifest_sha256": "c" * 64}, - energy_score_provenance={ - "source_commit": "a" * 40, - "normalization_sha256": "b" * 64, - "split_manifest_sha256": "c" * 64, - "resolved_config_path": str(config), - "wandb": { - "entity": "rl2-group", - "project": "fixture", - "run_id": "fixture-smoke", - }, - "dataset_content": { - "manifest_path": str(content), - "manifest_sha256": sha(content), - "aggregate_sha256": "d" * 64, - }, - }, - ) - - class Normalizer: - def unnormalize(self, values, _embodiment): - return { - key: value * 2 + value.new_tensor([1, -3, 0, 0, 0.5]) - for key, value in values.items() - } - - evaluator.bind_data_context(normalizer=Normalizer()) - evaluator.trainer = SimpleNamespace( - global_step=2, current_epoch=0, global_rank=0, precision="bf16" - ) - predictions = torch.randn( - 32, 16, 16, 5, generator=torch.Generator().manual_seed(42) - ) - batch = { - "chain": { - "actions": predictions[0], - "embodiment": torch.full((16,), 20), - "episode_hash": torch.arange(16) + 100, - "frame_index": torch.arange(16), - } - } - scores = { - "chain": { - key: torch.zeros(16) - for key in [ - "accuracy_by_condition", - "diversity_by_condition", - "score_by_condition", - ] - } - } - evaluator._save_artifact(0, {"chain": predictions}, scores, batch) - artifact = torch.load( - next((tmp_path / "artifacts").rglob("*.pt")), weights_only=False - ) - domain = artifact["domains"]["pushshapes_sim_chain_gripper"] - expected = torch.stack( - [ - evaluator.native_decoder.decode(sample) - for sample in Normalizer().unnormalize({"actions": predictions}, 20)[ - "actions" - ] - ] - ) - torch.testing.assert_close(domain["native_predictions"], expected, rtol=0, atol=0) - assert domain["native_predictions"].shape == (32, 16, 16, 4) - assert artifact["seed_bank"] == evaluator.seeds and len(artifact["seed_bank"]) == 32 - assert artifact["identity"]["normalization_sha256"] == "b" * 64 - assert ( - len( - artifact["identity"]["validation_conditions"][ - "pushshapes_sim_chain_gripper" - ] - ) - == 16 - ) diff --git a/tests/test_planar_runtime.py b/tests/test_planar_runtime.py deleted file mode 100644 index 98f4b7b995..0000000000 --- a/tests/test_planar_runtime.py +++ /dev/null @@ -1,549 +0,0 @@ -import importlib.util -import math -import sys -import types -from collections import OrderedDict -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -import torch.nn as nn -from omegaconf import OmegaConf - -from egomimic.eval.checkpoint_loading import ( - extract_pipeline_nets_state, - strict_load_pipeline_checkpoint, -) -from egomimic.eval.energy_score import energy_score -from egomimic.eval.planar_action_eval import PlanarActionEval -from egomimic.models.denoising_nets import CrossTransformer, PaperConditionalUnet1D -from egomimic.pipeline.core import resolve_homogeneous_scalar -from egomimic.pipeline.pushshapes import PlanarCommon5NativeDecoder -from egomimic.utils.ema_callback import EMACallback - - -def _load_planar_stages_without_shared_pipeline(): - """The isolated slice intentionally receives Stage from the preceding PR.""" - module_name = "egomimic.pipeline.core" - previous = sys.modules.get(module_name) - stub = types.ModuleType(module_name) - stub.Stage = nn.Module - stub.resolve_homogeneous_scalar = resolve_homogeneous_scalar - sys.modules[module_name] = stub - try: - path = Path(__file__).parents[1] / "egomimic/pipeline/stages_planar.py" - spec = importlib.util.spec_from_file_location("_planar_stages_test", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - finally: - if previous is None: - sys.modules.pop(module_name, None) - else: - sys.modules[module_name] = previous - - -class TinyDenoiser(nn.Module): - def __init__(self, latent_dim, hidden_dim): - super().__init__() - self.proj_u = nn.Linear(latent_dim, hidden_dim) - self.proj_d = nn.Linear(hidden_dim, latent_dim) - - def forward(self, latent, time, condition): - del time - return self.proj_d(torch.tanh(self.proj_u(latent) + condition[..., :4])) - - -def test_planar_flow_sampler_train_and_eval_contract(): - module = _load_planar_stages_without_shared_pipeline() - sampler = module.PlanarFlowSampler( - TinyDenoiser(3, 4), - condition_input_dim=2, - action_horizon=5, - action_dims={"pushshapes_sim_u_socket": 5}, - latent_dim=3, - condition_dim=4, - decoder_hidden_dim=8, - denoiser_hidden_dim=4, - gradient_checkpointing=False, - num_inference_steps=4, - ) - batch = { - "embodiment": torch.tensor([19, 19]), - "condition": torch.randn(2, 2), - "sampler/noise": torch.randn(2, 5, 3), - } - result = sampler(batch.copy()) - assert result["pred_action"].shape == (2, 5, 5) - torch.testing.assert_close( - sampler.last_integration_step_sizes.sum(dim=-1), torch.ones(2) - ) - sampler.train() - batches_seen = int(sampler.training_batches_seen) - result = sampler.execute(batch.copy(), mode="inference") - assert result["log/sampler_unroll_steps"] == 4 - assert int(sampler.training_batches_seen) == batches_seen - with pytest.raises(ValueError, match="homogeneous"): - sampler({**batch, "embodiment": torch.tensor([19, 20])}) - - -def test_planar_decoder_has_compact_sequential_state_keys(): - module = _load_planar_stages_without_shared_pipeline() - decoder = module.TokenwiseActionDecoder( - latent_dim=3, - hidden_dim=8, - action_dim=5, - num_layers=3, - ) - assert set(decoder.state_dict()) == { - "0.weight", - "0.bias", - "2.weight", - "2.bias", - "4.weight", - "4.bias", - } - - -def test_paper_unet_preserves_action_shape(): - model = PaperConditionalUnet1D( - input_dim=5, - global_cond_dim=12, - diffusion_step_embed_dim=8, - down_dims=(8, 16), - n_groups=4, - ) - output = model(torch.randn(2, 16, 5), torch.tensor([1, 2]), torch.randn(2, 12)) - assert output.shape == (2, 16, 5) - - -@pytest.mark.parametrize( - ("time_conditioning", "embedding_width"), - (("concat", 4), ("additive", 8)), -) -def test_cross_transformer_time_conditioning_preserves_expected_state_shapes( - time_conditioning, embedding_width -): - model = CrossTransformer( - nblocks=1, - cond_dim=6, - hidden_dim=8, - act_dim=4, - act_seq=3, - n_heads=2, - dropout=0.0, - mlp_layers=1, - mlp_ratio=2, - time_conditioning=time_conditioning, - ) - assert model.proj_u.weight.shape == (embedding_width, 4) - assert model.state_dict()["pos_emb"].shape == (1, 3, embedding_width) - output = model( - torch.randn(2, 3, 4), - torch.rand(2), - torch.randn(2, 2, 6), - ) - assert output.shape == (2, 3, 4) - - -def test_cross_transformer_rejects_invalid_or_rank_deficient_conditioning(): - kwargs = { - "nblocks": 1, - "cond_dim": 6, - "act_seq": 3, - "n_heads": 1, - "dropout": 0.0, - "mlp_layers": 1, - "mlp_ratio": 2, - } - with pytest.raises(ValueError, match="either 'concat' or 'additive'"): - CrossTransformer( - hidden_dim=8, - act_dim=4, - time_conditioning="unknown", - **kwargs, - ) - with pytest.raises(ValueError, match="even hidden_dim"): - CrossTransformer( - hidden_dim=7, - act_dim=3, - time_conditioning="concat", - **kwargs, - ) - with pytest.raises(ValueError, match="Rank-deficient denoiser input"): - CrossTransformer( - hidden_dim=8, - act_dim=5, - time_conditioning="concat", - **kwargs, - ) - additive = CrossTransformer( - hidden_dim=8, - act_dim=5, - time_conditioning="additive", - **kwargs, - ) - assert additive.proj_u.weight.shape == (8, 5) - - -def test_energy_score_separates_accuracy_and_diversity(): - target = torch.zeros(2, 3, 5) - samples = torch.stack((target, target + 1, target - 1)) - values = energy_score(samples, target) - assert values["accuracy"] > 0 - assert values["diversity"] > values["accuracy"] - torch.testing.assert_close( - values["score"], values["accuracy"] - 0.5 * values["diversity"] - ) - - -def test_planar_evaluator_uses_natural_batch_metadata_and_nested_results(): - target = torch.zeros(2, 16, 5) - batch = { - "opaque-stream": { - "actions": target, - "embodiment": torch.tensor([19, 19]), - } - } - - class IdentityNormalizer: - @staticmethod - def unnormalize(values, embodiment_id): - assert embodiment_id == 19 - return values - - evaluator = PlanarActionEval(energy_score_enabled=False) - evaluator.bind_data_context(normalizer=IdentityNormalizer()) - evaluator.model = SimpleNamespace( - forward_eval=lambda grouped: { - source: {"pred_action": source_batch["actions"] + 1} - for source, source_batch in grouped.items() - } - ) - logged = {} - evaluator.trainer = SimpleNamespace( - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.update(metrics) - ) - ) - - evaluator.on_validation_step(batch, batch_idx=0) - - assert logged["Valid/MSE"].item() == pytest.approx(1.0) - assert logged["Valid/MSE/pushshapes_sim_u_socket"].item() == pytest.approx(1.0) - - -class _IdentityNormalizer: - @staticmethod - def unnormalize(values, _embodiment_id): - return values - - -def _stochastic_evaluator_fixture(evaluator, target, predictions, logged=None): - def forward_eval(_batch): - prediction = torch.rand_like(target) - predictions.append(prediction.clone()) - return {"opaque-stream": {"pred_action": prediction}} - - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - evaluator.model = SimpleNamespace(forward_eval=forward_eval) - evaluator.trainer = SimpleNamespace( - current_epoch=2, - global_step=17, - global_rank=0, - lightning_module=SimpleNamespace( - log_dict=( - (lambda metrics, **_kwargs: logged.update(metrics)) - if logged is not None - else (lambda *_args, **_kwargs: None) - ) - ), - ) - return { - "opaque-stream": { - "actions": target, - "embodiment": torch.tensor([19]), - } - } - - -def test_planar_evaluator_restores_rng_after_ordinary_prediction(): - target = torch.zeros(1, 2, 5) - evaluator = PlanarActionEval(energy_score_enabled=False) - batch = _stochastic_evaluator_fixture(evaluator, target, []) - torch.manual_seed(8675309) - state = torch.random.get_rng_state().clone() - - evaluator.on_validation_step(batch, batch_idx=0) - - assert torch.equal(torch.random.get_rng_state(), state) - - -def test_planar_evaluator_default_seed_is_wired_and_repeatable(): - target = torch.zeros(1, 2, 5) - predictions = [] - evaluator = PlanarActionEval(energy_score_enabled=False) - batch = _stochastic_evaluator_fixture(evaluator, target, predictions) - - evaluator.on_validation_step(batch, batch_idx=0) - torch.rand(11) - evaluator.on_validation_step(batch, batch_idx=1) - - assert evaluator.deterministic_seed == 420042 - evaluator_config = OmegaConf.load( - Path(__file__).parents[1] - / "egomimic/hydra_configs/evaluator/eval_planar_v2.yaml" - ) - assert evaluator_config.deterministic_seed == evaluator.deterministic_seed - torch.testing.assert_close(predictions[0], predictions[1], rtol=0.0, atol=0.0) - - -def test_planar_evaluator_reuses_first_energy_sample_for_mse(tmp_path): - target = torch.zeros(1, 2, 5) - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs/evaluator" - evaluator = PlanarActionEval( - seed_bank_path=str(config_dir / "energy_score_seed_bank_k32_v1.json"), - seed_bank_sha256=( - "88657b829905d4374823db145ded19b99cec4735f76694734473bcee068bb5b6" - ), - artifact_root=str(tmp_path), - ) - predictions = [] - logged = {} - batch = _stochastic_evaluator_fixture(evaluator, target, predictions, logged) - torch.manual_seed(314159) - state = torch.random.get_rng_state().clone() - - evaluator.on_validation_step(batch, batch_idx=0) - - assert len(predictions) == 32 - assert torch.equal(torch.random.get_rng_state(), state) - torch.testing.assert_close(logged["Valid/MSE"], predictions[0].square().mean()) - artifact = tmp_path / "epoch-2-step-17/rank-0-batch-0.pt" - payload = torch.load(artifact, weights_only=True) - assert payload["deterministic_seed"] == evaluator.seeds[0] == 420042 - - -def test_planar_evaluator_rejects_nonreusable_deterministic_seed(tmp_path): - config_dir = Path(__file__).parents[1] / "egomimic/hydra_configs/evaluator" - with pytest.raises(ValueError, match="must equal the first Energy Score seed"): - PlanarActionEval( - seed_bank_path=str(config_dir / "energy_score_seed_bank_k32_v1.json"), - seed_bank_sha256=( - "88657b829905d4374823db145ded19b99cec4735f76694734473bcee068bb5b6" - ), - artifact_root=str(tmp_path), - deterministic_seed=7, - ) - - -def test_planar_evaluator_wraps_native_rotation_error_across_pi(): - epsilon = 0.01 - - def common_five(theta): - return torch.tensor( - [[[0.0, 0.0, math.cos(theta), math.sin(theta), 0.0]]], - dtype=torch.float64, - ) - - target = common_five(math.pi - epsilon) - prediction = common_five(-math.pi + epsilon) - batch = { - "opaque-stream": { - "actions": target, - "embodiment": torch.tensor([19]), - } - } - - class IdentityNormalizer: - @staticmethod - def unnormalize(values, embodiment_id): - assert embodiment_id == 19 - return values - - evaluator = PlanarActionEval( - energy_score_enabled=False, - native_decoder=PlanarCommon5NativeDecoder( - action_horizon=1, - native_action_dim=3, - ), - ) - evaluator.bind_data_context(normalizer=IdentityNormalizer()) - evaluator.model = SimpleNamespace( - forward_eval=lambda _batch: {"opaque-stream": {"pred_action": prediction}} - ) - logged = {} - evaluator.trainer = SimpleNamespace( - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.update(metrics) - ) - ) - - evaluator.on_validation_step(batch, batch_idx=0) - - expected = (2.0 * epsilon) ** 2 / 3.0 - assert logged["Valid/Native_MSE/pushshapes_sim_u_socket"].item() == pytest.approx( - expected - ) - assert logged["Valid/Native_MSE"].item() == pytest.approx(expected) - - -def test_planar_evaluator_selects_native_decoder_per_embodiment(): - target = torch.zeros(1, 16, 5) - batch = { - "u": {"actions": target, "embodiment": torch.tensor([19])}, - "chain": {"actions": target, "embodiment": torch.tensor([20])}, - } - evaluator = PlanarActionEval( - energy_score_enabled=False, - native_decoders={ - "pushshapes_sim_u_socket": PlanarCommon5NativeDecoder(16, 3), - "pushshapes_sim_chain_gripper": PlanarCommon5NativeDecoder(16, 4), - }, - ) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - evaluator.model = SimpleNamespace( - forward_eval=lambda grouped: { - source: {"pred_action": source_batch["actions"]} - for source, source_batch in grouped.items() - } - ) - logged = {} - evaluator.trainer = SimpleNamespace( - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.update(metrics) - ) - ) - - evaluator.on_validation_step(batch, batch_idx=0) - - assert logged["Valid/Native_MSE/pushshapes_sim_u_socket"] == 0 - assert logged["Valid/Native_MSE/pushshapes_sim_chain_gripper"] == 0 - - -def test_strict_checkpoint_loader_overlays_ema_and_retains_online_buffers(): - class BufferedPolicy(nn.Module): - def __init__(self): - super().__init__() - self.projection = nn.Linear(2, 1) - self.register_buffer("running_scale", torch.tensor(3.0)) - - class Algo: - nets = nn.ModuleDict({"pipeline": BufferedPolicy()}) - - algo = Algo() - expected = OrderedDict( - (key, value.clone()) for key, value in algo.nets.state_dict().items() - ) - online = OrderedDict((f"nets.{key}", value + 1) for key, value in expected.items()) - parameter_keys = set(dict(algo.nets.named_parameters())) - ema = OrderedDict( - (f"nets.{key}", expected[key] + 2) for key in sorted(parameter_keys) - ) - checkpoint = {"state_dict": online, "ema_state_dict": ema} - assert set(extract_pipeline_nets_state(checkpoint)) == set(expected) - strict_load_pipeline_checkpoint(algo, checkpoint, use_ema=True) - for key, value in algo.nets.state_dict().items(): - offset = 2 if key in parameter_keys else 1 - torch.testing.assert_close(value, expected[key] + offset) - with pytest.raises(ValueError): - strict_load_pipeline_checkpoint(algo, {"state_dict": {}}, use_ema=False) - - -def test_strict_checkpoint_loader_rejects_inexact_or_conflicting_ema_keys(): - class Algo: - nets = nn.ModuleDict({"pipeline": nn.Linear(2, 1)}) - - algo = Algo() - expected = algo.nets.state_dict() - online = OrderedDict( - (f"nets.{key}", value.clone()) for key, value in expected.items() - ) - ema = OrderedDict( - (f"nets.{key}", value.clone()) for key, value in algo.nets.named_parameters() - ) - - missing = OrderedDict(ema) - missing.pop(next(iter(missing))) - with pytest.raises(ValueError, match="EMA parameter key mismatch"): - strict_load_pipeline_checkpoint( - algo, - {"state_dict": online, "ema_state_dict": missing}, - use_ema=True, - ) - - extra = OrderedDict(ema) - extra["nets.pipeline.unexpected"] = torch.zeros(1) - with pytest.raises(ValueError, match="EMA parameter key mismatch"): - strict_load_pipeline_checkpoint( - algo, - {"state_dict": online, "ema_state_dict": extra}, - use_ema=True, - ) - - -def test_train_eval_pipeline_load_is_strict(): - from egomimic.pipeline.algo import PipelineAlgo - from egomimic.trainHydra import _load_eval_checkpoint - - class Algo(PipelineAlgo): - def __init__(self): - self.nets = nn.ModuleDict({"pipeline": nn.Linear(2, 1)}) - - algo = Algo() - expected = OrderedDict( - (key, value.clone()) for key, value in algo.nets.state_dict().items() - ) - state_dict = OrderedDict( - (f"nets.{key}", value + 1) for key, value in expected.items() - ) - - class PipelineWrapper: - model = algo - - wrapper = PipelineWrapper() - _load_eval_checkpoint(wrapper, {"state_dict": state_dict}, OmegaConf.create({})) - for key, value in algo.nets.state_dict().items(): - torch.testing.assert_close(value, expected[key] + 1) - - incomplete = OrderedDict(state_dict) - incomplete.pop(next(iter(incomplete))) - with pytest.raises(ValueError, match="Pipeline checkpoint key mismatch"): - _load_eval_checkpoint( - wrapper, - {"state_dict": incomplete}, - OmegaConf.create({}), - ) - - class NonPipelineWrapper: - model = nn.Linear(2, 1) - - with pytest.raises(TypeError, match="must wrap PipelineAlgo"): - _load_eval_checkpoint( - NonPipelineWrapper(), - {"state_dict": state_dict}, - OmegaConf.create({}), - ) - - -def test_eval_mode_removes_training_time_ema_callback(): - from egomimic.trainHydra import _callbacks_for_mode - - ema_callback = EMACallback() - retained_callback = object() - assert _callbacks_for_mode([ema_callback, retained_callback], "eval") == [ - retained_callback - ] - assert _callbacks_for_mode([ema_callback, retained_callback], "train") == [ - ema_callback, - retained_callback, - ] - - -def test_paper_ema_warmup_and_fail_closed_resume(): - callback = EMACallback(decay=0.9999, use_warmup=True, power=0.75) - assert callback._schedule(1) == 0 - assert 0 < callback._schedule(2) < callback._schedule(100) - with pytest.raises(RuntimeError): - callback.on_load_checkpoint(None, None, {"global_step": 3}) diff --git a/tests/test_proportional_batch_sampler.py b/tests/test_proportional_batch_sampler.py deleted file mode 100644 index a0a5f4e6c0..0000000000 --- a/tests/test_proportional_batch_sampler.py +++ /dev/null @@ -1,69 +0,0 @@ -from egomimic.rldb.zarr.proportional_batch_sampler import ( - ProportionalHomogeneousBatchSampler, -) - - -def test_every_window_once_and_homogeneous(): - sampler = ProportionalHomogeneousBatchSampler( - {"yam": 17, "human": 11}, batch_size=4, seed=42 - ) - batches = list(sampler) - assert len(batches) == len(sampler) == 8 - assert sorted(i for batch in batches for i in batch) == list(range(28)) - assert all( - len({sampler.source_for_index(i) for i in batch}) == 1 for batch in batches - ) - assert ( - sum(sampler.source_for_index(i) == "yam" for batch in batches for i in batch) - == 17 - ) - assert ( - sum(sampler.source_for_index(i) == "human" for batch in batches for i in batch) - == 11 - ) - assert sorted(len(batch) for batch in batches) == [1, 3, 4, 4, 4, 4, 4, 4] - - -def test_epoch_replay_and_seed_change(): - sampler = ProportionalHomogeneousBatchSampler( - {"yam": 17, "human": 11}, batch_size=4, seed=42 - ) - first = list(sampler) - second = list(sampler) - assert first != second - sampler.set_epoch(0) - assert list(sampler) == first - - -def test_rejects_invalid_contract(): - for lengths, batch in [ - ({"yam": 2}, 2), - ({"yam": 2, "human": 0}, 2), - ({"yam": 2, "human": 2}, 0), - ]: - try: - ProportionalHomogeneousBatchSampler(lengths, batch) - except ValueError: - pass - else: - raise AssertionError("Invalid mixture was accepted") - - -def test_resume_replays_active_epoch_for_lightning_batch_skip(): - original = ProportionalHomogeneousBatchSampler({"yam": 17, "human": 11}, 4, 42) - iterator = iter(original) - consumed = [next(iterator), next(iterator)] - state = original.state_dict() - resumed = ProportionalHomogeneousBatchSampler({"yam": 17, "human": 11}, 4, 42) - resumed.load_state_dict(state) - replay = iter(resumed) - assert [next(replay), next(replay)] == consumed - assert list(replay) == list(iterator) - try: - ProportionalHomogeneousBatchSampler( - {"yam": 17, "human": 10}, 4, 42 - ).load_state_dict(state) - except ValueError: - pass - else: - raise AssertionError("Changed source lengths were accepted on resume") diff --git a/tests/test_pusht_parent_cleanup.py b/tests/test_pusht_parent_cleanup.py deleted file mode 100644 index f8c67cec79..0000000000 --- a/tests/test_pusht_parent_cleanup.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Keep every PushT recipe's science identical when deduplicating its parents.""" - -import hashlib -import json -from pathlib import Path - -import pytest -from hydra.utils import instantiate -from omegaconf import OmegaConf - -from scripts.audit_hydra_configs import CONFIGS, compose_for_audit - -BASELINE = json.loads( - ( - Path(__file__).parent / "fixtures/pusht_config_before_parent_cleanup.json" - ).read_text() -) - - -@pytest.mark.parametrize( - "parent", - [ - "episode_split", - "action_flow_uc_h16", - "action_flow_points6_uc_h16", - "standard_dp_uc_h16", - ], -) -def test_shared_parents_construct_their_data_boundary(parent): - with compose_for_audit(CONFIGS / "data/pusht/parents" / f"{parent}.yaml") as cfg: - data = instantiate(cfg.data, _recursive_=False) - assert data.preflight_configuration() - - -@pytest.mark.parametrize("recipe,expected", sorted(BASELINE["recipes"].items())) -def test_existing_pusht_recipe_keeps_exact_configuration(recipe, expected): - with compose_for_audit(CONFIGS / "experiment/pusht" / f"{recipe}.yaml") as cfg: - actual = OmegaConf.to_container(cfg, resolve=False) - actual.update(BASELINE.get("parent_identity_metadata", {}).get(recipe, {})) - if recipe == "action_flow_cotrain_uc_multiplier_interpolation": - # Only the adapter selector changes: the strict wrapper delegates the - # same raw-rate graph, and is verified by the multiplier tests. - assert actual["model"]["pipeline"]["_target_"].endswith( - ".build_multiplier_conditioned_pipeline" - ) - actual["model"]["pipeline"]["_target_"] = ( - "egomimic.pipeline.stages_speed.build_speed_conditioned_pipeline" - ) - actual.pop("hydra", None) # A parent's selection path intentionally changed. - encoded = json.dumps(actual, sort_keys=True, separators=(",", ":")).encode() - assert hashlib.sha256(encoded).hexdigest() == expected - - -def test_retired_parent_paths_have_no_selectable_config_references(): - for group, retired in ( - ("data", BASELINE["retired_data"]), - ("experiment", BASELINE["retired_recipes"]), - ): - for old in retired: - assert not (CONFIGS / group / "pusht" / f"{old}.yaml").exists() - for path in CONFIGS.rglob("*.yaml"): - assert f"pusht/{old}" not in path.read_text() - assert not ( - CONFIGS / "data/pusht/planar_usocket_chain_manual4919_standard_retimed.yaml" - ).exists() - - -@pytest.mark.parametrize( - "recipe", - [ - "planar_uc_manual4919_dp_261m_af_obs_multiplier", - "action_flow_cotrain_uc_multiplier_interpolation", - ], -) -def test_selected_pair_still_has_2999_and_4919_with_two_full_batches(recipe): - with compose_for_audit(CONFIGS / "experiment/pusht" / f"{recipe}.yaml") as cfg: - for group in ("train_datasets", "valid_datasets"): - datasets = cfg.data[group] - assert ( - datasets.pushshapes_sim_u_socket.resolver.expected_episode_count == 2999 - ) - assert ( - datasets.pushshapes_sim_chain_gripper.resolver.expected_episode_count - == 4919 - ) - assert all( - d.valid_ratio == 0.01 and d.split_seed == 42 for d in datasets.values() - ) - assert all( - d.batch_size == 32 for d in cfg.data.train_dataloader_params.values() - ) diff --git a/tests/test_rank_synthetic_trajectory_nn.py b/tests/test_rank_synthetic_trajectory_nn.py deleted file mode 100644 index f22602274e..0000000000 --- a/tests/test_rank_synthetic_trajectory_nn.py +++ /dev/null @@ -1,207 +0,0 @@ -import hashlib -import json -import subprocess -import sys -from pathlib import Path - -import numpy as np - -SCRIPT = ( - Path(__file__).parents[1] - / "scripts" - / "experiments" - / "rank_synthetic_trajectory_nn.py" -) - - -def _matrix(tmp_path, *, variants=("a", "b"), seeds=(42, 43, 44), particles=16): - rng = np.random.default_rng(7) - targets = rng.normal(size=(particles, 3)).astype(np.float32) - dataset = tmp_path / "eval.npz" - np.savez_compressed( - dataset, - split=np.ones(particles, dtype=np.int64), - target_3d=targets, - ) - config_dir = tmp_path / "config" - config_dir.mkdir() - trajectory_root = tmp_path / "trajectories" - trajectory_root.mkdir() - configs = [] - trajectories = {} - for variant in variants: - for seed in seeds: - run_id = f"run-{variant}-{seed}" - output = tmp_path / "runs" / run_id - (output / "checkpoints").mkdir(parents=True) - (output / "summary.json").write_text("{}\n") - checkpoint = ( - output / "checkpoints" / "epoch-equivalent-000001-global-step-000010.pt" - ) - checkpoint.write_bytes(f"checkpoint-{variant}-{seed}".encode()) - config = { - "variant": variant, - "seed": seed, - "evaluation_dataset": str(dataset), - "output_dir": str(output), - "max_steps": 10, - "wandb": {"id": run_id}, - } - config_path = config_dir / f"{run_id}.json" - config_path.write_text(json.dumps(config)) - configs.append(str(config_path)) - final = targets if variant == "a" else targets + 5.0 - points = np.stack([np.zeros_like(targets), final]) - trajectory = trajectory_root / f"{run_id}.npz" - np.savez_compressed( - trajectory, - times=np.array([0.0, 1.0], dtype=np.float32), - points=points, - target=targets, - ) - trajectories[run_id] = trajectory - manifest = { - "schema_version": 1, - "variants": list(variants), - "seeds": list(seeds), - "datasets": { - "evaluation": { - "path": str(dataset), - "sha256": hashlib.sha256(dataset.read_bytes()).hexdigest(), - } - }, - "configs": configs, - } - manifest_path = config_dir / "manifest.json" - manifest_path.write_text(json.dumps(manifest)) - return manifest_path, dataset, trajectory_root, trajectories, targets - - -def _command(manifest, output_json, output_markdown, particles): - return [ - sys.executable, - str(SCRIPT), - "--manifest", - str(manifest), - "--expected-particles", - str(particles), - "--distance-chunk-size", - "5", - "--output-json", - str(output_json), - "--output-markdown", - str(output_markdown), - ] - - -def test_ranks_complete_matrix_and_records_exact_provenance(tmp_path): - manifest, dataset, root, trajectories, _ = _matrix(tmp_path) - output_json = tmp_path / "ranking.json" - output_markdown = tmp_path / "ranking.md" - subprocess.run( - _command(manifest, output_json, output_markdown, 16) - + ["--trajectory-root", str(root)], - check=True, - ) - - result = json.loads(output_json.read_text()) - assert [row["variant"] for row in result["ranking"]] == ["a", "b"] - assert result["ranking"][0]["mean"] == 0.0 - assert result["ranking"][0]["sample_std"] == 0.0 - assert [row["seed"] for row in result["ranking"][0]["per_seed"]] == [ - 42, - 43, - 44, - ] - assert result["comparison"]["particles"] == 16 - assert ( - result["comparison"]["dataset"]["sha256"] - == hashlib.sha256(dataset.read_bytes()).hexdigest() - ) - run = next(row for row in result["runs"] if row["run_id"] == "run-a-42") - assert ( - run["trajectory"]["sha256"] - == hashlib.sha256(trajectories["run-a-42"].read_bytes()).hexdigest() - ) - assert run["checkpoint"]["global_step"] == 10 - assert len(run["checkpoint"]["sha256"]) == 64 - markdown = output_markdown.read_text() - assert "Symmetric NN MSE" in markdown - assert "42: 0" in markdown - - -def test_accepts_only_a_complete_set_of_exact_npz_paths(tmp_path): - manifest, _, _, trajectories, _ = _matrix( - tmp_path, variants=("a",), seeds=(42, 43), particles=8 - ) - output_json = tmp_path / "ranking.json" - output_markdown = tmp_path / "ranking.md" - explicit = [] - for run_id, path in trajectories.items(): - explicit.extend(["--trajectory-npz", f"{run_id}={path}"]) - subprocess.run( - _command(manifest, output_json, output_markdown, 8) + explicit, - check=True, - ) - assert len(json.loads(output_json.read_text())["runs"]) == 2 - - incomplete = subprocess.run( - _command(manifest, output_json, output_markdown, 8) - + ["--trajectory-npz", f"run-a-42={trajectories['run-a-42']}"], - text=True, - capture_output=True, - ) - assert incomplete.returncode != 0 - assert "explicit trajectory set does not match complete matrix" in incomplete.stderr - - -def test_rejects_target_or_time_grid_mismatch(tmp_path): - manifest, _, root, trajectories, targets = _matrix( - tmp_path, variants=("a",), seeds=(42, 43), particles=8 - ) - mismatched = trajectories["run-a-43"] - np.savez_compressed( - mismatched, - times=np.array([0.0, 0.4, 1.0], dtype=np.float32), - points=np.stack([targets, targets, targets]), - target=targets, - ) - result = subprocess.run( - _command(manifest, tmp_path / "ranking.json", tmp_path / "ranking.md", 8) - + ["--trajectory-root", str(root)], - text=True, - capture_output=True, - ) - assert result.returncode != 0 - assert "time grid differs" in result.stderr - - np.savez_compressed( - mismatched, - times=np.array([0.0, 1.0], dtype=np.float32), - points=np.stack([targets, targets]), - target=targets + 1.0, - ) - result = subprocess.run( - _command(manifest, tmp_path / "ranking.json", tmp_path / "ranking.md", 8) - + ["--trajectory-root", str(root)], - text=True, - capture_output=True, - ) - assert result.returncode != 0 - assert "does not exactly match evaluation dataset" in result.stderr - - -def test_rejects_run_without_terminal_completion_artifacts(tmp_path): - manifest, _, root, _, _ = _matrix( - tmp_path, variants=("a",), seeds=(42,), particles=8 - ) - config = json.loads(next((tmp_path / "config").glob("run-*.json")).read_text()) - (Path(config["output_dir"]) / "summary.json").unlink() - result = subprocess.run( - _command(manifest, tmp_path / "ranking.json", tmp_path / "ranking.md", 8) - + ["--trajectory-root", str(root)], - text=True, - capture_output=True, - ) - assert result.returncode != 0 - assert "run is incomplete because summary.json is missing" in result.stderr diff --git a/tests/test_ravens_contract.py b/tests/test_ravens_contract.py deleted file mode 100644 index dd9ec13cb4..0000000000 --- a/tests/test_ravens_contract.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Guard action/observation alignment and reuse of the selected LIBERO models.""" - -from pathlib import Path - -import hydra -import numpy as np -import pytest -from omegaconf import OmegaConf - -from egomimic.benchmarks.ravens.continuous import ( - defer_dependency, - validate_suction_transitions, -) - - -def test_integrated_embodiment_ids_are_distinct_and_replay_keeps_ravens(tmp_path): - import json - - from egomimic.benchmarks.ravens.replay import ( - ACTION_CONTRACT, - RavensReplayResolver, - ) - from egomimic.rldb.embodiment.embodiment import EMBODIMENT, get_embodiment_id - from egomimic.rldb.zarr.libero_dataset import LiberoReplayResolver - - assert get_embodiment_id("libero_panda") == 21 - assert get_embodiment_id("libero_panda_sharpa") == 22 - assert get_embodiment_id("ravens_ur5") == 23 - assert len({member.value for member in EMBODIMENT}) == len(EMBODIMENT.__members__) - (tmp_path / "benchmark_manifest.json").write_text( - json.dumps( - { - "complete": True, - "action_contract": ACTION_CONTRACT, - "task": {"module": "fixture", "class": "Task"}, - } - ) - ) - resolver = RavensReplayResolver(tmp_path, {}, "fixture", "Task") - assert resolver.embodiment == "ravens_ur5" - assert resolver.embodiment_id == 23 - resolver.validate_task_ids(np.array([0])) - with pytest.raises(ValueError, match="fixed task ID"): - resolver.validate_task_ids(np.array([1])) - assert LiberoReplayResolver(tmp_path, {}, "spatial").embodiment_id == 21 - - -@pytest.mark.parametrize("corruption", [None, "checksum", "dataset", "metadata"]) -def test_saved_native_normalization_is_exact_and_fails_closed(tmp_path, corruption): - torch = pytest.importorskip("torch") - pytest.importorskip( - "pandas" - ) # Full data runtime: exercised in the scheduled integration gate. - import hashlib - import json - from types import SimpleNamespace - - from egomimic.benchmarks.ravens.replay import ( - ACTION_CONTRACT, - SUITE, - RavensNormalizer, - ) - from egomimic.rldb.embodiment.embodiment import get_embodiment_id - - emb = get_embodiment_id("ravens_ur5") - state = { - "norm_mode": "oat_limits", - "embodiments": [emb], - "key_types": {emb: {"actions": "action_keys"}}, - "zarr_keys": {emb: {"actions": "action"}}, - "shapes": {emb: {"actions": (32, 7)}}, - "norm_stats": { - emb: { - "actions": { - key: np.full(7, value, np.float32) - for key, value in [ - ("min", 0), - ("max", 1), - ("scale", 2), - ("offset", -1), - ] - } - } - }, - "benchmark_context": { - "suite": SUITE, - "split_seed": 42, - "val_ratio": 0.1, - "embodiment": "ravens_ur5", - "action_contract": ACTION_CONTRACT, - }, - } - dataset = tmp_path / "stage" / "replay.zarr" - dataset.mkdir(parents=True) - (dataset / "benchmark_manifest.json").write_text('{"fixture":true}') - (dataset.parent / ".stage-receipt.json").write_text( - json.dumps({"complete": True, "sha256": "a" * 64}) - ) - names = [f"episode_{i:06d}" for i in range(500)] - split = { - "train": names[:450], - "valid": names[450:], - "seed": 42, - "dataset_manifest_sha256": hashlib.sha256( - (dataset / "benchmark_manifest.json").read_bytes() - ).hexdigest(), - } - split_path = tmp_path / "split.json" - split_path.write_text(json.dumps(split)) - checkpoint = tmp_path / "fixture.ckpt" - torch.save( - {"state_dict": {"weight": torch.ones(1)}, "normalizer_state": state}, checkpoint - ) - saved = tmp_path / "normalization.pt" - receipt = RavensNormalizer.export_saved_state( - checkpoint, dataset, split_path, saved - ) - normalizer = RavensNormalizer( - state=state, saved_state_path=saved, saved_state_sha256=receipt["sha256"] - ) - selected = SimpleNamespace( - resolver=SimpleNamespace(folder_path=dataset), - datasets=dict.fromkeys(names[:450]), - split_seed=42, - val_ratio=0.1, - ) - if corruption == "checksum": - normalizer.saved_state_sha256 = "b" * 64 - elif corruption == "dataset": - (dataset / "benchmark_manifest.json").write_text('{"fixture":false}') - elif corruption == "metadata": - normalizer.shapes[emb]["actions"] = (100, 7) - if corruption: - with pytest.raises(ValueError, match="mismatch"): - normalizer.infer_norm_from_dataset(selected, "ravens_ur5") - else: - normalizer.infer_norm_from_dataset(selected, "ravens_ur5") - actual = normalizer.to_state() - assert actual["benchmark_context"] == state["benchmark_context"] - for key, value in state["norm_stats"][emb]["actions"].items(): - assert ( - actual["norm_stats"][emb]["actions"][key].tobytes() == value.tobytes() - ) - - -def test_staged_mount_paths_do_not_change_scientific_identity(tmp_path): - from egomimic.benchmarks.ravens.training import compose - - first, _, first_hash = compose( - "dp", tmp_path / "node-one", tmp_path / "smoke", "smoke" - ) - second, _, second_hash = compose( - "dp", tmp_path / "node-two", tmp_path / "full", "full" - ) - assert first_hash == second_hash - assert first.benchmark.dataset != second.benchmark.dataset - - -def test_optional_dependency_loads_actual_module_only_when_used(tmp_path, monkeypatch): - import importlib - import sys - - name = "ravens_lazy_dependency_fixture" - (tmp_path / f"{name}.py").write_text("VALUE = 42\n") - monkeypatch.syspath_prepend(str(tmp_path)) - proxy = defer_dependency(name) - try: - assert "VALUE" not in vars(proxy) - assert importlib.import_module(name) is proxy - assert proxy.VALUE == 42 - loaded = sys.modules[name] - assert loaded is not proxy - assert proxy.VALUE == loaded.VALUE - assert sys.modules[name] is loaded - finally: - sys.modules.pop(name, None) - - -def test_suction_recording_requires_decisions_before_state_changes(): - states = np.array([[0.0, 0.0], [1.0, 0.0]], np.float32) - actions = np.zeros((2, 7), np.float32) - actions[:, 6] = [1.0, -1.0] - validate_suction_transitions(states, actions) - leaked_states = np.array([[1.0, 0.0], [0.0, 0.0]], np.float32) - with pytest.raises(ValueError, match="pre-action suction"): - validate_suction_transitions(leaked_states, actions) - - -@pytest.mark.parametrize("family", ["action_flow", "dp"]) -def test_ravens_reuses_libero_model_and_changes_benchmark_contract(family): - source = Path(__file__).resolve().parents[1] - with hydra.initialize_config_dir( - version_base="1.2", config_dir=str(source / "egomimic/hydra_configs") - ): - common = [ - "hydra/launcher=basic", - "benchmark.dataset=/UNBOUND", - "paths.output_dir=/UNBOUND", - f"paths.work_dir={source}", - ] - reference_group = "libero_historical" if family == "dp" else "libero" - original = hydra.compose( - config_name="train_zarr_cartesian", - overrides=[ - *common, - f"+experiment={reference_group}/{family}_libero_spatial_oat_dp_matched_s42", - ], - ) - ravens = hydra.compose( - config_name="train_zarr_cartesian", - overrides=[ - *common, - f"+experiment=ravens/{family}", - "evaluator.source_commit=UNBOUND", - "evaluator.config_sha256=UNBOUND", - ], - ) - model = OmegaConf.to_container(ravens.model, resolve=True) - reference = OmegaConf.to_container(original.model, resolve=True) - # Protocol suite is a benchmark identity, not a model architecture change. - for value in (model, reference): - value.pop("benchmark_protocol", None) - assert model == reference - assert ravens.data._target_ == original.data._target_ - assert list(ravens.data.train_datasets) == ["ravens_ur5"] - assert list(ravens.data.valid_datasets) == ["ravens_ur5"] - assert ravens.data.train_datasets.ravens_ur5.split_seed == 42 - assert ravens.data.train_datasets.ravens_ur5.valid_ratio == 0.1 - assert ravens.benchmark.horizon == 32 - assert ravens.benchmark.n_obs_steps == 2 - assert ravens.benchmark.n_action_steps == 16 - assert ravens.callbacks.model_checkpoint.save_top_k == -1 - assert ravens.callbacks.model_checkpoint.every_n_train_steps == 15000 - assert ravens.trainer.val_check_interval == 15000 - assert ravens.trainer.accumulate_grad_batches == 1 - assert ravens.callbacks.batch_budget.global_batch_size == 32 diff --git a/tests/test_ravens_rollout.py b/tests/test_ravens_rollout.py deleted file mode 100644 index 2aaadfb124..0000000000 --- a/tests/test_ravens_rollout.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Rollout ABI transport and embodiment routing; no simulator needed.""" - -import hashlib -import importlib.util -import json -import sys -import tempfile -import threading -import time -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest - -path = Path(__file__).parents[1] / "egomimic/benchmarks/ravens/rollout.py" -spec = importlib.util.spec_from_file_location("ravens_rollout", path) -module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(module) - - -def test_server_close_after_listener_unlinks_socket(monkeypatch): - from multiprocessing.connection import Client - - # Exercise the real server lifecycle without loading a model or using CUDA. - monkeypatch.setitem(sys.modules, "torch", SimpleNamespace()) - monkeypatch.setattr(module, "validate_request", lambda request: None) - nets = SimpleNamespace( - parameters=lambda: iter([SimpleNamespace(dtype="torch.float32")]) - ) - monkeypatch.setattr( - module, - "load_policy", - lambda request: (SimpleNamespace(algo=SimpleNamespace(nets=nets)), {}), - ) - errors = [] - # Short socket paths work within the Unix address limit on Linux and macOS. - with tempfile.TemporaryDirectory(prefix="rv-", dir="/tmp") as directory: - path = Path(directory) - request = {"socket": str(path / "p.sock"), "checkpoint_step": 60000} - - def target(): - try: - module.serve(request, path) - except BaseException as error: - errors.append(error) - - thread = threading.Thread(target=target, daemon=True) - thread.start() - deadline = time.monotonic() + 5 - while not (path / "MODEL_READY.json").exists() and not errors: - assert time.monotonic() < deadline - time.sleep(0.01) - assert not errors - with Client(request["socket"], family="AF_UNIX") as connection: - module.send(connection, {"command": "close"}) - assert module.receive(connection) == {"status": "closed"} - thread.join(timeout=5) - assert not thread.is_alive() - assert not errors - assert not Path(request["socket"]).exists() - - -def training_scene_request(tmp_path): - manifest = { - "complete": True, - "episodes": [ - {"seed": i * 2, "sha256": "a" * 64, "frames": 710, "reward": 1.0} - for i in range(500) - ], - } - raw = json.dumps(manifest).encode() - dataset_sha = hashlib.sha256(raw).hexdigest() - (tmp_path / "dataset.json").write_bytes(raw) - split = { - "train": [f"episode_{i:06d}" for i in range(450)], - "valid": [f"episode_{i:06d}" for i in range(450, 500)], - "seed": 42, - "dataset_manifest_sha256": dataset_sha, - } - raw_split = json.dumps(split).encode() - (tmp_path / "split.json").write_bytes(raw_split) - return { - "evaluation_split": "train", - "seeds": [0], - "collection_seeds": list(range(0, 1000, 2)), - "dataset_manifest": str(tmp_path / "dataset.json"), - "dataset_manifest_sha256": dataset_sha, - "split_manifest": str(tmp_path / "split.json"), - "split_manifest_sha256": hashlib.sha256(raw_split).hexdigest(), - } - - -def test_training_scene_requires_actual_train_membership(tmp_path): - request = training_scene_request(tmp_path) - selected = module.validate_scene_selection(request) - assert selected[0]["episode_id"] == "episode_000000" - assert selected[0]["evaluation_split"] == "train" - request["seeds"] = [900] - with pytest.raises(ValueError, match="validation, not training"): - module.validate_scene_selection(request) - request["seeds"] = [20000] - with pytest.raises(ValueError, match="not recorded"): - module.validate_scene_selection(request) - - -def test_unseen_default_keeps_collection_exclusion(tmp_path): - request = training_scene_request(tmp_path) - del request["evaluation_split"] - with pytest.raises(ValueError, match="overlap"): - module.validate_scene_selection(request) - request["seeds"] = [20000] - assert module.validate_scene_selection(request)[20000] == { - "evaluation_split": "unseen" - } - - -def test_training_scene_rejects_changed_split_and_mapping(tmp_path): - request = training_scene_request(tmp_path) - request["collection_seeds"] = list(reversed(request["collection_seeds"])) - with pytest.raises(ValueError, match="mapping mismatch"): - module.validate_scene_selection(request) - request = training_scene_request(tmp_path) - Path(request["split_manifest"]).write_text("{}") - with pytest.raises(ValueError, match="checksum mismatch"): - module.validate_scene_selection(request) - - -def test_simulator_import_does_not_import_torch(): - # This file itself is importable in the simulation-only Python3.10 ABI. - assert "torch" not in module.__dict__ - - -def test_transport_roundtrip(): - values = { - "camera": np.arange(36, dtype=np.uint8).reshape(3, 4, 3), - "state": np.array([1, 2, 3], np.float32), - } - decoded = module.unpack(module.pack(values)) - for key in values: - assert values[key].dtype == decoded[key].dtype - np.testing.assert_array_equal(values[key], decoded[key]) - - -def test_transport_rejects_wrong_shape_and_dtype(): - payload = module.pack({"state": np.zeros(3, np.float32)}) - payload["state"]["shape"] = [4] - with pytest.raises(ValueError, match="shape/byte"): - module.unpack(payload) - with pytest.raises(ValueError, match="only uint8"): - module.pack({"state": np.zeros(3, np.float64)}) - - -def test_policy_routing_is_configurable(): - import torch - - from egomimic.benchmarks.libero.rollout import GraphPolicy - from egomimic.rldb.zarr.libero_dataset import OBS_KEYS - - class Norm: - def normalize(self, batch, embodiment): - self.embodiment = embodiment - return batch - - def unnormalize(self, batch, embodiment): - assert embodiment == self.embodiment - return batch - - class Algo: - nets = torch.nn.Identity() - - def process_batch_for_training(self, batch): - assert list(batch) == ["ravens_ur5"] - return batch - - def forward_eval(self, batch): - return {"ravens_ur5": {"pred_action": torch.zeros(1, 32, 7)}} - - policy = GraphPolicy(Algo(), Norm(), embodiment="ravens_ur5") - obs = { - key: np.zeros((128, 128, 3), np.uint8) - if key.endswith("rgb") - else np.zeros(1, np.float32) - for key in OBS_KEYS - } - policy.reset(obs) - assert policy.predict().shape == (16, 7) - - -def test_protocol_from_actual_af_model_only_checkpoint(): - from omegaconf import OmegaConf - - saved = OmegaConf.create({"model": {"action_horizon": 32, "action_dim": 7}}) - trained = OmegaConf.create( - { - "model": {"action_horizon": 32, "action_dim": 7}, - "benchmark": { - "suite": "ravens-continuous-v1", - "horizon": 32, - "n_obs_steps": 2, - "n_action_steps": 16, - }, - } - ) - assert module.resolve_protocol(saved, trained)["horizon"] == 32 - trained.model.action_horizon = 100 - with pytest.raises(ValueError, match="differs from checkpoint"): - module.resolve_protocol(saved, trained) - - -def test_protocol_rejects_wrong_trained_history(): - from omegaconf import OmegaConf - - saved = OmegaConf.create({"model": {"action_dim": 7}}) - trained = OmegaConf.create( - { - "model": {"action_dim": 7}, - "benchmark": { - "suite": "ravens-continuous-v1", - "horizon": 32, - "n_obs_steps": 1, - "n_action_steps": 16, - }, - } - ) - with pytest.raises(ValueError, match="trained action/history"): - module.resolve_protocol(saved, trained) - - -def test_overlay_frames_complete_native_horizon(): - assert module.overlay_frames(32, 16) == [0] - frames = module.overlay_frames(710, 16) - assert frames[0] == 0 and frames[-1] == 678 - assert len(frames) == len(set(frames)) == 44 - assert all(i + 32 <= 710 for i in frames) - with pytest.raises(ValueError): - module.overlay_frames(31, 16) - with pytest.raises(ValueError): - module.overlay_frames(710, 0) - - -def test_overlay_center_crop_replaces_random_paths_without_weights(): - import robomimic.models.base_nets as rmbn - import torch - - nets = torch.nn.ModuleDict( - { - "front": rmbn.CropRandomizer([3, 128, 128], 76, 76, 1, False), - "side": rmbn.CropRandomizer([3, 128, 128], 76, 76, 1, False), - "weights": torch.nn.Linear(2, 2), - } - ) - before = {name: value.detach().clone() for name, value in nets.state_dict().items()} - receipt = module.center_crop_for_overlay(nets) - assert receipt["mode"] == "fixed_center" - assert receipt["replaced_random_modules"] == 2 - assert len(receipt["modules"]) == 2 - assert all( - torch.equal(value, nets.state_dict()[name]) for name, value in before.items() - ) - assert module.center_crop_for_overlay(nets)["replaced_random_modules"] == 0 diff --git a/tests/test_recursive_config_audit.py b/tests/test_recursive_config_audit.py deleted file mode 100644 index bd5693fd5c..0000000000 --- a/tests/test_recursive_config_audit.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Every new YAML is automatically included in the offline composition gate.""" - -from hydra.core.hydra_config import HydraConfig - -from scripts.audit_components import audit_components -from scripts.audit_hydra_configs import CONFIGS, audit - - -def test_ravens_audit_supplies_context_without_relaxing_production_identity(): - from omegaconf import OmegaConf - - from scripts.audit_hydra_configs import compose_for_audit - - path = CONFIGS / "evaluator/ravens.yaml" - production = OmegaConf.load(path) - assert OmegaConf.is_missing(production, "source_commit") - assert OmegaConf.is_missing(production, "config_sha256") - for relative in ( - "data/ravens/continuous.yaml", - "evaluator/ravens.yaml", - "experiment/ravens/action_flow.yaml", - "experiment/ravens/dp.yaml", - ): - with compose_for_audit(CONFIGS / relative) as cfg: - assert cfg.benchmark.suite == "ravens-continuous-v1" - assert cfg.evaluator.source_commit == "0" * 40 - assert cfg.evaluator.config_sha256 == "0" * 64 - assert cfg.benchmark.dataset == "/tmp/audit/libero" - assert OmegaConf.is_missing(OmegaConf.load(path), "source_commit") - - -def test_diagnostic_constructor_fixture_preserves_production_seed_binding(): - import hashlib - from pathlib import Path - - from omegaconf import OmegaConf - - from scripts.audit_constructor_inputs import evaluator_inputs - - config = OmegaConf.create( - { - "_target_": "egomimic.eval.planar_action_eval.PlanarActionEval", - "action_flow_diagnostics": { - "noise_seed_bank_path": "/production/diagnostic-seeds.json", - "noise_seed_bank_sha256": "a" * 64, - }, - } - ) - original = OmegaConf.to_container(config, resolve=True) - inputs, evidence = evaluator_inputs(config) - diagnostics = inputs["action_flow_diagnostics"] - assert OmegaConf.to_container(config, resolve=True) == original - assert ( - hashlib.sha256( - Path(diagnostics["noise_seed_bank_path"]).read_bytes() - ).hexdigest() - == diagnostics["noise_seed_bank_sha256"] - ) - assert "UNVERIFIED" in evidence["external_binding"] - - -def test_every_shipped_yaml_composes_and_resolves(): - previous = HydraConfig.instance().cfg - records = audit() - assert HydraConfig.instance().cfg is previous - paths = {str(path.relative_to(CONFIGS)) for path in CONFIGS.rglob("*.yaml")} - assert {record["path"] for record in records} == paths - failures = [record for record in records if record["status"] != "passed"] - assert not failures, failures - - -def test_every_shipped_component_constructs_without_network_or_weights(): - previous = HydraConfig.instance().cfg - records = audit_components() - assert HydraConfig.instance().cfg is previous - assert {record["path"] for record in records} == { - str(path.relative_to(CONFIGS)) for path in CONFIGS.rglob("*.yaml") - } - failures = [record for record in records if record["status"] != "passed"] - assert not failures, failures - - -def test_scale_filter_defers_cloud_access_and_preserves_resolution_memo(monkeypatch): - import pandas as pd - - from egomimic.rldb.filters import ScaleAnnotationDatasetFilter - from egomimic.rldb.resolve_memo import resolve_once - from egomimic.utils import scale_utils - - monkeypatch.delenv("SCALE_API_KEY", raising=False) - first = ScaleAnnotationDatasetFilter("fixture") - assert first._tasks is None - calls = [] - monkeypatch.setenv("SCALE_API_KEY", "fixture-only") - monkeypatch.setattr( - scale_utils, - "get_completed_tasks", - lambda name, key: calls.append((name, key)) or ["keep"], - ) - monkeypatch.setattr( - scale_utils, - "build_df_from_tasks", - lambda tasks: pd.DataFrame({"SEQUENCE_ID": tasks}), - ) - with resolve_once(): - second = ScaleAnnotationDatasetFilter("fixture") - assert first.matches({"episode_hash": "keep"}) - assert second.cache_key() == first.cache_key() - assert not second.matches({"episode_hash": "missing"}) - assert not second.matches({"episode_hash": "keep", "is_deleted": True}) - assert calls == [("fixture", "fixture-only")] - with resolve_once(): - assert ScaleAnnotationDatasetFilter("fixture").matches({"episode_hash": "keep"}) - assert len(calls) == 2 diff --git a/tests/test_retained_hpt_variants.py b/tests/test_retained_hpt_variants.py deleted file mode 100644 index 8b1d457763..0000000000 --- a/tests/test_retained_hpt_variants.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Retained multi-head, keypoint and interpolated-target behavior gates.""" - -from copy import deepcopy -from types import SimpleNamespace - -import numpy as np -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import OmegaConf, open_dict - -from egomimic.pipeline.inference_config import ( - build_inference_config, - validate_model_data_context, -) -from egomimic.pipeline.sequence_decoder import UniformTimeGridDecoder -from tests.fixtures.synthetic_episodes import write_episode -from tests.test_retained_recipe_steps import CONFIGS, small_cpu_model - - -@pytest.mark.parametrize( - "name", - [ - "hpt_cotrain_flow_shared_head", - "hpt_cotrain_flow_seperate_head", - "hpt_cotrain_enc_dec_base", - ], -) -def test_both_domains_train_and_decode(name): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - config = compose( - config_name="train_zarr_cartesian", overrides=[f"model={name}"] - ) - cpu = small_cpu_model(config, "hpt") - - def reduce_blocks(node): - if OmegaConf.is_dict(node): - for key in ("nblocks", "num_layers"): - if key in node: - node[key] = 1 - if "num_inference_steps" in node: - node.num_inference_steps = 2 - for value in node.values(): - reduce_blocks(value) - elif OmegaConf.is_list(node): - for value in node: - reduce_blocks(value) - - reduce_blocks(cpu.model.pipeline) - graph = instantiate(cpu.model.pipeline, device="cpu") - optimizer = torch.optim.AdamW(graph.nets.parameters(), lr=1e-4) - for embodiment, width, domain in [ - (6, 14, "eva_bimanual"), - (3, 12, "human_bimanual"), - ]: - batch = { - "actions_cartesian": torch.randn(2, 100, width), - "observations.state.ee_pose": torch.randn(2, width), - "embodiment": torch.tensor([embodiment, embodiment]), - } - for key in ("front_img_1", "left_wrist_img", "right_wrist_img"): - batch[f"observations.images.{key}"] = torch.rand(2, 3, 32, 32) - for _ in range(2): - optimizer.zero_grad() - predictions = graph.forward_training({domain: batch}) - loss = graph.compute_losses(predictions, {domain: batch})["loss"] - assert torch.isfinite(loss) - loss.backward() - assert graph.pipeline.stage_by_id("trunk").action_token.grad is not None - optimizer.step() - graph.nets.eval() - result = graph.forward_eval({domain: batch})[domain]["pred_action"] - assert result.shape == (2, 100, width) - assert torch.isfinite(result).all() - config.model.deployment_domain = domain - artifact = build_inference_config(config) - assert artifact["status"] == "ready" - - -@pytest.mark.parametrize("wrist", [False, True]) -def test_keypoints_real_transforms_and_overlay(wrist, tmp_path): - name = "train_zarr_keypoint_wrist" if wrist else "train_zarr_keypoints" - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose(config_name=name) - for i in range(3): - write_episode(tmp_path, "aria", T=8, H=32, W=32, seed=i) - ds = cfg.data.train_datasets.human_bimanual - with open_dict(ds): - ds.resolver._target_ = ( - "egomimic.rldb.zarr.zarr_dataset_multi.LocalEpisodeResolver" - ) - ds.resolver.folder_path = str(tmp_path) - ds.filters = None - ds.bounds_check = False - with open_dict(cfg.data): - cfg.data.valid_datasets = {"human_bimanual": deepcopy(ds)} - cfg.data.train_dataloader_params = { - "human_bimanual": {"batch_size": 2, "num_workers": 0} - } - cfg.data.valid_dataloader_params = { - "human_bimanual": {"batch_size": 2, "num_workers": 0} - } - dm = instantiate(cfg.data, _recursive_=False) - context = dm.prepare_context(mode="train", normalization={"num_workers": 0}) - validate_model_data_context(cfg, context) - mismatched = deepcopy(cfg) - mismatched.model.data_requirements.preprocessing["3"].transforms.coord_frame = ( - "camframe" if wrist else "eef_frame" - ) - with pytest.raises(ValueError, match="coord_frame"): - validate_model_data_context(mismatched, context) - evaluator = instantiate(cfg.evaluator) - dm.configure_evaluation(evaluator.data_requirements()) - graph = instantiate(small_cpu_model(cfg, "hpt").model.pipeline, device="cpu") - context.bind(graph, evaluator) - evaluator.model = graph - evaluator.trainer = SimpleNamespace( - current_epoch=0, - default_root_dir=str(tmp_path), - is_global_zero=True, - lightning_module=SimpleNamespace(log_dict=lambda *a, **k: None), - ) - evaluator.on_validation_start() - batch = graph.process_batch_for_training(next(iter(dm.val_dataloader()))[0]) - source = next(iter(batch.values())) - assert source["actions_keypoints"].shape == (2, 100, 138) - loss = graph.compute_losses(graph.forward_training(batch), batch)["loss"] - loss.backward() - assert torch.isfinite(loss) - graph.nets.eval() - metrics = evaluator.on_validation_step(batch, 0) - assert "Valid/human_bimanual_actions_keypoints_paired_mse_avg" in metrics - assert ( - evaluator._frame_records - ), "The retained MANO overlay must actually produce frames" - - -def test_time_grid_preserves_duration_and_angle_wrap(): - decoder = UniformTimeGridDecoder([100, 2], 44 / (99 * 30), 45, 1 / 30, [1]) - native = np.stack([np.linspace(0, 44, 100), np.linspace(3.0, 3.4, 100)], axis=-1)[ - None - ] - native[..., 1] = (native[..., 1] + np.pi) % (2 * np.pi) - np.pi - output = decoder(native) - np.testing.assert_allclose(output[0, :, 0], np.arange(45), atol=1e-10) - np.testing.assert_allclose( - np.unwrap(output[0, :, 1]), np.linspace(3.0, 3.4, 45), atol=1e-10 - ) - with pytest.raises(ValueError, match="exceeds"): - UniformTimeGridDecoder([100, 2], 44 / (99 * 30), 100, 1 / 30) diff --git a/tests/test_retained_language.py b/tests/test_retained_language.py deleted file mode 100644 index 41e7831c04..0000000000 --- a/tests/test_retained_language.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Exercise real language stems/data/overlays with only the HF backend replaced.""" - -from copy import deepcopy -from types import SimpleNamespace - -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import open_dict -from transformers import AutoModel, AutoTokenizer, BatchEncoding - -from tests.fixtures.synthetic_episodes import write_episode -from tests.test_retained_recipe_steps import CONFIGS, small_cpu_model - - -class TextBackend(torch.nn.Module): - config = SimpleNamespace(hidden_size=32) - - def __init__(self): - super().__init__() - self.embedding = torch.nn.Embedding(64, 32) - - def forward(self, input_ids, attention_mask): - return SimpleNamespace(last_hidden_state=self.embedding(input_ids)) - - -@pytest.mark.parametrize("variant", ["pooled", "pertoken"]) -def test_language_optimizer_and_actual_annotation_overlay( - variant, monkeypatch, tmp_path -): - prompts_seen = [] - - class Tokenizer: - def __call__(self, prompts, **kwargs): - prompts_seen.extend(prompts) - ids = torch.tensor( - [[ord(c) % 64 for c in (text + " ")[:5]] for text in prompts] - ) - return BatchEncoding( - {"input_ids": ids, "attention_mask": torch.ones_like(ids)} - ) - - monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda *a, **kw: Tokenizer()) - monkeypatch.setattr(AutoModel, "from_pretrained", lambda *a, **kw: TextBackend()) - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - config = compose( - config_name="train_zarr_cartesian", - overrides=[ - f"model=hpt_bc_pickplace_qwen_{variant}", - "data=bc_pickplace_eva_qwen", - "evaluator=eval_hpt", - "evaluator/viz@evaluator.viz_func=cotrain_lang", - ], - ) - for index in range(3): - write_episode(tmp_path, "eva", T=8, H=64, W=64, seed=index) - dataset = config.data.train_datasets.eva_bimanual - with open_dict(dataset): - dataset.resolver._target_ = ( - "egomimic.rldb.zarr.zarr_dataset_multi.LocalEpisodeResolver" - ) - dataset.resolver.folder_path = str(tmp_path) - dataset.filters = None - dataset.bounds_check = False - with open_dict(config.data): - config.data.valid_datasets = {"eva_bimanual": deepcopy(dataset)} - config.data.train_dataloader_params = { - # Start within the annotated interval, rather than sometimes sampling - # the unannotated terminal frame. Empty prompts are tested below. - "eva_bimanual": {"batch_size": 2, "num_workers": 0, "shuffle": False} - } - config.data.valid_dataloader_params = { - "eva_bimanual": {"batch_size": 2, "num_workers": 0} - } - config.evaluator.rkl_samples = 1 - dm = instantiate(config.data, _recursive_=False) - context = dm.prepare_context(mode="train", normalization={"num_workers": 0}) - evaluator = instantiate(config.evaluator) - dm.configure_evaluation(evaluator.data_requirements()) - graph = instantiate(small_cpu_model(config, "hpt").model.pipeline, device="cpu") - context.bind(graph, evaluator) - source = next(iter(dm.train_dataloader().iterables["eva_bimanual"])) - batch = graph.process_batch_for_training({"eva_bimanual": source}) - optimizer = instantiate(config.model.optimizer)(params=graph.nets.parameters()) - scheduler = instantiate(config.model.scheduler)(optimizer=optimizer) - for _ in range(2): - optimizer.zero_grad() - loss = graph.compute_losses(graph.forward_training(batch), batch)["loss"] - loss.backward() - stem = graph.pipeline.stage_by_id("stems").stems["observations__annotation"] - assert stem.proj.weight.grad.abs().sum() > 0 - assert stem.encoder.embedding.weight.grad is None - optimizer.step() - scheduler.step() - assert prompts_seen and all( - text in ("pick up the red cube", "place it in the bin") for text in prompts_seen - ) - graph.nets.eval() - # Empty annotations must retain one prompt per sample, including at inference. - empty = deepcopy(batch) - empty["eva_bimanual"]["annotations"] = [[], []] - assert graph.forward_eval(empty)["eva_bimanual"]["observations.annotation"] == [ - "", - "", - ] - painted = [] - import egomimic.rldb.embodiment.embodiment as renderer - - original = renderer._viz_annotations - - def paint(*args, **kwargs): - painted.append(kwargs.get("annotations")) - return original(*args, **kwargs) - - monkeypatch.setattr(renderer, "_viz_annotations", paint) - evaluator.model = graph - evaluator.trainer = SimpleNamespace( - current_epoch=0, - default_root_dir=str(tmp_path), - is_global_zero=True, - lightning_module=SimpleNamespace(log_dict=lambda *a, **kw: None), - ) - evaluator.on_validation_start() - evaluator.on_validation_step(batch, 0) - assert evaluator._frame_records, "Language validation must actually render frames" - assert painted and any( - "pick up the red cube" in str(text) or "place it in the bin" in str(text) - for text in painted - ) - evaluator.on_validation_end() - assert list( - tmp_path.rglob("*.mp4") - ), "The language overlay must survive video encoding" diff --git a/tests/test_retained_pi_parity.py b/tests/test_retained_pi_parity.py deleted file mode 100644 index 9b3133336a..0000000000 --- a/tests/test_retained_pi_parity.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Retained PI schedules, mixed-source normalization and painted predictions.""" - -import json -from copy import deepcopy -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import OmegaConf, open_dict - -from egomimic.pipeline.inference_config import build_inference_config -from tests.fixtures.synthetic_episodes import write_episode -from tests.test_pi05_graph import tiny_openpi as _tiny_openpi -from tests.test_retained_recipe_steps import CONFIGS, small_cpu_model - -tiny_openpi = _tiny_openpi - - -def test_retained_pi_schedules_match_pinned_source_and_base_fails_closed(): - source = json.loads( - ( - Path(__file__).parents[1] - / "docs/integration/evidence/legacy-model-recipes.json" - ).read_text() - )["models"] - for name, original in source.items(): - if not name.startswith("pi0.5"): - continue - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose( - config_name="train_zarr_cartesian", overrides=[f"model={name}"] - ) - for field in ("optimizer", "scheduler", "scheduler_interval"): - value = cfg.model[field] - if OmegaConf.is_config(value): - value = OmegaConf.to_container(value, resolve=True) - assert value == original[field] - artifact = build_inference_config(cfg) - if name == "pi0.5_base": - assert artifact["status"] == "unsupported" - with pytest.raises(Exception, match="incomplete base fragment"): - instantiate(cfg.model.pipeline) - else: - assert artifact["status"] == "ready" - - -def test_cotrain_keeps_normalization_isolated_and_renders_prompt( - tiny_openpi, monkeypatch, tmp_path -): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "model=pi0.5_cotrain_eva_aria", - "data=cotrain_pi_base", - "evaluator=eval_pi", - ], - ) - datasets = {} - for vendor, domain in (("eva", "eva_bimanual"), ("aria", "human_bimanual")): - root = tmp_path / vendor - for index in range(3): - write_episode(root, vendor, T=8, H=64, W=64, seed=index) - ds = cfg.data.train_datasets[domain] - with open_dict(ds): - ds.resolver._target_ = ( - "egomimic.rldb.zarr.zarr_dataset_multi.LocalEpisodeResolver" - ) - ds.resolver.folder_path = str(root) - ds.filters = None - ds.mode = "total" - ds.bounds_check = False - datasets[domain] = deepcopy(ds) - with open_dict(cfg.data): - cfg.data.valid_datasets = datasets - for field in ("train_dataloader_params", "valid_dataloader_params"): - cfg.data[field] = { - name: {"batch_size": 2, "num_workers": 0} for name in datasets - } - cfg.evaluator.rkl_samples = 1 - dm = instantiate(cfg.data, _recursive_=False) - context = dm.prepare_context(mode="train", normalization={"num_workers": 0}) - assert context.normalizer.key_shape("actions_cartesian", 6) == (100, 14) - assert context.normalizer.key_shape("actions_cartesian", 3) == (100, 12) - evaluator = instantiate(cfg.evaluator) - dm.configure_evaluation(evaluator.data_requirements()) - graph = instantiate(small_cpu_model(cfg, "pi").model.pipeline, device="cpu") - context.bind(graph, evaluator) - batch = graph.process_batch_for_training( - { - source: next(iter(loader)) - for source, loader in dm.train_dataloader().iterables.items() - } - ) - optimizer = instantiate(cfg.model.optimizer)(params=graph.nets.parameters()) - for _ in range(2): - optimizer.zero_grad() - predictions = graph.forward_training(batch) - losses = graph.compute_losses(predictions, batch) - torch.testing.assert_close( - losses["loss"], - torch.stack([value["loss/pi05"] for value in predictions.values()]).mean(), - ) - losses["loss"].backward() - assert all( - p.grad is not None and torch.isfinite(p.grad).all() - for p in graph.nets.parameters() - if p.requires_grad - ) - optimizer.step() - graph.nets.eval() - import egomimic.rldb.embodiment.embodiment as renderer - - painted = [] - original = renderer._viz_annotations - - def paint(*args, **kwargs): - painted.append(kwargs.get("annotations")) - return original(*args, **kwargs) - - monkeypatch.setattr(renderer, "_viz_annotations", paint) - evaluator.model = graph - evaluator.trainer = SimpleNamespace( - current_epoch=0, - default_root_dir=str(tmp_path), - is_global_zero=True, - lightning_module=SimpleNamespace(log_dict=lambda *a, **kw: None), - ) - evaluator.on_validation_start() - metrics = evaluator.on_validation_step(batch, 0) - assert metrics and evaluator._frame_records - assert any( - "pick up the red cube" in str(text) or "place it in the bin" in str(text) - for text in painted - ) - evaluator.on_validation_end() - assert len(list(tmp_path.rglob("*.mp4"))) >= 2 diff --git a/tests/test_retained_recipe_steps.py b/tests/test_retained_recipe_steps.py deleted file mode 100644 index 4f04877594..0000000000 --- a/tests/test_retained_recipe_steps.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Real loader, normalizer, graph and optimizer steps across retained sources.""" - -from copy import deepcopy -from pathlib import Path - -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from omegaconf import OmegaConf, open_dict - -from egomimic.eval.checkpoint_loading import strict_load_pipeline_checkpoint -from egomimic.pipeline.inference_config import ( - build_inference_config, - export_configured_inference_artifact, -) -from egomimic.pipeline.inference_session import InferenceSession -from egomimic.pl_utils.pl_model import ModelWrapper -from tests.fixtures.synthetic_episodes import write_episode -from tests.test_pi05_graph import tiny_openpi as _tiny_openpi - -tiny_openpi = _tiny_openpi - -CONFIGS = Path(__file__).parents[1] / "egomimic/hydra_configs" - - -def recipe(vendor, family): - model = f"hpt_bc_flow_{vendor}" if family == "hpt" else f"pi0.5_bc_{vendor}" - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - return compose( - config_name="train_zarr_cartesian", - overrides=[f"model={model}", f"data={vendor}"], - ) - - -def small_cpu_model(config, family): - config = deepcopy(config) - with open_dict(config.model): - for stage in config.model.pipeline.stages: - if family == "pi": - stage.policy.config.pytorch_weight_path = None - stage.policy.config.pytorch_training_precision = "float32" - if "trunk" in stage: - stage.trunk.num_blocks = 1 - if "model" in stage: - stage.model.nblocks = 1 - stage.num_inference_steps = 2 - for key in ("stems", "domain_stems"): - - def disable_download(node): - if not OmegaConf.is_dict(node): - return - if "encoder" in node: - with open_dict(node.encoder): - node.encoder.weights = None - for value in node.values(): - disable_download(value) - - if key in stage: - disable_download(stage[key]) - return config - - -@pytest.mark.parametrize("vendor", ["eva", "aria", "mecka", "scale"]) -@pytest.mark.parametrize("family", ["hpt", "pi"]) -def test_two_optimizer_steps_and_strict_roundtrip( - vendor, family, tiny_openpi, tmp_path -): - config = recipe(vendor, family) - domain = "eva_bimanual" if vendor == "eva" else "human_bimanual" - for index in range(4): - write_episode(tmp_path, vendor, T=8, H=32, W=32, seed=index) - dataset = config.data.train_datasets[domain] - with open_dict(dataset): - dataset.resolver._target_ = ( - "egomimic.rldb.zarr.zarr_dataset_multi.LocalEpisodeResolver" - ) - dataset.resolver.folder_path = str(tmp_path) - dataset.resolver.key_map.annotation_key = "annotations" - dataset.filters = { - "_target_": "egomimic.rldb.filters.DatasetFilter", - "episode_hashes": [f"{vendor}_{i:02}" for i in range(3)], - } - dataset.bounds_check = False - valid = deepcopy(dataset) - valid.filters.episode_hashes = [f"{vendor}_03"] - # Materialize the old train/valid alias before separating the held-out set. - with open_dict(config.data): - config.data.valid_datasets = {domain: valid} - config.data.train_dataloader_params = { - domain: {"batch_size": 2, "num_workers": 0} - } - config.data.valid_dataloader_params = { - domain: {"batch_size": 2, "num_workers": 0} - } - dm = instantiate(config.data, _recursive_=False) - context = dm.prepare_context( - mode="train", - normalization={"norm_mode": "quantile", "num_workers": 0}, - normalizer=None, - ) - assert not ( - set(dm.train_datasets[domain].datasets) - & set(dm.valid_datasets[domain].datasets) - ) - assert dm.valid_datasets[domain].norm_stats is context.normalizer.norm_stats - cpu = small_cpu_model(config, family) - wrapper = ModelWrapper(config_tree=cpu) - graph = wrapper.model - context.bind(graph) - wrapper.data_context = context - optimizer = instantiate(config.model.optimizer)(params=wrapper.parameters()) - before = [ - parameter.detach().clone() - for parameter in wrapper.parameters() - if parameter.requires_grad - ] - loader = iter(dm.train_dataloader().iterables[domain]) - for _ in range(2): - batch = graph.process_batch_for_training({domain: next(loader)}) - optimizer.zero_grad() - predictions = graph.forward_training(batch) - loss = graph.compute_losses(predictions, batch)["loss"] - assert torch.isfinite(loss) - loss.backward() - parameters = [p for p in wrapper.parameters() if p.requires_grad] - assert all( - p.grad is not None and torch.isfinite(p.grad).all() for p in parameters - ) - optimizer.step() - assert any(not torch.equal(a, b) for a, b in zip(before, parameters, strict=True)) - graph.nets.eval() - observed = graph.process_batch_for_training({domain: next(loader)}) - observed[domain].pop("actions_cartesian") - output = graph.forward_eval(observed)[domain]["pred_action"] - assert output.shape == (2, 100, 14 if vendor == "eva" else 12) - assert torch.isfinite(output).all() - saved = {"state_dict": wrapper.state_dict()} - wrapper.on_save_checkpoint(saved) - restored = instantiate(cpu.model.pipeline, device="cpu") - context.bind(restored) - strict_load_pipeline_checkpoint(restored, saved) - for name, tensor in graph.nets.state_dict().items(): - torch.testing.assert_close(restored.nets.state_dict()[name], tensor) - assert build_inference_config(config)["status"] == "ready" - with open_dict(cpu): - cpu.inference_config.output_path = str(tmp_path / "inference-config.yaml") - export_configured_inference_artifact(cpu, data_context=context) - checkpoint_path = tmp_path / "model.ckpt" - torch.save(saved, checkpoint_path) - identity = int(observed[domain]["embodiment"][0]) - session = InferenceSession.load( - cpu, - checkpoint_path=checkpoint_path, - context_path=tmp_path / "data-context.json", - identity=identity, - normalize_inputs=False, - ) - controls = session.inference_controls() - session.apply_inference_overrides({"inference_steps": 2}) - actions = session.predict(observed[domain]) - assert tuple(actions.shape[1:]) == tuple(cpu.model.inference.output.shape) - assert torch.isfinite(actions).all() - assert session.execution_plan(actions).shape[1] == controls["replan_every"]["value"] diff --git a/tests/test_retiming_loader.py b/tests/test_retiming_loader.py deleted file mode 100644 index 56b6bf2c4e..0000000000 --- a/tests/test_retiming_loader.py +++ /dev/null @@ -1,117 +0,0 @@ -from pathlib import Path - -import numpy as np -import pytest - -from egomimic.rldb.zarr import zarr_dataset_multi as dm -from egomimic.rldb.zarr.planar_retiming import PlanarCommandRetiming - - -class Episode: - def __init__(self, path): - self.metadata = { - "total_frames": 35, - "fps": 30.0, - "embodiment": "PUSHSHAPES_SIM_U_SOCKET", - "features": {}, - } - self.intrinsics = None - self.actions = np.stack([np.arange(35), np.zeros(35), np.zeros(35)], -1) - - def _collect_keys(self): - return ["actions"] - - def read(self, requests): - return {k: self.actions[a:b].copy() for k, (a, b) in requests.items()} - - -def test_virtual_views_have_equal_rates_and_never_pad(monkeypatch): - monkeypatch.setattr(dm, "ZarrEpisode", Episode) - t = PlanarCommandRetiming((1, 1.25, 1.5, 1.75, 2)) - ds = dm.ZarrDataset( - Path("fake.zarr"), {"actions": {"zarr_key": "actions", "horizon": 31}}, [t] - ) - assert len(ds) == 25 - for i in range(len(ds)): - batch = ds[i] - anchor, view = divmod(i, 5) - assert batch["frame_index"] == anchor - assert batch["retiming_rate"].item() == t.rates[view] - assert batch["actions"][0, 0] == anchor - assert batch["actions"][-1, 0] == anchor + 15 * t.rates[view] - with pytest.raises(IndexError): - ds[25] - - -def test_loader_rejects_wrong_raw_horizon(monkeypatch): - monkeypatch.setattr(dm, "ZarrEpisode", Episode) - with pytest.raises(ValueError, match="horizon"): - dm.ZarrDataset( - Path("fake.zarr"), - {"actions": {"zarr_key": "actions", "horizon": 16}}, - [PlanarCommandRetiming((1, 2))], - ) - - -class HistoryEpisode(Episode): - reads = [] - - def _collect_keys(self): - return ["actions", "state"] - - def read(self, requests): - output = {} - for key, (start, end) in requests.items(): - self.reads.append((key, start, end)) - values = self.actions if key == "actions" else np.arange(35)[:, None] - output[key] = (values[start] if end is None else values[start:end]).copy() - return output - - -@pytest.mark.parametrize("anchor", [0, 1, 3, 4]) -@pytest.mark.parametrize("view", range(5)) -def test_causal_history_preserves_views_current_actions_and_episode_boundary( - monkeypatch, anchor, view -): - monkeypatch.setattr(dm, "ZarrEpisode", HistoryEpisode) - key_map = { - "actions": {"zarr_key": "actions", "horizon": 31}, - "observations.state.test": {"zarr_key": "state", "key_type": "proprio_keys"}, - } - transform = PlanarCommandRetiming((1, 1.25, 1.5, 1.75, 2)) - single = dm.ZarrDataset(Path("fake.zarr"), key_map, [transform]) - history = dm.ZarrDataset( - Path("fake.zarr"), key_map, [transform], observation_history_steps=2 - ) - assert len(history) == len(single) == 25 - expected = single[anchor * 5 + view] - HistoryEpisode.reads = [] - result = history[anchor * 5 + view] - frames = [max(0, anchor - 1), anchor] - assert result["observations.state.test"].tolist() == [[frames[0]], [frames[1]]] - assert result["observation_frame_indices"].tolist() == frames - assert result["frame_index"] == anchor - assert result["actions"].equal(expected["actions"]) - assert result["retiming_rate"].equal(expected["retiming_rate"]) - assert all( - start <= anchor and end is None - for key, start, end in HistoryEpisode.reads - if key == "state" - ) - - -def test_history_invalid_length_rejected_before_episode_read(monkeypatch): - for steps in [0, -1, 2.5, True]: - with pytest.raises(ValueError, match="positive integer"): - dm.ZarrDataset(Path("missing.zarr"), {}, observation_history_steps=steps) - - -def test_history_resolver_forwards_option_to_native_leaf(): - from egomimic.rldb.zarr.e1_resolvers import ( - LocalFolderEpisodeResolverWithEmbodimentOverride, - ) - - resolver = LocalFolderEpisodeResolverWithEmbodimentOverride( - Path("fake"), observation_history_steps=2 - ) - assert resolver._dataset_init_kwargs()["observation_history_steps"] == 2 diff --git a/tests/test_retiming_multiplier_condition.py b/tests/test_retiming_multiplier_condition.py deleted file mode 100644 index b27e740eaa..0000000000 --- a/tests/test_retiming_multiplier_condition.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Prevent substituting measured motion for the augmentation multiplier.""" - -from pathlib import Path - -import numpy as np -import pytest -import torch -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from egomimic.pipeline.core import Pipeline -from egomimic.pipeline.stages_action_flow import ( - ConditionalVelocityStage, - LatentBridgeStage, -) -from egomimic.pipeline.stages_speed import ( - SharedSpeedCondition, - requested_rollout_condition, -) -from egomimic.rldb.zarr.planar_retiming import PlanarCommandRetiming - - -def test_actual_motion_does_not_determine_multiplier_condition(): - transform = PlanarCommandRetiming((1.0, 2.0)) - stage = SharedSpeedCondition( - None, condition_dim=4, conditioning_input="retiming_multiplier" - ) - seen = [] - handle = stage.mlp.register_forward_pre_hook( - lambda _, args: seen.append(args[0].clone()) - ) - for distance in (0.0, 100.0): - actions = np.zeros((31, 3)) - actions[:, 0] = np.arange(31) * distance - result = transform.transform({"actions": actions, "_retiming_view": 1}) - stage( - { - "condition": torch.zeros(1, 4), - "retiming_rate": torch.from_numpy(result["retiming_rate"]).reshape( - 1, 1 - ), - "requested_speed": torch.tensor([[distance]]), - } - ) - handle.remove() - assert all(torch.equal(value, torch.tensor([[2.0]])) for value in seen) - with pytest.raises(KeyError, match="retiming_rate"): - stage({"condition": torch.zeros(1, 4), "requested_speed": torch.ones(1, 1)}) - - -@pytest.mark.parametrize("value", (0.0, -1.0, float("nan"), float("inf"))) -def test_bad_multiplier_fails_closed(value): - stage = SharedSpeedCondition(None, conditioning_input="retiming_multiplier") - with pytest.raises(ValueError): - stage( - {"condition": torch.zeros(1, 128), "retiming_rate": torch.tensor([[value]])} - ) - - -def test_strict_reload_cannot_reinterpret_native_speed_checkpoint(): - new = SharedSpeedCondition(None, conditioning_input="retiming_multiplier") - old_state = dict(new.state_dict()) - old_state.pop("retiming_multiplier_contract") - old_state["speed_reference"] = torch.tensor(100.0) - with pytest.raises(RuntimeError, match="retiming_multiplier_contract"): - new.load_state_dict(old_state, strict=True) - new.load_state_dict(new.state_dict(), strict=True) - - -def test_pusht_adapter_rejects_physical_speed_configuration(): - from egomimic.pipeline.stages_speed import build_multiplier_conditioned_pipeline - - with pytest.raises(ValueError, match="raw retiming_multiplier"): - build_multiplier_conditioned_pipeline([], conditioning_input="native_speed") - with pytest.raises(TypeError, match="speed_reference"): - build_multiplier_conditioned_pipeline([], speed_reference=100.0) - root = Path(__file__).parents[1] / "egomimic/hydra_configs/experiment" - assert not (root / "pusht/action_flow_cotrain_uc_speed_interpolation.yaml").exists() - assert not ( - root / "pusht_historical/action_flow_cotrain_uc_speed_interpolation.yaml" - ).exists() - - -def test_pusht_retiming_does_not_compute_physical_speed(monkeypatch): - def forbidden(*args, **kwargs): - raise AssertionError("PushT must not calculate a velocity-derived condition") - - monkeypatch.setattr(np.linalg, "norm", forbidden) - result = PlanarCommandRetiming((2.0,)).transform( - { - "actions": np.zeros((31, 3)), - "_retiming_view": 0, - "requested_speed": np.asarray([999.0]), - "requested_speed_value": np.asarray(999.0), - } - ) - assert "requested_speed" not in result and "requested_speed_value" not in result - np.testing.assert_array_equal(result["retiming_rate"], [2.0]) - - -def test_multiplier_reaches_shared_field_in_train_and_inference(): - class Field(torch.nn.Module): - def forward(self, x, t, condition, **kwargs): - return condition[:, : x.shape[-1]].unsqueeze(1).expand_as(x) - - condition = SharedSpeedCondition( - None, condition_dim=16, conditioning_input="retiming_multiplier" - ) - for layer in (condition.mlp[0], condition.mlp[-1]): - torch.nn.init.constant_(layer.weight, 0.1) - torch.nn.init.zeros_(layer.bias) - graph = Pipeline( - [ - condition, - LatentBridgeStage( - samples_per_content=1, - condition_key="speed_condition", - condition_dropout_probability=0, - ), - ConditionalVelocityStage( - Field(), - num_inference_steps=2, - inference_method="euler", - inference_condition_key="speed_condition", - ), - ] - ) - for mode in ("train", "inference"): - result = graph.execute( - { - "condition": torch.zeros(2, 16), - "retiming_rate": torch.tensor([[1.0], [2.0]]), - "action_flow/clean_latent": torch.zeros(2, 8, 16), - "sampler/noise": torch.zeros(2, 8, 16), - }, - mode=mode, - ) - key = ( - "action_flow/predicted_velocity" - if mode == "train" - else "action_flow/generated_latent" - ) - assert not torch.equal(result[key][0], result[key][1]) - if mode == "train": - result[key].square().mean().backward() - assert condition.mlp[0].weight.grad.abs().sum() > 0 - - -def test_recipe_preserves_compression_and_explicit_rollout_contract(monkeypatch): - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", "/unused/u") - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", "/unused/c") - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(config_dir=str(root), version_base="1.3"): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=pusht/action_flow_cotrain_uc_multiplier_interpolation", - ], - ) - assert ( - cfg.model.num_latent_tokens, - cfg.model.latent_dim, - cfg.model.action_horizon, - ) == (8, 16, 16) - assert "speed_reference" not in cfg.model.pipeline - assert cfg.model.pipeline.conditioning_input == "retiming_multiplier" - with pytest.raises(ValueError, match="requested_multiplier"): - requested_rollout_condition(cfg) - OmegaConf.update(cfg, "deployment.requested_multiplier", 1.0, force_add=True) - assert requested_rollout_condition(cfg) == ("retiming_rate", 1.0) - OmegaConf.update(cfg, "deployment.requested_speed", 100.0, force_add=True) - with pytest.raises(ValueError, match="requested_speed"): - requested_rollout_condition(cfg) diff --git a/tests/test_robot_arc_campaigns.py b/tests/test_robot_arc_campaigns.py deleted file mode 100644 index de3423cd73..0000000000 --- a/tests/test_robot_arc_campaigns.py +++ /dev/null @@ -1,79 +0,0 @@ -from pathlib import Path - -import numpy as np -import pytest -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate - -from egomimic.robot.arc_decoder import ARC_TOKEN_LAYOUTS, BimanualArcDecoder - - -@pytest.mark.parametrize("layout", ARC_TOKEN_LAYOUTS) -def test_decoder_matches_source_codec(layout): - decoder = BimanualArcDecoder(layout, resampled_vector_length=20, action_horizon=40) - values = np.zeros(decoder.shape) - t = np.linspace(0, 1, 20) - values[:20, 0] = 0.3 * t - values[:20, 7] = 0.2 * t - if layout == "lab": - values[-1, [0, 7]] = [0.3, 0.2] - elif layout == "e1_dur": - values[1:, 14:] = 1 / 30 - elif layout == "e1_logdur": - values[:, 14:] = np.log(1 / 30) - else: - values[:, 14:] = 0.3 - expected = decoder.codec.detokenize(values, action_horizon=40) - np.testing.assert_array_equal(decoder(values)[0], expected) - with pytest.raises(ValueError): - decoder(values[:-1]) - values[0, 0] = np.nan - with pytest.raises(ValueError): - decoder(values) - - -@pytest.mark.parametrize( - "experiment", - [ - "abc_stationery_bc", - "abc_stationery_arc_bc", - "shorts_extreme_bc", - "shorts_extreme_arc_bc", - "shorts_extreme_arcdur", - ], -) -def test_robot_campaign_composes_and_preserves_horizons(experiment): - root = Path(__file__).parents[1] / "egomimic/hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(root)): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - f"+experiment=abc_arc/{experiment}", - "paths.output_dir=/tmp/robot-campaign", - ], - ) - instantiate(cfg.evaluator) - expected = ( - (100, 16) - if "arcdur" in experiment - else (101, 14) - if "arc_bc" in experiment - else (100, 14) - ) - assert (cfg.e1.action_horizon, cfg.e1.action_dim) == expected - for group in [cfg.data.train_datasets, cfg.data.valid_datasets]: - for ds in group.values(): - instantiate(ds.resolver.key_map) - instantiate(ds.resolver.transform_list) - assert ds.resolver.embodiment_override == "yam_bimanual" - # Explicit disjoint shorts lists are 200 train episodes and 30 validation episodes. - if experiment.startswith("shorts"): - import ast - - sets = [] - for group in [cfg.data.train_datasets, cfg.data.valid_datasets]: - expr = next(iter(group.values())).filters.filter_lambdas[0] - tree = ast.parse(expr, mode="eval") - sets.append(ast.literal_eval(tree.body.args.defaults[0].args[0])) - assert len(sets[0]) == 200 and len(sets[1]) == 30 - assert sets[0].isdisjoint(sets[1]) diff --git a/tests/test_robot_graph_policy.py b/tests/test_robot_graph_policy.py deleted file mode 100644 index 482bb52a0c..0000000000 --- a/tests/test_robot_graph_policy.py +++ /dev/null @@ -1,703 +0,0 @@ -"""Graph checkpoint, normalization and robot frame-boundary regressions.""" - -import json -from types import SimpleNamespace - -import numpy as np -import pytest -import torch -from omegaconf import OmegaConf -from scipy.spatial.transform import Rotation - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pipeline.core import Stage -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset -from egomimic.robot.arc_decoder import BimanualArcDecoder -from egomimic.robot.graph_policy import ( - CartesianGraphAdapter, - GraphRobotPolicy, - configure_adapter_for_training, - configure_flow_inference_steps, - configure_profile_controls, - load_graph_policy, - load_normalizer, - validate_graph_device, -) -from egomimic.robot.interface import pose_matrix -from egomimic.robot.yam.kinematics import MujocoArmKinematics -from tests.test_robot_runtime import FakeRobot - -PROPRIO, ACTION = "observations.state.ee_pose", "actions_cartesian" - - -def adapter(**kwargs): - options = dict( - base_T_model={arm: np.eye(4) for arm in ("left", "right")}, - camera_keys={"front_img_1": "front"}, - embodiment_id=7, - rotation_mode="euler", - action_frame="eef_frame", - image_hw=(2, 3), - ) - options.update(kwargs) - return CartesianGraphAdapter(**options) - - -def normalizer(mode="zscore"): - stats = { - PROPRIO: {"mean": np.full(14, 2.0), "std": np.full(14, 2.0)}, - ACTION: { - "mean": np.tile([0.1, 0, 0, 0, 0, 0, 0.5], 2), - "std": np.full(14, 2.0), - }, - } - return MultiDataset( - state={ - "norm_mode": mode, - "embodiments": [7], - "key_types": {7: {PROPRIO: "proprio_keys", ACTION: "action_keys"}}, - "shapes": {7: {PROPRIO: [14], ACTION: [2, 14]}}, - "zarr_keys": {7: {PROPRIO: PROPRIO, ACTION: ACTION}}, - "norm_stats": {7: stats}, - } - ) - - -class EchoStage(Stage): - reads = (PROPRIO,) - writes = ("pred_action",) - - def __init__(self): - super().__init__() - self.bias = torch.nn.Parameter(torch.zeros(14)) - self.seen = None - - def forward(self, batch): - self.seen = batch[PROPRIO].detach().clone() - batch["pred_action"] = self.bias[None, None].expand(1, 2, 14) - return batch - - -class RetryStage(Stage): - reads = (PROPRIO,) - writes = ("pred_action",) - - def __init__(self, valid_after): - super().__init__() - self.valid_after, self.calls = valid_after, 0 - - def forward(self, batch): - self.calls += 1 - prediction = torch.zeros(1, 2, 14) - if self.calls < self.valid_after: - # The test normalizer maps 0.5 to a raw opening of 1.5. - prediction[..., [6, 13]] = 0.5 - batch["pred_action"] = prediction - return batch - - -class ConditionStage(Stage): - reads = (PROPRIO,) - writes = ("condition",) - - def forward(self, batch): - batch["condition"] = batch[PROPRIO][..., :8] - return batch - - -class ProfileSamplerStage(Stage): - reads = (PROPRIO,) - writes = ("pred_action",) - - def __init__(self): - super().__init__() - self.policy = SimpleNamespace(num_inference_steps=100) - - def forward(self, batch): - batch["pred_action"] = torch.zeros(1, 100, 14) - return batch - - -def declare_test_model(training, horizon=2, width=14, control_path=None): - from tests.test_inference_config import training_config - - declaration = training_config(horizon=horizon, action_dim=width).model.inference - declaration.input.keys = [PROPRIO, "front", "embodiment"] - declaration.input.history_keys = [PROPRIO, "front"] - declaration.profiles.default.overrides.replan_every.default = 1 - if control_path: - declaration.profiles.default.overrides.inference_steps.target.attribute_path = ( - control_path - ) - else: - del declaration.profiles.default.overrides["inference_steps"] - training.model.pipeline.stage_ids = {"sampler": 0} - training.model.inference = declaration - training.data_context_loader = { - "_target_": "egomimic.rldb.zarr.data_module.load_data_context" - } - declaration.compatibility.normalizer_schema = { - "action_key": ACTION, - "native_shape": [horizon, width], - "embodiment": 7, - } - training.model.inference = declaration - return training - - -def save_bound_checkpoint(path, training, state): - from egomimic.pipeline.checkpoint_binding import checkpoint_binding - from egomimic.pipeline.inference_config import write_inference_config - from egomimic.pl_utils.data_context import DataContext, serializable_state - from egomimic.rldb.zarr.data_module import _digest - - norm = normalizer() - snapshot = { - "kind": "zarr-normalizer-v1", - "normalizer_state": norm.to_state(), - "sha256": _digest(norm.to_state()), - } - context = DataContext(norm, norm.shapes, (), snapshot) - checkpoint = { - "state_dict": state, - "data_context": snapshot, - "inference_binding": checkpoint_binding(training, context), - } - torch.save(checkpoint, path) - (path.parent / "data-context.json").write_text( - json.dumps(serializable_state(snapshot)) - ) - write_inference_config( - training, path.parent / "inference-config.yaml", data_context=context - ) - return checkpoint - - -def test_flow_rollout_can_override_only_its_euler_solver_budget(): - from egomimic.pipeline.stages_flow import FlowDenoiserStage - - flow = FlowDenoiserStage( - torch.nn.Linear(1, 1), - action_horizon=2, - action_dim=14, - condition_input_dim=8, - num_inference_steps=50, - ) - graph = PipelineAlgo([flow], device="cpu", stage_ids={"sampler": 0}) - - configure_flow_inference_steps(graph, 10) - - assert flow.num_inference_steps == 10 - with pytest.raises(ValueError, match="positive integer"): - configure_flow_inference_steps(graph, 0) - with pytest.raises((KeyError, ValueError), match="sampler"): - configure_flow_inference_steps(PipelineAlgo([EchoStage()], device="cpu"), 10) - - -def test_checkpoint_load_applies_flow_euler_override(tmp_path): - from egomimic.pipeline.stages_flow import FlowDenoiserStage - - normalizer().cache_stats(str(tmp_path)) - # The strict loader rejects an empty checkpoint, so use a stateful model - # even though this regression needs only construction, never a forward. - flow = FlowDenoiserStage( - torch.nn.Linear(1, 1), - action_horizon=2, - action_dim=14, - condition_input_dim=8, - num_inference_steps=50, - ) - graph = PipelineAlgo( - [ConditionStage(), flow], device="cpu", stage_ids={"sampler": 1} - ) - ckpt = tmp_path / "flow.ckpt" - state = {f"nets.{key}": value for key, value in graph.nets.state_dict().items()} - torch.save({"state_dict": state}, ckpt) - training = tmp_path / "flow.yaml" - OmegaConf.save( - OmegaConf.create( - { - "model": { - "pipeline": { - "_target_": "egomimic.pipeline.algo.PipelineAlgo", - "stages": [ - { - "_target_": "tests.test_robot_graph_policy.ConditionStage" - }, - { - "_target_": "egomimic.pipeline.stages_flow.FlowDenoiserStage", - "model": { - "_target_": "torch.nn.Linear", - "in_features": 1, - "out_features": 1, - }, - "action_horizon": 2, - "action_dim": 14, - "condition_input_dim": 8, - "num_inference_steps": 50, - }, - ], - } - } - } - ), - training, - ) - OmegaConf.save( - declare_test_model( - OmegaConf.load(training), control_path="num_inference_steps" - ), - training, - ) - config = OmegaConf.load(training) - config.model.pipeline.stage_ids.sampler = 1 - OmegaConf.save(config, training) - save_bound_checkpoint(ckpt, config, state) - boundary = dict( - _target_="egomimic.robot.graph_policy.CartesianGraphAdapter", - base_T_model={a: np.eye(4).tolist() for a in ("left", "right")}, - camera_keys={"front_img_1": "front"}, - embodiment_id=7, - rotation_mode="euler", - action_frame="eef_frame", - image_hw=[2, 3], - ) - - policy = load_graph_policy( - dict( - normalizer_path=str(tmp_path / "data-context.json"), - training_config=str(training), - checkpoint=str(ckpt), - device="cpu", - adapter=boundary, - ) - ) - - assert policy.graph.pipeline.stages[1].num_inference_steps == 10 - assert policy.inference_controls()["inference_steps"]["value"] == 10 - assert policy.execution_plan(np.zeros((2, 14))).shape == (1, 14) - - -def test_graph_normalizes_proprio_and_unnormalizes_actions_once(): - stage = EchoStage() - policy = GraphRobotPolicy( - PipelineAlgo([stage], device="cpu"), normalizer(), adapter() - ) - obs = FakeRobot().get_obs() - result = policy.predict(obs) - expected = (torch.tensor(obs["ee_poses"]) - 2) / (2 + 1e-6) - torch.testing.assert_close(stage.seen[0].double(), expected, atol=1e-6, rtol=1e-6) - np.testing.assert_allclose(result[:, [0, 7]], 0.1, atol=1e-6) - np.testing.assert_allclose(result[:, [6, 13]], 0.5, atol=1e-6) - - -def test_graph_policy_resamples_whole_invalid_plan_without_clamping_grippers(): - stage = RetryStage(valid_after=2) - policy = GraphRobotPolicy( - PipelineAlgo([stage], device="cpu"), - normalizer(), - adapter(), - max_valid_samples=2, - ) - - result = policy.predict(FakeRobot().get_obs()) - - assert stage.calls == 2 - np.testing.assert_allclose(result[:, [6, 13]], 0.5, atol=1e-6) - - -def test_graph_adapter_clips_only_small_gripper_overshoots(): - native = np.zeros((1, 1, 14)) - native[0, 0, [6, 13]] = [-0.04, 1.04] - - result = adapter(gripper_clip_tolerance=0.05).actions(native, FakeRobot().get_obs()) - - np.testing.assert_allclose(result[:, [6, 13]], [[0, 1]]) - native[0, 0, 6] = -0.06 - with pytest.raises(ValueError, match="gripper opening"): - adapter(gripper_clip_tolerance=0.05).actions(native, FakeRobot().get_obs()) - - -def test_graph_policy_never_commands_when_all_stochastic_samples_are_invalid(): - stage = RetryStage(valid_after=3) - policy = GraphRobotPolicy( - PipelineAlgo([stage], device="cpu"), - normalizer(), - adapter(), - max_valid_samples=2, - ) - - with pytest.raises(ValueError, match="rejected all 2 sampled"): - policy.predict(FakeRobot().get_obs()) - assert stage.calls == 2 - - -def test_observation_color_and_camera_keys_follow_training_contract(): - result = adapter().observation(FakeRobot().get_obs()) - assert result["front"].shape == (1, 3, 2, 3) - np.testing.assert_allclose( - result["front"][0, :, 0, 0], np.array([30, 20, 10]) / 255.0 - ) - with pytest.raises(ValueError, match="observation"): - adapter().observation({**FakeRobot().get_obs(), "front_img_1": None}) - - -@pytest.mark.parametrize("frame", ["eef_frame", "model_frame"]) -def test_action_frame_is_explicit_and_does_not_accumulate_within_chunk(frame): - obs = FakeRobot().get_obs() - obs["ee_poses"][:6] = [1, 2, 3, np.pi / 2, 0, 0] - calibration = np.eye(4) - calibration[0, 3] = 10 - boundary = adapter( - action_frame=frame, base_T_model={"left": calibration, "right": np.eye(4)} - ) - native = np.zeros((1, 2, 14)) - native[:, :, 0] = 0.2 - native[:, :, [6, 13]] = 0.5 - out = boundary.actions(native, obs) - expected = [1, 2.2, 3] if frame == "eef_frame" else [10.2, 0, 0] - np.testing.assert_allclose(out[0, :3], expected, atol=1e-6) - np.testing.assert_allclose(out[0], out[1]) - - -def test_6d_rotation_and_invalid_output(): - obs = FakeRobot().get_obs() - native = np.zeros((1, 2, 20)) - for offset in (0, 10): - native[:, :, offset + 3] = 1 - native[:, :, offset + 7] = 1 - native[:, :, offset + 9] = 0.5 - out = adapter(rotation_mode="6D").actions(native, obs) - assert out.shape == (2, 14) - np.testing.assert_allclose(pose_matrix(out[0, :6]), np.eye(4)) - native[0, 0, 3:6] = 0 - with pytest.raises(ValueError, match="Degenerate"): - adapter(rotation_mode="6D").actions(native, obs) - - -@pytest.mark.parametrize("layout", ["lab", "e1_dur", "e1_logdur", "e1_profile"]) -def test_graph_adapter_decodes_all_arc_layouts_before_frame_conversion(layout): - decoder = BimanualArcDecoder( - token_layout=layout, resampled_vector_length=4, action_horizon=7 - ) - tokens = np.zeros((1, *decoder.shape)) - tokens[:, :, 6] = 0.5 - tokens[:, :, 13] = 0.5 - if layout == "lab": - tokens[:, 0, :] = 0.01 - else: - tokens[:, :, -2:] = 0.1 if layout != "e1_logdur" else np.log(0.1) - poses = adapter(decoder=decoder).actions(tokens, FakeRobot().get_obs()) - assert poses.shape == (7, 14) - assert np.isfinite(poses).all() - - -@pytest.mark.parametrize( - ("variant", "action_dim", "expected_layout"), - [ - ("time", 14, None), - ("arcvel", 16, "e1_profile"), - ("arcdur", 16, "e1_dur"), - ], -) -def test_selected_e1_model_derives_its_rollout_decoder( - variant, action_dim, expected_layout -): - training = OmegaConf.create( - { - "e1": { - "variant": variant, - "D": 0.4, - "M": 100, - "chunk_length": 100, - }, - "hpt": {"action_dim": action_dim, "action_horizon": 100}, - "evaluator": {"dt": 1 / 30}, - "model": { - "pipeline": { - "stages": [ - { - "_target_": "egomimic.pipeline.stages_flow.FlowDenoiserStage", - "action_horizon": 100, - "action_dim": action_dim, - } - ] - } - }, - } - ) - adapter_config = { - "_target_": "egomimic.robot.graph_policy.CartesianGraphAdapter", - "decoder": {"_target_": "old.decoder"}, - } - - training.model.pipeline.stage_ids = {"sampler": 0} - training.model.inference = {"native_output": {"shape": [100, action_dim]}} - profiles = { - "default": { - "stage_id": "sampler", - "native_shape": [100, action_dim], - "adapter": { - "decoder": None - if expected_layout is None - else { - "_target_": "egomimic.robot.arc_decoder.BimanualArcDecoder", - "token_layout": expected_layout, - } - }, - } - } - selected = configure_adapter_for_training(adapter_config, training, profiles) - - if expected_layout is None: - assert "decoder" not in selected - else: - assert selected["decoder"] == { - "_target_": "egomimic.robot.arc_decoder.BimanualArcDecoder", - "token_layout": expected_layout, - } - - -def test_selected_e1_model_rejects_an_incompatible_token_width(): - training = OmegaConf.create( - { - "e1": {"variant": "arcvel"}, - "hpt": {"action_dim": 14, "action_horizon": 100}, - "model": { - "pipeline": { - "stages": [ - { - "_target_": "egomimic.pipeline.stages_flow.FlowDenoiserStage", - "action_horizon": 100, - "action_dim": 14, - } - ] - } - }, - } - ) - - training.model.pipeline.stage_ids = {"sampler": 0} - training.model.inference = {"native_output": {"shape": [100, 14]}} - with pytest.raises(ValueError, match="expects"): - configure_adapter_for_training( - {}, - training, - { - "flow_arcvel": { - "stage_id": "sampler", - "native_shape": [100, 16], - "adapter": {}, - } - }, - ) - - -def test_diffusion_profile_exposes_typed_controls_and_needs_no_arc_variant(): - target = f"{ProfileSamplerStage.__module__}.{ProfileSamplerStage.__qualname__}" - training = OmegaConf.create( - { - "model": { - "pipeline": { - "stages": [ - { - "_target_": target, - "action_horizon": 100, - "action_dim": 14, - } - ] - } - } - } - ) - declare_test_model(training, horizon=100, control_path="policy.num_inference_steps") - profiles = { - "diffusion_time": { - "stage_id": "sampler", - "native_shape": [100, 14], - "overrides": { - "inference_steps": { - "label": "Diffusion denoising steps", - "description": "Denoising budget.", - "type": "integer", - "min": 1, - "max": 100, - "step": 1, - "default": 12, - "target": { - "kind": "stage_attribute", - "stage_id": "sampler", - "attribute_path": "policy.num_inference_steps", - }, - }, - "replan_every": { - "label": "Repredict every", - "description": "Execution prefix.", - "type": "integer", - "min": 1, - "max": 100, - "step": 1, - "default": 30, - "target": { - "kind": "policy_attribute", - "attribute_path": "replan_every", - }, - }, - }, - "adapter": {"decoder": None}, - } - } - stage = ProfileSamplerStage() - graph = PipelineAlgo([stage], device="cpu", stage_ids={"sampler": 0}) - - controls = configure_profile_controls(graph, training, profiles) - selected = configure_adapter_for_training( - {"decoder": {"_target_": "old.decoder"}}, training, profiles - ) - policy = GraphRobotPolicy( - graph, - normalizer(), - adapter(), - inference_controls=controls, - ) - - assert stage.policy.num_inference_steps == 12 - assert "decoder" not in selected - assert policy.inference_controls()["replan_every"]["value"] == 30 - assert policy.execution_plan(np.zeros((100, 14))).shape == (30, 14) - - controls = policy.apply_inference_overrides( - {"inference_steps": 7, "replan_every": 4} - ) - - assert stage.policy.num_inference_steps == 7 - assert controls["inference_steps"]["value"] == 7 - assert policy.execution_plan(np.zeros((100, 14))).shape == (4, 14) - with pytest.raises(ValueError, match="not exposed"): - policy.apply_inference_overrides({"unknown": 1}) - - -def test_inference_graph_owns_history_and_enforces_canonical_output(): - stage = EchoStage() - contract = { - "input": {"history_length": 2}, - "output": {"representation": "cartesian", "shape": [2, 14]}, - } - policy = GraphRobotPolicy( - PipelineAlgo([stage], device="cpu"), - normalizer(), - adapter(), - inference_graph=contract, - ) - - result = policy.predict(FakeRobot().get_obs()) - - assert stage.seen.shape == (1, 2, 14) - assert result.shape == (2, 14) - policy.reset() - assert not policy._observation_history - - incompatible = GraphRobotPolicy( - PipelineAlgo([EchoStage()], device="cpu"), - normalizer(), - adapter(), - inference_graph={ - "input": {"history_length": 1}, - "output": {"representation": "cartesian", "shape": [3, 14]}, - }, - ) - with pytest.raises(ValueError, match="canonical output contract"): - incompatible.predict(FakeRobot().get_obs()) - - -def test_normalizer_export_roundtrip_and_incomplete_cache_rejection(tmp_path): - norm = normalizer() - norm.cache_stats(str(tmp_path)) - path = tmp_path / "norm_stats/norm_stats.json" - restored = load_normalizer(path) - value = {PROPRIO: torch.ones(1, 14), ACTION: torch.ones(1, 2, 14)} - for key, expected in norm.normalize(value, 7).items(): - torch.testing.assert_close(restored.normalize(value, 7)[key], expected) - old = tmp_path / "stats-only.json" - old.write_text(json.dumps({"stats": {}})) - with pytest.raises(ValueError, match="normalizer_state"): - load_normalizer(old) - - -def test_graph_policy_rejects_unsupported_cuda_before_model_or_robot_loading( - monkeypatch, -): - monkeypatch.setattr(torch.cuda, "is_available", lambda: True) - monkeypatch.setattr(torch.cuda, "device_count", lambda: 1) - monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _index: (12, 0)) - monkeypatch.setattr(torch.cuda, "get_arch_list", lambda: ["sm_90"]) - - with pytest.raises(RuntimeError, match="sm_120"): - validate_graph_device("cuda:0") - - -def test_checkpoint_loading_is_strict_and_never_opens_training_datasets(tmp_path): - normalizer().cache_stats(str(tmp_path)) - graph = PipelineAlgo([EchoStage()], device="cpu") - ckpt = tmp_path / "model.ckpt" - state = {f"nets.{key}": value for key, value in graph.nets.state_dict().items()} - torch.save({"state_dict": state}, ckpt) - training = tmp_path / "training.yaml" - OmegaConf.save( - OmegaConf.create( - { - "model": { - "pipeline": { - "_target_": "egomimic.pipeline.algo.PipelineAlgo", - "stages": [ - {"_target_": "tests.test_robot_graph_policy.EchoStage"} - ], - } - }, - "data": {"_target_": "must.never.be.instantiated"}, - } - ), - training, - ) - OmegaConf.save( - declare_test_model(OmegaConf.load(training), control_path=None), training - ) - checkpoint = save_bound_checkpoint(ckpt, OmegaConf.load(training), state) - boundary = dict( - _target_="egomimic.robot.graph_policy.CartesianGraphAdapter", - base_T_model={a: np.eye(4).tolist() for a in ("left", "right")}, - camera_keys={"front_img_1": "front"}, - embodiment_id=7, - rotation_mode="euler", - action_frame="eef_frame", - image_hw=[2, 3], - ) - config = dict( - normalizer_path=str(tmp_path / "data-context.json"), - training_config=str(training), - checkpoint=str(ckpt), - device="cpu", - adapter=boundary, - ) - assert load_graph_policy(config).predict(FakeRobot().get_obs()).shape == (2, 14) - checkpoint["state_dict"] = {**state, "nets.unexpected": torch.zeros(1)} - torch.save(checkpoint, ckpt) - with pytest.raises(ValueError, match="key mismatch"): - load_graph_policy(config) - - -def test_mujoco_fk_ik_roundtrip_on_actual_numeric_model(tmp_path): - # A six-hinge chain exercises the real MuJoCo Jacobian, without hardware/models. - body = '' - for index in reversed(range(6)): - axis = [(1, 0, 0), (0, 1, 0), (0, 0, 1)][index % 3] - body = f'{body}' - path = tmp_path / "arm.xml" - path.write_text( - f'{body}' - ) - solver = MujocoArmKinematics(path, [f"joint{i + 1}" for i in range(6)]) - expected = solver.fk(np.array([0.1, 0.2, -0.1, 0.1, 0.1, -0.1])) - actual = solver.fk(solver.ik(expected, np.zeros(6))) - np.testing.assert_allclose(actual[:3, 3], expected[:3, 3], atol=0.002) - assert Rotation.from_matrix(actual[:3, :3] @ expected[:3, :3].T).magnitude() < 0.02 diff --git a/tests/test_robot_retiming.py b/tests/test_robot_retiming.py deleted file mode 100644 index 11070850e1..0000000000 --- a/tests/test_robot_retiming.py +++ /dev/null @@ -1,84 +0,0 @@ -import numpy as np -import pytest - -from egomimic.rldb.zarr.physical_retiming import PhysicalWindowRetiming - -RATES = (5.0, 2.5, 1 / 0.6, 1.25, 1.0) -FIELDS = {"left": "pose_wxyz", "right": "pose_wxyz", "grip": "hold"} - - -def transform(rates=RATES, enabled=True): - return PhysicalWindowRetiming( - rates, - FIELDS, - ("left", "right"), - 100, - embodiment="robot", - sample_views=len(rates), - allow_robot_augmentation=enabled, - ) - - -@pytest.mark.parametrize("view,rate", enumerate(RATES)) -def test_reciprocal_robot_future(view, rate): - t = transform() - assert t.required_frames == 496 - keys = {k: {"horizon": 100} for k in FIELDS} - t.bind_episode({"fps": 30}, keys) - assert all(v["horizon"] == 496 for v in keys.values()) - pose = np.zeros((496, 7)) - pose[:, 0] = np.arange(496) - pose[:, 3] = 1 - grip = np.arange(496, dtype=float) - obs = np.array([42]) - b = t.transform( - { - "left": pose, - "right": pose.copy(), - "grip": grip, - "obs": obs, - "_retiming_view": view, - } - ) - assert b["left"].shape == (100, 7) - np.testing.assert_allclose(b["left"][:, 0], rate * np.arange(100), atol=1e-10) - np.testing.assert_allclose(b["left"][:, 3], 1) - np.testing.assert_allclose( - b["grip"], np.floor(rate * np.arange(100) + 1e-10), atol=1 - ) - assert b["obs"] is obs - assert abs(float(b["retiming_rate"][0]) - rate) < 1e-6 - if rate == 1: - np.testing.assert_array_equal(b["left"], pose[:100]) - np.testing.assert_array_equal(pose[:, 0], np.arange(496)) - - -def test_default_robot_stays_one_x(): - with pytest.raises(ValueError): - transform(enabled=False) - t = transform((1.0,), False) - assert t.required_frames == 100 - - -def test_reject_incomplete_future(): - t = transform() - t.bind_episode({"fps": 30}, {k: {"horizon": 100} for k in FIELDS}) - pose = np.zeros((495, 7)) - pose[:, 3] = 1 - with pytest.raises(ValueError): - t.transform( - {"left": pose, "right": pose, "grip": np.zeros(495), "_retiming_view": 0} - ) - - -def test_human_speedup_remains_rejected(): - with pytest.raises(ValueError): - PhysicalWindowRetiming( - [2], - FIELDS, - ("left", "right"), - 100, - embodiment="human", - timestamp_key="clock", - allow_robot_augmentation=True, - ) diff --git a/tests/test_robot_runtime.py b/tests/test_robot_runtime.py deleted file mode 100644 index 07ede65ed5..0000000000 --- a/tests/test_robot_runtime.py +++ /dev/null @@ -1,918 +0,0 @@ -"""Offline behavior checks for the shared collection and rollout paths.""" - -import hashlib -import sys -import threading -import time -from pathlib import Path -from types import SimpleNamespace - -import h5py -import numpy as np -import pytest -from omegaconf import OmegaConf -from scipy.spatial.transform import Rotation - -from egomimic.robot.collect_demo import EpisodeWriter, next_episode_path, run_collection -from egomimic.robot.interface import ARM_OFFSET, pose_matrix -from egomimic.robot.replay_policy import ZarrReplayPolicy -from egomimic.robot.rollout import load_policy, run_rollout -from egomimic.robot.teleop import TeleopControl, WorldFrameTeleop -from egomimic.robot.yam.interface import YamInterface - - -def mapper(arms=("left", "right"), **kwargs): - return WorldFrameTeleop( - arms, - **dict( - translation_gain=1.0, - orientation_gain=1.0, - target_lpf=1.0, - max_controller_step_m=0.08, - max_controller_step_deg=40.0, - **kwargs, - ), - ) - - -class FakeRobot: - def __init__(self, arms=("left", "right")): - self.arms = list(arms) - self.q = np.zeros(14) - self.q[[6, 13]] = 0.5 - self.camera_res = { - "front_img_1": (2, 3), - "left_wrist_img": (2, 3), - "right_wrist_img": (2, 3), - } - self.recorders = {} - self.commands = [] - - def get_obs(self): - return { - "joint_positions": self.q.copy(), - "ee_poses": self.q.copy(), - **{ - key: np.tile(np.array([[[10, 20, 30]]], dtype=np.uint8), (2, 3, 1)) - for key in self.camera_res - }, - } - - def set_joints(self, command, arm): - self.commands.append((arm, command.copy())) - self.q[ARM_OFFSET[arm] : ARM_OFFSET[arm] + 7] = command - - def solve_ik(self, pose, arm): - return np.asarray(pose).copy() - - def forward_kinematics(self, q, arm): - return np.asarray(q).copy() - - def set_home(self): - self.q[:] = 0 - self.q[[6, 13]] = 1 - - -class View: - def __init__(self): - self.closed = False - self.frames = [] - - def update(self, obs, recording=False): - self.frames.append((obs, recording)) - - def close(self): - self.closed = True - - -@pytest.fixture(autouse=True) -def no_sleep(monkeypatch): - monkeypatch.setattr(time, "sleep", lambda _: None) - - -def test_world_translation_does_not_follow_initial_controller_or_head_rotation(): - control = mapper(("right",)) - initial = np.eye(4) - initial[:3, :3] = Rotation.from_euler("z", 90, degrees=True).as_matrix() - robot_pose = np.eye(4) - robot_pose[:3, :3] = Rotation.from_euler("x", 60, degrees=True).as_matrix() - buttons = {"RG": True, "rightTrig": (0.25,)} - control.update({"r": initial, "head": np.eye(4)}, buttons, {"right": robot_pose}) - moved = initial.copy() - moved[2, 3] -= 0.02 # Quest forward becomes arm-base forward. - changed_head = np.eye(4) - changed_head[:3, 3] = 99 - target = control.update( - {"r": moved, "head": changed_head}, buttons, {"right": robot_pose} - )["right"] - np.testing.assert_allclose(target[:3], [0.02, 0, 0], atol=1e-10) - np.testing.assert_allclose( - pose_matrix(target[:6])[:3, :3], robot_pose[:3, :3], atol=1e-10 - ) - assert target[6] == 0.75 - - -@pytest.mark.parametrize( - ("controller_delta", "robot_delta"), - [ - ([0.01, 0.0, 0.0], [0.0, 0.01, 0.0]), - ([0.0, 0.01, 0.0], [0.0, 0.0, 0.01]), - ([0.0, 0.0, -0.01], [-0.01, 0.0, 0.0]), - ], -) -def test_rl2_yaw_180_pins_xyz_basis(controller_delta, robot_delta): - control = mapper(("right",), headset_yaw_degrees=180.0) - initial = np.eye(4) - buttons = {"RG": 1} - control.update({"r": initial}, buttons, {"right": np.eye(4)}) - moved = initial.copy() - moved[:3, 3] += controller_delta - target = control.update({"r": moved}, buttons, {"right": np.eye(4)})["right"] - np.testing.assert_allclose(target[:3], robot_delta, atol=1e-10) - - -@pytest.mark.parametrize( - ("controller_axis", "robot_axis"), - [ - ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), - ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]), - ([0.0, 0.0, 1.0], [1.0, 0.0, 0.0]), - ], -) -def test_rl2_yaw_180_pins_roll_pitch_yaw_basis(controller_axis, robot_axis): - control = mapper(("right",), headset_yaw_degrees=180.0) - initial = np.eye(4) - buttons = {"RG": 1} - control.update({"r": initial}, buttons, {"right": np.eye(4)}) - angle = np.deg2rad(5.0) - moved = initial.copy() - moved[:3, :3] = Rotation.from_rotvec( - np.asarray(controller_axis) * angle - ).as_matrix() - target = control.update({"r": moved}, buttons, {"right": np.eye(4)})["right"] - expected = Rotation.from_rotvec(np.asarray(robot_axis) * angle).as_matrix() - np.testing.assert_allclose(pose_matrix(target[:6])[:3, :3], expected, atol=1e-10) - - -def test_world_rotation_uses_left_multiplication(): - control = mapper(("right",)) - initial = np.eye(4) - initial[:3, :3] = Rotation.from_euler("x", 0.6).as_matrix() - robot = np.eye(4) - robot[:3, :3] = Rotation.from_euler("y", 0.4).as_matrix() - control.update({"r": initial}, {"RG": 1}, {"right": robot}) - moved = initial.copy() - moved[:3, :3] = Rotation.from_euler("y", 0.1).as_matrix() @ initial[:3, :3] - target = control.update({"r": moved}, {"RG": 1}, {"right": robot})["right"] - expected = Rotation.from_euler("z", 0.1).as_matrix() @ robot[:3, :3] - np.testing.assert_allclose(pose_matrix(target[:6])[:3, :3], expected, atol=1e-10) - - -@pytest.mark.parametrize("loss", ["release", "missing", "jump", "invalid"]) -def test_clutch_loss_reanchors_at_current_measured_pose(loss): - control = mapper(("right",)) - first = np.eye(4) - control.update({"r": first}, {"RG": 1}, {"right": first}) - moved = first.copy() - moved[0, 3] = 0.04 - control.update({"r": moved}, {"RG": 1}, {"right": first}) - if loss == "release": - assert not control.update({"r": moved}, {}, {"right": first}) - elif loss == "missing": - assert not control.update({}, {"RG": 1}, {"right": first}) - elif loss == "jump": - moved[0, 3] = 2 - assert not control.update({"r": moved}, {"RG": 1}, {"right": first}) - else: - assert not control.update( - {"r": np.full((4, 4), np.nan)}, {"RG": 1}, {"right": first} - ) - current_robot = first.copy() - current_robot[:3, 3] = [1, 2, 3] - result = control.update({"r": moved}, {"RG": 1}, {"right": current_robot})["right"] - np.testing.assert_allclose(result[:3], [1, 2, 3]) - - -def test_arms_clutch_independently_and_joystick_reanchors(): - control = mapper() - pose = np.eye(4) - measured = {"left": pose.copy(), "right": pose.copy()} - result = control.update({"l": pose, "r": pose}, {"LG": 1}, measured) - assert set(result) == {"left"} - measured["left"][0, 3] = 4 - result = control.update({"l": pose}, {"LG": 1, "LJ": 1}, measured) - assert result["left"][0] == 4 - - -def test_teleop_holds_on_ik_failure_and_on_lost_tracking(): - robot = FakeRobot(("right",)) - control = TeleopControl(robot, mapper(("right",)), 30, 1) - control.step({"r": np.eye(4)}, {"RG": 1, "rightTrig": 0.5}, robot.get_obs()) - before = robot.q.copy() - robot.solve_ik = lambda *args: None - control.step({"r": np.eye(4)}, {"RG": 1, "rightTrig": 1}, robot.get_obs()) - control.step({}, {}, robot.get_obs()) - np.testing.assert_allclose(robot.q, before) - - -def test_hdf5_schema_rgb_alignment_and_exclusive_creation(tmp_path): - robot = FakeRobot() - obs = robot.get_obs() - writer = EpisodeWriter(tmp_path / "demo_0.hdf5", robot.camera_res) - writer.append(obs, np.ones(14), np.full(14, 2.0)) - invalid = {**obs, "left_wrist_img": None} - with pytest.raises(ValueError, match="camera"): - writer.append(invalid, np.ones(14), np.ones(14)) - writer.close() - with h5py.File(writer.path) as data: - assert not data.attrs["sim"] and data.attrs["complete"] - for key in ( - "observations/joints", - "observations/joint_positions", - "observations/eepose", - "actions/eepose", - "actions/joints", - "action", - ): - assert data[key].shape == (1, 14) - assert data[key].dtype == np.float32 - for camera in robot.camera_res: - frame = data[f"observations/images/{camera}"] - assert frame.shape == (1, 2, 3, 3) - np.testing.assert_array_equal(frame[0, 0, 0], [30, 20, 10]) - np.testing.assert_array_equal(data["action"], data["actions/joints"]) - np.testing.assert_array_equal(data["observations/eepose"][0], obs["ee_poses"]) - with pytest.raises(FileExistsError): - EpisodeWriter(writer.path, robot.camera_res) - assert next_episode_path(tmp_path).name == "demo_1.hdf5" - - -def test_collection_button_edges_and_interrupted_data_are_preserved(tmp_path): - config = OmegaConf.to_container( - OmegaConf.load("egomimic/hydra_configs/robot/eva_collect.yaml") - ) - config["recording"]["directory"] = str(tmp_path) - robot, view = FakeRobot(), View() - buttons = iter([{"B": 1}, {"B": 1}, {}, {"X": 1}, {"B": 1}, {"A": 1}]) - reader = SimpleNamespace( - get_transformations_and_buttons=lambda: ( - {"l": np.eye(4), "r": np.eye(4)}, - next(buttons), - ) - ) - run_collection(robot, reader, config, view=view, max_steps=10) - paths = sorted(tmp_path.glob("*.hdf5")) - assert len(paths) == 2 and view.closed - with h5py.File(paths[0]) as first, h5py.File(paths[1]) as second: - assert first["action"].shape[0] == 3 - assert second["action"].shape[0] == 1 - assert not first.attrs["complete"] and not second.attrs["complete"] - assert len(view.frames) == 6 - - -def test_collection_can_record_30hz_while_teleop_runs_at_60hz(tmp_path): - config = OmegaConf.to_container( - OmegaConf.load("egomimic/hydra_configs/robot/eva_collect.yaml") - ) - config["frequency"] = 60 - config["recording"].update(directory=str(tmp_path), rate_hz=30) - robot, view = FakeRobot(), View() - buttons = iter([{"B": 1}, {}, {}, {}, {}, {"A": 1}]) - reader = SimpleNamespace( - get_transformations_and_buttons=lambda: ( - {"l": np.eye(4), "r": np.eye(4)}, - next(buttons), - ) - ) - run_collection(robot, reader, config, view=view, max_steps=10) - paths = list(tmp_path.glob("*.hdf5")) - assert len(paths) == 1 - with h5py.File(paths[0]) as episode: - assert episode["action"].shape[0] == 3 - assert not episode.attrs["complete"] - - -class Driver: - xml_path = "test-model.xml" - - def __init__(self, channel): - self.channel, self.q, self.closed = ( - channel, - np.array([0, 0, 0, 0, 0, 0, 0.5]), - False, - ) - - def get_joint_pos(self): - return self.q.copy() - - def command_joint_pos(self, q): - self.q = q.copy() - - def close(self): - self.closed = True - - -class ReportingDriver(Driver): - def get_robot_info(self): - return { - "kp": np.r_[np.zeros(6), 10.0], - "kd": np.r_[np.zeros(6), 0.5], - "gripper_index": 6, - "limit_gripper_effort": 50.0, - } - - -class Solver: - def __init__(self, **kwargs): - self.kwargs = kwargs - - def fk(self, joints): - return pose_matrix(joints) - - def ik(self, pose, seed): - from egomimic.robot.interface import pose_vector - - return pose_vector(pose) - - -class StreamingSolver: - instances = [] - - def __init__(self, xml_path, **kwargs): - self.xml_path = xml_path - self.dt = float(kwargs["dt"]) - self.max_joint_vel = float(kwargs["max_joint_vel"]) - self.n_arm = int(kwargs["n_arm"]) - self.calls = [] - self.instances.append(self) - - def fk(self, joints): - return pose_matrix(np.asarray(joints)[:6]) - - def ik(self, target, seed): - from egomimic.robot.interface import pose_vector - - self.calls.append((np.asarray(target).copy(), np.asarray(seed).copy())) - return True, pose_vector(target) - - -def yam_robot(arms=("left", "right"), driver_factory=Driver, streaming=False): - teleop_kinematics = None - streaming_solver_factory = None - if streaming: - teleop_kinematics = { - "ee_site": "tcp_site", - "n_arm": 6, - "dt": 1 / 60, - "steps": 4, - "gain": 0.5, - "damping": 0.01, - "posture_cost": 0.08, - "max_joint_vel": 2.5, - } - streaming_solver_factory = StreamingSolver - return YamInterface( - arms, - {"left": "can0", "right": "can1"}, - {}, - {}, - {arm: [0, 0, 0, 0, 0, 0, 1] for arm in arms}, - teleop_kinematics=teleop_kinematics, - driver_factory=driver_factory, - solver_factory=Solver, - streaming_solver_factory=streaming_solver_factory, - ) - - -def test_yam_interface_matches_shared_observation_order_and_cleanup(): - robot = yam_robot() - drivers = list(robot.controller.values()) - robot.set_pose(np.array([0.1, 0.2, 0.3, 0, 0, 0, 0.8]), "right") - obs = robot.get_obs() - np.testing.assert_allclose( - obs["joint_positions"][7:], [0.1, 0.2, 0.3, 0, 0, 0, 0.8] - ) - np.testing.assert_allclose(obs["ee_poses"], obs["joint_positions"]) - assert drivers[0].channel == "can0" and drivers[1].channel == "can1" - robot.close() - robot.close() - assert all(driver.closed for driver in drivers) - - -def test_yam_interface_verifies_reported_gripper_control(capsys): - robot = YamInterface( - ["left"], - {"left": "can0"}, - {}, - {}, - {"left": [0, 0, 0, 0, 0, 0, 1]}, - gripper_kp=10, - gripper_kd=0.5, - gripper_force_limit=50, - driver_factory=ReportingDriver, - solver_factory=Solver, - ) - assert "gripper Kp=10, Kd=0.5, force limit=50 N" in capsys.readouterr().out - robot.close() - - -def test_yam_interface_rejects_driver_gripper_control_mismatch(): - with pytest.raises(RuntimeError, match="gripper control mismatch"): - YamInterface( - ["left"], - {"left": "can0"}, - {}, - {}, - {"left": [0, 0, 0, 0, 0, 0, 1]}, - gripper_kp=8, - gripper_kd=0.5, - gripper_force_limit=50, - driver_factory=ReportingDriver, - solver_factory=Solver, - ) - - -def test_yam_teleop_uses_reference_streaming_ik_with_command_seed(): - StreamingSolver.instances.clear() - robot = yam_robot(("right",), streaming=True) - control = TeleopControl(robot, mapper(("right",)), 60, 2.5) - initial = np.eye(4) - buttons = {"RG": 1, "rightTrig": (0.25,)} - control.step({"r": initial}, buttons, robot.get_obs()) - moved = initial.copy() - moved[2, 3] -= 0.01 - commands, _ = control.step({"r": moved}, buttons, robot.get_obs()) - solver = StreamingSolver.instances[0] - assert len(solver.calls) == 2 - np.testing.assert_allclose(solver.calls[-1][1], np.r_[np.zeros(6), 0.75]) - assert commands[7] == pytest.approx(0.01) - assert commands[13] == pytest.approx(0.75) - robot.close() - - -def test_yam_teleop_rejects_rate_drift_from_reference_solver(): - robot = yam_robot(("right",), streaming=True) - with pytest.raises(ValueError, match="frequency"): - TeleopControl(robot, mapper(("right",)), 30, 2.5) - with pytest.raises(ValueError, match="joint velocity"): - TeleopControl(robot, mapper(("right",)), 60, 1.0) - robot.close() - - -def test_yam_failed_initialization_closes_already_opened_arm(): - first = Driver("can0") - - def factory(channel): - if channel == "can1": - raise RuntimeError("CAN unavailable") - return first - - with pytest.raises(RuntimeError, match="CAN unavailable"): - yam_robot(driver_factory=factory) - assert first.closed - - -def test_camera_serial_preflight_rejects_bad_station_mapping(): - from egomimic.robot.cameras import validate_camera_devices - - cameras = { - "front": {"enabled": True, "type": "realsense", "serial_number": "top"}, - "left": {"enabled": True, "type": "d405", "serial_number": "left"}, - } - assert validate_camera_devices(cameras, ["left", "top"]) == ("top", "left") - with pytest.raises(RuntimeError, match="front=top"): - validate_camera_devices(cameras, ["left"]) - cameras["left"]["serial_number"] = "top" - with pytest.raises(ValueError, match="distinct serials"): - validate_camera_devices(cameras, ["top"]) - - -def test_yam_camera_preflight_runs_before_driver_initialization(): - calls = [] - - def reject(_config): - raise RuntimeError("camera preflight failed") - - with pytest.raises(RuntimeError, match="camera preflight failed"): - YamInterface( - ["left"], - {"left": "can0"}, - {"front": {"type": "realsense", "serial_number": "missing"}}, - {}, - {"left": [0, 0, 0, 0, 0, 0, 1]}, - driver_factory=lambda channel: calls.append(channel), - solver_factory=Solver, - camera_validator=reject, - ) - assert not calls - - -def test_yam_camera_reconnect_rebuilds_only_rgb_streams(monkeypatch): - import egomimic.robot.yam.interface as yam_interface - - class Recorder: - def __init__(self, generation): - self.generation = generation - self.stopped = False - - def stop(self): - self.stopped = True - - def get_image(self): - return np.full((2, 3, 3), self.generation, dtype=np.uint8) - - opened, validated = [], [] - - def fake_open(cameras): - generation = len(opened) + 1 - recorder = Recorder(generation) - opened.append((dict(cameras), recorder)) - return {"front": recorder}, {"front": (2, 3)} - - monkeypatch.setattr(yam_interface, "open_cameras", fake_open) - cameras = {"front": {"type": "realsense", "serial_number": "front"}} - robot = YamInterface( - ["left"], - {"left": "can0"}, - cameras, - {}, - {"left": [0, 0, 0, 0, 0, 0, 1]}, - driver_factory=Driver, - solver_factory=Solver, - camera_validator=lambda config: validated.append(dict(config)), - ) - driver = robot.controller["left"] - first = opened[0][1] - - assert robot.reconnect_cameras() == ("front",) - assert first.stopped - assert not driver.closed - assert len(opened) == 2 and len(validated) == 2 - np.testing.assert_array_equal(robot.get_obs()["front"], np.full((2, 3, 3), 2)) - - robot.close() - assert opened[1][1].stopped and driver.closed - - -def replay_store(tmp_path, padded=5, total=3): - import zarr - - store = zarr.open_group(str(tmp_path / "demo.zarr"), mode="w") - store.attrs["total_frames"] = total - for side in ("left", "right"): - store.create_array(f"{side}.cmd_joints", data=np.zeros((padded, 6))) - store.create_array(f"{side}.cmd_gripper", data=np.full((padded, 1), 0.5)) - return store - - -def yam_pipeline_replay_store(tmp_path, padded=5, total=3): - import zarr - - store = zarr.open_group(str(tmp_path / "yam_episode.zarr"), mode="w") - store.attrs.update( - complete=True, - committed_samples=total, - arm_order=["left", "right"], - schema="rl2_yam.episode.v1", - ) - joints = np.zeros((padded, 2, 6)) - joints[:total, 0, 0] = np.arange(total) * 0.01 - joints[:total, 1, 0] = np.arange(total) * -0.01 - grippers = np.full((padded, 2), 0.5) - store.create_array("actions/joint_position", data=joints) - store.create_array("actions/gripper", data=grippers) - return store - - -def replay_keys(): - return { - arm: {"joints": f"{arm}.cmd_joints", "gripper": f"{arm}.cmd_gripper"} - for arm in ARM_OFFSET - } - - -@pytest.mark.parametrize("robot_factory", [FakeRobot, yam_robot]) -def test_rollout_replays_all_valid_zarr_frames_and_stops_at_eof( - tmp_path, robot_factory -): - replay_store(tmp_path) - policy = ZarrReplayPolicy(tmp_path / "demo.zarr", keys=replay_keys(), chunk_size=2) - config = dict( - frequency=30, - max_steps=50, - max_joint_velocity=1, - preview={"enabled": False}, - ) - view = View() - assert run_rollout(robot_factory(), policy, config, view) == 3 - assert policy.cursor == 3 and view.closed - - -def test_rollout_validates_both_arms_before_commanding(): - robot = FakeRobot() - bad = robot.q.copy() - bad[7] = 100 - policy = SimpleNamespace(action_type="joints", predict=lambda obs: bad[None]) - with pytest.raises(ValueError, match="joint velocity"): - run_rollout( - robot, - policy, - dict( - frequency=30, - max_steps=2, - max_joint_velocity=1, - preview={"enabled": False}, - ), - ) - assert not robot.commands - - -def test_replay_rejects_nan_and_ignores_chunk_padding(tmp_path): - store = replay_store(tmp_path) - store["right.cmd_joints"][3:] = np.nan - policy = ZarrReplayPolicy(tmp_path / "demo.zarr", keys=replay_keys(), chunk_size=8) - assert policy.predict(FakeRobot().get_obs()).shape == (3, 14) - with pytest.raises(StopIteration): - policy.predict({}) - store["right.cmd_joints"][1] = np.nan - policy = ZarrReplayPolicy(tmp_path / "demo.zarr", keys=replay_keys(), chunk_size=8) - with pytest.raises(ValueError, match="finite"): - policy.predict(FakeRobot().get_obs()) - - -def test_replay_reads_completed_yam_pipeline_split_actions(tmp_path): - store = yam_pipeline_replay_store(tmp_path) - policy = ZarrReplayPolicy( - tmp_path / "yam_episode.zarr", - joint_action_key="actions/joint_position", - gripper_action_key="actions/gripper", - chunk_size=8, - ) - rows = policy.predict(FakeRobot().get_obs()) - assert rows.shape == (3, 14) - np.testing.assert_allclose(rows[:, :6], store["actions/joint_position"][:3, 0]) - np.testing.assert_allclose(rows[:, 6], store["actions/gripper"][:3, 0]) - np.testing.assert_allclose(rows[:, 7:13], store["actions/joint_position"][:3, 1]) - np.testing.assert_allclose(rows[:, 13], store["actions/gripper"][:3, 1]) - - store.attrs["complete"] = False - with pytest.raises(ValueError, match="completed Zarr episode"): - ZarrReplayPolicy( - tmp_path / "yam_episode.zarr", - joint_action_key="actions/joint_position", - gripper_action_key="actions/gripper", - ) - - -def test_hdf5_replay_requires_a_completed_joint_demo(tmp_path): - path = tmp_path / "demo_0.hdf5" - actions = np.zeros((2, 14), dtype=np.float32) - actions[:, [6, 13]] = 0.5 - with h5py.File(path, "w") as episode: - episode.attrs["complete"] = True - episode.create_dataset("actions/joints", data=actions) - - policy = load_policy( - { - "kind": "hdf5_replay", - "path": str(path), - "action_key": "actions/joints", - "chunk_size": 2, - } - ) - np.testing.assert_allclose(policy.predict(FakeRobot().get_obs()), actions) - policy.close() - - with h5py.File(path, "r+") as episode: - episode.attrs["complete"] = False - with pytest.raises(ValueError, match="completed HDF5"): - load_policy({"kind": "hdf5_replay", "path": str(path)}) - - -def test_no_non_graph_inference_backend(): - with pytest.raises(ValueError, match="kind=graph"): - load_policy({"kind": "legacy"}) - - -def test_yam_uploader_lists_hdf5_without_touching_source_files(tmp_path): - from egomimic.scripts.data_upload.yam_uploader import collect_files - - (tmp_path / "demo_1.hdf5").write_bytes(b"data") - (tmp_path / "metadata.json").write_text("{}") - assert collect_files(tmp_path) == [tmp_path / "demo_1.hdf5"] - assert (tmp_path / "demo_1.hdf5").read_bytes() == b"data" - - -def test_world_reader_drops_stale_input_and_uses_rail_world_stream(monkeypatch): - # Import the bundled reader, with only adb itself stubbed if unavailable. - monkeypatch.syspath_prepend(str(Path("egomimic/robot/oculus_reader").resolve())) - monkeypatch.setitem(sys.modules, "ppadb", SimpleNamespace()) - monkeypatch.setitem(sys.modules, "ppadb.client", SimpleNamespace(Client=object)) - from oculus_reader.reader import OculusReader - - reader = OculusReader.__new__(OculusReader) - reader.running = False - reader._lock = threading.Lock() - reader.tag = "wE9ryARX" - reader.max_age, reader.last_update = 0.1, time.monotonic() - 1 - reader.last_transforms, reader.last_buttons = {"r": np.eye(4)}, {"RG": 1} - assert reader.get_transformations_and_buttons() == ({}, {}) - reader.last_update = time.monotonic() - assert "r" in reader.get_transformations_and_buttons()[0] - assert reader.extract_data("wE9ryARXWorld: alternate tag") == "" - assert reader.extract_data("wE9ryARX: RAIL world frame") == "RAIL world frame" - - monkeypatch.setattr(OculusReader, "get_device", lambda self: object()) - monkeypatch.setattr(OculusReader, "install", lambda self, **kwargs: None) - configured = OculusReader(run=False) - assert configured.pose_frame == "world" - assert configured.tag == "wE9ryARX" - with pytest.raises(ValueError, match="emits world poses only"): - OculusReader(run=False, pose_frame="head") - - -def test_bundled_rail_world_apk_and_source_match_pinned_provenance(): - root = Path("egomimic/robot/oculus_reader") - source = (root / "app_source/Src/OculusTeleop.cpp").read_text() - assert "handPoseMatrixHeadCoord" not in source - assert ( - "handPoseTransformations.push_back(std::make_pair(side, handPoseMatrix));" - in source - ) - assert '"wE9ryARX"' in source - - apk = (root / "oculus_reader/APK/teleop-debug.apk").read_bytes() - expected = "cc990542fb539d541b0927a7dcce7ede4c3e6c3cb53990ad42c305bb42a05876" - provenance = (root / "oculus_reader/APK/PROVENANCE.md").read_text() - assert expected in provenance - assert "`7496215` bytes" in provenance - if apk.startswith(b"version https://git-lfs.github.com/spec/v1"): - assert f"oid sha256:{expected}".encode() in apk - assert b"size 7496215" in apk - else: - assert len(apk) == 7496215 - assert hashlib.sha256(apk).hexdigest() == expected - - -def test_camera_stream_pauses_disconnected_frames_and_resumes(): - from egomimic.robot.cameras import CameraStream - - image = np.zeros((2, 3, 3), dtype=np.uint8) - recorder = SimpleNamespace( - last_frame_time=time.monotonic() - 3, get_image=lambda: image - ) - stream = CameraStream(recorder, max_age=1.0) - assert stream.get_image() is None - recorder.last_frame_time = time.monotonic() - assert stream.get_image() is image - - -def test_live_view_displays_all_front_and_wrist_cameras(monkeypatch): - import cv2 - - from egomimic.robot.cameras import CameraView - - seen = [] - monkeypatch.setattr( - cv2, "imshow", lambda name, image: seen.append((name, image.copy())) - ) - monkeypatch.setattr(cv2, "waitKey", lambda _: ord("q")) - monkeypatch.setattr(cv2, "destroyAllWindows", lambda: None) - robot = FakeRobot() - view = CameraView(robot.camera_res) - assert view.update(robot.get_obs(), recording=True) == "q" - assert {name for name, _ in seen} == set(robot.camera_res) - view.close() - - -def test_reclutch_control_anchors_to_measured_joints_after_manual_movement(): - robot = FakeRobot(("right",)) - control = TeleopControl(robot, mapper(("right",)), 30, 1) - pose = np.eye(4) - control.step({"r": pose}, {"RG": 1}, robot.get_obs()) - control.step({"r": pose}, {}, robot.get_obs()) - robot.q[7] = 1 # Operator moved the released arm. - control.step({"r": pose}, {"RG": 1}, robot.get_obs()) - assert robot.q[7] == 1 - - -def test_existing_eva_interface_still_records_and_accepts_normalized_gripper( - monkeypatch, tmp_path -): - from egomimic.robot.eva.eva_ws.src.eva import robot_interface as eva - - robot = eva.ARXInterface.__new__(eva.ARXInterface) - robot.arms = ["right"] - robot.recorders, robot.camera_res = {}, {} - robot.gripper_close, robot.gripper_width = {"right": -0.01}, {"right": 0.1} - robot.ts_offset = 0.2 - q = np.array([0.1, 0.2, 0.3, 0, 0, 0]) - received = [] - state = SimpleNamespace(timestamp=1.0, pos=lambda: q.copy(), gripper_pos=0.04) - robot.controller = { - "right": SimpleNamespace( - get_joint_state=lambda: state, set_joint_cmd=received.append - ) - } - robot.kinematics_solver = SimpleNamespace( - fk=lambda joints: (np.asarray(joints)[:3], Rotation.identity()) - ) - monkeypatch.setattr(eva, "ArxJointState", lambda *args: SimpleNamespace()) - obs = robot.get_obs() - np.testing.assert_allclose(obs["joint_positions"][:7], 0) - np.testing.assert_allclose(obs["ee_poses"][7:], [0.1, 0.2, 0.3, 0, 0, 0, 0.5]) - robot.set_joints(np.r_[q, 0.8], "right") - assert received[0].gripper_pos == pytest.approx(0.07) - writer = EpisodeWriter(tmp_path / "demo_0.hdf5", {}) - writer.append(obs, obs["joint_positions"], obs["ee_poses"]) - writer.close() - with h5py.File(writer.path) as data: - assert "observations/images" in data - robot.close() - - -def test_yam_cleanup_attempts_both_arms_if_one_driver_fails(): - robot = yam_robot() - second = robot.controller["right"] - - def fail(): - raise RuntimeError("driver shutdown") - - robot.controller["left"].close = fail - with pytest.raises(ExceptionGroup, match="Yam cleanup"): - robot.close() - assert second.closed - - -def test_quest_stop_unblocks_a_pending_socket_read(monkeypatch): - import socket - - monkeypatch.syspath_prepend(str(Path("egomimic/robot/oculus_reader").resolve())) - monkeypatch.setitem(sys.modules, "ppadb", SimpleNamespace()) - monkeypatch.setitem(sys.modules, "ppadb.client", SimpleNamespace(Client=object)) - from oculus_reader.reader import OculusReader - - local, remote = socket.socketpair() - reader = OculusReader.__new__(OculusReader) - reader.running, reader._lock = True, threading.Lock() - reader.print_FPS = False - reader.tag = "wE9ryARX" - ready = threading.Event() - - def makefile(): - file = local.makefile() - ready.set() - return file - - connection = SimpleNamespace( - socket=SimpleNamespace(makefile=makefile, shutdown=local.shutdown), - close=local.close, - ) - reader._connection = connection - reader.thread = threading.Thread( - target=reader.read_logcat_by_line, args=(connection,), daemon=True - ) - reader.thread.start() - assert ready.wait(timeout=1.0) - reader.stop() - remote.close() - assert not reader.thread.is_alive() - - -def test_rollout_discards_unreachable_plan_and_waits_for_the_next_action(): - class UnreachableRobot(FakeRobot): - fail = True - - def solve_ik(self, pose, arm): - if self.fail: - raise ValueError( - "Predicted Cartesian target is unreachable within IK tolerances" - ) - return np.asarray(pose).copy() - - robot = UnreachableRobot() - chunk = np.tile(robot.q.copy(), (3, 1)) - calls = [] - - def predict(obs): - calls.append(obs) - if len(calls) > 1: - robot.fail = False - return chunk.copy() - - policy = SimpleNamespace(action_type="cartesian", predict=predict) - step = run_rollout( - robot, - policy, - dict( - frequency=30, - max_steps=2, - max_joint_velocity=10, - preview={"enabled": False}, - ), - ) - # The unreachable sample ended neither the session nor the process, sent no - # command, and discarded the rest of its chunk so the next tick replanned. - assert len(calls) == 2 - assert step == 2 - assert [arm for arm, _ in robot.commands] == ["left", "right", "left", "right"] diff --git a/tests/test_rollout_dashboard.py b/tests/test_rollout_dashboard.py deleted file mode 100644 index b5ec1837f6..0000000000 --- a/tests/test_rollout_dashboard.py +++ /dev/null @@ -1,1323 +0,0 @@ -"""Hardware-free checks for the YAM rollout browser view and its overlay.""" - -from __future__ import annotations - -import asyncio -import socket -import threading -import time -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -import yaml - -from egomimic.robot.interface import ARM_OFFSET -from egomimic.robot.rollout import run_rollout, validate_rollout_config -from egomimic.robot.rollout_dashboard import ( - SUPERSEDED_CLOSE_CODE, - CheckpointBrowser, - RolloutDashboard, - _broadcast_dashboard_message, - load_action_overlay, - validate_rollout_preview, -) -from egomimic.robot.rollout_video import ( - RolloutVideoRecorder, - list_rollout_videos, - rollout_video_path, - validate_video_recording, -) - -ROOT = Path(__file__).resolve().parents[1] - - -def calibration_file(tmp_path): - path = tmp_path / "agentview.yaml" - path.write_text( - yaml.safe_dump( - { - "convention": "point_base = base_T_camera @ point_camera", - "K": [[100.0, 0.0, 32.0], [0.0, 100.0, 24.0], [0.0, 0.0, 1.0]], - "dist": [0, 0, 0, 0, 0], - "channels": { - "can_l": {"base_T_camera": np.eye(4).tolist()}, - "can_r": {"base_T_camera": np.eye(4).tolist()}, - }, - } - ) - ) - return path - - -def overlay_config(path): - return { - "initial_enabled": False, - "camera": "front_img_1", - "calibration_path": str(path), - "arm_channels": {"left": "can_l", "right": "can_r"}, - } - - -def runtime_controls(inference_steps=10, replan_every=30): - return { - "inference_steps": { - "label": "Euler integration steps", - "description": "Flow solver budget.", - "type": "integer", - "min": 1, - "max": 100, - "step": 1, - "value": inference_steps, - }, - "replan_every": { - "label": "Repredict every", - "description": "Executable action prefix.", - "type": "integer", - "min": 1, - "max": 100, - "step": 1, - "value": replan_every, - }, - } - - -def available_loopback_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: - listener.bind(("127.0.0.1", 0)) - return listener.getsockname()[1] - - -class DashboardSocket: - def __init__(self, error=None, closed=False): - self.error = error - self.closed = closed - self.messages = [] - - async def send_json(self, message): - if self.error is not None: - raise self.error - self.messages.append(message) - - -def test_dashboard_broadcast_discards_only_a_disconnected_browser(): - healthy = DashboardSocket() - reset = DashboardSocket(error=ConnectionResetError()) - closed = DashboardSocket(closed=True) - - disconnected = asyncio.run( - _broadcast_dashboard_message({healthy, reset, closed}, {"type": "frame"}) - ) - - assert disconnected == {reset, closed} - assert healthy.messages == [{"type": "frame"}] - - -def test_rollout_rejects_the_removed_top_level_replan_setting(): - with pytest.raises(ValueError, match="moved to policy.inference_graph"): - validate_rollout_config({"execute_steps": 10}) - - -def test_dashboard_start_command_reaches_rollout_start_gate(tmp_path): - """A browser c/Start event becomes the rollout loop's c control.""" - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - wait_for_start=True, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - - async def request_start(): - from aiohttp import ClientSession - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as ws: - config = await ws.receive_json() - assert config["type"] == "config" - assert config["wait_for_start"] is True - await ws.send_json({"start": True}) - - try: - asyncio.run(request_start()) - deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline: - if dashboard.update({}) == "c": - break - time.sleep(0.01) - else: - pytest.fail("browser start command did not reach the rollout gate") - finally: - dashboard.close() - - -def test_dashboard_pause_command_toggles_only_after_start(tmp_path): - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - wait_for_start=True, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - - async def toggle_pause(): - from aiohttp import ClientSession - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as ws: - await ws.receive_json() - await ws.send_json({"paused": True}) - await asyncio.sleep(0.02) - assert not dashboard.is_paused() - await ws.send_json({"start": True}) - await ws.send_json({"paused": True}) - deadline = time.monotonic() + 1.0 - while not dashboard.is_paused(): - if time.monotonic() >= deadline: - pytest.fail("pause command did not reach the dashboard") - await asyncio.sleep(0.01) - await ws.send_json({"paused": False}) - while dashboard.is_paused(): - if time.monotonic() >= deadline: - pytest.fail("resume command did not reach the dashboard") - await asyncio.sleep(0.01) - - try: - asyncio.run(toggle_pause()) - finally: - dashboard.close() - - -def test_dashboard_camera_reconnect_clears_the_start_gate(tmp_path): - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - wait_for_start=True, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - - async def request_reconnect(): - from aiohttp import ClientSession - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as ws: - await ws.receive_json() - await ws.send_json({"start": True}) - await ws.send_json({"reconnect_cameras": True}) - deadline = time.monotonic() + 1.0 - while not dashboard._camera_reconnect_requested.is_set(): - if time.monotonic() >= deadline: - pytest.fail("camera reconnect did not reach dashboard") - await asyncio.sleep(0.01) - - try: - asyncio.run(request_reconnect()) - assert dashboard.take_camera_reconnect_request() - assert not dashboard._start_requested.is_set() - assert not dashboard.is_paused() - finally: - dashboard.close() - - -def test_dashboard_camera_reconnect_interrupts_velocity_prompt(tmp_path): - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - decision = [] - - def wait_for_velocity_choice(): - decision.append( - dashboard.choose_velocity_action( - {"arms": ["left"], "max_joint_step": 0.5, "limit": 0.4} - ) - ) - - try: - thread = threading.Thread(target=wait_for_velocity_choice) - thread.start() - deadline = time.monotonic() + 1.0 - while dashboard._snapshot()["velocity_prompt"] is None: - if time.monotonic() >= deadline: - pytest.fail("velocity prompt was not published") - time.sleep(0.01) - dashboard.request_camera_reconnect() - thread.join(timeout=1.0) - assert not thread.is_alive() - assert decision == ["reconnect"] - assert dashboard.take_camera_reconnect_request() - finally: - dashboard.close() - - -def test_dashboard_accepts_only_profile_declared_inference_overrides(tmp_path): - dashboard = RolloutDashboard( - ("front_img_1",), - inference_controls=runtime_controls(), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - wait_for_start=True, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - - async def set_interval(): - from aiohttp import ClientSession - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as ws: - config = await ws.receive_json() - assert config["inference_controls"]["replan_every"]["value"] == 30 - await ws.send_json( - { - "inference_override": { - "inference_steps": 7, - "replan_every": 17, - } - } - ) - deadline = time.monotonic() + 1.0 - while dashboard._pending_inference_overrides.get("replan_every") != 17: - if time.monotonic() >= deadline: - pytest.fail("inference override did not reach dashboard") - await asyncio.sleep(0.01) - - try: - asyncio.run(set_interval()) - assert dashboard.take_inference_override_request() == { - "inference_steps": 7, - "replan_every": 17, - } - dashboard.request_inference_overrides({"inference_steps": 8, "not_declared": 3}) - assert dashboard.take_inference_override_request() is None - dashboard.request_inference_override("not_declared", 3) - assert dashboard.take_inference_override_request() is None - finally: - dashboard.close() - - -def test_dashboard_reports_rolling_inference_latency(tmp_path): - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - try: - dashboard.record_inference(0.1) - dashboard.record_inference(0.2) - inference = dashboard._snapshot()["inference"] - assert inference["samples"] == 2 - assert inference["last_ms"] == pytest.approx(200.0) - assert inference["mean_ms"] == pytest.approx(150.0) - assert inference["plans_per_second"] == pytest.approx(1000.0 / 150.0) - finally: - dashboard.close() - - -def test_dashboard_video_record_command_reaches_only_the_rollout_loop(tmp_path): - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - action_overlay=overlay_config(calibration_file(tmp_path)), - video_recording={"enabled": True, "directory": str(tmp_path), "fps": 12}, - ) - - async def request_video_recording(): - from aiohttp import ClientSession - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as ws: - config = await ws.receive_json() - assert config["video_recording_enabled"] is True - await ws.send_json({"record_video": True}) - deadline = time.monotonic() + 1.0 - while not dashboard._video_record_requested.is_set(): - if time.monotonic() >= deadline: - pytest.fail("video record request did not reach dashboard") - await asyncio.sleep(0.01) - - try: - asyncio.run(request_video_recording()) - assert dashboard.take_video_recording_request() - dashboard.set_video_recording(True) - assert dashboard._snapshot()["video_recording"] is True - finally: - dashboard.close() - - -def test_checkpoint_browser_lists_only_rooted_checkpoint_candidates(tmp_path): - root = tmp_path / "models" - run = root / "run_a" - run.mkdir(parents=True) - checkpoint = run / "model.ckpt" - checkpoint.write_bytes(b"weights") - (run / "resolved-config.yaml").write_text("model: {}\n") - (run / "norm_stats.json").write_text("{}\n") - (run / "notes.txt").write_text("not a checkpoint") - outside = tmp_path / "outside.ckpt" - outside.write_bytes(b"outside") - (root / "outside-link.ckpt").symlink_to(outside) - browser = CheckpointBrowser(root) - - assert browser.list_directory()["entries"] == [ - {"type": "directory", "name": "run_a", "path": "run_a"} - ] - assert browser.list_directory("run_a")["entries"] == [ - {"type": "checkpoint", "name": "model.ckpt", "path": "run_a/model.ckpt"} - ] - assert browser.resolve_bundle("run_a/model.ckpt").checkpoint == checkpoint.resolve() - with pytest.raises(ValueError, match="escapes"): - browser.resolve_bundle("../outside.ckpt") - - -def test_checkpoint_browser_search_matches_anywhere_case_insensitive(tmp_path): - root = tmp_path / "models" - root.mkdir() - checkpoint = root / "hptflow-step-120000-final.ckpt" - checkpoint.write_bytes(b"weights") - (root / "resolved-config.yaml").write_text("model: {}\n") - (root / "norm_stats.json").write_text("{}\n") - (root / "unrelated").mkdir() - browser = CheckpointBrowser(root) - - assert browser.list_directory(query="STEP-120")["entries"] == [ - { - "type": "checkpoint", - "name": checkpoint.name, - "path": checkpoint.name, - } - ] - assert browser.list_directory(query="not-present")["entries"] == [] - - -def test_checkpoint_browser_accepts_checkpoint_prefixed_artifacts(tmp_path): - checkpoint = tmp_path / "towels_rl2_time__epoch-1199-step-120000__sha256-abcd.ckpt" - checkpoint.write_bytes(b"weights") - training_config = tmp_path / "towels_rl2_time.resolved-config.yaml" - training_config.write_text("model: {}\n") - normalizer = tmp_path / "towels_rl2_time.norm_stats.json" - normalizer.write_text("{}\n") - inference_config = tmp_path / "towels_rl2_time.inference-config.yaml" - inference_config.write_text("status: ready\n") - - bundle = CheckpointBrowser(tmp_path).resolve_bundle(checkpoint.name) - - assert bundle.checkpoint == checkpoint.resolve() - assert bundle.training_config == training_config.resolve() - assert bundle.normalizer_path == normalizer.resolve() - assert bundle.inference_config == inference_config.resolve() - assert ( - CheckpointBrowser(tmp_path).validate_policy( - { - "checkpoint": str(checkpoint), - "training_config": str(training_config), - "normalizer_path": str(normalizer), - } - ) - == bundle - ) - - -def test_dashboard_model_selection_reaches_only_rollout_loop(tmp_path): - root = tmp_path / "models" - root.mkdir() - checkpoint = root / "next.ckpt" - checkpoint.write_bytes(b"weights") - selected_checkpoint = root / "selected.ckpt" - selected_checkpoint.write_bytes(b"new weights") - training_config = root / "resolved-config.yaml" - training_config.write_text("model: {}\n") - normalizer = root / "norm_stats.json" - normalizer.write_text("{}\n") - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - wait_for_start=True, - action_overlay=overlay_config(calibration_file(tmp_path)), - model_browser={"enabled": True, "root": str(root)}, - policy={ - "checkpoint": str(checkpoint), - "training_config": str(training_config), - "normalizer_path": str(normalizer), - }, - ) - - async def request_model_selection(): - from aiohttp import ClientSession - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as ws: - config = await ws.receive_json() - assert config["model_browser_enabled"] is True - assert config["checkpoint"] == "next.ckpt" - await ws.send_json({"select_model": "selected.ckpt"}) - deadline = time.monotonic() + 1.0 - while dashboard._selected_model is None: - if time.monotonic() >= deadline: - pytest.fail("model selection did not reach dashboard") - await asyncio.sleep(0.01) - bundle = dashboard.take_model_selection_request() - dashboard.set_model_checkpoint(bundle) - while time.monotonic() < deadline: - message = await asyncio.wait_for(ws.receive_json(), timeout=0.25) - if ( - message.get("type") == "frame" - and message.get("checkpoint") == "selected.ckpt" - ): - return bundle - pytest.fail("selected model was absent from dashboard frame state") - - try: - bundle = asyncio.run(request_model_selection()) - assert bundle.checkpoint == selected_checkpoint.resolve() - assert not dashboard._start_requested.is_set() - finally: - dashboard.close() - - -def test_rollout_video_recorder_publishes_completed_mosaic_and_manifest(tmp_path): - config = {"enabled": True, "directory": str(tmp_path), "fps": 10} - assert validate_video_recording(config) == config - recorder = RolloutVideoRecorder(("front", "wrist"), config) - recorder.start() - frames = { - "front": np.full((48, 64, 3), 40, dtype=np.uint8), - "wrist": np.full((48, 64, 3), 180, dtype=np.uint8), - } - assert recorder.append(frames, now=10.0) - assert not recorder.append(frames, now=10.01) - assert recorder.append(frames, now=10.11) - saved = recorder.close() - - assert saved is not None - assert (tmp_path / saved["filename"]).is_file() - assert (tmp_path / saved["filename"]).stat().st_size > 0 - videos = list_rollout_videos(tmp_path) - assert len(videos) == 1 - assert videos[0]["id"] == saved["id"] - assert videos[0]["filename"] == saved["filename"] - assert videos[0]["frames"] == 2 - assert videos[0]["fps"] == 10 - assert videos[0]["duration_seconds"] == pytest.approx(0.2) - assert rollout_video_path(tmp_path, saved["id"]) == tmp_path / saved["filename"] - - -def test_dashboard_restart_dismisses_velocity_prompt_and_returns_to_ready_gate( - tmp_path, -): - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - wait_for_start=True, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - decision = [] - finished = threading.Event() - - def wait_for_velocity_choice(): - decision.append( - dashboard.choose_velocity_action( - {"arms": ["left"], "max_joint_step": 0.5, "limit": 0.4} - ) - ) - finished.set() - - async def request_restart(): - from aiohttp import ClientSession - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as ws: - await ws.receive_json() - deadline = time.monotonic() + 1.0 - while dashboard._snapshot()["velocity_prompt"] is None: - if time.monotonic() >= deadline: - pytest.fail("velocity prompt was not published") - await asyncio.sleep(0.01) - await ws.send_json({"restart": True}) - - try: - thread = threading.Thread(target=wait_for_velocity_choice) - thread.start() - asyncio.run(request_restart()) - assert finished.wait(timeout=1.0) - thread.join(timeout=1.0) - assert decision == ["restart"] - assert dashboard._snapshot()["velocity_prompt"] is None - finally: - dashboard.close() - - -def test_overlay_uses_base_to_camera_calibration_without_mutating_frame(tmp_path): - overlay = load_action_overlay(overlay_config(calibration_file(tmp_path))) - frame = np.zeros((48, 64, 3), dtype=np.uint8) - actions = np.zeros((2, 14), dtype=float) - actions[:, 2] = 1.0 - actions[:, 9] = 1.0 - actions[:, 7] = 0.1 - - output = overlay.draw(frame, actions) - - assert not np.any(frame) - assert np.any(output) - assert tuple(output[24, 32]) == (0, 190, 255) - assert tuple(output[24, 42]) == (255, 185, 80) - - -def test_dashboard_validation_requires_loopback_and_pinned_calibration(tmp_path): - preview = { - "mode": "dashboard", - "host": "127.0.0.1", - "action_overlay": overlay_config(calibration_file(tmp_path)), - } - result, overlay = validate_rollout_preview(preview, {"front_img_1"}) - assert result["port"] == 8081 - assert overlay.camera == "front_img_1" - - preview["host"] = "0.0.0.0" - with pytest.raises(ValueError, match="loopback"): - validate_rollout_preview(preview, {"front_img_1"}) - - -def test_hptflow_profile_derives_right_model_frame_from_pinned_calibration(): - profile = yaml.safe_load( - (ROOT / "egomimic/hydra_configs/robot/yam_rl2_hptflow_rollout.yaml").read_text() - ) - calibration = yaml.safe_load( - ( - ROOT / "egomimic/hydra_configs/robot/yam_rl2_agentview_extrinsics.yaml" - ).read_text() - ) - expected_right_T_left = np.asarray( - calibration["channels"]["can_follower_r"]["base_T_camera"], dtype=float - ) @ np.linalg.inv( - np.asarray( - calibration["channels"]["can_follower_l"]["base_T_camera"], dtype=float - ) - ) - adapter = profile["policy"]["adapter"] - np.testing.assert_allclose(adapter["base_T_model"]["left"], np.eye(4)) - np.testing.assert_allclose( - adapter["base_T_model"]["right"], expected_right_T_left, atol=1e-12 - ) - assert profile["max_joint_velocity"] / profile["frequency"] == 0.4 - assert "execute_steps" not in profile - inference = profile["policy"]["inference_graph"] - assert inference["input"]["history_length"] == 1 - assert inference["output"] == { - "representation": "cartesian", - "shape": [100, 14], - } - profiles = inference["profiles"] - assert all( - profiles[name]["overrides"]["inference_steps"]["default"] == 10 - for name in ("flow_time", "flow_arcvel", "flow_arcdur") - ) - assert profiles["diffusion_time"]["overrides"]["inference_steps"]["default"] == 100 - assert all( - model["overrides"]["replan_every"]["default"] == 30 - for model in profiles.values() - ) - assert profiles["diffusion_time"]["overrides"]["inference_steps"]["target"] == { - "kind": "stage_attribute", - "attribute_path": "policy.num_inference_steps", - } - assert profile["reset_on_start"] is True - assert profile["reset_home_on_restart"] is True - assert profile["video_recording"] == { - "enabled": True, - "directory": "/home/rohan/rollouts/yam_hptflow", - "fps": 12, - } - assert profile["model_browser"] == { - "enabled": True, - "root": "/home/rohan/checkpoints/EgoVerse", - } - assert set(adapter["camera_keys"]) == { - "front_img_1", - "left_wrist_img", - "right_wrist_img", - } - - -def test_dashboard_uses_space_and_places_dynamic_inference_controls_below_cameras(): - static = ROOT / "egomimic/robot/rollout_dashboard_static" - html = (static / "index.html").read_text() - javascript = (static / "app.js").read_text() - - assert "Pause rollout Space" in html - assert html.index('id="cameras"') < html.index('id="inference-controls"') - assert "event.code === 'Space'" in javascript - assert "event.key === 'v'" in javascript - assert 'id="recording-indicator"' in html - assert 'id="open-videos"' in html - assert 'id="select-model"' in html - assert 'id="model-search"' in html - assert "new URLSearchParams({path, query})" in javascript - assert 'id="current-model"' in html - assert html.index('id="current-model"') < html.index('id="status"') - assert "updateCurrentModel" in javascript - assert 'class="rollout-control-panel"' in html - assert 'class="rollout-button-grid"' in html - assert ".rollout-button-grid { display: grid;" in (static / "style.css").read_text() - assert ".recording[hidden]" in (static / "style.css").read_text() - assert "?v=9" in html - assert "inference_override" in javascript - assert "updateInferenceControls" in javascript - assert "Apply settings" in javascript - assert "current action prefix will finish" in javascript - assert "Euler" not in javascript - assert "execute-steps" not in html - assert "/api/checkpoints" in javascript - assert "event.key === 'p'" not in javascript - - -class FakeRobot: - def __init__(self): - self.arms = ["left", "right"] - self.camera_res = {"front_img_1": (2, 3)} - self.q = np.zeros(14) - self.q[[6, 13]] = 0.5 - self.commands = [] - self.home_calls = 0 - - def get_obs(self): - return { - "joint_positions": self.q.copy(), - "ee_poses": self.q.copy(), - "front_img_1": np.zeros((2, 3, 3), dtype=np.uint8), - } - - def solve_ik(self, pose, _arm): - return np.asarray(pose, dtype=float) - - def set_joints(self, command, arm): - command = np.asarray(command, dtype=float).copy() - self.commands.append((arm, command)) - offset = ARM_OFFSET[arm] - self.q[offset : offset + 7] = command - - def set_home(self): - self.home_calls += 1 - - -class View: - def __init__(self): - self.updates = 0 - self.plans = [] - self.closed = False - - def update(self, _obs): - self.updates += 1 - return None if self.updates == 1 else "q" - - def set_action_plan(self, plan, action_type): - self.plans.append((np.asarray(plan).copy(), action_type)) - - def close(self): - self.closed = True - - -class GatedView(View): - def __init__(self, controls): - super().__init__() - self.controls = iter(controls) - self.statuses = [] - - def update(self, _obs): - self.updates += 1 - return next(self.controls) - - def set_status(self, status): - self.statuses.append(status) - - def clear_action_plan(self): - self.plans.clear() - - -class VelocityChoiceView(GatedView): - def __init__(self, controls, decision): - super().__init__(controls) - self.decision = decision - self.velocity_details = [] - - def choose_velocity_action(self, details): - self.velocity_details.append(details) - return self.decision - - -class PauseView(GatedView): - def __init__(self, controls, pauses): - super().__init__(controls) - self.pauses = iter(pauses) - self.paused = False - - def update(self, obs): - self.paused = next(self.pauses) - return super().update(obs) - - def is_paused(self): - return self.paused - - -class InferenceOverrideView(GatedView): - def __init__(self, controls, overrides): - super().__init__(controls) - self.overrides = iter(overrides) - self.published = [] - - def take_inference_override_request(self): - return next(self.overrides) - - def set_inference_controls(self, controls): - self.published.append(controls) - - -class InferenceView(View): - def __init__(self): - super().__init__() - self.inference_seconds = [] - - def record_inference(self, seconds): - self.inference_seconds.append(seconds) - - -class CameraRecoveryView(GatedView): - def __init__(self, controls): - super().__init__(controls) - self.reconnect_requests = iter((True, False, False)) - - def take_camera_reconnect_request(self): - return next(self.reconnect_requests) - - -class ModelSelectionView(GatedView): - def __init__(self, controls, checkpoint): - super().__init__(controls) - self.checkpoints = iter((checkpoint, None, None)) - self.loaded = [] - self.inference_controls = [] - - def take_model_selection_request(self): - return next(self.checkpoints) - - def set_model_checkpoint(self, checkpoint): - self.loaded.append(str(checkpoint.checkpoint)) - - def set_inference_controls(self, controls): - self.inference_controls.append(controls) - - -def test_rollout_publishes_graph_plan_to_view_without_changing_command_path( - monkeypatch, -): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot, view = FakeRobot(), View() - target = np.zeros((1, 14), dtype=float) - target[:, [6, 13]] = 0.5 - policy = SimpleNamespace(action_type="cartesian", predict=lambda _obs: target) - - steps = run_rollout( - robot, - policy, - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False}, - }, - view=view, - ) - - assert steps == 1 and view.closed - assert len(view.plans) == 1 - np.testing.assert_allclose(view.plans[0][0], target) - assert view.plans[0][1] == "cartesian" - assert {arm for arm, _ in robot.commands} == {"left", "right"} - - -def test_rollout_waits_for_c_and_restart_discards_the_existing_plan(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - view = GatedView([None, "c", "r", "c", "q"]) - target = np.zeros((1, 14), dtype=float) - target[:, [6, 13]] = 0.5 - calls = 0 - - def predict(_obs): - nonlocal calls - calls += 1 - return target - - steps = run_rollout( - robot, - SimpleNamespace(action_type="cartesian", predict=predict), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False, "wait_for_start": True}, - }, - view=view, - ) - - assert calls == 2 - assert steps == 1 # The restart begins a new rollout step counter. - assert len(robot.commands) == 4 # Two safe paired-arm commands total. - assert view.closed - assert any("Ready" in status for status in view.statuses) - - -def test_rollout_homes_on_startup_and_restart_when_enabled(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - view = GatedView(["r", "q"]) - policy = SimpleNamespace( - action_type="joints", - predict=lambda _obs: pytest.fail("policy should wait for c"), - ) - - steps = run_rollout( - robot, - policy, - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "reset_on_start": True, - "reset_home_on_restart": True, - "preview": {"enabled": False, "wait_for_start": True}, - }, - view=view, - ) - - assert steps == 0 - assert robot.home_calls == 2 - assert not robot.commands - assert sum("Resetting YAM" in status for status in view.statuses) == 2 - - -def test_rollout_camera_reconnect_preserves_process_and_requires_c(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - robot.camera_reconnects = 0 - - def reconnect_cameras(): - robot.camera_reconnects += 1 - - robot.reconnect_cameras = reconnect_cameras - view = CameraRecoveryView([None, "c", "q"]) - target = np.zeros((1, 14), dtype=float) - target[:, [6, 13]] = 0.5 - - steps = run_rollout( - robot, - SimpleNamespace(action_type="joints", predict=lambda _obs: target), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False, "wait_for_start": True}, - }, - view=view, - ) - - assert steps == 1 - assert robot.camera_reconnects == 1 - assert len(robot.commands) == 2 - assert any("Reconnecting RGB cameras" in status for status in view.statuses) - - -def test_rollout_model_selection_holds_and_requires_a_fresh_start( - monkeypatch, tmp_path -): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - checkpoint = tmp_path / "next.ckpt" - checkpoint.write_bytes(b"weights") - training_config = tmp_path / "resolved-config.yaml" - training_config.write_text("model: {}\n") - normalizer = tmp_path / "norm_stats.json" - normalizer.write_text("{}\n") - bundle = CheckpointBrowser(tmp_path).resolve_bundle("next.ckpt") - robot = FakeRobot() - view = ModelSelectionView([None, "c", "q"], bundle) - target = np.zeros((1, 14), dtype=float) - target[:, [6, 13]] = 0.5 - replacement = SimpleNamespace(action_type="joints", predict=lambda _obs: target) - loaded = [] - monkeypatch.setattr( - "egomimic.robot.rollout.load_policy", - lambda config: loaded.append(config) or replacement, - ) - - steps = run_rollout( - robot, - SimpleNamespace( - action_type="joints", predict=lambda _obs: pytest.fail("old model ran") - ), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False, "wait_for_start": True}, - "policy": { - "kind": "graph", - "checkpoint": "/old/model.ckpt", - "training_config": "/old/resolved-config.yaml", - "normalizer_path": "/old/norm_stats.json", - "inference_config": "/old/inference-config.yaml", - }, - }, - view=view, - ) - - assert steps == 1 - assert loaded == [ - { - "kind": "graph", - "checkpoint": str(checkpoint), - "training_config": str(training_config), - "normalizer_path": str(normalizer), - } - ] - assert view.loaded == [str(checkpoint)] - assert view.inference_controls == [{}] - assert len(robot.commands) == 4 # paired hold, then paired new-model command - - -def test_rollout_pause_holds_measured_joints_and_discards_policy_queue(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - view = PauseView(controls=[None, None, None, "q"], pauses=[False, True, True, True]) - target = np.zeros((1, 14), dtype=float) - target[:, [6, 13]] = 0.5 - calls = 0 - - def predict(_obs): - nonlocal calls - calls += 1 - return target - - steps = run_rollout( - robot, - SimpleNamespace(action_type="joints", predict=predict), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False}, - }, - view=view, - ) - - assert steps == 1 - assert calls == 1 - assert len(robot.commands) == 4 # First paired policy target, then paired hold. - assert any("Paused" in status for status in view.statuses) - - -def test_rollout_applies_policy_owned_override_at_natural_replan_boundary( - monkeypatch, -): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - view = InferenceOverrideView( - [None, None, None, None, "q"], - [None, {"replan_every": 1}, None, None, None], - ) - target = np.zeros((3, 14), dtype=float) - target[:, [6, 13]] = 0.5 - - class RuntimePolicy: - action_type = "cartesian" - - def __init__(self): - self.calls = 0 - self.replan_every = 3 - self.applied_after_calls = None - - def predict(self, _obs): - self.calls += 1 - return target - - def execution_plan(self, prediction): - return prediction[: self.replan_every] - - def apply_inference_overrides(self, overrides): - self.applied_after_calls = self.calls - self.replan_every = overrides["replan_every"] - return runtime_controls(replan_every=self.replan_every) - - def inference_controls(self): - return runtime_controls(replan_every=self.replan_every) - - policy = RuntimePolicy() - - steps = run_rollout( - robot, - policy, - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False}, - }, - view=view, - ) - - assert steps == 4 - assert policy.calls == 2 - assert policy.applied_after_calls == 1 - assert view.published[-1]["replan_every"]["value"] == 1 - assert any("queued" in status.lower() for status in view.statuses) - - -def test_rollout_records_command_ready_inference_latency(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot, view = FakeRobot(), InferenceView() - target = np.zeros((1, 14), dtype=float) - target[:, [6, 13]] = 0.5 - - steps = run_rollout( - robot, - SimpleNamespace(action_type="cartesian", predict=lambda _obs: target), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False}, - }, - view=view, - ) - - assert steps == 1 - assert len(view.inference_seconds) == 1 - assert view.inference_seconds[0] >= 0 - - -def test_rollout_resamples_a_velocity_unsafe_plan_before_commanding(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - view = GatedView([None, None, "q"]) - unsafe = np.zeros((1, 14), dtype=float) - unsafe[:, [0, 7]] = 0.2 - unsafe[:, [6, 13]] = 0.5 - safe = np.zeros((1, 14), dtype=float) - safe[:, [6, 13]] = 0.5 - plans = iter((unsafe, safe)) - - steps = run_rollout( - robot, - SimpleNamespace(action_type="joints", predict=lambda _obs: next(plans)), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "max_velocity_replans": 1, - "preview": {"enabled": False}, - }, - view=view, - ) - - assert steps == 1 - assert len(robot.commands) == 2 # Only the second, paired safe plan ran. - assert any("Rejected velocity-unsafe" in status for status in view.statuses) - - -def test_rollout_executes_velocity_unsafe_pair_only_after_explicit_choice(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - view = VelocityChoiceView([None, "q"], "execute") - unsafe = np.zeros((1, 14), dtype=float) - unsafe[:, [0, 7]] = 0.2 - unsafe[:, [6, 13]] = 0.5 - - steps = run_rollout( - robot, - SimpleNamespace(action_type="joints", predict=lambda _obs: unsafe), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "max_velocity_replans": 1, - "preview": {"enabled": False}, - }, - view=view, - ) - - assert steps == 1 - assert len(robot.commands) == 2 - assert view.velocity_details == [ - {"arms": ["left", "right"], "max_joint_step": 0.2, "limit": 1 / 30} - ] - assert any("operator-approved" in status for status in view.statuses) - - -def test_rollout_continues_after_repeated_explicit_velocity_overrides(monkeypatch): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - robot = FakeRobot() - view = VelocityChoiceView([None, None, None, "q"], "execute") - unsafe = np.zeros((3, 14), dtype=float) - unsafe[:, 0] = [0.2, 0.4, 0.6] - unsafe[:, 7] = [0.2, 0.4, 0.6] - unsafe[:, [6, 13]] = 0.5 - - steps = run_rollout( - robot, - SimpleNamespace(action_type="joints", predict=lambda _obs: unsafe), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "max_velocity_replans": 1, - "preview": {"enabled": False}, - }, - view=view, - ) - - assert steps == 3 - assert len(robot.commands) == 6 - assert len(view.velocity_details) == 3 - assert view.statuses[-1] == "Running" - - -def test_dashboard_keeps_safety_status_visible_while_repredicting(tmp_path): - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - wait_for_start=True, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - try: - dashboard.request_start() - dashboard.set_status("Unreachable IK target; plan discarded") - dashboard.update({}) - assert dashboard._snapshot()["status"].startswith("Unreachable IK") - finally: - dashboard.close() - - -def test_a_new_browser_tab_supersedes_the_stale_dashboard_tab(tmp_path): - """Tabs left open by finished runs must not keep pulling a live stream.""" - dashboard = RolloutDashboard( - ("front_img_1",), - host="127.0.0.1", - port=available_loopback_port(), - open_browser=False, - action_overlay=overlay_config(calibration_file(tmp_path)), - ) - - async def two_tabs(): - from aiohttp import ClientSession, WSMsgType - - async with ClientSession() as session: - async with session.ws_connect(f"{dashboard.url}/ws") as stale: - assert (await stale.receive_json())["type"] == "config" - async with session.ws_connect(f"{dashboard.url}/ws") as current: - assert (await current.receive_json())["type"] == "config" - deadline = time.monotonic() + 5.0 - while time.monotonic() < deadline: - message = await stale.receive() - if message.type in ( - WSMsgType.CLOSE, - WSMsgType.CLOSED, - WSMsgType.CLOSING, - ): - break - assert not current.closed - return stale.close_code - - try: - assert asyncio.run(two_tabs()) == SUPERSEDED_CLOSE_CODE - finally: - dashboard.close() - - -def test_failed_model_selection_locks_rollout_and_never_runs_old_policy( - monkeypatch, tmp_path -): - monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None) - checkpoint = tmp_path / "broken.ckpt" - checkpoint.write_bytes(b"weights") - training_config = tmp_path / "resolved-config.yaml" - training_config.write_text("model: {}\n") - normalizer = tmp_path / "norm_stats.json" - normalizer.write_text("{}\n") - bundle = CheckpointBrowser(tmp_path).resolve_bundle("broken.ckpt") - robot = FakeRobot() - view = ModelSelectionView([None, "c", "q"], bundle) - - def fail_load(_config): - raise RuntimeError("checkpoint restore failed") - - monkeypatch.setattr("egomimic.robot.rollout.load_policy", fail_load) - steps = run_rollout( - robot, - SimpleNamespace( - action_type="joints", - predict=lambda _obs: pytest.fail("previous model must remain disarmed"), - ), - { - "frequency": 30, - "max_steps": 4, - "max_joint_velocity": 1.0, - "preview": {"enabled": False, "wait_for_start": True}, - "policy": { - "kind": "graph", - "checkpoint": "/old/model.ckpt", - "training_config": "/old/resolved-config.yaml", - "normalizer_path": "/old/norm_stats.json", - "inference_config": "/old/inference-config.yaml", - }, - }, - view=view, - ) - - assert steps == 0 - assert any("MODEL LOAD FAILED" in status for status in view.statuses) - assert any("rollout locked" in status for status in view.statuses) - - -@pytest.mark.parametrize("max_steps", [0, -1, True, 1.5, "3000"]) -def test_rollout_rejects_invalid_session_limits(max_steps): - with pytest.raises(ValueError, match="positive integer or null"): - run_rollout( - FakeRobot(), - SimpleNamespace(action_type="joints"), - {"frequency": 30, "max_steps": max_steps, "max_joint_velocity": 1.0}, - ) - - -def test_unlimited_rollout_exits_on_operator_quit(): - view = SimpleNamespace(update=lambda _obs: "q", close=lambda: None) - assert ( - run_rollout( - FakeRobot(), - SimpleNamespace(action_type="joints"), - { - "frequency": 30, - "max_steps": None, - "max_joint_velocity": 1.0, - "preview": {"enabled": False, "wait_for_start": True}, - }, - view=view, - ) - == 0 - ) diff --git a/tests/test_rotation_modes.py b/tests/test_rotation_modes.py deleted file mode 100644 index 788b37944c..0000000000 --- a/tests/test_rotation_modes.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Rotation-representation plumbing shared by Eva and Human transform lists.""" - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation as R - -from egomimic.rldb.embodiment.eva import Eva -from egomimic.rldb.embodiment.human import Human -from egomimic.rldb.zarr.action_chunk_transforms import ( - PadGripperZeros, - XYZWXYZ_to_XYZRot6D, - XYZWXYZ_to_XYZYPR, - transforms_for_rotation_mode, -) -from egomimic.utils.pose_utils import ( - _matrix_to_xyzrot6d, - _xyzrot6d_to_matrix, - _xyzwxyz_to_matrix, -) - - -def _random_poses(n: int, seed: int = 0) -> np.ndarray: - rng = np.random.default_rng(seed) - quats_xyzw = R.random(n, random_state=seed).as_quat() - wxyz = np.concatenate([quats_xyzw[:, 3:4], quats_xyzw[:, :3]], axis=-1) - return np.concatenate([rng.normal(size=(n, 3)), wxyz], axis=-1) - - -def test_rot6d_round_trips_through_matrix(): - mats = _xyzwxyz_to_matrix(_random_poses(8, seed=1)) - recovered = _xyzrot6d_to_matrix(_matrix_to_xyzrot6d(mats)) - np.testing.assert_allclose(recovered, mats, atol=1e-10) - - -def test_rot6d_stores_first_two_columns_column_major(): - mats = _xyzwxyz_to_matrix(_random_poses(3, seed=2)) - rot6d = _matrix_to_xyzrot6d(mats) - np.testing.assert_allclose(rot6d[:, 3:6], mats[:, :3, 0]) - np.testing.assert_allclose(rot6d[:, 6:9], mats[:, :3, 1]) - - -def test_rot6d_gram_schmidt_orthonormalises_a_perturbed_input(): - mats = _xyzwxyz_to_matrix(_random_poses(4, seed=3)) - rot6d = _matrix_to_xyzrot6d(mats) - rot6d[:, 3:9] += 1e-3 # a denoiser never emits an exactly orthonormal pair - rotations = _xyzrot6d_to_matrix(rot6d)[:, :3, :3] - identity = np.einsum("bij,bkj->bik", rotations, rotations) - np.testing.assert_allclose( - identity, np.broadcast_to(np.eye(3), (4, 3, 3)), atol=1e-9 - ) - np.testing.assert_allclose(np.linalg.det(rotations), np.ones(4), atol=1e-9) - - -@pytest.mark.parametrize( - "rotation_mode, types, width", - [ - ("quat", (), 7), - ("euler", (XYZWXYZ_to_XYZYPR,), 6), - ("6D", (XYZWXYZ_to_XYZRot6D,), 9), - ], -) -def test_transforms_for_rotation_mode_selects_and_sizes(rotation_mode, types, width): - transforms = transforms_for_rotation_mode( - keys=["pose"], rotation_mode=rotation_mode - ) - assert tuple(type(t) for t in transforms) == types - - batch = {"pose": _random_poses(5, seed=4)} - for transform in transforms: - batch = transform.transform(batch) - assert batch["pose"].shape == (5, width) - - -def test_transforms_for_rotation_mode_rejects_unknown_mode(): - with pytest.raises(ValueError, match="unknown rotation_mode"): - transforms_for_rotation_mode(keys=["pose"], rotation_mode="ypr") - - -def test_rot6d_transform_accepts_a_single_unbatched_pose(): - batch = {"pose": _random_poses(1, seed=5)[0]} - out = XYZWXYZ_to_XYZRot6D(keys=["pose"]).transform(batch) - assert out["pose"].shape == (9,) - - -def test_rot6d_transform_rejects_a_non_quaternion_width(): - with pytest.raises(ValueError, match="shape"): - XYZWXYZ_to_XYZRot6D(keys=["pose"]).transform({"pose": np.zeros((4, 6))}) - - -@pytest.mark.parametrize("pose_dim, expected", [(6, 14), (7, 16), (9, 20)]) -def test_pad_gripper_zeros_follows_pose_dim(pose_dim, expected): - chunk = np.arange(3 * 2 * pose_dim, dtype=np.float64).reshape(3, 2 * pose_dim) - out = PadGripperZeros(action_key="a", pose_dim=pose_dim).transform({"a": chunk}) - padded = out["a"] - assert padded.shape == (3, expected) - # A zero slot lands at the end of each arm, and the poses are untouched. - np.testing.assert_allclose(padded[:, pose_dim], 0.0) - np.testing.assert_allclose(padded[:, -1], 0.0) - np.testing.assert_allclose(padded[:, :pose_dim], chunk[:, :pose_dim]) - np.testing.assert_allclose(padded[:, pose_dim + 1 : -1], chunk[:, pose_dim:]) - - -def test_pad_gripper_zeros_reports_the_width_it_wanted(): - with pytest.raises(ValueError, match="last-dim 18"): - PadGripperZeros(action_key="a", pose_dim=9).transform({"a": np.zeros((2, 12))}) - - -@pytest.mark.parametrize("rotation_mode", ["euler", "quat", "6D"]) -@pytest.mark.parametrize("coord_frame", ["camframe", "eef_frame"]) -def test_eva_builds_every_rotation_mode(coord_frame, rotation_mode): - transforms = Eva.get_transform_list( - action_mode="cartesian", coord_frame=coord_frame, rotation_mode=rotation_mode - ) - assert transforms - kinds = {type(t) for t in transforms} - assert (XYZWXYZ_to_XYZRot6D in kinds) == (rotation_mode == "6D") - assert (XYZWXYZ_to_XYZYPR in kinds) == (rotation_mode == "euler") - - -def test_eva_rejects_unknown_axes(): - with pytest.raises(ValueError, match="unknown coord_frame"): - Eva.get_transform_list(coord_frame="headframe") - with pytest.raises(ValueError, match="unknown action_mode"): - Eva.get_transform_list(action_mode="keypoints") - - -@pytest.mark.parametrize("rotation_mode", ["euler", "quat", "6D"]) -@pytest.mark.parametrize("action_mode", ["cartesian", "keypoints"]) -def test_human_builds_every_rotation_mode(action_mode, rotation_mode): - transforms = Human.get_transform_list( - action_mode=action_mode, coord_frame="camframe", rotation_mode=rotation_mode - ) - assert transforms - kinds = {type(t) for t in transforms} - assert (XYZWXYZ_to_XYZRot6D in kinds) == (rotation_mode == "6D") - - -@pytest.mark.parametrize( - "rotation_mode, pose_dim", [("euler", 6), ("quat", 7), ("6D", 9)] -) -def test_human_gripper_padding_matches_the_rotation_width(rotation_mode, pose_dim): - transforms = Human.get_transform_list( - action_mode="cartesian_gripper_padded", rotation_mode=rotation_mode - ) - pads = [t for t in transforms if isinstance(t, PadGripperZeros)] - # One for the action chunk, one for the proprio pose. - assert [t.pose_dim for t in pads] == [pose_dim, pose_dim] - assert {t.action_key for t in pads} == { - "actions_cartesian", - "observations.state.ee_pose", - } - - -def test_human_rejects_an_unknown_action_mode(): - with pytest.raises(ValueError, match="Unsupported action_mode"): - Human.get_transform_list(action_mode="cartesian_padded") diff --git a/tests/test_shared_cartesian_dp.py b/tests/test_shared_cartesian_dp.py deleted file mode 100644 index ca11989d07..0000000000 --- a/tests/test_shared_cartesian_dp.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest -import torch - -from egomimic.eval.yam_human_cartesian_dp_eval import YamHumanCartesianDPEval -from egomimic.pipeline.stages_matched_dp import ( - SharedNativeDPStage, - shared_native_transformers, -) -from egomimic.rldb.zarr.action_chunk_transforms import PadGripperZeros - - -class IdentityNormalizer: - def unnormalize(self, batch, embodiment_id): - return batch - - -def test_shared_cartesian_head_and_optimizer_sampling_for_both_domains(): - models = shared_native_transformers(16, 144, 1, 8, 0.0, "shared_cartesian14") - assert models["yam_bimanual"] is models["human_bimanual"] - stage = SharedNativeDPStage( - condition_dim=16, - inference_steps=2, - denoiser="transformer", - transformer_width=144, - transformer_depth=1, - transformer_heads=8, - transformer_dropout=0.0, - action_contract="shared_cartesian14", - ) - opt = torch.optim.AdamW(stage.parameters(), lr=1e-4) - for domain in [7, 3]: - opt.zero_grad(set_to_none=True) - out = stage.execute( - { - "embodiment": domain, - "condition": torch.randn(2, 16), - "target": torch.randn(2, 100, 14), - }, - mode="train", - ) - loss = out["loss/diffusion_noise"] - assert torch.isfinite(loss) - loss.backward() - opt.step() - stage.eval() - with torch.no_grad(): - pred = stage.execute( - {"embodiment": domain, "condition": torch.randn(2, 16)}, - mode="inference", - )["pred_action"] - assert pred.shape == (2, 100, 14) and torch.isfinite(pred).all() - - -def test_human_padding_preserves_pose_and_inserts_two_zero_grippers(): - pose = torch.randn(2, 100, 12) - padded = PadGripperZeros().transform({"actions_cartesian": pose})[ - "actions_cartesian" - ] - assert padded.shape == (2, 100, 14) - assert torch.equal(padded[..., :6], pose[..., :6]) - assert torch.equal(padded[..., 7:13], pose[..., 6:]) - assert padded[..., [6, 13]].count_nonzero() == 0 - - -@pytest.mark.parametrize("label,domain", [("yam_bimanual", 7), ("human_bimanual", 3)]) -def test_common14_metrics_accept_each_embodiment(label, domain): - e = YamHumanCartesianDPEval(limit_val_batches=1.0, energy_score_enabled=False) - e.bind_data_context(normalizer=IdentityNormalizer()) - values = e._energy_values( - torch.zeros(32, 2, 100, 14), torch.zeros(2, 100, 14), domain, label - ) - assert all(torch.isfinite(v).all() for v in values.values()) - assert e._native_residual( - torch.zeros(2, 100, 14), torch.zeros(2, 100, 14), None - ).shape == (2, 100, 14) - - -def test_common14_rejects_old_keypoint_contract(): - with pytest.raises(ValueError, match="Cartesian actions"): - YamHumanCartesianDPEval( - limit_val_batches=1.0, - action_keys_by_embodiment={"human_bimanual": "actions_keypoints"}, - ) diff --git a/tests/test_shared_robot_components.py b/tests/test_shared_robot_components.py deleted file mode 100644 index 479cbf7390..0000000000 --- a/tests/test_shared_robot_components.py +++ /dev/null @@ -1,97 +0,0 @@ -from types import SimpleNamespace - -import pytest -import torch - -from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval -from egomimic.rldb.zarr import zarr_dataset_multi as data - - -def test_camera_mse_preserves_source_semantics_at_angle_wrap(): - from hydra import compose, initialize_config_dir - - from egomimic.eval.cartesian_metrics import cartesian_metrics - from tests.test_retained_recipe_steps import CONFIGS - - target = torch.zeros(1, 2, 14) - prediction = target.clone() - target[..., 3] = torch.pi - 0.01 - prediction[..., 3] = -torch.pi + 0.01 - for recipe in ("eval_hpt", "eval_pi"): - with initialize_config_dir(version_base=None, config_dir=str(CONFIGS)): - cfg = compose( - config_name="train_zarr_cartesian", overrides=[f"evaluator={recipe}"] - ) - assert cfg.evaluator.camera_mse_wrap is False - values = cartesian_metrics( - prediction, - target, - distribution=False, - wrap_angles=cfg.evaluator.camera_mse_wrap, - ) - torch.testing.assert_close( - values["paired_mse_avg"], (prediction - target).square().mean() - ) - wrapped = cartesian_metrics( - prediction, target, distribution=False, wrap_angles=True - ) - assert wrapped["paired_mse_avg"].item() == pytest.approx(0.02**2 / 14, rel=1e-4) - - -def test_vendor_metadata_is_canonicalized_without_editing_episode(monkeypatch): - metadata = {"embodiment": "MECKA_BIMANUAL", "total_frames": 3} - reader = SimpleNamespace(metadata=metadata, _collect_keys=lambda: []) - monkeypatch.setattr(data, "ZarrEpisode", lambda path: reader) - episode = data.ZarrDataset.__new__(data.ZarrDataset) - episode.episode_path = "/unused" - episode.init_episode() - assert episode.embodiment == "human_bimanual" - assert metadata["embodiment"] == "MECKA_BIMANUAL" - - -def test_pose_and_sample_metrics_need_only_graph_prediction_contract(tmp_path): - class Graph: - def forward_eval(self, batch): - return { - source: {"pred_action": torch.randn_like(row["actions_cartesian"])} - for source, row in batch.items() - } - - class Normalizer: - def unnormalize(self, values, embodiment): - return {key: value * 2 for key, value in values.items()} - - logged = [] - evaluator = BimanualCartesianEval( - pose_metrics=True, - rkl_samples=3, - viz_every_n_epochs=0, - group_options={"train_viz": {"rkl_samples": 1, "limit_batches": 1}}, - ) - evaluator.trainer = SimpleNamespace( - default_root_dir=str(tmp_path), - current_epoch=0, - is_global_zero=True, - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **kw: logged.append(metrics) - ), - ) - evaluator.model = Graph() - evaluator.bind_data_context(normalizer=Normalizer()) - batch = { - "opaque-source": { - "embodiment": torch.tensor([7]), - "actions_cartesian": torch.zeros(1, 4, 14), - "observations.state.ee_pose": torch.zeros(1, 14), - } - } - values = evaluator.on_validation_step(batch, 0) - assert values["Valid/Native_MSE"] == 4 * values["Valid/MSE"] - assert values["Valid/yam_bimanual_actions_cartesian_sample_diversity_M3"] > 0 - assert all(torch.isfinite(v) for v in values.values()) - evaluator.set_validation_group("train_viz") - values = evaluator.on_validation_step(batch, 0) - assert all(k.startswith("Valid_train_viz/") for k in values) - assert not any("reverse_kl" in k for k in values) - assert evaluator.on_validation_step(batch, 1) == {} - assert len(logged) == 2 diff --git a/tests/test_slurm_save_only_checkpoint.py b/tests/test_slurm_save_only_checkpoint.py deleted file mode 100644 index c2d543952f..0000000000 --- a/tests/test_slurm_save_only_checkpoint.py +++ /dev/null @@ -1,271 +0,0 @@ -import hashlib -import json -import os -import threading -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -import torch.distributed as dist -import torch.multiprocessing as mp - -from egomimic.utils.slurm_requeue import ( - SaveOnlySignalCheckpoint, - _exclusive_reservation, - _publish_hardlink_no_overwrite, -) - - -class _GlooStrategy: - @property - def world_size(self): - return dist.get_world_size() - - def broadcast(self, value, src=0): - values = [value] - dist.broadcast_object_list(values, src=src) - return values[0] - - -class _FakeDistributedTrainer: - def __init__(self, checkpoint_dir, rank): - self.current_epoch = 7 - self.global_step = 1234 - self.is_global_zero = rank == 0 - self.strategy = _GlooStrategy() - self._checkpoint_dir = Path(checkpoint_dir) - self._rank = rank - - def save_checkpoint(self, path, weights_only=None): - Path(self._checkpoint_dir / f"rank-{self._rank}-save-call.json").write_text( - json.dumps({"path": path, "weights_only": weights_only}) - ) - if self.is_global_zero: - temporary = Path(f"{path}.tmp") - torch.save( - { - "global_step": self.global_step, - "epoch": self.current_epoch, - "state_dict": {"weight": torch.tensor([1.0])}, - "optimizer_states": [{"state": {}, "param_groups": []}], - "lr_schedulers": [{"_last_lr": [3.0e-5]}], - "loops": {"fit_loop": {"state_dict": {}}}, - "ema_state_dict": {"weight": torch.tensor([1.0])}, - }, - temporary, - ) - os.replace(temporary, path) - dist.barrier() - - -def _gloo_save_worker(rank, world_size, init_file, checkpoint_dir): - dist.init_process_group( - "gloo", - init_method=f"file://{init_file}", - rank=rank, - world_size=world_size, - ) - try: - callback = SaveOnlySignalCheckpoint(checkpoint_dir) - callback._request_checkpoint(callback.save_signal.value, None) - trainer = _FakeDistributedTrainer(checkpoint_dir, rank) - callback.on_validation_batch_end( - trainer, - SimpleNamespace(), - None, - None, - 0, - 0, - ) - dist.barrier() - finally: - dist.destroy_process_group() - - -@pytest.mark.skipif(not dist.is_gloo_available(), reason="Gloo is unavailable") -def test_sigusr2_request_saves_full_state_on_both_ranks_during_validation( - monkeypatch, tmp_path -): - monkeypatch.setenv("SLURM_JOB_ID", "9876") - monkeypatch.setenv("SLURM_RESTART_COUNT", "3") - checkpoint_dir = tmp_path / "checkpoints" - checkpoint_dir.mkdir() - init_file = tmp_path / "gloo-init" - - mp.spawn( - _gloo_save_worker, - args=(2, str(init_file), str(checkpoint_dir)), - nprocs=2, - join=True, - ) - - expected = checkpoint_dir / ( - "signal-job=9876-restart=003-request=000-epoch=7-step=1234.ckpt" - ) - proof_path = expected.with_suffix(expected.suffix + ".save-only.json") - partial_path = expected.with_name(f".{expected.name}.partial") - reservation_path = expected.with_name(f".{expected.name}.reserve") - assert expected.is_file() - assert proof_path.is_file() - assert not partial_path.exists() - assert not reservation_path.exists() - for rank in range(2): - call = json.loads((checkpoint_dir / f"rank-{rank}-save-call.json").read_text()) - assert call == {"path": str(partial_path), "weights_only": False} - - checkpoint = torch.load(expected, map_location="cpu", weights_only=False) - assert checkpoint["global_step"] == 1234 - assert checkpoint["optimizer_states"] - assert checkpoint["lr_schedulers"] - assert checkpoint["loops"] - assert checkpoint["ema_state_dict"] - - proof = json.loads(proof_path.read_text()) - digest = hashlib.sha256(expected.read_bytes()).hexdigest() - assert proof["status"] == "FULL_STATE_CHECKPOINT_PUBLISHED_UNVALIDATED" - assert proof["checkpoint_path"] == str(expected) - assert proof["checkpoint_sha256"] == digest - assert proof["global_step"] == 1234 - assert proof["restart_count"] == 3 - assert proof["save_signal"] == "SIGUSR2" - assert proof["hook"] == "on_validation_batch_end" - assert proof["world_size"] == 2 - assert proof["all_ranks_save_checkpoint_returned"] is True - assert proof["weights_only"] is False - assert proof["publish_atomic_no_overwrite"] is True - assert proof["partial_path"] == str(partial_path) - assert proof["partial_path_absent"] is True - assert proof["reservation_path"] == str(reservation_path) - assert proof["reservation_path_absent"] is True - assert proof["application_validation_status"] == "NOT_RUN_BY_CALLBACK" - assert proof["scontrol_invoked"] is False - assert proof["wandb_finalize_invoked"] is False - - -def test_uneven_no_signal_hook_counts_do_no_collective_or_save(tmp_path): - class _NoCollectiveStrategy: - world_size = 2 - - @staticmethod - def broadcast(*_args, **_kwargs): - raise AssertionError("no-signal hook entered a collective") - - class _NoSaveTrainer: - current_epoch = 7 - global_step = 1234 - strategy = _NoCollectiveStrategy() - is_global_zero = True - - @staticmethod - def save_checkpoint(*_args, **_kwargs): - raise AssertionError("no-signal hook attempted a checkpoint") - - rank_zero_callback = SaveOnlySignalCheckpoint(tmp_path) - rank_one_callback = SaveOnlySignalCheckpoint(tmp_path) - trainer = _NoSaveTrainer() - for batch_index in range(3): - rank_zero_callback.on_train_batch_end( - trainer, - SimpleNamespace(), - None, - None, - batch_index, - ) - for batch_index in range(5): - rank_one_callback.on_validation_batch_end( - trainer, - SimpleNamespace(), - None, - None, - batch_index, - 0, - ) - - assert list(tmp_path.iterdir()) == [] - - -def test_concurrent_reservation_allows_exactly_one_owner(tmp_path): - reservation = tmp_path / ".checkpoint.reserve" - barrier = threading.Barrier(2) - - def attempt(owner): - barrier.wait() - try: - _exclusive_reservation(reservation, {"owner": owner}) - except FileExistsError: - return ("collision", owner) - return ("reserved", owner) - - with ThreadPoolExecutor(max_workers=2) as executor: - results = list(executor.map(attempt, (0, 1))) - - assert [status for status, _owner in results].count("reserved") == 1 - assert [status for status, _owner in results].count("collision") == 1 - winner = next(owner for status, owner in results if status == "reserved") - assert json.loads(reservation.read_text()) == {"owner": winner} - - -def test_concurrent_hardlink_publish_never_overwrites_winner(tmp_path): - partials = [tmp_path / ".a.partial", tmp_path / ".b.partial"] - partials[0].write_bytes(b"candidate-a") - partials[1].write_bytes(b"candidate-b") - final = tmp_path / "checkpoint.ckpt" - barrier = threading.Barrier(2) - - def attempt(index): - barrier.wait() - try: - _publish_hardlink_no_overwrite(partials[index], final) - except FileExistsError: - return ("collision", index) - return ("published", index) - - with ThreadPoolExecutor(max_workers=2) as executor: - results = list(executor.map(attempt, (0, 1))) - - assert [status for status, _index in results].count("published") == 1 - assert [status for status, _index in results].count("collision") == 1 - winner = next(index for status, index in results if status == "published") - assert final.read_bytes() == partials[winner].read_bytes() - winning_bytes = final.read_bytes() - with pytest.raises(FileExistsError): - _publish_hardlink_no_overwrite(partials[1 - winner], final) - assert final.read_bytes() == winning_bytes - - -def test_same_attempt_same_step_never_overwrites_existing_checkpoint( - monkeypatch, tmp_path -): - monkeypatch.setenv("SLURM_JOB_ID", "9876") - monkeypatch.setenv("SLURM_RESTART_COUNT", "3") - checkpoint_dir = tmp_path / "checkpoints" - checkpoint_dir.mkdir() - existing = checkpoint_dir / ( - "signal-job=9876-restart=003-request=000-epoch=7-step=1234.ckpt" - ) - existing.write_bytes(b"immutable") - - class _SingleStrategy: - world_size = 1 - - @staticmethod - def broadcast(value, src=0): - return value - - trainer = SimpleNamespace( - current_epoch=7, - global_step=1234, - strategy=_SingleStrategy(), - is_global_zero=True, - ) - callback = SaveOnlySignalCheckpoint(checkpoint_dir) - callback._request_checkpoint(callback.save_signal.value, None) - - with pytest.raises(RuntimeError, match="refusing to overwrite"): - callback._service(trainer, "test") - - assert existing.read_bytes() == b"immutable" - assert not existing.with_name(f".{existing.name}.partial").exists() - assert not existing.with_name(f".{existing.name}.reserve").exists() diff --git a/tests/test_speed_config.py b/tests/test_speed_config.py deleted file mode 100644 index 9d5b50cf51..0000000000 --- a/tests/test_speed_config.py +++ /dev/null @@ -1,77 +0,0 @@ -from pathlib import Path - -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - - -def test_pair_config_diff_is_only_encoding(monkeypatch): - monkeypatch.setenv("PUSHSHAPES_USOCKET_ROOT", "/unused/u") - monkeypatch.setenv("PUSHSHAPES_CHAIN_GRIPPER_ROOT", "/unused/c") - root = Path(__file__).resolve().parents[1] / "egomimic/hydra_configs" - configs = [] - with initialize_config_dir(config_dir=str(root), version_base="1.3"): - for encoding in ("scalar", "fourier"): - c = compose( - config_name="train_zarr_cartesian", - overrides=[ - "hydra/launcher=basic", - "+experiment=pusht/action_flow_cotrain_uc_multiplier_interpolation", - f"speed_diagnostic.encoding={encoding}", - ], - ) - assert c.model.hidden_dim == 696 - assert c.model.pipeline._recursive_ is False - assert c.trainer.max_steps == 80000 - assert ( - c.data.train_datasets.pushshapes_sim_u_socket.resolver.key_map.rates - == [1.0] - ) - assert ( - c.data.train_datasets.pushshapes_sim_chain_gripper.resolver.key_map.rates - == [1, 1.25, 1.5, 1.75, 2] - ) - # Normalize the intentional differing value before comparison. - c.speed_diagnostic.encoding = "scalar" - configs.append(OmegaConf.to_container(c, resolve=False)) - assert configs[0] == configs[1] - - -def test_factory_places_speed_inside_diagnostic_prefix(monkeypatch): - import hydra.utils - - from egomimic.pipeline.core import Stage - from egomimic.pipeline.stages_speed import ( - SharedSpeedCondition, - build_multiplier_conditioned_pipeline, - ) - - class Marker(Stage): - def __init__(self, config): - super().__init__() - self.config = config - - monkeypatch.setattr(hydra.utils, "instantiate", lambda c: Marker(c)) - configs = OmegaConf.create( - [ - {"_target_": "unused.FusedObsEncoder"}, - {"_target_": "unused.RoutedContentEncoderStage"}, - {"_target_": "unused.LatentBridgeStage"}, - {"_target_": "unused.ConditionalVelocityStage"}, - ] - ) - pipeline = build_multiplier_conditioned_pipeline(configs, device="cpu") - stages = pipeline.pipeline.stages - assert isinstance(stages[1], SharedSpeedCondition) - assert stages[2].config["_target_"].endswith("RoutedContentEncoderStage") - assert stages[3].config["condition_key"] == "speed_condition" - assert stages[4].config["inference_condition_key"] == "speed_condition" - - -def test_measured_speed_recipes_are_not_shipped(): - root = Path(__file__).resolve().parents[1] / "egomimic/hydra_configs" - for name in ( - "experiment/pusht/action_flow_cotrain_uc_speed_interpolation.yaml", - "experiment/e1/yam_human_keypoints_speed_h816_private512_s42.yaml", - "data/e1/yam_human_keypoints_speed_proportional_val01.yaml", - ): - assert not (root / name).exists() diff --git a/tests/test_speed_retiming_diagnostic.py b/tests/test_speed_retiming_diagnostic.py deleted file mode 100644 index ba1f02022d..0000000000 --- a/tests/test_speed_retiming_diagnostic.py +++ /dev/null @@ -1,123 +0,0 @@ -import numpy as np -import pytest -import torch - -from egomimic.pipeline.stages_speed import SharedSpeedCondition -from egomimic.rldb.zarr.planar_retiming import PlanarCommandRetiming - - -@pytest.mark.parametrize("view,rate", list(enumerate((1, 1.25, 1.5, 1.75, 2)))) -def test_constant_speed_native_retiming(view, rate): - t = PlanarCommandRetiming((1, 1.25, 1.5, 1.75, 2)) - native = np.stack( - (np.arange(31), np.zeros(31), np.zeros(31), np.linspace(0, 1, 31)), axis=-1 - ) - obs = np.array([5, 6, 7]) - b = t.transform({"actions": native, "_retiming_view": view, "obs": obs}) - np.testing.assert_allclose(b["actions"][:, 0], np.arange(16) * rate) - np.testing.assert_allclose(b["actions"][:, 3], np.arange(16) * rate / 30) - np.testing.assert_allclose(b["retiming_rate"], [rate]) - assert "requested_speed" not in b - assert b["obs"] is obs - assert np.array_equal(native[:, 0], np.arange(31)) - - -def test_wrap_stationary_and_tail_rejection(): - t = PlanarCommandRetiming((1.5,), horizon=3) - a = np.zeros((4, 3)) - a[:, 2] = [3.0, 3.1, -3.1, -3.0] - b = t.transform({"actions": a, "_retiming_view": 0}) - assert abs(abs(b["actions"][1, 2]) - np.pi) < 1e-6 - assert b["retiming_rate"].item() == 1.5 - assert "requested_speed" not in b - with pytest.raises(ValueError, match="unpadded"): - t.transform({"actions": a[:3], "_retiming_view": 0}) - - -@pytest.mark.parametrize("encoding", ("scalar", "fourier")) -def test_condition_modes_rng_and_learning(encoding): - torch.manual_seed(123) - rng = torch.get_rng_state().clone() - stage = SharedSpeedCondition(None, encoding) - assert torch.equal(rng, torch.get_rng_state()) - assert stage.contract("train") == stage.contract("inference") - cond = torch.zeros(2, 128) - speed = torch.tensor([[0.25], [2.0]]) - out = stage({"condition": cond, "retiming_rate": speed})["speed_condition"] - assert torch.equal(out, cond) - opt = torch.optim.SGD(stage.parameters(), lr=0.01) - for _ in range(2): - opt.zero_grad() - out = stage({"condition": cond, "retiming_rate": speed})["speed_condition"] - (out - 1).square().mean().backward() - opt.step() - assert stage.mlp[0].weight.grad.abs().sum() > 0 - assert not torch.equal(out[0], out[1]) - with pytest.raises(KeyError): - stage({"condition": cond}) - with pytest.raises(ValueError): - stage({"condition": cond, "retiming_rate": -torch.ones(2, 1)}) - - -def test_unchanged_initialization_across_arms(): - weights = [] - for encoding in ("scalar", "fourier"): - torch.manual_seed(42) - before = torch.nn.Linear(10, 10) - SharedSpeedCondition(None, encoding) - after = torch.nn.Linear(10, 10) - weights.append((before.weight.detach(), after.weight.detach())) - assert all(torch.equal(a, b) for a, b in zip(*weights)) - - -def test_condition_initializer_never_reseeds_cuda(monkeypatch): - def forbidden(*args, **kwargs): - raise AssertionError("Condition initializer must not reseed CUDA") - - monkeypatch.setattr(torch, "manual_seed", forbidden) - monkeypatch.setattr(torch.cuda, "manual_seed_all", forbidden) - rng = torch.get_rng_state().clone() - for encoding in ("scalar", "fourier"): - SharedSpeedCondition(None, encoding) - assert torch.equal(rng, torch.get_rng_state()) - - -@pytest.mark.parametrize("encoding", ("scalar", "fourier")) -def test_real_graph_consumes_speed_in_both_modes(encoding): - from egomimic.pipeline.core import Pipeline - from egomimic.pipeline.stages_action_flow import ( - ConditionalVelocityStage, - LatentBridgeStage, - ) - - class Field(torch.nn.Module): - def forward(self, x, t, condition, **kwargs): - return condition[:, : x.shape[-1]].unsqueeze(1).expand_as(x) - - speed = SharedSpeedCondition(None, encoding, condition_dim=4) - torch.nn.init.constant_(speed.mlp[-1].weight, 0.1) - bridge = LatentBridgeStage( - samples_per_content=1, - condition_key="speed_condition", - condition_dropout_probability=0, - ) - velocity = ConditionalVelocityStage( - Field(), num_inference_steps=2, inference_condition_key="speed_condition" - ) - graph = Pipeline([speed, bridge, velocity]) - b = { - "condition": torch.zeros(2, 4), - "retiming_rate": torch.tensor([[0.25], [2.0]]), - "action_flow/clean_latent": torch.zeros(2, 2, 4), - "sampler/noise": torch.zeros(2, 2, 4), - } - for mode in ("train", "inference"): - _, excluded = graph.plan(b.keys(), mode) - assert all(missing == [""] for _, missing in excluded), excluded - result = graph.execute(b, mode=mode) - key = ( - "action_flow/predicted_velocity" - if mode == "train" - else "action_flow/generated_latent" - ) - assert not torch.equal(result[key][0], result[key][1]) diff --git a/tests/test_standard_dp_active_dimensions.py b/tests/test_standard_dp_active_dimensions.py deleted file mode 100644 index 34dd4dce9c..0000000000 --- a/tests/test_standard_dp_active_dimensions.py +++ /dev/null @@ -1,148 +0,0 @@ -"""U common-five padding must not become a gripper supervision target.""" - -from types import SimpleNamespace - -import pytest -import torch -import torch.nn.functional as F - -from egomimic.eval.energy_score import energy_score -from egomimic.eval.planar_action_eval import PlanarActionEval -from egomimic.pipeline.action_dimensions import normalize_active_action_dimensions -from egomimic.pipeline.stages_diffusion import DiffusionEpsilonLossStage -from egomimic.rldb.embodiment.embodiment import get_embodiment_id - -U = "pushshapes_sim_u_socket" -CHAIN = "pushshapes_sim_chain_gripper" -DIMS = {U: 4, CHAIN: 5} -BLOCKS = {U: [[0, 2], [2, 4]], CHAIN: [[0, 2], [2, 4], [4, 5]]} - - -def batch_for(name, prediction, noise): - return { - "diffusion/predicted_noise": prediction, - "diffusion/noise_target": noise, - "target": torch.zeros_like(prediction), - "embodiment": torch.full((prediction.shape[0],), get_embodiment_id(name)), - } - - -def test_u_gripper_loss_and_output_gradient_are_zero(): - pred = torch.ones(2, 16, 5, requires_grad=True) - noise = torch.zeros_like(pred) - stage = DiffusionEpsilonLossStage(active_action_dims_by_embodiment=DIMS) - loss = stage(batch_for(U, pred, noise))["loss/diffusion_noise"] - assert torch.equal(loss, F.mse_loss(pred[..., :4], noise[..., :4])) - loss.backward() - assert torch.count_nonzero(pred.grad[..., :4]) == 2 * 16 * 4 - assert torch.count_nonzero(pred.grad[..., 4]) == 0 - changed = pred.detach().clone() - changed[..., 4] = 1e10 - assert torch.equal( - stage(batch_for(U, changed, noise))["loss/diffusion_noise"], loss.detach() - ) - - -def test_chain_retains_exact_full_five_loss_and_gradient(): - pred = torch.ones(2, 16, 5, requires_grad=True) - noise = torch.zeros_like(pred) - loss = DiffusionEpsilonLossStage(DIMS)(batch_for(CHAIN, pred, noise))[ - "loss/diffusion_noise" - ] - assert torch.equal(loss, F.mse_loss(pred, noise)) - loss.backward() - assert torch.count_nonzero(pred.grad[..., 4]) == 2 * 16 - - -def test_unconfigured_legacy_loss_is_unchanged(): - torch.manual_seed(5) - pred, noise = torch.randn(2, 16, 5), torch.randn(2, 16, 5) - assert torch.equal( - DiffusionEpsilonLossStage()(batch_for(U, pred, noise))["loss/diffusion_noise"], - F.mse_loss(pred, noise), - ) - - -@pytest.mark.parametrize("bad", [{U: 0}, {U: True}, {U: 4.5}, {}]) -def test_invalid_contract_is_rejected(bad): - with pytest.raises(ValueError): - normalize_active_action_dimensions(bad) - - -def test_mixed_embodiments_and_missing_contract_fail_closed(): - pred, noise = torch.ones(2, 16, 5), torch.zeros(2, 16, 5) - batch = batch_for(U, pred, noise) - batch["embodiment"][1] = get_embodiment_id(CHAIN) - with pytest.raises((ValueError, RuntimeError)): - DiffusionEpsilonLossStage(DIMS)(batch) - with pytest.raises(ValueError): - DiffusionEpsilonLossStage({CHAIN: 5})(batch_for(U, pred, noise)) - with pytest.raises(ValueError): - DiffusionEpsilonLossStage({U: 6})(batch_for(U, pred, noise)) - - -def evaluator(): - return PlanarActionEval( - energy_score_enabled=False, - active_action_dims_by_embodiment=DIMS, - semantic_blocks_by_embodiment=BLOCKS, - ) - - -def test_energy_score_ignores_u_gripper_but_preserves_chain_contract(): - torch.manual_seed(123) - samples, target = torch.randn(32, 2, 16, 5), torch.randn(2, 16, 5) - ev = evaluator() - u = ev._energy_values(samples, target, get_embodiment_id(U), U) - changed = samples.clone() - changed[..., 4] += 1e9 - same = ev._energy_values(changed, target, get_embodiment_id(U), U) - for key in u: - assert torch.equal(u[key], same[key]) - chain = ev._energy_values(samples, target, get_embodiment_id(CHAIN), CHAIN) - native = energy_score(samples, target, BLOCKS[CHAIN]) - for key in chain: - assert torch.equal(chain[key], native[key]) - - -def test_validation_normalized_and_native_u_metrics_ignore_padding(): - ev = evaluator() - collected = {} - ev.trainer = SimpleNamespace( - lightning_module=SimpleNamespace( - log_dict=lambda values, **kw: collected.update(values) - ) - ) - ev.normalizer = SimpleNamespace(unnormalize=lambda values, embodiment: values) - - class Decoder: - def decode(self, value): - return torch.stack( - ( - value[..., 0], - value[..., 1], - torch.atan2(value[..., 3], value[..., 2]), - ), - dim=-1, - ) - - ev.native_decoders = {U: Decoder()} - target = torch.zeros(2, 16, 5) - target[..., 2] = 1 - prediction = target.clone() - prediction[..., 0] = 2 - prediction[..., 4] = 1e9 - ev.model = SimpleNamespace( - forward_eval=lambda batch: {"u": {"pred_action": prediction}} - ) - batch = { - "u": {"actions": target, "embodiment": torch.full((2,), get_embodiment_id(U))} - } - ev.on_validation_step(batch, 0) - assert torch.equal(collected["Valid/MSE/" + U], torch.tensor(1.0)) - assert torch.allclose(collected["Valid/Native_MSE/" + U], torch.tensor(4.0 / 3)) - original = {k: v.clone() for k, v in collected.items()} - prediction[..., 4] = -1e9 - ev.on_validation_step(batch, 0) - for key in original: - assert torch.equal(original[key], collected[key]) diff --git a/tests/test_standard_dp_launch_args.py b/tests/test_standard_dp_launch_args.py deleted file mode 100644 index ea31518103..0000000000 --- a/tests/test_standard_dp_launch_args.py +++ /dev/null @@ -1,16 +0,0 @@ -"""The maintained DP helper cannot silently select a retired single-source recipe.""" - -import runpy -from pathlib import Path - -import pytest - - -@pytest.mark.parametrize("profile", ["", "chain_manual4919_retimed_v1"]) -def test_retired_standard_dp_default_rejected(monkeypatch, profile): - monkeypatch.delenv("DP_COTRAIN_STANDARD", raising=False) - monkeypatch.setenv("DP_SINGLE_SOURCE_STANDARD", profile) - root = Path(__file__).resolve().parents[1] - recipe = runpy.run_path(str(root / "scripts/train/standard_dp_launch.py"))["recipe"] - with pytest.raises(ValueError, match="retired"): - recipe() diff --git a/tests/test_standard_dp_pid_transport.py b/tests/test_standard_dp_pid_transport.py deleted file mode 100644 index f595c8cbbf..0000000000 --- a/tests/test_standard_dp_pid_transport.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Exercise the operational driver against the maintained Lightning save callback.""" - -import importlib.util -from pathlib import Path - -import pytest -from omegaconf import OmegaConf - -ROOT = Path(__file__).resolve().parents[1] -spec = importlib.util.spec_from_file_location( - "dp_pid_transport_driver", ROOT / "scripts/train/standard_dp_launch.py" -) -driver = importlib.util.module_from_spec(spec) -spec.loader.exec_module(driver) - - -@pytest.fixture -def environment(monkeypatch, tmp_path): - fields = { - "DP_COTRAIN_STANDARD": "usocket_chain_manual4919_af_obs_multiplier_261m_v1", - "DP_REPO": str(ROOT), - "DP_EXPECTED_HEAD": "a" * 40, - "DP_WANDB_ID": "test-run", - "DP_NORM_SHA256": "b" * 64, - "DP_SPLIT_SHA256": "c" * 64, - "DP_CONTENT_MANIFEST": str(tmp_path / "content.json"), - "DP_CONTENT_SHA256": "d" * 64, - "DP_CONTENT_AGGREGATE_SHA256": "e" * 64, - } - for key, value in fields.items(): - monkeypatch.setenv(key, value) - monkeypatch.delenv("DP_REQUEUE_OWNER", raising=False) - return tmp_path - - -@pytest.mark.parametrize("phase", ["smoke", "full"]) -def test_runner_uses_native_non_requeue_save_callback(environment, monkeypatch, phase): - from egomimic.trainHydra import _instantiate_slurm_callbacks, _slurm_auto_requeue - from egomimic.utils.slurm_requeue import SaveOnlySignalCheckpoint - - monkeypatch.setenv("DP_REQUEUE_OWNER", "runner") - monkeypatch.setenv("ICE_REQUEUE_OWNER", "runner") - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", "1") - monkeypatch.setenv("SLURM_JOB_ID", "12345") - args = driver.arguments(phase, environment, environment / "norm") - runtime = [x[2:] for x in args if x.startswith("++runtime.")] - cfg = OmegaConf.from_dotlist(["paths.output_dir=" + str(environment)] + runtime) - assert _slurm_auto_requeue(cfg) is False - callbacks = _instantiate_slurm_callbacks(cfg) - assert len(callbacks) == 1 - assert isinstance(callbacks[0], SaveOnlySignalCheckpoint) - assert callbacks[0].checkpoint_dir == environment / "checkpoints" - - -def test_default_and_normalization_have_no_external_owner(environment, monkeypatch): - assert not any( - x.startswith("++runtime.") - for x in driver.arguments("full", environment, environment / "norm") - ) - monkeypatch.setenv("DP_REQUEUE_OWNER", "runner") - assert not any( - x.startswith("++runtime.") - for x in driver.arguments("normalize", environment, environment / "norm") - ) - - -def test_unknown_owner_rejects_before_training(environment, monkeypatch): - monkeypatch.setenv("DP_REQUEUE_OWNER", "child") - with pytest.raises(ValueError, match="DP_REQUEUE_OWNER"): - driver.arguments("full", environment, environment / "norm") - - -def test_full_restart_preserves_output_and_tracking(environment, monkeypatch): - output = environment / "full" - checkpoints = output / "checkpoints" - checkpoints.mkdir(parents=True) - checkpoint = checkpoints / "step-1.ckpt" - checkpoint.write_bytes(b"native-runner-selected") - fields = { - "DP_REQUEUE_OWNER": "runner", - "ICE_REQUEUE_OWNER": "runner", - "ICE_CHILD_REQUEUE_DISABLED": "1", - "ICE_RESTART_COUNT": "1", - "ICE_RESUME_CHECKPOINT": str(checkpoint), - "ICE_RESUME_CHECKPOINT_SHA256": "a" * 64, - "ICE_RESUME_GLOBAL_STEP": "1", - } - for key, value in fields.items(): - monkeypatch.setenv(key, value) - driver.verify_output_directory(output, "full") - args = driver.arguments("full", output, environment / "norm") - assert "++logger.wandb.resume=must" in args - assert "trainer.max_steps=80000" in args - assert "ckpt_path=null" in args - assert checkpoint.read_bytes() == b"native-runner-selected" - - -def test_full_output_requires_same_run_resume_authority(environment, monkeypatch): - output = environment / "full" - (output / "checkpoints").mkdir(parents=True) - with pytest.raises(FileExistsError): - driver.verify_output_directory(output, "full") - monkeypatch.setenv("ICE_RESTART_COUNT", "1") - with pytest.raises(ValueError, match="native resume authority"): - driver.verify_output_directory(output, "full") - - -def test_foreign_checkpoint_cannot_unlock_existing_output(environment, monkeypatch): - output = environment / "full" - (output / "checkpoints").mkdir(parents=True) - checkpoint = environment / "foreign.ckpt" - checkpoint.write_bytes(b"other-run") - for key, value in { - "DP_REQUEUE_OWNER": "runner", - "ICE_REQUEUE_OWNER": "runner", - "ICE_CHILD_REQUEUE_DISABLED": "1", - "ICE_RESTART_COUNT": "1", - "ICE_RESUME_CHECKPOINT": str(checkpoint), - "ICE_RESUME_CHECKPOINT_SHA256": "a" * 64, - "ICE_RESUME_GLOBAL_STEP": "1", - }.items(): - monkeypatch.setenv(key, value) - with pytest.raises(ValueError): - driver.verify_output_directory(output, "full") - - -@pytest.mark.parametrize("case", ["valid", "missing_python3", "wrong_python3"]) -def test_pid_runner_pins_validator_venv(monkeypatch, tmp_path, case): - import hashlib - import os - import runpy - import sys - - shim = ROOT / "scripts/ice/ice_requeue_trainer_pid.py" - native = ROOT / "scripts/ice/ice_requeue_runner.py" - adapter = ROOT / "scripts/ice/trainer_pid_signal.py" - runtime = tmp_path / "venv/bin" - runtime.mkdir(parents=True) - python = runtime / "python" - python.write_text("pinned") - python.chmod(0o700) - if case == "valid": - (runtime / "python3").symlink_to(python) - elif case == "wrong_python3": - (runtime / "python3").write_text("foreign") - for key, value in { - "ICE_CANONICAL_REQUEUE_RUNNER": str(native), - "ICE_TRAINER_REPO": str(ROOT), - "ICE_NATIVE_RUNNER_SHA256": hashlib.sha256(native.read_bytes()).hexdigest(), - "ICE_TRAINER_SIGNAL_ADAPTER_SHA256": hashlib.sha256( - adapter.read_bytes() - ).hexdigest(), - "ICE_TRAINER_PYTHON": str(python), - "PATH": "/foreign/login/bin", - }.items(): - monkeypatch.setenv(key, value) - monkeypatch.setattr(sys, "argv", [str(shim), "--", "child"]) - captured = [] - original = runpy.run_path - monkeypatch.setattr(runpy, "run_path", lambda *args, **kw: captured.append(args)) - if case == "valid": - original(str(shim)) - assert os.environ["PATH"] == str(runtime) + ":/foreign/login/bin" - assert captured == [(str(native),)] - assert sys.argv[1:3] == ["--scancel", str(adapter)] - else: - with pytest.raises(AssertionError): - original(str(shim)) - assert not captured diff --git a/tests/test_stationary_causal_history_recipe.py b/tests/test_stationary_causal_history_recipe.py deleted file mode 100644 index 11bc27572f..0000000000 --- a/tests/test_stationary_causal_history_recipe.py +++ /dev/null @@ -1,74 +0,0 @@ -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir - -CONFIG_DIR = Path(__file__).parents[1] / "egomimic/hydra_configs" - - -@pytest.mark.parametrize("codec", ["transformer", "small", "unet"]) -@pytest.mark.parametrize("phase", ["full", "smoke"]) -def test_corrected_recipe_reaches_consumers_and_preserves_smoke( - monkeypatch, codec, phase -): - for name in [ - "MATCHED_AF_SPLIT_SHA256", - "MATCHED_SPLIT_SHA256", - "MATCHED_NORMALIZATION_SHA256", - "MATCHED_SOURCE_COMMIT", - ]: - monkeypatch.setenv(name, "test-identity") - monkeypatch.setenv("AF_COTRAIN_NORM", "/test/train-only-norm.json") - experiment = ( - "yam_human_af64_unet1d_large_codecs" - if codec == "unet" - else "yam_human_af64_observation640_total180m_open_loop" - ) - overrides = [ - f"+experiment=e1/{experiment}", - "+stationary_recipe=history2_sum_cfg4", - "++paths.root_dir=.", - ] - if codec == "small": - overrides += [ - "model.pipeline.stages.6.field.backbone.hidden_dim=576", - "model.pipeline.stages.6.field.backbone.depth=12", - ] - if phase == "smoke": - overrides += [ - "trainer.max_steps=8", - "trainer.val_check_interval=8", - "callbacks.model_checkpoint.every_n_train_steps=8", - ] - with initialize_config_dir(version_base=None, config_dir=str(CONFIG_DIR.resolve())): - cfg = compose(config_name="train_zarr_cartesian", overrides=overrides) - assert cfg.model.cfg_scale == cfg.model.pipeline.stages[6].cfg_scale == 4.0 - assert ( - cfg.model.flow_loss_aggregation - == cfg.model.pipeline.stages[-1].flow_aggregation - == "sum_samples" - ) - assert cfg.model.pipeline.stages[-1].flow_samples_per_content == 14 - for source in [ - cfg.data.train_datasets.yam_bimanual, - cfg.data.train_datasets.human_bimanual, - cfg.data.valid_datasets.yam.yam_bimanual, - cfg.data.valid_datasets.human.human_bimanual, - ]: - assert source.resolver.observation_history_steps == 2 - cadence = 8 if phase == "smoke" else 15000 - assert ( - cfg.trainer.val_check_interval - == cfg.callbacks.model_checkpoint.every_n_train_steps - == cadence - ) - assert cfg.trainer.accumulate_grad_batches == 1 - assert cfg.trainer.check_val_every_n_epoch is None - assert cfg.trainer.num_sanity_val_steps == 0 - assert cfg.callbacks.model_checkpoint.every_n_epochs is None - assert cfg.callbacks.model_checkpoint.save_on_train_epoch_end is False - assert cfg.callbacks.model_checkpoint.save_top_k == -1 - assert list(cfg.stationary_speed.human_rates) == [0.2, 0.4, 0.6, 0.8, 1.0] - assert list(cfg.stationary_speed.yam_rates) == [1.0] - assert cfg.model.pipeline.conditioning_input == "retiming_multiplier" - assert cfg.model.num_latent_tokens == 64 and cfg.model.latent_dim == 16 diff --git a/tests/test_sync_runtime_script.py b/tests/test_sync_runtime_script.py deleted file mode 100644 index 49b15a0ee0..0000000000 --- a/tests/test_sync_runtime_script.py +++ /dev/null @@ -1,29 +0,0 @@ -import subprocess -from pathlib import Path - -SCRIPT = ( - Path(__file__).parents[1] / "scripts" / "clusters" / "common" / "sync_runtime.sh" -) - - -def test_sync_runtime_is_valid_bash_and_rejects_relative_paths(): - subprocess.run(["bash", "-n", str(SCRIPT)], check=True) - result = subprocess.run( - [str(SCRIPT), "relative/source", "/tmp/runtime"], - check=False, - capture_output=True, - text=True, - ) - - assert result.returncode == 2 - assert "must be absolute paths" in result.stderr - - -def test_sync_runtime_uses_frozen_lock_and_verifies_torchdiffeq(): - text = SCRIPT.read_text() - - assert 'uv sync --frozen --project "$repo"' in text - assert 'version("torchdiffeq")' in text - assert 'item.startswith("torchdiffeq==")' in text - assert "/coc/" not in text - assert "/storage/" not in text diff --git a/tests/test_synthetic_action_adapter_flow.py b/tests/test_synthetic_action_adapter_flow.py deleted file mode 100644 index e415ea383a..0000000000 --- a/tests/test_synthetic_action_adapter_flow.py +++ /dev/null @@ -1,234 +0,0 @@ -import pytest -import torch - -from egomimic.synthetic.action_adapter_flow import SyntheticActionAdapterFlow - - -def _perturb_residual_outputs(model: SyntheticActionAdapterFlow) -> None: - with torch.no_grad(): - for adapter in (model.encoder, model.decoder): - adapter.residual[-1].weight.normal_(std=0.02) - adapter.residual[-1].bias.normal_(std=0.02) - - -def test_fixed_lift_reconstructs_and_projects_unit_noise_exactly(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="fixed_affine") - action = torch.randn(11, 3) - torch.testing.assert_close(model.decoder(model.encoder(action)), action) - torch.testing.assert_close( - model.decoder.weight @ model.decoder.weight.T, torch.eye(3) - ) - torch.testing.assert_close(model.scale_loss(torch.randn(16, 8)), torch.tensor(0.0)) - assert not any(parameter.requires_grad for parameter in model.encoder.parameters()) - assert not any(parameter.requires_grad for parameter in model.decoder.parameters()) - - -def test_affine_path_identity_equals_reconstruction(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="joint_affine") - with torch.no_grad(): - model.encoder.weight.add_(0.05 * torch.randn_like(model.encoder.weight)) - model.decoder.weight.add_(0.05 * torch.randn_like(model.decoder.weight)) - model.encoder.bias.add_(0.02 * torch.randn_like(model.encoder.bias)) - model.decoder.bias.add_(0.02 * torch.randn_like(model.decoder.bias)) - action = torch.randn(13, 3) - noise = torch.randn(13, 8) - time = torch.rand(13, 1) - torch.testing.assert_close( - model.path_consistency_loss(action, noise, time), - model.reconstruction_loss(action), - rtol=2e-5, - atol=2e-6, - ) - - -def test_nonlinear_adapters_start_at_the_same_fixed_lift(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="nonlinear") - action = torch.randn(9, 3) - torch.testing.assert_close(model.decoder(model.encoder(action)), action) - - -def test_nonlinear_decoder_jvp_matches_centered_finite_difference(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="nonlinear") - _perturb_residual_outputs(model) - state = torch.randn(7, 8) - tangent = torch.randn(7, 8) - epsilon = 1e-3 - finite_difference = ( - model.decoder(state + epsilon * tangent) - - model.decoder(state - epsilon * tangent) - ) / (2 * epsilon) - torch.testing.assert_close( - model.decoder_jvp(state, tangent), - finite_difference, - rtol=3e-3, - atol=3e-4, - ) - - -def test_flow_gradient_reaches_trainable_encoder_through_state_and_target(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="joint_affine") - losses = model.losses( - torch.randn(12, 3), objective="reconstruction", flow_samples=3 - ) - losses["flow_loss"].backward() - assert all(parameter.grad is not None for parameter in model.encoder.parameters()) - assert ( - sum( - float(parameter.grad.abs().sum()) - for parameter in model.encoder.parameters() - ) - > 0 - ) - - -def test_loss_uses_the_supplied_fixed_noise_cloud_for_all_flow_samples(): - model = SyntheticActionAdapterFlow( - latent_dim=8, adapter_family="fixed_affine", field_width=16, field_depth=2 - ) - action = torch.randn(5, 3) - noise = torch.randn(5, 8) - time = torch.rand(15, 1) - losses = model.losses( - action, - objective="none", - flow_samples=3, - noise=noise, - time=time, - ) - clean = model.encoder(action) - clean_many = clean[:, None].expand(-1, 3, -1).reshape(-1, 8) - noise_many = noise[:, None].expand(-1, 3, -1).reshape(-1, 8) - state = (1.0 - time) * clean_many + time * noise_many - expected = (model.velocity(state, time) - (noise_many - clean_many)).square().mean() - torch.testing.assert_close(losses["flow_loss"], expected) - - -def test_clean_gradient_modes_keep_forward_loss_identical_and_change_encoder_route(): - model = SyntheticActionAdapterFlow( - latent_dim=8, adapter_family="nonlinear", field_width=16, field_depth=2 - ) - action, noise, time = torch.randn(12, 3), torch.randn(12, 8), torch.rand(36, 1) - losses = {} - gradients = {} - for mode in ("full", "target_stopgrad", "all_stopgrad"): - model.zero_grad(set_to_none=True) - losses[mode] = model.losses( - action, - objective="action_velocity", - flow_samples=3, - noise=noise, - time=time, - clean_gradient_mode=mode, - )["flow_loss"] - losses[mode].backward() - gradients[mode] = sum( - float(parameter.grad.abs().sum()) - for parameter in model.encoder.parameters() - if parameter.grad is not None - ) - torch.testing.assert_close(losses["full"], losses["target_stopgrad"]) - torch.testing.assert_close(losses["full"], losses["all_stopgrad"]) - assert gradients["full"] > 0 - assert gradients["target_stopgrad"] > 0 - assert gradients["all_stopgrad"] == 0 - - -def test_unknown_clean_gradient_mode_is_rejected(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="nonlinear") - with pytest.raises(ValueError, match="unknown clean gradient mode"): - model.losses( - torch.randn(8, 3), objective="action_velocity", clean_gradient_mode="bad" - ) - - -def test_path_loss_gradients_reach_both_nonlinear_adapters(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="nonlinear") - _perturb_residual_outputs(model) - losses = model.losses(torch.randn(10, 3), objective="path", flow_samples=2) - losses["path_loss"].backward() - for adapter in (model.encoder, model.decoder): - gradients = [ - parameter.grad - for parameter in adapter.parameters() - if parameter.requires_grad - ] - assert any( - gradient is not None and bool(gradient.abs().sum()) - for gradient in gradients - ) - - -def test_action_velocity_loss_matches_explicit_decoder_jacobian_product(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="nonlinear") - _perturb_residual_outputs(model) - state = torch.randn(7, 8) - residual = torch.randn(7, 8) - - jacobians = torch.func.vmap(torch.func.jacrev(model.decoder))(state) - explicit_action_residual = torch.einsum("bai,bi->ba", jacobians, residual) - expected = explicit_action_residual.square().sum(dim=-1).mean() / 3.0 - - torch.testing.assert_close(model.action_velocity_loss(state, residual), expected) - - -def test_action_velocity_objective_reaches_encoder_decoder_and_field(): - model = SyntheticActionAdapterFlow( - latent_dim=8, - adapter_family="nonlinear", - field_width=16, - field_depth=2, - ) - _perturb_residual_outputs(model) - losses = model.losses( - torch.randn(10, 3), - objective="action_velocity", - flow_samples=2, - lambda_reconstruction=0.7, - lambda_scale=0.2, - lambda_action_velocity=0.4, - ) - losses["action_velocity_loss"].backward() - - for module in (model.encoder, model.decoder, model.field): - gradients = [ - parameter.grad - for parameter in module.parameters() - if parameter.requires_grad - ] - assert any( - gradient is not None and bool(gradient.abs().sum()) - for gradient in gradients - ) - - -def test_action_velocity_objective_combines_reconstruction_scale_and_metric_loss(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="joint_affine") - losses = model.losses( - torch.randn(9, 3), - objective="action_velocity", - lambda_reconstruction=0.7, - lambda_scale=0.2, - lambda_action_velocity=0.4, - ) - expected = ( - losses["flow_loss"] - + 0.7 * losses["reconstruction_loss"] - + 0.2 * losses["scale_loss"] - + 0.4 * losses["action_velocity_loss"] - ) - torch.testing.assert_close(losses["loss"], expected) - - -def test_affine_path_objective_is_rejected_as_duplicate(): - model = SyntheticActionAdapterFlow(latent_dim=8, adapter_family="joint_affine") - with pytest.raises(ValueError, match="duplicates reconstruction"): - model.losses(torch.randn(8, 3), objective="path") - - -def test_reverse_time_trajectory_decodes_every_state(): - model = SyntheticActionAdapterFlow( - latent_dim=8, adapter_family="fixed_affine", field_width=16, field_depth=2 - ) - trajectory = model.trajectory(torch.randn(6, 8), steps=4) - assert trajectory.shape == (5, 6, 3) - assert torch.isfinite(trajectory).all() diff --git a/tests/test_synthetic_multi_action_adapter_flow.py b/tests/test_synthetic_multi_action_adapter_flow.py deleted file mode 100644 index 649c565748..0000000000 --- a/tests/test_synthetic_multi_action_adapter_flow.py +++ /dev/null @@ -1,125 +0,0 @@ -import json -import subprocess -import sys -from pathlib import Path - -import numpy as np -import torch - -from egomimic.synthetic.multi_action_adapter_flow import ( - SyntheticMultiActionAdapterFlow, -) - - -def test_private_adapters_and_shared_field_receive_expected_gradients(): - model = SyntheticMultiActionAdapterFlow( - embodiments=["shallow", "steep"], field_width=16, field_depth=2 - ) - losses = model.losses_for_embodiment("shallow", torch.randn(8, 3), flow_samples=2) - losses["loss"].backward() - assert any( - p.grad is not None and bool(p.grad.abs().sum()) - for p in model.field.parameters() - ) - assert any( - p.grad is not None and bool(p.grad.abs().sum()) - for p in model.encoders["shallow"].parameters() - ) - assert any( - p.grad is not None and bool(p.grad.abs().sum()) - for p in model.decoders["shallow"].parameters() - ) - assert all(p.grad is None for p in model.encoders["steep"].parameters()) - assert all(p.grad is None for p in model.decoders["steep"].parameters()) - - -def test_private_decoders_share_one_generated_latent_path(): - model = SyntheticMultiActionAdapterFlow( - embodiments=["shallow", "steep"], field_width=16, field_depth=2 - ) - noise = torch.randn(5, 8) - shallow = model.trajectory(noise, embodiment="shallow", steps=3) - steep = model.trajectory(noise, embodiment="steep", steps=3) - assert shallow.shape == steep.shape == (4, 5, 3) - torch.testing.assert_close(shallow, steep) - - -def test_paraboloid_surface_metric_is_zero_on_surface(): - from egomimic.eval.synthetic_trajectory_eval import SyntheticTrajectoryEval - - xy = torch.randn(20, 2) - curvature = 0.5 - points = torch.cat([xy, curvature * xy.square().sum(dim=-1, keepdim=True)], dim=-1) - torch.testing.assert_close( - SyntheticTrajectoryEval.paraboloid_surface_rmse(points, curvature=curvature), - torch.tensor(0.0), - ) - - -def test_multi_trainer_runs_optimizer_validation_and_immutable_checkpoint(tmp_path): - repository = Path(__file__).parents[1] - source = tmp_path / "source" - source.mkdir() - datasets = {} - evaluations = {} - rng = np.random.default_rng(5) - for name, curvature in (("shallow", 0.125), ("steep", 0.5)): - latent = rng.normal(size=(24, 8)).astype(np.float32) - xy = rng.normal(size=(24, 2)).astype(np.float32) - target = np.concatenate( - [xy, curvature * np.square(xy).sum(axis=1, keepdims=True)], axis=1 - ).astype(np.float32) - split = np.array([0] * 20 + [1] * 4, dtype=np.uint8) - path = source / f"{name}.npz" - np.savez_compressed( - path, - source_gaussian_latent=latent, - target_3d=target, - split=split, - ) - datasets[name] = str(path) - evaluations[name] = str(path) - output = tmp_path / "run" - config = { - "seed": 42, - "output_dir": str(output), - "datasets": datasets, - "evaluation_datasets": evaluations, - "curvatures": {"shallow": 0.125, "steep": 0.5}, - "source_key": "source_gaussian_latent", - "batch_size_per_embodiment": 4, - "flow_samples": 2, - "learning_rate": 3e-4, - "max_steps": 1, - "log_every": 1, - "checkpoint_every": 1, - "inference_steps": 2, - "evaluation_particles": 4, - "model": { - "embodiments": ["shallow", "steep"], - "latent_dim": 8, - "residual_width": 8, - "residual_depth": 1, - "field_width": 8, - "field_depth": 1, - }, - } - config_path = tmp_path / "config.json" - config_path.write_text(json.dumps(config)) - subprocess.run( - [ - sys.executable, - str(repository / "scripts/train/train_synthetic_multi_action_flow.py"), - "--config", - str(config_path), - ], - check=True, - ) - assert list( - (output / "checkpoints").glob("epoch-equivalent-*-global-step-000001.pt") - ) - summary = json.loads((output / "summary.json").read_text()) - assert set(summary["embodiments"]) == {"shallow", "steep"} - assert summary["shared_field_parameters"] > 0 - for name in ("shallow", "steep"): - assert (output / f"validation_trajectory_{name}.npz").is_file() diff --git a/tests/test_synthetic_shared_latent_flow.py b/tests/test_synthetic_shared_latent_flow.py deleted file mode 100644 index 7e51b47421..0000000000 --- a/tests/test_synthetic_shared_latent_flow.py +++ /dev/null @@ -1,55 +0,0 @@ -import torch - -from egomimic.synthetic.shared_latent_flow import ( - SyntheticDirectFlow, - SyntheticSharedLatentFlow, -) -from scripts.train.train_synthetic_manifold import energy_distance - - -def test_direct_flow_has_no_encoder_or_decoder_and_updates_field(): - model = SyntheticDirectFlow(data_dim=3, field_width=16, field_depth=2) - assert not hasattr(model, "encoder") - assert not hasattr(model, "decoder") - source = torch.randn(8, 3) - target = torch.randn(8, 3) - losses = model.losses(source, target, flow_samples=2) - losses["loss"].backward() - assert set(losses) == {"loss", "flow_loss"} - assert all(parameter.grad is not None for parameter in model.parameters()) - assert model.integrate(source, steps=2).shape == source.shape - - -def test_energy_distance_is_zero_for_identical_empirical_distributions(): - points = torch.randn(12, 3) - torch.testing.assert_close(energy_distance(points, points), torch.tensor(0.0)) - - -def test_both_objectives_are_finite_and_differentiable(): - for latent_dim in (2, 4): - for method in ("unite", "vfm"): - model = SyntheticSharedLatentFlow(latent_dim=latent_dim) - losses = model.losses( - torch.randn(8, latent_dim), torch.randn(8, 3), method=method - ) - assert all(torch.isfinite(value) for value in losses.values()) - losses["loss"].backward() - assert all(parameter.grad is not None for parameter in model.parameters()) - - -def test_vfm_reconstruction_can_freeze_only_field_parameters(): - model = SyntheticSharedLatentFlow(latent_dim=4) - losses = model.losses( - torch.randn(8, 4), - torch.randn(8, 3), - method="vfm", - reconstruction_updates_field=False, - ) - losses["reconstruction_loss"].backward(retain_graph=True) - assert all(parameter.grad is None for parameter in model.field.parameters()) - assert all(parameter.grad is not None for parameter in model.encoder.parameters()) - assert all(parameter.grad is not None for parameter in model.decoder.parameters()) - - model.zero_grad(set_to_none=True) - losses["flow_loss"].backward() - assert all(parameter.grad is not None for parameter in model.field.parameters()) diff --git a/tests/test_synthetic_trainer_resume.py b/tests/test_synthetic_trainer_resume.py deleted file mode 100644 index f5eeedb45d..0000000000 --- a/tests/test_synthetic_trainer_resume.py +++ /dev/null @@ -1,188 +0,0 @@ -import json -import subprocess -import sys -from pathlib import Path - -import numpy as np -import pytest -import torch - -from scripts.train.train_synthetic_manifold import ( - _capture_rng_state, - _restore_rng_state, -) - - -def _run(source: Path, config: Path, resume: Path | None = None) -> None: - command = [ - sys.executable, - str(source / "scripts/train/train_synthetic_manifold.py"), - "--config", - str(config), - ] - if resume is not None: - command.extend(("--resume", str(resume))) - subprocess.run(command, cwd=source, check=True) - - -def test_training_resume_preserves_checkpoints_and_appends_metrics(tmp_path): - source = Path(__file__).parents[1] - dataset = tmp_path / "dataset.npz" - rng = np.random.default_rng(7) - split = np.array([0] * 30 + [1] * 5 + [2] * 5, dtype=np.uint8) - np.savez_compressed( - dataset, - source_latent=rng.normal(size=(40, 2)).astype(np.float32), - target_3d=rng.normal(size=(40, 3)).astype(np.float32), - split=split, - ) - output = tmp_path / "run" - config_path = tmp_path / "config.json" - config = { - "method": "unite", - "seed": 42, - "dataset": str(dataset), - "source_key": "source_latent", - "output_dir": str(output), - "model": { - "latent_dim": 2, - "codec_width": 8, - "codec_depth": 1, - "field_width": 8, - "field_depth": 1, - }, - "flow_samples": 2, - "reconstruction_noise_range": [0.5, 1.0], - "learning_rate": 0.0003, - "batch_size": 4, - "max_steps": 2, - "inference_steps": 2, - "log_every": 1, - "checkpoint_every": 1, - } - config_path.write_text(json.dumps(config)) - _run(source, config_path) - checkpoint = next((output / "checkpoints").glob("*global-step-000002.pt")) - legacy_state = torch.load(checkpoint, map_location="cpu", weights_only=False) - legacy_state.pop("rng") - torch.save(legacy_state, checkpoint) - - config["max_steps"] = 4 - config_path.write_text(json.dumps(config)) - _run(source, config_path, checkpoint) - - steps = sorted( - int(path.stem.rsplit("-", 1)[-1]) - for path in (output / "checkpoints").glob("*.pt") - ) - assert steps == [1, 2, 3, 4] - metric_steps = [ - json.loads(row)["step"] - for row in (output / "metrics.jsonl").read_text().splitlines() - ] - assert metric_steps == [1, 2, 3, 4] - final = torch.load( - next((output / "checkpoints").glob("*global-step-000004.pt")), - map_location="cpu", - weights_only=False, - ) - assert final["step"] == 4 - assert final["resume"]["checkpoint"] == str(checkpoint) - assert final["resume"]["rng_restored"] is False - assert "rng" in final - - -def test_restore_rng_state_moves_saved_cuda_states_to_cpu(monkeypatch): - generator = torch.Generator().manual_seed(9) - state = _capture_rng_state(generator) - - class DeviceState: - def __init__(self): - self.cpu_called = False - - def cpu(self): - self.cpu_called = True - return torch.tensor([1, 2, 3], dtype=torch.uint8) - - device_state = DeviceState() - state["cuda"] = [device_state] - captured = [] - monkeypatch.setattr(torch.cuda, "is_available", lambda: True) - monkeypatch.setattr(torch.cuda, "set_rng_state_all", captured.append) - - _restore_rng_state(state, generator) - - assert device_state.cpu_called - assert captured[0][0].device.type == "cpu" - assert captured[0][0].dtype == torch.uint8 - - -@pytest.mark.parametrize( - ("adapter_family", "adapter_objective"), - ( - ("fixed_affine", "none"), - ("nonlinear", "path"), - ("nonlinear", "action_velocity"), - ), -) -def test_action_adapter_training_uses_50k_default_and_real_validation( - tmp_path, adapter_family, adapter_objective -): - source = Path(__file__).parents[1] - dataset = tmp_path / "dataset.npz" - rng = np.random.default_rng(17) - split = np.array([0] * 30 + [1] * 5 + [2] * 5, dtype=np.uint8) - np.savez_compressed( - dataset, - source_gaussian_latent=rng.normal(size=(40, 8)).astype(np.float32), - target_3d=rng.normal(size=(40, 3)).astype(np.float32), - split=split, - ) - output = tmp_path / f"action-adapter-{adapter_family}-{adapter_objective}" - config_path = tmp_path / f"action-adapter-{adapter_family}-{adapter_objective}.json" - config = { - "architecture": "action_adapter_flow", - "adapter_objective": adapter_objective, - "seed": 42, - "dataset": str(dataset), - "evaluation_dataset": str(dataset), - "evaluation_particles": 5, - "source_key": "source_gaussian_latent", - "output_dir": str(output), - "model": { - "latent_dim": 8, - "adapter_family": adapter_family, - "residual_width": 8, - "residual_depth": 1, - "field_width": 8, - "field_depth": 1, - }, - "flow_samples": 2, - "lambda_reconstruction": 0.0, - "lambda_scale": 0.0, - "lambda_path": 0.0, - "lambda_action_velocity": 1.0, - "learning_rate": 0.0003, - "batch_size": 4, - "max_steps": 2, - "inference_steps": 2, - "diagnostic_noise_samples": 16, - "angular_bins": 4, - "log_every": 1, - } - config_path.write_text(json.dumps(config)) - _run(source, config_path) - checkpoints = list((output / "checkpoints").glob("*.pt")) - assert len(checkpoints) == 1 - assert checkpoints[0].name.endswith("global-step-000002.pt") - summary = json.loads((output / "summary.json").read_text()) - for key in ( - "validation_generation_symmetric_nn_mse", - "validation_path_consistency_mse", - "validation_action_velocity_mse", - "validation_scale_loss", - "validation_torus_surface_rmse", - "validation_angular_histogram_l1", - "validation_decoder_jacobian_singular_max", - ): - assert np.isfinite(summary[key]) diff --git a/tests/test_synthetic_trainer_signals.py b/tests/test_synthetic_trainer_signals.py deleted file mode 100644 index 46cad3b9e7..0000000000 --- a/tests/test_synthetic_trainer_signals.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Regression coverage for checkpoint requests during startup and saving.""" - -import json -import os -import signal -import subprocess -import sys -from pathlib import Path - -import numpy as np -import pytest -import torch - -from scripts.train.train_synthetic_manifold import ( - _atomic_torch_save, - _CheckpointRequests, -) - - -def test_request_received_during_save_stays_pending(): - requests = _CheckpointRequests() - requests.request(0, None) - snapshot = requests.received - requests.request(0, None) - requests.saved = snapshot - assert requests.received != requests.saved - - -def test_failed_atomic_save_preserves_checkpoint_and_removes_temporary( - tmp_path, monkeypatch -): - checkpoint = tmp_path / "checkpoint.pt" - torch.save({"step": 1}, checkpoint) - original = checkpoint.read_bytes() - - def fail_save(state, temporary): - Path(temporary).write_bytes(b"incomplete") - raise OSError("simulated interrupted write") - - monkeypatch.setattr(torch, "save", fail_save) - with pytest.raises(OSError, match="interrupted write"): - _atomic_torch_save({"step": 2}, checkpoint) - assert checkpoint.read_bytes() == original - assert list(tmp_path.iterdir()) == [checkpoint] - - -def _assert_equal(left, right): - if isinstance(left, torch.Tensor): - torch.testing.assert_close(left, right, rtol=0, atol=0) - elif isinstance(left, np.ndarray): - np.testing.assert_array_equal(left, right) - elif isinstance(left, dict): - assert left.keys() == right.keys() - for key in left: - _assert_equal(left[key], right[key]) - elif isinstance(left, (tuple, list)): - assert len(left) == len(right) - for lhs, rhs in zip(left, right, strict=True): - _assert_equal(lhs, rhs) - else: - assert left == right - - -@pytest.mark.skipif(not hasattr(signal, "SIGUSR2"), reason="requires POSIX signals") -def test_startup_signal_checkpoints_and_resumes_exactly(tmp_path): - source = Path(__file__).resolve().parents[1] - trainer = source / "scripts/train/train_synthetic_manifold.py" - dataset = tmp_path / "dataset.npz" - rng = np.random.default_rng(17) - np.savez_compressed( - dataset, - source_gaussian_latent=rng.normal(size=(40, 8)).astype(np.float32), - target_3d=rng.normal(size=(40, 3)).astype(np.float32), - split=np.array([0] * 30 + [1] * 5 + [2] * 5, dtype=np.uint8), - ) - config = { - "architecture": "action_adapter_flow", - "adapter_objective": "action_velocity", - "seed": 42, - "dataset": str(dataset), - "source_key": "source_gaussian_latent", - "output_dir": str(tmp_path / "interrupted"), - "model": { - "latent_dim": 8, - "adapter_family": "nonlinear", - "residual_width": 8, - "residual_depth": 1, - "field_width": 8, - "field_depth": 1, - }, - "flow_samples": 2, - "lambda_reconstruction": 100.0, - "learning_rate": 0.0003, - "batch_size": 4, - "max_steps": 4, - "inference_steps": 2, - "diagnostic_noise_samples": 16, - "angular_bins": 4, - "log_every": 1, - "checkpoint_every": 50_000, - } - config_path = tmp_path / "config.json" - config_path.write_text(json.dumps(config)) - # Inject a real OS signal precisely at the first heavyweight import. There - # is no sleep-based startup race and no production-only test hook. Exit only - # after the requested atomic checkpoint is published, simulating a restart. - bootstrap = """ -import builtins -import os -import runpy -import signal -import sys - -original_import = builtins.__import__ -sent = False -def signal_before_numpy(name, *args, **kwargs): - global sent - if name == 'numpy' and not sent: - sent = True - assert callable(signal.getsignal(signal.SIGUSR2)), 'handler installed too late' - os.kill(os.getpid(), signal.SIGUSR2) - return original_import(name, *args, **kwargs) -builtins.__import__ = signal_before_numpy -original_replace = os.replace -def interrupt_after_checkpoint(src, dst): - original_replace(src, dst) - if str(dst).endswith('.pt'): - os._exit(75) -os.replace = interrupt_after_checkpoint -sys.argv = sys.argv[1:] -runpy.run_path(sys.argv[0], run_name='__main__') -""" - env = {**os.environ, "CUDA_VISIBLE_DEVICES": "", "OMP_NUM_THREADS": "1"} - - def run(command, expected=0): - result = subprocess.run( - command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60 - ) - assert result.returncode == expected, result.stdout + result.stderr - - run( - [sys.executable, "-c", bootstrap, str(trainer), "--config", str(config_path)], - expected=75, - ) - output = Path(config["output_dir"]) - checkpoints = list((output / "checkpoints").glob("*.pt")) - assert len(checkpoints) == 1 - checkpoint = checkpoints[0] - assert checkpoint.name.endswith("global-step-000001.pt") - checkpoint_bytes = checkpoint.read_bytes() - run( - [ - sys.executable, - str(trainer), - "--config", - str(config_path), - "--resume", - str(checkpoint), - ] - ) - assert checkpoint.read_bytes() == checkpoint_bytes - final_path = next((output / "checkpoints").glob("*global-step-000004.pt")) - resumed = torch.load(final_path, map_location="cpu", weights_only=False) - assert resumed["resume"]["rng_restored"] is True - assert [ - json.loads(row)["step"] - for row in (output / "metrics.jsonl").read_text().splitlines() - ] == [1, 2, 3, 4] - summary = json.loads((output / "summary.json").read_text()) - assert np.isfinite(summary["validation_generation_symmetric_nn_mse"]) - assert np.isfinite(summary["validation_action_velocity_mse"]) - - config["output_dir"] = str(tmp_path / "uninterrupted") - config_path.write_text(json.dumps(config)) - run([sys.executable, str(trainer), "--config", str(config_path)]) - control_path = next((tmp_path / "uninterrupted/checkpoints").glob("*.pt")) - control = torch.load(control_path, map_location="cpu", weights_only=False) - for key in ("model", "optimizer", "rng", "step"): - _assert_equal(resumed[key], control[key]) - control_summary = json.loads((tmp_path / "uninterrupted/summary.json").read_text()) - assert summary == control_summary diff --git a/tests/test_synthetic_trajectory_eval.py b/tests/test_synthetic_trajectory_eval.py deleted file mode 100644 index a26ccdb389..0000000000 --- a/tests/test_synthetic_trajectory_eval.py +++ /dev/null @@ -1,120 +0,0 @@ -import numpy as np -import pytest -import torch - -from egomimic.eval.synthetic_trajectory_eval import SyntheticTrajectoryEval -from egomimic.synthetic.action_adapter_flow import SyntheticActionAdapterFlow -from egomimic.synthetic.multi_action_adapter_flow import ( - SyntheticMultiActionAdapterFlow, -) -from egomimic.synthetic.shared_latent_flow import ( - SyntheticDirectFlow, - SyntheticSharedLatentFlow, -) -from scripts.eval.export_synthetic_trajectory_npz import ( - _EmbodimentTrajectoryView, - resolve_device, -) - - -def test_shared_eval_exports_same_npz_contract_for_both_model_families(tmp_path): - target = torch.randn(7, 3) - models_and_sources = ( - (SyntheticSharedLatentFlow(latent_dim=2), torch.randn(7, 2)), - (SyntheticDirectFlow(data_dim=3), torch.randn(7, 3)), - ( - SyntheticActionAdapterFlow(latent_dim=8, adapter_family="fixed_affine"), - torch.randn(7, 8), - ), - ) - for index, (model, source) in enumerate(models_and_sources): - output = tmp_path / f"trajectory-{index}.npz" - points = SyntheticTrajectoryEval.export(model, source, target, output, steps=3) - archive = np.load(output, allow_pickle=False) - assert set(archive.files) == {"times", "points", "target"} - assert points.shape == (4, 7, 3) - assert archive["points"].shape == (4, 7, 3) - np.testing.assert_array_equal(archive["target"], target.numpy()) - - -def test_ground_truth_uses_same_npz_contract(tmp_path): - source = torch.randn(5, 2) - target = torch.randn(5, 3) - output = tmp_path / "ground-truth.npz" - points = SyntheticTrajectoryEval.export_linear_ground_truth( - source, target, output, steps=2 - ) - torch.testing.assert_close(points[0, :, :2], source) - torch.testing.assert_close(points[0, :, 2], torch.zeros(5)) - torch.testing.assert_close(points[-1], target) - - -def test_validation_loader_requires_exact_requested_particle_count(tmp_path): - dataset = tmp_path / "small.npz" - np.savez_compressed( - dataset, - source_latent=np.zeros((5, 2), dtype=np.float32), - target_3d=np.zeros((5, 3), dtype=np.float32), - split=np.array([0, 1, 1, 2, 2], dtype=np.uint8), - ) - source, target = SyntheticTrajectoryEval.load_validation_data( - dataset, "source_latent", 2 - ) - assert source.shape == (2, 2) - assert target.shape == (2, 3) - with pytest.raises(ValueError, match="requested 3 validation particles"): - SyntheticTrajectoryEval.load_validation_data(dataset, "source_latent", 3) - - -def test_symmetric_nearest_neighbor_mse_checks_both_directions(): - samples = torch.tensor([[0.0, 0.0], [2.0, 0.0]]) - targets = torch.tensor([[0.0, 0.0], [4.0, 0.0]]) - loss = SyntheticTrajectoryEval.symmetric_nearest_neighbor_mse(samples, targets) - torch.testing.assert_close(loss, torch.tensor(2.0)) - - collapsed = torch.zeros(2, 2) - collapsed_loss = SyntheticTrajectoryEval.symmetric_nearest_neighbor_mse( - collapsed, targets - ) - assert collapsed_loss > 0 - - -def test_torus_metrics_are_exact_on_uniform_surface_samples(): - theta = torch.linspace(0, 2 * torch.pi, 32)[:-1] - phi = torch.linspace(0, 2 * torch.pi, 32)[:-1] - theta, phi = torch.meshgrid(theta, phi, indexing="ij") - tube = 2.0 + 0.65 * phi.cos() - points = torch.stack( - (tube * theta.cos(), tube * theta.sin(), 0.65 * phi.sin()), dim=-1 - ).reshape(-1, 3) - torch.testing.assert_close( - SyntheticTrajectoryEval.torus_surface_rmse( - points, major_radius=2.0, minor_radius=0.65 - ), - torch.tensor(0.0), - atol=2e-7, - rtol=0, - ) - coverage = SyntheticTrajectoryEval.torus_angular_coverage(points, points, bins=8) - torch.testing.assert_close(coverage["angular_histogram_l1"], torch.tensor(0.0)) - torch.testing.assert_close(coverage["angular_support_recall"], torch.tensor(1.0)) - - -def test_trajectory_export_device_resolution_rejects_missing_cuda(monkeypatch): - assert resolve_device("cpu") == torch.device("cpu") - monkeypatch.setattr(torch.cuda, "is_available", lambda: False) - assert resolve_device("auto") == torch.device("cpu") - with pytest.raises(RuntimeError, match="CUDA was requested"): - resolve_device("cuda") - - -def test_multi_action_trajectory_view_selects_private_decoder(): - model = SyntheticMultiActionAdapterFlow( - embodiments=["shallow", "steep"], field_width=8, field_depth=1 - ) - source = torch.randn(5, 8) - view = _EmbodimentTrajectoryView(model, "steep") - torch.testing.assert_close( - view.trajectory(source, steps=2), - model.trajectory(source, embodiment="steep", steps=2), - ) diff --git a/tests/test_temporal_action_codec.py b/tests/test_temporal_action_codec.py deleted file mode 100644 index 2c80ec4e7b..0000000000 --- a/tests/test_temporal_action_codec.py +++ /dev/null @@ -1,111 +0,0 @@ -import pytest -import torch -from torch.func import jvp - -from egomimic.models.temporal_action_codec import ( - TemporalActionDecoder, - TemporalActionEncoder, -) - - -@pytest.mark.parametrize("action_dim", [14, 138]) -def test_native_horizons_and_no_reconstruction_bypass(action_dim): - torch.manual_seed(42) - encoder = TemporalActionEncoder(action_dim, 100, 16, 64, [8, 16, 32]) - decoder = TemporalActionDecoder(action_dim, 100, 16, 64, [8, 16, 32]) - encoder.eval() - decoder.eval() - actions = torch.randn(2, 100, action_dim) - latent = encoder(actions) - assert latent.shape == (2, 64, 16) - assert decoder(latent).shape == actions.shape - assert torch.equal(latent, encoder(actions)) - assert not torch.allclose(decoder(latent), decoder(torch.zeros_like(latent))) - loss = (decoder(latent) - actions).square().mean() - loss.backward() - assert encoder.backbone.down_blocks[0][0].layers[0].block[0].weight.grad.norm() > 0 - assert decoder.backbone.output.weight.grad.norm() > 0 - with pytest.raises(ValueError, match="Expected"): - decoder(latent[:, :-1]) - - -def test_decoder_jvp_matches_finite_difference_and_trains_parameters(): - torch.manual_seed(42) - decoder = TemporalActionDecoder(14, 100, 16, 64, [8, 16, 32]).double().eval() - z = torch.randn(1, 64, 16, dtype=torch.float64) - direction = torch.randn_like(z) - _, tangent = jvp(decoder, (z,), (direction,)) - eps = 1e-5 - numerical = (decoder(z + eps * direction) - decoder(z - eps * direction)) / ( - 2 * eps - ) - torch.testing.assert_close(tangent, numerical, rtol=1e-4, atol=1e-5) - tangent.square().mean().backward() - assert decoder.backbone.output.weight.grad.norm() > 0 - - -def test_checkpointed_joint_action_velocity_path_reaches_encoder_and_field(): - torch.manual_seed(42) - encoder = TemporalActionEncoder(14, 100, 16, 64, [8, 16, 32]).train() - decoder = TemporalActionDecoder(14, 100, 16, 64, [8, 16, 32]).train() - actions = torch.randn(2, 100, 14) - z = encoder(actions) - field = torch.nn.Linear(16, 16) - residual = field(z) - (torch.randn_like(z) - z) - # Match ContentDecoderStage's AD boundary: checkpointing off inside JVP. - decoder.gradient_checkpointing = False - _, tangent = jvp(decoder, (z,), (residual,)) - decoder.gradient_checkpointing = True - loss = tangent.square().mean() + (decoder(z) - actions).square().mean() - loss.backward() - assert field.weight.grad.norm() > 0 - assert encoder.backbone.output.weight.grad.norm() > 0 - assert decoder.backbone.output.weight.grad.norm() > 0 - - -def test_diagnostic_hooks_are_real_equal_shape_activations(): - encoder = TemporalActionEncoder(14, 100, 16, 64, [8, 16, 32]).eval() - values = [] - handles = [ - block.register_forward_hook( - lambda module, args, output, i=i: values.append( - encoder.canonicalize_diagnostic_block_output(output, i) - ) - ) - for i, block in enumerate(encoder.blocks) - ] - encoder(torch.randn(2, 100, 14)) - for handle in handles: - handle.remove() - assert len(values) == 2 - assert values[0].shape == values[1].shape == (2, 64, 8) - - -def test_config_replacement_preserves_embodiments_and_latent_boundaries(): - from omegaconf import OmegaConf - - from egomimic.pipeline.stages_speed import replace_action_codec_configs - - spec = { - "_target_": "old", - "action_dim": 14, - "action_horizon": 100, - "latent_dim": 16, - "num_latent_tokens": 64, - } - stages = [ - {"_target_": "test.ContentEncoderStage", "encoders": {"yam": dict(spec)}}, - {"_target_": "test.ContentDecoderStage", "decoders": {"yam": dict(spec)}}, - ] - replacement = OmegaConf.create( - {key: {"yam": {**spec, "_target_": "new"}} for key in ("encoders", "decoders")} - ) - replace_action_codec_configs(stages, replacement) - assert stages[0]["encoders"]["yam"]["_target_"] == "new" - replacement.encoders.yam.latent_dim = 8 - with pytest.raises(ValueError, match="changes yam.latent_dim"): - replace_action_codec_configs(stages, replacement) - replacement.encoders.yam.latent_dim = 16 - del replacement.decoders.yam - with pytest.raises(ValueError, match="embodiment mismatch"): - replace_action_codec_configs(stages, replacement) diff --git a/tests/test_text_encoders.py b/tests/test_text_encoders.py deleted file mode 100644 index 93caffb68b..0000000000 --- a/tests/test_text_encoders.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Qwen pooling helpers: no HuggingFace download.""" - -import torch - -from egomimic.models.stems.text_encoders import qwen_last_token_pool - - -def test_qwen_last_token_pool_left_padding(): - hidden = torch.arange(8, dtype=torch.float32).view(2, 4, 1) - # Both rows end on a real token -> left-padded recipe uses [:, -1]. - mask = torch.tensor([[0, 0, 1, 1], [0, 1, 1, 1]], dtype=torch.long) - pooled = qwen_last_token_pool(hidden, mask) - assert pooled.squeeze(-1).tolist() == [3.0, 7.0] - - -def test_qwen_last_token_pool_right_padding(): - hidden = torch.arange(8, dtype=torch.float32).view(2, 4, 1) - mask = torch.tensor([[1, 1, 0, 0], [1, 1, 1, 0]], dtype=torch.long) - pooled = qwen_last_token_pool(hidden, mask) - assert pooled.squeeze(-1).tolist() == [1.0, 6.0] diff --git a/tests/test_three_source_reconciliation.py b/tests/test_three_source_reconciliation.py deleted file mode 100644 index 29307c1658..0000000000 --- a/tests/test_three_source_reconciliation.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Behavior contracts required by the stationary/LIBERO shared-source merge.""" - -import pytest -import torch -from torch import nn - -from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, -) -from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset - - -class DeclaredNormalizer(MultiDataset): - def __init__(self, state, **kwargs): - super().__init__(state=state, **kwargs) - self.opaque_metadata = state["opaque_metadata"] - - -def test_resume_normalizer_digest_preserves_nested_hydra_state(): - from omegaconf import OmegaConf - - from egomimic.rldb.zarr.data_module import _digest - - plain = {"stats": {"scale": [1.0, 2.0]}, "shapes": {"1": {"actions": [16, 7]}}} - wrapped = {"stats": OmegaConf.create(plain["stats"]), "shapes": plain["shapes"]} - assert _digest(wrapped) == _digest(plain) - from egomimic.pl_utils.data_context import state_fingerprint - - assert state_fingerprint(OmegaConf.create(plain)) == state_fingerprint(plain) - - -def test_saved_context_restores_declared_normalizer_semantics(tmp_path): - import json - - from egomimic.rldb.zarr.data_module import load_data_context, load_normalizer - - state = { - "norm_mode": "quantile", - "embodiments": [], - "key_types": {}, - "zarr_keys": {}, - "shapes": {}, - "norm_stats": {}, - "opaque_metadata": {"scope": "training-only", "split": "exact"}, - } - from egomimic.rldb.zarr.data_module import _digest - - payload = { - "kind": "zarr-normalizer-v1", - "normalizer_state": state, - "sha256": _digest(state), - "normalizer_config": {"_target_": __name__ + ".DeclaredNormalizer"}, - } - path = tmp_path / "context.json" - path.write_text(json.dumps({"data_context": payload})) - for restored in (load_data_context(path).normalizer, load_normalizer(path)): - assert type(restored) is DeclaredNormalizer - assert restored.opaque_metadata == state["opaque_metadata"] - - -class Field(nn.Module): - def __init__(self): - super().__init__() - self.scale = nn.Parameter(torch.tensor(0.4, dtype=torch.float64)) - self.calls = 0 - - def forward(self, state, time, condition, *, condition_drop_mask): - self.calls += 1 - return self.scale * state.tanh() + condition[:, None] + time[:, None, None] - - -def execute(mode): - state = ( - torch.linspace(-1, 1, 8, dtype=torch.float64).reshape(2, 2, 2).requires_grad_() - ) - condition = torch.ones(2, 2, dtype=torch.float64, requires_grad=True) - field = Field() - stage = ConditionalVelocityStage( - field, - flow_clean_gradient_mode="all_stopgrad", - fm_field_execution=mode, - ) - batch = stage( - { - "action_flow/state": state, - "action_flow/time": torch.ones(2, dtype=torch.float64), - "action_flow/condition": condition, - "action_flow/condition_drop_mask": torch.zeros(2, dtype=torch.bool), - "action_flow/target_velocity": -state, - } - ) - fm = batch["action_flow/fm_velocity_residual"].square().mean() - action = batch["action_flow/velocity_residual"].square().mean() - fm_state = torch.autograd.grad(fm, state, retain_graph=True, allow_unused=True)[0] - assert fm_state is None or torch.count_nonzero(fm_state) == 0 - gradients = torch.autograd.grad(fm + action, (state, condition, field.scale)) - return field.calls, fm.detach(), action.detach(), gradients - - -def test_explicit_field_execution_preserves_each_source_and_gradients(): - separate, shared = execute("separate"), execute("shared") - assert (separate[0], shared[0]) == (2, 1) - for a, b in zip(separate[1:3], shared[1:3]): - torch.testing.assert_close(a, b, rtol=0, atol=0) - for a, b in zip(separate[3], shared[3]): - torch.testing.assert_close(a, b, rtol=1e-14, atol=1e-14) - with pytest.raises(ValueError, match="fm_field_execution"): - ConditionalVelocityStage(Field(), fm_field_execution="automatic") - - -def test_routing_consumes_an_opaque_declared_key_not_a_model_family(): - encoders = {"x": nn.Identity(), "y": nn.Identity()} - decoders = {"x": nn.Identity(), "y": nn.Identity()} - encoder = ContentEncoderStage( - encoders=encoders, selector_key="opaque", selector_aliases={"19": "x"} - ) - decoder = ContentDecoderStage( - decoders=decoders, selector_key="opaque", selector_aliases={"19": "x"} - ) - batch = {"opaque": torch.tensor([19, 19]), "target": torch.ones(2, 3, 4)} - assert encoder.encoder_for(batch) is encoders["x"] - assert decoder.decoder_for(batch) is decoders["x"] - with pytest.raises(ValueError): - encoder.encoder_for({"opaque": torch.tensor([19, 20])}) - - -def test_optional_moment_objective_survives_merge_without_default_loss_change(): - batch = { - "target": torch.zeros(2, 2), - "action_flow/reconstruction": torch.ones(2, 2), - "action_flow/velocity_residual": torch.ones(2, 2), - "action_flow/decoded_velocity_residual": torch.ones(2, 2), - "action_flow/decoded_noise": torch.tensor([[1.0, 0.0], [-1.0, 0.0]]), - } - default = ActionFlowObjectiveStage()(dict(batch)) - enabled = ActionFlowObjectiveStage(moment_weight=2)(dict(batch)) - assert default["loss/action_flow"] == 3 - torch.testing.assert_close( - enabled["loss/action_flow"], - default["loss/action_flow"] - + 2 * enabled["log/action_flow_decoded_noise_moments"], - ) - - -def test_lazy_proportional_adapter_retains_native_loader_contract(): - from egomimic.pl_utils.data_context import ContextDataModule - from egomimic.pl_utils.pl_data_utils import ProportionalMultiDataModuleWrapper - from egomimic.rldb.zarr.data_module import ProportionalZarrDataModule - - module = ProportionalZarrDataModule( - train_datasets={}, - valid_datasets={}, - train_dataloader_params={}, - valid_dataloader_params={}, - proportional_train_batch_size=8, - proportional_train_num_workers=0, - proportional_train_seed=42, - ) - assert isinstance(module, ContextDataModule) - assert module.proportional_train_batch_size == 8 - assert ( - type(module).train_dataloader - is ProportionalMultiDataModuleWrapper.train_dataloader - ) - assert ( - type(module).val_dataloader is ProportionalMultiDataModuleWrapper.val_dataloader - ) - - -def test_partial_normalization_resume_is_not_used_for_training(): - from types import SimpleNamespace - - from egomimic.rldb.zarr.data_module import ZarrDataModule - - calls = [] - owner = SimpleNamespace(infer_norm_from_dataset=lambda *a, **kw: calls.append(kw)) - options = {"resume_partial_norm_path": "/exact/task/partial.json"} - ZarrDataModule._fit_normalizer( - None, owner, object(), "opaque", 19, options, "train", None - ) - ZarrDataModule._fit_normalizer( - None, owner, object(), "opaque", 19, options, "normalization", None - ) - assert calls[0]["resume_partial_norm_path"] is None - assert calls[1]["resume_partial_norm_path"] == options["resume_partial_norm_path"] - - -def test_offline_audit_inputs_are_scoped_and_do_not_become_launch_defaults(monkeypatch): - import os - - from omegaconf import OmegaConf - - from scripts.audit_hydra_configs import CONFIGS, compose_for_audit - - monkeypatch.setenv("MATCHED_SOURCE_COMMIT", "human-owned-value") - monkeypatch.delenv("DP_CHAIN_SPLIT_MANIFEST", raising=False) - for relative in ( - "experiment/e1/yam_human_matched_af64_multiplier.yaml", - "experiment/e1/yam_human_matched_dp_noaug.yaml", - "experiment/pusht/planar_uc_manual4919_dp_261m_af_obs_multiplier.yaml", - ): - with compose_for_audit(CONFIGS / relative) as cfg: - OmegaConf.to_container(cfg.model, resolve=True, throw_on_missing=True) - assert os.environ["MATCHED_SOURCE_COMMIT"] == "0" * 40 - assert os.environ["MATCHED_SOURCE_COMMIT"] == "human-owned-value" - assert "DP_CHAIN_SPLIT_MANIFEST" not in os.environ diff --git a/tests/test_token_diagnostics.py b/tests/test_token_diagnostics.py deleted file mode 100644 index 85817625e4..0000000000 --- a/tests/test_token_diagnostics.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Provider-scoped attention capture, source identities and reusable archives.""" - -from functools import wraps -from types import SimpleNamespace - -import numpy as np -import pytest -import torch - -from egomimic.eval.eval import EvaluationDataRequirements -from egomimic.eval.latent_archive import ( - read_archive, - rebuild_archives, - reduce_features, - write_archive, -) -from egomimic.eval.token_diagnostics import TokenDiagnosticsEval -from egomimic.models.pi05.diagnostics import PI05AttentionProvider, capture_attention - - -def attention_layers(): - layer = torch.nn.Module() - layer.self_attn = torch.nn.Module() - layer.self_attn.k_proj = torch.nn.Linear(4, 4) - return torch.nn.ModuleList([layer]) - - -class AttentionBackend(torch.nn.Module): - def __init__(self): - super().__init__() - self.paligemma_with_expert = torch.nn.Module() - self.paligemma_with_expert.paligemma = torch.nn.Module() - self.paligemma_with_expert.paligemma.language_model = torch.nn.Module() - self.paligemma_with_expert.paligemma.language_model.layers = attention_layers() - self.paligemma_with_expert.gemma_expert = torch.nn.Module() - self.paligemma_with_expert.gemma_expert.model = torch.nn.Module() - self.paligemma_with_expert.gemma_expert.model.layers = attention_layers() - - def embed_prefix(self, images, image_masks, language_tokens, language_masks): - embeddings = torch.ones(len(language_tokens), 5, 4) - padding = torch.cat( - [torch.ones(len(language_tokens), 2, dtype=torch.bool), language_masks], 1 - ) - return embeddings, padding, torch.zeros_like(padding) - - def sample(self): - prefix, _, _ = self.embed_prefix( - None, - None, - torch.ones(2, 3), - torch.tensor([[True, False, False], [True, True, False]]), - ) - language = self.paligemma_with_expert.paligemma.language_model.layers[ - 0 - ].self_attn.k_proj(prefix) - expert = self.paligemma_with_expert.gemma_expert.model.layers[ - 0 - ].self_attn.k_proj - first = expert(torch.ones(2, 2, 4)) - expert(torch.ones(2, 2, 4) * 5) - return language, first - - -def test_capture_slices_first_step_masks_and_cleanup(): - backend = AttentionBackend() - with capture_attention(backend, mask_padding=True) as output: - language, first = backend.sample() - torch.testing.assert_close(output["expert_layer_00"]["tokens"], first) - torch.testing.assert_close( - output["paligemma_layer_00_img"]["tokens"], language[:, :2] - ) - assert output["paligemma_layer_00_lang"]["mask"].sum() == 3 - assert "embed_prefix" not in backend.__dict__ - for module in backend.modules(): - assert not module._forward_hooks - with pytest.raises(RuntimeError, match="sample failure"): - with capture_attention(backend): - raise RuntimeError("sample failure") - assert "embed_prefix" not in backend.__dict__ - assert all(not module._forward_hooks for module in backend.modules()) - - -def test_provider_uses_declared_id_and_normal_graph_forward(): - backend = AttentionBackend() - seen = [] - stage = SimpleNamespace(backend=True, policy_nets={"policy": backend}) - - def lookup(name): - assert name == "explicit-policy-id" - return stage - - def forward(batch): - seen.append(next(iter(batch))) - backend.sample() - return {source: {"pred_action": torch.ones(2, 4, 3)} for source in batch} - - graph = SimpleNamespace( - pipeline=SimpleNamespace(stage_by_id=lookup), - forward_eval=forward, - process_batch_for_training=lambda batch: batch, - ) - provider = PI05AttentionProvider(stage_id="explicit-policy-id") - result = provider.run( - graph, {"source_a": {}, "source_b": {}}, already_processed=False - ) - assert seen == ["source_a", "source_b"] - assert result["source_b"]["predictions"]["pred_action"].shape == (2, 4, 3) - assert len(result["source_a"]["activations"]) == 4 - - -def test_capture_bypasses_compiled_sampler_and_restores_it(): - backend = AttentionBackend() - - @wraps(backend.sample) - def compiled(): - raise AssertionError("Capture must use the original eager sampler") - - backend.sample_actions = compiled - with capture_attention(backend) as output: - backend.sample_actions() - assert output and backend.sample_actions is compiled - with pytest.raises(RuntimeError, match="inference failed"): - with capture_attention(backend): - raise RuntimeError("inference failed") - assert backend.sample_actions is compiled - - -def test_prediction_requirements_are_preserved_and_conflicts_rejected(): - prediction = SimpleNamespace( - data_requirements=lambda: EvaluationDataRequirements( - ordered=True, - complete_episodes=True, - source_fps=20, - max_episodes=2, - sample_id_key="episode_hash", - frame_index_key="frame_index", - ), - trainer_overrides=lambda: {"inference_mode": False}, - ) - evaluator = TokenDiagnosticsEval( - prediction_evaluator=prediction, limit_val_batches=1.0 - ) - assert evaluator.data_requirements() == prediction.data_requirements() - assert evaluator.trainer_overrides() == { - "inference_mode": False, - "limit_val_batches": 1.0, - "num_sanity_val_steps": 0, - } - evaluator.sample_id_key = "different_episode_id" - with pytest.raises(ValueError, match="sample_id_key"): - evaluator.data_requirements() - - -def test_diagnostics_score_the_captured_prediction_through_shared_metrics(tmp_path): - from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval - - prediction_evaluator = BimanualCartesianEval( - viz_every_n_epochs=0, pose_metrics=False - ) - evaluator = TokenDiagnosticsEval( - prediction_evaluator=prediction_evaluator, methods=["pca"], save_plots=False - ) - logged, calls = [], [] - evaluator.trainer = SimpleNamespace( - default_root_dir=str(tmp_path), - current_epoch=0, - global_step=1, - global_rank=0, - is_global_zero=True, - world_size=1, - lightning_module=SimpleNamespace( - log_dict=lambda values, **kw: logged.append(values) - ), - ) - evaluator.bind_data_context( - normalizer=SimpleNamespace( - unnormalize=lambda values, identity: {k: v * 2 for k, v in values.items()} - ) - ) - batch = { - "opaque": { - "embodiment": torch.tensor([7]), - "episode_hash": ["episode"], - "frame_index": torch.tensor([0]), - "actions_cartesian": torch.zeros(1, 4, 14), - } - } - - def run(capability, values): - calls.append(capability) - return { - "opaque": { - "predictions": {"pred_action": torch.ones(1, 4, 14)}, - "activations": { - "layer": { - "tokens": torch.eye(4)[None], - "mask": torch.ones(1, 4, dtype=torch.bool), - } - }, - } - } - - evaluator.model = SimpleNamespace(run_diagnostic=run) - evaluator.on_validation_start() - metrics = evaluator.on_validation_step(batch, 0) - evaluator.on_validation_end() - assert calls == ["token_activations"] - assert metrics["Valid/MSE"] == 1 - assert metrics["Valid/Native_MSE"] == 4 - assert len(logged) == 1 - - -def test_archives_preserve_frames_balance_sources_and_rebuild(tmp_path): - evaluator = TokenDiagnosticsEval( - methods=["pca"], max_tokens_per_source_layer=6, save_plots=True - ) - evaluator.trainer = SimpleNamespace( - default_root_dir=str(tmp_path), current_epoch=0, global_step=10, global_rank=1 - ) - batch = { - name: { - "episode_hash": [f"{name}-episode"] * 2, - "frame_index": torch.tensor([4, 17]), - } - for name in ["A", "B"] - } - - def diagnostics(capability, values): - assert capability == "token_activations" - return { - name: { - "activations": { - "layer_00": { - "tokens": torch.arange(32).reshape(2, 4, 4).float(), - "mask": torch.ones(2, 4, dtype=torch.bool), - } - } - } - for name in values - } - - evaluator.model = SimpleNamespace(run_diagnostic=diagnostics) - evaluator.on_validation_start() - evaluator.on_validation_step(batch, 0) - evaluator.on_validation_step( - batch, 1 - ) # repeated distributed/combined-loader samples - evaluator.on_validation_end() - path = next(evaluator.output_dir.rglob("*.csv")) - keys, rows = read_archive(path) - assert keys.shape == (12, 4) - assert {row["frame_idx"] for row in rows} == {"4", "17"} - assert sum(row["embodiment"] == "A" for row in rows) == 6 - assert sum(row["embodiment"] == "B" for row in rows) == 6 - assert ( - len( - { - tuple(row[key] for key in ("video_hash", "frame_idx", "token_idx")) - for row in rows - } - ) - == 12 - ) - rebuilt = tmp_path / "rebuild" - rebuild_archives(path.parent, rebuilt, methods=["pca"], seed=0) - restored, metadata = read_archive(rebuilt / path.name) - np.testing.assert_array_equal(restored, keys) - assert metadata == rows - assert list(rebuilt.glob("*.png")) - with pytest.raises(FileExistsError): - rebuild_archives(path.parent, rebuilt, methods=["pca"]) - with pytest.raises(ValueError, match="differ"): - rebuild_archives(path.parent, path.parent, methods=["pca"]) - - -def test_all_reduction_methods_are_finite_and_reproducible(): - pytest.importorskip("umap") - features = np.random.default_rng(42).normal(size=(16, 8)).astype(np.float32) - methods = ["pca", "umap", "pca_umap", "tsne2d", "tsne3d"] - result, receipt = reduce_features(features, methods=methods, pca_components=5) - assert set(result) == set(methods) - assert len(receipt["pca_explained_variance"]) == 5 - for name, coordinates in result.items(): - assert coordinates.shape == (16, 2 if name == "tsne2d" else 3) - assert np.isfinite(coordinates).all() - - -def test_old_inline_csv_rebuild_and_bad_row_count(tmp_path): - source = tmp_path / "old.csv" - source.write_text( - "video_hash,embodiment,frame_idx,token_idx,k0,k1\nhash,A,7,0,1.0,2.0\n" - ) - keys, rows = read_archive(source) - np.testing.assert_array_equal(keys, [[1, 2]]) - assert rows[0]["frame_idx"] == "7" - with pytest.raises(ValueError, match="row counts"): - write_archive(tmp_path / "bad", "layer", keys, [], reductions={}, receipt={}) diff --git a/tests/test_train_hydra_checkpoint_provenance.py b/tests/test_train_hydra_checkpoint_provenance.py deleted file mode 100644 index bbd9dc541f..0000000000 --- a/tests/test_train_hydra_checkpoint_provenance.py +++ /dev/null @@ -1,33 +0,0 @@ -from omegaconf import OmegaConf - -from egomimic.trainHydra import _build_model_config_tree - - -def test_model_config_tree_preserves_checkpoint_identity_provenance(): - cfg = OmegaConf.create( - { - "model": {"_target_": "example.ModelWrapper", "latent_dim": 8}, - "logger": {"wandb": {"id": "action-flow-smoke-v1"}}, - "run_provenance": { - "source_commit": "a" * 40, - "split_manifest_sha256": "b" * 64, - "normalization_sha256": "c" * 64, - }, - } - ) - - config_tree = _build_model_config_tree(cfg) - - assert config_tree.model.latent_dim == 8 - assert config_tree.run_provenance.source_commit == "a" * 40 - assert config_tree.run_provenance.split_manifest_sha256 == "b" * 64 - assert config_tree.run_provenance.normalization_sha256 == "c" * 64 - assert config_tree.run_provenance.run_id == "action-flow-smoke-v1" - - -def test_model_config_tree_keeps_provenance_optional(): - cfg = OmegaConf.create({"model": {"_target_": "example.ModelWrapper"}}) - - config_tree = _build_model_config_tree(cfg) - - assert set(config_tree) == {"model"} diff --git a/tests/test_train_hydra_slurm_environment.py b/tests/test_train_hydra_slurm_environment.py deleted file mode 100644 index 69ec177e5d..0000000000 --- a/tests/test_train_hydra_slurm_environment.py +++ /dev/null @@ -1,271 +0,0 @@ -import hashlib -import signal -from pathlib import Path -from types import SimpleNamespace - -import pytest -import torch -from lightning.pytorch.plugins.environments import SLURMEnvironment -from omegaconf import OmegaConf - -import egomimic.trainHydra as train_hydra -from egomimic.utils.slurm_requeue import SaveOnlySignalCheckpoint - -_OWNERSHIP_VARIABLES = ("ICE_REQUEUE_OWNER", "ICE_CHILD_REQUEUE_DISABLED") -_RESUME_VARIABLES = ( - "ICE_RESUME_CHECKPOINT", - "ICE_RESUME_CHECKPOINT_SHA256", - "ICE_RESUME_GLOBAL_STEP", -) - - -def _clear_ownership(monkeypatch): - for name in _OWNERSHIP_VARIABLES: - monkeypatch.delenv(name, raising=False) - - -def _set_runner_environment(monkeypatch): - monkeypatch.setenv("SLURM_JOB_ID", "123") - monkeypatch.setenv("ICE_REQUEUE_OWNER", "runner") - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", "1") - - -def _write_checkpoint(path, *, global_step): - torch.save({"global_step": global_step, "state_dict": {}}, path) - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def _cfg(owner="lightning"): - return OmegaConf.create( - { - "mmap_checkpoint": False, - "runtime": {"slurm_requeue_owner": owner}, - } - ) - - -def test_base_config_records_lightning_as_default_requeue_owner(): - config_path = ( - Path(__file__).parents[1] - / "egomimic" - / "hydra_configs" - / "train_zarr_cartesian.yaml" - ) - cfg = OmegaConf.load(config_path) - cfg.paths = {"output_dir": "/tmp/egomimic-test-output"} - - assert cfg.runtime.slurm_requeue_owner == "lightning" - assert cfg.runtime.slurm_save_signal == "SIGUSR2" - assert ( - cfg.runtime.slurm_signal_checkpoint_dir - == "/tmp/egomimic-test-output/checkpoints" - ) - - -def test_skynet_default_keeps_lightning_auto_requeue(monkeypatch): - _clear_ownership(monkeypatch) - plugin = train_hydra._slurm_environment(_cfg()) - - assert isinstance(plugin, SLURMEnvironment) - assert plugin.auto_requeue is True - assert plugin.requeue_signal == signal.SIGUSR1 - - -def test_runner_owned_job_builds_one_non_requeueing_slurm_environment(monkeypatch): - monkeypatch.setenv("SLURM_JOB_ID", "123") - monkeypatch.setenv("ICE_REQUEUE_OWNER", "runner") - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", "1") - - plugins = train_hydra._instantiate_trainer_plugins(_cfg("runner")) - - slurm_plugins = [ - plugin for plugin in plugins if isinstance(plugin, SLURMEnvironment) - ] - assert len(slurm_plugins) == 1 - assert slurm_plugins[0].auto_requeue is False - assert slurm_plugins[0].requeue_signal == signal.SIGUSR1 - - -def test_runner_owned_job_adds_one_save_only_sigusr2_callback(monkeypatch, tmp_path): - monkeypatch.setenv("SLURM_JOB_ID", "123") - monkeypatch.setenv("ICE_REQUEUE_OWNER", "runner") - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", "1") - cfg = _cfg("runner") - cfg.runtime.slurm_save_signal = "SIGUSR2" - cfg.runtime.slurm_signal_checkpoint_dir = str(tmp_path / "checkpoints") - - callbacks = train_hydra._instantiate_slurm_callbacks(cfg) - - assert len(callbacks) == 1 - assert isinstance(callbacks[0], SaveOnlySignalCheckpoint) - assert callbacks[0].save_signal == signal.SIGUSR2 - assert callbacks[0].checkpoint_dir == (tmp_path / "checkpoints").resolve() - - -def test_child_owned_job_keeps_lightning_auto_requeue(monkeypatch): - monkeypatch.setenv("ICE_REQUEUE_OWNER", "child") - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", "0") - - plugin = train_hydra._slurm_environment(_cfg()) - - assert plugin.auto_requeue is True - - -def test_non_requeue_job_disables_lightning_without_signal_callback(monkeypatch): - _clear_ownership(monkeypatch) - monkeypatch.setenv("SLURM_JOB_ID", "123") - cfg = _cfg("none") - - plugin = train_hydra._slurm_environment(cfg) - - assert isinstance(plugin, SLURMEnvironment) - assert plugin.auto_requeue is False - assert train_hydra._instantiate_slurm_callbacks(cfg) == [] - - -@pytest.mark.parametrize( - ("configured_owner", "owner", "disabled"), - [ - ("runner", "runner", None), - ("runner", "runner", "0"), - ("runner", None, None), - ("lightning", "runner", "1"), - ("lightning", "child", None), - ("lightning", "child", "1"), - ("lightning", None, "1"), - ("none", "runner", "1"), - ("none", "child", "0"), - ("none", None, "1"), - ("unknown", "runner", "1"), - ], -) -def test_inconsistent_requeue_ownership_fails_closed( - monkeypatch, configured_owner, owner, disabled -): - _clear_ownership(monkeypatch) - if owner is not None: - monkeypatch.setenv("ICE_REQUEUE_OWNER", owner) - if disabled is not None: - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", disabled) - - with pytest.raises(RuntimeError, match="inconsistent Slurm requeue ownership"): - train_hydra._slurm_environment(_cfg(configured_owner)) - - -def test_runner_resume_uses_and_validates_exact_selected_checkpoint( - monkeypatch, tmp_path -): - _set_runner_environment(monkeypatch) - monkeypatch.setenv("SLURM_RESTART_COUNT", "4") - checkpoint_path = tmp_path / "runner-selected.ckpt" - checkpoint_sha256 = _write_checkpoint(checkpoint_path, global_step=321) - monkeypatch.setenv("ICE_RESUME_CHECKPOINT", str(checkpoint_path)) - monkeypatch.setenv("ICE_RESUME_CHECKPOINT_SHA256", checkpoint_sha256) - monkeypatch.setenv("ICE_RESUME_GLOBAL_STEP", "321") - cfg = _cfg("runner") - cfg.ckpt_path = str(checkpoint_path) - trainer = SimpleNamespace(default_root_dir=str(tmp_path / "lightning-root")) - - selected = train_hydra._resolve_training_checkpoint(cfg, trainer) - - assert selected == str(checkpoint_path.resolve()) - assert cfg.ckpt_path == selected - assert selected != str(tmp_path / "lightning-root" / "checkpoints" / "last.ckpt") - - -@pytest.mark.parametrize( - ("sha_override", "step_override", "message"), - [ - ("0" * 64, "321", "ICE_RESUME_CHECKPOINT_SHA256 mismatch"), - (None, "320", "ICE_RESUME_GLOBAL_STEP mismatch"), - ], -) -def test_runner_resume_rejects_checkpoint_identity_mismatch( - monkeypatch, tmp_path, sha_override, step_override, message -): - _set_runner_environment(monkeypatch) - checkpoint_path = tmp_path / "runner-selected.ckpt" - checkpoint_sha256 = _write_checkpoint(checkpoint_path, global_step=321) - monkeypatch.setenv("ICE_RESUME_CHECKPOINT", str(checkpoint_path)) - monkeypatch.setenv( - "ICE_RESUME_CHECKPOINT_SHA256", sha_override or checkpoint_sha256 - ) - monkeypatch.setenv("ICE_RESUME_GLOBAL_STEP", step_override) - - with pytest.raises(RuntimeError, match=message): - train_hydra._resolve_training_checkpoint( - _cfg("runner"), - SimpleNamespace(default_root_dir=str(tmp_path)), - ) - - -def test_runner_resume_requires_complete_environment(monkeypatch, tmp_path): - _set_runner_environment(monkeypatch) - checkpoint_path = tmp_path / "runner-selected.ckpt" - monkeypatch.setenv("ICE_RESUME_CHECKPOINT", str(checkpoint_path)) - monkeypatch.setenv("ICE_RESUME_CHECKPOINT_SHA256", "") - monkeypatch.setenv("ICE_RESUME_GLOBAL_STEP", "") - - with pytest.raises(RuntimeError, match="must be supplied together"): - train_hydra._resolve_training_checkpoint( - _cfg("runner"), - SimpleNamespace(default_root_dir=str(tmp_path)), - ) - - -def test_runner_restart_refuses_last_checkpoint_fallback(monkeypatch, tmp_path): - _set_runner_environment(monkeypatch) - monkeypatch.setenv("SLURM_RESTART_COUNT", "1") - for name in _RESUME_VARIABLES: - monkeypatch.setenv(name, "") - - with pytest.raises(RuntimeError, match="has no ICE_RESUME_CHECKPOINT"): - train_hydra._resolve_training_checkpoint( - _cfg("runner"), - SimpleNamespace(default_root_dir=str(tmp_path)), - ) - - -def test_non_requeue_job_rejects_a_slurm_restart(monkeypatch, tmp_path): - _clear_ownership(monkeypatch) - monkeypatch.setenv("SLURM_RESTART_COUNT", "1") - - with pytest.raises(RuntimeError, match="non-requeue job cannot resume"): - train_hydra._resolve_training_checkpoint( - _cfg("none"), - SimpleNamespace(default_root_dir=str(tmp_path)), - ) - - -def test_runner_clean_first_attempt_accepts_explicit_empty_selection( - monkeypatch, tmp_path -): - _set_runner_environment(monkeypatch) - monkeypatch.setenv("SLURM_RESTART_COUNT", "0") - for name in _RESUME_VARIABLES: - monkeypatch.setenv(name, "") - cfg = _cfg("runner") - - selected = train_hydra._resolve_training_checkpoint( - cfg, - SimpleNamespace(default_root_dir=str(tmp_path)), - ) - - assert selected is None - assert cfg.ckpt_path is None - - -def test_lightning_owned_restart_retains_last_checkpoint_fallback( - monkeypatch, tmp_path -): - _clear_ownership(monkeypatch) - monkeypatch.setenv("SLURM_JOB_ID", "123") - monkeypatch.setenv("SLURM_RESTART_COUNT", "2") - cfg = _cfg("lightning") - trainer = SimpleNamespace(default_root_dir=str(tmp_path / "lightning-root")) - - selected = train_hydra._resolve_training_checkpoint(cfg, trainer) - - expected = str(tmp_path / "lightning-root" / "checkpoints" / "last.ckpt") - assert selected == expected - assert cfg.ckpt_path == expected diff --git a/tests/test_trainer_pid_signal.py b/tests/test_trainer_pid_signal.py deleted file mode 100644 index ec22a3571a..0000000000 --- a/tests/test_trainer_pid_signal.py +++ /dev/null @@ -1,200 +0,0 @@ -import runpy -import signal -from pathlib import Path - -import pytest - - -@pytest.fixture -def fixture(tmp_path): - api = runpy.run_path( - str(Path(__file__).resolve().parents[1] / "scripts/ice/trainer_pid_signal.py") - ) - p = tmp_path / "proc" / "123" - (p / "ns").mkdir(parents=True) - ns = p / "ns/pid" - ns.write_text("namespace") - repo = tmp_path / "repo" - repo.mkdir() - python = tmp_path / "python" - python.touch() - (p / "cwd").symlink_to(repo) - (p / "exe").symlink_to(python) - bit = 1 << (signal.SIGUSR2 - 1) - (p / "status").write_text( - f"Uid:\t42\t42\t42\t42\nSigCgt:\t{bit:016x}\nSigIgn:\t0000000000000000\n" - ) - (p / "cgroup").write_text("0::/slurm/uid_42/job_456/step_0\n") - (p / "cmdline").write_bytes(b"python\0-m\0egomimic.trainHydra\0") - (p / "stat").write_text( - "123 (python worker) " + " ".join(["0"] * 19 + ["100"] + ["0"] * 20) - ) - kwargs = dict( - repo=repo, - python=python, - uid=42, - namespace=ns.stat().st_ino, - job="456", - proc=tmp_path / "proc", - ) - return api, p, kwargs - - -def test_verified_identity(fixture): - api, p, kw = fixture - assert api["candidate"](123, **kw)["start_time_ticks"] == 100 - - -@pytest.mark.parametrize( - "field", - [ - "uid", - "namespace", - "job", - "source", - "python", - "handler", - "ignored", - "command", - "start", - ], -) -def test_fail_closed(fixture, field): - api, p, kw = fixture - if field == "uid": - kw["uid"] = 99 - if field == "namespace": - kw["namespace"] += 1 - if field == "job": - kw["job"] = "999" - if field == "source": - kw["repo"] = kw["repo"].parent - if field == "python": - kw["python"] = kw["python"].parent - if field == "handler": - (p / "status").write_text("Uid: 42 42 42 42\nSigCgt: 0\nSigIgn: 0\n") - if field == "ignored": - (p / "status").write_text( - f"Uid: 42 42 42 42\nSigCgt: {1 << (signal.SIGUSR2 - 1):x}\nSigIgn: {1 << (signal.SIGUSR2 - 1):x}\n" - ) - if field == "command": - (p / "cmdline").write_bytes(b"python\0ice_requeue_runner.py\0") - if field == "start": - (p / "stat").write_text("123 (python) " + " ".join(["0"] * 40)) - with pytest.raises(AssertionError): - api["candidate"](123, **kw) - - -def test_pid_handle_python_api(monkeypatch): - api = runpy.run_path( - str(Path(__file__).resolve().parents[1] / "scripts/ice/trainer_pid_signal.py") - ) - import os - - monkeypatch.setattr( - os, "pidfd_open", lambda pid: 77 if pid == 123 else None, raising=False - ) - monkeypatch.setattr( - signal, "pidfd_send_signal", lambda fd, sig: (fd, sig), raising=False - ) - assert api["open_pidfd"](123) == 77 - assert api["send_pidfd"](77, signal.SIGUSR2) == (77, signal.SIGUSR2) - - -def test_missing_api_rejects_unverified_platform(monkeypatch): - api = runpy.run_path( - str(Path(__file__).resolve().parents[1] / "scripts/ice/trainer_pid_signal.py") - ) - import os - import sys - - monkeypatch.delattr(os, "pidfd_open", raising=False) - monkeypatch.setattr(sys, "platform", "unverified") - with pytest.raises(AssertionError, match="ABI"): - api["open_pidfd"](123) - - -def test_pace_cgroup_v2_native_job_step_identity(fixture): - api, p, kw = fixture - (p / "cgroup").write_text( - "0::/system.slice/slurmstepd.scope/s8FXM4GN19R200/step_3/user/task_0\n" - ) - (p / "environ").write_bytes(b"SLURM_JOB_ID=456\0SLURM_STEP_ID=3\0") - result = api["candidate"](123, **kw, native_steps={123: "3"}) - assert result["job_membership"].startswith("native scontrol") - - -@pytest.mark.parametrize( - "case", - [ - "mapping_missing", - "pid_missing", - "step_wrong", - "job_wrong", - "env_step_wrong", - "env_missing", - "layout_wrong", - "duplicate_job", - ], -) -def test_pace_cgroup_v2_refuses_ambiguous_membership(fixture, case): - api, p, kw = fixture - (p / "cgroup").write_text( - "0::/system.slice/slurmstepd.scope/s8FXM4GN19R200/step_3/user/task_0\n" - ) - (p / "environ").write_bytes(b"SLURM_JOB_ID=456\0SLURM_STEP_ID=3\0") - mapping = {123: "3"} - if case == "mapping_missing": - mapping = None - if case == "pid_missing": - mapping = {124: "3"} - if case == "step_wrong": - mapping = {123: "4"} - if case == "job_wrong": - (p / "environ").write_bytes(b"SLURM_JOB_ID=999\0SLURM_STEP_ID=3\0") - if case == "env_step_wrong": - (p / "environ").write_bytes(b"SLURM_JOB_ID=456\0SLURM_STEP_ID=4\0") - if case == "env_missing": - (p / "environ").write_bytes(b"OTHER=456\0") - if case == "layout_wrong": - (p / "cgroup").write_text("0::/unrelated/step_3\n") - if case == "duplicate_job": - (p / "environ").write_bytes( - b"SLURM_JOB_ID=456\0SLURM_JOB_ID=456\0SLURM_STEP_ID=3\0" - ) - with pytest.raises(AssertionError): - api["candidate"](123, **kw, native_steps=mapping) - - -def test_fork_workers_select_only_verified_trainer_root(): - api = runpy.run_path( - str(Path(__file__).resolve().parents[1] / "scripts/ice/trainer_pid_signal.py") - ) - root = dict(pid=100, parent_pid=90, job_id="456", cgroup="same-step") - workers = [dict(root, pid=i, parent_pid=100) for i in range(101, 113)] - assert api["select_trainer_root"](workers + [root]) is root - nested = dict(root, pid=113, parent_pid=101) - assert api["select_trainer_root"]([nested, root] + workers) is root - - -@pytest.mark.parametrize("case", ["empty", "two_roots", "cross_step", "cycle"]) -def test_ambiguous_trainer_trees_fail_closed(case): - api = runpy.run_path( - str(Path(__file__).resolve().parents[1] / "scripts/ice/trainer_pid_signal.py") - ) - root = dict(pid=100, parent_pid=90, job_id="456", cgroup="same-step") - child = dict(root, pid=101, parent_pid=100) - rows = [root, child] - if case == "empty": - rows = [] - elif case == "two_roots": - child["parent_pid"] = 91 - elif case == "cross_step": - child["cgroup"] = "other-step" - elif case == "cycle": - rows += [ - dict(root, pid=102, parent_pid=103), - dict(root, pid=103, parent_pid=102), - ] - with pytest.raises(AssertionError): - api["select_trainer_root"](rows) diff --git a/tests/test_unite_action_velocity.py b/tests/test_unite_action_velocity.py deleted file mode 100644 index 48d7ed500d..0000000000 --- a/tests/test_unite_action_velocity.py +++ /dev/null @@ -1,184 +0,0 @@ -from types import SimpleNamespace - -import pytest -import torch -import torch.nn as nn -from omegaconf import OmegaConf - -from egomimic.models.unite_action_decoder import UniteActionDecoder -from egomimic.pipeline.stages_unite_released import ( - ReleasedRecipeUniteLatentPolicy, - ReleasedRecipeUniteObjective, -) -from egomimic.pipeline.stages_unite_separate import ( - build_configurable_unite_generative_encoder, -) -from egomimic.pipeline.unite_action_velocity import decoder_action_velocity_loss -from egomimic.pl_utils.training_behavior_unite import ( - ReleasedUniteTrainingBehavior, -) - -DOMAIN = "pushshapes_sim_u_socket" - - -def _policy( - action_velocity_samples: int, - *, - gradient_checkpointing: bool = False, -) -> ReleasedRecipeUniteLatentPolicy: - backbone = { - "_target_": "egomimic.models.unite_dit.UniteDiTBackbone", - "input_dim": 4, - "output_dim": 4, - "horizon": 2, - "condition_dim": 4, - "max_condition_tokens": 1, - "max_content_tokens": 3, - "hidden_dim": 8, - "depth": 1, - "num_heads": 2, - "mlp_ratio": 2.0, - "time_fourier_dim": 8, - "context_adaln_summary": False, - "in_context_start": 0, - "in_context_len": 4, - "trainable_position_embeddings": False, - "trainable_register_position_embeddings": True, - "trainable_content_position_embeddings": False, - "trainable_condition_position_embeddings": False, - "trainable_in_context_position_embeddings": True, - "gradient_checkpointing": gradient_checkpointing, - } - encoder = build_configurable_unite_generative_encoder( - backbone_config=backbone, - share_encoder_denoiser=True, - action_dims={DOMAIN: 2}, - condition_input_dim=4, - latent_dim=4, - num_latent_tokens=2, - condition_dim=4, - denoiser_hidden_dim=8, - gradient_checkpointing=gradient_checkpointing, - in_context_start=0, - in_context_len=4, - ) - decoder = UniteActionDecoder( - latent_dim=4, - action_dim=2, - num_latent_tokens=2, - action_horizon=3, - hidden_dim=8, - depth=1, - num_heads=2, - mlp_ratio=2.0, - gradient_checkpointing=gradient_checkpointing, - ) - return ReleasedRecipeUniteLatentPolicy( - generative_encoder=encoder, - decoders={DOMAIN: decoder}, - flow_steps_per_reconstruction=2, - flow_mini_batch=2, - action_velocity_samples_per_reconstruction=action_velocity_samples, - reconstruction_noising_probability=0.0, - condition_dropout_probability=0.0, - ) - - -def _batch() -> dict: - return { - "target": torch.randn(2, 3, 2), - "condition": torch.randn(2, 4), - "sampler/noise": torch.randn(2, 2, 4), - "embodiment": DOMAIN, - } - - -def _activate_initially_zero_latent_projection(policy) -> None: - projection = policy.generative_encoder.denoising_module.proj_d - nn.init.normal_(projection.weight, std=0.02) - - -def test_decoder_jvp_matches_linear_action_metric(): - decoder = nn.Linear(3, 2, bias=False) - decoder.weight.data.copy_(torch.tensor([[1.0, 2.0, 0.0], [0.0, -1.0, 3.0]])) - latent = torch.randn(4, 3, requires_grad=True) - residual = torch.randn(4, 3, requires_grad=True) - expected = (residual @ decoder.weight.T).square().mean() - actual = decoder_action_velocity_loss( - decoder, latent.unsqueeze(1), residual.unsqueeze(1) - ) - torch.testing.assert_close(actual, expected) - - -def test_baseline_executes_no_decoder_jvp_and_keeps_zero_av_loss(monkeypatch): - policy = _policy(0).train() - monkeypatch.setattr( - "egomimic.pipeline.stages_unite_released.decoder_action_velocity_loss", - lambda *_args: pytest.fail("baseline UNITE executed a decoder JVP"), - ) - output = ReleasedRecipeUniteObjective()(policy(_batch())) - assert output["unite/action_velocity_sample_count"] == 0 - assert output["loss/unite_action_velocity"] == 0.0 - - -def test_unite_av_is_finite_and_reaches_encoder_denoiser_and_decoder(): - torch.manual_seed(17) - policy = _policy(1).train() - _activate_initially_zero_latent_projection(policy) - output = ReleasedRecipeUniteObjective(action_velocity_weight=1.0)(policy(_batch())) - loss = output["loss/unite_action_velocity"] - assert loss.ndim == 0 and bool(torch.isfinite(loss)) and float(loss) > 0.0 - loss.backward() - - encoder = policy.generative_encoder - decoder = policy.action_decoder.decoder_for(DOMAIN) - assert any( - parameter.grad is not None - for parameter in encoder.action_context_projections[DOMAIN].parameters() - ) - assert any( - parameter.grad is not None - for parameter in encoder.denoising_module.parameters() - ) - assert any(parameter.grad is not None for parameter in decoder.parameters()) - - -def test_unite_av_supports_decoder_gradient_checkpointing(): - policy = _policy(1, gradient_checkpointing=True).train() - _activate_initially_zero_latent_projection(policy) - output = ReleasedRecipeUniteObjective(action_velocity_weight=1.0)(policy(_batch())) - loss = output["loss/unite_action_velocity"] - assert bool(torch.isfinite(loss)) - loss.backward() - decoder = policy.action_decoder.decoder_for(DOMAIN) - assert any(parameter.grad is not None for parameter in decoder.parameters()) - - -def test_objective_and_policy_settings_fail_closed(): - with pytest.raises(ValueError, match="samples"): - _policy(3) - policy = _policy(0).train() - with pytest.raises(RuntimeError, match="sampled no AV bridges"): - ReleasedRecipeUniteObjective(action_velocity_weight=1.0)(policy(_batch())) - - -def test_unite_av_checkpoint_contract_rejects_baseline_full_resume(): - config = OmegaConf.create( - { - "model": { - "architecture_id": "unite_register_v1", - "objective_id": "unite_action_velocity_v1", - "action_velocity_samples_per_reconstruction": 4, - "action_velocity_weight": 1.0, - } - } - ) - contract = ReleasedUniteTrainingBehavior._resolve_unite_contract(config) - wrapper = SimpleNamespace(_unite_contract=contract) - with pytest.raises(RuntimeError, match="weights-only warm start"): - ReleasedUniteTrainingBehavior.on_load_checkpoint(wrapper, {}) - checkpoint = {"unite_contract": dict(contract)} - ReleasedUniteTrainingBehavior.on_load_checkpoint(wrapper, checkpoint) - checkpoint["unite_contract"]["objective_id"] = "unite_baseline_v1" - with pytest.raises(RuntimeError, match="contract mismatch"): - ReleasedUniteTrainingBehavior.on_load_checkpoint(wrapper, checkpoint) diff --git a/tests/test_unite_diagnostics.py b/tests/test_unite_diagnostics.py deleted file mode 100644 index bcdbc08880..0000000000 --- a/tests/test_unite_diagnostics.py +++ /dev/null @@ -1,117 +0,0 @@ -import json - -import numpy as np -import pytest -import torch - -from egomimic.eval.diagnostics.gradients import gradient_cosine_similarity -from egomimic.eval.diagnostics.latent_projection import project_pca -from egomimic.eval.diagnostics.trajectory import ( - chunk_seam_metrics, - validate_chunk_seam_artifact, - write_chunk_seam_artifact, -) -from egomimic.eval.diagnostics.unite_artifact import ( - UniteDiagnosticArtifactWriter, - validate_unite_artifact, -) - - -def test_gradient_cosine_detects_exact_conflict_without_populating_grad(): - parameter = torch.nn.Parameter(torch.tensor([1.0, -2.0])) - first = parameter.sum() - second = -parameter.sum() - - cosine = gradient_cosine_similarity(first, second, [parameter]) - - assert cosine.item() == pytest.approx(-1.0) - assert parameter.grad is None - - -def test_chunk_seam_metrics_use_circular_rotation_and_derivatives(): - old = np.asarray( - [ - [0.0, 0.0, 3.10, 0.0], - [1.0, 0.0, 3.11, 0.5], - [2.0, 0.0, 3.12, 1.0], - [3.0, 0.0, 3.13, 1.0], - ], - dtype=np.float32, - ) - new = old.copy() - new[:, 2] = -3.10 - metrics = chunk_seam_metrics(old, new) - - assert metrics["aligned_steps"] == 4 - assert metrics["position_mean_l2"] == pytest.approx(0.0) - assert metrics["rotation_mean_abs_radians"] < 0.1 - assert metrics["velocity_rms_difference"] == pytest.approx(0.0) - assert metrics["jerk_rms_difference"] == pytest.approx(0.0) - - -def test_chunk_seam_artifact_is_immutable_and_hash_validated(tmp_path): - event = { - "t": 8, - "embodiment_id": 19, - "episode_index": 0, - "previous_tail": np.zeros((8, 4), dtype=np.float32), - "new_head": np.ones((8, 4), dtype=np.float32), - "executed_head": np.ones((8, 4), dtype=np.float32), - } - artifact = write_chunk_seam_artifact([event], tmp_path / "seams") - - manifest = validate_chunk_seam_artifact(artifact) - - assert manifest["event_count"] == 1 - with pytest.raises(FileExistsError): - write_chunk_seam_artifact([event], artifact) - - -def test_shared_latent_pca_has_stable_shape(): - features = np.arange(48, dtype=np.float32).reshape(8, 6) - - projected, ratio = project_pca(features, 3) - - assert projected.shape == (8, 3) - assert ratio.shape == (3,) - assert np.all(np.isfinite(projected)) - - -def test_unite_artifact_round_trip(tmp_path): - request = tmp_path / "request.json" - request.write_text(json.dumps({"schema_version": 1, "checkpoint_sha256": "abc"})) - writer = UniteDiagnosticArtifactWriter( - str(tmp_path / "unite"), request_path=str(request) - ) - batch, horizon, action_dim, tokens, latent_dim = 2, 4, 3, 4, 8 - steps, samples = 3, 5 - writer.append( - embodiment_id=17, - embodiment_name="pushshapes_sim_u_socket", - action_key="actions", - target_action=np.zeros((batch, horizon, action_dim), dtype=np.float32), - reconstructed_action=np.zeros((batch, horizon, action_dim), dtype=np.float32), - generated_action=np.zeros((batch, horizon, action_dim), dtype=np.float32), - clean_latent=np.zeros((batch, tokens, latent_dim), dtype=np.float32), - generated_latent=np.zeros((batch, tokens, latent_dim), dtype=np.float32), - denoising_latents=np.zeros( - (batch, steps, tokens, latent_dim), dtype=np.float32 - ), - denoising_actions=np.zeros( - (batch, steps, horizon, action_dim), dtype=np.float32 - ), - diversity_latents=np.zeros( - (batch, samples, tokens, latent_dim), dtype=np.float32 - ), - diversity_actions=np.zeros( - (batch, samples, horizon, action_dim), dtype=np.float32 - ), - episode_hashes=["episode-a", "episode-b"], - ) - artifact = writer.finalize() - - manifest = validate_unite_artifact(artifact, request) - - assert manifest["sample_count"] == 2 - assert manifest["parts"][0]["denoising_steps"] == steps - assert manifest["parts"][0]["diversity_samples"] == samples diff --git a/tests/test_unite_energy.py b/tests/test_unite_energy.py deleted file mode 100644 index 408c81be74..0000000000 --- a/tests/test_unite_energy.py +++ /dev/null @@ -1,697 +0,0 @@ -from __future__ import annotations - -import copy -import hashlib -import json -import math -from types import SimpleNamespace - -import pytest -import torch - -from egomimic.eval.energy_score import ( - USOCKET_ENERGY_DISTANCE_CONFIG, - normalize_usocket_energy_distance_config, - usocket_energy_distance_metadata, -) -from egomimic.eval.planar_action_eval import PlanarActionEval -from egomimic.pipeline.pushshapes import ( - ChainGripperPointsNativeDecoder, - USocketRotVecNativeDecoder, -) - - -@pytest.fixture(autouse=True) -def _isolate_scheduler_execution_identity(monkeypatch): - """Keep artifact-path tests independent of the scheduler running pytest.""" - monkeypatch.delenv("SLURM_JOB_ID", raising=False) - monkeypatch.delenv("SLURM_RESTART_COUNT", raising=False) - - -class _IdentityNormalizer: - @staticmethod - def unnormalize(values, embodiment_id): - assert embodiment_id == 19 - return values - - -class _AffineActionNormalizer: - def __init__(self, scale, bias): - self.scale = torch.as_tensor(scale) - self.bias = torch.as_tensor(bias) - - def unnormalize(self, values, embodiment_id): - assert embodiment_id == 19 - return { - key: value * self.scale.to(value) + self.bias.to(value) - for key, value in values.items() - } - - def normalize_tensor(self, value): - return (value - self.bias.to(value)) / self.scale.to(value) - - -class _NestedRandomModel: - @staticmethod - def forward_eval(batch): - return { - source_id: {"pred_action": torch.rand_like(source_batch["actions"])} - for source_id, source_batch in batch.items() - } - - -def _write_seed_bank(tmp_path, seeds=range(32), filename="energy-seeds.json"): - payload = json.dumps({"seeds": list(seeds)}, sort_keys=True).encode() - path = tmp_path / filename - path.write_bytes(payload) - return path, hashlib.sha256(payload).hexdigest() - - -def _evaluator(tmp_path, **kwargs): - path, digest = _write_seed_bank(tmp_path) - return PlanarActionEval( - seed_bank_path=str(path), - seed_bank_sha256=digest, - artifact_root=str(tmp_path / "artifacts"), - semantic_blocks=((0, 2), (2, 4)), - deterministic_seed=0, - **kwargs, - ) - - -def _typed_evaluator(tmp_path): - path, digest = _write_seed_bank(tmp_path) - run_dir = tmp_path / "typed-run" - config_path = run_dir / ".hydra" / "config.yaml" - config_path.parent.mkdir(parents=True) - config_path.write_text("name: typed-usocket-test\n") - content_manifest_path = run_dir / "content-manifest.json" - content_manifest_payload = { - "schema_version": 1, - "status": "ZARR_CONTENT_MANIFEST", - "aggregate_sha256": "d" * 64, - } - content_manifest_path.write_text( - json.dumps(content_manifest_payload, sort_keys=True, separators=(",", ":")) - ) - split_hash = "c" * 64 - provenance = { - "source_commit": "a" * 40, - "normalization_sha256": "b" * 64, - "split_manifest_sha256": split_hash, - "resolved_config_path": str(config_path), - "wandb": { - "entity": "rl2-group", - "project": "pushshapes-action-flow", - "run_id": "typed-energy-test", - }, - "distance_contract": USOCKET_ENERGY_DISTANCE_CONFIG, - "dataset_content": { - "manifest_path": str(content_manifest_path), - "manifest_sha256": hashlib.sha256( - content_manifest_path.read_bytes() - ).hexdigest(), - "aggregate_sha256": content_manifest_payload["aggregate_sha256"], - }, - "action_representation": "x_y_cos_theta_sin_theta", - } - evaluator = PlanarActionEval( - seed_bank_path=str(path), - seed_bank_sha256=digest, - artifact_root=str(run_dir / "energy"), - semantic_blocks=((0, 2), (2, 4)), - deterministic_seed=0, - native_decoder=USocketRotVecNativeDecoder(), - energy_score_distance=USOCKET_ENERGY_DISTANCE_CONFIG, - energy_score_validation_view={ - "definition": "first_deterministic_validation_batch_per_rank", - "split_manifest_sha256": split_hash, - "per_rank_batch_size": 2, - "world_size": 1, - }, - energy_score_provenance=provenance, - ) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - return evaluator, run_dir, config_path, content_manifest_path - - -def test_energy_artifacts_preserve_validation_across_slurm_attempts( - tmp_path, monkeypatch -): - target = torch.zeros(2, 2, 4) - samples = target.unsqueeze(0).repeat(32, 1, 1, 1) - batch = _batch(target) - legacy = tmp_path / "artifacts/epoch-0-step-20001/rank-0-batch-0.pt" - legacy.parent.mkdir(parents=True) - legacy.write_bytes(b"historical validation artifact") - saved_paths = [] - for job_id, restart in (("5691426", 0), ("5691426", 1), ("5692000", 0)): - monkeypatch.setenv("SLURM_JOB_ID", job_id) - monkeypatch.setenv("SLURM_RESTART_COUNT", str(restart)) - evaluator = _evaluator(tmp_path) - evaluator.trainer = SimpleNamespace( - current_epoch=0, global_step=20001, global_rank=0 - ) - scores = {"validation/usocket": evaluator._energy_values(samples, target, 19)} - evaluator._save_artifact(0, {"validation/usocket": samples}, scores, batch) - with pytest.raises(FileExistsError, match="refusing to overwrite"): - evaluator._save_artifact(0, {"validation/usocket": samples}, scores, batch) - path = ( - tmp_path - / "artifacts" - / f"job-{job_id}-restart-{restart}" - / "epoch-0-step-20001/rank-0-batch-0.pt" - ) - saved_paths.append(path) - payload = torch.load(path, map_location="cpu", weights_only=True) - assert payload["execution"] == { - "slurm_job_id": job_id, - "slurm_restart_count": restart, - } - torch.testing.assert_close( - payload["domains"]["pushshapes_sim_u_socket"]["predictions"], samples - ) - assert legacy.read_bytes() == b"historical validation artifact" - assert len(set(saved_paths)) == 3 - - -def _rotvec(theta, *, batch_size=2, horizon=16): - theta = torch.full((batch_size, horizon), float(theta)) - return torch.stack( - ( - torch.zeros_like(theta), - torch.zeros_like(theta), - torch.cos(theta), - torch.sin(theta), - ), - dim=-1, - ) - - -def _batch(target): - return { - "validation/usocket": { - "actions": target, - "embodiment": torch.full((target.shape[0],), 19), - } - } - - -def test_energy_seed_bank_hash_and_seed_identity_are_strict(tmp_path): - path, digest = _write_seed_bank(tmp_path) - with pytest.raises(ValueError, match="seed-bank identity mismatch"): - PlanarActionEval( - seed_bank_path=str(path), - seed_bank_sha256="0" * 64, - artifact_root=str(tmp_path / "bad-hash"), - ) - - duplicate_path, duplicate_digest = _write_seed_bank( - tmp_path, [7] * 32, "duplicate-energy-seeds.json" - ) - with pytest.raises(ValueError, match="32 unique seeds"): - PlanarActionEval( - seed_bank_path=str(duplicate_path), - seed_bank_sha256=duplicate_digest, - artifact_root=str(tmp_path / "duplicate-seeds"), - ) - - evaluator = PlanarActionEval( - seed_bank_path=str(path), - seed_bank_sha256=digest, - artifact_root=str(tmp_path / "artifacts"), - semantic_blocks=((0, 2), (2, 4)), - deterministic_seed=0, - ) - evaluator.model = _NestedRandomModel() - target = torch.zeros(2, 2, 4) - batch = _batch(target) - - torch.manual_seed(8675309) - original_rng_state = torch.random.get_rng_state() - sampled, first_result = evaluator._seeded_predictions(batch) - sampled = sampled["validation/usocket"] - actual_next_random = torch.rand(4) - torch.random.set_rng_state(original_rng_state) - expected_next_random = torch.rand(4) - - with torch.random.fork_rng(): - torch.manual_seed(evaluator.seeds[0]) - expected_first_sample = torch.rand_like(target) - assert sampled.shape == (32, 2, 2, 4) - torch.testing.assert_close(sampled[0], expected_first_sample) - torch.testing.assert_close( - first_result["validation/usocket"]["pred_action"], expected_first_sample - ) - torch.testing.assert_close(actual_next_random, expected_next_random) - - -def test_typed_usocket_energy_uses_wrapped_native_theta_over_complete_h16(tmp_path): - evaluator, _, _, _ = _typed_evaluator(tmp_path) - normalizer = _AffineActionNormalizer( - scale=[5.0, 7.0, 2.0, 4.0], - bias=[1.0, -2.0, 0.25, -0.75], - ) - evaluator.bind_data_context(normalizer=normalizer) - target = normalizer.normalize_tensor(_rotvec(math.pi - 0.01)) - prediction = normalizer.normalize_tensor(_rotvec(-math.pi + 0.01)) - samples = prediction.unsqueeze(0).repeat(32, 1, 1, 1) - - values = evaluator._energy_values(samples, target, embodiment_id=19) - - expected = torch.tensor(0.01 / math.pi) - torch.testing.assert_close(values["accuracy"], expected, atol=1.0e-7, rtol=0.0) - torch.testing.assert_close(values["diversity"], torch.zeros(())) - torch.testing.assert_close(values["score"], expected, atol=1.0e-7, rtol=0.0) - - -def test_typed_usocket_energy_weights_xy_and_circular_theta_equally(tmp_path): - evaluator, _, _, _ = _typed_evaluator(tmp_path) - target = _rotvec(0.0) - translated = target.clone() - translated[..., :2] = 1.0 - rotated = _rotvec(math.pi) - - translation_score = evaluator._energy_values( - translated.unsqueeze(0).repeat(32, 1, 1, 1), - target, - embodiment_id=19, - )["score"] - rotation_score = evaluator._energy_values( - rotated.unsqueeze(0).repeat(32, 1, 1, 1), - target, - embodiment_id=19, - )["score"] - - torch.testing.assert_close(translation_score, torch.tensor(0.5)) - torch.testing.assert_close(rotation_score, torch.tensor(0.5)) - - short_target = target[:, :-1] - with pytest.raises(ValueError, match="complete normalized chunks"): - evaluator._energy_values( - short_target.unsqueeze(0).repeat(32, 1, 1, 1), - short_target, - embodiment_id=19, - ) - - -def test_typed_usocket_energy_contract_rejects_undeclared_weight_knobs(): - contract = { - **USOCKET_ENERGY_DISTANCE_CONFIG, - "semantic_weights": { - **USOCKET_ENERGY_DISTANCE_CONFIG["semantic_weights"], - "undeclared": 0.0, - }, - } - - with pytest.raises(ValueError, match="semantic weight keys differ"): - normalize_usocket_energy_distance_config(contract) - - -def test_typed_usocket_energy_artifact_binds_full_metric_identity(tmp_path): - evaluator, run_dir, config_path, content_manifest_path = _typed_evaluator(tmp_path) - target = _rotvec(math.pi - 0.01) - prediction = _rotvec(-math.pi + 0.01) - samples = prediction.unsqueeze(0).repeat(32, 1, 1, 1) - batch = _batch(target) - batch["validation/usocket"]["episode_hash"] = ["episode-a", "episode-b"] - batch["validation/usocket"]["frame_index"] = torch.tensor([3, 9]) - evaluator.model = SimpleNamespace() - evaluator.trainer = SimpleNamespace( - current_epoch=0, - global_step=2, - global_rank=0, - precision="bf16-mixed", - lightning_module=SimpleNamespace(log_dict=lambda *_args, **_kwargs: None), - ) - evaluator._seeded_predictions = lambda _batch: ( - {"validation/usocket": samples}, - {"validation/usocket": {"pred_action": samples[0]}}, - ) - - evaluator.on_validation_step(batch, batch_idx=0) - - artifact_path = run_dir / "energy/epoch-0-step-2/rank-0-batch-0.pt" - artifact = torch.load(artifact_path, map_location="cpu", weights_only=False) - assert artifact["schema_version"] == 2 - assert artifact["distance"] == usocket_energy_distance_metadata( - USOCKET_ENERGY_DISTANCE_CONFIG - ) - assert artifact["identity"]["metric"] == { - "name": "EnergyScore@32", - "sample_count": 32, - "deterministic_seed": 0, - } - assert artifact["identity"]["source_commit"] == "a" * 40 - assert artifact["identity"]["normalization_sha256"] == "b" * 64 - assert artifact["identity"]["split_manifest_sha256"] == "c" * 64 - assert ( - artifact["identity"]["resolved_config_sha256"] - == hashlib.sha256(config_path.read_bytes()).hexdigest() - ) - assert artifact["identity"]["wandb"] == { - "entity": "rl2-group", - "project": "pushshapes-action-flow", - "run_id": "typed-energy-test", - } - assert artifact["identity"]["dataset_content"] == { - "manifest_path": str(content_manifest_path.resolve()), - "manifest_sha256": hashlib.sha256( - content_manifest_path.read_bytes() - ).hexdigest(), - "aggregate_sha256": "d" * 64, - } - assert artifact["identity"]["validation_view"] == { - "definition": "first_deterministic_validation_batch_per_rank", - "split_manifest_sha256": "c" * 64, - "per_rank_batch_size": 2, - "world_size": 1, - } - assert artifact["identity"]["checkpoint_binding"] == { - "global_step": 2, - "checkpoint_sha256": None, - "sha256_status": ( - "unavailable_during_validation_artifact_write; post-run_smoke_" - "verifier_binds_checkpoint_and_artifact_by_global_step_and_records_" - "both_file_hashes" - ), - } - encoded_identity = json.dumps( - artifact["identity"], - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - allow_nan=False, - ).encode() - assert artifact["identity_sha256"] == hashlib.sha256(encoded_identity).hexdigest() - domain = artifact["domains"]["pushshapes_sim_u_socket"] - assert domain["native_predictions"].shape == (32, 2, 16, 3) - assert domain["native_targets"].shape == (2, 16, 3) - assert [item["episode_hash"] for item in domain["condition_ids"]] == [ - "episode-a", - "episode-b", - ] - assert [item["frame_index"] for item in domain["condition_ids"]] == [3, 9] - assert artifact["identity"]["validation_conditions"] == { - "pushshapes_sim_u_socket": domain["condition_ids"] - } - - -def test_typed_usocket_energy_identity_rejects_mutated_content_manifest(tmp_path): - evaluator, _, _, content_manifest_path = _typed_evaluator(tmp_path) - content_manifest_path.write_text('{"aggregate_sha256":"e"}') - - with pytest.raises(ValueError, match="manifest hash differs"): - evaluator._typed_artifact_identity( - domains={"pushshapes_sim_u_socket": {"condition_ids": []}}, - global_step=2, - ) - - -def test_energy_artifact_records_provenance_and_per_condition_outputs(tmp_path): - validation_view = { - "definition": "first_energy_score_batch_per_ddp_rank", - "split_manifest_sha256": "split-sha", - "per_rank_batch_size": 2, - "world_size": 4, - } - provenance = { - "action_representations": { - "pushshapes_sim_u_socket": "x_y_cos_theta_sin_theta" - }, - "prediction_horizon": 2, - "sampler": "dopri5", - "sampler_evaluation_points": 50, - "sampler_atol": 1.0e-6, - "sampler_rtol": 1.0e-3, - "model_autocast_precision": "bf16", - "dopri5_state_precision": "fp32", - "dopri5_derivative_precision": "fp32", - "dopri5_error_control_precision": "fp32", - } - evaluator = _evaluator( - tmp_path, - energy_score_max_batches_per_rank=1, - energy_score_validation_view=validation_view, - energy_score_provenance=provenance, - ) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - target = torch.zeros(2, 2, 4) - batch = _batch(target) - evaluator.model = SimpleNamespace( - forward_eval=lambda grouped: { - source_id: {"pred_action": source_batch["actions"]} - for source_id, source_batch in grouped.items() - } - ) - logged = {} - evaluator.trainer = SimpleNamespace( - current_epoch=3, - global_step=41, - global_rank=2, - precision="bf16-mixed", - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.update(metrics) - ), - ) - - samples = target.unsqueeze(0).repeat(32, 1, 1, 1) - condition_values = torch.linspace(0.0, 1.0, 32) - samples[:, 1] = condition_values[:, None, None] - evaluator._seeded_predictions = lambda _batch: ( - {"validation/usocket": samples}, - {"validation/usocket": {"pred_action": samples[0]}}, - ) - - evaluator.on_validation_step(batch, batch_idx=0) - - artifact_path = tmp_path / "artifacts" / "epoch-3-step-41" / "rank-2-batch-0.pt" - assert artifact_path.is_file() - artifact = torch.load(artifact_path, map_location="cpu", weights_only=False) - assert artifact["schema_version"] == 1 - assert artifact["metric"] == "EnergyScore@32" - assert artifact["sample_count"] == 32 - assert artifact["seed_bank"] == list(range(32)) - assert artifact["seed_bank_sha256"] == evaluator.seed_bank_sha256 - assert artifact["deterministic_seed"] == 0 - assert artifact["distance"] == { - "space": "normalized_action_chunk", - "formula": "mean_equal_weight_semantic_block_rms", - "semantic_blocks": ((0, 2), (2, 4)), - } - assert artifact["aggregation"] == ("condition_mean_then_equal_domain_macro_mean") - assert artifact["global_step"] == 41 - assert artifact["epoch"] == 3 - assert artifact["rank"] == 2 - assert artifact["batch_idx"] == 0 - assert artifact["precision"] == "bf16-mixed" - assert artifact["validation_view"] == validation_view - assert artifact["provenance"] == provenance - - domain = artifact["domains"]["pushshapes_sim_u_socket"] - assert domain["source_id"] == "validation/usocket" - assert domain["embodiment_id"] == 19 - assert domain["action_key"] == "actions" - assert domain["predictions"].shape == (32, 2, 2, 4) - assert domain["targets"].shape == (2, 2, 4) - for name in ( - "accuracy_by_condition", - "diversity_by_condition", - "score_by_condition", - ): - assert domain[name].shape == (2,) - assert bool(torch.isfinite(domain[name]).all()) - - off_diagonal = ~torch.eye(32, dtype=torch.bool) - expected_diversity = ( - (condition_values[:, None] - condition_values[None, :]) - .abs()[off_diagonal] - .mean() - ) - expected_accuracy = torch.tensor([0.0, condition_values.mean()]) - expected_diversity_by_condition = torch.tensor([0.0, expected_diversity]) - expected_score = expected_accuracy - 0.5 * expected_diversity_by_condition - torch.testing.assert_close(domain["accuracy_by_condition"], expected_accuracy) - torch.testing.assert_close( - domain["diversity_by_condition"], expected_diversity_by_condition - ) - torch.testing.assert_close(domain["score_by_condition"], expected_score) - torch.testing.assert_close(logged["Valid/EnergyScore@32"], expected_score.mean()) - - with pytest.raises(FileExistsError, match="refusing to overwrite"): - evaluator.on_validation_step(batch, batch_idx=0) - - -def test_energy_batch_cap_does_not_cap_normal_mse_validation(tmp_path): - evaluator = _evaluator(tmp_path, energy_score_max_batches_per_rank=1) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - target = torch.zeros(2, 2, 4) - batch = _batch(target) - evaluator.model = SimpleNamespace( - forward_eval=lambda grouped: { - source_id: {"pred_action": source_batch["actions"]} - for source_id, source_batch in grouped.items() - } - ) - logged = [] - evaluator.trainer = SimpleNamespace( - lightning_module=SimpleNamespace( - log_dict=lambda metrics, **_kwargs: logged.append(metrics) - ) - ) - energy_calls = [] - evaluator._seeded_predictions = lambda _batch: ( - energy_calls.append(True) - or ( - {"validation/usocket": target.unsqueeze(0).repeat(32, 1, 1, 1)}, - {"validation/usocket": {"pred_action": target}}, - ) - ) - evaluator._save_artifact = lambda *_args: None - - evaluator.on_validation_step(batch, batch_idx=1) - evaluator.on_validation_step(batch, batch_idx=0) - - assert energy_calls == [True] - assert "Valid/MSE" in logged[0] and "Valid/EnergyScore@32" not in logged[0] - assert "Valid/MSE" in logged[1] and "Valid/EnergyScore@32" in logged[1] - - -def test_generic_energy_score_selects_semantic_blocks_by_embodiment(tmp_path): - blocks = { - "pushshapes_sim_u_socket": ((0, 2), (2, 4)), - "pushshapes_sim_chain_gripper": ((0, 2), (2, 4), (4, 6)), - } - evaluator = _evaluator(tmp_path, semantic_blocks_by_embodiment=blocks) - target = torch.zeros(2, 2, 6) - samples = target.unsqueeze(0).repeat(32, 1, 1, 1) - - values = evaluator._energy_values(samples, target, 20) - - assert all(not bool(torch.count_nonzero(value)) for value in values.values()) - assert evaluator.energy_score_distance_metadata == { - "space": "normalized_action_chunk", - "formula": "mean_equal_weight_semantic_block_rms", - "fallback_semantic_blocks": ((0, 2), (2, 4)), - "semantic_blocks_by_embodiment": blocks, - } - - -def test_cotrain_energy_score_routes_distance_and_audits_both_domains(tmp_path): - historical, run_dir, _, _ = _typed_evaluator(tmp_path) - contracts = { - "pushshapes_sim_u_socket": USOCKET_ENERGY_DISTANCE_CONFIG, - "pushshapes_sim_chain_gripper": None, - } - provenance = copy.deepcopy(historical.energy_score_provenance) - provenance["distance_contract"] = contracts - - class RoutedIdentityNormalizer: - @staticmethod - def unnormalize(values, embodiment_id): - assert embodiment_id in (19, 20) - return values - - evaluator = PlanarActionEval( - seed_bank_path=str(tmp_path / "energy-seeds.json"), - seed_bank_sha256=historical.seed_bank_sha256, - artifact_root=str(run_dir / "cotrain-energy"), - deterministic_seed=0, - native_decoders={ - "pushshapes_sim_u_socket": USocketRotVecNativeDecoder(), - "pushshapes_sim_chain_gripper": ChainGripperPointsNativeDecoder(), - }, - semantic_blocks_by_embodiment={ - "pushshapes_sim_u_socket": ((0, 2), (2, 4)), - "pushshapes_sim_chain_gripper": ((0, 2), (2, 4), (4, 6)), - }, - energy_score_distances_by_embodiment=contracts, - energy_score_validation_view=historical.energy_score_validation_view, - energy_score_provenance=provenance, - ) - evaluator.bind_data_context(normalizer=RoutedIdentityNormalizer()) - evaluator.trainer = SimpleNamespace( - current_epoch=0, global_step=2, global_rank=0, precision="bf16-mixed" - ) - - u_target = _rotvec(math.pi - 0.1) - u_sample = _rotvec(-math.pi + 0.1) - c_target = torch.zeros(2, 16, 6) - c_sample = c_target.clone() - c_sample[..., 0] = 1.0 - # The synthetic fixture starts as [B,K,H,A]; the evaluator's metric - # interface consumes the same samples in canonical [K,B,H,A] order. - samples_bkha = { - "u": u_sample[:, None].expand(-1, 32, -1, -1), - "c": c_sample[:, None].expand(-1, 32, -1, -1), - } - samples = { - source: value.permute(1, 0, 2, 3).contiguous() - for source, value in samples_bkha.items() - } - batch = { - "u": { - "actions": u_target, - "embodiment": torch.full((2,), 19), - "episode_hash": ["u0", "u1"], - "frame_index": torch.tensor([3, 4]), - }, - "c": { - "actions": c_target, - "embodiment": torch.full((2,), 20), - "episode_hash": ["c0", "c1"], - "frame_index": torch.tensor([5, 6]), - }, - } - scores = { - "u": evaluator._energy_values(samples["u"], u_target, 19), - "c": evaluator._energy_values(samples["c"], c_target, 20), - } - assert scores["u"]["score"].item() == pytest.approx(0.1 / math.pi, rel=1e-5) - assert scores["c"]["score"].item() == pytest.approx( - math.sqrt(1.0 / 2.0) / 3.0, rel=1e-5 - ) - assert scores["u"]["diversity"].item() == pytest.approx(0.0) - assert scores["c"]["diversity"].item() == pytest.approx(0.0) - - evaluator._save_artifact(0, samples, scores, batch) - artifact = torch.load( - run_dir / "cotrain-energy/epoch-0-step-2/rank-0-batch-0.pt", - map_location="cpu", - weights_only=True, - ) - assert artifact["schema_version"] == 2 - assert artifact["identity"]["source_commit"] == "a" * 40 - assert artifact["identity"]["distance"] == ( - evaluator.energy_score_distance_metadata - ) - assert ( - artifact["provenance"]["distance_contract"] - == (evaluator.energy_score_provenance["distance_contract"]) - ) - assert set(artifact["domains"]) == set(contracts) - assert "native_predictions" in artifact["domains"]["pushshapes_sim_u_socket"] - chain = artifact["domains"]["pushshapes_sim_chain_gripper"] - # Schema v2 retains Points6 and its separately decoded native4 payload; - # the domain's metric remains in the declared normalized semantic blocks. - native_decoder = ChainGripperPointsNativeDecoder() - expected_native = native_decoder.decode(c_sample).float().cpu() - assert chain["native_predictions"].shape == (32, 2, 16, 4) - torch.testing.assert_close(chain["predictions"], samples["c"].float().cpu()) - torch.testing.assert_close( - chain["native_predictions"], expected_native[None].expand(32, -1, -1, -1) - ) - torch.testing.assert_close( - chain["native_targets"], native_decoder.decode(c_target).float().cpu() - ) - for domain in artifact["domains"].values(): - assert len(domain["condition_ids"]) == 2 - assert torch.isfinite(domain["score_by_condition"]).all() - - altered = copy.deepcopy(provenance) - altered["distance_contract"]["pushshapes_sim_u_socket"][ - "rotation_scale_radians" - ] = 1.0 - evaluator.energy_score_provenance = altered - with pytest.raises(ValueError, match="unsupported USocket EnergyScore distance"): - evaluator._typed_artifact_identity(domains=artifact["domains"], global_step=2) diff --git a/tests/test_unite_fixed_step_requeue_boundary.py b/tests/test_unite_fixed_step_requeue_boundary.py deleted file mode 100644 index 7c50632e2d..0000000000 --- a/tests/test_unite_fixed_step_requeue_boundary.py +++ /dev/null @@ -1,121 +0,0 @@ -import json -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from egomimic.utils.slurm_requeue import SaveOnlySignalCheckpoint -from scripts.ice.unite.fixed_step_requeue_boundary import FixedStepRequeueBoundary - - -class _Strategy: - world_size = 2 - - def __init__(self): - self.barriers = [] - - def barrier(self, name): - self.barriers.append(name) - - @staticmethod - def broadcast(value, src=0): - return value - - -def _callback(tmp_path: Path, scancel: Path) -> FixedStepRequeueBoundary: - return FixedStepRequeueBoundary( - initial_global_step=3, - trigger_global_step=8, - slurm_job_id="1234", - scancel=str(scancel), - record_path=str(tmp_path / "boundary.json"), - signal_ack_timeout_seconds=0.1, - requeue_wait_timeout_seconds=0.01, - ) - - -def test_boundary_requires_exactly_five_steps(tmp_path): - executable = tmp_path / "scancel" - executable.write_text("#!/bin/sh\n", encoding="utf-8") - executable.chmod(0o700) - with pytest.raises(ValueError, match="exactly five"): - FixedStepRequeueBoundary( - initial_global_step=3, - trigger_global_step=9, - slurm_job_id="1234", - scancel=str(executable), - record_path=str(tmp_path / "boundary.json"), - ) - - -def test_step_eight_requests_boundary_and_records_all_rank_ack(monkeypatch, tmp_path): - executable = tmp_path / "scancel" - executable.write_text("#!/bin/sh\n", encoding="utf-8") - executable.chmod(0o700) - callback = _callback(tmp_path, executable) - save_callback = SaveOnlySignalCheckpoint(tmp_path / "checkpoints") - strategy = _Strategy() - trainer = SimpleNamespace( - callbacks=[callback, save_callback], - global_step=8, - is_global_zero=True, - strategy=strategy, - ) - callback.setup(trainer, SimpleNamespace(), "fit") - monkeypatch.setenv("ICE_REQUEUE_OWNER", "runner") - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", "1") - monkeypatch.setenv("SLURM_JOB_ID", "1234") - monkeypatch.delenv("SLURM_RESTART_COUNT", raising=False) - - def request(*_args, **_kwargs): - save_callback._request_generation += 1 - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr("subprocess.run", request) - callback.on_train_batch_end(trainer, SimpleNamespace(), None, None, 0) - - proof = json.loads((tmp_path / "boundary.json").read_text(encoding="utf-8")) - assert proof["observed_global_step"] == 8 - assert proof["optimizer_steps_advanced"] == 5 - assert proof["step_after_signal_executed"] is False - assert proof["save_acknowledged_ranks"] == [0, 1] - assert proof["world_size"] == 2 - assert strategy.barriers == [ - "unite-fixed-step-boundary-ready", - "unite-fixed-step-boundary-signal-acknowledged", - ] - - -def test_boundary_rejects_wrong_owner_before_signalling(monkeypatch, tmp_path): - executable = tmp_path / "scancel" - executable.write_text("#!/bin/sh\n", encoding="utf-8") - executable.chmod(0o700) - callback = _callback(tmp_path, executable) - save_callback = SaveOnlySignalCheckpoint(tmp_path / "checkpoints") - trainer = SimpleNamespace( - callbacks=[callback, save_callback], - global_step=8, - is_global_zero=True, - strategy=_Strategy(), - ) - callback.setup(trainer, SimpleNamespace(), "fit") - monkeypatch.setenv("ICE_REQUEUE_OWNER", "child") - monkeypatch.setenv("ICE_CHILD_REQUEUE_DISABLED", "1") - monkeypatch.setenv("SLURM_JOB_ID", "1234") - - with pytest.raises(RuntimeError, match="sole requeue owner"): - callback.on_train_batch_end(trainer, SimpleNamespace(), None, None, 0) - assert not (tmp_path / "boundary.json").exists() - - -def test_step_after_boundary_is_blocked(monkeypatch, tmp_path): - executable = tmp_path / "scancel" - executable.write_text("#!/bin/sh\n", encoding="utf-8") - executable.chmod(0o700) - callback = _callback(tmp_path, executable) - callback._sent = True - monotonic_values = iter((0.0, 0.0, 0.02)) - monkeypatch.setattr("time.monotonic", lambda: next(monotonic_values)) - monkeypatch.setattr("time.sleep", lambda _seconds: None) - with pytest.raises(RuntimeError, match="did not terminate"): - callback.on_train_batch_start(SimpleNamespace(), SimpleNamespace(), None, 0) diff --git a/tests/test_unite_ice_contract.py b/tests/test_unite_ice_contract.py deleted file mode 100644 index 4739df3a33..0000000000 --- a/tests/test_unite_ice_contract.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir -from omegaconf import OmegaConf - -from scripts.ice.unite.unite_contract import validate_unite_config - - -@pytest.fixture -def resolved_config(): - config_dir = Path("egomimic/hydra_configs").resolve() - with initialize_config_dir(config_dir=str(config_dir), version_base="1.3"): - return compose( - config_name="train_zarr_cartesian", - overrides=[ - "model=bf/us_unite_register_shared_nt4_s42", - "+experiment=pusht/unite_usocket_register_sweep_val01_h16", - "trainer.devices=2", - "++paths.root_dir=.", - ], - ) - - -@pytest.mark.parametrize( - ("shared", "tokens"), - [(True, 4), (True, 8), (False, 4), (False, 8)], -) -def test_clean_rows_bind_energy_view_to_world_size(resolved_config, shared, tokens): - config = OmegaConf.create(OmegaConf.to_container(resolved_config, resolve=False)) - config.model.share_encoder_denoiser = shared - config.model.num_latent_tokens = tokens - result = validate_unite_config(config, expected_world_size=2) - assert result["status"] == "READY" - assert result["row"] == { - "share_encoder_denoiser": shared, - "num_latent_tokens": tokens, - "latent_dim": 16, - } - assert result["energy_score_conditions"] == 32 - assert result["diagnostics"] == "ABSENT" - - -def test_single_gpu_view_is_supported_when_total_is_32(resolved_config): - config = OmegaConf.create(OmegaConf.to_container(resolved_config, resolve=False)) - config.trainer.devices = 1 - config.evaluator.energy_score_validation_view.world_size = 1 - config.evaluator.energy_score_validation_view.per_rank_batch_size = 32 - assert validate_unite_config(config, expected_world_size=1)["world_size"] == 1 - - -def test_energy_view_rejects_world_size_or_total_mismatch(resolved_config): - config = OmegaConf.create(OmegaConf.to_container(resolved_config, resolve=False)) - config.evaluator.energy_score_validation_view.per_rank_batch_size = 8 - with pytest.raises(ValueError, match="exactly 32 conditions"): - validate_unite_config(config, expected_world_size=2) - - -def test_optional_diagnostics_use_the_same_world_size_contract(resolved_config): - config = OmegaConf.create(OmegaConf.to_container(resolved_config, resolve=False)) - config.evaluator.unite_diagnostics = { - "validation_view": {"world_size": 2, "per_rank_batch_size": 16} - } - assert ( - validate_unite_config(config, expected_world_size=2)["diagnostics"] - == "BOUND_TO_WORLD_SIZE" - ) - config.evaluator.unite_diagnostics.validation_view.per_rank_batch_size = 15 - with pytest.raises(ValueError, match="diagnostic validation view"): - validate_unite_config(config, expected_world_size=2) - - -def test_contract_rejects_scientific_drift(resolved_config): - config = OmegaConf.create(OmegaConf.to_container(resolved_config, resolve=False)) - policy = next( - stage - for stage in config.model.pipeline.stages - if str(stage.get("_target_", "")).endswith("ReleasedRecipeUniteLatentPolicy") - ) - policy.flow_steps_per_reconstruction = 13 - with pytest.raises(ValueError, match="unsupported UNITE"): - validate_unite_config(config, expected_world_size=2) - - -def test_n8_flow42_ablation_is_explicit_and_contract_valid(): - config_dir = Path("egomimic/hydra_configs").resolve() - with initialize_config_dir(config_dir=str(config_dir), version_base="1.3"): - config = compose( - config_name="train_zarr_cartesian", - overrides=[ - "model=bf/us_unite_register_shared_nt8_flow42_s42", - "+experiment=pusht/unite_usocket_register_sweep_val01_h16", - "trainer.devices=1", - "evaluator.energy_score_validation_view.per_rank_batch_size=32", - "++paths.root_dir=.", - ], - ) - result = validate_unite_config(config, expected_world_size=1) - assert result["row"]["num_latent_tokens"] == 8 - assert result["flow_steps_per_reconstruction"] == 42 - assert ( - config.run_provenance.unite_update_schedule.flow_samples_per_reconstruction - == 42 - ) diff --git a/tests/test_unite_pinned_latent_norm.py b/tests/test_unite_pinned_latent_norm.py deleted file mode 100644 index 0ba9cc5471..0000000000 --- a/tests/test_unite_pinned_latent_norm.py +++ /dev/null @@ -1,66 +0,0 @@ -"""latent_norm_affine=False pins the tokenizer's output LayerNorm to unit scale.""" - -import pytest -import torch - -from egomimic.pipeline.stages_unite_separate import ( - build_configurable_unite_generative_encoder, -) -from tests.test_unite_split_tokenizer import C, U, _backbone_config - - -def _build(per_embodiment: bool, affine: bool, shared: bool = False): - return build_configurable_unite_generative_encoder( - backbone_config=_backbone_config(4), - share_encoder_denoiser=shared, - action_dims={U: 4, C: 6}, - condition_input_dim=14, - latent_dim=16, - num_latent_tokens=4, - condition_dim=12, - denoiser_hidden_dim=32, - gradient_checkpointing=False, - in_context_start=1, - in_context_len=8, - per_embodiment_tokenizer=per_embodiment, - latent_norm_affine=affine, - ) - - -@pytest.mark.parametrize("per_embodiment", [False, True]) -def test_pinned_tokenization_norm_has_no_parameters_and_unit_scale(per_embodiment): - torch.manual_seed(0) - enc = _build(per_embodiment, affine=False) - for domain in (U, C): - norm = enc._tokenization_norm(domain) - assert not norm.elementwise_affine - assert sum(p.numel() for p in norm.parameters()) == 0 - # the denoiser-side norm keeps its affine parameters - assert enc._denoising_norm().elementwise_affine - assert enc.latent_norm_affine is False - # the backbone's output projection is zero-initialised, so give it weight - with torch.no_grad(): - for p in enc._tokenization_backbone(U).parameters(): - p.normal_(0.0, 0.05) - actions = torch.randn(3, 5, 4) - registers = torch.randn(3, 4, 16) - latent = enc.tokenize(actions, U, registers) - assert latent.shape == (3, 4, 16) - assert torch.allclose(latent.mean(-1), torch.zeros(3, 4), atol=1e-4) - assert torch.allclose(latent.var(-1, unbiased=False), torch.ones(3, 4), atol=2e-2) - - -def test_default_keeps_affine_and_parameter_count_differs(): - free = _build(False, affine=True) - pinned = _build(False, affine=False) - n_free = sum(p.numel() for p in free.parameters()) - n_pinned = sum(p.numel() for p in pinned.parameters()) - assert n_free - n_pinned == 2 * 16 # gain + bias of one LayerNorm(16) - assert ( - not hasattr(free, "latent_norm_affine") or free.latent_norm_affine is not False - ) - - -def test_pinned_norm_rejects_tied_encoder(): - with pytest.raises(ValueError): - _build(False, affine=False, shared=True) diff --git a/tests/test_unite_register.py b/tests/test_unite_register.py deleted file mode 100644 index 7895da695a..0000000000 --- a/tests/test_unite_register.py +++ /dev/null @@ -1,651 +0,0 @@ -import inspect -import sys -from pathlib import Path -from types import SimpleNamespace - -import numpy as np -import pytest -import torch -import yaml -from hydra import compose, initialize_config_dir -from hydra.utils import get_class -from omegaconf import OmegaConf - -from egomimic.eval.planar_action_eval import PlanarActionEval -from egomimic.models.unite_action_decoder import UniteActionDecoder -from egomimic.pipeline.pushshapes import ( - USocketModelStateObservationAdapter, - USocketRotVecNativeDecoder, -) -from egomimic.pipeline.stages_sampler import KeyedFeatureProjection -from egomimic.pipeline.stages_unite_released import ( - ReleasedRecipeUniteLatentPolicy, - ReleasedRecipeUniteObjective, -) -from egomimic.pipeline.stages_unite_separate import ( - SeparateUniteGenerativeEncoder, - build_configurable_unite_generative_encoder, -) -from egomimic.rldb.embodiment.pushshapes import ( - get_planar_keymap_per_source_proprio, - get_usocket_rotvec_action_state_transform_list, -) - -DOMAIN = "pushshapes_sim_u_socket" -ROOT = Path(__file__).parents[1] -CONFIG_DIR = ROOT / "egomimic/hydra_configs" - - -def _backbone_config(num_latent_tokens: int) -> dict: - return { - "_target_": "egomimic.models.unite_dit.UniteDiTBackbone", - "input_dim": 16, - "output_dim": 16, - "horizon": num_latent_tokens, - "condition_dim": 12, - "max_condition_tokens": 1, - "max_content_tokens": 16, - "hidden_dim": 32, - "depth": 2, - "num_heads": 4, - "mlp_ratio": 2.0, - "time_fourier_dim": 16, - "context_adaln_summary": False, - "in_context_start": 1, - "in_context_len": 8, - "trainable_position_embeddings": False, - "trainable_register_position_embeddings": True, - "trainable_content_position_embeddings": False, - "trainable_condition_position_embeddings": False, - "trainable_in_context_position_embeddings": True, - "gradient_checkpointing": False, - } - - -def _encoder(shared: bool, num_latent_tokens: int): - return build_configurable_unite_generative_encoder( - backbone_config=_backbone_config(num_latent_tokens), - share_encoder_denoiser=shared, - action_dims={DOMAIN: 4}, - condition_input_dim=14, - latent_dim=16, - num_latent_tokens=num_latent_tokens, - condition_dim=12, - denoiser_hidden_dim=32, - gradient_checkpointing=False, - in_context_start=1, - in_context_len=8, - ) - - -def _policy(shared: bool, num_latent_tokens: int, flow_mini_batch: int = 14): - decoder = UniteActionDecoder( - latent_dim=16, - action_dim=4, - num_latent_tokens=num_latent_tokens, - action_horizon=16, - hidden_dim=32, - depth=2, - num_heads=4, - gradient_checkpointing=False, - ) - return ReleasedRecipeUniteLatentPolicy( - generative_encoder=_encoder(shared, num_latent_tokens), - decoders={DOMAIN: decoder}, - flow_steps_per_reconstruction=14, - flow_mini_batch=flow_mini_batch, - ) - - -@pytest.mark.parametrize("shared", [True, False]) -@pytest.mark.parametrize("num_latent_tokens", [4, 8]) -def test_register_topology_and_h16_round_trip(shared, num_latent_tokens): - policy = _policy(shared, num_latent_tokens) - encoder = policy.generative_encoder - assert encoder.num_latent_tokens == num_latent_tokens - assert encoder.denoising_module.horizon == num_latent_tokens - if shared: - assert not isinstance(encoder, SeparateUniteGenerativeEncoder) - else: - assert isinstance(encoder, SeparateUniteGenerativeEncoder) - assert encoder.tokenization_module is not encoder.denoising_module - - actions = torch.randn(2, 16, 4, requires_grad=True) - latent = encoder.tokenize( - actions, - DOMAIN, - torch.randn(2, num_latent_tokens, 16), - ) - prediction = encoder.denoise( - latent.detach(), - torch.full((2,), 0.5), - torch.randn(2, 14), - DOMAIN, - ) - decoded = policy._decode(prediction, DOMAIN) - assert latent.shape == prediction.shape == (2, num_latent_tokens, 16) - assert decoded.shape == (2, 16, 4) - (latent.square().mean() + decoded.square().mean()).backward() - assert actions.grad is not None - - -def test_action_decoder_does_not_require_horizon_register_divisibility(): - decoder = UniteActionDecoder( - latent_dim=16, - action_dim=4, - num_latent_tokens=3, - action_horizon=7, - hidden_dim=32, - depth=2, - num_heads=4, - gradient_checkpointing=False, - ) - assert decoder(torch.randn(2, 3, 16)).shape == (2, 7, 4) - - -def test_clean_content_and_in_context_tokens_enter_shared_adaln_backbone(): - encoder = _encoder(True, 4).eval() - captured = {} - sequence_lengths = [] - - def capture_inputs(_module, _args, kwargs): - captured["registers"] = _args[0] - captured["condition"] = kwargs["condition"] - captured["content"] = kwargs["content_tokens"] - - input_hook = encoder.denoising_module.register_forward_pre_hook( - capture_inputs, with_kwargs=True - ) - block_hooks = [ - block.register_forward_pre_hook( - lambda _module, args: sequence_lengths.append(int(args[0].shape[1])) - ) - for block in encoder.denoising_module.blocks - ] - registers = torch.randn(2, 4, 16) - encoder.tokenize(torch.randn(2, 16, 4), DOMAIN, registers) - input_hook.remove() - for hook in block_hooks: - hook.remove() - - assert captured["condition"].shape == (2, 1, 12) - assert captured["content"].shape == (2, 16, 12) - torch.testing.assert_close(captured["registers"], registers) - assert sequence_lengths == [20, 28] - assert all( - hasattr(block, "adaln_modulation") for block in encoder.denoising_module.blocks - ) - assert ( - "CrossTransformer" - not in Path( - encoder.denoising_module.__class__.__module__.replace(".", "/") - ).name - ) - - -@pytest.mark.parametrize("shared", [True, False]) -def test_joint_step_uses_one_reconstruction_and_fourteen_flow_samples(shared): - policy = _policy(shared, 4, flow_mini_batch=4).train() - calls = [] - modules = [policy.generative_encoder.denoising_module] - if not shared: - modules.append(policy.generative_encoder.tokenization_module) - hooks = [ - module.register_forward_hook( - lambda _module, args, _output: calls.append(int(args[0].shape[0])) - ) - for module in modules - ] - batch = { - "target": torch.randn(2, 16, 4), - "condition": torch.randn(2, 14), - "sampler/noise": torch.randn(2, 4, 16), - "embodiment": torch.tensor([19, 19]), - } - output = ReleasedRecipeUniteObjective()(policy(batch)) - for hook in hooks: - hook.remove() - - assert policy._flow_chunks(14, 4) == (4, 4, 4, 2) - assert sorted(calls) == sorted([2, 8, 8, 8, 4]) - total = output["loss/unite_reconstruction"] + output["loss/unite_latent"] - assert total.ndim == 0 and bool(torch.isfinite(total)) - assert "sampler/endpoint" not in output - assert "pred_action" not in output - total.backward() - - -def test_separate_flow_target_is_stop_gradient(): - policy = _policy(False, 4).train() - output = policy( - { - "target": torch.randn(2, 16, 4), - "condition": torch.randn(2, 14), - "sampler/noise": torch.randn(2, 4, 16), - "embodiment": torch.tensor([19, 19]), - } - ) - output["unite/flow_loss"].backward() - encoder = policy.generative_encoder - assert all( - parameter.grad is None for parameter in encoder.tokenization_module.parameters() - ) - assert all(parameter.grad is None for parameter in encoder.output_norm.parameters()) - assert any( - parameter.grad is not None - for parameter in encoder.denoising_module.parameters() - ) - - -def test_unite_graph_execution_mode_is_explicit(monkeypatch): - policy = _policy(True, 4).train() - monkeypatch.setattr( - policy, - "sample", - lambda noise, _condition, _embodiment: torch.zeros_like(noise), - ) - monkeypatch.setattr( - policy.generative_encoder, - "tokenize", - lambda *_args, **_kwargs: pytest.fail("inference called the training path"), - ) - output = policy.execute( - { - "target": torch.randn(2, 16, 4), - "condition": torch.randn(2, 14), - "sampler/noise": torch.randn(2, 4, 16), - "embodiment": torch.tensor([19, 19]), - }, - mode="inference", - ) - assert output["pred_action"].shape == (2, 16, 4) - assert "unite/flow_loss" not in output - - -def test_dopri5_has_one_fp32_integrator_with_released_grid(monkeypatch): - policy = _policy(True, 4).eval() - captured = {} - - monkeypatch.setattr( - policy, - "_guided_clean_prediction", - lambda latent, _time, _condition, _scale, _embodiment: torch.zeros_like( - latent, dtype=torch.bfloat16 - ), - ) - - def fake_odeint(function, initial, times, *, method, atol, rtol): - derivative = function(times[0], initial) - captured.update( - initial_dtype=initial.dtype, - derivative_dtype=derivative.dtype, - times=times.detach().clone(), - method=method, - atol=atol, - rtol=rtol, - ) - return torch.stack([initial for _ in times]) - - monkeypatch.setitem(sys.modules, "torchdiffeq", SimpleNamespace(odeint=fake_odeint)) - endpoint = policy.sample( - torch.randn(2, 4, 16, dtype=torch.bfloat16), - torch.randn(2, 14, dtype=torch.bfloat16), - DOMAIN, - ) - assert endpoint.shape == (2, 4, 16) - assert endpoint.dtype == torch.float32 - assert captured["initial_dtype"] == captured["derivative_dtype"] == torch.float32 - assert captured["method"] == "dopri5" - assert captured["times"].shape == (50,) - assert captured["times"][0] == 0.0 and captured["times"][-1] == 1.0 - assert captured["atol"] == 1.0e-6 and captured["rtol"] == 1.0e-3 - assert policy.dopri5_output_points == 50 - - -def test_released_unite_diagnostics_capture_dopri5_and_shared_blocks(monkeypatch): - policy = _policy(True, 4).eval() - - def fake_odeint(function, initial, times, *, method, atol, rtol): - assert method == "dopri5" - assert atol == 1.0e-6 and rtol == 1.0e-3 - assert bool(torch.isfinite(function(times[1], initial)).all()) - return torch.stack( - [initial + float(index) / 100.0 for index in range(len(times))] - ) - - monkeypatch.setitem(sys.modules, "torchdiffeq", SimpleNamespace(odeint=fake_odeint)) - diagnostics = policy.validation_diagnostics( - noise=torch.randn(12, 4, 16), - condition=torch.randn(12, 14), - target=torch.randn(12, 16, 4), - embodiment=DOMAIN, - raw_noise_levels=[0.0, 0.5, 1.0], - ) - assert diagnostics["sampler_latents"].shape == (50, 12, 4, 16) - assert diagnostics["decoded_actions_normalized"].shape == (50, 12, 16, 4) - assert diagnostics["noise_level_final_predictions"].shape == (3, 12, 4, 16) - assert set(diagnostics["tokenization_activations"]) == {"block_00", "block_01"} - for layer in diagnostics["tokenization_activations"]: - assert diagnostics["tokenization_activations"][layer].shape[0] == 12 - assert diagnostics["denoising_activations"][layer].shape[0] == 3 - assert not policy.generative_encoder.denoising_module.blocks[ - int(layer.rsplit("_", 1)[1]) - ]._forward_hooks - assert bool(torch.isfinite(diagnostics["sampler_latents"]).all()) - - -def test_cfg_threads_the_dataset_selected_branch_to_encoder(monkeypatch): - policy = _policy(True, 4).eval() - selected = [] - - def null_condition(condition, embodiment): - selected.append(("null", embodiment)) - return torch.zeros_like(condition) - - def denoise(latent, _time, _condition, embodiment): - selected.append(("denoise", embodiment)) - return torch.zeros_like(latent) - - monkeypatch.setattr( - policy.generative_encoder, "null_condition_like", null_condition - ) - monkeypatch.setattr(policy.generative_encoder, "denoise", denoise) - policy._guided_clean_prediction( - torch.randn(2, 4, 16), - torch.full((2,), 0.5), - torch.randn(2, 14), - 4.0, - DOMAIN, - ) - assert selected == [("null", DOMAIN), ("denoise", DOMAIN)] - - -@pytest.mark.parametrize( - ("topology", "num_latent_tokens"), - [("shared", 4), ("shared", 8), ("separate", 4), ("separate", 8)], -) -def test_four_rows_are_thin_overlays_of_one_resolved_base(topology, num_latent_tokens): - model_dir = CONFIG_DIR / "model/bf" - canonical_path = model_dir / "us_unite_register_shared_nt4_s42.yaml" - canonical = OmegaConf.load(canonical_path) - row_path = ( - model_dir / f"us_unite_register_{topology}_nt{num_latent_tokens}_s42.yaml" - ) - if row_path == canonical_path: - row = canonical - assert "defaults" not in row - else: - overlay = OmegaConf.load(row_path) - assert set(overlay) <= { - "defaults", - "share_encoder_denoiser", - "num_latent_tokens", - } - assert set(overlay) - {"defaults"} - del overlay["defaults"] - row = OmegaConf.merge(canonical, overlay) - root = OmegaConf.create({"model": row}) - OmegaConf.resolve(root) - model = root.model - assert model.share_encoder_denoiser is (topology == "shared") - assert model.num_latent_tokens == num_latent_tokens - stages = model.pipeline.stages - assert not { - "action_horizon", - "domains", - "ac_keys", - "rollout_adapter", - "rollout_adapters", - "rollout_observation_adapters", - "norm_stats", - } & set(model.pipeline) - assert [stage._target_.rsplit(".", 1)[-1] for stage in stages[:4]] == [ - "KeyedFeatureProjection", - "FusedObsEncoder", - "ActionTargetBuilder", - "GaussianLatentNoise", - ] - assert stages[3].num_tokens == num_latent_tokens - ge = stages[4].generative_encoder - assert ge.share_encoder_denoiser is (topology == "shared") - assert ge.num_latent_tokens == ge.backbone_config.horizon == num_latent_tokens - assert stages[4].flow_mini_batch == 4 - assert ge.gradient_checkpointing is True - assert ge.backbone_config.gradient_checkpointing is True - assert stages[4].decoders[DOMAIN].action_horizon == 16 - assert stages[4].decoders[DOMAIN].gradient_checkpointing is True - - -@pytest.mark.parametrize("topology", ["shared", "separate"]) -@pytest.mark.parametrize("num_latent_tokens", [4, 8]) -def test_four_rows_compose_with_supported_data_and_evaluator_schema( - topology, num_latent_tokens -): - row = f"us_unite_register_{topology}_nt{num_latent_tokens}_s42" - with initialize_config_dir(version_base=None, config_dir=str(CONFIG_DIR.resolve())): - cfg = compose( - config_name="train_zarr_cartesian", - overrides=[ - f"model=bf/{row}", - "+experiment=pusht/unite_usocket_register_sweep_val01_h16", - "++paths.root_dir=.", - ], - ) - OmegaConf.resolve(cfg.model) - OmegaConf.resolve(cfg.data) - assert cfg.model.share_encoder_denoiser is (topology == "shared") - assert cfg.model.num_latent_tokens == num_latent_tokens - for section in (cfg.data, cfg.evaluator): - target = get_class(section._target_) - accepted = set(inspect.signature(target.__init__).parameters) - assert set(section) - accepted - {"_target_", "_recursive_"} == set() - - -def test_only_registered_rows_and_no_legacy_or_diagnostic_surface(): - model_dir = CONFIG_DIR / "model/bf" - rows = sorted(path.name for path in model_dir.glob("us_unite_register_*_s42.yaml")) - assert rows == [ - "us_unite_register_separate_nt4_s42.yaml", - "us_unite_register_separate_nt8_s42.yaml", - "us_unite_register_shared_nt16_s42.yaml", - "us_unite_register_shared_nt4_s42.yaml", - "us_unite_register_shared_nt8_flow42_s42.yaml", - "us_unite_register_shared_nt8_s42.yaml", - ] - assert ( - sorted(path.name for path in model_dir.glob("us_unite_register*.yaml")) == rows - ) - - -def test_ice_launcher_explicitly_wires_unite_diagnostics(): - launcher = (ROOT / "scripts/ice/launch_unite.sbatch").read_text() - assert "ICE_UNITE_DIAGNOSTICS=${ICE_UNITE_DIAGNOSTICS:-false}" in launcher - assert "evaluator.unite_diagnostics.enabled=true" in launcher - assert ( - "evaluator.unite_diagnostics.validation_view.split_manifest_sha256=" - "$ICE_EXPECTED_SPLIT_MANIFEST_SHA256" in launcher - ) - assert '"$ICE_EXPECTED_SPLIT_MANIFEST_SHA256" "$ICE_UNITE_DIAGNOSTICS"' in launcher - assert "launch_contract.json" in launcher - assert '"world_size": int(world_size)' in launcher - assert '"unite_diagnostics": unite_diagnostics' in launcher - assert "ICE_WORLD_SIZE=1" in launcher - assert "#SBATCH --gres=gpu:1" in launcher - assert "#SBATCH --constraint=H100|H200" in launcher - assert "ICE_GPU_CONTRACT=${ICE_GPU_CONTRACT:-H100_OR_H200}" in launcher - assert ( - "GPU_PROBE_ARGS=(--allowed-gpu-name H100 --allowed-gpu-name H200)" in launcher - ) - assert "GPU_PROBE_ARGS=(--allowed-gpu-name L40S)" in launcher - assert "GPU_PROBE_ARGS=(--allowed-gpu-name A100)" in launcher - assert '"${GPU_PROBE_ARGS[@]}"' in launcher - assert "ICE_UNITE_FAST=${ICE_UNITE_FAST:-false}" in launcher - assert "ICE_UNITE_H100_FAST" not in launcher - assert "ICE_EXPECTED_GPU_NAME" not in launcher - assert "bf/us_unite_register_shared_nt8_flow42_s42" in launcher - assert "+runtime.slurm_requeue_owner" not in launcher - assert "runtime.slurm_requeue_owner=runner" in launcher - assert '"trainer.devices=$ICE_WORLD_SIZE"' in launcher - assert "export ICE_WORLD_SIZE ICE_TRAINER_STRATEGY" in launcher - assert ( - '"evaluator.unite_diagnostics.validation_view.world_size=$ICE_WORLD_SIZE"' - in launcher - ) - assert '--ntasks="${ICE_WORLD_SIZE:?}"' in launcher - assert 'test "$ICE_MAX_STEPS" -ge "$ICE_GRADIENT_TELEMETRY_CADENCE"' in launcher - assert 'test "$ICE_VAL_CHECK_INTERVAL" -le "$ICE_MAX_STEPS"' in launcher - assert not (ROOT / "egomimic/pipeline/stages_unite.py").exists() - policy_parameters = inspect.signature( - ReleasedRecipeUniteLatentPolicy.__init__ - ).parameters - assert not { - "sampling_method", - "cfg_norm_order", - "dopri5_num_steps", - "reconstruction_noise_std", - } & set(policy_parameters) - experiment = yaml.safe_load( - ( - CONFIG_DIR / "experiment/pusht/unite_usocket_register_sweep_val01_h16.yaml" - ).read_text() - ) - assert experiment["trainer"]["max_steps"] == 240_000 - assert experiment["trainer"]["check_val_every_n_epoch"] is None - assert experiment["trainer"]["gradient_clip_val"] == 3.0 - assert experiment["trainer"]["gradient_clip_algorithm"] == "norm" - assert experiment["callbacks"]["model_checkpoint"]["every_n_train_steps"] == 20_000 - assert experiment["callbacks"]["model_checkpoint"]["every_n_epochs"] is None - assert experiment["eval_checkpoint"] == {"use_ema": True} - assert {"override /evaluator": "eval_planar_v2"} in experiment["defaults"] - assert experiment["evaluator"]["energy_score_max_batches_per_rank"] == 1 - assert experiment["evaluator"]["deterministic_seed"] == 420_042 - assert experiment["evaluator"]["energy_score_validation_view"]["definition"] == ( - "first_deterministic_validation_batch_per_ddp_rank" - ) - assert experiment["evaluator"]["energy_score_provenance"]["sampler"] == "dopri5" - assert experiment["evaluator"]["semantic_blocks"] == [[0, 2], [2, 4]] - assert experiment["evaluator"]["native_decoder"] == { - "_target_": "egomimic.pipeline.pushshapes.USocketRotVecNativeDecoder" - } - assert set(experiment["deployment"]) == { - "observation_adapter", - "action_decoder", - } - assert experiment["run_provenance"]["energy_score_contract"]["sample_count"] == 32 - assert "train_only_normalization_sha256" not in experiment["run_provenance"] - assert "unite_diagnostics" not in experiment["evaluator"] - assert "unite_training_diagnostics" not in experiment["run_provenance"] - - -def test_unite_energy_score_is_limited_without_truncating_mse_validation(tmp_path): - evaluator = PlanarActionEval( - seed_bank_path=str(CONFIG_DIR / "evaluator/energy_score_seed_bank_k32_v1.json"), - seed_bank_sha256=( - "88657b829905d4374823db145ded19b99cec4735f76694734473bcee068bb5b6" - ), - artifact_root=str(tmp_path), - semantic_blocks=((0, 2), (2, 4)), - energy_score_max_batches_per_rank=1, - ) - - class IdentityNorm: - @staticmethod - def unnormalize(values, _embodiment_id): - return values - - target = torch.zeros(2, 16, 4) - evaluator.model = SimpleNamespace( - forward_eval=lambda _batch: {19: {"pred_action": target}}, - ) - evaluator.bind_data_context(normalizer=IdentityNorm()) - evaluator.trainer = SimpleNamespace( - current_epoch=0, - global_step=0, - global_rank=0, - lightning_module=SimpleNamespace(log_dict=lambda *_args, **_kwargs: None), - ) - score_calls = [] - evaluator._seeded_predictions = lambda _batch: ( - score_calls.append(True) - or ( - {19: target.unsqueeze(0).repeat(32, 1, 1, 1)}, - {19: {"pred_action": target}}, - ) - ) - evaluator._save_artifact = lambda *_args: None - batch = { - 19: { - "actions": target, - "embodiment": torch.full((target.shape[0],), 19), - } - } - - evaluator.on_validation_step(batch, batch_idx=1) - assert score_calls == [] - evaluator.on_validation_step(batch, batch_idx=0) - assert score_calls == [True] - - -@pytest.mark.parametrize("as_torch", [False, True]) -def test_usocket_training_and_rollout_action_contract_match(as_torch): - raw = np.array([10.0, 20.0, -1.2, 30.0, 40.0, 0.5], dtype=np.float32) - actions = np.tile(np.array([1.0, 2.0, -0.4], dtype=np.float32), (16, 1)) - if as_torch: - raw, actions = torch.from_numpy(raw), torch.from_numpy(actions) - sample = { - "state_agent_obj": raw, - "state_agent_model": raw.clone() if as_torch else raw.copy(), - "actions": actions, - } - for transform in get_usocket_rotvec_action_state_transform_list(): - sample = transform.transform(sample) - assert sample["state_agent_model"].shape == (4,) - assert sample["actions"].shape == (16, 4) - - rollout_obs = USocketModelStateObservationAdapter().encode({"state_agent_obj": raw}) - assert rollout_obs["state_agent_obj"] is raw - assert rollout_obs["state_agent_model"].shape == (4,) - decoded = USocketRotVecNativeDecoder().decode(sample["actions"]) - if as_torch: - torch.testing.assert_close(decoded, actions) - else: - np.testing.assert_allclose(decoded, actions, atol=1e-6) - - -def test_usocket_keymap_and_proprio_projection_are_explicit(): - keymap = get_planar_keymap_per_source_proprio(action_horizon=16) - assert keymap["state_agent_obj"]["key_type"] == "metadata_keys" - assert keymap["state_agent_model"]["key_type"] == "proprio_keys" - assert keymap["actions"]["horizon"] == 16 - - projection = KeyedFeatureProjection( - selector_key="embodiment", - selector_aliases={19: DOMAIN}, - input_key="state_agent_model", - output_key="proprio_condition", - projections={DOMAIN: {"input_dim": 4, "hidden_dim": 8}}, - output_dim=6, - ) - assert projection.contract() == ( - ("state_agent_model", "embodiment"), - ("proprio_condition",), - ) - batch = { - "embodiment": torch.tensor([19, 19, 19]), - "state_agent_model": torch.randn(3, 4), - } - assert projection(batch)["proprio_condition"].shape == (3, 6) - with pytest.raises(ValueError, match="expected 4"): - projection( - { - "embodiment": torch.tensor([19, 19, 19]), - "state_agent_model": torch.randn(3, 6), - } - ) - with pytest.raises(ValueError, match="homogeneous"): - projection( - { - "embodiment": torch.tensor([19, 20, 19]), - "state_agent_model": torch.randn(3, 4), - } - ) diff --git a/tests/test_unite_split_tokenizer.py b/tests/test_unite_split_tokenizer.py deleted file mode 100644 index 71e7768d9c..0000000000 --- a/tests/test_unite_split_tokenizer.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Cotrain topology B: one tokenizer per embodiment, one shared denoiser.""" - -import numpy as np -import pytest -import torch - -from egomimic.pipeline.pushshapes import PaddedPlanarCommon5NativeDecoder -from egomimic.pipeline.stages_unite_separate import ( - PerEmbodimentTokenizerUniteGenerativeEncoder, - SeparateUniteGenerativeEncoder, - build_configurable_unite_generative_encoder, -) -from egomimic.rldb.embodiment.pushshapes import get_planar_paper_padded_transform_list -from egomimic.rldb.zarr.action_chunk_transforms import PadActionWidth -from tests.test_unite_register import _backbone_config - -U = "pushshapes_sim_u_socket" -C = "pushshapes_sim_chain_gripper" - - -def _encoder(per_embodiment: bool, shared: bool = False): - return build_configurable_unite_generative_encoder( - backbone_config=_backbone_config(4), - share_encoder_denoiser=shared, - action_dims={U: 4, C: 6}, - condition_input_dim=14, - latent_dim=16, - num_latent_tokens=4, - condition_dim=12, - denoiser_hidden_dim=32, - gradient_checkpointing=False, - in_context_start=1, - in_context_len=8, - per_embodiment_tokenizer=per_embodiment, - ) - - -def test_split_tokenizer_topology_and_parameter_ownership(): - enc = _encoder(True) - assert isinstance(enc, PerEmbodimentTokenizerUniteGenerativeEncoder) - assert not isinstance(enc, SeparateUniteGenerativeEncoder) - assert set(enc.tokenization_modules) == {U, C} - assert enc.tokenization_modules[U] is not enc.tokenization_modules[C] - assert enc.tokenization_module is not enc.denoising_module - assert not hasattr(enc, "output_norm") - # parameter budget: A (shared tokenizer body) + one extra tokenizer DiT - a = _encoder(False) - tok = sum(p.numel() for p in a.tokenization_module.parameters()) - n_a = sum(p.numel() for p in a.parameters()) - n_b = sum(p.numel() for p in enc.parameters()) - extra_heads = sum( - p.numel() - for n, p in enc.named_parameters() - if n.startswith( - ("tokenization_output_norms.", "tokenization_condition_projections.") - ) - ) - sum( - p.numel() - for n, p in a.named_parameters() - if n.startswith(("output_norm.", "tokenization_condition_projection.")) - ) - assert n_b == n_a + tok + extra_heads - names = dict(enc.named_parameters()) - assert not any(n.startswith("tokenization_module.") for n in names) - assert len({id(p) for p in names.values()}) == len(names) - - -def test_split_tokenizer_routes_each_embodiment_to_its_own_tokenizer(): - torch.manual_seed(0) - enc = _encoder(True).eval() - reg = torch.randn(3, 4, 16) - cond = torch.randn(3, 14) - z_u = enc.tokenize(torch.randn(3, 16, 4), U, reg) - z_c = enc.tokenize(torch.randn(3, 16, 6), C, reg) - assert z_u.shape == z_c.shape == (3, 4, 16) - # zeroing the chain tokenizer must not change the U-Socket latent - with torch.no_grad(): - for p in enc.tokenization_modules[C].parameters(): - p.zero_() - torch.manual_seed(1) - z_u2 = enc.tokenize(torch.randn(3, 16, 4) * 0 + 1, U, reg) - torch.manual_seed(1) - z_u3 = enc.tokenize(torch.randn(3, 16, 4) * 0 + 1, U, reg) - assert torch.allclose(z_u2, z_u3) - with pytest.raises(ValueError): - enc.tokenize(torch.randn(3, 16, 6), U, reg) - out = enc.denoise(torch.randn(3, 4, 16), torch.rand(3), cond, C) - assert out.shape == (3, 4, 16) - - -def test_split_tokenizer_gradients_stay_per_embodiment(): - enc = _encoder(True).train() - reg = torch.randn(2, 4, 16) - enc.tokenize(torch.randn(2, 16, 4), U, reg).sum().backward() - assert all(p.grad is None for p in enc.tokenization_modules[C].parameters()) - assert any(p.grad is not None for p in enc.tokenization_modules[U].parameters()) - assert all(p.grad is None for p in enc.denoising_module.parameters()) - - -def test_split_tokenizer_requires_unshared_flag(): - with pytest.raises(ValueError): - _encoder(True, shared=True) - - -def test_split_tokenizer_state_dict_round_trip(): - a = _encoder(True) - b = _encoder(True) - b.load_state_dict(a.state_dict(), strict=True) - for (n1, p1), (n2, p2) in zip(a.named_parameters(), b.named_parameters()): - assert n1 == n2 and torch.equal(p1, p2) - - -def test_padded_planar_transform_and_decoder_round_trip_shape(): - transforms = get_planar_paper_padded_transform_list( - action_horizon=16, action_target_offset=1 - ) - batch = {"actions": np.random.rand(17, 3).astype(np.float32)} - for t in transforms: - batch = t.transform(batch) - assert batch["actions"].shape == (16, 6) - assert np.all(batch["actions"][:, 4:] == 0) - dec = PaddedPlanarCommon5NativeDecoder( - action_horizon=16, native_action_dim=3, padded_dim=6 - ) - native = dec.decode(torch.from_numpy(batch["actions"])) - assert native.shape == (1, 16, 3) - with pytest.raises(ValueError): - PadActionWidth(["actions"], width=2).transform({"actions": np.zeros((4, 3))}) - - -def _policy(per_embodiment: bool, compile_backbones: bool): - from egomimic.models.unite_action_decoder import UniteActionDecoder - from egomimic.pipeline.stages_unite_released import ReleasedRecipeUniteLatentPolicy - - decoders = { - d: UniteActionDecoder( - latent_dim=16, - action_dim=a, - num_latent_tokens=4, - action_horizon=16, - hidden_dim=32, - depth=2, - num_heads=4, - gradient_checkpointing=False, - ) - for d, a in ((U, 4), (C, 6)) - } - return ReleasedRecipeUniteLatentPolicy( - generative_encoder=_encoder(per_embodiment), - decoders=decoders, - flow_steps_per_reconstruction=14, - flow_mini_batch=14, - compile_backbones=compile_backbones, - ) - - -@pytest.mark.parametrize("per_embodiment", [False, True]) -def test_compile_flag_wraps_backbones_without_renaming_parameters(per_embodiment): - torch.manual_seed(0) - plain = _policy(per_embodiment, False) - torch.manual_seed(0) - compiled = _policy(per_embodiment, True) - assert compiled.compile_backbones is True and plain.compile_backbones is False - targets = compiled.compiled_modules() - assert ( - len(targets) == (3 if per_embodiment else 2) + 2 - ) # tokenizer(s) + denoiser + two decoders - assert all(getattr(m, "_compiled_call_impl", None) is not None for m in targets) - assert all( - getattr(m, "_compiled_call_impl", None) is None - for m in plain.compiled_modules() - ) - assert list(compiled.state_dict().keys()) == list(plain.state_dict().keys()) - plain.load_state_dict(compiled.state_dict(), strict=True) - for (n1, p1), (n2, p2) in zip( - plain.named_parameters(), compiled.named_parameters() - ): - assert n1 == n2 and torch.equal(p1, p2) diff --git a/tests/test_unite_training.py b/tests/test_unite_training.py deleted file mode 100644 index dc30f7afe3..0000000000 --- a/tests/test_unite_training.py +++ /dev/null @@ -1,507 +0,0 @@ -import copy -from collections import OrderedDict - -import pytest -import torch -import torch.nn as nn -from omegaconf import OmegaConf - -from egomimic.pl_utils.pl_model import ModelWrapper -from egomimic.pl_utils.training_behavior_unite import ( - ReleasedUniteTrainingBehavior, -) -from egomimic.pl_utils.training_metrics import gradient_norm, reduce_component_means -from egomimic.trainHydra import _instantiate_model_wrapper, _resolve_model_wrapper_class -from egomimic.utils.unite_optim import ( - ReleasedUniteCompositeOptimizer, - VendoredTorchMuon, - partition_released_unite_parameters, - released_unite_two_stage_scheduler, -) - - -class _PartitionPolicy(nn.Module): - def __init__(self): - super().__init__() - visual = nn.Module() - visual.encoder = nn.Module() - visual.encoder.img_encoders = nn.ModuleDict( - {"front_img_1": nn.Sequential(nn.Conv2d(3, 2, 1), nn.Flatten())} - ) - generative = nn.Module() - generative.blocks = nn.ModuleList([nn.Linear(2, 2)]) - generative.content_projection = nn.Linear(2, 2) - generative.condition_projection = nn.Linear(2, 2) - generative.final_layer = nn.Linear(2, 2) - generative.action_projection = nn.Linear(2, 2) - self.stages = nn.ModuleList([visual, generative]) - - -class _PartitionAlgo: - def __init__(self): - self.nets = nn.ModuleDict({"pipeline": _PartitionPolicy()}) - - -class _Backbone(nn.Module): - def __init__(self): - super().__init__() - self.shared = nn.Linear(1, 1, bias=False) - self.content_projection = nn.Linear(1, 1, bias=False) - - -class _SharedEncoder(nn.Module): - def __init__(self): - super().__init__() - self.domains = ("demo",) - self.denoising_module = _Backbone() - self.output_norm = nn.Linear(1, 1, bias=False) - self.condition_projection = nn.Linear(1, 1, bias=False) - self.null_condition_inputs = nn.ParameterDict( - {"demo": nn.Parameter(torch.full((1,), 0.5))} - ) - self.domain_embeddings = nn.ParameterDict( - {"demo": nn.Parameter(torch.full((1,), 0.5))} - ) - self.action_context_projections = nn.ModuleDict( - {"demo": nn.Linear(1, 1, bias=False)} - ) - - @staticmethod - def _resolve_domain(_value=None): - return "demo" - - -class _SeparateEncoder(_SharedEncoder): - def __init__(self): - super().__init__() - self.tokenization_module = self.denoising_module - self.denoising_module = _Backbone() - self.denoising_output_norm = nn.Linear(1, 1, bias=False) - self.tokenization_condition_projection = nn.Linear(1, 1, bias=False) - self.tokenization_null_condition_inputs = nn.ParameterDict( - {"demo": nn.Parameter(torch.full((1,), 0.5))} - ) - self.denoising_domain_embeddings = nn.ParameterDict( - {"demo": nn.Parameter(torch.full((1,), 0.5))} - ) - - -class _UniteStage(nn.Module): - def __init__(self, shared: bool, dropout_probability: float = 0.0): - super().__init__() - self.generative_encoder = _SharedEncoder() if shared else _SeparateEncoder() - self.condition_dropout_probability = float(dropout_probability) - - -class _MetricPipeline(nn.Module): - def __init__(self, shared: bool, dropout_probability: float): - super().__init__() - self.stages = nn.ModuleList( - [_UniteStage(shared, dropout_probability=dropout_probability)] - ) - self.anchor = nn.Parameter(torch.ones(())) - - -class _MetricAlgo: - def __init__(self, *, shared: bool = True, dropout_probability: float = 0.0): - self.nets = nn.ModuleDict( - { - "pipeline": _MetricPipeline( - shared, dropout_probability=dropout_probability - ) - } - ) - self.device = torch.device("cpu") - - @property - def pipeline(self): - return self.nets["pipeline"] - - def process_batch_for_training(self, batch): - return batch - - def forward_training(self, batch): - anchor = self.pipeline.anchor - results = OrderedDict() - for source, values in batch.items(): - count = int(values["count"]) - results[source] = { - "target": anchor.new_zeros((count, 1, 1)), - "embodiment": "demo", - "loss/unite_reconstruction": anchor - * anchor.new_tensor(values["reconstruction"]), - "loss/unite_latent": anchor * anchor.new_tensor(values["flow"]), - "log/unite_reconstruction_l1": anchor.new_tensor(values["l1"]), - } - return results - - -def _wrapper(*, shared=True, dropout_probability=0.0): - return ModelWrapper( - pipeline=_MetricAlgo(shared=shared, dropout_probability=dropout_probability), - training_behavior=ReleasedUniteTrainingBehavior(), - ) - - -def _batch(*rows): - return OrderedDict( - ( - f"source_{index}", - { - "count": count, - "reconstruction": reconstruction, - "flow": flow, - "l1": l1, - }, - ) - for index, (count, reconstruction, flow, l1) in enumerate(rows) - ) - - -def _optimizer(model) -> ReleasedUniteCompositeOptimizer: - return ReleasedUniteCompositeOptimizer( - model.named_parameters(), - lr=1.0e-3, - adamw_weight_decay=0.0, - muon_weight_decay=0.0, - ) - - -def _quadratic_loss(model): - return sum(parameter.square().sum() for parameter in model.parameters()) - - -def _parameter_loss(named, offset: float): - return sum((parameter - offset).square().sum() for _, parameter in named) - - -def test_parameter_partition_is_complete_disjoint_and_name_stable(): - model = _PartitionAlgo().nets - named = tuple(model.named_parameters(prefix="nets", remove_duplicate=True)) - adamw, muon = partition_released_unite_parameters(named) - adamw_names = {name for name, _ in adamw} - muon_names = {name for name, _ in muon} - assert adamw_names.isdisjoint(muon_names) - assert adamw_names | muon_names == {name for name, _ in named} - assert len(named) == len({id(parameter) for _, parameter in named}) - assert any("img_encoders" in name for name in adamw_names) - assert any("content_projection" in name for name in adamw_names) - assert any("condition_projection" in name for name in adamw_names) - assert any("blocks.0.weight" in name for name in muon_names) - assert any("action_projection.weight" in name for name in muon_names) - - -def test_composite_optimizer_step_and_state_round_trip(): - torch.manual_seed(7) - model_a = _PartitionAlgo().nets - optimizer_a = _optimizer(model_a) - _quadratic_loss(model_a).backward() - optimizer_a.step() - optimizer_a.zero_grad() - assert isinstance(optimizer_a.muon, VendoredTorchMuon) - assert optimizer_a.muon.state - state = copy.deepcopy(optimizer_a.state_dict()) - - model_b = _PartitionAlgo().nets - model_b.load_state_dict(model_a.state_dict()) - optimizer_b = _optimizer(model_b) - optimizer_b.load_state_dict(state) - _quadratic_loss(model_a).backward() - _quadratic_loss(model_b).backward() - optimizer_a.step() - optimizer_b.step() - for left, right in zip(model_a.parameters(), model_b.parameters()): - torch.testing.assert_close(left, right, rtol=0.0, atol=0.0) - - -def test_released_scheduler_intentionally_keeps_phase_two_beyond_run(): - parameter = nn.Parameter(torch.zeros(())) - optimizer = torch.optim.SGD([parameter], lr=1.0e-4) - scheduler = released_unite_two_stage_scheduler( - optimizer, - warmup_steps=8_000, - decay_start_1_steps=12_000, - decay_end_1_steps=20_000, - decay_start_2_steps=1_200_000, - decay_end_2_steps=1_200_000, - base_lr_1=1.0e-4, - base_lr_2=5.0e-5, - final_lr=1.0e-5, - ) - multiplier = scheduler.lr_lambdas[0] - assert multiplier(0) == 0.0 - assert multiplier(8_000) == 1.0 - assert multiplier(12_000) == 1.0 - assert multiplier(20_000) == 0.5 - assert multiplier(240_000) == 0.5 - assert multiplier(1_200_000) == pytest.approx(0.1) - - -def test_wrapper_has_no_direct_optimizer_or_topology_compatibility(): - wrapper = _wrapper() - with pytest.raises(RuntimeError, match="requires config_tree"): - wrapper.configure_optimizers() - with pytest.raises(TypeError, match="unexpected keyword argument"): - ModelWrapper(pipeline=_MetricAlgo(), share_encoder_denoiser=True) - with pytest.raises(TypeError, match="unexpected keyword argument"): - ModelWrapper(pipeline=_MetricAlgo(), optimizer=lambda *_args: None) - - -def test_training_components_are_sample_weighted_and_finite(monkeypatch): - wrapper = _wrapper() - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **kwargs: logged.setdefault(name, (value, kwargs)), - ) - loss = wrapper.training_step(_batch((1, 1.0, 2.0, 3.0), (3, 5.0, 6.0, 7.0)), 0) - - assert float(loss) == pytest.approx(9.0) - expected = { - "Train/UNITE/TotalLoss": 9.0, - "Train/UNITE/ReconstructionLoss": 4.0, - "Train/UNITE/FlowLoss": 5.0, - "Train/UNITE/ReconstructionL1": 6.0, - } - assert set(logged) == set(expected) - for name, expected_value in expected.items(): - value, kwargs = logged[name] - assert float(value) == pytest.approx(expected_value) - assert kwargs == { - "on_step": True, - "on_epoch": True, - "sync_dist": False, - "batch_size": 4, - } - - with pytest.raises(RuntimeError, match="Non-finite metric"): - wrapper.training_step(_batch((1, float("nan"), 1.0, 1.0)), 1) - - -def test_component_reduction_weights_remote_ranks(monkeypatch): - local = OrderedDict( - TotalLoss=torch.tensor(3.0), - ReconstructionLoss=torch.tensor(1.0), - FlowLoss=torch.tensor(2.0), - ReconstructionL1=torch.tensor(4.0), - ) - remote_means = torch.tensor((9.0, 4.0, 5.0, 7.0), dtype=torch.float64) - monkeypatch.setattr(torch.distributed, "is_available", lambda: True) - monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True) - - def add_remote(payload, op): - assert op is torch.distributed.ReduceOp.SUM - payload.add_(torch.cat((remote_means * 3, torch.tensor((3.0,))))) - - monkeypatch.setattr(torch.distributed, "all_reduce", add_remote) - reduced, count = reduce_component_means(local, 1, label="UNITE") - assert count == 4 - assert float(reduced["TotalLoss"]) == pytest.approx(7.5) - assert float(reduced["ReconstructionLoss"]) == pytest.approx(3.25) - assert float(reduced["FlowLoss"]) == pytest.approx(4.25) - assert float(reduced["ReconstructionL1"]) == pytest.approx(6.25) - - -def test_validation_components_weight_sources_batches_and_ranks(monkeypatch): - wrapper = _wrapper() - wrapper.on_validation_start() - wrapper.validation_step(_batch((1, 1.0, 2.0, 3.0), (3, 5.0, 6.0, 7.0)), 0) - wrapper.validation_step(_batch((2, 9.0, 10.0, 11.0)), 1) - - remote_means = torch.tensor((6.0, 2.0, 4.0, 5.0), dtype=torch.float64) - monkeypatch.setattr(torch.distributed, "is_available", lambda: True) - monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True) - - def add_remote(payload, op): - assert op is torch.distributed.ReduceOp.SUM - payload.add_(torch.cat((remote_means * 4, torch.tensor((4.0,))))) - - monkeypatch.setattr(torch.distributed, "all_reduce", add_remote) - logged = {} - monkeypatch.setattr( - wrapper, - "log_dict", - lambda values, **kwargs: logged.update({"values": values, "kwargs": kwargs}), - ) - wrapper.on_validation_epoch_end() - - assert logged["kwargs"] == { - "on_step": False, - "on_epoch": True, - "sync_dist": False, - } - values = logged["values"] - assert set(values) == { - "Valid/UNITE/TotalLoss", - "Valid/UNITE/ReconstructionLoss", - "Valid/UNITE/FlowLoss", - "Valid/UNITE/ReconstructionL1", - } - assert float(values["Valid/UNITE/TotalLoss"]) == pytest.approx(9.8) - assert float(values["Valid/UNITE/ReconstructionLoss"]) == pytest.approx(4.2) - assert float(values["Valid/UNITE/FlowLoss"]) == pytest.approx(5.6) - assert float(values["Valid/UNITE/ReconstructionL1"]) == pytest.approx(6.6) - assert not any("Native" in name or "Energy" in name for name in values) - - -def test_gradient_telemetry_runs_on_update_completing_each_hundred_steps(monkeypatch): - wrapper = _wrapper() - monkeypatch.setattr(wrapper, "log", lambda *_args, **_kwargs: None) - completed_steps = {"value": 0} - monkeypatch.setattr( - ModelWrapper, - "global_step", - property(lambda _self: completed_steps["value"]), - ) - measured = [] - monkeypatch.setattr( - wrapper.training_behavior, - "_measure_topology_gradients", - lambda *_args: measured.append(completed_steps["value"] + 1), - ) - for step in (0, 1, 98, 99, 100, 198, 199, 200): - completed_steps["value"] = step - wrapper.training_step(_batch((1, 1.0, 1.0, 1.0)), step) - assert measured == [100, 200] - - -def test_shared_telemetry_uses_true_intersection_without_touching_grad(monkeypatch): - wrapper = _wrapper(shared=True, dropout_probability=0.0) - behavior = wrapper.training_behavior - _, encoder, shared = behavior._unite_topology() - assert shared - selected = behavior._shared_named_parameters( - encoder, ("demo",), include_null_input=False - ) - selected_names = {name for name, _ in selected} - assert "domain_embeddings.demo" in selected_names - assert not any("content_projection" in name for name in selected_names) - assert not any("action_context_projections" in name for name in selected_names) - assert "null_condition_inputs.demo" not in selected_names - with_null = behavior._shared_named_parameters( - encoder, ("demo",), include_null_input=True - ) - assert "null_condition_inputs.demo" in {name for name, _ in with_null} - - for _, parameter in selected: - parameter.data.fill_(0.5) - parameter.grad = torch.full_like(parameter, 7.0) - before = {id(parameter): parameter.grad.clone() for _, parameter in selected} - reconstruction = _parameter_loss(selected, 0.0) - flow = _parameter_loss(selected, 2.0) - predictions = {"source": {"embodiment": "demo"}} - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **_kwargs: logged.setdefault(name, value), - ) - behavior._measure_topology_gradients(reconstruction, flow, predictions) - - assert set(logged) == { - "log/unite_gradient_cosine", - "log/unite_recon_grad_norm", - "log/unite_denoise_grad_norm", - "log/unite_gradient_parameter_count", - "log/unite_gradient_tensor_count", - } - assert float(logged["log/unite_gradient_cosine"]) == pytest.approx(-1.0) - for _, parameter in selected: - torch.testing.assert_close(parameter.grad, before[id(parameter)]) - (reconstruction + flow).backward() - - -def test_separate_telemetry_is_disjoint_and_does_not_touch_grad(monkeypatch): - wrapper = _wrapper(shared=False, dropout_probability=0.0) - behavior = wrapper.training_behavior - _, encoder, shared = behavior._unite_topology() - assert not shared - tokenizer, denoiser = behavior._separate_named_parameters( - encoder, ("demo",), include_denoiser_null_input=False - ) - assert {id(parameter) for _, parameter in tokenizer}.isdisjoint( - {id(parameter) for _, parameter in denoiser} - ) - assert "null_condition_inputs.demo" not in {name for name, _ in denoiser} - for _, parameter in (*tokenizer, *denoiser): - parameter.data.fill_(0.5) - parameter.grad = torch.full_like(parameter, 7.0) - before = { - id(parameter): parameter.grad.clone() - for _, parameter in (*tokenizer, *denoiser) - } - reconstruction = _parameter_loss(tokenizer, 0.0) - flow = _parameter_loss(denoiser, 2.0) - logged = {} - monkeypatch.setattr( - wrapper, - "log", - lambda name, value, **_kwargs: logged.setdefault(name, value), - ) - behavior._measure_topology_gradients( - reconstruction, flow, {"source": {"embodiment": "demo"}} - ) - - assert set(logged) == { - "log/unite_tokenizer_recon_grad_norm", - "log/unite_denoiser_flow_grad_norm", - } - for _, parameter in (*tokenizer, *denoiser): - torch.testing.assert_close(parameter.grad, before[id(parameter)]) - (reconstruction + flow).backward() - - -def test_topology_and_gradient_gates_fail_loudly(): - wrapper = _wrapper(shared=True) - behavior = wrapper.training_behavior - behavior._configured_share_encoder_denoiser = False - with pytest.raises(RuntimeError, match="disagrees"): - behavior._unite_topology() - - with pytest.raises(RuntimeError, match="zero or non-finite"): - gradient_norm((torch.zeros(2),), label="UNITE telemetry") - with pytest.raises(RuntimeError, match="zero or non-finite"): - gradient_norm((torch.full((2,), float("nan")),), label="UNITE telemetry") - - behavior._configured_share_encoder_denoiser = None - _, encoder, _ = behavior._unite_topology() - named = behavior._shared_named_parameters( - encoder, ("demo",), include_null_input=False - ) - disconnected = named[0][1].square().sum() - with pytest.raises(RuntimeError, match="not have been used in the graph"): - behavior._measure_shared_gradients(disconnected, disconnected, named) - - -def test_training_entry_requires_the_single_model_wrapper(): - cfg = OmegaConf.create( - {"model": {"_target_": "egomimic.pl_utils.pl_model.ModelWrapper"}} - ) - assert _resolve_model_wrapper_class(cfg) is ModelWrapper - cfg.model._target_ = "torch.nn.Linear" - with pytest.raises(TypeError, match="exactly to ModelWrapper"): - _resolve_model_wrapper_class(cfg) - - -def test_training_entry_forwards_enable_grad_norm(monkeypatch): - class CaptureWrapper: - def __init__(self, **kwargs): - self.kwargs = kwargs - - cfg = OmegaConf.create( - { - "model": { - "_target_": "unused.for.this.unit.test", - "enable_grad_norm": False, - } - } - ) - monkeypatch.setattr( - "egomimic.trainHydra._resolve_model_wrapper_class", - lambda _cfg: CaptureWrapper, - ) - - wrapper = _instantiate_model_wrapper(cfg) - - assert wrapper.kwargs["enable_grad_norm"] is False diff --git a/tests/test_validate_action_flow_config.py b/tests/test_validate_action_flow_config.py deleted file mode 100644 index f9436cab4c..0000000000 --- a/tests/test_validate_action_flow_config.py +++ /dev/null @@ -1,421 +0,0 @@ -from __future__ import annotations - -import copy -import hashlib -import importlib.util -import json -import subprocess -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[1] -CONFIG_ROOT = ROOT / "egomimic" / "hydra_configs" -TOOL = ROOT / "tools" / "validate_action_flow_config.py" -SPEC = importlib.util.spec_from_file_location("action_flow_preflight", TOOL) -assert SPEC is not None and SPEC.loader is not None -preflight = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(preflight) - - -def test_real_action_flow_config_instantiates_and_passes_full_preflight(): - report, _ = preflight.validate_experiment( - "pusht/action_flow_bc_usocket_recon1_s42", - config_root=CONFIG_ROOT, - ) - - assert report["status"] == "PASS" - assert report["config_name"] == "action_flow_bc_usocket_recon1_s42" - assert len(report["resolved_config_sha256"]) == 64 - assert report["dimensions"] == { - "action": [16, 4], - "condition": 67, - "image_feature": 64, - "latent": [16, 8], - "normalized_state": 3, - } - expected_counts = { - "decoder_g": 10_744, - "encoder_e": 10_748, - "field_v": 39_506_641, - "observation_encoder": 11_197_088, - "pipeline_total": 50_725_221, - } - assert set(report["parameters"]) == set(expected_counts) - for name, expected in expected_counts.items(): - manifest = report["parameters"][name] - assert manifest["total"] == manifest["trainable"] == expected - assert len(manifest["manifest_sha256"]) == 64 - assert sum(entry["numel"] for entry in manifest["entries"]) == expected - assert len({entry["name"] for entry in manifest["entries"]}) == len( - manifest["entries"] - ) - assert report["topology"]["shared_field_instance"] is True - assert report["topology"]["shared_decoder_instance"] is True - assert report["topology"]["inference_order"] == [ - "FusedObsEncoder", - "GaussianLatentNoise", - "ConditionalVelocityStage", - "ContentDecoderStage", - ] - assert report["launch"]["effective_global_batch"] == 32 - assert report["split"]["zero_id_overlap"] is True - assert report["split"]["zero_resolved_path_overlap"] is True - content_manifest_path = ROOT / preflight.CANONICAL_CONTENT_MANIFEST_RELATIVE_PATH - content_manifest = json.loads(content_manifest_path.read_text()) - assert report["split"]["content_manifest_path"] == ( - preflight.CANONICAL_CONTENT_MANIFEST_RELATIVE_PATH.as_posix() - ) - assert ( - report["split"]["content_manifest_sha256"] - == hashlib.sha256(content_manifest_path.read_bytes()).hexdigest() - ) - assert ( - report["split"]["dataset_content_aggregate_sha256"] - == content_manifest["aggregate_sha256"] - ) - - -def test_option_a_200m_muon_config_has_exact_capacity_optimizer_and_schedule(): - report, _ = preflight.validate_experiment( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42", - config_root=CONFIG_ROOT, - ) - - assert report["status"] == "PASS" - assert report["parameters"]["encoder_e"]["total"] == 1_010_420 - assert report["parameters"]["decoder_g"]["total"] == 1_010_416 - assert report["parameters"]["field_v"]["total"] == 186_536_913 - assert report["parameters"]["pipeline_total"]["total"] == 199_754_837 - optimization = report["optimization"] - assert optimization["optimizer"]["target"].endswith( - "ReleasedUniteCompositeOptimizer" - ) - assert optimization["optimizer"]["lr"] == pytest.approx(1.0e-5) - assert optimization["scheduler"]["eta_min"] == pytest.approx(1.0e-6) - assert optimization["parameter_groups"]["complete"] is True - assert optimization["parameter_groups"]["disjoint"] is True - - -def test_option_a_200m_adamw_changes_only_optimizer_family(): - report, _ = preflight.validate_experiment( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_adamw_lr1e5_s42", - config_root=CONFIG_ROOT, - ) - - assert report["status"] == "PASS" - assert report["parameters"]["encoder_e"]["total"] == 1_010_420 - assert report["parameters"]["decoder_g"]["total"] == 1_010_416 - assert report["parameters"]["field_v"]["total"] == 186_536_913 - assert report["parameters"]["pipeline_total"]["total"] == 199_754_837 - optimization = report["optimization"] - assert optimization["optimizer"]["target"] == "torch.optim.AdamW" - assert optimization["optimizer"]["lr"] == pytest.approx(1.0e-5) - assert optimization["scheduler"]["eta_min"] == pytest.approx(1.0e-6) - assert optimization["parameter_groups"]["adamw_parameters"] == 199_754_837 - assert optimization["parameter_groups"]["muon_parameters"] == 0 - - -def test_resolved_hash_is_stable_and_uses_runtime_sentinels(): - first = preflight.compose_experiment( - "pusht/action_flow_bc_usocket_recon1_s42", config_root=CONFIG_ROOT - ) - second = preflight.compose_experiment( - "pusht/action_flow_bc_usocket_recon1_s42", config_root=CONFIG_ROOT - ) - first_payload, first_hash = preflight.resolved_config_payload(first) - second_payload, second_hash = preflight.resolved_config_payload(second) - - assert first_hash == second_hash - assert first_payload == second_payload - assert first_payload["paths"]["output_dir"] == "" - assert first_payload["logger"]["wandb"]["id"] == "" - - -def test_pair_diff_is_only_the_declared_reconstruction_setting(): - report = preflight.validate_pair( - "pusht/action_flow_bc_usocket_recon1_s42", - "pusht/action_flow_bc_usocket_recon10_s42", - config_root=CONFIG_ROOT, - ) - - assert report["status"] == "PASS" - assert report["comparison"] == { - "differing_paths": sorted(preflight.ALLOWED_PAIR_DIFFERENCES), - "only_declared_reconstruction_differences": True, - "status": "PASS", - } - assert { - item["objective"]["reconstruction_weight"] for item in report["experiments"] - } == {1.0, 10.0} - - -def test_recon100_diff_is_only_the_declared_reconstruction_setting(): - report = preflight.validate_pair( - "pusht/action_flow_bc_usocket_recon1_s42", - "pusht/action_flow_bc_usocket_recon100_s42", - config_root=CONFIG_ROOT, - ) - - assert report["status"] == "PASS" - assert report["comparison"]["only_declared_reconstruction_differences"] is True - assert { - item["objective"]["reconstruction_weight"] for item in report["experiments"] - } == {1.0, 100.0} - - -@pytest.mark.parametrize( - ("path", "value", "message"), - [ - ("model.pipeline.stages.5.field.time_scale", 1.0, "time scale"), - ("model.pipeline.stages.4.samples_per_content", 13, "bridge samples"), - ("callbacks.model_checkpoint.every_n_train_steps", 20_000, "checkpoint"), - ("launch_params.gpus_per_node", 2, "GPUs per node"), - ( - "evaluator.action_flow_diagnostics.enabled", - False, - "diagnostics enabled", - ), - ( - "evaluator.action_flow_diagnostics.native_error.native_theta_index", - 1, - "unsupported USocket native error contract", - ), - ( - "run_provenance.content_manifest_path", - "egomimic/hydra_configs/data/pusht/manifests/not-canonical.json", - "canonical dataset-content manifest path", - ), - ( - "run_provenance.content_manifest_sha256", - "0" * 64, - "canonical dataset-content manifest hash", - ), - ( - "run_provenance.dataset_content_aggregate_sha256", - "0" * 64, - "canonical dataset aggregate content hash", - ), - ( - "evaluator.energy_score_provenance.source_commit", - "0" * 40, - "EnergyScore source commit provenance", - ), - ( - "evaluator.energy_score_provenance.normalization_sha256", - "0" * 64, - "EnergyScore normalization provenance", - ), - ( - "evaluator.energy_score_provenance.split_manifest_sha256", - "0" * 64, - "EnergyScore split provenance", - ), - ( - "evaluator.energy_score_validation_view.split_manifest_sha256", - "0" * 64, - "EnergyScore validation-view split provenance", - ), - ( - "evaluator.energy_score_provenance.dataset_content.manifest_path", - "not-the-run-manifest.json", - "evaluator dataset-content manifest path provenance", - ), - ( - "evaluator.energy_score_provenance.dataset_content.manifest_sha256", - "0" * 64, - "evaluator dataset-content manifest hash provenance", - ), - ( - "evaluator.energy_score_provenance.dataset_content.aggregate_sha256", - "0" * 64, - "evaluator dataset aggregate content hash provenance", - ), - ( - "evaluator.action_flow_diagnostics.provenance.source_commit", - "0" * 40, - "diagnostic source commit provenance", - ), - ( - "evaluator.action_flow_diagnostics.provenance.normalization_sha256", - "0" * 64, - "diagnostic normalization provenance", - ), - ( - "evaluator.action_flow_diagnostics.provenance.split_manifest_sha256", - "0" * 64, - "diagnostic split provenance", - ), - ( - "evaluator.action_flow_diagnostics.validation_view.split_manifest_sha256", - "0" * 64, - "diagnostic validation-view split provenance", - ), - ( - "evaluator.action_flow_diagnostics.provenance.dataset_content.manifest_sha256", - "0" * 64, - "diagnostic dataset-content manifest provenance", - ), - ( - "evaluator.action_flow_diagnostics.provenance.dataset_content.aggregate_sha256", - "0" * 64, - "diagnostic dataset aggregate content provenance", - ), - ], -) -def test_preflight_fails_closed_before_training(path, value, message): - config = preflight.compose_experiment( - "pusht/action_flow_bc_usocket_recon1_s42", config_root=CONFIG_ROOT - ) - from omegaconf import OmegaConf - - OmegaConf.update(config, path, value) - with pytest.raises(preflight.PreflightError, match=message): - preflight.validate_config( - config, - experiment="pusht/action_flow_bc_usocket_recon1_s42", - config_root=CONFIG_ROOT, - ) - - -@pytest.mark.parametrize( - "path", - [ - "evaluator.energy_score_distance", - "run_provenance.energy_score_contract.distance", - "evaluator.energy_score_provenance.distance_contract", - ], -) -def test_every_energy_distance_surface_requires_the_typed_usocket_contract(path): - config = preflight.compose_experiment( - "pusht/action_flow_bc_usocket_recon1_s42", config_root=CONFIG_ROOT - ) - from omegaconf import OmegaConf - - invalid = copy.deepcopy(preflight.USOCKET_ENERGY_DISTANCE_CONFIG) - invalid["rotation_scale_radians"] = 1.0 - OmegaConf.update(config, path, invalid, merge=False) - - with pytest.raises( - preflight.PreflightError, - match="unsupported USocket EnergyScore distance contract", - ): - preflight.validate_config( - config, - experiment="pusht/action_flow_bc_usocket_recon1_s42", - config_root=CONFIG_ROOT, - ) - - -def test_native_error_surface_rejects_undeclared_contract_keys(): - config = preflight.compose_experiment( - "pusht/action_flow_bc_usocket_recon1_s42", config_root=CONFIG_ROOT - ) - from omegaconf import OmegaConf - - invalid = copy.deepcopy(preflight.USOCKET_NATIVE_ERROR_CONFIG) - invalid["undeclared"] = True - OmegaConf.update( - config, - "evaluator.action_flow_diagnostics.native_error", - invalid, - merge=False, - ) - - with pytest.raises(preflight.PreflightError, match="native error keys differ"): - preflight.validate_config( - config, - experiment="pusht/action_flow_bc_usocket_recon1_s42", - config_root=CONFIG_ROOT, - ) - - -@pytest.mark.parametrize( - "path", - [ - "evaluator.energy_score_provenance.dataset_content", - "evaluator.action_flow_diagnostics.provenance.dataset_content", - ], -) -def test_dataset_content_provenance_rejects_undeclared_keys(path): - config = preflight.compose_experiment( - "pusht/action_flow_bc_usocket_recon1_s42", config_root=CONFIG_ROOT - ) - from omegaconf import OmegaConf - - payload = OmegaConf.to_container(OmegaConf.select(config, path), resolve=True) - assert isinstance(payload, dict) - payload["undeclared"] = True - OmegaConf.update(config, path, payload, merge=False) - - with pytest.raises(preflight.PreflightError, match="provenance keys"): - preflight.validate_config( - config, - experiment="pusht/action_flow_bc_usocket_recon1_s42", - config_root=CONFIG_ROOT, - ) - - -def test_cli_emits_deterministic_machine_readable_failure(tmp_path, monkeypatch): - output = tmp_path / "preflight.json" - - def fail(*_args, **_kwargs): - raise preflight.PreflightError("deliberate contract failure") - - monkeypatch.setattr(preflight, "validate_experiment", fail) - status = preflight.main( - [ - "--experiment", - "pusht/action_flow_bc_usocket_recon1_s42", - "--output", - str(output), - ] - ) - - assert status == 1 - assert json.loads(output.read_text()) == { - "error": { - "message": "deliberate contract failure", - "type": "PreflightError", - }, - "schema_version": 1, - "status": "FAIL", - } - assert output.read_text().endswith("\n") - - -def test_direct_script_cli_resolves_repository_package(tmp_path): - output = tmp_path / "preflight.json" - completed = subprocess.run( - [ - sys.executable, - str(TOOL), - "--experiment", - "pusht/action_flow_bc_usocket_recon1_s42", - "--output", - str(output), - ], - cwd=ROOT, - check=False, - capture_output=True, - text=True, - ) - - assert completed.returncode == 0, completed.stderr or completed.stdout - payload = json.loads(output.read_text()) - assert payload["status"] == "PASS" - assert payload["parameters"]["field_v"]["total"] == 39_506_641 - - -@pytest.mark.parametrize( - "selector", - [ - "pusht/action_flow_chain_manual4919_avln_80k_s42", - "pusht/parents/action_flow_usocket_h384", - "pusht_historical/action_flow_cotrain_uc_speed_interpolation", - ], -) -def test_fresh_composition_rejects_retired_or_non_recipe_selectors(selector): - with pytest.raises(preflight.PreflightError, match="retired|historical"): - preflight.compose_experiment(selector, config_root=CONFIG_ROOT) diff --git a/tests/test_validate_lightning_checkpoint.py b/tests/test_validate_lightning_checkpoint.py deleted file mode 100644 index a39e04d108..0000000000 --- a/tests/test_validate_lightning_checkpoint.py +++ /dev/null @@ -1,501 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import os -import subprocess -import sys -from pathlib import Path - -import pytest -import torch - -from egomimic.pipeline.core import Stage - -SCRIPT = ( - Path(__file__).parents[1] / "scripts" / "ice" / "validate_lightning_checkpoint.py" -) -SPEC = importlib.util.spec_from_file_location("validate_lightning_checkpoint", SCRIPT) -MODULE = importlib.util.module_from_spec(SPEC) -assert SPEC.loader is not None -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - -SOURCE = "a" * 40 -CONFIG = "b" * 64 -SPLIT = "c" * 64 -NORMALIZATION = "d" * 64 -RUN = "planar-bc-smoke" - - -@pytest.mark.parametrize( - "corruption", [None, "mask_nan", "mask_wrong", "memory_nan", "learned_inf"] -) -def test_fixed_oat_attention_masks_are_exact_and_other_nonfinite_values_fail( - corruption, -): - prefix = "nets.pipeline.stages.3.policy.model." - state = { - prefix + "pos_emb": torch.zeros(1, 4, 8), - prefix + "cond_pos_emb": torch.zeros(1, 3, 8), - prefix + "input_emb.weight": torch.zeros(8, 7), - prefix + "head.weight": torch.zeros(7, 8), - prefix + "mask": torch.zeros(4, 4).masked_fill( - torch.triu(torch.ones(4, 4), diagonal=1).bool(), -torch.inf - ), - prefix + "memory_mask": torch.zeros(4, 3).masked_fill( - torch.arange(4)[:, None] < torch.arange(3)[None, :] - 1, -torch.inf - ), - } - if corruption == "mask_nan": - state[prefix + "mask"][0, 1] = torch.nan - elif corruption == "mask_wrong": - state[prefix + "mask"][0, 0] = -torch.inf - elif corruption == "memory_nan": - state[prefix + "memory_mask"][0, 2] = torch.nan - elif corruption == "learned_inf": - state[prefix + "head.weight"][0, 0] = torch.inf - candidate = {"state_dict": state} - allowed = MODULE.allowed_fixed_nonfinite_paths(candidate) - if corruption: - with pytest.raises(RuntimeError, match="non-finite tensor"): - MODULE.finite_scan(candidate, allowed_nonfinite_paths=allowed) - else: - assert len(allowed) == 2 - MODULE.finite_scan(candidate, allowed_nonfinite_paths=allowed) - - -def payload(*, scheduler: bool = True) -> dict: - scheduler_config = ( - {"_target_": "example.scheduler", "max_steps": 100} if scheduler else None - ) - return { - "global_step": 7, - "epoch": 2, - "state_dict": {"model.weight": torch.tensor([1.0, 2.0])}, - "optimizer_states": [ - { - "state": {0: {"momentum": torch.tensor([0.25])}}, - "param_groups": [{"params": [0], "lr": 1.0e-4}], - } - ], - "lr_schedulers": ( - [{"last_epoch": 7, "_last_lr": [1.0e-4]}] if scheduler else [] - ), - "loops": {"fit_loop": {"completed": 7}}, - "hyper_parameters": { - "config_tree": { - "model": { - "_target_": "example.ModelWrapper", - "scheduler": scheduler_config, - } - } - }, - "run_provenance": { - "source_commit": SOURCE, - "config_sha256": CONFIG, - "run_id": RUN, - "split_manifest_sha256": SPLIT, - "normalization_sha256": NORMALIZATION, - }, - } - - -def add_unranked_model_checkpoint_state( - candidate: dict, - *, - include_config: bool = False, - save_top_k: int = -1, -) -> tuple[str, dict]: - state_key = ( - "ModelCheckpoint{'monitor': None, 'mode': 'min', " - "'every_n_train_steps': 2, 'every_n_epochs': 0, " - "'train_time_interval': None}" - ) - state = { - "monitor": None, - "best_model_score": None, - "best_model_path": "/output/checkpoints/epoch-0-step-2.ckpt", - "current_score": None, - "dirpath": "/output/checkpoints", - "best_k_models": {}, - "kth_best_model_path": "", - "kth_value": torch.tensor(float("inf")), - "last_model_path": "/output/checkpoints/last.ckpt", - } - candidate["callbacks"] = {state_key: state} - if include_config: - candidate["hyper_parameters"]["callbacks"] = { - "model_checkpoint": { - "_target_": "lightning.pytorch.callbacks.ModelCheckpoint", - "monitor": None, - "mode": "min", - "save_top_k": save_top_k, - "every_n_train_steps": 2, - "every_n_epochs": None, - "train_time_interval": None, - } - } - return state_key, state - - -def save(path: Path, value: object) -> Path: - torch.save(value, path) - return path - - -def invoke( - checkpoint: Path, - *arguments: str, - environment: dict[str, str] | None = None, -) -> subprocess.CompletedProcess[str]: - merged_environment = os.environ.copy() - if environment: - merged_environment.update(environment) - return subprocess.run( - [sys.executable, str(SCRIPT), str(checkpoint), *arguments], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=merged_environment, - check=False, - ) - - -def success_json(result: subprocess.CompletedProcess[str]) -> dict: - assert result.returncode == 0, result.stderr - lines = [line for line in result.stdout.splitlines() if line.strip()] - assert lines - return json.loads(lines[-1]) - - -def test_structural_cli_emits_runner_compatible_json(tmp_path: Path): - checkpoint = save(tmp_path / "model.ckpt", payload()) - result = invoke( - checkpoint, - "--structural-only", - environment={ - "ICE_EXPECTED_SOURCE_COMMIT": SOURCE, - "ICE_EXPECTED_CONFIG_SHA256": CONFIG, - "ICE_EXPECTED_RUN_ID": RUN, - "ICE_EXPECTED_SPLIT_SHA256": SPLIT, - "ICE_EXPECTED_NORMALIZATION_SHA256": NORMALIZATION, - "ICE_REQUIRE_LR_SCHEDULERS": "1", - }, - ) - - metadata = success_json(result) - - assert metadata["global_step"] == 7 - assert type(metadata["global_step"]) is int - assert metadata["valid"] is True - assert metadata["strict_model_reload"] is False - assert metadata["optimizer_state_count"] == 1 - assert metadata["lr_scheduler_state_count"] == 1 - assert metadata["source_commit"] == SOURCE - assert metadata["config_sha256"] == CONFIG - assert metadata["run_id"] == RUN - assert metadata["split_sha256"] == SPLIT - assert metadata["normalization_sha256"] == NORMALIZATION - - -@pytest.mark.parametrize( - ("key", "value", "message"), - [ - ("state_dict", {}, "state_dict"), - ("optimizer_states", [], "optimizer_states"), - ("loops", {}, "loops"), - ("lr_schedulers", [], "missing lr_schedulers"), - ], -) -def test_required_full_state_is_enforced( - tmp_path: Path, key: str, value: object, message: str -): - candidate = payload() - candidate[key] = value - result = invoke(save(tmp_path / f"{key}.ckpt", candidate), "--structural-only") - - assert result.returncode == 1 - assert message in result.stderr - assert result.stdout == "" - - -def test_checkpoint_without_configured_scheduler_may_omit_scheduler_state( - tmp_path: Path, -): - checkpoint = save(tmp_path / "no-scheduler.ckpt", payload(scheduler=False)) - - metadata = success_json(invoke(checkpoint, "--structural-only")) - - assert metadata["scheduler_configured"] is False - required = invoke( - checkpoint, - "--structural-only", - "--require-lr-schedulers", - ) - assert required.returncode == 1 - assert "missing lr_schedulers" in required.stderr - - -@pytest.mark.parametrize("step", [-1, 1.5, True]) -def test_global_step_must_be_an_exact_nonnegative_integer(tmp_path: Path, step: object): - candidate = payload() - candidate["global_step"] = step - - result = invoke(save(tmp_path / "bad-step.ckpt", candidate), "--structural-only") - - assert result.returncode == 1 - assert "global_step must be a non-negative integer" in result.stderr - - -@pytest.mark.parametrize( - ("mutate", "message"), - [ - ( - lambda value: value["state_dict"].__setitem__( - "bad", torch.tensor(float("nan")) - ), - "non-finite tensor", - ), - ( - lambda value: value["optimizer_states"][0]["param_groups"][0].__setitem__( - "lr", float("inf") - ), - "non-finite scalar", - ), - ( - lambda value: value["lr_schedulers"][0].__setitem__( - "last_epoch", float("inf") - ), - "non-finite scalar", - ), - ( - lambda value: value["loops"]["fit_loop"].__setitem__( - "completed", float("inf") - ), - "non-finite scalar", - ), - ( - lambda value: value.__setitem__("numeric_content", float("inf")), - "non-finite scalar", - ), - ], -) -def test_nonfinite_checkpoint_content_is_rejected(tmp_path: Path, mutate, message: str): - candidate = payload() - mutate(candidate) - - result = invoke(save(tmp_path / "nonfinite.ckpt", candidate), "--structural-only") - - assert result.returncode == 1 - assert message in result.stderr - - -@pytest.mark.parametrize("include_config", [False, True]) -def test_unranked_model_checkpoint_kth_value_sentinel_is_allowed( - tmp_path: Path, include_config: bool -): - candidate = payload() - add_unranked_model_checkpoint_state(candidate, include_config=include_config) - - metadata = success_json( - invoke(save(tmp_path / "model-checkpoint.ckpt", candidate), "--structural-only") - ) - - assert metadata["allowed_callback_sentinel_count"] == 1 - - -def test_model_checkpoint_sentinel_requires_save_all_config_when_available( - tmp_path: Path, -): - candidate = payload() - add_unranked_model_checkpoint_state( - candidate, - include_config=True, - save_top_k=1, - ) - - result = invoke( - save(tmp_path / "top-one-checkpoint.ckpt", candidate), - "--structural-only", - ) - - assert result.returncode == 1 - assert "non-finite tensor" in result.stderr - assert ".kth_value" in result.stderr - - -def test_model_checkpoint_exception_does_not_cover_other_callback_state( - tmp_path: Path, -): - candidate = payload() - _, callback_state = add_unranked_model_checkpoint_state(candidate) - callback_state["current_score"] = torch.tensor(float("inf")) - - result = invoke( - save(tmp_path / "bad-callback-state.ckpt", candidate), - "--structural-only", - ) - - assert result.returncode == 1 - assert "non-finite tensor" in result.stderr - assert ".current_score" in result.stderr - - -def test_model_checkpoint_exception_requires_unmonitored_callback_identity( - tmp_path: Path, -): - candidate = payload() - state_key, callback_state = add_unranked_model_checkpoint_state(candidate) - monitored_key = state_key.replace("'monitor': None", "'monitor': 'Valid/MSE'") - callback_state["monitor"] = "Valid/MSE" - candidate["callbacks"] = {monitored_key: callback_state} - - result = invoke( - save(tmp_path / "monitored-checkpoint.ckpt", candidate), - "--structural-only", - ) - - assert result.returncode == 1 - assert "non-finite tensor" in result.stderr - assert ".kth_value" in result.stderr - - -def test_model_checkpoint_exception_requires_tensor_ranking_sentinel( - tmp_path: Path, -): - candidate = payload() - _, callback_state = add_unranked_model_checkpoint_state(candidate) - callback_state["kth_value"] = float("inf") - - result = invoke( - save(tmp_path / "scalar-sentinel.ckpt", candidate), - "--structural-only", - ) - - assert result.returncode == 1 - assert "non-finite scalar" in result.stderr - assert ".kth_value" in result.stderr - - -def test_expected_identity_is_fail_closed_on_missing_or_mismatch(tmp_path: Path): - missing = payload() - del missing["run_provenance"]["split_manifest_sha256"] - missing_result = invoke( - save(tmp_path / "missing.ckpt", missing), - "--structural-only", - "--expected-split-sha256", - SPLIT, - ) - mismatch_result = invoke( - save(tmp_path / "mismatch.ckpt", payload()), - "--structural-only", - "--expected-run-id", - "another-run", - ) - - assert missing_result.returncode == 1 - assert "not present" in missing_result.stderr - assert mismatch_result.returncode == 1 - assert "run checkpoint identity mismatch" in mismatch_result.stderr - - -def test_cli_identity_overrides_environment(tmp_path: Path): - checkpoint = save(tmp_path / "override.ckpt", payload()) - - metadata = success_json( - invoke( - checkpoint, - "--structural-only", - "--expected-run-id", - RUN, - environment={"ICE_EXPECTED_RUN_ID": "wrong-from-environment"}, - ) - ) - - assert metadata["run_id"] == RUN - - -def test_conflicting_embedded_identity_aliases_are_rejected(tmp_path: Path): - candidate = payload() - candidate["metadata"] = {"wandb_run_id": "different-run"} - - result = invoke(save(tmp_path / "conflict.ckpt", candidate), "--structural-only") - - assert result.returncode == 1 - assert "conflicting run checkpoint identities" in result.stderr - - -def test_default_requires_strict_model_reload(tmp_path: Path): - result = invoke(save(tmp_path / "synthetic.ckpt", payload())) - - assert result.returncode == 1 - assert "ModelWrapper" in result.stderr - - -def test_invalid_scheduler_environment_is_rejected(tmp_path: Path): - result = invoke( - save(tmp_path / "environment.ckpt", payload()), - "--structural-only", - environment={"ICE_REQUIRE_LR_SCHEDULERS": "yes"}, - ) - - assert result.returncode == 1 - assert "must be 0 or 1" in result.stderr - - -def test_strict_reload_reconstructs_configured_pipeline_wrapper(tmp_path: Path): - from egomimic.pl_utils.pl_model import ModelWrapper - - class_path = f"{__name__}.TinyParameterizedStage" - config_tree = { - "model": { - "_target_": "egomimic.pl_utils.pl_model.ModelWrapper", - "pipeline": { - "_target_": "egomimic.pipeline.algo.PipelineAlgo", - "stages": [{"_target_": class_path}], - }, - "optimizer": { - "_target_": "torch.optim.AdamW", - "_partial_": True, - "lr": 1.0e-4, - }, - "scheduler": None, - } - } - wrapper = ModelWrapper(config_tree=config_tree) - candidate = { - "global_step": 0, - "epoch": 0, - "state_dict": wrapper.state_dict(), - "optimizer_states": [{"state": {}, "param_groups": [{"params": [0]}]}], - "lr_schedulers": [], - "loops": {"fit_loop": {"completed": 0}}, - "hyper_parameters": { - "config_tree": config_tree, - "scheduler_interval": "step", - "scheduler_frequency": 1, - "enable_grad_norm": True, - }, - } - checkpoint = save(tmp_path / "strict.ckpt", candidate) - - metadata = MODULE.validate_checkpoint(checkpoint) - - assert metadata["strict_model_reload"] is True - assert metadata["strict_model_parameter_count"] == 1 - assert metadata["strict_model_tensor_count"] >= 1 - - candidate["state_dict"]["unexpected.weight"] = torch.tensor(0.0) - broken = save(tmp_path / "strict-broken.ckpt", candidate) - with pytest.raises(RuntimeError, match="strict reload failed"): - MODULE.validate_checkpoint(broken) - - -class TinyParameterizedStage(Stage): - def __init__(self): - super().__init__() - self.weight = torch.nn.Parameter(torch.tensor(1.0)) - - def forward(self, batch: dict) -> dict: - return batch diff --git a/tests/test_validate_planar_dataset.py b/tests/test_validate_planar_dataset.py deleted file mode 100644 index b93f493bde..0000000000 --- a/tests/test_validate_planar_dataset.py +++ /dev/null @@ -1,241 +0,0 @@ -import copy -import hashlib -import importlib.util -import json -import subprocess -import sys -from pathlib import Path - -import pytest -import zarr - -ROOT = Path(__file__).parents[1] -MODULE_PATH = ROOT / "scripts" / "ice" / "validate_planar_dataset.py" -MANIFEST_PATH = ( - ROOT - / "egomimic" - / "hydra_configs" - / "data" - / "pusht" - / "planar_v2_usocket_dp_3k_split_seed42_v1.json" -) -SPEC = importlib.util.spec_from_file_location("validate_planar_dataset", MODULE_PATH) -assert SPEC is not None and SPEC.loader is not None -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -def _manifest(names, source_root, domain=MODULE.DOMAIN): - train, valid = MODULE.split_names(names) - source_paths = {name: source_root / name for name in names} - train_paths = {source_paths[name] for name in train} - valid_paths = {source_paths[name] for name in valid} - return { - "schema_version": 1, - "status": "PASS", - "split_algorithm": MODULE.SPLIT_ALGORITHM, - "split_seed": 42, - "valid_ratio": 0.01, - "generator_sha256": "a" * 64, - "cross_domain_train_valid_resolved_path_overlap_count": 0, - "domains": { - domain: { - "folder_path": str(source_root), - "total_count": len(names), - "inventory_names_sha256": MODULE.names_sha256(names), - "train_count": len(train), - "train_names_sha256": MODULE.names_sha256(train), - "train_resolved_paths_sha256": MODULE.paths_sha256(train_paths), - "train_ids": sorted(train), - "valid_count": len(valid), - "valid_names_sha256": MODULE.names_sha256(valid), - "valid_resolved_paths_sha256": MODULE.paths_sha256(valid_paths), - "valid_ids": sorted(valid), - "id_overlap_count": 0, - "resolved_path_overlap_count": 0, - "union_count": len(names), - "union_matches_inventory": True, - } - }, - } - - -def test_committed_manifest_has_exact_hash_and_contract(): - expected = "3683e3461596eef8df2432fa865779b3c77b2a2057dabd0fea125595729cf313" - assert hashlib.sha256(MANIFEST_PATH.read_bytes()).hexdigest() == expected - - result = MODULE.validate_manifest_structure(json.loads(MANIFEST_PATH.read_text())) - - assert result["total_count"] == 2999 - assert result["train_count"] == 2970 - assert result["valid_count"] == 29 - assert result["id_overlap_count"] == 0 - assert result["union_matches_inventory"] is True - - -def test_committed_chain_manifest_has_exact_hash_and_contract(): - path = MANIFEST_PATH.with_name("planar_v2_chain_gripper_dp_3k_split_seed42_v1.json") - expected = "3ced944ea3af8e875ea88fc5c2df3a5d2865a9f95223d109fb4bd28c8be7cf69" - assert hashlib.sha256(path.read_bytes()).hexdigest() == expected - result = MODULE.validate_manifest_structure(json.loads(path.read_text())) - assert result["domain"] == "pushshapes_sim_chain_gripper" - assert (result["total_count"], result["train_count"], result["valid_count"]) == ( - 3000, - 2970, - 30, - ) - - -def test_unsupported_domain_still_fails_closed(): - manifest = _manifest({"episode_000"}, Path("/original/dataset"), "other_domain") - with pytest.raises(RuntimeError, match="supported PushT domain"): - MODULE.validate_manifest_structure(manifest) - - -def test_portable_inventory_reproduces_split_and_has_no_path_overlap(tmp_path): - names = {f"episode_{index:03d}" for index in range(100)} - source_root = Path("/original/dataset") - manifest = _manifest(names, source_root) - dataset_root = tmp_path / "portable-dataset" - dataset_root.mkdir() - entries = [] - for name in sorted(names): - path = dataset_root / f"{name}.zarr" - path.mkdir() - entries.append((path, name)) - - result = MODULE.validate_physical_inventory(manifest, entries, dataset_root) - - assert result["domain"] == MODULE.DOMAIN - assert result["total_count"] == 100 - assert result["train_count"] == 99 - assert result["valid_count"] == 1 - assert result["resolved_path_overlap_count"] == 0 - assert result["union_count"] == 100 - assert result["union_matches_inventory"] is True - - -def test_inventory_mismatch_fails_closed(tmp_path): - names = {f"episode_{index:03d}" for index in range(10)} - manifest = _manifest(names, Path("/original/dataset")) - dataset_root = tmp_path / "dataset" - dataset_root.mkdir() - entries = [] - for name in sorted(names - {"episode_009"}): - path = dataset_root / name - path.mkdir() - entries.append((path, name)) - - with pytest.raises(RuntimeError, match="physical episode inventory mismatch"): - MODULE.validate_physical_inventory(manifest, entries, dataset_root) - - -def test_manifest_split_tampering_fails_closed(): - names = {f"episode_{index:03d}" for index in range(100)} - manifest = _manifest(names, Path("/original/dataset")) - tampered = copy.deepcopy(manifest) - domain = tampered["domains"][MODULE.DOMAIN] - domain["train_ids"][0], domain["valid_ids"][0] = ( - domain["valid_ids"][0], - domain["train_ids"][0], - ) - domain["train_ids"].sort() - domain["valid_ids"].sort() - domain["train_names_sha256"] = MODULE.names_sha256(domain["train_ids"]) - domain["valid_names_sha256"] = MODULE.names_sha256(domain["valid_ids"]) - domain["inventory_names_sha256"] = MODULE.names_sha256( - domain["train_ids"] + domain["valid_ids"] - ) - - with pytest.raises(RuntimeError, match="do not reproduce"): - MODULE.validate_manifest_structure(tampered) - - -def test_two_ids_resolving_to_one_physical_directory_fail(tmp_path): - names = {"episode_a", "episode_b"} - manifest = _manifest(names, Path("/original/dataset")) - dataset_root = tmp_path / "dataset" - dataset_root.mkdir() - first = dataset_root / "episode_a" - first.mkdir() - second = dataset_root / "episode_b" - second.symlink_to(first, target_is_directory=True) - - with pytest.raises(RuntimeError, match="same physical path"): - MODULE.validate_physical_inventory( - manifest, - [(first, "episode_a"), (second, "episode_b")], - dataset_root, - ) - - -def test_explicit_deleted_directory_is_excluded_but_accounted(tmp_path): - names = {"episode_a", "episode_b"} - manifest = _manifest(names, Path("/original/dataset")) - dataset_root = tmp_path / "dataset" - dataset_root.mkdir() - entries = [] - for name in sorted(names): - path = dataset_root / name - path.mkdir() - entries.append((path, name)) - deleted = dataset_root / "deleted_episode" - deleted.mkdir() - - result = MODULE.validate_physical_inventory( - manifest, - entries, - dataset_root, - excluded_deleted_paths=[deleted], - ) - - assert result["excluded_deleted_directory_count"] == 1 - - -def test_cli_reuses_training_resolver_and_allows_only_explicitly_deleted_store( - tmp_path, -): - names = {f"episode_{index:03d}" for index in range(10)} - dataset_root = tmp_path / "dataset" - dataset_root.mkdir() - for name in names: - zarr.open_group(str(dataset_root / f"{name}.zarr"), mode="w") - deleted = zarr.open_group(str(dataset_root / "deleted.zarr"), mode="w") - deleted.attrs["is_deleted"] = True - - manifest = _manifest(names, Path("/original/dataset")) - generator = ROOT / "scripts" / "data" / "materialize_local_episode_split.py" - manifest["generator_sha256"] = hashlib.sha256(generator.read_bytes()).hexdigest() - manifest_path = tmp_path / "manifest.json" - manifest_path.write_text( - json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n" - ) - expected_sha = hashlib.sha256(manifest_path.read_bytes()).hexdigest() - output = tmp_path / "validation.json" - - completed = subprocess.run( - [ - sys.executable, - str(MODULE_PATH), - "--repo", - str(ROOT), - "--dataset-root", - str(dataset_root), - "--manifest", - str(manifest_path), - "--expected-manifest-sha256", - expected_sha, - "--output", - str(output), - ], - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - result = json.loads(output.read_text()) - assert result["status"] == "DATASET_VALIDATED" - assert result["physical_inventory"]["total_count"] == 10 - assert result["physical_inventory"]["excluded_deleted_directory_count"] == 1 - assert json.loads(completed.stdout.splitlines()[-1]) == result diff --git a/tests/test_validate_slurm_job_contract.py b/tests/test_validate_slurm_job_contract.py deleted file mode 100644 index 138581fd3c..0000000000 --- a/tests/test_validate_slurm_job_contract.py +++ /dev/null @@ -1,240 +0,0 @@ -import importlib.util -import json -import subprocess -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).parents[1] -SCRIPT = ROOT / "scripts" / "train" / "validate_slurm_job_contract.py" - - -def _load_module(): - spec = importlib.util.spec_from_file_location("validate_slurm_job_contract", SCRIPT) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _record(**replacements): - fields = { - "JobId": "12345", - "JobName": "action-flow", - "Account": "gts-dxu345-rl2", - "QOS": "inferno", - "JobState": "RUNNING", - "TimeLimit": "3-00:00:00", - "Partition": "gpu-h200", - "NumNodes": "1", - "NumCPUs": "8", - "NumTasks": "1", - "CPUs/Task": "8", - "ReqTRES": "cpu=8,mem=250G,node=1,billing=8,gres/gpu=1", - "MinCPUsNode": "8", - "MinMemoryNode": "250G", - "Features": "H100|H200", - "Command": "/task/launch.sh", - } - fields.update(replacements) - return " ".join(f"{key}={value}" for key, value in fields.items()) + "\n" - - -def _expectations(): - return { - "expected_job_id": "12345", - "expected_account": "gts-dxu345-rl2", - "expected_partition": "gpu-h200", - "expected_qos": "inferno", - "expected_cpus": 8, - "expected_memory": "250G", - "expected_time_limit": "3-00:00:00", - "expected_constraint": "H100|H200", - } - - -def test_contract_cli_writes_immutable_pass_evidence(tmp_path): - record = tmp_path / "scontrol.txt" - record.write_text(_record()) - output = tmp_path / "contract.json" - completed = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--record", - str(record), - "--expected-job-id", - "12345", - "--expected-account", - "gts-dxu345-rl2", - "--expected-partition", - "gpu-h200", - "--expected-qos", - "inferno", - "--expected-cpus", - "8", - "--expected-memory", - "250G", - "--expected-time-limit", - "3-00:00:00", - "--expected-constraint", - "H100|H200", - "--output", - str(output), - ], - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - payload = json.loads(output.read_text()) - assert payload["status"] == "SLURM_JOB_CONTRACT_VALIDATED" - assert payload["failures"] == [] - assert payload["observed"]["memory_bytes"] == 250 * 1024**3 - assert payload["observed"]["requested_tres_gpus"] == 1 - - repeated = subprocess.run( - completed.args, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert repeated.returncode != 0 - assert json.loads(output.read_text())["status"] == "SLURM_JOB_CONTRACT_VALIDATED" - - -@pytest.mark.parametrize("constraint", ["H100", "H200", "H100|H200"]) -def test_exact_supported_gpu_constraints_pass(constraint): - module = _load_module() - fields = module.parse_scontrol_record(_record(Features=constraint)) - expectations = _expectations() - expectations["expected_constraint"] = constraint - expected, _, failures = module.evaluate_contract(fields, **expectations) - assert failures == [] - assert expected["constraint"] == constraint - - -@pytest.mark.parametrize( - ("replacement", "failure_field"), - [ - ({"JobId": "999"}, "job_id"), - ({"Account": "coc"}, "account"), - ({"Partition": "ice-gpu"}, "partition"), - ({"QOS": "coc-ice"}, "qos"), - ({"NumNodes": "2"}, "nodes"), - ({"NumTasks": "2"}, "tasks"), - ({"NumCPUs": "16"}, "cpus"), - ({"CPUs/Task": "4"}, "cpus_per_task"), - ({"MinCPUsNode": "4"}, "minimum_cpus_per_node"), - ({"MinMemoryNode": "200G"}, "memory_bytes"), - ({"Features": "H100"}, "constraint"), - ({"TimeLimit": "2-00:00:00"}, "time_limit_seconds"), - ( - {"ReqTRES": "cpu=8,mem=250G,node=2,billing=8,gres/gpu=1"}, - "requested_tres_nodes", - ), - ( - {"ReqTRES": "cpu=4,mem=250G,node=1,billing=8,gres/gpu=1"}, - "requested_tres_cpus", - ), - ( - {"ReqTRES": "cpu=8,mem=200G,node=1,billing=8,gres/gpu=1"}, - "requested_tres_memory_bytes", - ), - ( - {"ReqTRES": "cpu=8,mem=250G,node=1,billing=8,gres/gpu=2"}, - "requested_tres_gpus", - ), - ( - { - "ReqTRES": ( - "cpu=8,mem=250G,node=1,billing=8," "gres/gpu=1,gres/gpu:h100=1" - ) - }, - "typed_gpu_tres", - ), - ], -) -def test_each_scheduler_contract_mismatch_fails(replacement, failure_field): - module = _load_module() - fields = module.parse_scontrol_record(_record(**replacement)) - _, _, failures = module.evaluate_contract(fields, **_expectations()) - assert failure_field in {failure["field"] for failure in failures} - - -def test_contract_cli_records_failure_and_exits_nonzero(tmp_path): - record = tmp_path / "scontrol.txt" - record.write_text(_record(Features="H200")) - output = tmp_path / "contract.json" - completed = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--record", - str(record), - "--expected-job-id", - "12345", - "--expected-account", - "gts-dxu345-rl2", - "--expected-partition", - "gpu-h200", - "--expected-qos", - "inferno", - "--expected-cpus", - "8", - "--expected-memory", - "250G", - "--expected-time-limit", - "3-00:00:00", - "--expected-constraint", - "H100|H200", - "--output", - str(output), - ], - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert completed.returncode == 1 - payload = json.loads(output.read_text()) - assert payload["status"] == "SLURM_JOB_CONTRACT_FAILED" - assert payload["failures"] == [ - {"field": "constraint", "expected": "H100|H200", "observed": "H200"} - ] - - -def test_parser_rejects_multiline_and_duplicate_fields(): - module = _load_module() - with pytest.raises(module.ContractError, match="one-line"): - module.parse_scontrol_record("JobId=1\nAccount=x\n") - with pytest.raises(module.ContractError, match="repeats field JobId"): - module.parse_scontrol_record("JobId=1 JobId=2") - - -def test_parser_accepts_empty_unrelated_slurm_fields(): - module = _load_module() - fields = module.parse_scontrol_record(_record(StdErr="")) - assert fields["StdErr"] == "" - _, _, failures = module.evaluate_contract(fields, **_expectations()) - assert failures == [] - - -def test_contract_still_rejects_empty_required_fields(): - module = _load_module() - fields = module.parse_scontrol_record(_record(Account="")) - with pytest.raises( - module.ContractError, match="required scontrol field Account is empty" - ): - module.evaluate_contract(fields, **_expectations()) - - -@pytest.mark.parametrize( - ("raw", "seconds"), - [("3-00:00:00", 3 * 86400), ("16:00:00", 16 * 3600), ("30", 1800)], -) -def test_slurm_duration_forms(raw, seconds): - module = _load_module() - assert module.parse_slurm_duration(raw) == seconds diff --git a/tests/test_verify_action_flow_training_smoke.py b/tests/test_verify_action_flow_training_smoke.py deleted file mode 100644 index 7c8bfe4ea5..0000000000 --- a/tests/test_verify_action_flow_training_smoke.py +++ /dev/null @@ -1,1026 +0,0 @@ -from __future__ import annotations - -import hashlib -import importlib.util -import json -import sys -from pathlib import Path - -import pytest -import torch -from hydra import compose, initialize_config_dir -from hydra.core.hydra_config import HydraConfig -from omegaconf import OmegaConf, open_dict - -from tools.validate_action_flow_config import PreflightError, compose_experiment - -SCRIPT = ( - Path(__file__).parents[1] - / "scripts" - / "train" - / "verify_action_flow_training_smoke.py" -) -SPEC = importlib.util.spec_from_file_location( - "verify_action_flow_training_smoke", SCRIPT -) -MODULE = importlib.util.module_from_spec(SPEC) -assert SPEC.loader is not None -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - -HEAD = "a" * 40 - - -def _resolved_smoke_config( - tmp_path: Path, - *, - reconstruction_weight: float = 1.0, - experiment: str | None = None, -): - if experiment is None: - suffix = {1.0: "1", 10.0: "10", 100.0: "100"}[reconstruction_weight] - experiment = f"pusht/action_flow_bc_usocket_recon{suffix}_s42" - cfg = compose_experiment(experiment) - run_dir = tmp_path / "run" - run_dir.mkdir() - normalization = tmp_path / "normalization.pt" - normalization.write_bytes(b"train-only-normalization") - normalization_hash = hashlib.sha256(normalization.read_bytes()).hexdigest() - - with open_dict(cfg): - cfg.trainer.max_steps = 2 - cfg.trainer.val_check_interval = 1 - cfg.trainer.limit_val_batches = 1 - cfg.trainer.log_every_n_steps = 1 - cfg.trainer.precision = "bf16" - cfg.callbacks.model_checkpoint.every_n_train_steps = 1 - cfg.model.gradient_telemetry_cadence = 2 - cfg.norm_stats.precomputed_norm_path = str(normalization) - cfg.run_provenance.source_commit = HEAD - cfg.run_provenance.normalization_sha256 = normalization_hash - cfg.evaluator.artifact_root = str(run_dir / "energy") - cfg.evaluator.action_flow_diagnostics.artifact_root = str( - run_dir / "diagnostics" - ) - cfg.hydra.runtime.cwd = str(MODULE.REPOSITORY_ROOT) - cfg.hydra.runtime.output_dir = str(run_dir) - HydraConfig.instance().set_config(cfg) - selected = OmegaConf.masked_copy(cfg, [key for key in cfg.keys() if key != "hydra"]) - resolved = OmegaConf.create(OmegaConf.to_container(selected, resolve=True)) - config_path = run_dir / ".hydra/config.yaml" - config_path.parent.mkdir() - OmegaConf.save(resolved, config_path) - return experiment, run_dir, config_path, normalization_hash - - -def test_config_gate_accepts_only_exact_two_step_contract(tmp_path, monkeypatch): - experiment, run_dir, config_path, normalization_hash = _resolved_smoke_config( - tmp_path - ) - monkeypatch.setattr(MODULE, "_git_head", lambda: HEAD) - - _, identities = MODULE._validate_config( - config_path=config_path, - experiment=experiment, - run_dir=run_dir, - expected_head=HEAD, - expected_config_sha256=hashlib.sha256(config_path.read_bytes()).hexdigest(), - expected_split_sha256=None, - expected_normalization_sha256=normalization_hash, - ) - - assert identities["repo_head"] == HEAD - assert identities["normalization_sha256"] == normalization_hash - assert ( - identities["config_sha256"] - == hashlib.sha256(config_path.read_bytes()).hexdigest() - ) - - -def test_config_gate_accepts_option_a_200m_muon_contract(tmp_path, monkeypatch): - experiment, run_dir, config_path, normalization_hash = _resolved_smoke_config( - tmp_path, - experiment=( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42" - ), - ) - monkeypatch.setattr(MODULE, "_git_head", lambda: HEAD) - - config, _ = MODULE._validate_config( - config_path=config_path, - experiment=experiment, - run_dir=run_dir, - expected_head=HEAD, - expected_config_sha256=hashlib.sha256(config_path.read_bytes()).hexdigest(), - expected_split_sha256=None, - expected_normalization_sha256=normalization_hash, - ) - - assert config.model.pipeline.stages[5].field.hidden_dim == 1_024 - assert config.model.pipeline.stages[5].field.depth == 14 - assert config.model.optimizer.lr == pytest.approx(1.0e-5) - - -def test_config_gate_accepts_option_a_200m_adamw_contract(tmp_path, monkeypatch): - experiment, run_dir, config_path, normalization_hash = _resolved_smoke_config( - tmp_path, - experiment=( - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_adamw_lr1e5_s42" - ), - ) - monkeypatch.setattr(MODULE, "_git_head", lambda: HEAD) - - config, _ = MODULE._validate_config( - config_path=config_path, - experiment=experiment, - run_dir=run_dir, - expected_head=HEAD, - expected_config_sha256=hashlib.sha256(config_path.read_bytes()).hexdigest(), - expected_split_sha256=None, - expected_normalization_sha256=normalization_hash, - ) - - assert config.model.pipeline.stages[5].field.hidden_dim == 1_024 - assert config.model.pipeline.stages[5].field.depth == 14 - assert config.model.optimizer._target_ == "torch.optim.AdamW" - assert config.model.optimizer.lr == pytest.approx(1.0e-5) - assert config.model.optimizer_named_parameters is False - - -@pytest.mark.parametrize( - "experiment", - [ - "pusht/action_flow_usocket_latent_fm_sg_unite_h384_s42", - "pusht/action_flow_usocket_latent_fm_sg_unite_h384_sum14_cfg4_val8_s42", - ], -) -def test_config_gate_rejects_retired_unite_h384_recipes(experiment): - with pytest.raises(PreflightError, match="absent or retired"): - compose_experiment(experiment) - - -def test_unite_h384_parent_preserves_component_contract(): - # Direct component composition is not authorization for a fresh smoke/run. - config_dir = MODULE.REPOSITORY_ROOT / "egomimic" / "hydra_configs" - with initialize_config_dir(version_base=None, config_dir=str(config_dir)): - config = compose( - config_name="train_zarr_cartesian", - overrides=[ - "+experiment=pusht/parents/action_flow_usocket_h384", - "++paths.root_dir=.", - "model.cfg_scale=4.0", - "model.flow_loss_aggregation=sum_samples", - "data.valid_dataloader_params.pushshapes_sim_u_socket.batch_size=32", - ], - ) - assert config.model.latent_dim == 16 - assert config.model.condition_dim == 128 - assert config.model.pipeline.stages[6].field.backbone.hidden_dim == 384 - assert config.model.pipeline.stages[6].field.backbone.depth == 12 - assert config.model.optimizer.lr == pytest.approx(1.0e-4) - assert config.norm_stats.norm_mode == "minmax" - assert config.model.pipeline.stages[6].cfg_scale == pytest.approx(4.0) - assert config.model.pipeline.stages[8].flow_aggregation == "sum_samples" - assert config.data.valid_dataloader_params.pushshapes_sim_u_socket.batch_size == 32 - - -@pytest.mark.parametrize( - ("path", "value", "message"), - [ - ("model.flow_samples_per_content", 1, "flow_samples_per_content"), - ("model.gradient_telemetry_cadence", 1, "gradient_telemetry_cadence"), - ("trainer.devices", 2, "trainer.devices"), - ("callbacks.model_checkpoint.every_n_train_steps", 2, "every_n_train_steps"), - ], -) -def test_config_gate_rejects_smoke_shortcuts( - tmp_path, monkeypatch, path, value, message -): - experiment, run_dir, config_path, _ = _resolved_smoke_config(tmp_path) - cfg = OmegaConf.load(config_path) - OmegaConf.update(cfg, path, value) - OmegaConf.save(cfg, config_path) - monkeypatch.setattr(MODULE, "_git_head", lambda: HEAD) - - with pytest.raises(MODULE.SmokeVerificationError, match=message): - MODULE._validate_config( - config_path=config_path, - experiment=experiment, - run_dir=run_dir, - expected_head=HEAD, - expected_config_sha256=None, - expected_split_sha256=None, - expected_normalization_sha256=None, - ) - - -def _history_row(): - row = {} - for name in ( - "TotalLoss", - "FlowMatchingLoss", - "ReconstructionLoss", - "ReconstructionL1", - "ActionVelocityLoss", - ): - train_value = 3.75 if name == "TotalLoss" else 1.25 - valid_value = 4.5 if name == "TotalLoss" else 1.5 - row[f"Train/ActionFlow/{name}_step"] = train_value - row[f"Train/ActionFlow/{name}/{MODULE.SOURCE_LABEL}_step"] = train_value - row[f"Valid/ActionFlow/{name}"] = valid_value - for label in ("FM", "Reconstruction", "ActionVelocity"): - row[f"Train/ActionFlow/GradientNorm/{label}"] = 0.5 - row[f"Train/ActionFlow/GradientParameterCount/{label}"] = 100.0 - for pair in ( - "FM__Reconstruction", - "FM__ActionVelocity", - "Reconstruction__ActionVelocity", - ): - row[f"Train/ActionFlow/GradientCosine/{pair}"] = 0.25 - row[f"Train/ActionFlow/GradientCosineDefined/{pair}"] = 1.0 - row[f"Train/ActionFlow/GradientIntersectionParameterCount/{pair}"] = 50.0 - row["Train/MSE"] = 1.25 - row[f"Train/MSE/{MODULE.SOURCE_LABEL}"] = 1.25 - row["Train/ActionFlow/Compute/FieldForwardCallsPerStep"] = 1.0 - row["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"] = 14.0 - row["Train/ActionFlow/Compute/DecoderJVPCallsPerStep"] = 1.0 - row["Train/ActionFlow/Compute/PeakAllocatedBytes"] = 1024.0 - row["Train/ActionFlow/Schedule/ReconstructionOnly"] = 0.0 - row["Train/ActionFlow/Schedule/EffectiveFlowWeight"] = 1.0 - row["Train/ActionFlow/Schedule/EffectiveActionVelocityWeight"] = 1.0 - for base in ( - "Valid/MSE", - "Valid/Native_MSE", - "Valid/EnergyScore@32", - "Valid/EnergyScoreAccuracy@32", - "Valid/EnergyScoreDiversity@32", - ): - row[base] = 0.75 - row[f"{base}/{MODULE.SOURCE_LABEL}"] = 0.75 - for name in ( - "Valid/ActionFlow/CleanReconstructionMSE", - "Valid/ActionFlow/CleanReconstructionNativeMSE", - "Valid/ActionFlow/Latent/clean/RMS", - "Valid/ActionFlow/Latent/generated/EffectiveRank", - "Valid/ActionFlow/DecoderJacobian/clean/spectral_norm_mean", - "Valid/ActionFlow/DecodedFullNoise/token_radius_mean", - "Valid/ActionFlow/DenoisingTrajectory/LatentMSE/t1000", - "Valid/ActionFlow/DenoisingTrajectory/DecodedMSE/t0000", - "Valid/ActionFlow/DenoisingTrajectory/DecodedNativeMSE/t0000", - "Valid/ActionFlow/Alignment/FinalLatentCosine/t1000", - "Valid/ActionFlow/Alignment/CKA/encoder_00__field_00/t0500", - "Valid/ActionFlow/Alignment/CKNNA/encoder_00__field_00/t0500", - ): - row[name] = 0.5 - if "NativeMSE" in name or "decoded_native_mse" in name: - row[f"{name}/{MODULE.SOURCE_LABEL}"] = 0.5 - return row - - -def test_history_gate_requires_components_gradients_and_scheduled_validation(): - result = MODULE._validate_history({0: {}, 2: _history_row()}) - - assert result["train_step"] == 2 - assert result["valid_step"] == 2 - assert result["train"]["Train/ActionFlow/FlowMatchingLoss"] == 1.25 - - -def test_history_gate_accepts_float32_flow_weight_telemetry(): - row = _history_row() - row["Train/ActionFlow/Schedule/EffectiveFlowWeight"] = float(torch.tensor(0.01)) - for suffix in ("", f"/{MODULE.SOURCE_LABEL}"): - row[f"Train/ActionFlow/TotalLoss{suffix}_step"] = 2.5125 - row["Valid/ActionFlow/TotalLoss"] = 3.015 - - result = MODULE._validate_history( - {2: row}, reconstruction_weight=1.0, flow_weight=0.01 - ) - - assert result["train_step"] == 2 - - -def test_unite_history_gate_requires_both_optimizer_family_rates(): - row = _history_row() - row["Train/ActionFlow/Compute/FieldForwardCallsPerStep"] = 2.0 - row["Train/ActionFlow/Compute/FieldSampleEquivalentsPerStep"] = 28.0 - row["Train/ActionFlow/GradientCosine/FM__Reconstruction"] = 0.0 - row["Train/ActionFlow/GradientCosineDefined/FM__Reconstruction"] = 0.0 - row["Train/ActionFlow/GradientIntersectionParameterCount/FM__Reconstruction"] = 0.0 - row["Optimizer/LR/AdamW"] = 2.5e-8 - row["Optimizer/LR/Muon"] = 2.5e-8 - - result = MODULE._validate_history({2: row}, method=MODULE.STOPGRAD_UNITE_METHOD) - - assert result["train"]["Optimizer/LR/AdamW"] == pytest.approx(2.5e-8) - assert result["train"]["Optimizer/LR/Muon"] == pytest.approx(2.5e-8) - - del row["Optimizer/LR/Muon"] - with pytest.raises(MODULE.SmokeVerificationError, match="gradient telemetry"): - MODULE._validate_history({2: row}, method=MODULE.STOPGRAD_UNITE_METHOD) - - -def test_history_gate_rejects_missing_gradient_telemetry(): - row = _history_row() - del row["Train/ActionFlow/GradientCosine/FM__ActionVelocity"] - - with pytest.raises(MODULE.SmokeVerificationError, match="gradient telemetry"): - MODULE._validate_history({2: row}) - - -def _write_artifacts(tmp_path: Path): - split_hash = "b" * 64 - norm_hash = "d" * 64 - content_hash = "e" * 64 - aggregate_hash = "f" * 64 - energy_root = tmp_path / "energy" - diagnostic_root = tmp_path / "diagnostics" - config_path = tmp_path / ".hydra/config.yaml" - config_path.parent.mkdir() - config_path.write_text("name: test\n") - content_path = tmp_path / "content.json" - content_path.write_text("{}\n") - leaf = Path("epoch-0-step-2/rank-0-batch-0.pt") - energy_path = energy_root / leaf - diagnostic_path = diagnostic_root / leaf - energy_path.parent.mkdir(parents=True) - diagnostic_path.parent.mkdir(parents=True) - seed_hash = "c" * 64 - targets = torch.zeros(16, 16, 4) - condition_ids = [ - { - "batch_position": index, - "episode_hash": f"episode-{index:04d}", - "frame_index": index, - "normalized_target_sha256": MODULE._target_tensor_sha256(targets[index]), - } - for index in range(16) - ] - energy_view = { - "definition": "first_deterministic_validation_batch_per_ddp_rank", - "split_manifest_sha256": split_hash, - "per_rank_batch_size": 16, - "world_size": 1, - } - distance = MODULE.usocket_energy_distance_metadata( - MODULE.USOCKET_ENERGY_DISTANCE_CONFIG - ) - wandb = {"entity": "rl2-group", "project": "test", "run_id": "smoke"} - energy_identity = { - "schema": "energy-score-validation-artifact/v2", - "metric": { - "name": "EnergyScore@32", - "sample_count": 32, - "deterministic_seed": 420042, - }, - "source_commit": HEAD, - "normalization_sha256": norm_hash, - "split_manifest_sha256": split_hash, - "resolved_config_path": str(config_path.resolve()), - "resolved_config_sha256": hashlib.sha256(config_path.read_bytes()).hexdigest(), - "wandb": wandb, - "dataset_content": { - "manifest_path": str(content_path.resolve()), - "manifest_sha256": content_hash, - "aggregate_sha256": aggregate_hash, - }, - "distance": distance, - "seed_bank_sha256": seed_hash, - "validation_view": energy_view, - "validation_conditions": {MODULE.SOURCE_LABEL: condition_ids}, - "checkpoint_binding": { - "global_step": 2, - "checkpoint_sha256": None, - "sha256_status": MODULE.ENERGY_CHECKPOINT_STATUS, - }, - } - torch.save( - { - "schema_version": 2, - "metric": "EnergyScore@32", - "sample_count": 32, - "seed_bank": list(range(32)), - "seed_bank_sha256": seed_hash, - "deterministic_seed": 420042, - "distance": distance, - "global_step": 2, - "validation_view": energy_view, - "identity": energy_identity, - "identity_sha256": MODULE._canonical_json_sha256(energy_identity), - "domains": { - MODULE.SOURCE_LABEL: { - "predictions": torch.zeros(32, 16, 16, 4), - "targets": targets, - "native_predictions": torch.zeros(32, 16, 16, 3), - "native_targets": torch.zeros(16, 16, 3), - "condition_ids": condition_ids, - } - }, - }, - energy_path, - ) - diagnostic_provenance = { - "source_commit": HEAD, - "normalization_sha256": norm_hash, - "split_manifest_sha256": split_hash, - "sampler_steps": 16, - "dataset_content": { - "manifest_sha256": content_hash, - "aggregate_sha256": aggregate_hash, - }, - } - identity = { - "schema": "action-flow-validation-diagnostics/v1", - "validation_view": energy_view, - "provenance": diagnostic_provenance, - "native_error": MODULE.USOCKET_NATIVE_ERROR_CONFIG, - } - identity_hash = hashlib.sha256( - json.dumps( - identity, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - allow_nan=False, - ).encode() - ).hexdigest() - torch.save( - { - "schema_version": 1, - "metric": "ActionFlowValidationDiagnostics", - "global_step": 2, - "identity": identity, - "identity_sha256": identity_hash, - "sampler": {"name": "reverse_euler"}, - "validation_view": energy_view, - "provenance": diagnostic_provenance, - "statistics": {"native_action_error": MODULE.USOCKET_NATIVE_ERROR_CONFIG}, - "sources": { - MODULE.SOURCE_LABEL: { - "computed": { - "clean_reconstruction_native_mse_by_condition": torch.ones(16), - "trajectory_decoded_native_mse_by_condition": torch.ones( - 17, 16 - ), - "fixed_level_metrics": { - label: { - metric: torch.ones(16) - for metric in ( - "state_decoded_native_mse", - "predicted_clean_decoded_native_mse", - "final_decoded_native_mse", - ) - } - for label in MODULE.NATIVE_LEVELS - }, - } - } - }, - }, - diagnostic_path, - ) - diagnostic_hash = hashlib.sha256(diagnostic_path.read_bytes()).hexdigest() - Path(f"{diagnostic_path}.sha256").write_text( - json.dumps( - { - "artifact": diagnostic_path.name, - "identity_sha256": identity_hash, - "sha256": diagnostic_hash, - } - ) - ) - config = OmegaConf.create( - { - "evaluator": { - "artifact_root": str(energy_root), - "seed_bank_sha256": seed_hash, - "energy_score_validation_view": energy_view, - "action_flow_diagnostics": { - "artifact_root": str(diagnostic_root), - "validation_view": energy_view, - "provenance": {"sampler_steps": 16}, - }, - }, - "logger": { - "wandb": { - "entity": wandb["entity"], - "project": wandb["project"], - "id": wandb["run_id"], - } - }, - } - ) - identities = { - "config_sha256": hashlib.sha256(config_path.read_bytes()).hexdigest(), - "content_manifest_path": str(content_path.resolve()), - "content_manifest_sha256": content_hash, - "dataset_content_aggregate_sha256": aggregate_hash, - "normalization_sha256": norm_hash, - "repo_head": HEAD, - "split_manifest_sha256": split_hash, - } - checkpoint = { - "checkpoint_sha256": "1" * 64, - "file_size_bytes": 100, - "global_step": 2, - } - return config, identities, checkpoint, diagnostic_path - - -def test_artifact_gate_verifies_energy_and_diagnostic_immutability(tmp_path): - config, identities, checkpoint, diagnostic_path = _write_artifacts(tmp_path) - - result = MODULE._validate_artifacts( - config=config, - run_dir=tmp_path, - identities=identities, - checkpoint=checkpoint, - ) - - assert result["energy_score"]["sha256"] - assert result["action_flow_diagnostics"]["path"] == str(diagnostic_path) - - -def test_artifact_gate_selects_exact_slurm_attempt_and_checks_execution( - tmp_path, monkeypatch -): - root = tmp_path / "artifacts" - for restart in (0, 1): - artifact = ( - root / f"job-5714540-restart-{restart}" / "epoch-0-step-2/rank-0-batch-0.pt" - ) - artifact.parent.mkdir(parents=True) - torch.save( - { - "global_step": 2, - "execution": { - "slurm_job_id": "5714540", - "slurm_restart_count": restart, - }, - }, - artifact, - ) - monkeypatch.setenv("SLURM_JOB_ID", "5714540") - monkeypatch.setenv("SLURM_RESTART_COUNT", "1") - selected, payload = MODULE._step_two_artifact(root, label="test") - assert "job-5714540-restart-1" in str(selected) - payload["execution"]["slurm_restart_count"] = 0 - torch.save(payload, selected) - with pytest.raises( - MODULE.SmokeVerificationError, match="execution identity mismatch" - ): - MODULE._step_two_artifact(root, label="test") - monkeypatch.delenv("SLURM_JOB_ID") - with pytest.raises(MODULE.SmokeVerificationError, match="expected one step-2"): - MODULE._step_two_artifact(root, label="test") - - -def test_artifact_gate_rejects_tampered_diagnostic_sidecar(tmp_path): - config, identities, checkpoint, diagnostic_path = _write_artifacts(tmp_path) - sidecar = Path(f"{diagnostic_path}.sha256") - payload = json.loads(sidecar.read_text()) - payload["sha256"] = "0" * 64 - sidecar.write_text(json.dumps(payload)) - - with pytest.raises(MODULE.SmokeVerificationError, match="sidecar mismatch"): - MODULE._validate_artifacts( - config=config, - run_dir=tmp_path, - identities=identities, - checkpoint=checkpoint, - ) - - -def test_artifact_gate_accepts_unique_origin_from_separate_validation_job( - tmp_path, monkeypatch -): - root = tmp_path / "artifacts" - artifact = root / "job-5714540-restart-1/epoch-0-step-2/rank-0-batch-0.pt" - artifact.parent.mkdir(parents=True) - torch.save( - { - "global_step": 2, - "execution": {"slurm_job_id": "5714540", "slurm_restart_count": 1}, - }, - artifact, - ) - monkeypatch.setenv("SLURM_JOB_ID", "5719999") - monkeypatch.setenv("SLURM_RESTART_COUNT", "0") - selected, payload = MODULE._step_two_artifact(root, label="test") - assert selected == artifact - assert payload["execution"]["slurm_job_id"] == "5714540" - - -def test_preflight_gate_binds_source_experiment_split_and_normalization(tmp_path): - experiment = "pusht/action_flow_bc_usocket_recon1_s42" - split_hash = "b" * 64 - normalization_hash = "c" * 64 - content_hash = "d" * 64 - aggregate_hash = "e" * 64 - destination = tmp_path / "provenance/restart-0/PREFLIGHT_RESULT.json" - destination.parent.mkdir(parents=True) - destination.write_text( - json.dumps( - { - "status": "PASS", - "source": {"head": HEAD}, - "experiment": experiment, - "split_manifest_sha256": split_hash, - "normalization_sha256": normalization_hash, - "content_manifest_sha256": content_hash, - "dataset_content_aggregate_sha256": aggregate_hash, - }, - sort_keys=True, - ) - ) - digest = hashlib.sha256(destination.read_bytes()).hexdigest() - - result = MODULE._validate_preflight( - run_dir=tmp_path, - expected_sha256=digest, - expected_head=HEAD, - experiment=experiment, - split_sha256=split_hash, - normalization_sha256=normalization_hash, - content_manifest_sha256=content_hash, - dataset_content_aggregate_sha256=aggregate_hash, - ) - - assert result == {"path": str(destination), "sha256": digest, "copy_count": 1} - - -def test_cli_accepts_launcher_compatibility_names(): - args = MODULE._parser().parse_args( - [ - "/tmp/run", - "--expected-head", - HEAD, - "--expected-experiment", - "pusht/action_flow_bc_usocket_recon10_s42", - "--expected-reconstruction-weight", - "10", - "--expected-preflight-sha256", - "d" * 64, - ] - ) - - assert args.experiment.endswith("recon10_s42") - assert args.expected_reconstruction_weight == 10.0 - - -def test_cli_accepts_codec98k_experiment(): - args = MODULE._parser().parse_args( - [ - "/tmp/run", - "--expected-head", - HEAD, - "--expected-experiment", - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_codec98k_s42", - "--expected-preflight-sha256", - "d" * 64, - ] - ) - - assert args.experiment.endswith("recon1_codec98k_s42") - - -def test_cli_accepts_option_a_200m_muon_experiment(): - args = MODULE._parser().parse_args( - [ - "/tmp/run", - "--expected-head", - HEAD, - "--expected-experiment", - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42", - "--expected-preflight-sha256", - "d" * 64, - ] - ) - - assert args.experiment.endswith("recon1_200m_muon_lr1e5_s42") - - -def test_cli_accepts_option_a_200m_adamw_experiment(): - args = MODULE._parser().parse_args( - [ - "/tmp/run", - "--expected-head", - HEAD, - "--expected-experiment", - "pusht/action_flow_bc_usocket_latent_fm_sg_recon1_200m_adamw_lr1e5_s42", - "--expected-preflight-sha256", - "d" * 64, - ] - ) - - assert args.experiment.endswith("recon1_200m_adamw_lr1e5_s42") - - -def test_gpu_probe_gate_requires_real_single_h100_or_h200_bf16(tmp_path): - path = tmp_path / "provenance/restart-0/gpu_probe.json" - path.parent.mkdir(parents=True) - path.write_text( - json.dumps( - { - "status": "PASSED", - "world_size": 1, - "gpu_name": "NVIDIA H200", - "bf16_supported": True, - "bf16_forward_backward": { - "dtype": "torch.bfloat16", - "gradient_dtype": "torch.bfloat16", - "finite": True, - }, - "nccl": {"world_size": 1, "all_reduce": 1.0, "destroyed": True}, - } - ) - ) - - records = MODULE._validate_gpu_probes(tmp_path) - - assert records[0]["gpu_name"] == "NVIDIA H200" - - -def test_gpu_probe_gate_rejects_non_target_gpu(tmp_path): - path = tmp_path / "provenance/restart-0/gpu_probe.json" - path.parent.mkdir(parents=True) - path.write_text( - json.dumps( - { - "status": "PASSED", - "world_size": 1, - "gpu_name": "NVIDIA A100-SXM4-80GB", - "bf16_supported": True, - "bf16_forward_backward": { - "dtype": "torch.bfloat16", - "gradient_dtype": "torch.bfloat16", - "finite": True, - }, - "nccl": {"world_size": 1, "all_reduce": 1.0, "destroyed": True}, - } - ) - ) - - with pytest.raises(MODULE.SmokeVerificationError, match="H100 or H200"): - MODULE._validate_gpu_probes(tmp_path) - - -def _scaled_muon_optimizer_state(): - return { - "adamw": { - "state": {0: {"exp_avg": torch.zeros(1)}}, - "param_groups": [{"lr": 1.0e-5, "params": [0]}], - }, - "muon": { - "state": {1: {"momentum_buffer": torch.zeros(1)}}, - "param_groups": [{"lr": 1.0e-5, "params": [1]}], - }, - "group_manifest": { - "adamw_parameter_names": ("encoder.bias",), - "muon_parameter_names": ("field.weight",), - "muon_source_commit": "a" * 40, - }, - } - - -def test_optimizer_state_gate_accepts_scaled_muon_composite_state(): - config = OmegaConf.create( - {"name": "action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42"} - ) - - MODULE._validate_optimizer_state(_scaled_muon_optimizer_state(), config) - - -@pytest.mark.parametrize("group", ["adamw", "muon"]) -def test_optimizer_state_gate_rejects_empty_scaled_muon_nested_state(group): - config = OmegaConf.create( - {"name": "action_flow_bc_usocket_latent_fm_sg_recon1_200m_muon_lr1e5_s42"} - ) - optimizer_state = _scaled_muon_optimizer_state() - optimizer_state[group]["state"] = {} - - with pytest.raises( - MODULE.SmokeVerificationError, match=f"{group} optimizer state is empty" - ): - MODULE._validate_optimizer_state(optimizer_state, config) - - -def _checkpoint_payload(): - return { - "global_step": 2, - "state_dict": {"model.weight": torch.ones(1)}, - "optimizer_states": [ - { - "state": {0: {"exp_avg": torch.zeros(1)}}, - "param_groups": [{"lr": 3.0e-5, "params": [0]}], - } - ], - "lr_schedulers": [{"last_epoch": 2, "_last_lr": [3.0e-6]}], - "loops": {"fit_loop": {"completed": 2}}, - } - - -def test_checkpoint_gate_strictly_reloads_exact_action_flow_wrapper( - tmp_path, monkeypatch -): - checkpoint_dir = tmp_path / "checkpoints" - checkpoint_dir.mkdir() - payload = _checkpoint_payload() - immutable = checkpoint_dir / "epoch-epoch=0-step-step=2.ckpt" - torch.save(payload, immutable) - (checkpoint_dir / "last.ckpt").symlink_to(immutable.name) - - class _Parameter: - @staticmethod - def numel(): - return MODULE.EXPECTED_PARAMETER_COUNT - - class _Wrapper: - encoder_e = object() - field_v = object() - decoder_g = object() - - class _Nets: - @staticmethod - def named_parameters(**_kwargs): - return [] - - nets = _Nets() - - @classmethod - def load_from_checkpoint(cls, *_args, **_kwargs): - return cls() - - @staticmethod - def parameters(): - return [_Parameter()] - - monkeypatch.setattr(MODULE, "ActionFlowModelWrapper", _Wrapper) - monkeypatch.setattr( - MODULE, - "_validate_gradient_route_manifest", - lambda *_args: {"manifest_sha256": "f" * 64}, - ) - - result = MODULE._validate_checkpoint( - tmp_path, reconstruction_weight=1.0, flow_weight=1.0 - ) - - assert result["global_step"] == 2 - assert result["parameter_count"] == MODULE.EXPECTED_PARAMETER_COUNT - assert result["strict_checkpoint_reload"] == "passed" - assert result["file_size_bytes"] > 0 - - -def _gradient_manifest(named_parameters): - route_names = { - "FM": [name for name, _ in named_parameters[:3]], - "Reconstruction": [named_parameters[1][0], named_parameters[3][0]], - "ActionVelocity": [name for name, _ in named_parameters], - } - routes = {} - route_hashes = {} - for label, names in route_names.items(): - parameters = dict(named_parameters) - route = [ - { - "dtype": str(parameters[name].dtype), - "name": name, - "numel": parameters[name].numel(), - "shape": list(parameters[name].shape), - } - for name in names - ] - routes[label] = route - route_hashes[label] = MODULE._canonical_json_sha256(route) - intersections = { - "FM__Reconstruction": [named_parameters[1][0]], - "FM__ActionVelocity": [name for name, _ in named_parameters[:3]], - "Reconstruction__ActionVelocity": [ - named_parameters[1][0], - named_parameters[3][0], - ], - } - core = { - "routes": routes, - "route_sha256": route_hashes, - "intersections": intersections, - "schema_version": 1, - } - return {**core, "manifest_sha256": MODULE._canonical_json_sha256(core)} - - -def test_gradient_route_manifest_binds_exact_named_shared_paths(): - named = ( - ("nets.pipeline.stages.0.observation", torch.nn.Parameter(torch.ones(2))), - ("nets.pipeline.stages.3.encoder", torch.nn.Parameter(torch.ones(3))), - ("nets.pipeline.stages.5.field", torch.nn.Parameter(torch.ones(4))), - ("nets.pipeline.stages.6.decoder", torch.nn.Parameter(torch.ones(5))), - ) - manifest = _gradient_manifest(named) - - result = MODULE._validate_gradient_route_manifest(manifest, named) - - assert result["manifest_sha256"] == manifest["manifest_sha256"] - assert result["route_parameter_counts"] == { - "FM": 9, - "Reconstruction": 8, - "ActionVelocity": 14, - } - assert result["shared_path_parameter_counts"]["FM__Reconstruction"] == 3 - - -def test_gradient_route_manifest_rejects_tampered_named_route(): - named = ( - ("nets.pipeline.stages.0.observation", torch.nn.Parameter(torch.ones(2))), - ("nets.pipeline.stages.3.encoder", torch.nn.Parameter(torch.ones(3))), - ("nets.pipeline.stages.5.field", torch.nn.Parameter(torch.ones(4))), - ("nets.pipeline.stages.6.decoder", torch.nn.Parameter(torch.ones(5))), - ) - manifest = _gradient_manifest(named) - manifest["routes"]["FM"][0]["numel"] = 99 - - with pytest.raises(MODULE.SmokeVerificationError, match="size mismatch"): - MODULE._validate_gradient_route_manifest(manifest, named) - - -def test_checkpoint_gate_rejects_missing_scheduler(tmp_path): - checkpoint_dir = tmp_path / "checkpoints" - checkpoint_dir.mkdir() - payload = _checkpoint_payload() - payload["lr_schedulers"] = [] - immutable = checkpoint_dir / "epoch-epoch=0-step-step=2.ckpt" - torch.save(payload, immutable) - (checkpoint_dir / "last.ckpt").symlink_to(immutable.name) - - with pytest.raises(MODULE.SmokeVerificationError, match="scheduler state"): - MODULE._validate_checkpoint( - tmp_path, reconstruction_weight=1.0, flow_weight=1.0 - ) - - -def _raw_multiplier_verifier(): - import importlib.util - - path = ( - Path(__file__).resolve().parents[1] - / "scripts/train/verify_scaled_action_flow_training_smoke.py" - ) - spec = importlib.util.spec_from_file_location("raw_multiplier_verifier", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.mark.parametrize( - "damage", [None, "missing", "decay", "updates", "shape", "nan"] -) -def test_raw_multiplier_native_ema_verification(damage): - from types import SimpleNamespace - - from omegaconf import OmegaConf - - from egomimic.utils.ema_callback import EMACallback - - verifier = _raw_multiplier_verifier() - row = verifier.EXPERIMENTS["pusht/action_flow_cotrain_uc_multiplier_interpolation"] - assert row["parameter_count"] == 471549402 and row["hidden_dim"] == 696 - model = torch.nn.Linear(2, 2) - callback = EMACallback(decay=0.9978) - trainer = SimpleNamespace(global_step=0) - callback.on_fit_start(trainer, model) - callback._update(model) - callback._update(model) - checkpoint = {"global_step": 2, "state_dict": model.state_dict()} - callback.on_save_checkpoint(trainer, model, checkpoint) - cfg = OmegaConf.create( - { - "callbacks": { - "ema": { - "_target_": "egomimic.utils.ema_callback.EMACallback", - "decay": 0.9978, - "validate_with_ema": True, - "use_warmup": False, - } - } - } - ) - if damage == "missing": - checkpoint.pop("ema_state_dict") - elif damage == "decay": - checkpoint["ema_config"]["decay"] = 0.9 - elif damage == "updates": - checkpoint["ema_num_updates"] = 1 - elif damage == "shape": - checkpoint["ema_state_dict"]["weight"] = torch.zeros(3, 3) - elif damage == "nan": - checkpoint["ema_state_dict"]["weight"].fill_(float("nan")) - if damage: - with pytest.raises((RuntimeError, verifier.VerificationError)): - verifier.validate_raw_multiplier_ema(cfg, model, checkpoint) - else: - assert ( - verifier.validate_raw_multiplier_ema(cfg, model, checkpoint)[ - "strict_ema_reload" - ] - == "passed" - ) diff --git a/tests/test_video_contract.py b/tests/test_video_contract.py deleted file mode 100644 index e055b83c8c..0000000000 --- a/tests/test_video_contract.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Episode identity, native frame timing and distributed video reconstruction.""" - -from types import SimpleNamespace - -import pytest -import torch - -from egomimic.eval.bimanual_cartesian_eval import BimanualCartesianEval - - -def evaluator(tmp_path, **kwargs): - obj = BimanualCartesianEval( - sample_id_key="trial", frame_index_key="tick", source_fps=24.0, **kwargs - ) - obj.trainer = SimpleNamespace( - is_global_zero=True, - current_epoch=0, - default_root_dir=str(tmp_path), - world_size=2, - logger=None, - ) - obj.on_validation_start() - return obj - - -def test_rank_frames_are_reassembled_and_padding_deduplicated(tmp_path, monkeypatch): - obj = evaluator(tmp_path, complete_video_episodes=True) - key = ("valid", "opaque-source") - obj._record_video_frames( - key, - torch.zeros(2, 4, 4, 3, dtype=torch.uint8), - {"trial": ["episode", "episode"], "tick": [0, 2]}, - ) - remote = { - (*key, "episode"): { - 1: torch.ones(4, 4, 3, dtype=torch.uint8), - 3: torch.ones(4, 4, 3, dtype=torch.uint8), - } - } - monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True) - monkeypatch.setattr(torch.distributed, "get_world_size", lambda: 2) - monkeypatch.setattr(torch.distributed, "get_rank", lambda: 0) - monkeypatch.setattr( - torch.distributed, - "all_gather_object", - lambda output, local: output.__setitem__( - slice(None), [local, {key: tuple(values) for key, values in remote.items()}] - ), - ) - monkeypatch.setattr( - torch.distributed, - "gather_object", - lambda values, output, dst: output.__setitem__( - slice(None), [values, next(iter(remote.values()))] - ), - ) - monkeypatch.setattr( - torch.distributed, "broadcast_object_list", lambda *a, **k: None - ) - writes = [] - monkeypatch.setattr( - obj, - "_write_episode_stream", - lambda path, name, frames, **kw: writes.append( - (torch.stack([torch.as_tensor(f) for f in frames]), kw) - ), - ) - obj.on_validation_end() - assert len(writes) == 1 - frames, settings = writes[0] - assert settings["fps"] == 24.0 - assert frames[:, 0, 0, 0].tolist() == [0, 1, 0, 1] - - -def test_sparse_video_uses_actual_indices_not_world_size(tmp_path, monkeypatch): - obj = evaluator(tmp_path) - obj._record_video_frames( - ("valid", "source"), - torch.zeros(3, 4, 4, 3, dtype=torch.uint8), - {"trial": ["e"] * 3, "tick": [0, 3, 6]}, - ) - settings = [] - monkeypatch.setattr( - obj, "_write_episode_stream", lambda *a, **kw: settings.append(kw) - ) - obj.on_validation_end() - assert settings[0]["fps"] == 8.0 - - -def test_episode_video_does_not_guess_missing_metadata(tmp_path): - obj = evaluator(tmp_path) - with pytest.raises(ValueError, match="trial.*tick"): - obj._record_video_frames(("valid", "source"), torch.zeros(1, 4, 4, 3), {}) - - -def test_full_episode_video_rejects_gaps(tmp_path): - obj = evaluator(tmp_path, complete_video_episodes=True) - obj._record_video_frames( - ("valid", "source"), - torch.zeros(2, 4, 4, 3), - {"trial": ["e"] * 2, "tick": [0, 2]}, - ) - with pytest.raises(ValueError, match="incomplete"): - obj.on_validation_end() - - -def test_streamed_video_has_exact_frame_count_and_fractional_fps(tmp_path): - import av - - obj = evaluator(tmp_path, complete_video_episodes=True) - obj.source_fps = 30000 / 1001 - frames = torch.stack( - [torch.full((16, 16, 3), i * 50, dtype=torch.uint8) for i in range(4)] - ) - obj._record_video_frames( - ("valid", "source"), frames, {"trial": ["episode"] * 4, "tick": list(range(4))} - ) - # The corpus is represented by filenames, not resident image tensors. - assert all( - path.is_file() - for records in obj._frame_records.values() - for path in records.values() - ) - obj.on_validation_end() - [path] = list(tmp_path.rglob("episode.mp4")) - with av.open(str(path)) as container: - stream = container.streams.video[0] - assert float(stream.average_rate) == pytest.approx(30000 / 1001) - decoded = [f.to_ndarray(format="rgb24") for f in container.decode(stream)] - assert len(decoded) == 4 - assert [float(f.mean()) for f in decoded] == pytest.approx([0, 50, 100, 150], abs=3) - - -def test_nonzero_rank_renders_its_local_samples(tmp_path): - import numpy as np - - obj = evaluator(tmp_path, complete_video_episodes=True) - obj.trainer.is_global_zero = False - obj.viz_func = {"source": lambda **kwargs: np.zeros((1, 16, 16, 3), dtype=np.uint8)} - obj.revert_transforms = {"source": []} - obj._native = lambda values, embodiment: values - obj._native_pose = lambda values, embodiment: values - obj._viz_source = lambda values, embodiment: values - obj._maybe_log_overlay( - embodiment_name="source", - source_batch={ - "trial": ["e"], - "tick": [1], - "embodiment": torch.tensor([7]), - obj.action_key: torch.zeros(1, 2, 14), - obj.obs_pose_key: torch.zeros(1, 14), - obj.image_key: torch.zeros(1, 3, 16, 16), - }, - predictions={"pred_action": torch.zeros(1, 2, 14)}, - embodiment_id=7, - ) - assert set(obj._frame_records[("valid", "source", "e")]) == {1} - - -def test_complete_video_rejects_batch_cap(): - obj = BimanualCartesianEval( - complete_video_episodes=True, viz_max_batches=1, viz_func={"source": object()} - ) - with pytest.raises(ValueError, match="viz_max_batches"): - obj.data_requirements() - - -def test_complete_video_requires_renderer_reversion_and_declares_tensor_inputs(): - obj = BimanualCartesianEval(complete_video_episodes=True) - with pytest.raises(ValueError, match="renderers"): - obj.data_requirements() - obj.viz_func = {"source": object()} - with pytest.raises(ValueError, match="frame reversion"): - obj.data_requirements() - obj.revert_transforms = {"source": []} - assert set(obj.data_requirements().required_keys) == { - obj.action_key, - obj.obs_pose_key, - obj.image_key, - } - - -def test_sparse_episode_upload_keeps_the_encoded_frame_rate(tmp_path, monkeypatch): - import sys - - obj = evaluator(tmp_path) - logged = [] - obj.trainer.logger = SimpleNamespace( - experiment=SimpleNamespace( - id="fake", log=lambda values, **kw: logged.append(values) - ) - ) - monkeypatch.setitem( - sys.modules, - "wandb", - SimpleNamespace(Video=lambda path, **kw: {"path": path, **kw}), - ) - obj._record_video_frames( - ("valid", "source"), - torch.zeros(3, 16, 16, 3, dtype=torch.uint8), - {"trial": ["e"] * 3, "tick": [0, 3, 6]}, - ) - obj.on_validation_end() - assert logged[0]["Val_video/source"][0]["fps"] == 8.0 diff --git a/tests/test_virtual_gello_teleop.py b/tests/test_virtual_gello_teleop.py deleted file mode 100644 index d775124259..0000000000 --- a/tests/test_virtual_gello_teleop.py +++ /dev/null @@ -1,127 +0,0 @@ -from pathlib import Path - -import numpy as np -import pytest -import yaml - -from egomimic.robot.virtual_gello_teleop import ( - VirtualYamFollower, - default_model_path, - profile_home, - real_leader_specs, - run_demo, -) - - -def station_xml(tmp_path: Path) -> Path: - """A minimal named dual-arm model keeps CPU checks independent of i2rt.""" - arms = [] - for arm, y in (("left", 0), ("right", 0.7)): - links = [f''] - for index in range(1, 7): - links.append( - f'' - '' - ) - links.append(f'') - for index in (7, 8): - links.append( - f'' - '' - ) - links.append("" * 8) - arms.append("".join(links)) - path = tmp_path / "dual_arm.xml" - path.write_text( - '' - + "".join(arms) - + "" - ) - return path - - -def test_virtual_gello_runs_shared_controller_against_dual_arm_model(tmp_path): - robot = VirtualYamFollower(station_xml(tmp_path)) - start = robot.get_obs()["joint_positions"] - command = run_demo(robot, frequency=60, steps=120) - assert command.shape == (14,) - assert np.isfinite(command).all() - assert not np.allclose(command, start) - assert np.all((robot.q[[6, 13]] >= 0) & (robot.q[[6, 13]] <= 1)) - - -def test_real_leader_specs_overlay_exported_calibrations(tmp_path): - config = { - "gello": { - "bus": {"baudrate": 57600}, - "input_filter_alpha": 0.9, - "leaders": {"left": {"port": "L"}, "right": {"port": "R"}}, - } - } - left = {"gello": {"leaders": {"left": {"joint_offsets_rad": [1] * 6}}}} - right = {"gello": {"leaders": {"right": {"joint_offsets_rad": [2] * 6}}}} - paths = [] - for name, value in (("config", config), ("left", left), ("right", right)): - path = tmp_path / f"{name}.yaml" - path.write_text(yaml.safe_dump(value)) - paths.append(path) - specs = real_leader_specs(*paths) - assert specs["left"]["joint_offsets_rad"] == [1] * 6 - assert specs["right"]["joint_offsets_rad"] == [2] * 6 - assert specs["left"]["filter_alpha"] == 0.9 - assert specs["right"]["filter_alpha"] == 0.9 - - -def test_profile_home_uses_configured_follower_pose(tmp_path): - path = tmp_path / "profile.yaml" - path.write_text( - yaml.safe_dump( - {"robot": {"home": {"left": [1] * 6 + [1.0], "right": [2] * 6 + [1.0]}}} - ) - ) - home = profile_home(path) - assert np.array_equal(home["left"], np.ones(7)) - assert np.array_equal(home["right"], np.r_[np.full(6, 2.0), 1.0]) - - -def test_virtual_absolute_mapping_preserves_home_and_full_turn_error(tmp_path): - from egomimic.robot.yam.gello import GelloSample, GelloTeleopControl - - robot = VirtualYamFollower(station_xml(tmp_path)) - robot.home = { - "left": np.r_[np.full(6, 0.01), 1.0], - "right": np.r_[np.full(6, -0.02), 1.0], - } - leaders = { - "left": np.r_[np.full(6, 0.1), 0.25], - "right": np.r_[np.full(5, -0.1), -2 * np.pi + 0.04, 0.75], - } - controller = GelloTeleopControl( - robot, - frequency=60, - alignment="absolute", - max_joint_velocity=10000, - max_alignment_velocity=10000, - ) - command = controller.step( - GelloSample(joints=leaders, timestamps={"left": 0, "right": 0}), - robot.get_obs(), - toggle_arms=("left", "right"), - now=0, - ) - for arm, offset in (("left", 0), ("right", 7)): - np.testing.assert_allclose( - command.joints[offset : offset + 6], - robot.home[arm][:6] + leaders[arm][:6], - ) - assert command.joints[offset + 6] == leaders[arm][6] - # An erroneous full turn must remain visible in absolute calibration checks. - assert command.joints[12] < -6 - - -def test_installed_yam_model_when_optional_sdk_is_available(): - pytest.importorskip("i2rt", reason="Requires the optional Yam SDK model assets") - robot = VirtualYamFollower(default_model_path()) - assert robot.get_obs()["joint_positions"].shape == (14,) diff --git a/tests/test_visual_core.py b/tests/test_visual_core.py deleted file mode 100644 index 49653de478..0000000000 --- a/tests/test_visual_core.py +++ /dev/null @@ -1,64 +0,0 @@ -import torch -import torch.nn as nn - -from egomimic.models.stems import SpatialSoftmax, VisualCore - - -def test_spatial_softmax_returns_bounded_keypoints(): - pool = SpatialSoftmax(in_channels=3, in_h=4, in_w=5, num_kp=2) - output = pool(torch.randn(6, 3, 4, 5)) - - assert output.shape == (6, 2, 2) - assert torch.isfinite(output).all() - assert torch.all(output.abs() <= 1.0) - - -def test_visual_core_preserves_leading_shape_and_group_norm_contract(): - encoder = VisualCore( - image_size=40, - feature_dimension=12, - num_kp=4, - pretrained=False, - crop_aug=True, - crop_height=32, - crop_width=32, - crop_eval_mode="center", - norm_layer="group", - ).eval() - - with torch.inference_mode(): - output = encoder(torch.randn(2, 3, 3, 40, 40)) - - assert output.shape == (2, 3, 12) - assert torch.isfinite(output).all() - assert not any(isinstance(module, nn.BatchNorm2d) for module in encoder.modules()) - - -def test_libero_native_half_resnet_architecture_and_parameter_count(): - encoder = VisualCore( - image_size=96, - feature_dimension=32, - num_kp=32, - pretrained=False, - crop_aug=True, - crop_height=84, - crop_width=84, - crop_eval_mode="center", - crop_sample_mode="v02", - crop_scope="frame", - norm_layer="group", - pool_type="spatial_softmax", - resnet_model="resnet18_half", - ).eval() - assert encoder.backbone[0].out_channels == 32 - assert [encoder.backbone[i][-1].conv2.out_channels for i in (4, 5, 6, 7)] == [ - 32, - 64, - 128, - 256, - ] - assert sum(p.numel() for p in encoder.parameters()) == 2809184 - assert not any(isinstance(m, nn.BatchNorm2d) for m in encoder.modules()) - with torch.inference_mode(): - result = encoder(torch.zeros(1, 3, 96, 96)) - assert result.shape == (1, 32) and torch.isfinite(result).all() diff --git a/tests/test_wandb_health.py b/tests/test_wandb_health.py deleted file mode 100644 index 4c1dc3b70c..0000000000 --- a/tests/test_wandb_health.py +++ /dev/null @@ -1,120 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - -PATH = Path(__file__).parents[1] / "scripts/train/check_wandb_health.py" -SPEC = importlib.util.spec_from_file_location("check_wandb_health", PATH) -health = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(health) - - -class FakeRun: - path = ["team", "project", "run"] - url = "https://wandb.ai/team/project/runs/run" - state = "running" - lastHistoryStep = 20545 - config = {"run_provenance": {"source_commit": "exact-source"}} - - def __init__(self, rows, summary=None): - self.rows = rows - self.summary = summary or {} - self.scan_arguments = None - - def scan_history(self, **kwargs): - self.scan_arguments = kwargs - assert kwargs["keys"] is None # Sparse keys must never be intersected. - return iter(self.rows) - - -class FakeApi: - def __init__(self, run): - self.run_object = run - - def run(self, path): - assert path == "team/project/run" - return self.run_object - - -def inspect(run, **kwargs): - return health.collect_health(FakeApi(run), "team/project/run", **kwargs) - - -def test_sparse_latest_metrics_and_optimizer_axis_use_bounded_history(): - run = FakeRun( - [ - {"_step": 20544, "trainer/global_step": 200000, "Train/loss": 0.2}, - {"_step": 20545, "trainer/global_step": 200010, "Schedule/flow": 0.01}, - ] - ) - result = inspect( - run, - metrics=["Train/loss", "Schedule/flow"], - window=20, - expected_config={"run_provenance.source_commit": "exact-source"}, - ) - assert result["status"] == "PASS" - assert result["optimizer_step"] == 200010 - assert result["wandb_history_step"] == 20545 - assert result["metrics"]["Train/loss"]["optimizer_step"] == 200000 - assert run.scan_arguments == { - "keys": None, - "min_step": 20526, - "max_step": 20546, - "page_size": 20, - } - - -def test_latest_nan_is_not_hidden_by_an_earlier_finite_value(): - run = FakeRun( - [ - {"_step": 20544, "trainer/global_step": 20, "loss": 0.1}, - {"_step": 20545, "trainer/global_step": 30, "loss": float("nan")}, - ] - ) - result = inspect(run, metrics=["loss"]) - assert result["status"] == "ERROR" - assert result["metrics"]["loss"]["finite"] is False - - -def test_summary_only_metric_is_reported_without_inventing_a_step(): - run = FakeRun([{"_step": 20545, "trainer/global_step": 20}], {"Valid/loss": 0.3}) - result = inspect(run, metrics=["Valid/loss"]) - assert result["status"] == "WARN" - assert result["metrics"]["Valid/loss"]["source"] == "summary_only" - assert result["metrics"]["Valid/loss"]["optimizer_step"] is None - - -def test_missing_and_wrong_config_identity_fail_closed(): - run = FakeRun([{"trainer/global_step": 20}]) - result = inspect( - run, expected_config={"run_provenance.source_commit": "wrong", "missing": 1} - ) - assert result["status"] == "ERROR" - assert len(result["errors"]) == 2 - - -def test_history_counter_is_never_substituted_for_missing_optimizer_step(): - result = inspect(FakeRun([{"_step": 20545, "loss": 0.1}]), metrics=["loss"]) - assert result["status"] == "INCOMPLETE" - assert result["optimizer_step"] is None - - -def test_recent_resume_regression_and_stale_metrics_are_visible(): - run = FakeRun( - [ - {"_step": 20544, "trainer/global_step": 200000, "_timestamp": 10}, - {"_step": 20545, "trainer/global_step": 160000, "_timestamp": 20}, - ] - ) - result = inspect(run, max_age_seconds=30, now=100) - assert result["status"] == "WARN" - assert result["optimizer_step_regressions"] == [ - {"from": 200000, "to": 160000, "wandb_step": 20545} - ] - assert result["latest_history_age_seconds"] == 80 - - -def test_no_unbounded_history_request(): - with pytest.raises(ValueError, match="window"): - inspect(FakeRun([]), window=1000000) diff --git a/tests/test_wrist6d_roundtrip.py b/tests/test_wrist6d_roundtrip.py deleted file mode 100644 index 3c8298dd82..0000000000 --- a/tests/test_wrist6d_roundtrip.py +++ /dev/null @@ -1,523 +0,0 @@ -"""Independent end-to-end checks of the wrist-frame 6D pipeline. - -The round-trip tests in ``test_pi05_norm_rot6d.py`` compare the pipeline -against itself (``_rot6d_to_ypr(_ypr_to_rot6d(x)) == x``), which a -consistently-wrong convention would pass, and the transform-list tests there -only assert which Transform classes are present. These tests instead run the -REAL transform lists on synthetic world-frame poses and compare every stage -against plain numpy/scipy SE(3) math that shares no code with the pipeline: - - raw world-frame poses - -> Human/Eva.get_transform_list("cartesian", coord_frame="eef_frame", rotation_mode="6D") (data) - -> quantile normalize (the dataset formula) - -> to32_norm_6d / from32_norm_6d (model I/O) - -> unnormalize - -> _build_*_cartesian_revert_6d_wristframe_transform_list (evaluator) - -> head/cam-frame xyz+ypr == independent inv(T_head) @ T_action -""" - -import json - -import numpy as np -import pytest -import torch -from scipy.spatial.transform import Rotation as R - -from egomimic.rldb.embodiment.embodiment import Embodiment -from egomimic.rldb.zarr.action_chunk_transforms import ( - CartesianYPRToRot6D, - SplitKeys, -) -from egomimic.utils.action_encoding import ( - HumanBimanualCartesianEuler, - RobotBimanualCartesianEuler, - _apply_norm_one, - _apply_unnorm_one, - _matrix_to_ypr, - _reconstruct_R_from_cols, - _ypr_to_matrix, -) -from egomimic.utils.pose_utils import _rot6d_to_ypr, _ypr_to_rot6d - -T = 100 - - -# ---------------------------------------------------------------- helpers -def _rng(seed): - return np.random.default_rng(seed) - - -def _rand_pose(rng, scale=1.0): - q = R.random(random_state=int(rng.integers(1 << 31))).as_quat() # xyzw - return np.concatenate([rng.uniform(-scale, scale, 3), q[[3, 0, 1, 2]]]) - - -def _rand_chunk(rng, start): - """Smooth random walk of xyz+quat(wxyz) poses starting AT ``start``.""" - out = np.zeros((T, 7)) - p = start[:3].copy() - r = R.from_quat(start[[4, 5, 6, 3]]) - for t in range(T): - if t > 0: - p = p + rng.normal(0, 0.01, 3) - r = R.from_rotvec(rng.normal(0, 0.05, 3)) * r - q = r.as_quat() - out[t] = np.concatenate([p, q[[3, 0, 1, 2]]]) - return out - - -def _T(p7): - M = np.eye(4) - M[:3, :3] = R.from_quat(p7[[4, 5, 6, 3]]).as_matrix() - M[:3, 3] = p7[:3] - return M - - -def _T_chunk(c): - return np.stack([_T(row) for row in c]) - - -def _xyzypr(M): - return np.concatenate( - [M[..., :3, 3], R.from_matrix(M[..., :3, :3]).as_euler("ZYX")], axis=-1 - ) - - -def _R_of_ypr(ypr): - return R.from_euler("ZYX", ypr).as_matrix() - - -def _apply(transform_list, sample): - s = {k: (v.copy() if isinstance(v, np.ndarray) else v) for k, v in sample.items()} - for t in transform_list: - s = t.transform(s) - return s - - -def _assert_pose12_close(got, ref, atol): - """Compare (..., 12) xyz+ypr vectors: xyz directly, rotation via matrices - (immune to ±π wrap and gimbal ambiguity).""" - got = np.asarray(got, dtype=np.float64) - ref = np.asarray(ref, dtype=np.float64) - for off in (0, 6): - np.testing.assert_allclose( - got[..., off : off + 3], ref[..., off : off + 3], atol=atol - ) - Rg = _R_of_ypr(got[..., off + 3 : off + 6].reshape(-1, 3)) - Rr = _R_of_ypr(ref[..., off + 3 : off + 6].reshape(-1, 3)) - np.testing.assert_allclose(Rg, Rr, atol=atol) - - -def _quantile_stats(x, width): - flat = x.reshape(-1, width) - return { - "quantile_1": np.percentile(flat, 1, axis=0).astype(np.float32), - "quantile_99": np.percentile(flat, 99, axis=0).astype(np.float32), - } - - -def _wrist6d_ref(obs_pose12, act_pose12, side): - """Independent wrist-frame 6D block (T, 9) for one arm from head-frame - xyz+ypr proprio (12,) and actions (T, 12).""" - o = 6 * side - To = np.eye(4) - To[:3, :3] = _R_of_ypr(obs_pose12[o + 3 : o + 6]) - To[:3, 3] = obs_pose12[o : o + 3] - Ta = np.stack([np.eye(4)] * act_pose12.shape[0]) - Ta[:, :3, :3] = _R_of_ypr(act_pose12[:, o + 3 : o + 6]) - Ta[:, :3, 3] = act_pose12[:, o : o + 3] - Tw = np.linalg.inv(To)[None] @ Ta - return np.concatenate([Tw[:, :3, 3], Tw[:, :3, 0], Tw[:, :3, 1]], axis=-1) - - -# ------------------------------------------------ convention cross-checks -def test_torch_ypr_matrix_matches_scipy_zyx(): - """The torch packers and the numpy transforms must agree on what 'ypr' - means: intrinsic Z-Y-X (yaw about z, then pitch about the new y, then - roll about the new x), radians.""" - ypr = _rng(0).uniform(-3.0, 3.0, size=(64, 3)) - R_torch = _ypr_to_matrix(torch.from_numpy(ypr)).numpy() - R_scipy = R.from_euler("ZYX", ypr).as_matrix() - np.testing.assert_allclose(R_torch, R_scipy, atol=1e-12) - # ...and _matrix_to_ypr inverts it on the principal branch. - back = _matrix_to_ypr(torch.from_numpy(R_scipy)).numpy() - np.testing.assert_allclose( - R.from_euler("ZYX", back).as_matrix(), R_scipy, atol=1e-12 - ) - # numpy 6D helpers use the same columns as the torch packers. - six = _ypr_to_rot6d(ypr) - np.testing.assert_allclose(six[:, :3], R_scipy[:, :, 0], atol=1e-12) - np.testing.assert_allclose(six[:, 3:], R_scipy[:, :, 1], atol=1e-12) - np.testing.assert_allclose( - R.from_euler("ZYX", _rot6d_to_ypr(six)).as_matrix(), R_scipy, atol=1e-12 - ) - - -def test_gram_schmidt_matches_independent_and_is_proper(): - rng = _rng(1) - c1 = rng.normal(size=(32, 3)) - c2 = rng.normal(size=(32, 3)) - Rt = _reconstruct_R_from_cols(torch.from_numpy(c1), torch.from_numpy(c2)).numpy() - # independent GS - a = c1 / np.linalg.norm(c1, axis=-1, keepdims=True) - b = c2 - (c2 * a).sum(-1, keepdims=True) * a - b = b / np.linalg.norm(b, axis=-1, keepdims=True) - Rn = np.stack([a, b, np.cross(a, b)], axis=-1) - np.testing.assert_allclose(Rt, Rn, atol=1e-12) - np.testing.assert_allclose(np.linalg.det(Rt), 1.0, atol=1e-12) - np.testing.assert_allclose( - Rt @ np.transpose(Rt, (0, 2, 1)), - np.broadcast_to(np.eye(3), Rt.shape), - atol=1e-12, - ) - - -# ------------------------------------------------------------ human path -@pytest.mark.parametrize("fix_left", [False, True]) -def test_human_wristframe_6d_pipeline_round_trips_to_headframe(fix_left): - from egomimic.rldb.embodiment.human import ( - Human, - _build_human_cartesian_revert_6d_wristframe_transform_list, - ) - - rng = _rng(2 + int(fix_left)) - B = 3 - raws = [] - for _ in range(B): - head, lobs, robs = _rand_pose(rng), _rand_pose(rng), _rand_pose(rng) - raws.append( - { - "obs_head_pose": head, - "left.obs_ee_pose": lobs, - "right.obs_ee_pose": robs, - "left.action_ee_pose": _rand_chunk(rng, lobs), - "right.action_ee_pose": _rand_chunk(rng, robs), - } - ) - fwd = Human.get_transform_list( - "cartesian", - coord_frame="eef_frame", - rotation_mode="6D", - stride=1, - local_frame_rotations=( - {"left.action_ee_pose": [0, 0, 0, 1], "left.obs_ee_pose": [0, 0, 0, 1]} - if fix_left - else None - ), - pad_proprio_gripper=True, - ) - outs = [_apply(fwd, r) for r in raws] - act6 = np.stack([o["actions_cartesian"] for o in outs]) - obs6 = np.stack([o["observations.state.ee_pose"] for o in outs]) - assert act6.shape == (B, T, 18) and obs6.shape == (B, 20) - assert set(outs[0]) == {"actions_cartesian", "observations.state.ee_pose"} - assert np.all(obs6[:, [9, 19]] == 0.0) - - # Independent head-frame ground truth. The Rz(180°) fix relabels the - # LEFT hand's local axes (right-multiply), before any frame math. - Rfix = np.eye(4) - Rfix[:3, :3] = R.from_euler("z", np.pi).as_matrix() - gt_act = np.zeros((B, T, 12)) - gt_obs = np.zeros((B, 12)) - for b, r in enumerate(raws): - Th = _T(r["obs_head_pose"]) - for si, side in enumerate(("left", "right")): - Ta = _T_chunk(r[f"{side}.action_ee_pose"]) - To = _T(r[f"{side}.obs_ee_pose"]) - if fix_left and side == "left": - Ta = Ta @ Rfix - To = To @ Rfix - gt_act[b, :, 6 * si : 6 * si + 6] = _xyzypr(np.linalg.inv(Th)[None] @ Ta) - gt_obs[b, 6 * si : 6 * si + 6] = _xyzypr(np.linalg.inv(Th) @ To) - - # (1) proprio = head-frame obs pose, 6D-encoded, grip slots zero - obs_ref = np.stack( - [CartesianYPRToRot6D(action_key="k").transform({"k": g})["k"] for g in gt_obs] - ) - keep = [i for i in range(20) if i not in (9, 19)] - np.testing.assert_allclose(obs6[:, keep], obs_ref, atol=1e-9) - - # (2) actions = each arm's pose in that arm's obs-wrist frame, 6D-encoded - for b in range(B): - for si in range(2): - np.testing.assert_allclose( - act6[b, :, 9 * si : 9 * si + 9], - _wrist6d_ref(gt_obs[b], gt_act[b], si), - atol=1e-9, - ) - # t = 0 is the identity pose exactly (reference IS the obs pose) — the - # bounds-check tolerance in MultiDataset._check_bounds relies on this. - np.testing.assert_allclose(act6[:, 0, [0, 1, 2, 9, 10, 11]], 0.0, atol=1e-12) - - # (3) normalize -> 32D pack -> unpack -> unnormalize is exact - st_act = _quantile_stats(act6, 18) - st_obs = _quantile_stats(obs6, 20) - a_t, o_t = torch.from_numpy(act6).float(), torch.from_numpy(obs6).float() - a_n = _apply_norm_one(a_t, st_act, "quantile") - o_n = _apply_norm_one(o_t, st_obs, "quantile") - conv = HumanBimanualCartesianEuler() - a32 = conv.to32_norm_6d(a_n) - assert a32.shape == (B, T, 32) - assert torch.all(a32[..., [9, 19]] == 0) and torch.all(a32[..., 20:] == 0) - torch.testing.assert_close(conv.from32_norm_6d(a32), a_n, atol=0, rtol=0) - a_un = _apply_unnorm_one(conv.from32_norm_6d(a32), st_act, "quantile") - o_un = _apply_unnorm_one(o_n, st_obs, "quantile") - torch.testing.assert_close(a_un, a_t, atol=1e-5, rtol=0) - torch.testing.assert_close(o_un, o_t, atol=1e-5, rtol=0) - - # (4) evaluator revert (batched, like eval_pi) lands back in head frame - rev = _build_human_cartesian_revert_6d_wristframe_transform_list() - out = Embodiment.apply_transform( - {"actions_cartesian": a_un, "observations.state.ee_pose": o_un}, rev - ) - assert np.asarray(out["actions_cartesian"]).shape == (B, T, 12) - assert np.asarray(out["observations.state.ee_pose"]).shape == (B, 12) - _assert_pose12_close(out["actions_cartesian"], gt_act, atol=1e-4) - _assert_pose12_close(out["observations.state.ee_pose"], gt_obs, atol=1e-4) - - # (5) a noisy (non-orthonormal) prediction still reverts to finite poses - a_noisy = _apply_unnorm_one(a_n + 0.05 * torch.randn_like(a_n), st_act, "quantile") - out_n = Embodiment.apply_transform( - {"actions_cartesian": a_noisy, "observations.state.ee_pose": o_un}, rev - ) - assert np.isfinite(np.asarray(out_n["actions_cartesian"])).all() - - -def test_human_wristframe_actions_are_headframe_invariant(): - """Wrist-relative targets must not depend on the head pose at all.""" - from egomimic.rldb.embodiment.human import Human - - rng = _rng(7) - lobs, robs = _rand_pose(rng), _rand_pose(rng) - raw = { - "left.obs_ee_pose": lobs, - "right.obs_ee_pose": robs, - "left.action_ee_pose": _rand_chunk(rng, lobs), - "right.action_ee_pose": _rand_chunk(rng, robs), - } - fwd = Human.get_transform_list( - "cartesian", coord_frame="eef_frame", rotation_mode="6D", stride=1 - ) - a1 = _apply(fwd, {**raw, "obs_head_pose": _rand_pose(rng)})["actions_cartesian"] - a2 = _apply(fwd, {**raw, "obs_head_pose": _rand_pose(rng)})["actions_cartesian"] - np.testing.assert_allclose(a1, a2, atol=1e-12) - - -# -------------------------------------------------------------- eva path -def _eva_extrinsics_variants(): - from pathlib import Path - - import yaml - - return [ - pytest.param(p.stem, yaml.safe_load(p.read_text())["extrinsics"], id=p.stem) - for p in sorted( - (Path(__file__).parents[1] / "egomimic/hydra_configs/calibration").glob( - "*.yaml" - ) - ) - ] - - -@pytest.mark.parametrize("extrinsics_key,extrinsics", _eva_extrinsics_variants()) -def test_eva_wristframe_6d_pipeline_round_trips_to_camframe(extrinsics_key, extrinsics): - from egomimic.rldb.embodiment.eva import ( - Eva, - _build_eva_cartesian_revert_6d_wristframe_transform_list, - ) - - rng = _rng(11) - B = 3 - raws = [] - for _ in range(B): - lobs, robs = _rand_pose(rng), _rand_pose(rng) - raws.append( - { - "left.obs_ee_pose": lobs, - "right.obs_ee_pose": robs, - "left.cmd_ee_pose": _rand_chunk(rng, lobs), - "right.cmd_ee_pose": _rand_chunk(rng, robs), - "left.obs_gripper": rng.uniform(0, 1, (1,)), - "right.obs_gripper": rng.uniform(0, 1, (1,)), - "left.cmd_gripper": rng.uniform(0, 1, (T, 1)), - "right.cmd_gripper": rng.uniform(0, 1, (T, 1)), - } - ) - kwargs = {"extrinsics": extrinsics} - fwd = Eva.get_transform_list( - "cartesian", coord_frame="eef_frame", rotation_mode="6D", **kwargs - ) - outs = [_apply(fwd, r) for r in raws] - act6 = np.stack([o["actions_cartesian"] for o in outs]) - obs6 = np.stack([o["observations.state.ee_pose"] for o in outs]) - assert act6.shape == (B, T, 20) and obs6.shape == (B, 20) - - # independent cam-frame GT: T_cam = inv(E_side) @ T_base - gt_act = np.zeros((B, T, 14)) - gt_obs = np.zeros((B, 14)) - for b, r in enumerate(raws): - for si, side in enumerate(("left", "right")): - Einv = np.linalg.inv(np.asarray(extrinsics[side])) - gt_act[b, :, 7 * si : 7 * si + 6] = _xyzypr( - Einv[None] @ _T_chunk(r[f"{side}.cmd_ee_pose"]) - ) - gt_act[b, :, 7 * si + 6] = r[f"{side}.cmd_gripper"][:, 0] - gt_obs[b, 7 * si : 7 * si + 6] = _xyzypr( - Einv @ _T(r[f"{side}.obs_ee_pose"]) - ) - gt_obs[b, 7 * si + 6] = r[f"{side}.obs_gripper"][0] - - # the x5Dec13_2 rig calibration is ~6e-9 off orthonormal, hence 1e-7 - obs_ref = np.stack( - [CartesianYPRToRot6D(action_key="k").transform({"k": g})["k"] for g in gt_obs] - ) - np.testing.assert_allclose(obs6, obs_ref, atol=1e-7) - pose_idx = [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12] - for b in range(B): - for si in range(2): - ref = _wrist6d_ref(gt_obs[b][pose_idx], gt_act[b][:, pose_idx], si) - got = act6[b, :, 10 * si : 10 * si + 10] - np.testing.assert_allclose(got[:, :9], ref, atol=1e-7) - np.testing.assert_allclose(got[:, 9], gt_act[b, :, 7 * si + 6], atol=1e-12) - - st_act, st_obs = _quantile_stats(act6, 20), _quantile_stats(obs6, 20) - a_t, o_t = torch.from_numpy(act6).float(), torch.from_numpy(obs6).float() - a_n = _apply_norm_one(a_t, st_act, "quantile") - o_n = _apply_norm_one(o_t, st_obs, "quantile") - conv = RobotBimanualCartesianEuler() - a32 = conv.to32_norm_6d(a_n) - torch.testing.assert_close(conv.from32_norm_6d(a32), a_n, atol=0, rtol=0) - a_un = _apply_unnorm_one(conv.from32_norm_6d(a32), st_act, "quantile") - o_un = _apply_unnorm_one(o_n, st_obs, "quantile") - - rev = _build_eva_cartesian_revert_6d_wristframe_transform_list() - out = Embodiment.apply_transform( - {"actions_cartesian": a_un, "observations.state.ee_pose": o_un}, rev - ) - ra = np.asarray(out["actions_cartesian"]) - ro = np.asarray(out["observations.state.ee_pose"]) - assert ra.shape == (B, T, 14) and ro.shape == (B, 14) - _assert_pose12_close(ra[..., pose_idx], gt_act[..., pose_idx], atol=1e-4) - np.testing.assert_allclose(ra[..., [6, 13]], gt_act[..., [6, 13]], atol=1e-5) - _assert_pose12_close(ro[..., pose_idx], gt_obs[..., pose_idx], atol=1e-4) - - -# ------------------------------------------------------ silent-failure guards -def test_split_keys_rejects_width_mismatch(): - """A ypr revert list fed a 6D batch used to slice 'xyz + col0' as - 'xyz + ypr' without complaint; now it must fail loudly.""" - sk = SplitKeys(input_key="k", output_key_list=[("a", 6), ("b", 6)]) - ok = sk.transform({"k": np.zeros((4, 12))}) - assert ok["a"].shape == (4, 6) and ok["b"].shape == (4, 6) - with pytest.raises(ValueError, match="last dim 18"): - sk.transform({"k": np.zeros((4, 18))}) - with pytest.raises(ValueError, match="sums to 12"): - sk.transform({"k": torch.zeros(4, 20)}) - - -def test_ypr_revert_on_6d_batch_fails_loudly(): - """The evaluator/data-config mismatch the guard is for: eval_pi.yaml's - ypr revert applied to a cartesian_wristframe_6d batch.""" - from egomimic.rldb.embodiment.human import ( - _build_human_cartesian_revert_eef_frame_transform_list, - ) - - rev = _build_human_cartesian_revert_eef_frame_transform_list(is_quat=False) - batch = { - "actions_cartesian": torch.zeros(2, T, 18), - "observations.state.ee_pose": torch.zeros(2, 20), - } - with pytest.raises(ValueError, match="SplitKeys"): - Embodiment.apply_transform(batch, rev) - - -def _bounds_dataset(key, width, q_low, q_high): - from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset as MultiDataset - - md = MultiDataset.__new__(MultiDataset) - md.norm_stats = { - 0: { - key: { - "quantile_1": np.full(width, q_low, dtype=np.float32), - "quantile_99": np.full(width, q_high, dtype=np.float32), - } - } - } - md.zarr_keys = {0: {key: key}} - md._warned_violations = set() - return md - - -def test_bounds_check_tolerates_roundoff_on_collapsed_bounds(): - """Wrist-frame t=0 cells have bounds [0, 0]; roundoff must not reject.""" - md = _bounds_dataset("actions_cartesian", 18, 0.0, 0.0) - arr = np.zeros((5, 18), dtype=np.float32) - arr[0, 0] = 1e-9 # a roundoff-scale xyz value at a [0, 0] bound - assert ( - md._check_bounds({"embodiment": 0, "actions_cartesian": arr}, None, 0, "ep") - is None - ) - arr[0, 0] = 1e-3 # a real violation is still caught - assert ( - md._check_bounds({"embodiment": 0, "actions_cartesian": arr}, None, 0, "ep") - is not None - ) - - -def test_bounds_check_warns_once_on_stat_shape_mismatch(caplog): - md = _bounds_dataset("actions_cartesian", 18, -1.0, 1.0) - arr = np.zeros((5, 20), dtype=np.float32) # stats are 18-wide - with caplog.at_level("WARNING"): - assert ( - md._check_bounds({"embodiment": 0, "actions_cartesian": arr}, None, 0, "ep") - is None - ) - assert ( - md._check_bounds({"embodiment": 0, "actions_cartesian": arr}, None, 1, "ep") - is None - ) - msgs = [r.message for r in caplog.records if "bounds check skipped" in r.message] - assert len(msgs) == 1, msgs - - -def test_precomputed_norm_stats_provenance_is_checked(tmp_path): - from egomimic.rldb.zarr.zarr_dataset_multi import MultiDataset as MultiDataset - - writer = MultiDataset(state={}, norm_mode="quantile") - writer.norm_mode = "quantile" - writer._norm_run_metadata = None - writer.norm_stats = { - 1: { - "actions_cartesian": { - "quantile_1": np.zeros((T, 18)), - "quantile_99": np.ones((T, 18)), - }, - "observations.state.ee_pose": { - "quantile_1": np.zeros(20), - "quantile_99": np.ones(20), - }, - } - } - writer.cache_stats(str(tmp_path)) - path = tmp_path / "norm_stats" / "norm_stats.json" - payload = json.loads(path.read_text()) - assert payload["provenance"]["norm_mode"] == "quantile" - assert payload["provenance"]["stat_shapes"]["1"]["actions_cartesian"] == [T, 18] - - def reader(norm_mode): - md = MultiDataset.__new__(MultiDataset) - md.norm_mode = norm_mode - md.norm_stats = {1: {}} - md._norm_run_metadata = None - return md - - keys = ["actions_cartesian", "observations.state.ee_pose"] - good = reader("quantile") - good._load_precomputed_stats(str(path), 1, keys) - assert set(good.norm_stats[1]) == set(keys) - with pytest.raises(ValueError, match="norm_mode='zscore'"): - reader("zscore")._load_precomputed_stats(str(path), 1, keys) - with pytest.raises(ValueError, match="different keymap/transform mode"): - reader("quantile")._load_precomputed_stats(str(path), 1, ["actions_cartesian"]) - with pytest.raises(ValueError, match="no entry for embodiment id 2"): - reader("quantile")._load_precomputed_stats(str(path), 2, keys) diff --git a/tests/test_yam_action_flow_eval.py b/tests/test_yam_action_flow_eval.py deleted file mode 100644 index c4c8517211..0000000000 --- a/tests/test_yam_action_flow_eval.py +++ /dev/null @@ -1,95 +0,0 @@ -import math - -import pytest -import torch - -from egomimic.eval.yam_action_flow_eval import ( - YAM_ENERGY_DISTANCE_METADATA, - YAM_NATIVE_ERROR_CONTRACT, - YamCartesianActionFlowEval, - yam_native_residual, -) - - -class IdentityNormalizer: - def unnormalize(self, values, embodiment_id): - assert embodiment_id == 7 - assert set(values) == {"actions_cartesian"} - return values - - -def evaluator_for_synthetic_chunk(): - evaluator = object.__new__(YamCartesianActionFlowEval) - evaluator.normalizer = IdentityNormalizer() - evaluator.action_key = "actions_cartesian" - evaluator.action_keys_by_embodiment = {} - return evaluator - - -def test_yam_evaluator_rejects_other_action_interfaces_and_records_distance(): - evaluator = YamCartesianActionFlowEval(energy_score_enabled=False) - assert evaluator.action_key == "actions_cartesian" - assert evaluator.energy_score_distance_metadata == YAM_ENERGY_DISTANCE_METADATA - with pytest.raises(ValueError, match="actions_cartesian"): - YamCartesianActionFlowEval(energy_score_enabled=False, action_key="actions") - with pytest.raises(ValueError, match="private decoder"): - YamCartesianActionFlowEval(energy_score_enabled=False, native_decoder=object()) - - -def test_yam_native_residual_wraps_both_arms_and_broadcasts(): - prediction = torch.zeros(32, 1, 2, 100, 14) - target = torch.zeros(1, 32, 2, 100, 14) - prediction[..., 3] = math.pi - 0.01 - target[..., 3] = -math.pi + 0.01 - prediction[..., 10] = -math.pi + 0.02 - target[..., 10] = math.pi - 0.02 - residual = yam_native_residual(prediction, target) - assert residual.shape == (32, 32, 2, 100, 14) - torch.testing.assert_close( - residual[..., 3], torch.full_like(residual[..., 3], -0.02), atol=1e-5, rtol=0 - ) - torch.testing.assert_close( - residual[..., 10], torch.full_like(residual[..., 10], 0.04), atol=1e-5, rtol=0 - ) - - -def test_yam_energy_score_requires_complete_32_by_100_by_14_chunks(): - evaluator = evaluator_for_synthetic_chunk() - target = torch.zeros(2, 100, 14) - samples = target.unsqueeze(0).expand(32, -1, -1, -1).clone() - values = evaluator._energy_values( - samples, target, embodiment_id=7, label="yam_bimanual" - ) - assert values["score"].item() == pytest.approx(0.0) - assert values["accuracy"].item() == pytest.approx(0.0) - assert values["diversity"].item() == pytest.approx(0.0) - - samples[..., 0] = 1.0 - values = evaluator._energy_values( - samples, target, embodiment_id=7, label="yam_bimanual" - ) - assert values["accuracy"].item() > 0 - assert values["diversity"].item() == pytest.approx(0.0) - with pytest.raises(ValueError, match="32"): - evaluator._energy_values(samples[:31], target, embodiment_id=7) - with pytest.raises(ValueError, match="complete"): - evaluator._energy_values(samples[..., :13], target, embodiment_id=7) - - -def test_yam_action_flow_diagnostic_native_error_wraps_after_unnormalize(): - evaluator = evaluator_for_synthetic_chunk() - evaluator._action_flow_diagnostics = type( - "Runner", - (), - {"native_error_enabled": True, "native_error": YAM_NATIVE_ERROR_CONTRACT}, - )() - functions = evaluator._action_flow_native_error_fns( - {"yam_bimanual": {"embodiment": torch.tensor([7])}} - ) - target = torch.zeros(1, 100, 14) - prediction = target.clone() - prediction[..., 3] = math.pi - 0.01 - target[..., 3] = -math.pi + 0.01 - value = functions["yam_bimanual"](prediction, target) - assert value.shape == (1,) - assert value.item() == pytest.approx((0.02**2) / 14, abs=1e-6) diff --git a/tests/test_yam_gpu_uuid_contract.py b/tests/test_yam_gpu_uuid_contract.py deleted file mode 100644 index be82ec9e1d..0000000000 --- a/tests/test_yam_gpu_uuid_contract.py +++ /dev/null @@ -1,67 +0,0 @@ -"""CPU-only contract checks; no CUDA or dataset initialization.""" - -import ast -import re -from pathlib import Path - -import pytest - - -def helper(): - path = Path(__file__).resolve().parents[1] / "scripts/ice/ice_gpu_probe.py" - tree = ast.parse(path.read_text()) - function = next( - n - for n in tree.body - if isinstance(n, ast.FunctionDef) and n.name == "nvml_gpu_uuid" - ) - namespace = {"re": re} - exec( - compile(ast.Module(body=[function], type_ignores=[]), str(path), "exec"), - namespace, - ) - return namespace["nvml_gpu_uuid"] - - -@pytest.mark.parametrize("prefix", ["", "GPU-"]) -def test_actual_cuda_uuid_is_normalized(prefix): - bare = "12345678-1234-1234-1234-123456789abc" - assert helper()(prefix + bare) == "GPU-" + bare - - -@pytest.mark.parametrize( - "value", - [ - "0", - "MIG-1234", - "GPU-", - None, - b"1234", - "GPU-12345678-1234-1234-1234-123456789abc; x", - ], -) -def test_physical_indices_or_unknown_uuid_rejected(value): - with pytest.raises(RuntimeError): - helper()(value) - - -@pytest.mark.parametrize("text", ["f8ae3ac1-0e5a-3e23-c619-d06dd44000a2", "invalid"]) -def test_torch_cuuuid_wrapper_keeps_strict_uuid_validation(text): - wrapper = type( - "_CUuuid", (), {"__module__": "torch._C", "__str__": lambda self: text} - ) - if text == "invalid": - with pytest.raises(RuntimeError): - helper()(wrapper()) - else: - assert helper()(wrapper()) == "GPU-" + text - - -def test_unrelated_stringifiable_object_is_rejected(): - wrapper = type( - "OtherUUID", - (), - {"__str__": lambda self: "f8ae3ac1-0e5a-3e23-c619-d06dd44000a2"}, - ) - with pytest.raises(RuntimeError): - helper()(wrapper()) diff --git a/tests/test_yam_human_keypoints_action_flow_eval.py b/tests/test_yam_human_keypoints_action_flow_eval.py deleted file mode 100644 index f3aa78de95..0000000000 --- a/tests/test_yam_human_keypoints_action_flow_eval.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Fixed YAM/human validation contract checks without a training allocation.""" - -import torch - -from egomimic.eval.yam_human_keypoints_action_flow_eval import ( - HUMAN_ACTION_DIM, - HUMAN_WRIST_ANGLE_INDICES, - YamHumanKeypointsActionFlowEval, - human_native_residual, -) - - -class _IdentityNormalizer: - def __init__(self): - self.keys = [] - - def unnormalize(self, batch, embodiment_id): - self.keys.append((int(embodiment_id), tuple(batch))) - return batch - - -def test_action_keys_are_private_and_fail_closed(): - evaluator = YamHumanKeypointsActionFlowEval(energy_score_enabled=False) - normalizer = _IdentityNormalizer() - evaluator.bind_data_context(normalizer=normalizer) - assert evaluator._action_key_for_id(7) == "actions_cartesian" - assert evaluator._action_key_for_id(3) == "actions_keypoints" - assert evaluator._native(torch.zeros(1, 100, 14), 7, None).shape == (1, 100, 14) - assert evaluator._native(torch.zeros(1, 100, 138), 3, None).shape == (1, 100, 138) - assert normalizer.keys == [(7, ("actions_cartesian",)), (3, ("actions_keypoints",))] - - -def test_human_native_wrist_angles_wrap_without_changing_keypoint_residual(): - target = torch.zeros(1, 100, HUMAN_ACTION_DIM) - prediction = target.clone() - prediction[..., list(HUMAN_WRIST_ANGLE_INDICES)] = 2 * torch.pi - 0.1 - prediction[..., 6] = 2.0 - residual = human_native_residual(prediction, target) - assert torch.allclose( - residual[..., list(HUMAN_WRIST_ANGLE_INDICES)], - torch.full((1, 100, 6), -0.1), - atol=1e-5, - ) - assert torch.all(residual[..., 6] == 2.0) - - -def test_energy_score_32_is_finite_for_both_native_action_layouts(): - evaluator = YamHumanKeypointsActionFlowEval(energy_score_enabled=False) - evaluator.bind_data_context(normalizer=_IdentityNormalizer()) - for label, embodiment_id, width in ( - ("yam_bimanual", 7, 14), - ("human_bimanual", 3, 138), - ): - target = torch.zeros(2, 100, width) - samples = ( - torch.linspace(-0.1, 0.1, 32) - .view(32, 1, 1, 1) - .expand(32, 2, 100, width) - .clone() - ) - values = evaluator._energy_values(samples, target, embodiment_id, label) - assert all(torch.isfinite(value).all() for value in values.values()) - assert values["score_by_condition"].shape == (2,) - - -def test_validation_groups_have_distinct_artifact_paths_and_diagnostic_budgets( - tmp_path, -): - class Diagnostic: - def __init__(self): - self.artifact_root = tmp_path / "diag" - self.reset_count = 0 - - def reset(self): - self.reset_count += 1 - - evaluator = YamHumanKeypointsActionFlowEval(energy_score_enabled=False) - diagnostic = Diagnostic() - evaluator._co_train_artifact_root = tmp_path / "energy" - evaluator._co_train_diagnostic_root = diagnostic.artifact_root - evaluator._action_flow_diagnostics = diagnostic - - evaluator.set_validation_group("yam") - yam_energy = evaluator.artifact_root - yam_diagnostic = diagnostic.artifact_root - assert (yam_energy, yam_diagnostic) == ( - tmp_path / "energy/yam", - tmp_path / "diag/yam", - ) - assert diagnostic.reset_count == 1 - evaluator.set_validation_group("yam") - assert diagnostic.reset_count == 1 - - evaluator.set_validation_group("human") - assert evaluator.artifact_root == tmp_path / "energy/human" - assert diagnostic.artifact_root == tmp_path / "diag/human" - assert evaluator.artifact_root != yam_energy - assert diagnostic.artifact_root != yam_diagnostic - assert diagnostic.reset_count == 2 diff --git a/tests/test_yam_human_launch_contract.py b/tests/test_yam_human_launch_contract.py deleted file mode 100644 index ef2fdca2ca..0000000000 --- a/tests/test_yam_human_launch_contract.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Regression tests for the frozen two-domain YAM+human launch boundary.""" - -import hashlib -import json - -import pytest - -from scripts.train.verify_yam_human_action_flow_launch import DOMAINS, split_identities - - -def _sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def _manifest(): - result = { - "split_seed": 42, - "valid_ratio": 0.01, - "train_frame_windows_total": 376264, - "proportional_train_window_probabilities": { - "yam": 249565 / 376264, - "human": 126699 / 376264, - }, - } - for domain, ( - _, - train_count, - valid_count, - train_windows, - valid_windows, - prefix, - ) in DOMAINS.items(): - rows = [] - summary = { - "episodes": train_count + valid_count, - "train_episodes": train_count, - "valid_episodes": valid_count, - "train_frame_windows": train_windows, - "valid_frame_windows": valid_windows, - } - for group, count, windows in ( - ("train", train_count, train_windows), - ("valid", valid_count, valid_windows), - ): - names = [f"{domain}-{group}-{index:03d}" for index in range(count)] - summary[f"{group}_ids_sha256"] = hashlib.sha256( - "".join(f"{name}\n" for name in names).encode() - ).hexdigest() - for index, name in enumerate(names): - rows.append( - { - "split": group, - "episode_hash": name, - "zarr_processed_path": f"{prefix}{name}.zarr", - "num_frames": windows - count + 1 if index == 0 else 1, - } - ) - result[domain] = {"summary": summary, "episodes": rows} - return result - - -def test_exact_proportional_split_accepts_all_episodes(tmp_path): - path = tmp_path / "split.json" - path.write_text(json.dumps(_manifest())) - ids = split_identities(path, _sha(path)) - assert set(ids) == {"yam_train", "yam_valid", "human_train", "human_valid"} - - -@pytest.mark.parametrize( - "mutation", ["duplicate_path", "balanced_sampling", "wrong_holdout"] -) -def test_split_rejects_science_contract_drift(tmp_path, mutation): - split = _manifest() - if mutation == "duplicate_path": - split["human"]["episodes"][1]["zarr_processed_path"] = split["human"][ - "episodes" - ][0]["zarr_processed_path"] - elif mutation == "balanced_sampling": - split["proportional_train_window_probabilities"] = {"yam": 0.5, "human": 0.5} - else: - split["human"]["episodes"][0]["split"] = "valid" - path = tmp_path / "split.json" - path.write_text(json.dumps(split)) - with pytest.raises(ValueError): - split_identities(path, _sha(path)) diff --git a/tests/test_yam_human_lightning_sampler_resume.py b/tests/test_yam_human_lightning_sampler_resume.py deleted file mode 100644 index 48dc70dada..0000000000 --- a/tests/test_yam_human_lightning_sampler_resume.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Checkpointed Lightning fit must replay and skip exact co-train batches.""" - -import lightning as L -import pytest -import torch -from torch.utils.data import Dataset - -from egomimic.pl_utils.pl_data_utils import ProportionalMultiDataModuleWrapper - - -class _IndexedDataset(Dataset): - def __init__(self, count: int): - self.count = count - - def __len__(self): - return self.count - - def __getitem__(self, index): - return {"index": torch.tensor(index, dtype=torch.float32)} - - -class _RecordingModel(L.LightningModule): - def __init__(self): - super().__init__() - self.scale = torch.nn.Parameter(torch.ones(())) - self.seen = [] - - def training_step(self, batch, batch_idx): - assert len(batch) == 1 - source, values = next(iter(batch.items())) - self.seen.append((source, tuple(int(x) for x in values["index"].tolist()))) - return self.scale * (values["index"].mean() + 1) - - def configure_optimizers(self): - return torch.optim.SGD(self.parameters(), lr=0.001) - - -def _data(num_workers: int): - return ProportionalMultiDataModuleWrapper( - train_datasets={ - "yam_bimanual": _IndexedDataset(17), - "human_bimanual": _IndexedDataset(11), - }, - valid_datasets={"valid": {"yam_bimanual": _IndexedDataset(2)}}, - train_dataloader_params={}, - valid_dataloader_params={}, - proportional_train_batch_size=4, - proportional_train_num_workers=num_workers, - proportional_train_seed=42, - ) - - -def _trainer(max_steps: int, root): - return L.Trainer( - accelerator="cpu", - devices=1, - max_steps=max_steps, - default_root_dir=root, - logger=False, - enable_checkpointing=False, - enable_progress_bar=False, - enable_model_summary=False, - limit_val_batches=0, - num_sanity_val_steps=0, - ) - - -@pytest.mark.parametrize("num_workers", [0, 2]) -def test_mid_epoch_full_state_resume_preserves_exact_source_order( - tmp_path, num_workers -): - uninterrupted = _RecordingModel() - _trainer(4, tmp_path / "reference").fit( - uninterrupted, datamodule=_data(num_workers) - ) - assert len(uninterrupted.seen) == 4 - - first = _RecordingModel() - first_trainer = _trainer(2, tmp_path / "first") - first_trainer.fit(first, datamodule=_data(num_workers)) - checkpoint = tmp_path / "mid_epoch.ckpt" - first_trainer.save_checkpoint(checkpoint) - assert first.seen == uninterrupted.seen[:2] - - resumed = _RecordingModel() - _trainer(4, tmp_path / "resumed").fit( - resumed, datamodule=_data(num_workers), ckpt_path=checkpoint - ) - assert resumed.seen == uninterrupted.seen[2:4] diff --git a/tests/test_yam_human_open_loop_eval.py b/tests/test_yam_human_open_loop_eval.py deleted file mode 100644 index d795da0a4b..0000000000 --- a/tests/test_yam_human_open_loop_eval.py +++ /dev/null @@ -1,108 +0,0 @@ -from types import SimpleNamespace - -import pytest -import torch - -from egomimic.eval.yam_human_open_loop_eval import YamHumanOpenLoopEval - - -class IdentityNormalizer: - def unnormalize(self, batch, embodiment_id): - return batch - - -def evaluator(): - value = YamHumanOpenLoopEval(limit_val_batches=1.0, energy_score_enabled=False) - value.bind_data_context(normalizer=IdentityNormalizer()) - value.on_validation_start() - value.set_validation_group("yam") - return value - - -def batch(width=14, embodiment=7): - action_key = "actions_cartesian" if width == 14 else "actions_keypoints" - return { - "source": { - "embodiment": torch.full((6,), embodiment), - action_key: torch.zeros(6, 100, width), - "episode_hash": ["episode-a"] * 6, - "frame_index": torch.arange(6), - "retiming_rate": torch.ones(6), - } - } - - -def test_reuses_predictions_and_scores_robot_episode_with_existing_implementation(): - value = evaluator() - value._collect_validation_predictions( - batch(), {"source": {"pred_action": torch.ones(6, 100, 14)}} - ) - result = value.on_validation_end() - assert result["execute_control_steps"] == 25 - assert result["executed_control_steps"] == 6 - assert result["per_group"]["yam"]["micro"]["mse"] == 1.0 - assert result["episodes"] == 1 - - -def test_human_resampled_clock_is_not_scored_as_robot_control_frequency(): - value = evaluator() - value.set_validation_group("human") - value._collect_validation_predictions( - batch(138, 3), {"source": {"pred_action": torch.ones(6, 100, 138)}} - ) - assert value.robot_open_loop._records == [] - - -@pytest.mark.parametrize("limit", [1, 8, None, 0.5]) -def test_rejects_truncated_evaluator_validation(limit): - with pytest.raises(ValueError, match="limit_val_batches"): - YamHumanOpenLoopEval(limit_val_batches=limit, energy_score_enabled=False) - - -def test_rejects_trainer_truncation_and_missing_episode_frames(): - value = evaluator() - value.trainer = SimpleNamespace(limit_val_batches=256) - with pytest.raises(ValueError, match="trainer.limit_val_batches"): - value.on_validation_start() - value.trainer = None - source = batch() - source["source"]["frame_index"] = torch.tensor([0, 1, 2, 4, 5, 6]) - value._collect_validation_predictions( - source, {"source": {"pred_action": torch.ones(6, 100, 14)}} - ) - with pytest.raises(RuntimeError, match="missing 1 frames"): - value.on_validation_end() - - -@pytest.mark.parametrize("rate", [0.2, 5.0]) -def test_nonidentity_robot_views_are_not_open_loop_records(rate): - value = evaluator() - source = batch() - source["source"]["retiming_rate"] = torch.full((6,), rate) - value._collect_validation_predictions( - source, {"source": {"pred_action": torch.ones(6, 100, 14)}} - ) - assert value.robot_open_loop._records == [] - - -def test_mixed_views_keep_prediction_and_episode_frame_metadata_aligned(): - value = evaluator() - source = batch() - source["source"]["retiming_rate"] = torch.tensor([5.0, 1.0, 2.5, 1.25, 1.0, 5.0]) - pred = torch.arange(6.0).reshape(6, 1, 1).expand(6, 100, 14) - value._collect_validation_predictions(source, {"source": {"pred_action": pred}}) - records = value.robot_open_loop._records - assert [row["frame"] for row in records] == [1, 4] - assert [float(row["prediction"].mean()) for row in records] == [1.0, 4.0] - assert [row["episode"] for row in records] == ["episode-a", "episode-a"] - - -@pytest.mark.parametrize("rate", [float("nan"), 0.0, -1.0]) -def test_invalid_retiming_metadata_still_fails(rate): - value = evaluator() - source = batch() - source["source"]["retiming_rate"] = torch.full((6,), rate) - with pytest.raises(ValueError, match="finite positive"): - value._collect_validation_predictions( - source, {"source": {"pred_action": torch.ones(6, 100, 14)}} - ) diff --git a/tests/test_yam_human_phase_composition.py b/tests/test_yam_human_phase_composition.py deleted file mode 100644 index fe31f6e395..0000000000 --- a/tests/test_yam_human_phase_composition.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Compose the maintained real argv for all phases before any corpus traversal. -Synthetic identities are schema regression fixtures, never launch receipts. -""" - -import argparse -import importlib.util -import os -import subprocess -from pathlib import Path - -import pytest -from hydra import compose, initialize_config_dir -from hydra.core.hydra_config import HydraConfig -from omegaconf import OmegaConf, open_dict - -from scripts.train.verify_yam_human_action_flow_launch import ( - aggregate_content, - split_identities, - verify_config, -) - -ROOT = Path(__file__).resolve().parents[1] -spec = importlib.util.spec_from_file_location( - "corpus_fixture", ROOT / "tests/test_yam_human_refreshed_corpus_contract.py" -) -module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(module) - - -@pytest.mark.parametrize("phase", ["norm", "smoke", "full"]) -def test_real_shared_argv_composes_and_verifies(tmp_path, monkeypatch, phase): - cp, sp, c, s, content = module.fixture(tmp_path) - ids = split_identities(sp, module.sha(sp), cp, module.sha(cp)) - aggregate = aggregate_content( - content["yam"], - module.sha(content["yam"]), - content["human"], - module.sha(content["human"]), - cp, - module.sha(cp), - ) - norm = tmp_path / "norm.json" - norm.write_text("{}") - env = { - "AF_YAM_RECIPE": "yam_human_keypoints", - "AF_YAM_PHASE": phase, - "AF_YAM_ACCUMULATION": "1", - "AF_YAM_CHECKPOINT_POLICY": "dit_half", - "AF_YAM_INFERENCE_METHOD": "euler", - "AF_YAM_DATA_ROOT": str(tmp_path / "yam"), - "AF_HUMAN_DATA_ROOT": str(tmp_path / "human"), - "AF_YAM_NORM_JSON": str(norm), - "AF_COTRAIN_NORM": str(norm), - "AF_YAM_NORM_CACHE_DIR": str(tmp_path / "norm-cache"), - "AF_YAM_RUN_DIR": str(tmp_path / phase), - "AF_YAM_SOURCE_COMMIT": "0" * 40, - "AF_YAM_NORM_SHA": module.sha(norm), - "AF_YAM_SPLIT_SHA": module.sha(sp), - "AF_YAM_WANDB_ENTITY": "schema_fixture", - "AF_YAM_WANDB_PROJECT": "schema_fixture", - "AF_YAM_WANDB_ID": "schema_fixture_" + phase, - "content_sha": aggregate, - } - for d in ("yam", "human"): - for group in ("train", "valid"): - env[d + "_" + group + "_count"] = str(s[d]["summary"][group + "_episodes"]) - env[d + "_" + group + "_windows"] = str( - s[d]["summary"][group + "_frame_windows"] - ) - env[d.upper() + "_" + group.upper() + "_IDS_SHA256"] = ids[d + "_" + group] - for k, v in env.items(): - monkeypatch.setenv(k, v) - command = r'source "$1"; yam_action_flow_arguments; printf "%s\0" "${overrides[@]}"' - assert chr(0) not in command, "shell executable argv must never embed a NUL" - argv = ( - subprocess.check_output( - [ - "bash", - "-c", - command, - "argv", - str(ROOT / "scripts/train/yam_action_flow_arguments.sh"), - ], - env={**os.environ, **env}, - ) - .decode() - .rstrip("\0") - .split("\0") - ) - with initialize_config_dir( - version_base=None, config_dir=str(ROOT / "egomimic/hydra_configs") - ): - cfg = compose( - config_name="train_zarr_cartesian", overrides=argv, return_hydra_config=True - ) - cfg.hydra.runtime.output_dir = str(tmp_path / phase) - HydraConfig.instance().set_config(cfg) - with open_dict(cfg): - del cfg["hydra"] - OmegaConf.resolve(cfg) - resolved = tmp_path / (phase + ".yaml") - OmegaConf.save(cfg, resolved) - args = argparse.Namespace( - phase=phase, - yam_root=tmp_path / "yam", - human_root=tmp_path / "human", - norm_json=norm, - norm_sha=module.sha(norm), - norm_cache_dir=tmp_path / "norm-cache", - source_commit="0" * 40, - split_sha=module.sha(sp), - wandb_entity="schema_fixture", - wandb_project="schema_fixture", - wandb_id="schema_fixture_" + phase, - run_dir=tmp_path / phase, - inference_method="euler", - checkpoint_policy="dit_half", - corpus_contract=cp, - corpus_contract_sha=module.sha(cp), - ) - verify_config(resolved, args, ids, aggregate) - assert cfg.model.pipeline.stages[6].num_inference_steps == 50 - for provenance in ("energy_score_provenance", "action_flow_diagnostics.provenance"): - path = "evaluator." + provenance + ".sampler" - OmegaConf.update(cfg, path, "dopri5") - OmegaConf.save(cfg, resolved) - with pytest.raises(ValueError): - verify_config(resolved, args, ids, aggregate) - OmegaConf.update(cfg, path, "euler") - OmegaConf.save(cfg, resolved) - assert not any("cuda" in value for value in argv) - if phase == "norm": - cfg.norm_stats.precomputed_norm_path = str(norm) - OmegaConf.save(cfg, resolved) - with pytest.raises(ValueError): - verify_config(resolved, args, ids, aggregate) diff --git a/tests/test_yam_human_refreshed_corpus_contract.py b/tests/test_yam_human_refreshed_corpus_contract.py deleted file mode 100644 index a050f05dff..0000000000 --- a/tests/test_yam_human_refreshed_corpus_contract.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Refreshed corpus must retain native deterministic splits and complete bytes.""" - -import hashlib -import json -import random - -import pytest - -from egomimic.rldb.zarr.episode_split import split_dataset_names -from scripts.train.verify_yam_human_action_flow_launch import ( - DOMAINS, - aggregate_content, - corpus_domains, - split_identities, -) - - -def sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def save(path, value): - path.write_text(json.dumps(value)) - return sha(path) - - -def fixture(tmp_path): - contract = { - "schema_version": 1, - "complete_training_corpus": True, - "split_seed": 42, - "valid_ratio": 0.01, - "domains": {}, - } - split = {"split_seed": 42, "valid_ratio": 0.01} - manifests = {} - total = 0 - for domain, count in (("yam", 378), ("human", 38)): - names = [f"{domain}-{i:04}" for i in range(count)] - train, valid = split_dataset_names(names, 0.01, 42) - prefix = DOMAINS[domain][-1] - rows = [ - { - "episode_hash": n, - "zarr_processed_path": f"{prefix}{n}.zarr", - "num_frames": 101 + i, - "split": "train" if n in train else "valid", - } - for i, n in enumerate(names) - ] - summary = { - "episodes": count, - "train_episodes": len(train), - "valid_episodes": len(valid), - } - for group, ns in (("train", train), ("valid", valid)): - summary[group + "_frame_windows"] = sum( - r["num_frames"] for r in rows if r["split"] == group - ) - summary[group + "_ids_sha256"] = hashlib.sha256( - "".join(n + "\n" for n in sorted(ns)).encode() - ).hexdigest() - total += summary["train_frame_windows"] - inv = sorted( - (r["episode_hash"], r["zarr_processed_path"], r["num_frames"]) for r in rows - ) - inv_sha = hashlib.sha256( - json.dumps(inv, separators=(",", ":")).encode() - ).hexdigest() - content = { - "complete_training_corpus": True, - "episode_count": count, - "object_count": count * 10, - "content_bytes": count * 1000, - "episode_inventory_sha256": inv_sha, - "aggregate_content_sha256": hashlib.sha256(domain.encode()).hexdigest(), - } - path = tmp_path / (domain + "-content.json") - manifest_sha = save(path, content) - manifests[domain] = path - contract["domains"][domain] = { - **summary, - "episode_inventory_sha256": inv_sha, - "complete_content_manifest_sha256": manifest_sha, - "aggregate_content_sha256": content["aggregate_content_sha256"], - "object_count": content["object_count"], - "content_bytes": content["content_bytes"], - } - split[domain] = {"summary": summary, "episodes": rows} - split["train_frame_windows_total"] = total - split["proportional_train_window_probabilities"] = { - d: split[d]["summary"]["train_frame_windows"] / total for d in DOMAINS - } - cp = tmp_path / "contract.json" - sp = tmp_path / "split.json" - save(cp, contract) - save(sp, split) - return cp, sp, contract, split, manifests - - -def test_refreshed_complete_corpus_and_deterministic_split(tmp_path): - cp, sp, c, s, content = fixture(tmp_path) - assert corpus_domains(cp, sha(cp))["yam"][1:3] == (375, 3) - assert corpus_domains(cp, sha(cp))["human"][1:3] == (37, 1) - assert set(split_identities(sp, sha(sp), cp, sha(cp))) == { - "yam_train", - "yam_valid", - "human_train", - "human_valid", - } - assert ( - len( - aggregate_content( - content["yam"], - sha(content["yam"]), - content["human"], - sha(content["human"]), - cp, - sha(cp), - ) - ) - == 64 - ) - - -@pytest.mark.parametrize( - "mutation", ["sparse", "ratio", "domain", "holdout", "bad_hash", "bool_count"] -) -def test_contract_rejects_bad_complete_identity(tmp_path, mutation): - cp, sp, c, s, _ = fixture(tmp_path) - if mutation == "sparse": - c["complete_training_corpus"] = False - elif mutation == "ratio": - c["valid_ratio"] = 0.02 - elif mutation == "domain": - del c["domains"]["human"] - elif mutation == "holdout": - c["domains"]["yam"]["valid_episodes"] = 2 - elif mutation == "bad_hash": - c["domains"]["yam"]["aggregate_content_sha256"] = "z" * 64 - elif mutation == "bool_count": - c["domains"]["yam"]["content_bytes"] = True - save(cp, c) - with pytest.raises(ValueError): - corpus_domains(cp, sha(cp)) - - -@pytest.mark.parametrize( - "mutation", - ["duplicate", "wrong_inventory", "balanced_sampling", "swapped_membership"], -) -def test_split_rejects_changed_corpus_or_native_membership(tmp_path, mutation): - cp, sp, c, s, _ = fixture(tmp_path) - if mutation == "duplicate": - s["yam"]["episodes"][1] = s["yam"]["episodes"][0].copy() - elif mutation == "wrong_inventory": - c["domains"]["yam"]["episode_inventory_sha256"] = "0" * 64 - save(cp, c) - elif mutation == "balanced_sampling": - s["proportional_train_window_probabilities"] = {"yam": 0.5, "human": 0.5} - else: - rows = s["yam"]["episodes"] - a = next(r for r in rows if r["split"] == "train") - b = next(r for r in rows if r["split"] == "valid") - a["split"], b["split"] = b["split"], a["split"] - save(sp, s) - with pytest.raises(ValueError): - split_identities(sp, sha(sp), cp, sha(cp)) - - -def test_sparse_audit_cannot_become_training_content(tmp_path): - cp, sp, c, s, content = fixture(tmp_path) - p = content["yam"] - item = json.loads(p.read_text()) - item["sparse_audit_cache_not_training_data"] = True - c["domains"]["yam"]["complete_content_manifest_sha256"] = save(p, item) - save(cp, c) - with pytest.raises(ValueError): - aggregate_content( - p, sha(p), content["human"], sha(content["human"]), cp, sha(cp) - ) - - -def test_native_split_preserves_original_algorithm(): - for count in (1, 37, 38, 229, 378): - names = [str(i) for i in range(count)] - reference = sorted(names) - random.Random(42).shuffle(reference) - n = max(1, int(count * 0.01)) - assert split_dataset_names(names, 0.01, 42) == ( - set(reference[n:]), - set(reference[:n]), - ) diff --git a/tests/test_yam_human_shared_gradient_routes.py b/tests/test_yam_human_shared_gradient_routes.py deleted file mode 100644 index 1251a58ad0..0000000000 --- a/tests/test_yam_human_shared_gradient_routes.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Focused two-embodiment gradient contract for the provisional co-train graph.""" - -import pytest -import torch -import torch.nn as nn - -from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, - LatentBridgeStage, -) - - -class _SharedField(nn.Module): - def __init__(self): - super().__init__() - self.scale = nn.Parameter(torch.tensor(0.4, dtype=torch.float64)) - self.condition = nn.Linear(3, 8).double() - - def forward(self, state, time, condition, *, condition_drop_mask): - visible = condition.masked_fill(condition_drop_mask[:, None], 0) - return ( - self.scale * state.tanh() - + self.condition(visible)[:, None] - + time[:, None, None] - ) - - -def _norm(gradients): - return sum( - float(gradient.abs().sum()) for gradient in gradients if gradient is not None - ) - - -@pytest.mark.parametrize("embodiment,action_dim", [(7, 14), (3, 138)]) -def test_both_domains_supervise_one_shared_field_and_their_own_codec( - embodiment, action_dim -): - torch.manual_seed(19) - encoders = { - "yam_bimanual": nn.Sequential(nn.Linear(14, 8), nn.Tanh()).double(), - "human_bimanual": nn.Sequential(nn.Linear(138, 8), nn.Tanh()).double(), - } - decoders = { - "yam_bimanual": nn.Sequential( - nn.Linear(8, 12), nn.SiLU(), nn.Linear(12, 14) - ).double(), - "human_bimanual": nn.Sequential( - nn.Linear(8, 12), nn.SiLU(), nn.Linear(12, 138) - ).double(), - } - aliases = {"7": "yam_bimanual", "3": "human_bimanual"} - field = _SharedField() - stages = ( - ContentEncoderStage(encoders=encoders, selector_aliases=aliases), - LatentBridgeStage(samples_per_content=3, condition_dropout_probability=0.0), - ConditionalVelocityStage(field, flow_clean_gradient_mode="all_stopgrad"), - ContentDecoderStage(decoders=decoders, selector_aliases=aliases), - ActionFlowObjectiveStage(residual_key="action_flow/fm_velocity_residual"), - ) - batch = { - "target": torch.randn(2, 4, action_dim, dtype=torch.float64), - "sampler/noise": torch.randn(2, 4, 8, dtype=torch.float64), - "condition": torch.randn(2, 3, dtype=torch.float64), - "embodiment": torch.full((2,), embodiment), - } - for stage in stages: - batch = stage(batch) - selected = aliases[str(embodiment)] - other = next(name for name in encoders if name != selected) - selected_encoder = tuple(encoders[selected].parameters()) - selected_decoder = tuple(decoders[selected].parameters()) - shared = tuple(field.parameters()) - fm = batch["log/action_flow_fm"] - velocity = batch["log/action_flow_action_velocity"] - total = batch["loss/action_flow"] - assert torch.isfinite(torch.stack((fm, velocity, total))).all() - assert _norm(torch.autograd.grad(fm, shared, retain_graph=True)) > 0 - assert ( - _norm( - torch.autograd.grad( - fm, selected_encoder, retain_graph=True, allow_unused=True - ) - ) - == 0 - ) - assert _norm(torch.autograd.grad(velocity, selected_encoder, retain_graph=True)) > 0 - assert _norm(torch.autograd.grad(total, selected_decoder, retain_graph=True)) > 0 - assert _norm(torch.autograd.grad(total, shared, retain_graph=True)) > 0 - assert ( - _norm( - torch.autograd.grad( - total, - tuple(encoders[other].parameters()), - retain_graph=True, - allow_unused=True, - ) - ) - == 0 - ) - assert ( - _norm( - torch.autograd.grad( - total, tuple(decoders[other].parameters()), allow_unused=True - ) - ) - == 0 - ) diff --git a/tests/test_yam_human_speed_contract.py b/tests/test_yam_human_speed_contract.py deleted file mode 100644 index a28734e660..0000000000 --- a/tests/test_yam_human_speed_contract.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Physical-time retiming and actual native leaf/condition boundaries.""" - -import numpy as np -import pytest -import torch - -from egomimic.pipeline.stages_speed import SharedSpeedCondition -from egomimic.rldb.zarr.episode_split import complete_window_count -from egomimic.rldb.zarr.physical_retiming import PhysicalWindowRetiming -from egomimic.rldb.zarr.zarr_dataset_multi import ZarrDataset - -RATES = [0.2, 0.4, 0.6, 0.8, 1.0] - - -def fixture(human=True): - n = 30 if human else 100 - dt = np.resize(np.array([0.02, 0.04, 0.03, 0.05]), n - 1) - clock = np.concatenate(([0.0], np.cumsum(dt))) if human else np.arange(n) / 30 - pose = np.zeros((n, 7)) - pose[:, 0] = 0.2 * clock - pose[:, 3] = 1 - keys = ("left.pose", "right.pose") - obj = PhysicalWindowRetiming( - RATES if human else [1.0], - dict.fromkeys(keys, "pose_wxyz"), - keys, - n, - 3 if human else 1, - "human" if human else "robot", - 5, - "clock" if human else None, - ) - keymap = {k: {"zarr_key": k, "horizon": n} for k in keys} - if human: - keymap["clock"] = {"zarr_key": "clock", "horizon": n} - obj.bind_episode({"fps": 30}, keymap) - raw = {k: pose.copy() for k in keys} - if human: - raw["clock"] = (clock * 1e9).round().astype(np.int64) - return obj, raw, keymap - - -@pytest.mark.parametrize("view", range(5)) -def test_all_selected_rates_follow_irregular_physical_time(view): - obj, raw, _ = fixture() - old = raw["left.pose"].copy() - raw["_retiming_view"] = view - got = obj.transform(raw) - assert got["retiming_rate"] == np.float32(RATES[view]) - np.testing.assert_allclose(got["retiming_rate"], [RATES[view]]) - assert "requested_speed" not in got - if view == 4: - assert np.array_equal(got["left.pose"], old) - np.testing.assert_allclose(np.linalg.norm(got["left.pose"][:, 3:], axis=1), 1) - - -@pytest.mark.parametrize("view", range(5)) -def test_robot_five_identity_views_preserve_values(view): - obj, raw, _ = fixture(False) - old = raw["left.pose"].copy() - raw["_retiming_view"] = view - got = obj.transform(raw) - assert np.array_equal(got["left.pose"], old) - np.testing.assert_allclose(got["retiming_rate"], [1.0]) - assert "requested_speed" not in got - - -@pytest.mark.parametrize( - "bad", ["short", "duplicate_clock", "float_clock", "zero_quaternion"] -) -def test_invalid_physical_futures_rejected(bad): - obj, raw, _ = fixture() - raw["_retiming_view"] = 0 - if bad == "short": - raw["left.pose"] = raw["left.pose"][:-1] - elif bad == "duplicate_clock": - raw["clock"][1] = raw["clock"][0] - elif bad == "float_clock": - raw["clock"] = raw["clock"].astype(float) - else: - raw["left.pose"][:, 3:] = 0 - with pytest.raises(ValueError): - obj.transform(raw) - - -def test_real_leaf_maps_views_anchors_and_never_reads_padded_tail( - monkeypatch, tmp_path -): - obj, _, keymap = fixture() - total = 35 - clock = np.arange(total, dtype=np.int64) * 33333333 - pose = np.zeros((total, 7)) - pose[:, 0] = clock * 1e-9 * 0.2 - pose[:, 3] = 1 - arrays = {"clock": clock, "left.pose": pose, "right.pose": pose} - - class Reader: - metadata = { - "total_frames": total, - "embodiment": "human_bimanual", - "fps": 30, - "features": {}, - } - intrinsics = None - - def read(self, requests): - return { - key: arrays[key][a:z].copy() if z is not None else arrays[key][a].copy() - for key, (a, z) in requests.items() - } - - def init(self): - self.episode_reader = Reader() - self.metadata = self.episode_reader.metadata - self.total_frames = total - self.embodiment = "human_bimanual" - self.keys_dict = {} - self._image_keys = set() - self._json_keys = set() - - monkeypatch.setattr(ZarrDataset, "init_episode", init) - leaf = ZarrDataset(tmp_path / "episode.zarr", keymap, [obj]) - assert len(leaf) == complete_window_count(total, 30, 5) == 30 - values = [leaf[j] for j in range(5)] - assert [int(x["frame_index"]) for x in values] == [0] * 5 - assert [int(x["retiming_view"]) for x in values] == list(range(5)) - assert int(leaf[len(leaf) - 1]["frame_index"]) == 5 - with pytest.raises(IndexError): - leaf[len(leaf)] - - -def test_scalar_condition_rng_identity_gradient_and_explicit_inference_input(): - torch.manual_seed(5) - before = torch.get_rng_state().clone() - stage = SharedSpeedCondition(None, condition_dim=8) - assert torch.equal(before, torch.get_rng_state()) - condition = torch.randn(3, 8, requires_grad=True) - speed = torch.tensor([[0.01], [0.05], [0.1]]) - got = stage({"condition": condition, "retiming_rate": speed})["speed_condition"] - torch.testing.assert_close(got, condition, rtol=0, atol=0) - got.square().sum().backward() - assert stage.mlp[-1].weight.grad.norm() > 0 and condition.grad.norm() > 0 - with pytest.raises(KeyError): - stage({"condition": condition}) - with pytest.raises(ValueError): - stage({"condition": condition, "retiming_rate": torch.full((3, 1), -1.0)}) - - -@pytest.mark.parametrize("domain,dim", [(7, 14), (3, 138)]) -def test_speed_reaches_actual_action_flow_objective(domain, dim): - from egomimic.pipeline.stages_action_flow import ( - ActionFlowObjectiveStage, - ConditionalVelocityStage, - ContentDecoderStage, - ContentEncoderStage, - LatentBridgeStage, - ) - - class Field(torch.nn.Module): - def __init__(self): - super().__init__() - self.linear = torch.nn.Linear(3, 8).double() - - def forward(self, state, time, condition, *, condition_drop_mask): - visible = condition.masked_fill(condition_drop_mask[:, None], 0) - return state.tanh() + self.linear(visible)[:, None] + time[:, None, None] - - torch.manual_seed(19) - aliases = {"7": "robot", "3": "human"} - encoders = { - name: torch.nn.Sequential(torch.nn.Linear(d, 8), torch.nn.Tanh()).double() - for name, d in (("robot", 14), ("human", 138)) - } - decoders = { - name: torch.nn.Linear(8, d).double() - for name, d in (("robot", 14), ("human", 138)) - } - speed = SharedSpeedCondition(condition_dim=3, speed_reference=None).double() - field = Field() - batch = { - "target": torch.randn(2, 4, dim, dtype=torch.float64), - "sampler/noise": torch.randn(2, 4, 8, dtype=torch.float64), - "condition": torch.randn(2, 3, dtype=torch.float64), - "retiming_rate": torch.tensor([[0.1], [0.3]], dtype=torch.float64), - "embodiment": torch.full((2,), domain), - } - stages = [ - speed, - ContentEncoderStage(encoders=encoders, selector_aliases=aliases), - LatentBridgeStage( - samples_per_content=3, - condition_dropout_probability=0.0, - condition_key="speed_condition", - ), - ConditionalVelocityStage( - field, - flow_clean_gradient_mode="all_stopgrad", - inference_condition_key="speed_condition", - ), - ContentDecoderStage(decoders=decoders, selector_aliases=aliases), - ActionFlowObjectiveStage(residual_key="action_flow/fm_velocity_residual"), - ] - for stage in stages: - batch = stage(batch) - loss = batch["loss/action_flow"] - assert torch.isfinite(loss) - loss.backward() - assert speed.mlp[-1].weight.grad.norm() > 0 - assert field.linear.weight.grad.norm() > 0 - assert encoders[aliases[str(domain)]][0].weight.grad.norm() > 0 - assert decoders[aliases[str(domain)]].weight.grad.norm() > 0 diff --git a/tests/test_yam_human_validation_loader_topology.py b/tests/test_yam_human_validation_loader_topology.py deleted file mode 100644 index 37200d6f35..0000000000 --- a/tests/test_yam_human_validation_loader_topology.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Prevent nested CombinedLoader tuples at the two-domain validation boundary.""" - -from collections.abc import Mapping - -import pytest -import torch -from lightning import LightningModule, Trainer -from torch.utils.data import Dataset - -from egomimic.pipeline.algo import PipelineAlgo -from egomimic.pl_utils.pl_data_utils import ProportionalMultiDataModuleWrapper - - -class _TinyDataset(Dataset): - def __len__(self): - return 4 - - def __getitem__(self, index): - return {"value": torch.tensor(index, dtype=torch.float32)} - - -class _ValidationProbe(LightningModule): - def __init__(self): - super().__init__() - self.seen = [] - - def validation_step(self, batch, batch_idx, dataloader_idx=0): - PipelineAlgo._validate_groups(batch) - self.seen.append((dataloader_idx, tuple(batch), batch_idx)) - - -@pytest.mark.parametrize("workers", [0, 2]) -def test_two_validation_groups_reach_lightning_as_flat_source_mappings(workers): - data = ProportionalMultiDataModuleWrapper( - train_datasets={}, - valid_datasets={ - "yam": {"yam_bimanual": _TinyDataset()}, - "human": {"human_bimanual": _TinyDataset()}, - }, - train_dataloader_params={}, - valid_dataloader_params={ - "yam": { - "yam_bimanual": { - "batch_size": 4, - "num_workers": workers, - "shuffle": False, - } - }, - "human": { - "human_bimanual": { - "batch_size": 4, - "num_workers": workers, - "shuffle": False, - } - }, - }, - proportional_train_batch_size=8, - proportional_train_num_workers=0, - ) - loaders = data.val_dataloader() - assert len(loaders) == 2 - for source, loader in zip(("yam_bimanual", "human_bimanual"), loaders): - batch = next(iter(loader)) - assert isinstance(batch, Mapping) - PipelineAlgo._validate_groups(batch) - assert tuple(batch) == (source,) - probe = _ValidationProbe() - trainer = Trainer( - accelerator="cpu", - devices=1, - logger=False, - enable_checkpointing=False, - enable_model_summary=False, - limit_val_batches=1, - ) - trainer.validate(probe, dataloaders=loaders) - assert probe.seen == [ - (0, ("yam_bimanual",), 0), - (1, ("human_bimanual",), 0), - ] diff --git a/tests/test_yam_native_dit_half.py b/tests/test_yam_native_dit_half.py deleted file mode 100644 index 463d9afdb3..0000000000 --- a/tests/test_yam_native_dit_half.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Native depth6/depth12 DiT-half: raw derivatives, JVP, RNG and state.""" - -import pytest -import torch - -from egomimic.models.unite_dit import UniteDiTBackbone -from egomimic.utils.runtime_compatibility import math_sdpa_context - - -def make(depth, policy): - return ( - UniteDiTBackbone( - input_dim=4, - output_dim=4, - horizon=2, - max_condition_tokens=1, - condition_dim=4, - hidden_dim=16, - depth=depth, - num_heads=4, - in_context_len=2, - in_context_start=4, - max_content_tokens=2, - time_fourier_dim=8, - dropout=0.1, - gradient_checkpointing=True, - checkpoint_policy=policy, - ) - .float() - .train() - ) - - -def raw_equal(a, b): - if a is None or b is None: - assert a is None and b is None - return - assert a.shape == b.shape and a.dtype == b.dtype - assert torch.isfinite(a).all() and torch.isfinite(b).all() - assert torch.equal( - a.detach().contiguous().view(torch.uint8), - b.detach().contiguous().view(torch.uint8), - ) - - -@pytest.mark.parametrize("depth", [6, 8, 10, 12]) -def test_native_half_checkpoint_raw_higher_order_rng_and_state(depth): - torch.set_num_threads(1) - torch.manual_seed(42) - model = make(depth, "all") - with torch.no_grad(): - for p in model.parameters(): - p.normal_(0, 0.1) - state = {k: v.detach().clone() for k, v in model.state_dict().items()} - - def evaluate(policy): - model.checkpoint_policy = policy - model.checkpoint_policy_counts = {"direct": 0, "checkpoint": 0} - torch.manual_seed(73) - x = torch.linspace(-1, 1, 8).reshape(1, 2, 4).requires_grad_() - t = torch.tensor([0.4], requires_grad=True) - c = torch.linspace(-0.2, 0.3, 4).reshape(1, 4).requires_grad_() - params = tuple(model.parameters()) - - def forward(value): - return model(value, t, c, content_tokens=value) - - with math_sdpa_context(): - y = forward(x) - first = torch.autograd.grad( - y.square().sum(), - (x, t, c) + params, - create_graph=True, - retain_graph=True, - allow_unused=True, - ) - second = torch.autograd.grad( - sum(v.square().sum() for v in first if v is not None), - (x, t, c), - retain_graph=True, - ) - repeat = torch.autograd.grad(y.sum(), (x, t, c), retain_graph=True) - torch.manual_seed(79) - jy, jvp = torch.autograd.functional.jvp(forward, x, torch.ones_like(x)) - return [y, *first, *second, *repeat, jy, jvp, torch.get_rng_state()], dict( - model.checkpoint_policy_counts - ) - - baseline, counts_all = evaluate("all") - candidate, counts_half = evaluate("dit_half") - for a, b in zip(baseline, candidate): - raw_equal(a, b) - assert counts_all["direct"] == 0 and counts_all["checkpoint"] > 0 - assert counts_half["direct"] > 0 and counts_half["checkpoint"] > 0 - assert counts_half["direct"] == counts_half["checkpoint"] - for k, v in model.state_dict().items(): - raw_equal(v, state[k]) - restored = make(depth, "dit_half") - restored.load_state_dict(state, strict=True) - for k, v in restored.state_dict().items(): - raw_equal(v, state[k]) - assert not torch.cuda.is_initialized() - - -def test_callback_configurable_depth_state_is_checkpoint_bound(): - from egomimic.utils.yam_dit_half import YamHumanDiTHalf - - legacy = YamHumanDiTHalf() - assert legacy.state_dict()["encoder_depth"] == 6 - assert legacy.state_dict()["velocity_depth"] == 12 - candidate = YamHumanDiTHalf(encoder_depth=8, velocity_depth=10) - state = candidate.state_dict() - assert state["encoder_depth"] == 8 and state["velocity_depth"] == 10 - candidate.load_state_dict(state) - with pytest.raises(ValueError, match="identity mismatch"): - candidate.load_state_dict(legacy.state_dict()) - - -@pytest.mark.parametrize("kwargs", [{"encoder_depth": 7}, {"velocity_depth": 9}]) -def test_callback_declared_depth_rejects_invalid_half_policy(kwargs): - from egomimic.utils.yam_dit_half import YamHumanDiTHalf - - with pytest.raises(ValueError, match="positive even"): - YamHumanDiTHalf(**kwargs) - - -@pytest.mark.parametrize( - "policy,depth,checkpointing", - [("invalid", 6, True), ("dit_half", 5, True), ("dit_half", 6, False)], -) -def test_native_policy_fails_closed(policy, depth, checkpointing): - with pytest.raises(ValueError): - UniteDiTBackbone( - input_dim=4, - output_dim=4, - horizon=2, - max_condition_tokens=1, - condition_dim=4, - hidden_dim=16, - depth=depth, - num_heads=4, - gradient_checkpointing=checkpointing, - checkpoint_policy=policy, - ) diff --git a/tests/test_yam_packaging.py b/tests/test_yam_packaging.py deleted file mode 100644 index 77b7e1b49a..0000000000 --- a/tests/test_yam_packaging.py +++ /dev/null @@ -1,96 +0,0 @@ -import hashlib -import tomllib -from pathlib import Path - -import yaml - -PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml" -ROOT = PYPROJECT.parent - - -def _extra_conflict_pairs(conflicts: list[list[dict[str, str]]]) -> set[frozenset[str]]: - pairs = set() - for group in conflicts: - selected = [requirement["extra"] for requirement in group] - if len(selected) == 2: - pairs.add(frozenset(selected)) - return pairs - - -def test_yam_dependencies_are_isolated_from_aria_and_pi05() -> None: - with PYPROJECT.open("rb") as file: - project = tomllib.load(file) - - base_dependencies = project["project"]["dependencies"] - extras = project["project"]["optional-dependencies"] - conflicts = project["tool"]["uv"]["conflicts"] - - assert not any( - dependency.startswith("projectaria-tools") for dependency in base_dependencies - ) - assert extras["aria"] == ["projectaria-tools[all]==2.0.0"] - assert "mink==1.1.0" in base_dependencies - assert "dynamixel-sdk==4.0.5" in extras["yam"] - - conflict_pairs = _extra_conflict_pairs(conflicts) - assert frozenset(("aria", "yam")) in conflict_pairs - assert frozenset(("pi05", "yam")) in conflict_pairs - - -def test_yam_pipeline_control_and_calibration_copies_are_byte_exact() -> None: - expected = { - "egomimic/robot/yam/quest_mapper.py": ( - "a94d333cc3467da41670b49e77dd3e37f565f1f82579c28a6ad31f0f7d2a5b8d" - ), - "egomimic/robot/yam/streaming_ik.py": ( - "488a453ab6623d05d3c690d3008970f83700a149240a8715c205f43ff46b8add" - ), - "egomimic/hydra_configs/robot/yam_rl2_agentview_extrinsics.yaml": ( - "5795bd2480e3ef31a1ffcf993834613bd3bf267082e24c23c30094848fbd122c" - ), - "egomimic/hydra_configs/robot/yam_rl2_workspace.yaml": ( - "bef2471d3a6d759728bcb7d85eb2943fd9f0377cf65d450d4712f2ad43f9a9b9" - ), - } - for relative, digest in expected.items(): - assert hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() == digest - - -def test_rl2_yam_profile_locks_reference_yaw_rate_and_streaming_ik() -> None: - profile = yaml.safe_load( - (ROOT / "egomimic/hydra_configs/robot/yam_rl2_collect.yaml").read_text() - ) - assert profile["teleop"]["headset_yaw_degrees"] == 180.0 - assert profile["teleop"]["orientation_rx_degrees"] == 0.0 - assert profile["frequency"] == 60 - assert profile["recording"]["rate_hz"] == 30 - assert profile["max_joint_velocity"] == 2.5 - assert profile["robot"]["teleop_kinematics"] == { - "ee_site": "tcp_site", - "n_arm": 6, - "dt": 1 / 60, - "steps": 4, - "gain": 0.5, - "damping": 0.01, - "posture_cost": 0.08, - "max_joint_vel": 2.5, - } - - -def test_rl2_yam_compatibility_calibration_matches_reference_copy() -> None: - reference = yaml.safe_load( - ( - ROOT / "egomimic/hydra_configs/robot/yam_rl2_agentview_extrinsics.yaml" - ).read_text() - ) - calibration = yaml.safe_load( - (ROOT / "egomimic/hydra_configs/calibration/rl2yam.yaml").read_text() - ) - assert calibration["camera_serial"] == reference["serial"] - assert calibration["convention"] == reference["convention"] - assert calibration["distortion"] == reference["dist"] - assert calibration["intrinsics"] == [row + [0.0] for row in reference["K"]] - assert calibration["extrinsics"] == { - "left": reference["channels"]["can_follower_l"]["base_T_camera"], - "right": reference["channels"]["can_follower_r"]["base_T_camera"], - } diff --git a/tests/test_zarr_content_manifest.py b/tests/test_zarr_content_manifest.py deleted file mode 100644 index 089e84dbc7..0000000000 --- a/tests/test_zarr_content_manifest.py +++ /dev/null @@ -1,212 +0,0 @@ -import hashlib -import json -import subprocess -import sys -from pathlib import Path - -import numpy as np -import zarr - -from egomimic.rldb.zarr.content_manifest import ( - build_content_manifest, - canonical_manifest_bytes, -) - -ROOT = Path(__file__).parents[1] -GENERATOR = ROOT / "scripts" / "data" / "materialize_zarr_content_manifest.py" -VALIDATOR = ROOT / "scripts" / "ice" / "validate_planar_dataset.py" - - -def _tiny_episodes(root: Path) -> dict[str, Path]: - root.mkdir() - episodes = {} - for index in range(3): - episode_id = f"episode_{index:03d}" - path = root / f"{episode_id}.zarr" - group = zarr.open_group(str(path), mode="w") - group.create_array( - "actions", - data=np.asarray([[index, index + 1]], dtype=np.int64), - chunks=(1, 2), - ) - group.attrs["episode"] = index - episodes[episode_id] = path - return episodes - - -def _split_manifest(module, names, source_root): - train, valid = module.split_names(names) - source_paths = {name: source_root / name for name in names} - generator = ROOT / "scripts" / "data" / "materialize_local_episode_split.py" - return { - "schema_version": 1, - "status": "PASS", - "split_algorithm": module.SPLIT_ALGORITHM, - "split_seed": 42, - "valid_ratio": 0.01, - "generator_sha256": hashlib.sha256(generator.read_bytes()).hexdigest(), - "cross_domain_train_valid_resolved_path_overlap_count": 0, - "domains": { - module.DOMAIN: { - "folder_path": str(source_root), - "total_count": len(names), - "inventory_names_sha256": module.names_sha256(names), - "train_count": len(train), - "train_names_sha256": module.names_sha256(train), - "train_resolved_paths_sha256": module.paths_sha256( - source_paths[name] for name in train - ), - "train_ids": sorted(train), - "valid_count": len(valid), - "valid_names_sha256": module.names_sha256(valid), - "valid_resolved_paths_sha256": module.paths_sha256( - source_paths[name] for name in valid - ), - "valid_ids": sorted(valid), - "id_overlap_count": 0, - "resolved_path_overlap_count": 0, - "union_count": len(names), - "union_matches_inventory": True, - } - }, - } - - -def test_manifest_is_path_independent_and_byte_sensitive(tmp_path): - first = _tiny_episodes(tmp_path / "source") - second_root = tmp_path / "mirror" - second_root.mkdir() - second = {} - for episode_id, source in first.items(): - destination = second_root / source.name - import shutil - - shutil.copytree(source, destination) - second[episode_id] = destination - - first_manifest = build_content_manifest(first) - second_manifest = build_content_manifest(second) - assert first_manifest == second_manifest - assert canonical_manifest_bytes(first_manifest) == canonical_manifest_bytes( - second_manifest - ) - - chunk = next( - path - for path in sorted(second["episode_001"].rglob("*")) - if path.is_file() and path.name not in {"zarr.json", ".zgroup", ".zattrs"} - ) - chunk.write_bytes(chunk.read_bytes() + b"tamper") - tampered = build_content_manifest(second) - assert tampered["aggregate_sha256"] != first_manifest["aggregate_sha256"] - rows = {row["episode_id"]: row for row in tampered["episodes"]} - expected = {row["episode_id"]: row for row in first_manifest["episodes"]} - assert rows["episode_001"]["sha256"] != expected["episode_001"]["sha256"] - assert rows["episode_000"] == expected["episode_000"] - - -def test_generator_and_validator_reject_byte_tamper(tmp_path): - import importlib.util - - spec = importlib.util.spec_from_file_location("validate_planar_dataset", VALIDATOR) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - episodes = _tiny_episodes(tmp_path / "dataset") - content_path = tmp_path / "content.json" - subprocess.run( - [ - sys.executable, - str(GENERATOR), - "--repo", - str(ROOT), - "--dataset-root", - str(tmp_path / "dataset"), - "--output", - str(content_path), - ], - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - content_sha = hashlib.sha256(content_path.read_bytes()).hexdigest() - - split = _split_manifest(module, set(episodes), Path("/canonical/source")) - split_path = tmp_path / "split.json" - split_path.write_text( - json.dumps(split, sort_keys=True, separators=(",", ":")) + "\n" - ) - split_sha = hashlib.sha256(split_path.read_bytes()).hexdigest() - - def validate(output: Path, *, check: bool): - return subprocess.run( - [ - sys.executable, - str(VALIDATOR), - "--repo", - str(ROOT), - "--dataset-root", - str(tmp_path / "dataset"), - "--manifest", - str(split_path), - "--expected-manifest-sha256", - split_sha, - "--content-manifest", - str(content_path), - "--expected-content-manifest-sha256", - content_sha, - "--output", - str(output), - ], - check=check, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - validate(tmp_path / "pass.json", check=True) - payload = json.loads((tmp_path / "pass.json").read_text()) - assert ( - payload["content_identity"]["aggregate_sha256"] - == json.loads(content_path.read_text())["aggregate_sha256"] - ) - - chunk = next( - path - for path in sorted(episodes["episode_002"].rglob("*")) - if path.is_file() and path.name not in {"zarr.json", ".zgroup", ".zattrs"} - ) - chunk.write_bytes(chunk.read_bytes() + b"tamper") - failed = validate(tmp_path / "fail.json", check=False) - assert failed.returncode != 0 - assert "Zarr content manifest mismatch" in failed.stderr - assert not (tmp_path / "fail.json").exists() - - -def test_validator_requires_both_content_manifest_flags(tmp_path): - completed = subprocess.run( - [ - sys.executable, - str(VALIDATOR), - "--repo", - str(ROOT), - "--dataset-root", - str(tmp_path), - "--manifest", - str(tmp_path / "split.json"), - "--expected-manifest-sha256", - "a" * 64, - "--content-manifest", - str(tmp_path / "content.json"), - "--output", - str(tmp_path / "result.json"), - ], - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert completed.returncode != 0 - assert "must be supplied together" in completed.stderr diff --git a/tools/test_libero_maintained_dispatch_v1.py b/tools/test_libero_maintained_dispatch_v1.py deleted file mode 100644 index 68754a7fd8..0000000000 --- a/tools/test_libero_maintained_dispatch_v1.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Pure metadata guards on the actual YAML's externally parsed JSON, no models.""" - -import copy -import json -import sys - -from libero_maintained_dispatch_v1 import ( - METRICS, - PROFILE, - evaluator_overrides, - require_finite_native_metrics, - scheduled_only, - validate_data_receipt, - validate_resolved, -) -from typed_libero_profile_v3 import phase_bindings - - -def assign(config, path, value): - cursor = config - keys = path.split(".") - for key in keys[:-1]: - cursor = cursor[int(key)] if isinstance(cursor, list) else cursor[key] - if isinstance(cursor, list): - cursor[int(keys[-1])] = value - else: - cursor[keys[-1]] = value - - -def check(config): - cfg = copy.deepcopy(config) - cfg["callbacks"]["dit_half"] = { - "_target_": "egomimic.utils.libero_dit_half.LiberoDiTHalf" - } - for phase in ("preflight", "smoke", "full"): - variant = copy.deepcopy(cfg) - for key, value in phase_bindings(phase).items(): - assign(variant, key, value) - validate_resolved(variant, phase) - full = copy.deepcopy(cfg) - for key, value in phase_bindings("full").items(): - assign(full, key, value) - mutations = [ - ( - "data.train_datasets.libero_panda._target_", - "egomimic.rldb.zarr.libero_dataset.LiberoDataset._from_resolver", - ), - ("normalizer._target_", "egomimic.rldb.zarr.libero_dataset.LiberoNormalizer"), - ("norm_stats.precomputed_norm_path", "/planar/norm_stats.json"), - ("data.valid_datasets.libero_panda.mode", "train"), - ("data.valid_datasets.libero_panda.valid_ratio", 0.05), - ("model.pipeline.stages.6.field.backbone.num_heads", 12), - ("callbacks.dit_half._target_", "invented"), - ("callbacks.ema.validate_with_ema", False), - ("evaluator.energy_sample_count", 8), - ("data.train_dataloader_params.libero_panda.batch_size", 64), - ] - for key, value in mutations: - variant = copy.deepcopy(full) - assign(variant, key, value) - try: - validate_resolved(variant, "full") - except ValueError: - pass - else: - raise AssertionError(("bad dispatch accepted", key)) - evaluator_overrides(PROFILE) - assert not any("validation_view" in item for item in evaluator_overrides(PROFILE)) - metrics = { - "Train": {"normalized_reconst_mse": 1.0}, - "Valid": {key: 1.0 for key in METRICS}, - } - require_finite_native_metrics(metrics) - for key in METRICS: - bad = copy.deepcopy(metrics) - bad["Valid"][key] = float("nan") - try: - require_finite_native_metrics(bad) - except ValueError: - pass - else: - raise AssertionError(("nonfinite accepted", key)) - receipt = { - "suite": "libero10", - "replay_path": "/fixture.zarr", - "dataset_logical_sha256": "fixture", - "episodes": 500, - "valid_ratio": 0.01, - "split_seed": 42, - "frames": 1000, - "action_shape": [1000, 7], - "train_episode_indices": list(range(495)), - "valid_episode_indices": list(range(495, 500)), - } - validate_data_receipt( - receipt, replay_path="/fixture.zarr", expected_logical_sha="fixture" - ) - for key, value in ( - ("valid_ratio", 0.05), - ("valid_episode_indices", [0, 496, 497, 498, 499]), - ("replay_path", "/wrong"), - ("action_shape", [1000, 4]), - ): - bad = copy.deepcopy(receipt) - bad[key] = value - try: - validate_data_receipt( - bad, replay_path="/fixture.zarr", expected_logical_sha="fixture" - ) - except ValueError: - pass - else: - raise AssertionError(("bad receipt accepted", key)) - try: - scheduled_only() - except RuntimeError: - pass - else: - raise AssertionError("login compute allowed") - return { - "status": "LIBERO_MAINTAINED_DISPATCH_METADATA_PASS_ONLY", - "phases": 3, - "semantic_negatives": len(mutations), - "metric_negatives": len(METRICS), - "receipt_negatives": 4, - "actual_constructor": False, - "gpu_ready": False, - } - - -if __name__ == "__main__": - print(json.dumps(check(json.load(sys.stdin)), indent=2))