cmpnd 0.4.0__tar.gz → 0.4.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 (213) hide show
  1. {cmpnd-0.4.0 → cmpnd-0.4.1}/PKG-INFO +1 -1
  2. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/callback.py +70 -10
  3. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/api_client.py +10 -0
  4. cmpnd-0.4.1/cmpnd/cli/commands/admin.py +274 -0
  5. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/org.py +44 -0
  6. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/optimizers/gepa_callback.py +5 -1
  7. {cmpnd-0.4.0 → cmpnd-0.4.1}/pyproject.toml +1 -1
  8. cmpnd-0.4.1/scripts/capture_review_run.py +162 -0
  9. cmpnd-0.4.1/scripts/fixtures/review_run.json.gz +0 -0
  10. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_callback.py +123 -51
  11. cmpnd-0.4.1/tests/test_cli/test_admin.py +201 -0
  12. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_api_client.py +26 -0
  13. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_org.py +25 -0
  14. {cmpnd-0.4.0 → cmpnd-0.4.1}/uv.lock +1 -1
  15. cmpnd-0.4.0/cmpnd/cli/commands/admin.py +0 -59
  16. cmpnd-0.4.0/scripts/fixtures/review_run.json.gz +0 -0
  17. cmpnd-0.4.0/tests/test_cli/test_admin.py +0 -33
  18. {cmpnd-0.4.0 → cmpnd-0.4.1}/.gitignore +0 -0
  19. {cmpnd-0.4.0 → cmpnd-0.4.1}/CLAUDE.md +0 -0
  20. {cmpnd-0.4.0 → cmpnd-0.4.1}/README.md +0 -0
  21. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/__init__.py +0 -0
  22. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/_gepa_patch.py +0 -0
  23. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/_program_patch.py +0 -0
  24. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/__init__.py +0 -0
  25. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/__init__.py +0 -0
  26. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/auth.py +0 -0
  27. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/datasets.py +0 -0
  28. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/deployments.py +0 -0
  29. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/evals.py +0 -0
  30. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/login.py +0 -0
  31. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/optimizations.py +0 -0
  32. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/programs.py +0 -0
  33. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/top.py +0 -0
  34. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/commands/traces.py +0 -0
  35. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/credentials.py +0 -0
  36. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/shell.py +0 -0
  37. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/cli/verbs.py +0 -0
  38. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/configuration.py +0 -0
  39. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/context.py +0 -0
  40. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/dataset_sync.py +0 -0
  41. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/datasets.py +0 -0
  42. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/decorators.py +0 -0
  43. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/deployment.py +0 -0
  44. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/eval_handler.py +0 -0
  45. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/execution.py +0 -0
  46. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/exporter.py +0 -0
  47. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/helpers.py +0 -0
  48. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/identity.py +0 -0
  49. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/ir/__init__.py +0 -0
  50. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/ir/decode.py +0 -0
  51. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/ir/encode.py +0 -0
  52. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/ir/hash.py +0 -0
  53. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/ir/program.py +0 -0
  54. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/ir/reflection.py +0 -0
  55. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/ir/types.py +0 -0
  56. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/models.py +0 -0
  57. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/optimization.py +0 -0
  58. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/optimizers/__init__.py +0 -0
  59. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/optimizers/gepa.py +0 -0
  60. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/optimizers/resume.py +0 -0
  61. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/optimizers/tracker.py +0 -0
  62. {cmpnd-0.4.0 → cmpnd-0.4.1}/cmpnd/packaging.py +0 -0
  63. {cmpnd-0.4.0 → cmpnd-0.4.1}/docs/2026-04-02-code-review.md +0 -0
  64. {cmpnd-0.4.0 → cmpnd-0.4.1}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  65. {cmpnd-0.4.0 → cmpnd-0.4.1}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  66. {cmpnd-0.4.0 → cmpnd-0.4.1}/docs/sdk-instrumentation.md +0 -0
  67. {cmpnd-0.4.0 → cmpnd-0.4.1}/examples/local_ollama.py +0 -0
  68. {cmpnd-0.4.0 → cmpnd-0.4.1}/scripts/gepa_resume_bug.py +0 -0
  69. {cmpnd-0.4.0 → cmpnd-0.4.1}/scripts/seed_review_run.py +0 -0
  70. {cmpnd-0.4.0 → cmpnd-0.4.1}/stubs/dspy/__init__.pyi +0 -0
  71. {cmpnd-0.4.0 → cmpnd-0.4.1}/stubs/dspy/primitives/__init__.pyi +0 -0
  72. {cmpnd-0.4.0 → cmpnd-0.4.1}/stubs/dspy/primitives/prediction.pyi +0 -0
  73. {cmpnd-0.4.0 → cmpnd-0.4.1}/stubs/dspy/utils/__init__.pyi +0 -0
  74. {cmpnd-0.4.0 → cmpnd-0.4.1}/stubs/dspy/utils/callback.pyi +0 -0
  75. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/__init__.py +0 -0
  76. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/_exporter_helpers.py +0 -0
  77. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/_identity_helpers.py +0 -0
  78. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/__init__.py +0 -0
  79. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/committee_task.py +0 -0
  80. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/conftest.py +0 -0
  81. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  82. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  83. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/fake_lm.py +0 -0
  84. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/helpers.py +0 -0
  85. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_capture_prompts.py +0 -0
  86. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_composite_module.py +0 -0
  87. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_dataset_auto_creation.py +0 -0
  88. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_deploy_execute.py +0 -0
  89. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_deploy_programs.py +0 -0
  90. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_edge_cases.py +0 -0
  91. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_example_hash.py +0 -0
  92. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_gepa_optimization.py +0 -0
  93. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_lm_accuracy.py +0 -0
  94. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_module_tracing.py +0 -0
  95. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_optimization_lineage.py +0 -0
  96. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_optimization_metric_identity.py +0 -0
  97. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_optimize_failures.py +0 -0
  98. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_optimize_loop.py +0 -0
  99. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_optimize_review_committee.py +0 -0
  100. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  101. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_parallel_export.py +0 -0
  102. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_project_attribution.py +0 -0
  103. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_react_tool_spans.py +0 -0
  104. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_save_load.py +0 -0
  105. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_trace_structure.py +0 -0
  106. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/e2e/test_user_attribution.py +0 -0
  107. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/01_predict_qa.json +0 -0
  108. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  109. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  110. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  111. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  112. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  113. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/07_program_of_thought.json +0 -0
  114. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/08_opaque_module.json +0 -0
  115. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  116. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/10_react.json +0 -0
  117. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/11_refine.json +0 -0
  118. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/12_retrieve.json +0 -0
  119. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/13_knn.json +0 -0
  120. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/14_parallel.json +0 -0
  121. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/identity_vectors/README.md +0 -0
  122. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/integration/__init__.py +0 -0
  123. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/integration/conftest.py +0 -0
  124. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  125. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/integration/test_logs_renders_traces.py +0 -0
  126. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/integration/test_optimizing_progress.py +0 -0
  127. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/integration/test_unhappy_path.py +0 -0
  128. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/__init__.py +0 -0
  129. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/conftest.py +0 -0
  130. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/seed_parity.py +0 -0
  131. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_auth.py +0 -0
  132. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_datasets.py +0 -0
  133. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_deployments.py +0 -0
  134. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_evals.py +0 -0
  135. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_health.py +0 -0
  136. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_modules.py +0 -0
  137. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_optimizations.py +0 -0
  138. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_programs.py +0 -0
  139. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_signatures.py +0 -0
  140. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_trace_streaming.py +0 -0
  141. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/parity/test_traces.py +0 -0
  142. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/__init__.py +0 -0
  143. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/_reliability.py +0 -0
  144. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/test_deploy_programs.py +0 -0
  145. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/test_multi_client_stress.py +0 -0
  146. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/test_optimize_client_committee.py +0 -0
  147. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/test_optimize_loop.py +0 -0
  148. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/test_optimize_stress.py +0 -0
  149. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/test_real_provider_twins.py +0 -0
  150. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/stress/test_reliability.py +0 -0
  151. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/__init__.py +0 -0
  152. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/conftest.py +0 -0
  153. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_auth.py +0 -0
  154. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_credentials.py +0 -0
  155. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_datasets.py +0 -0
  156. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_deployments.py +0 -0
  157. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_evals.py +0 -0
  158. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_login.py +0 -0
  159. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_main.py +0 -0
  160. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_optimizations.py +0 -0
  161. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_parity.py +0 -0
  162. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_programs.py +0 -0
  163. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_shell.py +0 -0
  164. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_top.py +0 -0
  165. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_cli/test_traces.py +0 -0
  166. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_clustering/__init__.py +0 -0
  167. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_clustering/conftest.py +0 -0
  168. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_clustering/test_edge_cases.py +0 -0
  169. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_clustering/test_hash_consistency.py +0 -0
  170. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_clustering/test_type_conversion.py +0 -0
  171. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_configuration.py +0 -0
  172. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_context.py +0 -0
  173. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_context_propagation.py +0 -0
  174. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_dataset_polling.py +0 -0
  175. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_dataset_sync.py +0 -0
  176. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_datasets.py +0 -0
  177. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_decorators.py +0 -0
  178. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_deploy.py +0 -0
  179. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_eval.py +0 -0
  180. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_eval_start.py +0 -0
  181. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_exception_paths.py +0 -0
  182. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_execute.py +0 -0
  183. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_exporter.py +0 -0
  184. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_exporter_real.py +0 -0
  185. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_fake_lm.py +0 -0
  186. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_gepa_callback.py +0 -0
  187. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_gepa_integration.py +0 -0
  188. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_gepa_resume.py +0 -0
  189. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_identity.py +0 -0
  190. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_integration.py +0 -0
  191. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_ir_encoder_properties.py +0 -0
  192. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_ir_golden_vectors.py +0 -0
  193. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_ir_roundtrip.py +0 -0
  194. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_models.py +0 -0
  195. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_optimization_start.py +0 -0
  196. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_optimize.py +0 -0
  197. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_optimize_lift.py +0 -0
  198. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_optimize_terminal_failure.py +0 -0
  199. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_packaging.py +0 -0
  200. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_packaging_callable_source.py +0 -0
  201. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_packaging_program_source.py +0 -0
  202. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_program_lineage_persist.py +0 -0
  203. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_project_payload.py +0 -0
  204. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_reflector_properties.py +0 -0
  205. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_reflector_smoke.py +0 -0
  206. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_repro_reactv2_bugs.py +0 -0
  207. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_schemas.py +0 -0
  208. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_seed_review_run.py +0 -0
  209. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_stats_export.py +0 -0
  210. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_tracked_gepa.py +0 -0
  211. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_tracker_enrich.py +0 -0
  212. {cmpnd-0.4.0 → cmpnd-0.4.1}/tests/test_truncation.py +0 -0
  213. {cmpnd-0.4.0 → cmpnd-0.4.1}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmpnd
3
- Version: 0.4.0
3
+ Version: 0.4.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>
@@ -747,18 +747,20 @@ class _LMMixin(_CallbackBase):
747
747
 
748
748
  context.pop_span()
749
749
 
750
- # If this LM call has no trace context but we're in an optimization,
751
- # accumulate token usage for the optimization cost computation.
752
- # This captures GEPA reflection LM calls which run outside traced program flow.
750
+ # An LM call with no active trace context, made while an optimization is
751
+ # running, is GEPA optimizer machinery an LM call outside any program
752
+ # rollout. Two phases produce these: reflection (the instructor proposing
753
+ # new instructions, between evaluations) and metric (an LLM-as-judge /
754
+ # semantic metric scoring outputs, during evaluation). Rather than drop
755
+ # them, wrap each in its own trace linked to the optimization run so it
756
+ # shows up alongside the student traces (CMP-485). serve's
757
+ # TracedTokensForRun then accounts for their cost from the span, so these
758
+ # must NOT also be added to _orphaned_lm_usage / untraced_lm_usage — that
759
+ # would double-count them in the optimization total.
753
760
  current_trace = context.get_current_trace()
754
761
  if current_trace is None and self._active_optimization_run_id is not None:
755
- model_name = span.model_name or "unknown"
756
- if model_name not in self._orphaned_lm_usage:
757
- self._orphaned_lm_usage[model_name] = {"prompt_tokens": 0, "completion_tokens": 0}
758
- self._orphaned_lm_usage[model_name]["prompt_tokens"] += span.prompt_tokens or 0
759
- self._orphaned_lm_usage[model_name]["completion_tokens"] += span.completion_tokens or 0
760
- logger.debug(f"Orphaned LM usage ({model_name}): +{span.prompt_tokens}p/{span.completion_tokens}c tokens")
761
- return # Don't export orphaned spans (no trace to attach to)
762
+ self._emit_optimization_lm_trace(span)
763
+ return # handled as a standalone GEPA-process trace
762
764
 
763
765
  # Track span by trace_id for metric aggregation (LM spans have token info!)
764
766
  if span.trace_id:
@@ -772,6 +774,64 @@ class _LMMixin(_CallbackBase):
772
774
  if exp:
773
775
  exp.export_span(span)
774
776
 
777
+ def _emit_optimization_lm_trace(self, span: models.SpanBuilder) -> None:
778
+ """Wrap an LM call the GEPA optimizer made outside a program rollout in
779
+ its own trace, linked to the active optimization run.
780
+
781
+ Two phases produce such calls, on different models:
782
+ - "reflection": GEPA's instruction proposer (the reflection LM), run in
783
+ the optimize loop between evaluations.
784
+ - "metric": an LLM-as-judge / semantic metric (the student LM or a
785
+ separate judge LM), run during candidate evaluation after the program
786
+ trace has already closed.
787
+
788
+ We distinguish them by `_active_eval_run` — set only while DSPy Evaluate
789
+ is running — instead of assuming every untraced call is reflection (that
790
+ would mislabel judge calls). The trace is named with the umbrella "GEPA";
791
+ the specific model stays on the span's model_name, and `gepa.phase`
792
+ carries the distinction (CMP-485). Each becomes a standalone one-span
793
+ trace so the prompt, response, tokens and cost are visible alongside the
794
+ student traces instead of being reduced to an aggregate token count.
795
+ """
796
+ run_id = self._active_optimization_run_id
797
+ if not run_id:
798
+ return
799
+
800
+ phase = "metric" if self._active_eval_run is not None else "reflection"
801
+ span.attributes["gepa.phase"] = phase
802
+
803
+ trace = models.TraceBuilder(
804
+ trace_id=span.trace_id or uuid4(),
805
+ start_time=span.start_time,
806
+ end_time=span.end_time,
807
+ program_name="GEPA",
808
+ )
809
+ # A metric-phase call can start while DSPy's Evaluate span is still on the
810
+ # context stack, so on_lm_start stamped this span with a parent_span_id for
811
+ # a span that isn't in this standalone one-span trace. This span IS the
812
+ # trace's root, so force it parentless and pin it to this trace so tree/root
813
+ # reconstruction works (CMP-485, Greptile P1).
814
+ span.parent_span_id = None
815
+ span.trace_id = trace.trace_id
816
+ trace.signature_name = span.model_name or phase
817
+ trace.status = span.status
818
+ trace.optimization_run_id = run_id
819
+ trace.tags["optimization_phase"] = phase
820
+
821
+ # Single-span trace: roll the span's usage up onto the trace so the
822
+ # traces list shows tokens/cost without a separate aggregation pass.
823
+ trace.prompt_tokens = span.prompt_tokens or 0
824
+ trace.completion_tokens = span.completion_tokens or 0
825
+ trace.total_tokens = span.total_tokens or 0
826
+ trace.total_cost = span.cost or 0.0
827
+ trace.span_count = 1
828
+
829
+ exp = exporter.get_exporter()
830
+ if exp:
831
+ exp.export_trace_stats(trace) # stats tier (never dropped)
832
+ trace._batch_spans = [span]
833
+ exp.export_trace(trace)
834
+
775
835
 
776
836
  class _AdapterMixin(_CallbackBase):
777
837
  """DSPy adapter callbacks (format/parse start/end)."""
@@ -49,6 +49,12 @@ class ApiClient:
49
49
  def post(self, path: str, json: dict[str, Any] | None = None) -> Any:
50
50
  return self._request("POST", path, json=json)
51
51
 
52
+ def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
53
+ return self._request("PATCH", path, json=json)
54
+
55
+ def put(self, path: str, json: dict[str, Any] | None = None) -> Any:
56
+ return self._request("PUT", path, json=json)
57
+
52
58
  def delete(self, path: str) -> Any:
53
59
  return self._request("DELETE", path)
54
60
 
@@ -77,6 +83,10 @@ class ApiClient:
77
83
  response = client.get(url, params=params)
78
84
  elif method == "DELETE":
79
85
  response = client.delete(url)
86
+ elif method == "PATCH":
87
+ response = client.patch(url, json=json)
88
+ elif method == "PUT":
89
+ response = client.put(url, json=json)
80
90
  else:
81
91
  response = client.post(url, json=json)
82
92
  except httpx.ConnectError:
@@ -0,0 +1,274 @@
1
+ """Platform-operator commands, hidden behind CMPND_CLI_ADMIN=1.
2
+
3
+ `cmpnd admin org create|set-owner|list|get|set-limit|set-github-login|elect-owner`
4
+ is the closed-beta org bootstrap and steward surface (CMP-126/CMP-278/CMP-472):
5
+ stand up a shared org with its GitHub auto-join key, anoint its first owner,
6
+ adjust its member cap or GitHub link, and grant ownership from the election
7
+ queue. `cmpnd admin user …` is the operator's user-lifecycle surface (list,
8
+ inspect, create, approve/reject the pending-signup queue, promote/demote admin,
9
+ disable/enable, reset a password, and manage a user's API-key scopes). `cmpnd
10
+ admin feature-flags …` reads and toggles the server's runtime flags. These hit
11
+ /api/v1/admin/* and require an API key carrying the admin:* scopes — the server
12
+ is the real authority. The CMPND_CLI_ADMIN env gate (mirroring CMPND_CLI_DEV)
13
+ only keeps these out of customers' --help; it grants nothing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from argparse import ArgumentParser, _SubParsersAction
19
+ from typing import Any
20
+
21
+ from .. import api_client
22
+
23
+
24
+ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
25
+ parser = subparsers.add_parser("admin", help="Platform-operator commands (CMPND_CLI_ADMIN)")
26
+ sub = parser.add_subparsers(dest="action")
27
+
28
+ _register_org(sub)
29
+ _register_user(sub)
30
+ _register_feature_flags(sub)
31
+
32
+
33
+ def _register_org(sub: _SubParsersAction[ArgumentParser]) -> None:
34
+ org = sub.add_parser("org", help="Organization bootstrap")
35
+ osub = org.add_subparsers(dest="org_action")
36
+
37
+ create = osub.add_parser("create", help="Create an organization")
38
+ create.add_argument("name", help="Organization name")
39
+ create.add_argument(
40
+ "--github-login",
41
+ dest="github_login",
42
+ default="",
43
+ help="GitHub org login for OAuth auto-join (optional)",
44
+ )
45
+ create.set_defaults(func=handle_org_create)
46
+
47
+ set_owner = osub.add_parser("set-owner", help="Make an existing user the org's owner")
48
+ set_owner.add_argument("org_id", help="Organization id")
49
+ set_owner.add_argument("email", help="Owner email (must already be a provisioned user)")
50
+ set_owner.set_defaults(func=handle_org_set_owner)
51
+
52
+ ls = osub.add_parser("list", help="List organizations")
53
+ ls.set_defaults(func=handle_org_list)
54
+
55
+ get = osub.add_parser("get", help="Show one organization")
56
+ get.add_argument("org_id", help="Organization id")
57
+ get.set_defaults(func=handle_org_get)
58
+
59
+ set_limit = osub.add_parser("set-limit", help="Set an org's member cap")
60
+ set_limit.add_argument("org_id", help="Organization id")
61
+ set_limit.add_argument("max", type=int, help="Maximum members")
62
+ set_limit.set_defaults(func=handle_org_set_limit)
63
+
64
+ set_github_login = osub.add_parser("set-github-login", help="Set (or clear) an org's GitHub login")
65
+ set_github_login.add_argument("org_id", help="Organization id")
66
+ set_github_login.add_argument("login", help='GitHub org login (pass "" to unlink)')
67
+ set_github_login.set_defaults(func=handle_org_set_github_login)
68
+
69
+ elect_owner = osub.add_parser("elect-owner", help="Grant ownership from the election queue")
70
+ elect_owner.add_argument("github_login", help="The org's GitHub login")
71
+ elect_owner.add_argument("user_id", help="User id to make owner")
72
+ elect_owner.set_defaults(func=handle_org_elect_owner)
73
+
74
+
75
+ def _register_user(sub: _SubParsersAction[ArgumentParser]) -> None:
76
+ user = sub.add_parser("user", help="User lifecycle management")
77
+ usub = user.add_subparsers(dest="user_action")
78
+
79
+ ls = usub.add_parser("list", help="List users")
80
+ ls.set_defaults(func=handle_user_list)
81
+
82
+ get = usub.add_parser("get", help="Show one user")
83
+ get.add_argument("id", help="User id")
84
+ get.set_defaults(func=handle_user_get)
85
+
86
+ create = usub.add_parser("create", help="Provision a user")
87
+ create.add_argument("email", help="User email")
88
+ create.add_argument("--name", default="", help="Display name (optional)")
89
+ create.add_argument("--org-id", dest="org_id", default="", help="Org to place the user in (optional)")
90
+ create.add_argument("--password", default="", help="Initial password (optional)")
91
+ create.add_argument("--admin", dest="admin", action="store_true", help="Grant platform-admin")
92
+ create.set_defaults(func=handle_user_create)
93
+
94
+ approve = usub.add_parser("approve", help="Approve a pending signup")
95
+ approve.add_argument("id", help="User id")
96
+ approve.set_defaults(func=handle_user_approve)
97
+
98
+ promote = usub.add_parser("promote", help="Grant platform-admin")
99
+ promote.add_argument("id", help="User id")
100
+ promote.set_defaults(func=handle_user_promote)
101
+
102
+ demote = usub.add_parser("demote", help="Revoke platform-admin")
103
+ demote.add_argument("id", help="User id")
104
+ demote.set_defaults(func=handle_user_demote)
105
+
106
+ reject = usub.add_parser("reject", help="Decline a pending signup")
107
+ reject.add_argument("id", help="User id")
108
+ reject.add_argument("--org-id", dest="org_id", required=True, help="Org the pending signup belongs to")
109
+ reject.set_defaults(func=handle_user_reject)
110
+
111
+ disable = usub.add_parser("disable", help="Disable a user")
112
+ disable.add_argument("id", help="User id")
113
+ disable.set_defaults(func=handle_user_disable)
114
+
115
+ enable = usub.add_parser("enable", help="Re-enable a user")
116
+ enable.add_argument("id", help="User id")
117
+ enable.set_defaults(func=handle_user_enable)
118
+
119
+ reset_password = usub.add_parser("reset-password", help="Set a user's password")
120
+ reset_password.add_argument("id", help="User id")
121
+ reset_password.add_argument("--password", required=True, help="New password")
122
+ reset_password.set_defaults(func=handle_user_reset_password)
123
+
124
+ keys = usub.add_parser("keys", help="List a user's API keys")
125
+ keys.add_argument("id", help="User id")
126
+ keys.set_defaults(func=handle_user_keys)
127
+
128
+ set_scopes = usub.add_parser("set-scopes", help="Replace an API key's scopes")
129
+ set_scopes.add_argument("id", help="User id")
130
+ set_scopes.add_argument("key_id", help="API key id")
131
+ set_scopes.add_argument(
132
+ "--scope",
133
+ dest="scopes",
134
+ action="append",
135
+ required=True,
136
+ help="A scope to grant (repeat for multiple)",
137
+ )
138
+ set_scopes.set_defaults(func=handle_user_set_scopes)
139
+
140
+
141
+ def _register_feature_flags(sub: _SubParsersAction[ArgumentParser]) -> None:
142
+ ff = sub.add_parser("feature-flags", help="Runtime feature flags")
143
+ ffsub = ff.add_subparsers(dest="ff_action")
144
+
145
+ ls = ffsub.add_parser("list", help="List feature flags")
146
+ ls.set_defaults(func=handle_feature_flags_list)
147
+
148
+ set_flag = ffsub.add_parser("set", help="Enable (or, with --disabled, disable) a flag")
149
+ set_flag.add_argument("name", help="Flag name")
150
+ set_flag.add_argument("--disabled", action="store_true", help="Disable the flag (default is enable)")
151
+ set_flag.set_defaults(func=handle_feature_flags_set)
152
+
153
+
154
+ # --- org handlers ---------------------------------------------------------
155
+
156
+
157
+ def handle_org_create(client: api_client.ApiClient, args: Any) -> Any:
158
+ return client.post(
159
+ "/api/v1/admin/organizations",
160
+ json={"name": args.name, "github_login": args.github_login},
161
+ )
162
+
163
+
164
+ def handle_org_set_owner(client: api_client.ApiClient, args: Any) -> Any:
165
+ # 204 No Content → post() returns None; synthesize a confirmation.
166
+ client.post(f"/api/v1/admin/organizations/{args.org_id}/owner", json={"email": args.email})
167
+ return {"org_id": args.org_id, "owner": args.email}
168
+
169
+
170
+ def handle_org_list(client: api_client.ApiClient, _args: Any) -> Any:
171
+ return client.get("/api/v1/admin/organizations")
172
+
173
+
174
+ def handle_org_get(client: api_client.ApiClient, args: Any) -> Any:
175
+ return client.get(f"/api/v1/admin/organizations/{args.org_id}")
176
+
177
+
178
+ def handle_org_set_limit(client: api_client.ApiClient, args: Any) -> Any:
179
+ return client.patch(f"/api/v1/admin/organizations/{args.org_id}", json={"max_members": args.max})
180
+
181
+
182
+ def handle_org_set_github_login(client: api_client.ApiClient, args: Any) -> Any:
183
+ # An empty login unlinks the org from GitHub auto-join; pass it through
184
+ # verbatim so the server can distinguish "clear" from "unchanged".
185
+ return client.patch(f"/api/v1/admin/organizations/{args.org_id}", json={"github_login": args.login})
186
+
187
+
188
+ def handle_org_elect_owner(client: api_client.ApiClient, args: Any) -> Any:
189
+ # 204 No Content → post() returns None; synthesize a confirmation.
190
+ client.post(
191
+ "/api/v1/admin/organizations/elect-owner",
192
+ json={"github_login": args.github_login, "user_id": args.user_id},
193
+ )
194
+ return {"elected": args.user_id, "github_login": args.github_login}
195
+
196
+
197
+ # --- user handlers --------------------------------------------------------
198
+
199
+
200
+ def handle_user_list(client: api_client.ApiClient, _args: Any) -> Any:
201
+ return client.get("/api/v1/admin/users")
202
+
203
+
204
+ def handle_user_get(client: api_client.ApiClient, args: Any) -> Any:
205
+ return client.get(f"/api/v1/admin/users/{args.id}")
206
+
207
+
208
+ def handle_user_create(client: api_client.ApiClient, args: Any) -> Any:
209
+ # email/name/org_id/is_admin are always sent; password is only included
210
+ # when supplied so the server keeps its own default (e.g. no local login).
211
+ body = {
212
+ "email": args.email,
213
+ "name": args.name,
214
+ "org_id": args.org_id,
215
+ "is_admin": args.admin,
216
+ }
217
+ if args.password:
218
+ body["password"] = args.password
219
+ return client.post("/api/v1/admin/users", json=body)
220
+
221
+
222
+ def handle_user_approve(client: api_client.ApiClient, args: Any) -> Any:
223
+ return client.patch(f"/api/v1/admin/users/{args.id}", json={"is_approved": True})
224
+
225
+
226
+ def handle_user_promote(client: api_client.ApiClient, args: Any) -> Any:
227
+ return client.patch(f"/api/v1/admin/users/{args.id}", json={"is_admin": True})
228
+
229
+
230
+ def handle_user_demote(client: api_client.ApiClient, args: Any) -> Any:
231
+ return client.patch(f"/api/v1/admin/users/{args.id}", json={"is_admin": False})
232
+
233
+
234
+ def handle_user_reject(client: api_client.ApiClient, args: Any) -> Any:
235
+ # 204 No Content → post() returns None; synthesize a confirmation.
236
+ client.post(f"/api/v1/admin/users/{args.id}/reject", json={"org_id": args.org_id})
237
+ return {"rejected": args.id}
238
+
239
+
240
+ def handle_user_disable(client: api_client.ApiClient, args: Any) -> Any:
241
+ client.post(f"/api/v1/admin/users/{args.id}/disable")
242
+ return {"disabled": args.id}
243
+
244
+
245
+ def handle_user_enable(client: api_client.ApiClient, args: Any) -> Any:
246
+ client.post(f"/api/v1/admin/users/{args.id}/enable")
247
+ return {"enabled": args.id}
248
+
249
+
250
+ def handle_user_reset_password(client: api_client.ApiClient, args: Any) -> Any:
251
+ client.post(f"/api/v1/admin/users/{args.id}/reset-password", json={"password": args.password})
252
+ return {"password_reset": args.id}
253
+
254
+
255
+ def handle_user_keys(client: api_client.ApiClient, args: Any) -> Any:
256
+ return client.get(f"/api/v1/admin/users/{args.id}/keys")
257
+
258
+
259
+ def handle_user_set_scopes(client: api_client.ApiClient, args: Any) -> Any:
260
+ return client.put(
261
+ f"/api/v1/admin/users/{args.id}/keys/{args.key_id}/scopes",
262
+ json={"scopes": args.scopes},
263
+ )
264
+
265
+
266
+ # --- feature-flag handlers ------------------------------------------------
267
+
268
+
269
+ def handle_feature_flags_list(client: api_client.ApiClient, _args: Any) -> Any:
270
+ return client.get("/api/v1/admin/feature-flags")
271
+
272
+
273
+ def handle_feature_flags_set(client: api_client.ApiClient, args: Any) -> Any:
274
+ return client.put(f"/api/v1/admin/feature-flags/{args.name}", json={"enabled": not args.disabled})
@@ -15,6 +15,13 @@ hit /api/v1/org/ownership-requests and are self-scoped to the calling key's
15
15
  user — no owner check (a platform admin grants ownership from the election
16
16
  queue; the request is just recorded intent).
17
17
 
18
+ `cmpnd org keys list|create|revoke` is your own API-key surface (CMP-472):
19
+ list the keys you've minted, mint a new scoped key (the response carries the
20
+ one-time token — the server stores only its hash), and revoke one. These hit
21
+ /api/v1/org/keys and are self-scoped to the calling key's user — you only ever
22
+ see and manage your own keys, bound to your active org (the non-interactive
23
+ alternative to the `cmpnd login` loopback).
24
+
18
25
  Always registered (no env gate); the server is the authority.
19
26
  """
20
27
 
@@ -66,6 +73,27 @@ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
66
73
  withdraw.add_argument("github_login", help="The org's GitHub login (e.g. acme)")
67
74
  withdraw.set_defaults(func=handle_withdraw_ownership)
68
75
 
76
+ keys = sub.add_parser("keys", help="Manage your organization's API keys")
77
+ ksub = keys.add_subparsers(dest="keys_action")
78
+
79
+ keys_list = ksub.add_parser("list", help="List the org's API keys")
80
+ keys_list.set_defaults(func=handle_keys_list)
81
+
82
+ keys_create = ksub.add_parser("create", help="Mint a new scoped API key")
83
+ keys_create.add_argument("name", help="A label for the key")
84
+ keys_create.add_argument(
85
+ "--scope",
86
+ dest="scopes",
87
+ action="append",
88
+ required=True,
89
+ help="A scope to grant (repeat for multiple)",
90
+ )
91
+ keys_create.set_defaults(func=handle_keys_create)
92
+
93
+ keys_revoke = ksub.add_parser("revoke", help="Revoke an API key")
94
+ keys_revoke.add_argument("key_id", help="API key id (from `org keys list`)")
95
+ keys_revoke.set_defaults(func=handle_keys_revoke)
96
+
69
97
 
70
98
  def handle_members_pending(client: api_client.ApiClient, _args: Any) -> Any:
71
99
  return client.get("/api/v1/org/members/pending")
@@ -113,3 +141,19 @@ def handle_ownership_requests(client: api_client.ApiClient, _args: Any) -> Any:
113
141
  def handle_withdraw_ownership(client: api_client.ApiClient, args: Any) -> Any:
114
142
  client.post(f"/api/v1/org/ownership-requests/{args.github_login}/withdraw")
115
143
  return {"withdrawn": args.github_login}
144
+
145
+
146
+ def handle_keys_list(client: api_client.ApiClient, _args: Any) -> Any:
147
+ return client.get("/api/v1/org/keys")
148
+
149
+
150
+ def handle_keys_create(client: api_client.ApiClient, args: Any) -> Any:
151
+ # The response carries the one-time key token — the server stores only its
152
+ # hash, so this is the single moment it's shown. Pass it straight through.
153
+ return client.post("/api/v1/org/keys", json={"name": args.name, "scopes": args.scopes})
154
+
155
+
156
+ def handle_keys_revoke(client: api_client.ApiClient, args: Any) -> Any:
157
+ # 204 No Content → delete() returns None; synthesize a confirmation.
158
+ client.delete(f"/api/v1/org/keys/{args.key_id}")
159
+ return {"revoked": args.key_id}
@@ -255,7 +255,11 @@ class CmpndGEPACallback:
255
255
  self._tracker.best_score = c.val_aggregate_score
256
256
  break
257
257
 
258
- # Collect untraced LM usage (reflection calls) before clearing context
258
+ # Collect any untraced LM usage before clearing context. Reflection
259
+ # calls are now emitted as standalone traces linked to this run
260
+ # (CMP-485), so their cost is accounted for by serve's
261
+ # TracedTokensForRun and _orphaned_lm_usage is normally empty; this
262
+ # remains only as a fallback for LM calls that never got a trace.
259
263
  cmpnd_cb = self._get_cmpnd_callback()
260
264
  if cmpnd_cb:
261
265
  if cmpnd_cb._orphaned_lm_usage:
@@ -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.4.0"
8
+ version = "0.4.1"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python
2
+ """Capture a real committee GEPA run into scripts/fixtures/review_run.json.gz.
3
+
4
+ This regenerates the fixture that `scripts/seed_review_run.py` replays into every
5
+ review app. The fixture is a list of `{"kind", "payload"}` items — one per SDK
6
+ exporter HTTP call — captured by hooking `cmpnd.exporter._send_json` (the single
7
+ chokepoint every export POST routes through) while running the exact committee
8
+ GEPA optimization the stress suite runs
9
+ (`tests/stress/test_optimize_client_committee.py` → `tests/e2e/committee_task`):
10
+ a weak OpenRouter student + a Groq reflection LM over the vendored committee
11
+ subset. Capturing a *real* run (rather than fabricating payloads) keeps the seed
12
+ faithful — real prompts, token counts, span trees, and now the GEPA optimizer
13
+ traces (program_name "GEPA", `gepa.phase` reflection/metric — CMP-485).
14
+
15
+ Only the four kinds `seed_review_run.py` replays are kept — `optimization` (the
16
+ completed run header), `traces_batch`, `spans`, `stats`; the optimization *start*
17
+ POST, progress, start/finalize, and eval calls are dropped. Timestamps, UUIDs,
18
+ and the run linkage are all remapped by the seeder at replay time, so the
19
+ absolute values captured here don't matter.
20
+
21
+ Usage (from python-sdk/, real third-party inference — no mocks):
22
+ PLATFORM_API_KEY=... PLATFORM_OPENROUTER_KEY=... PLATFORM_GROQ_KEY=... \
23
+ PLATFORM_ENDPOINT=http://host.orb.internal:8081 \
24
+ .venv-linux/bin/python scripts/capture_review_run.py
25
+
26
+ Env (mirrors the stress test, with CMPND_E2E_* accepted as aliases):
27
+ - PLATFORM_API_KEY / CMPND_E2E_API_KEY: cmpnd server API key
28
+ - PLATFORM_ENDPOINT / CMPND_E2E_ENDPOINT: server URL (default http://localhost:8081)
29
+ - PLATFORM_OPENROUTER_KEY: student LM (OpenRouter)
30
+ - PLATFORM_GROQ_KEY: reflection LM (Groq)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import gzip
36
+ import json
37
+ import os
38
+ import pathlib
39
+ import re
40
+ import sys
41
+
42
+ # Import the shared committee task + the SDK from the python-sdk root (this
43
+ # script lives one level down in scripts/).
44
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
45
+
46
+ import dspy # noqa: E402
47
+
48
+ import cmpnd # noqa: E402
49
+ from cmpnd import exporter as exporter_mod # noqa: E402
50
+ from tests.e2e import committee_task # noqa: E402
51
+
52
+ _FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "review_run.json.gz"
53
+ _SPANS_PATH = re.compile(r"^/api/v1/traces/[^/]+/spans$")
54
+ _OPT_COMPLETE_PATH = re.compile(r"^/api/v1/optimizations/[^/]+$")
55
+
56
+
57
+ def _kind_for(method: str, path: str, payload) -> tuple[str, object] | None:
58
+ """Map an exporter HTTP call to a fixture item, or None to drop it.
59
+
60
+ Keeps the four kinds `seed_review_run.py` replays. Normalizes single-trace
61
+ POSTs (`/api/v1/traces`) into a one-item `traces_batch` so the seeder's
62
+ replay (which posts every batch to `/api/v1/traces/batch`) stays uniform.
63
+ The completed run header arrives as a PUT to `/api/v1/optimizations/{id}`
64
+ (`export_optimization_complete`); the `/api/v1/optimizations` POST is just
65
+ the "running" start record (CMP-313) and is dropped.
66
+ """
67
+ if method == "POST":
68
+ if path == "/api/v1/traces/batch":
69
+ return ("traces_batch", payload)
70
+ if path == "/api/v1/traces":
71
+ return ("traces_batch", [payload])
72
+ if _SPANS_PATH.match(path):
73
+ return ("spans", payload)
74
+ if path == "/api/v1/traces/stats":
75
+ return ("stats", payload)
76
+ elif method == "PUT" and _OPT_COMPLETE_PATH.match(path):
77
+ return ("optimization", payload)
78
+ return None
79
+
80
+
81
+ def main() -> int:
82
+ endpoint = (os.getenv("PLATFORM_ENDPOINT") or os.getenv("CMPND_E2E_ENDPOINT") or "http://localhost:8081").rstrip(
83
+ "/"
84
+ )
85
+ api_key = os.getenv("PLATFORM_API_KEY") or os.getenv("CMPND_E2E_API_KEY")
86
+ if not api_key:
87
+ print("Set PLATFORM_API_KEY (or CMPND_E2E_API_KEY)", file=sys.stderr)
88
+ return 2
89
+ if not committee_task.openrouter_key() or not committee_task.groq_key():
90
+ print("Set PLATFORM_OPENROUTER_KEY and PLATFORM_GROQ_KEY", file=sys.stderr)
91
+ return 2
92
+
93
+ captured: list[dict] = []
94
+
95
+ # Hook the single HTTP chokepoint: record every POST, then send it for real
96
+ # so the run also lands in the target server (a normal optimization).
97
+ orig_send_json = exporter_mod._send_json
98
+
99
+ def _capturing_send_json(client, method, path, payload):
100
+ item = _kind_for(method, path, payload)
101
+ if item is not None:
102
+ # Deep-copy via round-trip so a later in-place mutation (the
103
+ # exporter reuses builders) can't rewrite what we captured. `path`
104
+ # is what the seeder reads to route "spans" items to their trace
105
+ # (`/api/v1/traces/{id}/spans`); harmless on the other kinds.
106
+ captured.append({"kind": item[0], "path": path, "payload": json.loads(json.dumps(item[1], default=str))})
107
+ return orig_send_json(client, method, path, payload)
108
+
109
+ exporter_mod._send_json = _capturing_send_json # type: ignore[assignment]
110
+ try:
111
+ cmpnd.configure(
112
+ api_key=api_key, endpoint=endpoint, project_tags=["capture-review-run"], flush_interval_seconds=1
113
+ )
114
+ cmpnd.auto_instrument()
115
+
116
+ program = committee_task.build_program(committee_task.STUDENT_3B)
117
+ trainset, valset = committee_task.load_examples()
118
+ reflection_lm = dspy.LM(
119
+ committee_task.REFLECTION_MODEL,
120
+ api_key=committee_task.groq_key(),
121
+ api_base=committee_task.GROQ_API_BASE,
122
+ temperature=1.0,
123
+ max_tokens=8192,
124
+ cache=False,
125
+ )
126
+ gepa = dspy.GEPA(
127
+ metric=committee_task.committee_feedback_metric,
128
+ reflection_lm=reflection_lm,
129
+ max_metric_calls=120,
130
+ reflection_minibatch_size=5,
131
+ num_threads=8,
132
+ track_stats=True,
133
+ )
134
+ gepa.compile(program, trainset=trainset, valset=valset)
135
+ finally:
136
+ cmpnd.shutdown_exporter()
137
+ exporter_mod._send_json = orig_send_json # type: ignore[assignment]
138
+
139
+ # Sanity: refuse to overwrite the fixture with a broken capture.
140
+ kinds = [it["kind"] for it in captured]
141
+ n_opt = kinds.count("optimization")
142
+ trace_payloads = [t for it in captured if it["kind"] == "traces_batch" for t in it["payload"]]
143
+ programs = {t.get("program_name") for t in trace_payloads}
144
+ if n_opt != 1:
145
+ print(f"expected exactly 1 completed optimization, captured {n_opt}; not writing", file=sys.stderr)
146
+ return 1
147
+ if "GEPA" not in programs:
148
+ print(f"no GEPA optimizer traces captured (programs={programs}); not writing", file=sys.stderr)
149
+ return 1
150
+
151
+ _FIXTURE.parent.mkdir(parents=True, exist_ok=True)
152
+ _FIXTURE.write_bytes(gzip.compress(json.dumps(captured).encode("utf-8")))
153
+
154
+ from collections import Counter
155
+
156
+ print(f"wrote {_FIXTURE} — {len(captured)} items {dict(Counter(kinds))}")
157
+ print(f" trace program_names: {dict(Counter(t.get('program_name') for t in trace_payloads))}")
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ raise SystemExit(main())