Functional Weave
Code in Python

insurance.no-claims-discount Unreviewed

No-claims discount at renewal: earn a year, or step back after claims on the insurer's scale, with NCD protection.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 25 tests, run in TypeScript, Python and Rust.

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified actuary has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates insurance figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have an actuary review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

The no-claims discount (NCD, or no-claims bonus) a policyholder holds at renewal, from the years they held, the claims in the year just ended and the insurer's scale.

## The scale is the caller's

For example

  • next_no_claims_discount(scale ×7, 0, 0, —) → years 1, discount basis points 30%, protection applied false a first claim-free year earns 30%
  • next_no_claims_discount(scale ×7, 4, 0, —) → years 5, discount basis points 65%, protection applied false four years claim-free becomes five: 65%
  • next_no_claims_discount(scale ×7, 5, 0, —) → years 6, discount basis points 65%, protection applied false six years sits on the 5-year level until 9 years

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def next_no_claims_discount(scale: Sequence[NcdLevel], current_years: int, claims: int, protection: Optional[NcdProtection]) -> NcdOutcome
scaleNcdLevel[]the insurer's NCD scale, from 0 years upwards
current_yearsintclaim-free years held at the start of the policy year just ended
claimsintclaims counting against NCD in that policy year
protectionNcdProtection?the protected NCD terms, or null when NCD is not protected
returnsNcdOutcome

The types it declares, generated into your project

@dataclass(frozen=True)
class NcdLevel:
    """One step of an NCD scale."""

    #: claim-free years from which this level applies
    years: int
    #: discount at this level, 6500 = 65%
    discount_basis_points: int
    #: years held after 1, 2, 3... claims from this level; more claims than listed use the last
    step_back: List[int]

@dataclass(frozen=True)
class NcdProtection:
    """Protected NCD: claims up to a limit in a window leave the years untouched."""

    #: the most claims the protection allows in its window, e.g. 2 in 5 years
    claim_limit: int
    #: claims in the window, this year's included
    claims_in_window: int

@dataclass(frozen=True)
class NcdOutcome:
    """The NCD held at renewal."""

    years: int
    discount_basis_points: int
    #: true when protection kept the years from stepping back
    protection_applied: bool

Your code names it in one line, in the file that uses it

from fune.insurance.no_claims_discount import next_no_claims_discount  # insurance.no-claims-discount@^1
impl/python.py · 73 lines · open · raw
from typing import Optional, Sequence

from .insurance_no_claims_discount_types import NcdLevel, NcdOutcome, NcdProtection


def _whole_at_least_zero(value: object, name: str) -> None:
    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
        raise ValueError("%s must be a whole number of 0 or more, received %s" % (name, value))


def _level_for(scale: Sequence[NcdLevel], years: int) -> NcdLevel:
    found = scale[0]
    for level in scale:
        if level.years <= years:
            found = level
    return found


def next_no_claims_discount(
    scale: Sequence[NcdLevel], current_years: int, claims: int, protection: Optional[NcdProtection]
) -> NcdOutcome:
    """The no-claims discount held at renewal.

    A claim-free year earns a year. A year with claims steps back to what the
    scale says for the level held and the number of claims, unless protected
    NCD covers them, in which case the years stay where they were (a year with
    a claim earns nothing). Years keep counting past the top of the scale; the
    discount is the highest level not above them.
    """
    if len(scale) == 0:
        raise ValueError("the NCD scale must not be empty")
    if scale[0].years != 0:
        raise ValueError("the NCD scale must start at 0 years")
    for i, level in enumerate(scale):
        _whole_at_least_zero(level.years, "years")
        if i > 0 and level.years <= scale[i - 1].years:
            raise ValueError("the NCD scale must be in ascending order of years")
        bp = level.discount_basis_points
        if isinstance(bp, bool) or not isinstance(bp, int) or bp < 0 or bp > 10000:
            raise ValueError("discountBasisPoints must be from 0 to 10000, received %s" % (bp,))
        previous = level.years
        for back in level.step_back:
            _whole_at_least_zero(back, "stepBack")
            if back > previous:
                raise ValueError(
                    "stepBack from %d years must not rise above the level or the step before it" % (level.years,)
                )
            previous = back
    _whole_at_least_zero(current_years, "currentYears")
    _whole_at_least_zero(claims, "claims")

    protection_applied = False
    if claims == 0:
        years = current_years + 1
    else:
        covered = False
        if protection is not None:
            _whole_at_least_zero(protection.claim_limit, "claimLimit")
            _whole_at_least_zero(protection.claims_in_window, "claimsInWindow")
            if protection.claims_in_window < claims:
                raise ValueError("claimsInWindow must include this year's claims")
            covered = protection.claims_in_window <= protection.claim_limit
        if covered:
            years = current_years
            protection_applied = True
        else:
            steps = _level_for(scale, current_years).step_back
            years = 0 if len(steps) == 0 else steps[min(claims, len(steps)) - 1]
    return NcdOutcome(
        years=years,
        discount_basis_points=_level_for(scale, years).discount_basis_points,
        protection_applied=protection_applied,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add insurance.no-claims-discount
Download for Python insurance.no-claims-discount-1.0.1-python.fune · 32,819 bytes sha256 5f4406243479763d454681262288e9c0f099d168acfbd4ec0e540fab354b1147

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./insurance.no-claims-discount-1.0.1-python.fune, or fetch it from a terminal with fune pull insurance.no-claims-discount@1.0.1:python.

The whole function, every language, is one file too: insurance.no-claims-discount-1.0.1.fune, 40,702 bytes, sha256 27923c7f066fa8f20dfcb109cdee16c333ccada33e48bdf747d60c63e1e3d800. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before insurance.no-claims-discount

after — your function gets the result and the arguments, and returns the final result.

# fune: after insurance.no-claims-discount

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show insurance.no-claims-discount --steps.

# fune: step insurance.no-claims-discount after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a first claim-free year earns 30% scale ×7, 0, 0, — → years 1, discount basis points 30%, protection applied false
four years claim-free becomes five: 65% scale ×7, 4, 0, — → years 5, discount basis points 65%, protection applied false
six years sits on the 5-year level until 9 years scale ×7, 5, 0, — → years 6, discount basis points 65%, protection applied false
reaching 9 years: the top of the scale scale ×7, 8, 0, — → years 9, discount basis points 70%, protection applied false
years keep counting past the top; the discount stays at the top scale ×7, 12, 0, — → years 13, discount basis points 70%, protection applied false
one claim at 5 years steps back to 3 years: 50% scale ×7, 5, 1, — → years 3, discount basis points 50%, protection applied false
two claims at 5 years step back to 1 year scale ×7, 5, 2, — → years 1, discount basis points 30%, protection applied false
three claims at 5 years lose it all scale ×7, 5, 3, — → years 0, discount basis points 0%, protection applied false
more claims than the scale lists use its last step scale ×7, 5, 4, — → years 0, discount basis points 0%, protection applied false
above the top level the top level's step-back applies: 12 years, one claim, 7 years at 65% scale ×7, 12, 1, — → years 7, discount basis points 65%, protection applied false
Show the other 15 tests
CaseArgumentsExpected
between levels the level below applies: 7 years, one claim, the 5-year step to 3 scale ×7, 7, 1, — → years 3, discount basis points 50%, protection applied false
one claim at 1 year loses it scale ×7, 1, 1, — → years 0, discount basis points 0%, protection applied false
a claim with no NCD stays at 0 scale ×7, 0, 1, — → years 0, discount basis points 0%, protection applied false
protected NCD, one claim of two allowed: years kept, none earned scale ×7, 9, 1, claim limit 2, claims in window 1 → years 9, discount basis points 70%, protection applied true
protected NCD, the second allowed claim in the window is still covered scale ×7, 9, 1, claim limit 2, claims in window 2 → years 9, discount basis points 70%, protection applied true
protected NCD exceeded: the third claim in the window steps back as usual scale ×7, 9, 1, claim limit 2, claims in window 3 → years 7, discount basis points 65%, protection applied false
protection with no claims earns a year as normal scale ×7, 9, 0, claim limit 2, claims in window 0 → years 10, discount basis points 70%, protection applied false
an empty scale is refused , 3, 0, — → error: the NCD scale must not be empty
a scale must start at 0 years scale ×1, 3, 0, — → error: the NCD scale must start at 0 years
a scale out of order is refused scale ×3, 3, 0, — → error: ascending order of years
a step-back above the level is refused scale ×2, 2, 1, — → error: must not rise above the level
a discount above 100% is refused scale ×2, 1, 0, — → error: discountBasisPoints must be from 0 to 10000
negative claims are refused scale ×7, 3, -1, — → error: claims must be a whole number of 0 or more
fractional years are refused scale ×7, 2.5, 0, — → error: currentYears must be a whole number of 0 or more
a protection window without this year's claims is refused scale ×7, 5, 2, claim limit 2, claims in window 1 → error: claimsInWindow must include this year's claims

More from the author

Every insurer publishes its own scale: the discount at each number of claim-free years, and how far a claim steps you back. So the scale is an argument, not data in this package. Each level says:

- `years`: the claim-free years from which it applies (the scale starts at 0 and rises; gaps are fine: years 6 to 8 on a scale with levels at 5 and 9 get the 5-year discount); - `discountBasisPoints`: its discount; - `stepBack`: the years held after 1, 2, 3... claims in a year from this level. More claims than listed use the last entry, and an empty list means any claim goes back to 0. A step never rises above the level or the step before it.

The vectors use an illustrative scale (0, 30, 40, 50, 60, 65% up to 5 years, 70% at 9; one claim at 5 years steps back to 3), not any insurer's.

## The rules applied

- A claim-free year earns one year. Years keep counting beyond the top of the scale (UK insurers quote "9+ years"), and the discount is that of the highest level not above them. - A year with claims steps back by the level the policyholder held at the start of the year: 12 years with a top level at 9 uses the 9-year steps. - **Protected NCD**: when `claimsInWindow` (this year's claims included) is within `claimLimit` (commonly 2 claims in 5 years), the years stay as they were. The claim year earns no extra year, which is how protection is usually sold: it protects the discount, not the premium. Beyond the limit the normal step-back applies. Some insurers do let a protected claim year earn; add one to `years` if yours does.

Which claims count against NCD (a non-fault claim where costs are fully recovered usually does not) is for the caller to decide before calling. Applying the discount to a premium is `money.apply-rate` or `insurance.rating-factors`.

## Before you rely on this

**Not professional advice.** This capability calculates insurance figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have an actuary review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified actuary has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

1.0.1 marks it unreviewed. The code and the tests are unchanged.

Files

PathBytes
README.md3,108
impl/python.py3,125
impl/rust.rs4,525
impl/typescript.ts3,038
vectors.json20,338