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.
- sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
- sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
- sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
- sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
- sphynx/__init__.py +469 -0
- sphynx/__init__.pyi +376 -0
- sphynx/_analytical_client.py +77 -0
- sphynx/_analytical_values.py +252 -0
- sphynx/_audit_client.py +173 -0
- sphynx/_comparison_adapters.py +113 -0
- sphynx/_componentized_full_rag.py +34 -0
- sphynx/_composed_pipeline.py +109 -0
- sphynx/_full_rag_grading.py +218 -0
- sphynx/_full_rag_trace.py +221 -0
- sphynx/_local_eval.py +268 -0
- sphynx/_pipeline_core.py +243 -0
- sphynx/_pr_client.py +237 -0
- sphynx/_rag_diagnostics_calibration.py +285 -0
- sphynx/_rag_diagnostics_catalog.py +39 -0
- sphynx/_rag_diagnostics_common.py +26 -0
- sphynx/_rag_diagnostics_contracts.py +186 -0
- sphynx/_rag_diagnostics_engine.py +243 -0
- sphynx/_rag_diagnostics_importers.py +242 -0
- sphynx/_rag_diagnostics_ranking.py +81 -0
- sphynx/_rag_diagnostics_semantic.py +325 -0
- sphynx/_rag_ranking.py +149 -0
- sphynx/_speech_benchmark_scoring.py +140 -0
- sphynx/_speech_benchmark_sources.py +275 -0
- sphynx/_speech_complete_scoring.py +218 -0
- sphynx/_speech_complete_source.py +118 -0
- sphynx/_speech_kido.py +294 -0
- sphynx/_speech_scoring.py +77 -0
- sphynx/_track_capture.py +213 -0
- sphynx/analytical_views.py +515 -0
- sphynx/assessment_contracts.py +378 -0
- sphynx/assessment_selection.py +57 -0
- sphynx/audit_access.py +245 -0
- sphynx/audit_contracts.py +204 -0
- sphynx/audit_requests.py +180 -0
- sphynx/authoring.py +188 -0
- sphynx/benchmarks.py +568 -0
- sphynx/candidates.py +62 -0
- sphynx/catalog.py +272 -0
- sphynx/chunking.py +372 -0
- sphynx/clickhouse_reader.py +169 -0
- sphynx/client.py +486 -0
- sphynx/comparison_core.py +149 -0
- sphynx/comparison_views.py +223 -0
- sphynx/components.py +1661 -0
- sphynx/contracts.py +514 -0
- sphynx/customer_rerank.py +299 -0
- sphynx/customer_retrieval.py +146 -0
- sphynx/data/benchmark-releases.v1.json +164 -0
- sphynx/data/public-benchmark-release.v1.json +354 -0
- sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
- sphynx/data/rag-paper-references.v1.json +176 -0
- sphynx/data/rag-paper-schemas.v1.json +1069 -0
- sphynx/data/rag-rerank-v1.json +507 -0
- sphynx/dataset_catalog_data.py +1214 -0
- sphynx/dataset_readiness.py +138 -0
- sphynx/dataset_v3.py +999 -0
- sphynx/demo.py +130 -0
- sphynx/demo_candidates/__init__.py +1 -0
- sphynx/demo_candidates/dense.py +11 -0
- sphynx/demo_candidates/lexical.py +21 -0
- sphynx/diagnostics.py +207 -0
- sphynx/documentation.py +737 -0
- sphynx/documentation_candidate.py +939 -0
- sphynx/evals.py +538 -0
- sphynx/evaluation_protocols.py +124 -0
- sphynx/evaluator_bindings.py +370 -0
- sphynx/evidence.py +352 -0
- sphynx/execution_capabilities.py +109 -0
- sphynx/fast_search.py +285 -0
- sphynx/full_rag.py +341 -0
- sphynx/hosted/__init__.py +18 -0
- sphynx/hosted/benchmark_intake.py +218 -0
- sphynx/hosted/contracts.py +361 -0
- sphynx/hosted/model_admission.py +130 -0
- sphynx/hosted/model_artifacts.py +298 -0
- sphynx/hosted/model_contracts.py +63 -0
- sphynx/hosted/model_gcs.py +181 -0
- sphynx/hosted/model_history.py +84 -0
- sphynx/hosted/model_session.py +233 -0
- sphynx/hosted/public_suite.py +74 -0
- sphynx/hosted/registry.py +97 -0
- sphynx/hosted/revisions.py +126 -0
- sphynx/hosted/sandbox.py +105 -0
- sphynx/hosted/submission.py +287 -0
- sphynx/hosted_workflow.py +451 -0
- sphynx/http_transport.py +86 -0
- sphynx/integrated_rag.py +352 -0
- sphynx/integrated_workflow.py +552 -0
- sphynx/integration_contracts.py +474 -0
- sphynx/invocation.py +791 -0
- sphynx/invocation_candidates.py +392 -0
- sphynx/invocation_catalog_data.py +40 -0
- sphynx/learned_catalog_data.py +30 -0
- sphynx/learned_rerank.py +267 -0
- sphynx/legacy_full_rag.py +67 -0
- sphynx/lifecycle.py +885 -0
- sphynx/lifecycle_workflow.py +490 -0
- sphynx/metric_views.py +474 -0
- sphynx/metrics.py +282 -0
- sphynx/model_adapters.py +438 -0
- sphynx/model_catalog_data.py +30 -0
- sphynx/official_benchmarks.py +385 -0
- sphynx/packs.py +258 -0
- sphynx/pipeline.py +313 -0
- sphynx/pipeline_workflow.py +551 -0
- sphynx/pr_evaluations.py +560 -0
- sphynx/progress.py +89 -0
- sphynx/public_bundle.py +173 -0
- sphynx/public_eval.py +62 -0
- sphynx/public_rerank.py +355 -0
- sphynx/py.typed +0 -0
- sphynx/pytest_plugin.py +62 -0
- sphynx/question_robustness.py +437 -0
- sphynx/rag.py +329 -0
- sphynx/rag_diagnostics.py +309 -0
- sphynx/rag_papers/__init__.py +29 -0
- sphynx/rag_papers/__main__.py +45 -0
- sphynx/rag_papers/candidates.py +19 -0
- sphynx/rag_papers/contracts.py +282 -0
- sphynx/rag_papers/datasets.py +291 -0
- sphynx/rag_papers/experiments.py +116 -0
- sphynx/rag_papers/judges.py +308 -0
- sphynx/rag_papers/registry.py +33 -0
- sphynx/rag_papers/runner.py +176 -0
- sphynx/rag_papers/sable.py +135 -0
- sphynx/rag_papers/schemas.py +12 -0
- sphynx/rag_papers/scoring.py +249 -0
- sphynx/rag_papers/semantic.py +288 -0
- sphynx/rag_papers/trec.py +80 -0
- sphynx/recorded_scores.py +352 -0
- sphynx/records.py +124 -0
- sphynx/reflex_traces.py +193 -0
- sphynx/report_rendering.py +71 -0
- sphynx/rerank_family_contracts.py +150 -0
- sphynx/rerank_workflow.py +424 -0
- sphynx/research_candidate.py +190 -0
- sphynx/results.py +852 -0
- sphynx/retrieval.py +750 -0
- sphynx/retrieval_candidates.py +295 -0
- sphynx/retrieval_catalog_data.py +19 -0
- sphynx/retrieval_recordings.py +125 -0
- sphynx/reviewer_audit.py +266 -0
- sphynx/routing_policies.py +375 -0
- sphynx/runner.py +544 -0
- sphynx/sable_rerank.py +489 -0
- sphynx/score_reliability.py +393 -0
- sphynx/speech_benchmarks.py +204 -0
- sphynx/speech_campaigns.py +378 -0
- sphynx/speech_complete.py +236 -0
- sphynx/speech_contracts.py +225 -0
- sphynx/speech_evidence.py +429 -0
- sphynx/speech_listening.py +115 -0
- sphynx/speech_multilingual.py +71 -0
- sphynx/speech_tts.py +479 -0
- sphynx/sql/001_sync_v1.sql +30 -0
- sphynx/stage_contracts.py +199 -0
- sphynx/statistical_catalog_data.py +53 -0
- sphynx/statistical_rerank.py +232 -0
- sphynx/sync.py +797 -0
- sphynx/workbench.py +378 -0
- sphynx/workbench_catalog_data.py +13 -0
- sphynx/workbench_tracks.py +78 -0
- sphynx/workflow_async.py +197 -0
- sphynx/workflow_contracts.py +594 -0
- sphynx/workflow_decisions.py +211 -0
- sphynx/workflow_defaults.py +16 -0
- sphynx/workflow_extensions.py +212 -0
- sphynx/workflow_registry.py +513 -0
- 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
|
+
})
|