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.txtparse_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 checkparse_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
| 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
@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
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
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.
| 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 |