evalshift 1.2.2__tar.gz → 1.3.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 (274) hide show
  1. {evalshift-1.2.2 → evalshift-1.3.0}/CHANGELOG.md +102 -0
  2. {evalshift-1.2.2 → evalshift-1.3.0}/PKG-INFO +23 -4
  3. {evalshift-1.2.2 → evalshift-1.3.0}/README.md +11 -3
  4. {evalshift-1.2.2 → evalshift-1.3.0}/docs/agents.md +31 -4
  5. {evalshift-1.2.2 → evalshift-1.3.0}/docs/configuration.md +232 -11
  6. {evalshift-1.2.2 → evalshift-1.3.0}/docs/evaluators.md +34 -3
  7. {evalshift-1.2.2 → evalshift-1.3.0}/docs/faq.md +8 -1
  8. {evalshift-1.2.2 → evalshift-1.3.0}/docs/getting-started.md +8 -2
  9. {evalshift-1.2.2 → evalshift-1.3.0}/docs/hosted.md +20 -8
  10. {evalshift-1.2.2 → evalshift-1.3.0}/docs/index.md +2 -1
  11. {evalshift-1.2.2 → evalshift-1.3.0}/docs/methodology.md +46 -12
  12. {evalshift-1.2.2 → evalshift-1.3.0}/docs/sdk.md +25 -6
  13. {evalshift-1.2.2 → evalshift-1.3.0}/docs/traces.md +40 -3
  14. {evalshift-1.2.2 → evalshift-1.3.0}/pyproject.toml +13 -1
  15. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/analysis/policy.py +151 -5
  16. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/captures/__init__.py +3 -2
  17. evalshift-1.3.0/src/evalshift_cli/captures/remote.py +432 -0
  18. evalshift-1.3.0/src/evalshift_cli/captures/store_uri.py +97 -0
  19. evalshift-1.3.0/src/evalshift_cli/captures/stores/__init__.py +22 -0
  20. evalshift-1.3.0/src/evalshift_cli/captures/stores/azure.py +60 -0
  21. evalshift-1.3.0/src/evalshift_cli/captures/stores/gcs.py +53 -0
  22. evalshift-1.3.0/src/evalshift_cli/captures/stores/s3.py +56 -0
  23. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/_scaffold.py +6 -0
  24. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/capture.py +243 -12
  25. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/doctor.py +48 -3
  26. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/evaluate.py +140 -2
  27. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/config/models.py +214 -2
  28. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/failures.py +6 -0
  29. evalshift-1.3.0/src/evalshift_cli/evaluators/invariants.py +240 -0
  30. evalshift-1.3.0/src/evalshift_cli/evaluators/trace_invariants.py +236 -0
  31. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/hosted/bundle.py +47 -1
  32. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/hosted/bundle_manifest.schema.json +122 -1
  33. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/hosted/push.py +1 -1
  34. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/insights/facts.py +2 -2
  35. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/reports/html.py +17 -0
  36. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/reports/json.py +151 -16
  37. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/reports/templates/report.css +2 -0
  38. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/reports/templates/report.html.j2 +32 -2
  39. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_tool_pipeline.py +93 -0
  40. evalshift-1.3.0/tests/unit/fake_remote_store.py +110 -0
  41. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_bundle_shape.py +199 -1
  42. evalshift-1.3.0/tests/unit/test_captures_remote.py +362 -0
  43. evalshift-1.3.0/tests/unit/test_captures_store_uri.py +106 -0
  44. evalshift-1.3.0/tests/unit/test_captures_stores.py +219 -0
  45. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_cli_capture.py +123 -1
  46. evalshift-1.3.0/tests/unit/test_cli_capture_remote.py +344 -0
  47. evalshift-1.3.0/tests/unit/test_config_captures.py +98 -0
  48. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_config_models.py +108 -0
  49. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_doctor.py +73 -1
  50. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_evaluate_command.py +386 -2
  51. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_init.py +16 -1
  52. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_insight_facts.py +25 -1
  53. evalshift-1.3.0/tests/unit/test_invariants.py +218 -0
  54. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_policy.py +262 -1
  55. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_reports.py +283 -0
  56. evalshift-1.3.0/tests/unit/test_trace_invariants.py +200 -0
  57. {evalshift-1.2.2 → evalshift-1.3.0}/.gitignore +0 -0
  58. {evalshift-1.2.2 → evalshift-1.3.0}/CLAUDE.md +0 -0
  59. {evalshift-1.2.2 → evalshift-1.3.0}/LICENSE +0 -0
  60. {evalshift-1.2.2 → evalshift-1.3.0}/docs/changelog.md +0 -0
  61. {evalshift-1.2.2 → evalshift-1.3.0}/docs/conversations.md +0 -0
  62. {evalshift-1.2.2 → evalshift-1.3.0}/docs/github-action.md +0 -0
  63. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent/README.md +0 -0
  64. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent/evalshift.yaml +0 -0
  65. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent/golden.jsonl +0 -0
  66. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent/prompts.py +0 -0
  67. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent/tools.yaml +0 -0
  68. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent/toolsets/ad3f07832877239336d594c7c1626197b39b9eb4c178842304c8754e94323d67.json +0 -0
  69. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent-traces/evalshift.yaml +0 -0
  70. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent-traces/golden.jsonl +0 -0
  71. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent-traces/source-traces.jsonl +0 -0
  72. {evalshift-1.2.2 → evalshift-1.3.0}/examples/agent-traces/target-traces.jsonl +0 -0
  73. {evalshift-1.2.2 → evalshift-1.3.0}/examples/capture-first/.gitignore +0 -0
  74. {evalshift-1.2.2 → evalshift-1.3.0}/examples/capture-first/README.md +0 -0
  75. {evalshift-1.2.2 → evalshift-1.3.0}/examples/capture-first/agent.py +0 -0
  76. {evalshift-1.2.2 → evalshift-1.3.0}/examples/capture-first/evalshift.yaml +0 -0
  77. {evalshift-1.2.2 → evalshift-1.3.0}/examples/simple/README.md +0 -0
  78. {evalshift-1.2.2 → evalshift-1.3.0}/examples/simple/evalshift.yaml +0 -0
  79. {evalshift-1.2.2 → evalshift-1.3.0}/examples/simple/golden.jsonl +0 -0
  80. {evalshift-1.2.2 → evalshift-1.3.0}/examples/simple/prompts.py +0 -0
  81. {evalshift-1.2.2 → evalshift-1.3.0}/scripts/smoke_live_tools.py +0 -0
  82. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/__init__.py +0 -0
  83. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/__main__.py +0 -0
  84. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/analysis/__init__.py +0 -0
  85. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/analysis/slicing.py +0 -0
  86. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/analysis/statistics.py +0 -0
  87. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cache/__init__.py +0 -0
  88. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cache/schema.py +0 -0
  89. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cache/store.py +0 -0
  90. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/captures/models.py +0 -0
  91. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/captures/promote.py +0 -0
  92. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/captures/reader.py +0 -0
  93. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/captures/toolset.py +0 -0
  94. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/__init__.py +0 -0
  95. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/__init__.py +0 -0
  96. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/_agents.py +0 -0
  97. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/_suites.py +0 -0
  98. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/analyze.py +0 -0
  99. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/bundle.py +0 -0
  100. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/cache.py +0 -0
  101. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/compare.py +0 -0
  102. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/debug_artifacts.py +0 -0
  103. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/diff.py +0 -0
  104. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/init.py +0 -0
  105. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/inspect.py +0 -0
  106. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/login.py +0 -0
  107. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/logout.py +0 -0
  108. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/push.py +0 -0
  109. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/replay.py +0 -0
  110. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/report.py +0 -0
  111. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/run.py +0 -0
  112. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/runs.py +0 -0
  113. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/test_call.py +0 -0
  114. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/traces.py +0 -0
  115. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/validate.py +0 -0
  116. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/commands/whoami.py +0 -0
  117. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/cli/main.py +0 -0
  118. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/config/__init__.py +0 -0
  119. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/config/loader.py +0 -0
  120. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/__init__.py +0 -0
  121. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/agent_trace.py +0 -0
  122. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/base.py +0 -0
  123. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/llm_judge.py +0 -0
  124. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/semantic.py +0 -0
  125. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/structural.py +0 -0
  126. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/tool_arguments.py +0 -0
  127. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/tool_loader.py +0 -0
  128. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/tool_models.py +0 -0
  129. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/tool_parser.py +0 -0
  130. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/tool_rounds.py +0 -0
  131. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/tool_selection.py +0 -0
  132. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/evaluators/tool_trace_structure.py +0 -0
  133. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/hosted/__init__.py +0 -0
  134. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/hosted/client.py +0 -0
  135. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/hosted/credentials.py +0 -0
  136. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/hosted/trace_events.py +0 -0
  137. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/insights/__init__.py +0 -0
  138. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/insights/generator.py +0 -0
  139. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/insights/models.py +0 -0
  140. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/insights/stage.py +0 -0
  141. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/insights/templates.py +0 -0
  142. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/models/__init__.py +0 -0
  143. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/models/capabilities.py +0 -0
  144. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/models/client.py +0 -0
  145. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/models/deepseek.py +0 -0
  146. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/models/family.py +0 -0
  147. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/models/registry.py +0 -0
  148. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/parsers/__init__.py +0 -0
  149. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/parsers/base.py +0 -0
  150. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/parsers/manual.py +0 -0
  151. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/parsers/python_string.py +0 -0
  152. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/py.typed +0 -0
  153. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/reports/__init__.py +0 -0
  154. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/reports/economics.py +0 -0
  155. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/runner/__init__.py +0 -0
  156. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/runner/checkpoint.py +0 -0
  157. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/runner/generation.py +0 -0
  158. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/runner/models.py +0 -0
  159. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/runner/orchestrator.py +0 -0
  160. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/suite/__init__.py +0 -0
  161. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/suite/loader.py +0 -0
  162. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/suite/models.py +0 -0
  163. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/suite/tags.py +0 -0
  164. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/traces/__init__.py +0 -0
  165. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/traces/diff.py +0 -0
  166. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/traces/loader.py +0 -0
  167. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/traces/models.py +0 -0
  168. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/utils/__init__.py +0 -0
  169. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/utils/ci_pin.py +0 -0
  170. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/utils/cost.py +0 -0
  171. {evalshift-1.2.2 → evalshift-1.3.0}/src/evalshift_cli/utils/templating.py +0 -0
  172. {evalshift-1.2.2 → evalshift-1.3.0}/tests/__init__.py +0 -0
  173. {evalshift-1.2.2 → evalshift-1.3.0}/tests/conftest.py +0 -0
  174. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/__init__.py +0 -0
  175. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_missing_var/evalshift.yaml +0 -0
  176. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_missing_var/golden.jsonl +0 -0
  177. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_non_literal/evalshift.yaml +0 -0
  178. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_non_literal/golden.jsonl +0 -0
  179. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_non_literal/prompts.py +0 -0
  180. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_ok/evalshift.yaml +0 -0
  181. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_ok/golden.jsonl +0 -0
  182. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/fixtures/validate_ok/prompts.py +0 -0
  183. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/replay_client.py +0 -0
  184. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_agent_trace_pipeline.py +0 -0
  185. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_capture_to_verdict.py +0 -0
  186. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_conversation_pipeline.py +0 -0
  187. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_multi_round_replay.py +0 -0
  188. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_scoring_semantics.py +0 -0
  189. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_simple_example.py +0 -0
  190. {evalshift-1.2.2 → evalshift-1.3.0}/tests/integration/test_validate_command.py +0 -0
  191. {evalshift-1.2.2 → evalshift-1.3.0}/tests/scoring_fixtures.py +0 -0
  192. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/__init__.py +0 -0
  193. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/conftest.py +0 -0
  194. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/captures/multi_round_tools.json +0 -0
  195. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/scoring/project_insights_pairs.jsonl +0 -0
  196. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/anthropic/parallel_tool_calls.json +0 -0
  197. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/anthropic/refusal.json +0 -0
  198. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/anthropic/single_tool_call.json +0 -0
  199. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/anthropic/text_only.json +0 -0
  200. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/anthropic/tool_call_with_text.json +0 -0
  201. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/deepseek/single_tool_call.json +0 -0
  202. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/gemini/single_tool_call.json +0 -0
  203. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/gemini/text_only.json +0 -0
  204. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/openai/parallel_tool_calls.json +0 -0
  205. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/openai/refusal.json +0 -0
  206. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/openai/single_tool_call.json +0 -0
  207. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/fixtures/tool_responses/openai/text_only.json +0 -0
  208. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/insights_factories.py +0 -0
  209. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/suite_examples.py +0 -0
  210. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_agent_trace_evaluator.py +0 -0
  211. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_agents.py +0 -0
  212. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_analysis.py +0 -0
  213. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_analyze_command.py +0 -0
  214. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_cache.py +0 -0
  215. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_captures_models.py +0 -0
  216. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_captures_promote.py +0 -0
  217. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_captures_reader.py +0 -0
  218. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_captures_toolset.py +0 -0
  219. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_checkpoint.py +0 -0
  220. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_ci_pin.py +0 -0
  221. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_cli_runs_clean.py +0 -0
  222. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_cli_smoke.py +0 -0
  223. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_compare_alias.py +0 -0
  224. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_compare_command.py +0 -0
  225. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_compare_suite_name.py +0 -0
  226. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_config_loader.py +0 -0
  227. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_config_suites.py +0 -0
  228. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_cost.py +0 -0
  229. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_debug_commands.py +0 -0
  230. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_deepseek.py +0 -0
  231. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_deferred_warnings.py +0 -0
  232. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_docs_currency.py +0 -0
  233. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_dropped_params.py +0 -0
  234. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_economics.py +0 -0
  235. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_evaluators.py +0 -0
  236. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_failures.py +0 -0
  237. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_hosted_cli.py +0 -0
  238. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_hosted_client.py +0 -0
  239. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_insight_generator.py +0 -0
  240. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_insights_stage.py +0 -0
  241. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_litellm_log_dedupe.py +0 -0
  242. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_litellm_log_stream.py +0 -0
  243. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_model_capabilities.py +0 -0
  244. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_model_client.py +0 -0
  245. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_model_family.py +0 -0
  246. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_model_registry.py +0 -0
  247. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_orchestrator.py +0 -0
  248. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_parsers.py +0 -0
  249. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_push_server_id.py +0 -0
  250. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_push_validation.py +0 -0
  251. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_reserved_slice_name.py +0 -0
  252. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_run_command.py +0 -0
  253. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_run_determinism.py +0 -0
  254. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_run_retention.py +0 -0
  255. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_run_suite_name.py +0 -0
  256. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_runner_generation.py +0 -0
  257. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_runner_models.py +0 -0
  258. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_suite_loader.py +0 -0
  259. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_suite_models.py +0 -0
  260. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_suites_helpers.py +0 -0
  261. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_templating.py +0 -0
  262. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_test_call_command.py +0 -0
  263. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_tool_arguments.py +0 -0
  264. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_tool_loader.py +0 -0
  265. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_tool_models.py +0 -0
  266. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_tool_parser.py +0 -0
  267. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_tool_rounds.py +0 -0
  268. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_tool_selection.py +0 -0
  269. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_tool_trace_structure.py +0 -0
  270. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_trace_diff.py +0 -0
  271. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_trace_events.py +0 -0
  272. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_trace_import_command.py +0 -0
  273. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_trace_models.py +0 -0
  274. {evalshift-1.2.2 → evalshift-1.3.0}/tests/unit/test_version.py +0 -0
@@ -7,6 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-10-09
11
+
12
+ ### Added
13
+
14
+ - `captures.store` in `evalshift.yaml`: name the object store the capture SDK
15
+ ships captures to — `s3://<bucket>/<prefix>`, `gs://<bucket>/<prefix>` or
16
+ `az://<account>/<container>/<prefix>` — and `capture sync` and `capture list`
17
+ mirror new captures and toolset sidecars from it into `.evalshift/` before
18
+ they run, with no flags. Only objects missing locally are downloaded
19
+ (captures are immutable, toolsets content-addressed), captures already
20
+ promoted are skipped so `capture clean` never causes a re-download, and
21
+ every file is written atomically. New `capture fetch` does only that step;
22
+ `--since 30m|24h|7d|<ISO>` limits a fetch to recently written captures and
23
+ `--offline` skips it. Keys from a bucket are validated against the SDK's
24
+ layout and anything else is ignored as malformed — including a suite
25
+ directory containing `:`, `/`, `\`, NUL or `..`, or starting or ending with
26
+ `.` or a space, and a key with a trailing newline — and no destination can
27
+ land outside `.evalshift/`; folder-marker keys ending in `/` (the S3
28
+ console's "Create folder", ADLS directories) are skipped without being
29
+ counted. A list or download failure exits 1 with a hint naming the
30
+ provider's credential chain (`aws sso login` / `AWS_PROFILE` / the CI OIDC
31
+ role, `gcloud auth application-default login`, `az login`); a local write
32
+ failure names the file and asks you to check disk space and permissions on
33
+ its directory instead. Extras:
34
+ `evalshift[s3]`, `[gcs]`, `[azure]` (azure-storage-blob and azure-identity);
35
+ a missing one exits 1 naming the `pip install` to run. Credentials come from
36
+ each provider's default chain and are rejected in the URI: a value with `@`
37
+ or `?` fails to load, naming the accepted forms. No rejected value is echoed
38
+ back (it may be a pasted key or connection string); at most its scheme is
39
+ named.
40
+ `capture list --json` keeps stdout pure JSON (the fetch line and warnings
41
+ go to stderr). `doctor` reports whether the store is installed (`fail`, exit 1,
42
+ when the extra is missing) and reachable (`warn` when it cannot be listed).
43
+ Without `captures.store` nothing changes; a `sync` or `list` whose
44
+ `evalshift.yaml` cannot be read or does not validate skips the fetch,
45
+ warning only when the file has a top-level `captures:` key. Older CLIs
46
+ reject the `captures:` key, so bump the CLI (and a pinned CI action's
47
+ `evalshift-version`) before adding it.
48
+ - `evaluators.trace_invariants`: hand-written rules over tool-call traces —
49
+ `forbidden`, `required`, `order`, `call_count` (`min_calls` and/or
50
+ `max_calls` per tool; equal bounds = an exact count) and
51
+ `arguments` (JSON Schema) — checked on both sides of every pair, on replayed
52
+ traces or (`traces: imported`) on imported ones. A rule the target breaks
53
+ counts whatever the source did. Its `applies_to` is enforced: a prompt
54
+ outside the globs is never checked, and `evaluate` warns, naming the entry
55
+ and its `applies_to`, when the globs matched nothing in the run (so a typo
56
+ is not a silent clean run). A `traces: imported` entry on a run without
57
+ `traces.jsonl` fails `evaluate` with an error naming that family.
58
+ - `migration_policy.max_invariant_violations` (default `0`, per-slice
59
+ overridable): distinct examples on which the target broke a blocking trace
60
+ rule, so an example that broke rules in two entries counts once. A breach
61
+ fails the run even when the suite is too small for statistics, and a
62
+ slice whose own budget breaches reads `fail` however few comparisons it has.
63
+ `analyze` adds a recommendation when the source broke the same rules the
64
+ target did: review the rule with its owner rather than raising the budget.
65
+ Pushed bundles' `decision.policy` now always carries the field; hosted
66
+ EvalShift accepts and stores it since 2026-10-08 (it never re-evaluates it —
67
+ a run that carries its own policy is answered with the CLI's decision).
68
+ - The HTML report lists every broken trace rule in a "Trace rules broken"
69
+ panel, with its owner and whether the source broke it too — advisory
70
+ (`blocking: false`) entries included, though only blocking ones count
71
+ toward the budget; those rows are tagged "advisory". `report.json` carries
72
+ the same rows under a new top-level `invariant_violations` key, each with
73
+ the entry's `blocking` flag. The "Overall, by evaluator" table tags every
74
+ advisory (`blocking: false`) evaluator's row the same way and says what a
75
+ trace-rule row measures, and the "Top
76
+ regression causes" panel counts `INVARIANT_VIOLATION` as the budget does —
77
+ distinct examples with a blocking target violation — so the narrative never
78
+ quotes a figure the verdict did not use.
79
+ - `evalshift init` scaffolds `max_invariant_violations: 0` in every
80
+ `--profile` policy block, beside the other budgets. Older CLIs reject the
81
+ field, so bump the CI action's `evalshift-version` pin to this release
82
+ before committing a freshly scaffolded config.
83
+ - `capture sync` keeps a managed entry's hand-written
84
+ `evaluators.trace_invariants` block when it regenerates the entry. The
85
+ marker comment still says hand edits are overwritten; this block is the one
86
+ exception.
87
+ - `push` now uploads each score row's one-line `explanation` and, for
88
+ `trace_invariants` rows, the rules each side broke (`violations.source` /
89
+ `violations.target`: rule id, type, tool, round, detail, and sample on
90
+ repeated-sampling runs). Hosted EvalShift shows them on the example detail
91
+ and on share links instead of only the violation count. `rules_checked`,
92
+ `owner` and `failure_categories` are not repeated on the score row (they
93
+ already ship in `evaluator_config` / `decision`). Requires a hosted service
94
+ that accepts the two fields (hosted EvalShift was updated before this
95
+ release); nothing else in the bundle changed.
96
+
97
+ ### Changed
98
+
99
+ - The report's per-prompt Executive summary — and the header's mean score Δ,
100
+ which averages its rows — now picks each prompt's worst severity with ties
101
+ broken toward the most negative effect size (the order the hero panel and
102
+ the narrative already used) instead of whichever evaluator `analyze` listed
103
+ first, and skips advisory (`blocking: false`) evaluators while the prompt
104
+ has a gating one. An advisory rule the target satisfied by calling no tools
105
+ at all could otherwise read as the prompt's "+9% improved" above a failed
106
+ verdict.
107
+ - Upgrading moves the resume `config_hash`: the new config fields are part of
108
+ every config's canonical dump, so a run left in progress by an older CLI
109
+ cannot be `--resume`d after upgrading (it fails with the usual config-hash
110
+ mismatch). Start a fresh run; cached responses are reused.
111
+
10
112
  ## [1.2.2] - 2026-10-05
11
113
 
12
114
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: evalshift
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Run your prompts on two LLMs and find out, with statistical confidence, what regressed.
5
5
  Project-URL: Homepage, https://github.com/evalshift/evalshift-cli
6
6
  Project-URL: Documentation, https://github.com/evalshift/evalshift-cli#readme
@@ -41,7 +41,14 @@ Requires-Dist: scipy>=1.12
41
41
  Requires-Dist: sqlalchemy>=2.0
42
42
  Requires-Dist: tiktoken>=0.7
43
43
  Requires-Dist: typer>=0.12
44
+ Provides-Extra: azure
45
+ Requires-Dist: azure-identity>=1.15; extra == 'azure'
46
+ Requires-Dist: azure-storage-blob>=12.19; extra == 'azure'
44
47
  Provides-Extra: dev
48
+ Requires-Dist: azure-identity>=1.15; extra == 'dev'
49
+ Requires-Dist: azure-storage-blob>=12.19; extra == 'dev'
50
+ Requires-Dist: boto3>=1.34; extra == 'dev'
51
+ Requires-Dist: google-cloud-storage>=2.14; extra == 'dev'
45
52
  Requires-Dist: mypy>=1.10; extra == 'dev'
46
53
  Requires-Dist: pre-commit>=3.7; extra == 'dev'
47
54
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
@@ -51,6 +58,10 @@ Requires-Dist: respx>=0.21; extra == 'dev'
51
58
  Requires-Dist: ruff==0.16.4; extra == 'dev'
52
59
  Requires-Dist: types-jsonschema>=4.22; extra == 'dev'
53
60
  Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
61
+ Provides-Extra: gcs
62
+ Requires-Dist: google-cloud-storage>=2.14; extra == 'gcs'
63
+ Provides-Extra: s3
64
+ Requires-Dist: boto3>=1.34; extra == 's3'
54
65
  Description-Content-Type: text/markdown
55
66
 
56
67
  # EvalShift
@@ -318,7 +329,11 @@ shape, `fail-on` modes, and baseline behavior.
318
329
 
319
330
  Migrating an agent (a prompt that uses tools)? EvalShift detects
320
331
  regressions in *which* tools the new model calls, *what* arguments it
321
- passes, and in what order and parallelism within a response. By default
332
+ passes, and in what order and parallelism within a response. Hand-written
333
+ `trace_invariants` rules (auth before a charge, a tool that must never be
334
+ called, at most one charge, argument bounds) hold *both* models to a
335
+ contract whatever the source did, gated by
336
+ `migration_policy.max_invariant_violations`. By default
322
337
  each example is one model call scored against the first recorded round;
323
338
  promote with `--rounds all` to replay every recorded round teacher-forced,
324
339
  with the recorded tool results fed back and each round scored on its own.
@@ -344,7 +359,10 @@ assets, works offline) has:
344
359
 
345
360
  * **Migration verdict** — the policy decision up top: which budgets failed, the
346
361
  top regression causes, and the recommendation.
347
- * **Executive summary** — one row per prompt with a severity badge.
362
+ * **Executive summary** — one row per prompt with a severity badge, drawn from
363
+ blocking evaluators (advisory ones only when nothing else scored the prompt).
364
+ * **Trace rules broken** — every hand-written trace rule the target broke,
365
+ with its owner and whether the source broke it too; advisory rows are tagged.
348
366
  * **What changed, in plain language** — the verdict, the economics and the
349
367
  behavioural drift explained by `defaults.insights_model`. Every figure in it
350
368
  is copied from the computed statistics, never generated. Needs a provider
@@ -358,7 +376,8 @@ assets, works offline) has:
358
376
 
359
377
  Your prompts and suite stay local for `doctor`, `run`, `evaluate`, `analyze`,
360
378
  and `report`. The only outbound calls in local mode are to the LLM providers you
361
- configure — any provider LiteLLM supports, called with your own API keys.
379
+ configure — any provider LiteLLM supports, called with your own API keys — and,
380
+ if you set `captures.store`, reads from your own capture bucket.
362
381
  Anthropic, DeepSeek, Google and OpenAI ids additionally get a curated pricing and
363
382
  capability entry; everything else is passed through with the provider inferred
364
383
  from the id.
@@ -263,7 +263,11 @@ shape, `fail-on` modes, and baseline behavior.
263
263
 
264
264
  Migrating an agent (a prompt that uses tools)? EvalShift detects
265
265
  regressions in *which* tools the new model calls, *what* arguments it
266
- passes, and in what order and parallelism within a response. By default
266
+ passes, and in what order and parallelism within a response. Hand-written
267
+ `trace_invariants` rules (auth before a charge, a tool that must never be
268
+ called, at most one charge, argument bounds) hold *both* models to a
269
+ contract whatever the source did, gated by
270
+ `migration_policy.max_invariant_violations`. By default
267
271
  each example is one model call scored against the first recorded round;
268
272
  promote with `--rounds all` to replay every recorded round teacher-forced,
269
273
  with the recorded tool results fed back and each round scored on its own.
@@ -289,7 +293,10 @@ assets, works offline) has:
289
293
 
290
294
  * **Migration verdict** — the policy decision up top: which budgets failed, the
291
295
  top regression causes, and the recommendation.
292
- * **Executive summary** — one row per prompt with a severity badge.
296
+ * **Executive summary** — one row per prompt with a severity badge, drawn from
297
+ blocking evaluators (advisory ones only when nothing else scored the prompt).
298
+ * **Trace rules broken** — every hand-written trace rule the target broke,
299
+ with its owner and whether the source broke it too; advisory rows are tagged.
293
300
  * **What changed, in plain language** — the verdict, the economics and the
294
301
  behavioural drift explained by `defaults.insights_model`. Every figure in it
295
302
  is copied from the computed statistics, never generated. Needs a provider
@@ -303,7 +310,8 @@ assets, works offline) has:
303
310
 
304
311
  Your prompts and suite stay local for `doctor`, `run`, `evaluate`, `analyze`,
305
312
  and `report`. The only outbound calls in local mode are to the LLM providers you
306
- configure — any provider LiteLLM supports, called with your own API keys.
313
+ configure — any provider LiteLLM supports, called with your own API keys — and,
314
+ if you set `captures.store`, reads from your own capture bucket.
307
315
  Anthropic, DeepSeek, Google and OpenAI ids additionally get a curated pricing and
308
316
  capability entry; everything else is passed through with the provider inferred
309
317
  from the id.
@@ -22,8 +22,9 @@ The killer scenario it catches:
22
22
 
23
23
  * **`ToolTrace` data model**: provider-agnostic, populated from
24
24
  Anthropic / OpenAI / Gemini responses.
25
- * **Three new evaluators**: `tool_selection`, `tool_arguments`,
26
- `tool_trace_structure`.
25
+ * **Four new evaluators**: `tool_selection`, `tool_arguments`,
26
+ `tool_trace_structure`, and hand-written `trace_invariants` (both sides
27
+ judged against your rules, not against each other).
27
28
  * **Suite extension**: optional `expected_tools`, `expected_tool_count`,
28
29
  `expected_no_tools`, `expected_parallel` per example, plus a required
29
30
  `toolset_ref` or inline `tools` — see [Suite ground truth](#suite-ground-truth).
@@ -85,7 +86,9 @@ defaults:
85
86
 
86
87
  evaluators:
87
88
  # Top-level: what every suite is scored with. Tool evaluators do not belong
88
- # here — `capture sync` writes them per suite (see below).
89
+ # here — `capture sync` writes them per suite (see below). Hand-written
90
+ # `trace_invariants` are the exception: top level is fine, and sync keeps
91
+ # them on the managed entries it rewrites.
89
92
  semantic:
90
93
  embedding_model: gemini/gemini-embedding-001
91
94
  blocking: false
@@ -123,7 +126,11 @@ inherited. See
123
126
  Hand edits inside the markers are regenerated away on the next sync. To keep
124
127
  them — a `severity_floor: high` on `routing`, a per-field strategy — set
125
128
  `managed: false` on that suite's entry; sync then prints what it would have
126
- written instead of writing it.
129
+ written instead of writing it. The one exception is a hand-written
130
+ `evaluators.trace_invariants` block: no capture can derive a team's rules, so
131
+ sync carries it into the regenerated entry verbatim, even though the marker
132
+ comment says hand edits are overwritten. See
133
+ [Configuration → `managed`](configuration.md#managed).
127
134
 
128
135
  > **Note:** `structural.length` is intentionally **not** in the
129
136
  > scaffolded config. Agent runs frequently produce empty `final_text`
@@ -437,6 +444,7 @@ together and `run` replays each one with its recorded history prefix. See
437
444
  | `tool_selection` | You care about *which* tools fire (most common). |
438
445
  | `tool_arguments` | You care about *what* the model passes to each tool. |
439
446
  | `tool_trace_structure` | You care about call counts, parallelism, or refusals. |
447
+ | `trace_invariants` | A rule must hold whatever the source did (auth before a charge, at most one charge, argument bounds). |
440
448
 
441
449
  You can run multiple at once. Each becomes an independent comparison
442
450
  in `analysis.json`, with the existing Benjamini-Hochberg correction
@@ -460,6 +468,18 @@ It scores the same recall, counting duplicates (two expected `archive_project`
460
468
  calls need two actual ones) and ignoring extra calls, without penalising a
461
469
  permutation. Call counts are `tool_trace_structure`'s job, not this one's.
462
470
 
471
+ When an order genuinely matters — authenticate before charging — don't rely on
472
+ the recording's order to say so. Write it as an `order` rule under
473
+ [`trace_invariants`](configuration.md#evaluatorstrace_invariants), which checks
474
+ both models against the rule rather than against each other, and keep
475
+ `expected_set` for the fan-out.
476
+
477
+ `trace_invariants` is also the rule-based counterpart to `agent_trace`'s
478
+ `dangerous_tools`: that one flags a target that made *more* dangerous calls
479
+ than the source, so a dangerous call both made passes; a `forbidden` or
480
+ `call_count` rule fails the target whatever the source did. It reads replayed
481
+ traces by default and imported ones with `traces: imported`.
482
+
463
483
  ### Scoring free-text arguments
464
484
 
465
485
  Free-text tool arguments — search queries, titles, descriptions — will never
@@ -583,6 +603,13 @@ identically is not a ✗ — it is the same pair before and after, and what
583
603
  is wrong is the ground truth. Look for **Ground truth missed by both**
584
604
  in the evaluator table when that happens.
585
605
 
606
+ A **Trace rules broken** panel appears when the target broke a hand-written
607
+ [`trace_invariants`](configuration.md#evaluatorstrace_invariants) rule: every
608
+ broken rule with its owner and whether the source broke it too, whatever the
609
+ delta, advisory (`blocking: false`) rows tagged **advisory**. `report.json`
610
+ carries the same rows under `invariant_violations`, and the evaluator table
611
+ tags an advisory entry's row the same way.
612
+
586
613
  ## Troubleshooting
587
614
 
588
615
  * **A suite reports tool evaluator scores you didn't expect on some rows** —
@@ -21,6 +21,7 @@ evaluators: {...} # optional (but at least one is needed for `evaluate`)
21
21
  suites: {...} # optional named suites for `run --suite-name`, each with
22
22
  # its own optional `evaluators:` block
23
23
  retention: {...} # optional run-history pruning policy
24
+ captures: {...} # optional, see below
24
25
  ```
25
26
 
26
27
  Unknown keys are rejected (`extra: forbid` everywhere) so typos fail
@@ -126,6 +127,7 @@ migration_policy:
126
127
  min_equivalence_rate: 0.75
127
128
  max_tool_argument_drift: 0.20
128
129
  max_tool_divergence: 0.20 # share of pairs where the target routed elsewhere
130
+ max_invariant_violations: 0 # examples where the target broke a blocking trace rule
129
131
  tool_argument_drift_floor: 0.9 # below this a call counts as drifted
130
132
  max_cost_increase: 0.30 # target may cost up to 30% more
131
133
  max_latency_increase: 0.30 # …and be up to 30% slower
@@ -196,6 +198,25 @@ means the target called a tool the source did not, or skipped one it did.
196
198
  On a teacher-forced multi-round replay the row's score is the mean over the
197
199
  replayed rounds, so an example counts as diverged if **any** round diverged.
198
200
 
201
+ `max_invariant_violations` is a **count**, not a rate: the number of examples
202
+ on which the target broke at least one rule of a blocking
203
+ [`trace_invariants`](#evaluatorstrace_invariants) evaluator, judged against the
204
+ rules alone — a rule the source broke too still counts against the target. The
205
+ default `0` fails on the first broken rule. Examples are counted distinctly by
206
+ `(prompt_id, example_id)`: every `trace_invariants` entry writes its own row, and
207
+ an example that broke rules in two entries counts once, over a denominator of
208
+ the distinct examples with a blocking row. A row averaged over
209
+ `samples_per_example` repeats counts when any one sample broke a rule. Because
210
+ a rule is an assertion the team wrote down rather than a statistic, a breach is
211
+ always conclusive: no interval softens it, it carries no `1/n` granularity
212
+ warning, and it turns `inconclusive` and `conditional_pass` into `fail` even
213
+ when the suite is too small for the paired tests. The same holds per slice: a
214
+ slice whose own `max_invariant_violations` breaches reads `fail` however few
215
+ comparisons it has. The budget's row appears only in scopes that scored at least
216
+ one blocking `trace_invariants` row, so a project with no trace rules (or only
217
+ advisory ones) sees no new budget row. It is overridable per slice like the
218
+ others.
219
+
199
220
  A `tool_selection.conformance` row where **both** models missed the recorded
200
221
  ground truth by the same margin is excluded from every policy rate: its zero
201
222
  delta is a shared failure, not evidence the migration is safe. Such rows are
@@ -263,6 +284,16 @@ Two behaviours to know:
263
284
  `conclusive: false` on a scope that scored zero records — their
264
285
  `0/0` default looks clean but measures nothing.
265
286
 
287
+ * **`max_invariant_violations` is conclusive by construction.** It counts
288
+ distinct examples, so it has no interval and no "too small to be sure": a breach
289
+ fails the run (or the slice) outright, overriding `inconclusive` and
290
+ `conditional_pass`, and the verdict's `reason` names the scope and the
291
+ count. Only blocking `trace_invariants` evaluators count toward it. When the
292
+ source broke the same rule on the same example, `recommendations` adds a
293
+ line saying so — not an exemption, but a hint that either both models break
294
+ the rule or the rule no longer matches the toolset, which its owner should
295
+ review.
296
+
266
297
  * **`fail_on_dropped_params` gates constraints, not scores.** Default
267
298
  `false`. A promoted capture can pin generation parameters
268
299
  (`response_format`, `tool_choice`, `parallel_tool_calls`, `top_p`, a
@@ -312,6 +343,8 @@ the snapshot each run pushed. `policy` is `null` when no `migration_policy` is
312
343
  configured, and on a `migration_decision.json` written before this field
313
344
  existed. See [What `push` sends](hosted.md#what-push-sends-block-by-block) for
314
345
  the upload contract and the notices `push` prints around a policy-less run.
346
+ Because every budget is resolved, the snapshot carries `max_invariant_violations`
347
+ (at its default `0`) even for a config that never mentions trace rules.
315
348
 
316
349
  ## `prompts`
317
350
 
@@ -404,8 +437,9 @@ bad generations fall back to deterministic templated prose.
404
437
 
405
438
  ## `evaluators`
406
439
 
407
- Seven sub-keys, all optional — `structural`, `semantic`, `tool_selection`,
408
- `tool_arguments`, `tool_trace_structure`, `agent_trace` and `llm_judge`, each
440
+ Eight sub-keys, all optional — `structural`, `semantic`, `tool_selection`,
441
+ `tool_arguments`, `tool_trace_structure`, `agent_trace`, `trace_invariants` and
442
+ `llm_judge`, each
409
443
  documented below. **At least one evaluator must be configured for
410
444
  `evalshift evaluate` to do anything.**
411
445
 
@@ -471,7 +505,7 @@ A list. Each entry has:
471
505
  | `name` | string | (required) | Identifier surfaced in reports. |
472
506
  | `conformance` | enum | `expected` | Ground-truth axis: `expected` / `expected_set` / `off`. `expected_set` is `expected` made order-insensitive: multiset recall of `example.expected_tools` names. Use it when the expected calls are a parallel fan-out whose order carries no meaning. |
473
507
  | `divergence` | enum | `set` | Target-vs-source axis: `set` (Jaccard on tool names) / `exact` (sequence equality) / `first` (first call only) / `off`. |
474
- | `applies_to` | list | `["*"]` | Glob list of prompt ids. |
508
+ | `applies_to` | list | `["*"]` | Glob list of prompt ids (accepted, not yet enforced — only [`trace_invariants`](#evaluatorstrace_invariants) enforces it). |
475
509
  | `severity_floor` | enum | `null` | If set, surfaces in metadata so the analysis layer can floor severity. |
476
510
 
477
511
  The two axes are independent and each writes **its own record**, under
@@ -502,7 +536,7 @@ deprecated, and a config still carrying one fails validation.
502
536
  | Field | Type | Default | Description |
503
537
  | ----------------------- | ------ | ------- | ----------- |
504
538
  | `name` | string | (required) | Identifier. |
505
- | `applies_to` | list | `["*"]` | Glob list. |
539
+ | `applies_to` | list | `["*"]` | Glob list of prompt ids (accepted, not yet enforced — only [`trace_invariants`](#evaluatorstrace_invariants) enforces it). |
506
540
  | `against` | enum | `source` | What arguments are compared to: `source` (drift from the source model) or `expected` (correctness against `expected_tools[].arguments`, scored on both sides). |
507
541
  | `strategies` | dict | `{}` | Per-field strategy overrides (`exact`/`subset`/`numeric`/`semantic`/`auto`). |
508
542
  | `default_strategy` | enum | `auto` | Strategy for fields `strategies` does not name. `auto` is the ladder below; `exact` restores byte-equality scoring. |
@@ -578,7 +612,7 @@ unaffected: it counts calls whose *target* score fell below
578
612
  | Field | Type | Default | Description |
579
613
  | --------------------- | ------ | ------- | ----------- |
580
614
  | `name` | string | (required) | Identifier. |
581
- | `applies_to` | list | `["*"]` | Glob list. |
615
+ | `applies_to` | list | `["*"]` | Glob list of prompt ids (accepted, not yet enforced — only [`trace_invariants`](#evaluatorstrace_invariants) enforces it). |
582
616
  | `check_call_count` | bool | `true` | Score the number of tool calls. |
583
617
  | `check_parallelism` | bool | `true` | Score parallel-vs-sequential alignment. |
584
618
  | `check_refusals` | bool | `true` | Score refusal alignment; mismatches force `severity_floor: high`. |
@@ -593,7 +627,7 @@ the normal `raw.jsonl` model-call artifact.
593
627
  | Field | Type | Default | Description |
594
628
  | ---------------------------- | ------ | ------- | ----------- |
595
629
  | `name` | string | required | Identifier surfaced in scores and reports. |
596
- | `applies_to` | list | `["*"]` | Glob list of prompt ids. |
630
+ | `applies_to` | list | `["*"]` | Glob list of prompt ids (accepted, not yet enforced — only [`trace_invariants`](#evaluatorstrace_invariants) enforces it). |
597
631
  | `check_tool_order` | bool | `true` | Compare source and target tool-call order. |
598
632
  | `check_arguments` | bool | `true` | Compare arguments for same-name matched tool calls. |
599
633
  | `check_missing_verification` | bool | `true` | Check dangerous tools have an earlier verification tool. |
@@ -610,6 +644,136 @@ evaluators:
610
644
  dangerous_tools: ["issue_refund"]
611
645
  ```
612
646
 
647
+ ### `evaluators.trace_invariants`
648
+
649
+ A list. Each entry is a set of hand-written rules that every in-scope tool-call
650
+ trace must satisfy, checked on **both** sides of every pair and judged against
651
+ the rules, not against the source: a rule the source also broke still counts
652
+ against the target. See [Evaluators → Trace invariants](evaluators.md#trace-invariants)
653
+ for when to reach for it.
654
+
655
+ Write it in the top-level `evaluators:` block (outside the `capture sync`
656
+ managed region) or in a suite's own `evaluators:` override. `capture sync`
657
+ keeps a managed suite entry's `trace_invariants` when it regenerates that
658
+ entry — see [`managed`](#managed).
659
+
660
+ ```yaml
661
+ evaluators:
662
+ trace_invariants:
663
+ - name: payments_contract
664
+ owner: "@payments-team" # optional; echoed onto every record and the report
665
+ applies_to: ["checkout-*"] # prompt-id globs; default ["*"]
666
+ blocking: true # default; false = reported, never gates
667
+ traces: replayed # default; `imported` checks `evalshift traces import` traces
668
+ rules:
669
+ - id: auth-before-charge
670
+ type: order
671
+ before: authenticate
672
+ after: charge_card
673
+ - id: no-legacy-refund
674
+ type: forbidden
675
+ tools: [refund_v1]
676
+ - id: one-charge
677
+ type: call_count
678
+ tool: charge_card
679
+ max_calls: 1 # min_calls, max_calls or both; both equal = exact
680
+ - id: must-book
681
+ type: required
682
+ tools: [create_booking]
683
+ - id: sane-amount
684
+ type: arguments
685
+ tool: charge_card
686
+ json_schema:
687
+ type: object
688
+ required: [amount]
689
+ properties:
690
+ amount: {type: number, minimum: 0.01, maximum: 5000}
691
+
692
+ migration_policy:
693
+ max_invariant_violations: 0 # default; examples where the target broke any blocking rule
694
+ ```
695
+
696
+ | Field | Type | Default | Description |
697
+ | ------------ | ------ | ------------ | ----------- |
698
+ | `name` | string | (required) | Identifier surfaced in scores and reports. |
699
+ | `applies_to` | list | `["*"]` | Glob list of prompt ids. **Enforced here**, unlike on the other families: a prompt outside these globs is never checked, gets no row and is never counted. If the globs match no prompt in a run (with `traces: imported`, no imported trace pair), `evaluate` prints a warning naming the entry and its `applies_to`, because none of its rules were checked (`compare` scores quietly and does not print it); matching at least one prompt stays silent. |
700
+ | `blocking` | bool | `true` | `true` counts target violations toward [`max_invariant_violations`](#migration_policy); `false` still checks and reports them, but never gates. |
701
+ | `traces` | string | `replayed` | `replayed` checks the tool calls `evalshift run` replayed (a pair whose replay recorded no tool trace gets no row). `imported` checks the traces brought in with [`evalshift traces import`](traces.md); `evaluate` fails on a run with no `traces.jsonl`, naming `trace_invariants (traces: imported)` in the error. |
702
+ | `owner` | string | `null` | Free text (a team or a handle). Echoed onto every record and into the report, so a violation names who to ask. Nothing enforces it. |
703
+ | `rules` | list | (required) | At least one rule. Every rule has an `id` (unique within the entry; it names violations in reports) and a `type`. |
704
+
705
+ The unit of checking is a **response**: one model turn, plus the tool calls
706
+ already in its **context** before it answered. On a replayed trace there is one
707
+ response per replayed round, and round *k*'s context is the tool calls on the
708
+ example's `history` assistant turns plus the *recorded* calls of the rounds the
709
+ teacher-forced replay fed back before it; the model's own earlier rounds are
710
+ never context, because the replay never feeds them back. An imported trace is
711
+ one response with an empty context — every call in it was the agent's own.
712
+ Context calls can never *be* violations, since the model under test did not
713
+ make them, but they can satisfy or count toward a rule:
714
+
715
+ | `type` | Fields | Violated when | Context role |
716
+ | ------------ | ------ | ------------- | ------------ |
717
+ | `forbidden` | `tools` (list) | the model calls any tool in `tools` (one violation per call) | none |
718
+ | `required` | `tools` (list) | some tool in `tools` is called in no response by the model | none: a call only in the context does not satisfy it. Use `call_count` `min_calls` when the conversation may already contain the call |
719
+ | `order` | `before`, `after` (two different tools) | the model calls `after` and no `before` precedes it, either in the context or earlier in the same response | a context `before` satisfies it |
720
+ | `call_count` | `tool`, `min_calls` and/or `max_calls` (at least one; `max_calls` ≥ `min_calls`) | `max_calls`: a model call to `tool` takes the running count past it, counting context calls first (one violation per excess call). `min_calls`: at the end of the last response, context calls plus the model's calls to `tool` number fewer than it (one violation). Both set and equal is exact cardinality | context calls count, toward both bounds |
721
+ | `arguments` | `tool`, `json_schema` | a model call to `tool` has arguments that fail `json_schema` (Draft 2020-12, itself validated when the config loads) | none |
722
+
723
+ "Earlier in the same response" means emitted earlier; imported traces are
724
+ ordered by `sequence_index`, so the two modes agree.
725
+
726
+ **Scoring.** Each side scores the share of rules it broke zero times —
727
+ `(rules − rules broken) / rules` — so a rule broken three times costs the same
728
+ as one broken once, and `delta = target − source`. The record's kind is
729
+ `trace_invariants`; its metadata carries `rules_checked`, `source_violations`
730
+ and `target_violations` (each a list of `{rule_id, rule_type, tool,
731
+ round_index, detail}`), `owner` when set, and the failure category
732
+ `INVARIANT_VIOLATION` when the target broke anything. The report's **Top
733
+ regression causes** panel (and `failure_categories` in
734
+ `migration_decision.json`) counts that category the way the budget does —
735
+ distinct examples on which the target broke a *blocking* rule — so an advisory
736
+ violation, or a second entry broken on the same example, never inflates it
737
+ past the figure the verdict used. With `samples_per_example` above 1 the
738
+ example's row keeps every scored sample's violations, each tagged with its
739
+ `sample` ordinal. The HTML report's **Trace rules broken** panel and
740
+ `report.json`'s `invariant_violations` list every rule the target broke,
741
+ whatever the delta — including those of advisory (`blocking: false`) entries,
742
+ which never count toward the budget — with the owner and whether the source
743
+ broke it too. Each row carries the entry's `blocking` flag, and the panel tags
744
+ `blocking: false` rows **advisory**. The **Overall, by evaluator** table tags
745
+ every advisory (`blocking: false`) evaluator's row the same way, and every
746
+ `trace_invariants` row there also says what it measures: each side judged
747
+ against the entry's rules, not against the other side. The
748
+ per-prompt **Executive summary** — and the header's mean score Δ, which
749
+ averages its rows — is drawn from blocking evaluators only (worst severity
750
+ first, ties to the most negative effect size), falling back to advisory ones
751
+ on a prompt that has nothing else, so an advisory rule the target satisfied by
752
+ calling no tools cannot read as the prompt's improvement above a failed
753
+ verdict.
754
+
755
+ Four things to know:
756
+
757
+ - **`required` and `min_calls` are not vacuous.** `order`, `arguments` and a
758
+ `call_count` with only `max_calls` say nothing about a trace that never calls
759
+ the tool. `required` and `call_count` with `min_calls` fail on every in-scope
760
+ trace that lacks the calls. A single-shot replay sees only the first
761
+ response, so scope them with `applies_to`, or use them with
762
+ `capture sync --rounds all` or with imported traces.
763
+ - **`required` counts the model's calls; `call_count` counts the
764
+ conversation's.** `required` asks whether the model under test made the
765
+ call. `call_count` asks whether the conversation contains it, so a recorded
766
+ history that already called `charge_card` satisfies `min_calls: 1` and
767
+ leaves `max_calls: 1` no room.
768
+ - **Teacher-forced rounds see recorded context.** Round *k* is judged with the
769
+ recorded rounds before it as context, not the model's own earlier rounds.
770
+ That applies to `min_calls` too: it is judged on the last response's context
771
+ plus that response's own calls.
772
+ - **Malformed arguments fail any schema that requires a field.** Arguments the
773
+ model emitted as unparseable JSON are recorded as
774
+ `{"_raw": …, "_parse_error": true}`, which fails any `json_schema` that
775
+ requires a field.
776
+
613
777
  ### `evaluators.llm_judge`
614
778
 
615
779
  A list of pairwise judges. Each entry has:
@@ -769,8 +933,8 @@ suites:
769
933
 
770
934
  The block takes the same families as the top-level `evaluators:` — `structural`,
771
935
  `semantic`, `llm_judge`, `tool_selection`, `tool_arguments`,
772
- `tool_trace_structure`, `agent_trace` — and resolution is **family-level
773
- replacement**:
936
+ `tool_trace_structure`, `agent_trace`, `trace_invariants` — and resolution is
937
+ **family-level replacement**:
774
938
 
775
939
  - A family the suite does **not** mention is inherited from the top level.
776
940
  - A family it **does** mention replaces the top-level one wholesale. There is no
@@ -787,9 +951,15 @@ a name that has no `suites:` entry, resolves to the top-level block.
787
951
 
788
952
  ### `managed`
789
953
 
790
- `capture sync` regenerates a managed suite's entire entry — `path` and
954
+ `capture sync` regenerates a managed suite's entry — `path` and
791
955
  `evaluators` — from what that suite's captures contain, so hand edits inside the
792
- marker-delimited region are overwritten. Set `managed: false` to freeze an entry:
956
+ marker-delimited region are overwritten. The one exception is
957
+ `evaluators.trace_invariants`: no capture can derive a team's rules, so sync
958
+ copies a managed entry's existing `trace_invariants` block into the entry it
959
+ regenerates, verbatim. The marker comment above the region still says hand edits
960
+ are overwritten; that text cannot change (sync finds the region by matching it
961
+ exactly), so this paragraph is where the exception is written down. Every other
962
+ hand edit is overwritten. Set `managed: false` to freeze an entry:
793
963
 
794
964
  ```yaml
795
965
  suites:
@@ -844,6 +1014,50 @@ evalshift runs clean --suite main_chat # restrict to one suite
844
1014
  `--keep` beats `EVALSHIFT_MAX_RUNS`, which beats the config value. `runs clean` works even without a
845
1015
  valid `evalshift.yaml` (it falls back to the defaults), so it's always available for disk cleanup.
846
1016
 
1017
+ ## `captures`
1018
+
1019
+ Where the capture SDK's recordings live when they are not on this machine. Omit the block
1020
+ entirely for the default: captures are read from `.evalshift/captures/` (or `EVALSHIFT_DIR`)
1021
+ on local disk and nothing here is needed.
1022
+
1023
+ Production agents on Fargate, Lambda or Kubernetes lose their disk when they stop, so the
1024
+ SDK can ship captures to an object store you own instead (`EVALSHIFT_SINK=<uri>` in the
1025
+ agent's environment — see [Capture SDK](sdk.md)). Name the same store here and the CLI
1026
+ pulls new captures down before it promotes them, with no flags to remember:
1027
+
1028
+ ```yaml
1029
+ captures:
1030
+ store: s3://acme-evals/support-agent
1031
+ ```
1032
+
1033
+ | Field | Type | Default | Description |
1034
+ | ------- | ------------- | ------- | ----------- |
1035
+ | `store` | string (URI) | (none) | Object store the SDK writes to. `capture sync` and `capture list` fetch new captures and toolset sidecars from it into the local directory first; `capture fetch` does only that step. |
1036
+
1037
+ CLI versions before this release reject the `captures:` key (the config is strict), so bump
1038
+ the CLI — and a pinned GitHub Action's `evalshift-version` — before adding it.
1039
+
1040
+ Accepted URI forms — the same grammar the SDK uses:
1041
+
1042
+ | Form | Store | Install | Credentials |
1043
+ | --- | --- | --- | --- |
1044
+ | `s3://<bucket>/<prefix>` | Amazon S3; MinIO, Cloudflare R2, Backblaze B2 via boto3's `AWS_ENDPOINT_URL` | `pip install "evalshift[s3]"` | IAM role in CI (OIDC), `aws sso login` locally |
1045
+ | `gs://<bucket>/<prefix>` | Google Cloud Storage | `pip install "evalshift[gcs]"` | Workload Identity in CI, `gcloud auth application-default login` locally |
1046
+ | `az://<account>/<container>/<prefix>` | Azure Blob Storage | `pip install "evalshift[azure]"` | Managed Identity / federated credential in CI, `az login` locally |
1047
+
1048
+ `<prefix>` is optional. Credentials never go in the URI: a value containing `@` or `?` fails
1049
+ to load, naming the accepted forms. A rejected value is never echoed back (it may be a pasted
1050
+ key or connection string); the error names at most its scheme. Under the prefix the layout is exactly the local one —
1051
+ `captures/<suite>/cap_<hex>.json` and `toolsets/<hex>.json` — so the bucket is a mirror of
1052
+ `.evalshift/`, not a different format.
1053
+
1054
+ What the fetch does: it lists the bucket, downloads only objects missing locally (captures
1055
+ are immutable and toolsets content-addressed, so "exists" means "current"), skips captures
1056
+ already promoted into a suite (so `capture clean` never causes a re-download), and writes
1057
+ each file atomically. `--since 24h` (or `7d`, `30m`, an ISO date) limits a fetch to recently
1058
+ written captures; `--offline` skips it and works with what is already local. Retention in the bucket
1059
+ is yours to set with a lifecycle rule; the SDK's `max_captures` does not apply there.
1060
+
847
1061
  ## Capture lifecycle
848
1062
 
849
1063
  The companion `evalshift-sdk` package records real agent runs to
@@ -852,11 +1066,16 @@ the base). The CLI consumes them via the `capture` commands:
852
1066
 
853
1067
  ```shell
854
1068
  evalshift capture list # see what the SDK recorded
1069
+ evalshift capture fetch # (captures.store only) mirror new captures locally
855
1070
  evalshift capture sync --input-var query # promote every capture + wire suites:
856
1071
  evalshift run --suite-name support_agent --yes # score a candidate model against it
857
1072
  evalshift capture clean # prune already-promoted captures
858
1073
  ```
859
1074
 
1075
+ With [`captures.store`](#captures) configured, `list` and `sync` run the same fetch first
1076
+ and print one summary line (`fetched 12 capture(s) and 1 toolset(s) from s3://…`); pass
1077
+ `--offline` to skip it. `promote`, `diff` and `clean` operate on the local mirror only.
1078
+
860
1079
  `capture clean [<suite>]` deletes promoted captures by default (`--promoted`);
861
1080
  `--all` deletes every capture, promoted or not. It never touches promoted
862
1081
  suites, then offers to sweep toolset sidecars no surviving capture or suite
@@ -883,7 +1102,9 @@ key on evaluator names across runs, so regenerating a suite must not rename what
883
1102
  it already wired. `structural` is deliberately not derived: nothing in a capture
884
1103
  says what shape an answer must have. Sync regenerates only the suites it just
885
1104
  promoted and carries every other entry in the region forward verbatim, and
886
- `managed: false` freezes an entry entirely (see [`suites`](#suites)).
1105
+ `managed: false` freezes an entry entirely (see [`suites`](#suites)). A
1106
+ regenerated entry keeps its hand-written `evaluators.trace_invariants` (see
1107
+ [`managed`](#managed)).
887
1108
  Captures with no recorded events are skipped, and captures whose replayed
888
1109
  content duplicates an already-promoted case (or an earlier capture in the
889
1110
  same run) are skipped too — duplicate examples inflate *n* and corrupt the