sable-sphynx 0.7.0.dev0__py3-none-any.whl

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 (174) hide show
  1. sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
  2. sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
  3. sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
  4. sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
  5. sphynx/__init__.py +469 -0
  6. sphynx/__init__.pyi +376 -0
  7. sphynx/_analytical_client.py +77 -0
  8. sphynx/_analytical_values.py +252 -0
  9. sphynx/_audit_client.py +173 -0
  10. sphynx/_comparison_adapters.py +113 -0
  11. sphynx/_componentized_full_rag.py +34 -0
  12. sphynx/_composed_pipeline.py +109 -0
  13. sphynx/_full_rag_grading.py +218 -0
  14. sphynx/_full_rag_trace.py +221 -0
  15. sphynx/_local_eval.py +268 -0
  16. sphynx/_pipeline_core.py +243 -0
  17. sphynx/_pr_client.py +237 -0
  18. sphynx/_rag_diagnostics_calibration.py +285 -0
  19. sphynx/_rag_diagnostics_catalog.py +39 -0
  20. sphynx/_rag_diagnostics_common.py +26 -0
  21. sphynx/_rag_diagnostics_contracts.py +186 -0
  22. sphynx/_rag_diagnostics_engine.py +243 -0
  23. sphynx/_rag_diagnostics_importers.py +242 -0
  24. sphynx/_rag_diagnostics_ranking.py +81 -0
  25. sphynx/_rag_diagnostics_semantic.py +325 -0
  26. sphynx/_rag_ranking.py +149 -0
  27. sphynx/_speech_benchmark_scoring.py +140 -0
  28. sphynx/_speech_benchmark_sources.py +275 -0
  29. sphynx/_speech_complete_scoring.py +218 -0
  30. sphynx/_speech_complete_source.py +118 -0
  31. sphynx/_speech_kido.py +294 -0
  32. sphynx/_speech_scoring.py +77 -0
  33. sphynx/_track_capture.py +213 -0
  34. sphynx/analytical_views.py +515 -0
  35. sphynx/assessment_contracts.py +378 -0
  36. sphynx/assessment_selection.py +57 -0
  37. sphynx/audit_access.py +245 -0
  38. sphynx/audit_contracts.py +204 -0
  39. sphynx/audit_requests.py +180 -0
  40. sphynx/authoring.py +188 -0
  41. sphynx/benchmarks.py +568 -0
  42. sphynx/candidates.py +62 -0
  43. sphynx/catalog.py +272 -0
  44. sphynx/chunking.py +372 -0
  45. sphynx/clickhouse_reader.py +169 -0
  46. sphynx/client.py +486 -0
  47. sphynx/comparison_core.py +149 -0
  48. sphynx/comparison_views.py +223 -0
  49. sphynx/components.py +1661 -0
  50. sphynx/contracts.py +514 -0
  51. sphynx/customer_rerank.py +299 -0
  52. sphynx/customer_retrieval.py +146 -0
  53. sphynx/data/benchmark-releases.v1.json +164 -0
  54. sphynx/data/public-benchmark-release.v1.json +354 -0
  55. sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
  56. sphynx/data/rag-paper-references.v1.json +176 -0
  57. sphynx/data/rag-paper-schemas.v1.json +1069 -0
  58. sphynx/data/rag-rerank-v1.json +507 -0
  59. sphynx/dataset_catalog_data.py +1214 -0
  60. sphynx/dataset_readiness.py +138 -0
  61. sphynx/dataset_v3.py +999 -0
  62. sphynx/demo.py +130 -0
  63. sphynx/demo_candidates/__init__.py +1 -0
  64. sphynx/demo_candidates/dense.py +11 -0
  65. sphynx/demo_candidates/lexical.py +21 -0
  66. sphynx/diagnostics.py +207 -0
  67. sphynx/documentation.py +737 -0
  68. sphynx/documentation_candidate.py +939 -0
  69. sphynx/evals.py +538 -0
  70. sphynx/evaluation_protocols.py +124 -0
  71. sphynx/evaluator_bindings.py +370 -0
  72. sphynx/evidence.py +352 -0
  73. sphynx/execution_capabilities.py +109 -0
  74. sphynx/fast_search.py +285 -0
  75. sphynx/full_rag.py +341 -0
  76. sphynx/hosted/__init__.py +18 -0
  77. sphynx/hosted/benchmark_intake.py +218 -0
  78. sphynx/hosted/contracts.py +361 -0
  79. sphynx/hosted/model_admission.py +130 -0
  80. sphynx/hosted/model_artifacts.py +298 -0
  81. sphynx/hosted/model_contracts.py +63 -0
  82. sphynx/hosted/model_gcs.py +181 -0
  83. sphynx/hosted/model_history.py +84 -0
  84. sphynx/hosted/model_session.py +233 -0
  85. sphynx/hosted/public_suite.py +74 -0
  86. sphynx/hosted/registry.py +97 -0
  87. sphynx/hosted/revisions.py +126 -0
  88. sphynx/hosted/sandbox.py +105 -0
  89. sphynx/hosted/submission.py +287 -0
  90. sphynx/hosted_workflow.py +451 -0
  91. sphynx/http_transport.py +86 -0
  92. sphynx/integrated_rag.py +352 -0
  93. sphynx/integrated_workflow.py +552 -0
  94. sphynx/integration_contracts.py +474 -0
  95. sphynx/invocation.py +791 -0
  96. sphynx/invocation_candidates.py +392 -0
  97. sphynx/invocation_catalog_data.py +40 -0
  98. sphynx/learned_catalog_data.py +30 -0
  99. sphynx/learned_rerank.py +267 -0
  100. sphynx/legacy_full_rag.py +67 -0
  101. sphynx/lifecycle.py +885 -0
  102. sphynx/lifecycle_workflow.py +490 -0
  103. sphynx/metric_views.py +474 -0
  104. sphynx/metrics.py +282 -0
  105. sphynx/model_adapters.py +438 -0
  106. sphynx/model_catalog_data.py +30 -0
  107. sphynx/official_benchmarks.py +385 -0
  108. sphynx/packs.py +258 -0
  109. sphynx/pipeline.py +313 -0
  110. sphynx/pipeline_workflow.py +551 -0
  111. sphynx/pr_evaluations.py +560 -0
  112. sphynx/progress.py +89 -0
  113. sphynx/public_bundle.py +173 -0
  114. sphynx/public_eval.py +62 -0
  115. sphynx/public_rerank.py +355 -0
  116. sphynx/py.typed +0 -0
  117. sphynx/pytest_plugin.py +62 -0
  118. sphynx/question_robustness.py +437 -0
  119. sphynx/rag.py +329 -0
  120. sphynx/rag_diagnostics.py +309 -0
  121. sphynx/rag_papers/__init__.py +29 -0
  122. sphynx/rag_papers/__main__.py +45 -0
  123. sphynx/rag_papers/candidates.py +19 -0
  124. sphynx/rag_papers/contracts.py +282 -0
  125. sphynx/rag_papers/datasets.py +291 -0
  126. sphynx/rag_papers/experiments.py +116 -0
  127. sphynx/rag_papers/judges.py +308 -0
  128. sphynx/rag_papers/registry.py +33 -0
  129. sphynx/rag_papers/runner.py +176 -0
  130. sphynx/rag_papers/sable.py +135 -0
  131. sphynx/rag_papers/schemas.py +12 -0
  132. sphynx/rag_papers/scoring.py +249 -0
  133. sphynx/rag_papers/semantic.py +288 -0
  134. sphynx/rag_papers/trec.py +80 -0
  135. sphynx/recorded_scores.py +352 -0
  136. sphynx/records.py +124 -0
  137. sphynx/reflex_traces.py +193 -0
  138. sphynx/report_rendering.py +71 -0
  139. sphynx/rerank_family_contracts.py +150 -0
  140. sphynx/rerank_workflow.py +424 -0
  141. sphynx/research_candidate.py +190 -0
  142. sphynx/results.py +852 -0
  143. sphynx/retrieval.py +750 -0
  144. sphynx/retrieval_candidates.py +295 -0
  145. sphynx/retrieval_catalog_data.py +19 -0
  146. sphynx/retrieval_recordings.py +125 -0
  147. sphynx/reviewer_audit.py +266 -0
  148. sphynx/routing_policies.py +375 -0
  149. sphynx/runner.py +544 -0
  150. sphynx/sable_rerank.py +489 -0
  151. sphynx/score_reliability.py +393 -0
  152. sphynx/speech_benchmarks.py +204 -0
  153. sphynx/speech_campaigns.py +378 -0
  154. sphynx/speech_complete.py +236 -0
  155. sphynx/speech_contracts.py +225 -0
  156. sphynx/speech_evidence.py +429 -0
  157. sphynx/speech_listening.py +115 -0
  158. sphynx/speech_multilingual.py +71 -0
  159. sphynx/speech_tts.py +479 -0
  160. sphynx/sql/001_sync_v1.sql +30 -0
  161. sphynx/stage_contracts.py +199 -0
  162. sphynx/statistical_catalog_data.py +53 -0
  163. sphynx/statistical_rerank.py +232 -0
  164. sphynx/sync.py +797 -0
  165. sphynx/workbench.py +378 -0
  166. sphynx/workbench_catalog_data.py +13 -0
  167. sphynx/workbench_tracks.py +78 -0
  168. sphynx/workflow_async.py +197 -0
  169. sphynx/workflow_contracts.py +594 -0
  170. sphynx/workflow_decisions.py +211 -0
  171. sphynx/workflow_defaults.py +16 -0
  172. sphynx/workflow_extensions.py +212 -0
  173. sphynx/workflow_registry.py +513 -0
  174. sphynx/workflows.py +792 -0
sphynx/invocation.py ADDED
@@ -0,0 +1,791 @@
1
+ """Local, externally supplied customer RAG invocation evaluation.
2
+
3
+ The corpus and labels are never package resources. This development pack is
4
+ intentionally separate from the frozen-pool reranker evidence format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import math
12
+ import os
13
+ import platform
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import uuid
20
+ from collections import Counter
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from .contracts import CandidateSpec, ContractError, canonical_json
26
+ from .runner import MAX_STREAM_BYTES, _BoundedStream, _InputWrite, _candidate_environment
27
+
28
+ PACK_ID = "rag.invoke.v1"
29
+ INTERFACE_VERSION = "sphynx.rag.invoke.v1"
30
+ REQUEST_VERSION = "sphynx.rag.invoke.request.v1"
31
+ RESPONSE_VERSION = "sphynx.rag.invoke.response.v1"
32
+ METRIC_RELEASE = "rag.invoke.metric.v1"
33
+ GRADER_RELEASE = "rag.invoke.grader.v1"
34
+ INPUT_SHA256 = "7316862d37a7185ed7c995278a5d5f9ae34ff0ae05afe5c4d281e2c00303d2bf"
35
+ GOLD_SHA256 = "ee66b8193deceb4c82f5fe87a4344278b94cca9436724e4b2bc7d1b1f51dedef"
36
+ _COMPANIES = frozenset({"figma", "monday", "notion", "tipalti", "vanta"})
37
+ _ACTIONS = frozenset({"CALL_RAG", "ASK_BUYER", "ANSWER_FROM_CONTEXT"})
38
+ _SOURCE_ACTIONS = {
39
+ "call_rag": "CALL_RAG",
40
+ "clarify_first": "ASK_BUYER",
41
+ "do_not_call_rag": "ANSWER_FROM_CONTEXT",
42
+ }
43
+ _ID = re.compile(r"^(?:invq|nhq|dbq)-[a-z0-9]+$")
44
+ _RUN_ID = re.compile(r"^invoke-run-[0-9a-f]{32}$")
45
+ _EVAL_ID = re.compile(r"^invoke-eval-[0-9a-f]{32}$")
46
+ _METRICS = (
47
+ "three_way_accuracy", "required_lookup_recall", "unnecessary_call_rate",
48
+ "clarification_accuracy", "answer_from_context_accuracy",
49
+ )
50
+
51
+
52
+ class InvocationError(ValueError):
53
+ """The private corpus, candidate response, or saved result is invalid."""
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class InvocationBank:
58
+ """Content-free identity of a supported owner-held question release."""
59
+
60
+ bank_id: str
61
+ release: str
62
+ title: str
63
+ case_count: int
64
+ source_case_count: int
65
+ cases_per_company: int
66
+ action_counts: tuple[tuple[str, int], ...]
67
+ input_sha256: str
68
+ gold_sha256: str
69
+ source_schema: str
70
+ manifest_sha256: str | None
71
+ id_prefix: str
72
+ population: str
73
+ contains_source_excerpt_controls: bool = False
74
+ companies: tuple[str, ...] = tuple(sorted(_COMPANIES))
75
+
76
+
77
+ _BANKS = (
78
+ InvocationBank(
79
+ "excerpt-free-200", "v1-development-200", "Excerpt-free development slice",
80
+ 200, 200, 40, (("CALL_RAG", 119), ("ASK_BUYER", 81)),
81
+ INPUT_SHA256, GOLD_SHA256, "sphynx.jev-hybrid-safe200.v1", None, "invq-",
82
+ "authored_development",
83
+ ),
84
+ InvocationBank(
85
+ "development-300", "v1-20260918-r3-five-customer", "Full five-customer development bank",
86
+ 300, 360, 60,
87
+ (("CALL_RAG", 100), ("ASK_BUYER", 100), ("ANSWER_FROM_CONTEXT", 100)),
88
+ "80336bade48c6bc1acadbae8c66e321d68008e0ee6d2d18fd473e56d566319a6",
89
+ "b886cc3cb572249018e94656ceeff796c576dc7bc5162b30e90d9ed039a02472",
90
+ "sable.invocation-question-corpus.v1",
91
+ "fca2e2236016983de5f8cf579d488b20b07a8a2f32e1fc9cb459e6ef124e6f92", "invq-",
92
+ "authored_development", True,
93
+ ),
94
+ InvocationBank(
95
+ "narrow-policy-90", "v1-20260925", "Narrow-policy regression bank",
96
+ 90, 90, 18,
97
+ (("CALL_RAG", 30), ("ASK_BUYER", 30), ("ANSWER_FROM_CONTEXT", 30)),
98
+ "ecde7c3141fa8b508386e5b59468296acba55e14aa4f64e1e2f8143de977878e",
99
+ "01430c66f73a58562834c7521186bace303d8a2ecc52e46a96da64e93cd769a9",
100
+ "sphynx.invocation-holdout.v1",
101
+ "eae4d921391dad58c73507a129eb20fbe86fd5109d37f5e19626cdb3a061d937", "nhq-",
102
+ "previously_exercised_regression",
103
+ ),
104
+ InvocationBank(
105
+ "decision-boundary-80", "v1-20260925", "Decision-boundary regression bank",
106
+ 80, 80, 16,
107
+ (("CALL_RAG", 40), ("ASK_BUYER", 20), ("ANSWER_FROM_CONTEXT", 20)),
108
+ "f1aea9ab88c55586ed01a6bf5aee4ac78661ae11727dfea12ecee14da09ea2b6",
109
+ "fc3f39b79435eab66033b9ac2d919a2931b26568f848542cce2f6a27dc15eb44",
110
+ "sphynx.decision-boundary-holdout.v1",
111
+ "307d5bb41d7c649062b0da8bdf9966ef3479c4d1484958bf331b69300a18ada9", "dbq-",
112
+ "previously_exercised_regression",
113
+ ),
114
+ )
115
+
116
+
117
+ # Revised bytes always retain distinct releases. Six-customer banks require explicit selection.
118
+ _BANKS += (
119
+ InvocationBank('narrow-gate-20260925-r2', 'narrow-gate-20260925-r2', 'narrow-gate-20260925-r2', 90, 90, 18, (('CALL_RAG', 30), ('ASK_BUYER', 30), ('ANSWER_FROM_CONTEXT', 30)),
120
+ 'fea544f918248f710be172bcadfdf1e6506ca2f4e08ab97c6099bd3afed1d316', '45dc2201b9e92efb2713282781ece6605f208855240b7f3087892275fcc5e4c1',
121
+ 'sphynx.invocation-holdout.v1', '0a1b1a81e58db5c4b442c834fe40f458b738d3fdd296b5be873fd6af269aa32b', 'nhq-', 'previously_exercised_regression', True),
122
+ InvocationBank('narrow-gate-20260925-r4', 'narrow-gate-20260925-r4', 'narrow-gate-20260925-r4', 90, 90, 18, (('CALL_RAG', 30), ('ASK_BUYER', 30), ('ANSWER_FROM_CONTEXT', 30)),
123
+ '2af3d98cb92146511e85de1a6c038d01f4bf7c117b622c61c6425a03f6e4a391', '7b593b9838e0bbfd3a74f359ddefe9eed7af476f0a914040ccff8889352750d5',
124
+ 'sphynx.invocation-holdout.v1', 'bd945b7961e662af377b3a933cc7c92cbbe448442724d3b9fac0965bb4a9cedf', 'nhq-', 'previously_exercised_regression'),
125
+ InvocationBank('narrow-gate-20260925-r5', 'narrow-gate-20260925-r5', 'narrow-gate-20260925-r5', 90, 90, 18, (('CALL_RAG', 30), ('ASK_BUYER', 30), ('ANSWER_FROM_CONTEXT', 30)),
126
+ 'c230ad79a9aba6cef17266b83345d273e93eba0871408ce23f87d59e27543194', '7b593b9838e0bbfd3a74f359ddefe9eed7af476f0a914040ccff8889352750d5',
127
+ 'sphynx.invocation-holdout.v1', '66638d4679cb8858eec24d170a5f36ac60954718dca29c8c7b6d7f472feb0146', 'nhq-', 'previously_exercised_regression'),
128
+ InvocationBank('six-customer-v1-20260918', 'v1-20260918', 'six-customer-v1-20260918', 360, 360, 60, (('CALL_RAG', 120), ('ASK_BUYER', 120), ('ANSWER_FROM_CONTEXT', 120)),
129
+ '797cf8793b901a1f87460bf079216a48385ac31bcb965210e588251ef430694b', '92db1243cd6decaa2ca84ee1302ec07b22d08af4235c8946fcd86522e0ea4977',
130
+ 'sable.invocation-question-corpus.v1', 'dbce75ef669d09156598d0bfc822495eeb8c2572802521ff7b4ab1903a44d5d3', 'invq-', 'six_customer_authored_development', True, tuple(sorted(_COMPANIES | {"atlassian"}))),
131
+ InvocationBank('six-customer-v1-20260918-r2', 'v1-20260918-r2', 'six-customer-v1-20260918-r2', 360, 360, 60, (('CALL_RAG', 120), ('ASK_BUYER', 120), ('ANSWER_FROM_CONTEXT', 120)),
132
+ 'd4f60c0046b9c55fc992dacc699ffbb8743c2c331dfc6ce73f5601be1c6c78fb', 'c21dd65089f2a1a06f891e9f2c618fda4f69ec8b6d67ea42a83afac7bdff9e29',
133
+ 'sable.invocation-question-corpus.v1', 'dbce75ef669d09156598d0bfc822495eeb8c2572802521ff7b4ab1903a44d5d3', 'invq-', 'six_customer_authored_development', True, tuple(sorted(_COMPANIES | {"atlassian"}))),
134
+ InvocationBank('six-customer-v1-20260918-r3', 'v1-20260918-r3', 'six-customer-v1-20260918-r3', 360, 360, 60, (('CALL_RAG', 120), ('ASK_BUYER', 120), ('ANSWER_FROM_CONTEXT', 120)),
135
+ '80336bade48c6bc1acadbae8c66e321d68008e0ee6d2d18fd473e56d566319a6', 'b886cc3cb572249018e94656ceeff796c576dc7bc5162b30e90d9ed039a02472',
136
+ 'sable.invocation-question-corpus.v1', 'fca2e2236016983de5f8cf579d488b20b07a8a2f32e1fc9cb459e6ef124e6f92', 'invq-', 'six_customer_authored_development', True, tuple(sorted(_COMPANIES | {"atlassian"}))),
137
+ )
138
+
139
+ def list_invocation_banks() -> tuple[InvocationBank, ...]:
140
+ """List supported releases without loading or downloading private data."""
141
+
142
+ return _BANKS
143
+
144
+
145
+ def _sha(data: bytes) -> str:
146
+ return hashlib.sha256(data).hexdigest()
147
+
148
+
149
+ def _write_json(path: Path, value: object) -> str:
150
+ data = (canonical_json(value) + "\n").encode("utf-8")
151
+ path.parent.mkdir(parents=True, exist_ok=True)
152
+ with path.open("xb") as stream:
153
+ stream.write(data)
154
+ return _sha(data)
155
+
156
+
157
+ def _read_json(path: Path) -> dict[str, Any]:
158
+ try:
159
+ value = json.loads(path.read_text(encoding="utf-8"))
160
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
161
+ raise InvocationError(f"cannot read {path.name}: {exc}") from exc
162
+ if not isinstance(value, dict):
163
+ raise InvocationError(f"{path.name} must contain an object")
164
+ return value
165
+
166
+
167
+ def _read_jsonl_bytes(data: bytes, label: str) -> list[dict[str, Any]]:
168
+ try:
169
+ rows = [json.loads(line) for line in data.decode("utf-8").splitlines() if line]
170
+ except (UnicodeError, json.JSONDecodeError) as exc:
171
+ raise InvocationError(f"{label} is not valid UTF-8 JSONL") from exc
172
+ if any(not isinstance(row, dict) for row in rows):
173
+ raise InvocationError(f"{label} must contain JSON objects")
174
+ return rows
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class InvocationCase:
179
+ case_id: str
180
+ company_id: str
181
+ user_turn: str
182
+ conversation_context: tuple[dict[str, str], ...]
183
+
184
+ def request(self) -> dict[str, Any]:
185
+ # Explicit allowlist: never serialize the gold row or a source anchor.
186
+ return {
187
+ "schema_version": REQUEST_VERSION,
188
+ "pack_id": PACK_ID,
189
+ "case_id": self.case_id,
190
+ "company_id": self.company_id,
191
+ "user_turn": self.user_turn,
192
+ "conversation_context": [dict(turn) for turn in self.conversation_context],
193
+ }
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class InvocationPack:
198
+ cases: tuple[InvocationCase, ...]
199
+ gold: dict[str, str]
200
+ input_sha256: str
201
+ gold_sha256: str
202
+ bank_id: str = "excerpt-free-200"
203
+ bank_release: str = "v1-development-200"
204
+ population: str = "authored_development"
205
+ contains_source_excerpt_controls: bool = False
206
+
207
+ @property
208
+ def cohort_sha256(self) -> str:
209
+ return _sha(canonical_json([
210
+ {"request": case.request(), "gold_action": self.gold[case.case_id]}
211
+ for case in self.cases
212
+ ]).encode("utf-8"))
213
+
214
+ def identity(self) -> dict[str, Any]:
215
+ return {
216
+ "bank_id": self.bank_id, "bank_release": self.bank_release,
217
+ "cohort_sha256": self.cohort_sha256, "population": self.population,
218
+ "contains_source_excerpt_controls": self.contains_source_excerpt_controls,
219
+ "label_status": "provisional_no_independent_human_review",
220
+ }
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class InvocationRunRef:
225
+ run_id: str
226
+ path: Path
227
+ pack_id: str = PACK_ID
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class InvocationEvaluationRef:
232
+ evaluation_id: str
233
+ path: Path
234
+ run_id: str
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class InvocationScorecard:
239
+ data: dict[str, Any]
240
+
241
+ def to_json(self) -> str:
242
+ return canonical_json(self.data)
243
+
244
+ def to_dict(self) -> dict[str, Any]:
245
+ return json.loads(self.to_json())
246
+
247
+ def to_markdown(self) -> str:
248
+ data = self.data
249
+ lines = [
250
+ "# Sphynx RAG invocation development scorecard", "",
251
+ "**LOCAL_UNVERIFIED — authored questions; no independent human label review.**", "",
252
+ f"- Pack: `{PACK_ID}`; candidate: `{data['candidate']['name']}` `{data['candidate']['version']}`",
253
+ f"- Cases: {data['case_count']}; successful: {data['success_count']}; failed: {data['failure_count']}",
254
+ f"- Bank: `{data.get('bank_id', 'excerpt-free-200')}`; population: `{data.get('population', 'authored_development')}`",
255
+ ("- No ANSWER_FROM_CONTEXT gold cases; that metric is undefined."
256
+ if data['metrics']['answer_from_context_accuracy']['eligible_count'] == 0
257
+ else "- Includes ANSWER_FROM_CONTEXT gold cases; that metric is graded."),
258
+ ("- Source-excerpt controls are supplied conversation context, not retrieved evidence."
259
+ if data.get('contains_source_excerpt_controls') else "- Labels and source anchors stay outside candidate requests."), "",
260
+ "| Metric | Value | Eligible | Observed | Missing |", "| --- | ---: | ---: | ---: | ---: |",
261
+ ]
262
+ for metric, item in data["metrics"].items():
263
+ value = "null" if item["value"] is None else f"{item['value']:.6f}"
264
+ lines.append(f"| {metric} | {value} | {item['eligible_count']} | {item['observed_count']} | {item['missing_count']} |")
265
+ lines.extend(["", "| Customer | Three-way accuracy | Observed | Missing |", "| --- | ---: | ---: | ---: |"])
266
+ for customer, item in data["customer_slices"].items():
267
+ value = "null" if item["value"] is None else f"{item['value']:.6f}"
268
+ lines.append(f"| {customer} | {value} | {item['observed_count']} | {item['missing_count']} |")
269
+ return "\n".join(lines) + "\n"
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class InvocationComparison:
274
+ data: dict[str, Any]
275
+
276
+ def to_json(self) -> str:
277
+ return canonical_json(self.data)
278
+
279
+ def to_dict(self) -> dict[str, Any]:
280
+ return json.loads(self.to_json())
281
+
282
+ def to_markdown(self) -> str:
283
+ data = self.data
284
+ value = data["mean_delta_b_minus_a"]
285
+ return (
286
+ "# Sphynx RAG invocation development comparison\n\n"
287
+ "**LOCAL_UNVERIFIED — paired authored development cases.**\n\n"
288
+ f"- A: `{data['evaluation_a']}`; B: `{data['evaluation_b']}`\n"
289
+ f"- Bank: `{data.get('bank_id', 'excerpt-free-200')}`\n"
290
+ f"- Paired: {data['paired_count']}/{data['eligible_count']}; missing: {data['missing_pair_count']}\n"
291
+ f"- Three-way accuracy mean B − A: {'null' if value is None else format(value, '.6f')}\n"
292
+ )
293
+
294
+
295
+ def load_invocation_pack(
296
+ data_dir: str | os.PathLike[str], *, bank_id: str | None = None,
297
+ ) -> InvocationPack:
298
+ """Load a supported hash-pinned bank; infer its identity when omitted.
299
+
300
+ The 300-case bank selects the five customers from the pinned 360-row source.
301
+ Six-customer releases require explicit bank_id selection. Every revised
302
+ source uses its own pinned release; arbitrary subsets are unsupported.
303
+ """
304
+
305
+ root = Path(data_dir).expanduser().resolve()
306
+ try:
307
+ input_bytes = (root / "model-input.jsonl").read_bytes()
308
+ gold_bytes = (root / "gold.jsonl").read_bytes()
309
+ except OSError as exc:
310
+ raise InvocationError(f"local invocation corpus is unavailable: {exc}") from exc
311
+ if bank_id is not None and bank_id not in {bank.bank_id for bank in _BANKS}:
312
+ raise InvocationError(f"unknown invocation bank {bank_id!r}; use list_invocation_banks()")
313
+ matches = [bank for bank in _BANKS
314
+ if ((bank_id is None and not bank.bank_id.startswith("six-customer-")) or bank.bank_id == bank_id)
315
+ and _sha(input_bytes) == bank.input_sha256 and _sha(gold_bytes) == bank.gold_sha256]
316
+ if len(matches) != 1:
317
+ raise InvocationError("local invocation corpus differs from a supported pinned bank or selected bank_id")
318
+ bank = matches[0]
319
+ manifest = _read_json(root / "manifest.json")
320
+ if bank.manifest_sha256 is not None:
321
+ try:
322
+ manifest_digest = _sha((root / "manifest.json").read_bytes())
323
+ except OSError as exc:
324
+ raise InvocationError("invocation manifest is unavailable") from exc
325
+ if manifest_digest != bank.manifest_sha256:
326
+ raise InvocationError("invocation manifest differs from the pinned bank release")
327
+ count_field = "question_count" if bank.source_schema == "sable.invocation-question-corpus.v1" else "case_count"
328
+ if (manifest.get("schema_version") != bank.source_schema
329
+ or type(manifest.get(count_field)) is not int
330
+ or manifest.get(count_field) != bank.source_case_count
331
+ or (count_field == "case_count" and (
332
+ manifest.get("model_input_sha256") != bank.input_sha256
333
+ or manifest.get("gold_sha256") != bank.gold_sha256))):
334
+ raise InvocationError("invocation manifest does not match the pinned release")
335
+ inputs = _read_jsonl_bytes(input_bytes, "model input")
336
+ labels = _read_jsonl_bytes(gold_bytes, "gold")
337
+ if len(inputs) != bank.source_case_count or len(labels) != bank.source_case_count:
338
+ raise InvocationError("input and gold counts differ from the pinned source release")
339
+ cases: list[InvocationCase] = []
340
+ input_by_id: dict[str, dict[str, Any]] = {}
341
+ selected_companies = frozenset(bank.companies)
342
+ source_companies = selected_companies | {"atlassian"} if bank.source_case_count != bank.case_count else selected_companies
343
+ for row in inputs:
344
+ if set(row) != {"question_id", "company_id", "user_turn", "conversation_context"}:
345
+ raise InvocationError("model input fields differ from the allowlisted contract")
346
+ case_id, company, question, context = (row[key] for key in ("question_id", "company_id", "user_turn", "conversation_context"))
347
+ if (not isinstance(case_id, str) or not _ID.fullmatch(case_id)
348
+ or not case_id.startswith(bank.id_prefix) or case_id in input_by_id):
349
+ raise InvocationError("invalid question ID")
350
+ if not isinstance(company, str) or company not in source_companies or not isinstance(question, str) or not question.strip():
351
+ raise InvocationError("invalid company or question")
352
+ if not isinstance(context, list) or any(
353
+ not isinstance(turn, dict) or set(turn) != {"role", "content"}
354
+ or not isinstance(turn["role"], str) or not isinstance(turn["content"], str)
355
+ for turn in context
356
+ ):
357
+ raise InvocationError("invalid conversation context")
358
+ input_by_id[case_id] = row
359
+ if company in selected_companies:
360
+ cases.append(InvocationCase(case_id, company, question, tuple(context)))
361
+ ids = [case.case_id for case in cases]
362
+ if (len(cases) != bank.case_count
363
+ or Counter(case.company_id for case in cases) != {company: bank.cases_per_company for company in selected_companies}
364
+ or Counter(row["company_id"] for row in inputs) != {company: bank.cases_per_company for company in source_companies}):
365
+ raise InvocationError("invocation case IDs or customer distribution differ from release")
366
+ gold: dict[str, str] = {}
367
+ source_gold_ids: set[str] = set()
368
+ for row in labels:
369
+ if bank.bank_id == "excerpt-free-200" and set(row) != {"question_id", "company_id", "family_id", "final_action", "provenance"}:
370
+ raise InvocationError("gold row fields differ from the pinned contract")
371
+ case_id = row.get("question_id")
372
+ if (not isinstance(case_id, str) or case_id in source_gold_ids
373
+ or case_id not in input_by_id or row.get("company_id") != input_by_id[case_id]["company_id"]):
374
+ raise InvocationError("invalid, duplicate, or misaligned gold identity")
375
+ source_gold_ids.add(case_id)
376
+ if count_field == "question_count":
377
+ reference = row.get("gold")
378
+ if not isinstance(reference, dict) or any(
379
+ row.get(key) != input_by_id[case_id][key] for key in ("user_turn", "conversation_context")
380
+ ):
381
+ raise InvocationError("gold context differs from the pinned model input")
382
+ source_action = reference.get("invocation_action")
383
+ else:
384
+ source_action = row.get("final_action")
385
+ if not isinstance(source_action, str) or source_action not in _SOURCE_ACTIONS.keys() | _ACTIONS:
386
+ raise InvocationError("invalid or duplicate gold action")
387
+ if row["company_id"] in selected_companies:
388
+ gold[case_id] = _SOURCE_ACTIONS.get(source_action, source_action)
389
+ if (source_gold_ids != set(input_by_id) or set(gold) != set(ids)
390
+ or Counter(gold.values()) != dict(bank.action_counts)):
391
+ raise InvocationError("gold IDs or action counts differ from release")
392
+ return InvocationPack(
393
+ tuple(cases), gold, bank.input_sha256, bank.gold_sha256,
394
+ bank.bank_id, bank.release, bank.population, bank.contains_source_excerpt_controls,
395
+ )
396
+
397
+
398
+ def _parse_response(stdout: bytes, case_id: str) -> str:
399
+ if len(stdout) > MAX_STREAM_BYTES:
400
+ raise InvocationError("candidate output exceeds 1 MiB")
401
+ def unique_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
402
+ result: dict[str, Any] = {}
403
+ for key, item in pairs:
404
+ if key in result:
405
+ raise ValueError(f"duplicate response field: {key}")
406
+ result[key] = item
407
+ return result
408
+
409
+ def invalid_constant(value: str) -> None:
410
+ raise ValueError(f"non-finite JSON constant: {value}")
411
+
412
+ try:
413
+ value = json.loads(stdout.decode("utf-8"), object_pairs_hook=unique_fields, parse_constant=invalid_constant)
414
+ except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
415
+ raise InvocationError("candidate output is not one JSON object") from exc
416
+ if not isinstance(value, dict) or set(value) != {"schema_version", "case_id", "action"}:
417
+ raise InvocationError("candidate response fields must be schema_version, case_id, action")
418
+ if (value["schema_version"] != RESPONSE_VERSION or value["case_id"] != case_id
419
+ or not isinstance(value["action"], str) or value["action"] not in _ACTIONS):
420
+ raise InvocationError("candidate response version, case ID, or action is invalid")
421
+ return value["action"]
422
+
423
+
424
+ def _execute_candidate(candidate: CandidateSpec, request_bytes: bytes, timeout_seconds: float) -> tuple[bytes, bytes, int | None, str | None, float]:
425
+ """Bound stdout/stderr retained in memory while draining to avoid pipe stalls."""
426
+
427
+ start = time.monotonic_ns()
428
+ stdout = _BoundedStream()
429
+ stderr = _BoundedStream()
430
+ sent = _InputWrite()
431
+ process: subprocess.Popen[bytes] | None = None
432
+ exit_code: int | None = None
433
+ failure: str | None = None
434
+ try:
435
+ process = subprocess.Popen(
436
+ candidate.argv, shell=False, stdin=subprocess.PIPE,
437
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
438
+ env=_candidate_environment(), bufsize=0,
439
+ )
440
+ assert process.stdin is not None and process.stdout is not None and process.stderr is not None
441
+ readers = [
442
+ threading.Thread(target=stdout.drain, args=(process.stdout,), daemon=True),
443
+ threading.Thread(target=stderr.drain, args=(process.stderr,), daemon=True),
444
+ ]
445
+ for reader in readers:
446
+ reader.start()
447
+ writer = threading.Thread(target=sent.send, args=(process.stdin, request_bytes), daemon=True)
448
+ writer.start()
449
+ try:
450
+ elapsed = (time.monotonic_ns() - start) / 1_000_000_000
451
+ exit_code = process.wait(timeout=max(0.0, timeout_seconds - elapsed))
452
+ except subprocess.TimeoutExpired:
453
+ failure = "timeout"
454
+ process.kill()
455
+ process.wait(timeout=5)
456
+ writer.join(timeout=1)
457
+ for reader in readers:
458
+ reader.join(timeout=1)
459
+ if failure is None:
460
+ if not sent.complete:
461
+ failure = "input_write_error"
462
+ elif exit_code != 0:
463
+ failure = f"candidate_exit_{exit_code}"
464
+ elif stdout.capped or not stdout.eof or stdout.error:
465
+ failure = "output_truncated"
466
+ except OSError as exc:
467
+ failure = f"spawn_error:{type(exc).__name__}"
468
+ finally:
469
+ if process is not None:
470
+ for stream in (process.stdin, process.stdout, process.stderr):
471
+ if stream is not None:
472
+ try:
473
+ stream.close()
474
+ except OSError:
475
+ pass
476
+ return bytes(stdout.retained), bytes(stderr.retained), exit_code, failure, (time.monotonic_ns() - start) / 1_000_000
477
+
478
+
479
+ def run_invocation(
480
+ candidate: CandidateSpec,
481
+ data_dir: str | os.PathLike[str],
482
+ output_dir: str | os.PathLike[str],
483
+ *,
484
+ timeout_seconds: float = 30.0,
485
+ bank_id: str | None = None,
486
+ ) -> InvocationRunRef:
487
+ """Run every case in the selected pinned bank through a local subprocess."""
488
+
489
+ if not isinstance(candidate, CandidateSpec) or candidate.interface_version != INTERFACE_VERSION:
490
+ raise ContractError(f"candidate must use {INTERFACE_VERSION}")
491
+ if type(timeout_seconds) not in (int, float) or not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
492
+ raise InvocationError("timeout_seconds must be positive and finite")
493
+ pack = load_invocation_pack(data_dir, bank_id=bank_id)
494
+ run_id = "invoke-run-" + uuid.uuid4().hex
495
+ run_path = Path(output_dir).expanduser().resolve() / "runs" / run_id
496
+ run_path.mkdir(parents=True, exist_ok=False)
497
+ captures: list[dict[str, str]] = []
498
+ for case in pack.cases:
499
+ request = case.request()
500
+ request_bytes = (canonical_json(request) + "\n").encode("utf-8")
501
+ action: str | None = None
502
+ stdout, stderr, exit_code, failure, elapsed_ms = _execute_candidate(candidate, request_bytes, timeout_seconds)
503
+ if failure is None:
504
+ try:
505
+ action = _parse_response(stdout, case.case_id)
506
+ except InvocationError as exc:
507
+ failure = str(exc)
508
+ capture = {
509
+ "schema_version": "sphynx.rag.invoke.capture.v1",
510
+ "case_id": case.case_id,
511
+ "request": request,
512
+ "request_sha256": _sha(request_bytes),
513
+ "stdout_utf8": stdout.decode("utf-8", errors="replace"),
514
+ "stderr_utf8": stderr.decode("utf-8", errors="replace"),
515
+ "exit_code": exit_code,
516
+ "action": action,
517
+ "status": "success" if action is not None else "failure",
518
+ "failure": failure,
519
+ "runner_latency_ms": elapsed_ms,
520
+ }
521
+ digest = _write_json(run_path / "captures" / f"{case.case_id}.json", capture)
522
+ captures.append({"case_id": case.case_id, "sha256": digest})
523
+ manifest = {
524
+ "schema_version": "sphynx.rag.invoke.run.v1",
525
+ "run_id": run_id,
526
+ "pack_id": PACK_ID,
527
+ "input_sha256": pack.input_sha256,
528
+ "gold_sha256": pack.gold_sha256,
529
+ "candidate": {
530
+ "name": candidate.name,
531
+ "version": candidate.version,
532
+ "interface_version": candidate.interface_version,
533
+ "argv_sha256": _sha(canonical_json(list(candidate.argv)).encode("utf-8")),
534
+ },
535
+ "execution": {"trials": 1, "timeout_seconds": timeout_seconds, "environment_policy": "minimal-candidate-runtime.v1"},
536
+ "runner": {"release": "rag.invoke.runner.v1", "python_version": platform.python_version(), "platform": sys.platform},
537
+ "trust_tier": "local_unverified",
538
+ "case_count": len(pack.cases),
539
+ "captures": captures,
540
+ **pack.identity(),
541
+ }
542
+ _write_json(run_path / "manifest.json", manifest)
543
+ return InvocationRunRef(run_id, run_path)
544
+
545
+
546
+ def _verified_run(ref: InvocationRunRef | str | os.PathLike[str], pack: InvocationPack) -> tuple[dict[str, Any], list[dict[str, Any]], Path]:
547
+ path = Path(ref.path if isinstance(ref, InvocationRunRef) else ref).expanduser().resolve()
548
+ if isinstance(ref, InvocationRunRef) and (ref.run_id != path.name or ref.pack_id != PACK_ID):
549
+ raise InvocationError("run reference differs from its directory")
550
+ manifest = _read_json(path / "manifest.json")
551
+ if (not _RUN_ID.fullmatch(path.name) or manifest.get("run_id") != path.name
552
+ or manifest.get("schema_version") != "sphynx.rag.invoke.run.v1"
553
+ or manifest.get("pack_id") != PACK_ID
554
+ or manifest.get("input_sha256") != pack.input_sha256
555
+ or manifest.get("gold_sha256") != pack.gold_sha256
556
+ or manifest.get("case_count") != len(pack.cases)
557
+ or manifest.get("trust_tier") != "local_unverified"):
558
+ raise InvocationError("run identity differs from the pinned invocation pack")
559
+ identity = pack.identity()
560
+ if any(key in manifest for key in identity):
561
+ if any(manifest.get(key) != value for key, value in identity.items()):
562
+ raise InvocationError("run bank identity differs from the pinned invocation cohort")
563
+ elif pack.bank_id != "excerpt-free-200":
564
+ raise InvocationError("run is missing its bank identity")
565
+ candidate = manifest.get("candidate")
566
+ execution = manifest.get("execution")
567
+ runner = manifest.get("runner")
568
+ if (not isinstance(candidate, dict) or set(candidate) != {"name", "version", "interface_version", "argv_sha256"}
569
+ or candidate.get("interface_version") != INTERFACE_VERSION
570
+ or not isinstance(execution, dict)
571
+ or set(execution) != {"trials", "timeout_seconds", "environment_policy"}
572
+ or execution.get("trials") != 1
573
+ or type(execution.get("timeout_seconds")) not in (int, float)
574
+ or not math.isfinite(execution["timeout_seconds"])
575
+ or execution["timeout_seconds"] <= 0
576
+ or execution.get("environment_policy") != "minimal-candidate-runtime.v1"
577
+ or not isinstance(runner, dict)
578
+ or set(runner) != {"release", "python_version", "platform"}
579
+ or runner.get("release") != "rag.invoke.runner.v1"):
580
+ raise InvocationError("run candidate, execution, or runner identity is invalid")
581
+ entries = manifest.get("captures")
582
+ if not isinstance(entries, list) or [item.get("case_id") for item in entries if isinstance(item, dict)] != [case.case_id for case in pack.cases]:
583
+ raise InvocationError("run capture inventory differs from the pack")
584
+ if {item.name for item in (path / "captures").iterdir()} != {f"{case.case_id}.json" for case in pack.cases}:
585
+ raise InvocationError("run capture directory has missing or unindexed files")
586
+ captures: list[dict[str, Any]] = []
587
+ for case, entry in zip(pack.cases, entries):
588
+ capture_path = path / "captures" / f"{case.case_id}.json"
589
+ try:
590
+ raw = capture_path.read_bytes()
591
+ except OSError as exc:
592
+ raise InvocationError(f"capture missing for {case.case_id}") from exc
593
+ if set(entry) != {"case_id", "sha256"} or _sha(raw) != entry["sha256"]:
594
+ raise InvocationError(f"capture hash mismatch for {case.case_id}")
595
+ capture = _read_json(capture_path)
596
+ expected = case.request()
597
+ if (capture.get("schema_version") != "sphynx.rag.invoke.capture.v1"
598
+ or capture.get("case_id") != case.case_id or capture.get("request") != expected
599
+ or capture.get("request_sha256") != _sha((canonical_json(expected) + "\n").encode("utf-8"))):
600
+ raise InvocationError(f"capture request differs from pinned case {case.case_id}")
601
+ if not isinstance(capture.get("stdout_utf8"), str) or not isinstance(capture.get("stderr_utf8"), str):
602
+ raise InvocationError(f"capture streams invalid for {case.case_id}")
603
+ if capture.get("status") == "success":
604
+ if capture.get("failure") is not None or capture.get("exit_code") != 0 or capture.get("action") != _parse_response(capture["stdout_utf8"].encode("utf-8"), case.case_id):
605
+ raise InvocationError(f"capture response mismatch for {case.case_id}")
606
+ elif capture.get("status") != "failure" or capture.get("action") is not None or not isinstance(capture.get("failure"), str):
607
+ raise InvocationError(f"capture terminal status invalid for {case.case_id}")
608
+ latency = capture.get("runner_latency_ms")
609
+ if type(latency) not in (int, float) or not math.isfinite(latency) or latency < 0:
610
+ raise InvocationError(f"capture latency invalid for {case.case_id}")
611
+ captures.append(capture)
612
+ return manifest, captures, path
613
+
614
+
615
+ def _metric(observations: list[float | None], *, lower: bool = False) -> dict[str, Any]:
616
+ valid = [value for value in observations if value is not None]
617
+ missing = len(observations) - len(valid)
618
+ return {
619
+ "value": sum(valid) / len(valid) if valid and missing == 0 else None,
620
+ "observed_value": sum(valid) / len(valid) if valid else None,
621
+ "eligible_count": len(observations), "observed_count": len(valid),
622
+ "missing_count": missing, "complete": bool(valid) and missing == 0,
623
+ "direction": "lower" if lower else "higher",
624
+ }
625
+
626
+
627
+ def _percentile(sorted_values: list[float], fraction: float) -> float:
628
+ position = (len(sorted_values) - 1) * fraction
629
+ lower = math.floor(position)
630
+ upper = math.ceil(position)
631
+ return sorted_values[lower] + (sorted_values[upper] - sorted_values[lower]) * (position - lower)
632
+
633
+
634
+ def _judgments(pack: InvocationPack, captures: list[dict[str, Any]]) -> list[dict[str, Any]]:
635
+ rows: list[dict[str, Any]] = []
636
+ for case, capture in zip(pack.cases, captures):
637
+ gold = pack.gold[case.case_id]
638
+ action = capture["action"]
639
+ rows.append({
640
+ "case_id": case.case_id, "company_id": case.company_id,
641
+ "gold_action": gold, "candidate_action": action,
642
+ "correct": (action == gold) if action is not None else None,
643
+ "status": capture["status"], "runner_latency_ms": capture["runner_latency_ms"],
644
+ })
645
+ return rows
646
+
647
+
648
+ def _score(pack: InvocationPack, run_manifest: dict[str, Any], judgments: list[dict[str, Any]], eval_id: str) -> dict[str, Any]:
649
+ metrics = {
650
+ "three_way_accuracy": _metric([float(row["correct"]) if row["correct"] is not None else None for row in judgments]),
651
+ "required_lookup_recall": _metric([float(row["candidate_action"] == "CALL_RAG") if row["candidate_action"] is not None else None for row in judgments if row["gold_action"] == "CALL_RAG"]),
652
+ "unnecessary_call_rate": _metric([float(row["candidate_action"] == "CALL_RAG") if row["candidate_action"] is not None else None for row in judgments if row["gold_action"] != "CALL_RAG"], lower=True),
653
+ "clarification_accuracy": _metric([float(row["candidate_action"] == "ASK_BUYER") if row["candidate_action"] is not None else None for row in judgments if row["gold_action"] == "ASK_BUYER"]),
654
+ "answer_from_context_accuracy": _metric([float(row["candidate_action"] == "ANSWER_FROM_CONTEXT") if row["candidate_action"] is not None else None for row in judgments if row["gold_action"] == "ANSWER_FROM_CONTEXT"]),
655
+ }
656
+ latencies = sorted(row["runner_latency_ms"] for row in judgments)
657
+ return {
658
+ "schema_version": "sphynx.rag.invoke.metrics.v1", "evaluation_id": eval_id,
659
+ "run_id": run_manifest["run_id"], "pack_id": PACK_ID, "metric_release": METRIC_RELEASE,
660
+ "grader_release": GRADER_RELEASE, "trust_tier": "local_unverified",
661
+ "development_only": True, "input_sha256": pack.input_sha256,
662
+ "gold_sha256": pack.gold_sha256, "candidate": run_manifest["candidate"],
663
+ "execution": run_manifest["execution"], "runner": run_manifest["runner"],
664
+ "case_count": len(pack.cases),
665
+ "success_count": sum(row["status"] == "success" for row in judgments),
666
+ "failure_count": sum(row["status"] != "success" for row in judgments),
667
+ "metrics": metrics,
668
+ "runner_latency_ms": {"p50": _percentile(latencies, 0.5), "p95": _percentile(latencies, 0.95)},
669
+ "customer_slices": {
670
+ company: _metric([float(row["correct"]) if row["correct"] is not None else None for row in judgments if row["company_id"] == company])
671
+ for company in sorted({case.company_id for case in pack.cases})
672
+ },
673
+ **(pack.identity() if "bank_id" in run_manifest else {}),
674
+ }
675
+
676
+
677
+ def grade_invocation(
678
+ run_ref: InvocationRunRef | str | os.PathLike[str],
679
+ data_dir: str | os.PathLike[str],
680
+ *, bank_id: str | None = None,
681
+ ) -> InvocationEvaluationRef:
682
+ """Grade saved captures locally; labels never enter candidate requests."""
683
+
684
+ pack = load_invocation_pack(data_dir, bank_id=bank_id)
685
+ run_manifest, captures, run_path = _verified_run(run_ref, pack)
686
+ judgments = _judgments(pack, captures)
687
+ eval_id = "invoke-eval-" + uuid.uuid4().hex
688
+ eval_path = run_path / "evaluations" / eval_id
689
+ eval_path.mkdir(parents=True, exist_ok=False)
690
+ score = _score(pack, run_manifest, judgments, eval_id)
691
+ judgment_bytes = "".join(canonical_json(row) + "\n" for row in judgments).encode("utf-8")
692
+ with (eval_path / "judgments.jsonl").open("xb") as stream:
693
+ stream.write(judgment_bytes)
694
+ score_hash = _write_json(eval_path / "metrics.json", score)
695
+ _write_json(eval_path / "manifest.json", {
696
+ "schema_version": "sphynx.rag.invoke.evaluation.v1", "evaluation_id": eval_id,
697
+ "run_id": run_path.name, "pack_id": PACK_ID,
698
+ "judgments_sha256": _sha(judgment_bytes), "metrics_sha256": score_hash,
699
+ **(pack.identity() if "bank_id" in run_manifest else {}),
700
+ })
701
+ return InvocationEvaluationRef(eval_id, eval_path, run_path.name)
702
+
703
+
704
+ def _verified_evaluation(
705
+ ref: InvocationEvaluationRef | str | os.PathLike[str],
706
+ data_dir: str | os.PathLike[str],
707
+ *, bank_id: str | None = None,
708
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
709
+ path = Path(ref.path if isinstance(ref, InvocationEvaluationRef) else ref).expanduser().resolve()
710
+ if isinstance(ref, InvocationEvaluationRef) and (ref.evaluation_id != path.name or ref.run_id != path.parent.parent.name):
711
+ raise InvocationError("evaluation reference differs from its directory")
712
+ manifest = _read_json(path / "manifest.json")
713
+ if (not _EVAL_ID.fullmatch(path.name) or manifest.get("evaluation_id") != path.name
714
+ or manifest.get("schema_version") != "sphynx.rag.invoke.evaluation.v1"
715
+ or manifest.get("pack_id") != PACK_ID or manifest.get("run_id") != path.parent.parent.name):
716
+ raise InvocationError("evaluation identity is invalid")
717
+ if {item.name for item in path.iterdir()} != {"manifest.json", "metrics.json", "judgments.jsonl"}:
718
+ raise InvocationError("evaluation file inventory is incomplete or has unindexed files")
719
+ try:
720
+ judgments_bytes = (path / "judgments.jsonl").read_bytes()
721
+ metrics_bytes = (path / "metrics.json").read_bytes()
722
+ except OSError as exc:
723
+ raise InvocationError("evaluation file is missing") from exc
724
+ if _sha(judgments_bytes) != manifest.get("judgments_sha256") or _sha(metrics_bytes) != manifest.get("metrics_sha256"):
725
+ raise InvocationError("evaluation hash mismatch")
726
+ pack = load_invocation_pack(data_dir, bank_id=bank_id)
727
+ identity = pack.identity()
728
+ if any(key in manifest for key in identity):
729
+ if any(manifest.get(key) != value for key, value in identity.items()):
730
+ raise InvocationError("evaluation bank identity differs from the pinned invocation cohort")
731
+ elif pack.bank_id != "excerpt-free-200":
732
+ raise InvocationError("evaluation is missing its bank identity")
733
+ score = _read_json(path / "metrics.json")
734
+ judgments = _read_jsonl_bytes(judgments_bytes, "judgments")
735
+ if (score.get("evaluation_id") != path.name or score.get("run_id") != manifest["run_id"]
736
+ or score.get("pack_id") != PACK_ID or score.get("metric_release") != METRIC_RELEASE
737
+ or score.get("grader_release") != GRADER_RELEASE or score.get("trust_tier") != "local_unverified"
738
+ or len(judgments) != len(pack.cases) or score.get("case_count") != len(pack.cases)):
739
+ raise InvocationError("evaluation release or count is invalid")
740
+ run_manifest, captures, _ = _verified_run(path.parent.parent, pack)
741
+ if ("bank_id" in manifest) != ("bank_id" in run_manifest):
742
+ raise InvocationError("evaluation and run bank identities differ")
743
+ expected_judgments = _judgments(pack, captures)
744
+ expected_score = _score(pack, run_manifest, expected_judgments, path.name)
745
+ if judgments != expected_judgments or score != expected_score:
746
+ raise InvocationError("evaluation differs from the pinned labels or verified source captures")
747
+ return score, judgments
748
+
749
+
750
+ def report_invocation(
751
+ ref: InvocationEvaluationRef | str | os.PathLike[str],
752
+ data_dir: str | os.PathLike[str],
753
+ *, bank_id: str | None = None,
754
+ ) -> InvocationScorecard:
755
+ """Read a hash-verified private development scorecard."""
756
+
757
+ score, _ = _verified_evaluation(ref, data_dir, bank_id=bank_id)
758
+ return InvocationScorecard(score)
759
+
760
+
761
+ def compare_invocation(
762
+ evaluation_a: InvocationEvaluationRef | str | os.PathLike[str],
763
+ evaluation_b: InvocationEvaluationRef | str | os.PathLike[str],
764
+ data_dir: str | os.PathLike[str],
765
+ *, bank_id: str | None = None,
766
+ ) -> InvocationComparison:
767
+ """Paired three-way comparison on exactly the same pinned local cohort."""
768
+
769
+ a, rows_a = _verified_evaluation(evaluation_a, data_dir, bank_id=bank_id)
770
+ b, rows_b = _verified_evaluation(evaluation_b, data_dir, bank_id=bank_id)
771
+ for key in ("pack_id", "input_sha256", "gold_sha256", "metric_release", "grader_release", "execution", "runner"):
772
+ if a.get(key) != b.get(key):
773
+ raise InvocationError(f"comparison identity mismatch: {key}")
774
+ if [(row["case_id"], row["gold_action"]) for row in rows_a] != [
775
+ (row["case_id"], row["gold_action"]) for row in rows_b
776
+ ]:
777
+ raise InvocationError("comparison case order or labels differ")
778
+ deltas = [float(right["correct"]) - float(left["correct"])
779
+ for left, right in zip(rows_a, rows_b)
780
+ if left["correct"] is not None and right["correct"] is not None]
781
+ return InvocationComparison({
782
+ "pack_id": PACK_ID, "evaluation_a": a["evaluation_id"],
783
+ "evaluation_b": b["evaluation_id"], "trust_tier": "local_unverified",
784
+ "bank_id": a.get("bank_id", "excerpt-free-200"),
785
+ "bank_release": a.get("bank_release", "v1-development-200"),
786
+ "population": a.get("population", "authored_development"),
787
+ "eligible_count": len(rows_a), "paired_count": len(deltas),
788
+ "missing_pair_count": len(rows_a) - len(deltas),
789
+ "mean_delta_b_minus_a": sum(deltas) / len(deltas) if len(deltas) == len(rows_a) else None,
790
+ "observed_mean_delta_b_minus_a": sum(deltas) / len(deltas) if deltas else None,
791
+ })