# 单路流类型合同(`stream-contract/1`) 用途:reference。范围:`orisys.stream_contract` 冻结的逐帧身份、采集时钟、测量头和事件信封。 状态:T03 已验收(done),2026-10-21。只冻结类型、校验和 JSON 往返,不接入现有 Sensor、StreamPipeline 或 ForceSession;T05、T06、T08、T11、T13–T15 复用这些头,不再各自另立字段。 ## 为什么先冻结共享头 阶段 0 修复的是参考版本传递和复位线程归属;阶段 1 要让图像块、力会话、单路入口以及 Studio 的记录与分析使用同一份逐帧身份和时标。若每层各写一套字段,就会重新出现“同一 PTS 的两个源帧被当成重复”“文件名代替标定版本”“处理耗时冒充源时间”这类旧缺陷。本任务把这些共享字段冻结成可 JSON 往返的 frozen 数据类;后续任务只增加各自负载,不改已有字段含义。 ## 稳定性规则 - `STREAM_CONTRACT_VERSION = "stream-contract/1"` 写入每个可持久化对象的 `to_dict()`。采用严格读取(政策 A):**任何新增、改名、删除或改类型的字段都升版本**,不存在同版本新增字段。 - 读取方只读 `SUPPORTED_CONTRACT_VERSIONS` 中显式列出的版本,并拒绝未列出的版本和未知字段;写方要加字段时必须升版本,并把新版本加入读取方列表。 - 增加新事件 `kind` 属于兼容变化:消费者忽略不认识的 `kind`,不因未知事件失败;但 `kind` 本身的名称格式受信封合同约束(见下)。 - 构造即校验;非法数据立即失败,不生成“看起来合理”的默认值。缺失组身份不伪造同步,缺失时钟不补假时间。 - 本任务不改变已发布接口:`Sensor.reset()`、`StreamPipeline` 快照和当前 `ForceSession` 行为不变;T06 重做会话时才改为直接消费这里定义的测量头。 ## 帧身份 `FrameIdentity` 表达“哪一路的哪一帧”,字段如下。 | 字段 | 类型 | 说明 | |---|---|---| | `sensor_id` | `str` | 逻辑 Sensor 身份,非空 | | `session_id` | `str` | 一次运行/录制的会话身份,非空 | | `frame_id` | `int` | 该路连续输出序号,`>= 0` | | `source_frame_id` | `int \| None` | 源自己的帧号;源没有编号时为 `None`,有编号时必须保留原值 | | `acquisition_id` | `str \| None` | 可选组身份之一,与 `sync_group_id` 同时出现或同时缺失 | | `sync_group_id` | `str \| None` | 可选组身份之一,不得只给一半 | 规则: - 去重、缓存和迟到判断以源身份为准;时间戳不是身份。两个源帧可以有相同的 PTS,它们仍是两帧。 - 相同时间戳不触发去重、清零、复位或缓存替换;入口如何忽略重复输入属于 T08,本任务只保证类型层面可区分。 - 组身份缺失表示本路独立,不表示已同步;不伪造 acquisition/sync 值。 - 组身份即使存在,也只说明采集组归属;跨时钟映射和同步容差仍需后续计划实测,不能由字段存在推断。 ## 采集时钟 `FrameClock` 保存一帧的采集时间和它的时间来源。 | 字段 | 类型 | 说明 | |---|---|---| | `capture_time_ns` | `int` | 采集时间,整数纳秒,`>= 0` | | `clock_id` | `str` | 时钟域标识,例如 `host_monotonic`、`video:recording-4` | | `capture_time_basis` | `str` 枚举 | 见下表 | | `source_pts` | `int \| float \| None` | 源时间戳原值(单位由源声明),与处理时间无关 | `capture_time_basis` 的取值: | 取值 | 含义 | |---|---| | `capture_monotonic` | 实时源的主机单调采集/接收时间 | | `recorded_signal` | 录像保存的信号时间(回放/debug) | | `video_pts` | 容器真实 PTS 换算成纳秒 | | `estimated_source` | 无可靠 PTS,按源帧号和名义帧率推算;消费方必须当作估算值 | 规则: - 不能用“处理计数 × 名义周期”冒充 `video_pts`,不用离线处理耗时作为信号时间,也不伪造递增 PTS。没有可信 PTS 时明确标为 `estimated_source`。 - `source_pts` 保留原值,不因换算成 `capture_time_ns` 而丢弃;发布日期、写入时间由结果信封另记,不写进采集时间。 - 时钟域之间不隐式换算;需要换算时由上层显式声明来源。 ## 帧头 FrameStamp `FrameStamp` 是身份加时钟的扁平帧头,可持久化形式如下;`identity` 和 `clock` 属性返回对应的两个组件,`from_parts()` 从组件构造。 ```json { "schema": "stream-contract/1", "sensor_id": "FINGER-001", "session_id": "run-7", "frame_id": 12, "source_frame_id": 41, "capture_time_ns": 1600000000000000000, "clock_id": "host_monotonic", "capture_time_basis": "capture_monotonic", "source_pts": 41.5, "acquisition_id": null, "sync_group_id": null } ``` ## 测量头 MeasurementFrame | 字段 | 类型 | 说明 | |---|---|---| | `stamp` | `FrameStamp` | 本帧身份与采集时钟 | | `source_dt_s` | `float \| None` | 与同一 Sensor 上一条实际处理帧之间的源时间差,单位秒;第一帧为 `None`,其余 `>= 0` | | `reference_generation` | `int` | 计算该帧光流时生效的参考版本,`>= 0` | `source_dt_s` 的定义和边界: - 用声明的采集时钟计算源时间差,不是处理速度,也不是相邻输出序号的固定周期。 - 相同源时间(`0.0`)是合法值,表示两帧时间相等;不能因此把两帧合并。 - 长间隔只产生警告和质量标记,不自动复位、清零或改滤波设计帧率;阈值由后续实测确定(K4.5、K9)。 - `reference_generation` 由图像线程在该帧计算光流时写入,随后沿用到结果;不能在 HHD 输出或 Studio 消费时读取全局最新计数补标签(K8.1)。迟到旧版本的样本不能进入新零点基线。 测量负载(flow、HHD 场、接触、场标定特征)以及力结果、批次和记录格式不在本任务冻结;T05、T06 定义负载并在本头上扩展,T11、T13–T15 复用同一身份和时标。 ## 事件信封 StreamEvent `StreamEvent` 是状态变化事件的公共信封:参考复位、归零进度、滤波切换、长间隔、跳帧等都由后续任务登记具体 `kind` 与 `payload` 字段。 | 字段 | 类型 | 说明 | |---|---|---| | `kind` | `str` | 稳定的 lower_snake 事件名,必须匹配 `^[a-z][a-z0-9_]*$`;未知 kind 必须被消费者忽略而不是报错 | | `stamp` | `FrameStamp \| None` | 事件所属帧;无帧事件为 `None` | | `payload` | `Mapping[str, Any]` | 只允许 JSON 类型,构造时校验并冻结为只读深拷贝 | | `event_version` | `int` | 信封版本,`>= 1`;信封结构不兼容变化时递增 | `payload` 拒绝 `NaN`、无穷、`bytes`、集合和非字符串键,因为这些值无法无损通过 `json.dumps`/`json.loads` 往返。事件序列化后必须能原样读回,记录才能真正复现在线状态。`StreamEvent` 可哈希:`payload` 冻结但不参与哈希,哈希只由 `kind`、`stamp` 和 `event_version` 决定,因此相等事件哈希相同;不同 payload 的事件允许落在同一哈希桶。 ```json { "schema": "stream-contract/1", "kind": "source_gap", "event_version": 1, "stamp": { "schema": "stream-contract/1", "sensor_id": "BOX-3", "session_id": "run-7", "frame_id": 3, "source_frame_id": 12, "capture_time_ns": 1600000000000000000, "clock_id": "host_monotonic", "capture_time_basis": "capture_monotonic", "source_pts": null, "acquisition_id": null, "sync_group_id": null }, "payload": {"source_dt_s": 0.5, "dropped_frames": 3} } ``` ## JSON 往返与严格校验 每个可持久化类型提供 `to_dict()` 和 `from_dict()`。`to_dict()` 带 `schema`;`from_dict()` 只接受 `SUPPORTED_CONTRACT_VERSIONS` 中显式列出的版本,并校验必填字段、未知字段和字段类型,损坏的记录立即失败而不是被部分接受。下面的例子可以直接运行: ```python import json from orisys import FrameClock, FrameIdentity, FrameStamp, MeasurementFrame stamp = FrameStamp.from_parts( FrameIdentity( sensor_id="FINGER-001", session_id="run-7", frame_id=12, source_frame_id=41, ), FrameClock( capture_time_ns=1_600_000_000_000_000_000, clock_id="host_monotonic", capture_time_basis="capture_monotonic", ), ) text = json.dumps(MeasurementFrame(stamp, source_dt_s=0.033).to_dict()) measurement = MeasurementFrame.from_dict(json.loads(text)) assert measurement.stamp == stamp ``` 未知字段被拒绝,是为了让不同版本的记录不会静默错配。采用政策 A:字段变更(包括新增字段)一律升 `STREAM_CONTRACT_VERSION`,同时把新版本加入读取方的 `SUPPORTED_CONTRACT_VERSIONS`;同版本内不新增字段。 ## 不包含什么 - 不实现 SensorStream 入口、实时最新选帧、完整回放和背压(T08)。 - 不实现图像块拆分、测量负载、ForceSession 重做、零点与输出滤波(T04–T07)。 - 不实现批次、配组、多流调度(后续多流计划)。 - 不实现录制/回放格式、Studio 迁移和离线重新滤波(T11、T13–T15)。 - 不修改现有 `Sensor`、`StreamPipeline` 和当前 `ForceSession`;现有 `ForceSession.FrameStamp` 在 T06 重做时被本合同的帧头替换。 ## 验证 `tests/test_stream_contract.py` 覆盖 T03 的四项验收:逐路身份与时钟、组字段可缺但不半组、同 PTS 帧可区分、`source_dt_s` 与事件 JSON 往返;同时覆盖 frozen 校验、严格版本/未知字段拒绝(政策 A)、`SUPPORTED_CONTRACT_VERSIONS`、事件可哈希、`kind` 的 lower_snake 格式和非法负载拒绝。类型本身不启动传感器、流水线或会话。