Receipt OCR performance comparison — 21 October 2026
Issue #694. Implemented replacement: PP-OCRv6 small images + Ministral 3 8B, with improved prompting/schema/normalization. PDFs now render locally through PDFium and use the same OCR; Textract is removed from the serving path. Four new calls using saved OCR returned 4/4 exact totals and complete item-cost lists, no truncation, and 1.384 s median model-stage latency. This is a development sample, not a production accuracy estimate. See the implementation follow-up below.
The original ten paid calls cost an estimated $0.00415208 at standard rates; the four new Ministral calls add $0.00085515. Cumulative reservations are $0.077, under the unchanged $0.08 cap and strict $0.10 testing limit, with $0.00500723 in full-rate service-cost estimates before cache discounts. This is not a settled AWS bill. No completed calls were repeated, ledger reset or reservations released. No Lambda, Step Functions or production changes were deployed. The original comparison and its limitations are preserved below.
Rollout update — 22 October 2026: deployment is authorized through the repository's normal dev → beta → production pipeline. Deployment status must be checked in GitHub Actions; the measurements and historical deployment statements in this report describe the evaluation at the time it ran. No additional paid inference tests are included in the rollout, and the persistent budget ledger remains unchanged.
Production follow-up — reviewed 24 October 2026: rollout completed, but two real cold OCR invocations took 32–40 seconds, and a reported item mismatch equals an explicitly included VAT disclosure. The four-image development score did not establish production accuracy or Lambda cost savings. See the final follow-up for measured Lambda reports, a conservative tax correction and remaining limits.
Recovery and method
The original handoff archive was unavailable in the new environment. Its
report, runner, README, dependency versions, model hashes, and console results
were recovered from the earlier chat, Assess low-cost OCR options for 694
(01a15d3a-167b-7604-ab19-6efe32c86076). The transcript establishes that the prior
AWS credential preflight failed before opening the ledger; no paid request
or reservation had occurred. This is recovery of an unexecuted paid evaluation,
not a reset of an existing spent ledger.
The original full local JSON files could not be recovered. The saved one-thread and two-thread results contain the recovered summaries, rounded per-receipt timings, and provenance; individual timing repetitions are unavailable. The completed benchmark was not repeated. Missing OCR text was regenerated from the same pinned images and hash-verified weights; all saved original amount tokens and final 28 lines match on every receipt. See text and hashes and verbatim recovery evidence.
AWS SSO device authorization authenticated the documented account and role.
Paid calls ran serially from a local Linux x64 Node process, against us-east-1,
using the production structurer at repository 643edcb. Every send reserved
its allowance on disk first, with SDK retries disabled. Each route stopped at
its first truncation. Thus both Gemma 3 routes attempted receipts 000–001 only;
Gemma 4 attempted all four. Unattempted receipts are not failures or successes.
Results include single calls, not repeated samples or a production accuracy/p95 estimate.
The Gemma 3 requests used the production prompt/schema, temperature 0, and
max_tokens: 2048. Gemma 4 used Mantle /openai/v1/responses, the image plus
adapted production prompt/schema, temperature 1, top-p 0.95, high reasoning,
and max_output_tokens: 2048. Different models/API settings are part of the
routes being compared; the test does not isolate OCR quality from parser quality.
Direct image output passed through the same normalizer with empty independent
OCR text; no generated text was substituted as independent repair evidence.
What actually ran
PP-OCRv6 small through RapidOCR 3.10.0 / ONNX Runtime 1.24.4 ran against four
public receipt images. The deterministic selection was receipts 000–003
from this SROIE mirror, pinned at
27be4271b251c256f695acbade9a801bffe85994. Images are 439–463 pixels wide and
933–1,013 pixels tall, approximately 84–122 KiB each. They include faint thermal
print, handwritten totals, discounts, and rounding. These four Malaysian
receipts are not a representative multilingual/quantity/PDF eval set.
The machine is Linux x86_64 with a cgroup CPU quota of two cores. This is neither deployed Lambda nor Graviton. Both detector and recogniser are the bundled v6 small models, totalling 29.7 MiB; hashes are saved in the raw reports. Orientation classification is disabled, detector minimum-side limit is 736, and preprocessing maximum side is 2,000 pixels.
Each thread configuration ran four fresh Python subprocesses, four separate warm-up calls, and twelve scored warm calls (three per receipt). Fresh-process timing includes interpreter/import/first model load/OCR. It is not a measured Lambda cold start. Warm statistics below describe these twelve calls, not a population p95.
| Configuration | Median warm OCR | Warm min–max | Fresh process + first OCR min–max | Peak process RSS |
|---|---|---|---|---|
| One inference thread | 2.635 s | 2.130–3.281 s | 2.842–3.695 s | 401.9 MiB |
| Two inference threads | 1.822 s | 1.573–2.116 s | 2.600–3.044 s | 403.6 MiB |
Per-receipt medians:
| Receipt | Printed total (MYR) | One thread | Two threads | Printed total token in OCR |
|---|---|---|---|---|
| 000 | 9.00 | 2.254 s | 1.733 s | Yes |
| 001 | 60.30 | 2.666 s | 1.851 s | Yes |
| 002 | 33.90 | 2.605 s | 1.793 s | Yes |
| 003 | 80.90 | 3.227 s | 2.034 s | Yes |
OCR output was stable across warm repetitions. Printed purchase prices and
the -5.59 discount are present, but this is text evidence, not correct
expense-draft extraction. Receipt 000 places a handwritten 9.00 after
CHANGE, followed by 100 for the printed 1.00; downstream selection can
still be wrong. Receipt 003 renders the time as 1231.00, which a parser must
not treat as an item price. Dataset transcriptions also contain typos and
duplicate lines, so the recorded transcript edit distances are diagnostics.
Two threads improved median latency by about 31% on this host. The observed memory suggests the OCR component can fit within 1 GiB, but Lambda allocates CPU with memory and this does not establish 1 GiB Lambda performance. The unpruned installed Python dependency directory is 426.3 MiB, exceeding the 250 MB unzipped Lambda ZIP/layer limit. Plan a CPU container or measure a carefully trimmed package; do not equate 29.7 MiB of weights with the complete deployment size.
Live extraction correctness
Raw provider content, normalized drafts, latency, and usage are preserved unchanged. The offline summary separates raw copying from normalized results.
| Route | Attempts | Valid raw JSON | Exact normalized total | Exact complete item-cost list | Truncations |
|---|---|---|---|---|---|
| Textract → Gemma 3 4B | 2 | 1/2 | 0/2 | 0/2 | 1/2 |
| PP-OCRv6 small → Gemma 3 4B | 2 | 1/2 | 0/2 | 0/2 | 1/2 |
| Image → Gemma 4 E2B | 4 | 4/4 | 3/4 | 1/4 | 0/4 |
An exact total matches the printed final charged value in MYR minor units.
The item metric compares multisets of all purchase, discount, and nonzero
rounding amounts; zero adjustments, subtotals, payment/change, and handwritten
copies of totals are excluded. It does not score item labels or ordering.
Gold was fixed before inference and checked against all four source images.
Currency MYR was supplied as context, so matching it is not independent
currency-recognition evidence. Gemma 4 returned literal RM on 001, which the
normalizer replaced with group MYR; only three of its raw currency strings were MYR.
| Receipt | Expected total | Textract + Gemma 3 | PP-OCRv6 + Gemma 3 | Direct Gemma 4 |
|---|---|---|---|---|
| 000 | 9.00 | Total absent; BHD currency; 9.00 item becomes 9,000 BHD minor units | Same currency/scale problem; additionally includes cash as a purchase | Correct total and cost; uses product name instead of merchant |
| 001 | 60.30 | Truncated, invalid JSON; currency-only fallback | Truncated, invalid JSON; currency-only fallback | Returns pre-rounding 60.31; pairs lamp with 10.00, omits 55.90 purchase and -0.01 rounding |
| 002 | 33.90 | Not attempted after route stop | Not attempted after route stop | Correct total; invents another 3.02 purchase from a product code; omits -0.02 rounding |
| 003 | 80.90 | Not attempted after route stop | Not attempted after route stop | Correct total and 80.91 purchase amount; omits -0.01 rounding; selects overlaid name as merchant |
There are separate model and application faults:
- On 000, both Gemma 3 raw responses omit total and currency, but copy 9.00.
The existing
currencyFromOcrthen mistakes the merchant's SDN BHD suffix for an ISO currency code and overrides the supplied MYR context. That causes the three-decimal BHD conversion. This is a deterministic normalizer failure, not proof that either OCR engine read the price as 90.00. Local OCR's raw model output also contains a cash line; its payment filter does not excludecash. - The 001 Gemma 3 responses exhaust the 2,048-token cap while emitting whitespace
after
quantity, fail JSON parsing, and return the minimal fallback. The schema request did not guarantee a usable extraction. On 000 the JSON is syntactically valid, butquantitycontains prose rather than a positive integer, which the normalizer defaults to one. Schema shape alone is insufficient. - Direct Gemma 4's wrong total, purchase pairing, duplicate item, and merchant selections already appear in its raw JSON. Normalization did not create them. All four Responses calls completed successfully; API compatibility is verified for this bounded payload, while financial correctness is insufficient.
These results reject a production switch with either tested configuration. They also expose problems in the current baseline; retaining its deployment is not an endorsement of its measured accuracy. The small, public, similarly formatted Malaysian sample cannot establish broader success rates.
Latency: measurements and limits
Seconds, one cloud call per attempted route/receipt:
| Receipt | Textract + Gemma 3 | PP-OCRv6 + Gemma 3 | Direct Gemma 4 |
|---|---|---|---|
| 000 | 2.713 | 3.445 | 15.198 |
| 001 | 12.061, truncated | 12.540, truncated | 17.033 |
| 002 | Not attempted | Not attempted | 16.008 |
| 003 | Not attempted | Not attempted | 16.266 |
Textract totals add measured OCR and model time; its OCR calls took 1.113 and 1.362 seconds. PP totals add the previous one-thread OCR median (rounded to milliseconds) to the newly measured Gemma time, not a deployed end-to-end measurement. Model-only medians were 6.150 s and 5.533 s across the two attempts, including truncation, versus 16.137 s for four direct-image attempts. These are not medians of correct successful extractions. On the two paired images, Gemma 4's median was 16.115 s; all-four and two-receipt populations must not be silently compared as equally representative.
The two-thread OCR results could reduce the local component, but have not been combined with another paid model run. Network region, cold connections, prompt caching, server load, different prompts, and reasoning settings may affect latency. No throughput, concurrency, tail latency, or Lambda cold start was measured.
Memory and Lambda suitability
| Route | Observed memory | Lambda assessment |
|---|---|---|
| Textract + Gemma 3 | Shared cloud-evaluation Node process peaked at 99.7 MiB across all routes; service memory invisible | Existing worker is 1,024 MiB ARM64. Fits existing packaging, but its whole allocation bills while awaiting both services. |
| PP-OCRv6 + Gemma 3 | Original standalone Python OCR peak 401.9–403.6 MiB; 29.7 MiB model files | CPU feasible candidate; Node plus Python, image/PDF decoding, larger pages, and extensions need additional headroom. ARM64 runtime and CPU performance remain untested. |
| Direct Gemma 4 | Same shared Node-process observation; no local model loaded; remote model memory invisible | Native OCR packaging unnecessary, but downloaded/base64/decoded images increase memory. A smaller adapter may suffice; minimum safe Lambda memory was not measured. |
Do not add independently measured process peaks and present the sum as a measured combined Lambda peak. The original unpruned Python dependency directory was 426.3 MiB, larger than the 250 MB unzipped ZIP/layer allowance. A pinned CPU container is the plausible PP-OCR packaging route; container images allow up to 10 GB uncompressed. CPU allocation scales with memory (1,769 MB provides one vCPU equivalent), so the two-thread x86 timing cannot be assumed at 1 GiB on Graviton. Native wheel compatibility and real billed duration still need verification. These are ordinary Lambda limits, not measurements of a deployed OCR function. Self-hosting Gemma was not tested; all compared language models are remotely hosted on Bedrock.
Pricing and cumulative budget
Verified us-east-1 standard rates: Textract price-list evidence is $0.0015/page for the first million pages; Bedrock pricing is $0.04/million input and $0.08/million output tokens for both models. Actual usage, including returned image/output token counts, replaces the old 1,000/1,000 illustration. The estimates below charge all input at full rate even when a response reports cached tokens. AWS billing/free-tier/cache discounts were not queried. No S3 upload, Lambda execution, or Step Functions execution was used.
| Route | Input / output tokens in this run | Service-cost estimate for run | Service cost per 1,000 attempts | With illustrative 1 GiB ARM waiting/compute worker |
|---|---|---|---|---|
| Textract + Gemma 3, n=2 | 1,951 / 2,174 | $0.00325196 | $1.626 | $1.725 |
| PP-OCRv6 + Gemma 3, n=2 | 1,967 / 2,223 | $0.00025652 | $0.128 | $0.235 |
| Direct Gemma 4, n=4 | 5,248 / 5,421 | $0.00064360 | $0.161 | $0.376 |
The final column is a scenario, using each route's arithmetic mean measured component durations at ARM Lambda's $0.0000133334/GB-second plus $0.20/million requests. It transplants x86/network timings into ARM pricing; it is not observed Lambda billing. It excludes initialization, validation, storage, logs, orchestration, retries, free tiers, and taxes. Truncated/incorrect attempts are included, so these figures are not costs per correct receipt. The n=2 OCR routes and n=4 direct route also have different receipt mixes.
Holding 1 GiB for Gemma 4's measured wait makes direct extraction more expensive than local OCR in this scenario despite eliminating Textract. Allocating 2 GiB to local OCR would double compute cost at unchanged duration; improved CPU speed might offset some of it but must be measured. Merely removing Textract does not establish the cheapest complete pipeline.
The canonical cumulative ledger
contained two $0.0015 Textract reservations, eight $0.0055 model reservations, and
$0.008 ancillary allowance at the original run’s conclusion: $0.055 retained.
The follow-up below appends four new reservations, taking the same ledger to $0.077.
The original /workspace/receipt-ocr-694/budget.json points to that same file.
It enforces a fixed $0.08 ceiling. The follow-up used $0.022 of the original
$0.025 headroom for new request IDs; no completed request was repeated and
the original token caps were not raised. Keep this ledger with all future
continuations. Both output reports reject overwriting attempted results and
duplicate ledger IDs reject repeat sends.
Gemma 4 was asked for high reasoning. The API reported zero separately counted reasoning tokens on each response; total output usage above is retained as returned. That field alone does not validate how internal computation is billed, and no hidden tokens were added or subtracted from the price calculation.
Step Functions and idle 1 GiB compute
Step Functions saves Lambda waiting cost when it calls the remote service
itself. Wrapping the current waiting Lambda preserves its compute charge.
The optimized Bedrock integration
can call Gemma 3 InvokeModel; keep OCR and validation/finalization in short
functions, pass durable object references, and let the workflow await Bedrock.
The integration supports S3 input/output for large payloads. Synchronous
Textract images can use the AWS SDK integration and an S3 object reference;
filter large OCR responses before carrying them into later states.
Standard is attractive for asynchronous Textract PDFs: start once with a stable idempotency token, wait for its completion notification through a callback, then fetch/validate results in a short task. This avoids the existing 500 ms polling loop in a 1 GiB Lambda. Six transitions is an illustrative happy path, not a counted implementation. Every polling iteration/retry adds transitions; shorter waits do not automatically make Standard cheaper.
Waiting/orchestration costs per 1,000 jobs, excluding service calls, short Lambda tasks, free tiers, logs, and connection/secret overhead:
| Wait duration | 1 GiB ARM Lambda compute only | Standard, six transitions | Express, assumed 64 MiB |
|---|---|---|---|
| 10 seconds | $0.1333 | $0.1500 | $0.0114 |
| 30 seconds | $0.4000 | $0.1500 | $0.0323 |
| 16.126 seconds, direct-image observed mean | $0.2150 | $0.1500 | $0.0178 |
AWS Step Functions prices used here are $0.000025/Standard transition and Express $1/million starts plus $0.00001667/GB-second, with duration and memory billing rounding. At six transitions, Standard breaks even against 11.25 seconds of 1 GiB ARM waiting before other tasks/costs. The measured 7.387-second baseline remote wait would cost $0.0985/1,000 in Lambda compute, already below $0.15 in Standard transitions; the local route's 5.533-second model wait is $0.0738/1,000. Express scenarios for those waits are $0.0087 and $0.0068/1,000, plus the CPU OCR and short tasks. 64 MiB is a billed-workflow-memory assumption, not the local model memory size.
Gemma 4 E2B
is Mantle-only and cannot simply replace the model ID in bedrock:invokeModel.
Our successful SigV4 request proves the evaluation client, not a Step Functions
integration. A direct HTTP Task
requires an EventBridge connection, has a 60-second timeout, and does not
inherit the SDK client's SigV4 signing. Bedrock supports bearer API keys,
which could be a connection design to validate, with rotation and scoped
permissions. The workflow's 256 KiB state payload limit also matters: these
small inline images fit, but normal large receipts may not. Larger-image
transport and API authentication need an explicit design; Bedrock InvokeModel's
S3 extension does not establish S3 support for a Mantle HTTP Task.
A smaller SigV4 Lambda adapter is an alternative: at a hypothetical 128 MiB,
16.126 seconds of waiting compute is about $0.0269/1,000 calls, plus invocation
and workflow costs. It still pays for waiting, and 128 MiB suitability is
unmeasured. Express has a five-minute limit and no callback or .sync job-run
pattern; asynchronous Express has at-least-once execution semantics. Preserve
leases, completed-step checkpoints, and paid-call deduplication regardless of
workflow choice. Standard callbacks suit long PDF waits; Express direct service
calls are the more promising cost experiment for short image jobs. No workflow
was created or benchmarked in this task.
Published evidence is a separate category
The research evaluation retains sources and limitations. Paddle's vendor evaluation reports PP-OCRv6 small recognition accuracy of 81.3% and 0.61 s/image on Xeon/ONNX; those are neither receipt-field accuracy nor Lambda timing. Google's E2B OmniDocBench 1.5 edit-distance score (0.290) measures broader document parsing. The independent Fraunhofer receipt result of 53.79% exact fields is for direct Gemma 3 image input, not either OCR→Gemma route tested here. These published scores cannot be combined into a common leaderboard or used to override the observed errors.
Saved evidence and verification
- Live results, offline summary, and persistent ledger.
- Pinned public fixtures, local text, and original dependency versions.
- Runner and safe offline analysis instructions.
Budget tests verify cap enforcement, persisted reservations, duplicate/concurrent run rejection, and corrupt-ledger refusal. Offline analysis checks every saved model/OCR result against its reservation, token bounds, raw JSON, totals, complete cost multisets, and standard-rate cost calculations. No inference was repeated to validate this report. Formatting and local evidence links are checked separately.
Original comparison recommendation
Do not replace Textract with either tested configuration yet. PP-OCRv6 small is a credible low-memory CPU OCR component and retains the necessary text amounts, but pairing it with the unchanged Gemma 3 structurer fails the same quality checks as the existing baseline. Direct Gemma 4 integrates successfully and removes native OCR packaging, but 3/4 totals, 1/4 complete amount lists, merchant errors, and a 16-second median do not support production adoption.
Prioritize the shared extraction contract: fix currency evidence so SDN BHD cannot select BHD, exclude cash/tender from purchases, reject truncated or incomplete drafts, and validate totals plus rounding/discount/item arithmetic. Then evaluate a successor structurer using these saved OCR inputs and a broader, private labelled set. AWS lists Gemma 3 4B as legacy with EOL on 30 March 2027, so changing only the OCR engine would leave a near-term model migration. Retain Textract for PDFs until a bounded rasterization alternative is measured.
For orchestration, assess Express with direct supported service calls for short image jobs and Standard callbacks for asynchronous PDFs; compare measured wait time to transition costs before choosing. Treat a smaller remote-call adapter as another cost option. Keep issue #694 open: deployment should follow corrected extraction, representative multilingual/quantity/large-image/PDF testing, real ARM64 packaging and cold-start measurements, and a validated orchestration path. The present evaluation is complete within its fail-stop budget; none of these follow-ups were deployed or silently substituted into the measured results.
Implemented follow-up: PP-OCRv6 small + Ministral 3 8B
After the original comparison, the replacement was implemented in source:
PP-OCRv6 small for JPEG/PNG through a dedicated Python CPU container Lambda,
Ministral 3 8B Instruct (mistral.ministral-3-8b-instruct) for structuring,
and revised system instructions/schema/normalization. PDFs retain the existing
asynchronous Textract path. No production or evaluation Lambda was deployed.
The prompt separates receipt/context data from instructions, copies final charged totals and signed adjustments, excludes tender/summary rows, and forbids invented balancing amounts. The schema requires explicit fields with nulls for unknowns and integer quantities. The normalizer rejects non-stop completions, prose quantities and excess decimal precision; avoids the SDN BHD currency trap; filters cash/summary rows; and retains signed rounding. A subsequent local-only cleanup removes purchase categories from negative adjustments and discount scope from positive purchases. Offline replay of all four saved provider responses confirmed unchanged totals, currency and item costs; no further model call was made. This cleanup changes metadata, not the scored financial values.
Measured new model calls
Immutable follow-up requests, raw responses, drafts, usage and reservations record four new model calls, reusing the previously saved PP-OCRv6 OCR text. No completed OCR, Textract or Gemma request was repeated. The production request used temperature zero, strict JSON schema, and 2,048 maximum output tokens. The new runner rejects requests over 32,000 bytes, reserves before every send through the existing ledger, disables SDK retries, and refuses to overwrite reports.
| Receipt | Exact total | Exact complete item-cost multiset | Model call + normalization |
|---|---|---|---|
| 000 | 9.00 MYR | Yes | 1.139 s |
| 001 | 60.30 MYR | Yes, including -5.59 discount and -0.01 rounding | 1.564 s |
| 002 | 33.90 MYR | Yes, including -0.02 rounding | 2.445 s |
| 003 | 80.90 MYR | Yes, including -0.01 rounding | 1.205 s |
All four returned valid JSON with normal stop reasons, exact normalized totals, and exact expected complete item-cost lists; none truncated. Median model-stage latency was 1.384 seconds, range 1.139–2.445 seconds. This is a local client calling Bedrock with saved OCR text, not deployed end-to-end Lambda latency. Two responses reported 784 cached prompt tokens each, which may affect latency. These results do not measure uncached throughput or p95.
Currency MYR was supplied as context, so matching it is not independent currency recognition evidence. Merchant labels, categories, translations and quantity accuracy were not scored. In particular, the live output labeled receipt 001's negative discount as DRINK; the later deterministic metadata cleanup removes that category without another model call. Public fixture failures informed the rules; prompt examples use different amounts. This is a development/regression sample, not a held-out set. Switching the model, prompt, schema and normalizer together means the improvement cannot be attributed to model size alone.
The old PP-OCRv6 + Gemma configuration attempted only 000–001: 0/2 exact totals, 0/2 complete item-cost lists, one truncation. The new configuration got 4/4 on both financial metrics, including those two receipts. That supports the new configuration while neither establishing a population accuracy rate nor a like-for-like production speedup.
Packaging and price evidence
The image builds from AWS's Python 3.12 Lambda base and verifies the original model SHA-256 hashes. The actual handler completed an offline container smoke check using mocked S3, a read-only filesystem, no network, 2 GiB memory and one CPU; warm calls reused the model. An initial single-image container check used 419.1 MiB peak RSS and 5.772 seconds for first model load/inference. This is one local container check, not a repeated benchmark or Lambda cold-start measure.
The CDK helper uses 2 GiB x86_64, one OCR inference thread, 60 seconds and ten reserved executions. This architecture matches the evaluated native packages; Graviton remains unmeasured. The 1 GiB ARM Node worker remains separate and bills while waiting for both helper OCR and Bedrock. Step Functions is not implemented by this change. PDFs incur their existing Textract cost.
At standard us-east-1 rates of $0.15/M input and $0.15/M output, the new calls used 4,646 input and 1,055 output tokens: $0.00085515 before cache discounts. That scales to $0.214 per 1,000 model attempts at this sample's usage; it excludes all Lambda, S3, logs and orchestration. The earlier illustrative 1,000-input/1,000-output receipt instead costs $0.30 per 1,000 in model tokens. An assumed two-second 2 GiB x86 OCR invocation adds about $0.067 per 1,000; actual worker waits, cold starts and OCR duration still need measurement.
The ledger retains every original entry unchanged, appending only four
ppocr6_ministral_v1 reservations of $0.0055. Cumulative reservations are now
$0.077, under the fixed $0.08 cap and strictly below the user's $0.10
limit. Cumulative full-standard-rate service estimates are $0.00500723,
not a settled AWS bill. No reservations were released or reset. The retired
historical runner now rejects live execution to prevent accidentally replaying
old calls with the changed production prompt. No further paid calls were made.
Updated recommendation
Proceed with PP-OCRv6 small + Ministral 3 8B + revised prompting and deterministic validation as the implemented replacement for image receipts. The development sample now extracts all expected financial amounts, with short model responses and far less API cost than keeping Textract for every image. Retain Textract for PDFs. Before production rollout, validate a broader private multilingual, quantity, orientation, large-image and multi-page PDF set and measure actual Lambda cold/warm memory, billed duration and full-pipeline cost. Choose Step Functions only after measuring waiting costs against orchestration charges. Issue #694 remains open for those checks; no production changes were deployed.
PDF follow-up: local rendering and OCR
PDFs now use the same receipt OCR Lambda. PDFium (pypdfium2 5.14.0) rasterizes
each visible page and PP-OCRv6 small reads it; Ministral receives the combined
text with explicit [Page N of M] boundaries. PDFs with embedded text are also
rasterized in this first implementation. There is no embedded-text bypass and
no Textract fallback. The former serving Textract adapter, polling loop and IAM
grants are removed; its SDK remains development-only for historical evaluation.
The worker/helper environment key is now SCAN_OCR_FUNCTION_NAME.
Existing upload restrictions remain 10 MB, one to three pages, unencrypted. The helper independently checks the byte and page bounds and rejects encrypted PDFs even when they open with an empty password. Rendering targets 200 DPI with a longest-edge cap of 2,000 pixels before bitmap allocation. One page, bitmap and decoded image is held at a time and closed before the next page. The model is reused across pages. Aggregate text above 24,000 characters or a failed page rejects the document rather than silently returning a partial document. Blank PDFs return empty text so the existing structurer skips a model call. The container remains 2 GiB x86_64 with one inference thread and a 60-second timeout; the Node client has a 70-second request timeout and disables retries.
Offline native verification
PDF container smoke results
come from the built AWS Python 3.12 Lambda image, restricted to one CPU, 2 GiB,
a read-only filesystem and no network. The real handler read mocked S3 bodies.
The two fixtures are generated by apps/receipt-ocr/smoke_pdf.py, with synthetic
English invoice text; no production receipt or private data was used.
| Synthetic fixture | Pages | Expected page order and amount-token presence | Local full-document OCR |
|---|---|---|---|
| Digitally generated PDF | 3 | All three page markers and 10.00 / 20.00 / 30.00 on their respective pages | 8.255 s |
| Scanned-image PDF | 3 | All three page markers and 10.00 / 20.00 / 30.00 on their respective pages | 6.820 s |
Peak process RSS across this smoke was 608.8 MiB. The digital fixture ran first and includes lazy model setup; the scanned fixture reused the model. There is one local invocation per fixture. This is functional packaging evidence, not a Lambda cold/warm benchmark, a representative latency distribution, or a comparison between digital and scanned PDF performance. Corrupt PDFs, four-page PDFs and a synthetic empty-user-password encrypted PDF were rejected before OCR; a blank PDF returned empty text. The previous image smoke still passed in the PDF-enabled container.
Standard-library unit tests verify sequential resource cleanup, page limits, encryption rejection, invalid page geometry, aggregate text bounds and failure on unreadable later pages. A mocked service-pipeline test passes a checksum-bound three-page PDF through real upload validation, the Lambda OCR adapter and the Ministral adapter, verifying that every page reaches the extraction request. Infrastructure tests verify that no Textract IAM action remains.
Cost and suitability implications
The serving path no longer pays Textract's $0.0015 per PDF page; rendering and OCR instead incur Lambda compute, with cost increasing with page count and complexity. The Node worker still bills while waiting for OCR and Bedrock. No Lambda was deployed and no PDF was sent to a language model during these checks, so PDF financial extraction accuracy and actual deployed cost remain unmeasured. The earlier four-image Ministral result is not evidence of PDF financial accuracy. Embedded-text bypass, form-field handling, unusual layouts, small print and a representative private PDF set remain evaluation work.
No additional paid test ran. The ledger is byte-for-byte unchanged at $0.077
reserved (SHA-256 e611a1e00faca4af1a175b0461fb8a82a7377ba803f28b15224831a2b86eb61f),
with every prior reservation retained. Nothing was deployed. The implemented
image/PDF path is ready for code review; production rollout should follow the
broader accuracy and real-Lambda checks already tracked in issue #694.
Production follow-up — reviewed 24 October 2026
PR #742 reached production through the successful Main Pipeline. The following observations come from two existing user submissions on 22 October, not additional paid evaluation calls. Aggregate evidence is in production-reports-2026-10-22.json. Private uploads, OCR text, account identifiers and payment details are excluded.
| Existing submission | OCR duration / billed duration | OCR peak memory | Node worker duration / billed duration | Worker peak memory |
|---|---|---|---|---|
| First | 39.902 s / 40.365 s | 696 MiB of 2,048 MiB | 43.246 s / 44.949 s | 225 MiB of 1,024 MiB |
| Second | 32.185 s / 32.390 s | 651 MiB of 2,048 MiB | 34.501 s / 36.137 s | 224 MiB of 1,024 MiB |
Both use fresh execution environments. Python init alone was 462/204 ms; lazy OCR/model initialization runs inside the invocation and is included in duration. These two different receipts do not establish warm latency, tail latency, concurrency throughput, or the reason for the cold-time slowdown. Local offline OCR of the same uploads took 8.135 s for the first and 4.004 s for the second in one container, with the latter reusing the model. That is functional verification with no AWS/model calls, not a Lambda replacement benchmark. Local timings substantially underestimated these deployed durations.
At published us-east-1 first-tier on-demand rates, the two OCR invocations imply $0.002425 compute and the waiting ARM Node workers $0.001081 compute; four requests add $0.0000008. Lambda alone is approximately $0.003507 for the pair, or $1.75 per 1,000 attempts if these two cold cases were representative. This uses observed billed duration with standard prices, not an AWS invoice. It excludes Bedrock, storage, logs, DynamoDB, free tiers and discounts. The OCR helper alone is about $1.21/1,000; standalone Textract OCR is $1.50/1,000. An end-to-end saving over the old pipeline is not established by these two cases, and earlier local-time savings scenarios must not be treated as measured Lambda economics.
The worker cost averages about $0.00054 per receipt, much of it waiting. This strengthens the case for testing direct-service Step Functions orchestration: ten Standard transitions at $0.025/1,000 would cost $0.00025 per receipt before validation/persistence compute and other states. Actual savings depend on the final state count and remaining work. Step Functions would not remove the 2 GiB OCR function's own 32–40 seconds. It remains a separate implementation and cost evaluation; no workflow was deployed or executed for this follow-up. Also profile a 512 MiB Node worker before adding orchestration: halving memory would halve its waiting allocation at unchanged duration, but these two image peaks do not establish safe PDF/worst-case headroom or the CPU/latency effect. No function memory setting changed in this follow-up.
The second receipt has two purchases of 4.49 and 2.29, a total of 6.78 marked as including VAT, and a tax-table disclosure of 1.13. Its reported item excess of 1.13 is consistent with counting that disclosure again. The expired saved model response cannot establish its exact raw item labels. The correction adds explicit included/excluded-tax examples and a deterministic guard: remove positive tax-only rows only with explicit included-tax OCR evidence and known non-tax amounts that already equal the grand total. Keep additional taxes, refunds, unknown amounts and unexplained mismatches; never invent a balancing row. Mocked regression tests exercise this without buying another completion.
No worker or model-fallback error appeared in the inspected submission window; both workers returned normally. The first client error's wording is unavailable, so its exact cause remains unresolved. A bounded transport retry now preserves the same attempt ID and request, allowing recovery of a lost acceptance response without a new credit reservation or paid dispatch. API rejections and failed jobs are not automatically restarted. Opaque client-operation and server job-outcome events improve correlation on any recurrence. This recovery is not proof of the original failure's cause.
Recommendation: ship the conservative VAT and client/UI fixes with regression checks, retain the user-visible total mismatch warning, and prioritize cold/warm Lambda profiling and a held-out receipt set before claiming broad accuracy or large cost savings. Evaluate Step Functions using these observed waiting costs. The persistent evaluation ledger remains byte-for-byte unchanged at $0.077 reserved under its $0.08 cap; no paid inference was repeated or added here.