Functional Weave
Code in Rust

inventory.cycle-count-schedule

Cycle count schedule: spread each item's counts evenly over the working days of a period, by ABC class frequency.

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

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

What it does

Cycle counting replaces the annual stocktake with small counts every working day, counting valuable (class A) items more often than cheap ones. This builds the calendar: given each item's class and how many times a class is counted in the period, it says which items to count on each working day.

**Working days** are Monday to Friday from `startDate` to `endDate` inclusive, less the `holidays` (pass `dates.bank-holidays` for the UK). The schedule lists every working day, including days with nothing to count, so a count team can see its load.

For example

  • cycle_count_schedule(items ×6, a 2, b 1, c 0, 2026-09-21, 2026-10-02, ) → ×10 two weeks: A twice (days 0-4 and 5-9), B once over ten days, C never
  • cycle_count_schedule(items ×4, a 3, 2026-12-21, 2027-01-01, 2026-12-25, 2026-12-28, 2027-01-01) → ×7 Christmas: weekends and the 25th, 28th (Boxing Day substitute) and 1st are skipped, leaving 7 days in windows of 2, 2 and 3
  • cycle_count_schedule(items ×1, a 3, 2026-09-23, 2026-09-25, ) → ×3 counted as often as there are working days: every day

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 cycle_count_schedule(items: &[CountItem], counts_per_period: &[(String, i64)], start_date: &str, end_date: &str, holidays: &[String]) -> Vec<CountDay>
itemsCountItem[]every item to count, with its class, e.g. from inventory.abc-classification
counts_per_periodmap<int>how many times each class is counted in the period: {"A": 12, "B": 4, "C": 1}
start_datedatefirst day of the period
end_datedatelast day of the period, inclusive
holidaysdate[]non-working dates, e.g. from dates.bank-holidays
returnsCountDay[]every working day of the period in date order, with the items to count that day

The types it declares, generated into your project

/// One item to count.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CountItem {
    /// unique
    pub sku: String,
    /// a key of countsPerPeriod
    pub abc_class: String,
}

/// One working day of the schedule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CountDay {
    pub date: String,
    /// items to count, by class then sku; empty on a day with nothing to count
    pub skus: Vec<String>,
}

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

fune!(inventory.cycle-count-schedule@^1);  // then call cycle_count_schedule(…)
impl/rust.rs · 124 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::collections::HashSet;

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::dates_add_days::{epoch_day_from_iso, iso_from_epoch_day};  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week;  ← from dates.day-of-week ^1.0.0 · built alongside by fune

/// Spread each item's counts evenly over the working days of a period: a class
/// counted f times gets f even windows, and its items are spread evenly within
/// each window.
///
/// # Panics
/// Panics on an unknown class, a duplicate SKU, a negative frequency, a class
/// counted more often than there are working days, a period that ends before it
/// starts, or a malformed date.
pub fn cycle_count_schedule(
    items: &[CountItem],
    counts_per_period: &[(String, i64)],
    start_date: &str,
    end_date: &str,
    holidays: &[String],
) -> Vec<CountDay> {
    let first = epoch_day_from_iso(start_date);
    let last = epoch_day_from_iso(end_date);
    if last < first {
        panic!("endDate {} is before startDate {}", end_date, start_date);
    }
    let closed: HashSet<i64> = holidays.iter().map(|h| epoch_day_from_iso(h)).collect();
    let mut days: Vec<String> = Vec::new();
    for day in first..=last {
        let iso = iso_from_epoch_day(day);
        if day_of_week(&iso) <= 5 && !closed.contains(&day) {
            days.push(iso);
        }
    }

    let mut classes: Vec<&(String, i64)> = counts_per_period.iter().collect();
    classes.sort_by(|a, b| a.0.cmp(&b.0));
    for (cls, f) in &classes {
        if *f < 0 {
            panic!(
                "countsPerPeriod for class \"{}\" must be a whole number, not negative, received {}",
                cls, f
            );
        }
    }
    let mut seen: HashSet<&str> = HashSet::new();
    for item in items {
        if !seen.insert(item.sku.as_str()) {
            panic!("duplicate sku \"{}\"", item.sku);
        }
        if !counts_per_period.iter().any(|(c, _)| *c == item.abc_class) {
            panic!("no count frequency for class \"{}\" (sku \"{}\")", item.abc_class, item.sku);
        }
    }

    let n = days.len() as i64;
    let mut schedule: Vec<Vec<String>> = vec![Vec::new(); days.len()];
    for (cls, f) in classes {
        let f = *f;
        let mut skus: Vec<&String> = items.iter().filter(|i| i.abc_class == *cls).map(|i| &i.sku).collect();
        skus.sort();
        if skus.is_empty() || f == 0 {
            continue;
        }
        if f > n {
            panic!(
                "class \"{}\" is counted {} times but the period has only {} working days",
                cls, f, n
            );
        }
        let m = skus.len() as i64;
        for k in 0..f {
            let start = k * n / f;
            let length = (k + 1) * n / f - start;
            for (j, sku) in skus.iter().enumerate() {
                schedule[(start + j as i64 * length / m) as usize].push((*sku).clone());
            }
        }
    }
    days.into_iter()
        .zip(schedule)
        .map(|(date, skus)| CountDay { date, skus })
        .collect()
}

pub fn count_item_from_value(v: &Value) -> CountItem {
    CountItem {
        sku: v.get("sku").as_str().to_string(),
        abc_class: v.get("abcClass").as_str().to_string(),
    }
}

pub fn count_day_to_value(day: &CountDay) -> Value {
    Value::obj(vec![
        ("date", Value::str(&day.date)),
        ("skus", Value::Arr(day.skus.iter().map(|s| Value::str(s)).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let items: Vec<CountItem> = args[0].as_arr().iter().map(count_item_from_value).collect();
    let counts: Vec<(String, i64)> = match &args[1] {
        Value::Obj(pairs) => pairs
            .iter()
            .map(|(k, v)| {
                if let Value::Float(f) = v {
                    panic!(
                        "countsPerPeriod for class \"{}\" must be a whole number, not negative, received {}",
                        k, f
                    );
                }
                (k.clone(), v.as_i64())
            })
            .collect(),
        _ => Vec::new(),
    };
    let holidays: Vec<String> = args[4].as_arr().iter().map(|v| v.as_str().to_string()).collect();
    Value::Arr(
        cycle_count_schedule(&items, &counts, args[2].as_str(), args[3].as_str(), &holidays)
            .iter()
            .map(count_day_to_value)
            .collect(),
    )
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 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 inventory.cycle-count-schedule
Download for Rust inventory.cycle-count-schedule-1.0.0-rust.fune · 14,899 bytes sha256 69a91ca5079773942393ac404085c2b1da297a696cc207f979e5b28280b04b91

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

The whole function, every language, is one file too: inventory.cycle-count-schedule-1.0.0.fune, 20,209 bytes, sha256 28d643e77bd3b58fea5f1e3246191548aa9c02667cc931fd33fe53ce5e5bdd87. 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 inventory.cycle-count-schedule

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

// fune: after inventory.cycle-count-schedule

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 dates.add-days in inventory.cycle-count-schedule
// fune: replace dates.day-of-week in inventory.cycle-count-schedule

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 inventory.cycle-count-schedule --steps.

// fune: step inventory.cycle-count-schedule 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
two weeks: A twice (days 0-4 and 5-9), B once over ten days, C never items ×6, a 2, b 1, c 0, 2026-09-21, 2026-10-02, → ×10
Christmas: weekends and the 25th, 28th (Boxing Day substitute) and 1st are skipped, leaving 7 days in windows of 2, 2 and 3 items ×4, a 3, 2026-12-21, 2027-01-01, 2026-12-25, 2026-12-28, 2027-01-01 → ×7
counted as often as there are working days: every day items ×1, a 3, 2026-09-23, 2026-09-25, → ×3
skus sort by character code, so B10 comes before B2 items ×2, b 1, 2026-09-21, 2026-09-22, → ×2
a class sorts before another on a shared day whatever the map order items ×2, b 1, a 1, 2026-09-21, 2026-09-21, → ×1
a holiday that falls on a weekend changes nothing, and a one-day period works items ×1, a 1, 2026-09-25, 2026-09-27, 2026-09-26 → ×1
no items: every working day, all empty , a 12, 2026-09-24, 2026-09-28, → ×3
a weekend-only period with nothing to count is an empty schedule items ×1, c 0, 2026-09-26, 2026-09-27, →
across a leap day: 2028-02-28 to 2028-03-01 is three working days items ×3, a 1, 2028-02-28, 2028-03-01, → ×3
a class counted more often than there are working days is an error items ×1, a 4, 2026-09-23, 2026-09-25, → error: class "A" is counted 4 times but the period has only 3 working days
Show the other 7 tests
CaseArgumentsExpected
something to count in a weekend-only period is an error items ×1, a 1, 2026-09-26, 2026-09-27, → error: the period has only 0 working days
an item whose class has no frequency is an error items ×1, a 1, 2026-09-21, 2026-09-25, → error: no count frequency for class "D" (sku "D1")
a duplicate sku is an error items ×2, a 1, b 1, 2026-09-21, 2026-09-25, → error: duplicate sku "A1"
a negative frequency is an error , a -1, 2026-09-21, 2026-09-25, → error: countsPerPeriod for class "A" must be a whole number, not negative
a fractional frequency is an error , a 1.5, 2026-09-21, 2026-09-25, → error: countsPerPeriod for class "A" must be a whole number, not negative
a period that ends before it starts is an error , a 1, 2026-09-25, 2026-09-21, → error: endDate 2026-09-21 is before startDate 2026-09-25
an impossible holiday is an error , a 1, 2026-09-21, 2026-09-25, 2026-02-30 → error: is not a real calendar date

More from the author

**Spreading.** For a class counted `f` times over `N` working days, the days are cut into `f` consecutive windows as evenly as whole days allow (window `k` starts on day `floor(k x N / f)`), so the counts of one item are roughly `N / f` days apart. Within each window the class's items, in SKU order, are spread evenly: item `j` of `m` goes on day `floor(j x length / m)` of the window. Each class is spread on its own, so the daily load is the sum of even spreads. The rule is plain integer arithmetic, so a schedule can be checked by hand and is the same in every language.

A class counted 0 times is never scheduled. A class counted more often than there are working days is an error (it would need two counts of one item on one day); so are an item whose class has no frequency, a duplicate SKU, a negative frequency, a period that ends before it starts, and malformed dates. Items on a day are listed by class, then SKU, by character code (keep them ASCII for the same order everywhere).

Typical frequencies: A monthly (12 a year), B quarterly (4), C yearly (1). The schedule does not carry counts across periods: a yearly schedule is built once for the year.

Source: the practice of ABC-based cycle counting, e.g. APICS Dictionary ("cycle counting") and Silver, Pyke and Thomas, *Inventory and Production Management in Supply Chains*, 4th ed.

Files

PathBytes
README.md1,935
impl/python.py2,452
impl/rust.rs4,274
impl/typescript.ts2,662
vectors.json4,903