完善 O30 标定时钟同步与准备契约校验

This commit is contained in:
lxp
2026-09-20 18:26:41 +08:00
parent 5ee4a3bb7c
commit db4c7af3e7
15 changed files with 543 additions and 13 deletions
@@ -861,8 +861,9 @@ DIP 只完成模拟后续访问的正式采集/父参考回读测试,使用真
观测后,两关节各得到 248 帧。离线反事实回放还使用新 PIP 模型重新投影原 DIP 图像, 观测后,两关节各得到 248 帧。离线反事实回放还使用新 PIP 模型重新投影原 DIP 图像,
验证整条依赖链;不修改原零位或历史日志,不把旧断点直接重新标记为通过。 验证整条依赖链;不修改原零位或历史日志,不把旧断点直接重新标记为通过。
这项优化需要新采集的上游模型。验证整场稳定性使用 `--no-resume`;继续旧断点仍保留 这项优化需要新采集的上游模型。新版会自动排除缺少当前准备契约的旧断点,重新采集
原冻结模型及不确定度,不能承诺仅重试 DIP 就会通过。正式运行命令: 完整准备链;新版本同契约断点仍支持恢复。验证独立整场运行时使用 `--no-resume`。
正式运行命令:
```bash ```bash
ros2 run linkerhand_calibration calibrate_hand \ ros2 run linkerhand_calibration calibrate_hand \
@@ -883,3 +884,27 @@ ros2 run linkerhand_calibration calibrate_hand \
报告和按角色哈希绑定的证据。辅助运动、独立准备、断点与最终验收的重新拟合均读取 报告和按角色哈希绑定的证据。辅助运动、独立准备、断点与最终验收的重新拟合均读取
这项策略;无字段的旧日志继续使用原预算。删除策略、改写策略或使训练/留出帧数超过 这项策略;无字段的旧日志继续使用原预算。删除策略、改写策略或使训练/留出帧数超过
对应预算会拒绝回读,不能让在线与离线分别选择不同的子集。 对应预算会拒绝回读,不能让在线与离线分别选择不同的子集。
## 准备契约与时钟刷新修复(2026-09-20)
`session_start.preparation_contract` 记录实际准备图:单/多关节采样预算、求解参数、
父子来源、零位路径和辅助动作。自动发现断点先比较这份契约,缺失或不同则排除该断点,
不读入大日志后再进入原停点。节点的显式恢复入口同样校验;产物回读还检查声明的新
契约下不存在使用旧采样预算的已接受模型。旧原始记录保持可读、不会被重写。
`resume_selection.json` 保存候选排除原因;显式重新开始的会话阻止回溯更早的旧谱系。
这里采用整场重新准备,避免对冻结训练、共享 Tag 交接和派生角度进行局部替换。
后续同契约恢复仍需基准及逐关节复核。此变更修复新旧模型混用,不代表已实现整手
准备统一联合求解,也不保证所有现场视角都具备可观测性。
相机在通过原连续性检查后,根据实测相位漂移缩短下一次校时的间隔,并扣除当前锚点
已经经历的时间。刷新目标使用原 1 ms 漂移预算的四分之一;周期限制在 20–500 ms。
真正的时钟跳变、计数器复位、过期映射及未来/乱序图像仍按原规则拒绝。
这解决了固定 500 ms 刷新无法适应平滑漂移的机制;三台实机的具体异常仍需新日志验收。
辅助旁观姿态拟合在计算前检查继承误差是否已耗尽原 1°/1 mm 预算。不可行来源记录为
`observer_parent_precision_budget_exhausted`,不会通过重复拟合或降低门限试图挽救。
针对回归、真实像素回放、合成完整 JSON→URDF 链路与只读配置检查均已通过,详见
`software_review_stability_repair/software_validation.json`。本次设备被独立的
`linkerhand_range_calibration` 占用,未中断它,也未执行新的实机标定或发布标定产物。
@@ -0,0 +1,119 @@
# O30 标定稳定性评估(2026-09-20)
当前结论:已有精度拒绝和产物发布边界应保留,但现有逐关节准备、立即冻结、
再向下游传递的观测组织方式,尚不足以支撑稳定的一次整手标定。
不能将局部软件回放通过或某个候选精度达标等同于整手稳定。
后续修复进展:已实现准备契约隔离、发布入口预算核验、按漂移调整相机刷新间隔、
启动锚点年龄扣除及辅助观测继承预算前置检查。详见 `O30_RIGHT_CALIBRATION.md`
末节。下文保留评估时的事实和整体目标;不能将这些局部结构修复视为所有重构或
整手实机稳定验收已完成。当前独立范围标定占用设备,尚无新版实机验收结果。
## 最新实机证据
会话 `calibration_output/O30_RIGHT_001/20260920_175156`,复用 12 个扫描单元后,
在小指 DIP 准备阶段以 `OBS-POSE-AMBIGUOUS-117` 暂停。
完整证据为该目录的 `assistant_resume_review.json`、`node_status.json`、
`raw_samples.jsonl` 第 117841 行及三个 `camera_timing_*.jsonl`。
| 项目 | 实测结果 | 含义 |
| --- | --- | --- |
| DIP 准备图像 | 228 帧,114 训练 / 114 留出 | 增加采样已实际生效 |
| 候选 0 轴向不确定度界 | 2.915°,门限 3° | 合格但裕量很小 |
| 候选 1 轴向不确定度界 | 3.035° | 不能发布,但仍能解释图像 |
| 两候选留出 RMS | 0.350 / 0.395 px | 像素拟合接近,不足以直接选三维解 |
| 校正后的比较 p 值 | 0.09375,要求 ≤ 0.01 | 歧义拒绝符合现有契约 |
| 继承 PIP 轴向不确定度界 | 2.274° | 占用下游大部分精度预算 |
| 旁观辅助观测 | 两次 `observer_image_validation_failed` | 本次没有提供可用的额外约束 |
| SDK | 约 29.8 Hz,无报告故障 | 没有证据把直接停因归为 SDK 故障 |
`axis_uncertainty_95_rad` 是现有字段名;实现使用局部线性化协方差的三倍标准差界,
并保守加入继承项。它不是包含内参、标签系统偏差在内的实测绝对精度保证。
## 为什么此前修复后仍会停
1. **改动没有更新整条依赖链。** 密集采样用于新的 DIP 准备;恢复则保留已冻结的
旧 PIP 模型。参考复核仅确认当前安装/姿态可以复用,不会把旧模型重新训练成新模型。
因而恢复成功和下游可观测并不是同一个条件。
2. **单视角平面标签仍有不同三维解释。** 增加同一运动弧上的帧数可以改善随机误差,
不保证增加区分两个候选所需的信息。本次两候选都收敛,不能靠增加求解次数解决。
也不能删掉略超精度门限、但仍可解释图像的备选候选。
3. **辅助观测路径没有预先核算可行性。** `fit_observer_pose` 将父模型不确定度加入
辅助相对姿态的界,再要求旋转不超过 1°。旧 PIP 已有约 2.27°,沿用这个来源时,
即使像素验收过关也无法满足该界。本次实际首先失败于像素验收,不能把两者混为一项。
4. **准备策略的组合复杂度超过已完成的实机覆盖。** 当前存在共享 Tag 交接、父几何
传递、旁观辅助、侧摆辅助及进入姿态联合准备等路径,部分组合显式不支持。
各路径局部回放通过,不构成它们在整手遮挡、回访和断点恢复中的组合保证。
现有基准锁定、证据哈希、训练/留出隔离、禁止失败发布和有界恢复是合理的。
需要调整的是观测规划、冻结范围、恢复依赖和验收顺序,而不是放宽拒绝条件。
### 最新像素的父模型敏感性实验
重新估计历史 PIP 准备模型,并仅在隔离的深拷贝中重算最新一轮 228 帧 DIP 的父姿态,
按相同门限求解:PIP 继承轴向界由约 2.274° 降为 1.574°,DIP 候选 0 的轴向界
为 2.213°,校正 p 值为 0.002227,离线解析成功。相较原实机的 2.915° / 0.09375,
这支持“旧父模型是本次主要限制,必须连同依赖链更新”的判断。
该实验使用历史 PIP 图像和最新 DIP 图像,没有实机重采,也没有重新完成当前父姿态
准入。脚本显式使用离线准入标记,未改变原始记录,不允许发布。
不能据此声称实机下次必过,更不能把重新计算的父模型注入旧正式产物。
结果和复现脚本存于 `software_review_stability_architecture/latest_parent_counterfactual.json`
及同目录 `parent_counterfactual.py`。脚本建立来源注册表时曾两次缺少依赖而被正式入口
拒绝,补齐历史 MCP/PIP 来源后才完成实验;拒绝记录保存在该目录的验证摘要中。
## 独立存在的时钟问题
约 374 秒内,front / side / top 分别出现 33 / 31 / 27 次时钟失效并重同步;
曝光时间序列的最大图像间隔分别约 372 / 338 / 371 ms。
这不是此次歧义判定的直接错误码,但会削弱采集连续性,必须单独消除。
三相机的主机时间与设备计时差在该轮累计同向变化约 201–203 ms,提示应优先检查
共用的主机时间基础。另做 30 秒只读探测,墙钟相对 `CLOCK_MONOTONIC_RAW` 变化
约 -13.9 ms;主机启用了时间同步。这些现象不足以证明历史失效就是 NTP 导致,
也不能据此直接关时间同步或扩大同步容差。
旧日志仅保存成功的锁存和失效原因,没有保存触发拒绝的锁存事务,缺少定位证据。
本次补充了两个独立时钟的事务边界、被拒绝的原始锁存和旧锚点,拒绝时立即落盘。
曝光时间映射、2 ms 锁存界、时钟连续性界和图像新鲜度界均未修改。
对应相机时序测试 12 项通过,耗时 0.33 秒。这是诊断能力修复,不是时钟故障已解决。
## 应采用的流程和实施顺序
1. **先验收基础设施。** 固定本次控制进程的 ROS 发现范围;确保同一设备只有一个
控制者。三相机进行覆盖预期标定时长的连续采集检查,以原始双时钟证据定位并修复
同步异常。ROS 图隔离不能替代物理设备独占检查。
2. **正式训练前完成整条手指的观测准备。** 每条依赖链先检查可见性、运动覆盖、
候选可区分性和完整精度预算,再整体接受准备模型。父模型应有经过数据论证的
下游预算,不能只按自身 3° 门限提前冻结。
3. **独立信息不足时改变预先声明的观测设计。** 优先评估保持其他关节约束的不同
上游姿态,必要时采用经过验证的跨视角共同观测。额外动作需纳入运动计划、
原始证据和独立留出;不得观察留出结果后反复挑方案直到通过。
三台相机存在并不代表当前求解已使用多视角。本轮预检中光学内参和相对外参的
实测状态均为 `unverified`,跨视角方案必须先补足对应验证。
4. **按依赖链管理准备版本和断点。** 准备策略升级或父模型需要更新时,在新会话中
明确重建受影响的父子准备模型及其派生数据;保留无依赖的数据和历史原件。
不得将新父模型与旧角度、旧零位或旧发布证据直接拼接。完全冻结后的训练版本
不能在正式留出失败后局部重训并继续沿用同一留出验收。
5. **统一有限恢复。** 对短暂缺帧执行有界、可回放的局部恢复;基准变化、设备故障、
真正不可观测或证据损坏仍应终止/暂停。恢复必须由具体证据触发,同一信息不足的
运动不能无限重走。启动索引和重求解保持在实时控制回调之外。
6. **最后验证完整交付。** 连续完成整手训练与独立验收,验证 JSON 的证据、数值、
单位和关节覆盖,从该 JSON 重建修正 URDF 并回读核验,最后才更新发布指针。
末节 CAD 零位保留假设必须继续在产物中明确,不能称所有零位均已独立实测。
以上是结构调整的实施要求,并非这些重构已经完成。禁止用新增重试、降低门限或
跳过候选比较来替代观测设计。
## 稳定性的验收定义
- 故障回放覆盖断帧、时钟跳变、SDK 失联、参考移动、候选歧义和恢复版本不兼容;
暂态在规定预算内恢复,其余明确拒绝,均无错误发布。
- 固定代码、配置和现场布置后,至少连续三次完整实机运行无需人工介入,均完成独立
验收并生成可回读的 JSON/URDF,同时报告关节结果重复性。三次是工程验收建议,
不是数学上的成功率证明,也未改写当前产品 `required_independent_passes: 1`。
- 单独验证正常中断后的断点恢复与一次完整运行结果一致;不能仅验证复用单元数量。
- 保留每次失败和软件版本;不能只汇总成功运行或把反事实重放计为实机通过。
当前尚未达到上述验收。本轮没有发布标定 JSON 或修正 URDF,不能宣称流程稳定或
保证下一次一次通过。
+18
View File
@@ -1,5 +1,23 @@
# 标定测试的执行范围 # 标定测试的执行范围
准备契约升级用 `test_preparation_contract.py`,覆盖新旧断点分离、父子/预算/动作
变化、历史记录保留、原会话边界、节点写入和发布入口禁止旧模型冒充新预算。
时钟自适应刷新用 `test_camera_timing.py`,正负平滑漂移在模拟 30 Hz 的 60 秒内
曝光时间误差保持小于原 1 ms 连续性预算,同时验证真跳变仍拒绝及启动锚点年龄。
辅助精度预算前置用 `test_observer_pose.py` 和 `test_observer_reference.py`。
此次增量验证分组记录在 `software_review_stability_repair`:初始针对组 79 项、8.84 秒;
证据/真实 PIP-DIP 回放/runner/合成完整产物组 91 项、58.99 秒;修正启动锚点刷新
时机后的相机组 15 项、0.47 秒。组间有重复用例,不将累计次数称为一次全量回归。
实际目录的断点选择只读验证约 17 ms,排除了旧准备契约;配置 validate-only 通过。
这些结果不替代新版本的连续实机验收。
相机时钟诊断变更使用 `test_camera_timing.py`:同时覆盖原始计时单位、跳变、陈旧帧、
重同步顺序及拒绝事务可重放。新增 `CLOCK_MONOTONIC_RAW` 仅记录诊断边界,不能
替换 ROS 时间或放宽同步门限。2026-09-20 针对组 12 项通过,0.33 秒,记录于
`calibration_output/O30_RIGHT_001/software_review_stability_architecture/camera_timing_tests.json`。
这不表示实机重复失同步已经修复;整体评估见 `O30_STABILITY_ASSESSMENT_20260920.md`。
日常修改运行受影响用例和必要契约用例,目标为 60 秒内完成。不要因为修改了一行代码就运行 日常修改运行受影响用例和必要契约用例,目标为 60 秒内完成。不要因为修改了一行代码就运行
整手拟合或全量 `colcon test`。公共产物契约变更需要覆盖相关型号,发布前再执行全量回归。 整手拟合或全量 `colcon test`。公共产物契约变更需要覆盖相关型号,发布前再执行全量回归。
@@ -13,6 +13,7 @@ import time
CAMERA_TIMING_POLICY = "mvs_latched_exposure_time_v1" CAMERA_TIMING_POLICY = "mvs_latched_exposure_time_v1"
CLOCK_DRIFT_TOLERANCE_NS = 1_000_000
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -20,6 +21,8 @@ class ClockLatch:
device_tick: int device_tick: int
before_ns: int before_ns: int
after_ns: int after_ns: int
raw_before_ns: int | None = None
raw_after_ns: int | None = None
@property @property
def midpoint_ns(self): def midpoint_ns(self):
@@ -45,6 +48,7 @@ class DeviceFrameClock:
self.anchor = None self.anchor = None
self.last_frame_tick = None self.last_frame_tick = None
self.last_stamp_ns = None self.last_stamp_ns = None
self.refresh_interval_seconds = .5
def elapsed_ns(self, ticks): def elapsed_ns(self, ticks):
return round(ticks*1_000_000_000/self.ticks_per_second) return round(ticks*1_000_000_000/self.ticks_per_second)
@@ -59,9 +63,19 @@ class DeviceFrameClock:
for current in latches: for current in latches:
elapsed = self.elapsed_ns(current.device_tick-previous.device_tick) elapsed = self.elapsed_ns(current.device_tick-previous.device_tick)
host_elapsed = current.midpoint_ns-previous.midpoint_ns host_elapsed = current.midpoint_ns-previous.midpoint_ns
tolerance = current.half_width_ns+previous.half_width_ns+1_000_000 tolerance = current.half_width_ns+previous.half_width_ns+CLOCK_DRIFT_TOLERANCE_NS
if elapsed < 0 or abs(elapsed-host_elapsed) > tolerance: if elapsed < 0 or abs(elapsed-host_elapsed) > tolerance:
raise ValueError("camera/host clock discontinuity or counter unit mismatch") raise ValueError("camera/host clock discontinuity or counter unit mismatch")
# Refresh before measured phase drift spends the existing 1 ms
# continuity allowance. A fixed 0.5 s period repeatedly invalidates a
# smooth slewing host clock. Scheduling changes; admission never does.
current = max(latches, key=lambda x: x.midpoint_ns)
host_elapsed = current.midpoint_ns-previous.midpoint_ns
if host_elapsed >= 50_000_000:
drift = abs(host_elapsed-self.elapsed_ns(current.device_tick-previous.device_tick))
rate = drift/host_elapsed
self.refresh_interval_seconds = max(.02, min(.5,
.25*CLOCK_DRIFT_TOLERANCE_NS/1e9/max(rate, 1e-12)))
self.anchor = min(latches, key=lambda x: x.half_width_ns) self.anchor = min(latches, key=lambda x: x.half_width_ns)
def timestamp(self, tick, *, received_ns, exposure_us): def timestamp(self, tick, *, received_ns, exposure_us):
@@ -80,8 +94,11 @@ class DeviceFrameClock:
class MvsCameraTiming: class MvsCameraTiming:
"""The camera-specific clock latch and its optional append-only journal.""" """The camera-specific clock latch and its optional append-only journal."""
def __init__(self, camera, mvs, clock_ns, *, exposure_us, journal_path=""): def __init__(self, camera, mvs, clock_ns, *, exposure_us, journal_path="", raw_clock_ns=None):
self.camera, self.mvs, self.clock_ns = camera, mvs, clock_ns self.camera, self.mvs, self.clock_ns = camera, mvs, clock_ns
# CLOCK_MONOTONIC_RAW is independent of wall-clock synchronization.
# It is diagnostic evidence only, never a replacement ROS timestamp.
self.raw_clock_ns = raw_clock_ns or (lambda: time.clock_gettime_ns(time.CLOCK_MONOTONIC_RAW))
self.exposure_us = exposure_us self.exposure_us = exposure_us
self.frame_clock = None self.frame_clock = None
self.last_stamp_ns = None self.last_stamp_ns = None
@@ -100,12 +117,14 @@ class MvsCameraTiming:
return int(value.nCurValue) return int(value.nCurValue)
def _latch(self): def _latch(self):
raw_before = self.raw_clock_ns()
before = self.clock_ns() before = self.clock_ns()
status = self.camera.MV_CC_SetCommandValue("DeviceTimestampLatch") status = self.camera.MV_CC_SetCommandValue("DeviceTimestampLatch")
if status != 0: if status != 0:
raise ValueError(f"camera clock latch failed:0x{status:08x}") raise ValueError(f"camera clock latch failed:0x{status:08x}")
tick = self._integer("DeviceTimestamp") tick = self._integer("DeviceTimestamp")
return ClockLatch(tick, before, self.clock_ns()) after = self.clock_ns()
return ClockLatch(tick, before, after, raw_before, self.raw_clock_ns())
def _write(self, row): def _write(self, row):
if self.journal is not None: if self.journal is not None:
@@ -135,13 +154,29 @@ class MvsCameraTiming:
self._write(dict(kind="camera_clock_latch_rejected", latches=[asdict(x) for x in latches])) self._write(dict(kind="camera_clock_latch_rejected", latches=[asdict(x) for x in latches]))
self.next_refresh = time.monotonic()+.02 self.next_refresh = time.monotonic()+.02
return return
self.frame_clock.update(valid) try:
self.frame_clock.update(valid)
except ValueError as error:
# Invalidation alone loses the transaction that failed. Preserve
# both clocks and the old anchor so drift, jumps and bad device
# counters can be distinguished without weakening the check.
self._write(dict(kind="camera_clock_sync_rejected", policy=CAMERA_TIMING_POLICY,
reason=str(error), ticks_per_second=self.frame_clock.ticks_per_second,
previous_anchor=asdict(self.frame_clock.anchor) if self.frame_clock.anchor else None,
latches=[asdict(x) for x in latches]))
if self.journal is not None:
self.journal.flush()
raise
self._write(dict(kind="camera_clock_sync", policy=CAMERA_TIMING_POLICY, self._write(dict(kind="camera_clock_sync", policy=CAMERA_TIMING_POLICY,
ticks_per_second=self.frame_clock.ticks_per_second, ticks_per_second=self.frame_clock.ticks_per_second,
refresh_interval_seconds=self.frame_clock.refresh_interval_seconds,
latches=[asdict(x) for x in latches], anchor=asdict(self.frame_clock.anchor))) latches=[asdict(x) for x in latches], anchor=asdict(self.frame_clock.anchor)))
if self.journal is not None: if self.journal is not None:
self.journal.flush() self.journal.flush()
self.next_refresh = time.monotonic()+.5 # Startup may select a precise latch near the beginning of its window.
# That anchor has already spent part of the refresh budget.
anchor_age = (max(x.after_ns for x in latches)-self.frame_clock.anchor.midpoint_ns)/1e9
self.next_refresh = time.monotonic()+max(0., self.frame_clock.refresh_interval_seconds-anchor_age)
def refresh_if_due(self): def refresh_if_due(self):
if self.frame_clock is None: if self.frame_clock is None:
@@ -16,6 +16,17 @@ from .ippe import square_object_points
from .relative import _relative_pose from .relative import _relative_pose
OBSERVER_POSE_LIMITS = (math.radians(1), .001)
def require_observer_precision_budget(inherited):
"""A parent alone must not exhaust the observer's final precision budget."""
if (len(inherited) != 2 or any(not math.isfinite(v) or v < 0 for v in inherited)):
raise ValueError('observer_parent_uncertainty_invalid')
if any(value >= limit for value, limit in zip(inherited, OBSERVER_POSE_LIMITS)):
raise ValueError('observer_parent_precision_budget_exhausted')
@dataclass(frozen=True) @dataclass(frozen=True)
class ObserverPose: class ObserverPose:
quaternion_xyzw: tuple quaternion_xyzw: tuple
@@ -79,6 +90,7 @@ def _relative_covariance(fit, indices, parents, parent_corners, parent_size, chi
def fit_observer_pose(parents, candidates, corners, matrices, size, stamps, *, inherited=(0., 0.), def fit_observer_pose(parents, candidates, corners, matrices, size, stamps, *, inherited=(0., 0.),
parent_corners=None, parent_size=None): parent_corners=None, parent_size=None):
require_observer_precision_budget(inherited)
count = len(stamps) count = len(stamps)
if count < 24 or tuple(stamps) != tuple(sorted(set(stamps))) or any(not c for c in candidates): if count < 24 or tuple(stamps) != tuple(sorted(set(stamps))) or any(not c for c in candidates):
raise ValueError("observer_independent_images_missing") raise ValueError("observer_independent_images_missing")
@@ -142,7 +154,7 @@ def fit_observer_pose(parents, candidates, corners, matrices, size, stamps, *, i
points, matrices, variance) points, matrices, variance)
bounds = [float(3*np.sqrt(max(0., np.linalg.eigvalsh(covariance[i:i+3, i:i+3])[-1]))) bounds = [float(3*np.sqrt(max(0., np.linalg.eigvalsh(covariance[i:i+3, i:i+3])[-1])))
+ inherited[i//3] for i in (0, 3)] + inherited[i//3] for i in (0, 3)]
if not all(math.isfinite(v) for v in bounds) or bounds[0] > math.radians(1) or bounds[1] > .001: if not all(math.isfinite(v) for v in bounds) or any(v > limit for v, limit in zip(bounds, OBSERVER_POSE_LIMITS)):
raise ValueError("observer_pose_uncertain") raise ValueError("observer_pose_uncertain")
return ObserverPose(tuple(Rotation.from_rotvec(winner.x[:3]).as_quat()), tuple(winner.x[3:]), return ObserverPose(tuple(Rotation.from_rotvec(winner.x[:3]).as_quat()), tuple(winner.x[3:]),
*bounds, tuple(stamps[i] for i in training), tuple(stamps[i] for i in validation), adjusted) *bounds, tuple(stamps[i] for i in training), tuple(stamps[i] for i in validation), adjusted)
@@ -1,5 +1,6 @@
"""Explicit, bounded pose excitation at each task's existing clearance pose.""" """Explicit, bounded pose excitation at each task's existing clearance pose."""
from dataclasses import replace from dataclasses import asdict, replace
import json
from ..core.domain.profile import ModelPreparation from ..core.domain.profile import ModelPreparation
@@ -21,6 +22,54 @@ def preparation_image_budget(profile, *, joint_count=1):
else (128, image_parameters_for_sampling())) else (128, image_parameters_for_sampling()))
def preparation_contract(profile):
"""Version the entire preparation graph independently of capture cycles.
A strategy upgrade changes upstream geometry as well as downstream image
budgets. Old captures remain readable, but cannot supply frozen models to
a new preparation graph. Legacy strategies keep their existing contract.
"""
if not profile.motion.preparation_witnesses:
return None
tasks = {}
for task in profile.motion.tasks:
intervals, parameters = preparation_image_budget(profile, joint_count=len(task.joints))
tasks[task.key] = dict(joints=list(task.joints), command_intervals=intervals,
image_parameters=asdict(parameters),
parent_reference=asdict(task.parent_reference) if task.parent_reference else None)
value = dict(version='dependency_bound_preparation_v1', tasks=tasks,
zero_references={name: asdict(spec) for name, spec in profile.motion.joint_zero_references.items()},
witnesses={name: asdict(spec) for name, spec in profile.motion.preparation_witnesses.items()})
# The live header and decoded JSON must compare identically (including
# tuple-valued poses), without relying on incidental Python container types.
return json.loads(json.dumps(value, allow_nan=False))
def validate_preparation_contract(profile, records):
"""Audit a declared graph against both its profile and accepted models.
Older captures remain evaluable under their original per-model policies.
A new header must not advertise the new graph while importing old budgets.
"""
from ..core.geometry.tag_pose.production_image_motion import image_parameters_for_sampling
declared = [row['preparation_contract'] for row in records
if row.get('kind') == 'session_start' and 'preparation_contract' in row]
if not declared:
return
if len(declared) != 1 or declared[0] != preparation_contract(profile):
raise ValueError('preparation_contract_profile_changed')
tasks = {task.key: set(task.joints) for task in profile.motion.tasks}
for row in records:
if row.get('kind') != 'motion_branch_initialization' or row.get('status') != 'resolved':
continue
joints = row.get('zero_joints', ())
if not joints or not set(joints) <= tasks.get(row.get('task_name'), set()):
raise ValueError('preparation_contract_model_dependency_changed')
_, expected = preparation_image_budget(profile, joint_count=len(joints))
if image_parameters_for_sampling(row.get('image_sampling_policy')) != expected:
raise ValueError('preparation_contract_model_budget_changed')
def configure_preparation(profile, policy): def configure_preparation(profile, policy):
if policy != 'occlusion_aware_witness_v1': if policy != 'occlusion_aware_witness_v1':
raise ValueError('unknown_preparation_policy') raise ValueError('unknown_preparation_policy')
@@ -182,6 +182,10 @@ class CalibrationCoordinator:
"source": "sdk_target_position_register" if parameters.initial_command else "adapter_feedback"}, "source": "sdk_target_position_register" if parameters.initial_command else "adapter_feedback"},
**({"diagnostic_capture": self.diagnostic_capture.as_dict()} if self.diagnostic_capture else {}), **({"diagnostic_capture": self.diagnostic_capture.as_dict()} if self.diagnostic_capture else {}),
} }
from ..profiles.preparation import preparation_contract
preparation = preparation_contract(profile)
if preparation is not None:
self._session_header["preparation_contract"] = preparation
if profile.acquisition.command_capture_mode == "separate" and self.diagnostic_capture is None: if profile.acquisition.command_capture_mode == "separate" and self.diagnostic_capture is None:
from ..core.fitting.command_sampling import SAMPLING_POLICY from ..core.fitting.command_sampling import SAMPLING_POLICY
self._session_header["command_sampling_policy"] = SAMPLING_POLICY self._session_header["command_sampling_policy"] = SAMPLING_POLICY
@@ -299,7 +299,9 @@ class CalibrationEngine:
) )
def resume_compatible(self, session_start: Mapping[str, Any]) -> bool: def resume_compatible(self, session_start: Mapping[str, Any]) -> bool:
from ..profiles.preparation import preparation_contract
return (self.capture_compatible(session_start) return (self.capture_compatible(session_start)
and session_start.get("preparation_contract") == preparation_contract(self.profile)
and session_start.get("capture_schedule_version") == capture_schedule_version(self.profile)) and session_start.get("capture_schedule_version") == capture_schedule_version(self.profile))
@@ -343,6 +343,8 @@ def validate_motion_provenance(profile, records, *, required=False, require_comp
The explicit ``required`` flag is set by the production owner. Independent The explicit ``required`` flag is set by the production owner. Independent
record markers preserve this requirement after a header is removed. record markers preserve this requirement after a header is removed.
""" """
from ..profiles.preparation import validate_preparation_contract
validate_preparation_contract(profile, records)
if not required and not uses_motion_evidence(records): if not required and not uses_motion_evidence(records):
return {"status": "legacy_image_confirmation", "motion_evidence_verified": False} return {"status": "legacy_image_confirmation", "motion_evidence_verified": False}
from .visual_motion import validate_visual_motion_evidence from .visual_motion import validate_visual_motion_evidence
@@ -6,7 +6,7 @@ import numpy as np
from ..core.geometry.tag_pose.motion_evidence import MotionRelation from ..core.geometry.tag_pose.motion_evidence import MotionRelation
from ..core.geometry.tag_pose.pose_bridge import SharedTagPoseBridge from ..core.geometry.tag_pose.pose_bridge import SharedTagPoseBridge
from ..core.geometry.tag_pose.observer_pose import fit_observer_pose from ..core.geometry.tag_pose.observer_pose import fit_observer_pose, require_observer_precision_budget
from ..core.geometry.tag_pose.image_motion_projection import select_image_motion_frame from ..core.geometry.tag_pose.image_motion_projection import select_image_motion_frame
from .branch_initialization import _pose from .branch_initialization import _pose
from .shared_tag_reference import bridge_frames from .shared_tag_reference import bridge_frames
@@ -37,6 +37,9 @@ def build_observer_bridge(profile, source):
rows, held = source.rows, source.held_channels rows, held = source.rows, source.held_channels
if len(parent_model.geometry) != 1 or parent_model.geometry[0].relation.child_role != spec.parent_role: if len(parent_model.geometry) != 1 or parent_model.geometry[0].relation.child_role != spec.parent_role:
raise ValueError('observer_parent_model_invalid') raise ValueError('observer_parent_model_invalid')
uncertainty = {name: (angle, point) for name, angle, point in source.parent_report['geometry_uncertainty']}
inherited = uncertainty[parent_model.geometry[0].relation.joint]
require_observer_precision_budget(inherited)
target = profile.motion.joint_zero_references[source.joint].command target = profile.motion.joint_zero_references[source.joint].command
minimum = max(10, profile.acquisition.fixed_reference_minimum_frames) minimum = max(10, profile.acquisition.fixed_reference_minimum_frames)
endpoints = source.endpoint_rows[-minimum:] endpoints = source.endpoint_rows[-minimum:]
@@ -77,8 +80,6 @@ def build_observer_bridge(profile, source):
matrices.append(row['camera_matrix']) matrices.append(row['camera_matrix'])
if len(sizes) != 1: if len(sizes) != 1:
raise ValueError('observer_tag_size_changed') raise ValueError('observer_tag_size_changed')
uncertainty = {name: (angle, point) for name, angle, point in source.parent_report['geometry_uncertainty']}
inherited = uncertainty[parent_model.geometry[0].relation.joint]
pose = fit_observer_pose(parents, candidates, corners, matrices, sizes.pop(), pose = fit_observer_pose(parents, candidates, corners, matrices, sizes.pop(),
tuple(r['image_stamp_ns'] for r in rows), inherited=inherited, tuple(r['image_stamp_ns'] for r in rows), inherited=inherited,
parent_corners=[r['tags'][spec.parent_role]['corners_xy'] for r in rows], parent_corners=[r['tags'][spec.parent_role]['corners_xy'] for r in rows],
@@ -45,6 +45,8 @@ def discover_resume_candidate(
session_root: Path, *, profile_id: str, serial_number: str, session_root: Path, *, profile_id: str, serial_number: str,
protected_hashes: Mapping[str, str], protected_hashes: Mapping[str, str],
training_policy: str = "fixed", training_policy: str = "fixed",
preparation_contract: Mapping[str, Any] | None = None,
rejected_candidates: list | None = None,
) -> Path | None: ) -> Path | None:
"""Locate evidence, never authorize its reuse or publish from a checkpoint. """Locate evidence, never authorize its reuse or publish from a checkpoint.
@@ -99,6 +101,8 @@ def discover_resume_candidate(
raise ValueError("checkpoint training policy differs") raise ValueError("checkpoint training policy differs")
if not _compatible_header(start, profile_id, serial_number, root.name, protected_hashes): if not _compatible_header(start, profile_id, serial_number, root.name, protected_hashes):
raise ValueError("checkpoint header incompatible") raise ValueError("checkpoint header incompatible")
if start.get("preparation_contract") != preparation_contract:
raise ValueError("checkpoint preparation contract changed")
if kind == "fixed_base_reference_locked": if kind == "fixed_base_reference_locked":
reference_found = True reference_found = True
if kind == "scan_unit_complete" or kind.endswith("sweep_observation_quality"): if kind == "scan_unit_complete" or kind.endswith("sweep_observation_quality"):
@@ -116,7 +120,13 @@ def discover_resume_candidate(
previous = completed.get(identity) previous = completed.get(identity)
if previous is None or attempt >= previous[0]: if previous is None or attempt >= previous[0]:
completed[identity] = (attempt, passed) completed[identity] = (attempt, passed)
except (OSError, ValueError): except (OSError, ValueError) as error:
if rejected_candidates is not None:
rejected_candidates.append(dict(session=candidate.name, reason=str(error)))
# An explicit fresh run is a lineage boundary even if its header
# cannot be reused. Do not resurrect evidence from before it.
if start is not None and start.get("resume_checkpoint_requested") is False:
break
continue continue
if start is None or not reference_found or not completed_found: if start is None or not reference_found or not completed_found:
continue continue
@@ -141,11 +141,21 @@ def run_online(
atomic_write_json(session/"measurement_contract.json", contract_report) atomic_write_json(session/"measurement_contract.json", contract_report)
if profile.artifacts.output_schema_version >= 3 and commands_enabled and diagnostic is None: if profile.artifacts.output_schema_version >= 3 and commands_enabled and diagnostic is None:
require_release_geometry(contract_report) require_release_geometry(contract_report)
from ..profiles.preparation import preparation_contract
rejected_checkpoints = []
preparation = preparation_contract(profile)
resume = discover_resume_candidate( resume = discover_resume_candidate(
config.session_root, profile_id=profile.key.profile_id, config.session_root, profile_id=profile.key.profile_id,
serial_number=config.serial_number, protected_hashes=protected_inputs(config), serial_number=config.serial_number, protected_hashes=protected_inputs(config),
training_policy=profile.quality.training_policy, training_policy=profile.quality.training_policy,
preparation_contract=preparation, rejected_candidates=rejected_checkpoints,
) if allow_resume and commands_enabled and diagnostic is None else None ) if allow_resume and commands_enabled and diagnostic is None else None
atomic_write_json(session/"resume_selection.json", dict(
selected_session=resume.name if resume else None,
preparation_contract=preparation, rejected_candidates=rejected_checkpoints))
if any(row['reason'] == 'checkpoint preparation contract changed' for row in rejected_checkpoints):
print("旧断点的准备策略或父子模型预算与当前版本不同,已排除复用;"
+ ("将使用兼容断点。" if resume else "本轮重新采集整条准备链,历史记录保留。"), flush=True)
if contract_report["cad_constraint_conflicts"]: if contract_report["cad_constraint_conflicts"]:
print("原始 CAD 存在限位/mimic 冲突,未修改原文件;详见 measurement_contract.json。最终范围验证不通过时禁止发布。", flush=True) print("原始 CAD 存在限位/mimic 冲突,未修改原文件;详见 measurement_contract.json。最终范围验证不通过时禁止发布。", flush=True)
log_path = session / "calibration.log" log_path = session / "calibration.log"
@@ -67,6 +67,85 @@ def test_uncertain_control_transaction_does_not_refresh_the_clock():
assert timing.frame_clock.anchor == latch(600_000_000) assert timing.frame_clock.anchor == latch(600_000_000)
def test_rejected_sync_preserves_failed_transaction_and_previous_anchor(tmp_path):
path = tmp_path/'clock.jsonl'
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000., journal_path=path)
timing.frame_clock = initialized()
anchor = timing.frame_clock.anchor
jumped = latch(500_000_000, host_jump=10_000_000)
with pytest.raises(ValueError, match='discontinuity'):
timing._update([jumped])
assert timing.frame_clock.anchor == anchor
row = json.loads(path.read_text()) # Evidence is flushed before invalidation.
assert row['kind'] == 'camera_clock_sync_rejected'
assert ClockLatch(**row['previous_anchor']) == anchor
assert [ClockLatch(**x) for x in row['latches']] == [jumped]
replay = DeviceFrameClock(row['ticks_per_second'])
replay.anchor = ClockLatch(**row['previous_anchor'])
with pytest.raises(ValueError, match='discontinuity'):
replay.update([ClockLatch(**x) for x in row['latches']])
timing.close()
def test_raw_clock_brackets_wall_clock_without_replacing_it(monkeypatch):
wall = iter((T0, T0+100_000))
raw = iter((20_000, 130_000))
camera = SimpleNamespace(MV_CC_SetCommandValue=lambda name: 0)
timing = MvsCameraTiming(camera, None, lambda: next(wall), exposure_us=5000.,
raw_clock_ns=lambda: next(raw))
monkeypatch.setattr(timing, '_integer', lambda name: 123456)
measured = timing._latch()
assert measured == ClockLatch(123456, T0, T0+100_000, 20_000, 130_000)
assert measured.midpoint_ns == T0+50_000
assert measured.half_width_ns == 50_000
@pytest.mark.parametrize('slew', [-.005, .005])
def test_measured_drift_refreshes_before_continuity_budget_is_spent(monkeypatch, slew):
from dataclasses import replace
def slewed(elapsed_ns):
return latch(elapsed_ns, width=100_000, host_jump=round(elapsed_ns*slew))
# At the former fixed interval smooth slew fails the unchanged gate.
fixed = DeviceFrameClock(100_000_000)
fixed.update([slewed(0), slewed(100_000_000)])
with pytest.raises(ValueError, match='discontinuity'):
fixed.update([slewed(600_000_000)])
now = [.1]
monkeypatch.setattr('linkerhand_calibration.camera_timing.time.monotonic', lambda: now[0])
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
timing.frame_clock = DeviceFrameClock(100_000_000)
timing._update([slewed(0), slewed(100_000_000)])
monkeypatch.setattr(timing, '_latch', lambda: slewed(round(now[0]*1e9)))
maximum_error = 0
for index in range(1, 1801): # 60 seconds at the real camera's 30 Hz.
now[0] = .1+index/30
timing.refresh_if_due()
elapsed = round((now[0]-.016)*1e9)
stamp = timing.frame_clock.timestamp(latch(elapsed).device_tick,
received_ns=slewed(round(now[0]*1e9)).midpoint_ns, exposure_us=5000.)
expected = T0+round((elapsed+2_500_000)*(1+slew))
maximum_error = max(maximum_error, abs(stamp-expected))
assert maximum_error < 1_000_000
assert .02 <= timing.frame_clock.refresh_interval_seconds < .1
# A discontinuity is still rejected, including after successful adaptation.
anchor = timing.frame_clock.anchor
bad = slewed(round((now[0]+.04)*1e9))
with pytest.raises(ValueError, match='discontinuity'):
timing._update([replace(bad, before_ns=bad.before_ns+10_000_000, after_ns=bad.after_ns+10_000_000)])
assert timing.frame_clock.anchor == anchor
def test_startup_anchor_age_is_charged_to_refresh_budget(monkeypatch):
monkeypatch.setattr('linkerhand_calibration.camera_timing.time.monotonic', lambda: 10.)
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
timing.frame_clock = DeviceFrameClock(100_000_000)
initial = [latch(i*20_000_000, width=100_000 if i == 0 else 200_000,
host_jump=i*100_000) for i in range(8)]
timing._update(initial)
assert timing.frame_clock.anchor == initial[0]
assert timing.next_refresh == 10. # Renew before acquiring the next frame.
@pytest.mark.parametrize('host_jump', [0, 10_000_000, -1_000_000_000]) @pytest.mark.parametrize('host_jump', [0, 10_000_000, -1_000_000_000])
def test_recovery_revalidates_clock_and_preserves_frame_time_order(monkeypatch, host_jump): def test_recovery_revalidates_clock_and_preserves_frame_time_order(monkeypatch, host_jump):
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.) timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
@@ -41,6 +41,14 @@ def test_all_legal_candidates_use_fixed_split_and_propagate_parent_uncertainty(o
assert result.training_stamps == tuple(range(1, 49, 2)) assert result.training_stamps == tuple(range(1, 49, 2))
@pytest.mark.parametrize('inherited', [(math.radians(2.274), .0005), (0., .001)])
def test_exhausted_parent_budget_is_rejected_before_fitting(observer_images, monkeypatch, inherited):
monkeypatch.setattr('linkerhand_calibration.core.geometry.tag_pose.observer_pose.least_squares',
lambda *a, **kw: pytest.fail('fitting cannot recover spent parent precision'))
with pytest.raises(ValueError, match='parent_precision_budget_exhausted'):
fit_observer_pose(*observer_images, inherited=inherited)
@pytest.mark.parametrize('fault', ['missing', 'moving', 'installation', 'unobservable', 'uncertain_parent']) @pytest.mark.parametrize('fault', ['missing', 'moving', 'installation', 'unobservable', 'uncertain_parent'])
def test_invalid_observer_evidence_cannot_authorize_a_pose(observer_images, fault): def test_invalid_observer_evidence_cannot_authorize_a_pose(observer_images, fault):
parents, candidates, corners, matrices, size, stamps = observer_images parents, candidates, corners, matrices, size, stamps = observer_images
@@ -0,0 +1,156 @@
"""Preparation upgrades cannot silently reuse frozen upstream geometry."""
from copy import deepcopy
from dataclasses import replace
import json
import pytest
from linkerhand_calibration.core.domain.capture_plan import STAGED, configure_training
from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION
from linkerhand_calibration.profiles import load_bundled_hand_profile
from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_contract
from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CalibrationEngine
from linkerhand_calibration.runtime.joint_resume import PreparedResume
from linkerhand_calibration.runtime.resume import discover_resume_candidate
def dense_profile():
return configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), STAGED),
'occlusion_aware_witness_v1')
def header(profile):
return dict(kind='session_start', serial_number='O30_TEST', profile_id=profile.key.profile_id,
acquisition_policy_version=ACQUISITION_POLICY_VERSION,
pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION,
capture_schedule_version=CalibrationEngine(profile).capture_schedule_version,
capture_plan={'policy': STAGED}, protected_hashes={'source': 'a'*64},
preparation_contract=preparation_contract(profile), resume_checkpoint_requested=True)
def checkpoint(root, name, start, *, bad_tail=False):
path = root/name
path.mkdir()
rows = [start, dict(kind='fixed_base_reference_locked'),
dict(kind='scan_unit_complete', task_name='thumb_cmc_roll_front',
cycle=0, direction='increasing', passed=True)]
(path/'raw_samples.jsonl').write_text('\n'.join(json.dumps(row) for row in rows)
+ ('\nnot-json' if bad_tail else '\n'))
return path
def discover(root, profile, rejected):
return discover_resume_candidate(root, profile_id=profile.key.profile_id, serial_number='O30_TEST',
protected_hashes={'source': 'a'*64}, training_policy=STAGED,
preparation_contract=preparation_contract(profile), rejected_candidates=rejected)
def test_old_budget_rejected_at_header_before_loading_large_history(tmp_path):
profile = dense_profile()
old = header(profile)
old.pop('preparation_contract')
checkpoint(tmp_path, '001', old, bad_tail=True)
rejected = []
assert discover(tmp_path, profile, rejected) is None
assert rejected == [dict(session='001', reason='checkpoint preparation contract changed')]
assert not CalibrationEngine(profile).resume_compatible(old)
def test_matching_contract_resumes_and_json_roundtrip_is_identical(tmp_path):
profile = dense_profile()
start = json.loads(json.dumps(header(profile)))
source = checkpoint(tmp_path, '001', start)
assert CalibrationEngine(profile).resume_compatible(start)
assert discover(tmp_path, profile, []) == source
# Capture validity is separate: readable historical artifacts are not
# relabelled corrupt simply because a new live plan uses a different budget.
old = {key: value for key, value in start.items() if key != 'preparation_contract'}
assert CalibrationEngine(profile).capture_compatible(old)
@pytest.mark.parametrize('field', ['budget', 'witness', 'parent', 'version'])
def test_changed_dependency_graph_invalidates_whole_preparation_before_motion(tmp_path, field):
profile = dense_profile()
changed = header(profile)
contract = changed['preparation_contract']
if field == 'budget':
contract['tasks']['pinky_pip_side']['command_intervals'] = 128
elif field == 'witness':
contract['witnesses'].pop('pinky_mcp_pitch')
elif field == 'parent':
contract['tasks']['pinky_dip_side']['parent_reference']['geometry_joint'] = 'ring_pip'
else:
contract['version'] = 'future_strategy'
checkpoint(tmp_path, '001', changed)
assert discover(tmp_path, profile, []) is None
assert not CalibrationEngine(profile).resume_compatible(changed)
with pytest.raises(ValueError, match='identity/policy changed'):
PreparedResume.load(profile, CalibrationEngine(profile), tmp_path/'001/raw_samples.jsonl', 'O30_TEST')
def test_incompatible_fresh_run_cannot_resurrect_older_lineage(tmp_path):
profile = dense_profile()
checkpoint(tmp_path, '001', header(profile))
fresh = header(profile)
fresh.pop('preparation_contract')
fresh['resume_checkpoint_requested'] = False
checkpoint(tmp_path, '002', fresh)
assert discover(tmp_path, profile, []) is None
def test_resume_upgrade_does_not_rewrite_historical_capture(tmp_path):
profile = dense_profile()
old = header(profile)
old.pop('preparation_contract')
path = checkpoint(tmp_path, '001', old)/'raw_samples.jsonl'
before = path.read_bytes()
assert discover(tmp_path, profile, []) is None
assert path.read_bytes() == before
def test_capture_cycle_supplement_keeps_preparation_but_changed_pose_does_not():
from linkerhand_calibration.core.domain.capture_plan import select_task_training
profile = dense_profile()
supplemented = select_task_training(profile, profile.motion.tasks[0].key, (0, 1, 2))
assert preparation_contract(supplemented) == preparation_contract(profile)
zeros = dict(profile.motion.joint_zero_references)
spec = zeros['pinky_pip']
command = list(spec.command)
command[0] -= 1
zeros['pinky_pip'] = replace(spec, command=tuple(command))
changed = replace(profile, motion=replace(profile.motion, joint_zero_references=zeros))
assert preparation_contract(changed) != preparation_contract(profile)
def test_live_coordinator_writes_effective_preparation_graph(tmp_path):
from runtime_host_fixture import coordinator_fixture
host, _ = coordinator_fixture(tmp_path, model='o30', profile_transform=lambda _: dense_profile())
try:
row = json.loads(host.raw_path.read_text().splitlines()[0])
assert row['preparation_contract'] == preparation_contract(host.profile)
assert CalibrationEngine(host.profile).resume_compatible(row)
finally:
host.close()
def test_legacy_profiles_keep_existing_resume_contract():
profile = load_bundled_hand_profile('o30_right_18')
assert preparation_contract(profile) is None
def test_new_header_cannot_relabel_old_parent_models():
from linkerhand_calibration.profiles.preparation import validate_preparation_contract
from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance
from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import DENSE_PREPARATION_SAMPLING
profile = dense_profile()
old = dict(kind='motion_branch_initialization', task_name='pinky_pip_side',
zero_joints=['pinky_pip'], status='resolved')
with pytest.raises(ValueError, match='model_budget_changed'):
validate_motion_provenance(profile, [header(profile), old])
dense = {**old, 'image_sampling_policy': DENSE_PREPARATION_SAMPLING}
validate_preparation_contract(profile, [header(profile), dense])
changed = deepcopy(header(profile))
changed['preparation_contract']['version'] = 'another_graph'
with pytest.raises(ValueError, match='profile_changed'):
validate_preparation_contract(profile, [changed, dense])