Was this contract ever physically signed? Detecting unsigned documents in scanned archives

TL;DR: We built a signature-presence detector for scanned documents and scored it against expert zone annotations on 100 real Tobacco800 pages (66 signed / 34 unsigned): precision 0.981, recall 0.773, F1 0.864. The trivial baseline — assume every archived contract is signed — scores F1 0.795 and finds zero unsigned documents; the detector flags 33 of 34 with 1 false positive. The best signal is not what you'd guess: OCR cannot read an ink signature, and that failure is exactly what we detect. Every number is a point-in-time measurement (2026-07-07), committed in content/signature_results.toml and reproducible with the commands at the end.

What a signature looks like to an OCR engine

Here is a real signature from the eval set, as paddleocr saw it — two consecutive blocks near the bottom of a 1970s typewritten letter:

block text OCR confidence
wm.Hobbr 0.62
Wm. D. Hobbs 1.0

The engine read the typed name perfectly and choked on the ink squiggle above it — emitting a garbled, low-confidence token. It has no idea a signature exists. But that pattern — a low-confidence garble token sitting directly above a cleanly-read personal-name line, in the lower part of the page — is what a signature reliably does to OCR output. We don't detect signatures; we detect their wreckage.

Why anyone needs this

The question comes from contract lifecycle management: an enterprise migrating a legacy archive — thousands of scanned historical contracts — needs to know which of them were never actually signed. An unsigned contract in an archive is a legal and audit problem, and the default assumption ("it's in the archive, so it's executed") is silently wrong for a substantial fraction: 34 of 100 pages in our real-archive eval set carry no signature at all. No document-level quality score will tell you this — we measured that blind spot separately in our OCR-omission article: a missing signature, like any omission, produces no block for a quality signal to score.

Three signals, and one deliberately rejected

detect_signature(ir) combines three block-level signals, each designed from real Tobacco800 OCR output, combined as a probabilistic OR into a P(signed) confidence:

  1. Closing salutation — a block matching "Sincerely / Regards / Very truly yours / …". By far the strongest: present in 73% of signed documents vs 6% of unsigned ones, with near-perfect precision. A letter that signs off was signed.
  2. Explicit signature cues/s/, By:, "duly authorized", "authorized signature".
  3. The signature block — the squiggle-over-name geometry above: a typed personal-name line in the lower page with a low-confidence OCR token directly on top of it. This recovers signed memos and forms that carry no salutation, at zero measured false-positive cost on this set.

One tempting signal was deliberately rejected: per-block OCR confidence as a general occlusion detector. On real scans, blocks overlapping signature/stamp zones do average lower confidence (0.893 vs 0.963) — but unsigned faxes and telexes are just as noisy as signed letters, so at block granularity the signal barely discriminates and leaning on it hurt precision. Confidence only becomes useful when anchored to geometry (signal 3): low confidence directly above a name line means something; low confidence in general means it's a fax.

Results against expert ground truth

Ground truth is the GEDI zone annotations for Tobacco800: a page counts as signed iff its annotation carries a signature zone. Tobacco800 annotates signatures comprehensively, so a zero-zone page is a genuine negative, not an unannotated one. On 100 pages:

precision recall F1 accuracy
always-signed baseline 0.660 1.000 0.795
signature detector 0.981 0.773 0.864 (+0.069) 0.840

The F1 delta looks modest because the baseline gets recall for free on a mostly-signed corpus. The column that matters is the one F1 doesn't show: the baseline identifies zero unsigned documents by construction; the detector flags 33 of 34, with 1 false positive. For the archive-migration question — "show me everything that might never have been executed" — that is the entire value, and the always-signed default provides none of it.

Each prediction carries evidence_block_ids, so a reviewer sees which blocks fired — the same source-attribution discipline as the rest of the pipeline: a claim you can't trace to evidence is a claim you can't audit.

Honest limitations

Reproduce it yourself

git clone <repo> && cd contract-rag && uv sync --extra dev
# Tobacco800 TIFFs + GEDI XML zone annotations: from the Illinois CDIP collection (never committed here)
SIGNATURE_DIR=path/to/tobacco800/tiffs SIGNATURE_GT_DIR=path/to/gedi/xml \
  uv run python -m contract_rag.eval.signature

The OCR parses are IR-cached, so re-runs are fast. The detector itself is pure logic over the parsed document — unit-tested with hand-built IRs, no OCR or network needed. If your numbers differ materially, open an issue — that is the point of measuring in public.