cmpnd 0.4.3__tar.gz → 0.5.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 (215) hide show
  1. {cmpnd-0.4.3 → cmpnd-0.5.0}/PKG-INFO +1 -1
  2. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/callback.py +17 -3
  3. cmpnd-0.5.0/cmpnd/cli/commands/traces.py +159 -0
  4. cmpnd-0.5.0/cmpnd/cli/trace_render.py +352 -0
  5. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/configuration.py +8 -0
  6. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/exporter.py +11 -0
  7. {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/sdk-instrumentation.md +12 -1
  8. {cmpnd-0.4.3 → cmpnd-0.5.0}/pyproject.toml +1 -1
  9. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_callback.py +77 -11
  10. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_traces.py +33 -0
  11. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_configuration.py +8 -0
  12. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_exporter.py +38 -0
  13. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_repro_reactv2_bugs.py +5 -0
  14. cmpnd-0.5.0/tests/test_trace_render.py +348 -0
  15. {cmpnd-0.4.3 → cmpnd-0.5.0}/uv.lock +1 -1
  16. cmpnd-0.4.3/cmpnd/cli/commands/traces.py +0 -63
  17. {cmpnd-0.4.3 → cmpnd-0.5.0}/.gitignore +0 -0
  18. {cmpnd-0.4.3 → cmpnd-0.5.0}/CLAUDE.md +0 -0
  19. {cmpnd-0.4.3 → cmpnd-0.5.0}/README.md +0 -0
  20. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/__init__.py +0 -0
  21. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/_gepa_patch.py +0 -0
  22. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/_program_patch.py +0 -0
  23. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/_rlm_patch.py +0 -0
  24. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/__init__.py +0 -0
  25. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/api_client.py +0 -0
  26. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/__init__.py +0 -0
  27. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/admin.py +0 -0
  28. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/auth.py +0 -0
  29. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/datasets.py +0 -0
  30. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/deployments.py +0 -0
  31. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/evals.py +0 -0
  32. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/login.py +0 -0
  33. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/optimizations.py +0 -0
  34. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/org.py +0 -0
  35. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/programs.py +0 -0
  36. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/top.py +0 -0
  37. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/credentials.py +0 -0
  38. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/shell.py +0 -0
  39. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/verbs.py +0 -0
  40. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/context.py +0 -0
  41. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/dataset_sync.py +0 -0
  42. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/datasets.py +0 -0
  43. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/decorators.py +0 -0
  44. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/deployment.py +0 -0
  45. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/eval_handler.py +0 -0
  46. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/execution.py +0 -0
  47. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/helpers.py +0 -0
  48. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/identity.py +0 -0
  49. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/__init__.py +0 -0
  50. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/decode.py +0 -0
  51. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/encode.py +0 -0
  52. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/hash.py +0 -0
  53. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/program.py +0 -0
  54. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/reflection.py +0 -0
  55. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/types.py +0 -0
  56. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/models.py +0 -0
  57. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimization.py +0 -0
  58. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/__init__.py +0 -0
  59. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/gepa.py +0 -0
  60. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/gepa_callback.py +0 -0
  61. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/resume.py +0 -0
  62. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/tracker.py +0 -0
  63. {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/packaging.py +0 -0
  64. {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/2026-04-02-code-review.md +0 -0
  65. {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  66. {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  67. {cmpnd-0.4.3 → cmpnd-0.5.0}/examples/local_ollama.py +0 -0
  68. {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/capture_review_run.py +0 -0
  69. {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/fixtures/review_run.json.gz +0 -0
  70. {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/gepa_resume_bug.py +0 -0
  71. {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/seed_review_run.py +0 -0
  72. {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/__init__.pyi +0 -0
  73. {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/primitives/__init__.pyi +0 -0
  74. {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/primitives/prediction.pyi +0 -0
  75. {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/utils/__init__.pyi +0 -0
  76. {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/utils/callback.pyi +0 -0
  77. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/__init__.py +0 -0
  78. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/_exporter_helpers.py +0 -0
  79. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/_identity_helpers.py +0 -0
  80. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/__init__.py +0 -0
  81. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/committee_task.py +0 -0
  82. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/conftest.py +0 -0
  83. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  84. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  85. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/fake_lm.py +0 -0
  86. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/helpers.py +0 -0
  87. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_capture_prompts.py +0 -0
  88. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_composite_module.py +0 -0
  89. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
  90. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_deploy_execute.py +0 -0
  91. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_deploy_programs.py +0 -0
  92. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_edge_cases.py +0 -0
  93. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_example_hash.py +0 -0
  94. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_gepa_optimization.py +0 -0
  95. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_lm_accuracy.py +0 -0
  96. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_module_tracing.py +0 -0
  97. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimization_lineage.py +0 -0
  98. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
  99. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_failures.py +0 -0
  100. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_loop.py +0 -0
  101. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_review_committee.py +0 -0
  102. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  103. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_parallel_export.py +0 -0
  104. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_project_attribution.py +0 -0
  105. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_react_tool_spans.py +0 -0
  106. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_save_load.py +0 -0
  107. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_trace_structure.py +0 -0
  108. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_user_attribution.py +0 -0
  109. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/01_predict_qa.json +0 -0
  110. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  111. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  112. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  113. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  114. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  115. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
  116. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/08_opaque_module.json +0 -0
  117. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  118. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/10_react.json +0 -0
  119. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/11_refine.json +0 -0
  120. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/12_retrieve.json +0 -0
  121. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/13_knn.json +0 -0
  122. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/14_parallel.json +0 -0
  123. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/README.md +0 -0
  124. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/__init__.py +0 -0
  125. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/conftest.py +0 -0
  126. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  127. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_logs_renders_traces.py +0 -0
  128. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_optimizing_progress.py +0 -0
  129. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_unhappy_path.py +0 -0
  130. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/__init__.py +0 -0
  131. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/conftest.py +0 -0
  132. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/seed_parity.py +0 -0
  133. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_auth.py +0 -0
  134. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_datasets.py +0 -0
  135. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_deployments.py +0 -0
  136. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_evals.py +0 -0
  137. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_health.py +0 -0
  138. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_modules.py +0 -0
  139. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_optimizations.py +0 -0
  140. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_programs.py +0 -0
  141. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_signatures.py +0 -0
  142. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_trace_streaming.py +0 -0
  143. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_traces.py +0 -0
  144. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/__init__.py +0 -0
  145. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/_reliability.py +0 -0
  146. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_deploy_programs.py +0 -0
  147. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_multi_client_stress.py +0 -0
  148. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_optimize_client_committee.py +0 -0
  149. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_optimize_loop.py +0 -0
  150. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_optimize_stress.py +0 -0
  151. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_real_provider_twins.py +0 -0
  152. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_reliability.py +0 -0
  153. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/__init__.py +0 -0
  154. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/conftest.py +0 -0
  155. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_admin.py +0 -0
  156. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_api_client.py +0 -0
  157. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_auth.py +0 -0
  158. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_credentials.py +0 -0
  159. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_datasets.py +0 -0
  160. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_deployments.py +0 -0
  161. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_evals.py +0 -0
  162. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_login.py +0 -0
  163. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_main.py +0 -0
  164. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_optimizations.py +0 -0
  165. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_org.py +0 -0
  166. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_parity.py +0 -0
  167. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_programs.py +0 -0
  168. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_shell.py +0 -0
  169. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_top.py +0 -0
  170. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/__init__.py +0 -0
  171. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/conftest.py +0 -0
  172. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/test_edge_cases.py +0 -0
  173. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/test_hash_consistency.py +0 -0
  174. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/test_type_conversion.py +0 -0
  175. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_context.py +0 -0
  176. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_context_propagation.py +0 -0
  177. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_dataset_polling.py +0 -0
  178. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_dataset_sync.py +0 -0
  179. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_datasets.py +0 -0
  180. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_decorators.py +0 -0
  181. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_deploy.py +0 -0
  182. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_eval.py +0 -0
  183. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_eval_start.py +0 -0
  184. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_exception_paths.py +0 -0
  185. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_execute.py +0 -0
  186. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_exporter_real.py +0 -0
  187. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_fake_lm.py +0 -0
  188. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_gepa_callback.py +0 -0
  189. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_gepa_integration.py +0 -0
  190. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_gepa_resume.py +0 -0
  191. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_identity.py +0 -0
  192. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_integration.py +0 -0
  193. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_ir_encoder_properties.py +0 -0
  194. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_ir_golden_vectors.py +0 -0
  195. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_ir_roundtrip.py +0 -0
  196. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_models.py +0 -0
  197. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimization_start.py +0 -0
  198. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimize.py +0 -0
  199. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimize_lift.py +0 -0
  200. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimize_terminal_failure.py +0 -0
  201. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_packaging.py +0 -0
  202. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_packaging_callable_source.py +0 -0
  203. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_packaging_program_source.py +0 -0
  204. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_program_lineage_persist.py +0 -0
  205. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_project_payload.py +0 -0
  206. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_reflector_properties.py +0 -0
  207. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_reflector_smoke.py +0 -0
  208. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_rlm_patch.py +0 -0
  209. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_schemas.py +0 -0
  210. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_seed_review_run.py +0 -0
  211. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_stats_export.py +0 -0
  212. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_tracked_gepa.py +0 -0
  213. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_tracker_enrich.py +0 -0
  214. {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_truncation.py +0 -0
  215. {cmpnd-0.4.3 → cmpnd-0.5.0}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmpnd
3
- Version: 0.4.3
3
+ Version: 0.5.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>
@@ -64,12 +64,17 @@ class _CallbackBase:
64
64
  capture_prompts: bool | None = None,
65
65
  max_input_field_size: int | None = None,
66
66
  max_output_field_size: int | None = None,
67
+ stream_trace_starts: bool | None = None,
67
68
  ) -> None:
68
69
  # Resolve feature flags: explicit param > config > default
69
70
  config = configuration.get_config()
70
71
  self._capture_inputs = (
71
72
  capture_inputs if capture_inputs is not None else (config.capture_inputs if config else True)
72
73
  )
74
+ # Emit an in_progress start for every root trace, not just RLM (CMP-257).
75
+ self._stream_trace_starts = (
76
+ stream_trace_starts if stream_trace_starts is not None else (config.stream_trace_starts if config else True)
77
+ )
73
78
  self._capture_outputs = (
74
79
  capture_outputs if capture_outputs is not None else (config.capture_outputs if config else True)
75
80
  )
@@ -186,13 +191,22 @@ class _ModuleMixin(_CallbackBase):
186
191
  # start (CMP-537) — before any span lands — instead of inferring it
187
192
  # from span shape later.
188
193
  trace.root_span_type = span_type.value
189
- # RLM traces use streaming export; everything else batches
194
+ # RLM traces use streaming export (per-span); everything else
195
+ # batches its spans and sends the whole trace at finalize.
190
196
  if span_type == models.SpanType.RLM:
191
197
  trace.streaming = True
192
198
  self._streaming_traces[str(trace.trace_id)] = trace.streaming
193
199
 
194
- # Only stream trace_start for streaming traces
195
- if trace.streaming:
200
+ # Emit an in_progress start row for EVERY root trace (CMP-257), not
201
+ # just streaming/RLM ones, so a non-RLM program is visible the moment
202
+ # it starts (`traces search --status in_progress`, a lookupable id)
203
+ # instead of only appearing at finalize. `trace.streaming` still
204
+ # governs per-span vs batch export below — only the start emission is
205
+ # generalized. The batch finalize upserts this same row (serve
206
+ # traceUpsertSQL, ON CONFLICT org_id,trace_id,start_time), and the
207
+ # exporter coalesces a start with its batch trace in one flush, so a
208
+ # fast trace pays a single insert, not two.
209
+ if self._stream_trace_starts:
196
210
  exp = exporter.get_exporter()
197
211
  if exp:
198
212
  exp.export_trace_start(trace)
@@ -0,0 +1,159 @@
1
+ """Traces commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import time
8
+ from argparse import ArgumentParser, _SubParsersAction
9
+ from typing import Any
10
+
11
+ from .. import api_client, trace_render
12
+
13
+
14
+ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
15
+ parser = subparsers.add_parser("traces", help="Browse traces")
16
+ sub = parser.add_subparsers(dest="action")
17
+
18
+ search = sub.add_parser("search", help="Search traces")
19
+ search.add_argument("--status", help="Filter by status (ok, error, incomplete, in_progress)")
20
+ search.add_argument("--program", dest="program_name", help="Filter by program name")
21
+ search.add_argument("--hash", dest="program_signature_hash", help="Filter by program signature hash")
22
+ search.add_argument("--deployment", dest="deployment_id", help="Filter by deployment id (UUID)")
23
+ search.add_argument(
24
+ "--tag",
25
+ dest="tags",
26
+ action="append",
27
+ help="Filter by project tag (the sidebar 'Tags' filter); repeat for any-of",
28
+ )
29
+ search.add_argument("--from", dest="start_time_from", help="Start time from (ISO 8601)")
30
+ search.add_argument("--to", dest="start_time_to", help="Start time to (ISO 8601)")
31
+ search.add_argument("--page", type=int, default=1)
32
+ search.add_argument("--per-page", type=int, default=50)
33
+ search.set_defaults(func=handle_search)
34
+
35
+ show = sub.add_parser("show", help="Show a trace (raw JSON by default; --tree / --timeline for human views)")
36
+ show.add_argument("id", help="Trace ID (UUID)")
37
+ view = show.add_mutually_exclusive_group()
38
+ view.add_argument("--tree", action="store_true", help="Colorized span tree (Tree view)")
39
+ view.add_argument("--timeline", action="store_true", help="Gantt-style timeline (mirrors the web Timeline view)")
40
+ view.add_argument(
41
+ "--notebook", action="store_true", help="Emit the RLM trace's Jupyter .ipynb to stdout (errors if not RLM)"
42
+ )
43
+ show.set_defaults(func=handle_show)
44
+
45
+ follow = sub.add_parser("follow", help="Tail a trace's spans live as they land, until it finishes")
46
+ follow.add_argument("id", help="Trace ID (UUID)")
47
+ # JSON-first, like the rest of `traces`: the default is NDJSON (one compact
48
+ # span per line, flushed as it lands); --tree opts into the human view.
49
+ follow.add_argument("--tree", action="store_true", help="Human view: one span line as it lands, then the full tree")
50
+ follow.add_argument("--interval", type=float, default=1.0, help="Poll interval in seconds (default 1.0)")
51
+ follow.add_argument(
52
+ "--timeout",
53
+ type=float,
54
+ default=0.0,
55
+ help="Stop after N seconds if the trace hasn't finished. Default 0 = follow until it finishes (or Ctrl-C)",
56
+ )
57
+ follow.set_defaults(func=handle_follow)
58
+
59
+
60
+ def handle_search(client: api_client.ApiClient, args: Any) -> Any:
61
+ body: dict[str, Any] = {"page": args.page, "per_page": args.per_page}
62
+ for attr, key in [
63
+ ("status", "status"),
64
+ ("program_name", "program_name"),
65
+ ("program_signature_hash", "program_signature_hash"),
66
+ ("deployment_id", "deployment_id"),
67
+ ("tags", "tags"),
68
+ ("start_time_from", "start_time_from"),
69
+ ("start_time_to", "start_time_to"),
70
+ ]:
71
+ val = getattr(args, attr, None)
72
+ if val is not None:
73
+ body[key] = val
74
+ resp = client.post("/api/v1/traces/search", json=body)
75
+
76
+ # Servers that predate the deployment_id filter silently drop the
77
+ # unknown key and return whole-org traces. If the user asked for a
78
+ # deployment filter and we got back traces with no deployment_id
79
+ # field, warn — otherwise the count looks legitimate but isn't.
80
+ if getattr(args, "deployment_id", None) and resp.get("traces"):
81
+ first = resp["traces"][0]
82
+ if first.get("deployment_id") in (None, ""):
83
+ print(
84
+ "warning: server does not support the deployment_id filter; results are unfiltered",
85
+ file=sys.stderr,
86
+ )
87
+
88
+ return resp
89
+
90
+
91
+ def handle_show(client: api_client.ApiClient, args: Any) -> Any:
92
+ if getattr(args, "notebook", False):
93
+ # Reconstructed .ipynb straight to stdout (the framework pretty-prints
94
+ # the returned dict as JSON — a valid notebook file). A non-RLM or
95
+ # missing trace 404s, which ApiClient raises → non-zero exit; a valid
96
+ # RLM trace returns the notebook → exit 0.
97
+ return client.get(f"/api/v1/traces/{args.id}/notebook.ipynb")
98
+ resp = client.get(f"/api/v1/traces/{args.id}")
99
+ trace = resp.get("trace") or {}
100
+ spans = resp.get("spans") or []
101
+ if getattr(args, "tree", False):
102
+ # Color for readability, but only on a TTY (a pipe/file/CI gets plain).
103
+ print(trace_render.render_tree(trace, spans, color=trace_render.use_color(sys.stdout)))
104
+ return None
105
+ if getattr(args, "timeline", False):
106
+ print(trace_render.render_timeline(trace, spans, color=trace_render.use_color(sys.stdout)))
107
+ return None
108
+ # Default: raw trace+spans JSON — the CLI framework pretty-prints the dict,
109
+ # matching `traces search` (JSON-first). Human views are opt-in above.
110
+ return resp
111
+
112
+
113
+ def handle_follow(client: api_client.ApiClient, args: Any) -> Any:
114
+ """Tail a trace's spans as they land, exiting when it reaches a terminal
115
+ status. Polls GET /api/v1/traces/{id}, emits only spans not seen before
116
+ (ordered by start time), and owns its output — NDJSON by default (one
117
+ compact span per line, flushed), or a human span-line stream + closing tree
118
+ under --tree. `tail -f` semantics: no timeout unless --timeout asks for one,
119
+ and Ctrl-C is a clean stop."""
120
+ trace_id = args.id
121
+ interval = max(0.0, getattr(args, "interval", 1.0))
122
+ timeout = getattr(args, "timeout", 0.0) or 0.0
123
+ as_human = getattr(args, "tree", False)
124
+ color = trace_render.use_color(sys.stdout) if as_human else False
125
+
126
+ seen: set[str] = set()
127
+ started = time.monotonic()
128
+ trace: dict[str, Any] = {}
129
+ spans: list[dict[str, Any]] = []
130
+ settled = False
131
+ try:
132
+ while True:
133
+ resp = client.get(f"/api/v1/traces/{trace_id}")
134
+ trace = resp.get("trace") or {}
135
+ spans = resp.get("spans") or []
136
+ # Emit spans not seen yet, in arrival (start-time) order.
137
+ for s in sorted(spans, key=lambda x: (str(x.get("start_time") or ""), str(x.get("span_id") or ""))):
138
+ sid = str(s.get("span_id") or "")
139
+ if not sid or sid in seen:
140
+ continue
141
+ seen.add(sid)
142
+ if as_human:
143
+ print(trace_render.span_line(s, color=color), flush=True)
144
+ else:
145
+ print(json.dumps(s, separators=(",", ":")), flush=True)
146
+ status = str(trace.get("status") or "")
147
+ if status and status != "in_progress":
148
+ settled = True # terminal: ok / error / incomplete
149
+ break
150
+ if timeout > 0 and (time.monotonic() - started) >= timeout:
151
+ raise api_client.CliError(f"traces follow: {trace_id} still in progress after {timeout:g}s")
152
+ time.sleep(interval)
153
+ except KeyboardInterrupt:
154
+ # tail -f semantics: Ctrl-C is a normal stop, not an error.
155
+ return None
156
+ # Human view closes with the full tree once the trace settles.
157
+ if as_human and settled:
158
+ print(trace_render.render_tree(trace, spans, color=color))
159
+ return None
@@ -0,0 +1,352 @@
1
+ """Render a trace's span forest as a tree or an ASCII timeline.
2
+
3
+ Consumed by ``cmpnd traces show <id>`` (CMP-260). The raw
4
+ ``GET /api/v1/traces/{id}`` response — ``{"trace": {...}, "spans": [...]}``
5
+ with flat, raw-column-named spans — is turned into something readable when the
6
+ caller opts into ``--tree`` / ``--timeline`` (``show`` is JSON by default, like
7
+ ``traces search``).
8
+
9
+ The span-type labels and duration strings mirror serve's trace views verbatim
10
+ — ``spanLabel`` and ``DurationClock`` in
11
+ ``serve/internal/web/views/traces.templ`` /
12
+ ``serve/internal/platform/displayfmt`` — so the CLI and the web UI print the
13
+ same words for the same span. Structure is carried by indentation +
14
+ box-drawing glyphs (UTF-8 output is assumed).
15
+
16
+ Color: the **tree** view tints for readability, gated on a TTY without
17
+ ``NO_COLOR`` (the CLI convention shared with ``cli/commands/top.py``) so a
18
+ pipe/file/CI gets clean plain text. The timeline is always plain — its bars
19
+ already carry the signal.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import datetime
25
+ import os
26
+ import re
27
+ import sys
28
+ from typing import Any, TextIO
29
+
30
+ # --- color (tree only; TTY + no NO_COLOR) --------------------------------
31
+
32
+ _ANSI = {
33
+ "reset": "\x1b[0m",
34
+ "bold": "\x1b[1m",
35
+ "dim": "\x1b[2m",
36
+ "red": "\x1b[31m",
37
+ "green": "\x1b[32m",
38
+ "yellow": "\x1b[33m",
39
+ "grey": "\x1b[90m",
40
+ }
41
+
42
+
43
+ def use_color(stream: TextIO | None = None) -> bool:
44
+ """Color only when writing to a TTY and NO_COLOR is unset
45
+ (https://no-color.org) — same gate as cli/commands/top.py."""
46
+ stream = stream or sys.stdout
47
+ isatty = getattr(stream, "isatty", None)
48
+ return bool(isatty and isatty()) and os.environ.get("NO_COLOR") is None
49
+
50
+
51
+ def _paint(text: str, color: str, enabled: bool) -> str:
52
+ if not enabled or not color:
53
+ return text
54
+ return f"{_ANSI[color]}{text}{_ANSI['reset']}"
55
+
56
+
57
+ def _status_color(status: Any) -> str:
58
+ s = str(status or "").lower()
59
+ if s == "error":
60
+ return "red"
61
+ if s in ("incomplete", "in_progress"):
62
+ return "yellow"
63
+ return "green"
64
+
65
+
66
+ # --- serve string parity -------------------------------------------------
67
+
68
+ # Span-type → display label, verbatim from serve's spanLabel
69
+ # (serve/internal/web/views/traces.templ). An unmapped type (e.g. "rlm")
70
+ # falls through to itself, matching serve.
71
+ _SPAN_LABELS = {
72
+ "predict": "Predict",
73
+ "lm_call": "LM Call",
74
+ "adapter_format": "Format",
75
+ "adapter_parse": "Parse",
76
+ "module": "Module",
77
+ "retrieve": "Retrieve",
78
+ "tool": "Tool",
79
+ "chain_of_thought": "Chain of Thought",
80
+ "react": "ReAct",
81
+ "program_of_thought": "Program of Thought",
82
+ "subcall": "Subcall",
83
+ }
84
+
85
+
86
+ def span_label(span_type: Any) -> str:
87
+ st = str(span_type or "")
88
+ return _SPAN_LABELS.get(st, st)
89
+
90
+
91
+ def duration_clock(ns: Any) -> str:
92
+ """Nanoseconds → serve's DurationClock string: ``3.0s`` / ``0.0s`` /
93
+ ``1:05.8s`` / ``1:02:03.4s``. Ported byte-for-byte from
94
+ serve/internal/platform/displayfmt/displayfmt.go so the CLI matches the
95
+ web timeline exactly (including ``0.0s`` for a zero-duration span)."""
96
+ try:
97
+ n = int(ns)
98
+ except (TypeError, ValueError):
99
+ n = 0
100
+ if n < 0:
101
+ n = 0
102
+ ns_per_tenth = 100_000_000 # 100ms
103
+ total_tenths = (n + ns_per_tenth // 2) // ns_per_tenth
104
+ tenth = total_tenths % 10
105
+ total_sec = total_tenths // 10
106
+ sec = total_sec % 60
107
+ total_min = total_sec // 60
108
+ minute = total_min % 60
109
+ hr = total_min // 60
110
+ if hr > 0:
111
+ return f"{hr}:{minute:02d}:{sec:02d}.{tenth}s"
112
+ if minute > 0:
113
+ return f"{minute}:{sec:02d}.{tenth}s"
114
+ return f"{sec}.{tenth}s"
115
+
116
+
117
+ # --- span forest ---------------------------------------------------------
118
+
119
+
120
+ class Node:
121
+ """A span plus its ordered children in the reconstructed forest."""
122
+
123
+ __slots__ = ("span", "children")
124
+
125
+ def __init__(self, span: dict[str, Any]) -> None:
126
+ self.span = span
127
+ self.children: list[Node] = []
128
+
129
+
130
+ def build_forest(spans: list[dict[str, Any]]) -> list[Node]:
131
+ """Group flat spans into a parent→child forest, children ordered by
132
+ start_time (span_id as a stable tiebreak).
133
+
134
+ A span whose parent_span_id is null/missing, or points at a span not in
135
+ this set (an orphan — e.g. a mid-run RLM trace whose parent lands last),
136
+ is treated as a root, so no span is ever dropped. A parent cycle can't
137
+ wedge the walk: each span is attached at most once.
138
+ """
139
+ nodes: dict[str, Node] = {}
140
+ for s in spans:
141
+ sid = str(s.get("span_id") or "")
142
+ if sid:
143
+ nodes[sid] = Node(s)
144
+
145
+ def _would_cycle(node: Node, parent: Node) -> bool:
146
+ # Attaching node under parent would close a cycle if node is already an
147
+ # ancestor of parent (walking parent's parent_span_id chain), or that
148
+ # chain itself already loops. Keep node a root in either case so the
149
+ # children graph stays acyclic — otherwise a mutual/reachable cycle
150
+ # yields an empty tree or unbounded _sort/walk recursion (Greptile P1).
151
+ seen: set[str] = set()
152
+ cur: Node | None = parent
153
+ while cur is not None:
154
+ if cur is node:
155
+ return True
156
+ csid = str(cur.span.get("span_id") or "")
157
+ if csid in seen:
158
+ return True
159
+ seen.add(csid)
160
+ pid = cur.span.get("parent_span_id")
161
+ cur = nodes.get(str(pid)) if pid else None
162
+ return False
163
+
164
+ roots: list[Node] = []
165
+ for s in spans:
166
+ sid = str(s.get("span_id") or "")
167
+ node = nodes.get(sid)
168
+ if node is None:
169
+ continue
170
+ parent_id = s.get("parent_span_id")
171
+ parent = nodes.get(str(parent_id)) if parent_id else None
172
+ if parent is not None and parent is not node and not _would_cycle(node, parent):
173
+ parent.children.append(node)
174
+ else:
175
+ roots.append(node)
176
+
177
+ def _sort_key(n: Node) -> tuple[str, str]:
178
+ return (str(n.span.get("start_time") or ""), str(n.span.get("span_id") or ""))
179
+
180
+ def _sort(node_list: list[Node]) -> None:
181
+ node_list.sort(key=_sort_key)
182
+ for n in node_list:
183
+ _sort(n.children)
184
+
185
+ _sort(roots)
186
+ return roots
187
+
188
+
189
+ # --- helpers -------------------------------------------------------------
190
+
191
+ _TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$")
192
+
193
+
194
+ def _parse_ts(value: Any) -> datetime.datetime | None:
195
+ """Parse an RFC3339 timestamp tolerantly. Server emits nanosecond (9-digit)
196
+ fractions that datetime.fromisoformat rejects, so truncate the fraction to
197
+ microseconds and normalize a trailing Z. Returns None on anything unparsable
198
+ — the caller degrades (offset 0) rather than crashing."""
199
+ if not isinstance(value, str):
200
+ return None
201
+ m = _TS_RE.match(value.strip())
202
+ if not m:
203
+ return None
204
+ base, frac, tz = m.group(1), m.group(2), m.group(3)
205
+ micros = ""
206
+ if frac:
207
+ micros = "." + frac[1:7].ljust(6, "0")
208
+ if tz in (None, "Z"):
209
+ tz = "+00:00"
210
+ elif len(tz) == 5: # +0000 → +00:00
211
+ tz = tz[:3] + ":" + tz[3:]
212
+ try:
213
+ return datetime.datetime.fromisoformat(f"{base}{micros}{tz}")
214
+ except ValueError:
215
+ return None
216
+
217
+
218
+ def _int(v: Any) -> int:
219
+ try:
220
+ return int(v)
221
+ except (TypeError, ValueError):
222
+ return 0
223
+
224
+
225
+ def _header(trace: dict[str, Any], span_total: int, color: bool) -> str:
226
+ program = trace.get("program_name") or trace.get("signature_name") or "trace"
227
+ tid = str(trace.get("trace_id") or "")
228
+ status = trace.get("status")
229
+ meta = [
230
+ _paint(str(status or "—"), _status_color(status), color),
231
+ duration_clock(trace.get("duration_ns")),
232
+ f"{span_total} spans",
233
+ ]
234
+ if _int(trace.get("total_tokens")) > 0:
235
+ meta.append(f"{_int(trace.get('total_tokens'))} tokens")
236
+ line1 = (_paint(str(program), "bold", color) + " " + _paint(tid, "grey", color)).rstrip()
237
+ return line1 + "\n" + " · ".join(meta)
238
+
239
+
240
+ def span_line(span: dict[str, Any], *, color: bool) -> str:
241
+ """One span rendered as a single line: '<type label> <name> · <duration> ·
242
+ <tokens> · <model>', mirroring serve's span-badge + span-name, then the same
243
+ duration string. Used both as a tree node body and as `traces follow`'s
244
+ per-span line (CMP-258)."""
245
+ # Two visually distinct segments: the span-type label carries the status
246
+ # hue (green/red/yellow), the span name is bold default-foreground. Bold on
247
+ # the terminal's own fg reads on any background; the two never share a color.
248
+ label = span_label(span.get("span_type"))
249
+ name = str(span.get("name") or "")
250
+ label_c = _paint(label, _status_color(span.get("status")), color)
251
+ if label and name and name != label:
252
+ head = label_c + " " + _paint(name, "bold", color)
253
+ elif label:
254
+ head = label_c
255
+ elif name:
256
+ head = _paint(name, "bold", color)
257
+ else:
258
+ head = "span"
259
+
260
+ meta = [duration_clock(span.get("duration_ns"))]
261
+ if _int(span.get("total_tokens")) > 0:
262
+ meta.append(f"{_int(span.get('total_tokens'))} tokens")
263
+ model = span.get("model_name")
264
+ if model:
265
+ meta.append(str(model))
266
+ line = head + " " + _paint("· " + " · ".join(meta), "dim", color)
267
+ err = span.get("error_message")
268
+ if err:
269
+ line += " " + _paint(f"! {err}", "red", color)
270
+ return line
271
+
272
+
273
+ # --- tree ----------------------------------------------------------------
274
+
275
+
276
+ def render_tree(trace: dict[str, Any], spans: list[dict[str, Any]], *, color: bool) -> str:
277
+ """Header + an indented span tree; box-drawing glyphs carry the structure,
278
+ color (when enabled) tints for readability only."""
279
+ roots = build_forest(spans)
280
+ lines = [_header(trace, len(spans), color)]
281
+ if not spans:
282
+ lines.append("(no spans yet)")
283
+ return "\n".join(lines)
284
+
285
+ def walk(node: Node, prefix: str, is_last: bool, is_root: bool) -> None:
286
+ connector = "" if is_root else ("└─ " if is_last else "├─ ")
287
+ child_prefix = "" if is_root else prefix + (" " if is_last else "│ ")
288
+ lines.append(prefix + _paint(connector, "grey", color) + span_line(node.span, color=color))
289
+ for i, child in enumerate(node.children):
290
+ walk(child, child_prefix, i == len(node.children) - 1, False)
291
+
292
+ for i, root in enumerate(roots):
293
+ walk(root, "", i == len(roots) - 1, True)
294
+ return "\n".join(lines)
295
+
296
+
297
+ # --- timeline (always plain) ---------------------------------------------
298
+
299
+ _BAR_WIDTH = 40
300
+
301
+
302
+ def render_timeline(trace: dict[str, Any], spans: list[dict[str, Any]], *, color: bool) -> str:
303
+ """A gantt-style timeline mirroring serve's Timeline view: each span a row
304
+ with a right-aligned type label, a bar offset + scaled to the trace window,
305
+ and the DurationClock duration right-aligned in its own column (like the UI).
306
+ Uses the tree's color choices for the label (status hue) and duration
307
+ (dimmed); the bars stay the terminal's default foreground (neutral — reads
308
+ black on a light theme, white on a dark one — status-green on every bar
309
+ looked bad). Spans are flattened and ordered by start time so the time axis
310
+ reads top-to-bottom."""
311
+ lines = [_header(trace, len(spans), color)]
312
+ if not spans:
313
+ lines.append("(no spans yet)")
314
+ return "\n".join(lines)
315
+
316
+ parsed = [(_parse_ts(s.get("start_time")), s) for s in spans]
317
+ parsed_ok = [(t, s) for t, s in parsed if t is not None]
318
+ # Timestamped spans first (in time order), then any with a missing/malformed
319
+ # start_time appended at offset 0 — degrade, never drop a span (Greptile P1).
320
+ ordered = [s for _, s in sorted(parsed_ok, key=lambda p: p[0])]
321
+ ordered.extend(s for t, s in parsed if t is None)
322
+ t0 = min((t for t, _ in parsed_ok), default=None)
323
+
324
+ def _end_offset_s(span: dict[str, Any]) -> float:
325
+ start = _parse_ts(span.get("start_time"))
326
+ if start is None or t0 is None:
327
+ return _int(span.get("duration_ns")) / 1e9
328
+ return (start - t0).total_seconds() + _int(span.get("duration_ns")) / 1e9
329
+
330
+ window = max((_end_offset_s(s) for s in spans), default=0.0)
331
+ if window <= 0:
332
+ window = 1.0 # avoid divide-by-zero; every bar renders at column 0
333
+
334
+ label_w = min(18, max((len(span_label(s.get("span_type"))) for s in ordered), default=0))
335
+ # Right-align the duration column (UI parity): pad every value to the widest.
336
+ durs = {id(s): duration_clock(s.get("duration_ns")) for s in ordered}
337
+ dur_w = max((len(v) for v in durs.values()), default=0)
338
+
339
+ for s in ordered:
340
+ start = _parse_ts(s.get("start_time"))
341
+ dur_s = _int(s.get("duration_ns")) / 1e9
342
+ offset_s = (start - t0).total_seconds() if (start is not None and t0 is not None) else 0.0
343
+ start_col = min(int((offset_s / window) * _BAR_WIDTH), _BAR_WIDTH - 1)
344
+ bar_len = min(max(1, int((dur_s / window) * _BAR_WIDTH)), _BAR_WIDTH - start_col)
345
+ hue = _status_color(s.get("status"))
346
+ # rjust/ljust on the plain text FIRST, then paint — escape codes must
347
+ # not count toward column width. The bar is left unpainted (neutral fg).
348
+ label = _paint(span_label(s.get("span_type"))[:label_w].rjust(label_w), hue, color)
349
+ track = (" " * start_col + "█" * bar_len).ljust(_BAR_WIDTH)
350
+ dur = _paint(durs[id(s)].rjust(dur_w), "dim", color)
351
+ lines.append(f"{label} │ {track} │ {dur}")
352
+ return "\n".join(lines)
@@ -78,6 +78,14 @@ class Config:
78
78
  max_queue_size: int = 10000
79
79
  max_export_workers: int = 4
80
80
 
81
+ # Emit an in_progress trace_start for every root trace (CMP-257), not just
82
+ # RLM, so non-RLM programs (Predict/ReAct/CoT/optimizer students) are
83
+ # visible while running instead of appearing only at finalize. The exporter
84
+ # coalesces start + batch in one flush, so a fast trace still pays a single
85
+ # insert. Set False to opt out of the extra start (traces then reappear only
86
+ # at finalize, the pre-CMP-257 behavior).
87
+ stream_trace_starts: bool = True
88
+
81
89
  # Feature flags
82
90
  capture_inputs: bool = True
83
91
  capture_outputs: bool = True
@@ -495,6 +495,17 @@ class BatchExporter:
495
495
  new_spans[trace_id] = []
496
496
  new_spans[trace_id].append(span_dict)
497
497
 
498
+ # Coalesce start + batch within one flush (CMP-257): if a trace's full
499
+ # batch trace is in this same flush, sending it upserts the row to its
500
+ # final status directly (serve traceUpsertSQL, ON CONFLICT on
501
+ # org_id,trace_id,start_time), so the in_progress start POST is
502
+ # redundant — drop it to save the round-trip for a fast trace. A start
503
+ # whose finalize lands in a LATER flush still ships, which is the whole
504
+ # point: the trace is visible while it runs.
505
+ if trace_starts and traces_to_send:
506
+ batched_ids = {str(t.get("trace_id")) for t in traces_to_send}
507
+ trace_starts = [pair for pair in trace_starts if pair[0] not in batched_ids]
508
+
498
509
  # Add new spans to pending buffer
499
510
  with self._pending_spans_lock:
500
511
  for trace_id, spans in new_spans.items():
@@ -149,6 +149,17 @@ Override any of these by passing kwargs to `cmpnd.configure(...)`.
149
149
 
150
150
  A span can arrive at `_flush_batch` before its parent trace has been exported — this happens whenever the exporter ticks in the middle of a DSPy call. The exporter holds those loose spans in `self._pending_spans` (`exporter.py:65`) keyed by `trace_id`, and when the trace finally arrives it attaches the buffered spans before shipping. Spans for which the trace never arrives get flushed as a bare spans batch via `/api/v1/traces/:id/spans` — see `_send_spans` at `exporter.py:431`.
151
151
 
152
+ ### In-progress trace starts (CMP-257)
153
+
154
+ `on_module_start` emits a `trace_start` for **every** root trace (gated by `Config.stream_trace_starts`, default `True`), not just streaming (RLM) traces. This writes an `in_progress` placeholder row the moment a program starts, so a non-RLM program (Predict / ChainOfThought / ReAct / optimizer students) is visible while it runs — `cmpnd traces search --status in_progress` returns it and it has a lookupable id — instead of appearing only at finalize.
155
+
156
+ `trace.streaming` (set only for RLM) is a separate axis: it still governs whether individual spans stream (`export_span`) and whether finalize goes through `/finalize` (streaming) vs a bundled `export_trace` (batch). CMP-257 generalized only the start emission, not the whole streaming model.
157
+
158
+ Two things keep the extra start cheap and correct:
159
+
160
+ - **Upsert, not duplicate.** The batch finalize (`export_trace`) and the start write the same row: serve's `traceUpsertSQL` keys `ON CONFLICT (org_id, trace_id, start_time)`, and the SDK's `start_time` is immutable across a trace's life, so the batch overwrites the `in_progress` row with the final status. A trace that never finalizes (crash / cancel) correctly stays `in_progress`.
161
+ - **Coalescing.** When a trace's `trace_start` and its full batch `trace` land in the **same** flush, `_flush_batch` drops the redundant `/start` POST (the batch upserts the final row directly), so a fast trace pays one insert, not two. A start whose finalize lands in a later flush still ships — that visibility is the point. Set `stream_trace_starts=False` to opt out entirely (traces then reappear only at finalize, the pre-CMP-257 behavior).
162
+
152
163
  ---
153
164
 
154
165
  ## Dataset capture sidecar
@@ -184,7 +195,7 @@ The exporter POSTs to a handful of endpoints, all under `/api/v1/` on the cmpnd-
184
195
  - `POST /api/v1/traces` — single trace with bundled spans.
185
196
  - `POST /api/v1/traces/batch` — multiple traces with bundled spans, used whenever a flush contains more than one trace (`exporter.py:391`).
186
197
  - `POST /api/v1/traces/:id/spans` — loose spans whose trace was exported earlier.
187
- - `POST /api/v1/traces/:id/start` and `/finalize` — two-tier ingestion for long-running traces.
198
+ - `POST /api/v1/traces/:id/start` and `/finalize` — two-tier ingestion. `/start` writes an `in_progress` placeholder row; `/finalize` closes a streaming (RLM) trace.
188
199
  - `POST /api/v1/eval_runs`, `POST /api/v1/optimization_runs`, and related progress endpoints.
189
200
 
190
201
  The trace payload is built in `python-sdk/cmpnd/models.py` by `TraceBuilder.to_api_dict()` (`models.py:398`) and `SpanBuilder.to_api_dict()` (`models.py:300`). A span's dict looks roughly like this, with every field mirrored verbatim to the Postgres `spans` schema documented in [`serve/DB.md`](../../serve/DB.md) ("Analytics tables"):
@@ -5,7 +5,7 @@ name = "cmpnd"
5
5
  # it's new) — no git tags. Bump with `uv version --bump {minor,patch}` or by
6
6
  # hand. Runtime `cmpnd.__version__` reads installed metadata, so it tracks
7
7
  # this automatically. See docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md.
8
- version = "0.4.3"
8
+ version = "0.5.0"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"