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.
- {cmpnd-0.4.3 → cmpnd-0.5.0}/PKG-INFO +1 -1
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/callback.py +17 -3
- cmpnd-0.5.0/cmpnd/cli/commands/traces.py +159 -0
- cmpnd-0.5.0/cmpnd/cli/trace_render.py +352 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/configuration.py +8 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/exporter.py +11 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/sdk-instrumentation.md +12 -1
- {cmpnd-0.4.3 → cmpnd-0.5.0}/pyproject.toml +1 -1
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_callback.py +77 -11
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_traces.py +33 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_configuration.py +8 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_exporter.py +38 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_repro_reactv2_bugs.py +5 -0
- cmpnd-0.5.0/tests/test_trace_render.py +348 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/uv.lock +1 -1
- cmpnd-0.4.3/cmpnd/cli/commands/traces.py +0 -63
- {cmpnd-0.4.3 → cmpnd-0.5.0}/.gitignore +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/CLAUDE.md +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/README.md +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/_gepa_patch.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/_program_patch.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/_rlm_patch.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/api_client.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/admin.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/auth.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/datasets.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/deployments.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/evals.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/login.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/optimizations.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/org.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/programs.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/commands/top.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/credentials.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/shell.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/cli/verbs.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/context.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/dataset_sync.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/datasets.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/decorators.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/deployment.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/eval_handler.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/execution.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/helpers.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/identity.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/decode.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/encode.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/hash.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/program.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/reflection.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/ir/types.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/models.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimization.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/gepa.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/gepa_callback.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/resume.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/optimizers/tracker.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/cmpnd/packaging.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/2026-04-02-code-review.md +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/examples/local_ollama.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/capture_review_run.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/fixtures/review_run.json.gz +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/gepa_resume_bug.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/scripts/seed_review_run.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/__init__.pyi +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/primitives/__init__.pyi +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/primitives/prediction.pyi +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/utils/__init__.pyi +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/stubs/dspy/utils/callback.pyi +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/_exporter_helpers.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/_identity_helpers.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/committee_task.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/conftest.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/data/committee_train.ndjson.gz +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/data/committee_val.ndjson.gz +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/fake_lm.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/helpers.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_capture_prompts.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_composite_module.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_dataset_auto_creation.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_deploy_execute.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_deploy_programs.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_edge_cases.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_example_hash.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_gepa_optimization.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_lm_accuracy.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_module_tracing.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimization_lineage.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimization_metric_identity.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_failures.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_loop.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_review_committee.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_parallel_export.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_project_attribution.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_react_tool_spans.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_save_load.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_trace_structure.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/e2e/test_user_attribution.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/01_predict_qa.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/04_predict_richer_types.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/06_multichaincomparison.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/07_program_of_thought.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/08_opaque_module.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/10_react.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/11_refine.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/12_retrieve.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/13_knn.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/14_parallel.json +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/identity_vectors/README.md +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/conftest.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_cli_deploy_smoketest.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_logs_renders_traces.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_optimizing_progress.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/integration/test_unhappy_path.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/conftest.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/seed_parity.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_auth.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_datasets.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_deployments.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_evals.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_health.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_modules.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_optimizations.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_programs.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_signatures.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_trace_streaming.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/parity/test_traces.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/_reliability.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_deploy_programs.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_multi_client_stress.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_optimize_client_committee.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_optimize_loop.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_optimize_stress.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_real_provider_twins.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/stress/test_reliability.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/conftest.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_admin.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_api_client.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_auth.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_credentials.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_datasets.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_deployments.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_evals.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_login.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_main.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_optimizations.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_org.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_parity.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_programs.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_shell.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_cli/test_top.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/__init__.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/conftest.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/test_edge_cases.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/test_hash_consistency.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_clustering/test_type_conversion.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_context.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_context_propagation.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_dataset_polling.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_dataset_sync.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_datasets.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_decorators.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_deploy.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_eval.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_eval_start.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_exception_paths.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_execute.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_exporter_real.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_fake_lm.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_gepa_callback.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_gepa_integration.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_gepa_resume.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_identity.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_integration.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_ir_encoder_properties.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_ir_golden_vectors.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_ir_roundtrip.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_models.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimization_start.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimize.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimize_lift.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_optimize_terminal_failure.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_packaging.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_packaging_callable_source.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_packaging_program_source.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_program_lineage_persist.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_project_payload.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_reflector_properties.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_reflector_smoke.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_rlm_patch.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_schemas.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_seed_review_run.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_stats_export.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_tracked_gepa.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_tracker_enrich.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/tests/test_truncation.py +0 -0
- {cmpnd-0.4.3 → cmpnd-0.5.0}/todo.md +0 -0
|
@@ -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
|
|
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
|
-
#
|
|
195
|
-
|
|
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
|
|
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.
|
|
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"
|