mostlyright-data 0.30.1__tar.gz → 0.30.3__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.1 → mostlyright_data-0.30.3}/PKG-INFO +1 -1
  2. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/SKILL.md +3 -0
  3. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/page_coverage.py +62 -2
  4. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/progress_rate.py +145 -29
  5. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_runs.py +108 -0
  6. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_stream.py +10 -2
  7. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/.gitignore +0 -0
  8. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/README.md +0 -0
  9. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/pyproject.toml +0 -0
  10. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/scripts/hatch_build.py +0 -0
  11. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/agents/openai.yaml +0 -0
  12. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/1-open-the-page-and-the-link-to-it-in-the-first-message.md +0 -0
  13. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
  14. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
  15. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/4-decide-say-what-you-chose-what-you-refused-and-ask-one-question.md +0 -0
  16. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
  17. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
  18. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
  19. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
  20. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
  21. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/agent-protocol.md +0 -0
  22. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
  23. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
  24. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/boundaries.md +0 -0
  25. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
  26. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/commands.md +0 -0
  27. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
  28. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/installation-parity.md +0 -0
  29. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/live-run.md +0 -0
  30. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
  31. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/narrating-the-run.md +0 -0
  32. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
  33. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/one-install.md +0 -0
  34. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/patterns.md +0 -0
  35. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/prediction-labels.md +0 -0
  36. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/preparing-table-search.md +0 -0
  37. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/promote.md +0 -0
  38. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/readers.md +0 -0
  39. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/receipts.md +0 -0
  40. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/recipe-test.md +0 -0
  41. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
  42. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
  43. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/reference-pages.md +0 -0
  44. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/required-protocol.md +0 -0
  45. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/source-credentials.md +0 -0
  46. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/sources.md +0 -0
  47. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
  48. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/transforms.md +0 -0
  49. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/user-communication-contract.md +0 -0
  50. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
  51. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  52. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/__init__.py +0 -0
  53. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/agent_protocol.py +0 -0
  54. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/canonical.py +0 -0
  55. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/formats.py +0 -0
  56. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  57. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/key_seam.py +0 -0
  58. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  59. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/session_probes.py +0 -0
  60. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/skill_assets.py +0 -0
  61. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/table_manifest.py +0 -0
  62. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  63. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  64. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  65. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/activity.py +0 -0
  66. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  67. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/backoff.py +0 -0
  68. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/categories.py +0 -0
  69. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/commands.py +0 -0
  70. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  71. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/download.py +0 -0
  72. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/fast_features.py +0 -0
  73. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  74. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/parity.py +0 -0
  75. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/probe.py +0 -0
  76. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  77. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/propose.py +0 -0
  78. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/recipe.py +0 -0
  79. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
  80. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/recipe_explain.py +0 -0
  81. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
  82. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/recipe_lint.py +0 -0
  83. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/recipe_test.py +0 -0
  84. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/refresh_rehearsal.py +0 -0
  85. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/release_check.py +0 -0
  86. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/research.py +0 -0
  87. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/router.py +0 -0
  88. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
  89. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/run_prefix.py +0 -0
  90. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/runs.py +0 -0
  91. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/session.py +0 -0
  92. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/stream.py +0 -0
  93. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  94. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
  95. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/support.py +0 -0
  96. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/table_search_contract.json +0 -0
  97. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/transport.py +0 -0
  98. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  99. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4.py +0 -0
  100. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  101. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  102. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
  103. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  104. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  105. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
  106. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  107. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  108. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
  109. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  110. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_reader.py +0 -0
  111. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_search.py +0 -0
  112. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_search_contract.py +0 -0
  113. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  114. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  115. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  116. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  117. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  118. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  119. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  120. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  121. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  122. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  123. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  124. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  125. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  126. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  127. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  128. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/login.py +0 -0
  129. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  130. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  131. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  132. {mostlyright_data-0.30.1 → mostlyright_data-0.30.3}/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.1
3
+ Version: 0.30.3
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/
@@ -152,6 +152,9 @@ step 6. The stage 3 reference holds its rule. Gate 4 is step 6.
152
152
  quality checks.
153
153
  7. Follow the returned run with `mr-data watch RUN_ID --json`. Its `source_rates` and
154
154
  `mr-data status RUN_ID --json` on a live run give each collection source's rate and time left.
155
+ On a queued or running run, `status` also gives `progress`: the current attempt, how many of
156
+ its sources it fetched of how many, the source it is on with its pages, and how the attempt
157
+ before it ended. A handed-back run's new attempt counts from zero.
155
158
  Quote those numbers rather than doing the arithmetic. Read `run.status`: returning an existing
156
159
  run does not mean new work was queued. Resume the known run after an interruption, using the
157
160
  saved cursor where available. Read [run lifecycle](references/live-run.md) for holds and
@@ -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
@@ -6,10 +6,10 @@ Each tick also carries the time it happened. Two ticks of one source give a rate
6
6
  count gives what is left. Nothing here is a new Studio fact: it is the arithmetic an agent used to
7
7
  do by hand while polling.
8
8
 
9
- ``pages_processed`` counts saved answers replayed as well as requests sent, so a run that starts
10
- by replaying runs fast at first. The live rate is taken over a trailing window of ticks, so it
11
- settles on the sending rate once the replays are done. The time remaining is an estimate at that
12
- rate and says so.
9
+ ``pages_processed`` combines saved answers replayed and requests sent. Its rate is therefore
10
+ source-member throughput, never publisher throughput or whole-run completion. Live status rates
11
+ require reaching the exact snapshot event, matching its attempt, and a fresh source observation.
12
+ Reaching that cursor does not establish complete source inventory or complete event history.
13
13
  """
14
14
 
15
15
  from __future__ import annotations
@@ -19,7 +19,7 @@ import time
19
19
  from collections import deque
20
20
  from collections.abc import Callable, Mapping
21
21
  from dataclasses import dataclass, field
22
- from datetime import datetime
22
+ from datetime import UTC, datetime
23
23
  from typing import Any
24
24
 
25
25
  from mostlyright.data_harness.thin.stream import RunEventStream
@@ -32,6 +32,13 @@ TRAILING_WINDOW_SECONDS = 600.0
32
32
  #: How often ``watch`` prints a rate line for one source, in seconds of event time.
33
33
  RATE_LINE_EVERY_SECONDS = 60.0
34
34
 
35
+ #: A rate is a live estimate only while its own latest observation is this recent.
36
+ LIVE_FRESHNESS_SECONDS = 30.0
37
+
38
+
39
+ def utc_now() -> datetime:
40
+ return datetime.now(UTC)
41
+
35
42
 
36
43
  def parse_instant(value: Any) -> datetime | None:
37
44
  """An RFC 3339 instant as Studio stamps it, or ``None`` when it is absent or unreadable."""
@@ -74,16 +81,16 @@ class SourceRate:
74
81
  planned: int | None = None
75
82
  discovery_complete: bool = False
76
83
  printed_at: datetime | None = None
84
+ unavailable_reason: str | None = None
77
85
 
78
- def observe(self, at: datetime, done: int) -> None:
79
- if self.ticks and done < self.ticks[-1][1]:
80
- # The count went back, so this is a different acquisition. Start again.
81
- self.ticks.clear()
82
- self.first = None
86
+ def observe(self, at: datetime, done: int) -> bool:
83
87
  if self.ticks and at <= self.ticks[-1][0]:
84
- if done > self.ticks[-1][1]:
85
- self.ticks[-1] = (self.ticks[-1][0], done)
86
- return
88
+ return False
89
+ if self.ticks and done < self.ticks[-1][1]:
90
+ # There is no batch identity on these counters. A decrease cannot establish one.
91
+ self.unavailable_reason = "counter_regressed_without_scope_change"
92
+ self.planned = None
93
+ self.discovery_complete = False
87
94
  self.ticks.append((at, done))
88
95
  if self.first is None:
89
96
  self.first = (at, done)
@@ -92,6 +99,7 @@ class SourceRate:
92
99
  and (at - self.ticks[1][0]).total_seconds() >= TRAILING_WINDOW_SECONDS
93
100
  ):
94
101
  self.ticks.popleft()
102
+ return True
95
103
 
96
104
  @property
97
105
  def done(self) -> int | None:
@@ -100,7 +108,7 @@ class SourceRate:
100
108
  def per_second(self) -> float | None:
101
109
  """The trailing rate, or ``None`` before two ticks with time between them."""
102
110
 
103
- if len(self.ticks) < 2:
111
+ if self.unavailable_reason is not None or len(self.ticks) < 2:
104
112
  return None
105
113
  (start, start_done), (end, end_done) = self.ticks[0], self.ticks[-1]
106
114
  elapsed = (end - start).total_seconds()
@@ -109,7 +117,7 @@ class SourceRate:
109
117
  def average_per_second(self) -> float | None:
110
118
  """The rate from the first tick to the last, or ``None`` before two ticks."""
111
119
 
112
- if self.first is None or not self.ticks:
120
+ if self.unavailable_reason is not None or self.first is None or not self.ticks:
113
121
  return None
114
122
  elapsed = (self.ticks[-1][0] - self.first[0]).total_seconds()
115
123
  return (self.ticks[-1][1] - self.first[1]) / elapsed if elapsed > 0 else None
@@ -127,6 +135,7 @@ class SourceRate:
127
135
  or done is None
128
136
  or rate is None
129
137
  or rate <= 0
138
+ or done > self.planned
130
139
  ):
131
140
  return None
132
141
  return max(0, self.planned - done) / rate
@@ -171,21 +180,46 @@ class RateTracker:
171
180
 
172
181
  def __init__(self) -> None:
173
182
  self.sources: dict[str, SourceRate] = {}
183
+ self.attempt_id: str | None = None
184
+ self.retired_attempts: set[str] = set()
185
+ self.last_sequence = 0
186
+ self.live_observation: dict[str, Any] | None = None
174
187
 
175
188
  def observe(self, body: Mapping[str, Any]) -> SourceRate | None:
176
189
  """Fold one run event record, and answer the source it moved, or ``None``."""
177
190
 
191
+ sequence = _count(body.get("sequence"))
192
+ if sequence is None or sequence <= self.last_sequence:
193
+ return None
194
+ self.last_sequence = sequence
178
195
  event_type = body.get("event_type")
179
- if isinstance(event_type, str) and not event_type.startswith("run_progress_"):
196
+ if not isinstance(event_type, str) or not event_type.startswith("run_progress_"):
180
197
  return None
198
+ attempt = body.get("attempt_id")
199
+ if isinstance(attempt, str) and attempt:
200
+ if attempt in self.retired_attempts:
201
+ return None
202
+ if attempt != self.attempt_id:
203
+ if self.attempt_id is not None:
204
+ self.retired_attempts.add(self.attempt_id)
205
+ self.sources.clear()
206
+ self.attempt_id = attempt
181
207
  facts = tick_facts(body)
182
208
  at = parse_instant(body.get("occurred_at"))
183
209
  done = _count(facts.get("pages_processed"))
184
210
  name = facts.get("source_id") or facts.get("source_name")
185
- if at is None or done is None or not isinstance(name, str) or not name:
211
+ if (
212
+ at is None
213
+ or done is None
214
+ or not isinstance(name, str)
215
+ or not name
216
+ or not isinstance(attempt, str)
217
+ or not attempt
218
+ ):
186
219
  return None
187
220
  source = self.sources.setdefault(name, SourceRate(name))
188
- source.observe(at, done)
221
+ if not source.observe(at, done) or source.unavailable_reason is not None:
222
+ return None
189
223
  planned = _count(facts.get("pages_planned"))
190
224
  if planned is not None:
191
225
  source.planned = planned
@@ -193,12 +227,17 @@ class RateTracker:
193
227
  source.discovery_complete = facts["discovery_complete"]
194
228
  return source
195
229
 
196
- def due_line(self, source: SourceRate) -> str | None:
230
+ def due_line(
231
+ self, source: SourceRate, *, wall_clock: Callable[[], datetime] = utc_now
232
+ ) -> str | None:
197
233
  """The source's line when one is due, at most once a minute of event time per source."""
198
234
 
199
235
  if not source.ticks:
200
236
  return None
201
237
  now = source.ticks[-1][0]
238
+ age = (wall_clock() - now).total_seconds()
239
+ if age < 0 or age > LIVE_FRESHNESS_SECONDS:
240
+ return None
202
241
  if (
203
242
  source.printed_at is not None
204
243
  and (now - source.printed_at).total_seconds() < RATE_LINE_EVERY_SECONDS
@@ -265,7 +304,8 @@ def live_rates(
265
304
  """The rates of a live run, from the last ``tail`` records of its event log.
266
305
 
267
306
  Reads the progress snapshot for the newest sequence, then replays the log from ``tail``
268
- records before it and stops at that sequence. Answers ``None`` when the run has reported no
307
+ records before it and stops at or beyond that sequence. Only observing the exact target
308
+ certifies completion of this bounded replay. Answers ``None`` when the run has reported no
269
309
  progress yet. A refusal on the way is raised, and the caller decides what it costs.
270
310
  """
271
311
 
@@ -274,14 +314,51 @@ def live_rates(
274
314
  if type(sequence) is not int or sequence < 1:
275
315
  return None
276
316
  tracker = RateTracker()
277
- RunEventStream(
317
+ target_seen = False
318
+ target_attempt: str | None = None
319
+ observed_at: str | None = None
320
+ from_sequence = max(0, sequence - tail)
321
+ expected_sequence = from_sequence + 1
322
+ sequence_gap = False
323
+
324
+ def observe(body: Mapping[str, Any], _frame_name: str) -> bool:
325
+ nonlocal target_seen, target_attempt, observed_at, expected_sequence, sequence_gap
326
+ observed = _count(body.get("sequence"))
327
+ if observed is None:
328
+ return False
329
+ if observed != expected_sequence:
330
+ sequence_gap = True
331
+ expected_sequence = observed + 1
332
+ instant = parse_instant(body.get("occurred_at"))
333
+ observed_at = instant.isoformat() if instant is not None else None
334
+ if observed <= sequence:
335
+ tracker.observe(body)
336
+ if observed == sequence:
337
+ target_seen = True
338
+ attempt = body.get("attempt_id")
339
+ target_attempt = attempt if isinstance(attempt, str) and attempt else None
340
+ return observed >= sequence
341
+
342
+ followed = RunEventStream(
278
343
  client.session, run_id, transport=client.transport, sleep=sleep, path=RUN_EVENTS_PATH
279
344
  ).follow(
280
- from_seq=max(0, sequence - tail),
281
- observe=follow_into(tracker, until_sequence=sequence),
345
+ from_seq=from_sequence,
346
+ observe=observe,
282
347
  deadline=clock() + deadline_seconds,
283
348
  clock=clock,
284
349
  )
350
+ tracker.live_observation = {
351
+ "scope": "bounded_event_tail",
352
+ "from_sequence_exclusive": from_sequence,
353
+ "target_sequence": sequence,
354
+ "observed_sequence": followed.last_sequence,
355
+ "replay_complete": target_seen and not sequence_gap,
356
+ "sequence_gap": sequence_gap,
357
+ "observed_at": observed_at,
358
+ "attempt_id": target_attempt,
359
+ "deadline_expired": followed.expired,
360
+ "end_reason": followed.end_reason,
361
+ }
285
362
  return tracker
286
363
 
287
364
 
@@ -314,19 +391,58 @@ def history_rates(
314
391
  return tracker
315
392
 
316
393
 
317
- def live_rate_members(client: Any, run_id: str, **kwargs: Any) -> dict[str, Any]:
394
+ def live_rate_members(
395
+ client: Any, run_id: str, *, wall_clock: Callable[[], datetime] = utc_now, **kwargs: Any
396
+ ) -> dict[str, Any]:
318
397
  """The ``status`` receipt members for a live run's rates, or why there are none."""
319
398
 
320
399
  try:
321
400
  tracker = live_rates(client, run_id, **kwargs)
322
401
  except ThinLaneError as refusal:
323
402
  return {"source_rates_unavailable": f"{refusal.code}: {refusal.detail}"}
324
- if tracker is None or not tracker.receipts():
403
+ if tracker is None:
325
404
  return {"source_rates_unavailable": "no collection source has reported two ticks yet"}
326
- return {
327
- "source_rates": tracker.receipts(),
328
- "rates": {f"source {index}": line for index, line in enumerate(tracker.lines(), 1)},
329
- }
405
+ observation = dict(tracker.live_observation or {})
406
+ now = wall_clock()
407
+ observation["checked_at"] = now.isoformat()
408
+ result: dict[str, Any] = {"source_rate_observation": observation}
409
+ if not observation.get("replay_complete"):
410
+ result["source_rates_unavailable"] = (
411
+ "incomplete replay: event gap or snapshot event missing"
412
+ )
413
+ return result
414
+ if not observation.get("attempt_id") or observation["attempt_id"] != tracker.attempt_id:
415
+ result["source_rates_unavailable"] = "snapshot attempt could not be established"
416
+ return result
417
+ receipts, lines, unavailable = [], {}, {}
418
+ for source in tracker.sources.values():
419
+ at = source.ticks[-1][0] if source.ticks else None
420
+ age = (now - at).total_seconds() if at is not None else None
421
+ reason = source.unavailable_reason
422
+ if reason is None and (age is None or age < 0):
423
+ reason = "observation_time_unavailable_or_in_future"
424
+ if reason is None and age is not None and age > LIVE_FRESHNESS_SECONDS:
425
+ reason = "stale_observation"
426
+ if reason is None and source.per_second() is None:
427
+ reason = "insufficient_observations"
428
+ if reason is not None:
429
+ unavailable[source.name] = {
430
+ "reason": reason,
431
+ "observed_at": at.isoformat() if at else None,
432
+ "age_seconds": age,
433
+ }
434
+ continue
435
+ receipt = source.to_receipt()
436
+ receipt.update(observed_at=at.isoformat(), age_seconds=age, attempt_id=tracker.attempt_id)
437
+ receipts.append(receipt)
438
+ lines[f"source {len(lines) + 1}"] = source.line()
439
+ if unavailable:
440
+ result["source_rates_unavailable_by_source"] = unavailable
441
+ if receipts:
442
+ result.update(source_rates=receipts, rates=lines)
443
+ else:
444
+ result["source_rates_unavailable"] = "no fresh, same-attempt source rate is available"
445
+ return result
330
446
 
331
447
 
332
448
  __all__ = [
@@ -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
 
@@ -40,10 +40,11 @@ import argparse
40
40
  import json
41
41
  import time
42
42
  from collections.abc import Callable, Mapping
43
+ from datetime import datetime
43
44
  from typing import Any
44
45
 
45
46
  from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
46
- from mostlyright.data_harness.thin.progress_rate import RateTracker
47
+ from mostlyright.data_harness.thin.progress_rate import RateTracker, utc_now
47
48
  from mostlyright.data_harness.thin.session import (
48
49
  StudioSession,
49
50
  StudioSessionProvider,
@@ -216,6 +217,7 @@ def watch(
216
217
  *,
217
218
  client: StudioV4RunClient | None = None,
218
219
  clock: Callable[[], float] = time.monotonic,
220
+ wall_clock: Callable[[], datetime] = utc_now,
219
221
  sessions: StudioSessionProvider | None = None,
220
222
  ) -> dict[str, Any]:
221
223
  """``mr-data watch RUN``: the live timeline, and the record the run stopped on.
@@ -259,7 +261,7 @@ def watch(
259
261
  print(render_v4_frame(body, frame_name), flush=True)
260
262
  moved = rates.observe(body)
261
263
  if moved is not None and announce:
262
- line = rates.due_line(moved)
264
+ line = rates.due_line(moved, wall_clock=wall_clock)
263
265
  if line is not None:
264
266
  print(f" rate {line}", flush=True)
265
267
  if body.get("event_type") in V4_TERMINAL_EVENT_TYPES:
@@ -334,6 +336,12 @@ def terminal_payload(
334
336
  if measured:
335
337
  # The last rate each collection source reached, from its own progress ticks.
336
338
  payload["source_rates"] = measured
339
+ payload["source_rates_basis"] = "historical_last_observation_per_source"
340
+ payload["source_rate_observed_at"] = {
341
+ name: source.ticks[-1][0].isoformat()
342
+ for name, source in rates.sources.items()
343
+ if source.per_second() is not None
344
+ }
337
345
  if event_type == "run_failed":
338
346
  triple = failure_from(event_facts(terminal), subject="a run_failed event")
339
347
  payload["failure"] = triple