How to Map Actuarial Models to NAIC VM-20 Requirements

Mapping an actuarial model to NAIC VM-20 means binding every figure the projection engine emits to the exact clause of the Valuation Manual that governs it, so an examiner can trace the Net Premium Reserve, Deterministic Reserve, and Stochastic Reserve back to a specific section, a pinned assumption set, and a reproducible scenario seed. This page shows the focused technique — a clause-tagging map that turns loose model output into a traceable, examiner-ready lineage — and sits inside the wider NAIC VM-20 Compliance Frameworks discipline that computes and reconciles those reserves.

Clause-tagging pipeline: seeded projection engine to a jurisdiction-routed compliance dashboard A seeded projection engine feeds a tagging step that binds each figure to its VM-20 clause, then a SHA-256 checksum-and-metadata seal. A cross-jurisdiction decision diamond routes sealed outputs onto the NAIC VM-20 track or the OSFI E-23 and LICAT track, and both tracks converge on one compliance dashboard. Projection engine seeded RNG Tag output to VM-20 clause SHA-256 seal checksum + metadata Cross- jurisdiction? NAIC OSFI VM-20 track Sections 2–6 clause map E-23 & LICAT track cross-border mirror Compliance dashboard filing-ready

The Mapping Problem

A VM-20 filing does not fail because a reserve is a few dollars off; it fails because a reported number cannot be attached to the clause that requires it. Under VM-20 Section 2 the minimum reserve is the greatest of three components, each computed on a different path — Section 3 for the Net Premium Reserve, Section 4 for the Deterministic Reserve, Section 5 for the CTE 70 Stochastic Reserve — and Section 6 governs whether a component may be excluded at all. When those figures leave the model as a bare dictionary of floats, the mapping between “this number” and “that clause” lives only in an actuary’s head or a side spreadsheet. The technique below makes that mapping a data structure: every output must resolve to exactly one governing clause, carry the assumption version and seed that produced it, and hash to a checksum before it is allowed anywhere near a filing package.

A Minimal Clause-Tagging Map

The whole pattern is a lookup table from output key to VM-20 clause, a tagging function that refuses to tag anything the table does not know about, and a coverage check that blocks a filing until every required clause is present. It has no framework dependencies and runs as-is:

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

# The canonical VM-20 clause -> model-output contract. Every reported figure
# must resolve to exactly one governing section before it can be filed.
VM20_CLAUSE_MAP: dict[str, str] = {
    "net_premium_reserve": "VM-20 Section 3",
    "deterministic_reserve": "VM-20 Section 4",
    "stochastic_reserve": "VM-20 Section 5",
    "stochastic_exclusion_ratio": "VM-20 Section 6",
    "minimum_reserve": "VM-20 Section 2",
}


@dataclass(frozen=True)
class ClauseTag:
    """One model output bound to the VM-20 clause it satisfies."""

    output_key: str
    vm20_clause: str
    value: float
    assumption_set_version: str
    scenario_seed: int
    checksum: str
    tagged_at_utc: str


def tag_output(
    output_key: str,
    value: float,
    assumption_set_version: str,
    scenario_seed: int,
) -> ClauseTag:
    if output_key not in VM20_CLAUSE_MAP:
        raise KeyError(f"{output_key!r} has no VM-20 clause mapping")
    payload = json.dumps(
        {
            "output_key": output_key,
            "value": round(value, 2),
            "assumption_set_version": assumption_set_version,
            "scenario_seed": scenario_seed,
        },
        sort_keys=True,
    )
    checksum = hashlib.sha256(payload.encode()).hexdigest()
    return ClauseTag(
        output_key=output_key,
        vm20_clause=VM20_CLAUSE_MAP[output_key],
        value=value,
        assumption_set_version=assumption_set_version,
        scenario_seed=scenario_seed,
        checksum=checksum,
        tagged_at_utc=datetime.now(timezone.utc).isoformat(),
    )


def coverage_report(tags: list[ClauseTag]) -> dict[str, Any]:
    covered = {t.vm20_clause for t in tags}
    required = set(VM20_CLAUSE_MAP.values())
    missing = sorted(required - covered)
    return {
        "required_clauses": len(required),
        "covered_clauses": len(covered & required),
        "missing_clauses": missing,
        "filing_ready": not missing,
    }


if __name__ == "__main__":
    model_outputs = {
        "net_premium_reserve": 1_000_000.00,
        "deterministic_reserve": 1_050_000.00,
        "stochastic_reserve": 1_100_000.00,
        "stochastic_exclusion_ratio": 0.041,
        "minimum_reserve": 1_100_000.00,
    }
    tags = [
        tag_output(key, value, assumption_set_version="2024Q4.3", scenario_seed=20241231)
        for key, value in model_outputs.items()
    ]
    print(coverage_report(tags))

The coverage check is the filing gate. Expressed as a ratio, readiness is simply

coverage=clauses taggedclauses required,\text{coverage} = \frac{\lvert\text{clauses tagged}\rvert}{\lvert\text{clauses required}\rvert},

and the package may not leave the building until that ratio equals 1.

How the Mapping Works, Block by Block

VM20_CLAUSE_MAP is the contract, not documentation. Encoding the section numbers as a dictionary — rather than a comment or a wiki page — means the binding between a figure and its clause is executable and version-controlled. When the Valuation Manual is amended, the map changes in one place and every downstream tag inherits it. The keys use the same realistic output names the reserve engine emits (net_premium_reserve, deterministic_reserve, stochastic_reserve), so there is no translation layer to drift out of sync.

tag_output fails closed. The KeyError on an unmapped key is deliberate: a figure the map has never heard of is exactly the figure that slips into a filing with no clause behind it. Forcing every value through the map converts “we forgot to document that number” from a silent omission into a loud, blocking exception at the moment of tagging.

The checksum seals the tuple that made the number. Hashing the output key, the value rounded to cents, the assumption_set_version, and the scenario_seed together binds the figure to the precise inputs that produced it. Two runs that agree on the reserve but disagree on the assumption version produce different checksums — which is the point, because under examination “same number, different assumptions” is a finding, not a coincidence. Rounding to two decimals before serialization keeps the hash stable against sub-cent float noise; more on that below.

coverage_report is the pre-filing circuit breaker. It compares the set of clauses actually tagged against the set VM-20 requires and returns filing_ready only when nothing is missing. Wire this into the same gate that assembles the submission and a run that computed the reserve but never tagged the Section 6 exclusion ratio is stopped before it reaches the actuarial audit trail, not after an examiner reconstructs the gap. The reserves themselves arrive from the upstream valuation layer described in the NAIC VM-20 Compliance Frameworks framework, and the Stochastic Reserve consumes scenario reserves produced by Stochastic Scenario Generation Frameworks that were reconciled through economic scenario mapping and yield-curve alignment.

Edge Cases and Production Hardening

Seed non-determinism breaks reproducibility silently. If the projection engine draws from an unpinned or globally shared random generator, re-running the same reserve group yields a different Stochastic Reserve and therefore a different checksum, and the mapping now attests to a number the model no longer produces. Isolate the generator per reserve group and pin its seed:

import numpy as np

def rng_for_group(policy_group_id: str, base_seed: int) -> np.random.Generator:
    # Derive a stable, per-group seed so groups never share RNG state.
    group_seed = (base_seed * 1_000_003 + hash(policy_group_id)) & 0xFFFF_FFFF
    return np.random.default_rng(group_seed)

The scenario_seed recorded in each ClauseTag must be the seed actually used, so the run can be replayed bit-for-bit from the sealed record.

Float ordering drift trips the checksum across environments. Summing present values in a different order, or on a different BLAS build, can shift a reserve by fractions of a cent — enough to change the hash even though the reserve is economically identical. The round(value, 2) before serialization absorbs sub-cent noise; for figures where even cents can wobble, serialize with a fixed format (for example f"{value:.2f}") and adopt a canonical accumulation order in the projection engine so the same inputs always fold in the same sequence.

An un-mapped or renamed output escapes the gate. If a modeler renames stochastic_reserve to sr_cte70 in the engine but not in VM20_CLAUSE_MAP, the KeyError fires — which is correct, but only if the tagging call sits on the mandatory path. Enforce the schema at the boundary with Pydantic schema enforcement so the output object cannot be constructed with keys the clause map does not recognise, and keep policyholder identifiers out of the tagged payload by tokenizing seriatim inputs through the data security and PII boundary layer before any figure is hashed.

Compliance Note

This clause-tagging map is the mechanical evidence a VM-20 filing needs, but it is answering to VM-31, the PBR Actuarial Report requirement, which obliges the appointed actuary to document each reported value and trace it to its source assumptions and methods. The assumption_set_version and scenario_seed carried on every tag are precisely the reconstruction inputs VM-31 expects, and the coverage gate is a concrete expression of ASOP No. 56 (Modeling), which calls for a model whose intended purpose, reconciliation, and controls are documented rather than assumed. For carriers filing on both sides of the border, mirror this map against the OSFI Model Risk Management Guidelines so a single tagged output can satisfy the risk-classification and independent-validation expectations of both regimes without a second, divergent pipeline.

Up a level: NAIC VM-20 Compliance Frameworks · Regulatory Architecture & Compliance Mapping


Regulatory references: NAIC Valuation Manual VM-20 (Requirements for Principle-Based Reserves for Life Products) and VM-31 (PBR Actuarial Report); Actuarial Standards Board ASOP No. 56 (Modeling).