From 823e7a7492fa4cbfdc1ad04c3906561a2601ed90 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:54:04 +0800 Subject: [PATCH 01/82] feat: add source v2 snapshot contracts --- README.md | 2 +- docs/project/README.md | 3 + .../WaveBench_CLI\345\275\242\346\200\201.md" | 13 +- ...23\345\207\272\346\240\274\345\274\217.md" | 19 + ...345\231\250\346\217\222\344\273\266API.md" | 30 +- docs/project/rfcs/README.md | 3 + ...345\207\272\345\256\211\345\205\250RFC.md" | 2747 +++++++++++++++ pyproject.toml | 2 +- src/wavebench/cli.py | 11 + src/wavebench/cli_parser.py | 6 + src/wavebench/instruments/__init__.py | 6 + src/wavebench/instruments/api.py | 7 + src/wavebench/instruments/capabilities.py | 6 + src/wavebench/instruments/registry.py | 2 + .../source_extension_capabilities.py | 264 ++ .../instruments/source_extensions.py | 3070 +++++++++++++++++ src/wavebench/services/operation_specs.py | 18 + src/wavebench/services/source_service.py | 56 + src/wavebench/services/source_snapshot_v2.py | 774 +++++ tests/source_v2_fixtures.py | 317 ++ tests/test_scope_extension_registry.py | 7 +- tests/test_source_extensions.py | 584 ++++ tests/test_source_snapshot_v2.py | 475 +++ 23 files changed, 8417 insertions(+), 5 deletions(-) create mode 100644 "docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" create mode 100644 src/wavebench/instruments/source_extension_capabilities.py create mode 100644 src/wavebench/instruments/source_extensions.py create mode 100644 src/wavebench/services/source_snapshot_v2.py create mode 100644 tests/source_v2_fixtures.py create mode 100644 tests/test_source_extensions.py create mode 100644 tests/test_source_snapshot_v2.py diff --git a/README.md b/README.md index 1df4a8e..c4a9388 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ WaveBench 是一个用 Python 编写的实验室自动测量台,面向电子设计竞赛调试和日常实验。它把仪器控制、实验步骤和采集证据放在同一条命令链中,支持先离线检查 plan,再决定是否连接硬件。 -当前仓库开发线为 `0.8.23`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 +当前仓库开发线为 `0.8.24`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 ## 🌟 特别鸣谢 diff --git a/docs/project/README.md b/docs/project/README.md index fb316ec..81111f8 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -28,6 +28,9 @@ ## rfcs:接口提案与决策 - [RFC 索引](rfcs/README.md) +- [Source V2 能力、状态与复合输出安全 RFC](rfcs/WaveBench_source能力状态与复合输出安全RFC.md): + `Accepted R5`;核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5 与 C1 的 snapshot、预算、 + 事务和兼容合同,写能力未授权 - [transport 重放与 session 健康 RFC](rfcs/WaveBench_transport重放与session健康RFC.md) ## contributing:开发和接入 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index ebcb8fd..6a0e05b 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -31,7 +31,7 @@ wavebench run --help | 类别 | 示例 | 行为 | |---|---|---| | 离线 | `run schema`、`run template`、`run check`、`run intent`、`run report`、`run compare`、`run resume`、`capability explain`、`lock status`、`capture inspect`、`tui --fake` | 不连接仪器;报告、比较、检查、能力解释、锁查询和意图生成只读取本地文件 | -| 连接读取 | `doctor`、`net`、`scope idn`、`scope status`、`run verify` | 查询资源、身份或状态,不应修改实验设置 | +| 连接读取 | `doctor`、`net`、`scope idn`、`scope status`、`source snapshot-v2`、`run verify` | 查询资源、身份或状态,不应修改实验设置 | | 显式写入或触发 | `scope auto`、`scope fetch/capture`、source / power setter、`run plan` | 可能改变设置、触发采集或切换输出 | 执行硬件写入前,应先确认接线、输入阻抗、输出状态和安全限制。CLI 不会自动发送 `*RST`,也不会因为设置电压或幅度而自动打开输出。 @@ -68,6 +68,17 @@ wavebench capability explain source.output --candidates --json `--candidates` 只筛选当前本地 registry 中的驱动,不安装、不下载插件。 +完整的只读信号源状态使用: + +```bash +wavebench source snapshot-v2 --config wavebench.toml +wavebench --json source snapshot-v2 --config wavebench.toml +``` + +该命令要求插件声明 `source.snapshot_v2`,按 descriptor topology 查询全部通道、输入和跨通道 +关系,不接受单通道或 raw query 参数。普通模式输出缩进 JSON;`--json` 使用 +`wavebench.cli.result.v1` envelope。命令不授权任何 Source V2 写入。 + `--json` 可以放在命令行任意位置。非交互命令输出 `wavebench.cli.result.v1`;错误输出 `wavebench.error.v1`,诊断信息写入 stderr。TUI 和 HTTP MCP 不使用 one-shot JSON 包装。 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 10ad87c..00574bd 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" @@ -823,3 +823,22 @@ CLI 仍会在请求的 artifact 路径写入 `status = "failed"`、安全错误 伪成功的 PNG/NPY。artifact 自身无法写入时,CLI 删除本轮刚生成的 PNG/NPY,并在错误结果中 报告 `scope_artifact.reason_code = "write_failed"`;若部分输出无法删除,还会报告 `scope_output.reason_code = "remove_failed"`。 + +## Source V2 snapshot 操作产物 + +`wavebench source snapshot-v2` 返回 `wavebench.source.operation.v1`。使用 `--json` 时,该对象位于 +`wavebench.cli.result.v1.result`;普通模式直接输出缩进后的 operation artifact。当前命令不写入 +持久化文件,也不产生 conformance manifest。 + +operation artifact 固定包含: + +- `operation = "source.snapshot_v2"`、context、correlation 和 session epoch; +- capability decision、`wavebench.source.v2` 合同版本和 descriptor SHA-256; +- `wavebench.source.snapshot.v2` snapshot document; +- pure-read query effect、plan SHA-256 和 query count; +- 操作前后 session health、最终 consistency 和受限 evidence refs。 + +snapshot 使用 typed `Observed` 表达 `value`、`unsupported`、`not_applicable`、`not_queried`、 +`unavailable` 和 `unknown`,不会以 `0` 或空字符串冒充缺值。artifact 不包含协议执行记录、SCPI、 +完整响应、resource、序列号或原始 device revision token;revision token 只以 SHA-256 摘要出现在 +consistency 中。该 operation artifact 与插件 conformance manifest 是不同 schema,不能互换。 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 900bd1a..1f0735f 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" @@ -140,6 +140,7 @@ def descriptor() -> InstrumentDescriptor: | `scope_coupling_policy` | 值由类型约定为三种策略 | scope 必须准确声明;无法证明时使用 `unknown`,核心会默认拒绝无法确认高阻的采集 | | `config_fields` | 当前只展示;为空时由 `option_specs` 推导 `options.` | 只列出用户实际可配置的字段,不代表核心会按此字段授权 | | `scope_extensions` | 仅允许 scope descriptor 使用,类型必须为 `ScopeDescriptorExtensions` | 为 R1.3 capability 提供静态截图、采集控制和 trace profile;旧插件保持 `None` | +| `source_extensions` | 仅允许 source descriptor 使用,类型必须为 `SourceDescriptorExtensions` | 为 `source.snapshot_v2` 提供 topology、只读 feature profile 和查询合同;旧插件保持 `None` | ### `scope_coupling_policy` @@ -341,6 +342,33 @@ I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot | `source.output` | `set_output` | | `source.arbitrary_probe` | `probe_arbitrary_queries` | | `source.arbitrary_upload` | `upload_dg4000_dac14_block` | +| `source.snapshot_v2` | `execute_source_query_plan_v2` | + +### Source V2 snapshot 扩展 + +`source.snapshot_v2` 从核心 `0.8.24` 开始提供,仍使用 `wavebench.instrument.v2`。采用该能力的 +wheel 依赖和 descriptor `wavebench_min_version` 都必须为 `0.8.24` 或更高的 `0.8.x` 版本。 +`source_extensions` 位于 descriptor 末尾且默认值为 `None`,因此未声明该能力的 V1 插件不需要 +修改 descriptor 或提高版本下限。 + +插件从 `wavebench.instruments` 导入 `SourceDescriptorExtensions`、`SourceSnapshotV2Driver`、 +query plan/execution record 和各类 typed profile。核心签发 semantic query plan,插件只负责将 +item 转成合法的厂商协议查询并返回类型化执行记录。插件不得返回完整 `SourceSnapshotV2`,也不得 +自行判定 `UNSUPPORTED`、`NOT_APPLICABLE`、runtime profile 或 snapshot consistency。 + +首个修订只接受 `PURE_READ`。每个受支持的 read feature 必须有同 scope 的 query contract; +identity 必须是唯一、required 的 instrument-scope facet。声明为 `UNSUPPORTED` 或 `UNKNOWN` 的 +feature 不得进入查询计划。查询项、effect、字段覆盖、query count 和 deadline 由核心复核;不符合 +合同的执行记录不会生成 snapshot。 + +公共调用入口为 `SourceService.snapshot_v2()` 和: + +```text +wavebench source snapshot-v2 +``` + +当前没有注册任何 Source V2 写 capability。旧 `source.*` setter、output、trigger 和 ARB 路径继续 +遵守 V1 合同;`source.snapshot_v2` 不会授权、适配或代理这些写入。 ### Power、DMM 和 sweep analyzer @@ -381,7 +409,7 @@ I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot | kind | 常用公共返回类型 | | --- | --- | | scope | `WaveformHeader`、`WaveformData`、`ScopeSnapshot`、`ScopeAcquisitionStatus` 及各分析 model | -| source | `SourceStatus`、`SourceChannelProfile`、各配置和 profile model | +| source | `SourceStatus`、`SourceChannelProfile`、各配置和 profile model;Source V2 使用 `SourceQueryExecutionRecord`,最终 snapshot 由核心构造 | | power | `PowerStatus`、`PowerMeasurement`、`PowerProtectionStatus` | | dmm | `DmmReading`、`DmmMeasurementProfile`、各状态和配置 model | | sweep analyzer | `SweepPlan`、`SweepAnalyzerSnapshot`、`FrequencyResponseTrace`、`TraceIntegrity`、`MarkerReading`、`InstrumentMeasurementResult` | diff --git a/docs/project/rfcs/README.md b/docs/project/rfcs/README.md index 6133d2b..4a9dac8 100644 --- a/docs/project/rfcs/README.md +++ b/docs/project/rfcs/README.md @@ -11,6 +11,9 @@ RFC 使用以下状态: ## 当前 RFC +- [Source V2 能力、状态与复合输出安全 RFC](WaveBench_source能力状态与复合输出安全RFC.md): + `Accepted R5`,核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5 与 C1:只读 snapshot、 + 显式安全配置、纯预算、离线事务协调、V1 路由审计和受管插件兼容门;写能力仍未授权。 - [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 验收门和离线完成证据。 diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" new file mode 100644 index 0000000..97785f0 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" @@ -0,0 +1,2747 @@ +# WaveBench Source V2 能力、状态与复合输出安全 RFC + +> 状态:`Accepted` +> 修订:`R5` +> 核心基线:WaveBench `0.8.23`,`master@6cd2eb5` +> 首个支持版本:WaveBench `0.8.24` +> 实施状态:P0、M1–M4、M4.5 与 C1 已进入核心 `0.8.24` 开发线;只注册 +> `source.snapshot_v2`,未注册写 capability + +> [!IMPORTANT] +> `Accepted R5` 在 R4 的 operation context、受影响字段闭包、phase、nonce、cleanup reserve +> 和离线故障注入之外,增加 V1 写路由审计、可选 operation artifact 根键,以及受管插件的 +> Source V2 wheel/descriptor 版本交叉门。它仍不注册写 capability,也不改变现有 V1 写合同、 +> 插件版本下限或 V1 行为。 + +## 摘要 + +WaveBench 当前的 Source 基础接口可以表示固定波形、频率、Vpp 和输出开关,但无法无损表示 +Harmonic、Modulation、Sweep、Burst、Pulse、Noise、DC、ARB、Counter、Combine 和 Coupling 等功能。 +当前多个 profile 要求所有字段同时存在,`SourceStatus` 则使用裸 `Optional`。设备不支持、 +当前模式不适用、查询未执行、查询失败与支持情况未知,因此可能被压缩成同一个 `None` +或伪造的默认值。 + +本 RFC 提议在不破坏 V1 的前提下,增加 Source 领域的 V2 合同: + +- 用类型化 descriptor extension 表示功能、通道、读写方向、模式约束和查询特性; +- 用 `Observed[T]` 区分有值、不支持、不适用、未查询、暂不可获取和未知; +- 将通道、系统和跨通道状态拆分为按模式激活的 facet; +- 使用 anchor 复读和 session epoch 表示查询期间的一致性; +- 使用变长、稀疏且带完整性标记的谐波分量取代固定 H2–H16 假设; +- 对所有可开始或改变端口信号的操作使用统一的复合输出预算门; +- 复用已有 `InstrumentSessionState`、`SessionTransactionCoordinator` 和 + `GuardedAuditedTransport`,由核心组织写入、恢复与独立验证; +- 将软件测试、协议查询、仪器回读、波形测量和真实触发接线分层记录。 + +本提案源于 `wavebench-instrument-plugins` 仓库的 +[Source V2 草案(修订 `224707d`)](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/224707d78e24720100129a32a935219798dcd19c/packages/wavebench-siglent-sdg2000x/doc/RFC_SOURCE_V2_CAPABILITY_STATE_SAFETY.md), +但规范归核心仓库所有。型号命令、私有响应形态和实机验收证据仍由仪器插件仓库维护。 + +## 修订记录 + +| 修订 | 状态 | 主要变化 | +| --- | --- | --- | +| R0 | Draft | 建立问题边界、安全不变量、候选模型、预算和事务方向 | +| R1 | Draft | 冻结维护者决议、公共类型名称、两层查询计划、Source V1 生命周期、安全配置、ARB storage mutation、事务阶段、双 artifact schema 和兼容边界 | +| R2 | Accepted | 冻结 snapshot-only 公共类型、reason code、serializer、`__all__`、OperationSpec、Service/CLI JSON 和版本门;测试条件移至 M1–M2 退出门,写能力继续保留 | +| R3 | Accepted | 冻结 M3 的绝对端口电压配置、端接证据、预算 blocker、端口/共享功率预算模型和纯计算器;不注册任何 V2 写 capability | +| R4 | Accepted | 冻结 M4 的 Source operation contract、affected closure、固定 phase、core-owned baseline 与离线事务协调者;不注册任何 V2 写 capability | +| R5 | Accepted | 冻结 M4.5 的 V1 写路由清单和 additive artifact 边界,并实现 C1 的受管 wheel/descriptor PEP 440 交叉门与 V1/V2 兼容 fixture;不注册任何 V2 写 capability | + +## Accepted R5 范围 + +下文的「必须」和「不得」是核心 `0.8.24` 的 snapshot-only、预算、事务和兼容公共合同。P0、M1–M4、 +M4.5 与 C1 可以按本文实施;M5–M7 仍需对应写 capability 的后续 Accepted 修订。 +以下内容不属于 R5 的实施授权: + +- 不注册任何 V2 写 capability 或写 `OperationSpec`; +- 不增加 Source V2 run plan 写入口; +- 不改变现有 V1 `source.output` 或高级 Source 操作; +- 不执行仪器 I/O 或实机验收。 + +## 规范关系 + +- [transport 重放与 session 健康 RFC](WaveBench_transport重放与session健康RFC.md) 是连接代次、 + 重放策略、`healthy`/`uncertain`/`poisoned`/`closed`、恢复授权和验证证据的权威合同。 + 本 RFC 不覆盖或放宽其中任何状态转移。 +- [scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md) 提供 operation context、 + phase authorization、core-owned baseline、nonce 和 typed descriptor extension 的设计先例, + 但 scope 专用 binary、acquisition 和 trace 语义不会直接复制到 Source。 +- 仪器插件仓库中的 Source V2 草案、SDG2000X 验收记录和 DG4000 实现是问题与设备证据, + 不是核心公共合同。发生歧义时,以本文件及已接受的核心 RFC 为准。 + +## 背景与当前事实 + +### V1 状态无法表示可用性 + +`SourceStatus` 是平面 dataclass。`frequency_hz`、`amplitude`、`offset_v`、`phase_deg` 和 +`square_duty_cycle_percent` 使用 `None` 表示缺失,但没有字段说明缺失原因。 + +当前 `SourceService.set_output(enabled=True)` 会将 `status.amplitude` 交给 `isfinite()`。Noise 和 DC +状态合理地返回 `amplitude=None` 时,路径会抛出裸 `TypeError`,而不是类型化、零写入的 +失败关闭错误。 + +### 高级 profile 假定完整状态 + +| 功能 | 当前核心假设 | 跨厂商问题 | +| --- | --- | --- | +| Harmonic | 固定 H2–H16,要求 15 个幅度和相位都有限的分量 | 设备可能只支持部分阶次,或只能无写入地读取当前选中槽位 | +| Modulation | 关闭时仍要求内部源函数、频率和深度或偏差 | 部分设备关闭时只返回 `STATE,OFF` | +| Sweep | 要求 steps、hold、return、marker 等完整字段 | 组合查询可能不返回步数,某些字段只适用于特定 spacing | +| Burst | 关闭、Gate 和 Infinity 仍要求有限 cycles、period 和全部 trigger 字段 | 这些字段不会在所有模式下同时适用 | +| Pulse | 必须声明 `hold=WIDTH` 或 `hold=DUTY` | 设备可能同时返回宽度与占空比,但不返回权威 hold 状态 | +| Counter | 除 measurement 外,要求完整阻抗、衰减、门时间和统计配置 | 基础计数器可能只提供部分测量和触发字段 | +| Coupling | 固定 base channel 与 deviation 模型 | 部分设备使用 ratio、tracking direction 或不公开 base channel | + +### capability 只是粗粒度路由 + +`InstrumentDescriptor.capabilities` 当前是字符串 tuple。核心可以验证 capability 名字与必需方法, +但它不表示指定型号、通道、当前模式和读写方向的细节。方法存在也不会自动生成 capability。 + +新 Source capability 不能由插件单方增加。核心 registry 会拒绝未登记的 capability;公共 +descriptor、Protocol、Service、`OperationSpec`、artifact 和版本门必须由核心同时定义。 + +### 输出开启不只有一个入口 + +当前 `source.output` 在 ON 前读取基础状态并检查 `max_source_vpp`。该检查不覆盖偏置、 +谐波、AM、Noise、Combine、负载语义、频率降额和共享功率。另外,以下操作也可能开始 +或改变端口信号: + +- `source.arb_load` 使用 `output_on=true`; +- 输出已开启时修改频率、函数、Vpp 或占空比; +- 修改谐波、调制、Sweep、Burst、Pulse、ARB、Combine、Coupling 或 Tracking; +- arm、fire、manual trigger、Gate 或 Sync 导致信号实际发出; +- 恢复操作将执行前为 ON 的输出重新开启。 + +只改 `SourceService.set_output()` 会留下真实旁路。Source V2 必须按「是否可能增加端口能量或 +开始发出信号」分类操作,而不是只检查方法名。 + +### 已有 session 底座可以复用 + +核心已为每次具体连接建立 `InstrumentSessionState`,并通过 `GuardedAuditedTransport` 实施 +access 与 health 门禁。`SessionTransactionCoordinator` 可以限定 I/O 类型、字段、步骤、 +deadline 和验证证据。 + +Source Service 当前尚未将普通写入、恢复和独立验证接入该协调者。现有 +`RestorableSourceState` 只覆盖 output、function、frequency、Vpp 和方波占空比,不能被描述为 +完整 Source snapshot。 + +## 术语与分层 + +- **可执行仪器 API V2**:当前 `wavebench.instrument.v2` 插件装载、descriptor 和 factory 合同。 +- **Source V1**:当前 `SourceStatus`、`SourceDriver` 和现有 `source.*` capability 合同。 +- **Source V2**:本 RFC 提议的 Source 领域能力、状态、预算和事务合同。该名称不表示 + `wavebench.instrument.v2` 已升级。 +- **facet**:只在特定功能和模式下适用的类型化状态分组。 +- **anchor**:决定 facet 是否可查询、可解释的基础状态字段集合。 +- **语义查询计划**:核心签发的 facet、anchor、作用域、副作用上限、查询数和 deadline 合同; + 不包含 SCPI 或厂商响应格式。 +- **协议查询计划**:插件为满足语义查询计划而选择的厂商命令、合法顺序和解析流程。 +- **一致 snapshot**:在同一 session epoch 和有界 deadline 内,查询前后 anchor 相同且所需 facet + 没有缺少的 snapshot。 +- **端口电压边界**:在明确负载语义下,信号端口最小电压下界、最大电压上界和 Vpp 上界。 +- **输出预算**:将所有已启用 contributor 转换为端口电压边界,并与配置安全上限比较的核心决策。 +- **结果未知**:写入可能已被仪器处理,但调用方无法证明最终结果。 +- **受影响字段闭包**:某项操作直接或间接可能改变、使证据失效或作为安全前置的最小字段集合。 +- **实际端接证据**:由显式实验配置、运行计划或受控人工确认绑定到资源与通道的物理负载信息。 +- **显示负载**:仪器用于计算和显示幅度的负载设置;它不证明端口外部的真实端接。 +- **storage mutation**:创建、覆盖、删除或重命名仪器存储槽位的操作。它可以不产生端口信号, + 但可能造成不可恢复的数据变化,不能作为普通输出配置处理。 + +## 目标 + +- 保持 V1 `source.idn`、`source.status`、基础 setter、`source.output`、CLI 和 run plan 可迁移。 +- 允许驱动如实表示部分支持、模式不适用、未查询和未知状态。 +- 让 snapshot 查询适配状态依赖、消费型或存在其他副作用的查询。 +- 让输出决策基于端口总波形的保守边界,不把基础 Vpp 当作高级模式的充分条件。 +- 让插件只声明可以无损实现且经过测试的 read、configure、enable、disable、arm 和 fire 方向。 +- 将所有可能改变端口能量的入口统一到同一安全决策,并防止 Service、run plan、TUI + 或恢复路径绕过。 +- 复用现有 session health、结构化 transport 错误、资源租约和核心授权,不创建第二套锁存模型。 +- 将功能声明、运行时状态、安全决策和实机验收作为四层独立证据。 + +## 非目标 + +- 不统一厂商 SCPI 助记符、响应文本或私有波形存储方式。 +- 不把全部 Source 私有功能压缩为最低公分母命令。 +- 不允许通过发送未知命令并读取错误队列自动探测 capability。 +- 不把 descriptor 声明、方法存在或单台仪器 A4 证据解释为整个型号系列可安全使用。 +- 不把示波器环回测量解释为校准证书或仪器计量合格结论。 +- 不要求 V1 驱动一次性迁移,也不继续向 V1 profile 增加厂商专用必填字段。 +- 不在 R2 冻结 V2 写 CLI 或 run plan schema;只读 `snapshot-v2` 除外。 +- 不将实际端接从仪器显示负载或型号名自动推断。 +- 不在 `poisoned` session 上尝试 OFF、IDN、恢复或验证 I/O。 + +## 插件信任边界 + +Python 插件仍以 WaveBench 进程权限运行,不是安全沙箱。Source V2 可以收紧公共调用路径, +但无法阻止恶意插件绕过 Python 公共契约。 + +运行时安全不能只依赖 descriptor 中的自我声明。核心必须仍然执行 access policy、资源租约、 +session health、`OperationSpec`、预算决策和独立回读。插件证据引用只是可审查 metadata, +不是运行时授权 token。 + +## 强制安全不变量 + +本节规则自 `Accepted R2` 起构成强制合同。M1–M2 只消费其中的只读子集;涉及能量转换、存储 +mutation 或恢复写入的规则仍作为对应写 capability 的后续准入门。 + +1. V1 公共 model、现有 capability ID 和插件调用语法不增加新的必填字段。 +2. capability 只用于路由;类型化 descriptor profile、运行时 snapshot、预算与 access/session + 门必须全部通过。 +3. 任一安全相关字段为 `UNKNOWN`、`UNAVAILABLE`、`NOT_QUERIED` 或未被所需查询覆盖时,不得开启输出、 + 重新开启输出或执行可能增加端口能量的写入。 +4. `NOT_APPLICABLE` 不等于 0,`NOT_QUERIED` 不等于 `UNSUPPORTED`,测量值不得补成仪器状态值。 +5. 输出预算只使用 `VALUE` 且有明确单位、语义和证据来源的值。 +6. 上层不得使用 V2 → V1 展平视图进行预算、写前比较、恢复或安全决策。 +7. 一致性未证明或 anchor 复读不一致的 snapshot 不得作为写事务 baseline。 +8. 任一目标字段在一次 operation 中最多执行一次写入;结果未知不得自动重试。 +9. 一次公共 operation 只能属于一个 operation context;phase authorization 必须顺序、非嵌套、 + 绑定 session epoch、字段闭包、最大步骤和绝对 deadline。 +10. 普通插件方法不接收 session 授权 token,不能将 session health 恢复为 `healthy`。 +11. `uncertain` 只允许核心授权的有界 recovery/verification I/O;`poisoned` 上的旧连接只允许 close。 +12. 应急恢复的默认结束状态是受影响输出 OFF,不会在失败路径自动恢复为 ON。 +13. 恢复时重新开启输出是新的、显式授权的输出操作,必须使用 fresh snapshot 重新计算预算。 +14. 显示负载不证明实际端接。实际端接无证据且负载可能改变端口电压时,预算必须失败关闭。 +15. artifact 不记录授权 token、baseline nonce、完整仪器响应、真实资源串、序列号或凭据。 +16. 插件未声明 Source V2 时,核心不会从型号、方法存在或 V1 profile 自动推导 V2 写能力。 +17. Source V2 能量增加操作必须显式配置 Vpp 与端口绝对电压上下限;缺失不表示无限制。 +18. Source V2 首个可写修订只允许相关输出 OFF 时配置;该限制不追溯改变 V1 行为,也不表示 + 仪器硬件不支持 live mutation。 +19. storage mutation、波形选择/配置和输出 ON 是三个独立 operation,不共享一次准入决定。 + +## R2 公共集成合同 + +Source V2 的公共 model、enum、descriptor extension 和 driver Protocol 统一放在 +`wavebench.instruments.source_extensions`,并由 `wavebench.instruments` 重导出。 +Service coordinator、授权句柄、baseline nonce 和 artifact writer 属于核心内部实现, +不得从该公共模块导出。 + +### `InstrumentDescriptor` 扩展 + +R2 决定参照 `ScopeDescriptorExtensions`,在 `InstrumentDescriptor` 现有末尾字段 +`scope_extensions` 之后增加可选的 +`source_extensions: SourceDescriptorExtensions | None = None`。此前全部字段的顺序、默认值、 +`frozen`、比较和 `dataclasses.replace()` 语义保持不变。只传入旧 V2 位置参数的 descriptor +必须继续构造成功,新增字段得到 `None`;不能把 `source_extensions` 插入 +`config_fields`、`resource_schemes` 或 `scope_extensions` 之前。 + +冻结形态: + +```python +SOURCE_CONTRACT_VERSION = "wavebench.source.v2" + + +@dataclass(frozen=True, slots=True) +class SourceDescriptorExtensions: + contract_version: Literal["wavebench.source.v2"] + topology: SourceTopologyContract + features: tuple[SourceFeatureCapability, ...] + query_contract: SourceQueryContract + safety_profile: SourceSafetyProfile = SourceSafetyProfile() + + +@dataclass(frozen=True, slots=True) +class SourceFeatureCapability: + feature: SourceFeature + support: SupportState + directions: tuple[SourceFeatureDirection, ...] + scope: SourceFacetScope + channels: tuple[int, ...] + applicability: SourceConstraintApplicability + profile: SourceFeatureProfile + evidence_refs: tuple[str, ...] = () +``` + +`SourceFeatureCapability` 不使用任意 `Mapping[str, object]` 作为公共约束容器。各功能必须选择 +已注册的 typed profile,例如 `SourceBasicCapabilityProfile`、 +`SourceHarmonicCapabilityProfile` 或 `SourceBurstCapabilityProfile`。核心按现有 factory +生命周期分层验证: + +- registry 在调用 driver factory 前验证 `source_extensions` 只能由 `kind="source"` 的 + descriptor 使用,并验证 contract version、topology、feature 名、operation 方向、通道、 + typed profile、capability 和核心版本范围相互一致; +- factory 返回实例后,核心立即验证 capability 所需的 Protocol 方法;失败时关闭 factory 已打开的 + transport,且不得调用任何 Source operation method; +- 多声明的方法不生成隐式 capability; +- profile 只能收紧核心数值上限、deadline 和恢复步骤,不能放宽; +- 使用 Source V2 的插件必须提高 wheel 与 descriptor 的最低核心版本。 + +R2 不改变现有 eager factory 合同:factory 可以调用 `DriverContext.open_transport()`。因此 +Protocol 方法缺失能够保证「零 Source operation 命令」,不能保证「零连接建立」。离线 A0 与插件 +发布检查必须在真实资源使用前发现这类声明错误。插件绕过 `DriverContext` 自行访问设备或网络 +属于违反插件信任合同;核心不把进程内第三方代码描述成可沙箱化代码。 + +`wavebench.instrument.v2` 保持不变。新插件的 wheel `Requires-Dist` 是旧核心导入公共类型前的 +第一道版本门,descriptor `wavebench_min_version` 是加载 descriptor 后、factory 前的第二道版本门。 +Source V2 distribution 的生效 `Requires-Dist: wavebench ...` 必须使用 PEP 440,并明确包含 +`>=descriptor.wavebench_min_version,= max`、缺少任一边界、wheel 范围扩大,或 +两者首个支持核心版本不一致。该校验只作用于 opt-in 的 Source V2 插件,不改变旧 V1 descriptor +当前接受的版本字符串。 + +R5 在受管安装的 postflight 中执行这项交叉校验:通用 wheel 检查先在 entry point import 前确认 +当前环境恰有一条生效的 `wavebench` 依赖;随后 descriptor 已加载、但 driver factory 尚未调用时, +核心要求同一条生效依赖显式包含 descriptor 的 `>=min, SourceQueryExecutionRecord: ... +``` + +R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。后续写能力使用以下保留 ID; +只有对应 operation contract、Protocol、Service 和 A0 验收同时完成时,才能逐项注册: + +| capability | required method | 范围 | +| --- | --- | --- | +| `source.basic_configure_v2` | `configure_source_basic_v2` | 基础函数、频率、Vpp、偏置和方波参数 | +| `source.output_v2` | `set_source_output_v2` | 单独的 ON/OFF 转换 | +| `source.harmonics_configure_v2` | `configure_source_harmonics_v2` | 谐波配置 | +| `source.modulation_configure_v2` | `configure_source_modulation_v2` | AM/FM/PM/PWM 等调制配置 | +| `source.pulse_configure_v2` | `configure_source_pulse_v2` | Pulse 配置 | +| `source.sweep_configure_v2` | `configure_source_sweep_v2` | Sweep 配置 | +| `source.burst_configure_v2` | `configure_source_burst_v2` | Burst 配置 | +| `source.arbitrary_storage_v2` | `mutate_source_arbitrary_storage_v2` | 创建或覆盖 ARB 存储槽位 | +| `source.arbitrary_select_v2` | `select_source_arbitrary_v2` | 选择并配置已存在的 ARB | +| `source.combine_configure_v2` | `configure_source_combine_v2` | Combine 关系 | +| `source.coupling_configure_v2` | `configure_source_coupling_v2` | Coupling 关系 | +| `source.tracking_configure_v2` | `configure_source_tracking_v2` | Tracking 关系 | +| `source.phase_relation_configure_v2` | `configure_source_phase_relation_v2` | 跨通道相位关系 | +| `source.burst_arm_v2` | `arm_source_burst_v2` | Burst 准备动作 | +| `source.burst_fire_v2` | `fire_source_burst_v2` | Burst 发出动作 | +| `source.sweep_arm_v2` | `arm_source_sweep_v2` | Sweep 准备动作 | +| `source.sweep_fire_v2` | `fire_source_sweep_v2` | Sweep 发出动作 | + +保留 ID 不是已注册 capability,也不是实施授权。新增其它 V2 写 ID 必须通过后续 RFC 修订, +不得由插件自行拼接字符串。 + +Source V2 驱动不接收 `SessionAuthorization`、`InstrumentSessionState` 或 raw transport handle。 +核心在授权 phase 中调用已冻结的 driver 方法,driver 只返回公共类型化 model。 + +核心拥有语义查询计划、descriptor 决策、activation rule、session epoch 和 snapshot consistency。 +插件拥有协议查询计划,包括具体 SCPI、合法顺序和解析。driver 只报告按语义计划取得的 +类型化值、未取得值的受限诊断,以及实际执行的查询项;不得自行签发 +`session_epoch`、`CONSISTENT`、`UNSUPPORTED` 或 `NOT_APPLICABLE`。核心必须根据 descriptor、 +anchor 和实际查询记录重建 `Observed`,并在返回 snapshot 前逐项验证。driver 不返回整包 +`SourceSnapshotV2`,也不能把协议查询计划或原始响应文本放进公共返回值。 + +### snapshot-only Service、CLI 与 operation + +R2 冻结 Service 签名: + +```python +def snapshot_v2( + self, + *, + correlation_id: str | None = None, +) -> SourceSnapshotV2: ... +``` + +未提供 correlation ID 时由核心生成。Service 在同一独占资源租约、`healthy` session epoch 和 +transaction lock 内构造 semantic plan;driver 返回后,核心校验 item、effect、query count、 +deadline 和观测字段,再构造 snapshot。`TransportIOError` 与 `SessionHealthError` 原样优先抛出; +公共执行记录不进入 Service 返回值。 + +`source.snapshot_v2` 的通用 operation metadata 固定为: + +```python +OperationSpec( + operation="source.snapshot_v2", + instrument_kind="source", + required_capabilities=("source.snapshot_v2",), + effect="stateful_read", + lease_mode="exclusive", + changed_fields=(), + restore_coverage="none-read-only", + session_purpose="normal", + required_verified_fields=(), + verification_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + postcondition_fields=(), + cleanup_verification_fields=(), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5000, + error_check_minimum="disabled", + risk_flags=("state_dependent_query",), +) +``` + +实际 deadline 取 5000 ms、descriptor `query_contract.timeout_ms` 和 connection timeout 的最小值。 +operation 本身不消费错误队列;插件协议查询必须使用 `ReplayPolicy.NO_REPLAY`。 + +CLI 固定为 `wavebench source snapshot-v2 [--config PATH] [--resource RESOURCE]`,不接收 channel、 +facet、raw query 或 effect 覆盖参数;它总是查询 descriptor topology 的完整 snapshot。普通模式输出 +缩进 JSON,`--json` 使用现有 `wavebench.cli.result.v1` envelope。`result` 是 +`wavebench.source.operation.v1`,其 `snapshot` 字段是 `wavebench.source.snapshot.v2`: + +```json +{ + "schema": "wavebench.cli.result.v1", + "status": "ok", + "exit_code": 0, + "result": { + "schema": "wavebench.source.operation.v1", + "operation": "source.snapshot_v2", + "context_id": "", + "correlation_id": "", + "session_epoch": "", + "capability_decision": { + "capability": "source.snapshot_v2", + "contract_version": "wavebench.source.v2", + "descriptor_digest": "sha256:<64 lowercase hex>" + }, + "snapshot": { + "schema": "wavebench.source.snapshot.v2", + "type": "SourceSnapshotV2" + }, + "query": { + "effect": "pure_read", + "plan_digest": "sha256:<64 lowercase hex>", + "query_count": 1 + }, + "session_health": {"before": "healthy", "after": "healthy"}, + "final_state": {"consistency": "consistent", "session_health": "healthy"}, + "evidence_refs": [] + } +} +``` + +示例中的 snapshot 只省略其余已冻结字段,不表示允许任意 shape。CLI 不提供 raw protocol record, +也不把 device-native revision token、资源串、序列号、命令或响应写入 artifact;revision token 只以 +SHA-256 出现在 snapshot consistency 中。 + +### `OperationSpec` 与入口覆盖 + +每个新公共 operation 必须冻结: + +- required 与 optional capability; +- effect、lease mode 和 session purpose; +- `changed_fields`、`required_verified_fields`、`verification_fields`、 + `postcondition_fields` 和 `cleanup_verification_fields`; +- `restore_coverage`、绝对 deadline 来源、最大步骤和 error policy; +- 是否可能增加端口能量,或在静态配置完成后发出信号; +- 对应 Service、CLI/run 入口和 artifact 字段。 + +Source V2 operation 在通用 `OperationSpec` 之外绑定以下 typed contract: + +```python +class SourceEnergyEffect(StrEnum): + NONE = "none" + DECREASE_ONLY = "decrease_only" + POTENTIAL_WHILE_OFF = "potential_while_off" + MAY_INCREASE = "may_increase" + EMIT = "emit" + UNKNOWN = "unknown" + + +class SourceStorageEffect(StrEnum): + NONE = "none" + READ = "read" + CREATE = "create" + REPLACE = "replace" + DELETE = "delete" + UNKNOWN = "unknown" + + +class SourceV1WriteRouteId(StrEnum): + SET_FREQUENCY = "source_service.set_frequency" + SET_FUNCTION = "source_service.set_function" + SET_AMPLITUDE_VPP = "source_service.set_amplitude_vpp" + SET_SQUARE_DUTY_CYCLE = "source_service.set_square_duty_cycle" + SET_OUTPUT = "source_service.set_output" + CONFIGURE_COUPLING = "source_service.configure_coupling" + CONFIGURE_HARMONICS = "source_service.configure_harmonics" + CONFIGURE_AM = "source_service.configure_am_modulation" + CONFIGURE_FM = "source_service.configure_fm_modulation" + CONFIGURE_PM = "source_service.configure_pm_modulation" + CONFIGURE_PWM = "source_service.configure_pwm_modulation" + CONFIGURE_PULSE = "source_service.configure_pulse" + CONFIGURE_BURST = "source_service.configure_burst" + TRIGGER_BURST = "source_service.trigger_burst" + CONFIGURE_SWEEP = "source_service.configure_sweep" + TRIGGER_SWEEP = "source_service.trigger_sweep" + UPLOAD_ARBITRARY = "source_service.upload_arbitrary_waveform" + RESTORE = "source_service.restore_restorable_state" + + +@dataclass(frozen=True, slots=True) +class SourceOperationContract: + operation: str + capability: str + feature: SourceFeature + direction: SourceFeatureDirection + energy_effect: SourceEnergyEffect + storage_effect: SourceStorageEffect + required_fields: tuple[SourceFieldId, ...] + changed_fields: tuple[SourceFieldId, ...] + postcondition_fields: tuple[SourceFieldId, ...] + cleanup_verification_fields: tuple[SourceFieldId, ...] + v1_equivalent_routes: tuple[SourceV1WriteRouteId, ...] + v1_overlapping_routes: tuple[SourceV1WriteRouteId, ...] + operation_timeout_ms: int + main_max_steps: int + recovery_max_steps: int + verification_max_steps: int + + +@dataclass(frozen=True, slots=True) +class SourceAffectedClosure: + operation: str + context_id: str + session_epoch: str + baseline_snapshot_digest: str + fields: tuple[SourceFieldRef, ...] + required_off_outputs: tuple[SourceScopeRef, ...] + emergency_off_outputs: tuple[SourceScopeRef, ...] + restore_order: tuple[SourceFieldRef, ...] + non_restorable_fields: tuple[SourceFieldRef, ...] + closure_digest: str +``` + +`SourceOperationContract` 是静态注册表项;`SourceAffectedClosure` 由核心根据 request、topology、 +runtime profile 和当前跨通道关系在任何 mutation 前实例化。closure 只能扩大静态字段模板, +不能遗漏 descriptor 声明的依赖。`closure_digest` 使用 canonical JSON SHA-256,并绑定 context、 +epoch 和 baseline。R4 已把这些两个 model、固定 phase 和 core-only coordinator 写入核心;它们 +仍不注册任何 Source V2 写 capability,也不提供 Service、CLI 或 run plan 写入口。 + +`v1_equivalent_routes` 与 `v1_overlapping_routes` 不得重复。前者表示同一用户意图,后者表示虽然 +入口名称不同,但可能修改该 operation 的字段闭包或经同一路径发出信号。未出现在当前已注册 V2 +写合同中的 route 仍需在全量审计矩阵中标记为不相交,不能因漏填 tuple 自动视为安全。 + +`UNKNOWN` energy/storage effect 在 I/O 前拒绝。`POTENTIAL_WHILE_OFF` 要求全部 +`required_off_outputs` 已通过 fresh readback 证明 OFF,并且 operation 本身不能打开输出。 +`MAY_INCREASE` 与 `EMIT` 必须通过统一预算门。`DECREASE_ONLY` 不需要预算,但仍需 access、 +session、operation context 和 postcondition。 + +只要 descriptor 声明 V2 写 capability,以下路径就必须进入同一套核心安全决策路径: + +| 路径 | 统一要求 | +| --- | --- | +| V2 输出 ON | fresh 一致 snapshot + 预算 + 写后回读 | +| V1 同义写入口调用双合同驱动 | 在 Service 边界映射到对应 V2 operation,无法无损映射时在 I/O 前拒绝 | +| `source.arb_load output_on=true` | 先在输出 OFF 的配置 phase 完成上传或选择,再用 fresh snapshot 签发只授权下一次 ON 的新决定 | +| 输出 ON 时的 Source V2 setter/patch | 首版在 I/O 前拒绝;未来若允许 live mutation,必须对完整目标状态使用专项预算合同 | +| arm/fire/trigger | 在可能发出信号前完成预算与接线证据检查 | +| 恢复为 ON | 作为独立、显式授权的 ON 操作重新计算预算 | + +V1 驱动未 opt in 时继续使用现有 V1 路径,不伪装成已获得 Source V2 复合安全保证。 +Source V2 的首版 live-mutation 禁令不追溯改变 V1 驱动的既有行为,也不表示硬件本身不支持 +ON 状态写入。 + +每个 V2 写 capability 必须在 `SourceOperationContract` 中登记其 V1 等价入口、重叠字段和可能发出 +信号的间接入口。双合同驱动声明该 capability 后,只有落入这些集合的 V1 路径必须映射到 V2 +operation 或在 I/O 前拒绝;字段闭包完全不相交的 V1 operation 可以继续走 V1。核心仍需审计完整 +V1 写表面,防止遗漏隐式副作用。OFF 不需要复合预算,但不能绕过 access、session health、 +operation context 和必要回读。插件不能通过保留旧方法名重新引入同字段或同发信号路径的旁路。 + +`source.output_v2` 的等价集合至少包括 V1 `set_output(ON)`、ARB 的 `output_on=True`、会发出信号的 +trigger/fire 和恢复 ON;`source.arbitrary_storage_v2` 至少接管 V1 上传入口,但不因此接管无关的 +频率 setter。若一个 V1 方法把上传、选择和 ON 合并为一次调用,它不能部分映射,必须拆分为多个 +V2 operation 或在 I/O 前拒绝。该规则允许按 feature 渐进 opt in,同时保证已迁移字段没有 V1 旁路。 + +### Service 与 operation context + +R4 实现内部 `SourceOperationContextCoordinator`。它复用 `InstrumentSessionState`、 +`SessionTransactionCoordinator` 和 guarded transport,但不直接复用 +`ScopeOperationContextCoordinator` 的 scope/binary 假设。 + +冻结 phase: + +```python +class SourceOperationPhase(StrEnum): + PREFLIGHT = "preflight" + MAIN = "main" + POSTCONDITION = "postcondition" + FAILURE_SAFE_STATE = "failure_safe_state" + FAILURE_RESTORE = "failure_restore" + CLEANUP_VERIFICATION = "cleanup_verification" +``` + +正常顺序: + +```text +offline validation + -> PREFLIGHT + -> MAIN + -> POSTCONDITION + -> terminal success +``` + +可能发生 mutation 后的失败顺序: + +```text +close MAIN authorization + -> FAILURE_SAFE_STATE + -> FAILURE_RESTORE + -> CLEANUP_VERIFICATION + -> terminal failure artifact +``` + +phase purpose 固定为:`PREFLIGHT`、`POSTCONDITION` 和 `CLEANUP_VERIFICATION` 使用 +`verification`;`MAIN` 使用 `normal`;`FAILURE_SAFE_STATE` 和 `FAILURE_RESTORE` 使用 +`recovery`。verification phase 禁止 write;每个 phase 最多进入一次,授权严格顺序且不能嵌套。 + +`PREFLIGHT` 负责 fresh snapshot、runtime profile、受影响字段闭包、baseline、输出 OFF 前置、 +端接证据和预算。`MAIN` 只执行已冻结 request。`POSTCONDITION` 独立读取目标字段和未修改闭包。 +`FAILURE_SAFE_STATE` 只执行预先计算的 emergency OFF;`FAILURE_RESTORE` 只恢复不会重新供能的 +明确字段;`CLEANUP_VERIFICATION` 独立证明 OFF、恢复字段和 session 证据。 + +每个 context 绑定 `context_id`、`operation_id`、`correlation_id`、`session_epoch`、资源租约、 +`hard_deadline`、更早的 `main_deadline`、不可退还的 cleanup reserve 和受影响字段闭包。 +normal phase 不得消耗 cleanup reserve;recovery/verification phase 的实际 deadline 取静态上限与 +`hard_deadline` 剩余时间的较小值。同一个 context 中最多存在一个 active authorization。 + +cleanup reserve 为 operation timeout 的 20%,最少 1000 ms、最多 5000 ms,但不得超过当前 +hard-deadline 剩余时间的一半。caller deadline 只能继续收紧 hard deadline。若剩余时间不足以 +保留至少 1 ms 的 main 时间,operation 在 transport I/O 前拒绝。 + +## 类型化能力模型 + +### feature 与方向 + +R2 冻结以下 enum;snapshot-only descriptor 只能声明 `READ`,其余方向保留给后续写修订: + +```python +class SourceFeature(StrEnum): + BASIC = "basic" + OUTPUT = "output" + HARMONICS = "harmonics" + MODULATION = "modulation" + SWEEP = "sweep" + BURST = "burst" + PULSE = "pulse" + ARBITRARY = "arbitrary" + COUNTER = "counter" + REFERENCE_CLOCK = "reference_clock" + SYNC = "sync" + CASCADE = "cascade" + COMBINE = "combine" + TRACKING = "tracking" + COUPLING = "coupling" + COPY = "copy" + PHASE_RELATION = "phase_relation" + SHARED_POWER = "shared_power" + + +class SourceFeatureDirection(StrEnum): + READ = "read" + CONFIGURE = "configure" + ENABLE = "enable" + DISABLE = "disable" + ARM = "arm" + FIRE = "fire" + + +class SourceWaveformKind(StrEnum): + SINE = "sine" + SQUARE = "square" + RAMP = "ramp" + PULSE = "pulse" + NOISE = "noise" + DC = "dc" + ARBITRARY = "arbitrary" + OTHER = "other" + + +class SourceFrequencyMode(StrEnum): + FIXED = "fixed" + SWEEP = "sweep" + LIST = "list" + UNKNOWN = "unknown" + + +class SourceArbitraryPlaybackMode(StrEnum): + DDS = "dds" + TRUE_ARB = "true_arb" + UNKNOWN = "unknown" + + +SourceAnchorValue: TypeAlias = ( + bool | SourceWaveformKind | SourceFrequencyMode | SourceArbitraryPlaybackMode +) +``` + +facet 辅助 enum 也使用封闭 value 集: + +- `SourceAmplitudeUnit`:`vpp`、`vrms`、`dbm`、`v`、`unknown`; +- `SourceOutputPolarity`:`normal`、`inverted`、`unknown`; +- `SourceLoadKind`:`high_impedance`、`resistive`、`unknown`; +- `SourceModulationKind`:`am`、`dsb_am`、`fm`、`pm`、`pwm`、`ask`、`fsk`、`psk`、`other`; +- `SourceModulationSource`:`internal`、`external`、`channel`、`unknown`; +- `SourceModulationParameterKind`:`depth_percent`、`frequency_deviation_hz`、 + `phase_deviation_deg`、`duty_deviation_percent`、`symbol_rate_hz`; +- `SourceSweepSpacing`:`linear`、`logarithmic`、`step`、`unknown`; +- `SourceTriggerSource`:`internal`、`external`、`manual`、`bus`、`unknown`; +- `SourceTriggerSlope`:`positive`、`negative`、`either`、`unknown`; +- `SourceTriggerOutput`:`off`、`positive`、`negative`、`unknown`; +- `SourceBurstMode`:`triggered`、`gated`、`infinity`、`unknown`; +- `SourceGatePolarity`:`normal`、`inverted`、`unknown`; +- `SourcePulseHoldBasis`:`width`、`duty`、`unknown`; +- `SourceCounterMeasurementKind`:`frequency_hz`、`period_s`、`duty_percent`、 + `positive_width_s`、`negative_width_s`、`unknown`; +- `SourceInputCoupling`:`ac`、`dc`、`unknown`; +- `SourceReferenceClockMode`:`internal`、`external`、`auto`、`unknown`。 + +方向必须按 feature、通道和模式声明。例如 Harmonic `read` 不表示 `configure`; +`disable` 不表示 `enable`;Burst 的内部 `fire` 不表示外部 Gate 已完成 A5 接线验收。 +这种拆分允许驱动诚实声明「可以安全关闭,但尚不能证明可安全开启」。 + +机器可读 feature ID 按作用域分为: + +- channel:`basic`、`output`、`harmonics`、`modulation`、`sweep`、`burst`、`pulse`、`arbitrary`; +- system:`counter`、`reference_clock`、`sync`、`cascade`; +- cross-channel:`combine`、`tracking`、`coupling`、`copy`、`phase_relation`、`shared_power`。 + +正文中的 Harmonic、Sync、Combine 等首字母大写名称只是展示术语;注册表、artifact 和 +descriptor 一律使用上述小写 ID,不允许通过大小写或单复数增加别名。 + +feature 集合是核心注册表,不接受插件自定义任意字符串作为新安全语义。厂商专用功能可继续 +使用独立 capability,但未经核心注册时不进入通用 Source V2 预算或恢复。 + +R2 冻结以下 11 个只读 capability profile。布尔字段只声明该值能否读取,不提供写授权;tuple +使用 enum value 或 ID 的升序并且不重复。 + +```python +@dataclass(frozen=True, slots=True) +class SourceBasicCapabilityProfile: + waveform_kinds: tuple[SourceWaveformKind, ...] + frequency_modes: tuple[SourceFrequencyMode, ...] + amplitude_units: tuple[SourceAmplitudeUnit, ...] + offset_readable: bool + phase_readable: bool + square_duty_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceOutputCapabilityProfile: + output_readable: bool + display_load_readable: bool + polarity_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceHarmonicCapabilityProfile: + minimum_order: int + maximum_order: int + amplitude_kinds: tuple[ComponentAmplitudeKind, ...] + completeness_modes: tuple[HarmonicCompleteness, ...] + + +@dataclass(frozen=True, slots=True) +class SourceModulationCapabilityProfile: + kinds: tuple[SourceModulationKind, ...] + sources: tuple[SourceModulationSource, ...] + parameter_kinds: tuple[SourceModulationParameterKind, ...] + inactive_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceSweepCapabilityProfile: + spacing_modes: tuple[SourceSweepSpacing, ...] + trigger_sources: tuple[SourceTriggerSource, ...] + timing_readable: bool + marker_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceBurstCapabilityProfile: + modes: tuple[SourceBurstMode, ...] + trigger_sources: tuple[SourceTriggerSource, ...] + timing_readable: bool + gate_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourcePulseCapabilityProfile: + hold_modes: tuple[SourcePulseHoldBasis, ...] + delay_readable: bool + transitions_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryCapabilityProfile: + playback_modes: tuple[SourceArbitraryPlaybackMode, ...] + selection_readable: bool + storage_metadata_readable: bool + sample_rate_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceCounterCapabilityProfile: + input_ids: tuple[str, ...] + measurement_kinds: tuple[SourceCounterMeasurementKind, ...] + configuration_readable: bool + query_effect: SourceQueryEffect + + +@dataclass(frozen=True, slots=True) +class SourceClockSyncCapabilityProfile: + reference_clock_modes: tuple[SourceReferenceClockMode, ...] + sync_readable: bool + cascade_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceCrossChannelCapabilityProfile: + relation_kinds: tuple[SourceFeature, ...] + supported_channel_sets: tuple[tuple[int, ...], ...] + relation_graph_readable: bool + shared_power_constraint_readable: bool +``` + +`SourceFeatureProfile` 是上述公共 profile 的封闭 union: + +```python +SourceFeatureProfile: TypeAlias = ( + SourceBasicCapabilityProfile + | SourceOutputCapabilityProfile + | SourceHarmonicCapabilityProfile + | SourceModulationCapabilityProfile + | SourceSweepCapabilityProfile + | SourceBurstCapabilityProfile + | SourcePulseCapabilityProfile + | SourceArbitraryCapabilityProfile + | SourceCounterCapabilityProfile + | SourceClockSyncCapabilityProfile + | SourceCrossChannelCapabilityProfile +) +``` + +feature 与 profile 类型使用固定映射;例如 `HARMONICS` 只能使用 +`SourceHarmonicCapabilityProfile`。union 新增成员属于公共合同扩展,必须由核心注册并补版本门。 + +### facet 作用域 + +```python +class SourceFacetScope(str, Enum): + CHANNEL = "channel" + CHANNEL_SET = "channel_set" + INSTRUMENT = "instrument" + INPUT = "input" + + +@dataclass(frozen=True, slots=True) +class SourceScopeRef: + scope: SourceFacetScope + channel: int | None = None + channels: tuple[int, ...] = () + input_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class SourceTopologyContract: + channels: tuple[int, ...] + input_ids: tuple[str, ...] = () +``` + +`SourceScopeRef` 必须且只能使用与 scope 对应的字段:`CHANNEL` 使用一个正整数 `channel`; +`CHANNEL_SET` 使用至少两个、递增且不重复的 `channels`;`INPUT` 使用一个核心安全 token +格式的 `input_id`;`INSTRUMENT` 不携带这些字段。所有通道必须属于 topology。 +`SourceTopologyContract.channels` 必须递增、唯一且非空;`input_ids` 必须排序稳定且不重复。 + +- `basic`、`output`、`harmonics`、`modulation`、`pulse`、`sweep`、`burst` 和 `arbitrary` + 通常属于 `CHANNEL`; +- `combine`、`coupling` 和 `tracking` 属于 `CHANNEL_SET`,并明确列出关系参与者; +- `reference_clock`、`sync` 和 `cascade` 属于 `INSTRUMENT` 或 `CHANNEL_SET`; +- `counter` 通常属于独立 `INPUT`,只有存在已声明路由关系时才参与输出预算。 + +descriptor 中的 topology 是静态上界。实际 capability 必须根据已验证的型号、固件、选件和 +当前连接代次收紧。 + +### 字段引用 + +OperationSpec、query plan、snapshot、预算、恢复和 artifact 统一使用结构化字段引用,不再使用 +`"coupling"` 或 `"trigger"` 这类无作用域字符串: + +```python +class SourceFieldId(StrEnum): + IDENTITY = "source.identity" + BASIC = "source.channel.basic" + OUTPUT = "source.channel.output" + DISPLAY_LOAD = "source.channel.display_load" + HARMONICS = "source.channel.harmonics" + MODULATION = "source.channel.modulation" + SWEEP = "source.channel.sweep" + BURST = "source.channel.burst" + PULSE = "source.channel.pulse" + ARBITRARY_SELECTION = "source.channel.arbitrary_selection" + ARBITRARY_STORAGE = "source.channel.arbitrary_storage" + ARM_STATE = "source.channel.arm_state" + TRIGGER_STATE = "source.channel.trigger_state" + COMBINE = "source.cross_channel.combine" + COUPLING = "source.cross_channel.coupling" + TRACKING = "source.cross_channel.tracking" + COPY = "source.cross_channel.copy" + PHASE_RELATION = "source.cross_channel.phase_relation" + RELATION_GRAPH = "source.cross_channel.relation_graph" + REFERENCE_CLOCK = "source.instrument.reference_clock" + SYNC = "source.instrument.sync" + CASCADE = "source.instrument.cascade" + SHARED_POWER = "source.instrument.shared_power" + COUNTER = "source.input.counter" + + +@dataclass(frozen=True, slots=True) +class SourceFieldRef: + field: SourceFieldId + target: SourceScopeRef +``` + +每个 `SourceFieldId` 固定允许的 scope;构造时必须校验 field 与 target。tuple 序列化按 +`field.value`、scope、channel/channels/input_id 的稳定顺序排序。厂商字段只能映射到这些 ID, +不能进入公共字段闭包。 + +### 跨通道关系图 + +```python +class SourceSignalPathKind(StrEnum): + INTERNAL_WAVEFORM = "internal_waveform" + OUTPUT_PORT = "output_port" + CONFIG_TRACKING = "config_tracking" + SHARED_RESOURCE = "shared_resource" + + +@dataclass(frozen=True, slots=True) +class SourceRelationEdge: + relation_id: str + feature: SourceFeature + sources: tuple[int, ...] + targets: tuple[int, ...] + signal_path: SourceSignalPathKind + affected_fields: tuple[SourceFieldId, ...] + implicit_changed_fields: tuple[SourceFieldId, ...] + + +@dataclass(frozen=True, slots=True) +class SourceRelationGraph: + channels: tuple[int, ...] + edges: tuple[SourceRelationEdge, ...] +``` + +`sources` 和 `targets` 非空、递增且属于 topology。Combine 使用有向 edge;来源通道的输出 relay +是否参与由 `signal_path` 明示,不能从 source output 状态推断。共享功率使用 +`SHARED_RESOURCE`,其 participants 为 edge 的 sources/targets 并集。 + +R2 首版拒绝形成有向环的关系图,也拒绝无法解析参与者的关系。affected closure 从目标 operation +沿 edge 依赖展开,必须加入 `implicit_changed_fields` 和所有可能发出信号的 target output。 +例如开启 Combine 会隐式同步两通道 load 时,两通道 `DISPLAY_LOAD` 都属于 changed fields。 + +### activation rule + +activation rule 只允许引用核心注册的 canonical anchor,不接受字符串表达式、正则表达式、 +Python callback 或厂商命令: + +```python +class SourceAnchorField(StrEnum): + WAVEFORM_KIND = "waveform_kind" + FREQUENCY_MODE = "frequency_mode" + OUTPUT_ENABLED = "output_enabled" + HARMONICS_ENABLED = "harmonics_enabled" + MODULATION_ENABLED = "modulation_enabled" + SWEEP_ENABLED = "sweep_enabled" + BURST_ENABLED = "burst_enabled" + ARBITRARY_PLAYBACK_MODE = "arbitrary_playback_mode" + COMBINE_ENABLED = "combine_enabled" + COUPLING_ENABLED = "coupling_enabled" + TRACKING_ENABLED = "tracking_enabled" + + +@dataclass(frozen=True, slots=True) +class SourceActivationPredicate: + field: SourceAnchorField + equals: SourceAnchorValue + + +@dataclass(frozen=True, slots=True) +class SourceActivationRule: + predicates: tuple[SourceActivationPredicate, ...] +``` + +predicate 相对于当前 semantic query item 的 target 求值。单条 rule 的 predicate 使用 AND; +多个 rule 使用 OR。predicate 按 field 排序且不重复,`equals` 类型必须与 anchor 匹配。首版没有 +NOT、任意算术、跨 target 引用或插件回调;无法无损表示的激活条件,对应 facet 保持 `UNKNOWN`。 + +### 约束适用域与运行时收窄 + +```python +@dataclass(frozen=True, slots=True) +class ClosedFloatInterval: + minimum: float + maximum: float + + +@dataclass(frozen=True, slots=True) +class SourceConstraintApplicability: + models: tuple[str, ...] = () + firmware_ids: tuple[str, ...] = () + option_ids: tuple[str, ...] = () + waveform_kinds: tuple[SourceWaveformKind, ...] = () + frequency_hz: ClosedFloatInterval | None = None + amplitude_vpp: ClosedFloatInterval | None = None + offset_v: ClosedFloatInterval | None = None + + +@dataclass(frozen=True, slots=True) +class SourceRuntimeIdentity: + manufacturer: str + model: str + firmware_id: str + option_ids: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class SourceRuntimeCapabilityProfile: + session_epoch: str + descriptor_digest: str + identity: SourceRuntimeIdentity + features: tuple[SourceFeatureCapability, ...] +``` + +数值区间必须有限、拒绝 `bool`,并满足 `minimum <= maximum`。型号和固件使用精确 ID;R2 +不定义厂商固件版本排序,也不接受插件回调比较版本。安全约束若没有覆盖当前型号、固件、选件、 +波形、频率、幅度、偏置和显示负载,就不能进入 `HARD_CONSERVATIVE` 证明。 + +`SourceRuntimeCapabilityProfile` 由核心构造,绑定当前 epoch。核心对 descriptor 与 runtime identity +取交集;运行时只能删除 feature、direction、channel 或 constraint,不能增加 descriptor 未声明的 +内容。`descriptor_digest` 使用 canonical JSON 的 SHA-256,格式为 `sha256:<64 lowercase hex>`。 +driver 只在查询执行记录中报告类型化 identity 观测,不自行构造 runtime profile。 + +### 支持状态 + +```python +class SupportState(str, Enum): + SUPPORTED = "supported" + UNSUPPORTED = "unsupported" + UNKNOWN = "unknown" +``` + +- `SUPPORTED` 只表示 descriptor 声明该功能存在,仍需检查方向、通道、模式、运行时状态和安全证据; +- `UNSUPPORTED` 只能在已知型号或固件范围中使用,不发送探测命令; +- `UNKNOWN` 不得作为运行时写入授权。 + +## `Observed[T]` 合同 + +### 可用性状态 + +```python +class Availability(str, Enum): + VALUE = "value" + UNSUPPORTED = "unsupported" + NOT_APPLICABLE = "not_applicable" + NOT_QUERIED = "not_queried" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class Observed(Generic[T]): + availability: Availability + value: T | None = None + reason_code: SourceReasonCode | None = None + evidence_refs: tuple[str, ...] = () +``` + +R2 的 `SourceReasonCode` 注册表固定为: + +```text +descriptor_unsupported +support_unknown +not_requested +inactive_by_anchor +anchor_unavailable +response_missing_field +response_invalid_value +driver_skipped_optional +query_deadline_exceeded +query_limit_exceeded +protocol_record_invalid +required_observation_missing +session_not_healthy +consistency_unproven +consistency_drifted +``` + +插件只能在 `SourceProtocolQueryRecord` 中使用这些 code;最终 availability 仍由核心结合 descriptor、 +activation 和执行记录构造。新增 reason code 属于 append-only 公共合同变更。 + +### 构造不变量 + +1. `VALUE` 必须携带通过对应 model 校验的非 `None` 值。`False`、`0` 和空 tuple 是合法值, + 不得使用真值判断。 +2. 非 `VALUE` 必须使用 `value=None`。 +3. `UNAVAILABLE`、`UNKNOWN` 和 `NOT_QUERIED` 必须携带核心注册的 `reason_code`。 +4. `UNSUPPORTED` 必须能追溯到 descriptor 中的 feature/model 决策,不使用查询超时推断。 +5. `NOT_APPLICABLE` 必须由已验证 anchor 和已注册 activation rule 计算;插件自由文本不是证明。 +6. `NOT_QUERIED` 只表示查询计划主动跳过,不表示设备不支持。 +7. `UNAVAILABLE` 只表示该值按当前合同应存在,而且完整响应已经取得、同步仍为 `proven`、 + session 仍为 `healthy`,但响应在语义上缺字段或无法解析。 +8. `UNKNOWN` 表示支持或语义本身无法确定,不是查询失败的替代状态。 +9. `evidence_refs` 只保存稳定、脱敏的证据 ID 或摘要,不保存原始响应、本地路径或凭据。 +10. 安全相关的 `VALUE` 必须携带证据来源;纯展示字段可以不携带。 +11. 非有限浮点数不能构造为 `VALUE`,也不能进入 JSON artifact。 +12. facet 为 `VALUE` 只表示 facet 对象存在;其成员仍可分别为非 `VALUE`。 + +`TransportIOError` 和 `SessionHealthError` 不得降级成 `Observed`。一旦查询使 session 进入 +`uncertain` 或 `poisoned`,核心立即停止剩余 snapshot 查询并保留结构化异常优先级; +插件不能用 `UNAVAILABLE` 吞掉连接边界变化。 + +### 序列化 + +Source V2 artifact 必须序列化 `availability`、值的类型化表示、`reason_code` 和证据摘要。 +不得将非 `VALUE` 展平为 JSON `null` 后丢失原因。 + +R2 的 canonical serializer 规则为:dataclass 必须带 `type` 判别字段;enum 序列化为 value;tuple +序列化为 array;object key 排序;使用 UTF-8、`ensure_ascii=false`、无多余空白,并拒绝 NaN、 +Infinity、未知 object 和非字符串 mapping key。摘要固定为该 canonical JSON 的 +`sha256:<64 lowercase hex>`。operation artifact 可以嵌入 snapshot document,但不能嵌入插件的 +`SourceQueryExecutionRecord`。 + +V2 → V1 adapter 可以将非 `VALUE` 映射为 `None`,但该视图只允许用于兼容显示和旧公共返回类型。 + +## facet 状态模型 + +### 分层 snapshot + +R2 冻结 8 个通道 facet、2 个非通道状态和顶层 snapshot: + +```python +@dataclass(frozen=True, slots=True) +class BasicWaveFacet: + waveform_kind: Observed[SourceWaveformKind] + waveform_id: Observed[str] + frequency_mode: Observed[SourceFrequencyMode] + frequency_hz: Observed[float] + amplitude: Observed[SourceAmplitude] + offset_v: Observed[float] + phase_deg: Observed[float] + square_duty_cycle_percent: Observed[float] + + +@dataclass(frozen=True, slots=True) +class OutputFacet: + enabled: Observed[bool] + display_load: Observed[SourceDisplayLoad] + polarity: Observed[SourceOutputPolarity] + + +@dataclass(frozen=True, slots=True) +class HarmonicFacet: + enabled: Observed[bool] + completeness: Observed[HarmonicCompleteness] + maximum_supported_order: Observed[int] + components: Observed[tuple[SourceHarmonicComponentV2, ...]] + + +@dataclass(frozen=True, slots=True) +class ModulationFacet: + enabled: Observed[bool] + kind: Observed[SourceModulationKind] + source: Observed[SourceModulationSource] + parameters: Observed[tuple[SourceModulationParameter, ...]] + internal_frequency_hz: Observed[float] + internal_waveform_kind: Observed[SourceWaveformKind] + + +@dataclass(frozen=True, slots=True) +class SweepFacet: + enabled: Observed[bool] + start_hz: Observed[float] + stop_hz: Observed[float] + spacing: Observed[SourceSweepSpacing] + steps: Observed[int] + sweep_time_s: Observed[float] + start_hold_s: Observed[float] + stop_hold_s: Observed[float] + return_time_s: Observed[float] + trigger: Observed[SourceTriggerState] + marker: Observed[SourceSweepMarker] + + +@dataclass(frozen=True, slots=True) +class BurstFacet: + enabled: Observed[bool] + mode: Observed[SourceBurstMode] + cycles: Observed[int] + phase_deg: Observed[float] + internal_period_s: Observed[float] + delay_s: Observed[float] + gate_polarity: Observed[SourceGatePolarity] + trigger: Observed[SourceTriggerState] + + +@dataclass(frozen=True, slots=True) +class PulseFacet: + hold_basis: Observed[SourcePulseHoldBasis] + width_s: Observed[float] + duty_cycle_percent: Observed[float] + delay_s: Observed[float] + leading_transition_s: Observed[float] + trailing_transition_s: Observed[float] + + +@dataclass(frozen=True, slots=True) +class ArbitraryFacet: + selected_waveform_id: Observed[str] + playback_mode: Observed[SourceArbitraryPlaybackMode] + playback_frequency_hz: Observed[float] + sample_rate_hz: Observed[float] + point_count: Observed[int] + storage_digest: Observed[str] + + +@dataclass(frozen=True, slots=True) +class SourceSystemStateV2: + counters: tuple[SourceCounterInputState, ...] + reference_clock: Observed[SourceReferenceClockState] + sync: Observed[SourceSyncState] + cascade: Observed[SourceCascadeState] + + +@dataclass(frozen=True, slots=True) +class SourceCrossChannelStateV2: + relations: tuple[SourceRelationState, ...] + relation_graph: Observed[SourceRelationGraph] + shared_power: Observed[SourceSharedPowerState] + + +@dataclass(frozen=True, slots=True) +class SourceChannelStateV2: + channel: int + basic: Observed[BasicWaveFacet] + output: Observed[OutputFacet] + harmonics: Observed[HarmonicFacet] + modulation: Observed[ModulationFacet] + sweep: Observed[SweepFacet] + burst: Observed[BurstFacet] + pulse: Observed[PulseFacet] + arbitrary: Observed[ArbitraryFacet] + + +@dataclass(frozen=True, slots=True) +class SourceSnapshotV2: + snapshot_id: str + context_id: str + correlation_id: str + captured_at_utc: str + runtime_profile: SourceRuntimeCapabilityProfile + channels: tuple[SourceChannelStateV2, ...] + system: Observed[SourceSystemStateV2] + cross_channel: Observed[SourceCrossChannelStateV2] + consistency: SourceSnapshotConsistency + plan_digest: str + query_count: int + session_health_before: str + session_health_after: str +``` + +嵌套辅助类型也使用 fixed dataclass,不使用 mapping:`SourceAmplitude(value, unit)`、 +`SourceDisplayLoad(kind, resistance_ohm)`、`SourceComponentAmplitude(kind, value)`、 +`SourceHarmonicComponentV2(order, amplitude, phase_deg)`、 +`SourceModulationParameter(kind, value)`、`SourceTriggerState(source, slope, output)`、 +`SourceSweepMarker(enabled, frequency_hz)`、`SourceCounterMeasurementV2(kind, value)`、 +`SourceCounterInputState(input_id, enabled, measurements, coupling, impedance_ohm, attenuation, +gate_time_s, trigger_level_v, statistics_enabled)`、 +`SourceReferenceClockState(mode, frequency_hz, locked)`、 +`SourceSyncState(enabled, polarity, source_channel)`、`SourceCascadeState(enabled, role)`、 +`SourceRelationState(feature, channels, enabled)` 和 +`SourceSharedPowerState(participants, active_power_upper_w, hard_limit_w)`。 + +每个数值必须有限;频率、时间、阻抗、点数、阶次和功率等非负量不得为负;百分比范围为 +`[0, 100]`;相位范围为 `[0, 360]`。`storage_digest` 使用 SHA-256 格式。counter、relation、 +component 和 parameter tuple 按公共 key 排序且不重复。 + +### 一致性模型 + +```python +class SnapshotConsistencyState(str, Enum): + CONSISTENT = "consistent" + DRIFTED = "drifted" + UNPROVEN = "unproven" + + +@dataclass(frozen=True, slots=True) +class SourceSnapshotConsistency: + state: SnapshotConsistencyState + session_epoch: str + anchor_fields: tuple[SourceFieldRef, ...] + anchor_digest_before: str + anchor_digest_after: str | None + device_revision_token_before: str | None + device_revision_token_after: str | None + reason_code: SourceReasonCode | None = None +``` + +- `CONSISTENT` 要求查询前后 anchor 规范化值一致,并且所有必需查询在同一 epoch 和 deadline 内完成; +- `DRIFTED` 表示 anchor 已变化; +- `UNPROVEN` 表示复读、epoch 或必需证据不完整; +- device-native revision token 与核心摘要是两个独立字段,不能互相冒充。driver record 可以携带 + 原生 token,但核心 snapshot 只保存 token 的 SHA-256,避免把厂商私有修订文本写入 artifact。没有原生 token 时, + 核心仍对 canonical anchor JSON 计算 SHA-256;摘要格式为 `sha256:<64 lowercase hex>`; +- `CONSISTENT` 要求两次 anchor digest 相同;若原生 token 在前后都存在,也必须相同; +- 写入前必须在同一独占资源租约和 session 事务范围内立即比较 baseline,不能依赖旧 snapshot。 + +### 状态依赖查询计划 + +查询 effect 使用单一枚举,避免多个布尔值互相矛盾: + +```python +class SourceQueryEffect(str, Enum): + PURE_READ = "pure_read" + STATEFUL_CONSUMING_READ = "stateful_consuming_read" + REQUIRES_SELECTOR_WRITE = "requires_selector_write" + UNKNOWN_EFFECT = "unknown_effect" + + +class SourceQueryPhase(StrEnum): + ANCHOR_BEFORE = "anchor_before" + FACET = "facet" + ANCHOR_AFTER = "anchor_after" + + +@dataclass(frozen=True, slots=True) +class SourceFacetQueryContract: + feature: SourceFeature + scope: SourceFacetScope + fields: tuple[SourceFieldId, ...] + activation_any: tuple[SourceActivationRule, ...] + effect: SourceQueryEffect + max_queries: int + required: bool = False + + +@dataclass(frozen=True, slots=True) +class SourceQueryContract: + anchor_fields: tuple[SourceFieldId, ...] + facets: tuple[SourceFacetQueryContract, ...] + max_queries: int + timeout_ms: int + + +@dataclass(frozen=True, slots=True) +class SourceSemanticQueryItem: + item_id: str + phase: SourceQueryPhase + feature: SourceFeature + target: SourceScopeRef + fields: tuple[SourceFieldRef, ...] + activation_any: tuple[SourceActivationRule, ...] + required: bool + effect: SourceQueryEffect + max_queries: int + + +@dataclass(frozen=True, slots=True) +class SourceSemanticQueryPlan: + contract_version: Literal["wavebench.source.v2"] + plan_id: str + items: tuple[SourceSemanticQueryItem, ...] + allowed_effects: tuple[SourceQueryEffect, ...] + max_queries: int + deadline_monotonic: float + + +class SourceQueryItemOutcome(StrEnum): + OBSERVED = "observed" + SEMANTIC_UNAVAILABLE = "semantic_unavailable" + SKIPPED = "skipped" + + +SourceObservationValue: TypeAlias = ( + SourceRuntimeIdentity + | BasicWaveFacet + | OutputFacet + | HarmonicFacet + | ModulationFacet + | SweepFacet + | BurstFacet + | PulseFacet + | ArbitraryFacet + | SourceDisplayLoad + | SourceCounterInputState + | SourceReferenceClockState + | SourceSyncState + | SourceCascadeState + | SourceRelationState + | SourceRelationGraph + | SourceSharedPowerState + | bool + | str +) + + +@dataclass(frozen=True, slots=True) +class SourceTypedObservation: + field: SourceFieldRef + value: SourceObservationValue + evidence_refs: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class SourceProtocolQueryRecord: + item_id: str + effect: SourceQueryEffect + outcome: SourceQueryItemOutcome + query_count: int + observations: tuple[SourceTypedObservation, ...] = () + reason_code: SourceReasonCode | None = None + + +@dataclass(frozen=True, slots=True) +class SourceQueryExecutionRecord: + contract_version: Literal["wavebench.source.v2"] + plan_id: str + items: tuple[SourceProtocolQueryRecord, ...] + query_count: int + device_revision_token_before: str | None = None + device_revision_token_after: str | None = None +``` + +`UNKNOWN_EFFECT` 不允许普通 snapshot 发送查询。`REQUIRES_SELECTOR_WRITE` 只能由独立、 +受控的 stateful snapshot operation 使用,不能进入普通 profile read。 + +`plan_id` 和 `item_id` 由核心生成,使用短安全 token。`items` 按 phase 顺序排列; +`ANCHOR_BEFORE` 和 `ANCHOR_AFTER` 必须覆盖 `anchor_fields` 的同一组展开后字段。`max_queries`、item 上限和 deadline +必须为正且不得超过 descriptor 与 OperationSpec 的交集。第一阶段的 `source.snapshot_v2` +只允许 `PURE_READ`;消费型查询和 selector-write snapshot 需要独立 operation,不能通过扩大 +`allowed_effects` 偷渡。 + +查询顺序: + +1. 查询身份、输出、基础波形、主模式和其他 anchor; +2. 根据 descriptor 中的 typed activation rule 选择合法 facet 查询; +3. 对未激活、不支持或主动跳过的 facet 构造准确 `Observed`; +4. 重新查询 anchor; +5. 生成 `SourceSnapshotConsistency`。 + +核心签发语义查询计划;插件将每个 item 转换为自己的协议查询计划,选择具体 SCPI、合法顺序 +和解析流程。driver 返回类型化观测和不含命令文本的 `SourceProtocolQueryRecord`。核心计算 +`UNSUPPORTED`、`NOT_APPLICABLE`、`NOT_QUERIED`,注入当前 `epoch_id`,比较两次 anchor, +最后构造 consistency。未在计划中、缺少执行证明、查询计数超限、effect 超限或超出 descriptor +的观测一律不能提高可用性。 + +`OBSERVED` 必须覆盖该 item 的全部字段;`SEMANTIC_UNAVAILABLE` 只能在完整响应已经取得且 +session 仍为 `healthy` 时使用,并携带核心注册的 reason code。`SKIPPED` 在 activation rule +判定不激活时使用 `inactive_by_anchor`;已激活但属于 optional 的 item 可以使用 +`driver_skipped_optional`。required item 不能跳过,插件不能通过 `SKIPPED` 隐藏查询失败。 + +R2 descriptor 中每个受支持 read feature 都必须有同 scope 的 facet query contract;identity 必须由 +唯一、required 的 instrument-scope item 提供。activation 引用的字段必须属于 `anchor_fields`。 +展开通道、输入和通道集合后,item 最大查询数之和不能超过全局 `max_queries`。首版所有 facet +均为 `PURE_READ`,插件协议查询使用 `ReplayPolicy.NO_REPLAY`。 + +只能通过写入选择槽位才能读完的设备,不得将该流程伪装成 `source.snapshot_v2`。如果未来需要 +这类快照,必须定义独立的 stateful snapshot operation,包含写前 baseline、有界写入、恢复和独立验证。 +Protocol 返回值不得包含 SCPI、完整响应、真实资源串或异常原文。 + +## 谐波 facet + +### 变长与稀疏分量 + +```python +class HarmonicCompleteness(str, Enum): + COMPLETE = "complete" + ACTIVE_ONLY = "active_only" + SELECTED_ONLY = "selected_only" + PARTIAL = "partial" + + +class ComponentAmplitudeKind(str, Enum): + ABSOLUTE_VPP = "absolute_vpp" + RELATIVE_LINEAR = "relative_linear" + RELATIVE_DB = "relative_db" +``` + +约束: + +- 分量按 `order` 唯一,可以稀疏; +- `maximum_supported_order` 不得由当前已读分量的最高阶次猜测; +- 必须显式记录谐波总开关和 completeness; +- 当前只返回选中槽位的设备不得伪造其他阶次为 0; +- `ABSOLUTE_VPP` 换算为对称峰值时使用 `peak_v = vpp_v / 2`; +- `RELATIVE_LINEAR` 和 `RELATIVE_DB` 必须引用明确、同一 snapshot 中的 carrier 幅度语义; +- dB 是幅度比还是功率比必须由 typed profile 冻结,不许默认猜测; +- 任一已启用分量的幅度为非 `VALUE` 时,复合输出预算必须拒绝放行。 + +## feature-specific patch 请求 + +### 操作语义 + +```python +class PatchAction(str, Enum): + KEEP = "keep" + SET = "set" + + +@dataclass(frozen=True, slots=True) +class PatchValue(Generic[T]): + action: PatchAction + value: T | None = None + + +class PatchMode(StrEnum): + PATCH = "patch" + REPLACE_ALL = "replace_all" +``` + +构造规则: + +- `SET` 必须携带非 `None` 值; +- `KEEP` 必须使用 `value=None`; +- 关闭功能通过对 typed `enabled` 字段执行 `SET(False)`,不增加含义模糊的通用 `CLEAR`; +- 当前模式不适用的 `SET` 必须在仪器 I/O 前拒绝; +- 驱动不得静默忽略不支持的 patch 字段。 + +首版通用 patch 不提供 `RESET_DEFAULT` 或 `CLEAR`。厂商默认值可能随型号、固件或当前模式变化, +不能被核心解释成安全目标。未来若确需重置,必须由具体 facet 冻结目标值、预算影响和回读要求, +并使用专项 action 或 capability,不能扩展通用 `SET(None)` 的语义。 + +`PatchValue` 和 `PatchMode` 只是各 feature request 共用的值语义,不对应统一 capability。 +`SourceBasicPatch`、`SourceHarmonicPatch`、`SourceModulationPatch` 等 request 必须是独立 dataclass, +只能包含所属 feature 的 typed 字段。核心不提供 `dict[str, PatchValue[object]]` 或动态字段名入口。 + +完整替换另使用 `PatchMode.REPLACE_ALL`。只有 snapshot 完整、恢复顺序已冻结且 descriptor +明确声明无损替换时才能使用。`SELECTED_ONLY`、`PARTIAL` 或安全字段未知的 snapshot +不能作为 `REPLACE_ALL` baseline。 + +## ARB storage mutation + +ARB 上传不属于普通配置,也不能因为输出 OFF 就自动获准。R2 将存储、选择/播放配置和输出转换 +拆成三个 operation context: + +```python +class SourceStorageWriteMode(StrEnum): + CREATE_ONLY = "create_only" + REPLACE_IF_DIGEST_MATCHES = "replace_if_digest_matches" + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryStorageRequest: + channel: int + slot_id: str + write_mode: SourceStorageWriteMode + payload_sha256: str + payload_size_bytes: int + expected_previous_sha256: str | None = None + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryStorageResult: + channel: int + slot_id: str + payload_sha256: str + payload_size_bytes: int + write_completed: bool + rollback_available: bool + readback_verified: bool +``` + +- `slot_id` 必须显式给出;核心不自动挑选可覆盖槽位; +- `CREATE_ONLY` 要求槽位已证明为空,且 `expected_previous_sha256=None`; +- `REPLACE_IF_DIGEST_MATCHES` 要求写前取得权威旧摘要并与请求完全相同,不提供 force overwrite; +- payload 摘要使用 `sha256:<64 lowercase hex>`;大小必须为正且受 operation/profile 双重上限; +- 写入最多尝试一次,结果未知不重试; +- 成功必须通过设备回读或设备提供的权威摘要验证新内容;无法验证的 driver 不声明该 capability; +- `rollback_available=True` 只有在写前已取得可重放旧 payload、恢复步骤有界且经过 A0 故障注入时成立; +- 删除、重命名、隐式覆盖和批量清理不属于首版公共合同; +- storage mutation 只记录摘要、大小和槽位的脱敏标识,artifact 不保存 payload; +- storage mutation 请求不包含 `output_on`,也不签发能量转换准入决定。 + +上传完成后,选择 ARB 使用新的 `source.arbitrary_select_v2` operation。若随后需要 ON,必须重新 +读取 fresh snapshot、计算预算,并以 `source.output_v2` 单独取得一次性准入决定。 + +## 复合输出预算 + +### 预算输入与输出 + +复合预算使用端口电压边界,不直接将所有波形当作正弦波。R3 冻结以下只读、纯计算形态; +它们不能单独授权写入,后续写修订只能消费同一计算结果,不能另造宽松模型: + +```python +class BudgetProofStrength(str, Enum): + HARD_CONSERVATIVE = "hard_conservative" + STATISTICAL_ONLY = "statistical_only" + MEASURED_ONLY = "measured_only" + INCOMPLETE = "incomplete" + + +class TerminationKind(str, Enum): + HIGH_IMPEDANCE = "high_impedance" + RESISTIVE = "resistive" + + +class VoltageReferenceBasis(str, Enum): + OPEN_CIRCUIT = "open_circuit" + DELIVERED_INTO_DISPLAY_LOAD = "delivered_into_display_load" + + +@dataclass(frozen=True, slots=True) +class ResistanceBounds: + minimum_ohm: float + maximum_ohm: float + + +@dataclass(frozen=True, slots=True) +class TerminationSpec: + kind: TerminationKind + resistance_bounds: ResistanceBounds | None + + +class BudgetEvidenceSource(str, Enum): + INSTRUMENT_READBACK = "instrument_readback" + DEVICE_HARD_LIMIT = "device_hard_limit" + EXPLICIT_TERMINATION = "explicit_termination" + EXTERNAL_MEASUREMENT = "external_measurement" + + +@dataclass(frozen=True, slots=True) +class PortVoltageBounds: + minimum_v_lower: float + maximum_v_upper: float + vpp_upper_v: float + absolute_peak_upper_v: float + rms_upper_v: float | None + + +@dataclass(frozen=True, slots=True) +class SafetyContributor: + contributor_id: str + feature: SourceFeature + channels: tuple[int, ...] + minimum_v: float + maximum_v: float + constraint_ids: tuple[str, ...] + proof_strength: BudgetProofStrength + evidence_sources: tuple[BudgetEvidenceSource, ...] + + +@dataclass(frozen=True, slots=True) +class CompositeOutputBudget: + bounds: Observed[PortVoltageBounds] + voltage_reference_basis: Observed[VoltageReferenceBasis] + display_load: Observed[TerminationSpec] + output_source_resistance: Observed[ResistanceBounds] + actual_termination: Observed[TerminationSpec] + shared_power: Observed[SourceSharedPowerBudget] + proof_strength: BudgetProofStrength + evidence_sources: tuple[BudgetEvidenceSource, ...] + contributors: tuple[SafetyContributor, ...] + blockers: tuple[SourceBudgetBlockerCode, ...] + + +@dataclass(frozen=True, slots=True) +class SourceSharedPowerBudget: + participants: tuple[int, ...] + observed_active_power_upper_w: float + projected_power_upper_w: float + effective_hard_limit_w: float + constraint_ids: tuple[str, ...] + evidence_sources: tuple[BudgetEvidenceSource, ...] + + +class SourceSafetyConstraintKind(StrEnum): + VOLTAGE_REFERENCE = "voltage_reference" + SOURCE_RESISTANCE = "source_resistance" + FREQUENCY_DERATING = "frequency_derating" + MODULATION_ENVELOPE = "modulation_envelope" + ARBITRARY_OVERSHOOT = "arbitrary_overshoot" + NOISE_PEAK = "noise_peak" + SHARED_POWER = "shared_power" + + +class SourceBudgetBlockerCode(StrEnum): + SNAPSHOT_NOT_CONSISTENT = "snapshot_not_consistent" + DESCRIPTOR_MISMATCH = "descriptor_mismatch" + TARGET_CHANNEL_UNKNOWN = "target_channel_unknown" + BASIC_STATE_UNAVAILABLE = "basic_state_unavailable" + AMPLITUDE_UNIT_UNSUPPORTED = "amplitude_unit_unsupported" + WAVEFORM_UNSUPPORTED = "waveform_unsupported" + DC_LEVEL_UNAVAILABLE = "dc_level_unavailable" + FREQUENCY_MODE_UNSUPPORTED = "frequency_mode_unsupported" + OUTPUT_POLARITY_UNAVAILABLE = "output_polarity_unavailable" + VOLTAGE_REFERENCE_MISSING = "voltage_reference_missing" + SOURCE_RESISTANCE_MISSING = "source_resistance_missing" + DISPLAY_LOAD_UNAVAILABLE = "display_load_unavailable" + DISPLAY_LOAD_UNSUPPORTED = "display_load_unsupported" + ACTUAL_TERMINATION_MISSING = "actual_termination_missing" + TERMINATION_EVIDENCE_INVALID = "termination_evidence_invalid" + TERMINATION_NOT_RESISTIVE = "termination_not_resistive" + HARMONIC_STATE_UNAVAILABLE = "harmonic_state_unavailable" + HARMONIC_COMPLETENESS_INSUFFICIENT = "harmonic_completeness_insufficient" + HARMONIC_AMPLITUDE_UNSUPPORTED = "harmonic_amplitude_unsupported" + MODULATION_CONSTRAINT_MISSING = "modulation_constraint_missing" + ARBITRARY_OVERSHOOT_MISSING = "arbitrary_overshoot_missing" + NOISE_PEAK_MISSING = "noise_peak_missing" + SWEEP_DERATING_MISSING = "sweep_derating_missing" + ACTIVE_CHANNEL_UNKNOWN = "active_channel_unknown" + COMBINE_STATE_UNAVAILABLE = "combine_state_unavailable" + COMBINE_PATH_UNSUPPORTED = "combine_path_unsupported" + SHARED_POWER_STATE_UNAVAILABLE = "shared_power_state_unavailable" + SHARED_POWER_CONSTRAINT_MISSING = "shared_power_constraint_missing" + SHARED_POWER_LIMIT_EXCEEDED = "shared_power_limit_exceeded" + CONSTRAINT_NOT_HARD = "constraint_not_hard" + VPP_LIMIT_EXCEEDED = "vpp_limit_exceeded" + PORT_VOLTAGE_LIMIT_EXCEEDED = "port_voltage_limit_exceeded" + + +class SourceModulationKind(StrEnum): + AM = "am" + DSB_AM = "dsb_am" + FM = "fm" + PM = "pm" + PWM = "pwm" + ASK = "ask" + FSK = "fsk" + PSK = "psk" + + +@dataclass(frozen=True, slots=True) +class SourceVoltageReferenceConstraint: + basis: VoltageReferenceBasis + + +@dataclass(frozen=True, slots=True) +class SourceResistanceConstraint: + resistance_ohm: ResistanceBounds + + +@dataclass(frozen=True, slots=True) +class SourceFrequencyDeratingBand: + frequency_hz: ClosedFloatInterval + gain_upper: float + + +@dataclass(frozen=True, slots=True) +class SourceFrequencyDeratingConstraint: + bands: tuple[SourceFrequencyDeratingBand, ...] + + +@dataclass(frozen=True, slots=True) +class SourceModulationEnvelopeConstraint: + kind: SourceModulationKind + gain_upper: float + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryOvershootConstraint: + gain_upper: float + + +@dataclass(frozen=True, slots=True) +class SourceNoisePeakConstraint: + absolute_peak_upper_v: float + + +@dataclass(frozen=True, slots=True) +class SourceSharedPowerConstraint: + participants: tuple[int, ...] + maximum_power_w: float + + +SourceSafetyConstraintProfile: TypeAlias = ( + SourceVoltageReferenceConstraint + | SourceResistanceConstraint + | SourceFrequencyDeratingConstraint + | SourceModulationEnvelopeConstraint + | SourceArbitraryOvershootConstraint + | SourceNoisePeakConstraint + | SourceSharedPowerConstraint +) + + +@dataclass(frozen=True, slots=True) +class SourceSafetyConstraint: + constraint_id: str + kind: SourceSafetyConstraintKind + applicability: SourceConstraintApplicability + profile: SourceSafetyConstraintProfile + proof_strength: BudgetProofStrength + evidence_refs: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class SourceSafetyProfile: + constraints: tuple[SourceSafetyConstraint, ...] +``` + +`blockers` 是核心注册的稳定 reason code,不是可供插件自由拼接的错误文本。 +`ResistanceBounds.minimum_ohm` 和 `maximum_ohm` 都必须有限且大于 0,且上界不得小于下界。 +缺少有限上界不构成 `ResistanceBounds`;应缺少对应 constraint 并成为预算 blocker。`RESISTIVE` +必须携带有限上下界;`HIGH_IMPEDANCE` 仍由明确枚举表示,不能用缺失值暗示。没有可计算阻抗边界的 +高阻枚举本身不能产生 `HARD_CONSERVATIVE`。`PortVoltageBounds.absolute_peak_upper_v` 按 +`max(abs(minimum_v_lower), abs(maximum_v_upper))` 计算。 + +`SourceSafetyConstraintProfile` 是以下 typed profile 的封闭 union:电压参考、源电阻区间、 +频率分段降额、调制最大包络因子、ARB 最大过冲因子、Noise 硬峰值边界和共享功率 envelope。 +每种 profile 必须使用带单位字段,不能使用 `Mapping[str, object]`、表达式字符串或 callback。 +无法映射到该 union 的设备事实只能作为证据记录,不能进入自动准入计算。 + +R3 的计算入口固定为核心内部纯函数 `evaluate_source_output_budget()`。它只接受已经构造的 +`SourceSnapshotV2`、descriptor extension、`SourceEnergySafetyLimits` 和已绑定的端接证据, +不打开 session、不调用 driver、不发送 transport I/O,也不注册 capability。输入 snapshot 的 +consistency、descriptor digest、目标通道、端接 context 的 correlation ID 和证据 binding 任一不符时, +函数返回 `INCOMPLETE` 预算与稳定 blocker;它不会修正、补齐或猜测任何输入。 + +R3 的首个可计算子集如下: + +- `OPEN_CIRCUIT` 与 `DELIVERED_INTO_DISPLAY_LOAD` 使用有限纯电阻 Thevenin 区间换算;显示负载、 + 源电阻和实际端接是三个独立输入; +- 输出极性必须为 `normal` 或 `inverted`。反相把参考区间 `[min, max]` 映射为 `[-max, -min]`; + 极性未知是 blocker; +- `gain_upper` 表示围绕 DC offset 的 AC 包络上界。调制、ARB 和 sweep gain 只放大 AC 分量, + 不得把 offset 一并相乘;当前首版只接受已读为 `internal` 的调制源; +- 固定频率和已完整读取的 sweep 可以计算;`list`、`unknown` 或缺失 frequency mode 固定失败关闭; +- Noise 只有匹配的硬峰值 constraint 才能参与;已启用 Harmonic 必须是 `COMPLETE` 且每个分量为 + `ABSOLUTE_VPP`;ARB、调制和 sweep 必须各自具备硬约束; +- Pulse 没有已冻结的最小/最大电平 facet,因此 R3 返回 `waveform_unsupported`,不得把 Vpp + 猜成 Pulse 的高低电平;DC 只在 Vpp 缺失或为零且 `offset_v` 可作为唯一电平时计算,非零 Vpp + 同时出现时返回 `dc_level_unavailable`; +- Combine 只接受已启用关系图中明确的 `INTERNAL_WAVEFORM` edge。其源波形经目标物理端口的 + 实际端接换算;`OUTPUT_PORT` 或缺失关系图为 blocker,不得把一个通道的端接误用于另一个独立端口; +- 声明 `shared_power` 的 topology 必须同时提供 snapshot 的 active-power upper bound、runtime hard + limit 和覆盖所有 active participant 的 descriptor hard constraint。核心比较观测值与预演的保守 + 功率上界,取两类 hard limit 中更严格者;任一缺失、范围不一致或超限均拒绝。 + +`CompositeOutputBudget.can_authorize_energy` 仅在 `proof_strength=HARD_CONSERVATIVE`、`bounds` 为 +`VALUE` 且 `blockers` 为空时为真。R3 不把这个布尔值接到任何输出、setter、trigger、恢复或 +run plan 入口;M4/M5 必须在同一 operation context 内重新调用该计算器,才可能把它作为写前条件。 + +分段频率区间必须递增、无重叠;目标频域存在空洞时预算为 `INCOMPLETE`。gain、Vpp、峰值和功率 +必须为有限非负值,gain 不得小于 1。`SourceNoisePeakConstraint` 只有在适用域内存在确定性硬边界 +时才能使用 `HARD_CONSERVATIVE`;crest factor 或有限窗口测量只能生成 +`STATISTICAL_ONLY`/`MEASURED_ONLY` 证据。共享功率 profile 未覆盖全部 active participant 时, +相关 topology 的输出 ON 准入固定拒绝。 + +### 单位与区间运算 + +所有计算字段使用 SI 单位,字段名最后使用 `_v`、`_hz`、`_ohm`、`_w`、`_deg` 或 +`_percent` 等单位后缀;`vpp`、`rms` 和 `peak` 是位于单位后缀前的语义标记,例如 +`vpp_upper_v`、`rms_upper_v` 和 `absolute_peak_upper_v`。无单位的 `amplitude` 不进入 +V2 安全计算。 + +对称、已知 Vpp 的独立 AC 分量: + +```text +require is_finite(vpp_i) and not isinstance(vpp_i, bool) and vpp_i >= 0 +peak_i = vpp_i / 2 +ac_peak_upper = sum(peak_i) +minimum_v_lower = dc_min_v - ac_peak_upper +maximum_v_upper = dc_max_v + ac_peak_upper +vpp_upper_v = maximum_v_upper - minimum_v_lower +``` + +一般 contributor 必须直接提供以伏特表示的最小和最大边界。Combine 和跨通道跟踪使用区间和 +作为保守上界,不使用未证明的相位抵消降低预算。 + +特殊语义: + +- AM 必须使用 typed profile 给出的最大包络因子;深度百分比不能在未冻结厂商语义时直接代入; +- Pulse、Square、DC 和 ARB 使用最小和最大电平,不使用正弦峰值假设; +- ARB 必须计入样本归一化、输出滤波或插值可能产生的 overshoot 边界;无法给出边界时为 `UNKNOWN`; +- Sweep 在完整频率范围内取最大边界并应用频率降额; +- Noise 只有在型号和固件范围内存在已审计的硬峰值上界时,才能进入 `HARD_CONSERVATIVE`; +- RMS 无法保守计算时可以为 `None`,但若配置了 RMS 上限,该缺失会成为 blocker; +- 多通道共享功率限制必须由 typed constraint 表示或返回可审查的计算结果,不允许使用自由文本声明。 + +当前 SDG2000X 插件证据中,DDS 内置目录已有 199/199 项 A4 冒烟,TARB 才是一个内置波形、 +一个采样率的覆盖。本 RFC 不把两者误写成「ARB 只测了一个内置波形」,但两类证据都不能证明 +用户上传波形、插值和重建滤波的确定性硬过冲上界,因此不会提高对应 ON 准入的 proof strength。 + +### 显式安全配置 + +R3 保持 `SafetyLimitsConfig` 的三个既有位置字段不变,并在 +`max_power_current_limit_a` 之后追加两个带默认值的字段: + +```python +@dataclass(frozen=True) +class SafetyLimitsConfig: + max_source_vpp: float | None = None + max_power_voltage_v: float | None = None + max_power_current_limit_a: float | None = None + min_source_port_voltage_v: float | None = None + max_source_port_voltage_v: float | None = None +``` + +旧代码使用三个位置参数构造 `SafetyLimitsConfig` 时,参数含义不能因 Source V2 改变。 +`WaveBenchConfig.with_source_resource()`、其它资源覆盖和 waveform override 必须原样保留这两个 +新字段,不能在复制配置时丢失或补默认值。 + +配置规则: + +- 绝对电压两项均缺失时,旧配置正常加载,V1 行为不变; +- 只配置其中一项时,配置加载失败; +- 两项均配置时,必须是有限的 `int` 或 `float`,拒绝 `bool`、NaN 和 Infinity,并满足 + `min_source_port_voltage_v < max_source_port_voltage_v`;区间不要求包含 0; +- 两个端点是有符号区间,不是两个「正数上限」,不得复用只接受正数的配置 parser,也不得把 + 负端点改写成绝对值; +- `max_source_vpp` 继续作为独立安全轴,不能由绝对电压区间替代; +- Source V2 能量增加操作要求 `max_source_vpp` 与绝对电压两项全部显式配置; +- 不从 `max_source_vpp` 推导对称绝对区间,也不从绝对区间反推 Vpp 上限; +- 设备硬限制、实验台配置和 run 限制取交集;设备与 run 只能继续收紧实验台配置; +- 缺少必需安全轴时,在 driver factory 和 transport 打开前抛出 + `SourceSafetyLimitsRequiredError`,其 `wavebench.error.v1` code 固定为 + `source_safety_limits_required`,`details.missing_fields` 使用排序稳定的配置键; +- 两项只配置一项属于加载期 `config_error`,不是运行时缺失错误。 + +既有 `max_source_vpp` 的安全修复可以拒绝 `bool` 和非有限数,但不能改变正常有限正数的 V1 +含义。绝对电压区间只约束 Source V2 能量操作;它不会被暗中应用到旧 `source.set_vpp` run step、 +V1 CLI 或未 opt in 的 V1 driver。 + +缺少显式安全轴仍允许 `source.snapshot_v2`、正常 OFF、disable,以及已经证明不会发出信号的 +输出 OFF 配置或独立 storage mutation。ON、fire、恢复 ON、可能发出信号的 trigger、live mutation, +以及能量影响为 unknown 的 operation 必须零仪器 I/O 拒绝。 + +示例配置中的「缺失表示不限」只能继续描述 V1。Source V2 文案必须明确:缺失表示没有能量转换 +授权,而不是无限制。 + +### 负载与端接 + +`display_load` 来自仪器用于计算和显示幅度的状态;`output_source_resistance` 表示输出端的 +等效源电阻边界;`actual_termination` 是独立的外部证据。三者不得互相自动复制。 +`TerminationSpec` 用明确枚举表示高阻或电阻端接,不能使用 `None` 暗示高阻。 + +实际端接使用独立公共证据类型: + +```python +class TerminationEvidenceSource(StrEnum): + CONFIG = "config" + RUN_INTENT = "run_intent" + MANUAL_CONFIRMATION = "manual_confirmation" + EXTERNAL_MEASUREMENT = "external_measurement" + + +class TerminationEvidenceLifetime(StrEnum): + OPERATION = "operation" + RUN = "run" + CONFIG_DIGEST = "config_digest" + + +@dataclass(frozen=True, slots=True) +class SourceTerminationEvidence: + target: SourceScopeRef + termination: TerminationSpec + source: TerminationEvidenceSource + lifetime: TerminationEvidenceLifetime + resource_fingerprint: str + binding_digest: str + observed_at_utc: str + expires_at_utc: str | None + evidence_ref: str +``` + +`MANUAL_CONFIRMATION` 只能使用 `OPERATION`;`RUN_INTENT` 只能使用 `RUN`;静态 fixture 配置使用 +`CONFIG_DIGEST` 并同时绑定脱敏资源指纹。任何 evidence 在 resource、config、intent、correlation、 +target 或有效期不匹配时都不能参与预算。仪器显示负载和插件 descriptor 不得构造 +`SourceTerminationEvidence`。 + +对于首版支持的纯电阻模型,幅度参考和端接换算固定为 Thevenin 关系。若配置幅度 `v_ref` +表示开路电压,则 `v_port = v_ref * r_actual / (r_source + r_actual)`;若它表示显示负载 +`r_display` 上的电压,则先计算 +`v_open = v_ref * (r_source + r_display) / r_display`,再按实际端接计算端口电压。 +存在阻抗区间时,核心用区间运算选择使绝对端口电压最大的组合,不得使用标称值代替容差上界。 +幅度参考未知、源电阻无有限上界、显示参考不可计算,或实际负载超出首版纯电阻模型时, +换算结果为 blocker。确切 enum、区间序列化和频率相关模型必须在对应写 capability 的 +`Accepted` 修订前冻结。 + +实际端接证据至少绑定: + +- 资源指纹和通道; +- 端接阻抗或高阻语义; +- 证据来源和有效期; +- 本次 operation 或 run 的 correlation ID; +- 是否允许在报告中公开。 + +具体配置键和 CLI 人工确认语法留到 M3 入口设计阶段,但不能改变上述公共类型和有效期规则。 +任何依赖端接换算的 Source V2 输出 ON 准入,都不得因为仪器显示为 `HiZ` 而猜测外部也是高阻。 + +### 预算准入决策 + +R3 将「证据来源」与「证明强度」分开。仪器数据手册、状态回读、显式实验配置和外部测量可以 +作为 `evidence_sources`,但来源名称本身不决定能否通过输出 ON 准入。 + +本节的「输出 ON 准入」包括任何由 WaveBench 软件发起的 OFF → ON,以及会让已配置波形 +实际开始发出的 arm、fire、trigger 或恢复动作,不区分有人值守和无人值守。 + +| proof strength | 是否充分支持输出 ON 准入 | 说明 | +| --- | --- | --- | +| `HARD_CONSERVATIVE` | 是 | 使用全部已启用 contributor、硬边界和负载转换得到保守上界 | +| `STATISTICAL_ONLY` | 否 | crest factor、概率区间或有限时间窗口不能证明瞬时硬上界 | +| `MEASURED_ONLY` | 否 | 历史或单次测量不证明当前状态的未来上界 | +| `INCOMPLETE` | 否 | 必需状态、约束或端接证据缺失,失败关闭 | + +设备声明的硬限制可以成为 `HARD_CONSERVATIVE` 计算输入,但插件自报的「安全」结论不能直接 +成为证明强度。后续若需允许统计预算,必须作为单独 RFC 或本 RFC 的明确修订,定义配置授权、 +证据新鲜度、artifact 风险标记和无人执行边界。 + +输出 ON 准入至少需要: + +- 预算无 blocker; +- `proof_strength=HARD_CONSERVATIVE`; +- `configured_min_v` 与 `configured_max_v` 均已显式配置且顺序有效; +- `vpp_upper_v <= max_source_vpp`; +- `minimum_v_lower >= configured_min_v` 且 `maximum_v_upper <= configured_max_v`; +- 配置 RMS 上限时,`rms_upper_v` 存在且不超限; +- 负载参考与实际端接的换算已证明; +- 频率降额和多通道共享功率限制通过; +- 所有已启用 contributor 均已纳入。 + +仅配置现有 `max_source_vpp` 只能维持 V1 行为,不能单独放行 Source V2 能量启动。 +topology 或 profile 只要声明共享功率关系,就必须提供完整 typed power envelope;该合同冻结前, +相关 topology 的输出 ON 准入固定拒绝。 + +### 统一输出准入门 + +准入决定由核心产生,插件不能通过 `safe=True` 或等价自报绕过。决定至少绑定: + +- operation context、session epoch 和一次性 snapshot ID; +- 受影响通道、跨通道关系与预期写后状态; +- 当前及目标预算、实际端接证据和配置限制; +- 允许的下一次单一能量转换 action; +- 绝对 deadline。 + +准入决定只能在所有配置写入完成后签发。签发后的任何配置写入、anchor 变化、连接代次变化或 +context 结束都会使它失效;获准的 ON/fire/trigger action 会一次性消费它。首版禁止用一个决定 +同时授权 ARB 上传和 ON,也禁止输出 ON 时执行多字段 live patch。 + +R2 规定未来 Source V2 首个可写修订中,高级配置只允许在所有相关输出 OFF 时执行,不自动执行 +「关闭 → 配置 → 重新开启」。未来若允许 live mutation,必须通过本 RFC 修订并复用同一 +写后预算门。该规则不追溯改变未 opt in 的 V1 驱动行为。 + +OFF 不需要复合预算,但仍需要正常 OFF 权限或核心签发的 recovery 授权。`poisoned` 连接不得 +为了 OFF 再发送协议 I/O。 + +## 核心协调写事务 + +### 正常路径 + +1. 在 driver factory 前验证 request 类型、显式安全配置、capability、descriptor 静态 profile、 + `SourceOperationContract` 与 access policy。 +2. 取得独占资源租约并调用现有 factory;factory 可以打开单一 session。实例返回后立即验证 + Protocol 方法,失败时关闭 transport、释放租约,且不调用 Source operation method。 +3. 只有实例验证通过后,才构造只能收窄 descriptor 的 runtime profile 并进入 operation preflight。 +4. 核心根据 request、topology、runtime profile 和跨通道关系生成 `SourceAffectedClosure`; + 无法确定依赖或 emergency OFF 范围时拒绝。 +5. 在 `PREFLIGHT` 中读取 fresh 一致 snapshot,证明 required-off 输出,创建 core-owned baseline, + 并冻结 target state。 +6. 对 target state 离线预演。`MAY_INCREASE` 或 `EMIT` 计算完整预算;storage mutation 执行槽位、 + 摘要和覆盖策略检查;`DECREASE_ONLY` 不要求预算。 +7. 立即复读安全 anchor;与 baseline 不一致时在 mutation 前拒绝。 +8. 在第一次可能发送的写入前,由 `SessionTransactionCoordinator.invalidate_verified_fields()` + 使 closure 中的旧 session 证据失效。 +9. 关闭 `PREFLIGHT`,在唯一 `MAIN` phase 中执行 request。每个目标字段和预先声明的辅助转换 + 最多写入一次;结果未知不重试。 +10. 关闭 `MAIN`,在 `POSTCONDITION` 中独立读取目标字段、隐式变化字段、未修改闭包和安全前置。 +11. 只有 postcondition 全部匹配,且核心验证器提交完整 fresh evidence 后,才返回成功。 + +目标值与仪器当前值相同时,可以零写入返回。该返回仍必须有 fresh snapshot 和所需安全证据, +不得使用旧缓存推断成功。 + +「每个目标字段最多写入一次」不把辅助转换藏起来。例如从 Sweep 切换到固定频率属于独立的 +`frequency_mode` mutation,必须出现在 closure、执行记录和 postcondition 中;它不能作为重复写入 +同一字段的理由。仪器支持分号批处理也不能被假定具有原子性,除非公共 profile 明确证明 +all-or-nothing 语义并完成对应故障注入。 + +postcondition readback 只证明本次 operation 的结果,不会自动恢复 session 的 `verified_fields`。 +只有核心验证器在相应 verification authorization 中逐字段记录 fresh evidence,并完成验证闭包后, +字段才能重新加入当前 epoch 的 `verified_fields`。 + +### 失败与恢复 + +- 仪器 I/O 前的预检失败:零写入,session 保持当前 health; +- 写入确认未发送:零仪器变化,不执行多余 OFF; +- 写后回读不符,且通信仍为 `healthy`:进入核心授权的有界 recovery phase; +- 结果未知但同步仍可证:session 进入 `uncertain`,只允许核心授权的 recovery/verification; +- 同步不可证或丢失:session 进入 `poisoned`,旧连接不再发送 OFF 或任何查询; +- recovery 写入、OFF 回读或验证失败:保留更保守的 session health,不报告已恢复; +- 只有新 session 或原 `uncertain` session 在授权验证中覆盖完整字段闭包后,才能继续 mutation。 + +`MAIN` 关闭前不能签发 recovery authorization。只有 operation 开始前已经计算出完整 +`emergency_off_outputs`,且 session 不是 `poisoned`,才能进入 `FAILURE_SAFE_STATE`。 +该 phase 的 OFF 也最多发送一次;OFF 结果未知不得重试。`FAILURE_RESTORE` 不能恢复输出 ON、 +arm/fire 状态或其它会重新供能的字段。 + +应急 OFF 范围由受影响字段闭包和 descriptor 的跨通道依赖决定。Combine、Tracking、Coupling +或共享功率可能要求关闭多个通道;不得默认只关闭目标通道。 + +启用 Combine 等会隐式改写两通道 load 的设备,closure 必须包含两个通道的 `DISPLAY_LOAD`、 +相关波形预算和输出状态。未来首个写修订只在所有相关输出已证明 OFF 时允许这类配置;任一 load 或关系 +回读未知时,operation 不进入 `MAIN`。 + +OFF 回读成功只证明对应输出字段,不会自动证明全部 Source 状态可信,也不会独立将 +`uncertain` 恢复为 `healthy`。 + +### baseline 与 nonce + +可恢复的 Source V2 写入必须使用 core-owned baseline handle,至少包含: + +- `context_id`; +- `session_epoch`; +- core-generated opaque nonce; +- snapshot 摘要; +- 受影响字段闭包; +- restore order 和最大步骤; +- 明确不恢复的字段。 + +nonce 只能在同一 context 与 epoch 中使用一次。artifact 只记录 nonce 摘要,不记录完整值。 + +如果某个 facet 不可完整读取,或恢复要求未冻结的写选择动作,该 facet 必须记录为不可恢复。 +这类操作只能在明确策略下执行,失败后默认保持受影响输出 OFF,不伪装恢复完整前状态。 + +## 错误队列与查询副作用 + +错误队列是可选证据,不是 Source V2 的必填 capability。 + +- 没有已认证错误队列的驱动可以依靠单写、独立回读、核心授权的 OFF 和 session 锁存; +- 支持错误队列的驱动必须通过独立 capability 与类型化 error policy 声明; +- 读后清除或消费型查询使用 `stateful_read` effect 和 `ReplayPolicy.NO_REPLAY`; +- 一次 operation 只能由 core 或 legacy driver 一方负责错误检查,不能重复消费; +- 错误队列失败不会被映射成假空列表。 + +R2 不增加新的 access mode。query-only 但会消费错误状态的 operation 使用通用 +`effect="stateful_read"`、独占 lease 和 `ReplayPolicy.NO_REPLAY`,并在 Source 语义计划中标记 +`SourceQueryEffect.STATEFUL_CONSUMING_READ`。它可以在 `read_only` access 下显式执行, +但不得并入首版 `source.snapshot_v2`,也不得标记为无副作用 `observe`。 + +## artifact 合同 + +### 运行时 operation artifact + +R2 冻结 `wavebench.source.operation.v1` artifact。Source V2 是领域模型版本,artifact 使用 +`v1` 表示该 artifact schema 的第一个版本,两者不冲突。 + +`source.snapshot_v2` 精确包含: + +| 字段 | 语义 | +| --- | --- | +| `schema` | 固定为 `wavebench.source.operation.v1` | +| `operation` | 固定为 `source.snapshot_v2` | +| `context_id` / `correlation_id` | 单次 operation 与上层运行的关联标识 | +| `session_epoch` | 脱敏的连接代次标识 | +| `capability_decision` | capability、Source contract version 和 descriptor digest | +| `snapshot` | 完整 `wavebench.source.snapshot.v2` document | +| `query` | 固定 `pure_read` effect、plan digest 和总 query count | +| `session_health` | operation 前后 health | +| `final_state` | consistency 和 operation 结束时 health | +| `evidence_refs` | 脱敏证据 ID、摘要和验收等级,不含原始 payload | + +snapshot artifact 不包含空的 `budget`、`mutation`、`postcondition`、`recovery` 或 `verification` +占位字段。后续写 operation 采用同一 schema 时,必须通过 Accepted 写修订增加这些 typed 字段, +不能让 snapshot-only 实现提前猜测其 shape。预检失败使用 `wavebench.error.v1`;纯离线拒绝不得 +为了生成 artifact 打开 transport。 + +### 发布 conformance manifest + +运行时 artifact 与插件发布证据使用不同 schema。R2 冻结: + +```text +schema = wavebench.source.conformance.v1 +conformance_scheme = wavebench.source.a0-a5.v1 +``` + +manifest 至少包含: + +| 字段 | 语义 | +| --- | --- | +| `manifest_id` | 稳定安全 token | +| `conformance_scheme` | 采用的等级定义和修订,不能只写裸 `A3` | +| `claimed_level` | `A0`–`A5` | +| `capability` / `feature` / `direction` | 本证据实际覆盖的公共合同范围 | +| `model` / `firmware_id` / `option_ids` / `channels` | 不允许外推的设备适用域 | +| `core_version` / `plugin_version` / `wheel_sha256` | 软件与发布物身份 | +| `descriptor_digest` / `source_contract_version` | 公共 descriptor 与 Source 合同身份 | +| `fixture` | 脱敏端口、端接和接线摘要 | +| `safety_limits` / `budget` | 当次限制、contributor、blocker 和准入结果 | +| `results` | 请求、回读、外测、容差和发送计数摘要 | +| `session_health` / `final_state` | 前后 health、最终 OFF 与恢复范围 | +| `coverage` / `limitations` | 已证明和明确未证明的内容 | +| `evidence_digest` | canonical manifest 的 SHA-256 | + +manifest 在 wheel 中使用本 distribution 自己的 +`.dist-info/wavebench-source-conformance/.json` 路径。descriptor 的 +`evidence_refs` 使用 `dist-info:wavebench-source-conformance/.json`,不得引用开发机 +绝对路径。核心只读取当前 distribution 的资源,拒绝路径穿越、跨 distribution 引用和摘要不匹配。 + +历史证据不能执行全局字母替换。若历史文档使用另一套等级定义,新 manifest 必须记录原始 +scheme、原始等级、按 `wavebench.source.a0-a5.v1` 重新评定的等级和理由;没有可证明映射时, +只保留为未分级 evidence ref。一次 operation artifact 只引用 manifest ID 和摘要,不复制整份证据。 + +## V1 兼容与迁移 + +### 版本门与双合同组合 + +| 组合 | 预期行为 | +| --- | --- | +| 旧核心 + 旧插件 | 保持对应版本的 V1 行为 | +| 新核心 + 旧插件 | `source_extensions=None`,保持 V1 路径,不推导 V2 写能力 | +| 旧核心 + 新插件 | 受管安装由 wheel `Requires-Dist` 在 entry point import 前拒绝;绕过 package inspection 的直接 `pip --no-deps` 或手工安装不承诺零导入,且不属于支持组合 | +| 新核心 + 新插件 | 只对明确声明并通过验证的 Source V2 capability 使用新合同 | +| 新核心 + 同时声明 V1/V2 的新插件 | 新 operation 只使用 V2;同义或副作用重叠的旧写入口映射/拒绝,不相交的旧 operation 保持 V1;单次事务不混用两套安全视图 | + +R2 决定保持 `wavebench.instrument.v2`。`source_extensions` 是带默认值的末尾扩展,新 Protocol +不改变现有 `SourceDriver`,新 capability 通过最低核心版本门显式 opt in。只有删除 Source V1、 +改变既有 capability 语义或签名、改变既有返回 model、破坏 descriptor append-only,或者新核心 +无法继续装载并执行兼容范围内的旧 V2 插件时,才升级为 `wavebench.instrument.v3`。 + +### 兼容性不变面 + +Source V2 是并列合同,不是对现有 Python model、CLI JSON 或 run artifact 的原地扩容。 +以下不变面在实现前冻结: + +| 层级 | 兼容合同 | +| --- | --- | +| descriptor | `source_extensions` 位于 `scope_extensions` 之后且默认 `None`;旧位置参数构造、字段默认值、比较和 `replace()` 保持原义 | +| 公共 Python API | 不向 `SourceDriver` 增加必需方法;不向 `SourceStatus` 或 `RestorableSourceState` 增加字段;V2 使用独立 Protocol 和 model | +| capability | 不修改既有 `source.*` ID、required method、参数、返回类型或副作用;V2 方法存在本身不产生 capability | +| 配置 | 旧 TOML 缺少绝对电压字段时按原值加载;新增字段仅显式授权 V2,不能反向限制或放宽 V1 | +| CLI 与 TUI | `snapshot-v2` 是附加子命令;既有子命令参数、V1 status JSON 和 TUI adapter 行为不变 | +| run plan | 首阶段不增加 V2 写 step;既有 step kind、必填字段、schema 和 V1 安全检查保持不变 | +| 恢复 artifact | 保留 `restore.source_state_scope="basic"`、`snapshot`、`snapshots` 及其 V1 字段;V2 operation artifact 使用独立 schema,不替换旧键 | +| 报告与包读取 | 旧 run package 继续可读;消费者必须忽略未知的附加 V2 artifact 引用,不能把 V2 缺失视为旧包损坏 | + +V1 `SourceStatus.as_dict()` 和 `RestorableSourceState.as_dict()` 的键集合与值语义保持不变。 +V2 snapshot 不得先展平到 V1 model 再用于预算或恢复。若未来需要把 V2 operation 引用嵌入 +`run.json`,只能增加带独立 `schema` 的可选命名空间,并先证明旧 reader 对未知键宽容;本 RFC +首阶段不修改现有 `restore` 对象。 + +Source V1 的 `0.8.23` capability → required method 冻结基线为: + +```text +source.idn -> idn +source.errors -> errors, assert_no_errors +source.status -> get_status +source.channel_profile -> get_channel_profile +source.coupling_profile -> get_coupling_profile +source.coupling_configure -> configure_coupling +source.harmonic_profile -> get_harmonic_profile +source.harmonic_configure -> configure_harmonics +source.modulation_am_profile -> get_am_modulation_profile +source.modulation_am_configure -> configure_am_modulation +source.modulation_fm_profile -> get_fm_modulation_profile +source.modulation_fm_configure -> configure_fm_modulation +source.modulation_pm_profile -> get_pm_modulation_profile +source.modulation_pm_configure -> configure_pm_modulation +source.modulation_pwm_profile -> get_pwm_modulation_profile +source.modulation_pwm_configure -> configure_pwm_modulation +source.pulse_profile -> get_pulse_profile +source.pulse_configure -> configure_pulse +source.burst_profile -> get_burst_profile +source.burst_configure -> configure_burst +source.burst_trigger -> trigger_burst +source.sweep_profile -> get_sweep_profile +source.sweep_configure -> configure_sweep +source.sweep_trigger -> trigger_sweep +source.counter_profile -> get_counter_profile +source.set_frequency -> set_frequency +source.set_function -> set_function +source.set_amplitude_vpp -> set_amplitude_vpp +source.set_square_duty_cycle -> set_square_duty_cycle +source.output -> set_output +source.arbitrary_probe -> probe_arbitrary_queries +source.arbitrary_upload -> upload_dg4000_dac14_block +``` + +该清单和 tuple 顺序使用静态契约测试锁定。注册 `source.snapshot_v2` 或后续 V2 capability 不能 +修改这些映射,也不能改变内建 descriptor 对既有 V1 capability 的声明。 + +双合同驱动首次声明 V2 写 capability 时,核心必须审计当前全部 V1 写表面: +`set_frequency`、`set_function`、`set_amplitude_vpp`、`set_square_duty_cycle`、`set_output`、 +`configure_coupling`、`configure_harmonics`、`configure_am_modulation`、 +`configure_fm_modulation`、`configure_pm_modulation`、`configure_pwm_modulation`、 +`configure_pulse`、`configure_burst`、`trigger_burst`、`configure_sweep`、`trigger_sweep`、 +`upload_arbitrary_waveform` 和 `restore_restorable_state`,以及 run 与 TUI 间接入口。审计结果必须 +逐项标记为「同义」「字段/发信号副作用重叠」或「不相交」。前两类必须有无损 V2 映射或稳定的 +零写入拒绝;不相交项可以保持 V1,不能仅凭「已扫描」省略分类证据。V1-only driver 的现有恢复 +顺序保持不变;声明 `source.output_v2` 的双合同驱动必须把「恢复 ON」转为独立 V2 授权,不得沿用 +旧恢复路径直接重新 ON。 + +### R5 V1 路由清单与 artifact 边界 + +M4.5 将上述 18 条 V1 Service 写路由冻结成核心内部清单,并为每条记录其 `OperationSpec`、CLI、 +run plan、TUI、离散扫频、恢复或安全门等间接入口,以及是否可能在输出已开启时改变信号、开始或 +重新开始输出、修改仪器存储。该清单是未来 V2 写 capability 的审计输入,不是 V1→V2 自动映射: +在本修订中所有 V1 路径仍按原合同执行,任何具体 V2 写 capability 仍必须逐项标记为「同义」、 +「字段/发信号副作用重叠」或「不相交」。 + +`run.json` 预留可选根键 `source_operations`,但只有某次运行实际产生一个或多个带独立 +`wavebench.source.operation.v1` schema 的 Source V2 operation artifact 时才写入。空列表、缺失值 +和所有当前 V1 run 都不得写该键;因此既有 `restore` 对象、step artifact 内的 `source_status` 和 +默认 V1 run JSON 的字节表示保持不变。run package 与报告 reader 必须继续忽略未知根键,不能把 +该可选命名空间的缺失当作旧包损坏。 + +### Source V1 生命周期 + +Source V1 冻结基线是 WaveBench `0.8.23` 已注册的全部既有 `source.*` capability,不只包括 +status、output 和基础 setter,也包括已经发布的 profile、configure、trigger 与 ARB capability。 + +1. **共存期**:Source V2 首次发布后,V1 保持完整支持且默认不产生弃用诊断。旧插件继续走 V1; + 只有显式声明 V2 capability 的插件才进入新路径。 +2. **冻结与弃用期**:V1 不再增加高级模型或厂商特例。允许保持签名和返回类型的安全修复、 + 错误类型修复及失败关闭修复。只有 V1 operation 已有无损 V2 映射或零 I/O 拒绝规则、全部公共 + 入口已迁移、双合同旁路已消除、两类协议完成试迁移、V1-only 兼容测试持续通过,并已发布 + 迁移指南后,才能标记 deprecated。诊断优先出现在文档、`plugin doctor` 和 + `capability explain`,不在每次成功执行时重复打印。 +3. **删除期**:Source V1 只能随 `wavebench.instrument.v3` 删除。V3 不能同时成为首次弃用通知 + 和删除版本;弃用状态必须至少经历一个完整的稳定核心发布周期。 + +插件自己的 `wavebench_max_version` 只约束具体 distribution 版本,不是 Source V1 的自动弃用日期, +也不覆盖核心对 executable API v2 的兼容承诺。 + +### adapter 边界 + +- V1 → V2 adapter 只能产生可无损映射的 basic/output 只读视图; +- V1 `None` 默认映射为 `UNKNOWN`,不根据函数名猜测 `NOT_APPLICABLE`; +- V1 adapter 不声明 `source.output_v2` 或其他 V2 写 capability; +- V2 → V1 可将非 `VALUE` 展平为 `None`,但只用于兼容返回值和显示; +- 有损展平不得用于预算、恢复、写前比较或 capability 声明; +- 旧高级 profile 只在无损对应时保持,不继续增加厂商伪默认。 + +同时声明 V1 与 V2 的驱动必须保持两套公共返回类型可区分。单次写事务不得混合 V1 状态视图 +与 V2 安全决策。 + +双合同驱动声明某项 V2 写 capability 后,同义或副作用闭包重叠的 V1 写入口必须在 Service 边界 +映射到对应 V2 operation,无法无损映射时在 I/O 前拒绝;经审计确认字段闭包和发信号路径均不相交 +的 V1 operation 可以继续保持原行为。未声明 V2 写 capability 的 V1-only 驱动保持原行为; +Source V2 首版禁止 live mutation 不追溯改变该路径。 + +### 独立 P0 缺陷修复 + +以下缺陷修复不需要完整 Source V2 才能设计,但 `Accepted R2` 的 M1–M2 授权不包含该修复: + +1. V1 `source.output ON` 遇到 `amplitude=None`、非有限数或非 Vpp 幅度时,返回稳定 `ConfigError`; +2. `bool`、字符串、NaN、Infinity 和负值不能通过 V1 安全检查; +3. 拒绝必须发生在 driver 写入前,测试断言写入计数为 0; +4. `source.output OFF` 不因幅度缺失被拒绝,但仍受 access policy 和 session health 规则约束; +5. Service、run、CLI 和 TUI 的错误包装保持一致; +6. 该修复不声明 Noise/DC 已获得可安全开启的 V2 预算。 + +## 验收证据分层 + +本节等级 scheme 固定为 `wavebench.source.a0-a5.v1`。验收等级绑定「具体 capability × 型号 × +固件 × 通道 × operation 方向」,不作为整个产品系列的标签,也不接受没有 scheme 的裸等级。 + +| 等级 | 证据 | 可以证明 | 不能证明 | +| --- | --- | --- | --- | +| A0 | 离线 fixture、model 校验与故障注入 | 命令格式、解析、发送次数、预算分支和失败关闭 | 真实仪器响应、波形或接线 | +| A1 | 实机只读 | 查询合法性、响应形态、型号和固件 | 写入、输出和触发 | +| A2 | 受控 ON/OFF | 输出转换、回读、OFF 恢复和最终状态 | 幅度、频率或波形精度 | +| A3 | 示波器通道环回 | 基础频率、Vpp、偏置、函数和占空比 | 高级波形语义或外部触发 | +| A4 | 高级波形测量 | 谐波频谱、调制包络、Sweep 路径、Burst 周期数等 | 未接线的触发、Gate、Sync 或通道间时序 | +| A5 | 真实触发或同步接线 | 外部触发、Gate、Sync 和通道间时序 | 未实际接入的其他端口 | + +建议最低关系: + +- 任一 capability 必须有 A0; +- profile read 至少有 A1; +- enable/disable 至少有 A2; +- 基础波形 write 至少有 A3; +- 高级 waveform write 至少有对应功能的 A4; +- external trigger、Gate 或 Sync 至少有对应功能的 A5。 + +每份 A1–A5 证据至少记录: + +- core、插件、distribution、版本和 wheel 摘要; +- 仪器族、型号、固件、选件和通道; +- descriptor 与 Source 合同修订; +- capability、feature、direction 和 mode; +- 脱敏的端口映射、实际端接和接线说明; +- 预算输入、配置上限、决策和 blocker; +- 请求值、回读值、外测值、容差和测量方法; +- 实际写入数、已完成数和结果未知数; +- session health 前后状态; +- 最终输出状态、恢复状态和未恢复字段; +- 证据时间与不在声明范围内的项目。 + +验收等级是发布和 capability 评审证据,不是运行时授权。核心可以验证证据引用格式, +但不会因 descriptor 声明 `A4` 而跳过 fresh snapshot、预算或 session 门。 + +历史文档若使用相同字母但定义不同,必须逐份重新评定;不得用「旧 A3 → 新 A1」之类全局替换。 + +## 离线验收门 + +### model 与 descriptor + +- `Observed` 全部合法与非法组合有单元测试; +- Vpp、RMS、频率、电阻和功率类型拒绝 `bool`、负值与非有限数,不在预算中静默正规化非法输入; +- feature、direction、channel、mode 和 typed profile 相互校验; +- `SourceFieldRef` 的 field/scope 组合、排序、去重和 canonical digest 有属性测试; +- runtime profile 对 descriptor 只能删减,任何新增 feature、direction、channel 或 constraint 都拒绝; +- descriptor 缺失 profile 或版本门时在 factory 前拒绝;缺 Protocol 方法时可由现有 factory 建立 + transport,但必须在任何 Source operation method 前拒绝,并关闭连接、释放租约; +- `source_extensions` 紧跟 `scope_extensions` 并保持 descriptor 最末字段;旧 V2 全位置参数 fixture、 + 关键字构造、比较和 `dataclasses.replace()` 测试通过; +- `source_extensions.__all__` 与顶层重导出逐项相等且对象 identity 相同,内部 coordinator、token、 + nonce、writer 和 raw transport 不可导入; +- 多声明方法不产生隐式 capability; +- 只实现 basic V1 的第三方 Source 不需要填充高级 V2 字段。 + +### snapshot 与查询计划 + +- 非激活 Harmonic 不发送不合法查询; +- `anchor -> facet -> anchor` 中的状态漂移返回 `DRIFTED`,不能作为写 baseline; +- core-issued 语义计划与插件协议执行记录逐项匹配;插件自报 epoch、availability 或 consistency + 不能提高 snapshot 可信度; +- plan ID、item ID、phase、effect、query count 和 deadline 任一不符时失败关闭; +- 消费型查询最多发送一次,并在 artifact 中标明 effect; +- selected-only 谐波不填充未读阶次; +- query timeout、短响应、解析失败和 session health 转移的 `Observed` 语义明确。 + +### 预算与入口覆盖 + +- 基础正弦、DC、Pulse、谐波、AM、Noise、ARB、Sweep、Combine 和负载转换都有正、负 fixture; +- 任一已启用 contributor 为非 `VALUE` 时拒绝 ON,写入计数为 0; +- 50 Ω 显示参考与实际高阻端接的电压变化可表示,且不会因基础 Vpp 未超限而误放行; +- `source.output`、`arb_load output_on`、ON 状态 setter/patch、arm/fire 和恢复 ON + 的统一决策有契约测试; +- 缺少绝对端口上下限、幅度参考基准、阻抗区间或所需共享功率 envelope 时拒绝 ON; +- `max_source_vpp` 或绝对电压任一安全轴缺失时,在 driver factory 前返回 + `source_safety_limits_required` 和稳定 `missing_fields`; +- 旧 TOML、只有一个绝对电压端点、有限负端点、不包含 0 的合法区间、`bool`、字符串、NaN、 + Infinity 和 `min >= max` 均有配置 fixture;所有 `WaveBenchConfig.with_*` 复制路径保留新字段; +- OFF 不因预算缺失被拒绝,但 `uncertain`/`poisoned` 仍按 session health 合同执行。 + +### ARB storage + +- create-only 对非空槽位零写拒绝; +- compare-and-replace 的旧摘要不匹配时零写拒绝; +- payload 摘要、大小、槽位和设备回读不一致时不报告成功; +- 上传结果未知时发送次数不超过 1,不自动改为新槽位重试; +- storage、selection 和 ON 的 operation/context/nonce 各自独立; +- 不支持权威摘要或内容回读的 fake driver 不能声明 `source.arbitrary_storage_v2`。 + +### 事务与恢复 + +- 每个目标字段写入次数不超过 1; +- 每个 phase 最多进入一次,phase 顺序、purpose 和允许 I/O 不匹配时拒绝; +- 预检、写入、回读、恢复和验证每个 phase 都有 deadline 与步骤上限,normal phase 不能消耗 + cleanup reserve; +- 第一次可能写入前失效受影响的 `verified_fields`,postcondition 不会隐式恢复旧证据; +- 辅助模式转换和设备隐式变化全部出现在 closure、执行记录和 postcondition; +- `uncertain` 普通 I/O 零发送,只允许核心授权恢复或验证; +- `poisoned` 旧连接 OFF、IDN、恢复和验证均零发送; +- recovery 失败不会把 health 改回 `healthy`; +- Combine/Coupling 故障的 OFF 范围覆盖 descriptor 声明的所有受影响通道; +- 不可恢复的 ARB memory、外部触发和私有状态显式记录为未覆盖。 + +### V1 兼容 + +- V1 公共 model 构造和重导出测试保持通过; +- 旧 `SourceStatus.as_dict()`、`RestorableSourceState.as_dict()`、CLI status JSON、run plan schema、 + `run.json.restore` 和报告读取 fixture 保持通过; +- 新核心 + 旧插件使用不导入任何 V2 symbol 的真实 entry point fixture,V1 resolve、factory 和 + operation 成功,V2 operation 以缺 capability 且零 transport 调用拒绝; +- 受管的旧核心 + 新插件 fixture 在 wheel metadata 阶段拒绝,entry point import side-effect + sentinel 为 0;越界安装的运行时 import 风险有显式负向测试,但不伪装成支持组合; +- Source V2 wheel dependency 与 descriptor 的 PEP 440 下界、上界、marker 和非法区间有一致性测试; +- descriptor/profile/version 错误在 factory 前拒绝;缺 Protocol 方法的 factory fixture 可以建立连接, + 但 operation method 调用数和协议发送数均为 0,且 transport 与租约已清理; +- 双合同驱动的全部 V1 setter、configure、ARB、ON/OFF、trigger、restore、run 和 TUI 路径均有 + 同义/副作用重叠/不相交分类;前两类无直接 driver 旁路,不相交项保持 V1 的契约测试; +- Source V1 的 0.8.23 capability 冻结清单有静态测试,V2 注册不会改变旧 required method 映射; +- V1 adapter 不能用于 V2 输出放行、恢复或写前比较; +- 旧 CLI 参数与机器可读输出、run plan step/schema、TUI 和 V1-only 恢复调用顺序不因未声明 V2 + 而失效; +- V1 `source.output ON` 对 `None`、字符串、`bool`、NaN、Infinity、负数和非 Vpp 单位返回稳定 + `ConfigError` 且写入数为 0;OFF 在幅度缺失时仍可执行; +- 新 Source V2 插件的 wheel 与 descriptor 下限指向第一个正式包含合同的核心版本。 + +R5 已加入以下纯离线兼容 fixture,作为上述要求的持续回归: + +- 合成 Source V2 wheel 在旧核心版本模拟下由 metadata gate 拒绝,entry point import sentinel 保持为零; +- 合成受管 Source V2 wheel 的 `Requires-Dist` 与 descriptor `[min,max)` 相同才可通过 postflight; + 较低下界、较宽上界、排除首个支持版本、无生效 marker、重复依赖和非法 requirement 都失败关闭, + 失败安装回滚且不留下受管记录; +- 合成 V1 entry point 仍可 resolve、factory 并成功执行一个 V1 frequency setter;同一对象调用 + `source.snapshot_v2` 在 factory/driver I/O 前以缺 capability 拒绝; +- V1 route 清单、V1 capability→method 映射、run plan step 集合、TUI/CLI 间接入口,以及 V2 run + plan 写 step 的解析拒绝均有静态测试。 + +### artifact 与 conformance + +- operation artifact 和 conformance manifest 分别校验各自 schema,不能互换; +- manifest 缺少 `conformance_scheme`、适用域、摘要或 limitations 时不能支持 capability 发布; +- 历史裸 A0–A5 等级不会自动映射; +- `dist-info:` evidence ref 只能解析当前 distribution 内的规范路径,路径穿越和跨包引用拒绝; +- operation artifact 只保留 manifest ID/摘要,不包含原始 payload、授权 token 或完整 nonce。 + +## Accepted 决议基线 + +1. 11 个 `SourceFeatureProfile`、8 个 channel facet、2 个非通道状态、嵌套 helper、reason code 和 + canonical serializer 按本文冻结,不保留 `object` 或自由 mapping。 +2. `source_extensions.__all__`、顶层 identity re-export 和 descriptor append-only 布局按本文冻结。 +3. `source.snapshot_v2` 的 `OperationSpec`、Service、CLI JSON、snapshot document 和只读 operation + artifact 按本文冻结。 +4. 首个支持核心版本为 WaveBench `0.8.24`;Source V2 descriptor 必须使用 PEP 440 且声明 + `wavebench_min_version >= 0.8.24`。 +5. 本次接受只授权 M1–M2。组合/标量 fake、负向测试和兼容矩阵属于实施退出门,不再作为 + `Accepted` 之前必须先写代码的条件。 + +后续任一 V2 写 capability 注册前,还必须补齐该 feature 的 request/result、完整 +`SourceOperationContract`、closure、预算、恢复、artifact、双合同入口和 A0 验收;本 RFC +`Accepted` 不会自动批准全部保留写 ID。 + +## 实施里程碑 + +| 里程碑 | 状态 | 范围 | 退出条件 | +| --- | --- | --- | --- | +| M0 | `Accepted` | 冻结 R2 | snapshot 公共类型、serializer、OperationSpec、Service/CLI、版本门和验收门全部决定 | +| M1 | `implemented-unreleased` | 纯 model 与 descriptor validation | `Observed`、field/scope、typed profile、runtime narrowing、显式 `__all__`、旧位置参数和负向构造测试通过;不改变 V1 | +| M2 | `implemented-unreleased` | `source.snapshot_v2` | 组合响应型与独立标量型 fake、anchor/facet/anchor、activation、语义缺字段、deadline、query limit、传输异常、artifact、CLI 和一致性 fixture 通过 | +| M3 | `implemented-unreleased` | 纯预算与显式安全配置 | 配置迁移、端接、适用域、有限纯电阻 Thevenin、DC/Noise/Harmonic/AM/ARB/Sweep/Combine/共享功率正负 fixture 通过;仍不注册写 capability | +| M4 | `implemented-unreleased` | Source operation context | phase、单写、closure、cleanup reserve、session health、nonce 和恢复 fixture 通过;仍不注册写 capability | +| M4.5 | `implemented-unreleased` | V1 路由审计与 artifact 兼容防线 | 18 条 V1 写路由及其间接入口冻结;V2 写 operation/run plan 为零;空 `source_operations` 不改变 V1 `run.json`,非空根键保持 additive | +| C1 | `implemented-unreleased` | 受管插件版本门与兼容矩阵 | metadata import-before gate、wheel/descriptor PEP 440 交叉校验、合成 V1 entry point 的 V1 成功/V2 零 I/O 拒绝,以及生命周期回滚 fixture 通过 | +| M5 | 未授权 | 分 feature 写 capability | basic/output 起步;每项分别完成 request/result、预算、恢复、双合同映射和 A0 | +| M6 | 未授权 | ARB storage 与跨通道 | CAS storage、selection、Combine/Coupling 图、多通道 OFF 和共享功率合同通过 | +| M7 | 未授权 | 插件逐项 opt in | 在单独授权和合适接线下按 scheme `wavebench.source.a0-a5.v1` 逐 capability 验收 | +| P0 | `implemented-unreleased` | V1 `amplitude=None` 失败关闭 | ON 对缺失、非有限、非 VPP 或负 Vpp 在 driver 写入前返回稳定 `ConfigError`;OFF 保持原有可执行语义 | + +## 已否决方案 + +- **继续向 V1 profile 增加 `Optional` 字段**:不能区分不支持、不适用、未查询和查询失败。 +- **使用 0 或默认值补齐 profile**:会制造虚假谐波、Burst 周期或调制参数,并污染预算。 +- **为每个厂商增加核心专用字段**:会将核心变成厂商协议集合。 +- **用一个 `source.patch_v2` 授权全部 feature**:不同 feature 的字段闭包、预算、恢复和验收等级不同, + 一个 capability 会掩盖真实授权范围。 +- **让 profile 查询临时写选择槽位**:破坏 query-only 语义,也无法在 `read_only` 会话中工作。 +- **把 ARB 上传、选择和 ON 放进一个 operation**:上传会改变存储且可能不可恢复,选择会改变配置, + ON 则开始能量转换,三者不能共享 baseline 或准入 nonce。 +- **只修改 `SourceService.set_output()`**:会留下 ARB `output_on`、ON 状态 setter、trigger 和恢复 ON 旁路。 +- **把复合安全预算全部交给插件**:核心无法保证不同 Service、run 和 TUI 入口使用同一决策。 +- **把 session 锁存放在 Source Service 或 driver**:Service 可被重建,driver 不能自行完成核心验证授权。 +- **结果未知时盲目重试或直接重连**:新连接不证明旧写入结果,也不证明配置已经恢复。 +- **在 `poisoned` session 上发送应急 OFF**:通信边界已不可证,新 I/O 会扩大未知状态。 +- **用历史示波器测量替代当前预算**:历史测量不是当前仪器状态或真实端接证明。 +- **用人工强制参数放行未知预算**:未知 contributor 不是数值超限,不能通过提高阈值解决。 + +## R2 维护者决议 + +- Source V2 是 Source 领域合同,不与当前可执行仪器 API V2 混用名称或版本语义。 +- executable API 保持 `wavebench.instrument.v2`;`source_extensions` append-only,新方法使用独立 Protocol。 +- 受管安装使用 wheel metadata 作为 import 前版本门;runtime descriptor 门位于模块导入后、factory + 前,不对越界安装虚构零导入保证。 +- Source V2 沿用现有 eager factory;实例 Protocol 验证位于 factory 后、operation 前,失败时关闭 + transport,且不发送 Source operation 命令。 +- Source V1 在整个 executable API v2 生命周期内保留;立即冻结,满足迁移门后才弃用,只能随 V3 删除。 +- 第一阶段只注册 `source.snapshot_v2`,不注册 V2 写 capability。 +- 首个支持核心版本为 `0.8.24`;snapshot-only descriptor 的版本下限不得更低。 +- 查询分为核心语义计划与插件协议计划;availability、runtime profile 和 consistency 由核心构造。 +- 不提供统一 `source.patch_v2`、`source.arm_v2` 或 `source.fire_v2`;写能力按 feature 和事务闭包拆分。 +- `Observed` 首版限于 Source V2,不在本 RFC 中扩展到 Scope、Power 或 DMM。 +- descriptor extension 使用 typed profile,不使用任意 mapping 冻结安全语义。 +- 复合预算由核心统一消费;插件提供厂商状态、typed constraint 和协议回读。 +- `max_source_vpp` 与端口绝对电压上下限必须显式配置,不能互相推导;缺失时 V2 能量操作失败关闭。 +- 只有 `HARD_CONSERVATIVE` 可以支持输出 ON 准入。 +- Source V2 首个可写修订禁止 live mutation;未 opt in 的 V1 行为不变。 +- ARB storage、selection/configuration 和 ON 使用三个独立 operation。 +- Source 事务复用核心 session health 和授权底座,不新增平行 `state_uncertain` 布尔值。 +- 失败恢复默认以 OFF 结束;重新 ON 是新的授权操作。 +- operation artifact 与 conformance manifest 使用不同 schema。 +- A0–A5 scheme 固定为 `wavebench.source.a0-a5.v1`,不替代运行时 snapshot、预算或 session 门。 + +## R5 维护者增补 + +- M4.5 只冻结 V1 写路由的审计事实和 run artifact 的可选命名空间;它不把任何 V1 路径自动迁移到 + V2,也不改变 V1 restore、`source_status` 或现有安全门的行为。 +- C1 的 wheel/descriptor 交叉门只适用于声明 `source.snapshot_v2` 的受管插件;V1-only wheel 和 + descriptor 继续使用现有 executable API v2 兼容规则。 +- 通用 wheel metadata gate 是 entry point import 前的保护;wheel/descriptor 交叉门必须等待 + descriptor 已加载,因此只承诺在 driver factory 和仪器 I/O 前失败关闭。 +- R5 的合成 fixture 证明当前核心门禁及回滚语义,不替代历史核心发行物、外部插件 wheel 或实机的 + 独立兼容与 conformance 验收。 + +## 剩余开放问题 + +以下问题不阻塞 snapshot-only 的 M1–M2,但会阻塞对应写 capability: + +1. `SourceTerminationEvidence` 对应的 TOML、run intent 和 CLI 人工确认语法;公共类型和有效期规则已冻结。 +2. 反应性、频率相关、非线性和未知负载是否扩展首版纯电阻模型;未扩展前固定失败关闭。 +3. 各型号/固件 ARB 插值、重建滤波和硬过冲上界如何取得;没有硬证据时 storage/selection 可独立验收,ON 拒绝。 +4. AM、FM、PM 及其它调制的载波幅度语义、过调制钳位和硬增益上界如何映射到 typed profile。 +5. 具体设备的共享功率 envelope、热降额和跨通道关系证据;公共关系图与失败关闭规则已冻结。 +6. 可选 RMS 安全配置键、默认迁移和哪些波形必须提供硬 RMS 上界。 +7. conformance manifest 是否增加签名、签名信任根和长期保留策略;schema、scheme、wheel 路径和摘要已冻结。 +8. 历史插件证据逐份迁移后的正式 manifest 清单;禁止全局等级替换。 + +这些问题描述的是「未来端口电压、电流或功率能否被保守上界覆盖」,只读 snapshot 不执行 +setter、trigger、storage mutation 或输出转换,因此不需要用尚未证明的负载/过冲模型放行任何 +动作。它们会阻塞写能力,是因为输出 ON、fire、恢复 ON 和 live mutation 必须证明完整目标状态 +在实验台绝对电压、Vpp、端接和设备共享功率边界内;缺少任一硬边界时只能失败关闭。 diff --git a/pyproject.toml b/pyproject.toml index c578e49..f25e4bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wavebench" -version = "0.8.23" +version = "0.8.24" 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 c8ae68a..d1959c8 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -1058,6 +1058,17 @@ def _main(argv: list[str] | None = None) -> int: else: _print_source_status(result) return 0 + if args.command == "snapshot-v2": + from wavebench.instruments.source_extensions import ( + source_snapshot_v2_operation_artifact, + ) + + payload = source_snapshot_v2_operation_artifact(service.snapshot_v2()) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 if args.command == "profile": _print_source_channel_profile(service.channel_profile(channel=args.channel)) return 0 diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index ef63cd7..ae973c2 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -656,6 +656,12 @@ def build_parser() -> argparse.ArgumentParser: source_status.add_argument("--channel", type=int, default=None) add_runtime_options(source_status) + source_snapshot_v2 = source_sub.add_parser( + "snapshot-v2", + help="Query a typed, read-only Source V2 snapshot", + ) + add_runtime_options(source_snapshot_v2) + source_profile = source_sub.add_parser( "profile", help="Query the complete read-only source channel profile", diff --git a/src/wavebench/instruments/__init__.py b/src/wavebench/instruments/__init__.py index 6850da3..2d09409 100644 --- a/src/wavebench/instruments/__init__.py +++ b/src/wavebench/instruments/__init__.py @@ -109,10 +109,14 @@ WaveformHeader, ) from . import scope_extensions as _scope_extensions +from . import source_extensions as _source_extensions for _scope_extension_name in _scope_extensions.__all__: globals()[_scope_extension_name] = getattr(_scope_extensions, _scope_extension_name) +for _source_extension_name in _source_extensions.__all__: + globals()[_source_extension_name] = getattr(_source_extensions, _source_extension_name) + __all__ = [ "ArbitraryQueryProbeResult", "DmmCalculationStatistics", @@ -225,6 +229,8 @@ "WaveformHeader", "open_instrument_driver", *_scope_extensions.__all__, + *_source_extensions.__all__, ] del _scope_extension_name +del _source_extension_name diff --git a/src/wavebench/instruments/api.py b/src/wavebench/instruments/api.py index d69c9df..939005e 100644 --- a/src/wavebench/instruments/api.py +++ b/src/wavebench/instruments/api.py @@ -10,6 +10,7 @@ from wavebench.transport.base import InstrumentTransport from .scope_extensions import ScopeDescriptorExtensions +from .source_extensions import SourceDescriptorExtensions EXECUTABLE_PLUGIN_API_VERSION = "wavebench.instrument.v2" ScopeCouplingPolicy = Literal["fixed-high-impedance", "switchable-termination", "unknown"] @@ -90,6 +91,7 @@ class InstrumentDescriptor: resource_schemes: tuple[str, ...] = () # Append-only to preserve the positional layout accepted by instrument API v2. scope_extensions: ScopeDescriptorExtensions | None = None + source_extensions: SourceDescriptorExtensions | None = None def __post_init__(self) -> None: if not self.driver_id or self.driver_id.strip() != self.driver_id: @@ -122,6 +124,11 @@ def __post_init__(self) -> None: 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") + if self.source_extensions is not None: + if self.kind != "source": + raise ValueError("source_extensions can only be declared by source descriptors") + if not isinstance(self.source_extensions, SourceDescriptorExtensions): + raise TypeError("source_extensions has an invalid type") def with_distribution( self, diff --git a/src/wavebench/instruments/capabilities.py b/src/wavebench/instruments/capabilities.py index 823db27..371981c 100644 --- a/src/wavebench/instruments/capabilities.py +++ b/src/wavebench/instruments/capabilities.py @@ -9,6 +9,10 @@ SCOPE_CAPABILITY_METHODS, validate_scope_descriptor, ) +from .source_extension_capabilities import ( + SOURCE_EXTENSION_CAPABILITY_METHODS, + validate_source_descriptor, +) CAPABILITY_METHODS: dict[str, tuple[str, ...]] = { @@ -90,6 +94,7 @@ "sweep_analyzer.analysis": ("read_measurements",), } CAPABILITY_METHODS.update(SCOPE_CAPABILITY_METHODS) +CAPABILITY_METHODS.update(SOURCE_EXTENSION_CAPABILITY_METHODS) def require_capabilities( @@ -125,3 +130,4 @@ def validate_declared_capabilities( f"method(s): {', '.join(missing_methods)}" ) validate_scope_descriptor(descriptor, driver=driver) + validate_source_descriptor(descriptor, driver=driver) diff --git a/src/wavebench/instruments/registry.py b/src/wavebench/instruments/registry.py index 2adfcc2..86d2c39 100644 --- a/src/wavebench/instruments/registry.py +++ b/src/wavebench/instruments/registry.py @@ -20,6 +20,7 @@ from .builtin import BUILTIN_INSTRUMENTS from .capabilities import CAPABILITY_METHODS from .scope_extension_capabilities import validate_scope_descriptor +from .source_extension_capabilities import validate_source_descriptor from .migrations import BUILTIN_MIGRATION_DISTRIBUTIONS ENTRY_POINT_GROUP = "wavebench.instruments" @@ -204,6 +205,7 @@ def _validate_descriptor( f"{', '.join(unknown_capabilities)}" ) validate_scope_descriptor(descriptor) + validate_source_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/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py new file mode 100644 index 0000000..3c102ed --- /dev/null +++ b/src/wavebench/instruments/source_extension_capabilities.py @@ -0,0 +1,264 @@ +"""Capability registration and validation for Source V2 snapshots.""" + +from __future__ import annotations + +from collections.abc import Iterable +from types import MappingProxyType +from typing import Mapping + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +from wavebench.errors import ConfigError + +from .source_extensions import ( + SOURCE_CONTRACT_VERSION, + SOURCE_SNAPSHOT_MIN_CORE_VERSION, + SourceDescriptorExtensions, + SourceAnchorField, + SourceFieldId, + SourceFeature, + SourceFeatureDirection, + SourceQueryEffect, +) + + +SOURCE_EXTENSION_CAPABILITY_METHODS: Mapping[str, tuple[str, ...]] = MappingProxyType( + { + "source.snapshot_v2": ("execute_source_query_plan_v2",), + } +) + + +def validate_source_descriptor(descriptor: object, driver: object | None = None) -> None: + capabilities = tuple(getattr(descriptor, "capabilities", ())) + declared = tuple( + capability + for capability in capabilities + if capability in SOURCE_EXTENSION_CAPABILITY_METHODS + ) + extensions = getattr(descriptor, "source_extensions", None) + if extensions is None: + if declared: + raise ConfigError("Source V2 capabilities require descriptor source_extensions") + return + if getattr(descriptor, "kind", None) != "source": + raise ConfigError("source_extensions can only be declared by source descriptors") + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError("source_extensions has an invalid type") + if extensions.contract_version != SOURCE_CONTRACT_VERSION: + raise ConfigError("source_extensions uses an unsupported contract version") + if declared != ("source.snapshot_v2",): + raise ConfigError( + "source_extensions require the source.snapshot_v2 capability and no other " + "Source V2 capability is registered in this core revision" + ) + _validate_source_version_range(descriptor) + _validate_read_contract(extensions) + if driver is not None: + method = getattr(driver, "execute_source_query_plan_v2", None) + if not callable(method): + raise TypeError( + "descriptor declares capability 'source.snapshot_v2', but driver lacks " + "callable method execute_source_query_plan_v2" + ) + + +def validate_source_plugin_dependencies( + descriptor: object, + dependencies: Iterable[str], +) -> None: + """Cross-check a Source V2 descriptor against its wheel metadata. + + The generic wheel gate proves that the current core satisfies one active + ``wavebench`` requirement before importing an entry point. This second, + post-import gate is deliberately limited to Source V2: it makes the + descriptor's declared PEP 440 interval and that wheel requirement describe + the same first-supported floor and exclusive ceiling. V1-only plugins + retain their existing package semantics. + """ + + capabilities = tuple(getattr(descriptor, "capabilities", ())) + if "source.snapshot_v2" not in capabilities: + return + _validate_source_version_range(descriptor) + + requirements: list[Requirement] = [] + for dependency in dependencies: + if not isinstance(dependency, str): + raise ConfigError("Source V2 wheel dependency metadata must contain strings") + try: + requirement = Requirement(dependency) + except InvalidRequirement as exc: + raise ConfigError("Source V2 wheel has an invalid Requires-Dist entry") from exc + if canonicalize_name(requirement.name) == "wavebench" and ( + requirement.marker is None or requirement.marker.evaluate() + ): + requirements.append(requirement) + if len(requirements) != 1: + raise ConfigError( + "Source V2 wheel must declare exactly one active WaveBench dependency for its descriptor" + ) + requirement = requirements[0] + + try: + minimum = Version(getattr(descriptor, "wavebench_min_version", "")) + maximum = Version(getattr(descriptor, "wavebench_max_version", "")) + except (InvalidVersion, TypeError) as exc: # pragma: no cover - checked above + raise ConfigError("Source V2 descriptor versions must use valid PEP 440 syntax") from exc + specifiers = tuple(requirement.specifier) + has_floor = any( + item.operator == ">=" and Version(item.version) == minimum + for item in specifiers + ) + has_ceiling = any( + item.operator == "<" and Version(item.version) == maximum + for item in specifiers + ) + if not has_floor or not has_ceiling: + raise ConfigError( + "Source V2 wheel WaveBench dependency must explicitly include " + f">={minimum},<{maximum} to match the descriptor" + ) + if minimum not in requirement.specifier or maximum in requirement.specifier: + raise ConfigError( + "Source V2 wheel WaveBench dependency expands or excludes its descriptor interval" + ) + + +def _validate_source_version_range(descriptor: object) -> None: + minimum_text = getattr(descriptor, "wavebench_min_version", "") + maximum_text = getattr(descriptor, "wavebench_max_version", "") + try: + minimum = Version(minimum_text) + maximum = Version(maximum_text) + required = Version(SOURCE_SNAPSHOT_MIN_CORE_VERSION) + except (InvalidVersion, TypeError) as exc: + raise ConfigError("Source V2 descriptor versions must use valid PEP 440 syntax") from exc + if minimum >= maximum: + raise ConfigError("Source V2 descriptor version range must satisfy min < max") + if minimum < required: + raise ConfigError( + "Source V2 descriptors require wavebench_min_version >= " + f"{SOURCE_SNAPSHOT_MIN_CORE_VERSION}" + ) + + +def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None: + feature_keys = { + (feature.feature, feature.scope, feature.channels): feature + for feature in extensions.features + } + for feature in extensions.features: + if feature.support.value == "supported" and ( + SourceFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + f"supported Source V2 feature {feature.feature.value!r} must declare read" + ) + if any(direction is not SourceFeatureDirection.READ for direction in feature.directions): + raise ConfigError( + "the accepted Source V2 snapshot revision only allows read directions" + ) + if feature.support.value == "supported" and not any( + facet.feature is feature.feature and facet.scope is feature.scope + for facet in extensions.query_contract.facets + ): + raise ConfigError( + f"supported Source V2 feature {feature.feature.value!r} lacks a query contract" + ) + identity_facets = tuple( + facet + for facet in extensions.query_contract.facets + if SourceFieldId.IDENTITY in facet.fields + ) + if len(identity_facets) != 1 or not identity_facets[0].required: + raise ConfigError("Source V2 query contracts require one required identity facet") + activation_fields = { + SourceAnchorField.WAVEFORM_KIND: SourceFieldId.BASIC, + SourceAnchorField.FREQUENCY_MODE: SourceFieldId.BASIC, + SourceAnchorField.OUTPUT_ENABLED: SourceFieldId.OUTPUT, + SourceAnchorField.HARMONICS_ENABLED: SourceFieldId.HARMONICS, + SourceAnchorField.MODULATION_ENABLED: SourceFieldId.MODULATION, + SourceAnchorField.SWEEP_ENABLED: SourceFieldId.SWEEP, + SourceAnchorField.BURST_ENABLED: SourceFieldId.BURST, + SourceAnchorField.ARBITRARY_PLAYBACK_MODE: SourceFieldId.ARBITRARY_SELECTION, + SourceAnchorField.COMBINE_ENABLED: SourceFieldId.COMBINE, + SourceAnchorField.COUPLING_ENABLED: SourceFieldId.COUPLING, + SourceAnchorField.TRACKING_ENABLED: SourceFieldId.TRACKING, + } + field_features = { + SourceFieldId.BASIC: frozenset({SourceFeature.BASIC}), + SourceFieldId.OUTPUT: frozenset({SourceFeature.OUTPUT}), + SourceFieldId.DISPLAY_LOAD: frozenset({SourceFeature.OUTPUT}), + SourceFieldId.HARMONICS: frozenset({SourceFeature.HARMONICS}), + SourceFieldId.MODULATION: frozenset({SourceFeature.MODULATION}), + SourceFieldId.SWEEP: frozenset({SourceFeature.SWEEP}), + SourceFieldId.BURST: frozenset({SourceFeature.BURST}), + SourceFieldId.PULSE: frozenset({SourceFeature.PULSE}), + SourceFieldId.ARBITRARY_SELECTION: frozenset({SourceFeature.ARBITRARY}), + SourceFieldId.ARBITRARY_STORAGE: frozenset({SourceFeature.ARBITRARY}), + SourceFieldId.ARM_STATE: frozenset({SourceFeature.BURST, SourceFeature.SWEEP}), + SourceFieldId.TRIGGER_STATE: frozenset({SourceFeature.BURST, SourceFeature.SWEEP}), + SourceFieldId.COMBINE: frozenset({SourceFeature.COMBINE}), + SourceFieldId.COUPLING: frozenset({SourceFeature.COUPLING}), + SourceFieldId.TRACKING: frozenset({SourceFeature.TRACKING}), + SourceFieldId.COPY: frozenset({SourceFeature.COPY}), + SourceFieldId.PHASE_RELATION: frozenset({SourceFeature.PHASE_RELATION}), + SourceFieldId.RELATION_GRAPH: frozenset( + { + SourceFeature.COMBINE, + SourceFeature.COUPLING, + SourceFeature.TRACKING, + SourceFeature.COPY, + SourceFeature.PHASE_RELATION, + } + ), + SourceFieldId.REFERENCE_CLOCK: frozenset({SourceFeature.REFERENCE_CLOCK}), + SourceFieldId.SYNC: frozenset({SourceFeature.SYNC}), + SourceFieldId.CASCADE: frozenset({SourceFeature.CASCADE}), + SourceFieldId.SHARED_POWER: frozenset({SourceFeature.SHARED_POWER}), + SourceFieldId.COUNTER: frozenset({SourceFeature.COUNTER}), + } + for facet in extensions.query_contract.facets: + if facet.effect is not SourceQueryEffect.PURE_READ: + raise ConfigError("source.snapshot_v2 query contracts only allow pure_read") + if SourceFieldId.IDENTITY in facet.fields: + if ( + facet.feature is not SourceFeature.BASIC + or facet.fields != (SourceFieldId.IDENTITY,) + or facet.scope.value != "instrument" + ): + raise ConfigError("Source V2 identity facet must contain only source.identity") + continue + if any(facet.feature not in field_features[field] for field in facet.fields): + raise ConfigError( + f"Source V2 query field does not belong to feature {facet.feature.value!r}" + ) + matching = [ + feature + for (kind, scope, _channels), feature in feature_keys.items() + if kind is facet.feature and scope is facet.scope + ] + if not matching: + raise ConfigError( + f"Source V2 query contract references undeclared feature {facet.feature.value!r}" + ) + if not any(feature.support.value == "supported" for feature in matching): + raise ConfigError( + "Source V2 query contracts cannot probe unsupported or unknown features" + ) + for rule in facet.activation_any: + for predicate in rule.predicates: + if activation_fields[predicate.field] not in extensions.query_contract.anchor_fields: + raise ConfigError( + "Source V2 activation predicates must reference declared anchor fields" + ) + + +__all__ = [ + "SOURCE_EXTENSION_CAPABILITY_METHODS", + "validate_source_descriptor", + "validate_source_plugin_dependencies", +] diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py new file mode 100644 index 0000000..89fab83 --- /dev/null +++ b/src/wavebench/instruments/source_extensions.py @@ -0,0 +1,3070 @@ +"""Public Source V2 snapshot contracts. + +The first accepted revision is deliberately read-only. It models descriptor +capabilities, typed observations, query plans and snapshot artifacts without +authorizing any Source write operation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, is_dataclass +from datetime import datetime, timezone +from enum import StrEnum +from hashlib import sha256 +import json +from math import isfinite +import re +from typing import Generic, Literal, Protocol, TypeAlias, TypeVar, runtime_checkable + +from .contracts import InstrumentDriver + + +SOURCE_CONTRACT_VERSION = "wavebench.source.v2" +SOURCE_SNAPSHOT_SCHEMA = "wavebench.source.snapshot.v2" +SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.source.operation.v1" +SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.24" + +_SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") +_DIST_INFO_EVIDENCE_REF = re.compile( + r"^dist-info:[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$" +) +_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") + + +def _require_bool(value: object, label: str) -> None: + if not isinstance(value, bool): + raise ValueError(f"{label} must be boolean") + + +def _require_int( + value: object, + label: str, + *, + minimum: int | None = None, + maximum: int | None = None, +) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label} must be an integer") + if minimum is not None and value < minimum: + raise ValueError(f"{label} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{label} must be <= {maximum}") + + +def _require_finite( + value: object, + label: str, + *, + minimum: float | None = None, + maximum: float | None = None, +) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise ValueError(f"{label} must be finite") + if minimum is not None and value < minimum: + raise ValueError(f"{label} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{label} must be <= {maximum}") + + +def _require_token(value: object, label: str) -> None: + if not isinstance(value, str) or _SAFE_TOKEN.fullmatch(value) is None: + raise ValueError(f"{label} must be a short safe token") + + +def _require_text(value: object, label: str) -> None: + if ( + not isinstance(value, str) + or not value + or value.strip() != value + or len(value) > 128 + or any(ord(character) < 32 for character in value) + ): + raise ValueError(f"{label} must be non-empty, trimmed safe text") + + +def _require_text_tuple(values: object, label: str) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + for value in values: + _require_text(value, label) + if len(set(values)) != len(values) or tuple(sorted(values)) != values: + raise ValueError(f"{label} must be sorted and unique") + + +def _require_token_tuple( + values: object, + label: str, + *, + allow_empty: bool = True, +) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + for value in values: + _require_token(value, label) + if len(set(values)) != len(values) or tuple(sorted(values)) != values: + raise ValueError(f"{label} must be sorted and unique") + + +def _require_evidence_ref_tuple(values: object, label: str) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + for value in values: + valid_dist_info = ( + isinstance(value, str) + and len(value) <= 240 + and _DIST_INFO_EVIDENCE_REF.fullmatch(value) is not None + and all(part not in {".", ".."} for part in value.removeprefix("dist-info:").split("/")) + ) + if not valid_dist_info: + _require_token(value, label) + if len(set(values)) != len(values) or tuple(sorted(values)) != values: + raise ValueError(f"{label} must be sorted and unique") + + +def _require_enum_tuple( + values: object, + enum_type: type[StrEnum], + label: str, + *, + allow_empty: bool = False, +) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + if any(not isinstance(value, enum_type) for value in values): + raise ValueError(f"{label} entries have an invalid type") + enum_values = tuple(value.value for value in values) + if len(set(enum_values)) != len(enum_values) or tuple(sorted(enum_values)) != enum_values: + raise ValueError(f"{label} must be sorted by value and unique") + + +def _require_positive_channels(values: object, label: str, *, allow_empty: bool = False) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + for value in values: + _require_int(value, label, minimum=1) + if len(set(values)) != len(values) or tuple(sorted(values)) != values: + raise ValueError(f"{label} must be sorted and unique") + + +def _contains_nonfinite(value: object) -> bool: + if isinstance(value, float): + return not isfinite(value) + if isinstance(value, tuple): + return any(_contains_nonfinite(item) for item in value) + if is_dataclass(value) and not isinstance(value, type): + return any(_contains_nonfinite(getattr(value, item.name)) for item in fields(value)) + return False + + +class SourceFeature(StrEnum): + BASIC = "basic" + OUTPUT = "output" + HARMONICS = "harmonics" + MODULATION = "modulation" + SWEEP = "sweep" + BURST = "burst" + PULSE = "pulse" + ARBITRARY = "arbitrary" + COUNTER = "counter" + REFERENCE_CLOCK = "reference_clock" + SYNC = "sync" + CASCADE = "cascade" + COMBINE = "combine" + TRACKING = "tracking" + COUPLING = "coupling" + COPY = "copy" + PHASE_RELATION = "phase_relation" + SHARED_POWER = "shared_power" + + +class SourceFeatureDirection(StrEnum): + READ = "read" + CONFIGURE = "configure" + ENABLE = "enable" + DISABLE = "disable" + ARM = "arm" + FIRE = "fire" + + +class SourceWaveformKind(StrEnum): + SINE = "sine" + SQUARE = "square" + RAMP = "ramp" + PULSE = "pulse" + NOISE = "noise" + DC = "dc" + ARBITRARY = "arbitrary" + OTHER = "other" + + +class SourceFrequencyMode(StrEnum): + FIXED = "fixed" + SWEEP = "sweep" + LIST = "list" + UNKNOWN = "unknown" + + +class SourceArbitraryPlaybackMode(StrEnum): + DDS = "dds" + TRUE_ARB = "true_arb" + UNKNOWN = "unknown" + + +class SourceAmplitudeUnit(StrEnum): + VPP = "vpp" + VRMS = "vrms" + DBM = "dbm" + V = "v" + UNKNOWN = "unknown" + + +class SourceOutputPolarity(StrEnum): + NORMAL = "normal" + INVERTED = "inverted" + UNKNOWN = "unknown" + + +class SourceLoadKind(StrEnum): + HIGH_IMPEDANCE = "high_impedance" + RESISTIVE = "resistive" + UNKNOWN = "unknown" + + +class HarmonicCompleteness(StrEnum): + COMPLETE = "complete" + ACTIVE_ONLY = "active_only" + SELECTED_ONLY = "selected_only" + PARTIAL = "partial" + + +class ComponentAmplitudeKind(StrEnum): + ABSOLUTE_VPP = "absolute_vpp" + RELATIVE_LINEAR = "relative_linear" + RELATIVE_DB = "relative_db" + + +class SourceModulationKind(StrEnum): + AM = "am" + DSB_AM = "dsb_am" + FM = "fm" + PM = "pm" + PWM = "pwm" + ASK = "ask" + FSK = "fsk" + PSK = "psk" + OTHER = "other" + + +class SourceModulationSource(StrEnum): + INTERNAL = "internal" + EXTERNAL = "external" + CHANNEL = "channel" + UNKNOWN = "unknown" + + +class SourceModulationParameterKind(StrEnum): + DEPTH_PERCENT = "depth_percent" + FREQUENCY_DEVIATION_HZ = "frequency_deviation_hz" + PHASE_DEVIATION_DEG = "phase_deviation_deg" + DUTY_DEVIATION_PERCENT = "duty_deviation_percent" + SYMBOL_RATE_HZ = "symbol_rate_hz" + + +class SourceSweepSpacing(StrEnum): + LINEAR = "linear" + LOGARITHMIC = "logarithmic" + STEP = "step" + UNKNOWN = "unknown" + + +class SourceTriggerSource(StrEnum): + INTERNAL = "internal" + EXTERNAL = "external" + MANUAL = "manual" + BUS = "bus" + UNKNOWN = "unknown" + + +class SourceTriggerSlope(StrEnum): + POSITIVE = "positive" + NEGATIVE = "negative" + EITHER = "either" + UNKNOWN = "unknown" + + +class SourceTriggerOutput(StrEnum): + OFF = "off" + POSITIVE = "positive" + NEGATIVE = "negative" + UNKNOWN = "unknown" + + +class SourceBurstMode(StrEnum): + TRIGGERED = "triggered" + GATED = "gated" + INFINITY = "infinity" + UNKNOWN = "unknown" + + +class SourceGatePolarity(StrEnum): + NORMAL = "normal" + INVERTED = "inverted" + UNKNOWN = "unknown" + + +class SourcePulseHoldBasis(StrEnum): + WIDTH = "width" + DUTY = "duty" + UNKNOWN = "unknown" + + +class SourceCounterMeasurementKind(StrEnum): + FREQUENCY_HZ = "frequency_hz" + PERIOD_S = "period_s" + DUTY_PERCENT = "duty_percent" + POSITIVE_WIDTH_S = "positive_width_s" + NEGATIVE_WIDTH_S = "negative_width_s" + UNKNOWN = "unknown" + + +class SourceInputCoupling(StrEnum): + AC = "ac" + DC = "dc" + UNKNOWN = "unknown" + + +class SourceReferenceClockMode(StrEnum): + INTERNAL = "internal" + EXTERNAL = "external" + AUTO = "auto" + UNKNOWN = "unknown" + + +class SourceReasonCode(StrEnum): + DESCRIPTOR_UNSUPPORTED = "descriptor_unsupported" + SUPPORT_UNKNOWN = "support_unknown" + NOT_REQUESTED = "not_requested" + INACTIVE_BY_ANCHOR = "inactive_by_anchor" + ANCHOR_UNAVAILABLE = "anchor_unavailable" + RESPONSE_MISSING_FIELD = "response_missing_field" + RESPONSE_INVALID_VALUE = "response_invalid_value" + DRIVER_SKIPPED_OPTIONAL = "driver_skipped_optional" + QUERY_DEADLINE_EXCEEDED = "query_deadline_exceeded" + QUERY_LIMIT_EXCEEDED = "query_limit_exceeded" + PROTOCOL_RECORD_INVALID = "protocol_record_invalid" + REQUIRED_OBSERVATION_MISSING = "required_observation_missing" + SESSION_NOT_HEALTHY = "session_not_healthy" + CONSISTENCY_UNPROVEN = "consistency_unproven" + CONSISTENCY_DRIFTED = "consistency_drifted" + + +class SupportState(StrEnum): + SUPPORTED = "supported" + UNSUPPORTED = "unsupported" + UNKNOWN = "unknown" + + +class Availability(StrEnum): + VALUE = "value" + UNSUPPORTED = "unsupported" + NOT_APPLICABLE = "not_applicable" + NOT_QUERIED = "not_queried" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +T = TypeVar("T") + + +@dataclass(frozen=True, slots=True) +class Observed(Generic[T]): + availability: Availability + value: T | None = None + reason_code: SourceReasonCode | None = None + evidence_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.availability, Availability): + raise ValueError("observed availability has an invalid type") + _require_evidence_ref_tuple(self.evidence_refs, "observed evidence_refs") + if self.availability is Availability.VALUE: + if self.value is None: + raise ValueError("VALUE observations must carry a value") + if self.reason_code is not None: + raise ValueError("VALUE observations cannot carry a reason_code") + if _contains_nonfinite(self.value): + raise ValueError("VALUE observations cannot contain non-finite floats") + else: + if self.value is not None: + raise ValueError("non-VALUE observations cannot carry a value") + if not isinstance(self.reason_code, SourceReasonCode): + raise ValueError("non-VALUE observations require a registered reason_code") + + @classmethod + def value_of(cls, value: T, *, evidence_refs: tuple[str, ...] = ()) -> "Observed[T]": + return cls(Availability.VALUE, value, evidence_refs=evidence_refs) + + @classmethod + def missing( + cls, + availability: Availability, + reason_code: SourceReasonCode, + ) -> "Observed[T]": + if availability is Availability.VALUE: + raise ValueError("missing observations cannot use VALUE availability") + return cls(availability=availability, reason_code=reason_code) + + +@dataclass(frozen=True, slots=True) +class SourceBasicCapabilityProfile: + waveform_kinds: tuple[SourceWaveformKind, ...] + frequency_modes: tuple[SourceFrequencyMode, ...] + amplitude_units: tuple[SourceAmplitudeUnit, ...] + offset_readable: bool + phase_readable: bool + square_duty_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple(self.waveform_kinds, SourceWaveformKind, "basic waveform_kinds") + _require_enum_tuple(self.frequency_modes, SourceFrequencyMode, "basic frequency_modes") + _require_enum_tuple(self.amplitude_units, SourceAmplitudeUnit, "basic amplitude_units") + _require_bool(self.offset_readable, "basic offset_readable") + _require_bool(self.phase_readable, "basic phase_readable") + _require_bool(self.square_duty_readable, "basic square_duty_readable") + + +@dataclass(frozen=True, slots=True) +class SourceOutputCapabilityProfile: + output_readable: bool + display_load_readable: bool + polarity_readable: bool + + def __post_init__(self) -> None: + _require_bool(self.output_readable, "output output_readable") + _require_bool(self.display_load_readable, "output display_load_readable") + _require_bool(self.polarity_readable, "output polarity_readable") + + +@dataclass(frozen=True, slots=True) +class SourceHarmonicCapabilityProfile: + minimum_order: int + maximum_order: int + amplitude_kinds: tuple[ComponentAmplitudeKind, ...] + completeness_modes: tuple[HarmonicCompleteness, ...] + + def __post_init__(self) -> None: + _require_int(self.minimum_order, "harmonic minimum_order", minimum=2) + _require_int(self.maximum_order, "harmonic maximum_order", minimum=self.minimum_order) + _require_enum_tuple( + self.amplitude_kinds, + ComponentAmplitudeKind, + "harmonic amplitude_kinds", + ) + _require_enum_tuple( + self.completeness_modes, + HarmonicCompleteness, + "harmonic completeness_modes", + ) + + +@dataclass(frozen=True, slots=True) +class SourceModulationCapabilityProfile: + kinds: tuple[SourceModulationKind, ...] + sources: tuple[SourceModulationSource, ...] + parameter_kinds: tuple[SourceModulationParameterKind, ...] + inactive_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple(self.kinds, SourceModulationKind, "modulation kinds") + _require_enum_tuple(self.sources, SourceModulationSource, "modulation sources") + _require_enum_tuple( + self.parameter_kinds, + SourceModulationParameterKind, + "modulation parameter_kinds", + ) + _require_bool(self.inactive_readable, "modulation inactive_readable") + + +@dataclass(frozen=True, slots=True) +class SourceSweepCapabilityProfile: + spacing_modes: tuple[SourceSweepSpacing, ...] + trigger_sources: tuple[SourceTriggerSource, ...] + timing_readable: bool + marker_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple(self.spacing_modes, SourceSweepSpacing, "sweep spacing_modes") + _require_enum_tuple(self.trigger_sources, SourceTriggerSource, "sweep trigger_sources") + _require_bool(self.timing_readable, "sweep timing_readable") + _require_bool(self.marker_readable, "sweep marker_readable") + + +@dataclass(frozen=True, slots=True) +class SourceBurstCapabilityProfile: + modes: tuple[SourceBurstMode, ...] + trigger_sources: tuple[SourceTriggerSource, ...] + timing_readable: bool + gate_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple(self.modes, SourceBurstMode, "burst modes") + _require_enum_tuple(self.trigger_sources, SourceTriggerSource, "burst trigger_sources") + _require_bool(self.timing_readable, "burst timing_readable") + _require_bool(self.gate_readable, "burst gate_readable") + + +@dataclass(frozen=True, slots=True) +class SourcePulseCapabilityProfile: + hold_modes: tuple[SourcePulseHoldBasis, ...] + delay_readable: bool + transitions_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple(self.hold_modes, SourcePulseHoldBasis, "pulse hold_modes") + _require_bool(self.delay_readable, "pulse delay_readable") + _require_bool(self.transitions_readable, "pulse transitions_readable") + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryCapabilityProfile: + playback_modes: tuple[SourceArbitraryPlaybackMode, ...] + selection_readable: bool + storage_metadata_readable: bool + sample_rate_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple( + self.playback_modes, + SourceArbitraryPlaybackMode, + "arbitrary playback_modes", + ) + _require_bool(self.selection_readable, "arbitrary selection_readable") + _require_bool( + self.storage_metadata_readable, + "arbitrary storage_metadata_readable", + ) + _require_bool(self.sample_rate_readable, "arbitrary sample_rate_readable") + + +class SourceQueryEffect(StrEnum): + PURE_READ = "pure_read" + STATEFUL_CONSUMING_READ = "stateful_consuming_read" + REQUIRES_SELECTOR_WRITE = "requires_selector_write" + UNKNOWN_EFFECT = "unknown_effect" + + +@dataclass(frozen=True, slots=True) +class SourceCounterCapabilityProfile: + input_ids: tuple[str, ...] + measurement_kinds: tuple[SourceCounterMeasurementKind, ...] + configuration_readable: bool + query_effect: SourceQueryEffect + + def __post_init__(self) -> None: + _require_token_tuple(self.input_ids, "counter input_ids", allow_empty=False) + _require_enum_tuple( + self.measurement_kinds, + SourceCounterMeasurementKind, + "counter measurement_kinds", + ) + _require_bool(self.configuration_readable, "counter configuration_readable") + if not isinstance(self.query_effect, SourceQueryEffect): + raise ValueError("counter query_effect has an invalid type") + + +@dataclass(frozen=True, slots=True) +class SourceClockSyncCapabilityProfile: + reference_clock_modes: tuple[SourceReferenceClockMode, ...] + sync_readable: bool + cascade_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple( + self.reference_clock_modes, + SourceReferenceClockMode, + "clock reference_clock_modes", + ) + _require_bool(self.sync_readable, "clock sync_readable") + _require_bool(self.cascade_readable, "clock cascade_readable") + + +@dataclass(frozen=True, slots=True) +class SourceCrossChannelCapabilityProfile: + relation_kinds: tuple[SourceFeature, ...] + supported_channel_sets: tuple[tuple[int, ...], ...] + relation_graph_readable: bool + shared_power_constraint_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple(self.relation_kinds, SourceFeature, "cross-channel relation_kinds") + allowed = { + SourceFeature.COMBINE, + SourceFeature.TRACKING, + SourceFeature.COUPLING, + SourceFeature.COPY, + SourceFeature.PHASE_RELATION, + SourceFeature.SHARED_POWER, + } + if not set(self.relation_kinds) <= allowed: + raise ValueError("cross-channel relation_kinds contain a channel-local feature") + if not isinstance(self.supported_channel_sets, tuple): + raise ValueError("cross-channel supported_channel_sets must be a tuple") + for channel_set in self.supported_channel_sets: + _require_positive_channels( + channel_set, + "cross-channel supported channel set", + ) + if len(channel_set) < 2: + raise ValueError("cross-channel channel sets require at least two channels") + if len(set(self.supported_channel_sets)) != len(self.supported_channel_sets): + raise ValueError("cross-channel supported_channel_sets must be unique") + _require_bool(self.relation_graph_readable, "cross-channel relation_graph_readable") + _require_bool( + self.shared_power_constraint_readable, + "cross-channel shared_power_constraint_readable", + ) + + +SourceFeatureProfile: TypeAlias = ( + SourceBasicCapabilityProfile + | SourceOutputCapabilityProfile + | SourceHarmonicCapabilityProfile + | SourceModulationCapabilityProfile + | SourceSweepCapabilityProfile + | SourceBurstCapabilityProfile + | SourcePulseCapabilityProfile + | SourceArbitraryCapabilityProfile + | SourceCounterCapabilityProfile + | SourceClockSyncCapabilityProfile + | SourceCrossChannelCapabilityProfile +) + + +class SourceFacetScope(StrEnum): + CHANNEL = "channel" + CHANNEL_SET = "channel_set" + INSTRUMENT = "instrument" + INPUT = "input" + + +@dataclass(frozen=True, slots=True) +class SourceScopeRef: + scope: SourceFacetScope + channel: int | None = None + channels: tuple[int, ...] = () + input_id: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.scope, SourceFacetScope): + raise ValueError("source scope has an invalid type") + if self.scope is SourceFacetScope.CHANNEL: + _require_int(self.channel, "source scope channel", minimum=1) + if self.channels or self.input_id is not None: + raise ValueError("CHANNEL scope can only carry channel") + elif self.scope is SourceFacetScope.CHANNEL_SET: + _require_positive_channels(self.channels, "source scope channels") + if len(self.channels) < 2 or self.channel is not None or self.input_id is not None: + raise ValueError("CHANNEL_SET scope requires only two or more channels") + elif self.scope is SourceFacetScope.INPUT: + _require_token(self.input_id, "source scope input_id") + if self.channel is not None or self.channels: + raise ValueError("INPUT scope can only carry input_id") + elif self.channel is not None or self.channels or self.input_id is not None: + raise ValueError("INSTRUMENT scope cannot carry channel or input fields") + + +@dataclass(frozen=True, slots=True) +class SourceTopologyContract: + channels: tuple[int, ...] + input_ids: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_positive_channels(self.channels, "source topology channels") + _require_token_tuple(self.input_ids, "source topology input_ids") + + +class SourceFieldId(StrEnum): + IDENTITY = "source.identity" + BASIC = "source.channel.basic" + OUTPUT = "source.channel.output" + DISPLAY_LOAD = "source.channel.display_load" + HARMONICS = "source.channel.harmonics" + MODULATION = "source.channel.modulation" + SWEEP = "source.channel.sweep" + BURST = "source.channel.burst" + PULSE = "source.channel.pulse" + ARBITRARY_SELECTION = "source.channel.arbitrary_selection" + ARBITRARY_STORAGE = "source.channel.arbitrary_storage" + ARM_STATE = "source.channel.arm_state" + TRIGGER_STATE = "source.channel.trigger_state" + COMBINE = "source.cross_channel.combine" + COUPLING = "source.cross_channel.coupling" + TRACKING = "source.cross_channel.tracking" + COPY = "source.cross_channel.copy" + PHASE_RELATION = "source.cross_channel.phase_relation" + RELATION_GRAPH = "source.cross_channel.relation_graph" + REFERENCE_CLOCK = "source.instrument.reference_clock" + SYNC = "source.instrument.sync" + CASCADE = "source.instrument.cascade" + SHARED_POWER = "source.instrument.shared_power" + COUNTER = "source.input.counter" + + +_FIELD_SCOPES: dict[SourceFieldId, frozenset[SourceFacetScope]] = { + SourceFieldId.IDENTITY: frozenset({SourceFacetScope.INSTRUMENT}), + SourceFieldId.BASIC: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.OUTPUT: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.DISPLAY_LOAD: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.HARMONICS: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.MODULATION: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.SWEEP: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.BURST: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.PULSE: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.ARBITRARY_SELECTION: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.ARBITRARY_STORAGE: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.ARM_STATE: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.TRIGGER_STATE: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.COMBINE: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFieldId.COUPLING: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFieldId.TRACKING: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFieldId.COPY: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFieldId.PHASE_RELATION: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFieldId.RELATION_GRAPH: frozenset({SourceFacetScope.INSTRUMENT}), + SourceFieldId.REFERENCE_CLOCK: frozenset({SourceFacetScope.INSTRUMENT}), + SourceFieldId.SYNC: frozenset( + {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} + ), + SourceFieldId.CASCADE: frozenset( + {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} + ), + SourceFieldId.SHARED_POWER: frozenset({SourceFacetScope.INSTRUMENT}), + SourceFieldId.COUNTER: frozenset({SourceFacetScope.INPUT}), +} + + +@dataclass(frozen=True, slots=True) +class SourceFieldRef: + field: SourceFieldId + target: SourceScopeRef + + def __post_init__(self) -> None: + if not isinstance(self.field, SourceFieldId): + raise ValueError("source field has an invalid type") + if not isinstance(self.target, SourceScopeRef): + raise ValueError("source field target has an invalid type") + if self.target.scope not in _FIELD_SCOPES[self.field]: + raise ValueError( + f"source field {self.field.value!r} cannot use scope {self.target.scope.value!r}" + ) + + +class SourceEnergyEffect(StrEnum): + NONE = "none" + DECREASE_ONLY = "decrease_only" + POTENTIAL_WHILE_OFF = "potential_while_off" + MAY_INCREASE = "may_increase" + EMIT = "emit" + UNKNOWN = "unknown" + + +class SourceStorageEffect(StrEnum): + NONE = "none" + READ = "read" + CREATE = "create" + REPLACE = "replace" + DELETE = "delete" + UNKNOWN = "unknown" + + +class SourceV1WriteRouteId(StrEnum): + SET_FREQUENCY = "source_service.set_frequency" + SET_FUNCTION = "source_service.set_function" + SET_AMPLITUDE_VPP = "source_service.set_amplitude_vpp" + SET_SQUARE_DUTY_CYCLE = "source_service.set_square_duty_cycle" + SET_OUTPUT = "source_service.set_output" + CONFIGURE_COUPLING = "source_service.configure_coupling" + CONFIGURE_HARMONICS = "source_service.configure_harmonics" + CONFIGURE_AM = "source_service.configure_am_modulation" + CONFIGURE_FM = "source_service.configure_fm_modulation" + CONFIGURE_PM = "source_service.configure_pm_modulation" + CONFIGURE_PWM = "source_service.configure_pwm_modulation" + CONFIGURE_PULSE = "source_service.configure_pulse" + CONFIGURE_BURST = "source_service.configure_burst" + TRIGGER_BURST = "source_service.trigger_burst" + CONFIGURE_SWEEP = "source_service.configure_sweep" + TRIGGER_SWEEP = "source_service.trigger_sweep" + UPLOAD_ARBITRARY = "source_service.upload_arbitrary_waveform" + RESTORE = "source_service.restore_restorable_state" + + +def _source_scope_ref_sort_key(value: SourceScopeRef) -> tuple[object, ...]: + return ( + value.scope.value, + -1 if value.channel is None else value.channel, + value.channels, + "" if value.input_id is None else value.input_id, + ) + + +def _source_field_ref_sort_key(value: SourceFieldRef) -> tuple[object, ...]: + return (value.field.value, *_source_scope_ref_sort_key(value.target)) + + +def _require_source_field_ref_tuple( + values: object, + label: str, + *, + allow_empty: bool = False, + sorted_values: bool = True, +) -> tuple[SourceFieldRef, ...]: + if not isinstance(values, tuple) or any(not isinstance(item, SourceFieldRef) for item in values): + raise ValueError(f"{label} must be a tuple of SourceFieldRef values") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + keys = tuple(_source_field_ref_sort_key(item) for item in values) + if len(set(keys)) != len(keys): + raise ValueError(f"{label} must be unique") + if sorted_values and tuple(sorted(keys)) != keys: + raise ValueError(f"{label} must be sorted") + return values + + +def _require_source_channel_scope_tuple( + values: object, + label: str, + *, + allow_empty: bool = False, +) -> tuple[SourceScopeRef, ...]: + if not isinstance(values, tuple) or any( + not isinstance(item, SourceScopeRef) or item.scope is not SourceFacetScope.CHANNEL + for item in values + ): + raise ValueError(f"{label} must be a tuple of channel SourceScopeRef values") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + keys = tuple(_source_scope_ref_sort_key(item) for item in values) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError(f"{label} must be sorted and unique") + return values + + +@dataclass(frozen=True, slots=True) +class SourceOperationContract: + """Static core contract for one future Source V2 mutation operation. + + Constructing the model never registers a capability. Capability registry, + driver Protocol, request/result model and Service entry must still be added + together in the feature-specific implementation milestone. + """ + + operation: str + capability: str + feature: SourceFeature + direction: SourceFeatureDirection + energy_effect: SourceEnergyEffect + storage_effect: SourceStorageEffect + required_fields: tuple[SourceFieldId, ...] + changed_fields: tuple[SourceFieldId, ...] + postcondition_fields: tuple[SourceFieldId, ...] + cleanup_verification_fields: tuple[SourceFieldId, ...] + v1_equivalent_routes: tuple[SourceV1WriteRouteId, ...] + v1_overlapping_routes: tuple[SourceV1WriteRouteId, ...] + operation_timeout_ms: int + main_max_steps: int + recovery_max_steps: int + verification_max_steps: int + + def __post_init__(self) -> None: + _require_token(self.operation, "source operation contract operation") + _require_token(self.capability, "source operation contract capability") + if not self.operation.startswith("source.") or not self.capability.startswith("source."): + raise ValueError("source operation contract operation and capability must use source.* IDs") + if not isinstance(self.feature, SourceFeature): + raise ValueError("source operation contract feature has an invalid type") + if not isinstance(self.direction, SourceFeatureDirection): + raise ValueError("source operation contract direction has an invalid type") + if self.direction is SourceFeatureDirection.READ: + raise ValueError("source operation contracts cannot use the read direction") + if not isinstance(self.energy_effect, SourceEnergyEffect): + raise ValueError("source operation contract energy_effect has an invalid type") + if not isinstance(self.storage_effect, SourceStorageEffect): + raise ValueError("source operation contract storage_effect has an invalid type") + _require_enum_tuple(self.required_fields, SourceFieldId, "source operation required_fields") + _require_enum_tuple( + self.changed_fields, + SourceFieldId, + "source operation changed_fields", + allow_empty=self.energy_effect is SourceEnergyEffect.NONE + and self.storage_effect in {SourceStorageEffect.NONE, SourceStorageEffect.READ}, + ) + _require_enum_tuple( + self.postcondition_fields, + SourceFieldId, + "source operation postcondition_fields", + allow_empty=True, + ) + _require_enum_tuple( + self.cleanup_verification_fields, + SourceFieldId, + "source operation cleanup_verification_fields", + allow_empty=True, + ) + _require_enum_tuple( + self.v1_equivalent_routes, + SourceV1WriteRouteId, + "source operation v1_equivalent_routes", + allow_empty=True, + ) + _require_enum_tuple( + self.v1_overlapping_routes, + SourceV1WriteRouteId, + "source operation v1_overlapping_routes", + allow_empty=True, + ) + if set(self.v1_equivalent_routes) & set(self.v1_overlapping_routes): + raise ValueError("source operation V1 route groups must not overlap") + for label, value in ( + ("source operation operation_timeout_ms", self.operation_timeout_ms), + ("source operation main_max_steps", self.main_max_steps), + ("source operation recovery_max_steps", self.recovery_max_steps), + ("source operation verification_max_steps", self.verification_max_steps), + ): + _require_int(value, label, minimum=1) + + +@dataclass(frozen=True, slots=True) +class SourceAffectedClosure: + """A core-created, context-bound field closure for one Source operation.""" + + operation: str + context_id: str + session_epoch: str + baseline_snapshot_digest: str + fields: tuple[SourceFieldRef, ...] + required_off_outputs: tuple[SourceScopeRef, ...] + emergency_off_outputs: tuple[SourceScopeRef, ...] + restore_order: tuple[SourceFieldRef, ...] + non_restorable_fields: tuple[SourceFieldRef, ...] + closure_digest: str + + def __post_init__(self) -> None: + _require_token(self.operation, "source affected closure operation") + _require_token(self.context_id, "source affected closure context_id") + _require_token(self.session_epoch, "source affected closure session_epoch") + for label, value in ( + ("source affected closure baseline_snapshot_digest", self.baseline_snapshot_digest), + ("source affected closure closure_digest", self.closure_digest), + ): + if not isinstance(value, str) or _SHA256.fullmatch(value) is None: + raise ValueError(f"{label} must be sha256:<64 lowercase hex>") + fields = _require_source_field_ref_tuple(self.fields, "source affected closure fields") + required_off = _require_source_channel_scope_tuple( + self.required_off_outputs, + "source affected closure required_off_outputs", + allow_empty=True, + ) + emergency_off = _require_source_channel_scope_tuple( + self.emergency_off_outputs, + "source affected closure emergency_off_outputs", + allow_empty=True, + ) + restore_order = _require_source_field_ref_tuple( + self.restore_order, + "source affected closure restore_order", + allow_empty=True, + sorted_values=False, + ) + non_restorable = _require_source_field_ref_tuple( + self.non_restorable_fields, + "source affected closure non_restorable_fields", + allow_empty=True, + ) + if not set(required_off) <= set(emergency_off): + raise ValueError("source affected closure emergency OFF set must cover required OFF outputs") + if not set(restore_order) <= set(fields): + raise ValueError("source affected closure restore_order exceeds fields") + if not set(non_restorable) <= set(fields): + raise ValueError("source affected closure non_restorable_fields exceeds fields") + if set(restore_order) & set(non_restorable): + raise ValueError("source affected closure restore and non-restorable fields overlap") + expected = source_v2_digest( + { + "schema": "wavebench.source.affected-closure.v1", + "operation": self.operation, + "context_id": self.context_id, + "session_epoch": self.session_epoch, + "baseline_snapshot_digest": self.baseline_snapshot_digest, + "fields": fields, + "required_off_outputs": required_off, + "emergency_off_outputs": emergency_off, + "restore_order": restore_order, + "non_restorable_fields": non_restorable, + } + ) + if self.closure_digest != expected: + raise ValueError("source affected closure digest does not match its fields") + + +class SourceSignalPathKind(StrEnum): + INTERNAL_WAVEFORM = "internal_waveform" + OUTPUT_PORT = "output_port" + CONFIG_TRACKING = "config_tracking" + SHARED_RESOURCE = "shared_resource" + + +@dataclass(frozen=True, slots=True) +class SourceRelationEdge: + relation_id: str + feature: SourceFeature + sources: tuple[int, ...] + targets: tuple[int, ...] + signal_path: SourceSignalPathKind + affected_fields: tuple[SourceFieldId, ...] + implicit_changed_fields: tuple[SourceFieldId, ...] = () + + def __post_init__(self) -> None: + _require_token(self.relation_id, "source relation_id") + if self.feature not in { + SourceFeature.COMBINE, + SourceFeature.TRACKING, + SourceFeature.COUPLING, + SourceFeature.COPY, + SourceFeature.PHASE_RELATION, + SourceFeature.SHARED_POWER, + }: + raise ValueError("source relation feature is not cross-channel") + _require_positive_channels(self.sources, "source relation sources") + _require_positive_channels(self.targets, "source relation targets") + if set(self.sources) & set(self.targets): + raise ValueError("source relation sources and targets must be disjoint") + if not isinstance(self.signal_path, SourceSignalPathKind): + raise ValueError("source relation signal_path has an invalid type") + _require_enum_tuple( + self.affected_fields, + SourceFieldId, + "source relation affected_fields", + ) + _require_enum_tuple( + self.implicit_changed_fields, + SourceFieldId, + "source relation implicit_changed_fields", + allow_empty=True, + ) + + +@dataclass(frozen=True, slots=True) +class SourceRelationGraph: + channels: tuple[int, ...] + edges: tuple[SourceRelationEdge, ...] + + def __post_init__(self) -> None: + _require_positive_channels(self.channels, "source relation graph channels") + if not isinstance(self.edges, tuple) or any( + not isinstance(edge, SourceRelationEdge) for edge in self.edges + ): + raise ValueError("source relation graph edges have an invalid type") + relation_ids = tuple(edge.relation_id for edge in self.edges) + if len(set(relation_ids)) != len(relation_ids) or tuple(sorted(relation_ids)) != relation_ids: + raise ValueError("source relation graph edges must be sorted by relation_id and unique") + participants = set(self.channels) + if any(not set(edge.sources + edge.targets) <= participants for edge in self.edges): + raise ValueError("source relation graph edge references an unknown channel") + adjacency: dict[int, set[int]] = {channel: set() for channel in self.channels} + for edge in self.edges: + for source in edge.sources: + adjacency[source].update(edge.targets) + + def visit(channel: int, visiting: set[int], visited: set[int]) -> None: + if channel in visiting: + raise ValueError("source relation graph cannot contain directed cycles") + if channel in visited: + return + visiting.add(channel) + for target in adjacency[channel]: + visit(target, visiting, visited) + visiting.remove(channel) + visited.add(channel) + + visited: set[int] = set() + for channel in self.channels: + visit(channel, set(), visited) + + +class SourceAnchorField(StrEnum): + WAVEFORM_KIND = "waveform_kind" + FREQUENCY_MODE = "frequency_mode" + OUTPUT_ENABLED = "output_enabled" + HARMONICS_ENABLED = "harmonics_enabled" + MODULATION_ENABLED = "modulation_enabled" + SWEEP_ENABLED = "sweep_enabled" + BURST_ENABLED = "burst_enabled" + ARBITRARY_PLAYBACK_MODE = "arbitrary_playback_mode" + COMBINE_ENABLED = "combine_enabled" + COUPLING_ENABLED = "coupling_enabled" + TRACKING_ENABLED = "tracking_enabled" + + +SourceAnchorValue: TypeAlias = ( + bool | SourceWaveformKind | SourceFrequencyMode | SourceArbitraryPlaybackMode +) + + +@dataclass(frozen=True, slots=True) +class SourceActivationPredicate: + field: SourceAnchorField + equals: SourceAnchorValue + + def __post_init__(self) -> None: + if not isinstance(self.field, SourceAnchorField): + raise ValueError("source activation field has an invalid type") + if isinstance(self.equals, bool): + return + if not isinstance( + self.equals, + (SourceWaveformKind, SourceFrequencyMode, SourceArbitraryPlaybackMode), + ): + raise ValueError("source activation value has an invalid type") + + +@dataclass(frozen=True, slots=True) +class SourceActivationRule: + predicates: tuple[SourceActivationPredicate, ...] + + def __post_init__(self) -> None: + if not isinstance(self.predicates, tuple) or not self.predicates: + raise ValueError("source activation rule requires predicates") + if any(not isinstance(item, SourceActivationPredicate) for item in self.predicates): + raise ValueError("source activation predicates have an invalid type") + keys = tuple(item.field.value for item in self.predicates) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError("source activation predicates must be sorted by field and unique") + + +@dataclass(frozen=True, slots=True) +class ClosedFloatInterval: + minimum: float + maximum: float + + def __post_init__(self) -> None: + _require_finite(self.minimum, "interval minimum") + _require_finite(self.maximum, "interval maximum") + if self.minimum > self.maximum: + raise ValueError("interval minimum must not exceed maximum") + + +@dataclass(frozen=True, slots=True) +class SourceConstraintApplicability: + models: tuple[str, ...] = () + firmware_ids: tuple[str, ...] = () + option_ids: tuple[str, ...] = () + waveform_kinds: tuple[SourceWaveformKind, ...] = () + frequency_hz: ClosedFloatInterval | None = None + amplitude_vpp: ClosedFloatInterval | None = None + offset_v: ClosedFloatInterval | None = None + + def __post_init__(self) -> None: + _require_text_tuple(self.models, "applicability models") + _require_text_tuple(self.firmware_ids, "applicability firmware_ids") + _require_token_tuple(self.option_ids, "applicability option_ids") + _require_enum_tuple( + self.waveform_kinds, + SourceWaveformKind, + "applicability waveform_kinds", + allow_empty=True, + ) + for name, value in ( + ("frequency_hz", self.frequency_hz), + ("amplitude_vpp", self.amplitude_vpp), + ("offset_v", self.offset_v), + ): + if value is not None and not isinstance(value, ClosedFloatInterval): + raise ValueError(f"applicability {name} has an invalid type") + + +@dataclass(frozen=True, slots=True) +class SourceRuntimeIdentity: + manufacturer: str + model: str + firmware_id: str + option_ids: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_text(self.manufacturer, "source runtime manufacturer") + _require_text(self.model, "source runtime model") + _require_text(self.firmware_id, "source runtime firmware_id") + _require_token_tuple(self.option_ids, "source runtime option_ids") + + +_FEATURE_PROFILE_TYPES: dict[SourceFeature, type[object]] = { + SourceFeature.BASIC: SourceBasicCapabilityProfile, + SourceFeature.OUTPUT: SourceOutputCapabilityProfile, + SourceFeature.HARMONICS: SourceHarmonicCapabilityProfile, + SourceFeature.MODULATION: SourceModulationCapabilityProfile, + SourceFeature.SWEEP: SourceSweepCapabilityProfile, + SourceFeature.BURST: SourceBurstCapabilityProfile, + SourceFeature.PULSE: SourcePulseCapabilityProfile, + SourceFeature.ARBITRARY: SourceArbitraryCapabilityProfile, + SourceFeature.COUNTER: SourceCounterCapabilityProfile, + SourceFeature.REFERENCE_CLOCK: SourceClockSyncCapabilityProfile, + SourceFeature.SYNC: SourceClockSyncCapabilityProfile, + SourceFeature.CASCADE: SourceClockSyncCapabilityProfile, + SourceFeature.COMBINE: SourceCrossChannelCapabilityProfile, + SourceFeature.TRACKING: SourceCrossChannelCapabilityProfile, + SourceFeature.COUPLING: SourceCrossChannelCapabilityProfile, + SourceFeature.COPY: SourceCrossChannelCapabilityProfile, + SourceFeature.PHASE_RELATION: SourceCrossChannelCapabilityProfile, + SourceFeature.SHARED_POWER: SourceCrossChannelCapabilityProfile, +} + +_FEATURE_SCOPES: dict[SourceFeature, frozenset[SourceFacetScope]] = { + SourceFeature.BASIC: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.OUTPUT: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.HARMONICS: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.MODULATION: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.SWEEP: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.BURST: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.PULSE: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.ARBITRARY: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.COUNTER: frozenset({SourceFacetScope.INPUT}), + SourceFeature.REFERENCE_CLOCK: frozenset({SourceFacetScope.INSTRUMENT}), + SourceFeature.SYNC: frozenset( + {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} + ), + SourceFeature.CASCADE: frozenset( + {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} + ), + SourceFeature.COMBINE: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFeature.TRACKING: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFeature.COUPLING: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFeature.COPY: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFeature.PHASE_RELATION: frozenset({SourceFacetScope.CHANNEL_SET}), + SourceFeature.SHARED_POWER: frozenset({SourceFacetScope.INSTRUMENT}), +} + + +@dataclass(frozen=True, slots=True) +class SourceFeatureCapability: + feature: SourceFeature + support: SupportState + directions: tuple[SourceFeatureDirection, ...] + scope: SourceFacetScope + channels: tuple[int, ...] + applicability: SourceConstraintApplicability + profile: SourceFeatureProfile + evidence_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.feature, SourceFeature): + raise ValueError("source feature has an invalid type") + if not isinstance(self.support, SupportState): + raise ValueError("source support state has an invalid type") + _require_enum_tuple( + self.directions, + SourceFeatureDirection, + "source directions", + allow_empty=self.support is not SupportState.SUPPORTED, + ) + if not isinstance(self.scope, SourceFacetScope): + raise ValueError("source feature scope has an invalid type") + if self.scope not in _FEATURE_SCOPES[self.feature]: + raise ValueError( + f"source feature {self.feature.value!r} cannot use scope {self.scope.value!r}" + ) + _require_positive_channels( + self.channels, + "source feature channels", + allow_empty=self.scope in {SourceFacetScope.INSTRUMENT, SourceFacetScope.INPUT}, + ) + if self.scope is SourceFacetScope.CHANNEL and not self.channels: + raise ValueError("channel-scoped source features require channels") + if self.scope is SourceFacetScope.CHANNEL_SET and len(self.channels) < 2: + raise ValueError("channel-set source features require two or more channels") + if self.scope in {SourceFacetScope.INSTRUMENT, SourceFacetScope.INPUT} and self.channels: + raise ValueError("instrument/input source features cannot carry channels") + if not isinstance(self.applicability, SourceConstraintApplicability): + raise ValueError("source feature applicability has an invalid type") + expected = _FEATURE_PROFILE_TYPES[self.feature] + if not isinstance(self.profile, expected): + raise ValueError( + f"source feature {self.feature.value!r} requires {expected.__name__}" + ) + _require_evidence_ref_tuple(self.evidence_refs, "source feature evidence_refs") + + +class BudgetProofStrength(StrEnum): + HARD_CONSERVATIVE = "hard_conservative" + STATISTICAL_ONLY = "statistical_only" + MEASURED_ONLY = "measured_only" + INCOMPLETE = "incomplete" + + +class SourceSafetyConstraintKind(StrEnum): + VOLTAGE_REFERENCE = "voltage_reference" + SOURCE_RESISTANCE = "source_resistance" + FREQUENCY_DERATING = "frequency_derating" + MODULATION_ENVELOPE = "modulation_envelope" + ARBITRARY_OVERSHOOT = "arbitrary_overshoot" + NOISE_PEAK = "noise_peak" + SHARED_POWER = "shared_power" + + +class VoltageReferenceBasis(StrEnum): + OPEN_CIRCUIT = "open_circuit" + DELIVERED_INTO_DISPLAY_LOAD = "delivered_into_display_load" + + +class TerminationKind(StrEnum): + HIGH_IMPEDANCE = "high_impedance" + RESISTIVE = "resistive" + + +@dataclass(frozen=True, slots=True) +class ResistanceBounds: + minimum_ohm: float + maximum_ohm: float + + def __post_init__(self) -> None: + _require_finite(self.minimum_ohm, "resistance minimum_ohm", minimum=0.0) + _require_finite(self.maximum_ohm, "resistance maximum_ohm", minimum=self.minimum_ohm) + if self.minimum_ohm == 0: + raise ValueError("resistance minimum_ohm must be positive") + + +@dataclass(frozen=True, slots=True) +class TerminationSpec: + kind: TerminationKind + resistance_bounds: ResistanceBounds | None = None + + def __post_init__(self) -> None: + if not isinstance(self.kind, TerminationKind): + raise ValueError("termination kind has an invalid type") + if self.resistance_bounds is not None and not isinstance( + self.resistance_bounds, + ResistanceBounds, + ): + raise ValueError("termination resistance_bounds has an invalid type") + if self.kind is TerminationKind.RESISTIVE and self.resistance_bounds is None: + raise ValueError("resistive termination requires resistance_bounds") + + +class TerminationEvidenceSource(StrEnum): + CONFIG = "config" + RUN_INTENT = "run_intent" + MANUAL_CONFIRMATION = "manual_confirmation" + EXTERNAL_MEASUREMENT = "external_measurement" + + +class TerminationEvidenceLifetime(StrEnum): + OPERATION = "operation" + RUN = "run" + CONFIG_DIGEST = "config_digest" + + +class BudgetEvidenceSource(StrEnum): + INSTRUMENT_READBACK = "instrument_readback" + DEVICE_HARD_LIMIT = "device_hard_limit" + EXPLICIT_TERMINATION = "explicit_termination" + EXTERNAL_MEASUREMENT = "external_measurement" + + +class SourceBudgetBlockerCode(StrEnum): + SNAPSHOT_NOT_CONSISTENT = "snapshot_not_consistent" + DESCRIPTOR_MISMATCH = "descriptor_mismatch" + TARGET_CHANNEL_UNKNOWN = "target_channel_unknown" + BASIC_STATE_UNAVAILABLE = "basic_state_unavailable" + AMPLITUDE_UNIT_UNSUPPORTED = "amplitude_unit_unsupported" + WAVEFORM_UNSUPPORTED = "waveform_unsupported" + DC_LEVEL_UNAVAILABLE = "dc_level_unavailable" + FREQUENCY_MODE_UNSUPPORTED = "frequency_mode_unsupported" + OUTPUT_POLARITY_UNAVAILABLE = "output_polarity_unavailable" + VOLTAGE_REFERENCE_MISSING = "voltage_reference_missing" + SOURCE_RESISTANCE_MISSING = "source_resistance_missing" + DISPLAY_LOAD_UNAVAILABLE = "display_load_unavailable" + DISPLAY_LOAD_UNSUPPORTED = "display_load_unsupported" + ACTUAL_TERMINATION_MISSING = "actual_termination_missing" + TERMINATION_EVIDENCE_INVALID = "termination_evidence_invalid" + TERMINATION_NOT_RESISTIVE = "termination_not_resistive" + HARMONIC_STATE_UNAVAILABLE = "harmonic_state_unavailable" + HARMONIC_COMPLETENESS_INSUFFICIENT = "harmonic_completeness_insufficient" + HARMONIC_AMPLITUDE_UNSUPPORTED = "harmonic_amplitude_unsupported" + MODULATION_CONSTRAINT_MISSING = "modulation_constraint_missing" + ARBITRARY_OVERSHOOT_MISSING = "arbitrary_overshoot_missing" + NOISE_PEAK_MISSING = "noise_peak_missing" + SWEEP_DERATING_MISSING = "sweep_derating_missing" + ACTIVE_CHANNEL_UNKNOWN = "active_channel_unknown" + COMBINE_STATE_UNAVAILABLE = "combine_state_unavailable" + COMBINE_PATH_UNSUPPORTED = "combine_path_unsupported" + SHARED_POWER_STATE_UNAVAILABLE = "shared_power_state_unavailable" + SHARED_POWER_CONSTRAINT_MISSING = "shared_power_constraint_missing" + SHARED_POWER_LIMIT_EXCEEDED = "shared_power_limit_exceeded" + CONSTRAINT_NOT_HARD = "constraint_not_hard" + VPP_LIMIT_EXCEEDED = "vpp_limit_exceeded" + PORT_VOLTAGE_LIMIT_EXCEEDED = "port_voltage_limit_exceeded" + + +@dataclass(frozen=True, slots=True) +class PortVoltageBounds: + minimum_v_lower: float + maximum_v_upper: float + vpp_upper_v: float + absolute_peak_upper_v: float + rms_upper_v: float | None + + def __post_init__(self) -> None: + _require_finite(self.minimum_v_lower, "port voltage minimum_v_lower") + _require_finite(self.maximum_v_upper, "port voltage maximum_v_upper") + if self.minimum_v_lower > self.maximum_v_upper: + raise ValueError("port voltage minimum_v_lower must not exceed maximum_v_upper") + _require_finite(self.vpp_upper_v, "port voltage vpp_upper_v", minimum=0.0) + _require_finite( + self.absolute_peak_upper_v, + "port voltage absolute_peak_upper_v", + minimum=0.0, + ) + span = self.maximum_v_upper - self.minimum_v_lower + if self.vpp_upper_v < span: + raise ValueError("port voltage vpp_upper_v must cover the voltage span") + if self.absolute_peak_upper_v < max( + abs(self.minimum_v_lower), + abs(self.maximum_v_upper), + ): + raise ValueError("port voltage absolute_peak_upper_v must cover both bounds") + if self.rms_upper_v is not None: + _require_finite(self.rms_upper_v, "port voltage rms_upper_v", minimum=0.0) + if self.rms_upper_v > self.absolute_peak_upper_v: + raise ValueError("port voltage rms_upper_v cannot exceed absolute_peak_upper_v") + + +@dataclass(frozen=True, slots=True) +class SafetyContributor: + contributor_id: str + feature: SourceFeature + channels: tuple[int, ...] + minimum_v: float + maximum_v: float + constraint_ids: tuple[str, ...] + proof_strength: BudgetProofStrength + evidence_sources: tuple[BudgetEvidenceSource, ...] + + def __post_init__(self) -> None: + _require_token(self.contributor_id, "safety contributor_id") + if not isinstance(self.feature, SourceFeature): + raise ValueError("safety contributor feature has an invalid type") + _require_positive_channels(self.channels, "safety contributor channels") + _require_finite(self.minimum_v, "safety contributor minimum_v") + _require_finite(self.maximum_v, "safety contributor maximum_v") + if self.minimum_v > self.maximum_v: + raise ValueError("safety contributor minimum_v must not exceed maximum_v") + _require_token_tuple(self.constraint_ids, "safety contributor constraint_ids") + if not isinstance(self.proof_strength, BudgetProofStrength): + raise ValueError("safety contributor proof_strength has an invalid type") + _require_enum_tuple( + self.evidence_sources, + BudgetEvidenceSource, + "safety contributor evidence_sources", + allow_empty=True, + ) + + +@dataclass(frozen=True, slots=True) +class SourceSharedPowerBudget: + """Auditable shared-power envelope used by a composite output budget. + + ``observed_active_power_upper_w`` comes from the current snapshot. The + projected bound independently includes the requested target output and + every explicitly active direct output, so an ON preflight cannot rely on a + current-reading value that excludes the target while it is still OFF. + """ + + participants: tuple[int, ...] + observed_active_power_upper_w: float + projected_power_upper_w: float + effective_hard_limit_w: float + constraint_ids: tuple[str, ...] + evidence_sources: tuple[BudgetEvidenceSource, ...] + + def __post_init__(self) -> None: + _require_positive_channels(self.participants, "shared power budget participants") + for label, value in ( + ("shared power budget observed_active_power_upper_w", self.observed_active_power_upper_w), + ("shared power budget projected_power_upper_w", self.projected_power_upper_w), + ("shared power budget effective_hard_limit_w", self.effective_hard_limit_w), + ): + _require_finite(value, label, minimum=0.0) + _require_token_tuple(self.constraint_ids, "shared power budget constraint_ids") + _require_enum_tuple( + self.evidence_sources, + BudgetEvidenceSource, + "shared power budget evidence_sources", + ) + + +@dataclass(frozen=True, slots=True) +class CompositeOutputBudget: + bounds: Observed[PortVoltageBounds] + voltage_reference_basis: Observed[VoltageReferenceBasis] + display_load: Observed[TerminationSpec] + output_source_resistance: Observed[ResistanceBounds] + actual_termination: Observed[TerminationSpec] + shared_power: Observed[SourceSharedPowerBudget] + proof_strength: BudgetProofStrength + evidence_sources: tuple[BudgetEvidenceSource, ...] + contributors: tuple[SafetyContributor, ...] + blockers: tuple[SourceBudgetBlockerCode, ...] + + def __post_init__(self) -> None: + for label, value in ( + ("budget bounds", self.bounds), + ("budget voltage_reference_basis", self.voltage_reference_basis), + ("budget display_load", self.display_load), + ("budget output_source_resistance", self.output_source_resistance), + ("budget actual_termination", self.actual_termination), + ("budget shared_power", self.shared_power), + ): + _require_observed(value, label) + if self.bounds.availability is Availability.VALUE and not isinstance( + self.bounds.value, + PortVoltageBounds, + ): + raise ValueError("budget bounds value has an invalid type") + if self.shared_power.availability is Availability.VALUE and not isinstance( + self.shared_power.value, + SourceSharedPowerBudget, + ): + raise ValueError("budget shared_power value has an invalid type") + if not isinstance(self.proof_strength, BudgetProofStrength): + raise ValueError("budget proof_strength has an invalid type") + _require_enum_tuple( + self.evidence_sources, + BudgetEvidenceSource, + "budget evidence_sources", + allow_empty=True, + ) + if not isinstance(self.contributors, tuple) or any( + not isinstance(item, SafetyContributor) for item in self.contributors + ): + raise ValueError("budget contributors have an invalid type") + contributor_ids = tuple(item.contributor_id for item in self.contributors) + if len(set(contributor_ids)) != len(contributor_ids) or tuple( + sorted(contributor_ids) + ) != contributor_ids: + raise ValueError("budget contributors must be sorted by contributor_id and unique") + _require_enum_tuple( + self.blockers, + SourceBudgetBlockerCode, + "budget blockers", + allow_empty=True, + ) + if self.proof_strength is BudgetProofStrength.HARD_CONSERVATIVE and ( + self.blockers or self.bounds.availability is not Availability.VALUE + ): + raise ValueError("hard conservative budgets require bounds and no blockers") + + @property + def can_authorize_energy(self) -> bool: + return ( + self.proof_strength is BudgetProofStrength.HARD_CONSERVATIVE + and not self.blockers + and self.bounds.availability is Availability.VALUE + ) + + +_TERMINATION_EVIDENCE_LIFETIMES: dict[ + TerminationEvidenceSource, + frozenset[TerminationEvidenceLifetime], +] = { + TerminationEvidenceSource.CONFIG: frozenset({TerminationEvidenceLifetime.CONFIG_DIGEST}), + TerminationEvidenceSource.RUN_INTENT: frozenset({TerminationEvidenceLifetime.RUN}), + TerminationEvidenceSource.MANUAL_CONFIRMATION: frozenset( + {TerminationEvidenceLifetime.OPERATION} + ), + TerminationEvidenceSource.EXTERNAL_MEASUREMENT: frozenset( + { + TerminationEvidenceLifetime.OPERATION, + TerminationEvidenceLifetime.RUN, + TerminationEvidenceLifetime.CONFIG_DIGEST, + } + ), +} + + +def _parse_utc_timestamp(value: object, label: str) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise ValueError(f"{label} must be an RFC 3339 UTC timestamp") + try: + return datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ValueError(f"{label} must be an RFC 3339 UTC timestamp") from exc + + +@dataclass(frozen=True, slots=True) +class SourceTerminationEvidence: + target: SourceScopeRef + termination: TerminationSpec + source: TerminationEvidenceSource + lifetime: TerminationEvidenceLifetime + resource_fingerprint: str + binding_digest: str + observed_at_utc: str + expires_at_utc: str | None + evidence_ref: str + + def __post_init__(self) -> None: + if not isinstance(self.target, SourceScopeRef) or self.target.scope is not SourceFacetScope.CHANNEL: + raise ValueError("termination evidence target must be a channel scope") + if not isinstance(self.termination, TerminationSpec): + raise ValueError("termination evidence has an invalid termination") + if not isinstance(self.source, TerminationEvidenceSource): + raise ValueError("termination evidence source has an invalid type") + if not isinstance(self.lifetime, TerminationEvidenceLifetime): + raise ValueError("termination evidence lifetime has an invalid type") + if self.lifetime not in _TERMINATION_EVIDENCE_LIFETIMES[self.source]: + raise ValueError("termination evidence source and lifetime are incompatible") + for label, value in ( + ("termination evidence resource_fingerprint", self.resource_fingerprint), + ("termination evidence binding_digest", self.binding_digest), + ): + if not isinstance(value, str) or _SHA256.fullmatch(value) is None: + raise ValueError(f"{label} must be sha256:<64 lowercase hex>") + observed_at = _parse_utc_timestamp( + self.observed_at_utc, + "termination evidence observed_at_utc", + ) + if self.expires_at_utc is not None: + expires_at = _parse_utc_timestamp( + self.expires_at_utc, + "termination evidence expires_at_utc", + ) + if expires_at <= observed_at: + raise ValueError("termination evidence expires_at_utc must be after observed_at_utc") + _require_evidence_ref_tuple((self.evidence_ref,), "termination evidence evidence_ref") + + +@dataclass(frozen=True, slots=True) +class SourceVoltageReferenceConstraint: + basis: VoltageReferenceBasis + + def __post_init__(self) -> None: + if not isinstance(self.basis, VoltageReferenceBasis): + raise ValueError("voltage reference basis has an invalid type") + + +@dataclass(frozen=True, slots=True) +class SourceResistanceConstraint: + resistance_ohm: ResistanceBounds + + def __post_init__(self) -> None: + if not isinstance(self.resistance_ohm, ResistanceBounds): + raise ValueError("source resistance constraint has an invalid type") + + +@dataclass(frozen=True, slots=True) +class SourceFrequencyDeratingBand: + frequency_hz: ClosedFloatInterval + gain_upper: float + + def __post_init__(self) -> None: + if not isinstance(self.frequency_hz, ClosedFloatInterval): + raise ValueError("frequency derating band has an invalid interval") + _require_finite(self.gain_upper, "frequency derating gain_upper", minimum=1.0) + + +@dataclass(frozen=True, slots=True) +class SourceFrequencyDeratingConstraint: + bands: tuple[SourceFrequencyDeratingBand, ...] + + def __post_init__(self) -> None: + if not isinstance(self.bands, tuple) or not self.bands or any( + not isinstance(band, SourceFrequencyDeratingBand) for band in self.bands + ): + raise ValueError("frequency derating bands have an invalid type") + previous: float | None = None + for band in self.bands: + if previous is not None and band.frequency_hz.minimum <= previous: + raise ValueError("frequency derating bands must be increasing and disjoint") + previous = band.frequency_hz.maximum + + +@dataclass(frozen=True, slots=True) +class SourceModulationEnvelopeConstraint: + kind: SourceModulationKind + gain_upper: float + + def __post_init__(self) -> None: + if not isinstance(self.kind, SourceModulationKind): + raise ValueError("modulation envelope kind has an invalid type") + _require_finite(self.gain_upper, "modulation envelope gain_upper", minimum=1.0) + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryOvershootConstraint: + gain_upper: float + + def __post_init__(self) -> None: + _require_finite(self.gain_upper, "arbitrary overshoot gain_upper", minimum=1.0) + + +@dataclass(frozen=True, slots=True) +class SourceNoisePeakConstraint: + absolute_peak_upper_v: float + + def __post_init__(self) -> None: + _require_finite(self.absolute_peak_upper_v, "noise absolute_peak_upper_v", minimum=0.0) + + +@dataclass(frozen=True, slots=True) +class SourceSharedPowerConstraint: + participants: tuple[int, ...] + maximum_power_w: float + + def __post_init__(self) -> None: + _require_positive_channels(self.participants, "shared power participants") + _require_finite(self.maximum_power_w, "shared power maximum_power_w", minimum=0.0) + + +SourceSafetyConstraintProfile: TypeAlias = ( + SourceVoltageReferenceConstraint + | SourceResistanceConstraint + | SourceFrequencyDeratingConstraint + | SourceModulationEnvelopeConstraint + | SourceArbitraryOvershootConstraint + | SourceNoisePeakConstraint + | SourceSharedPowerConstraint +) + + +_SAFETY_PROFILE_TYPES: dict[SourceSafetyConstraintKind, type[object]] = { + SourceSafetyConstraintKind.VOLTAGE_REFERENCE: SourceVoltageReferenceConstraint, + SourceSafetyConstraintKind.SOURCE_RESISTANCE: SourceResistanceConstraint, + SourceSafetyConstraintKind.FREQUENCY_DERATING: SourceFrequencyDeratingConstraint, + SourceSafetyConstraintKind.MODULATION_ENVELOPE: SourceModulationEnvelopeConstraint, + SourceSafetyConstraintKind.ARBITRARY_OVERSHOOT: SourceArbitraryOvershootConstraint, + SourceSafetyConstraintKind.NOISE_PEAK: SourceNoisePeakConstraint, + SourceSafetyConstraintKind.SHARED_POWER: SourceSharedPowerConstraint, +} + + +@dataclass(frozen=True, slots=True) +class SourceSafetyConstraint: + constraint_id: str + kind: SourceSafetyConstraintKind + applicability: SourceConstraintApplicability + profile: SourceSafetyConstraintProfile + proof_strength: BudgetProofStrength + evidence_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_token(self.constraint_id, "source safety constraint_id") + if not isinstance(self.kind, SourceSafetyConstraintKind): + raise ValueError("source safety kind has an invalid type") + if not isinstance(self.applicability, SourceConstraintApplicability): + raise ValueError("source safety applicability has an invalid type") + expected = _SAFETY_PROFILE_TYPES[self.kind] + if not isinstance(self.profile, expected): + raise ValueError(f"source safety kind {self.kind.value!r} requires {expected.__name__}") + if not isinstance(self.proof_strength, BudgetProofStrength): + raise ValueError("source safety proof_strength has an invalid type") + _require_evidence_ref_tuple(self.evidence_refs, "source safety evidence_refs") + + +@dataclass(frozen=True, slots=True) +class SourceSafetyProfile: + constraints: tuple[SourceSafetyConstraint, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.constraints, tuple) or any( + not isinstance(item, SourceSafetyConstraint) for item in self.constraints + ): + raise ValueError("source safety constraints have an invalid type") + ids = tuple(item.constraint_id for item in self.constraints) + if len(set(ids)) != len(ids) or tuple(sorted(ids)) != ids: + raise ValueError("source safety constraints must be sorted by constraint_id and unique") + + +def _require_observed(value: object, label: str) -> None: + if not isinstance(value, Observed): + raise ValueError(f"{label} must be Observed") + + +@dataclass(frozen=True, slots=True) +class SourceAmplitude: + value: float + unit: SourceAmplitudeUnit + + def __post_init__(self) -> None: + _require_finite(self.value, "source amplitude value") + if not isinstance(self.unit, SourceAmplitudeUnit): + raise ValueError("source amplitude unit has an invalid type") + + +@dataclass(frozen=True, slots=True) +class SourceDisplayLoad: + kind: SourceLoadKind + resistance_ohm: float | None = None + + def __post_init__(self) -> None: + if not isinstance(self.kind, SourceLoadKind): + raise ValueError("source display load kind has an invalid type") + if self.kind is SourceLoadKind.RESISTIVE: + _require_finite( + self.resistance_ohm, + "source display load resistance_ohm", + minimum=0.0, + ) + if self.resistance_ohm == 0: + raise ValueError("source display load resistance_ohm must be positive") + elif self.resistance_ohm is not None: + raise ValueError("non-resistive source display load cannot carry resistance_ohm") + + +@dataclass(frozen=True, slots=True) +class SourceComponentAmplitude: + kind: ComponentAmplitudeKind + value: float + + def __post_init__(self) -> None: + if not isinstance(self.kind, ComponentAmplitudeKind): + raise ValueError("source component amplitude kind has an invalid type") + _require_finite(self.value, "source component amplitude value") + if self.kind is not ComponentAmplitudeKind.RELATIVE_DB and self.value < 0: + raise ValueError("linear or Vpp component amplitudes must be non-negative") + + +@dataclass(frozen=True, slots=True) +class SourceHarmonicComponentV2: + order: int + amplitude: Observed[SourceComponentAmplitude] + phase_deg: Observed[float] + + def __post_init__(self) -> None: + _require_int(self.order, "source harmonic order", minimum=2) + _require_observed(self.amplitude, "source harmonic amplitude") + _require_observed(self.phase_deg, "source harmonic phase_deg") + if self.phase_deg.availability is Availability.VALUE: + _require_finite(self.phase_deg.value, "source harmonic phase_deg", minimum=0, maximum=360) + + +@dataclass(frozen=True, slots=True) +class SourceModulationParameter: + kind: SourceModulationParameterKind + value: float + + def __post_init__(self) -> None: + if not isinstance(self.kind, SourceModulationParameterKind): + raise ValueError("source modulation parameter kind has an invalid type") + _require_finite(self.value, "source modulation parameter value") + + +@dataclass(frozen=True, slots=True) +class SourceTriggerState: + source: Observed[SourceTriggerSource] + slope: Observed[SourceTriggerSlope] + output: Observed[SourceTriggerOutput] + + def __post_init__(self) -> None: + _require_observed(self.source, "source trigger source") + _require_observed(self.slope, "source trigger slope") + _require_observed(self.output, "source trigger output") + + +@dataclass(frozen=True, slots=True) +class SourceSweepMarker: + enabled: Observed[bool] + frequency_hz: Observed[float] + + def __post_init__(self) -> None: + _require_observed(self.enabled, "source sweep marker enabled") + _require_observed(self.frequency_hz, "source sweep marker frequency_hz") + if self.frequency_hz.availability is Availability.VALUE: + _require_finite( + self.frequency_hz.value, + "source sweep marker frequency_hz", + minimum=0.0, + ) + + +@dataclass(frozen=True, slots=True) +class BasicWaveFacet: + waveform_kind: Observed[SourceWaveformKind] + waveform_id: Observed[str] + frequency_mode: Observed[SourceFrequencyMode] + frequency_hz: Observed[float] + amplitude: Observed[SourceAmplitude] + offset_v: Observed[float] + phase_deg: Observed[float] + square_duty_cycle_percent: Observed[float] + + def __post_init__(self) -> None: + for name, value in ( + ("waveform_kind", self.waveform_kind), + ("waveform_id", self.waveform_id), + ("frequency_mode", self.frequency_mode), + ("frequency_hz", self.frequency_hz), + ("amplitude", self.amplitude), + ("offset_v", self.offset_v), + ("phase_deg", self.phase_deg), + ("square_duty_cycle_percent", self.square_duty_cycle_percent), + ): + _require_observed(value, f"basic {name}") + if self.waveform_id.availability is Availability.VALUE: + _require_token(self.waveform_id.value, "basic waveform_id") + if self.frequency_hz.availability is Availability.VALUE: + _require_finite(self.frequency_hz.value, "basic frequency_hz", minimum=0.0) + if self.offset_v.availability is Availability.VALUE: + _require_finite(self.offset_v.value, "basic offset_v") + if self.phase_deg.availability is Availability.VALUE: + _require_finite(self.phase_deg.value, "basic phase_deg", minimum=0, maximum=360) + if self.square_duty_cycle_percent.availability is Availability.VALUE: + _require_finite( + self.square_duty_cycle_percent.value, + "basic square_duty_cycle_percent", + minimum=0, + maximum=100, + ) + + +@dataclass(frozen=True, slots=True) +class OutputFacet: + enabled: Observed[bool] + display_load: Observed[SourceDisplayLoad] + polarity: Observed[SourceOutputPolarity] + + def __post_init__(self) -> None: + _require_observed(self.enabled, "output enabled") + _require_observed(self.display_load, "output display_load") + _require_observed(self.polarity, "output polarity") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "output enabled value") + + +@dataclass(frozen=True, slots=True) +class HarmonicFacet: + enabled: Observed[bool] + completeness: Observed[HarmonicCompleteness] + maximum_supported_order: Observed[int] + components: Observed[tuple[SourceHarmonicComponentV2, ...]] + + def __post_init__(self) -> None: + _require_observed(self.enabled, "harmonic enabled") + _require_observed(self.completeness, "harmonic completeness") + _require_observed(self.maximum_supported_order, "harmonic maximum_supported_order") + _require_observed(self.components, "harmonic components") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "harmonic enabled value") + if self.maximum_supported_order.availability is Availability.VALUE: + _require_int( + self.maximum_supported_order.value, + "harmonic maximum_supported_order value", + minimum=2, + ) + if self.components.availability is Availability.VALUE: + components = self.components.value + if not isinstance(components, tuple) or any( + not isinstance(item, SourceHarmonicComponentV2) for item in components + ): + raise ValueError("harmonic components value has an invalid type") + orders = tuple(item.order for item in components) + if len(set(orders)) != len(orders) or tuple(sorted(orders)) != orders: + raise ValueError("harmonic components must be sorted by order and unique") + + +@dataclass(frozen=True, slots=True) +class ModulationFacet: + enabled: Observed[bool] + kind: Observed[SourceModulationKind] + source: Observed[SourceModulationSource] + parameters: Observed[tuple[SourceModulationParameter, ...]] + internal_frequency_hz: Observed[float] + internal_waveform_kind: Observed[SourceWaveformKind] + + def __post_init__(self) -> None: + for name, value in ( + ("enabled", self.enabled), + ("kind", self.kind), + ("source", self.source), + ("parameters", self.parameters), + ("internal_frequency_hz", self.internal_frequency_hz), + ("internal_waveform_kind", self.internal_waveform_kind), + ): + _require_observed(value, f"modulation {name}") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "modulation enabled value") + if self.parameters.availability is Availability.VALUE: + parameters = self.parameters.value + if not isinstance(parameters, tuple) or any( + not isinstance(item, SourceModulationParameter) for item in parameters + ): + raise ValueError("modulation parameters value has an invalid type") + kinds = tuple(item.kind.value for item in parameters) + if len(set(kinds)) != len(kinds) or tuple(sorted(kinds)) != kinds: + raise ValueError("modulation parameters must be sorted by kind and unique") + if self.internal_frequency_hz.availability is Availability.VALUE: + _require_finite( + self.internal_frequency_hz.value, + "modulation internal_frequency_hz", + minimum=0.0, + ) + + +@dataclass(frozen=True, slots=True) +class SweepFacet: + enabled: Observed[bool] + start_hz: Observed[float] + stop_hz: Observed[float] + spacing: Observed[SourceSweepSpacing] + steps: Observed[int] + sweep_time_s: Observed[float] + start_hold_s: Observed[float] + stop_hold_s: Observed[float] + return_time_s: Observed[float] + trigger: Observed[SourceTriggerState] + marker: Observed[SourceSweepMarker] + + def __post_init__(self) -> None: + for name, value in ( + ("enabled", self.enabled), + ("start_hz", self.start_hz), + ("stop_hz", self.stop_hz), + ("spacing", self.spacing), + ("steps", self.steps), + ("sweep_time_s", self.sweep_time_s), + ("start_hold_s", self.start_hold_s), + ("stop_hold_s", self.stop_hold_s), + ("return_time_s", self.return_time_s), + ("trigger", self.trigger), + ("marker", self.marker), + ): + _require_observed(value, f"sweep {name}") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "sweep enabled value") + for name, value in ( + ("start_hz", self.start_hz), + ("stop_hz", self.stop_hz), + ("sweep_time_s", self.sweep_time_s), + ("start_hold_s", self.start_hold_s), + ("stop_hold_s", self.stop_hold_s), + ("return_time_s", self.return_time_s), + ): + if value.availability is Availability.VALUE: + _require_finite(value.value, f"sweep {name} value", minimum=0.0) + if self.steps.availability is Availability.VALUE: + _require_int(self.steps.value, "sweep steps value", minimum=2) + + +@dataclass(frozen=True, slots=True) +class BurstFacet: + enabled: Observed[bool] + mode: Observed[SourceBurstMode] + cycles: Observed[int] + phase_deg: Observed[float] + internal_period_s: Observed[float] + delay_s: Observed[float] + gate_polarity: Observed[SourceGatePolarity] + trigger: Observed[SourceTriggerState] + + def __post_init__(self) -> None: + for name, value in ( + ("enabled", self.enabled), + ("mode", self.mode), + ("cycles", self.cycles), + ("phase_deg", self.phase_deg), + ("internal_period_s", self.internal_period_s), + ("delay_s", self.delay_s), + ("gate_polarity", self.gate_polarity), + ("trigger", self.trigger), + ): + _require_observed(value, f"burst {name}") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "burst enabled value") + if self.cycles.availability is Availability.VALUE: + _require_int(self.cycles.value, "burst cycles value", minimum=1) + if self.phase_deg.availability is Availability.VALUE: + _require_finite(self.phase_deg.value, "burst phase_deg value", minimum=0, maximum=360) + for name, value in ( + ("internal_period_s", self.internal_period_s), + ("delay_s", self.delay_s), + ): + if value.availability is Availability.VALUE: + _require_finite(value.value, f"burst {name} value", minimum=0.0) + + +@dataclass(frozen=True, slots=True) +class PulseFacet: + hold_basis: Observed[SourcePulseHoldBasis] + width_s: Observed[float] + duty_cycle_percent: Observed[float] + delay_s: Observed[float] + leading_transition_s: Observed[float] + trailing_transition_s: Observed[float] + + def __post_init__(self) -> None: + for name, value in ( + ("hold_basis", self.hold_basis), + ("width_s", self.width_s), + ("duty_cycle_percent", self.duty_cycle_percent), + ("delay_s", self.delay_s), + ("leading_transition_s", self.leading_transition_s), + ("trailing_transition_s", self.trailing_transition_s), + ): + _require_observed(value, f"pulse {name}") + for name, value in ( + ("width_s", self.width_s), + ("delay_s", self.delay_s), + ("leading_transition_s", self.leading_transition_s), + ("trailing_transition_s", self.trailing_transition_s), + ): + if value.availability is Availability.VALUE: + _require_finite(value.value, f"pulse {name} value", minimum=0.0) + if self.duty_cycle_percent.availability is Availability.VALUE: + _require_finite( + self.duty_cycle_percent.value, + "pulse duty_cycle_percent value", + minimum=0, + maximum=100, + ) + + +@dataclass(frozen=True, slots=True) +class ArbitraryFacet: + selected_waveform_id: Observed[str] + playback_mode: Observed[SourceArbitraryPlaybackMode] + playback_frequency_hz: Observed[float] + sample_rate_hz: Observed[float] + point_count: Observed[int] + storage_digest: Observed[str] + + def __post_init__(self) -> None: + for name, value in ( + ("selected_waveform_id", self.selected_waveform_id), + ("playback_mode", self.playback_mode), + ("playback_frequency_hz", self.playback_frequency_hz), + ("sample_rate_hz", self.sample_rate_hz), + ("point_count", self.point_count), + ("storage_digest", self.storage_digest), + ): + _require_observed(value, f"arbitrary {name}") + if self.selected_waveform_id.availability is Availability.VALUE: + _require_token(self.selected_waveform_id.value, "arbitrary selected_waveform_id") + for name, value in ( + ("playback_frequency_hz", self.playback_frequency_hz), + ("sample_rate_hz", self.sample_rate_hz), + ): + if value.availability is Availability.VALUE: + _require_finite(value.value, f"arbitrary {name} value", minimum=0.0) + if self.point_count.availability is Availability.VALUE: + _require_int(self.point_count.value, "arbitrary point_count value", minimum=1) + if self.storage_digest.availability is Availability.VALUE and ( + not isinstance(self.storage_digest.value, str) + or _SHA256.fullmatch(self.storage_digest.value) is None + ): + raise ValueError("arbitrary storage_digest must be sha256:<64 lowercase hex>") + + +@dataclass(frozen=True, slots=True) +class SourceCounterMeasurementV2: + kind: SourceCounterMeasurementKind + value: float + + def __post_init__(self) -> None: + if not isinstance(self.kind, SourceCounterMeasurementKind): + raise ValueError("counter measurement kind has an invalid type") + _require_finite(self.value, "counter measurement value") + + +@dataclass(frozen=True, slots=True) +class SourceCounterInputState: + input_id: str + enabled: Observed[bool] + measurements: Observed[tuple[SourceCounterMeasurementV2, ...]] + coupling: Observed[SourceInputCoupling] + impedance_ohm: Observed[float] + attenuation: Observed[int] + gate_time_s: Observed[float] + trigger_level_v: Observed[float] + statistics_enabled: Observed[bool] + + def __post_init__(self) -> None: + _require_token(self.input_id, "counter input_id") + for name, value in ( + ("enabled", self.enabled), + ("measurements", self.measurements), + ("coupling", self.coupling), + ("impedance_ohm", self.impedance_ohm), + ("attenuation", self.attenuation), + ("gate_time_s", self.gate_time_s), + ("trigger_level_v", self.trigger_level_v), + ("statistics_enabled", self.statistics_enabled), + ): + _require_observed(value, f"counter {name}") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "counter enabled value") + if self.statistics_enabled.availability is Availability.VALUE: + _require_bool(self.statistics_enabled.value, "counter statistics_enabled value") + if self.measurements.availability is Availability.VALUE: + measurements = self.measurements.value + if not isinstance(measurements, tuple) or any( + not isinstance(item, SourceCounterMeasurementV2) for item in measurements + ): + raise ValueError("counter measurements value has an invalid type") + kinds = tuple(item.kind.value for item in measurements) + if len(set(kinds)) != len(kinds) or tuple(sorted(kinds)) != kinds: + raise ValueError("counter measurements must be sorted by kind and unique") + if self.impedance_ohm.availability is Availability.VALUE: + _require_finite(self.impedance_ohm.value, "counter impedance_ohm", minimum=0.0) + if self.attenuation.availability is Availability.VALUE: + _require_int(self.attenuation.value, "counter attenuation", minimum=1) + if self.gate_time_s.availability is Availability.VALUE: + _require_finite(self.gate_time_s.value, "counter gate_time_s", minimum=0.0) + if self.trigger_level_v.availability is Availability.VALUE: + _require_finite(self.trigger_level_v.value, "counter trigger_level_v") + + +@dataclass(frozen=True, slots=True) +class SourceReferenceClockState: + mode: Observed[SourceReferenceClockMode] + frequency_hz: Observed[float] + locked: Observed[bool] + + def __post_init__(self) -> None: + _require_observed(self.mode, "reference clock mode") + _require_observed(self.frequency_hz, "reference clock frequency_hz") + _require_observed(self.locked, "reference clock locked") + if self.frequency_hz.availability is Availability.VALUE: + _require_finite(self.frequency_hz.value, "reference clock frequency_hz", minimum=0.0) + if self.locked.availability is Availability.VALUE: + _require_bool(self.locked.value, "reference clock locked value") + + +@dataclass(frozen=True, slots=True) +class SourceSyncState: + enabled: Observed[bool] + polarity: Observed[SourceOutputPolarity] + source_channel: Observed[int] + + def __post_init__(self) -> None: + _require_observed(self.enabled, "sync enabled") + _require_observed(self.polarity, "sync polarity") + _require_observed(self.source_channel, "sync source_channel") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "sync enabled value") + if self.source_channel.availability is Availability.VALUE: + _require_int(self.source_channel.value, "sync source_channel value", minimum=1) + + +@dataclass(frozen=True, slots=True) +class SourceCascadeState: + enabled: Observed[bool] + role: Observed[str] + + def __post_init__(self) -> None: + _require_observed(self.enabled, "cascade enabled") + _require_observed(self.role, "cascade role") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "cascade enabled value") + if self.role.availability is Availability.VALUE: + _require_token(self.role.value, "cascade role value") + + +@dataclass(frozen=True, slots=True) +class SourceRelationState: + feature: SourceFeature + channels: tuple[int, ...] + enabled: Observed[bool] + + def __post_init__(self) -> None: + if self.feature not in { + SourceFeature.COMBINE, + SourceFeature.TRACKING, + SourceFeature.COUPLING, + SourceFeature.COPY, + SourceFeature.PHASE_RELATION, + }: + raise ValueError("source relation state feature is not a relation") + _require_positive_channels(self.channels, "source relation state channels") + if len(self.channels) < 2: + raise ValueError("source relation state requires two or more channels") + _require_observed(self.enabled, "source relation state enabled") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "source relation state enabled value") + + +@dataclass(frozen=True, slots=True) +class SourceSharedPowerState: + participants: tuple[int, ...] + active_power_upper_w: Observed[float] + hard_limit_w: Observed[float] + + def __post_init__(self) -> None: + _require_positive_channels(self.participants, "shared power state participants") + _require_observed(self.active_power_upper_w, "shared power active_power_upper_w") + _require_observed(self.hard_limit_w, "shared power hard_limit_w") + for name, value in ( + ("active_power_upper_w", self.active_power_upper_w), + ("hard_limit_w", self.hard_limit_w), + ): + if value.availability is Availability.VALUE: + _require_finite(value.value, f"shared power {name} value", minimum=0.0) + + +@dataclass(frozen=True, slots=True) +class SourceSystemStateV2: + counters: tuple[SourceCounterInputState, ...] + reference_clock: Observed[SourceReferenceClockState] + sync: Observed[SourceSyncState] + cascade: Observed[SourceCascadeState] + + def __post_init__(self) -> None: + if not isinstance(self.counters, tuple) or any( + not isinstance(item, SourceCounterInputState) for item in self.counters + ): + raise ValueError("source system counters have an invalid type") + ids = tuple(item.input_id for item in self.counters) + if len(set(ids)) != len(ids) or tuple(sorted(ids)) != ids: + raise ValueError("source system counters must be sorted by input_id and unique") + _require_observed(self.reference_clock, "source system reference_clock") + _require_observed(self.sync, "source system sync") + _require_observed(self.cascade, "source system cascade") + + +@dataclass(frozen=True, slots=True) +class SourceCrossChannelStateV2: + relations: tuple[SourceRelationState, ...] + relation_graph: Observed[SourceRelationGraph] + shared_power: Observed[SourceSharedPowerState] + + def __post_init__(self) -> None: + if not isinstance(self.relations, tuple) or any( + not isinstance(item, SourceRelationState) for item in self.relations + ): + raise ValueError("source cross-channel relations have an invalid type") + keys = tuple((item.feature.value, item.channels) for item in self.relations) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError("source cross-channel relations must be sorted and unique") + _require_observed(self.relation_graph, "source cross-channel relation_graph") + _require_observed(self.shared_power, "source cross-channel shared_power") + + +@dataclass(frozen=True, slots=True) +class SourceChannelStateV2: + channel: int + basic: Observed[BasicWaveFacet] + output: Observed[OutputFacet] + harmonics: Observed[HarmonicFacet] + modulation: Observed[ModulationFacet] + sweep: Observed[SweepFacet] + burst: Observed[BurstFacet] + pulse: Observed[PulseFacet] + arbitrary: Observed[ArbitraryFacet] + + def __post_init__(self) -> None: + _require_int(self.channel, "source channel state channel", minimum=1) + for name, value in ( + ("basic", self.basic), + ("output", self.output), + ("harmonics", self.harmonics), + ("modulation", self.modulation), + ("sweep", self.sweep), + ("burst", self.burst), + ("pulse", self.pulse), + ("arbitrary", self.arbitrary), + ): + _require_observed(value, f"source channel state {name}") + + +@dataclass(frozen=True, slots=True) +class SourceRuntimeCapabilityProfile: + session_epoch: str + descriptor_digest: str + identity: SourceRuntimeIdentity + features: tuple[SourceFeatureCapability, ...] + + def __post_init__(self) -> None: + _require_token(self.session_epoch, "source runtime session_epoch") + if not isinstance(self.descriptor_digest, str) or _SHA256.fullmatch( + self.descriptor_digest + ) is None: + raise ValueError("source runtime descriptor_digest must be sha256:<64 lowercase hex>") + if not isinstance(self.identity, SourceRuntimeIdentity): + raise ValueError("source runtime identity has an invalid type") + if not isinstance(self.features, tuple) or any( + not isinstance(item, SourceFeatureCapability) for item in self.features + ): + raise ValueError("source runtime features have an invalid type") + keys = tuple( + (item.feature.value, item.scope.value, item.channels) for item in self.features + ) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError("source runtime features must be sorted and unique") + + +class SourceQueryPhase(StrEnum): + ANCHOR_BEFORE = "anchor_before" + FACET = "facet" + ANCHOR_AFTER = "anchor_after" + + +@dataclass(frozen=True, slots=True) +class SourceFacetQueryContract: + feature: SourceFeature + scope: SourceFacetScope + fields: tuple[SourceFieldId, ...] + activation_any: tuple[SourceActivationRule, ...] + effect: SourceQueryEffect + max_queries: int + required: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.feature, SourceFeature): + raise ValueError("source query feature has an invalid type") + if not isinstance(self.scope, SourceFacetScope): + raise ValueError("source query scope has an invalid type") + _require_enum_tuple(self.fields, SourceFieldId, "source query fields") + if any(self.scope not in _FIELD_SCOPES[field] for field in self.fields): + raise ValueError("source query field does not support the declared scope") + if not isinstance(self.activation_any, tuple) or any( + not isinstance(item, SourceActivationRule) for item in self.activation_any + ): + raise ValueError("source query activation_any has an invalid type") + if not isinstance(self.effect, SourceQueryEffect): + raise ValueError("source query effect has an invalid type") + _require_int(self.max_queries, "source query max_queries", minimum=1) + _require_bool(self.required, "source query required") + + +@dataclass(frozen=True, slots=True) +class SourceQueryContract: + anchor_fields: tuple[SourceFieldId, ...] + facets: tuple[SourceFacetQueryContract, ...] + max_queries: int + timeout_ms: int + + def __post_init__(self) -> None: + _require_enum_tuple(self.anchor_fields, SourceFieldId, "source query anchor_fields") + if SourceFieldId.IDENTITY not in self.anchor_fields: + raise ValueError("source query anchor_fields must include source.identity") + if not isinstance(self.facets, tuple) or not self.facets or any( + not isinstance(item, SourceFacetQueryContract) for item in self.facets + ): + raise ValueError("source query facets have an invalid type") + keys = tuple( + ( + item.feature.value, + item.scope.value, + tuple(field.value for field in item.fields), + ) + for item in self.facets + ) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError("source query facets must be sorted and unique") + covered = {field for item in self.facets for field in item.fields} + if not set(self.anchor_fields) <= covered: + raise ValueError("source query facets do not cover every anchor field") + _require_int(self.max_queries, "source query max_queries", minimum=1) + _require_int(self.timeout_ms, "source query timeout_ms", minimum=1) + + +@dataclass(frozen=True, slots=True) +class SourceSemanticQueryItem: + item_id: str + phase: SourceQueryPhase + feature: SourceFeature + target: SourceScopeRef + fields: tuple[SourceFieldRef, ...] + activation_any: tuple[SourceActivationRule, ...] + required: bool + effect: SourceQueryEffect + max_queries: int + + def __post_init__(self) -> None: + _require_token(self.item_id, "source semantic query item_id") + if not isinstance(self.phase, SourceQueryPhase): + raise ValueError("source semantic query phase has an invalid type") + if not isinstance(self.feature, SourceFeature): + raise ValueError("source semantic query feature has an invalid type") + if not isinstance(self.target, SourceScopeRef): + raise ValueError("source semantic query target has an invalid type") + if not isinstance(self.fields, tuple) or not self.fields or any( + not isinstance(item, SourceFieldRef) for item in self.fields + ): + raise ValueError("source semantic query fields have an invalid type") + if any(item.target != self.target for item in self.fields): + raise ValueError("source semantic query fields must use the item target") + field_values = tuple(item.field.value for item in self.fields) + if len(set(field_values)) != len(field_values) or tuple(sorted(field_values)) != field_values: + raise ValueError("source semantic query fields must be sorted and unique") + if not isinstance(self.activation_any, tuple) or any( + not isinstance(item, SourceActivationRule) for item in self.activation_any + ): + raise ValueError("source semantic query activation_any has an invalid type") + _require_bool(self.required, "source semantic query required") + if not isinstance(self.effect, SourceQueryEffect): + raise ValueError("source semantic query effect has an invalid type") + _require_int(self.max_queries, "source semantic query max_queries", minimum=1) + + +@dataclass(frozen=True, slots=True) +class SourceSemanticQueryPlan: + contract_version: Literal["wavebench.source.v2"] + plan_id: str + items: tuple[SourceSemanticQueryItem, ...] + allowed_effects: tuple[SourceQueryEffect, ...] + max_queries: int + deadline_monotonic: float + + def __post_init__(self) -> None: + if self.contract_version != SOURCE_CONTRACT_VERSION: + raise ValueError("source semantic query contract_version is unsupported") + _require_token(self.plan_id, "source semantic query plan_id") + if not isinstance(self.items, tuple) or any( + not isinstance(item, SourceSemanticQueryItem) for item in self.items + ): + raise ValueError("source semantic query items have an invalid type") + item_ids = tuple(item.item_id for item in self.items) + if len(set(item_ids)) != len(item_ids): + raise ValueError("source semantic query item_ids must be unique") + phases = tuple(item.phase.value for item in self.items) + if phases != tuple(sorted(phases, key=("anchor_before", "facet", "anchor_after").index)): + raise ValueError("source semantic query items have an invalid phase order") + _require_enum_tuple( + self.allowed_effects, + SourceQueryEffect, + "source semantic query allowed_effects", + ) + if any(item.effect not in self.allowed_effects for item in self.items): + raise ValueError("source semantic query item effect is not allowed") + _require_int(self.max_queries, "source semantic query max_queries", minimum=1) + _require_finite(self.deadline_monotonic, "source semantic query deadline_monotonic", minimum=0) + + +class SourceQueryItemOutcome(StrEnum): + OBSERVED = "observed" + SEMANTIC_UNAVAILABLE = "semantic_unavailable" + SKIPPED = "skipped" + + +SourceObservationValue: TypeAlias = ( + SourceRuntimeIdentity + | BasicWaveFacet + | OutputFacet + | SourceDisplayLoad + | HarmonicFacet + | ModulationFacet + | SweepFacet + | BurstFacet + | PulseFacet + | ArbitraryFacet + | SourceCounterInputState + | SourceReferenceClockState + | SourceSyncState + | SourceCascadeState + | SourceRelationState + | SourceRelationGraph + | SourceSharedPowerState + | bool + | str +) + + +_OBSERVATION_TYPES: dict[SourceFieldId, type[object] | tuple[type[object], ...]] = { + SourceFieldId.IDENTITY: SourceRuntimeIdentity, + SourceFieldId.BASIC: BasicWaveFacet, + SourceFieldId.OUTPUT: OutputFacet, + SourceFieldId.DISPLAY_LOAD: SourceDisplayLoad, + SourceFieldId.HARMONICS: HarmonicFacet, + SourceFieldId.MODULATION: ModulationFacet, + SourceFieldId.SWEEP: SweepFacet, + SourceFieldId.BURST: BurstFacet, + SourceFieldId.PULSE: PulseFacet, + SourceFieldId.ARBITRARY_SELECTION: ArbitraryFacet, + SourceFieldId.ARBITRARY_STORAGE: str, + SourceFieldId.ARM_STATE: bool, + SourceFieldId.TRIGGER_STATE: bool, + SourceFieldId.COMBINE: SourceRelationState, + SourceFieldId.COUPLING: SourceRelationState, + SourceFieldId.TRACKING: SourceRelationState, + SourceFieldId.COPY: SourceRelationState, + SourceFieldId.PHASE_RELATION: SourceRelationState, + SourceFieldId.RELATION_GRAPH: SourceRelationGraph, + SourceFieldId.REFERENCE_CLOCK: SourceReferenceClockState, + SourceFieldId.SYNC: SourceSyncState, + SourceFieldId.CASCADE: SourceCascadeState, + SourceFieldId.SHARED_POWER: SourceSharedPowerState, + SourceFieldId.COUNTER: SourceCounterInputState, +} + + +@dataclass(frozen=True, slots=True) +class SourceTypedObservation: + field: SourceFieldRef + value: SourceObservationValue + evidence_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.field, SourceFieldRef): + raise ValueError("source typed observation field has an invalid type") + expected = _OBSERVATION_TYPES[self.field.field] + if expected is bool: + if not isinstance(self.value, bool): + raise ValueError("source typed observation value has an invalid type") + elif not isinstance(self.value, expected): + raise ValueError("source typed observation value has an invalid type") + if _contains_nonfinite(self.value): + raise ValueError("source typed observation cannot contain non-finite floats") + _require_evidence_ref_tuple(self.evidence_refs, "source typed observation evidence_refs") + + +@dataclass(frozen=True, slots=True) +class SourceProtocolQueryRecord: + item_id: str + effect: SourceQueryEffect + outcome: SourceQueryItemOutcome + query_count: int + observations: tuple[SourceTypedObservation, ...] = () + reason_code: SourceReasonCode | None = None + + def __post_init__(self) -> None: + _require_token(self.item_id, "source protocol query item_id") + if not isinstance(self.effect, SourceQueryEffect): + raise ValueError("source protocol query effect has an invalid type") + if not isinstance(self.outcome, SourceQueryItemOutcome): + raise ValueError("source protocol query outcome has an invalid type") + _require_int(self.query_count, "source protocol query_count", minimum=0) + if not isinstance(self.observations, tuple) or any( + not isinstance(item, SourceTypedObservation) for item in self.observations + ): + raise ValueError("source protocol query observations have an invalid type") + fields_seen = tuple( + (item.field.field.value, source_v2_canonical_json(item.field.target)) + for item in self.observations + ) + if len(set(fields_seen)) != len(fields_seen): + raise ValueError("source protocol query observations must be unique") + if self.outcome is SourceQueryItemOutcome.OBSERVED: + if not self.observations or self.reason_code is not None: + raise ValueError("OBSERVED query records require observations and no reason_code") + else: + if self.observations: + raise ValueError("non-OBSERVED query records cannot carry observations") + if not isinstance(self.reason_code, SourceReasonCode): + raise ValueError("non-OBSERVED query records require a registered reason_code") + + +@dataclass(frozen=True, slots=True) +class SourceQueryExecutionRecord: + contract_version: Literal["wavebench.source.v2"] + plan_id: str + items: tuple[SourceProtocolQueryRecord, ...] + query_count: int + device_revision_token_before: str | None = None + device_revision_token_after: str | None = None + + def __post_init__(self) -> None: + if self.contract_version != SOURCE_CONTRACT_VERSION: + raise ValueError("source query execution contract_version is unsupported") + _require_token(self.plan_id, "source query execution plan_id") + if not isinstance(self.items, tuple) or any( + not isinstance(item, SourceProtocolQueryRecord) for item in self.items + ): + raise ValueError("source query execution items have an invalid type") + item_ids = tuple(item.item_id for item in self.items) + if len(set(item_ids)) != len(item_ids): + raise ValueError("source query execution item_ids must be unique") + _require_int(self.query_count, "source query execution query_count", minimum=1) + if self.query_count != sum(item.query_count for item in self.items): + raise ValueError("source query execution query_count does not match item counts") + for label, value in ( + ("device_revision_token_before", self.device_revision_token_before), + ("device_revision_token_after", self.device_revision_token_after), + ): + if value is not None: + _require_token(value, f"source query execution {label}") + + +@dataclass(frozen=True, slots=True) +class SourceDescriptorExtensions: + contract_version: Literal["wavebench.source.v2"] + topology: SourceTopologyContract + features: tuple[SourceFeatureCapability, ...] + query_contract: SourceQueryContract + safety_profile: SourceSafetyProfile = SourceSafetyProfile() + + def __post_init__(self) -> None: + if self.contract_version != SOURCE_CONTRACT_VERSION: + raise ValueError("source descriptor contract_version is unsupported") + if not isinstance(self.topology, SourceTopologyContract): + raise ValueError("source descriptor topology has an invalid type") + if not isinstance(self.features, tuple) or not self.features or any( + not isinstance(item, SourceFeatureCapability) for item in self.features + ): + raise ValueError("source descriptor features have an invalid type") + keys = tuple( + (item.feature.value, item.scope.value, item.channels) for item in self.features + ) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError("source descriptor features must be sorted and unique") + if any(not set(item.channels) <= set(self.topology.channels) for item in self.features): + raise ValueError("source descriptor feature references an unknown channel") + for feature in self.features: + if isinstance(feature.profile, SourceCounterCapabilityProfile) and not set( + feature.profile.input_ids + ) <= set(self.topology.input_ids): + raise ValueError("source counter profile references an unknown input_id") + if isinstance(feature.profile, SourceCrossChannelCapabilityProfile) and any( + not set(channel_set) <= set(self.topology.channels) + for channel_set in feature.profile.supported_channel_sets + ): + raise ValueError( + "source cross-channel profile references an unknown channel" + ) + if not isinstance(self.query_contract, SourceQueryContract): + raise ValueError("source descriptor query_contract has an invalid type") + if not isinstance(self.safety_profile, SourceSafetyProfile): + raise ValueError("source descriptor safety_profile has an invalid type") + + +class SnapshotConsistencyState(StrEnum): + CONSISTENT = "consistent" + DRIFTED = "drifted" + UNPROVEN = "unproven" + + +@dataclass(frozen=True, slots=True) +class SourceSnapshotConsistency: + state: SnapshotConsistencyState + session_epoch: str + anchor_fields: tuple[SourceFieldRef, ...] + anchor_digest_before: str + anchor_digest_after: str | None + device_revision_token_before: str | None + device_revision_token_after: str | None + reason_code: SourceReasonCode | None = None + + def __post_init__(self) -> None: + if not isinstance(self.state, SnapshotConsistencyState): + raise ValueError("source snapshot consistency state has an invalid type") + _require_token(self.session_epoch, "source snapshot consistency session_epoch") + if not isinstance(self.anchor_fields, tuple) or not self.anchor_fields or any( + not isinstance(item, SourceFieldRef) for item in self.anchor_fields + ): + raise ValueError("source snapshot consistency anchor_fields have an invalid type") + anchor_keys = tuple( + (item.field.value, source_v2_canonical_json(item.target)) + for item in self.anchor_fields + ) + if len(set(anchor_keys)) != len(anchor_keys) or tuple(sorted(anchor_keys)) != anchor_keys: + raise ValueError("source snapshot consistency anchor_fields must be sorted and unique") + if _SHA256.fullmatch(self.anchor_digest_before) is None: + raise ValueError("source snapshot anchor_digest_before has an invalid format") + if self.anchor_digest_after is not None and _SHA256.fullmatch( + self.anchor_digest_after + ) is None: + raise ValueError("source snapshot anchor_digest_after has an invalid format") + for label, value in ( + ("device_revision_token_before", self.device_revision_token_before), + ("device_revision_token_after", self.device_revision_token_after), + ): + if value is not None: + _require_token(value, f"source snapshot consistency {label}") + if self.state is SnapshotConsistencyState.CONSISTENT: + if self.anchor_digest_after != self.anchor_digest_before: + raise ValueError("CONSISTENT snapshots require matching anchor digests") + if ( + self.device_revision_token_before is not None + and self.device_revision_token_after is not None + and self.device_revision_token_before != self.device_revision_token_after + ): + raise ValueError("CONSISTENT snapshots require matching device revision tokens") + if self.reason_code is not None: + raise ValueError("CONSISTENT snapshots cannot carry a reason_code") + elif not isinstance(self.reason_code, SourceReasonCode): + raise ValueError("non-CONSISTENT snapshots require a registered reason_code") + + +@dataclass(frozen=True, slots=True) +class SourceSnapshotV2: + snapshot_id: str + context_id: str + correlation_id: str + captured_at_utc: str + runtime_profile: SourceRuntimeCapabilityProfile + channels: tuple[SourceChannelStateV2, ...] + system: Observed[SourceSystemStateV2] + cross_channel: Observed[SourceCrossChannelStateV2] + consistency: SourceSnapshotConsistency + plan_digest: str + query_count: int + session_health_before: str + session_health_after: str + + def __post_init__(self) -> None: + _require_token(self.snapshot_id, "source snapshot_id") + _require_token(self.context_id, "source snapshot context_id") + _require_token(self.correlation_id, "source snapshot correlation_id") + _parse_utc_timestamp(self.captured_at_utc, "source snapshot captured_at_utc") + if not isinstance(self.runtime_profile, SourceRuntimeCapabilityProfile): + raise ValueError("source snapshot runtime_profile has an invalid type") + if not isinstance(self.channels, tuple) or not self.channels or any( + not isinstance(item, SourceChannelStateV2) for item in self.channels + ): + raise ValueError("source snapshot channels have an invalid type") + channel_ids = tuple(item.channel for item in self.channels) + if len(set(channel_ids)) != len(channel_ids) or tuple(sorted(channel_ids)) != channel_ids: + raise ValueError("source snapshot channels must be sorted and unique") + _require_observed(self.system, "source snapshot system") + _require_observed(self.cross_channel, "source snapshot cross_channel") + if not isinstance(self.consistency, SourceSnapshotConsistency): + raise ValueError("source snapshot consistency has an invalid type") + if _SHA256.fullmatch(self.plan_digest) is None: + raise ValueError("source snapshot plan_digest has an invalid format") + _require_int(self.query_count, "source snapshot query_count", minimum=0) + _require_token(self.session_health_before, "source snapshot session_health_before") + _require_token(self.session_health_after, "source snapshot session_health_after") + + def as_dict(self) -> dict[str, object]: + return source_snapshot_v2_document(self) + + +@runtime_checkable +class SourceSnapshotV2Driver(InstrumentDriver, Protocol): + def execute_source_query_plan_v2( + self, + plan: SourceSemanticQueryPlan, + ) -> SourceQueryExecutionRecord: ... + + +def source_v2_to_data(value: object) -> object: + """Convert Source V2 public values into strict JSON-compatible data.""" + + if isinstance(value, StrEnum): + return value.value + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + if not isfinite(value): + raise ValueError("Source V2 JSON cannot contain non-finite floats") + return value + if isinstance(value, tuple): + return [source_v2_to_data(item) for item in value] + if isinstance(value, dict): + if any(not isinstance(key, str) for key in value): + raise TypeError("Source V2 JSON object keys must be strings") + return {key: source_v2_to_data(value[key]) for key in sorted(value)} + if is_dataclass(value) and not isinstance(value, type): + payload: dict[str, object] = {"type": type(value).__name__} + for item in fields(value): + payload[item.name] = source_v2_to_data(getattr(value, item.name)) + return payload + raise TypeError(f"unsupported Source V2 JSON value: {type(value).__name__}") + + +def source_v2_canonical_json(value: object) -> str: + return json.dumps( + source_v2_to_data(value), + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def source_v2_digest(value: object) -> str: + return "sha256:" + sha256(source_v2_canonical_json(value).encode("utf-8")).hexdigest() + + +def source_snapshot_v2_document(snapshot: SourceSnapshotV2) -> dict[str, object]: + if not isinstance(snapshot, SourceSnapshotV2): + raise TypeError("snapshot must be SourceSnapshotV2") + data = source_v2_to_data(snapshot) + assert isinstance(data, dict) + return { + "schema": SOURCE_SNAPSHOT_SCHEMA, + **data, + } + + +def source_snapshot_v2_operation_artifact(snapshot: SourceSnapshotV2) -> dict[str, object]: + """Build the read-only operation artifact without driver-private records.""" + + if not isinstance(snapshot, SourceSnapshotV2): + raise TypeError("snapshot must be SourceSnapshotV2") + return { + "schema": SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "source.snapshot_v2", + "context_id": snapshot.context_id, + "correlation_id": snapshot.correlation_id, + "session_epoch": snapshot.consistency.session_epoch, + "capability_decision": { + "capability": "source.snapshot_v2", + "contract_version": SOURCE_CONTRACT_VERSION, + "descriptor_digest": snapshot.runtime_profile.descriptor_digest, + }, + "snapshot": source_snapshot_v2_document(snapshot), + "query": { + "effect": SourceQueryEffect.PURE_READ.value, + "plan_digest": snapshot.plan_digest, + "query_count": snapshot.query_count, + }, + "session_health": { + "before": snapshot.session_health_before, + "after": snapshot.session_health_after, + }, + "final_state": { + "consistency": snapshot.consistency.state.value, + "session_health": snapshot.session_health_after, + }, + "evidence_refs": sorted( + { + ref + for feature in snapshot.runtime_profile.features + for ref in feature.evidence_refs + } + ), + } + + +def source_snapshot_timestamp_utc() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +__all__ = [ + "SOURCE_CONTRACT_VERSION", + "SOURCE_OPERATION_ARTIFACT_SCHEMA", + "SOURCE_SNAPSHOT_MIN_CORE_VERSION", + "SOURCE_SNAPSHOT_SCHEMA", + "ArbitraryFacet", + "Availability", + "BasicWaveFacet", + "BudgetEvidenceSource", + "BudgetProofStrength", + "BurstFacet", + "ClosedFloatInterval", + "ComponentAmplitudeKind", + "CompositeOutputBudget", + "HarmonicCompleteness", + "HarmonicFacet", + "ModulationFacet", + "Observed", + "OutputFacet", + "PortVoltageBounds", + "PulseFacet", + "ResistanceBounds", + "SnapshotConsistencyState", + "SourceActivationPredicate", + "SourceActivationRule", + "SourceAmplitude", + "SourceAmplitudeUnit", + "SourceAnchorField", + "SourceAffectedClosure", + "SourceArbitraryCapabilityProfile", + "SourceArbitraryOvershootConstraint", + "SourceArbitraryPlaybackMode", + "SourceBasicCapabilityProfile", + "SourceBudgetBlockerCode", + "SourceBurstCapabilityProfile", + "SourceBurstMode", + "SourceCascadeState", + "SourceChannelStateV2", + "SourceClockSyncCapabilityProfile", + "SourceComponentAmplitude", + "SourceConstraintApplicability", + "SourceCounterCapabilityProfile", + "SourceCounterInputState", + "SourceCounterMeasurementKind", + "SourceCounterMeasurementV2", + "SourceCrossChannelCapabilityProfile", + "SourceCrossChannelStateV2", + "SourceDescriptorExtensions", + "SourceDisplayLoad", + "SourceEnergyEffect", + "SourceFacetQueryContract", + "SourceFacetScope", + "SourceFeature", + "SourceFeatureCapability", + "SourceFeatureDirection", + "SourceFeatureProfile", + "SourceFieldId", + "SourceFieldRef", + "SourceFrequencyDeratingBand", + "SourceFrequencyDeratingConstraint", + "SourceFrequencyMode", + "SourceGatePolarity", + "SourceHarmonicCapabilityProfile", + "SourceHarmonicComponentV2", + "SourceInputCoupling", + "SourceLoadKind", + "SourceModulationCapabilityProfile", + "SourceModulationEnvelopeConstraint", + "SourceModulationKind", + "SourceModulationParameter", + "SourceModulationParameterKind", + "SourceModulationSource", + "SourceNoisePeakConstraint", + "SourceOperationContract", + "SourceOutputCapabilityProfile", + "SourceOutputPolarity", + "SourceProtocolQueryRecord", + "SourcePulseCapabilityProfile", + "SourcePulseHoldBasis", + "SourceQueryContract", + "SourceQueryEffect", + "SourceQueryExecutionRecord", + "SourceQueryItemOutcome", + "SourceQueryPhase", + "SourceReasonCode", + "SourceReferenceClockMode", + "SourceReferenceClockState", + "SourceRelationEdge", + "SourceRelationGraph", + "SourceRelationState", + "SourceResistanceConstraint", + "SourceRuntimeCapabilityProfile", + "SourceRuntimeIdentity", + "SourceSafetyConstraint", + "SourceSafetyConstraintKind", + "SourceSafetyConstraintProfile", + "SourceSafetyProfile", + "SafetyContributor", + "SourceScopeRef", + "SourceSemanticQueryItem", + "SourceSemanticQueryPlan", + "SourceSharedPowerConstraint", + "SourceSharedPowerBudget", + "SourceSharedPowerState", + "SourceSignalPathKind", + "SourceSnapshotConsistency", + "SourceSnapshotV2", + "SourceSnapshotV2Driver", + "SourceSweepCapabilityProfile", + "SourceSweepMarker", + "SourceSweepSpacing", + "SourceSyncState", + "SourceTerminationEvidence", + "SourceStorageEffect", + "SourceSystemStateV2", + "SourceTopologyContract", + "SourceTriggerOutput", + "SourceTriggerSlope", + "SourceTriggerSource", + "SourceTriggerState", + "SourceTypedObservation", + "SourceVoltageReferenceConstraint", + "SourceV1WriteRouteId", + "SourceWaveformKind", + "SupportState", + "SweepFacet", + "TerminationEvidenceLifetime", + "TerminationEvidenceSource", + "TerminationKind", + "TerminationSpec", + "VoltageReferenceBasis", + "source_snapshot_v2_document", + "source_v2_canonical_json", + "source_v2_digest", + "source_v2_to_data", +] diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 61fc984..4fdae91 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -403,6 +403,24 @@ def _spec( _spec("source.idn", "source", required_capabilities=("source.idn",), effect="observe"), _spec("source.errors", "source", required_capabilities=("source.errors",), effect="stateful_read"), _spec("source.status", "source", required_capabilities=("source.status",), effect="stateful_read"), + _spec( + "source.snapshot_v2", + "source", + required_capabilities=("source.snapshot_v2",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + session_purpose="normal", + verification_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("state_dependent_query",), + ), _spec("source.channel_profile", "source", required_capabilities=("source.channel_profile",), effect="stateful_read"), _spec("source.coupling_profile", "source", required_capabilities=("source.coupling_profile",), effect="stateful_read"), _spec("source.coupling_configure", "source", required_capabilities=("source.coupling_configure",), effect="write", changed_fields=("coupling",), risk_flags=("state_drift",)), diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py index ceb2aea..01ca1c3 100644 --- a/src/wavebench/services/source_service.py +++ b/src/wavebench/services/source_service.py @@ -59,6 +59,11 @@ SourceSweepProfile, SourceStatus, ) +from wavebench.instruments.source_extensions import ( + SourceDescriptorExtensions, + SourceSnapshotV2, + SourceSnapshotV2Driver, +) from wavebench.logging import CommandLogger from wavebench.instruments.registry import resolve_instrument_descriptor from wavebench.services.source_state import RestorableSourceState @@ -69,6 +74,13 @@ from wavebench.services.state_guard import SourceStateGuard from wavebench.transport.base import InstrumentTransport from wavebench.transport.session import InstrumentSessionState +from wavebench.services.source_snapshot_v2 import ( + SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS, + SourceSnapshotContractError, + build_source_snapshot, + build_source_snapshot_plan, + new_source_snapshot_context, +) @dataclass @@ -92,6 +104,7 @@ def _require(self, operation: str, *capabilities: str) -> None: source.driver, expected_kind="source", ) + self.descriptor = descriptor require_capabilities(descriptor, capabilities, operation=operation) def _source_config(self) -> SourceConfig: @@ -183,6 +196,49 @@ def status(self, channel: int | None = None) -> SourceStatus: self.state_guard.observe(status) return status + def snapshot_v2(self, *, correlation_id: str | None = None) -> SourceSnapshotV2: + self._require("source.snapshot_v2", "source.snapshot_v2") + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + if not isinstance(extensions, SourceDescriptorExtensions): + raise SourceSnapshotContractError( + "source.snapshot_v2 requires validated source_extensions" + ) + session_state = self.session_state + if session_state is None: + raise SourceSnapshotContractError( + "source.snapshot_v2 requires a connection-bound session state" + ) + with session_state.transaction_lock: + if session_state.health.value != "healthy": + raise SourceSnapshotContractError( + "source.snapshot_v2 requires a healthy session" + ) + timeout_ms = min( + SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS, + extensions.query_contract.timeout_ms, + self.config.connection.timeout_ms, + ) + context = new_source_snapshot_context( + session_epoch=session_state.epoch_id, + session_health_before=session_state.health.value, + descriptor_extensions=extensions, + timeout_ms=timeout_ms, + correlation_id=correlation_id, + ) + plan = build_source_snapshot_plan(context) + execution = cast( + SourceSnapshotV2Driver, + source, + ).execute_source_query_plan_v2(plan) + return build_source_snapshot( + context=context, + plan=plan, + execution=execution, + session_health_after=session_state.health.value, + ) + def channel_profile(self, channel: int | None = None) -> SourceChannelProfile: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel diff --git a/src/wavebench/services/source_snapshot_v2.py b/src/wavebench/services/source_snapshot_v2.py new file mode 100644 index 0000000..7748e85 --- /dev/null +++ b/src/wavebench/services/source_snapshot_v2.py @@ -0,0 +1,774 @@ +"""Core-owned planning and validation for Source V2 snapshots.""" + +from __future__ import annotations + +from dataclasses import dataclass +import time +from uuid import uuid4 + +from wavebench.errors import ConfigError +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + ArbitraryFacet, + Availability, + BasicWaveFacet, + BurstFacet, + HarmonicFacet, + ModulationFacet, + Observed, + OutputFacet, + SnapshotConsistencyState, + SourceActivationPredicate, + SourceActivationRule, + SourceAnchorField, + SourceChannelStateV2, + SourceCounterInputState, + SourceCrossChannelStateV2, + SourceDescriptorExtensions, + SourceFacetQueryContract, + SourceFacetScope, + SourceFeature, + SourceFeatureCapability, + SourceFieldId, + SourceFieldRef, + SourceQueryExecutionRecord, + SourceQueryEffect, + SourceQueryItemOutcome, + SourceQueryPhase, + SourceReasonCode, + SourceRelationState, + SourceRuntimeCapabilityProfile, + SourceRuntimeIdentity, + SourceScopeRef, + SourceSemanticQueryItem, + SourceSemanticQueryPlan, + SourceSnapshotConsistency, + SourceSnapshotV2, + SourceSystemStateV2, + SourceTypedObservation, + SweepFacet, + source_snapshot_timestamp_utc, + source_v2_digest, +) + + +SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS = 5_000 + + +class SourceSnapshotContractError(ConfigError): + code = "source_snapshot_contract_error" + + +@dataclass(frozen=True, slots=True) +class SourceSnapshotContext: + context_id: str + correlation_id: str + session_epoch: str + session_health_before: str + descriptor_extensions: SourceDescriptorExtensions + deadline_monotonic: float + + +def new_source_snapshot_context( + *, + session_epoch: str, + session_health_before: str, + descriptor_extensions: SourceDescriptorExtensions, + timeout_ms: int, + correlation_id: str | None = None, +) -> SourceSnapshotContext: + if timeout_ms < 1: + raise SourceSnapshotContractError("source snapshot timeout must be positive") + return SourceSnapshotContext( + context_id=uuid4().hex, + correlation_id=correlation_id or uuid4().hex, + session_epoch=session_epoch, + session_health_before=session_health_before, + descriptor_extensions=descriptor_extensions, + deadline_monotonic=time.monotonic() + (timeout_ms / 1000.0), + ) + + +def build_source_snapshot_plan(context: SourceSnapshotContext) -> SourceSemanticQueryPlan: + extensions = context.descriptor_extensions + query_contract = extensions.query_contract + items: list[SourceSemanticQueryItem] = [] + + def append_item( + *, + phase: SourceQueryPhase, + contract: SourceFacetQueryContract, + target: SourceScopeRef, + field_ids: tuple[SourceFieldId, ...], + ) -> None: + if not field_ids: + return + item_id = f"q{len(items) + 1:04d}" + items.append( + SourceSemanticQueryItem( + item_id=item_id, + phase=phase, + feature=contract.feature, + target=target, + fields=tuple( + SourceFieldRef(field=field_id, target=target) + for field_id in field_ids + ), + activation_any=( + contract.activation_any if phase is SourceQueryPhase.FACET else () + ), + required=(contract.required or phase is not SourceQueryPhase.FACET), + effect=contract.effect, + max_queries=contract.max_queries, + ) + ) + + for phase in ( + SourceQueryPhase.ANCHOR_BEFORE, + SourceQueryPhase.FACET, + SourceQueryPhase.ANCHOR_AFTER, + ): + for contract in query_contract.facets: + anchor_ids = tuple( + field for field in contract.fields if field in query_contract.anchor_fields + ) + facet_ids = tuple( + field for field in contract.fields if field not in query_contract.anchor_fields + ) + field_ids = anchor_ids if phase is not SourceQueryPhase.FACET else facet_ids + for target in _targets_for_contract(extensions, contract): + append_item( + phase=phase, + contract=contract, + target=target, + field_ids=field_ids, + ) + max_queries = sum(item.max_queries for item in items) + if max_queries > query_contract.max_queries: + raise SourceSnapshotContractError( + "expanded Source V2 query plan exceeds descriptor max_queries" + ) + return SourceSemanticQueryPlan( + contract_version=SOURCE_CONTRACT_VERSION, + plan_id=uuid4().hex, + items=tuple(items), + # The first accepted snapshot revision intentionally has no consuming reads. + allowed_effects=(SourceQueryEffect.PURE_READ,), + max_queries=query_contract.max_queries, + deadline_monotonic=context.deadline_monotonic, + ) + + +def build_source_snapshot( + *, + context: SourceSnapshotContext, + plan: SourceSemanticQueryPlan, + execution: SourceQueryExecutionRecord, + session_health_after: str, +) -> SourceSnapshotV2: + if time.monotonic() > plan.deadline_monotonic: + raise SourceSnapshotContractError("source snapshot query deadline was exceeded") + if session_health_after != "healthy": + raise SourceSnapshotContractError( + "source snapshot session health changed before validation completed" + ) + records = _validate_execution(plan, execution) + before = _phase_observations(plan, records, SourceQueryPhase.ANCHOR_BEFORE) + facet = _phase_observations(plan, records, SourceQueryPhase.FACET, anchors=before) + after = _phase_observations(plan, records, SourceQueryPhase.ANCHOR_AFTER) + + identity_ref = SourceFieldRef( + field=SourceFieldId.IDENTITY, + target=SourceScopeRef(SourceFacetScope.INSTRUMENT), + ) + identity_observed = before.get(identity_ref) + if identity_observed is None or identity_observed.availability is not Availability.VALUE: + raise SourceSnapshotContractError("source snapshot requires a readable runtime identity") + if not isinstance(identity_observed.value, SourceRuntimeIdentity): + raise SourceSnapshotContractError("source snapshot identity observation has an invalid type") + identity = identity_observed.value + runtime_features = _narrow_runtime_features( + context.descriptor_extensions.features, + identity, + ) + runtime_profile = SourceRuntimeCapabilityProfile( + session_epoch=context.session_epoch, + descriptor_digest=source_v2_digest(context.descriptor_extensions), + identity=identity, + features=runtime_features, + ) + + values = dict(before) + values.update(facet) + consistency = _build_consistency( + session_epoch=context.session_epoch, + before=before, + after=after, + device_revision_token_before=execution.device_revision_token_before, + device_revision_token_after=execution.device_revision_token_after, + ) + channels = tuple( + _channel_state( + channel, + values, + runtime_features, + ) + for channel in context.descriptor_extensions.topology.channels + ) + system = Observed.value_of( + _system_state( + context.descriptor_extensions, + values, + runtime_features, + ) + ) + cross_channel = Observed.value_of( + _cross_channel_state( + context.descriptor_extensions, + values, + runtime_features, + ) + ) + return SourceSnapshotV2( + snapshot_id=uuid4().hex, + context_id=context.context_id, + correlation_id=context.correlation_id, + captured_at_utc=source_snapshot_timestamp_utc(), + runtime_profile=runtime_profile, + channels=channels, + system=system, + cross_channel=cross_channel, + consistency=consistency, + plan_digest=source_v2_digest(plan), + query_count=execution.query_count, + session_health_before=context.session_health_before, + session_health_after=session_health_after, + ) + + +def _targets_for_contract( + extensions: SourceDescriptorExtensions, + contract: SourceFacetQueryContract, +) -> tuple[SourceScopeRef, ...]: + if contract.scope is SourceFacetScope.INSTRUMENT: + return (SourceScopeRef(SourceFacetScope.INSTRUMENT),) + if contract.scope is SourceFacetScope.INPUT: + return tuple( + SourceScopeRef(SourceFacetScope.INPUT, input_id=input_id) + for input_id in extensions.topology.input_ids + ) + matching = tuple( + feature + for feature in extensions.features + if feature.feature is contract.feature + and feature.scope is contract.scope + and feature.support.value == "supported" + ) + if contract.scope is SourceFacetScope.CHANNEL: + channels = sorted({channel for feature in matching for channel in feature.channels}) + return tuple( + SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + for channel in channels + ) + channel_sets = sorted({feature.channels for feature in matching}) + return tuple( + SourceScopeRef(SourceFacetScope.CHANNEL_SET, channels=channels) + for channels in channel_sets + ) + + +def _validate_execution( + plan: SourceSemanticQueryPlan, + execution: SourceQueryExecutionRecord, +) -> dict[str, object]: + if not isinstance(execution, SourceQueryExecutionRecord): + raise SourceSnapshotContractError( + "source driver returned an invalid query execution record" + ) + if execution.plan_id != plan.plan_id: + raise SourceSnapshotContractError("source query execution plan_id does not match") + if execution.query_count > plan.max_queries: + raise SourceSnapshotContractError("source query execution exceeded max_queries") + expected_ids = tuple(item.item_id for item in plan.items) + actual_ids = tuple(item.item_id for item in execution.items) + if actual_ids != expected_ids: + raise SourceSnapshotContractError( + "source query execution records do not match the semantic plan" + ) + records: dict[str, object] = {} + for item, record in zip(plan.items, execution.items, strict=True): + if record.effect is not item.effect or record.query_count > item.max_queries: + raise SourceSnapshotContractError("source query execution exceeded an item contract") + if record.outcome is SourceQueryItemOutcome.OBSERVED: + expected_fields = set(item.fields) + actual_fields = {observation.field for observation in record.observations} + if actual_fields != expected_fields: + raise SourceSnapshotContractError( + "source query execution observations do not match item fields" + ) + elif item.required: + raise SourceSnapshotContractError("a required Source V2 query item was not observed") + records[item.item_id] = record + return records + + +def _phase_observations( + plan: SourceSemanticQueryPlan, + records: dict[str, object], + phase: SourceQueryPhase, + *, + anchors: dict[SourceFieldRef, Observed[object]] | None = None, +) -> dict[SourceFieldRef, Observed[object]]: + values: dict[SourceFieldRef, Observed[object]] = {} + for item in plan.items: + if item.phase is not phase: + continue + record = records[item.item_id] + outcome = getattr(record, "outcome") + if outcome is SourceQueryItemOutcome.OBSERVED: + for observation in getattr(record, "observations"): + assert isinstance(observation, SourceTypedObservation) + values[observation.field] = Observed.value_of( + observation.value, + evidence_refs=observation.evidence_refs, + ) + continue + reason = getattr(record, "reason_code") + if outcome is SourceQueryItemOutcome.SEMANTIC_UNAVAILABLE: + availability = Availability.UNAVAILABLE + elif item.activation_any: + active = _activation_state(item.activation_any, item.target, anchors or {}) + if active is None: + raise SourceSnapshotContractError( + "source query item was skipped without proven activation state" + ) + availability = Availability.NOT_QUERIED if active else Availability.NOT_APPLICABLE + if active and reason is not SourceReasonCode.DRIVER_SKIPPED_OPTIONAL: + raise SourceSnapshotContractError( + "active optional Source V2 query item used an invalid skip reason" + ) + if not active and reason is not SourceReasonCode.INACTIVE_BY_ANCHOR: + raise SourceSnapshotContractError( + "inactive Source V2 query item used an invalid skip reason" + ) + else: + availability = Availability.NOT_QUERIED + for field in item.fields: + values[field] = Observed.missing(availability, reason) + return values + + +def _activation_state( + rules: tuple[SourceActivationRule, ...], + target: SourceScopeRef, + anchors: dict[SourceFieldRef, Observed[object]], +) -> bool | None: + unknown = False + for rule in rules: + matched = True + for predicate in rule.predicates: + actual = _anchor_predicate_value(predicate, target, anchors) + if actual is None: + unknown = True + matched = False + break + if actual != predicate.equals: + matched = False + break + if matched: + return True + return None if unknown else False + + +def _anchor_predicate_value( + predicate: SourceActivationPredicate, + target: SourceScopeRef, + anchors: dict[SourceFieldRef, Observed[object]], +) -> object | None: + mapping = { + SourceAnchorField.WAVEFORM_KIND: SourceFieldId.BASIC, + SourceAnchorField.FREQUENCY_MODE: SourceFieldId.BASIC, + SourceAnchorField.OUTPUT_ENABLED: SourceFieldId.OUTPUT, + SourceAnchorField.HARMONICS_ENABLED: SourceFieldId.HARMONICS, + SourceAnchorField.MODULATION_ENABLED: SourceFieldId.MODULATION, + SourceAnchorField.SWEEP_ENABLED: SourceFieldId.SWEEP, + SourceAnchorField.BURST_ENABLED: SourceFieldId.BURST, + SourceAnchorField.ARBITRARY_PLAYBACK_MODE: SourceFieldId.ARBITRARY_SELECTION, + SourceAnchorField.COMBINE_ENABLED: SourceFieldId.COMBINE, + SourceAnchorField.COUPLING_ENABLED: SourceFieldId.COUPLING, + SourceAnchorField.TRACKING_ENABLED: SourceFieldId.TRACKING, + } + field_id = mapping[predicate.field] + observed = anchors.get(SourceFieldRef(field=field_id, target=target)) + if observed is None or observed.availability is not Availability.VALUE: + return None + value = observed.value + if predicate.field is SourceAnchorField.WAVEFORM_KIND and isinstance(value, BasicWaveFacet): + return _observed_value(value.waveform_kind) + if predicate.field is SourceAnchorField.FREQUENCY_MODE and isinstance(value, BasicWaveFacet): + return _observed_value(value.frequency_mode) + if predicate.field is SourceAnchorField.OUTPUT_ENABLED and isinstance(value, OutputFacet): + return _observed_value(value.enabled) + if predicate.field is SourceAnchorField.HARMONICS_ENABLED and isinstance(value, HarmonicFacet): + return _observed_value(value.enabled) + if predicate.field is SourceAnchorField.MODULATION_ENABLED and isinstance(value, ModulationFacet): + return _observed_value(value.enabled) + if predicate.field is SourceAnchorField.SWEEP_ENABLED and isinstance(value, SweepFacet): + return _observed_value(value.enabled) + if predicate.field is SourceAnchorField.BURST_ENABLED and isinstance(value, BurstFacet): + return _observed_value(value.enabled) + if ( + predicate.field is SourceAnchorField.ARBITRARY_PLAYBACK_MODE + and isinstance(value, ArbitraryFacet) + ): + return _observed_value(value.playback_mode) + if isinstance(value, SourceRelationState): + return _observed_value(value.enabled) + return None + + +def _observed_value(observed: Observed[object]) -> object | None: + return observed.value if observed.availability is Availability.VALUE else None + + +def _narrow_runtime_features( + features: tuple[SourceFeatureCapability, ...], + identity: SourceRuntimeIdentity, +) -> tuple[SourceFeatureCapability, ...]: + narrowed = [] + identity_options = set(identity.option_ids) + for feature in features: + applicability = feature.applicability + if applicability.models and identity.model not in applicability.models: + continue + if applicability.firmware_ids and identity.firmware_id not in applicability.firmware_ids: + continue + if applicability.option_ids and not set(applicability.option_ids) <= identity_options: + continue + narrowed.append(feature) + return tuple(narrowed) + + +def _build_consistency( + *, + session_epoch: str, + before: dict[SourceFieldRef, Observed[object]], + after: dict[SourceFieldRef, Observed[object]], + device_revision_token_before: str | None, + device_revision_token_after: str | None, +) -> SourceSnapshotConsistency: + anchor_fields = tuple(sorted(before, key=_field_ref_key)) + before_payload = _anchor_payload(anchor_fields, before) + before_digest = source_v2_digest(before_payload) + public_token_before = ( + None + if device_revision_token_before is None + else source_v2_digest(device_revision_token_before) + ) + public_token_after = ( + None + if device_revision_token_after is None + else source_v2_digest(device_revision_token_after) + ) + if set(before) != set(after) or any( + observed.availability is not Availability.VALUE for observed in after.values() + ): + return SourceSnapshotConsistency( + state=SnapshotConsistencyState.UNPROVEN, + session_epoch=session_epoch, + anchor_fields=anchor_fields, + anchor_digest_before=before_digest, + anchor_digest_after=None, + device_revision_token_before=public_token_before, + device_revision_token_after=public_token_after, + reason_code=SourceReasonCode.CONSISTENCY_UNPROVEN, + ) + after_digest = source_v2_digest(_anchor_payload(anchor_fields, after)) + if (device_revision_token_before is None) != (device_revision_token_after is None): + return SourceSnapshotConsistency( + state=SnapshotConsistencyState.UNPROVEN, + session_epoch=session_epoch, + anchor_fields=anchor_fields, + anchor_digest_before=before_digest, + anchor_digest_after=after_digest, + device_revision_token_before=public_token_before, + device_revision_token_after=public_token_after, + reason_code=SourceReasonCode.CONSISTENCY_UNPROVEN, + ) + tokens_match = not ( + device_revision_token_before is not None + and device_revision_token_after is not None + and device_revision_token_before != device_revision_token_after + ) + if before_digest == after_digest and tokens_match: + state = SnapshotConsistencyState.CONSISTENT + reason = None + else: + state = SnapshotConsistencyState.DRIFTED + reason = SourceReasonCode.CONSISTENCY_DRIFTED + return SourceSnapshotConsistency( + state=state, + session_epoch=session_epoch, + anchor_fields=anchor_fields, + anchor_digest_before=before_digest, + anchor_digest_after=after_digest, + device_revision_token_before=public_token_before, + device_revision_token_after=public_token_after, + reason_code=reason, + ) + + +def _field_ref_key(value: SourceFieldRef) -> tuple[object, ...]: + target = value.target + return ( + value.field.value, + target.scope.value, + target.channel or 0, + target.channels, + target.input_id or "", + ) + + +def _anchor_payload( + anchor_fields: tuple[SourceFieldRef, ...], + values: dict[SourceFieldRef, Observed[object]], +) -> tuple[tuple[SourceFieldRef, object], ...]: + payload = [] + for field in anchor_fields: + observed = values[field] + if observed.availability is not Availability.VALUE: + raise SourceSnapshotContractError("source snapshot anchor was not observed") + payload.append((field, observed.value)) + return tuple(payload) + + +def _channel_state( + channel: int, + values: dict[SourceFieldRef, Observed[object]], + features: tuple[SourceFeatureCapability, ...], +) -> SourceChannelStateV2: + target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + return SourceChannelStateV2( + channel=channel, + basic=_field_value( + values, + SourceFieldRef(SourceFieldId.BASIC, target), + features, + SourceFeature.BASIC, + ), + output=_field_value( + values, + SourceFieldRef(SourceFieldId.OUTPUT, target), + features, + SourceFeature.OUTPUT, + ), + harmonics=_field_value( + values, + SourceFieldRef(SourceFieldId.HARMONICS, target), + features, + SourceFeature.HARMONICS, + ), + modulation=_field_value( + values, + SourceFieldRef(SourceFieldId.MODULATION, target), + features, + SourceFeature.MODULATION, + ), + sweep=_field_value( + values, + SourceFieldRef(SourceFieldId.SWEEP, target), + features, + SourceFeature.SWEEP, + ), + burst=_field_value( + values, + SourceFieldRef(SourceFieldId.BURST, target), + features, + SourceFeature.BURST, + ), + pulse=_field_value( + values, + SourceFieldRef(SourceFieldId.PULSE, target), + features, + SourceFeature.PULSE, + ), + arbitrary=_field_value( + values, + SourceFieldRef(SourceFieldId.ARBITRARY_SELECTION, target), + features, + SourceFeature.ARBITRARY, + ), + ) + + +def _system_state( + extensions: SourceDescriptorExtensions, + values: dict[SourceFieldRef, Observed[object]], + features: tuple[SourceFeatureCapability, ...], +) -> SourceSystemStateV2: + counters = [] + for input_id in extensions.topology.input_ids: + target = SourceScopeRef(SourceFacetScope.INPUT, input_id=input_id) + observed = _field_value( + values, + SourceFieldRef(SourceFieldId.COUNTER, target), + features, + SourceFeature.COUNTER, + ) + if observed.availability is Availability.VALUE: + counters.append(observed.value) + else: + counters.append(_missing_counter_input_state(input_id, observed)) + instrument = SourceScopeRef(SourceFacetScope.INSTRUMENT) + return SourceSystemStateV2( + counters=tuple(counters), + reference_clock=_field_value( + values, + SourceFieldRef(SourceFieldId.REFERENCE_CLOCK, instrument), + features, + SourceFeature.REFERENCE_CLOCK, + ), + sync=_field_value( + values, + SourceFieldRef(SourceFieldId.SYNC, instrument), + features, + SourceFeature.SYNC, + ), + cascade=_field_value( + values, + SourceFieldRef(SourceFieldId.CASCADE, instrument), + features, + SourceFeature.CASCADE, + ), + ) + + +def _cross_channel_state( + extensions: SourceDescriptorExtensions, + values: dict[SourceFieldRef, Observed[object]], + features: tuple[SourceFeatureCapability, ...], +) -> SourceCrossChannelStateV2: + relations: list[SourceRelationState] = [] + relation_fields = { + SourceFeature.COMBINE: SourceFieldId.COMBINE, + SourceFeature.TRACKING: SourceFieldId.TRACKING, + SourceFeature.COUPLING: SourceFieldId.COUPLING, + SourceFeature.COPY: SourceFieldId.COPY, + SourceFeature.PHASE_RELATION: SourceFieldId.PHASE_RELATION, + } + for feature in features: + field_id = relation_fields.get(feature.feature) + if field_id is None or feature.scope is not SourceFacetScope.CHANNEL_SET: + continue + target = SourceScopeRef(SourceFacetScope.CHANNEL_SET, channels=feature.channels) + observed = _field_value( + values, + SourceFieldRef(field_id, target), + features, + feature.feature, + ) + if observed.availability is Availability.VALUE: + relations.append(observed.value) + else: + relations.append( + SourceRelationState( + feature=feature.feature, + channels=feature.channels, + enabled=observed, + ) + ) + relations.sort(key=lambda item: (item.feature.value, item.channels)) + instrument = SourceScopeRef(SourceFacetScope.INSTRUMENT) + graph_ref = SourceFieldRef(SourceFieldId.RELATION_GRAPH, instrument) + graph = values.get(graph_ref) + if graph is None: + graph = Observed.missing( + Availability.NOT_QUERIED, + SourceReasonCode.NOT_REQUESTED, + ) + shared_power = _field_value( + values, + SourceFieldRef(SourceFieldId.SHARED_POWER, instrument), + features, + SourceFeature.SHARED_POWER, + ) + return SourceCrossChannelStateV2( + relations=tuple(relations), + relation_graph=graph, + shared_power=shared_power, + ) + + +def _missing_counter_input_state( + input_id: str, + observed: Observed[object], +) -> SourceCounterInputState: + missing = Observed( + availability=observed.availability, + reason_code=observed.reason_code, + evidence_refs=observed.evidence_refs, + ) + return SourceCounterInputState( + input_id=input_id, + enabled=missing, + measurements=missing, + coupling=missing, + impedance_ohm=missing, + attenuation=missing, + gate_time_s=missing, + trigger_level_v=missing, + statistics_enabled=missing, + ) + + +def _field_value( + values: dict[SourceFieldRef, Observed[object]], + field_ref: SourceFieldRef, + features: tuple[SourceFeatureCapability, ...], + feature_kind: SourceFeature, +) -> Observed[object]: + matching = tuple( + feature + for feature in features + if feature.feature is feature_kind + and feature.scope is field_ref.target.scope + and ( + field_ref.target.scope not in { + SourceFacetScope.CHANNEL, + SourceFacetScope.CHANNEL_SET, + } + or feature.channels + == ( + (field_ref.target.channel,) + if field_ref.target.scope is SourceFacetScope.CHANNEL + else field_ref.target.channels + ) + ) + ) + if not matching or all(feature.support.value == "unsupported" for feature in matching): + return Observed.missing( + Availability.UNSUPPORTED, + SourceReasonCode.DESCRIPTOR_UNSUPPORTED, + ) + if all(feature.support.value == "unknown" for feature in matching): + return Observed.missing( + Availability.UNKNOWN, + SourceReasonCode.SUPPORT_UNKNOWN, + ) + observed = values.get(field_ref) + if observed is not None: + return observed + return Observed.missing( + Availability.NOT_QUERIED, + SourceReasonCode.NOT_REQUESTED, + ) + + +__all__ = [ + "SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS", + "SourceSnapshotContext", + "SourceSnapshotContractError", + "build_source_snapshot", + "build_source_snapshot_plan", + "new_source_snapshot_context", +] diff --git a/tests/source_v2_fixtures.py b/tests/source_v2_fixtures.py new file mode 100644 index 0000000..c9a338e --- /dev/null +++ b/tests/source_v2_fixtures.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from dataclasses import replace + +from wavebench.instruments import InstrumentDescriptor +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + Availability, + BasicWaveFacet, + Observed, + OutputFacet, + SourceAmplitude, + SourceAmplitudeUnit, + SourceBasicCapabilityProfile, + SourceConstraintApplicability, + SourceDescriptorExtensions, + SourceFacetQueryContract, + SourceFacetScope, + SourceFeature, + SourceFeatureCapability, + SourceFeatureDirection, + SourceFieldId, + SourceFrequencyMode, + SourceOutputCapabilityProfile, + SourceOutputPolarity, + SourceActivationPredicate, + SourceActivationRule, + SourceAnchorField, + SourceHarmonicCapabilityProfile, + ComponentAmplitudeKind, + HarmonicCompleteness, + SourceProtocolQueryRecord, + SourceQueryContract, + SourceQueryEffect, + SourceQueryExecutionRecord, + SourceQueryItemOutcome, + SourceReasonCode, + SourceRuntimeIdentity, + SourceSafetyProfile, + SourceTopologyContract, + SourceTypedObservation, + SourceWaveformKind, + SupportState, +) + + +def missing() -> Observed[object]: + return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) + + +def basic_facet( + *, + frequency_hz: float = 1000.0, + waveform_unknown: bool = False, +) -> BasicWaveFacet: + return BasicWaveFacet( + waveform_kind=(missing() if waveform_unknown else Observed.value_of(SourceWaveformKind.SINE)), + waveform_id=Observed.value_of("sine"), + frequency_mode=Observed.value_of(SourceFrequencyMode.FIXED), + frequency_hz=Observed.value_of(frequency_hz), + amplitude=Observed.value_of(SourceAmplitude(1.0, SourceAmplitudeUnit.VPP)), + offset_v=Observed.value_of(0.0), + phase_deg=Observed.value_of(0.0), + square_duty_cycle_percent=missing(), + ) + + +def output_facet(*, enabled: bool = False) -> OutputFacet: + return OutputFacet( + enabled=Observed.value_of(enabled), + display_load=missing(), + polarity=Observed.value_of(SourceOutputPolarity.NORMAL), + ) + + +def source_extensions() -> SourceDescriptorExtensions: + applicability = SourceConstraintApplicability() + features = ( + SourceFeatureCapability( + feature=SourceFeature.BASIC, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(1,), + applicability=applicability, + profile=SourceBasicCapabilityProfile( + waveform_kinds=(SourceWaveformKind.SINE,), + frequency_modes=(SourceFrequencyMode.FIXED,), + amplitude_units=(SourceAmplitudeUnit.VPP,), + offset_readable=True, + phase_readable=True, + square_duty_readable=False, + ), + evidence_refs=("evidence.basic",), + ), + SourceFeatureCapability( + feature=SourceFeature.OUTPUT, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(1,), + applicability=applicability, + profile=SourceOutputCapabilityProfile( + output_readable=True, + display_load_readable=False, + polarity_readable=True, + ), + evidence_refs=("evidence.output",), + ), + ) + query_contract = SourceQueryContract( + anchor_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + facets=( + SourceFacetQueryContract( + feature=SourceFeature.BASIC, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.BASIC,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + SourceFacetQueryContract( + feature=SourceFeature.BASIC, + scope=SourceFacetScope.INSTRUMENT, + fields=(SourceFieldId.IDENTITY,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + SourceFacetQueryContract( + feature=SourceFeature.OUTPUT, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.OUTPUT,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + ), + max_queries=6, + timeout_ms=2000, + ) + return SourceDescriptorExtensions( + contract_version=SOURCE_CONTRACT_VERSION, + topology=SourceTopologyContract((1,)), + features=features, + query_contract=query_contract, + safety_profile=SourceSafetyProfile(), + ) + + +def source_extensions_with_harmonics() -> SourceDescriptorExtensions: + base = source_extensions() + harmonic = SourceFeatureCapability( + feature=SourceFeature.HARMONICS, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(1,), + applicability=SourceConstraintApplicability(), + profile=SourceHarmonicCapabilityProfile( + minimum_order=2, + maximum_order=16, + amplitude_kinds=(ComponentAmplitudeKind.ABSOLUTE_VPP,), + completeness_modes=(HarmonicCompleteness.PARTIAL,), + ), + ) + harmonic_query = SourceFacetQueryContract( + feature=SourceFeature.HARMONICS, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.HARMONICS,), + activation_any=( + SourceActivationRule( + ( + SourceActivationPredicate( + SourceAnchorField.WAVEFORM_KIND, + SourceWaveformKind.SQUARE, + ), + ) + ), + ), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=False, + ) + return replace( + base, + features=(base.features[0], harmonic, base.features[1]), + query_contract=replace( + base.query_contract, + facets=( + base.query_contract.facets[0], + base.query_contract.facets[1], + harmonic_query, + base.query_contract.facets[2], + ), + max_queries=7, + ), + ) + + +def source_descriptor( + *, + driver: object | None = None, + extensions: SourceDescriptorExtensions | None = None, +) -> InstrumentDescriptor: + if extensions is None: + extensions = source_extensions() + return InstrumentDescriptor( + driver_id="example.source-v2", + kind="source", + display_name="Example Source V2", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=("source.snapshot_v2",), + idn_patterns=("EXAMPLE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: driver if driver is not None else object(), + wavebench_min_version="0.8.24", + wavebench_max_version="0.9.0", + source_extensions=extensions, + ) + + +class SourceV2FakeDriver: + def __init__( + self, + *, + combined: bool, + drift: bool = False, + harmonic_unavailable: bool = False, + anchor_unknown: bool = False, + ) -> None: + self.combined = combined + self.drift = drift + self.harmonic_unavailable = harmonic_unavailable + self.anchor_unknown = anchor_unknown + self.plans = [] + self.closed = False + + def close(self) -> None: + self.closed = True + + def execute_source_query_plan_v2(self, plan): + self.plans.append(plan) + records = [] + for index, item in enumerate(plan.items): + if item.feature is SourceFeature.HARMONICS: + records.append( + SourceProtocolQueryRecord( + item_id=item.item_id, + effect=item.effect, + outcome=( + SourceQueryItemOutcome.SEMANTIC_UNAVAILABLE + if self.harmonic_unavailable + else SourceQueryItemOutcome.SKIPPED + ), + query_count=(1 if self.harmonic_unavailable else 0), + reason_code=( + SourceReasonCode.RESPONSE_MISSING_FIELD + if self.harmonic_unavailable + else SourceReasonCode.INACTIVE_BY_ANCHOR + ), + ) + ) + continue + observations = [] + for field in item.fields: + if field.field is SourceFieldId.IDENTITY: + value = SourceRuntimeIdentity( + manufacturer="Example", + model="EX1", + firmware_id="1.0", + ) + elif field.field is SourceFieldId.BASIC: + value = basic_facet(waveform_unknown=self.anchor_unknown) + elif field.field is SourceFieldId.OUTPUT: + value = output_facet( + enabled=(self.drift and item.phase.value == "anchor_after") + ) + else: + raise AssertionError(field) + observations.append(SourceTypedObservation(field, value)) + records.append( + SourceProtocolQueryRecord( + item_id=item.item_id, + effect=item.effect, + outcome=SourceQueryItemOutcome.OBSERVED, + query_count=(1 if not self.combined or index == 0 else 0), + observations=tuple(observations), + ) + ) + query_count = 1 if self.combined else len(records) + return SourceQueryExecutionRecord( + contract_version=SOURCE_CONTRACT_VERSION, + plan_id=plan.plan_id, + items=tuple(records), + query_count=query_count, + device_revision_token_before="revision-1", + device_revision_token_after="revision-1", + ) + + +def with_min_version( + descriptor: InstrumentDescriptor, + minimum: str, +) -> InstrumentDescriptor: + return replace(descriptor, wavebench_min_version=minimum) diff --git a/tests/test_scope_extension_registry.py b/tests/test_scope_extension_registry.py index 038e4bc..71e6804 100644 --- a/tests/test_scope_extension_registry.py +++ b/tests/test_scope_extension_registry.py @@ -100,7 +100,12 @@ def test_public_scope_capability_requires_new_core_floor() -> None: 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"] + assert names[-4:] == [ + "config_fields", + "resource_schemes", + "scope_extensions", + "source_extensions", + ] def test_new_old_core_plugin_capability_matrix_is_fail_closed() -> None: diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py new file mode 100644 index 0000000..bc3ee9d --- /dev/null +++ b/tests/test_source_extensions.py @@ -0,0 +1,584 @@ +from __future__ import annotations + +from dataclasses import fields, replace +from math import nan +from pathlib import Path +import re + +import pytest + +import wavebench.instruments as public +from wavebench.errors import ConfigError +from wavebench.instruments import InstrumentDescriptor +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.source_extension_capabilities import ( + SOURCE_EXTENSION_CAPABILITY_METHODS, + validate_source_descriptor, + validate_source_plugin_dependencies, +) +from wavebench.instruments import source_extensions as module +from wavebench.instruments.registry import InstrumentRegistry +from wavebench.instruments.factory import open_instrument_driver +from wavebench.logging import CommandLogger +from wavebench.instruments.source_extensions import ( + Availability, + Observed, + SourceFacetScope, + SourceFieldId, + SourceReasonCode, + SupportState, + source_v2_canonical_json, +) + +from tests.source_v2_fixtures import ( + SourceV2FakeDriver, + source_descriptor, + source_extensions, +) + + +def test_source_public_exports_are_explicit_and_preserve_identity() -> None: + assert module.__all__ + assert len(module.__all__) == len(set(module.__all__)) + assert all(getattr(public, name) is getattr(module, name) for name in module.__all__) + assert "SourceSnapshotContext" not in module.__all__ + assert "SourceSnapshotContractError" not in module.__all__ + + rfc = Path( + "docs/project/rfcs/WaveBench_source能力状态与复合输出安全RFC.md" + ).read_text(encoding="utf-8") + match = re.search(r"R5 的精确清单为:\n\n```text\n(.*?)\n```", rfc, re.S) + assert match is not None + assert match.group(1).splitlines() == module.__all__ + + +def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None: + missing = Observed.missing( + Availability.NOT_QUERIED, + SourceReasonCode.NOT_REQUESTED, + ) + assert missing.value is None + assert '"availability":"not_queried"' in source_v2_canonical_json(missing) + assert '"reason_code":"not_requested"' in source_v2_canonical_json(missing) + + with pytest.raises(ValueError, match="non-finite"): + Observed.value_of(nan) + with pytest.raises(ValueError, match="reason_code"): + Observed(Availability.UNAVAILABLE) + + evidence = "dist-info:wavebench-source-conformance/manifest.json" + assert Observed.value_of(1.0, evidence_refs=(evidence,)).evidence_refs == (evidence,) + with pytest.raises(ValueError, match="safe token"): + Observed.value_of(1.0, evidence_refs=("/tmp/private.json",)) + + +def test_resistance_bounds_require_two_finite_positive_limits() -> None: + assert module.ResistanceBounds(49.5, 50.5).maximum_ohm == 50.5 + + with pytest.raises(ValueError, match="finite"): + module.ResistanceBounds(50.0, float("inf")) + with pytest.raises(ValueError, match="must be >="): + module.ResistanceBounds(50.0, 49.5) + + +def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: + expected = { + "SourceBasicCapabilityProfile": ( + "waveform_kinds", + "frequency_modes", + "amplitude_units", + "offset_readable", + "phase_readable", + "square_duty_readable", + ), + "SourceOutputCapabilityProfile": ( + "output_readable", + "display_load_readable", + "polarity_readable", + ), + "SourceHarmonicCapabilityProfile": ( + "minimum_order", + "maximum_order", + "amplitude_kinds", + "completeness_modes", + ), + "SourceModulationCapabilityProfile": ( + "kinds", + "sources", + "parameter_kinds", + "inactive_readable", + ), + "SourceSweepCapabilityProfile": ( + "spacing_modes", + "trigger_sources", + "timing_readable", + "marker_readable", + ), + "SourceBurstCapabilityProfile": ( + "modes", + "trigger_sources", + "timing_readable", + "gate_readable", + ), + "SourcePulseCapabilityProfile": ( + "hold_modes", + "delay_readable", + "transitions_readable", + ), + "SourceArbitraryCapabilityProfile": ( + "playback_modes", + "selection_readable", + "storage_metadata_readable", + "sample_rate_readable", + ), + "SourceCounterCapabilityProfile": ( + "input_ids", + "measurement_kinds", + "configuration_readable", + "query_effect", + ), + "SourceClockSyncCapabilityProfile": ( + "reference_clock_modes", + "sync_readable", + "cascade_readable", + ), + "SourceCrossChannelCapabilityProfile": ( + "relation_kinds", + "supported_channel_sets", + "relation_graph_readable", + "shared_power_constraint_readable", + ), + "ResistanceBounds": ("minimum_ohm", "maximum_ohm"), + "PortVoltageBounds": ( + "minimum_v_lower", + "maximum_v_upper", + "vpp_upper_v", + "absolute_peak_upper_v", + "rms_upper_v", + ), + "SafetyContributor": ( + "contributor_id", + "feature", + "channels", + "minimum_v", + "maximum_v", + "constraint_ids", + "proof_strength", + "evidence_sources", + ), + "SourceSharedPowerBudget": ( + "participants", + "observed_active_power_upper_w", + "projected_power_upper_w", + "effective_hard_limit_w", + "constraint_ids", + "evidence_sources", + ), + "CompositeOutputBudget": ( + "bounds", + "voltage_reference_basis", + "display_load", + "output_source_resistance", + "actual_termination", + "shared_power", + "proof_strength", + "evidence_sources", + "contributors", + "blockers", + ), + "SourceOperationContract": ( + "operation", + "capability", + "feature", + "direction", + "energy_effect", + "storage_effect", + "required_fields", + "changed_fields", + "postcondition_fields", + "cleanup_verification_fields", + "v1_equivalent_routes", + "v1_overlapping_routes", + "operation_timeout_ms", + "main_max_steps", + "recovery_max_steps", + "verification_max_steps", + ), + "SourceAffectedClosure": ( + "operation", + "context_id", + "session_epoch", + "baseline_snapshot_digest", + "fields", + "required_off_outputs", + "emergency_off_outputs", + "restore_order", + "non_restorable_fields", + "closure_digest", + ), + "SourceVoltageReferenceConstraint": ("basis",), + "SourceResistanceConstraint": ("resistance_ohm",), + "SourceFrequencyDeratingBand": ("frequency_hz", "gain_upper"), + "SourceFrequencyDeratingConstraint": ("bands",), + "SourceModulationEnvelopeConstraint": ("kind", "gain_upper"), + "SourceArbitraryOvershootConstraint": ("gain_upper",), + "SourceNoisePeakConstraint": ("absolute_peak_upper_v",), + "SourceSharedPowerConstraint": ("participants", "maximum_power_w"), + "SourceSafetyConstraint": ( + "constraint_id", + "kind", + "applicability", + "profile", + "proof_strength", + "evidence_refs", + ), + "SourceSafetyProfile": ("constraints",), + "TerminationSpec": ("kind", "resistance_bounds"), + "SourceTerminationEvidence": ( + "target", + "termination", + "source", + "lifetime", + "resource_fingerprint", + "binding_digest", + "observed_at_utc", + "expires_at_utc", + "evidence_ref", + ), + "BasicWaveFacet": ( + "waveform_kind", + "waveform_id", + "frequency_mode", + "frequency_hz", + "amplitude", + "offset_v", + "phase_deg", + "square_duty_cycle_percent", + ), + "OutputFacet": ("enabled", "display_load", "polarity"), + "HarmonicFacet": ( + "enabled", + "completeness", + "maximum_supported_order", + "components", + ), + "ModulationFacet": ( + "enabled", + "kind", + "source", + "parameters", + "internal_frequency_hz", + "internal_waveform_kind", + ), + "SweepFacet": ( + "enabled", + "start_hz", + "stop_hz", + "spacing", + "steps", + "sweep_time_s", + "start_hold_s", + "stop_hold_s", + "return_time_s", + "trigger", + "marker", + ), + "BurstFacet": ( + "enabled", + "mode", + "cycles", + "phase_deg", + "internal_period_s", + "delay_s", + "gate_polarity", + "trigger", + ), + "PulseFacet": ( + "hold_basis", + "width_s", + "duty_cycle_percent", + "delay_s", + "leading_transition_s", + "trailing_transition_s", + ), + "ArbitraryFacet": ( + "selected_waveform_id", + "playback_mode", + "playback_frequency_hz", + "sample_rate_hz", + "point_count", + "storage_digest", + ), + "SourceSystemStateV2": ( + "counters", + "reference_clock", + "sync", + "cascade", + ), + "SourceCrossChannelStateV2": ( + "relations", + "relation_graph", + "shared_power", + ), + } + actual = { + name: tuple(item.name for item in fields(getattr(module, name))) + for name in expected + } + assert actual == expected + + +def test_source_descriptor_append_only_and_replace_compatible() -> None: + descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) + names = [item.name for item in fields(InstrumentDescriptor)] + + assert names[-4:] == [ + "config_fields", + "resource_schemes", + "scope_extensions", + "source_extensions", + ] + assert replace(descriptor, summary="changed").source_extensions is descriptor.source_extensions + + legacy = InstrumentDescriptor( + driver_id="legacy.source", + kind="source", + display_name="Legacy Source", + manufacturer="Example", + models=("OLD1",), + aliases=(), + capabilities=("source.status",), + idn_patterns=("EXAMPLE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: object(), + ) + legacy_values = [getattr(legacy, item.name) for item in fields(InstrumentDescriptor)[:-1]] + reconstructed = InstrumentDescriptor(*legacy_values) + assert reconstructed == legacy + assert reconstructed.source_extensions is None + + +def test_source_snapshot_capability_is_additive_and_validated() -> None: + descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) + assert CAPABILITY_METHODS["source.snapshot_v2"] == ("execute_source_query_plan_v2",) + assert dict(SOURCE_EXTENSION_CAPABILITY_METHODS) == { + "source.snapshot_v2": ("execute_source_query_plan_v2",) + } + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, SourceV2FakeDriver(combined=True)) + + with pytest.raises(TypeError, match="execute_source_query_plan_v2"): + validate_declared_capabilities(descriptor, type("Driver", (), {"close": lambda self: None})()) + + +def test_source_v2_rejects_invalid_feature_scope_and_query_field_ownership() -> None: + extensions = source_extensions() + basic = extensions.features[0] + with pytest.raises(ValueError, match="cannot use scope"): + replace( + basic, + scope=SourceFacetScope.INSTRUMENT, + channels=(), + ) + + basic_query = extensions.query_contract.facets[0] + invalid_query = replace( + basic_query, + fields=(SourceFieldId.BASIC, SourceFieldId.OUTPUT), + ) + invalid_extensions = replace( + extensions, + query_contract=replace( + extensions.query_contract, + facets=(invalid_query, *extensions.query_contract.facets[1:]), + ), + ) + with pytest.raises(ConfigError, match="does not belong"): + validate_source_descriptor(source_descriptor(extensions=invalid_extensions)) + + identity_query = replace( + extensions.query_contract.facets[1], + feature=extensions.features[-1].feature, + ) + invalid_identity = replace( + extensions, + query_contract=replace( + extensions.query_contract, + facets=( + extensions.query_contract.facets[0], + extensions.query_contract.facets[2], + identity_query, + ), + ), + ) + with pytest.raises(ConfigError, match="identity facet"): + validate_source_descriptor(source_descriptor(extensions=invalid_identity)) + + +def test_source_v2_query_contract_cannot_probe_non_supported_feature() -> None: + extensions = source_extensions() + unsupported_output = replace( + extensions.features[-1], + support=SupportState.UNSUPPORTED, + directions=(), + ) + invalid_extensions = replace( + extensions, + features=(*extensions.features[:-1], unsupported_output), + ) + + with pytest.raises(ConfigError, match="cannot probe"): + validate_source_descriptor(source_descriptor(extensions=invalid_extensions)) + + +def test_source_v1_capability_mapping_is_unchanged() -> None: + expected = { + "source.idn": ("idn",), + "source.errors": ("errors", "assert_no_errors"), + "source.status": ("get_status",), + "source.channel_profile": ("get_channel_profile",), + "source.coupling_profile": ("get_coupling_profile",), + "source.coupling_configure": ("configure_coupling",), + "source.harmonic_profile": ("get_harmonic_profile",), + "source.harmonic_configure": ("configure_harmonics",), + "source.modulation_am_profile": ("get_am_modulation_profile",), + "source.modulation_am_configure": ("configure_am_modulation",), + "source.modulation_fm_profile": ("get_fm_modulation_profile",), + "source.modulation_fm_configure": ("configure_fm_modulation",), + "source.modulation_pm_profile": ("get_pm_modulation_profile",), + "source.modulation_pm_configure": ("configure_pm_modulation",), + "source.modulation_pwm_profile": ("get_pwm_modulation_profile",), + "source.modulation_pwm_configure": ("configure_pwm_modulation",), + "source.pulse_profile": ("get_pulse_profile",), + "source.pulse_configure": ("configure_pulse",), + "source.burst_profile": ("get_burst_profile",), + "source.burst_configure": ("configure_burst",), + "source.burst_trigger": ("trigger_burst",), + "source.sweep_profile": ("get_sweep_profile",), + "source.sweep_configure": ("configure_sweep",), + "source.sweep_trigger": ("trigger_sweep",), + "source.counter_profile": ("get_counter_profile",), + "source.set_frequency": ("set_frequency",), + "source.set_function": ("set_function",), + "source.set_amplitude_vpp": ("set_amplitude_vpp",), + "source.set_square_duty_cycle": ("set_square_duty_cycle",), + "source.output": ("set_output",), + "source.arbitrary_probe": ("probe_arbitrary_queries",), + "source.arbitrary_upload": ("upload_dg4000_dac14_block",), + } + actual = { + key: value + for key, value in CAPABILITY_METHODS.items() + if key.startswith("source.") and key != "source.snapshot_v2" + } + assert actual == expected + + +def test_source_v2_missing_method_closes_eager_factory_transport(monkeypatch) -> None: + closed = {"driver": False, "transport": False} + + class InnerTransport: + def close(self) -> None: + closed["transport"] = True + + class MissingDriver: + def close(self) -> None: + closed["driver"] = True + + def factory(context): + context.open_transport() + return MissingDriver() + + descriptor = replace(source_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda reference, expected_kind: descriptor, + ) + monkeypatch.setattr( + "wavebench.instruments.factory.PyVisaTransport.open", + lambda connection, logger: InnerTransport(), + ) + + with pytest.raises(ConfigError, match="execute_source_query_plan_v2"): + open_instrument_driver( + driver_reference="example.source-v2", + expected_kind="source", + resource="configured-resource", + configured_backend="lan", + timeout_ms=1000, + opc_timeout_ms=1000, + read_retry_attempts=0, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + assert closed == {"driver": True, "transport": True} + + +def test_source_snapshot_descriptor_requires_pep440_floor() -> None: + descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) + with pytest.raises(ConfigError, match="0.8.24"): + validate_source_descriptor(replace(descriptor, wavebench_min_version="0.8.23")) + with pytest.raises(ConfigError, match="PEP 440"): + validate_source_descriptor(replace(descriptor, wavebench_min_version="not-a-version")) + with pytest.raises(ConfigError, match="min < max"): + validate_source_descriptor( + replace( + descriptor, + wavebench_min_version="0.9.0", + wavebench_max_version="0.9.0", + ) + ) + + +def test_source_v2_wheel_dependency_must_match_descriptor_interval() -> None: + descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) + + validate_source_plugin_dependencies( + descriptor, + ("wavebench>=0.8.24,<0.9",), + ) + validate_source_plugin_dependencies( + descriptor, + ( + "wavebench>=0.8.24,<0.9,!=0.8.25", + 'wavebench>=99; python_version < "3.0"', + ), + ) + + with pytest.raises(ConfigError, match="explicitly include >=0.8.24,<0.9.0"): + validate_source_plugin_dependencies(descriptor, ("wavebench>=0.8,<0.9",)) + with pytest.raises(ConfigError, match="explicitly include >=0.8.24,<0.9.0"): + validate_source_plugin_dependencies(descriptor, ("wavebench>=0.8.24,<1.0",)) + with pytest.raises(ConfigError, match="expands or excludes"): + validate_source_plugin_dependencies( + descriptor, + ("wavebench>=0.8.24,<0.9,!=0.8.24",), + ) + with pytest.raises(ConfigError, match="exactly one active"): + validate_source_plugin_dependencies( + descriptor, + ('wavebench>=0.8.24,<0.9; python_version < "3.0"',), + ) + with pytest.raises(ConfigError, match="exactly one"): + validate_source_plugin_dependencies( + descriptor, + ("wavebench>=0.8.24,<0.9", "wavebench>=0.8.24,<0.9"), + ) + with pytest.raises(ConfigError, match="invalid Requires-Dist"): + validate_source_plugin_dependencies(descriptor, ("wavebench=>not-a-version",)) + + +def test_registry_rejects_invalid_source_v2_descriptor_before_factory() -> None: + calls = [] + descriptor = replace( + source_descriptor(driver=SourceV2FakeDriver(combined=True)), + factory=lambda context: calls.append(context), + wavebench_min_version="0.8.23", + ) + registry = InstrumentRegistry(builtins=(descriptor,)) + + with pytest.raises(ConfigError, match="0.8.24"): + registry.resolve("example.source-v2", expected_kind="source") + assert calls == [] diff --git a/tests/test_source_snapshot_v2.py b/tests/test_source_snapshot_v2.py new file mode 100644 index 0000000..ee9e256 --- /dev/null +++ b/tests/test_source_snapshot_v2.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +from dataclasses import replace +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from wavebench import cli +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + ScopeConfig, + SourceConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.logging import CommandLogger +from wavebench.services.operation_specs import require_operation_spec +from wavebench.services.source_service import SourceService +from wavebench.services.source_snapshot_v2 import SourceSnapshotContractError +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.models import SourceStatus +from wavebench.instruments.registry import InstrumentRegistry +from wavebench.instruments import InstrumentDescriptor +from wavebench.instruments.source_extensions import ( + SOURCE_OPERATION_ARTIFACT_SCHEMA, + SOURCE_SNAPSHOT_SCHEMA, + SnapshotConsistencyState, + SourceQueryExecutionRecord, + SourceCrossChannelCapabilityProfile, + SourceFacetScope, + SourceFeature, + SourceFeatureCapability, + SourceTopologyContract, + SupportState, + source_snapshot_v2_operation_artifact, +) +from wavebench.transport.session import InstrumentSessionState +from wavebench.transport.session import SessionHealth +from wavebench.errors import ConfigError, TransportIOError +from wavebench.transport.contracts import ( + CommandTransmission, + ReplayPolicy, + ResponseProgress, + Synchronization, + TransportPhase, +) + +from tests.source_v2_fixtures import ( + SourceV2FakeDriver, + source_descriptor, + source_extensions_with_harmonics, +) +from wavebench.instruments.source_extensions import SourceConstraintApplicability + + +def make_config() -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1000, 1000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "DMAX"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("wavebench.toml"), + source=SourceConfig( + "example.source-v2", + "TCPIP::source::INSTR", + 1, + True, + True, + 0, + ), + ) + + +def make_service( + driver: SourceV2FakeDriver, + *, + session_health: SessionHealth = SessionHealth.HEALTHY, +) -> SourceService: + return SourceService( + config=make_config(), + logger=CommandLogger(), + session=driver, # type: ignore[arg-type] + descriptor=source_descriptor(driver=driver), + session_state=InstrumentSessionState( + epoch_id="epoch-source-v2", + health=session_health, + ), + ) + + +@pytest.mark.parametrize(("combined", "query_count"), [(True, 1), (False, 6)]) +def test_snapshot_v2_accepts_combined_and_scalar_protocol_plans( + combined: bool, + query_count: int, +) -> None: + driver = SourceV2FakeDriver(combined=combined) + snapshot = make_service(driver).snapshot_v2(correlation_id="test-correlation") + + assert snapshot.consistency.state is SnapshotConsistencyState.CONSISTENT + assert snapshot.query_count == query_count + assert snapshot.runtime_profile.identity.model == "EX1" + assert snapshot.channels[0].basic.value.frequency_hz.value == 1000.0 + assert snapshot.channels[0].output.value.enabled.value is False + assert snapshot.correlation_id == "test-correlation" + assert len(driver.plans) == 1 + assert driver.plans[0].allowed_effects[0].value == "pure_read" + + +def test_snapshot_v2_runtime_identity_can_only_narrow_descriptor_features() -> None: + extensions = source_extensions_with_harmonics() + narrowed_output = replace( + extensions.features[-1], + applicability=SourceConstraintApplicability(firmware_ids=("2.0",)), + ) + extensions = replace( + extensions, + features=(*extensions.features[:-1], narrowed_output), + ) + driver = SourceV2FakeDriver(combined=True) + service = make_service(driver) + service.descriptor = source_descriptor(driver=driver, extensions=extensions) + + snapshot = service.snapshot_v2() + assert all( + feature.feature.value != "output" for feature in snapshot.runtime_profile.features + ) + assert snapshot.channels[0].output.availability.value == "unsupported" + + +def test_snapshot_v2_preserves_declared_input_and_relation_placeholders() -> None: + extensions = source_extensions_with_harmonics() + relation = SourceFeatureCapability( + feature=SourceFeature.COMBINE, + support=SupportState.UNSUPPORTED, + directions=(), + scope=SourceFacetScope.CHANNEL_SET, + channels=(1, 2), + applicability=SourceConstraintApplicability(), + profile=SourceCrossChannelCapabilityProfile( + relation_kinds=(SourceFeature.COMBINE,), + supported_channel_sets=((1, 2),), + relation_graph_readable=False, + shared_power_constraint_readable=False, + ), + ) + second_basic = replace(extensions.features[0], channels=(2,)) + second_output = replace(extensions.features[-1], channels=(2,)) + extensions = replace( + extensions, + topology=SourceTopologyContract((1, 2), ("counter",)), + features=( + extensions.features[0], + second_basic, + relation, + extensions.features[1], + extensions.features[-1], + second_output, + ), + query_contract=replace(extensions.query_contract, max_queries=11), + ) + driver = SourceV2FakeDriver(combined=True) + service = make_service(driver) + service.descriptor = source_descriptor(driver=driver, extensions=extensions) + + snapshot = service.snapshot_v2() + + counter = snapshot.system.value.counters[0] + assert counter.input_id == "counter" + assert counter.enabled.availability.value == "unsupported" + relation_state = snapshot.cross_channel.value.relations[0] + assert relation_state.feature is SourceFeature.COMBINE + assert relation_state.enabled.availability.value == "unsupported" + + +def test_snapshot_v2_reports_anchor_drift_without_authorizing_writes() -> None: + snapshot = make_service(SourceV2FakeDriver(combined=False, drift=True)).snapshot_v2() + + assert snapshot.consistency.state is SnapshotConsistencyState.DRIFTED + assert snapshot.consistency.reason_code.value == "consistency_drifted" + + +def test_snapshot_v2_core_derives_inactive_and_unavailable_facets() -> None: + extensions = source_extensions_with_harmonics() + inactive_driver = SourceV2FakeDriver(combined=True) + inactive_service = make_service(inactive_driver) + inactive_service.descriptor = source_descriptor( + driver=inactive_driver, + extensions=extensions, + ) + inactive = inactive_service.snapshot_v2() + assert inactive.channels[0].harmonics.availability.value == "not_applicable" + assert inactive.channels[0].harmonics.reason_code.value == "inactive_by_anchor" + + unavailable_driver = SourceV2FakeDriver( + combined=False, + harmonic_unavailable=True, + ) + unavailable_service = make_service(unavailable_driver) + unavailable_service.descriptor = source_descriptor( + driver=unavailable_driver, + extensions=extensions, + ) + unavailable = unavailable_service.snapshot_v2() + assert unavailable.channels[0].harmonics.availability.value == "unavailable" + assert unavailable.channels[0].harmonics.reason_code.value == "response_missing_field" + + +def test_snapshot_v2_rejects_skipped_facet_when_activation_anchor_is_unknown() -> None: + driver = SourceV2FakeDriver(combined=True, anchor_unknown=True) + service = make_service(driver) + service.descriptor = source_descriptor( + driver=driver, + extensions=source_extensions_with_harmonics(), + ) + + with pytest.raises(SourceSnapshotContractError, match="without proven activation"): + service.snapshot_v2() + + +def test_snapshot_v2_rejects_query_count_overrun() -> None: + class BadDriver(SourceV2FakeDriver): + def execute_source_query_plan_v2(self, plan): + result = super().execute_source_query_plan_v2(plan) + records = list(result.items) + records[0] = replace(records[0], query_count=2) + return SourceQueryExecutionRecord( + contract_version=result.contract_version, + plan_id=result.plan_id, + items=tuple(records), + query_count=result.query_count + 1, + device_revision_token_before=result.device_revision_token_before, + device_revision_token_after=result.device_revision_token_after, + ) + + with pytest.raises(SourceSnapshotContractError, match="item contract"): + make_service(BadDriver(combined=True)).snapshot_v2() + + +def test_snapshot_v2_rejects_invalid_execution_record_type() -> None: + class BadDriver(SourceV2FakeDriver): + def execute_source_query_plan_v2(self, plan): + return object() + + with pytest.raises(SourceSnapshotContractError, match="invalid query execution"): + make_service(BadDriver(combined=True)).snapshot_v2() + + +def test_snapshot_v2_rejects_unhealthy_session_before_driver_call() -> None: + driver = SourceV2FakeDriver(combined=True) + service = make_service(driver, session_health=SessionHealth.UNCERTAIN) + + with pytest.raises(SourceSnapshotContractError, match="healthy"): + service.snapshot_v2() + assert driver.plans == [] + + +def test_new_core_keeps_a_v1_entry_point_usable_and_rejects_v2_before_driver_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class LegacyDriver: + def __init__(self) -> None: + self.frequency_calls = 0 + self.error_checks = 0 + self.closed = False + + def close(self) -> None: + self.closed = True + + def errors(self, limit: int = 8) -> list[str]: + del limit + return [] + + def assert_no_errors(self) -> None: + self.error_checks += 1 + + def set_frequency( + self, + channel: int, + value_hz: float, + *, + ensure_fix_mode: bool = True, + check_errors: bool = True, + ) -> SourceStatus: + del ensure_fix_mode, check_errors + self.frequency_calls += 1 + return SourceStatus( + channel=channel, + output="OFF", + function="SIN", + frequency_hz=value_hz, + amplitude=1.0, + amplitude_unit="VPP", + offset_v=0.0, + phase_deg=0.0, + frequency_mode="FIX", + sweep_enabled="OFF", + apply_raw="SIN,1000,1,0", + ) + + class EntryPoint: + group = "wavebench.instruments" + dist = None + + def __init__(self, descriptor: InstrumentDescriptor) -> None: + self.name = descriptor.driver_id + self._descriptor = descriptor + self.load_count = 0 + + def load(self): + self.load_count += 1 + return lambda: self._descriptor + + driver = LegacyDriver() + factory_calls = 0 + + def factory(context): + nonlocal factory_calls + del context + factory_calls += 1 + return driver + + descriptor = InstrumentDescriptor( + driver_id="example.legacy-source", + kind="source", + display_name="Example Legacy Source", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=("source.errors", "source.set_frequency"), + idn_patterns=("EXAMPLE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=factory, + ) + entry_point = EntryPoint(descriptor) + resolved = InstrumentRegistry(external_entry_points=(entry_point,)).resolve( + "example.legacy-source", + expected_kind="source", + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda reference, expected_kind: resolved, + ) + opened = open_instrument_driver( + driver_reference="example.legacy-source", + expected_kind="source", + resource="TCPIP::legacy-source::INSTR", + configured_backend="lan", + timeout_ms=1000, + opc_timeout_ms=1000, + read_retry_attempts=0, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + service = SourceService( + config=make_config(), + logger=CommandLogger(), + session=opened.driver, # type: ignore[arg-type] + descriptor=opened.descriptor, + ) + + status = service.set_frequency(channel=1, value_hz=1234.0) + calls_before_v2 = (factory_calls, driver.frequency_calls, driver.error_checks) + with pytest.raises(ConfigError, match="missing capabilities: source.snapshot_v2"): + service.snapshot_v2() + + assert status.frequency_hz == 1234.0 + assert entry_point.load_count == 1 + assert calls_before_v2 == (1, 1, 1) + assert (factory_calls, driver.frequency_calls, driver.error_checks) == calls_before_v2 + + +def test_snapshot_v2_rejects_session_health_change_after_query() -> None: + class DegradingDriver(SourceV2FakeDriver): + session_state = None + + def execute_source_query_plan_v2(self, plan): + result = super().execute_source_query_plan_v2(plan) + self.session_state.degrade(SessionHealth.UNCERTAIN, reason="test_query_uncertain") + return result + + driver = DegradingDriver(combined=True) + service = make_service(driver) + driver.session_state = service.session_state + + with pytest.raises(SourceSnapshotContractError, match="health changed"): + service.snapshot_v2() + + +def test_snapshot_v2_marks_asymmetric_device_revision_token_unproven() -> None: + class PartialTokenDriver(SourceV2FakeDriver): + def execute_source_query_plan_v2(self, plan): + result = super().execute_source_query_plan_v2(plan) + return replace(result, device_revision_token_after=None) + + snapshot = make_service(PartialTokenDriver(combined=True)).snapshot_v2() + assert snapshot.consistency.state is SnapshotConsistencyState.UNPROVEN + assert snapshot.consistency.reason_code.value == "consistency_unproven" + + +def test_snapshot_v2_does_not_flatten_transport_failures() -> None: + expected = TransportIOError( + "redacted", + operation="source.snapshot_v2", + phase=TransportPhase.BEFORE_SEND, + replay_policy=ReplayPolicy.NO_REPLAY, + command_transmission=CommandTransmission.NOT_SENT, + response_progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + reason_code="query_rejected", + ) + + class RaisingDriver(SourceV2FakeDriver): + def execute_source_query_plan_v2(self, plan): + raise expected + + with pytest.raises(TransportIOError) as raised: + make_service(RaisingDriver(combined=True)).snapshot_v2() + assert raised.value is expected + + +def test_snapshot_v2_enforces_absolute_deadline(monkeypatch) -> None: + ticks = iter((100.0, 103.0)) + monkeypatch.setattr( + "wavebench.services.source_snapshot_v2.time.monotonic", + lambda: next(ticks), + ) + + with pytest.raises(SourceSnapshotContractError, match="deadline"): + make_service(SourceV2FakeDriver(combined=True)).snapshot_v2() + + +def test_snapshot_v2_artifact_is_typed_and_excludes_protocol_records() -> None: + snapshot = make_service(SourceV2FakeDriver(combined=True)).snapshot_v2() + artifact = source_snapshot_v2_operation_artifact(snapshot) + + assert artifact["schema"] == SOURCE_OPERATION_ARTIFACT_SCHEMA + assert artifact["snapshot"]["schema"] == SOURCE_SNAPSHOT_SCHEMA + assert artifact["snapshot"]["channels"][0]["basic"]["availability"] == "value" + assert "items" not in artifact["query"] + serialized = json.dumps(artifact) + assert "TCPIP" not in serialized + assert "revision-1" not in serialized + + +def test_snapshot_v2_operation_spec_is_read_only_and_bounded() -> None: + spec = require_operation_spec("source.snapshot_v2") + + assert spec.required_capabilities == ("source.snapshot_v2",) + assert spec.effect == "stateful_read" + assert spec.mutates is False + assert spec.lease_mode == "exclusive" + assert spec.operation_timeout_ms == 5000 + assert spec.changed_fields == () + + +def test_snapshot_v2_cli_emits_operation_artifact(capsys) -> None: + service = make_service(SourceV2FakeDriver(combined=True)) + with patch("wavebench.cli._load_source_service", return_value=service): + exit_code = cli.main( + ["--json", "source", "snapshot-v2", "--config", "unused.toml"] + ) + + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert payload["schema"] == "wavebench.cli.result.v1" + assert payload["result"]["schema"] == SOURCE_OPERATION_ARTIFACT_SCHEMA + assert payload["result"]["snapshot"]["schema"] == SOURCE_SNAPSHOT_SCHEMA From 3a7392e6ce74fcb6fb90207d0c20cfcdd2385c9b Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:54:09 +0800 Subject: [PATCH 02/82] fix: fail closed on invalid source output amplitude --- src/wavebench/services/source_service.py | 20 ++++++++-- tests/test_safety_limits.py | 48 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py index 01ca1c3..db80459 100644 --- a/src/wavebench/services/source_service.py +++ b/src/wavebench/services/source_service.py @@ -624,6 +624,11 @@ def set_output(self, channel: int | None, enabled: bool) -> SourceStatus: if enabled: status = current assert status is not None + if status.amplitude_unit != "VPP": + raise ConfigError( + "source output requires a readable VPP amplitude / " + "信号源输出要求可读的 VPP 幅度" + ) self._check_source_vpp(status.amplitude, field="source output amplitude / 信号源输出幅度") if self.state_guard is not None: assert current is not None @@ -726,8 +731,13 @@ def upload_arbitrary_waveform( self._state_guard_after_write(result) return result - def _check_source_vpp(self, value_vpp: float, *, field: str) -> None: + def _check_source_vpp(self, value_vpp: object, *, field: str) -> None: self._require_finite(value_vpp, field=field) + assert isinstance(value_vpp, (int, float)) and not isinstance(value_vpp, bool) + if value_vpp < 0: + raise ConfigError( + f"non-negative Vpp required / Vpp 必须为非负数: {field}" + ) limit = self.config.safety_limits.max_source_vpp if limit is not None and value_vpp > limit: raise ConfigError( @@ -736,8 +746,12 @@ def _check_source_vpp(self, value_vpp: float, *, field: str) -> None: ) @staticmethod - def _require_finite(value: float, *, field: str) -> None: - if not isfinite(value): + def _require_finite(value: object, *, field: str) -> None: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + ): raise ConfigError( f"finite value required / 必须为有限数: {field}" ) diff --git a/tests/test_safety_limits.py b/tests/test_safety_limits.py index a6c19b3..9b99325 100644 --- a/tests/test_safety_limits.py +++ b/tests/test_safety_limits.py @@ -1,3 +1,4 @@ +from dataclasses import replace from pathlib import Path from math import inf, nan from unittest.mock import patch @@ -190,6 +191,44 @@ def test_source_output_on_rejects_current_amplitude_over_limit(self): self.assertEqual(fake.output_calls, []) self.assertTrue(fake.closed) + def test_source_output_on_rejects_invalid_or_non_vpp_amplitude_before_write(self): + cases = ( + (replace(source_status(1.0), amplitude=None), "必须为有限数"), + (replace(source_status(1.0), amplitude="1.0"), "必须为有限数"), + (replace(source_status(1.0), amplitude=True), "必须为有限数"), + (replace(source_status(1.0), amplitude=nan), "必须为有限数"), + (replace(source_status(1.0), amplitude=inf), "必须为有限数"), + (replace(source_status(1.0), amplitude=-1.0), "必须为非负数"), + (replace(source_status(1.0), amplitude_unit="VRMS"), "可读的 VPP 幅度"), + ) + for status, message in cases: + with self.subTest(status=status): + fake = FakeSource(status) + service = SourceService(config=make_config(), logger=CommandLogger()) + + with patch.object(service, "_open_source", return_value=fake): + with self.assertRaisesRegex(ConfigError, message): + service.set_output(channel=2, enabled=True) + + self.assertEqual(fake.output_calls, []) + self.assertTrue(fake.closed) + + def test_source_set_vpp_rejects_invalid_values_before_opening_source(self): + service = SourceService(config=make_config(), logger=CommandLogger()) + for value, message in ( + (None, "必须为有限数"), + ("1.0", "必须为有限数"), + (True, "必须为有限数"), + (nan, "必须为有限数"), + (inf, "必须为有限数"), + (-1.0, "必须为非负数"), + ): + with self.subTest(value=value): + with patch.object(service, "_open_source") as open_source: + with self.assertRaisesRegex(ConfigError, message): + service.set_amplitude_vpp(channel=2, value_vpp=value) # type: ignore[arg-type] + open_source.assert_not_called() + def test_source_output_off_does_not_check_amplitude(self): fake = FakeSource(source_status(5.0)) service = SourceService(config=make_config(), logger=CommandLogger()) @@ -199,6 +238,15 @@ def test_source_output_off_does_not_check_amplitude(self): self.assertEqual(fake.output_calls, [(2, False, True)]) + def test_source_output_off_allows_missing_amplitude(self): + fake = FakeSource(replace(source_status(1.0), amplitude=None)) + service = SourceService(config=make_config(), logger=CommandLogger()) + + with patch.object(service, "_open_source", return_value=fake): + service.set_output(channel=2, enabled=False) + + self.assertEqual(fake.output_calls, [(2, False, True)]) + def test_power_output_on_rejects_current_setpoints_over_limit(self): fake = FakePower(power_status(12.0, 0.1)) service = PowerService(config=make_config(), logger=CommandLogger()) From 12a41c54c5bfdb7a906a327fdc7355d7cac2d03b Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:58:52 +0800 Subject: [PATCH 03/82] feat: add source v2 safety configuration axes --- ...07\344\273\266\346\240\274\345\274\217.md" | 42 ++++++- src/wavebench/config.py | 110 +++++++++++++++++- src/wavebench/errors.py | 34 +++++- wavebench.example.toml | 14 +++ 4 files changed, 195 insertions(+), 5 deletions(-) diff --git "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" index c9f7edd..a7c9878 100644 --- "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" @@ -158,6 +158,8 @@ duty_consistency = 0.03 max_source_vpp = 5.0 max_power_voltage_v = 5.0 max_power_current_limit_a = 0.2 +# min_source_port_voltage_v = -5.0 +# max_source_port_voltage_v = 5.0 [source] driver = "dg4202" @@ -368,11 +370,15 @@ max_power_voltage_v = 5.0 max_power_current_limit_a = 0.2 ``` -这些参数是第一层执行安全上限。全部都是可选项;省略某一项表示这一轴不设软件上限。 +这些参数是第一层执行安全上限。现有 V1 轴均为可选项;省略某一项表示该 V1 轴不设软件上限。 - `max_source_vpp`:限制 `source set-vpp`、`source arb-load`、`run plan` 中 `source.set_vpp` / `source.arb_load`,以及 `sweep.frequency_response` 的每个 `amplitudes_vpp` / 生成 Vpp 切片。 - `max_power_voltage_v`:限制 `power set` 与 `run plan` 中 `power.set` 的设定电压。 - `max_power_current_limit_a`:限制 `power set` 与 `run plan` 中 `power.set` 的限流值。 +- `min_source_port_voltage_v`、`max_source_port_voltage_v`:Source V2 能量增加操作的显式、有符号端口 + 电压区间。两项必须同时出现,值必须有限且满足最小值小于最大值;不能从 `max_source_vpp` + 推导。两项同时缺失不会影响现有 V1 行为,但会让后续 Source V2 ON、fire、恢复 ON 或其它 + 增加端口能量的操作在仪器 I/O 前拒绝。 `run plan` 会在创建 run 目录和连接仪器前先检查这些上限。直接 CLI 设置也会在写仪器前检查。`source output on` / `power output on` 会先读取当前设定值,若当前设定值超限,则拒绝打开输出。 @@ -389,10 +395,27 @@ check_errors = true ensure_fix_mode_on_set_frequency = true settle_ms_after_set_frequency = 500 access = "read_write" + +[[source.terminations]] +channel = 1 +kind = "resistive" +minimum_ohm = 49.5 +maximum_ohm = 50.5 ``` 当前 source 支持 DG4202。`ensure_fix_mode_on_set_frequency = true` 表示设置固定频率前,若设备仍在 sweep 模式,先显式切回 FIX,避免扫频状态污染单点实验。 +`[[source.terminations]]` 是实际端接的静态证据,供后续 Source V2 能量操作使用,不会替代仪器的 +显示负载,也不会改变现有 V1 CLI、run plan 或 setter 行为。每项必须包含正整数 `channel` 和 +`kind`: + +- `resistive` 还必须包含有限、正数且递增的 `minimum_ohm`、`maximum_ohm`; +- `high_impedance` 可以不提供电阻区间,但没有有限区间时不能单独形成保守的输出 ON 证明; +- 同一个 channel 最多出现一次。 + +实际端接与仪器显示的 `HiZ`、`50 Ω` 或其它 load setting 是不同事实。配置项只声明实验台已确认的 +外部端接;未配置不会被核心根据显示负载自动推断。 + ## `[power]` ```toml @@ -452,13 +475,18 @@ import tomllib max_source_vpp = 5.0 max_power_voltage_v = 5.0 max_power_current_limit_a = 0.2 +# min_source_port_voltage_v = -5.0 +# max_source_port_voltage_v = 5.0 ``` -这些参数是第一层执行安全上限。全部都是可选项;省略某一项表示这一轴不设软件上限。 +这些参数是第一层执行安全上限。现有 V1 轴均为可选项;省略某一项表示该 V1 轴不设软件上限。 - `max_source_vpp`:限制 `source set-vpp`、`source arb-load`、`run plan` 中 `source.set_vpp` / `source.arb_load`,以及 `sweep.frequency_response` 的每个 `amplitudes_vpp` / 生成 Vpp 切片。 - `max_power_voltage_v`:限制 `power set` 与 `run plan` 中 `power.set` 的设定电压。 - `max_power_current_limit_a`:限制 `power set` 与 `run plan` 中 `power.set` 的限流值。 +- `min_source_port_voltage_v`、`max_source_port_voltage_v`:只用于后续 Source V2 能量增加操作的 + 显式有符号端口电压区间。两项必须同时配置,且不从 `max_source_vpp` 推导;两项缺失时旧 V1 + 命令继续保持原有行为,V2 能量操作则在仪器 I/O 前拒绝。 `run plan` 会在创建 run 目录和连接仪器前先检查这些上限。直接 CLI 设置也会在写仪器前检查。`source output on` / `power output on` 会先读取当前设定值,若当前设定值超限,则拒绝打开输出。 @@ -474,6 +502,12 @@ default_channel = 1 check_errors = true ensure_fix_mode_on_set_frequency = true settle_ms_after_set_frequency = 500 + +[[source.terminations]] +channel = 1 +kind = "resistive" +minimum_ohm = 49.5 +maximum_ohm = 50.5 ``` 当前第二阶段信号源只支持: @@ -488,6 +522,10 @@ driver = "dg4202" - `default_channel` 是 `wavebench source ...` 未显式传 `--channel` 时使用的通道。 - `ensure_fix_mode_on_set_frequency = true` 表示在执行 `source set-freq` 前,若仪器当前处于 `SWE` 模式,则先切到 `FIX`,避免把 sweep 频率误当成固定频率输出。 +`[[source.terminations]]` 声明已确认的外部端接,而不是仪器显示负载。`resistive` 需要有限的 +`minimum_ohm`、`maximum_ohm`;`high_impedance` 可省略电阻区间,但不能因此自动获得 Source V2 +输出 ON 准入。该配置仅供后续 Source V2 安全预算使用,不改变现有 V1 source 命令。 + ### `settle_ms_after_set_frequency` diff --git a/src/wavebench/config.py b/src/wavebench/config.py index 0bb5189..172818c 100644 --- a/src/wavebench/config.py +++ b/src/wavebench/config.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from math import isfinite from pathlib import Path import tomllib @@ -97,6 +98,7 @@ class SourceConfig: settle_ms_after_set_frequency: int options: dict[str, object] = field(default_factory=dict) access: AccessMode = "read_write" + terminations: tuple["SourceTerminationConfig", ...] = () @dataclass(frozen=True) class PowerConfig: @@ -153,6 +155,18 @@ class SafetyLimitsConfig: max_source_vpp: float | None = None max_power_voltage_v: float | None = None max_power_current_limit_a: float | None = None + min_source_port_voltage_v: float | None = None + max_source_port_voltage_v: float | None = None + + +@dataclass(frozen=True) +class SourceTerminationConfig: + """Static, per-channel actual-termination evidence from the local config.""" + + channel: int + kind: str + minimum_ohm: float | None = None + maximum_ohm: float | None = None @dataclass(frozen=True) class TuiConfig: @@ -160,15 +174,98 @@ class TuiConfig: log_keep_lines_after_trim: int = 1_000 -def _optional_positive_float(raw: dict, key: str) -> float | None: +def _optional_positive_float(raw: dict[str, object], key: str) -> float | None: if key not in raw: return None - value = float(raw[key]) + value = _finite_number(raw[key], path=f"safety_limits.{key}") if value <= 0: raise ConfigError(f"safety_limits.{key} must be > 0") return value +def _finite_number(value: object, *, path: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise ConfigError(f"{path} must be a finite number") + return float(value) + + +def _optional_signed_finite_float(raw: dict[str, object], key: str) -> float | None: + if key not in raw: + return None + return _finite_number(raw[key], path=f"safety_limits.{key}") + + +def _source_port_voltage_limits( + raw: dict[str, object], +) -> tuple[float | None, float | None]: + minimum = _optional_signed_finite_float(raw, "min_source_port_voltage_v") + maximum = _optional_signed_finite_float(raw, "max_source_port_voltage_v") + if (minimum is None) != (maximum is None): + raise ConfigError( + "safety_limits.min_source_port_voltage_v and " + "safety_limits.max_source_port_voltage_v must be configured together" + ) + if minimum is not None and maximum is not None and minimum >= maximum: + raise ConfigError( + "safety_limits.min_source_port_voltage_v must be < " + "safety_limits.max_source_port_voltage_v" + ) + return minimum, maximum + + +def _source_terminations(raw: dict[str, object]) -> tuple[SourceTerminationConfig, ...]: + values = raw.get("terminations", []) + if not isinstance(values, list): + raise ConfigError("source.terminations must be an array of TOML tables") + parsed: list[SourceTerminationConfig] = [] + for index, value in enumerate(values): + path = f"source.terminations[{index}]" + if not isinstance(value, dict): + raise ConfigError(f"{path} must be a TOML table") + channel = value.get("channel") + if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1: + raise ConfigError(f"{path}.channel must be a positive integer") + kind = value.get("kind") + if not isinstance(kind, str) or kind not in {"resistive", "high_impedance"}: + raise ConfigError(f"{path}.kind must be 'resistive' or 'high_impedance'") + minimum = ( + None + if "minimum_ohm" not in value + else _finite_number(value["minimum_ohm"], path=f"{path}.minimum_ohm") + ) + maximum = ( + None + if "maximum_ohm" not in value + else _finite_number(value["maximum_ohm"], path=f"{path}.maximum_ohm") + ) + if (minimum is None) != (maximum is None): + raise ConfigError( + f"{path}.minimum_ohm and {path}.maximum_ohm must be configured together" + ) + if kind == "resistive" and minimum is None: + raise ConfigError(f"{path} resistive termination requires resistance bounds") + if minimum is not None and maximum is not None: + if minimum <= 0 or maximum <= 0: + raise ConfigError(f"{path} resistance bounds must be > 0") + if minimum > maximum: + raise ConfigError( + f"{path}.minimum_ohm must be <= {path}.maximum_ohm" + ) + parsed.append( + SourceTerminationConfig( + channel=channel, + kind=kind, + minimum_ohm=minimum, + maximum_ohm=maximum, + ) + ) + parsed.sort(key=lambda item: item.channel) + channels = tuple(item.channel for item in parsed) + if len(set(channels)) != len(channels): + raise ConfigError("source.terminations channels must be unique") + return tuple(parsed) + + def _instrument_options(raw: dict, section: str) -> dict[str, object]: options = raw.get("options", {}) if not isinstance(options, dict): @@ -344,6 +441,7 @@ def with_source_resource(self, resource: str) -> "WaveBenchConfig": settle_ms_after_set_frequency=source.settle_ms_after_set_frequency, options=source.options, access=source.access, + terminations=source.terminations, ), power=self.power, dmm=self.dmm, @@ -451,6 +549,11 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: o = raw.get("output", {}) q = raw.get("quality", {}) sl = raw.get("safety_limits", {}) + if not isinstance(sl, dict): + raise ConfigError("safety_limits must be a TOML table") + min_source_port_voltage_v, max_source_port_voltage_v = _source_port_voltage_limits( + sl + ) tui_raw = raw.get("tui", {}) src = raw.get("source") source = None @@ -464,6 +567,7 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: settle_ms_after_set_frequency=int(src.get("settle_ms_after_set_frequency", 0)), options=_instrument_options(src, "source"), access=normalize_access_mode(src.get("access", "read_write"), "source.access"), + terminations=_source_terminations(src), ) pwr = raw.get("power") power = None @@ -566,6 +670,8 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: max_source_vpp=_optional_positive_float(sl, "max_source_vpp"), max_power_voltage_v=_optional_positive_float(sl, "max_power_voltage_v"), max_power_current_limit_a=_optional_positive_float(sl, "max_power_current_limit_a"), + min_source_port_voltage_v=min_source_port_voltage_v, + max_source_port_voltage_v=max_source_port_voltage_v, ), tui=TuiConfig( log_max_lines=int(tui_raw.get("log_max_lines", 10_000)), diff --git a/src/wavebench/errors.py b/src/wavebench/errors.py index c0a8279..c95d791 100644 --- a/src/wavebench/errors.py +++ b/src/wavebench/errors.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import re -from typing import Any, Mapping +from typing import Any, Iterable, Mapping from wavebench.transport.contracts import ( CommandTransmission, @@ -116,6 +116,38 @@ class AccessDeniedError(ConfigError): exit_code = 2 code = "access_denied" + +class SourceSafetyLimitsRequiredError(ConfigError): + """A Source V2 energy operation lacks explicitly configured safety axes.""" + + code = "source_safety_limits_required" + + def __init__(self, missing_fields: Iterable[str]) -> None: + normalized = tuple(sorted(set(missing_fields))) + allowed = { + "max_source_vpp", + "min_source_port_voltage_v", + "max_source_port_voltage_v", + } + if not normalized or not set(normalized) <= allowed: + raise ValueError("invalid missing Source safety limit fields") + super().__init__( + "Source V2 energy operations require explicit safety limits / " + "Source V2 能量操作要求显式安全限制" + ) + self.missing_fields = normalized + + def to_envelope( + self, + *, + operation: str | None = None, + details: Mapping[str, Any] | None = None, + cause: Mapping[str, Any] | BaseException | None = None, + ) -> ErrorEnvelope: + merged = dict(details or {}) + merged["missing_fields"] = list(self.missing_fields) + return super().to_envelope(operation=operation, details=merged, cause=cause) + class ConnectionError(WaveBenchError): exit_code = 3 code = "connection_error" diff --git a/wavebench.example.toml b/wavebench.example.toml index 814b3ff..9c4803e 100644 --- a/wavebench.example.toml +++ b/wavebench.example.toml @@ -125,6 +125,12 @@ duty_consistency = 0.03 # Source amplitude writes above this Vpp are rejected before instrument I/O. max_source_vpp = 5.0 +# Source V2 energy-increasing operations also require this explicit signed port-voltage +# interval. Do not infer it from max_source_vpp. Existing V1 operations ignore these +# keys; omit both keys to leave Source V2 energy operations unauthorized. +# min_source_port_voltage_v = -5.0 +# max_source_port_voltage_v = 5.0 + # Power voltage/current-limit writes above these values are rejected before instrument I/O. # Turning power output on also checks the currently configured setpoints first. max_power_voltage_v = 5.0 @@ -161,6 +167,14 @@ ensure_fix_mode_on_set_frequency = true # Extra wait after setting a fixed source frequency, in milliseconds. settle_ms_after_set_frequency = 500 +# Optional actual-termination evidence for future Source V2 energy operations. +# This is deliberately independent of the instrument display load. +# [[source.terminations]] +# channel = 1 +# kind = "resistive" +# minimum_ohm = 49.5 +# maximum_ohm = 50.5 + [power] # Power supply driver. Current read-only power support targets Rigol DP800 series. From d8f75f98a909a0c5539585172e38a2c2dee518e2 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:58:59 +0800 Subject: [PATCH 04/82] feat: bind source termination safety evidence --- src/wavebench/services/source_safety.py | 323 ++++++++++++++++++++++ tests/test_config_overrides.py | 143 +++++++++- tests/test_errors.py | 14 + tests/test_source_termination_evidence.py | 248 +++++++++++++++++ 4 files changed, 727 insertions(+), 1 deletion(-) create mode 100644 src/wavebench/services/source_safety.py create mode 100644 tests/test_source_termination_evidence.py diff --git a/src/wavebench/services/source_safety.py b/src/wavebench/services/source_safety.py new file mode 100644 index 0000000..4efd40f --- /dev/null +++ b/src/wavebench/services/source_safety.py @@ -0,0 +1,323 @@ +"""Core-only Source V2 safety configuration primitives. + +These functions intentionally do not open a session or authorize a write. They +only turn explicit experiment-table limits into the prerequisite used by a +future energy-increasing Source V2 operation. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from enum import StrEnum +from math import isfinite +import re + +from wavebench.config import SafetyLimitsConfig, WaveBenchConfig +from wavebench.errors import ConfigError, SourceSafetyLimitsRequiredError +from wavebench.instruments.source_extensions import ( + SourceScopeRef, + SourceTerminationEvidence, + TerminationEvidenceLifetime, + TerminationEvidenceSource, + TerminationKind, + TerminationSpec, + ResistanceBounds, + source_v2_digest, +) +from wavebench.services.resource_lease import resource_fingerprint + + +_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +_SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") + + +@dataclass(frozen=True, slots=True) +class SourceEnergySafetyLimits: + """Three independent, explicit safety axes for Source V2 energy operations.""" + + max_source_vpp: float + min_source_port_voltage_v: float + max_source_port_voltage_v: float + + def __post_init__(self) -> None: + for name, value in ( + ("max_source_vpp", self.max_source_vpp), + ("min_source_port_voltage_v", self.min_source_port_voltage_v), + ("max_source_port_voltage_v", self.max_source_port_voltage_v), + ): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise ConfigError(f"source safety limit {name} must be a finite number") + if self.max_source_vpp <= 0: + raise ConfigError("source safety limit max_source_vpp must be > 0") + if self.min_source_port_voltage_v >= self.max_source_port_voltage_v: + raise ConfigError( + "source safety limit min_source_port_voltage_v must be < " + "max_source_port_voltage_v" + ) + + +def require_source_v2_energy_safety_limits( + limits: SafetyLimitsConfig, +) -> SourceEnergySafetyLimits: + """Return explicit V2 energy limits or fail closed before any instrument I/O.""" + + required = ( + "max_source_vpp", + "min_source_port_voltage_v", + "max_source_port_voltage_v", + ) + missing = tuple(name for name in required if getattr(limits, name, None) is None) + if missing: + raise SourceSafetyLimitsRequiredError(missing) + return SourceEnergySafetyLimits( + max_source_vpp=limits.max_source_vpp, # type: ignore[arg-type] + min_source_port_voltage_v=limits.min_source_port_voltage_v, # type: ignore[arg-type] + max_source_port_voltage_v=limits.max_source_port_voltage_v, # type: ignore[arg-type] + ) + + +class SourceTerminationEvidenceStatus(StrEnum): + VALID = "valid" + TARGET_MISMATCH = "target_mismatch" + RESOURCE_MISMATCH = "resource_mismatch" + BINDING_MISMATCH = "binding_mismatch" + EXPIRED = "expired" + OBSERVED_IN_FUTURE = "observed_in_future" + RUN_INTENT_REQUIRED = "run_intent_required" + + +@dataclass(frozen=True, slots=True) +class SourceTerminationEvidenceContext: + """Core-owned binding facts for deciding whether termination evidence is usable.""" + + target: SourceScopeRef + resource_fingerprint: str + config_digest: str + correlation_id: str + observed_at_utc: str + run_intent_digest: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.target, SourceScopeRef): + raise ValueError("termination evidence context target has an invalid type") + if self.target.scope.value != "channel": + raise ValueError("termination evidence context target must be a channel scope") + for label, value in ( + ("resource_fingerprint", self.resource_fingerprint), + ("config_digest", self.config_digest), + ): + if not isinstance(value, str) or _SHA256.fullmatch(value) is None: + raise ValueError(f"termination evidence context {label} has an invalid format") + if not isinstance(self.correlation_id, str) or _SAFE_TOKEN.fullmatch(self.correlation_id) is None: + raise ValueError("termination evidence context correlation_id has an invalid format") + _parse_utc(self.observed_at_utc, "termination evidence context observed_at_utc") + if self.run_intent_digest is not None and ( + not isinstance(self.run_intent_digest, str) + or _SAFE_TOKEN.fullmatch(self.run_intent_digest) is None + ): + raise ValueError("termination evidence context run_intent_digest has an invalid format") + + +@dataclass(frozen=True, slots=True) +class SourceTerminationEvidenceValidation: + status: SourceTerminationEvidenceStatus + evidence: SourceTerminationEvidence | None = None + + @property + def is_valid(self) -> bool: + return self.status is SourceTerminationEvidenceStatus.VALID + + +def source_safety_config_digest(config: WaveBenchConfig) -> str: + """Hash only the source-side safety semantics without exposing the resource string.""" + + source = config.source + resource = None if source is None or source.resource is None else _resource_digest(source.resource) + return source_v2_digest( + { + "schema": "wavebench.source.safety.config.v1", + "source": ( + None + if source is None + else { + "driver": source.driver, + "resource_fingerprint": resource, + "default_channel": source.default_channel, + "terminations": tuple(asdict(item) for item in source.terminations), + } + ), + "safety_limits": asdict(config.safety_limits), + } + ) + + +def source_termination_evidence_context( + config: WaveBenchConfig, + *, + target: SourceScopeRef, + correlation_id: str, + observed_at_utc: str | None = None, + run_intent_digest: str | None = None, +) -> SourceTerminationEvidenceContext: + """Create a current operation/run context without performing instrument I/O.""" + + source = config.source + if source is None or not source.resource: + raise ConfigError("source termination evidence requires a configured source resource") + return SourceTerminationEvidenceContext( + target=target, + resource_fingerprint=_resource_digest(source.resource), + config_digest=source_safety_config_digest(config), + correlation_id=correlation_id, + observed_at_utc=observed_at_utc or _timestamp_utc(), + run_intent_digest=run_intent_digest, + ) + + +def source_config_termination_evidence( + config: WaveBenchConfig, + *, + context: SourceTerminationEvidenceContext, +) -> SourceTerminationEvidence | None: + """Materialize a channel's static config evidence for one bound operation context.""" + + source = config.source + if source is None: + return None + channel = context.target.channel + assert channel is not None + configured = next((item for item in source.terminations if item.channel == channel), None) + if configured is None: + return None + bounds = ( + None + if configured.minimum_ohm is None + else ResistanceBounds(configured.minimum_ohm, configured.maximum_ohm) + ) + termination = TerminationSpec(TerminationKind(configured.kind), bounds) + source_kind = TerminationEvidenceSource.CONFIG + lifetime = TerminationEvidenceLifetime.CONFIG_DIGEST + return SourceTerminationEvidence( + target=context.target, + termination=termination, + source=source_kind, + lifetime=lifetime, + resource_fingerprint=context.resource_fingerprint, + binding_digest=source_termination_binding_digest( + context, + source=source_kind, + lifetime=lifetime, + ), + observed_at_utc=context.observed_at_utc, + expires_at_utc=None, + evidence_ref=f"config.source_terminations.ch{channel}", + ) + + +def source_termination_binding_digest( + context: SourceTerminationEvidenceContext, + *, + source: TerminationEvidenceSource, + lifetime: TerminationEvidenceLifetime, +) -> str: + """Build the unforgeable-by-accident digest that binds evidence to its use.""" + + if not isinstance(context, SourceTerminationEvidenceContext): + raise TypeError("termination evidence context has an invalid type") + if not isinstance(source, TerminationEvidenceSource) or not isinstance( + lifetime, + TerminationEvidenceLifetime, + ): + raise TypeError("termination evidence source/lifetime has an invalid type") + if lifetime is TerminationEvidenceLifetime.RUN and context.run_intent_digest is None: + raise ConfigError("run-lifetime termination evidence requires a run intent digest") + return source_v2_digest( + { + "schema": "wavebench.source.termination.binding.v1", + "target": context.target, + "resource_fingerprint": context.resource_fingerprint, + "config_digest": context.config_digest, + "correlation_id": context.correlation_id, + "run_intent_digest": ( + context.run_intent_digest + if lifetime is TerminationEvidenceLifetime.RUN + else None + ), + "source": source, + "lifetime": lifetime, + } + ) + + +def validate_source_termination_evidence( + evidence: SourceTerminationEvidence, + *, + context: SourceTerminationEvidenceContext, +) -> SourceTerminationEvidenceValidation: + """Fail closed when any target, binding, resource, or time fact differs.""" + + if not isinstance(evidence, SourceTerminationEvidence): + raise TypeError("termination evidence has an invalid type") + if not isinstance(context, SourceTerminationEvidenceContext): + raise TypeError("termination evidence context has an invalid type") + if evidence.target != context.target: + return SourceTerminationEvidenceValidation(SourceTerminationEvidenceStatus.TARGET_MISMATCH) + if evidence.resource_fingerprint != context.resource_fingerprint: + return SourceTerminationEvidenceValidation(SourceTerminationEvidenceStatus.RESOURCE_MISMATCH) + if ( + evidence.lifetime is TerminationEvidenceLifetime.RUN + and context.run_intent_digest is None + ): + return SourceTerminationEvidenceValidation(SourceTerminationEvidenceStatus.RUN_INTENT_REQUIRED) + expected = source_termination_binding_digest( + context, + source=evidence.source, + lifetime=evidence.lifetime, + ) + if evidence.binding_digest != expected: + return SourceTerminationEvidenceValidation(SourceTerminationEvidenceStatus.BINDING_MISMATCH) + now = _parse_utc(context.observed_at_utc, "termination evidence context observed_at_utc") + observed = _parse_utc(evidence.observed_at_utc, "termination evidence observed_at_utc") + if observed > now: + return SourceTerminationEvidenceValidation(SourceTerminationEvidenceStatus.OBSERVED_IN_FUTURE) + if evidence.expires_at_utc is not None and _parse_utc( + evidence.expires_at_utc, + "termination evidence expires_at_utc", + ) < now: + return SourceTerminationEvidenceValidation(SourceTerminationEvidenceStatus.EXPIRED) + return SourceTerminationEvidenceValidation( + SourceTerminationEvidenceStatus.VALID, + evidence=evidence, + ) + + +def _resource_digest(resource: str) -> str: + return "sha256:" + resource_fingerprint(resource) + + +def _timestamp_utc() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _parse_utc(value: object, label: str) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise ValueError(f"{label} must be an RFC 3339 UTC timestamp") + try: + return datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ValueError(f"{label} must be an RFC 3339 UTC timestamp") from exc + + +__all__ = [ + "SourceEnergySafetyLimits", + "SourceTerminationEvidenceContext", + "SourceTerminationEvidenceStatus", + "SourceTerminationEvidenceValidation", + "require_source_v2_energy_safety_limits", + "source_config_termination_evidence", + "source_safety_config_digest", + "source_termination_binding_digest", + "source_termination_evidence_context", + "validate_source_termination_evidence", +] diff --git a/tests/test_config_overrides.py b/tests/test_config_overrides.py index e93c315..6e5e342 100644 --- a/tests/test_config_overrides.py +++ b/tests/test_config_overrides.py @@ -1,8 +1,11 @@ import unittest import tempfile +from math import inf, nan from pathlib import Path from wavebench.config import AutoscaleConfig, ConnectionConfig, DmmConfig, OutputConfig, SafetyLimitsConfig, ScopeConfig, WaveBenchConfig, WaveformConfig, load_config +from wavebench.errors import ConfigError +from wavebench.services.source_safety import require_source_v2_energy_safety_limits class ConfigOverrideTests(unittest.TestCase): @@ -117,11 +120,17 @@ def test_safety_limits_defaults_and_are_preserved_by_overrides(self): waveform=WaveformConfig("real", "lsbf", "dmax"), output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), source_path=Path("test.toml"), - safety_limits=SafetyLimitsConfig(max_source_vpp=2.5), + safety_limits=SafetyLimitsConfig( + max_source_vpp=2.5, + min_source_port_voltage_v=-2.0, + max_source_port_voltage_v=3.0, + ), ) self.assertEqual(config.safety_limits.max_source_vpp, 2.5) updated = config.with_waveform_overrides(points="def") self.assertEqual(updated.safety_limits.max_source_vpp, 2.5) + self.assertEqual(updated.safety_limits.min_source_port_voltage_v, -2.0) + self.assertEqual(updated.safety_limits.max_source_port_voltage_v, 3.0) def test_quality_config_defaults_and_is_preserved_by_overrides(self): config = WaveBenchConfig( @@ -194,11 +203,143 @@ def test_loads_safety_limits_config(self): max_source_vpp = 2.5 max_power_voltage_v = 5.0 max_power_current_limit_a = 0.2 +min_source_port_voltage_v = -3.0 +max_source_port_voltage_v = 4.0 """, encoding="utf-8") config = load_config(path) self.assertEqual(config.safety_limits.max_source_vpp, 2.5) self.assertEqual(config.safety_limits.max_power_voltage_v, 5.0) self.assertEqual(config.safety_limits.max_power_current_limit_a, 0.2) + self.assertEqual(config.safety_limits.min_source_port_voltage_v, -3.0) + self.assertEqual(config.safety_limits.max_source_port_voltage_v, 4.0) + + def test_old_safety_limits_positional_layout_is_compatible(self): + limits = SafetyLimitsConfig(2.5, 5.0, 0.2) + + self.assertEqual(limits.max_source_vpp, 2.5) + self.assertEqual(limits.max_power_voltage_v, 5.0) + self.assertEqual(limits.max_power_current_limit_a, 0.2) + self.assertIsNone(limits.min_source_port_voltage_v) + self.assertIsNone(limits.max_source_port_voltage_v) + + def test_source_port_voltage_limits_must_be_configured_together(self): + for body in ( + "min_source_port_voltage_v = -2.0", + "max_source_port_voltage_v = 2.0", + ): + with self.subTest(body=body), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "wavebench.toml" + path.write_text( + "[connection]\nresource = \"TCPIP::127.0.0.1::INSTR\"\n" + "[scope]\n[safety_limits]\n" + f"{body}\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ConfigError, "configured together"): + load_config(path) + + def test_source_port_voltage_limits_require_finite_signed_interval(self): + for minimum, maximum, message in ( + (2.0, -2.0, "must be <"), + (2.0, 2.0, "must be <"), + (True, 2.0, "finite number"), + (-2.0, True, "finite number"), + (nan, 2.0, "finite number"), + (-2.0, inf, "finite number"), + ): + with self.subTest(minimum=minimum, maximum=maximum): + with self.assertRaisesRegex(ConfigError, message): + require_source_v2_energy_safety_limits( + SafetyLimitsConfig( + max_source_vpp=2.0, + min_source_port_voltage_v=minimum, + max_source_port_voltage_v=maximum, + ) + ) + + def test_source_v2_energy_limits_fail_closed_when_an_axis_is_missing(self): + cases = ( + ( + SafetyLimitsConfig(), + ["max_source_port_voltage_v", "max_source_vpp", "min_source_port_voltage_v"], + ), + ( + SafetyLimitsConfig(max_source_vpp=2.0), + ["max_source_port_voltage_v", "min_source_port_voltage_v"], + ), + ( + SafetyLimitsConfig(max_source_vpp=2.0, min_source_port_voltage_v=-2.0), + ["max_source_port_voltage_v"], + ), + ) + for limits, expected_missing in cases: + with self.subTest(limits=limits): + with self.assertRaisesRegex(ConfigError, "explicit safety limits") as raised: + require_source_v2_energy_safety_limits(limits) + self.assertEqual(raised.exception.missing_fields, tuple(expected_missing)) + self.assertEqual( + raised.exception.to_envelope().as_dict()["code"], + "source_safety_limits_required", + ) + + def test_source_v2_energy_limits_keep_vpp_and_absolute_limits_independent(self): + result = require_source_v2_energy_safety_limits( + SafetyLimitsConfig( + max_source_vpp=1.0, + min_source_port_voltage_v=-3.0, + max_source_port_voltage_v=4.0, + ) + ) + self.assertEqual(result.max_source_vpp, 1.0) + self.assertEqual(result.min_source_port_voltage_v, -3.0) + self.assertEqual(result.max_source_port_voltage_v, 4.0) + + def test_load_config_rejects_invalid_source_port_voltage_values(self): + for minimum, maximum, message in ( + ("true", "2.0", "finite number"), + ("-2.0", "true", "finite number"), + ("nan", "2.0", "finite number"), + ("-2.0", "inf", "finite number"), + ("2.0", "-2.0", "must be <"), + ): + with self.subTest(minimum=minimum, maximum=maximum), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "wavebench.toml" + path.write_text( + "[connection]\nresource = \"TCPIP::127.0.0.1::INSTR\"\n" + "[scope]\n[safety_limits]\n" + f"min_source_port_voltage_v = {minimum}\n" + f"max_source_port_voltage_v = {maximum}\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ConfigError, message): + load_config(path) + + def test_all_config_overrides_preserve_source_port_voltage_limits(self): + config = WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::127.0.0.1::INSTR", 100, 100), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("test.toml"), + safety_limits=SafetyLimitsConfig( + max_source_vpp=2.0, + min_source_port_voltage_v=-2.5, + max_source_port_voltage_v=3.5, + ), + ) + overrides = ( + config.with_connection_timeout_ms(200), + config.with_resource("TCPIP::127.0.0.2::INSTR"), + config.with_output_overrides(save_csv=False), + config.with_waveform_overrides(points="def"), + config.with_source_resource("TCPIP::127.0.0.3::INSTR"), + config.with_power_resource("TCPIP::127.0.0.4::INSTR"), + config.with_dmm_resource("TCPIP::127.0.0.5::INSTR"), + ) + for updated in overrides: + with self.subTest(updated=updated): + self.assertEqual(updated.safety_limits, config.safety_limits) def test_rejects_non_positive_safety_limit(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_errors.py b/tests/test_errors.py index 9524eff..d0e113c 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -9,6 +9,7 @@ InstrumentError, SessionCloseError, SessionHealthError, + SourceSafetyLimitsRequiredError, TransportIOError, error_envelope, ensure_error_envelope, @@ -68,6 +69,19 @@ def test_error_envelope_is_json_compatible() -> None: assert payload["schema"] == ERROR_SCHEMA +def test_source_safety_limits_required_error_has_stable_sorted_fields() -> None: + error = SourceSafetyLimitsRequiredError( + ("min_source_port_voltage_v", "max_source_vpp", "min_source_port_voltage_v") + ) + + payload = error.to_envelope(operation="source.output_v2").as_dict() + + assert payload["code"] == "source_safety_limits_required" + assert payload["details"] == { + "missing_fields": ["max_source_vpp", "min_source_port_voltage_v"], + } + + def test_session_health_error_is_zero_io_and_does_not_serialize_reason_or_cause() -> None: error = SessionHealthError( "blocked: SECRET:VALUE", diff --git a/tests/test_source_termination_evidence.py b/tests/test_source_termination_evidence.py new file mode 100644 index 0000000..377b98b --- /dev/null +++ b/tests/test_source_termination_evidence.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + SafetyLimitsConfig, + ScopeConfig, + SourceConfig, + SourceTerminationConfig, + WaveBenchConfig, + WaveformConfig, + load_config, +) +from wavebench.errors import ConfigError +from wavebench.instruments.source_extensions import ( + ResistanceBounds, + SourceFacetScope, + SourceScopeRef, + SourceTerminationEvidence, + TerminationEvidenceLifetime, + TerminationEvidenceSource, + TerminationKind, + TerminationSpec, +) +from wavebench.services.source_safety import ( + SourceTerminationEvidenceStatus, + source_config_termination_evidence, + source_termination_binding_digest, + source_termination_evidence_context, + validate_source_termination_evidence, +) + + +def make_config() -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "DMAX"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("wavebench.toml"), + source=SourceConfig( + "example.source", + "TCPIP::source::INSTR", + 1, + True, + True, + 0, + terminations=( + SourceTerminationConfig(1, "resistive", 49.5, 50.5), + SourceTerminationConfig(2, "high_impedance"), + ), + ), + safety_limits=SafetyLimitsConfig( + max_source_vpp=2.0, + min_source_port_voltage_v=-2.0, + max_source_port_voltage_v=2.0, + ), + ) + + +def test_termination_spec_requires_explicit_resistive_bounds() -> None: + assert TerminationSpec(TerminationKind.HIGH_IMPEDANCE).resistance_bounds is None + assert TerminationSpec( + TerminationKind.RESISTIVE, + ResistanceBounds(49.5, 50.5), + ).kind is TerminationKind.RESISTIVE + + with pytest.raises(ValueError, match="requires resistance_bounds"): + TerminationSpec(TerminationKind.RESISTIVE) + with pytest.raises(ValueError, match="source and lifetime"): + SourceTerminationEvidence( + target=SourceScopeRef(SourceFacetScope.CHANNEL, channel=1), + termination=TerminationSpec(TerminationKind.HIGH_IMPEDANCE), + source=TerminationEvidenceSource.CONFIG, + lifetime=TerminationEvidenceLifetime.OPERATION, + resource_fingerprint="sha256:" + "0" * 64, + binding_digest="sha256:" + "1" * 64, + observed_at_utc="2026-08-22T00:00:00.000Z", + expires_at_utc=None, + evidence_ref="test.evidence", + ) + + +def test_config_termination_evidence_is_bound_to_current_operation() -> None: + config = make_config() + target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=1) + context = source_termination_evidence_context( + config, + target=target, + correlation_id="correlation-1", + observed_at_utc="2026-08-22T00:00:00.000Z", + ) + + evidence = source_config_termination_evidence(config, context=context) + + assert evidence is not None + assert evidence.termination.kind is TerminationKind.RESISTIVE + assert evidence.termination.resistance_bounds == ResistanceBounds(49.5, 50.5) + assert evidence.source is TerminationEvidenceSource.CONFIG + assert evidence.lifetime is TerminationEvidenceLifetime.CONFIG_DIGEST + assert validate_source_termination_evidence(evidence, context=context).is_valid + + +def test_missing_config_termination_is_not_inferred_from_display_state() -> None: + config = make_config() + target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=3) + context = source_termination_evidence_context( + config, + target=target, + correlation_id="correlation-1", + observed_at_utc="2026-08-22T00:00:00.000Z", + ) + + assert source_config_termination_evidence(config, context=context) is None + + +@pytest.mark.parametrize( + ("change", "expected"), + ( + ("target", SourceTerminationEvidenceStatus.TARGET_MISMATCH), + ("resource", SourceTerminationEvidenceStatus.RESOURCE_MISMATCH), + ("binding", SourceTerminationEvidenceStatus.BINDING_MISMATCH), + ("expired", SourceTerminationEvidenceStatus.EXPIRED), + ("future", SourceTerminationEvidenceStatus.OBSERVED_IN_FUTURE), + ), +) +def test_termination_evidence_rejects_mismatched_or_expired_binding( + change: str, + expected: SourceTerminationEvidenceStatus, +) -> None: + config = make_config() + context = source_termination_evidence_context( + config, + target=SourceScopeRef(SourceFacetScope.CHANNEL, channel=1), + correlation_id="correlation-1", + observed_at_utc="2026-08-22T12:00:00.000Z", + ) + evidence = source_config_termination_evidence(config, context=context) + assert evidence is not None + if change == "target": + evidence = replace( + evidence, + target=SourceScopeRef(SourceFacetScope.CHANNEL, channel=2), + ) + elif change == "resource": + evidence = replace(evidence, resource_fingerprint="sha256:" + "2" * 64) + elif change == "binding": + evidence = replace(evidence, binding_digest="sha256:" + "3" * 64) + elif change == "expired": + evidence = replace( + evidence, + expires_at_utc="2026-08-22T11:00:00.000Z", + observed_at_utc="2026-08-22T10:00:00.000Z", + ) + else: + evidence = replace(evidence, observed_at_utc="2026-08-22T13:00:00.000Z") + + result = validate_source_termination_evidence(evidence, context=context) + + assert result.status is expected + assert result.evidence is None + + +def test_run_evidence_requires_run_intent_binding() -> None: + config = make_config() + context = source_termination_evidence_context( + config, + target=SourceScopeRef(SourceFacetScope.CHANNEL, channel=1), + correlation_id="correlation-1", + observed_at_utc="2026-08-22T00:00:00.000Z", + ) + with pytest.raises(ConfigError, match="run intent digest"): + source_termination_binding_digest( + context, + source=TerminationEvidenceSource.RUN_INTENT, + lifetime=TerminationEvidenceLifetime.RUN, + ) + + +def test_source_config_parser_preserves_sorted_termination_entries(tmp_path: Path) -> None: + path = tmp_path / "wavebench.toml" + path.write_text( + """ +[connection] +resource = "TCPIP::127.0.0.1::INSTR" + +[scope] + +[source] +resource = "TCPIP::127.0.0.2::INSTR" + +[[source.terminations]] +channel = 2 +kind = "high_impedance" + +[[source.terminations]] +channel = 1 +kind = "resistive" +minimum_ohm = 49.5 +maximum_ohm = 50.5 +""", + encoding="utf-8", + ) + + config = load_config(path) + + assert config.source is not None + assert config.source.terminations == ( + SourceTerminationConfig(1, "resistive", 49.5, 50.5), + SourceTerminationConfig(2, "high_impedance"), + ) + assert config.with_source_resource("TCPIP::127.0.0.3::INSTR").source.terminations == ( + SourceTerminationConfig(1, "resistive", 49.5, 50.5), + SourceTerminationConfig(2, "high_impedance"), + ) + + +@pytest.mark.parametrize( + "entry", + ( + "channel = 1\nkind = \"resistive\"", + "channel = 1\nkind = \"resistive\"\nminimum_ohm = 50", + "channel = 1\nkind = \"resistive\"\nminimum_ohm = 50\nmaximum_ohm = 49", + "channel = true\nkind = \"high_impedance\"", + "channel = 1\nkind = \"unknown\"", + ), +) +def test_source_config_parser_rejects_invalid_termination_entries( + tmp_path: Path, + entry: str, +) -> None: + path = tmp_path / "wavebench.toml" + path.write_text( + "[connection]\nresource = \"TCPIP::127.0.0.1::INSTR\"\n[scope]\n[source]\n" + "resource = \"TCPIP::127.0.0.2::INSTR\"\n[[source.terminations]]\n" + f"{entry}\n", + encoding="utf-8", + ) + + with pytest.raises(ConfigError, match="source.terminations"): + load_config(path) From 49448af5fe60acf39e24780f08f0f76be6d05e31 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:59:14 +0800 Subject: [PATCH 05/82] feat: evaluate conservative source output budgets --- src/wavebench/services/source_budget.py | 1472 +++++++++++++++++++++++ tests/test_source_budget.py | 814 +++++++++++++ 2 files changed, 2286 insertions(+) create mode 100644 src/wavebench/services/source_budget.py create mode 100644 tests/test_source_budget.py diff --git a/src/wavebench/services/source_budget.py b/src/wavebench/services/source_budget.py new file mode 100644 index 0000000..92b6955 --- /dev/null +++ b/src/wavebench/services/source_budget.py @@ -0,0 +1,1472 @@ +"""Pure, conservative Source V2 output-budget evaluation. + +This module deliberately has no dependency on a driver, transport, session, or +write capability. It evaluates an already captured consistent snapshot with +typed descriptor constraints and returns port-voltage bounds plus stable +blockers. A caller may use a positive result as one input to a later write +authorization, but evaluating a budget can never send instrument I/O. + +The first implementation supports only a finite-resistance Thevenin model. +Unknown state, a non-resistive load, an unmodelled Combine path, or an +incomplete shared-power envelope is a blocker rather than an invitation to +guess a nominal value. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite + +from wavebench.instruments.source_extensions import ( + ArbitraryFacet, + Availability, + BasicWaveFacet, + BudgetEvidenceSource, + BudgetProofStrength, + ComponentAmplitudeKind, + CompositeOutputBudget, + HarmonicCompleteness, + HarmonicFacet, + ModulationFacet, + Observed, + OutputFacet, + PortVoltageBounds, + ResistanceBounds, + SafetyContributor, + SnapshotConsistencyState, + SourceAmplitudeUnit, + SourceArbitraryOvershootConstraint, + SourceBudgetBlockerCode, + SourceChannelStateV2, + SourceConstraintApplicability, + SourceDescriptorExtensions, + SourceDisplayLoad, + SourceFeature, + SourceFrequencyDeratingBand, + SourceFrequencyDeratingConstraint, + SourceFrequencyMode, + SourceLoadKind, + SourceModulationEnvelopeConstraint, + SourceNoisePeakConstraint, + SourceOutputPolarity, + SourceReasonCode, + SourceResistanceConstraint, + SourceRuntimeIdentity, + SourceSafetyConstraint, + SourceSafetyConstraintKind, + SourceSharedPowerBudget, + SourceSharedPowerConstraint, + SourceSignalPathKind, + SourceSnapshotV2, + SourceTerminationEvidence, + SourceVoltageReferenceConstraint, + SourceWaveformKind, + SupportState, + TerminationKind, + TerminationSpec, + VoltageReferenceBasis, + source_v2_digest, +) +from wavebench.services.source_safety import ( + SourceEnergySafetyLimits, + SourceTerminationEvidenceContext, + SourceTerminationEvidenceStatus, + validate_source_termination_evidence, +) + + +@dataclass(frozen=True, slots=True) +class SourceOutputBudgetRequest: + """Input for one future energy-increasing Source V2 operation. + + ``target_channel`` is always treated as a projected active output, even if + it is currently OFF. ``projected_active_channels`` adds other direct + outputs that a future operation expects to be active. It only affects a + declared shared-power envelope; it does not change the snapshot or issue + a write. + + Termination contexts must carry the snapshot correlation ID. This keeps a + valid-looking config or manual evidence object from a different operation + out of the calculation. + """ + + snapshot: SourceSnapshotV2 + descriptor_extensions: SourceDescriptorExtensions + limits: SourceEnergySafetyLimits + target_channel: int + termination_evidence: tuple[SourceTerminationEvidence, ...] + termination_contexts: tuple[SourceTerminationEvidenceContext, ...] + projected_active_channels: tuple[int, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.snapshot, SourceSnapshotV2): + raise ValueError("source budget snapshot has an invalid type") + if not isinstance(self.descriptor_extensions, SourceDescriptorExtensions): + raise ValueError("source budget descriptor_extensions has an invalid type") + if not isinstance(self.limits, SourceEnergySafetyLimits): + raise ValueError("source budget limits has an invalid type") + _require_channel(self.target_channel, "source budget target_channel") + if not isinstance(self.termination_evidence, tuple) or any( + not isinstance(item, SourceTerminationEvidence) for item in self.termination_evidence + ): + raise ValueError("source budget termination_evidence has an invalid type") + if not isinstance(self.termination_contexts, tuple) or any( + not isinstance(item, SourceTerminationEvidenceContext) + for item in self.termination_contexts + ): + raise ValueError("source budget termination_contexts has an invalid type") + _require_channels( + self.projected_active_channels, + "source budget projected_active_channels", + allow_empty=True, + ) + evidence_channels = tuple(_context_channel(item.target) for item in self.termination_evidence) + context_channels = tuple(_context_channel(item.target) for item in self.termination_contexts) + if len(set(evidence_channels)) != len(evidence_channels): + raise ValueError("source budget termination_evidence channels must be unique") + if len(set(context_channels)) != len(context_channels): + raise ValueError("source budget termination_contexts channels must be unique") + + +@dataclass(frozen=True, slots=True) +class _TerminationResolution: + observed: Observed[TerminationSpec] + evidence_source: BudgetEvidenceSource | None + + +@dataclass(frozen=True, slots=True) +class _ChannelFacts: + channel: int + basic: BasicWaveFacet + output: OutputFacet + waveform_kind: SourceWaveformKind + frequency_mode: SourceFrequencyMode | None + frequency_min_hz: float | None + frequency_max_hz: float | None + amplitude_vpp: float | None + offset_v: float + polarity: SourceOutputPolarity + + +@dataclass(frozen=True, slots=True) +class _ChannelPortEvaluation: + channel: int + physical_port_channel: int + facts: _ChannelFacts | None + bounds: PortVoltageBounds | None + contributors: tuple[SafetyContributor, ...] + blockers: tuple[SourceBudgetBlockerCode, ...] + proof_strength: BudgetProofStrength + voltage_reference_basis: Observed[VoltageReferenceBasis] + display_load: Observed[TerminationSpec] + source_resistance: Observed[ResistanceBounds] + actual_termination: Observed[TerminationSpec] + actual_termination_evidence_source: BudgetEvidenceSource | None + + +@dataclass(frozen=True, slots=True) +class _GainEvaluation: + gain_upper: float + proof_strength: BudgetProofStrength + constraint_ids: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class _SharedPowerEvaluation: + observed: Observed[SourceSharedPowerBudget] + contributors: tuple[SafetyContributor, ...] + blockers: tuple[SourceBudgetBlockerCode, ...] + proof_strength: BudgetProofStrength + + +def evaluate_source_output_budget(request: SourceOutputBudgetRequest) -> CompositeOutputBudget: + """Return a conservative target-port budget without any instrument I/O.""" + + if not isinstance(request, SourceOutputBudgetRequest): + raise TypeError("source budget request has an invalid type") + + blockers: set[SourceBudgetBlockerCode] = set() + contributors: list[SafetyContributor] = [] + proof_strength = BudgetProofStrength.HARD_CONSERVATIVE + if request.snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + blockers.add(SourceBudgetBlockerCode.SNAPSHOT_NOT_CONSISTENT) + if request.snapshot.runtime_profile.descriptor_digest != source_v2_digest( + request.descriptor_extensions + ): + blockers.add(SourceBudgetBlockerCode.DESCRIPTOR_MISMATCH) + + channels = {state.channel: state for state in request.snapshot.channels} + target = channels.get(request.target_channel) + if target is None: + blockers.add(SourceBudgetBlockerCode.TARGET_CHANNEL_UNKNOWN) + return _incomplete_budget(blockers=blockers) + + combine_sources = _combine_source_channels(request, blockers) + main_evaluations: dict[int, _ChannelPortEvaluation] = {} + for channel_id in sorted(combine_sources): + channel = channels.get(channel_id) + if channel is None: + blockers.add(SourceBudgetBlockerCode.TARGET_CHANNEL_UNKNOWN) + continue + # An INTERNAL_WAVEFORM Combine source is not a separately terminated + # physical port. Its waveform is converted through the selected target + # port, whose actual termination is therefore the relevant evidence. + evaluation = _evaluate_channel_port( + request, + channel=channel, + physical_port_channel=request.target_channel, + ) + main_evaluations[channel_id] = evaluation + blockers.update(evaluation.blockers) + contributors.extend(evaluation.contributors) + proof_strength = _weaker_proof(proof_strength, evaluation.proof_strength) + if ( + channel_id != request.target_channel + and evaluation.facts is not None + and evaluation.facts.polarity is not SourceOutputPolarity.NORMAL + ): + # The relation graph names an internal waveform path but does not + # define how an auxiliary channel's output-polarity setting maps + # into that path. Do not reinterpret it electrically. + blockers.add(SourceBudgetBlockerCode.COMBINE_PATH_UNSUPPORTED) + + target_evaluation = main_evaluations.get(request.target_channel) + if target_evaluation is None: + blockers.add(SourceBudgetBlockerCode.TARGET_CHANNEL_UNKNOWN) + return _incomplete_budget(blockers=blockers) + + combined_bounds = _sum_evaluation_bounds(tuple(main_evaluations.values())) + if combined_bounds is None: + proof_strength = BudgetProofStrength.INCOMPLETE + + active_direct_channels = _active_direct_channels( + request, + channels=channels, + blockers=blockers, + ) + shared = _evaluate_shared_power( + request, + channels=channels, + active_direct_channels=active_direct_channels, + combine_sources=combine_sources, + main_evaluations=main_evaluations, + ) + blockers.update(shared.blockers) + contributors.extend(shared.contributors) + proof_strength = _weaker_proof(proof_strength, shared.proof_strength) + + if combined_bounds is not None: + if combined_bounds.vpp_upper_v > request.limits.max_source_vpp: + blockers.add(SourceBudgetBlockerCode.VPP_LIMIT_EXCEEDED) + if ( + combined_bounds.minimum_v_lower < request.limits.min_source_port_voltage_v + or combined_bounds.maximum_v_upper > request.limits.max_source_port_voltage_v + ): + blockers.add(SourceBudgetBlockerCode.PORT_VOLTAGE_LIMIT_EXCEEDED) + + if blockers: + proof_strength = BudgetProofStrength.INCOMPLETE + return CompositeOutputBudget( + bounds=_bounds_observed(combined_bounds), + voltage_reference_basis=target_evaluation.voltage_reference_basis, + display_load=target_evaluation.display_load, + output_source_resistance=target_evaluation.source_resistance, + actual_termination=target_evaluation.actual_termination, + shared_power=shared.observed, + proof_strength=proof_strength, + evidence_sources=_evidence_sources(contributors, shared.observed), + contributors=tuple(sorted(contributors, key=lambda item: item.contributor_id)), + blockers=tuple(sorted(blockers, key=lambda item: item.value)), + ) + + +def _evaluate_channel_port( + request: SourceOutputBudgetRequest, + *, + channel: SourceChannelStateV2, + physical_port_channel: int, +) -> _ChannelPortEvaluation: + blockers: set[SourceBudgetBlockerCode] = set() + contributors: list[SafetyContributor] = [] + proof = BudgetProofStrength.HARD_CONSERVATIVE + facts = _channel_facts(channel, blockers) + termination = _actual_termination(request, physical_port_channel, blockers) + if facts is None: + return _channel_incomplete( + channel=channel.channel, + physical_port_channel=physical_port_channel, + facts=None, + blockers=blockers, + actual_termination=termination, + ) + + voltage_constraint, voltage_proof = _single_constraint( + request, + SourceSafetyConstraintKind.VOLTAGE_REFERENCE, + facts, + blockers, + SourceBudgetBlockerCode.VOLTAGE_REFERENCE_MISSING, + ) + resistance_constraint, resistance_proof = _single_constraint( + request, + SourceSafetyConstraintKind.SOURCE_RESISTANCE, + facts, + blockers, + SourceBudgetBlockerCode.SOURCE_RESISTANCE_MISSING, + ) + proof = _weaker_proof(proof, voltage_proof, resistance_proof) + voltage_reference = _constraint_observed( + voltage_constraint, + SourceVoltageReferenceConstraint, + lambda profile: profile.basis, + ) + source_resistance = _constraint_observed( + resistance_constraint, + SourceResistanceConstraint, + lambda profile: profile.resistance_ohm, + ) + display_load = _display_load(facts.output, blockers) + if ( + voltage_reference.availability is not Availability.VALUE + or source_resistance.availability is not Availability.VALUE + or termination.observed.availability is not Availability.VALUE + ): + return _channel_incomplete( + channel=channel.channel, + physical_port_channel=physical_port_channel, + facts=facts, + blockers=blockers, + actual_termination=termination, + voltage_reference=voltage_reference, + display_load=display_load, + source_resistance=source_resistance, + proof=proof, + ) + + reference_bounds, reference_contributors, reference_blockers, reference_proof = _reference_bounds( + request, + channel, + facts, + ) + blockers.update(reference_blockers) + contributors.extend(reference_contributors) + proof = _weaker_proof(proof, reference_proof) + if reference_bounds is None: + return _channel_incomplete( + channel=channel.channel, + physical_port_channel=physical_port_channel, + facts=facts, + blockers=blockers, + actual_termination=termination, + voltage_reference=voltage_reference, + display_load=display_load, + source_resistance=source_resistance, + contributors=contributors, + proof=proof, + ) + + factor = _conversion_factor( + basis=voltage_reference.value, + source_resistance=source_resistance.value, + display_load=display_load, + actual_termination=termination.observed, + blockers=blockers, + ) + if factor is None: + return _channel_incomplete( + channel=channel.channel, + physical_port_channel=physical_port_channel, + facts=facts, + blockers=blockers, + actual_termination=termination, + voltage_reference=voltage_reference, + display_load=display_load, + source_resistance=source_resistance, + contributors=contributors, + proof=proof, + ) + minimum, maximum = _scaled_interval(reference_bounds, factor) + bounds = _make_bounds(minimum, maximum) + constraint_ids = tuple( + sorted( + { + *( + item.constraint_id + for item in (voltage_constraint, resistance_constraint) + if item is not None + ), + *(item.constraint_id for item in _all_reference_constraints(request, facts)), + } + ) + ) + evidence_sources = { + BudgetEvidenceSource.INSTRUMENT_READBACK, + BudgetEvidenceSource.DEVICE_HARD_LIMIT, + } + if termination.evidence_source is not None: + evidence_sources.add(termination.evidence_source) + contributors.append( + SafetyContributor( + contributor_id=( + f"channel-{channel.channel}.port" + if physical_port_channel == channel.channel + else f"channel-{channel.channel}.to-channel-{physical_port_channel}.port" + ), + feature=SourceFeature.BASIC, + channels=tuple(sorted({channel.channel, physical_port_channel})), + minimum_v=minimum, + maximum_v=maximum, + constraint_ids=constraint_ids, + proof_strength=proof, + evidence_sources=tuple(sorted(evidence_sources, key=lambda item: item.value)), + ) + ) + return _ChannelPortEvaluation( + channel=channel.channel, + physical_port_channel=physical_port_channel, + facts=facts, + bounds=bounds, + contributors=tuple(contributors), + blockers=tuple(sorted(blockers, key=lambda item: item.value)), + proof_strength=proof, + voltage_reference_basis=voltage_reference, + display_load=display_load, + source_resistance=source_resistance, + actual_termination=termination.observed, + actual_termination_evidence_source=termination.evidence_source, + ) + + +def _channel_facts( + channel: SourceChannelStateV2, + blockers: set[SourceBudgetBlockerCode], +) -> _ChannelFacts | None: + if channel.basic.availability is not Availability.VALUE or channel.output.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.BASIC_STATE_UNAVAILABLE) + return None + basic = channel.basic.value + output = channel.output.value + if ( + basic.waveform_kind.availability is not Availability.VALUE + or basic.offset_v.availability is not Availability.VALUE + ): + blockers.add(SourceBudgetBlockerCode.BASIC_STATE_UNAVAILABLE) + return None + waveform = basic.waveform_kind.value + if waveform in {SourceWaveformKind.OTHER, SourceWaveformKind.PULSE}: + blockers.add(SourceBudgetBlockerCode.WAVEFORM_UNSUPPORTED) + return None + if output.polarity.availability is not Availability.VALUE or output.polarity.value is SourceOutputPolarity.UNKNOWN: + blockers.add(SourceBudgetBlockerCode.OUTPUT_POLARITY_UNAVAILABLE) + return None + + frequency_mode: SourceFrequencyMode | None = None + frequency_min_hz: float | None = None + frequency_max_hz: float | None = None + if basic.frequency_mode.availability is Availability.VALUE: + frequency_mode = basic.frequency_mode.value + if frequency_mode in {SourceFrequencyMode.UNKNOWN, SourceFrequencyMode.LIST}: + blockers.add(SourceBudgetBlockerCode.FREQUENCY_MODE_UNSUPPORTED) + return None + if frequency_mode is SourceFrequencyMode.FIXED: + if basic.frequency_hz.availability is Availability.VALUE: + frequency_min_hz = basic.frequency_hz.value + frequency_max_hz = basic.frequency_hz.value + elif waveform not in {SourceWaveformKind.DC, SourceWaveformKind.NOISE}: + blockers.add(SourceBudgetBlockerCode.BASIC_STATE_UNAVAILABLE) + return None + elif frequency_mode is SourceFrequencyMode.SWEEP: + if channel.sweep.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.SWEEP_DERATING_MISSING) + return None + sweep = channel.sweep.value + if ( + sweep.enabled.availability is not Availability.VALUE + or not sweep.enabled.value + or sweep.start_hz.availability is not Availability.VALUE + or sweep.stop_hz.availability is not Availability.VALUE + ): + blockers.add(SourceBudgetBlockerCode.SWEEP_DERATING_MISSING) + return None + frequency_min_hz = min(sweep.start_hz.value, sweep.stop_hz.value) + frequency_max_hz = max(sweep.start_hz.value, sweep.stop_hz.value) + elif waveform not in {SourceWaveformKind.DC, SourceWaveformKind.NOISE}: + blockers.add(SourceBudgetBlockerCode.FREQUENCY_MODE_UNSUPPORTED) + return None + + amplitude_vpp: float | None = None + if basic.amplitude.availability is Availability.VALUE: + amplitude = basic.amplitude.value + if amplitude.unit is SourceAmplitudeUnit.VPP: + amplitude_vpp = amplitude.value + elif waveform not in {SourceWaveformKind.NOISE, SourceWaveformKind.DC}: + blockers.add(SourceBudgetBlockerCode.AMPLITUDE_UNIT_UNSUPPORTED) + return None + elif waveform not in {SourceWaveformKind.DC, SourceWaveformKind.NOISE}: + blockers.add(SourceBudgetBlockerCode.BASIC_STATE_UNAVAILABLE) + return None + if waveform is SourceWaveformKind.DC and amplitude_vpp not in {None, 0.0}: + # The V2 basic facet has no independent DC low/high-level model. A + # nonzero Vpp alongside DC therefore cannot be safely interpreted as + # merely redundant display information. + blockers.add(SourceBudgetBlockerCode.DC_LEVEL_UNAVAILABLE) + return None + + return _ChannelFacts( + channel=channel.channel, + basic=basic, + output=output, + waveform_kind=waveform, + frequency_mode=frequency_mode, + frequency_min_hz=frequency_min_hz, + frequency_max_hz=frequency_max_hz, + amplitude_vpp=amplitude_vpp, + offset_v=basic.offset_v.value, + polarity=output.polarity.value, + ) + + +def _reference_bounds( + request: SourceOutputBudgetRequest, + channel: SourceChannelStateV2, + facts: _ChannelFacts, +) -> tuple[ + tuple[float, float] | None, + list[SafetyContributor], + set[SourceBudgetBlockerCode], + BudgetProofStrength, +]: + blockers: set[SourceBudgetBlockerCode] = set() + contributors: list[SafetyContributor] = [] + proof = BudgetProofStrength.HARD_CONSERVATIVE + constraint_ids: set[str] = set() + + if facts.waveform_kind is SourceWaveformKind.NOISE: + constraints, noise_proof = _constraints( + request, + SourceSafetyConstraintKind.NOISE_PEAK, + facts, + ) + proof = _weaker_proof(proof, noise_proof) + noise = tuple( + item for item in constraints if isinstance(item.profile, SourceNoisePeakConstraint) + ) + if not noise: + blockers.add(SourceBudgetBlockerCode.NOISE_PEAK_MISSING) + return None, contributors, blockers, BudgetProofStrength.INCOMPLETE + if noise_proof is not BudgetProofStrength.HARD_CONSERVATIVE: + blockers.add(SourceBudgetBlockerCode.CONSTRAINT_NOT_HARD) + return None, contributors, blockers, BudgetProofStrength.INCOMPLETE + peak = max(item.profile.absolute_peak_upper_v for item in noise) + constraint_ids.update(item.constraint_id for item in noise) + else: + peak = (facts.amplitude_vpp or 0.0) / 2.0 + ac_minimum, ac_maximum = -peak, peak + contributors.append( + _contributor( + contributor_id=f"channel-{channel.channel}.base_reference", + feature=SourceFeature.BASIC, + channels=(channel.channel,), + minimum=ac_minimum, + maximum=ac_maximum, + constraint_ids=tuple(sorted(constraint_ids)), + proof_strength=proof, + ) + ) + + harmonic_bounds, harmonic_contributors, harmonic_blockers = _harmonic_bounds(channel) + blockers.update(harmonic_blockers) + contributors.extend(harmonic_contributors) + if harmonic_bounds is not None: + ac_minimum += harmonic_bounds[0] + ac_maximum += harmonic_bounds[1] + + modulation = _modulation_gain(request, channel, facts, blockers) + arbitrary = _arbitrary_gain(request, channel, facts, blockers) + sweep = _sweep_gain(request, channel, facts, blockers) + proof = _weaker_proof( + proof, + modulation.proof_strength, + arbitrary.proof_strength, + sweep.proof_strength, + ) + gain = modulation.gain_upper * arbitrary.gain_upper * sweep.gain_upper + if not isfinite(gain) or gain < 1.0: + blockers.add(SourceBudgetBlockerCode.CONSTRAINT_NOT_HARD) + return None, contributors, blockers, BudgetProofStrength.INCOMPLETE + if gain != 1.0: + ac_minimum, ac_maximum = _scaled_interval( + (ac_minimum, ac_maximum), + (gain, gain), + ) + applied = tuple( + item + for item in (SourceFeature.MODULATION, SourceFeature.ARBITRARY, SourceFeature.SWEEP) + if ( + (item is SourceFeature.MODULATION and modulation.gain_upper != 1.0) + or (item is SourceFeature.ARBITRARY and arbitrary.gain_upper != 1.0) + or (item is SourceFeature.SWEEP and sweep.gain_upper != 1.0) + ) + ) + feature = applied[0] + contributors.append( + _contributor( + contributor_id=f"channel-{channel.channel}.{feature.value}_ac_envelope", + feature=feature, + channels=(channel.channel,), + minimum=ac_minimum, + maximum=ac_maximum, + constraint_ids=tuple( + sorted( + set(modulation.constraint_ids) + | set(arbitrary.constraint_ids) + | set(sweep.constraint_ids) + ) + ), + proof_strength=proof, + ) + ) + if blockers: + return None, contributors, blockers, proof + + # gain_upper is an AC-envelope bound. It applies around the configured + # DC offset, rather than multiplying the offset itself. That distinction + # is essential for asymmetric absolute-voltage limits. + minimum = facts.offset_v + ac_minimum + maximum = facts.offset_v + ac_maximum + if facts.polarity is SourceOutputPolarity.INVERTED: + minimum, maximum = -maximum, -minimum + return (minimum, maximum), contributors, blockers, proof + + +def _harmonic_bounds( + channel: SourceChannelStateV2, +) -> tuple[ + tuple[float, float] | None, + list[SafetyContributor], + set[SourceBudgetBlockerCode], +]: + blockers: set[SourceBudgetBlockerCode] = set() + contributors: list[SafetyContributor] = [] + if channel.harmonics.availability in {Availability.UNSUPPORTED, Availability.NOT_APPLICABLE}: + return (0.0, 0.0), contributors, blockers + if channel.harmonics.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.HARMONIC_STATE_UNAVAILABLE) + return None, contributors, blockers + facet: HarmonicFacet = channel.harmonics.value + if facet.enabled.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.HARMONIC_STATE_UNAVAILABLE) + return None, contributors, blockers + if not facet.enabled.value: + return (0.0, 0.0), contributors, blockers + if ( + facet.completeness.availability is not Availability.VALUE + or facet.completeness.value is not HarmonicCompleteness.COMPLETE + or facet.maximum_supported_order.availability is not Availability.VALUE + or facet.components.availability is not Availability.VALUE + ): + blockers.add(SourceBudgetBlockerCode.HARMONIC_COMPLETENESS_INSUFFICIENT) + return None, contributors, blockers + peak = 0.0 + for component in facet.components.value: + if component.amplitude.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.HARMONIC_STATE_UNAVAILABLE) + return None, contributors, blockers + amplitude = component.amplitude.value + if amplitude.kind is not ComponentAmplitudeKind.ABSOLUTE_VPP: + blockers.add(SourceBudgetBlockerCode.HARMONIC_AMPLITUDE_UNSUPPORTED) + return None, contributors, blockers + peak += amplitude.value / 2.0 + contributors.append( + _contributor( + contributor_id=f"channel-{channel.channel}.harmonics_reference", + feature=SourceFeature.HARMONICS, + channels=(channel.channel,), + minimum=-peak, + maximum=peak, + constraint_ids=(), + proof_strength=BudgetProofStrength.HARD_CONSERVATIVE, + ) + ) + return (-peak, peak), contributors, blockers + + +def _modulation_gain( + request: SourceOutputBudgetRequest, + channel: SourceChannelStateV2, + facts: _ChannelFacts, + blockers: set[SourceBudgetBlockerCode], +) -> _GainEvaluation: + if channel.modulation.availability in {Availability.UNSUPPORTED, Availability.NOT_APPLICABLE}: + return _GainEvaluation(1.0, BudgetProofStrength.HARD_CONSERVATIVE, ()) + if channel.modulation.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.MODULATION_CONSTRAINT_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + facet: ModulationFacet = channel.modulation.value + if ( + facet.enabled.availability is not Availability.VALUE + or facet.kind.availability is not Availability.VALUE + or facet.source.availability is not Availability.VALUE + or facet.source.value.value != "internal" + ): + blockers.add(SourceBudgetBlockerCode.MODULATION_CONSTRAINT_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + if not facet.enabled.value: + return _GainEvaluation(1.0, BudgetProofStrength.HARD_CONSERVATIVE, ()) + constraints, proof = _constraints( + request, + SourceSafetyConstraintKind.MODULATION_ENVELOPE, + facts, + ) + matching = tuple( + item + for item in constraints + if isinstance(item.profile, SourceModulationEnvelopeConstraint) + and item.profile.kind is facet.kind.value + ) + if not matching: + blockers.add(SourceBudgetBlockerCode.MODULATION_CONSTRAINT_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + if proof is not BudgetProofStrength.HARD_CONSERVATIVE: + blockers.add(SourceBudgetBlockerCode.CONSTRAINT_NOT_HARD) + return _GainEvaluation( + max(item.profile.gain_upper for item in matching), + proof, + tuple(sorted(item.constraint_id for item in matching)), + ) + + +def _arbitrary_gain( + request: SourceOutputBudgetRequest, + channel: SourceChannelStateV2, + facts: _ChannelFacts, + blockers: set[SourceBudgetBlockerCode], +) -> _GainEvaluation: + if facts.waveform_kind is not SourceWaveformKind.ARBITRARY: + return _GainEvaluation(1.0, BudgetProofStrength.HARD_CONSERVATIVE, ()) + if channel.arbitrary.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.ARBITRARY_OVERSHOOT_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + facet: ArbitraryFacet = channel.arbitrary.value + if ( + facet.playback_mode.availability is not Availability.VALUE + or facet.selected_waveform_id.availability is not Availability.VALUE + ): + blockers.add(SourceBudgetBlockerCode.ARBITRARY_OVERSHOOT_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + constraints, proof = _constraints( + request, + SourceSafetyConstraintKind.ARBITRARY_OVERSHOOT, + facts, + ) + matching = tuple( + item for item in constraints if isinstance(item.profile, SourceArbitraryOvershootConstraint) + ) + if not matching: + blockers.add(SourceBudgetBlockerCode.ARBITRARY_OVERSHOOT_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + if proof is not BudgetProofStrength.HARD_CONSERVATIVE: + blockers.add(SourceBudgetBlockerCode.CONSTRAINT_NOT_HARD) + return _GainEvaluation( + max(item.profile.gain_upper for item in matching), + proof, + tuple(sorted(item.constraint_id for item in matching)), + ) + + +def _sweep_gain( + request: SourceOutputBudgetRequest, + channel: SourceChannelStateV2, + facts: _ChannelFacts, + blockers: set[SourceBudgetBlockerCode], +) -> _GainEvaluation: + if facts.frequency_mode is not SourceFrequencyMode.SWEEP: + return _GainEvaluation(1.0, BudgetProofStrength.HARD_CONSERVATIVE, ()) + if facts.frequency_min_hz is None or facts.frequency_max_hz is None: + blockers.add(SourceBudgetBlockerCode.SWEEP_DERATING_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + constraints, proof = _constraints( + request, + SourceSafetyConstraintKind.FREQUENCY_DERATING, + facts, + ) + bands = tuple( + band + for item in constraints + if isinstance(item.profile, SourceFrequencyDeratingConstraint) + for band in item.profile.bands + ) + covered, maximum_gain = _cover_frequency_range( + tuple(sorted(bands, key=lambda item: item.frequency_hz.minimum)), + facts.frequency_min_hz, + facts.frequency_max_hz, + ) + if not covered: + blockers.add(SourceBudgetBlockerCode.SWEEP_DERATING_MISSING) + return _GainEvaluation(1.0, BudgetProofStrength.INCOMPLETE, ()) + if proof is not BudgetProofStrength.HARD_CONSERVATIVE: + blockers.add(SourceBudgetBlockerCode.CONSTRAINT_NOT_HARD) + return _GainEvaluation( + maximum_gain, + proof, + tuple(sorted(item.constraint_id for item in constraints)), + ) + + +def _single_constraint( + request: SourceOutputBudgetRequest, + kind: SourceSafetyConstraintKind, + facts: _ChannelFacts, + blockers: set[SourceBudgetBlockerCode], + missing: SourceBudgetBlockerCode, +) -> tuple[SourceSafetyConstraint | None, BudgetProofStrength]: + constraints, proof = _constraints(request, kind, facts) + if not constraints: + blockers.add(missing) + return None, BudgetProofStrength.INCOMPLETE + profiles = {source_v2_digest(item.profile) for item in constraints} + if len(profiles) != 1: + blockers.add(SourceBudgetBlockerCode.CONSTRAINT_NOT_HARD) + return None, BudgetProofStrength.INCOMPLETE + if proof is not BudgetProofStrength.HARD_CONSERVATIVE: + blockers.add(SourceBudgetBlockerCode.CONSTRAINT_NOT_HARD) + return constraints[0], proof + + +def _constraints( + request: SourceOutputBudgetRequest, + kind: SourceSafetyConstraintKind, + facts: _ChannelFacts, +) -> tuple[tuple[SourceSafetyConstraint, ...], BudgetProofStrength]: + matching = tuple( + constraint + for constraint in request.descriptor_extensions.safety_profile.constraints + if constraint.kind is kind + and _applicability_matches(constraint.applicability, request.snapshot.runtime_profile.identity, facts) + ) + if not matching: + return (), BudgetProofStrength.INCOMPLETE + hard = tuple( + item for item in matching if item.proof_strength is BudgetProofStrength.HARD_CONSERVATIVE + ) + if hard: + return hard, BudgetProofStrength.HARD_CONSERVATIVE + return matching, _strongest_nonhard(item.proof_strength for item in matching) + + +def _all_reference_constraints( + request: SourceOutputBudgetRequest, + facts: _ChannelFacts, +) -> tuple[SourceSafetyConstraint, ...]: + relevant = { + SourceSafetyConstraintKind.NOISE_PEAK, + SourceSafetyConstraintKind.MODULATION_ENVELOPE, + SourceSafetyConstraintKind.ARBITRARY_OVERSHOOT, + SourceSafetyConstraintKind.FREQUENCY_DERATING, + } + return tuple( + constraint + for constraint in request.descriptor_extensions.safety_profile.constraints + if constraint.kind in relevant + and _applicability_matches(constraint.applicability, request.snapshot.runtime_profile.identity, facts) + and constraint.proof_strength is BudgetProofStrength.HARD_CONSERVATIVE + ) + + +def _applicability_matches( + applicability: SourceConstraintApplicability, + identity: SourceRuntimeIdentity, + facts: _ChannelFacts, +) -> bool: + if applicability.models and identity.model not in applicability.models: + return False + if applicability.firmware_ids and identity.firmware_id not in applicability.firmware_ids: + return False + if applicability.option_ids and not set(applicability.option_ids) <= set(identity.option_ids): + return False + if applicability.waveform_kinds and facts.waveform_kind not in applicability.waveform_kinds: + return False + if applicability.frequency_hz is not None: + if facts.frequency_min_hz is None or facts.frequency_max_hz is None: + return False + if not ( + applicability.frequency_hz.minimum <= facts.frequency_min_hz + and facts.frequency_max_hz <= applicability.frequency_hz.maximum + ): + return False + if applicability.amplitude_vpp is not None: + if facts.amplitude_vpp is None or not ( + applicability.amplitude_vpp.minimum + <= facts.amplitude_vpp + <= applicability.amplitude_vpp.maximum + ): + return False + return applicability.offset_v is None or ( + applicability.offset_v.minimum <= facts.offset_v <= applicability.offset_v.maximum + ) + + +def _actual_termination( + request: SourceOutputBudgetRequest, + channel: int, + blockers: set[SourceBudgetBlockerCode], +) -> _TerminationResolution: + evidence = next( + (item for item in request.termination_evidence if _context_channel(item.target) == channel), + None, + ) + context = next( + (item for item in request.termination_contexts if _context_channel(item.target) == channel), + None, + ) + if evidence is None or context is None: + blockers.add(SourceBudgetBlockerCode.ACTUAL_TERMINATION_MISSING) + return _TerminationResolution(_missing(), None) + if context.correlation_id != request.snapshot.correlation_id: + blockers.add(SourceBudgetBlockerCode.TERMINATION_EVIDENCE_INVALID) + return _TerminationResolution(_missing(), None) + validation = validate_source_termination_evidence(evidence, context=context) + if validation.status is not SourceTerminationEvidenceStatus.VALID: + blockers.add(SourceBudgetBlockerCode.TERMINATION_EVIDENCE_INVALID) + return _TerminationResolution(_missing(), None) + assert validation.evidence is not None + if ( + validation.evidence.termination.kind is not TerminationKind.RESISTIVE + or validation.evidence.termination.resistance_bounds is None + ): + blockers.add(SourceBudgetBlockerCode.TERMINATION_NOT_RESISTIVE) + return _TerminationResolution(Observed.value_of(validation.evidence.termination), None) + evidence_source = ( + BudgetEvidenceSource.EXTERNAL_MEASUREMENT + if validation.evidence.source.value == "external_measurement" + else BudgetEvidenceSource.EXPLICIT_TERMINATION + ) + return _TerminationResolution(Observed.value_of(validation.evidence.termination), evidence_source) + + +def _display_load( + output: OutputFacet, + blockers: set[SourceBudgetBlockerCode], +) -> Observed[TerminationSpec]: + if output.display_load.availability is not Availability.VALUE: + return Observed( + availability=output.display_load.availability, + reason_code=output.display_load.reason_code, + evidence_refs=output.display_load.evidence_refs, + ) + load: SourceDisplayLoad = output.display_load.value + if load.kind is SourceLoadKind.RESISTIVE: + assert load.resistance_ohm is not None + return Observed.value_of( + TerminationSpec( + TerminationKind.RESISTIVE, + ResistanceBounds(load.resistance_ohm, load.resistance_ohm), + ), + evidence_refs=output.display_load.evidence_refs, + ) + if load.kind is SourceLoadKind.HIGH_IMPEDANCE: + return Observed.value_of( + TerminationSpec(TerminationKind.HIGH_IMPEDANCE), + evidence_refs=output.display_load.evidence_refs, + ) + blockers.add(SourceBudgetBlockerCode.DISPLAY_LOAD_UNSUPPORTED) + return Observed.missing(Availability.UNKNOWN, SourceReasonCode.SUPPORT_UNKNOWN) + + +def _conversion_factor( + *, + basis: VoltageReferenceBasis, + source_resistance: ResistanceBounds, + display_load: Observed[TerminationSpec], + actual_termination: Observed[TerminationSpec], + blockers: set[SourceBudgetBlockerCode], +) -> tuple[float, float] | None: + if actual_termination.availability is not Availability.VALUE: + return None + actual = actual_termination.value + if actual.kind is not TerminationKind.RESISTIVE or actual.resistance_bounds is None: + blockers.add(SourceBudgetBlockerCode.TERMINATION_NOT_RESISTIVE) + return None + if basis is VoltageReferenceBasis.DELIVERED_INTO_DISPLAY_LOAD: + if display_load.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.DISPLAY_LOAD_UNAVAILABLE) + return None + display = display_load.value + if display.kind is not TerminationKind.RESISTIVE or display.resistance_bounds is None: + blockers.add(SourceBudgetBlockerCode.DISPLAY_LOAD_UNSUPPORTED) + return None + factors = tuple( + ((source + shown) / shown) * (actual_load / (source + actual_load)) + for source in _endpoints(source_resistance) + for shown in _endpoints(display.resistance_bounds) + for actual_load in _endpoints(actual.resistance_bounds) + ) + else: + factors = tuple( + actual_load / (source + actual_load) + for source in _endpoints(source_resistance) + for actual_load in _endpoints(actual.resistance_bounds) + ) + if not factors or any(not isfinite(item) or item <= 0 for item in factors): + blockers.add(SourceBudgetBlockerCode.SOURCE_RESISTANCE_MISSING) + return None + return min(factors), max(factors) + + +def _combine_source_channels( + request: SourceOutputBudgetRequest, + blockers: set[SourceBudgetBlockerCode], +) -> set[int]: + """Return waveform sources proven to feed the target's physical port. + + M3 only handles explicitly mapped ``INTERNAL_WAVEFORM`` edges. A generic + output-port or otherwise unknown Combine path cannot be safely translated + through one physical termination, so it remains a blocker until a later + feature-specific contract adds its electrical model. + """ + + target = request.target_channel + sources = {target} + if not _feature_declared(request, SourceFeature.COMBINE): + return sources + if request.snapshot.cross_channel.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.COMBINE_STATE_UNAVAILABLE) + return sources + cross_channel = request.snapshot.cross_channel.value + declared_sets = { + feature.channels + for feature in request.descriptor_extensions.features + if feature.feature is SourceFeature.COMBINE + and feature.support is not SupportState.UNSUPPORTED + } + relations = tuple( + relation for relation in cross_channel.relations if relation.feature is SourceFeature.COMBINE + ) + if not declared_sets <= {relation.channels for relation in relations}: + blockers.add(SourceBudgetBlockerCode.COMBINE_STATE_UNAVAILABLE) + return sources + if any(relation.enabled.availability is not Availability.VALUE for relation in relations): + blockers.add(SourceBudgetBlockerCode.COMBINE_STATE_UNAVAILABLE) + return sources + active_relations = tuple(relation for relation in relations if relation.enabled.value) + if not active_relations: + return sources + if cross_channel.relation_graph.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.COMBINE_STATE_UNAVAILABLE) + return sources + graph = cross_channel.relation_graph.value + active_edges = tuple( + edge + for edge in graph.edges + if edge.feature is SourceFeature.COMBINE + and any( + set(edge.sources + edge.targets) <= set(relation.channels) + for relation in active_relations + ) + ) + changed = True + while changed: + changed = False + for edge in active_edges: + if not set(edge.targets) & sources: + continue + if edge.signal_path is not SourceSignalPathKind.INTERNAL_WAVEFORM: + blockers.add(SourceBudgetBlockerCode.COMBINE_PATH_UNSUPPORTED) + continue + before = len(sources) + sources.update(edge.sources) + changed = changed or len(sources) != before + + # If an enabled relation claims the target as a destination but the graph + # supplies no active compatible edge, there is no trustworthy way to know + # which waveform reaches the port. + for relation in active_relations: + if target not in relation.channels: + continue + destination_edges = tuple( + edge + for edge in active_edges + if target in edge.targets and set(edge.sources + edge.targets) <= set(relation.channels) + ) + if not destination_edges: + blockers.add(SourceBudgetBlockerCode.COMBINE_STATE_UNAVAILABLE) + return sources + + +def _active_direct_channels( + request: SourceOutputBudgetRequest, + *, + channels: dict[int, SourceChannelStateV2], + blockers: set[SourceBudgetBlockerCode], +) -> set[int]: + active = {request.target_channel, *request.projected_active_channels} + shared_power_declared = _feature_declared(request, SourceFeature.SHARED_POWER) + for channel in request.snapshot.channels: + if channel.output.availability is not Availability.VALUE: + if shared_power_declared: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_STATE_UNAVAILABLE) + continue + if channel.output.value.enabled.availability is not Availability.VALUE: + if shared_power_declared: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_STATE_UNAVAILABLE) + continue + if channel.output.value.enabled.value: + active.add(channel.channel) + if any(channel not in channels for channel in active): + blockers.add(SourceBudgetBlockerCode.ACTIVE_CHANNEL_UNKNOWN) + return active + + +def _evaluate_shared_power( + request: SourceOutputBudgetRequest, + *, + channels: dict[int, SourceChannelStateV2], + active_direct_channels: set[int], + combine_sources: set[int], + main_evaluations: dict[int, _ChannelPortEvaluation], +) -> _SharedPowerEvaluation: + if not _feature_declared(request, SourceFeature.SHARED_POWER): + return _SharedPowerEvaluation(_not_applicable(), (), (), BudgetProofStrength.HARD_CONSERVATIVE) + + blockers: set[SourceBudgetBlockerCode] = set() + contributors: list[SafetyContributor] = [] + if len(combine_sources) > 1: + # A shared-power device must provide a dedicated envelope for internal + # Combine. Target-port voltage alone cannot bound each amplifier's + # supply power, so M3 deliberately does not extrapolate it. + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + return _SharedPowerEvaluation(_missing(), (), tuple(sorted(blockers, key=lambda x: x.value)), BudgetProofStrength.INCOMPLETE) + if request.snapshot.cross_channel.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_STATE_UNAVAILABLE) + return _SharedPowerEvaluation(_missing(), (), tuple(sorted(blockers, key=lambda x: x.value)), BudgetProofStrength.INCOMPLETE) + shared_observed = request.snapshot.cross_channel.value.shared_power + if shared_observed.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_STATE_UNAVAILABLE) + return _SharedPowerEvaluation(_missing(), (), tuple(sorted(blockers, key=lambda x: x.value)), BudgetProofStrength.INCOMPLETE) + shared_state = shared_observed.value + if ( + shared_state.active_power_upper_w.availability is not Availability.VALUE + or shared_state.hard_limit_w.availability is not Availability.VALUE + ): + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_STATE_UNAVAILABLE) + return _SharedPowerEvaluation(_missing(), (), tuple(sorted(blockers, key=lambda x: x.value)), BudgetProofStrength.INCOMPLETE) + if not active_direct_channels <= set(shared_state.participants): + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + + evaluations: dict[int, _ChannelPortEvaluation] = dict(main_evaluations) + for channel_id in sorted(active_direct_channels): + if channel_id not in channels: + blockers.add(SourceBudgetBlockerCode.ACTIVE_CHANNEL_UNKNOWN) + continue + if channel_id not in evaluations: + evaluations[channel_id] = _evaluate_channel_port( + request, + channel=channels[channel_id], + physical_port_channel=channel_id, + ) + contributors.extend(evaluations[channel_id].contributors) + evaluation = evaluations[channel_id] + blockers.update(evaluation.blockers) + + facts = tuple( + evaluations[channel_id].facts + for channel_id in sorted(active_direct_channels) + if channel_id in evaluations and evaluations[channel_id].facts is not None + ) + if len(facts) != len(active_direct_channels): + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + constraints = tuple( + item + for item in request.descriptor_extensions.safety_profile.constraints + if item.kind is SourceSafetyConstraintKind.SHARED_POWER + and isinstance(item.profile, SourceSharedPowerConstraint) + and item.proof_strength is BudgetProofStrength.HARD_CONSERVATIVE + and active_direct_channels <= set(item.profile.participants) + and all( + _applicability_matches( + item.applicability, + request.snapshot.runtime_profile.identity, + fact, + ) + for fact in facts + ) + ) + if not constraints: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + return _SharedPowerEvaluation(_missing(), tuple(contributors), tuple(sorted(blockers, key=lambda x: x.value)), BudgetProofStrength.INCOMPLETE) + + descriptor_limit = min(item.profile.maximum_power_w for item in constraints) + runtime_limit = shared_state.hard_limit_w.value + if runtime_limit > descriptor_limit: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + effective_limit = min(descriptor_limit, runtime_limit) + + projected = 0.0 + for channel_id in active_direct_channels: + evaluation = evaluations.get(channel_id) + if evaluation is None or evaluation.bounds is None: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + continue + power = _power_upper(evaluation) + if power is None: + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + continue + projected += power + if not isfinite(projected): + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_CONSTRAINT_MISSING) + return _SharedPowerEvaluation(_missing(), tuple(contributors), tuple(sorted(blockers, key=lambda x: x.value)), BudgetProofStrength.INCOMPLETE) + projected = max(projected, shared_state.active_power_upper_w.value) + budget = SourceSharedPowerBudget( + participants=tuple(sorted(active_direct_channels)), + observed_active_power_upper_w=shared_state.active_power_upper_w.value, + projected_power_upper_w=projected, + effective_hard_limit_w=effective_limit, + constraint_ids=tuple(sorted(item.constraint_id for item in constraints)), + evidence_sources=( + BudgetEvidenceSource.DEVICE_HARD_LIMIT, + BudgetEvidenceSource.INSTRUMENT_READBACK, + ), + ) + if ( + shared_state.active_power_upper_w.value > effective_limit + or projected > effective_limit + ): + blockers.add(SourceBudgetBlockerCode.SHARED_POWER_LIMIT_EXCEEDED) + proof = BudgetProofStrength.HARD_CONSERVATIVE + if blockers: + proof = BudgetProofStrength.INCOMPLETE + return _SharedPowerEvaluation( + Observed.value_of(budget), + tuple(contributors), + tuple(sorted(blockers, key=lambda x: x.value)), + proof, + ) + + +def _power_upper(evaluation: _ChannelPortEvaluation) -> float | None: + if ( + evaluation.bounds is None + or evaluation.bounds.rms_upper_v is None + or evaluation.actual_termination.availability is not Availability.VALUE + ): + return None + termination = evaluation.actual_termination.value + if termination.kind is not TerminationKind.RESISTIVE or termination.resistance_bounds is None: + return None + value = (evaluation.bounds.rms_upper_v**2) / termination.resistance_bounds.minimum_ohm + return value if isfinite(value) and value >= 0.0 else None + + +def _feature_declared(request: SourceOutputBudgetRequest, feature: SourceFeature) -> bool: + return any( + item.feature is feature and item.support is not SupportState.UNSUPPORTED + for item in request.descriptor_extensions.features + ) + + +def _constraint_observed( + constraint: SourceSafetyConstraint | None, + expected_type: type[object], + value_getter, +) -> Observed[object]: + if constraint is None or not isinstance(constraint.profile, expected_type): + return _missing() + return Observed.value_of(value_getter(constraint.profile), evidence_refs=constraint.evidence_refs) + + +def _cover_frequency_range( + bands: tuple[SourceFrequencyDeratingBand, ...], + minimum: float, + maximum: float, +) -> tuple[bool, float]: + current = minimum + maximum_gain = 1.0 + for band in bands: + if band.frequency_hz.maximum < current: + continue + if band.frequency_hz.minimum > current: + return False, maximum_gain + maximum_gain = max(maximum_gain, band.gain_upper) + current = max(current, band.frequency_hz.maximum) + if current >= maximum: + return True, maximum_gain + return False, maximum_gain + + +def _sum_evaluation_bounds( + evaluations: tuple[_ChannelPortEvaluation, ...], +) -> PortVoltageBounds | None: + bounds: PortVoltageBounds | None = None + for evaluation in evaluations: + if evaluation.bounds is None: + return None + bounds = evaluation.bounds if bounds is None else _sum_bounds(bounds, evaluation.bounds) + return bounds + + +def _sum_bounds(first: PortVoltageBounds, second: PortVoltageBounds) -> PortVoltageBounds: + return _make_bounds( + first.minimum_v_lower + second.minimum_v_lower, + first.maximum_v_upper + second.maximum_v_upper, + ) + + +def _make_bounds(minimum: float, maximum: float) -> PortVoltageBounds: + absolute = max(abs(minimum), abs(maximum)) + return PortVoltageBounds( + minimum_v_lower=minimum, + maximum_v_upper=maximum, + vpp_upper_v=maximum - minimum, + absolute_peak_upper_v=absolute, + # Peak is a conservative upper bound for RMS when no waveform-specific + # hard RMS model is available. + rms_upper_v=absolute, + ) + + +def _scaled_interval( + interval: tuple[float, float], + factor: tuple[float, float], +) -> tuple[float, float]: + values = tuple(left * right for left in interval for right in factor) + return min(values), max(values) + + +def _endpoints(bounds: ResistanceBounds) -> tuple[float, float]: + return bounds.minimum_ohm, bounds.maximum_ohm + + +def _contributor( + *, + contributor_id: str, + feature: SourceFeature, + channels: tuple[int, ...], + minimum: float, + maximum: float, + constraint_ids: tuple[str, ...], + proof_strength: BudgetProofStrength, +) -> SafetyContributor: + return SafetyContributor( + contributor_id=contributor_id, + feature=feature, + channels=channels, + minimum_v=minimum, + maximum_v=maximum, + constraint_ids=constraint_ids, + proof_strength=proof_strength, + evidence_sources=(BudgetEvidenceSource.INSTRUMENT_READBACK,), + ) + + +def _bounds_observed(bounds: PortVoltageBounds | None) -> Observed[PortVoltageBounds]: + return Observed.value_of(bounds) if bounds is not None else _missing() + + +def _channel_incomplete( + *, + channel: int, + physical_port_channel: int, + facts: _ChannelFacts | None, + blockers: set[SourceBudgetBlockerCode], + actual_termination: _TerminationResolution, + voltage_reference: Observed[VoltageReferenceBasis] | None = None, + display_load: Observed[TerminationSpec] | None = None, + source_resistance: Observed[ResistanceBounds] | None = None, + contributors: list[SafetyContributor] | None = None, + proof: BudgetProofStrength = BudgetProofStrength.INCOMPLETE, +) -> _ChannelPortEvaluation: + return _ChannelPortEvaluation( + channel=channel, + physical_port_channel=physical_port_channel, + facts=facts, + bounds=None, + contributors=tuple(contributors or ()), + blockers=tuple(sorted(blockers, key=lambda item: item.value)), + proof_strength=proof, + voltage_reference_basis=voltage_reference or _missing(), + display_load=display_load or _missing(), + source_resistance=source_resistance or _missing(), + actual_termination=actual_termination.observed, + actual_termination_evidence_source=actual_termination.evidence_source, + ) + + +def _incomplete_budget(*, blockers: set[SourceBudgetBlockerCode]) -> CompositeOutputBudget: + missing = _missing() + return CompositeOutputBudget( + bounds=missing, + voltage_reference_basis=missing, + display_load=missing, + output_source_resistance=missing, + actual_termination=missing, + shared_power=missing, + proof_strength=BudgetProofStrength.INCOMPLETE, + evidence_sources=(), + contributors=(), + blockers=tuple(sorted(blockers, key=lambda item: item.value)), + ) + + +def _evidence_sources( + contributors: list[SafetyContributor], + shared_power: Observed[SourceSharedPowerBudget], +) -> tuple[BudgetEvidenceSource, ...]: + values = { + source + for contributor in contributors + for source in contributor.evidence_sources + } + if shared_power.availability is Availability.VALUE: + values.update(shared_power.value.evidence_sources) + return tuple(sorted(values, key=lambda item: item.value)) + + +def _missing() -> Observed: + return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) + + +def _not_applicable() -> Observed[SourceSharedPowerBudget]: + return Observed.missing(Availability.NOT_APPLICABLE, SourceReasonCode.DESCRIPTOR_UNSUPPORTED) + + +def _strongest_nonhard(values) -> BudgetProofStrength: + available = tuple(values) + if BudgetProofStrength.STATISTICAL_ONLY in available: + return BudgetProofStrength.STATISTICAL_ONLY + if BudgetProofStrength.MEASURED_ONLY in available: + return BudgetProofStrength.MEASURED_ONLY + return BudgetProofStrength.INCOMPLETE + + +def _weaker_proof(*values: BudgetProofStrength) -> BudgetProofStrength: + ranks = { + BudgetProofStrength.HARD_CONSERVATIVE: 3, + BudgetProofStrength.STATISTICAL_ONLY: 2, + BudgetProofStrength.MEASURED_ONLY: 1, + BudgetProofStrength.INCOMPLETE: 0, + } + return min(values, key=lambda item: ranks[item]) + + +def _context_channel(target) -> int: + assert target.channel is not None + return target.channel + + +def _require_channel(value: object, label: str) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{label} must be a positive integer") + + +def _require_channels(values: object, label: str, *, allow_empty: bool) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + for value in values: + _require_channel(value, label) + if len(set(values)) != len(values) or tuple(sorted(values)) != values: + raise ValueError(f"{label} must be sorted and unique") + + +__all__ = ["SourceOutputBudgetRequest", "evaluate_source_output_budget"] diff --git a/tests/test_source_budget.py b/tests/test_source_budget.py new file mode 100644 index 0000000..64cccad --- /dev/null +++ b/tests/test_source_budget.py @@ -0,0 +1,814 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from wavebench.instruments.source_extensions import ( + ArbitraryFacet, + Availability, + BasicWaveFacet, + BudgetProofStrength, + ClosedFloatInterval, + ComponentAmplitudeKind, + HarmonicCompleteness, + HarmonicFacet, + ModulationFacet, + Observed, + OutputFacet, + ResistanceBounds, + SnapshotConsistencyState, + SourceAmplitude, + SourceAmplitudeUnit, + SourceArbitraryOvershootConstraint, + SourceArbitraryPlaybackMode, + SourceChannelStateV2, + SourceComponentAmplitude, + SourceConstraintApplicability, + SourceCrossChannelCapabilityProfile, + SourceCrossChannelStateV2, + SourceDescriptorExtensions, + SourceDisplayLoad, + SourceFeature, + SourceFeatureCapability, + SourceFeatureDirection, + SourceFacetScope, + SourceFieldId, + SourceFieldRef, + SourceFrequencyDeratingBand, + SourceFrequencyDeratingConstraint, + SourceFrequencyMode, + SourceLoadKind, + SourceModulationEnvelopeConstraint, + SourceModulationKind, + SourceModulationSource, + SourceOutputPolarity, + SourceNoisePeakConstraint, + SourceReasonCode, + SourceRelationEdge, + SourceRelationGraph, + SourceRelationState, + SourceRuntimeCapabilityProfile, + SourceRuntimeIdentity, + SourceResistanceConstraint, + SourceSafetyConstraint, + SourceSafetyConstraintKind, + SourceSafetyProfile, + SourceScopeRef, + SourceSharedPowerConstraint, + SourceSharedPowerState, + SourceSignalPathKind, + SourceSnapshotConsistency, + SourceSnapshotV2, + SourceTerminationEvidence, + SourceTopologyContract, + SourceVoltageReferenceConstraint, + SourceWaveformKind, + SupportState, + SweepFacet, + TerminationEvidenceLifetime, + TerminationEvidenceSource, + TerminationKind, + TerminationSpec, + VoltageReferenceBasis, + SourceHarmonicComponentV2, + source_v2_digest, +) +from wavebench.services.source_budget import ( + SourceOutputBudgetRequest, + evaluate_source_output_budget, +) +from wavebench.services.source_safety import ( + SourceEnergySafetyLimits, + SourceTerminationEvidenceContext, + source_termination_binding_digest, +) + +from tests.source_v2_fixtures import source_extensions + + +def _missing( + availability: Availability = Availability.UNSUPPORTED, +) -> Observed[object]: + reason = ( + SourceReasonCode.DESCRIPTOR_UNSUPPORTED + if availability is Availability.UNSUPPORTED + else SourceReasonCode.NOT_REQUESTED + ) + return Observed.missing(availability, reason) + + +def _basic( + *, + waveform: SourceWaveformKind = SourceWaveformKind.SINE, + amplitude_vpp: float | None = 2.0, + offset_v: float = 0.0, + frequency_mode: SourceFrequencyMode | None = SourceFrequencyMode.FIXED, + frequency_hz: float | None = 1_000.0, +) -> BasicWaveFacet: + return BasicWaveFacet( + waveform_kind=Observed.value_of(waveform), + waveform_id=Observed.value_of(waveform.value), + frequency_mode=( + Observed.value_of(frequency_mode) + if frequency_mode is not None + else _missing(Availability.NOT_QUERIED) + ), + frequency_hz=( + Observed.value_of(frequency_hz) + if frequency_hz is not None + else _missing(Availability.NOT_QUERIED) + ), + amplitude=( + Observed.value_of(SourceAmplitude(amplitude_vpp, SourceAmplitudeUnit.VPP)) + if amplitude_vpp is not None + else _missing(Availability.NOT_QUERIED) + ), + offset_v=Observed.value_of(offset_v), + phase_deg=_missing(Availability.NOT_QUERIED), + square_duty_cycle_percent=_missing(Availability.NOT_APPLICABLE), + ) + + +def _channel( + channel: int, + *, + waveform: SourceWaveformKind = SourceWaveformKind.SINE, + amplitude_vpp: float | None = 2.0, + offset_v: float = 0.0, + frequency_mode: SourceFrequencyMode | None = SourceFrequencyMode.FIXED, + frequency_hz: float | None = 1_000.0, + display_load: SourceDisplayLoad | None = None, + polarity: SourceOutputPolarity | None = SourceOutputPolarity.NORMAL, + enabled: bool = False, + harmonics: Observed[HarmonicFacet] | None = None, + modulation: Observed[ModulationFacet] | None = None, + sweep: Observed[SweepFacet] | None = None, + arbitrary: Observed[ArbitraryFacet] | None = None, +) -> SourceChannelStateV2: + return SourceChannelStateV2( + channel=channel, + basic=Observed.value_of( + _basic( + waveform=waveform, + amplitude_vpp=amplitude_vpp, + offset_v=offset_v, + frequency_mode=frequency_mode, + frequency_hz=frequency_hz, + ) + ), + output=Observed.value_of( + OutputFacet( + enabled=Observed.value_of(enabled), + display_load=( + Observed.value_of(display_load) + if display_load is not None + else Observed.value_of(SourceDisplayLoad(SourceLoadKind.RESISTIVE, 50.0)) + ), + polarity=( + Observed.value_of(polarity) + if polarity is not None + else _missing(Availability.UNKNOWN) + ), + ) + ), + harmonics=harmonics or _missing(), + modulation=modulation or _missing(), + sweep=sweep or _missing(), + burst=_missing(), + pulse=_missing(), + arbitrary=arbitrary or _missing(), + ) + + +def _constraint( + constraint_id: str, + kind: SourceSafetyConstraintKind, + profile: object, + *, + proof: BudgetProofStrength = BudgetProofStrength.HARD_CONSERVATIVE, +) -> SourceSafetyConstraint: + return SourceSafetyConstraint( + constraint_id=constraint_id, + kind=kind, + applicability=SourceConstraintApplicability(), + profile=profile, # type: ignore[arg-type] + proof_strength=proof, + evidence_refs=(f"evidence.{constraint_id}",), + ) + + +def _constraints( + *extra: SourceSafetyConstraint, + basis: VoltageReferenceBasis = VoltageReferenceBasis.OPEN_CIRCUIT, +) -> SourceSafetyProfile: + values = ( + _constraint( + "safety.reference", + SourceSafetyConstraintKind.VOLTAGE_REFERENCE, + SourceVoltageReferenceConstraint(basis), + ), + _constraint( + "safety.resistance", + SourceSafetyConstraintKind.SOURCE_RESISTANCE, + SourceResistanceConstraint(ResistanceBounds(50.0, 50.0)), + ), + *extra, + ) + return SourceSafetyProfile(tuple(sorted(values, key=lambda item: item.constraint_id))) + + +def _extensions( + *, + safety_profile: SourceSafetyProfile, + topology: SourceTopologyContract | None = None, + features: tuple[SourceFeatureCapability, ...] | None = None, +) -> SourceDescriptorExtensions: + base = source_extensions() + return replace( + base, + topology=topology or base.topology, + features=features or base.features, + safety_profile=safety_profile, + ) + + +def _snapshot( + extensions: SourceDescriptorExtensions, + channels: tuple[SourceChannelStateV2, ...], + *, + correlation_id: str = "budget-correlation", + cross_channel: Observed[SourceCrossChannelStateV2] | None = None, + consistency: SnapshotConsistencyState = SnapshotConsistencyState.CONSISTENT, +) -> SourceSnapshotV2: + identity = SourceRuntimeIdentity("Example", "EX1", "1.0") + anchor = SourceFieldRef( + SourceFieldId.IDENTITY, + SourceScopeRef(SourceFacetScope.INSTRUMENT), + ) + digest = source_v2_digest("budget-anchor") + return SourceSnapshotV2( + snapshot_id="budget-snapshot", + context_id="budget-context", + correlation_id=correlation_id, + captured_at_utc="2026-08-22T00:00:00.000Z", + runtime_profile=SourceRuntimeCapabilityProfile( + session_epoch="budget-epoch", + descriptor_digest=source_v2_digest(extensions), + identity=identity, + features=extensions.features, + ), + channels=channels, + system=_missing(Availability.NOT_APPLICABLE), + cross_channel=cross_channel or _missing(Availability.NOT_APPLICABLE), + consistency=SourceSnapshotConsistency( + state=consistency, + session_epoch="budget-epoch", + anchor_fields=(anchor,), + anchor_digest_before=digest, + anchor_digest_after=digest if consistency is SnapshotConsistencyState.CONSISTENT else None, + device_revision_token_before=None, + device_revision_token_after=None, + reason_code=( + None + if consistency is SnapshotConsistencyState.CONSISTENT + else SourceReasonCode.CONSISTENCY_UNPROVEN + ), + ), + plan_digest=source_v2_digest("budget-plan"), + query_count=1, + session_health_before="healthy", + session_health_after="healthy", + ) + + +def _termination( + snapshot: SourceSnapshotV2, + channel: int, + *, + bounds: ResistanceBounds | None = ResistanceBounds(50.0, 50.0), +) -> tuple[SourceTerminationEvidence, SourceTerminationEvidenceContext]: + target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + context = SourceTerminationEvidenceContext( + target=target, + resource_fingerprint="sha256:" + "0" * 64, + config_digest="sha256:" + "1" * 64, + correlation_id=snapshot.correlation_id, + observed_at_utc="2026-08-22T00:00:00.000Z", + ) + termination = ( + TerminationSpec(TerminationKind.HIGH_IMPEDANCE) + if bounds is None + else TerminationSpec(TerminationKind.RESISTIVE, bounds) + ) + evidence = SourceTerminationEvidence( + target=target, + termination=termination, + source=TerminationEvidenceSource.CONFIG, + lifetime=TerminationEvidenceLifetime.CONFIG_DIGEST, + resource_fingerprint=context.resource_fingerprint, + binding_digest=source_termination_binding_digest( + context, + source=TerminationEvidenceSource.CONFIG, + lifetime=TerminationEvidenceLifetime.CONFIG_DIGEST, + ), + observed_at_utc=context.observed_at_utc, + expires_at_utc=None, + evidence_ref=f"test.termination.{channel}", + ) + return evidence, context + + +def _request( + snapshot: SourceSnapshotV2, + extensions: SourceDescriptorExtensions, + *, + target_channel: int = 1, + terminations: tuple[tuple[SourceTerminationEvidence, SourceTerminationEvidenceContext], ...] = (), + limits: SourceEnergySafetyLimits | None = None, + projected_active_channels: tuple[int, ...] = (), +) -> SourceOutputBudgetRequest: + return SourceOutputBudgetRequest( + snapshot=snapshot, + descriptor_extensions=extensions, + limits=limits or SourceEnergySafetyLimits(3.0, -2.0, 2.0), + target_channel=target_channel, + termination_evidence=tuple(item[0] for item in terminations), + termination_contexts=tuple(item[1] for item in terminations), + projected_active_channels=projected_active_channels, + ) + + +def test_basic_open_circuit_budget_is_pure_and_authorizable() -> None: + extensions = _extensions(safety_profile=_constraints()) + snapshot = _snapshot(extensions, (_channel(1),)) + budget = evaluate_source_output_budget( + _request(snapshot, extensions, terminations=(_termination(snapshot, 1),)) + ) + + assert budget.can_authorize_energy + assert budget.bounds.value.minimum_v_lower == pytest.approx(-0.5) + assert budget.bounds.value.maximum_v_upper == pytest.approx(0.5) + assert budget.bounds.value.vpp_upper_v == pytest.approx(1.0) + assert budget.shared_power.availability is Availability.NOT_APPLICABLE + + +def test_missing_or_non_resistive_termination_fails_closed() -> None: + extensions = _extensions(safety_profile=_constraints()) + snapshot = _snapshot(extensions, (_channel(1),)) + + missing = evaluate_source_output_budget(_request(snapshot, extensions)) + high_impedance = evaluate_source_output_budget( + _request( + snapshot, + extensions, + terminations=(_termination(snapshot, 1, bounds=None),), + ) + ) + + assert not missing.can_authorize_energy + assert {item.value for item in missing.blockers} >= {"actual_termination_missing"} + assert not high_impedance.can_authorize_energy + assert {item.value for item in high_impedance.blockers} >= {"termination_not_resistive"} + + +def test_dc_and_pulse_take_distinct_conservative_paths() -> None: + extensions = _extensions(safety_profile=_constraints()) + dc = _snapshot( + extensions, + ( + _channel( + 1, + waveform=SourceWaveformKind.DC, + amplitude_vpp=None, + offset_v=2.0, + frequency_mode=None, + frequency_hz=None, + ), + ), + ) + dc_budget = evaluate_source_output_budget( + _request( + dc, + extensions, + terminations=(_termination(dc, 1),), + limits=SourceEnergySafetyLimits(1.0, 0.9, 1.1), + ) + ) + pulse = _snapshot( + extensions, + (_channel(1, waveform=SourceWaveformKind.PULSE),), + ) + pulse_budget = evaluate_source_output_budget( + _request(pulse, extensions, terminations=(_termination(pulse, 1),)) + ) + ambiguous_dc = _snapshot( + extensions, + (_channel(1, waveform=SourceWaveformKind.DC, amplitude_vpp=2.0),), + ) + ambiguous_dc_budget = evaluate_source_output_budget( + _request( + ambiguous_dc, + extensions, + terminations=(_termination(ambiguous_dc, 1),), + ) + ) + + assert dc_budget.can_authorize_energy + assert dc_budget.bounds.value.minimum_v_lower == pytest.approx(1.0) + assert "waveform_unsupported" in {item.value for item in pulse_budget.blockers} + assert not pulse_budget.can_authorize_energy + assert "dc_level_unavailable" in {item.value for item in ambiguous_dc_budget.blockers} + assert not ambiguous_dc_budget.can_authorize_energy + + +def test_polarity_and_unknown_frequency_mode_cannot_bypass_absolute_limits() -> None: + extensions = _extensions(safety_profile=_constraints()) + inverted = _snapshot( + extensions, + (_channel(1, offset_v=1.0, polarity=SourceOutputPolarity.INVERTED),), + ) + inverted_budget = evaluate_source_output_budget( + _request(inverted, extensions, terminations=(_termination(inverted, 1),)) + ) + unknown_polarity = _snapshot(extensions, (_channel(1, polarity=None),)) + polarity_budget = evaluate_source_output_budget( + _request(unknown_polarity, extensions, terminations=(_termination(unknown_polarity, 1),)) + ) + unknown_frequency = _snapshot( + extensions, + (_channel(1, frequency_mode=None),), + ) + frequency_budget = evaluate_source_output_budget( + _request(unknown_frequency, extensions, terminations=(_termination(unknown_frequency, 1),)) + ) + + assert inverted_budget.can_authorize_energy + assert inverted_budget.bounds.value.minimum_v_lower == pytest.approx(-1.0) + assert inverted_budget.bounds.value.maximum_v_upper == pytest.approx(0.0) + assert "output_polarity_unavailable" in {item.value for item in polarity_budget.blockers} + assert "frequency_mode_unsupported" in {item.value for item in frequency_budget.blockers} + + +def test_modulation_gain_expands_only_the_ac_component() -> None: + modulation = Observed.value_of( + ModulationFacet( + enabled=Observed.value_of(True), + kind=Observed.value_of(SourceModulationKind.AM), + source=Observed.value_of(SourceModulationSource.INTERNAL), + parameters=_missing(Availability.NOT_QUERIED), + internal_frequency_hz=_missing(Availability.NOT_APPLICABLE), + internal_waveform_kind=_missing(Availability.NOT_APPLICABLE), + ) + ) + extensions = _extensions( + safety_profile=_constraints( + _constraint( + "safety.modulation", + SourceSafetyConstraintKind.MODULATION_ENVELOPE, + SourceModulationEnvelopeConstraint(SourceModulationKind.AM, 2.0), + ) + ) + ) + snapshot = _snapshot( + extensions, + (_channel(1, offset_v=10.0, modulation=modulation),), + ) + budget = evaluate_source_output_budget( + _request( + snapshot, + extensions, + terminations=(_termination(snapshot, 1, bounds=ResistanceBounds(1_000_000_000.0, 1_000_000_000.0)),), + limits=SourceEnergySafetyLimits(10.0, -20.0, 20.0), + ) + ) + + assert budget.can_authorize_energy + assert budget.bounds.value.minimum_v_lower == pytest.approx(8.0, abs=1e-6) + assert budget.bounds.value.maximum_v_upper == pytest.approx(12.0, abs=1e-6) + + +def test_noise_harmonic_arb_and_sweep_require_their_hard_contributors() -> None: + noise_extensions = _extensions( + safety_profile=_constraints( + _constraint( + "safety.noise", + SourceSafetyConstraintKind.NOISE_PEAK, + SourceNoisePeakConstraint(2.0), + ) + ) + ) + noise = _snapshot( + noise_extensions, + ( + _channel( + 1, + waveform=SourceWaveformKind.NOISE, + amplitude_vpp=None, + frequency_mode=None, + frequency_hz=None, + ), + ), + ) + noise_budget = evaluate_source_output_budget( + _request(noise, noise_extensions, terminations=(_termination(noise, 1),)) + ) + + harmonics = Observed.value_of( + HarmonicFacet( + enabled=Observed.value_of(True), + completeness=Observed.value_of(HarmonicCompleteness.COMPLETE), + maximum_supported_order=Observed.value_of(2), + components=Observed.value_of( + ( + SourceHarmonicComponentV2( + 2, + Observed.value_of( + SourceComponentAmplitude(ComponentAmplitudeKind.ABSOLUTE_VPP, 2.0) + ), + Observed.value_of(0.0), + ), + ) + ), + ) + ) + harmonic_extensions = _extensions(safety_profile=_constraints()) + harmonic = _snapshot(harmonic_extensions, (_channel(1, harmonics=harmonics),)) + harmonic_budget = evaluate_source_output_budget( + _request(harmonic, harmonic_extensions, terminations=(_termination(harmonic, 1),)) + ) + + arbitrary = Observed.value_of( + ArbitraryFacet( + selected_waveform_id=Observed.value_of("arb-1"), + playback_mode=Observed.value_of(SourceArbitraryPlaybackMode.DDS), + playback_frequency_hz=Observed.value_of(1_000.0), + sample_rate_hz=Observed.value_of(10_000.0), + point_count=Observed.value_of(16), + storage_digest=Observed.value_of("sha256:" + "2" * 64), + ) + ) + arbitrary_extensions = _extensions( + safety_profile=_constraints( + _constraint( + "safety.arb", + SourceSafetyConstraintKind.ARBITRARY_OVERSHOOT, + SourceArbitraryOvershootConstraint(1.5), + ) + ) + ) + arbitrary_snapshot = _snapshot( + arbitrary_extensions, + (_channel(1, waveform=SourceWaveformKind.ARBITRARY, arbitrary=arbitrary),), + ) + arbitrary_budget = evaluate_source_output_budget( + _request( + arbitrary_snapshot, + arbitrary_extensions, + terminations=(_termination(arbitrary_snapshot, 1),), + ) + ) + + sweep = Observed.value_of( + SweepFacet( + enabled=Observed.value_of(True), + start_hz=Observed.value_of(100.0), + stop_hz=Observed.value_of(2_000.0), + spacing=_missing(Availability.NOT_APPLICABLE), + steps=_missing(Availability.NOT_APPLICABLE), + sweep_time_s=_missing(Availability.NOT_APPLICABLE), + start_hold_s=_missing(Availability.NOT_APPLICABLE), + stop_hold_s=_missing(Availability.NOT_APPLICABLE), + return_time_s=_missing(Availability.NOT_APPLICABLE), + trigger=_missing(Availability.NOT_APPLICABLE), + marker=_missing(Availability.NOT_APPLICABLE), + ) + ) + sweep_extensions = _extensions( + safety_profile=_constraints( + _constraint( + "safety.sweep", + SourceSafetyConstraintKind.FREQUENCY_DERATING, + SourceFrequencyDeratingConstraint( + (SourceFrequencyDeratingBand(ClosedFloatInterval(100.0, 2_000.0), 2.0),) + ), + ) + ) + ) + sweep_snapshot = _snapshot( + sweep_extensions, + ( + _channel( + 1, + frequency_mode=SourceFrequencyMode.SWEEP, + sweep=sweep, + ), + ), + ) + sweep_budget = evaluate_source_output_budget( + _request(sweep_snapshot, sweep_extensions, terminations=(_termination(sweep_snapshot, 1),)) + ) + + assert noise_budget.can_authorize_energy + assert noise_budget.bounds.value.absolute_peak_upper_v == pytest.approx(1.0) + assert harmonic_budget.can_authorize_energy + assert harmonic_budget.bounds.value.absolute_peak_upper_v == pytest.approx(1.0) + assert arbitrary_budget.can_authorize_energy + assert arbitrary_budget.bounds.value.absolute_peak_upper_v == pytest.approx(0.75) + assert sweep_budget.can_authorize_energy + assert sweep_budget.bounds.value.absolute_peak_upper_v == pytest.approx(1.0) + + +def _two_channel_features( + *, + combine: bool = False, + shared_power: bool = False, +) -> tuple[SourceFeatureCapability, ...]: + base = source_extensions() + basic_1, output_1 = base.features + basic_2 = replace(basic_1, channels=(2,)) + output_2 = replace(output_1, channels=(2,)) + features = [basic_1, basic_2, output_1, output_2] + profile = SourceCrossChannelCapabilityProfile( + relation_kinds=tuple( + sorted( + { + *((SourceFeature.COMBINE,) if combine else ()), + *((SourceFeature.SHARED_POWER,) if shared_power else ()), + }, + key=lambda item: item.value, + ) + ), + supported_channel_sets=((1, 2),), + relation_graph_readable=combine, + shared_power_constraint_readable=shared_power, + ) + if combine: + features.append( + SourceFeatureCapability( + feature=SourceFeature.COMBINE, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL_SET, + channels=(1, 2), + applicability=SourceConstraintApplicability(), + profile=profile, + ) + ) + if shared_power: + features.append( + SourceFeatureCapability( + feature=SourceFeature.SHARED_POWER, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.INSTRUMENT, + channels=(), + applicability=SourceConstraintApplicability(), + profile=profile, + ) + ) + return tuple(sorted(features, key=lambda item: (item.feature.value, item.scope.value, item.channels))) + + +def test_internal_combine_uses_the_target_port_termination_and_unknown_path_blocks() -> None: + features = _two_channel_features(combine=True) + extensions = _extensions( + safety_profile=_constraints(), + topology=SourceTopologyContract((1, 2)), + features=features, + ) + relation = SourceRelationState( + SourceFeature.COMBINE, + (1, 2), + Observed.value_of(True), + ) + internal_edge = SourceRelationEdge( + relation_id="combine-1-to-2", + feature=SourceFeature.COMBINE, + sources=(1,), + targets=(2,), + signal_path=SourceSignalPathKind.INTERNAL_WAVEFORM, + affected_fields=(SourceFieldId.BASIC,), + ) + cross = Observed.value_of( + SourceCrossChannelStateV2( + relations=(relation,), + relation_graph=Observed.value_of(SourceRelationGraph((1, 2), (internal_edge,))), + shared_power=_missing(Availability.NOT_APPLICABLE), + ) + ) + snapshot = _snapshot(extensions, (_channel(1), _channel(2)), cross_channel=cross) + combined = evaluate_source_output_budget( + _request( + snapshot, + extensions, + target_channel=2, + terminations=(_termination(snapshot, 2),), + ) + ) + + output_edge = replace(internal_edge, signal_path=SourceSignalPathKind.OUTPUT_PORT) + unsupported = _snapshot( + extensions, + (_channel(1), _channel(2)), + cross_channel=Observed.value_of( + SourceCrossChannelStateV2( + relations=(relation,), + relation_graph=Observed.value_of(SourceRelationGraph((1, 2), (output_edge,))), + shared_power=_missing(Availability.NOT_APPLICABLE), + ) + ), + ) + unsupported_budget = evaluate_source_output_budget( + _request( + unsupported, + extensions, + target_channel=2, + terminations=(_termination(unsupported, 2),), + ) + ) + + assert combined.can_authorize_energy + assert combined.bounds.value.absolute_peak_upper_v == pytest.approx(1.0) + assert any(item.channels == (1, 2) for item in combined.contributors) + assert "combine_path_unsupported" in {item.value for item in unsupported_budget.blockers} + + +def test_shared_power_compares_runtime_and_descriptor_hard_limits() -> None: + features = _two_channel_features(shared_power=True) + extensions = _extensions( + safety_profile=_constraints( + _constraint( + "safety.shared", + SourceSafetyConstraintKind.SHARED_POWER, + SourceSharedPowerConstraint((1, 2), 1.0), + ) + ), + topology=SourceTopologyContract((1, 2)), + features=features, + ) + shared = SourceSharedPowerState( + participants=(1, 2), + active_power_upper_w=Observed.value_of(2.0), + hard_limit_w=Observed.value_of(1.0), + ) + snapshot = _snapshot( + extensions, + (_channel(1), _channel(2)), + cross_channel=Observed.value_of( + SourceCrossChannelStateV2( + relations=(), + relation_graph=_missing(Availability.NOT_APPLICABLE), + shared_power=Observed.value_of(shared), + ) + ), + ) + budget = evaluate_source_output_budget( + _request(snapshot, extensions, terminations=(_termination(snapshot, 1),)) + ) + + assert not budget.can_authorize_energy + assert "shared_power_limit_exceeded" in {item.value for item in budget.blockers} + assert budget.shared_power.value.observed_active_power_upper_w == 2.0 + + +def test_delivered_display_load_and_context_binding_are_explicit() -> None: + extensions = _extensions( + safety_profile=_constraints(basis=VoltageReferenceBasis.DELIVERED_INTO_DISPLAY_LOAD) + ) + snapshot = _snapshot(extensions, (_channel(1),)) + termination = _termination(snapshot, 1, bounds=ResistanceBounds(1_000.0, 1_000.0)) + budget = evaluate_source_output_budget( + _request( + snapshot, + extensions, + terminations=(termination,), + limits=SourceEnergySafetyLimits(5.0, -3.0, 3.0), + ) + ) + wrong_context = replace(termination[1], correlation_id="different-operation") + wrong_context_budget = evaluate_source_output_budget( + _request(snapshot, extensions, terminations=((termination[0], wrong_context),)) + ) + + assert budget.can_authorize_energy + assert budget.bounds.value.absolute_peak_upper_v == pytest.approx(1.9047619047619047) + assert not wrong_context_budget.can_authorize_energy + assert "termination_evidence_invalid" in { + item.value for item in wrong_context_budget.blockers + } + + +def test_non_consistent_snapshot_remains_a_budget_blocker() -> None: + extensions = _extensions(safety_profile=_constraints()) + snapshot = _snapshot( + extensions, + (_channel(1),), + consistency=SnapshotConsistencyState.UNPROVEN, + ) + budget = evaluate_source_output_budget( + _request(snapshot, extensions, terminations=(_termination(snapshot, 1),)) + ) + + assert not budget.can_authorize_energy + assert "snapshot_not_consistent" in {item.value for item in budget.blockers} From c2af5222fc29821f134161de51aec59322ce7a1a Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:59:22 +0800 Subject: [PATCH 06/82] feat: add source v2 operation context guards --- .../services/source_operation_context.py | 967 ++++++++++++++++++ tests/test_source_operation_context.py | 424 ++++++++ 2 files changed, 1391 insertions(+) create mode 100644 src/wavebench/services/source_operation_context.py create mode 100644 tests/test_source_operation_context.py diff --git a/src/wavebench/services/source_operation_context.py b/src/wavebench/services/source_operation_context.py new file mode 100644 index 0000000..ad8cb43 --- /dev/null +++ b/src/wavebench/services/source_operation_context.py @@ -0,0 +1,967 @@ +"""Core-owned Source V2 mutation operation contexts. + +The module intentionally does not register a capability, open a driver, or +invoke a Source method. It binds a future feature-specific Source write to the +existing ``InstrumentSessionState`` / ``SessionTransactionCoordinator`` safety +machinery. The coordinator is internal: drivers receive neither its handles +nor its authorizations. +""" + +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, isfinite +import time +from typing import Iterable, Iterator +from uuid import uuid4 + +from wavebench.errors import ConfigError +from wavebench.instruments.source_extensions import ( + SOURCE_OPERATION_ARTIFACT_SCHEMA, + SourceAffectedClosure, + SourceEnergyEffect, + SourceFieldId, + SourceFieldRef, + SourceOperationContract, + SourceScopeRef, + SourceStorageEffect, + source_v2_digest, +) +from wavebench.transport.session import ( + InstrumentSessionState, + SessionAuthorization, + SessionHealth, + SessionPurpose, + SessionTransactionCoordinator, +) + +from .operation_specs import OperationSpec + + +class SourceOperationPhase(StrEnum): + PREFLIGHT = "preflight" + MAIN = "main" + POSTCONDITION = "postcondition" + FAILURE_SAFE_STATE = "failure_safe_state" + FAILURE_RESTORE = "failure_restore" + CLEANUP_VERIFICATION = "cleanup_verification" + + +class SourcePhasePurpose(StrEnum): + NORMAL = "normal" + RECOVERY = "recovery" + VERIFICATION = "verification" + + +class SourceBaselineUseState(StrEnum): + FRESH = "fresh" + PASSED_TO_MAIN = "passed_to_main" + RESTORE_ATTEMPTED = "restore_attempted" + VERIFY_ATTEMPTED = "verify_attempted" + CONSUMED = "consumed" + INVALIDATED = "invalidated" + + +_PHASE_PURPOSE = { + SourceOperationPhase.PREFLIGHT: SourcePhasePurpose.VERIFICATION, + SourceOperationPhase.MAIN: SourcePhasePurpose.NORMAL, + SourceOperationPhase.POSTCONDITION: SourcePhasePurpose.VERIFICATION, + SourceOperationPhase.FAILURE_SAFE_STATE: SourcePhasePurpose.RECOVERY, + SourceOperationPhase.FAILURE_RESTORE: SourcePhasePurpose.RECOVERY, + SourceOperationPhase.CLEANUP_VERIFICATION: SourcePhasePurpose.VERIFICATION, +} +_READ_IO = frozenset({"query", "query_float_list", "query_opc"}) +_WRITE_IO = frozenset({"write", "write_bytes"}) +_ALL_IO = _READ_IO | _WRITE_IO +_NON_REENERGIZING_RESTORE_FIELDS = frozenset( + { + SourceFieldId.OUTPUT, + SourceFieldId.ARM_STATE, + SourceFieldId.TRIGGER_STATE, + } +) + + +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_sort_key(value: SourceFieldRef) -> tuple[object, ...]: + target = value.target + return ( + value.field.value, + target.scope.value, + -1 if target.channel is None else target.channel, + target.channels, + "" if target.input_id is None else target.input_id, + ) + + +def _field_refs( + values: Iterable[SourceFieldRef], + *, + label: str, + allow_empty: bool = False, + sorted_values: bool = True, +) -> tuple[SourceFieldRef, ...]: + if isinstance(values, (str, bytes)): + raise ValueError(f"{label} must be an iterable of SourceFieldRef values") + result = tuple(values) + if (not result and not allow_empty) or any( + not isinstance(item, SourceFieldRef) for item in result + ): + raise ValueError(f"{label} must contain SourceFieldRef values") + keys = tuple(_field_sort_key(item) for item in result) + if len(set(keys)) != len(keys): + raise ValueError(f"{label} must not contain duplicates") + if sorted_values and tuple(sorted(keys)) != keys: + raise ValueError(f"{label} must be sorted") + return result + + +def _field_keys(values: Iterable[SourceFieldRef]) -> frozenset[str]: + return frozenset(source_v2_digest(value) for value in values) + + +def _output_fields(outputs: Iterable[SourceScopeRef]) -> tuple[SourceFieldRef, ...]: + result = tuple(SourceFieldRef(SourceFieldId.OUTPUT, output) for output in outputs) + return tuple(sorted(result, key=_field_sort_key)) + + +@dataclass(frozen=True, slots=True) +class SourcePhaseAuthorizationSpec: + """One bounded, core-issued phase specification. + + It is intentionally not part of ``wavebench.instruments.source_extensions`` + and no driver accepts it as an argument. + """ + + context_id: str + operation_id: str + phase: SourceOperationPhase + purpose: SourcePhasePurpose + allowed_io: frozenset[str] + fields: frozenset[SourceFieldRef] + 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", SourceOperationPhase(self.phase)) + object.__setattr__(self, "purpose", SourcePhasePurpose(self.purpose)) + if self.purpose is not _PHASE_PURPOSE[self.phase]: + raise ValueError("source phase purpose does not match the fixed phase mapping") + allowed_io = frozenset(self.allowed_io) + fields = frozenset(self.fields) + if not allowed_io or not allowed_io <= _ALL_IO: + raise ValueError("source phase allowed_io is empty or unsupported") + if not fields or any(not isinstance(item, SourceFieldRef) for item in fields): + raise ValueError("source phase fields must contain SourceFieldRef values") + object.__setattr__(self, "allowed_io", allowed_io) + object.__setattr__(self, "fields", fields) + if self.purpose is SourcePhasePurpose.VERIFICATION and allowed_io & _WRITE_IO: + raise ValueError("source verification phases cannot write") + if self.phase in { + SourceOperationPhase.FAILURE_SAFE_STATE, + SourceOperationPhase.FAILURE_RESTORE, + } and allowed_io - _WRITE_IO: + raise ValueError("source recovery write phases cannot issue reads") + if isinstance(self.deadline, bool) or not isinstance(self.deadline, (int, float)): + raise ValueError("source phase deadline must be a finite monotonic timestamp") + if not isfinite(self.deadline): + raise ValueError("source phase deadline must be a finite monotonic timestamp") + if isinstance(self.max_steps, bool) or not isinstance(self.max_steps, int) or self.max_steps < 1: + raise ValueError("source phase max_steps must be a positive integer") + + +@dataclass(frozen=True, slots=True, init=False, eq=False) +class SourcePhaseAuthorization: + """Opaque context-owned bridge to the session authorization token.""" + + context_id: str + operation_id: str + phase: SourceOperationPhase + purpose: SourcePhasePurpose + allowed_io: frozenset[str] + fields: frozenset[SourceFieldRef] + 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("source phase authorizations are coordinator-issued") + + @classmethod + def _issue( + cls, + spec: SourcePhaseAuthorizationSpec, + session_authorization: SessionAuthorization, + owner_nonce: object, + ) -> "SourcePhaseAuthorization": + 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 SourceBaselineHandle: + """Opaque core baseline identity; its nonce is never serialized in artifacts.""" + + context_id: str + operation_id: str + session_epoch: str + closure_digest: str + baseline_nonce: str = field(repr=False) + fields: tuple[SourceFieldRef, ...] + restore_order: tuple[SourceFieldRef, ...] + _owner_nonce: object = field(repr=False, compare=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("source baseline handles are coordinator-issued") + + @classmethod + def _issue( + cls, + *, + context_id: str, + operation_id: str, + session_epoch: str, + closure_digest: str, + baseline_nonce: str, + fields: tuple[SourceFieldRef, ...], + restore_order: tuple[SourceFieldRef, ...], + owner_nonce: object, + ) -> "SourceBaselineHandle": + instance = object.__new__(cls) + for name, value in ( + ("context_id", context_id), + ("operation_id", operation_id), + ("session_epoch", session_epoch), + ("closure_digest", closure_digest), + ("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: SourceBaselineHandle + state: SourceBaselineUseState = SourceBaselineUseState.FRESH + restore_succeeded: bool | None = None + verification_succeeded: bool | None = None + + +class SourceOperationContextCoordinator: + """One Source mutation context with sequential, non-nested safe phases.""" + + def __init__( + self, + *, + session_state: InstrumentSessionState, + operation_spec: OperationSpec, + operation_contract: SourceOperationContract, + connection_timeout_ms: int, + baseline_snapshot_digest: str, + fields: tuple[SourceFieldRef, ...], + required_off_outputs: tuple[SourceScopeRef, ...], + emergency_off_outputs: tuple[SourceScopeRef, ...], + restore_order: tuple[SourceFieldRef, ...], + non_restorable_fields: tuple[SourceFieldRef, ...], + correlation_id: str | None = None, + caller_deadline: float | None = None, + now: float | None = None, + ) -> None: + if not isinstance(session_state, InstrumentSessionState): + raise TypeError("source operation context requires an InstrumentSessionState") + if not isinstance(operation_spec, OperationSpec): + raise TypeError("source operation context requires an OperationSpec") + if not isinstance(operation_contract, SourceOperationContract): + raise TypeError("source operation context requires a SourceOperationContract") + self._validate_operation_pair(operation_spec, operation_contract) + 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 Source operations require a healthy session") + if not isinstance(baseline_snapshot_digest, str) or not baseline_snapshot_digest.startswith( + "sha256:" + ): + raise ValueError("source operation baseline_snapshot_digest must be a SHA-256 digest") + + current = time.monotonic() if now is None else float(now) + if not isfinite(current): + raise ValueError("source operation clock must be finite") + hard_deadline = current + (operation_contract.operation_timeout_ms / 1000.0) + if caller_deadline is not None: + if isinstance(caller_deadline, bool) or not isinstance( + caller_deadline, (int, float) + ) or not isfinite(caller_deadline): + raise ValueError("caller_deadline must be a finite monotonic timestamp") + hard_deadline = min(hard_deadline, float(caller_deadline)) + if hard_deadline - current <= 0.001: + raise ValueError("source operation deadline cannot retain one millisecond of main time") + requested_reserve_ms = min(5_000, max(1_000, operation_contract.operation_timeout_ms // 5)) + reserve_s = min( + requested_reserve_ms / 1000.0, + (hard_deadline - current) / 2.0, + ) + main_deadline = hard_deadline - reserve_s + if main_deadline - current < 0.001: + raise ValueError("source operation deadline cannot retain one millisecond of main time") + + self.context_id = uuid4().hex + self.operation_id = operation_contract.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 = main_deadline + self.cleanup_reserve_ms = int(reserve_s * 1000) + self.operation_spec = operation_spec + self.operation_contract = operation_contract + 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(timespec="milliseconds").replace( + "+00:00", "Z" + ) + self._session_coordinator = SessionTransactionCoordinator(session_state) + self._owner_nonce = object() + self._active_phase: SourcePhaseAuthorization | None = None + self._phase_history: list[dict[str, object]] = [] + self._used_phases: set[SourceOperationPhase] = set() + self._baseline: _BaselineRecord | None = None + self._main_entered = False + self._failure_required = False + self._postcondition_verified = False + self._cleanup_verified = False + self._terminal = False + + normalized_fields = _field_refs(fields, label="source operation closure fields") + normalized_restore = _field_refs( + restore_order, + label="source operation restore_order", + allow_empty=True, + sorted_values=False, + ) + normalized_non_restorable = _field_refs( + non_restorable_fields, + label="source operation non_restorable_fields", + allow_empty=True, + ) + self._validate_closure_inputs( + fields=normalized_fields, + required_off_outputs=required_off_outputs, + emergency_off_outputs=emergency_off_outputs, + restore_order=normalized_restore, + non_restorable_fields=normalized_non_restorable, + ) + self.closure = self._build_closure( + baseline_snapshot_digest=baseline_snapshot_digest, + fields=normalized_fields, + required_off_outputs=required_off_outputs, + emergency_off_outputs=emergency_off_outputs, + restore_order=normalized_restore, + non_restorable_fields=normalized_non_restorable, + ) + + @property + def terminal(self) -> bool: + return self._terminal + + def has_phase(self, phase: SourceOperationPhase) -> bool: + return SourceOperationPhase(phase) in self._used_phases + + def make_phase_spec( + self, + phase: SourceOperationPhase, + *, + allowed_io: Iterable[str], + fields: Iterable[SourceFieldRef], + max_steps: int, + deadline: float | None = None, + ) -> SourcePhaseAuthorizationSpec: + phase = SourceOperationPhase(phase) + ceiling = ( + self.deadline + if phase + in { + SourceOperationPhase.FAILURE_SAFE_STATE, + SourceOperationPhase.FAILURE_RESTORE, + SourceOperationPhase.CLEANUP_VERIFICATION, + } + else self.main_deadline + ) + chosen_deadline = ceiling if deadline is None else min(float(deadline), ceiling) + return SourcePhaseAuthorizationSpec( + context_id=self.context_id, + operation_id=self.operation_id, + phase=phase, + purpose=_PHASE_PURPOSE[phase], + allowed_io=frozenset(allowed_io), + fields=frozenset(_field_refs(fields, label="source phase fields")), + deadline=chosen_deadline, + max_steps=max_steps, + ) + + @contextmanager + def authorize_phase( + self, + phase_spec: SourcePhaseAuthorizationSpec, + ) -> Iterator[SourcePhaseAuthorization]: + 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 Source/session authorizations are not allowed") + self._validate_phase_order(phase_spec.phase) + if phase_spec.phase is SourceOperationPhase.MAIN: + # Once a possible mutation phase begins, old proof for the complete + # affected closure is stale even if a driver later reports no-op. + self._session_coordinator.invalidate_verified_fields(_field_keys(self.closure.fields)) + remaining_ms = min( + self.connection_timeout_ms, + max(1, ceil((phase_spec.deadline - time.monotonic()) * 1000.0)), + ) + if phase_spec.purpose is SourcePhasePurpose.NORMAL: + manager = self._session_coordinator.authorize_normal( + operation_id=self.operation_id, + allowed_io=phase_spec.allowed_io, + fields=_field_keys(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, + ) + else: + evidence_fields = None + if phase_spec.purpose is SourcePhasePurpose.VERIFICATION: + evidence_fields = { + io_kind: _field_keys(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=_field_keys(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, + ) + status = "failed" + with manager as session_authorization: + authorization = SourcePhaseAuthorization._issue( + phase_spec, + session_authorization, + self._owner_nonce, + ) + self._active_phase = authorization + self._used_phases.add(phase_spec.phase) + if phase_spec.phase is SourceOperationPhase.MAIN: + self._main_entered = True + try: + yield authorization + status = "completed" + finally: + 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(_field_keys(phase_spec.fields)), + "max_steps": phase_spec.max_steps, + "actual_steps": session_authorization._record.successful_steps, + "status": status, + } + ) + + def create_baseline(self) -> SourceBaselineHandle: + """Issue a one-use baseline handle while the preflight phase is active.""" + + self._require_phase(SourceOperationPhase.PREFLIGHT) + if self._baseline is not None: + raise ValueError("source operation already has a baseline") + if not self.closure.restore_order: + raise ValueError("source operation closure has no restorable fields") + nonce = uuid4().hex + handle = SourceBaselineHandle._issue( + context_id=self.context_id, + operation_id=self.operation_id, + session_epoch=self.session_epoch, + closure_digest=self.closure.closure_digest, + baseline_nonce=nonce, + fields=self.closure.restore_order, + restore_order=self.closure.restore_order, + owner_nonce=self._owner_nonce, + ) + self._baseline = _BaselineRecord(handle=handle) + return handle + + def pass_baseline_to_main(self, handle: SourceBaselineHandle) -> None: + self._require_phase(SourceOperationPhase.PREFLIGHT) + record = self._baseline_record(handle) + if record.state is not SourceBaselineUseState.FRESH: + raise ValueError("source baseline is not fresh") + record.state = SourceBaselineUseState.PASSED_TO_MAIN + + def consume_baseline_after_success(self, handle: SourceBaselineHandle) -> 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 SourceBaselineUseState.PASSED_TO_MAIN: + raise ValueError("source baseline cannot be consumed from its current state") + record.state = SourceBaselineUseState.CONSUMED + + def mark_failure_required(self) -> None: + """Enter the only permitted post-main failure route. + + A transport failure may already have degraded the session. If it has + not, make the health conservative before recovery authorization so the + normal path cannot resume between failed mutation and cleanup. + """ + + if self._active_phase is not None or not self._main_entered: + raise ValueError("Source failure cleanup requires a closed main phase") + if self._failure_required: + raise ValueError("Source operation cleanup is already required") + if SourceOperationPhase.POSTCONDITION in self._used_phases: + raise ValueError("Source operation cannot fail after postcondition completed") + self._failure_required = True + if self.session_state.health is SessionHealth.HEALTHY: + self.session_state.degrade(SessionHealth.UNCERTAIN, reason="source_cleanup_required") + + def begin_restore(self, handle: SourceBaselineHandle) -> None: + self._require_phase(SourceOperationPhase.FAILURE_RESTORE) + record = self._baseline_record(handle) + if record.state is not SourceBaselineUseState.PASSED_TO_MAIN: + raise ValueError("source baseline restore slot is already consumed or unavailable") + record.state = SourceBaselineUseState.RESTORE_ATTEMPTED + + def finish_restore(self, handle: SourceBaselineHandle, *, succeeded: bool) -> None: + record = self._baseline_record(handle) + if record.state is not SourceBaselineUseState.RESTORE_ATTEMPTED: + raise ValueError("source baseline restore was not attempted") + if record.restore_succeeded is not None: + raise ValueError("source baseline restore outcome is already recorded") + if not isinstance(succeeded, bool): + raise TypeError("source restore outcome must be bool") + record.restore_succeeded = succeeded + + def begin_cleanup_verification(self, handle: SourceBaselineHandle) -> None: + self._require_phase(SourceOperationPhase.CLEANUP_VERIFICATION) + record = self._baseline_record(handle) + if record.state is not SourceBaselineUseState.RESTORE_ATTEMPTED: + raise ValueError("source baseline verification requires exactly one restore attempt") + if record.restore_succeeded is None: + raise ValueError("source baseline restore outcome has not been recorded") + record.state = SourceBaselineUseState.VERIFY_ATTEMPTED + + def finish_cleanup_verification( + self, + handle: SourceBaselineHandle, + authorization: SourcePhaseAuthorization, + *, + io_kind: str, + verified_fields: Iterable[SourceFieldRef], + matched: bool, + ) -> None: + self._require_authorization(authorization, SourceOperationPhase.CLEANUP_VERIFICATION) + record = self._baseline_record(handle) + if record.state is not SourceBaselineUseState.VERIFY_ATTEMPTED: + raise ValueError("source baseline verification slot is not active") + verified = frozenset(_field_refs(verified_fields, label="source verified fields")) + if verified != authorization.fields: + raise ValueError("source cleanup verification must cover exactly its authorized fields") + if not isinstance(matched, bool): + raise TypeError("source verification match result must be bool") + candidate = bool(record.restore_succeeded and matched) + verification_succeeded = False + try: + if candidate: + self._session_coordinator.record_evidence( + authorization._session_authorization, + io_kind, + _field_keys(verified), + ) + self._session_coordinator.complete_verification( + authorization._session_authorization + ) + verification_succeeded = True + self._cleanup_verified = True + finally: + record.verification_succeeded = verification_succeeded + record.state = SourceBaselineUseState.CONSUMED + if not verification_succeeded: + raise ValueError("source baseline restoration verification is incomplete or mismatched") + + def complete_phase_verification( + self, + authorization: SourcePhaseAuthorization, + *, + io_kind: str, + fields: Iterable[SourceFieldRef], + ) -> None: + """Commit full fresh evidence for preflight or postcondition reads.""" + + if authorization.phase is SourceOperationPhase.CLEANUP_VERIFICATION: + if self._baseline is not None: + raise ValueError("cleanup verification must use the baseline-aware completion path") + if authorization.purpose is not SourcePhasePurpose.VERIFICATION: + raise ValueError("source phase is not a verification phase") + self._require_authorization(authorization, authorization.phase) + verified = frozenset(_field_refs(fields, label="source verified fields")) + if verified != authorization.fields: + raise ValueError("source verification must cover exactly its authorized fields") + self._session_coordinator.record_evidence( + authorization._session_authorization, + io_kind, + _field_keys(verified), + ) + self._session_coordinator.complete_verification(authorization._session_authorization) + if authorization.phase is SourceOperationPhase.POSTCONDITION: + self._postcondition_verified = True + if authorization.phase is SourceOperationPhase.CLEANUP_VERIFICATION: + self._cleanup_verified = True + + def complete(self) -> None: + """Terminally close the context and poison an incomplete failure cleanup.""" + + if self._active_phase is not None: + raise ValueError("source operation context cannot terminate with an active phase") + if self._terminal: + return + baseline = self._baseline + if self._failure_required: + safe_state_required = bool(self.closure.emergency_off_outputs) + restore_required = bool(self.closure.restore_order) + cleanup_ok = ( + (not safe_state_required or SourceOperationPhase.FAILURE_SAFE_STATE in self._used_phases) + and (not restore_required or SourceOperationPhase.FAILURE_RESTORE in self._used_phases) + and SourceOperationPhase.CLEANUP_VERIFICATION in self._used_phases + and self._cleanup_verified + and (baseline is None or baseline.verification_succeeded is True) + ) + if not cleanup_ok and self.session_state.health in { + SessionHealth.HEALTHY, + SessionHealth.UNCERTAIN, + }: + self.session_state.degrade( + SessionHealth.POISONED, + reason="source_cleanup_incomplete", + ) + elif self._main_entered and not self._postcondition_verified: + if self.session_state.health in {SessionHealth.HEALTHY, SessionHealth.UNCERTAIN}: + self.session_state.degrade( + SessionHealth.POISONED, + reason="source_postcondition_missing", + ) + if baseline is not None and baseline.state is not SourceBaselineUseState.CONSUMED: + baseline.state = SourceBaselineUseState.INVALIDATED + self._terminal = True + + def artifact(self) -> dict[str, object]: + """Return safe operation-context evidence without nonce or transport payloads.""" + + baseline = self._baseline + return { + "schema": SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": self.operation_id, + "context_id": self.context_id, + "correlation_id": self.correlation_id, + "session_epoch": self.session_epoch, + "session_health": { + "before": self.session_health_before, + "after": self.session_state.health.value, + }, + "closure": { + "digest": self.closure.closure_digest, + "baseline_snapshot_digest": self.closure.baseline_snapshot_digest, + "field_count": len(self.closure.fields), + "required_off_channels": [item.channel for item in self.closure.required_off_outputs], + "emergency_off_channels": [item.channel for item in self.closure.emergency_off_outputs], + "non_restorable_fields": sorted(_field_keys(self.closure.non_restorable_fields)), + }, + "cleanup_reserve_ms": self.cleanup_reserve_ms, + "phases": [dict(item) for item in self._phase_history], + "baseline": ( + None + if baseline is None + else { + "nonce_digest": sha256( + baseline.handle.baseline_nonce.encode("ascii") + ).hexdigest()[:16], + "fields": sorted(_field_keys(baseline.handle.fields)), + "restore_order": [ + source_v2_digest(item) for item in baseline.handle.restore_order + ], + "consumption": baseline.state.value, + "restore_succeeded": baseline.restore_succeeded, + "verification_succeeded": baseline.verification_succeeded, + } + ), + "terminal": self._terminal, + } + + def _validate_operation_pair( + self, + spec: OperationSpec, + contract: SourceOperationContract, + ) -> None: + if spec.instrument_kind != "source" or spec.effect != "write": + raise ValueError("source mutation contexts require a source write OperationSpec") + if spec.operation != contract.operation: + raise ValueError("source OperationSpec and SourceOperationContract operations differ") + if contract.capability not in spec.required_capabilities: + raise ValueError("source OperationSpec must require its Source V2 capability") + if ( + spec.timeout_source != "operation.timeout_ms" + or spec.operation_timeout_ms != contract.operation_timeout_ms + ): + raise ValueError("source OperationSpec timeout must match its operation contract") + if spec.lease_mode != "exclusive": + raise ValueError("source mutation contexts require an exclusive operation lease") + required_changed = {field.value for field in contract.changed_fields} + if not required_changed <= set(spec.changed_fields): + raise ValueError("source OperationSpec changed_fields do not cover its operation contract") + if contract.energy_effect is SourceEnergyEffect.UNKNOWN: + raise ConfigError("unknown Source energy effect is not authorized") + if contract.storage_effect is SourceStorageEffect.UNKNOWN: + raise ConfigError("unknown Source storage effect is not authorized") + + def _validate_closure_inputs( + self, + *, + fields: tuple[SourceFieldRef, ...], + required_off_outputs: tuple[SourceScopeRef, ...], + emergency_off_outputs: tuple[SourceScopeRef, ...], + restore_order: tuple[SourceFieldRef, ...], + non_restorable_fields: tuple[SourceFieldRef, ...], + ) -> None: + contract_field_ids = {item.field for item in fields} + required_ids = { + *self.operation_contract.required_fields, + *self.operation_contract.changed_fields, + *self.operation_contract.postcondition_fields, + *self.operation_contract.cleanup_verification_fields, + } + if not required_ids <= contract_field_ids: + raise ValueError("source affected closure does not cover its operation contract fields") + if any(not isinstance(item, SourceScopeRef) for item in required_off_outputs + emergency_off_outputs): + raise ValueError("source operation outputs must use SourceScopeRef values") + if any(item.scope.value != "channel" for item in required_off_outputs + emergency_off_outputs): + raise ValueError("source operation output scopes must be channel scoped") + expected_emergency_fields = set(_output_fields(emergency_off_outputs)) + if not expected_emergency_fields <= set(fields): + raise ValueError("source affected closure must include every emergency output field") + if set(restore_order) & set(non_restorable_fields): + raise ValueError("source restore order and non-restorable fields overlap") + if any(item.field in _NON_REENERGIZING_RESTORE_FIELDS for item in restore_order): + raise ValueError("source failure restore cannot re-enable output, arm, or trigger fields") + if self.operation_contract.energy_effect in { + SourceEnergyEffect.MAY_INCREASE, + SourceEnergyEffect.EMIT, + } and not required_off_outputs: + raise ValueError("energy-increasing Source operations require explicit OFF outputs") + + def _build_closure( + self, + *, + baseline_snapshot_digest: str, + fields: tuple[SourceFieldRef, ...], + required_off_outputs: tuple[SourceScopeRef, ...], + emergency_off_outputs: tuple[SourceScopeRef, ...], + restore_order: tuple[SourceFieldRef, ...], + non_restorable_fields: tuple[SourceFieldRef, ...], + ) -> SourceAffectedClosure: + payload = { + "schema": "wavebench.source.affected-closure.v1", + "operation": self.operation_id, + "context_id": self.context_id, + "session_epoch": self.session_epoch, + "baseline_snapshot_digest": baseline_snapshot_digest, + "fields": fields, + "required_off_outputs": required_off_outputs, + "emergency_off_outputs": emergency_off_outputs, + "restore_order": restore_order, + "non_restorable_fields": non_restorable_fields, + } + return SourceAffectedClosure( + operation=self.operation_id, + context_id=self.context_id, + session_epoch=self.session_epoch, + baseline_snapshot_digest=baseline_snapshot_digest, + fields=fields, + required_off_outputs=required_off_outputs, + emergency_off_outputs=emergency_off_outputs, + restore_order=restore_order, + non_restorable_fields=non_restorable_fields, + closure_digest=source_v2_digest(payload), + ) + + def _validate_phase_spec(self, phase_spec: SourcePhaseAuthorizationSpec) -> None: + if not isinstance(phase_spec, SourcePhaseAuthorizationSpec): + raise TypeError("source phase authorization spec has an invalid type") + if self._terminal: + raise ValueError("source operation context is terminal") + if self.session_state.epoch_id != self.session_epoch: + self.complete() + raise ValueError("source 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("source phase authorization belongs to another operation context") + ceiling = ( + self.deadline + if phase_spec.phase + in { + SourceOperationPhase.FAILURE_SAFE_STATE, + SourceOperationPhase.FAILURE_RESTORE, + SourceOperationPhase.CLEANUP_VERIFICATION, + } + else self.main_deadline + ) + if phase_spec.deadline > ceiling or phase_spec.deadline <= time.monotonic(): + raise ValueError("source phase deadline exceeds or exhausts the operation deadline") + if not phase_spec.fields <= set(self.closure.fields): + raise ValueError("source phase fields exceed the affected closure") + if phase_spec.phase is SourceOperationPhase.FAILURE_SAFE_STATE and phase_spec.fields != set( + _output_fields(self.closure.emergency_off_outputs) + ): + raise ValueError("source failure safe-state phase must cover exactly emergency OFF outputs") + if ( + phase_spec.phase is SourceOperationPhase.FAILURE_SAFE_STATE + and not self.closure.emergency_off_outputs + ): + raise ValueError("source failure safe-state phase is unavailable without emergency OFF outputs") + if phase_spec.phase is SourceOperationPhase.FAILURE_RESTORE and phase_spec.fields != set( + self.closure.restore_order + ): + raise ValueError("source failure restore phase must cover exactly the frozen restore order") + if phase_spec.phase is SourceOperationPhase.FAILURE_RESTORE and not self.closure.restore_order: + raise ValueError("source failure restore phase is unavailable without restorable fields") + if phase_spec.phase is SourceOperationPhase.CLEANUP_VERIFICATION: + expected = frozenset( + (*_output_fields(self.closure.emergency_off_outputs), *self.closure.restore_order) + ) + if phase_spec.fields != expected: + raise ValueError( + "source cleanup verification must cover emergency OFF and restored fields" + ) + + def _validate_phase_order(self, phase: SourceOperationPhase) -> None: + if phase in self._used_phases: + raise ValueError("source operation phases are single use") + if phase is SourceOperationPhase.PREFLIGHT: + if self._used_phases: + raise ValueError("source preflight must be the first phase") + return + if SourceOperationPhase.PREFLIGHT not in self._used_phases: + raise ValueError("source operation phases require preflight") + if phase is SourceOperationPhase.MAIN: + if self._failure_required or SourceOperationPhase.POSTCONDITION in self._used_phases: + raise ValueError("source main cannot run after failure or postcondition") + return + if phase is SourceOperationPhase.POSTCONDITION: + if not self._main_entered or self._failure_required: + raise ValueError("source postcondition requires a successful main path") + return + if phase is SourceOperationPhase.FAILURE_SAFE_STATE: + if not self._main_entered or not self._failure_required: + raise ValueError("source failure safe-state requires a failed main path") + return + if phase is SourceOperationPhase.FAILURE_RESTORE: + if self.closure.emergency_off_outputs and ( + SourceOperationPhase.FAILURE_SAFE_STATE not in self._used_phases + ): + raise ValueError("source failure restore requires failure safe-state first") + return + if phase is SourceOperationPhase.CLEANUP_VERIFICATION: + if self.closure.restore_order: + if SourceOperationPhase.FAILURE_RESTORE not in self._used_phases: + raise ValueError("source cleanup verification requires failure restore first") + elif self.closure.emergency_off_outputs and ( + SourceOperationPhase.FAILURE_SAFE_STATE not in self._used_phases + ): + raise ValueError("source cleanup verification requires failure safe-state first") + + def _baseline_record(self, handle: SourceBaselineHandle) -> _BaselineRecord: + if not isinstance(handle, SourceBaselineHandle): + raise TypeError("source baseline handle has an invalid type") + if handle._owner_nonce is not self._owner_nonce: + raise ValueError("source 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 + or handle.closure_digest != self.closure.closure_digest + ): + raise ValueError("source baseline binding does not match the operation context") + if self._baseline is None or self._baseline.handle is not handle: + raise ValueError("source baseline nonce is unknown or replayed") + return self._baseline + + def _require_phase(self, phase: SourceOperationPhase) -> None: + if self._active_phase is None or self._active_phase.phase is not phase: + raise ValueError(f"source operation requires active phase {phase.value}") + + def _require_authorization( + self, + authorization: SourcePhaseAuthorization, + phase: SourceOperationPhase, + ) -> None: + if ( + not isinstance(authorization, SourcePhaseAuthorization) + or authorization._owner_nonce is not self._owner_nonce + or self._active_phase is not authorization + or authorization.phase is not phase + ): + raise ValueError("source phase authorization is inactive or belongs to another context") + + +__all__ = [ + "SourceBaselineHandle", + "SourceBaselineUseState", + "SourceOperationContextCoordinator", + "SourceOperationPhase", + "SourcePhaseAuthorization", + "SourcePhaseAuthorizationSpec", + "SourcePhasePurpose", +] diff --git a/tests/test_source_operation_context.py b/tests/test_source_operation_context.py new file mode 100644 index 0000000..45200d2 --- /dev/null +++ b/tests/test_source_operation_context.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import pytest + +from wavebench.instruments.source_extensions import ( + SourceAffectedClosure, + SourceEnergyEffect, + SourceFeature, + SourceFeatureDirection, + SourceFieldId, + SourceFieldRef, + SourceOperationContract, + SourceScopeRef, + SourceStorageEffect, + SourceV1WriteRouteId, + SourceFacetScope, +) +from wavebench.services.operation_specs import OperationSpec +from wavebench.services.source_operation_context import ( + SourceBaselineUseState, + SourceOperationContextCoordinator, + SourceOperationPhase, +) +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: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del replay + 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]: + del timeout_ms, replay + self.queries.append(command) + return [1.0] + + def query_bin_block( + self, + command: str, + *, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> bytes: + del replay + self.queries.append(command) + return b"data" + + def query_opc(self, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del replay + 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(command.decode("ascii")) + + def close(self) -> None: + pass + + +def _field(field: SourceFieldId, channel: int | None = None) -> SourceFieldRef: + target = ( + SourceScopeRef(SourceFacetScope.INSTRUMENT) + if channel is None + else SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + ) + return SourceFieldRef(field, target) + + +FIELDS = tuple( + sorted( + ( + _field(SourceFieldId.BASIC, 1), + _field(SourceFieldId.OUTPUT, 1), + _field(SourceFieldId.IDENTITY), + ), + key=lambda item: ( + item.field.value, + item.target.scope.value, + -1 if item.target.channel is None else item.target.channel, + ), + ) +) +BASIC = _field(SourceFieldId.BASIC, 1) +OUTPUT = _field(SourceFieldId.OUTPUT, 1) + + +def _contract(*, energy: SourceEnergyEffect = SourceEnergyEffect.POTENTIAL_WHILE_OFF) -> SourceOperationContract: + return SourceOperationContract( + operation="source.basic_configure_v2", + capability="source.basic_configure_v2", + feature=SourceFeature.BASIC, + direction=SourceFeatureDirection.CONFIGURE, + energy_effect=energy, + storage_effect=SourceStorageEffect.NONE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.BASIC,), + postcondition_fields=(SourceFieldId.BASIC,), + cleanup_verification_fields=(SourceFieldId.BASIC, SourceFieldId.OUTPUT), + v1_equivalent_routes=(SourceV1WriteRouteId.SET_FREQUENCY,), + v1_overlapping_routes=(), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=2, + verification_max_steps=2, + ) + + +def _spec(contract: SourceOperationContract) -> OperationSpec: + return OperationSpec( + operation=contract.operation, + instrument_kind="source", + required_capabilities=(contract.capability,), + effect="write", + lease_mode="exclusive", + changed_fields=(SourceFieldId.BASIC.value,), + restore_coverage="source-v2-closure", + required_verified_fields=(SourceFieldId.IDENTITY.value,), + verification_fields=(SourceFieldId.BASIC.value, SourceFieldId.OUTPUT.value), + postcondition_fields=(SourceFieldId.BASIC.value,), + cleanup_verification_fields=(SourceFieldId.BASIC.value, SourceFieldId.OUTPUT.value), + timeout_source="operation.timeout_ms", + operation_timeout_ms=contract.operation_timeout_ms, + risk_flags=("source_v2",), + ) + + +def _context( + *, + energy: SourceEnergyEffect = SourceEnergyEffect.POTENTIAL_WHILE_OFF, +) -> tuple[GuardedAuditedTransport, SourceOperationContextCoordinator]: + transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type] + contract = _contract(energy=energy) + context = SourceOperationContextCoordinator( + session_state=transport.session_state, + operation_spec=_spec(contract), + operation_contract=contract, + connection_timeout_ms=1_000, + baseline_snapshot_digest="sha256:" + "1" * 64, + fields=FIELDS, + required_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + emergency_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + restore_order=(BASIC,), + non_restorable_fields=(OUTPUT,), + correlation_id="source-context-test", + ) + return transport, context + + +def _preflight( + transport: GuardedAuditedTransport, + context: SourceOperationContextCoordinator, +): + phase = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=FIELDS, + max_steps=context.operation_contract.verification_max_steps, + ) + with context.authorize_phase(phase) as authorization: + transport.query("SNAPSHOT?") + context.complete_phase_verification(authorization, io_kind="query", fields=FIELDS) + baseline = context.create_baseline() + context.pass_baseline_to_main(baseline) + return baseline + + +def _main( + transport: GuardedAuditedTransport, + context: SourceOperationContextCoordinator, +) -> None: + phase = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write"}, + fields=(BASIC,), + max_steps=context.operation_contract.main_max_steps, + ) + with context.authorize_phase(phase): + assert not transport.session_state.verified_fields + transport.write("CONFIGURE") + + +def test_context_binds_closure_and_successfully_verifies_postcondition() -> None: + transport, context = _context() + baseline = _preflight(transport, context) + _main(transport, context) + + postcondition = context.make_phase_spec( + SourceOperationPhase.POSTCONDITION, + allowed_io={"query"}, + fields=(BASIC,), + max_steps=context.operation_contract.verification_max_steps, + ) + with context.authorize_phase(postcondition) as authorization: + transport.query("BASIC?") + context.complete_phase_verification(authorization, io_kind="query", fields=(BASIC,)) + context.consume_baseline_after_success(baseline) + context.complete() + + artifact = context.artifact() + assert context.terminal + assert transport.session_state.health is SessionHealth.HEALTHY + assert artifact["closure"]["digest"] == context.closure.closure_digest + assert artifact["baseline"]["consumption"] == SourceBaselineUseState.CONSUMED.value + assert baseline.baseline_nonce not in repr(artifact) + assert [item["phase"] for item in artifact["phases"]] == [ + "preflight", + "main", + "postcondition", + ] + + +def test_failure_path_orders_safe_off_restore_and_cleanup_verification() -> None: + transport, context = _context() + baseline = _preflight(transport, context) + _main(transport, context) + context.mark_failure_required() + + safe_state = context.make_phase_spec( + SourceOperationPhase.FAILURE_SAFE_STATE, + allowed_io={"write"}, + fields=(OUTPUT,), + max_steps=1, + ) + with context.authorize_phase(safe_state): + transport.write("OUTPUT OFF") + + restore = context.make_phase_spec( + SourceOperationPhase.FAILURE_RESTORE, + allowed_io={"write"}, + fields=(BASIC,), + max_steps=context.operation_contract.recovery_max_steps, + ) + with context.authorize_phase(restore): + context.begin_restore(baseline) + transport.write("RESTORE BASIC") + context.finish_restore(baseline, succeeded=True) + + cleanup = context.make_phase_spec( + SourceOperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=tuple(sorted((BASIC, OUTPUT), key=lambda item: item.field.value)), + max_steps=context.operation_contract.verification_max_steps, + ) + with context.authorize_phase(cleanup) as authorization: + context.begin_cleanup_verification(baseline) + transport.query("VERIFY?") + context.finish_cleanup_verification( + baseline, + authorization, + io_kind="query", + verified_fields=tuple(sorted((BASIC, OUTPUT), key=lambda item: item.field.value)), + matched=True, + ) + context.complete() + + assert transport.session_state.health is SessionHealth.HEALTHY + assert [item["phase"] for item in context.artifact()["phases"]] == [ + "preflight", + "main", + "failure_safe_state", + "failure_restore", + "cleanup_verification", + ] + + +def test_context_rejects_nested_or_out_of_order_phases_before_transport_io() -> None: + transport, context = _context() + preflight = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=FIELDS, + max_steps=1, + ) + with context.authorize_phase(preflight): + nested = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write"}, + fields=(BASIC,), + max_steps=1, + ) + with pytest.raises(ValueError, match="nested"): + with context.authorize_phase(nested): + pass + + main = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write"}, + fields=(BASIC,), + max_steps=1, + ) + with context.authorize_phase(main): + transport.write("CONFIGURE") + context.mark_failure_required() + restore = context.make_phase_spec( + SourceOperationPhase.FAILURE_RESTORE, + allowed_io={"write"}, + fields=(BASIC,), + max_steps=1, + ) + with pytest.raises(ValueError, match="safe-state"): + with context.authorize_phase(restore): + pass + assert transport.audit_snapshot()["counters"]["write_completed"] == 1 + + +def test_failure_cleanup_without_matched_verification_poisoned_session() -> None: + transport, context = _context() + baseline = _preflight(transport, context) + _main(transport, context) + context.mark_failure_required() + safe_state = context.make_phase_spec( + SourceOperationPhase.FAILURE_SAFE_STATE, + allowed_io={"write"}, + fields=(OUTPUT,), + max_steps=1, + ) + with context.authorize_phase(safe_state): + transport.write("OUTPUT OFF") + restore = context.make_phase_spec( + SourceOperationPhase.FAILURE_RESTORE, + allowed_io={"write"}, + fields=(BASIC,), + max_steps=1, + ) + with context.authorize_phase(restore): + context.begin_restore(baseline) + transport.write("RESTORE BASIC") + context.finish_restore(baseline, succeeded=False) + cleanup = context.make_phase_spec( + SourceOperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=tuple(sorted((BASIC, OUTPUT), key=lambda item: item.field.value)), + max_steps=1, + ) + with context.authorize_phase(cleanup) as authorization: + context.begin_cleanup_verification(baseline) + transport.query("VERIFY?") + with pytest.raises(ValueError, match="incomplete or mismatched"): + context.finish_cleanup_verification( + baseline, + authorization, + io_kind="query", + verified_fields=tuple(sorted((BASIC, OUTPUT), key=lambda item: item.field.value)), + matched=True, + ) + context.complete() + + assert transport.session_state.health is SessionHealth.POISONED + + +def test_unknown_effect_and_reenergizing_restore_are_rejected_without_io() -> None: + transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type] + contract = _contract(energy=SourceEnergyEffect.UNKNOWN) + with pytest.raises(Exception, match="unknown Source energy effect"): + SourceOperationContextCoordinator( + session_state=transport.session_state, + operation_spec=_spec(contract), + operation_contract=contract, + connection_timeout_ms=1_000, + baseline_snapshot_digest="sha256:" + "1" * 64, + fields=FIELDS, + required_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + emergency_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + restore_order=(BASIC,), + non_restorable_fields=(OUTPUT,), + ) + with pytest.raises(ValueError, match="cannot re-enable"): + SourceOperationContextCoordinator( + session_state=transport.session_state, + operation_spec=_spec(_contract()), + operation_contract=_contract(), + connection_timeout_ms=1_000, + baseline_snapshot_digest="sha256:" + "1" * 64, + fields=FIELDS, + required_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + emergency_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + restore_order=(OUTPUT,), + non_restorable_fields=(BASIC,), + ) + assert transport.audit_snapshot()["counters"]["write_completed"] == 0 + assert transport.audit_snapshot()["counters"]["query_calls"] == 0 + + +def test_affected_closure_digest_rejects_tampering() -> None: + transport, context = _context() + closure = context.closure + assert closure.context_id == context.context_id + with pytest.raises(ValueError, match="digest"): + SourceAffectedClosure( + operation=closure.operation, + context_id=closure.context_id, + session_epoch=closure.session_epoch, + baseline_snapshot_digest=closure.baseline_snapshot_digest, + fields=closure.fields, + required_off_outputs=closure.required_off_outputs, + emergency_off_outputs=closure.emergency_off_outputs, + restore_order=closure.restore_order, + non_restorable_fields=closure.non_restorable_fields, + closure_digest="sha256:" + "0" * 64, + ) From aab6ef27b6b013d6d44d3c972fc947c103b350f7 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:59:28 +0800 Subject: [PATCH 07/82] feat: audit source v1 routes and artifacts --- src/wavebench/services/run_artifacts.py | 39 ++++ src/wavebench/services/source_v1_routes.py | 196 +++++++++++++++++++++ tests/test_packages.py | 19 ++ tests/test_run_artifacts.py | 111 ++++++++++++ tests/test_source_v1_routes.py | 65 +++++++ 5 files changed, 430 insertions(+) create mode 100644 src/wavebench/services/source_v1_routes.py create mode 100644 tests/test_run_artifacts.py create mode 100644 tests/test_source_v1_routes.py diff --git a/src/wavebench/services/run_artifacts.py b/src/wavebench/services/run_artifacts.py index 5a1b339..b6f053b 100644 --- a/src/wavebench/services/run_artifacts.py +++ b/src/wavebench/services/run_artifacts.py @@ -7,6 +7,7 @@ from typing import Any from wavebench.errors import ensure_error_envelope +from wavebench.instruments.source_extensions import SOURCE_OPERATION_ARTIFACT_SCHEMA from wavebench.services.run_plan import RunPlan from wavebench.services.source_state import RestorableSourceState @@ -38,6 +39,37 @@ def write_step_record(steps_dir: Path, record: RunStepRecord) -> None: ) +def _validated_source_operations( + source_operations: list[dict[str, Any]] | None, +) -> list[dict[str, Any]] | None: + """Accept only real, schema-labelled Source V2 operation artifacts. + + The namespace stays absent for all V1 runs. Feature-specific V2 operation + code owns the rest of each artifact's shape, but it cannot accidentally + insert an arbitrary unlabelled dictionary at the run root. + """ + + if source_operations is None: + return None + if not isinstance(source_operations, list) or any( + not isinstance(item, dict) for item in source_operations + ): + raise TypeError("source_operations must be a list of operation artifact objects") + if not source_operations: + return None + for artifact in source_operations: + if artifact.get("schema") != SOURCE_OPERATION_ARTIFACT_SCHEMA: + raise ValueError("source operation artifact has an unsupported schema") + operation = artifact.get("operation") + if ( + not isinstance(operation, str) + or not operation.startswith("source.") + or operation.strip() != operation + ): + raise ValueError("source operation artifact must have a trimmed source.* operation") + return source_operations + + def write_run_files( *, plan: RunPlan, @@ -49,6 +81,7 @@ def write_run_files( restore_state: list[RestorableSourceState] | None = None, restore_error: dict[str, Any] | None = None, provenance: dict[str, Any] | None = None, + source_operations: list[dict[str, Any]] | None = None, ) -> None: run_data: dict[str, Any] = { "status": status, @@ -88,6 +121,12 @@ def write_run_files( run_data["error"] = ensure_error_envelope(error, default_exit_code=2) if provenance is not None: run_data["provenance"] = provenance + # Keep the V2 namespace absent until an actual Source V2 operation has a + # typed artifact to place in it. In particular, this must not alter the + # byte representation of existing V1 run artifacts. + validated_source_operations = _validated_source_operations(source_operations) + if validated_source_operations is not None: + run_data["source_operations"] = validated_source_operations run_json_path.write_text( json.dumps(run_data, indent=2, ensure_ascii=False), encoding="utf-8", diff --git a/src/wavebench/services/source_v1_routes.py b/src/wavebench/services/source_v1_routes.py new file mode 100644 index 0000000..062e0d4 --- /dev/null +++ b/src/wavebench/services/source_v1_routes.py @@ -0,0 +1,196 @@ +"""Frozen inventory of every currently supported Source V1 write route. + +This is deliberately an internal migration ledger, not a new V1 API. There +is no Source V2 write capability in this core revision, so a record does not +claim a V2 mapping. A feature-specific V2 write implementation must classify +each related route as synonymous, overlapping, or disjoint before it can be +registered. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from wavebench.instruments.source_extensions import SourceV1WriteRouteId + + +@dataclass(frozen=True, slots=True) +class SourceV1WriteRoute: + """One Service write route and its non-Service public entry points.""" + + route: SourceV1WriteRouteId + operation: str | None + entrypoints: tuple[str, ...] + may_change_signal_when_output_on: bool + may_start_or_reenergize_output: bool + mutates_storage: bool + + def __post_init__(self) -> None: + if self.operation is not None and ( + not self.operation.startswith("source.") or self.operation.strip() != self.operation + ): + raise ValueError("Source V1 write route operation must be a trimmed source.* ID") + if not self.entrypoints: + raise ValueError("Source V1 write route must declare an entry point") + if tuple(sorted(self.entrypoints)) != self.entrypoints or len(set(self.entrypoints)) != len( + self.entrypoints + ): + raise ValueError("Source V1 write route entry points must be sorted and unique") + if any(not entrypoint or entrypoint.strip() != entrypoint for entrypoint in self.entrypoints): + raise ValueError("Source V1 write route entry points must be non-empty and trimmed") + if self.mutates_storage and not self.may_change_signal_when_output_on: + raise ValueError("Source V1 storage mutation must declare its signal-state side effect") + + +# Route order is the public SourceV1WriteRouteId enum order. The direct +# SourceService method is encoded in ``route.value``; entrypoints below only +# list additional CLI, run-plan, TUI, sweep, and restore paths. +SOURCE_V1_WRITE_ROUTE_INVENTORY: tuple[SourceV1WriteRoute, ...] = ( + SourceV1WriteRoute( + SourceV1WriteRouteId.SET_FREQUENCY, + "source.set_frequency", + ("cli.source.set-freq", "run-plan.source.set_freq", "sweep.discrete", "tui.source.set_frequency"), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.SET_FUNCTION, + "source.set_function", + ("cli.source.set-func", "run-plan.source.set_func", "sweep.discrete", "tui.source.set_function"), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.SET_AMPLITUDE_VPP, + "source.set_amplitude_vpp", + ("cli.source.set-vpp", "run-plan.source.set_vpp", "sweep.discrete", "tui.source.set_amplitude_vpp"), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.SET_SQUARE_DUTY_CYCLE, + "source.set_square_duty_cycle", + ("cli.source.set-duty", "run-plan.source.set_duty"), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.SET_OUTPUT, + "source.output", + ("cli.source.output", "run-plan.source.output", "run.safety-gate", "tui.source.set_output"), + True, + True, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_COUPLING, + "source.coupling_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_HARMONICS, + "source.harmonic_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_AM, + "source.modulation_am_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_FM, + "source.modulation_fm_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_PM, + "source.modulation_pm_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_PWM, + "source.modulation_pwm_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_PULSE, + "source.pulse_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_BURST, + "source.burst_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.TRIGGER_BURST, + "source.burst_trigger", + ("python.source-service",), + True, + True, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.CONFIGURE_SWEEP, + "source.sweep_configure", + ("python.source-service",), + True, + False, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.TRIGGER_SWEEP, + "source.sweep_trigger", + ("python.source-service",), + True, + True, + False, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.UPLOAD_ARBITRARY, + "source.arbitrary_upload", + ("cli.source.arb-load", "run-plan.source.arb_load"), + True, + True, + True, + ), + SourceV1WriteRoute( + SourceV1WriteRouteId.RESTORE, + None, + ("run.restore-source-state", "sweep.discrete.restore-source-state"), + True, + True, + False, + ), +) + + +__all__ = ["SOURCE_V1_WRITE_ROUTE_INVENTORY", "SourceV1WriteRoute"] diff --git a/tests/test_packages.py b/tests/test_packages.py index 0afbbd6..e2e4576 100644 --- a/tests/test_packages.py +++ b/tests/test_packages.py @@ -89,6 +89,25 @@ def test_load_run_package_reads_run_json_and_summary(self): self.assertEqual(len(loaded.steps), 1) self.assertEqual(loaded.summary_rows[0]["kind"], "scope.capture") + def test_load_run_package_tolerates_additive_source_operation_namespace(self): + with TemporaryDirectory() as tmp: + run = Path(tmp) + source_operations = [ + { + "schema": "wavebench.source.operation.v1", + "operation": "source.future_v2", + } + ] + (run / "run.json").write_text( + json.dumps({"status": "ok", "steps": [], "source_operations": source_operations}), + encoding="utf-8", + ) + + loaded = load_run_package(run) + + self.assertEqual(loaded.status, "ok") + self.assertEqual(loaded.run["source_operations"], source_operations) + def test_load_run_package_reads_frequency_response_and_tolerates_bad_fit_json(self): with TemporaryDirectory() as tmp: run = Path(tmp) diff --git a/tests/test_run_artifacts.py b/tests/test_run_artifacts.py new file mode 100644 index 0000000..a932b7f --- /dev/null +++ b/tests/test_run_artifacts.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from wavebench.services.run_artifacts import RunStepRecord, write_run_files +from wavebench.services.run_plan import load_run_plan +from wavebench.services.source_state import RestorableSourceState + + +def _plan(directory: Path): + path = directory / "plan.toml" + path.write_text('[[steps]]\nkind = "source.status"\n', encoding="utf-8") + return load_run_plan(path) + + +def _write( + directory: Path, + *, + source_operations: list[dict[str, object]] | None = None, +) -> bytes: + run_path = directory / "run.json" + write_run_files( + plan=_plan(directory), + run_json_path=run_path, + summary_csv_path=directory / "summary.csv", + status="ok", + records=[ + RunStepRecord( + index=0, + kind="source.status", + status="ok", + fields={"channel": 1}, + artifact={"source_status": {"channel": 1, "output": "OFF"}}, + ) + ], + error=None, + restore_state=[ + RestorableSourceState( + channel=1, + output="OFF", + function="SIN", + frequency_hz=1000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + ], + provenance={"schema": "wavebench.run_provenance.v1"}, + source_operations=source_operations, + ) + return run_path.read_bytes() + + +def test_empty_source_operation_namespace_preserves_v1_run_json_bytes() -> None: + with TemporaryDirectory() as tmp: + directory = Path(tmp) + default_bytes = _write(directory) + explicit_none_bytes = _write(directory, source_operations=None) + + assert default_bytes == explicit_none_bytes + run = json.loads(default_bytes) + assert "source_operations" not in run + assert run["restore"]["source_state_scope"] == "basic" + assert run["steps"][0]["artifact"]["source_status"] == { + "channel": 1, + "output": "OFF", + } + + +def test_nonempty_source_operation_namespace_is_additive_to_v1_run_artifacts() -> None: + with TemporaryDirectory() as tmp: + directory = Path(tmp) + baseline = json.loads(_write(directory)) + enriched = json.loads( + _write( + directory, + source_operations=[ + { + "schema": "wavebench.source.operation.v1", + "operation": "source.future_v2", + } + ], + ) + ) + + assert enriched["source_operations"] == [ + { + "schema": "wavebench.source.operation.v1", + "operation": "source.future_v2", + } + ] + assert enriched["restore"] == baseline["restore"] + assert enriched["steps"] == baseline["steps"] + + +@pytest.mark.parametrize( + "source_operations", + [ + [{"operation": "source.future_v2"}], + [{"schema": "wavebench.source.operation.v1", "operation": "future_v2"}], + ], +) +def test_source_operation_namespace_rejects_untyped_artifacts( + source_operations: list[dict[str, object]], +) -> None: + with TemporaryDirectory() as tmp: + with pytest.raises((TypeError, ValueError)): + _write(Path(tmp), source_operations=source_operations) diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py new file mode 100644 index 0000000..1c2fdf6 --- /dev/null +++ b/tests/test_source_v1_routes.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from tempfile import TemporaryDirectory +from pathlib import Path + +import pytest + +from wavebench.errors import ConfigError +from wavebench.instruments.source_extensions import SourceV1WriteRouteId +from wavebench.services.operation_specs import list_operation_specs, require_operation_spec +from wavebench.services.run_plan import ALLOWED_STEP_KINDS, load_run_plan +from wavebench.services.source_service import SourceService +from wavebench.services.source_v1_routes import SOURCE_V1_WRITE_ROUTE_INVENTORY + + +def test_source_v1_write_inventory_covers_every_route_and_write_operation() -> None: + inventory = SOURCE_V1_WRITE_ROUTE_INVENTORY + + assert tuple(item.route for item in inventory) == tuple(SourceV1WriteRouteId) + assert all( + callable(getattr(SourceService, item.route.value.removeprefix("source_service."), None)) + for item in inventory + ) + + source_write_operations = { + spec.operation + for spec in list_operation_specs(instrument_kind="source") + if spec.effect == "write" + } + inventoried_operations = {item.operation for item in inventory if item.operation is not None} + assert source_write_operations == inventoried_operations + assert all(require_operation_spec(operation).effect == "write" for operation in inventoried_operations) + + +def test_source_v1_indirect_write_entries_are_frozen_and_v2_writes_are_not_registered() -> None: + entrypoints = { + entrypoint + for item in SOURCE_V1_WRITE_ROUTE_INVENTORY + for entrypoint in item.entrypoints + } + expected_run_steps = { + "source.arb_load", + "source.set_func", + "source.set_freq", + "source.set_vpp", + "source.set_duty", + "source.output", + } + assert {item.removeprefix("run-plan.") for item in entrypoints if item.startswith("run-plan.")} == expected_run_steps + assert { + kind + for kind in ALLOWED_STEP_KINDS + if kind.startswith("source.") and kind != "source.status" + } == expected_run_steps + assert not [ + spec + for spec in list_operation_specs(instrument_kind="source") + if "_v2" in spec.operation and spec.effect == "write" + ] + + with TemporaryDirectory() as tmp: + plan_path = Path(tmp) / "plan.toml" + plan_path.write_text('[[steps]]\nkind = "source.output_v2"\n', encoding="utf-8") + with pytest.raises(ConfigError, match="source.output_v2.*not supported"): + load_run_plan(plan_path) From b190b99a528d7b2a36c0c5d7fd515e6f36ba1bfa Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:00:18 +0800 Subject: [PATCH 08/82] feat: gate managed source v2 plugin compatibility --- ...00\345\217\221\346\214\207\345\215\227.md" | 16 ++ src/wavebench/plugins/lifecycle.py | 5 + tests/test_plugin_lifecycle.py | 192 +++++++++++++++++- tests/test_plugin_package_inspect.py | 32 ++- 4 files changed, 242 insertions(+), 3 deletions(-) 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 542edd5..2aeea2c 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" @@ -147,6 +147,22 @@ capability 名必须与 `kind` 同前缀。例如 scope 只能声明 `scope.*` 未采用新增 capability 的旧插件不需要提高核心版本下限。旧 `scope capture --screenshot` 不承载 新 `scope.screenshot_v2`;新插件应使用独立截图 Service 或 `wavebench scope screenshot capture`。 +### 采用 Source V2 snapshot 扩展 + +准备提供完整信号源状态快照时,先阅读 +[Source V2 能力、状态与复合输出安全 RFC](../rfcs/WaveBench_source能力状态与复合输出安全RFC.md)。 +首个修订只开放 `source.snapshot_v2`,采用条件如下: + +- wheel 依赖和 descriptor 均要求 WaveBench `0.8.24` 或更高的 `0.8.x` 版本; +- descriptor 追加 `source_extensions`,显式声明 topology、read feature profile 和 pure-read query contract; +- driver 实现 `execute_source_query_plan_v2(plan)`,返回 `SourceQueryExecutionRecord`; +- 插件负责具体协议、合法查询顺序和解析,核心负责 semantic plan、availability、runtime narrowing 和 consistency; +- fake transport 覆盖组合响应、标量查询、activation、语义缺字段、query limit、deadline 和传输异常; +- descriptor 导入、snapshot 和 capability 校验均不得写入仪器、触发、切换输出或消费状态。 + +`source_extensions` 不能单独启用能力,必须同时声明 `source.snapshot_v2`。未声明该能力的旧插件 +继续走 Source V1;当前核心不会注册或接受 Source V2 写 capability。 + ## 配置 options 插件私有配置放在对应的 `[.options]` 表中,并为每个键定义 `OptionSpec`。适合 `OptionSpec` 的内容包括分块点数、插件专用超时和明确枚举;resource、backend、通用 timeout、安全限制和输出状态仍由核心配置管理。 diff --git a/src/wavebench/plugins/lifecycle.py b/src/wavebench/plugins/lifecycle.py index 049f15e..da4f8fb 100644 --- a/src/wavebench/plugins/lifecycle.py +++ b/src/wavebench/plugins/lifecycle.py @@ -776,6 +776,7 @@ def _postflight(self, record: dict[str, str]) -> dict[str, object]: from wavebench.instruments.api import descriptor_from_entry_point from wavebench.instruments.registry import _validate_descriptor +from wavebench.instruments.source_extension_capabilities import validate_source_plugin_dependencies ( expected_name, @@ -820,6 +821,10 @@ def installed_metadata_hash(suffix): if descriptor.driver_id != expected_driver or descriptor.aliases: raise SystemExit("descriptor identity mismatch") _validate_descriptor(descriptor, expected_kind=None) +validate_source_plugin_dependencies( + descriptor, + tuple(dist.metadata.get_all("Requires-Dist") or ()), +) with zipfile.ZipFile(wheel_path) as archive: record_names = [name for name in archive.namelist() if name.endswith(".dist-info/RECORD")] if len(record_names) != 1: diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py index 1c9cd4c..9e77377 100644 --- a/tests/test_plugin_lifecycle.py +++ b/tests/test_plugin_lifecycle.py @@ -40,6 +40,142 @@ def _target_venv(root: Path) -> Path: return python +def _source_v2_descriptor_module( + *, + driver_id: str, + capabilities: tuple[str, ...], + minimum_version: str, + maximum_version: str, +) -> bytes: + return f'''from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + SourceAmplitudeUnit, + SourceBasicCapabilityProfile, + SourceConstraintApplicability, + SourceDescriptorExtensions, + SourceFacetQueryContract, + SourceFacetScope, + SourceFeature, + SourceFeatureCapability, + SourceFeatureDirection, + SourceFieldId, + SourceFrequencyMode, + SourceOutputCapabilityProfile, + SourceQueryContract, + SourceQueryEffect, + SourceSafetyProfile, + SourceTopologyContract, + SourceWaveformKind, + SupportState, +) + + +class Driver: + def close(self): + pass + + def execute_source_query_plan_v2(self, plan): + raise RuntimeError("query fixture must not execute during postflight") + + +def descriptor(): + applicability = SourceConstraintApplicability() + features = ( + SourceFeatureCapability( + feature=SourceFeature.BASIC, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(1,), + applicability=applicability, + profile=SourceBasicCapabilityProfile( + waveform_kinds=(SourceWaveformKind.SINE,), + frequency_modes=(SourceFrequencyMode.FIXED,), + amplitude_units=(SourceAmplitudeUnit.VPP,), + offset_readable=True, + phase_readable=True, + square_duty_readable=False, + ), + ), + SourceFeatureCapability( + feature=SourceFeature.OUTPUT, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(1,), + applicability=applicability, + profile=SourceOutputCapabilityProfile( + output_readable=True, + display_load_readable=False, + polarity_readable=True, + ), + ), + ) + query_contract = SourceQueryContract( + anchor_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + facets=( + SourceFacetQueryContract( + feature=SourceFeature.BASIC, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.BASIC,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + SourceFacetQueryContract( + feature=SourceFeature.BASIC, + scope=SourceFacetScope.INSTRUMENT, + fields=(SourceFieldId.IDENTITY,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + SourceFacetQueryContract( + feature=SourceFeature.OUTPUT, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.OUTPUT,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + ), + max_queries=3, + timeout_ms=1000, + ) + return InstrumentDescriptor( + driver_id={driver_id!r}, + kind="source", + display_name="Example Source V2", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities={capabilities!r}, + idn_patterns=("EXAMPLE,SOURCE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: Driver(), + wavebench_min_version={minimum_version!r}, + wavebench_max_version={maximum_version!r}, + source_extensions=SourceDescriptorExtensions( + contract_version=SOURCE_CONTRACT_VERSION, + topology=SourceTopologyContract((1,)), + features=features, + query_contract=query_contract, + safety_profile=SourceSafetyProfile(), + ), + ) +'''.encode() + + def _plugin_wheel( root: Path, *, @@ -50,20 +186,35 @@ def _plugin_wheel( capabilities: tuple[str, ...] = ("scope.idn",), broken_descriptor: bool = False, include_entry_point: bool = True, + requires_dist: str | tuple[str, ...] = "wavebench>=0.8,<0.9", + source_v2: bool = False, + wavebench_min_version: str = "0.8.0", + wavebench_max_version: str = "0.9.0", ) -> Path: filename_name = distribution.replace("-", "_") dist_info = f"{filename_name}-{version}.dist-info" package_name = "wavebench_example_scope" path = root / f"{filename_name}-{version}-py3-none-any.whl" + dependency_lines = "".join( + f"Requires-Dist: {dependency}\n" + for dependency in ((requires_dist,) if isinstance(requires_dist, str) else requires_dist) + ) metadata = ( "Metadata-Version: 2.1\n" f"Name: {distribution}\n" f"Version: {version}\n" "Requires-Python: >=3.11\n" - "Requires-Dist: wavebench>=0.8,<0.9\n\n" + f"{dependency_lines}\n" ).encode() if broken_descriptor: package = b"def descriptor():\n raise RuntimeError('broken descriptor')\n" + elif source_v2: + package = _source_v2_descriptor_module( + driver_id=driver_id, + capabilities=capabilities, + minimum_version=wavebench_min_version, + maximum_version=wavebench_max_version, + ) else: package = f'''from wavebench.instruments.api import InstrumentDescriptor @@ -215,6 +366,45 @@ def test_lifecycle_preserves_venv_launcher_path(tmp_path): assert Path(environment.python) == launcher +def test_lifecycle_cross_checks_source_v2_wheel_and_descriptor_versions(tmp_path): + python = _target_venv(tmp_path) + good = _plugin_wheel( + tmp_path, + version="0.1.0", + driver_id="example.source-v2", + distribution="wavebench-example-source-v2", + kind="source", + capabilities=("source.snapshot_v2",), + source_v2=True, + wavebench_min_version="0.8.24", + wavebench_max_version="0.9.0", + requires_dist="wavebench>=0.8.24,<0.9", + ) + bad_floor = _plugin_wheel( + tmp_path, + version="0.1.1", + driver_id="example.source-v2", + distribution="wavebench-example-source-v2", + kind="source", + capabilities=("source.snapshot_v2",), + source_v2=True, + wavebench_min_version="0.8.24", + wavebench_max_version="0.9.0", + requires_dist="wavebench>=0.8,<0.9", + ) + lifecycle = PluginLifecycle(python_executable=python) + + assert lifecycle.install(good).status == "installed" + assert lifecycle.info("example.source-v2").status == "healthy" + assert lifecycle.remove("example.source-v2").status == "removed" + + with pytest.raises(ConfigError, match="explicitly include >=0.8.24,<0.9.0"): + lifecycle.install(bad_floor) + + assert lifecycle.installed() == () + assert not lifecycle.journal_path.exists() + + def test_dry_run_does_not_modify_target_venv(tmp_path): python = _target_venv(tmp_path) wheel = _plugin_wheel(tmp_path, version="0.1.0") diff --git a/tests/test_plugin_package_inspect.py b/tests/test_plugin_package_inspect.py index b9962f3..c69669a 100644 --- a/tests/test_plugin_package_inspect.py +++ b/tests/test_plugin_package_inspect.py @@ -48,19 +48,23 @@ def _wheel( wheel_version: str | None = "1.0", entry_points: str = "[wavebench.instruments]\nexample.scope = example:descriptor\n", requires_python: str = ">=3.11", - requires_dist: str = "wavebench>=0.8,<0.9", + requires_dist: str | tuple[str, ...] = "wavebench>=0.8,<0.9", extra_members: dict[str, bytes] | None = None, include_record: bool = True, ) -> Path: filename_name = name.replace("-", "_") path = root / f"{filename_name}-{version}-{filename_tag}.whl" dist_info = f"{filename_name}-{version}.dist-info" + dependency_lines = "".join( + f"Requires-Dist: {dependency}\n" + for dependency in ((requires_dist,) if isinstance(requires_dist, str) else requires_dist) + ) metadata = ( "Metadata-Version: 2.1\n" f"Name: {name}\n" f"Version: {version}\n" f"Requires-Python: {requires_python}\n" - f"Requires-Dist: {requires_dist}\n\n" + f"{dependency_lines}\n" ) members = { f"{dist_info}/METADATA": metadata.encode(), @@ -126,6 +130,30 @@ def test_inspect_wheel_rejects_incompatible_wavebench_version(tmp_path): inspect_plugin_wheel(path) +def test_source_v2_wheel_is_rejected_before_entry_point_import_on_old_core( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + sentinel = tmp_path / "entry-point-imported" + path = _wheel( + tmp_path, + requires_dist="wavebench>=0.8.24,<0.9", + extra_members={ + "wavebench_example_scope/__init__.py": ( + "from pathlib import Path\n" + f"Path({str(sentinel)!r}).write_text('imported', encoding='utf-8')\n" + "def descriptor():\n return None\n" + ).encode(), + }, + ) + monkeypatch.setattr("wavebench.plugins.package_inspect.__version__", "0.8.23") + + with pytest.raises(ConfigError, match="current WaveBench"): + inspect_plugin_wheel(path) + + assert not sentinel.exists() + + def test_inspect_wheel_rejects_filename_and_metadata_tag_mismatch(tmp_path): path = _wheel(tmp_path, wheel_tag="py2-none-any") From 1077418cc00ad72d9442ec621f8aa35bd865d427 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:32:46 +0800 Subject: [PATCH 09/82] docs: accept source v2 basic write R6 --- docs/project/README.md | 4 +- docs/project/rfcs/README.md | 4 +- ...345\207\272\345\256\211\345\205\250RFC.md" | 91 ++++++++++++++++--- 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/docs/project/README.md b/docs/project/README.md index 81111f8..5457d8d 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -29,8 +29,8 @@ - [RFC 索引](rfcs/README.md) - [Source V2 能力、状态与复合输出安全 RFC](rfcs/WaveBench_source能力状态与复合输出安全RFC.md): - `Accepted R5`;核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5 与 C1 的 snapshot、预算、 - 事务和兼容合同,写能力未授权 + `Accepted R6`;核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5 与 C1,后续按 M5-A 至 C3 + 实现基础写入、高级功能、插件验收与发布审计 - [transport 重放与 session 健康 RFC](rfcs/WaveBench_transport重放与session健康RFC.md) ## contributing:开发和接入 diff --git a/docs/project/rfcs/README.md b/docs/project/rfcs/README.md index 4a9dac8..239c4d4 100644 --- a/docs/project/rfcs/README.md +++ b/docs/project/rfcs/README.md @@ -12,8 +12,8 @@ RFC 使用以下状态: ## 当前 RFC - [Source V2 能力、状态与复合输出安全 RFC](WaveBench_source能力状态与复合输出安全RFC.md): - `Accepted R5`,核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5 与 C1:只读 snapshot、 - 显式安全配置、纯预算、离线事务协调、V1 路由审计和受管插件兼容门;写能力仍未授权。 + `Accepted R6`,核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5 与 C1;R6 已冻结基本写入、 + 核心接口和兼容方向,后续按 M5-A 至 C3 逐阶段实现。 - [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 验收门和离线完成证据。 diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" index 97785f0..d3f3b3c 100644 --- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" +++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" @@ -1,11 +1,11 @@ # WaveBench Source V2 能力、状态与复合输出安全 RFC > 状态:`Accepted` -> 修订:`R5` +> 修订:`R6` > 核心基线:WaveBench `0.8.23`,`master@6cd2eb5` > 首个支持版本:WaveBench `0.8.24` -> 实施状态:P0、M1–M4、M4.5 与 C1 已进入核心 `0.8.24` 开发线;只注册 -> `source.snapshot_v2`,未注册写 capability +> 实施状态:P0、M1–M4、M4.5 与 C1 已进入核心 `0.8.24` 开发线;R6 已接受, +> M5-A 起的基础写入实现尚未开始。当前只注册 `source.snapshot_v2`,未注册写 capability。 > [!IMPORTANT] > `Accepted R5` 在 R4 的 operation context、受影响字段闭包、phase、nonce、cleanup reserve @@ -47,6 +47,7 @@ Harmonic、Modulation、Sweep、Burst、Pulse、Noise、DC、ARB、Counter、Com | R3 | Accepted | 冻结 M3 的绝对端口电压配置、端接证据、预算 blocker、端口/共享功率预算模型和纯计算器;不注册任何 V2 写 capability | | R4 | Accepted | 冻结 M4 的 Source operation contract、affected closure、固定 phase、core-owned baseline 与离线事务协调者;不注册任何 V2 写 capability | | R5 | Accepted | 冻结 M4.5 的 V1 写路由清单和 additive artifact 边界,并实现 C1 的受管 wheel/descriptor PEP 440 交叉门与 V1/V2 兼容 fixture;不注册任何 V2 写 capability | +| R6 | Accepted | 冻结基本写入安全、核心接口归属和兼容边界;授权按 M5-A → M5-B → M5-C → M5-D → C2 → M6-A → M6-B → M6-C → M7 → C3 实施 | ## Accepted R5 范围 @@ -2654,9 +2655,8 @@ R5 已加入以下纯离线兼容 fixture,作为上述要求的持续回归: 5. 本次接受只授权 M1–M2。组合/标量 fake、负向测试和兼容矩阵属于实施退出门,不再作为 `Accepted` 之前必须先写代码的条件。 -后续任一 V2 写 capability 注册前,还必须补齐该 feature 的 request/result、完整 -`SourceOperationContract`、closure、预算、恢复、artifact、双合同入口和 A0 验收;本 RFC -`Accepted` 不会自动批准全部保留写 ID。 +R2 的本段只约束 R2–R5 的 snapshot-only 阶段。R6 已为后续基础写入冻结较小的安全集合, +并授权按其里程碑逐项注册 capability;R6 仍不会自动批准未列入对应里程碑的保留写 ID。 ## 实施里程碑 @@ -2669,9 +2669,17 @@ R5 已加入以下纯离线兼容 fixture,作为上述要求的持续回归: | M4 | `implemented-unreleased` | Source operation context | phase、单写、closure、cleanup reserve、session health、nonce 和恢复 fixture 通过;仍不注册写 capability | | M4.5 | `implemented-unreleased` | V1 路由审计与 artifact 兼容防线 | 18 条 V1 写路由及其间接入口冻结;V2 写 operation/run plan 为零;空 `source_operations` 不改变 V1 `run.json`,非空根键保持 additive | | C1 | `implemented-unreleased` | 受管插件版本门与兼容矩阵 | metadata import-before gate、wheel/descriptor PEP 440 交叉校验、合成 V1 entry point 的 V1 成功/V2 零 I/O 拒绝,以及生命周期回滚 fixture 通过 | -| M5 | 未授权 | 分 feature 写 capability | basic/output 起步;每项分别完成 request/result、预算、恢复、双合同映射和 A0 | -| M6 | 未授权 | ARB storage 与跨通道 | CAS storage、selection、Combine/Coupling 图、多通道 OFF 和共享功率合同通过 | -| M7 | 未授权 | 插件逐项 opt in | 在单独授权和合适接线下按 scheme `wavebench.source.a0-a5.v1` 逐 capability 验收 | +| R6 | `Accepted` | 基础写入方向 | 冻结基本安全、核心接口、V1 兼容和下列实施顺序 | +| M5-A | 未开始 | 公共类型与静态验证 | `basic_configure_v2`/`output_v2` 的 request、result、Protocol、descriptor validation 和 A0 构造测试通过;不开放写入口 | +| M5-B | 未开始 | 基础配置事务 | 输出 OFF 的 basic configure、单写、回读、失败恢复和 operation artifact 通过;不改变 V1 setter | +| M5-C | 未开始 | 独立输出转换 | ON/OFF、最终 Vpp/Offset 检查、回读、失败 OFF 和 session health fixture 通过 | +| M5-D | 未开始 | 公共入口与双合同路由 | Service/CLI 后,新增 run plan step、intent、artifact 和 V1 同义路径映射/零 I/O 拒绝通过 | +| C2 | 未开始 | 核心兼容与候选发布门 | 新旧核心/插件矩阵、wheel/sdist、全量离线测试和 V1 artifact 兼容通过 | +| M6-A | 未开始 | 单通道高级配置 | Harmonic、Modulation、Pulse、Sweep、Burst 按 feature 独立 opt in,复用基本写入门 | +| M6-B | 未开始 | ARB storage 与 selection | 上传、覆盖、选择和 ON 分离;ON 仍由 `output_v2` 管理 | +| M6-C | 未开始 | 跨通道配置 | Combine、Coupling、Tracking 和相位关系按受影响端口回读;独立端口允许同时 ON | +| M7 | 未开始 | 插件逐项 opt in | 首个插件完成 basic/output 的 A0–A3;第二种协议形态作为兼容验证,不阻塞首次发布 | +| C3 | 未开始 | 稳定发布审计 | 首个真实插件完成 M5 基础能力、文档和包检查完成、无未登记写 capability | | P0 | `implemented-unreleased` | V1 `amplitude=None` 失败关闭 | ON 对缺失、非有限、非 VPP 或负 Vpp 在 driver 写入前返回稳定 `ConfigError`;OFF 保持原有可执行语义 | ## 已否决方案 @@ -2728,9 +2736,63 @@ R5 已加入以下纯离线兼容 fixture,作为上述要求的持续回归: - R5 的合成 fixture 证明当前核心门禁及回滚语义,不替代历史核心发行物、外部插件 wheel 或实机的 独立兼容与 conformance 验收。 -## 剩余开放问题 +## R6 基础写入修订 -以下问题不阻塞 snapshot-only 的 M1–M2,但会阻塞对应写 capability: +R6 在不改变 Source V1 公共合同的前提下,授权基础 Source V2 写能力。其目标是让已经能返回最终 +Vpp、Offset 和输出状态的设备正常使用信号发生器功能,而不是把完整电气模型、统计模型或发布 +基础设施变成每次写入的前置条件。 + +### R6 基本安全 + +`source.basic_configure_v2` 与 `source.output_v2` 使用以下核心门: + +1. 请求值、设备回读值和单位必须有效;Vpp 必须为有限、非负的最终输出 Vpp,Offset 必须为有限值。 +2. 若配置了 `max_source_vpp`,目标与回读 Vpp 不得超过该限制;没有该配置时,不将其解释为拒绝 + 基础 Source V2 功能。 +3. 若同时配置了 `min_source_port_voltage_v` 和 `max_source_port_voltage_v`,核心以 + `offset ± Vpp / 2` 检查该端口区间;两个配置均缺失时,不增加额外端口电压限制。 +4. 基础配置要求目标通道在写前为 OFF;不支持 V2 live mutation。独立端口可以同时保持 ON,核心 + 不为缺少共享功率或热模型而全局拒绝。 +5. 每个目标字段最多写入一次;写后必须独立回读。结果不明时不得重试;在 session 仍允许 recovery + I/O 时请求受影响端口 OFF,`poisoned` session 仍遵守 transport RFC 的 close-only 规则。 +6. `source.output_v2` 的 OFF 不因 Vpp、Offset、端接或预算信息缺失而拒绝;ON 使用 fresh snapshot + 和上述数值检查。 + +基础写入不要求 `SourceTerminationEvidence`、`CompositeOutputBudget`、RMS、Noise crest factor、ARB +插值上界、复杂负载或共享热功率模型。R3 的严格预算模型继续保留给明确选择它的后续 capability, +不能反向限制 R6 的 basic/output 正常路径。 + +Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使用 `offset ± Vpp / 2`;不要求独立 +`SourceNoisePeakConstraint`。若设备只能提供标称值、RMS 或载波幅度,插件不得为该模式声明 +`source.output_v2`,直到能够返回最终 Vpp 或定义专项 capability。 + +跨通道关系不构成全局单端口 ON 限制。没有已启用 Combine、Coupling、Tracking 或相位关系时,端口 +分别检查。关系影响多个端口时,相关配置要求受影响端口 OFF,并要求插件回读每个受影响端口的最终 +状态;无法确定影响范围时只拒绝该跨通道 operation。 + +### R6 接口归属与兼容 + +- 核心拥有所有 V2 capability ID、request/result、Protocol、`OperationSpec`、错误 envelope、CLI、 + run plan step 和 operation artifact;插件不得自行拼接新的公共写 ID。 +- `source.basic_configure_v2` 和 `source.output_v2` 是独立 capability,不提供通用 + `source.patch_v2`、`source.arm_v2` 或 `source.fire_v2`。 +- 新类型、descriptor 字段和 artifact 键必须 append-only;既有 `SourceDriver`、`SourceStatus`、 + V1 CLI、V1 run step、V1 JSON 和 V1 artifact 不改变语义。 +- V1-only 插件继续执行 V1 路径。双合同插件声明某项 V2 capability 后,核心在 M5-D 将同义或副作用 + 重叠的 V1 route 映射到 V2,无法无损映射时在仪器 I/O 前拒绝;不相交的 V1 route 保持原行为。 +- M5-D 在同一开发线内先完成 Service/CLI,再增加 V2 run plan step、intent 和 artifact;中间不发布 + 稳定写接口。 + +### R6 延后事项 + +RMS、统计 Noise、反应性/非线性负载、ARB 插值过冲、共享热功率、manifest 签名和信任根不阻塞 +基础写入。它们仅在某一设备无法返回最终 Vpp/Offset、或某一后续 capability 明确需要更窄边界时 +再按 feature 增加;不得用缺少这些模型作为阻止普通信号发生器基本配置和独立端口输出的理由。 + +## 剩余开放问题(不阻塞 R6 基础写入) + +以下问题不阻塞 R6 的 basic/output 正常路径。它们只影响无法返回最终 Vpp/Offset、需要更精确电气 +边界,或明确选择严格预算模型的后续 capability: 1. `SourceTerminationEvidence` 对应的 TOML、run intent 和 CLI 人工确认语法;公共类型和有效期规则已冻结。 2. 反应性、频率相关、非线性和未知负载是否扩展首版纯电阻模型;未扩展前固定失败关闭。 @@ -2741,7 +2803,6 @@ R5 已加入以下纯离线兼容 fixture,作为上述要求的持续回归: 7. conformance manifest 是否增加签名、签名信任根和长期保留策略;schema、scheme、wheel 路径和摘要已冻结。 8. 历史插件证据逐份迁移后的正式 manifest 清单;禁止全局等级替换。 -这些问题描述的是「未来端口电压、电流或功率能否被保守上界覆盖」,只读 snapshot 不执行 -setter、trigger、storage mutation 或输出转换,因此不需要用尚未证明的负载/过冲模型放行任何 -动作。它们会阻塞写能力,是因为输出 ON、fire、恢复 ON 和 live mutation 必须证明完整目标状态 -在实验台绝对电压、Vpp、端接和设备共享功率边界内;缺少任一硬边界时只能失败关闭。 +这些问题描述的是更精确的端口电压、电流和功率上界。R6 只使用设备最终回读的 Vpp、Offset、输出 +状态和已配置的基础限制;不能获得这些基本事实时才失败关闭。后续 feature 若需要额外模型,必须在 +其 capability 合同中明确声明,而不是把全部问题提升为普通 Source V2 写入的前置条件。 From c8404625afd7b19ea996dca430a52ebb505596e7 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:35:03 +0800 Subject: [PATCH 10/82] docs: define source output v2 operation directions --- ...210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" | 4 ++++ 1 file changed, 4 insertions(+) diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" index d3f3b3c..c063661 100644 --- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" +++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" @@ -2776,6 +2776,10 @@ Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使 run plan step 和 operation artifact;插件不得自行拼接新的公共写 ID。 - `source.basic_configure_v2` 和 `source.output_v2` 是独立 capability,不提供通用 `source.patch_v2`、`source.arm_v2` 或 `source.fire_v2`。 +- `source.output_v2` 使用两个核心 operation contract:`source.output_enable_v2` 的 direction 为 + `ENABLE`、energy effect 为 `EMIT`;`source.output_disable_v2` 的 direction 为 `DISABLE`、energy + effect 为 `DECREASE_ONLY`。二者共享 capability 和 driver 方法,但各自拥有 closure、phase、 + artifact 和错误路径,不能用一个方向不明确的 contract 混合表示。 - 新类型、descriptor 字段和 artifact 键必须 append-only;既有 `SourceDriver`、`SourceStatus`、 V1 CLI、V1 run step、V1 JSON 和 V1 artifact 不改变语义。 - V1-only 插件继续执行 V1 路径。双合同插件声明某项 V2 capability 后,核心在 M5-D 将同义或副作用 From 0c72445c08c6ba93bb03e60ed57cdc7d630d8bfb Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:05:03 +0800 Subject: [PATCH 11/82] feat: register source v2 basic write contracts --- ...345\231\250\346\217\222\344\273\266API.md" | 22 +- ...345\207\272\345\256\211\345\205\250RFC.md" | 53 +++- .../source_extension_capabilities.py | 168 ++++++++++- .../instruments/source_extensions.py | 284 +++++++++++++++++- src/wavebench/services/operation_specs.py | 67 +++++ tests/test_operation_specs.py | 56 ++++ tests/test_source_extensions.py | 210 ++++++++++++- tests/test_source_v1_routes.py | 35 ++- 8 files changed, 850 insertions(+), 45 deletions(-) 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 1f0735f..1e01faf 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" @@ -343,20 +343,24 @@ I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot | `source.arbitrary_probe` | `probe_arbitrary_queries` | | `source.arbitrary_upload` | `upload_dg4000_dac14_block` | | `source.snapshot_v2` | `execute_source_query_plan_v2` | +| `source.basic_configure_v2` | `configure_source_basic_v2` | +| `source.output_v2` | `set_source_output_v2` | -### Source V2 snapshot 扩展 +### Source V2 扩展 -`source.snapshot_v2` 从核心 `0.8.24` 开始提供,仍使用 `wavebench.instrument.v2`。采用该能力的 +`source.snapshot_v2`、`source.basic_configure_v2` 和 `source.output_v2` 从核心 `0.8.24` 开始提供, +仍使用 `wavebench.instrument.v2`。采用任一 Source V2 capability 的 wheel 依赖和 descriptor `wavebench_min_version` 都必须为 `0.8.24` 或更高的 `0.8.x` 版本。 `source_extensions` 位于 descriptor 末尾且默认值为 `None`,因此未声明该能力的 V1 插件不需要 修改 descriptor 或提高版本下限。 插件从 `wavebench.instruments` 导入 `SourceDescriptorExtensions`、`SourceSnapshotV2Driver`、 -query plan/execution record 和各类 typed profile。核心签发 semantic query plan,插件只负责将 -item 转成合法的厂商协议查询并返回类型化执行记录。插件不得返回完整 `SourceSnapshotV2`,也不得 -自行判定 `UNSUPPORTED`、`NOT_APPLICABLE`、runtime profile 或 snapshot consistency。 +`SourceBasicConfigureV2Driver`、`SourceOutputV2Driver`、query plan/execution record 和各类 typed +profile。核心签发 semantic query plan;snapshot driver 只负责将 item 转成合法的厂商协议查询并返回 +类型化执行记录。插件不得返回完整 `SourceSnapshotV2`,也不得自行判定 `UNSUPPORTED`、 +`NOT_APPLICABLE`、runtime profile 或 snapshot consistency。 -首个修订只接受 `PURE_READ`。每个受支持的 read feature 必须有同 scope 的 query contract; +snapshot query contract 只接受 `PURE_READ`。每个受支持的 read feature 必须有同 scope 的 query contract; identity 必须是唯一、required 的 instrument-scope facet。声明为 `UNSUPPORTED` 或 `UNKNOWN` 的 feature 不得进入查询计划。查询项、effect、字段覆盖、query count 和 deadline 由核心复核;不符合 合同的执行记录不会生成 snapshot。 @@ -367,8 +371,10 @@ feature 不得进入查询计划。查询项、effect、字段覆盖、query cou wavebench source snapshot-v2 ``` -当前没有注册任何 Source V2 写 capability。旧 `source.*` setter、output、trigger 和 ARB 路径继续 -遵守 V1 合同;`source.snapshot_v2` 不会授权、适配或代理这些写入。 +旧 `source.*` setter、output、trigger 和 ARB 路径继续遵守 V1 合同;M5-A 已注册两个 V2 写 +capability 以验证 descriptor 和 driver Protocol,但尚未提供 +`SourceService`、CLI 或 run plan 写入口。插件不得将 capability 注册视为自行发起写操作的许可,也 +不得通过现有 V1 路径绕过后续核心入口。 ### Power、DMM 和 sweep analyzer diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" index c063661..97d947a 100644 --- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" +++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" @@ -4,8 +4,9 @@ > 修订:`R6` > 核心基线:WaveBench `0.8.23`,`master@6cd2eb5` > 首个支持版本:WaveBench `0.8.24` -> 实施状态:P0、M1–M4、M4.5 与 C1 已进入核心 `0.8.24` 开发线;R6 已接受, -> M5-A 起的基础写入实现尚未开始。当前只注册 `source.snapshot_v2`,未注册写 capability。 +> 实施状态:P0、M1–M4、M4.5、C1 与 M5-A 已进入核心 `0.8.24` 开发线;R6 已接受。 +> 当前注册 `source.snapshot_v2`、`source.basic_configure_v2` 和 `source.output_v2`;M5-A 只冻结 +> 公共合同与 descriptor 校验,尚未开放 Source V2 写入口。 > [!IMPORTANT] > `Accepted R5` 在 R4 的 operation context、受影响字段闭包、phase、nonce、cleanup reserve @@ -444,6 +445,24 @@ source_v2_to_data `source_snapshot_timestamp_utc()`、operation artifact builder、coordinator 和错误类为核心内部符号, 不在 `__all__` 中。 +R6/M5-A 在上述 R5 清单末尾追加以下精确条目: + +```text +PatchAction +PatchMode +PatchValue +SourceBasicPatch +SourceBasicConfigureRequest +SourceBasicConfigureResult +SourceBasicConfigureV2Driver +SourceOutputRequest +SourceOutputResult +SourceOutputV2Driver +SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT +SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT +SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT +``` + ### capability 与 Protocol capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。 @@ -2670,7 +2689,7 @@ R2 的本段只约束 R2–R5 的 snapshot-only 阶段。R6 已为后续基础 | M4.5 | `implemented-unreleased` | V1 路由审计与 artifact 兼容防线 | 18 条 V1 写路由及其间接入口冻结;V2 写 operation/run plan 为零;空 `source_operations` 不改变 V1 `run.json`,非空根键保持 additive | | C1 | `implemented-unreleased` | 受管插件版本门与兼容矩阵 | metadata import-before gate、wheel/descriptor PEP 440 交叉校验、合成 V1 entry point 的 V1 成功/V2 零 I/O 拒绝,以及生命周期回滚 fixture 通过 | | R6 | `Accepted` | 基础写入方向 | 冻结基本安全、核心接口、V1 兼容和下列实施顺序 | -| M5-A | 未开始 | 公共类型与静态验证 | `basic_configure_v2`/`output_v2` 的 request、result、Protocol、descriptor validation 和 A0 构造测试通过;不开放写入口 | +| M5-A | `implemented-unreleased` | 公共类型与静态验证 | `basic_configure_v2`/`output_v2` 的 request、result、Protocol、descriptor validation 和 A0 构造测试通过;不开放写入口 | | M5-B | 未开始 | 基础配置事务 | 输出 OFF 的 basic configure、单写、回读、失败恢复和 operation artifact 通过;不改变 V1 setter | | M5-C | 未开始 | 独立输出转换 | ON/OFF、最终 Vpp/Offset 检查、回读、失败 OFF 和 session health fixture 通过 | | M5-D | 未开始 | 公共入口与双合同路由 | Service/CLI 后,新增 run plan step、intent、artifact 和 V1 同义路径映射/零 I/O 拒绝通过 | @@ -2787,6 +2806,34 @@ Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使 - M5-D 在同一开发线内先完成 Service/CLI,再增加 V2 run plan step、intent 和 artifact;中间不发布 稳定写接口。 +### M5-A 公开合同 + +M5-A 只增加闭合的单通道 model,不提供自由 mapping 或通用 patch capability。`PatchValue` 只有 +`KEEP` 与 `SET` 两种 action;`SET` 必须带有限值,`KEEP` 必须不带值。`SourceBasicPatch` 仅含 +`waveform_kind`、`frequency_hz`、`amplitude_vpp`、`offset_v` 和 `square_duty_cycle_percent`,并且至少 +有一个字段为 `SET`。`SourceBasicConfigureRequest` 只接受 `PatchMode.PATCH`,不接受 replace-all。 + +`SourceBasicConfigureResult` 返回同一 channel 的 `BasicWaveFacet` 和 `output_enabled=False`。其中最终 +幅度必须是有限、非负的 Vpp,最终 Offset 必须为有限值。`SourceOutputRequest` 是单通道的 +`enabled: bool`;`SourceOutputResult` 在 `enabled=True` 时必须返回最终 Vpp 和 Offset,在 +`enabled=False` 时允许二者缺失,以保证 OFF 不依赖无关回读。 + +三个静态 operation contract 分别是: + +| operation | capability | direction | energy effect | +| --- | --- | --- | --- | +| `source.basic_configure_v2` | `source.basic_configure_v2` | `CONFIGURE` | `POTENTIAL_WHILE_OFF` | +| `source.output_enable_v2` | `source.output_v2` | `ENABLE` | `EMIT` | +| `source.output_disable_v2` | `source.output_v2` | `DISABLE` | `DECREASE_ONLY` | + +声明任一 M5-A 写 capability 的 descriptor 必须同时声明 `source.snapshot_v2`。基础配置要求同一 +channel 的 Basic 支持 `READ` 与 `CONFIGURE`,并能回读最终 Vpp、Offset 和输出状态;输出 capability +要求同一 channel 的 Output 支持 `READ`、`ENABLE` 与 `DISABLE`,并能回读输出状态、最终 Vpp 和 +Offset。方向、profile、channel 或 required method 不匹配时,在 factory 及仪器 I/O 前失败。 + +M5-A 不增加 `SourceService` 写方法、CLI 写命令或 run plan step;现有 V1 setter、CLI、run plan、TUI +和 artifact 保持原样。capability 注册只让核心识别插件合同,不构成可调用写入口。 + ### R6 延后事项 RMS、统计 Noise、反应性/非线性负载、ARB 插值过冲、共享热功率、manifest 签名和信任根不阻塞 diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py index 3c102ed..5f5dccb 100644 --- a/src/wavebench/instruments/source_extension_capabilities.py +++ b/src/wavebench/instruments/source_extension_capabilities.py @@ -1,4 +1,4 @@ -"""Capability registration and validation for Source V2 snapshots.""" +"""Capability registration and descriptor validation for Source V2.""" from __future__ import annotations @@ -15,18 +15,32 @@ from .source_extensions import ( SOURCE_CONTRACT_VERSION, SOURCE_SNAPSHOT_MIN_CORE_VERSION, + SourceAmplitudeUnit, SourceDescriptorExtensions, SourceAnchorField, + SourceBasicCapabilityProfile, SourceFieldId, + SourceFacetScope, SourceFeature, SourceFeatureDirection, + SourceOutputCapabilityProfile, SourceQueryEffect, + SupportState, ) SOURCE_EXTENSION_CAPABILITY_METHODS: Mapping[str, tuple[str, ...]] = MappingProxyType( { "source.snapshot_v2": ("execute_source_query_plan_v2",), + "source.basic_configure_v2": ("configure_source_basic_v2",), + "source.output_v2": ("set_source_output_v2",), + } +) + +_SOURCE_WRITE_CAPABILITIES = frozenset( + { + "source.basic_configure_v2", + "source.output_v2", } ) @@ -49,20 +63,22 @@ def validate_source_descriptor(descriptor: object, driver: object | None = None) raise ConfigError("source_extensions has an invalid type") if extensions.contract_version != SOURCE_CONTRACT_VERSION: raise ConfigError("source_extensions uses an unsupported contract version") - if declared != ("source.snapshot_v2",): + if "source.snapshot_v2" not in declared: raise ConfigError( - "source_extensions require the source.snapshot_v2 capability and no other " - "Source V2 capability is registered in this core revision" + "source_extensions require the source.snapshot_v2 capability" ) _validate_source_version_range(descriptor) _validate_read_contract(extensions) + _validate_write_contract(extensions, frozenset(declared) & _SOURCE_WRITE_CAPABILITIES) if driver is not None: - method = getattr(driver, "execute_source_query_plan_v2", None) - if not callable(method): - raise TypeError( - "descriptor declares capability 'source.snapshot_v2', but driver lacks " - "callable method execute_source_query_plan_v2" - ) + for capability in declared: + for method_name in SOURCE_EXTENSION_CAPABILITY_METHODS[capability]: + method = getattr(driver, method_name, None) + if not callable(method): + raise TypeError( + f"descriptor declares capability {capability!r}, but driver lacks " + f"callable method {method_name}" + ) def validate_source_plugin_dependencies( @@ -80,7 +96,7 @@ def validate_source_plugin_dependencies( """ capabilities = tuple(getattr(descriptor, "capabilities", ())) - if "source.snapshot_v2" not in capabilities: + if not set(capabilities) & set(SOURCE_EXTENSION_CAPABILITY_METHODS): return _validate_source_version_range(descriptor) @@ -157,10 +173,6 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None: raise ConfigError( f"supported Source V2 feature {feature.feature.value!r} must declare read" ) - if any(direction is not SourceFeatureDirection.READ for direction in feature.directions): - raise ConfigError( - "the accepted Source V2 snapshot revision only allows read directions" - ) if feature.support.value == "supported" and not any( facet.feature is feature.feature and facet.scope is feature.scope for facet in extensions.query_contract.facets @@ -257,6 +269,132 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None: ) +def _validate_write_contract( + extensions: SourceDescriptorExtensions, + capabilities: frozenset[str], +) -> None: + _validate_declared_write_directions(extensions, capabilities) + if not capabilities: + return + + basic_readable = _channels_with_basic_final_vpp(extensions) + output_readable = _channels_with_output_readback(extensions) + + if "source.basic_configure_v2" in capabilities: + configurable = _channels_with_direction( + extensions, + SourceFeature.BASIC, + SourceFeatureDirection.CONFIGURE, + ) + if not configurable: + raise ConfigError( + "source.basic_configure_v2 requires basic feature CONFIGURE directions" + ) + if not configurable <= basic_readable: + raise ConfigError( + "source.basic_configure_v2 requires readable final VPP and Offset on every channel" + ) + if not configurable <= output_readable: + raise ConfigError( + "source.basic_configure_v2 requires readable output state on every channel" + ) + + if "source.output_v2" in capabilities: + enabled = _channels_with_direction( + extensions, + SourceFeature.OUTPUT, + SourceFeatureDirection.ENABLE, + ) + disabled = _channels_with_direction( + extensions, + SourceFeature.OUTPUT, + SourceFeatureDirection.DISABLE, + ) + if not enabled or enabled != disabled: + raise ConfigError( + "source.output_v2 requires matching output ENABLE and DISABLE directions" + ) + if not enabled <= output_readable: + raise ConfigError( + "source.output_v2 requires readable output state on every channel" + ) + if not enabled <= basic_readable: + raise ConfigError( + "source.output_v2 requires readable final VPP and Offset on every channel" + ) + + +def _channels_with_direction( + extensions: SourceDescriptorExtensions, + feature_kind: SourceFeature, + direction: SourceFeatureDirection, +) -> frozenset[int]: + return frozenset( + channel + for feature in extensions.features + if ( + feature.feature is feature_kind + and feature.scope is SourceFacetScope.CHANNEL + and feature.support is SupportState.SUPPORTED + and direction in feature.directions + ) + for channel in feature.channels + ) + + +def _channels_with_basic_final_vpp(extensions: SourceDescriptorExtensions) -> frozenset[int]: + return frozenset( + channel + for feature in extensions.features + if ( + feature.feature is SourceFeature.BASIC + and feature.scope is SourceFacetScope.CHANNEL + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.READ in feature.directions + and isinstance(feature.profile, SourceBasicCapabilityProfile) + and feature.profile.offset_readable + and SourceAmplitudeUnit.VPP in feature.profile.amplitude_units + ) + for channel in feature.channels + ) + + +def _channels_with_output_readback(extensions: SourceDescriptorExtensions) -> frozenset[int]: + return frozenset( + channel + for feature in extensions.features + if ( + feature.feature is SourceFeature.OUTPUT + and feature.scope is SourceFacetScope.CHANNEL + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.READ in feature.directions + and isinstance(feature.profile, SourceOutputCapabilityProfile) + and feature.profile.output_readable + ) + for channel in feature.channels + ) + + +def _validate_declared_write_directions( + extensions: SourceDescriptorExtensions, + capabilities: frozenset[str], +) -> None: + capability_by_direction = { + (SourceFeature.BASIC, SourceFeatureDirection.CONFIGURE): "source.basic_configure_v2", + (SourceFeature.OUTPUT, SourceFeatureDirection.ENABLE): "source.output_v2", + (SourceFeature.OUTPUT, SourceFeatureDirection.DISABLE): "source.output_v2", + } + for feature in extensions.features: + for direction in feature.directions: + if direction is SourceFeatureDirection.READ: + continue + required_capability = capability_by_direction.get((feature.feature, direction)) + if required_capability is None or required_capability not in capabilities: + raise ConfigError( + "Source V2 write directions require their matching declared capability" + ) + + __all__ = [ "SOURCE_EXTENSION_CAPABILITY_METHODS", "validate_source_descriptor", diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py index 89fab83..2a44088 100644 --- a/src/wavebench/instruments/source_extensions.py +++ b/src/wavebench/instruments/source_extensions.py @@ -1,8 +1,8 @@ -"""Public Source V2 snapshot contracts. +"""Public Source V2 contracts. -The first accepted revision is deliberately read-only. It models descriptor -capabilities, typed observations, query plans and snapshot artifacts without -authorizing any Source write operation. +The snapshot contract is read-only. M5-A additionally freezes typed basic +write requests, results, driver Protocols, and static operation contracts; it +does not provide a Source write entry point or perform instrument I/O. """ from __future__ import annotations @@ -382,6 +382,33 @@ class Availability(StrEnum): T = TypeVar("T") +class PatchAction(StrEnum): + KEEP = "keep" + SET = "set" + + +class PatchMode(StrEnum): + PATCH = "patch" + REPLACE_ALL = "replace_all" + + +@dataclass(frozen=True, slots=True) +class PatchValue(Generic[T]): + action: PatchAction + value: T | None = None + + def __post_init__(self) -> None: + if not isinstance(self.action, PatchAction): + raise ValueError("patch action has an invalid type") + if self.action is PatchAction.SET: + if self.value is None: + raise ValueError("SET patch values require a value") + if _contains_nonfinite(self.value): + raise ValueError("SET patch values cannot contain non-finite floats") + elif self.value is not None: + raise ValueError("KEEP patch values must use value=None") + + @dataclass(frozen=True, slots=True) class Observed(Generic[T]): availability: Availability @@ -938,6 +965,93 @@ def __post_init__(self) -> None: _require_int(value, label, minimum=1) +SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.basic_configure_v2", + capability="source.basic_configure_v2", + feature=SourceFeature.BASIC, + direction=SourceFeatureDirection.CONFIGURE, + energy_effect=SourceEnergyEffect.POTENTIAL_WHILE_OFF, + storage_effect=SourceStorageEffect.NONE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.BASIC,), + postcondition_fields=(SourceFieldId.BASIC,), + cleanup_verification_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + ), + v1_equivalent_routes=( + SourceV1WriteRouteId.SET_AMPLITUDE_VPP, + SourceV1WriteRouteId.SET_FREQUENCY, + SourceV1WriteRouteId.SET_FUNCTION, + SourceV1WriteRouteId.SET_SQUARE_DUTY_CYCLE, + ), + v1_overlapping_routes=( + SourceV1WriteRouteId.RESTORE, + SourceV1WriteRouteId.UPLOAD_ARBITRARY, + ), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=2, + verification_max_steps=2, +) + + +SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.output_enable_v2", + capability="source.output_v2", + feature=SourceFeature.OUTPUT, + direction=SourceFeatureDirection.ENABLE, + energy_effect=SourceEnergyEffect.EMIT, + storage_effect=SourceStorageEffect.NONE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.OUTPUT,), + postcondition_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + ), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=(SourceV1WriteRouteId.SET_OUTPUT,), + v1_overlapping_routes=( + SourceV1WriteRouteId.RESTORE, + SourceV1WriteRouteId.TRIGGER_BURST, + SourceV1WriteRouteId.TRIGGER_SWEEP, + SourceV1WriteRouteId.UPLOAD_ARBITRARY, + ), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=1, +) + + +SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.output_disable_v2", + capability="source.output_v2", + feature=SourceFeature.OUTPUT, + direction=SourceFeatureDirection.DISABLE, + energy_effect=SourceEnergyEffect.DECREASE_ONLY, + storage_effect=SourceStorageEffect.NONE, + required_fields=(SourceFieldId.OUTPUT,), + changed_fields=(SourceFieldId.OUTPUT,), + postcondition_fields=(SourceFieldId.OUTPUT,), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=(SourceV1WriteRouteId.SET_OUTPUT,), + v1_overlapping_routes=(SourceV1WriteRouteId.RESTORE,), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=1, +) + + @dataclass(frozen=True, slots=True) class SourceAffectedClosure: """A core-created, context-bound field closure for one Source operation.""" @@ -1943,6 +2057,139 @@ def __post_init__(self) -> None: _require_bool(self.enabled.value, "output enabled value") +@dataclass(frozen=True, slots=True) +class SourceBasicPatch: + waveform_kind: PatchValue[SourceWaveformKind] = PatchValue(PatchAction.KEEP) + frequency_hz: PatchValue[float] = PatchValue(PatchAction.KEEP) + amplitude_vpp: PatchValue[float] = PatchValue(PatchAction.KEEP) + offset_v: PatchValue[float] = PatchValue(PatchAction.KEEP) + square_duty_cycle_percent: PatchValue[float] = PatchValue(PatchAction.KEEP) + + def __post_init__(self) -> None: + values = ( + ("waveform_kind", self.waveform_kind), + ("frequency_hz", self.frequency_hz), + ("amplitude_vpp", self.amplitude_vpp), + ("offset_v", self.offset_v), + ("square_duty_cycle_percent", self.square_duty_cycle_percent), + ) + if any(not isinstance(value, PatchValue) for _, value in values): + raise ValueError("source basic patch values must be PatchValue") + if not any(value.action is PatchAction.SET for _, value in values): + raise ValueError("source basic patch requires at least one SET value") + if self.waveform_kind.action is PatchAction.SET and not isinstance( + self.waveform_kind.value, + SourceWaveformKind, + ): + raise ValueError("source basic patch waveform_kind must be SourceWaveformKind") + for label, value, minimum, maximum in ( + ("frequency_hz", self.frequency_hz, 0.0, None), + ("amplitude_vpp", self.amplitude_vpp, 0.0, None), + ("offset_v", self.offset_v, None, None), + ("square_duty_cycle_percent", self.square_duty_cycle_percent, 0.0, 100.0), + ): + if value.action is PatchAction.SET: + _require_finite( + value.value, + f"source basic patch {label}", + minimum=minimum, + maximum=maximum, + ) + + +@dataclass(frozen=True, slots=True) +class SourceBasicConfigureRequest: + channel: int + patch: SourceBasicPatch + mode: PatchMode = PatchMode.PATCH + + def __post_init__(self) -> None: + _require_int(self.channel, "source basic configure channel", minimum=1) + if not isinstance(self.patch, SourceBasicPatch): + raise ValueError("source basic configure patch has an invalid type") + if not isinstance(self.mode, PatchMode): + raise ValueError("source basic configure mode has an invalid type") + if self.mode is not PatchMode.PATCH: + raise ValueError("source basic configure only supports PATCH mode") + + +@dataclass(frozen=True, slots=True) +class SourceOutputRequest: + channel: int + enabled: bool + + def __post_init__(self) -> None: + _require_int(self.channel, "source output channel", minimum=1) + _require_bool(self.enabled, "source output enabled") + + +@dataclass(frozen=True, slots=True) +class SourceBasicConfigureResult: + channel: int + basic: BasicWaveFacet + output_enabled: bool + + def __post_init__(self) -> None: + _require_int(self.channel, "source basic configure result channel", minimum=1) + if not isinstance(self.basic, BasicWaveFacet): + raise ValueError("source basic configure result basic has an invalid type") + _require_bool(self.output_enabled, "source basic configure result output_enabled") + if self.output_enabled: + raise ValueError("source basic configure result requires output_enabled=False") + if ( + self.basic.amplitude.availability is not Availability.VALUE + or not isinstance(self.basic.amplitude.value, SourceAmplitude) + or self.basic.amplitude.value.unit is not SourceAmplitudeUnit.VPP + ): + raise ValueError( + "source basic configure result requires a final VPP amplitude readback" + ) + _require_finite( + self.basic.amplitude.value.value, + "source basic configure result final_amplitude", + minimum=0.0, + ) + if self.basic.offset_v.availability is not Availability.VALUE: + raise ValueError( + "source basic configure result requires a final offset readback" + ) + _require_finite( + self.basic.offset_v.value, + "source basic configure result final_offset_v", + ) + + +@dataclass(frozen=True, slots=True) +class SourceOutputResult: + channel: int + enabled: bool + final_amplitude: SourceAmplitude | None = None + final_offset_v: float | None = None + + def __post_init__(self) -> None: + _require_int(self.channel, "source output result channel", minimum=1) + _require_bool(self.enabled, "source output result enabled") + if self.final_amplitude is not None: + if not isinstance(self.final_amplitude, SourceAmplitude): + raise ValueError("source output result final_amplitude has an invalid type") + if self.final_amplitude.unit is not SourceAmplitudeUnit.VPP: + raise ValueError("source output result final_amplitude must use VPP") + _require_finite( + self.final_amplitude.value, + "source output result final_amplitude value", + minimum=0.0, + ) + if self.final_offset_v is not None: + _require_finite( + self.final_offset_v, + "source output result final_offset_v", + ) + if self.enabled and (self.final_amplitude is None or self.final_offset_v is None): + raise ValueError( + "enabled source output results require final_amplitude and final_offset_v" + ) + + @dataclass(frozen=True, slots=True) class HarmonicFacet: enabled: Observed[bool] @@ -2838,6 +3085,22 @@ def execute_source_query_plan_v2( ) -> SourceQueryExecutionRecord: ... +@runtime_checkable +class SourceBasicConfigureV2Driver(InstrumentDriver, Protocol): + def configure_source_basic_v2( + self, + request: SourceBasicConfigureRequest, + ) -> SourceBasicConfigureResult: ... + + +@runtime_checkable +class SourceOutputV2Driver(InstrumentDriver, Protocol): + def set_source_output_v2( + self, + request: SourceOutputRequest, + ) -> SourceOutputResult: ... + + def source_v2_to_data(value: object) -> object: """Convert Source V2 public values into strict JSON-compatible data.""" @@ -3067,4 +3330,17 @@ def source_snapshot_timestamp_utc() -> str: "source_v2_canonical_json", "source_v2_digest", "source_v2_to_data", + "PatchAction", + "PatchMode", + "PatchValue", + "SourceBasicPatch", + "SourceBasicConfigureRequest", + "SourceBasicConfigureResult", + "SourceBasicConfigureV2Driver", + "SourceOutputRequest", + "SourceOutputResult", + "SourceOutputV2Driver", + "SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT", + "SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT", + "SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT", ] diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 4fdae91..f096b32 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -421,6 +421,73 @@ def _spec( error_check_minimum="disabled", risk_flags=("state_dependent_query",), ), + _spec( + "source.basic_configure_v2", + "source", + required_capabilities=("source.basic_configure_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=("source.channel.basic",), + restore_coverage="source-v2-basic", + required_verified_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + verification_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + postcondition_fields=("source.channel.basic",), + cleanup_verification_fields=("source.channel.basic", "source.channel.output"), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("source_v2", "output_must_be_off"), + ), + _spec( + "source.output_enable_v2", + "source", + required_capabilities=("source.output_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=("source.channel.output",), + restore_coverage="source-v2-output", + required_verified_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + verification_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + postcondition_fields=("source.channel.basic", "source.channel.output"), + cleanup_verification_fields=("source.channel.output",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("source_v2", "dangerous_output"), + ), + _spec( + "source.output_disable_v2", + "source", + required_capabilities=("source.output_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=("source.channel.output",), + restore_coverage="source-v2-output", + required_verified_fields=("source.channel.output",), + verification_fields=("source.channel.output",), + postcondition_fields=("source.channel.output",), + cleanup_verification_fields=("source.channel.output",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("source_v2", "safe_output_off"), + ), _spec("source.channel_profile", "source", required_capabilities=("source.channel_profile",), effect="stateful_read"), _spec("source.coupling_profile", "source", required_capabilities=("source.coupling_profile",), effect="stateful_read"), _spec("source.coupling_configure", "source", required_capabilities=("source.coupling_configure",), effect="write", changed_fields=("coupling",), risk_flags=("state_drift",)), diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index 54d7d9b..a441142 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -3,6 +3,12 @@ import pytest from wavebench.errors import ConfigError +from wavebench.instruments.source_extensions import ( + SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT, + SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT, + SourceEnergyEffect, +) from wavebench.services.operation_specs import ( OPERATION_REGISTRY, OperationRegistry, @@ -26,6 +32,56 @@ def test_source_output_spec_describes_mutation_and_restore_boundary() -> None: assert spec.as_dict()["required_capabilities"] == ["source.output"] +def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: + pairs = ( + ( + SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, + "source-v2-basic", + ("source_v2", "output_must_be_off"), + ), + ( + SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT, + "source-v2-output", + ("source_v2", "dangerous_output"), + ), + ( + SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT, + "source-v2-output", + ("source_v2", "safe_output_off"), + ), + ) + + for contract, restore_coverage, risk_flags in pairs: + spec = require_operation_spec(contract.operation) + required_fields = {field.value for field in contract.required_fields} + changed_fields = {field.value for field in contract.changed_fields} + postcondition_fields = {field.value for field in contract.postcondition_fields} + cleanup_fields = {field.value for field in contract.cleanup_verification_fields} + + assert spec.instrument_kind == "source" + assert spec.effect == "write" + assert spec.required_capabilities == (contract.capability,) + assert spec.lease_mode == "exclusive" + assert spec.timeout_source == "operation.timeout_ms" + assert spec.operation_timeout_ms == contract.operation_timeout_ms + assert required_fields <= set(spec.required_verified_fields) + assert required_fields <= set(spec.verification_fields) + assert changed_fields <= set(spec.changed_fields) + assert postcondition_fields == set(spec.postcondition_fields) + assert cleanup_fields == set(spec.cleanup_verification_fields) + assert spec.restore_coverage == restore_coverage + assert spec.risk_flags == risk_flags + assert spec.error_check_minimum == "disabled" + + assert SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT.energy_effect is ( + SourceEnergyEffect.POTENTIAL_WHILE_OFF + ) + assert SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT.energy_effect is SourceEnergyEffect.EMIT + assert SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT.energy_effect is ( + SourceEnergyEffect.DECREASE_ONLY + ) + + def test_registry_is_read_only_and_filters_by_instrument_kind() -> None: assert get_operation_spec("run.check") is not None assert get_operation_spec("does.not.exist") is None diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py index bc3ee9d..ee94161 100644 --- a/tests/test_source_extensions.py +++ b/tests/test_source_extensions.py @@ -23,6 +23,7 @@ from wavebench.instruments.source_extensions import ( Availability, Observed, + SourceFeatureDirection, SourceFacetScope, SourceFieldId, SourceReasonCode, @@ -32,6 +33,7 @@ from tests.source_v2_fixtures import ( SourceV2FakeDriver, + basic_facet, source_descriptor, source_extensions, ) @@ -49,7 +51,11 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None: ).read_text(encoding="utf-8") match = re.search(r"R5 的精确清单为:\n\n```text\n(.*?)\n```", rfc, re.S) assert match is not None - assert match.group(1).splitlines() == module.__all__ + r5_exports = match.group(1).splitlines() + assert module.__all__[: len(r5_exports)] == r5_exports + match = re.search(r"R6/M5-A 在上述 R5 清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", rfc, re.S) + assert match is not None + assert module.__all__[len(r5_exports) :] == match.group(1).splitlines() def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None: @@ -204,6 +210,23 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "recovery_max_steps", "verification_max_steps", ), + "PatchValue": ("action", "value"), + "SourceBasicPatch": ( + "waveform_kind", + "frequency_hz", + "amplitude_vpp", + "offset_v", + "square_duty_cycle_percent", + ), + "SourceBasicConfigureRequest": ("channel", "patch", "mode"), + "SourceBasicConfigureResult": ("channel", "basic", "output_enabled"), + "SourceOutputRequest": ("channel", "enabled"), + "SourceOutputResult": ( + "channel", + "enabled", + "final_amplitude", + "final_offset_v", + ), "SourceAffectedClosure": ( "operation", "context_id", @@ -362,10 +385,13 @@ def test_source_descriptor_append_only_and_replace_compatible() -> None: def test_source_snapshot_capability_is_additive_and_validated() -> None: descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) - assert CAPABILITY_METHODS["source.snapshot_v2"] == ("execute_source_query_plan_v2",) - assert dict(SOURCE_EXTENSION_CAPABILITY_METHODS) == { - "source.snapshot_v2": ("execute_source_query_plan_v2",) + expected = { + "source.snapshot_v2": ("execute_source_query_plan_v2",), + "source.basic_configure_v2": ("configure_source_basic_v2",), + "source.output_v2": ("set_source_output_v2",), } + assert dict(SOURCE_EXTENSION_CAPABILITY_METHODS) == expected + assert {key: CAPABILITY_METHODS[key] for key in expected} == expected validate_source_descriptor(descriptor) validate_declared_capabilities(descriptor, SourceV2FakeDriver(combined=True)) @@ -373,6 +399,180 @@ def test_source_snapshot_capability_is_additive_and_validated() -> None: validate_declared_capabilities(descriptor, type("Driver", (), {"close": lambda self: None})()) +def test_source_v2_basic_write_models_are_closed_and_serializable() -> None: + keep = module.PatchValue(module.PatchAction.KEEP) + set_frequency = module.PatchValue(module.PatchAction.SET, 1_000.0) + patch = module.SourceBasicPatch(frequency_hz=set_frequency) + request = module.SourceBasicConfigureRequest(channel=1, patch=patch) + + assert module.source_v2_to_data(request) == { + "type": "SourceBasicConfigureRequest", + "channel": 1, + "patch": { + "type": "SourceBasicPatch", + "waveform_kind": {"type": "PatchValue", "action": "keep", "value": None}, + "frequency_hz": {"type": "PatchValue", "action": "set", "value": 1_000.0}, + "amplitude_vpp": {"type": "PatchValue", "action": "keep", "value": None}, + "offset_v": {"type": "PatchValue", "action": "keep", "value": None}, + "square_duty_cycle_percent": { + "type": "PatchValue", + "action": "keep", + "value": None, + }, + }, + "mode": "patch", + } + assert keep.action is module.PatchAction.KEEP + assert module.SourceBasicConfigureResult(1, basic_facet(), False).output_enabled is False + assert module.SourceOutputResult(1, False) == module.SourceOutputResult(1, False) + + with pytest.raises(ValueError, match="SET patch values"): + module.PatchValue(module.PatchAction.SET) + with pytest.raises(ValueError, match="KEEP patch values"): + module.PatchValue(module.PatchAction.KEEP, 1.0) + with pytest.raises(ValueError, match="at least one SET"): + module.SourceBasicPatch() + with pytest.raises(ValueError, match="must be >= 0.0"): + module.SourceBasicPatch(amplitude_vpp=module.PatchValue(module.PatchAction.SET, -0.1)) + with pytest.raises(ValueError, match="only supports PATCH"): + module.SourceBasicConfigureRequest( + channel=1, + patch=patch, + mode=module.PatchMode.REPLACE_ALL, + ) + with pytest.raises(ValueError, match="output_enabled=False"): + module.SourceBasicConfigureResult(1, basic_facet(), True) + with pytest.raises(ValueError, match="final VPP amplitude"): + module.SourceBasicConfigureResult( + 1, + replace( + basic_facet(), + amplitude=Observed.value_of( + module.SourceAmplitude(1.0, module.SourceAmplitudeUnit.VRMS) + ), + ), + False, + ) + with pytest.raises(ValueError, match="final offset"): + module.SourceBasicConfigureResult( + 1, + replace( + basic_facet(), + offset_v=Observed.missing( + Availability.NOT_QUERIED, + SourceReasonCode.NOT_REQUESTED, + ), + ), + False, + ) + with pytest.raises(ValueError, match="require final_amplitude"): + module.SourceOutputResult(1, True) + with pytest.raises(ValueError, match="must be >= 0.0"): + module.SourceOutputResult( + 1, + False, + module.SourceAmplitude(-0.1, module.SourceAmplitudeUnit.VPP), + ) + + +def test_source_v2_write_capabilities_require_matching_directions_and_readback() -> None: + extensions = source_extensions() + basic, output = extensions.features + write_extensions = replace( + extensions, + features=( + replace( + basic, + directions=( + SourceFeatureDirection.CONFIGURE, + SourceFeatureDirection.READ, + ), + ), + replace( + output, + directions=( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), + ), + ), + ) + descriptor = replace( + source_descriptor(extensions=write_extensions), + capabilities=( + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + ), + ) + + class WriteDriver(SourceV2FakeDriver): + def configure_source_basic_v2(self, request): + raise AssertionError(request) + + def set_source_output_v2(self, request): + raise AssertionError(request) + + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, WriteDriver(combined=True)) + + with pytest.raises(ConfigError, match="matching declared capability"): + validate_source_descriptor(source_descriptor(extensions=write_extensions)) + with pytest.raises(ConfigError, match="require the source.snapshot_v2"): + validate_source_descriptor( + replace( + descriptor, + capabilities=("source.basic_configure_v2",), + ) + ) + with pytest.raises(ConfigError, match="CONFIGURE directions"): + validate_source_descriptor( + replace( + descriptor, + source_extensions=replace( + write_extensions, + features=( + basic, + write_extensions.features[1], + ), + ), + ) + ) + with pytest.raises(ConfigError, match="matching output ENABLE and DISABLE"): + validate_source_descriptor( + replace( + descriptor, + source_extensions=replace( + write_extensions, + features=( + write_extensions.features[0], + replace( + write_extensions.features[1], + directions=( + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), + ), + ), + ), + ) + ) + with pytest.raises(TypeError, match="configure_source_basic_v2"): + validate_declared_capabilities( + descriptor, + type( + "MissingBasicWriteDriver", + (), + { + "close": lambda self: None, + "execute_source_query_plan_v2": lambda self, plan: None, + "set_source_output_v2": lambda self, request: None, + }, + )(), + ) + + def test_source_v2_rejects_invalid_feature_scope_and_query_field_ownership() -> None: extensions = source_extensions() basic = extensions.features[0] @@ -471,7 +671,7 @@ def test_source_v1_capability_mapping_is_unchanged() -> None: actual = { key: value for key, value in CAPABILITY_METHODS.items() - if key.startswith("source.") and key != "source.snapshot_v2" + if key.startswith("source.") and not key.endswith("_v2") } assert actual == expected diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py index 1c2fdf6..323a39d 100644 --- a/tests/test_source_v1_routes.py +++ b/tests/test_source_v1_routes.py @@ -13,7 +13,7 @@ from wavebench.services.source_v1_routes import SOURCE_V1_WRITE_ROUTE_INVENTORY -def test_source_v1_write_inventory_covers_every_route_and_write_operation() -> None: +def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs() -> None: inventory = SOURCE_V1_WRITE_ROUTE_INVENTORY assert tuple(item.route for item in inventory) == tuple(SourceV1WriteRouteId) @@ -28,11 +28,16 @@ def test_source_v1_write_inventory_covers_every_route_and_write_operation() -> N if spec.effect == "write" } inventoried_operations = {item.operation for item in inventory if item.operation is not None} - assert source_write_operations == inventoried_operations + assert inventoried_operations <= source_write_operations + assert source_write_operations - inventoried_operations == { + "source.basic_configure_v2", + "source.output_enable_v2", + "source.output_disable_v2", + } assert all(require_operation_spec(operation).effect == "write" for operation in inventoried_operations) -def test_source_v1_indirect_write_entries_are_frozen_and_v2_writes_are_not_registered() -> None: +def test_source_v1_indirect_write_entries_are_frozen_and_v2_writes_have_no_run_plan_entry() -> None: entrypoints = { entrypoint for item in SOURCE_V1_WRITE_ROUTE_INVENTORY @@ -52,14 +57,24 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_writes_are_not_regis for kind in ALLOWED_STEP_KINDS if kind.startswith("source.") and kind != "source.status" } == expected_run_steps - assert not [ - spec + assert { + spec.operation for spec in list_operation_specs(instrument_kind="source") if "_v2" in spec.operation and spec.effect == "write" - ] + } == { + "source.basic_configure_v2", + "source.output_enable_v2", + "source.output_disable_v2", + } with TemporaryDirectory() as tmp: - plan_path = Path(tmp) / "plan.toml" - plan_path.write_text('[[steps]]\nkind = "source.output_v2"\n', encoding="utf-8") - with pytest.raises(ConfigError, match="source.output_v2.*not supported"): - load_run_plan(plan_path) + for kind in ( + "source.basic_configure_v2", + "source.output_enable_v2", + "source.output_disable_v2", + "source.output_v2", + ): + plan_path = Path(tmp) / f"{kind}.toml" + plan_path.write_text(f'[[steps]]\nkind = "{kind}"\n', encoding="utf-8") + with pytest.raises(ConfigError, match=f"{kind}.*not supported"): + load_run_plan(plan_path) From 6efffe861038878653660c0003cd74f54bb056d3 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:38:16 +0800 Subject: [PATCH 12/82] feat: bind source preflight snapshots in operation contexts --- .../services/source_operation_context.py | 71 +++++++++++++++++-- tests/test_source_operation_context.py | 38 ++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/wavebench/services/source_operation_context.py b/src/wavebench/services/source_operation_context.py index ad8cb43..8027a27 100644 --- a/src/wavebench/services/source_operation_context.py +++ b/src/wavebench/services/source_operation_context.py @@ -291,7 +291,7 @@ def __init__( operation_spec: OperationSpec, operation_contract: SourceOperationContract, connection_timeout_ms: int, - baseline_snapshot_digest: str, + baseline_snapshot_digest: str | None, fields: tuple[SourceFieldRef, ...], required_off_outputs: tuple[SourceScopeRef, ...], emergency_off_outputs: tuple[SourceScopeRef, ...], @@ -314,8 +314,9 @@ def __init__( raise ValueError("connection_timeout_ms must be a positive integer") if session_state.health is not SessionHealth.HEALTHY: raise ValueError("new Source operations require a healthy session") - if not isinstance(baseline_snapshot_digest, str) or not baseline_snapshot_digest.startswith( - "sha256:" + if baseline_snapshot_digest is not None and ( + not isinstance(baseline_snapshot_digest, str) + or not baseline_snapshot_digest.startswith("sha256:") ): raise ValueError("source operation baseline_snapshot_digest must be a SHA-256 digest") @@ -361,10 +362,12 @@ def __init__( self._phase_history: list[dict[str, object]] = [] self._used_phases: set[SourceOperationPhase] = set() self._baseline: _BaselineRecord | None = None + self._baseline_snapshot_bound = baseline_snapshot_digest is not None self._main_entered = False self._failure_required = False self._postcondition_verified = False self._cleanup_verified = False + self._safe_state_verified = False self._terminal = False normalized_fields = _field_refs(fields, label="source operation closure fields") @@ -387,7 +390,17 @@ def __init__( non_restorable_fields=normalized_non_restorable, ) self.closure = self._build_closure( - baseline_snapshot_digest=baseline_snapshot_digest, + baseline_snapshot_digest=( + baseline_snapshot_digest + if baseline_snapshot_digest is not None + else source_v2_digest( + { + "operation": self.operation_id, + "context_id": self.context_id, + "state": "preflight_pending", + } + ) + ), fields=normalized_fields, required_off_outputs=required_off_outputs, emergency_off_outputs=emergency_off_outputs, @@ -516,6 +529,8 @@ def create_baseline(self) -> SourceBaselineHandle: """Issue a one-use baseline handle while the preflight phase is active.""" self._require_phase(SourceOperationPhase.PREFLIGHT) + if not self._baseline_snapshot_bound: + raise ValueError("source operation baseline snapshot is not bound") if self._baseline is not None: raise ValueError("source operation already has a baseline") if not self.closure.restore_order: @@ -534,6 +549,26 @@ def create_baseline(self) -> SourceBaselineHandle: self._baseline = _BaselineRecord(handle=handle) return handle + def bind_baseline_snapshot_digest(self, baseline_snapshot_digest: str) -> None: + """Bind the preflight snapshot after its core-owned read has completed.""" + + self._require_phase(SourceOperationPhase.PREFLIGHT) + if self._baseline_snapshot_bound or self._baseline is not None: + raise ValueError("source operation baseline snapshot is already bound") + if not isinstance(baseline_snapshot_digest, str) or not baseline_snapshot_digest.startswith( + "sha256:" + ): + raise ValueError("source operation baseline_snapshot_digest must be a SHA-256 digest") + self.closure = self._build_closure( + baseline_snapshot_digest=baseline_snapshot_digest, + fields=self.closure.fields, + required_off_outputs=self.closure.required_off_outputs, + emergency_off_outputs=self.closure.emergency_off_outputs, + restore_order=self.closure.restore_order, + non_restorable_fields=self.closure.non_restorable_fields, + ) + self._baseline_snapshot_bound = True + def pass_baseline_to_main(self, handle: SourceBaselineHandle) -> None: self._require_phase(SourceOperationPhase.PREFLIGHT) record = self._baseline_record(handle) @@ -561,7 +596,7 @@ def mark_failure_required(self) -> None: raise ValueError("Source failure cleanup requires a closed main phase") if self._failure_required: raise ValueError("Source operation cleanup is already required") - if SourceOperationPhase.POSTCONDITION in self._used_phases: + if self._postcondition_verified: raise ValueError("Source operation cannot fail after postcondition completed") self._failure_required = True if self.session_state.health is SessionHealth.HEALTHY: @@ -660,6 +695,29 @@ def complete_phase_verification( if authorization.phase is SourceOperationPhase.CLEANUP_VERIFICATION: self._cleanup_verified = True + def mark_safe_state_verified( + self, + authorization: SourcePhaseAuthorization, + *, + io_kind: str, + fields: Iterable[SourceFieldRef], + ) -> None: + """Record a verified emergency OFF state without restoring mutation evidence.""" + + self._require_authorization(authorization, SourceOperationPhase.CLEANUP_VERIFICATION) + if not self._failure_required: + raise ValueError("safe-state verification requires a failed source operation") + verified = frozenset(_field_refs(fields, label="source verified fields")) + expected = frozenset(_output_fields(self.closure.emergency_off_outputs)) + if verified != expected or verified != authorization.fields: + raise ValueError("safe-state verification must cover exactly emergency OFF outputs") + self._session_coordinator.record_evidence( + authorization._session_authorization, + io_kind, + _field_keys(verified), + ) + self._safe_state_verified = True + def complete(self) -> None: """Terminally close the context and poison an incomplete failure cleanup.""" @@ -678,7 +736,7 @@ def complete(self) -> None: and self._cleanup_verified and (baseline is None or baseline.verification_succeeded is True) ) - if not cleanup_ok and self.session_state.health in { + if not cleanup_ok and not self._safe_state_verified and self.session_state.health in { SessionHealth.HEALTHY, SessionHealth.UNCERTAIN, }: @@ -719,6 +777,7 @@ def artifact(self) -> dict[str, object]: "non_restorable_fields": sorted(_field_keys(self.closure.non_restorable_fields)), }, "cleanup_reserve_ms": self.cleanup_reserve_ms, + "safe_state_verified": self._safe_state_verified, "phases": [dict(item) for item in self._phase_history], "baseline": ( None diff --git a/tests/test_source_operation_context.py b/tests/test_source_operation_context.py index 45200d2..4ddc210 100644 --- a/tests/test_source_operation_context.py +++ b/tests/test_source_operation_context.py @@ -233,6 +233,44 @@ def test_context_binds_closure_and_successfully_verifies_postcondition() -> None ] +def test_context_can_bind_a_core_snapshot_digest_during_preflight() -> None: + transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type] + contract = _contract() + context = SourceOperationContextCoordinator( + session_state=transport.session_state, + operation_spec=_spec(contract), + operation_contract=contract, + connection_timeout_ms=1_000, + baseline_snapshot_digest=None, + fields=FIELDS, + required_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + emergency_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + restore_order=(BASIC,), + non_restorable_fields=(OUTPUT,), + correlation_id="source-context-bind-test", + ) + placeholder = context.closure.baseline_snapshot_digest + bound = "sha256:" + "2" * 64 + phase = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=FIELDS, + max_steps=2, + ) + with context.authorize_phase(phase) as authorization: + with pytest.raises(ValueError, match="not bound"): + context.create_baseline() + context.bind_baseline_snapshot_digest(bound) + baseline = context.create_baseline() + context.pass_baseline_to_main(baseline) + transport.query("SNAPSHOT?") + context.complete_phase_verification(authorization, io_kind="query", fields=FIELDS) + + assert placeholder != bound + assert context.closure.baseline_snapshot_digest == bound + context.complete() + + def test_failure_path_orders_safe_off_restore_and_cleanup_verification() -> None: transport, context = _context() baseline = _preflight(transport, context) From 7d33ebcfd881d6e1b59f1528c7d10550d0a32a4f Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:43:26 +0800 Subject: [PATCH 13/82] feat: add source v2 basic configure transaction --- ...345\207\272\345\256\211\345\205\250RFC.md" | 40 +- .../instruments/source_extensions.py | 7 + src/wavebench/services/source_service.py | 662 +++++++++++++++++- src/wavebench/services/source_snapshot_v2.py | 4 +- tests/test_source_basic_configure_v2.py | 407 +++++++++++ tests/test_source_extensions.py | 7 + 6 files changed, 1094 insertions(+), 33 deletions(-) create mode 100644 tests/test_source_basic_configure_v2.py diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" index 97d947a..9d28525 100644 --- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" +++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" @@ -4,9 +4,9 @@ > 修订:`R6` > 核心基线:WaveBench `0.8.23`,`master@6cd2eb5` > 首个支持版本:WaveBench `0.8.24` -> 实施状态:P0、M1–M4、M4.5、C1 与 M5-A 已进入核心 `0.8.24` 开发线;R6 已接受。 +> 实施状态:P0、M1–M4、M4.5、C1、M5-A 与 M5-B 已进入核心 `0.8.24` 开发线;R6 已接受。 > 当前注册 `source.snapshot_v2`、`source.basic_configure_v2` 和 `source.output_v2`;M5-A 只冻结 -> 公共合同与 descriptor 校验,尚未开放 Source V2 写入口。 +> 公共合同与 descriptor 校验,M5-B 只提供内部基础配置事务,尚未开放 Source V2 写入口。 > [!IMPORTANT] > `Accepted R5` 在 R4 的 operation context、受影响字段闭包、phase、nonce、cleanup reserve @@ -2690,7 +2690,7 @@ R2 的本段只约束 R2–R5 的 snapshot-only 阶段。R6 已为后续基础 | C1 | `implemented-unreleased` | 受管插件版本门与兼容矩阵 | metadata import-before gate、wheel/descriptor PEP 440 交叉校验、合成 V1 entry point 的 V1 成功/V2 零 I/O 拒绝,以及生命周期回滚 fixture 通过 | | R6 | `Accepted` | 基础写入方向 | 冻结基本安全、核心接口、V1 兼容和下列实施顺序 | | M5-A | `implemented-unreleased` | 公共类型与静态验证 | `basic_configure_v2`/`output_v2` 的 request、result、Protocol、descriptor validation 和 A0 构造测试通过;不开放写入口 | -| M5-B | 未开始 | 基础配置事务 | 输出 OFF 的 basic configure、单写、回读、失败恢复和 operation artifact 通过;不改变 V1 setter | +| M5-B | `implemented-unreleased` | 基础配置事务 | 输出 OFF 的 basic configure、单写、回读、失败恢复和 operation artifact 通过;不改变 V1 setter | | M5-C | 未开始 | 独立输出转换 | ON/OFF、最终 Vpp/Offset 检查、回读、失败 OFF 和 session health fixture 通过 | | M5-D | 未开始 | 公共入口与双合同路由 | Service/CLI 后,新增 run plan step、intent、artifact 和 V1 同义路径映射/零 I/O 拒绝通过 | | C2 | 未开始 | 核心兼容与候选发布门 | 新旧核心/插件矩阵、wheel/sdist、全量离线测试和 V1 artifact 兼容通过 | @@ -2811,7 +2811,8 @@ Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使 M5-A 只增加闭合的单通道 model,不提供自由 mapping 或通用 patch capability。`PatchValue` 只有 `KEEP` 与 `SET` 两种 action;`SET` 必须带有限值,`KEEP` 必须不带值。`SourceBasicPatch` 仅含 `waveform_kind`、`frequency_hz`、`amplitude_vpp`、`offset_v` 和 `square_duty_cycle_percent`,并且至少 -有一个字段为 `SET`。`SourceBasicConfigureRequest` 只接受 `PatchMode.PATCH`,不接受 replace-all。 +有一个字段为 `SET`。`ARBITRARY` 与 `OTHER` 不属于此 basic patch。`SourceBasicConfigureRequest` 只接受 +`PatchMode.PATCH`,不接受 replace-all。 `SourceBasicConfigureResult` 返回同一 channel 的 `BasicWaveFacet` 和 `output_enabled=False`。其中最终 幅度必须是有限、非负的 Vpp,最终 Offset 必须为有限值。`SourceOutputRequest` 是单通道的 @@ -2834,6 +2835,37 @@ Offset。方向、profile、channel 或 required method 不匹配时,在 facto M5-A 不增加 `SourceService` 写方法、CLI 写命令或 run plan step;现有 V1 setter、CLI、run plan、TUI 和 artifact 保持原样。capability 注册只让核心识别插件合同,不构成可调用写入口。 +### M5-B 基础配置事务 + +M5-B 只实现 `SourceService` 内部的 `_configure_basic_v2_transaction()`;它不是公开 Python API,也不新增 +CLI、TUI 或 run plan 入口。M5-D 以前,外部调用方不能借此绕过 Service/CLI 的统一接口设计。 + +事务仅处理一个 channel,按以下顺序执行: + +1. 在独占会话中读取 fresh、consistent 的 Source V2 snapshot,确认目标 channel 输出为 OFF,并确认 + runtime profile 仍支持 `BASIC/CONFIGURE`。 +2. 使用当前最终 Vpp、Offset 与 patch 中明确设置的值检查 `max_source_vpp`;只有两个绝对端口电压 + 限制均已配置时,才检查 `offset ± Vpp / 2`。不引入端接、RMS、Noise crest factor、ARB 过冲或共享 + 功率前置条件。 +3. 以单一 `configure_source_basic_v2(request)` 调用执行配置。每个 target field 在正常路径上至多发送 + 一次写入;值相同的请求允许 driver 返回零写入结果。 +4. 读取独立 postcondition snapshot,确认目标仍为 OFF、每个 `SET` 字段与回读相符,且最终 Vpp/Offset + 与 driver result 相符。 + +snapshot 的 transport 步数由该 descriptor 已声明的 `query_contract.max_queries` 限定,因此组合式与 +独立标量式 Source V2 driver 都能使用该事务;核心不会把它缩窄为某一种协议批处理方式。 + +当 MAIN 已开始而 result 或 postcondition 失败时,核心最多尝试一次 `source.output_v2` 的 OFF。只有 +插件同时声明该 capability、session 仍允许 recovery I/O 且 OFF 回读成功时,artifact 记录 +`off_verified`;basic 状态仍可能未知,因此 session 保持 `uncertain`,不会伪装为已恢复。未声明 +`source.output_v2` 时,核心不会调用 V1 `set_output` 作为旁路,连接转为 `poisoned`。任何 recovery +write、OFF readback 或同步失败也保持更保守的 session 状态。 + +成功与 MAIN 后失败的内部 artifact 均使用 `wavebench.source.operation.v1`,包含 capability 决定、 +typed request/result、preflight/postcondition snapshot 摘要、phase 摘要、最终 session 状态和脱敏 +evidence ref。它不包含 raw SCPI、资源地址、完整响应、授权 token 或 baseline nonce。M5-B 尚不将 +这些 artifact 写入 `run.json`;该连接由 M5-D 负责。 + ### R6 延后事项 RMS、统计 Noise、反应性/非线性负载、ARB 插值过冲、共享热功率、manifest 签名和信任根不阻塞 diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py index 2a44088..0742f04 100644 --- a/src/wavebench/instruments/source_extensions.py +++ b/src/wavebench/instruments/source_extensions.py @@ -2082,6 +2082,13 @@ def __post_init__(self) -> None: SourceWaveformKind, ): raise ValueError("source basic patch waveform_kind must be SourceWaveformKind") + if self.waveform_kind.action is PatchAction.SET and self.waveform_kind.value in { + SourceWaveformKind.ARBITRARY, + SourceWaveformKind.OTHER, + }: + raise ValueError( + "source basic patch waveform_kind cannot configure arbitrary or other waveforms" + ) for label, value, minimum, maximum in ( ("frequency_hz", self.frequency_hz, 0.0, None), ("amplitude_vpp", self.amplitude_vpp, 0.0, None), diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py index db80459..1606900 100644 --- a/src/wavebench/services/source_service.py +++ b/src/wavebench/services/source_service.py @@ -60,9 +60,34 @@ SourceStatus, ) from wavebench.instruments.source_extensions import ( + Availability, + BasicWaveFacet, + OutputFacet, + PatchAction, + SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_CONTRACT_VERSION, + SnapshotConsistencyState, SourceDescriptorExtensions, + SourceAmplitude, + SourceAmplitudeUnit, + SourceBasicCapabilityProfile, + SourceBasicConfigureRequest, + SourceBasicConfigureResult, + SourceBasicConfigureV2Driver, + SourceFacetScope, + SourceFieldId, + SourceFieldRef, + SourceFeature, + SourceFeatureDirection, + SourceOutputRequest, + SourceOutputResult, + SourceOutputV2Driver, + SourceScopeRef, SourceSnapshotV2, SourceSnapshotV2Driver, + SupportState, + source_v2_digest, + source_v2_to_data, ) from wavebench.logging import CommandLogger from wavebench.instruments.registry import resolve_instrument_descriptor @@ -81,6 +106,19 @@ build_source_snapshot_plan, new_source_snapshot_context, ) +from wavebench.services.source_operation_context import ( + SourceOperationContextCoordinator, + SourceOperationPhase, +) +from wavebench.transport.session import SessionHealth + + +@dataclass(frozen=True, slots=True) +class _SourceBasicConfigureV2Transaction: + """Internal M5-B result; public Source V2 write entry points come later.""" + + result: SourceBasicConfigureResult + artifact: dict[str, object] @dataclass @@ -199,45 +237,613 @@ def status(self, channel: int | None = None) -> SourceStatus: def snapshot_v2(self, *, correlation_id: str | None = None) -> SourceSnapshotV2: self._require("source.snapshot_v2", "source.snapshot_v2") with self._source_session() as source: - descriptor = self.descriptor - extensions = None if descriptor is None else descriptor.source_extensions - if not isinstance(extensions, SourceDescriptorExtensions): - raise SourceSnapshotContractError( - "source.snapshot_v2 requires validated source_extensions" - ) session_state = self.session_state if session_state is None: raise SourceSnapshotContractError( "source.snapshot_v2 requires a connection-bound session state" ) with session_state.transaction_lock: - if session_state.health.value != "healthy": - raise SourceSnapshotContractError( - "source.snapshot_v2 requires a healthy session" + return self._snapshot_v2_with_open_source( + source, + correlation_id=correlation_id, + ) + + def _snapshot_v2_with_open_source( + self, + source: SourceDriver, + *, + correlation_id: str | None, + allow_uncertain_session: bool = False, + deadline: float | None = None, + ) -> SourceSnapshotV2: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + if not isinstance(extensions, SourceDescriptorExtensions): + raise SourceSnapshotContractError( + "source.snapshot_v2 requires validated source_extensions" + ) + session_state = self.session_state + if session_state is None: + raise SourceSnapshotContractError( + "source.snapshot_v2 requires a connection-bound session state" + ) + accepted_health = {SessionHealth.HEALTHY} + if allow_uncertain_session: + accepted_health.add(SessionHealth.UNCERTAIN) + if session_state.health not in accepted_health: + raise SourceSnapshotContractError( + "source.snapshot_v2 requires a healthy session" + ) + timeout_ms = min( + SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS, + extensions.query_contract.timeout_ms, + self.config.connection.timeout_ms, + ) + if deadline is not None: + remaining_ms = int((deadline - time.monotonic()) * 1000.0) + if remaining_ms < 1: + raise SourceSnapshotContractError("source snapshot query deadline was exceeded") + timeout_ms = min(timeout_ms, remaining_ms) + context = new_source_snapshot_context( + session_epoch=session_state.epoch_id, + session_health_before=session_state.health.value, + descriptor_extensions=extensions, + timeout_ms=timeout_ms, + correlation_id=correlation_id, + ) + plan = build_source_snapshot_plan(context) + execution = cast( + SourceSnapshotV2Driver, + source, + ).execute_source_query_plan_v2(plan) + return build_source_snapshot( + context=context, + plan=plan, + execution=execution, + session_health_after=session_state.health.value, + allow_uncertain_session=allow_uncertain_session, + ) + + def _configure_basic_v2_transaction( + self, + request: SourceBasicConfigureRequest, + *, + correlation_id: str | None = None, + ) -> _SourceBasicConfigureV2Transaction: + """Execute the private M5-B basic-write transaction. + + This method deliberately remains private until M5-D owns the public + Service, CLI, run-plan and V1 dual-contract routes. It is the single + core path that M5-B tests use to prove the write/recovery contract. + """ + + if not isinstance(request, SourceBasicConfigureRequest): + raise ConfigError("source.basic_configure_v2 requires SourceBasicConfigureRequest") + self._require( + "source.basic_configure_v2", + "source.snapshot_v2", + "source.basic_configure_v2", + ) + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + session_state = self.session_state + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError( + "source.basic_configure_v2 requires validated source_extensions" + ) + if session_state is None: + raise ConfigError( + "source.basic_configure_v2 requires a connection-bound session state" + ) + fields = self._source_basic_v2_fields(request.channel) + output_field = next( + field for field in fields if field.field is SourceFieldId.OUTPUT + ) + context = SourceOperationContextCoordinator( + session_state=session_state, + operation_spec=require_operation_spec("source.basic_configure_v2"), + operation_contract=SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, + connection_timeout_ms=self.config.connection.timeout_ms, + baseline_snapshot_digest=None, + fields=fields, + required_off_outputs=( + SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel), + ), + emergency_off_outputs=( + SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel), + ), + restore_order=(), + non_restorable_fields=( + next(field for field in fields if field.field is SourceFieldId.BASIC), + output_field, + ), + correlation_id=correlation_id, + ) + preflight_snapshot: SourceSnapshotV2 | None = None + postcondition_snapshot: SourceSnapshotV2 | None = None + result: SourceBasicConfigureResult | None = None + main_entered = False + failure: BaseException | None = None + recovery: dict[str, object] | None = None + + try: + preflight = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(preflight) as authorization: + preflight_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + preflight_basic, preflight_output = self._source_basic_v2_target( + preflight_snapshot, + request.channel, + ) + self._validate_source_basic_v2_preflight( + request, + preflight_snapshot, + preflight_basic, + preflight_output, + ) + context.bind_baseline_snapshot_digest( + source_v2_digest((request.channel, preflight_basic, preflight_output)) ) - timeout_ms = min( - SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS, - extensions.query_contract.timeout_ms, - self.config.connection.timeout_ms, + context.complete_phase_verification( + authorization, + io_kind="query", + fields=fields, + ) + + main = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write"}, + fields=( + next(field for field in fields if field.field is SourceFieldId.BASIC), + ), + max_steps=SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT.main_max_steps, ) - context = new_source_snapshot_context( - session_epoch=session_state.epoch_id, - session_health_before=session_state.health.value, - descriptor_extensions=extensions, - timeout_ms=timeout_ms, - correlation_id=correlation_id, + try: + with context.authorize_phase(main): + main_entered = True + result = cast(SourceBasicConfigureV2Driver, source).configure_source_basic_v2( + request + ) + self._validate_source_basic_v2_result(request, result) + except BaseException as exc: + failure = exc + + if failure is None: + try: + postcondition = context.make_phase_spec( + SourceOperationPhase.POSTCONDITION, + allowed_io={"query"}, + fields=( + next( + field + for field in fields + if field.field is SourceFieldId.BASIC + ), + output_field, + ), + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(postcondition) as authorization: + postcondition_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + postcondition_basic, postcondition_output = ( + self._source_basic_v2_target( + postcondition_snapshot, + request.channel, + ) + ) + assert result is not None + self._validate_source_basic_v2_postcondition( + request, + result, + postcondition_snapshot, + postcondition_basic, + postcondition_output, + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=( + next( + field + for field in fields + if field.field is SourceFieldId.BASIC + ), + output_field, + ), + ) + except BaseException as exc: + failure = exc + + if failure is not None: + if main_entered: + try: + context.mark_failure_required() + recovery = self._recover_source_basic_v2_off( + context, + source, + request.channel, + extensions, + output_field, + ) + except BaseException: + recovery = { + "status": "recovery_setup_failed", + "session_health": session_state.health.value, + } + context.complete() + if main_entered: + self._attach_source_basic_v2_diagnostics( + failure, + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + recovery=recovery, + ) + raise failure + + context.complete() + assert result is not None + assert preflight_snapshot is not None + assert postcondition_snapshot is not None + return _SourceBasicConfigureV2Transaction( + result=result, + artifact=self._source_basic_v2_artifact( + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + ), ) - plan = build_source_snapshot_plan(context) - execution = cast( - SourceSnapshotV2Driver, + except BaseException: + if not context.terminal: + context.complete() + raise + + @staticmethod + def _source_basic_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]: + target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + fields = ( + SourceFieldRef(SourceFieldId.BASIC, target), + SourceFieldRef(SourceFieldId.OUTPUT, target), + SourceFieldRef( + SourceFieldId.IDENTITY, + SourceScopeRef(SourceFacetScope.INSTRUMENT), + ), + ) + return tuple( + sorted( + fields, + key=lambda field: ( + field.field.value, + field.target.scope.value, + -1 if field.target.channel is None else field.target.channel, + field.target.channels, + "" if field.target.input_id is None else field.target.input_id, + ), + ) + ) + + @staticmethod + def _source_basic_v2_target( + snapshot: SourceSnapshotV2, + channel: int, + ) -> tuple[BasicWaveFacet, OutputFacet]: + target = next((item for item in snapshot.channels if item.channel == channel), None) + if target is None: + raise ConfigError("source.basic_configure_v2 target channel is absent from snapshot") + if target.basic.availability is not Availability.VALUE or not isinstance( + target.basic.value, + BasicWaveFacet, + ): + raise ConfigError("source.basic_configure_v2 requires readable basic state") + if target.output.availability is not Availability.VALUE or not isinstance( + target.output.value, + OutputFacet, + ): + raise ConfigError("source.basic_configure_v2 requires readable output state") + return target.basic.value, target.output.value + + def _validate_source_basic_v2_preflight( + self, + request: SourceBasicConfigureRequest, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + output: OutputFacet, + ) -> None: + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError("source.basic_configure_v2 requires a fresh consistent snapshot") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError("source.basic_configure_v2 requires target output OFF") + if not any( + feature.feature is SourceFeature.BASIC + and feature.scope is SourceFacetScope.CHANNEL + and request.channel in feature.channels + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in feature.directions + for feature in snapshot.runtime_profile.features + ): + raise ConfigError( + "source.basic_configure_v2 is not available for the runtime target channel" + ) + runtime_basic = next( + feature + for feature in snapshot.runtime_profile.features + if feature.feature is SourceFeature.BASIC + and feature.scope is SourceFacetScope.CHANNEL + and request.channel in feature.channels + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in feature.directions + ) + assert isinstance(runtime_basic.profile, SourceBasicCapabilityProfile) + if ( + request.patch.waveform_kind.action is PatchAction.SET + and request.patch.waveform_kind.value not in runtime_basic.profile.waveform_kinds + ): + raise ConfigError( + "source.basic_configure_v2 waveform_kind is not supported by the runtime profile" + ) + current_vpp, current_offset = self._source_basic_v2_amplitude_offset(basic) + patch = request.patch + requested_vpp = ( + float(patch.amplitude_vpp.value) + if patch.amplitude_vpp.action is PatchAction.SET + else current_vpp + ) + requested_offset = ( + float(patch.offset_v.value) + if patch.offset_v.action is PatchAction.SET + else current_offset + ) + self._check_source_basic_v2_limits(requested_vpp, requested_offset) + + def _validate_source_basic_v2_result( + self, + request: SourceBasicConfigureRequest, + result: object, + ) -> None: + if not isinstance(result, SourceBasicConfigureResult): + raise ConfigError( + "configure_source_basic_v2() returned an invalid SourceBasicConfigureResult" + ) + if result.channel != request.channel: + raise ConfigError("source.basic_configure_v2 result channel does not match request") + if result.output_enabled: + raise ConfigError("source.basic_configure_v2 result reports output ON") + vpp, offset = self._source_basic_v2_amplitude_offset(result.basic) + self._check_source_basic_v2_limits(vpp, offset) + self._validate_source_basic_v2_patch_readback(request, result.basic) + + def _validate_source_basic_v2_postcondition( + self, + request: SourceBasicConfigureRequest, + result: SourceBasicConfigureResult, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + output: OutputFacet, + ) -> None: + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError("source.basic_configure_v2 postcondition snapshot is inconsistent") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError("source.basic_configure_v2 postcondition reports output ON") + self._validate_source_basic_v2_patch_readback(request, basic) + result_vpp, result_offset = self._source_basic_v2_amplitude_offset(result.basic) + post_vpp, post_offset = self._source_basic_v2_amplitude_offset(basic) + if (result_vpp, result_offset) != (post_vpp, post_offset): + raise ConfigError( + "source.basic_configure_v2 final amplitude or offset readback does not match" + ) + self._check_source_basic_v2_limits(post_vpp, post_offset) + + @staticmethod + def _source_basic_v2_amplitude_offset(basic: BasicWaveFacet) -> tuple[float, float]: + amplitude = basic.amplitude + if amplitude.availability is not Availability.VALUE or not isinstance( + amplitude.value, + SourceAmplitude, + ): + raise ConfigError("source.basic_configure_v2 requires a final Vpp amplitude") + if amplitude.value.unit is not SourceAmplitudeUnit.VPP: + raise ConfigError("source.basic_configure_v2 requires a final Vpp amplitude") + offset = basic.offset_v + if offset.availability is not Availability.VALUE: + raise ConfigError("source.basic_configure_v2 requires a final offset") + return float(amplitude.value.value), float(offset.value) + + @staticmethod + def _validate_source_basic_v2_patch_readback( + request: SourceBasicConfigureRequest, + basic: BasicWaveFacet, + ) -> None: + patch = request.patch + values = ( + ("waveform_kind", patch.waveform_kind, basic.waveform_kind), + ("frequency_hz", patch.frequency_hz, basic.frequency_hz), + ("offset_v", patch.offset_v, basic.offset_v), + ( + "square_duty_cycle_percent", + patch.square_duty_cycle_percent, + basic.square_duty_cycle_percent, + ), + ) + for name, patch_value, observed in values: + if patch_value.action is not PatchAction.SET: + continue + if observed.availability is not Availability.VALUE or observed.value != patch_value.value: + raise ConfigError( + f"source.basic_configure_v2 {name} readback does not match request" + ) + if patch.amplitude_vpp.action is PatchAction.SET: + actual_vpp, _ = SourceService._source_basic_v2_amplitude_offset(basic) + if actual_vpp != patch.amplitude_vpp.value: + raise ConfigError( + "source.basic_configure_v2 amplitude_vpp readback does not match request" + ) + + def _check_source_basic_v2_limits(self, vpp: float, offset: float) -> None: + self._check_source_vpp(vpp, field="source.basic_configure_v2 final amplitude") + limits = self.config.safety_limits + minimum = limits.min_source_port_voltage_v + maximum = limits.max_source_port_voltage_v + if minimum is None or maximum is None: + return + low = offset - (vpp / 2.0) + high = offset + (vpp / 2.0) + if low < minimum or high > maximum: + raise ConfigError( + "source.basic_configure_v2 final port voltage exceeds configured limits" + ) + + def _recover_source_basic_v2_off( + self, + context: SourceOperationContextCoordinator, + source: SourceDriver, + channel: int, + extensions: SourceDescriptorExtensions, + output_field: SourceFieldRef, + ) -> dict[str, object]: + descriptor = self.descriptor + session_state = self.session_state + if session_state is None or session_state.health is SessionHealth.POISONED: + return {"status": "not_attempted", "reason": "session_poisoned"} + if descriptor is None or "source.output_v2" not in descriptor.capabilities: + return {"status": "not_attempted", "reason": "output_capability_unavailable"} + if not callable(getattr(source, "set_source_output_v2", None)): + return {"status": "not_attempted", "reason": "output_method_unavailable"} + try: + safe_state = context.make_phase_spec( + SourceOperationPhase.FAILURE_SAFE_STATE, + allowed_io={"write"}, + fields=(output_field,), + max_steps=1, + ) + with context.authorize_phase(safe_state): + result = cast(SourceOutputV2Driver, source).set_source_output_v2( + SourceOutputRequest(channel=channel, enabled=False) + ) + if ( + not isinstance(result, SourceOutputResult) + or result.channel != channel + or result.enabled + ): + raise ConfigError("source.basic_configure_v2 recovery OFF is not proven") + except BaseException: + return { + "status": "off_failed", + "session_health": session_state.health.value, + } + if session_state.health is SessionHealth.POISONED: + return {"status": "off_sent_unverified", "reason": "session_poisoned"} + try: + verification = context.make_phase_spec( + SourceOperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=(output_field,), + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(verification) as authorization: + snapshot = self._snapshot_v2_with_open_source( source, - ).execute_source_query_plan_v2(plan) - return build_source_snapshot( - context=context, - plan=plan, - execution=execution, - session_health_after=session_state.health.value, + correlation_id=context.correlation_id, + allow_uncertain_session=True, + deadline=authorization.deadline, + ) + _, output = self._source_basic_v2_target(snapshot, channel) + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError("source.basic_configure_v2 recovery OFF readback is not proven") + context.mark_safe_state_verified( + authorization, + io_kind="query", + fields=(output_field,), + ) + except BaseException: + return { + "status": "off_sent_unverified", + "session_health": session_state.health.value, + } + return { + "status": "off_verified", + "session_health": session_state.health.value, + } + + def _source_basic_v2_artifact( + self, + *, + context: SourceOperationContextCoordinator, + request: SourceBasicConfigureRequest, + preflight_snapshot: SourceSnapshotV2 | None, + postcondition_snapshot: SourceSnapshotV2 | None, + result: SourceBasicConfigureResult | None, + recovery: dict[str, object] | None = None, + ) -> dict[str, object]: + artifact = context.artifact() + descriptor_digest = ( + None + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.descriptor_digest + ) + artifact["capability_decision"] = { + "capability": "source.basic_configure_v2", + "contract_version": SOURCE_CONTRACT_VERSION, + "descriptor_digest": descriptor_digest, + } + artifact["request"] = source_v2_to_data(request) + if preflight_snapshot is not None: + artifact["preflight"] = { + "target_channel": request.channel, + "snapshot_digest": source_v2_digest(preflight_snapshot), + "consistency": preflight_snapshot.consistency.state.value, + } + if result is not None: + artifact["mutation"] = {"result": source_v2_to_data(result)} + if postcondition_snapshot is not None: + artifact["postcondition"] = { + "snapshot_digest": source_v2_digest(postcondition_snapshot), + "consistency": postcondition_snapshot.consistency.state.value, + } + if recovery is not None: + artifact["recovery"] = dict(recovery) + artifact["final_state"] = { + "session_health": context.session_state.health.value, + "output_expected": "off", + } + artifact["evidence_refs"] = sorted( + { + evidence_ref + for feature in ( + () + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.features ) + for evidence_ref in feature.evidence_refs + } + ) + return artifact + + def _attach_source_basic_v2_diagnostics( + self, + exc: BaseException, + **kwargs: object, + ) -> None: + try: + setattr(exc, "source_operation_artifact", self._source_basic_v2_artifact(**kwargs)) + except Exception: + pass def channel_profile(self, channel: int | None = None) -> SourceChannelProfile: source_cfg = self._source_config() diff --git a/src/wavebench/services/source_snapshot_v2.py b/src/wavebench/services/source_snapshot_v2.py index 7748e85..9e62893 100644 --- a/src/wavebench/services/source_snapshot_v2.py +++ b/src/wavebench/services/source_snapshot_v2.py @@ -165,10 +165,12 @@ def build_source_snapshot( plan: SourceSemanticQueryPlan, execution: SourceQueryExecutionRecord, session_health_after: str, + allow_uncertain_session: bool = False, ) -> SourceSnapshotV2: if time.monotonic() > plan.deadline_monotonic: raise SourceSnapshotContractError("source snapshot query deadline was exceeded") - if session_health_after != "healthy": + accepted_health = {"healthy", "uncertain"} if allow_uncertain_session else {"healthy"} + if session_health_after not in accepted_health: raise SourceSnapshotContractError( "source snapshot session health changed before validation completed" ) diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py new file mode 100644 index 0000000..4c5c753 --- /dev/null +++ b/tests/test_source_basic_configure_v2.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + SafetyLimitsConfig, + ScopeConfig, + SourceConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.capabilities import validate_declared_capabilities +from wavebench.instruments.source_extension_capabilities import validate_source_descriptor +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + Observed, + SourceAmplitude, + SourceAmplitudeUnit, + SourceBasicConfigureRequest, + SourceBasicConfigureResult, + SourceBasicPatch, + SourceFeatureDirection, + SourceFieldId, + SourceOutputRequest, + SourceOutputResult, + SourceProtocolQueryRecord, + SourceQueryExecutionRecord, + SourceQueryItemOutcome, + SourceRuntimeIdentity, + SourceTypedObservation, + PatchAction, + PatchValue, +) +from wavebench.logging import CommandLogger +from wavebench.services.source_service import SourceService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + +from tests.source_v2_fixtures import ( + basic_facet, + output_facet, + source_descriptor, + source_extensions, +) + + +class _TextTransport: + resource = "fake-source-v2" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, direction: str, text: str) -> None: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del replay + self.queries.append(command) + return "ok" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + pass + + +class _BasicWriteDriver: + def __init__( + self, + *, + session_state: InstrumentSessionState, + combined: bool, + output_enabled: bool = False, + postcondition_frequency_hz: float | None = None, + raise_after_write: bool = False, + ) -> None: + self.transport = GuardedAuditedTransport( + _TextTransport(), + session_state=session_state, + ) + self.combined = combined + self.output_enabled = output_enabled + self.postcondition_frequency_hz = postcondition_frequency_hz + self.raise_after_write = raise_after_write + self.basic = basic_facet() + self.basic_requests: list[SourceBasicConfigureRequest] = [] + self.output_requests: list[SourceOutputRequest] = [] + self.v1_output_calls = 0 + self.closed = False + + def close(self) -> None: + self.closed = True + self.transport.close() + + def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord: + records = [] + for index, item in enumerate(plan.items): + if not self.combined or index == 0: + self.transport.query("SOURCE:STATE?") + observations = [] + for field in item.fields: + if field.field is SourceFieldId.IDENTITY: + value = SourceRuntimeIdentity( + manufacturer="Example", + model="EX1", + firmware_id="1.0", + ) + elif field.field is SourceFieldId.BASIC: + value = self._readback_basic() + elif field.field is SourceFieldId.OUTPUT: + value = output_facet(enabled=self.output_enabled) + else: # pragma: no cover - the fixture descriptor only needs these fields. + raise AssertionError(field) + observations.append(SourceTypedObservation(field, value)) + records.append( + SourceProtocolQueryRecord( + item_id=item.item_id, + effect=item.effect, + outcome=SourceQueryItemOutcome.OBSERVED, + query_count=(1 if not self.combined or index == 0 else 0), + observations=tuple(observations), + ) + ) + return SourceQueryExecutionRecord( + contract_version=SOURCE_CONTRACT_VERSION, + plan_id=plan.plan_id, + items=tuple(records), + query_count=(1 if self.combined else len(records)), + device_revision_token_before="revision-1", + device_revision_token_after="revision-1", + ) + + def configure_source_basic_v2( + self, + request: SourceBasicConfigureRequest, + ) -> SourceBasicConfigureResult: + self.transport.write("SOURCE:CONFIGURE") + self.basic_requests.append(request) + self.basic = self._apply_patch(request) + if self.raise_after_write: + raise ConfigError("fake basic configure failed after write") + return SourceBasicConfigureResult( + channel=request.channel, + basic=self.basic, + output_enabled=False, + ) + + def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult: + self.transport.write("SOURCE:OUTPUT OFF") + self.output_requests.append(request) + self.output_enabled = request.enabled + return SourceOutputResult(channel=request.channel, enabled=request.enabled) + + def set_output(self, *args, **kwargs): + del args, kwargs + self.v1_output_calls += 1 + raise AssertionError("M5-B recovery must not fall back to the V1 output route") + + def _readback_basic(self): + if self.postcondition_frequency_hz is None or not self.basic_requests: + return self.basic + return replace( + self.basic, + frequency_hz=Observed.value_of(self.postcondition_frequency_hz), + ) + + def _apply_patch(self, request: SourceBasicConfigureRequest): + patch = request.patch + updates = {} + if patch.waveform_kind.action is PatchAction.SET: + updates["waveform_kind"] = Observed.value_of(patch.waveform_kind.value) + if patch.frequency_hz.action is PatchAction.SET: + updates["frequency_hz"] = Observed.value_of(patch.frequency_hz.value) + if patch.amplitude_vpp.action is PatchAction.SET: + updates["amplitude"] = Observed.value_of( + SourceAmplitude(patch.amplitude_vpp.value, SourceAmplitudeUnit.VPP) + ) + if patch.offset_v.action is PatchAction.SET: + updates["offset_v"] = Observed.value_of(patch.offset_v.value) + if patch.square_duty_cycle_percent.action is PatchAction.SET: + updates["square_duty_cycle_percent"] = Observed.value_of( + patch.square_duty_cycle_percent.value + ) + return replace(self.basic, **updates) + + +def _config(*, limits: SafetyLimitsConfig = SafetyLimitsConfig()) -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "DMAX"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("wavebench.toml"), + source=SourceConfig( + "example.source-v2", + "TCPIP::source::INSTR", + 1, + True, + True, + 0, + ), + safety_limits=limits, + ) + + +def _write_extensions(*, include_output: bool): + extensions = source_extensions() + basic, output = extensions.features + return replace( + extensions, + features=( + replace( + basic, + directions=( + SourceFeatureDirection.CONFIGURE, + SourceFeatureDirection.READ, + ), + ), + replace( + output, + directions=( + ( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ) + if include_output + else (SourceFeatureDirection.READ,) + ), + ), + ), + ) + + +def _service( + *, + combined: bool = True, + include_output: bool = True, + output_enabled: bool = False, + postcondition_frequency_hz: float | None = None, + raise_after_write: bool = False, + limits: SafetyLimitsConfig = SafetyLimitsConfig(), +) -> tuple[SourceService, _BasicWriteDriver]: + session_state = InstrumentSessionState(epoch_id="source-basic-v2") + driver = _BasicWriteDriver( + session_state=session_state, + combined=combined, + output_enabled=output_enabled, + postcondition_frequency_hz=postcondition_frequency_hz, + raise_after_write=raise_after_write, + ) + extensions = _write_extensions(include_output=include_output) + capabilities = ["source.snapshot_v2", "source.basic_configure_v2"] + if include_output: + capabilities.append("source.output_v2") + descriptor = replace( + source_descriptor(driver=driver, extensions=extensions), + capabilities=tuple(capabilities), + ) + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, driver) + return ( + SourceService( + config=_config(limits=limits), + logger=CommandLogger(), + session=driver, # type: ignore[arg-type] + descriptor=descriptor, + transport=driver.transport, + session_state=session_state, + ), + driver, + ) + + +def _frequency_request(value_hz: float = 2_000.0) -> SourceBasicConfigureRequest: + return SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch( + frequency_hz=PatchValue(PatchAction.SET, value_hz), + ), + ) + + +@pytest.mark.parametrize("combined", (True, False)) +def test_basic_configure_v2_private_transaction_supports_combined_and_scalar_queries( + combined: bool, +) -> None: + service, driver = _service(combined=combined) + request = _frequency_request() + + transaction = service._configure_basic_v2_transaction(request, correlation_id="basic-write") + + assert not hasattr(service, "configure_basic_v2") + assert transaction.result.basic.frequency_hz.value == 2_000.0 + assert driver.basic_requests == [request] + assert driver.output_requests == [] + assert driver.transport.counters.write_completed == 1 + assert transaction.artifact["operation"] == "source.basic_configure_v2" + assert transaction.artifact["request"]["channel"] == 1 + assert transaction.artifact["final_state"] == { + "session_health": "healthy", + "output_expected": "off", + } + assert [item["phase"] for item in transaction.artifact["phases"]] == [ + "preflight", + "main", + "postcondition", + ] + assert "fake-source-v2" not in repr(transaction.artifact) + assert "SOURCE:STATE?" not in repr(transaction.artifact) + + +def test_basic_configure_v2_rejects_target_output_on_before_write() -> None: + service, driver = _service(output_enabled=True) + + with pytest.raises(ConfigError, match="target output OFF"): + service._configure_basic_v2_transaction(_frequency_request()) + + assert driver.basic_requests == [] + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 0 + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_basic_configure_v2_rejects_configured_limits_before_write() -> None: + service, driver = _service(limits=SafetyLimitsConfig(max_source_vpp=2.0)) + request = SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch( + amplitude_vpp=PatchValue(PatchAction.SET, 2.5), + ), + ) + + with pytest.raises(ConfigError, match="max_source_vpp"): + service._configure_basic_v2_transaction(request) + + assert driver.basic_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_basic_configure_v2_rejects_configured_absolute_port_limits_before_write() -> None: + service, driver = _service( + limits=SafetyLimitsConfig( + min_source_port_voltage_v=-1.0, + max_source_port_voltage_v=1.0, + ) + ) + request = SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch(offset_v=PatchValue(PatchAction.SET, 1.0)), + ) + + with pytest.raises(ConfigError, match="port voltage"): + service._configure_basic_v2_transaction(request) + + assert driver.basic_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_basic_configure_v2_postcondition_mismatch_runs_one_off_recovery() -> None: + service, driver = _service(postcondition_frequency_hz=2_001.0) + + with pytest.raises(ConfigError, match="frequency_hz readback") as raised: + service._configure_basic_v2_transaction(_frequency_request()) + + artifact = raised.value.source_operation_artifact + assert driver.basic_requests == [_frequency_request()] + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)] + assert driver.transport.counters.write_completed == 2 + assert artifact["recovery"] == { + "status": "off_verified", + "session_health": "uncertain", + } + assert artifact["safe_state_verified"] is True + assert artifact["final_state"]["session_health"] == "uncertain" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_basic_configure_v2_never_falls_back_to_v1_output_for_recovery() -> None: + service, driver = _service(include_output=False, raise_after_write=True) + + with pytest.raises(ConfigError, match="failed after write") as raised: + service._configure_basic_v2_transaction(_frequency_request()) + + artifact = raised.value.source_operation_artifact + assert driver.basic_requests == [_frequency_request()] + assert driver.output_requests == [] + assert driver.v1_output_calls == 0 + assert artifact["recovery"] == { + "status": "not_attempted", + "reason": "output_capability_unavailable", + } + assert service.session_state is not None + assert service.session_state.health is SessionHealth.POISONED diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py index ee94161..6d3ec99 100644 --- a/tests/test_source_extensions.py +++ b/tests/test_source_extensions.py @@ -432,6 +432,13 @@ def test_source_v2_basic_write_models_are_closed_and_serializable() -> None: module.PatchValue(module.PatchAction.KEEP, 1.0) with pytest.raises(ValueError, match="at least one SET"): module.SourceBasicPatch() + with pytest.raises(ValueError, match="arbitrary or other"): + module.SourceBasicPatch( + waveform_kind=module.PatchValue( + module.PatchAction.SET, + module.SourceWaveformKind.ARBITRARY, + ) + ) with pytest.raises(ValueError, match="must be >= 0.0"): module.SourceBasicPatch(amplitude_vpp=module.PatchValue(module.PatchAction.SET, -0.1)) with pytest.raises(ValueError, match="only supports PATCH"): From b9f5614c1da838aafbd5da7f3c3da0d764d878be Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:54:30 +0800 Subject: [PATCH 14/82] fix: keep source v2 output disable independent of vpp --- .../source_extension_capabilities.py | 4 --- tests/test_source_extensions.py | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py index 5f5dccb..cea3a26 100644 --- a/src/wavebench/instruments/source_extension_capabilities.py +++ b/src/wavebench/instruments/source_extension_capabilities.py @@ -318,10 +318,6 @@ def _validate_write_contract( raise ConfigError( "source.output_v2 requires readable output state on every channel" ) - if not enabled <= basic_readable: - raise ConfigError( - "source.output_v2 requires readable final VPP and Offset on every channel" - ) def _channels_with_direction( diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py index 6d3ec99..9a83250 100644 --- a/tests/test_source_extensions.py +++ b/tests/test_source_extensions.py @@ -580,6 +580,38 @@ def set_source_output_v2(self, request): ) +def test_source_output_v2_descriptor_keeps_off_available_without_final_vpp_readback() -> None: + extensions = source_extensions() + basic, output = extensions.features + output_only_extensions = replace( + extensions, + features=( + replace( + basic, + profile=replace( + basic.profile, + amplitude_units=(module.SourceAmplitudeUnit.VRMS,), + offset_readable=False, + ), + ), + replace( + output, + directions=( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), + ), + ), + ) + descriptor = replace( + source_descriptor(extensions=output_only_extensions), + capabilities=("source.snapshot_v2", "source.output_v2"), + ) + + validate_source_descriptor(descriptor) + + def test_source_v2_rejects_invalid_feature_scope_and_query_field_ownership() -> None: extensions = source_extensions() basic = extensions.features[0] From a646c3618d69eb42ff38c47a914a8d4c63f452c7 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:54 +0800 Subject: [PATCH 15/82] feat: add source v2 output transactions --- ...345\207\272\345\256\211\345\205\250RFC.md" | 36 +- src/wavebench/services/source_service.py | 601 +++++++++++++++++- tests/test_source_output_v2.py | 376 +++++++++++ 3 files changed, 982 insertions(+), 31 deletions(-) create mode 100644 tests/test_source_output_v2.py diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" index 9d28525..cf6d82d 100644 --- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" +++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" @@ -4,9 +4,9 @@ > 修订:`R6` > 核心基线:WaveBench `0.8.23`,`master@6cd2eb5` > 首个支持版本:WaveBench `0.8.24` -> 实施状态:P0、M1–M4、M4.5、C1、M5-A 与 M5-B 已进入核心 `0.8.24` 开发线;R6 已接受。 +> 实施状态:P0、M1–M4、M4.5、C1、M5-A、M5-B 与 M5-C 已进入核心 `0.8.24` 开发线;R6 已接受。 > 当前注册 `source.snapshot_v2`、`source.basic_configure_v2` 和 `source.output_v2`;M5-A 只冻结 -> 公共合同与 descriptor 校验,M5-B 只提供内部基础配置事务,尚未开放 Source V2 写入口。 +> 公共合同与 descriptor 校验,M5-B/M5-C 只提供内部写事务,尚未开放 Source V2 写入口。 > [!IMPORTANT] > `Accepted R5` 在 R4 的 operation context、受影响字段闭包、phase、nonce、cleanup reserve @@ -2691,7 +2691,7 @@ R2 的本段只约束 R2–R5 的 snapshot-only 阶段。R6 已为后续基础 | R6 | `Accepted` | 基础写入方向 | 冻结基本安全、核心接口、V1 兼容和下列实施顺序 | | M5-A | `implemented-unreleased` | 公共类型与静态验证 | `basic_configure_v2`/`output_v2` 的 request、result、Protocol、descriptor validation 和 A0 构造测试通过;不开放写入口 | | M5-B | `implemented-unreleased` | 基础配置事务 | 输出 OFF 的 basic configure、单写、回读、失败恢复和 operation artifact 通过;不改变 V1 setter | -| M5-C | 未开始 | 独立输出转换 | ON/OFF、最终 Vpp/Offset 检查、回读、失败 OFF 和 session health fixture 通过 | +| M5-C | `implemented-unreleased` | 独立输出转换 | ON/OFF、最终 Vpp/Offset 检查、回读、失败 OFF 和 session health fixture 通过 | | M5-D | 未开始 | 公共入口与双合同路由 | Service/CLI 后,新增 run plan step、intent、artifact 和 V1 同义路径映射/零 I/O 拒绝通过 | | C2 | 未开始 | 核心兼容与候选发布门 | 新旧核心/插件矩阵、wheel/sdist、全量离线测试和 V1 artifact 兼容通过 | | M6-A | 未开始 | 单通道高级配置 | Harmonic、Modulation、Pulse、Sweep、Burst 按 feature 独立 opt in,复用基本写入门 | @@ -2829,8 +2829,9 @@ M5-A 只增加闭合的单通道 model,不提供自由 mapping 或通用 patch 声明任一 M5-A 写 capability 的 descriptor 必须同时声明 `source.snapshot_v2`。基础配置要求同一 channel 的 Basic 支持 `READ` 与 `CONFIGURE`,并能回读最终 Vpp、Offset 和输出状态;输出 capability -要求同一 channel 的 Output 支持 `READ`、`ENABLE` 与 `DISABLE`,并能回读输出状态、最终 Vpp 和 -Offset。方向、profile、channel 或 required method 不匹配时,在 factory 及仪器 I/O 前失败。 +要求同一 channel 的 Output 支持 `READ`、`ENABLE` 与 `DISABLE`,并能回读输出状态。启用动作在运行时 +另行要求同一 channel 可返回最终 Vpp 与 Offset;关闭动作不以它们为条件。方向、profile、channel 或 +required method 不匹配时,在 factory 及仪器 I/O 前失败。 M5-A 不增加 `SourceService` 写方法、CLI 写命令或 run plan step;现有 V1 setter、CLI、run plan、TUI 和 artifact 保持原样。capability 注册只让核心识别插件合同,不构成可调用写入口。 @@ -2866,6 +2867,31 @@ typed request/result、preflight/postcondition snapshot 摘要、phase 摘要 evidence ref。它不包含 raw SCPI、资源地址、完整响应、授权 token 或 baseline nonce。M5-B 尚不将 这些 artifact 写入 `run.json`;该连接由 M5-D 负责。 +### M5-C 单端口输出事务 + +M5-C 只实现 `SourceService` 内部的 `_set_output_v2_transaction()`;它仍不是公开 Python API,也不新增 +CLI、TUI 或 run plan 写入口。请求为 `SourceOutputRequest(channel, enabled)`,核心根据 `enabled` 选择 +`source.output_enable_v2` 或 `source.output_disable_v2` 的独立 operation contract。 + +启用时,核心读取目标 channel 的 fresh、consistent snapshot,确认 runtime profile 支持 `OUTPUT/ENABLE`, +并以最终 Vpp、Offset 检查 `max_source_vpp` 和已完整配置的绝对端口电压区间。它不要求端接证据、 +`CompositeOutputBudget`、RMS、Noise crest factor、ARB 过冲、共享功率或热模型。没有已报告为 enabled 的 +跨通道关系涉及目标端口时,其他独立端口可以保持 ON;已报告的活动关系由 M6-C 处理,不在单端口 +enable 路径中猜测其副作用。 + +关闭时,核心只要求该 target 的输出状态可读及 runtime profile 支持 `OUTPUT/DISABLE`。它不以最终 +Vpp、Offset、端接、预算或绝对端口电压为前提。目标已经处于请求状态时,事务只保留 fresh readback, +不发送多余写入。 + +需要转换状态时,MAIN 至多调用一次 `set_source_output_v2(request)`,随后通过独立 snapshot 回读目标 +状态。启用还要求最终 Vpp/Offset 与 driver result 一致;关闭只要求回读为 OFF。启用的 result 或 +postcondition 失败时,核心最多发送一次 recovery OFF,并在 OFF 回读后保留 `uncertain` session。关闭 +的 result 未知时不得重试同一 OFF;连接转为更保守状态。两个路径都不回退到 V1 `set_output`。 + +M5-C artifact 与 M5-B 使用相同 schema,记录 enable/disable 意图、是否实际写入、typed result、 +snapshot 摘要、phase 摘要、recovery 结果和脱敏 evidence ref。该 artifact 仍只在内部事务中存在, +M5-D 才将其连接到公开入口与 `run.json`。 + ### R6 延后事项 RMS、统计 Noise、反应性/非线性负载、ARB 插值过冲、共享热功率、manifest 签名和信任根不阻塞 diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py index 1606900..d9bc146 100644 --- a/src/wavebench/services/source_service.py +++ b/src/wavebench/services/source_service.py @@ -66,6 +66,8 @@ PatchAction, SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_CONTRACT_VERSION, + SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT, + SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT, SnapshotConsistencyState, SourceDescriptorExtensions, SourceAmplitude, @@ -121,6 +123,14 @@ class _SourceBasicConfigureV2Transaction: artifact: dict[str, object] +@dataclass(frozen=True, slots=True) +class _SourceOutputV2Transaction: + """Internal M5-C result; public Source V2 write entry points come later.""" + + result: SourceOutputResult + artifact: dict[str, object] + + @dataclass class SourceService(SessionStateAliasMixin): config: WaveBenchConfig @@ -380,9 +390,10 @@ def _configure_basic_v2_transaction( correlation_id=context.correlation_id, deadline=authorization.deadline, ) - preflight_basic, preflight_output = self._source_basic_v2_target( + preflight_basic, preflight_output = self._source_v2_target( preflight_snapshot, request.channel, + operation="source.basic_configure_v2", ) self._validate_source_basic_v2_preflight( request, @@ -439,9 +450,10 @@ def _configure_basic_v2_transaction( deadline=authorization.deadline, ) postcondition_basic, postcondition_output = ( - self._source_basic_v2_target( + self._source_v2_target( postcondition_snapshot, request.channel, + operation="source.basic_configure_v2", ) ) assert result is not None @@ -471,12 +483,13 @@ def _configure_basic_v2_transaction( if main_entered: try: context.mark_failure_required() - recovery = self._recover_source_basic_v2_off( + recovery = self._recover_source_v2_output_off( context, source, request.channel, extensions, output_field, + operation="source.basic_configure_v2", ) except BaseException: recovery = { @@ -515,6 +528,245 @@ def _configure_basic_v2_transaction( context.complete() raise + def _set_output_v2_transaction( + self, + request: SourceOutputRequest, + *, + correlation_id: str | None = None, + ) -> _SourceOutputV2Transaction: + """Execute the private M5-C single-port output transaction.""" + + if not isinstance(request, SourceOutputRequest): + raise ConfigError("source.output_v2 requires SourceOutputRequest") + operation = ( + "source.output_enable_v2" if request.enabled else "source.output_disable_v2" + ) + contract = ( + SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT + if request.enabled + else SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT + ) + self._require(operation, "source.snapshot_v2", "source.output_v2") + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + session_state = self.session_state + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated source_extensions") + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + fields = self._source_output_v2_fields( + request.channel, + include_basic=request.enabled, + ) + output_field = next( + field for field in fields if field.field is SourceFieldId.OUTPUT + ) + basic_field = next( + (field for field in fields if field.field is SourceFieldId.BASIC), + None, + ) + target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel) + context = SourceOperationContextCoordinator( + session_state=session_state, + operation_spec=require_operation_spec(operation), + operation_contract=contract, + connection_timeout_ms=self.config.connection.timeout_ms, + baseline_snapshot_digest=None, + fields=fields, + required_off_outputs=((target_scope,) if request.enabled else ()), + emergency_off_outputs=((target_scope,) if request.enabled else ()), + restore_order=(), + non_restorable_fields=tuple( + field for field in fields if field.field is not SourceFieldId.IDENTITY + ), + correlation_id=correlation_id, + ) + preflight_snapshot: SourceSnapshotV2 | None = None + postcondition_snapshot: SourceSnapshotV2 | None = None + result: SourceOutputResult | None = None + main_entered = False + wrote_main = False + failure: BaseException | None = None + recovery: dict[str, object] | None = None + + try: + preflight = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(preflight) as authorization: + preflight_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + if request.enabled: + basic, output = self._source_v2_target( + preflight_snapshot, + request.channel, + operation=operation, + ) + wrote_main = self._validate_source_output_enable_preflight( + request, + preflight_snapshot, + basic, + output, + ) + baseline_payload = (request.channel, basic, output) + else: + output = self._source_v2_output_target( + preflight_snapshot, + request.channel, + operation=operation, + ) + wrote_main = self._validate_source_output_disable_preflight( + request, + preflight_snapshot, + output, + ) + baseline_payload = (request.channel, output) + context.bind_baseline_snapshot_digest(source_v2_digest(baseline_payload)) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=fields, + ) + + if wrote_main: + main = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write"}, + fields=(output_field,), + max_steps=contract.main_max_steps, + ) + try: + with context.authorize_phase(main): + main_entered = True + result = cast(SourceOutputV2Driver, source).set_source_output_v2( + request + ) + self._validate_source_output_v2_result( + request, + result, + operation=operation, + ) + except BaseException as exc: + failure = exc + else: + result = self._source_output_v2_noop_result( + request, + preflight_snapshot, + operation=operation, + ) + + if failure is None and wrote_main: + try: + postcondition_fields = ( + (basic_field, output_field) + if request.enabled + else (output_field,) + ) + assert all(field is not None for field in postcondition_fields) + postcondition = context.make_phase_spec( + SourceOperationPhase.POSTCONDITION, + allowed_io={"query"}, + fields=postcondition_fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(postcondition) as authorization: + postcondition_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + assert result is not None + if request.enabled: + basic, output = self._source_v2_target( + postcondition_snapshot, + request.channel, + operation=operation, + ) + self._validate_source_output_enable_postcondition( + result, + postcondition_snapshot, + basic, + output, + ) + else: + output = self._source_v2_output_target( + postcondition_snapshot, + request.channel, + operation=operation, + ) + self._validate_source_output_disable_postcondition(output) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=postcondition_fields, + ) + except BaseException as exc: + failure = exc + + if failure is not None: + if main_entered: + try: + context.mark_failure_required() + recovery = ( + self._recover_source_v2_output_off( + context, + source, + request.channel, + extensions, + output_field, + operation=operation, + ) + if request.enabled + else { + "status": "not_attempted", + "reason": "off_result_unknown_not_retried", + } + ) + except BaseException: + recovery = { + "status": "recovery_setup_failed", + "session_health": session_state.health.value, + } + context.complete() + if main_entered: + self._attach_source_output_v2_diagnostics( + failure, + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + wrote_main=wrote_main, + recovery=recovery, + ) + raise failure + + context.complete() + assert result is not None + assert preflight_snapshot is not None + return _SourceOutputV2Transaction( + result=result, + artifact=self._source_output_v2_artifact( + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + wrote_main=wrote_main, + ), + ) + except BaseException: + if not context.terminal: + context.complete() + raise + @staticmethod def _source_basic_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]: target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) @@ -539,26 +791,61 @@ def _source_basic_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]: ) ) + @classmethod + def _source_output_v2_fields( + cls, + channel: int, + *, + include_basic: bool, + ) -> tuple[SourceFieldRef, ...]: + if include_basic: + return cls._source_basic_v2_fields(channel) + return ( + SourceFieldRef( + SourceFieldId.OUTPUT, + SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel), + ), + ) + @staticmethod - def _source_basic_v2_target( + def _source_v2_target( snapshot: SourceSnapshotV2, channel: int, + *, + operation: str, ) -> tuple[BasicWaveFacet, OutputFacet]: target = next((item for item in snapshot.channels if item.channel == channel), None) if target is None: - raise ConfigError("source.basic_configure_v2 target channel is absent from snapshot") + raise ConfigError(f"{operation} target channel is absent from snapshot") if target.basic.availability is not Availability.VALUE or not isinstance( target.basic.value, BasicWaveFacet, ): - raise ConfigError("source.basic_configure_v2 requires readable basic state") + raise ConfigError(f"{operation} requires readable basic state") if target.output.availability is not Availability.VALUE or not isinstance( target.output.value, OutputFacet, ): - raise ConfigError("source.basic_configure_v2 requires readable output state") + raise ConfigError(f"{operation} requires readable output state") return target.basic.value, target.output.value + @staticmethod + def _source_v2_output_target( + snapshot: SourceSnapshotV2, + channel: int, + *, + operation: str, + ) -> OutputFacet: + target = next((item for item in snapshot.channels if item.channel == channel), None) + if target is None: + raise ConfigError(f"{operation} target channel is absent from snapshot") + if target.output.availability is not Availability.VALUE or not isinstance( + target.output.value, + OutputFacet, + ): + raise ConfigError(f"{operation} requires readable output state") + return target.output.value + def _validate_source_basic_v2_preflight( self, request: SourceBasicConfigureRequest, @@ -598,7 +885,10 @@ def _validate_source_basic_v2_preflight( raise ConfigError( "source.basic_configure_v2 waveform_kind is not supported by the runtime profile" ) - current_vpp, current_offset = self._source_basic_v2_amplitude_offset(basic) + current_vpp, current_offset = self._source_v2_amplitude_offset( + basic, + operation="source.basic_configure_v2", + ) patch = request.patch requested_vpp = ( float(patch.amplitude_vpp.value) @@ -610,7 +900,11 @@ def _validate_source_basic_v2_preflight( if patch.offset_v.action is PatchAction.SET else current_offset ) - self._check_source_basic_v2_limits(requested_vpp, requested_offset) + self._check_source_v2_final_output_limits( + requested_vpp, + requested_offset, + operation="source.basic_configure_v2", + ) def _validate_source_basic_v2_result( self, @@ -625,8 +919,15 @@ def _validate_source_basic_v2_result( raise ConfigError("source.basic_configure_v2 result channel does not match request") if result.output_enabled: raise ConfigError("source.basic_configure_v2 result reports output ON") - vpp, offset = self._source_basic_v2_amplitude_offset(result.basic) - self._check_source_basic_v2_limits(vpp, offset) + vpp, offset = self._source_v2_amplitude_offset( + result.basic, + operation="source.basic_configure_v2", + ) + self._check_source_v2_final_output_limits( + vpp, + offset, + operation="source.basic_configure_v2", + ) self._validate_source_basic_v2_patch_readback(request, result.basic) def _validate_source_basic_v2_postcondition( @@ -642,27 +943,41 @@ def _validate_source_basic_v2_postcondition( if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: raise ConfigError("source.basic_configure_v2 postcondition reports output ON") self._validate_source_basic_v2_patch_readback(request, basic) - result_vpp, result_offset = self._source_basic_v2_amplitude_offset(result.basic) - post_vpp, post_offset = self._source_basic_v2_amplitude_offset(basic) + result_vpp, result_offset = self._source_v2_amplitude_offset( + result.basic, + operation="source.basic_configure_v2", + ) + post_vpp, post_offset = self._source_v2_amplitude_offset( + basic, + operation="source.basic_configure_v2", + ) if (result_vpp, result_offset) != (post_vpp, post_offset): raise ConfigError( "source.basic_configure_v2 final amplitude or offset readback does not match" ) - self._check_source_basic_v2_limits(post_vpp, post_offset) + self._check_source_v2_final_output_limits( + post_vpp, + post_offset, + operation="source.basic_configure_v2", + ) @staticmethod - def _source_basic_v2_amplitude_offset(basic: BasicWaveFacet) -> tuple[float, float]: + def _source_v2_amplitude_offset( + basic: BasicWaveFacet, + *, + operation: str, + ) -> tuple[float, float]: amplitude = basic.amplitude if amplitude.availability is not Availability.VALUE or not isinstance( amplitude.value, SourceAmplitude, ): - raise ConfigError("source.basic_configure_v2 requires a final Vpp amplitude") + raise ConfigError(f"{operation} requires a final Vpp amplitude") if amplitude.value.unit is not SourceAmplitudeUnit.VPP: - raise ConfigError("source.basic_configure_v2 requires a final Vpp amplitude") + raise ConfigError(f"{operation} requires a final Vpp amplitude") offset = basic.offset_v if offset.availability is not Availability.VALUE: - raise ConfigError("source.basic_configure_v2 requires a final offset") + raise ConfigError(f"{operation} requires a final offset") return float(amplitude.value.value), float(offset.value) @staticmethod @@ -689,14 +1004,23 @@ def _validate_source_basic_v2_patch_readback( f"source.basic_configure_v2 {name} readback does not match request" ) if patch.amplitude_vpp.action is PatchAction.SET: - actual_vpp, _ = SourceService._source_basic_v2_amplitude_offset(basic) + actual_vpp, _ = SourceService._source_v2_amplitude_offset( + basic, + operation="source.basic_configure_v2", + ) if actual_vpp != patch.amplitude_vpp.value: raise ConfigError( "source.basic_configure_v2 amplitude_vpp readback does not match request" ) - def _check_source_basic_v2_limits(self, vpp: float, offset: float) -> None: - self._check_source_vpp(vpp, field="source.basic_configure_v2 final amplitude") + def _check_source_v2_final_output_limits( + self, + vpp: float, + offset: float, + *, + operation: str, + ) -> None: + self._check_source_vpp(vpp, field=f"{operation} final amplitude") limits = self.config.safety_limits minimum = limits.min_source_port_voltage_v maximum = limits.max_source_port_voltage_v @@ -706,16 +1030,169 @@ def _check_source_basic_v2_limits(self, vpp: float, offset: float) -> None: high = offset + (vpp / 2.0) if low < minimum or high > maximum: raise ConfigError( - "source.basic_configure_v2 final port voltage exceeds configured limits" + f"{operation} final port voltage exceeds configured limits" ) - def _recover_source_basic_v2_off( + @staticmethod + def _validate_source_output_v2_runtime_direction( + snapshot: SourceSnapshotV2, + *, + channel: int, + direction: SourceFeatureDirection, + operation: str, + ) -> None: + if not any( + feature.feature is SourceFeature.OUTPUT + and feature.scope is SourceFacetScope.CHANNEL + and channel in feature.channels + and feature.support is SupportState.SUPPORTED + and direction in feature.directions + for feature in snapshot.runtime_profile.features + ): + raise ConfigError(f"{operation} is not available for the runtime target channel") + + def _validate_source_output_enable_preflight( + self, + request: SourceOutputRequest, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + output: OutputFacet, + ) -> bool: + operation = "source.output_enable_v2" + self._validate_source_output_v2_runtime_direction( + snapshot, + channel=request.channel, + direction=SourceFeatureDirection.ENABLE, + operation=operation, + ) + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if output.enabled.availability is not Availability.VALUE: + raise ConfigError(f"{operation} requires readable output state") + vpp, offset = self._source_v2_amplitude_offset(basic, operation=operation) + self._check_source_v2_final_output_limits(vpp, offset, operation=operation) + if output.enabled.value is True: + return False + self._reject_active_cross_channel_relations(snapshot, request.channel, operation=operation) + return True + + @staticmethod + def _validate_source_output_disable_preflight( + request: SourceOutputRequest, + snapshot: SourceSnapshotV2, + output: OutputFacet, + ) -> bool: + operation = "source.output_disable_v2" + SourceService._validate_source_output_v2_runtime_direction( + snapshot, + channel=request.channel, + direction=SourceFeatureDirection.DISABLE, + operation=operation, + ) + if output.enabled.availability is not Availability.VALUE: + raise ConfigError(f"{operation} requires readable output state") + return output.enabled.value is not False + + @staticmethod + def _reject_active_cross_channel_relations( + snapshot: SourceSnapshotV2, + channel: int, + *, + operation: str, + ) -> None: + cross_channel = snapshot.cross_channel + if cross_channel.availability is not Availability.VALUE: + return + for relation in getattr(cross_channel.value, "relations", ()): + enabled = getattr(relation, "enabled", None) + if ( + channel in getattr(relation, "channels", ()) + and getattr(enabled, "availability", None) is Availability.VALUE + and getattr(enabled, "value", None) is True + ): + raise ConfigError( + f"{operation} requires M6-C handling for an active cross-channel relation" + ) + + def _validate_source_output_v2_result( + self, + request: SourceOutputRequest, + result: object, + *, + operation: str, + ) -> None: + if not isinstance(result, SourceOutputResult): + raise ConfigError(f"set_source_output_v2() returned an invalid result for {operation}") + if result.channel != request.channel or result.enabled is not request.enabled: + raise ConfigError(f"{operation} result does not match the requested output state") + if not request.enabled: + return + if result.final_amplitude is None or result.final_offset_v is None: + raise ConfigError(f"{operation} requires final Vpp and Offset readback") + self._check_source_v2_final_output_limits( + result.final_amplitude.value, + result.final_offset_v, + operation=operation, + ) + + def _source_output_v2_noop_result( + self, + request: SourceOutputRequest, + snapshot: SourceSnapshotV2, + *, + operation: str, + ) -> SourceOutputResult: + if not request.enabled: + return SourceOutputResult(channel=request.channel, enabled=False) + basic, _ = self._source_v2_target( + snapshot, + request.channel, + operation=operation, + ) + vpp, offset = self._source_v2_amplitude_offset(basic, operation=operation) + return SourceOutputResult( + channel=request.channel, + enabled=True, + final_amplitude=SourceAmplitude(vpp, SourceAmplitudeUnit.VPP), + final_offset_v=offset, + ) + + def _validate_source_output_enable_postcondition( + self, + result: SourceOutputResult, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + output: OutputFacet, + ) -> None: + operation = "source.output_enable_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} postcondition snapshot is inconsistent") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not True: + raise ConfigError(f"{operation} postcondition reports output OFF") + self._reject_active_cross_channel_relations(snapshot, result.channel, operation=operation) + vpp, offset = self._source_v2_amplitude_offset(basic, operation=operation) + if ( + result.final_amplitude is None + or result.final_offset_v is None + or (result.final_amplitude.value, result.final_offset_v) != (vpp, offset) + ): + raise ConfigError(f"{operation} final Vpp or Offset readback does not match") + self._check_source_v2_final_output_limits(vpp, offset, operation=operation) + + @staticmethod + def _validate_source_output_disable_postcondition(output: OutputFacet) -> None: + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError("source.output_disable_v2 postcondition reports output ON") + + def _recover_source_v2_output_off( self, context: SourceOperationContextCoordinator, source: SourceDriver, channel: int, extensions: SourceDescriptorExtensions, output_field: SourceFieldRef, + *, + operation: str, ) -> dict[str, object]: descriptor = self.descriptor session_state = self.session_state @@ -741,7 +1218,7 @@ def _recover_source_basic_v2_off( or result.channel != channel or result.enabled ): - raise ConfigError("source.basic_configure_v2 recovery OFF is not proven") + raise ConfigError(f"{operation} recovery OFF is not proven") except BaseException: return { "status": "off_failed", @@ -763,9 +1240,13 @@ def _recover_source_basic_v2_off( allow_uncertain_session=True, deadline=authorization.deadline, ) - _, output = self._source_basic_v2_target(snapshot, channel) + output = self._source_v2_output_target( + snapshot, + channel, + operation=operation, + ) if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: - raise ConfigError("source.basic_configure_v2 recovery OFF readback is not proven") + raise ConfigError(f"{operation} recovery OFF readback is not proven") context.mark_safe_state_verified( authorization, io_kind="query", @@ -835,6 +1316,64 @@ def _source_basic_v2_artifact( ) return artifact + def _source_output_v2_artifact( + self, + *, + context: SourceOperationContextCoordinator, + request: SourceOutputRequest, + preflight_snapshot: SourceSnapshotV2 | None, + postcondition_snapshot: SourceSnapshotV2 | None, + result: SourceOutputResult | None, + wrote_main: bool, + recovery: dict[str, object] | None = None, + ) -> dict[str, object]: + artifact = context.artifact() + descriptor_digest = ( + None + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.descriptor_digest + ) + artifact["capability_decision"] = { + "capability": "source.output_v2", + "contract_version": SOURCE_CONTRACT_VERSION, + "descriptor_digest": descriptor_digest, + } + artifact["request"] = source_v2_to_data(request) + if preflight_snapshot is not None: + artifact["preflight"] = { + "target_channel": request.channel, + "snapshot_digest": source_v2_digest(preflight_snapshot), + "consistency": preflight_snapshot.consistency.state.value, + } + if result is not None: + artifact["mutation"] = { + "status": "written" if wrote_main else "already_at_target", + "result": source_v2_to_data(result), + } + if postcondition_snapshot is not None: + artifact["postcondition"] = { + "snapshot_digest": source_v2_digest(postcondition_snapshot), + "consistency": postcondition_snapshot.consistency.state.value, + } + if recovery is not None: + artifact["recovery"] = dict(recovery) + artifact["final_state"] = { + "session_health": context.session_state.health.value, + "output_expected": "on" if request.enabled else "off", + } + artifact["evidence_refs"] = sorted( + { + evidence_ref + for feature in ( + () + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.features + ) + for evidence_ref in feature.evidence_refs + } + ) + return artifact + def _attach_source_basic_v2_diagnostics( self, exc: BaseException, @@ -845,6 +1384,16 @@ def _attach_source_basic_v2_diagnostics( except Exception: pass + def _attach_source_output_v2_diagnostics( + self, + exc: BaseException, + **kwargs: object, + ) -> None: + try: + setattr(exc, "source_operation_artifact", self._source_output_v2_artifact(**kwargs)) + except Exception: + pass + def channel_profile(self, channel: int | None = None) -> SourceChannelProfile: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel diff --git a/tests/test_source_output_v2.py b/tests/test_source_output_v2.py new file mode 100644 index 0000000..cf8c10d --- /dev/null +++ b/tests/test_source_output_v2.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + SafetyLimitsConfig, + ScopeConfig, + SourceConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.capabilities import validate_declared_capabilities +from wavebench.instruments.source_extension_capabilities import validate_source_descriptor +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + Availability, + Observed, + SourceAmplitude, + SourceAmplitudeUnit, + SourceFeatureDirection, + SourceFieldId, + SourceOutputRequest, + SourceOutputResult, + SourceProtocolQueryRecord, + SourceQueryExecutionRecord, + SourceQueryItemOutcome, + SourceReasonCode, + SourceRuntimeIdentity, + SourceTypedObservation, +) +from wavebench.logging import CommandLogger +from wavebench.services.source_service import SourceService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + +from tests.source_v2_fixtures import ( + basic_facet, + output_facet, + source_descriptor, + source_extensions, +) + + +class _TextTransport: + resource = "fake-source-output-v2" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, direction: str, text: str) -> None: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del replay + self.queries.append(command) + return "ok" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + pass + + +class _OutputDriver: + def __init__( + self, + *, + session_state: InstrumentSessionState, + enabled: dict[int, bool] | None = None, + final_vpp_available: bool = True, + ignore_enable: bool = False, + raise_after_output_write: bool = False, + ) -> None: + self.transport = GuardedAuditedTransport( + _TextTransport(), + session_state=session_state, + ) + self.enabled = dict(enabled or {1: False, 2: False}) + self.final_vpp_available = final_vpp_available + self.ignore_enable = ignore_enable + self.raise_after_output_write = raise_after_output_write + self.output_requests: list[SourceOutputRequest] = [] + self.v1_output_calls = 0 + self.closed = False + + def close(self) -> None: + self.closed = True + self.transport.close() + + def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord: + self.transport.query("SOURCE:STATE?") + records = [] + for index, item in enumerate(plan.items): + observations = [] + for field in item.fields: + if field.field is SourceFieldId.IDENTITY: + value = SourceRuntimeIdentity( + manufacturer="Example", + model="EX1", + firmware_id="1.0", + ) + elif field.field is SourceFieldId.BASIC: + assert field.target.channel is not None + value = self._basic(field.target.channel) + elif field.field is SourceFieldId.OUTPUT: + assert field.target.channel is not None + value = output_facet(enabled=self.enabled[field.target.channel]) + else: # pragma: no cover - the fixture descriptor only needs these fields. + raise AssertionError(field) + observations.append(SourceTypedObservation(field, value)) + records.append( + SourceProtocolQueryRecord( + item_id=item.item_id, + effect=item.effect, + outcome=SourceQueryItemOutcome.OBSERVED, + query_count=(1 if index == 0 else 0), + observations=tuple(observations), + ) + ) + return SourceQueryExecutionRecord( + contract_version=SOURCE_CONTRACT_VERSION, + plan_id=plan.plan_id, + items=tuple(records), + query_count=1, + device_revision_token_before="revision-1", + device_revision_token_after="revision-1", + ) + + def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult: + self.transport.write(f"SOURCE:OUTPUT {request.channel} {'ON' if request.enabled else 'OFF'}") + self.output_requests.append(request) + if self.raise_after_output_write: + raise ConfigError("fake output write failed after transmission") + if not request.enabled or not self.ignore_enable: + self.enabled[request.channel] = request.enabled + if not request.enabled: + return SourceOutputResult(channel=request.channel, enabled=False) + return SourceOutputResult( + channel=request.channel, + enabled=True, + final_amplitude=SourceAmplitude(1.0, SourceAmplitudeUnit.VPP), + final_offset_v=0.0, + ) + + def set_output(self, *args, **kwargs): + del args, kwargs + self.v1_output_calls += 1 + raise AssertionError("M5-C must not fall back to the V1 output route") + + def _basic(self, channel: int): + del channel + if self.final_vpp_available: + return basic_facet() + return replace( + basic_facet(), + amplitude=Observed.value_of(SourceAmplitude(1.0, SourceAmplitudeUnit.VRMS)), + offset_v=Observed.missing( + Availability.NOT_QUERIED, + SourceReasonCode.NOT_REQUESTED, + ), + ) + + +def _config(*, limits: SafetyLimitsConfig = SafetyLimitsConfig()) -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "DMAX"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("wavebench.toml"), + source=SourceConfig( + "example.source-v2", + "TCPIP::source::INSTR", + 1, + True, + True, + 0, + ), + safety_limits=limits, + ) + + +def _extensions(*, final_vpp_available: bool): + base = source_extensions() + basic, output = base.features + if not final_vpp_available: + basic = replace( + basic, + profile=replace( + basic.profile, + amplitude_units=(SourceAmplitudeUnit.VRMS,), + offset_readable=False, + ), + ) + second_basic = replace(basic, channels=(2,)) + output = replace( + output, + directions=( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), + ) + second_output = replace(output, channels=(2,)) + return replace( + base, + topology=replace(base.topology, channels=(1, 2)), + features=(basic, second_basic, output, second_output), + query_contract=replace(base.query_contract, max_queries=10), + ) + + +def _service( + *, + enabled: dict[int, bool] | None = None, + final_vpp_available: bool = True, + ignore_enable: bool = False, + raise_after_output_write: bool = False, + limits: SafetyLimitsConfig = SafetyLimitsConfig(), +) -> tuple[SourceService, _OutputDriver]: + session_state = InstrumentSessionState(epoch_id="source-output-v2") + driver = _OutputDriver( + session_state=session_state, + enabled=enabled, + final_vpp_available=final_vpp_available, + ignore_enable=ignore_enable, + raise_after_output_write=raise_after_output_write, + ) + descriptor = replace( + source_descriptor(driver=driver, extensions=_extensions(final_vpp_available=final_vpp_available)), + capabilities=("source.snapshot_v2", "source.output_v2"), + ) + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, driver) + return ( + SourceService( + config=_config(limits=limits), + logger=CommandLogger(), + session=driver, # type: ignore[arg-type] + descriptor=descriptor, + transport=driver.transport, + session_state=session_state, + ), + driver, + ) + + +def test_output_v2_enables_and_disables_one_port_with_independent_readback() -> None: + service, driver = _service() + + enabled = service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) + disabled = service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=False)) + + assert enabled.result.enabled is True + assert enabled.artifact["operation"] == "source.output_enable_v2" + assert enabled.artifact["final_state"]["output_expected"] == "on" + assert disabled.result.enabled is False + assert disabled.artifact["operation"] == "source.output_disable_v2" + assert disabled.artifact["final_state"]["output_expected"] == "off" + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=True), + SourceOutputRequest(channel=1, enabled=False), + ] + assert driver.transport.counters.write_completed == 2 + assert "fake-source-output-v2" not in repr(enabled.artifact) + + +def test_output_v2_allows_second_independent_port_to_turn_on() -> None: + service, driver = _service() + + first = service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) + second = service._set_output_v2_transaction(SourceOutputRequest(channel=2, enabled=True)) + + assert first.result.enabled is True + assert second.result.enabled is True + assert driver.enabled == {1: True, 2: True} + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=True), + SourceOutputRequest(channel=2, enabled=True), + ] + assert all(request.enabled for request in driver.output_requests) + + +def test_output_disable_v2_does_not_require_final_vpp_or_offset() -> None: + service, driver = _service( + enabled={1: True, 2: False}, + final_vpp_available=False, + ) + + transaction = service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=False)) + + assert transaction.result == SourceOutputResult(channel=1, enabled=False) + assert driver.enabled[1] is False + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)] + + +def test_output_enable_v2_rejects_missing_final_vpp_or_offset_before_write() -> None: + service, driver = _service(final_vpp_available=False) + + with pytest.raises(ConfigError, match="final Vpp amplitude"): + service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) + + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_output_enable_v2_applies_configured_vpp_limit_before_write() -> None: + service, driver = _service(limits=SafetyLimitsConfig(max_source_vpp=0.5)) + + with pytest.raises(ConfigError, match="max_source_vpp"): + service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) + + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_output_enable_v2_postcondition_mismatch_runs_one_off_recovery() -> None: + service, driver = _service(ignore_enable=True) + + with pytest.raises(ConfigError, match="postcondition reports output OFF") as raised: + service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) + + artifact = raised.value.source_operation_artifact + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=True), + SourceOutputRequest(channel=1, enabled=False), + ] + assert artifact["recovery"] == { + "status": "off_verified", + "session_health": "uncertain", + } + assert driver.v1_output_calls == 0 + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_output_disable_v2_never_retries_an_unknown_off_result() -> None: + service, driver = _service( + enabled={1: True, 2: False}, + raise_after_output_write=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission") as raised: + service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=False)) + + artifact = raised.value.source_operation_artifact + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)] + assert artifact["recovery"] == { + "status": "not_attempted", + "reason": "off_result_unknown_not_retried", + } + assert service.session_state is not None + assert service.session_state.health is SessionHealth.POISONED + + +def test_output_v2_idempotent_requests_send_no_write() -> None: + service, driver = _service(enabled={1: True, 2: False}) + + already_on = service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) + already_off = service._set_output_v2_transaction(SourceOutputRequest(channel=2, enabled=False)) + + assert already_on.artifact["mutation"]["status"] == "already_at_target" + assert already_off.artifact["mutation"]["status"] == "already_at_target" + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 0 From 00706a314fdea6a5295201c7b221aa606e38a8d4 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:13:29 +0800 Subject: [PATCH 16/82] feat: expose source v2 service routes --- src/wavebench/services/source_service.py | 249 ++++++++++++++++++++++- tests/test_source_basic_configure_v2.py | 60 +++++- tests/test_source_output_v2.py | 36 +++- 3 files changed, 323 insertions(+), 22 deletions(-) diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py index d9bc146..1590fef 100644 --- a/src/wavebench/services/source_service.py +++ b/src/wavebench/services/source_service.py @@ -64,6 +64,7 @@ BasicWaveFacet, OutputFacet, PatchAction, + PatchValue, SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_CONTRACT_VERSION, SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT, @@ -76,6 +77,7 @@ SourceBasicConfigureRequest, SourceBasicConfigureResult, SourceBasicConfigureV2Driver, + SourceBasicPatch, SourceFacetScope, SourceFieldId, SourceFieldRef, @@ -87,6 +89,8 @@ SourceScopeRef, SourceSnapshotV2, SourceSnapshotV2Driver, + SourceV1WriteRouteId, + SourceWaveformKind, SupportState, source_v2_digest, source_v2_to_data, @@ -117,18 +121,20 @@ @dataclass(frozen=True, slots=True) class _SourceBasicConfigureV2Transaction: - """Internal M5-B result; public Source V2 write entry points come later.""" + """Core transaction result shared by public and V1-adapter routes.""" result: SourceBasicConfigureResult artifact: dict[str, object] + snapshot: SourceSnapshotV2 @dataclass(frozen=True, slots=True) class _SourceOutputV2Transaction: - """Internal M5-C result; public Source V2 write entry points come later.""" + """Core transaction result shared by public and V1-adapter routes.""" result: SourceOutputResult artifact: dict[str, object] + snapshot: SourceSnapshotV2 @dataclass @@ -155,6 +161,36 @@ def _require(self, operation: str, *capabilities: str) -> None: self.descriptor = descriptor require_capabilities(descriptor, capabilities, operation=operation) + def _declared_source_capabilities(self) -> tuple[str, ...]: + """Return the descriptor declaration without opening an instrument session.""" + + source = self._source_config() + descriptor = self.descriptor or resolve_instrument_descriptor( + source.driver, + expected_kind="source", + ) + self.descriptor = descriptor + return descriptor.capabilities + + def _declares_source_v2_capability(self, capability: str) -> bool: + return capability in self._declared_source_capabilities() + + def _reject_v1_route_for_source_v2( + self, + route: SourceV1WriteRouteId, + *overlapping_capabilities: str, + ) -> None: + declared = set(self._declared_source_capabilities()) + overlaps = tuple( + capability for capability in overlapping_capabilities if capability in declared + ) + if overlaps: + joined = ", ".join(overlaps) + raise ConfigError( + f"{route.value} cannot run for a Source V2 write driver " + f"({joined}); use the dedicated Source V2 operation" + ) + def _source_config(self) -> SourceConfig: if self.config.source is None or not self.config.source.resource: raise ConfigError("source resource is not configured. Set [source].resource or pass --resource.") @@ -258,6 +294,34 @@ def snapshot_v2(self, *, correlation_id: str | None = None) -> SourceSnapshotV2: correlation_id=correlation_id, ) + def configure_basic_v2( + self, + request: SourceBasicConfigureRequest, + *, + correlation_id: str | None = None, + ) -> tuple[SourceBasicConfigureResult, dict[str, object]]: + """Configure one OFF source channel and return its typed result and artifact.""" + + transaction = self._configure_basic_v2_transaction( + request, + correlation_id=correlation_id, + ) + return transaction.result, transaction.artifact + + def set_output_v2( + self, + request: SourceOutputRequest, + *, + correlation_id: str | None = None, + ) -> tuple[SourceOutputResult, dict[str, object]]: + """Apply one Source V2 output transition and return its typed result and artifact.""" + + transaction = self._set_output_v2_transaction( + request, + correlation_id=correlation_id, + ) + return transaction.result, transaction.artifact + def _snapshot_v2_with_open_source( self, source: SourceDriver, @@ -522,6 +586,7 @@ def _configure_basic_v2_transaction( postcondition_snapshot=postcondition_snapshot, result=result, ), + snapshot=postcondition_snapshot, ) except BaseException: if not context.terminal: @@ -761,6 +826,11 @@ def _set_output_v2_transaction( result=result, wrote_main=wrote_main, ), + snapshot=( + postcondition_snapshot + if postcondition_snapshot is not None + else preflight_snapshot + ), ) except BaseException: if not context.terminal: @@ -1394,6 +1464,105 @@ def _attach_source_output_v2_diagnostics( except Exception: pass + @staticmethod + def _source_v2_waveform_from_v1(function: str) -> SourceWaveformKind: + aliases = { + "SIN": SourceWaveformKind.SINE, + "SINE": SourceWaveformKind.SINE, + "SQU": SourceWaveformKind.SQUARE, + "SQUARE": SourceWaveformKind.SQUARE, + "RAMP": SourceWaveformKind.RAMP, + "TRI": SourceWaveformKind.RAMP, + "TRIANGLE": SourceWaveformKind.RAMP, + "PULS": SourceWaveformKind.PULSE, + "PULSE": SourceWaveformKind.PULSE, + "NOIS": SourceWaveformKind.NOISE, + "NOISE": SourceWaveformKind.NOISE, + "DC": SourceWaveformKind.DC, + } + normalized = function.strip().upper() + try: + return aliases[normalized] + except KeyError as exc: + raise ConfigError( + "source.set_function cannot map this waveform to source.basic_configure_v2" + ) from exc + + @staticmethod + def _source_status_from_v2_snapshot( + snapshot: SourceSnapshotV2, + channel: int, + ) -> SourceStatus: + """Flatten a V2 readback only for a legacy V1 return value.""" + + target = next((item for item in snapshot.channels if item.channel == channel), None) + if target is None: + raise ConfigError("Source V2 postcondition does not contain the target channel") + + basic = ( + target.basic.value + if target.basic.availability is Availability.VALUE + and isinstance(target.basic.value, BasicWaveFacet) + else None + ) + output = ( + target.output.value + if target.output.availability is Availability.VALUE + and isinstance(target.output.value, OutputFacet) + else None + ) + + def observed_value(value: object) -> object | None: + return ( + getattr(value, "value", None) + if getattr(value, "availability", None) is Availability.VALUE + else None + ) + + waveform_codes = { + SourceWaveformKind.SINE: "SIN", + SourceWaveformKind.SQUARE: "SQU", + SourceWaveformKind.RAMP: "RAMP", + SourceWaveformKind.PULSE: "PULS", + SourceWaveformKind.NOISE: "NOIS", + SourceWaveformKind.DC: "DC", + SourceWaveformKind.ARBITRARY: "ARB", + SourceWaveformKind.OTHER: "OTHER", + } + waveform = None if basic is None else observed_value(basic.waveform_kind) + function = waveform_codes.get(waveform, "UNKNOWN") + frequency = None if basic is None else observed_value(basic.frequency_hz) + offset = None if basic is None else observed_value(basic.offset_v) + phase = None if basic is None else observed_value(basic.phase_deg) + duty = None if basic is None else observed_value(basic.square_duty_cycle_percent) + amplitude = None if basic is None else observed_value(basic.amplitude) + amplitude_value = amplitude.value if isinstance(amplitude, SourceAmplitude) else None + amplitude_unit = amplitude.unit.value.upper() if isinstance(amplitude, SourceAmplitude) else None + frequency_mode_value = None if basic is None else observed_value(basic.frequency_mode) + frequency_mode = { + "fixed": "FIX", + "sweep": "SWE", + "list": "LIST", + }.get(getattr(frequency_mode_value, "value", None), "UNKNOWN") + enabled = None if output is None else observed_value(output.enabled) + + return SourceStatus( + channel=channel, + output="ON" if enabled is True else "OFF" if enabled is False else "UNKNOWN", + function=function, + frequency_hz=float(frequency) if isinstance(frequency, (int, float)) else None, + amplitude=float(amplitude_value) if isinstance(amplitude_value, (int, float)) else None, + amplitude_unit=amplitude_unit, + offset_v=float(offset) if isinstance(offset, (int, float)) else None, + phase_deg=float(phase) if isinstance(phase, (int, float)) else None, + frequency_mode=frequency_mode, + sweep_enabled=( + "ON" if frequency_mode == "SWE" else "OFF" if frequency_mode != "UNKNOWN" else "UNKNOWN" + ), + apply_raw=None, + square_duty_cycle_percent=float(duty) if isinstance(duty, (int, float)) else None, + ) + def channel_profile(self, channel: int | None = None) -> SourceChannelProfile: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel @@ -1650,6 +1819,10 @@ def configure_burst( def trigger_burst(self, channel: int | None = None) -> None: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + self._reject_v1_route_for_source_v2( + SourceV1WriteRouteId.TRIGGER_BURST, + "source.output_v2", + ) required = ["source.burst_trigger"] if source_cfg.check_errors: required.append("source.errors") @@ -1707,6 +1880,10 @@ def configure_sweep( def trigger_sweep(self, channel: int | None = None) -> None: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + self._reject_v1_route_for_source_v2( + SourceV1WriteRouteId.TRIGGER_SWEEP, + "source.output_v2", + ) required = ["source.sweep_trigger"] if source_cfg.check_errors: required.append("source.errors") @@ -1725,6 +1902,11 @@ def snapshot_restorable_state(self, channel: int | None = None) -> RestorableSou return RestorableSourceState.from_status(self.status(channel=channel)) def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus: + self._reject_v1_route_for_source_v2( + SourceV1WriteRouteId.RESTORE, + "source.basic_configure_v2", + "source.output_v2", + ) self.set_output(channel=state.channel, enabled=False) self.set_function(channel=state.channel, function=state.function) self.set_amplitude_vpp(channel=state.channel, value_vpp=state.amplitude_vpp) @@ -1739,6 +1921,18 @@ def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus def set_frequency(self, channel: int | None, value_hz: float) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + if self._declares_source_v2_capability("source.basic_configure_v2"): + transaction = self._configure_basic_v2_transaction( + SourceBasicConfigureRequest( + channel=channel, + patch=SourceBasicPatch( + frequency_hz=PatchValue(PatchAction.SET, value_hz), + ), + ) + ) + status = self._source_status_from_v2_snapshot(transaction.snapshot, channel) + self._state_guard_after_write(status) + return status required = ["source.set_frequency"] if source_cfg.settle_ms_after_set_frequency: required.append("source.status") @@ -1766,6 +1960,13 @@ def set_frequency(self, channel: int | None, value_hz: float) -> SourceStatus: def set_output(self, channel: int | None, enabled: bool) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + if self._declares_source_v2_capability("source.output_v2"): + transaction = self._set_output_v2_transaction( + SourceOutputRequest(channel=channel, enabled=enabled), + ) + status = self._source_status_from_v2_snapshot(transaction.snapshot, channel) + self._state_guard_after_write(status) + return status required = ["source.output"] if enabled: required.append("source.status") @@ -1795,6 +1996,21 @@ def set_output(self, channel: int | None, enabled: bool) -> SourceStatus: def set_function(self, channel: int | None, function: str) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + if self._declares_source_v2_capability("source.basic_configure_v2"): + transaction = self._configure_basic_v2_transaction( + SourceBasicConfigureRequest( + channel=channel, + patch=SourceBasicPatch( + waveform_kind=PatchValue( + PatchAction.SET, + self._source_v2_waveform_from_v1(function), + ), + ), + ) + ) + status = self._source_status_from_v2_snapshot(transaction.snapshot, channel) + self._state_guard_after_write(status) + return status required = ["source.set_function"] if self.state_guard is not None: required.append("source.status") @@ -1808,6 +2024,18 @@ def set_function(self, channel: int | None, function: str) -> SourceStatus: def set_square_duty_cycle(self, channel: int | None, duty_percent: float) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + if self._declares_source_v2_capability("source.basic_configure_v2"): + transaction = self._configure_basic_v2_transaction( + SourceBasicConfigureRequest( + channel=channel, + patch=SourceBasicPatch( + square_duty_cycle_percent=PatchValue(PatchAction.SET, duty_percent), + ), + ) + ) + status = self._source_status_from_v2_snapshot(transaction.snapshot, channel) + self._state_guard_after_write(status) + return status required = ["source.set_square_duty_cycle"] if self.state_guard is not None: required.append("source.status") @@ -1826,6 +2054,18 @@ def set_amplitude_vpp(self, channel: int | None, value_vpp: float) -> SourceStat source_cfg = self._source_config() self._check_source_vpp(value_vpp, field="source amplitude / 信号源幅度") channel = source_cfg.default_channel if channel is None else channel + if self._declares_source_v2_capability("source.basic_configure_v2"): + transaction = self._configure_basic_v2_transaction( + SourceBasicConfigureRequest( + channel=channel, + patch=SourceBasicPatch( + amplitude_vpp=PatchValue(PatchAction.SET, value_vpp), + ), + ) + ) + status = self._source_status_from_v2_snapshot(transaction.snapshot, channel) + self._state_guard_after_write(status) + return status required = ["source.set_amplitude_vpp"] if self.state_guard is not None: required.append("source.status") @@ -1855,6 +2095,11 @@ def upload_arbitrary_waveform( output_on: bool = False, ) -> SourceStatus: source_cfg = self._source_config() + self._reject_v1_route_for_source_v2( + SourceV1WriteRouteId.UPLOAD_ARBITRARY, + "source.basic_configure_v2", + "source.output_v2", + ) self._require_finite( playback_frequency_hz, field="arbitrary waveform playback frequency / 任意波播放频率", diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py index 4c5c753..7d0085a 100644 --- a/tests/test_source_basic_configure_v2.py +++ b/tests/test_source_basic_configure_v2.py @@ -40,6 +40,7 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService +from wavebench.services.source_state import RestorableSourceState from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState, SessionHealth @@ -293,32 +294,71 @@ def _frequency_request(value_hz: float = 2_000.0) -> SourceBasicConfigureRequest @pytest.mark.parametrize("combined", (True, False)) -def test_basic_configure_v2_private_transaction_supports_combined_and_scalar_queries( +def test_basic_configure_v2_public_service_supports_combined_and_scalar_queries( combined: bool, ) -> None: service, driver = _service(combined=combined) request = _frequency_request() - transaction = service._configure_basic_v2_transaction(request, correlation_id="basic-write") + result, artifact = service.configure_basic_v2(request, correlation_id="basic-write") - assert not hasattr(service, "configure_basic_v2") - assert transaction.result.basic.frequency_hz.value == 2_000.0 + assert result.basic.frequency_hz.value == 2_000.0 assert driver.basic_requests == [request] assert driver.output_requests == [] assert driver.transport.counters.write_completed == 1 - assert transaction.artifact["operation"] == "source.basic_configure_v2" - assert transaction.artifact["request"]["channel"] == 1 - assert transaction.artifact["final_state"] == { + assert artifact["operation"] == "source.basic_configure_v2" + assert artifact["request"]["channel"] == 1 + assert artifact["final_state"] == { "session_health": "healthy", "output_expected": "off", } - assert [item["phase"] for item in transaction.artifact["phases"]] == [ + assert [item["phase"] for item in artifact["phases"]] == [ "preflight", "main", "postcondition", ] - assert "fake-source-v2" not in repr(transaction.artifact) - assert "SOURCE:STATE?" not in repr(transaction.artifact) + assert "fake-source-v2" not in repr(artifact) + assert "SOURCE:STATE?" not in repr(artifact) + + +def test_v1_frequency_route_maps_to_v2_for_a_dual_contract_driver() -> None: + service, driver = _service() + assert service.descriptor is not None + service.descriptor = replace( + service.descriptor, + capabilities=( + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + "source.set_frequency", + ), + ) + + status = service.set_frequency(channel=1, value_hz=2_000.0) + + assert status.channel == 1 + assert status.output == "OFF" + assert status.frequency_hz == 2_000.0 + assert driver.basic_requests == [_frequency_request()] + assert driver.transport.counters.write_completed == 1 + + +def test_v1_restore_route_rejects_before_io_for_a_dual_contract_driver() -> None: + service, driver = _service() + + with pytest.raises(ConfigError, match="restore_restorable_state cannot run"): + service.restore_restorable_state( + RestorableSourceState( + channel=1, + output="OFF", + function="SIN", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + ) + + assert driver.transport.counters.write_requests == 0 def test_basic_configure_v2_rejects_target_output_on_before_write() -> None: diff --git a/tests/test_source_output_v2.py b/tests/test_source_output_v2.py index cf8c10d..c933b6f 100644 --- a/tests/test_source_output_v2.py +++ b/tests/test_source_output_v2.py @@ -259,21 +259,37 @@ def _service( def test_output_v2_enables_and_disables_one_port_with_independent_readback() -> None: service, driver = _service() - enabled = service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) - disabled = service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=False)) - - assert enabled.result.enabled is True - assert enabled.artifact["operation"] == "source.output_enable_v2" - assert enabled.artifact["final_state"]["output_expected"] == "on" - assert disabled.result.enabled is False - assert disabled.artifact["operation"] == "source.output_disable_v2" - assert disabled.artifact["final_state"]["output_expected"] == "off" + enabled, enable_artifact = service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + disabled, disable_artifact = service.set_output_v2(SourceOutputRequest(channel=1, enabled=False)) + + assert enabled.enabled is True + assert enable_artifact["operation"] == "source.output_enable_v2" + assert enable_artifact["final_state"]["output_expected"] == "on" + assert disabled.enabled is False + assert disable_artifact["operation"] == "source.output_disable_v2" + assert disable_artifact["final_state"]["output_expected"] == "off" assert driver.output_requests == [ SourceOutputRequest(channel=1, enabled=True), SourceOutputRequest(channel=1, enabled=False), ] assert driver.transport.counters.write_completed == 2 - assert "fake-source-output-v2" not in repr(enabled.artifact) + assert "fake-source-output-v2" not in repr(enable_artifact) + + +def test_v1_output_route_maps_to_v2_without_using_the_v1_driver_method() -> None: + service, driver = _service() + assert service.descriptor is not None + service.descriptor = replace( + service.descriptor, + capabilities=("source.snapshot_v2", "source.output_v2", "source.output"), + ) + + status = service.set_output(channel=1, enabled=True) + + assert status.channel == 1 + assert status.output == "ON" + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=True)] + assert driver.v1_output_calls == 0 def test_output_v2_allows_second_independent_port_to_turn_on() -> None: From b720e6064ccf7b80b07e41e05c963c21ae35cb9b Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:15:25 +0800 Subject: [PATCH 17/82] feat: add source v2 write cli commands --- src/wavebench/cli.py | 72 +++++++++++++++++++++++++ src/wavebench/cli_parser.py | 28 ++++++++++ tests/test_cli.py | 26 +++++++++ tests/test_source_snapshot_v2.py | 90 ++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index d1959c8..7afe87d 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -256,6 +256,50 @@ def _json_payload(value: object) -> object: return value +def _source_basic_configure_v2_request(args: argparse.Namespace): + from .instruments.source_extensions import ( + PatchAction, + PatchValue, + SourceBasicConfigureRequest, + SourceBasicPatch, + SourceWaveformKind, + ) + + values = { + "waveform_kind": args.waveform, + "frequency_hz": args.frequency_hz, + "amplitude_vpp": args.amplitude_vpp, + "offset_v": args.offset_v, + "square_duty_cycle_percent": args.square_duty_cycle_percent, + } + if all(value is None for value in values.values()): + raise ConfigError("source basic-configure-v2 requires at least one basic field") + + waveform = ( + PatchValue(PatchAction.SET, SourceWaveformKind(args.waveform)) + if args.waveform is not None + else PatchValue(PatchAction.KEEP) + ) + + def patch_value(value: object): + return ( + PatchValue(PatchAction.SET, value) + if value is not None + else PatchValue(PatchAction.KEEP) + ) + + return SourceBasicConfigureRequest( + channel=args.channel, + patch=SourceBasicPatch( + waveform_kind=waveform, + frequency_hz=patch_value(args.frequency_hz), + amplitude_vpp=patch_value(args.amplitude_vpp), + offset_v=patch_value(args.offset_v), + square_duty_cycle_percent=patch_value(args.square_duty_cycle_percent), + ), + ) + + def _scope_error_check(args: argparse.Namespace) -> ErrorCheckSpec | None: policy = getattr(args, "error_policy", None) if policy is None: @@ -334,6 +378,11 @@ def _scope_error_payload(exc: BaseException) -> dict[str, object]: "status": "partial_cleanup_failed", "reason_code": "remove_failed", } + source_operation_artifact = getattr(exc, "source_operation_artifact", None) + if isinstance(source_operation_artifact, Mapping): + payload["source_operation_artifact"] = _json_payload( + dict(source_operation_artifact) + ) return payload @@ -1069,6 +1118,29 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(payload, indent=2, ensure_ascii=False)) return 0 + if args.command == "basic-configure-v2": + _, payload = service.configure_basic_v2( + _source_basic_configure_v2_request(args) + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + if args.command == "output-v2": + from wavebench.instruments.source_extensions import SourceOutputRequest + + _, payload = service.set_output_v2( + SourceOutputRequest( + channel=args.channel, + enabled=args.state == "on", + ) + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 if args.command == "profile": _print_source_channel_profile(service.channel_profile(channel=args.channel)) return 0 diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index ae973c2..814911b 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -662,6 +662,34 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(source_snapshot_v2) + source_basic_configure_v2 = source_sub.add_parser( + "basic-configure-v2", + help="Configure one OFF Source V2 channel with one or more basic fields", + ) + source_basic_configure_v2.add_argument("--channel", type=int, required=True) + source_basic_configure_v2.add_argument( + "--waveform", + choices=("sine", "square", "ramp", "pulse", "noise", "dc"), + default=None, + ) + source_basic_configure_v2.add_argument("--frequency-hz", type=float, default=None) + source_basic_configure_v2.add_argument("--amplitude-vpp", type=float, default=None) + source_basic_configure_v2.add_argument("--offset-v", type=float, default=None) + source_basic_configure_v2.add_argument( + "--square-duty-cycle-percent", + type=float, + default=None, + ) + add_runtime_options(source_basic_configure_v2) + + source_output_v2 = source_sub.add_parser( + "output-v2", + help="Turn one Source V2 channel output on or off", + ) + source_output_v2.add_argument("--channel", type=int, required=True) + source_output_v2.add_argument("state", choices=("on", "off")) + add_runtime_options(source_output_v2) + source_profile = source_sub.add_parser( "profile", help="Query the complete read-only source channel profile", diff --git a/tests/test_cli.py b/tests/test_cli.py index 7109c2e..a58b57c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -633,6 +633,32 @@ def test_source_set_freq_accepts_value(self): self.assertEqual(args.channel, 2) self.assertEqual(args.value_hz, 1000.0) + def test_source_v2_basic_and_output_commands_accept_explicit_channels(self): + basic = build_parser().parse_args( + [ + "source", + "basic-configure-v2", + "--channel", + "2", + "--waveform", + "square", + "--frequency-hz", + "1000", + "--amplitude-vpp", + "1.5", + ] + ) + output = build_parser().parse_args(["source", "output-v2", "--channel", "2", "on"]) + + self.assertEqual(basic.command, "basic-configure-v2") + self.assertEqual(basic.channel, 2) + self.assertEqual(basic.waveform, "square") + self.assertEqual(basic.frequency_hz, 1000.0) + self.assertEqual(basic.amplitude_vpp, 1.5) + self.assertEqual(output.command, "output-v2") + self.assertEqual(output.channel, 2) + self.assertEqual(output.state, "on") + def test_fetch_accepts_points(self): args = build_parser().parse_args(["scope", "fetch", "--points", "dmax"]) self.assertEqual(args.command, "fetch") diff --git a/tests/test_source_snapshot_v2.py b/tests/test_source_snapshot_v2.py index ee9e256..7b780e3 100644 --- a/tests/test_source_snapshot_v2.py +++ b/tests/test_source_snapshot_v2.py @@ -473,3 +473,93 @@ def test_snapshot_v2_cli_emits_operation_artifact(capsys) -> None: assert payload["schema"] == "wavebench.cli.result.v1" assert payload["result"]["schema"] == SOURCE_OPERATION_ARTIFACT_SCHEMA assert payload["result"]["snapshot"]["schema"] == SOURCE_SNAPSHOT_SCHEMA + + +def test_source_v2_write_cli_emits_existing_operation_artifacts(capsys) -> None: + basic_artifact = { + "schema": SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "source.basic_configure_v2", + } + output_artifact = { + "schema": SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "source.output_enable_v2", + } + + class _Service: + def configure_basic_v2(self, request): + assert request.channel == 1 + assert request.patch.frequency_hz.value == 2_000.0 + return object(), basic_artifact + + def set_output_v2(self, request): + assert request.channel == 1 + assert request.enabled is True + return object(), output_artifact + + with patch("wavebench.cli._load_source_service", return_value=_Service()): + basic_code = cli.main( + [ + "--json", + "source", + "basic-configure-v2", + "--channel", + "1", + "--frequency-hz", + "2000", + "--config", + "unused.toml", + ] + ) + basic_payload = json.loads(capsys.readouterr().out) + output_code = cli.main( + [ + "--json", + "source", + "output-v2", + "--channel", + "1", + "on", + "--config", + "unused.toml", + ] + ) + output_payload = json.loads(capsys.readouterr().out) + + assert basic_code == 0 + assert basic_payload["result"] == basic_artifact + assert output_code == 0 + assert output_payload["result"] == output_artifact + + +def test_source_v2_write_cli_keeps_failure_operation_artifact(capsys) -> None: + artifact = { + "schema": SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "source.output_enable_v2", + "recovery": {"status": "off_verified"}, + } + + class _Service: + def set_output_v2(self, request): + del request + error = ConfigError("write failed") + error.source_operation_artifact = artifact + raise error + + with patch("wavebench.cli._load_source_service", return_value=_Service()): + exit_code = cli.main( + [ + "--json", + "source", + "output-v2", + "--channel", + "1", + "on", + "--config", + "unused.toml", + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert exit_code == 2 + assert payload["schema"] == "wavebench.error.v1" + assert payload["source_operation_artifact"] == artifact From 99455c3cf83fbcea30674b4c1954fc5e7e7351e5 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:17:14 +0800 Subject: [PATCH 18/82] feat: add source v2 run plan schema --- src/wavebench/services/execution_intent.py | 3 ++ src/wavebench/services/run_plan.py | 55 ++++++++++++++++++++++ src/wavebench/services/run_safety.py | 14 ++++++ tests/test_execution_intent.py | 32 +++++++++++++ tests/test_run_plan.py | 47 ++++++++++++++++++ tests/test_source_v1_routes.py | 38 ++++++++++----- 6 files changed, 176 insertions(+), 13 deletions(-) diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index 611a861..f8a6eeb 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -29,6 +29,9 @@ "source.set_vpp": "source.set_amplitude_vpp", "source.set_duty": "source.set_square_duty_cycle", "source.output": "source.output", + "source.basic_configure_v2": "source.basic_configure_v2", + "source.output_enable_v2": "source.output_enable_v2", + "source.output_disable_v2": "source.output_disable_v2", "power.status": "power.status", "power.set": "power.set_voltage_current_limit", "power.output": "power.output", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 7a66c0b..5e55ffc 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -26,6 +26,9 @@ "source.set_vpp", "source.set_duty", "source.output", + "source.basic_configure_v2", + "source.output_enable_v2", + "source.output_disable_v2", "power.status", "power.set", "power.output", @@ -42,6 +45,9 @@ "source.set_vpp": ("value_vpp",), "source.set_duty": ("duty_percent",), "source.output": ("state",), + "source.basic_configure_v2": ("channel",), + "source.output_enable_v2": ("channel",), + "source.output_disable_v2": ("channel",), "sweep.frequency_response": ("reference_channel", "response_channel"), "sleep": ("duration_s",), } @@ -106,6 +112,16 @@ "source.set_vpp": {"channel", "on_failure"}, "source.set_duty": {"channel", "on_failure"}, "source.output": {"channel", "on_failure"}, + "source.basic_configure_v2": { + "waveform_kind", + "frequency_hz", + "amplitude_vpp", + "offset_v", + "square_duty_cycle_percent", + "on_failure", + }, + "source.output_enable_v2": {"on_failure"}, + "source.output_disable_v2": {"on_failure"}, "power.status": {"channel", "on_failure"}, "power.set": {"channel", "on_failure"}, "power.output": {"channel", "on_failure"}, @@ -131,6 +147,9 @@ "source.set_vpp": "Set source amplitude in Vpp.", "source.set_duty": "Set square-wave duty cycle in percent; valid range is 0 < duty_percent < 100.", "source.output": "Turn source channel output on or off.", + "source.basic_configure_v2": "Configure one Source V2 channel while its output is OFF. At least one basic field is required.", + "source.output_enable_v2": "Turn one Source V2 channel output on after a fresh V2 readback.", + "source.output_disable_v2": "Turn one Source V2 channel output off without requiring Vpp or offset readback.", "power.status": "Read power-supply channel state without changing output.", "power.set": "Set DP800 voltage/current limit; does not change output state.", "power.output": "Turn power-supply channel output on or off; does not change voltage/current limit.", @@ -521,6 +540,42 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["value_vpp"] = _positive_float(fields["value_vpp"], f"{prefix}.value_vpp") elif kind == "source.set_duty": fields["duty_percent"] = _duty_percent(fields["duty_percent"], f"{prefix}.duty_percent") + elif kind == "source.basic_configure_v2": + patch_fields = { + "waveform_kind", + "frequency_hz", + "amplitude_vpp", + "offset_v", + "square_duty_cycle_percent", + } + if not patch_fields & fields.keys(): + raise ConfigError(f"{prefix} source.basic_configure_v2 requires at least one basic field") + if "waveform_kind" in fields: + waveform_kind = _non_empty_str( + fields["waveform_kind"], + f"{prefix}.waveform_kind", + ).lower() + if waveform_kind not in {"sine", "square", "ramp", "pulse", "noise", "dc"}: + raise ConfigError( + f"{prefix}.waveform_kind must be one of sine, square, ramp, pulse, noise, dc" + ) + fields["waveform_kind"] = waveform_kind + for field in ("frequency_hz", "amplitude_vpp"): + if field in fields: + value = _finite_float(fields[field], f"{prefix}.{field}") + if value < 0: + raise ConfigError(f"{prefix}.{field} must be >= 0") + fields[field] = value + if "offset_v" in fields: + fields["offset_v"] = _finite_float(fields["offset_v"], f"{prefix}.offset_v") + if "square_duty_cycle_percent" in fields: + duty = _finite_float( + fields["square_duty_cycle_percent"], + f"{prefix}.square_duty_cycle_percent", + ) + if not 0 <= duty <= 100: + raise ConfigError(f"{prefix}.square_duty_cycle_percent must be in [0, 100]") + fields["square_duty_cycle_percent"] = duty elif kind == "dmm.read": fields["function"] = _non_empty_str(fields.get("function", "dcv"), f"{prefix}.function").lower() if "expect" in fields: diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index c236631..4df10d8 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -27,6 +27,9 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "source.set_vpp", "source.set_duty", "source.output", + "source.basic_configure_v2", + "source.output_enable_v2", + "source.output_disable_v2", "dmm.read", "sleep", } @@ -42,6 +45,17 @@ def check_run_plan_safety_limits(plan: RunPlan, limits: SafetyLimitsConfig) -> N config_key="max_source_vpp", unit="Vpp", ) + elif step.kind == "source.basic_configure_v2" and "amplitude_vpp" in step.fields: + _check_limit( + step.fields["amplitude_vpp"], + limits.max_source_vpp, + field=( + f"run step {step.index} Source V2 amplitude / " + f"运行步骤 {step.index} Source V2 幅度" + ), + config_key="max_source_vpp", + unit="Vpp", + ) elif step.kind == "sweep.frequency_response": for amplitude in step.fields.get("amplitudes_vpp", []): _check_limit( diff --git a/tests/test_execution_intent.py b/tests/test_execution_intent.py index de9c4be..1f76683 100644 --- a/tests/test_execution_intent.py +++ b/tests/test_execution_intent.py @@ -96,6 +96,38 @@ def test_execution_intent_rejects_payload_change() -> None: verify_execution_intent(expected, plan, config) +def test_execution_intent_uses_distinct_source_v2_output_operations() -> None: + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.basic_configure_v2" +channel = 1 +frequency_hz = 2000 + +[[steps]] +kind = "source.output_enable_v2" +channel = 1 + +[[steps]] +kind = "source.output_disable_v2" +channel = 1 +""", + ) + ) + + intent = build_execution_intent(plan, make_config(tmp)) + + assert [item["operation"] for item in intent.operations] == [ + "source.basic_configure_v2", + "source.output_enable_v2", + "source.output_disable_v2", + ] + assert intent.operations[0]["parameters"]["frequency_hz"] == 2000.0 + + def test_run_rejects_intent_mismatch_before_opening_instrument_services() -> None: with TemporaryDirectory() as tmp: plan = _sleep_plan(tmp) diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py index 9052bbf..fc8f822 100644 --- a/tests/test_run_plan.py +++ b/tests/test_run_plan.py @@ -290,11 +290,58 @@ def test_source_and_sleep_steps_validate_fields(self): self.assertEqual(plan.steps[2].fields["duty_percent"], 25.0) self.assertEqual(plan.steps[3].fields["duration_s"], 0.5) + def test_source_v2_steps_validate_explicit_channels_and_closed_basic_patch(self): + plan = load_run_plan(self._write_plan(""" +[[steps]] +kind = "source.basic_configure_v2" +channel = 2 +waveform_kind = "SQUARE" +frequency_hz = 1000 +amplitude_vpp = 0 +offset_v = -0.2 +square_duty_cycle_percent = 100 + +[[steps]] +kind = "source.output_enable_v2" +channel = 2 + +[[steps]] +kind = "source.output_disable_v2" +channel = 2 +""")) + + basic = plan.steps[0] + self.assertEqual(basic.fields["channel"], 2) + self.assertEqual(basic.fields["waveform_kind"], "square") + self.assertEqual(basic.fields["frequency_hz"], 1000.0) + self.assertEqual(basic.fields["amplitude_vpp"], 0.0) + self.assertEqual(basic.fields["offset_v"], -0.2) + self.assertEqual(basic.fields["square_duty_cycle_percent"], 100.0) + + empty_patch = self._write_plan(""" +[[steps]] +kind = "source.basic_configure_v2" +channel = 2 +""") + with self.assertRaisesRegex(ConfigError, "requires at least one basic field"): + load_run_plan(empty_patch) + + arbitrary = self._write_plan(""" +[[steps]] +kind = "source.basic_configure_v2" +channel = 2 +waveform_kind = "arbitrary" +""") + with self.assertRaisesRegex(ConfigError, "waveform_kind must be one of"): + load_run_plan(arbitrary) + def test_format_run_plan_schema_lists_expect_and_power_output(self): text = format_run_plan_schema() self.assertIn("power.output", text) self.assertIn("source.arb_load", text) + self.assertIn("source.basic_configure_v2", text) + self.assertIn("source.output_enable_v2", text) self.assertIn("sweep.frequency_response", text) self.assertIn("[steps.expect]", text) self.assertIn("[steps.expect_fft]", text) diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py index 323a39d..543270c 100644 --- a/tests/test_source_v1_routes.py +++ b/tests/test_source_v1_routes.py @@ -37,13 +37,13 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs assert all(require_operation_spec(operation).effect == "write" for operation in inventoried_operations) -def test_source_v1_indirect_write_entries_are_frozen_and_v2_writes_have_no_run_plan_entry() -> None: +def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive() -> None: entrypoints = { entrypoint for item in SOURCE_V1_WRITE_ROUTE_INVENTORY for entrypoint in item.entrypoints } - expected_run_steps = { + expected_v1_run_steps = { "source.arb_load", "source.set_func", "source.set_freq", @@ -51,12 +51,17 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_writes_have_no_run_p "source.set_duty", "source.output", } - assert {item.removeprefix("run-plan.") for item in entrypoints if item.startswith("run-plan.")} == expected_run_steps + expected_v2_run_steps = { + "source.basic_configure_v2", + "source.output_enable_v2", + "source.output_disable_v2", + } + assert {item.removeprefix("run-plan.") for item in entrypoints if item.startswith("run-plan.")} == expected_v1_run_steps assert { kind for kind in ALLOWED_STEP_KINDS if kind.startswith("source.") and kind != "source.status" - } == expected_run_steps + } == expected_v1_run_steps | expected_v2_run_steps assert { spec.operation for spec in list_operation_specs(instrument_kind="source") @@ -68,13 +73,20 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_writes_have_no_run_p } with TemporaryDirectory() as tmp: - for kind in ( - "source.basic_configure_v2", - "source.output_enable_v2", - "source.output_disable_v2", - "source.output_v2", - ): + valid_steps = { + "source.basic_configure_v2": "channel = 1\nfrequency_hz = 1000\n", + "source.output_enable_v2": "channel = 1\n", + "source.output_disable_v2": "channel = 1\n", + } + for kind, fields in valid_steps.items(): plan_path = Path(tmp) / f"{kind}.toml" - plan_path.write_text(f'[[steps]]\nkind = "{kind}"\n', encoding="utf-8") - with pytest.raises(ConfigError, match=f"{kind}.*not supported"): - load_run_plan(plan_path) + plan_path.write_text( + f'[[steps]]\nkind = "{kind}"\n{fields}', + encoding="utf-8", + ) + assert load_run_plan(plan_path).steps[0].kind == kind + + plan_path = Path(tmp) / "source.output_v2.toml" + plan_path.write_text('[[steps]]\nkind = "source.output_v2"\n', encoding="utf-8") + with pytest.raises(ConfigError, match="source.output_v2.*not supported"): + load_run_plan(plan_path) From 901f013a2064443518e548c6cc30f02edc570f5c Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:21:42 +0800 Subject: [PATCH 19/82] feat: execute source v2 run plan steps --- src/wavebench/services/run_service.py | 98 ++++++++++++- tests/test_run_service.py | 189 ++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 2 deletions(-) diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index c19b6e9..d8b8660 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -21,6 +21,14 @@ ) from wavebench.instruments.capabilities import require_capabilities from wavebench.instruments.registry import resolve_instrument_descriptor +from wavebench.instruments.source_extensions import ( + PatchAction, + PatchValue, + SourceBasicConfigureRequest, + SourceBasicPatch, + SourceOutputRequest, + SourceWaveformKind, +) from wavebench.logging import CommandLogger from wavebench.services.power_service import PowerService from wavebench.services.dmm_service import DmmService @@ -326,6 +334,20 @@ def _check_plan_capabilities(self, plan: RunPlan) -> None: def add(kind: str, *capabilities: str) -> None: required.setdefault(kind, set()).update(capabilities) + def add_source_output_gate_capability() -> None: + source = self.config.source + if source is None or not source.resource: + add("source", "source.output") + return + descriptor = resolve_instrument_descriptor( + source.driver, + expected_kind="source", + ) + if "source.output_v2" in descriptor.capabilities: + add("source", "source.snapshot_v2", "source.output_v2") + else: + add("source", "source.output") + for step in plan.steps: if step.kind == "scope.auto": add("scope", "scope.autoscale") @@ -378,6 +400,10 @@ def add(kind: str, *capabilities: str) -> None: add("source", "source.output") if step.fields["state"] == "on": add("source", "source.status") + elif step.kind == "source.basic_configure_v2": + add("source", "source.snapshot_v2", "source.basic_configure_v2") + elif step.kind in {"source.output_enable_v2", "source.output_disable_v2"}: + add("source", "source.snapshot_v2", "source.output_v2") elif step.kind == "power.status": add("power", "power.status") elif step.kind == "power.set": @@ -397,7 +423,7 @@ def add(kind: str, *capabilities: str) -> None: or step.kind == "sweep.frequency_response" or plan.restore.source_state ): - add("source", "source.output") + add_source_output_gate_capability() if gate.get("power_channels") or step.kind.startswith("power."): add("power", "power.output") @@ -416,7 +442,7 @@ def add(kind: str, *capabilities: str) -> None: item.kind.startswith("source.") or item.kind == "sweep.frequency_response" for item in plan.steps ): - add("source", "source.output") + add_source_output_gate_capability() if plan.safety.off_power_channels or any( item.kind.startswith("power.") for item in plan.steps ): @@ -490,6 +516,7 @@ def run( shutil.copyfile(plan.path, run_dir / "plan.toml") records: list[RunStepRecord] = [] + source_operations: list[dict[str, Any]] = [] run_json_path = run_dir / "run.json" summary_csv_path = run_dir / "summary.csv" restore_state: list[RestorableSourceState] | None = None @@ -508,6 +535,10 @@ def run( }, } + def append_source_operation_artifact(value: object) -> None: + if isinstance(value, dict): + source_operations.append(value) + def refresh_provenance() -> None: instrument_io = services.audit_snapshot() if instrument_io is not None: @@ -545,6 +576,7 @@ def report_close_errors() -> None: restore_state=restore_state, restore_error=restore_error, provenance=provenance, + source_operations=source_operations, ) services.close_reporters.append(report_close_errors) @@ -589,6 +621,13 @@ def report_close_errors() -> None: ) }, ) + append_source_operation_artifact( + record.artifact.get("source_operation") + ) + if step_failure is not None: + append_source_operation_artifact( + getattr(step_failure, "source_operation_artifact", None) + ) safety_gate = self._safety_gate_for_step(plan, step) gate_triggered = safety_gate["enabled"] and record.status in { "failed", @@ -686,6 +725,7 @@ def report_close_errors() -> None: restore_state=restore_state, restore_error=restore_error, provenance=provenance, + source_operations=source_operations, ) if restore_error is not None: raise ConfigError( @@ -700,6 +740,9 @@ def report_close_errors() -> None: write_step_record(steps_dir, exc.record) self._update_frequency_responses_manifest(run_dir, exc.record) failure = exc.cause + append_source_operation_artifact( + getattr(failure, "source_operation_artifact", None) + ) restore_error = restore_source_state( restore_state, source_service_factory=lambda: self._source_service(services=services), @@ -721,6 +764,7 @@ def report_close_errors() -> None: restore_state=restore_state, restore_error=restore_error, provenance=provenance, + source_operations=source_operations, ) if isinstance(exc, _FrequencyResponseExecutionError): raise failure from None @@ -774,6 +818,7 @@ def report_close_errors() -> None: restore_state=restore_state, restore_error=restore_error, provenance=provenance, + source_operations=source_operations, ) raise ConfigError("run plan source state restore failed: " + restore_error["message"]) @@ -790,6 +835,7 @@ def report_close_errors() -> None: restore_state=restore_state, restore_error=None, provenance=provenance, + source_operations=source_operations, ) result = RunResult( run_dir=run_dir, @@ -1037,6 +1083,54 @@ def _run_step( elif step.kind == "source.status": status = self._source_service(services=services).status(channel=step.fields.get("channel")) artifact = {"source_status": _status_payload(status)} + elif step.kind == "source.basic_configure_v2": + fields = step.fields + _, source_operation = self._source_service(services=services).configure_basic_v2( + SourceBasicConfigureRequest( + channel=fields["channel"], + patch=SourceBasicPatch( + waveform_kind=( + PatchValue( + PatchAction.SET, + SourceWaveformKind(fields["waveform_kind"]), + ) + if "waveform_kind" in fields + else PatchValue(PatchAction.KEEP) + ), + frequency_hz=( + PatchValue(PatchAction.SET, fields["frequency_hz"]) + if "frequency_hz" in fields + else PatchValue(PatchAction.KEEP) + ), + amplitude_vpp=( + PatchValue(PatchAction.SET, fields["amplitude_vpp"]) + if "amplitude_vpp" in fields + else PatchValue(PatchAction.KEEP) + ), + offset_v=( + PatchValue(PatchAction.SET, fields["offset_v"]) + if "offset_v" in fields + else PatchValue(PatchAction.KEEP) + ), + square_duty_cycle_percent=( + PatchValue(PatchAction.SET, fields["square_duty_cycle_percent"]) + if "square_duty_cycle_percent" in fields + else PatchValue(PatchAction.KEEP) + ), + ), + ) + ) + artifact = {"source_operation": source_operation} + elif step.kind == "source.output_enable_v2": + _, source_operation = self._source_service(services=services).set_output_v2( + SourceOutputRequest(channel=step.fields["channel"], enabled=True) + ) + artifact = {"source_operation": source_operation} + elif step.kind == "source.output_disable_v2": + _, source_operation = self._source_service(services=services).set_output_v2( + SourceOutputRequest(channel=step.fields["channel"], enabled=False) + ) + artifact = {"source_operation": source_operation} elif step.kind == "source.set_freq": status = self._source_service(services=services).set_frequency( channel=step.fields.get("channel"), diff --git a/tests/test_run_service.py b/tests/test_run_service.py index d8dffe1..6e707cb 100644 --- a/tests/test_run_service.py +++ b/tests/test_run_service.py @@ -441,6 +441,75 @@ def test_check_rejects_missing_capability_before_opening_session(self): open_services.assert_not_called() + def test_check_requires_source_v2_capability_before_opening_session(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.basic_configure_v2" +channel = 1 +frequency_hz = 1000 +""", + ) + ) + descriptor = SimpleNamespace( + driver_id="minimal.source-v2", + capabilities=("source.snapshot_v2",), + ) + service = RunService(config=make_config(tmp), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ), patch.object(service, "_run_instrument_services") as open_services: + with self.assertRaisesRegex(ConfigError, "source.basic_configure_v2"): + service.run(plan) + + open_services.assert_not_called() + + def test_check_accepts_source_v2_steps_without_v1_source_write_capabilities(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[safety] +safety_gate = true +off_source_channels = [1] + +[[steps]] +kind = "source.basic_configure_v2" +channel = 1 +frequency_hz = 1000 + +[[steps]] +kind = "source.output_enable_v2" +channel = 1 + +[[steps]] +kind = "source.output_disable_v2" +channel = 1 +""", + ) + ) + descriptor = SimpleNamespace( + driver_id="minimal.source-v2", + capabilities=( + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + ), + ) + service = RunService(config=make_config(tmp), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + service.check(plan) + def test_check_requires_protection_capability_for_power_output_on(self): with TemporaryDirectory() as tmp: plan = load_run_plan( @@ -1253,6 +1322,126 @@ def test_runs_source_steps(self): source.set_output.assert_called_once_with(channel=2, enabled=True) self.assertEqual(len(result.steps), 6) + def test_runs_source_v2_steps_and_writes_operation_artifacts(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.basic_configure_v2" +channel = 1 +waveform_kind = "square" +frequency_hz = 1000 +amplitude_vpp = 1.5 + +[[steps]] +kind = "source.output_enable_v2" +channel = 1 + +[[steps]] +kind = "source.output_disable_v2" +channel = 1 +""", + ) + ) + artifacts = [ + { + "schema": "wavebench.source.operation.v1", + "operation": "source.basic_configure_v2", + }, + { + "schema": "wavebench.source.operation.v1", + "operation": "source.output_enable_v2", + }, + { + "schema": "wavebench.source.operation.v1", + "operation": "source.output_disable_v2", + }, + ] + source = Mock() + source.configure_basic_v2.return_value = (SimpleNamespace(), artifacts[0]) + source.set_output_v2.side_effect = [ + (SimpleNamespace(), artifacts[1]), + (SimpleNamespace(), artifacts[2]), + ] + + class OfflineV2RunService(RunService): + def check(self, plan): + del plan + + @contextmanager + def _run_instrument_services(self, plan): + del plan + yield RunInstrumentServices(source=source) + + def _run_safety_guards(self, plan, *, services=None): + del plan, services + + result = OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan) + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + + request = source.configure_basic_v2.call_args.args[0] + self.assertEqual(request.channel, 1) + self.assertEqual(request.patch.waveform_kind.value.value, "square") + self.assertEqual(request.patch.frequency_hz.value, 1000.0) + self.assertEqual(request.patch.amplitude_vpp.value, 1.5) + self.assertEqual( + [ + (item.args[0].channel, item.args[0].enabled) + for item in source.set_output_v2.call_args_list + ], + [(1, True), (1, False)], + ) + self.assertEqual(run_data["source_operations"], artifacts) + self.assertEqual( + [record.artifact["source_operation"] for record in result.steps], + artifacts, + ) + + def test_source_v2_failure_artifact_is_written_to_run_root(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.basic_configure_v2" +channel = 1 +frequency_hz = 1000 +""", + ) + ) + artifact = { + "schema": "wavebench.source.operation.v1", + "operation": "source.basic_configure_v2", + "recovery": {"status": "off_verified"}, + } + failure = ConfigError("postcondition mismatch") + failure.source_operation_artifact = artifact + source = Mock() + source.configure_basic_v2.side_effect = failure + run_dir = Path(tmp) / "source-v2-failure" + + class OfflineV2RunService(RunService): + def check(self, plan): + del plan + + @contextmanager + def _run_instrument_services(self, plan): + del plan + yield RunInstrumentServices(source=source) + + def _run_safety_guards(self, plan, *, services=None): + del plan, services + + with patch("wavebench.services.run_service.new_package_dir", return_value=run_dir): + with self.assertRaisesRegex(ConfigError, "postcondition mismatch"): + OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan) + + run_data = json.loads((run_dir / "run.json").read_text(encoding="utf-8")) + self.assertEqual(run_data["source_operations"], [artifact]) + def test_restores_source_state_after_success_when_enabled(self): with TemporaryDirectory() as tmp: plan = load_run_plan( From 40f2e3bc129d3391521a94f9c25a0721689e8269 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:22:24 +0800 Subject: [PATCH 20/82] test: cover source v2 dual contract routes --- tests/test_source_basic_configure_v2.py | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py index 7d0085a..064f683 100644 --- a/tests/test_source_basic_configure_v2.py +++ b/tests/test_source_basic_configure_v2.py @@ -343,6 +343,39 @@ def test_v1_frequency_route_maps_to_v2_for_a_dual_contract_driver() -> None: assert driver.transport.counters.write_completed == 1 +@pytest.mark.parametrize( + ("method", "value", "patch_field", "expected"), + ( + ("set_frequency", 2_000.0, "frequency_hz", 2_000.0), + ("set_function", "SIN", "waveform_kind", "sine"), + ("set_amplitude_vpp", 1.5, "amplitude_vpp", 1.5), + ("set_square_duty_cycle", 25.0, "square_duty_cycle_percent", 25.0), + ), +) +def test_all_v1_basic_routes_use_the_v2_transaction_when_declared( + method: str, + value: object, + patch_field: str, + expected: object, +) -> None: + service, driver = _service() + + if method == "set_frequency": + service.set_frequency(channel=1, value_hz=value) + elif method == "set_function": + service.set_function(channel=1, function=value) + elif method == "set_amplitude_vpp": + service.set_amplitude_vpp(channel=1, value_vpp=value) + else: + service.set_square_duty_cycle(channel=1, duty_percent=value) + + assert len(driver.basic_requests) == 1 + patch_value = getattr(driver.basic_requests[0].patch, patch_field) + assert patch_value.action is PatchAction.SET + assert getattr(patch_value.value, "value", patch_value.value) == expected + assert driver.transport.counters.write_completed == 1 + + def test_v1_restore_route_rejects_before_io_for_a_dual_contract_driver() -> None: service, driver = _service() @@ -361,6 +394,29 @@ def test_v1_restore_route_rejects_before_io_for_a_dual_contract_driver() -> None assert driver.transport.counters.write_requests == 0 +@pytest.mark.parametrize( + "operation", + ("upload", "trigger_burst", "trigger_sweep"), +) +def test_overlapping_v1_routes_reject_before_io_for_a_dual_contract_driver(operation: str) -> None: + service, driver = _service() + + with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"): + if operation == "upload": + service.upload_arbitrary_waveform( + channel=1, + file_path="unused.npy", + playback_frequency_hz=1_000.0, + amplitude_vpp=1.0, + ) + elif operation == "trigger_burst": + service.trigger_burst(channel=1) + else: + service.trigger_sweep(channel=1) + + assert driver.transport.counters.write_requests == 0 + + def test_basic_configure_v2_rejects_target_output_on_before_write() -> None: service, driver = _service(output_enabled=True) From 4fb63bc8aea606184f85a847b004c5375e19f01d Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:25:37 +0800 Subject: [PATCH 21/82] docs: document source v2 write entry points --- ...77\347\224\250\346\214\207\345\215\227.md" | 27 ++++++++ ...23\345\207\272\346\240\274\345\274\217.md" | 13 ++-- ...07\344\273\266\346\240\274\345\274\217.md" | 23 +++---- ...345\231\250\346\217\222\344\273\266API.md" | 27 ++++++-- ...345\207\272\345\256\211\345\205\250RFC.md" | 61 ++++++++++++++++++- 5 files changed, 127 insertions(+), 24 deletions(-) diff --git "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" index edd8049..e7eaf25 100644 --- "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -361,6 +361,32 @@ state = "on" 这看起来啰嗦,但它能避免现场调试时被隐藏动作吓到。 +### Source V2 基础写 step + +声明 `source.snapshot_v2` 与对应写 capability 的插件可以使用三个 Source V2 step。基础配置只在目标输出已关闭时执行;输出 ON 与 OFF 分别使用不同 step: + +```toml +[[steps]] +kind = "source.basic_configure_v2" +channel = 1 +waveform_kind = "sine" +frequency_hz = 1000 +amplitude_vpp = 1.0 +offset_v = 0.0 + +[[steps]] +kind = "source.output_enable_v2" +channel = 1 + +[[steps]] +kind = "source.output_disable_v2" +channel = 1 +``` + +`source.basic_configure_v2` 的 `channel` 必填,五个 basic 字段中至少写一个;缺失字段保持当前值。 +`source.output_enable_v2` 和 `source.output_disable_v2` 都只接受 `channel`。执行意图会分别记录三个 operation, +实际执行时的完整 Source V2 artifact 会写入 `run.json.source_operations`。没有声明 V2 capability 的旧插件继续使用 V1 step。 + ## 常见 `run check` 报错 ### step kind 拼错 @@ -493,6 +519,7 @@ data/runs/YYYYMMDD_HHMMSS_