space ocr update #4 — the layer I said I would not build
In my first update post I turned down a suggestion. Someone with nineteen years in banking software asked for control totals, checking that the numbers on a document add up, and I said no. Getting there meant giving fields types, then converting formats, then summing, then comparing, and I thought all four of those belonged to a layer above this one. I have now built the first two, and I want to explain what moved.
What moved was not the argument. It was hearing the same thing from people who had never read that thread.
Separately from the workflow request in my last post, I put out a call in Japan for developers to build something real with the API. The proposals came back describing completely different businesses and they all ended in the same place. Extract the fields, then do something with the result, and the something always began by turning a printed string into a number or a date.
One of them wants to rebuild an invoice into the order a screen reader can read it in, using the coordinates to separate the columns that otherwise collapse into a line of unrelated digits, and to have amounts and dates spoken as amounts and dates. He also wants the flagged values announced rather than read out as though they were fine, because a wrong value that arrives unmarked is a much worse thing when you cannot glance at the page to catch it.
Another builds for construction sites, where the delivery slips and invoices for one project arrive separately over weeks, and nobody knows what a site actually cost until somebody adds it all up by hand.
Neither of those is a receipts and invoices product, which is the thing I was worried about turning into. Both of them need a number that behaves like a number before anything downstream can start.
So the line moved, and I think it sits in a clearer place now than the one I drew at launch. I will convert what is printed. I will not compute what is not. Reading a page and saying what is on it is mine. Deciding what it means is yours. Turning 令和8年8月16日 into 2026-08-16 is still reading. Adding up the line items and telling you the printed total is wrong is not.
Here is what that came out as.
You can now declare what a field is supposed to be. A type of number, integer or date, plus pattern, enum, and min and max. Like required in update #1 and label in update #2, none of it is shown to the model. Extraction behaves exactly as it did before, and the declaration only changes what the response is willing to say afterward. A value that breaks your rule comes back flagged as type_mismatch, out_of_range or pattern_mismatch, and those rank above the engine's own suspicions, because a rule you stated is a stronger claim than a doubt I inferred.
The parsed values arrive in data.normalized, which has exactly the same shape as values and contains only the leaves you declared. values does not change and never will. It is the half that carries the coordinates and the character level match, so "¥13,220" stays "¥13,220" and 13220 shows up beside it in the other tree. The parsers are deterministic, with no model call, no added latency, and the same answer on every run. They handle full width digits, thousands separators, currency marks, unit suffixes like 3袋, accounting negatives written as △ or in parentheses, era years like 令和8年, and compound scale notation like 1億2千万.
When a parse fails the leaf is null and the cell says why, as not_numeric, not_an_integer, not_a_date or no_year. A date printed as 8/16 is refused rather than completed from the issue date. Filling in the year would be producing a value the document does not print, which is the one thing this whole product exists in order not to do.
One more, and it is a correction. In update #2 I wrote that values gives you "exactly the schema you asked for and nothing else". That was not true when the schema was sparse. I declared a single field on a delivery note and got four more back that I never asked for, with a declared count of 44. The thinner the schema, the more the model added whatever else it could see on the page. The response is now pruned to your declared fields before any of the matching work runs, so the extra values are never produced rather than produced and thrown away, and declared is a number you can predict again.
Now the breaking change, and a bad call I want on the record.
reason is gone from the v2 surface. It was always reasons[0], and keeping both meant the same document could report two different counts depending on which key you read. If you are reading it, read reasons and take the first element.
I deleted it on the reasoning that nobody was calling it yet. That was wrong. Someone had already written f.get("reason", "") against it, and the way it fails is the quiet kind. No exception, no log, every flag silently downgraded and the reason string empty. I only know because he sent me his code.
So here is the commitment. I will announce the removal of a published v2 key before I remove it. "No external callers" was not something I had any way to verify, and I was wrong about it.
That code, incidentally, is the first finished demo from the call. It turns a photo of an invoice into an import file for the accounting software its maker already uses, and he does the books for a small company on his own. There are a lot more of them coming, and several are more interesting than anything I would have thought to build. When they are all delivered I would like to bring them back here, with their makers' permission.
Replies