Skip to content

Commit be6127b

Browse files
vosesoftclaude
andcommitted
AB#2646 build_mcda: AHP pairwise weight elicitation (0.0.25)
Set weight_source="ahp" + an ahp_matrix (Saaty 1-9, (1+criteria) square, ordered [financial, ...criteria]) to derive MCDA weights from pairwise comparisons instead of entering them directly. The tool mirrors the add-in's AhpCalculator (power-iteration eigenvector + Saaty consistency ratio) to show the derived weights + CR in the preview, validates the matrix (square, sized, positive), and forwards weightSource/ahpMatrix to MC_ApplyMcda_Auto, which recomputes them authoritatively. - schemas: McdaSpec.weight_source + ahp_matrix; McdaBuildResult.weight_source + weights + consistency_ratio (KeyValue moved above the MCDA models so the forward ref resolves). - tools: _ahp_weights + _validate_ahp_matrix helpers; build_mcda branches on weight_source; 4 unit tests (consistent 3x3 -> [4/7,2/7,1/7], CR~0; commit forwards the matrix; missing/bad-size raise). README + CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 805a9f5 commit be6127b

8 files changed

Lines changed: 212 additions & 21 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.25
7+
- **`build_mcda` gains AHP weight elicitation (AB#2646)** — set
8+
`weight_source="ahp"` and pass an `ahp_matrix` (Saaty 1-9 pairwise comparisons,
9+
`(1 + criteria)` square, ordered `[financial, …criteria]`) instead of direct
10+
weights. The tool computes the eigenvector weights + consistency ratio for the
11+
preview and forwards `weightSource`/`ahpMatrix` to `MC_ApplyMcda_Auto`, which
12+
recomputes them authoritatively (CR surfaced; CR > 0.10 flagged inconsistent).
13+
`McdaBuildResult` now reports `weight_source`, `weights`, `consistency_ratio`.
14+
615
## 0.0.24
716
- **`/decision-tree-monte-carlo` prompt** — guides the cross-server Monte Carlo
817
hand-off: assign `Vose*` distributions to a tree's uncertain inputs

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
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.24` — 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.
7+
> **Status: `0.0.25` — 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

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-
| `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`. |
14+
| `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. Weights can be entered directly or derived from an **AHP pairwise-comparison matrix** (`weight_source="ahp"` → eigenvector weights + consistency ratio). 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. |
1616
| `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. |
1717
| `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`. |
@@ -58,7 +58,7 @@ The Python roller is validated against ModelChoice's own authoritative C# test (
5858
The original phased roadmap is delivered. Candidate next steps (not yet scheduled):
5959

6060
- **Simulation hand-off (cross-server):** the `decision-tree-monte-carlo` prompt guides assigning distributions → wrapping the root EV as a ModelRisk output → running the simulation in `modelrisk-mcp` → reading the EV's output distribution back. A thin orchestration tool could automate the hand-off end-to-end.
61-
- **AHP weight elicitation for MCDA:** `build_mcda` v1 takes direct weights; the add-in's Core already ships an `AhpCalculator` (pairwise comparisons → eigenvector weights + consistency ratio). A future `build_mcda` could accept a pairwise matrix instead (tracked in ADO).
61+
- ~~**AHP weight elicitation for MCDA**~~*done (0.0.25, AB#2646):* `build_mcda` accepts a pairwise-comparison matrix (`weight_source="ahp"`) and derives the weights + consistency ratio via the add-in's `AhpCalculator`.
6262

6363
## Architecture
6464

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

src/modelchoice_mcp/schemas.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ class ScenarioComparison(BaseModel):
211211
note: str
212212

213213

214+
class KeyValue(BaseModel):
215+
label: str
216+
value: Any
217+
218+
214219
class CriterionSpec(BaseModel):
215220
"""One non-financial MCDA criterion with discrete ordinal options."""
216221

@@ -240,6 +245,22 @@ class McdaSpec(BaseModel):
240245
default_factory=dict,
241246
description="Per terminal: {terminal_id: {criterion_id: option_label}}.",
242247
)
248+
weight_source: str = Field(
249+
default="direct",
250+
description=(
251+
"'direct' uses the per-criterion `weight` + `financial_weight` fields; "
252+
"'ahp' derives all weights from `ahp_matrix` (those fields are ignored)."
253+
),
254+
)
255+
ahp_matrix: list[list[float]] | None = Field(
256+
default=None,
257+
description=(
258+
"AHP pairwise comparison matrix on Saaty's 1-9 scale — required when "
259+
"weight_source='ahp'. Must be (1 + number_of_criteria) square, ordered "
260+
"[financial, criterion0, criterion1, ...], positive, and reciprocal "
261+
"across the diagonal (a[j][i] = 1/a[i][j], diagonal = 1)."
262+
),
263+
)
243264

244265

245266
class McdaBuildResult(BaseModel):
@@ -250,6 +271,17 @@ class McdaBuildResult(BaseModel):
250271
node_count: int
251272
criteria: list[str] = Field(description="Criterion names defined.")
252273
terminals_scored: int
274+
weight_source: str = Field(
275+
default="direct", description="'direct' or 'ahp' — how the weights were set."
276+
)
277+
weights: list[KeyValue] = Field(
278+
default_factory=list,
279+
description="Effective weights (label -> weight): 'Financial' + each criterion.",
280+
)
281+
consistency_ratio: float | None = Field(
282+
default=None,
283+
description="AHP consistency ratio (CR < 0.10 acceptable); None for direct weights.",
284+
)
253285
note: str
254286

255287

@@ -313,11 +345,6 @@ class SheetData(BaseModel):
313345
truncated: bool = Field(description="True if the sheet had more rows/cols than returned.")
314346

315347

316-
class KeyValue(BaseModel):
317-
label: str
318-
value: Any
319-
320-
321348
class RobustnessSummary(BaseModel):
322349
"""Structured read of ModelChoice's robustness ('break the decision')
323350
analysis — how much inputs must change to flip the optimal choice."""

src/modelchoice_mcp/tools.py

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,57 @@ def build_tree(
166166
)
167167

168168

169+
_AHP_RANDOM_INDEX = [
170+
0.0, 0.0, 0.58, 0.90, 1.12, 1.24, 1.32, 1.41, 1.45, 1.49,
171+
1.51, 1.48, 1.56, 1.57, 1.59,
172+
]
173+
174+
175+
def _ahp_weights(matrix: list[list[float]]) -> tuple[list[float], float]:
176+
"""AHP principal-eigenvector weights + consistency ratio, mirroring the
177+
add-in's AhpCalculator (power iteration; Saaty's RI table). `matrix` is an
178+
n-by-n positive reciprocal pairwise-comparison matrix; returns (weights, CR)."""
179+
n = len(matrix)
180+
w = [1.0 / n] * n
181+
for _ in range(100):
182+
t = [sum(matrix[i][j] * w[j] for j in range(n)) for i in range(n)]
183+
s = sum(t)
184+
if s < 1e-15:
185+
raise ValueError("ahp_matrix is degenerate (zero column sum).")
186+
t = [x / s for x in t]
187+
converged = max(abs(t[i] - w[i]) for i in range(n)) < 1e-10
188+
w = t
189+
if converged:
190+
break
191+
if n <= 2:
192+
return w, 0.0
193+
lam = 0.0
194+
for i in range(n):
195+
aw = sum(matrix[i][j] * w[j] for j in range(n))
196+
if w[i] > 1e-15:
197+
lam += aw / w[i]
198+
lam /= n
199+
ci = (lam - n) / (n - 1)
200+
ri = _AHP_RANDOM_INDEX[min(n, len(_AHP_RANDOM_INDEX)) - 1]
201+
return w, (ci / ri if ri > 1e-15 else 0.0)
202+
203+
204+
def _validate_ahp_matrix(matrix: list[list[float]] | None, n: int) -> list[list[float]]:
205+
"""Validate an AHP matrix is present, n-by-n, and strictly positive."""
206+
if not matrix:
207+
raise ValueError("weight_source='ahp' requires ahp_matrix.")
208+
if len(matrix) != n or any(len(row) != n for row in matrix):
209+
raise ValueError(
210+
f"ahp_matrix must be {n}x{n} (financial + {n - 1} criteria), "
211+
"ordered [financial, then criteria in spec order]."
212+
)
213+
for i, row in enumerate(matrix):
214+
for j, v in enumerate(row):
215+
if isinstance(v, bool) or not isinstance(v, (int, float)) or v <= 0:
216+
raise ValueError(f"ahp_matrix[{i}][{j}] must be a positive number.")
217+
return matrix
218+
219+
169220
@mcp.tool(
170221
description=(
171222
"ModelChoice: Build a MULTI-CRITERIA (MCDA) decision model — when the "
@@ -175,9 +226,13 @@ def build_tree(
175226
"an aggregation method (weighted_sum / weighted_geometric / waspas), and "
176227
"each terminal's option per criterion (`terminal_scores`). The tool "
177228
"validates structure + weights + that every score references a real "
178-
"criterion option. dry_run=True (default) previews; dry_run=False builds "
179-
"the tree, sets MCDA mode, and renders — the add-in computes the "
180-
"composite scores. Needs the add-in loaded (a build with the MCDA command)."
229+
"criterion option. Weights are entered directly (weight_source='direct') "
230+
"OR derived from an AHP pairwise-comparison matrix (weight_source='ahp' "
231+
"with `ahp_matrix` — the tool computes the eigenvector weights + "
232+
"consistency ratio and the add-in recomputes them authoritatively). "
233+
"dry_run=True (default) previews; dry_run=False builds the tree, sets MCDA "
234+
"mode, and renders — the add-in computes the composite scores. Needs the "
235+
"add-in loaded (a build with the MCDA command)."
181236
)
182237
)
183238
def build_mcda(
@@ -197,8 +252,6 @@ def build_mcda(
197252
if len(c.options) < 2:
198253
raise ValueError(f"criterion {c.id!r} needs >=2 options.")
199254
crit_by_id[c.id] = c
200-
weight_sum = mcda.financial_weight + sum(c.weight for c in mcda.criteria)
201-
202255
term_ids = {n.id for n in tree.nodes if n.type.lower() == "terminal"}
203256
for tid, scores in mcda.terminal_scores.items():
204257
if tid not in term_ids:
@@ -214,9 +267,31 @@ def build_mcda(
214267

215268
crit_names = [c.name for c in mcda.criteria]
216269
scored = len(mcda.terminal_scores)
217-
weight_note = (
218-
"" if abs(weight_sum - 1.0) < 1e-3 else f" (weights sum {weight_sum:.3f}, not 1.0)"
219-
)
270+
ws = mcda.weight_source.strip().lower()
271+
272+
# Resolve effective weights for preview/reporting. For AHP the matrix is
273+
# authoritative (the add-in recomputes it on commit); we mirror the math
274+
# here so the preview shows the derived weights + consistency ratio.
275+
consistency_ratio: float | None = None
276+
ahp_matrix: list[list[float]] | None = None
277+
if ws == "ahp":
278+
ahp_matrix = _validate_ahp_matrix(mcda.ahp_matrix, len(mcda.criteria) + 1)
279+
derived, consistency_ratio = _ahp_weights(ahp_matrix)
280+
financial_w, crit_w = derived[0], derived[1:]
281+
weight_note = f" AHP weights (CR={consistency_ratio:.3f}" + (
282+
"; INCONSISTENT, CR>0.10 — consider revising judgments)"
283+
if consistency_ratio > 0.10
284+
else ")"
285+
)
286+
else:
287+
financial_w = mcda.financial_weight
288+
crit_w = [c.weight for c in mcda.criteria]
289+
total = financial_w + sum(crit_w)
290+
weight_note = "" if abs(total - 1.0) < 1e-3 else f" (weights sum {total:.3f}, not 1.0)"
291+
292+
weights_kv = [KeyValue(label="Financial", value=round(financial_w, 4))] + [
293+
KeyValue(label=c.name, value=round(crit_w[i], 4)) for i, c in enumerate(mcda.criteria)
294+
]
220295

221296
if dry_run:
222297
return McdaBuildResult(
@@ -225,19 +300,23 @@ def build_mcda(
225300
node_count=len(t.nodes),
226301
criteria=crit_names,
227302
terminals_scored=scored,
303+
weight_source=ws,
304+
weights=weights_kv,
305+
consistency_ratio=consistency_ratio,
228306
note=(
229307
f"Validated MCDA preview: {len(crit_names)} criteria, {scored} terminals "
230-
f"scored{weight_note}. Composite scores are computed by the add-in on commit."
308+
f"scored;{weight_note}. Composite scores are computed by the add-in on commit."
231309
),
232310
)
233311

234312
bridge = get_bridge()
235313
sheet = bridge.write_tree(model_json, workbook=workbook_name)
236314
bridge.render_tree(sheet, workbook_name)
237-
spec = {
315+
spec: dict[str, Any] = {
238316
"financialWeight": mcda.financial_weight,
239317
"aggregation": mcda.aggregation,
240318
"waspasLambda": mcda.waspas_lambda,
319+
"weightSource": ws,
241320
"criteria": [
242321
{
243322
"id": c.id,
@@ -250,13 +329,18 @@ def build_mcda(
250329
],
251330
"terminalScores": mcda.terminal_scores,
252331
}
332+
if ahp_matrix is not None:
333+
spec["ahpMatrix"] = ahp_matrix
253334
bridge.apply_mcda(json.dumps(spec), workbook_name)
254335
return McdaBuildResult(
255336
written=True,
256337
sheet=sheet,
257338
node_count=len(t.nodes),
258339
criteria=crit_names,
259340
terminals_scored=scored,
341+
weight_source=ws,
342+
weights=weights_kv,
343+
consistency_ratio=consistency_ratio,
260344
note=(
261345
f"Built MCDA model on {sheet!r}: {len(crit_names)} criteria, {scored} "
262346
f"terminals scored, aggregation {mcda.aggregation}{weight_note}."

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

1212

1313
def test_server_name() -> None:

tests/test_tools.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,77 @@ def test_build_mcda_bad_option_raises(bridge: _FakeBridge) -> None:
596596
tools.build_mcda(tree, mcda)
597597

598598

599+
def _mcda_ahp_specs():
600+
"""Two non-financial criteria + a consistent 3x3 AHP matrix
601+
[financial, cost, safety] proportional to weights [4, 2, 1]."""
602+
from modelchoice_mcp.schemas import BranchSpec, CriterionSpec, McdaSpec, NodeSpec, TreeSpec
603+
604+
tree = TreeSpec(
605+
root_id="D", model_name="Site", maximize=True,
606+
nodes=[
607+
NodeSpec(id="D", type="decision", name="Site", branches=[
608+
BranchSpec(name="A", child_id="T1"),
609+
BranchSpec(name="B", child_id="T2")]),
610+
NodeSpec(id="T1", type="terminal", name="A", value=0),
611+
NodeSpec(id="T2", type="terminal", name="B", value=0),
612+
],
613+
)
614+
mcda = McdaSpec(
615+
criteria=[
616+
CriterionSpec(id="cost", name="Cost", weight=0.0, maximize=False,
617+
options=["High", "Low"]),
618+
CriterionSpec(id="safety", name="Safety", weight=0.0, maximize=True,
619+
options=["Low", "High"]),
620+
],
621+
weight_source="ahp",
622+
ahp_matrix=[[1.0, 2.0, 4.0], [0.5, 1.0, 2.0], [0.25, 0.5, 1.0]],
623+
terminal_scores={"T1": {"cost": "Low", "safety": "Low"},
624+
"T2": {"cost": "High", "safety": "High"}},
625+
)
626+
return tree, mcda
627+
628+
629+
def test_build_mcda_ahp_dry_run(bridge: _FakeBridge) -> None:
630+
tree, mcda = _mcda_ahp_specs()
631+
out = tools.build_mcda(tree, mcda)
632+
assert out.weight_source == "ahp"
633+
# Perfectly consistent matrix -> CR ~ 0.
634+
assert out.consistency_ratio is not None and out.consistency_ratio < 0.01
635+
by_label = {kv.label: kv.value for kv in out.weights}
636+
assert by_label["Financial"] == pytest.approx(4 / 7, abs=1e-3)
637+
assert by_label["Cost"] == pytest.approx(2 / 7, abs=1e-3)
638+
assert by_label["Safety"] == pytest.approx(1 / 7, abs=1e-3)
639+
640+
641+
def test_build_mcda_ahp_commit_sends_matrix() -> None:
642+
fake = _FakeBridge({})
643+
tools.set_bridge_for_testing(fake) # type: ignore[arg-type]
644+
try:
645+
tree, mcda = _mcda_ahp_specs()
646+
out = tools.build_mcda(tree, mcda, dry_run=False)
647+
assert out.written is True and out.weight_source == "ahp"
648+
import json as _json
649+
spec = _json.loads(fake.mcda_spec)
650+
assert spec["weightSource"] == "ahp"
651+
assert spec["ahpMatrix"] == [[1.0, 2.0, 4.0], [0.5, 1.0, 2.0], [0.25, 0.5, 1.0]]
652+
finally:
653+
tools.set_bridge_for_testing(None)
654+
655+
656+
def test_build_mcda_ahp_missing_matrix_raises(bridge: _FakeBridge) -> None:
657+
tree, mcda = _mcda_ahp_specs()
658+
mcda.ahp_matrix = None
659+
with pytest.raises(ValueError, match="requires ahp_matrix"):
660+
tools.build_mcda(tree, mcda)
661+
662+
663+
def test_build_mcda_ahp_bad_size_raises(bridge: _FakeBridge) -> None:
664+
tree, mcda = _mcda_ahp_specs()
665+
mcda.ahp_matrix = [[1.0, 2.0], [0.5, 1.0]] # 2x2, need 3x3 (financial + 2 criteria)
666+
with pytest.raises(ValueError, match="3x3"):
667+
tools.build_mcda(tree, mcda)
668+
669+
599670
def test_run_scenarios_compares(bridge: _FakeBridge) -> None:
600671
from modelchoice_mcp.schemas import ScenarioComparison, ScenarioSpec
601672

0 commit comments

Comments
 (0)