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.
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
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.
Related Guides
- NAIC VM-20 Compliance Frameworks — the reserve computation and reconciliation layer this mapping tags.
- Actuarial Audit Trail Architecture — the tamper-evident ledger the clause tags are sealed into.
- Data Security & PII Boundaries for Filing Systems — tokenizing seriatim inputs before figures are hashed.
- Automating NAIC Filing Deadline Alerts in Python — escalating a coverage failure with time to remediate.
- Assumption Validation & Rule Engine Design — governing the assumption sets whose versions the tags record.
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).