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.
- claimbounded/__init__.py +94 -0
- claimbounded/claims.py +352 -0
- claimbounded/cli.py +129 -0
- claimbounded/data/fda_ai_device_claims.csv +2465 -0
- claimbounded/outputs.py +152 -0
- claimbounded/precedents.py +284 -0
- claimbounded/profiles.py +160 -0
- claimbounded/reports.py +188 -0
- claimbounded/schema.py +275 -0
- claimbounded/ui.py +1566 -0
- claimbounded-0.2.0.dist-info/METADATA +340 -0
- claimbounded-0.2.0.dist-info/RECORD +16 -0
- claimbounded-0.2.0.dist-info/WHEEL +5 -0
- claimbounded-0.2.0.dist-info/entry_points.txt +2 -0
- claimbounded-0.2.0.dist-info/licenses/LICENSE +21 -0
- claimbounded-0.2.0.dist-info/top_level.txt +1 -0
claimbounded/__init__.py
ADDED
|
@@ -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())
|