From db4c7af3e7dfc8836a58121cef8b42dec36778a1 Mon Sep 17 00:00:00 2001 From: lxp <2770281812@qq.com> Date: Sun, 20 Sep 2026 18:26:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20O30=20=E6=A0=87=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E9=92=9F=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=87=86=E5=A4=87?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../O30_RIGHT_CALIBRATION.md | 29 +++- .../O30_STABILITY_ASSESSMENT_20260920.md | 119 +++++++++++++ src/linkerhand_calibration/TESTING.md | 18 ++ .../linkerhand_calibration/camera_timing.py | 45 ++++- .../core/geometry/tag_pose/observer_pose.py | 14 +- .../profiles/preparation.py | 51 +++++- .../runtime/coordinator.py | 4 + .../linkerhand_calibration/runtime/engine.py | 2 + .../runtime/motion_provenance.py | 2 + .../runtime/observer_reference.py | 7 +- .../linkerhand_calibration/runtime/resume.py | 12 +- .../linkerhand_calibration/runtime/runner.py | 10 ++ .../test/test_camera_timing.py | 79 +++++++++ .../test/test_observer_pose.py | 8 + .../test/test_preparation_contract.py | 156 ++++++++++++++++++ 15 files changed, 543 insertions(+), 13 deletions(-) create mode 100644 src/linkerhand_calibration/O30_STABILITY_ASSESSMENT_20260920.md create mode 100644 src/linkerhand_calibration/test/test_preparation_contract.py diff --git a/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md b/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md index 68cd83b..8e046ba 100644 --- a/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md +++ b/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md @@ -861,8 +861,9 @@ DIP 只完成模拟后续访问的正式采集/父参考回读测试,使用真 观测后,两关节各得到 248 帧。离线反事实回放还使用新 PIP 模型重新投影原 DIP 图像, 验证整条依赖链;不修改原零位或历史日志,不把旧断点直接重新标记为通过。 -这项优化需要新采集的上游模型。验证整场稳定性使用 `--no-resume`;继续旧断点仍保留 -原冻结模型及不确定度,不能承诺仅重试 DIP 就会通过。正式运行命令: +这项优化需要新采集的上游模型。新版会自动排除缺少当前准备契约的旧断点,重新采集 +完整准备链;新版本同契约断点仍支持恢复。验证独立整场运行时使用 `--no-resume`。 +正式运行命令: ```bash 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` 占用,未中断它,也未执行新的实机标定或发布标定产物。 diff --git a/src/linkerhand_calibration/O30_STABILITY_ASSESSMENT_20260920.md b/src/linkerhand_calibration/O30_STABILITY_ASSESSMENT_20260920.md new file mode 100644 index 0000000..5d94cd1 --- /dev/null +++ b/src/linkerhand_calibration/O30_STABILITY_ASSESSMENT_20260920.md @@ -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,不能宣称流程稳定或 +保证下一次一次通过。 diff --git a/src/linkerhand_calibration/TESTING.md b/src/linkerhand_calibration/TESTING.md index 02c97c7..867133c 100644 --- a/src/linkerhand_calibration/TESTING.md +++ b/src/linkerhand_calibration/TESTING.md @@ -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 秒内完成。不要因为修改了一行代码就运行 整手拟合或全量 `colcon test`。公共产物契约变更需要覆盖相关型号,发布前再执行全量回归。 diff --git a/src/linkerhand_calibration/linkerhand_calibration/camera_timing.py b/src/linkerhand_calibration/linkerhand_calibration/camera_timing.py index d596de1..64966e1 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/camera_timing.py +++ b/src/linkerhand_calibration/linkerhand_calibration/camera_timing.py @@ -13,6 +13,7 @@ import time CAMERA_TIMING_POLICY = "mvs_latched_exposure_time_v1" +CLOCK_DRIFT_TOLERANCE_NS = 1_000_000 @dataclass(frozen=True) @@ -20,6 +21,8 @@ class ClockLatch: device_tick: int before_ns: int after_ns: int + raw_before_ns: int | None = None + raw_after_ns: int | None = None @property def midpoint_ns(self): @@ -45,6 +48,7 @@ class DeviceFrameClock: self.anchor = None self.last_frame_tick = None self.last_stamp_ns = None + self.refresh_interval_seconds = .5 def elapsed_ns(self, ticks): return round(ticks*1_000_000_000/self.ticks_per_second) @@ -59,9 +63,19 @@ class DeviceFrameClock: for current in latches: elapsed = self.elapsed_ns(current.device_tick-previous.device_tick) 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: 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) def timestamp(self, tick, *, received_ns, exposure_us): @@ -80,8 +94,11 @@ class DeviceFrameClock: class MvsCameraTiming: """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 + # 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.frame_clock = None self.last_stamp_ns = None @@ -100,12 +117,14 @@ class MvsCameraTiming: return int(value.nCurValue) def _latch(self): + raw_before = self.raw_clock_ns() before = self.clock_ns() status = self.camera.MV_CC_SetCommandValue("DeviceTimestampLatch") if status != 0: raise ValueError(f"camera clock latch failed:0x{status:08x}") 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): 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.next_refresh = time.monotonic()+.02 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, 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))) if self.journal is not None: 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): if self.frame_clock is None: diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/observer_pose.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/observer_pose.py index d1d8302..07ce29e 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/observer_pose.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/observer_pose.py @@ -16,6 +16,17 @@ from .ippe import square_object_points 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) class ObserverPose: 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.), parent_corners=None, parent_size=None): + require_observer_precision_budget(inherited) count = len(stamps) if count < 24 or tuple(stamps) != tuple(sorted(set(stamps))) or any(not c for c in candidates): raise ValueError("observer_independent_images_missing") @@ -142,7 +154,7 @@ def fit_observer_pose(parents, candidates, corners, matrices, size, stamps, *, i points, matrices, variance) 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)] - 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") 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) diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py index d26f547..0f68ac6 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/preparation.py @@ -1,5 +1,6 @@ """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 @@ -21,6 +22,54 @@ def preparation_image_budget(profile, *, joint_count=1): 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): if policy != 'occlusion_aware_witness_v1': raise ValueError('unknown_preparation_policy') diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py index c66ee45..a77d508 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py @@ -182,6 +182,10 @@ class CalibrationCoordinator: "source": "sdk_target_position_register" if parameters.initial_command else "adapter_feedback"}, **({"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: from ..core.fitting.command_sampling import SAMPLING_POLICY self._session_header["command_sampling_policy"] = SAMPLING_POLICY diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py index d7892a1..96bab10 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py @@ -299,7 +299,9 @@ class CalibrationEngine: ) def resume_compatible(self, session_start: Mapping[str, Any]) -> bool: + from ..profiles.preparation import preparation_contract 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)) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_provenance.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_provenance.py index 0487035..ec821cc 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_provenance.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_provenance.py @@ -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 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): return {"status": "legacy_image_confirmation", "motion_evidence_verified": False} from .visual_motion import validate_visual_motion_evidence diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/observer_reference.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/observer_reference.py index ef142cf..e806e79 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/observer_reference.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/observer_reference.py @@ -6,7 +6,7 @@ import numpy as np from ..core.geometry.tag_pose.motion_evidence import MotionRelation 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 .branch_initialization import _pose from .shared_tag_reference import bridge_frames @@ -37,6 +37,9 @@ def build_observer_bridge(profile, source): rows, held = source.rows, source.held_channels if len(parent_model.geometry) != 1 or parent_model.geometry[0].relation.child_role != spec.parent_role: 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 minimum = max(10, profile.acquisition.fixed_reference_minimum_frames) endpoints = source.endpoint_rows[-minimum:] @@ -77,8 +80,6 @@ def build_observer_bridge(profile, source): matrices.append(row['camera_matrix']) if len(sizes) != 1: 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(), tuple(r['image_stamp_ns'] for r in rows), inherited=inherited, parent_corners=[r['tags'][spec.parent_role]['corners_xy'] for r in rows], diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py index 1e3ceda..3eddba8 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py @@ -45,6 +45,8 @@ def discover_resume_candidate( session_root: Path, *, profile_id: str, serial_number: str, protected_hashes: Mapping[str, str], training_policy: str = "fixed", + preparation_contract: Mapping[str, Any] | None = None, + rejected_candidates: list | None = None, ) -> Path | None: """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") if not _compatible_header(start, profile_id, serial_number, root.name, protected_hashes): raise ValueError("checkpoint header incompatible") + if start.get("preparation_contract") != preparation_contract: + raise ValueError("checkpoint preparation contract changed") if kind == "fixed_base_reference_locked": reference_found = True if kind == "scan_unit_complete" or kind.endswith("sweep_observation_quality"): @@ -116,7 +120,13 @@ def discover_resume_candidate( previous = completed.get(identity) if previous is None or attempt >= previous[0]: 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 if start is None or not reference_found or not completed_found: continue diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py index 8873746..9b82841 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py @@ -141,11 +141,21 @@ def run_online( atomic_write_json(session/"measurement_contract.json", contract_report) if profile.artifacts.output_schema_version >= 3 and commands_enabled and diagnostic is None: require_release_geometry(contract_report) + from ..profiles.preparation import preparation_contract + rejected_checkpoints = [] + preparation = preparation_contract(profile) resume = discover_resume_candidate( config.session_root, profile_id=profile.key.profile_id, serial_number=config.serial_number, protected_hashes=protected_inputs(config), 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 + 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"]: print("原始 CAD 存在限位/mimic 冲突,未修改原文件;详见 measurement_contract.json。最终范围验证不通过时禁止发布。", flush=True) log_path = session / "calibration.log" diff --git a/src/linkerhand_calibration/test/test_camera_timing.py b/src/linkerhand_calibration/test/test_camera_timing.py index 3155a18..1fa8191 100644 --- a/src/linkerhand_calibration/test/test_camera_timing.py +++ b/src/linkerhand_calibration/test/test_camera_timing.py @@ -67,6 +67,85 @@ def test_uncertain_control_transaction_does_not_refresh_the_clock(): 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]) def test_recovery_revalidates_clock_and_preserves_frame_time_order(monkeypatch, host_jump): timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.) diff --git a/src/linkerhand_calibration/test/test_observer_pose.py b/src/linkerhand_calibration/test/test_observer_pose.py index c28de5c..cd6d57f 100644 --- a/src/linkerhand_calibration/test/test_observer_pose.py +++ b/src/linkerhand_calibration/test/test_observer_pose.py @@ -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)) +@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']) def test_invalid_observer_evidence_cannot_authorize_a_pose(observer_images, fault): parents, candidates, corners, matrices, size, stamps = observer_images diff --git a/src/linkerhand_calibration/test/test_preparation_contract.py b/src/linkerhand_calibration/test/test_preparation_contract.py new file mode 100644 index 0000000..8708287 --- /dev/null +++ b/src/linkerhand_calibration/test/test_preparation_contract.py @@ -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])