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
claimproofkit/cli.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""``claimproofkit`` CLI — mirrors the Swift executable's commands and exit codes.
|
|
5
|
+
|
|
6
|
+
Exit codes: 0 published / gate passed, 2 blocked, 3 benchmark gate failed,
|
|
7
|
+
64 usage, 65 unreadable input, 73 provenance trace write failed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from .benchmark import BenchmarkCorpus, BenchmarkRunner
|
|
18
|
+
from .models import EvidencePassage, SourceType, VerificationPolicy
|
|
19
|
+
from .provenance import ProvenanceExporter
|
|
20
|
+
from .report import benchmark_report_json, render_benchmark_markdown, render_report_markdown
|
|
21
|
+
from .verifier import ClaimVerifier
|
|
22
|
+
|
|
23
|
+
USAGE = """Usage:
|
|
24
|
+
claimproofkit verify <input.json> [--provenance <trace.json>]
|
|
25
|
+
claimproofkit benchmark <corpus.json> [--json]
|
|
26
|
+
|
|
27
|
+
--provenance writes a DProvenanceKit-compatible trace manifest of the
|
|
28
|
+
verification run alongside the report.
|
|
29
|
+
|
|
30
|
+
Exit codes: 0 published, 2 blocked, 3 benchmark gate failed,
|
|
31
|
+
64 usage, 65 unreadable input, 73 provenance trace write failed.
|
|
32
|
+
|
|
33
|
+
Backward compatibility: claimproofkit <input.json> runs verification.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _usage(code: int) -> int:
|
|
38
|
+
sys.stderr.write(USAGE)
|
|
39
|
+
return code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_verification(path: str, provenance_path: Optional[str]) -> int:
|
|
43
|
+
try:
|
|
44
|
+
with open(path, encoding="utf-8") as handle:
|
|
45
|
+
data = json.load(handle)
|
|
46
|
+
evidence = [
|
|
47
|
+
EvidencePassage(
|
|
48
|
+
id=p["id"],
|
|
49
|
+
source=p["source"],
|
|
50
|
+
source_type=SourceType(p["sourceType"]),
|
|
51
|
+
text=p["text"],
|
|
52
|
+
)
|
|
53
|
+
for p in data["evidence"]
|
|
54
|
+
]
|
|
55
|
+
policy = VerificationPolicy()
|
|
56
|
+
report = ClaimVerifier(policy).verify_document(data["document"], evidence, created_at=time.time())
|
|
57
|
+
except Exception as error: # noqa: BLE001 - CLI boundary
|
|
58
|
+
sys.stderr.write(f"claimproofkit: {error}\n")
|
|
59
|
+
return 65
|
|
60
|
+
|
|
61
|
+
# The report is always emitted, even if the trace write fails afterwards.
|
|
62
|
+
print(render_report_markdown(report))
|
|
63
|
+
if provenance_path is not None:
|
|
64
|
+
try:
|
|
65
|
+
trace = ProvenanceExporter().trace(report, policy)
|
|
66
|
+
with open(provenance_path, "w", encoding="utf-8") as handle:
|
|
67
|
+
handle.write(trace.manifest_json())
|
|
68
|
+
sys.stderr.write(
|
|
69
|
+
f"provenance trace written to {provenance_path} (run fingerprint {trace.run_fingerprint})\n"
|
|
70
|
+
)
|
|
71
|
+
except Exception as error: # noqa: BLE001 - CLI boundary
|
|
72
|
+
sys.stderr.write(f"claimproofkit: failed to write provenance trace to {provenance_path}: {error}\n")
|
|
73
|
+
return 73
|
|
74
|
+
return 0 if report.is_safe_to_publish else 2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_benchmark(path: str, as_json: bool) -> int:
|
|
78
|
+
try:
|
|
79
|
+
with open(path, encoding="utf-8") as handle:
|
|
80
|
+
corpus = BenchmarkCorpus.from_dict(json.load(handle))
|
|
81
|
+
except Exception as error: # noqa: BLE001 - CLI boundary
|
|
82
|
+
sys.stderr.write(f"claimproofkit: {error}\n")
|
|
83
|
+
return 65
|
|
84
|
+
report = BenchmarkRunner().run(corpus)
|
|
85
|
+
print(benchmark_report_json(report) if as_json else render_benchmark_markdown(report))
|
|
86
|
+
return 0 if report.passes_safety_gate else 3
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
90
|
+
# The Swift CLI writes UTF-8 bytes unconditionally; without this, a
|
|
91
|
+
# non-UTF-8 stdout (e.g. cp1252) crashes mid-print with exit 1 and breaks
|
|
92
|
+
# the documented exit-code contract.
|
|
93
|
+
for stream in (sys.stdout, sys.stderr):
|
|
94
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
95
|
+
if reconfigure is not None:
|
|
96
|
+
reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
97
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
98
|
+
if "--help" in arguments or "-h" in arguments:
|
|
99
|
+
sys.stderr.write(USAGE)
|
|
100
|
+
return 0
|
|
101
|
+
if not arguments:
|
|
102
|
+
return _usage(64)
|
|
103
|
+
|
|
104
|
+
command = arguments[0]
|
|
105
|
+
if command == "verify":
|
|
106
|
+
path: Optional[str] = None
|
|
107
|
+
provenance: Optional[str] = None
|
|
108
|
+
rest = arguments[1:]
|
|
109
|
+
while rest:
|
|
110
|
+
argument = rest.pop(0)
|
|
111
|
+
if argument == "--provenance":
|
|
112
|
+
if provenance is not None or not rest:
|
|
113
|
+
return _usage(64)
|
|
114
|
+
provenance = rest.pop(0)
|
|
115
|
+
elif path is None:
|
|
116
|
+
path = argument
|
|
117
|
+
else:
|
|
118
|
+
return _usage(64)
|
|
119
|
+
if path is None:
|
|
120
|
+
return _usage(64)
|
|
121
|
+
return _run_verification(path, provenance)
|
|
122
|
+
if command == "benchmark":
|
|
123
|
+
rest = arguments[1:]
|
|
124
|
+
if len(rest) == 1:
|
|
125
|
+
return _run_benchmark(rest[0], as_json=False)
|
|
126
|
+
if len(rest) == 2 and rest[1] == "--json":
|
|
127
|
+
return _run_benchmark(rest[0], as_json=True)
|
|
128
|
+
return _usage(64)
|
|
129
|
+
if len(arguments) == 1:
|
|
130
|
+
# Backward compatibility: a lone path runs verification.
|
|
131
|
+
return _run_verification(command, None)
|
|
132
|
+
return _usage(64)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Claim extraction — mirrors the Swift `ClaimExtractor.swift`.
|
|
5
|
+
|
|
6
|
+
Claim ids and ranges use UTF-16 code-unit offsets (Swift extracts with
|
|
7
|
+
NSRegularExpression over NSString), so ids like ``claim-46-0`` are identical
|
|
8
|
+
across both implementations even for documents containing astral-plane
|
|
9
|
+
characters.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from ._text import split_words, swift_lower, swift_trim_whitespace
|
|
18
|
+
from .models import Claim, ClaimType
|
|
19
|
+
|
|
20
|
+
# ICU's non-multiline `$` (NSRegularExpression) matches at end of string AND
|
|
21
|
+
# before a final line terminator; Python's non-MULTILINE `$` matches at end
|
|
22
|
+
# and before a final "\n". Combined with the `[^.!?\n]+` class and the
|
|
23
|
+
# Swift-parity trimming below, the extracted claim text and fingerprint are
|
|
24
|
+
# identical for every terminator (\n, \r\n, U+0085, U+2028, U+2029) —
|
|
25
|
+
# verified differentially against the Swift CLI.
|
|
26
|
+
_SENTENCE = re.compile(r"[^.!?\n]+(?:[.!?]+|$)")
|
|
27
|
+
|
|
28
|
+
# ICU regex `\s` does NOT include the information separators U+001C-U+001F;
|
|
29
|
+
# Python's Unicode `\s` does, so the whitespace class is spelled out to match
|
|
30
|
+
# ICU's set exactly.
|
|
31
|
+
_ICU_WS = "\\t-\\r \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000"
|
|
32
|
+
_CLAUSE_SEPARATOR = re.compile(f"[{_ICU_WS}]+(?:and|but)[{_ICU_WS}]+", re.IGNORECASE)
|
|
33
|
+
|
|
34
|
+
_NON_CLAIMS = ("please note", "this document is not legal advice")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _utf16_offset(document: str, char_index: int) -> int:
|
|
38
|
+
"""Number of UTF-16 code units before ``char_index`` (Swift NSRange origin)."""
|
|
39
|
+
prefix = document[:char_index]
|
|
40
|
+
return sum(2 if ord(ch) > 0xFFFF else 1 for ch in prefix)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _utf16_length(text: str) -> int:
|
|
44
|
+
return sum(2 if ord(ch) > 0xFFFF else 1 for ch in text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_material_claim(text: str) -> bool:
|
|
48
|
+
if not text or text.endswith("?"):
|
|
49
|
+
return False
|
|
50
|
+
if len(split_words(text)) < 4:
|
|
51
|
+
return False
|
|
52
|
+
lower = swift_lower(text)
|
|
53
|
+
return not any(marker in lower for marker in _NON_CLAIMS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _atomic_clauses(sentence: str) -> List[str]:
|
|
57
|
+
# Swift replaces separator matches with a U+001F sentinel and then splits
|
|
58
|
+
# on it, so a pre-existing U+001F in the sentence also acts as a clause
|
|
59
|
+
# boundary. Reproduce that exactly rather than using re.split.
|
|
60
|
+
sentinel = _CLAUSE_SEPARATOR.sub("\x1f", sentence)
|
|
61
|
+
parts = [swift_trim_whitespace(part) for part in sentinel.split("\x1f")]
|
|
62
|
+
parts = [p for p in parts if p]
|
|
63
|
+
# Swift's split(separator: " ") drops empty subsequences, so runs of spaces
|
|
64
|
+
# must not inflate the word count here.
|
|
65
|
+
if len(parts) > 1 and all(len([w for w in p.split(" ") if w]) >= 3 for p in parts):
|
|
66
|
+
return parts
|
|
67
|
+
return [sentence]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _classify(claim: str) -> ClaimType:
|
|
71
|
+
lower = swift_lower(claim)
|
|
72
|
+
if " recommends " in lower or lower.startswith("recommend "):
|
|
73
|
+
return ClaimType.RECOMMENDATION
|
|
74
|
+
if " therefore " in lower or " violated " in lower:
|
|
75
|
+
return ClaimType.LEGAL_CONCLUSION
|
|
76
|
+
if (
|
|
77
|
+
" law requires " in lower
|
|
78
|
+
or " statute " in lower
|
|
79
|
+
or " rule requires " in lower
|
|
80
|
+
or " must be filed" in lower
|
|
81
|
+
or " shall be filed" in lower
|
|
82
|
+
):
|
|
83
|
+
return ClaimType.LEGAL_RULE
|
|
84
|
+
if " calculated " in lower or " equals " in lower:
|
|
85
|
+
return ClaimType.CALCULATION
|
|
86
|
+
if '"' in claim:
|
|
87
|
+
return ClaimType.QUOTATION
|
|
88
|
+
return ClaimType.FACTUAL
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ClaimExtractor:
|
|
92
|
+
"""Deterministic sentence/clause extractor, spec-locked with the Swift one."""
|
|
93
|
+
|
|
94
|
+
def extract(self, document: str) -> List[Claim]:
|
|
95
|
+
claims: List[Claim] = []
|
|
96
|
+
for match in _SENTENCE.finditer(document):
|
|
97
|
+
raw = match.group(0)
|
|
98
|
+
text = swift_trim_whitespace(raw)
|
|
99
|
+
if not _is_material_claim(text):
|
|
100
|
+
continue
|
|
101
|
+
start = _utf16_offset(document, match.start())
|
|
102
|
+
length = _utf16_length(raw)
|
|
103
|
+
for index, clause in enumerate(_atomic_clauses(text)):
|
|
104
|
+
claims.append(
|
|
105
|
+
Claim(
|
|
106
|
+
id=f"claim-{start}-{index}",
|
|
107
|
+
text=clause,
|
|
108
|
+
type=_classify(clause),
|
|
109
|
+
parent_text=None if clause == text else text,
|
|
110
|
+
range=(start, start + length),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return claims
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Claim Proof Specification v1 identity algorithms.
|
|
5
|
+
|
|
6
|
+
Mirrors the Swift `ProofIdentity.swift` byte-for-byte. Every function here is
|
|
7
|
+
locked by the golden vectors vendored under ``conformance/vectors`` (whose
|
|
8
|
+
source of truth is the Swift repo's ConformanceHarness).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .models import EvidencePassage, ProofReport, VerificationPolicy
|
|
19
|
+
|
|
20
|
+
SPEC_VERSION = "1.0"
|
|
21
|
+
VERIFIER_IDENTIFIER = "cpk-lexical"
|
|
22
|
+
VERIFIER_VERSION = "1.0"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_hex(text: str) -> str:
|
|
26
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def swift_number(value: Any) -> str:
|
|
30
|
+
"""Render a number the way Swift's ``JSONEncoder`` does.
|
|
31
|
+
|
|
32
|
+
Integral Doubles print without a fractional part (``Double(1)`` → ``"1"``)
|
|
33
|
+
up to the magnitude where Swift switches to exponent form (1e16); above
|
|
34
|
+
that, and for all non-integral values, Swift uses shortest round-trip
|
|
35
|
+
rendering, which matches Python's ``repr`` for finite floats. Negative
|
|
36
|
+
zero stays ``"-0"``.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, bool): # bool is an int subclass; keep JSON booleans out
|
|
39
|
+
raise TypeError("swift_number does not take booleans")
|
|
40
|
+
if isinstance(value, int):
|
|
41
|
+
return str(value)
|
|
42
|
+
if isinstance(value, float):
|
|
43
|
+
if not math.isfinite(value):
|
|
44
|
+
raise ValueError("non-finite numbers are not canonically encodable")
|
|
45
|
+
if value == 0 and math.copysign(1, value) < 0:
|
|
46
|
+
return "-0"
|
|
47
|
+
if value.is_integer() and abs(value) < 1e16:
|
|
48
|
+
return str(int(value))
|
|
49
|
+
return repr(value)
|
|
50
|
+
raise TypeError(f"not a number: {value!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def swift_interpolation(value: Any) -> str:
|
|
54
|
+
"""Render a number the way Swift STRING INTERPOLATION does — distinct from
|
|
55
|
+
JSON encoding: ``"\\(Double(1))"`` is ``"1.0"``, not ``"1"``. Python's
|
|
56
|
+
``repr`` for floats matches Swift's Double description exactly for the
|
|
57
|
+
shortest-round-trip cases used in fingerprint listings.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
raise TypeError("swift_interpolation does not take booleans")
|
|
61
|
+
if isinstance(value, int):
|
|
62
|
+
return str(value)
|
|
63
|
+
if isinstance(value, float):
|
|
64
|
+
if not math.isfinite(value):
|
|
65
|
+
raise ValueError("non-finite numbers are not interpolatable")
|
|
66
|
+
return repr(value)
|
|
67
|
+
raise TypeError(f"not a number: {value!r}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _json_string(text: str) -> str:
|
|
71
|
+
# Swift's JSONEncoder escapes forward slashes by default (the bridge encodes
|
|
72
|
+
# rawJSON with plain .sortedKeys), so the canonical form does too.
|
|
73
|
+
return json.dumps(text, ensure_ascii=False).replace("/", "\\/")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _canonical_fragment(value: Any) -> str:
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
return _json_string(value)
|
|
79
|
+
if isinstance(value, bool):
|
|
80
|
+
return "true" if value else "false"
|
|
81
|
+
if isinstance(value, (int, float)):
|
|
82
|
+
return swift_number(value)
|
|
83
|
+
if isinstance(value, dict):
|
|
84
|
+
items = sorted(value.items(), key=lambda kv: kv[0])
|
|
85
|
+
inner = ",".join(f"{_json_string(k)}:{_canonical_fragment(v)}" for k, v in items)
|
|
86
|
+
return "{" + inner + "}"
|
|
87
|
+
if isinstance(value, (list, tuple)):
|
|
88
|
+
return "[" + ",".join(_canonical_fragment(v) for v in value) + "]"
|
|
89
|
+
if value is None:
|
|
90
|
+
return "null"
|
|
91
|
+
raise TypeError(f"not canonically encodable: {type(value).__name__}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def canonical_json(value: Any) -> str:
|
|
95
|
+
"""Canonical payload encoding (Spec §2): sorted keys, UTF-8, no extra
|
|
96
|
+
whitespace, Swift number rendering. Byte-identical to Swift's
|
|
97
|
+
``JSONEncoder`` with ``.sortedKeys`` for the payload types used on the wire.
|
|
98
|
+
"""
|
|
99
|
+
return _canonical_fragment(value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def evidence_content_fingerprint(passage: EvidencePassage) -> str:
|
|
103
|
+
"""Spec §5: SHA-256 over the exact UTF-8 bytes of the passage text."""
|
|
104
|
+
return sha256_hex(passage.text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def report_fingerprint(report: ProofReport) -> str:
|
|
108
|
+
"""Spec §3: SHA-256 over the domain-separated verdict signature string."""
|
|
109
|
+
signature = "".join(f"{v.claim.fingerprint}:{v.status.value}|" for v in report.verdicts)
|
|
110
|
+
return sha256_hex("cpk:v1:report:" + signature)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def policy_fingerprint(
|
|
114
|
+
policy: VerificationPolicy,
|
|
115
|
+
verifier: str = VERIFIER_IDENTIFIER,
|
|
116
|
+
verifier_version: str = VERIFIER_VERSION,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Spec §4: SHA-256 over the frozen newline-delimited key:value listing.
|
|
119
|
+
|
|
120
|
+
The Swift reference builds this listing with STRING INTERPOLATION (not
|
|
121
|
+
JSONEncoder), so integral thresholds render as ``1.0``, never ``1``.
|
|
122
|
+
"""
|
|
123
|
+
canonical = "\n".join(
|
|
124
|
+
[
|
|
125
|
+
f"specVersion:{SPEC_VERSION}",
|
|
126
|
+
f"verifier:{verifier}",
|
|
127
|
+
f"verifierVersion:{verifier_version}",
|
|
128
|
+
f"supportedThreshold:{swift_interpolation(float(policy.supported_threshold))}",
|
|
129
|
+
f"ambiguousThreshold:{swift_interpolation(float(policy.ambiguous_threshold))}",
|
|
130
|
+
f"contradictionThreshold:{swift_interpolation(float(policy.contradiction_threshold))}",
|
|
131
|
+
f"maximumEvidenceMatches:{policy.maximum_evidence_matches}",
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
return sha256_hex(canonical)
|
claimproofkit/models.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Core data model — mirrors the Swift ClaimProofKit `Models.swift` exactly.
|
|
5
|
+
|
|
6
|
+
Claim Proof Specification v1 locks the claim fingerprint algorithm here; the
|
|
7
|
+
rest of the identity surface lives in :mod:`claimproofkit.identity`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SourceType(str, Enum):
|
|
18
|
+
EVIDENCE = "evidence"
|
|
19
|
+
DECLARATION = "declaration"
|
|
20
|
+
COURT_RECORD = "courtRecord"
|
|
21
|
+
STATUTE = "statute"
|
|
22
|
+
REGULATION = "regulation"
|
|
23
|
+
COURT_RULE = "courtRule"
|
|
24
|
+
CASE_LAW = "caseLaw"
|
|
25
|
+
CALCULATION_INPUT = "calculationInput"
|
|
26
|
+
UNKNOWN = "unknown"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class EvidencePassage:
|
|
31
|
+
id: str
|
|
32
|
+
source: str
|
|
33
|
+
text: str
|
|
34
|
+
source_type: SourceType = SourceType.UNKNOWN
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClaimType(str, Enum):
|
|
38
|
+
FACTUAL = "factual"
|
|
39
|
+
LEGAL_RULE = "legalRule"
|
|
40
|
+
LEGAL_CONCLUSION = "legalConclusion"
|
|
41
|
+
CALCULATION = "calculation"
|
|
42
|
+
RECOMMENDATION = "recommendation"
|
|
43
|
+
QUOTATION = "quotation"
|
|
44
|
+
UNKNOWN = "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def make_claim_fingerprint(text: str) -> str:
|
|
48
|
+
"""FNV-1a 64-bit over the lowercased UTF-8 claim text, unpadded lowercase hex.
|
|
49
|
+
|
|
50
|
+
Spec §1. Byte-for-byte identical to Swift's ``Claim.makeFingerprint``:
|
|
51
|
+
offset basis 14695981039346656037, prime 1099511628211, XOR-then-wrapping-
|
|
52
|
+
multiply per byte, rendered via ``String(hash, radix: 16)`` (no zero padding).
|
|
53
|
+
Lowercasing uses ``swift_lower`` (no Final_Sigma rule) and hashes the
|
|
54
|
+
original scalars — no Unicode normalization, matching Swift exactly.
|
|
55
|
+
"""
|
|
56
|
+
from ._text import swift_lower
|
|
57
|
+
|
|
58
|
+
fnv = 14_695_981_039_346_656_037
|
|
59
|
+
for byte in swift_lower(text).encode("utf-8"):
|
|
60
|
+
fnv ^= byte
|
|
61
|
+
fnv = (fnv * 1_099_511_628_211) & 0xFFFF_FFFF_FFFF_FFFF
|
|
62
|
+
return format(fnv, "x")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Claim:
|
|
67
|
+
id: str
|
|
68
|
+
text: str
|
|
69
|
+
type: ClaimType = ClaimType.UNKNOWN
|
|
70
|
+
parent_text: Optional[str] = None
|
|
71
|
+
fingerprint: str = ""
|
|
72
|
+
range: Optional[Tuple[int, int]] = None # UTF-16 code-unit offsets, like Swift
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if not self.fingerprint:
|
|
76
|
+
object.__setattr__(self, "fingerprint", make_claim_fingerprint(self.text))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SupportStatus(str, Enum):
|
|
80
|
+
DIRECTLY_SUPPORTED = "directlySupported"
|
|
81
|
+
SUPPORTED_BY_INFERENCE = "supportedByInference"
|
|
82
|
+
PARTIALLY_SUPPORTED = "partiallySupported"
|
|
83
|
+
CONTRADICTED = "contradicted"
|
|
84
|
+
AMBIGUOUS = "ambiguous"
|
|
85
|
+
UNSUPPORTED = "unsupported"
|
|
86
|
+
NOT_VERIFIABLE = "notVerifiable"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
APPROVED_STATUSES = frozenset(
|
|
90
|
+
{SupportStatus.DIRECTLY_SUPPORTED, SupportStatus.SUPPORTED_BY_INFERENCE}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class EvidenceMatch:
|
|
96
|
+
passage: EvidencePassage
|
|
97
|
+
score: float
|
|
98
|
+
matched_terms: Tuple[str, ...]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class ClaimVerdict:
|
|
103
|
+
claim: Claim
|
|
104
|
+
status: SupportStatus
|
|
105
|
+
confidence: float
|
|
106
|
+
evidence: Tuple[EvidenceMatch, ...]
|
|
107
|
+
explanation: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ProofReport:
|
|
112
|
+
verdicts: Tuple[ClaimVerdict, ...]
|
|
113
|
+
version: int = 1
|
|
114
|
+
created_at: float = 0.0 # POSIX seconds; explicit, never implicit "now"
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_safe_to_publish(self) -> bool:
|
|
118
|
+
return bool(self.verdicts) and all(v.status in APPROVED_STATUSES for v in self.verdicts)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def support_rate(self) -> float:
|
|
122
|
+
if not self.verdicts:
|
|
123
|
+
return 0.0
|
|
124
|
+
supported = sum(1 for v in self.verdicts if v.status in APPROVED_STATUSES)
|
|
125
|
+
return supported / len(self.verdicts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class VerificationPolicy:
|
|
130
|
+
supported_threshold: float = 0.80
|
|
131
|
+
ambiguous_threshold: float = 0.40
|
|
132
|
+
contradiction_threshold: float = 0.55
|
|
133
|
+
maximum_evidence_matches: int = 3
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Copyright 2026 Daniel Kissel
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Pluggable verification pipeline — mirrors the Swift `VerificationProtocols.swift`
|
|
5
|
+
and `VerificationPipeline.swift` contracts so local or external verifiers
|
|
6
|
+
(semantic entailment, retrieval, human review) can be substituted without
|
|
7
|
+
changing the report contract. Swift's async protocol methods map to plain
|
|
8
|
+
Python methods; substitute async implementations by overriding `verify_document`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import List, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from .extractor import ClaimExtractor
|
|
19
|
+
from .models import Claim, ClaimVerdict, EvidencePassage, ProofReport, SupportStatus
|
|
20
|
+
from .verifier import ClaimVerifier
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AtomicClaimExtracting(ABC):
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def extract(self, document: str) -> List[Claim]: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EvidenceRetrieving(ABC):
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def retrieve(
|
|
31
|
+
self, claim: Claim, corpus: Sequence[EvidencePassage], limit: int
|
|
32
|
+
) -> List[EvidencePassage]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ClaimEntailmentVerifying(ABC):
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def verify(self, claim: Claim, evidence: Sequence[EvidencePassage]) -> ClaimVerdict: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProofPolicyEvaluating(ABC):
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def evaluate(self, report: ProofReport) -> "ProofDecision": ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TrustReportGenerating(ABC):
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def generate(self, verdicts: Sequence[ClaimVerdict]) -> ProofReport: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Disposition(str, Enum):
|
|
51
|
+
ALLOW = "allow"
|
|
52
|
+
REQUIRE_REVIEW = "requireReview"
|
|
53
|
+
BLOCK = "block"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ProofDecision:
|
|
58
|
+
disposition: Disposition
|
|
59
|
+
blocking_claim_ids: Tuple[str, ...] = ()
|
|
60
|
+
review_claim_ids: Tuple[str, ...] = ()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PipelineResult:
|
|
65
|
+
report: ProofReport
|
|
66
|
+
decision: ProofDecision
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PassthroughEvidenceRetriever(EvidenceRetrieving):
|
|
70
|
+
def retrieve(
|
|
71
|
+
self, claim: Claim, corpus: Sequence[EvidencePassage], limit: int
|
|
72
|
+
) -> List[EvidencePassage]:
|
|
73
|
+
return list(corpus[: max(1, limit)])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LexicalEntailmentVerifier(ClaimEntailmentVerifying):
|
|
77
|
+
def __init__(self, verifier: "ClaimVerifier | None" = None):
|
|
78
|
+
self._verifier = verifier if verifier is not None else ClaimVerifier()
|
|
79
|
+
|
|
80
|
+
def verify(self, claim: Claim, evidence: Sequence[EvidencePassage]) -> ClaimVerdict:
|
|
81
|
+
return self._verifier.verify(claim, evidence)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DefaultTrustReportGenerator(TrustReportGenerating):
|
|
85
|
+
def generate(self, verdicts: Sequence[ClaimVerdict]) -> ProofReport:
|
|
86
|
+
return ProofReport(verdicts=tuple(verdicts))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ConservativeProofPolicy(ProofPolicyEvaluating):
|
|
90
|
+
def evaluate(self, report: ProofReport) -> ProofDecision:
|
|
91
|
+
blocking = tuple(
|
|
92
|
+
v.claim.id
|
|
93
|
+
for v in report.verdicts
|
|
94
|
+
if v.status in (SupportStatus.CONTRADICTED, SupportStatus.UNSUPPORTED)
|
|
95
|
+
)
|
|
96
|
+
review = tuple(
|
|
97
|
+
v.claim.id
|
|
98
|
+
for v in report.verdicts
|
|
99
|
+
if v.status
|
|
100
|
+
in (SupportStatus.PARTIALLY_SUPPORTED, SupportStatus.AMBIGUOUS, SupportStatus.NOT_VERIFIABLE)
|
|
101
|
+
)
|
|
102
|
+
if blocking:
|
|
103
|
+
return ProofDecision(
|
|
104
|
+
disposition=Disposition.BLOCK, blocking_claim_ids=blocking, review_claim_ids=review
|
|
105
|
+
)
|
|
106
|
+
if review:
|
|
107
|
+
return ProofDecision(disposition=Disposition.REQUIRE_REVIEW, review_claim_ids=review)
|
|
108
|
+
return ProofDecision(disposition=Disposition.ALLOW)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class VerificationPipeline:
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
extractor: "AtomicClaimExtracting | None" = None,
|
|
115
|
+
retriever: "EvidenceRetrieving | None" = None,
|
|
116
|
+
verifier: "ClaimEntailmentVerifying | None" = None,
|
|
117
|
+
policy: "ProofPolicyEvaluating | None" = None,
|
|
118
|
+
report_generator: "TrustReportGenerating | None" = None,
|
|
119
|
+
evidence_limit: int = 20,
|
|
120
|
+
):
|
|
121
|
+
self._extractor = extractor if extractor is not None else _DefaultExtractor()
|
|
122
|
+
self._retriever = retriever if retriever is not None else PassthroughEvidenceRetriever()
|
|
123
|
+
self._verifier = verifier if verifier is not None else LexicalEntailmentVerifier()
|
|
124
|
+
self._policy = policy if policy is not None else ConservativeProofPolicy()
|
|
125
|
+
self._report_generator = (
|
|
126
|
+
report_generator if report_generator is not None else DefaultTrustReportGenerator()
|
|
127
|
+
)
|
|
128
|
+
self._evidence_limit = max(1, evidence_limit)
|
|
129
|
+
|
|
130
|
+
def verify_document(self, document: str, corpus: Sequence[EvidencePassage]) -> PipelineResult:
|
|
131
|
+
claims = self._extractor.extract(document)
|
|
132
|
+
verdicts = []
|
|
133
|
+
for claim in claims:
|
|
134
|
+
evidence = self._retriever.retrieve(claim, corpus, self._evidence_limit)
|
|
135
|
+
verdicts.append(self._verifier.verify(claim, evidence))
|
|
136
|
+
report = self._report_generator.generate(verdicts)
|
|
137
|
+
return PipelineResult(report=report, decision=self._policy.evaluate(report))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _DefaultExtractor(AtomicClaimExtracting):
|
|
141
|
+
def __init__(self) -> None:
|
|
142
|
+
self._extractor = ClaimExtractor()
|
|
143
|
+
|
|
144
|
+
def extract(self, document: str) -> List[Claim]:
|
|
145
|
+
return self._extractor.extract(document)
|