cmpnd 0.8.2__tar.gz → 0.8.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. {cmpnd-0.8.2 → cmpnd-0.8.3}/PKG-INFO +1 -1
  2. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/exporter_http.py +172 -111
  3. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/optimization.py +211 -35
  4. cmpnd-0.8.3/cmpnd/retry.py +186 -0
  5. {cmpnd-0.8.2 → cmpnd-0.8.3}/docs/sdk-instrumentation.md +63 -7
  6. {cmpnd-0.8.2 → cmpnd-0.8.3}/pyproject.toml +1 -1
  7. cmpnd-0.8.3/tests/_fake_clock.py +31 -0
  8. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_multi_client_stress.py +14 -8
  9. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_reliability_classify.py +44 -0
  10. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_eval_start.py +0 -2
  11. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_exporter.py +139 -100
  12. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_optimization_start.py +77 -2
  13. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_optimize.py +354 -3
  14. cmpnd-0.8.3/tests/test_retry.py +113 -0
  15. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_schemas.py +60 -3
  16. {cmpnd-0.8.2 → cmpnd-0.8.3}/uv.lock +1 -1
  17. {cmpnd-0.8.2 → cmpnd-0.8.3}/.gitignore +0 -0
  18. {cmpnd-0.8.2 → cmpnd-0.8.3}/CLAUDE.md +0 -0
  19. {cmpnd-0.8.2 → cmpnd-0.8.3}/README.md +0 -0
  20. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/__init__.py +0 -0
  21. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/_gepa_patch.py +0 -0
  22. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/_program_patch.py +0 -0
  23. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/_rlm_patch.py +0 -0
  24. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/callback.py +0 -0
  25. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/__init__.py +0 -0
  26. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/api_client.py +0 -0
  27. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/__init__.py +0 -0
  28. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/admin.py +0 -0
  29. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/auth.py +0 -0
  30. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/datasets.py +0 -0
  31. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/deployments.py +0 -0
  32. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/evals.py +0 -0
  33. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/login.py +0 -0
  34. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/optimizations.py +0 -0
  35. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/org.py +0 -0
  36. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/programs.py +0 -0
  37. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/top.py +0 -0
  38. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/commands/traces.py +0 -0
  39. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/credentials.py +0 -0
  40. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/shell.py +0 -0
  41. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/trace_render.py +0 -0
  42. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/cli/verbs.py +0 -0
  43. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/configuration.py +0 -0
  44. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/context.py +0 -0
  45. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/dataset_sync.py +0 -0
  46. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/datasets.py +0 -0
  47. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/decorators.py +0 -0
  48. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/deployment.py +0 -0
  49. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/eval_handler.py +0 -0
  50. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/execution.py +0 -0
  51. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/exporter.py +0 -0
  52. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/helpers.py +0 -0
  53. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/identity.py +0 -0
  54. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/imaging.py +0 -0
  55. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/__init__.py +0 -0
  56. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/decode.py +0 -0
  57. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/encode.py +0 -0
  58. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/hash.py +0 -0
  59. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/program.py +0 -0
  60. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/reflection.py +0 -0
  61. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/types.py +0 -0
  62. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/ir/xxh64.py +0 -0
  63. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/models.py +0 -0
  64. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/optimizers/__init__.py +0 -0
  65. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/optimizers/gepa.py +0 -0
  66. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/optimizers/gepa_callback.py +0 -0
  67. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/optimizers/resume.py +0 -0
  68. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/optimizers/tracker.py +0 -0
  69. {cmpnd-0.8.2 → cmpnd-0.8.3}/cmpnd/packaging.py +0 -0
  70. {cmpnd-0.8.2 → cmpnd-0.8.3}/docs/2026-04-02-code-review.md +0 -0
  71. {cmpnd-0.8.2 → cmpnd-0.8.3}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  72. {cmpnd-0.8.2 → cmpnd-0.8.3}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  73. {cmpnd-0.8.2 → cmpnd-0.8.3}/examples/local_ollama.py +0 -0
  74. {cmpnd-0.8.2 → cmpnd-0.8.3}/scripts/capture_review_run.py +0 -0
  75. {cmpnd-0.8.2 → cmpnd-0.8.3}/scripts/fixtures/review_run.json.gz +0 -0
  76. {cmpnd-0.8.2 → cmpnd-0.8.3}/scripts/gepa_resume_bug.py +0 -0
  77. {cmpnd-0.8.2 → cmpnd-0.8.3}/scripts/seed_review_run.py +0 -0
  78. {cmpnd-0.8.2 → cmpnd-0.8.3}/stubs/dspy/__init__.pyi +0 -0
  79. {cmpnd-0.8.2 → cmpnd-0.8.3}/stubs/dspy/primitives/__init__.pyi +0 -0
  80. {cmpnd-0.8.2 → cmpnd-0.8.3}/stubs/dspy/primitives/prediction.pyi +0 -0
  81. {cmpnd-0.8.2 → cmpnd-0.8.3}/stubs/dspy/utils/__init__.pyi +0 -0
  82. {cmpnd-0.8.2 → cmpnd-0.8.3}/stubs/dspy/utils/callback.pyi +0 -0
  83. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/__init__.py +0 -0
  84. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/_exporter_helpers.py +0 -0
  85. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/_identity_helpers.py +0 -0
  86. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/__init__.py +0 -0
  87. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/committee_task.py +0 -0
  88. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/conftest.py +0 -0
  89. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  90. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  91. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/fake_lm.py +0 -0
  92. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/helpers.py +0 -0
  93. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_capture_prompts.py +0 -0
  94. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_composite_module.py +0 -0
  95. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_dataset_auto_creation.py +0 -0
  96. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_deploy_execute.py +0 -0
  97. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_deploy_programs.py +0 -0
  98. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_edge_cases.py +0 -0
  99. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_example_hash.py +0 -0
  100. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_gepa_optimization.py +0 -0
  101. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_lm_accuracy.py +0 -0
  102. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_module_tracing.py +0 -0
  103. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_optimization_lineage.py +0 -0
  104. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_optimization_metric_identity.py +0 -0
  105. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_optimize_failures.py +0 -0
  106. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_optimize_loop.py +0 -0
  107. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_optimize_review_committee.py +0 -0
  108. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  109. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_parallel_export.py +0 -0
  110. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_project_attribution.py +0 -0
  111. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_react_tool_spans.py +0 -0
  112. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_save_load.py +0 -0
  113. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_trace_structure.py +0 -0
  114. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/e2e/test_user_attribution.py +0 -0
  115. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/01_predict_qa.json +0 -0
  116. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  117. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  118. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  119. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  120. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  121. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/07_program_of_thought.json +0 -0
  122. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/08_opaque_module.json +0 -0
  123. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  124. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/10_react.json +0 -0
  125. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/11_refine.json +0 -0
  126. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/12_retrieve.json +0 -0
  127. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/13_knn.json +0 -0
  128. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/14_parallel.json +0 -0
  129. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/identity_vectors/README.md +0 -0
  130. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/integration/__init__.py +0 -0
  131. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/integration/conftest.py +0 -0
  132. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  133. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/integration/test_logs_renders_traces.py +0 -0
  134. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/integration/test_optimizing_progress.py +0 -0
  135. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/integration/test_unhappy_path.py +0 -0
  136. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/__init__.py +0 -0
  137. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/conftest.py +0 -0
  138. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/seed_parity.py +0 -0
  139. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_auth.py +0 -0
  140. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_datasets.py +0 -0
  141. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_deployments.py +0 -0
  142. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_evals.py +0 -0
  143. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_health.py +0 -0
  144. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_modules.py +0 -0
  145. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_optimizations.py +0 -0
  146. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_programs.py +0 -0
  147. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_signatures.py +0 -0
  148. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_trace_streaming.py +0 -0
  149. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/parity/test_traces.py +0 -0
  150. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/__init__.py +0 -0
  151. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/_provider.py +0 -0
  152. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/_reliability.py +0 -0
  153. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/mint_bedrock_token.py +0 -0
  154. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_deploy_programs.py +0 -0
  155. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_optimize_client_committee.py +0 -0
  156. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_optimize_loop.py +0 -0
  157. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_optimize_loop_wasm.py +0 -0
  158. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_optimize_stress.py +0 -0
  159. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_provider_roles.py +0 -0
  160. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_real_provider_twins.py +0 -0
  161. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_reliability.py +0 -0
  162. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/stress/test_rlm_wasm.py +0 -0
  163. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_callback.py +0 -0
  164. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_candidate_lineage.py +0 -0
  165. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/__init__.py +0 -0
  166. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/conftest.py +0 -0
  167. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_admin.py +0 -0
  168. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_api_client.py +0 -0
  169. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_auth.py +0 -0
  170. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_credentials.py +0 -0
  171. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_datasets.py +0 -0
  172. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_deployments.py +0 -0
  173. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_evals.py +0 -0
  174. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_login.py +0 -0
  175. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_main.py +0 -0
  176. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_optimizations.py +0 -0
  177. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_org.py +0 -0
  178. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_parity.py +0 -0
  179. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_programs.py +0 -0
  180. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_shell.py +0 -0
  181. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_top.py +0 -0
  182. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cli/test_traces.py +0 -0
  183. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_clustering/__init__.py +0 -0
  184. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_clustering/conftest.py +0 -0
  185. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_clustering/test_edge_cases.py +0 -0
  186. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_clustering/test_hash_consistency.py +0 -0
  187. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_clustering/test_type_conversion.py +0 -0
  188. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_cmp551_grouping_identity.py +0 -0
  189. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_configuration.py +0 -0
  190. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_context.py +0 -0
  191. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_context_propagation.py +0 -0
  192. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_dataset_polling.py +0 -0
  193. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_dataset_sync.py +0 -0
  194. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_datasets.py +0 -0
  195. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_decorators.py +0 -0
  196. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_deploy.py +0 -0
  197. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_eval.py +0 -0
  198. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_exception_paths.py +0 -0
  199. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_execute.py +0 -0
  200. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_exporter_real.py +0 -0
  201. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_fake_lm.py +0 -0
  202. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_gepa_callback.py +0 -0
  203. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_gepa_integration.py +0 -0
  204. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_gepa_resume.py +0 -0
  205. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_identity.py +0 -0
  206. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_imaging.py +0 -0
  207. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_import_weight.py +0 -0
  208. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_integration.py +0 -0
  209. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_ir_encoder_properties.py +0 -0
  210. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_ir_golden_vectors.py +0 -0
  211. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_ir_roundtrip.py +0 -0
  212. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_models.py +0 -0
  213. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_optimization_progress.py +0 -0
  214. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_optimize_lift.py +0 -0
  215. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_optimize_terminal_failure.py +0 -0
  216. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_packaging.py +0 -0
  217. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_packaging_callable_source.py +0 -0
  218. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_packaging_program_source.py +0 -0
  219. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_program_lineage_persist.py +0 -0
  220. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_project_payload.py +0 -0
  221. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_reflector_properties.py +0 -0
  222. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_reflector_smoke.py +0 -0
  223. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_repro_reactv2_bugs.py +0 -0
  224. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_rlm_patch.py +0 -0
  225. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_seed_review_run.py +0 -0
  226. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_stats_export.py +0 -0
  227. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_trace_render.py +0 -0
  228. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_tracked_gepa.py +0 -0
  229. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_tracker_enrich.py +0 -0
  230. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_truncation.py +0 -0
  231. {cmpnd-0.8.2 → cmpnd-0.8.3}/tests/test_xxh64.py +0 -0
  232. {cmpnd-0.8.2 → cmpnd-0.8.3}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmpnd
3
- Version: 0.8.2
3
+ Version: 0.8.3
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>
@@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Any, cast
23
23
 
24
24
  import httpx
25
25
 
26
- from . import configuration
26
+ from . import configuration, retry
27
27
 
28
28
  if TYPE_CHECKING:
29
29
  from . import models
@@ -55,73 +55,73 @@ def _send_json(client: httpx.Client, method: str, path: str, payload: Any) -> ht
55
55
  return client.request(method, path, content=body, headers=_GZIP_HEADERS)
56
56
 
57
57
 
58
- # Gateway/overload statuses a data POST should retry rather than count as a hard
59
- # export error: the backend (or the proxy in front of it) is transiently busy,
60
- # not rejecting the payload. A retry is safe because serve dedups on ingest —
61
- # traces by (org_id, trace_id, start_time) and spans by the spans_dedup index —
62
- # so a re-POST of a request that actually landed can't double-insert.
63
- _RETRYABLE_STATUS = frozenset({502, 503, 504})
58
+ # Backpressure, not unavailability: the server wants the record shed, so the
59
+ # caller counts it as *dropped* and it is never retried — not even carrying a
60
+ # retryable flag. Named because that exemption is the one thing the contract's
61
+ # body-flag read must not be able to override.
62
+ _SHED_STATUS = 429
64
63
 
65
- # Backpressure, not unavailability: the server's buffer is full and it wants the
66
- # record dropped, so the caller counts it as *dropped* and we never retry it —
67
- # not even if the body carried a retryable flag. Named because that exemption is
68
- # the one thing the body-flag read below must not be able to override.
69
- _DROP_STATUS = 429
70
64
 
65
+ class ExportRetriesExhausted(RuntimeError):
66
+ """A data POST rode through transient answers until its budget ran out.
71
67
 
72
- def _server_says_retryable(response: httpx.Response) -> bool:
73
- """Whether the server explicitly marked this error response retryable.
74
-
75
- The `"retryable": true` error-body field is the server's stable machine
76
- signal for "this failed transiently and did not commit — send it again". It
77
- is what keeps this side off a hard-coded status list: the server can start
78
- signalling a new transient condition without an SDK release. A body that
79
- isn't JSON, isn't an object, or lacks the field reads as not-retryable.
68
+ Distinct from the underlying status error so the exhaustion — not whichever
69
+ blip happened to be last — is what a caller sees and classifies, and so the
70
+ count and the window it covered can be named in the message.
80
71
  """
81
- try:
82
- body = response.json()
83
- except Exception:
84
- return False
85
- return isinstance(body, dict) and body.get("retryable") is True
86
72
 
87
73
 
88
74
  def _is_retryable_export_error(exc: BaseException) -> bool:
89
75
  """Whether a failed data send is a transient delivery blip worth retrying.
90
76
 
91
- True for gateway/overload statuses (502/503/504), a response the server
92
- explicitly flagged retryable (see `_server_says_retryable`), and httpx
93
- transport/timeout errors (connect/read/pool timeouts, connection
94
- refused/reset). False for everything else — 4xx (client error, a retry won't
95
- help), 429 (backpressure, the caller records it as *dropped* not errored, and
96
- no body flag overrides that), and an unflagged 500 (the platform's to answer
97
- for, mirroring the is_external_outage boundary). Non-httpx exceptions are not
98
- retried (a bug in our own build path shouldn't loop)."""
77
+ A failed *response* is classified by the platform's retryable-error contract
78
+ (``cmpnd.retry``): the body's ``retryable: true`` first, then the
79
+ gateway/overload statuses. So 4xx (client error, a retry won't help) and a
80
+ plain 500 (the platform's to answer for, mirroring the is_external_outage
81
+ boundary) fail fast, while a transient condition the server signals on a
82
+ status this client has no opinion about is honoured.
83
+
84
+ **429 is the one status that outranks the flag**, which is why it is checked
85
+ before the contract rather than left to it. It is backpressure — the server's
86
+ buffer is full and it wants the record shed, which the caller records as
87
+ *dropped* rather than errored — so re-sending would amplify the very overload
88
+ the status exists to relieve. A general classifier cannot make that call: it
89
+ is a property of this path, where a drop is a legitimate outcome. The
90
+ contract stays free of the exemption so a poll, which has no drop to fall
91
+ back on, is not forced into it.
92
+
93
+ An httpx transport/timeout error (connect/read/pool timeout, connection
94
+ refused/reset) is transient too — there is no answer to read, and the send
95
+ is safe to repeat because serve dedups on ingest (traces by
96
+ (org_id, trace_id, start_time), spans by the spans_dedup index), so a
97
+ re-POST of a request that actually landed can't double-insert. Non-httpx
98
+ exceptions are not retried (a bug in our own build path shouldn't loop)."""
99
99
  if isinstance(exc, httpx.HTTPStatusError):
100
- status = exc.response.status_code
101
- if status == _DROP_STATUS:
100
+ if exc.response.status_code == _SHED_STATUS:
102
101
  return False
103
- return status in _RETRYABLE_STATUS or _server_says_retryable(exc.response)
102
+ return retry.is_retryable(exc.response)
104
103
  return isinstance(exc, (httpx.TimeoutException, httpx.TransportError))
105
104
 
106
105
 
107
- def _retry_after_seconds(exc: BaseException) -> float | None:
108
- """The server's own backoff hint for this failure, in seconds, or None.
106
+ def _may_resend_a_minting_post(exc: BaseException) -> bool:
107
+ """The same question as above, asked of a POST the server mints a record for.
108
+
109
+ The data path may re-send almost anything because serve dedups on ingest, so
110
+ a repeat of a request that actually landed collapses into the row already
111
+ there. A start POST has no such safety net — it carries the "new" sentinel
112
+ and the server allocates the id — so a re-send that the first attempt
113
+ already completed produces a *second* run, and the caller only ever learns
114
+ about one of them.
109
115
 
110
- Only the delta-seconds form of `Retry-After` is honored — that is what the
111
- server emits alongside a retryable error. A missing header, the HTTP-date
112
- form, or an unparseable value returns None so the caller falls back to its
113
- own exponential backoff rather than trusting a number it can't read. A
114
- transport error has no response at all and so has no hint.
116
+ That narrows what earns a retry to the two answers which say the first
117
+ attempt cannot have landed: the server's own ``retryable``, emitted having
118
+ already failed the work, and a connection that was never established. A
119
+ read timeout or a bare 502/504 is excluded precisely because it looks
120
+ identical whether the server did the work or not.
115
121
  """
116
- if not isinstance(exc, httpx.HTTPStatusError):
117
- return None
118
- raw = exc.response.headers.get("retry-after")
119
- if not raw:
120
- return None
121
- try:
122
- return max(0.0, float(raw))
123
- except ValueError:
124
- return None
122
+ if isinstance(exc, httpx.HTTPStatusError):
123
+ return retry.signalled_retryable(exc.response)
124
+ return retry.never_left_the_client(exc)
125
125
 
126
126
 
127
127
  @dataclass
@@ -179,24 +179,6 @@ class BatchExporter:
179
179
  # never open a socket. httpx.Client is safe to share across threads.
180
180
  self._sync_client: httpx.Client | None = None
181
181
  self._sync_client_lock = threading.Lock()
182
- # Small courtesy-retry budget for a failed start POST before raising
183
- # (fail-loud, no local-id fallback). Instance attrs so tests can tune.
184
- self._start_post_attempts = 3
185
- self._start_post_backoff = 0.5
186
-
187
- # Bounded retry for the best-effort data POSTs (traces/spans/stats/…):
188
- # a transient gateway/overload or transport blip retries with backoff
189
- # instead of immediately counting a hard export error. Idempotent on the
190
- # serve side (see _is_retryable_export_error). Instance attrs so tests
191
- # can zero the backoff. Distinct from the start-post budget above.
192
- self._data_post_attempts = 3
193
- self._data_post_backoff = 0.5
194
- # Ceiling on a single inter-attempt wait, including one the server asked
195
- # for via Retry-After. The exporter thread is the only thing draining the
196
- # queue, so an over-long wait doesn't just delay this send — it stalls
197
- # every queued record behind it and risks the queue filling. Honoring the
198
- # server's hint is worth it; honoring an unbounded one is not.
199
- self._data_post_max_delay = 5.0
200
182
 
201
183
  # Guard against registering multiple exit handlers on repeated start()
202
184
  self._atexit_registered = False
@@ -209,6 +191,13 @@ class BatchExporter:
209
191
  self._exported_count = 0
210
192
  self._dropped_count = 0
211
193
  self._error_count = 0
194
+ # What the wire actually answered, so a failed export is diagnosable
195
+ # past a bare error count: the distribution of non-2xx statuses seen, a
196
+ # sample of the last error body, and how many transient answers the
197
+ # retry budget rode through to keep the run alive.
198
+ self._observed_statuses: dict[int, int] = {}
199
+ self._last_error_body = ""
200
+ self._transient_ridden = 0
212
201
  self._stats_lock = threading.Lock()
213
202
 
214
203
  def start(self) -> None:
@@ -377,24 +366,53 @@ class BatchExporter:
377
366
  def _post_start_sync(self, path: str, payload: dict[str, Any], *, id_field: str) -> str:
378
367
  """POST a start record synchronously and return the server-assigned id.
379
368
 
380
- Retries a bounded number of times with exponential backoff, then
381
- raises — there is no local-id fallback by design.
369
+ **The server mints the id here** — the payload carries the "new"
370
+ sentinel — so this POST is not idempotent and what it may re-send is
371
+ narrower than the data path's. Two answers earn a retry, and only two:
372
+ the server's own ``retryable``, which is it saying the write did not
373
+ land, and a connection that was never established, which carried
374
+ nothing to it. A bare 502/504 does not: that comes from a hop with no
375
+ idea whether the server processed the request, and re-sending on the
376
+ guess is how one run start becomes two runs.
377
+
378
+ Within that narrowing it rides the full budget, because the window this
379
+ exists to survive outlasts a couple of courtesy attempts. There is no
380
+ local-id fallback by design: a run that cannot be started fails loud.
382
381
  """
383
382
  client = self._sync_http_client()
384
- last_exc: Exception | None = None
385
- for attempt in range(self._start_post_attempts):
383
+ tally = retry.TransientTally()
384
+ while True:
386
385
  try:
387
386
  response = _send_json(client, "POST", path, payload)
388
387
  response.raise_for_status()
389
388
  assigned = response.json().get(id_field)
390
389
  if not assigned:
391
390
  raise RuntimeError(f"{path} response missing {id_field}: {response.text}")
391
+ if tally.count:
392
+ self._note_ridden_through(tally.count)
393
+ logger.warning(
394
+ "cmpnd: rode through %s to start a run via %s", tally.describe(time.monotonic()), path
395
+ )
392
396
  return str(assigned)
393
397
  except Exception as e:
394
- last_exc = e
395
- if attempt + 1 < self._start_post_attempts:
396
- time.sleep(self._start_post_backoff * (2**attempt))
397
- raise RuntimeError(f"failed to start run via {path} after {self._start_post_attempts} attempts") from last_exc
398
+ resp = e.response if isinstance(e, httpx.HTTPStatusError) else None
399
+ if resp is not None:
400
+ self._note_status(resp)
401
+ if not _may_resend_a_minting_post(e):
402
+ # RuntimeError whatever the cause, because that is this
403
+ # method's documented failure: a run start either yields an
404
+ # id or fails loud, and a caller should not have to know
405
+ # which layer said no.
406
+ raise RuntimeError(f"failed to start run via {path}: {e}") from e
407
+ tally.record(
408
+ str(resp.status_code) if resp is not None else type(e).__name__,
409
+ at=time.monotonic(),
410
+ sample=str(e),
411
+ )
412
+ now = time.monotonic()
413
+ if tally.exhausted(now) or not self._wait_before_retry(retry.delay_for(tally.count - 1, resp)):
414
+ self._note_ridden_through(tally.count)
415
+ raise RuntimeError(f"failed to start run via {path}: gave up after {tally.describe(now)}") from e
398
416
 
399
417
  def export_optimization_progress(self, optimization_run: tracker.OptimizationTracker) -> None:
400
418
  """Queue an optimization progress update for export (non-blocking).
@@ -523,45 +541,88 @@ class BatchExporter:
523
541
  raise RuntimeError("BackgroundExporter has no HTTP client (not started or already shut down)")
524
542
  return self._client
525
543
 
544
+ def _wait_before_retry(self, seconds: float) -> bool:
545
+ """Wait out a backoff, interruptibly. False means "stop retrying".
546
+
547
+ The wait runs on the exporter's own threads, so a plain sleep would hold
548
+ the process open for the length of the budget at exit. Waiting on the
549
+ shutdown event instead ends it the moment shutdown is signalled — and
550
+ means the drain that runs *after* shutdown gets no retries at all, which
551
+ is the right trade: it must not delay the exit it is part of.
552
+ """
553
+ if seconds <= 0:
554
+ return not self._shutdown.is_set()
555
+ return not self._shutdown.wait(seconds)
556
+
526
557
  def _send_json_retrying(self, client: httpx.Client, method: str, path: str, payload: Any) -> httpx.Response:
527
- """`_send_json` + `raise_for_status`, retrying transient delivery blips.
528
-
529
- Retries `_data_post_attempts` times on a retryable failure — a
530
- gateway/overload status, a response the server flagged `retryable`, or a
531
- transport/timeout error (see `_is_retryable_export_error`) — then
532
- re-raises. The wait between attempts is the server's `Retry-After` hint
533
- when it sent one, else exponential backoff, capped either way by
534
- `_data_post_max_delay`. A non-retryable failure (4xx, 429, an unflagged
535
- 500, or a 2xx that somehow raised) propagates on the first attempt, so
536
- the caller's existing except blocks still record it (429 → dropped,
537
- everything else → error) exactly as before — the only change is that a
538
- transient blip now gets a few tries before it counts."""
539
- last_exc: Exception | None = None
540
- for attempt in range(self._data_post_attempts):
558
+ """`_send_json` + `raise_for_status`, riding through transient blips.
559
+
560
+ Retries a failure the retryable-error contract calls transient (see
561
+ `_is_retryable_export_error`) on `cmpnd.retry`'s budget: jittered
562
+ exponential backoff, floored by any `Retry-After` the server sent and
563
+ capped so no hop can park the exporter, spanning a window sized on the
564
+ longest transient outage we've measured. A non-retryable failure (4xx,
565
+ 429, a plain 500, or a 2xx that somehow raised) propagates on the first
566
+ attempt, so the caller's except blocks record it (429 → dropped,
567
+ everything else → error) with no budget burned.
568
+
569
+ An exhausted budget raises ExportRetriesExhausted naming the count and
570
+ the window, so a run that could not be saved says what it endured.
571
+ """
572
+ tally = retry.TransientTally()
573
+ while True:
541
574
  try:
542
575
  response = _send_json(client, method, path, payload)
543
576
  response.raise_for_status()
577
+ if tally.count:
578
+ self._note_ridden_through(tally.count)
579
+ logger.warning("cmpnd: rode through %s to %s %s", tally.describe(time.monotonic()), method, path)
544
580
  return response
545
581
  except Exception as e:
546
- last_exc = e
547
- if _is_retryable_export_error(e) and attempt + 1 < self._data_post_attempts:
548
- delay = _retry_after_seconds(e)
549
- if delay is None:
550
- delay = self._data_post_backoff * (2**attempt)
551
- delay = min(delay, self._data_post_max_delay)
552
- logger.debug(
553
- "cmpnd: retrying %s %s after transient error in %.2fs (attempt %d): %s",
554
- method,
555
- path,
556
- delay,
557
- attempt + 1,
558
- e,
559
- )
560
- time.sleep(delay)
561
- continue
562
- raise
563
- assert last_exc is not None # unreachable: the loop either returns or raises
564
- raise last_exc
582
+ resp = e.response if isinstance(e, httpx.HTTPStatusError) else None
583
+ if resp is not None:
584
+ self._note_status(resp)
585
+ if not _is_retryable_export_error(e):
586
+ raise
587
+ tally.record(
588
+ str(resp.status_code) if resp is not None else type(e).__name__,
589
+ at=time.monotonic(),
590
+ sample=str(e),
591
+ )
592
+ now = time.monotonic()
593
+ if tally.exhausted(now) or not self._wait_before_retry(retry.delay_for(tally.count - 1, resp)):
594
+ self._note_ridden_through(tally.count)
595
+ raise ExportRetriesExhausted(f"{method} {path} gave up after {tally.describe(now)}: {e}") from e
596
+
597
+ def _note_status(self, response: httpx.Response) -> None:
598
+ """Record a non-2xx answer for the end-of-run diagnosis."""
599
+ with self._stats_lock:
600
+ self._observed_statuses[response.status_code] = self._observed_statuses.get(response.status_code, 0) + 1
601
+ body = (response.text or "").strip()
602
+ if body:
603
+ self._last_error_body = body[:400]
604
+
605
+ def _note_ridden_through(self, count: int) -> None:
606
+ with self._stats_lock:
607
+ self._transient_ridden += count
608
+
609
+ def failure_summary(self) -> str:
610
+ """What the wire answered, for a caller reporting a failed export.
611
+
612
+ An error count alone can't tell a saturated backend from a rejected
613
+ payload; the status distribution and one body can.
614
+ """
615
+ with self._stats_lock:
616
+ statuses = dict(self._observed_statuses)
617
+ ridden = self._transient_ridden
618
+ body = self._last_error_body
619
+ if not statuses:
620
+ return "no non-2xx responses observed"
621
+ mix = ", ".join(f"{code}={n}" for code, n in sorted(statuses.items()))
622
+ summary = f"non-2xx observed: {mix}; transient answers ridden through: {ridden}"
623
+ if body:
624
+ summary += f"; sample body: {body}"
625
+ return summary
565
626
 
566
627
  def _flush_batch(self, batch: list[ExportItem]) -> None:
567
628
  """Send a batch of items to the backend with parallel dispatch."""