Functional Weave
Code in Python

validation.uk-modulus-table

Parse Vocalink's VALACDOS.txt and SCSUBTAB.txt into the table UK sort code modulus checking needs.

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

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

What it does

Reads the two files UK sort code modulus checking runs on, as Vocalink publishes them for Pay.UK, into the table that `validation.uk-sort-code-account` checks against:

- **VALACDOS.txt**, the modulus weight table: one line per sort code range, giving the check to run (MOD10, MOD11 or DBLAL), fourteen weights and an optional exception number. - **SCSUBTAB.txt**, the sort code substitution table for exception 5: one line per sort code, with the sort code to weight in its place.

For example

  • parse_uk_modulus_table(110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 , ) → rows ×1, substitutions one line with no exception, and no SCSUBTAB.txt
  • parse_uk_modulus_table(140000 149999 MOD11 0 0 0 0 0 0 8 7 6 5 4 3 2 1 140000 149999 DBLAL 2 1 2 1 2 1 2 1 2 1 2 1 2 1…) → rows ×2, substitutions two rows for one range keep file order: the MOD11 row is the first check
  • parse_uk_modulus_table(210000 210099 DBLAL 2 1 2 1 2 1 2 1 2 1 2 1 2 1 1 , ) → rows ×1, substitutions a trailing exception column is read as a number

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 parse_uk_modulus_table(valacdos: str, scsubtab: str) -> UkModulusTable
valacdosstringthe text of VALACDOS.txt, as downloaded from Vocalink
scsubtabstringthe text of SCSUBTAB.txt, as downloaded alongside it; "" if you have none
returnsUkModulusTable

The types it declares, generated into your project

@dataclass(frozen=True)
class UkModulusTable:
    """The modulus weight table and the exception 5 substitutions, in file order. Supplied by the user, never bundled."""

    #: one per line of VALACDOS.txt; a sort code in two rows gets two checks
    rows: List[UkModulusRow]
    #: one per line of SCSUBTAB.txt
    substitutions: List[UkSortCodeSubstitution]

@dataclass(frozen=True)
class UkModulusRow:
    """One line of VALACDOS.txt: a sort code range, its check and its weights."""

    #: first sort code of the range, six digits
    start: str
    #: last sort code of the range, inclusive
    end: str
    algorithm: UkModulusAlgorithm
    #: fourteen, for the digits u v w x y z (sort code) and a b c d e f g h (account)
    weights: List[int]
    #: 1 to 14, or null
    exception: Optional[int]

UkModulusAlgorithm = Literal["MOD10", "MOD11", "DBLAL"]

@dataclass(frozen=True)
class UkSortCodeSubstitution:
    """One line of SCSUBTAB.txt: exception 5 weights `original` as if it were `substitute`."""

    original: str
    substitute: str

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

from fune.validation.uk_modulus_table import parse_uk_modulus_table  # validation.uk-modulus-table@^1
impl/python.py · 88 lines · open · raw
import re
from typing import List, Optional, Tuple

from .validation_uk_modulus_table_types import UkModulusRow, UkModulusTable, UkSortCodeSubstitution

_SORT_CODE = re.compile(r"[0-9]{6}")
_WEIGHT = re.compile(r"-?[0-9]{1,4}")
_EXCEPTION = re.compile(r"[0-9]{1,2}")
_SEPARATOR = re.compile(r"[ \t]+")
_ALGORITHMS = ("MOD10", "MOD11", "DBLAL")


def _lines(text: str) -> List[Tuple[int, List[str]]]:
    """The fields of each non-blank line. Only ASCII spaces and tabs separate
    fields, and a trailing carriage return is dropped, so a file saved with
    Windows line endings reads the same and every language splits alike."""
    out: List[Tuple[int, List[str]]] = []
    body = text[1:] if text.startswith("") else text
    for i, raw in enumerate(body.split("\n")):
        line = raw[:-1] if raw.endswith("\r") else raw
        fields = [f for f in _SEPARATOR.split(line) if f != ""]
        if fields:
            out.append((i + 1, fields))
    return out


def _sort_code(file: str, line: int, value: str) -> str:
    if not _SORT_CODE.fullmatch(value):
        raise ValueError('%s line %d: "%s" is not a six-digit sort code' % (file, line, value))
    return value


def _parse_row(line: int, fields: List[str]) -> UkModulusRow:
    where = "VALACDOS.txt line %d" % line
    if len(fields) not in (17, 18):
        raise ValueError(
            "%s: expected 17 or 18 fields (start, end, algorithm, 14 weights, optional exception), found %d"
            % (where, len(fields))
        )
    start = _sort_code("VALACDOS.txt", line, fields[0])
    end = _sort_code("VALACDOS.txt", line, fields[1])
    if end < start:
        raise ValueError("%s: range %s to %s ends before it starts" % (where, start, end))
    algorithm = fields[2]
    if algorithm not in _ALGORITHMS:
        raise ValueError('%s: unknown algorithm "%s"; expected MOD10, MOD11 or DBLAL' % (where, algorithm))
    weights: List[int] = []
    for w in fields[3:17]:
        if not _WEIGHT.fullmatch(w):
            raise ValueError('%s: weight "%s" is not a whole number' % (where, w))
        weights.append(int(w))
    # A digit sum of a negative product means different things in different
    # languages' remainder rules; the specification never needs one.
    if algorithm == "DBLAL" and any(w < 0 for w in weights):
        raise ValueError("%s: a DBLAL row cannot have a negative weight" % where)
    exception: Optional[int] = None
    if len(fields) == 18:
        e = fields[17]
        if not _EXCEPTION.fullmatch(e) or not 1 <= int(e) <= 14:
            raise ValueError('%s: exception "%s" is not a number from 1 to 14' % (where, e))
        exception = int(e)
    return UkModulusRow(start=start, end=end, algorithm=algorithm, weights=weights, exception=exception)


def _parse_substitution(line: int, fields: List[str]) -> UkSortCodeSubstitution:
    if len(fields) != 2:
        raise ValueError("SCSUBTAB.txt line %d: expected two sort codes, found %d fields" % (line, len(fields)))
    return UkSortCodeSubstitution(
        original=_sort_code("SCSUBTAB.txt", line, fields[0]),
        substitute=_sort_code("SCSUBTAB.txt", line, fields[1]),
    )


def parse_uk_modulus_table(valacdos: str, scsubtab: str) -> UkModulusTable:
    """Parse the text of Vocalink's VALACDOS.txt (the modulus weight table)
    and SCSUBTAB.txt (exception 5's sort code substitutions) into the table
    validate_uk_sort_code_account checks against.

    Rows keep their file order, which matters: where a sort code falls in two
    rows, the first is the first check. Blank lines are skipped; anything else
    malformed is an error naming the file and the line, because a silently
    dropped row turns a checkable sort code into an unchecked one.
    """
    rows = [_parse_row(number, fields) for number, fields in _lines(valacdos)]
    if not rows:
        raise ValueError("VALACDOS.txt has no rows")
    substitutions = [_parse_substitution(number, fields) for number, fields in _lines(scsubtab)]
    return UkModulusTable(rows=rows, substitutions=substitutions)

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 validation.uk-modulus-table
Download for Python validation.uk-modulus-table-1.0.0-python.fune · 21,401 bytes sha256 f79a7e7de9b75dcbe0429b8808ae5b758a8f670b91a2ad58d08c0015e44bbf5c

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.uk-modulus-table-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.uk-modulus-table@1.0.0:python.

The whole function, every language, is one file too: validation.uk-modulus-table-1.0.0.fune, 33,152 bytes, sha256 7b72440545c7642c6abb082c6ba8003139826250ab85e6dbebe2d1ecbbd0826c. 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 validation.uk-modulus-table

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

# fune: after validation.uk-modulus-table

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 validation.uk-modulus-table --steps.

# fune: step validation.uk-modulus-table 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
one line with no exception, and no SCSUBTAB.txt 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 , → rows ×1, substitutions
two rows for one range keep file order: the MOD11 row is the first check 140000 149999 MOD11 0 0 0 0 0 0 8 7 6 5 4 3 2 1 140000 149999 DBLAL 2 1 2 1 2 1 2 1 2 1 2 1 2 1… → rows ×2, substitutions
a trailing exception column is read as a number 210000 210099 DBLAL 2 1 2 1 2 1 2 1 2 1 2 1 2 1 1 , → rows ×1, substitutions
Windows line endings read the same as Unix ones 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 210000 210099 DBLAL 2 1 2 1 2 1 2 1 2 1 2 1 2 … → rows ×2, substitutions
tabs and single spaces separate fields as well as padding does; blank lines are skipped 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 210000 210099 DBLAL 2 1 2 1 2 1 2 1 2 1 2 1 2 1 1, → rows ×2, substitutions
a negative weight is kept in a MOD11 row 330000 330099 MOD11 0 0 0 0 0 0 -1 7 6 5 4 3 2 1 , → rows ×1, substitutions
a range of one sort code, two-digit exception 14, and an exception written 05 320040 320040 MOD11 0 0 0 0 0 0 8 7 6 5 4 3 2 1 14 250000 250099 MOD11 7 6 5 4 3 2 7 6 5 4 3 2 0… → rows ×2, substitutions
SCSUBTAB.txt lines become substitutions, in order 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 , 250050 250010 250051 250020 → rows ×1, substitutions ×2
a byte order mark at the start of the file is ignored 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, 250050 250010 → rows ×1, substitutions ×1
a double-digit weight 400000 400099 MOD11 0 0 0 0 0 0 10 9 8 7 6 5 4 3 , → rows ×1, substitutions
Show the other 18 tests
CaseArgumentsExpected
a line with only 13 weights 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 , → error: VALACDOS.txt line 1: expected 17 or 18 fields (start, end, algorithm, 14 weights, optional exception), found 16
a line with a nineteenth field 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 1 1 , → error: VALACDOS.txt line 1: expected 17 or 18 fields
line numbers count blank lines, so the error points at the right line 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 110000 119999 MOD10 0 0 0 , → error: VALACDOS.txt line 3: expected 17 or 18 fields
a letter in a sort code 11000A 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, → error: VALACDOS.txt line 1: "11000A" is not a six-digit sort code
non-ASCII digits are not a sort code ١١0000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, → error: VALACDOS.txt line 1: "١١0000" is not a six-digit sort code
a five-digit end of range 110000 11999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, → error: VALACDOS.txt line 1: "11999" is not a six-digit sort code
a range that ends before it starts 119999 110000 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, → error: VALACDOS.txt line 1: range 119999 to 110000 ends before it starts
algorithm names are upper case 110000 119999 mod10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, → error: VALACDOS.txt line 1: unknown algorithm "mod10"; expected MOD10, MOD11 or DBLAL
a fractional weight 110000 119999 MOD10 0 0 0 0 0 0 7 1.5 3 7 1 3 7 1, → error: VALACDOS.txt line 1: weight "1.5" is not a whole number
a weight with a plus sign 110000 119999 MOD10 0 0 0 0 0 0 7 +1 3 7 1 3 7 1, → error: VALACDOS.txt line 1: weight "+1" is not a whole number
a five-digit weight 110000 119999 MOD10 0 0 0 0 0 0 7 10000 3 7 1 3 7 1, → error: VALACDOS.txt line 1: weight "10000" is not a whole number
a negative weight in a DBLAL row 140000 149999 DBLAL 2 1 2 1 2 1 2 -1 2 1 2 1 2 1, → error: VALACDOS.txt line 1: a DBLAL row cannot have a negative weight
exception 15 does not exist 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 15, → error: VALACDOS.txt line 1: exception "15" is not a number from 1 to 14
exception 0 does not exist 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1 0, → error: VALACDOS.txt line 1: exception "0" is not a number from 1 to 14
an empty VALACDOS.txt would leave every sort code unchecked , → error: VALACDOS.txt has no rows
a file of blank lines has no rows either , → error: VALACDOS.txt has no rows
an SCSUBTAB.txt line with three fields 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, 250050 250010 250020 → error: SCSUBTAB.txt line 1: expected two sort codes, found 3 fields
an SCSUBTAB.txt line with a short sort code, on line 2 110000 119999 MOD10 0 0 0 0 0 0 7 1 3 7 1 3 7 1, 250050 250010 25005 250010 → error: SCSUBTAB.txt line 2: "25005" is not a six-digit sort code

More from the author

**Neither file is in this package.** Both are Vocalink's copyright ("All rights reserved"), so the registry ships the algorithm and the parser, and you supply the data. Download both from Vocalink's modulus checking page, <https://www.vocalink.com/tools/modulus-checking/> (Vocalink runs it for Pay.UK), and pass their text in. Vocalink updates VALACDOS.txt several times a year as banks add and retire sort codes, and the page lists the date of each release: fetch the new file when it changes, as you would any reference data. A table that is out of date answers "unchecked" for a new sort code rather than rejecting it, so a stale file fails safe, but it does stop checking.

## Usage

Read the files, parse them once when your application starts, and pass the table to every check. Parsing the full file takes a few milliseconds; do not do it per request.

TypeScript:

import { readFileSync } from "node:fs";
import { parseUkModulusTable } from "#fune/validation.uk-modulus-table@^1";
import { validateUkSortCodeAccount } from "#fune/validation.uk-sort-code-account@^3";

const table = parseUkModulusTable(
  readFileSync("data/valacdos.txt", "utf8"),
  readFileSync("data/scsubtab.txt", "utf8"),
);
validateUkSortCodeAccount("08-99-99", "66374958", table);

Python:

from pathlib import Path

from fune.validation.uk_modulus_table import parse_uk_modulus_table  # validation.uk-modulus-table@^1
from fune.validation.uk_sort_code_account import validate_uk_sort_code_account  # validation.uk-sort-code-account@^3

table = parse_uk_modulus_table(
    Path("data/valacdos.txt").read_text(encoding="utf-8"),
    Path("data/scsubtab.txt").read_text(encoding="utf-8"),
)
validate_uk_sort_code_account("08-99-99", "66374958", table)

Rust:

fune!(validation.uk-modulus-table@^1);
fune!(validation.uk-sort-code-account@^3);

let table = parse_uk_modulus_table(
    &std::fs::read_to_string("data/valacdos.txt")?,
    &std::fs::read_to_string("data/scsubtab.txt")?,
);
validate_uk_sort_code_account("08-99-99", "66374958", &table);

## The format it reads

Each non-blank line of VALACDOS.txt is 17 or 18 fields separated by spaces (Vocalink pads the columns; any run of spaces or tabs is one separator):

start end algorithm  u v w x y z a b c d e f g h  [exception]

`start` and `end` are six-digit sort codes, inclusive. The fourteen weights are whole numbers, and may be negative in a MOD10 or MOD11 row. The exception is 1 to 14. A sort code that falls in two rows gets two checks, the first row first, so **rows keep their file order**; do not sort or de-duplicate them.

Each non-blank line of SCSUBTAB.txt is two six-digit sort codes: the original and its substitute. Pass `""` if you have no SCSUBTAB.txt; exception 5 rows then weight every sort code as itself, which is only right for sort codes the substitution table does not list.

Windows line endings and a leading byte order mark are accepted.

## Errors

Anything else is an error naming the file and the line (blank lines count, so the number matches your editor's): a wrong number of fields, a sort code that is not six ASCII digits, a range that ends before it starts, an unknown algorithm (they are upper case), a weight that is not a whole number of at most four digits, a negative weight in a DBLAL row (a digit sum of a negative product is not defined, and the specification never needs one), an exception outside 1 to 14, or a VALACDOS.txt with no rows at all. A parser that skipped a bad line would quietly turn a checkable sort code into an unchecked one.

## Tests

The vectors use short invented lines in VALACDOS.txt's layout, not rows of the real file: invented sort code ranges, weights and exceptions.

## Sources

- Vocalink, "Validating account numbers: UK Modulus Checking", section 2 (the file layouts and the meaning of each column), and the modulus checking page that links the current files: <https://www.vocalink.com/tools/modulus-checking/>

Files

PathBytes
README.md4,500
impl/python.py4,051
impl/rust.rs7,197
impl/typescript.ts4,104
vectors.json8,305