Functional Weave
Code in Rust

fleet.vehicle-depreciation

A vehicle's current value from its cost, age and mileage, on retention curves the caller supplies, exactly.

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

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

What it does

A vehicle's value today from what it cost, its age and its mileage, on retention curves you supply, and how much it has lost. There is no built-in market data: residual values differ by make, model and year, and belong to whoever publishes them (CAP HPI, Glass's, your own disposals). This does the arithmetic on the curve you have, exactly.

## The curves

For example

  • vehicle_depreciation(£20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, half-up) → value £10,922.87, depreciation £9,077.13, retained basis points 54.61% three years and 30,000 miles: 0.8 x 0.85 x 0.88 x 0.97^3 of 20,000.00
  • vehicle_depreciation(£20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, up) → value £10,922.88, depreciation £9,077.12, retained basis points 54.61% the same, rounded up
  • vehicle_depreciation(£20,000.00, 0, 0, 80%, 85%, 97%, half-up) → value £20,000.00, depreciation £0.00, retained basis points 100% brand new with no miles keeps its whole value

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 vehicle_depreciation(cost: &Money, age_months: i64, mileage: i64, retained_per_year_basis_points: &[i64], retained_per10k_miles_basis_points: i64, mode: &str) -> VehicleValue
costMoneywhat the vehicle cost new (or at the point the curves start from)
age_monthsintwhole months since then, 0 to 600
mileageintmiles on the clock, 0 to 2,000,000
retained_per_year_basis_pointsint[]share of value kept through each year of age, first year first: [8000, 8500, 8800]; the last repeats
retained_per10k_miles_basis_pointsintshare of value kept for every 10,000 miles: 9700 = loses 3% per 10k
modeRoundingModehow the exact value is rounded to a minor unit
returnsVehicleValue

The type it declares, generated into your project

/// What the vehicle is worth now and what it has lost.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VehicleValue {
    pub value: Money,
    /// cost less value
    pub depreciation: Money,
    /// the exact combined share kept, rounded half-up to a basis point
    pub retained_basis_points: i64,
}

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

fune!(fleet.vehicle-depreciation@^1);  // then call vehicle_depreciation(…)
impl/rust.rs · 115 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
use super::math_big_integer::BigInt;  ← from math.big-integer ^1.0.0 · built alongside by fune
use super::math_round_div_big::round_div_big;  ← from math.round-div-big ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

fn whole(name: &str, value: i64, max: i64) {
    if value < 0 || value > max {
        panic!("{} must be a whole number from 0 to {}, received {}", name, max, value);
    }
}

/// A vehicle's value from its age and mileage, on the caller's retention curves.
///
/// Each whole year multiplies by that year's retention and a part year by a
/// straight-line share of it; each whole 10,000 miles multiplies by the
/// mileage retention and the remaining miles by a straight-line share. The
/// product is kept as one exact fraction and rounded once: rounding the value
/// year by year drifts by a penny or more over a long life.
///
/// # Panics
/// Panics on a negative cost, an out-of-range age, mileage or rate, an empty
/// curve or an unknown mode.
pub fn vehicle_depreciation(
    cost: &Money,
    age_months: i64,
    mileage: i64,
    retained_per_year_basis_points: &[i64],
    retained_per10k_miles_basis_points: i64,
    mode: &str,
) -> VehicleValue {
    if cost.minor < 0 {
        panic!("cost must not be negative, received {}", cost.minor);
    }
    whole("ageMonths", age_months, 600);
    whole("mileage", mileage, 2000000);
    if retained_per_year_basis_points.is_empty() {
        panic!("retainedPerYearBasisPoints must have at least one year");
    }
    for bp in retained_per_year_basis_points {
        whole("a retention rate", *bp, 10000);
    }
    whole("a retention rate", retained_per10k_miles_basis_points, 10000);

    let last = retained_per_year_basis_points.len() - 1;
    let rate_for = |year: usize| retained_per_year_basis_points[year.min(last)];
    let ten_thousand = BigInt::from_i64(10000);
    let mut num = BigInt::from_i64(1);
    let mut den = BigInt::from_i64(1);
    let years = (age_months / 12) as usize;
    for year in 0..years {
        num = num.mul(&BigInt::from_i64(rate_for(year)));
        den = den.mul(&ten_thousand);
    }
    let months = age_months % 12;
    if months > 0 {
        num = num.mul(&BigInt::from_i64(120000 - (10000 - rate_for(years)) * months));
        den = den.mul(&BigInt::from_i64(120000));
    }
    let mile_rate = retained_per10k_miles_basis_points;
    for _ in 0..(mileage / 10000) {
        num = num.mul(&BigInt::from_i64(mile_rate));
        den = den.mul(&ten_thousand);
    }
    let miles = mileage % 10000;
    if miles > 0 {
        num = num.mul(&BigInt::from_i64(100000000 - (10000 - mile_rate) * miles));
        den = den.mul(&BigInt::from_i64(100000000));
    }

    let value: i64 = round_div_big(&BigInt::from_i64(cost.minor).mul(&num).to_string(), &den.to_string(), mode)
        .parse()
        .unwrap();
    let retained: i64 = round_div_big(&num.mul(&ten_thousand).to_string(), &den.to_string(), "half-up")
        .parse()
        .unwrap();
    VehicleValue {
        value: money(value, &cost.currency),
        depreciation: money(cost.minor - value, &cost.currency),
        retained_basis_points: retained,
    }
}

pub fn vehicle_value_to_value(v: &VehicleValue) -> Value {
    Value::obj(vec![
        ("value", money_to_value(&v.value)),
        ("depreciation", money_to_value(&v.depreciation)),
        ("retainedBasisPoints", Value::Int(v.retained_basis_points)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    for (i, name) in [(1usize, "ageMonths"), (2, "mileage"), (4, "a retention rate")] {
        if let Value::Float(f) = &args[i] {
            panic!("{} must be a whole number, received {}", name, f);
        }
    }
    let curve: Vec<i64> = args[3]
        .as_arr()
        .iter()
        .map(|v| {
            if let Value::Float(f) = v {
                panic!("a retention rate must be a whole number, received {}", f);
            }
            v.as_i64()
        })
        .collect();
    vehicle_value_to_value(&vehicle_depreciation(
        &money_from_value(&args[0]),
        args[1].as_i64(),
        args[2].as_i64(),
        &curve,
        args[4].as_i64(),
        args[5].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 4 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 fleet.vehicle-depreciation
Download for Rust fleet.vehicle-depreciation-1.0.0-rust.fune · 14,573 bytes sha256 fcc6b8be8fe52a2fb24272ef820cf8813752fd6a9cfbbd98fa3c8785809c9486

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./fleet.vehicle-depreciation-1.0.0-rust.fune, or fetch it from a terminal with fune pull fleet.vehicle-depreciation@1.0.0:rust.

The whole function, every language, is one file too: fleet.vehicle-depreciation-1.0.0.fune, 20,117 bytes, sha256 b50858107abbf776025a8428d9a393149ba85037572410721642ac50a82a6752. 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 fleet.vehicle-depreciation

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

// fune: after fleet.vehicle-depreciation

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 fleet.vehicle-depreciation
// fune: replace math.round-div in fleet.vehicle-depreciation
// fune: replace math.round-div-big in fleet.vehicle-depreciation
// fune: replace money.amount in fleet.vehicle-depreciation

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 fleet.vehicle-depreciation --steps.

// fune: step fleet.vehicle-depreciation 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
three years and 30,000 miles: 0.8 x 0.85 x 0.88 x 0.97^3 of 20,000.00 £20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, half-up → value £10,922.87, depreciation £9,077.13, retained basis points 54.61%
the same, rounded up £20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, up → value £10,922.88, depreciation £9,077.12, retained basis points 54.61%
brand new with no miles keeps its whole value £20,000.00, 0, 0, 80%, 85%, 97%, half-up → value £20,000.00, depreciation £0.00, retained basis points 100%
six months into a year that keeps 80% keeps 90% £10,000.00, 6, 0, 80%, 100%, half-up → value £9,000.00, depreciation £1,000.00, retained basis points 90%
past the end of the curve the last year repeats: 0.8 x 0.85^4 £15,000.00, 60, 0, 80%, 85%, 100%, half-up → value £6,264.08, depreciation £8,735.92, retained basis points 41.76%
the same 6264.075 rounded down £15,000.00, 60, 0, 80%, 85%, 100%, down → value £6,264.07, depreciation £8,735.93, retained basis points 41.76%
15,000 miles is one whole band and half of the next: 0.9 x 0.95 £10,000.00, 0, 15,000, 80%, 90%, half-up → value £8,550.00, depreciation £1,450.00, retained basis points 85.5%
18 months and 25,000 miles: 0.8 x 0.95 x 0.95^2 x 0.975, with a retained share of 6687.525 bp rounding up £25,000.00, 18, 25,000, 80%, 90%, 95%, half-up → value £16,718.81, depreciation £8,281.19, retained basis points 66.88%
a vehicle written off to nothing £12,000.00, 12, 0, 0%, 97%, half-up → value £0.00, depreciation £12,000.00, retained basis points 0%
euro cents, ten years on a flat 85% curve €30,000.00, 120, 0, 85%, 100%, half-up → value €5,906.23, depreciation €24,093.77, retained basis points 19.69%
Show the other 10 tests
CaseArgumentsExpected
a long-lived van: 0.9^50 x 0.99^200 of 50,000.00 is 34.525 and still divides exactly £50,000.00, 600, 2,000,000, 90%, 99%, half-up → value £34.53, depreciation £49,965.47, retained basis points 0.07%
an empty curve is an error £10,000.00, 12, 0, , 97%, half-up → error: retainedPerYearBasisPoints must have at least one year
a retention above 100% is an error £10,000.00, 12, 0, 100.01%, 97%, half-up → error: a retention rate must be a whole number from 0 to 10000
a negative mileage retention is an error £10,000.00, 12, 0, 80%, -0.01%, half-up → error: a retention rate must be a whole number from 0 to 10000
a negative age is an error £10,000.00, -1, 0, 80%, 97%, half-up → error: ageMonths must be a whole number from 0 to 600
an age over 50 years is an error £10,000.00, 601, 0, 80%, 97%, half-up → error: ageMonths must be a whole number from 0 to 600
a fractional age is an error £10,000.00, 6.5, 0, 80%, 97%, half-up → error: ageMonths must be a whole number
a negative mileage is an error £10,000.00, 12, -5, 80%, 97%, half-up → error: mileage must be a whole number from 0 to 2000000
a negative cost is an error -£0.01, 12, 0, 80%, 97%, half-up → error: cost must not be negative
an unknown rounding mode is an error £10,000.00, 12, 0, 80%, 97%, nearest → error: unknown rounding mode

More from the author

- `retainedPerYearBasisPoints` is the share of value kept through each year of age, first year first. `[8000, 8500, 8800]` means the vehicle keeps 80% of its value in year one, 85% of what is left in year two and 88% in year three; from year four on the last entry (88%) repeats. A part year is a straight-line share of that year's loss: six months into a year that keeps 80% keeps 90%. - `retainedPer10kMilesBasisPoints` is the share kept for every 10,000 miles, compounding per whole 10,000 and straight-line within one: at 90% per 10k, 15,000 miles keeps 0.9 x 0.95 = 85.5%.

The two multiply: value = cost x age factor x mileage factor. Build the age curve for a vehicle doing no miles (or your curve's baseline mileage) if you use both; if your age curve already assumes an average mileage, pass `10000` for the mileage rate and adjust separately.

## Exactness

Every factor is a fraction of integers, so the product is kept as one exact fraction (in big integers, via `math.round-div-big`) and rounded once to the minor unit with the `mode` you choose. Valuing year by year and rounding each year drifts. `retainedBasisPoints` is the exact combined share, rounded half-up to a basis point, for display; the value is not derived from it.

## Limits and errors

Age 0 to 600 months (50 years); mileage 0 to 2,000,000; every rate 0 to 10000 basis points (a vehicle that gains value is outside this model). A negative cost, an empty curve, a value out of range, a fractional number or an unknown rounding mode is an error.

Files

PathBytes
README.md1,928
impl/python.py2,610
impl/rust.rs4,148
impl/typescript.ts2,719
vectors.json4,979