Functional Weave
Code in Rust

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.

pub fn next_no_claims_discount(scale: &[NcdLevel], current_years: i64, claims: i64, protection: Option<&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

/// One step of an NCD scale.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NcdLevel {
    /// claim-free years from which this level applies
    pub years: i64,
    /// discount at this level, 6500 = 65%
    pub discount_basis_points: i64,
    /// years held after 1, 2, 3... claims from this level; more claims than listed use the last
    pub step_back: Vec<i64>,
}

/// Protected NCD: claims up to a limit in a window leave the years untouched.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NcdProtection {
    /// the most claims the protection allows in its window, e.g. 2 in 5 years
    pub claim_limit: i64,
    /// claims in the window, this year's included
    pub claims_in_window: i64,
}

/// The NCD held at renewal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NcdOutcome {
    pub years: i64,
    pub discount_basis_points: i64,
    /// true when protection kept the years from stepping back
    pub protection_applied: bool,
}

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

fune!(insurance.no-claims-discount@^1);  // then call next_no_claims_discount(…)
impl/rust.rs · 136 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

fn whole_at_least_zero(value: i64, name: &str) {
    if value < 0 {
        panic!("{} must be a whole number of 0 or more, received {}", name, value);
    }
}

fn level_for(scale: &[NcdLevel], years: i64) -> &NcdLevel {
    let mut found = &scale[0];
    for level in scale {
        if level.years <= years {
            found = level;
        }
    }
    found
}

/// 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.
///
/// # Panics
/// Panics on a malformed scale, negative counts, or a protection window that
/// leaves out this year's claims.
pub fn next_no_claims_discount(
    scale: &[NcdLevel],
    current_years: i64,
    claims: i64,
    protection: Option<&NcdProtection>,
) -> NcdOutcome {
    if scale.is_empty() {
        panic!("the NCD scale must not be empty");
    }
    if scale[0].years != 0 {
        panic!("the NCD scale must start at 0 years");
    }
    for (i, level) in scale.iter().enumerate() {
        whole_at_least_zero(level.years, "years");
        if i > 0 && level.years <= scale[i - 1].years {
            panic!("the NCD scale must be in ascending order of years");
        }
        if !(0..=10000).contains(&level.discount_basis_points) {
            panic!("discountBasisPoints must be from 0 to 10000, received {}", level.discount_basis_points);
        }
        let mut previous = level.years;
        for back in &level.step_back {
            whole_at_least_zero(*back, "stepBack");
            if *back > previous {
                panic!(
                    "stepBack from {} 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");

    let mut protection_applied = false;
    let years = if claims == 0 {
        current_years + 1
    } else {
        let mut covered = false;
        if let Some(p) = protection {
            whole_at_least_zero(p.claim_limit, "claimLimit");
            whole_at_least_zero(p.claims_in_window, "claimsInWindow");
            if p.claims_in_window < claims {
                panic!("claimsInWindow must include this year's claims");
            }
            covered = p.claims_in_window <= p.claim_limit;
        }
        if covered {
            protection_applied = true;
            current_years
        } else {
            let steps = &level_for(scale, current_years).step_back;
            if steps.is_empty() {
                0
            } else {
                steps[(claims as usize).min(steps.len()) - 1]
            }
        }
    };
    NcdOutcome {
        years,
        discount_basis_points: level_for(scale, years).discount_basis_points,
        protection_applied,
    }
}

fn int_arg(v: &Value, name: &str) -> i64 {
    if let Value::Float(f) = v {
        panic!("{} must be a whole number of 0 or more, received {}", name, f);
    }
    v.as_i64()
}

pub fn ncd_level_from_value(v: &Value) -> NcdLevel {
    NcdLevel {
        years: int_arg(v.get("years"), "years"),
        discount_basis_points: v.get("discountBasisPoints").as_i64(),
        step_back: v.get("stepBack").as_arr().iter().map(|s| int_arg(s, "stepBack")).collect(),
    }
}

pub fn ncd_outcome_to_value(o: &NcdOutcome) -> Value {
    Value::obj(vec![
        ("years", Value::Int(o.years)),
        ("discountBasisPoints", Value::Int(o.discount_basis_points)),
        ("protectionApplied", Value::Bool(o.protection_applied)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let scale: Vec<NcdLevel> = args[0].as_arr().iter().map(ncd_level_from_value).collect();
    let protection = if args[3].is_null() {
        None
    } else {
        Some(NcdProtection {
            claim_limit: int_arg(args[3].get("claimLimit"), "claimLimit"),
            claims_in_window: int_arg(args[3].get("claimsInWindow"), "claimsInWindow"),
        })
    };
    ncd_outcome_to_value(&next_no_claims_discount(
        &scale,
        int_arg(&args[1], "currentYears"),
        int_arg(&args[2], "claims"),
        protection.as_ref(),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add insurance.no-claims-discount
Download for Rust insurance.no-claims-discount-1.0.1-rust.fune · 34,302 bytes sha256 37a31a59ff86ee297ec7eb7f88ac240c29232ae493ecaac1ecb35e2b2ed6e9b6

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

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