# Rubbing Ocr Review

> Run auditable OCR baselines on registered rubbing, stele, copybook, and inscription images, preserving empty or failed results, and create append-only, actor-declared review decisions and pending human-review handoffs. Use when comparing OCR across an original and deterministic derivatives, binding readings to image hashes and pixel regions, retaining uncertain or rejected readings, separating AI prelabels from declared-human review, or handing OCR evidence to text-structure workflows.

- Skill: `lx050/rubbing-ocr-review` (Agent Skill, multi-file: 13 files)
- Install (CLI): `npx skillmds@latest add lx050/rubbing-ocr-review`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lx050/rubbing-ocr-review/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lx050 (https://skillmd.com/u/lx050)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lx050/rubbing-ocr-review

---


# Rubbing OCR Review

Create traceable machine candidates and immutable actor-declared review
history. Never present OCR output as ground truth, recovered writing, proven
history, or completed human review. The current self-attested identity mode
can report declared-human coverage, but it can never satisfy the authenticated
human gate.

## The authenticated human gate is unreachable by construction

Read this before promising anyone that a review will "pass the human gate".

`review-verify` reports two different things under `human_review_gate`, and
they are routinely confused:

- **`declared_human_coverage`** — does a declaration claiming
  `actor_type: human` have a current decision for every reviewable item? This
  **can** reach `status: complete` / `satisfies: true`. It is what
  REVIEWER_GUIDE asks a reviewer to aim at, and it is a real, useful result.
- **`eligible` and `satisfies`** — the authenticated gate. These are
  **hard-coded Python literals** (`"eligible": False, "satisfies": False` in
  `verify_review_path`). They are not a queue waiting for a person and not a
  flag a sufficiently qualified reviewer can flip. **No human review, however
  expert, careful, or well documented, can ever make them true.**

The gate is unreachable because the only supported identity assurance is
`self-attested-not-authenticated`: software has no way to check that the person
behind a declaration is who they claim to be. Reaching the gate would require
an **external trusted-identity verifier plus a contract change** — not a better
reviewer and not more review. Until that exists, `--require-human-gate` is a
correct fail-closed publication stop, and `identity-unverified` with reason code
`self_attested_identity_not_authenticated` is the expected terminal state.

Do **not** "fix" this by making the gate satisfiable. A gate that a
self-attested actor can pass is worse than no gate: it converts an honest
"nobody has verified who reviewed this" into a false claim of verified human
review. See
[HUMAN-REVIEW-HANDOFF-002/REVIEWER_GUIDE.md](../../docs/research/records/runs/HUMAN-REVIEW-HANDOFF-002/REVIEWER_GUIDE.md)
and the recorded dry run under
`docs/research/records/runs/OCR-EXIT-FORWARD-001/dry-run/`, which shows
coverage complete and the gate still blocked in the same command output.

## Preconditions

1. Register and verify the source with `research-provenance-ledger`.
2. Pass untrusted images through the project's quarantine and image safety gate.
3. For a `DER` input, verify its `rubbing-image-preparation` manifest.
4. Choose an OCR adapter whose code, weights, data, and license passed the OCR
   candidate registry. Do not download or install adapters from this Skill.
5. Wrap material runs with `research-run-ledger` when command-level transcripts
   are required.

Read [output-contract.md](references/output-contract.md) before integrating an
OCR result. Read [review-contract.md](references/review-contract.md) before
creating any review set. Read
[comparison-contract.md](references/comparison-contract.md) before comparing
OCR results. Read
[contested-export-contract.md](references/contested-export-contract.md) before
handing contested readings to another Skill. Read
[human-review-handoff-contract.md](references/human-review-handoff-contract.md)
before packaging evidence for a real reviewer. Read
[apple-vision-adapter.md](references/apple-vision-adapter.md) before using the
included local baseline.

## Build and preflight the local baseline

Compile only the bundled reviewed Swift helper, as an explicit step:

```bash
python3 scripts/ocr_review.py compile-apple-vision \
  --output-binary /new/run/apple-vision-adapter \
  --output-manifest /new/run/apple-vision-build.json
```

The command uses a fixed argv vector with `shell: false`, refuses existing
outputs, and records source, compiler, and binary hashes. It does not fetch
code or models.

Confirm the exact revision and language priority:

```bash
python3 scripts/ocr_review.py apple-preflight \
  --adapter-binary /new/run/apple-vision-adapter \
  --build-manifest /new/run/apple-vision-build.json \
  --revision 3 \
  --level accurate \
  --language zh-Hant \
  --language zh-Hans \
  --no-language-correction \
  --output-report /new/run/apple-vision-preflight.json
```

Stop if preflight fails. Never substitute Vision's current default revision.

## Run OCR

Run on an original registered image:

```bash
python3 scripts/ocr_review.py run \
  --adapter-binary /new/run/apple-vision-adapter \
  --build-manifest /new/run/apple-vision-build.json \
  --input /immutable/source.png \
  --asset-id MAT-RUBBING-001 \
  --image-role OBS \
  --revision 3 \
  --level accurate \
  --language zh-Hant \
  --language zh-Hans \
  --no-language-correction \
  --output /new/run/ocr-original.json
```

For a derivative, use `--image-role DER --der-manifest
/prepared/run/manifest.json`. The command requires an exact output path and
hash match in that manifest and records the parent SHA-256.

Run the same fixed OCR configuration on every justified image variant.
Preserve `completed`, `empty`, and `failed` outputs. Do not select a preferred
variant from confidence alone.

## OCR on a region crop declares two frames, never one

`rubbing-image-preparation derive --region X,Y,W,H` writes a **schema 1.1**
derivation manifest carrying a `region` block. Its pixels are numbered from the
region, not from the parent asset, so a box the adapter reports inside the crop
is **not** a parent coordinate. The OCR result therefore ladders too:

| OCR `schema_version` | input | pixel rectangles per box |
| --- | --- | --- |
| `1.0` | whole image (`OBS`, or a `DER` of a whole image) | `raw_image_top_left_pixels` — the raw pixel frame of the OCR'd file |
| `1.1` | region crop (a schema 1.1 derivation manifest) | `region_local_top_left_pixels` **and** `parent_raw_top_left_pixels` |

The rung is a function of the input alone, including for `empty` and `failed`
results. A whole-image run still writes 1.0, byte for byte as before.

**Both frames are recorded and both are named.** 1.1 deliberately shares no
rectangle name with 1.0: `raw_image_top_left_pixels` is absent from a region
document. A consumer that looks that key up gets nothing instead of a
confidently wrong whole-image coordinate — which is the only acceptable
outcome, because the value of a coordinate here is that it points at the right
pixels.

- `region_local_top_left_pixels` — inside the OCR'd crop. Use this to crop the
  crop, e.g. for a review image.
- `parent_raw_top_left_pixels` — in the parent asset's raw sensor frame. Use
  this to compare against a whole-image observation, another region, or the
  parent asset's own pixels.
- `coordinate_frame.frame_id` — `region-crop-raw-pixels`, naming the frame the
  crop-local rectangles live in.
- `coordinate_frame.region_projection` — the full mapping: `region_offset`,
  `region_dimensions`, `parent_derived_dimensions`, `parent_raw_dimensions`,
  `parent_exif_orientation`, the two box field names, and
  `composition_order`.
- `input.derivation.region` and `input.derivation.parent_coordinate_transform`
  — the same facts as read from the manifest. `verify` re-reads the manifest
  and requires the two copies to agree.

**The composition order is load-bearing.** `rubbing-image-preparation` declares
the region offset in the parent's **derived** frame — the frame that exists only
after the parent's EXIF orientation has been applied. So:

```
crop-local box
  -> add region_offset            (still in the parent's DERIVED frame)
  -> inverse EXIF orientation     (into the parent's RAW frame)
  = parent_raw_top_left_pixels
```

`composition_order` records this as
`add-region-offset-in-parent-derived-frame-then-inverse-exif-to-parent-raw`.
Reversing the two steps agrees for EXIF orientation 1 and disagrees for all
seven rotated or mirrored orientations, usually landing on a plausible but
wrong rectangle inside the parent. Do not reimplement the mapping; read
`region_projection` or use `parent_raw_top_left_pixels`.

A worked round trip on a real asset — a region derived from the Met 36445 leaf,
OCR'd through this command, mapped back onto a whole-image observation of 首 —
is recorded under
`docs/research/records/runs/REGION-COMPOSITION-001/`.

### What still refuses a region result

`compare`, `review-init`, and `review-handoff-create` **refuse** a schema 1.1
OCR result with an explicit error. Their own contracts copy the rectangle under
the 1.0 name and have no field in which to say which frame it belongs to, so
consuming one would strip the region frame and republish a region coordinate as
a whole-image coordinate. Until those contracts grow their own region rung the
refusal is the correct answer, not a limitation to work around by relabelling
the document.

## Verify before review or handoff

```bash
python3 scripts/ocr_review.py verify \
  --ocr-result /new/run/ocr-original.json \
  --output-report /new/run/ocr-original.verify.json
```

Require `status: pass`. Verification checks schema semantics, coordinate
transforms, current input bytes, adapter binary/build hashes, and any DER
parent chain.

## Compare machine outputs without choosing a winner

Compare an original and one or more deterministic derivatives:

```bash
python3 scripts/ocr_review.py compare \
  --ocr-result /new/run/ocr-original.json \
  --ocr-result /new/run/ocr-grayscale.json \
  --ocr-result /new/run/ocr-binary.json \
  --comparison-id COMPARE-RUBBING-001 \
  --output-report /new/run/ocr-comparison.json
```

The command is read-only over OCR inputs and always verifies their current
files, build artifacts, and complete DER chains. It rejects parameter objects
that differ under exact canonical JSON comparison—including provider
extensions and JSON types—or different evidence-family roots. It preserves
`completed`, `empty`, and `failed`, and reports every observation, all
candidates, rank 1, Unicode code points, confidence, per-observation
character-box coverage, failures, complete runtime engine identity, and exact
machine-output differences.

Do not use the comparison report as accuracy, CER, transcription truth, or an
automatic image-variant ranking. Observation IDs are local to each run and are
not automatic physical-glyph alignments. Verification reloads every bound OCR
source and reconstructs the full comparison inputs, summary, and fixed
machine-output difference set. The SHA-256 returned by current-file
verification must equal the bytes reloaded for reconstruction, closing the
verification/read snapshot race. Injected difference kinds—including any
accuracy or CER claim—are rejected even when their counts are edited to look
consistent.

## Hand a verified comparison to other Skills

A comparison report is evidence, but on its own it leaves this Skill only as
prose — which is why every formal record under
`REAL-RUBBING-SLICE-002/formal-records/` was hand-authored. Two commands give
it typed exits. Both reload and reconstruct the comparison from its own OCR
sources first, so an edited report cannot become a downstream record.

Emit the disputed glyphs, their machine candidates, their code points, and
their raw-image regions:

```bash
python3 scripts/ocr_review.py export-contested-observations \
  --comparison-report /frozen/ocr-comparison.json \
  --export-id EXPORT-RUBBING-001 \
  --repo-root /path/to/repo \
  --output-export /new/run/contested-observations.json
```

Observations from different runs are grouped when their boxes overlap in the
shared raw frame. That grouping is a **machine geometry hypothesis**: every
item records `grouping_method`, pins `physical_glyph_identity` to
`not-established`, and leaves `adjudication` null. Observation IDs are local to
each run and are never an automatic physical-glyph alignment. An item is
contested when variants disagree on the top-1 reading, when they offer several
candidate readings, or when some variants report nothing at that region at all
— absence is disagreement, and a `failed` run is excluded because it could not
testify.

Turn that export into ResearchCase records:

```bash
python3 scripts/ocr_review.py export-case-records \
  --contested-export /new/run/contested-observations.json \
  --repo-root /path/to/repo \
  --output-dir /new/run/case-records \
  --material-id MAT-RUBBING-CONTESTED-EXPORT \
  --evidence-id-prefix EVD-RUBBING-CONTESTED \
  --independence-group one-engine-one-source-family \
  --source-url local://run/contested-observations.json \
  --publisher "your team" \
  --license PROJECT-RECORD \
  --rights-status restricted
```

The output is one `DER` material record plus one machine evidence record per
contested item, and a manifest giving the order to apply them with
`orchestrate.py record`. **Order is load-bearing**: an evidence record naming a
material the case does not yet contain is rejected, so the material goes first.
Evidence is always written `machine-checked`, never `human-checked`. The
command re-derives the export from its comparison and refuses a hand-edited
one. It never invents licensing or rights: those are required arguments.

## Review without changing machine output

Create one actor declaration per review set. Keep the subject ID non-personal;
`actor_type`, `actor_id`, and `role_id` are separate fields. A human declaration
must self-attest direct visual review and independence while explicitly
recording that software did not authenticate the person's real-world identity.

Initialize a review set:

```bash
python3 scripts/ocr_review.py review-init \
  --ocr-result /new/run/ocr-original.json \
  --review-set-id REVIEW-RUBBING-001 \
  --actor-declaration /new/run/actor-declaration.json \
  --output-review /new/run/review-v001.json
```

Prepare one decision JSON with exactly `decision_id`, known `item_id`,
`status`, `rationale`, and timezone-aware `created_at`, plus
`corrected_text` or `supersedes_decision_id` only when applicable. The CLI
injects actor, source OCR, and coordinate-region hashes from the immutable
review set; a decision file may not claim its own actor.

Append it as a new version and event:

```bash
python3 scripts/ocr_review.py review-append \
  --review /new/run/review-v001.json \
  --decision /new/run/decision-001.json \
  --output-review /new/run/review-v002.json \
  --output-event /new/run/review-event-001.json

python3 scripts/ocr_review.py review-verify \
  --review /new/run/review-v002.json
```

Never edit an older OCR result, review state, or event. Use `accepted`,
`corrected`, `uncertain`, or `rejected`; preserve uncertainty and rejection as
research outcomes.

`review-verify` returns `integrity-pass` for a valid chain and separately
reports `declared_human_coverage` inside `human_review_gate`. A complete
coverage result means only that a declaration claiming `actor_type: human`
has a current decision for every reviewable item. Under the only supported
identity assurance, `self-attested-not-authenticated`,
`human_review_gate.satisfies` is always false and a fully covered chain remains
`identity-unverified`. Use `--require-human-gate` as a fail-closed stopping or
publication gate; it remains blocked until a future trusted external identity
verifier is implemented. A legacy v1.0 chain remains verifiable but is always
ineligible and read-only, even if its object name contains `human` or its role
starts with `role-`.

## Correct an actor misclassification

Do not edit an old mislabelled chain. Create a machine-readable correction
index. Schema 1.1 requires the original mislabelled
`ocr-human-review-summary` as an authoritative discovery index. It binds the
summary bytes, its ordered latest review tips, and its complete decision-ID
inventory, then binds every external decision file and any superseded
artifact by current SHA-256:

```bash
python3 scripts/ocr_review.py review-correction-create \
  --legacy-discovery-index /frozen/mislabelled-summary.original.json \
  --legacy-review /frozen/review-a-v002.json \
  --decision-file /frozen/decision-a.json \
  --superseded-artifact /frozen/mislabelled-summary.json \
  --correction-id CORRECTION-RUBBING-001 \
  --actual-actor-type ai-agent \
  --actual-actor-id agent-ocr-previewer \
  --reason "AI pre-review was incorrectly labelled as human review." \
  --output-index /new/run/actor-correction-index.json
```

The correction command can only downgrade legacy semantics to `ai-agent` or
`software`; it can never promote old evidence to human. The ordered
`--legacy-review` inputs must exactly equal the discovery summary's
authoritative latest tips, and the external decision IDs must exactly equal
the summary's observation decision IDs. Passing older ancestors, omitting a
decision, or adding an unindexed decision is rejected. Verification reloads
the discovery source and all bound files. Verify the result with
`review-correction-verify`.

## Prepare, but do not perform, human review

Create a blocked handoff from the exact OCR inputs, comparison, correction
index, ResearchCase, and preregistration:

```bash
python3 scripts/ocr_review.py review-handoff-create \
  --ocr-result /frozen/ocr-original.json \
  --ocr-result /frozen/ocr-derivative.json \
  --comparison-report /frozen/ocr-comparison.json \
  --actor-correction-index /new/run/actor-correction-index.json \
  --research-case /frozen/case-final.json \
  --preregistration /frozen/preregistration.md \
  --repo-root /path/to/repo \
  --handoff-id HANDOFF-RUBBING-001 \
  --output-handoff /new/run/handoff.json \
  --output-actor-template /new/run/actor-declaration.template.json \
  --output-decision-template /new/run/decision.template.json
```

Schema 1.3 packets are portable. Every path is POSIX-relative to `--repo-root`,
and `path_encoding.handoff_dir` records where the document sits so verification
recovers the root from the document's own location — no marker files, no
guessing, and a wrong root fails closed instead of resolving to something
plausible. Schemas 1.0–1.2 wrote absolute paths and could only be verified on
the machine that produced them; those artifacts stay readable exactly as
written and are never rewritten.

Acquisition images and image-preparation manifests usually live under a
gitignored `data/` tree. A portable packet binds them by SHA-256 and marks them
`location: outside-repository` with a null path, because a repo-relative path
to a file the repository does not carry is a promise it cannot keep. Nothing is
weakened: those bytes are still read and hash-checked through the OCR chain on
every verification.

Each task also carries a `reference_lookup` slot. Empty is a complete, valid
state, so a later automated pass can attach literature cross-references without
reissuing the packet. Only non-human actors may populate it, every entry pins
`satisfies_human_gate: false`, and a lookup is never a reading or a verdict —
a reviewer's judgement goes through the decision template.

The handoff always remains `pending-human-review`, preserves empty and failed
OCR outcomes, does not assign physical regions automatically, and emits only
invalid blank templates. It never creates a human declaration or decision.
Its comparison is accepted only after full reconstruction from the current OCR
sources, including the fixed difference set; arbitrary accuracy/CER
differences cannot enter the package. Run `review-handoff-verify` before
delivery. Record material correction, handoff, and future human commands with
`research-run-ledger`.

Every materialization path must be new. Never overwrite or reuse an earlier
correction index, handoff, template, verification report, or command ledger;
when a contract changes, retain the old artifact as superseded evidence and
write a new versioned output.

## Gates

- Reject symlinks, non-regular files, path traversal, unsupported image magic,
  byte/pixel-limit violations, changed hashes, and existing outputs.
- Keep the original or specific `DER` hash and oriented coordinate frame
  attached to every observation.
- Keep raw Vision bottom-left rectangles; never synthesize missing
  per-character boxes.
- Compare only exact parameter matches within one source-image hash family;
  preserve empty and failed outcomes instead of filtering them.
- Bind actor type, a non-personal subject/agent/software ID, and role ID
  separately; a role string is never proof of a human actor.
- Treat self-attestation as a structured claim, not authentication of
  biological identity, expertise, or independence.
- Report complete declared-human coverage separately from the authenticated
  human gate; self-attestation never makes that gate pass. Treat
  `eligible`/`satisfies` as unreachable by construction and say so plainly
  instead of implying a reviewer could satisfy them.
- Keep the contested-observation export free of adjudications and physical
  glyph identities; a machine grouping is a hypothesis, not an alignment.
- Emit exported evidence as `machine-checked` only; an export never claims a
  human checked anything.
- Write portable packets with repo-relative paths; bind gitignored inputs by
  hash rather than publishing a machine-local path.
- Treat Pillow as an optional dependency for context-image rendering only;
  every other command must keep working without it.
- Require a new v1.1 set for real human review; never append to a legacy v1.0
  chain to upgrade its semantics.
- Keep AI advisories outside the human gate and require a schema 1.1
  correction index bound to the original legacy discovery summary whenever a
  handoff includes mislabelled legacy chains.
- Recompute comparison inputs, summary, and fixed difference kinds from the
  current OCR sources; reject injected accuracy/CER claims.
- Preserve old materialized evidence and use new exclusive output paths for
  every corrected or superseding artifact.
- Treat Apple Vision as a Darwin-only proprietary baseline, not the SCP
  portable default and not evidence of ancient-rubbing accuracy.
- Add future PaddleOCR or Tesseract adapters behind the provider-neutral
  contract only after their registry and license gates pass.

