cmpnd 0.8.1__tar.gz → 0.8.2__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 (230) hide show
  1. {cmpnd-0.8.1 → cmpnd-0.8.2}/PKG-INFO +1 -1
  2. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/top.py +1 -2
  3. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/dataset_sync.py +11 -3
  4. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/deployment.py +19 -8
  5. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/models.py +98 -31
  6. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/optimizers/gepa_callback.py +5 -4
  7. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/optimizers/tracker.py +20 -8
  8. {cmpnd-0.8.1 → cmpnd-0.8.2}/docs/sdk-instrumentation.md +16 -0
  9. {cmpnd-0.8.1 → cmpnd-0.8.2}/pyproject.toml +1 -1
  10. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_deploy.py +20 -4
  11. cmpnd-0.8.2/tests/test_models.py +849 -0
  12. {cmpnd-0.8.1 → cmpnd-0.8.2}/uv.lock +88 -88
  13. cmpnd-0.8.1/tests/test_models.py +0 -438
  14. {cmpnd-0.8.1 → cmpnd-0.8.2}/.gitignore +0 -0
  15. {cmpnd-0.8.1 → cmpnd-0.8.2}/CLAUDE.md +0 -0
  16. {cmpnd-0.8.1 → cmpnd-0.8.2}/README.md +0 -0
  17. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/__init__.py +0 -0
  18. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/_gepa_patch.py +0 -0
  19. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/_program_patch.py +0 -0
  20. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/_rlm_patch.py +0 -0
  21. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/callback.py +0 -0
  22. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/__init__.py +0 -0
  23. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/api_client.py +0 -0
  24. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/__init__.py +0 -0
  25. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/admin.py +0 -0
  26. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/auth.py +0 -0
  27. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/datasets.py +0 -0
  28. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/deployments.py +0 -0
  29. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/evals.py +0 -0
  30. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/login.py +0 -0
  31. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/optimizations.py +0 -0
  32. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/org.py +0 -0
  33. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/programs.py +0 -0
  34. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/commands/traces.py +0 -0
  35. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/credentials.py +0 -0
  36. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/shell.py +0 -0
  37. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/trace_render.py +0 -0
  38. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/cli/verbs.py +0 -0
  39. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/configuration.py +0 -0
  40. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/context.py +0 -0
  41. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/datasets.py +0 -0
  42. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/decorators.py +0 -0
  43. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/eval_handler.py +0 -0
  44. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/execution.py +0 -0
  45. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/exporter.py +0 -0
  46. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/exporter_http.py +0 -0
  47. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/helpers.py +0 -0
  48. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/identity.py +0 -0
  49. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/imaging.py +0 -0
  50. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/__init__.py +0 -0
  51. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/decode.py +0 -0
  52. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/encode.py +0 -0
  53. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/hash.py +0 -0
  54. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/program.py +0 -0
  55. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/reflection.py +0 -0
  56. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/types.py +0 -0
  57. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/ir/xxh64.py +0 -0
  58. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/optimization.py +0 -0
  59. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/optimizers/__init__.py +0 -0
  60. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/optimizers/gepa.py +0 -0
  61. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/optimizers/resume.py +0 -0
  62. {cmpnd-0.8.1 → cmpnd-0.8.2}/cmpnd/packaging.py +0 -0
  63. {cmpnd-0.8.1 → cmpnd-0.8.2}/docs/2026-04-02-code-review.md +0 -0
  64. {cmpnd-0.8.1 → cmpnd-0.8.2}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  65. {cmpnd-0.8.1 → cmpnd-0.8.2}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  66. {cmpnd-0.8.1 → cmpnd-0.8.2}/examples/local_ollama.py +0 -0
  67. {cmpnd-0.8.1 → cmpnd-0.8.2}/scripts/capture_review_run.py +0 -0
  68. {cmpnd-0.8.1 → cmpnd-0.8.2}/scripts/fixtures/review_run.json.gz +0 -0
  69. {cmpnd-0.8.1 → cmpnd-0.8.2}/scripts/gepa_resume_bug.py +0 -0
  70. {cmpnd-0.8.1 → cmpnd-0.8.2}/scripts/seed_review_run.py +0 -0
  71. {cmpnd-0.8.1 → cmpnd-0.8.2}/stubs/dspy/__init__.pyi +0 -0
  72. {cmpnd-0.8.1 → cmpnd-0.8.2}/stubs/dspy/primitives/__init__.pyi +0 -0
  73. {cmpnd-0.8.1 → cmpnd-0.8.2}/stubs/dspy/primitives/prediction.pyi +0 -0
  74. {cmpnd-0.8.1 → cmpnd-0.8.2}/stubs/dspy/utils/__init__.pyi +0 -0
  75. {cmpnd-0.8.1 → cmpnd-0.8.2}/stubs/dspy/utils/callback.pyi +0 -0
  76. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/__init__.py +0 -0
  77. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/_exporter_helpers.py +0 -0
  78. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/_identity_helpers.py +0 -0
  79. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/__init__.py +0 -0
  80. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/committee_task.py +0 -0
  81. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/conftest.py +0 -0
  82. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  83. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  84. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/fake_lm.py +0 -0
  85. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/helpers.py +0 -0
  86. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_capture_prompts.py +0 -0
  87. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_composite_module.py +0 -0
  88. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_dataset_auto_creation.py +0 -0
  89. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_deploy_execute.py +0 -0
  90. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_deploy_programs.py +0 -0
  91. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_edge_cases.py +0 -0
  92. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_example_hash.py +0 -0
  93. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_gepa_optimization.py +0 -0
  94. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_lm_accuracy.py +0 -0
  95. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_module_tracing.py +0 -0
  96. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_optimization_lineage.py +0 -0
  97. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_optimization_metric_identity.py +0 -0
  98. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_optimize_failures.py +0 -0
  99. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_optimize_loop.py +0 -0
  100. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_optimize_review_committee.py +0 -0
  101. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  102. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_parallel_export.py +0 -0
  103. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_project_attribution.py +0 -0
  104. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_react_tool_spans.py +0 -0
  105. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_save_load.py +0 -0
  106. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_trace_structure.py +0 -0
  107. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/e2e/test_user_attribution.py +0 -0
  108. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/01_predict_qa.json +0 -0
  109. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  110. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  111. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  112. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  113. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  114. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/07_program_of_thought.json +0 -0
  115. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/08_opaque_module.json +0 -0
  116. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  117. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/10_react.json +0 -0
  118. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/11_refine.json +0 -0
  119. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/12_retrieve.json +0 -0
  120. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/13_knn.json +0 -0
  121. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/14_parallel.json +0 -0
  122. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/identity_vectors/README.md +0 -0
  123. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/integration/__init__.py +0 -0
  124. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/integration/conftest.py +0 -0
  125. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  126. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/integration/test_logs_renders_traces.py +0 -0
  127. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/integration/test_optimizing_progress.py +0 -0
  128. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/integration/test_unhappy_path.py +0 -0
  129. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/__init__.py +0 -0
  130. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/conftest.py +0 -0
  131. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/seed_parity.py +0 -0
  132. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_auth.py +0 -0
  133. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_datasets.py +0 -0
  134. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_deployments.py +0 -0
  135. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_evals.py +0 -0
  136. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_health.py +0 -0
  137. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_modules.py +0 -0
  138. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_optimizations.py +0 -0
  139. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_programs.py +0 -0
  140. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_signatures.py +0 -0
  141. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_trace_streaming.py +0 -0
  142. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/parity/test_traces.py +0 -0
  143. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/__init__.py +0 -0
  144. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/_provider.py +0 -0
  145. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/_reliability.py +0 -0
  146. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/mint_bedrock_token.py +0 -0
  147. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_deploy_programs.py +0 -0
  148. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_multi_client_stress.py +0 -0
  149. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_optimize_client_committee.py +0 -0
  150. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_optimize_loop.py +0 -0
  151. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_optimize_loop_wasm.py +0 -0
  152. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_optimize_stress.py +0 -0
  153. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_provider_roles.py +0 -0
  154. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_real_provider_twins.py +0 -0
  155. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_reliability.py +0 -0
  156. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_reliability_classify.py +0 -0
  157. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/stress/test_rlm_wasm.py +0 -0
  158. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_callback.py +0 -0
  159. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_candidate_lineage.py +0 -0
  160. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/__init__.py +0 -0
  161. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/conftest.py +0 -0
  162. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_admin.py +0 -0
  163. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_api_client.py +0 -0
  164. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_auth.py +0 -0
  165. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_credentials.py +0 -0
  166. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_datasets.py +0 -0
  167. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_deployments.py +0 -0
  168. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_evals.py +0 -0
  169. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_login.py +0 -0
  170. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_main.py +0 -0
  171. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_optimizations.py +0 -0
  172. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_org.py +0 -0
  173. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_parity.py +0 -0
  174. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_programs.py +0 -0
  175. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_shell.py +0 -0
  176. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_top.py +0 -0
  177. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cli/test_traces.py +0 -0
  178. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_clustering/__init__.py +0 -0
  179. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_clustering/conftest.py +0 -0
  180. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_clustering/test_edge_cases.py +0 -0
  181. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_clustering/test_hash_consistency.py +0 -0
  182. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_clustering/test_type_conversion.py +0 -0
  183. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_cmp551_grouping_identity.py +0 -0
  184. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_configuration.py +0 -0
  185. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_context.py +0 -0
  186. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_context_propagation.py +0 -0
  187. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_dataset_polling.py +0 -0
  188. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_dataset_sync.py +0 -0
  189. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_datasets.py +0 -0
  190. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_decorators.py +0 -0
  191. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_eval.py +0 -0
  192. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_eval_start.py +0 -0
  193. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_exception_paths.py +0 -0
  194. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_execute.py +0 -0
  195. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_exporter.py +0 -0
  196. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_exporter_real.py +0 -0
  197. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_fake_lm.py +0 -0
  198. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_gepa_callback.py +0 -0
  199. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_gepa_integration.py +0 -0
  200. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_gepa_resume.py +0 -0
  201. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_identity.py +0 -0
  202. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_imaging.py +0 -0
  203. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_import_weight.py +0 -0
  204. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_integration.py +0 -0
  205. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_ir_encoder_properties.py +0 -0
  206. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_ir_golden_vectors.py +0 -0
  207. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_ir_roundtrip.py +0 -0
  208. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_optimization_progress.py +0 -0
  209. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_optimization_start.py +0 -0
  210. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_optimize.py +0 -0
  211. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_optimize_lift.py +0 -0
  212. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_optimize_terminal_failure.py +0 -0
  213. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_packaging.py +0 -0
  214. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_packaging_callable_source.py +0 -0
  215. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_packaging_program_source.py +0 -0
  216. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_program_lineage_persist.py +0 -0
  217. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_project_payload.py +0 -0
  218. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_reflector_properties.py +0 -0
  219. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_reflector_smoke.py +0 -0
  220. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_repro_reactv2_bugs.py +0 -0
  221. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_rlm_patch.py +0 -0
  222. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_schemas.py +0 -0
  223. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_seed_review_run.py +0 -0
  224. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_stats_export.py +0 -0
  225. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_trace_render.py +0 -0
  226. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_tracked_gepa.py +0 -0
  227. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_tracker_enrich.py +0 -0
  228. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_truncation.py +0 -0
  229. {cmpnd-0.8.1 → cmpnd-0.8.2}/tests/test_xxh64.py +0 -0
  230. {cmpnd-0.8.1 → cmpnd-0.8.2}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmpnd
3
- Version: 0.8.1
3
+ Version: 0.8.2
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>
@@ -622,10 +622,9 @@ def _parse_deploy_metadata(raw: str | None) -> dict[str, Any] | None:
622
622
  from ... import deployment
623
623
 
624
624
  try:
625
- deployment._validate_deploy_metadata(value)
625
+ return deployment._prepare_deploy_metadata(value)
626
626
  except (TypeError, ValueError) as exc:
627
627
  raise api_client.CliError(f"--metadata is invalid: {exc}")
628
- return value
629
628
 
630
629
 
631
630
  def cmd_deploy(client: api_client.ApiClient, args: Any) -> None:
@@ -12,7 +12,7 @@ from typing import Any
12
12
 
13
13
  import httpx
14
14
 
15
- from . import configuration, identity
15
+ from . import configuration, identity, models
16
16
 
17
17
  logger = logging.getLogger(__name__)
18
18
 
@@ -113,12 +113,20 @@ def build_dataset_payload(
113
113
  inputs = {k: v for k, v in d.items() if k in input_fields}
114
114
  expected_outputs = {k: v for k, v in d.items() if k not in input_fields}
115
115
 
116
+ # Hashed from the example as given, deliberately: this hash keys dataset
117
+ # dedup and the per-run split-membership join (train/val_example_hashes,
118
+ # computed elsewhere from the same unscrubbed example), so it is the
119
+ # example's identity and must not move with a storage concern.
116
120
  example_hash = identity.compute_example_hash(inputs) if inputs else ""
117
121
 
118
122
  examples.append(
119
123
  {
120
- "inputs": inputs,
121
- "expected_outputs": expected_outputs,
124
+ # Both land in jsonb columns, so they go through the same
125
+ # serializer the trace path uses — which also makes a
126
+ # non-JSON-native field value (a Pydantic model, an image) exportable
127
+ # instead of failing the encode at send time.
128
+ "inputs": models._safe_serialize(inputs),
129
+ "expected_outputs": models._safe_serialize(expected_outputs),
122
130
  "example_hash": example_hash,
123
131
  }
124
132
  )
@@ -16,7 +16,7 @@ from typing import Any
16
16
 
17
17
  import httpx
18
18
 
19
- from . import configuration, execution, identity, packaging
19
+ from . import configuration, execution, identity, models, packaging
20
20
 
21
21
  logger = logging.getLogger(__name__)
22
22
 
@@ -27,9 +27,19 @@ logger = logging.getLogger(__name__)
27
27
  _MAX_DEPLOY_METADATA_BYTES = 64 * 1024
28
28
 
29
29
 
30
- def _validate_deploy_metadata(metadata: Any) -> None:
31
- """Validate the deploy metadata blob, raising at the call site on a bad
32
- value so the user sees a clear message instead of a server 422/413."""
30
+ def _prepare_deploy_metadata(metadata: Any) -> dict[str, Any]:
31
+ """Validate the deploy metadata blob and return it in storable form.
32
+
33
+ Raises at the call site on a bad value so the user sees a clear message
34
+ instead of a server 422/413. Validation and scrubbing are one step on
35
+ purpose: the returned blob lands in a jsonb column, so a caller that
36
+ validated without scrubbing would still be able to ship a payload the
37
+ column must reject.
38
+
39
+ The size cap is measured on the value as given — scrubbing only ever
40
+ shortens it, so the check stays the conservative one the server's own
41
+ limit is stated against.
42
+ """
33
43
  if not isinstance(metadata, dict):
34
44
  raise TypeError(f"deploy(metadata=...) must be a dict, got {type(metadata).__name__}")
35
45
  if not all(isinstance(k, str) for k in metadata):
@@ -40,6 +50,7 @@ def _validate_deploy_metadata(metadata: Any) -> None:
40
50
  raise TypeError(f"deploy(metadata=...) must be JSON-serializable: {exc}") from exc
41
51
  if len(encoded.encode("utf-8")) > _MAX_DEPLOY_METADATA_BYTES:
42
52
  raise ValueError(f"deploy(metadata=...) exceeds the {_MAX_DEPLOY_METADATA_BYTES}-byte limit")
53
+ return models._safe_serialize(metadata)
43
54
 
44
55
 
45
56
  def _raise_http_error(response: httpx.Response, action: str) -> None:
@@ -155,11 +166,11 @@ def deploy(module, metric=None, timeout=300, metadata=None):
155
166
  # slot and reject an oversized deploy before signing it (Slice E).
156
167
  "declared_content_length": len(zip_bytes),
157
168
  }
158
- # Carry the caller's metadata blob (validated at the call site) as a
159
- # reserved top-level body key, committed with the deployment record server-side.
169
+ # Carry the caller's metadata blob (validated and made jsonb-storable at the
170
+ # call site) as a reserved top-level body key, committed with the deployment
171
+ # record server-side.
160
172
  if metadata is not None:
161
- _validate_deploy_metadata(metadata)
162
- deploy_body["metadata"] = metadata
173
+ deploy_body["metadata"] = _prepare_deploy_metadata(metadata)
163
174
  if sig is not None:
164
175
  deploy_body["signature_name"] = packaging._signature_to_string(sig)
165
176
  deploy_body["module_class"] = type(module).__name__
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  from dataclasses import dataclass, field
7
8
  from datetime import datetime, timezone
8
9
  from enum import Enum
@@ -11,6 +12,50 @@ from uuid import UUID
11
12
 
12
13
  from . import identity, imaging
13
14
 
15
+ # U+0000 (NUL) and lone UTF-16 surrogates are valid JSON, and valid in a Python
16
+ # str, but the server cannot store either: a JSON column rejects both, and a NUL
17
+ # is rejected by a plain text column too (it is valid UTF-8, but PostgreSQL text
18
+ # cannot hold it). A captured input/output/attribute string, and the text of an
19
+ # exception quoting that data, can all legitimately contain them — so we strip
20
+ # them here rather than shipping a payload the server must reject. On a UTF-8
21
+ # database these are the only such code points, so this is the complete set.
22
+ #
23
+ # "Chokepoint" is load-bearing and is a property of the callers, not just of
24
+ # this function: every payload field bound for a JSON *or* text column goes
25
+ # through _safe_serialize (or _strip_unstorable / _strip_unstorable_opt for a
26
+ # value already known to be a string). Anything that hand-rolls its own
27
+ # serializer instead reopens the hole — the optimizer tracker and the
28
+ # dataset/deploy payload builders therefore route through here too.
29
+ _UNSTORABLE_CODEPOINTS = re.compile("[\x00\ud800-\udfff]")
30
+
31
+
32
+ def _strip_unstorable(s: str) -> str:
33
+ """Drop un-storable code points (U+0000, lone surrogates) from ``s``.
34
+
35
+ Fast path: the C-level search returns ``None`` for the ~always-clean case,
36
+ so no replacement string is built.
37
+ """
38
+ return _UNSTORABLE_CODEPOINTS.sub("", s) if _UNSTORABLE_CODEPOINTS.search(s) else s
39
+
40
+
41
+ def _strip_unstorable_opt(s: str | None) -> str | None:
42
+ """``_strip_unstorable`` for an optional string, preserving ``None``.
43
+
44
+ For the top-level payload fields that are plain strings on the wire and land
45
+ in text columns (notably ``error_message``, which is ``str(exception)`` and
46
+ so routinely quotes the very captured data the code point came from) — they
47
+ never pass through ``_safe_serialize``.
48
+ """
49
+ return None if s is None else _strip_unstorable(s)
50
+
51
+
52
+ def _type_name(value: Any) -> str:
53
+ """Scrubbed class name of ``value``, for the type tags and marker strings
54
+ below. CPython refuses an un-storable code point in a real class name, so
55
+ this is defensive: a metaclass answering ``__name__`` itself is the only way
56
+ one arrives, and the scrub costs one call at each interpolation site."""
57
+ return _strip_unstorable(type(value).__name__)
58
+
14
59
 
15
60
  def _safe_serialize(value: Any, _stack: set[int] | None = None) -> Any:
16
61
  """Recursively serialize a value to JSON-safe types.
@@ -28,8 +73,11 @@ def _safe_serialize(value: Any, _stack: set[int] | None = None) -> Any:
28
73
  if value is None:
29
74
  return None
30
75
 
31
- # Primitives - JSON safe (no need to track these)
32
- if isinstance(value, (str, int, float, bool)):
76
+ # Primitives - JSON safe (no need to track these). Strings are scrubbed of
77
+ # jsonb-un-storable code points; numerics/bools can't carry any.
78
+ if isinstance(value, str):
79
+ return _strip_unstorable(value)
80
+ if isinstance(value, (int, float, bool)):
33
81
  return value
34
82
 
35
83
  # UUID
@@ -43,23 +91,27 @@ def _safe_serialize(value: Any, _stack: set[int] | None = None) -> Any:
43
91
  # Bytes
44
92
  if isinstance(value, bytes):
45
93
  try:
46
- return value.decode("utf-8")
94
+ return _strip_unstorable(value.decode("utf-8"))
47
95
  except Exception:
48
96
  return f"<bytes: {len(value)} bytes>"
49
97
 
50
- # Enums
98
+ # Enums — recurse so a string-backed member value is scrubbed (real enums
99
+ # like SpanType have clean values, so this is a no-op for them; it only
100
+ # strips a pathological NUL/lone-surrogate value).
51
101
  if isinstance(value, Enum):
52
- return value.value
102
+ return _safe_serialize(value.value, _stack)
53
103
 
54
- # Types/classes (like SignatureMeta)
104
+ # Types/classes (like SignatureMeta). repr() of a class is whatever its
105
+ # metaclass says — DSPy's interpolates the signature's instructions and field
106
+ # metadata — so it is captured text, not a bounded label.
55
107
  if isinstance(value, type):
56
- return repr(value)
108
+ return _strip_unstorable(repr(value))
57
109
 
58
110
  # For compound types, check for cycles using object identity
59
111
  obj_id = id(value)
60
112
  if obj_id in _stack:
61
113
  # True cycle detected - we're currently serializing this object
62
- return f"<circular ref: {type(value).__name__}>"
114
+ return f"<circular ref: {_type_name(value)}>"
63
115
 
64
116
  # Add to stack before recursing into compound types
65
117
  _stack.add(obj_id)
@@ -73,9 +125,9 @@ def _safe_serialize(value: Any, _stack: set[int] | None = None) -> Any:
73
125
  if isinstance(value, (list, tuple)):
74
126
  return [_safe_serialize(v, _stack) for v in value]
75
127
 
76
- # Dicts
128
+ # Dicts (keys become jsonb object keys, so scrub them too)
77
129
  if isinstance(value, dict):
78
- return {str(k): _safe_serialize(v, _stack) for k, v in value.items()}
130
+ return {_strip_unstorable(str(k)): _safe_serialize(v, _stack) for k, v in value.items()}
79
131
 
80
132
  # DSPy Prediction and similar - has toDict method
81
133
  if hasattr(value, "toDict") and callable(value.toDict):
@@ -107,12 +159,17 @@ def _safe_serialize(value: Any, _stack: set[int] | None = None) -> Any:
107
159
  except Exception:
108
160
  pass
109
161
 
110
- # Objects with __dict__ (general objects, custom classes)
162
+ # Objects with __dict__ (general objects, custom classes). Attribute
163
+ # names become jsonb object keys, so scrub them like dict keys.
111
164
  if hasattr(value, "__dict__"):
112
165
  try:
113
- obj_dict = {k: _safe_serialize(v, _stack) for k, v in value.__dict__.items() if not k.startswith("_")}
166
+ obj_dict = {
167
+ _strip_unstorable(k): _safe_serialize(v, _stack)
168
+ for k, v in value.__dict__.items()
169
+ if not k.startswith("_")
170
+ }
114
171
  # Add type info for clarity
115
- obj_dict["__type__"] = type(value).__name__
172
+ obj_dict["__type__"] = _type_name(value)
116
173
  return obj_dict
117
174
  except Exception:
118
175
  pass
@@ -123,17 +180,17 @@ def _safe_serialize(value: Any, _stack: set[int] | None = None) -> Any:
123
180
  obj_dict = {}
124
181
  for slot in value.__slots__: # pyright: ignore[reportAttributeAccessIssue]
125
182
  if not slot.startswith("_") and hasattr(value, slot):
126
- obj_dict[slot] = _safe_serialize(getattr(value, slot), _stack)
127
- obj_dict["__type__"] = type(value).__name__
183
+ obj_dict[_strip_unstorable(slot)] = _safe_serialize(getattr(value, slot), _stack)
184
+ obj_dict["__type__"] = _type_name(value)
128
185
  return obj_dict
129
186
  except Exception:
130
187
  pass
131
188
 
132
189
  # Final fallback - convert to string
133
190
  try:
134
- return str(value)
191
+ return _strip_unstorable(str(value))
135
192
  except Exception:
136
- return f"<unserializable: {type(value).__name__}>"
193
+ return f"<unserializable: {_type_name(value)}>"
137
194
 
138
195
  finally:
139
196
  # Remove from stack after we're done serializing this object
@@ -455,7 +512,13 @@ class SpanBuilder:
455
512
  )
456
513
 
457
514
  def _build_attributes(self) -> dict[str, str]:
458
- """Build attributes dict, injecting repl_history if present."""
515
+ """Build attributes dict, injecting repl_history if present.
516
+
517
+ The nested JSON rides as an attribute *value*, so it reaches the wire
518
+ through the same scrub as every other attribute string (``to_api_dict``
519
+ passes the result to ``_safe_serialize``) — the jsonb-storability of the
520
+ column does not rest on ``json.dumps``' ``ensure_ascii`` escaping here.
521
+ """
459
522
  attrs = dict(self.attributes)
460
523
  if self.repl_history:
461
524
  attrs["repl_history"] = json.dumps(self.repl_history)
@@ -472,7 +535,7 @@ class SpanBuilder:
472
535
  "start_time": self.start_time.isoformat() if self.start_time else None,
473
536
  "end_time": self.end_time.isoformat() if self.end_time else None,
474
537
  "status": self.status.value,
475
- "error_message": self.error_message,
538
+ "error_message": _strip_unstorable_opt(self.error_message),
476
539
  "error_type": self.error_type,
477
540
  "signature_name": self.signature_name,
478
541
  "signature_instructions": self.signature_instructions,
@@ -577,7 +640,7 @@ class TraceBuilder:
577
640
  "start_time": self.start_time.isoformat() if self.start_time else None,
578
641
  "end_time": self.end_time.isoformat() if self.end_time else None,
579
642
  "status": self.status.value,
580
- "error_message": self.error_message,
643
+ "error_message": _strip_unstorable_opt(self.error_message),
581
644
  "program_name": self.program_name,
582
645
  "program_version": self.program_version,
583
646
  "signature_name": self.signature_name,
@@ -589,7 +652,11 @@ class TraceBuilder:
589
652
  "signature_output_field_schemas": self.signature_output_field_schemas,
590
653
  "program_signature_hash": identity.format_signature_hash(self.program_signature_hash),
591
654
  "signature_summary": self.signature_summary,
592
- "component_signatures": self.component_signatures,
655
+ # The server forwards this blob verbatim into a jsonb column, and its
656
+ # program registration is best-effort (a rejection is only logged), so
657
+ # an un-storable code point here would silently lose the whole
658
+ # component list. Scrub at the source — the only place that can.
659
+ "component_signatures": _safe_serialize(self.component_signatures),
593
660
  "compiled_from_run_id": self.compiled_from_run_id,
594
661
  "demo_ids": self.demo_ids,
595
662
  "request_preview": (self.request_preview or "")[:500],
@@ -597,8 +664,8 @@ class TraceBuilder:
597
664
  "eval_run_id": str(self.eval_run_id) if self.eval_run_id else None,
598
665
  "optimization_run_id": (str(self.optimization_run_id) if self.optimization_run_id else None),
599
666
  "adapter_type": self.adapter_type,
600
- "tags": self.tags,
601
- "metadata": self.metadata,
667
+ "tags": _safe_serialize(self.tags),
668
+ "metadata": _safe_serialize(self.metadata),
602
669
  "project": self.project_tags[0] if self.project_tags else None,
603
670
  "project_tags": self.project_tags if self.project_tags else [],
604
671
  "spans": [s.to_api_dict() for s in self._batch_spans] if self._batch_spans else [],
@@ -617,7 +684,7 @@ class TraceBuilder:
617
684
  "signature_output_field_types": self.signature_output_field_types,
618
685
  "program_signature_hash": identity.format_signature_hash(self.program_signature_hash),
619
686
  "signature_summary": self.signature_summary,
620
- "component_signatures": self.component_signatures,
687
+ "component_signatures": _safe_serialize(self.component_signatures),
621
688
  "root_span_type": self.root_span_type,
622
689
  "project": self.project_tags[0] if self.project_tags else None,
623
690
  "project_tags": self.project_tags if self.project_tags else [],
@@ -629,7 +696,7 @@ class TraceBuilder:
629
696
  "start_time": self.start_time.isoformat() if self.start_time else None,
630
697
  "end_time": self.end_time.isoformat() if self.end_time else None,
631
698
  "status": self.status.value,
632
- "error_message": self.error_message,
699
+ "error_message": _strip_unstorable_opt(self.error_message),
633
700
  "request_preview": (self.request_preview or "")[:500],
634
701
  "response_preview": (self.response_preview or "")[:500],
635
702
  "total_tokens": self.total_tokens,
@@ -642,8 +709,8 @@ class TraceBuilder:
642
709
  "demo_ids": self.demo_ids,
643
710
  "eval_run_id": str(self.eval_run_id) if self.eval_run_id else None,
644
711
  "optimization_run_id": (str(self.optimization_run_id) if self.optimization_run_id else None),
645
- "tags": self.tags,
646
- "metadata": self.metadata,
712
+ "tags": _safe_serialize(self.tags),
713
+ "metadata": _safe_serialize(self.metadata),
647
714
  "project": self.project_tags[0] if self.project_tags else None,
648
715
  "project_tags": self.project_tags if self.project_tags else [],
649
716
  }
@@ -757,7 +824,7 @@ class EvalExampleBuilder:
757
824
  "total_tokens": self.total_tokens,
758
825
  "cost": self.cost,
759
826
  "status": self.status.value,
760
- "error_message": self.error_message,
827
+ "error_message": _strip_unstorable_opt(self.error_message),
761
828
  }
762
829
 
763
830
 
@@ -922,7 +989,7 @@ class EvalRunBuilder:
922
989
  "end_time": self.end_time.isoformat() if self.end_time else None,
923
990
  "duration_ms": self.duration_ms,
924
991
  "status": self.status.value,
925
- "error_message": self.error_message,
992
+ "error_message": _strip_unstorable_opt(self.error_message),
926
993
  "score": self.score,
927
994
  "total_examples": self.total_examples,
928
995
  "passed_examples": self.passed_examples,
@@ -935,8 +1002,8 @@ class EvalRunBuilder:
935
1002
  "total_cost": self.total_cost,
936
1003
  "avg_tokens_per_example": self.avg_tokens_per_example,
937
1004
  "avg_cost_per_example": self.avg_cost_per_example,
938
- "tags": self.tags,
939
- "metadata": self.metadata,
1005
+ "tags": _safe_serialize(self.tags),
1006
+ "metadata": _safe_serialize(self.metadata),
940
1007
  "examples": [ex.to_api_dict() for ex in self._examples],
941
1008
  "optimization_run_id": (str(self.optimization_run_id) if self.optimization_run_id else None),
942
1009
  "project": self.project_tags[0] if self.project_tags else None,
@@ -12,13 +12,12 @@ Usage:
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- import json
16
15
  import logging
17
16
  import time
18
17
  from typing import Any
19
18
  from uuid import UUID
20
19
 
21
- from .. import context
20
+ from .. import context, models
22
21
  from . import tracker
23
22
 
24
23
  logger = logging.getLogger(__name__)
@@ -191,8 +190,10 @@ class CmpndGEPACallback:
191
190
  components = event.get("components", [])
192
191
  dataset = event.get("dataset", {})
193
192
 
194
- # Deep copy to avoid mutation (matches tracker.py pattern)
195
- dataset_copy = json.loads(json.dumps(dataset, default=str))
193
+ # Deep copy to avoid mutation (matches tracker.py pattern), and the
194
+ # copy is jsonb-storable: the dataset is raw captured LM text bound
195
+ # for a jsonb column.
196
+ dataset_copy = models._safe_serialize(dataset)
196
197
 
197
198
  self._tracker.capture_reflective_dataset(
198
199
  iteration=iteration,
@@ -11,7 +11,7 @@ from datetime import datetime, timezone
11
11
  from typing import Any
12
12
  from uuid import UUID
13
13
 
14
- from .. import configuration, identity
14
+ from .. import configuration, identity, models
15
15
 
16
16
  logger = logging.getLogger(__name__)
17
17
 
@@ -45,6 +45,13 @@ def _sanitize_for_json(obj: Any) -> Any:
45
45
  """Recursively sanitize an object for JSON serialization.
46
46
 
47
47
  Handles numpy types, datetimes, UUIDs, and other common non-serializable types.
48
+
49
+ Every string this produces — value, dict key, or stringified fallback — is
50
+ scrubbed of code points a Postgres jsonb column cannot store, because the
51
+ fields it feeds (reflective datasets, instructions, run config, compiled
52
+ program state) are jsonb columns holding raw captured LM text and user
53
+ datasets. It shares ``models._strip_unstorable`` with the trace path rather
54
+ than re-deriving the rule.
48
55
  """
49
56
  if obj is None:
50
57
  return None
@@ -62,22 +69,24 @@ def _sanitize_for_json(obj: Any) -> Any:
62
69
  if "bool" in obj_type.lower():
63
70
  return bool(obj)
64
71
  if "str" in obj_type.lower():
65
- return str(obj)
72
+ return models._strip_unstorable(str(obj))
66
73
  # numpy arrays
67
74
  if hasattr(obj, "tolist"):
68
75
  return _sanitize_for_json(obj.tolist())
69
76
  # Fallback for other numpy types
70
- return str(obj)
77
+ return models._strip_unstorable(str(obj))
71
78
 
72
79
  # Handle standard types
73
- if isinstance(obj, (str, int, float, bool)):
80
+ if isinstance(obj, str):
81
+ return models._strip_unstorable(obj)
82
+ if isinstance(obj, (int, float, bool)):
74
83
  return obj
75
84
  if isinstance(obj, (datetime,)):
76
85
  return obj.isoformat()
77
86
  if isinstance(obj, UUID):
78
87
  return str(obj)
79
88
  if isinstance(obj, dict):
80
- return {str(k): _sanitize_for_json(v) for k, v in obj.items()}
89
+ return {models._strip_unstorable(str(k)): _sanitize_for_json(v) for k, v in obj.items()}
81
90
  if isinstance(obj, (list, tuple)):
82
91
  return [_sanitize_for_json(item) for item in obj]
83
92
  if isinstance(obj, set):
@@ -85,7 +94,7 @@ def _sanitize_for_json(obj: Any) -> Any:
85
94
 
86
95
  # For unknown types, try to convert to string
87
96
  try:
88
- return str(obj)
97
+ return models._strip_unstorable(str(obj))
89
98
  except Exception:
90
99
  return None
91
100
 
@@ -414,8 +423,11 @@ class OptimizationTracker:
414
423
  Called when DspyAdapter.make_reflective_dataset() completes.
415
424
  """
416
425
  if self._current_iteration and self._current_iteration.iteration == iteration:
417
- # Deep copy to avoid mutation
418
- self._current_iteration.reflective_dataset = json.loads(json.dumps(dataset, default=str))
426
+ # Deep copy to avoid mutation, via the serializer the trace path
427
+ # uses: it detaches the structure as a json round-trip does, keeps the
428
+ # jsonb-un-storable code points out of what we hold (this is raw
429
+ # captured LM text), and needs no encode/decode pass to do it.
430
+ self._current_iteration.reflective_dataset = models._safe_serialize(dataset)
419
431
  self._current_iteration.components_updated = components
420
432
 
421
433
  # Count examples
@@ -261,6 +261,22 @@ Dict handling: **value-aware allocation** keeps every key (small values whole, t
261
261
 
262
262
  This structural form replaced an older flat `{_truncated, _size, _preview}` envelope that sliced the serialized string mid-object; serve's JSON inspector renders these envelopes as a legible warn band over the surviving tree (`serve/internal/web/views/format.go` + `traces.templ`).
263
263
 
264
+ ## Storability: what the SDK guarantees about the payloads it emits
265
+
266
+ U+0000 and lone UTF-16 surrogates are valid JSON, and valid in a Python `str`, but the server can store neither: a `jsonb` column rejects both, and a NUL is rejected by a plain `text` column too (it is valid UTF-8, but PostgreSQL `text` cannot hold it). Captured LM text can legitimately carry either, and so can the text of an exception quoting that data. The SDK is the producer, so it strips them rather than shipping a payload the server has to reject: `_strip_unstorable` in `python-sdk/cmpnd/models.py` is the single rule, `_safe_serialize` is the chokepoint that applies it to values, dict keys, object attribute names, class reprs and every stringified fallback, and `_strip_unstorable_opt` covers the top-level optional strings that never pass through `_safe_serialize`.
267
+
268
+ "Chokepoint" is a property of the callers, so it holds only as long as **every payload field bound for a `jsonb` or `text` column goes through it**. The ones that do, beyond span `inputs`/`outputs`/`attributes`/`events`:
269
+
270
+ - `error_message` on spans, traces, eval runs and eval examples — a plain string on the wire bound for a `text` column, so it is scrubbed at each `to_api_dict`/`to_finalize_dict` rather than by `_safe_serialize`. It is the field most likely to carry one, since it is `str(exception)` and an exception message routinely quotes the captured data the code point came from. `None` is preserved, so "no error" stays distinct from an empty message;
271
+
272
+ - trace `tags`, `metadata`, and `component_signatures` (the last is forwarded verbatim by the server, whose program registration is best-effort — a rejection there is only logged, so the SDK is the only place it can be prevented);
273
+ - the optimizer tracker's own serializer, `_sanitize_for_json` in `python-sdk/cmpnd/optimizers/tracker.py`, which feeds reflective datasets, parent/proposed instructions, candidate instructions and val scores, run config, and the compiled program state;
274
+ - the reflective-dataset capture boundary in that tracker and in `optimizers/gepa_callback.py`, which deep-copy through `_safe_serialize` so what the tracker holds is already storable;
275
+ - dataset example `inputs`/`expected_outputs` (`dataset_sync.build_dataset_payload`) — the per-example `example_hash` stays derived from the example **as given**, since it keys dataset dedup and the per-run split-membership join;
276
+ - the `deploy(metadata=...)` blob (`deployment._prepare_deploy_metadata`, which validates and scrubs in one step).
277
+
278
+ A new hand-rolled serializer on any of those paths reopens the hole. `tests/test_models.py` pins the invariant end to end — asserting on the decoded wire body, not on the raw escape text, so a nested JSON document (`repl_history`) isn't mistaken for a violation.
279
+
264
280
  ---
265
281
 
266
282
  ## See also
@@ -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.1"
8
+ version = "0.8.2"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -151,21 +151,37 @@ class TestDeploy:
151
151
  body = client.post.call_args_list[0].kwargs["json"]
152
152
  assert "metadata" not in body
153
153
 
154
+ def test_metadata_scrubbed_of_unstorable_code_points(self, mock_httpx):
155
+ # The blob lands in a jsonb column, which cannot hold U+0000 or a lone
156
+ # surrogate — so a caller's checkpoint label carrying one must not turn
157
+ # the whole deploy into a server rejection. Built with chr(...) so the
158
+ # code points are real rather than escape text.
159
+ client = self._setup_mocks(mock_httpx)
160
+ with mock.patch.object(deployment, "time") as mock_time:
161
+ mock_time.time.side_effect = [0, 0, 2]
162
+ mock_time.sleep = mock.MagicMock()
163
+ deployment.deploy(
164
+ dspy.Predict("question -> answer"),
165
+ metadata={"ckpt" + chr(0): "step" + chr(0xD800), "step": 7},
166
+ )
167
+ body = client.post.call_args_list[0].kwargs["json"]
168
+ assert body["metadata"] == {"ckpt": "step", "step": 7}
169
+
154
170
  def test_metadata_rejects_non_dict(self):
155
171
  with pytest.raises(TypeError):
156
- deployment._validate_deploy_metadata([1, 2, 3])
172
+ deployment._prepare_deploy_metadata([1, 2, 3])
157
173
 
158
174
  def test_metadata_rejects_non_string_keys(self):
159
175
  with pytest.raises(TypeError):
160
- deployment._validate_deploy_metadata({1: "a"})
176
+ deployment._prepare_deploy_metadata({1: "a"})
161
177
 
162
178
  def test_metadata_rejects_non_serializable(self):
163
179
  with pytest.raises(TypeError):
164
- deployment._validate_deploy_metadata({"f": object()})
180
+ deployment._prepare_deploy_metadata({"f": object()})
165
181
 
166
182
  def test_metadata_rejects_oversized(self):
167
183
  with pytest.raises(ValueError):
168
- deployment._validate_deploy_metadata({"blob": "x" * (64 * 1024)})
184
+ deployment._prepare_deploy_metadata({"blob": "x" * (64 * 1024)})
169
185
 
170
186
  def test_polls_until_ready(self, mock_httpx):
171
187
  client = self._setup_mocks(