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.
@@ -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
+ )