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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Explicit Sable/Reflex bridges. No question-text matching or invented gold."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import copy
|
|
4
|
+
|
|
5
|
+
from .contracts import (digest, make_case, make_trace, parse, require, validate_case,
|
|
6
|
+
validate_trace)
|
|
7
|
+
from .datasets import _document, _read, _write_bundle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def prepare_sable_paper_dataset(source_path, output_dir, *, expected_sha256,
|
|
11
|
+
approval_receipt=None, synthetic=False):
|
|
12
|
+
"""Preserve DatasetV3 history, grades, evidence groups and authority metadata.
|
|
13
|
+
|
|
14
|
+
Sable qrel grades stay 0..3. BEIR's >0 and Sable's configured relevance
|
|
15
|
+
threshold remain distinct: use evaluate_sable_evidence for Sable coverage.
|
|
16
|
+
Non-synthetic input needs an explicit owner-held usage receipt; possession
|
|
17
|
+
of a repository is not customer-data authorization.
|
|
18
|
+
"""
|
|
19
|
+
from ..dataset_v3 import validate_dataset_v3
|
|
20
|
+
raw=_read(source_path,expected_sha256); native=parse(raw);validate_dataset_v3(native)
|
|
21
|
+
require(type(synthetic) is bool,'Explicit synthetic status required')
|
|
22
|
+
if not synthetic:
|
|
23
|
+
require(type(approval_receipt) is dict and set(approval_receipt)=={'owner','purpose','source_sha256'}
|
|
24
|
+
and approval_receipt['source_sha256']==expected_sha256
|
|
25
|
+
and all(type(v) is str and v.strip() for v in approval_receipt.values()),'Owner-approved source usage receipt required')
|
|
26
|
+
corpus={};cases=[]
|
|
27
|
+
for task in native['tasks']:
|
|
28
|
+
docs=[_document(corpus,c['input'],key=task['task_id']+':'+c['candidate_id']) for c in task['candidates']]
|
|
29
|
+
names={c['candidate_id']:d for c,d in zip(task['candidates'],docs)}
|
|
30
|
+
qrels={names[c['candidate_id']]:c['qrel'] for c in task['candidates'] if c['judgment_status']=='judged'}
|
|
31
|
+
required=[g for g in task['evidence_groups'] if g['requirement']=='required']
|
|
32
|
+
c=make_case(task['task_id'],task['query'],docs,
|
|
33
|
+
history=[dict(role=t['role'],content=t['text']) for t in task['conversation_context']],
|
|
34
|
+
snapshot=expected_sha256,origin='synthetic' if synthetic else 'owner-held',
|
|
35
|
+
family='sable.'+task['customer_id'],split=task['partition'],cluster_id=task['cluster_id'],source_id=native['dataset_id'],
|
|
36
|
+
context={'as_of':task['evaluation_time']},references=dict(qrels=qrels,
|
|
37
|
+
answerable=task['answerability']=='answerable',expected_action=task['expected_behavior'],
|
|
38
|
+
required_atoms=[g['evidence_group_id'] for g in required],
|
|
39
|
+
atom_support={g['evidence_group_id']:[names[i] for i in g['acceptable_candidate_ids']] for g in required},
|
|
40
|
+
native=dict(dataset_v3_task=task,candidate_id_map=names,sable_relevance_threshold=native['default_relevance_threshold'])))
|
|
41
|
+
cases.append(c)
|
|
42
|
+
return _write_bundle(corpus,cases,output_dir,dict(kind='sable-dataset-v3',source_sha256=expected_sha256,
|
|
43
|
+
synthetic=synthetic,approval_receipt=copy.deepcopy(approval_receipt),customer_validation_completed=False),source=raw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def evaluate_sable_evidence(case, trace, corpus):
|
|
47
|
+
"""Sable adaptation: required evidence-group recall at each observed stage."""
|
|
48
|
+
from .scoring import ratio, measure
|
|
49
|
+
validate_case(case,corpus);validate_trace(trace,case,corpus)
|
|
50
|
+
required=case['references']['required_atoms'];support=case['references']['atom_support']
|
|
51
|
+
metrics={}
|
|
52
|
+
for stage, ranking in trace['stages'].items():
|
|
53
|
+
metrics[stage+'.required_group_recall']=measure(reason='Required evidence or observed stage unavailable')
|
|
54
|
+
if ranking is not None and required is not None and support is not None:
|
|
55
|
+
metrics[stage+'.required_group_recall']=ratio(sum(bool(set(support[a])&set(ranking)) for a in required),len(required))
|
|
56
|
+
return dict(protocol='sphynx.sable.evidence.v1',paper_parity=False,case_id=case['id'],
|
|
57
|
+
case_sha256=digest(case),trace_sha256=digest(trace),metrics=metrics)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def bind_reflex_case_history(diagnostics_dataset, case_bank_path, *, expected_sha256, case_map):
|
|
61
|
+
"""Join an already validated Reflex pairing to explicit original case IDs.
|
|
62
|
+
|
|
63
|
+
Every search and every bank case must be accounted for. A bank may have
|
|
64
|
+
multiple searches; no original question/history is replaced by a rewritten
|
|
65
|
+
query. Gold and generation stay unknown when only retrieval was observed.
|
|
66
|
+
"""
|
|
67
|
+
from .._rag_diagnostics_contracts import validate_dataset
|
|
68
|
+
validated=validate_dataset(diagnostics_dataset)
|
|
69
|
+
rows=[parse(line) for line in _read(case_bank_path,expected_sha256).splitlines() if line.strip()]
|
|
70
|
+
bank={r['id']:r for r in rows};require(len(bank)==len(rows),'Duplicate Reflex case bank ID')
|
|
71
|
+
require(type(case_map) is dict and set(case_map)=={c['id'] for c in validated['cases']}
|
|
72
|
+
and set(case_map.values())==set(bank),'Explicit complete Reflex-to-bank mapping required')
|
|
73
|
+
corpus={}; cases=[]; traces={'baseline':[],'candidate':[]}
|
|
74
|
+
for old in validated['cases']:
|
|
75
|
+
row=bank[case_map[old['id']]]
|
|
76
|
+
require(old['question'] in row['rag_queries'],'Recorded retrieval query differs from its explicit case bank')
|
|
77
|
+
docs=[_document(corpus,d['text'],key=d['id']) for d in old['documents']]
|
|
78
|
+
c=make_case(old['id'],row['raw_user_question'],docs,history=row['conversation_history'],query=old['question'],
|
|
79
|
+
snapshot=validated['provenance']['baseline_sha256'],origin='recorded',family='sable.reflex',
|
|
80
|
+
cluster_id=old['cluster_id'],source_id='reflex-case-bank',native_id=row['id'],
|
|
81
|
+
references=dict(native={'case_bank_sha256':expected_sha256,'bank_row':row}))
|
|
82
|
+
cases.append(c)
|
|
83
|
+
for variant in traces:
|
|
84
|
+
oldtrace=old['traces'][variant]
|
|
85
|
+
t=make_trace(c,corpus,status='missing',attempt_id=oldtrace['id'],candidate_id=variant,
|
|
86
|
+
source_digest=digest(validated['provenance']['execution_identities'][variant]))
|
|
87
|
+
t['stages']=copy.deepcopy(oldtrace['stages'])
|
|
88
|
+
t['native']={'reflex':copy.deepcopy(oldtrace),'generation_observed':False}
|
|
89
|
+
traces[variant].append(validate_trace(t,c,corpus))
|
|
90
|
+
return dict(corpus=corpus,cases=cases,traces=traces,source_sha256=digest(validated),
|
|
91
|
+
answer_generation_observed=False,human_review_status='KIV')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def trace_from_level3(case, corpus, response, *, candidate_id, source_sha256, candidate_id_map):
|
|
95
|
+
"""Map an exact level-3 full-RAG response; reject query and evidence drift."""
|
|
96
|
+
require(response.get('schema_version')=='sable.rag.level3-trace.v1'
|
|
97
|
+
and response.get('task_id')==case['id'] and response.get('as_uttered_query')==case['question'],
|
|
98
|
+
'Full-RAG trace does not bind the original case')
|
|
99
|
+
from .._full_rag_trace import validate_level3_trace
|
|
100
|
+
raw=copy.deepcopy(response)
|
|
101
|
+
if 'expected_invocation' not in raw:raw['expected_invocation']=case['references']['expected_action']!='no_rag'
|
|
102
|
+
if raw.get('latency_ms')=={}:raw['latency_ms']={k:0.0 for k in ('routing','rewrite','retrieval','generation','total')}
|
|
103
|
+
parsed=validate_level3_trace(raw)
|
|
104
|
+
def mapped(values):
|
|
105
|
+
require(all(v in candidate_id_map for v in values),'Level-3 evidence is absent from the explicit ID map')
|
|
106
|
+
return [candidate_id_map[v] for v in values]
|
|
107
|
+
trace=make_trace(case,corpus,text=parsed.answer or '',ranked=mapped(parsed.retrieved_candidate_ids),
|
|
108
|
+
prompt=mapped(parsed.handed_context_ids),status='error' if response['call_outcome']=='error' else 'ok',
|
|
109
|
+
abstained=parsed.abstained,candidate_id=candidate_id,source_digest=source_sha256)
|
|
110
|
+
trace['rewritten_query']=parsed.rewritten_query
|
|
111
|
+
trace['operational']['tokens']=parsed.prompt_tokens+parsed.completion_tokens
|
|
112
|
+
trace['native']={'level3':copy.deepcopy(response)}
|
|
113
|
+
return validate_trace(trace,case,corpus)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def trace_from_full_rag_capture(case, corpus, request, response, *, candidate_id, source_sha256, candidate_id_map):
|
|
117
|
+
"""Verify retained full-RAG request/response digests and original history."""
|
|
118
|
+
from ..full_rag import validate_full_rag_response
|
|
119
|
+
from ..integration_contracts import value_digest
|
|
120
|
+
require(request['input_digest']==value_digest({k:v for k,v in request.items() if k!='input_digest'}),
|
|
121
|
+
'Retained full-RAG request digest differs')
|
|
122
|
+
require(request['case_id']==case['id'] and request['query']==case['question']
|
|
123
|
+
and request['conversation_context']==[dict(role=t['role'],text=t['content']) for t in case['conversation_history']],
|
|
124
|
+
'Retained full-RAG original question or history differs')
|
|
125
|
+
require(set(candidate_id_map.values())==set(case['document_ids']) and len(candidate_id_map)==len(case['document_ids']),
|
|
126
|
+
'Full-RAG evidence map differs from case inventory')
|
|
127
|
+
require({r['candidate_id'] for r in request['candidates']}==set(candidate_id_map)
|
|
128
|
+
and all(r['input']==corpus[candidate_id_map[r['candidate_id']]]['text'] for r in request['candidates']),
|
|
129
|
+
'Retained full-RAG document bytes differ')
|
|
130
|
+
validate_full_rag_response(response,request,expected_invocation=case['references']['expected_action']!='no_rag')
|
|
131
|
+
trace=trace_from_level3(case,corpus,response['trace'],candidate_id=candidate_id,
|
|
132
|
+
source_sha256=source_sha256,candidate_id_map=candidate_id_map)
|
|
133
|
+
trace['native']['full_rag_request_sha256']=digest(request)
|
|
134
|
+
trace['native']['full_rag_response_sha256']=digest(response)
|
|
135
|
+
return validate_trace(trace,case,corpus)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Portable JSON Schema documents, independent of Python dataclasses."""
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from .contracts import require
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_paper_schema(kind):
|
|
9
|
+
require(kind in ('case','trace','judgment','metric_result','result'),'Unknown paper wire schema')
|
|
10
|
+
schema=json.loads(files('sphynx').joinpath('data/rag-paper-schemas.v1.json').read_text(encoding='utf-8'))
|
|
11
|
+
schema['$ref']='#/$defs/'+kind
|
|
12
|
+
return copy.deepcopy(schema)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Independent implementations of named papers' deterministic scoring rules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import Counter
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import string
|
|
8
|
+
|
|
9
|
+
from .contracts import (RESULT_SCHEMA, digest, require, validate_case,
|
|
10
|
+
validate_judgment, validate_trace)
|
|
11
|
+
from .registry import paper_spec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_answer(text):
|
|
15
|
+
"""SQuAD-family English normalization; deliberately not Unicode WER policy."""
|
|
16
|
+
require(type(text) is str, 'Answer must be text')
|
|
17
|
+
text = text.lower().translate(str.maketrans('', '', string.punctuation))
|
|
18
|
+
return ' '.join(re.sub(r'\b(a|an|the)\b', ' ', text).split())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def answer_scores(prediction, references, *, categorical=False):
|
|
22
|
+
require(type(references) is list and all(type(x) is str for x in references), 'Invalid answer aliases')
|
|
23
|
+
if not references:
|
|
24
|
+
return dict(em=None, f1=None, precision=None, recall=None)
|
|
25
|
+
predicted = normalize_answer(prediction)
|
|
26
|
+
rows = []
|
|
27
|
+
for reference in references:
|
|
28
|
+
gold = normalize_answer(reference)
|
|
29
|
+
em = int(predicted == gold)
|
|
30
|
+
if categorical and (predicted in ('yes', 'no', 'noanswer') or gold in ('yes', 'no', 'noanswer')) and not em:
|
|
31
|
+
precision = recall = f1 = 0.0
|
|
32
|
+
else:
|
|
33
|
+
p, g = predicted.split(), gold.split()
|
|
34
|
+
overlap = sum((Counter(p) & Counter(g)).values())
|
|
35
|
+
precision = overlap / len(p) if p else 0.0
|
|
36
|
+
recall = overlap / len(g) if g else 0.0
|
|
37
|
+
f1 = 2 * precision * recall / (precision + recall) if overlap else 0.0
|
|
38
|
+
rows.append(dict(em=em, f1=f1, precision=precision, recall=recall))
|
|
39
|
+
# Official answer F1 chooses a complete reference comparison, not separately
|
|
40
|
+
# maximized precision and recall from incompatible references.
|
|
41
|
+
best = max(rows, key=lambda row: row['f1'])
|
|
42
|
+
return {**best, 'em': max(row['em'] for row in rows)}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_scores(predicted, gold, *, empty_f1=False):
|
|
46
|
+
p, g = set(predicted), set(gold)
|
|
47
|
+
overlap = len(p & g)
|
|
48
|
+
precision = overlap / len(p) if p else 0.0
|
|
49
|
+
recall = overlap / len(g) if g else 0.0
|
|
50
|
+
return dict(em=float(p == g), precision=precision, recall=recall,
|
|
51
|
+
f1=2*precision*recall/(precision+recall) if overlap else float(empty_f1 and p == g))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def measure(value=None, *, numerator=None, denominator=None, reason=None, unit='ratio'):
|
|
55
|
+
return dict(value=value, numerator=numerator, denominator=denominator, unit=unit,
|
|
56
|
+
status='available' if value is not None else 'unavailable', reason=reason)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ratio(numerator, denominator, reason='Empty eligible population'):
|
|
60
|
+
return measure(numerator/denominator if denominator else None,
|
|
61
|
+
numerator=numerator, denominator=denominator,
|
|
62
|
+
reason=None if denominator else reason)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def result(paper, case, trace, corpus, metrics, *, judgments=(), notes=(), parameters=None):
|
|
66
|
+
validate_case(case, corpus); validate_trace(trace, case, corpus)
|
|
67
|
+
for row in judgments:
|
|
68
|
+
validate_judgment(row, case, trace)
|
|
69
|
+
return dict(schema_version=RESULT_SCHEMA, protocol=paper_spec(paper),
|
|
70
|
+
case_id=case['id'], case_sha256=digest(case), trace_sha256=digest(trace),
|
|
71
|
+
input_sha256=trace['input_sha256'], response_status=trace['response']['status'],
|
|
72
|
+
metrics=metrics, judgment_sha256=[digest(j) for j in judgments],
|
|
73
|
+
parameters={} if parameters is None else parameters,
|
|
74
|
+
judgment_origins=sorted({j['provenance']['origin'] for j in judgments}),
|
|
75
|
+
notes=list(notes), hosted_admitted=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def labels_for(judgments, case, trace, task):
|
|
79
|
+
matches = [j for j in judgments if j['task'] == task]
|
|
80
|
+
require(len(matches) <= 1, 'Duplicate judgment for task')
|
|
81
|
+
if not matches:
|
|
82
|
+
return None
|
|
83
|
+
j = validate_judgment(matches[0], case, trace, task)
|
|
84
|
+
return j['labels'] if j['status'] == 'judged' else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def evaluate_beir(case, trace, corpus, *, cutoff=10, stage='retrieved'):
|
|
88
|
+
"""BEIR/TREC linear nDCG, AP, recall, precision and MRR on native qrels.
|
|
89
|
+
|
|
90
|
+
Supplied unique order resolves ties. No-positive topics retain trec_eval's
|
|
91
|
+
zero convention, with their eligibility count separately available.
|
|
92
|
+
"""
|
|
93
|
+
require(type(cutoff) is int and 1 <= cutoff <= 1_000_000, 'Invalid ranking cutoff')
|
|
94
|
+
require(stage in trace['stages'], 'Unknown ranking stage')
|
|
95
|
+
qrels, ranking = case['references']['qrels'], trace['stages'][stage]
|
|
96
|
+
names = ['ndcg', 'map', 'recall', 'precision', 'mrr', 'unjudged_fraction']
|
|
97
|
+
if qrels is None or ranking is None:
|
|
98
|
+
scores = {name+'@'+str(cutoff): measure(reason='Native qrels or recorded ranking unavailable') for name in names}
|
|
99
|
+
else:
|
|
100
|
+
top = ranking[:cutoff]
|
|
101
|
+
relevant = {d for d, grade in qrels.items() if grade > 0}
|
|
102
|
+
hits = [d in relevant for d in top]
|
|
103
|
+
found, total = sum(hits), len(relevant)
|
|
104
|
+
ideal = sum(grade/math.log2(rank+1) for rank, grade in enumerate(sorted(qrels.values(), reverse=True)[:cutoff], 1))
|
|
105
|
+
dcg = sum(qrels.get(d, 0)/math.log2(rank+1) for rank, d in enumerate(top, 1))
|
|
106
|
+
seen = 0; ap = 0.0
|
|
107
|
+
for rank, hit in enumerate(hits, 1):
|
|
108
|
+
seen += hit
|
|
109
|
+
if hit:
|
|
110
|
+
ap += seen/rank
|
|
111
|
+
first = next((rank for rank, hit in enumerate(hits, 1) if hit), None)
|
|
112
|
+
scores = {'ndcg': measure(dcg/ideal if ideal else 0.0, numerator=dcg, denominator=ideal),
|
|
113
|
+
'map': measure(ap/total if total else 0.0, numerator=ap, denominator=total),
|
|
114
|
+
'recall': measure(found/total if total else 0.0, numerator=found, denominator=total),
|
|
115
|
+
'precision': ratio(found, cutoff), 'mrr': measure(1/first if first else 0.0),
|
|
116
|
+
'unjudged_fraction': ratio(sum(d not in qrels for d in top), len(top))}
|
|
117
|
+
scores = {name+'@'+str(cutoff): row for name, row in scores.items()}
|
|
118
|
+
scores['known_positive_count'] = measure(None if qrels is None else sum(g>0 for g in qrels.values()),unit='documents')
|
|
119
|
+
return result('beir', case, trace, corpus, scores, parameters=dict(cutoff=cutoff,stage=stage),
|
|
120
|
+
notes=['Absent qrels remain unknown; zero gain is a ranking projection, not a new negative judgment.'])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _qa(case, trace, *, hotpot=False):
|
|
124
|
+
answer = case['references']['answers']
|
|
125
|
+
if answer is None:
|
|
126
|
+
return {key: measure(reason='Reference answers unavailable') for key in ('answer_em','answer_f1','answer_precision','answer_recall')}
|
|
127
|
+
raw = answer_scores(trace['response']['text'], answer, categorical=hotpot)
|
|
128
|
+
if trace['response']['status'] != 'ok':
|
|
129
|
+
raw = dict.fromkeys(raw, 0.0)
|
|
130
|
+
return {'answer_'+key: measure(value) for key, value in raw.items()}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def evaluate_hotpotqa(case, trace, corpus):
|
|
134
|
+
"""Official answer/support/joint arithmetic on title+sentence-index units."""
|
|
135
|
+
metrics = _qa(case, trace, hotpot=True)
|
|
136
|
+
gold, pred = case['references']['support_facts'], trace['response']['support_facts']
|
|
137
|
+
if gold is not None and pred is not None:
|
|
138
|
+
def facts(rows):
|
|
139
|
+
require(all(type(r) is list and len(r) == 2 and type(r[0]) is str
|
|
140
|
+
and type(r[1]) is int and r[1] >= 0 for r in rows), 'Invalid supporting fact unit')
|
|
141
|
+
return [tuple(r) for r in rows]
|
|
142
|
+
support = set_scores(facts(pred), facts(gold))
|
|
143
|
+
if trace['response']['status'] != 'ok':
|
|
144
|
+
support = dict.fromkeys(support, 0.0)
|
|
145
|
+
metrics.update({'support_'+k: measure(v) for k, v in support.items()})
|
|
146
|
+
p = metrics.get('answer_precision', {}).get('value')
|
|
147
|
+
r = metrics.get('answer_recall', {}).get('value')
|
|
148
|
+
jp = None if p is None else p * support['precision']
|
|
149
|
+
jr = None if r is None else r * support['recall']
|
|
150
|
+
metrics['joint_em'] = measure(None if p is None else metrics['answer_em']['value'] * support['em'])
|
|
151
|
+
metrics['joint_f1'] = measure(None if jp is None else 2*jp*jr/(jp+jr) if jp+jr else 0.0)
|
|
152
|
+
metrics['joint_precision']=measure(jp);metrics['joint_recall']=measure(jr)
|
|
153
|
+
else:
|
|
154
|
+
metrics.update({name: measure(reason='Sentence support annotation/prediction unavailable')
|
|
155
|
+
for name in ('support_em','support_f1','support_precision','support_recall',
|
|
156
|
+
'joint_em','joint_f1','joint_precision','joint_recall')})
|
|
157
|
+
return result('hotpotqa', case, trace, corpus, metrics)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def evaluate_musique(case, trace, corpus):
|
|
161
|
+
"""MuSiQue answer aliases and supporting-paragraph EM/F1; retain hop graph."""
|
|
162
|
+
metrics = _qa(case, trace)
|
|
163
|
+
gold, pred = case['references']['support_ids'], trace['response']['support_ids']
|
|
164
|
+
if gold is not None and pred is not None:
|
|
165
|
+
values = set_scores(pred, gold, empty_f1=True)
|
|
166
|
+
if trace['response']['status'] != 'ok':
|
|
167
|
+
values = dict.fromkeys(values, 0.0)
|
|
168
|
+
metrics.update({'support_'+k: measure(v) for k, v in values.items()})
|
|
169
|
+
else:
|
|
170
|
+
metrics.update({k: measure(reason='Paragraph support annotation/prediction unavailable') for k in ('support_em','support_f1','support_precision','support_recall')})
|
|
171
|
+
expected = case['references']['answerable']
|
|
172
|
+
metrics['answerability_accuracy'] = measure(None if expected is None else
|
|
173
|
+
float(trace['response']['status'] == 'ok' and (not trace['response']['abstained']) == expected))
|
|
174
|
+
required=case['references']['required_atoms'];supports=case['references']['atom_support'];prompt=trace['stages']['prompt']
|
|
175
|
+
metrics['adaptation.required_hop_context_recall']=measure(reason='Complete hop annotations or prompt unavailable')
|
|
176
|
+
if required and supports is not None and prompt is not None:
|
|
177
|
+
metrics['adaptation.required_hop_context_recall']=ratio(sum(bool(set(supports[a])&set(prompt)) for a in required),len(required))
|
|
178
|
+
return result('musique', case, trace, corpus, metrics,
|
|
179
|
+
notes=['Native question decomposition is retained; supporting two unrelated facts does not establish connected reasoning.',
|
|
180
|
+
'The adaptation-prefixed metric records hop evidence availability. Use plan_musique for actual leave-one-hop-out execution.'])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def evaluate_qasper(case, trace, corpus):
|
|
184
|
+
"""Best human-reference answer/evidence F1, with independent denominators."""
|
|
185
|
+
annotations = case['references']['annotation_sets']
|
|
186
|
+
metrics = {}
|
|
187
|
+
if annotations is None or not annotations:
|
|
188
|
+
metrics = {k: measure(reason='Human QASPER annotation sets unavailable') for k in ('answer_f1','evidence_f1')}
|
|
189
|
+
else:
|
|
190
|
+
require(all(type(a) is dict and set(a) == {'answer', 'evidence_ids'}
|
|
191
|
+
and type(a['answer']) is str and (a['evidence_ids'] is None or type(a['evidence_ids']) is list) for a in annotations),
|
|
192
|
+
'Invalid QASPER reference set')
|
|
193
|
+
metrics['answer_f1'] = measure(max(answer_scores(trace['response']['text'], [a['answer']])['f1']
|
|
194
|
+
if normalize_answer(trace['response']['text']) and normalize_answer(a['answer']) else 0.0 for a in annotations))
|
|
195
|
+
pred = trace['response']['support_ids']
|
|
196
|
+
known = pred is not None and all(a['evidence_ids'] is not None for a in annotations)
|
|
197
|
+
def evidence_f1(gold):
|
|
198
|
+
# QASPER identifies evidence by paragraph text, retaining list lengths
|
|
199
|
+
# in its denominator even when identical paragraphs occur twice.
|
|
200
|
+
p = [corpus[d]['text'] for d in pred]
|
|
201
|
+
g = [corpus[d]['text'] for d in gold]
|
|
202
|
+
overlap = len(set(p) & set(g))
|
|
203
|
+
return 2*overlap/(len(p)+len(g)) if p or g else 1.0
|
|
204
|
+
metrics['evidence_f1'] = measure(max(evidence_f1(a['evidence_ids']) for a in annotations) if known else None,
|
|
205
|
+
reason=None if known else 'Predicted evidence or a complete native reference mapping unavailable')
|
|
206
|
+
if trace['response']['status'] != 'ok':
|
|
207
|
+
metrics = {k: measure(0.0) for k in metrics}
|
|
208
|
+
return result('qasper', case, trace, corpus, metrics,
|
|
209
|
+
notes=['Answer and evidence can maximize over different human annotations, matching the reference evaluator.'])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def evaluate_rgb(case, trace, corpus, *, judgments=()):
|
|
213
|
+
"""RGB's all-required answer-group substring match, separately per testbed."""
|
|
214
|
+
native = case['references']['native']; groups = native.get('answer_groups')
|
|
215
|
+
metrics = {}
|
|
216
|
+
if groups is None:
|
|
217
|
+
metrics['accuracy'] = measure(reason='Native RGB answer groups unavailable')
|
|
218
|
+
else:
|
|
219
|
+
require(type(groups) is list and groups and all(type(g) is list and g
|
|
220
|
+
and all(type(a) is str and a for a in g) for g in groups), 'Invalid RGB answer groups')
|
|
221
|
+
text = trace['response']['text'].lower()
|
|
222
|
+
success = all(any(alias.lower() in text for alias in group) for group in groups)
|
|
223
|
+
metrics['accuracy'] = measure(float(success and trace['response']['status'] == 'ok'))
|
|
224
|
+
labels = labels_for(judgments, case, trace, 'rgb')
|
|
225
|
+
for name in ('rejection','error_detected','error_corrected'):
|
|
226
|
+
value = None if labels is None else labels.get(name)
|
|
227
|
+
require(value is None or type(value) is bool, 'Invalid RGB semantic verdict')
|
|
228
|
+
metrics[name] = measure(None if value is None else float(value), reason='Captured testbed-specific judgment required' if value is None else None)
|
|
229
|
+
return result('rgb', case, trace, corpus, metrics, judgments=judgments,
|
|
230
|
+
notes=['Substring matching is RGB compatibility, not an independent factuality judgment.',
|
|
231
|
+
'Scenario testbeds are never silently pooled into one robustness score.'])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def evaluate_crag(case, trace, corpus, *, judgments=()):
|
|
235
|
+
"""CRAG utility over exact retained verdicts: correct=1, abstain=0, wrong=-1."""
|
|
236
|
+
labels = labels_for(judgments, case, trace, 'crag.correctness')
|
|
237
|
+
if trace['response']['status'] != 'ok':
|
|
238
|
+
verdict = 'incorrect'
|
|
239
|
+
elif trace['response']['abstained']:
|
|
240
|
+
verdict = 'abstain'
|
|
241
|
+
elif labels is None:
|
|
242
|
+
verdict = None
|
|
243
|
+
else:
|
|
244
|
+
verdict = labels.get('verdict')
|
|
245
|
+
require(verdict in ('correct', 'incorrect', 'abstain'), 'Invalid CRAG verdict')
|
|
246
|
+
score = None if verdict is None else {'correct': 1.0, 'abstain': 0.0, 'incorrect': -1.0}[verdict]
|
|
247
|
+
return result('crag', case, trace, corpus, {'utility': measure(score, unit='score',
|
|
248
|
+
reason='A captured correctness verdict is required' if score is None else None)}, judgments=judgments,
|
|
249
|
+
notes=['This implements the scoring rule, not an assertion that a supplied/model verdict is true.'])
|