Functional Weave
Code in Rust

insurance.rating-factors Unreviewed

Premium from a base rate times factor-table lookups, loadings and discounts, within minimum and maximum premiums.

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

Pinned by 24 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

A premium from a base rate multiplied by rating factors (relativities) looked up for the risk, then by loadings and discounts, held within a minimum and a maximum premium. This is the multiplicative model most personal-lines and many commercial rating engines use.

## Inputs

For example

  • rate_premium(£400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , —, —, half-up) → factors ×3, calculated £712.80, premium £712.80, cap none 400.00 x 1.10 x 1.35 x 1.20 is 712.80
  • rate_premium(£400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×1, —, —, half-up) → factors ×4, calculated £641.52, premium £641.52, cap none a 10% multi-car discount multiplies by 0.90
  • rate_premium(£400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×2, —, —, half-up) → factors ×5, calculated £801.90, premium £801.90, cap none a 25% conviction loading and a 10% discount: 712.80 x 1.25 x 0.90

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 rate_premium(base_premium: &Money, tables: &[FactorTable], risk: &[(String, String)], adjustments: &[PremiumAdjustment], minimum_premium: Option<&Money>, maximum_premium: Option<&Money>, mode: &str) -> RatedPremium
base_premiumMoneythe base rate before any factor, 0 or more
tablesFactorTable[]the rating factor tables, applied in order
riskmap<string>the risk's value for each table, by table name: {"area": "C", "vehicleGroup": "11-20"}
adjustmentsPremiumAdjustment[]loadings (positive) and discounts (negative) applied after the tables
minimum_premiumMoney?the least the premium may be, or null
maximum_premiumMoney?the most the premium may be, or null
modeRoundingModehow the one final rounding to a minor unit goes
returnsRatedPremium

The types it declares, generated into your project

/// One rating factor: a multiplier for each value the risk can take.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FactorTable {
    /// the factor, e.g. area or driverAge
    pub name: String,
    pub rows: Vec<FactorRow>,
}

/// One value of a factor and its multiplier.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FactorRow {
    pub key: String,
    /// multiplier in basis points: 10000 = 1.0, 13500 = 1.35
    pub basis_points: i64,
}

/// A loading or discount, as a change to the premium.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PremiumAdjustment {
    pub name: String,
    /// 2500 = 25% loading, -1000 = 10% discount; -10000 at the least
    pub basis_points: i64,
}

/// A multiplier used, in the order it was applied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedFactor {
    pub name: String,
    /// the multiplier: 10000 = 1.0, 9000 for a 10% discount
    pub basis_points: i64,
}

// PremiumCap is a string in Rust, one of: "none", "minimum", "maximum".
// Parameters take it as &str and results hold it as String.

/// The premium and how it was reached.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RatedPremium {
    /// table factors then adjustments, as multipliers
    pub factors: Vec<AppliedFactor>,
    /// base times every multiplier, rounded once, before the caps
    pub calculated: Money,
    /// calculated, held within the minimum and maximum premiums
    pub premium: Money,
    /// which cap, if any, set the premium
    pub cap: String,
}

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

fune!(insurance.rating-factors@^1);  // then call rate_premium(…)
impl/rust.rs · 202 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 std::cmp::Ordering;

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_big_integer::BigInt;  ← from math.big-integer ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

fn round_quotient(numerator: &BigInt, denominator: &BigInt, mode: &str) -> i64 {
    // Both are non-negative here: a base of 0 or more times multipliers of 0 or more.
    let (q, r) = numerator.div_rem(denominator);
    let twice = r.mul(&BigInt::from_i64(2));
    let one = BigInt::from_i64(1);
    let up = match mode {
        "down" => false,
        "up" => !r.is_zero(),
        "half-up" => twice.cmp(denominator) != Ordering::Less,
        "half-even" => match twice.cmp(denominator) {
            Ordering::Greater => true,
            Ordering::Equal => !q.rem(&BigInt::from_i64(2)).is_zero(),
            Ordering::Less => false,
        },
        other => panic!("unknown rounding mode \"{}\"", other),
    };
    if up {
        q.add(&one).to_i64()
    } else {
        q.to_i64()
    }
}

/// Rate a premium: the base times each table's multiplier for the risk, then
/// each loading or discount, with one rounding at the end, then held within
/// the minimum and maximum premiums.
///
/// Rounding after every factor drifts: 1.01 at 1.005 three times is 1.03
/// exactly but 1.04 rounded at each step. The product is exact integer
/// arithmetic of any size (math.big-integer), so there is no limit on the
/// number of factors.
///
/// # Panics
/// Panics on a negative base, a missing, unknown or duplicated factor value,
/// a bad multiplier, caps out of order or in another currency.
pub fn rate_premium(
    base_premium: &Money,
    tables: &[FactorTable],
    risk: &[(String, String)],
    adjustments: &[PremiumAdjustment],
    minimum_premium: Option<&Money>,
    maximum_premium: Option<&Money>,
    mode: &str,
) -> RatedPremium {
    if base_premium.minor < 0 {
        panic!("basePremium must not be negative, received {}", base_premium.minor);
    }
    if let Some(m) = minimum_premium {
        assert_same_currency(base_premium, m);
    }
    if let Some(m) = maximum_premium {
        assert_same_currency(base_premium, m);
    }
    if let (Some(lo), Some(hi)) = (minimum_premium, maximum_premium) {
        if lo.minor > hi.minor {
            panic!("minimumPremium must not be more than maximumPremium");
        }
    }

    let mut factors: Vec<AppliedFactor> = Vec::new();
    let mut names: Vec<&str> = Vec::new();
    for table in tables {
        if names.contains(&table.name.as_str()) {
            panic!("rating factor \"{}\" appears twice", table.name);
        }
        names.push(&table.name);
        let key = match risk.iter().find(|(k, _)| *k == table.name) {
            Some((_, v)) => v,
            None => panic!("no value for rating factor \"{}\"", table.name),
        };
        let mut found: Option<i64> = None;
        for row in &table.rows {
            if row.key == *key {
                if found.is_some() {
                    panic!("rating factor \"{}\" has two rows for \"{}\"", table.name, key);
                }
                if row.basis_points < 0 {
                    panic!("factor basisPoints must be a whole number of 0 or more, received {}", row.basis_points);
                }
                found = Some(row.basis_points);
            }
        }
        match found {
            Some(bp) => factors.push(AppliedFactor { name: table.name.clone(), basis_points: bp }),
            None => panic!("rating factor \"{}\" has no row for \"{}\"", table.name, key),
        }
    }
    for (name, _) in risk {
        if !names.contains(&name.as_str()) {
            panic!("risk has a value for \"{}\", which no table rates", name);
        }
    }
    for adjustment in adjustments {
        if adjustment.basis_points < -10000 {
            panic!(
                "adjustment basisPoints must be a whole number of -10000 or more, received {}",
                adjustment.basis_points
            );
        }
        factors.push(AppliedFactor {
            name: adjustment.name.clone(),
            basis_points: 10000 + adjustment.basis_points,
        });
    }

    let mut numerator = BigInt::from_i64(base_premium.minor);
    let mut denominator = BigInt::from_i64(1);
    let scale = BigInt::from_i64(10000);
    for f in &factors {
        numerator = numerator.mul(&BigInt::from_i64(f.basis_points));
        denominator = denominator.mul(&scale);
    }
    let c = &base_premium.currency;
    let calculated = money(round_quotient(&numerator, &denominator, mode), c);
    let mut premium = calculated.clone();
    let mut cap = "none";
    if let Some(lo) = minimum_premium.filter(|lo| premium.minor < lo.minor) {
        premium = lo.clone();
        cap = "minimum";
    } else if let Some(hi) = maximum_premium.filter(|hi| premium.minor > hi.minor) {
        premium = hi.clone();
        cap = "maximum";
    }
    RatedPremium {
        factors,
        calculated,
        premium,
        cap: cap.to_string(),
    }
}

fn int_field(v: &Value, key: &str, message: &str) -> i64 {
    if let Value::Float(f) = v.get(key) {
        panic!("{}, received {}", message, f);
    }
    v.get(key).as_i64()
}

pub fn rated_premium_to_value(r: &RatedPremium) -> Value {
    Value::obj(vec![
        (
            "factors",
            Value::Arr(
                r.factors
                    .iter()
                    .map(|f| Value::obj(vec![("name", Value::str(&f.name)), ("basisPoints", Value::Int(f.basis_points))]))
                    .collect(),
            ),
        ),
        ("calculated", money_to_value(&r.calculated)),
        ("premium", money_to_value(&r.premium)),
        ("cap", Value::str(&r.cap)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let tables: Vec<FactorTable> = args[1]
        .as_arr()
        .iter()
        .map(|t| FactorTable {
            name: t.get("name").as_str().to_string(),
            rows: t
                .get("rows")
                .as_arr()
                .iter()
                .map(|r| FactorRow {
                    key: r.get("key").as_str().to_string(),
                    basis_points: int_field(r, "basisPoints", "factor basisPoints must be a whole number of 0 or more"),
                })
                .collect(),
        })
        .collect();
    let risk: Vec<(String, String)> = match &args[2] {
        Value::Obj(pairs) => pairs.iter().map(|(k, v)| (k.clone(), v.as_str().to_string())).collect(),
        _ => Vec::new(),
    };
    let adjustments: Vec<PremiumAdjustment> = args[3]
        .as_arr()
        .iter()
        .map(|a| PremiumAdjustment {
            name: a.get("name").as_str().to_string(),
            basis_points: int_field(a, "basisPoints", "adjustment basisPoints must be a whole number of -10000 or more"),
        })
        .collect();
    let minimum = if args[4].is_null() { None } else { Some(money_from_value(&args[4])) };
    let maximum = if args[5].is_null() { None } else { Some(money_from_value(&args[5])) };
    rated_premium_to_value(&rate_premium(
        &money_from_value(&args[0]),
        &tables,
        &risk,
        &adjustments,
        minimum.as_ref(),
        maximum.as_ref(),
        args[6].as_str(),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, 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.rating-factors
Download for Rust insurance.rating-factors-1.0.1-rust.fune · 37,988 bytes sha256 c98a967f89c4e971535c25b3fba66431e37e8a7931c65796e5d7373e7b752d9c

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

The whole function, every language, is one file too: insurance.rating-factors-1.0.1.fune, 46,983 bytes, sha256 ce4334fca52247b4674c4c0994edd03dd8a34c79973ffbe7f84a108accd00508. 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.rating-factors

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

// fune: after insurance.rating-factors

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace math.big-integer in insurance.rating-factors
// fune: replace math.round-div in insurance.rating-factors
// fune: replace money.amount in insurance.rating-factors

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.rating-factors --steps.

// fune: step insurance.rating-factors 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
400.00 x 1.10 x 1.35 x 1.20 is 712.80 £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , —, —, half-up → factors ×3, calculated £712.80, premium £712.80, cap none
a 10% multi-car discount multiplies by 0.90 £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×1, —, —, half-up → factors ×4, calculated £641.52, premium £641.52, cap none
a 25% conviction loading and a 10% discount: 712.80 x 1.25 x 0.90 £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×2, —, —, half-up → factors ×5, calculated £801.90, premium £801.90, cap none
333.33 x 1.80 x 1.35 x 0.95 is 769.492305, rounded once £333.33, tables ×3, driver age 17-24, area C, vehicle group 1-10, , —, —, half-up → factors ×3, calculated £769.49, premium £769.49, cap none
the same rounded up £333.33, tables ×3, driver age 17-24, area C, vehicle group 1-10, , —, —, up → factors ×3, calculated £769.50, premium £769.50, cap none
one rounding, not three: 1.01 x 1.005^3 is 1.0252..., so 1.03, where rounding each step gives 1.04 £1.01, tables ×3, a x, b x, c x, , —, —, half-up → factors ×3, calculated £1.03, premium £1.03, cap none
a minimum premium lifts a low result £100.00, tables ×1, area A, , £150.00, —, half-up → factors ×1, calculated £90.00, premium £150.00, cap minimum
a maximum premium caps a high one £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , —, £500.00, half-up → factors ×3, calculated £712.80, premium £500.00, cap maximum
inside both caps nothing changes £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , £150.00, £1,000.00, half-up → factors ×3, calculated £712.80, premium £712.80, cap none
a result exactly the minimum is not capped £150.00, , , , £150.00, —, half-up → factors , calculated £150.00, premium £150.00, cap none
Show the other 14 tests
CaseArgumentsExpected
no tables and no adjustments leave the base £250.00, , , , —, —, half-up → factors , calculated £250.00, premium £250.00, cap none
a 100% discount gives zero, then the minimum £250.00, , , adjustments ×1, £50.00, —, half-up → factors ×1, calculated £0.00, premium £50.00, cap minimum
half a penny rounds to even £0.01, , , adjustments ×1, —, —, half-even → factors ×1, calculated £0.00, premium £0.00, cap none
half a penny rounds up half-up £0.01, , , adjustments ×1, —, —, half-up → factors ×1, calculated £0.01, premium £0.01, cap none
a table without a value for the risk is refused £400.00, tables ×3, driver age 25-39, area C, , —, —, half-up → error: no value for rating factor "vehicleGroup"
a value the table has no row for is refused £400.00, tables ×3, driver age 25-39, area Z, vehicle group 1-10, , —, —, half-up → error: rating factor "area" has no row for "Z"
a risk value no table rates is refused £400.00, tables ×1, area A, colour red, , —, —, half-up → error: risk has a value for "colour", which no table rates
a table named twice is refused £400.00, tables ×2, area A, , —, —, half-up → error: rating factor "area" appears twice
a discount beyond 100% is refused £400.00, , , adjustments ×1, —, —, half-up → error: adjustment basisPoints must be a whole number of -10000 or more
a negative factor is refused £400.00, tables ×1, area A, , —, —, half-up → error: factor basisPoints must be a whole number of 0 or more
a fractional factor is refused £400.00, tables ×1, area A, , —, —, half-up → error: factor basisPoints must be a whole number of 0 or more
a minimum above the maximum is refused £400.00, , , , £200.00, £100.00, half-up → error: minimumPremium must not be more than maximumPremium
a negative base is refused -£0.01, , , , —, —, half-up → error: basePremium must not be negative
a minimum in another currency is refused £400.00, , , , €1.00, —, half-up → error: currency mismatch

More from the author

- **tables**: one per rating factor (area, driver age band, vehicle group, occupation...), each a list of `key -> multiplier` rows. Multipliers are basis points: 10000 is 1.0, 13500 is 1.35, 9000 is 0.9. The tables are the insurer's own and change by the insurer's release, so they are arguments rather than data in this package. - **risk**: the risk's value for each table, by table name. Every table needs a value, every value needs a row, and a value for a factor no table rates is an error rather than being ignored (a misspelt factor name would otherwise silently rate at 1.0). Numeric factors are banded first, e.g. with `insurance.age-banding`. - **adjustments**: loadings and discounts after the tables, as changes: 2500 is a 25% loading, multiplying by 1.25; -1000 a 10% discount, multiplying by 0.9. They multiply, one after another, like the table factors: a 25% loading and a 10% discount come to 1.125, not 1.15. -10000 (free) is the lowest allowed.

## One rounding

The base is multiplied by every multiplier in exact integer arithmetic (math.big-integer in Rust, native big integers in TypeScript and Python), and rounded to a minor unit once, in the caller's mode. Rounding after each factor drifts: 1.01 at 1.005 three times is 1.0252, so 1.03, but 1.04 when rounded at every step. Order therefore does not change the answer.

## Minimum and maximum premium

Applied last, to the rounded result. `calculated` is the premium before them, `premium` after, and `cap` says which one, if either, set it. A result exactly equal to a cap is not reported as capped.

Not covered: additive loadings in money (a flat 25.00 policy fee), which are added after rating, and IPT, which is `insurance.ipt` on the final premium.

## 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,132
impl/python.py4,338
impl/rust.rs7,253
impl/typescript.ts4,338
vectors.json19,194