cmpnd 0.30.0__tar.gz → 0.31.0__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 (352) hide show
  1. {cmpnd-0.30.0 → cmpnd-0.31.0}/AGENTS.md +25 -0
  2. {cmpnd-0.30.0 → cmpnd-0.31.0}/PKG-INFO +1 -1
  3. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_assessments.py +6 -4
  4. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/callback.py +27 -11
  5. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/assessments.py +20 -6
  6. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/client.py +9 -2
  7. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/helpers.py +31 -0
  8. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/cli.md +10 -4
  9. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/client.md +4 -0
  10. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/sdk-instrumentation.md +5 -0
  11. {cmpnd-0.30.0 → cmpnd-0.31.0}/pyproject.toml +1 -1
  12. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_assessments_cli.py +9 -3
  13. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_valsets_cli.py +6 -1
  14. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/_readiness.py +57 -7
  15. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_readiness.py +82 -0
  16. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_trace_search.py +25 -4
  17. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_assessments_api.py +14 -3
  18. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_assessments.py +80 -4
  19. cmpnd-0.31.0/tests/test_gepa_output_normalization.py +306 -0
  20. {cmpnd-0.30.0 → cmpnd-0.31.0}/uv.lock +1 -1
  21. {cmpnd-0.30.0 → cmpnd-0.31.0}/.gitignore +0 -0
  22. {cmpnd-0.30.0 → cmpnd-0.31.0}/CLAUDE.md +0 -0
  23. {cmpnd-0.30.0 → cmpnd-0.31.0}/README.md +0 -0
  24. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/__init__.py +0 -0
  25. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_authored_import.py +0 -0
  26. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_deployments.py +0 -0
  27. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_exporter_pool.py +0 -0
  28. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_gepa_patch.py +0 -0
  29. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_http.py +0 -0
  30. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_program_patch.py +0 -0
  31. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_rlm_patch.py +0 -0
  32. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_runtime_tools.py +0 -0
  33. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_timespec.py +0 -0
  34. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_traces.py +0 -0
  35. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/_valsets.py +0 -0
  36. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/authored.py +0 -0
  37. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/__init__.py +0 -0
  38. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/api_client.py +0 -0
  39. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/__init__.py +0 -0
  40. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/admin.py +0 -0
  41. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/auth.py +0 -0
  42. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/datasets.py +0 -0
  43. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/deployments.py +0 -0
  44. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/evals.py +0 -0
  45. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/login.py +0 -0
  46. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/optimizations.py +0 -0
  47. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/org.py +0 -0
  48. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/programs.py +0 -0
  49. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/provider_credentials.py +0 -0
  50. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/signatures.py +0 -0
  51. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/tool_servers.py +0 -0
  52. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/top.py +0 -0
  53. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/traces.py +0 -0
  54. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/commands/valsets.py +0 -0
  55. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/credentials.py +0 -0
  56. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/deprecation.py +0 -0
  57. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/pagination.py +0 -0
  58. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/shell.py +0 -0
  59. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/trace_render.py +0 -0
  60. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/tree_help.py +0 -0
  61. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/cli/verbs.py +0 -0
  62. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/configuration.py +0 -0
  63. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/context.py +0 -0
  64. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/dataset_sync.py +0 -0
  65. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/datasets.py +0 -0
  66. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/decorators.py +0 -0
  67. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/deployment.py +0 -0
  68. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/deployment_metadata.py +0 -0
  69. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/eval_handler.py +0 -0
  70. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/execution.py +0 -0
  71. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/exporter.py +0 -0
  72. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/exporter_http.py +0 -0
  73. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/identity.py +0 -0
  74. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/imaging.py +0 -0
  75. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/__init__.py +0 -0
  76. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/decode.py +0 -0
  77. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/encode.py +0 -0
  78. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/hash.py +0 -0
  79. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/program.py +0 -0
  80. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/reflection.py +0 -0
  81. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/types.py +0 -0
  82. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/ir/xxh64.py +0 -0
  83. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/lm_configuration.py +0 -0
  84. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/mcp_servers.py +0 -0
  85. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/models.py +0 -0
  86. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/optimization.py +0 -0
  87. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/optimizers/__init__.py +0 -0
  88. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/optimizers/gepa.py +0 -0
  89. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/optimizers/gepa_callback.py +0 -0
  90. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/optimizers/resume.py +0 -0
  91. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/optimizers/tracker.py +0 -0
  92. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/packaging.py +0 -0
  93. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/references.py +0 -0
  94. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/retry.py +0 -0
  95. {cmpnd-0.30.0 → cmpnd-0.31.0}/cmpnd/traces.py +0 -0
  96. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/2026-04-02-code-review.md +0 -0
  97. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/custom-proposer.md +0 -0
  98. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  99. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  100. {cmpnd-0.30.0 → cmpnd-0.31.0}/docs/reading-traces.md +0 -0
  101. {cmpnd-0.30.0 → cmpnd-0.31.0}/examples/generalizing_proposer.py +0 -0
  102. {cmpnd-0.30.0 → cmpnd-0.31.0}/examples/local_ollama.py +0 -0
  103. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/LINEAGE.md +0 -0
  104. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/README.md +0 -0
  105. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/cmpnd_ingest/__init__.py +0 -0
  106. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/cmpnd_ingest/bodies.py +0 -0
  107. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/cmpnd_ingest/exporter_http.py +0 -0
  108. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/cmpnd_ingest/protocols.py +0 -0
  109. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/cmpnd_ingest/py.typed +0 -0
  110. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/cmpnd_ingest/request_log.py +0 -0
  111. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/cmpnd_ingest/retry.py +0 -0
  112. {cmpnd-0.30.0 → cmpnd-0.31.0}/ingest/pyproject.toml +0 -0
  113. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/audit-sdist.sh +0 -0
  114. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/capture_review_run.py +0 -0
  115. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/check_release_artifacts.py +0 -0
  116. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/fixtures/review_run.json.gz +0 -0
  117. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/gepa_resume_bug.py +0 -0
  118. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/release-stress.sh +0 -0
  119. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/require_version_bump.py +0 -0
  120. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/seed_review_run.py +0 -0
  121. {cmpnd-0.30.0 → cmpnd-0.31.0}/scripts/test-in-linux.sh +0 -0
  122. {cmpnd-0.30.0 → cmpnd-0.31.0}/stubs/dspy/__init__.pyi +0 -0
  123. {cmpnd-0.30.0 → cmpnd-0.31.0}/stubs/dspy/primitives/__init__.pyi +0 -0
  124. {cmpnd-0.30.0 → cmpnd-0.31.0}/stubs/dspy/primitives/prediction.pyi +0 -0
  125. {cmpnd-0.30.0 → cmpnd-0.31.0}/stubs/dspy/utils/__init__.pyi +0 -0
  126. {cmpnd-0.30.0 → cmpnd-0.31.0}/stubs/dspy/utils/callback.pyi +0 -0
  127. {cmpnd-0.30.0 → cmpnd-0.31.0}/stubs/dspy/utils/dummies.pyi +0 -0
  128. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/__init__.py +0 -0
  129. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/_deployment_signature_helpers.py +0 -0
  130. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/_exporter_helpers.py +0 -0
  131. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/_fake_clock.py +0 -0
  132. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/_identity_helpers.py +0 -0
  133. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/_resolve_refs.py +0 -0
  134. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/_traced_run.py +0 -0
  135. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/conftest.py +0 -0
  136. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/__init__.py +0 -0
  137. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/committee_task.py +0 -0
  138. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/conftest.py +0 -0
  139. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  140. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  141. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/fake_lm.py +0 -0
  142. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/helpers.py +0 -0
  143. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/target_capabilities.py +0 -0
  144. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_capture_prompts.py +0 -0
  145. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_composite_module.py +0 -0
  146. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
  147. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_deploy_execute.py +0 -0
  148. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_deploy_programs.py +0 -0
  149. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_edge_cases.py +0 -0
  150. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_example_hash.py +0 -0
  151. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_gepa_optimization.py +0 -0
  152. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_interpreter_spans.py +0 -0
  153. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_lm_accuracy.py +0 -0
  154. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_module_tracing.py +0 -0
  155. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_optimization_lineage.py +0 -0
  156. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
  157. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_optimize_failures.py +0 -0
  158. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_optimize_loop.py +0 -0
  159. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_optimize_review_committee.py +0 -0
  160. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  161. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_parallel_export.py +0 -0
  162. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_project_attribution.py +0 -0
  163. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_react_tool_spans.py +0 -0
  164. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_save_load.py +0 -0
  165. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_trace_structure.py +0 -0
  166. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_traces_read.py +0 -0
  167. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/e2e/test_user_attribution.py +0 -0
  168. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/01_predict_qa.json +0 -0
  169. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  170. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  171. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  172. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  173. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  174. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
  175. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/08_opaque_module.json +0 -0
  176. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  177. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/10_react.json +0 -0
  178. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/11_refine.json +0 -0
  179. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/12_retrieve.json +0 -0
  180. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/13_knn.json +0 -0
  181. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/14_parallel.json +0 -0
  182. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/identity_vectors/README.md +0 -0
  183. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/ingest_conformance.py +0 -0
  184. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/__init__.py +0 -0
  185. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/authored_optimize/judge.py +0 -0
  186. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/authored_optimize/optimize_hosted.py +0 -0
  187. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/conftest.py +0 -0
  188. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/hosted_mcp_tool_optimize_probe.py +0 -0
  189. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/hosted_optimize_probe.py +0 -0
  190. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/optimization_run_probe.py +0 -0
  191. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  192. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/test_logs_renders_traces.py +0 -0
  193. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/test_optimization_run.py +0 -0
  194. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/test_optimizing_progress.py +0 -0
  195. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/integration/test_unhappy_path.py +0 -0
  196. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/__init__.py +0 -0
  197. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/__main__.py +0 -0
  198. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/drain.py +0 -0
  199. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/ingest.py +0 -0
  200. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/latency_transport.py +0 -0
  201. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/rlm_shape.py +0 -0
  202. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/rlm_size_report.py +0 -0
  203. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/sweep.py +0 -0
  204. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/test_rlm_shape.py +0 -0
  205. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/load/test_rlm_size_report.py +0 -0
  206. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/__init__.py +0 -0
  207. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/conftest.py +0 -0
  208. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/seed_parity.py +0 -0
  209. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_auth.py +0 -0
  210. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_datasets.py +0 -0
  211. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_deployments.py +0 -0
  212. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_evals.py +0 -0
  213. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_health.py +0 -0
  214. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_modules.py +0 -0
  215. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_optimizations.py +0 -0
  216. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_programs.py +0 -0
  217. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_signatures.py +0 -0
  218. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_trace_streaming.py +0 -0
  219. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/parity/test_traces.py +0 -0
  220. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/span_reference_fixture.py +0 -0
  221. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/__init__.py +0 -0
  222. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/_provider.py +0 -0
  223. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/_reliability.py +0 -0
  224. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/_result.py +0 -0
  225. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/mint_bedrock_token.py +0 -0
  226. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_deploy_programs.py +0 -0
  227. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_multi_client_stress.py +0 -0
  228. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_optimize_client_committee.py +0 -0
  229. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_optimize_loop.py +0 -0
  230. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_optimize_loop_wasm.py +0 -0
  231. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_optimize_stress.py +0 -0
  232. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_provider_roles.py +0 -0
  233. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_real_provider_twins.py +0 -0
  234. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_reliability.py +0 -0
  235. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_reliability_classify.py +0 -0
  236. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/stress/test_rlm_wasm.py +0 -0
  237. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_adapter_reentrancy.py +0 -0
  238. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_authored.py +0 -0
  239. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_authored_bundle_collisions.py +0 -0
  240. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_authored_metric_contract.py +0 -0
  241. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_authored_named_models.py +0 -0
  242. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_authored_optimize.py +0 -0
  243. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_callback.py +0 -0
  244. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_candidate_lineage.py +0 -0
  245. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/__init__.py +0 -0
  246. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/conftest.py +0 -0
  247. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_admin.py +0 -0
  248. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_api_client.py +0 -0
  249. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_auth.py +0 -0
  250. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_credentials.py +0 -0
  251. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_datasets.py +0 -0
  252. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_deployments.py +0 -0
  253. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_evals.py +0 -0
  254. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_login.py +0 -0
  255. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_main.py +0 -0
  256. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_optimizations.py +0 -0
  257. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_org.py +0 -0
  258. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_pagination.py +0 -0
  259. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_parity.py +0 -0
  260. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_programs.py +0 -0
  261. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_provider_credentials.py +0 -0
  262. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_schema_sample.py +0 -0
  263. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_shell.py +0 -0
  264. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_signatures.py +0 -0
  265. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_tool_servers.py +0 -0
  266. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_top.py +0 -0
  267. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_traces.py +0 -0
  268. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_traces_search_wire.py +0 -0
  269. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_tree_help.py +0 -0
  270. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cli/test_valsets.py +0 -0
  271. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_client.py +0 -0
  272. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_clustering/__init__.py +0 -0
  273. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_clustering/conftest.py +0 -0
  274. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_clustering/test_edge_cases.py +0 -0
  275. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_clustering/test_hash_consistency.py +0 -0
  276. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_clustering/test_type_conversion.py +0 -0
  277. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_cmp551_grouping_identity.py +0 -0
  278. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_configuration.py +0 -0
  279. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_context.py +0 -0
  280. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_context_propagation.py +0 -0
  281. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_dataset_polling.py +0 -0
  282. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_dataset_sync.py +0 -0
  283. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_datasets.py +0 -0
  284. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_decorators.py +0 -0
  285. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_deploy.py +0 -0
  286. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_deploy_mcp_tool_references.py +0 -0
  287. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_deployment_metadata.py +0 -0
  288. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_deployments_read.py +0 -0
  289. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_eval.py +0 -0
  290. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_eval_example_attribution.py +0 -0
  291. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_eval_start.py +0 -0
  292. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_eval_trace_grouping.py +0 -0
  293. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_exception_paths.py +0 -0
  294. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_execute.py +0 -0
  295. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_exporter.py +0 -0
  296. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_exporter_queue.py +0 -0
  297. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_exporter_real.py +0 -0
  298. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_fake_lm.py +0 -0
  299. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_gepa_callback.py +0 -0
  300. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_gepa_integration.py +0 -0
  301. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_gepa_resume.py +0 -0
  302. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_identity.py +0 -0
  303. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_imaging.py +0 -0
  304. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_import_weight.py +0 -0
  305. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_ingest_conformance.py +0 -0
  306. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_ingest_second_producer.py +0 -0
  307. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_integration.py +0 -0
  308. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_interpreter_callback.py +0 -0
  309. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_ir_encoder_properties.py +0 -0
  310. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_ir_golden_vectors.py +0 -0
  311. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_ir_roundtrip.py +0 -0
  312. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_lm_configuration.py +0 -0
  313. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_lm_usage_shapes.py +0 -0
  314. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_mcp_tools.py +0 -0
  315. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_models.py +0 -0
  316. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimization_progress.py +0 -0
  317. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimization_start.py +0 -0
  318. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimize.py +0 -0
  319. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimize_budget.py +0 -0
  320. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimize_lift.py +0 -0
  321. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimize_lms.py +0 -0
  322. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimize_proposer.py +0 -0
  323. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimize_reflection_lm.py +0 -0
  324. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_optimize_terminal_failure.py +0 -0
  325. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_packaging.py +0 -0
  326. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_packaging_callable_source.py +0 -0
  327. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_packaging_mcp_tools.py +0 -0
  328. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_packaging_program_source.py +0 -0
  329. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_poll_until.py +0 -0
  330. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_program_lineage_persist.py +0 -0
  331. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_project_payload.py +0 -0
  332. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_reflector_properties.py +0 -0
  333. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_reflector_smoke.py +0 -0
  334. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_release_stress_script.py +0 -0
  335. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_repro_reactv2_bugs.py +0 -0
  336. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_require_version_bump.py +0 -0
  337. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_retry.py +0 -0
  338. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_reversible_failures.py +0 -0
  339. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_rlm_patch.py +0 -0
  340. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_runtime_tools.py +0 -0
  341. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_schemas.py +0 -0
  342. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_seed_review_run.py +0 -0
  343. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_span_references.py +0 -0
  344. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_span_sent_once.py +0 -0
  345. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_trace_render.py +0 -0
  346. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_traces_read.py +0 -0
  347. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_tracked_gepa.py +0 -0
  348. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_tracker_enrich.py +0 -0
  349. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_truncation.py +0 -0
  350. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_valsets_api.py +0 -0
  351. {cmpnd-0.30.0 → cmpnd-0.31.0}/tests/test_xxh64.py +0 -0
  352. {cmpnd-0.30.0 → cmpnd-0.31.0}/todo.md +0 -0
@@ -119,6 +119,31 @@ uv run ruff check . # lint
119
119
  uv run ruff format --check . # format check
120
120
  ```
121
121
 
122
+ **Never run pytest processes in parallel** — no `pytest-xdist`, no backgrounding
123
+ one run while starting another. DSPy is memory-heavy on import, and parallel
124
+ pytest crashes the machine. Run suites serially.
125
+
126
+ **Spec every mock, and assert wire shape over a real client.** A spec-less
127
+ `MagicMock` answers any method with any arguments, so an assertion about how a
128
+ dependency was called is worth exactly the mock's fidelity to it. That shipped
129
+ a live bug: the CLI dispatched a revocation as `httpx.Client.delete(url,
130
+ json=body)`, `httpx` gives `delete` no `json` parameter, the body never left the
131
+ process, and the test asserting the call passed. Two rules, and both are needed:
132
+
133
+ - Build every double from the real signature — `create_autospec(Thing,
134
+ instance=True)` or `mock.patch(..., autospec=True)`, as
135
+ `tests/test_cli/conftest.py` does for `ApiClient`.
136
+ - Where the property under test is what goes on the wire, drive a real
137
+ `httpx.Client` over an `httpx.MockTransport` that captures the request and
138
+ assert on the captured bytes — `tests/test_cli/test_api_client.py` is the
139
+ pattern. Autospec guards only the seam you point it at; the bug above lived
140
+ one layer below an autospec'd client.
141
+
142
+ **E2E tests hit a live server.** `tests/e2e/` runs against the real platform
143
+ with real LLMs (`CMPND_E2E_*` keys), never a mock or an in-process fake: its
144
+ job is the integration bugs between the SDK and the real API surface. It is
145
+ local-only by design.
146
+
122
147
  ## Two packages, one published distribution
123
148
 
124
149
  `cmpnd` (`cmpnd/`) is the SDK. `cmpnd_ingest` (`ingest/cmpnd_ingest/`) is the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cmpnd
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
4
  Summary: DSPy observability and deployment SDK for cmpnd
5
5
  Project-URL: Homepage, https://cmpnd.ai
6
6
  Author-email: cmpnd <hello@cmpnd.ai>
@@ -25,15 +25,17 @@ def create(
25
25
  *,
26
26
  conclusion: Conclusion = "neutral",
27
27
  body: str | None = None,
28
- annotations: Sequence[tuple[str, str]] | None = None,
28
+ annotations: Sequence[tuple[str, str, str]] | None = None,
29
29
  api_key: str | None = None,
30
30
  endpoint: str | None = None,
31
31
  ) -> dict[str, Any]:
32
32
  """Record one judgement of a trace and return the created assessment.
33
33
 
34
34
  ``conclusion`` affirms (``"up"``), disputes (``"down"``), or neither
35
- (``"neutral"``, the default). ``annotations`` are ``(span_id, note)`` pairs,
36
- each attaching a note to one span of the trace; the server rejects a span id
35
+ (``"neutral"``, the default). ``annotations`` are ``(kind, ref, note)``
36
+ tuples where ``kind`` is ``"span"`` (``ref`` is a span id) or ``"output"``
37
+ (``ref`` is an RFC 6901 JSON pointer such as ``/trends/2``); the tuple order
38
+ matches the CLI's ``--annotate KIND REF NOTE``. The server rejects a target
37
39
  that does not belong to the trace. ``api_key`` / ``endpoint`` override the
38
40
  configured credentials for this call.
39
41
  """
@@ -41,7 +43,7 @@ def create(
41
43
  if body is not None:
42
44
  payload["body"] = body
43
45
  if annotations:
44
- payload["annotations"] = [{"span_id": span, "note": note} for span, note in annotations]
46
+ payload["annotations"] = [{"kind": kind, "ref": ref, "note": note} for kind, ref, note in annotations]
45
47
  return _client(api_key, endpoint).post("/api/v1/assessments", json=payload)
46
48
 
47
49
 
@@ -499,6 +499,11 @@ class _CallbackBase:
499
499
  # Track trace roots
500
500
  self._root_call_ids: set[str] = set()
501
501
 
502
+ # GEPA's bootstrap tracer temporarily patches the evaluated program's
503
+ # forward method to return (prediction, bootstrap trace). Track that
504
+ # wrapper itself so ordinary tuples at roots or under Evaluate stay intact.
505
+ self._gepa_bootstrap_call_ids: set[str] = set()
506
+
502
507
  # Track spans by trace_id for metric aggregation
503
508
  self._trace_spans: dict[str, list[models.SpanBuilder]] = {}
504
509
 
@@ -682,6 +687,8 @@ class _ModuleMixin(_CallbackBase):
682
687
  # of them, so a module call beneath one roots its own trace. An open
683
688
  # trace still wins: a user's `cmpnd.trace` around an Evaluate owns it.
684
689
  parent_span = context.get_current_span()
690
+ if helpers.is_gepa_bootstrap_wrapper(instance):
691
+ self._gepa_bootstrap_call_ids.add(call_id)
685
692
  is_root = parent_span is None or (
686
693
  parent_span.span_type == models.SpanType.EVALUATION and context.get_current_trace() is None
687
694
  )
@@ -784,11 +791,22 @@ class _ModuleMixin(_CallbackBase):
784
791
 
785
792
  span = self._call_id_to_span.pop(call_id, None)
786
793
  instance = self._call_id_to_instance.pop(call_id, None)
794
+ is_gepa_capture_boundary = call_id in self._gepa_bootstrap_call_ids
795
+ self._gepa_bootstrap_call_ids.discard(call_id)
787
796
 
788
797
  if not span:
789
798
  logger.warning(f"No span found for call_id: {call_id}")
790
799
  return
791
800
 
801
+ in_optimization = (
802
+ self._active_optimization_run_id is not None or context.get_current_optimization_run_id() is not None
803
+ )
804
+ captured_outputs = (
805
+ helpers.normalize_gepa_bootstrap_output(outputs, dspy, in_optimization=in_optimization)
806
+ if is_gepa_capture_boundary
807
+ else outputs
808
+ )
809
+
792
810
  # Set end time and status
793
811
  span.end_time = datetime.now(timezone.utc)
794
812
 
@@ -798,12 +816,12 @@ class _ModuleMixin(_CallbackBase):
798
816
  span.status = models.TraceStatus.OK
799
817
 
800
818
  # Capture outputs
801
- if self._capture_outputs and outputs is not None:
802
- cleaned_outputs = helpers.clean_outputs(outputs, instance, dspy, self._max_output_field_size)
819
+ if self._capture_outputs and captured_outputs is not None:
820
+ cleaned_outputs = helpers.clean_outputs(captured_outputs, instance, dspy, self._max_output_field_size)
803
821
  span.set_outputs(cleaned_outputs)
804
822
 
805
823
  # Extract token usage if available
806
- self._extract_usage_from_prediction(span, outputs, dspy)
824
+ self._extract_usage_from_prediction(span, captured_outputs, dspy)
807
825
 
808
826
  # Pop from context stack
809
827
  context.pop_span()
@@ -851,10 +869,10 @@ class _ModuleMixin(_CallbackBase):
851
869
  # Skip "FINAL: ..." outputs — these contain the entire RLM result
852
870
  # dict and are not meaningful code cell outputs.
853
871
  if span.span_type == models.SpanType.RLM:
854
- if rlm_parent and rlm_parent.repl_history and outputs is not None:
872
+ if rlm_parent and rlm_parent.repl_history and captured_outputs is not None:
855
873
  try:
856
874
  last_output = None
857
- traj = outputs.trajectory if hasattr(outputs, "trajectory") else None
875
+ traj = captured_outputs.trajectory if hasattr(captured_outputs, "trajectory") else None
858
876
  if traj and hasattr(traj, "entries") and traj.entries:
859
877
  last_output = traj.entries[-1].output
860
878
  elif isinstance(traj, list) and traj:
@@ -893,10 +911,8 @@ class _ModuleMixin(_CallbackBase):
893
911
  trace.error_message = str(exception)
894
912
 
895
913
  # Set response preview
896
- if outputs is not None and self._capture_outputs:
897
- trace.response_preview = helpers.create_preview(
898
- helpers.clean_outputs(outputs, instance, dspy, self._max_output_field_size)
899
- )
914
+ if captured_outputs is not None and self._capture_outputs:
915
+ trace.response_preview = helpers.create_preview(span.outputs or {})
900
916
 
901
917
  # Aggregate span metrics into trace
902
918
  trace_id_str = str(trace.trace_id)
@@ -950,9 +966,9 @@ class _ModuleMixin(_CallbackBase):
950
966
  # store and surface as a phantom output field. Read it back via the
951
967
  # public `cmpnd.trace_id(pred)` accessor. Best-effort: a non-Prediction
952
968
  # or immutable return must never break the call.
953
- if outputs is not None:
969
+ if captured_outputs is not None:
954
970
  try:
955
- outputs._cmpnd_trace_id = trace_id_str
971
+ captured_outputs._cmpnd_trace_id = trace_id_str
956
972
  except (AttributeError, TypeError):
957
973
  pass
958
974
 
@@ -1,7 +1,10 @@
1
1
  """Assessments commands.
2
2
 
3
3
  An assessment is a person's judgement of a trace: an up/down/neutral
4
- conclusion, a prose body, and per-span annotations. See the assessments API.
4
+ conclusion, a prose body, and annotations. An annotation attaches a note to a
5
+ typed target, given as ``--annotate KIND REF NOTE``. KIND is ``span`` (REF is a
6
+ span id) or ``output`` (REF is an RFC 6901 JSON pointer such as /trends/2). See
7
+ the assessments API.
5
8
  """
6
9
 
7
10
  from __future__ import annotations
@@ -11,6 +14,9 @@ from typing import Any
11
14
 
12
15
  from .. import api_client, pagination
13
16
 
17
+ # The annotation kinds --annotate accepts; sent through to the wire unchanged.
18
+ _ANNOTATION_KINDS = {"span", "output"}
19
+
14
20
 
15
21
  def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
16
22
  parser = subparsers.add_parser("assessments", help="Record and read judgements of traces")
@@ -38,9 +44,10 @@ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
38
44
  "--annotate",
39
45
  dest="annotations",
40
46
  action="append",
41
- nargs=2,
42
- metavar=("SPAN_ID", "NOTE"),
43
- help="Attach a note to one span in the trace (repeat for multiple)",
47
+ nargs=3,
48
+ metavar=("KIND", "REF", "NOTE"),
49
+ help="Attach a note to a target: KIND is 'span' (REF is a span id) or "
50
+ "'output' (REF is an RFC 6901 JSON pointer like /trends/2). Repeat for multiple.",
44
51
  )
45
52
  create.set_defaults(func=handle_create)
46
53
 
@@ -68,8 +75,15 @@ def handle_create(client: api_client.ApiClient, args: Any) -> Any:
68
75
  body: dict[str, Any] = {"trace_id": args.trace_id, "conclusion": args.conclusion}
69
76
  if args.body:
70
77
  body["body"] = args.body
71
- if args.annotations:
72
- body["annotations"] = [{"span_id": span, "note": note} for span, note in args.annotations]
78
+ annotations: list[dict[str, str]] = []
79
+ for kind, ref, note in args.annotations or []:
80
+ if kind not in _ANNOTATION_KINDS:
81
+ raise api_client.CliError(
82
+ f"--annotate KIND must be one of: {', '.join(sorted(_ANNOTATION_KINDS))} (got {kind!r})"
83
+ )
84
+ annotations.append({"kind": kind, "ref": ref, "note": note})
85
+ if annotations:
86
+ body["annotations"] = annotations
73
87
  return client.post("/api/v1/assessments", json=body)
74
88
 
75
89
 
@@ -181,9 +181,16 @@ class Client:
181
181
  *,
182
182
  conclusion: Literal["up", "down", "neutral"] = "neutral",
183
183
  body: str | None = None,
184
- annotations: list[tuple[str, str]] | None = None,
184
+ annotations: list[tuple[str, str, str]] | None = None,
185
185
  ) -> dict[str, Any]:
186
- """Record a judgement of a trace. An omitted conclusion is neutral."""
186
+ """Record a judgement of a trace. An omitted conclusion is neutral.
187
+
188
+ ``annotations`` attach notes to typed targets, each a ``(kind, ref,
189
+ note)`` tuple — ``kind`` is ``"span"`` (``ref`` is a span id) or
190
+ ``"output"`` (``ref`` is an RFC 6901 JSON pointer like ``/trends/2``),
191
+ e.g. ``annotations=[("output", "/trends/2", "the third trend"),
192
+ ("span", "a1b2c3", "wrong field")]``.
193
+ """
187
194
  self._check_active()
188
195
  _exporter_pool.flush(self._config) # the referenced trace must be durable first
189
196
  return _assessments.create(
@@ -45,6 +45,37 @@ def clean_inputs(inputs: dict[str, Any], _dspy: Any, max_field_size: int) -> dic
45
45
  return result
46
46
 
47
47
 
48
+ def is_gepa_bootstrap_wrapper(instance: Any) -> bool:
49
+ """Return whether DSPy's bootstrap tracer temporarily patched this module."""
50
+ forward = getattr(instance, "forward", None)
51
+ function = getattr(forward, "__func__", forward)
52
+ return (
53
+ getattr(function, "__module__", None) == "dspy.teleprompt.bootstrap_trace"
54
+ and getattr(function, "__name__", None) == "patched_forward"
55
+ )
56
+
57
+
58
+ def normalize_gepa_bootstrap_output(outputs: Any, dspy: Any, *, in_optimization: bool) -> Any:
59
+ """Return the prediction from a GEPA ``(prediction, trace)`` result pair."""
60
+ if not in_optimization or not isinstance(outputs, tuple) or len(outputs) != 2:
61
+ return outputs
62
+
63
+ prediction, trace = outputs
64
+ if not isinstance(trace, list):
65
+ return outputs
66
+
67
+ for entry in trace:
68
+ if (
69
+ not isinstance(entry, tuple)
70
+ or len(entry) != 3
71
+ or not isinstance(entry[0], dspy.Module)
72
+ or not isinstance(entry[1], dict)
73
+ ):
74
+ return outputs
75
+
76
+ return prediction
77
+
78
+
48
79
  def clean_outputs(outputs: Any, _instance: Any, dspy: Any, max_field_size: int) -> dict[str, Any]:
49
80
  """Clean and prepare outputs for storage, with per-field truncation."""
50
81
  if isinstance(outputs, dspy.Prediction):
@@ -123,19 +123,25 @@ warn. On the page-number commands `--offset` must be a whole multiple of
123
123
  ## Assessments
124
124
 
125
125
  `cmpnd assessments` records and reads a person's judgement of a trace: an
126
- up/down/neutral conclusion, an optional prose body, and per-span annotations.
126
+ up/down/neutral conclusion, an optional prose body, and annotations. An
127
+ annotation attaches a note to a typed target — a span, or a piece of the output
128
+ addressed by a JSON pointer.
127
129
 
128
130
  > **In development — behind `CMPND_CLI_DEV=1`.** The group is registered only
129
131
  > when that env var is set (mirroring `CMPND_CLI_ADMIN`), so it stays hidden from
130
132
  > `cmpnd --help` until the flow is finished. The env var only hides the commands;
131
133
  > the server still gates each action on the `assessments:*` scope.
132
134
 
133
- - `cmpnd assessments create <trace-id> [--up|--down|--neutral] [-m "prose"] [--annotate <span-id> "note" …]`
135
+ - `cmpnd assessments create <trace-id> [--up|--down|--neutral] [-m "prose"] [--annotate KIND REF NOTE …]`
134
136
  — record one judgement. The conclusion flag is optional and mutually exclusive
135
137
  (`--up` affirms the trace's output, `--down` disputes it, `--neutral` — the
136
138
  default when none is given — does neither). `-m`/`--message` sets the prose
137
- body. `--annotate` takes a span id and a note, and repeats to attach a note to
138
- more than one span in the trace.
139
+ body. `--annotate` takes three values: `KIND` is `span` (`REF` is a span id) or
140
+ `output` (`REF` is an RFC 6901 JSON pointer such as `/trends/2` into the trace's
141
+ root-span outputs), and `NOTE` is the prose. It repeats, so a judgement can
142
+ carry several annotations of either kind in the order given. The server rejects
143
+ a span id absent from the trace and a pointer that does not resolve against the
144
+ outputs.
139
145
  - `cmpnd assessments list (<program> | --trace <trace-id>) [--limit N] [--offset N]`
140
146
  — the assessments recorded against a program (the program signature hash is
141
147
  positional, in the 16-char hex form `cmpnd signatures list` prints) or against
@@ -85,6 +85,10 @@ platform's data, so the whole surface is one `dir(sdk)` away:
85
85
  - **Assessments** — `sdk.create_assessment(trace_id, conclusion=..., body=..., annotations=...)`,
86
86
  `sdk.list_assessments(program=... | trace_id=..., limit=..., offset=...)`,
87
87
  `sdk.get_assessment(assessment_id)`, `sdk.delete_assessment(assessment_id)`.
88
+ `annotations` are `(kind, ref, note)` tuples — `kind` is `"span"` (`ref` is a
89
+ span id) or `"output"` (`ref` is an RFC 6901 pointer such as `/trends/2`), e.g.
90
+ `annotations=[("output", "/trends/2", "the third trend"), ("span", "a1b2c3",
91
+ "wrong field")]`.
88
92
  - **Valsets** — `sdk.create_valset(program, name=...)`,
89
93
  `sdk.list_valsets(program, limit=..., offset=...)`, `sdk.get_valset(valset_id)`,
90
94
  `sdk.list_valset_entries(valset_id, limit=..., offset=...)`,
@@ -86,6 +86,11 @@ The hook methods, in the order you'd expect them to fire for a typical `dspy.Cha
86
86
  - **Interpreter (code execution)** — `_InterpreterMixin` (`callback.py:1119`), for the DSPy `CodeInterpreter` hooks DSPy >= 3.3.1 fires around a program running code in the interpreter (`RLM`, `ProgramOfThought`, `CodeAct`, `Flex` — every module reaching it through `_create_interpreter`; `ReAct` / `ReActV2` use no interpreter and fire none). `on_interpreter_execute_start` / `_end` emit one `INTERPRETER_EXECUTE` span per REPL cell — the code in, its captured stdout out, and the `SUBMIT()` result (a DSPy `FinalOutput`) recorded distinctly from stdout. `on_interpreter_tool_call_start` / `_end` synthesize a `TOOL` span for a tool called from interpreter code **only when no real `dspy.Tool` span covered the call** (a user tool routes through `Tool.__call__` and fires `on_tool_*`, so its row is suppressed to avoid a duplicate; the `llm_query` builtins fire no tool hook, so they get the synthesized span). `on_interpreter_startup_*` / `on_interpreter_shutdown_*` emit no span — their duration folds into an `interpreter.startup_ms` / `interpreter.shutdown_ms` attribute on the enclosing span, and an event with no enclosing span (a pooled pre-warm) is dropped.
87
87
  - **Evaluate** — `on_evaluate_start` (`callback.py:773`) / `on_evaluate_end` (`callback.py:782`). These are thin forwarders that delegate to `EvalHandler` (see "Eval handling" below).
88
88
 
89
+ During a GEPA evaluation, DSPy wraps the program prediction with its internal
90
+ bootstrap trace. The callback recognizes this pair only while an optimization
91
+ is active. It records the prediction on the parent span and leaves the internal
92
+ trace available to GEPA.
93
+
89
94
  Not inheriting the base class costs the free no-op every hook would otherwise
90
95
  have. DSPy resolves a handler with a bare `getattr(callback, "on_…")` and does
91
96
  not guard for a callback that omits one, so a DSPy release that adds hooks turns
@@ -5,7 +5,7 @@ name = "cmpnd"
5
5
  # it's new) — no git tags. Bump with `uv version --bump {minor,patch}` or by
6
6
  # hand. Runtime `cmpnd.__version__` reads installed metadata, so it tracks
7
7
  # this automatically. See docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md.
8
- version = "0.30.0"
8
+ version = "0.31.0"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -44,14 +44,18 @@ class TestAssessmentsCliE2E:
44
44
 
45
45
  client = api_client.ApiClient(CMPND_E2E_API_KEY, E2E_ENDPOINT)
46
46
 
47
- # create — a down judgement with one span annotation.
47
+ # create — a down judgement with one span annotation and one output
48
+ # pointer into the root span's `answer` output.
48
49
  created = assessments.handle_create(
49
50
  client,
50
51
  _ns(
51
52
  trace_id=trace_id,
52
53
  conclusion="down",
53
54
  body="the answer names the office, not the representative",
54
- annotations=[[span_id, "this predict returned the wrong field"]],
55
+ annotations=[
56
+ ["span", span_id, "this predict returned the wrong field"],
57
+ ["output", "/answer", "the answer field is the wrong value"],
58
+ ],
55
59
  ),
56
60
  )
57
61
  assert created["id"].startswith("as_")
@@ -59,7 +63,9 @@ class TestAssessmentsCliE2E:
59
63
  # The program hash the server copied off the trace round-trips as the same
60
64
  # 16-hex string the trace read returned — the CLI passes it through verbatim.
61
65
  assert created["program_signature_hash"] == program_hash
62
- assert created["annotations"][0]["span_id"] == span_id
66
+ anns = {(a["kind"], a["ref"]): a for a in created["annotations"]}
67
+ assert ("span", span_id) in anns
68
+ assert ("output", "/answer") in anns
63
69
 
64
70
  # list by program (the 16-hex form) finds the assessment we just created.
65
71
  listed = assessments.handle_list(client, _ns(program=program_hash, trace_id=None))
@@ -84,7 +84,12 @@ class TestValsetsCliE2E:
84
84
  # An assessment of the trace, then an entry created from it.
85
85
  assessment = assessments.handle_create(
86
86
  client,
87
- _ns(trace_id=trace_id, conclusion="down", body="corrected", annotations=[[span_id, "wrong field"]]),
87
+ _ns(
88
+ trace_id=trace_id,
89
+ conclusion="down",
90
+ body="corrected",
91
+ annotations=[["span", span_id, "wrong field"]],
92
+ ),
88
93
  )
89
94
  vs = valsets.handle_create(
90
95
  client, _ns(program_hash=program_hash, name=f"e2e from-assessment {uuid.uuid4().hex[:8]}")
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
+ import urllib.error
6
7
  from collections.abc import Callable, Collection, Mapping, Sequence
7
8
  from dataclasses import dataclass
8
9
  from typing import Generic, TypeAlias, TypeVar
@@ -99,21 +100,64 @@ def each_scope_has_real_model(
99
100
  )
100
101
 
101
102
 
103
+ def read_tolerating_timeout(
104
+ read: Callable[[RemainingTimeout], T],
105
+ unpublished: T,
106
+ ) -> Callable[[RemainingTimeout], T]:
107
+ """Wrap a read so a request timeout counts as "not published yet".
108
+
109
+ Only a timeout is tolerated: a read-phase ``TimeoutError`` (a socket timeout
110
+ after the request was sent) and a connect-phase ``urllib.error.URLError``
111
+ whose ``.reason`` is a timeout. Inside a bounded poll that is not a failure —
112
+ the value is simply not visible yet — so return ``unpublished`` and let
113
+ ``wait_for_publication`` poll again to the deadline, rather than crashing the
114
+ whole run on one slow request. Other ``URLError`` reasons (DNS, TLS,
115
+ connection refused) propagate: they are real misconfigurations to surface, not
116
+ "not published yet", so they must fail rather than be retried to the deadline.
117
+ Every other exception still surfaces too, so a real HTTP error (which the
118
+ callers raise as ``AssertionError``) still fails fast. ``socket.timeout`` is an
119
+ alias of ``TimeoutError`` on Python 3.10+, so the checks cover it. Pair with
120
+ ``wait_for_publication``'s ``read_timeout`` cap, which bounds how long each
121
+ such request can block before it is retried.
122
+ """
123
+
124
+ def wrapped(remaining_timeout: RemainingTimeout) -> T:
125
+ try:
126
+ return read(remaining_timeout)
127
+ except TimeoutError:
128
+ return unpublished
129
+ except urllib.error.URLError as exc:
130
+ # URLError also wraps DNS / TLS / connection-refused failures, which
131
+ # are real problems to surface — not "not published yet". Only a
132
+ # connect-phase timeout (its reason is a timeout) is tolerated.
133
+ if isinstance(exc.reason, TimeoutError):
134
+ return unpublished
135
+ raise
136
+
137
+ return wrapped
138
+
139
+
102
140
  def wait_for_publication(
103
141
  read: Callable[[RemainingTimeout], T],
104
142
  ready: Callable[[T], bool],
105
143
  *,
106
144
  timeout: float = 90.0,
107
145
  interval: float = 3.0,
146
+ read_timeout: float = 10.0,
108
147
  ) -> PublicationResult[T]:
109
148
  """Read until ``ready`` observes publication within one monotonic deadline.
110
149
 
111
- ``read`` receives ``remaining_timeout`` and must use a fresh positive value
112
- for each blocking HTTP request it makes. The HTTP client uses that value for
113
- network-operation timeouts. This helper rejects late results but does not
114
- cancel in-flight requests. Exceptions from either callback surface immediately.
115
- The returned value remains available for a scoped timeout diagnostic, but a
116
- result observed after the deadline is not accepted.
150
+ ``read`` receives a ``remaining_timeout`` callable and must use a fresh
151
+ positive value for each blocking HTTP request it makes; the HTTP client uses
152
+ that value for network-operation timeouts. That value is capped at
153
+ ``read_timeout`` so one slow request cannot consume the whole window — the loop
154
+ keeps polling to the deadline instead of blocking for the full budget in a
155
+ single attempt. ``read`` should treat a request timeout as "not published yet"
156
+ (wrap it with ``read_tolerating_timeout``) so a slow read is retried rather
157
+ than fatal; other exceptions from either callback surface immediately. This
158
+ helper rejects late results but does not cancel in-flight requests. The
159
+ returned value remains available for a scoped timeout diagnostic, but a result
160
+ observed after the deadline is not accepted.
117
161
  """
118
162
  deadline = time.monotonic() + timeout
119
163
  last_value: T | None = None
@@ -122,8 +166,14 @@ def wait_for_publication(
122
166
  remaining = deadline - time.monotonic()
123
167
  return remaining if remaining > 0 else None
124
168
 
169
+ def read_budget() -> float | None:
170
+ # Bound each request so a single slow read is capped and the loop can poll
171
+ # again, rather than spending the entire deadline waiting on one request.
172
+ remaining = remaining_timeout()
173
+ return None if remaining is None else min(remaining, read_timeout)
174
+
125
175
  while remaining_timeout() is not None:
126
- last_value = read(remaining_timeout)
176
+ last_value = read(read_budget)
127
177
  if remaining_timeout() is None:
128
178
  return PublicationResult(published=False, value=last_value)
129
179
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
+ import urllib.error
6
7
 
7
8
  import pytest
8
9
 
@@ -51,6 +52,87 @@ def test_wait_for_publication_returns_a_late_published_read(monkeypatch):
51
52
  assert clock.sleeps == [2.0]
52
53
 
53
54
 
55
+ def test_wait_for_publication_caps_each_read_below_the_full_budget(monkeypatch):
56
+ clock = install_fake_clock(monkeypatch)
57
+ response_timeouts: list[float | None] = []
58
+ responses = iter([[], [{"trace_id": "trace-1"}]])
59
+
60
+ def read(remaining_timeout):
61
+ response_timeouts.append(remaining_timeout())
62
+ return next(responses)
63
+
64
+ result = _readiness.wait_for_publication(read, bool, timeout=90.0, interval=3.0, read_timeout=10.0)
65
+
66
+ assert result.published
67
+ # Each request is bounded by read_timeout (10.0), never the whole remaining
68
+ # budget (90.0, then 87.0), so one slow read cannot spend the window.
69
+ assert response_timeouts == [10.0, 10.0]
70
+ assert clock.sleeps == [3.0]
71
+
72
+
73
+ def test_read_tolerating_timeout_treats_a_read_timeout_as_unpublished():
74
+ def read(_remaining_timeout):
75
+ raise TimeoutError("the read operation timed out")
76
+
77
+ wrapped = _readiness.read_tolerating_timeout(read, [])
78
+
79
+ assert wrapped(lambda: 5.0) == []
80
+
81
+
82
+ def test_read_tolerating_timeout_treats_a_connect_phase_timeout_as_unpublished():
83
+ def read(_remaining_timeout):
84
+ raise urllib.error.URLError(TimeoutError("timed out"))
85
+
86
+ assert _readiness.read_tolerating_timeout(read, [])(lambda: 5.0) == []
87
+
88
+
89
+ def test_read_tolerating_timeout_surfaces_a_non_timeout_urlerror():
90
+ def read(_remaining_timeout):
91
+ raise urllib.error.URLError(ConnectionRefusedError("refused"))
92
+
93
+ with pytest.raises(urllib.error.URLError):
94
+ _readiness.read_tolerating_timeout(read, [])(lambda: 5.0)
95
+
96
+
97
+ def test_read_tolerating_timeout_passes_a_successful_read_through():
98
+ def read(_remaining_timeout):
99
+ return ["value"]
100
+
101
+ assert _readiness.read_tolerating_timeout(read, [])(lambda: 5.0) == ["value"]
102
+
103
+
104
+ def test_read_tolerating_timeout_does_not_swallow_other_errors():
105
+ def read(_remaining_timeout):
106
+ raise RuntimeError("POST /api/v1/traces/search -> HTTP 500")
107
+
108
+ with pytest.raises(RuntimeError, match="HTTP 500"):
109
+ _readiness.read_tolerating_timeout(read, [])(lambda: 5.0)
110
+
111
+
112
+ def test_wait_for_publication_keeps_polling_after_a_read_timeout(monkeypatch):
113
+ clock = install_fake_clock(monkeypatch)
114
+ attempts = {"n": 0}
115
+
116
+ def read(_remaining_timeout):
117
+ attempts["n"] += 1
118
+ if attempts["n"] == 1:
119
+ raise TimeoutError("the read operation timed out")
120
+ return [{"trace_id": "trace-1"}]
121
+
122
+ result = _readiness.wait_for_publication(
123
+ _readiness.read_tolerating_timeout(read, []),
124
+ bool,
125
+ timeout=90.0,
126
+ interval=3.0,
127
+ )
128
+
129
+ # A timed-out first poll is retried to publication, not fatal.
130
+ assert result.published
131
+ assert result.value == [{"trace_id": "trace-1"}]
132
+ assert attempts["n"] == 2
133
+ assert clock.sleeps == [3.0]
134
+
135
+
54
136
  def test_wait_for_publication_returns_the_last_missing_value_at_deadline(monkeypatch):
55
137
  clock = install_fake_clock(monkeypatch)
56
138
  response_timeouts: list[float] = []
@@ -110,9 +110,12 @@ def test_cross_trace_q_search_finds_the_span_token():
110
110
  return _search(token, tag, timeout=timeout)
111
111
 
112
112
  # The projected search reader can finish a request before it folds this
113
- # trace. Poll the same scoped read without writing another trace.
113
+ # trace. Poll the same scoped read without writing another trace. A slow or
114
+ # unresponsive search read on a loaded staging box is "not published yet",
115
+ # not a failure: read_tolerating_timeout retries it to the deadline rather
116
+ # than letting one timed-out request eject the run.
114
117
  publication = _readiness.wait_for_publication(
115
- search_published_trace,
118
+ _readiness.read_tolerating_timeout(search_published_trace, []),
116
119
  lambda traces: [trace["trace_id"] for trace in traces] == [trace_id],
117
120
  )
118
121
  found = publication.value or []
@@ -126,5 +129,23 @@ def test_cross_trace_q_search_finds_the_span_token():
126
129
 
127
130
  # A token no span carries returns nothing under the same tag — proof the match
128
131
  # is real token filtering, not the tag scope handing back every tagged trace.
129
- absent = _search(f"nomatch{run}", tag)
130
- assert absent == [], f"q= matched a token no span carries: {absent}"
132
+ # Bound it like the positive poll so a slow staging read cannot eject the lane
133
+ # with an uncaught timeout. Sentinel None (not []) marks a timed-out read,
134
+ # since [] is the success value; any real response ends the poll, and a
135
+ # deadline of only-timeouts fails readably rather than falsely confirming
136
+ # absence.
137
+ def search_absent_token(remaining_timeout: _readiness.RemainingTimeout) -> list[typing.Any] | None:
138
+ timeout = remaining_timeout()
139
+ if timeout is None:
140
+ return None
141
+ return _search(f"nomatch{run}", tag, timeout=timeout)
142
+
143
+ negative = _readiness.wait_for_publication(
144
+ _readiness.read_tolerating_timeout(search_absent_token, None),
145
+ lambda result: result is not None,
146
+ )
147
+ assert negative.published, (
148
+ "negative-token search did not return within 90 seconds (search read timed "
149
+ "out under load); token absence could not be confirmed"
150
+ )
151
+ assert negative.value == [], f"q= matched a token no span carries: {negative.value}"