x4d-devkit 0.23.0__tar.gz → 0.24.0rc1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/PKG-INFO +7 -5
  2. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/README.md +6 -4
  3. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/pyproject.toml +1 -1
  4. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_dataset_identity.py +298 -70
  5. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_label_schemas.py +143 -1
  6. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_sdk_api.py +1 -1
  7. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_work_session.py +27 -0
  8. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/cli.py +14 -14
  9. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/__init__.py +9 -1
  10. x4d_devkit-0.24.0rc1/x4d_devkit/client/label_schemas.py +423 -0
  11. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/dataset_identity.py +382 -193
  12. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/materialize.py +99 -19
  13. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit.egg-info/PKG-INFO +7 -5
  14. x4d_devkit-0.23.0/x4d_devkit/client/label_schemas.py +0 -130
  15. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/LICENSE +0 -0
  16. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/setup.cfg +0 -0
  17. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_annotation_contract.py +0 -0
  18. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_assets_client.py +0 -0
  19. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_boundary.py +0 -0
  20. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_box.py +0 -0
  21. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_build_info.py +0 -0
  22. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_cli_auth.py +0 -0
  23. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_client_json_contract.py +0 -0
  24. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_client_projects.py +0 -0
  25. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_clip_archives.py +0 -0
  26. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_contract_validation.py +0 -0
  27. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_coordinate_frames.py +0 -0
  28. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_current_state.py +0 -0
  29. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_current_state_identity_consumers_example.py +0 -0
  30. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_dataset_contract.py +0 -0
  31. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_detection_eval.py +0 -0
  32. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_evaluate.py +0 -0
  33. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_export_bundle.py +0 -0
  34. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_inference_builders.py +0 -0
  35. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_inference_schemas.py +0 -0
  36. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_inference_service.py +0 -0
  37. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_loader.py +0 -0
  38. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_matching.py +0 -0
  39. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_metrics.py +0 -0
  40. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_models.py +0 -0
  41. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_nuscenes_converter.py +0 -0
  42. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_platform_pose.py +0 -0
  43. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_release_gate.py +0 -0
  44. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_token.py +0 -0
  45. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_training_identity.py +0 -0
  46. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_transform.py +0 -0
  47. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_transform_chain.py +0 -0
  48. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/tests/test_validation.py +0 -0
  49. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/__init__.py +0 -0
  50. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/annotation_contract.py +0 -0
  51. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/build_info.py +0 -0
  52. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/_origin.py +0 -0
  53. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/assets.py +0 -0
  54. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/bags.py +0 -0
  55. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/capabilities.py +0 -0
  56. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/checkpoints.py +0 -0
  57. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/client.py +0 -0
  58. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/clip_work_sessions.py +0 -0
  59. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/clips.py +0 -0
  60. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/dataset_identity.py +0 -0
  61. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/projects.py +0 -0
  62. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/services.py +0 -0
  63. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/client/test_sets.py +0 -0
  64. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/consumer_identity.py +0 -0
  65. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/converters/__init__.py +0 -0
  66. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/converters/nuscenes.py +0 -0
  67. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/coordinate_contract.py +0 -0
  68. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/core/__init__.py +0 -0
  69. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/core/loader.py +0 -0
  70. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/core/models.py +0 -0
  71. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/core/token.py +0 -0
  72. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/core/transform.py +0 -0
  73. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/current_state.py +0 -0
  74. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/dataset_contract.py +0 -0
  75. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/eval/__init__.py +0 -0
  76. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/eval/api.py +0 -0
  77. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/eval/box.py +0 -0
  78. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/eval/detection.py +0 -0
  79. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/eval/matching.py +0 -0
  80. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/eval/metrics.py +0 -0
  81. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/export_bundle.py +0 -0
  82. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/inference/__init__.py +0 -0
  83. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/inference/builders.py +0 -0
  84. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/inference/models.py +0 -0
  85. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/inference/schemas.py +0 -0
  86. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/inference/service.py +0 -0
  87. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/inference/tasks.py +0 -0
  88. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/json_contract.py +0 -0
  89. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/label_schema.py +0 -0
  90. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/training_identity.py +0 -0
  91. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/validation/__init__.py +0 -0
  92. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/validation/contract.py +0 -0
  93. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/validation/platform_pose.py +0 -0
  94. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/validation/report.py +0 -0
  95. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/validation/validator.py +0 -0
  96. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit/work_session.py +0 -0
  97. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit.egg-info/SOURCES.txt +0 -0
  98. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  99. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit.egg-info/entry_points.txt +0 -0
  100. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit.egg-info/requires.txt +0 -0
  101. {x4d_devkit-0.23.0 → x4d_devkit-0.24.0rc1}/x4d_devkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.23.0
3
+ Version: 0.24.0rc1
4
4
  Summary: Official X-4D external SDK and CLI for datasets, platform APIs, training bridges, and automation
5
5
  Author: windzu
6
6
  License-Expression: Apache-2.0
@@ -66,7 +66,7 @@ Design decisions for this boundary are tracked in GitHub issues, starting with:
66
66
  ## Installation
67
67
 
68
68
  ```bash
69
- pip install x4d-devkit==0.23.0
69
+ pip install x4d-devkit==0.24.0rc1
70
70
  ```
71
71
 
72
72
  Devkit is released independently as an immutable wheel. X-4D, XPoints, and
@@ -255,9 +255,11 @@ identity manifests, and update plans use one schema field only:
255
255
  without the complete identity is also rejected.
256
256
 
257
257
  `x4d-devkit dataset inspect` writes `x4d-remote-identity-v2`, and
258
- `x4d-devkit dataset diff` writes `x4d-update-plan-v2`. These artifacts contain
259
- normalized clip records once; they do not embed a duplicate raw platform
260
- response. V1 artifacts are rejected rather than migrated.
258
+ `x4d-devkit dataset diff` writes `x4d-update-plan-v3`. The v3 plan records the
259
+ exact materialization policy and each changed clip's reviewed target and local
260
+ predecessor identities. Applying it is fail-closed and idempotent: completed
261
+ clips are reported as already satisfied, while local or remote identity drift
262
+ requires a new plan. Older plan contracts are rejected rather than migrated.
261
263
 
262
264
  ### Load a clip
263
265
 
@@ -23,7 +23,7 @@ Design decisions for this boundary are tracked in GitHub issues, starting with:
23
23
  ## Installation
24
24
 
25
25
  ```bash
26
- pip install x4d-devkit==0.23.0
26
+ pip install x4d-devkit==0.24.0rc1
27
27
  ```
28
28
 
29
29
  Devkit is released independently as an immutable wheel. X-4D, XPoints, and
@@ -212,9 +212,11 @@ identity manifests, and update plans use one schema field only:
212
212
  without the complete identity is also rejected.
213
213
 
214
214
  `x4d-devkit dataset inspect` writes `x4d-remote-identity-v2`, and
215
- `x4d-devkit dataset diff` writes `x4d-update-plan-v2`. These artifacts contain
216
- normalized clip records once; they do not embed a duplicate raw platform
217
- response. V1 artifacts are rejected rather than migrated.
215
+ `x4d-devkit dataset diff` writes `x4d-update-plan-v3`. The v3 plan records the
216
+ exact materialization policy and each changed clip's reviewed target and local
217
+ predecessor identities. Applying it is fail-closed and idempotent: completed
218
+ clips are reported as already satisfied, while local or remote identity drift
219
+ requires a new plan. Older plan contracts are rejected rather than migrated.
218
220
 
219
221
  ### Load a clip
220
222
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "x4d-devkit"
7
- version = "0.23.0"
7
+ version = "0.24.0rc1"
8
8
  description = "Official X-4D external SDK and CLI for datasets, platform APIs, training bridges, and automation"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -10,6 +10,7 @@ from tests.schema_contract_fixtures import schema_identity
10
10
  from x4d_devkit import cli
11
11
  from x4d_devkit.client.dataset_identity import DatasetIdentityAPI
12
12
  from x4d_devkit.consumer_identity import empty_consumer_identity_diff
13
+ from x4d_devkit.current_state import identity_from_work_session
13
14
  from x4d_devkit.dataset_identity import (
14
15
  DATASET_IDENTITY_BUCKETS,
15
16
  build_remote_identity_manifest,
@@ -82,6 +83,10 @@ def _diff_record(bucket, **overrides):
82
83
  pose_revision="sha256:pose",
83
84
  )
84
85
  )
86
+ if bucket != "new_clips":
87
+ record["local_identity"] = _local_identity(
88
+ clip_id=record["clip_id"],
89
+ )
85
90
  elif bucket == "removed_clips":
86
91
  record["local_identity"] = _local_identity(clip_id=record["clip_id"])
87
92
  elif bucket == "errors":
@@ -450,6 +455,7 @@ def test_null_remote_pose_revision_roundtrips_through_manifest_and_plan():
450
455
  plan = build_update_plan(
451
456
  diff,
452
457
  consumer_identity_diff=empty_consumer_identity_diff(),
458
+ materialization_policy="full",
453
459
  )
454
460
  assert plan["clips"][0]["pose_revision"] is None
455
461
  assert load_update_plan(plan)["clips"][0]["pose_revision"] is None
@@ -500,6 +506,7 @@ def test_build_update_plan_maps_actions_and_exit_code():
500
506
  plan = build_update_plan(
501
507
  diff,
502
508
  consumer_identity_diff=empty_consumer_identity_diff(),
509
+ materialization_policy="full",
503
510
  local_manifest="m.json",
504
511
  )
505
512
 
@@ -510,7 +517,8 @@ def test_build_update_plan_maps_actions_and_exit_code():
510
517
  assert plan["actions"]["reuse_source_cache"] == ["same", "ann"]
511
518
  assert plan["actions"]["rebuild_infos"] is True
512
519
  assert update_plan_exit_code(plan) == 13
513
- assert plan["schema_version"] == "x4d-update-plan-v2"
520
+ assert plan["schema_version"] == "x4d-update-plan-v3"
521
+ assert plan["materialization_policy"] == "full"
514
522
  assert "platform_diff" not in plan
515
523
 
516
524
 
@@ -523,6 +531,7 @@ def test_build_update_plan_refreshes_schema_only_and_clip_format_changes():
523
531
  plan = build_update_plan(
524
532
  diff,
525
533
  consumer_identity_diff=empty_consumer_identity_diff(),
534
+ materialization_policy="full",
526
535
  )
527
536
 
528
537
  assert plan["actions"]["download_annotation_only"] == ["schema"]
@@ -553,7 +562,11 @@ def test_build_update_plan_includes_consumer_identity_changes():
553
562
  }],
554
563
  }
555
564
 
556
- plan = build_update_plan(diff, consumer_identity_diff=consumer_diff)
565
+ plan = build_update_plan(
566
+ diff,
567
+ consumer_identity_diff=consumer_diff,
568
+ materialization_policy="full",
569
+ )
557
570
 
558
571
  assert plan["summary"]["consumer_changed"] is True
559
572
  assert plan["consumer_identity"]["changed_fields"] == ["split", "class_mapping"]
@@ -576,12 +589,13 @@ def test_build_update_plan_rejects_partial_consumer_identity_diff():
576
589
  build_update_plan(
577
590
  diff,
578
591
  consumer_identity_diff={"changed": True},
592
+ materialization_policy="full",
579
593
  )
580
594
 
581
595
 
582
596
  def test_build_update_plan_requires_explicit_consumer_identity_diff():
583
597
  with pytest.raises(TypeError, match="consumer_identity_diff"):
584
- build_update_plan(_diff_response())
598
+ build_update_plan(_diff_response(), materialization_policy="full")
585
599
 
586
600
 
587
601
  def test_build_update_plan_distinguishes_pose_and_calibration_changes():
@@ -595,6 +609,7 @@ def test_build_update_plan_distinguishes_pose_and_calibration_changes():
595
609
  plan = build_update_plan(
596
610
  diff,
597
611
  consumer_identity_diff=empty_consumer_identity_diff(),
612
+ materialization_policy="full",
598
613
  )
599
614
 
600
615
  assert plan["summary"]["calibration_changed"] == 1
@@ -661,6 +676,7 @@ def test_dataset_diff_cli_writes_plan_and_exits(monkeypatch, tmp_path, capsys):
661
676
  project_name=None,
662
677
  clip_ids=None,
663
678
  include_asset_revision=False,
679
+ materialization_policy="full",
664
680
  out=str(out),
665
681
  format="table",
666
682
  api_url=None,
@@ -742,6 +758,7 @@ def test_dataset_diff_cli_compares_training_identity(monkeypatch, tmp_path, caps
742
758
  project_name=None,
743
759
  clip_ids=None,
744
760
  include_asset_revision=False,
761
+ materialization_policy="full",
745
762
  out=str(out),
746
763
  format="table",
747
764
  api_url=None,
@@ -820,10 +837,11 @@ def test_dataset_inspect_cli_writes_remote_manifest(monkeypatch, tmp_path, capsy
820
837
 
821
838
  def _plan(**overrides):
822
839
  data = {
823
- "schema_version": "x4d-update-plan-v2",
840
+ "schema_version": "x4d-update-plan-v3",
824
841
  "generated_at": "2026-06-30T00:00:00Z",
825
842
  "x4d_devkit_version": "0.19.0",
826
843
  "local_manifest": None,
844
+ "materialization_policy": "full",
827
845
  "summary": {},
828
846
  "clips": [
829
847
  {
@@ -848,6 +866,7 @@ def _plan(**overrides):
848
866
  "actions": {
849
867
  "reuse_source_cache": ["clip-a"],
850
868
  "download_source": ["clip-b"],
869
+ "refresh_source_metadata": [],
851
870
  "download_annotation_only": ["clip-a"],
852
871
  "invalidate_mapped_cache": ["clip-a", "clip-b"],
853
872
  "rebuild_infos": True,
@@ -860,9 +879,88 @@ def _plan(**overrides):
860
879
  return data
861
880
 
862
881
 
863
- def test_load_update_plan_rejects_old_v1_contract():
864
- with pytest.raises(ValueError, match="x4d-update-plan-v2"):
865
- load_update_plan({**_plan(), "schema_version": "x4d-update-plan-v1"})
882
+ def _portable_work_session_identity(payload):
883
+ identity = identity_from_work_session(payload).to_dict()
884
+ return {
885
+ key: identity[key]
886
+ for key in (
887
+ "project_id",
888
+ "clip_id",
889
+ "source_revision",
890
+ "calibration_revision",
891
+ "pose_revision",
892
+ "annotation_revision",
893
+ "label_schema",
894
+ "clip_schema_version",
895
+ )
896
+ }
897
+
898
+
899
+ def _write_cache_marker(clip_dir, identity, *, policy="full"):
900
+ clip_dir.mkdir(parents=True, exist_ok=True)
901
+ (clip_dir / ".cache_identity.json").write_text(json.dumps({
902
+ **identity,
903
+ "ready": True,
904
+ "blockers": [],
905
+ "asset_revision": {},
906
+ "materialization_policy": policy,
907
+ "has_sweeps": policy == "full",
908
+ "x4d_devkit_version": "0.23.0",
909
+ "materialized_at": "2026-07-20T00:00:00Z",
910
+ }))
911
+
912
+
913
+ class _FakeStream:
914
+ def __init__(self, payload=b"asset"):
915
+ self.payload = payload
916
+
917
+ def __enter__(self):
918
+ return self
919
+
920
+ def __exit__(self, *args):
921
+ return None
922
+
923
+ def raise_for_status(self):
924
+ return None
925
+
926
+ def iter_bytes(self, chunk_size):
927
+ yield self.payload
928
+
929
+
930
+ class _FakeHTTP:
931
+ def __init__(self):
932
+ self.urls = []
933
+
934
+ def stream(self, method, url):
935
+ self.urls.append((method, url))
936
+ return _FakeStream()
937
+
938
+
939
+ def _sync_plan_for(
940
+ clips,
941
+ *,
942
+ download_source=(),
943
+ refresh_source_metadata=(),
944
+ download_annotation_only=(),
945
+ materialization_policy="full",
946
+ ):
947
+ return _plan(
948
+ clips=list(clips),
949
+ materialization_policy=materialization_policy,
950
+ actions={
951
+ **_plan()["actions"],
952
+ "reuse_source_cache": [],
953
+ "download_source": list(download_source),
954
+ "refresh_source_metadata": list(refresh_source_metadata),
955
+ "download_annotation_only": list(download_annotation_only),
956
+ "invalidate_mapped_cache": [],
957
+ },
958
+ )
959
+
960
+
961
+ def test_load_update_plan_rejects_old_v2_contract():
962
+ with pytest.raises(ValueError, match="x4d-update-plan-v3"):
963
+ load_update_plan({**_plan(), "schema_version": "x4d-update-plan-v2"})
866
964
 
867
965
 
868
966
  def test_load_update_plan_rejects_old_consumer_identity_diff():
@@ -895,21 +993,30 @@ def test_load_update_plan_rejects_removed_schema_fields(field):
895
993
 
896
994
 
897
995
  def test_sync_update_plan_dry_run_reports_actions(tmp_path):
996
+ plan = _plan()
997
+ _write_cache_marker(
998
+ tmp_path / "cache" / "clips" / "clip-a",
999
+ plan["clips"][0]["local_identity"],
1000
+ )
898
1001
  result = sync_update_plan(
899
1002
  None,
900
- plan=_plan(),
1003
+ plan=plan,
901
1004
  data_root=tmp_path / "cache",
902
1005
  dry_run=True,
903
1006
  mapped_cache_dir=tmp_path / "mapped",
904
1007
  )
905
1008
 
906
1009
  assert result["dry_run"] is True
907
- assert result["downloaded_source"] == [{
908
- "clip_id": "clip-b",
909
- "path": str(tmp_path / "cache" / "clips" / "clip-b"),
910
- "dry_run": True,
911
- }]
912
- assert result["downloaded_annotation_only"][0]["clip_id"] == "clip-a"
1010
+ assert [item["clip_id"] for item in result["planned_actions"]] == [
1011
+ "clip-b",
1012
+ "clip-a",
1013
+ ]
1014
+ assert [item["clip_id"] for item in result["actions_to_execute"]] == [
1015
+ "clip-b",
1016
+ "clip-a",
1017
+ ]
1018
+ assert result["executed_actions"] == []
1019
+ assert result["already_satisfied_actions"] == []
913
1020
  assert result["invalidated_mapped_cache"] == [
914
1021
  {"clip_id": "clip-a", "dry_run": True},
915
1022
  {"clip_id": "clip-b", "dry_run": True},
@@ -919,48 +1026,62 @@ def test_sync_update_plan_dry_run_reports_actions(tmp_path):
919
1026
 
920
1027
 
921
1028
  def test_sync_update_plan_blocks_not_ready_by_default(tmp_path):
1029
+ plan = _sync_plan_for([
1030
+ {
1031
+ "bucket": "not_ready",
1032
+ **_diff_record(
1033
+ "not_ready",
1034
+ project_id=1812,
1035
+ clip_id="clip-a",
1036
+ ),
1037
+ "blockers": [{"code": "missing_ego_pose", "message": "ego pose is absent"}],
1038
+ }
1039
+ ])
922
1040
  result = sync_update_plan(
923
1041
  None,
924
- plan=_plan(
925
- clips=[
926
- {
927
- "bucket": "not_ready",
928
- **_diff_record(
929
- "not_ready",
930
- project_id=1812,
931
- clip_id="clip-a",
932
- ),
933
- "blockers": [{"code": "missing_ego_pose", "message": "ego pose is absent"}],
934
- }
935
- ]
936
- ),
1042
+ plan=plan,
937
1043
  data_root=tmp_path,
938
1044
  dry_run=True,
939
1045
  )
940
1046
 
941
- assert result["errors"] == [{
942
- "clip_id": "clip-a",
943
- "code": "not_ready",
944
- "blockers": [{"code": "missing_ego_pose", "message": "ego pose is absent"}],
945
- }]
1047
+ assert result["failures"][0]["code"] == "not_ready"
1048
+ assert result["failures"][0]["details"]["blockers"] == [
1049
+ {"code": "missing_ego_pose", "message": "ego pose is absent"}
1050
+ ]
946
1051
  assert sync_result_exit_code(result) == 20
947
1052
 
948
1053
 
949
- def test_sync_update_plan_writes_annotation_only_tables_and_stale_markers(tmp_path):
1054
+ def test_sync_update_plan_annotation_only_first_apply_and_replay(tmp_path):
1055
+ target_payload = current_work_session_payload(clip_id="clip-a")
1056
+ target_identity = _portable_work_session_identity(target_payload)
1057
+ predecessor = {
1058
+ **target_identity,
1059
+ "annotation_revision": "sha256:" + "1" * 64,
1060
+ }
1061
+ record = {
1062
+ "bucket": "annotation_changed",
1063
+ **_diff_record(
1064
+ "annotation_changed",
1065
+ **target_identity,
1066
+ local_identity=predecessor,
1067
+ ),
1068
+ }
1069
+ plan = _sync_plan_for(
1070
+ [record],
1071
+ download_annotation_only=["clip-a"],
1072
+ )
950
1073
  clip_dir = tmp_path / "cache" / "clips" / "clip-a"
951
- clip_dir.mkdir(parents=True)
1074
+ _write_cache_marker(clip_dir, predecessor)
952
1075
  (clip_dir / "meta.json").write_text("{}\n")
953
1076
 
954
1077
  class WorkSessions:
1078
+ def __init__(self):
1079
+ self.calls = 0
1080
+
955
1081
  def open(self, project_id, clip_id):
956
1082
  assert project_id == 1812
957
- payload = current_work_session_payload(clip_id=clip_id)
958
- payload.update(
959
- source_revision="s2",
960
- calibration_revision="c2",
961
- pose_revision="p2",
962
- )
963
- return payload
1083
+ self.calls += 1
1084
+ return target_payload
964
1085
 
965
1086
  class Client:
966
1087
  def __init__(self):
@@ -968,46 +1089,154 @@ def test_sync_update_plan_writes_annotation_only_tables_and_stale_markers(tmp_pa
968
1089
  self._http = None
969
1090
 
970
1091
  client = Client()
971
- result = sync_update_plan(
1092
+ first = sync_update_plan(
972
1093
  client,
973
- plan=_plan(
974
- clips=[
975
- {
976
- "bucket": "annotation_changed",
977
- **_diff_record(
978
- "annotation_changed",
979
- project_id=1812,
980
- clip_id="clip-a",
981
- ),
982
- }
983
- ],
984
- actions={
985
- **_plan()["actions"],
986
- "download_source": [],
987
- "invalidate_mapped_cache": ["clip-a"],
988
- },
989
- ),
1094
+ plan=plan,
990
1095
  data_root=tmp_path / "cache",
991
- mapped_cache_dir=tmp_path / "mapped",
992
1096
  )
1097
+ replay = sync_update_plan(client, plan=plan, data_root=tmp_path / "cache")
993
1098
 
994
1099
  annotation_document = json.loads((clip_dir / "annotation.json").read_text())
995
1100
  assert annotation_document["annotations"][0]["annotation_token"] == "annotation-0"
996
1101
  assert not (clip_dir / "instance.json").exists()
997
- assert (
998
- json.loads((clip_dir / ".x4d_identity.json").read_text())[
999
- "annotation_revision"
1000
- ]
1001
- == annotation_document["annotation_revision"]
1102
+ assert first["failures"] == []
1103
+ assert first["executed_actions"][0]["action"] == "download_annotation_only"
1104
+ assert replay["executed_actions"] == []
1105
+ assert replay["already_satisfied_actions"][0]["clip_id"] == "clip-a"
1106
+ assert client.clip_work_sessions.calls == 1
1107
+
1108
+
1109
+ def test_sync_update_plan_partial_retry_preserves_completed_clips(tmp_path):
1110
+ payloads = {
1111
+ clip_id: current_work_session_payload(clip_id=clip_id)
1112
+ for clip_id in ("clip-a", "clip-b")
1113
+ }
1114
+ records = [
1115
+ {
1116
+ "bucket": "new_clips",
1117
+ **_diff_record(
1118
+ "new_clips",
1119
+ **_portable_work_session_identity(payloads[clip_id]),
1120
+ ),
1121
+ }
1122
+ for clip_id in ("clip-a", "clip-b")
1123
+ ]
1124
+ plan = _sync_plan_for(
1125
+ records,
1126
+ download_source=["clip-a", "clip-b"],
1002
1127
  )
1003
- assert (tmp_path / "mapped" / "clip-a" / ".x4d_stale.json").exists()
1004
- assert result["errors"] == []
1128
+
1129
+ class WorkSessions:
1130
+ def __init__(self):
1131
+ self.calls = []
1132
+ self.fail_clip_b = True
1133
+
1134
+ def open(self, project_id, clip_id):
1135
+ self.calls.append(clip_id)
1136
+ if clip_id == "clip-b" and self.fail_clip_b:
1137
+ raise RuntimeError("transient open failure")
1138
+ return payloads[clip_id]
1139
+
1140
+ class Client:
1141
+ def __init__(self):
1142
+ self.clip_work_sessions = WorkSessions()
1143
+ self._http = _FakeHTTP()
1144
+
1145
+ client = Client()
1146
+ first = sync_update_plan(client, plan=plan, data_root=tmp_path / "cache", workers=1)
1147
+ first_download_count = len(client._http.urls)
1148
+ client.clip_work_sessions.fail_clip_b = False
1149
+ retry = sync_update_plan(client, plan=plan, data_root=tmp_path / "cache", workers=1)
1150
+ completed_download_count = len(client._http.urls)
1151
+ replay = sync_update_plan(client, plan=plan, data_root=tmp_path / "cache", workers=1)
1152
+
1153
+ assert [item["clip_id"] for item in first["executed_actions"]] == ["clip-a"]
1154
+ assert first["failures"][0]["clip_id"] == "clip-b"
1155
+ assert [item["clip_id"] for item in retry["already_satisfied_actions"]] == ["clip-a"]
1156
+ assert [item["clip_id"] for item in retry["executed_actions"]] == ["clip-b"]
1157
+ assert len(client._http.urls) == first_download_count * 2
1158
+ assert client.clip_work_sessions.calls.count("clip-a") == 1
1159
+ assert replay["executed_actions"] == []
1160
+ assert [item["clip_id"] for item in replay["already_satisfied_actions"]] == [
1161
+ "clip-a",
1162
+ "clip-b",
1163
+ ]
1164
+ assert len(client._http.urls) == completed_download_count
1165
+
1166
+
1167
+ def test_sync_update_plan_rejects_policy_and_local_target_conflicts(tmp_path):
1168
+ plan = _plan()
1169
+ clip_dir = tmp_path / "cache" / "clips" / "clip-a"
1170
+ _write_cache_marker(
1171
+ clip_dir,
1172
+ plan["clips"][0]["local_identity"],
1173
+ policy="keyframes_only",
1174
+ )
1175
+ policy_result = sync_update_plan(
1176
+ None,
1177
+ plan=plan,
1178
+ data_root=tmp_path / "cache",
1179
+ dry_run=True,
1180
+ )
1181
+ assert policy_result["failures"][0]["code"] == "materialization_policy_mismatch"
1182
+
1183
+ conflicting = {
1184
+ **plan["clips"][0]["local_identity"],
1185
+ "annotation_revision": "sha256:" + "2" * 64,
1186
+ }
1187
+ _write_cache_marker(clip_dir, conflicting)
1188
+ conflict_result = sync_update_plan(
1189
+ None,
1190
+ plan=plan,
1191
+ data_root=tmp_path / "cache",
1192
+ dry_run=True,
1193
+ )
1194
+ assert conflict_result["failures"][0]["code"] == "update_plan_target_mismatch"
1195
+
1196
+
1197
+ def test_sync_update_plan_rejects_remote_state_moving_after_review(tmp_path):
1198
+ target_payload = current_work_session_payload(clip_id="clip-a")
1199
+ target_identity = _portable_work_session_identity(target_payload)
1200
+ predecessor = {
1201
+ **target_identity,
1202
+ "annotation_revision": "sha256:" + "1" * 64,
1203
+ }
1204
+ plan = _sync_plan_for(
1205
+ [{
1206
+ "bucket": "annotation_changed",
1207
+ **_diff_record(
1208
+ "annotation_changed",
1209
+ **target_identity,
1210
+ local_identity=predecessor,
1211
+ ),
1212
+ }],
1213
+ download_annotation_only=["clip-a"],
1214
+ )
1215
+ _write_cache_marker(tmp_path / "cache" / "clips" / "clip-a", predecessor)
1216
+ moved = {**target_payload, "source_revision": "source-moved"}
1217
+
1218
+ class WorkSessions:
1219
+ def open(self, project_id, clip_id):
1220
+ return moved
1221
+
1222
+ class Client:
1223
+ clip_work_sessions = WorkSessions()
1224
+ _http = _FakeHTTP()
1225
+
1226
+ result = sync_update_plan(Client(), plan=plan, data_root=tmp_path / "cache")
1227
+ assert result["executed_actions"] == []
1228
+ assert result["failures"][0]["code"] == "remote_update_plan_target_mismatch"
1005
1229
 
1006
1230
 
1007
1231
  def test_dataset_sync_cli_writes_result_and_exits(monkeypatch, tmp_path, capsys):
1232
+ plan_data = _plan()
1008
1233
  plan = tmp_path / "plan.json"
1009
- plan.write_text(json.dumps(_plan()))
1234
+ plan.write_text(json.dumps(plan_data))
1010
1235
  out = tmp_path / "sync_result.json"
1236
+ _write_cache_marker(
1237
+ tmp_path / "cache" / "clips" / "clip-a",
1238
+ plan_data["clips"][0]["local_identity"],
1239
+ )
1011
1240
 
1012
1241
  args = argparse.Namespace(
1013
1242
  plan=str(plan),
@@ -1016,7 +1245,6 @@ def test_dataset_sync_cli_writes_result_and_exits(monkeypatch, tmp_path, capsys)
1016
1245
  dry_run=True,
1017
1246
  allow_not_ready_skip=False,
1018
1247
  mapped_cache_dir=None,
1019
- materialization_policy="full",
1020
1248
  out=str(out),
1021
1249
  format="table",
1022
1250
  api_url=None,
@@ -1028,4 +1256,4 @@ def test_dataset_sync_cli_writes_result_and_exits(monkeypatch, tmp_path, capsys)
1028
1256
 
1029
1257
  assert exc.value.code == 0
1030
1258
  assert "Dataset sync dry-run completed" in capsys.readouterr().out
1031
- assert json.loads(out.read_text())["schema_version"] == "x4d-sync-result-v1"
1259
+ assert json.loads(out.read_text())["schema_version"] == "x4d-sync-result-v2"