Skip to content

Commit 7b3f408

Browse files
vosesoftclaude
andcommitted
Add run_evpi tool — first Phase 2 vertical slice (drives MC_EVPI_Auto)
Wires the MCP side of EVPI: run_evpi drives ModelChoice's headless MC_EVPI_Auto command via Application.Run and reads the MC_EVPI result sheet, returning the full-tree EVPI, optimal EV, value-with-perfect- information, and a plain-English interpretation. bridge.run_evpi activates the workbook, runs the command, and reads the sheet (clear errors if the add-in isn't loaded or no tree is active). New EvpiResult schema. Unit-tested with a fake bridge; live verification needs a ModelChoice build that includes MC_EVPI_Auto (AB#2558, pending that side shipping). 23 tests; ruff + mypy clean. Bumped to 0.0.2 (not released — release waits on the ModelChoice command being deployed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1eb0be1 commit 7b3f408

8 files changed

Lines changed: 120 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): w
1414
| `get_tree` | Full structure of one tree — decision / chance / terminal nodes, branches, probabilities, values. |
1515
| `roll_up` | Roll the tree back to its expected values and **optimal policy** — the decision recommendation, in plain English. |
1616
| `verify_rollback` | Cross-check our rollback against the `MC_V_<id>` cells ModelChoice itself wrote — a correctness guarantee when the tree has been rendered. |
17+
| `run_evpi` | Expected Value of Perfect Information for the active tree — the most you'd pay for perfect information before deciding. Drives ModelChoice's headless `MC_EVPI_Auto`. *(Needs a ModelChoice build that includes that command — Phase 2.)* |
1718

1819
Run it: `uv run python -m modelchoice_mcp` (stdio), or wire `modelchoice-mcp` into Claude Desktop like any MCP server.
1920

@@ -31,7 +32,7 @@ The Python roller is validated against ModelChoice's own authoritative C# test (
3132
- **Phase 0 (done):** the read + rollback engine — parse `_MC_Store`, reconstruct each tree, roll back to EV + optimal policy. Validated against ModelChoice's own C# rollback test.
3233
- **Phase 1 (done):** the MCP server — `list_trees`, `get_tree`, `roll_up`, proven live against a real workbook.
3334
- **Phase 1b (done):** `verify_rollback` cross-checks our EVs against the `MC_V_<id>` named ranges; CI (ruff + mypy + pytest) and a tag-driven PyPI release pipeline; wheel + sdist build. *(First publish awaits a PyPI trusted-publisher; the live `MC_V_` cross-check awaits a real rendered tree.)*
34-
- **Phase 2:** drive analyses. ModelChoice's analyses (sensitivity, EVPI, EVII, strategy table, robustness) are currently UI/modal-dialog only; the clean path — since we own the source — is to add headless `[ExcelCommand]` entry points mirroring the existing `MC_RiskProfile_Auto`, then invoke + read them via `Application.Run`.
35+
- **Phase 2 (in progress):** drive analyses. ModelChoice's analyses are UI/modal-dialog only; the clean path — since we own the source — is to add headless `[ExcelCommand]` entry points mirroring `MC_RiskProfile_Auto`, then invoke + read them via `Application.Run`. **EVPI** is the first vertical slice: `MC_EVPI_Auto` (ModelChoice, AB#2558) + the `run_evpi` tool here. Sensitivity, robustness, strategy table, and EVII follow.
3536
- **Phase 3:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
3637

3738
## Architecture

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "modelchoice-mcp"
3-
version = "0.0.1"
3+
version = "0.0.2"
44
description = "An open Model Context Protocol server for Vose Software's ModelChoice decision-tree add-in for Excel."
55
readme = "README.md"
66
requires-python = ">=3.11"

src/modelchoice_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""ModelChoice MCP — an open Model Context Protocol server for Vose
22
Software's ModelChoice decision-tree add-in for Excel."""
33

4-
__version__ = "0.0.1"
4+
__version__ = "0.0.2"

src/modelchoice_mcp/bridge.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,44 @@ def read_node_values(self, workbook: str | None = None) -> dict[str, float]:
133133
if isinstance(val, (int, float)) and not isinstance(val, bool):
134134
out[node_id] = float(val)
135135
return out
136+
137+
def run_evpi(self, workbook: str | None = None) -> dict[str, Any]:
138+
"""Drive ModelChoice's headless EVPI command and read its result.
139+
140+
Activates the workbook, calls ``Application.Run("MC_EVPI_Auto")``
141+
— which computes full-tree EVPI for the active tree and writes the
142+
``MC_EVPI`` sheet — then reads that sheet back. Requires the
143+
ModelChoice add-in to be loaded in Excel and a tree to be active.
144+
Raises ``ModelChoiceNotFoundError`` if the command produced no
145+
result sheet (add-in not loaded, or no active tree)."""
146+
book = self._book(workbook)
147+
try:
148+
book.activate()
149+
except Exception:
150+
pass
151+
try:
152+
book.app.api.Run("MC_EVPI_Auto")
153+
except Exception as exc:
154+
raise ModelChoiceNotFoundError(
155+
"MC_EVPI_Auto could not run — is the ModelChoice add-in loaded "
156+
"in Excel, with a decision tree open?"
157+
) from exc
158+
try:
159+
sheet = book.sheets["MC_EVPI"]
160+
except Exception as exc:
161+
raise ModelChoiceNotFoundError(
162+
"EVPI ran but wrote no MC_EVPI sheet — check that a tree is the "
163+
"active ModelChoice model."
164+
) from exc
165+
166+
def _num(cell: str) -> float | None:
167+
v = sheet.range(cell).value
168+
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
169+
170+
return {
171+
"model_name": sheet.range("C4").value,
172+
"objective": sheet.range("C5").value,
173+
"optimal_ev": _num("C6"),
174+
"evpi": _num("C7"),
175+
"value_with_perfect_info": _num("C8"),
176+
}

src/modelchoice_mcp/schemas.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ class NodeDiff(BaseModel):
7878
diff: float = Field(description="computed - cell.")
7979

8080

81+
class EvpiResult(BaseModel):
82+
"""Expected Value of Perfect Information for the active tree, produced
83+
by ModelChoice's headless MC_EVPI_Auto command."""
84+
85+
model_name: str | None = None
86+
objective: str | None = Field(default=None, description="'Maximize' or 'Minimize'.")
87+
optimal_ev: float | None = Field(default=None, description="Rolled-back expected value.")
88+
evpi: float | None = Field(
89+
default=None,
90+
description="Full-tree EVPI — the most you'd pay for perfect information.",
91+
)
92+
value_with_perfect_info: float | None = Field(
93+
default=None, description="Optimal EV plus (or minus) the EVPI."
94+
)
95+
interpretation: str = Field(
96+
description="Plain-English reading of the EVPI."
97+
)
98+
99+
81100
class RollbackVerification(BaseModel):
82101
"""Cross-check of our Python rollback against the EVs ModelChoice
83102
itself wrote into the MC_V_<nodeId> named ranges."""

src/modelchoice_mcp/tools.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from modelchoice_mcp.bridge import ModelChoiceBridge
1515
from modelchoice_mcp.schemas import (
1616
BranchView,
17+
EvpiResult,
1718
NodeDiff,
1819
NodeResultView,
1920
NodeView,
@@ -245,11 +246,47 @@ def verify_rollback(
245246
)
246247

247248

249+
@mcp.tool(
250+
description=(
251+
"ModelChoice: Compute the Expected Value of Perfect Information (EVPI) "
252+
"for the active decision tree — the most a decision-maker should pay "
253+
"for perfect information about all uncertainties before deciding. "
254+
"Drives ModelChoice's headless analysis (MC_EVPI_Auto) and reads the "
255+
"result. Requires the ModelChoice add-in loaded in Excel with a tree "
256+
"open. EVPI is not defined for MCDA models."
257+
)
258+
)
259+
def run_evpi(workbook_name: str | None = None) -> EvpiResult:
260+
r = get_bridge().run_evpi(workbook_name)
261+
evpi = r.get("evpi")
262+
if evpi is None:
263+
interp = "EVPI could not be read."
264+
elif evpi <= 1e-9:
265+
interp = (
266+
"EVPI is essentially zero — perfect information would not change the "
267+
"optimal decision, so don't pay for more information."
268+
)
269+
else:
270+
interp = (
271+
f"Perfect information about all uncertainties is worth up to "
272+
f"{evpi:,.2f}; that's the ceiling on what to spend gathering it."
273+
)
274+
return EvpiResult(
275+
model_name=r.get("model_name"),
276+
objective=r.get("objective"),
277+
optimal_ev=r.get("optimal_ev"),
278+
evpi=evpi,
279+
value_with_perfect_info=r.get("value_with_perfect_info"),
280+
interpretation=interp,
281+
)
282+
283+
248284
__all__ = [
249285
"get_bridge",
250286
"get_tree",
251287
"list_trees",
252288
"roll_up",
289+
"run_evpi",
253290
"set_bridge_for_testing",
254291
"verify_rollback",
255292
]

tests/test_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88

99
def test_version_set() -> None:
10-
assert __version__ == "0.0.1"
10+
assert __version__ == "0.0.2"
1111

1212

1313
def test_server_name() -> None:

tests/test_tools.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from modelchoice_mcp import tools
1212
from modelchoice_mcp.schemas import (
13+
EvpiResult,
1314
RollbackVerification,
1415
RollupResponse,
1516
TreeList,
@@ -46,6 +47,15 @@ def list_trees(self, workbook: str | None = None) -> dict[str, str]:
4647
def read_node_values(self, workbook: str | None = None) -> dict[str, float]:
4748
return dict(self._node_values)
4849

50+
def run_evpi(self, workbook: str | None = None) -> dict[str, object]:
51+
return {
52+
"model_name": "Oil",
53+
"objective": "Maximize",
54+
"optimal_ev": 50.0,
55+
"evpi": 25.0,
56+
"value_with_perfect_info": 75.0,
57+
}
58+
4959

5060
@pytest.fixture
5161
def bridge() -> Iterator[_FakeBridge]:
@@ -131,3 +141,11 @@ def test_verify_rollback_not_rendered() -> None:
131141
assert "not rendered" in out.verdict.lower()
132142
finally:
133143
tools.set_bridge_for_testing(None)
144+
145+
146+
def test_run_evpi(bridge: _FakeBridge) -> None:
147+
out = tools.run_evpi()
148+
assert isinstance(out, EvpiResult)
149+
assert out.evpi == 25.0 and out.optimal_ev == 50.0
150+
assert out.value_with_perfect_info == 75.0
151+
assert "25" in out.interpretation

0 commit comments

Comments
 (0)