mostlyright-data 0.30.1__tar.gz → 0.30.2__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.2}/PKG-INFO +1 -1
  2. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/progress_rate.py +145 -29
  3. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_stream.py +10 -2
  4. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/.gitignore +0 -0
  5. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/README.md +0 -0
  6. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/pyproject.toml +0 -0
  7. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/scripts/hatch_build.py +0 -0
  8. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/SKILL.md +0 -0
  9. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/agents/openai.yaml +0 -0
  10. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/1-open-the-page-and-the-link-to-it-in-the-first-message.md +0 -0
  11. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
  12. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
  13. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/4-decide-say-what-you-chose-what-you-refused-and-ask-one-question.md +0 -0
  14. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
  15. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
  16. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
  17. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
  18. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
  19. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/agent-protocol.md +0 -0
  20. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
  21. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
  22. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/boundaries.md +0 -0
  23. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
  24. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/commands.md +0 -0
  25. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
  26. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/installation-parity.md +0 -0
  27. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/live-run.md +0 -0
  28. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
  29. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/narrating-the-run.md +0 -0
  30. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
  31. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/one-install.md +0 -0
  32. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/patterns.md +0 -0
  33. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/prediction-labels.md +0 -0
  34. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/preparing-table-search.md +0 -0
  35. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/promote.md +0 -0
  36. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/readers.md +0 -0
  37. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/receipts.md +0 -0
  38. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/recipe-test.md +0 -0
  39. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
  40. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
  41. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/reference-pages.md +0 -0
  42. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/required-protocol.md +0 -0
  43. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/source-credentials.md +0 -0
  44. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/sources.md +0 -0
  45. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
  46. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/transforms.md +0 -0
  47. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/user-communication-contract.md +0 -0
  48. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
  49. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  50. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/__init__.py +0 -0
  51. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/agent_protocol.py +0 -0
  52. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/canonical.py +0 -0
  53. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/formats.py +0 -0
  54. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  55. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/key_seam.py +0 -0
  56. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/page_coverage.py +0 -0
  57. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  58. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/session_probes.py +0 -0
  59. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/skill_assets.py +0 -0
  60. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/table_manifest.py +0 -0
  61. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  62. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  63. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  64. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/activity.py +0 -0
  65. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  66. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/backoff.py +0 -0
  67. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/categories.py +0 -0
  68. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/commands.py +0 -0
  69. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  70. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/download.py +0 -0
  71. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/fast_features.py +0 -0
  72. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  73. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/parity.py +0 -0
  74. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/probe.py +0 -0
  75. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  76. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/propose.py +0 -0
  77. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe.py +0 -0
  78. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
  79. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_explain.py +0 -0
  80. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
  81. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_lint.py +0 -0
  82. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_test.py +0 -0
  83. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/refresh_rehearsal.py +0 -0
  84. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/release_check.py +0 -0
  85. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/research.py +0 -0
  86. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/router.py +0 -0
  87. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
  88. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/run_prefix.py +0 -0
  89. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/runs.py +0 -0
  90. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/session.py +0 -0
  91. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/stream.py +0 -0
  92. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  93. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
  94. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/support.py +0 -0
  95. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/table_search_contract.json +0 -0
  96. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/transport.py +0 -0
  97. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  98. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4.py +0 -0
  99. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  100. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  101. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
  102. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  103. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  104. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
  105. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  106. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  107. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
  108. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  109. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_reader.py +0 -0
  110. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_runs.py +0 -0
  111. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_search.py +0 -0
  112. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_search_contract.py +0 -0
  113. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  114. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  115. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  116. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  117. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  118. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  119. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  120. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  121. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  122. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  123. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  124. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  125. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  126. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  127. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  128. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/login.py +0 -0
  129. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  130. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  131. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  132. {mostlyright_data-0.30.1 → mostlyright_data-0.30.2}/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.2
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/
@@ -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__ = [
@@ -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