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,664 @@
1
+ """Session-less channel index — the warm DuckDB reader behind ChannelStore.
2
+
3
+ A channel producer *stamps* its ``session_id`` onto every sample it writes; this
4
+ index *reads* session_ids off the rows and has none of its own. It is the
5
+ cross-session corpus: a persistent derived cache (``_index.duckdb`` in the
6
+ channels dir) over the producer IPC segments, plus an ephemeral ``:memory:``
7
+ overlay for live ``do_put`` rows. Producer IPC files remain the durable truth;
8
+ the index survives a daemon restart and is brought current by an incremental
9
+ ledger-gated scan.
10
+
11
+ ``ChannelStore`` composes one of these when indexing is enabled — on the daemon
12
+ (it ingests + serves at-rest query) and in warm-index tests (a writer that also
13
+ indexes its own writes). The index never knows which session is "active": every
14
+ read filters by a ``session_id`` query parameter, never an instance field.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import threading
22
+ import time
23
+ from collections.abc import Sequence
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import duckdb
29
+ import pyarrow as pa
30
+ import pyarrow.ipc as ipc
31
+
32
+ from testerkit.data._daemon_lifecycle import daemon_duckdb_config
33
+ from testerkit.data.channels.models import (
34
+ ChannelDescriptor,
35
+ ChannelSample,
36
+ encode_value,
37
+ )
38
+ from testerkit.data.schema_dispatch import (
39
+ SchemaVersionRefused,
40
+ dispatch,
41
+ report_schema_refusal,
42
+ stamp_from_arrow_metadata,
43
+ )
44
+ from testerkit.data.schema_versions import SchemaStore
45
+
46
+
47
+ def _to_utc(dt: datetime | None) -> datetime | None:
48
+ """Coerce a datetime to UTC (or pass through ``None``).
49
+
50
+ Naive datetimes are interpreted as already UTC; aware datetimes
51
+ are converted via ``astimezone(UTC)``.
52
+ """
53
+ if dt is None:
54
+ return None
55
+ if dt.tzinfo is None:
56
+ return dt.replace(tzinfo=UTC)
57
+ return dt.astimezone(UTC)
58
+
59
+
60
+ def _lttb_indices(values: Sequence[float], n_out: int) -> list[int]:
61
+ """Largest Triangle Three Buckets downsampling — return selected indices.
62
+
63
+ Visually lossless: preserves peaks, valleys, and shape better than naive
64
+ stride decimation. Delegates to ``tsdownsample`` (compiled LTTB); first and
65
+ last points are always kept.
66
+
67
+ Reference: Sveinn Steinarsson, "Downsampling Time Series for Visual
68
+ Representation", MSc thesis, University of Iceland, 2013.
69
+ """
70
+ n = len(values)
71
+ if n <= n_out or n_out < 3:
72
+ return list(range(n))
73
+ # Heavy deps deferred off the module import path — only the decimation
74
+ # (query w/ max_points) path pays numpy/tsdownsample's load.
75
+ import numpy as np # noqa: PLC0415
76
+ from tsdownsample import LTTBDownsampler # noqa: PLC0415
77
+
78
+ indices = LTTBDownsampler().downsample(np.asarray(values, dtype=float), n_out=n_out)
79
+ return [int(i) for i in indices]
80
+
81
+
82
+ def _decimate_table(table: pa.Table, max_points: int) -> pa.Table:
83
+ """Apply LTTB decimation to an Arrow table.
84
+
85
+ Uses the ``value`` column for scalar channels, or row index for
86
+ struct/array channels (where there's no single numeric column).
87
+ """
88
+ n = len(table)
89
+ if n <= max_points:
90
+ return table
91
+
92
+ # Find best column for LTTB area calculation
93
+ if "value" in table.schema.names:
94
+ col = table.column("value")
95
+ try:
96
+ values = [float(v.as_py()) for v in col]
97
+ except (TypeError, ValueError):
98
+ # Non-numeric value column — fall back to stride
99
+ indices = list(range(0, n, max(1, n // max_points)))[:max_points]
100
+ return table.take(indices)
101
+ else:
102
+ # Struct/array channel — use row index as proxy (preserves time density)
103
+ values = list(range(n))
104
+
105
+ indices = _lttb_indices(values, max_points)
106
+ return table.take(indices)
107
+
108
+
109
+ class ChannelIndex:
110
+ """Warm DuckDB index over closed channel segments + a live overlay.
111
+
112
+ Session-less: reads session_ids off the data rows and filters by a query
113
+ parameter. Owns the index connection, the incremental scan ledger, the
114
+ per-(channel, session) registry, and the live-row pending buffer.
115
+ """
116
+
117
+ _INDEX_ENVELOPE = frozenset(
118
+ {
119
+ "received_at",
120
+ "sampled_at",
121
+ "source_method",
122
+ "session_id",
123
+ "sample_interval",
124
+ "sample_offset",
125
+ }
126
+ )
127
+
128
+ _INDEX_ARROW_SCHEMA = pa.schema(
129
+ [
130
+ ("channel_id", pa.utf8()),
131
+ ("session_id", pa.utf8()),
132
+ ("received_at", pa.timestamp("us", tz="UTC")),
133
+ ("sampled_at", pa.timestamp("us", tz="UTC")),
134
+ ("source_method", pa.utf8()),
135
+ ("sample_interval", pa.float64()),
136
+ ("value", pa.utf8()),
137
+ ("sample_offset", pa.int64()),
138
+ ]
139
+ )
140
+
141
+ # Bound how often a query re-globs the channels dir for newly-closed
142
+ # segments — frequent dashboard polls shouldn't each pay a directory walk.
143
+ _RUNTIME_SCAN_INTERVAL = 1.0
144
+
145
+ _REGISTRY_COLUMNS = (
146
+ "channel_id, session_id, hostname, value_type, instrument_role, "
147
+ "resource, unit, first_seen, last_updated"
148
+ )
149
+
150
+ def __init__(self, channels_dir: Path) -> None:
151
+ self._channels_dir = channels_dir
152
+ # Served descriptor cache (channel-keyed, first-wins) populated by
153
+ # absorb_descriptor as segments/streams are read.
154
+ self._descriptors: dict[str, ChannelDescriptor] = {}
155
+ self._index_db: duckdb.DuckDBPyConnection | None = None
156
+ self._index_local = threading.local()
157
+ self._index_lock = threading.Lock()
158
+ self._pending: list[dict[str, Any]] = []
159
+ self._pending_lock = threading.Lock()
160
+ self._pending_threshold = 100
161
+ self._scan_lock = threading.Lock()
162
+ self._last_scan = 0.0
163
+
164
+ # ---- descriptor cache ----
165
+
166
+ def has(self, channel_id: str) -> bool:
167
+ """Whether a descriptor has been absorbed for this channel."""
168
+ return channel_id in self._descriptors
169
+
170
+ def descriptor(self, channel_id: str) -> ChannelDescriptor | None:
171
+ """The absorbed descriptor for a channel, or None."""
172
+ return self._descriptors.get(channel_id)
173
+
174
+ def descriptors(self) -> list[ChannelDescriptor]:
175
+ """All absorbed descriptors (one per channel)."""
176
+ return list(self._descriptors.values())
177
+
178
+ # ---- lifecycle ----
179
+
180
+ def open(self) -> None:
181
+ """Open the on-disk index and fold in segments closed since last run.
182
+
183
+ The index is a persistent derived cache (``_index.duckdb`` in the
184
+ channels dir): it survives a daemon restart and is brought current
185
+ by an **incremental** scan — only segments not already in the
186
+ ``_ingested`` ledger are read (vs. the old wipe-and-rebuild-from-all
187
+ on every start). Producer IPC files remain the durable truth.
188
+
189
+ Live ``do_put`` rows ride a separate attached ``:memory:`` overlay
190
+ (``live.channel_live``): they are ephemeral (lost on restart, then
191
+ re-derived from their now-closed segments by the incremental scan),
192
+ so they never collide with a segment-scanned row. Mirrors the runs
193
+ daemon's persistent-index + in-memory-overlay split.
194
+ """
195
+ index_path = self._channels_dir / "_index.duckdb"
196
+ self._index_db = duckdb.connect(str(index_path), config=daemon_duckdb_config())
197
+ self._ensure_schema(self._index_db)
198
+ # Ephemeral live overlay: attached :memory: so it's visible to every
199
+ # child read cursor (a register()'d temp view would not be), yet not
200
+ # persisted — it's a projection of in-flight samples, re-derived from
201
+ # segments on restart.
202
+ self._index_db.execute("ATTACH ':memory:' AS live")
203
+ self._index_db.execute(
204
+ "CREATE TABLE live.channel_live AS SELECT * FROM channel_index LIMIT 0"
205
+ )
206
+ self._scan_disk()
207
+
208
+ def close(self) -> None:
209
+ """Close the index connection."""
210
+ if self._index_db is not None:
211
+ try:
212
+ self._index_db.close()
213
+ except (OSError, duckdb.Error):
214
+ pass
215
+ self._index_db = None
216
+
217
+ @staticmethod
218
+ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
219
+ """Create the on-disk index schema if absent.
220
+
221
+ The index is a disposable projection — every row is re-derivable from
222
+ the durable ``.arrow`` segments. ``CREATE TABLE IF NOT EXISTS`` keeps an
223
+ existing table as-is, so a column change is not an in-place migration:
224
+ clear ``data/channels`` and let the next open rebuild the projection.
225
+ """
226
+ conn.execute(
227
+ """
228
+ CREATE TABLE IF NOT EXISTS channel_index (
229
+ channel_id VARCHAR,
230
+ session_id VARCHAR,
231
+ received_at TIMESTAMPTZ,
232
+ sampled_at TIMESTAMPTZ,
233
+ source_method VARCHAR,
234
+ sample_interval DOUBLE,
235
+ value VARCHAR,
236
+ sample_offset BIGINT
237
+ )
238
+ """
239
+ )
240
+ # Ledger of ingested segments — keyed on path alone. A channel
241
+ # segment is written exactly once (one batch, then closed
242
+ # immutable), so a path that's already recorded never needs
243
+ # re-reading. (Events key on (path, mtime, size) because its IPC
244
+ # files grow; channel segments don't.)
245
+ conn.execute(
246
+ "CREATE TABLE IF NOT EXISTS _ingested (path VARCHAR PRIMARY KEY, row_count BIGINT)"
247
+ )
248
+ conn.execute(
249
+ "CREATE INDEX IF NOT EXISTS idx_channel_index_cid_recv "
250
+ "ON channel_index(channel_id, received_at)"
251
+ )
252
+ # Derived registry: one version row per (channel, session). Non-unique on
253
+ # (hostname, channel) — each session that opens a channel appends a row, so
254
+ # current def = latest, history = all rows. ``last_updated`` (coarse) is the
255
+ # freshest received_at seen, for liveness staleness.
256
+ conn.execute(
257
+ """
258
+ CREATE TABLE IF NOT EXISTS channel_registry (
259
+ channel_id VARCHAR,
260
+ session_id VARCHAR,
261
+ hostname VARCHAR,
262
+ value_type VARCHAR,
263
+ instrument_role VARCHAR,
264
+ resource VARCHAR,
265
+ unit VARCHAR,
266
+ first_seen TIMESTAMPTZ,
267
+ last_updated TIMESTAMPTZ,
268
+ PRIMARY KEY (channel_id, session_id)
269
+ )
270
+ """
271
+ )
272
+
273
+ def _cursor(self) -> duckdb.DuckDBPyConnection:
274
+ """Thread-local read cursor over the shared in-memory index."""
275
+ cur = getattr(self._index_local, "cur", None)
276
+ if cur is None:
277
+ assert self._index_db is not None
278
+ cur = self._index_db.cursor()
279
+ self._index_local.cur = cur
280
+ return cur
281
+
282
+ # ---- segment scanning ----
283
+
284
+ def _scan_disk(self) -> None:
285
+ """Fold segments closed since last run into the persistent index.
286
+
287
+ Incremental: a segment already in the ``_ingested`` ledger is
288
+ skipped, so a daemon restart only reads new files rather than
289
+ rebuilding the whole index from every segment.
290
+ """
291
+ if self._index_db is None:
292
+ return
293
+ pattern = re.compile(r"^(.+)_([0-9a-f]{8})(?:_\d+)?$")
294
+ with self._index_lock:
295
+ ingested = {
296
+ row[0] for row in self._index_db.execute("SELECT path FROM _ingested").fetchall()
297
+ }
298
+ for arrow_file in sorted(self._channels_dir.glob("*/*.arrow")):
299
+ path_str = str(arrow_file)
300
+ if path_str in ingested:
301
+ continue
302
+ m = pattern.match(arrow_file.stem)
303
+ if not m:
304
+ continue
305
+ try:
306
+ reader = ipc.open_stream(pa.OSFile(str(arrow_file), "rb"))
307
+ table = reader.read_all()
308
+ except (pa.ArrowInvalid, OSError):
309
+ # Torn / still-open segment — leave it out of the ledger so
310
+ # the next restart re-reads it once it's a complete file.
311
+ continue
312
+ try:
313
+ adapter = dispatch(
314
+ SchemaStore.CHANNELS, stamp_from_arrow_metadata(table.schema.metadata)
315
+ )
316
+ except SchemaVersionRefused as exc:
317
+ # Surface it (permanent → warn-once; deferrable → debug), then skip
318
+ # WITHOUT ledgering so a deferrable (newer) segment is re-read next
319
+ # scan and a newer daemon ingests it (#43). Presence-only ledger →
320
+ # an absent/pre-1.0 segment is re-read too; the warning is deduped.
321
+ report_schema_refusal(exc, path_str)
322
+ continue
323
+ table = adapter(table)
324
+ desc = self.absorb_descriptor(m.group(1), table.schema)
325
+ if desc is not None and "received_at" in table.column_names:
326
+ recv = [t for t in table.column("received_at").to_pylist() if t is not None]
327
+ if recv:
328
+ self.bump_last_updated(desc.channel_id, desc.session_id, max(recv))
329
+ rows = self._segment_rows_to_index(m.group(1), table)
330
+ self._insert_index_rows(rows, "channel_index", ledger_path=path_str)
331
+
332
+ def _maybe_scan_disk(self) -> None:
333
+ """Fold newly-closed segments into the index before a query (throttled).
334
+
335
+ Closes the restart-recovery gap: a sample the live push dropped under
336
+ overflow is durable in its closed segment and becomes queryable here,
337
+ without bouncing the daemon. Incremental (ledger-gated) and rate-limited;
338
+ the union dedup in ``query`` absorbs any overlap with the overlay.
339
+ """
340
+ now = time.monotonic()
341
+ with self._scan_lock:
342
+ if now - self._last_scan < self._RUNTIME_SCAN_INTERVAL:
343
+ return
344
+ self._last_scan = now
345
+ self._scan_disk()
346
+
347
+ # ---- registry ----
348
+
349
+ def absorb_descriptor(self, channel_id: str, schema: pa.Schema) -> ChannelDescriptor | None:
350
+ """Populate the served descriptor map + registry from segment/stream metadata.
351
+
352
+ Producers stamp the ``ChannelDescriptor`` as Arrow schema metadata on
353
+ every segment; the daemon (which never calls ``write()``) reads it here
354
+ so ``list_channel_info`` can serve it. Returns the parsed descriptor (or
355
+ ``None`` if absent) so callers can reuse it without re-parsing.
356
+ """
357
+ meta = (schema.metadata or {}).get(b"testerkit.channel_descriptor")
358
+ if not meta:
359
+ return None
360
+ desc = ChannelDescriptor.model_validate_json(meta)
361
+ # Registry row is per (channel, session): establish it even when the
362
+ # channel-keyed descriptor cache already holds another session's descriptor
363
+ # (its last-write-wins would otherwise drop this session's version row).
364
+ self._register_descriptor_row(desc)
365
+ if channel_id not in self._descriptors:
366
+ self._descriptors[channel_id] = desc
367
+ return desc
368
+
369
+ def _register_descriptor_row(self, desc: ChannelDescriptor) -> None:
370
+ """Establish a (channel, session) registry version row (idempotent).
371
+
372
+ Carries the per-session hostname/descriptor; ``last_updated`` starts NULL
373
+ and is advanced by :meth:`bump_last_updated`. No-op off the indexed daemon.
374
+ """
375
+ if self._index_db is None:
376
+ return
377
+ with self._index_lock:
378
+ self._index_db.execute(
379
+ """
380
+ INSERT INTO channel_registry
381
+ (channel_id, session_id, hostname, value_type, instrument_role,
382
+ resource, unit, first_seen, last_updated)
383
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
384
+ ON CONFLICT (channel_id, session_id) DO NOTHING
385
+ """,
386
+ [
387
+ desc.channel_id,
388
+ desc.session_id,
389
+ desc.hostname,
390
+ desc.value_type,
391
+ desc.instrument_role,
392
+ desc.resource,
393
+ desc.unit,
394
+ desc.first_seen,
395
+ ],
396
+ )
397
+
398
+ def bump_last_updated(self, channel_id: str, session_id: str, ts: datetime | None) -> None:
399
+ """Advance a registry row's ``last_updated`` to ``ts`` if newer (NULL-safe)."""
400
+ if self._index_db is None or ts is None or not session_id:
401
+ return
402
+ with self._index_lock:
403
+ self._index_db.execute(
404
+ """
405
+ UPDATE channel_registry
406
+ SET last_updated = CASE
407
+ WHEN last_updated IS NULL OR ? > last_updated THEN ? ELSE last_updated END
408
+ WHERE channel_id = ? AND session_id = ?
409
+ """,
410
+ [ts, ts, channel_id, session_id],
411
+ )
412
+
413
+ def query_registry(self) -> pa.Table:
414
+ """All ``(hostname, channel, session)`` registry version rows.
415
+
416
+ Daemon-side read of the derived registry (folds any newly-closed segments
417
+ first). Off the indexed daemon this is empty.
418
+ """
419
+ if self._index_db is None:
420
+ return pa.table({c.strip(): [] for c in self._REGISTRY_COLUMNS.split(",")})
421
+ self._maybe_scan_disk()
422
+ cur = self._cursor()
423
+ result = cur.execute(f"SELECT {self._REGISTRY_COLUMNS} FROM channel_registry")
424
+ return result.arrow().read_all()
425
+
426
+ # ---- row building + insert ----
427
+
428
+ @classmethod
429
+ def _segment_rows_to_index(cls, channel_id: str, table: pa.Table) -> list[dict[str, Any]]:
430
+ """Convert a typed segment table to index rows (``value`` JSON-encoded).
431
+
432
+ ``channel_id`` comes from the filename (segments don't store it);
433
+ ``session_id`` from the row column. Scalar/array rows carry a
434
+ ``value`` column; dict/struct rows fold their non-envelope columns
435
+ back into one JSON object.
436
+ """
437
+ out: list[dict[str, Any]] = []
438
+ for r in table.to_pylist():
439
+ if "value" in r:
440
+ payload = r["value"]
441
+ else:
442
+ payload = {k: v for k, v in r.items() if k not in cls._INDEX_ENVELOPE}
443
+ out.append(
444
+ {
445
+ "channel_id": channel_id,
446
+ "session_id": r.get("session_id"),
447
+ "received_at": r.get("received_at"),
448
+ "sampled_at": r.get("sampled_at"),
449
+ "source_method": r.get("source_method") or "",
450
+ "sample_interval": r.get("sample_interval"),
451
+ "value": encode_value(payload),
452
+ # Carry the segment's sample_offset into the index so a scanned row
453
+ # dedups against the same sample in the live overlay (else the
454
+ # runtime fold would double-count it with a null sample_offset).
455
+ "sample_offset": r.get("sample_offset", -1),
456
+ }
457
+ )
458
+ return out
459
+
460
+ def _insert_index_rows(
461
+ self,
462
+ rows: list[dict[str, Any]],
463
+ table: str,
464
+ *,
465
+ ledger_path: str | None = None,
466
+ ) -> None:
467
+ """Insert index rows under the write lock (single writer).
468
+
469
+ ``table`` is ``channel_index`` (durable, segment-scanned) or
470
+ ``live.channel_live`` (ephemeral overlay). ``ledger_path``, when
471
+ given, records the source segment in ``_ingested`` in the SAME
472
+ transaction as the insert, so a crash can't half-record a segment.
473
+ """
474
+ if self._index_db is None:
475
+ return
476
+ if not rows:
477
+ # An empty segment still needs its ledger mark so it isn't
478
+ # re-read on every restart.
479
+ if ledger_path is not None:
480
+ with self._index_lock:
481
+ self._index_db.execute(
482
+ "INSERT OR IGNORE INTO _ingested (path, row_count) VALUES (?, 0)",
483
+ [ledger_path],
484
+ )
485
+ return
486
+ tbl = pa.Table.from_pylist(rows, schema=self._INDEX_ARROW_SCHEMA)
487
+ with self._index_lock:
488
+ self._index_db.register("_incoming", tbl)
489
+ self._index_db.execute(f"INSERT INTO {table} SELECT * FROM _incoming")
490
+ self._index_db.unregister("_incoming")
491
+ if ledger_path is not None:
492
+ self._index_db.execute(
493
+ "INSERT OR IGNORE INTO _ingested (path, row_count) VALUES (?, ?)",
494
+ [ledger_path, len(rows)],
495
+ )
496
+
497
+ def insert_live_columnar(self, src_batch: pa.RecordBatch) -> None:
498
+ """Columnar index insert for the scalar fast path: project the wire batch's
499
+ columns straight into the index schema and INSERT…SELECT — no per-row
500
+ dict build. The wire ``value`` (JSON utf8) is already the index encoding."""
501
+ if self._index_db is None or src_batch.num_rows == 0:
502
+ return
503
+ idx = pa.record_batch(
504
+ {name: src_batch.column(name) for name in self._INDEX_ARROW_SCHEMA.names},
505
+ schema=self._INDEX_ARROW_SCHEMA,
506
+ )
507
+ tbl = pa.Table.from_batches([idx])
508
+ with self._index_lock:
509
+ self._index_db.register("_incoming", tbl)
510
+ self._index_db.execute("INSERT INTO live.channel_live SELECT * FROM _incoming")
511
+ self._index_db.unregister("_incoming")
512
+
513
+ @staticmethod
514
+ def _payload_and_interval(sample: ChannelSample) -> tuple[Any, float | None]:
515
+ """Split an array sample's ``{value, sample_interval}`` envelope.
516
+
517
+ ``_normalize_value`` folds arrays into ``{"value": [...],
518
+ "sample_interval": dt}``, which is what rides on the live sample.
519
+ Segments store the array in the ``value`` column with
520
+ ``sample_interval`` alongside, so the index must too — otherwise a
521
+ live-ingested array would encode differently from a disk-scanned one.
522
+ """
523
+ v = sample.value
524
+ if (
525
+ isinstance(v, dict)
526
+ and set(v.keys()) == {"value", "sample_interval"}
527
+ and isinstance(v.get("value"), list)
528
+ ):
529
+ return v["value"], v["sample_interval"]
530
+ return v, sample.sample_interval
531
+
532
+ def index_row(self, channel_id: str, sample: ChannelSample) -> dict[str, Any]:
533
+ """Build one index row from a sample (``value`` JSON-encoded)."""
534
+ payload, interval = self._payload_and_interval(sample)
535
+ return {
536
+ "channel_id": channel_id,
537
+ "session_id": sample.session_id,
538
+ "received_at": sample.received_at,
539
+ "sampled_at": sample.sampled_at,
540
+ "source_method": sample.source_method or "",
541
+ "sample_interval": interval,
542
+ "value": encode_value(payload),
543
+ "sample_offset": sample.sample_offset,
544
+ }
545
+
546
+ def extend_pending(self, rows: list[dict[str, Any]]) -> None:
547
+ """Append index rows to the pending buffer; flush past the threshold."""
548
+ if not rows:
549
+ return
550
+ with self._pending_lock:
551
+ self._pending.extend(rows)
552
+ overflowed = len(self._pending) >= self._pending_threshold
553
+ if overflowed:
554
+ self._flush_pending()
555
+
556
+ def _flush_pending(self) -> None:
557
+ """Move pending live rows into the index."""
558
+ with self._pending_lock:
559
+ if not self._pending:
560
+ return
561
+ pending = self._pending
562
+ self._pending = []
563
+ # Live rows land in the ephemeral overlay, NOT the durable index:
564
+ # their durable copy is the producer segment, folded into
565
+ # channel_index by the incremental scan on the next restart.
566
+ self._insert_index_rows(pending, "live.channel_live")
567
+
568
+ # ---- query ----
569
+
570
+ def query(
571
+ self,
572
+ channel_id: str,
573
+ *,
574
+ session_id: str | None,
575
+ start: datetime | None,
576
+ end: datetime | None,
577
+ last_n: int | None,
578
+ max_points: int | None,
579
+ sample_offset: int | None = None,
580
+ ) -> pa.Table:
581
+ """At-rest query served from the warm index (∪ pending buffer)."""
582
+ self._maybe_scan_disk()
583
+ self._flush_pending()
584
+ cur = self._cursor()
585
+ # Union the durable index with the live overlay (same columns). A sample
586
+ # lands in the overlay (push) and/or channel_index (segment scan), and the
587
+ # runtime fold can put it in both — _dedup_on_sample_offset below collapses the
588
+ # overlap on the per-sample cursor (session, sample_offset).
589
+ sql = [
590
+ "SELECT received_at, sampled_at, value, source_method, "
591
+ "session_id, sample_interval, sample_offset FROM ("
592
+ "SELECT * FROM channel_index UNION ALL SELECT * FROM live.channel_live"
593
+ ") WHERE channel_id = ?"
594
+ ]
595
+ params: list[Any] = [channel_id]
596
+ if session_id:
597
+ sql.append("AND left(session_id, 8) = left(?, 8)")
598
+ params.append(session_id)
599
+ start_utc = _to_utc(start)
600
+ end_utc = _to_utc(end)
601
+ if start_utc is not None:
602
+ sql.append("AND received_at >= ?")
603
+ params.append(start_utc)
604
+ if end_utc is not None:
605
+ sql.append("AND received_at <= ?")
606
+ params.append(end_utc)
607
+ if sample_offset is not None:
608
+ sql.append("AND sample_offset = ?")
609
+ params.append(sample_offset)
610
+ sql.append("ORDER BY received_at")
611
+ table = self._dedup_on_sample_offset(cur.execute(" ".join(sql), params).arrow().read_all())
612
+
613
+ if last_n is not None and table.num_rows > last_n:
614
+ table = table.slice(table.num_rows - last_n)
615
+ table = self._decode_value_column(table)
616
+ if max_points is not None and table.num_rows > max_points:
617
+ table = _decimate_table(table, max_points)
618
+ return table
619
+
620
+ @staticmethod
621
+ def _dedup_on_sample_offset(table: pa.Table) -> pa.Table:
622
+ """Collapse overlay∪index overlap on the per-sample cursor (session, sample_offset).
623
+
624
+ The runtime segment fold can place a sample in both the durable index and
625
+ the live overlay; both copies are identical, so keep the first (the table
626
+ is already ordered by ``received_at``). A no-op when they don't overlap.
627
+ Rows with an unstamped ``sample_offset`` (< 0, legacy) are never collapsed.
628
+ """
629
+ if table.num_rows == 0 or "sample_offset" not in table.column_names:
630
+ return table
631
+ sessions = table.column("session_id").to_pylist()
632
+ sample_offsets = table.column("sample_offset").to_pylist()
633
+ seen: set[tuple[Any, int]] = set()
634
+ keep: list[int] = []
635
+ for i, (s, o) in enumerate(zip(sessions, sample_offsets, strict=True)):
636
+ if o is not None and o >= 0:
637
+ key = (s, o)
638
+ if key in seen:
639
+ continue
640
+ seen.add(key)
641
+ keep.append(i)
642
+ return table if len(keep) == table.num_rows else table.take(keep)
643
+
644
+ @staticmethod
645
+ def _decode_value_column(table: pa.Table) -> pa.Table:
646
+ """JSON-decode the VARCHAR ``value`` column back to typed values.
647
+
648
+ Inverse of ``encode_value``: non-JSON strings pass through (matches
649
+ ``batch_row_to_sample``). Values within one channel are homogeneous,
650
+ so Arrow infers a single column type.
651
+ """
652
+ if "value" not in table.column_names or table.num_rows == 0:
653
+ return table
654
+ decoded: list[Any] = []
655
+ for v in table.column("value").to_pylist():
656
+ if v is None:
657
+ decoded.append(None)
658
+ continue
659
+ try:
660
+ decoded.append(json.loads(v))
661
+ except (json.JSONDecodeError, TypeError):
662
+ decoded.append(v)
663
+ idx = table.column_names.index("value")
664
+ return table.set_column(idx, "value", pa.array(decoded))