Blog

A postal code is not a negative number

truecopy 2.0.14 lets the caller say what a bracket around a figure means. Why the library stopped guessing, and why the default did not move.

On a balance sheet, (1 234) is a loss. On an address line, (69003) is a postal code. A footnote marker (1) has the same shape again. Until 2.0.14, truecopy read all three the first way, and the reason it took that long to notice says something about where reading errors hide.

It showed up as a refused citation

Nobody saw a wrong total. What happened is quieter. truecopy has a check for the moment a model claims a figure came from a page: carriesNumber(source, value) answers whether the page really prints that figure. A caller cited a postal code exactly as the page wrote it and was told the page does not carry it.

The reader had turned (69003) into -69003, and +69003 is not -69003. carriesText refused it too, for another reason. It matches whole words, and (69003) was one word.

That is the worst way for this check to fail. It exists to catch a model that invents a figure. Here it accused a citation that was right. A check that rejects correct answers gets switched off by the people it annoys, and then it catches nothing at all.

Why the library cannot tell the two apart

The obvious fix is a rule. Look at the position of the bracket, or at what comes before it, and decide. The issue that reported the defect measured it before anyone wrote that rule. On 270 quarterly reports, 2 637 of 4 176 bracketed figures sat at the head of a cell. That is exactly where an accounting negative sits too, so position settles nothing. What comes before the bracket separates avenue des Lilas (69003) from a column of figures, but a footnote marker has nothing before it.

There was a worse problem with tuning a rule on that set. Those reports print asides in brackets and almost never print negatives that way. The documents this library is written for, statements, invoices and payslips, are where accounting negatives are expected to live. A rule fitted to the first kind of page would have been fitted to the one place the negatives are missing, and it would have flipped signs on the second kind without a word.

The token does not carry its convention. The page does, and the caller knows which page it holds.

The caller says it, the way it already says the decimal mark

truecopy had met this shape of problem before. 1,234 is one thousand two hundred and thirty-four in one country and a little over one in another, and no amount of staring at the string decides it. So the caller declares the decimal mark, and decimalMarkOf helps it read the answer from the rest of the document.

Brackets now work the same way. readNumber, carriesNumber and carriesText take one more argument, 'sign' or 'punctuation':

import { readNumber, carriesNumber, carriesText } from 'truecopy';

readNumber('(69003)'); // -69003
readNumber('(69003)', null, 'punctuation'); // 69003

const line = '14 avenue des Lilas (69003) Lyon';
carriesNumber(line, '69003', ','); // false
carriesNumber(line, '69003', ',', 'punctuation'); // true
carriesText(line, '69003', 'punctuation'); // true

A minus sign stays a minus sign under both readings, so (-12,00) is -12 whichever convention the caller declares.

Why the default did not move

Left out, the argument is 'sign', which is what every earlier version did. Changing the default would have fixed the address lines and broken every accounts table read by an existing caller, without a warning anywhere. This is what 'punctuation' does to a real accounting negative:

readNumber('(1 234,56)', ','); // -1234.56
readNumber('(1 234,56)', ',', 'punctuation'); // 1234.56

A loss that comes back as a profit is the error this library exists to prevent. A visible refusal on an address line is annoying. A silent change of sign on a balance sheet is the kind of mistake that reaches a decision before anyone sees it. So the new reading arrives as an option, and a bracket that was read as a sign before 2.0.14 is still read as a sign after it.

If your documents hold both

Declare the convention for the text you are reading, not once for the whole program. readNumber reads one string at a time, so a document with an address block and a column of amounts can be read with 'punctuation' for the first and 'sign' for the second. The library will not decide that for you, because it cannot know it from the page.

The full reference is on the notation page and the citation page, and the release notes are on the versions page. The report that started it is issue #70.