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
parseUkModulusTable(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.txtparseUkModulusTable(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 checkparseUkModulusTable(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.
export function parseUkModulusTable(valacdos: string, scsubtab: string): UkModulusTable
| valacdos | string | the text of VALACDOS.txt, as downloaded from Vocalink |
| scsubtab | string | the text of SCSUBTAB.txt, as downloaded alongside it; "" if you have none |
| returns | UkModulusTable |
The types it declares, generated into your project
/** The modulus weight table and the exception 5 substitutions, in file order. Supplied by the user, never bundled. */
export interface UkModulusTable {
/** one per line of VALACDOS.txt; a sort code in two rows gets two checks */
readonly rows: readonly UkModulusRow[];
/** one per line of SCSUBTAB.txt */
readonly substitutions: readonly UkSortCodeSubstitution[];
}
/** One line of VALACDOS.txt: a sort code range, its check and its weights. */
export interface UkModulusRow {
/** first sort code of the range, six digits */
readonly start: string;
/** last sort code of the range, inclusive */
readonly end: string;
readonly algorithm: UkModulusAlgorithm;
/** fourteen, for the digits u v w x y z (sort code) and a b c d e f g h (account) */
readonly weights: readonly number[];
/** 1 to 14, or null */
readonly exception: number | null;
}
export type UkModulusAlgorithm = "MOD10" | "MOD11" | "DBLAL";
/** One line of SCSUBTAB.txt: exception 5 weights `original` as if it were `substitute`. */
export interface UkSortCodeSubstitution {
readonly original: string;
readonly substitute: string;
}
Your code names it in one line, in the file that uses it
import { parseUkModulusTable } from "#fune/validation.uk-modulus-table@^1";
import {
type UkModulusAlgorithm,
type UkModulusRow,
type UkModulusTable,
type UkSortCodeSubstitution,
} from "./validation_uk_modulus_table_types.ts";
const SORT_CODE = /^[0-9]{6}$/;
const WEIGHT = /^-?[0-9]{1,4}$/;
const EXCEPTION = /^[0-9]{1,2}$/;
const ALGORITHMS: readonly string[] = ["MOD10", "MOD11", "DBLAL"];
/**
* 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.
*/
function lines(text: string): { number: number; fields: string[] }[] {
const out: { number: number; fields: string[] }[] = [];
const body = text.startsWith("") ? text.slice(1) : text;
body.split("\n").forEach((raw, i) => {
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
const fields = line.split(/[ \t]+/).filter((f) => f !== "");
if (fields.length > 0) out.push({ number: i + 1, fields });
});
return out;
}
function sortCode(file: string, line: number, value: string): string {
if (!SORT_CODE.test(value)) throw new RangeError(`${file} line ${line}: "${value}" is not a six-digit sort code`);
return value;
}
function parseRow(line: number, fields: string[]): UkModulusRow {
const where = `VALACDOS.txt line ${line}`;
if (fields.length !== 17 && fields.length !== 18) {
throw new RangeError(
`${where}: expected 17 or 18 fields (start, end, algorithm, 14 weights, optional exception), found ${fields.length}`,
);
}
const start = sortCode("VALACDOS.txt", line, fields[0]);
const end = sortCode("VALACDOS.txt", line, fields[1]);
if (end < start) throw new RangeError(`${where}: range ${start} to ${end} ends before it starts`);
const algorithm = fields[2];
if (!ALGORITHMS.includes(algorithm)) {
throw new RangeError(`${where}: unknown algorithm "${algorithm}"; expected MOD10, MOD11 or DBLAL`);
}
const weights = fields.slice(3, 17).map((w) => {
if (!WEIGHT.test(w)) throw new RangeError(`${where}: weight "${w}" is not a whole number`);
const n = Number(w);
return n === 0 ? 0 : n; // "-0" is 0, not JavaScript's negative zero
});
// A digit sum of a negative product means different things in different
// languages' remainder rules; the specification never needs one.
if (algorithm === "DBLAL" && weights.some((w) => w < 0)) {
throw new RangeError(`${where}: a DBLAL row cannot have a negative weight`);
}
let exception: number | null = null;
if (fields.length === 18) {
const e = fields[17];
if (!EXCEPTION.test(e) || Number(e) < 1 || Number(e) > 14) {
throw new RangeError(`${where}: exception "${e}" is not a number from 1 to 14`);
}
exception = Number(e);
}
return { start, end, algorithm: algorithm as UkModulusAlgorithm, weights, exception };
}
function parseSubstitution(line: number, fields: string[]): UkSortCodeSubstitution {
if (fields.length !== 2) {
throw new RangeError(`SCSUBTAB.txt line ${line}: expected two sort codes, found ${fields.length} fields`);
}
return {
original: sortCode("SCSUBTAB.txt", line, fields[0]),
substitute: sortCode("SCSUBTAB.txt", line, fields[1]),
};
}
/**
* Parse the text of Vocalink's VALACDOS.txt (the modulus weight table) and
* SCSUBTAB.txt (exception 5's sort code substitutions) into the table
* `validateUkSortCodeAccount` 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.
*/
export function parseUkModulusTable(valacdos: string, scsubtab: string): UkModulusTable {
const rows = lines(valacdos).map(({ number, fields }) => parseRow(number, fields));
if (rows.length === 0) throw new RangeError("VALACDOS.txt has no rows");
const substitutions = lines(scsubtab).map(({ number, fields }) => parseSubstitution(number, fields));
return { rows, substitutions };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript 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
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./validation.uk-modulus-table-1.0.0-typescript.fune, or fetch it from a terminal with fune pull validation.uk-modulus-table@1.0.0:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 4,500 |
| impl/python.py | 4,051 |
| impl/rust.rs | 7,197 |
| impl/typescript.ts | 4,104 |
| vectors.json | 8,305 |