bounded-calcs 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,15 @@
1
+ """Clinical calculators that treat unknown as unknown."""
2
+
3
+ from bounded_calcs.engine import InputError, evaluate, schema
4
+ from bounded_calcs.spec import Calculator, SpecError, load_builtin, load_file, parse
5
+
6
+ __all__ = [
7
+ "Calculator",
8
+ "InputError",
9
+ "SpecError",
10
+ "evaluate",
11
+ "load_builtin",
12
+ "load_file",
13
+ "parse",
14
+ "schema",
15
+ ]
@@ -0,0 +1,344 @@
1
+ """Bounded evaluation: what can be concluded when some items are unknown.
2
+
3
+ The engine never assumes an unknown item is normal or absent. It computes every score the
4
+ patient could still have, the decision categories those scores fall into, and which unknown
5
+ items could change that set. All results are exact (no sampling): components are independent,
6
+ so reachable scores are the sumset of each component's reachable points.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from bounded_calcs.spec import Calculator, Component, Item
15
+
16
+ UNKNOWN_WORDS = {"unknown", "not_mentioned", "not mentioned", "unk", ""}
17
+ TRUE_WORDS = {"present", "yes", "true", "y", "1"}
18
+ FALSE_WORDS = {"absent", "no", "false", "n", "0"}
19
+
20
+ # Internal state per item: bool (boolean), str level (ordinal), int band index (numeric),
21
+ # or None (unknown).
22
+ State = dict[str, Any]
23
+
24
+
25
+ class InputError(ValueError):
26
+ pass
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class _Normalized:
31
+ state: State
32
+ raw_numeric: dict[str, float]
33
+
34
+
35
+ def _normalize(calc: Calculator, inputs: dict[str, Any] | None) -> _Normalized:
36
+ inputs = dict(inputs or {})
37
+ extra = set(inputs) - set(calc.items)
38
+ if extra:
39
+ raise InputError(
40
+ f"{calc.id}: unknown item(s) {sorted(extra)}; valid items: {sorted(calc.items)}"
41
+ )
42
+ state: State = {}
43
+ raw_numeric: dict[str, float] = {}
44
+ for iid, item in calc.items.items():
45
+ v = inputs.get(iid)
46
+ if v is None or (isinstance(v, str) and v.strip().lower() in UNKNOWN_WORDS):
47
+ state[iid] = None
48
+ continue
49
+ if item.type == "boolean":
50
+ if isinstance(v, bool):
51
+ state[iid] = v
52
+ elif isinstance(v, str) and v.strip().lower() in TRUE_WORDS:
53
+ state[iid] = True
54
+ elif isinstance(v, str) and v.strip().lower() in FALSE_WORDS:
55
+ state[iid] = False
56
+ else:
57
+ raise InputError(f"{calc.id}.{iid}: expected present, absent or unknown, got {v!r}")
58
+ elif item.type == "ordinal":
59
+ if v not in item.levels:
60
+ raise InputError(
61
+ f"{calc.id}.{iid}: expected one of {list(item.levels)} or unknown, got {v!r}"
62
+ )
63
+ state[iid] = v
64
+ else: # numeric
65
+ hint = (
66
+ f"{calc.id}.{iid}: expected a number in {item.unit}, or unknown. Send unknown "
67
+ f"when the source gives no number (for example only 'normal'); got {v!r}"
68
+ )
69
+ if isinstance(v, bool):
70
+ raise InputError(hint)
71
+ try:
72
+ x = float(v)
73
+ except (TypeError, ValueError):
74
+ raise InputError(hint) from None
75
+ if x != x or x in (float("inf"), float("-inf")):
76
+ raise InputError(hint)
77
+ state[iid] = next(i for i, b in enumerate(calc.numeric_bands(iid)) if b.matches(x))
78
+ raw_numeric[iid] = x
79
+ return _Normalized(state, raw_numeric)
80
+
81
+
82
+ def _domain(calc: Calculator, item: Item) -> list[Any]:
83
+ """Every internal value an unknown item could take."""
84
+ if item.type == "boolean":
85
+ return [True, False]
86
+ if item.type == "ordinal":
87
+ return list(item.levels)
88
+ return list(range(len(calc.numeric_bands(item.id))))
89
+
90
+
91
+ def _component_points(comp: Component, state: State) -> set[float]:
92
+ if comp.rule in ("present", "absent"):
93
+ v = state[comp.items[0]]
94
+ assert comp.points is not None
95
+ if v is None:
96
+ return {comp.points, 0.0}
97
+ return {comp.points if v == (comp.rule == "present") else 0.0}
98
+ if comp.rule == "levels":
99
+ v = state[comp.items[0]]
100
+ return {comp.level_points[v]} if v is not None else set(comp.level_points.values())
101
+ if comp.rule == "bands":
102
+ v = state[comp.items[0]]
103
+ return {comp.bands[v].points} if v is not None else {b.points for b in comp.bands}
104
+ if comp.rule == "any_of":
105
+ assert comp.points is not None
106
+
107
+ def met(i: str) -> bool | None:
108
+ v = state[i]
109
+ if v is None:
110
+ return None
111
+ return bool(v) if i not in comp.conditions else v == 0
112
+
113
+ flags = [met(i) for i in comp.items]
114
+ if any(f is True for f in flags):
115
+ return {comp.points}
116
+ return {0.0, comp.points} if any(f is None for f in flags) else {0.0}
117
+ # count
118
+ members = [i for i in comp.items if i != comp.override]
119
+ known = sum(1 for i in members if state[i] is True)
120
+ unknown = sum(1 for i in members if state[i] is None)
121
+
122
+ def by_count(n: int) -> float:
123
+ return next(b.points for b in comp.bands if b.op is None or n >= (b.value or 0))
124
+
125
+ from_count = {by_count(n) for n in range(known, known + unknown + 1)}
126
+ if comp.override is None:
127
+ return from_count
128
+ ov = state[comp.override]
129
+ assert comp.override_points is not None
130
+ if ov is True:
131
+ return {comp.override_points}
132
+ if ov is False:
133
+ return from_count
134
+ return from_count | {comp.override_points}
135
+
136
+
137
+ def _reachable_scores(calc: Calculator, state: State) -> set[float]:
138
+ scores = {0.0}
139
+ for comp in calc.components:
140
+ pts = _component_points(comp, state)
141
+ scores = {s + p for s in scores for p in pts}
142
+ return scores
143
+
144
+
145
+ def _categories(calc: Calculator, state: State) -> set[str]:
146
+ return {calc.category(s).name for s in _reachable_scores(calc, state)}
147
+
148
+
149
+ def _describe_value(calc: Calculator, item: Item, v: Any) -> str:
150
+ if item.type == "boolean":
151
+ return "present" if v else "absent"
152
+ if item.type == "ordinal":
153
+ return str(v)
154
+ band = calc.numeric_bands(item.id)[v]
155
+ if calc.component_of(item.id).rule == "any_of" and band.op is None:
156
+ met = calc.numeric_bands(item.id)[0]
157
+ return "not " + met.describe(item.unit)
158
+ return band.describe(item.unit)
159
+
160
+
161
+ def _fmt(x: float) -> float | int:
162
+ return int(x) if float(x).is_integer() else x
163
+
164
+
165
+ def evaluate(calc: Calculator, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
166
+ """Evaluate a calculator on partial inputs.
167
+
168
+ `inputs` maps item id to a value. Items that are omitted, None, or "unknown" are unknown.
169
+ Boolean items take present/absent (or true/false); ordinal items take one of their
170
+ levels; numeric items take a number in the item's unit.
171
+ """
172
+ norm = _normalize(calc, inputs)
173
+ state = norm.state
174
+ scores = _reachable_scores(calc, state)
175
+ cats = {calc.category(s).name for s in scores}
176
+ ordered = calc.by_risk(cats)
177
+ settled = len(cats) == 1
178
+ unknown_ids = [i for i, v in state.items() if v is None]
179
+
180
+ decisive: list[dict[str, Any]] = []
181
+ irrelevant: list[str] = []
182
+ jointly: list[str] = []
183
+ for iid in unknown_ids:
184
+ item = calc.items[iid]
185
+ if settled:
186
+ irrelevant.append(iid)
187
+ continue
188
+ outcomes = []
189
+ score_sets = []
190
+ for v in _domain(calc, item):
191
+ sub_state = {**state, iid: v}
192
+ score_sets.append(_reachable_scores(calc, sub_state))
193
+ sub = calc.by_risk({calc.category(s).name for s in score_sets[-1]})
194
+ outcomes.append({"if": _describe_value(calc, item, v), "possible_categories": sub})
195
+ narrows = any(len(o["possible_categories"]) < len(cats) for o in outcomes)
196
+ if not narrows:
197
+ # Alone it cannot change the category set; it may still matter together with
198
+ # other unknowns if it can move the score at all.
199
+ moves_score = any(s != scores for s in score_sets)
200
+ (jointly if moves_score else irrelevant).append(iid)
201
+ continue
202
+ settling = sum(1 for o in outcomes if len(o["possible_categories"]) == 1)
203
+ mean_left = sum(len(o["possible_categories"]) for o in outcomes) / len(outcomes)
204
+ decisive.append(
205
+ {
206
+ "item": iid,
207
+ "label": item.label,
208
+ "kind": item.kind,
209
+ "question": item.question or item.label,
210
+ "settles_decision_in": f"{settling} of {len(outcomes)} answers",
211
+ "outcomes": outcomes,
212
+ # Prefer items that settle more often, then narrow more on average; among
213
+ # equals, ask for data before asking for a judgment.
214
+ "_rank": (-settling / len(outcomes), mean_left, item.kind == "judgment"),
215
+ }
216
+ )
217
+ decisive.sort(key=lambda d: d["_rank"])
218
+ for d in decisive:
219
+ del d["_rank"]
220
+
221
+ components = []
222
+ for comp in calc.components:
223
+ pts = sorted(_component_points(comp, state))
224
+ entry: dict[str, Any] = {"component": comp.id, "label": comp.label}
225
+ if len(pts) == 1:
226
+ entry["points"] = _fmt(pts[0])
227
+ else:
228
+ entry["points_range"] = [_fmt(pts[0]), _fmt(pts[-1])]
229
+ entry["unknown_items"] = [i for i in comp.items if state[i] is None]
230
+ components.append(entry)
231
+
232
+ result: dict[str, Any] = {
233
+ "calculator": calc.id,
234
+ "calculator_version": calc.version,
235
+ "settled": settled,
236
+ "category": ordered[0] if settled else None,
237
+ "possible_categories": ordered,
238
+ "worst_case_category": ordered[-1],
239
+ "score": _fmt(next(iter(scores))) if len(scores) == 1 else None,
240
+ "score_range": [_fmt(min(scores)), _fmt(max(scores))],
241
+ "decisive_unknowns": decisive,
242
+ "jointly_relevant_unknowns": jointly,
243
+ "irrelevant_unknowns": irrelevant,
244
+ "components": components,
245
+ }
246
+ if settled and unknown_ids:
247
+ result["note"] = (
248
+ "The category is settled: no value of the remaining unknown items can change it."
249
+ )
250
+ elif not settled:
251
+ ask = "a decisive unknown" if decisive else "the jointly relevant unknowns"
252
+ result["note"] = (
253
+ f"Not settled. Unknown items were not assumed normal. Resolve {ask} (ask the "
254
+ "clinician) and evaluate again; do not report a single category yet."
255
+ )
256
+ judgment_pending = [d["item"] for d in decisive if d["kind"] == "judgment"]
257
+ if judgment_pending:
258
+ result["judgment_items_pending"] = judgment_pending
259
+ result["judgment_note"] = (
260
+ "These items are clinician judgments. Ask the clinician; do not infer them from a note."
261
+ )
262
+ return result
263
+
264
+
265
+ def schema(calc: Calculator) -> dict[str, Any]:
266
+ """A description of a calculator's inputs and outputs, for clients and LLMs."""
267
+ items = []
268
+ for item in calc.items.values():
269
+ entry: dict[str, Any] = {
270
+ "id": item.id,
271
+ "label": item.label,
272
+ "type": item.type,
273
+ "kind": item.kind,
274
+ }
275
+ if item.type == "boolean":
276
+ entry["values"] = ["present", "absent", "unknown"]
277
+ elif item.type == "ordinal":
278
+ entry["values"] = [*item.levels, "unknown"]
279
+ else:
280
+ entry["unit"] = item.unit
281
+ entry["values"] = f"number in {item.unit}, or unknown"
282
+ if item.question:
283
+ entry["question"] = item.question
284
+ if item.notes:
285
+ entry["notes"] = list(item.notes)
286
+ items.append(entry)
287
+
288
+ components = []
289
+ for comp in calc.components:
290
+ c: dict[str, Any] = {"id": comp.id, "label": comp.label, "rule": comp.rule}
291
+ if comp.rule in ("present", "absent"):
292
+ c["item"], c["points"] = comp.items[0], _fmt(comp.points or 0)
293
+ elif comp.rule == "levels":
294
+ c["item"] = comp.items[0]
295
+ c["points"] = {k: _fmt(v) for k, v in comp.level_points.items()}
296
+ elif comp.rule == "bands":
297
+ unit = calc.items[comp.items[0]].unit
298
+ c["item"] = comp.items[0]
299
+ c["bands"] = [{"when": b.describe(unit), "points": _fmt(b.points)} for b in comp.bands]
300
+ elif comp.rule == "any_of":
301
+ conds = []
302
+ for i in comp.items:
303
+ if i in comp.conditions:
304
+ conds.append(f"{i} {comp.conditions[i].describe(calc.items[i].unit)}")
305
+ else:
306
+ conds.append(f"{i} present")
307
+ c["any_of"], c["points"] = conds, _fmt(comp.points or 0)
308
+ else:
309
+ c["items"] = [i for i in comp.items if i != comp.override]
310
+ c["points_by_count"] = [
311
+ {"when": b.describe("present") if b.op else "otherwise", "points": _fmt(b.points)}
312
+ for b in comp.bands
313
+ ]
314
+ if comp.override:
315
+ c["override"] = {"item": comp.override, "points": _fmt(comp.override_points or 0)}
316
+ components.append(c)
317
+
318
+ return {
319
+ "id": calc.id,
320
+ "name": calc.name,
321
+ "version": calc.version,
322
+ "purpose": calc.purpose,
323
+ "citation": calc.citation,
324
+ "references": list(calc.references),
325
+ "items": items,
326
+ "components": components,
327
+ "categories": [
328
+ {
329
+ "name": c.name,
330
+ "label": c.label,
331
+ "when": "lowest" if c.lower == float("-inf")
332
+ else f"score {'>' if c.lower_strict else '>='} {c.lower:g}",
333
+ "meaning": c.meaning,
334
+ }
335
+ for c in calc.categories
336
+ ],
337
+ "notes": list(calc.notes),
338
+ "input_rules": (
339
+ "Pass only what is documented or known. Omit an item, or send 'unknown', when the "
340
+ "source does not mention it: never fill it in as absent or normal. A numeric item "
341
+ "described only as 'normal', with no number, is unknown. Items of kind 'judgment' "
342
+ "must come from the clinician, not from a note."
343
+ ),
344
+ }
@@ -0,0 +1,86 @@
1
+ """MCP server exposing the bounded calculators (stdio by default, so notes stay local).
2
+
3
+ Tools:
4
+ list_calculators() ids, names and purposes
5
+ get_schema(calculator_id) items, allowed values, scoring, categories, citation
6
+ evaluate(calculator_id, findings) bounded result: settled?, possible categories,
7
+ decisive unknowns to ask about
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from typing import Any
14
+
15
+ from mcp.server import MCPServer
16
+ from mcp.server.mcpserver.exceptions import ToolError
17
+
18
+ from bounded_calcs.engine import InputError, evaluate as _evaluate, schema as _schema
19
+ from bounded_calcs.spec import load_builtin
20
+
21
+ INSTRUCTIONS = """\
22
+ Clinical calculators that never assume an unmentioned finding is normal.
23
+
24
+ Workflow:
25
+ 1. list_calculators, then get_schema for the calculator you need.
26
+ 2. Read the note and send to `evaluate` only what it actually documents: present, absent, a
27
+ level, or a number. Leave out (or send "unknown") anything the note does not mention.
28
+ Never fill an unmentioned item as absent or normal. A numeric item described only as
29
+ "normal", with no number, is unknown: do not invent a value. Convert units yourself to the
30
+ unit the schema states.
31
+ 3. If the result is settled, report its category. If not, ask the clinician about the
32
+ decisive unknowns (in the order given), add the answers, and call evaluate again.
33
+ 4. Items of kind "judgment" (for example HEART history, Wells "alternative diagnosis less
34
+ likely") must come from the clinician, never from your reading of the note.
35
+
36
+ Research and reference use only. Not a medical device; not for unsupervised clinical decisions.
37
+ """
38
+
39
+ CALCS = load_builtin()
40
+ server = MCPServer(name="bounded-calcs", instructions=INSTRUCTIONS, version="0.1.0")
41
+
42
+
43
+ def _get(calculator_id: str):
44
+ if calculator_id not in CALCS:
45
+ raise ToolError(f"Unknown calculator '{calculator_id}'. Available: {sorted(CALCS)}")
46
+ return CALCS[calculator_id]
47
+
48
+
49
+ @server.tool()
50
+ def list_calculators() -> list[dict[str, str]]:
51
+ """List the available calculators (id, name, purpose)."""
52
+ return [{"id": c.id, "name": c.name, "purpose": c.purpose} for c in CALCS.values()]
53
+
54
+
55
+ @server.tool()
56
+ def get_schema(calculator_id: str) -> dict[str, Any]:
57
+ """Describe a calculator: its items and allowed values, how points are assigned, the
58
+ decision categories, the source citation, and open review notes."""
59
+ return _schema(_get(calculator_id))
60
+
61
+
62
+ @server.tool()
63
+ def evaluate(calculator_id: str, findings: dict[str, Any] | None = None) -> dict[str, Any]:
64
+ """Evaluate a calculator on what is actually known.
65
+
66
+ `findings` maps item ids to values: "present" / "absent" for boolean items, one of the
67
+ listed levels for ordinal items, a number for numeric items. Omit items that are not
68
+ documented (or send "unknown"); they are treated as unknown, never as normal.
69
+
70
+ Returns whether the decision category is settled, every category still possible, the
71
+ worst case, the score range, and the decisive unknowns: the only items worth asking the
72
+ clinician about, each with what the category would be for each possible answer.
73
+ """
74
+ try:
75
+ return _evaluate(_get(calculator_id), findings or {})
76
+ except InputError as e:
77
+ raise ToolError(str(e)) from None
78
+
79
+
80
+ def main() -> None:
81
+ transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
82
+ server.run(transport=transport) # type: ignore[arg-type]
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()