cmpnd 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. {cmpnd-0.3.0 → cmpnd-0.4.0}/CLAUDE.md +1 -1
  2. {cmpnd-0.3.0 → cmpnd-0.4.0}/PKG-INFO +1 -1
  3. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/__init__.py +7 -0
  4. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/_gepa_patch.py +3 -1
  5. cmpnd-0.4.0/cmpnd/_program_patch.py +112 -0
  6. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/deployment.py +8 -8
  7. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/gepa.py +3 -1
  8. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/gepa_callback.py +6 -2
  9. cmpnd-0.4.0/cmpnd/optimizers/resume.py +91 -0
  10. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/tracker.py +73 -17
  11. {cmpnd-0.3.0 → cmpnd-0.4.0}/pyproject.toml +1 -1
  12. cmpnd-0.4.0/scripts/fixtures/review_run.json.gz +0 -0
  13. cmpnd-0.4.0/scripts/gepa_resume_bug.py +187 -0
  14. cmpnd-0.4.0/scripts/seed_review_run.py +504 -0
  15. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/committee_task.py +4 -2
  16. cmpnd-0.4.0/tests/e2e/data/committee_train.ndjson.gz +0 -0
  17. cmpnd-0.4.0/tests/e2e/data/committee_val.ndjson.gz +0 -0
  18. cmpnd-0.4.0/tests/e2e/test_optimization_lineage.py +96 -0
  19. cmpnd-0.4.0/tests/e2e/test_optimize_review_committee.py +161 -0
  20. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimize_server_owned_completion.py +1 -1
  21. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/conftest.py +22 -35
  22. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/seed_parity.py +15 -50
  23. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_auth.py +14 -11
  24. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_datasets.py +20 -18
  25. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_deployments.py +11 -8
  26. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_evals.py +10 -6
  27. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_health.py +8 -6
  28. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_modules.py +7 -5
  29. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_optimizations.py +13 -10
  30. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_programs.py +9 -6
  31. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_signatures.py +9 -6
  32. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_trace_streaming.py +32 -35
  33. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_traces.py +11 -7
  34. cmpnd-0.4.0/tests/stress/_reliability.py +81 -0
  35. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_deploy_programs.py +39 -5
  36. cmpnd-0.4.0/tests/stress/test_optimize_client_committee.py +83 -0
  37. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_optimize_loop.py +4 -0
  38. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_optimize_stress.py +4 -0
  39. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_real_provider_twins.py +22 -8
  40. cmpnd-0.4.0/tests/stress/test_reliability.py +55 -0
  41. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_gepa_callback.py +3 -0
  42. cmpnd-0.4.0/tests/test_gepa_resume.py +145 -0
  43. cmpnd-0.4.0/tests/test_optimize_terminal_failure.py +110 -0
  44. cmpnd-0.4.0/tests/test_program_lineage_persist.py +122 -0
  45. cmpnd-0.4.0/tests/test_seed_review_run.py +120 -0
  46. {cmpnd-0.3.0 → cmpnd-0.4.0}/uv.lock +116 -0
  47. cmpnd-0.3.0/tests/e2e/data/committee_train.ndjson +0 -8
  48. cmpnd-0.3.0/tests/e2e/data/committee_val.ndjson +0 -5
  49. {cmpnd-0.3.0 → cmpnd-0.4.0}/.gitignore +0 -0
  50. {cmpnd-0.3.0 → cmpnd-0.4.0}/README.md +0 -0
  51. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/callback.py +0 -0
  52. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/__init__.py +0 -0
  53. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/api_client.py +0 -0
  54. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/__init__.py +0 -0
  55. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/admin.py +0 -0
  56. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/auth.py +0 -0
  57. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/datasets.py +0 -0
  58. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/deployments.py +0 -0
  59. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/evals.py +0 -0
  60. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/login.py +0 -0
  61. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/optimizations.py +0 -0
  62. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/org.py +0 -0
  63. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/programs.py +0 -0
  64. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/top.py +0 -0
  65. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/traces.py +0 -0
  66. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/credentials.py +0 -0
  67. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/shell.py +0 -0
  68. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/verbs.py +0 -0
  69. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/configuration.py +0 -0
  70. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/context.py +0 -0
  71. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/dataset_sync.py +0 -0
  72. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/datasets.py +0 -0
  73. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/decorators.py +0 -0
  74. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/eval_handler.py +0 -0
  75. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/execution.py +0 -0
  76. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/exporter.py +0 -0
  77. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/helpers.py +0 -0
  78. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/identity.py +0 -0
  79. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/__init__.py +0 -0
  80. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/decode.py +0 -0
  81. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/encode.py +0 -0
  82. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/hash.py +0 -0
  83. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/program.py +0 -0
  84. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/reflection.py +0 -0
  85. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/types.py +0 -0
  86. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/models.py +0 -0
  87. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimization.py +0 -0
  88. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/__init__.py +0 -0
  89. {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/packaging.py +0 -0
  90. {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/2026-04-02-code-review.md +0 -0
  91. {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  92. {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  93. {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/sdk-instrumentation.md +0 -0
  94. {cmpnd-0.3.0 → cmpnd-0.4.0}/examples/local_ollama.py +0 -0
  95. {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/__init__.pyi +0 -0
  96. {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/primitives/__init__.pyi +0 -0
  97. {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/primitives/prediction.pyi +0 -0
  98. {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/utils/__init__.pyi +0 -0
  99. {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/utils/callback.pyi +0 -0
  100. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/__init__.py +0 -0
  101. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/_exporter_helpers.py +0 -0
  102. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/_identity_helpers.py +0 -0
  103. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/__init__.py +0 -0
  104. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/conftest.py +0 -0
  105. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/fake_lm.py +0 -0
  106. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/helpers.py +0 -0
  107. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_capture_prompts.py +0 -0
  108. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_composite_module.py +0 -0
  109. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
  110. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_deploy_execute.py +0 -0
  111. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_deploy_programs.py +0 -0
  112. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_edge_cases.py +0 -0
  113. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_example_hash.py +0 -0
  114. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_gepa_optimization.py +0 -0
  115. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_lm_accuracy.py +0 -0
  116. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_module_tracing.py +0 -0
  117. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
  118. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimize_failures.py +0 -0
  119. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimize_loop.py +0 -0
  120. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_parallel_export.py +0 -0
  121. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_project_attribution.py +0 -0
  122. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_react_tool_spans.py +0 -0
  123. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_save_load.py +0 -0
  124. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_trace_structure.py +0 -0
  125. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_user_attribution.py +0 -0
  126. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/01_predict_qa.json +0 -0
  127. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  128. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  129. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  130. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  131. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  132. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
  133. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/08_opaque_module.json +0 -0
  134. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  135. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/10_react.json +0 -0
  136. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/11_refine.json +0 -0
  137. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/12_retrieve.json +0 -0
  138. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/13_knn.json +0 -0
  139. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/14_parallel.json +0 -0
  140. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/README.md +0 -0
  141. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/__init__.py +0 -0
  142. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/conftest.py +0 -0
  143. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  144. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_logs_renders_traces.py +0 -0
  145. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_optimizing_progress.py +0 -0
  146. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_unhappy_path.py +0 -0
  147. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/__init__.py +0 -0
  148. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/__init__.py +0 -0
  149. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_multi_client_stress.py +0 -0
  150. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_callback.py +0 -0
  151. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/__init__.py +0 -0
  152. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/conftest.py +0 -0
  153. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_admin.py +0 -0
  154. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_api_client.py +0 -0
  155. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_auth.py +0 -0
  156. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_credentials.py +0 -0
  157. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_datasets.py +0 -0
  158. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_deployments.py +0 -0
  159. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_evals.py +0 -0
  160. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_login.py +0 -0
  161. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_main.py +0 -0
  162. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_optimizations.py +0 -0
  163. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_org.py +0 -0
  164. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_parity.py +0 -0
  165. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_programs.py +0 -0
  166. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_shell.py +0 -0
  167. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_top.py +0 -0
  168. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_traces.py +0 -0
  169. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/__init__.py +0 -0
  170. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/conftest.py +0 -0
  171. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/test_edge_cases.py +0 -0
  172. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/test_hash_consistency.py +0 -0
  173. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/test_type_conversion.py +0 -0
  174. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_configuration.py +0 -0
  175. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_context.py +0 -0
  176. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_context_propagation.py +0 -0
  177. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_dataset_polling.py +0 -0
  178. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_dataset_sync.py +0 -0
  179. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_datasets.py +0 -0
  180. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_decorators.py +0 -0
  181. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_deploy.py +0 -0
  182. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_eval.py +0 -0
  183. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_eval_start.py +0 -0
  184. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_exception_paths.py +0 -0
  185. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_execute.py +0 -0
  186. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_exporter.py +0 -0
  187. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_exporter_real.py +0 -0
  188. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_fake_lm.py +0 -0
  189. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_gepa_integration.py +0 -0
  190. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_identity.py +0 -0
  191. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_integration.py +0 -0
  192. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_ir_encoder_properties.py +0 -0
  193. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_ir_golden_vectors.py +0 -0
  194. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_ir_roundtrip.py +0 -0
  195. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_models.py +0 -0
  196. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_optimization_start.py +0 -0
  197. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_optimize.py +0 -0
  198. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_optimize_lift.py +0 -0
  199. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_packaging.py +0 -0
  200. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_packaging_callable_source.py +0 -0
  201. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_packaging_program_source.py +0 -0
  202. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_project_payload.py +0 -0
  203. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_reflector_properties.py +0 -0
  204. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_reflector_smoke.py +0 -0
  205. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_repro_reactv2_bugs.py +0 -0
  206. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_schemas.py +0 -0
  207. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_stats_export.py +0 -0
  208. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_tracked_gepa.py +0 -0
  209. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_tracker_enrich.py +0 -0
  210. {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_truncation.py +0 -0
  211. {cmpnd-0.3.0 → cmpnd-0.4.0}/todo.md +0 -0
@@ -35,7 +35,7 @@ uv run ruff format --check . # format check
35
35
  - `cmpnd/callback.py` - DSPy instrumentation, signature extraction
36
36
  - `cmpnd/optimizers/tracker.py` - Optimization tracking
37
37
  - `cmpnd/packaging.py` - Source ZIP generation for deploy (ported from deploy/local_cmpnd.py)
38
- - `cmpnd/deployment.py` - `deploy()` function: SDK -> Rails -> AWS deploy stack
38
+ - `cmpnd/deployment.py` - `deploy()` function: SDK -> serve -> AWS deploy stack
39
39
 
40
40
  ## Reflector ↔ DSPy compatibility
41
41
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmpnd
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: DSPy observability and deployment SDK for cmpnd
5
5
  Project-URL: Homepage, https://cmpnd.ai
6
6
  Author-email: cmpnd <hello@cmpnd.ai>
@@ -129,4 +129,11 @@ def auto_instrument() -> CmpndCallback:
129
129
 
130
130
  _gepa_patch.patch_gepa()
131
131
 
132
+ # Persist the compiled-from lineage tag across state-only save/load so a
133
+ # continued optimization still stamps compiled_from_run_id after a disk
134
+ # reload / process restart (CMP-444).
135
+ from . import _program_patch
136
+
137
+ _program_patch.patch_program_state()
138
+
132
139
  return callback
@@ -144,7 +144,9 @@ def patch_gepa() -> None:
144
144
 
145
145
  except Exception as e:
146
146
  if callback is not None: # pyright: ignore[reportUnnecessaryComparison]
147
- callback._tracker.set_error(e)
147
+ # Flush a terminal 'failed', not just in-memory state — else the
148
+ # running row start() sent lingers forever (CMP-414).
149
+ callback._tracker.finalize_error(e)
148
150
  raise
149
151
 
150
152
  finally:
@@ -0,0 +1,112 @@
1
+ """Persist the compiled-from lineage tag across DSPy's state-only save/load.
2
+
3
+ GEPA tags a compiled program with ``result._optimization_run_id`` (see
4
+ ``cmpnd/optimizers/gepa.py`` and ``cmpnd/_gepa_patch.py``); a subsequent
5
+ ``compile()`` starting from that program reads the tag back in
6
+ ``tracker.enrich_from_program`` and sends it as ``compiled_from_run_id``, which
7
+ is how the cross-run lineage chain (Go breadcrumb bar) is built.
8
+
9
+ That tag is a plain instance attribute. DSPy's state-only save path
10
+ (``module.save("p.json")`` / ``.pkl`` with ``save_program=False``) serializes
11
+ **only named parameters** — ``BaseModule.dump_state`` returns
12
+ ``{name: param.dump_state()}`` and ``load_state`` iterates only parameter names
13
+ — so the tag is dropped on both ends of a disk round-trip and the lineage
14
+ silently vanishes across a save/load or process restart. (``save_program=True``
15
+ cloudpickles the whole instance and already preserves it; the gap is exclusively
16
+ the state-only path, which is the one DSPy steers users toward.)
17
+
18
+ DSPy exposes no extension point for out-of-band instance state (no
19
+ ``get_extra_state`` / attribute allowlist; ``metadata`` is DSPy-owned). Rather
20
+ than change DSPy upstream (CMP-444 decision), ``patch_program_state()`` wraps
21
+ ``dump_state`` / ``load_state`` on the two classes that define them — the
22
+ composite ``Module`` path and the bare ``Predict`` path — to round-trip the tag
23
+ under a namespaced carrier key. Installed lazily from
24
+ ``cmpnd.auto_instrument()`` alongside ``patch_gepa()``.
25
+
26
+ The carrier is a single reserved top-level key (the same shape DSPy itself uses
27
+ for its sibling ``metadata`` key). Stock ``Module.load_state`` ignores it (it
28
+ reads only parameter names); ``Predict.load_state`` ``setattr``s every key, so
29
+ the ``Predict`` wrapper strips the carrier after restoring the attribute so it
30
+ doesn't leak onto the instance.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Any
36
+
37
+ # Reserved top-level key carrying cmpnd's out-of-band program state. Namespaced
38
+ # so it can't collide with a DSPy named-parameter path.
39
+ _LINEAGE_KEY = "_cmpnd"
40
+ _RUN_ID_FIELD = "optimization_run_id"
41
+
42
+ _patched = False
43
+
44
+
45
+ def _inject(state: Any, module: Any) -> Any:
46
+ """Add the lineage carrier to a dumped state dict when the module is tagged."""
47
+ run_id = getattr(module, "_optimization_run_id", None)
48
+ if run_id is not None and isinstance(state, dict):
49
+ state[_LINEAGE_KEY] = {_RUN_ID_FIELD: str(run_id)}
50
+ return state
51
+
52
+
53
+ def _restore(state: Any, module: Any) -> None:
54
+ """Re-attach ``_optimization_run_id`` from a loaded state dict's carrier."""
55
+ if not isinstance(state, dict):
56
+ return
57
+ carrier = state.get(_LINEAGE_KEY)
58
+ if isinstance(carrier, dict) and carrier.get(_RUN_ID_FIELD):
59
+ module._optimization_run_id = str(carrier[_RUN_ID_FIELD])
60
+
61
+
62
+ # The wrappers forward *args/**kwargs verbatim rather than naming dump_state's
63
+ # json_mode / load_state's allow_unsafe_lm_state: those signatures differ across
64
+ # DSPy versions (allow_unsafe_lm_state was added after 3.1.x), and the patch is
65
+ # installed process-wide, so it must be transparent to every caller and version.
66
+ def _wrap_dump(orig):
67
+ def dump_state(self, *args, **kwargs):
68
+ return _inject(orig(self, *args, **kwargs), self)
69
+
70
+ return dump_state
71
+
72
+
73
+ def _wrap_load(orig, *, strip: bool):
74
+ def load_state(self, state, *args, **kwargs):
75
+ _restore(state, self)
76
+ # Predict.load_state setattrs every non-excluded key; drop the carrier
77
+ # so it doesn't land as a stray `_cmpnd` attribute. Module.load_state
78
+ # reads only parameter names, so it needs no strip.
79
+ if strip and isinstance(state, dict) and _LINEAGE_KEY in state:
80
+ state = {k: v for k, v in state.items() if k != _LINEAGE_KEY}
81
+ return orig(self, state, *args, **kwargs)
82
+
83
+ return load_state
84
+
85
+
86
+ def patch_program_state() -> None:
87
+ """Wrap dump_state/load_state so the lineage tag survives a disk reload.
88
+
89
+ Idempotent (a module-level flag guards against double-patching); a no-op
90
+ when dspy isn't installed.
91
+ """
92
+ global _patched
93
+ if _patched:
94
+ return
95
+
96
+ try:
97
+ import dspy
98
+ except ImportError:
99
+ return
100
+
101
+ # Composite programs (dspy.Module subclasses, ChainOfThought, …) inherit
102
+ # dump_state/load_state from BaseModule; patching on dspy.Module covers every
103
+ # composite. A bare Predict program overrides both, so patch that path too.
104
+ # The methods are swapped via getattr/setattr rather than direct attribute
105
+ # assignment because dspy.*.load_state types as Unknown to basedpyright (its
106
+ # body imports the untyped dspy.predict.predict) — reflection keeps the swap
107
+ # off the type checker's attribute-access path across every DSPy version.
108
+ for cls, strip in ((dspy.Module, False), (dspy.Predict, True)):
109
+ setattr(cls, "dump_state", _wrap_dump(getattr(cls, "dump_state")))
110
+ setattr(cls, "load_state", _wrap_load(getattr(cls, "load_state"), strip=strip))
111
+
112
+ _patched = True
@@ -72,7 +72,7 @@ def _raise_http_error(response: httpx.Response, action: str) -> None:
72
72
  class DeployFailed(RuntimeError):
73
73
  """Raised when a deployment transitions to ``status=failed``.
74
74
 
75
- Carries the structured failure detail Rails persists when the parser
75
+ Carries the structured failure detail the server persists when the parser
76
76
  writes a failure marker. ``failure_phase`` is one of a closed set of
77
77
  pipeline-stage names (e.g. ``unpack_zip``, ``signature_extraction``,
78
78
  ``lm_load``); ``failure_reason`` is a constant template synthesized
@@ -86,7 +86,7 @@ class DeployFailed(RuntimeError):
86
86
  parser Lambda's CloudWatch group.
87
87
 
88
88
  Both fields are ``None`` when the server didn't return structured
89
- detail (older deployments or older Rails versions).
89
+ detail (older deployments or older server versions).
90
90
 
91
91
  Subclasses ``RuntimeError`` so existing ``except RuntimeError`` callers
92
92
  keep working.
@@ -117,7 +117,7 @@ class DeployFailed(RuntimeError):
117
117
  def deploy(module, metric=None, timeout=300, metadata=None):
118
118
  """Deploy a DSPy module via the Cmpnd platform.
119
119
 
120
- Packages the module into a source ZIP, uploads it through Rails to the
120
+ Packages the module into a source ZIP, uploads it through the platform API to the
121
121
  deploy stack, and polls until the program is parsed and ready.
122
122
 
123
123
  Args:
@@ -192,18 +192,18 @@ def deploy(module, metric=None, timeout=300, metadata=None):
192
192
  if lm_kwargs.get("api_base"):
193
193
  deploy_body["lm_api_host"] = lm_kwargs["api_base"]
194
194
 
195
- # Client for Rails API calls (JSON)
196
- rails_headers = {
195
+ # Client for platform API calls (JSON)
196
+ api_headers = {
197
197
  "X-API-Key": config.api_key,
198
198
  "Content-Type": "application/json",
199
199
  }
200
200
 
201
201
  with httpx.Client(timeout=30.0) as client:
202
- # Step 1: Create deployment via Rails
202
+ # Step 1: Create deployment via the platform API
203
203
  create_resp = client.post(
204
204
  f"{config.endpoint}/api/v1/deployments",
205
205
  json=deploy_body,
206
- headers=rails_headers,
206
+ headers=api_headers,
207
207
  )
208
208
  _raise_http_error(create_resp, "create deployment")
209
209
  data = create_resp.json()
@@ -241,7 +241,7 @@ def deploy(module, metric=None, timeout=300, metadata=None):
241
241
 
242
242
  poll_resp = client.get(
243
243
  f"{config.endpoint}/api/v1/deployments/{deployment_id}",
244
- headers=rails_headers,
244
+ headers=api_headers,
245
245
  )
246
246
  _raise_http_error(poll_resp, "poll deployment status")
247
247
  status_data = poll_resp.json()
@@ -221,7 +221,9 @@ class TrackedGEPA:
221
221
 
222
222
  except Exception as e:
223
223
  if self._tracker:
224
- self._tracker.set_error(e)
224
+ # Flush a terminal 'failed', not just in-memory state — else the
225
+ # running row start() sent lingers forever (CMP-414).
226
+ self._tracker.finalize_error(e)
225
227
  raise
226
228
 
227
229
  finally:
@@ -292,10 +292,14 @@ class CmpndGEPACallback:
292
292
  if will_continue:
293
293
  logger.warning(f"GEPA recoverable error at iteration {event.get('iteration')}: {exception}")
294
294
  else:
295
+ # Terminal error: flush a 'failed' now. If GEPA also re-raises,
296
+ # the compile wrapper's finalize_error no-ops (shared _finalized
297
+ # guard); if GEPA swallows it, this is the only terminal flush,
298
+ # so the running row still transitions (CMP-414).
295
299
  if exception is not None:
296
- self._tracker.set_error(exception)
300
+ self._tracker.finalize_error(exception)
297
301
  else:
298
- self._tracker.set_error(RuntimeError(f"Unknown error at iteration {event.get('iteration')}"))
302
+ self._tracker.finalize_error(RuntimeError(f"Unknown error at iteration {event.get('iteration')}"))
299
303
  except Exception:
300
304
  logger.exception("Error in on_error")
301
305
  raise
@@ -0,0 +1,91 @@
1
+ """GEPA checkpoint-resume continuity: a ``run_id`` sidecar in the ``log_dir``.
2
+
3
+ CMP-443. GEPA's identity for a resumed job is its ``log_dir`` (== gepa's
4
+ ``run_dir``): it reloads ``gepa_state.bin`` and continues iteration/budget. But
5
+ the SDK mints a fresh ``run_id`` per ``compile()``, so N resumes become N
6
+ unrelated optimization-run rows. This module persists the server-assigned
7
+ ``run_id`` as ``cmpnd_run.json`` alongside GEPA's own state, and reads it back on
8
+ resume so the SDK re-adopts the original id (one continuous run).
9
+
10
+ Resume is detected by GEPA's ``gepa_state.bin`` checkpoint: if it is present the
11
+ next ``compile()`` continues cumulative state, so the SDK must re-adopt; if it is
12
+ absent (a fresh ``log_dir``, or a first run that died before its first
13
+ checkpoint) GEPA starts over and the SDK mints a new run.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from datetime import datetime
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _SIDECAR = "cmpnd_run.json"
26
+ _CHECKPOINT = "gepa_state.bin"
27
+
28
+
29
+ def sidecar_path(log_dir: str) -> str:
30
+ return os.path.join(log_dir, _SIDECAR)
31
+
32
+
33
+ def write_run_id(log_dir: str, run_id: str, program_signature_hash: int | None, start_time: datetime | None) -> None:
34
+ """Persist the server-assigned ``run_id`` into ``log_dir`` so a later resume
35
+ can re-adopt it. Called at ``start()``, which runs before GEPA creates its
36
+ ``run_dir`` — so create the directory if it isn't there yet. Best-effort: a
37
+ write failure must not break the optimization run.
38
+ """
39
+ payload = {
40
+ "run_id": run_id,
41
+ "program_signature_hash": program_signature_hash,
42
+ "start_time": start_time.isoformat() if start_time is not None else None,
43
+ }
44
+ try:
45
+ os.makedirs(log_dir, exist_ok=True)
46
+ with open(sidecar_path(log_dir), "w", encoding="utf-8") as f:
47
+ json.dump(payload, f)
48
+ except OSError as e:
49
+ logger.warning("could not write resume sidecar in %s: %s", log_dir, e)
50
+
51
+
52
+ def read_resumable(log_dir: str, program_signature_hash: int | None) -> tuple[str, datetime | None] | None:
53
+ """Return ``(run_id, start_time)`` to re-adopt, or ``None`` if this is not a
54
+ resumable job.
55
+
56
+ Resumable requires: GEPA's ``gepa_state.bin`` checkpoint present (else GEPA
57
+ itself starts fresh) AND a readable sidecar carrying a ``run_id``. A definite
58
+ ``program_signature_hash`` mismatch (a different program reusing the same
59
+ ``log_dir``) is rejected so it can't hijack the id; a missing hash on either
60
+ side errs toward stitching, since ``log_dir`` is the strong identity signal.
61
+ """
62
+ if not os.path.exists(os.path.join(log_dir, _CHECKPOINT)):
63
+ return None
64
+ try:
65
+ with open(sidecar_path(log_dir), encoding="utf-8") as f:
66
+ data = json.load(f)
67
+ except (OSError, ValueError):
68
+ return None
69
+
70
+ run_id = data.get("run_id")
71
+ if not run_id:
72
+ return None
73
+
74
+ prior_hash = data.get("program_signature_hash")
75
+ if prior_hash is not None and program_signature_hash is not None and prior_hash != program_signature_hash:
76
+ logger.warning(
77
+ "resume sidecar in %s is for a different program (hash %s != %s); not re-adopting",
78
+ log_dir,
79
+ prior_hash,
80
+ program_signature_hash,
81
+ )
82
+ return None
83
+
84
+ start_time = None
85
+ raw_start = data.get("start_time")
86
+ if raw_start:
87
+ try:
88
+ start_time = datetime.fromisoformat(raw_start)
89
+ except ValueError:
90
+ start_time = None
91
+ return str(run_id), start_time
@@ -617,12 +617,33 @@ class OptimizationTracker:
617
617
  logger.warning(f"Failed to capture compiled program state: {e}")
618
618
 
619
619
  def set_error(self, exception: Exception) -> None:
620
- """Mark the optimization run as failed."""
620
+ """Mark the optimization run as failed (in-memory only)."""
621
621
  self.status = "failed"
622
622
  self.error_message = str(exception)
623
623
  self.end_time = datetime.now(timezone.utc)
624
624
  logger.error(f"Optimization failed: {exception}")
625
625
 
626
+ def finalize_error(self, exception: Exception) -> None:
627
+ """Mark the run failed AND flush the terminal 'failed' to the backend.
628
+
629
+ The success path is finalize(); on an optimizer exception the run must
630
+ still post a terminal 'failed' — otherwise the 'running' row start()
631
+ created never transitions and the program page shows a perpetual
632
+ in-progress optimization (CMP-414). Idempotent and mutually exclusive
633
+ with finalize() via the shared _finalized guard (whichever fires first
634
+ wins, so a swallowed-then-completed run can't overwrite a failure and
635
+ vice versa). The export is best-effort: a flush failure is logged, never
636
+ raised, so it can't mask the original optimizer exception the caller is
637
+ about to re-raise."""
638
+ if self._finalized:
639
+ return
640
+ self._finalized = True
641
+ self.set_error(exception)
642
+ try:
643
+ self._flush_terminal()
644
+ except Exception:
645
+ logger.exception("Failed to export terminal 'failed' optimization run")
646
+
626
647
  def start(self) -> None:
627
648
  """Send initial optimization run record with status='running'.
628
649
 
@@ -634,20 +655,38 @@ class OptimizationTracker:
634
655
  self._started = True
635
656
 
636
657
  from .. import exporter
658
+ from . import resume
637
659
 
638
660
  exp = exporter.get_exporter()
639
- if exp:
640
- # CMP-313: the SDK doesn't mint — the component behind the exporter
641
- # assigns the id and returns it; adopt it before any trace ships.
642
- assigned = exp.export_optimization_start(self)
643
- if assigned:
644
- self.optimization_run_id = assigned
645
- logger.info(
646
- f"Started optimization tracking: run_id={self.optimization_run_id}, "
647
- f"max_metric_calls={self.max_metric_calls}"
648
- )
649
- else:
661
+ if not exp:
650
662
  logger.debug("No exporter available, optimization start not exported")
663
+ return
664
+
665
+ # CMP-443: a GEPA job resumed from the same log_dir is one continuous
666
+ # run. If a prior run left a checkpoint + our run_id sidecar, re-adopt
667
+ # that id (and its original start_time, so duration stays cumulative)
668
+ # instead of minting a fresh row. Jobs without a log_dir stay per-run.
669
+ log_dir = self.config.get("log_dir") if self.config else None
670
+ readopted = resume.read_resumable(log_dir, self.program_signature_hash) if log_dir else None
671
+ if readopted is not None:
672
+ self.optimization_run_id, prior_start = readopted
673
+ if prior_start is not None:
674
+ self.start_time = prior_start
675
+ logger.info(f"Resuming optimization run: run_id={self.optimization_run_id} (log_dir={log_dir})")
676
+ return
677
+
678
+ # CMP-313: the SDK doesn't mint — the component behind the exporter
679
+ # assigns the id and returns it; adopt it before any trace ships.
680
+ assigned = exp.export_optimization_start(self)
681
+ if assigned:
682
+ self.optimization_run_id = assigned
683
+ # Persist the id so a later resume of the same log_dir re-adopts it.
684
+ if log_dir and self.optimization_run_id:
685
+ resume.write_run_id(log_dir, self.optimization_run_id, self.program_signature_hash, self.start_time)
686
+ logger.info(
687
+ f"Started optimization tracking: run_id={self.optimization_run_id}, "
688
+ f"max_metric_calls={self.max_metric_calls}"
689
+ )
651
690
 
652
691
  def send_progress_update(self) -> None:
653
692
  """Send a progress update to the backend.
@@ -751,12 +790,29 @@ class OptimizationTracker:
751
790
  f"best_score={self.best_score:.3f}"
752
791
  )
753
792
 
754
- # Export via cmpnd exporter
755
- # If we already sent a start record, update it; otherwise create new
756
- if self._started:
757
- self._export_update()
758
- else:
793
+ # Export via cmpnd exporter: create, update, or skip — see _flush_terminal.
794
+ self._flush_terminal()
795
+
796
+ def _flush_terminal(self) -> None:
797
+ """Send the terminal run to the backend, choosing create vs. update.
798
+
799
+ - never started (no start() call): create the record (_export).
800
+ - started with an id: update the existing record (_export_update).
801
+ - started but WITHOUT an id: start()'s synchronous create raised
802
+ (export_optimization_start returns a real id or raises — it never
803
+ returns an empty one), so no server run exists to transition and
804
+ there is no id to address. Skip rather than PATCH/POST a missing
805
+ ("None") id — the optimizer exception already propagated, and serve's
806
+ abandon-sweep (CMP-412) is the backstop for a running row whose create
807
+ response was lost in flight.
808
+ """
809
+ if not self._started:
759
810
  self._export()
811
+ return
812
+ if not self.optimization_run_id:
813
+ logger.error("optimization run has no id (start export failed); no terminal run written")
814
+ return
815
+ self._export_update()
760
816
 
761
817
  def _export_update(self) -> None:
762
818
  """Update existing optimization run record."""
@@ -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.3.0"
8
+ version = "0.4.0"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -0,0 +1,187 @@
1
+ """Reproduce the GEPA checkpoint-resume duplication bug (CMP-442 / CMP-443).
2
+
3
+ Drives the *real* dspy.GEPA + cmpnd.auto_instrument export path twice against a
4
+ single GEPA log_dir (== gepa run_dir), the way a laptop-sleep / power-cut resume
5
+ actually happens:
6
+
7
+ phase 1 (crash): start a GEPA job with log_dir=D. After GEPA writes its second
8
+ gepa_state.bin checkpoint, hard-exit the process (os._exit) —
9
+ modelling a killed process. The SDK has already POSTed the
10
+ "running" row but never finalizes it, so run R1 sticks at
11
+ status='running'. The checkpoint (gepa_state.bin) is on disk.
12
+ phase 2 (resume): a fresh process re-runs GEPA with the SAME log_dir. GEPA
13
+ reloads gepa_state.bin and continues to completion. Because
14
+ the SDK mints a fresh run_id per compile(), this is run R2 —
15
+ unrelated to R1. R2 finalizes status='completed', cumulative.
16
+
17
+ Result today: two rows for one job — R1 (running, orphaned) + R2 (completed),
18
+ sharing config.log_dir and program_signature_hash but nothing linking them. That
19
+ is the bug CMP-443 fixes (re-adopt R1's id keyed on log_dir → one row).
20
+
21
+ Doubles as the acceptance harness for CMP-443: after the fix, `--phase both`
22
+ against a clean log_dir yields ONE run row, not two.
23
+
24
+ Offline: student + reflection are DummyLM, so no third-party inference. Only the
25
+ cmpnd export POSTs leave the process (to --endpoint).
26
+
27
+ Usage:
28
+ python gepa_resume_bug.py --endpoint http://host.orb.internal:8081 \
29
+ --api-key cmpnd_review_app_deterministic_key_for_ci
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import os
36
+ import subprocess
37
+ import sys
38
+ import tempfile
39
+
40
+
41
+ def _build_examples():
42
+ import dspy
43
+
44
+ # Committee-classification flavour so the run reads like the existing seed
45
+ # data (program "Predict", email_body -> committee).
46
+ rows = [
47
+ ("please refer this to the finance subcommittee", "finance"),
48
+ ("this concerns veterans healthcare access", "veterans"),
49
+ ("a bill about farm subsidies and crop insurance", "agriculture"),
50
+ ("questions on naval shipbuilding appropriations", "armed_services"),
51
+ ("immigration and border enforcement funding", "judiciary"),
52
+ ("public school funding formula reform", "education"),
53
+ ]
54
+ trainset = [dspy.Example(email_body=b, committee=c).with_inputs("email_body") for b, c in rows]
55
+ valset = [dspy.Example(email_body=b, committee=c).with_inputs("email_body") for b, c in rows[:4]]
56
+ return trainset, valset
57
+
58
+
59
+ def _metric(example, pred, trace=None, pred_name=None, pred_trace=None):
60
+ import dspy
61
+
62
+ got = str(getattr(pred, "committee", "")).strip().lower()
63
+ want = str(example.committee).strip().lower()
64
+ score = 1.0 if got == want else (0.5 if want in got else 0.0)
65
+ fb = "correct committee" if score == 1.0 else "name the single most specific committee"
66
+ return dspy.Prediction(score=score, feedback=fb)
67
+
68
+
69
+ class _CrashAfterCheckpoints:
70
+ """GEPA callback that hard-exits after the Nth gepa_state.bin checkpoint,
71
+ modelling a power-cut. Prints the SDK run_id first so the caller can see
72
+ which row was orphaned."""
73
+
74
+ def __init__(self, n: int):
75
+ self._n = n
76
+ self._seen = 0
77
+
78
+ def on_state_saved(self, event) -> None: # gepa StateSavedEvent
79
+ from cmpnd import context, exporter
80
+
81
+ self._seen += 1
82
+ if self._seen >= self._n:
83
+ # Push the queued progress PATCHes synchronously so the orphaned row
84
+ # reflects the iterations/metric-calls it reached — a killed process
85
+ # otherwise loses the async queue and the row reads as 0-progress.
86
+ exp = exporter.get_exporter()
87
+ if exp is not None:
88
+ try:
89
+ exp.flush(timeout=5.0)
90
+ except Exception:
91
+ pass
92
+ run_id = context.get_current_optimization_run_id()
93
+ sys.stdout.write(f"CRASH_RUN_ID={run_id}\n")
94
+ sys.stdout.flush()
95
+ sys.stderr.flush()
96
+ os._exit(0)
97
+
98
+
99
+ def _run_compile(endpoint: str, api_key: str, log_dir: str, crash_after: int | None) -> None:
100
+ import dspy
101
+ from dspy.utils.dummies import DummyLM
102
+
103
+ import cmpnd
104
+
105
+ cmpnd.configure(
106
+ api_key=api_key,
107
+ endpoint=endpoint,
108
+ project_tags=["seed-data", "gepa-resume-demo"],
109
+ flush_interval_seconds=1,
110
+ )
111
+ cmpnd.auto_instrument() # patches dspy.GEPA to export via the tracker
112
+
113
+ # DummyLM cycles a fixed answer set; the student ignores instructions so the
114
+ # score curve is modest — the bug is about run identity, not lift.
115
+ student_lm = DummyLM([{"committee": "finance"}, {"committee": "veterans"}] * 500)
116
+ reflection_lm = DummyLM([{"new_instruction": "Name the single most specific committee."}] * 500)
117
+ dspy.settings.configure(lm=student_lm)
118
+
119
+ program = dspy.Predict("email_body -> committee")
120
+ trainset, valset = _build_examples()
121
+
122
+ callbacks = []
123
+ if crash_after is not None:
124
+ callbacks.append(_CrashAfterCheckpoints(crash_after))
125
+
126
+ gepa = dspy.GEPA(
127
+ metric=_metric,
128
+ reflection_lm=reflection_lm,
129
+ max_metric_calls=30,
130
+ reflection_minibatch_size=2,
131
+ num_threads=1,
132
+ seed=0,
133
+ use_merge=False,
134
+ track_stats=True,
135
+ log_dir=log_dir,
136
+ gepa_kwargs={"callbacks": callbacks},
137
+ )
138
+ try:
139
+ optimized = gepa.compile(program, trainset=trainset, valset=valset)
140
+ run_id = getattr(optimized, "_optimization_run_id", None)
141
+ sys.stdout.write(f"RESUME_RUN_ID={run_id}\n")
142
+ finally:
143
+ cmpnd.shutdown_exporter()
144
+
145
+
146
+ def main() -> int:
147
+ ap = argparse.ArgumentParser()
148
+ ap.add_argument("--endpoint", required=True)
149
+ ap.add_argument("--api-key", required=True)
150
+ ap.add_argument("--log-dir", default=None)
151
+ ap.add_argument("--phase", choices=["both", "crash", "resume"], default="both")
152
+ args = ap.parse_args()
153
+
154
+ if args.phase == "crash":
155
+ _run_compile(args.endpoint, args.api_key, args.log_dir, crash_after=4)
156
+ return 0
157
+ if args.phase == "resume":
158
+ _run_compile(args.endpoint, args.api_key, args.log_dir, crash_after=None)
159
+ return 0
160
+
161
+ # both: run each phase in its own process (crash hard-exits the interpreter)
162
+ log_dir = args.log_dir or tempfile.mkdtemp(prefix="gepa_resume_demo_")
163
+ sys.stdout.write(f"log_dir={log_dir}\n")
164
+ sys.stdout.flush()
165
+ for phase in ("crash", "resume"):
166
+ sys.stdout.write(f"\n===== phase {phase} =====\n")
167
+ sys.stdout.flush()
168
+ rc = subprocess.call(
169
+ [
170
+ sys.executable,
171
+ os.path.abspath(__file__),
172
+ "--endpoint",
173
+ args.endpoint,
174
+ "--api-key",
175
+ args.api_key,
176
+ "--log-dir",
177
+ log_dir,
178
+ "--phase",
179
+ phase,
180
+ ],
181
+ )
182
+ sys.stdout.write(f"[{phase}] exit={rc}\n")
183
+ return 0
184
+
185
+
186
+ if __name__ == "__main__":
187
+ raise SystemExit(main())