claimproofkit 0.1.0__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.
- claimproofkit/__init__.py +95 -0
- claimproofkit/_text.py +94 -0
- claimproofkit/benchmark.py +407 -0
- claimproofkit/cli.py +136 -0
- claimproofkit/extractor.py +113 -0
- claimproofkit/identity.py +134 -0
- claimproofkit/models.py +133 -0
- claimproofkit/pipeline.py +145 -0
- claimproofkit/provenance.py +279 -0
- claimproofkit/py.typed +0 -0
- claimproofkit/report.py +219 -0
- claimproofkit/verifier.py +239 -0
- claimproofkit-0.1.0.dist-info/METADATA +95 -0
- claimproofkit-0.1.0.dist-info/RECORD +19 -0
- claimproofkit-0.1.0.dist-info/WHEEL +5 -0
- claimproofkit-0.1.0.dist-info/entry_points.txt +2 -0
- claimproofkit-0.1.0.dist-info/licenses/LICENSE +203 -0
- claimproofkit-0.1.0.dist-info/licenses/NOTICE +6 -0
- claimproofkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""claimproofkit — evidence-bound claim verification.
|
|
5
|
+
|
|
6
|
+
Python implementation of Claim Proof Specification v1, in locked parity with
|
|
7
|
+
the Swift ClaimProofKit (the reference implementation). Parity is proved by
|
|
8
|
+
the conformance runner under ``conformance/``, which executes the same golden
|
|
9
|
+
vectors the Swift repo locks in CI.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .extractor import ClaimExtractor
|
|
13
|
+
from .identity import (
|
|
14
|
+
SPEC_VERSION,
|
|
15
|
+
VERIFIER_IDENTIFIER,
|
|
16
|
+
VERIFIER_VERSION,
|
|
17
|
+
canonical_json,
|
|
18
|
+
evidence_content_fingerprint,
|
|
19
|
+
policy_fingerprint,
|
|
20
|
+
report_fingerprint,
|
|
21
|
+
)
|
|
22
|
+
from .models import (
|
|
23
|
+
APPROVED_STATUSES,
|
|
24
|
+
Claim,
|
|
25
|
+
ClaimType,
|
|
26
|
+
ClaimVerdict,
|
|
27
|
+
EvidenceMatch,
|
|
28
|
+
EvidencePassage,
|
|
29
|
+
ProofReport,
|
|
30
|
+
SourceType,
|
|
31
|
+
SupportStatus,
|
|
32
|
+
VerificationPolicy,
|
|
33
|
+
make_claim_fingerprint,
|
|
34
|
+
)
|
|
35
|
+
from .provenance import (
|
|
36
|
+
BRIDGE_VERSION,
|
|
37
|
+
CLAIM_EXTRACTED,
|
|
38
|
+
CLAIM_VERIFIED,
|
|
39
|
+
REPORT_FINALIZED,
|
|
40
|
+
ProvenanceEdge,
|
|
41
|
+
ProvenanceEventEnvelope,
|
|
42
|
+
ProvenanceExporter,
|
|
43
|
+
ProvenanceTrace,
|
|
44
|
+
run_fingerprint_of,
|
|
45
|
+
)
|
|
46
|
+
from .pipeline import (
|
|
47
|
+
AtomicClaimExtracting,
|
|
48
|
+
ClaimEntailmentVerifying,
|
|
49
|
+
ConservativeProofPolicy,
|
|
50
|
+
DefaultTrustReportGenerator,
|
|
51
|
+
Disposition,
|
|
52
|
+
EvidenceRetrieving,
|
|
53
|
+
LexicalEntailmentVerifier,
|
|
54
|
+
PassthroughEvidenceRetriever,
|
|
55
|
+
PipelineResult,
|
|
56
|
+
ProofDecision,
|
|
57
|
+
ProofPolicyEvaluating,
|
|
58
|
+
TrustReportGenerating,
|
|
59
|
+
VerificationPipeline,
|
|
60
|
+
)
|
|
61
|
+
from .verifier import ClaimVerifier
|
|
62
|
+
|
|
63
|
+
__version__ = "0.1.0"
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"APPROVED_STATUSES",
|
|
67
|
+
"BRIDGE_VERSION",
|
|
68
|
+
"CLAIM_EXTRACTED",
|
|
69
|
+
"CLAIM_VERIFIED",
|
|
70
|
+
"REPORT_FINALIZED",
|
|
71
|
+
"SPEC_VERSION",
|
|
72
|
+
"VERIFIER_IDENTIFIER",
|
|
73
|
+
"VERIFIER_VERSION",
|
|
74
|
+
"Claim",
|
|
75
|
+
"ClaimExtractor",
|
|
76
|
+
"ClaimType",
|
|
77
|
+
"ClaimVerdict",
|
|
78
|
+
"ClaimVerifier",
|
|
79
|
+
"EvidenceMatch",
|
|
80
|
+
"EvidencePassage",
|
|
81
|
+
"ProofReport",
|
|
82
|
+
"ProvenanceEdge",
|
|
83
|
+
"ProvenanceEventEnvelope",
|
|
84
|
+
"ProvenanceExporter",
|
|
85
|
+
"ProvenanceTrace",
|
|
86
|
+
"SourceType",
|
|
87
|
+
"SupportStatus",
|
|
88
|
+
"VerificationPolicy",
|
|
89
|
+
"canonical_json",
|
|
90
|
+
"evidence_content_fingerprint",
|
|
91
|
+
"make_claim_fingerprint",
|
|
92
|
+
"policy_fingerprint",
|
|
93
|
+
"report_fingerprint",
|
|
94
|
+
"run_fingerprint_of",
|
|
95
|
+
]
|
claimproofkit/_text.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Shared text primitives that must byte-match Swift's semantics.
|
|
5
|
+
|
|
6
|
+
Every helper here exists because a naive Python equivalent demonstrably
|
|
7
|
+
diverges from the Swift reference (verified by differential probes):
|
|
8
|
+
|
|
9
|
+
- ``swift_lower``: Python ``str.lower()`` applies the Unicode Final_Sigma
|
|
10
|
+
contextual rule (``"ΑΣ".lower() == "ας"``); Swift ``lowercased()`` maps
|
|
11
|
+
Σ→σ unconditionally. Fingerprints hash lowercased text, so this must match.
|
|
12
|
+
- ``split_words`` / ``split_letter_runs``: Swift splits on grapheme clusters
|
|
13
|
+
using ``Character.isLetter``/``.isNumber`` (Alphabetic incl. Nl; a cluster
|
|
14
|
+
like e+◌́ is one letter). Python evaluates per code point, so input is
|
|
15
|
+
NFC-normalized first (composing most marks) and Nl is treated as a letter.
|
|
16
|
+
Swift ``Set<String>`` term comparison uses canonical equivalence, which the
|
|
17
|
+
NFC normalization also reproduces.
|
|
18
|
+
- ``swift_trim_whitespace``: Python ``str.strip()`` strips the information
|
|
19
|
+
separators U+001C–U+001F (``isspace() == True``); Swift's
|
|
20
|
+
``CharacterSet.whitespacesAndNewlines`` does not.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import unicodedata
|
|
26
|
+
from typing import List
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def swift_lower(text: str) -> str:
|
|
30
|
+
"""``String.lowercased()`` parity: unconditional mappings, no Final_Sigma."""
|
|
31
|
+
return text.replace("Σ", "σ").lower()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_letter(ch: str) -> bool:
|
|
35
|
+
# Swift Character.isLetter is the Unicode Alphabetic property, which
|
|
36
|
+
# includes Letter_Number (Nl) characters like Ⅻ.
|
|
37
|
+
return ch.isalpha() or unicodedata.category(ch) == "Nl"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_word_char(ch: str) -> bool:
|
|
41
|
+
return _is_letter(ch) or ch.isnumeric()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def split_words(text: str) -> List[str]:
|
|
45
|
+
"""Runs of letter/number characters — Swift's
|
|
46
|
+
``split(whereSeparator: { !$0.isLetter && !$0.isNumber })`` semantics.
|
|
47
|
+
NFC-normalizes first so combining sequences behave like Swift's grapheme
|
|
48
|
+
clusters and the resulting tokens compare canonically."""
|
|
49
|
+
return _runs(unicodedata.normalize("NFC", text), _is_word_char)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def split_letter_runs(text: str) -> List[str]:
|
|
53
|
+
"""Runs of letters only (Swift's ``!$0.isLetter`` separator)."""
|
|
54
|
+
return _runs(unicodedata.normalize("NFC", text), _is_letter)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _runs(text: str, predicate) -> List[str]:
|
|
58
|
+
words: List[str] = []
|
|
59
|
+
current: List[str] = []
|
|
60
|
+
for ch in text:
|
|
61
|
+
if predicate(ch):
|
|
62
|
+
current.append(ch)
|
|
63
|
+
elif current:
|
|
64
|
+
words.append("".join(current))
|
|
65
|
+
current = []
|
|
66
|
+
if current:
|
|
67
|
+
words.append("".join(current))
|
|
68
|
+
return words
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# CharacterSet.whitespacesAndNewlines: Zs + Zl + Zp categories plus the
|
|
72
|
+
# control-range \t \n \v \f \r and U+0085. NOT U+001C-U+001F.
|
|
73
|
+
_TRIM_EXTRA = {"\t", "\n", "\x0b", "\x0c", "\r", "\x85"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_swift_whitespace(ch: str) -> bool:
|
|
77
|
+
return ch in _TRIM_EXTRA or unicodedata.category(ch) in ("Zs", "Zl", "Zp")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def swift_trim_whitespace(text: str) -> str:
|
|
81
|
+
"""``trimmingCharacters(in: .whitespacesAndNewlines)`` parity."""
|
|
82
|
+
start = 0
|
|
83
|
+
end = len(text)
|
|
84
|
+
while start < end and _is_swift_whitespace(text[start]):
|
|
85
|
+
start += 1
|
|
86
|
+
while end > start and _is_swift_whitespace(text[end - 1]):
|
|
87
|
+
end -= 1
|
|
88
|
+
return text[start:end]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def canonical_compare_key(text: str) -> str:
|
|
92
|
+
"""Approximation of Swift ``String`` ordering/equality: canonical (NFC)
|
|
93
|
+
normalization before code-point comparison."""
|
|
94
|
+
return unicodedata.normalize("NFC", text)
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Versioned benchmark harness — mirrors the Swift `BenchmarkModels.swift` /
|
|
5
|
+
`BenchmarkRunner.swift`, including the safety-gate semantics: a corpus with no
|
|
6
|
+
unsafe-expected cases must not pass vacuously."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
from ._text import split_words, swift_lower
|
|
14
|
+
from .extractor import ClaimExtractor
|
|
15
|
+
from .models import (
|
|
16
|
+
APPROVED_STATUSES,
|
|
17
|
+
Claim,
|
|
18
|
+
ClaimType,
|
|
19
|
+
EvidencePassage,
|
|
20
|
+
SupportStatus,
|
|
21
|
+
)
|
|
22
|
+
from .verifier import ClaimVerifier
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_CATEGORIES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"directSupport", "reasonableInference", "partialSupport", "contradiction",
|
|
28
|
+
"unsupported", "citationIntegrity", "sourceAuthority", "notVerifiable",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validated_category(category: str) -> str:
|
|
34
|
+
# Swift decodes category into an 8-case enum; unknown values must fail
|
|
35
|
+
# decoding (exit 65 at the CLI), not pass through silently.
|
|
36
|
+
if category not in _CATEGORIES:
|
|
37
|
+
raise ValueError(f"unknown benchmark category: {category!r}")
|
|
38
|
+
return category
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ClaimBenchmarkCase:
|
|
43
|
+
id: str
|
|
44
|
+
category: str
|
|
45
|
+
claim_text: str
|
|
46
|
+
claim_type: ClaimType
|
|
47
|
+
evidence: Tuple[EvidencePassage, ...]
|
|
48
|
+
expected_status: SupportStatus
|
|
49
|
+
rationale: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ExtractionBenchmarkCase:
|
|
54
|
+
id: str
|
|
55
|
+
document: str
|
|
56
|
+
expected_claims: Tuple[str, ...]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class BenchmarkCorpus:
|
|
61
|
+
version: str
|
|
62
|
+
name: str
|
|
63
|
+
claim_cases: Tuple[ClaimBenchmarkCase, ...]
|
|
64
|
+
extraction_cases: Tuple[ExtractionBenchmarkCase, ...]
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def from_dict(data: dict) -> "BenchmarkCorpus":
|
|
68
|
+
from .models import SourceType
|
|
69
|
+
|
|
70
|
+
return BenchmarkCorpus(
|
|
71
|
+
version=data["version"],
|
|
72
|
+
name=data["name"],
|
|
73
|
+
claim_cases=tuple(
|
|
74
|
+
ClaimBenchmarkCase(
|
|
75
|
+
id=case["id"],
|
|
76
|
+
category=_validated_category(case["category"]),
|
|
77
|
+
claim_text=case["claimText"],
|
|
78
|
+
claim_type=ClaimType(case["claimType"]),
|
|
79
|
+
evidence=tuple(
|
|
80
|
+
EvidencePassage(
|
|
81
|
+
id=p["id"],
|
|
82
|
+
source=p["source"],
|
|
83
|
+
source_type=SourceType(p["sourceType"]),
|
|
84
|
+
text=p["text"],
|
|
85
|
+
)
|
|
86
|
+
for p in case["evidence"]
|
|
87
|
+
),
|
|
88
|
+
expected_status=SupportStatus(case["expectedStatus"]),
|
|
89
|
+
rationale=case["rationale"],
|
|
90
|
+
)
|
|
91
|
+
for case in data["claimCases"]
|
|
92
|
+
),
|
|
93
|
+
extraction_cases=tuple(
|
|
94
|
+
ExtractionBenchmarkCase(
|
|
95
|
+
id=case["id"],
|
|
96
|
+
document=case["document"],
|
|
97
|
+
expected_claims=tuple(case["expectedClaims"]),
|
|
98
|
+
)
|
|
99
|
+
for case in data["extractionCases"]
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class BenchmarkThresholds:
|
|
106
|
+
minimum_verdict_accuracy: float = 0.70
|
|
107
|
+
maximum_false_supported_rate: float = 0.0
|
|
108
|
+
minimum_contradiction_recall: float = 0.80
|
|
109
|
+
minimum_extraction_precision: float = 0.80
|
|
110
|
+
minimum_extraction_recall: float = 0.80
|
|
111
|
+
# Ship criteria (feature go/no-go), evaluated separately from the regression gate.
|
|
112
|
+
minimum_supported_precision: float = 0.95
|
|
113
|
+
minimum_unsupported_recall: float = 0.90
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class StatusMetric:
|
|
118
|
+
expected_count: int
|
|
119
|
+
predicted_count: int
|
|
120
|
+
match_count: int
|
|
121
|
+
precision: float
|
|
122
|
+
recall: float
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class CategoryMetric:
|
|
127
|
+
case_count: int
|
|
128
|
+
exact_match_count: int
|
|
129
|
+
dangerous_approval_count: int
|
|
130
|
+
exact_match_rate: float
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class CalibrationBin:
|
|
135
|
+
lower_bound: float
|
|
136
|
+
upper_bound: float
|
|
137
|
+
case_count: int
|
|
138
|
+
average_confidence: float
|
|
139
|
+
exact_match_rate: float
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class CalibrationSummary:
|
|
144
|
+
bins: Tuple[CalibrationBin, ...]
|
|
145
|
+
expected_calibration_error: float
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class BenchmarkCaseResult:
|
|
150
|
+
id: str
|
|
151
|
+
category: str
|
|
152
|
+
expected: SupportStatus
|
|
153
|
+
predicted: SupportStatus
|
|
154
|
+
confidence: float
|
|
155
|
+
passed: bool
|
|
156
|
+
dangerously_approved: bool
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class ExtractionCaseResult:
|
|
161
|
+
id: str
|
|
162
|
+
expected: Tuple[str, ...]
|
|
163
|
+
predicted: Tuple[str, ...]
|
|
164
|
+
true_positive_count: int
|
|
165
|
+
false_positive_count: int
|
|
166
|
+
false_negative_count: int
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class BenchmarkReport:
|
|
171
|
+
corpus_version: str
|
|
172
|
+
corpus_name: str
|
|
173
|
+
thresholds: BenchmarkThresholds
|
|
174
|
+
claim_results: Tuple[BenchmarkCaseResult, ...]
|
|
175
|
+
extraction_results: Tuple[ExtractionCaseResult, ...]
|
|
176
|
+
verdict_accuracy: float
|
|
177
|
+
false_supported_rate: float
|
|
178
|
+
contradiction_recall: float
|
|
179
|
+
extraction_precision: float
|
|
180
|
+
extraction_recall: float
|
|
181
|
+
supported_precision: float
|
|
182
|
+
unsupported_recall: float
|
|
183
|
+
warning_precision: float
|
|
184
|
+
abstention_precision: float
|
|
185
|
+
abstention_recall: float
|
|
186
|
+
status_metrics: Dict[str, StatusMetric]
|
|
187
|
+
category_breakdown: Dict[str, CategoryMetric]
|
|
188
|
+
calibration: CalibrationSummary
|
|
189
|
+
confusion_matrix: Dict[str, Dict[str, int]]
|
|
190
|
+
passes_safety_gate: bool
|
|
191
|
+
meets_ship_criteria: bool
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _canonical_claim(text: str) -> str:
|
|
195
|
+
return " ".join(split_words(swift_lower(text)))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ratio(numerator: int, denominator: int) -> float:
|
|
199
|
+
return 1.0 if denominator == 0 else numerator / denominator
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_abstention(status: SupportStatus) -> bool:
|
|
203
|
+
return status in (SupportStatus.NOT_VERIFIABLE, SupportStatus.AMBIGUOUS)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _calibration_summary(results: Tuple[BenchmarkCaseResult, ...]) -> CalibrationSummary:
|
|
207
|
+
bin_count = 5
|
|
208
|
+
binned: List[List[BenchmarkCaseResult]] = [[] for _ in range(bin_count)]
|
|
209
|
+
for result in results:
|
|
210
|
+
index = min(bin_count - 1, max(0, int(result.confidence * bin_count)))
|
|
211
|
+
binned[index].append(result)
|
|
212
|
+
bins: List[CalibrationBin] = []
|
|
213
|
+
error_sum = 0.0
|
|
214
|
+
for index, contents in enumerate(binned):
|
|
215
|
+
lower = index / bin_count
|
|
216
|
+
upper = (index + 1) / bin_count
|
|
217
|
+
if contents:
|
|
218
|
+
# Sequential left-to-right sum, matching Swift's reduce order so the
|
|
219
|
+
# Double result is bit-identical.
|
|
220
|
+
confidence_sum = 0.0
|
|
221
|
+
for result in contents:
|
|
222
|
+
confidence_sum += result.confidence
|
|
223
|
+
average_confidence = confidence_sum / len(contents)
|
|
224
|
+
exact_match_rate = sum(1 for r in contents if r.passed) / len(contents)
|
|
225
|
+
else:
|
|
226
|
+
average_confidence = 0.0
|
|
227
|
+
exact_match_rate = 0.0
|
|
228
|
+
bins.append(
|
|
229
|
+
CalibrationBin(
|
|
230
|
+
lower_bound=lower,
|
|
231
|
+
upper_bound=upper,
|
|
232
|
+
case_count=len(contents),
|
|
233
|
+
average_confidence=average_confidence,
|
|
234
|
+
exact_match_rate=exact_match_rate,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
if contents and results:
|
|
238
|
+
weight = len(contents) / len(results)
|
|
239
|
+
error_sum += weight * abs(exact_match_rate - average_confidence)
|
|
240
|
+
return CalibrationSummary(bins=tuple(bins), expected_calibration_error=error_sum)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class BenchmarkRunner:
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
verifier: "ClaimVerifier | None" = None,
|
|
247
|
+
extractor: "ClaimExtractor | None" = None,
|
|
248
|
+
thresholds: BenchmarkThresholds = BenchmarkThresholds(),
|
|
249
|
+
):
|
|
250
|
+
self.verifier = verifier if verifier is not None else ClaimVerifier()
|
|
251
|
+
self.extractor = extractor if extractor is not None else ClaimExtractor()
|
|
252
|
+
self.thresholds = thresholds
|
|
253
|
+
|
|
254
|
+
def run(self, corpus: BenchmarkCorpus) -> BenchmarkReport:
|
|
255
|
+
claim_results = tuple(self._run_claim_case(c) for c in corpus.claim_cases)
|
|
256
|
+
extraction_results = tuple(self._run_extraction_case(c) for c in corpus.extraction_cases)
|
|
257
|
+
|
|
258
|
+
accurate = sum(1 for r in claim_results if r.passed)
|
|
259
|
+
verdict_accuracy = _ratio(accurate, len(claim_results))
|
|
260
|
+
|
|
261
|
+
unsafe_cases = [r for r in claim_results if r.expected not in APPROVED_STATUSES]
|
|
262
|
+
dangerous = sum(1 for r in unsafe_cases if r.dangerously_approved)
|
|
263
|
+
# A failure rate is vacuously 0 (not 1) when no unsafe-expected cases exist.
|
|
264
|
+
false_supported_rate = 0.0 if not unsafe_cases else _ratio(dangerous, len(unsafe_cases))
|
|
265
|
+
|
|
266
|
+
expected_contradictions = [r for r in claim_results if r.expected == SupportStatus.CONTRADICTED]
|
|
267
|
+
found = sum(1 for r in expected_contradictions if r.predicted == SupportStatus.CONTRADICTED)
|
|
268
|
+
contradiction_recall = _ratio(found, len(expected_contradictions))
|
|
269
|
+
|
|
270
|
+
tp = sum(r.true_positive_count for r in extraction_results)
|
|
271
|
+
fp = sum(r.false_positive_count for r in extraction_results)
|
|
272
|
+
fn = sum(r.false_negative_count for r in extraction_results)
|
|
273
|
+
extraction_precision = _ratio(tp, tp + fp)
|
|
274
|
+
extraction_recall = _ratio(tp, tp + fn)
|
|
275
|
+
|
|
276
|
+
confusion: Dict[str, Dict[str, int]] = {}
|
|
277
|
+
for result in claim_results:
|
|
278
|
+
row = confusion.setdefault(result.expected.value, {})
|
|
279
|
+
row[result.predicted.value] = row.get(result.predicted.value, 0) + 1
|
|
280
|
+
|
|
281
|
+
# Binary safe/unsafe metrics — same definitions and operation order as
|
|
282
|
+
# the Swift reference so the JSON reports compare equal.
|
|
283
|
+
predicted_approved = [r for r in claim_results if r.predicted in APPROVED_STATUSES]
|
|
284
|
+
supported_precision = _ratio(
|
|
285
|
+
sum(1 for r in predicted_approved if r.expected in APPROVED_STATUSES),
|
|
286
|
+
len(predicted_approved),
|
|
287
|
+
)
|
|
288
|
+
unsupported_recall = _ratio(
|
|
289
|
+
sum(1 for r in unsafe_cases if r.predicted not in APPROVED_STATUSES),
|
|
290
|
+
len(unsafe_cases),
|
|
291
|
+
)
|
|
292
|
+
predicted_flagged = [r for r in claim_results if r.predicted not in APPROVED_STATUSES]
|
|
293
|
+
warning_precision = _ratio(
|
|
294
|
+
sum(1 for r in predicted_flagged if r.expected not in APPROVED_STATUSES),
|
|
295
|
+
len(predicted_flagged),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
predicted_abstain = [r for r in claim_results if _is_abstention(r.predicted)]
|
|
299
|
+
expected_abstain = [r for r in claim_results if _is_abstention(r.expected)]
|
|
300
|
+
abstention_precision = _ratio(
|
|
301
|
+
sum(1 for r in predicted_abstain if _is_abstention(r.expected)),
|
|
302
|
+
len(predicted_abstain),
|
|
303
|
+
)
|
|
304
|
+
abstention_recall = _ratio(
|
|
305
|
+
sum(1 for r in expected_abstain if _is_abstention(r.predicted)),
|
|
306
|
+
len(expected_abstain),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
status_metrics: Dict[str, StatusMetric] = {}
|
|
310
|
+
for status in SupportStatus:
|
|
311
|
+
expected_count = sum(1 for r in claim_results if r.expected == status)
|
|
312
|
+
predicted_count = sum(1 for r in claim_results if r.predicted == status)
|
|
313
|
+
matches = sum(1 for r in claim_results if r.expected == status and r.predicted == status)
|
|
314
|
+
status_metrics[status.value] = StatusMetric(
|
|
315
|
+
expected_count=expected_count,
|
|
316
|
+
predicted_count=predicted_count,
|
|
317
|
+
match_count=matches,
|
|
318
|
+
precision=_ratio(matches, predicted_count),
|
|
319
|
+
recall=_ratio(matches, expected_count),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
category_breakdown: Dict[str, CategoryMetric] = {}
|
|
323
|
+
for category in sorted(_CATEGORIES):
|
|
324
|
+
cases = [r for r in claim_results if r.category == category]
|
|
325
|
+
if not cases:
|
|
326
|
+
continue
|
|
327
|
+
exact = sum(1 for r in cases if r.passed)
|
|
328
|
+
category_breakdown[category] = CategoryMetric(
|
|
329
|
+
case_count=len(cases),
|
|
330
|
+
exact_match_count=exact,
|
|
331
|
+
dangerous_approval_count=sum(1 for r in cases if r.dangerously_approved),
|
|
332
|
+
exact_match_rate=_ratio(exact, len(cases)),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
calibration = _calibration_summary(claim_results)
|
|
336
|
+
|
|
337
|
+
thresholds = self.thresholds
|
|
338
|
+
# A corpus with no unsafe-expected cases cannot measure false approvals,
|
|
339
|
+
# so it must not pass the safety gate vacuously.
|
|
340
|
+
passes = (
|
|
341
|
+
bool(unsafe_cases)
|
|
342
|
+
and verdict_accuracy >= thresholds.minimum_verdict_accuracy
|
|
343
|
+
and false_supported_rate <= thresholds.maximum_false_supported_rate
|
|
344
|
+
and contradiction_recall >= thresholds.minimum_contradiction_recall
|
|
345
|
+
and extraction_precision >= thresholds.minimum_extraction_precision
|
|
346
|
+
and extraction_recall >= thresholds.minimum_extraction_recall
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# The ship criteria are a stricter, separate bar: they gate whether the
|
|
350
|
+
# verifier is fit to face users, not whether the build regressed.
|
|
351
|
+
ships = (
|
|
352
|
+
bool(unsafe_cases)
|
|
353
|
+
and supported_precision >= thresholds.minimum_supported_precision
|
|
354
|
+
and unsupported_recall >= thresholds.minimum_unsupported_recall
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return BenchmarkReport(
|
|
358
|
+
corpus_version=corpus.version,
|
|
359
|
+
corpus_name=corpus.name,
|
|
360
|
+
thresholds=thresholds,
|
|
361
|
+
claim_results=claim_results,
|
|
362
|
+
extraction_results=extraction_results,
|
|
363
|
+
verdict_accuracy=verdict_accuracy,
|
|
364
|
+
false_supported_rate=false_supported_rate,
|
|
365
|
+
contradiction_recall=contradiction_recall,
|
|
366
|
+
extraction_precision=extraction_precision,
|
|
367
|
+
extraction_recall=extraction_recall,
|
|
368
|
+
supported_precision=supported_precision,
|
|
369
|
+
unsupported_recall=unsupported_recall,
|
|
370
|
+
warning_precision=warning_precision,
|
|
371
|
+
abstention_precision=abstention_precision,
|
|
372
|
+
abstention_recall=abstention_recall,
|
|
373
|
+
status_metrics=status_metrics,
|
|
374
|
+
category_breakdown=category_breakdown,
|
|
375
|
+
calibration=calibration,
|
|
376
|
+
confusion_matrix=confusion,
|
|
377
|
+
passes_safety_gate=passes,
|
|
378
|
+
meets_ship_criteria=ships,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def _run_claim_case(self, case: ClaimBenchmarkCase) -> BenchmarkCaseResult:
|
|
382
|
+
claim = Claim(id=case.id, text=case.claim_text, type=case.claim_type)
|
|
383
|
+
verdict = self.verifier.verify(claim, case.evidence)
|
|
384
|
+
approved_predicted = verdict.status in APPROVED_STATUSES
|
|
385
|
+
approved_expected = case.expected_status in APPROVED_STATUSES
|
|
386
|
+
return BenchmarkCaseResult(
|
|
387
|
+
id=case.id,
|
|
388
|
+
category=case.category,
|
|
389
|
+
expected=case.expected_status,
|
|
390
|
+
predicted=verdict.status,
|
|
391
|
+
confidence=verdict.confidence,
|
|
392
|
+
passed=verdict.status == case.expected_status,
|
|
393
|
+
dangerously_approved=approved_predicted and not approved_expected,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def _run_extraction_case(self, case: ExtractionBenchmarkCase) -> ExtractionCaseResult:
|
|
397
|
+
expected = {_canonical_claim(text) for text in case.expected_claims}
|
|
398
|
+
predicted_claims = tuple(c.text for c in self.extractor.extract(case.document))
|
|
399
|
+
predicted = {_canonical_claim(text) for text in predicted_claims}
|
|
400
|
+
return ExtractionCaseResult(
|
|
401
|
+
id=case.id,
|
|
402
|
+
expected=case.expected_claims,
|
|
403
|
+
predicted=predicted_claims,
|
|
404
|
+
true_positive_count=len(expected & predicted),
|
|
405
|
+
false_positive_count=len(predicted - expected),
|
|
406
|
+
false_negative_count=len(expected - predicted),
|
|
407
|
+
)
|