Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ vendor/afm/** -text
# which format a later fixture takes. And here rather than as a `core.autocrlf false` step in CI,
# because that would give the runner bytes no Windows clone of this repository gets.
fixtures/** -text

# The recognizer's models, fixture images and expected answers are bytes (docs/35-OCR-SCOPE.md).
# Its dictionary and expected.jsonl are ASCII with LF endings, so git would take them for text, and a
# CRLF checkout would move the dictionary's pinned sha256 in the recognizer's identity.
ocr/models/** -text
ocr/tests/data/** -text
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,39 @@ jobs:
- name: deny
run: cargo deny check

# The recognizer behind `--ocr` (docs/35-OCR-SCOPE.md): its own workspace and its own
# pinned toolchain, which the script installs and asserts. `ocr-platforms` below runs its
# fixture test on every supported CPU family; this step keeps it in the gate.
- name: ocr
run: ci/ocr.sh

# -----------------------------------------------------------------------------------------
# docs/35-OCR-SCOPE.md §10.1 — the recognizer answers with the same bytes on every platform
# -----------------------------------------------------------------------------------------
#
# Its fixture test compares every answer's bytes with ocr/tests/data/expected.jsonl, written on
# Apple silicon. Linux x86-64, Linux arm64, Windows x86-64 and Intel macOS each run it: four
# vector widths' worth of SIMD kernels (rten picks AVX-512 where the runner has it) and three
# operating systems' maths libraries, none of which may move a byte.
ocr-platforms:
if: github.event_name != 'schedule'
name: ocr same bytes · ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm, windows-latest, macos-latest, macos-15-intel]
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master branch, 2026-09-24
with:
toolchain: "1.98.0"
- name: The recognizer's fixtures, byte for byte
working-directory: ocr
shell: bash
run: cargo test --release --locked

# -----------------------------------------------------------------------------------------
# docs/history/03-V0-SCOPE.md §5 — every exit criterion, as its own named job
# -----------------------------------------------------------------------------------------
Expand Down
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,58 @@ milestone documents ([`05`](docs/history/05-MILESTONES.md), [`09`](docs/history/

## [Unreleased]

### ParseBench runs reuse the score of an unchanged output (decision #121)

- **`ETHOS_BENCH_LEDGER=<dir>`** makes the ParseBench adapter store each result under the SHA-256 of
what ParseBench's scorer receives, and reuse it for an example whose inputs repeat. The scores are
the same, checked against fresh scoring (0 of 2,553 apart), and an `--ocr` run with a few pages
changed scores in 38 s instead of 298. A held-out split was measured too, and not built: at this
benchmark's size it could not tell a typical decision from chance.
- The adapter's README pins ParseBench at `1f5b37a`, where its numbers were measured, and states a
full run's time as measured, about eight minutes.

### A scanned page and an image file are read, and say so: OCR, v3 (decision #120)

`extract --ocr` and `markdown --source --ocr` read text no text layer carries. Without the flag
nothing changes but the profile hash: the default profile gained a field, `recognizer`, set to
`not_pinned`, so its `profile_sha256` moved, and every artifact's with it, as every release's does.

- **A page that shows no text and places exactly one image is read by a recognizer**, and each
word it reads is a `text_run` of derivation `recognized`, under the OCR profile, whose
`profile_sha256` differs from the default's. The trigger is a reason code, never a score. A page
that needs OCR and is not read — several images, an inline image, a placement that is not
axis-aligned, a filter or colour space the recognizer does not decode, an image a mask shapes —
is counted under `scanned-page-not-recognized` with its reason. Nothing recognized is merged
with drawn text: the seal refuses a page holding both, so contract §6's placement rule is
mechanical.
- **A PNG or JPEG file is a one-page document**, its page the file's own pixel grid, one pixel to
one point, declared under `image-resolution-not-read`; the source is the file. Without `--ocr`
an image file is refused by name, with the flag that reads it. `ground` refuses it by name:
`ethos.grounding.v1` admits PDF and the office sources only (decision #16).
- **The recognizer is `ethos-parser-ocr`**, a separate binary in `ocr/`, on Rust 1.98.0 while the
engine stays on 1.88: PP-OCRv5 mobile (detection and English recognition, 12.7 MB of models,
Apache-2.0, built into the binary) on rten 0.26. Its output is the same bytes on every supported
CPU: rten's sums are made order-fixed in a vendored `rten-vecmath`, and Linux x86-64 and arm64,
Windows x86-64 with AVX-512 and macOS arm64 and x86-64 answer identically, at one thread and at
the default. An x86-64 CPU without AVX2 and FMA is refused by name. The engine finds it beside
its own executable or at `ETHOS_PARSER_OCR`; without it `--ocr` fails by name and never falls
back to an artifact read without it. No confidence is written.
- **On the wire:** the locator variant `recognized` (the run's place on its page, the image object
it was read from, the word's corners in the image's pixels); the profile's `recognizer`; the
source media types `image/png` and `image/jpeg`; five limitation codes,
`scanned-page-not-recognized`, `recognized-words-outside-visible-box`,
`recognized-text-orientation-not-corrected` and `recognized-text-has-no-font`, and
`image-resolution-not-read`. In the library: `extract_recognized`, `extract_image`,
`image_media_type`, the `Recognize` trait and the protocol's types.
- **Measured.** ParseBench with `--ocr`: 52.10 -> 53.94 overall, 75 pages up and none down
(content faithfulness 0.6750 -> 0.7524). Without the flag, the eight gate documents and
opendataloader-bench's 200 extract byte-identical but the profile hash; with it, the same 208
hold the same nodes. About 1.9 s and 1.35 GB peak per 200-dpi page on an Apple M4 Pro.
- **Not read yet:** composite pages, pages that need a renderer, CCITT, JBIG2 and JPEG 2000
images, photos, text turned within an image. OCR moves ahead of assist, so it is v3 and assist
v4. `ci/gate.sh` gains a tenth step, `ci/ocr.sh`, and CI an `ocr-platforms` job that runs the
recognizer's byte-identity fixtures on five runners.

### A footnote mark set against its label joins its line, an amount that opens with its sign opens its cell, and small numbers beside amounts key a table (decision #119)

Folded into `whitespace-tracks-v16`, which no release carries yet, so no rule id and no
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ ethos-parser ground repr.json > grounding.json
| Command | What it does | Exit codes |
| --- | --- | --- |
| `classify` | Counts and reason codes for a PDF — is it scanned? is the layout hard? | 0 / 1 / 2 |
| `extract` | The document as text runs with locators (`DocumentRepresentation v0`) | 0 / 2 |
| `extract` | The document as text runs with locators (`DocumentRepresentation v0`). `--ocr` also reads scanned pages, and PNG or JPEG files, through the recognizer `ethos-parser-ocr` ([`docs/35-OCR-SCOPE.md`](docs/35-OCR-SCOPE.md)) | 0 / 2 |
| `ground` | Turn a representation into `ethos.grounding.v1`, the citation format | 0 / 2 |
| `markdown` | Markdown plus a map from every character back to a node. Takes a representation, or `--source <FILE>` to read a document and project it in one process — the same bytes, without the record on disk between the two stages | 0 / 2 |
| `html` | The same thing in HTML, projected from the document rather than from the Markdown | 0 / 2 |
Expand All @@ -168,17 +168,17 @@ stdout never changes.
## Formats it reads

PDF, DOCX, XLSX, PPTX, ODT, ODS, ODP, RTF and EPUB — all into the same record, with the same
fingerprint and the same JSON shape.
fingerprint and the same JSON shape. With `--ocr`, PNG and JPEG too, each read as one page.

The format is decided by looking at the bytes, never the file extension. A `report.bin` that is
really a PDF still reads; a `.docx` full of something else is a clear error. Anything the engine
has no reader for is refused by name:

```
$ ethos-parser extract data.csv
engine: unsupported media type: these bytes state no format this engine reads. Three signatures
engine: unsupported media type: these bytes state no format this engine reads. Five signatures
were looked for at byte 0 and none is present: a ZIP local file header ... an RTF brace group ...
and a PDF header. Nothing else was consulted — not the file's name ...
a PDF header; and a PNG's or a JPEG's ... Nothing else was consulted — not the file's name ...
```

CSV is deliberately absent. A CSV parse would produce real text, but the engine would have to
Expand Down
6 changes: 3 additions & 3 deletions ci/forbidden-tokens.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ case "$mode" in
# 0` — a verdict its own evidence contradicts.
pattern='confidence|quality_score|trust_score|is_good'
why='docs/01-CONTRACT.md §9 forbids any public confidence field, score, grade, or quality
summary — at any version, including "diagnostic only". The v2.1 OCR lane may record a
processor-reported uncertainty, and when it does it goes on a recognition type under its own
summary — at any version, including "diagnostic only". The OCR lane (v3) writes none; if a
later slice records a processor-reported uncertainty, it goes on a recognition type under its own
profile, never on these.'
;;
verification)
Expand All @@ -66,7 +66,7 @@ the failure this whole project is arranged to prevent.'
;;
esac

files=$(find crates -path '*/src/*' -name '*.rs' | sort)
files=$(find crates ocr -path '*/src/*' -name '*.rs' -not -path 'ocr/vendor/*' | sort)
if [ -z "$files" ]; then
echo "::error::no crate sources found; the scan would pass vacuously" >&2
exit 1
Expand Down
5 changes: 4 additions & 1 deletion ci/gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ elif [ -x ../ethos-oracle/target/release/ethos ]; then
fi

step=0
total=9
total=10
announce() {
step=$((step + 1))
printf '\n\033[1m[%d/%d] %s\033[0m\n' "$step" "$total" "$1"
Expand Down Expand Up @@ -190,6 +190,9 @@ cargo deny check
announce 'sdk suites — node and python, the version guards that had never run'
ci/sdk-suites.sh

announce 'ocr — the recognizer, in its own workspace on its own toolchain'
ci/ocr.sh

printf '\n\033[1;32mgate: all %d checks passed.\033[0m\n' "$total"
printf 'Not run, by design: the oracle build, the toolchain tripwire, v0-fuzz-smoke,\n'
printf 'deny-policy-is-enforced, and the labelled re-runs of the suite above.\n'
Expand Down
18 changes: 18 additions & 0 deletions ci/ocr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# The recognizer behind `--ocr` (docs/35-OCR-SCOPE.md): fmt, clippy and its tests, in its own
# workspace on its own pinned toolchain (ocr/rust-toolchain.toml, 1.98.0; the engine stays on
# 1.88.0). Its fixture test compares every answer's bytes with ocr/tests/data/expected.jsonl,
# written on Apple silicon, so on any other CPU this is the same-bytes check of §10.1.
#
# Release mode: the fixture test runs both models over five pages, which a debug build of rten
# takes minutes to do.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../ocr"
want="$(grep -oE '[0-9]+\.[0-9]+\.[0-9]+' rust-toolchain.toml | head -1)"
rustup toolchain install "$want" --profile minimal --component rustfmt,clippy >/dev/null
got="$(cargo --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
echo "ocr/rust-toolchain.toml wants $want; cargo reports $got"
[ "$want" = "$got" ] || { echo "ocr: toolchain pin not in force" >&2; exit 1; }
cargo fmt --check
cargo clippy --release --all-targets --locked -- -D warnings
cargo test --release --locked
Loading
Loading