Skip to content

Commit 6d1599c

Browse files
vosesoftclaude
andcommitted
Add edit_tree — tweak an existing decision tree by talking (capability gap #4)
The "build it, then change it by talking" path. edit_tree reads a stored tree, applies a list of targeted EditOps — set_probability, set_branch_value, set_terminal_value, rename_node, rename_branch, set_objective — re-serializes, validates by rolling back, and returns the new EV + optimal policy. dry_run-default; dry_run=False writes back to the SAME sheet and re-renders. Frozen-dataclass edits via dataclasses.replace; missing target / unknown op raise clearly. Demo: nudging the geology probability and the sell offer re-rolls the recommendation live (Sell EV 30 -> 50 as 'wet' rises). 38 tests incl. decision-flip, minimize, commit-same-sheet, bad-target. ruff + mypy clean. 0.0.8 (not released). Other capability gaps (run_evii, modelrisk distribution hand-off, more structured analysis tools) are gated on the deployed add-in (named ranges / new commands) or cross-server; deferred behind that. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0a83e04 commit 6d1599c

7 files changed

Lines changed: 202 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): w
1111
| Tool | What it does |
1212
|---|---|
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. |
14+
| `edit_tree` | **Tweak an existing tree** — change probabilities, payoffs, labels, or the objective, then re-roll it (dry-run by default). The "tweak it by talking" path. |
1415
| `list_trees` | List the decision trees in a workbook with node-type counts. |
1516
| `get_tree` | Full structure of one tree — decision / chance / terminal nodes, branches, probabilities, values. |
1617
| `roll_up` | Roll the tree back to its expected values and **optimal policy** — the decision recommendation, in plain English. |

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

src/modelchoice_mcp/schemas.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,30 @@ class TreeSpec(BaseModel):
116116
nodes: list[NodeSpec] = Field(description="All nodes in the tree.")
117117

118118

119+
class EditOp(BaseModel):
120+
"""A single targeted edit to an existing tree. `op` selects the
121+
change; the other fields are the target/new value for that op."""
122+
123+
op: str = Field(
124+
description=(
125+
"One of: 'set_probability', 'set_branch_value', 'set_terminal_value', "
126+
"'rename_node', 'rename_branch', 'set_objective'."
127+
)
128+
)
129+
node_id: str | None = Field(default=None, description="Target node id (most ops).")
130+
branch_name: str | None = Field(
131+
default=None, description="Target branch/option name (branch-level ops)."
132+
)
133+
value: float | None = Field(
134+
default=None,
135+
description="New number — probability, branch cash flow, or terminal payoff.",
136+
)
137+
name: str | None = Field(default=None, description="New label for a rename op.")
138+
maximize: bool | None = Field(
139+
default=None, description="New objective for 'set_objective' (true=maximize)."
140+
)
141+
142+
119143
class BuildTreeResult(BaseModel):
120144
"""Outcome of building a tree from a spec."""
121145

src/modelchoice_mcp/tools.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import dataclasses
1011
from typing import Annotated, Any
1112

1213
from pydantic import Field
@@ -16,6 +17,7 @@
1617
AnalysisRun,
1718
BranchView,
1819
BuildTreeResult,
20+
EditOp,
1921
EvpiResult,
2022
KeyValue,
2123
NodeDiff,
@@ -149,6 +151,122 @@ def build_tree(
149151
)
150152

151153

154+
def _apply_edits(tree: DecisionTree, edits: list[EditOp]) -> DecisionTree:
155+
"""Return a new tree with the edits applied. Frozen dataclasses are
156+
rebuilt via dataclasses.replace; unknown ops or missing targets raise
157+
TreeParseError."""
158+
nodes = dict(tree.nodes)
159+
maximize = tree.maximize
160+
model_name = tree.model_name
161+
162+
for e in edits:
163+
op = e.op.lower()
164+
if op == "set_objective":
165+
if e.maximize is None:
166+
raise TreeParseError("set_objective needs `maximize`.")
167+
maximize = e.maximize
168+
continue
169+
170+
if not e.node_id or e.node_id not in nodes:
171+
raise TreeParseError(f"edit {op!r}: no node {e.node_id!r}.")
172+
node = nodes[e.node_id]
173+
174+
if op == "rename_node":
175+
if not e.name:
176+
raise TreeParseError("rename_node needs `name`.")
177+
nodes[e.node_id] = dataclasses.replace(node, name=e.name)
178+
elif op == "set_terminal_value":
179+
if node.kind != "terminal":
180+
raise TreeParseError(f"{e.node_id!r} is not a terminal node.")
181+
if e.value is None:
182+
raise TreeParseError("set_terminal_value needs `value`.")
183+
nodes[e.node_id] = dataclasses.replace(node, value=e.value)
184+
elif op in ("set_probability", "set_branch_value", "rename_branch"):
185+
found = False
186+
new_branches = []
187+
for b in node.branches:
188+
if b.name == e.branch_name:
189+
found = True
190+
if op == "set_probability":
191+
b = dataclasses.replace(b, probability=e.value)
192+
elif op == "set_branch_value":
193+
b = dataclasses.replace(b, value=e.value or 0.0)
194+
else: # rename_branch
195+
if not e.name:
196+
raise TreeParseError("rename_branch needs `name`.")
197+
b = dataclasses.replace(b, name=e.name)
198+
new_branches.append(b)
199+
if not found:
200+
raise TreeParseError(
201+
f"node {e.node_id!r} has no branch named {e.branch_name!r}."
202+
)
203+
nodes[e.node_id] = dataclasses.replace(node, branches=new_branches)
204+
else:
205+
raise TreeParseError(f"unknown edit op {e.op!r}.")
206+
207+
return dataclasses.replace(
208+
tree, nodes=nodes, maximize=maximize, model_name=model_name
209+
)
210+
211+
212+
@mcp.tool(
213+
description=(
214+
"ModelChoice: Edit an existing decision tree in place — change "
215+
"probabilities, branch cash flows, terminal payoffs, node/branch "
216+
"labels, or the maximize/minimize objective — then re-roll it. Pass "
217+
"`edits` as a list of operations ('set_probability', 'set_branch_value', "
218+
"'set_terminal_value', 'rename_node', 'rename_branch', 'set_objective'). "
219+
"Reads the named tree, applies the edits, validates by rolling back, and "
220+
"returns the new EV + optimal policy. dry_run=True (default) previews; "
221+
"dry_run=False writes and re-renders. The 'tweak it by talking' path."
222+
)
223+
)
224+
def edit_tree(
225+
edits: list[EditOp],
226+
tree_name: Annotated[
227+
str | None, Field(description="Tree sheet name. Omit for the first tree.")
228+
] = None,
229+
dry_run: bool = True,
230+
workbook_name: str | None = None,
231+
) -> BuildTreeResult:
232+
bridge = get_bridge()
233+
trees = bridge.list_trees(workbook_name)
234+
if tree_name is None:
235+
tree_name = next(iter(trees))
236+
if tree_name not in trees:
237+
raise TreeParseError(f"no tree {tree_name!r}; available: {', '.join(trees)}.")
238+
239+
edited = _apply_edits(parse_model(trees[tree_name]), edits)
240+
model_json = to_model_json(edited)
241+
parsed = parse_model(model_json)
242+
r = rollup(parsed)
243+
244+
direction = "maximize" if edited.maximize else "minimize"
245+
if r.optimal_path:
246+
rec = (
247+
f"After edits, optimal decision ({direction} EV): take "
248+
f"{' → '.join(r.optimal_path)}. Expected value {r.expected_value:,.2f}."
249+
)
250+
else:
251+
rec = f"After edits, expected value {r.expected_value:,.2f} ({direction})."
252+
253+
written = False
254+
if not dry_run:
255+
bridge.write_tree(model_json, sheet_name=tree_name, workbook=workbook_name)
256+
bridge.render_tree(tree_name, workbook_name)
257+
written = True
258+
259+
return BuildTreeResult(
260+
written=written,
261+
sheet=tree_name if written else None,
262+
node_count=len(parsed.nodes),
263+
expected_value=r.expected_value,
264+
optimal_path=r.optimal_path,
265+
recommendation=rec,
266+
model_json=model_json,
267+
)
268+
269+
152270
def _counts(tree: DecisionTree) -> tuple[int, int, int]:
153271
d = sum(1 for n in tree.nodes.values() if n.kind == "decision")
154272
c = sum(1 for n in tree.nodes.values() if n.kind == "chance")
@@ -559,6 +677,7 @@ def read_sheet(
559677

560678
__all__ = [
561679
"build_tree",
680+
"edit_tree",
562681
"get_bridge",
563682
"get_tree",
564683
"list_trees",

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

1212

1313
def test_server_name() -> None:

tests/test_tools.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from modelchoice_mcp.schemas import (
1313
BranchSpec,
1414
BuildTreeResult,
15+
EditOp,
1516
EvpiResult,
1617
NodeSpec,
1718
RollbackVerification,
@@ -264,6 +265,60 @@ def test_build_tree_commit_writes_and_renders() -> None:
264265
tools.set_bridge_for_testing(None)
265266

266267

268+
def test_edit_tree_changes_payoff_and_flips_decision() -> None:
269+
fake = _FakeBridge({"MC_Tree_1": _MODEL})
270+
tools.set_bridge_for_testing(fake) # type: ignore[arg-type]
271+
try:
272+
# Drop the 'Sell' payoff below the chance EV (-25) so Drill wins.
273+
out = tools.edit_tree(
274+
[EditOp(op="set_branch_value", node_id="D", branch_name="Sell", value=-30)]
275+
)
276+
assert isinstance(out, BuildTreeResult)
277+
assert out.written is False
278+
assert out.optimal_path == ["Drill"]
279+
assert out.expected_value == -25.0
280+
finally:
281+
tools.set_bridge_for_testing(None)
282+
283+
284+
def test_edit_tree_set_objective_minimize() -> None:
285+
fake = _FakeBridge({"MC_Tree_1": _MODEL})
286+
tools.set_bridge_for_testing(fake) # type: ignore[arg-type]
287+
try:
288+
out = tools.edit_tree([EditOp(op="set_objective", maximize=False)])
289+
# Minimizing prefers the chance node (EV -25) over Sell (50).
290+
assert out.expected_value == -25.0 and out.optimal_path == ["Drill"]
291+
finally:
292+
tools.set_bridge_for_testing(None)
293+
294+
295+
def test_edit_tree_commit_writes_same_sheet() -> None:
296+
fake = _FakeBridge({"MC_Tree_1": _MODEL})
297+
tools.set_bridge_for_testing(fake) # type: ignore[arg-type]
298+
try:
299+
out = tools.edit_tree(
300+
[EditOp(op="set_probability", node_id="C", branch_name="Wet", value=0.8)],
301+
dry_run=False,
302+
)
303+
assert out.written is True and out.sheet == "MC_Tree_1"
304+
assert fake.rendered == "MC_Tree_1"
305+
# The edited probability is reflected in the written JSON.
306+
assert '"probability": 0.8' in fake.written_json
307+
finally:
308+
tools.set_bridge_for_testing(None)
309+
310+
311+
def test_edit_tree_bad_target_raises() -> None:
312+
from modelchoice_mcp.tree import TreeParseError
313+
314+
tools.set_bridge_for_testing(_FakeBridge({"MC_Tree_1": _MODEL})) # type: ignore[arg-type]
315+
try:
316+
with pytest.raises(TreeParseError):
317+
tools.edit_tree([EditOp(op="set_terminal_value", node_id="NOPE", value=1)])
318+
finally:
319+
tools.set_bridge_for_testing(None)
320+
321+
267322
def test_run_sensitivity(bridge: _FakeBridge) -> None:
268323
out = tools.run_sensitivity()
269324
assert bridge.last_command == "MC_SensitivityAnalysis_Auto"

0 commit comments

Comments
 (0)