diff --git a/README.md b/README.md index b3e6402..1df4a8e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ WaveBench 是一个用 Python 编写的实验室自动测量台,面向电子设计竞赛调试和日常实验。它把仪器控制、实验步骤和采集证据放在同一条命令链中,支持先离线检查 plan,再决定是否连接硬件。 -当前仓库开发线为 `0.8.22`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 +当前仓库开发线为 `0.8.23`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 ## 🌟 特别鸣谢 diff --git "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" index 8380d14..542edd5 100644 --- "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -130,6 +130,23 @@ capability 名必须与 `kind` 同前缀。例如 scope 只能声明 `scope.*` 多通道 scope 如声明 `scope.capture_waveforms`,必须先配置全部通道,只执行一次 acquisition 和 OPC 等待,再逐通道读取。不得退化为每个通道独立触发。 +### 采用 scope R1.3 扩展 + +准备实现截图、采集控制或 typed trace 时,先阅读 +[scope 通用扩展接口 RFC](../rfcs/WaveBench_scope通用扩展接口RFC.md)和 +[核心实施说明](../rfcs/WaveBench_scope通用扩展接口RFC_核心实施说明.md)。采用条件如下: + +- wheel 依赖和 descriptor 均要求 WaveBench `0.8.23` 或更高的 `0.8.x` 版本; +- 从 `wavebench.instruments` 导入公共 Protocol、model 和 `ScopeDescriptorExtensions`; +- descriptor 提供 capability 对应的静态 profile; +- driver 实现 snapshot、baseline、restore 和 fresh verify,不把 session token 暴露给插件代码; +- `CHDR`、`CORD`、`WFSU` 等临时 transfer 设置逐字段映射、恢复和核对; +- binary framing 与具体 resource/backend 的 EOM 能力一致,不能用短读、换行或 timeout 猜测边界; +- fake conformance、包检查和实机验收分别通过后,再修改正式 descriptor。 + +未采用新增 capability 的旧插件不需要提高核心版本下限。旧 `scope capture --screenshot` 不承载 +新 `scope.screenshot_v2`;新插件应使用独立截图 Service 或 `wavebench scope screenshot capture`。 + ## 配置 options 插件私有配置放在对应的 `[.options]` 表中,并为每个键定义 `OptionSpec`。适合 `OptionSpec` 的内容包括分块点数、插件专用超时和明确枚举;resource、backend、通用 timeout、安全限制和输出状态仍由核心配置管理。 diff --git "a/docs/project/reference/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" index 83df36d..10ad87c 100644 --- "a/docs/project/reference/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" @@ -783,3 +783,43 @@ correction_limited,slope_limited - 曲面矩阵只来自 CSV 已有节点。`failed` 点写为缺失值并留洞,绝不补点或域外外推;圆点才表示真实采样。`warning` 点和成功自动恢复点使用独立标记,hover 保留首次 warning、重试次数、首次与最终采集路径。 - 单幅值或不足 2 × 2 的 response 不生成伪曲面,只保留静态 Bode 图。缺少 `report3d` extra 时报告仍能生成,并显示安装提示。 - 移动 `report.html` 时必须同时携带同级 `report-assets/`。PDF compact 路径完全不加载 Plotly,继续作为单文件静态视觉归档。 + +## Scope R1.3 操作产物 + +`wavebench scope screenshot capture` 和 `wavebench scope trace fetch` 分别写入显式指定的 PNG/NPY +文件,并写入一个 JSON artifact。默认 artifact 路径是在二进制输出路径后追加 `.json`;已有文件 +不会被覆盖。 + +Service 结果 schema 固定为: + +```json +{ + "schema": "wavebench.scope.result.v1", + "result": {}, + "diagnostics": { + "schema": "wavebench.scope.operation.v1", + "schema_version": 1 + }, + "observed_state": null, + "files": {} +} +``` + +`result` 只保存可审计摘要: + +- screenshot 保存媒体类型、尺寸、请求与实际请求、framing、payload 字节数和 SHA-256; +- trace 保存 typed metadata、点数、dtype、payload 字节数和 SHA-256;其中摘要对象是按 C order + 排列的数组原始字节,不是 `.npy` 容器文件; +- acquisition 保存类型化运行状态或完成证据。 + +`diagnostics` 保存 correlation、context、session epoch、UTC 时间、deadline 来源、phase、binary +budget 摘要、baseline nonce 摘要、恢复、fresh verify、error policy 和最终 session health。原始图片、 +波形数组、完整命令、resource 和 baseline nonce 不写入 JSON artifact。`files` 在持久化 artifact +中只保存文件名;CLI 当前结果可以显示调用方提供的完整路径。 + +PNG/NPY 写入失败或 artifact 写入失败都必须按失败处理。CLI 目前不使用覆盖选项;需要重跑时应 +选择新的输出路径或先由人工处理旧文件。Service 已产生结构化 operation diagnostics 后失败时, +CLI 仍会在请求的 artifact 路径写入 `status = "failed"`、安全错误 envelope 和恢复证据;不会创建 +伪成功的 PNG/NPY。artifact 自身无法写入时,CLI 删除本轮刚生成的 PNG/NPY,并在错误结果中 +报告 `scope_artifact.reason_code = "write_failed"`;若部分输出无法删除,还会报告 +`scope_output.reason_code = "remove_failed"`。 diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index e8f67e9..900bd1a 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -139,6 +139,7 @@ def descriptor() -> InstrumentDescriptor: | `distribution`、`version`、`source`、`origin` | entry point 加载后由 registry 按已安装分发覆盖 | 不得用于插件内部授权、信任或功能分支 | | `scope_coupling_policy` | 值由类型约定为三种策略 | scope 必须准确声明;无法证明时使用 `unknown`,核心会默认拒绝无法确认高阻的采集 | | `config_fields` | 当前只展示;为空时由 `option_specs` 推导 `options.` | 只列出用户实际可配置的字段,不代表核心会按此字段授权 | +| `scope_extensions` | 仅允许 scope descriptor 使用,类型必须为 `ScopeDescriptorExtensions` | 为 R1.3 capability 提供静态截图、采集控制和 trace profile;旧插件保持 `None` | ### `scope_coupling_policy` @@ -236,7 +237,10 @@ factory 必须同步返回,不得返回 coroutine、context manager 或 `(driv capability 必须与 descriptor 的 `kind` 使用相同前缀。当前 V2 loader 会拒绝未知 capability,但不会单独拒绝「已知但前缀属于其他 kind」的组合;插件测试必须覆盖该项。 -方法签名以 [`contracts.py`](../../../../src/wavebench/instruments/contracts.py) 为准,返回对象以 [`models.py`](../../../../src/wavebench/instruments/models.py) 为准。当前 capability 到方法的映射如下。 +旧接口的方法签名以 [`contracts.py`](../../../../src/wavebench/instruments/contracts.py) 为准,返回 +对象以 [`models.py`](../../../../src/wavebench/instruments/models.py) 为准。Scope R1.3 的 Protocol +和 model 由 [`scope_extensions.py`](../../../../src/wavebench/instruments/scope_extensions.py) 定义, +并从 `wavebench.instruments` 导出。当前 capability 到方法的映射如下。 ### Scope @@ -261,9 +265,46 @@ capability 必须与 descriptor 的 `kind` 使用相同前缀。当前 V2 loader | `scope.fft_status` | `get_fft_status` | | `scope.reference_metadata` | `get_reference_waveform_metadata` | | `scope.cursor_readout` | `get_cursor_readout` | +| `scope.screenshot_profile` | `get_screenshot_profile` | +| `scope.screenshot_v2` | `get_screenshot_profile`、`capture_screenshot`、`snapshot_screenshot_state`、`restore_screenshot_state`、`verify_screenshot_state_restored` | +| `scope.acquisition_run_state` | `get_acquisition_run_state` | +| `scope.acquisition_control` | `get_acquisition_run_state`、`start_continuous`、`stop_acquisition`、`acquire_single`、`snapshot_acquisition_control`、`restore_acquisition_control`、`verify_acquisition_control_restored` | +| `scope.trace_metadata` | `get_trace_metadata` | +| `scope.fetch_trace` | `get_trace_metadata`、`fetch_trace`、`snapshot_trace_transfer_state`、`restore_trace_transfer_state`、`verify_trace_transfer_state_restored` | +| `scope.error_drain_v1` | `drain_errors` | `scope.capture_waveforms` 的固定语义是:先配置全部目标通道,只执行一次 acquisition 和 OPC 等待,再逐通道读取。不得静默退回逐通道重复触发。回调、失败时部分结果和返回字典的签名以 `MultiChannelScopeDriver` 为准。 +### Scope R1.3 扩展 + +R1.3 的 Protocol 和 model 从 `wavebench.instruments` 导出。声明任一新增 capability 时,wheel +依赖和 descriptor 的 `wavebench_min_version` 都必须为 `0.8.23` 或更高的 `0.8.x` 版本。 + +profile 依赖如下: + +| capability | 必需 descriptor profile | +| --- | --- | +| `scope.screenshot_profile`、`scope.screenshot_v2` | `scope_extensions.screenshot_profile` | +| `scope.acquisition_control` | `scope_extensions.acquisition_control_profile` | +| `scope.trace_metadata`、`scope.fetch_trace` | `scope_extensions.trace_profile` | + +`scope.acquisition_control` 还必须同时声明 `scope.acquisition_run_state`。缺少 profile、方法或核心 +版本门时,核心会拒绝 descriptor;只实现方法而不声明 capability,不会产生隐式能力。 + +公共调用入口为 `ScopeService` 和以下 CLI: + +```text +wavebench scope screenshot profile +wavebench scope screenshot capture +wavebench scope acquisition status|start|single|stop +wavebench scope trace metadata|fetch +``` + +旧 `scope capture --screenshot` 继续服务声明 `scope.screenshot` 的插件。插件同时声明旧能力 +和 `scope.screenshot_v2` 时,旧命令仍走 legacy 路径;只有 v2、没有旧能力时,核心会在仪器 +I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot capture`。父 capture 字段 +闭包在后续实现前不得由插件自行模拟。 + ### Source | capability | 必须可调用的方法 | diff --git a/docs/project/rfcs/README.md b/docs/project/rfcs/README.md index 63fabf5..6133d2b 100644 --- a/docs/project/rfcs/README.md +++ b/docs/project/rfcs/README.md @@ -12,3 +12,6 @@ RFC 使用以下状态: ## 当前 RFC - [transport 重放与 session 健康 RFC](WaveBench_transport重放与session健康RFC.md):定义查询重放、结构化传输错误、共享 session 健康状态、恢复授权和版本迁移。 +- [scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md):`Accepted R1.3`,定义 operation context、binary budget、截图、采集控制、trace、错误策略及恢复验证合同。公共合同已进入核心 `0.8.23` 开发线;插件按 capability 单独 opt-in。 +- [scope 通用扩展接口 RFC:R1.3 Acceptance Addendum A1](WaveBench_scope通用扩展接口RFC-R1.3-acceptance-addendum.md):记录公共 capability 注册采用的 P0/P1 验收门和离线完成证据。 +- [scope 通用扩展接口 RFC:核心实施说明](WaveBench_scope通用扩展接口RFC_核心实施说明.md):记录 backend、公共 Service/CLI、artifact、版本门、旧 capture 分流和插件迁移边界。 diff --git "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC-R1.3-acceptance-addendum.md" "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC-R1.3-acceptance-addendum.md" new file mode 100644 index 0000000..2fb9be8 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC-R1.3-acceptance-addendum.md" @@ -0,0 +1,79 @@ +# WaveBench scope 通用扩展接口 RFC:R1.3 Acceptance Addendum A1 + +> 状态:`Accepted`(核心 `0.8.23` 开发线已通过离线 A1 门) +> 适用正文:[WaveBench scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md) +> 目的:记录从内部基础设施到公共 capability 注册的验收门和完成证据 +> 同步来源:WaveBench Instrument Plugins `a013891` + +本文件是总 RFC 第十二节的可单独审阅索引,不是第二套并行合同。字段、Protocol、数值和 +失败语义以总 RFC 为唯一事实源;本文件记录核心 `0.8.23` 开发线完成公共注册时采用的验收顺序。 + +## 验收结果 + +| 项目 | 结果 | 证据 | +| --- | --- | --- | +| P0/P1 核心 fixture | 通过 | `tests/test_scope_phase_coordinator.py`、`tests/test_scope_binary_contract.py`、`tests/test_scope_extension_service.py` | +| 两类 backend wrapper | 通过 | PyVISA 与 RsInstrument 的 VISA `INSTR` definite-block/message EOM;SocketIO/serial 零发送拒绝 | +| transfer、截图和采集 fixture | 通过 | 核心 fake fixture 与 SDS800X HD conformance fixture | +| 公共接口 | 通过 | `ScopeDescriptorExtensions`、公共 capability/operation registry、`ScopeService`、CLI、artifact schema、`0.8.23` 版本门 | +| 旧插件兼容 | 通过 | DS1000Z、RTM2000、SDS800X HD 离线插件测试;未声明新 capability 的 descriptor 行为不变 | +| SDS3000 迁移审计 | 插件侧待同步 | 运行相关测试通过;手工 capability 矩阵仍按旧核心 19 项记录,需更新为 26 项后再决定 opt-in 范围 | +| 实机覆盖 | 不属于离线 A1 结论 | 每个准备 opt-in 的插件仍需单独执行 resource/backend 和仪器状态恢复验收 | + +离线 A1 通过只允许核心发布合同和插件开始迁移,不表示某个型号已经通过新 capability 的实机 +验收。 + +## 历史实施边界 + +公共注册前,核心只允许实现下列私有或 feature-gated 组件: + +- operation context、非嵌套 phase coordinator 和跨 phase 不重置的 binary ledger; +- acquisition、screenshot、transfer 的 typed snapshot / baseline / restore / verify 模型; +- capability-method/descriptor gate、legacy artifact 和 fake/conformance fixture。 + +## P0 门 + +1. 核心必须实现 `ScopeTraceTransferRecoveryDriver`;`fetch_trace` 在 transfer 状态可能变化时必须 + 使用带 `context_id`、epoch、nonce 的 `ScopeTraceTransferBaseline`,并用 descriptor profile + 的固定 restore order/step 上限完成逐字段 restore/verify;`CHDR`、`CORD`、`WFSU` 等字段 + 不得只靠文字承诺。 +2. 核心必须实现 `ScopeDescriptorExtensions`、`SCOPE_CAPABILITY_METHODS` 和 required Protocol; + 缺 profile 或方法时在零 I/O 阶段拒绝,方法存在但未声明 capability 时不自动暴露。 +3. 核心固定并测试以下常量: + + | operation | response / total / query / resync | default timeout | + | --- | --- | --- | + | `scope.screenshot_v2` | `262144 / 262144 / 1 / 0` | `5000 ms` | + | `scope.acquisition_start/single` | binary `—` | `30000 ms` | + | `scope.fetch_trace` | `8388608 / 67108864 / 256 / 65536` | `60000 ms` | + + profile/connection 只能收紧;超出同步上限、无法证明边界或终止设置恢复失败时统一 + close + `poisoned`。 +4. `OperationRequest.deadline`、`before_and_after` 默认 error timing、recovery `disabled`、 + 每次 I/O 的剩余 deadline 计算和 artifact 字段已有负向测试。 + +## P1 门 + +- 旧 capture 嵌入 screenshot 只采用父 operation 字段闭包;没有完整字段闭包则 I/O 前拒绝, + 截图或恢复失败使父 capture 失败,不注册 composite operation。 +- screenshot、acquisition、transfer baseline 必须绑定 context、session epoch、opaque nonce, + 按一次性消费状态拒绝重放。 +- `identity_delta` 只有在 `ScopeAcquisitionControlProfile.identity_semantics` 为 + `unique_within_session_epoch` 时可用;否则只接受完整 state transition。 +- phase coordinator 必须通过现有 normal gate 与 recovery/verification authorization 的 + 非嵌套顺序桥接,driver 不接收 session token。 +- R1.3 公共 trace 只包含 analog/digital/reference;spectrum、math、fft_phase、frequency + axis 和新增单位移入后续 RFC。 + +## 退出条件 + +核心按以下条件决定是否注册 capability: + +1. P0/P1 fake/conformance fixture 全部通过; +2. 至少两个独立仪器族或 backend 证明 transfer restore、binary framing 和失败恢复; +3. Service、CLI、descriptor、registry、artifact schema 和版本门完成核心评审; +4. 未决的 trace extensions、continuation 和 poisoned-session reopen 设计不被当前 capability + 隐式引用。 + +上述条件已在核心 `0.8.23` 开发线的离线验收中满足。插件现在可以准备迁移,但只有在插件自身 +conformance、包检查和实机验收通过后,才能在正式 descriptor 中声明对应 capability。 diff --git "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC.md" "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC.md" new file mode 100644 index 0000000..de526d4 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC.md" @@ -0,0 +1,2287 @@ +# WaveBench scope 通用扩展接口 RFC + +> 状态:`Accepted`(核心 `0.8.23` 开发线已实现公共合同) +> 修订:`R1.3`(核心复审增补) +> 证据仓库:WaveBench Instrument Plugins +> 核心评审基线:WaveBench `0.8.22`,`origin/master@006c431` +> 目标版本:WaveBench `0.8.23` +> 同步来源:WaveBench Instrument Plugins `a013891`;核心副本补充实施状态与跨仓库证据链接。 + +## 摘要与状态边界 + +本文冻结 WaveBench scope 通用扩展合同。核心 `0.8.23` 开发线已经实现并注册本文定义的 +capability、operation、Service、CLI、artifact 和版本门。具体实现状态与插件采用条件见 +[核心实施说明](WaveBench_scope通用扩展接口RFC_核心实施说明.md)。 + +本修订吸收了对 `R1.1` 和前一轮 `R1.2` 复审意见,重点暂定四类此前仍有歧义的安全规则: + +1. action-specific `OperationSpec`、binary budget、Service、访问策略、资源租约、会话健康和 artifact; +2. `MESSAGE` binary 的边界、核心强制上限、超限、部分响应和失步处理; +3. 采集运行状态的状态机、单次采集完成证据、超时和恢复事务; +4. 类型化 trace 的首版运算/单位范围,以及三态错误检查的未知能力和继续策略。 + +核心已经注册下文新增的 operation 和 capability。插件不会因此自动获得新能力;SDS800X HD +当前正式 descriptor 仍只声明 `scope.idn`、 +`scope.channel_coupling`、`scope.fetch_waveform`、`scope.capture_waveform`、 +`scope.capture_waveforms` 和 `scope.measurement_statistics`。 + +本文把内容分为三类: + +- **现状证据**:已有核心代码、插件离线测试或受控实机观察; +- **规范合同**:核心与 opt-in 插件必须共同遵守的公共接口; +- **后续问题**:不属于 R1.3 公共范围的设计,不得由插件自行扩展。 + +### R1.3 本轮复审回应 + +| 核心阻断项 | 本修订的合同处理 | 状态 | +| --- | --- | --- | +| 失败恢复没有 driver 边界 | acquisition/screenshot 均提供 typed snapshot、core-owned baseline、restore result 和 fresh-snapshot verify;恢复阶段、字段顺序、epoch 与异常优先级固定 | 已实现并测试 | +| binary budget 与子授权冲突 | 选择单一 operation context;各阶段顺序授权且不嵌套,所有 binary phase 引用同一 ledger,error phase 不得创建或重置额度 | 已实现并测试 | +| count modulus 不能识别回绕/复位 | 删除 modulus proof;`count_delta_with_epoch` 必须联合未变化 `counter_epoch` 和有效 `state_transition`,否则改用 identity/state proof | 已由 fixture 固定 | +| 旧 `scope.errors` 与 typed drain 混用 | `scope.error_drain_v1` 独占 `max_records+1`;旧 `scope.errors` 保持 `legacy_unstructured`,`terminated/query_count=null`,未来 typed direct drain 另立 operation | 已按兼容边界实现 | + +本表记录核心裁决。插件是否迁移仍由各插件 descriptor 和验收证据决定。 + +核心后续复审指出的 P0/P1 项由第十二节 `R1.3 acceptance addendum` 和配套的 +[A1 索引](WaveBench_scope通用扩展接口RFC-R1.3-acceptance-addendum.md)收口:transfer recovery、 +capability-method/descriptor 映射、数值上限与 deadline 已写成候选合同;旧 capture 采用唯一 +的父 operation 字段闭包;baseline 增加 context/nonce/一次性消费;identity proof 改为静态 +profile 事实;trace 的 spectrum/math 开放项排除在首轮公共实施外。第十二节 A1 已在核心 +`0.8.23` 开发线通过离线验收,公共 capability 已注册;插件仍须完成自身 conformance 和实机验收。 + +## 术语与分层 + +| 术语 | 含义 | 不应混淆的对象 | +| --- | --- | --- | +| transport message | 一次 transport 交换,由 backend 证明起止边界 | TCP `recv()` 的物理分片 | +| application chunk | 一个波形或文件协议定义的逻辑分块 | transport message;一次 query 的任意重试 | +| operation | 由核心注册并受 `OperationSpec` 约束的公共动作 | driver 私有辅助函数 | +| capability | descriptor 声明的可发现能力 | 方法存在但未声明的偶然实现 | +| session health | 当前连接 epoch 的健康状态 | 仪器前面板显示的运行状态 | +| artifact | Service 产生的可审计结果和诊断元数据 | 原始二进制 payload 或完整命令日志 | + +transport 只负责可靠地交付字节和结构化 I/O 状态;PNG、波形 preamble、样本字节序和数学 +结果属于更高层的 driver/model。应用分块属于波形协议层,不能因为一次 `DATA?` 返回分成 +多次底层 read 就自动生成多个 application chunk。 + +## 当前证据 + +| 证据编号 | 功能 | 证据 | 当前结论 | +| --- | --- | --- | --- | +| `E-BLOCK-DS` | definite block 截图 | DS1000Z [driver 测试](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/master/packages/wavebench-rigol-ds1000z/tests/test_driver.py),离线 fake transport | 现有 `query_bin_block()` 可覆盖该类仪器,尚不是 backend EOM 实机证据 | +| `E-BLOCK-RTM` | definite block 截图、菜单和颜色控制 | RTM2000 [driver 测试](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/master/packages/wavebench-rohde-schwarz-rtm2000/tests/test_driver.py),离线 fake transport | 旧 screenshot 参数只对部分设备可映射 | +| `E-MESSAGE-SDS` | raw PNG message | SDS804X HD 固件 `4.8.12.1.1.6.5`、PyVISA TCPIP `INSTR` / VXI-11 受控观察,见[硬件验收](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/master/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_HARDWARE_ACCEPTANCE.md) | 只有一个仪器族的一次完整 raw PNG 观察,尚未证明通用 backend EOM 合同 | +| `E-CAPTURE` | SINGLE、Stop 轮询、同次多通道读取 | 同一 SDS804X HD 实机记录和[插件测试](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/master/packages/wavebench-siglent-sds800x-hd/tests/test_driver.py) | 证明 vendor transaction 可行,不证明核心已有独立控制 API | +| `E-TRACE-RTM` | math/FFT 元数据 | RTM2000 [覆盖矩阵](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/master/packages/wavebench-rohde-schwarz-rtm2000/doc/RTM2000_COVERAGE_MATRIX.md)与离线测试 | 只有一类可复用 trace 证据 | +| `E-TRACE-SDS` | SDS math 函数关闭 | SDS804X HD 受控探测 | 不能为 SDS 构造通用 math/FFT trace | +| `E-ERROR-SDS` | 无文档化错误队列 | CN11G 手册审计和实机边界 | 不能发送猜测命令,也不能返回伪造空队列 | + +当前证据支持核心发布 R1.3 合同和插件开始 opt-in 迁移。PyVISA、RsInstrument 与 SDS800X HD +fixture 已覆盖离线 backend、失败恢复和状态机语义;这些证据不替代具体型号的实机验收。 + +## 目标 + +- 让 definite block 和 message-bounded binary 使用同一套 replay、session health、access + policy、租约和审计合同。 +- 让核心公共合同不暴露 backend session;当前 Python 插件仍是受信任代码,本文不声称提供 + 进程级或运行时沙箱隔离。 +- 让截图请求只使用仪器明确声明支持的 request tuple,并记录实际生效请求。 +- 区分采集运行阶段、触发模式、平均/分段进度和完整 capture 事务。 +- 让模拟、数字、数学、参考和频域轨迹使用同一套 source、坐标轴、单位和数组不变量。 +- 让错误检查策略区分「能力明确不支持」「能力未知」「查询失败」和「设备返回错误」。 +- 保留现有 capability,通过新增 capability 和核心版本门逐步迁移。 + +## 非目标 + +- 不在 transport 中解析 PNG、波形 preamble 或厂商文件格式。 +- 不开放 raw SCPI、backend session、插件 parser 回调或任意 terminator。 +- 不把 `*OPC?` 定义为物理触发完成条件。 +- 不在本文设计数学表达式写入、FFT 配置或通用运算 AST。 +- 不废弃现有 `scope.fetch_waveform`、`scope.capture_waveform(s)`、`scope.screenshot` 或 + `ScopeAcquisitionStatus`。 +- 不为没有文档化错误队列的仪器猜测命令。 +- 未声明新 capability 的插件不提高核心版本下限;声明任一 R1.3 capability 的插件必须要求 + WaveBench `0.8.23` 或更高的 `0.8.x` 版本。 + +## 插件信任边界 + +当前 `DriverContext.open_transport()` 返回公共 Protocol,但运行时 +`GuardedAuditedTransport.inner` 仍可被受信任 Python 插件访问。因此 R1.3 只规定「公共合同 +不提供 backend session,插件代码不得依赖它」,不声称存在安全隔离。真正的 opaque facade、 +进程隔离、运行时属性拒绝和负向沙箱测试应由单独的安全设计处理,不能借本 RFC 的措辞假装 +已经实现。 + +## 规范用语与兼容边界 + +本文中的 `MUST` 表示互操作所需的不变量,`SHOULD` 表示默认实现,`MAY` 表示可选能力。 +插件只能声明已经实现并验收的 capability,不得以方法存在为由绕过核心 Service 或 transport gate。 + +旧核心 + 新插件、新核心 + 旧插件、旧核心 + 旧插件和新核心 + 新插件四种组合都必须在 +factory、能力发现和第一次仪器 I/O 前得到确定结果;未知 capability 不能静默降级为已支持。 + +## 一、核心集成合同:OperationSpec、Service 与 artifact + +### 1.1 现有核心字段与候选扩展 + +当前核心 `OperationSpec` 与外部 access policy 已有以下安全元数据: +`effect`、`lease_mode`、`changed_fields`、`restore_coverage`、`required_verified_fields`、 +`verification_fields`、`risk_flags`、`timeout_source`、capability 要求;access policy 由核心 +外部的 `access_policy(spec)` 统一判定,不是 `OperationSpec` 自身字段。 +R1.3 operation MUST 使用这些字段进入中央 registry;不能只在 driver Protocol 中声明方法。 + +候选扩展只使用静态、可序列化的安全元数据: + +```python +@dataclass(frozen=True) +class OperationSpec: + # 省略现有字段 + postcondition_fields: tuple[str, ...] = () + cleanup_verification_fields: tuple[str, ...] = () + binary_response_max_bytes: int | None = None + binary_operation_max_bytes: int | None = None + binary_query_max_count: int | None = None + binary_resynchronization_max_bytes: int | None = None + error_check_minimum: Literal["required", "if_supported", "disabled"] | None = None +``` + +当前核心的 `_session_preflight()` 只内建 `scope.identity` verifier;表中其余 +`scope.run_state`、`scope.waveform_*`、`scope.display_*` 和 `scope.trace_configuration` 都是 +待核心实现的验证器,不是插件可以自行写入 `verified_fields` 的旁路。 + +`scope.screenshot_profile` 和 profile variant 是 descriptor/profile 事实,不属于连接 epoch 的 +`verified_fields`;它们必须先在核心内存中完成静态校验,再作为 operation 输入约束。只有从 +仪器读回并由核心 verifier 校验的状态,才能进入 session verification fields。插件不得直接 +调用 session state 的内部方法或写入 verified fields。 + +R1.3 暂定四个独立 binary 限制:`binary_response_max_bytes` 限制每次 payload, +`binary_operation_max_bytes` 限制一个 operation 内所有 binary payload 的累计值, +`binary_query_max_count` 限制 query 次数,`binary_resynchronization_max_bytes` 限制超限或异常后为寻找 +已证明边界而额外丢弃的字节数。会产生 binary response 的 operation 中,前三者必须是 +有限正整数,resynchronization 必须是有限、非 bool 的非负整数;`0` 表示超限后不做额外 +丢弃,直接关闭/毒化。非 binary operation 的对应字段可为 `None`。profile 和 connection 只能 +给出更小的对应限制。 +核心 Service 在 operation 开始时分别计算有效值,并向 guarded transport 安装 opaque、 +短生命周期的 +`BinaryQueryBudget`。transport 每次 binary query 都必须验证 budget 与 operation context、phase、 +correlation 和 session epoch 匹配;插件只能进一步收紧单次上限,不能提高或重置累计额度。没有 budget 的新 +`query_binary()` 调用在发送前拒绝;旧 `query_bin_block()` 兼容入口使用核心固定有限上限。 + +现有 `verification_fields` 只表示按 `restore_coverage` 恢复到 baseline 后必须闭合的字段, +不用于表示读操作的观察结果,也不用于证明有意保留的控制状态。 +`postcondition_fields` 声明成功后由 action-specific result verifier 验证的目标状态; +`cleanup_verification_fields` 声明失败或取消后 best-effort cleanup 要验证的目标状态。 +后两者的证据只进入 operation result/artifact,不写入 session `verified_fields`;若同一字段还要 +恢复 baseline,必须另外列入 `verification_fields`。 + +`error_check_minimum` 为 `None` 时,该 operation 不接受 error-check override,也不触发 +错误队列 I/O;为三态值时,请求可以选择同等或更强的策略,但不能削弱静态最低值。 +完整解析规则见第六节。输入/输出 schema、取消、幂等性和并发策略仍可作为后续核心扩展, +但在 R1.3 中不把任意 Python 回调塞入公共合同。前置条件、恢复覆盖、验证字段、 +error policy 和 binary budget 必须可序列化、可审计。 + +字段 verifier 归属如下: + +| 字段 | 类型 | verifier / 来源 | +| --- | --- | --- | +| `scope.identity` | 仪器状态 | 现有核心 identity verifier | +| `scope.run_state`、`scope.trigger`、`scope.acquisition` | 仪器状态 | 核心 acquisition verifier | +| `scope.display_menu`、`scope.display_color` | 仪器状态 | 核心 screenshot restore/verification verifier | +| `scope.waveform_source`、`scope.waveform_mode` | 仪器状态 | 核心 waveform-source/mode verifier | +| `scope.query_response_header`、`scope.waveform_format`、`scope.waveform_byte_order`、`scope.waveform_points`、`scope.waveform_transfer_window` | 仪器状态 | 核心 waveform-transfer verifier | +| `scope.trace_configuration` | 仪器状态 | 不属于 R1.3 公共 fetch 范围,留给后续 RFC | +| `scope.screenshot_profile` | descriptor/profile 事实 | 核心 profile validator,不写入 session verified fields | +| `scope.acquisition_control_profile` | descriptor/profile 事实 | 核心静态 acquisition-control profile validator,不写入 session verified fields | +| `scope.trace_profile` | descriptor/profile 事实 | 核心静态 trace profile validator,不写入 session verified fields | +| `scope.error_queue` | 条件性消耗状态 | 核心 error-policy executor;只作为 changed field/artifact,不写入 verified fields | + +波形协议字段的最小映射必须保持显式: + +| 厂商状态示例 | 核心字段 | changed / verification 要求 | +| --- | --- | --- | +| `CHDR` 响应头/头部模式 | `scope.query_response_header` | 临时改变时两者都必须列出 | +| `CORD` 字节序 | `scope.waveform_byte_order` | 临时改变时两者都必须列出 | +| `WFSU` 格式、宽度、点数和窗口 | `scope.waveform_format`、`scope.waveform_points`、`scope.waveform_transfer_window` | 每个实际改变的字段都必须逐项列出 | + +### 1.2 Operation 映射 + +下表是 R1.3 的最小候选映射,字段完整不等于合同已经冻结。Service 和 CLI 项都是候选入口, +当前不存在,不能在插件侧自行模拟。所有 operation 的 `session_purpose` 为 `normal`;超时后的安全停止由核心另行签发 +有界 `recovery` transaction。R1.3 保守地为所有仪器 operation 使用 `exclusive` lease,因为 +当前 `ScopeService` 的 session lease 不会按 `OperationSpec.lease_mode` 动态切换。以后若开放 +共享只读 session,需要单独证明 backend、仪器和 transaction lock 的并发语义。 + +| operation | capability | effect / lease | changed_fields | restore_coverage | required_verified_fields | verification_fields | postcondition / cleanup fields | risk_flags | timeout_source | binary response / operation / query / resync limits | error minimum | 最低 access | Service / CLI / artifact | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `scope.screenshot_profile` | `scope.screenshot_profile` | `stateful_read` / `exclusive` | `none` | `none` | `scope.identity` | `none` | — / — | `profile_query` | `operation.timeout_ms=5000` | — | — | `read_only` | `ScopeService.screenshot_profile()` / `wavebench scope screenshot profile` / `screenshot.profile` | +| `scope.screenshot_v2` | `scope.screenshot_v2` | `write` / `exclusive` | `scope.display_menu`, `scope.display_color`, `scope.error_queue`, `output.screenshot` | `screenshot-baseline-only` | `scope.identity` | `scope.display_menu`, `scope.display_color` | — / `scope.display_menu`, `scope.display_color` | `front_panel_state`, `binary_response`, `temporary_display_setup` | `operation.timeout_ms=5000` | `262144 / 262144 / 1 / 0` | `disabled` | `read_write` | `ScopeService.screenshot_v2(request)` / `wavebench scope screenshot capture` / `screenshot`、`effective_request`、`media_type`、`dimensions`、`framing` | +| `scope.acquisition_run_state` | `scope.acquisition_run_state` | `stateful_read` / `exclusive` | `none` | `none` | `scope.identity` | `none` | — / — | `state_observation` | `operation.timeout_ms=5000` | — | — | `read_only` | `ScopeService.acquisition_run_state()` / `wavebench scope acquisition status` / `acquisition.run_state` | +| `scope.acquisition_start` | `scope.acquisition_control` + `scope.acquisition_run_state` | `write` / `exclusive` | `scope.run_state`, `scope.trigger`, `scope.acquisition`, `scope.error_queue` | `failure-cleanup-only` | `scope.identity` | `scope.trigger`, `scope.acquisition` | `scope.run_state`, `scope.trigger`, `scope.acquisition` / `scope.run_state`, `scope.trigger`, `scope.acquisition` | `trigger`, `acquisition_state`, `recovery_required` | `operation.timeout_ms=30000` | — | `disabled` | `read_write` | `ScopeService.start_acquisition(request)` / `wavebench scope acquisition start` / `acquisition.control`、`effective_trigger_mode`、`postcondition`、`cleanup` | +| `scope.acquisition_single` | `scope.acquisition_control` + `scope.acquisition_run_state` | `acquire` / `exclusive` | `scope.run_state`, `scope.trigger`, `scope.acquisition`, `scope.error_queue` | `failure-cleanup-only` | `scope.identity` | `scope.trigger`, `scope.acquisition` | `scope.run_state`, `scope.trigger`, `scope.acquisition` / `scope.run_state`, `scope.trigger`, `scope.acquisition` | `trigger`, `acquisition_state`, `recovery_required` | `operation.timeout_ms=30000` | — | `disabled` | `read_write` | `ScopeService.acquire_single()` / `wavebench scope acquisition single` / `acquisition.control`、`postcondition`、`completion_proof`、`cleanup` | +| `scope.acquisition_stop` | `scope.acquisition_control` + `scope.acquisition_run_state` | `write` / `exclusive` | `scope.run_state`, `scope.error_queue` | `failure-cleanup-only` | `scope.identity` | `none` | `scope.run_state` / `scope.run_state` | `acquisition_state`, `recovery_required` | `operation.timeout_ms=5000` | — | `disabled` | `read_write` | `ScopeService.stop_acquisition()` / `wavebench scope acquisition stop` / `acquisition.control`、`postcondition`、`cleanup` | +| `scope.trace_metadata` | `scope.trace_metadata` | `stateful_read` / `exclusive` | `none` | `none` | `scope.identity` | `none` | — / — | `analysis_state` | `operation.timeout_ms=5000` | — | `disabled` | `read_only` | `ScopeService.trace_metadata(source)` / `wavebench scope trace metadata` / `trace.metadata` | +| `scope.fetch_trace` | `scope.fetch_trace` | `acquire` / `exclusive` | `scope.run_state`, `scope.waveform_source`, `scope.waveform_mode`, `scope.query_response_header`, `scope.waveform_format`, `scope.waveform_byte_order`, `scope.waveform_points`, `scope.waveform_transfer_window`, `scope.error_queue`, `output.trace` | `trace-baseline-only` | `scope.identity` | `scope.run_state`, `scope.waveform_source`, `scope.waveform_mode`, `scope.query_response_header`, `scope.waveform_format`, `scope.waveform_byte_order`, `scope.waveform_points`, `scope.waveform_transfer_window` | — / — | `acquisition_state`, `temporary_transfer_setup`, `binary_response` | `operation.timeout_ms=60000` | `8388608 / 67108864 / 256 / 65536` | `disabled` | `read_write` | `ScopeService.fetch_trace(source)` / `wavebench scope trace fetch` / `trace`、`metadata`、`integrity`、`error_check` | + +R1.3 acceptance addendum 固定下列数值;表中 binary 列依次为 response、operation total、query +count、resynchronization bytes: + +```python +SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES = 262_144 +SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES = 262_144 +SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT = 1 +SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES = 0 + +SCOPE_TRACE_BINARY_RESPONSE_MAX_BYTES = 8_388_608 +SCOPE_TRACE_BINARY_OPERATION_MAX_BYTES = 67_108_864 +SCOPE_TRACE_BINARY_QUERY_MAX_COUNT = 256 +SCOPE_TRACE_BINARY_RESYNCHRONIZATION_MAX_BYTES = 65_536 + +SCOPE_PROFILE_OPERATION_TIMEOUT_MS = 5_000 +SCOPE_SCREENSHOT_OPERATION_TIMEOUT_MS = 5_000 +SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS = 30_000 +SCOPE_TRACE_OPERATION_TIMEOUT_MS = 60_000 +``` + +这些值是核心上限,不是 driver 默认值;descriptor/profile/connection 只能收紧,不能提高。 +`scope.screenshot_v2` 首版单次 PNG 上限为 256 KiB,依据已有 SDS raw PNG 证据保留明确余量; +`scope.fetch_trace` 每次 response 上限为 8 MiB、一次 operation 总上限为 64 MiB、最多 256 次 +binary query,并允许最多丢弃 64 KiB 以证明边界。超出 resynchronization 上限或无法证明边界时, +核心固定关闭 transport 并将 session 标记为 `poisoned`,不由 backend 自行选择 close/poison +策略。 + +`OperationRequest.deadline` 固定为单调时钟的绝对时间。未提供调用方 deadline 时,核心使用上表 +operation timeout;调用方提供更早 deadline 时只能收紧,不能延长。每次 I/O 的 timeout 为 +`min(connection.timeout_ms, deadline - monotonic_now)`,剩余时间不足 1 ms 时在发送前以 +`deadline_exhausted` 拒绝。profile source 固定采用 descriptor-first:descriptor 必须提供安全 +上限;仪器查询只能形成 `combined` 交集并收紧,`queried`-only profile 在 R1.3 拒绝。 + +`ErrorCheckSpec.timing` 的默认值固定为 `before_and_after`;screenshot、acquisition 和 trace +operation 只能使用该默认值或显式收紧为 `before`/`after`,driver 不得另行决定 timing。recovery +phase 固定为 `disabled`。文本 query timeout、binary timeout 和恢复 timeout 都受同一 operation +deadline 限制。 + +`scope.screenshot_v2` 采用保守的 `write` effect,因为某些设备需要临时写菜单或颜色设置; +只支持 `device` 行为的设备也不能在 profile 中把该 operation 降级为无状态副作用;它仍按 +`write` gate 执行。若未来需要真正的 read-only screenshot operation,应新增独立 operation +和 capability,不能由 profile 动态改变 effect。`scope.fetch_trace` 同理,默认按可能修改 +transfer 选择处理。 + +采集控制按动作拆成三个静态 operation:`scope.acquisition_start`、 +`scope.acquisition_single` 和 `scope.acquisition_stop`。它们共享 +`scope.acquisition_control` capability,但不得用一个带 `action` 参数的 operation 动态改变 +`effect`、changed fields、恢复覆盖或最低 access。`stop` 的公共入口和超时 cleanup 入口仍 +共享同一 driver 方法,但核心发放的 normal/recovery authorization 必须分别标识用途。 + +三个控制 operation 的成功 postcondition 与失败 cleanup 必须分开:`start` 成功后有意保留 +连续运行状态,`single` 成功后保留新记录已经完成的停止状态,`stop` 成功后有意保留停止 +状态。`failure-cleanup-only` 只描述写入失败、等待超时或取消后的 cleanup,不得在成功 +路径把 run state 恢复到调用前。`postcondition_fields` 在成功路径证明目标状态, +`cleanup_verification_fields` 在失败路径证明 cleanup 结果;artifact 必须用 +`postcondition` 与 `cleanup` 两个字段区分。两者都不得借用现有 `verification_fields` +向 session 伪造 baseline 恢复证据。 +对 `start` 和 `single`,失败 cleanup 在 session 仍可安全写入时必须先 STOP,再恢复并 +query-back 写入前的 `scope.trigger` 和 `scope.acquisition` baseline。只有 run state、trigger 和 +acquisition 全部验证成功时 cleanup 才是 `succeeded`;任一字段未恢复或未证明时, +session 不得回到 `healthy`。 + +`scope.screenshot_v2`、`scope.acquisition_start`、`scope.acquisition_single`、 +`scope.acquisition_stop` 和 `scope.fetch_trace` 应把 +`scope.error_drain_v1` 放入 `optional_capabilities`,是否变成当前请求的必需能力由第六节的错误策略 +和 `OperationSpec` 最低策略共同决定。profile、运行状态和 metadata 查询默认不触发错误队列, +避免一个只读观测产生额外的 consumptive read。 + +表中的 `scope.error_queue` 只在有效 error policy 实际执行完整 drain 时产生;如果 policy 为 +`disabled`,该字段不发生仪器变化,但 operation artifact 仍必须记录 `error_check.status`。 +核心实现可以用保守的静态 changed field,也可以在冻结 action-specific spec 后使用条件字段, +但不得把 consumptive error read 隐藏在普通 query 统计中。 + +波形 transfer 字段使用协议无关的核心名称;例如某些示波器的 `CHDR` 响应头、`CORD` 字节序、 +`WFSU` 格式/宽度/点数/窗口都必须映射到上述 `query_response_header`、`waveform_byte_order`、 +`waveform_format`、`waveform_points` 和 `waveform_transfer_window`。只在 +`changed_fields` 中列出「transfer」而不在 `verification_fields` 中逐项闭合,不满足本 RFC; +任一项恢复后无法由核心 verifier 证明时,operation 必须 fail-closed,不能因为 +`restore_coverage="capture-baseline-only"` 就回到 `healthy`。 +同一字段集也适用于现有 `scope.fetch_waveform`、`scope.capture`、 +`scope.capture_waveforms` 和 `scope.capture_multiple` 的核心规格;新 RFC operation 不能因为 +换成 `ScopeTraceData` 就缩小既有 transfer 恢复/验证要求。 + +现有 capture 若需要调用旧 `scope.screenshot`,R1.3 只允许「父 operation 字段闭包」方案: +父 `scope.capture_waveform`、`scope.capture_waveforms` 或 `scope.capture_multiple` 的静态 +`OperationSpec` 必须显式携带 `ScopeEmbeddedScreenshotContract`,并加入 +`scope.display_menu`、`scope.display_color`、`output.screenshot` 的 changed/verification/cleanup +字段,在同一 operation context 内按 screenshot phase 执行 +snapshot、capture、restore、verify。R1.3 不定义 composite operation,也不允许在父 operation +外单独开启 screenshot authorization;旧 capture 若没有这组字段闭包,必须在任何 I/O 前以 +`unsupported_capability` 拒绝 screenshot 请求。旧 `scope.screenshot_png()` 不能绕过该规则。 + +嵌入截图的失败语义固定为 `fail_parent`:截图 transport、PNG 校验、after error 或 restore/ +verify 失败都会使父 capture operation 失败;已经取得的 waveform 只能作为脱敏诊断摘要, +不得作为成功值返回。父 operation 只执行一次 error policy,artifact 在父记录下增加 +`screenshot.status`、`screenshot.failure_reason`、`screenshot.cleanup` 和 `screenshot.verification`, +不创建第二个 operation artifact。 +当前核心保留 `scope.screenshot` 的 legacy capture 行为。插件同时声明旧能力和 +`scope.screenshot_v2` 时,旧 capture 仍只调用 legacy 路径,不会嵌入 v2 截图;插件只有 +`scope.screenshot_v2` 而没有旧能力时,旧 capture 的截图请求会在仪器 I/O 前拒绝,并提示使用 +独立 `scope screenshot capture`。只有父 operation 字段闭包完整实现后,才能让旧 capture +直接嵌入 v2 截图。 + +### 1.3 输入、前置条件与输出 + +为避免只有 operation 名称而没有可执行边界,R1.3 规定以下最小 schema 和 Python Protocol。 +核心 `0.8.23` 已冻结 Python Service、CLI JSON artifact、取消、幂等性和并发边界: + +| operation | 输入 | 主操作写入 / binary 前置条件 | 成功输出 | +| --- | --- | --- | --- | +| `scope.screenshot_profile` | 无 | identity 已验证;profile source 可用 | `ScopeScreenshotProfile` | +| `scope.screenshot_v2` | `ScopeScreenshotRequest`、operation 级 error policy | profile 精确匹配;access 允许写入;必要的 baseline 可读 | `ScopeScreenshot` + effective request | +| `scope.acquisition_run_state` | 无 | identity 已验证;session healthy | `ScopeAcquisitionRunState` | +| `scope.acquisition_start` | `ScopeContinuousAcquisitionRequest`、operation 级 error policy | identity 已验证;phase 属于 `stopped/ready/complete`;descriptor 的 `scope_extensions.acquisition_control_profile` 已验证并支持请求 mode;trigger/acquisition baseline 可读;access 允许写入 | `ScopeAcquisitionRunState`,trigger mode 等于请求,phase 为 `ready/arming/waiting/acquiring/rolling` | +| `scope.acquisition_single` | operation 级 error policy | identity 已验证;phase 属于 `stopped/ready/complete`;descriptor 的 `scope_extensions.acquisition_control_profile` 已验证;trigger/acquisition baseline 可读;access 允许采集 | `ScopeAcquisitionCompletion`;失败则 cleanup diagnostics | +| `scope.acquisition_stop` | operation 级 error policy | normal 路径要求 identity 已验证、session healthy 且 phase 非 `unknown/error`;recovery 路径使用独立 core authorization | `ScopeAcquisitionRunState`,postcondition 为 `stopped`;recovery 另记 cleanup | +| `scope.trace_metadata` | 有效 `ScopeTraceRef` | source/index/name 不变量通过;identity 已验证 | `ScopeTraceMetadata` | +| `scope.fetch_trace` | `ScopeTraceRef`、points profile、operation 级 error policy | source 已配置;sequence/segmentation 与 profile 兼容;必要时 acquisition stopped | `ScopeTraceData` + integrity/error artifact | + +纯参数错误、能力不支持和 access 拒绝 MUST 在任何仪器 I/O 前返回。phase、baseline +和 query-back 等仪器状态前置条件可以使用核心签发的有界 preflight/verification authorization +执行只读查询,但必须在主 operation 的任何写入或 binary query 前完成。设备返回错误、 +传输失败和完成证据不足则归入对应 operation result/exception,不得伪装成参数错误。 +`scope.screenshot_v2`、三个 acquisition control operation 和 `scope.fetch_trace` 都从统一的 +`OperationRequest.error_check` 解析单 operation 覆盖;若未提供,按第六节优先级解析全局和 +`OperationSpec` 策略。driver 方法不再接受另一个独立策略来源。 + +### 1.4 请求、结果和异常边界 + +候选核心应为每个 operation 生成可审计的请求和结果,至少包含: + +```python +@dataclass(frozen=True) +class OperationRequest: + operation_id: str + arguments: Mapping[str, Any] + deadline: float | None + correlation_id: str + error_check: ErrorCheckSpec | None + +@dataclass(frozen=True) +class OperationResult: + value: object + diagnostics: Mapping[str, Any] + observed_state: Mapping[str, Any] | None +``` + +`OperationRequest.deadline` 使用单调时钟的绝对 deadline;artifact 只记录剩余时长或 +`deadline_source`,不记录进程时间戳。`correlation_id` 在一次 Service operation 及其 +recovery/verification phase 中保持不变,便于把 cleanup 和错误检查归到同一操作。 + +`operation_id` MUST 使用稳定的小写点分隔名称;版本变化通过 capability/core version gate +处理,不在同一名称下改变输入或输出语义。异常至少区分: + +- `unsupported_capability`:descriptor 明确没有能力; +- `unknown_capability`:核心或插件没有完成能力发现; +- `precondition_failed`:前置条件在任何写入和 binary 查询前失败; +- `access_denied`:访问策略不允许; +- `transport_io_error`:传输阶段失败,沿用现有结构化 framing、同步和 retry 字段; +- `completion_unproven`:操作可能已执行,但没有足够证据证明完成; +- `instrument_error`:设备明确返回错误。 + +### 1.5 恢复与 artifact 规则 + +所有可能写入仪器的 R1.3 operation MUST 在 `OperationSpec` 中声明 changed fields 和恢复覆盖。 +需要恢复 baseline 的字段写入 `verification_fields`;有意保留的成功状态和失败 cleanup +目标分别写入 `postcondition_fields` 和 `cleanup_verification_fields`。主异常不得被恢复异常覆盖; +结果或失败 artifact 应记录: + +```text +operation +correlation_id +requested_arguments(去除敏感值) +observed_state_before / observed_state_after +session_health_before / session_health_after +baseline.kind / baseline.context_id / baseline.session_epoch / baseline.nonce_digest / +baseline.fields / baseline.restore_order / baseline.consumption +postcondition.status / postcondition.reason_code / postcondition.observed_fields +completion_proof.proof / completion_proof.original_state / completion_proof.proof_baseline_state / +completion_proof.proof_baseline_stage / completion_proof.observed_states(仅 SINGLE) +cleanup.attempted / cleanup.restore / cleanup.verification / cleanup.error_code / cleanup.observed_fields +error_check +``` + +`postcondition` 必须由 operation-specific result verifier 产生,不得把 driver 返回非空当作验证。 +`scope.acquisition_single` 的 completion artifact 必须保留脱敏 baseline、完整的记录状态序列 +和 proof 分支;其他 action 不生成伪造的 completion 字段。 + +artifact 只记录 framing、长度、媒体类型、状态 token 和摘要,不记录图片、原始波形、真实 +resource、序列号或完整命令 payload。 + +### 1.6 非嵌套 operation context 与阶段授权 + +本轮增补选择「单个 operation context 下的顺序阶段授权」,不选择嵌套 session +authorization。候选核心内部模型为: + +```python +OperationPhase = Literal[ + "preflight", + "error_before", + "main", + "success_restore", + "error_after", + "failure_cleanup", + "cleanup_verification", +] + +@dataclass(frozen=True) +class _CoreOperationContext: + context_id: str + operation_id: str + correlation_id: str + session_epoch: str + deadline: float + binary_budget_ledger_id: str | None + +BaselineNonce = str + +@dataclass(frozen=True) +class _CoreBaselineUseRecord: + context_id: str + session_epoch: str + baseline_nonce: BaselineNonce + state: Literal["fresh", "passed_to_main", "restore_attempted", "consumed"] +``` + +operation context 只是核心 coordinator 持有的生命周期和额度账本,不是 session +authorization,创建它不会发送 I/O,也不会占用核心现有的 active authorization 槽位。 +核心在 access、capability、lease 和静态 profile 校验通过后,在第一次仪器 I/O 前创建一次 +context;同一公共 operation 不得创建第二个 context 来重置 deadline、correlation 或 binary +budget。 + +baseline 是 context-owned handle,不是 driver 可自行生成的状态快照。核心为每个可恢复操作 +生成一次不可预测的 `baseline_nonce`,并把 `context_id`、当前 `session_epoch` 和 nonce 写入 +baseline。nonce 只在该 context 的规定阶段内有效;核心在 baseline 传入 main 后标记 +`passed_to_main`,首次进入 success/failure restore 后标记 `restore_attempted`,随后标记 +`consumed`。任何 context、epoch、nonce、phase 或消费状态不匹配都必须在 driver I/O 前拒绝, +不得以同一连接 epoch 中的旧 baseline 重放。artifact 只记录 nonce 的脱敏摘要和消费状态, +不记录原值;driver 不得复制、替换或持久化 nonce。 + +需要动态 I/O 授权的每个阶段使用一个独立、有界的 session authorization;`main` 阶段 +沿用该公共 operation 已有的 `normal` gate,不在其中再开子授权。候选 phase 与当前 +`SessionPurpose` 的映射固定为:`preflight`/`error_before`/`error_after`/`cleanup_verification` +使用只读 `verification` 语义,`success_restore`/`failure_cleanup` 使用有界 `recovery` 语义, +`main` 使用既有 `normal` operation gate,但该 gate 的授权记录仍必须绑定本 context 的 +`context_id`、epoch、字段和 deadline;不是重新开启一个脱离 context 的 normal operation。 +若核心为 phase 增加专用枚举,必须保持同样的 I/O 白名单和非嵌套约束;driver 永远不接收 +或构造 authorization token。 + +上述阶段遵守以下硬约束: + +1. 开启新阶段前,上一阶段 authorization MUST 已关闭;任意时刻最多只有一个 active + authorization。阶段执行器不得从 active authorization 内再签发子 authorization。 +2. 每个 authorization 必须绑定 `context_id`、`session_epoch`、phase、字段白名单、 + allowed I/O、剩余 deadline 和静态 `max_steps`;阶段切换不得改变 access/lease 结论。 +3. `BinaryQueryBudget` 的可变账本归 operation context 所有,不归某个阶段 authorization + 所有。只有静态 phase contract 允许 binary query 时,guarded transport 才能在 active + authorization 中引用同一 `binary_budget_ledger_id`。创建、关闭或重试 phase 都不得 + 重建账本、增加额度或退回已消耗的 query count。 +4. session 进入 `uncertain/poisoned` 后,binary ledger 立即变为不可用,但已消耗计数保留到 + operation artifact 完成。只有静态声明的 `failure_cleanup`/`cleanup_verification` + recovery 阶段可继续;它们不得使用 binary I/O。 +5. context 在成功结束、主失败及全部允许的 cleanup/verification 结束、取消且无可执行 + recovery,或 session epoch 改变时进入 terminal。进入 terminal 后所有阶段 token 和 binary + ledger 失效,不得跨 operation 或跨 epoch 重用。 + +阶段与授权、binary 的固定对应关系为: + +| phase | session authorization purpose | 允许的 I/O | binary ledger | +| --- | --- | --- | --- | +| `preflight` | `verification` | 只读状态/profile query | 不可用 | +| `error_before` / `error_after` | `verification` | `scope.error_queue` 的文本 query | 只关联同一 context;禁止 binary 且不扣额度 | +| `main` | 既有 normal operation gate | 由 `OperationSpec` 静态声明的 write/acquire/text/binary | 若允许 binary,只能引用同一 ledger | +| `success_restore` / `failure_cleanup` | `recovery` | 有界文本 STOP、state restore write | 不可用 | +| `cleanup_verification` | `verification` | 有界文本 query-back | 不可用 | + +phase 与当前核心 session API 的衔接冻结为一个核心内部 coordinator,而不是让 driver 直接 +调用 `SessionTransactionCoordinator.authorize()`: + +```python +ScopePhasePurpose = Literal["normal", "recovery", "verification"] + +@dataclass(frozen=True) +class ScopePhaseAuthorizationSpec: + context_id: str + operation_id: str + phase: OperationPhase + purpose: ScopePhasePurpose + allowed_io: frozenset[str] + fields: frozenset[str] + deadline: float + max_steps: int + +class ScopeOperationContextCoordinator(Protocol): + def authorize_phase( + self, + spec: ScopePhaseAuthorizationSpec, + ) -> Iterator["ScopePhaseAuthorization"]: ... +``` + +`ScopeOperationContextCoordinator` 的实现规则固定为:`purpose="recovery"` 或 +`"verification"` 时,顺序调用当前 `SessionTransactionCoordinator.authorize()`; +`purpose="normal"` 时复用现有公共 operation gate。核心在两条路径都维护同一个 +`_CorePhaseAuthorizationRecord(authorization_handle_id, context_id, session_epoch, phase, purpose, +fields, allowed_io, deadline, max_steps)` 侧记录;`authorization_handle_id` 是核心生成的 +opaque 一次性 handle,侧记录以该 handle 对应的 authorization object identity 绑定,并在 +authorization 关闭后才允许下一 phase。若当前 +`SessionAuthorization` 对象没有这些字段,侧记录必须以不可伪造的 opaque authorization +identity 绑定;不能仅用 `operation_id` 或 `session_epoch` 推断 context。核心必须先扩展或 +包裹现有 gate,再开始任何新 capability 的实现;driver 只接收已授权的 transport facade, +不接收 `ScopePhaseAuthorization` 或 session token。 + +因此 `error_before`/`error_after` 即使拥有独立的顺序 authorization,也不会获得新的 +binary 权限或额度;它们只携带同一 `context_id`/`binary_budget_ledger_id` 的审计关联,账本 +计数保持不变。阶段切换时核心先关闭当前授权,再签发下一阶段授权;若当前核心的 +`authorize()` 只能接受 `recovery`/`verification` purpose,则 `main` 继续使用既有 normal +operation gate,其他 phase 按上表映射,不要求核心接受嵌套授权。 + +首版操作的阶段顺序为: + +| operation 类型 | 成功顺序 | 主操作/after 失败顺序 | +| --- | --- | --- | +| acquisition start/single | `preflight -> error_before? -> main -> error_after? -> terminal` | 关闭当前 phase 后 `failure_cleanup -> cleanup_verification -> terminal` | +| screenshot with state changes | `preflight -> error_before? -> main -> error_after? -> success_restore -> cleanup_verification -> terminal` | 关闭当前 phase 后 `failure_cleanup -> cleanup_verification -> terminal` | +| fetch/trace vendor transaction | `preflight -> error_before? -> main -> error_after? -> terminal` | 关闭当前 phase 后执行该 operation 已声明的非嵌套 restore/verification phase,然后 terminal | + +`?` 表示 error policy 决定该阶段是否存在,不表示插件可自行跳过。before/after error +executor 只是同一 context 中的独立顺序 phase,不是父/子 authorization。 +artifact MUST 记录每个 phase 的开始/结束、allowed I/O、实际 step 数、budget 前后摘要和跳过原因; +不记录 opaque token 本身。 + +## 二、binary framing 与 message 语义 + +### 2.1 两层 framing + +首版只接受两种可证明的 transport response framing: + +```python +class BinaryResponseFraming(str, Enum): + DEFINITE_BLOCK = "definite_block" + MESSAGE = "message" +``` + +- `DEFINITE_BLOCK`:应用层响应以 IEEE 488.2 `#N` 头声明 payload 长度; +- `MESSAGE`:backend 通过 EOI、VISA message END 或等价的明确事件报告一次响应结束。 + +底层 socket 的多次 `recv()`、串口暂时没有数据、读取 timeout 和换行都不是通用 message +边界。transport 不解析 PNG、波形或其他媒体;内容校验由 driver 完成。 + +### 2.2 definite block 的精确语法 + +候选 transport MUST 按以下语法解析: + +```text +response := '#' + N + length_field + payload +N := ASCII digit '1'..'9' +length_field := exactly N ASCII decimal digits +payload := exactly the declared number of bytes +``` + +`#0`(indefinite block)、缺失长度位、非 ASCII 数字、长度溢出、声明长度与已消费字节不一致 +都必须结构化失败。`#10` 表示零长度 payload 的语义是否允许由 operation profile 决定;不允许 +把 `#0` 当作零长度 definite block。解析前必须校验长度上限,不能先按声明长度分配无限内存。 + +### 2.3 `query_binary` 与上限 + +候选公共方法为: + +```python +# 概念模型:这是核心内部状态,不是公共构造函数。 +class _CoreBinaryQueryBudget(Protocol): + ledger_id: str + context_id: str + operation_id: str + correlation_id: str + per_response_max_bytes: int + remaining_operation_bytes: int + remaining_query_count: int + resynchronization_max_bytes: int + transport_trailing: bytes + expires_at: float # monotonic clock 的绝对时间 + + +@dataclass(frozen=True) +class BinaryQueryResult: + data: bytes + framing: BinaryResponseFraming + declared_length: int | None + framing_header_bytes: int + consumed_bytes: int + transport_trailing_bytes: bytes + synchronization: Literal["proven"] = "proven" + + +def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, +) -> BinaryQueryResult: ... +``` + +上面的 `_CoreBinaryQueryBudget` 是描述核心内部授权状态的概念模型,不是插件可实例化的公共输入; +文中简称 `BinaryQueryBudget`。核心只向 guarded transport 传递不可伪造的 opaque token,不把 +这些字段暴露给插件作为可修改对象。 +核心 coordinator 为一次 operation context 创建一次 budget ledger。session health 改变时立即 +禁止新 binary I/O,但 ledger 保留已消耗计数,直到第 1.6 节定义的 context terminal 时才失效; +transport 不得接受跨 context、跨 operation/correlation、跨 session epoch 或过期的 budget。 +`per_response_max_bytes` 限制每次 response;`remaining_operation_bytes` 限制一次 operation +中所有 binary response 的累计 payload;`remaining_query_count` 防止通过大量零长度响应绕过资源 +边界;`resynchronization_max_bytes` 限制失败后为证明同步而额外丢弃的字节。这些值都是核心 +在 operation 开始前计算的有限整数;前三者为正,resynchronization 可为零。多次 query 不能把额度累加成更大的单次 +response 上限,也不能通过重建 driver 对象重置任何额度。 + +`max_bytes` MUST 是核心签发的 `BinaryQueryBudget.per_response_max_bytes` 范围内的正整数; +它计量单次 response 的 payload 字节数,不包含 framing header。未配置 connection 上限时, +核心只在内部最小值计算中把对应 connection 项视为 `+∞`;operation 的 response 和 total +上限仍必须有限,公共方法不提供无限读取默认值。Service 分别计算: + +```text +effective_response_max = min(spec response, profile response, connection response) +effective_operation_max = min(spec total, profile total, connection total) +effective_query_max_count = min(spec query count, profile query count, connection query count) +effective_resynchronization_max = min(spec resync, profile resync, connection resync) +``` + +profile 或 connection 没有对应限制时只是不进一步收紧,不能取消 spec 的有限值。 +Service 在第一个阶段授权前向 operation context 安装与 context/operation/correlation/epoch +绑定的 opaque budget ledger。没有 ledger 或 active phase 不允许 binary I/O 的新 +`query_binary()` 调用必须在 `BEFORE_SEND` 以 `NOT_SENT` 拒绝。现有 `query_bin_block()` 保留 +为 definite block 兼容入口。它在 legacy operation 中使用核心固定的有限兼容上限;在已安装 +R1.3 budget 的新 operation 中必须消耗同一个 response/operation/query/resync budget,不得建立第二套 +兼容额度。新 operation 若无 R1.3 budget,通过 `query_bin_block()` 也必须在发送前拒绝。 +核心冻结 legacy 兼容上限时必须覆盖现有已接受 operation 的合法 payload,或给旧 operation 保留 +独立的有限 spec;不能在没有迁移说明的情况下静默降低既有波形读取上限。 + +如果 operation 使用 binary profile,则每个参与的 variant 必须提供有限正整数的 response、 +operation-total 和 query-count,以及有限非负整数的 resynchronization 限制;没有 profile 层的 operation 直接使用 +spec 与 connection 的最小值。 +显式 connection 限制使用同一数值规则。上限比较和计数使用整数 bytes,不能用浮点近似 +或按样本点数替代。 +每个 core-validated profile variant 只能声明一个精确 transport trailing byte sequence; +R1.3 暂定最长 16 bytes,不允许备选集合、正则、回调或「任意空白」。这避免空字符串 +与非空 terminator 形成前缀歧义,导致 backend 在 declared payload 后继续阻塞或吞掉下一响应。 +MESSAGE 只允许空 transport trailing。核心把已验证的精确 sequence 放入 opaque budget, +插件不得通过 `query_binary()` 参数临时放宽。 + +实现时必须同时修改 `InstrumentTransport` Protocol、全部 backend、 +`GuardedAuditedTransport`、session `_AUTHORIZED_IO` / `_VERIFICATION_IO`、审计计数器和 +结构化错误映射。`BinaryQueryBudget` 的创建、消费和失效只能由核心 coordinator 完成; +只给某个 backend 增加方法会绕过核心会话授权,不符合候选合同。 + +budget 不作为 `query_binary()` 的插件参数。核心 coordinator 在 operation context 中安装 +opaque ledger;每个允许 binary I/O 的非嵌套 phase authorization 只引用它的 +`ledger_id`。`GuardedAuditedTransport.query_binary()` 同时验证 active phase、context/epoch 和 +ledger 绑定,再把已计算的有效上限传给 backend。插件只提交 +`max_bytes <= per_response_max_bytes` 的收紧值;它无法看到、构造或替换 token。guarded +transport 在同一 transaction lock 内计算 +`effective_query_max = min(max_bytes, per_response_max_bytes, remaining_operation_bytes)`;结果不为正整数或 token +失效时,在发送前以 `NOT_SENT` 拒绝。guarded transport 在发送前还必须原子预留一个 +`remaining_query_count`;额度为零时不发送。backend 必须以该有效值执行单响应限制; +成功后再原子扣减 `len(result.data)`,不计 framing header 和已明确限定的 transport trailing。 +扣减前 guarded transport 必须再验证 `len(result.data) <= effective_query_max`、下文的 +精确 consumed 等式和 transport trailing allowlist;backend 返回越界的「成功」结果必须转为 +合同违反并关闭/毒化 session,不得只扣成负额度后交给调用方。 +任何失败一旦已发送 query,都不退回预留的 query count。阶段关闭、失败或切换也不退回 +任何额度。核心测试必须证明 `error_before -> main -> error_after` 三段使用同一 ledger, +且尝试在新 phase 重建 ledger 会在任何 I/O 前拒绝。 + +### 2.4 message boundary 的能力证明 + +`binary.message_boundary` 是「backend 类型 + 具体 resource/session 能力」的联合声明: + +| backend | 可声明条件 | 不能作为证据的行为 | +| --- | --- | --- | +| PyVISA | 具体 resource 能报告 EOI/message END,且同一锁内可恢复 read termination | 仅有 `read_raw()` 或一次成功的 timeout 读取 | +| RsInstrument | API 明确报告完整 message/EOI,并能在异常后报告同步状态 | 任意 `query_bin_block()` 成功 | +| TCP socket | 协议本身声明长度、EOM 或受控 message API | `recv()` 返回短块、idle timeout | +| serial | 只有设备和 backend 共同声明可证明的 EOM 才能提供 | 暂时没有数据、换行或固定延迟 | + +R1.3 暂不批准任何现有 backend 声明 `binary.message_boundary`;上表是进入 conformance fixture +前必须满足的条件,不是能力白名单。 + +能力可在 open 时由 backend 静态类型和 resource 属性共同确定;若 backend 无法提供证明, +必须在发送命令前拒绝 `MESSAGE`,不得通过探测命令猜测。`MESSAGE` 交换应在同一资源锁内 +临时关闭文本 read termination,完成或失败后恢复原设置;恢复失败必须进入 session health +状态机,不能当作普通 driver 解析错误。 +R1.3 acceptance policy 固定为:termination/read-setting 恢复失败、malformed framing、 +同步未知或超出 resynchronization ceiling 时关闭该 transport 并标记 `poisoned`;只有已经 +证明边界且完成设置恢复的有限失败才允许保留 `uncertain` session。backend 不得以自己的 +错误分类或重连策略改变这一默认。 + +### 2.5 超限、部分响应和失步 + +每个 backend MUST 在返回前给出同步结论。规则如下: + +1. definite block 头声明的长度超过 `effective_query_max` 时,backend 只有在到已声明 + payload 末尾的待丢弃字节数不超过 `resynchronization_max_bytes` 时,才能以有界流式读取 + 消费到已证明边界,然后抛出 `TransportIOError(reason_code="binary_limit_exceeded")`。 + 声明长度需要更多丢弃字节时必须立即关闭资源并标记 `poisoned`,不得为保持同步而无界读取。 +2. message 读取超过上限时,只有在额外消费不超过 `resynchronization_max_bytes` 且 backend + 在此范围内报告已证明的 message END 时,才允许「有界消费后失败」;超过该范围仍未 + 到 EOM 时必须终止资源并把 session 标为 `poisoned`。 +3. timeout、部分响应、终止符恢复失败和 malformed header 必须映射到现有 + `TransportIOError` 的 `phase`、`response_progress`、`synchronization`、`attempts` 和 + `replay_policy` 字段;不得重试已经发送且同步未知的完整 query。 +4. R1.3 候选为现有 `TransportIOError` 增加可选的稳定 `reason_code`、非负非 bool + `consumed_bytes` 和 `discarded_bytes`。binary `reason_code` 固定为 + `binary_framing_error`、`binary_limit_exceeded`、`binary_truncated`、`binary_timeout` 或 + `binary_transport_trailing_error`。它们进入现有脱敏 error envelope,不新建携带 payload 的公共异常。 + `TransportIOError` 的构造、`with_attempts()` 复制和 envelope 序列化都必须保留这三个 + 字段,不得在 retry 计数时丢失细分原因或字节证据。 + 有界丢弃成功时 `synchronization=proven`,未证明时为 `unproven`,已确认失步或关闭时为 + `lost`;后两者分别使 session 至少进入 `uncertain` 或 `poisoned`。 +5. R1.3 的 `query_binary(command=...)` 不接受 `ReplayPolicy.READ_CONTINUATION_ONLY`,因为该 + 签名必然携带一个可能被发送的 command;必须在 `BEFORE_SEND` 阶段以 `NOT_SENT` 拒绝。 + 未来若需要 continuation,应设计只接受 core-issued response token 的独立 + `read_binary_continuation(token, max_bytes=...)`,只消费同一响应且绝不重发 command。 + +definite block 已消费声明 payload 后发现额外字节时,额外字节不得被当作本次 payload,也 +不得静默丢弃:只有在它们与 core-validated profile 中某个 +`budget.transport_trailing` 精确匹配时,才能作为 `transport_trailing_bytes` 成功返回; +否则同步状态为 `LOST`,session 进入 `poisoned`。下一次 query 只有在 +backend 明确保留并授权 continuation token 时才可继续。 + +成功返回使用 `BinaryQueryResult`,从而显式携带 declared/consumed/transport-trailing +和已证明同步。`data` 只包含 framing 定义的 payload/message。对 definite block,成功必须同时满足: + +```text +declared_length == len(data) +framing_header_bytes == 2 + N +transport_trailing_bytes == budget.transport_trailing +consumed_bytes == framing_header_bytes + len(data) + len(transport_trailing_bytes) +``` + +`framing_header_bytes` 必须是非 bool 整数;definite block 中范围为 `3..11`,必须从实际读到的 +`N` 保留,不得从 `declared_length` 反推,因为 length field 可含前导零。 + +对 MESSAGE,EOM 之前全部字节都属于 `data`;成功必须满足 +`declared_length is None`、`transport_trailing_bytes == b""` 和 +`framing_header_bytes == 0`、`consumed_bytes == len(data)`。MESSAGE 的媒体内容后缀仍属于 `data`,不得改名为 transport +trailing。任何 backend 在返回前无法 +证明已到达响应边界,都不得构造 `BinaryQueryResult`;必须抛出结构化错误并把 session +标记为 `uncertain` 或 `poisoned`。因此 `synchronization="proven"` 是成功类型的先决条件,不是 +backend 可以随意填写的默认值。 +成功路径还必须写入 transport audit:`response_progress=complete`、 +`synchronization=proven`、`consumed_bytes`、`transport_trailing_bytes` 长度和 framing;失败还必须记录 +`consumed_bytes`、`discarded_bytes`、`reason_code` 和最终 session health,并继续使用 +`TransportIOError`。现有 `query_bin_block()` 兼容入口只为 definite block 提取 `data`,但 +仍保留 audit metadata。尾随字节不能用 `rstrip()` 静默删除。continuation token 的独立返回 +模型仍是 `[OPEN]`,在此之前成功结果不得暗中留下待续读状态。 + +### 2.6 内容校验与应用层分块 + +PNG driver 负责 signature、chunk、IEND、尺寸、MIME type 和 profile 的精确 content-trailing +校验。SDS800X HD 的实测 raw PNG 在 IEND 后有一个尾字节;在 MESSAGE framing 中, +该字节仍位于 `BinaryQueryResult.data`。driver 只能在确认 IEND 后按 +`ScopeScreenshotVariant.content_trailing_hex` 精确校验并从规范 PNG 结果中移除,不能把它当作 +transport terminator 或 `transport_trailing_bytes`。 + +波形多块读取另行携带: + +```text +record_id / acquisition_id +chunk_index +offset_samples +points +total_points(可选) +is_last +``` + +只有同一 `record_id`、连续 offset、唯一 chunk index 且最终点数一致时才允许拼接。不同 +query 的结果不得无条件拼接;样本格式、字节序和 preamble 解码仍属于 waveform decoder。 + +## 三、screenshot profile + +### 3.1 明确 request tuple + +格式、菜单和颜色不是独立笛卡尔积。候选 profile 使用明确支持的 request tuple: + +```python +ScreenshotMenuMode = Literal["device", "include", "exclude"] +ScreenshotColorMode = Literal["device", "color", "monochrome", "inverted"] +ScopeScreenshotStateField = Literal["scope.display_menu", "scope.display_color"] + +@dataclass(frozen=True) +class ScopeScreenshotRequest: + format: Literal["png"] = "png" + menu_mode: ScreenshotMenuMode = "device" + color_mode: ScreenshotColorMode = "device" + +@dataclass(frozen=True) +class ScopeScreenshotVariant: + request: ScopeScreenshotRequest + media_type: Literal["image/png"] + framing: BinaryResponseFraming + response_max_bytes: int + operation_max_bytes: int + resynchronization_max_bytes: int + changed_fields: tuple[ScopeScreenshotStateField, ...] + restore_order: tuple[ScopeScreenshotStateField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + query_max_count: Literal[1] = 1 + transport_trailing_hex: str = "" + content_trailing_hex: str = "" + width_px: tuple[int, int] | None = None + height_px: tuple[int, int] | None = None + +@dataclass(frozen=True) +class ScopeScreenshotProfile: + variants: tuple[ScopeScreenshotVariant, ...] + source: Literal["descriptor", "queried", "combined"] = "descriptor" + +@dataclass(frozen=True) +class ScopeScreenshotStateSnapshot: + captured_fields: tuple[ScopeScreenshotStateField, ...] + menu_state_token: str | None = None + color_state_token: str | None = None + +@dataclass(frozen=True) +class ScopeScreenshotBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeScreenshotStateSnapshot + restore_order: tuple[ScopeScreenshotStateField, ...] + +@dataclass(frozen=True) +class ScopeScreenshotRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeScreenshotStateField, ...] + restored_fields: tuple[ScopeScreenshotStateField, ...] + error_code: str | None = None + +@dataclass(frozen=True) +class ScopeScreenshotVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeScreenshotStateField, ...] + mismatched_fields: tuple[ScopeScreenshotStateField, ...] + error_code: str | None = None + +@dataclass(frozen=True) +class ScopeScreenshot: + data: bytes + media_type: Literal["image/png"] + width_px: int + height_px: int + requested: ScopeScreenshotRequest + effective: ScopeScreenshotRequest + framing: BinaryResponseFraming + +@dataclass(frozen=True) +class ScopeEmbeddedScreenshotContract: + request: ScopeScreenshotRequest + changed_fields: tuple[ScopeScreenshotStateField, ...] + verification_fields: tuple[ScopeScreenshotStateField, ...] + cleanup_verification_fields: tuple[ScopeScreenshotStateField, ...] + failure_policy: Literal["fail_parent"] = "fail_parent" + artifact_key: Literal["screenshot"] = "screenshot" + +class ScopeScreenshotDriver(InstrumentDriver, Protocol): + def snapshot_screenshot_state( + self, + fields: tuple[ScopeScreenshotStateField, ...], + ) -> ScopeScreenshotStateSnapshot: ... + def capture_screenshot( + self, + request: ScopeScreenshotRequest, + *, + baseline: ScopeScreenshotBaseline | None, + ) -> ScopeScreenshot: ... + def restore_screenshot_state( + self, + baseline: ScopeScreenshotBaseline, + ) -> ScopeScreenshotRestoreResult: ... + def verify_screenshot_state_restored( + self, + fields: tuple[ScopeScreenshotStateField, ...], + baseline: ScopeScreenshotBaseline, + ) -> ScopeScreenshotStateSnapshot: ... +``` + +`variants` MUST 非空,每个 request 只能出现一次;R1.3 首版只接受 `png` / `image/png`。 +两个 byte 上限 MUST 为正数,`resynchronization_max_bytes` MUST 为非 bool 非负整数; +descriptor variant 的 response/operation/query/resynchronization 值不得超过 +`262144/262144/1/0`,connection 或更严格 profile 只能进一步收紧; +首版 screenshot 只允许一次 +binary response,因此 `query_max_count == 1` 且 +`operation_max_bytes == response_max_bytes`。尺寸范围的上下界必须为正数且满足 +`min <= max`,固定尺寸使用 +`min == max`。请求未精确匹配一个 variant 时在任何 I/O +前拒绝。这样不会把 format、menu、color、framing 和 media type 错当成独立笛卡尔积。 +`device` 表示保留仪器当前行为,不等于 `include` 或 `exclude`。R1.3 public profile 必须 +来自 descriptor,或来自 descriptor 与仪器查询结果的 `combined` 交集;`queried`-only +profile 在 capability discovery 时拒绝。查询结果不得扩大 descriptor 未声明的安全上限, +结果中的 `effective` 和 variant 必须一致。 + +声明 `scope.screenshot_profile` 或 `scope.screenshot_v2` 时, +`InstrumentDescriptor.scope_extensions.screenshot_profile` MUST 非空,并在 factory 阶段通过 +全部静态不变量;`get_screenshot_profile()` 只能返回 descriptor 的原样事实或更严格的 +`combined` 交集。profile provider 缺失、返回 `queried`-only 结果或试图扩大任一上限时, +capability discovery 必须 fail-closed。 + +state restore 字段使用以下不变量: + +- `changed_fields` 唯一,只包含该 request tuple 实际会修改的 menu/color 字段; +- `restore_order` 必须与 `changed_fields` 集合相同且无重复;顺序是 driver 恢复写入的唯一事实源; +- `changed_fields` 为空时,三个 `*_max_steps` 必须全为 `0`;非空时,必须是 + `1..32` 的非 bool 整数,且分别覆盖 snapshot query、restore write 和 verify query 的 + 最大实际 step 数; +- snapshot 的 `captured_fields` 必须恰好等于 variant `changed_fields`;menu/color token 必须在 + 对应字段存在时非空,其他情况为 `None`。 +- core-owned `ScopeScreenshotBaseline.restore_order` 必须是已验证 variant 的精确顺序,driver + 不得自行追加、删除或重排字段。 + +`transport_trailing_hex` 和 `content_trailing_hex` 都使用小写、偶数长度的精确十六进制, +每个解码后最长 16 bytes;前者只表示 definite-block framing 后的文档化 transport +terminator,MESSAGE 必须固定为 `""`。后者表示已位于 MESSAGE payload 或 definite-block +payload 内的应用内容后缀。driver 必须先完整验证 PNG 到 IEND,再要求 IEND 后字节与 +`content_trailing_hex` 精确相等;不得使用 `rstrip()` 或任意 parser。`ScopeScreenshot.data` +只返回从 PNG signature 到 IEND 的规范 PNG,不包含 content trailing;artifact 只记录后缀 +长度和 variant 标识。binary response/operation budget 按清理前的完整 payload 计数。 + +driver 负责格式签名和媒体类型一致性,核心负责上限、artifact 字段和 transport framing。 +非 PNG 格式的完整校验由对应 format handler 定义,不能默认为「任意 bytes 都合法」。 +成功结果的 `width_px` / `height_px` 必须非空、为正整数并落在 variant 的闭区间内;PNG +handler 必须从已校验的 IHDR 得到尺寸,不能只相信 driver 自报值。 + +### 3.2 状态副作用与旧接口 + +若 menu/color 设置会写入前面板状态,`scope.screenshot_v2` 必须记录对应 changed fields、 +恢复覆盖和有效 request。只要 variant `changed_fields` 非空,核心必须在任何写入前调用 +`snapshot_screenshot_state()`,校验返回值后构造 core-owned +`ScopeScreenshotBaseline(context_id, session_epoch, baseline_nonce, snapshot, restore_order)`,并把同一 baseline 传入 +`capture_screenshot()`。driver 只能使用 baseline 中的已验证字段和顺序。 +若 `changed_fields` 为空,baseline 固定为 `None`,三个 state recovery 方法不得发生 I/O。 + +无论截图成功、媒体校验失败或 transport 失败,只要 `changed_fields` 非空,核心都必须按 +variant `restore_order` 调用 `restore_screenshot_state(baseline)`,再在独立只读阶段调用 +`verify_screenshot_state_restored(changed_fields, baseline)`。restore result 只证明写入已完成;verify 方法必须 +返回新查询的 `ScopeScreenshotStateSnapshot`,由核心与 baseline snapshot 逐字段比较并生成 +`ScopeScreenshotVerification`。只有全部 changed fields 一致时才能报告恢复成功。主 driver 抛异常不会丢失 +core-owned baseline。恢复或验证失败不覆盖主异常,但 session 不得保持 `healthy`, +主截图结果也不得伪装为成功。`changed_fields` 为空时,restore/verify 结果固定为 +`not_attempted`/`unavailable` 的无 I/O 记录,不调用这两个需要 baseline 的方法。 + +`ScopeScreenshotRestoreResult.attempted_fields` 必须遵循 baseline 的 `restore_order` 前缀, +`restored_fields` 只能是已尝试字段的有序子序列;`status="completed"` 只在所有 changed +fields 均已写入时成立。restore/verify 抛出的 transport 或协议异常由核心规范化为 +`failed`/`unavailable` 结果,核心不得把该结果当作 query-back 证据。 + +截图的执行顺序固定为:`preflight snapshot -> main capture -> error_after? -> +success_restore restore -> cleanup_verification verify`。主 capture 抛异常、PNG 校验失败或 +`error_after` 失败时,主异常优先级最高,但仍必须转入 `failure_cleanup restore -> +cleanup_verification verify`;没有 +changed fields 时整个 restore/verify 分支为零 I/O。核心只有在 restore result 完成且 verify +逐字段匹配时,才把 screenshot operation 标为恢复成功。 + +snapshot、restore 和 verify 分别只能在 `preflight`、`success_restore|failure_cleanup` +和 `cleanup_verification` 阶段授权中调用;每个授权使用 variant 已验证的 step 上限。 +baseline 的 `context_id`、epoch 或 nonce 不匹配、阶段不匹配、nonce 已消费,或 driver 尝试 +超出 fields/step 时,必须在越界 I/O 前拒绝。 + +这里的 snapshot → restore → verify 是可复用的 state-recovery 模式,不是 SDS800X HD 私有 +命令合同:不同仪器只需为各自的字段集合定义 typed snapshot/baseline 和 token 编解码,核心 +仍负责授权、字段比较、session health 与 artifact。后续 `CHDR`/`CORD`/`WFSU` 等 transfer +状态若需要写入,也应复用同一模式,而不是再发明只返回布尔值的 driver helper。 + +现有 `scope.screenshot` 仅在 profile 明确包含等价旧参数时适配;旧 driver 忽略 +`include_menu` 的行为不能由通用 adapter 静默继承。无法证明参数已生效时必须报告 +`unsupported_capability` 或 `precondition_failed`。 + +现有 DS1000Z driver 会丢弃 `include_menu`,因此不能宣称满足 `exclude`;RTM2000 driver +会写入菜单和颜色设置,因此迁移时必须把这两个字段纳入 changed/restore/verification。 +两者不能共享一个不检查 effective request 的旧接口 adapter。 + +## 四、采集运行状态与控制 + +### 4.1 组合状态模型 + +`ScopeAcquisitionStatus` 继续表示平均和分段信息。运行状态使用独立模型,`AUTO/NORMAL/SINGLE` +是 trigger mode,不是 phase: + +```python +ScopeAcquisitionPhase = Literal[ + "unknown", + "stopped", + "ready", + "arming", + "waiting", + "acquiring", + "rolling", + "stopping", + "complete", + "error", +] + +ScopeTriggerMode = Literal[ + "auto", + "normal", + "single", + "roll", + "unknown", +] + +ScopeContinuousTriggerMode = Literal["auto", "normal", "roll"] +ScopeSingleBaselineStage = Literal["configured_pre_arm", "original_atomic_arm"] +ScopeSingleArmSemantics = Literal["configure_then_arm", "atomic_configure_and_arm"] +ScopeAcquisitionIdentitySemantics = Literal["unique_within_session_epoch", "unknown"] +ScopeAcquisitionSettingField = Literal["scope.trigger", "scope.acquisition"] +ScopeAcquisitionRestoreField = Literal[ + "scope.run_state", + "scope.trigger", + "scope.acquisition", +] +ScopeStateToken = str + +@dataclass(frozen=True) +class ScopeAcquisitionControlProfile: + supported_continuous_modes: tuple[ScopeContinuousTriggerMode, ...] + single_arm_semantics: ScopeSingleArmSemantics + arm_resets_acquisition_count: bool + failure_restore_order: tuple[ScopeAcquisitionSettingField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + identity_semantics: ScopeAcquisitionIdentitySemantics + atomic_arm_preserves_count_mode_semantics: bool = False + +@dataclass(frozen=True) +class ScopeAcquisitionControlSnapshot: + run_state: "ScopeAcquisitionRunState" + trigger_state_token: ScopeStateToken + acquisition_state_token: ScopeStateToken + +@dataclass(frozen=True) +class ScopeAcquisitionControlBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeAcquisitionControlSnapshot + restore_order: tuple[ScopeAcquisitionRestoreField, ...] + +@dataclass(frozen=True) +class ScopeBaselineRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeAcquisitionRestoreField, ...] + restored_fields: tuple[ScopeAcquisitionRestoreField, ...] + error_code: str | None = None + +@dataclass(frozen=True) +class ScopeBaselineVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeAcquisitionRestoreField, ...] + mismatched_fields: tuple[ScopeAcquisitionRestoreField, ...] + error_code: str | None = None + +@dataclass(frozen=True) +class ScopeContinuousAcquisitionRequest: + trigger_mode: ScopeContinuousTriggerMode + +ScopeCompletionProof = Literal[ + "count_delta_with_epoch", + "identity_delta", + "state_transition", +] + +@dataclass(frozen=True) +class ScopeAcquisitionRunState: + phase: ScopeAcquisitionPhase + trigger_mode: ScopeTriggerMode + raw_state: str + acquisition_count: int | None = None + counter_epoch: ScopeStateToken | None = None + acquisition_identity: str | None = None + +@dataclass(frozen=True) +class ScopeAcquisitionCompletion: + state: ScopeAcquisitionRunState + original_state: ScopeAcquisitionRunState + proof_baseline_state: ScopeAcquisitionRunState + proof_baseline_stage: ScopeSingleBaselineStage + proof: ScopeCompletionProof + baseline_count: int | None = None + completed_count: int | None = None + baseline_identity: str | None = None + completed_identity: str | None = None + observed_states: tuple[ScopeAcquisitionRunState, ...] = () +``` + +`ScopeAcquisitionControlProfile` 是 descriptor 静态事实,不是 driver 在 operation 中自报的 +动态结果。候选 `InstrumentDescriptor.scope_extensions.acquisition_control_profile` 字段在声明 +`scope.acquisition_control` capability 时 MUST 非空;核心 factory 在第一次仪器 I/O 前 +完成静态校验,并把已验证 profile 传给 Service preflight。R1.3 不允许仪器查询或 +driver 返回值扩大 descriptor profile。 + +profile 不变量为: + +- `supported_continuous_modes` 非空、唯一,且只包含 `auto/normal/roll`; +- 两个 bool 字段必须是真正的 `bool`; +- `failure_restore_order` 必须恰好各包含一次 `scope.trigger` 和 `scope.acquisition`; + 顺序是核心恢复授权与 driver 实现的唯一事实源; +- 三个 `*_max_steps` 必须是 `1..64` 的非 bool 整数;snapshot/verify 各至少覆盖 run state、 + trigger 和 acquisition 三次 query,`restore_max_steps` 至少覆盖 STOP 和两个设置恢复写入, + 但不因此允许超出实际授权 step 数; +- `single_arm_semantics="configure_then_arm"` 时 + `atomic_arm_preserves_count_mode_semantics` MUST 为 `false`; +- `single_arm_semantics="atomic_configure_and_arm"` 时,只有 + `atomic_arm_preserves_count_mode_semantics=true` 且 `arm_resets_acquisition_count=false` 时, + operation verifier 才可以在原始状态确为 `trigger_mode="single"` 时把 count 作为辅助证据; +- `arm_resets_acquisition_count=true` 时任何 arm 路径都不得使用 `count_delta_with_epoch`;只能使用 + identity 或状态迁移证据。 +- `identity_semantics="unique_within_session_epoch"` 才允许 `identity_delta`; + `unknown` 时核心不得接受 identity proof,只能使用满足本节要求的 state transition。 + +核心构造 `ScopeAcquisitionControlBaseline` 时必须把固定的 +`("scope.run_state", *profile.failure_restore_order)` 写入 `restore_order`;baseline 中的顺序 +与该展开结果不一致时在第一次 recovery I/O 前拒绝。`ScopeAcquisitionControlSnapshot` 的三个 +字段必须始终齐全,因此 STOP、trigger 和 acquisition 的恢复/验证边界不会依赖 driver 临时 +决定字段集合。 + +`ScopeStateToken` MUST 是经 driver 规范化的、无换行、不含 resource/序列号的安全 token;不得把 +原始 SCPI、仪器地址或未脱敏响应放入 baseline。`context_id` 负责 operation 归属, +`session_epoch` 负责连接世代,`baseline_nonce` 负责一次性消费;三者任一不匹配都必须拒绝, +不得跨 epoch 或 context 重用。`ScopeBaselineRestoreResult` 和 `ScopeBaselineVerification` 是核心可审计的结果, +不是 driver 可以省略的布尔标记。 + +`ScopeBaselineRestoreResult.attempted_fields` 必须是 baseline `restore_order` 的前缀, +`restored_fields` 必须是其中的有序子序列且不得包含额外字段;`status="completed"` 仅在 +所有必需字段均已写入时成立,任何中途失败都必须为 `failed` 并保留已完成字段。 +driver 若在 restore/verify 中抛出 transport 或协议异常,核心必须把它规范化为对应的 +`failed`/`unavailable` cleanup 结果并保留异常证据;不得因为 Protocol 返回类型存在就假定 +调用一定返回。 +核心不得把 `restored_fields` 当作 query-back 证据,必须以随后返回的 snapshot 生成 +`ScopeBaselineVerification`。 + +`raw_state` MUST 是短、可打印、无换行 token;无法无损映射时使用 `unknown`,不能把相近 +文字硬映射成 `stopped` 或 `complete`。`acquisition_count` 必须是非负、非 bool 整数; +`counter_epoch`(若有)必须是 `1..64` 个 ASCII safe-token 字符,并在同一次 operation +中保持可比较; +`acquisition_identity` 只能是经 driver 校验的短 token,不能包含 resource 或序列号。 +`ScopeAcquisitionRunState` 只描述一次观察,不携带历史完成结论;completion proof 只存在于 +`ScopeAcquisitionCompletion`,防止普通状态查询伪装成某次 SINGLE 已完成。 +成功构造 `ScopeAcquisitionCompletion` 时,首先必须满足所有 proof 共享的终态不变量: +`state.phase` 属于 `complete/stopped`、`observed_states` 非空且最后一项等于 `state`。 +然后再验证分支:`count_delta_with_epoch` 仅可作为带联合证据的 proof,要求两个 count 都存在、两个 +`counter_epoch` 都存在且相等、满足本节比较规则,并且 `observed_states` 同时证明有效的 +状态迁移;它不再是仅凭模差或正向差值的独立完成证明; +`identity_delta` 要求新旧 identity 都是 `1..64` 个 ASCII safe-token 字符且不同, +`state_transition` 要求保留本节的最小观察序列,且不依赖 count;若该分支同时携带 count, +仍必须提供未变化的 `counter_epoch`,否则核心必须忽略 count 并按纯状态迁移验证。任一终态或证据不完整只能抛出 +`completion_unproven`,不得返回一个携带「不可用」proof 的成功对象。 +`identity_delta` 也不能仅凭两个 token 不同就成立;核心只在已验证 descriptor profile 的 +`identity_semantics="unique_within_session_epoch"` 时接受该 proof。`unknown` 或 profile +缺失时,即使 fixture 观察到 token 不同,也只能使用完整 `state_transition`,或拒绝完成证明。 +`baseline_count`/`completed_count` 非空时必须分别等于 +`proof_baseline_state.acquisition_count`/`state.acquisition_count`;identity 字段也必须与对应状态一致。 +`original_state` 必须等于 core-owned baseline 中的 `snapshot.run_state`。不能在 completion 外 +额外填一组更有利的 token 来通过 verifier。 +forced trigger 是瞬时 action/event,不是可 query-back 的持久 `ScopeTriggerMode`,因此不进入 +该枚举。执行强制触发时只在 operation artifact 记录 `trigger_action="force"` 及其完成证据, +不能把仪器随后返回的 mode 伪造成 `forced`。 + +### 4.2 状态迁移与操作语义 + +候选状态迁移至少包括: + +| 操作/事件 | 允许起始 phase | 预期观察 | 失败语义 | +| --- | --- | --- | --- | +| `scope.acquisition_start`(driver: `start_continuous`) | `stopped`、`ready`、`complete` | 写入后回读 `ready`/`arming`/`waiting`/`acquiring`/`rolling` | 写后回读失败,保留 session health 和 cleanup 结果 | +| `scope.acquisition_single`(driver: `acquire_single`) | `stopped`、`ready`、`complete` | 记录基线,观察新采集状态,再到 `complete/stopped` 且有 proof | 只观察到 arm 不算成功;超时进入失败 cleanup | +| trigger accepted | `arming`、`waiting`、`ready` | `acquiring` 或 `complete` | 外部变化时回读为 `unknown` | +| acquisition complete | 已观察到 `arming`、`waiting`、`ready` 或 `acquiring` 中至少一个新采集状态 | `complete` 或 `stopped` 且有 completion proof | 轮询可以跳过瞬时 `acquiring`;只有原本已 `stopped` 不足以证明新采集完成 | +| normal `scope.acquisition_stop`(driver: `stop_acquisition`) | `stopped`、`ready`、`arming`、`waiting`、`acquiring`、`rolling`、`stopping` 或 `complete` | 回读 `stopped` | 幂等;回读失败时保留 `uncertain`/`poisoned` | +| recovery STOP | 核心已签发有界 recovery authorization 的 `healthy/uncertain` session;phase 可为 `unknown/error` | 只写 STOP 并回读 `stopped` | 不得向 `poisoned` session 发送;结果只记入 cleanup,不伪装成 normal success | +| 外部/设备错误 | 任意 | `error` 或 `unknown` | 必须重新查询确认,不能继续普通 I/O | + +控制协议建议为: + +```python +class ScopeAcquisitionRunStateDriver(InstrumentDriver, Protocol): + def get_acquisition_run_state(self) -> ScopeAcquisitionRunState: ... + +class ScopeAcquisitionControlRecoveryDriver(InstrumentDriver, Protocol): + def snapshot_acquisition_control(self) -> ScopeAcquisitionControlSnapshot: ... + def restore_acquisition_control( + self, + baseline: ScopeAcquisitionControlBaseline, + ) -> ScopeBaselineRestoreResult: ... + def verify_acquisition_control_restored( + self, + baseline: ScopeAcquisitionControlBaseline, + ) -> ScopeAcquisitionControlSnapshot: ... + +class ScopeAcquisitionControlDriver( + ScopeAcquisitionRunStateDriver, + ScopeAcquisitionControlRecoveryDriver, + Protocol, +): + def start_continuous( + self, + *, + trigger_mode: ScopeContinuousTriggerMode, + baseline: ScopeAcquisitionControlBaseline, + ) -> ScopeAcquisitionRunState: ... + def stop_acquisition(self) -> ScopeAcquisitionRunState: ... + def acquire_single( + self, + *, + baseline: ScopeAcquisitionControlBaseline, + deadline: float, + ) -> ScopeAcquisitionCompletion: ... +``` + +核心必须在主操作写入前调用 `snapshot_acquisition_control()`,校验返回值后自行构造: + +```python +ScopeAcquisitionControlBaseline( + context_id, + session_epoch, + baseline_nonce, + snapshot, + ("scope.run_state", *failure_restore_order), +) +``` + +driver 只看到已验证的 +baseline,不能构造或修改 core-owned `session_epoch`。`start_continuous()` 和 +`acquire_single()` MUST 使用核心传入的同一 baseline,不得在内部替换为无法审计的另一份 +baseline。因此,即使主 driver 方法抛出异常,核心仍保留恢复所需的类型化状态。 + +`restore_acquisition_control()` 的固定语义是:先发送 STOP,再按 +`failure_restore_order` 恢复 +`scope.trigger` 和 `scope.acquisition`;不得恢复 snapshot 中的运行 phase。 +`ScopeBaselineRestoreResult.restored_fields` 只表示恢复写入已完成,不是 query-back 证据。 +核心必须随后使用独立只读阶段调用 `verify_acquisition_control_restored()`;成功需要 +driver 必须返回新查询的 `ScopeAcquisitionControlSnapshot`,不得直接返回布尔结论。 +核心 verifier 据此生成 `ScopeBaselineVerification`:`scope.run_state` 必须已为 `stopped`, +且 trigger/acquisition token 与 baseline snapshot 精确相等。只有 +restore 和 verification 全部成功时 session 才能恢复 `healthy`。 + +`start`/`single` 的失败执行顺序固定为: + +1. 在 `preflight` 保存并验证 baseline;关闭 preflight authorization 后执行 `main`。 +2. 若 driver 抛异常、deadline/cancel 触发,或主调用已返回但 `error_after` 判定失败,先保存 + 该主异常并禁止把它改写为 cleanup 结果。 +3. 在 session 为 `healthy/uncertain` 且 epoch 未变化时,关闭当前 authorization,依次开启 + `failure_cleanup` 和 `cleanup_verification` phase;前者调用 + `restore_acquisition_control(baseline)`,后者调用 + `verify_acquisition_control_restored(baseline)` 并由核心比较 snapshot。 +4. restore 或 verification 任一步失败,最终结果仍保留第 2 步主异常(若无主异常则报告 + recovery failure),session 保持 `uncertain/poisoned`;若 session 已 `poisoned`,两步均 + 记录 `not_attempted`,不得发送 STOP。 + +成功的 `start`/`single` 不执行上述 baseline restore;它们分别验证声明的 postcondition, +只在失败、取消、超时或 after-error cleanup 路径恢复调用前的 trigger/acquisition 设置。 + +snapshot、restore、verify 三个方法分别只能在 `preflight`、`failure_cleanup` 和 +`cleanup_verification` 阶段授权中调用。`failure_cleanup` 允许文本 write, +`cleanup_verification` 只允许文本 query;两者都不允许 binary I/O。任一方法在未授权阶段被调用, +或 baseline 的 context/epoch/nonce 与当前 context 不同,必须在发送前拒绝;一次 restore 尝试 +结束后不得再次使用同一 baseline。 + +`start_continuous()` 不保留一个未知或 `single` trigger mode。请求必须显式选择 +`auto`、`normal` 或 `roll`,并且该 mode 已在核心校验的 +`ScopeAcquisitionControlProfile.supported_continuous_modes` 中声明。 +核心必须先持有 `ScopeAcquisitionControlBaseline`,再把其 snapshot 传入 driver。driver 写入 +目标 mode 并 query-back,然后才发送连续运行 action。成功结果必须同时回读 +`state.trigger_mode == request.trigger_mode` 和允许的运行 phase; +仅发送 RUN、保留 `single` 后再采一次,不得报告为 continuous success。 + +`acquire_single()` 是等待完成的 acquire operation,不是 arm-only 写操作。它必须分开两种 baseline: + +- `original_state`:任何 operation 写入前读取,用于 artifact 和失败 cleanup; +- `proof_baseline_state`:用于 completion proof 的最后一个 pre-arm 观察。若仪器可分开「配置 + SINGLE」和「真正 arm/RUN」,driver 必须先写入 SINGLE、query-back,再读取同 mode 的 + count/identity,并标记 `proof_baseline_stage="configured_pre_arm"`。 + +`proof_baseline_stage="configured_pre_arm"` 必须对应 +`single_arm_semantics="configure_then_arm"`;`proof_baseline_stage="original_atomic_arm"` 必须对应 +`single_arm_semantics="atomic_configure_and_arm"`。核心 result verifier 必须与已验证 descriptor +profile 比对,不得信任 driver 自行选择更宽松的 stage。 + +如果仪器的单条命令不可分地同时配置并 arm,则 +`proof_baseline_stage="original_atomic_arm"` 且 `proof_baseline_state == original_state`。该路径只有在 +original 已为 `trigger_mode="single"`,且 `ScopeAcquisitionControlProfile` 同时声明 +`single_arm_semantics="atomic_configure_and_arm"`、 +`atomic_arm_preserves_count_mode_semantics=true` 和 `arm_resets_acquisition_count=false` 时, +才能把 count 作为辅助证据;最终 proof 仍必须同时满足未变化的 `counter_epoch` 和有效 +`state_transition`。否则必须改用 identity delta 或完整的 state transition。 + +真正 arm 后,Service/driver 在同一 deadline 内等待新 acquisition 完成。只有看到有效 +identity 变化,或看到 R1.3 暂定的最小状态序列后,才能成功返回 completion +proof;调用前本来就是 `stopped` 不能单独作为完成条件。没有 completion proof 时 +返回 `completion_unproven`,不得返回成功 waveform。 + +R1.3 暂定的最小 `state_transition` proof 为:SINGLE 写入并 query-back 后至少观察一次 +`arming`、`waiting` 或 `acquiring`;也可以观察 `ready`,但必须同时回读 +`trigger_mode="single"`,且 `(phase, trigger_mode)` 不得与 `proof_baseline_state` 相同。随后必须观察 +`complete` 或 `stopped`。如果仪器把 +写后第一个查询直接返回 `stopped`,且 count/identity 均不变或不可用,则 completion +unproven。后续若跨厂商 fixture 证明该序列仍不通用,应保持 `[OPEN]`,不得由单个插件放宽。 + +arm-only API 不属于 R1.3;未来如确有非阻塞需要,应新增 `scope.acquisition_arm_single`,其 +effect 为 `write`、成功输出只证明已 arm,不能复用 `scope.acquisition_single` 的成功合同。 + +count 比较使用 `proof_baseline_state` 的同一 acquisition mode 基线。新 count 大于基线只可作为 +`count_delta_with_epoch` 的辅助条件;它还必须满足 `counter_epoch` 非空且未变化,并与有效 +`state_transition` 联合,不能单独证明完成。`counter_epoch` 缺失、改变或无法证明连续时, +必须改用 `identity_delta` 或完整的 `state_transition` proof。count 因 +`ScopeAcquisitionControlProfile.arm_resets_acquisition_count=true` 而下降或归零时,不能使用 + `count_delta_with_epoch`。当前首版不接受 modulus,也不尝试用模差区分真实回绕、计数器复位或仪器重启;任何 +`completed_count <= baseline_count` 都使 count 辅助条件失效。mode 改变、仪器重启、前面板重置或 +疑似回绕会使原 baseline 失效,不能沿用旧 count。 + +`stop_acquisition()` MUST 幂等:已经 `stopped` 时可直接成功,但仍应返回观察到的状态;非 +`stopped` 时必须写入并 query-back。一个 session 同时只允许一个 control operation,且控制 +操作使用 exclusive lease。 + +### 4.3 deadline、取消和恢复 + +等待 deadline 来自 operation request;若调用方未给出,R1.3 使用第 1.2 节固定的 +`SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS`。调用方只能提供更早的绝对 deadline,不能延长它; +每次轮询和 STOP I/O 继续取 connection timeout 与剩余 deadline 的较小值。核心通过显式 +`operation_timeout_ms` 和绝对 monotonic deadline 实现该规则。 + +超时或取消时: + +1. 保留主异常和最后一次观察状态; +2. 在 session `healthy` 或 `uncertain` 且核心已授权 recovery transaction 时,best-effort + 执行 `STOP` 并 query-back; +3. 若 session 已 `poisoned`,普通 STOP I/O 必须继续被 gate 拒绝,应关闭并重新建立连接, + 不得从插件直接访问 backend session; +4. cleanup 的成功、失败和最终 `SessionHealth` 写入 artifact,但不能覆盖原始 timeout/cancel + 异常。 + +recovery STOP 不复用 normal operation 的 `phase != unknown/error` 前置条件:核心可以在 +`healthy/uncertain + unknown/error phase` 下签发有界 recovery authorization,但必须限制 I/O 种类、字段、 +step 数和 deadline,并要求 STOP 后只读 query-back。`poisoned` session 永远不能获得该授权。 +recovery authorization 不继承主请求的 `ErrorCheckSpec`,错误队列检查固定为 +`disabled / not_applicable`;否则 before drain 可能拦住本应执行的安全 STOP,也会超出授权的 +STOP + query-back I/O 白名单。 +normal `scope.acquisition_stop` 仍要求 healthy session 和可识别 phase,不能借 recovery 规则 +绕过普通 access/capability gate。 + +现有 `capture_waveform(s)` 仍是 vendor transaction。核心不能仅凭三项控制方法重新拼装它, +因为通道配置、一次 acquisition 的多通道一致性、transfer 临时状态和恢复仍属于 driver +合同。 + +多通道 capture MUST 证明所有通道来自一次 trigger transaction 和同一停止记录。优先使用 +同一 `acquisition_identity`;无法提供 identity 时,必须由同一 SINGLE 写入计数、count delta +和逐通道读取期间未再次触发的 audit evidence 组成 `shared_acquisition_proof`。部分通道失败时 +artifact 应记录 `completed_channels`、`failed_channel`、`shared_acquisition_proof` 和是否发生 +重采集,禁止为了补齐缺失通道而隐式重新触发。 + +## 五、类型化 trace source 与数据不变量 + +### 5.1 source、轴和 operation + +候选模型保留 `spectrum` 字面量,用受限的 FFT operation 描述产生方式;这比把 FFT +伪装成模拟 channel 更明确。但 R1.3 acceptance scope 不注册或迁移 `spectrum`、`math` 和 +`fft_phase`;它们只作为未来 trace-extensions RFC 的非读取 metadata 预留。是否把 +`spectrum` 拆为独立 `fft` kind、单位模型如何复用核心,均不阻塞本轮 M1 内部基础设施, +但也不能据此宣称 R1.3 全部 trace 合同已冻结。 + +```python +ScopeTraceKind = Literal["analog", "digital", "math", "reference", "spectrum"] +ScopeAxisKind = Literal["time", "frequency", "index", "unknown"] +ScopeAxisUnit = Literal["s", "Hz", "1", "unknown"] +ScopeTraceUnit = Literal[ + "v", + "mv", + "db", + "dbm", + "1", + "unknown", +] +ScopeTraceMagnitudeSemantics = Literal["absolute", "relative", "linear", "unknown"] +ScopeTraceOperation = Literal[ + "identity", + "reference_copy", + "fft_magnitude", + "fft_phase", + "device_other", + "unknown", +] + +@dataclass(frozen=True) +class ScopeTraceRef: + kind: ScopeTraceKind + index: int | None = None + name: str | None = None +``` + +`index` 和 `name` MUST 恰有一个有效值。公共编号按 kind 固定:analog、math、reference 和 +spectrum index 使用 one-based `1..65535`;digital index 使用 zero-based `0..15`,并与 bit N +一致;所有 index 都不能是 `bool`。具体仪器 profile 只能收紧范围,不能改变基准。`name` +必须包含 `1..64` 个 Unicode code point、去除首尾空格后不变、全部可打印且不得含控制字符。 +厂商 token 只存在于 driver,不进入公共模型。 + +```python +@dataclass(frozen=True) +class ScopeAxisMetadata: + kind: ScopeAxisKind + unit: ScopeAxisUnit + start: float | None + increment: float | None + points: int + +@dataclass(frozen=True) +class ScopeTraceMetadata: + source: ScopeTraceRef + x_axis: ScopeAxisMetadata + y_unit: ScopeTraceUnit + y_semantics: ScopeTraceMagnitudeSemantics + value_encoding: Literal["real", "digital_bitmask"] + y_increment: float | None = None + y_origin: float | None = None + y_resolution_bits: int | None = None + operation: ScopeTraceOperation = "unknown" + inputs: tuple[ScopeTraceRef, ...] = () + digital_channels: tuple[int, ...] = () + fetchable: bool = False + +@dataclass(frozen=True) +class ScopeTraceData: + metadata: ScopeTraceMetadata + values: np.ndarray + +ScopeTraceTransferField = Literal[ + "scope.run_state", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +] + +@dataclass(frozen=True) +class ScopeTraceTransferStateSnapshot: + captured_fields: tuple[ScopeTraceTransferField, ...] + run_state_token: ScopeStateToken | None = None + waveform_source_token: ScopeStateToken | None = None + waveform_mode_token: ScopeStateToken | None = None + query_response_header_token: ScopeStateToken | None = None + waveform_format_token: ScopeStateToken | None = None + waveform_byte_order_token: ScopeStateToken | None = None + waveform_points_token: ScopeStateToken | None = None + waveform_transfer_window_token: ScopeStateToken | None = None + +@dataclass(frozen=True) +class ScopeTraceTransferBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeTraceTransferStateSnapshot + restore_order: tuple[ScopeTraceTransferField, ...] + +@dataclass(frozen=True) +class ScopeTraceTransferRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeTraceTransferField, ...] + restored_fields: tuple[ScopeTraceTransferField, ...] + error_code: str | None = None + +@dataclass(frozen=True) +class ScopeTraceTransferVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeTraceTransferField, ...] + mismatched_fields: tuple[ScopeTraceTransferField, ...] + error_code: str | None = None + +@dataclass(frozen=True) +class ScopeTraceProfile: + fetchable_kinds: tuple[Literal["analog", "digital", "reference"], ...] + max_points: int + restore_order: tuple[ScopeTraceTransferField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + source_index_max: int = 65535 + +class ScopeTraceTransferRecoveryDriver(InstrumentDriver, Protocol): + def snapshot_trace_transfer_state( + self, + fields: tuple[ScopeTraceTransferField, ...], + ) -> ScopeTraceTransferStateSnapshot: ... + def restore_trace_transfer_state( + self, + baseline: ScopeTraceTransferBaseline, + ) -> ScopeTraceTransferRestoreResult: ... + def verify_trace_transfer_state_restored( + self, + baseline: ScopeTraceTransferBaseline, + ) -> ScopeTraceTransferStateSnapshot: ... + +class ScopeTraceMetadataDriver(InstrumentDriver, Protocol): + def get_trace_metadata(self, source: ScopeTraceRef) -> ScopeTraceMetadata: ... + +class ScopeTraceDriver( + ScopeTraceMetadataDriver, + ScopeTraceTransferRecoveryDriver, + Protocol, +): + def fetch_trace( + self, + source: ScopeTraceRef, + *, + points: str | int = "dmax", + baseline: ScopeTraceTransferBaseline | None, + ) -> ScopeTraceData: ... +``` + +`ScopeTraceProfile` 是 descriptor 必须提供的静态事实;`fetchable_kinds` 只能是 +`analog`、`digital`、`reference` 的非空唯一子集,`max_points`、`source_index_max` 和三个 +`*_max_steps` 是有限正整数。`restore_order` 必须唯一,并覆盖 profile 允许临时改变的所有 +transfer fields;每个 operation 的 baseline 只保留实际 changed fields 的有序子集,不能由 +driver 临时决定顺序。 +它必须位于 `InstrumentDescriptor.scope_extensions.trace_profile`;driver 或运行时 metadata +不得新增 fetchable kind、提高 points/index 上限或改变编号基准。 +R1.3 不把 `math`、`spectrum` 或 `fft_phase` 放入可注册的 fetch profile;它们可以继续出现在 +`ScopeTraceMetadata` 的非读取结果中,但必须为 `fetchable=false`。 + +`ScopeTraceTransferStateSnapshot.captured_fields` 必须唯一,且每个字段对应的 token 恰好在 +字段存在时非空;`restore_order` 必须与实际 changed field 集合相同且无重复。核心在 +`preflight` 读取 snapshot 并构造带 context/epoch/nonce 的 `ScopeTraceTransferBaseline`,在 +`success_restore` 或 `failure_cleanup` 调用 restore,再在 `cleanup_verification` 调用 verify。 +verify 方法必须返回 fresh snapshot,由核心逐字段生成 `ScopeTraceTransferVerification`;不得 +用 `ScopeTraceTransferRestoreResult` 的写入记录代替 query-back。context、epoch、nonce、phase +或一次性消费状态不匹配时,必须在 transfer I/O 前拒绝。上述接口同样覆盖 `CHDR`、`CORD`、 +`WFSU` 或其他等价 transfer 状态,不允许只为某一厂商保留私有恢复 helper。 +`attempted_fields` 必须是 baseline `restore_order` 的前缀,`restored_fields` 只能是已尝试字段 +的有序子序列;restore/verify 抛出的异常由核心规范化为 `failed`/`unavailable`,且不得覆盖 +主 fetch 异常。 + +### 5.2 R1.3 trace acceptance scope + +R1.3 首轮公共 trace 只接受 descriptor 明确声明的 `analog`、`digital` 和 `reference`,以及 +已冻结的 `time`/`index` 轴和现有核心可表达的单位。`ScopeTraceProfile.fetchable_kinds` 是 +唯一可注册边界;`spectrum`、`math`、`fft_phase`、频率轴和新增单位即使仍保留在模型字面量中, +也必须 `fetchable=false`,不得进入 capability registry 或插件迁移。后续 RFC 冻结 kind、 +axis、unit 和序列化后,才能扩展 profile;这项排除不影响本 RFC 对 binary/恢复基础设施的 +内部实现资格。 + +### 5.3 数组、单位和语义约束 + +- `points` MUST 等于 `len(values)`;values MUST 是一维、非空数组。 +- axis 的 `points >= 1`。`time`、`frequency` 和 `index` 的 `start`、`increment` 必须 + finite,`increment > 0`,计算出的最后一个坐标也必须 finite;`unknown` 的 `start` 和 + `increment` 必须为 `None`。 +- 未来 `frequency` 轴 profile 仅接受 one-sided、严格递增、非负频率;DC 可从 `0 Hz` 开始。 + 负频率、中心化双边 FFT 和降序轴需要后续 axis profile,不能通过负 increment 偷渡;该轴 + 不属于 R1.3 公共 fetch scope。 +- `analog`、`math`、`reference` 和 `spectrum` 的未来实现使用 finite、real、`float64` 值;核心应复制 + 并设置只读,不能把 driver 的可变数组直接暴露给调用方。首版明确拒绝 complex dtype, + FFT 的复数结果必须选择 magnitude 或 phase 语义后再进入模型。 +- `digital` 使用无符号整数 bitmask(首版上限 `uint16`),并在 metadata 中说明有效 bit + 与 `digital_channels` 的映射;`digital_channels` 必须非空、唯一且位于 `0..15`,bit N 对应 + digital channel N。R1.3 首版的 fetchable digital source 只支持 + `ScopeTraceRef(kind="digital", index=N)` 的单线语义:`digital_channels == (N,)`, + 每个样本只能是 `0` 或 `1 << N`。具名 bus/group 和多 bit 同步 bitmask 需要独立 + source kind/profile,在此之前只能保持 `fetchable=false` 或继续使用旧 + `ScopeDigitalWaveform`。非 digital trace 的 `digital_channels` 必须为空,不把数字值编码成浮点电压。 +- `time` 轴使用 `s`,`frequency` 轴使用 `Hz`,`index` 轴使用 `1`;未知轴只能使用 + `unknown`,不能同时声称精确的 start/increment 换算。`kind` 与 unit 不匹配时必须拒绝, + 不能把 `Hz` 当作任意显示标签。 +- `y_unit` 只冻结首版 token:`v`、`mv`、`db`、`dbm`、`1` 和 `unknown`。其中 + `dbm/absolute`、`db/relative`、`v|mv/linear` 沿用核心 + `MagnitudeUnit` / `MagnitudeSemantics` 规则;`1` 仅由新增的 digital-bitmask verifier + 校验,不能冒充现有 `MagnitudeUnit`。电流 `a`、相位 `degree` 和百分比 `percent` 需要先 + 扩展核心单位模型,不能在 R1.3 中以任意字符串或未经校验的新增 token 进入公共 + `ScopeTraceData`。无法证明时使用 `unknown/unknown`。 +- `digital_bitmask` 的 y unit 固定为 `1`,semantics 为 `unknown`;`spectrum` 的 dB/dBm + 结果必须明确 absolute/relative,不能只给一个 dB 字符串。`fft_phase` 在相位单位扩展 + 被核心接受前只能作为设备私有 metadata(或以 `unknown` 单位返回),不得宣称跨仪器可比较。 +- `analog` 和 `digital` 的 `operation` MUST 为 `identity` 且 `inputs` 为空;`reference` 的 + `identity` 也必须没有 input,`reference_copy` 恰有一个 input;`fft_magnitude` 和 + `fft_phase` 恰有一个 input。R1.3 不冻结通用 + `add`、`subtract`、`multiply`、`divide`、`differentiate` 或 `integrate`;这些运算及其 + 输入计数、单位代数移入独立的 trace operation/unit-algebra RFC。 +- `math` 在 operation catalog 和单位语义冻结前只能使用 `device_other` 或 `unknown`;核心 + 不得据此推导可移植的算术语义,且 `device_other`/`unknown` 的 `inputs` 必须为空。 + `reference` 只有 `identity`(设备原生 reference)或 + `reference_copy`(明确复制另一 source)两种首版语义。 +- 未来 `spectrum` 的 operation MUST 为 `fft_magnitude` 或 `fft_phase`,且 x 轴 MUST 为 + `frequency`。`get_trace_metadata()` 可以为尚不可读取的 math/source 返回 + `device_other`/`unknown`,但必须标记 `fetchable=false`;`fetch_trace()` 只接受 + `fetchable=true` 且满足对应 kind/operation 不变量的 metadata。`device_other` 和 `unknown` + 不得进入成功的 `ScopeTraceData`,也不能声称结果可跨仪器比较。 +- `y_increment`、`y_origin` 同时为 `None` 或同时为 finite float;`y_increment` 不得为零。 + `y_resolution_bits` 为 `None` 或 `1..64` 的非 bool 整数。`digital_bitmask` 的三项 y + scaling 字段必须全部为 `None`;real trace 若提供 resolution,必须同时提供 increment/origin。 +- `fetchable=true` 的 R1.3 kind 组合只有:analog/reference + time + `v|mv/linear`,以及 + digital + time + `1/unknown`。math、spectrum、`fft_phase`、frequency、未知轴、未知单位或 + `device_other/unknown` operation 只能返回 `fetchable=false` metadata;不能进入成功的 + `ScopeTraceData`。 + +### 5.4 迁移和读取前置条件 + +现有模型的单向迁移建议如下: + +| 现有模型 | 候选 trace 映射 | 约束 | +| --- | --- | --- | +| `WaveformData` | `analog` | 保留原 `fetch_waveform`;反向适配只允许 analog | +| `ScopeDigitalWaveform` | `digital` | 单通道可映射为 `index=N`;多通道 bitmask 保留旧模型,不伪装成单线 trace | +| `ScopeDerivedWaveformMetadata` | `math` 或 `reference` metadata | 不把 `source_kind` 丢失 | +| `ScopeFftStatus` + 频域数据 | `spectrum` | `ScopeFftStatus` 继续兼容;R1.3 只保留 `fetchable=false` metadata,频率轴单独表达留给后续 RFC | + +`fetch_trace()` 是 query/read operation,但若需要临时改变 source、transfer window 或停止 +采集,必须声明相应 `changed_fields` 和恢复覆盖。默认前置条件为:source 已配置、必要时 +acquisition 已停止、sequence/segmentation 状态与 source 合同一致、points 属于 descriptor +`ScopeTraceProfile.max_points`、 +错误检查策略已解析。前置条件失败必须发生在任何 transfer 写入或 binary query 前。 +对只声明普通非分段记录的 `fetch_trace` 和现有 `fetch_waveform`,sequence ON 必须返回 +`precondition_failed` 或 `unsupported_state`;SDS804X HD 已提供零 waveform 写入、零 binary +query 的实机拒绝证据,但该规则仍需第二个厂商 fixture。 + +`ScopeTraceMetadataDriver.get_trace_metadata()` 和 `ScopeTraceDriver.fetch_trace()` 是 R1.3 +正式候选 Protocol 方法,不再是仅供说明的自由函数。声明 `scope.trace_metadata` 时,driver +MUST 实现 `ScopeTraceMetadataDriver`;声明 `scope.fetch_trace` 时,driver MUST 同时实现 +`ScopeTraceDriver`、`ScopeTraceTransferRecoveryDriver`,并由 descriptor 提供已验证的 +`ScopeTraceProfile`。若 fetch operation 没有 changed transfer fields,`baseline` 固定为 +`None`;只要任一 transfer field 可能改变,核心必须传入本 context 的 +`ScopeTraceTransferBaseline`,driver 不得自行 snapshot 或替换 baseline。 + +`fetch_trace()` 的成功路径固定为:`preflight snapshot -> main transfer/fetch -> +error_after? -> success_restore restore -> cleanup_verification verify`。主 fetch、binary 校验或 +after error 失败时,保留主异常并执行 `failure_cleanup restore -> cleanup_verification verify`; +restore/verify 任一步失败不得覆盖主异常,且 session 不能恢复为 `healthy`。恢复结果和 fresh +snapshot 必须写入父 operation artifact,不能只记录「transfer 已恢复」布尔值。 + +`fetch_trace()` 属于 `core_v1` 错误策略执行路径,因此 driver 签名不再携带 +`check_errors`;核心在调用前后负责第六节的错误检查。旧 `fetch_waveform(..., +check_errors=bool)` 通过 legacy adapter 迁移,不能把两个执行者混入同一 transaction。 + +## 六、错误检查策略与 artifact + +### 6.1 策略模型 + +现有 `scope.check_errors: bool` 保持兼容。候选配置扩展为: + +```python +ErrorCheckPolicy = Literal["required", "if_supported", "disabled"] +ErrorCheckTiming = Literal["before", "after", "before_and_after"] +InstrumentErrorPolicy = Literal["fail", "record_and_continue"] + +@dataclass(frozen=True) +class ErrorCheckSpec: + policy: ErrorCheckPolicy + timing: ErrorCheckTiming = "before_and_after" + max_records: int = 16 + on_instrument_error: InstrumentErrorPolicy = "fail" +``` + +候选 `ErrorCheckSpec` 包含 `policy`、`timing`、`max_records` 和 `on_instrument_error`;上面的 +默认值是 R1.3 acceptance candidate;当前核心配置仍不因本文改变。`max_records` MUST 为非 bool 整数,范围固定为 +`1..256`;`256` 是核心硬上限,仪器、插件和调用方只能收紧。首版只提供完整 drain: +读到「无错误」终止 token 或达到上限;不提供会绕过检查的 `none`,也不提供无法证明队列 +完整性的 `one`。自动 clear 和 peek 都不进入首版合同。 +下表的 capability 对 R1.3 `core_v1` 路径专指 `scope.error_drain_v1`;只有旧 +`scope.errors` 不构成「明确支持」。 + +| 策略 | capability 明确支持 | capability 明确不支持 | capability 未知 | +| --- | --- | --- | --- | +| `required` | 按 timing 执行;空队列是成功 | 在 I/O 前拒绝 | 在能力发现完成前拒绝 | +| `if_supported` | 实际执行检查 | 不发送探测命令,记录 `status=skipped` 和 `reason_code=unsupported` | 在 I/O 前拒绝,记录 `status=rejected` 和 `reason_code=unknown_capability` | +| `disabled` | 不发送错误队列查询 | 不发送 | 不发送 | + +`check_errors=true` 映射为 `required`,`false` 映射为 `disabled`。`if_supported` 不能简单 +等价于 `false`:有 capability 时必须查询,查询失败也必须报告真实 failure,而不是 +`unsupported`。普通 transport/protocol/instrument response error 不因 `disabled` 而被吞掉。 +`disabled` 完全忽略 `scope.error_drain_v1` 和旧 `scope.errors` capability,不执行错误检查, +也不存在 unavailable 拒绝; +这条规则保留 SDS800X HD 的 `check_errors=false` 行为。 + +`on_instrument_error` 不是任意调用方可自由组合的容错开关。核心在解析策略后根据 +`OperationSpec.effect` 做静态校验:R1.3 只允许 `observe` 或 `stateful_read` operation 使用 +`record_and_continue`;`write` 和 `acquire` 只能使用 `fail`。recovery transaction 按第 4.3 节固定为 +`disabled`,不执行错误队列 I/O。因此 +错误检查不能把一个已经发生设备错误的写操作伪装成成功。 + +配置优先级从强到弱为:`OperationSpec` 最低策略、单 operation 显式覆盖、仪器全局策略、 +旧布尔配置映射。策略强度为 `required > if_supported > disabled`;低优先级或调用方参数不能 +削弱 `OperationSpec` 的最低要求。未配置新字段时继续使用旧布尔映射,默认行为不改变。 + +`if_supported` 的未知能力固定使用 `unknown_capability`,在任何 I/O 前拒绝,不得由插件选择性 +试探。`on_instrument_error="record_and_continue"` 在 R1.3 只允许用于 `observe` 或 +`stateful_read`;`write` 和 `acquire` 必须使用 `fail`,recovery operation 必须使用 `disabled`, +核心在零 I/O 前拒绝不相容配置。`on_instrument_error` 只处理成功读出的设备错误记录; +错误队列查询本身的 transport/session/protocol failure 始终中止当前 operation,不得被 +`record_and_continue` 吞掉。 + +每次 operation 只能有一个错误检查执行者,由 descriptor capability 和 driver 合同在 +factory 阶段固定: + +- `legacy_driver`:核心只做 capability gate,把旧 bool 交给 driver;核心不得再 drain 一次, + 该路径只适用于已有 operation,artifact 必须标记 `executor=legacy_driver` 和 + `status=legacy_unstructured`,不得伪造 R1.3 `checks`; +- `core_v1`:descriptor 必须声明 `scope.error_drain_v1`,factory 必须验证下文 + `ScopeErrorDrainDriver` Protocol。核心按 timing 调用唯一的 `drain_errors()`;新版主 operation + driver 签名不带 bool,或兼容 + adapter 固定传 `check_errors=False`,driver MUST NOT 再查询队列。 + +只声明旧 `scope.errors` 不能自动适配为 `scope.error_drain_v1`;`list[str]` 无法证明终止 token、 +查询次数或类型化记录。R1.3 operation 在有效策略不是 `disabled` 时只允许 +`core_v1`;现有 operation 才可在版本门内继续使用 `legacy_driver`。迁移测试必须按 operation +断言错误队列查询次数,避免 core 和 driver 双重 drain。现有公共 +`scope.errors` 本身是消耗性 `stateful_read`,其 `OperationSpec` 也必须增加 +`changed_fields=("scope.error_queue",)` 和 legacy artifact;其 `OperationRequest.error_check` 必须为 +`None`,不得在读错误队列前后递归套用 `ErrorCheckSpec`。其旧 `list[str]` 成功值保持兼容, +若未来需要公开类型化 direct-drain 结果,应新增 operation,不得改变旧返回类型。 +旧 driver 即使在内部读到某个厂商终止 token,核心也不得由 `list[str]` 反推通用终止证据或 +真实 query 次数。 + +现有 `scope.errors` 的候选兼容规格固定为:`effect="stateful_read"`、 +`changed_fields=("scope.error_queue",)`、`verification_fields=()`、 +`OperationRequest.error_check=None`,成功值仍为 `list[str]`。核心只记录 +`legacy_unstructured` operation-specific artifact;`terminated` 与 `query_count` 始终为 +`null`。该规格不继承 `scope.error_drain_v1` 的 phase、`max_records+1` 或 typed result。 + +直接调用旧 `scope.errors` 时,operation-specific artifact 固定使用: + +```json +{ + "executor": "legacy_driver", + "capability": "scope.errors", + "status": "legacy_unstructured", + "requested_limit": 16, + "returned_record_count": 3, + "terminated": null, + "query_count": null +} +``` + +`requested_limit` 只记录旧公共参数,`returned_record_count` 只记录返回列表长度;两者都不是 +完整 drain 证明。artifact 不复制旧原始字符串。`terminated` 和 `query_count` MUST 为 +`null`,不得由列表内容或长度猜测。 + +`scope.error_drain_v1` 是内部受管子事务 capability,不是第九个公共 operation,不加入 +operation registry,不提供独立 Service/CLI 入口。「子事务」只表示其结果归属当前 +operation context,不表示嵌套 session authorization。核心 error-policy executor 必须在上一阶段 +authorization 已关闭后,为同一 context 签发独立的 `error_before` 或 `error_after` +authorization。该阶段继承 context 的 correlation ID、session epoch、exclusive lease、 +access 结论和剩余 deadline,语义为 +`effect=stateful_read`、`changed_fields=("scope.error_queue",)`、只允许文本 query, +`max_steps=max_records+1`。它不得超出 context deadline、改变 lease/access、创建 binary +budget ledger,也不得生成独立 operation artifact;结果只写入当前 operation 的 +`error_check.checks`。签发前若 session 已非 healthy 或剩余 deadline 不足,该阶段不得开始。 + +### 6.2 错误记录与生命周期 + +候选 driver 边界和公开错误记录为: + +```python +@dataclass(frozen=True) +class DriverErrorRecord: + code: str | int | None + message: str + severity: Literal["info", "warning", "error", "fatal", "unknown"] + source: str + +@dataclass(frozen=True) +class ErrorDrainResult: + records: tuple[DriverErrorRecord, ...] + terminated: bool + query_count: int + overflow_record: DriverErrorRecord | None = None + +class ScopeErrorDrainDriver(InstrumentDriver, Protocol): + def drain_errors(self, *, max_records: int) -> ErrorDrainResult: ... + +@dataclass(frozen=True) +class ErrorRecord: + code: str | int | None + message: str + message_redacted: bool + severity: Literal["info", "warning", "error", "fatal", "unknown"] + source: str + observed_at_utc: str + correlation_id: str | None +``` + +`drain_errors()` 是 `scope.error_drain_v1` 唯一允许的 driver 方法,不带 clear/peek 参数。 +core 为它签发的非嵌套 `error_before|error_after` phase authorization 只允许文本 +`query` I/O,不允许 write、binary query 或再开启另一 authorization。 +driver 读到文档化的「无错误」token 时设 `terminated=true`,该 token 不进入 `records`。 +R1.3 首版规定每次 queue query 只解析一条错误记录或一个终止 token,不接受 +一次返回多条记录的无类型文本 batch。`query_count` 是真实仪器查询数,范围为 +`1..max_records+1`;核心 authorization 的 +`max_steps` 也必须固定为 `max_records+1`。最后一个额外 step 只用于证明终止;若它仍返回错误记录, +driver 必须把该条保存在 `overflow_record`,返回 `terminated=false`,核心以 +`error_queue_incomplete` 失败。该记录不塞入受 `max_records` 限制的 `records`,但必须经过与 +其他记录完全相同的 scrubber 后进入失败 artifact,不得静默丢弃。 +分支不变量为: + +- `terminated=true`:`overflow_record is None` 且 `query_count == len(records) + 1`; +- `terminated=false`:`len(records) == max_records`、`query_count == max_records + 1` 且 + `overflow_record is not None`。 + +任何 transport/session/protocol 失败直接抛出结构化异常,不构造 +伪造的 `ErrorDrainResult`。 +核心必须校验 `len(records) <= max_records`,并把 `query_count` 与 guarded transport +在当前 authorization 中的实际 query 增量对账;不一致是 driver contract violation,不得生成成功 +artifact。 + +`observed_at_utc` 由核心在成功解析该条记录后生成,必须是带 `Z` 的 RFC 3339 UTC 时间; +deadline 仍使用不序列化的单调时钟,两者不能混用。公开 `ErrorRecord` 不含 raw 响应。 +`DriverErrorRecord.message` 是受信任进程内输入,不得直接序列化。核心必须删除控制字符, +并用已知 resource、IP/USB 地址、IDN 序列号、本地路径和凭据模式的统一 scrubber 做脱敏。 +脱敏后的公开 `message` 必须是 `1..512` 个 Unicode code point;只要替换过内容, +`message_redacted=true`。若 scrubber 不能证明输出安全,固定改为 `instrument reported an error` +并设 `message_redacted=true`,不得把原文作为 fallback。`source` 是 `1..64` 个 ASCII safe-token +字符;字符串 code 也必须使用同一 safe-token 约束,整数 code 不接受 bool。 +未经清洗的 raw 只可保存在受控本地诊断中,并通过脱敏 evidence ID 关联;不得进入公开 +artifact、异常消息或 `ErrorRecord`。 + +R1.3 的完整 drain 合同只适用于 `scope.error_drain_v1`:最多公开 `max_records` 条错误, +不自动 clear,并区分空队列、队列不可用和查询本身失败。 +读取最多 `max_records` 条错误后,必须再执行第 `max_records+1` 次证明 query。 +该 query 返回「无错误」终止 token 才表示 drain 完整;若仍返回错误记录,必须按上文 +`overflow_record` 分支记录并以 `error_queue_incomplete` 失败,不能少发一次证明 query, +也不能把截断列表当作完整检查。 +现有 `scope.errors(limit) -> list[str]` 不受本段 `max_records+1` 规则约束,也不得用于 +R1.3 `required/if_supported` 错误策略。 + +before 阶段发现已有记录时,默认主 operation 不发送并报告 +`preexisting_instrument_error`;这些记录不归属于新的 `correlation_id`。只有在 operation +effect 为 `observe` 或 `stateful_read`,且显式选择 `record_and_continue` 时,核心才可记录后 +继续该非变更 operation;`write`、`acquire` 和 recovery 仍必须在发送前失败。after 阶段发现 +记录时,满足同一条件的非变更 operation 可继续, +变更 operation 报告 `instrument_error`,并保留已经发生的 +副作用和 cleanup 状态。错误队列查询发生 transport、session 或 protocol failure 时始终是 +`failed` 并中止,不能降级成 unsupported;设备明确返回的 instrument error 仍按 +`on_instrument_error` 处理。批量通道共享一次 operation correlation,不把同一组设备错误任意 +复制到每个通道。 + +after 阶段发现不允许继续的 instrument error 时,已成功返回的主 driver 调用必须转为 +operation failure。对 `write` 和 `acquire` operation,执行顺序固定为:after drain 完成并保存 +`instrument_error` 主异常 → 执行 `OperationSpec` 声明的 failure cleanup → 执行 +`cleanup_verification_fields` 验证 → 写入最终 session health 和 artifact。cleanup 或验证失败 +不得覆盖 after 的 `instrument_error`,但必须使 session 保持 `uncertain/poisoned`。 +对 `scope.acquisition_start` 和 `scope.acquisition_single`,这意味着必须按第 1.2/4.3 节尝试 +STOP、恢复 trigger/acquisition baseline 并 query-back;不得因为主 driver 曾返回成功就把 +仪器继续运行状态留作公共 success。若 after drain 本身使 session 不再允许普通 cleanup I/O, +只能使用已声明的有界 recovery authorization;不得绕过 session gate。 + +### 6.3 固定 artifact 结构 + +每个使用 R1.3 错误策略的 operation 至少写入: + +```json +{ + "executor": "core_v1", + "policy": "if_supported", + "capability": "scope.error_drain_v1", + "supported": false, + "status": "skipped", + "reason_code": "unsupported", + "timing": "before_and_after", + "max_records": 16, + "on_instrument_error": "fail", + "checks": [], + "attempted_phases": [], + "completed_phases": [], + "omitted_phases": [], + "last_drain_terminated": null, + "main_operation_sent": true, + "diagnostic_evidence_id": null +} +``` + +`executor` 固定为 `core_v1` 或 `legacy_driver`。`status` 固定为 `completed`、`skipped`、 +`disabled`、`rejected`、`failed` 或 `legacy_unstructured`。 +`reason_code` 固定为 `empty`、`records`、`unsupported`、`unknown_capability`、 +`preexisting_instrument_error`、`instrument_error`、`error_queue_incomplete`、`query_failed` +或 `not_applicable`。`rejected` 只用于能力或配置预检在错误队列 I/O 前拒绝;已开始 drain 后 +遇到队列不完整、查询失败或不允许继续的仪器错误时使用 `failed`。 + +`checks` 不是任意 JSON;每个已执行阶段都必须使用以下固定结构: + +```json +{ + "phase": "before", + "status": "completed", + "reason_code": "empty", + "query_count": 1, + "terminated": true, + "records": [], + "overflow_record": null +} +``` + +`phase` 固定为 `before` 或 `after`;check 级 `status` 固定为 `completed` 或 `failed`, +`reason_code` 固定为 `empty`、`records`、`error_queue_incomplete` 或 `query_failed`。 +`query_count` 是包含终止 token 查询在内的实际队列 query 次数,必须是 +`1..max_records+1` 的非 bool 整数; +`records` 只包含已清洗的 `ErrorRecord`,不包含终止 token,长度不得超过 +`max_records`。check 还必须包含 `overflow_record`,值为脱敏后的 `ErrorRecord` 或 `null`; +它必须与 `ErrorDrainResult` 的分支不变量一致。`terminated=true` 仅表示该阶段确实 +读到了文档化的「无错误」终止 token。 +未执行的 phase 不生成占位条目。 + +`attempted_phases` 按时间顺序列出至少发送过一次队列 query 的 phase;`completed_phases` +只列出以 `terminated=true` 完成的 phase。`last_drain_terminated` 是最后一个已尝试 phase +的终止结论,没有查询时为 `null`。这三个字段不会把「before 已完整 drain,但主操作 +未发送,因此 after 不适用」错写成 drain 不完整。`main_operation_sent` 只表示主 operation +是否已发送任一仪器命令,不包含 before check。`diagnostic_evidence_id` +为 `null` 或脱敏 opaque safe-token,只关联受控本地证据,不得嵌入原始响应、资源字符串或 +本地路径。 + +`omitted_phases` 每项固定为 +`{"phase":"after","reason_code":"main_operation_failed|session_unhealthy|cancelled"}`。主 operation 在 +after 之前失败、取消,或把 session 变为 `uncertain/poisoned` 时,核心 MUST NOT 为补齐 +timing 继续查询错误队列;必须把 after 写入 `omitted_phases`。若主操作失败但 session +仍 `healthy`,after 也默认 omitted,避免用消耗性查询干扰主异常恢复;未来若需「失败后检查」, +应单独设计策略。error-check 顶层只聚合实际执行的 phase,主失败、取消和 session health +仍由 operation artifact 作为主结果,不被 error-check status 覆盖。 + +顶层聚合规则固定为: + +| 情形 | 顶层 `status / reason_code` | `main_operation_sent` | +| --- | --- | --- | +| `disabled` | `disabled / not_applicable` | 按主 operation 实际情况 | +| `if_supported` 且明确 unsupported | `skipped / unsupported` | 主 operation 可继续 | +| `required` 且 unsupported,或能力未知 | `rejected / unsupported` 或 `rejected / unknown_capability` | `false` | +| 任一 phase 队列查询失败或未终止 | `failed / query_failed` 或 `failed / error_queue_incomplete` | 按失败发生前实际情况 | +| before 有记录且策略/操作不允许继续 | `failed / preexisting_instrument_error` | `false`;after 不适用 | +| after 有记录且策略/操作不允许继续 | `failed / instrument_error` | `true` | +| before 或 after 有记录,且非变更操作允许继续 | `completed / records` | 按实际情况 | +| 所有实际 phase 均空 | `completed / empty` | 按实际情况 | +| 旧 driver 自行查错 | `legacy_unstructured / not_applicable` | 按主 operation 实际情况 | + +聚合优先级为:查询失败/队列不完整 > 不允许继续的 instrument error > 已记录并继续 > +空队列。因此「before 有记录并继续,after 为空」固定聚合为 `completed / records`; +「after 有记录但 observe 允许继续」也是 `completed / records`。不能用模糊的 `failed` +吞掉具体原因。 + +`legacy_driver` 使用同一顶层 key 集,但 `capability="scope.errors"`、`checks=[]`、 +phase 数组全空,且不得填写伪造的 query count/termination。当旧 bool 为 `false` 时仍使用 +`disabled / not_applicable`,而不是 `legacy_unstructured`。直接调用 `scope.errors` 时 +`error_check=null`,另写 operation-specific `error_drain` artifact,不递归产生 before/after checks。 + +当前 SDS800X HD 没有文档化错误队列,插件继续要求显式 `check_errors=false`, +不声明 `scope.errors` 或 `scope.error_drain_v1`。 + +artifact 中的 `supported` 使用 `true` 或 `false` 表示能力已评估;使用 `null` 表示能力未评估或未知。 +`disabled` 路径即使未评估也必须使用 `status=disabled` 和 `reason_code=not_applicable`,不得因为未知而拒绝。 +只有 `required/if_supported` 因能力未知而在 I/O 前拒绝时,`supported=null` 才与 +`status=rejected` 和 `reason_code=unknown_capability` 一起出现。公开 artifact 使用结构化 code、经过 scrubber 和长度限制的 message、 +`message_redacted`、UTC 时间和 +correlation,不复制可能包含资源或设备私有内容的原文。 + +## 七、能力发现、版本门与旧接口 + +### 7.1 descriptor 与 capability-method contract + +R1.3 acceptance addendum 为 descriptor 增加一个可选、可序列化的 scope 扩展字段;字段名和 +必需关系冻结如下: + +```python +@dataclass(frozen=True) +class ScopeDescriptorExtensions: + screenshot_profile: ScopeScreenshotProfile | None = None + acquisition_control_profile: ScopeAcquisitionControlProfile | None = None + trace_profile: ScopeTraceProfile | None = None + +@dataclass(frozen=True) +class InstrumentDescriptor: + # 保留现有字段;新增字段不改变旧 descriptor 的默认语义。 + scope_extensions: ScopeDescriptorExtensions | None = None +``` + +核心 factory 在 capability discovery 阶段把 descriptor 的 `scope_extensions` 与 driver +Protocol 一起校验;缺失所需字段或方法时,在第一次仪器 I/O 前返回 +`unsupported_capability`,不能只因 Python 方法恰好存在就注册 capability。R1.3 的中央 +`CAPABILITY_METHODS` 等价映射固定为: + +| capability | descriptor 前置事实 | required Protocol | required method(s) | 可注册 operation | +| --- | --- | --- | --- | --- | +| `scope.screenshot_profile` | `scope_extensions.screenshot_profile` 非空 | `ScopeScreenshotProfileDriver` | `get_screenshot_profile()` | `scope.screenshot_profile` | +| `scope.screenshot_v2` | screenshot profile 非空且已验证 | `ScopeScreenshotDriver` + `ScopeScreenshotProfileDriver` | `capture_screenshot()`、state snapshot/restore/verify | `scope.screenshot_v2` | +| `scope.acquisition_run_state` | 无额外 profile | `ScopeAcquisitionRunStateDriver` | `get_acquisition_run_state()` | `scope.acquisition_run_state` | +| `scope.acquisition_control` | `scope_extensions.acquisition_control_profile` 非空 | `ScopeAcquisitionControlDriver` | `start_continuous()`、`stop_acquisition()`、`acquire_single()`、recovery 三方法 | `scope.acquisition_start/single/stop` | +| `scope.trace_metadata` | `scope_extensions.trace_profile` 非空 | `ScopeTraceMetadataDriver` | `get_trace_metadata()` | `scope.trace_metadata` | +| `scope.fetch_trace` | trace profile 非空且含请求 kind | `ScopeTraceDriver` | `fetch_trace()`、transfer snapshot/restore/verify、`get_trace_metadata()` | `scope.fetch_trace` | +| `scope.error_drain_v1` | capability 明确声明且策略允许 | `ScopeErrorDrainDriver` | `drain_errors(max_records=...)` | 只作为受管 error phase,不新增公共 operation | + +核心 registry 的方法映射可直接编码为: + +```python +SCOPE_CAPABILITY_METHODS = { + "scope.screenshot_profile": ("get_screenshot_profile",), + "scope.screenshot_v2": ( + "get_screenshot_profile", + "capture_screenshot", + "snapshot_screenshot_state", + "restore_screenshot_state", + "verify_screenshot_state_restored", + ), + "scope.acquisition_run_state": ("get_acquisition_run_state",), + "scope.acquisition_control": ( + "get_acquisition_run_state", + "start_continuous", + "stop_acquisition", + "acquire_single", + "snapshot_acquisition_control", + "restore_acquisition_control", + "verify_acquisition_control_restored", + ), + "scope.trace_metadata": ("get_trace_metadata",), + "scope.fetch_trace": ( + "get_trace_metadata", + "fetch_trace", + "snapshot_trace_transfer_state", + "restore_trace_transfer_state", + "verify_trace_transfer_state_restored", + ), + "scope.error_drain_v1": ("drain_errors",), +} +``` + +对应的 profile provider Protocol 为: + +```python +class ScopeScreenshotProfileDriver(InstrumentDriver, Protocol): + def get_screenshot_profile(self) -> ScopeScreenshotProfile: ... +``` + +`scope.screenshot_profile`、`scope.trace_metadata` 和 `scope.fetch_trace` 不再只有文字建议 +方法;上表中的 Protocol 是 capability 注册的必要接口。`ScopeAcquisitionControlProfile`、 +`ScopeScreenshotProfile` 和 `ScopeTraceProfile` 的安全上限以 descriptor 为准,driver 的 +运行时返回值只能 query-back 验证,不能扩大或替换 descriptor 事实。任何 capability 的 +required Protocol、profile 或方法不满足时,factory MUST 在零 I/O 阶段 fail-closed;未声明 +capability 的额外方法不产生隐式能力。 + +R1.3 公共 capability(核心 `0.8.23` 已注册): + +```text +scope.screenshot_profile +scope.screenshot_v2 +scope.acquisition_run_state +scope.acquisition_control +scope.trace_metadata +scope.fetch_trace +scope.error_drain_v1 +``` + +兼容要求: + +1. 现有 capability、方法和模型不删除、不改名; +2. 新核心 + 旧插件保持现状; +3. 使用新 transport 或新 capability 的插件提高 wheel 和 descriptor 的核心下限; +4. 旧核心 + 新插件在 factory 和第一次仪器 I/O 前明确拒绝; +5. 新增可选 capability 不自动要求升级 `wavebench.instrument.v2`; +6. 在 `ScopeDescriptorExtensions`、`CAPABILITY_METHODS`、operation registry、Service、CLI 和 + artifact schema 未同时冻结前,插件不得声明新能力。 + +## 八、conformance 测试矩阵与证据要求 + +| 层级 | 必测内容 | 当前状态 | +| --- | --- | --- | +| OperationSpec/Service | capability、access、lease、action-specific changed/restore/postcondition/cleanup、transfer 字段闭包、artifact | 已实现公共 operation、稳定 Service 和版本化 artifact | +| capability/descriptor gate | `ScopeDescriptorExtensions`、`CAPABILITY_METHODS`、required Protocol、缺 profile/method 零 I/O 拒绝 | 已实现;新 capability 要求核心下限 `0.8.23` | +| binary model | `#N` 精确语法、`#0` 拒绝、response/operation/query/resync budget、成功 metadata、尾部和 continuation | definite/message、超限、截断和尾部向量已覆盖;continuation 保持拒绝 | +| backend | PyVISA/RsInstrument/TCP/serial 的 message 能力证明、终止设置恢复 | PyVISA/RsInstrument definite-block 已实现;VISA `INSTR` EOM fixture 已覆盖;SocketIO/serial 拒绝 `MESSAGE` | +| guarded transport | access、计数、healthy/uncertain/poisoned、固定常量、超限后失步、close/poison 默认 | 已覆盖失败恢复和合同违反测试 | +| plugin trust boundary | 公共 Protocol 不暴露 session;禁止插件依赖 `.inner` 的代码审计 | 当前不是沙箱;opaque facade 不在 R1.3 范围 | +| screenshot | request tuple、PNG signature/IEND、媒体类型、尺寸、transport/content 尾部分层、父 capture fail-parent | 独立 `screenshot_v2` 已实现;旧 capture 对新 capability 采用零 I/O 拒绝,旧 capability 保持兼容 | +| acquisition | allowed phase、完成式 SINGLE、baseline/observed states、identity semantics、终态 proof、成功 postcondition、失败 recovery | 公共控制和 SDS conformance fixture 已通过 | +| transfer/trace | typed transfer snapshot/restore/verify、context nonce、analog/digital/reference profile、字段逐项闭合 | 公共模型与 Service 已实现,SDS transfer fixture 已通过 | +| errors | disabled 零 I/O、未知 fail-closed、`scope.error_drain_v1`、完整 drain、唯一执行者、聚合 reason | core v1 与 legacy artifact 已实现 | +| compatibility | 新旧核心/插件四组合、factory 拒绝、CLI/artifact | capability 组合、版本门和旧插件回归已覆盖 | +| opt-in hardware | 至少两种 framing、两个厂商状态机、两种 trace axis | 仍由每个插件单独完成,不属于核心离线验收结论 | + +### 8.1 必备 fixture 与失败向量 + +插件声明新 capability 前,至少需要带有 backend、resource class、固件版本和证据文件 +链接的 fixture: + +- definite block:合法 `#N`、`#0`、非法长度位、截断、response/operation/query budget 超限、 + resync ceiling、精确 consumed 等式、`BinaryQueryResult` 和 transport trailing; +- message:分片读取、EOM、超限后有界 drain/poison、termination 恢复失败和下一次 query; +- acquisition:descriptor profile 缺失/非法、continuous mode 支持集、configure-then-arm 与 + atomic-arm 两条 baseline 路径、count reset、counter epoch 缺失或变化与异常回绕拒绝、每个 action 的 allowed/rejected phase、 + 调用前已 stopped、count 不变、跳过 acquiring 的最小状态序列、外部前面板改状态、 + after instrument error cleanup 和 timeout 后 recovery STOP 失败; +- transfer restore:typed `ScopeTraceTransferBaseline`、`CHDR`/`CORD`/`WFSU` 或等价状态的 + 逐字段 changed/verification、context/nonce 重放拒绝、恢复失败和 healthy/poisoned 判定; +- screenshot:baseline/query-back/成功与失败恢复、transport/content trailing 负向向量、 + nonce 重放、旧 capture 父字段闭包和 fail-parent 语义; +- trace:capability/descriptor gate、kind-specific index、R1.3 analog/digital/reference fetch、 + spectrum/math 排除、负频/降序拒绝、scaling、非 finite 数值、只读数组和 points 不一致; +- errors:三种 policy、disabled + unsupported、能力未知、`scope.error_drain_v1` factory gate、 + `max_records+1` 终止证据、查询失败、driver/core 双读负向测试、聚合 reason 和脱敏; +- compatibility:旧核心 + 新插件与新核心 + 旧插件的能力发现和第一次 I/O 行为。 + +图片、原始波形、真实 resource、序列号和完整命令日志不进入仓库;只保留 framing、长度、 +状态迁移、固件版本和数值摘要。 + +## 九、里程碑 + +| 里程碑 | 范围 | 退出条件 | 状态 | +| --- | --- | --- | --- | +| M1 | Operation context、phase authorization、OperationSpec/artifact 内部骨架 | context/phase/ledger/legacy artifact 与 transfer recovery model 的 feature-gated 测试通过 | 已完成 | +| M2 | binary framing 与 backend capability | definite/message fake、四维 budget、有界 resync、失步、termination 恢复测试通过 | 已完成 | +| M3 | screenshot profile/v2 | definite block 和 raw message 两种 fixture 通过 | 已完成 | +| M4 | acquisition run state/control | descriptor profile validator、continuous mode、SINGLE proof、幂等 STOP、after-error cleanup 和 timeout recovery 通过 | 已完成 | +| M5 | trace source/axis | R1.3 范围内 analog、digital、reference fixture 通过 | 已完成 | +| M6 | error policy、版本门和迁移 | A1 P0/P1、`scope.error_drain_v1`、artifact、兼容组合和 CLI/Service 冻结 | 已完成 | +| M7 | opt-in 实机 | 核心离线回归通过,具体型号和 resource/backend 另行授权 | 按插件执行 | + +各里程碑应分别提交;不得把 transport、scope model、Service 和插件迁移压成一个不可回滚 +改动。 + +## 十、已否决方案 + +- 插件直接访问 `transport.session` 或 backend;当前只能以「公共合同不提供」约束,不能声称 + Python 运行时沙箱隔离; +- 给 `query_bin_block()` 增加含义模糊的 `raw=True`; +- 使用换行、idle timeout 或 `rstrip()` 推断 PNG 成功结束; +- 在 transport 内置 PNG parser; +- 忽略 screenshot 的 menu 参数; +- 把 trigger status 填入现有 `ScopeAcquisitionStatus`; +- 用 `*OPC?` 统一判断物理触发完成; +- 把 math/reference/FFT 编成负数或大号 channel; +- 扩宽 `WaveformData.channel` 为任意字符串; +- 为没有错误队列的仪器返回空列表; +- 超过 `max_bytes` 后在 healthy session 中留下未消费响应。 + +## 十一、R1.3 暂定安全结论与待决问题 + +以下是 Draft 阶段暂定的安全不变量,不代表 schema、常量或核心实现已经接受: + +1. 采集 start、完成式 single、stop 是三个 action-specific operation;共享 capability 不改变 + 各自 effect、postcondition、失败 cleanup 或最低 access。descriptor 的 + `ScopeAcquisitionControlProfile` 是 continuous mode、SINGLE arm 语义和 count 比较的唯一 + 静态事实源;arm-only 需要独立 operation。 +2. binary response、operation-total、query-count 和 resynchronization 分别使用有限上限;核心通过单个 + operation context 下的非嵌套阶段 authorization 引用同一 `BinaryQueryBudget` ledger, + 插件不能构造、提升、重置或跨 context 复用。 +3. `query_binary()` 成功返回 `BinaryQueryResult`,显式携带 framing、长度、 + `transport_trailing_bytes` 和精确 consumed 等式,以及 + `synchronization=proven`;失败使用现有结构化 `TransportIOError`。 +4. `disabled` 错误策略始终零错误队列 I/O;`if_supported` 只跳过明确 unsupported,未知能力 + fail-closed。`core_v1` 以 `scope.error_drain_v1` 和终止证据为门;旧 `scope.errors` + 只保持 `legacy_unstructured` 语义,不得被升级为类型化 drain。一个 operation 只能由 + core 或 legacy driver 其中一方执行错误检查。 +5. R1.3 的可读取 trace 不包含通用算术,只支持已列出的 kind/axis/unit/operation 组合; + one-sided FFT、编号基准和 scaling 不变量不能留给插件自行解释。 +6. `CHDR`/`CORD`/`WFSU` 或等价的 transfer 状态必须在核心规范化为逐项 + `changed_fields` + `verification_fields`;该要求覆盖 `scope.capture_multiple` 等现有别名。 +7. 旧 capture 调用 screenshot 只允许父 operation 字段闭包;R1.3 不注册 composite operation。 + 没有完整字段闭包时必须在 I/O 前拒绝,截图失败或恢复失败必须使父 capture 失败,不能 + 通过嵌套 authorization 或部分错误记录继续返回成功。 +8. 所有可恢复的 stateful write 都必须通过 core-owned snapshot、有界 restore 和独立 verify + 阶段闭合;主 driver 抛异常或 after error 不能让 baseline 丢失。 +9. 每个 operation 只能在同一 operation context 中顺序使用非嵌套 phase authorization; + error drain phase 不得创建或重置 binary budget ledger。 +10. 首版不使用 modulus 单独证明 SINGLE 完成;count 只能在未变化的 `counter_epoch` 和 + 有效 `state_transition` 联合时作为辅助证据,任何回绕、复位或非严格递增都必须转用 + `identity_delta`/`state_transition`。旧 `scope.errors` 不提供 R1.3 终止证明。 + +剩余待决问题: + +1. `OperationSpec` 的完整输入/输出序列化、取消、幂等性和并发字段仍未公开冻结;在这些字段 + 冻结前,核心只能实现第 12 节列出的内部 / feature-gated 骨架。 +2. 各 backend 是否能在固定 resynchronization 上限内安全 drain 仍需 fixture;超出上限时 + close + `poisoned` 已是 R1.3 的统一默认,不再由 backend 自选。 +3. 哪些 PyVISA resource class 和 RsInstrument API 能稳定证明 message END。 +4. `READ_CONTINUATION_ONLY` 的 core-issued continuation token 和返回模型如何授权。 +5. 旧 screenshot adapter 的具体拒绝码和更多 profile variant 仍待 fixture;profile 来源已固定为 + descriptor 或 descriptor/query 的 `combined` 交集。 +6. poisoned session 的 recovery/reopen API 仍属 transport 生命周期设计;在该 API 冻结前, + 新 capability 不得从 poisoned session 继续 I/O。 +7. 暂定的 completion state-transition 序列及 counter-epoch 联合条件能否通过第二个厂商 + fixture,是否需要进一步收紧;更丰富的计数器世代语义仍待单独 RFC。 +8. `spectrum` 是否作为独立 `ScopeTraceKind`,以及单位校验复用哪些现有核心模型;该项已 + 明确排除在 R1.3 公共 fetch scope 外,移入后续 trace-extensions RFC。 +9. binary operation/profile 的连接项如何映射到不同 backend 仍需核心实现细节;R1.3 的 + operation 常量、profile 收紧规则和超限 close/poison 默认已冻结。 +10. error queue 的未来 peek/clear operation 仍待独立设计;R1.3 timing 默认固定为 + `before_and_after`,未知能力不得增加 skip 分支。 + +R1.3 公共范围已经冻结。仍待后续 RFC 的问题不得扩展当前 capability;插件只有完成对应 +fixture、版本门和实机验收后,才能在正式 descriptor 中声明新能力。 + +## 十二、R1.3 acceptance addendum(A1) + +本 addendum 是 R1.3 的验收门,不是第二套并行规范。核心 `0.8.23` 开发线已经完成本节规定的 +离线验收并注册公共 capability。若本节与正文存在歧义,以本节的 acceptance gate 为准;正文 +仍是跨仪器模型的唯一事实源。 + +### 12.1 允许先行的内部工作 + +公共注册前,核心只允许在内部或 feature-gated 分支实现以下内容: + +| 内部组件 | 必须具备的约束 | 明确禁止 | +| --- | --- | --- | +| operation context | 一次 operation 一个 context;绑定 correlation、epoch、deadline 和 ledger | 为重试或错误检查创建第二个 context | +| phase coordinator | `normal`、`recovery`、`verification` 顺序授权;active authorization 最多一个 | 从 driver 或 active authorization 内嵌套签发 | +| binary ledger | response/total/query/resync 四项固定上限,跨 phase 不重置 | 用 error phase 或重建 driver 增加额度 | +| typed state models | acquisition、screenshot、transfer 的 snapshot/baseline/restore/verify 模型 | 暴露 session token、用布尔值替代 fresh snapshot | +| legacy artifact | 旧 `scope.errors` 的 `legacy_unstructured` 记录 | 将旧 `list[str]` 升级为 typed drain 证明 | +| fake/conformance fixture | 覆盖失败恢复、nonce 重放、phase 越界、binary 超限和 capability gate | 在实机或插件 descriptor 上开启新 capability | + +该限制用于记录公共注册前的历史门禁。现在仍适用于没有通过插件自身验收的具体 driver;未声明 +新 capability 的旧插件 descriptor 和核心版本下限保持不变。 + +### 12.2 P0 公共接口验收门 + +在任何新 capability 注册或插件迁移前,核心必须逐项验收: + +1. **transfer recovery**:`ScopeTraceTransferRecoveryDriver`、 + `ScopeTraceTransferStateSnapshot`、`ScopeTraceTransferBaseline`、restore result 和 + fresh-snapshot verification 已实现;descriptor `ScopeTraceProfile.restore_order` 和 step + 上限已验证;`fetch_trace` 在 changed transfer fields 非空时必须传入同一 context 的 + baseline,并按 `CHDR`/`CORD`/`WFSU` 等逐字段恢复和核对。 +2. **capability/descriptor**:`ScopeDescriptorExtensions` 字段、中央 + `CAPABILITY_METHODS` 映射和各 required Protocol 已实现;缺失 profile/method 时在零 I/O + 阶段拒绝,额外方法不产生隐式 capability。 +3. **numeric and deadline constants**:截图 `262144/262144/1/0`、trace + `8388608/67108864/256/65536`、operation timeout `5000/30000/60000 ms` 已作为核心常量 + 实现;profile/connection 只能收紧,超出同步界限统一 close + `poisoned`。 +4. **error timing**:默认 `before_and_after`、recovery 固定 `disabled`、每次 I/O 受绝对 + monotonic deadline 限制,并有对应 artifact 和负向测试。 + +### 12.3 P1 语义验收门 + +- **嵌入 screenshot**:R1.3 只采用父 capture operation 字段闭包,不注册 composite operation。 + 没有完整 changed/verification/cleanup 字段时在 I/O 前拒绝;截图或恢复失败使父 capture + 失败,不能只记部分错误。 +- **baseline handle**:screenshot、acquisition、transfer baseline 必须包含 + `context_id`、`session_epoch`、core-generated opaque `baseline_nonce` 和 restore order; + nonce 按 `fresh -> passed_to_main -> restore_attempted -> consumed` 一次性消费,重放在 I/O + 前拒绝,artifact 只留摘要。 +- **identity proof**:`ScopeAcquisitionControlProfile.identity_semantics` 必须为 + `unique_within_session_epoch` 才能使用 `identity_delta`;否则只能使用完整 state transition。 +- **phase API bridge**:核心通过 `ScopeOperationContextCoordinator.authorize_phase()` 包裹 + 当前 normal gate 与 `SessionTransactionCoordinator.authorize()`,并用 sidecar/扩展记录绑定 + context、phase、fields、allowed I/O、deadline 和 max steps;driver 不接收 session token。 +- **trace exclusion**:R1.3 公共 fetch 仅包含 analog/digital/reference;spectrum、math、 + fft_phase、frequency axis 和新增单位移入后续 RFC,不得以未决模型开始插件迁移。 + +### 12.4 Addendum 退出条件 + +A1 按以下证据决定 capability registry 和插件迁移边界: + +1. 核心内部 fixture 覆盖上述 P0/P1 gate,且新旧四组合在 factory 和第一次 I/O 前行为明确; +2. 至少两个独立仪器族或 backend fixture 证明 transfer restore、截图恢复、acquisition proof + 和 binary framing/limit 语义; +3. Service、CLI、descriptor、`CAPABILITY_METHODS`、artifact schema 和版本门由核心团队逐项 + 评审并冻结; +4. R1.3 Draft 的待决问题中仍保留的 trace-extensions、continuation 和 reopen 设计不再被 + 当前 capability 合同隐式引用。 + +上述退出条件已由核心 `0.8.23` 开发线的离线 fixture、两类 backend wrapper、SDS800X HD +conformance fixture、稳定 Service/CLI/artifact 和版本门满足。具体插件仍不得把核心通过等同于 +本型号实机通过。 diff --git "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC_\346\240\270\345\277\203\345\256\236\346\226\275\350\257\264\346\230\216.md" "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC_\346\240\270\345\277\203\345\256\236\346\226\275\350\257\264\346\230\216.md" new file mode 100644 index 0000000..af1c46a --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC_\346\240\270\345\277\203\345\256\236\346\226\275\350\257\264\346\230\216.md" @@ -0,0 +1,144 @@ +# WaveBench scope 通用扩展接口 RFC:核心实施说明 + +> 状态:核心 `0.8.23` 开发线已实现并注册公共合同 +> 对应规范:[scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md) +> 验收记录:[R1.3 Acceptance Addendum A1](WaveBench_scope通用扩展接口RFC-R1.3-acceptance-addendum.md) +> 实施分支:`Scaxlibur/feat/scope-generic-extensions-r1-3` + +本文记录 R1.3 在核心中的实际边界。公共合同已经进入开发线,但这不表示任何现有插件自动获得 +新能力,也不构成未执行实机验收的型号覆盖声明。 + +## 已发布的核心合同 + +- `ScopeDescriptorExtensions`、截图、采集控制、trace、错误策略和恢复模型已从 + `wavebench.instruments` 导出; +- 新 capability 已进入公共 `CAPABILITY_METHODS`; +- 新 operation 已进入公共 `OPERATION_REGISTRY`; +- `ScopeService` 提供截图、采集控制、trace metadata 和 trace fetch 方法; +- `ScopeExtensionService` 是可直接复用的稳定编排入口,不要求实验 enable 参数; +- operation artifact 使用 `wavebench.scope.operation.v1`,Service 结果使用 + `wavebench.scope.result.v1`; +- 使用任一 R1.3 capability 的插件必须声明 `wavebench_min_version >= 0.8.23`; +- `InstrumentDescriptor.scope_extensions` 位于 dataclass 字段末尾,旧位置参数顺序保持不变。 + +公共 operation 包括: + +```text +scope.screenshot_profile +scope.screenshot_v2 +scope.acquisition_run_state +scope.acquisition_start +scope.acquisition_single +scope.acquisition_stop +scope.trace_metadata +scope.fetch_trace +``` + +`scope.error_drain_v1` 是受管 error phase capability,不提供独立 operation。 + +## Backend 二进制传输 + +PyVISA 和 RsInstrument backend 已实现一次发送、有限读取的 `query_binary()`: + +- definite block 按实际 `#N` 头读取,不从 payload 长度反推头部; +- response、operation total、query count 和 resynchronization 使用同一 operation ledger; +- 声明长度超限时,只在剩余 resynchronization 额度内读到边界; +- 非法头、截断、超时、额外尾部和设置恢复失败使用结构化 `TransportIOError`; +- read timeout 和 read termination 在成功与失败路径均恢复;恢复失败视为失步; +- definite block 和 `MESSAGE` 都只对能够报告 EOM 的具体 VISA `INSTR` resource 开放; + SocketIO 和 serial 在发送前拒绝; +- 最终读取最多增加 1 个有界探测字节,避免合法 EOM 与 VISA `MAX_CNT` 状态重合时误判; +- RsInstrument 通过底层 VISA session 写入 binary query,避免 `write_str()` 自动插入状态查询; +- 失步、超出同步额度或 backend 合同违反会关闭 transport,并把 session 标记为 `poisoned`; +- 旧 `query_bin_block()` 保持原行为,不改变现有 driver 的采集入口。 + +## Baseline 与恢复 + +baseline nonce 使用以下一次性状态机: + +```text +fresh + -> passed_to_main + -> restore_attempted + -> verify_attempted + -> consumed +``` + +context、operation、session epoch、nonce 或 phase 不匹配时,在仪器 I/O 前拒绝。artifact 只记录 +nonce 摘要。restore 失败后允许一次诊断性 fresh verify,但不能据此把 session 恢复为 `healthy`。 + +`fetch_trace` 对以下 transfer 字段执行逐项 snapshot、restore 和 fresh verify: + +```text +scope.run_state +scope.waveform_source +scope.waveform_mode +scope.query_response_header +scope.waveform_format +scope.waveform_byte_order +scope.waveform_points +scope.waveform_transfer_window +``` + +这组字段覆盖 `CHDR`、`CORD`、`WFSU` 等厂商状态映射。缺少任一 fresh readback 时,operation +不能返回成功。 + +## CLI + +公共命令为: + +```text +wavebench scope screenshot profile +wavebench scope screenshot capture +wavebench scope acquisition status +wavebench scope acquisition start +wavebench scope acquisition single +wavebench scope acquisition stop +wavebench scope trace metadata +wavebench scope trace fetch +``` + +截图和 trace fetch 要求显式指定新文件路径,不覆盖已有文件。二进制 payload 写入 PNG 或 NPY; +JSON artifact 只保存媒体类型、尺寸、点数、dtype、字节数、SHA-256 摘要、phase、恢复和错误检查 +证据,不复制原始 payload。 + +## 旧 capture 兼容边界 + +声明旧 `scope.screenshot` 的插件继续使用原 capture 截图行为,既有 RTM2000 和 DS1000Z 路径 +不变。插件同时声明旧能力和 `scope.screenshot_v2` 时,旧 capture 仍只走 legacy 路径;只有 +v2、没有旧能力时,核心会在任何仪器 I/O 前拒绝嵌入请求,并要求改用独立的 +`scope screenshot capture` 命令。 + +该分流避免把旧 capture 的「截图失败可保留 waveform」语义伪装成 R1.3 的 `fail_parent`。 +以后若需要在新插件中重新开放嵌入截图,必须实现父 operation 字段闭包和同一 context 的 +snapshot、capture、restore、verify;不得调用独立的子 operation。 + +## 插件采用条件 + +插件只有同时满足以下条件,才能声明新 capability: + +1. wheel 依赖和 descriptor 均要求 WaveBench `0.8.23` 或更高的 `0.8.x` 版本; +2. descriptor 提供对应的 `scope_extensions` 静态 profile; +3. capability 所需方法全部实现,且额外方法不会产生隐式能力; +4. transfer 状态、截图状态和采集状态均有 fresh readback 证明; +5. binary framing 与 resource/backend 的实际 EOM 能力一致; +6. 插件自己的 conformance、包检查和实机验收分别通过。 + +现有插件没有声明新 capability 时,不需要提高最低核心版本。 + +## 当前验证与剩余工作 + +核心离线验证覆盖 definite block、`MESSAGE` EOM、超限 resynchronization、timeout、termination +恢复失败、nonce 重放、phase 越界、截图恢复、采集证明、trace transfer 恢复、error policy、 +CLI artifact、版本门和新旧 capability 组合。 + +仍需在插件仓库完成: + +- SDS3000 capability 审计清单从旧核心的 19 项更新为当前 26 项,再决定具体 opt-in 范围; +- 任何准备声明新 capability 的插件补齐 descriptor profile、driver 方法和发行版核心下限; +- 对实际 resource/backend 执行独立硬件验收,特别是 `MESSAGE` EOM、长 payload、设置恢复和 + 失败后的下一次 query; +- `spectrum`、`math`、frequency axis、continuation token 和 poisoned-session reopen 继续由后续 + RFC 处理。 + +本次核心实施没有连接真实仪器,没有改动 `wavebench.toml`,也没有安装或升级依赖。 diff --git a/pyproject.toml b/pyproject.toml index 242d499..c578e49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wavebench" -version = "0.8.22" +version = "0.8.23" description = "Lightweight VISA/SCPI measurement bench for contest debugging" readme = "README.md" requires-python = ">=3.11" diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index e608d74..c8ae68a 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from collections.abc import Mapping from contextlib import redirect_stderr, redirect_stdout from dataclasses import asdict, is_dataclass import io @@ -75,6 +76,14 @@ from .logging import CommandLogger from .instruments.registry import build_instrument_registry from .instruments.registry import resolve_instrument_descriptor +from .instruments.scope_extensions import ( + ErrorCheckSpec, + ScopeContinuousAcquisitionRequest, + ScopeScreenshot, + ScopeScreenshotRequest, + ScopeTraceData, + ScopeTraceRef, +) from .mcp_http import ( resolve_mcp_token, serve_mcp_http, @@ -247,6 +256,147 @@ def _json_payload(value: object) -> object: return value +def _scope_error_check(args: argparse.Namespace) -> ErrorCheckSpec | None: + policy = getattr(args, "error_policy", None) + if policy is None: + return None + try: + return ErrorCheckSpec( + policy=policy, + timing=args.error_timing, + max_records=args.error_max_records, + ) + except (TypeError, ValueError) as exc: + raise ConfigError(str(exc)) from exc + + +def _scope_trace_ref(args: argparse.Namespace) -> ScopeTraceRef: + try: + return ScopeTraceRef( + kind=args.trace_kind, + index=args.trace_index, + name=args.trace_name, + ) + except (TypeError, ValueError) as exc: + raise ConfigError(str(exc)) from exc + + +def _scope_trace_points(raw: str) -> str | int: + if raw == "dmax": + return raw + try: + value = int(raw) + except (TypeError, ValueError) as exc: + raise ConfigError("scope trace --points must be 'dmax' or a positive integer") from exc + if value < 1: + raise ConfigError("scope trace --points must be 'dmax' or a positive integer") + return value + + +def _new_cli_output_path(raw: str, *, suffix: str, label: str) -> Path: + path = Path(raw).expanduser() + if path.suffix.lower() != suffix: + raise ConfigError(f"{label} must use the {suffix} suffix") + if path.exists(): + raise ConfigError(f"{label} already exists: {path}") + try: + path.parent.mkdir(parents=True, exist_ok=True) + with TemporaryFile(dir=path.parent): + pass + except OSError as exc: + raise ConfigError(f"{label} directory is not writable: {path.parent}") from exc + return path + + +def _write_scope_artifact(path: Path, payload: dict[str, object]) -> None: + try: + with path.open("x", encoding="utf-8") as file: + json.dump(payload, file, indent=2, ensure_ascii=False) + file.write("\n") + except OSError as exc: + raise ConfigError(f"failed to write scope operation artifact: {path}") from exc + + +def _scope_error_payload(exc: BaseException) -> dict[str, object]: + payload: dict[str, object] = error_envelope(exc) + diagnostics = getattr(exc, "scope_operation_diagnostics", None) + if isinstance(diagnostics, Mapping): + payload["operation_diagnostics"] = _json_payload(dict(diagnostics)) + artifact_error = getattr(exc, "scope_failure_artifact_error", None) + if artifact_error == "write_failed": + payload["scope_artifact"] = { + "status": "failed", + "reason_code": "write_failed", + } + cleanup_error = getattr(exc, "scope_output_cleanup_error", None) + if cleanup_error == "remove_failed": + payload["scope_output"] = { + "status": "partial_cleanup_failed", + "reason_code": "remove_failed", + } + return payload + + +def _write_scope_failure_artifact(path: Path, exc: WaveBenchError) -> None: + diagnostics = getattr(exc, "scope_operation_diagnostics", None) + if not isinstance(diagnostics, Mapping): + return + try: + _write_scope_artifact( + path, + { + "schema": "wavebench.scope.result.v1", + "status": "failed", + "result": None, + "diagnostics": _json_payload(dict(diagnostics)), + "observed_state": None, + "error": _scope_error_payload(exc), + "files": {"artifact": path.name}, + }, + ) + except ConfigError: + setattr(exc, "scope_failure_artifact_error", "write_failed") + + +def _attach_scope_result_diagnostics( + exc: WaveBenchError, + payload: Mapping[str, object], +) -> None: + diagnostics = payload.get("diagnostics") + if isinstance(diagnostics, Mapping): + setattr(exc, "scope_operation_diagnostics", dict(diagnostics)) + + +def _remove_failed_scope_output(path: Path, exc: WaveBenchError) -> None: + try: + path.unlink(missing_ok=True) + except OSError: + setattr(exc, "scope_output_cleanup_error", "remove_failed") + + +def _scope_output_write_error( + *, + artifact_path: Path, + result_payload: Mapping[str, object], + message: str, +) -> ConfigError: + error = ConfigError(message) + _attach_scope_result_diagnostics(error, result_payload) + _write_scope_failure_artifact(artifact_path, error) + return error + + +def _emit_scope_extension_result( + payload: dict[str, object], + *, + json_mode: bool, +) -> None: + if json_mode: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + + def _emit_json_result(payload: object, *, status: str = "ok", exit_code: int = 0) -> None: print( json.dumps( @@ -975,6 +1125,139 @@ def _main(argv: list[str] | None = None) -> int: for item in service.errors(): print(item) return 0 + if args.command == "screenshot": + if args.screenshot_command == "profile": + result = service.screenshot_profile() + _emit_scope_extension_result(result.as_dict(), json_mode=args.json) + return 0 + output_path = _new_cli_output_path( + args.output, + suffix=".png", + label="scope screenshot output", + ) + artifact_path = _new_cli_output_path( + args.artifact or f"{output_path}.json", + suffix=".json", + label="scope screenshot artifact", + ) + if output_path.resolve() == artifact_path.resolve(): + raise ConfigError("scope screenshot output and artifact paths must differ") + request = ScopeScreenshotRequest( + menu_mode=args.menu_mode, + color_mode=args.color_mode, + ) + try: + result = service.screenshot_v2( + request, + error_check=_scope_error_check(args), + ) + except WaveBenchError as exc: + _write_scope_failure_artifact(artifact_path, exc) + raise + if not isinstance(result.value, ScopeScreenshot): + raise ConfigError("scope screenshot Service returned an invalid result") + payload = result.as_dict() + try: + with output_path.open("xb") as file: + file.write(result.value.data) + except OSError as exc: + error = _scope_output_write_error( + artifact_path=artifact_path, + result_payload=payload, + message="failed to write scope screenshot output", + ) + _remove_failed_scope_output(output_path, error) + raise error from exc + payload["files"] = { + "screenshot": output_path.name, + "artifact": artifact_path.name, + } + try: + _write_scope_artifact(artifact_path, payload) + except ConfigError as exc: + _attach_scope_result_diagnostics(exc, payload) + setattr(exc, "scope_failure_artifact_error", "write_failed") + _remove_failed_scope_output(output_path, exc) + raise + payload["files"] = { + "screenshot": str(output_path), + "artifact": str(artifact_path), + } + _emit_scope_extension_result(payload, json_mode=args.json) + return 0 + if args.command == "acquisition": + error_check = _scope_error_check(args) + if args.acquisition_command == "status": + result = service.acquisition_run_state() + elif args.acquisition_command == "start": + result = service.start_acquisition( + ScopeContinuousAcquisitionRequest(args.trigger_mode), + error_check=error_check, + ) + elif args.acquisition_command == "single": + result = service.acquire_single(error_check=error_check) + else: + result = service.stop_acquisition(error_check=error_check) + _emit_scope_extension_result(result.as_dict(), json_mode=args.json) + return 0 + if args.command == "trace": + source = _scope_trace_ref(args) + if args.trace_command == "metadata": + result = service.trace_metadata(source) + _emit_scope_extension_result(result.as_dict(), json_mode=args.json) + return 0 + output_path = _new_cli_output_path( + args.output, + suffix=".npy", + label="scope trace output", + ) + artifact_path = _new_cli_output_path( + args.artifact or f"{output_path}.json", + suffix=".json", + label="scope trace artifact", + ) + if output_path.resolve() == artifact_path.resolve(): + raise ConfigError("scope trace output and artifact paths must differ") + try: + result = service.fetch_trace( + source, + points=_scope_trace_points(args.trace_points), + error_check=_scope_error_check(args), + ) + except WaveBenchError as exc: + _write_scope_failure_artifact(artifact_path, exc) + raise + if not isinstance(result.value, ScopeTraceData): + raise ConfigError("scope trace Service returned an invalid result") + payload = result.as_dict() + try: + with output_path.open("xb") as file: + np.save(file, result.value.values, allow_pickle=False) + except OSError as exc: + error = _scope_output_write_error( + artifact_path=artifact_path, + result_payload=payload, + message="failed to write scope trace output", + ) + _remove_failed_scope_output(output_path, error) + raise error from exc + payload["files"] = { + "trace": output_path.name, + "artifact": artifact_path.name, + } + try: + _write_scope_artifact(artifact_path, payload) + except ConfigError as exc: + _attach_scope_result_diagnostics(exc, payload) + setattr(exc, "scope_failure_artifact_error", "write_failed") + _remove_failed_scope_output(output_path, exc) + raise + payload["files"] = { + "trace": str(output_path), + "artifact": str(artifact_path), + } + _emit_scope_extension_result(payload, json_mode=args.json) + return 0 if args.command == "status": channel = args.channel or service.config.scope.default_channel summary = getattr(service, "status_summary", None) @@ -1120,7 +1403,7 @@ def _main(argv: list[str] | None = None) -> int: parser.error("unknown command") except WaveBenchError as exc: if getattr(args, "json", False): - print(json.dumps(error_envelope(exc), indent=2, ensure_ascii=False)) + print(json.dumps(_scope_error_payload(exc), indent=2, ensure_ascii=False)) else: print(f"wavebench: {exc}", file=sys.stderr) return exc.exit_code diff --git a/src/wavebench/cli_output.py b/src/wavebench/cli_output.py index 91f1173..70814b0 100644 --- a/src/wavebench/cli_output.py +++ b/src/wavebench/cli_output.py @@ -103,7 +103,33 @@ def _print_capability_explanation(result: CapabilityExplanation) -> None: "verification_fields=" + (",".join(spec["verification_fields"]) or "none") ) + print( + "postcondition_fields=" + + (",".join(spec["postcondition_fields"]) or "none") + ) + print( + "cleanup_verification_fields=" + + (",".join(spec["cleanup_verification_fields"]) or "none") + ) print("timeout_source=" + str(spec["timeout_source"])) + print("operation_timeout_ms=" + str(spec["operation_timeout_ms"] or "none")) + print( + "binary_limits=" + + "/".join( + str(spec[name]) if spec[name] is not None else "none" + for name in ( + "binary_response_max_bytes", + "binary_operation_max_bytes", + "binary_query_max_count", + "binary_resynchronization_max_bytes", + ) + ) + ) + print("error_check_minimum=" + str(spec["error_check_minimum"] or "none")) + print( + "embedded_screenshot_contract=" + + ("present" if spec["embedded_screenshot_contract"] is not None else "none") + ) print("risk_flags=" + (",".join(spec["risk_flags"]) or "none")) print("safe_alternatives=" + (",".join(spec["safe_alternatives"]) or "none")) @@ -187,6 +213,18 @@ def _print_instrument_descriptor(descriptor: InstrumentDescriptor) -> None: print(f"distribution_version={descriptor.version}") print(f"source={descriptor.source}") print("permissions=" + ", ".join(descriptor.permissions)) + extensions = descriptor.scope_extensions + if extensions is not None: + profiles = [ + name + for name in ( + "screenshot_profile", + "acquisition_control_profile", + "trace_profile", + ) + if getattr(extensions, name) is not None + ] + print("scope_extensions=" + (", ".join(profiles) or "none")) def _print_plugin_doctor(records: list[PluginDoctorRecord]) -> None: diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index f4c8671..ef63cd7 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -11,6 +11,21 @@ def add_runtime_options(parser: argparse.ArgumentParser) -> None: parser.add_argument("--resource", help="Override VISA resource, e.g. TCPIP::192.0.2.100::INSTR") +def add_scope_error_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--error-policy", + choices=("required", "if_supported", "disabled"), + default=None, + help="Override the operation error-queue policy", + ) + parser.add_argument( + "--error-timing", + choices=("before", "after", "before_and_after"), + default="before_and_after", + ) + parser.add_argument("--error-max-records", type=int, default=16) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="wavebench") parser.add_argument( @@ -894,6 +909,90 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(cursor_readout) + screenshot = scope_sub.add_parser( + "screenshot", + help="Query the screenshot profile or capture a typed screenshot", + ) + screenshot_sub = screenshot.add_subparsers(dest="screenshot_command", required=True) + screenshot_profile = screenshot_sub.add_parser("profile", help="Query screenshot limits") + add_runtime_options(screenshot_profile) + screenshot_capture = screenshot_sub.add_parser( + "capture", + help="Capture a screenshot through the scope.screenshot_v2 contract", + ) + screenshot_capture.add_argument("--output", required=True, help="New .png output path") + screenshot_capture.add_argument( + "--artifact", + default=None, + help="New JSON artifact path; defaults to .json", + ) + screenshot_capture.add_argument( + "--menu-mode", + choices=("device", "include", "exclude"), + default="device", + ) + screenshot_capture.add_argument( + "--color-mode", + choices=("device", "color", "monochrome", "inverted"), + default="device", + ) + add_scope_error_options(screenshot_capture) + add_runtime_options(screenshot_capture) + + acquisition = scope_sub.add_parser( + "acquisition", + help="Inspect or control acquisition through the typed R1.3 contract", + ) + acquisition_sub = acquisition.add_subparsers(dest="acquisition_command", required=True) + acquisition_state = acquisition_sub.add_parser("status", help="Query acquisition run state") + add_runtime_options(acquisition_state) + acquisition_start = acquisition_sub.add_parser("start", help="Start continuous acquisition") + acquisition_start.add_argument( + "--trigger-mode", + choices=("auto", "normal", "roll"), + required=True, + ) + add_scope_error_options(acquisition_start) + add_runtime_options(acquisition_start) + acquisition_single = acquisition_sub.add_parser("single", help="Acquire one proven record") + add_scope_error_options(acquisition_single) + add_runtime_options(acquisition_single) + acquisition_stop = acquisition_sub.add_parser("stop", help="Stop acquisition") + add_scope_error_options(acquisition_stop) + add_runtime_options(acquisition_stop) + + trace = scope_sub.add_parser( + "trace", + help="Query trace metadata or fetch a typed trace", + ) + trace_sub = trace.add_subparsers(dest="trace_command", required=True) + + def add_trace_reference(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--kind", + dest="trace_kind", + choices=("analog", "digital", "reference"), + required=True, + ) + reference = parser.add_mutually_exclusive_group(required=True) + reference.add_argument("--index", dest="trace_index", type=int) + reference.add_argument("--name", dest="trace_name") + + trace_metadata = trace_sub.add_parser("metadata", help="Query trace metadata") + add_trace_reference(trace_metadata) + add_runtime_options(trace_metadata) + trace_fetch = trace_sub.add_parser("fetch", help="Fetch trace samples into a new .npy file") + add_trace_reference(trace_fetch) + trace_fetch.add_argument("--points", dest="trace_points", default="dmax") + trace_fetch.add_argument("--output", required=True, help="New .npy output path") + trace_fetch.add_argument( + "--artifact", + default=None, + help="New JSON artifact path; defaults to .json", + ) + add_scope_error_options(trace_fetch) + add_runtime_options(trace_fetch) + auto = scope_sub.add_parser("auto", help="Run explicit AUToscale and wait for *OPC?") add_runtime_options(auto) diff --git a/src/wavebench/errors.py b/src/wavebench/errors.py index 98d11cd..c0a8279 100644 --- a/src/wavebench/errors.py +++ b/src/wavebench/errors.py @@ -181,6 +181,9 @@ def __init__( response_progress: ResponseProgress, synchronization: Synchronization, attempts: int, + reason_code: str | None = None, + consumed_bytes: int | None = None, + discarded_bytes: int | None = None, ) -> None: super().__init__(message) phase = TransportPhase(phase) @@ -188,8 +191,21 @@ def __init__( command_transmission = CommandTransmission(command_transmission) response_progress = ResponseProgress(response_progress) synchronization = Synchronization(synchronization) - if attempts < 0: + if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 0: raise ValueError("transport attempts must be >= 0") + if reason_code is not None and ( + not isinstance(reason_code, str) + or re.fullmatch(r"[a-z][a-z0-9_]{0,63}", reason_code) is None + ): + raise ValueError("transport reason_code must be a stable lowercase token") + for label, value in ( + ("consumed_bytes", consumed_bytes), + ("discarded_bytes", discarded_bytes), + ): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + ): + raise ValueError(f"transport {label} must be a non-negative integer") if phase is TransportPhase.BEFORE_SEND and ( command_transmission is not CommandTransmission.NOT_SENT or response_progress is not ResponseProgress.NONE @@ -216,6 +232,9 @@ def __init__( self.response_progress = response_progress self.synchronization = synchronization self.attempts = attempts + self.reason_code = reason_code + self.consumed_bytes = consumed_bytes + self.discarded_bytes = discarded_bytes def with_attempts(self, attempts: int) -> "TransportIOError": return TransportIOError( @@ -227,6 +246,9 @@ def with_attempts(self, attempts: int) -> "TransportIOError": response_progress=self.response_progress, synchronization=self.synchronization, attempts=attempts, + reason_code=self.reason_code, + consumed_bytes=self.consumed_bytes, + discarded_bytes=self.discarded_bytes, ) def to_envelope( @@ -248,6 +270,12 @@ def to_envelope( "synchronization": self.synchronization.value, "attempts": self.attempts, } + if self.reason_code is not None: + merged["reason_code"] = self.reason_code + if self.consumed_bytes is not None: + merged["consumed_bytes"] = self.consumed_bytes + if self.discarded_bytes is not None: + merged["discarded_bytes"] = self.discarded_bytes return super().to_envelope( operation=operation, details=merged, diff --git a/src/wavebench/instruments/__init__.py b/src/wavebench/instruments/__init__.py index a788ed1..6850da3 100644 --- a/src/wavebench/instruments/__init__.py +++ b/src/wavebench/instruments/__init__.py @@ -108,6 +108,10 @@ WaveformData, WaveformHeader, ) +from . import scope_extensions as _scope_extensions + +for _scope_extension_name in _scope_extensions.__all__: + globals()[_scope_extension_name] = getattr(_scope_extensions, _scope_extension_name) __all__ = [ "ArbitraryQueryProbeResult", @@ -220,4 +224,7 @@ "WaveformData", "WaveformHeader", "open_instrument_driver", + *_scope_extensions.__all__, ] + +del _scope_extension_name diff --git a/src/wavebench/instruments/api.py b/src/wavebench/instruments/api.py index 5adc09c..d69c9df 100644 --- a/src/wavebench/instruments/api.py +++ b/src/wavebench/instruments/api.py @@ -9,6 +9,8 @@ from wavebench.services.access_policy import AccessMode, normalize_access_mode from wavebench.transport.base import InstrumentTransport +from .scope_extensions import ScopeDescriptorExtensions + EXECUTABLE_PLUGIN_API_VERSION = "wavebench.instrument.v2" ScopeCouplingPolicy = Literal["fixed-high-impedance", "switchable-termination", "unknown"] TransportFactory = Callable[[], InstrumentTransport] @@ -86,6 +88,8 @@ class InstrumentDescriptor: scope_coupling_policy: ScopeCouplingPolicy = "unknown" config_fields: tuple[str, ...] = () resource_schemes: tuple[str, ...] = () + # Append-only to preserve the positional layout accepted by instrument API v2. + scope_extensions: ScopeDescriptorExtensions | None = None def __post_init__(self) -> None: if not self.driver_id or self.driver_id.strip() != self.driver_id: @@ -113,6 +117,11 @@ def __post_init__(self) -> None: raise ValueError(f"instrument {self.driver_id!r} has duplicate resource schemes") if not callable(self.factory): raise TypeError(f"instrument {self.driver_id!r} factory must be callable") + if self.scope_extensions is not None: + if self.kind != "scope": + raise ValueError("scope_extensions can only be declared by scope descriptors") + if not isinstance(self.scope_extensions, ScopeDescriptorExtensions): + raise TypeError("scope_extensions has an invalid type") def with_distribution( self, diff --git a/src/wavebench/instruments/capabilities.py b/src/wavebench/instruments/capabilities.py index 66c080e..823db27 100644 --- a/src/wavebench/instruments/capabilities.py +++ b/src/wavebench/instruments/capabilities.py @@ -5,6 +5,10 @@ from wavebench.errors import ConfigError from .api import InstrumentDescriptor +from .scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + validate_scope_descriptor, +) CAPABILITY_METHODS: dict[str, tuple[str, ...]] = { @@ -85,6 +89,7 @@ "sweep_analyzer.marker": ("read_markers",), "sweep_analyzer.analysis": ("read_measurements",), } +CAPABILITY_METHODS.update(SCOPE_CAPABILITY_METHODS) def require_capabilities( @@ -119,3 +124,4 @@ def validate_declared_capabilities( f"descriptor declares capability {capability!r}, but driver lacks callable " f"method(s): {', '.join(missing_methods)}" ) + validate_scope_descriptor(descriptor, driver=driver) diff --git a/src/wavebench/instruments/registry.py b/src/wavebench/instruments/registry.py index f0b7bac..2adfcc2 100644 --- a/src/wavebench/instruments/registry.py +++ b/src/wavebench/instruments/registry.py @@ -19,6 +19,7 @@ ) from .builtin import BUILTIN_INSTRUMENTS from .capabilities import CAPABILITY_METHODS +from .scope_extension_capabilities import validate_scope_descriptor from .migrations import BUILTIN_MIGRATION_DISTRIBUTIONS ENTRY_POINT_GROUP = "wavebench.instruments" @@ -202,6 +203,7 @@ def _validate_descriptor( f"instrument driver {descriptor.driver_id!r} declares unknown capabilities: " f"{', '.join(unknown_capabilities)}" ) + validate_scope_descriptor(descriptor) current = _version_tuple(__version__) if current < _version_tuple(descriptor.wavebench_min_version) or current >= _version_tuple( descriptor.wavebench_max_version diff --git a/src/wavebench/instruments/scope_extension_capabilities.py b/src/wavebench/instruments/scope_extension_capabilities.py new file mode 100644 index 0000000..0997f4d --- /dev/null +++ b/src/wavebench/instruments/scope_extension_capabilities.py @@ -0,0 +1,141 @@ +"""Descriptor and capability validation for the scope R1.3 public contract.""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType + +from packaging.version import InvalidVersion, Version + +from wavebench.errors import ConfigError + +from .api import InstrumentDescriptor + + +SCOPE_EXTENSIONS_MIN_CORE_VERSION = "0.8.23" + + +SCOPE_CAPABILITY_METHODS: Mapping[str, tuple[str, ...]] = MappingProxyType( + { + "scope.screenshot_profile": ("get_screenshot_profile",), + "scope.screenshot_v2": ( + "get_screenshot_profile", + "capture_screenshot", + "snapshot_screenshot_state", + "restore_screenshot_state", + "verify_screenshot_state_restored", + ), + "scope.acquisition_run_state": ("get_acquisition_run_state",), + "scope.acquisition_control": ( + "get_acquisition_run_state", + "start_continuous", + "stop_acquisition", + "acquire_single", + "snapshot_acquisition_control", + "restore_acquisition_control", + "verify_acquisition_control_restored", + ), + "scope.trace_metadata": ("get_trace_metadata",), + "scope.fetch_trace": ( + "get_trace_metadata", + "fetch_trace", + "snapshot_trace_transfer_state", + "restore_trace_transfer_state", + "verify_trace_transfer_state_restored", + ), + "scope.error_drain_v1": ("drain_errors",), + } +) + +# Kept as an import-compatible alias for the internal implementation branch. +EXPERIMENTAL_SCOPE_CAPABILITY_METHODS = SCOPE_CAPABILITY_METHODS + + +def validate_scope_descriptor( + descriptor: InstrumentDescriptor, + *, + driver: object | None = None, + _require_public_version: bool = True, +) -> None: + """Validate scope extension declarations before instrument I/O.""" + + declared = set(descriptor.capabilities) & set(SCOPE_CAPABILITY_METHODS) + if not declared: + return + if descriptor.kind != "scope": + raise ConfigError("scope extension capabilities require a scope descriptor") + if _require_public_version: + try: + minimum = Version(descriptor.wavebench_min_version) + contract_minimum = Version(SCOPE_EXTENSIONS_MIN_CORE_VERSION) + except InvalidVersion as exc: + raise ConfigError("scope extension descriptor has an invalid core version") from exc + if minimum < contract_minimum: + raise ConfigError( + "scope extension capabilities require wavebench_min_version " + f">= {SCOPE_EXTENSIONS_MIN_CORE_VERSION}" + ) + dependencies = { + "scope.acquisition_control": {"scope.acquisition_run_state"}, + } + for capability, required in dependencies.items(): + if capability in declared and not required <= declared: + raise ConfigError( + f"instrument {descriptor.driver_id!r} capability {capability!r} requires " + + ", ".join(sorted(required)) + ) + extensions = descriptor.scope_extensions + profile_requirements = { + "scope.screenshot_profile": "screenshot_profile", + "scope.screenshot_v2": "screenshot_profile", + "scope.acquisition_control": "acquisition_control_profile", + "scope.trace_metadata": "trace_profile", + "scope.fetch_trace": "trace_profile", + } + for capability in sorted(declared): + profile_name = profile_requirements.get(capability) + if profile_name is not None and ( + extensions is None or getattr(extensions, profile_name) is None + ): + raise ConfigError( + f"instrument {descriptor.driver_id!r} capability {capability!r} " + f"requires scope_extensions.{profile_name}" + ) + if driver is None: + continue + missing = tuple( + method + for method in SCOPE_CAPABILITY_METHODS[capability] + if not callable(getattr(driver, method, None)) + ) + if missing: + raise ConfigError( + f"instrument {descriptor.driver_id!r} capability {capability!r} " + f"requires callable method(s): {', '.join(missing)}" + ) + + +def validate_experimental_scope_descriptor( + descriptor: InstrumentDescriptor, + *, + driver: object | None = None, + enabled: bool = False, +) -> None: + """Validate candidate capabilities without publishing them to the main registry.""" + + if not enabled: + raise ConfigError("experimental scope extensions are disabled") + validate_scope_descriptor( + descriptor, + driver=driver, + _require_public_version=False, + ) + + +__all__ = [ + "EXPERIMENTAL_SCOPE_CAPABILITY_METHODS", + "SCOPE_CAPABILITY_METHODS", + "SCOPE_EXTENSIONS_MIN_CORE_VERSION", + "validate_experimental_scope_descriptor", + "validate_scope_descriptor", +] diff --git a/src/wavebench/instruments/scope_extensions.py b/src/wavebench/instruments/scope_extensions.py new file mode 100644 index 0000000..83aad90 --- /dev/null +++ b/src/wavebench/instruments/scope_extensions.py @@ -0,0 +1,1366 @@ +"""Public scope-extension contracts from the R1.3 RFC.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +import re +from typing import Literal, Protocol, runtime_checkable +import zlib + +import numpy as np + +from wavebench.scope_extension_constants import ( + SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES, + SCOPE_TRACE_MAX_POINTS, +) +from wavebench.transport.contracts import BinaryResponseFraming + +from .contracts import InstrumentDriver + + +_SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$") + + +def _strict_int( + value: object, + *, + label: str, + minimum: int, + maximum: int | None = None, +) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label} must be an integer") + if value < minimum or (maximum is not None and value > maximum): + if maximum is None: + raise ValueError(f"{label} must be >= {minimum}") + raise ValueError(f"{label} must be in {minimum}..{maximum}") + return value + + +def _finite(value: object, *, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(float(value)): + raise ValueError(f"{label} must be finite") + return float(value) + + +def _literal(value: object, allowed: set[str], *, label: str) -> str: + if not isinstance(value, str) or value not in allowed: + raise ValueError(f"unsupported {label}: {value!r}") + return value + + +def _unique_tuple(values: object, *, label: str) -> tuple[object, ...]: + if not isinstance(values, tuple): + raise TypeError(f"{label} must be a tuple") + if len(set(values)) != len(values): + raise ValueError(f"{label} must not contain duplicates") + return values + + +def _safe_token(value: object, *, label: str) -> str: + if not isinstance(value, str) or _SAFE_TOKEN.fullmatch(value) is None: + raise ValueError(f"{label} must be a short ASCII safe token") + return value + + +def _optional_safe_token(value: object, *, label: str) -> str | None: + if value is None: + return None + return _safe_token(value, label=label) + + +def _hex_bytes(value: object, *, label: str) -> bytes: + if not isinstance(value, str) or value != value.lower() or len(value) % 2: + raise ValueError(f"{label} must be lowercase even-length hexadecimal") + if len(value) > 32 or re.fullmatch(r"[0-9a-f]*", value) is None: + raise ValueError(f"{label} must encode at most 16 bytes") + return bytes.fromhex(value) + + +def _validate_prefix_and_subsequence( + *, + expected: tuple[str, ...], + attempted: tuple[str, ...], + completed: tuple[str, ...], + status: str, +) -> None: + _unique_tuple(attempted, label="attempted_fields") + _unique_tuple(completed, label="restored_fields") + if expected[: len(attempted)] != attempted: + raise ValueError("attempted_fields must be a restore-order prefix") + completed_iter = iter(attempted) + if any(field_name not in completed_iter for field_name in completed): + raise ValueError("restored_fields must be an ordered attempted-field subsequence") + if status == "completed" and (attempted != expected or completed != expected): + raise ValueError("completed restore results must cover every restore field") + if status == "not_attempted" and (attempted or completed): + raise ValueError("not-attempted restore results cannot contain fields") + + +ScreenshotMenuMode = Literal["device", "include", "exclude"] +ScreenshotColorMode = Literal["device", "color", "monochrome", "inverted"] +ScopeScreenshotStateField = Literal["scope.display_menu", "scope.display_color"] +_SCREENSHOT_FIELDS = {"scope.display_menu", "scope.display_color"} + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshotRequest: + format: Literal["png"] = "png" + menu_mode: ScreenshotMenuMode = "device" + color_mode: ScreenshotColorMode = "device" + + def __post_init__(self) -> None: + _literal(self.format, {"png"}, label="screenshot format") + _literal(self.menu_mode, {"device", "include", "exclude"}, label="menu mode") + _literal( + self.color_mode, + {"device", "color", "monochrome", "inverted"}, + label="color mode", + ) + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshotVariant: + request: ScopeScreenshotRequest + media_type: Literal["image/png"] + framing: BinaryResponseFraming + response_max_bytes: int + operation_max_bytes: int + resynchronization_max_bytes: int + changed_fields: tuple[ScopeScreenshotStateField, ...] + restore_order: tuple[ScopeScreenshotStateField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + query_max_count: Literal[1] = 1 + transport_trailing_hex: str = "" + content_trailing_hex: str = "" + width_px: tuple[int, int] | None = None + height_px: tuple[int, int] | None = None + + def __post_init__(self) -> None: + if not isinstance(self.request, ScopeScreenshotRequest): + raise TypeError("screenshot variant request has an invalid type") + _literal(self.media_type, {"image/png"}, label="screenshot media type") + object.__setattr__(self, "framing", BinaryResponseFraming(self.framing)) + _strict_int( + self.response_max_bytes, + label="response_max_bytes", + minimum=1, + maximum=SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + ) + _strict_int( + self.operation_max_bytes, + label="operation_max_bytes", + minimum=1, + maximum=SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + ) + if self.operation_max_bytes != self.response_max_bytes: + raise ValueError("screenshot operation and response limits must be equal") + _strict_int( + self.resynchronization_max_bytes, + label="resynchronization_max_bytes", + minimum=0, + maximum=SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES, + ) + if self.query_max_count != 1 or isinstance(self.query_max_count, bool): + raise ValueError("screenshot query_max_count must be exactly 1") + changed = _unique_tuple(self.changed_fields, label="changed_fields") + restore = _unique_tuple(self.restore_order, label="restore_order") + if not set(changed) <= _SCREENSHOT_FIELDS or set(restore) != set(changed): + raise ValueError("screenshot restore_order must cover exactly the changed fields") + for field_name in changed: + if not isinstance(field_name, str): + raise TypeError("screenshot fields must be strings") + steps = (self.snapshot_max_steps, self.restore_max_steps, self.verify_max_steps) + if changed: + for label, value in zip( + ("snapshot_max_steps", "restore_max_steps", "verify_max_steps"), + steps, + strict=True, + ): + _strict_int(value, label=label, minimum=1, maximum=32) + elif steps != (0, 0, 0): + raise ValueError("stateless screenshot variants must use zero recovery steps") + transport_trailing = _hex_bytes( + self.transport_trailing_hex, + label="transport_trailing_hex", + ) + _hex_bytes(self.content_trailing_hex, label="content_trailing_hex") + if self.framing is BinaryResponseFraming.MESSAGE and transport_trailing: + raise ValueError("message-framed screenshots cannot declare transport trailing bytes") + for label, bounds in (("width_px", self.width_px), ("height_px", self.height_px)): + if bounds is None: + continue + if not isinstance(bounds, tuple) or len(bounds) != 2: + raise ValueError(f"{label} must contain minimum and maximum") + lower = _strict_int(bounds[0], label=f"{label}.minimum", minimum=1) + upper = _strict_int(bounds[1], label=f"{label}.maximum", minimum=1) + if lower > upper: + raise ValueError(f"{label} minimum cannot exceed maximum") + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshotProfile: + variants: tuple[ScopeScreenshotVariant, ...] + source: Literal["descriptor", "queried", "combined"] = "descriptor" + + def __post_init__(self) -> None: + if not isinstance(self.variants, tuple) or not self.variants: + raise ValueError("screenshot profile variants must be a non-empty tuple") + if any(not isinstance(item, ScopeScreenshotVariant) for item in self.variants): + raise TypeError("screenshot profile variants have an invalid type") + requests = tuple(item.request for item in self.variants) + if len(set(requests)) != len(requests): + raise ValueError("screenshot profile requests must be unique") + _literal(self.source, {"descriptor", "queried", "combined"}, label="profile source") + + def require_public_source(self) -> None: + if self.source == "queried": + raise ValueError("queried-only screenshot profiles cannot be public") + + def select(self, request: ScopeScreenshotRequest) -> ScopeScreenshotVariant: + matches = [variant for variant in self.variants if variant.request == request] + if len(matches) != 1: + raise ValueError("screenshot request does not match exactly one profile variant") + return matches[0] + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshotStateSnapshot: + captured_fields: tuple[ScopeScreenshotStateField, ...] + menu_state_token: str | None = None + color_state_token: str | None = None + + def __post_init__(self) -> None: + fields = _unique_tuple(self.captured_fields, label="captured_fields") + if not set(fields) <= _SCREENSHOT_FIELDS: + raise ValueError("screenshot snapshot contains an unsupported field") + menu = _optional_safe_token(self.menu_state_token, label="menu_state_token") + color = _optional_safe_token(self.color_state_token, label="color_state_token") + if ("scope.display_menu" in fields) != (menu is not None): + raise ValueError("menu token presence must match captured fields") + if ("scope.display_color" in fields) != (color is not None): + raise ValueError("color token presence must match captured fields") + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshotBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeScreenshotStateSnapshot + restore_order: tuple[ScopeScreenshotStateField, ...] + + def __post_init__(self) -> None: + _safe_token(self.context_id, label="context_id") + _safe_token(self.session_epoch, label="session_epoch") + _safe_token(self.baseline_nonce, label="baseline_nonce") + if not isinstance(self.snapshot, ScopeScreenshotStateSnapshot): + raise TypeError("screenshot baseline snapshot has an invalid type") + order = _unique_tuple(self.restore_order, label="restore_order") + if set(order) != set(self.snapshot.captured_fields): + raise ValueError("screenshot baseline order must cover captured fields exactly") + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshotRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeScreenshotStateField, ...] + restored_fields: tuple[ScopeScreenshotStateField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + attempted = _unique_tuple(self.attempted_fields, label="attempted_fields") + restored = _unique_tuple(self.restored_fields, label="restored_fields") + if not set(attempted + restored) <= _SCREENSHOT_FIELDS: + raise ValueError("screenshot restore fields are invalid") + _optional_safe_token(self.error_code, label="error_code") + + def validate_for(self, baseline: ScopeScreenshotBaseline) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + _optional_safe_token(self.error_code, label="error_code") + _validate_prefix_and_subsequence( + expected=baseline.restore_order, + attempted=self.attempted_fields, + completed=self.restored_fields, + status=self.status, + ) + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshotVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeScreenshotStateField, ...] + mismatched_fields: tuple[ScopeScreenshotStateField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"verified", "mismatch", "unavailable"}, label="verification status") + verified = _unique_tuple(self.verified_fields, label="verified_fields") + mismatched = _unique_tuple(self.mismatched_fields, label="mismatched_fields") + if set(verified) & set(mismatched) or not set(verified + mismatched) <= _SCREENSHOT_FIELDS: + raise ValueError("screenshot verification fields are inconsistent") + _optional_safe_token(self.error_code, label="error_code") + if self.status == "verified" and mismatched: + raise ValueError("verified screenshot state cannot contain mismatches") + if self.status == "mismatch" and not mismatched: + raise ValueError("mismatched screenshot verification requires mismatched fields") + if self.status == "unavailable" and (verified or mismatched): + raise ValueError("unavailable screenshot verification cannot claim fields") + + +def _png_dimensions(data: bytes) -> tuple[int, int]: + if ( + len(data) < 57 + or len(data) > SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES + or not data.startswith(b"\x89PNG\r\n\x1a\n") + ): + raise ValueError("screenshot data is not a PNG") + offset = 8 + width: int | None = None + height: int | None = None + saw_idat = False + chunk_index = 0 + while offset < len(data): + if len(data) - offset < 12: + raise ValueError("screenshot PNG contains a truncated chunk") + length = int.from_bytes(data[offset : offset + 4], "big") + chunk_type = data[offset + 4 : offset + 8] + end = offset + 12 + length + if end > len(data): + raise ValueError("screenshot PNG chunk length exceeds the payload") + if len(chunk_type) != 4 or any( + not (65 <= byte <= 90 or 97 <= byte <= 122) for byte in chunk_type + ): + raise ValueError("screenshot PNG contains an invalid chunk type") + chunk_data = data[offset + 8 : offset + 8 + length] + expected_crc = int.from_bytes(data[offset + 8 + length : end], "big") + actual_crc = zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF + if expected_crc != actual_crc: + raise ValueError("screenshot PNG chunk CRC is invalid") + if chunk_index == 0: + if chunk_type != b"IHDR" or length != 13: + raise ValueError("screenshot PNG must begin with a 13-byte IHDR") + width = int.from_bytes(chunk_data[0:4], "big") + height = int.from_bytes(chunk_data[4:8], "big") + if width < 1 or height < 1: + raise ValueError("screenshot PNG dimensions must be positive") + elif chunk_type == b"IHDR": + raise ValueError("screenshot PNG contains more than one IHDR") + if chunk_type == b"IDAT": + saw_idat = True + if chunk_type == b"IEND": + if length != 0 or end != len(data) or not saw_idat: + raise ValueError("screenshot PNG has an invalid IEND boundary") + assert width is not None and height is not None + return width, height + offset = end + chunk_index += 1 + raise ValueError("screenshot PNG is missing IEND") + + +@dataclass(frozen=True, slots=True) +class ScopeScreenshot: + data: bytes + media_type: Literal["image/png"] + width_px: int + height_px: int + requested: ScopeScreenshotRequest + effective: ScopeScreenshotRequest + framing: BinaryResponseFraming + + def __post_init__(self) -> None: + if not isinstance(self.data, bytes): + raise TypeError("screenshot data must be bytes") + _literal(self.media_type, {"image/png"}, label="screenshot media type") + width = _strict_int(self.width_px, label="width_px", minimum=1) + height = _strict_int(self.height_px, label="height_px", minimum=1) + if not isinstance(self.requested, ScopeScreenshotRequest) or not isinstance( + self.effective, ScopeScreenshotRequest + ): + raise TypeError("screenshot requests have an invalid type") + object.__setattr__(self, "framing", BinaryResponseFraming(self.framing)) + if _png_dimensions(self.data) != (width, height): + raise ValueError("screenshot dimensions do not match the PNG IHDR") + + +@dataclass(frozen=True, slots=True) +class ScopeEmbeddedScreenshotContract: + request: ScopeScreenshotRequest + changed_fields: tuple[ScopeScreenshotStateField, ...] + verification_fields: tuple[ScopeScreenshotStateField, ...] + cleanup_verification_fields: tuple[ScopeScreenshotStateField, ...] + output_fields: tuple[Literal["output.screenshot"], ...] = ("output.screenshot",) + failure_policy: Literal["fail_parent"] = "fail_parent" + artifact_key: Literal["screenshot"] = "screenshot" + + def __post_init__(self) -> None: + if not isinstance(self.request, ScopeScreenshotRequest): + raise TypeError("embedded screenshot request has an invalid type") + changed = _unique_tuple(self.changed_fields, label="changed_fields") + verified = _unique_tuple(self.verification_fields, label="verification_fields") + cleanup = _unique_tuple( + self.cleanup_verification_fields, + label="cleanup_verification_fields", + ) + if not set(changed) <= _SCREENSHOT_FIELDS: + raise ValueError("embedded screenshot changed fields are invalid") + if set(verified) != set(changed) or set(cleanup) != set(changed): + raise ValueError("embedded screenshot recovery fields must cover changed state") + if self.output_fields != ("output.screenshot",): + raise ValueError("embedded screenshot output field is fixed") + _literal(self.failure_policy, {"fail_parent"}, label="screenshot failure policy") + _literal(self.artifact_key, {"screenshot"}, label="screenshot artifact key") + + +ScopeAcquisitionPhase = Literal[ + "unknown", + "stopped", + "ready", + "arming", + "waiting", + "acquiring", + "rolling", + "stopping", + "complete", + "error", +] +ScopeTriggerMode = Literal["auto", "normal", "single", "roll", "unknown"] +ScopeContinuousTriggerMode = Literal["auto", "normal", "roll"] +ScopeSingleBaselineStage = Literal["configured_pre_arm", "original_atomic_arm"] +ScopeSingleArmSemantics = Literal["configure_then_arm", "atomic_configure_and_arm"] +ScopeAcquisitionIdentitySemantics = Literal["unique_within_session_epoch", "unknown"] +ScopeAcquisitionSettingField = Literal["scope.trigger", "scope.acquisition"] +ScopeAcquisitionRestoreField = Literal[ + "scope.run_state", + "scope.trigger", + "scope.acquisition", +] +ScopeCompletionProof = Literal["count_delta_with_epoch", "identity_delta", "state_transition"] +_ACQUISITION_SETTING_FIELDS = {"scope.trigger", "scope.acquisition"} +_ACQUISITION_RESTORE_FIELDS = {"scope.run_state", *_ACQUISITION_SETTING_FIELDS} +_ACQUISITION_PHASES = { + "unknown", + "stopped", + "ready", + "arming", + "waiting", + "acquiring", + "rolling", + "stopping", + "complete", + "error", +} + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionControlProfile: + supported_continuous_modes: tuple[ScopeContinuousTriggerMode, ...] + single_arm_semantics: ScopeSingleArmSemantics + arm_resets_acquisition_count: bool + failure_restore_order: tuple[ScopeAcquisitionSettingField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + identity_semantics: ScopeAcquisitionIdentitySemantics + atomic_arm_preserves_count_mode_semantics: bool = False + + def __post_init__(self) -> None: + modes = _unique_tuple( + self.supported_continuous_modes, + label="supported_continuous_modes", + ) + if not modes or not set(modes) <= {"auto", "normal", "roll"}: + raise ValueError("supported continuous modes are invalid") + _literal( + self.single_arm_semantics, + {"configure_then_arm", "atomic_configure_and_arm"}, + label="single arm semantics", + ) + if not isinstance(self.arm_resets_acquisition_count, bool) or not isinstance( + self.atomic_arm_preserves_count_mode_semantics, bool + ): + raise TypeError("acquisition profile flags must be bool") + restore = _unique_tuple(self.failure_restore_order, label="failure_restore_order") + if set(restore) != _ACQUISITION_SETTING_FIELDS: + raise ValueError("failure_restore_order must contain trigger and acquisition once") + _strict_int(self.snapshot_max_steps, label="snapshot_max_steps", minimum=3, maximum=64) + _strict_int(self.restore_max_steps, label="restore_max_steps", minimum=3, maximum=64) + _strict_int(self.verify_max_steps, label="verify_max_steps", minimum=3, maximum=64) + _literal( + self.identity_semantics, + {"unique_within_session_epoch", "unknown"}, + label="identity semantics", + ) + if self.single_arm_semantics == "configure_then_arm" and ( + self.atomic_arm_preserves_count_mode_semantics + ): + raise ValueError("configure-then-arm cannot claim atomic-arm count semantics") + if self.single_arm_semantics == "atomic_configure_and_arm" and ( + self.atomic_arm_preserves_count_mode_semantics + and self.arm_resets_acquisition_count + ): + raise ValueError("an arm that resets count cannot preserve count semantics") + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionRunState: + phase: ScopeAcquisitionPhase + trigger_mode: ScopeTriggerMode + raw_state: str + acquisition_count: int | None = None + counter_epoch: str | None = None + acquisition_identity: str | None = None + + def __post_init__(self) -> None: + _literal(self.phase, _ACQUISITION_PHASES, label="acquisition phase") + _literal( + self.trigger_mode, + {"auto", "normal", "single", "roll", "unknown"}, + label="trigger mode", + ) + if ( + not isinstance(self.raw_state, str) + or not self.raw_state + or len(self.raw_state) > 128 + or not self.raw_state.isprintable() + or "\n" in self.raw_state + or "\r" in self.raw_state + ): + raise ValueError("raw acquisition state must be a short printable token") + if self.acquisition_count is not None: + _strict_int(self.acquisition_count, label="acquisition_count", minimum=0) + _optional_safe_token(self.counter_epoch, label="counter_epoch") + _optional_safe_token(self.acquisition_identity, label="acquisition_identity") + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionControlSnapshot: + run_state: ScopeAcquisitionRunState + trigger_state_token: str + acquisition_state_token: str + + def __post_init__(self) -> None: + if not isinstance(self.run_state, ScopeAcquisitionRunState): + raise TypeError("acquisition snapshot run state has an invalid type") + _safe_token(self.trigger_state_token, label="trigger_state_token") + _safe_token(self.acquisition_state_token, label="acquisition_state_token") + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionControlBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeAcquisitionControlSnapshot + restore_order: tuple[ScopeAcquisitionRestoreField, ...] + + def __post_init__(self) -> None: + _safe_token(self.context_id, label="context_id") + _safe_token(self.session_epoch, label="session_epoch") + _safe_token(self.baseline_nonce, label="baseline_nonce") + if not isinstance(self.snapshot, ScopeAcquisitionControlSnapshot): + raise TypeError("acquisition baseline snapshot has an invalid type") + order = _unique_tuple(self.restore_order, label="restore_order") + if not order or order[0] != "scope.run_state" or set(order) != _ACQUISITION_RESTORE_FIELDS: + raise ValueError("acquisition restore order must start with run state and cover all fields") + + +@dataclass(frozen=True, slots=True) +class ScopeBaselineRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeAcquisitionRestoreField, ...] + restored_fields: tuple[ScopeAcquisitionRestoreField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + attempted = _unique_tuple(self.attempted_fields, label="attempted_fields") + restored = _unique_tuple(self.restored_fields, label="restored_fields") + if not set(attempted + restored) <= _ACQUISITION_RESTORE_FIELDS: + raise ValueError("acquisition restore fields are invalid") + _optional_safe_token(self.error_code, label="error_code") + + def validate_for(self, baseline: ScopeAcquisitionControlBaseline) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + _optional_safe_token(self.error_code, label="error_code") + _validate_prefix_and_subsequence( + expected=baseline.restore_order, + attempted=self.attempted_fields, + completed=self.restored_fields, + status=self.status, + ) + + +@dataclass(frozen=True, slots=True) +class ScopeBaselineVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeAcquisitionRestoreField, ...] + mismatched_fields: tuple[ScopeAcquisitionRestoreField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"verified", "mismatch", "unavailable"}, label="verification status") + verified = _unique_tuple(self.verified_fields, label="verified_fields") + mismatched = _unique_tuple(self.mismatched_fields, label="mismatched_fields") + if set(verified) & set(mismatched) or not set(verified + mismatched) <= _ACQUISITION_RESTORE_FIELDS: + raise ValueError("acquisition verification fields are inconsistent") + _optional_safe_token(self.error_code, label="error_code") + if self.status == "verified" and mismatched: + raise ValueError("verified acquisition state cannot contain mismatches") + if self.status == "mismatch" and not mismatched: + raise ValueError("mismatched acquisition verification requires mismatched fields") + if self.status == "unavailable" and (verified or mismatched): + raise ValueError("unavailable acquisition verification cannot claim fields") + + +@dataclass(frozen=True, slots=True) +class ScopeContinuousAcquisitionRequest: + trigger_mode: ScopeContinuousTriggerMode + + def __post_init__(self) -> None: + _literal(self.trigger_mode, {"auto", "normal", "roll"}, label="continuous trigger mode") + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionCompletion: + state: ScopeAcquisitionRunState + original_state: ScopeAcquisitionRunState + proof_baseline_state: ScopeAcquisitionRunState + proof_baseline_stage: ScopeSingleBaselineStage + proof: ScopeCompletionProof + baseline_count: int | None = None + completed_count: int | None = None + baseline_identity: str | None = None + completed_identity: str | None = None + observed_states: tuple[ScopeAcquisitionRunState, ...] = () + + def __post_init__(self) -> None: + if any( + not isinstance(value, ScopeAcquisitionRunState) + for value in (self.state, self.original_state, self.proof_baseline_state) + ): + raise TypeError("acquisition completion states have an invalid type") + _literal( + self.proof_baseline_stage, + {"configured_pre_arm", "original_atomic_arm"}, + label="proof baseline stage", + ) + _literal( + self.proof, + {"count_delta_with_epoch", "identity_delta", "state_transition"}, + label="completion proof", + ) + if not isinstance(self.observed_states, tuple) or not self.observed_states: + raise ValueError("completion proof must retain observed states") + if any(not isinstance(item, ScopeAcquisitionRunState) for item in self.observed_states): + raise TypeError("observed acquisition states have an invalid type") + if self.observed_states[-1] != self.state or self.state.phase not in {"complete", "stopped"}: + raise ValueError("completion proof must end in the reported complete/stopped state") + for label, value in ( + ("baseline_count", self.baseline_count), + ("completed_count", self.completed_count), + ): + if value is not None: + _strict_int(value, label=label, minimum=0) + _optional_safe_token(self.baseline_identity, label="baseline_identity") + _optional_safe_token(self.completed_identity, label="completed_identity") + if self.baseline_count is not None and ( + self.baseline_count != self.proof_baseline_state.acquisition_count + ): + raise ValueError("baseline_count must match proof_baseline_state") + if self.completed_count is not None and self.completed_count != self.state.acquisition_count: + raise ValueError("completed_count must match final state") + if self.baseline_identity is not None and ( + self.baseline_identity != self.proof_baseline_state.acquisition_identity + ): + raise ValueError("baseline_identity must match proof_baseline_state") + if self.completed_identity is not None and ( + self.completed_identity != self.state.acquisition_identity + ): + raise ValueError("completed_identity must match final state") + + +def validate_acquisition_completion( + completion: ScopeAcquisitionCompletion, + *, + baseline: ScopeAcquisitionControlBaseline, + profile: ScopeAcquisitionControlProfile, +) -> None: + """Validate proof semantics that depend on the core-owned profile/baseline.""" + + if completion.original_state != baseline.snapshot.run_state: + raise ValueError("completion original state does not match the core baseline") + if profile.single_arm_semantics == "configure_then_arm": + expected_stage = "configured_pre_arm" + else: + expected_stage = "original_atomic_arm" + if completion.proof_baseline_stage != expected_stage: + raise ValueError("completion proof stage does not match the acquisition profile") + if expected_stage == "original_atomic_arm" and ( + completion.proof_baseline_state != completion.original_state + ): + raise ValueError("atomic-arm proof baseline must equal the original state") + phases = tuple(item.phase for item in completion.observed_states) + transition_seen = any( + item.phase in {"arming", "waiting", "acquiring"} + or ( + item.phase == "ready" + and item.trigger_mode == "single" + and item != completion.proof_baseline_state + ) + for item in completion.observed_states[:-1] + ) + if completion.proof == "identity_delta": + if profile.identity_semantics != "unique_within_session_epoch": + raise ValueError("identity proof requires unique-within-epoch semantics") + if ( + completion.baseline_identity is None + or completion.completed_identity is None + or completion.baseline_identity == completion.completed_identity + ): + raise ValueError("identity proof requires different non-empty identities") + elif completion.proof == "count_delta_with_epoch": + if profile.arm_resets_acquisition_count: + raise ValueError("count proof is invalid when arm resets acquisition count") + if ( + completion.baseline_count is None + or completion.completed_count is None + or completion.completed_count <= completion.baseline_count + or completion.proof_baseline_state.counter_epoch is None + or completion.state.counter_epoch != completion.proof_baseline_state.counter_epoch + or not transition_seen + ): + raise ValueError("count proof requires an increasing count, stable epoch and transition") + if expected_stage == "original_atomic_arm" and ( + completion.original_state.trigger_mode != "single" + or not profile.atomic_arm_preserves_count_mode_semantics + ): + raise ValueError("atomic-arm count proof lacks preserved single-mode semantics") + elif not transition_seen: + raise ValueError("state-transition proof does not contain a valid new acquisition transition") + if phases[-1] not in {"complete", "stopped"}: + raise ValueError("completion proof has an invalid terminal phase") + + +ScopeTraceKind = Literal["analog", "digital", "math", "reference", "spectrum"] +ScopeAxisKind = Literal["time", "frequency", "index", "unknown"] +ScopeAxisUnit = Literal["s", "Hz", "1", "unknown"] +ScopeTraceUnit = Literal["v", "mv", "db", "dbm", "1", "unknown"] +ScopeTraceMagnitudeSemantics = Literal["absolute", "relative", "linear", "unknown"] +ScopeTraceOperation = Literal[ + "identity", + "reference_copy", + "fft_magnitude", + "fft_phase", + "device_other", + "unknown", +] +ScopeTraceTransferField = Literal[ + "scope.run_state", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +] +_TRACE_KINDS = {"analog", "digital", "math", "reference", "spectrum"} +_TRACE_FETCHABLE_KINDS = {"analog", "digital", "reference"} +_TRACE_TRANSFER_FIELDS = { + "scope.run_state", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +} +_TRACE_TOKEN_ATTRS = { + "scope.run_state": "run_state_token", + "scope.waveform_source": "waveform_source_token", + "scope.waveform_mode": "waveform_mode_token", + "scope.query_response_header": "query_response_header_token", + "scope.waveform_format": "waveform_format_token", + "scope.waveform_byte_order": "waveform_byte_order_token", + "scope.waveform_points": "waveform_points_token", + "scope.waveform_transfer_window": "waveform_transfer_window_token", +} + + +@dataclass(frozen=True, slots=True) +class ScopeTraceRef: + kind: ScopeTraceKind + index: int | None = None + name: str | None = None + + def __post_init__(self) -> None: + _literal(self.kind, _TRACE_KINDS, label="trace kind") + if (self.index is None) == (self.name is None): + raise ValueError("trace reference must provide exactly one of index or name") + if self.index is not None: + if self.kind == "digital": + _strict_int(self.index, label="digital trace index", minimum=0, maximum=15) + else: + _strict_int(self.index, label="trace index", minimum=1, maximum=65_535) + if self.name is not None and ( + not isinstance(self.name, str) + or not 1 <= len(self.name) <= 64 + or self.name.strip() != self.name + or not self.name.isprintable() + ): + raise ValueError("trace name must be 1..64 printable trimmed code points") + + +@dataclass(frozen=True, slots=True) +class ScopeAxisMetadata: + kind: ScopeAxisKind + unit: ScopeAxisUnit + start: float | None + increment: float | None + points: int + + def __post_init__(self) -> None: + _literal(self.kind, {"time", "frequency", "index", "unknown"}, label="axis kind") + _literal(self.unit, {"s", "Hz", "1", "unknown"}, label="axis unit") + points = _strict_int(self.points, label="axis points", minimum=1, maximum=SCOPE_TRACE_MAX_POINTS) + if self.kind == "unknown": + if self.unit != "unknown" or self.start is not None or self.increment is not None: + raise ValueError("unknown axes cannot claim units or scaling") + return + expected_unit = {"time": "s", "frequency": "Hz", "index": "1"}[self.kind] + if self.unit != expected_unit: + raise ValueError("axis kind and unit are inconsistent") + start = _finite(self.start, label="axis start") + increment = _finite(self.increment, label="axis increment") + if increment <= 0: + raise ValueError("axis increment must be positive") + if not isfinite(start + increment * (points - 1)): + raise ValueError("axis final coordinate must be finite") + if self.kind == "frequency" and start < 0: + raise ValueError("frequency axes cannot start below zero") + + +@dataclass(frozen=True, slots=True) +class ScopeTraceMetadata: + source: ScopeTraceRef + x_axis: ScopeAxisMetadata + y_unit: ScopeTraceUnit + y_semantics: ScopeTraceMagnitudeSemantics + value_encoding: Literal["real", "digital_bitmask"] + y_increment: float | None = None + y_origin: float | None = None + y_resolution_bits: int | None = None + operation: ScopeTraceOperation = "unknown" + inputs: tuple[ScopeTraceRef, ...] = () + digital_channels: tuple[int, ...] = () + fetchable: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.source, ScopeTraceRef) or not isinstance( + self.x_axis, ScopeAxisMetadata + ): + raise TypeError("trace metadata source or axis has an invalid type") + _literal(self.y_unit, {"v", "mv", "db", "dbm", "1", "unknown"}, label="trace unit") + _literal( + self.y_semantics, + {"absolute", "relative", "linear", "unknown"}, + label="trace magnitude semantics", + ) + _literal(self.value_encoding, {"real", "digital_bitmask"}, label="value encoding") + _literal( + self.operation, + {"identity", "reference_copy", "fft_magnitude", "fft_phase", "device_other", "unknown"}, + label="trace operation", + ) + if not isinstance(self.fetchable, bool): + raise TypeError("trace fetchable must be bool") + inputs = _unique_tuple(self.inputs, label="trace inputs") + if any(not isinstance(item, ScopeTraceRef) for item in inputs): + raise TypeError("trace inputs have an invalid type") + channels = _unique_tuple(self.digital_channels, label="digital_channels") + for channel in channels: + _strict_int(channel, label="digital channel", minimum=0, maximum=15) + scaling_values = (self.y_increment, self.y_origin) + if (self.y_increment is None) != (self.y_origin is None): + raise ValueError("trace y increment and origin must be present together") + if self.y_increment is not None: + if _finite(self.y_increment, label="y_increment") == 0: + raise ValueError("trace y increment cannot be zero") + _finite(self.y_origin, label="y_origin") + if self.y_resolution_bits is not None: + _strict_int(self.y_resolution_bits, label="y_resolution_bits", minimum=1, maximum=64) + if self.value_encoding == "digital_bitmask": + if ( + self.source.kind != "digital" + or self.y_unit != "1" + or self.y_semantics != "unknown" + or scaling_values != (None, None) + or self.y_resolution_bits is not None + or self.source.index is None + or channels != (self.source.index,) + ): + raise ValueError("digital trace metadata is inconsistent") + elif channels: + raise ValueError("real trace metadata cannot contain digital channels") + if self.source.kind == "digital" and self.value_encoding != "digital_bitmask": + raise ValueError("digital traces require bitmask encoding") + if self.source.kind != "digital" and self.value_encoding != "real": + raise ValueError("non-digital traces require real encoding") + expected_semantics = { + "dbm": "absolute", + "db": "relative", + "v": "linear", + "mv": "linear", + "1": "unknown", + "unknown": "unknown", + }[self.y_unit] + if self.y_semantics != expected_semantics: + raise ValueError("trace unit and magnitude semantics are inconsistent") + if self.source.kind in {"analog", "digital"} and ( + self.operation != "identity" or inputs + ): + raise ValueError("analog and digital traces must use identity without inputs") + if self.source.kind == "reference": + if self.operation == "identity" and inputs: + raise ValueError("identity reference traces cannot contain inputs") + if self.operation == "reference_copy" and len(inputs) != 1: + raise ValueError("reference_copy requires exactly one input") + if self.operation not in {"identity", "reference_copy"}: + raise ValueError("reference trace operation is unsupported") + if self.operation in {"device_other", "unknown"} and inputs: + raise ValueError("unknown/device operations cannot claim inputs") + if self.fetchable: + if self.source.kind not in _TRACE_FETCHABLE_KINDS or self.x_axis.kind != "time": + raise ValueError("R1.3 fetchable traces are analog/digital/reference time traces") + if self.source.kind == "digital": + if self.value_encoding != "digital_bitmask": + raise ValueError("fetchable digital traces require bitmask encoding") + elif ( + self.value_encoding != "real" + or self.y_unit not in {"v", "mv"} + or self.y_semantics != "linear" + or self.operation not in {"identity", "reference_copy"} + ): + raise ValueError("fetchable real trace metadata is outside R1.3") + + +@dataclass(frozen=True, slots=True) +class ScopeTraceData: + metadata: ScopeTraceMetadata + values: np.ndarray + + def __post_init__(self) -> None: + if not isinstance(self.metadata, ScopeTraceMetadata) or not self.metadata.fetchable: + raise ValueError("trace data requires fetchable metadata") + values = np.asarray(self.values) + if values.ndim != 1 or values.size < 1 or values.size != self.metadata.x_axis.points: + raise ValueError("trace values must be a non-empty 1-D array matching axis points") + if self.metadata.value_encoding == "digital_bitmask": + if values.dtype.kind != "u" or values.dtype.itemsize > 2: + raise ValueError("digital trace values must use an unsigned <=16-bit dtype") + channel = self.metadata.digital_channels[0] + allowed = np.logical_or(values == 0, values == (1 << channel)) + if not bool(np.all(allowed)): + raise ValueError("digital trace contains values outside its single-bit encoding") + copied = np.array(values, dtype=np.uint16, copy=True) + else: + if values.dtype.kind == "c": + raise ValueError("complex trace values are not supported") + try: + copied = np.array(values, dtype=np.float64, copy=True) + except (TypeError, ValueError) as exc: + raise ValueError("real trace values must be numeric") from exc + if not bool(np.all(np.isfinite(copied))): + raise ValueError("real trace values must be finite") + copied.setflags(write=False) + object.__setattr__(self, "values", copied) + + +@dataclass(frozen=True, slots=True) +class ScopeTraceTransferStateSnapshot: + captured_fields: tuple[ScopeTraceTransferField, ...] + run_state_token: str | None = None + waveform_source_token: str | None = None + waveform_mode_token: str | None = None + query_response_header_token: str | None = None + waveform_format_token: str | None = None + waveform_byte_order_token: str | None = None + waveform_points_token: str | None = None + waveform_transfer_window_token: str | None = None + + def __post_init__(self) -> None: + fields = _unique_tuple(self.captured_fields, label="captured_fields") + if not set(fields) <= _TRACE_TRANSFER_FIELDS: + raise ValueError("trace transfer snapshot contains unsupported fields") + for field_name, attr_name in _TRACE_TOKEN_ATTRS.items(): + token = _optional_safe_token(getattr(self, attr_name), label=attr_name) + if (field_name in fields) != (token is not None): + raise ValueError(f"{attr_name} presence must match captured fields") + + +@dataclass(frozen=True, slots=True) +class ScopeTraceTransferBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeTraceTransferStateSnapshot + restore_order: tuple[ScopeTraceTransferField, ...] + + def __post_init__(self) -> None: + _safe_token(self.context_id, label="context_id") + _safe_token(self.session_epoch, label="session_epoch") + _safe_token(self.baseline_nonce, label="baseline_nonce") + if not isinstance(self.snapshot, ScopeTraceTransferStateSnapshot): + raise TypeError("trace transfer baseline snapshot has an invalid type") + order = _unique_tuple(self.restore_order, label="restore_order") + if set(order) != set(self.snapshot.captured_fields): + raise ValueError("trace transfer restore order must cover captured fields exactly") + + +@dataclass(frozen=True, slots=True) +class ScopeTraceTransferRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeTraceTransferField, ...] + restored_fields: tuple[ScopeTraceTransferField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + attempted = _unique_tuple(self.attempted_fields, label="attempted_fields") + restored = _unique_tuple(self.restored_fields, label="restored_fields") + if not set(attempted + restored) <= _TRACE_TRANSFER_FIELDS: + raise ValueError("trace transfer restore fields are invalid") + _optional_safe_token(self.error_code, label="error_code") + + def validate_for(self, baseline: ScopeTraceTransferBaseline) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + _optional_safe_token(self.error_code, label="error_code") + _validate_prefix_and_subsequence( + expected=baseline.restore_order, + attempted=self.attempted_fields, + completed=self.restored_fields, + status=self.status, + ) + + +@dataclass(frozen=True, slots=True) +class ScopeTraceTransferVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeTraceTransferField, ...] + mismatched_fields: tuple[ScopeTraceTransferField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"verified", "mismatch", "unavailable"}, label="verification status") + verified = _unique_tuple(self.verified_fields, label="verified_fields") + mismatched = _unique_tuple(self.mismatched_fields, label="mismatched_fields") + if set(verified) & set(mismatched) or not set(verified + mismatched) <= _TRACE_TRANSFER_FIELDS: + raise ValueError("trace transfer verification fields are inconsistent") + _optional_safe_token(self.error_code, label="error_code") + if self.status == "verified" and mismatched: + raise ValueError("verified transfer state cannot contain mismatches") + if self.status == "mismatch" and not mismatched: + raise ValueError("mismatched transfer verification requires mismatched fields") + if self.status == "unavailable" and (verified or mismatched): + raise ValueError("unavailable transfer verification cannot claim fields") + + +@dataclass(frozen=True, slots=True) +class ScopeTraceProfile: + fetchable_kinds: tuple[Literal["analog", "digital", "reference"], ...] + max_points: int + restore_order: tuple[ScopeTraceTransferField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + source_index_max: int = 65_535 + + def __post_init__(self) -> None: + kinds = _unique_tuple(self.fetchable_kinds, label="fetchable_kinds") + if not kinds or not set(kinds) <= _TRACE_FETCHABLE_KINDS: + raise ValueError("trace profile fetchable kinds are outside R1.3") + _strict_int( + self.max_points, + label="max_points", + minimum=1, + maximum=SCOPE_TRACE_MAX_POINTS, + ) + restore = _unique_tuple(self.restore_order, label="restore_order") + if not restore or not set(restore) <= _TRACE_TRANSFER_FIELDS: + raise ValueError("trace profile restore order must contain transfer fields") + for label, value in ( + ("snapshot_max_steps", self.snapshot_max_steps), + ("restore_max_steps", self.restore_max_steps), + ("verify_max_steps", self.verify_max_steps), + ): + _strict_int(value, label=label, minimum=len(restore), maximum=64) + _strict_int( + self.source_index_max, + label="source_index_max", + minimum=1, + maximum=65_535, + ) + + +ErrorCheckPolicy = Literal["required", "if_supported", "disabled"] +ErrorCheckTiming = Literal["before", "after", "before_and_after"] +InstrumentErrorPolicy = Literal["fail", "record_and_continue"] + + +@dataclass(frozen=True, slots=True) +class ErrorCheckSpec: + policy: ErrorCheckPolicy + timing: ErrorCheckTiming = "before_and_after" + max_records: int = 16 + on_instrument_error: InstrumentErrorPolicy = "fail" + + def __post_init__(self) -> None: + _literal(self.policy, {"required", "if_supported", "disabled"}, label="error policy") + _literal(self.timing, {"before", "after", "before_and_after"}, label="error timing") + _strict_int(self.max_records, label="max_records", minimum=1, maximum=256) + _literal( + self.on_instrument_error, + {"fail", "record_and_continue"}, + label="instrument error policy", + ) + + +@dataclass(frozen=True, slots=True) +class DriverErrorRecord: + code: str | int | None + message: str + severity: Literal["info", "warning", "error", "fatal", "unknown"] + source: str + + def __post_init__(self) -> None: + if isinstance(self.code, bool) or not isinstance(self.code, (str, int, type(None))): + raise ValueError("driver error code has an invalid type") + if isinstance(self.code, str): + _safe_token(self.code, label="driver error code") + if not isinstance(self.message, str) or not self.message: + raise ValueError("driver error message must be non-empty") + _literal( + self.severity, + {"info", "warning", "error", "fatal", "unknown"}, + label="driver error severity", + ) + _safe_token(self.source, label="driver error source") + + +@dataclass(frozen=True, slots=True) +class ErrorDrainResult: + records: tuple[DriverErrorRecord, ...] + terminated: bool + query_count: int + overflow_record: DriverErrorRecord | None = None + + def validate_for(self, *, max_records: int) -> None: + limit = _strict_int(max_records, label="max_records", minimum=1, maximum=256) + if not isinstance(self.records, tuple) or any( + not isinstance(item, DriverErrorRecord) for item in self.records + ): + raise TypeError("error drain records have an invalid type") + if not isinstance(self.terminated, bool): + raise TypeError("error drain terminated must be bool") + _strict_int(self.query_count, label="query_count", minimum=1, maximum=limit + 1) + if self.terminated: + if self.overflow_record is not None or self.query_count != len(self.records) + 1: + raise ValueError("terminated error drain has inconsistent evidence") + elif ( + len(self.records) != limit + or self.query_count != limit + 1 + or not isinstance(self.overflow_record, DriverErrorRecord) + ): + raise ValueError("unterminated error drain must retain its overflow record") + + +@dataclass(frozen=True, slots=True) +class ErrorRecord: + code: str | int | None + message: str + message_redacted: bool + severity: Literal["info", "warning", "error", "fatal", "unknown"] + source: str + observed_at_utc: str + correlation_id: str | None + + def __post_init__(self) -> None: + if isinstance(self.code, bool) or not isinstance(self.code, (str, int, type(None))): + raise ValueError("error code has an invalid type") + if isinstance(self.code, str): + _safe_token(self.code, label="error code") + if ( + not isinstance(self.message, str) + or not 1 <= len(self.message) <= 512 + or not self.message.isprintable() + ): + raise ValueError("error message must be 1..512 printable code points") + if not isinstance(self.message_redacted, bool): + raise TypeError("message_redacted must be bool") + _literal( + self.severity, + {"info", "warning", "error", "fatal", "unknown"}, + label="error severity", + ) + _safe_token(self.source, label="error source") + if not isinstance(self.observed_at_utc, str) or re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", + self.observed_at_utc, + ) is None: + raise ValueError("observed_at_utc must be an RFC 3339 UTC timestamp") + if self.correlation_id is not None: + _safe_token(self.correlation_id, label="correlation_id") + + +@dataclass(frozen=True, slots=True) +class ScopeDescriptorExtensions: + screenshot_profile: ScopeScreenshotProfile | None = None + acquisition_control_profile: ScopeAcquisitionControlProfile | None = None + trace_profile: ScopeTraceProfile | None = None + + def __post_init__(self) -> None: + for label, value, expected in ( + ("screenshot_profile", self.screenshot_profile, ScopeScreenshotProfile), + ( + "acquisition_control_profile", + self.acquisition_control_profile, + ScopeAcquisitionControlProfile, + ), + ("trace_profile", self.trace_profile, ScopeTraceProfile), + ): + if value is not None and not isinstance(value, expected): + raise TypeError(f"{label} has an invalid type") + if self.screenshot_profile is not None: + self.screenshot_profile.require_public_source() + + +@runtime_checkable +class ScopeScreenshotProfileDriver(InstrumentDriver, Protocol): + def get_screenshot_profile(self) -> ScopeScreenshotProfile: ... + + +@runtime_checkable +class ScopeScreenshotDriver(InstrumentDriver, Protocol): + def snapshot_screenshot_state( + self, + fields: tuple[ScopeScreenshotStateField, ...], + ) -> ScopeScreenshotStateSnapshot: ... + + def capture_screenshot( + self, + request: ScopeScreenshotRequest, + *, + baseline: ScopeScreenshotBaseline | None, + ) -> ScopeScreenshot: ... + + def restore_screenshot_state( + self, + baseline: ScopeScreenshotBaseline, + ) -> ScopeScreenshotRestoreResult: ... + + def verify_screenshot_state_restored( + self, + fields: tuple[ScopeScreenshotStateField, ...], + baseline: ScopeScreenshotBaseline, + ) -> ScopeScreenshotStateSnapshot: ... + + +@runtime_checkable +class ScopeAcquisitionRunStateDriver(InstrumentDriver, Protocol): + def get_acquisition_run_state(self) -> ScopeAcquisitionRunState: ... + + +@runtime_checkable +class ScopeAcquisitionControlRecoveryDriver(InstrumentDriver, Protocol): + def snapshot_acquisition_control(self) -> ScopeAcquisitionControlSnapshot: ... + + def restore_acquisition_control( + self, + baseline: ScopeAcquisitionControlBaseline, + ) -> ScopeBaselineRestoreResult: ... + + def verify_acquisition_control_restored( + self, + baseline: ScopeAcquisitionControlBaseline, + ) -> ScopeAcquisitionControlSnapshot: ... + + +@runtime_checkable +class ScopeAcquisitionControlDriver( + ScopeAcquisitionRunStateDriver, + ScopeAcquisitionControlRecoveryDriver, + Protocol, +): + def start_continuous( + self, + *, + trigger_mode: ScopeContinuousTriggerMode, + baseline: ScopeAcquisitionControlBaseline, + ) -> ScopeAcquisitionRunState: ... + + def stop_acquisition(self) -> ScopeAcquisitionRunState: ... + + def acquire_single( + self, + *, + baseline: ScopeAcquisitionControlBaseline, + deadline: float, + ) -> ScopeAcquisitionCompletion: ... + + +@runtime_checkable +class ScopeTraceTransferRecoveryDriver(InstrumentDriver, Protocol): + def snapshot_trace_transfer_state( + self, + fields: tuple[ScopeTraceTransferField, ...], + ) -> ScopeTraceTransferStateSnapshot: ... + + def restore_trace_transfer_state( + self, + baseline: ScopeTraceTransferBaseline, + ) -> ScopeTraceTransferRestoreResult: ... + + def verify_trace_transfer_state_restored( + self, + baseline: ScopeTraceTransferBaseline, + ) -> ScopeTraceTransferStateSnapshot: ... + + +@runtime_checkable +class ScopeTraceMetadataDriver(InstrumentDriver, Protocol): + def get_trace_metadata(self, source: ScopeTraceRef) -> ScopeTraceMetadata: ... + + +@runtime_checkable +class ScopeTraceDriver( + ScopeTraceMetadataDriver, + ScopeTraceTransferRecoveryDriver, + Protocol, +): + def fetch_trace( + self, + source: ScopeTraceRef, + *, + points: str | int = "dmax", + baseline: ScopeTraceTransferBaseline | None, + ) -> ScopeTraceData: ... + + +@runtime_checkable +class ScopeErrorDrainDriver(InstrumentDriver, Protocol): + def drain_errors(self, *, max_records: int) -> ErrorDrainResult: ... + + +__all__ = [ + name + for name in globals() + if name.startswith("Scope") or name.startswith("Error") or name == "DriverErrorRecord" +] diff --git a/src/wavebench/scope_extension_constants.py b/src/wavebench/scope_extension_constants.py new file mode 100644 index 0000000..1f10ca1 --- /dev/null +++ b/src/wavebench/scope_extension_constants.py @@ -0,0 +1,17 @@ +"""Dependency-neutral constants for the public scope extension contract.""" + +SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES = 262_144 +SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES = 262_144 +SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT = 1 +SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES = 0 +SCOPE_TRACE_BINARY_RESPONSE_MAX_BYTES = 8_388_608 +SCOPE_TRACE_BINARY_OPERATION_MAX_BYTES = 67_108_864 +SCOPE_TRACE_BINARY_QUERY_MAX_COUNT = 256 +SCOPE_TRACE_BINARY_RESYNCHRONIZATION_MAX_BYTES = 65_536 +SCOPE_PROFILE_OPERATION_TIMEOUT_MS = 5_000 +SCOPE_SCREENSHOT_OPERATION_TIMEOUT_MS = 5_000 +SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS = 30_000 +SCOPE_TRACE_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_TRACE_MAX_POINTS = 8_388_608 + +__all__ = [name for name in globals() if name.startswith("SCOPE_")] diff --git a/src/wavebench/services/__init__.py b/src/wavebench/services/__init__.py index e69de29..1880db6 100644 --- a/src/wavebench/services/__init__.py +++ b/src/wavebench/services/__init__.py @@ -0,0 +1,27 @@ +"""Stable Service exports without eager dependency initialization.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .scope_extension_service import ( + ScopeExtensionOperationResult, + ScopeExtensionService, + ) + +__all__ = ["ScopeExtensionOperationResult", "ScopeExtensionService"] + + +def __getattr__(name: str): + if name in __all__: + from .scope_extension_service import ( + ScopeExtensionOperationResult, + ScopeExtensionService, + ) + + return { + "ScopeExtensionOperationResult": ScopeExtensionOperationResult, + "ScopeExtensionService": ScopeExtensionService, + }[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 6ac3a28..61fc984 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -8,11 +8,28 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from types import MappingProxyType -from typing import Literal, Mapping +from typing import TYPE_CHECKING, Literal, Mapping from wavebench.errors import ConfigError +from wavebench.scope_extension_constants import ( + SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS, + SCOPE_PROFILE_OPERATION_TIMEOUT_MS, + SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT, + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES, + SCOPE_SCREENSHOT_OPERATION_TIMEOUT_MS, + SCOPE_TRACE_BINARY_OPERATION_MAX_BYTES, + SCOPE_TRACE_BINARY_QUERY_MAX_COUNT, + SCOPE_TRACE_BINARY_RESPONSE_MAX_BYTES, + SCOPE_TRACE_BINARY_RESYNCHRONIZATION_MAX_BYTES, + SCOPE_TRACE_OPERATION_TIMEOUT_MS, +) + +if TYPE_CHECKING: + from wavebench.instruments.scope_extensions import ScopeEmbeddedScreenshotContract OperationEffect = Literal[ @@ -24,11 +41,13 @@ ] SessionPurpose = Literal["normal", "recovery", "verification", "lifecycle"] LeaseMode = Literal["none", "shared", "exclusive"] +ErrorCheckMinimum = Literal["required", "if_supported", "disabled"] _EFFECTS = frozenset({"offline", "observe", "stateful_read", "write", "acquire"}) _LEASE_MODES = frozenset({"none", "shared", "exclusive"}) _INSTRUMENT_KINDS = frozenset({"scope", "source", "power", "dmm", "sweep_analyzer"}) _SESSION_PURPOSES = frozenset({"normal", "recovery", "verification", "lifecycle"}) +_ERROR_CHECK_MINIMUMS = frozenset({"required", "if_supported", "disabled"}) @dataclass(frozen=True) @@ -46,7 +65,16 @@ class OperationSpec: session_purpose: SessionPurpose = "normal" required_verified_fields: tuple[str, ...] = () verification_fields: tuple[str, ...] = () + postcondition_fields: tuple[str, ...] = () + cleanup_verification_fields: tuple[str, ...] = () timeout_source: str = "connection.timeout_ms" + operation_timeout_ms: int | None = None + binary_response_max_bytes: int | None = None + binary_operation_max_bytes: int | None = None + binary_query_max_count: int | None = None + binary_resynchronization_max_bytes: int | None = None + error_check_minimum: ErrorCheckMinimum | None = None + embedded_screenshot_contract: "ScopeEmbeddedScreenshotContract | None" = None risk_flags: tuple[str, ...] = () safe_alternatives: tuple[str, ...] = () @@ -64,12 +92,29 @@ def __post_init__(self) -> None: raise ValueError(f"unsupported session purpose: {self.session_purpose!r}") if not self.timeout_source or self.timeout_source.strip() != self.timeout_source: raise ValueError("timeout_source must be non-empty and trimmed") + if self.operation_timeout_ms is not None and ( + isinstance(self.operation_timeout_ms, bool) + or not isinstance(self.operation_timeout_ms, int) + or self.operation_timeout_ms < 1 + ): + raise ValueError("operation_timeout_ms must be a positive integer") + if self.timeout_source == "operation.timeout_ms" and self.operation_timeout_ms is None: + raise ValueError("operation.timeout_ms requires an explicit operation_timeout_ms") + if self.operation_timeout_ms is not None and self.timeout_source != "operation.timeout_ms": + raise ValueError("explicit operation timeout must use timeout_source='operation.timeout_ms'") + if ( + self.error_check_minimum is not None + and self.error_check_minimum not in _ERROR_CHECK_MINIMUMS + ): + raise ValueError(f"unsupported error check minimum: {self.error_check_minimum!r}") for name, values in ( ("required_capabilities", self.required_capabilities), ("optional_capabilities", self.optional_capabilities), ("changed_fields", self.changed_fields), ("required_verified_fields", self.required_verified_fields), ("verification_fields", self.verification_fields), + ("postcondition_fields", self.postcondition_fields), + ("cleanup_verification_fields", self.cleanup_verification_fields), ("risk_flags", self.risk_flags), ("safe_alternatives", self.safe_alternatives), ): @@ -82,6 +127,54 @@ def __post_init__(self) -> None: raise ValueError( "required and optional capabilities overlap: " + ", ".join(sorted(overlap)) ) + binary_limits = ( + self.binary_response_max_bytes, + self.binary_operation_max_bytes, + self.binary_query_max_count, + self.binary_resynchronization_max_bytes, + ) + if any(value is not None for value in binary_limits): + if any(value is None for value in binary_limits): + raise ValueError("binary operations must define all four binary limits") + for label, value in ( + ("binary_response_max_bytes", self.binary_response_max_bytes), + ("binary_operation_max_bytes", self.binary_operation_max_bytes), + ("binary_query_max_count", self.binary_query_max_count), + ): + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{label} must be a positive integer") + if ( + isinstance(self.binary_resynchronization_max_bytes, bool) + or not isinstance(self.binary_resynchronization_max_bytes, int) + or self.binary_resynchronization_max_bytes < 0 + ): + raise ValueError( + "binary_resynchronization_max_bytes must be a non-negative integer" + ) + assert self.binary_response_max_bytes is not None + assert self.binary_operation_max_bytes is not None + if self.binary_operation_max_bytes < self.binary_response_max_bytes: + raise ValueError("binary operation limit must cover at least one response") + if self.embedded_screenshot_contract is not None: + from wavebench.instruments.scope_extensions import ScopeEmbeddedScreenshotContract + + contract = self.embedded_screenshot_contract + if not isinstance(contract, ScopeEmbeddedScreenshotContract): + raise TypeError("embedded_screenshot_contract has an invalid type") + if self.effect != "acquire": + raise ValueError("embedded screenshots are only valid for acquire operations") + if not set(contract.changed_fields + contract.output_fields) <= set(self.changed_fields): + raise ValueError("operation changed_fields do not cover embedded screenshot effects") + if not set(contract.verification_fields) <= set(self.verification_fields): + raise ValueError( + "operation verification_fields do not cover embedded screenshot state" + ) + if not set(contract.cleanup_verification_fields) <= set( + self.cleanup_verification_fields + ): + raise ValueError( + "operation cleanup fields do not cover embedded screenshot state" + ) @property def mutates(self) -> bool: @@ -102,7 +195,20 @@ def as_dict(self) -> dict[str, object]: "session_purpose": self.session_purpose, "required_verified_fields": list(self.required_verified_fields), "verification_fields": list(self.verification_fields), + "postcondition_fields": list(self.postcondition_fields), + "cleanup_verification_fields": list(self.cleanup_verification_fields), "timeout_source": self.timeout_source, + "operation_timeout_ms": self.operation_timeout_ms, + "binary_response_max_bytes": self.binary_response_max_bytes, + "binary_operation_max_bytes": self.binary_operation_max_bytes, + "binary_query_max_count": self.binary_query_max_count, + "binary_resynchronization_max_bytes": self.binary_resynchronization_max_bytes, + "error_check_minimum": self.error_check_minimum, + "embedded_screenshot_contract": ( + asdict(self.embedded_screenshot_contract) + if self.embedded_screenshot_contract is not None + else None + ), "risk_flags": list(self.risk_flags), "safe_alternatives": list(self.safe_alternatives), } @@ -149,7 +255,16 @@ def _spec( session_purpose: SessionPurpose = "normal", required_verified_fields: tuple[str, ...] = (), verification_fields: tuple[str, ...] = (), + postcondition_fields: tuple[str, ...] = (), + cleanup_verification_fields: tuple[str, ...] = (), timeout_source: str = "connection.timeout_ms", + operation_timeout_ms: int | None = None, + binary_response_max_bytes: int | None = None, + binary_operation_max_bytes: int | None = None, + binary_query_max_count: int | None = None, + binary_resynchronization_max_bytes: int | None = None, + error_check_minimum: ErrorCheckMinimum | None = None, + embedded_screenshot_contract: "ScopeEmbeddedScreenshotContract | None" = None, risk_flags: tuple[str, ...] = (), safe_alternatives: tuple[str, ...] = (), ) -> OperationSpec: @@ -165,7 +280,16 @@ def _spec( session_purpose=session_purpose, required_verified_fields=required_verified_fields, verification_fields=verification_fields, + postcondition_fields=postcondition_fields, + cleanup_verification_fields=cleanup_verification_fields, timeout_source=timeout_source, + operation_timeout_ms=operation_timeout_ms, + binary_response_max_bytes=binary_response_max_bytes, + binary_operation_max_bytes=binary_operation_max_bytes, + binary_query_max_count=binary_query_max_count, + binary_resynchronization_max_bytes=binary_resynchronization_max_bytes, + error_check_minimum=error_check_minimum, + embedded_screenshot_contract=embedded_screenshot_contract, risk_flags=risk_flags, safe_alternatives=safe_alternatives, ) @@ -217,7 +341,13 @@ def _spec( _spec("run.compare", None, effect="offline", lease_mode="none"), _spec("run.resume", None, effect="offline", lease_mode="none"), _spec("scope.idn", "scope", required_capabilities=("scope.idn",), effect="observe"), - _spec("scope.errors", "scope", required_capabilities=("scope.errors",), effect="stateful_read"), + _spec( + "scope.errors", + "scope", + required_capabilities=("scope.errors",), + effect="stateful_read", + changed_fields=("scope.error_queue",), + ), _spec( "scope.status", "scope", @@ -373,7 +503,192 @@ def _spec( _spec("dmm.set_dcv_impedance", "dmm", required_capabilities=("dmm.set_dcv_impedance",), effect="write", changed_fields=("dcv_impedance",), risk_flags=("state_drift",)), ) -OPERATION_REGISTRY = OperationRegistry({spec.operation: spec for spec in _BUILTIN_SPECS}) +_SCOPE_TRACE_TRANSFER_FIELDS = ( + "scope.run_state", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +) + + +def _scope_operation( + operation: str, + *, + required_capabilities: tuple[str, ...], + effect: OperationEffect, + timeout_ms: int, + changed_fields: tuple[str, ...] = (), + restore_coverage: str = "none", + verification_fields: tuple[str, ...] = (), + postcondition_fields: tuple[str, ...] = (), + cleanup_verification_fields: tuple[str, ...] = (), + risk_flags: tuple[str, ...] = (), + binary_limits: tuple[int, int, int, int] | None = None, + error_check_minimum: ErrorCheckMinimum | None = None, +) -> OperationSpec: + binary = binary_limits or (None, None, None, None) + return _spec( + operation, + "scope", + required_capabilities=required_capabilities, + optional_capabilities=( + ("scope.error_drain_v1",) if error_check_minimum is not None else () + ), + effect=effect, + lease_mode="exclusive", + changed_fields=changed_fields, + restore_coverage=restore_coverage, + required_verified_fields=("scope.identity",), + verification_fields=verification_fields, + postcondition_fields=postcondition_fields, + cleanup_verification_fields=cleanup_verification_fields, + timeout_source="operation.timeout_ms", + operation_timeout_ms=timeout_ms, + binary_response_max_bytes=binary[0], + binary_operation_max_bytes=binary[1], + binary_query_max_count=binary[2], + binary_resynchronization_max_bytes=binary[3], + error_check_minimum=error_check_minimum, + risk_flags=risk_flags, + ) + + +_SCOPE_EXTENSION_SPECS = ( + _scope_operation( + "scope.screenshot_profile", + required_capabilities=("scope.screenshot_profile",), + effect="stateful_read", + timeout_ms=SCOPE_PROFILE_OPERATION_TIMEOUT_MS, + risk_flags=("profile_query",), + ), + _scope_operation( + "scope.screenshot_v2", + required_capabilities=("scope.screenshot_v2",), + effect="write", + timeout_ms=SCOPE_SCREENSHOT_OPERATION_TIMEOUT_MS, + changed_fields=( + "scope.display_menu", + "scope.display_color", + "scope.error_queue", + "output.screenshot", + ), + restore_coverage="screenshot-baseline-only", + verification_fields=("scope.display_menu", "scope.display_color"), + cleanup_verification_fields=("scope.display_menu", "scope.display_color"), + risk_flags=("front_panel_state", "binary_response", "temporary_display_setup"), + binary_limits=( + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT, + SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES, + ), + error_check_minimum="disabled", + ), + _scope_operation( + "scope.acquisition_run_state", + required_capabilities=("scope.acquisition_run_state",), + effect="stateful_read", + timeout_ms=SCOPE_PROFILE_OPERATION_TIMEOUT_MS, + risk_flags=("state_observation",), + ), + _scope_operation( + "scope.acquisition_start", + required_capabilities=("scope.acquisition_control", "scope.acquisition_run_state"), + effect="write", + timeout_ms=SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS, + changed_fields=( + "scope.run_state", + "scope.trigger", + "scope.acquisition", + "scope.error_queue", + ), + restore_coverage="failure-cleanup-only", + verification_fields=("scope.trigger", "scope.acquisition"), + postcondition_fields=("scope.run_state", "scope.trigger", "scope.acquisition"), + cleanup_verification_fields=( + "scope.run_state", + "scope.trigger", + "scope.acquisition", + ), + risk_flags=("trigger", "acquisition_state", "recovery_required"), + error_check_minimum="disabled", + ), + _scope_operation( + "scope.acquisition_single", + required_capabilities=("scope.acquisition_control", "scope.acquisition_run_state"), + effect="acquire", + timeout_ms=SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS, + changed_fields=( + "scope.run_state", + "scope.trigger", + "scope.acquisition", + "scope.error_queue", + ), + restore_coverage="failure-cleanup-only", + verification_fields=("scope.trigger", "scope.acquisition"), + postcondition_fields=("scope.run_state", "scope.trigger", "scope.acquisition"), + cleanup_verification_fields=( + "scope.run_state", + "scope.trigger", + "scope.acquisition", + ), + risk_flags=("trigger", "acquisition_state", "recovery_required"), + error_check_minimum="disabled", + ), + _scope_operation( + "scope.acquisition_stop", + required_capabilities=("scope.acquisition_control", "scope.acquisition_run_state"), + effect="write", + timeout_ms=SCOPE_PROFILE_OPERATION_TIMEOUT_MS, + changed_fields=("scope.run_state", "scope.error_queue"), + restore_coverage="failure-cleanup-only", + postcondition_fields=("scope.run_state",), + cleanup_verification_fields=("scope.run_state",), + risk_flags=("acquisition_state", "recovery_required"), + error_check_minimum="disabled", + ), + _scope_operation( + "scope.trace_metadata", + required_capabilities=("scope.trace_metadata",), + effect="stateful_read", + timeout_ms=SCOPE_PROFILE_OPERATION_TIMEOUT_MS, + risk_flags=("analysis_state",), + error_check_minimum="disabled", + ), + _scope_operation( + "scope.fetch_trace", + required_capabilities=("scope.fetch_trace",), + effect="acquire", + timeout_ms=SCOPE_TRACE_OPERATION_TIMEOUT_MS, + changed_fields=(*_SCOPE_TRACE_TRANSFER_FIELDS, "scope.error_queue", "output.trace"), + restore_coverage="trace-baseline-only", + verification_fields=_SCOPE_TRACE_TRANSFER_FIELDS, + cleanup_verification_fields=_SCOPE_TRACE_TRANSFER_FIELDS, + risk_flags=("acquisition_state", "temporary_transfer_setup", "binary_response"), + binary_limits=( + SCOPE_TRACE_BINARY_RESPONSE_MAX_BYTES, + SCOPE_TRACE_BINARY_OPERATION_MAX_BYTES, + SCOPE_TRACE_BINARY_QUERY_MAX_COUNT, + SCOPE_TRACE_BINARY_RESYNCHRONIZATION_MAX_BYTES, + ), + error_check_minimum="disabled", + ), +) + +SCOPE_OPERATION_SPECS: Mapping[str, OperationSpec] = MappingProxyType( + {spec.operation: spec for spec in _SCOPE_EXTENSION_SPECS} +) + +OPERATION_REGISTRY = OperationRegistry( + { + **{spec.operation: spec for spec in _BUILTIN_SPECS}, + **SCOPE_OPERATION_SPECS, + } +) def get_operation_spec(operation: str) -> OperationSpec | None: diff --git a/src/wavebench/services/scope_error_policy.py b/src/wavebench/services/scope_error_policy.py new file mode 100644 index 0000000..8461d9b --- /dev/null +++ b/src/wavebench/services/scope_error_policy.py @@ -0,0 +1,287 @@ +"""Core-owned error policy executor for the scope R1.3 contract.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +import re +from typing import Iterable + +from wavebench.errors import ConfigError, InstrumentError +from wavebench.instruments.scope_extensions import ( + DriverErrorRecord, + ErrorCheckSpec, + ErrorDrainResult, + ErrorRecord, +) + +from .operation_specs import OperationSpec +from .scope_phase_coordinator import ( + OperationPhase, + ScopeOperationContextCoordinator, +) + + +_POLICY_STRENGTH = {"disabled": 0, "if_supported": 1, "required": 2} +_CONTROL = re.compile(r"[\x00-\x1f\x7f]") +_PRIVATE_PATTERN = re.compile( + r"(?:\b(?:\d{1,3}\.){3}\d{1,3}\b|TCPIP\S*|USB\S*|COM\d+|[A-Za-z]:\\\S+|/\S+)", + re.IGNORECASE, +) + + +def resolve_error_check( + operation_spec: OperationSpec, + override: ErrorCheckSpec | None, + *, + instrument_default: ErrorCheckSpec | None = None, +) -> ErrorCheckSpec | None: + """Resolve one operation policy without allowing a weaker override.""" + + minimum = operation_spec.error_check_minimum + if minimum is None: + if override is not None: + raise ConfigError( + f"operation {operation_spec.operation!r} does not accept error-check overrides" + ) + return None + selected = override or instrument_default or ErrorCheckSpec(policy=minimum) + if _POLICY_STRENGTH[selected.policy] < _POLICY_STRENGTH[minimum]: + raise ConfigError( + f"operation {operation_spec.operation!r} requires error policy {minimum!r} or stronger" + ) + if operation_spec.effect in {"write", "acquire"} and ( + selected.on_instrument_error != "fail" + ): + raise ConfigError("write/acquire operations cannot record instrument errors and continue") + return selected + + +def _scrub_record( + record: DriverErrorRecord, + *, + correlation_id: str, +) -> ErrorRecord: + original = record.message + cleaned = _CONTROL.sub(" ", original) + cleaned = _PRIVATE_PATTERN.sub("[redacted]", cleaned) + cleaned = " ".join(cleaned.split()) + redacted = cleaned != original + if not cleaned or len(cleaned) > 512 or not cleaned.isprintable(): + cleaned = "instrument reported an error" + redacted = True + observed = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + return ErrorRecord( + code=record.code, + message=cleaned, + message_redacted=redacted, + severity=record.severity, + source=record.source, + observed_at_utc=observed, + correlation_id=correlation_id, + ) + + +@dataclass(slots=True) +class ScopeErrorPolicyExecutor: + driver: object + capabilities: frozenset[str] + operation_spec: OperationSpec + error_spec: ErrorCheckSpec | None + correlation_id: str + artifact: dict[str, object] = field(init=False) + + def __post_init__(self) -> None: + supported = "scope.error_drain_v1" in self.capabilities + if self.error_spec is None: + self.artifact = { + "executor": None, + "status": "disabled", + "reason_code": "not_applicable", + "checks": [], + "attempted_phases": [], + "completed_phases": [], + "omitted_phases": [], + "last_drain_terminated": None, + "main_operation_sent": False, + } + return + policy = self.error_spec.policy + if policy == "disabled": + self.artifact = self._base_artifact(supported=None) + self.artifact.update(status="disabled", reason_code="not_applicable") + return + if not supported: + if policy == "required": + raise ConfigError("scope.error_drain_v1 is required but unsupported") + self.artifact = self._base_artifact(supported=False) + self.artifact.update(status="skipped", reason_code="unsupported") + return + if not callable(getattr(self.driver, "drain_errors", None)): + raise ConfigError("scope.error_drain_v1 requires callable drain_errors()") + self.artifact = self._base_artifact(supported=True) + self.artifact.update(status="completed", reason_code="empty") + + def _base_artifact(self, *, supported: bool | None) -> dict[str, object]: + assert self.error_spec is not None + return { + "executor": "core_v1", + "policy": self.error_spec.policy, + "capability": "scope.error_drain_v1", + "supported": supported, + "status": "completed", + "reason_code": "empty", + "timing": self.error_spec.timing, + "max_records": self.error_spec.max_records, + "on_instrument_error": self.error_spec.on_instrument_error, + "checks": [], + "attempted_phases": [], + "completed_phases": [], + "omitted_phases": [], + "last_drain_terminated": None, + "main_operation_sent": False, + "diagnostic_evidence_id": None, + } + + @property + def enabled(self) -> bool: + return bool( + self.error_spec is not None + and self.error_spec.policy != "disabled" + and self.artifact.get("supported") is True + ) + + def wants(self, phase: str) -> bool: + if not self.enabled or self.error_spec is None: + return False + return self.error_spec.timing in {phase, "before_and_after"} + + def mark_main_sent(self) -> None: + self.artifact["main_operation_sent"] = True + + def omit_after(self, reason_code: str) -> None: + if not self.wants("after"): + return + if reason_code not in {"main_operation_failed", "session_unhealthy", "cancelled"}: + raise ValueError("unsupported omitted error phase reason") + omitted = self.artifact["omitted_phases"] + assert isinstance(omitted, list) + omitted.append({"phase": "after", "reason_code": reason_code}) + + def run( + self, + context: ScopeOperationContextCoordinator, + *, + phase: str, + ) -> tuple[ErrorRecord, ...]: + if phase not in {"before", "after"}: + raise ValueError("error phase must be before or after") + if not self.wants(phase): + return () + assert self.error_spec is not None + operation_phase = ( + OperationPhase.ERROR_BEFORE if phase == "before" else OperationPhase.ERROR_AFTER + ) + phase_spec = context.make_phase_spec( + operation_phase, + allowed_io={"query"}, + fields={"scope.error_queue"}, + max_steps=self.error_spec.max_records + 1, + ) + attempted = self.artifact["attempted_phases"] + assert isinstance(attempted, list) + attempted.append(phase) + try: + with context.authorize_phase(phase_spec) as authorization: + result = self.driver.drain_errors( + max_records=self.error_spec.max_records + ) + if not isinstance(result, ErrorDrainResult): + raise TypeError("drain_errors() returned an invalid result") + result.validate_for(max_records=self.error_spec.max_records) + actual_queries = authorization._session_authorization._record.successful_io.get( + "query", 0 + ) + if actual_queries != result.query_count: + raise ValueError("error drain query_count does not match guarded transport evidence") + except Exception: + self.artifact.update(status="failed", reason_code="query_failed") + self.artifact["last_drain_terminated"] = None + raise + + records = tuple( + _scrub_record(item, correlation_id=self.correlation_id) + for item in result.records + ) + overflow = ( + _scrub_record(result.overflow_record, correlation_id=self.correlation_id) + if result.overflow_record is not None + else None + ) + check = { + "phase": phase, + "status": "completed" if result.terminated else "failed", + "reason_code": ( + "empty" + if result.terminated and not records + else "records" + if result.terminated + else "error_queue_incomplete" + ), + "query_count": result.query_count, + "terminated": result.terminated, + "records": list(records), + "overflow_record": overflow, + } + checks = self.artifact["checks"] + assert isinstance(checks, list) + checks.append(check) + self.artifact["last_drain_terminated"] = result.terminated + if not result.terminated: + self.artifact.update(status="failed", reason_code="error_queue_incomplete") + raise InstrumentError("scope error queue did not terminate within the configured bound") + completed = self.artifact["completed_phases"] + assert isinstance(completed, list) + completed.append(phase) + if records: + self.artifact.update( + status="completed", + reason_code="records", + ) + can_continue = ( + self.operation_spec.effect in {"observe", "stateful_read"} + and self.error_spec.on_instrument_error == "record_and_continue" + ) + if not can_continue: + reason = ( + "preexisting_instrument_error" if phase == "before" else "instrument_error" + ) + self.artifact.update(status="failed", reason_code=reason) + raise InstrumentError( + "instrument error records prevent the scope operation" + ) + elif self.artifact.get("reason_code") != "records": + self.artifact.update(status="completed", reason_code="empty") + return records + + +def legacy_scope_error_artifact(values: Iterable[str], *, requested_limit: int) -> dict[str, object]: + """Describe the old list[str] API without inventing typed drain evidence.""" + + returned = tuple(values) + return { + "executor": "legacy_driver", + "capability": "scope.errors", + "status": "legacy_unstructured", + "requested_limit": requested_limit, + "returned_record_count": len(returned), + "terminated": None, + "query_count": None, + } + + +__all__ = [ + "ScopeErrorPolicyExecutor", + "legacy_scope_error_artifact", + "resolve_error_check", +] diff --git a/src/wavebench/services/scope_extension_service.py b/src/wavebench/services/scope_extension_service.py new file mode 100644 index 0000000..683b029 --- /dev/null +++ b/src/wavebench/services/scope_extension_service.py @@ -0,0 +1,1396 @@ +"""Service orchestration for the scope R1.3 extension contract.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, is_dataclass +from hashlib import sha256 +from types import MappingProxyType +from typing import Any, Callable, Mapping, TypeVar + +from wavebench.errors import ConfigError, DataError, InstrumentError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.scope_extension_capabilities import ( + validate_scope_descriptor, + validate_experimental_scope_descriptor, +) +from wavebench.instruments.scope_extensions import ( + ErrorCheckSpec, + ScopeAcquisitionCompletion, + ScopeAcquisitionControlBaseline, + ScopeAcquisitionControlProfile, + ScopeAcquisitionControlSnapshot, + ScopeAcquisitionRunState, + ScopeBaselineRestoreResult, + ScopeBaselineVerification, + ScopeContinuousAcquisitionRequest, + ScopeScreenshot, + ScopeScreenshotBaseline, + ScopeScreenshotProfile, + ScopeScreenshotRequest, + ScopeScreenshotRestoreResult, + ScopeScreenshotStateSnapshot, + ScopeScreenshotVariant, + ScopeScreenshotVerification, + ScopeTraceData, + ScopeTraceMetadata, + ScopeTraceProfile, + ScopeTraceRef, + ScopeTraceTransferBaseline, + ScopeTraceTransferRestoreResult, + ScopeTraceTransferStateSnapshot, + ScopeTraceTransferVerification, + validate_acquisition_completion, +) +from wavebench.services.access_policy import AccessMode, access_policy +from wavebench.transport.session import InstrumentSessionState, SessionHealth + +from .operation_specs import OperationSpec, require_operation_spec +from .scope_error_policy import ScopeErrorPolicyExecutor, resolve_error_check +from .scope_extension_specs import EXPERIMENTAL_SCOPE_OPERATION_SPECS +from .scope_phase_coordinator import ( + OperationPhase, + ScopeBaselineHandle, + ScopeBinaryLimits, + ScopeOperationContextCoordinator, +) + + +_T = TypeVar("_T") +_TEXT_READ_IO = {"query", "query_float_list", "query_opc"} +_MAIN_TEXT_IO = {*_TEXT_READ_IO, "write", "write_bytes"} +_TRACE_MAIN_IO = {*_MAIN_TEXT_IO, "query_binary"} +_SCREENSHOT_MAIN_IO = {*_MAIN_TEXT_IO, "query_binary"} +SCOPE_EXTENSION_RESULT_SCHEMA = "wavebench.scope.result.v1" + + +def _json_safe(value: Any) -> Any: + if is_dataclass(value): + return _json_safe(asdict(value)) + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_json_safe(item) for item in value] + if isinstance(value, (str, int, float, bool, type(None))): + return value + return str(value) + + +@dataclass(frozen=True, slots=True) +class ScopeExtensionOperationResult: + value: object + diagnostics: Mapping[str, object] + observed_state: Mapping[str, object] | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "diagnostics", MappingProxyType(dict(self.diagnostics))) + if self.observed_state is not None: + object.__setattr__( + self, + "observed_state", + MappingProxyType(dict(self.observed_state)), + ) + + def as_dict(self) -> dict[str, object]: + return { + "schema": SCOPE_EXTENSION_RESULT_SCHEMA, + "result": _public_result_summary(self.value), + "diagnostics": _json_safe(self.diagnostics), + "observed_state": _json_safe(self.observed_state), + } + + +def _public_result_summary(value: object) -> object: + if isinstance(value, ScopeScreenshot): + return { + "media_type": value.media_type, + "dimensions": {"width_px": value.width_px, "height_px": value.height_px}, + "requested": _json_safe(value.requested), + "effective_request": _json_safe(value.effective), + "framing": value.framing.value, + "payload_bytes": len(value.data), + "payload_sha256": sha256(value.data).hexdigest(), + } + if isinstance(value, ScopeTraceData): + raw = value.values.tobytes(order="C") + return { + "metadata": _json_safe(value.metadata), + "integrity": { + "points": int(value.values.size), + "dtype": str(value.values.dtype), + "payload_bytes": len(raw), + "payload_sha256": sha256(raw).hexdigest(), + }, + } + return _json_safe(value) + + +@dataclass(slots=True) +class ExperimentalScopeExtensionService: + """Internal R1.3 Service; construction itself requires an explicit feature gate.""" + + driver: object + descriptor: InstrumentDescriptor + session_state: InstrumentSessionState + connection_timeout_ms: int + access: AccessMode = "read_write" + instrument_error_default: ErrorCheckSpec | None = None + enabled: bool = False + + def __post_init__(self) -> None: + if not self.enabled: + raise ConfigError("experimental scope extensions are disabled") + if isinstance(self.connection_timeout_ms, bool) or not isinstance( + self.connection_timeout_ms, int + ) or self.connection_timeout_ms < 1: + raise ValueError("connection_timeout_ms must be a positive integer") + if "scope.idn" not in self.descriptor.capabilities: + raise ConfigError("experimental scope operations require scope.idn") + if not callable(getattr(self.driver, "idn", None)): + raise ConfigError("experimental scope operations require callable idn()") + validate_experimental_scope_descriptor( + self.descriptor, + driver=self.driver, + enabled=True, + ) + + @property + def capabilities(self) -> frozenset[str]: + return frozenset(self.descriptor.capabilities) + + def screenshot_profile( + self, + *, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + spec = self._require("scope.screenshot_profile") + static = self._screenshot_profile() + context = self._context(spec, deadline=deadline) + try: + self._identity_preflight(context) + phase = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io=_TEXT_READ_IO, + fields={"scope.identity"}, + max_steps=2, + ) + with context.authorize_phase(phase): + runtime = self.driver.get_screenshot_profile() + profile = self._validate_screenshot_profile_narrowing(static, runtime) + context.complete() + return self._result(context, value=profile) + except BaseException as exc: + context.complete() + self._attach_diagnostics(exc, context, None) + raise + + def screenshot_v2( + self, + request: ScopeScreenshotRequest, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + if not isinstance(request, ScopeScreenshotRequest): + raise DataError("screenshot request has an invalid type") + spec = self._require("scope.screenshot_v2") + static_profile = self._screenshot_profile() + variant = static_profile.select(request) + profile_limits = ScopeBinaryLimits( + response_max_bytes=variant.response_max_bytes, + operation_max_bytes=variant.operation_max_bytes, + query_max_count=variant.query_max_count, + resynchronization_max_bytes=variant.resynchronization_max_bytes, + ) + context = self._context( + spec, + deadline=deadline, + profile_binary_limits=profile_limits, + transport_trailing=bytes.fromhex(variant.transport_trailing_hex), + ) + try: + error_executor = self._error_executor(spec, error_check, context) + except BaseException: + context.complete() + raise + baseline: ScopeScreenshotBaseline | None = None + handle: ScopeBaselineHandle | None = None + primary: BaseException | None = None + cleanup_error: BaseException | None = None + screenshot: ScopeScreenshot | None = None + main_entered = False + cleanup_diagnostics: dict[str, object] = {} + try: + fields = tuple(variant.changed_fields) + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity", *fields}, + max_steps=2 + variant.snapshot_max_steps, + ) + with context.authorize_phase(preflight) as authorization: + self._verify_identity() + runtime_profile = self.driver.get_screenshot_profile() + runtime_profile = self._validate_screenshot_profile_narrowing( + static_profile, + runtime_profile, + ) + runtime_variant = runtime_profile.select(request) + self._assert_same_screenshot_state_contract(variant, runtime_variant) + snapshot = None + if fields: + snapshot = self.driver.snapshot_screenshot_state(fields) + self._validate_screenshot_snapshot(snapshot, fields) + handle = context.create_baseline( + kind="screenshot", + fields=fields, + restore_order=variant.restore_order, + ) + baseline = ScopeScreenshotBaseline( + context_id=handle.context_id, + session_epoch=handle.session_epoch, + baseline_nonce=handle.baseline_nonce, + snapshot=snapshot, + restore_order=variant.restore_order, + ) + context.pass_baseline_to_main(handle) + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity", *fields}, + ) + + error_executor.run(context, phase="before") + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io=_SCREENSHOT_MAIN_IO, + fields=set(spec.changed_fields), + max_steps=64, + ) + try: + with context.authorize_phase(main): + main_entered = True + error_executor.mark_main_sent() + screenshot = self.driver.capture_screenshot( + request, + baseline=baseline, + ) + self._validate_screenshot_result(screenshot, variant) + error_executor.run(context, phase="after") + except BaseException as exc: + primary = exc + if error_executor.wants("after") and not context.has_phase( + OperationPhase.ERROR_AFTER + ): + error_executor.omit_after( + "session_unhealthy" + if self.session_state.health is not SessionHealth.HEALTHY + else "main_operation_failed" + ) + if handle is not None and main_entered: + context.mark_cleanup_required() + cleanup_error, cleanup_diagnostics = self._cleanup_screenshot( + context, + handle, + baseline, + variant, + failed=primary is not None, + ) + context.complete() + if primary is not None: + self._attach_diagnostics( + primary, + context, + error_executor, + cleanup_error=cleanup_error, + extra={"screenshot": cleanup_diagnostics}, + ) + raise primary + if cleanup_error is not None: + self._attach_diagnostics( + cleanup_error, + context, + error_executor, + extra={"screenshot": cleanup_diagnostics}, + ) + raise cleanup_error + assert screenshot is not None + return self._result( + context, + value=screenshot, + error_executor=error_executor, + extra={"screenshot": cleanup_diagnostics}, + ) + except BaseException as exc: + if not context.terminal: + context.complete() + if not hasattr(exc, "scope_operation_diagnostics"): + self._attach_diagnostics(exc, context, error_executor) + raise + + def acquisition_run_state( + self, + *, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + spec = self._require("scope.acquisition_run_state") + context = self._context(spec, deadline=deadline) + try: + self._identity_preflight(context) + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query"}, + fields={"scope.identity"}, + max_steps=1, + ) + with context.authorize_phase(main): + state = self.driver.get_acquisition_run_state() + if not isinstance(state, ScopeAcquisitionRunState): + raise DataError("get_acquisition_run_state() returned an invalid result") + context.complete() + return self._result( + context, + value=state, + observed_state={"run_state": asdict(state)}, + ) + except BaseException as exc: + context.complete() + self._attach_diagnostics(exc, context, None) + raise + + def start_acquisition( + self, + request: ScopeContinuousAcquisitionRequest, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + if not isinstance(request, ScopeContinuousAcquisitionRequest): + raise DataError("continuous acquisition request has an invalid type") + if request.trigger_mode not in self._acquisition_profile().supported_continuous_modes: + raise ConfigError("requested continuous trigger mode is unsupported") + return self._run_acquisition_change( + operation="scope.acquisition_start", + action=lambda baseline, _deadline: self.driver.start_continuous( + trigger_mode=request.trigger_mode, + baseline=baseline, + ), + validate=lambda value, baseline, profile: self._validate_start_result( + value, + request, + baseline, + profile, + ), + error_check=error_check, + deadline=deadline, + ) + + def acquire_single( + self, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + return self._run_acquisition_change( + operation="scope.acquisition_single", + action=lambda baseline, main_deadline: self.driver.acquire_single( + baseline=baseline, + deadline=main_deadline, + ), + validate=self._validate_single_result, + error_check=error_check, + deadline=deadline, + ) + + def stop_acquisition( + self, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + spec = self._require("scope.acquisition_stop") + context = self._context(spec, deadline=deadline) + try: + error_executor = self._error_executor(spec, error_check, context) + except BaseException: + context.complete() + raise + primary: BaseException | None = None + cleanup_error: BaseException | None = None + result: ScopeAcquisitionRunState | None = None + main_entered = False + try: + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity", "scope.run_state"}, + max_steps=2, + ) + with context.authorize_phase(preflight) as authorization: + self._verify_identity() + before = self.driver.get_acquisition_run_state() + if not isinstance(before, ScopeAcquisitionRunState): + raise DataError("get_acquisition_run_state() returned an invalid result") + if before.phase in {"unknown", "error"}: + raise ConfigError("normal acquisition stop requires a known non-error phase") + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity", "scope.run_state"}, + ) + error_executor.run(context, phase="before") + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io=_MAIN_TEXT_IO, + fields={"scope.run_state", "scope.error_queue"}, + max_steps=8, + ) + try: + with context.authorize_phase(main): + main_entered = True + error_executor.mark_main_sent() + result = self.driver.stop_acquisition() + if not isinstance(result, ScopeAcquisitionRunState) or result.phase != "stopped": + raise DataError("acquisition stop postcondition is not proven") + error_executor.run(context, phase="after") + except BaseException as exc: + primary = exc + if error_executor.wants("after") and not context.has_phase( + OperationPhase.ERROR_AFTER + ): + error_executor.omit_after( + "session_unhealthy" + if self.session_state.health is not SessionHealth.HEALTHY + else "main_operation_failed" + ) + if primary is not None and main_entered: + context.mark_cleanup_required() + cleanup_error = self._cleanup_stop(context) + context.complete() + if primary is not None: + self._attach_diagnostics( + primary, + context, + error_executor, + cleanup_error=cleanup_error, + ) + raise primary + if cleanup_error is not None: + raise cleanup_error + assert result is not None + return self._result( + context, + value=result, + error_executor=error_executor, + observed_state={"run_state": asdict(result)}, + ) + except BaseException as exc: + if not context.terminal: + context.complete() + if not hasattr(exc, "scope_operation_diagnostics"): + self._attach_diagnostics(exc, context, error_executor) + raise + + def trace_metadata( + self, + source: ScopeTraceRef, + *, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + if not isinstance(source, ScopeTraceRef): + raise DataError("trace source has an invalid type") + spec = self._require("scope.trace_metadata") + profile = self._trace_profile() + self._validate_trace_source(source, profile, require_fetchable=False) + context = self._context(spec, deadline=deadline) + try: + self._identity_preflight(context) + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query"}, + fields={"scope.identity"}, + max_steps=16, + ) + with context.authorize_phase(main): + metadata = self.driver.get_trace_metadata(source) + if not isinstance(metadata, ScopeTraceMetadata) or metadata.source != source: + raise DataError("get_trace_metadata() returned an inconsistent result") + context.complete() + return self._result(context, value=metadata) + except BaseException as exc: + context.complete() + self._attach_diagnostics(exc, context, None) + raise + + def fetch_trace( + self, + source: ScopeTraceRef, + *, + points: str | int = "dmax", + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + if not isinstance(source, ScopeTraceRef): + raise DataError("trace source has an invalid type") + spec = self._require("scope.fetch_trace") + profile = self._trace_profile() + self._validate_trace_source(source, profile, require_fetchable=True) + self._validate_trace_points(points, profile) + if set(profile.restore_order) != set(spec.verification_fields): + raise ConfigError( + "trace profile must close every transfer verification field before registration" + ) + context = self._context(spec, deadline=deadline) + try: + error_executor = self._error_executor(spec, error_check, context) + except BaseException: + context.complete() + raise + handle: ScopeBaselineHandle | None = None + baseline: ScopeTraceTransferBaseline | None = None + trace: ScopeTraceData | None = None + primary: BaseException | None = None + cleanup_error: BaseException | None = None + cleanup_diagnostics: dict[str, object] = {} + main_entered = False + try: + fields = tuple(profile.restore_order) + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity", *fields}, + max_steps=2 + profile.snapshot_max_steps, + ) + with context.authorize_phase(preflight) as authorization: + self._verify_identity() + metadata = self.driver.get_trace_metadata(source) + if ( + not isinstance(metadata, ScopeTraceMetadata) + or metadata.source != source + or not metadata.fetchable + ): + raise DataError("trace metadata does not prove a fetchable requested source") + snapshot = self.driver.snapshot_trace_transfer_state(fields) + self._validate_trace_snapshot(snapshot, fields) + handle = context.create_baseline( + kind="trace_transfer", + fields=fields, + restore_order=fields, + ) + baseline = ScopeTraceTransferBaseline( + context_id=handle.context_id, + session_epoch=handle.session_epoch, + baseline_nonce=handle.baseline_nonce, + snapshot=snapshot, + restore_order=fields, + ) + context.pass_baseline_to_main(handle) + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity", *fields}, + ) + error_executor.run(context, phase="before") + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io=_TRACE_MAIN_IO, + fields=set(spec.changed_fields), + max_steps=512, + ) + try: + with context.authorize_phase(main): + main_entered = True + error_executor.mark_main_sent() + trace = self.driver.fetch_trace( + source, + points=points, + baseline=baseline, + ) + self._validate_trace_result(trace, source, profile, points) + error_executor.run(context, phase="after") + except BaseException as exc: + primary = exc + if error_executor.wants("after") and not context.has_phase( + OperationPhase.ERROR_AFTER + ): + error_executor.omit_after( + "session_unhealthy" + if self.session_state.health is not SessionHealth.HEALTHY + else "main_operation_failed" + ) + if handle is not None and main_entered: + context.mark_cleanup_required() + cleanup_error, cleanup_diagnostics = self._cleanup_trace( + context, + handle, + baseline, + profile, + failed=primary is not None, + ) + context.complete() + if primary is not None: + self._attach_diagnostics( + primary, + context, + error_executor, + cleanup_error=cleanup_error, + extra={"trace_cleanup": cleanup_diagnostics}, + ) + raise primary + if cleanup_error is not None: + self._attach_diagnostics( + cleanup_error, + context, + error_executor, + extra={"trace_cleanup": cleanup_diagnostics}, + ) + raise cleanup_error + assert trace is not None + return self._result( + context, + value=trace, + error_executor=error_executor, + extra={"trace_cleanup": cleanup_diagnostics}, + ) + except BaseException as exc: + if not context.terminal: + context.complete() + if not hasattr(exc, "scope_operation_diagnostics"): + self._attach_diagnostics(exc, context, error_executor) + raise + + def _run_acquisition_change( + self, + *, + operation: str, + action: Callable[[ScopeAcquisitionControlBaseline, float], _T], + validate: Callable[ + [_T, ScopeAcquisitionControlBaseline, ScopeAcquisitionControlProfile], + None, + ], + error_check: ErrorCheckSpec | None, + deadline: float | None, + ) -> ScopeExtensionOperationResult: + spec = self._require(operation) + profile = self._acquisition_profile() + context = self._context(spec, deadline=deadline) + try: + error_executor = self._error_executor(spec, error_check, context) + except BaseException: + context.complete() + raise + handle: ScopeBaselineHandle | None = None + baseline: ScopeAcquisitionControlBaseline | None = None + value: _T | None = None + primary: BaseException | None = None + cleanup_error: BaseException | None = None + cleanup_diagnostics: dict[str, object] = {} + main_entered = False + try: + fields = ("scope.run_state", "scope.trigger", "scope.acquisition") + restore_order = ("scope.run_state", *profile.failure_restore_order) + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity", *fields}, + max_steps=1 + profile.snapshot_max_steps, + ) + with context.authorize_phase(preflight) as authorization: + self._verify_identity() + snapshot = self.driver.snapshot_acquisition_control() + self._validate_acquisition_snapshot(snapshot) + if snapshot.run_state.phase not in {"stopped", "ready", "complete"}: + raise ConfigError("acquisition control precondition phase is not allowed") + handle = context.create_baseline( + kind="acquisition_control", + fields=fields, + restore_order=restore_order, + ) + baseline = ScopeAcquisitionControlBaseline( + context_id=handle.context_id, + session_epoch=handle.session_epoch, + baseline_nonce=handle.baseline_nonce, + snapshot=snapshot, + restore_order=restore_order, + ) + context.pass_baseline_to_main(handle) + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity", *fields}, + ) + error_executor.run(context, phase="before") + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io=_MAIN_TEXT_IO, + fields=set(spec.changed_fields), + max_steps=128, + ) + try: + with context.authorize_phase(main): + main_entered = True + error_executor.mark_main_sent() + assert baseline is not None + value = action(baseline, context.main_deadline) + validate(value, baseline, profile) + error_executor.run(context, phase="after") + except BaseException as exc: + primary = exc + if error_executor.wants("after") and not context.has_phase( + OperationPhase.ERROR_AFTER + ): + error_executor.omit_after( + "session_unhealthy" + if self.session_state.health is not SessionHealth.HEALTHY + else "main_operation_failed" + ) + if primary is None: + assert handle is not None + context.consume_baseline_after_success(handle) + elif handle is not None and main_entered: + context.mark_cleanup_required() + cleanup_error, cleanup_diagnostics = self._cleanup_acquisition( + context, + handle, + baseline, + profile, + ) + context.complete() + if primary is not None: + self._attach_diagnostics( + primary, + context, + error_executor, + cleanup_error=cleanup_error, + extra={"cleanup": cleanup_diagnostics}, + ) + raise primary + assert value is not None + return self._result( + context, + value=value, + error_executor=error_executor, + extra={"postcondition": {"status": "verified"}}, + ) + except BaseException as exc: + if not context.terminal: + context.complete() + if not hasattr(exc, "scope_operation_diagnostics"): + self._attach_diagnostics(exc, context, error_executor) + raise + + def _cleanup_screenshot( + self, + context: ScopeOperationContextCoordinator, + handle: ScopeBaselineHandle, + baseline: ScopeScreenshotBaseline | None, + variant: ScopeScreenshotVariant, + *, + failed: bool, + ) -> tuple[BaseException | None, dict[str, object]]: + assert baseline is not None + restore_result: ScopeScreenshotRestoreResult | None = None + verification: ScopeScreenshotVerification | None = None + error: BaseException | None = None + phase = OperationPhase.FAILURE_CLEANUP if failed else OperationPhase.SUCCESS_RESTORE + try: + restore = context.make_phase_spec( + phase, + allowed_io={"write"}, + fields=handle.fields, + max_steps=variant.restore_max_steps, + ) + with context.authorize_phase(restore): + context.begin_restore(handle) + try: + restore_result = self.driver.restore_screenshot_state(baseline) + if not isinstance(restore_result, ScopeScreenshotRestoreResult): + raise TypeError("restore_screenshot_state() returned an invalid result") + restore_result.validate_for(baseline) + succeeded = restore_result.status == "completed" + except BaseException: + context.finish_restore(handle, succeeded=False) + raise + context.finish_restore(handle, succeeded=succeeded) + if not succeeded: + raise InstrumentError("screenshot state restore did not complete") + except BaseException as exc: + error = exc + + if self.session_state.health is not SessionHealth.POISONED: + try: + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=handle.fields, + max_steps=variant.verify_max_steps, + ) + with context.authorize_phase(verify) as authorization: + context.begin_verification(handle) + observed = self.driver.verify_screenshot_state_restored( + tuple(handle.fields), + baseline, + ) + self._validate_screenshot_snapshot(observed, tuple(handle.fields)) + matched = observed == baseline.snapshot + verification = ScopeScreenshotVerification( + status="verified" if matched else "mismatch", + verified_fields=tuple(handle.fields) if matched else (), + mismatched_fields=() if matched else tuple(handle.fields), + ) + context.finish_verification( + handle, + authorization, + io_kind="query", + verified_fields=tuple(handle.fields), + matched=matched, + ) + except BaseException as exc: + error = error or exc + return error, { + "restore": _json_safe(restore_result), + "verification": _json_safe(verification), + } + + def _cleanup_trace( + self, + context: ScopeOperationContextCoordinator, + handle: ScopeBaselineHandle, + baseline: ScopeTraceTransferBaseline | None, + profile: ScopeTraceProfile, + *, + failed: bool, + ) -> tuple[BaseException | None, dict[str, object]]: + assert baseline is not None + restore_result: ScopeTraceTransferRestoreResult | None = None + verification: ScopeTraceTransferVerification | None = None + error: BaseException | None = None + phase = OperationPhase.FAILURE_CLEANUP if failed else OperationPhase.SUCCESS_RESTORE + try: + restore = context.make_phase_spec( + phase, + allowed_io={"write"}, + fields=handle.fields, + max_steps=profile.restore_max_steps, + ) + with context.authorize_phase(restore): + context.begin_restore(handle) + try: + restore_result = self.driver.restore_trace_transfer_state(baseline) + if not isinstance(restore_result, ScopeTraceTransferRestoreResult): + raise TypeError("restore_trace_transfer_state() returned an invalid result") + restore_result.validate_for(baseline) + succeeded = restore_result.status == "completed" + except BaseException: + context.finish_restore(handle, succeeded=False) + raise + context.finish_restore(handle, succeeded=succeeded) + if not succeeded: + raise InstrumentError("trace transfer restore did not complete") + except BaseException as exc: + error = exc + + if self.session_state.health is not SessionHealth.POISONED: + try: + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=handle.fields, + max_steps=profile.verify_max_steps, + ) + with context.authorize_phase(verify) as authorization: + context.begin_verification(handle) + observed = self.driver.verify_trace_transfer_state_restored(baseline) + self._validate_trace_snapshot(observed, tuple(handle.fields)) + matched = observed == baseline.snapshot + verification = ScopeTraceTransferVerification( + status="verified" if matched else "mismatch", + verified_fields=tuple(handle.fields) if matched else (), + mismatched_fields=() if matched else tuple(handle.fields), + ) + context.finish_verification( + handle, + authorization, + io_kind="query", + verified_fields=tuple(handle.fields), + matched=matched, + ) + except BaseException as exc: + error = error or exc + return error, { + "restore": _json_safe(restore_result), + "verification": _json_safe(verification), + } + + def _cleanup_acquisition( + self, + context: ScopeOperationContextCoordinator, + handle: ScopeBaselineHandle, + baseline: ScopeAcquisitionControlBaseline | None, + profile: ScopeAcquisitionControlProfile, + ) -> tuple[BaseException | None, dict[str, object]]: + assert baseline is not None + restore_result: ScopeBaselineRestoreResult | None = None + verification: ScopeBaselineVerification | None = None + error: BaseException | None = None + try: + restore = context.make_phase_spec( + OperationPhase.FAILURE_CLEANUP, + allowed_io={"write"}, + fields=handle.fields, + max_steps=profile.restore_max_steps, + ) + with context.authorize_phase(restore): + context.begin_restore(handle) + try: + restore_result = self.driver.restore_acquisition_control(baseline) + if not isinstance(restore_result, ScopeBaselineRestoreResult): + raise TypeError("restore_acquisition_control() returned an invalid result") + restore_result.validate_for(baseline) + succeeded = restore_result.status == "completed" + except BaseException: + context.finish_restore(handle, succeeded=False) + raise + context.finish_restore(handle, succeeded=succeeded) + if not succeeded: + raise InstrumentError("acquisition control restore did not complete") + except BaseException as exc: + error = exc + + if self.session_state.health is not SessionHealth.POISONED: + try: + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=handle.fields, + max_steps=profile.verify_max_steps, + ) + with context.authorize_phase(verify) as authorization: + context.begin_verification(handle) + observed = self.driver.verify_acquisition_control_restored(baseline) + self._validate_acquisition_snapshot(observed) + matched = ( + observed.run_state.phase == "stopped" + and observed.trigger_state_token + == baseline.snapshot.trigger_state_token + and observed.acquisition_state_token + == baseline.snapshot.acquisition_state_token + ) + mismatched = tuple( + field_name + for field_name, field_matches in ( + ("scope.run_state", observed.run_state.phase == "stopped"), + ( + "scope.trigger", + observed.trigger_state_token + == baseline.snapshot.trigger_state_token, + ), + ( + "scope.acquisition", + observed.acquisition_state_token + == baseline.snapshot.acquisition_state_token, + ), + ) + if not field_matches + ) + verification = ScopeBaselineVerification( + status="verified" if matched else "mismatch", + verified_fields=tuple(handle.fields) if matched else (), + mismatched_fields=mismatched, + ) + context.finish_verification( + handle, + authorization, + io_kind="query", + verified_fields=tuple(handle.fields), + matched=matched, + ) + except BaseException as exc: + error = error or exc + return error, { + "restore": _json_safe(restore_result), + "verification": _json_safe(verification), + } + + def _cleanup_stop( + self, + context: ScopeOperationContextCoordinator, + ) -> BaseException | None: + if self.session_state.health is SessionHealth.POISONED: + return InstrumentError("poisoned session cannot run acquisition STOP cleanup") + try: + restore = context.make_phase_spec( + OperationPhase.FAILURE_CLEANUP, + allowed_io=_MAIN_TEXT_IO, + fields={"scope.run_state"}, + max_steps=2, + ) + with context.authorize_phase(restore): + stopped = self.driver.stop_acquisition() + if not isinstance(stopped, ScopeAcquisitionRunState) or stopped.phase != "stopped": + raise DataError("recovery STOP postcondition is not proven") + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields={"scope.run_state"}, + max_steps=1, + ) + with context.authorize_phase(verify) as authorization: + observed = self.driver.get_acquisition_run_state() + if not isinstance(observed, ScopeAcquisitionRunState) or observed.phase != "stopped": + raise DataError("recovery STOP verification is not proven") + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.run_state"}, + ) + return None + except BaseException as exc: + return exc + + def _identity_preflight(self, context: ScopeOperationContextCoordinator) -> None: + phase = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity"}, + max_steps=1, + ) + with context.authorize_phase(phase) as authorization: + self._verify_identity() + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity"}, + ) + + def _verify_identity(self) -> str: + identity = self.driver.idn() + if not isinstance(identity, str) or not identity.strip(): + raise DataError("scope identity verification returned an empty response") + return identity + + def _require(self, operation: str) -> OperationSpec: + spec = EXPERIMENTAL_SCOPE_OPERATION_SPECS.get(operation) + if spec is None: + raise ConfigError(f"unknown experimental scope operation: {operation!r}") + missing = sorted(set(spec.required_capabilities) - self.capabilities) + if missing: + raise ConfigError( + f"operation {operation!r} is missing capabilities: {', '.join(missing)}" + ) + access_policy(self.access, "scope.access").require(spec, operation=operation) + return spec + + def _context( + self, + spec: OperationSpec, + *, + deadline: float | None, + profile_binary_limits: ScopeBinaryLimits | None = None, + transport_trailing: bytes = b"", + ) -> ScopeOperationContextCoordinator: + return ScopeOperationContextCoordinator( + session_state=self.session_state, + spec=spec, + connection_timeout_ms=self.connection_timeout_ms, + caller_deadline=deadline, + profile_binary_limits=profile_binary_limits, + transport_trailing=transport_trailing, + enabled=True, + ) + + def _error_executor( + self, + spec: OperationSpec, + override: ErrorCheckSpec | None, + context: ScopeOperationContextCoordinator, + ) -> ScopeErrorPolicyExecutor: + resolved = resolve_error_check( + spec, + override, + instrument_default=self.instrument_error_default, + ) + return ScopeErrorPolicyExecutor( + driver=self.driver, + capabilities=self.capabilities, + operation_spec=spec, + error_spec=resolved, + correlation_id=context.correlation_id, + ) + + def _screenshot_profile(self) -> ScopeScreenshotProfile: + extensions = self.descriptor.scope_extensions + profile = extensions.screenshot_profile if extensions is not None else None + if profile is None: + raise ConfigError("scope screenshot capability requires a descriptor profile") + return profile + + def _acquisition_profile(self) -> ScopeAcquisitionControlProfile: + extensions = self.descriptor.scope_extensions + profile = extensions.acquisition_control_profile if extensions is not None else None + if profile is None: + raise ConfigError("scope acquisition control requires a descriptor profile") + return profile + + def _trace_profile(self) -> ScopeTraceProfile: + extensions = self.descriptor.scope_extensions + profile = extensions.trace_profile if extensions is not None else None + if profile is None: + raise ConfigError("scope trace capability requires a descriptor profile") + return profile + + @staticmethod + def _validate_screenshot_profile_narrowing( + static: ScopeScreenshotProfile, + runtime: object, + ) -> ScopeScreenshotProfile: + if not isinstance(runtime, ScopeScreenshotProfile): + raise DataError("get_screenshot_profile() returned an invalid result") + runtime.require_public_source() + static_variants = {item.request: item for item in static.variants} + for candidate in runtime.variants: + expected = static_variants.get(candidate.request) + if expected is None: + raise DataError("runtime screenshot profile expanded descriptor requests") + ExperimentalScopeExtensionService._assert_same_screenshot_state_contract( + expected, + candidate, + ) + if ( + candidate.response_max_bytes > expected.response_max_bytes + or candidate.operation_max_bytes > expected.operation_max_bytes + or candidate.query_max_count > expected.query_max_count + or candidate.resynchronization_max_bytes + > expected.resynchronization_max_bytes + ): + raise DataError("runtime screenshot profile expanded descriptor limits") + if ( + candidate.snapshot_max_steps > expected.snapshot_max_steps + or candidate.restore_max_steps > expected.restore_max_steps + or candidate.verify_max_steps > expected.verify_max_steps + ): + raise DataError("runtime screenshot profile expanded descriptor step limits") + for expected_bounds, candidate_bounds in ( + (expected.width_px, candidate.width_px), + (expected.height_px, candidate.height_px), + ): + if expected_bounds is None: + continue + if candidate_bounds is None or ( + candidate_bounds[0] < expected_bounds[0] + or candidate_bounds[1] > expected_bounds[1] + ): + raise DataError("runtime screenshot profile expanded descriptor dimensions") + return runtime + + @staticmethod + def _assert_same_screenshot_state_contract( + expected: ScopeScreenshotVariant, + candidate: ScopeScreenshotVariant, + ) -> None: + fixed = ( + "media_type", + "framing", + "changed_fields", + "restore_order", + "transport_trailing_hex", + "content_trailing_hex", + ) + if any(getattr(expected, name) != getattr(candidate, name) for name in fixed): + raise DataError("runtime screenshot profile changed a descriptor state contract") + + @staticmethod + def _validate_screenshot_snapshot( + snapshot: object, + fields: tuple[str, ...], + ) -> None: + if not isinstance(snapshot, ScopeScreenshotStateSnapshot): + raise DataError("screenshot state snapshot has an invalid type") + if tuple(snapshot.captured_fields) != fields: + raise DataError("screenshot state snapshot fields do not match the profile") + + @staticmethod + def _validate_screenshot_result( + screenshot: object, + variant: ScopeScreenshotVariant, + ) -> None: + if not isinstance(screenshot, ScopeScreenshot): + raise DataError("capture_screenshot() returned an invalid result") + if screenshot.requested != variant.request or screenshot.effective != variant.request: + raise DataError("screenshot effective request differs from its exact profile variant") + if screenshot.media_type != variant.media_type or screenshot.framing is not variant.framing: + raise DataError("screenshot media/framing differs from its profile variant") + for value, bounds, label in ( + (screenshot.width_px, variant.width_px, "width"), + (screenshot.height_px, variant.height_px, "height"), + ): + if bounds is not None and not bounds[0] <= value <= bounds[1]: + raise DataError(f"screenshot {label} is outside its profile bounds") + + @staticmethod + def _validate_acquisition_snapshot(snapshot: object) -> None: + if not isinstance(snapshot, ScopeAcquisitionControlSnapshot): + raise DataError("acquisition control snapshot has an invalid type") + + @staticmethod + def _validate_start_result( + value: object, + request: ScopeContinuousAcquisitionRequest, + baseline: ScopeAcquisitionControlBaseline, + profile: ScopeAcquisitionControlProfile, + ) -> None: + if request.trigger_mode not in profile.supported_continuous_modes: + raise ConfigError("requested continuous trigger mode is unsupported") + if not isinstance(value, ScopeAcquisitionRunState): + raise DataError("start_continuous() returned an invalid result") + if value.trigger_mode != request.trigger_mode or value.phase not in { + "ready", + "arming", + "waiting", + "acquiring", + "rolling", + }: + raise DataError("continuous acquisition postcondition is not proven") + + @staticmethod + def _validate_single_result( + value: object, + baseline: ScopeAcquisitionControlBaseline, + profile: ScopeAcquisitionControlProfile, + ) -> None: + if not isinstance(value, ScopeAcquisitionCompletion): + raise DataError("acquire_single() returned an invalid completion") + validate_acquisition_completion(value, baseline=baseline, profile=profile) + + @staticmethod + def _validate_trace_source( + source: ScopeTraceRef, + profile: ScopeTraceProfile, + *, + require_fetchable: bool, + ) -> None: + if require_fetchable and source.kind not in profile.fetchable_kinds: + raise ConfigError("trace kind is not fetchable in the descriptor profile") + if require_fetchable and source.kind == "digital" and source.index is None: + raise ConfigError("R1.3 digital trace fetch requires an indexed single-line source") + if source.index is not None and source.kind != "digital" and ( + source.index > profile.source_index_max + ): + raise ConfigError("trace source index exceeds the descriptor profile") + + @staticmethod + def _validate_trace_points(points: str | int, profile: ScopeTraceProfile) -> None: + if points == "dmax": + return + if isinstance(points, bool) or not isinstance(points, int) or not 1 <= points <= profile.max_points: + raise ConfigError("trace points must be 'dmax' or a positive value within the profile") + + @staticmethod + def _validate_trace_snapshot( + snapshot: object, + fields: tuple[str, ...], + ) -> None: + if not isinstance(snapshot, ScopeTraceTransferStateSnapshot): + raise DataError("trace transfer snapshot has an invalid type") + if tuple(snapshot.captured_fields) != fields: + raise DataError("trace transfer snapshot fields do not match the profile") + + @staticmethod + def _validate_trace_result( + trace: object, + source: ScopeTraceRef, + profile: ScopeTraceProfile, + points: str | int, + ) -> None: + if not isinstance(trace, ScopeTraceData) or trace.metadata.source != source: + raise DataError("fetch_trace() returned an inconsistent result") + if trace.values.size > profile.max_points: + raise DataError("trace result exceeds the descriptor point limit") + if isinstance(points, int) and not isinstance(points, bool) and trace.values.size != points: + raise DataError("trace result point count differs from the explicit request") + + def _result( + self, + context: ScopeOperationContextCoordinator, + *, + value: object, + error_executor: ScopeErrorPolicyExecutor | None = None, + observed_state: Mapping[str, object] | None = None, + extra: Mapping[str, object] | None = None, + ) -> ScopeExtensionOperationResult: + diagnostics = context.artifact() + diagnostics["error_check"] = ( + _json_safe(error_executor.artifact) if error_executor is not None else None + ) + diagnostics.update(_json_safe(extra or {})) + return ScopeExtensionOperationResult( + value=value, + diagnostics=diagnostics, + observed_state=observed_state, + ) + + def _attach_diagnostics( + self, + error: BaseException, + context: ScopeOperationContextCoordinator, + error_executor: ScopeErrorPolicyExecutor | None, + *, + cleanup_error: BaseException | None = None, + extra: Mapping[str, object] | None = None, + ) -> None: + diagnostics = context.artifact() + diagnostics["error_check"] = ( + _json_safe(error_executor.artifact) if error_executor is not None else None + ) + diagnostics["cleanup_error"] = ( + type(cleanup_error).__name__ if cleanup_error is not None else None + ) + diagnostics.update(_json_safe(extra or {})) + try: + setattr(error, "scope_operation_diagnostics", diagnostics) + except Exception: + pass + + +class ScopeExtensionService(ExperimentalScopeExtensionService): + """Stable public Service for descriptors that opt into scope R1.3 capabilities.""" + + def __init__( + self, + *, + driver: object, + descriptor: InstrumentDescriptor, + session_state: InstrumentSessionState, + connection_timeout_ms: int, + access: AccessMode = "read_write", + instrument_error_default: ErrorCheckSpec | None = None, + ) -> None: + super().__init__( + driver=driver, + descriptor=descriptor, + session_state=session_state, + connection_timeout_ms=connection_timeout_ms, + access=access, + instrument_error_default=instrument_error_default, + enabled=True, + ) + validate_scope_descriptor(descriptor, driver=driver) + + def _require(self, operation: str) -> OperationSpec: + spec = require_operation_spec(operation) + if operation not in EXPERIMENTAL_SCOPE_OPERATION_SPECS: + raise ConfigError(f"operation is not a scope R1.3 extension: {operation!r}") + missing = sorted(set(spec.required_capabilities) - self.capabilities) + if missing: + raise ConfigError( + f"operation {operation!r} is missing capabilities: {', '.join(missing)}" + ) + access_policy(self.access, "scope.access").require(spec, operation=operation) + return spec + + +__all__ = [ + "ExperimentalScopeExtensionService", + "SCOPE_EXTENSION_RESULT_SCHEMA", + "ScopeExtensionService", + "ScopeExtensionOperationResult", +] diff --git a/src/wavebench/services/scope_extension_specs.py b/src/wavebench/services/scope_extension_specs.py new file mode 100644 index 0000000..c663216 --- /dev/null +++ b/src/wavebench/services/scope_extension_specs.py @@ -0,0 +1,94 @@ +"""Scope R1.3 operation exports and legacy-capture screenshot contracts.""" + +from __future__ import annotations + +from types import MappingProxyType + +from wavebench.instruments.scope_extensions import ( + ScopeEmbeddedScreenshotContract, + ScopeScreenshotRequest, +) +from wavebench.scope_extension_constants import ( + SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT, + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES, + SCOPE_TRACE_OPERATION_TIMEOUT_MS, +) + +from .operation_specs import OperationSpec, SCOPE_OPERATION_SPECS, require_operation_spec + + +SCOPE_EXTENSIONS_ENABLED = True + +_EMBEDDED_SCREENSHOT_CONTRACT = ScopeEmbeddedScreenshotContract( + request=ScopeScreenshotRequest(menu_mode="exclude", color_mode="color"), + changed_fields=("scope.display_menu", "scope.display_color"), + verification_fields=("scope.display_menu", "scope.display_color"), + cleanup_verification_fields=("scope.display_menu", "scope.display_color"), +) + + +def _embedded_capture_spec(operation: str) -> OperationSpec: + stable = require_operation_spec(operation) + return OperationSpec( + operation=stable.operation, + instrument_kind=stable.instrument_kind, + required_capabilities=stable.required_capabilities, + optional_capabilities=(*stable.optional_capabilities, "scope.screenshot_v2"), + effect=stable.effect, + lease_mode=stable.lease_mode, + changed_fields=( + *stable.changed_fields, + "scope.display_menu", + "scope.display_color", + "output.screenshot", + ), + restore_coverage=stable.restore_coverage, + session_purpose=stable.session_purpose, + required_verified_fields=stable.required_verified_fields, + verification_fields=( + *stable.verification_fields, + "scope.display_menu", + "scope.display_color", + ), + cleanup_verification_fields=("scope.display_menu", "scope.display_color"), + timeout_source="operation.timeout_ms", + operation_timeout_ms=SCOPE_TRACE_OPERATION_TIMEOUT_MS, + binary_response_max_bytes=SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + binary_operation_max_bytes=SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + binary_query_max_count=SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT, + binary_resynchronization_max_bytes=( + SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES + ), + risk_flags=(*stable.risk_flags, "embedded_screenshot"), + safe_alternatives=stable.safe_alternatives, + embedded_screenshot_contract=_EMBEDDED_SCREENSHOT_CONTRACT, + ) + + +EMBEDDED_SCREENSHOT_CAPTURE_SPECS = MappingProxyType( + { + operation: _embedded_capture_spec(operation) + for operation in ( + "scope.capture", + "scope.capture_waveforms", + "scope.capture_multiple", + ) + } +) + +# Import-compatible names retained for code written against the internal draft. +EXPERIMENTAL_SCOPE_EXTENSIONS_ENABLED = SCOPE_EXTENSIONS_ENABLED +EXPERIMENTAL_SCOPE_OPERATION_SPECS = SCOPE_OPERATION_SPECS +EXPERIMENTAL_EMBEDDED_SCREENSHOT_CAPTURE_SPECS = EMBEDDED_SCREENSHOT_CAPTURE_SPECS + + +__all__ = [ + "EMBEDDED_SCREENSHOT_CAPTURE_SPECS", + "EXPERIMENTAL_SCOPE_EXTENSIONS_ENABLED", + "EXPERIMENTAL_EMBEDDED_SCREENSHOT_CAPTURE_SPECS", + "EXPERIMENTAL_SCOPE_OPERATION_SPECS", + "SCOPE_EXTENSIONS_ENABLED", + "SCOPE_OPERATION_SPECS", +] diff --git a/src/wavebench/services/scope_phase_coordinator.py b/src/wavebench/services/scope_phase_coordinator.py new file mode 100644 index 0000000..47ee213 --- /dev/null +++ b/src/wavebench/services/scope_phase_coordinator.py @@ -0,0 +1,844 @@ +"""Operation-context and phase bridge for the scope R1.3 contract.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum +from hashlib import sha256 +from math import ceil +import time +from typing import Iterable, Iterator +from uuid import uuid4 + +from wavebench.errors import ConfigError +from wavebench.transport.binary import BinaryQueryBudget, BinaryQueryLedger +from wavebench.transport.session import ( + InstrumentSessionState, + SessionAuthorization, + SessionHealth, + SessionPurpose, + SessionTransactionCoordinator, +) + +from .operation_specs import OperationSpec + + +SCOPE_OPERATION_ARTIFACT_SCHEMA = "wavebench.scope.operation.v1" + + +class OperationPhase(StrEnum): + PREFLIGHT = "preflight" + ERROR_BEFORE = "error_before" + MAIN = "main" + SUCCESS_RESTORE = "success_restore" + ERROR_AFTER = "error_after" + FAILURE_CLEANUP = "failure_cleanup" + CLEANUP_VERIFICATION = "cleanup_verification" + + +class ScopePhasePurpose(StrEnum): + NORMAL = "normal" + RECOVERY = "recovery" + VERIFICATION = "verification" + + +class BaselineUseState(StrEnum): + FRESH = "fresh" + PASSED_TO_MAIN = "passed_to_main" + RESTORE_ATTEMPTED = "restore_attempted" + VERIFY_ATTEMPTED = "verify_attempted" + CONSUMED = "consumed" + INVALIDATED = "invalidated" + + +_PHASE_PURPOSE = { + OperationPhase.PREFLIGHT: ScopePhasePurpose.VERIFICATION, + OperationPhase.ERROR_BEFORE: ScopePhasePurpose.VERIFICATION, + OperationPhase.MAIN: ScopePhasePurpose.NORMAL, + OperationPhase.SUCCESS_RESTORE: ScopePhasePurpose.RECOVERY, + OperationPhase.ERROR_AFTER: ScopePhasePurpose.VERIFICATION, + OperationPhase.FAILURE_CLEANUP: ScopePhasePurpose.RECOVERY, + OperationPhase.CLEANUP_VERIFICATION: ScopePhasePurpose.VERIFICATION, +} +_READ_IO = frozenset({"query", "query_float_list", "query_opc"}) +_BINARY_IO = frozenset({"query_binary", "query_bin_block"}) +_WRITE_IO = frozenset({"write", "write_bytes"}) +_ALL_IO = _READ_IO | _BINARY_IO | _WRITE_IO + + +def _safe_token(value: object, *, label: str) -> str: + if ( + not isinstance(value, str) + or not value + or value.strip() != value + or len(value) > 96 + or any(not (char.isalnum() or char in "_.:-") for char in value) + ): + raise ValueError(f"{label} must be a short safe token") + return value + + +def _field_tuple(values: Iterable[str], *, label: str, allow_empty: bool = False) -> tuple[str, ...]: + if isinstance(values, (str, bytes)): + raise ValueError(f"{label} must be an iterable of fields") + result = tuple(values) + if (not result and not allow_empty) or any( + not isinstance(item, str) or not item or item.strip() != item for item in result + ): + raise ValueError(f"{label} contains an invalid field") + if len(set(result)) != len(result): + raise ValueError(f"{label} must not contain duplicates") + return result + + +@dataclass(frozen=True, slots=True) +class ScopeBinaryLimits: + response_max_bytes: int + operation_max_bytes: int + query_max_count: int + resynchronization_max_bytes: int + + def __post_init__(self) -> None: + for label, value in ( + ("response_max_bytes", self.response_max_bytes), + ("operation_max_bytes", self.operation_max_bytes), + ("query_max_count", self.query_max_count), + ): + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{label} must be a positive integer") + if ( + isinstance(self.resynchronization_max_bytes, bool) + or not isinstance(self.resynchronization_max_bytes, int) + or self.resynchronization_max_bytes < 0 + ): + raise ValueError("resynchronization_max_bytes must be a non-negative integer") + if self.operation_max_bytes < self.response_max_bytes: + raise ValueError("operation byte limit must cover one full response") + + def intersect(self, other: "ScopeBinaryLimits") -> "ScopeBinaryLimits": + return ScopeBinaryLimits( + response_max_bytes=min(self.response_max_bytes, other.response_max_bytes), + operation_max_bytes=min(self.operation_max_bytes, other.operation_max_bytes), + query_max_count=min(self.query_max_count, other.query_max_count), + resynchronization_max_bytes=min( + self.resynchronization_max_bytes, + other.resynchronization_max_bytes, + ), + ) + + @classmethod + def from_spec(cls, spec: OperationSpec) -> "ScopeBinaryLimits | None": + values = ( + spec.binary_response_max_bytes, + spec.binary_operation_max_bytes, + spec.binary_query_max_count, + spec.binary_resynchronization_max_bytes, + ) + if values == (None, None, None, None): + return None + if any(value is None for value in values): + raise ValueError("operation spec has an incomplete binary limit set") + return cls(*values) # type: ignore[arg-type] + + +@dataclass(frozen=True, slots=True) +class ScopePhaseAuthorizationSpec: + context_id: str + operation_id: str + phase: OperationPhase + purpose: ScopePhasePurpose + allowed_io: frozenset[str] + fields: frozenset[str] + deadline: float + max_steps: int + + def __post_init__(self) -> None: + _safe_token(self.context_id, label="context_id") + _safe_token(self.operation_id, label="operation_id") + object.__setattr__(self, "phase", OperationPhase(self.phase)) + object.__setattr__(self, "purpose", ScopePhasePurpose(self.purpose)) + if self.purpose is not _PHASE_PURPOSE[self.phase]: + raise ValueError("phase purpose does not match the fixed scope phase mapping") + allowed_io = frozenset(self.allowed_io) + fields = frozenset(self.fields) + if not allowed_io or not allowed_io <= _ALL_IO: + raise ValueError("phase allowed_io is empty or unsupported") + if not fields or any( + not isinstance(item, str) or not item or item.strip() != item for item in fields + ): + raise ValueError("phase fields must contain non-empty trimmed names") + object.__setattr__(self, "allowed_io", allowed_io) + object.__setattr__(self, "fields", fields) + if self.phase is not OperationPhase.MAIN and allowed_io & _BINARY_IO: + raise ValueError("only the main phase can use binary I/O") + if self.purpose is ScopePhasePurpose.VERIFICATION and allowed_io & _WRITE_IO: + raise ValueError("verification phases cannot write") + if isinstance(self.deadline, bool) or not isinstance(self.deadline, (int, float)): + raise ValueError("phase deadline must be a monotonic timestamp") + if isinstance(self.max_steps, bool) or not isinstance(self.max_steps, int) or self.max_steps < 1: + raise ValueError("phase max_steps must be a positive integer") + + +@dataclass(frozen=True, slots=True, init=False, eq=False) +class ScopePhaseAuthorization: + """Concrete core-only phase handle; it is never passed to a driver.""" + + context_id: str + operation_id: str + phase: OperationPhase + purpose: ScopePhasePurpose + allowed_io: frozenset[str] + fields: frozenset[str] + deadline: float + max_steps: int + _session_authorization: SessionAuthorization = field(repr=False, compare=False) + _owner_nonce: object = field(repr=False, compare=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("scope phase authorizations are coordinator-issued") + + @classmethod + def _issue( + cls, + spec: ScopePhaseAuthorizationSpec, + session_authorization: SessionAuthorization, + owner_nonce: object, + ) -> "ScopePhaseAuthorization": + instance = object.__new__(cls) + for name in ( + "context_id", + "operation_id", + "phase", + "purpose", + "allowed_io", + "fields", + "deadline", + "max_steps", + ): + object.__setattr__(instance, name, getattr(spec, name)) + object.__setattr__(instance, "_session_authorization", session_authorization) + object.__setattr__(instance, "_owner_nonce", owner_nonce) + return instance + + +@dataclass(frozen=True, slots=True, init=False, eq=False) +class ScopeBaselineHandle: + context_id: str + operation_id: str + session_epoch: str + kind: str + baseline_nonce: str = field(repr=False) + fields: tuple[str, ...] + restore_order: tuple[str, ...] + _owner_nonce: object = field(repr=False, compare=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("scope baseline handles are coordinator-issued") + + @classmethod + def _issue( + cls, + *, + context_id: str, + operation_id: str, + session_epoch: str, + kind: str, + baseline_nonce: str, + fields: tuple[str, ...], + restore_order: tuple[str, ...], + owner_nonce: object, + ) -> "ScopeBaselineHandle": + instance = object.__new__(cls) + for name, value in ( + ("context_id", context_id), + ("operation_id", operation_id), + ("session_epoch", session_epoch), + ("kind", kind), + ("baseline_nonce", baseline_nonce), + ("fields", fields), + ("restore_order", restore_order), + ("_owner_nonce", owner_nonce), + ): + object.__setattr__(instance, name, value) + return instance + + +@dataclass(slots=True) +class _BaselineRecord: + handle: ScopeBaselineHandle + state: BaselineUseState = BaselineUseState.FRESH + restore_succeeded: bool | None = None + verification_succeeded: bool | None = None + + +class ScopeOperationContextCoordinator: + """One operation context with sequential, non-nested phases.""" + + def __init__( + self, + *, + session_state: InstrumentSessionState, + spec: OperationSpec, + connection_timeout_ms: int, + correlation_id: str | None = None, + caller_deadline: float | None = None, + profile_binary_limits: ScopeBinaryLimits | None = None, + connection_binary_limits: ScopeBinaryLimits | None = None, + transport_trailing: bytes = b"", + enabled: bool = False, + now: float | None = None, + ) -> None: + if not enabled: + raise ConfigError("experimental scope extensions are disabled") + if spec.instrument_kind != "scope" or spec.operation_timeout_ms is None: + raise ValueError("scope operation contexts require an explicit scope operation timeout") + if isinstance(connection_timeout_ms, bool) or not isinstance( + connection_timeout_ms, int + ) or connection_timeout_ms < 1: + raise ValueError("connection_timeout_ms must be a positive integer") + if session_state.health is not SessionHealth.HEALTHY: + raise ValueError("new scope operations require a healthy session") + current = time.monotonic() if now is None else float(now) + hard_deadline = current + (spec.operation_timeout_ms / 1000.0) + if caller_deadline is not None: + if isinstance(caller_deadline, bool) or not isinstance( + caller_deadline, (int, float) + ): + raise ValueError("caller_deadline must be a monotonic timestamp") + hard_deadline = min(hard_deadline, float(caller_deadline)) + if hard_deadline <= current: + raise ValueError("scope operation deadline is exhausted") + reserve_ms = min(5_000, max(1_000, spec.operation_timeout_ms // 5)) + reserve_s = min(reserve_ms / 1000.0, max((hard_deadline - current) / 2.0, 0.0)) + self.context_id = uuid4().hex + self.operation_id = spec.operation + self.correlation_id = _safe_token( + correlation_id or uuid4().hex, + label="correlation_id", + ) + self.session_epoch = session_state.epoch_id + self.deadline = hard_deadline + self.main_deadline = hard_deadline - reserve_s + self.cleanup_reserve_ms = int(reserve_s * 1000) + self.spec = spec + self.session_state = session_state + self.session_health_before = session_state.health.value + self.connection_timeout_ms = connection_timeout_ms + self.observed_at_utc = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + self._session_coordinator = SessionTransactionCoordinator(session_state) + self._owner_nonce = object() + self._active_phase: ScopePhaseAuthorization | None = None + self._phase_history: list[dict[str, object]] = [] + self._used_phases: set[OperationPhase] = set() + self._baselines: dict[str, _BaselineRecord] = {} + self._main_entered = False + self._cleanup_required = False + self._cleanup_verified_without_baseline = False + self._terminal = False + + limits = ScopeBinaryLimits.from_spec(spec) + if limits is not None and profile_binary_limits is not None: + limits = limits.intersect(profile_binary_limits) + if limits is not None and connection_binary_limits is not None: + limits = limits.intersect(connection_binary_limits) + self._binary_ledger: BinaryQueryLedger | None = None + self._binary_budget: BinaryQueryBudget | None = None + if limits is not None: + self._binary_ledger = BinaryQueryLedger( + context_id=self.context_id, + operation_id=self.operation_id, + correlation_id=self.correlation_id, + session_epoch=self.session_epoch, + deadline=self.deadline, + per_response_max_bytes=limits.response_max_bytes, + operation_max_bytes=limits.operation_max_bytes, + query_max_count=limits.query_max_count, + resynchronization_max_bytes=limits.resynchronization_max_bytes, + transport_trailing=transport_trailing, + ) + self._binary_budget = self._binary_ledger.issue_budget() + + @property + def binary_ledger(self) -> BinaryQueryLedger | None: + return self._binary_ledger + + @property + def terminal(self) -> bool: + return self._terminal + + def has_phase(self, phase: OperationPhase) -> bool: + return OperationPhase(phase) in self._used_phases + + def make_phase_spec( + self, + phase: OperationPhase, + *, + allowed_io: Iterable[str], + fields: Iterable[str], + max_steps: int, + deadline: float | None = None, + ) -> ScopePhaseAuthorizationSpec: + phase = OperationPhase(phase) + ceiling = ( + self.deadline + if phase + in { + OperationPhase.SUCCESS_RESTORE, + OperationPhase.FAILURE_CLEANUP, + OperationPhase.CLEANUP_VERIFICATION, + } + else self.main_deadline + ) + chosen_deadline = ceiling if deadline is None else min(float(deadline), ceiling) + return ScopePhaseAuthorizationSpec( + context_id=self.context_id, + operation_id=self.operation_id, + phase=phase, + purpose=_PHASE_PURPOSE[phase], + allowed_io=frozenset(allowed_io), + fields=frozenset(fields), + deadline=chosen_deadline, + max_steps=max_steps, + ) + + @contextmanager + def authorize_phase( + self, + phase_spec: ScopePhaseAuthorizationSpec, + ) -> Iterator[ScopePhaseAuthorization]: + self._validate_phase_spec(phase_spec) + if self._active_phase is not None or self.session_state._active_authorization() is not None: + raise ValueError("nested scope/session authorizations are not allowed") + self._validate_phase_order(phase_spec.phase) + remaining_ms = min( + self.connection_timeout_ms, + max(1, ceil((phase_spec.deadline - time.monotonic()) * 1000.0)), + ) + if phase_spec.phase is OperationPhase.MAIN: + manager = self._session_coordinator.authorize_normal( + operation_id=self.operation_id, + allowed_io=phase_spec.allowed_io, + fields=phase_spec.fields, + timeout_ms=remaining_ms, + max_steps=phase_spec.max_steps, + context_id=self.context_id, + correlation_id=self.correlation_id, + phase=phase_spec.phase.value, + absolute_deadline=phase_spec.deadline, + binary_budget=self._binary_budget, + ) + else: + evidence_fields = None + if phase_spec.purpose is ScopePhasePurpose.VERIFICATION and phase_spec.phase not in { + OperationPhase.ERROR_BEFORE, + OperationPhase.ERROR_AFTER, + }: + evidence_fields = { + io_kind: phase_spec.fields + for io_kind in phase_spec.allowed_io + if io_kind in _READ_IO + } + manager = self._session_coordinator.authorize( + operation_id=self.operation_id, + purpose=SessionPurpose(phase_spec.purpose.value), + allowed_io=phase_spec.allowed_io, + fields=phase_spec.fields, + timeout_ms=remaining_ms, + max_steps=phase_spec.max_steps, + evidence_fields=evidence_fields, + context_id=self.context_id, + correlation_id=self.correlation_id, + phase=phase_spec.phase.value, + absolute_deadline=phase_spec.deadline, + ) + budget_before = self._binary_ledger.snapshot() if self._binary_ledger else None + status = "failed" + with manager as session_authorization: + authorization = ScopePhaseAuthorization._issue( + phase_spec, + session_authorization, + self._owner_nonce, + ) + self._active_phase = authorization + self._used_phases.add(phase_spec.phase) + if phase_spec.phase is OperationPhase.MAIN: + self._main_entered = True + try: + yield authorization + status = "completed" + finally: + if phase_spec.phase is OperationPhase.MAIN: + self._session_coordinator.invalidate_verified_fields( + self.spec.changed_fields + ) + self._active_phase = None + self._phase_history.append( + { + "phase": phase_spec.phase.value, + "purpose": phase_spec.purpose.value, + "allowed_io": sorted(phase_spec.allowed_io), + "fields": sorted(phase_spec.fields), + "max_steps": phase_spec.max_steps, + "actual_steps": session_authorization._record.successful_steps, + "status": status, + "budget_before": budget_before, + "budget_after": ( + self._binary_ledger.snapshot() if self._binary_ledger else None + ), + } + ) + + def create_baseline( + self, + *, + kind: str, + fields: Iterable[str], + restore_order: Iterable[str], + ) -> ScopeBaselineHandle: + self._require_phase(OperationPhase.PREFLIGHT) + normalized_fields = _field_tuple(fields, label="baseline fields") + normalized_order = _field_tuple(restore_order, label="restore_order") + if set(normalized_fields) != set(normalized_order): + raise ValueError("baseline restore order must cover its fields exactly") + if not set(normalized_fields) <= self._field_universe(): + raise ValueError("baseline fields exceed the operation specification") + nonce = uuid4().hex + handle = ScopeBaselineHandle._issue( + context_id=self.context_id, + operation_id=self.operation_id, + session_epoch=self.session_epoch, + kind=_safe_token(kind, label="baseline kind"), + baseline_nonce=nonce, + fields=normalized_fields, + restore_order=normalized_order, + owner_nonce=self._owner_nonce, + ) + self._baselines[nonce] = _BaselineRecord(handle=handle) + return handle + + def pass_baseline_to_main(self, handle: ScopeBaselineHandle) -> None: + self._require_phase(OperationPhase.PREFLIGHT) + record = self._baseline_record(handle) + if record.state is not BaselineUseState.FRESH: + raise ValueError("baseline is not fresh") + record.state = BaselineUseState.PASSED_TO_MAIN + + def begin_restore(self, handle: ScopeBaselineHandle) -> None: + if self._active_phase is None or self._active_phase.phase not in { + OperationPhase.SUCCESS_RESTORE, + OperationPhase.FAILURE_CLEANUP, + }: + raise ValueError("baseline restore is outside a restore phase") + record = self._baseline_record(handle) + if record.state is not BaselineUseState.PASSED_TO_MAIN: + raise ValueError("baseline restore slot is already consumed or unavailable") + record.state = BaselineUseState.RESTORE_ATTEMPTED + + def finish_restore(self, handle: ScopeBaselineHandle, *, succeeded: bool) -> None: + record = self._baseline_record(handle) + if record.state is not BaselineUseState.RESTORE_ATTEMPTED: + raise ValueError("baseline restore was not attempted") + if record.restore_succeeded is not None: + raise ValueError("baseline restore outcome is already recorded") + if not isinstance(succeeded, bool): + raise TypeError("restore outcome must be bool") + record.restore_succeeded = succeeded + + def begin_verification(self, handle: ScopeBaselineHandle) -> None: + self._require_phase(OperationPhase.CLEANUP_VERIFICATION) + record = self._baseline_record(handle) + if record.state is not BaselineUseState.RESTORE_ATTEMPTED: + raise ValueError("baseline verification requires exactly one prior restore attempt") + if record.restore_succeeded is None: + raise ValueError("baseline restore outcome has not been recorded") + record.state = BaselineUseState.VERIFY_ATTEMPTED + + def finish_verification( + self, + handle: ScopeBaselineHandle, + authorization: ScopePhaseAuthorization, + *, + io_kind: str, + verified_fields: Iterable[str], + matched: bool, + ) -> None: + self._require_authorization(authorization, OperationPhase.CLEANUP_VERIFICATION) + record = self._baseline_record(handle) + if record.state is not BaselineUseState.VERIFY_ATTEMPTED: + raise ValueError("baseline verify slot is not active") + fields = frozenset(_field_tuple(verified_fields, label="verified_fields")) + complete = fields == set(handle.fields) + if not isinstance(matched, bool): + raise TypeError("verification match result must be bool") + candidate_succeeded = bool(record.restore_succeeded and matched and complete) + verification_succeeded = False + try: + if candidate_succeeded: + self._session_coordinator.record_evidence( + authorization._session_authorization, + io_kind, + fields, + ) + self._session_coordinator.complete_verification( + authorization._session_authorization + ) + verification_succeeded = True + finally: + record.verification_succeeded = verification_succeeded + record.state = BaselineUseState.CONSUMED + if not verification_succeeded: + raise ValueError("baseline restoration verification is incomplete or mismatched") + + def complete_phase_verification( + self, + authorization: ScopePhaseAuthorization, + *, + io_kind: str, + fields: Iterable[str], + ) -> None: + """Commit fresh readback evidence for a non-baseline verification phase.""" + + if not isinstance(authorization, ScopePhaseAuthorization): + raise TypeError("scope phase authorization has an invalid type") + if ( + authorization._owner_nonce is not self._owner_nonce + or self._active_phase is not authorization + or authorization.purpose is not ScopePhasePurpose.VERIFICATION + ): + raise ValueError("verification phase authorization is inactive") + verified = frozenset(_field_tuple(fields, label="verified fields")) + if not verified <= authorization.fields: + raise ValueError("verified fields exceed the phase authorization") + self._session_coordinator.record_evidence( + authorization._session_authorization, + io_kind, + verified, + ) + self._session_coordinator.complete_verification( + authorization._session_authorization + ) + if authorization.phase is OperationPhase.CLEANUP_VERIFICATION: + self._cleanup_verified_without_baseline = True + + def consume_baseline_after_success(self, handle: ScopeBaselineHandle) -> None: + if not self._main_entered or self._active_phase is not None: + raise ValueError("successful baseline consumption requires a closed main phase") + record = self._baseline_record(handle) + if record.state is not BaselineUseState.PASSED_TO_MAIN: + raise ValueError("baseline cannot be consumed from its current state") + record.state = BaselineUseState.CONSUMED + + def mark_cleanup_required(self) -> None: + if self._active_phase is not None or not self._main_entered: + raise ValueError("cleanup can only be required after the main phase closes") + if self._cleanup_required: + raise ValueError("operation cleanup is already required") + self._cleanup_required = True + if self._binary_ledger is not None: + self._binary_ledger.invalidate() + if self.session_state.health is SessionHealth.HEALTHY: + self.session_state.degrade( + SessionHealth.UNCERTAIN, + reason="scope_cleanup_required", + ) + + def complete(self) -> None: + if self._active_phase is not None: + raise ValueError("operation context cannot terminate with an active phase") + if self._terminal: + return + incomplete = [ + record + for record in self._baselines.values() + if record.state not in {BaselineUseState.CONSUMED, BaselineUseState.INVALIDATED} + ] + verified = ( + all(record.verification_succeeded is True for record in self._baselines.values()) + if self._baselines + else self._cleanup_verified_without_baseline + ) + if self._cleanup_required and (incomplete or not verified): + if self.session_state.health in {SessionHealth.HEALTHY, SessionHealth.UNCERTAIN}: + self.session_state.degrade( + SessionHealth.POISONED, + reason="scope_cleanup_incomplete", + ) + if self._binary_ledger is not None: + self._binary_ledger.invalidate() + for record in self._baselines.values(): + if record.state is not BaselineUseState.CONSUMED: + record.state = BaselineUseState.INVALIDATED + self._terminal = True + + def artifact(self) -> dict[str, object]: + return { + "schema": SCOPE_OPERATION_ARTIFACT_SCHEMA, + "schema_version": 1, + "observed_at_utc": self.observed_at_utc, + "operation": self.operation_id, + "correlation_id": self.correlation_id, + "context_id": self.context_id, + "session_epoch": self.session_epoch, + "session_health_before": self.session_health_before, + "session_health_after": self.session_state.health.value, + "deadline_source": self.spec.timeout_source, + "cleanup_reserve_ms": self.cleanup_reserve_ms, + "phases": [dict(item) for item in self._phase_history], + "binary_budget": self._binary_ledger.snapshot() if self._binary_ledger else None, + "baselines": [ + { + "kind": record.handle.kind, + "context_id": record.handle.context_id, + "session_epoch": record.handle.session_epoch, + "nonce_digest": sha256( + record.handle.baseline_nonce.encode("ascii") + ).hexdigest()[:16], + "fields": list(record.handle.fields), + "restore_order": list(record.handle.restore_order), + "consumption": record.state.value, + "restore_succeeded": record.restore_succeeded, + "verification_succeeded": record.verification_succeeded, + } + for record in self._baselines.values() + ], + "terminal": self._terminal, + } + + def _validate_phase_spec(self, phase_spec: ScopePhaseAuthorizationSpec) -> None: + if not isinstance(phase_spec, ScopePhaseAuthorizationSpec): + raise TypeError("phase authorization spec has an invalid type") + if self._terminal: + raise ValueError("scope operation context is terminal") + if self.session_state.epoch_id != self.session_epoch: + self.complete() + raise ValueError("scope operation context belongs to another session epoch") + if ( + phase_spec.context_id != self.context_id + or phase_spec.operation_id != self.operation_id + ): + raise ValueError("phase authorization belongs to another operation context") + ceiling = ( + self.deadline + if phase_spec.phase + in { + OperationPhase.SUCCESS_RESTORE, + OperationPhase.FAILURE_CLEANUP, + OperationPhase.CLEANUP_VERIFICATION, + } + else self.main_deadline + ) + if phase_spec.deadline > ceiling or phase_spec.deadline <= time.monotonic(): + raise ValueError("phase deadline exceeds or exhausts the operation deadline") + if not phase_spec.fields <= self._field_universe(): + raise ValueError("phase fields exceed the operation specification") + if phase_spec.allowed_io & _BINARY_IO and self._binary_budget is None: + raise ValueError("phase requests binary I/O without an operation budget") + + def _validate_phase_order(self, phase: OperationPhase) -> None: + if phase in self._used_phases: + raise ValueError("scope operation phases are single use") + if phase is OperationPhase.PREFLIGHT and self._used_phases: + raise ValueError("preflight must be the first scope operation phase") + if phase is not OperationPhase.PREFLIGHT and ( + OperationPhase.PREFLIGHT not in self._used_phases + ): + raise ValueError("scope operation main/error phases require preflight") + if phase is OperationPhase.ERROR_BEFORE and self._main_entered: + raise ValueError("error_before cannot run after the main phase") + if phase is OperationPhase.MAIN and OperationPhase.ERROR_AFTER in self._used_phases: + raise ValueError("main cannot run after error_after") + if phase in { + OperationPhase.ERROR_AFTER, + OperationPhase.SUCCESS_RESTORE, + OperationPhase.FAILURE_CLEANUP, + } and not self._main_entered: + raise ValueError("post-main phases require an entered main phase") + if phase in {OperationPhase.SUCCESS_RESTORE, OperationPhase.FAILURE_CLEANUP} and ( + { + OperationPhase.SUCCESS_RESTORE, + OperationPhase.FAILURE_CLEANUP, + } + & self._used_phases + ): + raise ValueError("an operation can enter only one restore phase") + if phase is OperationPhase.ERROR_AFTER and ( + { + OperationPhase.SUCCESS_RESTORE, + OperationPhase.FAILURE_CLEANUP, + } + & self._used_phases + ): + raise ValueError("error_after cannot run after restore begins") + if phase is OperationPhase.MAIN and ( + OperationPhase.ERROR_AFTER in self._used_phases + or OperationPhase.SUCCESS_RESTORE in self._used_phases + or OperationPhase.FAILURE_CLEANUP in self._used_phases + ): + raise ValueError("main cannot run after a cleanup phase") + if phase is OperationPhase.CLEANUP_VERIFICATION and not ( + { + OperationPhase.SUCCESS_RESTORE, + OperationPhase.FAILURE_CLEANUP, + } + & self._used_phases + ): + raise ValueError("cleanup verification requires one restore phase") + + def _field_universe(self) -> frozenset[str]: + return frozenset( + ( + *self.spec.changed_fields, + *self.spec.required_verified_fields, + *self.spec.verification_fields, + *self.spec.postcondition_fields, + *self.spec.cleanup_verification_fields, + ) + ) + + def _baseline_record(self, handle: ScopeBaselineHandle) -> _BaselineRecord: + if not isinstance(handle, ScopeBaselineHandle): + raise TypeError("baseline handle has an invalid type") + if handle._owner_nonce is not self._owner_nonce: + raise ValueError("baseline handle is not owned by this context") + if ( + handle.context_id != self.context_id + or handle.operation_id != self.operation_id + or handle.session_epoch != self.session_epoch + ): + raise ValueError("baseline handle binding does not match the operation context") + record = self._baselines.get(handle.baseline_nonce) + if record is None or record.handle is not handle: + raise ValueError("baseline nonce is unknown or replayed") + return record + + def _require_phase(self, phase: OperationPhase) -> None: + if self._active_phase is None or self._active_phase.phase is not phase: + raise ValueError(f"operation requires active phase {phase.value}") + + def _require_authorization( + self, + authorization: ScopePhaseAuthorization, + phase: OperationPhase, + ) -> None: + if ( + not isinstance(authorization, ScopePhaseAuthorization) + or authorization._owner_nonce is not self._owner_nonce + or self._active_phase is not authorization + or authorization.phase is not phase + ): + raise ValueError("scope phase authorization is inactive or belongs to another context") + + +__all__ = [ + "BaselineUseState", + "OperationPhase", + "ScopeBaselineHandle", + "ScopeBinaryLimits", + "ScopeOperationContextCoordinator", + "ScopePhaseAuthorization", + "ScopePhaseAuthorizationSpec", + "ScopePhasePurpose", + "SCOPE_OPERATION_ARTIFACT_SCHEMA", +] diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py index 38a7beb..e71b828 100644 --- a/src/wavebench/services/scope_service.py +++ b/src/wavebench/services/scope_service.py @@ -46,11 +46,21 @@ WaveformData, ) from wavebench.instruments.registry import resolve_instrument_descriptor +from wavebench.instruments.scope_extensions import ( + ErrorCheckSpec, + ScopeContinuousAcquisitionRequest, + ScopeScreenshotRequest, + ScopeTraceRef, +) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy from wavebench.services.operation_specs import OperationSpec, require_operation_spec from wavebench.services.resource_lease import ResourceLease from wavebench.services.session_alias import SessionStateAliasMixin +from wavebench.services.scope_extension_service import ( + ScopeExtensionOperationResult, + ScopeExtensionService, +) from wavebench.transport.base import InstrumentTransport from wavebench.transport.session import ( InstrumentSessionState, @@ -184,6 +194,8 @@ def _require(self, operation: str, *capabilities: str) -> OperationSpec: def _operation_timeout_ms(self, spec: OperationSpec) -> int: if spec.timeout_source == "connection.timeout_ms": return self.config.connection.timeout_ms + if spec.timeout_source == "operation.timeout_ms" and spec.operation_timeout_ms is not None: + return min(spec.operation_timeout_ms, self.config.connection.timeout_ms) raise ConfigError( f"unsupported timeout source {spec.timeout_source!r} for {spec.operation!r}" ) @@ -496,6 +508,141 @@ def cursor_readout( configured_cursor=configured_cursor, ) + def _scope_extension_service(self, scope: object) -> ScopeExtensionService: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + if self.session_state is None: + raise ConfigError("scope extension operations require a shared session state") + default_error_check = ErrorCheckSpec( + "if_supported" if self.config.scope.check_errors else "disabled" + ) + return ScopeExtensionService( + driver=scope, + descriptor=descriptor, + session_state=self.session_state, + connection_timeout_ms=self.config.connection.timeout_ms, + access=getattr(self.config.scope, "access", "read_write"), + instrument_error_default=default_error_check, + ) + + def screenshot_profile( + self, + *, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require("scope.screenshot_profile", "scope.screenshot_profile") + with self._scope_session() as scope: + return self._scope_extension_service(scope).screenshot_profile(deadline=deadline) + + def screenshot_v2( + self, + request: ScopeScreenshotRequest, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require("scope.screenshot_v2", "scope.screenshot_v2") + with self._scope_session() as scope: + return self._scope_extension_service(scope).screenshot_v2( + request, + error_check=error_check, + deadline=deadline, + ) + + def acquisition_run_state( + self, + *, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require("scope.acquisition_run_state", "scope.acquisition_run_state") + with self._scope_session() as scope: + return self._scope_extension_service(scope).acquisition_run_state(deadline=deadline) + + def start_acquisition( + self, + request: ScopeContinuousAcquisitionRequest, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require( + "scope.acquisition_start", + "scope.acquisition_control", + "scope.acquisition_run_state", + ) + with self._scope_session() as scope: + return self._scope_extension_service(scope).start_acquisition( + request, + error_check=error_check, + deadline=deadline, + ) + + def acquire_single( + self, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require( + "scope.acquisition_single", + "scope.acquisition_control", + "scope.acquisition_run_state", + ) + with self._scope_session() as scope: + return self._scope_extension_service(scope).acquire_single( + error_check=error_check, + deadline=deadline, + ) + + def stop_acquisition( + self, + *, + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require( + "scope.acquisition_stop", + "scope.acquisition_control", + "scope.acquisition_run_state", + ) + with self._scope_session() as scope: + return self._scope_extension_service(scope).stop_acquisition( + error_check=error_check, + deadline=deadline, + ) + + def trace_metadata( + self, + source: ScopeTraceRef, + *, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require("scope.trace_metadata", "scope.trace_metadata") + with self._scope_session() as scope: + return self._scope_extension_service(scope).trace_metadata( + source, + deadline=deadline, + ) + + def fetch_trace( + self, + source: ScopeTraceRef, + *, + points: str | int = "dmax", + error_check: ErrorCheckSpec | None = None, + deadline: float | None = None, + ) -> ScopeExtensionOperationResult: + self._require("scope.fetch_trace", "scope.fetch_trace") + with self._scope_session() as scope: + return self._scope_extension_service(scope).fetch_trace( + source, + points=points, + error_check=error_check, + deadline=deadline, + ) + def channel_coupling(self, channel: int) -> str: self._require("scope.channel_coupling", "scope.channel_coupling") with self._scope_session() as scope: @@ -592,6 +739,20 @@ def _write_screenshot_file(self, package_dir: Path, scope: ScopeDriver) -> tuple return None, {"type": type(exc).__name__, "message": str(exc)} return screenshot_path, None + def _legacy_capture_screenshot_capability(self) -> str: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + if "scope.screenshot" in descriptor.capabilities: + return "scope.screenshot" + if "scope.screenshot_v2" in descriptor.capabilities: + raise ConfigError( + "scope capture cannot embed scope.screenshot_v2 without the parent-operation " + "field-closure runtime; use 'wavebench scope screenshot capture'" + ) + return "scope.screenshot" + def _waveform_metadata(self, waveform: WaveformData) -> dict[str, Any]: return { "header": { @@ -652,7 +813,7 @@ def capture_waveform(self, channel: int, label: str) -> CaptureResult: if self.config.scope.check_errors: required.append("scope.errors") if self.config.output.save_screenshot: - required.append("scope.screenshot") + required.append(self._legacy_capture_screenshot_capability()) self._require("scope.capture", *required) package_dir = new_package_dir(self.config.output.directory, label) package_dir.mkdir(parents=True, exist_ok=False) @@ -732,7 +893,7 @@ def capture_waveforms(self, channels: list[int], label: str) -> MultiCaptureResu if self.config.scope.check_errors: required.append("scope.errors") if self.config.output.save_screenshot: - required.append("scope.screenshot") + required.append(self._legacy_capture_screenshot_capability()) self._require("scope.capture_multiple", *required) package_dir = new_package_dir(self.config.output.directory, label) package_dir.mkdir(parents=True, exist_ok=False) diff --git a/src/wavebench/transport/__init__.py b/src/wavebench/transport/__init__.py index e69de29..a7cccc7 100644 --- a/src/wavebench/transport/__init__.py +++ b/src/wavebench/transport/__init__.py @@ -0,0 +1,21 @@ +from .base import InstrumentTransport +from .contracts import ( + BinaryQueryResult, + BinaryResponseFraming, + CommandTransmission, + ReplayPolicy, + ResponseProgress, + Synchronization, + TransportPhase, +) + +__all__ = [ + "BinaryQueryResult", + "BinaryResponseFraming", + "CommandTransmission", + "InstrumentTransport", + "ReplayPolicy", + "ResponseProgress", + "Synchronization", + "TransportPhase", +] diff --git a/src/wavebench/transport/base.py b/src/wavebench/transport/base.py index 69f3bbb..8093921 100644 --- a/src/wavebench/transport/base.py +++ b/src/wavebench/transport/base.py @@ -2,7 +2,7 @@ from typing import Protocol -from .contracts import ReplayPolicy +from .contracts import BinaryQueryResult, BinaryResponseFraming, ReplayPolicy class InstrumentTransport(Protocol): resource: str @@ -28,6 +28,15 @@ def query_bin_block( *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, ) -> bytes: ... + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> BinaryQueryResult: ... def query_opc( self, *, diff --git a/src/wavebench/transport/binary.py b/src/wavebench/transport/binary.py new file mode 100644 index 0000000..9f64412 --- /dev/null +++ b/src/wavebench/transport/binary.py @@ -0,0 +1,1102 @@ +"""Bounded binary framing and operation-budget primitives for scope RFC R1.3.""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import nullcontext +from dataclasses import dataclass +from threading import RLock +import time +from typing import Any +from uuid import uuid4 + +from wavebench.errors import TransportIOError + +from .contracts import ( + BinaryQueryResult, + BinaryResponseFraming, + CommandTransmission, + ReplayPolicy, + ResponseProgress, + Synchronization, + TransportPhase, +) + + +def _positive_int(value: object, *, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{label} must be a positive integer") + return value + + +def _non_negative_int(value: object, *, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{label} must be a non-negative integer") + return value + + +def _binding(value: object, *, label: str) -> str: + if not isinstance(value, str) or not value or value.strip() != value: + raise ValueError(f"{label} must be a non-empty trimmed string") + return value + + +@dataclass(frozen=True, slots=True, init=False, eq=False) +class BinaryQueryBudget: + """Opaque handle issued for exactly one core-owned binary ledger.""" + + _ledger: "BinaryQueryLedger" + _nonce: object + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("binary query budgets are issued by BinaryQueryLedger") + + @classmethod + def _issue(cls, ledger: "BinaryQueryLedger", nonce: object) -> "BinaryQueryBudget": + instance = object.__new__(cls) + object.__setattr__(instance, "_ledger", ledger) + object.__setattr__(instance, "_nonce", nonce) + return instance + + @property + def ledger_id(self) -> str: + return self._ledger.ledger_id + + +@dataclass(frozen=True, slots=True, init=False, eq=False) +class BinaryQueryReservation: + """One pre-reserved binary query; a failed exchange never refunds it.""" + + _ledger: "BinaryQueryLedger" + _nonce: object + _reservation_id: str + effective_max_bytes: int + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("binary query reservations are issued by BinaryQueryLedger") + + @classmethod + def _issue( + cls, + ledger: "BinaryQueryLedger", + nonce: object, + reservation_id: str, + effective_max_bytes: int, + ) -> "BinaryQueryReservation": + instance = object.__new__(cls) + object.__setattr__(instance, "_ledger", ledger) + object.__setattr__(instance, "_nonce", nonce) + object.__setattr__(instance, "_reservation_id", reservation_id) + object.__setattr__(instance, "effective_max_bytes", effective_max_bytes) + return instance + + +@dataclass(slots=True) +class _ReservationRecord: + effective_max_bytes: int + active: bool = True + + +class BinaryQueryLedger: + """Thread-safe response/operation/query/resynchronization budget ledger.""" + + __slots__ = ( + "ledger_id", + "context_id", + "operation_id", + "correlation_id", + "session_epoch", + "deadline", + "per_response_max_bytes", + "operation_max_bytes", + "query_max_count", + "resynchronization_max_bytes", + "transport_trailing", + "_remaining_operation_bytes", + "_remaining_query_count", + "_discarded_bytes", + "_active", + "_lock", + "_nonce", + "_reservations", + ) + + def __init__( + self, + *, + context_id: str, + operation_id: str, + correlation_id: str, + session_epoch: str, + deadline: float, + per_response_max_bytes: int, + operation_max_bytes: int, + query_max_count: int, + resynchronization_max_bytes: int, + transport_trailing: bytes = b"", + ledger_id: str | None = None, + ) -> None: + self.context_id = _binding(context_id, label="context_id") + self.operation_id = _binding(operation_id, label="operation_id") + self.correlation_id = _binding(correlation_id, label="correlation_id") + self.session_epoch = _binding(session_epoch, label="session_epoch") + if isinstance(deadline, bool) or not isinstance(deadline, (int, float)): + raise ValueError("deadline must be a monotonic timestamp") + self.deadline = float(deadline) + if self.deadline <= time.monotonic(): + raise ValueError("binary query ledger deadline must be in the future") + self.per_response_max_bytes = _positive_int( + per_response_max_bytes, label="per_response_max_bytes" + ) + self.operation_max_bytes = _positive_int( + operation_max_bytes, label="operation_max_bytes" + ) + if self.operation_max_bytes < self.per_response_max_bytes: + raise ValueError("operation_max_bytes must cover at least one full response") + self.query_max_count = _positive_int(query_max_count, label="query_max_count") + self.resynchronization_max_bytes = _non_negative_int( + resynchronization_max_bytes, + label="resynchronization_max_bytes", + ) + if not isinstance(transport_trailing, bytes): + raise TypeError("transport_trailing must be bytes") + if len(transport_trailing) > 16: + raise ValueError("transport_trailing cannot exceed 16 bytes") + self.transport_trailing = transport_trailing + self.ledger_id = _binding(ledger_id or uuid4().hex, label="ledger_id") + self._remaining_operation_bytes = self.operation_max_bytes + self._remaining_query_count = self.query_max_count + self._discarded_bytes = 0 + self._active = True + self._lock = RLock() + self._nonce = object() + self._reservations: dict[str, _ReservationRecord] = {} + + def issue_budget(self) -> BinaryQueryBudget: + with self._lock: + if not self._active: + raise ValueError("binary query ledger is inactive") + return BinaryQueryBudget._issue(self, self._nonce) + + def reserve( + self, + budget: BinaryQueryBudget, + *, + context_id: str, + operation_id: str, + correlation_id: str, + session_epoch: str, + max_bytes: int, + ) -> BinaryQueryReservation: + requested = _positive_int(max_bytes, label="max_bytes") + with self._lock: + self._validate_budget(budget) + self._validate_binding( + context_id=context_id, + operation_id=operation_id, + correlation_id=correlation_id, + session_epoch=session_epoch, + ) + if time.monotonic() >= self.deadline: + raise ValueError("binary query ledger deadline is exhausted") + if self._remaining_query_count < 1: + raise ValueError("binary query count budget is exhausted") + if requested > self.per_response_max_bytes: + raise ValueError("requested max_bytes exceeds the per-response budget") + effective = min( + requested, + self.per_response_max_bytes, + self._remaining_operation_bytes, + ) + if effective < 1: + raise ValueError("binary operation byte budget is exhausted") + self._remaining_query_count -= 1 + reservation_id = uuid4().hex + self._reservations[reservation_id] = _ReservationRecord(effective) + return BinaryQueryReservation._issue( + self, + self._nonce, + reservation_id, + effective, + ) + + def commit( + self, + reservation: BinaryQueryReservation, + result: BinaryQueryResult, + ) -> None: + if not isinstance(result, BinaryQueryResult): + raise TypeError("binary query result has an invalid type") + with self._lock: + record = self._consume_reservation(reservation) + if len(result.data) > record.effective_max_bytes: + self._active = False + raise ValueError("binary backend returned more bytes than reserved") + if result.transport_trailing_bytes != self.transport_trailing: + self._active = False + raise ValueError("binary transport trailing bytes violate the ledger profile") + self._remaining_operation_bytes -= len(result.data) + + def fail( + self, + reservation: BinaryQueryReservation, + *, + consumed_payload_bytes: int = 0, + discarded_bytes: int = 0, + synchronization_proven: bool, + ) -> None: + consumed = _non_negative_int( + consumed_payload_bytes, + label="consumed_payload_bytes", + ) + discarded = _non_negative_int(discarded_bytes, label="discarded_bytes") + if not isinstance(synchronization_proven, bool): + raise TypeError("synchronization_proven must be bool") + with self._lock: + record = self._consume_reservation(reservation) + if consumed > record.effective_max_bytes: + self._active = False + raise ValueError("failed binary query consumed more payload than reserved") + self._remaining_operation_bytes = max( + self._remaining_operation_bytes - consumed, + 0, + ) + self._discarded_bytes += discarded + if self._discarded_bytes > self.resynchronization_max_bytes: + self._active = False + raise ValueError("binary resynchronization budget is exceeded") + if not synchronization_proven: + self._active = False + + def invalidate(self) -> None: + with self._lock: + self._active = False + for record in self._reservations.values(): + record.active = False + + def snapshot(self) -> dict[str, object]: + with self._lock: + return { + "ledger_id": self.ledger_id, + "active": self._active, + "per_response_max_bytes": self.per_response_max_bytes, + "operation_max_bytes": self.operation_max_bytes, + "remaining_operation_bytes": self._remaining_operation_bytes, + "query_max_count": self.query_max_count, + "remaining_query_count": self._remaining_query_count, + "resynchronization_max_bytes": self.resynchronization_max_bytes, + "discarded_bytes": self._discarded_bytes, + "transport_trailing_bytes": len(self.transport_trailing), + } + + @property + def remaining_resynchronization_bytes(self) -> int: + with self._lock: + return max(self.resynchronization_max_bytes - self._discarded_bytes, 0) + + def _validate_budget(self, budget: BinaryQueryBudget) -> None: + if not isinstance(budget, BinaryQueryBudget): + raise TypeError("binary query budget has an invalid type") + if budget._ledger is not self or budget._nonce is not self._nonce: + raise ValueError("binary query budget is not owned by this ledger") + if not self._active: + raise ValueError("binary query ledger is inactive") + + def _validate_binding( + self, + *, + context_id: str, + operation_id: str, + correlation_id: str, + session_epoch: str, + ) -> None: + if ( + context_id != self.context_id + or operation_id != self.operation_id + or correlation_id != self.correlation_id + or session_epoch != self.session_epoch + ): + raise ValueError("binary query budget binding does not match the active operation") + + def _consume_reservation( + self, + reservation: BinaryQueryReservation, + ) -> _ReservationRecord: + if not isinstance(reservation, BinaryQueryReservation): + raise TypeError("binary query reservation has an invalid type") + if reservation._ledger is not self or reservation._nonce is not self._nonce: + raise ValueError("binary query reservation is not owned by this ledger") + if not self._active: + raise ValueError("binary query ledger is inactive") + record = self._reservations.get(reservation._reservation_id) + if record is None or not record.active: + raise ValueError("binary query reservation is no longer active") + record.active = False + return record + + +def parse_definite_block_response( + raw: bytes, + *, + max_bytes: int, + transport_trailing: bytes = b"", + allow_zero_payload: bool = False, +) -> BinaryQueryResult: + """Parse one already bounded raw IEEE 488.2 definite-block response.""" + + limit = _positive_int(max_bytes, label="max_bytes") + if not isinstance(raw, bytes): + raise TypeError("raw definite-block response must be bytes") + if not isinstance(transport_trailing, bytes): + raise TypeError("transport_trailing must be bytes") + if len(transport_trailing) > 16: + raise ValueError("transport_trailing cannot exceed 16 bytes") + if not isinstance(allow_zero_payload, bool): + raise TypeError("allow_zero_payload must be bool") + + def fail( + reason_code: str, + *, + progress: ResponseProgress, + synchronization: Synchronization, + consumed_bytes: int, + discarded_bytes: int = 0, + ) -> TransportIOError: + return TransportIOError( + "binary definite-block response validation failed", + operation="query_binary", + phase=TransportPhase.PARSING, + replay_policy=ReplayPolicy.NO_REPLAY, + command_transmission=CommandTransmission.SENT, + response_progress=progress, + synchronization=synchronization, + attempts=1, + reason_code=reason_code, + consumed_bytes=consumed_bytes, + discarded_bytes=discarded_bytes, + ) + + if len(raw) < 2 or raw[0:1] != b"#" or raw[1:2] not in b"123456789": + raise fail( + "binary_framing_error", + progress=ResponseProgress.PARTIAL if raw else ResponseProgress.NONE, + synchronization=Synchronization.LOST, + consumed_bytes=len(raw), + ) + digits = raw[1] - ord("0") + header_bytes = 2 + digits + if len(raw) < header_bytes: + raise fail( + "binary_truncated", + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + consumed_bytes=len(raw), + ) + length_field = raw[2:header_bytes] + if any(byte < ord("0") or byte > ord("9") for byte in length_field): + raise fail( + "binary_framing_error", + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + consumed_bytes=header_bytes, + ) + declared = int(length_field.decode("ascii")) + expected_total = header_bytes + declared + len(transport_trailing) + if declared == 0 and not allow_zero_payload: + raise fail( + "binary_framing_error", + progress=ResponseProgress.COMPLETE if len(raw) == expected_total else ResponseProgress.PARTIAL, + synchronization=( + Synchronization.PROVEN + if len(raw) == expected_total + else Synchronization.LOST + ), + consumed_bytes=min(len(raw), expected_total), + ) + if declared > limit: + boundary_proven = len(raw) == expected_total + if boundary_proven and transport_trailing: + boundary_proven = raw[-len(transport_trailing) :] == transport_trailing + raise fail( + "binary_limit_exceeded", + progress=ResponseProgress.COMPLETE if boundary_proven else ResponseProgress.PARTIAL, + synchronization=( + Synchronization.PROVEN if boundary_proven else Synchronization.LOST + ), + consumed_bytes=len(raw), + discarded_bytes=( + declared + len(transport_trailing) if boundary_proven else 0 + ), + ) + if len(raw) < expected_total: + raise fail( + "binary_truncated", + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + consumed_bytes=len(raw), + ) + if len(raw) > expected_total or ( + transport_trailing and raw[-len(transport_trailing) :] != transport_trailing + ): + raise fail( + "binary_transport_trailing_error", + progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.LOST, + consumed_bytes=len(raw), + ) + payload = raw[header_bytes : header_bytes + declared] + return BinaryQueryResult( + data=payload, + framing=BinaryResponseFraming.DEFINITE_BLOCK, + declared_length=declared, + framing_header_bytes=header_bytes, + consumed_bytes=expected_total, + transport_trailing_bytes=transport_trailing, + ) + + +def visa_message_boundary_supported(session: object) -> bool: + """Return whether a concrete VISA resource can report a message boundary. + + A backend or conformance fake can provide an explicit boolean + ``wavebench_message_boundary`` attribute. Real VISA resources are accepted + only for message-based ``INSTR`` resources; raw TCP sockets and serial + resources do not have a portable EOM contract. + """ + + explicit = getattr(session, "wavebench_message_boundary", None) + if isinstance(explicit, bool): + return explicit + resource_name = str( + getattr(session, "resource_name", None) + or getattr(session, "_resource_name", "") + ).upper() + if "::SOCKET" in resource_name or resource_name.startswith("ASRL"): + return False + resource_class = getattr(session, "resource_class", None) + if resource_class is None: + try: + resource_class = session.resource_info.resource_class # type: ignore[attr-defined] + except Exception: + resource_class = None + if str(resource_class or "").upper() != "INSTR": + return False + return resource_name.startswith(("GPIB", "TCPIP", "USB", "VXI", "PXI")) + + +def query_visa_binary_response( + *, + session: object, + write_query: Callable[[str], object], + command: str, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None, + replay: ReplayPolicy, + transport_trailing: bytes = b"", + resynchronization_max_bytes: int = 0, +) -> BinaryQueryResult: + """Execute one bounded binary query through a PyVISA-compatible resource. + + The function deliberately uses the low-level VISA read status rather than + ``read_raw()`` so a backend cannot allocate beyond the response, the + authorized resynchronization allowance, and one boundary-probe byte. It + never retries a sent query. + """ + + framing = BinaryResponseFraming(framing) + replay = ReplayPolicy(replay) + limit = _positive_int(max_bytes, label="max_bytes") + resync_limit = _non_negative_int( + resynchronization_max_bytes, + label="resynchronization_max_bytes", + ) + if not isinstance(command, str) or not command: + raise ValueError("binary query command must be a non-empty string") + if timeout_ms is not None: + _positive_int(timeout_ms, label="timeout_ms") + if not isinstance(transport_trailing, bytes): + raise TypeError("transport_trailing must be bytes") + if len(transport_trailing) > 16: + raise ValueError("transport_trailing cannot exceed 16 bytes") + if framing is BinaryResponseFraming.MESSAGE and transport_trailing: + raise ValueError("message framing cannot use transport trailing bytes") + if replay is ReplayPolicy.READ_CONTINUATION_ONLY: + raise _binary_transport_error( + "binary_continuation_unsupported", + phase=TransportPhase.BEFORE_SEND, + replay=replay, + transmission=CommandTransmission.NOT_SENT, + progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + consumed_bytes=0, + ) + if not visa_message_boundary_supported(session): + raise _binary_transport_error( + "binary_framing_unsupported", + phase=TransportPhase.BEFORE_SEND, + replay=replay, + transmission=CommandTransmission.NOT_SENT, + progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + consumed_bytes=0, + ) + + if not callable(write_query): + raise _binary_transport_error( + "binary_framing_unsupported", + phase=TransportPhase.BEFORE_SEND, + replay=replay, + transmission=CommandTransmission.NOT_SENT, + progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + consumed_bytes=0, + ) + if not _has_low_level_visa_read(session): + raise _binary_transport_error( + "binary_framing_unsupported", + phase=TransportPhase.BEFORE_SEND, + replay=replay, + transmission=CommandTransmission.NOT_SENT, + progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + consumed_bytes=0, + ) + + original_timeout: object = _MISSING + original_termination: object = _MISSING + settings_changed = False + transmitted = False + progress_state = {"consumed": 0} + primary: BaseException | None = None + result: BinaryQueryResult | None = None + try: + original_timeout = getattr(session, "timeout") + original_termination = getattr(session, "read_termination") + if timeout_ms is not None: + setattr(session, "timeout", timeout_ms) + setattr(session, "read_termination", None) + settings_changed = True + except Exception as exc: + _restore_visa_read_settings( + session, + original_timeout=original_timeout, + original_termination=original_termination, + ) + raise _binary_transport_error( + "binary_framing_unsupported", + phase=TransportPhase.BEFORE_SEND, + replay=replay, + transmission=CommandTransmission.NOT_SENT, + progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + consumed_bytes=0, + ) from exc + + try: + try: + written = write_query(command) + if written == 0: + raise _binary_transport_error( + None, + phase=TransportPhase.BEFORE_SEND, + replay=replay, + transmission=CommandTransmission.NOT_SENT, + progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + consumed_bytes=0, + ) + transmitted = True + except TransportIOError: + raise + except Exception as exc: + raise _binary_transport_error( + "binary_timeout" if _is_timeout_error(exc) else None, + phase=TransportPhase.SENDING, + replay=replay, + transmission=CommandTransmission.UNKNOWN, + progress=ResponseProgress.NONE, + synchronization=Synchronization.UNPROVEN, + attempts=1, + consumed_bytes=0, + ) from exc + + if framing is BinaryResponseFraming.DEFINITE_BLOCK: + result = _read_visa_definite_block( + session, + max_bytes=limit, + transport_trailing=transport_trailing, + resynchronization_max_bytes=resync_limit, + replay=replay, + progress_state=progress_state, + ) + else: + result = _read_visa_message( + session, + max_bytes=limit, + resynchronization_max_bytes=resync_limit, + replay=replay, + progress_state=progress_state, + ) + except BaseException as exc: + if isinstance(exc, TransportIOError): + primary = exc + elif transmitted: + primary = _binary_transport_error( + "binary_timeout" if _is_timeout_error(exc) else "binary_truncated", + phase=TransportPhase.READING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=( + ResponseProgress.PARTIAL + if progress_state["consumed"] + else ResponseProgress.NONE + ), + synchronization=Synchronization.UNPROVEN, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) + primary.__cause__ = exc + else: + primary = exc + + restore_error: BaseException | None = None + if settings_changed: + try: + _restore_visa_read_settings( + session, + original_timeout=original_timeout, + original_termination=original_termination, + ) + except BaseException as exc: + restore_error = exc + if restore_error is not None: + raise _binary_transport_error( + "binary_transport_trailing_error", + phase=TransportPhase.READING, + replay=replay, + transmission=( + CommandTransmission.SENT if transmitted else CommandTransmission.UNKNOWN + ), + progress=( + ResponseProgress.COMPLETE + if result is not None + else ResponseProgress.PARTIAL + if progress_state["consumed"] + else ResponseProgress.NONE + ), + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) from (primary or restore_error) + if primary is not None: + raise primary + assert result is not None + return result + + +_MISSING = object() + + +def _restore_visa_read_settings( + session: object, + *, + original_timeout: object, + original_termination: object, +) -> None: + failures: list[BaseException] = [] + if original_termination is not _MISSING: + try: + setattr(session, "read_termination", original_termination) + except BaseException as exc: + failures.append(exc) + if original_timeout is not _MISSING: + try: + setattr(session, "timeout", original_timeout) + except BaseException as exc: + failures.append(exc) + if failures: + raise RuntimeError("failed to restore VISA read settings") from failures[0] + + +def _has_low_level_visa_read(session: object) -> bool: + visalib = getattr(session, "visalib", None) + return ( + visalib is not None + and callable(getattr(visalib, "read", None)) + and getattr(session, "session", None) is not None + and hasattr(session, "timeout") + and hasattr(session, "read_termination") + ) + + +def _read_visa_definite_block( + session: object, + *, + max_bytes: int, + transport_trailing: bytes, + resynchronization_max_bytes: int, + replay: ReplayPolicy, + progress_state: dict[str, int], +) -> BinaryQueryResult: + prefix, prefix_eom = _visa_read_exact( + session, + 2, + replay=replay, + progress_state=progress_state, + ) + if prefix_eom: + raise _binary_truncated_error(replay, progress_state["consumed"], proven=True) + if prefix[0:1] != b"#" or prefix[1:2] not in b"123456789": + raise _binary_transport_error( + "binary_framing_error", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) + digits = prefix[1] - ord("0") + length_field, length_eom = _visa_read_exact( + session, + digits, + replay=replay, + progress_state=progress_state, + ) + if any(byte < ord("0") or byte > ord("9") for byte in length_field): + raise _binary_transport_error( + "binary_framing_error", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) + declared = int(length_field.decode("ascii")) + if declared == 0: + raise _binary_transport_error( + "binary_framing_error", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) + if length_eom: + raise _binary_truncated_error(replay, progress_state["consumed"], proven=True) + header_bytes = 2 + digits + remaining_response = declared + len(transport_trailing) + if declared > max_bytes: + if remaining_response > resynchronization_max_bytes: + raise _binary_transport_error( + "binary_limit_exceeded", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + discarded_bytes=0, + ) + discarded = _visa_read_expected_message( + session, + remaining_response, + replay=replay, + progress_state=progress_state, + discarded_base=remaining_response, + ) + trailing = discarded[declared:] + if trailing != transport_trailing: + raise _binary_transport_error( + "binary_transport_trailing_error", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + discarded_bytes=remaining_response, + ) + raise _binary_transport_error( + "binary_limit_exceeded", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.PROVEN, + attempts=1, + consumed_bytes=progress_state["consumed"], + discarded_bytes=remaining_response, + ) + + response = _visa_read_expected_message( + session, + remaining_response, + replay=replay, + progress_state=progress_state, + ) + payload = response[:declared] + trailing = response[declared:] + if trailing != transport_trailing: + raise _binary_transport_error( + "binary_transport_trailing_error", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) + return BinaryQueryResult( + data=payload, + framing=BinaryResponseFraming.DEFINITE_BLOCK, + declared_length=declared, + framing_header_bytes=header_bytes, + consumed_bytes=progress_state["consumed"], + transport_trailing_bytes=trailing, + ) + + +def _read_visa_message( + session: object, + *, + max_bytes: int, + resynchronization_max_bytes: int, + replay: ReplayPolicy, + progress_state: dict[str, int], +) -> BinaryQueryResult: + # Request one byte beyond the authorized payload and resynchronization + # ceiling. VISA implementations commonly report MAX_CNT when EOM lands on + # the final requested byte; the bounded probe makes a valid response + # strictly shorter than the request and therefore proves its boundary. + capacity = max_bytes + resynchronization_max_bytes + 1 + response, eom = _visa_read_chunk(session, capacity) + if len(response) > capacity: + raise _binary_transport_error( + "binary_framing_error", + phase=TransportPhase.READING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) + progress_state["consumed"] += len(response) + if eom: + if len(response) <= max_bytes: + return BinaryQueryResult( + data=response, + framing=BinaryResponseFraming.MESSAGE, + declared_length=None, + framing_header_bytes=0, + consumed_bytes=len(response), + ) + raise _binary_transport_error( + "binary_limit_exceeded", + phase=TransportPhase.READING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.PROVEN, + attempts=1, + consumed_bytes=len(response), + discarded_bytes=len(response) - max_bytes, + ) + raise _binary_transport_error( + "binary_limit_exceeded" if len(response) > max_bytes else "binary_truncated", + phase=TransportPhase.READING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + discarded_bytes=max(progress_state["consumed"] - max_bytes, 0), + ) + + +def _visa_read_expected_message( + session: object, + count: int, + *, + replay: ReplayPolicy, + progress_state: dict[str, int], + discarded_base: int = 0, +) -> bytes: + """Read an exact expected tail while proving EOM with one bounded probe.""" + + response, eom = _visa_read_chunk(session, count + 1) + progress_state["consumed"] += len(response) + if len(response) > count: + raise _binary_transport_error( + "binary_transport_trailing_error", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + discarded_bytes=discarded_base + len(response) - count, + ) + if len(response) < count: + raise _binary_truncated_error( + replay, + progress_state["consumed"], + proven=eom, + ) + if not eom: + raise _binary_transport_error( + "binary_transport_trailing_error", + phase=TransportPhase.PARSING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + discarded_bytes=discarded_base, + ) + return response + + +def _visa_read_exact( + session: object, + count: int, + *, + replay: ReplayPolicy, + progress_state: dict[str, int], +) -> tuple[bytes, bool]: + if count == 0: + return b"", False + response = bytearray() + while len(response) < count: + chunk, eom = _visa_read_chunk(session, count - len(response)) + if len(chunk) > count - len(response): + raise _binary_transport_error( + "binary_framing_error", + phase=TransportPhase.READING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + consumed_bytes=progress_state["consumed"], + ) + response.extend(chunk) + progress_state["consumed"] += len(chunk) + if eom: + if len(response) < count: + raise _binary_truncated_error( + replay, + progress_state["consumed"], + proven=True, + ) + return bytes(response), True + if not chunk: + raise _binary_truncated_error( + replay, + progress_state["consumed"], + proven=False, + ) + return bytes(response), False + + +def _visa_read_chunk(session: object, count: int) -> tuple[bytes, bool]: + visalib = session.visalib # type: ignore[attr-defined] + warning_context: Any = nullcontext() + try: + from pyvisa.constants import StatusCode + + ignore_warning = getattr(session, "ignore_warning", None) + if callable(ignore_warning): + warning_context = ignore_warning( + StatusCode.success_device_not_present, + StatusCode.success_max_count_read, + ) + except Exception: + pass + with warning_context: + chunk, status = visalib.read(session.session, count) # type: ignore[attr-defined] + data = bytes(chunk) + status_name = str(getattr(status, "name", status)).lower() + max_count = "success_max_count_read" in status_name + return data, not max_count + + +def _binary_truncated_error( + replay: ReplayPolicy, + consumed_bytes: int, + *, + proven: bool, +) -> TransportIOError: + return _binary_transport_error( + "binary_truncated", + phase=TransportPhase.READING, + replay=replay, + transmission=CommandTransmission.SENT, + progress=ResponseProgress.PARTIAL if consumed_bytes else ResponseProgress.NONE, + synchronization=(Synchronization.PROVEN if proven else Synchronization.UNPROVEN), + attempts=1, + consumed_bytes=consumed_bytes, + ) + + +def _binary_transport_error( + reason_code: str | None, + *, + phase: TransportPhase, + replay: ReplayPolicy, + transmission: CommandTransmission, + progress: ResponseProgress, + synchronization: Synchronization, + attempts: int, + consumed_bytes: int, + discarded_bytes: int = 0, +) -> TransportIOError: + return TransportIOError( + "bounded binary transport exchange failed", + operation="query_binary", + phase=phase, + replay_policy=replay, + command_transmission=transmission, + response_progress=progress, + synchronization=synchronization, + attempts=attempts, + reason_code=reason_code, + consumed_bytes=consumed_bytes, + discarded_bytes=discarded_bytes, + ) + + +def _is_timeout_error(exc: BaseException) -> bool: + error_code = getattr(exc, "error_code", None) + error_name = getattr(error_code, "name", "") + token = f"{type(exc).__name__} {error_code!s} {error_name!s}".lower() + return "timeout" in token or "tmo" in token + + +__all__ = [ + "BinaryQueryBudget", + "BinaryQueryLedger", + "BinaryQueryReservation", + "parse_definite_block_response", + "query_visa_binary_response", + "visa_message_boundary_supported", +] diff --git a/src/wavebench/transport/contracts.py b/src/wavebench/transport/contracts.py index 069cb58..9b82e10 100644 --- a/src/wavebench/transport/contracts.py +++ b/src/wavebench/transport/contracts.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from enum import StrEnum @@ -37,7 +38,77 @@ class Synchronization(StrEnum): LOST = "lost" +class BinaryResponseFraming(StrEnum): + """Framing modes whose response boundary can be proven by the transport.""" + + DEFINITE_BLOCK = "definite_block" + MESSAGE = "message" + + +@dataclass(frozen=True, slots=True) +class BinaryQueryResult: + """A complete binary response with explicit framing and byte accounting.""" + + data: bytes + framing: BinaryResponseFraming + declared_length: int | None + framing_header_bytes: int + consumed_bytes: int + transport_trailing_bytes: bytes = b"" + synchronization: Synchronization = Synchronization.PROVEN + + def __post_init__(self) -> None: + if not isinstance(self.data, bytes): + raise TypeError("binary query data must be bytes") + object.__setattr__(self, "framing", BinaryResponseFraming(self.framing)) + object.__setattr__(self, "synchronization", Synchronization(self.synchronization)) + if self.synchronization is not Synchronization.PROVEN: + raise ValueError("successful binary responses must prove synchronization") + if not isinstance(self.transport_trailing_bytes, bytes): + raise TypeError("binary transport trailing data must be bytes") + for label, value in ( + ("framing_header_bytes", self.framing_header_bytes), + ("consumed_bytes", self.consumed_bytes), + ): + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{label} must be a non-negative integer") + + if self.framing is BinaryResponseFraming.DEFINITE_BLOCK: + if ( + isinstance(self.declared_length, bool) + or not isinstance(self.declared_length, int) + or self.declared_length < 0 + ): + raise ValueError("definite-block responses require a non-negative declared length") + if self.declared_length != len(self.data): + raise ValueError("definite-block declared length must equal payload length") + if not 3 <= self.framing_header_bytes <= 11: + raise ValueError("definite-block framing header length must be in 3..11") + length_digits = self.framing_header_bytes - 2 + if self.declared_length >= 10**length_digits: + raise ValueError("declared length cannot fit the observed framing header") + expected = ( + self.framing_header_bytes + + len(self.data) + + len(self.transport_trailing_bytes) + ) + if self.consumed_bytes != expected: + raise ValueError("definite-block consumed byte accounting is inconsistent") + return + + if self.declared_length is not None: + raise ValueError("message-framed responses cannot declare a payload length") + if self.framing_header_bytes != 0: + raise ValueError("message-framed responses cannot have a framing header") + if self.transport_trailing_bytes != b"": + raise ValueError("message-framed responses cannot have transport trailing bytes") + if self.consumed_bytes != len(self.data): + raise ValueError("message-framed consumed bytes must equal payload length") + + __all__ = [ + "BinaryQueryResult", + "BinaryResponseFraming", "CommandTransmission", "ReplayPolicy", "ResponseProgress", diff --git a/src/wavebench/transport/guarded.py b/src/wavebench/transport/guarded.py index 1492074..656ac6d 100644 --- a/src/wavebench/transport/guarded.py +++ b/src/wavebench/transport/guarded.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +import time from typing import Any from wavebench.errors import AccessDeniedError, SessionHealthError, TransportIOError @@ -10,7 +11,10 @@ from wavebench.services.resource_lease import ResourceLease from .base import InstrumentTransport +from .binary import BinaryQueryBudget from .contracts import ( + BinaryQueryResult, + BinaryResponseFraming, CommandTransmission, ReplayPolicy, ResponseProgress, @@ -159,10 +163,22 @@ def query_float_list( self._check_access("query_float_list") self.counters.query_calls += 1 authorization = self._gate("query_float_list") + effective_timeout_ms = timeout_ms + if authorization is not None: + remaining_ms = int( + max(authorization.deadline - time.monotonic(), 0.0) * 1000.0 + ) + if remaining_ms < 1: + raise self._deadline_preflight_error("query_float_list", replay) + effective_timeout_ms = min( + authorization.io_timeout_ms, + remaining_ms, + timeout_ms if timeout_ms is not None else authorization.io_timeout_ms, + ) try: result = self.inner.query_float_list( command, - timeout_ms=timeout_ms, + timeout_ms=effective_timeout_ms, replay=replay, ) except Exception as exc: @@ -180,6 +196,10 @@ def query_bin_block( with self.session_state.transaction_lock: self._check_access("query_bin_block") self.counters.binary_query_calls += 1 + active = self.session_state._active_authorization() + if active is not None and active.binary_budget is not None: + self.counters.blocked_binary_query_calls += 1 + raise self._binary_preflight_error("binary_legacy_entry_unsupported", replay) authorization = self._gate("query_bin_block") try: result = self.inner.query_bin_block(command, replay=replay) @@ -189,6 +209,147 @@ def query_bin_block( self._record_success(authorization, "query_bin_block") return result + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> BinaryQueryResult: + with self.session_state.transaction_lock: + replay = ReplayPolicy(replay) + framing = BinaryResponseFraming(framing) + self._check_access("query_binary") + self.counters.binary_query_calls += 1 + if replay is ReplayPolicy.READ_CONTINUATION_ONLY: + self.counters.blocked_binary_query_calls += 1 + raise self._binary_preflight_error("binary_continuation_unsupported", replay) + backend_query = getattr(self.inner, "query_binary", None) + if not callable(backend_query): + self.counters.blocked_binary_query_calls += 1 + raise self._binary_preflight_error("binary_framing_unsupported", replay) + active = self.session_state._active_authorization() + if active is None or not isinstance(active.binary_budget, BinaryQueryBudget): + self.counters.blocked_binary_query_calls += 1 + raise self._binary_preflight_error("binary_budget_missing", replay) + authorization = self._gate("query_binary") + assert authorization is active + remaining_ms = int( + max(authorization.deadline - time.monotonic(), 0.0) * 1000.0 + ) + if remaining_ms < 1: + self.counters.blocked_binary_query_calls += 1 + raise self._deadline_preflight_error("query_binary", replay) + effective_timeout_ms = min(authorization.io_timeout_ms, remaining_ms) + budget = active.binary_budget + ledger = budget._ledger + try: + reservation = ledger.reserve( + budget, + context_id=active.context_id or "", + operation_id=active.operation_id, + correlation_id=active.correlation_id or "", + session_epoch=active.epoch_id, + max_bytes=max_bytes, + ) + except (TypeError, ValueError) as exc: + self.counters.blocked_binary_query_calls += 1 + raise self._binary_preflight_error("binary_budget_rejected", replay) from exc + try: + backend_kwargs: dict[str, object] = { + "framing": framing, + "max_bytes": reservation.effective_max_bytes, + "timeout_ms": effective_timeout_ms, + "replay": replay, + } + if bool( + getattr(self.inner, "_wavebench_binary_budget_parameters", False) + ): + backend_kwargs.update( + _transport_trailing=ledger.transport_trailing, + _resynchronization_max_bytes=( + ledger.remaining_resynchronization_bytes + ), + ) + result = backend_query(command, **backend_kwargs) + except Exception as exc: + if isinstance(exc, TransportIOError): + try: + ledger.fail( + reservation, + # Structured failures expose total consumed bytes, + # not a trusted payload/header split. Debit the + # bounded maximum conservatively so failure cannot + # increase the remaining operation allowance. + consumed_payload_bytes=min( + exc.consumed_bytes or 0, + reservation.effective_max_bytes, + ), + discarded_bytes=exc.discarded_bytes or 0, + synchronization_proven=( + exc.synchronization is Synchronization.PROVEN + ), + ) + except (TypeError, ValueError): + ledger.invalidate() + if self.session_state.health in { + SessionHealth.HEALTHY, + SessionHealth.UNCERTAIN, + }: + self.session_state.degrade( + SessionHealth.POISONED, + reason="binary_budget_violation", + ) + else: + ledger.invalidate() + self._transition_after_failure(exc, authorization) + if self.session_state.health is SessionHealth.POISONED: + self._close_poisoned_backend() + raise + if not isinstance(result, BinaryQueryResult) or result.framing is not framing: + try: + ledger.fail( + reservation, + synchronization_proven=False, + ) + except (TypeError, ValueError): + ledger.invalidate() + error = TransportIOError( + "binary backend violated the result contract", + operation="query_binary", + phase=TransportPhase.PARSING, + replay_policy=replay, + command_transmission=CommandTransmission.SENT, + response_progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.LOST, + attempts=1, + reason_code="binary_contract_violation", + ) + self._transition_after_failure(error, authorization) + self._close_poisoned_backend() + raise error + try: + ledger.commit(reservation, result) + except (TypeError, ValueError) as exc: + error = TransportIOError( + "binary response exceeded the authorized contract", + operation="query_binary", + phase=TransportPhase.PARSING, + replay_policy=replay, + command_transmission=CommandTransmission.SENT, + response_progress=ResponseProgress.COMPLETE, + synchronization=Synchronization.LOST, + attempts=1, + reason_code="binary_contract_violation", + consumed_bytes=result.consumed_bytes, + ) + self._transition_after_failure(error, authorization) + self._close_poisoned_backend() + raise error from exc + self._record_success(authorization, "query_binary") + return result + def query_opc( self, *, @@ -250,7 +411,7 @@ def _check_access(self, operation: str, *, write: bool = False) -> None: self.counters.blocked_binary_write_requests += 1 raise self._denied(operation) if not write and self.access == "disabled": - if operation == "query_bin_block": + if operation in {"query_bin_block", "query_binary"}: self.counters.blocked_binary_query_calls += 1 else: self.counters.blocked_query_calls += 1 @@ -331,5 +492,64 @@ def _session_denied(self, io_kind: str, *, reason: str | None = None) -> Session epoch_id=self.session_state.epoch_id, ) + @staticmethod + def _binary_preflight_error( + reason_code: str, + replay: ReplayPolicy, + ) -> TransportIOError: + return TransportIOError( + "binary query was rejected before transmission", + operation="query_binary", + phase=TransportPhase.BEFORE_SEND, + replay_policy=replay, + command_transmission=CommandTransmission.NOT_SENT, + response_progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + reason_code=reason_code, + consumed_bytes=0, + discarded_bytes=0, + ) + + @staticmethod + def _deadline_preflight_error( + operation: str, + replay: ReplayPolicy, + ) -> TransportIOError: + return TransportIOError( + "operation deadline was exhausted before transmission", + operation=operation, + phase=TransportPhase.BEFORE_SEND, + replay_policy=replay, + command_transmission=CommandTransmission.NOT_SENT, + response_progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + reason_code="deadline_exhausted", + consumed_bytes=0, + discarded_bytes=0, + ) + + def _close_poisoned_backend(self) -> None: + """Close a framing-lost backend while preserving the poisoned diagnosis.""" + + if self._closed: + return + self._closed = True + try: + close = getattr(self.inner, "close", None) + if callable(close): + close() + except Exception: + # The triggering structured failure remains primary. Backend close + # evidence is intentionally not allowed to replace it here. + pass + finally: + if self.release_lease_on_close and self.lease is not None: + try: + self.lease.release() + except Exception: + pass + __all__ = ["AUDIT_SCHEMA", "AuditCounters", "GuardedAuditedTransport"] diff --git a/src/wavebench/transport/pyvisa_transport.py b/src/wavebench/transport/pyvisa_transport.py index a920be8..81aded2 100644 --- a/src/wavebench/transport/pyvisa_transport.py +++ b/src/wavebench/transport/pyvisa_transport.py @@ -8,7 +8,10 @@ from wavebench.errors import ConnectionError, SessionCloseError, TransportIOError from wavebench.logging import CommandLogger +from .binary import query_visa_binary_response from .contracts import ( + BinaryQueryResult, + BinaryResponseFraming, CommandTransmission, ReplayPolicy, ResponseProgress, @@ -19,6 +22,8 @@ @dataclass class PyVisaTransport: + _wavebench_binary_budget_parameters = True + resource: str resource_manager: Any session: Any @@ -243,6 +248,59 @@ def read_once() -> bytes: ) return data + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + _transport_trailing: bytes = b"", + _resynchronization_max_bytes: int = 0, + ) -> BinaryQueryResult: + replay = ReplayPolicy(replay) + framing = BinaryResponseFraming(framing) + if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1: + raise ValueError("max_bytes must be a positive integer") + if timeout_ms is not None and ( + isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or timeout_ms < 1 + ): + raise ValueError("timeout_ms must be a positive integer") + self._reject_continuation("query_binary", replay) + self.logger.record("query_binary", command) + started = time.perf_counter() + try: + result = query_visa_binary_response( + session=self.session, + write_query=getattr(self.session, "write", None), # type: ignore[arg-type] + command=command, + framing=framing, + max_bytes=max_bytes, + timeout_ms=timeout_ms, + replay=replay, + transport_trailing=_transport_trailing, + resynchronization_max_bytes=_resynchronization_max_bytes, + ) + except TransportIOError as exc: + self._record_query_telemetry( + operation="query_binary", + started=started, + status=f"failed:{exc.reason_code or 'transport_io_error'}", + replay=replay.value, + bytes_count=exc.consumed_bytes, + ) + raise + self.logger.record("response", f"") + self._record_query_telemetry( + operation="query_binary", + started=started, + status="ok", + replay=replay.value, + bytes_count=result.consumed_bytes, + ) + return result + def _record_query_telemetry( self, *, diff --git a/src/wavebench/transport/rsinstrument_transport.py b/src/wavebench/transport/rsinstrument_transport.py index 74208c7..ea39337 100644 --- a/src/wavebench/transport/rsinstrument_transport.py +++ b/src/wavebench/transport/rsinstrument_transport.py @@ -9,7 +9,10 @@ from wavebench.errors import ConnectionError, SessionCloseError, TransportIOError from wavebench.logging import CommandLogger +from .binary import query_visa_binary_response from .contracts import ( + BinaryQueryResult, + BinaryResponseFraming, CommandTransmission, ReplayPolicy, ResponseProgress, @@ -45,6 +48,8 @@ def _open_rsinstrument_session( @dataclass class RsInstrumentTransport: + _wavebench_binary_budget_parameters = True + resource: str session: Any logger: CommandLogger @@ -225,6 +230,69 @@ def read_once() -> bytes: ) return data + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + _transport_trailing: bytes = b"", + _resynchronization_max_bytes: int = 0, + ) -> BinaryQueryResult: + replay = ReplayPolicy(replay) + framing = BinaryResponseFraming(framing) + if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1: + raise ValueError("max_bytes must be a positive integer") + if timeout_ms is not None and ( + isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or timeout_ms < 1 + ): + raise ValueError("timeout_ms must be a positive integer") + self._reject_continuation("query_binary", replay) + get_session_handle = getattr(self.session, "get_session_handle", None) + try: + raw_session = get_session_handle() if callable(get_session_handle) else object() + except Exception: + raw_session = object() + self.logger.record("query_binary", command) + started = time.perf_counter() + try: + result = query_visa_binary_response( + session=raw_session, + # Use the underlying VISA write. RsInstrument.write_str() may + # run an automatic status query after the command, which would + # interleave text I/O before the binary response is consumed. + write_query=getattr(raw_session, "write", None), # type: ignore[arg-type] + command=command, + framing=framing, + max_bytes=max_bytes, + timeout_ms=timeout_ms, + replay=replay, + transport_trailing=_transport_trailing, + resynchronization_max_bytes=_resynchronization_max_bytes, + ) + except TransportIOError as exc: + self._record_query_telemetry( + operation="query_binary", + started=started, + status=f"failed:{exc.reason_code or 'transport_io_error'}", + progress=None, + replay=replay, + bytes_count=exc.consumed_bytes, + ) + raise + self.logger.record("response", f"") + self._record_query_telemetry( + operation="query_binary", + started=started, + status="ok", + progress=None, + replay=replay, + bytes_count=result.consumed_bytes, + ) + return result + @contextmanager def _temporary_timeout(self, timeout_ms: int | None): if timeout_ms is None: diff --git a/src/wavebench/transport/serial_transport.py b/src/wavebench/transport/serial_transport.py index fdda737..b6cf991 100644 --- a/src/wavebench/transport/serial_transport.py +++ b/src/wavebench/transport/serial_transport.py @@ -9,6 +9,8 @@ from wavebench.logging import CommandLogger from .contracts import ( + BinaryQueryResult, + BinaryResponseFraming, CommandTransmission, ReplayPolicy, ResponseProgress, @@ -270,6 +272,48 @@ def query_bin_block( attempts=0, ) + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + _transport_trailing: bytes = b"", + _resynchronization_max_bytes: int = 0, + ) -> BinaryQueryResult: + replay = ReplayPolicy(replay) + BinaryResponseFraming(framing) + if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1: + raise ValueError("max_bytes must be a positive integer") + if timeout_ms is not None and ( + isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or timeout_ms < 1 + ): + raise ValueError("timeout_ms must be a positive integer") + if not isinstance(_transport_trailing, bytes): + raise TypeError("transport trailing must be bytes") + if ( + isinstance(_resynchronization_max_bytes, bool) + or not isinstance(_resynchronization_max_bytes, int) + or _resynchronization_max_bytes < 0 + ): + raise ValueError("resynchronization limit must be a non-negative integer") + self._reject_continuation("query_binary", replay) + raise TransportIOError( + "serial has not passed the R1.3 binary framing conformance gate", + operation="query_binary", + phase=TransportPhase.BEFORE_SEND, + replay_policy=replay, + command_transmission=CommandTransmission.NOT_SENT, + response_progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + reason_code="binary_framing_unsupported", + consumed_bytes=0, + discarded_bytes=0, + ) + def query_opc( self, *, diff --git a/src/wavebench/transport/session.py b/src/wavebench/transport/session.py index 5a606be..eaabea8 100644 --- a/src/wavebench/transport/session.py +++ b/src/wavebench/transport/session.py @@ -36,6 +36,7 @@ class SessionPurpose(StrEnum): "query", "query_float_list", "query_bin_block", + "query_binary", "query_opc", "write", "write_bytes", @@ -65,14 +66,20 @@ def _normalize_field_set( @dataclass class _AuthorizationRecord: + authorization_id: str epoch_id: str operation_id: str purpose: SessionPurpose allowed_io: frozenset[str] fields: frozenset[str] deadline: float + io_timeout_ms: int remaining_steps: int evidence_fields: dict[str, frozenset[str]] + context_id: str | None = None + correlation_id: str | None = None + phase: str | None = None + binary_budget: object | None = None successful_steps: int = 0 successful_io: dict[str, int] = field(default_factory=dict) successful_fields: set[str] = field(default_factory=set) @@ -105,6 +112,10 @@ def _issue( def epoch_id(self) -> str: return self._record.epoch_id + @property + def authorization_id(self) -> str: + return self._record.authorization_id + @property def operation_id(self) -> str: return self._record.operation_id @@ -121,6 +132,30 @@ def allowed_io(self) -> frozenset[str]: def fields(self) -> frozenset[str]: return self._record.fields + @property + def context_id(self) -> str | None: + return self._record.context_id + + @property + def correlation_id(self) -> str | None: + return self._record.correlation_id + + @property + def phase(self) -> str | None: + return self._record.phase + + @property + def deadline(self) -> float: + return self._record.deadline + + @property + def io_timeout_ms(self) -> int: + return self._record.io_timeout_ms + + @property + def binary_budget(self) -> object | None: + return self._record.binary_budget + class InstrumentSessionState: """Health and verified-field state bound to one concrete connection epoch.""" @@ -245,6 +280,24 @@ def _complete_verification( "reason": reason, } + def _invalidate_verified_fields( + self, + fields: Iterable[str], + *, + _issuer: object | None = None, + ) -> None: + """Drop stale configuration evidence without changing communication health.""" + + if _issuer is not self._authorization_nonce: + raise ValueError("verified-field invalidation is coordinator-owned") + normalized = _normalize_field_set( + fields, + label="invalidated fields", + allow_empty=True, + ) + with self.transaction_lock: + self._verified_fields.difference_update(normalized) + def _active_authorization(self) -> SessionAuthorization | None: return self._authorization_context.get() @@ -336,10 +389,81 @@ def authorize( timeout_ms: int, max_steps: int, evidence_fields: dict[str, Iterable[str]] | None = None, + context_id: str | None = None, + correlation_id: str | None = None, + phase: str | None = None, + absolute_deadline: float | None = None, + binary_budget: object | None = None, ) -> Iterator[SessionAuthorization]: purpose = SessionPurpose(purpose) if purpose not in {SessionPurpose.RECOVERY, SessionPurpose.VERIFICATION}: raise ValueError("only recovery or verification can receive session authorization") + with self._authorize( + operation_id=operation_id, + purpose=purpose, + allowed_io=allowed_io, + fields=fields, + timeout_ms=timeout_ms, + max_steps=max_steps, + evidence_fields=evidence_fields, + context_id=context_id, + correlation_id=correlation_id, + phase=phase, + absolute_deadline=absolute_deadline, + binary_budget=binary_budget, + ) as authorization: + yield authorization + + @contextmanager + def authorize_normal( + self, + *, + operation_id: str, + allowed_io: Iterable[str], + fields: Iterable[str], + timeout_ms: int, + max_steps: int, + context_id: str, + correlation_id: str, + phase: str, + absolute_deadline: float, + binary_budget: object | None = None, + ) -> Iterator[SessionAuthorization]: + """Install the bounded normal-operation side of a core phase bridge.""" + + with self._authorize( + operation_id=operation_id, + purpose=SessionPurpose.NORMAL, + allowed_io=allowed_io, + fields=fields, + timeout_ms=timeout_ms, + max_steps=max_steps, + evidence_fields=None, + context_id=context_id, + correlation_id=correlation_id, + phase=phase, + absolute_deadline=absolute_deadline, + binary_budget=binary_budget, + ) as authorization: + yield authorization + + @contextmanager + def _authorize( + self, + *, + operation_id: str, + purpose: SessionPurpose, + allowed_io: Iterable[str], + fields: Iterable[str], + timeout_ms: int, + max_steps: int, + evidence_fields: dict[str, Iterable[str]] | None, + context_id: str | None, + correlation_id: str | None, + phase: str | None, + absolute_deadline: float | None, + binary_budget: object | None, + ) -> Iterator[SessionAuthorization]: if not operation_id or _SAFE_REASON.fullmatch(operation_id) is None: raise ValueError("operation_id must be a short safe code") normalized_io = frozenset(allowed_io) @@ -356,10 +480,29 @@ def authorize( if not evidence <= normalized_fields: raise ValueError("evidence fields exceed the authorized field scope") normalized_evidence[io_kind] = evidence - if timeout_ms < 1: + if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or timeout_ms < 1: raise ValueError("authorization timeout_ms must be >= 1") - if max_steps < 1: + if isinstance(max_steps, bool) or not isinstance(max_steps, int) or max_steps < 1: raise ValueError("authorization max_steps must be >= 1") + context_values = (context_id, correlation_id, phase) + if any(value is not None for value in context_values): + if any( + not isinstance(value, str) or _SAFE_REASON.fullmatch(value) is None + for value in context_values + ): + raise ValueError("phase authorizations require safe context/correlation/phase tokens") + if binary_budget is not None and purpose is not SessionPurpose.NORMAL: + raise ValueError("binary budgets are only valid for normal operation phases") + now = time.monotonic() + deadline = now + (timeout_ms / 1000.0) + if absolute_deadline is not None: + if isinstance(absolute_deadline, bool) or not isinstance( + absolute_deadline, (int, float) + ): + raise ValueError("absolute_deadline must be a monotonic timestamp") + deadline = float(absolute_deadline) + if deadline <= now: + raise ValueError("session authorization deadline is exhausted") # Hold the operation lock for the complete dynamic authorization range. with self.state.transaction_lock: @@ -367,17 +510,25 @@ def authorize( raise ValueError("cannot authorize I/O on a poisoned session") if self.state.health is SessionHealth.CLOSED: raise ValueError("cannot authorize I/O on a closed session") + if purpose is SessionPurpose.NORMAL and self.state.health is not SessionHealth.HEALTHY: + raise ValueError("normal operations require a healthy session") if self.state._active_authorization() is not None: raise ValueError("nested session authorizations are not allowed") record = _AuthorizationRecord( + authorization_id=uuid4().hex, epoch_id=self.state.epoch_id, operation_id=operation_id, purpose=purpose, allowed_io=normalized_io, fields=normalized_fields, evidence_fields=normalized_evidence, - deadline=time.monotonic() + (timeout_ms / 1000.0), + deadline=deadline, + io_timeout_ms=timeout_ms, remaining_steps=max_steps, + context_id=context_id, + correlation_id=correlation_id, + phase=phase, + binary_budget=binary_budget, ) authorization = SessionAuthorization._issue(record, self.state._authorization_nonce) token: Token[SessionAuthorization | None] = self.state._authorization_context.set( @@ -433,6 +584,14 @@ def record_evidence( raise ValueError("only verification authorization can record evidence") self.state._record_authorized_evidence(authorization, io_kind, fields) + def invalidate_verified_fields(self, fields: Iterable[str]) -> None: + """Invalidate fields changed by a core-authorized normal operation.""" + + self.state._invalidate_verified_fields( + fields, + _issuer=self.state._authorization_nonce, + ) + __all__ = [ "InstrumentSessionState", diff --git a/tests/test_capability_explain.py b/tests/test_capability_explain.py index 7b314b2..e42226c 100644 --- a/tests/test_capability_explain.py +++ b/tests/test_capability_explain.py @@ -93,6 +93,20 @@ def test_capability_cli_can_explain_partial_scope_status_offline() -> None: assert "missing_optional_capabilities=scope.snapshot\n" in stdout.getvalue() +def test_scope_extension_explain_includes_timeout_binary_and_recovery_contract() -> None: + result = explain_operation( + "scope.fetch_trace", + descriptor=_descriptor("scope.fetch_trace", kind="scope"), + ) + + assert result.status == "supported" + assert result.spec is not None + assert result.spec.operation_timeout_ms == 60_000 + assert result.spec.binary_response_max_bytes == 8_388_608 + assert "scope.waveform_byte_order" in result.spec.verification_fields + assert "scope.waveform_transfer_window" in result.spec.cleanup_verification_fields + + def test_capability_cli_lists_local_candidates_without_installing() -> None: stdout = io.StringIO() with redirect_stdout(stdout): diff --git a/tests/test_instrument_session_state.py b/tests/test_instrument_session_state.py index 2896477..c0de02c 100644 --- a/tests/test_instrument_session_state.py +++ b/tests/test_instrument_session_state.py @@ -111,7 +111,14 @@ def test_authorization_rejects_string_fields_and_nested_ranges() -> None: pass -def test_verification_authorization_expires_before_completion() -> None: +def test_verification_authorization_expires_before_completion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = [100.0] + monkeypatch.setattr( + "wavebench.transport.session.time.monotonic", + lambda: now[0], + ) state = InstrumentSessionState() coordinator = SessionTransactionCoordinator(state) with coordinator.authorize( @@ -125,8 +132,6 @@ def test_verification_authorization_expires_before_completion() -> None: ) as authorization: state._record_authorized_success(authorization, "query") coordinator.record_evidence(authorization, "query", {"identity"}) - import time - - time.sleep(0.01) + now[0] += 0.002 with pytest.raises(ValueError, match="expired"): coordinator.complete_verification(authorization) diff --git a/tests/test_package.py b/tests/test_package.py index a04fd37..5815c48 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,6 +1,7 @@ import json import tempfile import unittest +from dataclasses import replace from pathlib import Path from unittest.mock import patch @@ -17,6 +18,7 @@ from wavebench.data.package import safe_label from wavebench.drivers.rtm2032 import WaveformData, WaveformHeader from wavebench.logging import CommandLogger +from wavebench.instruments.registry import build_instrument_registry from wavebench.services.scope_service import ScopeService @@ -67,6 +69,107 @@ def test_capture_waveform_writes_screenshot_when_enabled(self): metadata = json.loads((result.package_dir / "metadata.json").read_text(encoding="utf-8")) self.assertEqual(metadata["files"]["screenshot"], str(result.screenshot_path)) + def test_new_screenshot_capability_rejects_legacy_embedding_before_io(self): + with tempfile.TemporaryDirectory() as tmp: + config = WaveBenchConfig( + connection=ConnectionConfig( + backend="lan", + resource="TCPIP::fake::INSTR", + timeout_ms=10_000, + opc_timeout_ms=30_000, + ), + scope=ScopeConfig( + driver="rtm2032", + model_hint=None, + default_channel=1, + reset_before_run=False, + check_errors=True, + ), + autoscale=AutoscaleConfig(wait_opc=True, check_errors=True), + waveform=WaveformConfig(format="real", byte_order="lsbf", points="DEF"), + output=OutputConfig( + directory=Path(tmp), + package_naming="timestamp_label", + save_csv=False, + save_npy=False, + save_json=True, + save_commands_log=False, + save_screenshot=True, + ), + source_path=Path(tmp) / "wavebench.toml", + ) + original = build_instrument_registry(include_entry_points=False).resolve("rtm2032") + descriptor = replace( + original, + capabilities=( + *(capability for capability in original.capabilities if capability != "scope.screenshot"), + "scope.screenshot_v2", + ), + ) + service = ScopeService( + config=config, + logger=CommandLogger(), + descriptor=descriptor, + ) + + with patch.object(service, "_open_scope") as open_scope: + with self.assertRaisesRegex(Exception, "field-closure runtime"): + service.capture_waveform(channel=1, label="blocked") + + open_scope.assert_not_called() + self.assertEqual(list(Path(tmp).iterdir()), []) + + def test_legacy_embedding_remains_available_during_dual_capability_migration(self): + with tempfile.TemporaryDirectory() as tmp: + config = WaveBenchConfig( + connection=ConnectionConfig( + backend="lan", + resource="TCPIP::fake::INSTR", + timeout_ms=10_000, + opc_timeout_ms=30_000, + ), + scope=ScopeConfig( + driver="rtm2032", + model_hint=None, + default_channel=1, + reset_before_run=False, + check_errors=True, + ), + autoscale=AutoscaleConfig(wait_opc=True, check_errors=True), + waveform=WaveformConfig( + format="real", + byte_order="lsbf", + points="DEF", + ), + output=OutputConfig( + directory=Path(tmp), + package_naming="timestamp_label", + save_csv=False, + save_npy=False, + save_json=True, + save_commands_log=False, + save_screenshot=True, + ), + source_path=Path(tmp) / "wavebench.toml", + ) + original = build_instrument_registry(include_entry_points=False).resolve( + "rtm2032" + ) + descriptor = replace( + original, + capabilities=(*original.capabilities, "scope.screenshot_v2"), + ) + service = ScopeService( + config=config, + logger=CommandLogger(), + descriptor=descriptor, + ) + + self.assertEqual( + service._legacy_capture_screenshot_capability(), + "scope.screenshot", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_scope_binary_contract.py b/tests/test_scope_binary_contract.py new file mode 100644 index 0000000..f036fb4 --- /dev/null +++ b/tests/test_scope_binary_contract.py @@ -0,0 +1,661 @@ +from __future__ import annotations + +from contextlib import nullcontext +import time + +import pytest + +from wavebench.errors import TransportIOError +from wavebench.services.scope_extension_specs import EXPERIMENTAL_SCOPE_OPERATION_SPECS +from wavebench.services.scope_phase_coordinator import ( + OperationPhase, + ScopeOperationContextCoordinator, +) +from wavebench.logging import CommandLogger +from wavebench.transport.binary import BinaryQueryLedger, parse_definite_block_response +from wavebench.transport.contracts import ( + BinaryQueryResult, + BinaryResponseFraming, + ReplayPolicy, + Synchronization, +) +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.pyvisa_transport import PyVisaTransport +from wavebench.transport.rsinstrument_transport import RsInstrumentTransport +from wavebench.transport.serial_transport import SerialTransport +from wavebench.transport.session import SessionHealth + + +def test_definite_block_parser_preserves_real_header_and_exact_trailing() -> None: + result = parse_definite_block_response( + b"#3004data\n", + max_bytes=8, + transport_trailing=b"\n", + ) + + assert result.data == b"data" + assert result.declared_length == 4 + assert result.framing_header_bytes == 5 + assert result.consumed_bytes == 10 + assert result.transport_trailing_bytes == b"\n" + + +@pytest.mark.parametrize( + ("raw", "reason"), + [ + (b"#0payload", "binary_framing_error"), + (b"#2x1", "binary_framing_error"), + (b"#24", "binary_truncated"), + (b"#14dataextra", "binary_transport_trailing_error"), + ], +) +def test_definite_block_parser_rejects_malformed_or_unbounded_responses( + raw: bytes, + reason: str, +) -> None: + with pytest.raises(TransportIOError) as raised: + parse_definite_block_response(raw, max_bytes=32) + + assert raised.value.reason_code == reason + assert raised.value.consumed_bytes is not None + + +def test_definite_block_limit_error_preserves_structured_byte_evidence() -> None: + with pytest.raises(TransportIOError) as raised: + parse_definite_block_response(b"#14data", max_bytes=3) + + error = raised.value + assert error.reason_code == "binary_limit_exceeded" + assert error.synchronization is Synchronization.PROVEN + assert error.consumed_bytes == 7 + assert error.discarded_bytes == 4 + copied = error.with_attempts(2) + assert copied.reason_code == error.reason_code + assert copied.consumed_bytes == error.consumed_bytes + assert copied.to_envelope().details["discarded_bytes"] == 4 + + +def test_binary_result_enforces_message_and_definite_accounting() -> None: + message = BinaryQueryResult( + data=b"png", + framing=BinaryResponseFraming.MESSAGE, + declared_length=None, + framing_header_bytes=0, + consumed_bytes=3, + ) + assert message.synchronization is Synchronization.PROVEN + + with pytest.raises(ValueError, match="consumed"): + BinaryQueryResult( + data=b"x", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + declared_length=1, + framing_header_bytes=3, + consumed_bytes=99, + ) + + +def _ledger(*, query_count: int = 2, total: int = 8) -> BinaryQueryLedger: + return BinaryQueryLedger( + context_id="ctx", + operation_id="scope.fetch_trace", + correlation_id="corr", + session_epoch="epoch", + deadline=time.monotonic() + 10, + per_response_max_bytes=4, + operation_max_bytes=total, + query_max_count=query_count, + resynchronization_max_bytes=2, + transport_trailing=b"", + ) + + +def test_binary_ledger_binds_context_and_never_refunds_failed_queries() -> None: + ledger = _ledger(query_count=1, total=4) + budget = ledger.issue_budget() + with pytest.raises(ValueError, match="per-response"): + ledger.reserve( + budget, + context_id="ctx", + operation_id="scope.fetch_trace", + correlation_id="corr", + session_epoch="epoch", + max_bytes=5, + ) + with pytest.raises(ValueError, match="binding"): + ledger.reserve( + budget, + context_id="other", + operation_id="scope.fetch_trace", + correlation_id="corr", + session_epoch="epoch", + max_bytes=4, + ) + + reservation = ledger.reserve( + budget, + context_id="ctx", + operation_id="scope.fetch_trace", + correlation_id="corr", + session_epoch="epoch", + max_bytes=4, + ) + ledger.fail(reservation, synchronization_proven=True) + assert ledger.snapshot()["remaining_query_count"] == 0 + with pytest.raises(ValueError, match="count budget"): + ledger.reserve( + budget, + context_id="ctx", + operation_id="scope.fetch_trace", + correlation_id="corr", + session_epoch="epoch", + max_bytes=1, + ) + + +def test_binary_ledger_is_cumulative_and_invalidates_on_lost_sync() -> None: + ledger = _ledger(query_count=2, total=6) + budget = ledger.issue_budget() + first = ledger.reserve( + budget, + context_id="ctx", + operation_id="scope.fetch_trace", + correlation_id="corr", + session_epoch="epoch", + max_bytes=4, + ) + ledger.commit( + first, + BinaryQueryResult( + data=b"1234", + framing=BinaryResponseFraming.MESSAGE, + declared_length=None, + framing_header_bytes=0, + consumed_bytes=4, + ), + ) + second = ledger.reserve( + budget, + context_id="ctx", + operation_id="scope.fetch_trace", + correlation_id="corr", + session_epoch="epoch", + max_bytes=4, + ) + assert second.effective_max_bytes == 2 + ledger.fail(second, discarded_bytes=1, synchronization_proven=False) + assert ledger.snapshot()["active"] is False + + +class _BinaryBackend: + resource = "fake" + + def __init__(self, *, wrong_result: bool = False) -> None: + self.calls = 0 + self.closed = 0 + self.wrong_result = wrong_result + self.timeout_ms: int | None = None + + def record_event(self, direction: str, text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + return "ok" + + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + _transport_trailing: bytes = b"", + _resynchronization_max_bytes: int = 0, + ) -> BinaryQueryResult: + self.calls += 1 + self.timeout_ms = timeout_ms + result_framing = ( + BinaryResponseFraming.MESSAGE if self.wrong_result else framing + ) + if result_framing is BinaryResponseFraming.MESSAGE: + return BinaryQueryResult( + data=b"data", + framing=result_framing, + declared_length=None, + framing_header_bytes=0, + consumed_bytes=4, + ) + return BinaryQueryResult( + data=b"data", + framing=result_framing, + declared_length=4, + framing_header_bytes=3, + consumed_bytes=7, + transport_trailing_bytes=_transport_trailing, + ) + + def close(self) -> None: + self.closed += 1 + + +def _binary_context(transport: GuardedAuditedTransport) -> ScopeOperationContextCoordinator: + context = ScopeOperationContextCoordinator( + session_state=transport.session_state, + spec=EXPERIMENTAL_SCOPE_OPERATION_SPECS["scope.fetch_trace"], + connection_timeout_ms=1_000, + enabled=True, + ) + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity"}, + max_steps=1, + ) + with context.authorize_phase(preflight) as authorization: + transport.query("*IDN?") + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity"}, + ) + return context + + +def test_guarded_binary_query_requires_context_budget_and_debits_it() -> None: + backend = _BinaryBackend() + guarded = GuardedAuditedTransport(backend) # type: ignore[arg-type] + with pytest.raises(TransportIOError) as raised: + guarded.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=8, + ) + assert raised.value.reason_code == "binary_budget_missing" + assert backend.calls == 0 + + context = _binary_context(guarded) + phase = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query_binary"}, + fields={"scope.waveform_transfer_window"}, + max_steps=1, + ) + with context.authorize_phase(phase): + result = guarded.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=8, + ) + assert result.data == b"data" + assert context.binary_ledger is not None + assert context.binary_ledger.snapshot()["remaining_query_count"] == 255 + assert backend.timeout_ms is not None + assert 1 <= backend.timeout_ms <= 1_000 + context.complete() + + +def test_guarded_binary_contract_violation_poison_closes_backend() -> None: + backend = _BinaryBackend(wrong_result=True) + guarded = GuardedAuditedTransport(backend) # type: ignore[arg-type] + context = _binary_context(guarded) + phase = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query_binary"}, + fields={"scope.waveform_transfer_window"}, + max_steps=1, + ) + with context.authorize_phase(phase): + with pytest.raises(TransportIOError, match="violated"): + guarded.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=8, + ) + assert guarded.session_state.health is SessionHealth.POISONED + assert backend.closed == 1 + + +@pytest.mark.parametrize( + "transport", + [ + PyVisaTransport("fake", object(), object(), CommandLogger()), + RsInstrumentTransport("fake", object(), CommandLogger()), + SerialTransport("fake", object(), CommandLogger()), + ], +) +def test_existing_backends_reject_new_binary_contract_before_send(transport) -> None: + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=16, + ) + assert raised.value.reason_code == "binary_framing_unsupported" + assert raised.value.attempts == 0 + + +class _FakeVisaLib: + def __init__(self, owner: "_FakeVisaSession") -> None: + self.owner = owner + + def read(self, handle: object, count: int): + from pyvisa.constants import StatusCode + + assert handle is self.owner.session + if self.owner.read_error is not None: + raise self.owner.read_error + chunk = self.owner.response[:count] + self.owner.response = self.owner.response[len(chunk) :] + status = ( + StatusCode.success_max_count_read + if len(chunk) == count + else StatusCode.success + ) + return chunk, status + + +class _FakeVisaSession: + resource_class = "INSTR" + wavebench_message_boundary: bool | None = None + + def __init__( + self, + response: bytes, + *, + resource_name: str = "TCPIP::example::INSTR", + ) -> None: + self.response = response + self.resource_name = resource_name + self.timeout = 12_345 + self.read_termination = "\n" + self.session = object() + self.visalib = _FakeVisaLib(self) + self.commands: list[str] = [] + self.read_error: BaseException | None = None + + def write(self, command: str) -> int: + self.commands.append(command) + return len(command) + + def query(self, command: str) -> str: + self.commands.append(command) + return "EXAMPLE,SCOPE,1,1" + + def ignore_warning(self, *statuses): + return nullcontext() + + +class _RestoreFailureVisaSession(_FakeVisaSession): + def __setattr__(self, name: str, value: object) -> None: + if ( + name == "read_termination" + and value == "\n" + and getattr(self, "fail_termination_restore", False) + ): + raise RuntimeError("restore failed") + super().__setattr__(name, value) + + +class _FakeRsSession: + def __init__(self, raw: _FakeVisaSession) -> None: + self.raw = raw + self.write_str_calls = 0 + + def get_session_handle(self) -> _FakeVisaSession: + return self.raw + + def write_str(self, command: str) -> None: + self.write_str_calls += 1 + self.raw.commands.append(command) + + +@pytest.mark.parametrize("backend", ["pyvisa", "rsinstrument"]) +def test_real_backends_stream_bounded_definite_blocks_and_restore_settings(backend: str) -> None: + raw = _FakeVisaSession(b"#14data\n") + rs_session: _FakeRsSession | None = None + if backend == "pyvisa": + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + else: + rs_session = _FakeRsSession(raw) + transport = RsInstrumentTransport("fake", rs_session, CommandLogger()) + + result = transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + timeout_ms=250, + _transport_trailing=b"\n", + ) + + assert result.data == b"data" + assert result.framing_header_bytes == 3 + assert result.consumed_bytes == 8 + assert result.transport_trailing_bytes == b"\n" + assert raw.commands == ["DATA?"] + assert raw.response == b"" + assert raw.timeout == 12_345 + assert raw.read_termination == "\n" + if rs_session is not None: + assert rs_session.write_str_calls == 0 + + +@pytest.mark.parametrize("backend", ["pyvisa", "rsinstrument"]) +def test_visa_message_framing_requires_and_uses_proven_eom(backend: str) -> None: + raw = _FakeVisaSession(b"png") + if backend == "pyvisa": + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + else: + transport = RsInstrumentTransport("fake", _FakeRsSession(raw), CommandLogger()) + + result = transport.query_binary( + "DISPLAY?", + framing=BinaryResponseFraming.MESSAGE, + max_bytes=8, + timeout_ms=250, + ) + + assert result.data == b"png" + assert result.framing is BinaryResponseFraming.MESSAGE + assert result.consumed_bytes == 3 + assert raw.commands == ["DISPLAY?"] + + +def test_visa_message_eom_at_exact_payload_limit_uses_bounded_probe() -> None: + raw = _FakeVisaSession(b"data") + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + result = transport.query_binary( + "DISPLAY?", + framing=BinaryResponseFraming.MESSAGE, + max_bytes=4, + ) + + assert result.data == b"data" + assert result.consumed_bytes == 4 + assert raw.response == b"" + + +def test_guarded_context_drives_real_pyvisa_backend_with_opaque_budget() -> None: + raw = _FakeVisaSession(b"#14data") + backend = PyVisaTransport("fake", object(), raw, CommandLogger()) + guarded = GuardedAuditedTransport(backend) + context = _binary_context(guarded) + phase = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query_binary"}, + fields={"scope.waveform_transfer_window"}, + max_steps=1, + ) + + with context.authorize_phase(phase): + result = guarded.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + ) + + assert result.data == b"data" + assert raw.commands == ["*IDN?", "DATA?"] + assert context.binary_ledger is not None + assert context.binary_ledger.snapshot()["remaining_query_count"] == 255 + context.complete() + + +@pytest.mark.parametrize( + "framing", + [BinaryResponseFraming.DEFINITE_BLOCK, BinaryResponseFraming.MESSAGE], +) +def test_pyvisa_binary_framing_rejects_socket_resource_before_send(framing) -> None: + raw = _FakeVisaSession(b"png", resource_name="TCPIP::example::5025::SOCKET") + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DISPLAY?", + framing=framing, + max_bytes=8, + ) + + assert raised.value.reason_code == "binary_framing_unsupported" + assert raised.value.attempts == 0 + assert raw.commands == [] + + +def test_definite_block_over_limit_uses_only_authorized_resynchronization() -> None: + raw = _FakeVisaSession(b"#15abcde") + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + _resynchronization_max_bytes=5, + ) + + assert raised.value.reason_code == "binary_limit_exceeded" + assert raised.value.synchronization is Synchronization.PROVEN + assert raised.value.discarded_bytes == 5 + assert raw.response == b"" + assert raw.timeout == 12_345 + assert raw.read_termination == "\n" + + +def test_definite_block_over_limit_without_resync_stops_at_header() -> None: + raw = _FakeVisaSession(b"#15abcde") + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + ) + + assert raised.value.reason_code == "binary_limit_exceeded" + assert raised.value.synchronization is Synchronization.LOST + assert raised.value.consumed_bytes == 3 + assert raw.response == b"abcde" + + +def test_definite_block_resync_counts_profiled_transport_trailing() -> None: + raw = _FakeVisaSession(b"#15abcde\n") + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + _transport_trailing=b"\n", + _resynchronization_max_bytes=6, + ) + + assert raised.value.reason_code == "binary_limit_exceeded" + assert raised.value.synchronization is Synchronization.PROVEN + assert raised.value.discarded_bytes == 6 + assert raw.response == b"" + + +def test_definite_block_rejects_unprofiled_trailing_bytes() -> None: + raw = _FakeVisaSession(b"#14dataextra") + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + ) + + assert raised.value.reason_code == "binary_transport_trailing_error" + assert raised.value.synchronization is Synchronization.LOST + assert raised.value.discarded_bytes == 1 + assert raw.response == b"xtra" + + +def test_binary_timeout_is_structured_and_restores_visa_settings() -> None: + from pyvisa.constants import StatusCode + from pyvisa.errors import VisaIOError + + raw = _FakeVisaSession(b"") + raw.read_error = VisaIOError(StatusCode.error_timeout) + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + timeout_ms=250, + ) + + assert raised.value.reason_code == "binary_timeout" + assert raised.value.synchronization is Synchronization.UNPROVEN + assert raw.timeout == 12_345 + assert raw.read_termination == "\n" + + +def test_termination_restore_failure_is_lost_synchronization() -> None: + raw = _RestoreFailureVisaSession(b"#14data") + raw.fail_termination_restore = True + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + timeout_ms=250, + ) + + assert raised.value.reason_code == "binary_transport_trailing_error" + assert raised.value.synchronization is Synchronization.LOST + assert raised.value.response_progress.value == "complete" + + +def test_guarded_transport_poison_closes_on_termination_restore_failure() -> None: + raw = _RestoreFailureVisaSession(b"#14data") + backend = PyVisaTransport("fake", object(), raw, CommandLogger()) + guarded = GuardedAuditedTransport(backend) + context = _binary_context(guarded) + raw.fail_termination_restore = True + phase = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query_binary"}, + fields={"scope.waveform_transfer_window"}, + max_steps=1, + ) + + with context.authorize_phase(phase): + with pytest.raises(TransportIOError): + guarded.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + ) + + assert guarded.session_state.health is SessionHealth.POISONED + assert guarded._closed is True + context.complete() diff --git a/tests/test_scope_extension_cli.py b/tests/test_scope_extension_cli.py new file mode 100644 index 0000000..8562269 --- /dev/null +++ b/tests/test_scope_extension_cli.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import Mock, patch +import zlib + +import numpy as np + +from wavebench.cli import main +from wavebench.errors import DataError +from wavebench.instruments.scope_extensions import ( + ScopeScreenshot, + ScopeScreenshotRequest, +) +from wavebench.transport.contracts import BinaryResponseFraming + + +def _png(width: int = 2, height: int = 3) -> bytes: + def chunk(kind: bytes, payload: bytes) -> bytes: + crc = zlib.crc32(kind + payload) & 0xFFFFFFFF + return len(payload).to_bytes(4, "big") + kind + payload + crc.to_bytes(4, "big") + + ihdr = width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x02\x00\x00\x00" + rows = b"".join(b"\x00" + b"\x00" * (width * 3) for _ in range(height)) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") + ) + + +class _Result: + def __init__(self, value: object, payload: dict[str, object]) -> None: + self.value = value + self.payload = payload + + def as_dict(self) -> dict[str, object]: + return dict(self.payload) + + +def test_scope_screenshot_cli_writes_payload_and_versioned_artifact(tmp_path) -> None: + request = ScopeScreenshotRequest(menu_mode="exclude", color_mode="color") + screenshot = ScopeScreenshot( + data=_png(), + media_type="image/png", + width_px=2, + height_px=3, + requested=request, + effective=request, + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ) + result = _Result( + screenshot, + { + "schema": "wavebench.scope.result.v1", + "result": {"payload_bytes": len(screenshot.data)}, + "diagnostics": {"schema": "wavebench.scope.operation.v1"}, + }, + ) + service = Mock() + service.screenshot_v2.return_value = result + output = tmp_path / "screen.png" + artifact = tmp_path / "screen.json" + stdout = io.StringIO() + + with patch("wavebench.cli._load_service", return_value=service), redirect_stdout(stdout): + code = main( + [ + "scope", + "screenshot", + "capture", + "--output", + str(output), + "--artifact", + str(artifact), + "--menu-mode", + "exclude", + "--color-mode", + "color", + ] + ) + + assert code == 0 + assert output.read_bytes() == screenshot.data + persisted = json.loads(artifact.read_text(encoding="utf-8")) + assert persisted["schema"] == "wavebench.scope.result.v1" + assert persisted["files"] == { + "screenshot": "screen.png", + "artifact": "screen.json", + } + assert str(tmp_path) not in artifact.read_text(encoding="utf-8") + service.screenshot_v2.assert_called_once() + assert service.screenshot_v2.call_args.args[0] == request + + +def test_scope_trace_cli_writes_npy_and_forwards_typed_reference(tmp_path) -> None: + values = np.array([1.0, 2.0], dtype=np.float64) + result = _Result( + SimpleNamespace(values=values), + { + "schema": "wavebench.scope.result.v1", + "result": {"integrity": {"points": 2}}, + "diagnostics": {"schema": "wavebench.scope.operation.v1"}, + }, + ) + # The CLI validates the stable result type before writing. + from wavebench.instruments.scope_extensions import ( + ScopeAxisMetadata, + ScopeTraceData, + ScopeTraceMetadata, + ScopeTraceRef, + ) + + source = ScopeTraceRef("analog", index=1) + trace = ScopeTraceData( + ScopeTraceMetadata( + source=source, + x_axis=ScopeAxisMetadata("time", "s", 0.0, 1e-6, 2), + y_unit="v", + y_semantics="linear", + value_encoding="real", + operation="identity", + fetchable=True, + ), + values, + ) + result.value = trace + service = Mock() + service.fetch_trace.return_value = result + output = tmp_path / "trace.npy" + artifact = tmp_path / "trace.json" + + with patch("wavebench.cli._load_service", return_value=service): + code = main( + [ + "scope", + "trace", + "fetch", + "--kind", + "analog", + "--index", + "1", + "--points", + "2", + "--output", + str(output), + "--artifact", + str(artifact), + ] + ) + + assert code == 0 + assert np.array_equal(np.load(output, allow_pickle=False), values) + persisted = json.loads(artifact.read_text(encoding="utf-8")) + assert persisted["schema"] == "wavebench.scope.result.v1" + assert persisted["files"] == {"trace": "trace.npy", "artifact": "trace.json"} + called_source = service.fetch_trace.call_args.args[0] + assert called_source == source + assert service.fetch_trace.call_args.kwargs["points"] == 2 + + +def test_scope_acquisition_cli_uses_action_specific_methods() -> None: + service = Mock() + service.start_acquisition.return_value = _Result( + object(), + {"schema": "wavebench.scope.result.v1", "result": {"phase": "ready"}}, + ) + + with patch("wavebench.cli._load_service", return_value=service): + code = main( + [ + "scope", + "acquisition", + "start", + "--trigger-mode", + "normal", + "--error-policy", + "disabled", + ] + ) + + assert code == 0 + request = service.start_acquisition.call_args.args[0] + assert request.trigger_mode == "normal" + assert service.start_acquisition.call_args.kwargs["error_check"].policy == "disabled" + + +def test_scope_screenshot_cli_persists_failure_diagnostics(tmp_path) -> None: + error = DataError("invalid screenshot payload") + error.scope_operation_diagnostics = { + "schema": "wavebench.scope.operation.v1", + "operation": "scope.screenshot_v2", + "correlation_id": "corr", + "session_health_after": "healthy", + } + service = Mock() + service.screenshot_v2.side_effect = error + output = tmp_path / "screen.png" + artifact = tmp_path / "screen.json" + + with patch("wavebench.cli._load_service", return_value=service): + code = main( + [ + "scope", + "screenshot", + "capture", + "--output", + str(output), + "--artifact", + str(artifact), + ] + ) + + assert code == error.exit_code + assert not output.exists() + persisted = json.loads(artifact.read_text(encoding="utf-8")) + assert persisted["status"] == "failed" + assert persisted["diagnostics"]["correlation_id"] == "corr" + assert persisted["error"]["schema"] == "wavebench.error.v1" + + +def test_scope_screenshot_cli_persists_local_output_failure(tmp_path) -> None: + request = ScopeScreenshotRequest(menu_mode="exclude", color_mode="color") + screenshot = ScopeScreenshot( + data=_png(), + media_type="image/png", + width_px=2, + height_px=3, + requested=request, + effective=request, + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ) + result = _Result( + screenshot, + { + "schema": "wavebench.scope.result.v1", + "result": {"payload_bytes": len(screenshot.data)}, + "diagnostics": { + "schema": "wavebench.scope.operation.v1", + "operation": "scope.screenshot_v2", + }, + }, + ) + service = Mock() + service.screenshot_v2.return_value = result + output = tmp_path / "screen.png" + artifact = tmp_path / "screen.json" + original_open = Path.open + + def open_with_output_failure(path, mode="r", *args, **kwargs): + if path == output and mode == "xb": + raise OSError("simulated output failure") + return original_open(path, mode, *args, **kwargs) + + with ( + patch("wavebench.cli._load_service", return_value=service), + patch.object(Path, "open", open_with_output_failure), + ): + code = main( + [ + "scope", + "screenshot", + "capture", + "--output", + str(output), + "--artifact", + str(artifact), + ] + ) + + assert code == 2 + assert not output.exists() + persisted = json.loads(artifact.read_text(encoding="utf-8")) + assert persisted["status"] == "failed" + assert persisted["diagnostics"]["operation"] == "scope.screenshot_v2" + assert str(tmp_path) not in artifact.read_text(encoding="utf-8") + + +def test_scope_screenshot_cli_removes_output_when_artifact_write_fails(tmp_path) -> None: + request = ScopeScreenshotRequest(menu_mode="exclude", color_mode="color") + screenshot = ScopeScreenshot( + data=_png(), + media_type="image/png", + width_px=2, + height_px=3, + requested=request, + effective=request, + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ) + result = _Result( + screenshot, + { + "schema": "wavebench.scope.result.v1", + "result": {"payload_bytes": len(screenshot.data)}, + "diagnostics": { + "schema": "wavebench.scope.operation.v1", + "operation": "scope.screenshot_v2", + }, + }, + ) + service = Mock() + service.screenshot_v2.return_value = result + output = tmp_path / "screen.png" + artifact = tmp_path / "screen.json" + stdout = io.StringIO() + original_open = Path.open + + def open_with_artifact_failure(path, mode="r", *args, **kwargs): + if path == artifact and mode == "x": + raise OSError("simulated artifact failure") + return original_open(path, mode, *args, **kwargs) + + with ( + patch("wavebench.cli._load_service", return_value=service), + patch.object(Path, "open", open_with_artifact_failure), + redirect_stdout(stdout), + ): + code = main( + [ + "--json", + "scope", + "screenshot", + "capture", + "--output", + str(output), + "--artifact", + str(artifact), + ] + ) + + assert code == 2 + assert not output.exists() + assert not artifact.exists() + error = json.loads(stdout.getvalue()) + assert error["scope_artifact"] == { + "status": "failed", + "reason_code": "write_failed", + } + assert error["operation_diagnostics"]["operation"] == "scope.screenshot_v2" diff --git a/tests/test_scope_extension_models.py b/tests/test_scope_extension_models.py new file mode 100644 index 0000000..53b0edd --- /dev/null +++ b/tests/test_scope_extension_models.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import numpy as np +import pytest +import zlib + +import wavebench.instruments as public_instruments +from wavebench.instruments.scope_extensions import ( + DriverErrorRecord, + ErrorDrainResult, + ScopeAcquisitionCompletion, + ScopeAcquisitionControlBaseline, + ScopeAcquisitionControlProfile, + ScopeAcquisitionControlSnapshot, + ScopeAcquisitionRunState, + ScopeAxisMetadata, + ScopeDescriptorExtensions, + ScopeScreenshot, + ScopeScreenshotProfile, + ScopeScreenshotRequest, + ScopeScreenshotStateSnapshot, + ScopeScreenshotVariant, + ScopeTraceData, + ScopeTraceMetadata, + ScopeTraceProfile, + ScopeTraceRef, + ScopeTraceTransferStateSnapshot, + validate_acquisition_completion, +) +from wavebench.transport.contracts import BinaryResponseFraming + + +def _png(width: int = 2, height: int = 3) -> bytes: + def chunk(kind: bytes, payload: bytes) -> bytes: + crc = zlib.crc32(kind + payload) & 0xFFFFFFFF + return len(payload).to_bytes(4, "big") + kind + payload + crc.to_bytes(4, "big") + + ihdr = width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x02\x00\x00\x00" + rows = b"".join(b"\x00" + b"\x00" * (width * 3) for _ in range(height)) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") + ) + + +def test_scope_extension_types_are_exported_from_stable_instrument_api() -> None: + assert public_instruments.ScopeTraceData is ScopeTraceData + assert public_instruments.ScopeScreenshotProfile is ScopeScreenshotProfile + assert public_instruments.DriverErrorRecord is DriverErrorRecord + + +def test_screenshot_profile_uses_exact_request_tuples_and_fixed_limits() -> None: + request = ScopeScreenshotRequest(menu_mode="device", color_mode="device") + variant = ScopeScreenshotVariant( + request=request, + media_type="image/png", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + response_max_bytes=262_144, + operation_max_bytes=262_144, + resynchronization_max_bytes=0, + changed_fields=(), + restore_order=(), + snapshot_max_steps=0, + restore_max_steps=0, + verify_max_steps=0, + ) + profile = ScopeScreenshotProfile((variant,)) + + assert profile.select(request) is variant + with pytest.raises(ValueError, match="exactly one"): + profile.select(ScopeScreenshotRequest(menu_mode="exclude")) + with pytest.raises(ValueError, match="equal"): + ScopeScreenshotVariant( + request=request, + media_type="image/png", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + response_max_bytes=100, + operation_max_bytes=101, + resynchronization_max_bytes=0, + changed_fields=(), + restore_order=(), + snapshot_max_steps=0, + restore_max_steps=0, + verify_max_steps=0, + ) + + +def test_screenshot_state_tokens_and_png_dimensions_are_verified() -> None: + ScopeScreenshotStateSnapshot( + captured_fields=("scope.display_menu",), + menu_state_token="MENU_OFF", + ) + with pytest.raises(ValueError, match="menu token"): + ScopeScreenshotStateSnapshot(captured_fields=("scope.display_menu",)) + + screenshot = ScopeScreenshot( + data=_png(), + media_type="image/png", + width_px=2, + height_px=3, + requested=ScopeScreenshotRequest(), + effective=ScopeScreenshotRequest(), + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ) + assert screenshot.width_px == 2 + with pytest.raises(ValueError, match="dimensions"): + ScopeScreenshot( + data=_png(), + media_type="image/png", + width_px=9, + height_px=3, + requested=ScopeScreenshotRequest(), + effective=ScopeScreenshotRequest(), + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ) + corrupted = bytearray(_png()) + corrupted[29] ^= 0x01 + with pytest.raises(ValueError, match="CRC"): + ScopeScreenshot( + data=bytes(corrupted), + media_type="image/png", + width_px=2, + height_px=3, + requested=ScopeScreenshotRequest(), + effective=ScopeScreenshotRequest(), + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ) + + +def _acquisition_profile( + *, + identity_semantics: str = "unique_within_session_epoch", +) -> ScopeAcquisitionControlProfile: + return ScopeAcquisitionControlProfile( + supported_continuous_modes=("auto", "normal"), + single_arm_semantics="configure_then_arm", + arm_resets_acquisition_count=False, + failure_restore_order=("scope.trigger", "scope.acquisition"), + snapshot_max_steps=3, + restore_max_steps=3, + verify_max_steps=3, + identity_semantics=identity_semantics, # type: ignore[arg-type] + ) + + +def test_acquisition_identity_proof_depends_on_descriptor_semantics() -> None: + original = ScopeAcquisitionRunState("stopped", "normal", "STOP", acquisition_identity="old") + proof_baseline = ScopeAcquisitionRunState("ready", "single", "READY", acquisition_identity="old") + armed = ScopeAcquisitionRunState("arming", "single", "ARM", acquisition_identity="old") + completed = ScopeAcquisitionRunState( + "stopped", + "single", + "STOP", + acquisition_identity="new", + ) + snapshot = ScopeAcquisitionControlSnapshot(original, "TRIG", "ACQ") + baseline = ScopeAcquisitionControlBaseline( + "ctx", + "epoch", + "nonce", + snapshot, + ("scope.run_state", "scope.trigger", "scope.acquisition"), + ) + result = ScopeAcquisitionCompletion( + state=completed, + original_state=original, + proof_baseline_state=proof_baseline, + proof_baseline_stage="configured_pre_arm", + proof="identity_delta", + baseline_identity="old", + completed_identity="new", + observed_states=(armed, completed), + ) + + validate_acquisition_completion(result, baseline=baseline, profile=_acquisition_profile()) + with pytest.raises(ValueError, match="identity proof"): + validate_acquisition_completion( + result, + baseline=baseline, + profile=_acquisition_profile(identity_semantics="unknown"), + ) + + +def test_trace_models_copy_arrays_and_enforce_r1_3_fetch_scope() -> None: + metadata = ScopeTraceMetadata( + source=ScopeTraceRef("analog", index=1), + x_axis=ScopeAxisMetadata("time", "s", 0.0, 1e-9, 2), + y_unit="v", + y_semantics="linear", + value_encoding="real", + operation="identity", + fetchable=True, + ) + source = np.array([1.0, 2.0]) + trace = ScopeTraceData(metadata, source) + source[0] = 99 + assert trace.values.tolist() == [1.0, 2.0] + assert trace.values.dtype == np.float64 + assert trace.values.flags.writeable is False + + with pytest.raises(ValueError, match="R1.3 fetchable"): + ScopeTraceMetadata( + source=ScopeTraceRef("math", index=1), + x_axis=ScopeAxisMetadata("time", "s", 0.0, 1e-9, 2), + y_unit="v", + y_semantics="linear", + value_encoding="real", + operation="device_other", + fetchable=True, + ) + + +def test_digital_trace_is_single_line_bitmask_only() -> None: + metadata = ScopeTraceMetadata( + source=ScopeTraceRef("digital", index=3), + x_axis=ScopeAxisMetadata("time", "s", 0.0, 1e-9, 3), + y_unit="1", + y_semantics="unknown", + value_encoding="digital_bitmask", + operation="identity", + digital_channels=(3,), + fetchable=True, + ) + trace = ScopeTraceData(metadata, np.array([0, 8, 0], dtype=np.uint8)) + assert trace.values.dtype == np.uint16 + with pytest.raises(ValueError, match="single-bit"): + ScopeTraceData(metadata, np.array([0, 1, 0], dtype=np.uint8)) + + +def test_trace_profile_and_snapshot_close_each_transfer_field() -> None: + fields = ( + "scope.query_response_header", + "scope.waveform_byte_order", + "scope.waveform_transfer_window", + ) + profile = ScopeTraceProfile( + fetchable_kinds=("analog", "reference"), + max_points=1_000, + restore_order=fields, + snapshot_max_steps=3, + restore_max_steps=3, + verify_max_steps=3, + ) + assert profile.restore_order == fields + ScopeTraceTransferStateSnapshot( + captured_fields=fields, + query_response_header_token="HEADER_OFF", + waveform_byte_order_token="LSB", + waveform_transfer_window_token="ALL", + ) + with pytest.raises(ValueError, match="presence"): + ScopeTraceTransferStateSnapshot( + captured_fields=fields, + query_response_header_token="HEADER_OFF", + waveform_byte_order_token="LSB", + ) + + +def test_descriptor_extension_rejects_queried_only_profile() -> None: + variant = ScopeScreenshotVariant( + request=ScopeScreenshotRequest(), + media_type="image/png", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + response_max_bytes=100, + operation_max_bytes=100, + resynchronization_max_bytes=0, + changed_fields=(), + restore_order=(), + snapshot_max_steps=0, + restore_max_steps=0, + verify_max_steps=0, + ) + with pytest.raises(ValueError, match="queried-only"): + ScopeDescriptorExtensions( + screenshot_profile=ScopeScreenshotProfile((variant,), source="queried") + ) + + +def test_error_drain_requires_terminator_or_overflow_evidence() -> None: + record = DriverErrorRecord(1, "error", "error", "queue") + ErrorDrainResult(records=(record,), terminated=True, query_count=2).validate_for( + max_records=16 + ) + with pytest.raises(ValueError, match="overflow"): + ErrorDrainResult(records=(record,), terminated=False, query_count=2).validate_for( + max_records=1 + ) diff --git a/tests/test_scope_extension_registry.py b/tests/test_scope_extension_registry.py new file mode 100644 index 0000000..038e4bc --- /dev/null +++ b/tests/test_scope_extension_registry.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from dataclasses import fields, replace + +import pytest + +from wavebench.errors import ConfigError +from wavebench.instruments import InstrumentDescriptor +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + validate_experimental_scope_descriptor, + validate_scope_descriptor, +) +from wavebench.instruments.scope_extensions import ( + ScopeDescriptorExtensions, + ScopeScreenshotProfile, + ScopeScreenshotRequest, + ScopeScreenshotVariant, +) +from wavebench.services.operation_specs import OperationSpec, get_operation_spec +from wavebench.services.scope_error_policy import legacy_scope_error_artifact +from wavebench.services.scope_extension_specs import ( + EMBEDDED_SCREENSHOT_CAPTURE_SPECS, + SCOPE_OPERATION_SPECS, +) +from wavebench.transport.contracts import BinaryResponseFraming + + +def _profile() -> ScopeScreenshotProfile: + return ScopeScreenshotProfile( + ( + ScopeScreenshotVariant( + request=ScopeScreenshotRequest(), + media_type="image/png", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + response_max_bytes=1_024, + operation_max_bytes=1_024, + resynchronization_max_bytes=0, + changed_fields=(), + restore_order=(), + snapshot_max_steps=0, + restore_max_steps=0, + verify_max_steps=0, + ), + ) + ) + + +def _descriptor(*, capabilities: tuple[str, ...], extensions=True) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.scope", + kind="scope", + display_name="Example", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities, + idn_patterns=("EXAMPLE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: object(), + wavebench_min_version="0.8.23", + scope_extensions=( + ScopeDescriptorExtensions(screenshot_profile=_profile()) if extensions else None + ), + ) + + +def test_scope_capabilities_and_operations_are_in_public_registries() -> None: + for capability in SCOPE_CAPABILITY_METHODS: + assert CAPABILITY_METHODS[capability] == SCOPE_CAPABILITY_METHODS[capability] + for operation, spec in SCOPE_OPERATION_SPECS.items(): + assert get_operation_spec(operation) is spec + + +def test_public_capability_validator_accepts_complete_scope_extension() -> None: + descriptor = _descriptor(capabilities=("scope.screenshot_profile",)) + class Driver: + def close(self) -> None: + pass + + def get_screenshot_profile(self): + return _profile() + + validate_declared_capabilities(descriptor, Driver()) + + +def test_public_scope_capability_requires_new_core_floor() -> None: + descriptor = replace( + _descriptor(capabilities=("scope.screenshot_profile",)), + wavebench_min_version="0.8.22", + ) + + with pytest.raises(ConfigError, match="0.8.23"): + validate_scope_descriptor(descriptor) + + +def test_scope_descriptor_extension_is_append_only_for_positional_compatibility() -> None: + names = [field.name for field in fields(InstrumentDescriptor)] + + assert names[-3:] == ["config_fields", "resource_schemes", "scope_extensions"] + + +def test_new_old_core_plugin_capability_matrix_is_fail_closed() -> None: + legacy_capabilities = set(CAPABILITY_METHODS) - set(SCOPE_CAPABILITY_METHODS) + old_plugin = {"scope.idn", "scope.fetch_waveform"} + new_plugin = {"scope.idn", "scope.screenshot_profile"} + + assert old_plugin <= legacy_capabilities + assert old_plugin <= set(CAPABILITY_METHODS) + assert new_plugin <= set(CAPABILITY_METHODS) + assert new_plugin - legacy_capabilities == {"scope.screenshot_profile"} + + +def test_extra_scope_methods_do_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",)) + + class Driver: + def close(self) -> None: + pass + + def idn(self) -> str: + return "EXAMPLE,EX1" + + def get_screenshot_profile(self): + return _profile() + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.screenshot_profile" not in descriptor.capabilities + + +def test_private_descriptor_gate_requires_explicit_enable_profile_and_methods() -> None: + descriptor = _descriptor(capabilities=("scope.screenshot_profile",)) + with pytest.raises(ConfigError, match="disabled"): + validate_experimental_scope_descriptor(descriptor) + with pytest.raises(ConfigError, match="requires callable"): + validate_experimental_scope_descriptor(descriptor, driver=object(), enabled=True) + + class Driver: + def get_screenshot_profile(self): + return _profile() + + validate_experimental_scope_descriptor(descriptor, driver=Driver(), enabled=True) + + missing_profile = _descriptor( + capabilities=("scope.screenshot_profile",), + extensions=False, + ) + with pytest.raises(ConfigError, match="requires scope_extensions"): + validate_experimental_scope_descriptor(missing_profile, enabled=True) + + +def test_scope_extension_operation_specs_freeze_timeout_and_binary_limits() -> None: + screenshot = SCOPE_OPERATION_SPECS["scope.screenshot_v2"] + assert screenshot.timeout_source == "operation.timeout_ms" + assert screenshot.operation_timeout_ms == 5_000 + assert ( + screenshot.binary_response_max_bytes, + screenshot.binary_operation_max_bytes, + screenshot.binary_query_max_count, + screenshot.binary_resynchronization_max_bytes, + ) == (262_144, 262_144, 1, 0) + + trace = SCOPE_OPERATION_SPECS["scope.fetch_trace"] + assert trace.operation_timeout_ms == 60_000 + assert ( + trace.binary_response_max_bytes, + trace.binary_operation_max_bytes, + trace.binary_query_max_count, + trace.binary_resynchronization_max_bytes, + ) == (8_388_608, 67_108_864, 256, 65_536) + assert "scope.query_response_header" in trace.verification_fields + assert "scope.waveform_byte_order" in trace.verification_fields + assert "scope.waveform_transfer_window" in trace.verification_fields + + +def test_operation_spec_rejects_partial_binary_or_ambiguous_timeout_metadata() -> None: + with pytest.raises(ValueError, match="all four"): + OperationSpec( + "scope.bad", + "scope", + binary_response_max_bytes=1, + ) + with pytest.raises(ValueError, match="explicit operation_timeout_ms"): + OperationSpec( + "scope.bad", + "scope", + timeout_source="operation.timeout_ms", + ) + with pytest.raises(ValueError, match="timeout_source"): + OperationSpec( + "scope.bad", + "scope", + operation_timeout_ms=1, + ) + + +def test_legacy_scope_errors_is_explicitly_consumptive_without_typed_proof() -> None: + legacy = get_operation_spec("scope.errors") + assert legacy is not None + assert legacy.changed_fields == ("scope.error_queue",) + assert legacy.verification_fields == () + artifact = legacy_scope_error_artifact(["one", "two"], requested_limit=16) + assert artifact["returned_record_count"] == 2 + assert artifact["terminated"] is None + assert artifact["query_count"] is None + + +def test_embedded_screenshot_parent_specs_have_complete_static_field_closure() -> None: + for operation, spec in EMBEDDED_SCREENSHOT_CAPTURE_SPECS.items(): + assert get_operation_spec(operation) is not spec + assert spec.embedded_screenshot_contract is not None + assert { + "scope.display_menu", + "scope.display_color", + "output.screenshot", + } <= set(spec.changed_fields) + assert {"scope.display_menu", "scope.display_color"} <= set( + spec.verification_fields + ) + assert {"scope.display_menu", "scope.display_color"} <= set( + spec.cleanup_verification_fields + ) + assert "scope.screenshot_v2" in spec.optional_capabilities + assert spec.binary_response_max_bytes == 262_144 diff --git a/tests/test_scope_extension_service.py b/tests/test_scope_extension_service.py new file mode 100644 index 0000000..19a930e --- /dev/null +++ b/tests/test_scope_extension_service.py @@ -0,0 +1,624 @@ +from __future__ import annotations + +import numpy as np +import pytest +import zlib + +import wavebench.services as public_services +from wavebench.errors import DataError, InstrumentError +from wavebench.instruments import InstrumentDescriptor +from wavebench.instruments.scope_extensions import ( + DriverErrorRecord, + ErrorCheckSpec, + ErrorDrainResult, + ScopeAcquisitionCompletion, + ScopeAcquisitionControlProfile, + ScopeAcquisitionControlSnapshot, + ScopeAcquisitionRunState, + ScopeAxisMetadata, + ScopeBaselineRestoreResult, + ScopeContinuousAcquisitionRequest, + ScopeDescriptorExtensions, + ScopeScreenshot, + ScopeScreenshotProfile, + ScopeScreenshotRequest, + ScopeScreenshotRestoreResult, + ScopeScreenshotStateSnapshot, + ScopeScreenshotVariant, + ScopeTraceData, + ScopeTraceMetadata, + ScopeTraceProfile, + ScopeTraceRef, + ScopeTraceTransferRestoreResult, + ScopeTraceTransferStateSnapshot, +) +from wavebench.services.scope_extension_service import ( + ExperimentalScopeExtensionService, + ScopeExtensionService, +) +from wavebench.transport.contracts import ( + BinaryQueryResult, + BinaryResponseFraming, + ReplayPolicy, +) +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import SessionHealth + + +def _png(width: int = 2, height: int = 3) -> bytes: + def chunk(kind: bytes, payload: bytes) -> bytes: + crc = zlib.crc32(kind + payload) & 0xFFFFFFFF + return len(payload).to_bytes(4, "big") + kind + payload + crc.to_bytes(4, "big") + + ihdr = width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x02\x00\x00\x00" + rows = b"".join(b"\x00" + b"\x00" * (width * 3) for _ in range(height)) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") + ) + + +class _Backend: + resource = "fake" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + self.binary_queries = 0 + + def record_event(self, direction: str, text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append(command) + return "ok" + + def query_float_list( + self, + command: str, + *, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> list[float]: + self.queries.append(command) + return [1.0] + + def query_bin_block( + self, + command: str, + *, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> bytes: + self.queries.append(command) + return b"data" + + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + _transport_trailing: bytes = b"", + _resynchronization_max_bytes: int = 0, + ) -> BinaryQueryResult: + self.binary_queries += 1 + data = _png() if "SCREEN" in command else b"\x01\x02" + assert len(data) <= max_bytes + if framing is BinaryResponseFraming.MESSAGE: + return BinaryQueryResult(data, framing, None, 0, len(data)) + header_bytes = 2 + len(str(len(data))) + return BinaryQueryResult( + data, + framing, + len(data), + header_bytes, + header_bytes + len(data) + len(_transport_trailing), + _transport_trailing, + ) + + def query_opc(self, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append("*OPC?") + return "1" + + def write(self, command: str) -> None: + self.writes.append(command) + + def write_bytes(self, command: bytes) -> None: + self.writes.append("BINARY_WRITE") + + def close(self) -> None: + pass + + +TRACE_FIELDS = ( + "scope.run_state", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +) + + +class _Driver: + def __init__(self, transport: GuardedAuditedTransport) -> None: + self.transport = transport + self.fail_screenshot = False + self.trace_verify_mismatch = False + self.fail_single_completion = False + self.fail_stop_once = False + self.restore_calls = 0 + self.error_records: tuple[DriverErrorRecord, ...] = () + self.error_queries = 0 + self.extra_error_query = False + self.screenshot_profile = ScopeScreenshotProfile( + ( + ScopeScreenshotVariant( + request=ScopeScreenshotRequest( + menu_mode="exclude", + color_mode="color", + ), + media_type="image/png", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + response_max_bytes=262_144, + operation_max_bytes=262_144, + resynchronization_max_bytes=0, + changed_fields=("scope.display_menu", "scope.display_color"), + restore_order=("scope.display_menu", "scope.display_color"), + snapshot_max_steps=2, + restore_max_steps=2, + verify_max_steps=2, + width_px=(2, 2), + height_px=(3, 3), + ), + ) + ) + self.acquisition_profile = ScopeAcquisitionControlProfile( + supported_continuous_modes=("auto", "normal"), + single_arm_semantics="configure_then_arm", + arm_resets_acquisition_count=False, + failure_restore_order=("scope.trigger", "scope.acquisition"), + snapshot_max_steps=3, + restore_max_steps=3, + verify_max_steps=3, + identity_semantics="unique_within_session_epoch", + ) + self.trace_profile = ScopeTraceProfile( + fetchable_kinds=("analog",), + max_points=1_000, + restore_order=TRACE_FIELDS, + snapshot_max_steps=8, + restore_max_steps=8, + verify_max_steps=8, + ) + self.screenshot_snapshot = ScopeScreenshotStateSnapshot( + captured_fields=("scope.display_menu", "scope.display_color"), + menu_state_token="MENU_ON", + color_state_token="COLOR", + ) + self.run_state = ScopeAcquisitionRunState( + "stopped", + "normal", + "STOP", + acquisition_identity="old", + ) + self.trigger_token = "TRIGGER_NORMAL" + self.acquisition_token = "ACQ_NORMAL" + self.trace_snapshot = ScopeTraceTransferStateSnapshot( + captured_fields=TRACE_FIELDS, + run_state_token="STOP", + waveform_source_token="C1", + waveform_mode_token="NORMAL", + query_response_header_token="OFF", + waveform_format_token="BYTE", + waveform_byte_order_token="LSB", + waveform_points_token="DMAX", + waveform_transfer_window_token="ALL", + ) + + def close(self) -> None: + pass + + def idn(self) -> str: + self.transport.query("*IDN?") + return "EXAMPLE,SCOPE" + + def get_screenshot_profile(self) -> ScopeScreenshotProfile: + return self.screenshot_profile + + def snapshot_screenshot_state(self, fields): + for field_name in fields: + self.transport.query(f"SNAP:{field_name}?") + return self.screenshot_snapshot + + def capture_screenshot(self, request, *, baseline): + self.transport.write("MENU EXCLUDE") + self.transport.write("COLOR COLOR") + binary = self.transport.query_binary( + "SCREEN?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=262_144, + ) + if self.fail_screenshot: + raise DataError("screenshot parser failed") + return ScopeScreenshot( + data=binary.data, + media_type="image/png", + width_px=2, + height_px=3, + requested=request, + effective=request, + framing=binary.framing, + ) + + def restore_screenshot_state(self, baseline): + self.restore_calls += 1 + for field_name in baseline.restore_order: + self.transport.write(f"RESTORE:{field_name}") + return ScopeScreenshotRestoreResult( + "completed", + baseline.restore_order, + baseline.restore_order, + ) + + def verify_screenshot_state_restored(self, fields, baseline): + for field_name in fields: + self.transport.query(f"VERIFY:{field_name}?") + return baseline.snapshot + + def get_acquisition_run_state(self): + self.transport.query("RUN_STATE?") + return self.run_state + + def snapshot_acquisition_control(self): + self.transport.query("RUN_STATE?") + self.transport.query("TRIGGER?") + self.transport.query("ACQUISITION?") + return ScopeAcquisitionControlSnapshot( + self.run_state, + self.trigger_token, + self.acquisition_token, + ) + + def start_continuous(self, *, trigger_mode, baseline): + self.transport.write(f"TRIGGER {trigger_mode}") + self.transport.write("RUN") + self.transport.query("RUN_STATE?") + self.run_state = ScopeAcquisitionRunState("acquiring", trigger_mode, "RUN") + return self.run_state + + def stop_acquisition(self): + if self.fail_stop_once: + self.fail_stop_once = False + raise DataError("stop result invalid") + self.transport.write("STOP") + self.transport.query("RUN_STATE?") + self.run_state = ScopeAcquisitionRunState("stopped", "normal", "STOP") + return self.run_state + + def acquire_single(self, *, baseline, deadline): + self.transport.write("TRIGGER SINGLE") + self.transport.query("READY?") + proof_baseline = ScopeAcquisitionRunState( + "ready", + "single", + "READY", + acquisition_identity="old", + ) + self.transport.write("SINGLE") + self.transport.query("ARMING?") + armed = ScopeAcquisitionRunState( + "arming", + "single", + "ARM", + acquisition_identity="old", + ) + self.transport.query("COMPLETE?") + completed = ScopeAcquisitionRunState( + "stopped", + "single", + "STOP", + acquisition_identity=("old" if self.fail_single_completion else "new"), + ) + return ScopeAcquisitionCompletion( + state=completed, + original_state=baseline.snapshot.run_state, + proof_baseline_state=proof_baseline, + proof_baseline_stage="configured_pre_arm", + proof="identity_delta", + baseline_identity="old", + completed_identity=completed.acquisition_identity, + observed_states=(armed, completed), + ) + + def restore_acquisition_control(self, baseline): + self.restore_calls += 1 + for field_name in baseline.restore_order: + self.transport.write(f"RESTORE:{field_name}") + self.run_state = ScopeAcquisitionRunState("stopped", "normal", "STOP") + self.trigger_token = baseline.snapshot.trigger_state_token + self.acquisition_token = baseline.snapshot.acquisition_state_token + return ScopeBaselineRestoreResult( + "completed", + baseline.restore_order, + baseline.restore_order, + ) + + def verify_acquisition_control_restored(self, baseline): + self.transport.query("VERIFY:RUN?") + self.transport.query("VERIFY:TRIGGER?") + self.transport.query("VERIFY:ACQUISITION?") + return ScopeAcquisitionControlSnapshot( + self.run_state, + self.trigger_token, + self.acquisition_token, + ) + + def get_trace_metadata(self, source): + self.transport.query("TRACE:METADATA?") + return ScopeTraceMetadata( + source=source, + x_axis=ScopeAxisMetadata("time", "s", 0.0, 1e-9, 2), + y_unit="v", + y_semantics="linear", + value_encoding="real", + operation="identity", + fetchable=True, + ) + + def snapshot_trace_transfer_state(self, fields): + for field_name in fields: + self.transport.query(f"SNAP:{field_name}?") + return self.trace_snapshot + + def fetch_trace(self, source, *, points="dmax", baseline=None): + self.transport.write("TRACE:SOURCE C1") + self.transport.query_binary( + "TRACE:DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=8_388_608, + ) + return ScopeTraceData(self.get_trace_metadata(source), np.array([1.0, 2.0])) + + def restore_trace_transfer_state(self, baseline): + self.restore_calls += 1 + for field_name in baseline.restore_order: + self.transport.write(f"RESTORE:{field_name}") + return ScopeTraceTransferRestoreResult( + "completed", + baseline.restore_order, + baseline.restore_order, + ) + + def verify_trace_transfer_state_restored(self, baseline): + for field_name in baseline.restore_order: + self.transport.query(f"VERIFY:{field_name}?") + if not self.trace_verify_mismatch: + return baseline.snapshot + return ScopeTraceTransferStateSnapshot( + captured_fields=TRACE_FIELDS, + run_state_token="STOP", + waveform_source_token="C1", + waveform_mode_token="NORMAL", + query_response_header_token="OFF", + waveform_format_token="BYTE", + waveform_byte_order_token="MSB", + waveform_points_token="DMAX", + waveform_transfer_window_token="ALL", + ) + + def drain_errors(self, *, max_records): + for _ in range(len(self.error_records) + 1): + self.transport.query("ERROR?") + self.error_queries += 1 + if self.extra_error_query: + self.transport.query("ERROR:EXTRA?") + self.error_queries += 1 + return ErrorDrainResult( + records=self.error_records, + terminated=True, + query_count=len(self.error_records) + 1, + ) + + +def _service(*, error_capability: bool = False): + backend = _Backend() + transport = GuardedAuditedTransport(backend) + driver = _Driver(transport) + capabilities = [ + "scope.idn", + "scope.screenshot_profile", + "scope.screenshot_v2", + "scope.acquisition_run_state", + "scope.acquisition_control", + "scope.trace_metadata", + "scope.fetch_trace", + ] + if error_capability: + capabilities.append("scope.error_drain_v1") + descriptor = InstrumentDescriptor( + driver_id="example.scope", + kind="scope", + display_name="Example", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=tuple(capabilities), + idn_patterns=("EXAMPLE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: driver, + wavebench_min_version="0.8.23", + scope_extensions=ScopeDescriptorExtensions( + screenshot_profile=driver.screenshot_profile, + acquisition_control_profile=driver.acquisition_profile, + trace_profile=driver.trace_profile, + ), + ) + service = ExperimentalScopeExtensionService( + driver=driver, + descriptor=descriptor, + session_state=transport.session_state, + connection_timeout_ms=1_000, + enabled=True, + ) + return service, driver, transport, backend + + +def test_screenshot_success_restores_and_verifies_before_return() -> None: + service, driver, transport, backend = _service() + request = ScopeScreenshotRequest(menu_mode="exclude", color_mode="color") + + result = service.screenshot_v2(request) + + assert isinstance(result.value, ScopeScreenshot) + assert transport.session_state.health is SessionHealth.HEALTHY + assert driver.restore_calls == 1 + assert backend.binary_queries == 1 + assert [phase["phase"] for phase in result.diagnostics["phases"]] == [ + "preflight", + "main", + "success_restore", + "cleanup_verification", + ] + + +def test_public_service_needs_no_experimental_enable_and_freezes_artifact_schema() -> None: + internal, driver, transport, _ = _service() + service = ScopeExtensionService( + driver=driver, + descriptor=internal.descriptor, + session_state=transport.session_state, + connection_timeout_ms=1_000, + ) + + result = service.screenshot_v2( + ScopeScreenshotRequest(menu_mode="exclude", color_mode="color") + ) + payload = result.as_dict() + + assert payload["schema"] == "wavebench.scope.result.v1" + assert payload["diagnostics"]["schema"] == "wavebench.scope.operation.v1" + assert payload["result"]["payload_bytes"] == len(result.value.data) + assert "data" not in payload["result"] + assert driver.restore_calls == 1 + assert public_services.ScopeExtensionService is ScopeExtensionService + + +def test_screenshot_main_failure_is_primary_but_cleanup_can_restore_health() -> None: + service, driver, transport, _ = _service() + driver.fail_screenshot = True + + with pytest.raises(DataError, match="parser failed") as raised: + service.screenshot_v2( + ScopeScreenshotRequest(menu_mode="exclude", color_mode="color") + ) + + assert transport.session_state.health is SessionHealth.HEALTHY + assert driver.restore_calls == 1 + diagnostics = raised.value.scope_operation_diagnostics + assert diagnostics["cleanup_error"] is None + assert diagnostics["screenshot"]["verification"]["status"] == "verified" + + +def test_trace_success_and_verification_mismatch_paths_are_fail_closed() -> None: + service, driver, transport, _ = _service() + source = ScopeTraceRef("analog", index=1) + result = service.fetch_trace(source, points=2) + assert isinstance(result.value, ScopeTraceData) + assert transport.session_state.health is SessionHealth.HEALTHY + assert result.diagnostics["trace_cleanup"]["verification"]["status"] == "verified" + + service, driver, transport, _ = _service() + driver.trace_verify_mismatch = True + with pytest.raises(ValueError, match="mismatched"): + service.fetch_trace(source, points=2) + assert transport.session_state.health is SessionHealth.POISONED + + +def test_acquisition_success_keeps_postcondition_and_failure_restores_baseline() -> None: + service, driver, transport, _ = _service() + started = service.start_acquisition( + ScopeContinuousAcquisitionRequest("normal") + ) + assert started.value.phase == "acquiring" + assert driver.restore_calls == 0 + assert transport.session_state.health is SessionHealth.HEALTHY + assert "scope.identity" in transport.session_state.verified_fields + assert "scope.run_state" not in transport.session_state.verified_fields + assert "scope.trigger" not in transport.session_state.verified_fields + + service, driver, transport, _ = _service() + driver.fail_single_completion = True + with pytest.raises(ValueError, match="identity proof") as raised: + service.acquire_single() + assert driver.restore_calls == 1 + assert transport.session_state.health is SessionHealth.HEALTHY + assert raised.value.scope_operation_diagnostics["cleanup"]["verification"]["status"] == "verified" + + +def test_failed_normal_stop_uses_bounded_recovery_stop_and_preserves_primary() -> None: + service, driver, transport, _ = _service() + driver.run_state = ScopeAcquisitionRunState("acquiring", "normal", "RUN") + driver.fail_stop_once = True + + with pytest.raises(DataError, match="stop result invalid"): + service.stop_acquisition() + + assert transport.session_state.health is SessionHealth.HEALTHY + assert driver.run_state.phase == "stopped" + + +def test_required_error_check_stops_before_main_and_disabled_is_zero_io() -> None: + service, driver, transport, backend = _service(error_capability=True) + driver.error_records = ( + DriverErrorRecord(1, "TCPIP::private::INSTR failed", "error", "queue"), + ) + with pytest.raises(InstrumentError, match="prevent") as raised: + service.screenshot_v2( + ScopeScreenshotRequest(menu_mode="exclude", color_mode="color"), + error_check=ErrorCheckSpec("required", timing="before"), + ) + assert backend.binary_queries == 0 + assert not any(command.startswith("MENU ") for command in backend.writes) + error_artifact = raised.value.scope_operation_diagnostics["error_check"] + assert error_artifact["status"] == "failed" + assert error_artifact["checks"][0]["records"][0]["message_redacted"] is True + assert transport.session_state.health is SessionHealth.HEALTHY + + service, driver, _, backend = _service(error_capability=True) + service.screenshot_v2( + ScopeScreenshotRequest(menu_mode="exclude", color_mode="color"), + error_check=ErrorCheckSpec("disabled"), + ) + assert driver.error_queries == 0 + assert backend.binary_queries == 1 + + +def test_experimental_service_gate_is_closed_by_default() -> None: + service, driver, transport, _ = _service() + with pytest.raises(Exception, match="disabled"): + ExperimentalScopeExtensionService( + driver=driver, + descriptor=service.descriptor, + session_state=transport.session_state, + connection_timeout_ms=1_000, + ) + + +def test_error_drain_query_count_must_match_guarded_transport_evidence() -> None: + service, driver, _, backend = _service(error_capability=True) + driver.extra_error_query = True + with pytest.raises(ValueError, match="query_count"): + service.screenshot_v2( + ScopeScreenshotRequest(menu_mode="exclude", color_mode="color"), + error_check=ErrorCheckSpec("required", timing="before"), + ) + assert backend.binary_queries == 0 diff --git a/tests/test_scope_phase_coordinator.py b/tests/test_scope_phase_coordinator.py new file mode 100644 index 0000000..398ff71 --- /dev/null +++ b/tests/test_scope_phase_coordinator.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import pytest + +from wavebench.services.scope_extension_specs import EXPERIMENTAL_SCOPE_OPERATION_SPECS +from wavebench.services.scope_phase_coordinator import ( + BaselineUseState, + OperationPhase, + ScopeOperationContextCoordinator, +) +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import SessionHealth + + +class _TextTransport: + resource = "fake" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, direction: str, text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append(command) + return "ok" + + def query_float_list( + self, + command: str, + *, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> list[float]: + self.queries.append(command) + return [1.0] + + def query_bin_block( + self, + command: str, + *, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> bytes: + self.queries.append(command) + return b"data" + + def query_opc(self, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append("*OPC?") + return "1" + + def write(self, command: str) -> None: + self.writes.append(command) + + def write_bytes(self, command: bytes) -> None: + pass + + def close(self) -> None: + pass + + +FIELDS = ( + "scope.query_response_header", + "scope.waveform_byte_order", + "scope.waveform_transfer_window", +) + + +def _context() -> tuple[ + GuardedAuditedTransport, + ScopeOperationContextCoordinator, +]: + transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type] + context = ScopeOperationContextCoordinator( + session_state=transport.session_state, + spec=EXPERIMENTAL_SCOPE_OPERATION_SPECS["scope.fetch_trace"], + connection_timeout_ms=1_000, + enabled=True, + ) + return transport, context + + +def _baseline( + transport: GuardedAuditedTransport, + context: ScopeOperationContextCoordinator, +): + phase = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=FIELDS, + max_steps=1, + ) + with context.authorize_phase(phase): + transport.query("SNAPSHOT?") + handle = context.create_baseline( + kind="trace_transfer", + fields=FIELDS, + restore_order=FIELDS, + ) + context.pass_baseline_to_main(handle) + return handle + + +def _main( + transport: GuardedAuditedTransport, + context: ScopeOperationContextCoordinator, +) -> None: + phase = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query"}, + fields=FIELDS, + max_steps=1, + ) + with context.authorize_phase(phase): + transport.query("FETCH?") + context.mark_cleanup_required() + + +def test_restore_and_verify_slots_are_distinct_and_single_use() -> None: + transport, context = _context() + handle = _baseline(transport, context) + nonce = handle.baseline_nonce + _main(transport, context) + + restore = context.make_phase_spec( + OperationPhase.SUCCESS_RESTORE, + allowed_io={"write"}, + fields=FIELDS, + max_steps=1, + ) + with context.authorize_phase(restore): + context.begin_restore(handle) + with pytest.raises(ValueError, match="restore slot"): + context.begin_restore(handle) + transport.write("RESTORE") + context.finish_restore(handle, succeeded=True) + + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=FIELDS, + max_steps=1, + ) + with context.authorize_phase(verify) as authorization: + context.begin_verification(handle) + transport.query("VERIFY?") + context.finish_verification( + handle, + authorization, + io_kind="query", + verified_fields=FIELDS, + matched=True, + ) + with pytest.raises(ValueError, match="prior restore"): + context.begin_verification(handle) + + context.complete() + artifact = context.artifact() + assert transport.session_state.health is SessionHealth.HEALTHY + assert artifact["baselines"][0]["consumption"] == BaselineUseState.CONSUMED.value + assert nonce not in repr(artifact) + assert artifact["baselines"][0]["nonce_digest"] != nonce + + +def test_restore_failure_allows_one_diagnostic_verify_but_never_recovers_health() -> None: + transport, context = _context() + handle = _baseline(transport, context) + _main(transport, context) + + restore = context.make_phase_spec( + OperationPhase.FAILURE_CLEANUP, + allowed_io={"write"}, + fields=FIELDS, + max_steps=1, + ) + with context.authorize_phase(restore): + context.begin_restore(handle) + transport.write("PARTIAL_RESTORE") + context.finish_restore(handle, succeeded=False) + + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=FIELDS, + max_steps=1, + ) + with context.authorize_phase(verify) as authorization: + context.begin_verification(handle) + transport.query("DIAGNOSTIC_VERIFY?") + with pytest.raises(ValueError, match="incomplete or mismatched"): + context.finish_verification( + handle, + authorization, + io_kind="query", + verified_fields=FIELDS, + matched=True, + ) + + assert transport.session_state.health is SessionHealth.UNCERTAIN + context.complete() + assert transport.session_state.health is SessionHealth.POISONED + assert context.artifact()["baselines"][0]["consumption"] == "consumed" + + +def test_cross_context_baseline_and_nested_phase_are_rejected_before_io() -> None: + transport, first = _context() + handle = _baseline(transport, first) + second = ScopeOperationContextCoordinator( + session_state=transport.session_state, + spec=EXPERIMENTAL_SCOPE_OPERATION_SPECS["scope.fetch_trace"], + connection_timeout_ms=1_000, + enabled=True, + ) + + phase = first.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query"}, + fields=FIELDS, + max_steps=1, + ) + with first.authorize_phase(phase) as authorization: + assert authorization.deadline == phase.deadline + assert authorization._session_authorization.io_timeout_ms == 1_000 + nested = first.make_phase_spec( + OperationPhase.ERROR_AFTER, + allowed_io={"query"}, + fields={"scope.error_queue"}, + max_steps=1, + ) + with pytest.raises(ValueError, match="nested"): + with first.authorize_phase(nested): + pass + + with pytest.raises(ValueError, match="not owned"): + second._baseline_record(handle) + + +def test_error_before_failure_does_not_require_cleanup_when_main_never_enters() -> None: + transport, context = _context() + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity"}, + max_steps=1, + ) + with context.authorize_phase(preflight) as authorization: + transport.query("*IDN?") + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity"}, + ) + before = context.make_phase_spec( + OperationPhase.ERROR_BEFORE, + allowed_io={"query"}, + fields={"scope.error_queue"}, + max_steps=1, + ) + with context.authorize_phase(before): + transport.query("ERROR?") + context.complete() + assert transport.session_state.health is SessionHealth.HEALTHY + + +def test_context_reserves_cleanup_time_inside_hard_operation_deadline() -> None: + _, context = _context() + assert context.main_deadline < context.deadline + assert 1_000 <= context.cleanup_reserve_ms <= 5_000 + + +def test_main_cannot_skip_preflight() -> None: + _, context = _context() + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query"}, + fields={"scope.identity"}, + max_steps=1, + ) + with pytest.raises(ValueError, match="require preflight"): + with context.authorize_phase(main): + pass + + +def test_default_feature_gate_is_closed() -> None: + transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type] + with pytest.raises(Exception, match="disabled"): + ScopeOperationContextCoordinator( + session_state=transport.session_state, + spec=EXPERIMENTAL_SCOPE_OPERATION_SPECS["scope.fetch_trace"], + connection_timeout_ms=1_000, + )