mostlyright-data 0.30.0__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.0 → mostlyright_data-0.30.2}/PKG-INFO +1 -1
  2. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/preparing-table-search.md +12 -8
  3. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/progress_rate.py +145 -29
  4. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/table_search_contract.json +1 -1
  5. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4.py +2 -2
  6. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_search.py +144 -75
  7. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_stream.py +10 -2
  8. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/.gitignore +0 -0
  9. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/README.md +0 -0
  10. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/pyproject.toml +0 -0
  11. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/scripts/hatch_build.py +0 -0
  12. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/SKILL.md +0 -0
  13. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/agents/openai.yaml +0 -0
  14. {mostlyright_data-0.30.0 → 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
  15. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
  16. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
  17. {mostlyright_data-0.30.0 → 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
  18. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
  19. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
  20. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
  21. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
  22. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
  23. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/agent-protocol.md +0 -0
  24. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
  25. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
  26. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/boundaries.md +0 -0
  27. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
  28. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/commands.md +0 -0
  29. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
  30. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/installation-parity.md +0 -0
  31. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/live-run.md +0 -0
  32. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
  33. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/narrating-the-run.md +0 -0
  34. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
  35. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/one-install.md +0 -0
  36. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/patterns.md +0 -0
  37. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/prediction-labels.md +0 -0
  38. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/promote.md +0 -0
  39. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/readers.md +0 -0
  40. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/receipts.md +0 -0
  41. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/recipe-test.md +0 -0
  42. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
  43. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
  44. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/reference-pages.md +0 -0
  45. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/required-protocol.md +0 -0
  46. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/source-credentials.md +0 -0
  47. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/sources.md +0 -0
  48. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
  49. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/transforms.md +0 -0
  50. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/user-communication-contract.md +0 -0
  51. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
  52. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  53. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/__init__.py +0 -0
  54. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/agent_protocol.py +0 -0
  55. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/canonical.py +0 -0
  56. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/formats.py +0 -0
  57. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  58. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/key_seam.py +0 -0
  59. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/page_coverage.py +0 -0
  60. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  61. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/session_probes.py +0 -0
  62. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/skill_assets.py +0 -0
  63. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/table_manifest.py +0 -0
  64. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  65. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  66. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  67. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/activity.py +0 -0
  68. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  69. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/backoff.py +0 -0
  70. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/categories.py +0 -0
  71. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/commands.py +0 -0
  72. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  73. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/download.py +0 -0
  74. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/fast_features.py +0 -0
  75. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  76. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/parity.py +0 -0
  77. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/probe.py +0 -0
  78. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  79. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/propose.py +0 -0
  80. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe.py +0 -0
  81. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
  82. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_explain.py +0 -0
  83. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
  84. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_lint.py +0 -0
  85. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/recipe_test.py +0 -0
  86. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/refresh_rehearsal.py +0 -0
  87. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/release_check.py +0 -0
  88. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/research.py +0 -0
  89. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/router.py +0 -0
  90. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
  91. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/run_prefix.py +0 -0
  92. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/runs.py +0 -0
  93. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/session.py +0 -0
  94. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/stream.py +0 -0
  95. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  96. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
  97. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/support.py +0 -0
  98. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/transport.py +0 -0
  99. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  100. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  101. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  102. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
  103. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  104. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  105. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
  106. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  107. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  108. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
  109. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  110. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_reader.py +0 -0
  111. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_runs.py +0 -0
  112. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_search_contract.py +0 -0
  113. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  114. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  115. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  116. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  117. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  118. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  119. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  120. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  121. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  122. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  123. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  124. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  125. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  126. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  127. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  128. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/login.py +0 -0
  129. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  130. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  131. {mostlyright_data-0.30.0 → mostlyright_data-0.30.2}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  132. {mostlyright_data-0.30.0 → 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.0
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/
@@ -199,8 +199,8 @@ The `local` block lists each refusal with a JSON pointer. Repair every diagnosti
199
199
 
200
200
  Register and build the table as usual. A finished build of a table with `table.search` prepares
201
201
  search in the background. Data publication never waits for it. The live search package that
202
- `search try` and `search locate` use exists only for a table published publicly. Read the
203
- version ID from the run receipt, then check readiness:
202
+ `search try` uses exists only for a table published publicly. Read the version ID from the run
203
+ receipt, then check readiness:
204
204
 
205
205
  ```sh
206
206
  mr-data search status TABLE_ID --version VERSION_ID --json
@@ -280,12 +280,14 @@ mr-data search locate TABLE_ID --public-table PUBLIC_TABLE_ID --title "Dune" --y
280
280
  mr-data search locate TABLE_ID --public-table PUBLIC_TABLE_ID --id 27205 --json
281
281
  ```
282
282
 
283
- `locate` searches the live package and maps each matching row to its locator. It reads the
284
- title, date and identifier columns from the field roles. Override them with `--title-column`,
285
- `--date-column` and `--id-column`. Each column it reads must be in `display_columns`. A target
286
- with a year reads the date column, so declare `display_columns` with the date in it. A title
287
- without a year can match several rows. `locate` then lists them all and exits 4. Add the year or
288
- use the id. It writes `--out` only when every target resolved and the cases meet the contract.
283
+ `locate` reads the public table's rows and maps each matching row to its locator. It needs no
284
+ search package, so write the cases before the version has one. The table must be published
285
+ publicly with its preview on. It reads the title, date and identifier columns from the field
286
+ roles. Override them with `--title-column`, `--date-column` and `--id-column`. A title matches
287
+ exactly, with the same case and spacing as the cell. A year matches the start of the date
288
+ column's value. A title without a year can match several rows. `locate` then lists them all and
289
+ exits 4. Add the year or use the id. It writes `--out` only when every target resolved and the
290
+ cases meet the contract.
289
291
 
290
292
  Locators belong to one sealed version. `locate` resolves rows of the live version only. Configure
291
293
  the cases on that same version. A later version has a different row layout, so locate again after
@@ -313,6 +315,8 @@ Leave out `--spec` to declare the `table.search` spec of the recipe that sealed
313
315
  Use that to add evaluation cases without retyping the spec. Without `--spec` the client cannot
314
316
  check locally that semantic cases include a paraphrase and a hard negative. Studio checks them
315
317
  against the recipe's spec. A recipe with no `table.search` answers 409 `SEARCH_NOT_CONFIGURED`.
318
+ `search status --job` reads any search job: the build, its package and embed steps, the
319
+ evaluator and a title job. A job still `queued`, `dispatched` or `running` exits 3.
316
320
  `--expected-revision` is the current draft from `search status` (`selected_draft_revision_id`),
317
321
  or `none`. Reuse `--idempotency-key` only to retry the same configuration. When the job succeeds,
318
322
  `search status TABLE_ID --version VERSION_ID --revision REVISION_ID` reports `not_activated` and
@@ -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__ = [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": "mostlyright-thin-table-search-contract.v1",
3
- "source_commit": "a0555f0152fe14beefaa5e082c7e3352d052f87a",
3
+ "source_commit": "b308bc2e9ebb424b4cfe1130438ab9f7d0b6736d",
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 = "fc5229095aacb4fb7fe57b34c206802b381c81088c40af9dc19c05889b486a41"
152
- PINNED_V4_CONTRACT_VERSION = "4.34.0"
151
+ PINNED_V4_OPENAPI_SOURCE_SHA256 = "001a5e132e8af9872ac61168e41df4465627f34ac062066b212f1b48a88385f4"
152
+ PINNED_V4_CONTRACT_VERSION = "4.35.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
@@ -197,6 +197,7 @@ def search_exit_code(payload: dict[str, Any]) -> int:
197
197
  "queued",
198
198
  "dispatched",
199
199
  "running",
200
+ "deferred",
200
201
  "pending",
201
202
  "cancellation_requested",
202
203
  }:
@@ -217,6 +218,12 @@ def search_exit_code(payload: dict[str, Any]) -> int:
217
218
 
218
219
 
219
220
  SEARCH_JOB_SCHEMAS = {"table-search-job.v1", "table-search-job.v2"}
221
+ #: Every job Studio's job read answers. The whole-version (v1) and sharded (v2) parents carry
222
+ #: their schema; the title job carries v3; a package, embed or evaluation child carries none.
223
+ JOB_STATUS_SCHEMAS = {*SEARCH_JOB_SCHEMAS, "table-search-job.v3", None}
224
+ JOB_STATUSES = frozenset(
225
+ {"queued", "dispatched", "running", "deferred", "succeeded", "failed", "cancelled"}
226
+ )
220
227
 
221
228
  RECIPE_INSPECTION_PATH = "/v4/search/recipe-inspection"
222
229
  TABLE_SEARCH_PATH = "/v4/tables/{table_id}/search"
@@ -715,26 +722,60 @@ def _try(
715
722
  return _payload("try", result)
716
723
 
717
724
 
718
- def _locator(row_id: int, counts: list[int]) -> list[int]:
719
- start = 0
720
- for part, count in enumerate(counts):
721
- if row_id < start + count:
722
- return [part, row_id - start]
723
- start += count
724
- raise ThinLaneError("THIN_RESPONSE_INVALID", "a search row lies outside the sealed version")
725
+ #: Rows per page of a locate read. Cloud returns each row's physical locator only on a compact
726
+ #: page, and a compact page holds at most 50 rows.
727
+ LOCATE_PAGE_ROWS = 50
728
+ #: Pages one locate target may read before it reports the rows it found as ambiguous.
729
+ MAX_LOCATE_PAGES = 20
730
+
731
+
732
+ def _public_table(public_table_id: str, public: Any) -> dict[str, Any]:
733
+ """The public schema's table: which Studio version it serves, and whether it is queryable."""
734
+
735
+ from mostlyright.data_harness.thin.v4_public import PublicCatalogClient, resolve_public_origin
736
+
737
+ selected = public or PublicCatalogClient(resolve_public_origin())
738
+ body = selected.get_table_schema(public_table_id)
739
+ table = body.get("table") if isinstance(body, dict) else None
740
+ if not isinstance(table, dict):
741
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "Cloud returned a schema without a table")
742
+ return table
725
743
 
726
744
 
727
745
  class _Locator:
728
- """Resolves rows of the published live version to evaluation-case locators."""
746
+ """Resolves rows of the published live version to evaluation-case locators.
747
+
748
+ It reads through Cloud's public table query, filtered on the title or identifier column, so
749
+ it needs no search package: cases are written before the version has one. Each row comes
750
+ back with its physical ``[part, row]`` locator. Every read is pinned to the version the first
751
+ one served, and that version must be the one Studio reports live."""
729
752
 
730
753
  def __init__(self, args, client, public, query_client) -> None:
731
754
  self.table_id = identifier(_required(args.target, "a table identifier"), "table identifier")
732
755
  self.public_table_id = identifier(args.public_table_id, "public table ID")
733
756
  self.studio = _studio(client)
734
- self.search, metadata = _bind_public(
735
- self.public_table_id, public, self.studio, self.table_id
736
- )
737
- self.version = metadata["table_version_id"]
757
+ table = _public_table(self.public_table_id, public)
758
+ metadata = _metadata(self.studio, self.table_id, None)
759
+ self.version = metadata.get("table_version_id")
760
+ capabilities = table.get("capabilities") or {}
761
+ search = capabilities.get("search") if isinstance(capabilities, dict) else None
762
+ served = {table.get("version_id")}
763
+ if isinstance(search, dict):
764
+ served.add(search.get("table_version_id"))
765
+ if not self.version or served != {self.version}:
766
+ raise ThinLaneError(
767
+ "THIN_SEARCH_VERSION_MISMATCH",
768
+ f"public table {self.public_table_id} serves version "
769
+ f"{table.get('version_id')}, but table {self.table_id} has live version "
770
+ f"{self.version}. Check --public-table with mr-data public tables DATASET_SLUG. "
771
+ "The public schema can lag a new live version by a few minutes, so retry then.",
772
+ )
773
+ if isinstance(capabilities, dict) and capabilities.get("query") is False:
774
+ raise ThinLaneError(
775
+ "THIN_SEARCH_CAPABILITY_UNAVAILABLE",
776
+ f"public table {self.public_table_id} does not serve queries, so locate cannot "
777
+ "read its rows. Turn on its preview in the dataset's publication settings.",
778
+ )
738
779
  if (
739
780
  args.table_version_id is not None
740
781
  and identifier(args.table_version_id, "table version") != self.version
@@ -751,22 +792,84 @@ class _Locator:
751
792
  self.id_column = args.id_column or roles.get("identifier")
752
793
  self.counts = _manifest_record_counts(self.studio, self.table_id, self.version)
753
794
  self.query_client = query_client
754
-
755
- def _rows(self, text: str, plan: dict[str, Any], columns: list[str]) -> list[dict[str, Any]]:
756
- answer, _ = _cloud_query(
757
- self.public_table_id,
758
- _resident_body(_query_text(text), self.search, columns, MAX_TRY_ROWS, plan),
759
- self.query_client,
760
- )
761
- if answer.get("path") != "resident" or answer.get("outcome") not in (None, "ok"):
795
+ self.pin: tuple[str, str] | None = None
796
+
797
+ def _bound(self, answer: dict[str, Any]) -> None:
798
+ served = answer.get("table")
799
+ if not isinstance(served, dict):
800
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "Cloud returned rows without a table")
801
+ coordinates = (served.get("version_id"), served.get("content_digest"))
802
+ if not all(isinstance(value, str) and value for value in coordinates):
803
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "Cloud returned rows without a version")
804
+ live = served.get("live_version_id")
805
+ rows = served.get("row_count")
806
+ if (
807
+ coordinates[0] != self.version
808
+ or live not in (None, self.version)
809
+ or (rows is not None and rows != sum(self.counts))
810
+ or (self.pin is not None and coordinates != self.pin)
811
+ ):
762
812
  raise ThinLaneError(
763
- "THIN_SEARCH_CAPABILITY_UNAVAILABLE",
764
- "the live search package did not answer ("
765
- + str(answer.get("note") or answer.get("outcome") or "no package")
766
- + f"). locate reads {', '.join(columns)}. Each must be in the spec's "
767
- "display_columns, and the package must be ready in mr-data search status.",
813
+ "THIN_SEARCH_VERSION_MISMATCH",
814
+ f"public table {self.public_table_id} stopped serving live version "
815
+ f"{self.version} while locate read it. Run locate again.",
768
816
  )
769
- return [row for row in answer.get("rows", []) if isinstance(row, dict)]
817
+ self.pin = coordinates
818
+
819
+ def _rows(self, filters: list[dict[str, Any]], columns: list[str]) -> tuple[list, bool]:
820
+ """Every row matching ``filters``, each with its locator, and whether pages remain."""
821
+
822
+ found: list[dict[str, Any]] = []
823
+ cursor: str | None = None
824
+ for _ in range(MAX_LOCATE_PAGES):
825
+ # A cursor is honoured only beside the query it was issued for, and it carries its
826
+ # own version pin.
827
+ body: dict[str, Any] = {
828
+ "columns": columns,
829
+ "filters": filters,
830
+ "limit": LOCATE_PAGE_ROWS,
831
+ "compact_cells": True,
832
+ }
833
+ if cursor is not None:
834
+ body["cursor"] = cursor
835
+ elif self.pin is not None:
836
+ body.update(version_id=self.pin[0], content_digest=self.pin[1])
837
+ answer, _ = _cloud_query(self.public_table_id, body, self.query_client)
838
+ self._bound(answer)
839
+ rows, refs = answer.get("rows"), answer.get("row_refs")
840
+ if (
841
+ not isinstance(rows, list)
842
+ or not isinstance(refs, list)
843
+ or len(rows) != len(refs)
844
+ or len(rows) > LOCATE_PAGE_ROWS
845
+ ):
846
+ raise ThinLaneError(
847
+ "THIN_RESPONSE_INVALID", "Cloud returned rows without their locators"
848
+ )
849
+ for row, ref in zip(rows, refs, strict=True):
850
+ part = ref.get("part_index") if isinstance(ref, dict) else None
851
+ index = ref.get("row_index") if isinstance(ref, dict) else None
852
+ if (
853
+ not isinstance(row, dict)
854
+ or type(part) is not int
855
+ or type(index) is not int
856
+ or not 0 <= part < len(self.counts)
857
+ or not 0 <= index < self.counts[part]
858
+ ):
859
+ raise ThinLaneError(
860
+ "THIN_RESPONSE_INVALID", "a located row lies outside the sealed version"
861
+ )
862
+ found.append(
863
+ {
864
+ "row_id": sum(self.counts[:part]) + index,
865
+ "locator": [part, index],
866
+ **row,
867
+ }
868
+ )
869
+ cursor = answer.get("next_cursor")
870
+ if not cursor:
871
+ return found, False
872
+ return found, True
770
873
 
771
874
  def resolve(self, target: Any) -> dict[str, Any]:
772
875
  """One draft target to its matches: ``{"title", "year"}``, ``{"id"}`` or a locator."""
@@ -796,23 +899,9 @@ class _Locator:
796
899
  value = target["id"]
797
900
  if self.types.get(column) == "integer" and isinstance(value, str) and value.isdigit():
798
901
  value = int(value)
799
- plan = {
800
- "schema_version": PLAN_SCHEMA,
801
- "kind": "browse",
802
- "text": "",
803
- "predicates": [
804
- {"column": column, "operator": "eq", "value": value, "origin": "explicit"}
805
- ],
806
- "prefer": [],
807
- "order": [],
808
- "scope": "matching_rows",
809
- "unresolved": [],
810
- }
811
- rows = [
812
- row
813
- for row in self._rows(str(target["id"]), plan, [column])
814
- if str((row.get("fields") or {}).get(column)) == str(value)
815
- ]
902
+ matches, more = self._rows(
903
+ [{"column": column, "operator": "eq", "value": value}], [column]
904
+ )
816
905
  else:
817
906
  column = _required(self.title_column, "--title-column (no title field role)")
818
907
  title = str(target["title"])
@@ -821,37 +910,18 @@ class _Locator:
821
910
  if year is not None:
822
911
  date = _required(self.date_column, "--date-column (no date field role)")
823
912
  columns.append(date)
824
- plan = {
825
- "schema_version": PLAN_SCHEMA,
826
- "kind": "title",
827
- "text": title,
828
- "predicates": [],
829
- "prefer": [],
830
- "order": [],
831
- "scope": "relevance_window",
832
- "unresolved": [],
833
- }
834
- rows = [
835
- row
836
- for row in self._rows(title, plan, list(dict.fromkeys(columns)))
837
- if _norm((row.get("fields") or {}).get(column)) == _norm(title)
838
- ]
913
+ matches, more = self._rows(
914
+ [{"column": column, "operator": "eq", "value": title}],
915
+ list(dict.fromkeys(columns)),
916
+ )
839
917
  if year is not None:
840
- rows = [
841
- row
842
- for row in rows
843
- if str((row.get("fields") or {}).get(date) or "").startswith(str(year))
918
+ matches = [
919
+ row for row in matches if str(row.get(date) or "").startswith(str(year))
844
920
  ]
845
- matches = [
846
- {
847
- "row_id": row["row_id"],
848
- "locator": _locator(row["row_id"], self.counts),
849
- **row["fields"],
850
- }
851
- for row in rows
852
- if type(row.get("row_id")) is int and isinstance(row.get("fields"), dict)
853
- ]
854
- status = "located" if len(matches) == 1 else "not_found" if not matches else "ambiguous"
921
+ if len(matches) == 1 and not more:
922
+ status = "located"
923
+ else:
924
+ status = "not_found" if not matches and not more else "ambiguous"
855
925
  return {
856
926
  "target": target,
857
927
  "status": status,
@@ -1057,9 +1127,8 @@ def search(
1057
1127
  if job_operation:
1058
1128
  if (
1059
1129
  answer.get("job_id") != job_id
1060
- or answer.get("status")
1061
- not in {"queued", "dispatched", "running", "succeeded", "failed", "cancelled"}
1062
- or (verb == "status" and answer.get("schema_version") not in SEARCH_JOB_SCHEMAS)
1130
+ or answer.get("status") not in JOB_STATUSES
1131
+ or (verb == "status" and answer.get("schema_version") not in JOB_STATUS_SCHEMAS)
1063
1132
  ):
1064
1133
  raise ThinLaneError(
1065
1134
  "THIN_RESPONSE_INVALID", "Studio returned invalid search job coordinates"
@@ -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