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,1143 @@
1
+ """Parquet storage backend for test results.
2
+
3
+ Implements an analysis-ready schema with one row per measurement and all
4
+ metadata denormalized for easy querying with DuckDB, Spark, Polars, etc.
5
+
6
+ Directory structure:
7
+ results/runs/{date}/
8
+ ├── {timestamp}_{run_id8}_{serial}.parquet # With serial (production)
9
+ ├── {timestamp}_{run_id8}.parquet # Without serial (dev/debug)
10
+ └── {timestamp}_{run_id8}_{serial}_ref/ # Reference data (waveforms, images)
11
+
12
+ The run_id (8-char prefix) sits in a fixed position right after the
13
+ timestamp so the optional serial can trail without shifting it; it
14
+ disambiguates two runs of the same serial that start in the same second
15
+ (otherwise the second would silently overwrite the first).
16
+
17
+ All timestamps are UTC for consistent cross-timezone analysis.
18
+
19
+ Schema design:
20
+ - One row per measurement
21
+ - All metadata denormalized onto each row
22
+ - Inputs lane: LIST<STRUCT> of stimulus conditions (role='input')
23
+ - Outputs lane: LIST<STRUCT> of observations (scalars inline, URIs for large data)
24
+ - Config snapshots in Parquet file-level metadata
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import io
30
+ import json
31
+ import logging
32
+ import pickle
33
+ from collections import defaultdict
34
+ from collections.abc import Callable, Generator
35
+ from contextlib import contextmanager
36
+ from datetime import datetime
37
+ from pathlib import Path, PurePosixPath
38
+ from typing import Any, BinaryIO
39
+ from uuid import UUID
40
+
41
+ import pyarrow as pa
42
+ import pyarrow.ipc as ipc
43
+ import pyarrow.parquet as pq
44
+
45
+ from testerkit.data._atomic import atomic_write_table
46
+ from testerkit.data.backends._event_accumulator import EventAccumulator
47
+ from testerkit.data.backends._row_helpers import (
48
+ HAS_NUMPY,
49
+ REF_PATH_PREFIX,
50
+ build_measurement_struct,
51
+ build_output_columns,
52
+ build_run_metadata,
53
+ build_run_row,
54
+ build_step_row,
55
+ build_vector_row,
56
+ decode_lane_structs,
57
+ run_context_from_run_started,
58
+ step_entry_dict,
59
+ vector_entry_dict,
60
+ )
61
+ from testerkit.data.data_dir import resolve_data_dir
62
+ from testerkit.data.models import (
63
+ UUT,
64
+ Measurement,
65
+ Outcome,
66
+ RunSummary,
67
+ TestRun,
68
+ TestStep,
69
+ TestVector,
70
+ Waveform,
71
+ _utcnow,
72
+ )
73
+ from testerkit.data.ref import is_ref, parse_channel_uri, ref_scheme
74
+ from testerkit.data.run_store import RunStore
75
+ from testerkit.data.schemas import (
76
+ SCHEMA_VERSION,
77
+ _build_write_schema,
78
+ table_from_rows,
79
+ )
80
+
81
+ logger = logging.getLogger(__name__)
82
+
83
+ # Suffix patterns that identify signal-path metadata keys among an
84
+ # inputs/outputs lane name. A key ending in one of these suffixes is
85
+ # metadata, not a stimulus value.
86
+ _STIMULUS_SUFFIXES = ("_instrument", "_resource", "_channel", "_uut_pin", "_fixture_connection")
87
+
88
+ # Outcome priority for deterministic worst-case selection from a set.
89
+ # Lower rank = worse outcome. Ties (same rank) pick the same "worst" value.
90
+ OUTCOME_RANK: dict[str, int] = {"failed": 0, "errored": 1, "skipped": 2, "passed": 3}
91
+
92
+
93
+ def _run_parquet_filename(timestamp: str, run_id: str, serial: str) -> str:
94
+ """Per-run parquet filename: ``{timestamp}_{run_id8}[_{serial}]``.
95
+
96
+ The 8-char run_id sits in a fixed position right after the timestamp; the
97
+ optional serial trails so its absence never shifts the leading parts. The
98
+ run_id prefix disambiguates two runs of the same serial that start in the
99
+ same second — without it the second would silently overwrite the first.
100
+ """
101
+ parts = [timestamp]
102
+ if run_id:
103
+ parts.append(run_id[:8])
104
+ if serial:
105
+ parts.append(serial)
106
+ return "_".join(parts) + ".parquet"
107
+
108
+
109
+ def _is_stimulus_key(name: str) -> bool:
110
+ """True if ``name`` is stimulus signal-path metadata, not a param value."""
111
+ return any(name.endswith(s) for s in _STIMULUS_SUFFIXES)
112
+
113
+
114
+ def _params_from_inputs(inputs: dict[str, Any]) -> dict[str, Any]:
115
+ """Split decoded inputs into vector params (drop stimulus signal-path keys)."""
116
+ return {k: v for k, v in inputs.items() if not _is_stimulus_key(k)}
117
+
118
+
119
+ def _build_parquet_metadata(
120
+ *,
121
+ environment_json: str | None = None,
122
+ custom_metadata: dict[str, Any] | None = None,
123
+ ) -> dict[bytes, bytes]:
124
+ """Build Parquet file-level metadata.
125
+
126
+ Shared by ParquetBackend (from TestRun) and
127
+ :func:`materialize_run_to_parquet` (from cached RunStarted event).
128
+ """
129
+ metadata: dict[bytes, bytes] = {}
130
+
131
+ if environment_json:
132
+ metadata[b"environment_json"] = environment_json.encode("utf-8")
133
+ if custom_metadata:
134
+ metadata[b"custom_metadata"] = json.dumps(custom_metadata, default=str).encode("utf-8")
135
+
136
+ metadata[b"schema_version"] = SCHEMA_VERSION.encode()
137
+ return metadata
138
+
139
+
140
+ class ParquetMeasurementWriter:
141
+ """Writes measurement RecordBatches as Parquet files."""
142
+
143
+ def write_batch(
144
+ self,
145
+ batch: pa.RecordBatch,
146
+ path: Path,
147
+ *,
148
+ file_metadata: dict[bytes, bytes] | None = None,
149
+ ) -> Path:
150
+ """Write a measurement batch to a Parquet file.
151
+
152
+ Converts the RecordBatch to a Table, attaches file-level metadata,
153
+ and writes atomically (temp file + os.replace).
154
+ """
155
+ table = pa.Table.from_batches([batch])
156
+ if file_metadata:
157
+ table = table.replace_schema_metadata(file_metadata)
158
+ atomic_write_table(table, path)
159
+ return path
160
+
161
+
162
+ class ParquetBackend:
163
+ """Save test results to Parquet files with analysis-ready schema.
164
+
165
+ Key design principles:
166
+ 1. One row per measurement - enables flexible queries
167
+ 2. All metadata denormalized - no joins needed
168
+ 3. Dynamic schema - inputs/outputs lanes vary per test
169
+ 4. Config snapshots in file metadata - full reconstruction possible
170
+ """
171
+
172
+ def __init__(self, data_dir: Path | str | None = None):
173
+ # ``data_dir`` is the parent (the project's results dir
174
+ # containing ``runs/``, ``events/``, ``channels/`` subdirs).
175
+ # Aligns with ``RunStore._data_dir`` and
176
+ # ``ProjectConfig.data_dir``. Parquets are written under
177
+ # ``self._runs_dir = data_dir / "runs"``.
178
+ self.data_dir = resolve_data_dir(data_dir)
179
+ self.data_dir.mkdir(parents=True, exist_ok=True)
180
+ self._runs_dir = self.data_dir / "runs"
181
+ self._runs_dir.mkdir(parents=True, exist_ok=True)
182
+ self._writer = ParquetMeasurementWriter()
183
+
184
+ def save_test_run(
185
+ self,
186
+ test_run: TestRun,
187
+ instrument_records: list[dict[str, Any]] | None = None,
188
+ ) -> Path:
189
+ """Save test run to Parquet with analysis-ready schema.
190
+
191
+ Creates files:
192
+ runs/{date}/{timestamp}_{run_id8}_{serial}.parquet (with serial)
193
+ runs/{date}/{timestamp}_{run_id8}.parquet (without serial)
194
+
195
+ All timestamps are UTC for consistent cross-timezone analysis.
196
+
197
+ Args:
198
+ test_run: Complete TestRun with steps, vectors, and measurements.
199
+
200
+ Returns:
201
+ Path to the Parquet file.
202
+ """
203
+ # UTC timestamp for filename (compact ISO 8601 basic format)
204
+ timestamp = test_run.started_at.strftime("%Y%m%dT%H%M%SZ")
205
+ date_str = test_run.started_at.strftime("%Y-%m-%d")
206
+ uut_serial = test_run.uut.serial.strip() if test_run.uut.serial else ""
207
+
208
+ # Create date directory under runs/
209
+ date_dir = self._runs_dir / date_str
210
+ date_dir.mkdir(parents=True, exist_ok=True)
211
+
212
+ # timestamp, then run_id (always present, fixed position), then the
213
+ # optional serial last — so serial's absence never shifts the leading
214
+ # parts and the run_id breaks same-second same-serial collisions.
215
+ filename = _run_parquet_filename(timestamp, str(test_run.id), uut_serial)
216
+
217
+ # Determine parquet path for _ref/ directory creation
218
+ parquet_path = date_dir / filename
219
+
220
+ # Run row first — written at the start of the parquet so row-
221
+ # group min/max stats prune ``WHERE record_type = 'run'`` to the
222
+ # first row group, and so ``parquet-tools head`` surfaces run
223
+ # identity immediately. Always present, including for runs with
224
+ # no steps or measurements (in which case the run row alone is
225
+ # the entire parquet — naturally handles the placeholder case).
226
+ rows: list[dict[str, Any]] = [self._build_run_row(test_run, instrument_records)]
227
+
228
+ # Append a ``record_type='step'`` row for every (step, vector)
229
+ # — containers, action steps, swept variants, and
230
+ # planned-but-unrun vectors all live in the same parquet under
231
+ # the unified RUN_ROW_SCHEMA. Step rows are emitted regardless
232
+ # of whether measurements exist for the same pair; queries
233
+ # discriminate via ``record_type`` rather than via row absence.
234
+ self._append_step_rows(test_run, rows)
235
+
236
+ # Convert to RecordBatch with explicit schema
237
+ schema = _build_write_schema(rows)
238
+ table = table_from_rows(rows, schema)
239
+ batch = table.combine_chunks().to_batches()[0]
240
+
241
+ # Single unified parquet — write triggers daemon notify.
242
+ metadata = self._build_file_metadata(test_run)
243
+ self._writer.write_batch(batch, parquet_path, file_metadata=metadata)
244
+
245
+ return parquet_path
246
+
247
+ def _append_step_rows(self, test_run: TestRun, rows: list[dict[str, Any]]) -> None:
248
+ """Append step and vector rows for the offline batch path.
249
+
250
+ Used by the batch writer (``save_test_run``). For each step in the
251
+ TestRun:
252
+ - ONE step row (vector_index always NULL — a step row never carries
253
+ its own sweep index, top-level or nested)
254
+ - ONE vector row per vector in ``step.vectors`` (from own VectorStarted
255
+ events — Mode-1 outer, class-outer, or Mode-2 in-body)
256
+
257
+ Step-scope measurements (in ``step.measurements``) ride the step row;
258
+ vector measurements ride their vector row.
259
+ """
260
+ if not test_run.steps:
261
+ return
262
+
263
+ run_context = build_run_metadata(test_run)
264
+ run_outcome = test_run.outcome.value if test_run.outcome else None
265
+ run_ended_at = test_run.ended_at
266
+ ref_saver = self._filestore_ref_saver(test_run)
267
+
268
+ for index, step in enumerate(test_run.steps):
269
+ step_path = step.step_path or step.name
270
+ step_started = step.started_at or test_run.started_at
271
+ step_ended = step.ended_at or test_run.ended_at or test_run.started_at
272
+ instruments = list(step.instrument_records or [])
273
+
274
+ # step.vector_index is always NULL at rest — the invariant the
275
+ # event-driven accumulator honors (a step row never carries its
276
+ # own sweep index, swept or not; vectors are separate rows).
277
+ at_rest_vi: int | None = None
278
+
279
+ # Same canonical struct builder the vector rows use below — an inline
280
+ # copy here had drifted (it isoformat'd the timestamp to a string, but
281
+ # the struct type is a TIMESTAMP), which only surfaced once a caller
282
+ # actually populated step-scope measurements.
283
+ step_meas = [build_measurement_struct(m) for m in (step.measurements or [])]
284
+
285
+ step_entry = step_entry_dict(
286
+ index=index,
287
+ name=step.name,
288
+ node_id=step.node_id,
289
+ file=step.file,
290
+ function=step.function,
291
+ class_name=step.class_name,
292
+ module=step.module,
293
+ step_path=step_path,
294
+ description=step.description,
295
+ markers=step.markers,
296
+ outcome=step.outcome.value if step.outcome else None,
297
+ started_at=step_started,
298
+ ended_at=step_ended,
299
+ vector_index=at_rest_vi,
300
+ inputs=dict(step.inputs),
301
+ outputs=dict(step.outputs),
302
+ measurements=step_meas,
303
+ measurement_count=len(step_meas),
304
+ step_retry=step.retry,
305
+ instrument_records=instruments,
306
+ )
307
+ rows.append(
308
+ build_step_row(
309
+ run_context=run_context,
310
+ entry=step_entry,
311
+ run_outcome=run_outcome,
312
+ run_ended_at=run_ended_at,
313
+ instruments=instruments,
314
+ )
315
+ )
316
+
317
+ for vec_offset, vector in enumerate(step.vectors):
318
+ vec_idx = vector.index if vector.index is not None else vec_offset
319
+ vec_entry = vector_entry_dict(
320
+ index=index,
321
+ name=step.name,
322
+ node_id=step.node_id,
323
+ file=step.file,
324
+ function=step.function,
325
+ class_name=step.class_name,
326
+ module=step.module,
327
+ step_path=step_path,
328
+ markers=step.markers,
329
+ step_started_at=step_started,
330
+ step_ended_at=step_ended,
331
+ vector_index=vec_idx,
332
+ retry=vector.retry,
333
+ step_retry=step.retry,
334
+ outcome=vector.outcome.value if vector.outcome else None,
335
+ started_at=vector.started_at or step_started,
336
+ ended_at=vector.ended_at or step_ended,
337
+ inputs=dict(vector.params),
338
+ outputs=build_output_columns(vector, ref_saver=ref_saver),
339
+ input_units=dict(vector.param_units),
340
+ output_units=dict(vector.observation_units),
341
+ output_pins=dict(vector.observation_pins),
342
+ measurements=[build_measurement_struct(m) for m in vector.measurements],
343
+ instrument_records=instruments,
344
+ )
345
+ rows.append(
346
+ build_vector_row(
347
+ run_context=run_context,
348
+ entry=vec_entry,
349
+ run_outcome=run_outcome,
350
+ run_ended_at=run_ended_at,
351
+ instruments=instruments,
352
+ )
353
+ )
354
+
355
+ @contextmanager
356
+ def _run_store_ctx(self) -> Generator[RunStore, None, None]:
357
+ """Yield a configured RunStore, closing it on exit.
358
+
359
+ ParquetBackend's read methods (``list_runs``, ``find_run_file``,
360
+ ``get_run``, etc.) delegate to RunStore — this helper centralises
361
+ the lifecycle.
362
+
363
+ Both classes use ``data_dir`` to mean *the parent* (the
364
+ results dir containing ``runs/``, ``events/``, ``channels/``).
365
+ Each appends its own subdir internally.
366
+ """
367
+ store = RunStore(_data_dir=self.data_dir)
368
+ try:
369
+ yield store
370
+ finally:
371
+ store.close()
372
+
373
+ def _filestore_ref_saver(self, test_run: TestRun) -> Callable[[str, str, Any], str]:
374
+ """Build the FileStore-backed ref_saver for this run's blobs.
375
+
376
+ Item 1d: ref writes route through FileStore (one canonical home
377
+ for all blobs) instead of the per-parquet sibling ``{stem}_ref/``.
378
+ The vector_id-shortened prefix on the FileStore filename preserves
379
+ the audit trail. Shared by the measurement and step-row writers so
380
+ a blob is claim-checked the same way regardless of which lane carries it.
381
+
382
+ Lazy import: ``data.files`` transitively pulls PIL / serializers
383
+ that are only needed when this writer runs. Top-level would add
384
+ load cost to every consumer that imports ParquetBackend.
385
+ """
386
+ from testerkit.data.files import get_filestore # noqa: PLC0415
387
+
388
+ filestore = get_filestore()
389
+ session_id_str = str(test_run.session_id)
390
+
391
+ def ref_saver(vector_id: str, key: str, value: Any) -> str:
392
+ return filestore.write(
393
+ key,
394
+ value,
395
+ session_id=session_id_str,
396
+ vector_id=vector_id,
397
+ )
398
+
399
+ return ref_saver
400
+
401
+ def _build_run_row(
402
+ self,
403
+ test_run: TestRun,
404
+ instrument_records: list[dict[str, Any]] | None = None,
405
+ ) -> dict[str, Any]:
406
+ """Build the single ``record_type='run'`` row for the parquet.
407
+
408
+ Carries run-level identity / UUT / station / fixture / environment
409
+ columns. Step and measurement columns are NULL. Always present (one per
410
+ parquet); for empty runs it is the entire parquet.
411
+ """
412
+ return build_run_row(
413
+ run_context=build_run_metadata(test_run),
414
+ run_outcome=test_run.outcome.value if test_run.outcome else None,
415
+ run_ended_at=test_run.ended_at,
416
+ instruments=instrument_records or [],
417
+ )
418
+
419
+ def _build_file_metadata(self, test_run: TestRun) -> dict[bytes, bytes]:
420
+ """Build Parquet file-level metadata."""
421
+ return _build_parquet_metadata(
422
+ environment_json=test_run.environment_json,
423
+ custom_metadata=dict(test_run.custom_metadata) or None,
424
+ )
425
+
426
+ def list_runs(self, limit: int = 50) -> list[RunSummary]:
427
+ """List recent test runs. Delegates to RunStore."""
428
+ with self._run_store_ctx() as store:
429
+ return store.list_runs(limit=limit)
430
+
431
+ def find_run_file(self, run_id: str) -> Path | None:
432
+ """Find parquet file for a run_id. Delegates to RunStore."""
433
+ with self._run_store_ctx() as store:
434
+ return store.find_run_file(run_id)
435
+
436
+ def get_run(self, run_id: str) -> RunSummary | None:
437
+ """Get a specific test run by ID. Delegates to RunStore."""
438
+ with self._run_store_ctx() as store:
439
+ return store.get_run(run_id)
440
+
441
+ def get_measurements(self, run_id: str) -> list[dict[str, Any]]:
442
+ """Get all measurements for a specific test run. Delegates to RunStore."""
443
+ with self._run_store_ctx() as store:
444
+ return store.get_measurements(run_id)
445
+
446
+ def get_measurement(
447
+ self,
448
+ file_path: str,
449
+ measurement_name: str,
450
+ *,
451
+ step_index: int | None = None,
452
+ ) -> list[dict[str, Any]]:
453
+ """Get rows for a specific measurement name with row-group pushdown."""
454
+ with self._run_store_ctx() as store:
455
+ return store.get_measurement(file_path, measurement_name, step_index=step_index)
456
+
457
+ def get_vectors(self, run_id: str) -> list[dict]:
458
+ """Get unique test vectors for a specific test run."""
459
+ measurements = self.get_measurements(run_id)
460
+ if not measurements:
461
+ return []
462
+
463
+ # Group by (step_name, vector_index, retry)
464
+ vectors_seen: dict[tuple, dict] = {}
465
+ for m in measurements:
466
+ key = (m.get("step_name"), m.get("vector_index"), m.get("vector_retry"))
467
+ if key not in vectors_seen:
468
+ # Extract vector-level info
469
+ vector_info = {
470
+ "test_run_id": m.get("run_id"),
471
+ "step_name": m.get("step_name"),
472
+ "index": m.get("vector_index"),
473
+ "retry": m.get("vector_retry"),
474
+ "outcome": m.get("vector_outcome"),
475
+ "started_at": m.get("vector_started_at"),
476
+ "ended_at": m.get("vector_ended_at"),
477
+ "uut_serial_number": m.get("uut_serial_number"),
478
+ "part_id": m.get("part_id"),
479
+ "station_id": m.get("station_id"),
480
+ }
481
+ vector_info["params"] = _params_from_inputs(m.get("inputs") or {})
482
+ vectors_seen[key] = vector_info
483
+
484
+ return list(vectors_seen.values())
485
+
486
+ def get_run_metadata(self, run_id: str) -> dict[str, str] | None:
487
+ """Get file-level metadata (config snapshots) for a run."""
488
+ with self._run_store_ctx() as store:
489
+ pq_file = store.find_run_file(run_id)
490
+ if pq_file is None:
491
+ return None
492
+
493
+ try:
494
+ pf = pq.ParquetFile(pq_file)
495
+ raw_metadata = pf.schema_arrow.metadata or {}
496
+ return {k.decode("utf-8"): v.decode("utf-8") for k, v in raw_metadata.items()}
497
+ except (OSError, pa.ArrowInvalid):
498
+ return None
499
+
500
+ # =========================================================================
501
+ # TestRun Reconstruction (for post-hoc export)
502
+ # =========================================================================
503
+
504
+ def reconstruct_test_run(self, run_id: str) -> TestRun:
505
+ """Reconstruct a TestRun model from a stored Parquet file.
506
+
507
+ Groups denormalized rows back into the TestRun → TestStep → TestVector
508
+ → Measurement hierarchy. Used by exporters for post-hoc conversion.
509
+
510
+ Args:
511
+ run_id: Full or partial run ID.
512
+
513
+ Returns:
514
+ Reconstructed TestRun model.
515
+
516
+ Raises:
517
+ FileNotFoundError: If no Parquet file found for the run ID.
518
+ """
519
+ pq_file = self.find_run_file(run_id)
520
+ if pq_file is None:
521
+ raise FileNotFoundError(f"No Parquet file found for run '{run_id}' in {self.data_dir}/")
522
+ return reconstruct_test_run_from_file(pq_file)
523
+
524
+ def save_from_rows(
525
+ self,
526
+ rows: list[dict[str, Any]],
527
+ started_at: datetime,
528
+ uut_serial: str,
529
+ run_id: str,
530
+ file_metadata: dict[bytes, bytes] | None = None,
531
+ ) -> Path:
532
+ """Save pre-built flat row dicts to Parquet.
533
+
534
+ Used by :func:`materialize_run_to_parquet` to write
535
+ accumulated rows without needing a TestRun object.
536
+ """
537
+ if not rows:
538
+ raise ValueError("save_from_rows() requires at least one row")
539
+
540
+ timestamp = started_at.strftime("%Y%m%dT%H%M%SZ")
541
+ date_str = started_at.strftime("%Y-%m-%d")
542
+ uut_serial = uut_serial.strip() if uut_serial else ""
543
+
544
+ date_dir = self._runs_dir / date_str
545
+ date_dir.mkdir(parents=True, exist_ok=True)
546
+
547
+ filename = _run_parquet_filename(timestamp, run_id, uut_serial)
548
+
549
+ parquet_path = date_dir / filename
550
+
551
+ schema = _build_write_schema(rows)
552
+ table = table_from_rows(rows, schema)
553
+ batch = table.combine_chunks().to_batches()[0]
554
+
555
+ self._writer.write_batch(batch, parquet_path, file_metadata=file_metadata)
556
+
557
+ return parquet_path
558
+
559
+
560
+ # EventAccumulator is defined in _event_accumulator.py (lightweight,
561
+ # no parquet/subscriber deps) and imported above. The runs daemon
562
+ # holds one EventAccumulator per in-flight run in its AccumulatorPool;
563
+ # materialize_run_to_parquet (below) takes one of those and writes the
564
+ # canonical parquet.
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Free-standing materializer — accumulator state → parquet file
569
+ # ---------------------------------------------------------------------------
570
+ #
571
+ # Called by the runs daemon's event-dispatch loop when ``RunEnded`` lands
572
+ # (real or synthetic-from-orphan-sweep). The daemon's
573
+ # :class:`~testerkit.data.accumulator_pool.AccumulatorPool` already holds
574
+ # the run's :class:`EventAccumulator`; the materializer writes its
575
+ # state to disk via :class:`ParquetBackend`. No subscriber class
576
+ # needed — projection lives on the accumulator, writing lives here.
577
+
578
+
579
+ def _build_unified_rows_from_acc(
580
+ acc: EventAccumulator, run_ended_at: datetime, run_outcome: str
581
+ ) -> list[dict[str, Any]]:
582
+ """Build the per-run unified-rows list from an accumulator's state.
583
+
584
+ Free-standing because the daemon calls it with accumulator instances
585
+ drawn from its pool; not method-on-class because EventAccumulator is
586
+ the pure projection and shouldn't know about output formats.
587
+
588
+ Record order: one ``run`` row, then ``vector`` rows (each carrying its
589
+ nested ``measurements``), then ``step`` rows. No fabricated rows: a
590
+ verify-less vector / assert fail / observation-only execution is
591
+ represented by its vector or step record, never a synthesized
592
+ measurement.
593
+ """
594
+ rows: list[dict[str, Any]] = []
595
+ run_row = _build_run_row_from_acc(acc, run_ended_at=run_ended_at, run_outcome=run_outcome)
596
+ if run_row is not None:
597
+ rows.append(run_row)
598
+ for entry in acc._build_vector_results_from_events():
599
+ vector_row = _build_vector_row_from_acc(
600
+ acc, entry, run_ended_at=run_ended_at, run_outcome=run_outcome
601
+ )
602
+ if vector_row is not None:
603
+ rows.append(vector_row)
604
+ for entry in acc._build_step_results_from_events():
605
+ step_row = _build_step_row_from_acc(
606
+ acc, entry, run_ended_at=run_ended_at, run_outcome=run_outcome
607
+ )
608
+ if step_row is not None:
609
+ rows.append(step_row)
610
+ return rows
611
+
612
+
613
+ def _build_run_row_from_acc(
614
+ acc: EventAccumulator, *, run_ended_at: datetime, run_outcome: str
615
+ ) -> dict[str, Any] | None:
616
+ s = acc._run_started
617
+ if not s:
618
+ return None
619
+ return build_run_row(
620
+ run_context=run_context_from_run_started(s, s, include_env=True),
621
+ run_outcome=run_outcome,
622
+ run_ended_at=run_ended_at,
623
+ instruments=acc._build_instrument_records(),
624
+ )
625
+
626
+
627
+ def _build_step_row_from_acc(
628
+ acc: EventAccumulator,
629
+ entry: dict[str, Any],
630
+ *,
631
+ run_ended_at: datetime,
632
+ run_outcome: str,
633
+ ) -> dict[str, Any] | None:
634
+ s = acc._run_started
635
+ if not s:
636
+ return None
637
+ return build_step_row(
638
+ run_context=run_context_from_run_started(s, s, include_env=True),
639
+ entry=entry,
640
+ run_outcome=run_outcome,
641
+ run_ended_at=run_ended_at,
642
+ instruments=entry.get("instrument_records") or [],
643
+ )
644
+
645
+
646
+ def _build_vector_row_from_acc(
647
+ acc: EventAccumulator,
648
+ entry: dict[str, Any],
649
+ *,
650
+ run_ended_at: datetime,
651
+ run_outcome: str,
652
+ ) -> dict[str, Any] | None:
653
+ s = acc._run_started
654
+ if not s:
655
+ return None
656
+ return build_vector_row(
657
+ run_context=run_context_from_run_started(s, s, include_env=True),
658
+ entry=entry,
659
+ run_outcome=run_outcome,
660
+ run_ended_at=run_ended_at,
661
+ instruments=entry.get("instrument_records") or [],
662
+ )
663
+
664
+
665
+ def _build_file_metadata_from_acc(acc: EventAccumulator) -> dict[bytes, bytes]:
666
+ s = acc._run_started
667
+ if not s:
668
+ return _build_parquet_metadata()
669
+ return _build_parquet_metadata(
670
+ environment_json=s.environment_json,
671
+ custom_metadata=dict(s.custom_metadata) or None,
672
+ )
673
+
674
+
675
+ def materialize_run_to_parquet(
676
+ acc: EventAccumulator,
677
+ output_dir: Path,
678
+ *,
679
+ outcome: str | None = None,
680
+ run_ended_at: datetime | None = None,
681
+ ) -> Path | None:
682
+ """Materialize an accumulator's state to a per-run parquet file.
683
+
684
+ Returns the parquet path on success, ``None`` when nothing was
685
+ written (no ``RunStarted`` seen, or empty run with no
686
+ measurements / executed steps).
687
+
688
+ Args:
689
+ acc: The :class:`EventAccumulator` holding the run's events.
690
+ output_dir: Where to write — the runs daemon's data dir.
691
+ outcome: Final run outcome. ``None`` falls back to ``"aborted"``
692
+ (matches the orphan-sweep semantic).
693
+ run_ended_at: Wall-clock time the run ended. Defaults to ``now()``.
694
+ """
695
+ s = acc._run_started
696
+ if not s:
697
+ return None
698
+
699
+ ended_at = run_ended_at if run_ended_at is not None else _utcnow()
700
+ final_outcome = outcome if outcome is not None else "aborted"
701
+
702
+ rows = _build_unified_rows_from_acc(acc, ended_at, final_outcome)
703
+ if not rows:
704
+ return None
705
+
706
+ backend = ParquetBackend(data_dir=output_dir)
707
+ return backend.save_from_rows(
708
+ rows,
709
+ started_at=s.occurred_at,
710
+ uut_serial=s.uut_serial_number,
711
+ run_id=str(s.run_id) if s.run_id else "",
712
+ file_metadata=_build_file_metadata_from_acc(acc),
713
+ )
714
+
715
+
716
+ def _resolve_ref_to_path(parquet_path: Path | None, ref: str) -> Path | None:
717
+ """Resolve a file ref to an on-disk path. Item 1d dual-path.
718
+
719
+ Returns ``None`` for non-file references (channel://, plain
720
+ strings) or unresolvable URIs. Callers decide what to do with
721
+ the un-resolution (typically return the ref as-is).
722
+
723
+ ``parquet_path`` is only consulted for legacy
724
+ ``file://_ref/{filename}`` URIs (per-parquet sidecar layout).
725
+ New FileStore-shape URIs (``file://{date}/{session_id}/{filename}``)
726
+ resolve without it.
727
+ """
728
+ raw = ref
729
+ if raw.startswith("file://"):
730
+ raw = raw[len("file://") :]
731
+
732
+ # Legacy: starts with the per-parquet ``_ref/`` prefix.
733
+ if raw.startswith(REF_PATH_PREFIX):
734
+ if parquet_path is None:
735
+ return None
736
+ filename = raw[len(REF_PATH_PREFIX) :]
737
+ return parquet_path.parent / (parquet_path.stem + "_ref") / filename
738
+
739
+ # New (item 1d) FileStore refs (``file://{date}/{session_id}/{filename}``) are
740
+ # NOT path-resolved here — ``load_file`` reads them as bytes through the
741
+ # blob backend (the store owns where they live; no path crosses out).
742
+ return None
743
+
744
+
745
+ def load_file(parquet_path: Path | None, ref: str) -> Any:
746
+ """Load a file reference (``file://`` URI or legacy ``_ref/`` path).
747
+
748
+ Dual-path post-item-1d:
749
+
750
+ - New: ``file://{date}/{session_id}/{filename}`` — resolves through
751
+ FileStore (canonical home for all artifacts).
752
+ - Legacy: ``file://_ref/{filename}`` or bare ``_ref/{filename}`` —
753
+ resolves to the parquet's sibling ``{stem}_ref/`` directory.
754
+ Stays for the lifetime of pre-1d parquets on disk.
755
+
756
+ Args:
757
+ parquet_path: Path to the parquet file (used to locate the
758
+ legacy ``_ref/`` sibling dir).
759
+ ref: Reference string — any of the three shapes above.
760
+
761
+ Returns:
762
+ Loaded data in appropriate format:
763
+ - .npz → Waveform model (if has Y, t0, dt) or dict
764
+ - .npy → numpy array
765
+ - .json → dict or Pydantic model
766
+ - .bin → bytes
767
+ - .arrow → Arrow Table
768
+ - .pkl → pickled object
769
+ - Other → raw file path
770
+ """
771
+ # FileStore artifact ref → read the bytes through the backend (the store
772
+ # owns where they live: local disk or a remote object store). Legacy
773
+ # ``file://_ref/`` / bare ``_ref/`` refs predate FileStore and stay
774
+ # path-based (a per-parquet sibling dir).
775
+ raw = ref[len("file://") :] if ref.startswith("file://") else ref
776
+ if ref.startswith("file://") and not raw.startswith(REF_PATH_PREFIX):
777
+ from testerkit.data.files import get_filestore # noqa: PLC0415
778
+
779
+ payload = get_filestore().read(ref)
780
+ if payload is None:
781
+ return ref # Unresolved / missing artifact
782
+ return _deserialize_ref(
783
+ PurePosixPath(raw).suffix.lower(),
784
+ lambda: io.BytesIO(payload),
785
+ ref,
786
+ fallback=payload,
787
+ )
788
+
789
+ path = _resolve_ref_to_path(parquet_path, ref)
790
+ if path is None or not path.exists():
791
+ return ref # Not a file reference, unresolved, or missing
792
+ return _deserialize_ref(path.suffix.lower(), lambda: path.open("rb"), ref, fallback=path)
793
+
794
+
795
+ def _deserialize_ref(
796
+ ext: str,
797
+ open_stream: Callable[[], BinaryIO],
798
+ ref: str,
799
+ *,
800
+ fallback: Any,
801
+ ) -> Any:
802
+ """Deserialize a file ref by extension from a binary stream.
803
+
804
+ Source-agnostic: ``open_stream`` yields a fresh readable for either a
805
+ FileStore artifact (``io.BytesIO`` over backend bytes) or a legacy
806
+ on-disk ref (the file handle). ``fallback`` is returned for unknown
807
+ extensions or when numpy is absent (the raw bytes for a FileStore ref,
808
+ the on-disk path for a legacy ref); a decode error returns ``ref``.
809
+ """
810
+ try:
811
+ if ext == ".npz":
812
+ if not HAS_NUMPY:
813
+ return fallback
814
+ # numpy import is deliberately deferred — top-level numpy imports
815
+ # add ~150ms to every consumer of this module (RunsQuery,
816
+ # MeasurementsQuery, the runs daemon). Readers that never touch
817
+ # .npz blobs should not pay it.
818
+ import numpy as np # noqa: PLC0415
819
+
820
+ with open_stream() as f:
821
+ data = dict(np.load(f, allow_pickle=True))
822
+ if "Y" in data and "t0" in data and "dt" in data:
823
+ attributes = {k: v for k, v in data.items() if k not in ("Y", "t0", "dt")}
824
+ # t0 is stored as ISO-8601 string by the serializer (datetime
825
+ # can't go into np.savez directly). Empty string = "unknown".
826
+ t0_str = str(data["t0"])
827
+ t0_val = datetime.fromisoformat(t0_str) if t0_str else None
828
+ return Waveform(
829
+ Y=data["Y"].tolist(),
830
+ t0=t0_val,
831
+ dt=float(data["dt"]),
832
+ attributes=attributes,
833
+ )
834
+ return data
835
+ elif ext == ".npy":
836
+ if not HAS_NUMPY:
837
+ return fallback
838
+ import numpy as np # noqa: PLC0415
839
+
840
+ with open_stream() as f:
841
+ return np.load(f)
842
+ elif ext == ".json":
843
+ with open_stream() as f:
844
+ return json.loads(f.read())
845
+ elif ext == ".bin":
846
+ with open_stream() as f:
847
+ return f.read()
848
+ elif ext == ".arrow":
849
+ with open_stream() as f:
850
+ return ipc.open_file(f).read_all()
851
+ elif ext == ".pkl":
852
+ with open_stream() as f:
853
+ return pickle.load(f)
854
+ else:
855
+ return fallback
856
+ except (
857
+ OSError,
858
+ ValueError,
859
+ json.JSONDecodeError,
860
+ pickle.UnpicklingError,
861
+ EOFError,
862
+ pa.ArrowInvalid,
863
+ ) as exc:
864
+ logger.warning("Failed to load reference %s: %s", ref, exc)
865
+ return ref
866
+
867
+
868
+ def load_ref(
869
+ value: str,
870
+ *,
871
+ parquet_path: Path | None = None,
872
+ channel_store: Any | None = None,
873
+ ) -> Any:
874
+ """Unified reference loader — dispatches on URI scheme.
875
+
876
+ Args:
877
+ value: URI string (``channel://...``, ``file://...``, or legacy ``_ref/...``).
878
+ parquet_path: Path to parquet file (needed for ``file://`` refs).
879
+ channel_store: ChannelStore instance (needed for ``channel://`` refs).
880
+
881
+ Returns:
882
+ Loaded data.
883
+ """
884
+ if not is_ref(value):
885
+ # Legacy _ref/ path without file:// prefix
886
+ if isinstance(value, str) and value.startswith(REF_PATH_PREFIX) and parquet_path:
887
+ return load_file(parquet_path, value)
888
+ return value
889
+
890
+ scheme = ref_scheme(value)
891
+
892
+ if scheme == "file":
893
+ # Item 1d: new FileStore-shape URIs resolve without
894
+ # parquet_path (FileStore walks date dirs itself). Legacy
895
+ # ``file://_ref/...`` URIs still need parquet_path for the
896
+ # per-parquet sibling-dir resolution.
897
+ return load_file(parquet_path, value)
898
+
899
+ if scheme == "channel":
900
+ if channel_store is None:
901
+ return value
902
+ try:
903
+ ticket = parse_channel_uri(value)
904
+ return channel_store.query(
905
+ ticket.channel_id,
906
+ session_id=ticket.session_id or None,
907
+ sample_offset=ticket.sample_offset,
908
+ )
909
+ except Exception: # noqa: BLE001
910
+ # A dangling or unreachable channel degrades to "unavailable" (return
911
+ # the URI) — never crash the caller/UI. Mirrors load_file's missing-
912
+ # artifact behaviour: a clean failure surfaced, not silent corruption.
913
+ logger.debug("Channel ref %r could not be resolved (unavailable)", value)
914
+ return value
915
+
916
+ # Unknown scheme — return as-is
917
+ return value
918
+
919
+
920
+ def is_file_reference(value: Any) -> bool:
921
+ """Check if a value is a file reference (``file://`` URI or legacy ``_ref/`` path)."""
922
+ if not isinstance(value, str):
923
+ return False
924
+ if value.startswith(REF_PATH_PREFIX):
925
+ return True
926
+ if is_ref(value) and ref_scheme(value) == "file":
927
+ return True
928
+ return False
929
+
930
+
931
+ def extract_refs(parquet_path: Path) -> tuple[set[tuple[str, str]], set[str]]:
932
+ """Channel ``(channel_id, session_id)`` pairs + ``file://`` keys a run references.
933
+
934
+ Scans the run's string columns (outputs lane structs and others) for
935
+ ``channel://`` / ``file://`` URIs — the run's full reachable set, both
936
+ schemes. Used by promote (carry a run's data) and retention (reference-aware
937
+ file pruning).
938
+ """
939
+ channels: set[tuple[str, str]] = set()
940
+ files: set[str] = set()
941
+ try:
942
+ table = pq.read_table(parquet_path)
943
+ except (OSError, pa.ArrowException):
944
+ return channels, files
945
+ for name in table.column_names:
946
+ col = table.column(name)
947
+ if not (pa.types.is_string(col.type) or pa.types.is_large_string(col.type)):
948
+ continue
949
+ for v in col.to_pylist():
950
+ if not is_ref(v):
951
+ continue
952
+ if v.startswith("channel://"):
953
+ # Retention is per-(channel, session): a channel is reachable if
954
+ # any ticket references it, regardless of offset. Drop the offset.
955
+ ticket = parse_channel_uri(v)
956
+ if ticket.channel_id and ticket.session_id:
957
+ channels.add((ticket.channel_id, ticket.session_id))
958
+ else: # file://
959
+ files.add(v[len("file://") :])
960
+ return channels, files
961
+
962
+
963
+ def reconstruct_test_run_from_file(pq_file: Path) -> TestRun:
964
+ """Reconstruct a TestRun model from a Parquet file.
965
+
966
+ Groups denormalized rows back into the TestRun → TestStep → TestVector
967
+ → Measurement hierarchy. Used by exporters for post-hoc conversion
968
+ and by the ``testerkit convert`` CLI.
969
+
970
+ Args:
971
+ pq_file: Path to the Parquet file.
972
+
973
+ Returns:
974
+ Reconstructed TestRun model.
975
+
976
+ Raises:
977
+ FileNotFoundError: If the file doesn't exist or is empty.
978
+ """
979
+ if not pq_file.exists():
980
+ raise FileNotFoundError(f"Parquet file not found: {pq_file}")
981
+
982
+ pf = pq.ParquetFile(pq_file)
983
+ table = pf.read()
984
+ rows = table.to_pylist()
985
+ if not rows:
986
+ raise FileNotFoundError(f"Parquet file is empty: {pq_file}")
987
+
988
+ first = rows[0]
989
+
990
+ # Required identity columns — fail fast with a clear error rather
991
+ # than letting bracket-access raise KeyError mid-reconstruction.
992
+ run_id_str = first.get("run_id")
993
+ if not run_id_str:
994
+ raise ValueError(f"Parquet file missing required 'run_id' column: {pq_file}")
995
+ run_started_at = first.get("run_started_at")
996
+ if run_started_at is None:
997
+ raise ValueError(f"Parquet file missing required 'run_started_at' column: {pq_file}")
998
+
999
+ # Read file-level metadata for config snapshots
1000
+ raw_meta = pf.schema_arrow.metadata or {}
1001
+ file_meta = {k.decode(): v.decode() for k, v in raw_meta.items()}
1002
+
1003
+ # Group rows for reconstruction. Vector rows are the carriers: each
1004
+ # holds its conditions (inputs), context (outputs), and nested
1005
+ # ``measurements``. Step rows supply step-level timing, outcome, and the
1006
+ # instrument arrays. Keyed by ``(step_name, step_index)``.
1007
+ step_vector_rows: dict[tuple[str | None, int | None], list[dict]] = defaultdict(list)
1008
+ step_meta_rows: dict[tuple[str | None, int | None], dict] = {}
1009
+ step_timing: dict[tuple[str | None, int | None], dict[str, Any]] = {}
1010
+
1011
+ for row in rows:
1012
+ rt = row.get("record_type")
1013
+ if rt == "run":
1014
+ # Run row carries run-level identity only; doesn't participate
1015
+ # in step/vector grouping. Run-level fields are denormalized
1016
+ # onto every other row, so reconstruction picks them up there.
1017
+ continue
1018
+ sk = (row.get("step_name"), row.get("step_index"))
1019
+ if sk not in step_timing:
1020
+ step_timing[sk] = {
1021
+ "started_at": row.get("step_started_at"),
1022
+ "ended_at": row.get("step_ended_at"),
1023
+ }
1024
+ if rt == "vector":
1025
+ step_vector_rows[sk].append(row)
1026
+ elif rt == "step":
1027
+ step_meta_rows[sk] = row
1028
+
1029
+ # Build steps
1030
+ steps: list[TestStep] = []
1031
+ all_sks = sorted(
1032
+ set(step_vector_rows) | set(step_meta_rows),
1033
+ key=lambda x: (x[1] or 0, x[0] or ""),
1034
+ )
1035
+ for sk in all_sks:
1036
+ vector_rows = step_vector_rows.get(sk, [])
1037
+ step_row = step_meta_rows.get(sk)
1038
+
1039
+ # Instrument records from the step row (fall back to a vector row).
1040
+ instr_source = step_row or (vector_rows[0] if vector_rows else {})
1041
+ step_instr: list[dict[str, Any]] = instr_source.get("instruments") or []
1042
+
1043
+ vectors: list[TestVector] = []
1044
+ for vr in sorted(
1045
+ vector_rows,
1046
+ key=lambda r: (r.get("vector_index") or 0, r.get("vector_retry") or 0),
1047
+ ):
1048
+ params: dict[str, Any] = _params_from_inputs(decode_lane_structs(vr.get("inputs")))
1049
+ observations = decode_lane_structs(vr.get("outputs"))
1050
+ measurements: list[Measurement] = []
1051
+ for ms in vr.get("measurements") or []:
1052
+ outcome_str = ms.get("outcome")
1053
+ m = Measurement(
1054
+ name=ms.get("name") or "",
1055
+ value=ms.get("value"),
1056
+ unit=ms.get("unit"),
1057
+ limit_low=ms.get("limit_low"),
1058
+ limit_high=ms.get("limit_high"),
1059
+ limit_nominal=ms.get("limit_nominal"),
1060
+ limit_comparator=ms.get("limit_comparator"),
1061
+ outcome=Outcome(outcome_str) if outcome_str else None,
1062
+ characteristic_id=ms.get("characteristic_id"),
1063
+ spec_ref=ms.get("spec_ref"),
1064
+ uut_pin=ms.get("uut_pin"),
1065
+ instrument_name=ms.get("instrument_name"),
1066
+ instrument_resource=ms.get("instrument_resource"),
1067
+ instrument_channel=ms.get("instrument_channel"),
1068
+ fixture_connection=ms.get("fixture_connection"),
1069
+ )
1070
+ ts = ms.get("timestamp")
1071
+ if ts is not None:
1072
+ m.timestamp = ts
1073
+ measurements.append(m)
1074
+
1075
+ vec_outcome_str = vr.get("vector_outcome")
1076
+ vectors.append(
1077
+ TestVector(
1078
+ index=vr.get("vector_index") or 0,
1079
+ retry=vr.get("vector_retry") or 0,
1080
+ params=params,
1081
+ observations=observations,
1082
+ outcome=Outcome(vec_outcome_str) if vec_outcome_str else Outcome.PASSED,
1083
+ measurements=measurements,
1084
+ started_at=vr.get("vector_started_at") or run_started_at,
1085
+ ended_at=vr.get("vector_ended_at"),
1086
+ )
1087
+ )
1088
+
1089
+ timing = step_timing.get(sk, {})
1090
+ # Prefer the stored step_outcome column (cascade rollup written at
1091
+ # row-build time); fall back to deriving from vector outcomes.
1092
+ step_outcome_str = step_row.get("step_outcome") if step_row else None
1093
+ if step_outcome_str:
1094
+ step_outcome = Outcome(step_outcome_str)
1095
+ elif any(v.outcome == Outcome.FAILED for v in vectors):
1096
+ step_outcome = Outcome.FAILED
1097
+ else:
1098
+ step_outcome = Outcome.PASSED
1099
+
1100
+ steps.append(
1101
+ TestStep(
1102
+ name=sk[0] or "",
1103
+ started_at=timing.get("started_at") or run_started_at,
1104
+ ended_at=timing.get("ended_at"),
1105
+ outcome=step_outcome,
1106
+ vectors=vectors,
1107
+ instrument_records=step_instr if step_instr else None,
1108
+ )
1109
+ )
1110
+
1111
+ run_outcome_str = first.get("run_outcome")
1112
+ run_outcome = Outcome(run_outcome_str) if run_outcome_str else Outcome.PASSED
1113
+
1114
+ return TestRun(
1115
+ id=UUID(run_id_str),
1116
+ started_at=run_started_at,
1117
+ ended_at=first.get("run_ended_at"),
1118
+ uut=UUT(
1119
+ serial=first.get("uut_serial_number") or "",
1120
+ part_number=first.get("uut_part_number"),
1121
+ revision=first.get("uut_revision"),
1122
+ lot_number=first.get("uut_lot_number"),
1123
+ ),
1124
+ part_id=first.get("part_id"),
1125
+ part_name=first.get("part_name"),
1126
+ part_revision=first.get("part_revision"),
1127
+ station_id=first.get("station_id"),
1128
+ station_name=first.get("station_name"),
1129
+ station_type=first.get("station_type"),
1130
+ station_location=first.get("station_location"),
1131
+ station_hostname=first.get("station_hostname"),
1132
+ fixture_id=first.get("fixture_id"),
1133
+ test_phase=first.get("test_phase"),
1134
+ operator_id=first.get("operator_id"),
1135
+ operator_name=first.get("operator_name"),
1136
+ git_commit=first.get("git_commit"),
1137
+ outcome=run_outcome,
1138
+ steps=steps,
1139
+ environment_json=file_meta.get("environment_json"),
1140
+ custom_metadata=(
1141
+ json.loads(file_meta["custom_metadata"]) if file_meta.get("custom_metadata") else {}
1142
+ ),
1143
+ )