cmpnd 0.26.0__tar.gz → 0.26.1__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 (324) hide show
  1. {cmpnd-0.26.0 → cmpnd-0.26.1}/PKG-INFO +1 -1
  2. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/sdk-instrumentation.md +6 -2
  3. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/exporter_http.py +23 -25
  4. {cmpnd-0.26.0 → cmpnd-0.26.1}/pyproject.toml +1 -1
  5. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exporter.py +79 -10
  6. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exporter_real.py +23 -12
  7. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ingest_second_producer.py +5 -6
  8. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_integration.py +9 -15
  9. cmpnd-0.26.1/tests/test_span_sent_once.py +178 -0
  10. {cmpnd-0.26.0 → cmpnd-0.26.1}/uv.lock +1 -1
  11. {cmpnd-0.26.0 → cmpnd-0.26.1}/.gitignore +0 -0
  12. {cmpnd-0.26.0 → cmpnd-0.26.1}/AGENTS.md +0 -0
  13. {cmpnd-0.26.0 → cmpnd-0.26.1}/CLAUDE.md +0 -0
  14. {cmpnd-0.26.0 → cmpnd-0.26.1}/README.md +0 -0
  15. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/__init__.py +0 -0
  16. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_assessments.py +0 -0
  17. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_authored_import.py +0 -0
  18. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_exporter_pool.py +0 -0
  19. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_gepa_patch.py +0 -0
  20. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_http.py +0 -0
  21. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_program_patch.py +0 -0
  22. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_rlm_patch.py +0 -0
  23. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_timespec.py +0 -0
  24. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_traces.py +0 -0
  25. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_valsets.py +0 -0
  26. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/authored.py +0 -0
  27. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/callback.py +0 -0
  28. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/__init__.py +0 -0
  29. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/api_client.py +0 -0
  30. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/__init__.py +0 -0
  31. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/admin.py +0 -0
  32. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/assessments.py +0 -0
  33. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/auth.py +0 -0
  34. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/datasets.py +0 -0
  35. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/deployments.py +0 -0
  36. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/evals.py +0 -0
  37. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/login.py +0 -0
  38. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/optimizations.py +0 -0
  39. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/org.py +0 -0
  40. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/programs.py +0 -0
  41. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/provider_credentials.py +0 -0
  42. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/signatures.py +0 -0
  43. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/tool_servers.py +0 -0
  44. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/top.py +0 -0
  45. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/traces.py +0 -0
  46. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/valsets.py +0 -0
  47. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/credentials.py +0 -0
  48. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/deprecation.py +0 -0
  49. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/pagination.py +0 -0
  50. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/shell.py +0 -0
  51. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/snippet.py +0 -0
  52. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/trace_render.py +0 -0
  53. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/tree_help.py +0 -0
  54. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/verbs.py +0 -0
  55. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/client.py +0 -0
  56. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/configuration.py +0 -0
  57. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/context.py +0 -0
  58. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/dataset_sync.py +0 -0
  59. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/datasets.py +0 -0
  60. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/decorators.py +0 -0
  61. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/deployment.py +0 -0
  62. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/eval_handler.py +0 -0
  63. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/execution.py +0 -0
  64. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/exporter.py +0 -0
  65. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/exporter_http.py +0 -0
  66. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/helpers.py +0 -0
  67. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/identity.py +0 -0
  68. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/imaging.py +0 -0
  69. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/__init__.py +0 -0
  70. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/decode.py +0 -0
  71. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/encode.py +0 -0
  72. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/hash.py +0 -0
  73. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/program.py +0 -0
  74. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/reflection.py +0 -0
  75. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/types.py +0 -0
  76. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/xxh64.py +0 -0
  77. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/mcp_servers.py +0 -0
  78. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/models.py +0 -0
  79. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimization.py +0 -0
  80. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/__init__.py +0 -0
  81. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/gepa.py +0 -0
  82. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/gepa_callback.py +0 -0
  83. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/resume.py +0 -0
  84. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/tracker.py +0 -0
  85. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/packaging.py +0 -0
  86. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/retry.py +0 -0
  87. {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/traces.py +0 -0
  88. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/2026-04-02-code-review.md +0 -0
  89. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/cli.md +0 -0
  90. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/client.md +0 -0
  91. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/custom-proposer.md +0 -0
  92. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  93. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  94. {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/reading-traces.md +0 -0
  95. {cmpnd-0.26.0 → cmpnd-0.26.1}/examples/generalizing_proposer.py +0 -0
  96. {cmpnd-0.26.0 → cmpnd-0.26.1}/examples/local_ollama.py +0 -0
  97. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/LINEAGE.md +0 -0
  98. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/README.md +0 -0
  99. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/__init__.py +0 -0
  100. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/bodies.py +0 -0
  101. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/protocols.py +0 -0
  102. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/py.typed +0 -0
  103. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/request_log.py +0 -0
  104. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/retry.py +0 -0
  105. {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/pyproject.toml +0 -0
  106. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/audit-sdist.sh +0 -0
  107. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/capture_review_run.py +0 -0
  108. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/check_release_artifacts.py +0 -0
  109. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/fixtures/review_run.json.gz +0 -0
  110. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/gepa_resume_bug.py +0 -0
  111. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/require_version_bump.py +0 -0
  112. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/seed_review_run.py +0 -0
  113. {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/test-in-linux.sh +0 -0
  114. {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/__init__.pyi +0 -0
  115. {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/primitives/__init__.pyi +0 -0
  116. {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/primitives/prediction.pyi +0 -0
  117. {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/utils/__init__.pyi +0 -0
  118. {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/utils/callback.pyi +0 -0
  119. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/__init__.py +0 -0
  120. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/_exporter_helpers.py +0 -0
  121. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/_fake_clock.py +0 -0
  122. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/_identity_helpers.py +0 -0
  123. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/conftest.py +0 -0
  124. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/__init__.py +0 -0
  125. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/committee_task.py +0 -0
  126. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/conftest.py +0 -0
  127. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  128. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  129. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/fake_lm.py +0 -0
  130. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/helpers.py +0 -0
  131. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/target_capabilities.py +0 -0
  132. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_assessments_cli.py +0 -0
  133. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_capture_prompts.py +0 -0
  134. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_composite_module.py +0 -0
  135. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_dataset_auto_creation.py +0 -0
  136. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_deploy_execute.py +0 -0
  137. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_deploy_programs.py +0 -0
  138. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_edge_cases.py +0 -0
  139. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_example_hash.py +0 -0
  140. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_gepa_optimization.py +0 -0
  141. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_interpreter_spans.py +0 -0
  142. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_lm_accuracy.py +0 -0
  143. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_module_tracing.py +0 -0
  144. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimization_lineage.py +0 -0
  145. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimization_metric_identity.py +0 -0
  146. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_failures.py +0 -0
  147. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_loop.py +0 -0
  148. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_review_committee.py +0 -0
  149. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  150. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_parallel_export.py +0 -0
  151. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_project_attribution.py +0 -0
  152. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_react_tool_spans.py +0 -0
  153. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_save_load.py +0 -0
  154. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_trace_structure.py +0 -0
  155. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_traces_read.py +0 -0
  156. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_user_attribution.py +0 -0
  157. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_valsets_cli.py +0 -0
  158. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/01_predict_qa.json +0 -0
  159. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  160. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  161. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  162. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  163. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  164. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/07_program_of_thought.json +0 -0
  165. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/08_opaque_module.json +0 -0
  166. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  167. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/10_react.json +0 -0
  168. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/11_refine.json +0 -0
  169. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/12_retrieve.json +0 -0
  170. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/13_knn.json +0 -0
  171. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/14_parallel.json +0 -0
  172. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/README.md +0 -0
  173. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/ingest_conformance.py +0 -0
  174. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/__init__.py +0 -0
  175. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/conftest.py +0 -0
  176. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/hosted_mcp_tool_optimize_probe.py +0 -0
  177. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/hosted_optimize_probe.py +0 -0
  178. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/optimization_run_probe.py +0 -0
  179. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  180. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_logs_renders_traces.py +0 -0
  181. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_optimization_run.py +0 -0
  182. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_optimizing_progress.py +0 -0
  183. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_unhappy_path.py +0 -0
  184. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/__init__.py +0 -0
  185. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/__main__.py +0 -0
  186. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/drain.py +0 -0
  187. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/ingest.py +0 -0
  188. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/latency_transport.py +0 -0
  189. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/rlm_shape.py +0 -0
  190. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/sweep.py +0 -0
  191. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/test_rlm_shape.py +0 -0
  192. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/__init__.py +0 -0
  193. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/conftest.py +0 -0
  194. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/seed_parity.py +0 -0
  195. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_auth.py +0 -0
  196. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_datasets.py +0 -0
  197. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_deployments.py +0 -0
  198. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_evals.py +0 -0
  199. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_health.py +0 -0
  200. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_modules.py +0 -0
  201. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_optimizations.py +0 -0
  202. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_programs.py +0 -0
  203. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_signatures.py +0 -0
  204. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_trace_streaming.py +0 -0
  205. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_traces.py +0 -0
  206. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/__init__.py +0 -0
  207. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/_provider.py +0 -0
  208. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/_reliability.py +0 -0
  209. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/_result.py +0 -0
  210. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/mint_bedrock_token.py +0 -0
  211. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_deploy_programs.py +0 -0
  212. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_multi_client_stress.py +0 -0
  213. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_client_committee.py +0 -0
  214. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_loop.py +0 -0
  215. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_loop_wasm.py +0 -0
  216. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_stress.py +0 -0
  217. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_provider_roles.py +0 -0
  218. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_real_provider_twins.py +0 -0
  219. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_reliability.py +0 -0
  220. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_reliability_classify.py +0 -0
  221. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_rlm_wasm.py +0 -0
  222. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_trace_search.py +0 -0
  223. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_assessments_api.py +0 -0
  224. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_authored.py +0 -0
  225. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_callback.py +0 -0
  226. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_candidate_lineage.py +0 -0
  227. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/__init__.py +0 -0
  228. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/conftest.py +0 -0
  229. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_admin.py +0 -0
  230. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_api_client.py +0 -0
  231. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_assessments.py +0 -0
  232. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_auth.py +0 -0
  233. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_credentials.py +0 -0
  234. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_datasets.py +0 -0
  235. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_deployments.py +0 -0
  236. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_evals.py +0 -0
  237. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_login.py +0 -0
  238. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_main.py +0 -0
  239. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_optimizations.py +0 -0
  240. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_org.py +0 -0
  241. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_pagination.py +0 -0
  242. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_parity.py +0 -0
  243. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_programs.py +0 -0
  244. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_provider_credentials.py +0 -0
  245. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_schema_sample.py +0 -0
  246. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_shell.py +0 -0
  247. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_signatures.py +0 -0
  248. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_tool_servers.py +0 -0
  249. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_top.py +0 -0
  250. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_traces.py +0 -0
  251. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_traces_search_wire.py +0 -0
  252. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_tree_help.py +0 -0
  253. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_valsets.py +0 -0
  254. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_client.py +0 -0
  255. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/__init__.py +0 -0
  256. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/conftest.py +0 -0
  257. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/test_edge_cases.py +0 -0
  258. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/test_hash_consistency.py +0 -0
  259. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/test_type_conversion.py +0 -0
  260. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cmp551_grouping_identity.py +0 -0
  261. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_configuration.py +0 -0
  262. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_context.py +0 -0
  263. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_context_propagation.py +0 -0
  264. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_dataset_polling.py +0 -0
  265. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_dataset_sync.py +0 -0
  266. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_datasets.py +0 -0
  267. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_decorators.py +0 -0
  268. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_deploy.py +0 -0
  269. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_deploy_mcp_tool_references.py +0 -0
  270. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval.py +0 -0
  271. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval_example_attribution.py +0 -0
  272. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval_start.py +0 -0
  273. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval_trace_grouping.py +0 -0
  274. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exception_paths.py +0 -0
  275. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_execute.py +0 -0
  276. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exporter_queue.py +0 -0
  277. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_fake_lm.py +0 -0
  278. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_gepa_callback.py +0 -0
  279. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_gepa_integration.py +0 -0
  280. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_gepa_resume.py +0 -0
  281. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_identity.py +0 -0
  282. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_imaging.py +0 -0
  283. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_import_weight.py +0 -0
  284. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ingest_conformance.py +0 -0
  285. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_interpreter_callback.py +0 -0
  286. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ir_encoder_properties.py +0 -0
  287. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ir_golden_vectors.py +0 -0
  288. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ir_roundtrip.py +0 -0
  289. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_lm_usage_shapes.py +0 -0
  290. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_mcp_tools.py +0 -0
  291. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_models.py +0 -0
  292. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimization_progress.py +0 -0
  293. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimization_start.py +0 -0
  294. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize.py +0 -0
  295. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_budget.py +0 -0
  296. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_lift.py +0 -0
  297. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_lms.py +0 -0
  298. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_proposer.py +0 -0
  299. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_reflection_lm.py +0 -0
  300. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_terminal_failure.py +0 -0
  301. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging.py +0 -0
  302. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging_callable_source.py +0 -0
  303. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging_mcp_tools.py +0 -0
  304. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging_program_source.py +0 -0
  305. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_poll_until.py +0 -0
  306. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_program_lineage_persist.py +0 -0
  307. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_project_payload.py +0 -0
  308. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_reflector_properties.py +0 -0
  309. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_reflector_smoke.py +0 -0
  310. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_repro_reactv2_bugs.py +0 -0
  311. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_require_version_bump.py +0 -0
  312. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_retry.py +0 -0
  313. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_reversible_failures.py +0 -0
  314. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_rlm_patch.py +0 -0
  315. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_schemas.py +0 -0
  316. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_seed_review_run.py +0 -0
  317. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_trace_render.py +0 -0
  318. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_traces_read.py +0 -0
  319. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_tracked_gepa.py +0 -0
  320. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_tracker_enrich.py +0 -0
  321. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_truncation.py +0 -0
  322. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_valsets_api.py +0 -0
  323. {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_xxh64.py +0 -0
  324. {cmpnd-0.26.0 → cmpnd-0.26.1}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cmpnd
3
- Version: 0.26.0
3
+ Version: 0.26.1
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>
@@ -412,9 +412,13 @@ Every session opened this way lives on one background thread and loop
412
412
  binds to the loop it was opened on, and `dspy.Tool.__call__` runs an async tool
413
413
  body under `asyncio.run` — a fresh loop — whenever none is already running.
414
414
 
415
- ### Loose spans vs bundled traces
415
+ ### A span is sent exactly once
416
416
 
417
- A span can arrive at `_flush_batch` before its parent trace has been exported — this happens whenever the exporter ticks in the middle of a DSPy call. The exporter holds those loose spans in `self._pending_spans` (`exporter_http.py:122`) keyed by `trace_id`, and when the trace finally arrives it attaches the buffered spans before shipping. Spans for which the trace never arrives get flushed as a bare spans batch via `/api/v1/traces/:id/spans` — see `_send_spans` at `exporter_http.py:637`.
417
+ `_flush_batch` picks one route per span and no producer can make both happen. When a flush carries a trace's own `trace` item, that trace's loose `span` items ride its body (`POST /api/v1/traces`) and the spans route is not called for them; a span is folded in only when the body does not already carry its `span_id`, because `TraceRecord.to_api_dict` already serialises the trace's `_batch_spans`. A span whose trace is not in the flush — an orphan, or a span the callback streams while its trace is still running — goes to `/api/v1/traces/:id/spans` and nowhere else.
418
+
419
+ The flush is the only place that decides, and it decides per flush with no buffer carried between them: both routes write the same trace's rows, so a span on both at once is two concurrent writers over one trace, and double upload for whoever pays for the bandwidth.
420
+
421
+ Two producers put a `span` item and its trace's `trace` item in the same flush. `cmpnd.start_span` / `@cmpnd.trace` export the span they close directly, and the callback's `_should_stream_span` streams a span whose trace it has no record of. Neither has to know about the other.
418
422
 
419
423
  ### In-progress trace starts (CMP-257)
420
424
 
@@ -306,10 +306,6 @@ class BatchExporter:
306
306
  # fresh lock mid-restart would let a second thread in immediately.
307
307
  self._restart_lock = threading.Lock()
308
308
 
309
- # Buffer spans by trace_id until trace arrives
310
- self._pending_spans: dict[str, list[dict[str, Any]]] = {}
311
- self._pending_spans_lock = threading.Lock()
312
-
313
309
  # Stats (guarded by _stats_lock for thread-safe parallel dispatch)
314
310
  self._exported_count = 0
315
311
  self._dropped_count = 0
@@ -438,8 +434,6 @@ class BatchExporter:
438
434
 
439
435
  self._sync_client_lock = threading.Lock()
440
436
  self._stats_lock = threading.Lock()
441
- self._pending_spans_lock = threading.Lock()
442
- self._pending_spans = {}
443
437
 
444
438
  self._flush_cond = threading.Condition()
445
439
  self._flush_requested_gen = 0
@@ -1138,7 +1132,6 @@ class BatchExporter:
1138
1132
  trace_starts: list[tuple[str, dict[str, Any]]] = []
1139
1133
  trace_finalizes: list[tuple[str, dict[str, Any]]] = []
1140
1134
  spans_by_trace: dict[str, list[dict[str, Any]]] = {}
1141
- new_spans: dict[str, list[dict[str, Any]]] = {}
1142
1135
 
1143
1136
  for item in batch:
1144
1137
  if item.item_type == "trace":
@@ -1170,9 +1163,6 @@ class BatchExporter:
1170
1163
  logger.warning("Dropping span without trace_id")
1171
1164
  continue
1172
1165
  spans_by_trace.setdefault(trace_id, []).append(span_dict)
1173
- if trace_id not in new_spans:
1174
- new_spans[trace_id] = []
1175
- new_spans[trace_id].append(span_dict)
1176
1166
 
1177
1167
  # Coalesce start + batch within one flush: if a trace's full
1178
1168
  # batch trace is in this same flush, sending it upserts the row to its
@@ -1185,23 +1175,31 @@ class BatchExporter:
1185
1175
  batched_ids = {str(t.get("trace_id")) for t in traces_to_send}
1186
1176
  trace_starts = [pair for pair in trace_starts if pair[0] not in batched_ids]
1187
1177
 
1188
- # Add new spans to pending buffer
1189
- with self._pending_spans_lock:
1190
- for trace_id, spans in new_spans.items():
1191
- if trace_id not in self._pending_spans:
1192
- self._pending_spans[trace_id] = []
1193
- self._pending_spans[trace_id].extend(spans)
1194
-
1195
- # Attach buffered spans to traces
1178
+ # A span is sent exactly once. When its trace ships in this same flush it
1179
+ # rides that trace's body and is taken off the spans route; otherwise the
1180
+ # spans route is the only way it reaches the server. This is the one
1181
+ # place that decides, so no producer can make both happen.
1182
+ #
1183
+ # Sending it twice is not merely wasteful upload: POST /traces and
1184
+ # POST /traces/{id}/spans write the same trace's rows, so a span on both
1185
+ # routes at once is two concurrent writers racing over one trace — the
1186
+ # shape the server has to be lock-ordered against. Not emitting the pair
1187
+ # is the SDK's half of that.
1188
+ #
1189
+ # A trace's own spans are already in its body (TraceRecord.to_api_dict
1190
+ # serialises them), so a span is folded in only when the body does not
1191
+ # already carry its span_id.
1196
1192
  for trace_dict in traces_to_send:
1197
1193
  trace_id = str(trace_dict.get("trace_id"))
1198
- with self._pending_spans_lock:
1199
- buffered_spans = self._pending_spans.pop(trace_id, [])
1200
- if buffered_spans:
1201
- existing = trace_dict.get("spans", [])
1202
- trace_dict["spans"] = existing + buffered_spans
1203
- elif "spans" not in trace_dict:
1204
- trace_dict["spans"] = []
1194
+ body_spans = list(trace_dict.get("spans") or [])
1195
+ carried = {str(span.get("span_id")) for span in body_spans}
1196
+ for span_dict in spans_by_trace.pop(trace_id, []):
1197
+ span_id = str(span_dict.get("span_id"))
1198
+ if span_id in carried:
1199
+ continue
1200
+ carried.add(span_id)
1201
+ body_spans.append(span_dict)
1202
+ trace_dict["spans"] = body_spans
1205
1203
 
1206
1204
  # --- Phase 2: Parallel dispatch ---
1207
1205
 
@@ -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.26.0"
8
+ version = "0.26.1"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -339,25 +339,92 @@ def test_exporter_sends_trace_finalize():
339
339
  assert payload["total_tokens"] == 100
340
340
 
341
341
 
342
- def test_exporter_old_trace_path_still_works():
343
- """The old trace item type should still work with pending spans."""
342
+ # ---------------------------------------------------------------------------
343
+ # A span is sent exactly once
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ def test_span_rides_its_trace_body_and_skips_the_spans_route():
348
+ """A span flushed alongside its own trace ships in that trace's body, and the
349
+ spans route is not called for it — the two server paths write the same trace,
350
+ so one span must not arrive on both at once."""
344
351
  capture = RequestCapture()
345
352
  exporter = _make_exporter(capture)
346
353
 
347
354
  tid = uuid4()
348
355
  trace = _make_trace(trace_id=tid)
349
-
350
- # Simulate old path: manually buffer a span
351
356
  span = _make_span(trace_id=tid)
352
- exporter._pending_spans[str(tid)] = [span.to_api_dict()]
353
-
354
- batch = [ExportItem(item_type="trace", data=trace, timestamp=0.0)]
357
+ batch = [
358
+ ExportItem(item_type="trace", data=trace, timestamp=0.0),
359
+ ExportItem(item_type="span", data=span, timestamp=0.0),
360
+ ]
355
361
  exporter._flush_batch(batch)
356
362
 
363
+ assert capture.requests_to(f"/api/v1/traces/{tid}/spans") == []
357
364
  reqs = capture.requests_to("/api/v1/traces")
358
365
  assert len(reqs) == 1
359
366
  payload = decode_body(reqs[0])
360
- assert len(payload["spans"]) == 1
367
+ assert [s["span_id"] for s in payload["spans"]] == [str(span.span_id)]
368
+
369
+
370
+ def test_span_without_its_trace_in_the_flush_goes_to_the_spans_route_once():
371
+ """No trace item for the span's trace means the spans route is the only way
372
+ it reaches the server — and it goes there exactly once."""
373
+ capture = RequestCapture()
374
+ exporter = _make_exporter(capture)
375
+
376
+ tid = uuid4()
377
+ span = _make_span(trace_id=tid)
378
+ other = _make_trace()
379
+ batch = [
380
+ ExportItem(item_type="span", data=span, timestamp=0.0),
381
+ ExportItem(item_type="trace", data=other, timestamp=0.0),
382
+ ]
383
+ exporter._flush_batch(batch)
384
+
385
+ reqs = capture.requests_to(f"/api/v1/traces/{tid}/spans")
386
+ assert len(reqs) == 1
387
+ payload = decode_body(reqs[0])
388
+ assert [s["span_id"] for s in payload["spans"]] == [str(span.span_id)]
389
+ # The unrelated trace ships with an empty body, not with someone else's span.
390
+ assert decode_body(capture.requests_to("/api/v1/traces")[0])["spans"] == []
391
+
392
+
393
+ def test_span_already_in_the_trace_body_is_not_appended_twice():
394
+ """A trace carries its own spans by construction. A span item naming one of
395
+ them adds nothing — the body keeps a single copy."""
396
+ capture = RequestCapture()
397
+ exporter = _make_exporter(capture)
398
+
399
+ tid = uuid4()
400
+ span = _make_span(trace_id=tid)
401
+ trace = _make_trace(trace_id=tid)
402
+ trace._batch_spans = [span]
403
+ batch = [
404
+ ExportItem(item_type="trace", data=trace, timestamp=0.0),
405
+ ExportItem(item_type="span", data=span, timestamp=0.0),
406
+ ]
407
+ exporter._flush_batch(batch)
408
+
409
+ assert capture.requests_to(f"/api/v1/traces/{tid}/spans") == []
410
+ payload = decode_body(capture.requests_to("/api/v1/traces")[0])
411
+ assert [s["span_id"] for s in payload["spans"]] == [str(span.span_id)]
412
+
413
+
414
+ def test_span_of_an_earlier_flush_is_not_re_attached_to_a_later_trace():
415
+ """A span dispatched on the spans route in one flush is delivered. A later
416
+ flush carrying its trace must not send it a second time in the body."""
417
+ capture = RequestCapture()
418
+ exporter = _make_exporter(capture)
419
+
420
+ tid = uuid4()
421
+ span = _make_span(trace_id=tid)
422
+ exporter._flush_batch([ExportItem(item_type="span", data=span, timestamp=0.0)])
423
+ assert len(capture.requests_to(f"/api/v1/traces/{tid}/spans")) == 1
424
+
425
+ exporter._flush_batch([ExportItem(item_type="trace", data=_make_trace(trace_id=tid), timestamp=0.0)])
426
+ payload = decode_body(capture.requests_to("/api/v1/traces")[0])
427
+ assert payload["spans"] == []
361
428
 
362
429
 
363
430
  # ---------------------------------------------------------------------------
@@ -747,8 +814,10 @@ def test_full_trace_spans_counted_in_confirmation(caplog):
747
814
 
748
815
  tid = uuid4()
749
816
  trace = _make_trace(trace_id=tid)
750
- exporter._pending_spans[str(tid)] = [_make_span(trace_id=tid).to_api_dict()]
751
- batch = [ExportItem(item_type="trace", data=trace, timestamp=0.0)]
817
+ batch = [
818
+ ExportItem(item_type="trace", data=trace, timestamp=0.0),
819
+ ExportItem(item_type="span", data=_make_span(trace_id=tid), timestamp=0.0),
820
+ ]
752
821
 
753
822
  with caplog.at_level(logging.INFO, logger="cmpnd.exporter"):
754
823
  exporter._flush_batch(batch)
@@ -312,29 +312,40 @@ class TestErrorHandling:
312
312
  # ---------------------------------------------------------------------------
313
313
 
314
314
 
315
- class TestSpanBuffering:
316
- """Test that spans arriving before their trace get attached correctly."""
315
+ class TestSpanRouting:
316
+ """A span takes one route to the server, decided by the flush it lands in."""
317
317
 
318
- def test_buffered_spans_attached_to_trace(self):
318
+ def test_span_flushed_before_its_trace_is_not_sent_again_with_it(self):
319
+ """The spans route already delivered it, so the trace that follows carries
320
+ only the spans of its own body."""
319
321
  capture = RequestCapture()
320
322
  exporter = _make_exporter(capture)
321
323
  tid = uuid4()
322
324
 
323
- # Span arrives first (via old batching path)
324
- span = _make_span(trace_id=tid)
325
- exporter.export_span(span)
326
- # Flush spans first to populate pending buffer
325
+ exporter.export_span(_make_span(trace_id=tid))
327
326
  exporter._flush_batch(exporter._drain_pending())
327
+ assert len(capture.payloads_to(f"/api/v1/traces/{tid}/spans")) == 1
328
328
 
329
- # Now trace arrives
330
- trace = _make_trace(trace_id=tid)
331
- exporter.export_trace(trace)
329
+ exporter.export_trace(_make_trace(trace_id=tid))
330
+ exporter._flush_batch(exporter._drain_pending())
331
+
332
+ trace_payloads = capture.payloads_to("/api/v1/traces")
333
+ assert len(trace_payloads) == 1
334
+ assert trace_payloads[0]["spans"] == []
335
+
336
+ def test_span_flushed_with_its_trace_rides_the_trace_body(self):
337
+ capture = RequestCapture()
338
+ exporter = _make_exporter(capture)
339
+ tid = uuid4()
340
+
341
+ exporter.export_span(_make_span(trace_id=tid))
342
+ exporter.export_trace(_make_trace(trace_id=tid))
332
343
  exporter._flush_batch(exporter._drain_pending())
333
344
 
334
- # The trace POST should include the buffered span
345
+ assert capture.payloads_to(f"/api/v1/traces/{tid}/spans") == []
335
346
  trace_payloads = capture.payloads_to("/api/v1/traces")
336
347
  assert len(trace_payloads) == 1
337
- assert len(trace_payloads[0]["spans"]) >= 1
348
+ assert len(trace_payloads[0]["spans"]) == 1
338
349
 
339
350
 
340
351
  # ---------------------------------------------------------------------------
@@ -456,10 +456,10 @@ def test_a_dict_trace_body_posts_the_bytes_the_typed_builder_would_have(spec: In
456
456
 
457
457
 
458
458
  def test_a_dict_trace_body_collects_the_dict_spans_that_claim_it() -> None:
459
- """A span emitted ahead of its trace is buffered and spliced into the trace that
460
- claims it — the same coalescing the typed path gets, so a dict-backed producer that
461
- hands spans over as it goes still sends one trace. Which trace a span belongs to is
462
- read out of the span's own body, so `SpanBody` must not lose it."""
459
+ """A span flushed alongside the trace that claims it rides that trace's body and
460
+ takes no second route — the same coalescing the typed path gets, so a dict-backed
461
+ producer that hands spans over as it goes still sends each span once. Which trace a
462
+ span belongs to is read out of the span's own body, so `SpanBody` must not lose it."""
463
463
  builder = _trace()
464
464
  transport = CapturingTransport()
465
465
 
@@ -468,8 +468,7 @@ def test_a_dict_trace_body_collects_the_dict_spans_that_claim_it() -> None:
468
468
  exporter.export_trace(TraceBody(builder.to_api_dict()))
469
469
  assert exporter.flush(timeout=10)
470
470
 
471
- spans_posts = [r for r in transport.requests if r.path.endswith("/spans")]
472
- assert [r.path for r in spans_posts] == [f"/api/v1/traces/{builder.trace_id}/spans"]
471
+ assert [r for r in transport.requests if r.path.endswith("/spans")] == []
473
472
  traces_post = next(r for r in transport.requests if r.path == "/api/v1/traces")
474
473
  assert isinstance(traces_post.body, dict)
475
474
  assert len(traces_post.body["spans"]) == 1
@@ -304,12 +304,10 @@ class TestExporterIntegration:
304
304
  shutdown_exporter()
305
305
  dspy.configure(callbacks=[])
306
306
 
307
- def test_exporter_buffers_spans_until_trace(self):
308
- """Test that exporter buffers spans until trace arrives.
309
-
310
- With the streaming exporter, spans are sent both directly (streaming
311
- path) AND buffered for the old trace path. So we expect 2 POST calls:
312
- one to /api/v1/traces/:id/spans and one to /api/v1/traces.
307
+ def test_spans_ride_the_trace_they_are_flushed_with(self):
308
+ """A span whose trace is in the same flush ships inside that trace's body,
309
+ and the per-trace spans route is not called for it: the two server paths
310
+ write the same trace's rows, so one span must not travel both at once.
313
311
  """
314
312
  from datetime import datetime, timezone
315
313
  from uuid import uuid4
@@ -368,10 +366,10 @@ class TestExporterIntegration:
368
366
 
369
367
  exporter._flush_batch(items)
370
368
 
371
- # Spans are sent via streaming path + trace picks up buffered spans.
372
- # Bodies ship gzipped through client.request(method, path, content=…)
373
- # now (CMP-314), so decode the content kwarg to inspect them.
374
- assert mock_client.request.call_count == 2
369
+ # One POST carries the whole trace. Bodies ship gzipped through
370
+ # client.request(method, path, content=…), so decode the
371
+ # content kwarg to inspect them.
372
+ assert mock_client.request.call_count == 1
375
373
 
376
374
  # Identify calls by URL (positional args: method, path) and decode body.
377
375
  calls_by_url = {}
@@ -380,10 +378,6 @@ class TestExporterIntegration:
380
378
  body = json.loads(gzip.decompress(c[1]["content"]))
381
379
  calls_by_url[url] = body
382
380
 
383
- # Old-path trace POST with embedded spans
381
+ assert f"/api/v1/traces/{trace_id}/spans" not in calls_by_url
384
382
  trace_data = calls_by_url["/api/v1/traces"]
385
383
  assert len(trace_data["spans"]) == 2
386
-
387
- # Streaming spans POST
388
- span_url = f"/api/v1/traces/{trace_id}/spans"
389
- assert len(calls_by_url[span_url]["spans"]) == 2
@@ -0,0 +1,178 @@
1
+ """Every span the SDK produces reaches the server exactly once.
2
+
3
+ A span has two routes to the server: ``POST /api/v1/traces/{id}/spans``, and its
4
+ trace's own body on ``POST /api/v1/traces``. Both write the same trace's rows, so
5
+ a span taking both at once is two concurrent writers over one trace, and it is
6
+ double upload for whoever is paying for the bandwidth. The exporter's flush is
7
+ the one place that picks a route, so these tests drive the real callback into a
8
+ real ``BatchExporter`` over a mock transport and count what the wire carries.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+ from unittest import mock
15
+
16
+ import dspy
17
+ import httpx
18
+ import pytest
19
+
20
+ import cmpnd
21
+ from cmpnd.configuration import Config
22
+ from cmpnd.exporter import BatchExporter
23
+ from tests._exporter_helpers import decode_body
24
+ from tests.e2e import fake_lm
25
+ from tests.test_eval_trace_grouping import ( # noqa: F401 (autouse fixtures)
26
+ CapitalQA,
27
+ RecordingExporter,
28
+ _fake_provider,
29
+ _sdk_configured,
30
+ )
31
+
32
+ pytest.importorskip("dspy")
33
+
34
+
35
+ class _Wire:
36
+ """Captures what the exporter puts on the wire."""
37
+
38
+ def __init__(self) -> None:
39
+ self.requests: list[httpx.Request] = []
40
+
41
+ def handler(self, request: httpx.Request) -> httpx.Response:
42
+ self.requests.append(request)
43
+ return httpx.Response(200, json={"status": "ok"})
44
+
45
+ def span_ids_by_route(self) -> dict[str, list[str]]:
46
+ """span_id -> the request paths that carried it, one entry per carry."""
47
+ seen: dict[str, list[str]] = {}
48
+ for request in self.requests:
49
+ path = request.url.path
50
+ payload = decode_body(request)
51
+ spans: list[dict[str, Any]] = []
52
+ if path.endswith("/spans"):
53
+ spans = payload.get("spans", [])
54
+ elif path == "/api/v1/traces":
55
+ spans = payload.get("spans", [])
56
+ elif path == "/api/v1/traces/batch":
57
+ for trace in payload:
58
+ spans.extend(trace.get("spans", []))
59
+ for span in spans:
60
+ seen.setdefault(str(span["span_id"]), []).append(path)
61
+ return seen
62
+
63
+
64
+ def _exporter(wire: _Wire) -> BatchExporter:
65
+ config = Config(api_key="test", endpoint="http://localhost:9999")
66
+ exporter = BatchExporter(config)
67
+ exporter._client = httpx.Client(
68
+ base_url=config.endpoint,
69
+ headers={"X-API-Key": config.api_key, "Content-Type": "application/json"},
70
+ timeout=5.0,
71
+ transport=httpx.MockTransport(wire.handler),
72
+ )
73
+ return exporter
74
+
75
+
76
+ class ManualStepProgram(dspy.Module):
77
+ """A program that opens a span of its own — the shape that puts a ``span``
78
+ item and its trace's ``trace`` item in the same flush."""
79
+
80
+ def __init__(self) -> None:
81
+ super().__init__()
82
+ self.predict = dspy.Predict(CapitalQA)
83
+
84
+ def forward(self, question: str) -> Any:
85
+ with cmpnd.start_span("manual_step") as span:
86
+ span.set_attribute("step", "lookup")
87
+ return self.predict(question=question)
88
+
89
+
90
+ def _run_under_exporter(program: dspy.Module) -> _Wire:
91
+ """Run ``program`` with the callback exporting into a real BatchExporter,
92
+ then flush that exporter's whole queue in one batch."""
93
+ from cmpnd.callback import CmpndCallback
94
+
95
+ wire = _Wire()
96
+ exporter = _exporter(wire)
97
+
98
+ with mock.patch("cmpnd.exporter.ensure_exporter_started"):
99
+ callback = CmpndCallback()
100
+
101
+ saved = list(dspy.settings.callbacks or [])
102
+ try:
103
+ dspy.settings.configure(callbacks=[callback])
104
+ with (
105
+ mock.patch("cmpnd.exporter.get_exporter", return_value=exporter),
106
+ dspy.context(lm=dspy.LM(fake_lm.FAKE_MODEL, cache=False)),
107
+ ):
108
+ program(question="What is the capital of France?")
109
+ finally:
110
+ dspy.settings.configure(callbacks=saved)
111
+
112
+ batch = []
113
+ for q in (exporter._lifecycle_queue, exporter._detail_queue):
114
+ while not q.empty():
115
+ batch.append(q.get_nowait())
116
+ exporter._flush_batch(batch)
117
+ return wire
118
+
119
+
120
+ def test_a_program_opened_span_is_not_sent_on_both_routes():
121
+ wire = _run_under_exporter(ManualStepProgram())
122
+ carried = wire.span_ids_by_route()
123
+
124
+ manual = [routes for routes in carried.values() if len(routes) > 1]
125
+ assert manual == [], f"span sent more than once: {manual}"
126
+ assert carried, "the run produced no spans at all"
127
+
128
+
129
+ def test_every_span_of_a_batched_trace_rides_its_body():
130
+ """A non-streaming trace and its spans in one flush means one POST carries
131
+ all of it, and the per-trace spans route is never called."""
132
+ wire = _run_under_exporter(ManualStepProgram())
133
+
134
+ spans_routes = [r for r in wire.requests if r.url.path.endswith("/spans")]
135
+ assert spans_routes == [], f"spans route called for a batched trace: {[r.url.path for r in spans_routes]}"
136
+
137
+ names = {
138
+ span["name"]
139
+ for request in wire.requests
140
+ if request.url.path == "/api/v1/traces"
141
+ for span in decode_body(request).get("spans", [])
142
+ }
143
+ assert "manual_step" in names
144
+ assert "Predict.forward" in names
145
+
146
+
147
+ def test_callback_does_not_stream_spans_of_a_non_streaming_trace():
148
+ """The classification half of the rule: a span the callback tracked for a
149
+ batched trace is never handed to ``export_span``, whichever thread it ends on."""
150
+ from cmpnd.callback import CmpndCallback
151
+
152
+ with mock.patch("cmpnd.exporter.ensure_exporter_started"):
153
+ callback = CmpndCallback()
154
+ recorder = RecordingExporter()
155
+
156
+ saved = list(dspy.settings.callbacks or [])
157
+ try:
158
+ dspy.settings.configure(callbacks=[callback])
159
+ with (
160
+ mock.patch("cmpnd.exporter.get_exporter", return_value=recorder),
161
+ dspy.context(lm=dspy.LM(fake_lm.FAKE_MODEL, cache=False)),
162
+ ):
163
+ evaluate = dspy.Evaluate(
164
+ devset=[
165
+ dspy.Example(question="What is the capital of France?", answer="Paris").with_inputs("question")
166
+ ],
167
+ metric=lambda example, prediction, trace=None: 1.0,
168
+ num_threads=4,
169
+ display_progress=False,
170
+ display_table=False,
171
+ )
172
+ evaluate(dspy.Predict(CapitalQA))
173
+ finally:
174
+ dspy.settings.configure(callbacks=saved)
175
+
176
+ streamed = {str(span.span_id) for span in recorder.spans}
177
+ batched = {str(span.span_id) for trace in recorder.traces for span in (trace._batch_spans or [])}
178
+ assert streamed & batched == set()
@@ -496,7 +496,7 @@ wheels = [
496
496
 
497
497
  [[package]]
498
498
  name = "cmpnd"
499
- version = "0.26.0"
499
+ version = "0.26.1"
500
500
  source = { editable = "." }
501
501
  dependencies = [
502
502
  { name = "dspy" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes