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.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|