diff --git a/projects/damm/BUILD b/projects/damm/BUILD new file mode 100644 index 0000000..653a463 --- /dev/null +++ b/projects/damm/BUILD @@ -0,0 +1,20 @@ +py_library( + name = "damm", + srcs = [ + "damm.py", + ], + imports = [ + "." + ], + visibility = [ + "//visibility:public", + ] +) + +py_pytest( + name = "test_damm", + srcs = glob(["test_*.py"]), + deps = [ + ":damm", + ] +) diff --git a/projects/damm/README.md b/projects/damm/README.md new file mode 100644 index 0000000..ca46a0c --- /dev/null +++ b/projects/damm/README.md @@ -0,0 +1,23 @@ +# Damm + +An implementation of a Damm check digits. + +doi:10.2174/9781608058822114010013 + +To get into the group theory here a bit, Damm showed that it was possible to construct an anti-symmetric quasigroup for most orders (group sizes). +A quasigroup is a non-associative group for which division is always well defined. +Damm's check digits are based on this non-associative property which allows for the full detection of transposition errors. + +We can use a quasigroup to implement a check digit scheme. +Checking a number consists of "multiplying" the digits of the number together through the group's multiplication table. + +The neat bit of Damm's quasigroups is the property `x ยท x = 0`. +This is visible in the multiplication matrix below. +What this means is that if the Damm code (product of all digits within the Damm group!) +of a numeric string is x, the product of THAT product with itself is 0. +Which means we can compute the Damm coding of any number simply by APPENDING the product of its digits. + +Damm checking is then defined by computing the product of the digits within a Damm group and affirming that the product is 0 which is only possible if the input string is '0' OR it is a valid Damm checked number. + +Another nice trick with the Damm quasigroup is that it's safe to PREFIX zeros to any Damm coded number without changing the Damm correction code. +Unfortunately this also means that SUFFIXING zeros won't impact the Damm code either. diff --git a/projects/damm/damm.py b/projects/damm/damm.py new file mode 100644 index 0000000..55460df --- /dev/null +++ b/projects/damm/damm.py @@ -0,0 +1,78 @@ +""" +An implementation of Damm check digits. +""" + +import re +from typing import NamedTuple + + +class DammException(Exception): + """Base class for all exceptions in the module.""" + + +class CheckDigitException(DammException): + """Thrown when a string did not check when decoding a supposedly checked string.""" + + +class NoMatchException(DammException): + """Thrown when a string did not match.""" + + +class Damm(NamedTuple): + """A Damm coded number.""" + + MATRIX = ( + (0, 3, 1, 7, 5, 9, 8, 6, 4, 2), + (7, 0, 9, 2, 1, 5, 4, 8, 6, 3), + (4, 2, 0, 6, 8, 7, 1, 3, 5, 9), + (1, 7, 5, 0, 9, 8, 3, 4, 2, 6), + (6, 1, 2, 3, 0, 4, 5, 9, 7, 8), + (3, 6, 7, 4, 2, 0, 9, 5, 8, 1), + (5, 8, 6, 9, 7, 2, 0, 1, 3, 4), + (8, 9, 4, 5, 3, 6, 2, 0, 1, 7), + (9, 4, 3, 8, 6, 1, 7, 2, 0, 5), + (2, 5, 8, 1, 4, 3, 6, 7, 9, 0), + ) + + DAMM_PATTERN = re.compile(r"(?P\d+)-?(?P\d)") + + value: int + + def __str__(self): + return f"{self.value}-{self.encode(self.value)}" + + def __repr__(self): + return f"{self.__class__.__name__}({self.value}, {self.encode(self.value)})" + + @classmethod + def encode(cls, value): + state = 0 + for digit in str(value): + state = cls.MATRIX[state][int(digit)] + return state + + @classmethod + def verify(cls, value: str) -> bool: + """Verify a Damm encoded number, returning its value if valid. + + The core Damm encoding property is that the LAST digit is ALWAYS the check. When the rest of + the string is correctly Damm encoded, the Damm of the entire string will be 0. + + """ + return cls.encode(int(value.replace("-", ""))) == 0 + + @classmethod + def from_str(cls, value: str) -> "Damm": + """Verify a Damm coded string, and return its decoding.""" + if match := re.fullmatch(cls.DAMM_PATTERN, value): + given_value = match.group("value") + computed_code = cls.encode(given_value) + given_code = int(match.group("check")) + if computed_code == given_code: + return Damm(int(given_value)) + else: + raise CheckDigitException( + f"Value {value!r} had check digit {given_code!r}, but computed check was {computed_code!r}" + ) + else: + raise NoMatchException(f"Input {value!r} was not a valid decimal number") diff --git a/projects/damm/test_damm.py b/projects/damm/test_damm.py new file mode 100644 index 0000000..0314556 --- /dev/null +++ b/projects/damm/test_damm.py @@ -0,0 +1,5 @@ +""" + +""" + +from damm import Damm