docs(studio-3d): 阶段 6 交付说明(正确性与失效状态)+ 接口骨架同步

- What:
  1. `docs/architecture/studio-3d-stages.md` 新增「阶段 6:运行正确性与失效状态(批次 A)—— 已完成」:
     做(epoch/字段级来源/设备几何身份/加载请求号/工具与重编包)、验收表格(含两份探针的
     before/after 与全量计数)、"这一阶段抓到的" 7 条、兼容说明 6 条;文首进度改成阶段 6 已完成、
     7/8/9 未开始;依赖关系图扩展为 6→7/8→9。
  2. `docs/architecture/studio-3d-contracts.pyi` 与本阶段的契约改动同步:`SceneState` 加 `'stale'`;
     `SurfaceFieldFrame`/`RenderFrame` 加 `computed_frame_id`;`ExpectedGeometry` 加 `source_geometry_id`
     并注明米制 id 何时为空;`SceneAssets` 加 `source_geometry_id`/`compatible_sensor_model`/
     `compatible_hardware_revisions`;`ThreeDCoordinator.__init__` 加 `clock`/`stale_grace_s`,新增
     `note_field_failure()`/`show_rest()`/`epoch`/`last_presented()`。
- Why: 规格 §13 要求每阶段同时交出实现、测试、输入样例、失败样例、兼容说明与平台诊断报告;阶段 6 的
  契约改动(`SceneState`、两个 `computed_frame_id`、`ExpectedGeometry`/`SceneAssets` 的新身份字段、
  协调器新参数)必须先落到接口骨架里,否则下一阶段会照着过期文档实现。审查报告本身不修改
  (`_review_scene3d_20260914/` 保持原样),结论与反号证据写进本仓文档。
- Verification:
  · 阶段末全量复核(与本提交同一工作树):apps/studio 234 项 0 失败(14 跳过 = offscreen 下的真 GL 用例,
    用 `QT_QPA_PLATFORM=windows` 单独跑为 14 项 0 失败 0 跳过);shared 316 项 0 失败 1 跳过;
    `ui_check` 587 项 0 失败;`locale_check` 0 缺失(zh/en 各 398 键,仅有键集合相同);
    `validate_scene.py` 对重编发布包 `source geometry recorded 46a03a5fbac1eace` 等全项 ok。
  · 复现证据:`.repro/probe_stage6_r1.py`(R1/R7-b 反号)、`.repro/probe_stage6_device.py`
    (R3:同尺寸不同映射被拒、表面不同被拒、设备不声明 → unverified),输出 JSON 与扫描件留在 `.repro/`。
- Not in this change: 不改代码(本提交只动文档与接口骨架);阶段 7/8/9 的计划条目(二维后端统一、
  回放入口收口、能力控件、限速与真实统计、保护包密钥注入、ARM 与 2 小时门禁)尚未开始;
  真机上"算法链失败时 `VRAW` 是否也过期"这一条仍待实测(风险项已写进阶段 6 小节)。
This commit is contained in:
ykcheungab
2026-09-14 21:13:57 +08:00
parent 3c0424b90c
commit bf643de93d
2 changed files with 136 additions and 10 deletions
+16 -4
View File
@@ -8,7 +8,7 @@ by NDArray. See studio-3d-design.md for threading and validation requirements.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar from typing import Callable, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar
from concurrent.futures import Executor from concurrent.futures import Executor
import numpy as np import numpy as np
@@ -28,7 +28,7 @@ Role: TypeAlias = Literal['sensing', 'housing', 'reference']
Quality: TypeAlias = Literal['calibrated', 'approximate', 'display_only'] Quality: TypeAlias = Literal['calibrated', 'approximate', 'display_only']
Profile: TypeAlias = Literal['desktop33', 'gles30'] Profile: TypeAlias = Literal['desktop33', 'gles30']
OfferResult: TypeAlias = Literal['accepted', 'replaced', 'stale', 'disabled', 'invalid'] OfferResult: TypeAlias = Literal['accepted', 'replaced', 'stale', 'disabled', 'invalid']
SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable'] SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable', 'stale']
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -75,6 +75,7 @@ class SurfaceFieldFrame:
displacement_sensor_m: F32 | None # [H,W,3]; base displacement BEFORE display gain. displacement_sensor_m: F32 | None # [H,W,3]; base displacement BEFORE display gain.
displacement_quality: Quality | None displacement_quality: Quality | None
diagnostic: Diagnostic | None diagnostic: Diagnostic | None
computed_frame_id: int | None # Frame the array was computed for; None = unstated.
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -118,9 +119,10 @@ class CameraPreset:
class ExpectedGeometry: class ExpectedGeometry:
sensor_model: str sensor_model: str
hardware_revision: str hardware_revision: str
geometry_id: str geometry_id: str # Metre-scaled shape identity; '' when the device cannot state it.
field_domain_id: str field_domain_id: str
shape_hw: tuple[int, int] shape_hw: tuple[int, int]
source_geometry_id: str # Unit-free surface identity a device CAN reproduce.
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -133,6 +135,9 @@ class SceneAssets:
binding: SurfaceBinding # v1 supports exactly one sensing part. binding: SurfaceBinding # v1 supports exactly one sensing part.
camera_presets: tuple[CameraPreset, ...] camera_presets: tuple[CameraPreset, ...]
bounds_sensor_m: F32 # [2,3]: min, max. bounds_sensor_m: F32 # [2,3]: min, max.
source_geometry_id: str # Unit-free source geometry this asset was built from.
compatible_sensor_model: str
compatible_hardware_revisions: tuple[str, ...] # '*' means any revision.
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -172,6 +177,7 @@ class RenderFrame:
valid: Mask # [N]. valid: Mask # [N].
scalar_descriptor: FieldDescriptor scalar_descriptor: FieldDescriptor
diagnostics: tuple[Diagnostic, ...] diagnostics: tuple[Diagnostic, ...]
computed_frame_id: int | None # Carried through so the display side re-checks it.
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -345,17 +351,23 @@ class Sensor3DWidget(QOpenGLWidget):
class ThreeDCoordinator: class ThreeDCoordinator:
def __init__(self, widget: Sensor3DWidget, composer: SceneComposer, def __init__(self, widget: Sensor3DWidget, composer: SceneComposer,
executor: Executor, max_fps: int = 30) -> None: ... executor: Executor, max_fps: int = 30, *,
clock: Callable[[], float] = ..., stale_grace_s: float = 1.0) -> None: ...
def activate(self, assets: SceneAssets, source_id: str) -> int: ... def activate(self, assets: SceneAssets, source_id: str) -> int: ...
def submit(self, frame: SurfaceFieldFrame) -> OfferResult: ... def submit(self, frame: SurfaceFieldFrame) -> OfferResult: ...
def note_field_failure(self, item: Diagnostic | None = None) -> None: ...
def tick(self) -> None: ... def tick(self) -> None: ...
def set_visible(self, visible: bool) -> None: ... def set_visible(self, visible: bool) -> None: ...
def set_settings(self, settings: DisplaySettings) -> None: ... def set_settings(self, settings: DisplaySettings) -> None: ...
def show_rest(self) -> None: ...
def close(self) -> None: ... def close(self) -> None: ...
@property @property
def state(self) -> SceneState: ... def state(self) -> SceneState: ...
@property @property
def generation(self) -> int: ... def generation(self) -> int: ...
@property
def epoch(self) -> int: ...
def last_presented(self) -> tuple[FrameIdentity, float] | None: ...
class ThreeDPanel(QWidget): class ThreeDPanel(QWidget):
+120 -6
View File
@@ -3,8 +3,11 @@
配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。 配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。
本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。 本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。
阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节)。 阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节);
唯一按用户要求暂缓的是 ARM 板卡上的能力报告与长时门禁运行:工具齐备,命令已写在阶段 5 一节。 **阶段 6 已完成(运行正确性与失效状态,见下)**,依据是对 `6de6b8b` 的独立审查
(`_review_scene3d_20260914/review.md`)与其可控复现。阶段 7(二维后端统一与回放入口收口)、
阶段 8(界面与有效控制)、阶段 9(性能与发布收尾)尚未开始。
唯一按用户要求暂缓的是 ARM 板卡上的能力报告与长时门禁运行:工具齐备,命令已写在阶段 5/9 一节。
每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。 每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。
原则(写下来是因为后面每阶段都会被诱惑违反一次): 原则(写下来是因为后面每阶段都会被诱惑违反一次):
@@ -368,13 +371,124 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
--- ---
## 阶段 6:运行正确性与失效状态(批次 A)—— 已完成
依据:对 `6de6b8b` 的独立审查(`_review_scene3d_20260914/review.md`)给出的 P1 项 R1/R2/R3/R7。
审查看法经逐条核对后有两处修正:报告提到的 `BindingInformation` 在仓库中不存在(对应物是
`contracts.SurfaceBinding`);`finger_worker.py` 在 `apps/studio/shared` 一侧而不在 SDK,且
`compute_error` 是局部变量。`valid=False && flow 非空` 有两种成因(读取失败时 `flow=None`;
算法链失败时 `flow` 仍被填入),所以按**字段级来源**处理,而不是把 `packet.valid` 机械套到三维。
**做**:
- `scene3d/coordinator.py`:**取消 epoch**。`_Inflight`(future/epoch/identity/started)取代裸 Future;
`work()` 只 `return` 合成结果,结果统一在 GUI 线程的 `_collect_inflight()` 里被接受,且必须同时满足
`epoch` 与 `_identity_is_current()`(source/generation/session/**request_id**);`activate`/
`begin_session`/`set_visible(False)`/`show_rest`/`close` 走 `_invalidate()`(epoch+1、清两块 mailbox、
重置请求号与"最后在场帧")。**未被成功取消的 future 仍占用唯一合成槽**,`CancelledError` 单独吞掉
(取消不是失败)。新增 `note_field_failure()` 与 `_stale_since` 宽限状态机:字段被拒 / 帧 invalid /
compose 抛错 / 合成结果 `computed_frame_id` 落后 → 立即 `stale` 并保留最后一帧,宽限
(`stale_grace_s`,默认 1.0 s,`clock` 可注入)到期回参考模型并转 `no_data`,收到好帧回 `live`。
顺带修掉"等待首帧时每 tick 重新上传同一个参考模型"(`_rest_presented`)。
- `acquisition/models.py` + `finger_worker.py`/`sdk_worker.py`:`SdkPacket` 增 `flow_valid`/
`flow_frame_id`/`depth_valid`/`depth_frame_id`/`forces_valid`(`None` = 未声明),两个生产 worker 逐帧
填写"该数组实际算出来的帧号";`stale_forces` 语义不变(力通道)。
- `scene3d/adapters.py`:`_field_validity()` 三态(未声明→`field_unverified`;声明为假→`field_stale`
并把 `invalid_reason` 带进诊断;帧号不是本帧→`field_stale` 且两个帧号进 details);
`_intersect_device_mask` 换成 `_device_mask()`——设备 mask 形状不符**拒绝该帧**(既不拉伸也不静默忽略);
发布帧带 `computed_frame_id`。box 适配器按字段分别判定,形变链失败仍可用有效光流。
- `scene3d/contracts.py`:`SceneState += "stale"`;诊断码 `field_stale`/`field_unverified`/
`geometry_unverified`;`SurfaceFieldFrame.computed_frame_id`、`RenderFrame.computed_frame_id`
(composer 原样透传);`validate_surface_frame` 校验帧号。
- `scene3d/view.py`:`submit_packet()` 把 adapter 的拒绝原因转给 `note_field_failure()`;`tick()` 把协调器
诊断的变化经已有 `diagnosticRaised` 冒泡(协调器自身仍零 Qt 依赖)。
- **设备几何身份(R3)**:`digest.source_geometry_digest(world_xyz)`(无单位);`legacy.convert()` 写入
`SceneAssets.source_geometry_id`;manifest 增 `source_geometry` 块并还原 `compatible`
(`compatible_sensor_model`/`compatible_hardware_revisions`);`ExpectedGeometry += source_geometry_id`;
`validate_assets()` 只比较**已声明**字段,`source_geometry_id` 不符即拒绝,包内没记录 → 返回
`geometry_unverified` 警告;修掉 `expected.sensor_model != assets.parts[0].role` 这行无意义比较。
新增 `acquisition/geometry.py::describe_sensor_geometry()`:从 `manifest.json` 的 `assets_dir`、
`sensor.json` 的 `resolution_stitch`/`source_scale` 与据此命名的 unroll map 推导设备侧描述
(domain + 无单位几何;米制 `geometry_id` 故意留空——那句单位声明只有人能写)。定位不到 bundle →
`verified=False`(可显示、不声明已校验);bundle 变了(map 缺失/与配置不符)→ 拒绝并指名文件与形状。
- **接线**:`app_window._sync_3d_source()` 传 `expected_provider=`;`SceneAssetLoader` 在**工作线程**解析
它与包;`view.use_scene(assets, source_id, expected)` 用 `validate_assets(assets, expected)`,记录
`device_verified`,`_make_snapshot()` 用**设备**的身份构造 adapter(设备没声明的字段才回退资产);
`describe()` 记录 `scene.device_verified` 与设备身份。
- **资产加载请求身份(R7-a)**:`SceneAssetLoader` 增请求号与 pending 槽(忙时保留**最新**请求而不是丢弃)、
完成信号带请求号(`loaded(assets, 描述, id, device)` / `failed(diag, id)`)、失败可重试;
`Mesh3dPanel` 丢弃过期完成。
- 工具与包:`compile_scene.py`/`validate_scene.py` 报告 `geometry_id`/`field_domain_id`/
`source_geometry_id`/兼容信息;`tools/scene_assets/README.md` 说明无单位身份;**重编发布包**
`apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene`
(geometry_id 仍是 `1a2e0e92…`、1032 顶点 / 1974 三角形,新增 `source_geometry_id=46a03a5f…`,
asset_hash `dbdf861c…` → `ea4beab6…`)。新增 locale `scene3d.state_stale`(中英各一条)。
**验收(已通过)**:
| 用例 | 结果 |
|---|---|
| R1 停止后旧任务复活 | 可控复现反号:`.repro/probe_stage6_r1.py` → `stopped_frame_returns` 由 `{valid_after_old_worker_finishes: true, state: "live"}` 变为 `{false, "no_data", epoch_bumped: true, slot_free_again: true}`;20 轮 begin_session→submit→tick→show_rest 循环里最后一个画面上永远没有有效测量,`max_in_flight==1` |
| R7-b request_id | 同一探针:`request_id_not_checked` 由 `{old_submit: "accepted", shown_request: 1}` 变为 `{old_submit: "stale", shown_request: 2, diagnostic: "stale_frame"}`;"较旧请求的结果晚到"在接收侧也被丢弃 |
| R2 失效字段 | 探针 `.repro/probe_stage6_*` 与单测:`flow_valid=False`(带 `invalid_reason`)→ 拒绝并报 `field_stale`;未声明 → `field_unverified`;`flow_frame_id` 落后 → `field_stale`;设备 mask 域不符 → `field_domain_mismatch` 拒帧;compose 抛错 → `stale` + 诊断,好帧恢复 `live`;宽限内保留画面、到期回参考模型 |
| R3 设备几何 | 探针 `.repro/probe_stage6_device.py` → `probe_stage6_device.json`(真实 bundle + 真实包):设备描述 `verified=true`、domain `feb7f71e…`、source geometry `46a03a5f…`、300x280、米制 id 未声明;发布包通过该描述;**同尺寸不同映射**(只改 map 一个像素)→ domain 变化、shape 不变、包被 `field_domain_mismatch` 拒绝;设备什么都不声明 → 接受 + `geometry_unverified` |
| R7-a 加载请求 | 单测:忙时第二个请求成为 pending 并在第一个结束后运行(请求号递增)→ 面板丢弃过期完成 → 失败后可重试 |
| 回归 | apps/studio **234 项 0 失败 14 跳过**(跳过的是 offscreen 下的真 GL 用例)、shared **316 项 0 失败 1 跳过**;真 GL 冒烟 `QT_QPA_PLATFORM=windows` **14 项 0 失败 0 跳过**;`ui_check` 587 项 0 失败;`locale_check` 0 缺失;`validate_scene.py` 对重编包全项 ok |
新增/改动的测试:`test_scene3d_coordinator.py` +13(epoch/取消/request_id/宽限状态机)、
`test_scene3d_adapters.py` +6(字段来源与 mask 域)、`test_scene3d_view.py` +9(诊断到界面、设备身份、
最新请求、过期完成)、`shared/tests/test_scene3d_device_geometry.py` 新增 9 项、
`test_scene3d_contracts.py` +4、`test_scene3d_package.py` +2。
**这一阶段抓到的**(按重要性):
1. **`Future.cancel()` 不是"完成"**:旧代码在 `_cancel_inflight()` 里就把 `_inflight` 置空,只比较
generation;于是"停止显示"之后旧 work 结束仍把结果写进 mailbox,下一次 tick 取到它并把状态改回
`live`——画面回来了,而没有任何东西在测量它。现在接受点在 GUI 线程,epoch 让"这一刻的显示声明"
成为可比较的事实,未成功取消的任务继续占用唯一合成槽(反复隐藏/显示不会在线程池里排队)。
2. **"没声明"必须当成"不可用"**:adapter 原来只看 `flow` 是否有有限值。加上字段级来源后,
`None`(生产者没说)→ `field_unverified`,"链失败留下的旧数组"→ `field_stale`。这一条也会打到
测试假包上(`ui_check._fake_packet`、`_Packet` 都补了 provenance),这正是"没声明就不算数"的证据。
3. **静默忽略 mask 域不符 = 悄悄扩大可信范围**:原来形状不符就把设备 mask 丢掉,于是整个 UV 域都算
"有效"。现在拒绝该帧并写明两个形状。
4. **"自证匹配"**:主窗口从不传 `expected`,`validate_assets(assets, None)` 跳过全部比对,adapter 的
geometry/domain/shape 全来自资产本身。修法是两层:设备侧从**运行时真正加载的文件**推导描述,
且 adapter 用设备的身份盖章。顺带发现米制 `geometry_id` 设备**根本无法复算**(需要"1 源单位 = N 米"
这句只有人能写的声明),于是补了无单位的 `source_geometry_id`——这才是设备能真正验的那一半。
5. **`mapping_digest` 没有加**:`field_domain_id` 本身就是由映射内容摘要派生的(含 flip/scale/origin、
shape 与索引约定),再加一个字段只是同一个事实的第二个名字;计划里那一项据此取消,理由写在此处。
6. **旧包不能"看起来已验证"**:没有 `source_geometry` 记录的包仍然可读,但 `validate_assets()` 返回
`geometry_unverified`(info),`describe()` 记 `device_verified=false`。
7. **工具的门禁一度把"没记录"判成失败**:`validate_scene.py` 新增的 `source geometry recorded` 一开始
要求该字段存在,于是 `test_the_release_gate_fails_an_unsigned_package` 里的合成包被判失败——工具测试
抓到了这个"兼容即失败"的错误,现在它是中性说明(发布门禁仍由 `--require-signature` 表达)。
**兼容说明**:
- 契约新增字段**全部可选且有默认**:`SdkPacket` 的五个来源字段默认为 `None`(因此**旧测试假包会被拒绝**,
这是有意的严格化;两个生产 worker 都已声明);`SurfaceFieldFrame`/`RenderFrame.computed_frame_id`
默认 `None`;`ExpectedGeometry.source_geometry_id`、`SceneAssets` 三个新字段默认空串/空元组。
- **旧包照读**:manifest 里没有 `source_geometry` 时读成空串→未校验;`compatible` 一直存在,现在才被
真正用来核对型号/硬件版本(`*` = 任意版本)。
- **重编包改变 `asset_hash`**(`dbdf861c…` → `ea4beab6…`,geometry_id 不变):旧录像的
`visualization3d` 块只是记录,不影响读取;`session.json` 的块版本未变(只增键)。
- 审查用的复现脚本里 `coordinator._inflight` 被当作 Future 使用,现在它是 `_Inflight` 记录,脚本无法
原样运行;等价探针(`.repro/probe_stage6_r1.py`)覆盖同样的两条结论并留在 `.repro/`。
- **真机待验证一项**:算法链失败时 SDK 的 `VRAW`(光流)是否也过期。当前实现取保守解释(链失败即本帧
派生量不可声明);若实测证明 `VRAW` 仍可靠,`flow_valid` 放宽为只看读取结果是一行改动。
- 长度单位仍只在编译期:设备侧不声明米制几何,`ExpectedGeometry.geometry_id` 留空,绝不猜。
---
## 依赖关系(并行度) ## 依赖关系(并行度)
``` ```
阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┐ 阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┬─> 阶段 5 ─> 阶段 6 (正确性/失效) ─┬─> 阶段 7 (二维后端统一)
│ ├─> 阶段 5 │ │ └─> 阶段 8 (界面与能力)
阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘ 阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘ └─> 阶段 9 (性能/发布)
``` ```
阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3。 阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3;
阶段 6 需要 3 与 4 的接线;阶段 7 与 8 可并行(7 不依赖三维,8 依赖 6 的失效状态与能力信号);
阶段 9 需要 6 的时钟/状态与 7 的后端收口。
每阶段开始前先确认上一阶段的失败样例仍然被覆盖(回归门)。 每阶段开始前先确认上一阶段的失败样例仍然被覆盖(回归门)。