Skip to content

Commit 8b43a25

Browse files
vosesoftclaude
andcommitted
Add set_input_distribution (0.0.23) — gap #9b: assign uncertainties to tree inputs
Put a ModelRisk Vose* distribution on a tree input (branch cash flow or probability), the way the ModelChoice UI lets you type a distribution into a cell. This is the decision-tree half of a Monte Carlo: once inputs are distributions, run the simulation with modelrisk-mcp (it samples these cells and collects the tree's output distribution). Implemented purely as an _MC_Store edit + re-render — the distribution is stored as the input cell's UserFormula and applied by the existing MC_RenderStoredTree, so no new add-in command is needed and it ships via PyPI alone. Branch values (MC_BV_) and probabilities (MC_BP_) only. - bridge.set_input_formula: edit UserFormulas in the stored model, write back, re-render so the Vose formula lands in the cell. - tools.set_input_distribution: resolve node/branch -> named range, normalize the formula, drive the bridge; validates kind and chance-only probability. - schemas.InputDistributionResult; 5 unit tests; CHANGELOG/README; 22 tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9fbd844 commit 8b43a25

9 files changed

Lines changed: 202 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
All notable changes to `modelchoice-mcp`. Versions are tag-driven; pushing a
44
`vX.Y.Z` tag publishes to PyPI via the release workflow.
55

6+
## 0.0.23
7+
- **`set_input_distribution`** — assign an uncertainty (a ModelRisk `Vose*`
8+
distribution) to a tree input (branch cash flow or probability), the way the
9+
UI lets you type a distribution into a cell. Stored as the input's
10+
user-formula and re-rendered, so it persists. This is the decision-tree half
11+
of a Monte Carlo: once inputs are distributions, run the simulation with
12+
**modelrisk-mcp** (it samples these cells and collects the tree's output
13+
distribution). Pure `_MC_Store` edit + re-render — no new add-in command.
14+
615
## 0.0.22
716
- **`build_mcda`** — build a multi-criteria (MCDA) model: tree + criteria
817
(ordinal options, weights, direction) + aggregation + per-terminal scores.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
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.
66

7-
> **Status: `0.0.22` — Phase 3 (build + drive).** 21 tools: build_tree / build_mcda / edit_tree (incl. add/remove options & outcomes) / build_control_panel / export_tree_json / import_tree_json / import_precisiontree, read + roll + verify, run_scenarios (what-if comparison), plus run_evpi / run_evii / run_risk_profile / run_robustness / run_sensitivity / run_decision_report (strategy/policy/brief/mcda/force-to-outcome/two-way) / run_analysis / read_sheet over ModelChoice's headless commands.
7+
> **Status: `0.0.23` — Phase 3 (build + drive).** 22 tools: build_tree / build_mcda / edit_tree (incl. add/remove options & outcomes) / set_input_distribution (put a Vose distribution on an input) / build_control_panel / export_tree_json / import_tree_json / import_precisiontree, read + roll + verify, run_scenarios (what-if comparison), plus run_evpi / run_evii / run_risk_profile / run_robustness / run_sensitivity / run_decision_report (strategy/policy/brief/mcda/force-to-outcome/two-way) / run_analysis / read_sheet over ModelChoice's headless commands.
88
99
## Tools
1010

@@ -13,6 +13,7 @@ A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): w
1313
| `build_tree` | **Build a tree from a structured description** and write it into Excel (dry-run by default). Validates + rolls it back so you confirm the recommendation before writing. The build-from-a-prompt path. |
1414
| `build_mcda` | **Build a multi-criteria (MCDA) model** — for choices that aren't pure money. Give the tree + criteria (ordinal options, weights, direction), aggregation, and each terminal's option per criterion; the add-in computes the composite scores. Drives `MC_ApplyMcda_Auto`. |
1515
| `edit_tree` | **Tweak an existing tree** — change probabilities, payoffs, labels, or the objective, **add or remove whole options/outcomes** (`add_option` / `add_branch` / `remove_branch`), then re-roll it (dry-run by default). The "tweak it by talking" path. |
16+
| `set_input_distribution` | **Make a tree input uncertain** — put a ModelRisk `Vose*` distribution on a branch's cash flow or probability (like typing a distribution into the cell in the UI). The decision-tree half of a Monte Carlo: once inputs are distributions, run the simulation with **modelrisk-mcp** to get the tree's outcome distribution. Pure store-edit + re-render — no add-in command. |
1617
| `build_control_panel` | **Lift the tree's inputs into a control panel** at the top of the sheet — every probability and cash flow as a labelled cell, with the tree linked back to it, so you drive the whole model from one input block. Drives `MC_BuildControlPanel_Auto`. |
1718
| `export_tree_json` / `import_tree_json` | Round-trip a tree's raw ModelChoice JSON — export to save/share/version, import (validated) to write it back into a workbook. |
1819
| `import_precisiontree` | Import a PrecisionTree workbook (.xls/.xlsx) into ModelChoice — converts a copy (original untouched). Drives `MC_ImportPrecisionTree_Auto`. |

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.22"
3+
version = "0.0.23"
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.22"
4+
__version__ = "0.0.23"

src/modelchoice_mcp/bridge.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,42 @@ def render_tree(self, sheet_name: str, workbook: str | None = None) -> None:
500500
"loaded? (The tree JSON was stored regardless.)"
501501
) from exc
502502

503+
def set_input_formula(
504+
self,
505+
named_range: str,
506+
formula: str,
507+
sheet_name: str | None = None,
508+
workbook: str | None = None,
509+
) -> str:
510+
"""Assign a cell formula (e.g. a ``=VoseNormal(...)`` distribution) to a
511+
tree input named range, the way the UI lets you type a distribution into
512+
a branch value/probability cell. Records it in the stored model's
513+
``UserFormulas`` (so it survives re-renders) and re-renders the tree so
514+
the formula lands in the cell. Returns the tree sheet name.
515+
516+
Pure ``_MC_Store`` edit + render — no special add-in command beyond the
517+
existing MC_RenderStoredTree. ModelRisk (via modelrisk-mcp) then samples
518+
the formula during simulation."""
519+
raw = self.read_store_raw(workbook)
520+
if not raw:
521+
raise ModelChoiceNotFoundError("Workbook has no ModelChoice tree store.")
522+
trees = parse_store(raw)
523+
if sheet_name is None:
524+
sheet_name = next(iter(trees))
525+
if sheet_name not in trees:
526+
raise ModelChoiceNotFoundError(
527+
f"Tree {sheet_name!r} not found. Available: {', '.join(trees)}."
528+
)
529+
model = json.loads(trees[sheet_name])
530+
uf = model.get("UserFormulas")
531+
if not isinstance(uf, dict):
532+
uf = {}
533+
uf[named_range] = formula
534+
model["UserFormulas"] = uf
535+
self.write_tree(json.dumps(model), sheet_name=sheet_name, workbook=workbook)
536+
self.render_tree(sheet_name, workbook)
537+
return sheet_name
538+
503539
def read_sheet(
504540
self,
505541
sheet_name: str,

src/modelchoice_mcp/schemas.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,24 @@ class McdaBuildResult(BaseModel):
253253
note: str
254254

255255

256+
class InputDistributionResult(BaseModel):
257+
"""Outcome of assigning an uncertainty (a Vose* distribution) to a tree
258+
input — a branch value or probability — the way the ModelChoice UI lets
259+
you type a distribution into a cell. The distribution is stored as the
260+
cell's user-formula and re-rendered, ready for ModelRisk (via
261+
modelrisk-mcp) to sample during a Monte Carlo simulation."""
262+
263+
tree: str = Field(description="Tree sheet the input belongs to.")
264+
node_id: str = Field(description="Node whose branch/option the distribution was put on.")
265+
outcome: str = Field(description="Branch/option label the distribution was assigned to.")
266+
kind: str = Field(description="'value' (branch cash flow) or 'probability'.")
267+
named_range: str = Field(
268+
description="The MC_BV_/MC_BP_ named range now holding the distribution formula."
269+
)
270+
formula: str = Field(description="The cell formula assigned (e.g. '=VoseNormal(100,20)').")
271+
note: str
272+
273+
256274
class TreeExport(BaseModel):
257275
"""A tree's stored ModelChoice model JSON, for saving or sharing."""
258276

src/modelchoice_mcp/tools.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
EviiResult,
2525
EvpiResult,
2626
ImportResult,
27+
InputDistributionResult,
2728
KeyValue,
2829
McdaBuildResult,
2930
McdaSpec,
@@ -1031,6 +1032,95 @@ def build_control_panel(
10311032
)
10321033

10331034

1035+
@mcp.tool(
1036+
description=(
1037+
"ModelChoice: Assign an UNCERTAINTY to a tree input — put a ModelRisk "
1038+
"`Vose*` distribution on a branch's cash flow or probability, the way the "
1039+
"ModelChoice UI lets you type a distribution into an input cell. Pass the "
1040+
"node id, the branch/option label, the distribution formula (e.g. "
1041+
"'VoseNormal(100,20)', 'VosePERT(80,100,150)'), and kind='value' (cash "
1042+
"flow) or 'probability'. The distribution is stored as the cell's "
1043+
"user-formula and re-rendered, so it survives edits. This is the "
1044+
"decision-tree half of a Monte Carlo: once inputs are distributions, run "
1045+
"the simulation with modelrisk-mcp (it samples these cells and collects "
1046+
"the tree's output distribution). Requires the add-in loaded with a tree "
1047+
"rendered. Branch values / probabilities only (not terminal payoffs)."
1048+
)
1049+
)
1050+
def set_input_distribution(
1051+
node_id: Annotated[str, Field(description="Node whose branch/option carries the input.")],
1052+
outcome: Annotated[
1053+
str, Field(description="The branch/option label to put the distribution on.")
1054+
],
1055+
distribution: Annotated[
1056+
str,
1057+
Field(
1058+
description=(
1059+
"The ModelRisk distribution formula, e.g. 'VoseNormal(100,20)' or "
1060+
"'=VosePERT(80,100,150)'. A leading '=' is optional."
1061+
)
1062+
),
1063+
],
1064+
kind: Annotated[
1065+
str, Field(description="'value' (branch cash flow) or 'probability'.")
1066+
] = "value",
1067+
tree_name: Annotated[
1068+
str | None, Field(description="Tree sheet name. Omit for the first tree.")
1069+
] = None,
1070+
workbook_name: str | None = None,
1071+
) -> InputDistributionResult:
1072+
k = kind.lower()
1073+
if k not in ("value", "probability"):
1074+
raise ValueError(f"kind must be 'value' or 'probability', got {kind!r}.")
1075+
1076+
bridge = get_bridge()
1077+
trees = bridge.list_trees(workbook_name)
1078+
if tree_name is None:
1079+
tree_name = next(iter(trees))
1080+
if tree_name not in trees:
1081+
raise TreeParseError(f"no tree {tree_name!r}; available: {', '.join(trees)}.")
1082+
1083+
tree = parse_model(trees[tree_name])
1084+
node = tree.nodes.get(node_id)
1085+
if node is None:
1086+
raise ValueError(f"node {node_id!r} not found in {tree_name!r}.")
1087+
branch = next((b for b in node.branches if b.name == outcome), None)
1088+
if branch is None:
1089+
names = ", ".join(b.name for b in node.branches) or "(none)"
1090+
raise ValueError(
1091+
f"node {node_id!r} ({node.name!r}) has no branch {outcome!r}. "
1092+
f"Branches: {names}."
1093+
)
1094+
if k == "probability" and node.kind != "chance":
1095+
raise ValueError(
1096+
f"kind='probability' is only valid on a chance node; {node_id!r} is a "
1097+
f"{node.kind} node. Use kind='value' for a decision option's cash flow."
1098+
)
1099+
1100+
prefix = "MC_BV_" if k == "value" else "MC_BP_"
1101+
named_range = f"{prefix}{node_id}_{branch.child_id}"
1102+
formula = distribution.strip()
1103+
if not formula.startswith("="):
1104+
formula = "=" + formula
1105+
1106+
sheet = bridge.set_input_formula(named_range, formula, tree_name, workbook_name)
1107+
1108+
return InputDistributionResult(
1109+
tree=sheet,
1110+
node_id=node_id,
1111+
outcome=outcome,
1112+
kind=k,
1113+
named_range=named_range,
1114+
formula=formula,
1115+
note=(
1116+
f"Put {formula} on {node.name!r}{outcome!r} ({named_range}). It's now "
1117+
"an uncertain input. Run the Monte Carlo with modelrisk-mcp's "
1118+
"run_simulation (track the root EV cell as the output) to get the "
1119+
"tree's outcome distribution."
1120+
),
1121+
)
1122+
1123+
10341124
@mcp.tool(
10351125
description=(
10361126
"ModelChoice: Run a decision-analysis on the active tree and report the "
@@ -1429,5 +1519,6 @@ def read_sheet(
14291519
"run_sensitivity",
14301520
"run_utility",
14311521
"set_bridge_for_testing",
1522+
"set_input_distribution",
14321523
"verify_rollback",
14331524
]

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.22"
10+
assert __version__ == "0.0.23"
1111

1212

1313
def test_server_name() -> None:

tests/test_tools.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ def build_control_panel(self, sheet_name: str | None = None,
135135
]
136136
return {"sheet": sheet_name or "MC_Tree_1", "rows": rows}
137137

138+
def set_input_formula(self, named_range: str, formula: str,
139+
sheet_name: str | None = None,
140+
workbook: str | None = None) -> str:
141+
self.input_formula = (named_range, formula)
142+
return sheet_name or "MC_Tree_1"
143+
138144
def write_tree(self, model_json: str, sheet_name: str | None = None,
139145
workbook: str | None = None) -> str:
140146
self.written_json = model_json
@@ -276,6 +282,43 @@ def test_build_control_panel_parses_inputs(bridge: _FakeBridge) -> None:
276282
assert "persist across re-renders" in out.note
277283

278284

285+
def test_set_input_distribution_value(bridge: _FakeBridge) -> None:
286+
from modelchoice_mcp.schemas import InputDistributionResult
287+
288+
out = tools.set_input_distribution("C", "Wet", "VoseNormal(50,10)", kind="value")
289+
assert isinstance(out, InputDistributionResult)
290+
# Wet -> child T2, value input => MC_BV_C_T2; leading '=' added.
291+
assert out.named_range == "MC_BV_C_T2"
292+
assert out.formula == "=VoseNormal(50,10)"
293+
assert bridge.input_formula == ("MC_BV_C_T2", "=VoseNormal(50,10)")
294+
assert out.tree == "MC_Tree_1"
295+
assert "modelrisk-mcp" in out.note
296+
297+
298+
def test_set_input_distribution_probability(bridge: _FakeBridge) -> None:
299+
out = tools.set_input_distribution(
300+
"C", "Dry", "=VosePERT(0.3,0.5,0.7)", kind="probability"
301+
)
302+
# Dry -> child T1, probability input => MC_BP_C_T1; existing '=' kept.
303+
assert out.named_range == "MC_BP_C_T1"
304+
assert out.formula == "=VosePERT(0.3,0.5,0.7)"
305+
306+
307+
def test_set_input_distribution_rejects_probability_on_decision(bridge: _FakeBridge) -> None:
308+
with pytest.raises(ValueError, match="chance node"):
309+
tools.set_input_distribution("D", "Drill", "VoseUniform(0,1)", kind="probability")
310+
311+
312+
def test_set_input_distribution_unknown_branch(bridge: _FakeBridge) -> None:
313+
with pytest.raises(ValueError, match="no branch"):
314+
tools.set_input_distribution("C", "Nope", "VoseNormal(0,1)")
315+
316+
317+
def test_set_input_distribution_bad_kind(bridge: _FakeBridge) -> None:
318+
with pytest.raises(ValueError, match=r"value.*probability"):
319+
tools.set_input_distribution("C", "Wet", "VoseNormal(0,1)", kind="bogus")
320+
321+
279322
def test_run_utility(bridge: _FakeBridge) -> None:
280323
from modelchoice_mcp.schemas import UtilityResult
281324

0 commit comments

Comments
 (0)