Skip to content

Commit b2870e2

Browse files
vosesoftclaude
andcommitted
Release v0.3.0-alpha.31: expression-based VoseOutput names
Bug #32, surfaced by Vose's own Inputs Outputs.xlsx sample which declares its output as VoseOutput("Total net revenue from "&B8&" to "&B23,"$k"). The name is an Excel expression — the runtime value (e.g. "Total net revenue from 2020 to 2027") only exists after Excel evaluates the formula. Our name_parser was treating the leading literal as a full literal name. The bridge then: - Passed the partial prefix to the XLL — never matched the cell - Post-condition looked it up in the .vmrs — never found it - Raised SimulationFailedError claiming post-phase crashed when in fact the only issue was name resolution Fix: - New ExpressionName type for first-args that prove to be expressions (literal followed by &, +, etc. rather than , or )) - Parser detects via what follows the closing quote - _resolve_vose_name returns the partial prefix with a … ellipsis marker so list_modelrisk_outputs still surfaces the cell — just signals "dynamic name" instead of dropping it - Post-condition verification filters out …-marked names rather than false-positively failing Doesn't make the dynamic-name output actually register in the .vmrs — that's a deeper investigation requiring expression evaluation. This release is the honesty improvement: don't claim failure when the sim ran fine. 408 tests pass (+4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cd5b17b commit b2870e2

9 files changed

Lines changed: 153 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@ All notable changes to ModelRisk MCP. Follows [Keep a Changelog](https://keepach
44

55
## [Unreleased]
66

7+
## [0.3.0-alpha.31] — 2026-05-22
8+
9+
### Fixed
10+
11+
- **Bug #32 — expression-based VoseInput/VoseOutput names false-positive-failed post-condition verification.** Vose's own `Inputs Outputs.xlsx` sample declares its output as `VoseOutput("Total net revenue from "&B8&" to "&B23,"$k")` — the name is an Excel expression, not a static literal. The runtime-evaluated name (e.g. `"Total net revenue from 2020 to 2027"`) is only knowable after Excel computes the formula at simulation time. Our `name_parser` was returning the literal prefix as a `LiteralName` (since it stopped at the closing quote), so:
12+
- The bridge's `expected_output_names` contained the partial prefix.
13+
- `run_simulation` passed it to the XLL, which couldn't match it against the actual VoseOutput cell.
14+
- Post-condition verification looked it up in the produced .vmrs, didn't find it, and raised `SimulationFailedError` — claiming the sim's post-phase had crashed when in fact the only issue was the name-resolution mismatch.
15+
16+
Fix:
17+
- New `ExpressionName` type in `name_parser.py` for first-args that turn out to be expressions (literal followed by `&`, `+`, etc., rather than a closing `,` or `)`).
18+
- The parser detects this by checking what follows the closing quote.
19+
- `_resolve_vose_name` returns the partial prefix marked with a `` ellipsis so `list_modelrisk_outputs` still surfaces the cell with an informational name (`"Total net revenue from …"`) instead of dropping it.
20+
- Post-condition verification filters out ``-marked names — we can't statically verify them, so we don't try, rather than failing loudly.
21+
22+
### Why this matters
23+
24+
Workbooks that build output names from cell content are a real pattern (year-range labels, scenario-specific outputs, anything dynamic). Before alpha.31 every one of those workbooks looked broken to the bridge. The deeper fix — actually evaluating the Excel expression to get the runtime name and registering THAT with the XLL — is a separate larger investigation; this release is the honesty improvement: don't claim failure when the sim ran fine.
25+
26+
### Tests
27+
28+
408 unit tests pass (+4 in `test_name_parser.py::TestExpressionForm` covering the Vose-sample literal-concat-cellref case, simple `"prefix"&A1`, two-arg literal-with-units, and whitespace tolerance around the closing quote).
29+
730
## [0.3.0-alpha.30] — 2026-05-22
831

932
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "modelrisk-mcp"
3-
version = "0.3.0a30"
3+
version = "0.3.0a31"
44
description = "Open MCP server bridging Anthropic Claude (and any MCP-compatible client) with the ModelRisk Excel add-in."
55
readme = "README.md"
66
requires-python = ">=3.11"

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"name": "io.github.vosesoftware/modelrisk-mcp",
44
"title": "ModelRisk",
55
"description": "Read, build, fit, and run Monte Carlo risk models in Excel through Vose Software's ModelRisk.",
6-
"version": "0.3.0a30",
6+
"version": "0.3.0a31",
77
"packages": [
88
{
99
"registryType": "pypi",
1010
"identifier": "modelrisk-mcp",
11-
"version": "0.3.0a30",
11+
"version": "0.3.0a31",
1212
"transport": {
1313
"type": "stdio"
1414
}

src/modelrisk_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.0a30"
1+
__version__ = "0.3.0a31"

src/modelrisk_mcp/bridge/modelrisk.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from modelrisk_mcp.bridge.excel import ExcelBridge
2525
from modelrisk_mcp.bridge.mrservice import MrServiceBridge
2626
from modelrisk_mcp.bridge.name_parser import (
27+
ExpressionName,
2728
LiteralName,
2829
extract_vose_first_arg,
2930
)
@@ -175,17 +176,22 @@ def run_simulation(
175176
# Pin the produced file so the existing reader tools find it.
176177
self._results.set_active_vmrs(result.vmrs_path)
177178

178-
# Post-condition verification — see _verify_simulation_post_conditions
179-
# for the full criteria. If the .vmrs doesn't contain any of the
180-
# expected output names, raise. On raise, attempt to restore the
181-
# workbook to deterministic state (bug #21) so the user isn't
182-
# left with VoseOutput cells stuck on sample values. The restore
183-
# is best-effort; if it fails too, the original SimulationFailed
184-
# propagates regardless.
185-
if expected_output_names:
179+
# Post-condition verification. Only check names we can
180+
# statically resolve — bug #32 (alpha.31): ExpressionName
181+
# outputs like `VoseOutput("prefix "&B8&" suffix")` have
182+
# their actual runtime name computed by Excel at simulation
183+
# time, so the scanner's partial prefix can't be looked up
184+
# in the .vmrs. Including those in the check would
185+
# false-positively claim every such workbook failed.
186+
verifiable_names = [
187+
n for n in expected_output_names
188+
if n and not n.endswith("…") # "…" marker for dynamic
189+
and n != "<dynamic name>"
190+
]
191+
if verifiable_names:
186192
try:
187193
self._verify_simulation_post_conditions(
188-
result.vmrs_path, expected_output_names,
194+
result.vmrs_path, verifiable_names,
189195
)
190196
except SimulationFailedError:
191197
try:
@@ -323,21 +329,29 @@ def _resolve_vose_name(
323329
) -> str | None:
324330
"""Extract and resolve a Vose wrapper's name argument.
325331
326-
Handles both forms:
327-
- `VoseInput("WidgetCost")` → returns "WidgetCost"
328-
- `VoseInput(A5)` → reads A5 and returns its value
329-
- `VoseInput(Sheet2!A5)` → reads Sheet2!A5 and returns its value
332+
Handles all three forms:
333+
- `VoseInput("WidgetCost")` → "WidgetCost"
334+
- `VoseInput(A5)` → reads A5
335+
- `VoseInput("prefix "&B8)` → returns the static
336+
prefix (post-alpha.31 ExpressionName). The runtime-
337+
computed name is unknown but at least the user sees
338+
"Total net revenue from " in `list_outputs` instead of
339+
the cell disappearing entirely.
330340
331341
Returns None if the wrapper isn't present, the form is
332342
unrecognized, or the referenced cell is empty / unreadable.
333-
Conservative on purpose — we'd rather skip a cell than
334-
confidently mis-attribute a name.
335343
"""
336344
arg = extract_vose_first_arg(formula, wrapper)
337345
if arg is None:
338346
return None
339347
if isinstance(arg, LiteralName):
340348
return _unescape_excel_string(arg.name)
349+
if isinstance(arg, ExpressionName):
350+
# Show the partial prefix so the user knows the cell exists
351+
# and roughly what it's called. Marker syntax `prefix…`
352+
# makes it obvious this is a dynamic name, not a literal.
353+
prefix = _unescape_excel_string(arg.static_prefix).rstrip()
354+
return f"{prefix}…" if prefix else "<dynamic name>"
341355
# CellRefName — read the target cell to get the actual name.
342356
target_sheet = arg.sheet or same_sheet
343357
try:

src/modelrisk_mcp/bridge/name_parser.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,31 @@ class CellRefName:
5555
cell: str # A1-style, e.g. "A5" or "AB12"
5656

5757

58+
@dataclass(frozen=True)
59+
class ExpressionName:
60+
"""The wrapper's first arg was an Excel expression (e.g.
61+
`"prefix"&B8&" suffix"` or `CONCAT(B8, B23)` or any arithmetic).
62+
The runtime-evaluated name is only knowable after Excel computes
63+
the formula — typically only ModelRisk's XLL sees the final
64+
string.
65+
66+
`static_prefix` is whatever literal-string portion we could
67+
extract from the start of the expression. Useful for display
68+
("we see the name starts with X"), but MUST NOT be used as the
69+
canonical name when comparing against the simulation's .vmrs
70+
output registry — the actual name will be longer or different.
71+
72+
Bug #32 (alpha.31): without this type the parser silently
73+
returned a partial-LiteralName for expression-based wrappers
74+
like Vose's own sample `VoseOutput("Total net revenue from "
75+
&B8&" to "&B23,"$k")`. The bridge then asked MRService to look
76+
up the partial name, which never matched the runtime-evaluated
77+
name, and `run_simulation`'s post-condition verification
78+
false-positively claimed the sim failed."""
79+
80+
static_prefix: str # informational only; not a usable lookup key
81+
82+
5883
# Matches a cell reference at the start of a string. Captures:
5984
# group "sheet" — sheet name (without quotes), optional
6085
# group "col" — column letters
@@ -81,7 +106,7 @@ class CellRefName:
81106

82107
def extract_vose_first_arg(
83108
formula: str, wrapper: str,
84-
) -> LiteralName | CellRefName | None:
109+
) -> LiteralName | CellRefName | ExpressionName | None:
85110
"""Extract and classify the first argument of a Vose wrapper call.
86111
87112
`wrapper` is the function name without the opening paren, e.g.
@@ -103,7 +128,7 @@ def extract_vose_first_arg(
103128
if start >= len(formula):
104129
return None
105130

106-
# Case 1: string literal
131+
# Case 1: string literal (possibly the start of an expression).
107132
if formula[start] == '"':
108133
i = start + 1
109134
buf: list[str] = []
@@ -115,8 +140,20 @@ def extract_vose_first_arg(
115140
buf.append('"')
116141
i += 2
117142
continue
118-
# End of string literal
119-
return LiteralName(name="".join(buf))
143+
# End of string literal at position i. Now check what
144+
# follows: a `,` or `)` (possibly after whitespace)
145+
# means the literal IS the whole first argument. A
146+
# `&`, `+`, or any other operator means the literal
147+
# was just the start of an expression — bug #32
148+
# surfaced by Vose's own `Inputs Outputs.xlsx` sample
149+
# which uses `VoseOutput("prefix "&B8&" suffix",...)`.
150+
j = i + 1
151+
while j < len(formula) and formula[j] in " \t":
152+
j += 1
153+
if j < len(formula) and formula[j] in ",)":
154+
return LiteralName(name="".join(buf))
155+
# Expression — return the partial prefix as informational.
156+
return ExpressionName(static_prefix="".join(buf))
120157
buf.append(ch)
121158
i += 1
122159
return None # unterminated string literal
@@ -154,4 +191,9 @@ def extract_vose_first_arg(
154191
return None
155192

156193

157-
__all__ = ["CellRefName", "LiteralName", "extract_vose_first_arg"]
194+
__all__ = [
195+
"CellRefName",
196+
"ExpressionName",
197+
"LiteralName",
198+
"extract_vose_first_arg",
199+
]

tests/unit/test_name_parser.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from modelrisk_mcp.bridge.name_parser import (
1515
CellRefName,
16+
ExpressionName,
1617
LiteralName,
1718
extract_vose_first_arg,
1819
)
@@ -178,4 +179,51 @@ def test_wrapper_inside_other_function(self) -> None:
178179
assert result.name == "X"
179180

180181

182+
# ----------------------------------------------------------------------
183+
# Expression form — bug #32, surfaced by Vose's own Inputs Outputs sample
184+
# ----------------------------------------------------------------------
185+
186+
187+
class TestExpressionForm:
188+
"""Vose's `Inputs Outputs.xlsx` has outputs declared as expressions:
189+
`VoseOutput("Total net revenue from "&B8&" to "&B23,"$k")`. The
190+
parser must recognise these as expression-form (not literal) so
191+
downstream code knows not to use the partial prefix as a lookup
192+
key against the .vmrs's runtime-evaluated name."""
193+
194+
def test_literal_concat_cellref_is_expression(self) -> None:
195+
result = extract_vose_first_arg(
196+
'=VoseOutput("Total net revenue from "&B8&" to "&B23,"$k")',
197+
"VoseOutput",
198+
)
199+
assert isinstance(result, ExpressionName)
200+
assert result.static_prefix == "Total net revenue from "
201+
202+
def test_literal_concat_simple(self) -> None:
203+
result = extract_vose_first_arg(
204+
'=VoseInput("prefix"&A1)', "VoseInput",
205+
)
206+
assert isinstance(result, ExpressionName)
207+
assert result.static_prefix == "prefix"
208+
209+
def test_literal_with_units_arg_is_still_literal(self) -> None:
210+
"""`VoseOutput("name", "$k")` is two args, first is a clean
211+
literal — NOT an expression. Make sure the comma-vs-ampersand
212+
distinction is sharp."""
213+
result = extract_vose_first_arg(
214+
'=VoseOutput("RealName","$k")', "VoseOutput",
215+
)
216+
assert isinstance(result, LiteralName)
217+
assert result.name == "RealName"
218+
219+
def test_literal_with_trailing_whitespace_before_close(self) -> None:
220+
"""Whitespace between the closing quote and the comma/paren
221+
must still classify as literal."""
222+
result = extract_vose_first_arg(
223+
'=VoseOutput("OK" , "$k")', "VoseOutput",
224+
)
225+
assert isinstance(result, LiteralName)
226+
assert result.name == "OK"
227+
228+
181229
_ = pytest

tests/unit/test_server_boot.py

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

66

77
def test_version_is_set() -> None:
8-
assert __version__ == "0.3.0a30"
8+
assert __version__ == "0.3.0a31"
99

1010

1111
def test_server_name() -> None:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)