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.
- {cmpnd-0.3.0 → cmpnd-0.4.0}/CLAUDE.md +1 -1
- {cmpnd-0.3.0 → cmpnd-0.4.0}/PKG-INFO +1 -1
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/__init__.py +7 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/_gepa_patch.py +3 -1
- cmpnd-0.4.0/cmpnd/_program_patch.py +112 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/deployment.py +8 -8
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/gepa.py +3 -1
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/gepa_callback.py +6 -2
- cmpnd-0.4.0/cmpnd/optimizers/resume.py +91 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/tracker.py +73 -17
- {cmpnd-0.3.0 → cmpnd-0.4.0}/pyproject.toml +1 -1
- cmpnd-0.4.0/scripts/fixtures/review_run.json.gz +0 -0
- cmpnd-0.4.0/scripts/gepa_resume_bug.py +187 -0
- cmpnd-0.4.0/scripts/seed_review_run.py +504 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/committee_task.py +4 -2
- cmpnd-0.4.0/tests/e2e/data/committee_train.ndjson.gz +0 -0
- cmpnd-0.4.0/tests/e2e/data/committee_val.ndjson.gz +0 -0
- cmpnd-0.4.0/tests/e2e/test_optimization_lineage.py +96 -0
- cmpnd-0.4.0/tests/e2e/test_optimize_review_committee.py +161 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimize_server_owned_completion.py +1 -1
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/conftest.py +22 -35
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/seed_parity.py +15 -50
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_auth.py +14 -11
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_datasets.py +20 -18
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_deployments.py +11 -8
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_evals.py +10 -6
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_health.py +8 -6
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_modules.py +7 -5
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_optimizations.py +13 -10
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_programs.py +9 -6
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_signatures.py +9 -6
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_trace_streaming.py +32 -35
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/test_traces.py +11 -7
- cmpnd-0.4.0/tests/stress/_reliability.py +81 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_deploy_programs.py +39 -5
- cmpnd-0.4.0/tests/stress/test_optimize_client_committee.py +83 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_optimize_loop.py +4 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_optimize_stress.py +4 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_real_provider_twins.py +22 -8
- cmpnd-0.4.0/tests/stress/test_reliability.py +55 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_gepa_callback.py +3 -0
- cmpnd-0.4.0/tests/test_gepa_resume.py +145 -0
- cmpnd-0.4.0/tests/test_optimize_terminal_failure.py +110 -0
- cmpnd-0.4.0/tests/test_program_lineage_persist.py +122 -0
- cmpnd-0.4.0/tests/test_seed_review_run.py +120 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/uv.lock +116 -0
- cmpnd-0.3.0/tests/e2e/data/committee_train.ndjson +0 -8
- cmpnd-0.3.0/tests/e2e/data/committee_val.ndjson +0 -5
- {cmpnd-0.3.0 → cmpnd-0.4.0}/.gitignore +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/README.md +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/callback.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/api_client.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/admin.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/auth.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/datasets.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/deployments.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/evals.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/login.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/optimizations.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/org.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/programs.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/top.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/commands/traces.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/credentials.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/shell.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/cli/verbs.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/configuration.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/context.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/dataset_sync.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/datasets.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/decorators.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/eval_handler.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/execution.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/exporter.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/helpers.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/identity.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/decode.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/encode.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/hash.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/program.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/reflection.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/ir/types.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/models.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimization.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/optimizers/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/cmpnd/packaging.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/2026-04-02-code-review.md +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/docs/sdk-instrumentation.md +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/examples/local_ollama.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/__init__.pyi +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/primitives/__init__.pyi +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/primitives/prediction.pyi +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/utils/__init__.pyi +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/stubs/dspy/utils/callback.pyi +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/_exporter_helpers.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/_identity_helpers.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/conftest.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/fake_lm.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/helpers.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_capture_prompts.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_composite_module.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_deploy_execute.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_deploy_programs.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_edge_cases.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_example_hash.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_gepa_optimization.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_lm_accuracy.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_module_tracing.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimize_failures.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_optimize_loop.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_parallel_export.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_project_attribution.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_react_tool_spans.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_save_load.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_trace_structure.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/e2e/test_user_attribution.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/01_predict_qa.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/08_opaque_module.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/10_react.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/11_refine.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/12_retrieve.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/13_knn.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/14_parallel.json +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/identity_vectors/README.md +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/conftest.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_logs_renders_traces.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_optimizing_progress.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/integration/test_unhappy_path.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/parity/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/stress/test_multi_client_stress.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_callback.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/conftest.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_admin.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_api_client.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_auth.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_credentials.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_datasets.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_deployments.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_evals.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_login.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_main.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_optimizations.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_org.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_parity.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_programs.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_shell.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_top.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_cli/test_traces.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/__init__.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/conftest.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/test_edge_cases.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/test_hash_consistency.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_clustering/test_type_conversion.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_configuration.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_context.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_context_propagation.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_dataset_polling.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_dataset_sync.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_datasets.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_decorators.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_deploy.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_eval.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_eval_start.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_exception_paths.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_execute.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_exporter.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_exporter_real.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_fake_lm.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_gepa_integration.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_identity.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_integration.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_ir_encoder_properties.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_ir_golden_vectors.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_ir_roundtrip.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_models.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_optimization_start.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_optimize.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_optimize_lift.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_packaging.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_packaging_callable_source.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_packaging_program_source.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_project_payload.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_reflector_properties.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_reflector_smoke.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_repro_reactv2_bugs.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_schemas.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_stats_export.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_tracked_gepa.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_tracker_enrich.py +0 -0
- {cmpnd-0.3.0 → cmpnd-0.4.0}/tests/test_truncation.py +0 -0
- {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 ->
|
|
38
|
+
- `cmpnd/deployment.py` - `deploy()` function: SDK -> serve -> AWS deploy stack
|
|
39
39
|
|
|
40
40
|
## Reflector ↔ DSPy compatibility
|
|
41
41
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
196
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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.
|
|
300
|
+
self._tracker.finalize_error(exception)
|
|
297
301
|
else:
|
|
298
|
-
self._tracker.
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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.
|
|
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"
|
|
Binary file
|
|
@@ -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())
|