testerkit 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (521) hide show
  1. testerkit/__init__.py +68 -0
  2. testerkit/_docs/_assets/operator-ui/channels/table.png +0 -0
  3. testerkit/_docs/_assets/operator-ui/dashboard/runs.png +0 -0
  4. testerkit/_docs/_assets/operator-ui/dashboard/stations.png +0 -0
  5. testerkit/_docs/_assets/operator-ui/events/filters.png +0 -0
  6. testerkit/_docs/_assets/operator-ui/events/table.png +0 -0
  7. testerkit/_docs/_assets/operator-ui/explore/chart.png +0 -0
  8. testerkit/_docs/_assets/operator-ui/explore/filters.png +0 -0
  9. testerkit/_docs/_assets/operator-ui/explore/plot-controls.png +0 -0
  10. testerkit/_docs/_assets/operator-ui/files/table.png +0 -0
  11. testerkit/_docs/_assets/operator-ui/launch/form.png +0 -0
  12. testerkit/_docs/_assets/operator-ui/metrics/filters.png +0 -0
  13. testerkit/_docs/_assets/operator-ui/metrics/pareto.png +0 -0
  14. testerkit/_docs/_assets/operator-ui/metrics/ppk.png +0 -0
  15. testerkit/_docs/_assets/operator-ui/metrics/yield.png +0 -0
  16. testerkit/_docs/_assets/operator-ui/results/detail-header.png +0 -0
  17. testerkit/_docs/_assets/operator-ui/results/detail-measurements.png +0 -0
  18. testerkit/_docs/_assets/operator-ui/results/detail-overview.png +0 -0
  19. testerkit/_docs/_assets/operator-ui/results/detail-steps.png +0 -0
  20. testerkit/_docs/_assets/operator-ui/results/stats.png +0 -0
  21. testerkit/_docs/_assets/operator-ui/results/table.png +0 -0
  22. testerkit/_docs/_assets/operator-ui/tour/designer.png +0 -0
  23. testerkit/_docs/_assets/operator-ui/tour/docs.png +0 -0
  24. testerkit/_docs/_assets/operator-ui/tour/fixtures.png +0 -0
  25. testerkit/_docs/_assets/operator-ui/tour/instruments.png +0 -0
  26. testerkit/_docs/_assets/operator-ui/tour/parts.png +0 -0
  27. testerkit/_docs/_assets/operator-ui/tour/profiles.png +0 -0
  28. testerkit/_docs/_assets/operator-ui/tour/stations.png +0 -0
  29. testerkit/_docs/_assets/operator-ui/tour/tests.png +0 -0
  30. testerkit/_docs/_assets/operator-ui/tour/uuts.png +0 -0
  31. testerkit/_docs/concepts/configuration/capabilities.md +447 -0
  32. testerkit/_docs/concepts/configuration/fixtures.md +335 -0
  33. testerkit/_docs/concepts/configuration/index.md +14 -0
  34. testerkit/_docs/concepts/configuration/parts.md +276 -0
  35. testerkit/_docs/concepts/configuration/stations.md +214 -0
  36. testerkit/_docs/concepts/data/data-stores.md +199 -0
  37. testerkit/_docs/concepts/data/event-log.md +185 -0
  38. testerkit/_docs/concepts/data/event-sourcing.md +76 -0
  39. testerkit/_docs/concepts/data/flight-streaming.md +58 -0
  40. testerkit/_docs/concepts/data/index.md +17 -0
  41. testerkit/_docs/concepts/data/sessions.md +78 -0
  42. testerkit/_docs/concepts/data/three-verbs.md +260 -0
  43. testerkit/_docs/concepts/execution/index.md +13 -0
  44. testerkit/_docs/concepts/execution/outcomes.md +199 -0
  45. testerkit/_docs/concepts/execution/step-hierarchy.md +170 -0
  46. testerkit/_docs/concepts/execution/step-manifest.md +115 -0
  47. testerkit/_docs/concepts/index.md +40 -0
  48. testerkit/_docs/concepts/overview/ai-integration.md +57 -0
  49. testerkit/_docs/concepts/overview/architecture.md +357 -0
  50. testerkit/_docs/concepts/overview/index.md +14 -0
  51. testerkit/_docs/concepts/overview/platform-vs-framework.md +147 -0
  52. testerkit/_docs/concepts/overview/pytest.md +53 -0
  53. testerkit/_docs/concepts/overview/tiers.md +46 -0
  54. testerkit/_docs/how-to/catalog/datasheet-to-test.md +169 -0
  55. testerkit/_docs/how-to/catalog/index.md +11 -0
  56. testerkit/_docs/how-to/configuration/configuring-stations.md +364 -0
  57. testerkit/_docs/how-to/configuration/custom-drivers.md +484 -0
  58. testerkit/_docs/how-to/configuration/index.md +13 -0
  59. testerkit/_docs/how-to/configuration/mock-mode.md +342 -0
  60. testerkit/_docs/how-to/data/benchmarking.md +120 -0
  61. testerkit/_docs/how-to/data/capture-an-artifact.md +84 -0
  62. testerkit/_docs/how-to/data/capture-waveform.md +110 -0
  63. testerkit/_docs/how-to/data/choosing-a-channel-verb.md +108 -0
  64. testerkit/_docs/how-to/data/compare-runs.md +120 -0
  65. testerkit/_docs/how-to/data/export-results.md +104 -0
  66. testerkit/_docs/how-to/data/find-flaky-tests.md +108 -0
  67. testerkit/_docs/how-to/data/grafana-dashboards.md +148 -0
  68. testerkit/_docs/how-to/data/index.md +23 -0
  69. testerkit/_docs/how-to/data/mcp-debug-failures.md +125 -0
  70. testerkit/_docs/how-to/data/mcp-query-runs.md +120 -0
  71. testerkit/_docs/how-to/data/querying-channels.md +117 -0
  72. testerkit/_docs/how-to/data/querying-events.md +85 -0
  73. testerkit/_docs/how-to/data/stream-live-channel.md +134 -0
  74. testerkit/_docs/how-to/execution/custom-operator-ui.md +136 -0
  75. testerkit/_docs/how-to/execution/index.md +21 -0
  76. testerkit/_docs/how-to/execution/limits.md +193 -0
  77. testerkit/_docs/how-to/execution/managing-sessions.md +100 -0
  78. testerkit/_docs/how-to/execution/multi-uut-testing.md +211 -0
  79. testerkit/_docs/how-to/execution/operator-prompts.md +151 -0
  80. testerkit/_docs/how-to/execution/profiles.md +304 -0
  81. testerkit/_docs/how-to/execution/spec-driven-testing.md +169 -0
  82. testerkit/_docs/how-to/execution/test-context.md +212 -0
  83. testerkit/_docs/how-to/execution/traceability.md +252 -0
  84. testerkit/_docs/how-to/execution/vector-expansion.md +394 -0
  85. testerkit/_docs/how-to/execution/writing-tests.md +363 -0
  86. testerkit/_docs/how-to/index.md +59 -0
  87. testerkit/_docs/how-to/overview/index.md +11 -0
  88. testerkit/_docs/how-to/overview/mcp-integration.md +168 -0
  89. testerkit/_docs/how-to/overview/operator-ui-tour.md +234 -0
  90. testerkit/_docs/integration/data/grafana.md +162 -0
  91. testerkit/_docs/integration/data/index.md +15 -0
  92. testerkit/_docs/integration/data/lakehouse-import.md +226 -0
  93. testerkit/_docs/integration/data/logging.md +131 -0
  94. testerkit/_docs/integration/data/results-api.md +51 -0
  95. testerkit/_docs/integration/index.md +20 -0
  96. testerkit/_docs/integration/runtime/harness.md +281 -0
  97. testerkit/_docs/integration/runtime/index.md +13 -0
  98. testerkit/_docs/integration/runtime/instruments.md +284 -0
  99. testerkit/_docs/integration/runtime/pytest-existing.md +315 -0
  100. testerkit/_docs/reference/catalog/cookbook.md +389 -0
  101. testerkit/_docs/reference/catalog/index.md +11 -0
  102. testerkit/_docs/reference/catalog/schema.md +375 -0
  103. testerkit/_docs/reference/cli.md +660 -0
  104. testerkit/_docs/reference/configuration.md +406 -0
  105. testerkit/_docs/reference/data/channels-schema.md +145 -0
  106. testerkit/_docs/reference/data/event-types.md +554 -0
  107. testerkit/_docs/reference/data/events-schema.md +120 -0
  108. testerkit/_docs/reference/data/files-schema.md +141 -0
  109. testerkit/_docs/reference/data/index.md +18 -0
  110. testerkit/_docs/reference/data/models.md +2229 -0
  111. testerkit/_docs/reference/data/outputs.md +82 -0
  112. testerkit/_docs/reference/data/parquet-schema.md +529 -0
  113. testerkit/_docs/reference/data/performance-limits.md +224 -0
  114. testerkit/_docs/reference/data/query-api.md +247 -0
  115. testerkit/_docs/reference/index.md +69 -0
  116. testerkit/_docs/reference/operator-ui/channels/detail.md +82 -0
  117. testerkit/_docs/reference/operator-ui/channels/list.md +70 -0
  118. testerkit/_docs/reference/operator-ui/dashboard.md +87 -0
  119. testerkit/_docs/reference/operator-ui/designer.md +167 -0
  120. testerkit/_docs/reference/operator-ui/events.md +89 -0
  121. testerkit/_docs/reference/operator-ui/files.md +74 -0
  122. testerkit/_docs/reference/operator-ui/fixtures.md +100 -0
  123. testerkit/_docs/reference/operator-ui/instruments.md +126 -0
  124. testerkit/_docs/reference/operator-ui/launch.md +79 -0
  125. testerkit/_docs/reference/operator-ui/live.md +102 -0
  126. testerkit/_docs/reference/operator-ui/measurements.md +159 -0
  127. testerkit/_docs/reference/operator-ui/metrics.md +217 -0
  128. testerkit/_docs/reference/operator-ui/parts.md +98 -0
  129. testerkit/_docs/reference/operator-ui/profiles.md +103 -0
  130. testerkit/_docs/reference/operator-ui/results/detail.md +214 -0
  131. testerkit/_docs/reference/operator-ui/results/list.md +106 -0
  132. testerkit/_docs/reference/operator-ui/stations.md +98 -0
  133. testerkit/_docs/reference/operator-ui/tests.md +173 -0
  134. testerkit/_docs/reference/operator-ui/uuts.md +67 -0
  135. testerkit/_docs/reference/overview/index.md +11 -0
  136. testerkit/_docs/reference/overview/pytest-native.md +142 -0
  137. testerkit/_docs/reference/overview/skills.md +177 -0
  138. testerkit/_docs/reference/pytest/fixtures.md +342 -0
  139. testerkit/_docs/reference/pytest/index.md +12 -0
  140. testerkit/_docs/reference/pytest/markers.md +257 -0
  141. testerkit/_docs/reference/runtime/api.md +246 -0
  142. testerkit/_docs/reference/runtime/client.md +280 -0
  143. testerkit/_docs/reference/runtime/connect.md +140 -0
  144. testerkit/_docs/reference/runtime/index.md +13 -0
  145. testerkit/_docs/tutorial/01-first-test.md +113 -0
  146. testerkit/_docs/tutorial/02-mock-instruments.md +88 -0
  147. testerkit/_docs/tutorial/03-fixtures.md +169 -0
  148. testerkit/_docs/tutorial/04-limits.md +139 -0
  149. testerkit/_docs/tutorial/05-configuration.md +194 -0
  150. testerkit/_docs/tutorial/06-specifications.md +273 -0
  151. testerkit/_docs/tutorial/07-real-instruments.md +245 -0
  152. testerkit/_docs/tutorial/08-capabilities.md +223 -0
  153. testerkit/_docs/tutorial/09-production.md +374 -0
  154. testerkit/_docs/tutorial/10-live-monitoring.md +156 -0
  155. testerkit/_docs/tutorial/11-waveforms-and-evidence.md +169 -0
  156. testerkit/_docs/tutorial/12-continuous-monitoring.md +145 -0
  157. testerkit/_docs/tutorial/13-parallel-testing.md +233 -0
  158. testerkit/_docs/tutorial/index.md +61 -0
  159. testerkit/_docs/tutorial/quickstart.md +282 -0
  160. testerkit/analysis/__init__.py +9 -0
  161. testerkit/analysis/_common.py +24 -0
  162. testerkit/analysis/measurement_facets.py +524 -0
  163. testerkit/analysis/measurements_query.py +1458 -0
  164. testerkit/analysis/metrics.py +389 -0
  165. testerkit/analysis/runs_query.py +434 -0
  166. testerkit/analysis/steps_query.py +413 -0
  167. testerkit/api/__init__.py +6 -0
  168. testerkit/api/_mime.py +71 -0
  169. testerkit/api/app.py +1171 -0
  170. testerkit/api/dialogs/__init__.py +36 -0
  171. testerkit/api/dialogs/manager.py +617 -0
  172. testerkit/api/dialogs/models.py +79 -0
  173. testerkit/api/models.py +80 -0
  174. testerkit/api/responses.py +205 -0
  175. testerkit/api/runner.py +222 -0
  176. testerkit/api/schemas.py +311 -0
  177. testerkit/benchmark/__init__.py +34 -0
  178. testerkit/benchmark/concurrency.py +248 -0
  179. testerkit/benchmark/core.py +232 -0
  180. testerkit/benchmark/runner.py +628 -0
  181. testerkit/benchmark/scenario.py +313 -0
  182. testerkit/benchmark/system.py +191 -0
  183. testerkit/benchmark/workloads.py +507 -0
  184. testerkit/catalog/__init__.py +6 -0
  185. testerkit/catalog/generic/generic_dmm.yaml +61 -0
  186. testerkit/catalog/generic/generic_eload.yaml +31 -0
  187. testerkit/catalog/generic/generic_fgen.yaml +56 -0
  188. testerkit/catalog/generic/generic_oscilloscope.yaml +45 -0
  189. testerkit/catalog/generic/generic_psu.yaml +77 -0
  190. testerkit/catalog/generic/generic_smu.yaml +73 -0
  191. testerkit/channels.py +538 -0
  192. testerkit/cli/__init__.py +32 -0
  193. testerkit/cli/_common.py +113 -0
  194. testerkit/cli/_time.py +179 -0
  195. testerkit/cli/benchmark_cmd.py +56 -0
  196. testerkit/cli/catalog_cmd.py +39 -0
  197. testerkit/cli/daemon.py +157 -0
  198. testerkit/cli/data_cmd.py +1040 -0
  199. testerkit/cli/discover_cmd.py +94 -0
  200. testerkit/cli/docs_cmd.py +100 -0
  201. testerkit/cli/instrument.py +172 -0
  202. testerkit/cli/mcp_cmd.py +37 -0
  203. testerkit/cli/metrics.py +366 -0
  204. testerkit/cli/project.py +312 -0
  205. testerkit/cli/root.py +14 -0
  206. testerkit/cli/runs.py +412 -0
  207. testerkit/cli/schema_cmd.py +87 -0
  208. testerkit/cli/serve_cmd.py +50 -0
  209. testerkit/cli/setup_cmd.py +604 -0
  210. testerkit/cli/station.py +294 -0
  211. testerkit/cli/validate.py +112 -0
  212. testerkit/client.py +463 -0
  213. testerkit/connect.py +635 -0
  214. testerkit/data/__init__.py +8 -0
  215. testerkit/data/_accumulator_pool.py +455 -0
  216. testerkit/data/_atomic.py +59 -0
  217. testerkit/data/_collection_indices.py +67 -0
  218. testerkit/data/_daemon_lifecycle.py +535 -0
  219. testerkit/data/_duckdb_daemon.py +695 -0
  220. testerkit/data/_duckdb_flight_server.py +813 -0
  221. testerkit/data/_event_filters.py +15 -0
  222. testerkit/data/_event_reader.py +101 -0
  223. testerkit/data/_flight_errors.py +199 -0
  224. testerkit/data/_flight_query.py +202 -0
  225. testerkit/data/_flight_retry.py +148 -0
  226. testerkit/data/_flight_subscribe.py +67 -0
  227. testerkit/data/_index_epoch.py +342 -0
  228. testerkit/data/_ipc_writer.py +185 -0
  229. testerkit/data/_json_safe.py +63 -0
  230. testerkit/data/_process.py +18 -0
  231. testerkit/data/_push_relay.py +124 -0
  232. testerkit/data/_runs_duckdb_daemon.py +2704 -0
  233. testerkit/data/_session_reaper.py +110 -0
  234. testerkit/data/_sql_helpers.py +30 -0
  235. testerkit/data/_store.py +24 -0
  236. testerkit/data/backends/__init__.py +7 -0
  237. testerkit/data/backends/_event_accumulator.py +957 -0
  238. testerkit/data/backends/_row_helpers.py +1172 -0
  239. testerkit/data/backends/parquet.py +1143 -0
  240. testerkit/data/backends/protocol.py +35 -0
  241. testerkit/data/channels/__init__.py +8 -0
  242. testerkit/data/channels/_flight_daemon.py +75 -0
  243. testerkit/data/channels/client.py +242 -0
  244. testerkit/data/channels/flight_manager.py +88 -0
  245. testerkit/data/channels/index.py +664 -0
  246. testerkit/data/channels/models.py +418 -0
  247. testerkit/data/channels/server.py +162 -0
  248. testerkit/data/channels/store.py +1481 -0
  249. testerkit/data/data_dir.py +61 -0
  250. testerkit/data/duckdb_manager.py +79 -0
  251. testerkit/data/event_log.py +384 -0
  252. testerkit/data/event_store.py +768 -0
  253. testerkit/data/events.py +1025 -0
  254. testerkit/data/exporters/__init__.py +6 -0
  255. testerkit/data/exporters/_helpers.py +40 -0
  256. testerkit/data/exporters/csv_exporter.py +168 -0
  257. testerkit/data/exporters/hdf5.py +234 -0
  258. testerkit/data/exporters/json_exporter.py +229 -0
  259. testerkit/data/exporters/mdf4.py +201 -0
  260. testerkit/data/exporters/stdf.py +255 -0
  261. testerkit/data/exporters/tdms.py +292 -0
  262. testerkit/data/files/__init__.py +71 -0
  263. testerkit/data/files/_backend.py +207 -0
  264. testerkit/data/files/_catalog_daemon.py +84 -0
  265. testerkit/data/files/catalog.py +218 -0
  266. testerkit/data/files/catalog_manager.py +325 -0
  267. testerkit/data/files/models.py +60 -0
  268. testerkit/data/files/serializers.py +414 -0
  269. testerkit/data/files/store.py +463 -0
  270. testerkit/data/files/streaming.py +783 -0
  271. testerkit/data/models.py +604 -0
  272. testerkit/data/ref.py +152 -0
  273. testerkit/data/retention.py +297 -0
  274. testerkit/data/run_store.py +367 -0
  275. testerkit/data/runs_duckdb_manager.py +103 -0
  276. testerkit/data/schema_dispatch.py +191 -0
  277. testerkit/data/schema_migrate.py +76 -0
  278. testerkit/data/schema_versions.py +85 -0
  279. testerkit/data/schemas.py +288 -0
  280. testerkit/data/subscribers/__init__.py +48 -0
  281. testerkit/data/subscribers/_base.py +20 -0
  282. testerkit/data/subscribers/_output_file.py +25 -0
  283. testerkit/data/subscribers/replay.py +55 -0
  284. testerkit/environment.py +84 -0
  285. testerkit/execution/__init__.py +11 -0
  286. testerkit/execution/_git.py +190 -0
  287. testerkit/execution/_state.py +740 -0
  288. testerkit/execution/accessors.py +54 -0
  289. testerkit/execution/audit.py +51 -0
  290. testerkit/execution/cascade.py +85 -0
  291. testerkit/execution/connections.py +491 -0
  292. testerkit/execution/harness.py +1931 -0
  293. testerkit/execution/instrument_events.py +42 -0
  294. testerkit/execution/limits.py +154 -0
  295. testerkit/execution/metadata.py +102 -0
  296. testerkit/execution/mocks.py +63 -0
  297. testerkit/execution/profiles.py +616 -0
  298. testerkit/execution/run_scope.py +1331 -0
  299. testerkit/execution/session_scope.py +196 -0
  300. testerkit/execution/sidecar.py +280 -0
  301. testerkit/execution/site_runner.py +705 -0
  302. testerkit/execution/sites.py +183 -0
  303. testerkit/execution/sync.py +294 -0
  304. testerkit/execution/uut_provider.py +231 -0
  305. testerkit/execution/vectors.py +205 -0
  306. testerkit/execution/verify.py +396 -0
  307. testerkit/expand.py +75 -0
  308. testerkit/files.py +235 -0
  309. testerkit/fixtures/__init__.py +6 -0
  310. testerkit/fixtures/manager.py +357 -0
  311. testerkit/grafana/__init__.py +1 -0
  312. testerkit/grafana/bootstrap.py +61 -0
  313. testerkit/grafana/cli.py +314 -0
  314. testerkit/grafana/dashboards/asset_utilization.json +92 -0
  315. testerkit/grafana/dashboards/channel_explorer.json +77 -0
  316. testerkit/grafana/dashboards/event_log.json +92 -0
  317. testerkit/grafana/dashboards/failure_pareto.json +104 -0
  318. testerkit/grafana/dashboards/measurement_distribution.json +97 -0
  319. testerkit/grafana/dashboards/measurement_trend.json +161 -0
  320. testerkit/grafana/dashboards/station_comparison.json +95 -0
  321. testerkit/grafana/dashboards/test_duration.json +104 -0
  322. testerkit/grafana/dashboards/unit_traceability.json +73 -0
  323. testerkit/grafana/dashboards/yield_overview.json +189 -0
  324. testerkit/grafana/provisioning/dashboards.yaml.j2 +12 -0
  325. testerkit/grafana/provisioning/datasources.yaml.j2 +17 -0
  326. testerkit/grafana/server.py +282 -0
  327. testerkit/init.py +724 -0
  328. testerkit/instruments/__init__.py +10 -0
  329. testerkit/instruments/base.py +82 -0
  330. testerkit/instruments/discovery/__init__.py +62 -0
  331. testerkit/instruments/discovery/_base.py +174 -0
  332. testerkit/instruments/discovery/lxi.py +152 -0
  333. testerkit/instruments/discovery/ni.py +95 -0
  334. testerkit/instruments/discovery/serial.py +73 -0
  335. testerkit/instruments/discovery/visa.py +98 -0
  336. testerkit/instruments/lifecycle.py +213 -0
  337. testerkit/instruments/loader.py +79 -0
  338. testerkit/instruments/locks.py +178 -0
  339. testerkit/instruments/mocks.py +230 -0
  340. testerkit/instruments/observer.py +270 -0
  341. testerkit/instruments/observers/__init__.py +36 -0
  342. testerkit/instruments/observers/_base.py +68 -0
  343. testerkit/instruments/observers/daqmx.py +70 -0
  344. testerkit/instruments/observers/descriptor.py +69 -0
  345. testerkit/instruments/observers/generic.py +92 -0
  346. testerkit/instruments/observers/lantz.py +31 -0
  347. testerkit/instruments/observers/modbus.py +96 -0
  348. testerkit/instruments/observers/motion.py +68 -0
  349. testerkit/instruments/observers/ni_modular.py +73 -0
  350. testerkit/instruments/observers/ophyd.py +80 -0
  351. testerkit/instruments/observers/pymeasure.py +120 -0
  352. testerkit/instruments/observers/qcodes.py +66 -0
  353. testerkit/instruments/observers/scpi.py +88 -0
  354. testerkit/instruments/observers/tektronix.py +57 -0
  355. testerkit/instruments/observers/visa.py +103 -0
  356. testerkit/instruments/pool.py +459 -0
  357. testerkit/instruments/proxy.py +59 -0
  358. testerkit/instruments/route_manager.py +329 -0
  359. testerkit/instruments/routed_proxy.py +88 -0
  360. testerkit/instruments/server.py +508 -0
  361. testerkit/instruments/switch.py +43 -0
  362. testerkit/instruments/visa.py +294 -0
  363. testerkit/matching/__init__.py +6 -0
  364. testerkit/matching/service.py +969 -0
  365. testerkit/mcp/__init__.py +6 -0
  366. testerkit/mcp/server.py +681 -0
  367. testerkit/mcp/tools.py +1868 -0
  368. testerkit/models/__init__.py +18 -0
  369. testerkit/models/capability.py +598 -0
  370. testerkit/models/catalog.py +76 -0
  371. testerkit/models/data_options.py +153 -0
  372. testerkit/models/enums.py +334 -0
  373. testerkit/models/instrument.py +152 -0
  374. testerkit/models/instrument_asset.py +25 -0
  375. testerkit/models/part.py +271 -0
  376. testerkit/models/part_manifest.py +96 -0
  377. testerkit/models/project.py +114 -0
  378. testerkit/models/station.py +140 -0
  379. testerkit/models/test_config.py +748 -0
  380. testerkit/ontology/__init__.py +39 -0
  381. testerkit/ontology/schema.py +188 -0
  382. testerkit/ontology/testerkit.yaml +1420 -0
  383. testerkit/parts/__init__.py +8 -0
  384. testerkit/parts/context.py +181 -0
  385. testerkit/parts/folder.py +251 -0
  386. testerkit/parts/loader.py +27 -0
  387. testerkit/prompts/__init__.py +17 -0
  388. testerkit/prompts/core.py +133 -0
  389. testerkit/pytest_plugin/__init__.py +1287 -0
  390. testerkit/pytest_plugin/autouse.py +369 -0
  391. testerkit/pytest_plugin/helpers.py +354 -0
  392. testerkit/pytest_plugin/hooks.py +1982 -0
  393. testerkit/pytest_plugin/markers.py +203 -0
  394. testerkit/pytest_plugin/retry.py +37 -0
  395. testerkit/pytest_plugin/sweeps.py +117 -0
  396. testerkit/queries.py +78 -0
  397. testerkit/reports/__init__.py +6 -0
  398. testerkit/reports/core.py +492 -0
  399. testerkit/reports/datasheet.py +831 -0
  400. testerkit/reports/templates/datasheet.html +383 -0
  401. testerkit/reports/templates/default.html +153 -0
  402. testerkit/sbom.py +142 -0
  403. testerkit/schema_export.py +101 -0
  404. testerkit/signals.py +63 -0
  405. testerkit/skills/README.md +58 -0
  406. testerkit/skills/templates/project-instructions.md +125 -0
  407. testerkit/skills/testerkit-analysis/SKILL.md +87 -0
  408. testerkit/skills/testerkit-capture/SKILL.md +114 -0
  409. testerkit/skills/testerkit-data/SKILL.md +138 -0
  410. testerkit/skills/testerkit-datasheets/SKILL.md +100 -0
  411. testerkit/skills/testerkit-datasheets/agents/scaffold-writer.md +115 -0
  412. testerkit/skills/testerkit-datasheets/agents/section-extractor.md +98 -0
  413. testerkit/skills/testerkit-datasheets/agents/section-reviewer.md +153 -0
  414. testerkit/skills/testerkit-datasheets/agents/section-splitter.md +89 -0
  415. testerkit/skills/testerkit-datasheets/agents/section-writer.md +226 -0
  416. testerkit/skills/testerkit-datasheets/references/catalog-pipeline.md +271 -0
  417. testerkit/skills/testerkit-datasheets/references/process-queue.md +42 -0
  418. testerkit/skills/testerkit-datasheets/references/scaffold.md +156 -0
  419. testerkit/skills/testerkit-datasheets/references/test-pipeline.md +312 -0
  420. testerkit/skills/testerkit-debug/SKILL.md +127 -0
  421. testerkit/skills/testerkit-interactive/SKILL.md +188 -0
  422. testerkit/skills/testerkit-interactive/references/live-ui-patterns.md +102 -0
  423. testerkit/skills/testerkit-mocks/SKILL.md +128 -0
  424. testerkit/skills/testerkit-parts/SKILL.md +126 -0
  425. testerkit/skills/testerkit-profiles/SKILL.md +139 -0
  426. testerkit/skills/testerkit-sites/SKILL.md +140 -0
  427. testerkit/skills/testerkit-stations/SKILL.md +146 -0
  428. testerkit/skills/testerkit-tests/SKILL.md +136 -0
  429. testerkit/store.py +1639 -0
  430. testerkit/ui/__init__.py +71 -0
  431. testerkit/ui/_asgi.py +162 -0
  432. testerkit/ui/app.py +32 -0
  433. testerkit/ui/components/__init__.py +13 -0
  434. testerkit/ui/components/artifact_viewer.py +250 -0
  435. testerkit/ui/components/channel_values.py +159 -0
  436. testerkit/ui/components/event_timeline.py +222 -0
  437. testerkit/ui/components/execution_gantt.py +210 -0
  438. testerkit/ui/components/file_streams.py +147 -0
  439. testerkit/ui/components/instrument_activity.py +146 -0
  440. testerkit/ui/components/session_table.py +118 -0
  441. testerkit/ui/pages/__init__.py +25 -0
  442. testerkit/ui/pages/channels/__init__.py +4 -0
  443. testerkit/ui/pages/channels/detail.py +932 -0
  444. testerkit/ui/pages/channels/list.py +515 -0
  445. testerkit/ui/pages/dashboard.py +236 -0
  446. testerkit/ui/pages/designer/__init__.py +5 -0
  447. testerkit/ui/pages/designer/graph.py +635 -0
  448. testerkit/ui/pages/designer/matching.py +663 -0
  449. testerkit/ui/pages/designer/page.py +593 -0
  450. testerkit/ui/pages/designer/properties.py +591 -0
  451. testerkit/ui/pages/designer/state.py +468 -0
  452. testerkit/ui/pages/docs/__init__.py +6 -0
  453. testerkit/ui/pages/docs/index.py +95 -0
  454. testerkit/ui/pages/docs/page.py +698 -0
  455. testerkit/ui/pages/events/__init__.py +3 -0
  456. testerkit/ui/pages/events/list.py +263 -0
  457. testerkit/ui/pages/explore.py +1108 -0
  458. testerkit/ui/pages/files/__init__.py +4 -0
  459. testerkit/ui/pages/files/detail.py +426 -0
  460. testerkit/ui/pages/files/list.py +447 -0
  461. testerkit/ui/pages/fixtures/__init__.py +8 -0
  462. testerkit/ui/pages/fixtures/detail.py +280 -0
  463. testerkit/ui/pages/fixtures/edit.py +370 -0
  464. testerkit/ui/pages/fixtures/list.py +180 -0
  465. testerkit/ui/pages/fixtures/new.py +195 -0
  466. testerkit/ui/pages/instruments/__init__.py +4 -0
  467. testerkit/ui/pages/instruments/detail.py +320 -0
  468. testerkit/ui/pages/instruments/edit.py +482 -0
  469. testerkit/ui/pages/instruments/list.py +249 -0
  470. testerkit/ui/pages/instruments/new.py +200 -0
  471. testerkit/ui/pages/launch.py +261 -0
  472. testerkit/ui/pages/live.py +124 -0
  473. testerkit/ui/pages/metrics_page.py +1229 -0
  474. testerkit/ui/pages/parts/__init__.py +11 -0
  475. testerkit/ui/pages/parts/detail.py +202 -0
  476. testerkit/ui/pages/parts/edit.py +310 -0
  477. testerkit/ui/pages/parts/list.py +160 -0
  478. testerkit/ui/pages/parts/new.py +170 -0
  479. testerkit/ui/pages/parts/requirements.py +153 -0
  480. testerkit/ui/pages/parts/stations.py +199 -0
  481. testerkit/ui/pages/profiles/__init__.py +4 -0
  482. testerkit/ui/pages/profiles/detail.py +113 -0
  483. testerkit/ui/pages/profiles/list.py +86 -0
  484. testerkit/ui/pages/results/__init__.py +4 -0
  485. testerkit/ui/pages/results/detail.py +631 -0
  486. testerkit/ui/pages/results/list.py +230 -0
  487. testerkit/ui/pages/stations/__init__.py +4 -0
  488. testerkit/ui/pages/stations/detail.py +256 -0
  489. testerkit/ui/pages/stations/edit.py +273 -0
  490. testerkit/ui/pages/stations/list.py +180 -0
  491. testerkit/ui/pages/stations/new.py +311 -0
  492. testerkit/ui/pages/tests/__init__.py +4 -0
  493. testerkit/ui/pages/tests/detail.py +152 -0
  494. testerkit/ui/pages/tests/list.py +194 -0
  495. testerkit/ui/pages/uuts/__init__.py +4 -0
  496. testerkit/ui/pages/uuts/list.py +97 -0
  497. testerkit/ui/shared/__init__.py +8 -0
  498. testerkit/ui/shared/components.py +1826 -0
  499. testerkit/ui/shared/dialogs.py +105 -0
  500. testerkit/ui/shared/event_binding.py +194 -0
  501. testerkit/ui/shared/layout.py +314 -0
  502. testerkit/ui/shared/services.py +1719 -0
  503. testerkit/ui/shared/timestamps.py +21 -0
  504. testerkit/ui/static/branding/favicon-16.png +0 -0
  505. testerkit/ui/static/branding/favicon-32.png +0 -0
  506. testerkit/ui/static/branding/favicon-48.png +0 -0
  507. testerkit/ui/static/branding/testerkit-icon-256.png +0 -0
  508. testerkit/ui/static/branding/testerkit-mark.svg +1 -0
  509. testerkit/ui/static/branding/testerkit-wordmark.svg +1 -0
  510. testerkit/ui/static/global.css +465 -0
  511. testerkit/utils/__init__.py +17 -0
  512. testerkit/utils/enum_meta.py +800 -0
  513. testerkit/utils/paths.py +75 -0
  514. testerkit/utils/ranges.py +257 -0
  515. testerkit/validation.py +82 -0
  516. testerkit/verbs.py +142 -0
  517. testerkit-0.4.0.dist-info/METADATA +250 -0
  518. testerkit-0.4.0.dist-info/RECORD +521 -0
  519. testerkit-0.4.0.dist-info/WHEEL +4 -0
  520. testerkit-0.4.0.dist-info/entry_points.txt +5 -0
  521. testerkit-0.4.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,1172 @@
1
+ """Shared row-building helpers for the parquet backend.
2
+
3
+ Produces denormalized rows with run-level and measurement-level fields.
4
+ This module extracts the common logic so new columns only need to be
5
+ added in one place.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
17
+
18
+ from testerkit.data.models import Measurement, TestRun, TestVector
19
+ from testerkit.data.ref import classify_value, is_ref
20
+ from testerkit.environment import EnvironmentSnapshot
21
+
22
+ try:
23
+ import importlib.util as _ilu
24
+
25
+ HAS_NUMPY = _ilu.find_spec("numpy") is not None
26
+ except (ImportError, ValueError):
27
+ HAS_NUMPY = False
28
+
29
+ # Canonical list of instrument struct field names for the at-rest
30
+ # ``instruments`` LIST<STRUCT> column. Lives here (data layer) so the
31
+ # daemon and parquet backend can import it without pulling in the
32
+ # execution framework.
33
+ INSTRUMENT_STRUCT_FIELDS: tuple[str, ...] = (
34
+ "name",
35
+ "id",
36
+ "driver",
37
+ "resource",
38
+ "protocol",
39
+ "manufacturer",
40
+ "model",
41
+ "serial_number",
42
+ "firmware",
43
+ "cal_due",
44
+ "cal_last",
45
+ "cal_certificate",
46
+ "cal_lab",
47
+ "mocked",
48
+ )
49
+
50
+ # Prefix for path references in output columns (legacy, use file:// URIs)
51
+ REF_PATH_PREFIX = "_ref/"
52
+
53
+ # Vector ID prefix length for filename namespacing in _ref/ directories.
54
+ VECTOR_ID_LENGTH = 8
55
+
56
+ # EAV lane struct — the at-rest nested representation of one input / output
57
+ # entry. ``value_type`` is the value-type discriminator that selects which
58
+ # ``value_*`` lane holds the value. The Arrow struct type in
59
+ # ``schemas._LANE_STRUCT`` must match these names (guarded there).
60
+ LANE_FIELDS: tuple[str, ...] = (
61
+ "name",
62
+ "value_type",
63
+ "value_int",
64
+ "value_double",
65
+ "value_bool",
66
+ "value_text",
67
+ "value_timestamp",
68
+ "value_json",
69
+ "unit",
70
+ "uut_pin",
71
+ )
72
+
73
+ # Fields of the at-rest nested measurement struct carried on the vector row.
74
+ MEASUREMENT_STRUCT_FIELDS: tuple[str, ...] = (
75
+ "name",
76
+ "value",
77
+ "unit",
78
+ "outcome",
79
+ "timestamp",
80
+ "limit_low",
81
+ "limit_high",
82
+ "limit_nominal",
83
+ "limit_comparator",
84
+ "characteristic_id",
85
+ "spec_ref",
86
+ "uut_pin",
87
+ "fixture_connection",
88
+ "instrument_name",
89
+ "instrument_resource",
90
+ "instrument_channel",
91
+ )
92
+
93
+ # Flat measurement scalar columns — present on the flat-fact / overlay / export
94
+ # row, but dropped from the at-rest parquet (at rest a measurement lives in the
95
+ # vector row's nested ``measurements`` list, not as flat columns).
96
+ _MEASUREMENT_SCALAR_FIELDS: frozenset[str] = frozenset(
97
+ {
98
+ "measurement_name",
99
+ "measurement_timestamp",
100
+ "measurement_value",
101
+ "measurement_unit",
102
+ "measurement_outcome",
103
+ "limit_low",
104
+ "limit_high",
105
+ "limit_nominal",
106
+ "limit_comparator",
107
+ "characteristic_id",
108
+ "spec_ref",
109
+ "uut_pin",
110
+ "fixture_connection",
111
+ "instrument_name",
112
+ "instrument_resource",
113
+ "instrument_channel",
114
+ }
115
+ )
116
+
117
+
118
+ def _decode_io_map(map_value: dict | list | None) -> dict[str, Any]:
119
+ """Decode one query-time ``inputs_map``/``outputs_map`` VARCHAR map into a dict.
120
+
121
+ Shared by :meth:`RunStore.get_measurements` and
122
+ :meth:`StepsQuery._rows_from` / ``list_for_session``. Each call site
123
+ receives the map as either a plain ``dict`` (DuckDB Arrow conversion) or
124
+ a list of ``(key, value)`` tuples. ``None`` keys or values are skipped.
125
+ VARCHAR values are coerced: ``"true"``/``"false"`` → ``bool``; numeric
126
+ strings → ``float``.
127
+
128
+ Replaces the old merged, ``in_``/``out_``-prefixed ``dynamic_attrs`` MAP
129
+ decode (projection-normalization, 0.3.1) — callers now get one map per
130
+ role (no prefix, no split) and call this once per map.
131
+ """
132
+ out: dict[str, Any] = {}
133
+ if not map_value:
134
+ return out
135
+ pairs = map_value.items() if isinstance(map_value, dict) else map_value
136
+ for k, v in pairs:
137
+ if k is None or v is None:
138
+ continue
139
+ out[k] = _coerce_io_map_value(v)
140
+ return out
141
+
142
+
143
+ def _decode_io_maps(
144
+ inputs_map: dict | list | None,
145
+ outputs_map: dict | list | None,
146
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
147
+ """Decode a ``(inputs_map, outputs_map)`` pair into ``(inputs, outputs)`` dicts."""
148
+ return _decode_io_map(inputs_map), _decode_io_map(outputs_map)
149
+
150
+
151
+ def _coerce_io_map_value(v: Any) -> Any:
152
+ """Coerce an inputs_map/outputs_map VARCHAR value to its native Python type."""
153
+ if not isinstance(v, str):
154
+ return v
155
+ if v == "true":
156
+ return True
157
+ if v == "false":
158
+ return False
159
+ try:
160
+ return float(v)
161
+ except ValueError:
162
+ return v
163
+
164
+
165
+ def _as_utc(value: datetime) -> datetime:
166
+ """Normalise a datetime to tz-aware UTC (assume UTC if naive)."""
167
+ if value.tzinfo is None:
168
+ return value.replace(tzinfo=UTC)
169
+ return value.astimezone(UTC)
170
+
171
+
172
+ def _lane_entry(
173
+ name: str, value: Any, unit: str | None = None, uut_pin: str | None = None
174
+ ) -> dict[str, Any]:
175
+ """Encode one ``(name, value)`` into an EAV lane struct dict.
176
+
177
+ ``observation_kind`` routes the value to exactly one ``value_*`` lane;
178
+ the others stay ``None``. ``unit`` carries the optional engineering unit;
179
+ ``uut_pin`` carries the pin this observation belongs to (or None = all pins).
180
+ """
181
+ value_type = observation_kind(value)
182
+ entry: dict[str, Any] = dict.fromkeys(LANE_FIELDS)
183
+ entry["name"] = name
184
+ entry["value_type"] = value_type
185
+ entry["unit"] = unit
186
+ entry["uut_pin"] = uut_pin
187
+ if value_type == "scalar:bool":
188
+ entry["value_bool"] = bool(value)
189
+ elif value_type == "scalar:int":
190
+ entry["value_int"] = int(value)
191
+ elif value_type == "scalar:float":
192
+ entry["value_double"] = float(value)
193
+ elif value_type == "scalar:datetime":
194
+ entry["value_timestamp"] = _as_utc(value)
195
+ elif value_type in ("scalar:str", "uri"):
196
+ entry["value_text"] = str(value)
197
+ elif value_type in ("list", "dict"):
198
+ entry["value_json"] = json.dumps(value, default=str)
199
+ else: # other:*
200
+ entry["value_text"] = repr(value)
201
+ return entry
202
+
203
+
204
+ def encode_lane_structs(
205
+ values: dict[str, Any],
206
+ units: dict[str, str] | None = None,
207
+ pins: dict[str, str] | None = None,
208
+ ) -> list[dict[str, Any]]:
209
+ """Encode an inputs or outputs dict into a list of lane structs.
210
+
211
+ ``units`` maps a slot name to its engineering unit; ``pins`` maps a slot
212
+ name to its ``uut_pin``. Both ride into the lane's named fields.
213
+ """
214
+ units = units or {}
215
+ pins = pins or {}
216
+ return [
217
+ _lane_entry(name, value, units.get(name), pins.get(name)) for name, value in values.items()
218
+ ]
219
+
220
+
221
+ def _lane_value(entry: dict[str, Any]) -> Any:
222
+ """Inverse of :func:`_lane_entry` — read the value from its lane by ``value_type``."""
223
+ value_type = entry.get("value_type")
224
+ if value_type == "scalar:bool":
225
+ return entry.get("value_bool")
226
+ if value_type == "scalar:int":
227
+ return entry.get("value_int")
228
+ if value_type == "scalar:float":
229
+ return entry.get("value_double")
230
+ if value_type == "scalar:datetime":
231
+ return entry.get("value_timestamp")
232
+ if value_type in ("list", "dict"):
233
+ raw = entry.get("value_json")
234
+ return json.loads(raw) if raw is not None else None
235
+ return entry.get("value_text") # scalar:str, uri, other:*
236
+
237
+
238
+ def decode_lane_structs(entries: list[dict[str, Any]] | None) -> dict[str, Any]:
239
+ """Decode a list of lane structs back into a ``{name: value}`` dict."""
240
+ return {entry["name"]: _lane_value(entry) for entry in (entries or [])}
241
+
242
+
243
+ def _to_datetime(value: Any) -> datetime | None:
244
+ """Coerce a value to ``datetime`` if possible, else ``None``.
245
+
246
+ Accepts a ``datetime`` (returned as-is), an ISO-8601 string (parsed
247
+ via ``datetime.fromisoformat``), or anything else (``None``).
248
+ Malformed strings return ``None`` rather than raising.
249
+ """
250
+ if isinstance(value, datetime):
251
+ return value
252
+ if isinstance(value, str):
253
+ try:
254
+ return datetime.fromisoformat(value)
255
+ except ValueError:
256
+ return None
257
+ return None
258
+
259
+
260
+ class RunParquetRow(BaseModel):
261
+ """A single denormalized row for streaming and storage.
262
+
263
+ Three row kinds, distinguished by the explicit ``record_type``
264
+ discriminator:
265
+
266
+ * ``record_type = 'run'`` — one row per run; carries run-level
267
+ identity / UUT / station / fixture / environment context. Step
268
+ and vector columns are NULL. Provides an addressable
269
+ "runs table" within the unified per-run parquet (lakehouse
270
+ adopters can ``WHERE record_type = 'run'`` for clean ingest).
271
+ * ``record_type = 'step'`` — one per ``(step_path, step_retry,
272
+ vector_outer_index)`` execution; carries code identity + timing +
273
+ rolled-up outcome. ``vector_index`` is always NULL on this row kind.
274
+ * ``record_type = 'vector'`` — one execution carrier; holds the
275
+ ``inputs``/``outputs`` lanes and the nested ``measurements``
276
+ list for that execution.
277
+
278
+ Run rows are keyed by ``run_id``; steps and vectors share grain
279
+ ``(run_id, step_path, step_retry, vector_outer_index)`` — a step row's
280
+ own ``vector_index`` is always NULL, while its vector rows carry
281
+ ``vector_index`` 0..N within that same grain.
282
+ """
283
+
284
+ model_config = ConfigDict(extra="forbid")
285
+
286
+ # Discriminator. ``measurement`` is the flat one-row-per-measurement fact
287
+ # (the overlay / export shape + what the daemon UNNESTs into
288
+ # ``measurements_materialized``); its ``vector_index`` mirrors its carrier
289
+ # (NULL for an ambient/step-scope measurement, 0..N for a vector-scope one).
290
+ record_type: Literal["run", "step", "vector", "measurement"]
291
+
292
+ # Session / run identity
293
+ session_id: str
294
+ run_id: str
295
+ # None only pre-RunStarted-correlation (see the ``run_started is None``
296
+ # placeholder branch below) — a streaming transient, never a persisted
297
+ # "no site" state. Corrected to ``run_started.site_index`` (always 0+)
298
+ # once RunStarted arrives.
299
+ site_index: int | None = None
300
+ site_name: str | None = None
301
+ run_started_at: datetime | None = None
302
+ run_ended_at: datetime | None = None
303
+
304
+ # Operator
305
+ operator_id: str | None = None
306
+ operator_name: str | None = None
307
+
308
+ # UUT
309
+ uut_serial_number: str
310
+ uut_part_number: str | None = None
311
+ uut_revision: str | None = None
312
+ uut_lot_number: str | None = None
313
+
314
+ # Part
315
+ part_id: str | None = None
316
+ part_name: str | None = None
317
+ part_revision: str | None = None
318
+
319
+ # Station — id is None for bringup tier (no station YAML loaded)
320
+ station_id: str | None = None
321
+ station_name: str | None = None
322
+ station_type: str | None = None
323
+ station_location: str | None = None
324
+ station_hostname: str | None = None
325
+
326
+ # Fixture
327
+ fixture_id: str | None = None
328
+
329
+ # Test context
330
+ test_phase: str | None = None
331
+ project_name: str | None = None
332
+ git_commit: str | None = None
333
+ git_branch: str | None = None
334
+ git_remote: str | None = None
335
+
336
+ # Environment traceability
337
+ python_version: str | None = None
338
+ testerkit_version: str | None = None
339
+ env_fingerprint: str | None = None
340
+
341
+ # Step/vector context
342
+ step_name: str
343
+ step_index: int
344
+ step_path: str = ""
345
+ step_started_at: datetime | None = None
346
+ step_ended_at: datetime | None = None
347
+ step_node_id: str | None = None
348
+ step_module: str | None = None
349
+ step_file: str | None = None
350
+ step_class: str | None = None
351
+ step_function: str | None = None
352
+ step_markers: str | None = None
353
+ # 0-based outer (item) retry — pytest-rerunfailures rerun count of this
354
+ # step. On step + scope-vector rows; NULL on run/measurement rows. The
355
+ # inner per-vector retry is ``vector_retry``.
356
+ step_retry: int | None = None
357
+ # NULL on step rows (step.vector_index is always NULL at rest).
358
+ vector_index: int | None = None
359
+ # The vector_index of the outer (class-level) vector a step or vector
360
+ # record belongs to; NULL for top-level steps.
361
+ vector_outer_index: int | None = None
362
+ # 0-based retry counter — 0 for the first execution, N for the Nth retry.
363
+ # Per-measurement (NULL on step / run rows). Companion to ``RetryConfig.max_retries``
364
+ # which bounds the count (max_retries=0 → no retries; max_retries=N → up to N retries).
365
+ vector_retry: int | None = None
366
+ vector_started_at: datetime | None = None
367
+ vector_ended_at: datetime | None = None
368
+
369
+ # Measurement payload — populated only when record_type == 'measurement'.
370
+ measurement_name: str | None = None
371
+ measurement_timestamp: datetime | None = None
372
+ measurement_value: float | None = None
373
+ measurement_unit: str | None = None
374
+ measurement_outcome: str | None = None
375
+ limit_low: float | None = None
376
+ limit_high: float | None = None
377
+ limit_nominal: float | None = None
378
+ limit_comparator: str | None = None
379
+ characteristic_id: str | None = None
380
+ spec_ref: str | None = None
381
+ uut_pin: str | None = None
382
+ fixture_connection: str | None = None
383
+ instrument_name: str | None = None
384
+ instrument_resource: str | None = None
385
+ instrument_channel: str | None = None
386
+
387
+ # Outcomes (cascade rollups: measurement → vector → step → run)
388
+ step_outcome: str | None = None
389
+ vector_outcome: str | None = None
390
+ run_outcome: str | None = None
391
+
392
+ # Dynamic namespaced columns
393
+ inputs: dict[str, Any] = Field(default_factory=dict)
394
+ outputs: dict[str, Any] = Field(default_factory=dict)
395
+ instruments: list[dict[str, Any]] = Field(default_factory=list)
396
+ # Optional per-site engineering unit for inputs / outputs (name → unit),
397
+ # flowed into the lane's ``unit`` field at encode time.
398
+ input_units: dict[str, str] = Field(default_factory=dict)
399
+ output_units: dict[str, str] = Field(default_factory=dict)
400
+ output_pins: dict[str, str] = Field(default_factory=dict)
401
+ # Nested measurements carried on the vector row (LIST<STRUCT>).
402
+ measurements: list[dict[str, Any]] = Field(default_factory=list)
403
+
404
+ @model_validator(mode="after")
405
+ def _check_grain_invariant(self) -> RunParquetRow:
406
+ """Enforce the grain law: ``vector_index IS NULL`` ⟺ the logical step.
407
+
408
+ A ``vector`` row is a real condition point and ALWAYS carries a concrete
409
+ ``vector_index`` (0..N); ``run`` and ``step`` rows are the ambient /
410
+ logical grain and carry NULL. ``measurement`` rows mirror their carrier
411
+ (NULL = ambient, 0..N = vector-scope) and are unconstrained. This makes
412
+ a malformed row — e.g. a ``vector`` with NULL index — impossible to
413
+ construct, rather than a silent leak into a grain-typed query later.
414
+ """
415
+ if self.record_type == "vector" and self.vector_index is None:
416
+ raise ValueError(
417
+ "record_type='vector' requires a concrete vector_index (0..N); "
418
+ "NULL vector_index marks the logical step grain"
419
+ )
420
+ if self.record_type in ("run", "step") and self.vector_index is not None:
421
+ raise ValueError(
422
+ f"record_type='{self.record_type}' requires vector_index=None "
423
+ f"(NULL ⟺ the logical step / ambient grain); got {self.vector_index}"
424
+ )
425
+ return self
426
+
427
+ def to_flat_dict(self, *, at_rest: bool = False) -> dict[str, Any]:
428
+ """Flatten to denormalized dict for the Parquet write boundary.
429
+
430
+ ``inputs`` / ``outputs`` are encoded as nested EAV lane structs
431
+ (``LIST<STRUCT>``; see :func:`encode_lane_structs`) under the
432
+ ``inputs`` / ``outputs`` keys. ``input_units`` / ``output_units``
433
+ ride into each lane's ``unit`` field. ``instruments`` passes
434
+ through as a ``list[dict]`` (nested LIST<STRUCT> at rest).
435
+
436
+ ``at_rest=True`` drops the flat measurement scalar columns: at rest a
437
+ measurement lives in the vector row's nested ``measurements`` list, not
438
+ as flat columns. The flat-fact path (overlay / export) keeps them.
439
+
440
+ Datetime values are left as ``datetime`` objects — callers must
441
+ serialise them at the actual write boundary (e.g. ``.isoformat()``).
442
+ """
443
+ exclude = {
444
+ "inputs",
445
+ "outputs",
446
+ "input_units",
447
+ "output_units",
448
+ "output_pins",
449
+ }
450
+ if at_rest:
451
+ exclude |= _MEASUREMENT_SCALAR_FIELDS
452
+ row = self.model_dump(exclude=exclude)
453
+ row["inputs"] = encode_lane_structs(self.inputs, self.input_units)
454
+ row["outputs"] = encode_lane_structs(self.outputs, self.output_units, self.output_pins)
455
+ return row
456
+
457
+
458
+ def build_run_metadata(test_run: TestRun) -> dict[str, Any]:
459
+ """Extract run-level metadata fields from a TestRun.
460
+
461
+ These fields are identical on every row in a run. Returns raw
462
+ Python objects (datetime, str, None) — callers that need JSON
463
+ serialisation should post-process timestamps.
464
+ """
465
+ return {
466
+ "session_id": str(test_run.session_id),
467
+ "run_id": str(test_run.id),
468
+ "site_index": test_run.site_index,
469
+ "site_name": test_run.site_name,
470
+ "run_started_at": test_run.started_at,
471
+ "run_ended_at": test_run.ended_at,
472
+ # WHO
473
+ "operator_id": test_run.operator_id,
474
+ "operator_name": test_run.operator_name,
475
+ # UUT
476
+ "uut_serial_number": test_run.uut.serial,
477
+ "uut_part_number": test_run.uut.part_number,
478
+ "uut_revision": test_run.uut.revision,
479
+ "uut_lot_number": test_run.uut.lot_number,
480
+ # Part
481
+ "part_id": test_run.part_id,
482
+ "part_name": test_run.part_name,
483
+ "part_revision": test_run.part_revision,
484
+ # Station
485
+ "station_id": test_run.station_id,
486
+ "station_name": test_run.station_name,
487
+ "station_type": test_run.station_type,
488
+ "station_location": test_run.station_location,
489
+ "station_hostname": test_run.station_hostname,
490
+ # Fixture
491
+ "fixture_id": test_run.fixture_id,
492
+ # Test context
493
+ "test_phase": test_run.test_phase,
494
+ "project_name": test_run.project_name,
495
+ "git_commit": test_run.git_commit,
496
+ "git_branch": test_run.git_branch,
497
+ "git_remote": test_run.git_remote,
498
+ # Environment traceability (scalars from environment snapshot)
499
+ **_env_columns(test_run.environment_json),
500
+ }
501
+
502
+
503
+ def _env_columns(environment_json: str | None) -> dict[str, str | None]:
504
+ """Extract queryable environment columns from the JSON snapshot."""
505
+ if not environment_json:
506
+ return {"python_version": None, "testerkit_version": None, "env_fingerprint": None}
507
+
508
+ snapshot = EnvironmentSnapshot.model_validate_json(environment_json)
509
+ return {
510
+ "python_version": snapshot.python_version,
511
+ "testerkit_version": snapshot.testerkit_version,
512
+ "env_fingerprint": snapshot.lockfile_hash,
513
+ }
514
+
515
+
516
+ def run_context_from_run_started(
517
+ run_started: Any | None,
518
+ event: Any,
519
+ *,
520
+ include_env: bool = False,
521
+ ) -> dict[str, Any]:
522
+ """Run-level context kwargs derived from a cached ``RunStarted`` event.
523
+
524
+ Streaming-path counterpart to :func:`build_run_metadata` (which
525
+ operates on a ``TestRun`` model).
526
+
527
+ ``event`` supplies the row's ``run_id`` (a measurement event may carry
528
+ it before ``RunStarted`` arrives). When ``run_started`` is ``None``
529
+ (events arrived before RunStarted), falls back to a sparse dict with
530
+ placeholder defaults.
531
+
532
+ Set ``include_env=True`` to include environment columns
533
+ (``python_version``, ``testerkit_version``, ``env_fingerprint``). The
534
+ measurement schema exposes them; the steps schema does not.
535
+ """
536
+ if run_started is None:
537
+ kwargs: dict[str, Any] = {
538
+ "session_id": str(event.session_id),
539
+ "run_id": str(event.run_id) if event.run_id else "",
540
+ "site_index": None,
541
+ "site_name": None,
542
+ "run_started_at": None,
543
+ "run_ended_at": None,
544
+ "operator_id": None,
545
+ "operator_name": None,
546
+ "uut_serial_number": "unknown",
547
+ "uut_part_number": None,
548
+ "uut_revision": None,
549
+ "uut_lot_number": None,
550
+ "part_id": None,
551
+ "part_name": None,
552
+ "part_revision": None,
553
+ "station_id": "unknown",
554
+ "station_name": None,
555
+ "station_type": None,
556
+ "station_location": None,
557
+ "station_hostname": None,
558
+ "fixture_id": None,
559
+ "test_phase": None,
560
+ "project_name": None,
561
+ "git_commit": None,
562
+ "git_branch": None,
563
+ "git_remote": None,
564
+ }
565
+ else:
566
+ kwargs = {
567
+ "session_id": str(run_started.session_id),
568
+ "run_id": str(event.run_id) if event.run_id else "",
569
+ "site_index": run_started.site_index,
570
+ "site_name": run_started.site_name,
571
+ "run_started_at": run_started.occurred_at,
572
+ "run_ended_at": None,
573
+ "operator_id": run_started.operator_id,
574
+ "operator_name": run_started.operator_name,
575
+ "uut_serial_number": run_started.uut_serial_number,
576
+ "uut_part_number": run_started.uut_part_number,
577
+ "uut_revision": run_started.uut_revision,
578
+ "uut_lot_number": run_started.uut_lot_number,
579
+ "part_id": run_started.part_id,
580
+ "part_name": run_started.part_name,
581
+ "part_revision": run_started.part_revision,
582
+ "station_id": run_started.station_id,
583
+ "station_name": run_started.station_name,
584
+ "station_type": run_started.station_type,
585
+ "station_location": run_started.station_location,
586
+ "station_hostname": run_started.station_hostname,
587
+ "fixture_id": run_started.fixture_id,
588
+ "test_phase": run_started.test_phase,
589
+ "project_name": run_started.project_name,
590
+ "git_commit": run_started.git_commit,
591
+ "git_branch": run_started.git_branch,
592
+ "git_remote": run_started.git_remote,
593
+ }
594
+ if include_env:
595
+ env_json = run_started.environment_json if run_started else None
596
+ kwargs.update(_env_columns(env_json))
597
+ return kwargs
598
+
599
+
600
+ def build_measurement_fields(measurement: Measurement) -> dict[str, Any]:
601
+ """Extract measurement-level fields from a Measurement."""
602
+ return {
603
+ "measurement_name": measurement.name,
604
+ "measurement_timestamp": measurement.timestamp,
605
+ "measurement_value": measurement.value,
606
+ "measurement_unit": measurement.unit,
607
+ # measurement.outcome is contractually set by log_measurement
608
+ # (RuntimeError raised in execution/run_scope.py if None reaches here).
609
+ "measurement_outcome": measurement.outcome.value if measurement.outcome else None,
610
+ # Limits
611
+ "limit_low": measurement.limit_low,
612
+ "limit_high": measurement.limit_high,
613
+ "limit_nominal": measurement.limit_nominal,
614
+ "limit_comparator": measurement.limit_comparator,
615
+ # Spec traceability
616
+ "characteristic_id": measurement.characteristic_id,
617
+ "spec_ref": measurement.spec_ref,
618
+ # Signal path
619
+ "uut_pin": measurement.uut_pin,
620
+ "fixture_connection": measurement.fixture_connection,
621
+ "instrument_name": measurement.instrument_name,
622
+ "instrument_resource": measurement.instrument_resource,
623
+ "instrument_channel": measurement.instrument_channel,
624
+ }
625
+
626
+
627
+ def build_measurement_struct(measurement: Measurement) -> dict[str, Any]:
628
+ """Encode a Measurement into the at-rest nested struct on the vector row.
629
+
630
+ Field order/names must match ``MEASUREMENT_STRUCT_FIELDS`` (and
631
+ ``schemas._MEASUREMENT_STRUCT``, guarded there).
632
+ """
633
+ return {
634
+ "name": measurement.name,
635
+ "value": measurement.value,
636
+ "unit": measurement.unit,
637
+ "outcome": measurement.outcome.value if measurement.outcome else None,
638
+ "timestamp": measurement.timestamp,
639
+ "limit_low": measurement.limit_low,
640
+ "limit_high": measurement.limit_high,
641
+ "limit_nominal": measurement.limit_nominal,
642
+ "limit_comparator": measurement.limit_comparator,
643
+ "characteristic_id": measurement.characteristic_id,
644
+ "spec_ref": measurement.spec_ref,
645
+ "uut_pin": measurement.uut_pin,
646
+ "fixture_connection": measurement.fixture_connection,
647
+ "instrument_name": measurement.instrument_name,
648
+ "instrument_resource": measurement.instrument_resource,
649
+ "instrument_channel": measurement.instrument_channel,
650
+ }
651
+
652
+
653
+ def build_input_columns(vector: TestVector) -> dict[str, Any]:
654
+ """Build inputs dict from vector params and stimulus records.
655
+
656
+ Keys are unprefixed (e.g. ``"vin"``); the result is encoded as the
657
+ ``inputs`` lane struct list by ``to_flat_dict()``.
658
+ """
659
+ cols: dict[str, Any] = {}
660
+
661
+ for param, value in vector.params.items():
662
+ if param.startswith("_"):
663
+ continue
664
+ cols[param] = value
665
+
666
+ for stim in vector.stimulus:
667
+ param = stim.param
668
+ if stim.value is not None:
669
+ cols[param] = stim.value
670
+ if stim.instrument:
671
+ cols[f"{param}_instrument"] = stim.instrument
672
+ if stim.resource:
673
+ cols[f"{param}_resource"] = stim.resource
674
+ if stim.channel:
675
+ cols[f"{param}_channel"] = stim.channel
676
+ if stim.uut_pin:
677
+ cols[f"{param}_uut_pin"] = stim.uut_pin
678
+ if stim.fixture_connection:
679
+ cols[f"{param}_fixture_connection"] = stim.fixture_connection
680
+
681
+ return cols
682
+
683
+
684
+ def observation_kind(value: Any) -> str:
685
+ """Classify a value into its EAV value-type tag.
686
+
687
+ Returns a short tag (``scalar:int`` / ``scalar:float`` / ``scalar:bool`` /
688
+ ``scalar:str`` / ``scalar:datetime`` / ``uri`` / ``list`` / ``dict`` /
689
+ ``other:*``) that :func:`_lane_entry` uses to route the value to its
690
+ ``value_*`` lane and that is stored as the ``value_type`` field.
691
+
692
+ URIs (``channel://`` and ``file://``) are tagged ``"uri"`` even
693
+ though they're ``str`` — keeps a claim-check ref distinct from a free
694
+ string (both share the ``value_text`` lane, disambiguated by ``value_type``).
695
+ """
696
+ if is_ref(value):
697
+ return "uri"
698
+ if isinstance(value, bool):
699
+ return "scalar:bool"
700
+ if isinstance(value, int):
701
+ return "scalar:int"
702
+ if isinstance(value, float):
703
+ return "scalar:float"
704
+ if isinstance(value, str):
705
+ return "scalar:str"
706
+ if isinstance(value, datetime):
707
+ return "scalar:datetime"
708
+ if isinstance(value, list):
709
+ return "list"
710
+ if isinstance(value, dict):
711
+ return "dict"
712
+ return f"other:{type(value).__name__}"
713
+
714
+
715
+ def build_output_columns(
716
+ vector: TestVector,
717
+ ref_saver: Callable[[str, str, Any], str] | None = None,
718
+ ) -> dict[str, Any]:
719
+ """Build outputs dict from vector observations.
720
+
721
+ Keys are unprefixed (e.g. ``"temperature"``); the result is encoded as
722
+ the ``outputs`` lane struct list by ``to_flat_dict()``.
723
+
724
+ By the time this runs, observations already contain URIs (from
725
+ Context.observe() writing to ChannelStore) or inline scalars.
726
+
727
+ Routing:
728
+ - **ref URI** (``channel://``, ``file://``) → pass through as-is
729
+ - **scalar** → inline value
730
+ - **blob** → ``ref_saver()`` → ``file://`` URI, or ``repr()``
731
+ """
732
+ cols: dict[str, Any] = {}
733
+
734
+ for key, value in vector.observations.items():
735
+ if key.startswith("_"):
736
+ continue
737
+
738
+ # Already a URI (from proxy or context.observe writing to stores)
739
+ if is_ref(value):
740
+ cols[key] = value
741
+ continue
742
+
743
+ vtype = classify_value(value)
744
+
745
+ if vtype == "scalar":
746
+ cols[key] = value
747
+ elif vtype == "blob" and ref_saver is not None:
748
+ cols[key] = ref_saver(str(vector.id)[:VECTOR_ID_LENGTH], key, value)
749
+ elif isinstance(value, (list, dict)):
750
+ cols[key] = value
751
+ elif ref_saver is not None:
752
+ cols[key] = ref_saver(str(vector.id)[:VECTOR_ID_LENGTH], key, value)
753
+ else:
754
+ cols[key] = repr(value)
755
+
756
+ return cols
757
+
758
+
759
+ def save_ref_to_dir(ref_dir: Path, vector_id: str, key: str, value: Any) -> str:
760
+ """Save observation data to a _ref/ directory and return the reference path.
761
+
762
+ Both materialization paths use this helper to save out-of-row
763
+ artifacts alongside the parquet (the ``_ref/`` sibling directory
764
+ convention). The dispatch table itself lives in
765
+ :mod:`testerkit.data.files.serializers` (build item 12); this
766
+ helper just owns the ``_ref/`` filename / URI shape.
767
+
768
+ Args:
769
+ ref_dir: Target directory for reference files.
770
+ vector_id: Vector ID prefix.
771
+ key: Key name for the data.
772
+ value: Data to save. Routed through
773
+ :func:`~testerkit.data.files.serializers.find_serializer`
774
+ — see that module for the convention table and the
775
+ ``testerkit_serialize`` / :func:`register_serializer`
776
+ extension points.
777
+
778
+ Returns:
779
+ Reference string like ``"file://_ref/abc123_waveform.npz"``.
780
+ """
781
+ from testerkit.data.files.serializers import find_serializer
782
+
783
+ serializer = find_serializer(value)
784
+ # Path values: source suffix wins over the serializer's default
785
+ # ``.bin`` so e.g. ``capture.tdms`` stays ``.tdms`` on disk.
786
+ if isinstance(value, Path):
787
+ ext = value.suffix or serializer.extension
788
+ else:
789
+ ext = serializer.extension
790
+ filename = f"{vector_id}_{key}{ext}"
791
+ serializer.write(value, ref_dir / filename)
792
+ return f"file://{REF_PATH_PREFIX}{filename}"
793
+
794
+
795
+ def build_run_row(
796
+ *,
797
+ run_context: dict[str, Any],
798
+ run_outcome: str | None,
799
+ run_ended_at: datetime | None,
800
+ instruments: list[dict[str, Any]],
801
+ ) -> dict[str, Any]:
802
+ """Build the single ``record_type = 'run'`` row for a parquet.
803
+
804
+ Carries run-level identity / UUT / station / fixture / environment
805
+ columns. Step and measurement columns stay NULL. Provides an
806
+ addressable run-row inside the unified per-run parquet so lakehouse
807
+ adopters can ``WHERE record_type = 'run'`` for clean ingest into a
808
+ ``runs`` table without ``SELECT DISTINCT`` over the denormalized
809
+ step + measurement rows.
810
+
811
+ Conventionally written first in the parquet so readers / row-group
812
+ pruners reach the run identity at the start of the file.
813
+ """
814
+ ctx = dict(run_context)
815
+ ctx["run_ended_at"] = run_ended_at
816
+ row = RunParquetRow(
817
+ record_type="run",
818
+ **ctx,
819
+ # Step / vector context: NULL on run rows. ``step_name`` and
820
+ # ``step_index`` are required-non-None on the model so they
821
+ # carry sentinel "" / 0 values.
822
+ step_name="",
823
+ step_index=0,
824
+ step_path="",
825
+ step_started_at=None,
826
+ step_ended_at=None,
827
+ step_node_id=None,
828
+ step_module=None,
829
+ step_file=None,
830
+ step_class=None,
831
+ step_function=None,
832
+ step_markers=None,
833
+ step_outcome=None,
834
+ vector_index=None,
835
+ vector_retry=None,
836
+ # Measurement payload: NULL on run rows.
837
+ measurement_name=None,
838
+ run_outcome=run_outcome,
839
+ inputs={},
840
+ outputs={},
841
+ instruments=instruments,
842
+ )
843
+ return row.to_flat_dict(at_rest=True)
844
+
845
+
846
+ def build_step_row(
847
+ *,
848
+ run_context: dict[str, Any],
849
+ entry: dict[str, Any],
850
+ run_outcome: str | None,
851
+ run_ended_at: datetime | None,
852
+ instruments: list[dict[str, Any]],
853
+ ) -> dict[str, Any]:
854
+ """Build one ``record_type = 'step'`` row from a step manifest entry.
855
+
856
+ Single source of truth for step-row construction. Used by BOTH the
857
+ streaming subscriber path
858
+ (``materialize_run_to_parquet``) and the batch path
859
+ (``ParquetBackend._append_step_rows``) so the on-disk shape is
860
+ identical regardless of which writer produced it.
861
+
862
+ Every ``(step_path, vector_index)`` pair gets a step row — including
863
+ pairs that also have measurement rows. Step rows are independent of
864
+ measurements; queries count steps via
865
+ ``COUNT(*) FILTER (WHERE record_type = 'step')`` instead of
866
+ deduping over measurement rows.
867
+
868
+ ``run_context`` is the dict returned by ``build_run_metadata`` or
869
+ ``run_context_from_run_started`` (with ``run_ended_at`` overridden
870
+ by the caller for the streaming case). ``entry`` is one step
871
+ manifest entry as produced by ``step_entry_dict`` /
872
+ ``_append_not_started``.
873
+ """
874
+ ctx = dict(run_context)
875
+ ctx["run_ended_at"] = run_ended_at
876
+ raw_vi = entry.get("vector_index")
877
+ raw_idx = entry.get("index")
878
+ row = RunParquetRow(
879
+ record_type="step",
880
+ **ctx,
881
+ step_name=entry.get("name") or "",
882
+ step_index=int(raw_idx) if raw_idx is not None else 0,
883
+ step_path=entry.get("step_path") or "",
884
+ step_started_at=_to_datetime(entry.get("started_at")),
885
+ step_ended_at=_to_datetime(entry.get("ended_at")),
886
+ step_node_id=entry.get("node_id"),
887
+ step_module=entry.get("module"),
888
+ step_file=entry.get("file"),
889
+ step_class=entry.get("class_name"),
890
+ step_function=entry.get("function"),
891
+ step_markers=entry.get("markers"),
892
+ step_outcome=entry.get("outcome"),
893
+ step_retry=entry.get("step_retry") or 0,
894
+ vector_index=raw_vi,
895
+ vector_outer_index=entry.get("vector_outer_index"),
896
+ vector_retry=None,
897
+ measurement_name=None,
898
+ run_outcome=run_outcome,
899
+ inputs=dict(entry.get("inputs") or {}),
900
+ outputs=dict(entry.get("outputs") or {}),
901
+ input_units=dict(entry.get("input_units") or {}),
902
+ output_units=dict(entry.get("output_units") or {}),
903
+ output_pins=dict(entry.get("output_pins") or {}),
904
+ measurements=entry.get("measurements") or [],
905
+ instruments=instruments,
906
+ )
907
+ return row.to_flat_dict(at_rest=True)
908
+
909
+
910
+ def build_vector_row(
911
+ *,
912
+ run_context: dict[str, Any],
913
+ entry: dict[str, Any],
914
+ run_outcome: str | None,
915
+ run_ended_at: datetime | None,
916
+ instruments: list[dict[str, Any]],
917
+ ) -> dict[str, Any]:
918
+ """Build one ``record_type = 'vector'`` row from a vector manifest entry.
919
+
920
+ Vector rows are the leaf carriers for ALL emitted VectorStarted/VectorEnded
921
+ events — Mode-1 (parametrize outer), class-outer sweeps, and Mode-2
922
+ in-body iterations (the ``vectors`` fixture / ``run_vector`` loop). Each
923
+ carries the iteration's own ``(step_path, vector_index, retry)`` identity,
924
+ ``inputs`` (this iteration's conditions), ``outputs``, nested
925
+ ``measurements``, and ``vector_outcome``. ``measurement_*`` scalar
926
+ columns are NULL (measurements live in the nested list).
927
+
928
+ ``entry`` is one vector manifest entry as produced by
929
+ ``vector_entry_dict``. Mirrors :func:`build_step_row` so both record
930
+ kinds share the same write boundary.
931
+ """
932
+ ctx = dict(run_context)
933
+ ctx["run_ended_at"] = run_ended_at
934
+ raw_vi = entry.get("vector_index")
935
+ raw_retry = entry.get("retry")
936
+ raw_idx = entry.get("index")
937
+ row = RunParquetRow(
938
+ record_type="vector",
939
+ **ctx,
940
+ step_name=entry.get("name") or "",
941
+ step_index=int(raw_idx) if raw_idx is not None else 0,
942
+ step_path=entry.get("step_path") or "",
943
+ step_started_at=_to_datetime(entry.get("step_started_at")),
944
+ step_ended_at=_to_datetime(entry.get("step_ended_at")),
945
+ step_node_id=entry.get("node_id"),
946
+ step_module=entry.get("module"),
947
+ step_file=entry.get("file"),
948
+ step_class=entry.get("class_name"),
949
+ step_function=entry.get("function"),
950
+ step_markers=entry.get("markers"),
951
+ step_outcome=None,
952
+ step_retry=entry.get("step_retry") or 0,
953
+ # A vector row MUST have a concrete index (0..N) — vector_entry_dict
954
+ # always supplies one; a None here is a real bug, caught loudly by
955
+ # RunParquetRow._check_grain_invariant rather than masked as vector 0.
956
+ vector_index=raw_vi,
957
+ vector_outer_index=entry.get("vector_outer_index"),
958
+ vector_retry=raw_retry if raw_retry is not None else 0,
959
+ vector_started_at=_to_datetime(entry.get("started_at")),
960
+ vector_ended_at=_to_datetime(entry.get("ended_at")),
961
+ vector_outcome=entry.get("outcome"),
962
+ measurement_name=None,
963
+ run_outcome=run_outcome,
964
+ inputs=dict(entry.get("inputs") or {}),
965
+ outputs=dict(entry.get("outputs") or {}),
966
+ input_units=dict(entry.get("input_units") or {}),
967
+ output_units=dict(entry.get("output_units") or {}),
968
+ output_pins=dict(entry.get("output_pins") or {}),
969
+ measurements=entry.get("measurements") or [],
970
+ instruments=instruments,
971
+ )
972
+ return row.to_flat_dict(at_rest=True)
973
+
974
+
975
+ def vector_entry_dict(
976
+ *,
977
+ index: int,
978
+ name: str,
979
+ node_id: str | None,
980
+ file: str | None,
981
+ function: str | None,
982
+ class_name: str | None,
983
+ module: str | None,
984
+ step_path: str,
985
+ markers: str | None,
986
+ step_started_at: datetime | None,
987
+ step_ended_at: datetime | None,
988
+ vector_index: int,
989
+ vector_outer_index: int | None = None,
990
+ retry: int,
991
+ step_retry: int = 0,
992
+ outcome: str | None,
993
+ started_at: datetime | None,
994
+ ended_at: datetime | None,
995
+ inputs: dict[str, Any] | None = None,
996
+ outputs: dict[str, Any] | None = None,
997
+ input_units: dict[str, str] | None = None,
998
+ output_units: dict[str, str] | None = None,
999
+ output_pins: dict[str, str] | None = None,
1000
+ measurements: list[dict[str, Any]] | None = None,
1001
+ instrument_records: list[dict[str, Any]] | None = None,
1002
+ ) -> dict[str, Any]:
1003
+ """Single source of truth for one in-body vector manifest entry's shape.
1004
+
1005
+ Distinct from :func:`step_entry_dict` — a vector entry keys on
1006
+ ``(step_path, vector_outer_index, vector_index, retry)`` and carries
1007
+ vector-grain timing and outcome. ``step_retry`` is the enclosing step's
1008
+ outer (item) attempt. Timestamps are serialised here.
1009
+ """
1010
+ return {
1011
+ "index": index,
1012
+ "name": name,
1013
+ "node_id": node_id,
1014
+ "file": file,
1015
+ "function": function,
1016
+ "class_name": class_name,
1017
+ "module": module,
1018
+ "step_path": step_path,
1019
+ "markers": markers,
1020
+ "step_started_at": step_started_at.isoformat() if step_started_at else None,
1021
+ "step_ended_at": step_ended_at.isoformat() if step_ended_at else None,
1022
+ "vector_index": vector_index,
1023
+ "vector_outer_index": vector_outer_index,
1024
+ "retry": retry,
1025
+ "step_retry": step_retry,
1026
+ "outcome": outcome,
1027
+ "started_at": started_at.isoformat() if started_at else None,
1028
+ "ended_at": ended_at.isoformat() if ended_at else None,
1029
+ "inputs": inputs or {},
1030
+ "outputs": outputs or {},
1031
+ "input_units": input_units or {},
1032
+ "output_units": output_units or {},
1033
+ "output_pins": output_pins or {},
1034
+ "measurements": measurements or [],
1035
+ "instrument_records": instrument_records or [],
1036
+ }
1037
+
1038
+
1039
+ def step_entry_dict(
1040
+ *,
1041
+ index: int,
1042
+ name: str,
1043
+ node_id: str | None,
1044
+ file: str | None,
1045
+ function: str | None,
1046
+ class_name: str | None,
1047
+ module: str | None,
1048
+ step_path: str,
1049
+ description: str | None,
1050
+ markers: str | None,
1051
+ outcome: str | None,
1052
+ started_at: datetime | None,
1053
+ ended_at: datetime | None,
1054
+ vector_index: int | None = None,
1055
+ vector_outer_index: int | None = None,
1056
+ inputs: dict[str, Any] | None = None,
1057
+ outputs: dict[str, Any] | None = None,
1058
+ input_units: dict[str, str] | None = None,
1059
+ output_units: dict[str, str] | None = None,
1060
+ output_pins: dict[str, str] | None = None,
1061
+ measurements: list[dict[str, Any]] | None = None,
1062
+ measurement_count: int,
1063
+ step_retry: int = 0,
1064
+ instrument_records: list[dict[str, Any]] | None = None,
1065
+ ) -> dict[str, Any]:
1066
+ """Single source of truth for one step manifest entry's shape.
1067
+
1068
+ Shared by the batch path (``ParquetBackend._append_step_rows``) and the
1069
+ streaming path (``EventAccumulator._build_step_results_from_events``);
1070
+ both pre-compute their values and pass them as kwargs. Timestamps are
1071
+ serialised here, ``duration_s`` derived from start/end.
1072
+
1073
+ ``vector_index`` is always NULL at rest (step rows never carry their
1074
+ own sweep index). ``vector_outer_index`` identifies which outer (class-
1075
+ level) vector this step ran inside; NULL for top-level steps.
1076
+ """
1077
+ duration_s: float | None = None
1078
+ if started_at and ended_at:
1079
+ duration_s = (ended_at - started_at).total_seconds()
1080
+ return {
1081
+ "index": index,
1082
+ "name": name,
1083
+ "node_id": node_id,
1084
+ "file": file,
1085
+ "function": function,
1086
+ "class_name": class_name,
1087
+ "module": module,
1088
+ "step_path": step_path,
1089
+ "description": description,
1090
+ "markers": markers,
1091
+ "outcome": outcome,
1092
+ "started_at": started_at.isoformat() if started_at else None,
1093
+ "ended_at": ended_at.isoformat() if ended_at else None,
1094
+ "duration_s": duration_s,
1095
+ "vector_index": vector_index,
1096
+ "vector_outer_index": vector_outer_index,
1097
+ "inputs": inputs or {},
1098
+ "outputs": outputs or {},
1099
+ "input_units": input_units or {},
1100
+ "output_units": output_units or {},
1101
+ "output_pins": output_pins or {},
1102
+ "measurements": measurements or [],
1103
+ "measurement_count": measurement_count,
1104
+ "step_retry": step_retry,
1105
+ "instrument_records": instrument_records or [],
1106
+ }
1107
+
1108
+
1109
+ def _append_not_started(
1110
+ manifest: list[dict[str, Any]],
1111
+ collected_items: list[dict[str, str | int | None]],
1112
+ executed_node_ids: set[str],
1113
+ *,
1114
+ executed_vectors: set[tuple[str, int | None]] | None = None,
1115
+ ) -> None:
1116
+ """Append ``planned`` entries for collected items that never executed.
1117
+
1118
+ Used by the streaming path
1119
+ (``EventAccumulator._build_step_results_from_events``); the offline
1120
+ batch path (``ParquetBackend._append_step_rows``) writes exactly the
1121
+ steps present on the ``TestRun`` and has no separate not-started notion.
1122
+
1123
+ Each collected item maps to ONE execution at its own
1124
+ ``(step_path, vector_index)``. We add a "not-started" entry iff
1125
+ that specific pair did not appear in the executed events.
1126
+
1127
+ ``executed_vectors`` is keyed by ``(step_path, vector_index)`` —
1128
+ matching the accumulator's keying — so the check is unambiguous
1129
+ even when multiple pytest items (parametrize variants) share one
1130
+ logical step.
1131
+ """
1132
+ next_index = len(manifest)
1133
+ for ci in collected_items:
1134
+ node_id = ci.get("node_id") or ""
1135
+ step_path = ci.get("step_path") or ""
1136
+ raw_vi = ci.get("vector_index") or 0
1137
+ vi = raw_vi if isinstance(raw_vi, int) else 0
1138
+ if executed_vectors is not None and (step_path, vi) in executed_vectors:
1139
+ # This exact (step_path, vector_index) ran; nothing to fill in.
1140
+ continue
1141
+ if node_id in executed_node_ids and executed_vectors is None:
1142
+ # Legacy path (no per-vector info): node_id ran, so nothing to do.
1143
+ continue
1144
+ manifest.append(
1145
+ {
1146
+ "index": next_index,
1147
+ "name": ci.get("function") or node_id,
1148
+ "node_id": node_id,
1149
+ "file": ci.get("file"),
1150
+ "function": ci.get("function"),
1151
+ "class_name": ci.get("class_name"),
1152
+ "module": ci.get("module"),
1153
+ "step_path": step_path,
1154
+ "description": None,
1155
+ # No outcome stamped — the absence IS the receipt
1156
+ # that this step never ran (the row was collected
1157
+ # but its turn never came). Display layer renders
1158
+ # "Never Ran" for outcome=None at finalize time.
1159
+ "outcome": None,
1160
+ "started_at": None,
1161
+ "ended_at": None,
1162
+ # A never-ran marker is the logical step grain (vector_index
1163
+ # NULL), same as a ran step row — ``vi`` above is only the
1164
+ # dedup key against executed_vectors, not this row's grain.
1165
+ "vector_index": None,
1166
+ "inputs": {},
1167
+ "outputs": {},
1168
+ "measurement_count": 0,
1169
+ "step_retry": 0,
1170
+ }
1171
+ )
1172
+ next_index += 1