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
@@ -0,0 +1,242 @@
1
+ """Offline, qrel-only BEIR/TREC import. No downloads, inference, or SDK imports.
2
+
3
+ This imports caller-held files and records exact byte receipts. Neither qrels
4
+ nor a caller's cluster mapping establish independent source answerability.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import math
11
+ from pathlib import Path
12
+ import re
13
+
14
+ from ._rag_diagnostics_common import digest
15
+ from ._rag_diagnostics_contracts import ContractError, validate_dataset
16
+
17
+ MAX_INPUT_BYTES = 20_000_000
18
+ MAX_OUTPUT_BYTES = 20_000_000
19
+ INPUT_KEYS = ('corpus', 'queries', 'qrels', 'baseline_run', 'candidate_run')
20
+ ORDER_POLICY = 'ascending_explicit_rank; equal_rank_retains_input_file_order; score_never_reorders_rows'
21
+
22
+
23
+ def _require(condition, message):
24
+ if not condition:
25
+ raise ContractError(message)
26
+
27
+
28
+ def _id(value, context):
29
+ _require(type(value) is str and 0 < len(value) <= 180 and not any(c.isspace() for c in value),
30
+ context + ': expected a nonempty ID without whitespace (maximum 180 characters)')
31
+ return value
32
+
33
+
34
+ def _pairs(items):
35
+ result = {}
36
+ for key, value in items:
37
+ _require(key not in result, 'Duplicate JSON object key')
38
+ result[key] = value
39
+ return result
40
+
41
+
42
+ def _json(raw):
43
+ def bad_constant(_):
44
+ raise ContractError('Nonfinite JSON number')
45
+ def finite_float(value):
46
+ number = float(value)
47
+ _require(math.isfinite(number), 'Nonfinite JSON number')
48
+ return number
49
+ try:
50
+ return json.loads(raw, object_pairs_hook=_pairs, parse_constant=bad_constant, parse_float=finite_float)
51
+ except (ValueError, UnicodeDecodeError) as exc:
52
+ if isinstance(exc, ContractError):
53
+ raise
54
+ raise ContractError('Invalid UTF-8 JSON input') from None
55
+
56
+
57
+ def _read(path, key, expected):
58
+ path = Path(path)
59
+ _require(path.is_file() and not path.is_symlink(), key + ': expected a regular local file')
60
+ _require(0 < path.stat().st_size <= MAX_INPUT_BYTES, key + ': empty or oversized input')
61
+ raw = path.read_bytes()
62
+ _require(0 < len(raw) <= MAX_INPUT_BYTES, key + ': empty or oversized input')
63
+ sha = hashlib.sha256(raw).hexdigest()
64
+ if expected is not None:
65
+ _require(expected == sha, key + ': input SHA256 differs from caller pin')
66
+ try:
67
+ text = raw.decode('utf-8')
68
+ except UnicodeDecodeError:
69
+ raise ContractError(key + ': invalid UTF-8') from None
70
+ return text, {'file_name': path.name, 'sha256': sha, 'size_bytes': len(raw)}
71
+
72
+
73
+ def _jsonl(text, *, corpus):
74
+ rows = {}
75
+ for line_number, line in enumerate(text.splitlines(), 1):
76
+ if not line.strip():
77
+ continue
78
+ row = _json(line)
79
+ _require(type(row) is dict, 'JSONL row must be an object')
80
+ identity = _id(row.get('_id'), 'JSONL _id')
81
+ _require(identity not in rows, 'Duplicate corpus or query ID')
82
+ _require(type(row.get('text')) is str and (corpus or bool(row['text'].strip())), 'JSONL text is required')
83
+ if corpus:
84
+ title = row.get('title', '')
85
+ _require(type(title) is str, 'Corpus title must be a string when supplied')
86
+ # Preserve exact original title/text in this immutable scoring unit.
87
+ rows[identity] = {'id': identity, 'text': (title + '\n' if title else '') + row['text']}
88
+ else:
89
+ rows[identity] = row['text']
90
+ _require(bool(rows), 'Corpus and queries must each contain rows')
91
+ _require(len(rows) <= (10000 if corpus else 5000), 'Input exceeds local prototype row limit')
92
+ return rows
93
+
94
+
95
+ def _qrels(text, corpus_ids, query_ids):
96
+ rows = {}
97
+ saw_content = False
98
+ for line in text.splitlines():
99
+ if not line.strip():
100
+ continue
101
+ fields = [part.strip() for part in line.split('\t')]
102
+ if not saw_content and fields == ['query-id', 'corpus-id', 'score']:
103
+ saw_content = True
104
+ continue
105
+ saw_content = True
106
+ _require(len(fields) == 3, 'Qrels require three tab-separated fields')
107
+ qid, did, value = fields
108
+ _id(qid, 'Qrel query ID'); _id(did, 'Qrel document ID')
109
+ _require(qid in query_ids, 'Qrels contain an unknown query ID')
110
+ _require(did in corpus_ids, 'Qrels contain an out-of-corpus document ID')
111
+ _require(re.fullmatch(r'[0-9]+', value) is not None and 0 <= int(value) <= 100,
112
+ 'Qrel grade must be an integer between 0 and 100')
113
+ _require(did not in rows.setdefault(qid, {}), 'Duplicate qrel query/document pair')
114
+ rows[qid][did] = int(value)
115
+ _require(set(rows) == set(query_ids), 'Qrels must cover exactly the supplied query population')
116
+ return rows
117
+
118
+
119
+ def _run(text, corpus_ids, query_ids):
120
+ rows, names, seen = {}, set(), set()
121
+ for line_number, line in enumerate(text.splitlines(), 1):
122
+ if not line.strip():
123
+ continue
124
+ fields = line.split()
125
+ _require(len(fields) == 6, 'TREC run requires qid Q0 docid rank score runname')
126
+ qid, marker, did, rank, score, run_name = fields
127
+ _id(qid, 'Run query ID'); _id(did, 'Run document ID'); _id(run_name, 'Run name')
128
+ _require(marker == 'Q0', 'TREC run second column must be Q0')
129
+ _require(qid in query_ids, 'TREC run contains an unknown query ID')
130
+ _require(did in corpus_ids, 'TREC run contains an out-of-corpus document ID')
131
+ _require((qid, did) not in seen, 'Duplicate TREC query/document pair')
132
+ seen.add((qid, did))
133
+ _require(re.fullmatch(r'[0-9]+', rank) is not None and 1 <= int(rank) <= 1000000,
134
+ 'TREC rank must be a positive integer')
135
+ try:
136
+ score_number = float(score)
137
+ except ValueError:
138
+ raise ContractError('TREC score must be finite') from None
139
+ _require(math.isfinite(score_number), 'TREC score must be finite')
140
+ names.add(run_name)
141
+ rows.setdefault(qid, []).append({'document_id': did, 'rank': int(rank),
142
+ 'score': score_number, 'input_line': line_number})
143
+ _require(set(rows) == set(query_ids), 'Each TREC run must cover exactly the supplied query population')
144
+ _require(len(names) == 1, 'Each TREC file must identify exactly one run')
145
+ for ranking in rows.values():
146
+ ranking.sort(key=lambda row: (row['rank'], row['input_line']))
147
+ return rows, next(iter(names))
148
+
149
+
150
+ def import_beir(corpus_path, queries_path, qrels_path, baseline_run_path,
151
+ candidate_run_path, *, release='external-beir-local.v1',
152
+ cluster_mapping_path=None, expected_sha256=None):
153
+ """Import paired caller-held rankings into a retrieval-only local dataset.
154
+
155
+ ``expected_sha256`` optionally pins all five inputs (plus ``clusters`` when
156
+ used). Without a mapping, every query shares one conservative cluster and
157
+ independence is explicitly false. A complete mapping is a caller assertion;
158
+ this offline importer cannot authenticate its independence or label review.
159
+ """
160
+ _id(release, 'Release')
161
+ paths = dict(zip(INPUT_KEYS, (corpus_path, queries_path, qrels_path, baseline_run_path, candidate_run_path)))
162
+ if cluster_mapping_path is not None:
163
+ paths['clusters'] = cluster_mapping_path
164
+ if expected_sha256 is not None:
165
+ _require(type(expected_sha256) is dict and set(expected_sha256) == set(paths),
166
+ 'Expected SHA256 pins must cover exactly all supplied inputs')
167
+ _require(all(type(value) is str and re.fullmatch(r'[0-9a-f]{64}', value)
168
+ for value in expected_sha256.values()), 'Expected SHA256 pins must be lowercase 64-character hex')
169
+ contents, receipts = {}, {}
170
+ for key, path in paths.items():
171
+ contents[key], receipts[key] = _read(path, key, expected_sha256.get(key) if expected_sha256 else None)
172
+ corpus = _jsonl(contents['corpus'], corpus=True)
173
+ queries = _jsonl(contents['queries'], corpus=False)
174
+ qrels = _qrels(contents['qrels'], corpus, queries)
175
+ runs, run_names = {}, {}
176
+ for variant in ('baseline', 'candidate'):
177
+ runs[variant], run_names[variant] = _run(contents[variant + '_run'], corpus, queries)
178
+ mapping = None
179
+ if cluster_mapping_path is not None:
180
+ mapping = _json(contents['clusters'])
181
+ _require(type(mapping) is dict and set(mapping) == set(queries),
182
+ 'Cluster mapping must cover exactly the supplied query population')
183
+ for qid, cluster in mapping.items():
184
+ _id(qid, 'Cluster query ID'); _id(cluster, 'Cluster ID')
185
+ corpus_bytes = len(json.dumps(list(corpus.values()), ensure_ascii=False, allow_nan=False).encode('utf-8'))
186
+ _require(corpus_bytes * len(queries) <= MAX_OUTPUT_BYTES,
187
+ 'Repeated corpus exceeds 20MB local limit; supply an explicit smaller cohort/corpus')
188
+ suite_id = 'external-beir-retrieval'
189
+ cases = []
190
+ for qid, question in queries.items():
191
+ case_id = 'beir-' + digest(qid)[:24]
192
+ traces = {}
193
+ for variant in ('baseline', 'candidate'):
194
+ traces[variant] = {'id': case_id + '/' + variant, 'execution_mode': 'recorded_replay',
195
+ 'stages': {'indexed': list(corpus), 'retrieved': [row['document_id'] for row in runs[variant][qid]],
196
+ 'reranked': None, 'selected': None, 'prompt': None},
197
+ 'response': {'status': 'ok', 'text': '', 'abstained': None},
198
+ 'operational': {'origin': 'component_reported', 'latency_ms': None, 'cost_usd': None, 'tokens': None},
199
+ 'source_run': {'name': run_names[variant], 'ranking': runs[variant][qid],
200
+ 'input_sha256': receipts[variant + '_run']['sha256']}}
201
+ cases.append({'id': case_id, 'suite_id': suite_id,
202
+ 'cluster_id': mapping[qid] if mapping is not None else 'unverified-all-imported-queries',
203
+ 'question': question, 'documents': list(corpus.values()), 'source_query_id': qid,
204
+ 'references': {'qrels': qrels[qid], 'corpus_answerable': None, 'required_atoms': None,
205
+ 'atom_support': None, 'required_relations': None, 'relation_support': None,
206
+ 'reviews': {'baseline': None, 'candidate': None}}, 'traces': traces})
207
+ data = {'schema_version': 'sphynx.rag-diagnostics.dataset.v1', 'release': release,
208
+ 'suites': [{'id': suite_id, 'title': 'Imported retrieval rankings',
209
+ 'description': 'Caller-held corpus, qrels and paired TREC rankings. Retrieval-only recorded data; no generated answer or semantic audit.',
210
+ 'inspiration': 'BEIR-compatible input conventions; no official BEIR evaluator parity claimed'}],
211
+ 'cases': cases,
212
+ 'provenance': {'kind': 'external_qrel_only_import', 'importer_version': 'sphynx.rag-diagnostics.beir-import.v1',
213
+ 'input_receipts': receipts, 'caller_sha256_pins_verified': expected_sha256 is not None,
214
+ 'ordering_policy': ORDER_POLICY, 'run_names': run_names,
215
+ 'independent_clusters_verified': mapping is not None,
216
+ 'cluster_mapping_basis': 'caller_supplied_not_authenticated' if mapping is not None else 'unknown; one conservative shared cluster',
217
+ 'semantic_review': 'absent', 'corpus_answerability': 'not_inferred_from_positive_qrels',
218
+ 'unjudged_policy': 'absent_qrel_is_unknown; never_insert_zero',
219
+ 'source_rights': 'caller_responsibility; not_verified_by_importer',
220
+ 'population': 'caller_selected_external_queries; representativeness_not_verified'}}
221
+ validate_dataset(data)
222
+ # Current wire contract repeats corpus documents per case; fail rather than
223
+ # silently sampling rows or constructing an oversized preview bundle.
224
+ _require(len(json.dumps(data, ensure_ascii=False, allow_nan=False).encode('utf-8')) <= MAX_OUTPUT_BYTES,
225
+ 'Imported dataset exceeds 20MB local limit; supply an explicit smaller cohort/corpus')
226
+ return data
227
+
228
+
229
+ def write_dataset(data, output_path):
230
+ """Write a new immutable review artifact; refuse replacement or truncation."""
231
+ validate_dataset(data)
232
+ raw = (json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False) + '\n').encode('utf-8')
233
+ _require(len(raw) <= MAX_OUTPUT_BYTES, 'Pretty JSON output exceeds 20MB local limit')
234
+ output = Path(output_path)
235
+ output.parent.mkdir(parents=True, exist_ok=True)
236
+ try:
237
+ with output.open('xb') as handle:
238
+ handle.write(raw)
239
+ except FileExistsError:
240
+ raise ContractError('Output already exists; select a new release file') from None
241
+ return {'path': str(output.resolve()), 'sha256': hashlib.sha256(raw).hexdigest(),
242
+ 'cases': len(data['cases']), 'suites': len(data['suites'])}
@@ -0,0 +1,81 @@
1
+ """Known-qrel retrieval metrics using trec_eval linear-gain conventions.
2
+
3
+ https://github.com/usnistgov/trec_eval/blob/master/m_ndcg_cut.c
4
+ https://github.com/usnistgov/trec_eval/blob/master/m_map_cut.c
5
+ Tie resolution is upstream: the supplied unique ordered IDs are authoritative.
6
+ No trec_eval binary parity is claimed for unspecified ties or unlabeled queries.
7
+ """
8
+ from __future__ import annotations
9
+ import math
10
+ from ._rag_diagnostics_common import metric, STAGES
11
+
12
+
13
+ def ranking_metrics(case,trace,cutoff=3):
14
+ if type(cutoff) is not int or not 1<=cutoff<=1000:
15
+ raise ValueError('cutoff must be an integer from 1 to 1000')
16
+ refs=case['references'];qrels=refs.get('qrels');rows=[]
17
+ for stage in ('retrieved','reranked','selected','prompt'):
18
+ ranked=trace['stages'].get(stage)
19
+ names=('recall','precision','average_precision','ndcg_linear','reciprocal_rank','unjudged_fraction')
20
+ for name in names:
21
+ mid='retrieval.'+name+'@'+str(cutoff)
22
+ if ranked is None or qrels is None:
23
+ rows.append(metric(mid,case['id'],stage,reason='missing stage or qrels',direction='lower' if name=='unjudged_fraction' else 'higher'));continue
24
+ top=ranked[:cutoff];positive={key for key,grade in qrels.items() if grade>0}
25
+ hits=[int(qrels.get(key,0)>0) for key in top]
26
+ total=len(positive);found=sum(hits)
27
+ kwargs={'evidence_refs':top,'population':'fixed full case qrels; positives > 0; unjudged retained at original ranks','direction':'higher'}
28
+ if name=='unjudged_fraction':
29
+ unknown=sum(key not in qrels for key in top)
30
+ rows.append(metric(mid,case['id'],stage,unknown/len(top) if top else None,numerator=unknown,denominator=len(top),status='available' if top else 'unavailable_zero_population',reason='No returned items' if not top else 'Unjudged contributes zero gain only to the known-qrel projection; it is not a negative label',direction='lower',evidence_refs=top));continue
31
+ if name=='precision':
32
+ rows.append(metric(mid,case['id'],stage,found/cutoff,numerator=found,denominator=cutoff,**kwargs));continue
33
+ if not total:
34
+ rows.append(metric(mid,case['id'],stage,status='unavailable_zero_population',reason='No known positive qrels; local undefined policy differs from trec_eval zero convention',**kwargs));continue
35
+ if name=='recall':
36
+ value=found/total;numerator=found;denominator=total
37
+ elif name=='average_precision':
38
+ cumulative=0;numerator=0.0
39
+ for rank,hit in enumerate(hits,1):
40
+ cumulative+=hit
41
+ if hit:numerator+=cumulative/rank
42
+ denominator=total;value=numerator/denominator
43
+ elif name=='reciprocal_rank':
44
+ first=next((i for i,hit in enumerate(hits,1) if hit),None)
45
+ value=1/first if first else 0.0;numerator=1 if first else 0;denominator=first if first else 1
46
+ if first is None:
47
+ kwargs['reason']='A valid ranking returned no known relevant item within the cutoff; observed reciprocal rank is 0/1, not missing data.'
48
+ else:
49
+ numerator=sum(qrels.get(key,0)/math.log2(rank+1) for rank,key in enumerate(top,1))
50
+ denominator=sum(g/math.log2(rank+1) for rank,g in enumerate(sorted(qrels.values(),reverse=True)[:cutoff],1))
51
+ value=numerator/denominator
52
+ rows.append(metric(mid,case['id'],stage,value,numerator=numerator,denominator=denominator,**kwargs))
53
+ return rows
54
+
55
+
56
+ def evidence_metrics(case,trace):
57
+ refs=case['references'];rows=[]
58
+ for stage in STAGES:
59
+ ids=trace['stages'].get(stage)
60
+ for key,mapping,label in [('required_atoms','atom_support','atom'),('required_relations','relation_support','relation')]:
61
+ required=refs.get(key);support=refs.get(mapping)
62
+ reason=None;status=None;count=None;denominator=None
63
+ if ids is None or required is None or support is None or not set(required)<=set(support):
64
+ reason='Missing stage or protected evidence mapping'
65
+ elif not required:
66
+ reason='No required '+label+' population';status='unavailable_zero_population'
67
+ else:
68
+ denominator=len(required)
69
+ if label=='relation':
70
+ # Every listed document is part of the required relation.
71
+ # Empty support cannot satisfy a relation by vacuous truth.
72
+ count=sum(bool(support[item]) and set(support[item])<=set(ids) for item in required)
73
+ reason='A relation is covered only when every document in its nonempty required support set is present.'
74
+ else:
75
+ count=sum(bool(set(support[item])&set(ids)) for item in required)
76
+ reason='An atom is covered when any declared alternative supporting document is present.'
77
+ value=count/denominator if denominator else None
78
+ rows.append(metric('evidence.'+label+'_recall',case['id'],stage,value,numerator=count,denominator=denominator,status=status,reason=reason,evidence_refs=ids or []))
79
+ if label=='atom':
80
+ rows.append(metric('evidence.complete_required_atoms',case['id'],stage,float(count==denominator) if denominator else None,numerator=int(count==denominator) if denominator else None,denominator=1 if denominator else None,status=status,reason=reason,evidence_refs=ids or []))
81
+ return rows
@@ -0,0 +1,325 @@
1
+ """Local arithmetic over explicit, trace-bound semantic reviews.
2
+
3
+ No text matching, claim extraction, model inference, or provider calls occur here.
4
+ Synthetic reviews exercise definitions; they do not demonstrate judge accuracy.
5
+
6
+ Sources: RAGAS faithfulness (supported response claims / response claims):
7
+ https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/faithfulness/
8
+ Sufficient Context is a question/context judgment, independent of correctness:
9
+ https://arxiv.org/html/2411.06037v3#S3
10
+ Citation arithmetic is ALCE-inspired over reviewed claims, not paper parity:
11
+ https://github.com/princeton-nlp/ALCE/blob/main/eval.py#L276
12
+ Claim-level separation of support and correctness follows RAGChecker's diagnosis:
13
+ https://arxiv.org/html/2408.08067v2#A2
14
+
15
+ The caller validates the review's trace digest. Missing and unresolved judgments
16
+ remain unavailable. Empty claim populations never become perfect faithfulness.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from ._rag_diagnostics_common import metric
21
+
22
+
23
+ _GROUNDED_REQUIREMENTS = (
24
+ 'Requires a correct, relevant answer, exhaustive claim support, supporting '
25
+ 'citations for citation-required claims, useful emitted citations, and full required-fact coverage '
26
+ 'when required atoms are configured; an explicitly expected abstention '
27
+ 'also succeeds.'
28
+ )
29
+
30
+
31
+ def _bool(value):
32
+ return value if type(value) is bool else None
33
+
34
+
35
+ def semantic_metrics(case, trace, review):
36
+ """Return semantic metric records without deriving semantic labels from text.
37
+
38
+ Required-fact coverage counts gold atoms linked to *correct* reviewed claims.
39
+ It is not context sufficiency. Overall answer correctness and response claim
40
+ correctness remain independent judgments. Success gates are conjunctive;
41
+ scores cannot compensate for a failed requirement.
42
+ """
43
+ case_id = case['id']
44
+ refs = case.get('references') or {}
45
+ response = trace.get('response') or {}
46
+ stages = trace.get('stages') or {}
47
+ review = review if isinstance(review, dict) else None
48
+ provenance = (review or {}).get('provenance') or {}
49
+ grader = 'synthetic_label' if provenance.get('kind') == 'synthetic' else 'reviewed_label'
50
+ records = []
51
+
52
+ def emit(metric_id, stage, value=None, *, numerator=None, denominator=None,
53
+ status=None, reason=None, evidence=None, population='this case',
54
+ direction='higher', unit='ratio', grader_type=None):
55
+ result = metric(metric_id, case_id, stage, value, numerator=numerator,
56
+ denominator=denominator, status=status, reason=reason,
57
+ grader_type=grader_type or grader, evidence_refs=evidence,
58
+ population=population, direction=direction, unit=unit)
59
+ records.append(result)
60
+ return result
61
+
62
+ def unavailable(metric_id, stage, reason, *, status='unavailable_missing_input',
63
+ numerator=None, denominator=None, evidence=None,
64
+ population='this case', direction='higher'):
65
+ return emit(metric_id, stage, status=status, reason=reason,
66
+ numerator=numerator, denominator=denominator,
67
+ evidence=evidence, population=population, direction=direction)
68
+
69
+ def judged_bool(metric_id, stage, label, field, *, missing_stage=False):
70
+ if missing_stage:
71
+ return unavailable(metric_id, stage, 'The context stage was not recorded.',
72
+ status='unavailable_missing_stage')
73
+ if review is None:
74
+ return unavailable(metric_id, stage, 'A trace-bound semantic review is required.')
75
+ value = _bool(label)
76
+ if value is None:
77
+ return unavailable(metric_id, stage, f'{field} has no resolved boolean judgment.',
78
+ status='unavailable_unknown_judgment', evidence=[f'review:{field}'])
79
+ return emit(metric_id, stage, int(value), numerator=int(value), denominator=1,
80
+ evidence=[f'review:{field}'], unit='boolean')
81
+
82
+ sufficiency = (review or {}).get('context_sufficiency') or {}
83
+ for stage in ('retrieved', 'selected', 'prompt'):
84
+ judged_bool(f'context.sufficient.{stage}', stage, sufficiency.get(stage),
85
+ f'context_sufficiency.{stage}', missing_stage=stages.get(stage) is None)
86
+
87
+ execution_ok = response.get('status') == 'ok'
88
+ execution_failed = response.get('status') in ('error', 'timeout')
89
+ answer_labels = {}
90
+ for metric_id, field in (
91
+ ('generation.answer_correctness', 'answer_correct'),
92
+ ('generation.response_relevance', 'answer_relevant'),
93
+ ):
94
+ if not execution_ok:
95
+ answer_labels[metric_id] = unavailable(
96
+ metric_id, 'generation', 'No successful answer execution was recorded.',
97
+ status='unavailable_failed_execution' if execution_failed else 'unavailable_missing_input')
98
+ else:
99
+ answer_labels[metric_id] = judged_bool(metric_id, 'generation', (review or {}).get(field), field)
100
+
101
+ claims = (review or {}).get('claims')
102
+ claim_gate = None
103
+ if not execution_ok:
104
+ claim_gate = ('unavailable_failed_execution' if execution_failed else 'unavailable_missing_input',
105
+ 'No successful answer execution was recorded.')
106
+ elif review is None or not isinstance(claims, list):
107
+ claim_gate = ('unavailable_missing_input', 'A trace-bound claim audit is required.')
108
+ elif review.get('claims_exhaustive') is not True:
109
+ claim_gate = ('unavailable_non_exhaustive_review',
110
+ 'The claim audit is not exhaustive; omitted claims could change the score.')
111
+ elif any(not isinstance(claim, dict) or not claim.get('id') for claim in claims):
112
+ claim_gate = ('unavailable_invalid_input', 'Every reviewed claim needs an identity.')
113
+ elif len({claim['id'] for claim in claims}) != len(claims):
114
+ claim_gate = ('unavailable_invalid_input', 'Claim identities must be unique.')
115
+ claim_list = claims if isinstance(claims, list) else []
116
+ claim_refs = [f"claim:{claim.get('id')}" for claim in claim_list if isinstance(claim, dict)]
117
+
118
+ def claim_fraction(metric_id, field, *, invert=False, direction='higher'):
119
+ population = 'all response claims in an exhaustive audit'
120
+ if claim_gate:
121
+ return unavailable(metric_id, 'generation', claim_gate[1], status=claim_gate[0],
122
+ evidence=claim_refs, population=population, direction=direction)
123
+ if field == 'supported_by_context' and stages.get('prompt') is None:
124
+ return unavailable(metric_id, 'generation', 'The delivered prompt context was not recorded.',
125
+ status='unavailable_missing_stage', evidence=claim_refs,
126
+ population=population, direction=direction)
127
+ if not claim_list:
128
+ return unavailable(metric_id, 'generation', 'The response has no audited claims.',
129
+ status='unavailable_zero_population', numerator=0, denominator=0,
130
+ evidence=claim_refs, population=population, direction=direction)
131
+ labels = [_bool(claim.get(field)) for claim in claim_list]
132
+ known = sum(value is (False if invert else True) for value in labels)
133
+ if any(value is None for value in labels):
134
+ return unavailable(metric_id, 'generation', f'{field} is unresolved for one or more claims.',
135
+ status='unavailable_unknown_judgment', numerator=known,
136
+ denominator=len(labels), evidence=claim_refs,
137
+ population=population, direction=direction)
138
+ return emit(metric_id, 'generation', known / len(labels), numerator=known,
139
+ denominator=len(labels), evidence=claim_refs, population=population,
140
+ direction=direction)
141
+
142
+ faithfulness = claim_fraction('generation.faithfulness', 'supported_by_context')
143
+ claim_fraction('generation.unsupported_claim_fraction', 'supported_by_context', invert=True,
144
+ direction='lower')
145
+ claim_fraction('generation.claim_correctness', 'correct')
146
+
147
+ atoms = refs.get('required_atoms')
148
+ fact_id = 'generation.required_fact_coverage'
149
+ if not isinstance(atoms, list):
150
+ fact_coverage = unavailable(fact_id, 'generation', 'Required reference atoms were not supplied.')
151
+ elif not atoms:
152
+ fact_coverage = unavailable(fact_id, 'generation', 'No required reference atoms were declared.',
153
+ status='unavailable_zero_population', numerator=0, denominator=0)
154
+ elif claim_gate:
155
+ fact_coverage = unavailable(fact_id, 'generation', claim_gate[1], status=claim_gate[0])
156
+ else:
157
+ required = set(atoms)
158
+ covered, unresolved = set(), set()
159
+ missing_links = False
160
+ for claim in claim_list:
161
+ links = claim.get('reference_atoms')
162
+ if not isinstance(links, list):
163
+ missing_links = True
164
+ continue
165
+ matching = required.intersection(links)
166
+ if _bool(claim.get('correct')) is True:
167
+ covered.update(matching)
168
+ elif _bool(claim.get('correct')) is None:
169
+ unresolved.update(matching)
170
+ evidence = claim_refs + [f'reference_atom:{atom}' for atom in sorted(required)]
171
+ if missing_links or unresolved.difference(covered):
172
+ fact_coverage = unavailable(fact_id, 'generation',
173
+ 'Claim-to-reference links or correctness judgments remain unresolved.',
174
+ status='unavailable_unknown_judgment', numerator=len(covered),
175
+ denominator=len(required), evidence=evidence, population='declared required reference atoms')
176
+ else:
177
+ fact_coverage = emit(fact_id, 'generation', len(covered) / len(required),
178
+ numerator=len(covered), denominator=len(required), evidence=evidence,
179
+ population='declared required reference atoms',
180
+ reason='Counts unique required atoms expressed in reviewed correct response claims; not context sufficiency.')
181
+
182
+ citation_ids = ('citation.reference_validity', 'citation.necessity_precision', 'citation.completeness')
183
+ citation_results = {}
184
+ if claim_gate:
185
+ for metric_id in citation_ids:
186
+ citation_results[metric_id] = unavailable(metric_id, 'citation', claim_gate[1], status=claim_gate[0])
187
+ else:
188
+ documents = {document['id'] for document in case.get('documents', [])}
189
+ malformed = any(not isinstance(claim.get('cited_ids'), list)
190
+ or len(set(claim.get('cited_ids', []))) != len(claim.get('cited_ids', []))
191
+ for claim in claim_list)
192
+ if malformed:
193
+ for metric_id in citation_ids:
194
+ citation_results[metric_id] = unavailable(metric_id, 'citation',
195
+ 'Cited document identities must be supplied as a list without duplicates.',
196
+ status='unavailable_invalid_input')
197
+ else:
198
+ total_citations = sum(len(claim['cited_ids']) for claim in claim_list)
199
+ valid_citations = sum(doc_id in documents for claim in claim_list for doc_id in claim['cited_ids'])
200
+ useful = 0
201
+ unknown_usefulness = 0
202
+ required_count = 0
203
+ complete = 0
204
+ unknown_completeness = 0
205
+ missing_requirement = False
206
+ for claim in claim_list:
207
+ cited = claim['cited_ids']
208
+ valid = bool(cited) and all(doc_id in documents for doc_id in cited)
209
+ joint = _bool(claim.get('joint_citation_support')) if valid else False
210
+ required_citation = _bool(claim.get('requires_citation'))
211
+ if required_citation is None:
212
+ missing_requirement = True
213
+ elif required_citation:
214
+ required_count += 1
215
+ if joint is True:
216
+ complete += 1
217
+ elif joint is None:
218
+ unknown_completeness += 1
219
+ individual = claim.get('individual_support') or {}
220
+ leave_one_out = claim.get('leave_one_out_support') or {}
221
+ for doc_id in cited:
222
+ if joint is False:
223
+ continue
224
+ if joint is None:
225
+ unknown_usefulness += 1
226
+ continue
227
+ if len(cited) == 1:
228
+ useful += 1
229
+ continue
230
+ alone = _bool(individual.get(doc_id))
231
+ remainder = _bool(leave_one_out.get(doc_id))
232
+ if alone is True or remainder is False:
233
+ useful += 1
234
+ elif alone is None or remainder is None:
235
+ unknown_usefulness += 1
236
+ for metric_id, numerator, denominator, unknown, population in (
237
+ ('citation.reference_validity', valid_citations, total_citations, 0, 'all emitted citation references'),
238
+ ('citation.necessity_precision', useful, total_citations, unknown_usefulness, 'all emitted citation references'),
239
+ ('citation.completeness', complete, required_count,
240
+ unknown_completeness + int(missing_requirement), 'all citation-required response claims'),
241
+ ):
242
+ evidence = claim_refs + [f'document:{doc_id}' for claim in claim_list for doc_id in claim['cited_ids']]
243
+ if unknown:
244
+ result = unavailable(metric_id, 'citation',
245
+ 'Citation requirements or support judgments remain unresolved.',
246
+ status='unavailable_unknown_judgment', numerator=numerator,
247
+ denominator=denominator, evidence=evidence, population=population)
248
+ elif denominator == 0:
249
+ result = unavailable(metric_id, 'citation', 'The citation metric has no eligible population.',
250
+ status='unavailable_zero_population', numerator=0, denominator=0,
251
+ evidence=evidence, population=population)
252
+ else:
253
+ result = emit(metric_id, 'citation', numerator / denominator,
254
+ numerator=numerator, denominator=denominator, evidence=evidence,
255
+ population=population, reason='ALCE-inspired arithmetic over explicit claim reviews; not official ALCE parity.')
256
+ citation_results[metric_id] = result
257
+
258
+ abstained = _bool(response.get('abstained'))
259
+ expected = _bool((review or {}).get('abstention_expected'))
260
+ if not execution_ok or review is None or abstained is None or expected is None:
261
+ abstention = unavailable('outcome.abstention_correctness', 'outcome',
262
+ 'A successful response and explicit actual/expected abstention labels are required.',
263
+ status='unavailable_failed_execution' if execution_failed else 'unavailable_missing_input')
264
+ else:
265
+ abstention = emit('outcome.abstention_correctness', 'outcome', int(abstained == expected),
266
+ numerator=int(abstained == expected), denominator=1, unit='boolean',
267
+ evidence=['response:abstained', 'review:abstention_expected'],
268
+ reason='Checks the declared abstention policy, including unnecessary abstention.')
269
+
270
+ if execution_failed:
271
+ task = emit('outcome.task_success', 'outcome', 0, numerator=0, denominator=1,
272
+ reason=f"Execution ended with {response['status']}.", unit='boolean', grader_type='deterministic')
273
+ elif abstention['value'] is None:
274
+ task = unavailable('outcome.task_success', 'outcome', abstention['reason'], status=abstention['status'])
275
+ elif abstention['value'] == 0:
276
+ task = emit('outcome.task_success', 'outcome', 0, numerator=0, denominator=1,
277
+ reason='The response did not follow the explicit abstention policy.', unit='boolean')
278
+ elif abstained:
279
+ task = emit('outcome.task_success', 'outcome', 1, numerator=1, denominator=1,
280
+ reason='The response abstained when the review explicitly required abstention.', unit='boolean')
281
+ else:
282
+ values = [answer_labels[key]['value'] for key in ('generation.answer_correctness', 'generation.response_relevance')]
283
+ if 0 in values:
284
+ task = emit('outcome.task_success', 'outcome', 0, numerator=0, denominator=1,
285
+ reason='The reviewed answer is incorrect or irrelevant.', unit='boolean')
286
+ elif None in values:
287
+ task = unavailable('outcome.task_success', 'outcome',
288
+ 'Answer correctness and relevance must both be resolved.', status='unavailable_unknown_judgment')
289
+ else:
290
+ task = emit('outcome.task_success', 'outcome', 1, numerator=1, denominator=1,
291
+ reason='The non-abstaining answer is reviewed as correct and relevant.', unit='boolean')
292
+
293
+ grounded_id = 'outcome.grounded_task_success'
294
+ if task['value'] is None:
295
+ unavailable(grounded_id, 'outcome', task['reason'], status=task['status'])
296
+ elif task['value'] == 0:
297
+ emit(grounded_id, 'outcome', task['value'], numerator=task['value'], denominator=1,
298
+ reason=f"{task['reason']} {_GROUNDED_REQUIREMENTS}", unit='boolean')
299
+ elif abstained is True and claim_gate:
300
+ unavailable(grounded_id, 'outcome',
301
+ 'An exhaustive claim audit is required to confirm the abstention contains no unsupported assertions.',
302
+ status=claim_gate[0])
303
+ elif abstained is True and not claim_list:
304
+ emit(grounded_id, 'outcome', 1, numerator=1, denominator=1,
305
+ reason='The review required abstention and an exhaustive audit found no asserted claims.', unit='boolean')
306
+ else:
307
+ requirements = [faithfulness]
308
+ if not abstained and isinstance(atoms, list) and atoms:
309
+ requirements.append(fact_coverage)
310
+ requires_citation = any(claim.get('requires_citation') is True for claim in claim_list)
311
+ missing_citation_requirement = any(_bool(claim.get('requires_citation')) is None for claim in claim_list)
312
+ if requires_citation or missing_citation_requirement:
313
+ requirements.append(citation_results['citation.completeness'])
314
+ if any(isinstance(claim.get('cited_ids'), list) and claim['cited_ids'] for claim in claim_list):
315
+ requirements.append(citation_results['citation.necessity_precision'])
316
+ if any(result['value'] is not None and result['value'] < 1 for result in requirements):
317
+ emit(grounded_id, 'outcome', 0, numerator=0, denominator=1,
318
+ reason=f'A grounding, citation, or required-fact condition failed. {_GROUNDED_REQUIREMENTS}', unit='boolean')
319
+ elif any(result['value'] is None for result in requirements):
320
+ unavailable(grounded_id, 'outcome', f'One or more grounding conditions are unavailable. {_GROUNDED_REQUIREMENTS}',
321
+ status='unavailable_unknown_judgment')
322
+ else:
323
+ emit(grounded_id, 'outcome', 1, numerator=1, denominator=1,
324
+ reason=_GROUNDED_REQUIREMENTS, unit='boolean')
325
+ return records