Skip to content

Commit b980166

Browse files
vosesoftclaude
andcommitted
Phase 1b: verify_rollback cross-check + packaging
verify_rollback tool: compares our Python rollback EVs to the MC_V_<nodeId> named ranges ModelChoice writes when it renders a tree (prefix confirmed from CellWriter.cs). Reports per-node diffs, a max-abs-diff, and a verdict; flags "not rendered" when no MC_V_ cells exist. bridge.read_node_values reads those named ranges (read-only). Unit-tested (match / mismatch / not-rendered); the live cross-check awaits a real add-in-rendered tree. Verified: sdist + wheel build clean. 22 unit tests; ruff + mypy clean. (CI + release workflows are a separate commit pending the `workflow` token scope to push.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1b13280 commit b980166

5 files changed

Lines changed: 197 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): w
1313
| `list_trees` | List the decision trees in a workbook with node-type counts. |
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. |
16+
| `verify_rollback` | Cross-check our rollback against the `MC_V_<id>` cells ModelChoice itself wrote — a correctness guarantee when the tree has been rendered. |
1617

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

@@ -29,7 +30,7 @@ The Python roller is validated against ModelChoice's own authoritative C# test (
2930

3031
- **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.
3132
- **Phase 1 (done):** the MCP server — `list_trees`, `get_tree`, `roll_up`, proven live against a real workbook.
32-
- **Phase 1b:** cross-check rolled-back EVs against the `MC_V_<id>` named ranges; packaging + CI + PyPI; Claude Desktop wiring docs.
33+
- **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.)*
3334
- **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`.
3435
- **Phase 3:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
3536

src/modelchoice_mcp/bridge.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,42 @@ def get_tree(self, sheet_name: str | None = None, workbook: str | None = None) -
9494
def roll_up(self, sheet_name: str | None = None, workbook: str | None = None) -> RollupResult:
9595
"""Read a tree and return its rolled-back EV + optimal policy."""
9696
return rollup(self.get_tree(sheet_name, workbook))
97+
98+
def read_node_values(self, workbook: str | None = None) -> dict[str, float]:
99+
"""Read the rolled-back expected values ModelChoice itself wrote
100+
into the ``MC_V_<nodeId>`` named ranges on the rendered tree
101+
sheet(s). Returns ``{nodeId: value}``. These are the add-in's own
102+
rollback numbers — comparing them to our Python rollback proves
103+
the two agree. Returns an empty dict if the tree hasn't been
104+
rendered (named ranges only exist after a render)."""
105+
book = self._book(workbook)
106+
prefix = "MC_V_"
107+
out: dict[str, float] = {}
108+
109+
name_objs: list[Any] = []
110+
try:
111+
name_objs.extend(list(book.names))
112+
except Exception:
113+
pass
114+
for sht in book.sheets:
115+
try:
116+
name_objs.extend(list(sht.names))
117+
except Exception:
118+
pass
119+
120+
for nm in name_objs:
121+
try:
122+
full = str(nm.name) # may be "MC_V_D" or "Sheet!MC_V_D"
123+
except Exception:
124+
continue
125+
idx = full.find(prefix)
126+
if idx < 0:
127+
continue
128+
node_id = full[idx + len(prefix):]
129+
try:
130+
val = nm.refers_to_range.value
131+
except Exception:
132+
continue
133+
if isinstance(val, (int, float)) and not isinstance(val, bool):
134+
out[node_id] = float(val)
135+
return out

src/modelchoice_mcp/schemas.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,28 @@ class RollupResponse(BaseModel):
6868
)
6969
recommendation: str = Field(description="Plain-English decision recommendation.")
7070
nodes: list[NodeResultView]
71+
72+
73+
class NodeDiff(BaseModel):
74+
node_id: str
75+
name: str
76+
computed: float = Field(description="Our Python rollback EV for this node.")
77+
cell: float = Field(description="The MC_V_<nodeId> value ModelChoice wrote.")
78+
diff: float = Field(description="computed - cell.")
79+
80+
81+
class RollbackVerification(BaseModel):
82+
"""Cross-check of our Python rollback against the EVs ModelChoice
83+
itself wrote into the MC_V_<nodeId> named ranges."""
84+
85+
name: str
86+
rendered: bool = Field(
87+
description="True if the tree has MC_V_ cells (i.e. it has been rendered by the add-in)."
88+
)
89+
compared_count: int = Field(description="Nodes present in both our rollback and the cells.")
90+
matches: int
91+
max_abs_diff: float = Field(description="Largest |computed - cell| across compared nodes.")
92+
mismatches: list[NodeDiff] = Field(
93+
default_factory=list, description="Nodes whose values differ beyond the tolerance."
94+
)
95+
verdict: str

src/modelchoice_mcp/tools.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
from modelchoice_mcp.bridge import ModelChoiceBridge
1515
from modelchoice_mcp.schemas import (
1616
BranchView,
17+
NodeDiff,
1718
NodeResultView,
1819
NodeView,
20+
RollbackVerification,
1921
RollupResponse,
2022
TreeList,
2123
TreeStructure,
@@ -178,4 +180,76 @@ def roll_up(
178180
)
179181

180182

181-
__all__ = ["get_bridge", "get_tree", "list_trees", "roll_up", "set_bridge_for_testing"]
183+
@mcp.tool(
184+
description=(
185+
"ModelChoice: Cross-check our rollback against ModelChoice's own. The "
186+
"add-in writes each node's rolled-back EV into an MC_V_<nodeId> named "
187+
"range when it renders a tree; this compares those cells to our Python "
188+
"rollback and reports any node that differs beyond `tolerance`. A clean "
189+
"result is strong evidence the read+rollback is faithful. If the tree "
190+
"hasn't been rendered (no MC_V_ cells), `rendered` is false — open the "
191+
"tree in ModelChoice so it renders, then re-run."
192+
)
193+
)
194+
def verify_rollback(
195+
tree_name: Annotated[
196+
str | None, Field(description="Tree sheet name. Omit for the first tree.")
197+
] = None,
198+
workbook_name: str | None = None,
199+
tolerance: Annotated[
200+
float, Field(gt=0, description="Max allowed |computed - cell| difference. Default 0.01.")
201+
] = 0.01,
202+
) -> RollbackVerification:
203+
bridge = get_bridge()
204+
trees = bridge.list_trees(workbook_name)
205+
if tree_name is None:
206+
tree_name = next(iter(trees))
207+
r = rollup(parse_model(trees[tree_name]))
208+
cells = bridge.read_node_values(workbook_name)
209+
210+
common = [nid for nid in r.node_results if nid in cells]
211+
mismatches: list[NodeDiff] = []
212+
max_abs = 0.0
213+
for nid in common:
214+
computed = r.node_results[nid].expected_value
215+
cell = cells[nid]
216+
diff = computed - cell
217+
max_abs = max(max_abs, abs(diff))
218+
if abs(diff) > tolerance:
219+
mismatches.append(
220+
NodeDiff(
221+
node_id=nid, name=r.node_results[nid].name,
222+
computed=computed, cell=cell, diff=diff,
223+
)
224+
)
225+
226+
rendered = len(cells) > 0
227+
matches = len(common) - len(mismatches)
228+
if not rendered:
229+
verdict = "Tree not rendered — no MC_V_ cells found. Open it in ModelChoice first."
230+
elif not common:
231+
verdict = "No overlapping node IDs between the model and the rendered cells."
232+
elif not mismatches:
233+
verdict = f"Rollback verified: all {matches} compared nodes match (max diff {max_abs:.2g})."
234+
else:
235+
verdict = f"{len(mismatches)} of {len(common)} nodes differ beyond {tolerance}."
236+
237+
return RollbackVerification(
238+
name=tree_name,
239+
rendered=rendered,
240+
compared_count=len(common),
241+
matches=matches,
242+
max_abs_diff=max_abs,
243+
mismatches=mismatches,
244+
verdict=verdict,
245+
)
246+
247+
248+
__all__ = [
249+
"get_bridge",
250+
"get_tree",
251+
"list_trees",
252+
"roll_up",
253+
"set_bridge_for_testing",
254+
"verify_rollback",
255+
]

tests/test_tools.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
import pytest
1010

1111
from modelchoice_mcp import tools
12-
from modelchoice_mcp.schemas import RollupResponse, TreeList, TreeStructure
12+
from modelchoice_mcp.schemas import (
13+
RollbackVerification,
14+
RollupResponse,
15+
TreeList,
16+
TreeStructure,
17+
)
1318

1419
# Ground-truth tree (matches ModelChoice's C# rollback test): EV 50, Option2.
1520
_MODEL = json.dumps({
@@ -29,12 +34,18 @@
2934

3035

3136
class _FakeBridge:
32-
def __init__(self, trees: dict[str, str]) -> None:
37+
def __init__(
38+
self, trees: dict[str, str], node_values: dict[str, float] | None = None
39+
) -> None:
3340
self._trees = trees
41+
self._node_values = node_values or {}
3442

3543
def list_trees(self, workbook: str | None = None) -> dict[str, str]:
3644
return dict(self._trees)
3745

46+
def read_node_values(self, workbook: str | None = None) -> dict[str, float]:
47+
return dict(self._node_values)
48+
3849

3950
@pytest.fixture
4051
def bridge() -> Iterator[_FakeBridge]:
@@ -44,6 +55,10 @@ def bridge() -> Iterator[_FakeBridge]:
4455
tools.set_bridge_for_testing(None)
4556

4657

58+
def _set(b: _FakeBridge) -> None:
59+
tools.set_bridge_for_testing(b) # type: ignore[arg-type]
60+
61+
4762
def test_list_trees_summarizes(bridge: _FakeBridge) -> None:
4863
out = tools.list_trees()
4964
assert isinstance(out, TreeList)
@@ -77,3 +92,42 @@ def test_roll_up_recommends_optimal(bridge: _FakeBridge) -> None:
7792
def test_named_tree_selection(bridge: _FakeBridge) -> None:
7893
out = tools.roll_up(tree_name="MC_Tree_1")
7994
assert out.name == "MC_Tree_1"
95+
96+
97+
# Computed EVs for the ground-truth tree: D=50, C=-25, T1=-100, T2=50.
98+
_TRUE_CELLS = {"D": 50.0, "C": -25.0, "T1": -100.0, "T2": 50.0}
99+
100+
101+
def test_verify_rollback_matches() -> None:
102+
_set(_FakeBridge({"MC_Tree_1": _MODEL}, _TRUE_CELLS))
103+
try:
104+
out = tools.verify_rollback()
105+
assert isinstance(out, RollbackVerification)
106+
assert out.rendered and out.compared_count == 4
107+
assert out.matches == 4 and not out.mismatches
108+
assert out.max_abs_diff < 0.01
109+
assert "verified" in out.verdict
110+
finally:
111+
tools.set_bridge_for_testing(None)
112+
113+
114+
def test_verify_rollback_flags_mismatch() -> None:
115+
bad = dict(_TRUE_CELLS, C=-20.0) # chance EV off by 5
116+
_set(_FakeBridge({"MC_Tree_1": _MODEL}, bad))
117+
try:
118+
out = tools.verify_rollback()
119+
assert [m.node_id for m in out.mismatches] == ["C"]
120+
assert out.mismatches[0].diff == pytest.approx(-5.0)
121+
assert out.max_abs_diff == pytest.approx(5.0)
122+
finally:
123+
tools.set_bridge_for_testing(None)
124+
125+
126+
def test_verify_rollback_not_rendered() -> None:
127+
_set(_FakeBridge({"MC_Tree_1": _MODEL}, {})) # no MC_V_ cells
128+
try:
129+
out = tools.verify_rollback()
130+
assert out.rendered is False and out.compared_count == 0
131+
assert "not rendered" in out.verdict.lower()
132+
finally:
133+
tools.set_bridge_for_testing(None)

0 commit comments

Comments
 (0)