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.
- bounded_calcs-0.1.0/.github/workflows/publish.yml +68 -0
- bounded_calcs-0.1.0/.gitignore +5 -0
- bounded_calcs-0.1.0/CLAUDE.md +135 -0
- bounded_calcs-0.1.0/LICENSE +21 -0
- bounded_calcs-0.1.0/PKG-INFO +214 -0
- bounded_calcs-0.1.0/README.md +185 -0
- bounded_calcs-0.1.0/docs/ADDING_A_CALCULATOR.md +298 -0
- bounded_calcs-0.1.0/docs/RELEASING.md +78 -0
- bounded_calcs-0.1.0/docs/ROADMAP.md +164 -0
- bounded_calcs-0.1.0/pyproject.toml +61 -0
- bounded_calcs-0.1.0/scripts/smoke_test.py +55 -0
- bounded_calcs-0.1.0/src/bounded_calcs/__init__.py +15 -0
- bounded_calcs-0.1.0/src/bounded_calcs/engine.py +344 -0
- bounded_calcs-0.1.0/src/bounded_calcs/mcp_server.py +86 -0
- bounded_calcs-0.1.0/src/bounded_calcs/spec.py +513 -0
- bounded_calcs-0.1.0/src/bounded_calcs/specs/__init__.py +0 -0
- bounded_calcs-0.1.0/src/bounded_calcs/specs/curb65.yaml +97 -0
- bounded_calcs-0.1.0/src/bounded_calcs/specs/heart.yaml +148 -0
- bounded_calcs-0.1.0/src/bounded_calcs/specs/perc.yaml +104 -0
- bounded_calcs-0.1.0/src/bounded_calcs/specs/wells_pe.yaml +78 -0
- bounded_calcs-0.1.0/src/bounded_calcs/testing.py +222 -0
- bounded_calcs-0.1.0/tests/calculators/test_curb65.py +89 -0
- bounded_calcs-0.1.0/tests/calculators/test_heart.py +146 -0
- bounded_calcs-0.1.0/tests/calculators/test_perc.py +85 -0
- bounded_calcs-0.1.0/tests/calculators/test_wells_pe.py +105 -0
- bounded_calcs-0.1.0/tests/test_builtin_specs.py +70 -0
- bounded_calcs-0.1.0/tests/test_docs.py +51 -0
- bounded_calcs-0.1.0/tests/test_engine.py +217 -0
- bounded_calcs-0.1.0/tests/test_mcp.py +45 -0
- bounded_calcs-0.1.0/tests/test_packaging.py +39 -0
- bounded_calcs-0.1.0/tests/test_spec.py +160 -0
- bounded_calcs-0.1.0/tests/test_testing.py +103 -0
- 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,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
|