Skip to content

Commit 2f62da9

Browse files
vosesoftclaude
andcommitted
Add fit_copula_to_data, reverse_stress_test, fit_all_data_and_wire, build_model_from_brief (0.3.8)
Four functional tools that widen the capability gap vs an advisory agent — each needs to run the engine and/or hold the per-iteration joint sample matrix: - fit_copula_to_data (analysis): fit Normal/T/Clayton/Frank/Gumbel copulas to a data block, rank by AIC/SIC/HQIC via Vose<Family>FitObject on a scratch sheet, report tail-dependence (lower/upper/both/none). Dependence fitted FROM data. New bridge method fit_and_rank_copulas mirrors fit_and_rank (adds the copula FitObject's Data_In_Rows arg). - reverse_stress_test (analysis): partition iterations breach/no-breach and profile each input's shift inside the breach set + extract a named stress scenario. Pure Python over .vmrs samples (reverse_stress_profile is factored out and unit-tested exactly). - fit_all_data_and_wire (workflows): fit marginals + copula and wire a correlated model (Vose<Family>Fit(range,unc,U) linked to the copula U block), atomic with rollback, dry_run preview. - build_model_from_brief (workflows): deterministic workbook -> wrapped outputs + VoseInput distributions -> validating sim -> headline percentiles, in one atomic reversible pass. Adds a _ChangeSet staged-write/rollback wrapper over safe_write_cell (repo had only per-cell restore before). New schemas in schemas/analysis.py. 14 new unit tests (pure logic + mocked-bridge + rollback); real-Excel wiring via gated integration tests. 620 unit tests green; ruff + mypy clean. Version 0.3.7 -> 0.3.8; tool count 56. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent caa70bd commit 2f62da9

11 files changed

Lines changed: 1315 additions & 7 deletions

File tree

CHANGELOG.md

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

55
## [Unreleased]
66

7+
## [0.3.8] — 2026-07-05
8+
9+
### Four functional tools that widen the "does the work" gap vs. advisory agents
10+
11+
Capabilities that structurally require *running the engine* and *holding the per-iteration joint sample matrix* — things an advisory copilot cannot do:
12+
13+
- **`fit_copula_to_data`** — fit parametric copulas (Normal / T / Clayton / Frank / Gumbel) to a multi-column data range, rank by AIC/SIC/HQIC (`Vose<Family>FitObject` scored via `VoseAIC/SIC/HQIC` on a transient scratch sheet), and report the winner's **tail-dependence** character (lower = crash-together, upper = boom-together, both = T). Dependence fitted *from data*, capturing joint-tail risk a single correlation coefficient discards.
14+
- **`reverse_stress_test`** — from a bad output outcome back to the joint input state that causes it: partitions iterations into breach / no-breach (threshold or percentile, above/below), reports each input's mean shift (in its own SDs) inside the breach set and its tail concentration (a breach-driver tornado), and extracts the mean input vector as a concrete named stress scenario. The Solvency II / PRA reverse-stress-test, pure Python over the recorded `.vmrs` sample matrix.
15+
- **`fit_all_data_and_wire`** — fit both the marginals *and* the copula from a data block and wire the whole **correlated** model into the workbook (each marginal `Vose<Family>Fit(range, unc, U)` linked to the fitted copula's correlated-U block). One atomic pass with full rollback; `dry_run=True` previews the exact formulas.
16+
- **`build_model_from_brief`** — turn a deterministic workbook into a simulation-ready model in one atomic, reversible pass: wrap outputs with `VoseOutput`, replace input cells with `VoseInput`-wrapped distributions you choose, optionally run a validating simulation, and return the headline percentiles. Every write goes through the audit-logged safe-write path inside a **change-set** — any mid-build failure rolls back the entire build so the workbook is never left half-converted.
17+
18+
Both orchestrators add a staged change-set/rollback wrapper over `safe_write_cell` (the repo previously had only per-cell restore). 14 new unit tests (pure breach-partition logic + mocked-bridge wrapper/rollback tests); real-Excel wiring is covered by the gated integration suite. 620 unit tests green; ruff + mypy clean. Tool count 56.
19+
720
## [0.3.7] — 2026-06-18
821

922
### Distribution charts: `create_histogram_chart` + `create_cdf_chart`

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ The server can both read *and* modify your workbook — that's the central diffe
234234

235235
## What's inside
236236

237-
- **52 tools** — 12 reading, 14 building (incl. `create_aggregate` for FFT / Panjer / MC frequency-severity methods), 5 simulation (incl. `run_simulation`, `run_scenarios`, `get_samples`, `restore_cell`, `restore_deterministic_state`), 8 analysis (`compute_distribution`, `fit_and_rank_distributions`, `get_tail_risk`, `fit_tail`, `compute_correlation_matrix`, `compare_distributions`, `backtest_output`, `decompose_uncertainty`), 10 workflow / reporting (incl. `plan_risk_model`, `audit_model`, `diagnose_workbook`, `create_tornado_chart`, `create_histogram_chart`, `create_cdf_chart`, `build_drivers_report`, `build_executive_report`, `generate_executive_summary`, `save_workbook_as`), 3 VMRS (`read_vmrs`, `set_active_vmrs`, `list_vmrs_variables`)
237+
- **56 tools** — 12 reading, 14 building (incl. `create_aggregate` for FFT / Panjer / MC frequency-severity methods), 5 simulation (incl. `run_simulation`, `run_scenarios`, `get_samples`, `restore_cell`, `restore_deterministic_state`), 10 analysis (`compute_distribution`, `fit_and_rank_distributions`, `fit_copula_to_data`, `reverse_stress_test`, `get_tail_risk`, `fit_tail`, `compute_correlation_matrix`, `compare_distributions`, `backtest_output`, `decompose_uncertainty`), 12 workflow / reporting (incl. `plan_risk_model`, `build_model_from_brief`, `fit_all_data_and_wire`, `audit_model`, `diagnose_workbook`, `create_tornado_chart`, `create_histogram_chart`, `create_cdf_chart`, `build_drivers_report`, `build_executive_report`, `generate_executive_summary`, `save_workbook_as`), 3 VMRS (`read_vmrs`, `set_active_vmrs`, `list_vmrs_variables`)
238238
- **5 resources**`modelrisk://functions`, `modelrisk://distributions`, `modelrisk://methodology`, `modelrisk://workbook/current`, `modelrisk://audit-rules`
239239
- **5 slash-command prompts**`/build-risk-model`, `/audit-model`, `/interpret-results`, `/add-uncertainty`, `/import-legacy-model`
240240
- **1417-entry function catalogue** extracted directly from the ModelRisk IDL + XLL header

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.7"
3+
version = "0.3.8"
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.7",
6+
"version": "0.3.8",
77
"packages": [
88
{
99
"registryType": "pypi",
1010
"identifier": "modelrisk-mcp",
11-
"version": "0.3.7",
11+
"version": "0.3.8",
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.7"
1+
__version__ = "0.3.8"

src/modelrisk_mcp/bridge/modelrisk.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,88 @@ def fit_and_rank(
475475

476476
return scored, skipped, sample_size
477477

478+
def fit_and_rank_copulas(
479+
self,
480+
data_range: str,
481+
families: list[str],
482+
*,
483+
data_in_rows: bool = False,
484+
workbook: str | None = None,
485+
uncertainty: bool = False,
486+
) -> tuple[list[dict[str, Any]], list[dict[str, str]], int]:
487+
"""Fit each copula `family` to a multi-column `data_range` and
488+
return its AIC/SIC/HQIC — the dependence structure fitted *from
489+
data*, ranked like distributions.
490+
491+
Mirrors `fit_and_rank` exactly, but the copula fit-object
492+
functions take a `Data_In_Rows` flag as well as the uncertainty
493+
flag: `Vose<Family>FitObject(range, data_in_rows, uncertainty)`.
494+
`family` names are catalogue stems like `CopulaMultiClayton`
495+
(the tool passes the Multi* families, which handle any n>=2).
496+
497+
`data_range` must be sheet-qualified. Returns
498+
`(scored, skipped, cell_count)` where `cell_count` is the total
499+
numeric cell count (the caller divides by the variable count to
500+
get the joint-observation row count)."""
501+
self.ensure_modelrisk_functional()
502+
unc = "TRUE" if uncertainty else "FALSE"
503+
dir_flag = "TRUE" if data_in_rows else "FALSE"
504+
505+
valid: list[str] = []
506+
skipped: list[dict[str, str]] = []
507+
for fam in families:
508+
if f"Vose{fam}FitObject" in self._catalogue:
509+
valid.append(fam)
510+
else:
511+
skipped.append(
512+
{"family": fam, "reason": "no Vose<Family>FitObject in catalogue"}
513+
)
514+
515+
scored: list[dict[str, Any]] = []
516+
cell_count = 0
517+
if valid:
518+
with self._scratch_sheet(workbook) as (app, sht):
519+
sht.range("F1").formula = f"=COUNT({data_range})"
520+
for i, fam in enumerate(valid):
521+
r = i + 1
522+
sht.range((r, 1)).formula = (
523+
f"=Vose{fam}FitObject({data_range},{dir_flag},{unc})"
524+
)
525+
sht.range((r, 2)).formula = f"=VoseAIC(A{r})"
526+
sht.range((r, 3)).formula = f"=VoseSIC(A{r})"
527+
sht.range((r, 4)).formula = f"=VoseHQIC(A{r})"
528+
app.api.CalculateFull()
529+
try:
530+
cell_count = int(sht.range("F1").value or 0)
531+
except (TypeError, ValueError):
532+
cell_count = 0
533+
for i, fam in enumerate(valid):
534+
r = i + 1
535+
aic = sht.range((r, 2)).value
536+
sic = sht.range((r, 3)).value
537+
hqic = sht.range((r, 4)).value
538+
if all(
539+
isinstance(v, (int, float)) and not isinstance(v, bool)
540+
for v in (aic, sic, hqic)
541+
):
542+
scored.append(
543+
{
544+
"family": fam,
545+
"aic": float(aic),
546+
"sic": float(sic),
547+
"hqic": float(hqic),
548+
}
549+
)
550+
else:
551+
reason = next(
552+
(str(v) for v in (aic, sic, hqic)
553+
if not isinstance(v, (int, float))),
554+
"fit failed",
555+
)
556+
skipped.append({"family": fam, "reason": reason})
557+
558+
return scored, skipped, cell_count
559+
478560
# ------------------------------------------------------------------
479561
# Run sim — wraps SimulationController + auto-pins the resulting
480562
# .vmrs as the active source so subsequent get_simulation_results

src/modelrisk_mcp/schemas/analysis.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,161 @@ class UncertaintyDecomposition(BaseModel):
252252
interpretation: str = Field(
253253
description="Which uncertainty dominates and what reduces it."
254254
)
255+
256+
257+
class CopulaFitCandidate(BaseModel):
258+
"""Goodness-of-fit scores for one fitted copula family. Lower
259+
information criteria are better fits."""
260+
261+
family: str = Field(description="Copula family, e.g. 'CopulaMultiClayton'.")
262+
aic: float = Field(description="Akaike information criterion (lower = better).")
263+
sic: float = Field(description="Schwarz/Bayesian information criterion (lower = better).")
264+
hqic: float = Field(description="Hannan-Quinn information criterion (lower = better).")
265+
rank: int = Field(description="1 = best fit by the chosen criterion.")
266+
tail_dependence: str = Field(
267+
description=(
268+
"Asymmetric tail-dependence character of the family: 'none' "
269+
"(Normal/Frank), 'lower' (Clayton — joint crashes), 'upper' "
270+
"(Gumbel — joint booms), or 'both' (T). This is what a single "
271+
"correlation coefficient throws away."
272+
)
273+
)
274+
275+
276+
class CopulaFitRanking(BaseModel):
277+
"""Result of fitting and ranking parametric copula families to a
278+
multi-column data range — the dependence structure *fitted from
279+
data*, not merely constructed."""
280+
281+
data_range: str
282+
n_variables: int = Field(description="Number of variables (columns) the copula spans.")
283+
criterion: str = Field(description="Criterion the ranking is sorted by (AIC / SIC / HQIC).")
284+
sample_size: int = Field(description="Number of joint observations (rows).")
285+
best_family: str | None = Field(
286+
default=None, description="Top-ranked copula family, or null if every fit failed."
287+
)
288+
candidates: list[CopulaFitCandidate] = Field(
289+
description="Successfully-fitted copula families, best first."
290+
)
291+
skipped: list[dict[str, str]] = Field(
292+
default_factory=list,
293+
description="Families that could not be fitted, with a reason each.",
294+
)
295+
note: str = Field(description="Interpretation of the winning family's tail behaviour.")
296+
297+
298+
class BreachDriver(BaseModel):
299+
"""One input's behaviour conditional on the output breaching the
300+
stress threshold — the inverse of a forward tornado."""
301+
302+
input_name: str
303+
marginal_mean: float = Field(description="The input's mean across all iterations.")
304+
breach_mean: float = Field(description="The input's mean across only the breach iterations.")
305+
mean_shift_sd: float = Field(
306+
description=(
307+
"Standardised departure = (breach_mean - marginal_mean) / marginal_stdev. "
308+
"Large magnitude ⇒ this input is systematically different when things go wrong."
309+
)
310+
)
311+
breach_share_of_own_tail: float = Field(
312+
description=(
313+
"Fraction of this input's own worst-decile iterations that fall in the "
314+
"breach set — how concentrated breaches are in this input's tail."
315+
)
316+
)
317+
rank: int = Field(description="1 = strongest breach driver by |mean_shift_sd|.")
318+
319+
320+
class StressScenario(BaseModel):
321+
"""A concrete named input state extracted from the breach iterations."""
322+
323+
label: str
324+
input_values: dict[str, float] = Field(
325+
description="Per-input value (the mean input vector over the breach set)."
326+
)
327+
328+
329+
class ReverseStressResult(BaseModel):
330+
"""Reverse stress test: from a bad output outcome back to the joint
331+
input state that produced it (Solvency II / PRA style). Pure analysis
332+
over the recorded per-iteration sample matrix — impossible without
333+
the engine's joint samples."""
334+
335+
output_name: str
336+
threshold: float
337+
direction: str = Field(description="'above' or 'below' — the breach side of the threshold.")
338+
iterations: int
339+
breach_count: int
340+
breach_probability: float
341+
drivers: list[BreachDriver] = Field(
342+
description="Inputs ranked by how far they shift in the breach set."
343+
)
344+
scenario: StressScenario | None = Field(
345+
default=None, description="The mean input vector over the breach iterations."
346+
)
347+
note: str
348+
349+
350+
class WiredColumnResult(BaseModel):
351+
"""Outcome of fitting + wiring one data column as a model input."""
352+
353+
input_name: str
354+
target_cell: str
355+
best_family: str | None
356+
formula: str
357+
written: bool
358+
skipped_reason: str | None = None
359+
360+
361+
class FitAndWireResult(BaseModel):
362+
"""Outcome of fitting marginals + a copula from data and wiring them
363+
into the workbook as a correlated, simulation-ready model."""
364+
365+
workbook: str
366+
sheet: str
367+
columns: list[WiredColumnResult]
368+
copula_family: str | None = Field(
369+
default=None, description="Best-fit copula family wired across the columns, if any."
370+
)
371+
copula_anchor: str | None = Field(
372+
default=None, description="Range where the correlated-U block was written."
373+
)
374+
dry_run: bool
375+
simulated: bool = Field(description="Whether a validating simulation was run.")
376+
achieved_correlation: list[list[float]] | None = Field(
377+
default=None, description="Rank correlation of the wired inputs from the validating run."
378+
)
379+
rolled_back: bool = Field(
380+
default=False, description="True if a mid-build failure triggered a full rollback."
381+
)
382+
steps: list[str] = Field(description="Ordered log of what happened.")
383+
note: str
384+
385+
386+
class BuiltInput(BaseModel):
387+
"""One input the brief-builder created."""
388+
389+
cell: str
390+
input_name: str
391+
formula: str
392+
source: str = Field(description="'fitted-from-data', 'proposed', or 'existing'.")
393+
394+
395+
class ModelFromBriefResult(BaseModel):
396+
"""Outcome of turning a deterministic workbook into a simulation-ready
397+
Monte Carlo model in one orchestrated, reversible pass."""
398+
399+
workbook: str
400+
dry_run: bool
401+
outputs_wrapped: list[str] = Field(default_factory=list)
402+
inputs_built: list[BuiltInput] = Field(default_factory=list)
403+
correlated: bool = Field(default=False)
404+
simulated: bool = Field(default=False)
405+
headline: dict[str, dict[str, float]] = Field(
406+
default_factory=dict,
407+
description="Per-output headline stats (mean/P10/P50/P90) from the validating run.",
408+
)
409+
rolled_back: bool = Field(default=False)
410+
change_set_size: int = Field(description="Number of cells written (0 if dry_run).")
411+
steps: list[str] = Field(description="Ordered, human-readable log of the build.")
412+
note: str

0 commit comments

Comments
 (0)