mostlyright-data 0.30.2__tar.gz → 0.30.4__tar.gz

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 (132) hide show
  1. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/PKG-INFO +1 -1
  2. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/SKILL.md +4 -0
  3. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/sources.md +27 -0
  4. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/page_coverage.py +62 -2
  5. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/recipe_lint.py +269 -0
  6. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/table_search_contract.json +1 -1
  7. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4.py +2 -2
  8. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_runs.py +108 -0
  9. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/.gitignore +0 -0
  10. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/README.md +0 -0
  11. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/pyproject.toml +0 -0
  12. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/scripts/hatch_build.py +0 -0
  13. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/agents/openai.yaml +0 -0
  14. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/1-open-the-page-and-the-link-to-it-in-the-first-message.md +0 -0
  15. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
  16. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
  17. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/4-decide-say-what-you-chose-what-you-refused-and-ask-one-question.md +0 -0
  18. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
  19. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
  20. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
  21. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
  22. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
  23. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/agent-protocol.md +0 -0
  24. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
  25. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
  26. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/boundaries.md +0 -0
  27. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
  28. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/commands.md +0 -0
  29. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
  30. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/installation-parity.md +0 -0
  31. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/live-run.md +0 -0
  32. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
  33. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/narrating-the-run.md +0 -0
  34. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
  35. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/one-install.md +0 -0
  36. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/patterns.md +0 -0
  37. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/prediction-labels.md +0 -0
  38. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/preparing-table-search.md +0 -0
  39. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/promote.md +0 -0
  40. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/readers.md +0 -0
  41. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/receipts.md +0 -0
  42. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/recipe-test.md +0 -0
  43. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
  44. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
  45. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/reference-pages.md +0 -0
  46. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/required-protocol.md +0 -0
  47. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/source-credentials.md +0 -0
  48. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
  49. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/transforms.md +0 -0
  50. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/user-communication-contract.md +0 -0
  51. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
  52. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  53. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/__init__.py +0 -0
  54. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/agent_protocol.py +0 -0
  55. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/canonical.py +0 -0
  56. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/formats.py +0 -0
  57. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  58. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/key_seam.py +0 -0
  59. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  60. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/session_probes.py +0 -0
  61. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/skill_assets.py +0 -0
  62. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/table_manifest.py +0 -0
  63. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  64. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  65. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  66. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/activity.py +0 -0
  67. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  68. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/backoff.py +0 -0
  69. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/categories.py +0 -0
  70. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/commands.py +0 -0
  71. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  72. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/download.py +0 -0
  73. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/fast_features.py +0 -0
  74. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  75. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/parity.py +0 -0
  76. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/probe.py +0 -0
  77. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/progress_rate.py +0 -0
  78. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  79. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/propose.py +0 -0
  80. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/recipe.py +0 -0
  81. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
  82. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/recipe_explain.py +0 -0
  83. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
  84. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/recipe_test.py +0 -0
  85. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/refresh_rehearsal.py +0 -0
  86. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/release_check.py +0 -0
  87. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/research.py +0 -0
  88. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/router.py +0 -0
  89. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
  90. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/run_prefix.py +0 -0
  91. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/runs.py +0 -0
  92. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/session.py +0 -0
  93. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/stream.py +0 -0
  94. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  95. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
  96. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/support.py +0 -0
  97. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/transport.py +0 -0
  98. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  99. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  100. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  101. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
  102. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  103. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  104. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
  105. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  106. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  107. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
  108. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  109. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_reader.py +0 -0
  110. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_search.py +0 -0
  111. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_search_contract.py +0 -0
  112. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  113. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
  114. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  115. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  116. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  117. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  118. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  119. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  120. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  121. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  122. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  123. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  124. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  125. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  126. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  127. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  128. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/login.py +0 -0
  129. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  130. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  131. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  132. {mostlyright_data-0.30.2 → mostlyright_data-0.30.4}/src/mostlyright/data_harness/ux/render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mostlyright-data
3
- Version: 0.30.2
3
+ Version: 0.30.4
4
4
  Summary: Mostly Right hosted CLI for reviewed datasets
5
5
  Project-URL: Homepage, https://mostlyright.md/
6
6
  Project-URL: Documentation, https://mostlyright.md/docs/guides/cli/
@@ -44,6 +44,7 @@ session finds it. Then choose the source shape that fits.
44
44
  | One request per row of another source | a per-row `request`, with `batch` when a `GET` takes a list of ids | the gap | [per-row](references/patterns.md#one-request-per-row-many-ids-per-request) |
45
45
  | Bulk dump beside a windowed source | `reference` | `recheck_seconds` | [reference](references/patterns.md#bulk-dump-beside-a-windowed-source) |
46
46
  | Live `wss://` feed | a recorded stream | see [Streams](references/recording-a-stream-venue.md) | none |
47
+ | A join of tables this workspace already built, rebuilt when one promotes | `table.version@1.0.0` sources with `refresh: upstream_promoted` and `rebuild` (Studio 4.38) | one shared `data_classification`, `debounce_seconds` | [table sources](references/sources.md#reading-another-table-of-this-workspace) |
47
48
 
48
49
  [Patterns](references/patterns.md) has one complete recipe per row, with the pace set.
49
50
  [Sources](references/sources.md#pace-and-width) has the pace rules and the limits of parallel
@@ -152,6 +153,9 @@ step 6. The stage 3 reference holds its rule. Gate 4 is step 6.
152
153
  quality checks.
153
154
  7. Follow the returned run with `mr-data watch RUN_ID --json`. Its `source_rates` and
154
155
  `mr-data status RUN_ID --json` on a live run give each collection source's rate and time left.
156
+ On a queued or running run, `status` also gives `progress`: the current attempt, how many of
157
+ its sources it fetched of how many, the source it is on with its pages, and how the attempt
158
+ before it ended. A handed-back run's new attempt counts from zero.
155
159
  Quote those numbers rather than doing the arithmetic. Read `run.status`: returning an existing
156
160
  run does not mean new work was queued. Resume the known run after an interruption, using the
157
161
  saved cursor where available. Read [run lifecycle](references/live-run.md) for holds and
@@ -383,3 +383,30 @@ with `epoch_millis_value` on the raw millisecond time column, and size the reque
383
383
  for all slices and pages. Do not substitute a whole-day query:
384
384
  1,440 one-minute bars exceed Binance's 1,000-row response cap. Existing credential-free
385
385
  `public.https` file fetches and `public.https.collection@2.0.0` keep their own contracts.
386
+
387
+ ### Reading another table of this workspace
388
+
389
+ Needs Studio 4.38. When the user wants a table that joins or reshapes tables this workspace has
390
+ already built, do not fetch the upstream data again. Read each upstream as a source:
391
+
392
+ - `source_class: mostlyright_table`, and a `table` locator with `{table_id, version:
393
+ latest_promoted}`.
394
+ - Adapter `table.version@1.0.0`, with `credential_mode: none` and the Reader pin
395
+ `parquet.tabular` `1.0.0`, decode options `{}`.
396
+ - `limits.max_source_bytes` above the upstream's real bytes. Take them from `mr-data table show`;
397
+ Studio refuses a run whose pinned upstream version is larger.
398
+
399
+ To rebuild the join whenever an upstream promotes, declare `rebuild` and add `refresh: {trigger:
400
+ upstream_promoted, debounce_seconds: N}`. Pick N so upstreams that promote together start one
401
+ rebuild; 900 is a sound default.
402
+
403
+ Three rules cost a round trip when they are missed:
404
+
405
+ - Every source states the same `data_classification`. Use the strictest upstream's class.
406
+ - The upstreams must be in this workspace, promoted, not archived, and not this table. No edge may
407
+ close a cycle.
408
+ - The trigger runs only after the user chose updates with `mr-data promote --cadence`. That
409
+ cadence stays the fallback schedule.
410
+
411
+ The full member list is in `docs/RECIPE-DOCUMENT.md`, "Needs Studio 4.38".
412
+
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "PAGE_BUDGET_KINDS",
21
21
  "PAGE_COVERAGE_COUNT_FIELDS",
22
22
  "PAGE_COVERAGE_FIELDS",
23
+ "SOURCE_ROWS_INCOMPLETE",
23
24
  "page_coverage_sentence",
24
25
  ]
25
26
 
@@ -42,6 +43,9 @@ __all__ = [
42
43
  #: predecessor rows, but current collection refresh is refused before acquisition, except a
43
44
  #: per-row request collection's, which refreshes with the direct request window it inherits and
44
45
  #: asks again for a failed row whenever a refresh covers that row's partition.
46
+ #: A ``source_rows`` collection also states it on a full run, as ``SOURCE_ROWS_INCOMPLETE``, when
47
+ #: the upstream relation it lists from is incomplete: that is not a listing anybody failed to read
48
+ #: but a list that may be short by what the upstream source is missing, and the sentence says so.
45
49
  PAGE_COVERAGE_FIELDS: tuple[str, ...] = (
46
50
  "discovered",
47
51
  "discovery_requests",
@@ -84,6 +88,46 @@ PAGE_COVERAGE_COUNT_FIELDS: tuple[str, ...] = (
84
88
  )
85
89
 
86
90
 
91
+ #: The discovery failure a ``source_rows`` collection states when the upstream relation it lists
92
+ #: from is itself incomplete (failed, pending or unlisted members upstream). Nothing failed to be
93
+ #: LISTED: the list is built from rows another source did not finish delivering, so it may be
94
+ #: short by exactly what that source is missing. Said as what it is, never as an unreadable
95
+ #: listing, because a reader told "the listing could not be read" goes looking for an outage.
96
+ SOURCE_ROWS_INCOMPLETE = "SOURCE_ROWS_INCOMPLETE"
97
+
98
+
99
+ def _count(block: Mapping[str, Any], member: str) -> int | None:
100
+ value = block.get(member)
101
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
102
+ return None
103
+ return value
104
+
105
+
106
+ def _pages_held(block: Mapping[str, Any]) -> int | None:
107
+ """How many of the collection's members hold an answer, or ``None`` when the block cannot say.
108
+
109
+ MEMBERS, NOT CALLS. ``fetched_this_run`` counts the requests this run made: a per-row request
110
+ collection asks once for every member that renders the same request, a batch answers up to a
111
+ hundred members in one call, and a page kept from an earlier capture is not fetched again. So
112
+ ``fetched_this_run`` over ``discovered`` divides calls by members, and a run that answered
113
+ every member it listed reads as half done (food run 23161446: "459,156 of 1,005,223" for a
114
+ run with 1,004,768 members answered and 455 failed). The members that hold an answer are the
115
+ ones the ledger knows that are neither still owed, nor failed, nor skipped -- the same count
116
+ the dataset page's sources card prints as fetched.
117
+
118
+ Read with ``get``: a record written before these members existed carries only the four the
119
+ sentence always needed, and says what it always said.
120
+ """
121
+
122
+ known = _count(block, "known")
123
+ pending = _count(block, "pending")
124
+ if known is None or pending is None:
125
+ return None
126
+ failed = _count(block, "failed") or 0
127
+ skipped = _count(block, "skipped") or 0
128
+ return max(known - pending - failed - skipped, 0)
129
+
130
+
87
131
  def page_coverage_sentence(block: Mapping[str, Any]) -> str:
88
132
  """The one sentence a person reads off a page collection's coverage.
89
133
 
@@ -96,13 +140,29 @@ def page_coverage_sentence(block: Mapping[str, Any]) -> str:
96
140
  failed = int(block["failed"])
97
141
  if block["complete"]:
98
142
  return f"all {discovered:,} pages fetched"
99
- parts = [f"{fetched:,} of {discovered:,} pages fetched"]
143
+ held = _pages_held(block)
144
+ if held is None:
145
+ parts = [f"{fetched:,} of {discovered:,} pages fetched"]
146
+ else:
147
+ # A ledger can know pages its latest listing no longer names (a refresh keeps them), so
148
+ # the whole is the larger of the two and the sentence never says "more of fewer".
149
+ whole = max(discovered, _count(block, "known") or 0)
150
+ lead = f"{held:,} of {whole:,} pages fetched"
151
+ if fetched != held:
152
+ lead += f" ({fetched:,} requests this run)"
153
+ parts = [lead]
100
154
  # READ WITH ``get``, AND ONLY THIS MEMBER. The block a run produces always carries it, and the
101
155
  # block Studio hands back may not: it is optional on the run record, so a record written before
102
156
  # the member existed is still one sentence rather than a KeyError this lane swallows whole.
103
157
  listing = block.get("discovery_failure")
104
158
  if isinstance(listing, Mapping):
105
- parts.append(f"the page listing could not be read ({listing['code']})")
159
+ if listing.get("code") == SOURCE_ROWS_INCOMPLETE:
160
+ parts.append(
161
+ "a page list built from another source's rows may be short, because that source "
162
+ f"is incomplete ({SOURCE_ROWS_INCOMPLETE})"
163
+ )
164
+ else:
165
+ parts.append(f"the page listing could not be read ({listing['code']})")
106
166
  if failed:
107
167
  parts.append(f"{failed:,} failed")
108
168
  # The block does not say which kind of collection it counts, so the remedy names both. A
@@ -231,6 +231,8 @@ def lint_document(document: Mapping[str, Any]) -> list[Finding]:
231
231
  findings.extend(_columns(document.get("table")))
232
232
  findings.extend(_identifiers(document))
233
233
  findings.extend(_sources(document.get("sources")))
234
+ findings.extend(_table_sources(document.get("sources")))
235
+ findings.extend(_refresh_trigger(document))
234
236
  findings.extend(_row_filters(document.get("sources")))
235
237
  findings.extend(_row_request_credentials(document.get("sources")))
236
238
  findings.extend(_row_request_templates(document.get("sources")))
@@ -551,6 +553,272 @@ def _sources(sources: Any) -> list[Finding]:
551
553
  return findings
552
554
 
553
555
 
556
+ #: ``recipe.schema.json``'s Mostly Right table source (Studio 4.38.0, ADR 0100): the one adapter
557
+ #: that reads another table of this workspace, its source class and its locator kind.
558
+ TABLE_SOURCE_ADAPTER_ID = "table.version@1.0.0"
559
+ TABLE_SOURCE_CLASS = "mostlyright_table"
560
+ TABLE_LOCATOR_KIND = "table"
561
+
562
+ #: ``#/$defs/table_source_locator/properties/version``: the one selector v1 admits.
563
+ TABLE_SOURCE_VERSIONS: frozenset[str] = frozenset({"latest_promoted"})
564
+
565
+ #: Members a table source never carries: it is read whole, presents no credential and names no
566
+ #: origin, so the schema refuses each of these beside it.
567
+ TABLE_SOURCE_FORBIDDEN: tuple[str, ...] = (
568
+ "credential",
569
+ "window",
570
+ "collection",
571
+ "closed",
572
+ "reference",
573
+ "document_archive",
574
+ )
575
+ TABLE_CONNECTOR_FORBIDDEN: tuple[str, ...] = (
576
+ "origin",
577
+ "generic_api",
578
+ "stream_connector_document_digest",
579
+ )
580
+
581
+ #: ``#/$defs/table_refresh_trigger``.
582
+ REFRESH_TRIGGERS: frozenset[str] = frozenset({"upstream_promoted"})
583
+ MAX_REFRESH_DEBOUNCE_SECONDS = 86_400
584
+
585
+ _UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
586
+
587
+
588
+ def is_table_source(source: Any) -> bool:
589
+ """Whether a source is a Mostly Right table source by any of the three marks the schema reads.
590
+
591
+ The schema applies the table-source rules when the adapter, the locator kind OR the source
592
+ class says so, so a source that states only one of them is held to all of them here too.
593
+ """
594
+
595
+ if not isinstance(source, Mapping):
596
+ return False
597
+ connector = source.get("connector")
598
+ locator = source.get("locator")
599
+ return (
600
+ (isinstance(connector, Mapping) and connector.get("adapter_id") == TABLE_SOURCE_ADAPTER_ID)
601
+ or (isinstance(locator, Mapping) and locator.get("kind") == TABLE_LOCATOR_KIND)
602
+ or source.get("source_class") == TABLE_SOURCE_CLASS
603
+ )
604
+
605
+
606
+ def _table_sources(sources: Any) -> list[Finding]:
607
+ """A Mostly Right table source's shape, which the schema refuses at the wire (Studio 4.38.0).
608
+
609
+ It reads the latest promoted version of another table in this workspace: source class
610
+ ``mostlyright_table``, a ``table`` locator naming the upstream ``table_id`` and
611
+ ``version: latest_promoted``, adapter ``table.version@1.0.0`` with ``credential_mode: none``
612
+ and the Reader pin that decodes the upstream's Parquet parts. Which table it names, whether that
613
+ table is in this workspace, promoted, archived, the table itself or closes a cycle, is Studio's
614
+ to judge at registration; nothing here can see the workspace.
615
+ """
616
+
617
+ findings: list[Finding] = []
618
+ for index, source in enumerate(sources if isinstance(sources, list) else ()):
619
+ if not is_table_source(source):
620
+ continue
621
+ pointer = f"/sources/{index}"
622
+ if source.get("source_class") != TABLE_SOURCE_CLASS:
623
+ findings.append(
624
+ Finding(
625
+ DOCUMENT_INVALID,
626
+ f"{pointer}/source_class",
627
+ f"must be {TABLE_SOURCE_CLASS!r} for a source that reads another table",
628
+ )
629
+ )
630
+ locator = source.get("locator")
631
+ locator = locator if isinstance(locator, Mapping) else {}
632
+ if locator.get("kind") != TABLE_LOCATOR_KIND:
633
+ findings.append(
634
+ Finding(
635
+ DOCUMENT_INVALID,
636
+ f"{pointer}/locator/kind",
637
+ f"must be {TABLE_LOCATOR_KIND!r} for a source that reads another table",
638
+ )
639
+ )
640
+ table = locator.get("table")
641
+ if not isinstance(table, Mapping):
642
+ findings.append(
643
+ Finding(
644
+ DOCUMENT_INVALID,
645
+ f"{pointer}/locator/table",
646
+ "is absent; it names the upstream table as {table_id, version: "
647
+ "latest_promoted}",
648
+ )
649
+ )
650
+ else:
651
+ table_id = table.get("table_id")
652
+ if not isinstance(table_id, str) or _UUID.match(table_id) is None:
653
+ findings.append(
654
+ Finding(
655
+ DOCUMENT_INVALID,
656
+ f"{pointer}/locator/table/table_id",
657
+ "is not a lowercase UUID naming a table of this workspace",
658
+ )
659
+ )
660
+ if _outside(table.get("version"), TABLE_SOURCE_VERSIONS):
661
+ findings.append(
662
+ Finding(
663
+ DOCUMENT_INVALID,
664
+ f"{pointer}/locator/table/version",
665
+ "must be 'latest_promoted', the one version selector Studio admits",
666
+ )
667
+ )
668
+ for member in sorted(set(table) - {"table_id", "version"}):
669
+ findings.append(
670
+ Finding(
671
+ DOCUMENT_INVALID,
672
+ f"{pointer}/locator/table/{_escape(str(member))}",
673
+ "is not a member of a table locator",
674
+ )
675
+ )
676
+ connector = source.get("connector")
677
+ connector = connector if isinstance(connector, Mapping) else {}
678
+ if connector.get("adapter_id") != TABLE_SOURCE_ADAPTER_ID:
679
+ findings.append(
680
+ Finding(
681
+ DOCUMENT_INVALID,
682
+ f"{pointer}/connector/adapter_id",
683
+ f"must be {TABLE_SOURCE_ADAPTER_ID!r} for a source that reads another table",
684
+ )
685
+ )
686
+ if connector.get("credential_mode") != "none":
687
+ findings.append(
688
+ Finding(
689
+ DOCUMENT_INVALID,
690
+ f"{pointer}/connector/credential_mode",
691
+ "must be 'none': a table source reads this workspace's own sealed parts",
692
+ )
693
+ )
694
+ parameters = connector.get("parameters")
695
+ if not isinstance(parameters, list) or len(parameters) < 2:
696
+ findings.append(
697
+ Finding(
698
+ DOCUMENT_INVALID,
699
+ f"{pointer}/connector/parameters",
700
+ "must pin the Reader that decodes the upstream's Parquet parts "
701
+ "(reader.family_id parquet.tabular, reader.family_version, "
702
+ "reader.decode_options)",
703
+ )
704
+ )
705
+ for member in TABLE_CONNECTOR_FORBIDDEN:
706
+ if member in connector:
707
+ findings.append(
708
+ Finding(
709
+ DOCUMENT_INVALID,
710
+ f"{pointer}/connector/{member}",
711
+ "is not stated on a table source, which reaches no origin",
712
+ )
713
+ )
714
+ for member in TABLE_SOURCE_FORBIDDEN:
715
+ if member in source:
716
+ findings.append(
717
+ Finding(
718
+ DOCUMENT_INVALID,
719
+ f"{pointer}/{member}",
720
+ "is not stated on a table source, which is read whole and presents no "
721
+ "credential",
722
+ )
723
+ )
724
+ return findings
725
+
726
+
727
+ def _refresh_trigger(document: Mapping[str, Any]) -> list[Finding]:
728
+ """``refresh: {trigger: upstream_promoted, debounce_seconds}`` and what it requires.
729
+
730
+ The trigger asks Studio to rebuild this table when an upstream table it reads promotes a new
731
+ version, so the schema requires ``rebuild`` beside it (the rebuild is what answers the request,
732
+ under its own interval and byte bounds) and at least one ``table.version@1.0.0`` source.
733
+ """
734
+
735
+ if "refresh" not in document:
736
+ return []
737
+ refresh = document.get("refresh")
738
+ if not isinstance(refresh, Mapping):
739
+ return [Finding(DOCUMENT_INVALID, "/refresh", "is not one JSON object")]
740
+ findings: list[Finding] = []
741
+ if _outside(refresh.get("trigger"), REFRESH_TRIGGERS):
742
+ findings.append(
743
+ Finding(DOCUMENT_INVALID, "/refresh/trigger", "must be 'upstream_promoted'")
744
+ )
745
+ debounce = refresh.get("debounce_seconds")
746
+ if (
747
+ isinstance(debounce, bool)
748
+ or not isinstance(debounce, int)
749
+ or not 0 <= debounce <= MAX_REFRESH_DEBOUNCE_SECONDS
750
+ ):
751
+ findings.append(
752
+ Finding(
753
+ DOCUMENT_INVALID,
754
+ "/refresh/debounce_seconds",
755
+ f"must be a whole number of seconds from 0 to {MAX_REFRESH_DEBOUNCE_SECONDS}",
756
+ )
757
+ )
758
+ for member in sorted(set(refresh) - {"trigger", "debounce_seconds"}):
759
+ findings.append(
760
+ Finding(
761
+ DOCUMENT_INVALID,
762
+ f"/refresh/{_escape(str(member))}",
763
+ "is not a member of refresh",
764
+ )
765
+ )
766
+ if "rebuild" not in document:
767
+ findings.append(
768
+ Finding(
769
+ DOCUMENT_INVALID,
770
+ "/rebuild",
771
+ "is absent, and refresh requires it: the declared whole-table rebuild is what "
772
+ "answers an upstream promotion",
773
+ )
774
+ )
775
+ sources = document.get("sources")
776
+ if not any(
777
+ isinstance(source, Mapping)
778
+ and isinstance(source.get("connector"), Mapping)
779
+ and source["connector"].get("adapter_id") == TABLE_SOURCE_ADAPTER_ID
780
+ for source in (sources if isinstance(sources, list) else ())
781
+ ):
782
+ findings.append(
783
+ Finding(
784
+ DOCUMENT_INVALID,
785
+ "/refresh",
786
+ f"needs at least one {TABLE_SOURCE_ADAPTER_ID} source: only an upstream table "
787
+ "this recipe reads can promote",
788
+ )
789
+ )
790
+ return findings
791
+
792
+
793
+ def _classification_coherence(sources: Any) -> list[Finding]:
794
+ """Every source of one run declares one ``data_classification``.
795
+
796
+ The worker's governance refuses a run whose sources declare two classifications
797
+ (``RUN_RECIPE_INVALID`` at acquire), after the document registered and a run was queued, so
798
+ it is named here. It matters most for a join of Mostly Right tables, whose sources inherit
799
+ each upstream's classification and must still agree.
800
+ """
801
+
802
+ stated = [
803
+ (index, source.get("data_classification"))
804
+ for index, source in enumerate(sources if isinstance(sources, list) else ())
805
+ if isinstance(source, Mapping) and isinstance(source.get("data_classification"), str)
806
+ ]
807
+ if len({value for _index, value in stated}) <= 1:
808
+ return []
809
+ first = stated[0][1]
810
+ return [
811
+ Finding(
812
+ DOCUMENT_INCOHERENT,
813
+ f"/sources/{index}/data_classification",
814
+ f"is {value!r} while /sources/{stated[0][0]} is {first!r}; the worker refuses a run "
815
+ "whose sources declare more than one classification, at acquire",
816
+ )
817
+ for index, value in stated
818
+ if value != first
819
+ ]
820
+
821
+
554
822
  def _row_filters(sources: Any) -> list[Finding]:
555
823
  """``collection.discovery.row_filter``: where one may stand, and what it must be.
556
824
 
@@ -905,6 +1173,7 @@ def _coherence(document: Mapping[str, Any], *, declared: Sequence[str]) -> list[
905
1173
  findings.extend(_table_partition_coherence(document, known=known))
906
1174
  findings.extend(_reference_coherence(document.get("sources")))
907
1175
  findings.extend(_rebuild_coherence(document))
1176
+ findings.extend(_classification_coherence(document.get("sources")))
908
1177
  return findings
909
1178
 
910
1179
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": "mostlyright-thin-table-search-contract.v1",
3
- "source_commit": "b308bc2e9ebb424b4cfe1130438ab9f7d0b6736d",
3
+ "source_commit": "f61f08e29fc94e2fb1c2d16e0de2e08522a9f993",
4
4
  "recipe_search_definition": "spec",
5
5
  "$defs": {
6
6
  "evaluation_case": {
@@ -148,8 +148,8 @@ from mostlyright.data_harness.thin.transport import ThinLaneError
148
148
  #: ``JOB_INVALID`` outright, which in production was every refresh a collection epoch was offered
149
149
  #: for rather than only the collection runs. This package must not reach a Studio older than the
150
150
  #: commit it pins; ``docs/V4-WORKER-PROTOCOL.md`` states it beside the layout's own ordering rule.
151
- PINNED_V4_OPENAPI_SOURCE_SHA256 = "001a5e132e8af9872ac61168e41df4465627f34ac062066b212f1b48a88385f4"
152
- PINNED_V4_CONTRACT_VERSION = "4.35.0"
151
+ PINNED_V4_OPENAPI_SOURCE_SHA256 = "67cc1e66ae3610d9ae5c8b5469bb05e51a8519e55ecef502387fdbb4185b084e"
152
+ PINNED_V4_CONTRACT_VERSION = "4.38.0"
153
153
 
154
154
  #: The ``schema_version`` every command body that predates 4.14.0 stamps: ``dataset activity``,
155
155
  #: ``table rename``, ``table archive`` and ``dataset archive``. It is the number the 0.25 line
@@ -2355,6 +2355,8 @@ def status(args: argparse.Namespace, *, client: StudioV4RunClient | None = None)
2355
2355
  held = {member: run[member] for member in _PROJECTION_MEMBERS[1:] if member in run}
2356
2356
  if held:
2357
2357
  payload["projection"] = held
2358
+ if state in ATTEMPTING_RUN_STATUSES:
2359
+ payload.update(attempt_progress_members(selected, run_id))
2358
2360
  if getattr(args, "rate", False) and state not in TERMINAL_RUN_STATUSES:
2359
2361
  # Imported here: progress_rate reads the event stream, whose module imports this one.
2360
2362
  from mostlyright.data_harness.thin import progress_rate
@@ -2378,6 +2380,112 @@ def status(args: argparse.Namespace, *, client: StudioV4RunClient | None = None)
2378
2380
  return payload
2379
2381
 
2380
2382
 
2383
+ #: The states a run holds while it has, or is between, producer attempts. A run held at a spend
2384
+ #: or sample gate has never been claimed, so there is no attempt to read for it.
2385
+ ATTEMPTING_RUN_STATUSES: frozenset[str] = frozenset({"queued", "running"})
2386
+
2387
+ #: How each way an attempt can end reads in the progress sentence. The keys are
2388
+ #: ``run-attempts.schema.json``'s closed ``end`` vocabulary.
2389
+ _ATTEMPT_ENDS: dict[str, str] = {
2390
+ "running": "is still running",
2391
+ "completed": "completed",
2392
+ "requeued_by_worker": "was handed back by its own worker",
2393
+ "handed_back": "was handed back after its worker went silent",
2394
+ "failed": "failed",
2395
+ "lost": "went silent",
2396
+ }
2397
+
2398
+
2399
+ def attempt_progress_members(client: StudioV4RunClient, run_id: str) -> dict[str, Any]:
2400
+ """The ``status`` members for a live run's current attempt and its sources, or why not.
2401
+
2402
+ Read off the attempt ledger (``GET /v4/runs/{run_id}/attempts``), which Studio folds from
2403
+ each progress tick as it arrives and never from the run log. So, unlike the rate, this does
2404
+ not depend on replaying the log in time, and an attempt that has run for hours still says
2405
+ how many of its sources it fetched. Counts are the current attempt's own: a run handed back
2406
+ walks its sources again from the first.
2407
+
2408
+ A Studio before 4.37.0 serves no per-source member, and then the attempt and how the one
2409
+ before it ended are still stated. A refused read is reported, never fatal: the run record
2410
+ has already been read.
2411
+ """
2412
+
2413
+ try:
2414
+ answer = client.run_attempts(run_id)
2415
+ except ThinLaneError as refusal:
2416
+ return {"progress_unavailable": f"{refusal.code}: {refusal.detail}"}
2417
+ rows = answer.get("attempts") if isinstance(answer, Mapping) else None
2418
+ rows = [
2419
+ row for row in rows or () if isinstance(row, Mapping) and type(row.get("generation")) is int
2420
+ ]
2421
+ if not rows:
2422
+ return {"progress_unavailable": "Studio lists no attempt of this run yet"}
2423
+ rows.sort(key=lambda row: row["generation"])
2424
+ current = rows[-1]
2425
+ sources = current.get("sources") if isinstance(current.get("sources"), Mapping) else None
2426
+ named = sources.get("current") if sources is not None else None
2427
+ progress: dict[str, Any] = {
2428
+ "attempt": current["generation"],
2429
+ "attempts": len(rows),
2430
+ "source_count": sources.get("source_count") if sources is not None else None,
2431
+ "sources_fetched": sources.get("fetched") if sources is not None else None,
2432
+ "sources_refused": sources.get("refused") if sources is not None else None,
2433
+ "counted_from_first_tick": (
2434
+ sources.get("counted_from_first_tick") if sources is not None else None
2435
+ ),
2436
+ "current_source": dict(named) if isinstance(named, Mapping) else None,
2437
+ "previous_attempt": None,
2438
+ }
2439
+ if len(rows) > 1:
2440
+ previous = rows[-2]
2441
+ loss = previous.get("loss") if isinstance(previous.get("loss"), Mapping) else {}
2442
+ progress["previous_attempt"] = {
2443
+ "attempt": previous["generation"],
2444
+ "end": previous.get("end"),
2445
+ "ended_at": previous.get("ended_at"),
2446
+ "cause": loss.get("cause"),
2447
+ "cause_inferred": loss.get("inferred"),
2448
+ }
2449
+ return {"progress": progress, "progress_sentence": attempt_progress_sentence(progress)}
2450
+
2451
+
2452
+ def attempt_progress_sentence(progress: Mapping[str, Any]) -> str:
2453
+ """``Attempt 4 of 4 has fetched 27 of 86 sources and is on ...``, from the members above."""
2454
+
2455
+ said = f"Attempt {progress['attempt']} of {progress['attempts']}"
2456
+ fetched, count = progress.get("sources_fetched"), progress.get("source_count")
2457
+ if fetched is None:
2458
+ said += "; this Studio reports no per-source progress for it."
2459
+ else:
2460
+ said += f" has fetched {fetched:,}"
2461
+ said += f" of {count:,} sources" if count is not None else " sources"
2462
+ if progress.get("counted_from_first_tick") is False:
2463
+ said += " (at least: counting began after the attempt started)"
2464
+ named = progress.get("current_source")
2465
+ if isinstance(named, Mapping):
2466
+ said += f" and is on {named.get('source_name')}"
2467
+ if named.get("source_index") is not None and count is not None:
2468
+ said += f", source {named['source_index']:,} of {count:,}"
2469
+ done, planned = named.get("pages_processed"), named.get("pages_planned")
2470
+ unit = "members" if named.get("pages_unit") == "member" else "pages"
2471
+ if done is not None and planned is not None and named.get("discovery_complete"):
2472
+ said += f": {done:,} of {planned:,} {unit}"
2473
+ elif done is not None:
2474
+ said += f": {done:,} {unit} so far"
2475
+ said += "."
2476
+ previous = progress.get("previous_attempt")
2477
+ if isinstance(previous, Mapping):
2478
+ end = _ATTEMPT_ENDS.get(str(previous.get("end")), f"ended {previous.get('end')}")
2479
+ said += f" Attempt {previous['attempt']} {end}"
2480
+ if previous.get("cause") is not None:
2481
+ said += (
2482
+ f"; {'inferred ' if previous.get('cause_inferred') else ''}cause: "
2483
+ f"{previous['cause']}"
2484
+ )
2485
+ said += "."
2486
+ return said
2487
+
2488
+
2381
2489
  def status_exit_code(payload: Mapping[str, Any]) -> int:
2382
2490
  """A failed run exits non-zero, so a script gating on a build cannot read it as a success."""
2383
2491