mostlyright-data 0.9.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 (314) hide show
  1. mostlyright/data_harness/__init__.py +158 -0
  2. mostlyright/data_harness/acquisition/__init__.py +55 -0
  3. mostlyright/data_harness/acquisition/http.py +2773 -0
  4. mostlyright/data_harness/acquisition/parsing.py +809 -0
  5. mostlyright/data_harness/acquisition/ranges.py +495 -0
  6. mostlyright/data_harness/acquisition/result_download.py +360 -0
  7. mostlyright/data_harness/acquisition/retention_admission.py +248 -0
  8. mostlyright/data_harness/acquisition/sandbox.py +4888 -0
  9. mostlyright/data_harness/acquisition/url_policy.py +530 -0
  10. mostlyright/data_harness/agent_runtime.py +2743 -0
  11. mostlyright/data_harness/assets/logo-ink.svg +31 -0
  12. mostlyright/data_harness/backends/__init__.py +28 -0
  13. mostlyright/data_harness/backends/pandas_backend.py +350 -0
  14. mostlyright/data_harness/backends/polars_backend.py +366 -0
  15. mostlyright/data_harness/backends/protocol.py +124 -0
  16. mostlyright/data_harness/backends/reference.py +83 -0
  17. mostlyright/data_harness/backends/registry.py +55 -0
  18. mostlyright/data_harness/backends/restrictions.py +126 -0
  19. mostlyright/data_harness/canonical.py +333 -0
  20. mostlyright/data_harness/catalog_job.py +625 -0
  21. mostlyright/data_harness/cli.py +5398 -0
  22. mostlyright/data_harness/contracts.py +53 -0
  23. mostlyright/data_harness/coordinator.py +1307 -0
  24. mostlyright/data_harness/deploy.py +924 -0
  25. mostlyright/data_harness/deploy_target.py +312 -0
  26. mostlyright/data_harness/deployment_evidence.py +1067 -0
  27. mostlyright/data_harness/event_presentation.py +576 -0
  28. mostlyright/data_harness/events.py +2152 -0
  29. mostlyright/data_harness/fast_delimited.py +239 -0
  30. mostlyright/data_harness/fleet.py +237 -0
  31. mostlyright/data_harness/formats.py +236 -0
  32. mostlyright/data_harness/governors.py +1163 -0
  33. mostlyright/data_harness/hosted_bootstrap.py +972 -0
  34. mostlyright/data_harness/hosted_crawler.py +1115 -0
  35. mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
  36. mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
  37. mostlyright/data_harness/hosted_crawler_job.py +1277 -0
  38. mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
  39. mostlyright/data_harness/hosted_dataset.py +1500 -0
  40. mostlyright/data_harness/hosted_deploy.py +3037 -0
  41. mostlyright/data_harness/hosted_handoff.py +62 -0
  42. mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
  43. mostlyright/data_harness/hosted_ingestion_job.py +356 -0
  44. mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
  45. mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
  46. mostlyright/data_harness/hosted_session_worker.py +3554 -0
  47. mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
  48. mostlyright/data_harness/hosted_worker.py +6784 -0
  49. mostlyright/data_harness/ingestion/__init__.py +56 -0
  50. mostlyright/data_harness/ingestion/contracts.py +461 -0
  51. mostlyright/data_harness/ingestion/faults.py +42 -0
  52. mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
  53. mostlyright/data_harness/ingestion/spool.py +130 -0
  54. mostlyright/data_harness/ingestion/store.py +885 -0
  55. mostlyright/data_harness/key_seam.py +434 -0
  56. mostlyright/data_harness/linux_process_boundary.py +262 -0
  57. mostlyright/data_harness/local_contracts.py +2880 -0
  58. mostlyright/data_harness/local_search/__init__.py +5 -0
  59. mostlyright/data_harness/local_search/build_index.py +1087 -0
  60. mostlyright/data_harness/local_search/contracts.py +920 -0
  61. mostlyright/data_harness/local_search/query_trace.py +266 -0
  62. mostlyright/data_harness/local_search/retrieval.py +700 -0
  63. mostlyright/data_harness/local_search/sealed.py +474 -0
  64. mostlyright/data_harness/local_search/service.py +784 -0
  65. mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
  66. mostlyright/data_harness/nbrender/__init__.py +12 -0
  67. mostlyright/data_harness/nbrender/chrome.py +359 -0
  68. mostlyright/data_harness/nbrender/code_body.py +266 -0
  69. mostlyright/data_harness/nbrender/document.py +407 -0
  70. mostlyright/data_harness/nbrender/frame.py +275 -0
  71. mostlyright/data_harness/nbrender/interactive.py +337 -0
  72. mostlyright/data_harness/nbrender/markdown_body.py +477 -0
  73. mostlyright/data_harness/nbrender/mr_components.py +134 -0
  74. mostlyright/data_harness/nbrender/outputs_data.py +595 -0
  75. mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
  76. mostlyright/data_harness/nbrender/outputs_source.py +260 -0
  77. mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
  78. mostlyright/data_harness/nbrender/outputs_text.py +400 -0
  79. mostlyright/data_harness/nbrender/parse.py +394 -0
  80. mostlyright/data_harness/nbrender/status.py +40 -0
  81. mostlyright/data_harness/nbrender/tokens.py +1295 -0
  82. mostlyright/data_harness/notebook.py +1710 -0
  83. mostlyright/data_harness/offline.py +2049 -0
  84. mostlyright/data_harness/operation_registry.py +1007 -0
  85. mostlyright/data_harness/operator_setup.py +239 -0
  86. mostlyright/data_harness/pipeline.py +6428 -0
  87. mostlyright/data_harness/plan_graph.py +2026 -0
  88. mostlyright/data_harness/preparation/__init__.py +104 -0
  89. mostlyright/data_harness/preparation/contracts.py +1017 -0
  90. mostlyright/data_harness/preparation/engine.py +221 -0
  91. mostlyright/data_harness/preparation/errors.py +14 -0
  92. mostlyright/data_harness/preparation/gates.py +751 -0
  93. mostlyright/data_harness/preparation/joins.py +574 -0
  94. mostlyright/data_harness/preparation/profile.py +384 -0
  95. mostlyright/data_harness/preparation/table.py +217 -0
  96. mostlyright/data_harness/preparation/transforms.py +568 -0
  97. mostlyright/data_harness/progress_events.py +534 -0
  98. mostlyright/data_harness/readers/__init__.py +46 -0
  99. mostlyright/data_harness/readers/containers.py +963 -0
  100. mostlyright/data_harness/readers/contracts.py +542 -0
  101. mostlyright/data_harness/readers/delimited.py +257 -0
  102. mostlyright/data_harness/readers/grib2/__init__.py +33 -0
  103. mostlyright/data_harness/readers/grib2/admission.py +722 -0
  104. mostlyright/data_harness/readers/grib2/decode.py +1009 -0
  105. mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
  106. mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
  107. mostlyright/data_harness/readers/json_tabular.py +485 -0
  108. mostlyright/data_harness/readers/registry.py +514 -0
  109. mostlyright/data_harness/readers/samples/README.md +110 -0
  110. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
  111. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
  112. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
  113. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
  114. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
  115. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
  116. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
  117. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
  118. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
  119. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
  120. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  121. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
  122. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  123. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
  124. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
  125. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
  126. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
  127. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
  128. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
  129. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
  130. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
  131. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
  132. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
  133. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
  134. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
  135. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
  136. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  137. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  138. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  139. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  140. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
  141. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
  142. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
  143. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
  144. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  145. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
  146. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
  147. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
  148. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  149. mostlyright/data_harness/readers/samples.py +582 -0
  150. mostlyright/data_harness/readers/spreadsheet.py +803 -0
  151. mostlyright/data_harness/readers/tabular.py +510 -0
  152. mostlyright/data_harness/recipe.py +5321 -0
  153. mostlyright/data_harness/repair/__init__.py +78 -0
  154. mostlyright/data_harness/repair/adapters.py +274 -0
  155. mostlyright/data_harness/repair/contracts.py +872 -0
  156. mostlyright/data_harness/repair/coordinator.py +1099 -0
  157. mostlyright/data_harness/repair/errors.py +16 -0
  158. mostlyright/data_harness/review.py +2533 -0
  159. mostlyright/data_harness/rowset.py +283 -0
  160. mostlyright/data_harness/serving.py +1975 -0
  161. mostlyright/data_harness/serving_edge.py +590 -0
  162. mostlyright/data_harness/serving_http.py +1031 -0
  163. mostlyright/data_harness/session_probes.py +759 -0
  164. mostlyright/data_harness/signing.py +101 -0
  165. mostlyright/data_harness/source_discovery.py +898 -0
  166. mostlyright/data_harness/sources/__init__.py +209 -0
  167. mostlyright/data_harness/sources/_adapter_steps.py +213 -0
  168. mostlyright/data_harness/sources/adapters.py +1214 -0
  169. mostlyright/data_harness/sources/cadence.py +1428 -0
  170. mostlyright/data_harness/sources/cadence_emission.py +453 -0
  171. mostlyright/data_harness/sources/cadence_history.py +546 -0
  172. mostlyright/data_harness/sources/catalog/__init__.py +17 -0
  173. mostlyright/data_harness/sources/catalog/admission.py +477 -0
  174. mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
  175. mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
  176. mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
  177. mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
  178. mostlyright/data_harness/sources/catalog/channel.py +523 -0
  179. mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
  180. mostlyright/data_harness/sources/catalog/contracts.py +825 -0
  181. mostlyright/data_harness/sources/catalog/coverage.py +137 -0
  182. mostlyright/data_harness/sources/catalog/delta.py +1340 -0
  183. mostlyright/data_harness/sources/catalog/embedding.py +532 -0
  184. mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
  185. mostlyright/data_harness/sources/catalog/fill.py +3889 -0
  186. mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
  187. mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
  188. mostlyright/data_harness/sources/catalog/gating.py +374 -0
  189. mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
  190. mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
  191. mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
  192. mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
  193. mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
  194. mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
  195. mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
  196. mostlyright/data_harness/sources/catalog/health.py +447 -0
  197. mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
  198. mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
  199. mostlyright/data_harness/sources/catalog/neural.py +1618 -0
  200. mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
  201. mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
  202. mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
  203. mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
  204. mostlyright/data_harness/sources/catalog/recommend.py +171 -0
  205. mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
  206. mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
  207. mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
  208. mostlyright/data_harness/sources/catalog/sealed.py +560 -0
  209. mostlyright/data_harness/sources/catalog/search.py +230 -0
  210. mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
  211. mostlyright/data_harness/sources/catalog/update.py +891 -0
  212. mostlyright/data_harness/sources/collections.py +815 -0
  213. mostlyright/data_harness/sources/contracts.py +2223 -0
  214. mostlyright/data_harness/sources/deletion.py +761 -0
  215. mostlyright/data_harness/sources/fitness.py +162 -0
  216. mostlyright/data_harness/sources/governance.py +163 -0
  217. mostlyright/data_harness/sources/hosted.py +173 -0
  218. mostlyright/data_harness/sources/integration.py +218 -0
  219. mostlyright/data_harness/sources/range_reader.py +418 -0
  220. mostlyright/data_harness/sources/registry.py +514 -0
  221. mostlyright/data_harness/sources/rights_rule.py +59 -0
  222. mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
  223. mostlyright/data_harness/sources/sports.py +521 -0
  224. mostlyright/data_harness/sources/stream.py +524 -0
  225. mostlyright/data_harness/sources/stream_connector.py +418 -0
  226. mostlyright/data_harness/sources/stream_recorder.py +1404 -0
  227. mostlyright/data_harness/studio_boundary.py +2019 -0
  228. mostlyright/data_harness/thin/__init__.py +37 -0
  229. mostlyright/data_harness/thin/acquire.py +1137 -0
  230. mostlyright/data_harness/thin/acquire_cancel.py +579 -0
  231. mostlyright/data_harness/thin/approvals.py +617 -0
  232. mostlyright/data_harness/thin/commands.py +406 -0
  233. mostlyright/data_harness/thin/download.py +194 -0
  234. mostlyright/data_harness/thin/narrative.py +589 -0
  235. mostlyright/data_harness/thin/parity.py +1070 -0
  236. mostlyright/data_harness/thin/propose.py +2759 -0
  237. mostlyright/data_harness/thin/research.py +1663 -0
  238. mostlyright/data_harness/thin/router.py +924 -0
  239. mostlyright/data_harness/thin/runs.py +519 -0
  240. mostlyright/data_harness/thin/session.py +281 -0
  241. mostlyright/data_harness/thin/stream.py +501 -0
  242. mostlyright/data_harness/thin/transport.py +187 -0
  243. mostlyright/data_harness/thin/vocabulary.py +368 -0
  244. mostlyright/data_harness/thin/workers.py +164 -0
  245. mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
  246. mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
  247. mostlyright/data_harness/unit_flow.py +927 -0
  248. mostlyright/data_harness/units.py +572 -0
  249. mostlyright/data_harness/ux/__init__.py +9 -0
  250. mostlyright/data_harness/ux/approve.py +485 -0
  251. mostlyright/data_harness/ux/author_yaml.py +597 -0
  252. mostlyright/data_harness/ux/cloud_auth.py +447 -0
  253. mostlyright/data_harness/ux/commands/__init__.py +260 -0
  254. mostlyright/data_harness/ux/commands/approve.py +136 -0
  255. mostlyright/data_harness/ux/commands/auth.py +744 -0
  256. mostlyright/data_harness/ux/commands/author.py +79 -0
  257. mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
  258. mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
  259. mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
  260. mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
  261. mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
  262. mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
  263. mostlyright/data_harness/ux/commands/deploy.py +134 -0
  264. mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
  265. mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
  266. mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
  267. mostlyright/data_harness/ux/commands/diff.py +74 -0
  268. mostlyright/data_harness/ux/commands/index.py +84 -0
  269. mostlyright/data_harness/ux/commands/inventory.py +47 -0
  270. mostlyright/data_harness/ux/commands/list_builds.py +143 -0
  271. mostlyright/data_harness/ux/commands/login.py +63 -0
  272. mostlyright/data_harness/ux/commands/peek.py +236 -0
  273. mostlyright/data_harness/ux/commands/plan_check.py +90 -0
  274. mostlyright/data_harness/ux/commands/preflight.py +97 -0
  275. mostlyright/data_harness/ux/commands/record.py +107 -0
  276. mostlyright/data_harness/ux/commands/review_setup.py +47 -0
  277. mostlyright/data_harness/ux/commands/search.py +440 -0
  278. mostlyright/data_harness/ux/commands/show.py +61 -0
  279. mostlyright/data_harness/ux/commands/whoami.py +37 -0
  280. mostlyright/data_harness/ux/credential_native.py +551 -0
  281. mostlyright/data_harness/ux/credential_store.py +1055 -0
  282. mostlyright/data_harness/ux/credentials.py +631 -0
  283. mostlyright/data_harness/ux/diffing.py +444 -0
  284. mostlyright/data_harness/ux/headline.py +671 -0
  285. mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
  286. mostlyright/data_harness/ux/hosted_run_status.py +619 -0
  287. mostlyright/data_harness/ux/inventory.py +427 -0
  288. mostlyright/data_harness/ux/local_review.py +375 -0
  289. mostlyright/data_harness/ux/login.py +691 -0
  290. mostlyright/data_harness/ux/path_kind.py +147 -0
  291. mostlyright/data_harness/ux/peek.py +1000 -0
  292. mostlyright/data_harness/ux/plain_file.py +178 -0
  293. mostlyright/data_harness/ux/plan_check.py +311 -0
  294. mostlyright/data_harness/ux/preflight.py +918 -0
  295. mostlyright/data_harness/ux/readers.py +1124 -0
  296. mostlyright/data_harness/ux/remediation.py +2195 -0
  297. mostlyright/data_harness/ux/render.py +657 -0
  298. mostlyright/data_harness/ux/workload.py +1077 -0
  299. mostlyright/data_harness/viewer.py +3713 -0
  300. mostlyright/data_harness/visual_run/__init__.py +83 -0
  301. mostlyright/data_harness/visual_run/authoring.py +235 -0
  302. mostlyright/data_harness/visual_run/contracts.py +673 -0
  303. mostlyright/data_harness/visual_run/materialize.py +486 -0
  304. mostlyright/data_harness/visual_run/observations.py +874 -0
  305. mostlyright/data_harness/visual_run/query.py +259 -0
  306. mostlyright/data_harness/visual_run/reducer.py +280 -0
  307. mostlyright/data_harness/visual_run/sdk.py +892 -0
  308. mostlyright/data_harness/visual_run/store.py +584 -0
  309. mostlyright/data_harness/visual_run/transport.py +239 -0
  310. mostlyright/data_harness/watch.py +2999 -0
  311. mostlyright_data-0.9.0.dist-info/METADATA +607 -0
  312. mostlyright_data-0.9.0.dist-info/RECORD +314 -0
  313. mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
  314. mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
@@ -0,0 +1,927 @@
1
+ """Two deterministic unit gates, read off the plan without touching a row of data.
2
+
3
+ A declared unit used to be a claim nobody checked. The agent wrote the unit, the agent wrote the
4
+ arithmetic that converted it, and nothing compared the two — so a plan could stack Celsius on
5
+ Fahrenheit, or multiply centimetres by ten and call the result metres, and the Build would carry an
6
+ auditable statement that was simply wrong.
7
+
8
+ This module closes both, from the plan alone:
9
+
10
+ **Commensurability.** Where an operation merges one column from two places — a ``union`` stacking
11
+ same-named columns, a ``join`` bringing two tables together, an ``unpivot`` folding several columns
12
+ into one — the declared units on each side must be the same unit. Disagreeing in dimension is one
13
+ refusal; agreeing in dimension while disagreeing in unit, with no conversion written between them,
14
+ is another.
15
+
16
+ **Conversion verification.** Where a ``derive`` declares what unit its output is in, and its
17
+ expression is a scaling of exactly one column whose unit is also declared, the arithmetic must be
18
+ exactly the map the grammar derives between those two units — coefficient for coefficient. A
19
+ conversion that multiplies Celsius by 9/5 and forgets the 32 is refused, because the check compares
20
+ the pair rather than a factor.
21
+
22
+ Both gates are silent on a column with no declared unit. That is what keeps this from being
23
+ retroactive: a plan that declares nothing is exactly as valid as it was.
24
+
25
+ The whole analysis is static. It reads node parameters, never a source file, so it is a function of
26
+ the sealed plan and cannot change with the data.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Mapping
32
+ from dataclasses import dataclass
33
+ from fractions import Fraction
34
+ from typing import TYPE_CHECKING, Any, Final
35
+
36
+ from mostlyright.data_harness.operation_registry import thaw_parameter
37
+ from mostlyright.data_harness.units import (
38
+ COUNT_UNIT_CODE,
39
+ Unit,
40
+ UnitError,
41
+ commensurable,
42
+ conversion,
43
+ dimension_signature,
44
+ parse_unit,
45
+ resolve_declared_unit,
46
+ same_unit,
47
+ )
48
+
49
+ if TYPE_CHECKING: # pragma: no cover - imported for typing only, and the other way at run time.
50
+ from mostlyright.data_harness.local_contracts import GraphDatasetPlan, GraphNode
51
+
52
+ #: What an aggregate measure does to the unit of the column it reads. Everything that returns one
53
+ #: of the values it was given keeps that value's unit; a count returns how many there were, which
54
+ #: is a count of rows rather than a quantity of whatever was in them.
55
+ _MEASURE_KEEPS_UNIT = frozenset({"sum", "min", "max", "mean", "first", "last"})
56
+
57
+ #: Every operation the walk below models, and what it does to a claim. The set is closed and
58
+ #: compared against the live registry by ``tests/test_unit_gates.py``: an operation added to the
59
+ #: vocabulary has to be classified here before it ships, rather than falling through to
60
+ #: "carry everything unchanged" and quietly taking a claim somewhere it does not belong.
61
+ UNIT_PASS_THROUGH_OPERATIONS = frozenset({"cast", "sort", "deduplicate"})
62
+ UNIT_MODELLED_OPERATIONS = (
63
+ frozenset(
64
+ {
65
+ "source",
66
+ "union",
67
+ "join",
68
+ "rename",
69
+ "project",
70
+ "derive",
71
+ "aggregate",
72
+ "resample",
73
+ "unpivot",
74
+ "prediction_label",
75
+ "date_add_days",
76
+ "filter",
77
+ }
78
+ )
79
+ | UNIT_PASS_THROUGH_OPERATIONS
80
+ )
81
+
82
+
83
+ #: What each derive-expression operator does to a declared unit, classified so the closure test can
84
+ #: hold this set equal to the expression vocabulary. ``if`` and the arithmetic carry a unit through;
85
+ #: everything else reads a date part or rewrites text, and its result is not a measured quantity.
86
+ UNIT_ARITHMETIC_OPERATORS = frozenset({"add", "subtract", "multiply", "divide"})
87
+ UNIT_UNMEASURED_OPERATORS = frozenset(
88
+ {"concat", "trim", "case_fold", "year", "month", "day", "hour"}
89
+ )
90
+ UNIT_MODELLED_EXPRESSIONS = UNIT_ARITHMETIC_OPERATORS | UNIT_UNMEASURED_OPERATORS | {"if"}
91
+
92
+
93
+ class UnitFlowError(Exception):
94
+ """A typed unit-consistency refusal naming the exact plan path that carries it."""
95
+
96
+ def __init__(self, code: str, path: str, detail: str) -> None:
97
+ self.code = code
98
+ self.path = path
99
+ self.detail = detail
100
+ super().__init__(f"{path}: {detail} [{code}]")
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class _Affine:
105
+ """One expression reduced to ``column * scale + offset``, or to a bare constant.
106
+
107
+ ``column`` is ``None`` for an expression made only of literals. Anything the reduction cannot
108
+ express -- a quotient by a column, a conditional, a date part, text -- is not one of these at
109
+ all, and the caller gets ``None``.
110
+ """
111
+
112
+ column: str | None
113
+ scale: Fraction
114
+ offset: Fraction
115
+
116
+
117
+ def _affine(column: str | None, scale: Fraction, offset: Fraction) -> _Affine:
118
+ """Build a reduction, reading a term that scales to zero as the constant it is.
119
+
120
+ ``y - y`` and ``y * 0`` name a column and contribute nothing to the value, so carrying the name
121
+ would make ``x + (y - y)`` look like an expression over two columns and stop the reduction --
122
+ which is one token's worth of hiding for any conversion at all.
123
+ """
124
+
125
+ return _Affine(column=column if scale else None, scale=scale, offset=offset)
126
+
127
+
128
+ def _constant(value: Fraction) -> _Affine:
129
+ return _affine(None, Fraction(0), value)
130
+
131
+
132
+ def _reduce(expression: Any) -> _Affine | None:
133
+ """Reduce one derive expression to an affine map over at most one column.
134
+
135
+ Only the integer literals the expression grammar admits appear here, so every coefficient is an
136
+ exact rational and the comparison against a grammar-derived conversion is exact too. That is
137
+ not an accident of this function: the plan contract has no decimal literal, so a conversion by
138
+ 9/5 is written as a multiply and a divide, and both are exact.
139
+ """
140
+
141
+ if not isinstance(expression, Mapping):
142
+ return None
143
+ keys = set(expression)
144
+ if keys == {"column"}:
145
+ column = expression["column"]
146
+ if not isinstance(column, str):
147
+ return None
148
+ return _affine(column, Fraction(1), Fraction(0))
149
+ if keys == {"literal"}:
150
+ literal = expression["literal"]
151
+ if type(literal) is not int:
152
+ return None
153
+ return _constant(Fraction(literal))
154
+ operation = expression.get("op")
155
+ arguments = expression.get("args")
156
+ if not isinstance(arguments, list):
157
+ # Every conditional is resolved to one branch before this runs, so nothing reaching here
158
+ # has ``condition``/``then``/``else`` in place of ``args``.
159
+ return None
160
+ if operation in {"add", "subtract"} and len(arguments) == 2:
161
+ left = _reduce(arguments[0])
162
+ right = _reduce(arguments[1])
163
+ if left is None or right is None:
164
+ return None
165
+ sign = 1 if operation == "add" else -1
166
+ if left.column is not None and right.column is not None:
167
+ if left.column != right.column:
168
+ return None
169
+ column = left.column
170
+ else:
171
+ column = left.column or right.column
172
+ scale = left.scale + sign * right.scale
173
+ return _affine(column, scale, left.offset + sign * right.offset)
174
+ if operation == "multiply" and len(arguments) == 2:
175
+ left = _reduce(arguments[0])
176
+ right = _reduce(arguments[1])
177
+ if left is None or right is None:
178
+ return None
179
+ if left.column is not None and right.column is not None:
180
+ return None
181
+ varying, constant = (left, right) if left.column is not None else (right, left)
182
+ if (
183
+ constant.column is not None
184
+ ): # pragma: no cover - one side is constant by the test above.
185
+ return None
186
+ factor = constant.offset
187
+ return _affine(varying.column, varying.scale * factor, varying.offset * factor)
188
+ if operation == "divide" and len(arguments) == 2:
189
+ numerator = _reduce(arguments[0])
190
+ denominator = _reduce(arguments[1])
191
+ if numerator is None or denominator is None or denominator.column is not None:
192
+ return None
193
+ if denominator.offset == 0:
194
+ return None
195
+ return _affine(
196
+ numerator.column,
197
+ numerator.scale / denominator.offset,
198
+ numerator.offset / denominator.offset,
199
+ )
200
+ return None
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class _Measured:
205
+ """What a subtree's value is measured in, and whether the code is exactly that.
206
+
207
+ ``exact`` is false where an operand has been scaled: multiplying a length by two leaves a
208
+ length, so the dimension is still known, but the number is no longer in the unit the column
209
+ declared and no code names what it is in. Both halves are useful -- the dimension catches a
210
+ length added to a time however either side was scaled, and the code catches Celsius added to
211
+ Fahrenheit.
212
+ """
213
+
214
+ unit: Unit
215
+ exact: bool
216
+
217
+
218
+ class _Unplaced:
219
+ """A subtree the walk could not read: not a number, and not a unit it can name.
220
+
221
+ Keeping this apart from ``None`` is the whole of one round's finding. A bare number
222
+ contributes no unit and lets the other operand speak for the result; a subtree nobody could
223
+ place contributes an unknown one, and letting the other operand speak for the result there is
224
+ how ``declared + undeclared`` came to be admitted as exactly the declared unit.
225
+ """
226
+
227
+ __slots__ = ()
228
+
229
+
230
+ UNPLACED: Final = _Unplaced()
231
+
232
+
233
+ def _measured(
234
+ expression: Any, incoming: Mapping[str, Unit], path: str
235
+ ) -> _Measured | _Unplaced | None:
236
+ """Walk one expression and refuse an addition whose two sides are not measuring the same thing.
237
+
238
+ Adding metres to seconds, or Celsius to Fahrenheit, is wrong in the plan rather than in the
239
+ data, and it is wrong before anything is said about the result. Without this the reduction
240
+ above simply declines to model such an expression, the declared output unit is taken at its
241
+ word, and an invented claim walks downstream past the merge gate -- which is the failure this
242
+ module exists to stop, reached by arithmetic instead of by a stack.
243
+
244
+ A literal is not a unit. It is a bare number whose meaning comes from what it is added to, so
245
+ ``(x * 9 + 160) / 5`` is a conversion rather than a mismatch. What this walk cannot place it
246
+ leaves unplaced rather than guessing at.
247
+ """
248
+
249
+ if not isinstance(expression, Mapping):
250
+ return UNPLACED
251
+ keys = set(expression)
252
+ if keys == {"column"}:
253
+ column = expression["column"]
254
+ if not isinstance(column, str):
255
+ return UNPLACED
256
+ unit = incoming.get(column)
257
+ return _Measured(unit=unit, exact=True) if unit is not None else UNPLACED
258
+ if keys == {"literal"}:
259
+ # A bare number. It contributes no unit of its own and takes the one it is used with.
260
+ return None
261
+ operation = expression.get("op")
262
+ if operation == "if":
263
+ # Both branches are values of the same column, so they answer the same question, and a
264
+ # mismatch inside either is a mismatch. Walking only ``args`` would have left every
265
+ # conditional unexamined -- which is a hole the width of one wrapper.
266
+ _predicate_units(expression.get("condition"), incoming, path)
267
+ return _combine(
268
+ _measured(expression.get("then"), incoming, path),
269
+ _measured(expression.get("else"), incoming, path),
270
+ path=path,
271
+ verb="offers",
272
+ fix="convert one to the other before choosing between them",
273
+ )
274
+ arguments = expression.get("args")
275
+ if not isinstance(arguments, list):
276
+ return UNPLACED
277
+ # Every argument is walked for its own refusals before the operation is dispatched on, so a
278
+ # mismatch does not become invisible by sitting inside an operator of a different arity. It
279
+ # was invisible inside a one-argument ``concat`` until this loop existed.
280
+ operands = [_measured(argument, incoming, path) for argument in arguments]
281
+ if len(arguments) != 2:
282
+ # A concatenation, a text rewrite, a date part. Its arguments were walked for their own
283
+ # refusals; what it produces is not a quantity this walk can name.
284
+ return UNPLACED
285
+ left, right = operands
286
+ if operation in {"add", "subtract"}:
287
+ return _combine(
288
+ left,
289
+ right,
290
+ path=path,
291
+ verb="adds",
292
+ fix="convert one to the other before adding them",
293
+ additive=True,
294
+ )
295
+ if operation in {"multiply", "divide"}:
296
+ if isinstance(left, _Unplaced) or isinstance(right, _Unplaced):
297
+ return UNPLACED
298
+ if left is not None and right is not None:
299
+ # A product of two units is a new unit, and nothing here multiplies units together.
300
+ return UNPLACED
301
+ scaled = left if right is None else right
302
+ if scaled is None or (operation == "divide" and left is None):
303
+ return UNPLACED
304
+ scalar = _literal(arguments[1] if right is None else arguments[0])
305
+ if scalar is None:
306
+ # The other operand is a column with no declared unit, not a number. Metres per second
307
+ # is metres divided by a column, and it is neither metres nor a mistake -- claiming it
308
+ # kept the numerator's dimension would refuse the plainest rate there is.
309
+ return UNPLACED
310
+ # Scaling by a number leaves the dimension and takes the code: what a scaled length is in
311
+ # depends on the scale, and no code here names it. Scaling by one is the exception, and it
312
+ # is worth the line -- a no-op multiply is otherwise a one-token way to hide an operand.
313
+ return _Measured(unit=scaled.unit, exact=scaled.exact and scalar == 1)
314
+ return UNPLACED
315
+
316
+
317
+ #: The comparisons a predicate can make between two values. Everything else a predicate does is
318
+ #: about presence or membership, where one side is not a measured quantity at all.
319
+ _COMPARISONS = frozenset({"eq", "ne", "lt", "le", "gt", "ge"})
320
+
321
+
322
+ def _predicate_units(predicate: Any, incoming: Mapping[str, Unit], path: str) -> None:
323
+ """Refuse a comparison between two columns declared in different units.
324
+
325
+ Asking whether a length in metres exceeds one in feet is the same mistake as stacking them,
326
+ and it is one the plan can see. A predicate is not an expression -- it yields a truth rather
327
+ than a quantity -- so nothing is returned; the walk is here for the refusal alone.
328
+ """
329
+
330
+ if not isinstance(predicate, Mapping):
331
+ return
332
+ operation = predicate.get("op")
333
+ if operation in {"and", "or"}:
334
+ for argument in predicate.get("args") or []:
335
+ _predicate_units(argument, incoming, path)
336
+ return
337
+ if operation == "not":
338
+ _predicate_units(predicate.get("arg"), incoming, path)
339
+ return
340
+ if operation not in _COMPARISONS:
341
+ return
342
+ _combine(
343
+ _measured(predicate.get("left"), incoming, path),
344
+ _measured(predicate.get("right"), incoming, path),
345
+ path=path,
346
+ verb="compares",
347
+ fix="convert one to the other before comparing them",
348
+ )
349
+
350
+
351
+ def _literal(expression: Any) -> int | None:
352
+ """The whole number an operand names, where it names one and nothing else."""
353
+
354
+ if not isinstance(expression, Mapping) or set(expression) != {"literal"}:
355
+ return None
356
+ value = expression["literal"]
357
+ return value if type(value) is int else None
358
+
359
+
360
+ def _combine(
361
+ left: _Measured | _Unplaced | None,
362
+ right: _Measured | _Unplaced | None,
363
+ *,
364
+ path: str,
365
+ verb: str,
366
+ fix: str,
367
+ additive: bool = False,
368
+ ) -> _Measured | _Unplaced | None:
369
+ """Hold two operands of one arithmetic to measuring the same thing, or refuse the pair."""
370
+
371
+ if isinstance(left, _Unplaced) or isinstance(right, _Unplaced):
372
+ # One side could not be read, so nothing is settled about the pair. Returning the side that
373
+ # could would say the result is in that unit, which is the claim nobody established.
374
+ return UNPLACED
375
+ if left is None or right is None:
376
+ settled = left if right is None else right
377
+ if additive and isinstance(settled, _Measured) and settled.unit.affine:
378
+ # Twenty degrees Celsius plus five is not twenty-five degrees Celsius: the number
379
+ # moved along an offset scale, and no code names where it landed.
380
+ return UNPLACED
381
+ return settled
382
+ if not commensurable(left.unit, right.unit):
383
+ raise UnitFlowError(
384
+ "UNIT_DIMENSION_MISMATCH",
385
+ path,
386
+ f"this expression {verb} a value declared {_describe(left.unit)} and one declared "
387
+ f"{_describe(right.unit)}; the two do not measure the same kind of thing",
388
+ )
389
+ if left.exact and right.exact and not same_unit(left.unit, right.unit):
390
+ raise UnitFlowError(
391
+ "UNIT_CONVERSION_MISSING",
392
+ path,
393
+ f"this expression {verb} a value declared {left.unit.code!r} and one declared "
394
+ f"{right.unit.code!r}; {fix}",
395
+ )
396
+ if additive and (left.unit.affine or right.unit.affine):
397
+ # Twenty degrees Celsius plus twenty is not forty degrees Celsius. An offset scale does not
398
+ # add, so the pair settles nothing -- but only after the two have been held to being the
399
+ # same unit, which is a mismatch worth naming whether or not the sum means anything.
400
+ return UNPLACED
401
+ return _Measured(unit=left.unit, exact=left.exact and right.exact)
402
+
403
+
404
+ def _parameters(node: GraphNode) -> dict[str, Any]:
405
+ return dict(node.parameters)
406
+
407
+
408
+ def _declared(code: Any) -> Unit | None:
409
+ if not isinstance(code, str):
410
+ return None
411
+ try:
412
+ return resolve_declared_unit(code)
413
+ except UnitError: # pragma: no cover - the registry resolved this code before it got here.
414
+ return None
415
+
416
+
417
+ def _describe(unit: Unit) -> str:
418
+ return f"{unit.code!r} ({dimension_signature(unit)})"
419
+
420
+
421
+ def _merge(
422
+ left: Mapping[str, Unit],
423
+ right: Mapping[str, Unit],
424
+ *,
425
+ path: str,
426
+ left_label: str,
427
+ right_label: str,
428
+ ) -> dict[str, Unit]:
429
+ """Combine two column-to-unit environments, refusing a column the two disagree about.
430
+
431
+ A column declared on one side only is carried across unchanged. That is the deliberate part:
432
+ the gate says nothing about a claim nobody made.
433
+ """
434
+
435
+ merged = dict(left)
436
+ for column, unit in right.items():
437
+ held = left.get(column)
438
+ if held is None:
439
+ merged[column] = unit
440
+ continue
441
+ if not commensurable(held, unit):
442
+ raise UnitFlowError(
443
+ "UNIT_DIMENSION_MISMATCH",
444
+ path,
445
+ f"{left_label} declares {column!r} as {_describe(held)} and {right_label} declares "
446
+ f"it as {_describe(unit)}; the two do not measure the same kind of thing",
447
+ )
448
+ if not same_unit(held, unit):
449
+ raise UnitFlowError(
450
+ "UNIT_CONVERSION_MISSING",
451
+ path,
452
+ f"{left_label} declares {column!r} as {held.code!r} and {right_label} declares it "
453
+ f"as {unit.code!r}; convert one to the other before merging them",
454
+ )
455
+ return merged
456
+
457
+
458
+ def _source_units(node: GraphNode) -> dict[str, Unit]:
459
+ declared = thaw_parameter(_parameters(node).get("column_units"))
460
+ if not isinstance(declared, Mapping):
461
+ return {}
462
+ units: dict[str, Unit] = {}
463
+ for column, code in declared.items():
464
+ unit = _declared(code)
465
+ if unit is not None:
466
+ units[column] = unit
467
+ return units
468
+
469
+
470
+ def _derive_units(node: GraphNode, incoming: Mapping[str, Unit], *, index: int) -> dict[str, Unit]:
471
+ """Carry the input units through, add every declared output unit, and verify the arithmetic."""
472
+
473
+ units = dict(incoming)
474
+ expressions = thaw_parameter(_parameters(node).get("expressions"))
475
+ if not isinstance(expressions, Mapping): # pragma: no cover - the registry shaped this already.
476
+ return units
477
+ for output, specification in expressions.items():
478
+ if not isinstance(specification, Mapping): # pragma: no cover - same.
479
+ continue
480
+ path = f"plan.nodes[{index}].parameters.expressions.{output}.expression"
481
+ measured = _measured(specification.get("expression"), incoming, path)
482
+ declared_code = specification.get("output_unit")
483
+ if declared_code is None:
484
+ # No claim about this column, so it carries none from here on. A later merge cannot be
485
+ # fooled by it, because a column with no declared unit is invisible to that gate.
486
+ units.pop(output, None)
487
+ continue
488
+ target = _declared(declared_code)
489
+ if target is None: # pragma: no cover - the registry resolved this code before it got here.
490
+ continue
491
+ node_path = f"plan.nodes[{index}].parameters.expressions.{output}"
492
+ _admit_declared_output(
493
+ specification.get("expression"),
494
+ incoming,
495
+ measured,
496
+ target,
497
+ node_path,
498
+ declared_code=declared_code,
499
+ )
500
+ units[output] = target
501
+ return units
502
+
503
+
504
+ #: How many arithmetics one expression may branch into before the conversion in it stops being
505
+ #: checkable. A conversion guarded against an absent value branches into two, and a sum of eight
506
+ #: guarded readings into 256; nothing anybody writes reaches past that, and an expression that does
507
+ #: is refused rather than passed over, because passing it over would make nesting the way to switch
508
+ #: the check off.
509
+ MAX_BRANCH_VARIANTS: Final = 1024
510
+
511
+ #: Where a value lives inside an expression. A conditional's ``condition`` is deliberately not
512
+ #: among them: a predicate decides which value is taken, never what that value is measured in, so a
513
+ #: column read only there says nothing about the result's unit and must not be required to declare
514
+ #: one. Everything else the grammar admits is a value.
515
+ _VALUE_POSITIONS = ("then", "else")
516
+
517
+
518
+ def _value_columns(expression: Any) -> set[str]:
519
+ """Every column whose value the expression's result is computed from, at any depth."""
520
+
521
+ if not isinstance(expression, Mapping):
522
+ return set()
523
+ if set(expression) == {"column"}:
524
+ return {expression["column"]} if isinstance(expression["column"], str) else set()
525
+ found: set[str] = set()
526
+ for key in _VALUE_POSITIONS:
527
+ found |= _value_columns(expression.get(key))
528
+ for argument in expression.get("args") or []:
529
+ found |= _value_columns(argument)
530
+ return found
531
+
532
+
533
+ def _branch_variants(expression: Any) -> list[Any] | None:
534
+ """Every arithmetic one expression can take, with each conditional resolved to one branch.
535
+
536
+ A conditional hides a conversion wherever it sits, not only at the root: ``(if x else 0) / 10``
537
+ is how anyone guards a conversion against a missing reading, and reducing it as one expression
538
+ answers "not a scaling of one column" and checks nothing. Expanding it into the two arithmetics
539
+ it can actually perform puts each of them back in front of the check.
540
+
541
+ ``None`` means the expansion ran past its bound.
542
+ """
543
+
544
+ if not isinstance(expression, Mapping):
545
+ return [expression]
546
+ if expression.get("op") == "if":
547
+ variants: list[Any] = []
548
+ for branch in ("then", "else"):
549
+ expanded = _branch_variants(expression.get(branch))
550
+ if expanded is None:
551
+ return None
552
+ variants.extend(expanded)
553
+ return variants if len(variants) <= MAX_BRANCH_VARIANTS else None
554
+ arguments = expression.get("args")
555
+ if not isinstance(arguments, list):
556
+ return [expression]
557
+ combined: list[list[Any]] = [[]]
558
+ for argument in arguments:
559
+ expanded = _branch_variants(argument)
560
+ if expanded is None:
561
+ return None
562
+ combined = [[*carried, one] for carried in combined for one in expanded]
563
+ if len(combined) > MAX_BRANCH_VARIANTS:
564
+ return None
565
+ return [{**expression, "args": chosen} for chosen in combined]
566
+
567
+
568
+ def _admit_declared_output(
569
+ expression: Any,
570
+ incoming: Mapping[str, Unit],
571
+ measured: _Measured | _Unplaced | None,
572
+ target: Unit,
573
+ path: str,
574
+ *,
575
+ declared_code: str,
576
+ ) -> None:
577
+ """Admit a declared output unit only where something establishes it, and refuse it otherwise.
578
+
579
+ Six rounds of review found six ways to make this check look away, and every one of them was
580
+ the same move: write the conversion so the reduction cannot read it, and the declaration went
581
+ unexamined. Asking "can I see anything wrong with this claim" will always have another answer
582
+ of that shape. So the question is the other way round -- what establishes this claim -- and
583
+ there are exactly three things that can.
584
+
585
+ The expression converts a declared column, and the arithmetic is exactly the conversion the
586
+ two units imply. That is the case this gate was written for.
587
+
588
+ Or the expression's own operands settle what it is in, unscaled and unconverted, and that is
589
+ the unit claimed: kilometres plus kilometres is kilometres.
590
+
591
+ Or nothing it reads carries a declared unit, so the claim is a first statement about the
592
+ numbers rather than a conclusion from other statements, and stands as a `source` declaration
593
+ stands.
594
+
595
+ Anything else is refused. That refuses some correct plans -- a density from a mass and a
596
+ volume, an area from a side -- and ``docs/UNITS.md`` says so: those columns carry no declared
597
+ unit until the arithmetic of units over the whole expression grammar is written, which is a
598
+ larger thing than this. Refusing a true claim costs a declaration. Admitting a false one costs
599
+ the point of the gate.
600
+ """
601
+
602
+ if isinstance(measured, _Measured) and measured.exact:
603
+ # The operands settle it: every one was read unscaled and unconverted, so the result is in
604
+ # the unit they are in and in no other.
605
+ if same_unit(measured.unit, target):
606
+ return
607
+ if not commensurable(measured.unit, target):
608
+ raise UnitFlowError(
609
+ "UNIT_DIMENSION_MISMATCH",
610
+ f"{path}.output_unit",
611
+ f"this expression is in {_describe(measured.unit)} and claims to be in "
612
+ f"{_describe(target)}; the two do not measure the same kind of thing",
613
+ )
614
+ raise UnitFlowError(
615
+ "UNIT_CONVERSION_FACTOR",
616
+ f"{path}.output_unit",
617
+ f"this expression reads its operands unconverted and so is in "
618
+ f"{measured.unit.code!r}, and claims to be in {declared_code!r}; convert them, or "
619
+ "declare the unit the expression is actually in",
620
+ )
621
+ if not any(incoming.get(column) is not None for column in _value_columns(expression)):
622
+ return
623
+ _require_verifiable_conversion(
624
+ expression, incoming, target, path, declared_code=declared_code, measured=measured
625
+ )
626
+
627
+
628
+ def _require_verifiable_conversion(
629
+ expression: Any,
630
+ incoming: Mapping[str, Unit],
631
+ target: Unit,
632
+ path: str,
633
+ *,
634
+ declared_code: str,
635
+ measured: _Measured | _Unplaced | None,
636
+ ) -> None:
637
+ """Every arithmetic the expression can perform is the conversion, or converts nothing at all."""
638
+
639
+ variants = _branch_variants(expression)
640
+ if variants is None:
641
+ raise UnitFlowError(
642
+ "UNIT_CONVERSION_UNVERIFIABLE",
643
+ f"{path}.expression",
644
+ f"this expression branches into more than {MAX_BRANCH_VARIANTS} arithmetics, which is "
645
+ "past what the declared conversion can be checked against; declare no output unit, or "
646
+ "write the conversion in one node and the branching in another",
647
+ )
648
+ converts = False
649
+ for variant in variants:
650
+ reduced = _reduce(variant)
651
+ if reduced is not None and reduced.column is None:
652
+ # A branch that reduces to a constant converts nothing: a default of zero beside a
653
+ # conversion, or the absent value a guard returns.
654
+ continue
655
+ if reduced is None or incoming.get(reduced.column) is None:
656
+ if not any(incoming.get(column) is not None for column in _value_columns(variant)):
657
+ # Reads nothing anybody declared, so it contradicts nothing.
658
+ continue
659
+ raise UnitFlowError(
660
+ "UNIT_CONVERSION_UNVERIFIABLE",
661
+ f"{path}.expression",
662
+ _unverifiable_detail(measured, declared_code),
663
+ )
664
+ _compare_conversion(reduced, incoming[reduced.column], target, path, declared_code)
665
+ converts = True
666
+ if not converts:
667
+ raise UnitFlowError(
668
+ "UNIT_CONVERSION_UNVERIFIABLE",
669
+ f"{path}.expression",
670
+ _unverifiable_detail(measured, declared_code),
671
+ )
672
+
673
+
674
+ def _unverifiable_detail(measured: _Measured | _Unplaced | None, declared_code: str) -> str:
675
+ if isinstance(measured, _Measured):
676
+ return (
677
+ f"this expression reads {measured.unit.code!r} operands scaled, so it is not in that "
678
+ f"unit either, and nothing establishes that it is in {declared_code!r}; declare no "
679
+ "output unit, or write the conversion as whole-number multiplies, divides and adds "
680
+ "over one declared column"
681
+ )
682
+ return (
683
+ f"nothing establishes that this expression is in {declared_code!r}: it reads a column "
684
+ "whose unit is declared and is not arithmetic that unit can be carried through; "
685
+ "declare no output unit, or write the conversion as whole-number multiplies, divides "
686
+ "and adds over one declared column"
687
+ )
688
+
689
+
690
+ def _compare_conversion(
691
+ reduced: _Affine, source: Unit, target: Unit, path: str, declared_code: str
692
+ ) -> None:
693
+ """Hold one reduced arithmetic to the exact map the grammar derives between the two units.
694
+
695
+ Where the claim is the column's own unit there is no conversion to hold it to: scaling a
696
+ distance leaves it a distance, and the reduction having read the arithmetic is what says so.
697
+ An expression the reduction could *not* read is a different matter and never reaches here.
698
+ """
699
+
700
+ if same_unit(source, target):
701
+ if source.affine and (reduced.scale, reduced.offset) != (Fraction(1), Fraction(0)):
702
+ raise UnitFlowError(
703
+ "UNIT_CONVERSION_FACTOR",
704
+ f"{path}.expression",
705
+ f"{reduced.column!r} is declared {source.code!r}, which is an offset scale: "
706
+ "doubling it or adding to it does not leave a value in that unit, so the result "
707
+ "cannot be declared to be in it",
708
+ )
709
+ return
710
+ if not commensurable(source, target):
711
+ raise UnitFlowError(
712
+ "UNIT_DIMENSION_MISMATCH",
713
+ f"{path}.output_unit",
714
+ f"the expression reads {reduced.column!r}, declared {_describe(source)}, and claims "
715
+ f"the result is {_describe(target)}; the two do not measure the same kind of thing",
716
+ )
717
+ expected_scale, expected_offset = conversion(source, target)
718
+ if (reduced.scale, reduced.offset) != (expected_scale, expected_offset):
719
+ raise UnitFlowError(
720
+ "UNIT_CONVERSION_FACTOR",
721
+ f"{path}.expression",
722
+ f"converting {reduced.column!r} from {source.code!r} to {declared_code!r} is "
723
+ f"multiply by {expected_scale} then add {expected_offset}; this expression "
724
+ f"multiplies by {reduced.scale} and adds {reduced.offset}",
725
+ )
726
+
727
+
728
+ def _aggregate_units(
729
+ node: GraphNode, incoming: Mapping[str, Unit], *, group_by_key: str, measures_key: str
730
+ ) -> dict[str, Unit]:
731
+ parameters = _parameters(node)
732
+ units: dict[str, Unit] = {}
733
+ for column in thaw_parameter(parameters.get(group_by_key)) or []:
734
+ if isinstance(column, str) and column in incoming:
735
+ units[column] = incoming[column]
736
+ for measure in thaw_parameter(parameters.get(measures_key)) or []:
737
+ if not isinstance(measure, Mapping): # pragma: no cover - the registry shaped this already.
738
+ continue
739
+ output = measure.get("as")
740
+ operation = measure.get("op")
741
+ if not isinstance(output, str): # pragma: no cover - same.
742
+ continue
743
+ if operation == "count":
744
+ units[output] = parse_unit(COUNT_UNIT_CODE)
745
+ continue
746
+ source = incoming.get(measure.get("column"))
747
+ if operation not in _MEASURE_KEEPS_UNIT or source is None:
748
+ continue
749
+ if operation == "sum" and source.affine:
750
+ # A total of Celsius readings is not a temperature. Every other measure here returns
751
+ # one of the values it was given, which an offset scale survives; adding them up is
752
+ # the one that does not.
753
+ continue
754
+ units[output] = source
755
+ return units
756
+
757
+
758
+ def _unpivot_units(node: GraphNode, incoming: Mapping[str, Unit], *, index: int) -> dict[str, Unit]:
759
+ """Fold the named value columns into one, refusing a fold the declared units disagree about."""
760
+
761
+ parameters = _parameters(node)
762
+ value_columns = [
763
+ column
764
+ for column in (thaw_parameter(parameters.get("value_columns")) or [])
765
+ if isinstance(column, str)
766
+ ]
767
+ name_column = parameters.get("name_column")
768
+ value_column = parameters.get("value_column")
769
+ units = {column: unit for column, unit in incoming.items() if column not in set(value_columns)}
770
+ units.pop(name_column, None)
771
+
772
+ # Every declared value column is compared, whatever order the plan lists them in. Deciding on
773
+ # the first undeclared one instead would have made the refusal a function of the order rather
774
+ # than of the units, and moving one undeclared name to the front would have got a disagreement
775
+ # past the gate.
776
+ declared: dict[str, Unit] = {}
777
+ first: str | None = None
778
+ path = f"plan.nodes[{index}].parameters.value_columns"
779
+ for column in value_columns:
780
+ unit = incoming.get(column)
781
+ if unit is None:
782
+ continue
783
+ declared = _merge(
784
+ declared,
785
+ {str(value_column): unit},
786
+ path=path,
787
+ # The column that was compared, not the column the fold writes to: naming the output
788
+ # on one side of the sentence would leave the reader looking for an input that is not
789
+ # there, and with sixteen value columns unable to tell which one disagreed.
790
+ left_label=f"value column {first!r}",
791
+ right_label=f"value column {column!r}",
792
+ )
793
+ first = first or column
794
+ if declared and all(column in incoming for column in value_columns):
795
+ # Only where every folded column declared something. One undeclared among them means the
796
+ # result is part quantity and part unknown, and saying it was all one unit would be the
797
+ # invention this gate exists to stop.
798
+ units.update(declared)
799
+ return units
800
+
801
+
802
+ def unit_environments(plan: GraphDatasetPlan) -> dict[str, dict[str, Unit]]:
803
+ """Walk the graph once and return the declared unit of every column each node emits.
804
+
805
+ Nodes are already ordered so that every input is defined before it is used, which is what makes
806
+ one forward pass enough. The environments are partial by design: a column appears only when
807
+ something declared its unit, so the result says what is claimed and never what is guessed.
808
+ """
809
+
810
+ environments: dict[str, dict[str, Unit]] = {}
811
+ for index, node in enumerate(plan.nodes):
812
+ incoming = [environments.get(input_id, {}) for input_id in node.inputs]
813
+ path = f"plan.nodes[{index}].parameters"
814
+ parameters = _parameters(node)
815
+ operation = node.operation
816
+ if operation == "source":
817
+ environments[node.node_id] = _source_units(node)
818
+ elif operation == "union":
819
+ merged: dict[str, Unit] = {}
820
+ for position, environment in enumerate(incoming):
821
+ merged = _merge(
822
+ merged,
823
+ environment,
824
+ path=path,
825
+ left_label="an earlier input",
826
+ right_label=f"input {node.inputs[position]!r}",
827
+ )
828
+ environments[node.node_id] = merged
829
+ elif operation == "join":
830
+ # An anti join emits left columns only and discards every right column, key or not. So
831
+ # the comparison there is the keys it matches on -- metres against feet is the same
832
+ # mistake whether or not the row is emitted -- and nothing else: refusing over a column
833
+ # the node provably throws away would refuse a plan that is correct.
834
+ anti = parameters.get("kind") == "anti"
835
+ keys = {
836
+ column
837
+ for column in (thaw_parameter(parameters.get("on")) or [])
838
+ if isinstance(column, str)
839
+ }
840
+ right = (
841
+ {column: unit for column, unit in incoming[1].items() if column in keys}
842
+ if anti
843
+ else incoming[1]
844
+ )
845
+ merged_sides = _merge(
846
+ incoming[0],
847
+ right,
848
+ path=path,
849
+ left_label=f"the left side {node.inputs[0]!r}",
850
+ right_label=f"the right side {node.inputs[1]!r}",
851
+ )
852
+ environments[node.node_id] = dict(incoming[0]) if anti else merged_sides
853
+ elif operation == "rename":
854
+ mappings = thaw_parameter(parameters.get("mappings")) or {}
855
+ carried = dict(incoming[0])
856
+ renamed = {
857
+ new: carried.pop(old)
858
+ for old, new in mappings.items()
859
+ if isinstance(new, str) and old in carried
860
+ }
861
+ for old in mappings:
862
+ carried.pop(old, None)
863
+ carried.update(renamed)
864
+ environments[node.node_id] = carried
865
+ elif operation == "project":
866
+ selected = {
867
+ column
868
+ for column in (thaw_parameter(parameters.get("columns")) or [])
869
+ if isinstance(column, str)
870
+ }
871
+ environments[node.node_id] = {
872
+ column: unit for column, unit in incoming[0].items() if column in selected
873
+ }
874
+ elif operation == "derive":
875
+ environments[node.node_id] = _derive_units(node, incoming[0], index=index)
876
+ elif operation == "aggregate":
877
+ environments[node.node_id] = _aggregate_units(
878
+ node, incoming[0], group_by_key="group_by", measures_key="measures"
879
+ )
880
+ elif operation == "resample":
881
+ environments[node.node_id] = _aggregate_units(
882
+ node, incoming[0], group_by_key="group_by", measures_key="aggregates"
883
+ )
884
+ elif operation == "unpivot":
885
+ environments[node.node_id] = _unpivot_units(node, incoming[0], index=index)
886
+ elif operation == "date_add_days":
887
+ # A calendar label is not a quantity. Existing quantity claims pass through, while
888
+ # the newly emitted date intentionally carries no unit declaration.
889
+ environments[node.node_id] = dict(incoming[0])
890
+ elif operation == "filter":
891
+ _predicate_units(
892
+ thaw_parameter(parameters.get("predicate")),
893
+ incoming[0],
894
+ f"{path}.predicate",
895
+ )
896
+ environments[node.node_id] = dict(incoming[0])
897
+ elif operation in UNIT_PASS_THROUGH_OPERATIONS or operation == "prediction_label":
898
+ # These change which rows or which types are present, never what the numbers are in, so
899
+ # the claim rides through. A retired operation appends a column, which carries no claim
900
+ # because nothing declared one for it.
901
+ environments[node.node_id] = dict(incoming[0]) if incoming else {}
902
+ else: # pragma: no cover - the closure test below makes this unreachable.
903
+ raise UnitFlowError(
904
+ "UNIT_OPERATION_UNMODELLED",
905
+ f"plan.nodes[{index}].operation",
906
+ f"{operation!r} is in the operation vocabulary and this analysis does not say "
907
+ "what it does to a declared unit",
908
+ )
909
+ return environments
910
+
911
+
912
+ def refuse_inconsistent_units(plan: GraphDatasetPlan) -> None:
913
+ """Run both gates over one plan, or raise the first refusal either of them reaches."""
914
+
915
+ unit_environments(plan)
916
+
917
+
918
+ __all__ = [
919
+ "UNIT_ARITHMETIC_OPERATORS",
920
+ "UNIT_MODELLED_EXPRESSIONS",
921
+ "UNIT_MODELLED_OPERATIONS",
922
+ "UNIT_PASS_THROUGH_OPERATIONS",
923
+ "UNIT_UNMEASURED_OPERATORS",
924
+ "UnitFlowError",
925
+ "refuse_inconsistent_units",
926
+ "unit_environments",
927
+ ]