Skip to content

Commit 2073353

Browse files
vosesoftclaude
andcommitted
Phase 0 spike: read + roll back ModelChoice decision trees from a workbook
Foundation for modelchoice-mcp, a sibling to modelrisk-mcp that brings decision analysis (decision trees) into a conversation. ModelChoice (C# Excel-DNA add-in) exposes no COM object model and no compute worksheet functions, but it persists the full tree as JSON in a very-hidden _MC_Store worksheet and the rollback logic is fully captured in that model. So this server reads trees without the add-in loaded: - store.py: parse the _MC_Store payload (v2 envelope with nested model JSON strings; v1 legacy raw model; chunked-across-columns reassembly). - tree.py: parse the model JSON (terminal/chance/decision nodes) and roll it back to expected values + the optimal policy in pure Python -- terminal payoff = accumulated branch values, chance = auto-normalized probability-weighted EV, decision = max/min by Settings.Maximize. - bridge.py: xlwings read of the very-hidden _MC_Store sheet (read-only; never unhides or mutates it). Validated against ModelChoice's own authoritative C# test (Model_Validate_Compile_Rollback_Works): the roller reproduces EV 50 with the optimal option selected, exactly. Proven end to end through real Excel COM: a saved .xlsx with a very-hidden _MC_Store -> bridge read -> parse -> roll -> decision recommendation, no add-in required. 11 unit tests; ruff + mypy(strict) clean. Roadmap (Phase 1 headless analysis commands, Phase 2 build/edit) in the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 parents  commit 2073353

9 files changed

Lines changed: 612 additions & 0 deletions

File tree

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
__pycache__/
2+
*.py[cod]
3+
.venv/
4+
.uv/
5+
uv.lock
6+
dist/
7+
build/
8+
*.egg-info/
9+
.pytest_cache/
10+
.mypy_cache/
11+
.ruff_cache/
12+
~$*.xlsx
13+
*.tmp

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# ModelChoice MCP
2+
3+
**An open Model Context Protocol server for [Vose Software's ModelChoice](https://www.vosesoftware.com) — the decision-tree add-in for Excel.**
4+
5+
A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): where that server brings Monte Carlo risk modelling into a conversation, this one brings **decision analysis** — building, reading, rolling back, and analysing decision trees in Excel.
6+
7+
> **Status: `0.0.1` — Phase 0 (read-only) spike.** The core read path is proven end to end. Building/analysis tools are on the roadmap below.
8+
9+
## How it works
10+
11+
ModelChoice is a C# .NET (Excel-DNA) add-in. Unlike ModelRisk it exposes **no COM object model and no compute worksheet functions** — but it persists the *entire* decision tree as JSON in a very-hidden worksheet (`_MC_Store`), and the rollback logic is fully captured in that model. So this server can:
12+
13+
- **Read** every tree in a workbook by reassembling the `_MC_Store` payload (chunked across columns, v2-envelope or v1-legacy format) and parsing the model JSON.
14+
- **Roll back** each tree to its expected values and **optimal policy** in pure Python — reproducing ModelChoice's own rollback (terminal payoff = accumulated branch values; chance = auto-normalized probability-weighted EV; decision = max/min by `Maximize`). **No add-in needs to be loaded** — it reads the saved model directly.
15+
16+
The Python roller is validated against ModelChoice's own authoritative C# test (`Model_Validate_Compile_Rollback_Works`): a decision/chance tree that rolls back to EV 50 with the optimal option selected — and matches exactly.
17+
18+
## Roadmap
19+
20+
- **Phase 0 (done):** read-only — list trees, read structure, roll back to EV + optimal policy, cross-check against the `MC_V_<id>` named ranges.
21+
- **Phase 1:** 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`.
22+
- **Phase 2:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
23+
24+
## Architecture
25+
26+
A separate server that reuses the patterns proven in `modelrisk-mcp` (xlwings bridge, dry-run-by-default safety on any future writes, packaging, MCP-registry flow). Decision analysis and Monte Carlo are distinct domains, so they ship as distinct servers.
27+
28+
## Development
29+
30+
```powershell
31+
uv sync --extra dev
32+
uv run pytest # parser/roller + store-format tests (no Excel needed)
33+
```
34+
35+
MIT licensed.

pyproject.toml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[project]
2+
name = "modelchoice-mcp"
3+
version = "0.0.1"
4+
description = "An open Model Context Protocol server for Vose Software's ModelChoice decision-tree add-in for Excel."
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
license = { text = "MIT" }
8+
authors = [{ name = "Vose Software" }]
9+
dependencies = [
10+
"mcp>=1.2.0",
11+
"pydantic>=2.0",
12+
"xlwings>=0.30",
13+
]
14+
15+
[project.optional-dependencies]
16+
dev = [
17+
"pytest>=8.0",
18+
"pytest-asyncio>=0.23",
19+
"ruff>=0.6",
20+
"mypy>=1.10",
21+
]
22+
23+
[build-system]
24+
requires = ["hatchling"]
25+
build-backend = "hatchling.build"
26+
27+
[tool.hatch.build.targets.wheel]
28+
packages = ["src/modelchoice_mcp"]
29+
30+
[tool.pytest.ini_options]
31+
testpaths = ["tests"]
32+
asyncio_mode = "auto"
33+
34+
[tool.ruff]
35+
line-length = 100
36+
src = ["src", "tests"]
37+
38+
[tool.ruff.lint]
39+
select = ["E", "F", "I", "UP", "B", "N", "RUF"]
40+
41+
[tool.mypy]
42+
python_version = "3.11"
43+
strict = true
44+
files = ["src"]

src/modelchoice_mcp/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""ModelChoice MCP — an open Model Context Protocol server for Vose
2+
Software's ModelChoice decision-tree add-in for Excel."""
3+
4+
__version__ = "0.0.1"

src/modelchoice_mcp/bridge.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Excel bridge for ModelChoice — read decision trees from a workbook.
2+
3+
Phase 0 is read-only. ModelChoice exposes no COM object model, but the
4+
full tree model is persisted as JSON in the very-hidden ``_MC_Store``
5+
worksheet (see :mod:`modelchoice_mcp.store`). We read that sheet's row-1
6+
content (chunked across columns) via xlwings, parse each tree, and roll
7+
it back in pure Python — so the add-in need not even be loaded.
8+
9+
The bridge deliberately does NOT unhide or modify ``_MC_Store``; it
10+
reads cell values only.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Any
16+
17+
from modelchoice_mcp.store import STORE_SHEET_NAME, parse_store, reassemble_chunks
18+
from modelchoice_mcp.tree import DecisionTree, RollupResult, parse_model, rollup
19+
20+
21+
class ModelChoiceNotFoundError(RuntimeError):
22+
"""The workbook contains no ModelChoice tree store (`_MC_Store`)."""
23+
24+
25+
class ExcelNotRunningError(RuntimeError):
26+
"""No running Excel instance could be attached."""
27+
28+
29+
class ModelChoiceBridge:
30+
"""Attach to a running Excel and read ModelChoice trees from a
31+
workbook's very-hidden ``_MC_Store`` sheet."""
32+
33+
def __init__(self) -> None:
34+
self._xw: Any = None
35+
36+
def _load_xw(self) -> Any:
37+
if self._xw is None:
38+
import xlwings # imported lazily so the package is importable without Excel
39+
40+
self._xw = xlwings
41+
return self._xw
42+
43+
def _book(self, workbook: str | None) -> Any:
44+
xw = self._load_xw()
45+
app = xw.apps.active
46+
if app is None:
47+
raise ExcelNotRunningError(
48+
"No running Excel instance found. Open the workbook in Excel first."
49+
)
50+
if workbook is None:
51+
return app.books.active
52+
for b in app.books:
53+
if b.name == workbook:
54+
return b
55+
raise ModelChoiceNotFoundError(f"Workbook {workbook!r} is not open.")
56+
57+
def read_store_raw(self, workbook: str | None = None) -> str:
58+
"""Return the reassembled ``_MC_Store`` A1 payload, or '' if the
59+
sheet is absent."""
60+
book = self._book(workbook)
61+
try:
62+
sheet = book.sheets[STORE_SHEET_NAME]
63+
except Exception:
64+
return ""
65+
# Read row 1 across enough columns to cover the chunk limit.
66+
row = sheet.range((1, 1), (1, 100)).value
67+
if not isinstance(row, list):
68+
row = [row]
69+
cells = [None if v is None else str(v) for v in row]
70+
return reassemble_chunks(cells)
71+
72+
def list_trees(self, workbook: str | None = None) -> dict[str, str]:
73+
"""Return a mapping of tree-sheet-name → model JSON string."""
74+
raw = self.read_store_raw(workbook)
75+
if not raw:
76+
raise ModelChoiceNotFoundError(
77+
"Workbook has no ModelChoice tree store (_MC_Store sheet)."
78+
)
79+
return parse_store(raw)
80+
81+
def get_tree(self, sheet_name: str | None = None, workbook: str | None = None) -> DecisionTree:
82+
"""Parse one tree (by its sheet name, or the first/only tree)."""
83+
trees = self.list_trees(workbook)
84+
if not trees:
85+
raise ModelChoiceNotFoundError("No trees stored in this workbook.")
86+
if sheet_name is None:
87+
sheet_name = next(iter(trees))
88+
if sheet_name not in trees:
89+
raise ModelChoiceNotFoundError(
90+
f"Tree {sheet_name!r} not found. Available: {', '.join(trees)}."
91+
)
92+
return parse_model(trees[sheet_name])
93+
94+
def roll_up(self, sheet_name: str | None = None, workbook: str | None = None) -> RollupResult:
95+
"""Read a tree and return its rolled-back EV + optimal policy."""
96+
return rollup(self.get_tree(sheet_name, workbook))

src/modelchoice_mcp/store.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Read ModelChoice's per-workbook tree storage.
2+
3+
ModelChoice persists every decision tree as JSON in a *very hidden*
4+
worksheet named ``_MC_Store`` (see ``TreeJsonStore.cs`` in the add-in).
5+
The payload lives in row 1, chunked across columns A1, B1, C1, … when it
6+
exceeds Excel's ~32k-character cell limit. The reassembled string is a
7+
v2 envelope::
8+
9+
{"_v": 2, "trees": {"<sheetName>": "<model-json-string>", ...}}
10+
11+
where each *value* is itself a JSON string (the serialized
12+
``DecisionTreeModel``). A v1 legacy form stored the raw model JSON
13+
directly (detected by a top-level ``RootId``), keyed as ``MC_TreeView``.
14+
15+
This module is pure: it turns the raw cell string(s) into a mapping of
16+
sheet-name → model-JSON-string. The Excel/COM reading lives in the
17+
bridge so this stays unit-testable without Excel.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
24+
STORE_SHEET_NAME = "_MC_Store"
25+
_LEGACY_TREE_KEY = "MC_TreeView"
26+
27+
28+
def parse_store(raw: str) -> dict[str, str]:
29+
"""Parse the reassembled ``_MC_Store`` A1 payload into a mapping of
30+
sheet name → model JSON string. Mirrors ``TreeJsonStore.ParseStorageFormat``.
31+
32+
Returns an empty dict for empty input.
33+
"""
34+
if not raw or not raw.strip():
35+
return {}
36+
37+
try:
38+
root = json.loads(raw)
39+
except json.JSONDecodeError:
40+
# Not valid JSON at all — treat the whole thing as a single v1 model.
41+
return {_LEGACY_TREE_KEY: raw}
42+
43+
if isinstance(root, dict):
44+
# v2 envelope: {"_v": >=2, "trees": {...}}
45+
v = root.get("_v")
46+
trees = root.get("trees")
47+
if isinstance(v, int) and v >= 2 and isinstance(trees, dict):
48+
return {
49+
k: (val if isinstance(val, str) else json.dumps(val))
50+
for k, val in trees.items()
51+
}
52+
53+
# v1: raw model JSON (has a RootId / rootId but no envelope).
54+
if "RootId" in root or "rootId" in root:
55+
return {_LEGACY_TREE_KEY: raw}
56+
57+
# Fallback: opaque content, treat as a single model.
58+
return {_LEGACY_TREE_KEY: raw}
59+
60+
61+
def reassemble_chunks(cells: list[str | None]) -> str:
62+
"""Reassemble the chunked A1, B1, C1, … payload. Stops at the first
63+
empty cell. Mirrors ``TreeJsonStore.ReadChunkedContent``."""
64+
parts: list[str] = []
65+
for c in cells:
66+
if c is None or c == "":
67+
break
68+
parts.append(c)
69+
return "".join(parts)

0 commit comments

Comments
 (0)