bounded-calcs 0.1.0__tar.gz

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.
Files changed (33) hide show
  1. bounded_calcs-0.1.0/.github/workflows/publish.yml +68 -0
  2. bounded_calcs-0.1.0/.gitignore +5 -0
  3. bounded_calcs-0.1.0/CLAUDE.md +135 -0
  4. bounded_calcs-0.1.0/LICENSE +21 -0
  5. bounded_calcs-0.1.0/PKG-INFO +214 -0
  6. bounded_calcs-0.1.0/README.md +185 -0
  7. bounded_calcs-0.1.0/docs/ADDING_A_CALCULATOR.md +298 -0
  8. bounded_calcs-0.1.0/docs/RELEASING.md +78 -0
  9. bounded_calcs-0.1.0/docs/ROADMAP.md +164 -0
  10. bounded_calcs-0.1.0/pyproject.toml +61 -0
  11. bounded_calcs-0.1.0/scripts/smoke_test.py +55 -0
  12. bounded_calcs-0.1.0/src/bounded_calcs/__init__.py +15 -0
  13. bounded_calcs-0.1.0/src/bounded_calcs/engine.py +344 -0
  14. bounded_calcs-0.1.0/src/bounded_calcs/mcp_server.py +86 -0
  15. bounded_calcs-0.1.0/src/bounded_calcs/spec.py +513 -0
  16. bounded_calcs-0.1.0/src/bounded_calcs/specs/__init__.py +0 -0
  17. bounded_calcs-0.1.0/src/bounded_calcs/specs/curb65.yaml +97 -0
  18. bounded_calcs-0.1.0/src/bounded_calcs/specs/heart.yaml +148 -0
  19. bounded_calcs-0.1.0/src/bounded_calcs/specs/perc.yaml +104 -0
  20. bounded_calcs-0.1.0/src/bounded_calcs/specs/wells_pe.yaml +78 -0
  21. bounded_calcs-0.1.0/src/bounded_calcs/testing.py +222 -0
  22. bounded_calcs-0.1.0/tests/calculators/test_curb65.py +89 -0
  23. bounded_calcs-0.1.0/tests/calculators/test_heart.py +146 -0
  24. bounded_calcs-0.1.0/tests/calculators/test_perc.py +85 -0
  25. bounded_calcs-0.1.0/tests/calculators/test_wells_pe.py +105 -0
  26. bounded_calcs-0.1.0/tests/test_builtin_specs.py +70 -0
  27. bounded_calcs-0.1.0/tests/test_docs.py +51 -0
  28. bounded_calcs-0.1.0/tests/test_engine.py +217 -0
  29. bounded_calcs-0.1.0/tests/test_mcp.py +45 -0
  30. bounded_calcs-0.1.0/tests/test_packaging.py +39 -0
  31. bounded_calcs-0.1.0/tests/test_spec.py +160 -0
  32. bounded_calcs-0.1.0/tests/test_testing.py +103 -0
  33. bounded_calcs-0.1.0/uv.lock +920 -0
@@ -0,0 +1,68 @@
1
+ # Publishes to PyPI when a version tag such as v0.1.0 is pushed. Publishing a GitHub release
2
+ # creates the tag, so that is the usual way to start it. Procedure: docs/RELEASING.md.
3
+ #
4
+ # No token is stored anywhere: PyPI trusts this workflow through Trusted Publishing, which is
5
+ # configured once on pypi.org (project bounded-calcs, workflow publish.yml, environment pypi).
6
+ #
7
+ # Layout and action pins follow the official uv guide
8
+ # (https://docs.astral.sh/uv/guides/integration/github/). Actions are pinned to commits
9
+ # because the publish job can upload to PyPI; update a pin only to a tagged release.
10
+ name: Publish to PyPI
11
+
12
+ on:
13
+ push:
14
+ tags:
15
+ - "v[0-9]+.[0-9]+.[0-9]+"
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ build:
22
+ name: Test and build
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26
+ with:
27
+ persist-credentials: false
28
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
29
+ - name: The tag must match the version in pyproject.toml
30
+ run: |
31
+ version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
32
+ if [ "v$version" != "$GITHUB_REF_NAME" ]; then
33
+ echo "::error::tag $GITHUB_REF_NAME does not match version $version in pyproject.toml"
34
+ exit 1
35
+ fi
36
+ - name: Run the tests
37
+ run: |
38
+ uv sync --locked
39
+ uv run pytest -q
40
+ - name: Build the sdist and the wheel
41
+ run: uv build
42
+ - name: Smoke-test both distributions as installed packages
43
+ run: |
44
+ uv run --isolated --no-project --with dist/*.whl --with "mcp>=2,<3" scripts/smoke_test.py
45
+ uv run --isolated --no-project --with dist/*.tar.gz --with "mcp>=2,<3" scripts/smoke_test.py
46
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+ if-no-files-found: error
51
+
52
+ publish:
53
+ name: Publish
54
+ needs: build
55
+ runs-on: ubuntu-latest
56
+ environment:
57
+ name: pypi
58
+ url: https://pypi.org/project/bounded-calcs/
59
+ permissions:
60
+ id-token: write # lets PyPI verify that the upload comes from this workflow
61
+ steps:
62
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
63
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
64
+ with:
65
+ name: dist
66
+ path: dist/
67
+ - name: Publish with Trusted Publishing
68
+ run: uv publish
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
@@ -0,0 +1,135 @@
1
+ # CLAUDE.md — bounded-calcs
2
+
3
+ Clinical calculators that treat **unknown as unknown**: one YAML spec per calculator, an exact
4
+ bounding engine, a Python library and an MCP server. Companion to the paper repo
5
+ `nicoveraz/calc-bounds` (arXiv:2609.34112). Research and reference tool, not a medical device.
6
+
7
+ These instructions are written so that a small model can extend the repo safely. Follow them
8
+ literally. When they do not cover a situation, stop and ask; do not improvise.
9
+
10
+ ## Start here
11
+
12
+ | Task | Read first |
13
+ |---|---|
14
+ | Add a calculator | `docs/ADDING_A_CALCULATOR.md`, then follow it step by step |
15
+ | Choose what to build | `docs/ROADMAP.md`: take the first item marked **ready**, one at a time, and read the GitHub issue it links to |
16
+ | Understand the engine | README "How it works"; docstrings of `spec.py`, `engine.py`, `testing.py` |
17
+ | Prepare a release | `docs/RELEASING.md`, and only when the user asks for a release |
18
+
19
+ ## Hard rules
20
+
21
+ 1. **Unknown is never absent or normal.** No defaults for missing items, in code, specs,
22
+ tests or examples.
23
+ 2. **No clinical criteria from memory.** Every item, threshold, inequality (`>` or `>=`),
24
+ point value and category cut-off comes from a source you opened in this session. If you
25
+ cannot open a source, stop and ask the user for it (they can attach the PDF).
26
+ 3. **Say how each source was verified.** End every citation with `[P]` (primary full text
27
+ read), `[A]` (abstract only) or `[S]` (secondary source, such as NICE or a later paper).
28
+ 4. **Mark what is uncertain.** For an ambiguous or unverifiable criterion, implement the best
29
+ reading and add an item note that starts with `TODO(physician-review):` and gives the
30
+ reading chosen and the alternative. Only the user changes a `TODO(physician-review)` into
31
+ `REVIEWED(physician)`. Never write `REVIEWED(physician)` yourself, unless a roadmap entry
32
+ tells you to carry over a reading that calc-bounds already records as reviewed.
33
+ 5. **Two independent implementations.** Every calculator has a `reference(v)` function in
34
+ `tests/calculators/test_<id>.py`, written from the source and not from the YAML, and checked
35
+ with `assert_matches_reference`. When the two disagree, re-read the source. Never copy one
36
+ side into the other to make the test pass.
37
+ 6. **Expected values come from the source, by hand.** Never run the engine and paste its
38
+ output into a test as the expected value.
39
+ 7. **Never weaken a check to get green.** Do not edit or delete existing tests, loosen the
40
+ validation in `spec.py`, or change `testing.py` to make something pass. If a check looks
41
+ wrong, stop and ask.
42
+ 8. **Engine and format are frozen unless the user approves.** Adding a calculator touches only
43
+ the files listed in "What a calculator consists of". Do not change `engine.py`, `spec.py`,
44
+ `testing.py`, `mcp_server.py`, the YAML format or the names of output fields (they are a
45
+ public API). If a calculator does not fit the format, stop and ask.
46
+ 9. **Deterministic core.** No LLM calls, network access or randomness in `src/`.
47
+ 10. **No real patient data** anywhere: specs, tests, examples, commit messages.
48
+ 11. **No claims of clinical validity.** Never write that this is validated, safe for clinical
49
+ use or a medical device.
50
+ 12. **Dependencies:** `pyyaml` (runtime), `mcp>=2,<3` (optional extra), `pytest` (dev). Ask
51
+ before adding anything else.
52
+
53
+ ## When to stop and ask
54
+
55
+ - You cannot open the primary source, and the scoring table is not in an abstract or a
56
+ secondary source you can open.
57
+ - The score exists in versions (two-tier or three-tier, original or modified) and the roadmap
58
+ does not say which one.
59
+ - The calculator needs something the format lacks: an item used by two components,
60
+ categories that depend on anything other than the total score, a continuous formula, or
61
+ points that are not whole or half numbers.
62
+ - A test you did not write fails, or a fix seems to need a change in `src/` outside `specs/`.
63
+ - Sources disagree in a way that changes a category for some patient and you cannot tell
64
+ which reading the roadmap intends.
65
+ - You are about to push, publish, tag a release or change repository settings.
66
+
67
+ ## What a calculator consists of
68
+
69
+ Adding calculator `<id>` creates or edits exactly these files:
70
+
71
+ | File | Change |
72
+ |---|---|
73
+ | `src/bounded_calcs/specs/<id>.yaml` | new: the spec |
74
+ | `tests/calculators/test_<id>.py` | new: reference function, threshold tests, clinical examples |
75
+ | `README.md` | one row in the calculator table |
76
+ | `docs/ROADMAP.md` | move the item to the "Built" table |
77
+
78
+ Nothing else. The MCP server and the generic tests pick up new specs automatically.
79
+
80
+ ## Layout
81
+
82
+ ```
83
+ src/bounded_calcs/
84
+ spec.py YAML format, strict validation (rejects typos, bad band order, bad YAML text)
85
+ engine.py evaluate(): bounds, possible categories, decisive unknowns; schema()
86
+ testing.py assert_matches_reference(): spec and engine against an independent reference
87
+ mcp_server.py MCP tools: list_calculators, get_schema, evaluate
88
+ specs/*.yaml one calculator each (file name = id)
89
+ tests/
90
+ calculators/ one test file per calculator (copy test_curb65.py for a new one)
91
+ test_builtin_specs.py rules every built-in calculator must satisfy
92
+ test_spec.py, test_engine.py, test_testing.py, test_mcp.py
93
+ docs/
94
+ ADDING_A_CALCULATOR.md the procedure
95
+ ROADMAP.md what is built, what is next, open decisions
96
+ RELEASING.md how a version reaches PyPI
97
+ scripts/smoke_test.py checks a built wheel or sdist as an installed package
98
+ .github/workflows/publish.yml tests, builds and publishes when a version tag is pushed
99
+ ```
100
+
101
+ ## Commands
102
+
103
+ ```sh
104
+ uv sync # install (pytest and the MCP SDK included)
105
+ uv run pytest # everything; must be green before a commit
106
+ uv run pytest tests/calculators/test_<id>.py -x # one calculator while working
107
+ uv run python -c "from bounded_calcs import load_builtin; print(list(load_builtin()))"
108
+ uv run bounded-calcs-mcp # MCP server on stdio
109
+ grep -rn "TODO(physician-review)" src/bounded_calcs/specs/ # open review items
110
+ ```
111
+
112
+ The MCP SDK is version 2: `from mcp.server import MCPServer`, tool errors via
113
+ `mcp.server.mcpserver.exceptions.ToolError`. Version 1 `FastMCP` examples do not apply. Check
114
+ an API by importing it before you use it.
115
+
116
+ ## Working style
117
+
118
+ - One calculator per commit. Subject: `Add <name> (<id>)`. Body: sources with their `[P]`,
119
+ `[A]`, `[S]` tags, each `TODO(physician-review)` note you added, and a last line
120
+ `Closes #N` with the number of the item's issue.
121
+ - Give your report in your reply. Comment on an issue, or change its labels, only when the
122
+ user asks.
123
+ - Do not refactor, rename or "improve" anything outside the task.
124
+ - Everything in the repository is written in English: code, specs, docs, commit messages.
125
+ - Run `uv run pytest` before every commit and read the output. A commit with a failing or
126
+ skipped test is not done.
127
+ - Push only when the user asks. The default branch is `main`.
128
+ - End every task with this report:
129
+ 1. What you added (files).
130
+ 2. Sources and how each was verified (`[P]`, `[A]`, `[S]`), and what you could not open.
131
+ 3. The scoring table you built in step 2 of the procedure.
132
+ 4. The last line of `uv run pytest`.
133
+ 5. Every `TODO(physician-review)` note you added, quoted in full.
134
+ 6. Anything you were unsure about.
135
+ - After a roadmap item, stop. Start the next one only when the user says so.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolás Vera Zúñiga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: bounded-calcs
3
+ Version: 0.1.0
4
+ Summary: Clinical risk calculators that treat unknown as unknown: exact score bounds, settled or unsettled decisions, and the questions worth asking. Python library and MCP server. Research use, not a medical device.
5
+ Project-URL: Homepage, https://github.com/nicoveraz/bounded-calcs
6
+ Project-URL: Repository, https://github.com/nicoveraz/bounded-calcs
7
+ Project-URL: Issues, https://github.com/nicoveraz/bounded-calcs/issues
8
+ Project-URL: Paper, https://arxiv.org/abs/2609.34112
9
+ Author: Nicolás Vera Zúñiga
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: clinical decision support,emergency medicine,llm,mcp,medical calculators,missing data,model context protocol,risk scores
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Healthcare Industry
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: pyyaml>=6.0.1
26
+ Provides-Extra: mcp
27
+ Requires-Dist: mcp<3,>=2; extra == 'mcp'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # bounded-calcs
31
+
32
+ Clinical calculators that treat **unknown as unknown**.
33
+
34
+ Most calculator tools, and the LLM benchmarks built on them, take a complete input. When a
35
+ note doesn't mention a finding, someone fills it in as absent or normal, and the calculator
36
+ returns a confident number. `bounded-calcs` instead accepts **present / absent / unknown** for
37
+ every item and returns:
38
+
39
+ - whether the decision category is **settled** by what is known,
40
+ - every category still **possible**, and the **worst case**,
41
+ - the exact **score range**,
42
+ - the **decisive unknowns**: the only items worth asking the clinician about, with what the
43
+ category would be for each possible answer.
44
+
45
+ It packages the approach from *Unknown is not normal: separating language-model extraction
46
+ from rule-based decision logic for clinical risk scores*
47
+ ([arXiv:2609.34112](https://arxiv.org/abs/2609.34112); experiment code:
48
+ [calc-bounds](https://github.com/nicoveraz/calc-bounds)): the LLM only reads the note;
49
+ deterministic code decides whether the missing items matter.
50
+
51
+ > **Research and reference use only.** Not a medical device. The built-in definitions were
52
+ > reviewed by one emergency physician (the author); several sources were read only as
53
+ > abstracts or through secondary sources, as each spec states.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ pip install bounded-calcs # the library
59
+ pip install "bounded-calcs[mcp]" # the library and the MCP server
60
+ ```
61
+
62
+ Python 3.11 or later. The MCP server can also run without installing anything first: see
63
+ [MCP server](#mcp-server).
64
+
65
+ ## Calculators
66
+
67
+ | id | Calculator | Source | Verified |
68
+ |---|---|---|---|
69
+ | `heart` | HEART score (low / moderate / high) | Six et al., Neth Heart J 2008. PMID 18665203 | [P] |
70
+ | `wells_pe` | Wells criteria for PE, two-tier (unlikely / likely) | Wells et al., Thromb Haemost 2000. PMID 10744147 | [A], [S] |
71
+ | `perc` | PERC rule (negative / positive) | Kline et al., J Thromb Haemost 2004. PMID 15304025 | [A] |
72
+ | `curb65` | CURB-65 (low / moderate / high) | Lim et al., Thorax 2003. PMID 12728155 | [P] |
73
+
74
+ Verified: **[P]** primary full text read, **[A]** abstract only, **[S]** secondary source
75
+ (guideline or later paper). All four are ported from calc-bounds, whose readings the author
76
+ reviewed and accepted (`docs/CALCULATOR_NOTES.md` there). Item notes in each spec record the
77
+ reading implemented and the alternatives: `REVIEWED(physician)` marks a reading a physician
78
+ accepted, `TODO(physician-review)` one that is still open.
79
+
80
+ Every calculator is checked against an independent reference function on all complete
81
+ inputs, including each numeric threshold, and on partial inputs by brute force.
82
+
83
+ ## Example
84
+
85
+ A note documents heart rate 110, no haemoptysis, no malignancy, and says nothing else:
86
+
87
+ ```python
88
+ from bounded_calcs import load_builtin, evaluate
89
+
90
+ wells = load_builtin()["wells_pe"]
91
+ r = evaluate(wells, {"heart_rate": 110, "hemoptysis": "absent", "malignancy": "absent"})
92
+ ```
93
+
94
+ ```json
95
+ {
96
+ "settled": false,
97
+ "possible_categories": ["pe_unlikely", "pe_likely"],
98
+ "worst_case_category": "pe_likely",
99
+ "score_range": [1.5, 10.5],
100
+ "decisive_unknowns": [
101
+ {"item": "dvt_signs", "kind": "finding", "settles_decision_in": "1 of 2 answers", "...": "..."},
102
+ {"item": "pe_most_likely", "kind": "judgment", "settles_decision_in": "1 of 2 answers", "...": "..."}
103
+ ],
104
+ "jointly_relevant_unknowns": ["immobilization_or_surgery", "prior_vte"],
105
+ "judgment_items_pending": ["pe_most_likely"]
106
+ }
107
+ ```
108
+
109
+ Filling the silence with "absent" would have returned *PE unlikely*. The honest answer is
110
+ that it depends on two things the note doesn't say, one of which only the clinician can judge.
111
+
112
+ ## Inputs
113
+
114
+ - **boolean** items: `"present"`, `"absent"` (or `true` / `false`)
115
+ - **ordinal** items: one of the listed levels
116
+ - **numeric** items: a number in the item's unit
117
+ - anything omitted, `null`, or `"unknown"` is unknown, **never** normal
118
+ - a numeric item the source only calls "normal", with no number, is also unknown
119
+
120
+ Each item also has a **kind**: `finding` (readable from a note), `measurement` (vitals, labs,
121
+ age) or `judgment` (clinician only, e.g. HEART history, Wells "alternative diagnosis less
122
+ likely"). Clients should never infer judgment items from text.
123
+
124
+ ### Unknown categories in the output
125
+
126
+ | field | meaning |
127
+ |---|---|
128
+ | `decisive_unknowns` | knowing this item alone could narrow the possible categories; ask these, in order |
129
+ | `jointly_relevant_unknowns` | can move the score but cannot change the category on its own; may matter together |
130
+ | `irrelevant_unknowns` | cannot change the category (always the case for every unknown once settled) |
131
+
132
+ ## MCP server
133
+
134
+ Runs over stdio by default, so notes stay on the machine. With
135
+ [uv](https://docs.astral.sh/uv/) installed, one command fetches and starts it:
136
+
137
+ ```bash
138
+ uvx --from "bounded-calcs[mcp]" bounded-calcs-mcp
139
+ ```
140
+
141
+ Claude Desktop / Claude Code config:
142
+
143
+ ```json
144
+ {
145
+ "mcpServers": {
146
+ "bounded-calcs": {
147
+ "command": "uvx",
148
+ "args": ["--from", "bounded-calcs[mcp]", "bounded-calcs-mcp"]
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ From a clone of this repository, use `"command": "uv"` with
155
+ `"args": ["--directory", "/path/to/bounded-calcs", "run", "bounded-calcs-mcp"]`.
156
+
157
+ Tools: `list_calculators`, `get_schema(calculator_id)`, `evaluate(calculator_id, findings)`.
158
+ The server's instructions tell the model to send only documented findings, ask about
159
+ decisive unknowns, and never fill judgment items itself.
160
+
161
+ ## Adding a calculator
162
+
163
+ Each calculator is one YAML file in `src/bounded_calcs/specs/`: items (type, kind, levels or
164
+ unit, a question to ask), components (how items become points), and categories. Component
165
+ rules: `present`, `absent`, `levels`, `bands` (numeric thresholds), `count` (number of present
166
+ items, with an optional override item), and `any_of` (points if any of several conditions is
167
+ met, such as CURB-65's "SBP < 90 or DBP <= 60"). Points may be negative. Every item belongs to
168
+ exactly one component; that is what makes the bounds exact rather than approximate. The
169
+ loader is strict: misspelled keys, bands that can never match and text that YAML misread are
170
+ errors.
171
+
172
+ The procedure is in [`docs/ADDING_A_CALCULATOR.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/docs/ADDING_A_CALCULATOR.md), the
173
+ queue in [`docs/ROADMAP.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/docs/ROADMAP.md), and the rules for AI coding agents in
174
+ [`CLAUDE.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/CLAUDE.md). In short: take every criterion from the primary source, write the
175
+ spec and an independent reference function, and let `assert_matches_reference` compare them:
176
+
177
+ ```python
178
+ from bounded_calcs import load_file
179
+ from bounded_calcs.testing import assert_matches_reference
180
+
181
+ def reference(v): # written from the paper, not from the YAML
182
+ score = v["confusion"] + (v["urea"] > 7) + (v["resp_rate"] >= 30) \
183
+ + (v["sbp"] < 90 or v["dbp"] <= 60) + (v["age"] >= 65)
184
+ return score, "low" if score <= 1 else "moderate" if score == 2 else "high"
185
+
186
+ assert_matches_reference(load_file("curb65.yaml"), reference)
187
+ ```
188
+
189
+ ## How it works
190
+
191
+ Components are independent, so the reachable scores are exactly the sumset of each
192
+ component's reachable points; there is no sampling and no LLM in the loop. An unknown item is
193
+ decisive if some value of it removes at least one category from the possible set. Decisive
194
+ items are ordered by how often an answer settles the decision, then by how much it narrows on
195
+ average, with data items before judgments when otherwise equal.
196
+
197
+ ## Development
198
+
199
+ ```bash
200
+ uv sync
201
+ uv run pytest
202
+ ```
203
+
204
+ Releases are published to PyPI by a GitHub Actions workflow when a version tag is pushed:
205
+ [`docs/RELEASING.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/docs/RELEASING.md).
206
+
207
+ Each calculator has its own test file with an independent reference function, threshold
208
+ cases taken from the source, and sparse-note examples. Other tests cover the spec loader's
209
+ validation, input handling, every rule type, an end-to-end MCP session, and that the
210
+ reference check itself fails on a wrong spec.
211
+
212
+ ## License
213
+
214
+ MIT
@@ -0,0 +1,185 @@
1
+ # bounded-calcs
2
+
3
+ Clinical calculators that treat **unknown as unknown**.
4
+
5
+ Most calculator tools, and the LLM benchmarks built on them, take a complete input. When a
6
+ note doesn't mention a finding, someone fills it in as absent or normal, and the calculator
7
+ returns a confident number. `bounded-calcs` instead accepts **present / absent / unknown** for
8
+ every item and returns:
9
+
10
+ - whether the decision category is **settled** by what is known,
11
+ - every category still **possible**, and the **worst case**,
12
+ - the exact **score range**,
13
+ - the **decisive unknowns**: the only items worth asking the clinician about, with what the
14
+ category would be for each possible answer.
15
+
16
+ It packages the approach from *Unknown is not normal: separating language-model extraction
17
+ from rule-based decision logic for clinical risk scores*
18
+ ([arXiv:2609.34112](https://arxiv.org/abs/2609.34112); experiment code:
19
+ [calc-bounds](https://github.com/nicoveraz/calc-bounds)): the LLM only reads the note;
20
+ deterministic code decides whether the missing items matter.
21
+
22
+ > **Research and reference use only.** Not a medical device. The built-in definitions were
23
+ > reviewed by one emergency physician (the author); several sources were read only as
24
+ > abstracts or through secondary sources, as each spec states.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install bounded-calcs # the library
30
+ pip install "bounded-calcs[mcp]" # the library and the MCP server
31
+ ```
32
+
33
+ Python 3.11 or later. The MCP server can also run without installing anything first: see
34
+ [MCP server](#mcp-server).
35
+
36
+ ## Calculators
37
+
38
+ | id | Calculator | Source | Verified |
39
+ |---|---|---|---|
40
+ | `heart` | HEART score (low / moderate / high) | Six et al., Neth Heart J 2008. PMID 18665203 | [P] |
41
+ | `wells_pe` | Wells criteria for PE, two-tier (unlikely / likely) | Wells et al., Thromb Haemost 2000. PMID 10744147 | [A], [S] |
42
+ | `perc` | PERC rule (negative / positive) | Kline et al., J Thromb Haemost 2004. PMID 15304025 | [A] |
43
+ | `curb65` | CURB-65 (low / moderate / high) | Lim et al., Thorax 2003. PMID 12728155 | [P] |
44
+
45
+ Verified: **[P]** primary full text read, **[A]** abstract only, **[S]** secondary source
46
+ (guideline or later paper). All four are ported from calc-bounds, whose readings the author
47
+ reviewed and accepted (`docs/CALCULATOR_NOTES.md` there). Item notes in each spec record the
48
+ reading implemented and the alternatives: `REVIEWED(physician)` marks a reading a physician
49
+ accepted, `TODO(physician-review)` one that is still open.
50
+
51
+ Every calculator is checked against an independent reference function on all complete
52
+ inputs, including each numeric threshold, and on partial inputs by brute force.
53
+
54
+ ## Example
55
+
56
+ A note documents heart rate 110, no haemoptysis, no malignancy, and says nothing else:
57
+
58
+ ```python
59
+ from bounded_calcs import load_builtin, evaluate
60
+
61
+ wells = load_builtin()["wells_pe"]
62
+ r = evaluate(wells, {"heart_rate": 110, "hemoptysis": "absent", "malignancy": "absent"})
63
+ ```
64
+
65
+ ```json
66
+ {
67
+ "settled": false,
68
+ "possible_categories": ["pe_unlikely", "pe_likely"],
69
+ "worst_case_category": "pe_likely",
70
+ "score_range": [1.5, 10.5],
71
+ "decisive_unknowns": [
72
+ {"item": "dvt_signs", "kind": "finding", "settles_decision_in": "1 of 2 answers", "...": "..."},
73
+ {"item": "pe_most_likely", "kind": "judgment", "settles_decision_in": "1 of 2 answers", "...": "..."}
74
+ ],
75
+ "jointly_relevant_unknowns": ["immobilization_or_surgery", "prior_vte"],
76
+ "judgment_items_pending": ["pe_most_likely"]
77
+ }
78
+ ```
79
+
80
+ Filling the silence with "absent" would have returned *PE unlikely*. The honest answer is
81
+ that it depends on two things the note doesn't say, one of which only the clinician can judge.
82
+
83
+ ## Inputs
84
+
85
+ - **boolean** items: `"present"`, `"absent"` (or `true` / `false`)
86
+ - **ordinal** items: one of the listed levels
87
+ - **numeric** items: a number in the item's unit
88
+ - anything omitted, `null`, or `"unknown"` is unknown, **never** normal
89
+ - a numeric item the source only calls "normal", with no number, is also unknown
90
+
91
+ Each item also has a **kind**: `finding` (readable from a note), `measurement` (vitals, labs,
92
+ age) or `judgment` (clinician only, e.g. HEART history, Wells "alternative diagnosis less
93
+ likely"). Clients should never infer judgment items from text.
94
+
95
+ ### Unknown categories in the output
96
+
97
+ | field | meaning |
98
+ |---|---|
99
+ | `decisive_unknowns` | knowing this item alone could narrow the possible categories; ask these, in order |
100
+ | `jointly_relevant_unknowns` | can move the score but cannot change the category on its own; may matter together |
101
+ | `irrelevant_unknowns` | cannot change the category (always the case for every unknown once settled) |
102
+
103
+ ## MCP server
104
+
105
+ Runs over stdio by default, so notes stay on the machine. With
106
+ [uv](https://docs.astral.sh/uv/) installed, one command fetches and starts it:
107
+
108
+ ```bash
109
+ uvx --from "bounded-calcs[mcp]" bounded-calcs-mcp
110
+ ```
111
+
112
+ Claude Desktop / Claude Code config:
113
+
114
+ ```json
115
+ {
116
+ "mcpServers": {
117
+ "bounded-calcs": {
118
+ "command": "uvx",
119
+ "args": ["--from", "bounded-calcs[mcp]", "bounded-calcs-mcp"]
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ From a clone of this repository, use `"command": "uv"` with
126
+ `"args": ["--directory", "/path/to/bounded-calcs", "run", "bounded-calcs-mcp"]`.
127
+
128
+ Tools: `list_calculators`, `get_schema(calculator_id)`, `evaluate(calculator_id, findings)`.
129
+ The server's instructions tell the model to send only documented findings, ask about
130
+ decisive unknowns, and never fill judgment items itself.
131
+
132
+ ## Adding a calculator
133
+
134
+ Each calculator is one YAML file in `src/bounded_calcs/specs/`: items (type, kind, levels or
135
+ unit, a question to ask), components (how items become points), and categories. Component
136
+ rules: `present`, `absent`, `levels`, `bands` (numeric thresholds), `count` (number of present
137
+ items, with an optional override item), and `any_of` (points if any of several conditions is
138
+ met, such as CURB-65's "SBP < 90 or DBP <= 60"). Points may be negative. Every item belongs to
139
+ exactly one component; that is what makes the bounds exact rather than approximate. The
140
+ loader is strict: misspelled keys, bands that can never match and text that YAML misread are
141
+ errors.
142
+
143
+ The procedure is in [`docs/ADDING_A_CALCULATOR.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/docs/ADDING_A_CALCULATOR.md), the
144
+ queue in [`docs/ROADMAP.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/docs/ROADMAP.md), and the rules for AI coding agents in
145
+ [`CLAUDE.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/CLAUDE.md). In short: take every criterion from the primary source, write the
146
+ spec and an independent reference function, and let `assert_matches_reference` compare them:
147
+
148
+ ```python
149
+ from bounded_calcs import load_file
150
+ from bounded_calcs.testing import assert_matches_reference
151
+
152
+ def reference(v): # written from the paper, not from the YAML
153
+ score = v["confusion"] + (v["urea"] > 7) + (v["resp_rate"] >= 30) \
154
+ + (v["sbp"] < 90 or v["dbp"] <= 60) + (v["age"] >= 65)
155
+ return score, "low" if score <= 1 else "moderate" if score == 2 else "high"
156
+
157
+ assert_matches_reference(load_file("curb65.yaml"), reference)
158
+ ```
159
+
160
+ ## How it works
161
+
162
+ Components are independent, so the reachable scores are exactly the sumset of each
163
+ component's reachable points; there is no sampling and no LLM in the loop. An unknown item is
164
+ decisive if some value of it removes at least one category from the possible set. Decisive
165
+ items are ordered by how often an answer settles the decision, then by how much it narrows on
166
+ average, with data items before judgments when otherwise equal.
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ uv sync
172
+ uv run pytest
173
+ ```
174
+
175
+ Releases are published to PyPI by a GitHub Actions workflow when a version tag is pushed:
176
+ [`docs/RELEASING.md`](https://github.com/nicoveraz/bounded-calcs/blob/main/docs/RELEASING.md).
177
+
178
+ Each calculator has its own test file with an independent reference function, threshold
179
+ cases taken from the source, and sparse-note examples. Other tests cover the spec loader's
180
+ validation, input handling, every rule type, an end-to-end MCP session, and that the
181
+ reference check itself fails on a wrong spec.
182
+
183
+ ## License
184
+
185
+ MIT