claimbounded 0.2.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,94 @@
1
+ """claimbounded: claim-bounded monitoring of AI-enabled medical devices.
2
+
3
+ Translate a device into the study schema, classify the strongest postmarket
4
+ claim its routine evidence can support, estimate the work needed to re-measure
5
+ the authorization endpoint, retrieve comparable public FDA precedents, and
6
+ generate operational outputs for health systems and manufacturers.
7
+
8
+ Quick start
9
+ -----------
10
+ >>> from claimbounded import profile_device, generate_monitoring_package
11
+ >>> profile = profile_device({
12
+ ... "device_name": "Acme LVO Triage",
13
+ ... "device_function": "triage_notification",
14
+ ... "authorization_endpoint_type": "diagnostic_accuracy",
15
+ ... "routine_postmarket_evidence_stream": "workflow_logs",
16
+ ... })
17
+ >>> pkg = generate_monitoring_package(profile, k=5)
18
+ >>> pkg["claim_profile"]["routine_evidence_claim_ceiling"]
19
+ 'workflow_performance'
20
+ """
21
+
22
+ from .schema import (
23
+ CLAIM_HIERARCHY,
24
+ CLAIM_LABELS,
25
+ SCHEMA_VERSION,
26
+ DeviceEvidenceProfile,
27
+ )
28
+ from .profiles import (
29
+ corpus_stats,
30
+ find_in_corpus,
31
+ load_corpus,
32
+ normalize_device_record,
33
+ profile_device,
34
+ search_corpus,
35
+ )
36
+ from .claims import (
37
+ classify_audit_burden,
38
+ classify_claim_ceiling,
39
+ classify_evaluability_class,
40
+ classify_recoverability,
41
+ classify_supportable_claims,
42
+ estimate_authorization_remeasurement,
43
+ )
44
+ from .precedents import (
45
+ build_bm25_index,
46
+ explain_precedent_match,
47
+ retrieve_precedents,
48
+ schema_similarity,
49
+ structured_similarity,
50
+ )
51
+ from .outputs import (
52
+ generate_claim_support_matrix,
53
+ generate_dashboard_claim_limits,
54
+ generate_manufacturer_design_requirements,
55
+ generate_minimum_audit_dataset,
56
+ generate_procurement_questions,
57
+ )
58
+ from .reports import (
59
+ generate_monitoring_package,
60
+ generate_monitoring_profile_report,
61
+ )
62
+
63
+ __version__ = "0.2.0"
64
+
65
+ __all__ = [
66
+ "DeviceEvidenceProfile",
67
+ "CLAIM_HIERARCHY",
68
+ "CLAIM_LABELS",
69
+ "SCHEMA_VERSION",
70
+ "profile_device",
71
+ "normalize_device_record",
72
+ "load_corpus",
73
+ "find_in_corpus",
74
+ "search_corpus",
75
+ "corpus_stats",
76
+ "classify_claim_ceiling",
77
+ "classify_evaluability_class",
78
+ "classify_recoverability",
79
+ "classify_supportable_claims",
80
+ "classify_audit_burden",
81
+ "estimate_authorization_remeasurement",
82
+ "retrieve_precedents",
83
+ "build_bm25_index",
84
+ "structured_similarity",
85
+ "schema_similarity",
86
+ "explain_precedent_match",
87
+ "generate_claim_support_matrix",
88
+ "generate_dashboard_claim_limits",
89
+ "generate_minimum_audit_dataset",
90
+ "generate_manufacturer_design_requirements",
91
+ "generate_procurement_questions",
92
+ "generate_monitoring_package",
93
+ "generate_monitoring_profile_report",
94
+ ]
claimbounded/claims.py ADDED
@@ -0,0 +1,352 @@
1
+ """Claim-bounded classification.
2
+
3
+ Given a :class:`~claimbounded.schema.DeviceEvidenceProfile`, decide:
4
+
5
+ * ``classify_claim_ceiling`` -> the strongest claim routine evidence supports
6
+ * ``classify_supportable_claims`` -> the full multi-label set of supportable claims
7
+ * ``classify_audit_burden`` -> the evidence work needed to go higher
8
+ * ``classify_evaluability_class`` -> what kind of correctness signal routine
9
+ deployment naturally produces
10
+ * ``classify_recoverability`` -> whether the authorization endpoint can be
11
+ recovered from routine data
12
+ * ``estimate_authorization_remeasurement`` -> whether/how the authorization
13
+ endpoint itself can be re-measured after deployment
14
+
15
+ The rules are transparent and conservative; they mirror the coding logic used
16
+ to build the empirical corpus. When a profile is a corpus row (its coded
17
+ primary variables are already present and not "unclear"), the coded values are
18
+ trusted and returned directly so package output is consistent with the V4 audit.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from .schema import (
26
+ AUDIT_BURDEN_LABELS,
27
+ CLAIM_EVIDENCE_REQUIREMENTS,
28
+ CLAIM_HIERARCHY,
29
+ CLAIM_RANK,
30
+ DeviceEvidenceProfile,
31
+ )
32
+
33
+ _TECHNICAL_FUNCTIONS = {
34
+ "image_reconstruction_enhancement",
35
+ "acquisition_guidance",
36
+ }
37
+
38
+ _YES = {"yes", "true", "structured"}
39
+
40
+
41
+ def _coded(profile: DeviceEvidenceProfile, field: str) -> str:
42
+ return str(profile.get(field, "unclear")).strip().lower()
43
+
44
+
45
+ def classify_claim_ceiling(profile: DeviceEvidenceProfile) -> str:
46
+ """Return the strongest auditable postmarket claim for this device.
47
+
48
+ If the profile already carries a coded ceiling (corpus row), trust it.
49
+ Otherwise apply a conservative decision tree over the deployment-evidence
50
+ fields.
51
+ """
52
+
53
+ coded = _coded(profile, "strongest_auditable_postmarket_claim")
54
+ if coded in CLAIM_RANK:
55
+ return coded
56
+
57
+ linked = _coded(profile, "endpoint_linked_to_ai_output")
58
+ recorded = _coded(profile, "endpoint_routinely_recorded")
59
+ correction = _coded(profile, "human_correction_available")
60
+ overread = _coded(profile, "human_overread_or_confirmation_required")
61
+ stream = _coded(profile, "routine_postmarket_evidence_stream")
62
+ function = _coded(profile, "device_function")
63
+
64
+ # No usable routine evidence at all.
65
+ if stream in {"none", "no_routine_evidence"}:
66
+ return "no_performance_claim_auditable"
67
+ if stream in {"utilization", "utilization_only"}:
68
+ return "utilization_only"
69
+
70
+ # Output-level reference evidence is linked case-to-case -> measurement.
71
+ if linked == "yes" and (recorded in _YES or correction == "yes"):
72
+ return "output_quality_or_measurement_agreement"
73
+
74
+ # Clinician edits / accept / reject / override CAPTURED on the AI output.
75
+ # Note: an overread merely being *required* does not, by itself, mean the
76
+ # accept/reject decision is captured as routine evidence; concordance
77
+ # requires that the human action is actually recorded.
78
+ if correction == "yes":
79
+ return "human_machine_concordance"
80
+ if overread == "yes" and stream in {"clinician_edits", "structured_report", "accept_reject_log"}:
81
+ return "human_machine_concordance"
82
+
83
+ # Purely technical pipelines (reconstruction, acquisition) with only logs.
84
+ if function in _TECHNICAL_FUNCTIONS and stream in {"device_logs", "technical_logs", "workflow_logs"}:
85
+ return "technical_pipeline_stability"
86
+
87
+ # Default: outputs flow through a workflow but nothing re-touches accuracy.
88
+ return "workflow_performance"
89
+
90
+
91
+ def classify_supportable_claims(profile: DeviceEvidenceProfile) -> list[str]:
92
+ """Return every claim level at or below the ceiling the evidence supports.
93
+
94
+ More operationally honest than a single ceiling: a device may support
95
+ several lower-level claims while topping out at one ceiling. Technical
96
+ pipeline stability and workflow performance are treated as supportable
97
+ whenever the device produces any routine output stream.
98
+ """
99
+
100
+ ceiling = classify_claim_ceiling(profile)
101
+ ceiling_rank = CLAIM_RANK[ceiling]
102
+ supportable: list[str] = []
103
+
104
+ stream = _coded(profile, "routine_postmarket_evidence_stream")
105
+ has_stream = stream not in {"none", "no_routine_evidence", "unclear"}
106
+
107
+ for claim in CLAIM_HIERARCHY:
108
+ rank = CLAIM_RANK[claim]
109
+ if claim in {"no_performance_claim_auditable", "utilization_only"}:
110
+ continue
111
+ if rank <= ceiling_rank:
112
+ if claim in {"technical_pipeline_stability", "workflow_performance"} and not has_stream:
113
+ continue
114
+ supportable.append(claim)
115
+ if not supportable:
116
+ supportable = [ceiling]
117
+ return supportable
118
+
119
+
120
+ def classify_audit_burden(profile: DeviceEvidenceProfile) -> dict[str, Any]:
121
+ """Classify the work needed to audit the authorization endpoint.
122
+
123
+ Trusts the coded ``postmarket_audit_burden`` when present; otherwise derives
124
+ it from the authorization ground-truth modality and linkage.
125
+ """
126
+
127
+ coded = _coded(profile, "postmarket_audit_burden")
128
+ if coded in AUDIT_BURDEN_LABELS and coded != "unclear":
129
+ burden = coded
130
+ else:
131
+ burden = _derive_audit_burden(profile)
132
+
133
+ return {
134
+ "postmarket_audit_burden": burden,
135
+ "label": AUDIT_BURDEN_LABELS.get(burden, burden),
136
+ "driven_by_ground_truth": _coded(profile, "authorization_ground_truth_modality"),
137
+ }
138
+
139
+
140
+ _EVALUABILITY_CODED = {
141
+ "closed_loop_evaluable", "workflow_endpoint_directly_auditable",
142
+ "correction_evaluable", "delayed_evaluable",
143
+ "surrogate_only", "not_evaluable",
144
+ }
145
+
146
+ _RECOVERABILITY_CODED = {
147
+ "directly_auditable", "recoverable_with_linkage",
148
+ "recoverable_with_chart_review", "proxy_only", "not_recoverable",
149
+ }
150
+
151
+ _STRUCTURED_GT = {
152
+ "clinical_diagnosis", "laboratory_reference_method", "physiologic_reference_standard",
153
+ }
154
+
155
+ _EXPERT_REVIEW_GT = {
156
+ "expert_reader_panel", "expert_annotation", "pathology_or_histology",
157
+ "longitudinal_clinical_outcome",
158
+ }
159
+
160
+ _BENCH_GT = {
161
+ "phantom_or_bench_reference", "predicate_device_comparison", "not_reported",
162
+ }
163
+
164
+ _NONCLINICAL_ENDPOINTS = {
165
+ "nonclinical_technical_or_bench_performance",
166
+ "no_device_specific_performance_data_in_public_summary",
167
+ "technical_performance_only",
168
+ "substantial_equivalence_only",
169
+ }
170
+
171
+
172
+ def classify_evaluability_class(profile: DeviceEvidenceProfile) -> str:
173
+ """Classify the postmarket evaluability class — what correctness signal routine
174
+ deployment naturally produces.
175
+
176
+ Trusts coded value for corpus rows; derives from user inputs for new devices.
177
+ Follows the V4 OSF codebook decision rules (conservative by default).
178
+ """
179
+ coded = _coded(profile, "postmarket_evaluability_class")
180
+ if coded in _EVALUABILITY_CODED:
181
+ return coded
182
+
183
+ endpoint_type = _coded(profile, "authorization_endpoint_type")
184
+ correction = _coded(profile, "human_correction_available")
185
+ linked = _coded(profile, "endpoint_linked_to_ai_output")
186
+ gt = _coded(profile, "authorization_ground_truth_modality")
187
+ endpoint_occurs = _coded(profile, "endpoint_occurs_in_routine_care")
188
+
189
+ # Bare clearance — no meaningful deployment description
190
+ if endpoint_type in {"no_device_specific_performance_data_in_public_summary"}:
191
+ return "not_evaluable"
192
+
193
+ # Workflow device with co-logged metric: the authorized endpoint IS the log
194
+ if endpoint_type in {"workflow_or_timeliness_performance"} and linked == "yes":
195
+ return "workflow_endpoint_directly_auditable"
196
+
197
+ # Physician edit/confirmation explicitly captured in accessible system
198
+ if correction == "yes":
199
+ return "correction_evaluable"
200
+
201
+ # Future outcome accumulates naturally in clinical records over time
202
+ if gt == "longitudinal_clinical_outcome" and endpoint_occurs in {"yes", "sometimes"}:
203
+ return "delayed_evaluable"
204
+
205
+ return "surrogate_only"
206
+
207
+
208
+ def classify_recoverability(profile: DeviceEvidenceProfile) -> str:
209
+ """Classify whether the authorization endpoint can be recovered from routine data.
210
+
211
+ Trusts coded value for corpus rows; derives for new devices.
212
+ The overwhelming empirical finding: 51% proxy_only, 43% requires chart review,
213
+ only 1 in 1,400 devices is directly_auditable.
214
+ """
215
+ coded = _coded(profile, "authorization_endpoint_recoverability")
216
+ if coded in _RECOVERABILITY_CODED:
217
+ return coded
218
+
219
+ endpoint_type = _coded(profile, "authorization_endpoint_type")
220
+ gt = _coded(profile, "authorization_ground_truth_modality")
221
+ linked = _coded(profile, "endpoint_linked_to_ai_output")
222
+ endpoint_occurs = _coded(profile, "endpoint_occurs_in_routine_care")
223
+
224
+ # Nonclinical/bench — no clinical correctness signal possible
225
+ if endpoint_type in _NONCLINICAL_ENDPOINTS:
226
+ return "not_recoverable"
227
+
228
+ # Workflow: authorized metric IS co-logged in deployment
229
+ if endpoint_type in {"workflow_or_timeliness_performance"} and linked == "yes":
230
+ return "directly_auditable"
231
+
232
+ # Explicit case-level linkage + reference occurs in routine care
233
+ if linked == "yes" and endpoint_occurs == "yes":
234
+ return "directly_auditable"
235
+
236
+ # Structured EHR records (ICD codes, lab, physiologic ref) — data engineering only
237
+ if gt in _STRUCTURED_GT and endpoint_occurs in {"yes", "sometimes"}:
238
+ return "recoverable_with_linkage"
239
+
240
+ # Expert panel / annotation / pathology / longitudinal — human effort required
241
+ if gt in _EXPERT_REVIEW_GT:
242
+ return "recoverable_with_chart_review"
243
+
244
+ # Phantom / bench / predicate — no clinical analogue in deployment
245
+ if gt in _BENCH_GT:
246
+ return "proxy_only"
247
+
248
+ # Conservative default: most AI devices cannot recover their authorization endpoint
249
+ return "proxy_only"
250
+
251
+
252
+ def _derive_audit_burden(profile: DeviceEvidenceProfile) -> str:
253
+ gt = _coded(profile, "authorization_ground_truth_modality")
254
+ linked = _coded(profile, "endpoint_linked_to_ai_output")
255
+ endpoint_type = _coded(profile, "authorization_endpoint_type")
256
+
257
+ if endpoint_type == "risk_prediction_or_prognosis":
258
+ return "requires_longitudinal_registry"
259
+ if endpoint_type in {"nonclinical_technical_or_bench_performance",
260
+ "no_device_specific_performance_data_in_public_summary",
261
+ "technical_performance_only", "substantial_equivalence_only"}:
262
+ return "requires_new_validation_study"
263
+ if endpoint_type == "workflow_or_timeliness_performance":
264
+ return "routine_data_only"
265
+ if gt in {"longitudinal_clinical_outcome"}:
266
+ return "requires_longitudinal_registry"
267
+ if gt in {"expert_annotation", "expert_reader_panel",
268
+ "pathology_or_histology", "phantom_or_bench_reference"}:
269
+ return "requires_sampling_or_chart_review"
270
+ if gt in {"clinical_diagnosis", "laboratory_reference_method",
271
+ "physiologic_reference_standard", "predicate_device_comparison"}:
272
+ return "requires_data_linkage"
273
+ if linked == "yes":
274
+ return "routine_data_only"
275
+ return "requires_data_linkage"
276
+
277
+
278
+ def estimate_authorization_remeasurement(profile: DeviceEvidenceProfile) -> dict[str, Any]:
279
+ """Estimate whether the authorization endpoint can be re-measured.
280
+
281
+ Compares the authorization endpoint type to the routine-evidence ceiling
282
+ and reports the gap, the auditability verdict, and the extra evidence work.
283
+ """
284
+
285
+ endpoint_type = _coded(profile, "authorization_endpoint_type")
286
+ ceiling = classify_claim_ceiling(profile)
287
+ burden = classify_audit_burden(profile)
288
+
289
+ endpoint_claim = _endpoint_type_to_claim(endpoint_type)
290
+ gap = CLAIM_RANK.get(endpoint_claim, len(CLAIM_HIERARCHY) - 1) - CLAIM_RANK[ceiling]
291
+
292
+ coded_audit = _coded(profile, "can_audit_authorization_endpoint_with_routine_data")
293
+ if coded_audit in {"yes", "partially", "no"}:
294
+ can_audit = coded_audit
295
+ else:
296
+ can_audit = "no" if gap >= 2 else ("partially" if gap == 1 else "yes")
297
+
298
+ extra = profile.get("extra_evidence_needed")
299
+ if not extra or str(extra).strip().lower() == "unclear":
300
+ extra = _default_extra_evidence(burden["postmarket_audit_burden"])
301
+
302
+ return {
303
+ "authorization_endpoint_type": endpoint_type,
304
+ "authorization_claim_level": endpoint_claim,
305
+ "routine_evidence_claim_ceiling": ceiling,
306
+ "claim_gap_levels": gap,
307
+ "claim_gap": _describe_gap(gap),
308
+ "can_audit_authorization_endpoint_with_routine_data": can_audit,
309
+ "postmarket_audit_burden": burden["postmarket_audit_burden"],
310
+ "extra_evidence_needed": extra,
311
+ }
312
+
313
+
314
+ def _endpoint_type_to_claim(endpoint_type: str) -> str:
315
+ mapping = {
316
+ # V4 endpoint type names (locked OSF codebook)
317
+ "diagnostic_accuracy": "clinical_accuracy_or_calibration",
318
+ "risk_prediction_or_prognosis": "clinical_accuracy_or_calibration",
319
+ "therapy_planning_or_control_performance": "clinical_accuracy_or_calibration",
320
+ "quantitative_measurement_agreement": "output_quality_or_measurement_agreement",
321
+ "segmentation_geometric_accuracy": "output_quality_or_measurement_agreement",
322
+ "data_generation_or_acquisition_quality": "output_quality_or_measurement_agreement",
323
+ "workflow_or_timeliness_performance": "workflow_performance",
324
+ "nonclinical_technical_or_bench_performance": "technical_pipeline_stability",
325
+ "no_device_specific_performance_data_in_public_summary": "technical_pipeline_stability",
326
+ # V3 legacy names (backward compat for any old corpus rows)
327
+ "triage_sensitivity_specificity": "clinical_accuracy_or_calibration",
328
+ "physiologic_event_detection": "clinical_accuracy_or_calibration",
329
+ "image_quality_or_reconstruction_fidelity": "output_quality_or_measurement_agreement",
330
+ "technical_performance_only": "technical_pipeline_stability",
331
+ "substantial_equivalence_only": "technical_pipeline_stability",
332
+ "workflow_or_time_to_notification": "workflow_performance",
333
+ }
334
+ return mapping.get(endpoint_type, "clinical_accuracy_or_calibration")
335
+
336
+
337
+ def _describe_gap(gap: int) -> str:
338
+ if gap <= 0:
339
+ return "routine evidence reaches the authorization claim level"
340
+ if gap == 1:
341
+ return "routine evidence is one level below the authorization claim"
342
+ return f"routine evidence is {gap} levels below the authorization claim"
343
+
344
+
345
+ def _default_extra_evidence(burden: str) -> str:
346
+ return {
347
+ "routine_data_only": "No additional linkage required; confirm denominator and version capture.",
348
+ "requires_data_linkage": "Join AI output log to structured clinical records (ICD codes, lab results, report fields) by patient/study identifier.",
349
+ "requires_sampling_or_chart_review": "Draw a sampling frame and perform chart/image review against an expert-adjudicated reference.",
350
+ "requires_longitudinal_registry": "Establish longitudinal EHR follow-up or registry linkage for outcome ascertainment.",
351
+ "requires_new_validation_study": "Run a new validation study — existing clinical data cannot reconstruct the authorized endpoint.",
352
+ }.get(burden, "Additional evidence linkage required.")
claimbounded/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """Command-line interface for claimbounded.
2
+
3
+ Examples
4
+ --------
5
+ claimbounded report examples/example_profiles/lvo_triage.json
6
+ claimbounded precedents examples/example_profiles/lvo_triage.json --mode hybrid -k 10
7
+ claimbounded lookup K192383
8
+ claimbounded search "large vessel occlusion"
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from typing import Any
17
+
18
+ from .profiles import find_in_corpus, profile_device, search_corpus
19
+ from .reports import generate_monitoring_package, generate_monitoring_profile_report
20
+
21
+
22
+ def _load_profile(path: str):
23
+ with open(path, encoding="utf-8") as fh:
24
+ record = json.load(fh)
25
+ return profile_device(record)
26
+
27
+
28
+ def _cmd_report(args: argparse.Namespace) -> int:
29
+ profile = _load_profile(args.profile)
30
+ if args.json:
31
+ pkg = generate_monitoring_package(profile, mode=args.mode, k=args.k)
32
+ print(json.dumps(pkg, indent=2))
33
+ else:
34
+ print(generate_monitoring_profile_report(profile, mode=args.mode, k=args.k))
35
+ return 0
36
+
37
+
38
+ def _cmd_precedents(args: argparse.Namespace) -> int:
39
+ from .precedents import retrieve_precedents
40
+
41
+ profile = _load_profile(args.profile)
42
+ precedents = retrieve_precedents(profile, mode=args.mode, k=args.k)
43
+ if args.json:
44
+ print(json.dumps(precedents, indent=2))
45
+ else:
46
+ for p in precedents:
47
+ print(f"{p['score']:.3f} {p['submission_number']:>10} {p['device_name'][:42]:42} "
48
+ f"-> {p['strongest_auditable_postmarket_claim']}")
49
+ print(f" {p['match']}")
50
+ return 0
51
+
52
+
53
+ def _cmd_lookup(args: argparse.Namespace) -> int:
54
+ profile = find_in_corpus(args.submission_number)
55
+ if profile is None:
56
+ print(f"No corpus record for {args.submission_number}", file=sys.stderr)
57
+ return 1
58
+ print(json.dumps(profile.to_dict(), indent=2))
59
+ return 0
60
+
61
+
62
+ def _cmd_ui(args: argparse.Namespace) -> int:
63
+ try:
64
+ from .ui import launch
65
+ except ImportError:
66
+ print(
67
+ "Gradio is not installed. Install the UI extra with:\n"
68
+ " pip install claimbounded[ui]",
69
+ file=sys.stderr,
70
+ )
71
+ return 1
72
+ launch(share=args.share, server_port=args.port)
73
+ return 0
74
+
75
+
76
+ def _cmd_search(args: argparse.Namespace) -> int:
77
+ hits = search_corpus(args.text)
78
+ for h in hits[: args.k]:
79
+ print(f"{h.get('submission_number'):>10} {h.get('applicant')[:24]:24} {h.name[:44]:44} "
80
+ f"-> {h.get('strongest_auditable_postmarket_claim')}")
81
+ print(f"\n{len(hits)} match(es).", file=sys.stderr)
82
+ return 0
83
+
84
+
85
+ def build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(prog="claimbounded", description=__doc__)
87
+ sub = parser.add_subparsers(dest="command", required=True)
88
+
89
+ p_report = sub.add_parser("report", help="full monitoring report from a profile JSON")
90
+ p_report.add_argument("profile")
91
+ p_report.add_argument("--mode", default="hybrid",
92
+ choices=["like_for_like", "adjacent", "claim_gap", "hybrid"])
93
+ p_report.add_argument("-k", type=int, default=8)
94
+ p_report.add_argument("--json", action="store_true")
95
+ p_report.set_defaults(func=_cmd_report)
96
+
97
+ p_prec = sub.add_parser("precedents", help="retrieve comparable precedents")
98
+ p_prec.add_argument("profile")
99
+ p_prec.add_argument("--mode", default="hybrid",
100
+ choices=["like_for_like", "adjacent", "claim_gap", "hybrid"])
101
+ p_prec.add_argument("-k", type=int, default=10)
102
+ p_prec.add_argument("--json", action="store_true")
103
+ p_prec.set_defaults(func=_cmd_precedents)
104
+
105
+ p_lookup = sub.add_parser("lookup", help="print a corpus record by submission number")
106
+ p_lookup.add_argument("submission_number")
107
+ p_lookup.set_defaults(func=_cmd_lookup)
108
+
109
+ p_ui = sub.add_parser("ui", help="launch interactive browser UI (requires claimbounded[ui])")
110
+ p_ui.add_argument("--share", action="store_true", help="create a public Gradio share link")
111
+ p_ui.add_argument("--port", type=int, default=7860, metavar="PORT")
112
+ p_ui.set_defaults(func=_cmd_ui)
113
+
114
+ p_search = sub.add_parser("search", help="substring search over the corpus")
115
+ p_search.add_argument("text")
116
+ p_search.add_argument("-k", type=int, default=20)
117
+ p_search.set_defaults(func=_cmd_search)
118
+
119
+ return parser
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ parser = build_parser()
124
+ args = parser.parse_args(argv)
125
+ return args.func(args)
126
+
127
+
128
+ if __name__ == "__main__": # pragma: no cover
129
+ raise SystemExit(main())