Chapter 15·Part IVLife, health and pensions

Life, Health, and Pensions

A multi-scheme pension pipeline for closed UK DB schemes: a quality gate on member data, a funding valuation with a six-way sensitivity panel, and member statements whose every figure carries a citation.

In the book this is Part IV, Agentic AI in Actuarial Practice.

What you’ll build

  • Life product development: profit testing, sensitivities, and documentation
  • Mortality and morbidity experience studies against IALM, CMI, and SOA tables
  • Health analytics: utilisation, treatment cost trends, and case-mix adjustment
  • High-volume pension scheme valuations with per-scheme benefit structures
  • Asset-liability management and scenario expansion
  • Personalised policyholder communication at scale under GDPR, HIPAA, and DPDP

02_quality_gate.py

runs in your browserSource on GitHub

Validates scheme member data before the pipeline is allowed to run.

02_quality_gate.py
# Generated from ch15_pension_pipeline/02_quality_gate.py for in-browser execution.
# Agno agent wiring is removed so the tool runs as a plain function;
# the full version is on GitHub. Do not edit: regenerate with
# scripts/build_demos.py.
# quality_gate.py — ingestion stage of the fixed-sequence pipeline.
# Book reference: Chapter 15, "Architecture"
# Repo note: the four validators live in support.py. For an agent
# exercising this tool, see 04_ingestion_agent.py.
import os


from support import (
    check_member_uniqueness,
    check_required_fields,
    check_value_ranges,
    validate_file_format,
)

DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data")


# NB: agents address this tool by its registered name "quality_gate" —
# instruction text must use that name, not the Python function name.
def check_scheme_data(scheme_id: str, file_path: str) -> dict:
    """Validate scheme member-data file before downstream stages.

    Returns 'ok' or 'needs_review' with a diagnostic naming the failed check.
    Failed schemes route to the manual review queue without blocking the pipeline.
    """
    # Required fields under the practice's standardised member schema.
    required_fields = ["member_id", "dob", "gender", "annual_pension_gbp",
                       "commencement_date", "pension_type", "dependant_indicator"]

    file_check      = validate_file_format(file_path)              # Parse and schema check.
    completeness    = check_required_fields(file_path, required_fields)
    range_check     = check_value_ranges(file_path)                # Plausible ranges on ages, pensions, dates.
    duplicate_check = check_member_uniqueness(file_path)           # No duplicate member_id values.

    if not file_check["valid"]:
        return {"status": "needs_review", "stage": "ingestion",
                "issue": "file_format", "detail": file_check["detail"]}
    if completeness["missing_fields"]:
        return {"status": "needs_review", "stage": "ingestion",
                "issue": "missing_fields", "detail": completeness["missing_fields"]}
    if range_check["out_of_range_count"] > 0:
        return {"status": "needs_review", "stage": "ingestion",
                "issue": "value_ranges", "detail": range_check["out_of_range_count"]}
    if duplicate_check["duplicates_count"] > 0:
        return {"status": "needs_review", "stage": "ingestion",
                "issue": "duplicate_members", "detail": duplicate_check["duplicates_count"]}

    return {"status": "ok", "stage": "ingestion",
            "member_count": completeness["member_count"]}


if __name__ == "__main__":
    members_file = os.path.join(DATA_DIR, "uk_annuity_members.csv")
    print(check_scheme_data("UKDB-MER-001", members_file))

01_pension_valuation.py

runs in your browserlive agent on GeminiSource on GitHub

Technical provisions, funding target, and the sensitivity panel.

01_pension_valuation.py
# Generated from ch15_pension_pipeline/01_pension_valuation.py for in-browser execution.
# Agno agent wiring is removed so the tool runs as a plain function;
# the full version is on GitHub. Do not edit: regenerate with
# scripts/build_demos.py.
# pension_valuation.py — funding valuation tool that returns the method's internals.
# Book reference: Chapter 15, "Architecture"
# Repo note: load_scheme_basis, compute_technical_provisions and
# compute_long_term_funding_target live in support.py.
import os
import sys

from support import (
    compute_long_term_funding_target,
    compute_technical_provisions,
    load_scheme_basis,
)


# NB: agents address this tool by its registered name "pension_valuation" —
# instruction text must use that name, not the Python function name.
def value_scheme(scheme_id: str, effective_date: str) -> dict:
    """Run a funding valuation on a closed UK DB scheme.

    Returns Technical Provisions and Long-Term Funding Target plus the
    sensitivity panel required under FRC TAS 300.
    """
    # Load scheme-specific assumption basis from the prior cycle's signed report.
    basis = load_scheme_basis(scheme_id, effective_date)

    # Base-case Technical Provisions and LTFT under the TPR Funding Code 2024.
    tp_base   = compute_technical_provisions(scheme_id, basis)
    ltft_base = compute_long_term_funding_target(scheme_id, basis)

    # Sensitivity panel — the method internals the Scheme Actuary reviews.
    sensitivity_panel = {
        "discount_rate_+100bp": compute_technical_provisions(scheme_id, basis.shift("dr", +100)),
        "discount_rate_-100bp": compute_technical_provisions(scheme_id, basis.shift("dr", -100)),
        "longevity_+25pct":     compute_technical_provisions(scheme_id, basis.shift("long", +0.25)),
        "longevity_-25pct":     compute_technical_provisions(scheme_id, basis.shift("long", -0.25)),
        "inflation_+50bp":      compute_technical_provisions(scheme_id, basis.shift("inf", +50)),
        "inflation_-50bp":      compute_technical_provisions(scheme_id, basis.shift("inf", -50)),
    }

    return {
        "status":                       "ok",
        "technical_provisions_gbp":     tp_base,
        "long_term_funding_target_gbp": ltft_base,
        "sensitivity_panel":            sensitivity_panel,
        "assumption_basis":             basis.as_dict(),  # mortality table version, scale id, etc.
    }


if __name__ == "__main__":
    result = value_scheme("UKDB-MER-001", "2025-03-31")
    print(f"status: {result['status']}")
    print(f"technical_provisions_gbp:     {result['technical_provisions_gbp']:,.0f}")
    print(f"long_term_funding_target_gbp: {result['long_term_funding_target_gbp']:,.0f}")
    print("sensitivity_panel:")
    for scenario, value in result["sensitivity_panel"].items():
        print(f"  {scenario:<22} {value:,.0f}")
    print(f"assumption_basis: {result['assumption_basis']}")

03_member_communication.py

runs in your browserlive agent on GeminiSource on GitHub

Drafts an annual annuity statement with source-traced citations.

03_member_communication.py
# Generated from ch15_pension_pipeline/03_member_communication.py for in-browser execution.
# Agno agent wiring is removed so the tool runs as a plain function;
# the full version is on GitHub. Do not edit: regenerate with
# scripts/build_demos.py.
# draft_member_communication.py — annual annuity statement with source-tracing.
# Book reference: Chapter 15, "Architecture" and "Results"
# Repo note: fetch_member_record and generate_statement_prose live in
# support.py.
import os
import sys

from support import fetch_member_record, generate_statement_prose


# NB: agents address this tool by its registered name "draft_member_communication" —
# instruction text must use that name, not the Python function name.
def draft_annual_statement(member_id: str, valuation_date: str, valuation_output: dict) -> dict:
    """Draft an annual annuity statement for a member of a closed UK DB scheme.

    Produces structured prose with a citations field. The citations render
    source data points visible to the member, not only the Fellow reviewer —
    the audience adjustment for consumer-facing output.
    """
    # Read the member record and the scheme-level valuation output from upstream stages.
    member_record = fetch_member_record(member_id, valuation_date)
    scheme_basis  = valuation_output["assumption_basis"]

    pension_paid_in_year = member_record["pension_paid_year_gbp"]
    # Only escalating pensions move with the scheme basis; a level pension
    # is paid at the same amount every year (same rule as _annuity_value).
    is_escalating        = member_record["pension_type"] == "escalating"
    escalation_index     = scheme_basis["inflation_assumption"] if is_escalating else 0.0
    pension_next_year    = pension_paid_in_year * (1 + escalation_index)

    statement_text = generate_statement_prose(member_record, pension_next_year, escalation_index)

    # Citations — every numerical claim back-traces to a named source.
    citations = {
        "pension_paid_in_year_gbp": "member_record.pension_paid_year_gbp",
        "escalation_index":         (f"scheme_basis.inflation_assumption ({scheme_basis['inflation_basis']})"
                                     if is_escalating else "member_record.pension_type = level (no escalation)"),
        "pension_next_year_gbp":    "computed: pension_paid_in_year * (1 + escalation_index)",
        "mortality_table":          scheme_basis["mortality_table_version"],
    }

    return {
        "status":          "ok",
        "member_id":       member_id,
        "statement_text":  statement_text,
        "citations":       citations,
    }


if __name__ == "__main__":
    # Build a valuation output by running the Ch 15 valuation tool directly.
    import importlib
    value_scheme = importlib.import_module("01_pension_valuation").value_scheme
    valuation_output = value_scheme("UKDB-MER-001", "2025-03-31")
    result = draft_annual_statement(
        member_id="UKA-00007", valuation_date="2025-03-31",
        valuation_output=valuation_output)
    print(f"status: {result['status']}   member: {result['member_id']}")
    print(f"statement: {result['statement_text']}")
    print("citations:")
    for claim, source in result["citations"].items():
        print(f"  {claim}: {source}")

04_ingestion_agent.py

live agent on GeminiSource on GitHub

An agent gates the member file through the quality checks.

about 30s · live model callOpen in Colab
# Repo demo (not a printed listing): a simple ingestion agent
# exercising the Chapter 15 quality_gate tool. The tool itself, and its
# direct test, live in 02_quality_gate.py — run that first to see the
# raw structured-status dict the agent consumes here.
import importlib
import os

from agno.agent import Agent
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))  # for common/
from common.config import get_model

DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data")

# Numeric module names can't be imported with a plain import statement.
check_scheme_data = importlib.import_module(
    "02_quality_gate").check_scheme_data

# The administrator names a scheme and file; the agent runs the gate
# and reports pass / route to manual review.
ingestion_agent = Agent(
    model=get_model(),
    tools=[check_scheme_data],
    tool_call_limit=3,
    markdown=True,
    instructions=(
        "Run the quality_gate tool on the named scheme and member-data "
        "file, then report whether the scheme passes ingestion or "
        "routes to the manual review queue, naming any failed check. "
        "Only call the tools you have been given."
    ),
)

if __name__ == "__main__":
    members_file = os.path.join(DATA_DIR, "uk_annuity_members.csv")
    ingestion_agent.print_response(
        f"Run the ingestion quality gate for scheme UKDB-MER-001 on the "
        f"member file {members_file} and report the result.",
        stream=True,
    )

Runs the unmodified chapter script on the server and streams the agent's tool calls and reasoning here.

Shared helper modules for this chapter live in support.py.