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,957 @@
1
+ """Pure in-memory event projection — no I/O, no parquet dependencies.
2
+
3
+ :class:`EventAccumulator` is the single canonical state machine that
4
+ projects the testerkit event stream into row-shaped state. Both the test
5
+ runner's :class:`~testerkit.data.backends.parquet.ParquetSubscriber` (I/O
6
+ destination: disk) and the runs daemon's live overlay (I/O destination:
7
+ in-memory UNION view) use this class so their projections never drift.
8
+
9
+ Lives in its own module so the daemon can import it without pulling in
10
+ the full parquet/subscribers/exporters stack.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from testerkit.data.backends._row_helpers import (
18
+ RunParquetRow,
19
+ _append_not_started,
20
+ _to_datetime,
21
+ run_context_from_run_started,
22
+ step_entry_dict,
23
+ vector_entry_dict,
24
+ )
25
+ from testerkit.data.events import (
26
+ InstrumentConnected,
27
+ InstrumentReserved,
28
+ MeasurementRecorded,
29
+ Observation,
30
+ RunEnded,
31
+ RunStarted,
32
+ StepEnded,
33
+ StepsDiscovered,
34
+ StepStarted,
35
+ VectorEnded,
36
+ VectorStarted,
37
+ )
38
+
39
+
40
+ def _safe_str(value: Any) -> str | None:
41
+ """Return ``str(value)`` or ``None`` if *value* is falsy."""
42
+ return str(value) if value else None
43
+
44
+
45
+ def _pack_io_maps(
46
+ inputs: dict[str, Any], outputs: dict[str, Any]
47
+ ) -> tuple[dict[str, str | None], dict[str, str | None]]:
48
+ """Render inputs/outputs lane dicts into two unprefixed VARCHAR maps.
49
+
50
+ Replaces the old merged, ``in_``/``out_``-prefixed ``dynamic_attrs`` MAP
51
+ (projection-normalization, 0.3.1) — the dict IS the role, so no prefix is
52
+ needed to tell them apart.
53
+ """
54
+ return (
55
+ {k: _safe_str(v) for k, v in inputs.items()},
56
+ {k: _safe_str(v) for k, v in outputs.items()},
57
+ )
58
+
59
+
60
+ def _step_key(event: Any) -> tuple[str, int, int | None]:
61
+ """Stable accumulator key for a StepStarted / StepEnded event.
62
+
63
+ Uses ``step_path`` (the canonical hierarchical id) when set;
64
+ falls back to ``step_name`` otherwise so direct-API callers
65
+ (and tests) that emit events without populating step_path
66
+ still get distinct keys per step. ``step_retry`` (the outer item
67
+ attempt) makes each rerun its own execution row instead of
68
+ overwriting the prior attempt (the de-fuse). ``vector_outer_index`` is
69
+ the ENCLOSING class-level iteration this step ran under — NULL at top
70
+ level, 0..N for methods nested under a swept class.
71
+ """
72
+ path = event.step_path or event.step_name or ""
73
+ return (path, getattr(event, "retry", 0) or 0, getattr(event, "vector_outer_index", None))
74
+
75
+
76
+ def _vector_key(event: Any) -> tuple[str, int | None, int, int]:
77
+ """Stable accumulator key for a VectorStarted / VectorEnded event.
78
+
79
+ Keyed on ``(step_path, vector_outer_index, vector_index, retry)`` — the
80
+ outer index distinguishes class-level sweep iterations; the inner index
81
+ distinguishes in-body loop iterations; retry counts re-executions of
82
+ the same (outer, inner) point.
83
+ """
84
+ path = event.step_path or event.step_name or ""
85
+ return (
86
+ path,
87
+ getattr(event, "vector_outer_index", None),
88
+ event.vector_index,
89
+ getattr(event, "retry", 0) or 0,
90
+ )
91
+
92
+
93
+ def _end_overrides_start(start: Any, end: Any, attr: str) -> dict[str, Any]:
94
+ """Resolve an inputs-side lane: End overrides Start, Start is the in-flight fallback.
95
+
96
+ A finished block (End event present) carries the post-``configure()`` snapshot
97
+ and wins; while in-flight (no End yet) the overlay reads Start.
98
+ """
99
+ if end is not None and getattr(end, attr, None):
100
+ return dict(getattr(end, attr))
101
+ if start is not None and getattr(start, attr, None):
102
+ return dict(getattr(start, attr))
103
+ return {}
104
+
105
+
106
+ def _measurement_event_struct(event: Any) -> dict[str, Any]:
107
+ """Encode a MeasurementRecorded event into the nested measurement struct.
108
+
109
+ Field order/names match ``_row_helpers.build_measurement_struct`` (and
110
+ ``schemas._MEASUREMENT_STRUCT``) so the streaming write and the offline
111
+ write produce identical nested structs.
112
+ """
113
+ return {
114
+ "name": event.measurement_name,
115
+ "value": event.value,
116
+ "unit": event.unit,
117
+ "outcome": event.outcome,
118
+ "timestamp": event.measurement_timestamp,
119
+ "limit_low": event.limit_low,
120
+ "limit_high": event.limit_high,
121
+ "limit_nominal": event.limit_nominal,
122
+ "limit_comparator": event.limit_comparator,
123
+ "characteristic_id": event.characteristic_id,
124
+ "spec_ref": event.spec_ref,
125
+ "uut_pin": event.uut_pin,
126
+ "fixture_connection": event.fixture_connection,
127
+ "instrument_name": event.instrument_name,
128
+ "instrument_resource": event.instrument_resource,
129
+ "instrument_channel": event.instrument_channel,
130
+ }
131
+
132
+
133
+ class EventAccumulator:
134
+ """Pure projection of run events into row state — no I/O.
135
+
136
+ The single canonical projection function from the testerkit event
137
+ stream into the parquet row shape. One per in-flight run.
138
+
139
+ Accumulates ``RunStarted`` / ``InstrumentConnected`` /
140
+ ``StepsDiscovered`` / ``StepStarted`` / ``MeasurementRecorded`` /
141
+ ``StepEnded`` events into in-memory state. ``RunEnded`` is
142
+ handled by subclasses (it's the trigger for the parquet
143
+ write) — the base class doesn't act on it.
144
+
145
+ Snapshot methods (``snapshot_run_row``, ``snapshot_step_rows``,
146
+ ``snapshot_measurement_rows``) materialize the current state
147
+ as the same dict shape ``ParquetSubscriber`` writes to disk,
148
+ but without writing anything. Used by the runs daemon to
149
+ surface in-flight runs in queries via an in-memory overlay.
150
+
151
+ The "one applier, multiple destinations" pattern from
152
+ event-sourcing / materialized-view design (Prometheus head
153
+ block, Materialize, RisingWave, CQRS read models): both the
154
+ test runner's parquet writer (``ParquetSubscriber``) and the
155
+ runs daemon's live overlay use this same projection so the
156
+ finalized parquet and the in-flight overlay can never drift.
157
+
158
+ Snapshot return conventions:
159
+
160
+ * ``snapshot_run_row()`` returns ``None`` when no ``RunStarted``
161
+ has been seen (there is no run to project). This is intentionally
162
+ ``Optional[dict]`` — a singular row either exists or it doesn't.
163
+ * ``snapshot_step_rows()`` and ``snapshot_measurement_rows()``
164
+ return ``[]`` for the same condition (plural methods follow
165
+ the empty-sequence convention rather than returning ``None``).
166
+ """
167
+
168
+ def __init__(self) -> None:
169
+ self._run_started: Any = None # RunStarted event (run context)
170
+ self._instruments: list[Any] = [] # InstrumentConnected events
171
+ self._reservations: list[Any] = [] # InstrumentReserved events
172
+ self._measurement_events: list[Any] = [] # MeasurementRecorded events
173
+ # ``observe()`` events accumulate here so a vector's observations
174
+ # can ride on its step/vector record's outputs lanes.
175
+ self._observation_events: list[Any] = []
176
+ # Step events keyed by (step_path, step_retry, vector_outer_index) so
177
+ # each sweep variant — each class-container iteration — AND each rerun
178
+ # (step_retry) gets its own entry. ``step_index`` is unique per
179
+ # logical-step within its parent bucket but COLLIDES across parents
180
+ # (a class container at root step_index=0 and its first method at
181
+ # class-bucket step_index=0 would clobber each other under a
182
+ # (step_index, vector_outer_index) key). step_path is unique end-to-end
183
+ # per step; step_retry de-fuses reruns (no overwrite).
184
+ self._step_starts: dict[tuple[str, int, int | None], Any] = {}
185
+ self._step_ends: dict[tuple[str, int, int | None], Any] = {}
186
+ # In-body loop vectors (Mode 2) keyed by (step_path, vector_outer_index,
187
+ # vector_index, retry). Present ONLY when VectorStarted/VectorEnded were
188
+ # emitted; their presence is the Mode-2 signal that produces ``vector`` rows.
189
+ self._vector_starts: dict[tuple[str, int | None, int, int], Any] = {}
190
+ self._vector_ends: dict[tuple[str, int | None, int, int], Any] = {}
191
+ self._run_ended: Any = None # RunEnded event (None for in-flight)
192
+ self._collected_items: list[dict[str, str | int | None]] = []
193
+ # node_id → markers, populated when StepsDiscovered arrives so
194
+ # ``_build_row`` can stamp step_markers on every measurement row
195
+ # without rebuilding the lookup per measurement.
196
+ self._markers_by_node: dict[str, str | None] = {}
197
+
198
+ def on_event(self, event: Any) -> None:
199
+ """Accumulate one event into in-memory state. No I/O."""
200
+ if isinstance(event, RunStarted):
201
+ self._run_started = event
202
+ elif isinstance(event, InstrumentConnected):
203
+ self._instruments.append(event)
204
+ elif isinstance(event, InstrumentReserved):
205
+ self._reservations.append(event)
206
+ elif isinstance(event, StepsDiscovered):
207
+ self._collected_items = event.items
208
+ markers: dict[str, str | None] = {}
209
+ for ci in event.items:
210
+ nid = ci.get("node_id")
211
+ if isinstance(nid, str) and nid:
212
+ m = ci.get("markers")
213
+ markers[nid] = m if isinstance(m, str) or m is None else str(m)
214
+ self._markers_by_node = markers
215
+ elif isinstance(event, StepStarted):
216
+ self._step_starts[_step_key(event)] = event
217
+ elif isinstance(event, VectorStarted):
218
+ self._vector_starts[_vector_key(event)] = event
219
+ elif isinstance(event, VectorEnded):
220
+ self._vector_ends[_vector_key(event)] = event
221
+ elif isinstance(event, MeasurementRecorded):
222
+ self._measurement_events.append(event)
223
+ elif isinstance(event, Observation):
224
+ self._observation_events.append(event)
225
+ elif isinstance(event, StepEnded):
226
+ self._step_ends[_step_key(event)] = event
227
+ elif isinstance(event, RunEnded):
228
+ self._run_ended = event
229
+
230
+ # ------------------------------------------------------------------
231
+ # Snapshot — materialize current state as row dicts (no I/O)
232
+ # ------------------------------------------------------------------
233
+
234
+ def snapshot_run_row(self) -> dict[str, Any] | None:
235
+ """Return a single dict matching the runs daemon's ``runs`` row shape.
236
+
237
+ ``None`` if no ``RunStarted`` has been seen yet (nothing to
238
+ project). For in-flight runs ``ended_at`` and ``outcome`` are
239
+ ``None``; those fields populate when ``RunEnded`` arrives.
240
+ """
241
+ s = self._run_started
242
+ if not s:
243
+ return None
244
+ ended_at = self._run_ended.occurred_at if self._run_ended else None
245
+ outcome = self._run_ended.outcome if self._run_ended else None
246
+ return {
247
+ "run_id": _safe_str(s.run_id),
248
+ "session_id": _safe_str(s.session_id),
249
+ "site_index": s.site_index,
250
+ "site_name": s.site_name,
251
+ "uut_serial_number": s.uut_serial_number,
252
+ "uut_part_number": s.uut_part_number,
253
+ "uut_revision": s.uut_revision,
254
+ "uut_lot_number": s.uut_lot_number,
255
+ "station_id": s.station_id,
256
+ "station_name": s.station_name,
257
+ "station_hostname": s.station_hostname,
258
+ "station_type": s.station_type,
259
+ "station_location": s.station_location,
260
+ "fixture_id": s.fixture_id,
261
+ "outcome": outcome,
262
+ "started_at": s.occurred_at,
263
+ "ended_at": ended_at,
264
+ "num_measurements": sum(
265
+ 1 for e in self._measurement_events if e.measurement_name is not None
266
+ ),
267
+ "num_steps": len(set(self._step_starts) | set(self._step_ends)),
268
+ "test_phase": s.test_phase,
269
+ "part_id": s.part_id,
270
+ "part_name": s.part_name,
271
+ "part_revision": s.part_revision,
272
+ "operator_id": s.operator_id,
273
+ "operator_name": s.operator_name,
274
+ "project_name": s.project_name,
275
+ "git_commit": s.git_commit,
276
+ "git_branch": s.git_branch,
277
+ "git_remote": s.git_remote,
278
+ "file_path": None,
279
+ }
280
+
281
+ def snapshot_step_rows(self) -> list[dict[str, Any]]:
282
+ """Return BOTH grains as overlay rows for the daemon's steps tables.
283
+
284
+ Emits the logical-step rows (``vector_index`` NULL) followed by the
285
+ condition-point rows (``vector_index`` 0..N) — mirroring the at-rest
286
+ ``steps_materialized`` table, which the daemon exposes via the grain-
287
+ split ``steps`` (NULL) and ``step_vectors`` (0..N) views. Callers that
288
+ want one grain filter on ``vector_index`` (as ``StepsQuery`` does).
289
+ """
290
+ s = self._run_started
291
+ if not s:
292
+ return []
293
+ ended_at = self._run_ended.occurred_at if self._run_ended else None
294
+ outcome = self._run_ended.outcome if self._run_ended else None
295
+ rows: list[dict[str, Any]] = []
296
+ for entry in self._build_step_results_from_events():
297
+ started_at = _to_datetime(entry.get("started_at"))
298
+ entry_ended_at = _to_datetime(entry.get("ended_at"))
299
+ # Round to microseconds — matches the materialized ROUND(..., 6),
300
+ # so the inflight↔materialized equivalence guard doesn't trip on
301
+ # float64 tails (timestamps are us-resolution anyway).
302
+ duration_s = (
303
+ round((entry_ended_at - started_at).total_seconds(), 6)
304
+ if started_at and entry_ended_at
305
+ else None
306
+ )
307
+ inputs_map, outputs_map = _pack_io_maps(
308
+ entry.get("inputs") or {},
309
+ entry.get("outputs") or {},
310
+ )
311
+ rows.append(
312
+ {
313
+ "run_id": _safe_str(s.run_id),
314
+ "step_index": entry.get("index"),
315
+ "session_id": _safe_str(s.session_id),
316
+ "site_index": s.site_index,
317
+ "site_name": s.site_name,
318
+ "step_name": entry.get("name"),
319
+ "step_path": entry.get("step_path"),
320
+ "vector_index": entry.get("vector_index"),
321
+ "vector_outer_index": entry.get("vector_outer_index"),
322
+ # Logical step: no own vector retry (NULL). Matches
323
+ # steps_materialized, which has no vector_retry column.
324
+ "vector_retry": None,
325
+ "outcome": entry.get("outcome"),
326
+ "started_at": started_at,
327
+ "ended_at": entry_ended_at,
328
+ "duration_s": duration_s,
329
+ "step_retry": entry.get("step_retry", 0),
330
+ "measurement_count": entry.get("measurement_count", 0),
331
+ "markers": entry.get("markers"),
332
+ "uut_serial_number": s.uut_serial_number,
333
+ "station_id": s.station_id,
334
+ "file_path": None,
335
+ "run_outcome": outcome,
336
+ "run_ended_at": ended_at,
337
+ "inputs_map": inputs_map,
338
+ "outputs_map": outputs_map,
339
+ }
340
+ )
341
+ for entry in self._build_vector_results_from_events():
342
+ v_started = _to_datetime(entry.get("started_at"))
343
+ v_ended = _to_datetime(entry.get("ended_at"))
344
+ v_duration = (
345
+ round((v_ended - v_started).total_seconds(), 6) if v_started and v_ended else None
346
+ )
347
+ inputs_map, outputs_map = _pack_io_maps(
348
+ entry.get("inputs") or {},
349
+ entry.get("outputs") or {},
350
+ )
351
+ rows.append(
352
+ {
353
+ "run_id": _safe_str(s.run_id),
354
+ "step_index": entry.get("index"),
355
+ "session_id": _safe_str(s.session_id),
356
+ "site_index": s.site_index,
357
+ "site_name": s.site_name,
358
+ "step_name": entry.get("name"),
359
+ "step_path": entry.get("step_path"),
360
+ "vector_index": entry.get("vector_index"),
361
+ "vector_outer_index": entry.get("vector_outer_index"),
362
+ # A vector row carries its OWN timing/outcome/retry — the
363
+ # ``vectors_materialized`` grain keys on ``vector_retry``
364
+ # (full snowflake, 0.3.1), so the overlay must carry the
365
+ # vector's own ``retry`` or the inflight/materialized
366
+ # consistency check can't match it 1:1.
367
+ "vector_retry": entry.get("retry", 0),
368
+ "outcome": entry.get("outcome"),
369
+ "started_at": v_started,
370
+ "ended_at": v_ended,
371
+ "duration_s": v_duration,
372
+ "step_retry": entry.get("step_retry", 0),
373
+ "measurement_count": len(entry.get("measurements") or []),
374
+ "markers": None,
375
+ "uut_serial_number": s.uut_serial_number,
376
+ "station_id": s.station_id,
377
+ "file_path": None,
378
+ "run_outcome": outcome,
379
+ "run_ended_at": ended_at,
380
+ "inputs_map": inputs_map,
381
+ "outputs_map": outputs_map,
382
+ }
383
+ )
384
+ return rows
385
+
386
+ def snapshot_measurement_rows(self) -> list[dict[str, Any]]:
387
+ """Return flat measurement-fact rows matching the daemon's UNNEST."""
388
+ if not self._run_started:
389
+ return []
390
+ ended_at = self._run_ended.occurred_at if self._run_ended else None
391
+ outcome = self._run_ended.outcome if self._run_ended else None
392
+ vectors_by_key: dict[tuple[str, int | None, int, int], dict[str, Any]] = {}
393
+ for entry in self._build_vector_results_from_events():
394
+ key = (
395
+ entry.get("step_path") or "",
396
+ entry.get("vector_outer_index"),
397
+ entry.get("vector_index", 0),
398
+ entry.get("retry", 0),
399
+ )
400
+ vectors_by_key[key] = entry
401
+ # AT-REST vector_outer_index (may be None) must match the step entry's key.
402
+ steps_by_key: dict[tuple[str, int, int | None], dict[str, Any]] = {}
403
+ for entry in self._build_step_results_from_events():
404
+ key = (
405
+ entry.get("step_path") or "",
406
+ entry.get("step_retry", 0) or 0,
407
+ entry.get("vector_outer_index"),
408
+ )
409
+ steps_by_key[key] = entry
410
+ looped = self._looped_keys()
411
+ rows: list[dict[str, Any]] = []
412
+ for event in self._measurement_events:
413
+ row = self._build_row(event)
414
+ row["run_ended_at"] = ended_at
415
+ row["run_outcome"] = outcome
416
+ # The measurement's enclosing-step retry — a coordinate on
417
+ # measurements_materialized (full snowflake, 0.3.1), so the overlay
418
+ # carries it too for 1:1 matching.
419
+ row["step_retry"] = getattr(event, "step_retry", 0) or 0
420
+ path = event.step_path or event.step_name or ""
421
+ ev_voi = getattr(event, "vector_outer_index", None)
422
+ if (path, ev_voi, event.vector_index) in looped:
423
+ entry = vectors_by_key.get(
424
+ (path, ev_voi, event.vector_index, event.retry or 0)
425
+ ) or vectors_by_key.get((path, ev_voi, event.vector_index, 0))
426
+ in_lanes = (entry.get("inputs") if entry else None) or {}
427
+ out_lanes = (entry.get("outputs") if entry else None) or {}
428
+ row["inputs_map"], row["outputs_map"] = _pack_io_maps(in_lanes, out_lanes)
429
+ row["vector_retry"] = entry.get("retry", 0) if entry else 0
430
+ row["vector_outcome"] = entry.get("outcome") if entry else None
431
+ row["step_outcome"] = None
432
+ if entry is not None:
433
+ row["step_started_at"] = _to_datetime(entry.get("step_started_at"))
434
+ row["step_ended_at"] = _to_datetime(entry.get("step_ended_at"))
435
+ else:
436
+ step_entry = steps_by_key.get((path, getattr(event, "step_retry", 0) or 0, ev_voi))
437
+ in_lanes = (step_entry.get("inputs") if step_entry else None) or {}
438
+ out_lanes = (step_entry.get("outputs") if step_entry else None) or {}
439
+ row["inputs_map"], row["outputs_map"] = _pack_io_maps(in_lanes, out_lanes)
440
+ row["vector_index"] = None
441
+ row["vector_retry"] = None
442
+ row["vector_outcome"] = None
443
+ row["step_outcome"] = step_entry.get("outcome") if step_entry else None
444
+ if step_entry is not None:
445
+ row["step_started_at"] = _to_datetime(step_entry.get("started_at"))
446
+ row["step_ended_at"] = _to_datetime(step_entry.get("ended_at"))
447
+ rows.append(row)
448
+ return rows
449
+
450
+ # ------------------------------------------------------------------
451
+ # Pure projection helpers — used by both snapshot and parquet write
452
+ # ------------------------------------------------------------------
453
+
454
+ def _build_instrument_records(self) -> list[dict[str, Any]]:
455
+ """Build instrument records from cached InstrumentConnected events."""
456
+ return [
457
+ {
458
+ "name": inst.role,
459
+ "id": inst.instrument_id,
460
+ "driver": inst.driver,
461
+ "resource": inst.resource,
462
+ "protocol": inst.protocol,
463
+ "manufacturer": inst.manufacturer,
464
+ "model": inst.model,
465
+ "serial_number": inst.serial,
466
+ "firmware": inst.firmware,
467
+ "cal_due": inst.cal_due,
468
+ "cal_last": inst.cal_last,
469
+ "cal_certificate": inst.cal_certificate,
470
+ "cal_lab": inst.cal_lab,
471
+ "mocked": inst.mocked,
472
+ }
473
+ for inst in self._instruments
474
+ ]
475
+
476
+ def _reserved_instrument_lookups(
477
+ self,
478
+ ) -> tuple[dict[tuple[int, int], set[str]], dict[str, dict[str, Any]]]:
479
+ reserved_by_step: dict[tuple[int, int], set[str]] = {}
480
+ structs_by_role: dict[str, dict[str, Any]] = {}
481
+ for ev in self._reservations:
482
+ if ev.step_index is not None:
483
+ reserved_by_step.setdefault(
484
+ (ev.step_index, ev.step_retry if ev.step_retry is not None else 0),
485
+ set(),
486
+ ).add(ev.role)
487
+ structs_by_role.setdefault(
488
+ ev.role,
489
+ {
490
+ "name": ev.role,
491
+ "id": ev.instrument_id,
492
+ "driver": None,
493
+ "resource": ev.resource,
494
+ "protocol": None,
495
+ "manufacturer": None,
496
+ "model": None,
497
+ "serial_number": None,
498
+ "firmware": None,
499
+ "cal_due": None,
500
+ "cal_last": None,
501
+ "cal_certificate": None,
502
+ "cal_lab": None,
503
+ "mocked": False,
504
+ },
505
+ )
506
+ for rec in self._build_instrument_records():
507
+ structs_by_role[rec["name"]] = rec
508
+ return reserved_by_step, structs_by_role
509
+
510
+ @staticmethod
511
+ def _min_retry_match(
512
+ cache: dict[tuple[str, int, int | None], Any],
513
+ step_path: str,
514
+ vector_outer_index: int | None,
515
+ ) -> Any:
516
+ """Lowest-retry cached step event for a (step_path, vector_outer_index).
517
+
518
+ Retry-invariant identity lookup: the de-fuse keys steps by
519
+ ``(step_path, step_retry, vector_outer_index)``, but a measurement
520
+ event's ``retry`` is the inner vector retry, not ``step_retry``. The
521
+ identity fields a measurement fact reads off the step (node_id, parent,
522
+ module/file/class/function) are the same across reruns, so any matching
523
+ attempt serves; prefer the lowest retry.
524
+ """
525
+ retries = [r for (p, r, v) in cache if p == step_path and v == vector_outer_index]
526
+ return cache.get((step_path, min(retries), vector_outer_index)) if retries else None
527
+
528
+ @staticmethod
529
+ def _step_event_at(
530
+ cache: dict[tuple[str, int, int | None], Any],
531
+ step_path: str,
532
+ step_retry: int,
533
+ vector_outer_index: int | None,
534
+ ) -> Any:
535
+ """Cached step event for a vector's OWN ``(step_path, step_retry)``.
536
+
537
+ An in-body iteration vector reads its enclosing step's identity and
538
+ timing; the de-fuse keys steps by ``(step_path, step_retry,
539
+ vector_outer_index)``, so a rerun has a distinct StepStarted/StepEnded
540
+ per attempt. Resolving by the vector's own ``step_retry`` makes attempt
541
+ 1's iteration vectors read attempt 1's step span — not attempt 0's,
542
+ which the retry-invariant :meth:`_min_retry_match` would return. The
543
+ enclosing leaf step's ``vector_outer_index`` is None for the common
544
+ top-level step, so the ``step_retry``-exact lookup falls through; the
545
+ final retry-invariant match preserves behaviour for parametrized Mode-2
546
+ steps whose StepStarted sits at a nonzero outer index.
547
+ """
548
+ return (
549
+ cache.get((step_path, step_retry, vector_outer_index))
550
+ or cache.get((step_path, step_retry, None))
551
+ or EventAccumulator._min_retry_match(cache, step_path, vector_outer_index)
552
+ or EventAccumulator._min_retry_match(cache, step_path, None)
553
+ )
554
+
555
+ def _step_start_for(self, step_path: str, vector_outer_index: int | None) -> Any:
556
+ return self._min_retry_match(self._step_starts, step_path, vector_outer_index)
557
+
558
+ def _step_end_for(self, step_path: str, vector_outer_index: int | None) -> Any:
559
+ return self._min_retry_match(self._step_ends, step_path, vector_outer_index)
560
+
561
+ def _looped_keys(self) -> set[tuple[str, int | None, int]]:
562
+ """``(step_path, vector_outer_index, vector_index)`` triples that ran an in-body loop."""
563
+ return {(k[0], k[1], k[2]) for k in (set(self._vector_starts) | set(self._vector_ends))}
564
+
565
+ def _step_start_field(self, step_path: str, vector_outer_index: int | None, attr: str) -> Any:
566
+ """Get a field from the cached StepStarted event, or None."""
567
+ start = self._step_start_for(step_path, vector_outer_index)
568
+ return getattr(start, attr, None) if start else None
569
+
570
+ def _partition_measurements(
571
+ self,
572
+ ) -> dict[tuple[str, int | None, int, int], list[dict[str, Any]]]:
573
+ """Group measurement structs by enclosing vector, full-identity keyed.
574
+
575
+ Returns ``by_vector`` keyed ``(step_path, vector_outer_index,
576
+ vector_index, vector_retry)`` for measurements that landed inside an
577
+ active vector (Mode-2 in-body or Mode-1/class-outer outer vector).
578
+ Measurements recorded with no own active vector (step-scope) carry the
579
+ outer index on the event but are NOT in ``_vector_starts`` for their
580
+ step_path — the accumulator assigns them to the step row instead (see
581
+ ``_build_step_results_from_events``).
582
+ """
583
+ by_vector: dict[tuple[str, int | None, int, int], list[dict[str, Any]]] = {}
584
+ looped = self._looped_keys()
585
+ for e in self._measurement_events:
586
+ path = e.step_path or e.step_name or ""
587
+ struct = _measurement_event_struct(e)
588
+ if (path, getattr(e, "vector_outer_index", None), e.vector_index) in looped:
589
+ by_vector.setdefault(
590
+ (path, getattr(e, "vector_outer_index", None), e.vector_index, e.retry or 0), []
591
+ ).append(struct)
592
+ return by_vector
593
+
594
+ def _build_vector_results_from_events(self) -> list[dict[str, Any]]:
595
+ """Build vector manifest entries from VectorStarted/VectorEnded.
596
+
597
+ One entry per emitted vector — every sweep point emits these events:
598
+ function ``@parametrize``, class-outer ``testerkit_sweeps``, and in-body
599
+ ``vectors`` / ``run_vector`` alike. A non-swept step emits none, so it
600
+ contributes no vector rows (its data rides the ``step`` record).
601
+
602
+ One entry per ``(step_path, vector_index, retry)`` execution. The
603
+ enclosing leaf step's identity (node_id / file / class / function /
604
+ timing) is sourced from its StepStarted when present.
605
+ """
606
+ by_iteration = self._partition_measurements()
607
+ reserved_by_step, structs_by_role = self._reserved_instrument_lookups()
608
+ entries: list[dict[str, Any]] = []
609
+ keys = sorted(
610
+ set(self._vector_starts) | set(self._vector_ends),
611
+ key=lambda k: (k[2], k[3], k[0]),
612
+ )
613
+ # vector_retry = the 0-based occurrence ordinal of (step_path,
614
+ # vector_outer_index, vector_index) across the whole run — a step rerun
615
+ # AND an in-body retry both re-execute the point and both count (cause is
616
+ # irrelevant). It is sourced at emit (RunScope.next_vector_occurrence
617
+ # stamps it onto VectorStarted.retry) and rides the event as ``key[3]``
618
+ # here, so a step rerun's vectors are DISTINCT keys (not fused) and the
619
+ # inflight overlay and materialized parquet — both reading this one
620
+ # builder — agree.
621
+ for key in keys:
622
+ path, vec_outer, vec, retry = key
623
+ start = self._vector_starts.get(key)
624
+ end = self._vector_ends.get(key)
625
+ ref = start or end
626
+ step_retry_v = getattr(ref, "step_retry", 0) or 0 if ref else 0
627
+ step_start = self._step_event_at(self._step_starts, path, step_retry_v, vec_outer)
628
+ step_end = self._step_event_at(self._step_ends, path, step_retry_v, vec_outer)
629
+ node_id = getattr(ref, "node_id", None) or (step_start.node_id if step_start else None)
630
+ inputs = _end_overrides_start(start, end, "inputs")
631
+ outputs = dict(end.outputs) if end and getattr(end, "outputs", None) else {}
632
+ input_units = _end_overrides_start(start, end, "input_units")
633
+ output_units = (
634
+ dict(end.output_units) if end and getattr(end, "output_units", None) else {}
635
+ )
636
+ output_pins = dict(end.output_pins) if end and getattr(end, "output_pins", None) else {}
637
+ step_idx_v = ref.step_index if ref else 0
638
+ v_roles = reserved_by_step.get((step_idx_v, step_retry_v), set())
639
+ v_instruments = [structs_by_role[r] for r in v_roles if r in structs_by_role]
640
+ entries.append(
641
+ vector_entry_dict(
642
+ index=step_idx_v,
643
+ name=ref.step_name if ref else "",
644
+ node_id=node_id,
645
+ file=step_start.file if step_start else None,
646
+ function=step_start.function if step_start else None,
647
+ class_name=step_start.class_name if step_start else None,
648
+ module=step_start.module if step_start else None,
649
+ step_path=ref.step_path if ref else path,
650
+ markers=self._markers_by_node.get(node_id) if node_id else None,
651
+ step_started_at=step_start.occurred_at if step_start else None,
652
+ step_ended_at=step_end.occurred_at if step_end else None,
653
+ vector_index=vec,
654
+ vector_outer_index=vec_outer,
655
+ retry=retry,
656
+ step_retry=step_retry_v,
657
+ outcome=end.outcome if end else None,
658
+ started_at=start.occurred_at if start else None,
659
+ ended_at=end.occurred_at if end else None,
660
+ inputs=inputs,
661
+ outputs=outputs,
662
+ input_units=input_units,
663
+ output_units=output_units,
664
+ output_pins=output_pins,
665
+ measurements=by_iteration.get(key, []),
666
+ instrument_records=v_instruments,
667
+ )
668
+ )
669
+ return entries
670
+
671
+ def _build_row(self, event: Any) -> dict[str, Any]:
672
+ """Denormalize a MeasurementRecorded event into a flat row dict."""
673
+ idx = event.step_index
674
+ vec = event.vector_index
675
+ path = event.step_path or event.step_name or ""
676
+ start = self._step_start_for(path, vec)
677
+ end = self._step_end_for(path, vec)
678
+ node_id = start.node_id if start else None
679
+ # A measurement fact mirrors its carrier's vector_index (NULL for an
680
+ # ambient/step-scope measurement, 0..N for a vector-scope one), so it's
681
+ # built as record_type='measurement' (unconstrained by the grain guard)
682
+ # rather than as a 'vector' row — which would fail the guard when the
683
+ # measurement is ambient (vector_index=NULL).
684
+ row = RunParquetRow(
685
+ record_type="measurement",
686
+ **run_context_from_run_started(self._run_started, event, include_env=True),
687
+ step_name=event.step_name,
688
+ step_index=idx,
689
+ step_path=event.step_path or event.step_name,
690
+ step_started_at=start.occurred_at if start else None,
691
+ step_ended_at=end.occurred_at if end else None,
692
+ step_node_id=node_id,
693
+ step_module=self._step_start_field(path, vec, "module"),
694
+ step_file=self._step_start_field(path, vec, "file"),
695
+ step_class=self._step_start_field(path, vec, "class_name"),
696
+ step_function=self._step_start_field(path, vec, "function"),
697
+ step_markers=self._markers_by_node.get(node_id) if node_id else None,
698
+ step_outcome=end.outcome if end else None,
699
+ vector_index=vec,
700
+ vector_outer_index=getattr(event, "vector_outer_index", None),
701
+ vector_retry=event.retry,
702
+ step_retry=getattr(event, "step_retry", 0) or 0,
703
+ measurement_name=event.measurement_name,
704
+ measurement_timestamp=event.measurement_timestamp,
705
+ measurement_value=event.value,
706
+ measurement_unit=event.unit,
707
+ measurement_outcome=event.outcome,
708
+ limit_low=event.limit_low,
709
+ limit_high=event.limit_high,
710
+ limit_nominal=event.limit_nominal,
711
+ limit_comparator=event.limit_comparator,
712
+ characteristic_id=event.characteristic_id,
713
+ spec_ref=event.spec_ref,
714
+ uut_pin=event.uut_pin,
715
+ fixture_connection=event.fixture_connection,
716
+ instrument_name=event.instrument_name,
717
+ instrument_resource=event.instrument_resource,
718
+ instrument_channel=event.instrument_channel,
719
+ run_outcome=None,
720
+ # v2: a measurement references (does not copy) its vector — the
721
+ # in/out conditions live on the (scope or in-body) vector record;
722
+ # the EAV join resolves them by the shared vector key.
723
+ inputs={},
724
+ outputs={},
725
+ instruments=self._build_instrument_records(),
726
+ )
727
+ flat = row.to_flat_dict()
728
+ # The nested measurements list has no place on the flat fact row. And
729
+ # record_type — set above only to satisfy the at-rest model and bypass
730
+ # the grain guard — is a projection vestige (the measurement fact is
731
+ # all-measurement by construction), so it's dropped from the inflight
732
+ # projection row too.
733
+ flat.pop("measurements", None)
734
+ flat.pop("record_type", None)
735
+ return flat
736
+
737
+ def _build_step_results_from_events(self) -> list[dict[str, Any]]:
738
+ """Build step manifest from cached StepStarted/StepEnded events.
739
+
740
+ Each entry corresponds to one ``(step_path, step_retry, vector_index)``
741
+ execution. A swept step running 4 vectors produces 4 manifest entries
742
+ with the same ``step_path`` but ``vector_index`` 0..3; a rerun adds a
743
+ fresh entry at ``step_retry`` N+1 (the de-fuse — reruns are distinct
744
+ rows, never fused).
745
+
746
+ Partially-run sweeps (some but not all planned vectors ran)
747
+ also produce manifest entries for the unrun vectors with
748
+ ``outcome=None`` — surfaced via ``_append_not_started`` from the
749
+ collected items that never appeared in the executed events.
750
+ """
751
+ manifest: list[dict[str, Any]] = []
752
+ executed_node_ids: set[str] = set()
753
+ executed_vectors: set[tuple[str, int | None]] = set()
754
+
755
+ looped = self._looped_keys()
756
+ # Step-scope measurements only; swept steps' vector measurements count on vector rows.
757
+ step_scope_meas: dict[tuple[str, int, int | None], list[dict[str, Any]]] = {}
758
+ for e in self._measurement_events:
759
+ path = e.step_path or e.step_name or ""
760
+ key = (path, getattr(e, "step_retry", 0) or 0, getattr(e, "vector_outer_index", None))
761
+ if (path, getattr(e, "vector_outer_index", None), e.vector_index) not in looped:
762
+ step_scope_meas.setdefault(key, []).append(_measurement_event_struct(e))
763
+
764
+ # Observations per vector key — merged into the step entry's outputs
765
+ # so the step record carries the vector's observations on its lanes.
766
+ obs_by_key: dict[tuple[str, int | None], dict[str, Any]] = {}
767
+ obs_units_by_key: dict[tuple[str, int | None], dict[str, str]] = {}
768
+ obs_pins_by_key: dict[tuple[str, int | None], dict[str, str]] = {}
769
+ for ev in self._observation_events:
770
+ if ev.name.startswith("_"):
771
+ continue
772
+ okey = (ev.step_path or ev.step_name or "", ev.vector_index)
773
+ obs_by_key.setdefault(okey, {}).setdefault(ev.name, ev.value)
774
+ if getattr(ev, "unit", None):
775
+ obs_units_by_key.setdefault(okey, {}).setdefault(ev.name, ev.unit)
776
+ if getattr(ev, "uut_pin", None) is not None:
777
+ obs_pins_by_key.setdefault(okey, {}).setdefault(ev.name, ev.uut_pin)
778
+
779
+ # Sort keys by the producer-assigned (step_index, vector_outer_index, retry)
780
+ # so the resulting manifest preserves execution order regardless of the
781
+ # alphabetical position of step_path. Falls back to the key itself
782
+ # for events that didn't set step_index (zero-default).
783
+ def _sort_key(k: tuple[str, int, int | None]) -> tuple[int, int, int, str]:
784
+ ev = self._step_starts.get(k) or self._step_ends.get(k)
785
+ step_index = getattr(ev, "step_index", 0) if ev else 0
786
+ # k = (path, step_retry, vector_outer_index)
787
+ return (step_index, k[2] if k[2] is not None else -1, k[1], k[0])
788
+
789
+ reserved_by_step, structs_by_role = self._reserved_instrument_lookups()
790
+ all_keys = sorted(set(self._step_starts) | set(self._step_ends), key=_sort_key)
791
+ emitted_step_keys: set[tuple[str, int, int | None]] = set()
792
+ for key in all_keys:
793
+ path, step_retry, vec = key
794
+ start = self._step_starts.get(key)
795
+ end = self._step_ends.get(key)
796
+ node_id = start.node_id if start else None
797
+ if node_id:
798
+ executed_node_ids.add(node_id)
799
+ # ``executed_vectors`` is keyed by (step_path, vector_outer_index)
800
+ # so _append_not_started can correctly identify which CIs already
801
+ # ran — pytest parametrize variants share one logical step_path but
802
+ # have distinct node_ids, so keying by node_id misses cross-CI
803
+ # matches. The ``else 0`` is a DEDUP-ONLY placeholder for a NULL
804
+ # outer index (top-level step) — it is not a real vector index and
805
+ # never lands on a row; it only has to agree with
806
+ # _append_not_started's matching ``ci.get("vector_index") or 0``.
807
+ executed_vectors.add((path, vec if vec is not None else 0))
808
+ emitted_step_keys.add(key)
809
+ step_meas = step_scope_meas.get((path, step_retry, vec), [])
810
+ entry = self._build_step_entry(
811
+ key,
812
+ start,
813
+ end,
814
+ len(step_meas),
815
+ # Observations are keyed by their own vector_index (NULL for an
816
+ # ambient/step-scope observation). A top-level step's ``vec``
817
+ # (its enclosing vector_outer_index) is NULL too, so match on
818
+ # ``vec`` directly — coercing to 0 would miss the NULL-keyed
819
+ # ambient observations (now that observe() stamps NULL ambient).
820
+ obs_by_key.get((path, vec), {}),
821
+ obs_units_by_key.get((path, vec), {}),
822
+ obs_pins_by_key.get((path, vec), {}),
823
+ step_measurements=step_meas,
824
+ )
825
+ s_idx = entry.get("index", 0)
826
+ s_retry = entry.get("step_retry", 0) or 0
827
+ s_roles = reserved_by_step.get((s_idx, s_retry), set())
828
+ entry["instrument_records"] = [
829
+ structs_by_role[r] for r in s_roles if r in structs_by_role
830
+ ]
831
+ manifest.append(entry)
832
+
833
+ # Orphan: no StepStarted/StepEnded; synthesize minimal so measurements are never dropped.
834
+ for mkey, structs in step_scope_meas.items():
835
+ if mkey in emitted_step_keys:
836
+ continue
837
+ path, step_retry, vec = mkey
838
+ m_event = next(
839
+ (
840
+ e
841
+ for e in self._measurement_events
842
+ if (e.step_path or e.step_name or "") == path
843
+ and getattr(e, "vector_outer_index", None) == vec
844
+ and (getattr(e, "step_retry", 0) or 0) == step_retry
845
+ ),
846
+ None,
847
+ )
848
+ executed_vectors.add((path, vec if vec is not None else 0))
849
+ manifest.append(
850
+ step_entry_dict(
851
+ index=m_event.step_index if m_event else 0,
852
+ name=(m_event.step_name if m_event else "") or "",
853
+ node_id=None,
854
+ file=None,
855
+ function=None,
856
+ class_name=None,
857
+ module=None,
858
+ step_path=path,
859
+ description=None,
860
+ markers=None,
861
+ outcome=None,
862
+ started_at=None,
863
+ ended_at=None,
864
+ vector_index=None,
865
+ vector_outer_index=vec,
866
+ measurements=structs,
867
+ measurement_count=len(structs),
868
+ step_retry=step_retry,
869
+ )
870
+ )
871
+
872
+ # A @parametrize variant emits its own VectorStarted at (step_path,
873
+ # vector_index), but the step-fusion key collapses all top-level
874
+ # variants onto vector_outer_index (None→0), so the loop above only
875
+ # recorded (step_path, 0) in ``executed_vectors``. Record every vector
876
+ # that actually emitted, keyed by its OWN vector_index — the same axis
877
+ # ``_append_not_started`` matches collected items on — so a ran-but-
878
+ # fused variant (vi=1,2,…) is not mistaken for never-run and ghosted.
879
+ # A genuinely unrun vector emits no event, so a partially-run sweep
880
+ # still leaves its unrun positions absent (their "never ran" rows are
881
+ # preserved). Keyed by (step_path, vector_index), matching the check.
882
+ for vkey in set(self._vector_starts) | set(self._vector_ends):
883
+ # vkey = (step_path, vector_outer_index, vector_index, retry)
884
+ executed_vectors.add((vkey[0], vkey[2]))
885
+
886
+ _append_not_started(
887
+ manifest,
888
+ self._collected_items,
889
+ executed_node_ids,
890
+ executed_vectors=executed_vectors,
891
+ )
892
+ return manifest
893
+
894
+ def _build_step_entry(
895
+ self,
896
+ key: tuple[str, int, int | None],
897
+ start: Any | None,
898
+ end: Any | None,
899
+ meas_count: int,
900
+ observations: dict[str, Any],
901
+ observation_units: dict[str, str] | None = None,
902
+ observation_pins: dict[str, str] | None = None,
903
+ step_measurements: list[dict[str, Any]] | None = None,
904
+ ) -> dict[str, Any]:
905
+ """Build one step manifest entry from cached StepStarted/StepEnded."""
906
+ path, step_retry, vec = key
907
+ # ``step_index`` for the manifest entry comes from the StepStarted
908
+ # event itself — ``step_path`` is the dict key (unique per logical
909
+ # step) and ``step_index`` is the per-bucket index the producer
910
+ # assigned. The two are distinct concepts now that containers and
911
+ # methods can share ``step_index`` across their respective buckets.
912
+ idx = start.step_index if start else (end.step_index if end else 0)
913
+ node_id = start.node_id if start else None
914
+ inputs = _end_overrides_start(start, end, "inputs")
915
+ outputs = dict(end.outputs) if end and getattr(end, "outputs", None) else {}
916
+ input_units = _end_overrides_start(start, end, "input_units")
917
+ output_units = dict(end.output_units) if end and getattr(end, "output_units", None) else {}
918
+ output_pins = dict(end.output_pins) if end and getattr(end, "output_pins", None) else {}
919
+ # Merge accumulated observations for this vector so the in-flight
920
+ # step row carries the same out_* the materialized step aggregates
921
+ # (StepEnded.outputs already holds them once it arrives merged; this
922
+ # covers the pre-merge / direct-event projection path).
923
+ for obs_name, obs_value in observations.items():
924
+ outputs.setdefault(obs_name, obs_value)
925
+ for obs_name, obs_unit in (observation_units or {}).items():
926
+ output_units.setdefault(obs_name, obs_unit)
927
+ for obs_name, obs_pin in (observation_pins or {}).items():
928
+ output_pins.setdefault(obs_name, obs_pin)
929
+ return step_entry_dict(
930
+ index=idx,
931
+ name=start.step_name if start else (end.step_name if end else ""),
932
+ node_id=node_id,
933
+ file=start.file if start else None,
934
+ function=start.function if start else None,
935
+ class_name=start.class_name if start else None,
936
+ module=start.module if start else None,
937
+ step_path=(
938
+ (start.step_path if start else end.step_path if end else "")
939
+ or (start.step_name if start else end.step_name if end else "")
940
+ ),
941
+ description=start.description if start else None,
942
+ markers=self._markers_by_node.get(node_id) if node_id else None,
943
+ outcome=end.outcome if end else None,
944
+ started_at=start.occurred_at if start else None,
945
+ ended_at=end.occurred_at if end else None,
946
+ vector_index=None,
947
+ vector_outer_index=vec,
948
+ inputs=inputs,
949
+ outputs=outputs,
950
+ input_units=input_units,
951
+ output_units=output_units,
952
+ output_pins=output_pins,
953
+ measurements=step_measurements or [],
954
+ measurement_count=meas_count,
955
+ step_retry=step_retry,
956
+ instrument_records=[],
957
+ )