diff --git a/crates/ogar-vocab/src/capability_registry.rs b/crates/ogar-vocab/src/capability_registry.rs index c6dc315..6a4127d 100644 --- a/crates/ogar-vocab/src/capability_registry.rs +++ b/crates/ogar-vocab/src/capability_registry.rs @@ -305,25 +305,25 @@ pub enum HotplugDrift { mod hotplug_tests { use super::*; - const OCR_IDS: &[u16] = &[0x0805, 0x0808, 0x0809]; - const OCR_COVERED: &[&str] = &[ - "extract_page_image", - "extract_text_layer", - "recognize_line", - "recognize_page", - "render_hocr", - "render_searchable_pdf", - "render_text", - "render_tsv", - ]; + // LIVE references (not hand-mirrored) so these can never drift from the + // authoritative table — the v2 growth (8→14 caps, +PAGE_LAYOUT subject, + // 2026-07-10) flows in automatically. `resolve_hotplug` does SET + // comparison, so the slice order is irrelevant. + const OCR_IDS: &[u16] = crate::ocr_actions::OCR_SUBJECT_CLASSIDS; + const OCR_COVERED: &[&str] = crate::ocr_actions::OCR_ACTION_NAMES; #[test] fn ocr_hotplug_resolves_vocab_and_actions() { let (concepts, caps) = resolve_hotplug("tesseract-ogar", OCR_IDS, OCR_COVERED).expect("green"); - assert_eq!(concepts.len(), 3); + assert_eq!( + concepts.len(), + OCR_IDS.len(), + "one concept per subject classid" + ); assert!(concepts.contains(&("textline", 0x0805))); - assert_eq!(caps.len(), 8); + assert!(concepts.contains(&("page_layout", 0x0807))); + assert_eq!(caps.len(), crate::ocr_actions::OCR_ACTION_NAMES.len()); } #[test] @@ -402,13 +402,15 @@ mod hotplug_tests { assert_eq!(entries_from_actions(&[a]), vec![("bar".to_string(), 0)]); } - /// The hand-authored OCR table, routed through the generic derive, is - /// byte-for-byte what it was before the refactor — the "config becomes - /// data" seam subsumes the bespoke path with zero behavior change. + /// The hand-authored OCR table, routed through the generic derive, has + /// one join row per declared capability (14 after the v2 growth, + /// 2026-07-10) — the "config becomes data" seam subsumes the bespoke + /// path with zero behavior change. Count keyed to the live + /// `OCR_ACTION_NAMES` so it can never re-drift. #[test] - fn ocr_entries_still_derive_the_eight_known_rows() { + fn ocr_entries_derive_one_row_per_declared_capability() { let ocr = ocr_entries(); - assert_eq!(ocr.len(), 8); + assert_eq!(ocr.len(), crate::ocr_actions::OCR_ACTION_NAMES.len()); assert!( ocr.iter() .any(|(cap, id)| cap == "recognize_line" && *id == crate::class_ids::TEXTLINE) @@ -417,5 +419,13 @@ mod hotplug_tests { ocr.iter() .any(|(cap, id)| cap == "render_hocr" && *id == crate::class_ids::OCR_RENDERER) ); + // v2 rows resolve to their page_image / page_layout subjects. + assert!( + ocr.iter() + .any(|(cap, id)| cap == "recognize_document" && *id == crate::class_ids::PAGE_IMAGE) + ); + assert!(ocr.iter().any( + |(cap, id)| cap == "detect_page_furniture" && *id == crate::class_ids::PAGE_LAYOUT + )); } } diff --git a/crates/ogar-vocab/src/ocr_actions.rs b/crates/ogar-vocab/src/ocr_actions.rs index 087a5e6..1dcad07 100644 --- a/crates/ogar-vocab/src/ocr_actions.rs +++ b/crates/ogar-vocab/src/ocr_actions.rs @@ -130,9 +130,18 @@ pub const OCR_ACTION_NAMES: &[&str] = &[ "render_tsv", "render_hocr", "render_searchable_pdf", + // v2 (2026-07-10) — the structured-document + layout-classifier surface + // the tesseract-rs arc shipped after the original eight. See + // docs/OCR-ACTIONS-V2-PROPOSAL.md. + "recognize_page_words", + "recognize_document", + "harvest_fields", + "segment_page", + "detect_halftone_regions", + "detect_page_furniture", ]; -const _: () = assert!(OCR_ACTION_NAMES.len() == 8); +const _: () = assert!(OCR_ACTION_NAMES.len() == 14); const RECOGNIZE_LINE_PARAMS: &[OcrActionParam] = &[ OcrActionParam::required("grey_line"), @@ -185,6 +194,75 @@ const RENDER_SEARCHABLE_PDF_PARAMS: &[OcrActionParam] = &[ ]; const RENDER_SEARCHABLE_PDF_PRODUCES: &[&str] = &["pdf_bytes"]; +// ── v2 capability signatures (2026-07-10) ────────────────────────────────── + +/// `recognize_page_words` — a full grey page recognized to WORD/box output +/// (`LineWords`: per-line words each carrying char boxes + confidences), +/// the word-level counterpart of `recognize_page`'s flat-text `textlines`. +const RECOGNIZE_PAGE_WORDS_PARAMS: &[OcrActionParam] = &[ + OcrActionParam::required("grey_page"), + OcrActionParam::required("width"), + OcrActionParam::required("height"), + OcrActionParam::optional("with_dict"), +]; +const RECOGNIZE_PAGE_WORDS_PRODUCES: &[&str] = &["line_words"]; + +/// `recognize_document` — the ONE-SHOT: grey page in → `doc.v1` structured +/// JSON (regions/lines/words with typed region classification) + a typed +/// field harvest out. `harvest_profile` selects the field set (v2 vocabulary: +/// `"german_invoice"`; absent = no harvest, empty `fields`; an unknown value +/// is an executor-side FAIL, not a silent no-harvest). +const RECOGNIZE_DOCUMENT_PARAMS: &[OcrActionParam] = &[ + OcrActionParam::required("grey_page"), + OcrActionParam::required("width"), + OcrActionParam::required("height"), + OcrActionParam::optional("with_dict"), + OcrActionParam::optional("harvest_profile"), +]; +const RECOGNIZE_DOCUMENT_PRODUCES: &[&str] = &["doc_json", "fields"]; + +/// `harvest_fields` — the typed field harvest over an already-recognized +/// page's word output (numeric hardening + label-proximity + IBAN mod-97 + +/// the netto+ust==brutto arithmetic cross-check). `harvest_profile` as above. +const HARVEST_FIELDS_PARAMS: &[OcrActionParam] = &[ + OcrActionParam::required("line_words"), + OcrActionParam::required("page_w"), + OcrActionParam::required("page_h"), + OcrActionParam::required("harvest_profile"), +]; +const HARVEST_FIELDS_PRODUCES: &[&str] = &["fields"]; + +/// `segment_page` — recursive XY-cut layout segmentation (columns / +/// deimposition) → reading-ordered `(l,t,r,b)` region rects. +const SEGMENT_PAGE_PARAMS: &[OcrActionParam] = &[ + OcrActionParam::required("grey_page"), + OcrActionParam::required("width"), + OcrActionParam::required("height"), + OcrActionParam::optional("min_gap_frac"), + OcrActionParam::optional("min_region_px"), + OcrActionParam::optional("max_depth"), +]; +const SEGMENT_PAGE_PRODUCES: &[&str] = &["regions_rects"]; + +/// `detect_halftone_regions` — leptonica-parity `pixGenerateHalftoneMask` +/// image-region detector over a BINARIZED page → figure component rects (+ +/// the mask dims and the found flag; the mask may be smaller than the page). +const DETECT_HALFTONE_REGIONS_PARAMS: &[OcrActionParam] = &[ + OcrActionParam::required("binary_page"), + OcrActionParam::required("width"), + OcrActionParam::required("height"), +]; +const DETECT_HALFTONE_REGIONS_PRODUCES: &[&str] = &["figure_rects", "mask_w", "mask_h", "found"]; + +/// `detect_page_furniture` — header / footer / page-number detection over an +/// already-recognized page's word output. +const DETECT_PAGE_FURNITURE_PARAMS: &[OcrActionParam] = &[ + OcrActionParam::required("line_words"), + OcrActionParam::required("page_w"), + OcrActionParam::required("page_h"), +]; +const DETECT_PAGE_FURNITURE_PRODUCES: &[&str] = &["header_lines", "footer_lines", "page_number"]; + /// Build one [`ActionDef`] for an OCR capability. `subject_concept` MUST /// be a name minted in [`class_ids::ALL`] under the `0x08XX` (OCR) domain /// — enforced by this module's tests, not by this constructor (the tables @@ -230,6 +308,12 @@ fn ocr_action_def( /// | `render_tsv` | `ocr_renderer` (`0x0809`) | `lines, page_w, page_h` | — | `tsv` | /// | `render_hocr` | `ocr_renderer` (`0x0809`) | `lines, page_w, page_h, image_name` | — | `hocr` | /// | `render_searchable_pdf` | `ocr_renderer` (`0x0809`) | `pages, dpi` | — | `pdf_bytes` | +/// | `recognize_page_words` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict` | `line_words` | +/// | `recognize_document` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict, harvest_profile` | `doc_json, fields` | +/// | `harvest_fields` | `page_layout` (`0x0807`) | `line_words, page_w, page_h, harvest_profile` | — | `fields` | +/// | `segment_page` | `page_image` (`0x0808`) | `grey_page, width, height` | `min_gap_frac, min_region_px, max_depth` | `regions_rects` | +/// | `detect_halftone_regions` | `page_image` (`0x0808`) | `binary_page, width, height` | — | `figure_rects, mask_w, mask_h, found` | +/// | `detect_page_furniture` | `page_layout` (`0x0807`) | `line_words, page_w, page_h` | — | `header_lines, footer_lines, page_number` | #[must_use] pub fn ocr_actions() -> Vec { vec![ @@ -313,6 +397,67 @@ pub fn ocr_actions() -> Vec { params: RENDER_SEARCHABLE_PDF_PARAMS, produces: RENDER_SEARCHABLE_PDF_PRODUCES, }, + // ── v2 rows (2026-07-10) ── + OcrActionSpec { + def: ocr_action_def( + "recognize_page_words", + "page_image", + RECOGNIZE_PAGE_WORDS_PARAMS, + RECOGNIZE_PAGE_WORDS_PRODUCES, + ), + params: RECOGNIZE_PAGE_WORDS_PARAMS, + produces: RECOGNIZE_PAGE_WORDS_PRODUCES, + }, + OcrActionSpec { + def: ocr_action_def( + "recognize_document", + "page_image", + RECOGNIZE_DOCUMENT_PARAMS, + RECOGNIZE_DOCUMENT_PRODUCES, + ), + params: RECOGNIZE_DOCUMENT_PARAMS, + produces: RECOGNIZE_DOCUMENT_PRODUCES, + }, + OcrActionSpec { + def: ocr_action_def( + "harvest_fields", + "page_layout", + HARVEST_FIELDS_PARAMS, + HARVEST_FIELDS_PRODUCES, + ), + params: HARVEST_FIELDS_PARAMS, + produces: HARVEST_FIELDS_PRODUCES, + }, + OcrActionSpec { + def: ocr_action_def( + "segment_page", + "page_image", + SEGMENT_PAGE_PARAMS, + SEGMENT_PAGE_PRODUCES, + ), + params: SEGMENT_PAGE_PARAMS, + produces: SEGMENT_PAGE_PRODUCES, + }, + OcrActionSpec { + def: ocr_action_def( + "detect_halftone_regions", + "page_image", + DETECT_HALFTONE_REGIONS_PARAMS, + DETECT_HALFTONE_REGIONS_PRODUCES, + ), + params: DETECT_HALFTONE_REGIONS_PARAMS, + produces: DETECT_HALFTONE_REGIONS_PRODUCES, + }, + OcrActionSpec { + def: ocr_action_def( + "detect_page_furniture", + "page_layout", + DETECT_PAGE_FURNITURE_PARAMS, + DETECT_PAGE_FURNITURE_PRODUCES, + ), + params: DETECT_PAGE_FURNITURE_PARAMS, + produces: DETECT_PAGE_FURNITURE_PRODUCES, + }, ] } @@ -323,10 +468,14 @@ pub const OCR_EXPECTED_EXECUTORS: &[&str] = &["tesseract-ogar"]; /// The distinct subject classids this table binds (canon-high concept ids). /// A registering consumer must activate exactly this set — verified via -/// [`crate::capability_registry::verify_registration`]. +/// [`crate::capability_registry::resolve_hotplug`] (the live hot-plug fuse; +/// [`crate::capability_registry::verify_registration`] is the equivalent +/// standalone check). `PAGE_LAYOUT` was added with the v2 rows +/// (`harvest_fields` / `detect_page_furniture`, 2026-07-10). pub const OCR_SUBJECT_CLASSIDS: &[u16] = &[ crate::class_ids::TEXTLINE, crate::class_ids::PAGE_IMAGE, + crate::class_ids::PAGE_LAYOUT, crate::class_ids::OCR_RENDERER, ]; @@ -442,4 +591,87 @@ mod tests { ); } } + + /// v2: `recognize_document` is the one-shot composition of the word-level + /// recognition — so its mandatory inputs must be a SUPERSET of + /// `recognize_page_words`'s mandatory inputs (the one-shot cannot need + /// less than the first stage it composes). + #[test] + fn recognize_document_reads_cover_the_word_stage() { + let actions = ocr_actions(); + let get = |name: &str| { + actions + .iter() + .find(|s| s.def.predicate == name) + .unwrap_or_else(|| panic!("missing capability {name}")) + }; + let words: BTreeSet<&str> = get("recognize_page_words") + .params + .iter() + .filter(|p| p.mandatory) + .map(|p| p.name) + .collect(); + let doc: BTreeSet<&str> = get("recognize_document") + .params + .iter() + .filter(|p| p.mandatory) + .map(|p| p.name) + .collect(); + assert!( + words.is_subset(&doc), + "recognize_document mandatory reads {doc:?} must cover recognize_page_words' {words:?}" + ); + } + + /// v2: the `harvest_profile` vocabulary has exactly one documented value + /// in v2 (`"german_invoice"`); pin it so a rename is a visible breaking + /// change. `harvest_fields` requires the profile; `recognize_document` + /// makes it optional (absent = no harvest). + #[test] + fn harvest_profile_slot_is_present_where_documented() { + let actions = ocr_actions(); + let harvest = actions + .iter() + .find(|s| s.def.predicate == "harvest_fields") + .expect("harvest_fields present"); + assert!( + harvest + .params + .iter() + .any(|p| p.name == "harvest_profile" && p.mandatory), + "harvest_fields must require harvest_profile" + ); + let doc = actions + .iter() + .find(|s| s.def.predicate == "recognize_document") + .expect("recognize_document present"); + assert!( + doc.params + .iter() + .any(|p| p.name == "harvest_profile" && !p.mandatory), + "recognize_document must offer harvest_profile as optional" + ); + } + + /// v2: the two `page_layout`-subject rows drove the single net-new entry + /// in [`OCR_SUBJECT_CLASSIDS`]; assert the set is exactly the four minted + /// concepts the 14 rows bind, no more, no less. + #[test] + fn subject_classids_match_the_actual_row_subjects() { + let mut from_rows: BTreeSet = BTreeSet::new(); + for spec in ocr_actions() { + let concept = subject_concept_of(&spec.def); + let id = class_ids::ALL + .iter() + .find(|(name, _)| *name == concept) + .expect("subject minted") + .1; + from_rows.insert(id); + } + let declared: BTreeSet = OCR_SUBJECT_CLASSIDS.iter().copied().collect(); + assert_eq!( + from_rows, declared, + "OCR_SUBJECT_CLASSIDS must equal the exact set of subjects the rows bind" + ); + } } diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index ec354bf..c295d64 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1181,3 +1181,35 @@ isolation. The map's job is to keep them visible. untouched. Full table + two fidelity notes (config-YAML nests the filter under `Var:`; `Mode: string` is a match-type discriminator): `ARAGO-ACTIONHANDLER-PARITY.md` §7 addendum. + +- **D-OCR-ACTIONS-V2 (tesseract-rs structured-document capability surface; + 2026-07-10; [G] — shipped + tested, 5-savant-verified pre-merge):** The + `ogar_vocab::ocr_actions` authoritative table grew from **8 to 14** + capabilities, adding the structured-document + layout-classifier surface the + tesseract-rs arc shipped after the original eight: `recognize_page_words` + (word/box page → `line_words`), `recognize_document` (the ONE-SHOT: grey + page → `doc.v1` JSON + typed `fields`, the WoA Rechnungs-Erfassung path), + `harvest_fields` (typed invoice harvest — numeric hardening, IBAN mod-97, + netto+ust==brutto cross-check), `segment_page` (recursive XY-cut / + deimposition), `detect_halftone_regions` (leptonica-parity + `pixGenerateHalftoneMask` figure detector), `detect_page_furniture` + (header/footer/page-number). **Zero new mints** — subjects are the already- + minted `page_image` (0x0808, rows 9/10/12/13) and `page_layout` (0x0807, + rows 11/14). `OCR_SUBJECT_CLASSIDS` gained exactly `{PAGE_LAYOUT}` (PAGE_IMAGE + was already present); the `capability_registry` hot-plug test mirrors + (`OCR_IDS`/`OCR_COVERED`) were converted to LIVE references to + `ocr_actions::{OCR_SUBJECT_CLASSIDS, OCR_ACTION_NAMES}` so they can never + re-drift. The `const _` fuse (`OCR_ACTION_NAMES.len()`) is 8→14; the + tesseract-ogar executor's `COVERED_CAPABILITIES` grows in lockstep (the + interim is a HARD workspace compile failure via the sibling path-dep, so the + OGAR PR merges FIRST). Deferred (recorded, not omitted): a `typed_field` + concept mint (would-be 0x080A) — only when a consumer persists harvested + fields as graph nodes; a `language` param slot — only WITH a multi-model + executor (eng-only ships today, so a dead param would be a lie in the facts); + a `classify_regions` cheap-path toggle — no precedent, regions always + classified today. Spec + phase-1 consolidation: + `docs/OCR-ACTIONS-V2-PROPOSAL.md`. Non-moves per OGAR-AS-IR §3: no new + `ActionDef` field, no lowering pass, additive rows only — the 3 applicable + IR-shape tests (effect-annotations-first-class, typed-signature, semantic- + preservation) pass; the change is a declared-capability growth, not an IR + reshape. diff --git a/docs/OCR-ACTIONS-V2-PROPOSAL.md b/docs/OCR-ACTIONS-V2-PROPOSAL.md new file mode 100644 index 0000000..ec7c5f3 --- /dev/null +++ b/docs/OCR-ACTIONS-V2-PROPOSAL.md @@ -0,0 +1,260 @@ +# OCR Action Surface v2 — PROPOSAL (council pending) + +> **Status:** PROPOSAL — 5+3 council scheduled (5 verification savants → +> consolidate → 3 brutal reviewers → fix → consolidate). Per operator +> protocol the spec below is deliberately detailed enough that the council +> VERIFIES rather than redesigns; deviations require evidence of factual +> error in a numbered claim, not taste. +> +> **Ask (operator, 2026-07-10):** "add an API for OGAR … so that ogar can +> allow OCR via ogar-vocab in the other consumers … needs actiondef." +> +> **One sentence:** extend the existing hand-authored OCR ActionDef table +> (`ogar_vocab::ocr_actions`, 8 capabilities) with the six capabilities the +> tesseract-rs arc shipped since the table was authored — word-level page +> recognition, the doc.v1 structured document, the typed invoice-field +> harvest, page segmentation (XY-cut / deimposition), halftone (figure) +> detection, and page-furniture detection — so consumers (woa-rs, +> medcare-rs, smb-office-rs, …) invoke them through ogar-vocab like every +> other Core capability, with the registration fuse forcing the executor +> to stay in lockstep. + +## C1. Current state (verified in-repo, 2026-07-10) + +- `ogar_vocab::ocr_actions` (crates/ogar-vocab/src/ocr_actions.rs, 445 + lines) declares EIGHT capabilities as real `ActionDef`s: + `recognize_line, recognize_page, extract_text_layer, extract_page_image, + render_text, render_tsv, render_hocr, render_searchable_pdf`. +- Pattern (all preserved by v2): hand-authored table (sanctioned for + tesseract-rs — no upstream AST to lift from, per the module doc); + `object_class = "ogit-ocr/"` with `` minted in + `class_ids::ALL` under `0x08XX`; `kausal = Some(KausalSpec::External)`; + `default_subject = ActionSubject::System`; `reads`/`writes` carry + name-level effect facts; `OcrActionSpec { def, params, produces }` + carries the arago-parity typed signature (`OcrActionParam + { name, mandatory }`); `OCR_ACTION_NAMES` is the const fingerprint; + `OCR_SUBJECT_CLASSIDS` lists the bound concepts; + `verify_ocr_registration` is the drift fuse + (`capability_registry::verify_registration`). +- Minted `0x08XX` concepts: `unicharset 0x0801, recoder 0x0802, charset + 0x0803, network_layer 0x0804, textline 0x0805, blob 0x0806, page_layout + 0x0807, page_image 0x0808, ocr_renderer 0x0809`. +- Executor expectation: `OCR_EXPECTED_EXECUTORS = ["tesseract-ogar"]` + (crate exists in tesseract-rs: crates/tesseract-ogar). +- The library capabilities shipped in tesseract-rs since the table was + authored (all merged to master; PRs #29-#32 + branch e517a60): + `LstmRecognizer::recognize_page_makerow_words` (word/box page API), + `structured::{DocPage, render_json, render_json_with_regions, + build_regions, harden_numeric_tokens, harvest_fields, + german_invoice_fields}` (doc.v1 + typed fields), `xy_cut::xy_cut` + (recursive segmentation / deimposition), `pageseg:: + generate_halftone_mask` (leptonica-parity figure detector), + `page_furniture::detect_page_furniture` (header/footer/page number). + +## C2. Proposed v2 table — exact rows + +Six NEW capabilities appended to the existing eight (table order below = +`OCR_ACTION_NAMES` order; existing rows unchanged and not repeated): + +| # | capability | subject concept | mandatory params | optional params | produces | +|---|---|---|---|---|---| +| 9 | `recognize_page_words` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict` | `line_words` | +| 10 | `recognize_document` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict, harvest_profile, classify_regions` | `doc_json, fields` | +| 11 | `harvest_fields` | `page_layout` (`0x0807`) | `line_words, page_w, page_h, harvest_profile` | — | `fields` | +| 12 | `segment_page` | `page_image` (`0x0808`) | `grey_page, width, height` | `min_gap_frac, min_region_px, max_depth` | `regions_rects` | +| 13 | `detect_halftone_regions` | `page_image` (`0x0808`) | `binary_page, width, height` | — | `figure_rects, mask_w, mask_h, found` | +| 14 | `detect_page_furniture` | `page_layout` (`0x0807`) | `line_words, page_w, page_h` | — | `header_lines, footer_lines, page_number` | + +Numbered claims the council verifies (not redesigns): + +- **V2-1 (granularity):** `recognize_document` is the ONE-SHOT consumers + need (image in → doc.v1 JSON + typed fields out — the exact composition + the tesseract-ocr-web JSON arm runs: words → DocPage → harden → + harvest(profile) → furniture+xy_cut+halftone → build_regions → + render_json_with_regions). The fine-grained rows (9, 11, 12, 13, 14) + exist for consumers that already hold intermediate artifacts. Both + granularities mirror the existing table's own split (recognize_page vs + render_*). +- **V2-2 (zero new mints):** every subject is an ALREADY-MINTED `0x08XX` + concept. `page_layout` (`0x0807`) is the natural subject for + layout-DOM-level actions (11, 14); `page_image` (`0x0808`) for + pixel-level actions (9, 10, 12, 13). Deliberate deferral: a + `typed_field` concept mint (would-be `0x080A`) is NOT needed until a + consumer persists harvested fields as graph nodes — the trigger + condition is recorded here so the deferral is a decision, not an + omission. +- **V2-3 (harvest_profile vocabulary):** `harvest_profile` is a string + slot with ONE defined value in v2 — `"german_invoice"` (maps to + `tesseract_ocr::german_invoice_fields()`); absent/empty = no harvest + (empty `fields`). Unknown profiles are an executor-side invocation + FAILURE (fail-closed), not a silent no-harvest: a typo must not + silently drop invoice validation. Future profiles extend the + vocabulary; the ActionDef facts do not change. +- **V2-4 (fields are FACTS, executor stays consumer-private):** per the + AR-OGAR mailbox plan §4 rule, the v2 rows carry facts only (predicate, + subject, reads/writes, External kausal). No exec binding, no transport, + no encoding enters ogar-vocab. How `grey_page` bytes travel (same + process slice, mailbox payload, REST) is the consumer/executor seam's + business, exactly as for the existing eight rows. +- **V2-5 (naming):** `line_words` (not `textlines`) names the word/box + unit (tesseract-rs `LineWords`: words with per-char boxes + confidences + per line) to distinguish it from row 2's flat `textlines` text output. + `regions_rects` are reading-ordered `(l,t,r,b)` rects (xy_cut leaves); + `figure_rects` are halftone component bboxes in page space (mask may be + smaller than the page — `mask_w`/`mask_h` carried so the consumer can + interpret; the found=0 arm yields empty rects and found=false). +- **V2-6 (fuses extend, not fork):** `OCR_ACTION_NAMES` grows to 14 (the + `const _` length assert updates 8 → 14); `OCR_SUBJECT_CLASSIDS` is + UNCHANGED (`{TEXTLINE, PAGE_IMAGE, OCR_RENDERER}` ∪ v2 subjects adds + `PAGE_LAYOUT` — so it DOES change: `+ class_ids::PAGE_LAYOUT`); + `verify_ocr_registration` therefore forces every registered executor to + either handle all 14 or fail registration — the fuse IS the pairing + mechanism that keeps tesseract-ogar in lockstep, by design. + (Council: note the deliberate self-correction in this claim — the + subjects set changes by exactly {PAGE_LAYOUT}; verify.) +- **V2-7 (docs pairing):** `docs/ARAGO-ACTIONHANDLER-PARITY.md` (the + 8-capability parity table the module doc cites) gains the six v2 rows + marked "tesseract-rs-native (no arago counterpart)" — the honest + labeling that these six have NO arago twin; parity claims stay scoped + to the original eight. + +## C3. Executor pairing (tesseract-rs side, separate PR) + +- `tesseract-ogar` extends its executor match to the six new predicates, + each a thin call into the already-public tesseract-ocr functions listed + in C1 (no new logic in the executor — composition only, mirroring the + web crate's JSON arm for `recognize_document`). +- Merge order: OGAR PR first (table + fuse), tesseract-rs PR second + (executor catches up; its registration test goes green again). The + interim red on the tesseract-rs registration test is the fuse working + as designed — same pattern as the lance-graph #556 / tesseract-rs #3 + merge-order note in tesseract-rs CLAUDE.md. +- Consumer sketch (woa-rs, IN THE SPEC ONLY — not shipped in v2): + resolve classid via `PAGE_IMAGE`, fire `recognize_document` with + `harvest_profile = "german_invoice"`, read back `doc_json` + `fields` + (netto/ust/brutto with `arithmetic_ok`, IBAN with `iban_mod97_ok`) — + the Rechnungs-Erfassung path with zero tesseract-rs types in woa-rs. + +## C4. OGAR-AS-IR §3 — the six IR-shape tests, answered + +1. *Facts or policy?* Facts only (V2-4). 2. *Effect annotations + name-level?* Yes — reads/writes mirror params/produces exactly as the + existing eight. 3. *Behavior on the Core node, not the address?* + Yes — capabilities attach to `0x08XX` concepts via ActionDef; classids + stay pure address. 4. *Adapter-neutral?* Yes — no transport/encoding + in the table; executors register. 5. *Additive, version-gated?* + Additive rows + fuse-count bump; no existing row edited. 6. *Lift or + hand-authored, and why?* Hand-authored, sanctioned: tesseract-rs has + no upstream AST (the table's own module doc is the precedent). + +## C5. SURREAL-AST-TRAP-PREFLIGHT — answered for this change + +Not a producer→IR / codegen / .surql session (no DDL, no lift). The five +questions collapse to: behavior lands as `ActionDef` facts in the vocab +(the sanctioned home), invocation stays `ActionInvocation` at runtime, +no lifecycle enters any adapter. Verdict: the change is ON the sanctioned +path by construction. + +## C6. Test plan (OGAR side) + +- Existing module tests extend mechanically (length 14, uniqueness, + minted-subject fuse now also passing PAGE_LAYOUT, External kausal, + identity well-formedness — all loops, no per-row edits needed). +- NEW: `recognize_document_reads_cover_composed_inputs` — row 10's reads + ⊇ row 9's mandatory reads (the one-shot cannot need less than its + first stage); `harvest_profile_documented_value_is_stable` — the + `"german_invoice"` string is pinned (a rename is a breaking change). +- Registry: a `verify_ocr_registration` negative test — a registration + carrying only the original 8 must FAIL with the missing-capability + drift (the fuse proof). + +## C7. Non-goals (v2) + +- No `typed_field` mint (trigger recorded in V2-2). +- No streaming/chunked payload story (executor seam's business). +- No REST/HTTP surface anywhere in OGAR (lab-vs-canonical rule). +- No deu.lstm / multi-language slot — `language` is NOT added in v2 + because the executor cannot honor it yet (eng-only model shipped); + adding a dead param would be a lie in the facts. Recorded as the v3 + trigger: the slot lands WITH the multi-model executor. + +--- + +# CONSOLIDATION — Phase 1 (5 verification savants, 2026-07-10) + +> 5/5 savants completed (0 errors, ~1M tokens). Lens 1 (vocab) 9/9 CONFIRMED. +> The corrections below are folded in BEFORE the 3 brutal reviewers so they +> attack a clean spec (operator protocol: "consolidate first, otherwise the +> 5+3 becomes mushy"). Each is evidence-backed (file:line verified in-repo, +> the load-bearing fuse correction re-verified directly by the orchestrator). + +## Corrections applied (supersede the body above where they conflict) + +- **[Lens 4 — LOAD-BEARING] The live fuse is `resolve_hotplug` + `HOT_PLUG`, + NOT `verify_ocr_registration`.** Verified: `ogar-vocab/src/ + capability_registry.rs:242` `resolve_hotplug`; the green test is + `hotplug_activation_is_green` (:323) calling + `resolve_hotplug("tesseract-ogar", OCR_IDS, OCR_COVERED)`; the executor's + live surface is `tesseract-ogar::{COVERED_CAPABILITIES, HOT_PLUG}` + + `const _: () = assert!(OCR_ACTION_NAMES.len() == COVERED_CAPABILITIES.len())` + (tesseract-ogar/src/lib.rs:61,81,94). `verify_ocr_registration` still + compiles + self-tests but is NOT this executor's live path. C1/V2-6 are + corrected to name the hot-plug surface. +- **[Lens 4 GAP + Lens 2 CORRECTED] Implementation touches THREE fuse sites, + all in-PR:** (a) OGAR `OCR_ACTION_NAMES` 8→14 + `const _` assert 8→14; + (b) OGAR `OCR_SUBJECT_CLASSIDS` += `class_ids::PAGE_LAYOUT` (the only net-new + subject — PAGE_IMAGE already present); (c) the `#[cfg(test)]` mirrors in + `capability_registry.rs` (`OCR_IDS` += `0x0807`, `OCR_COVERED` → 14) — the + spec now mandates converting these test mirrors to LIVE references + (`ocr_actions::{OCR_SUBJECT_CLASSIDS, OCR_ACTION_NAMES}`) so they can never + drift again. tesseract-ogar side: `COVERED_CAPABILITIES` 8→14 + the + `hotplug_activation_is_green` concept count 3→4. +- **[Lens 2 CORRECTED — merge order] The interim break is a HARD COMPILE + FAILURE of the whole tesseract-rs workspace** (the `const _` assert + the + unpinned sibling path-dep on ogar-vocab), not "a registration test goes + red." Consequence for C3: OGAR PR merges FIRST; the tesseract-rs PR + (executor + `COVERED_CAPABILITIES`=14) must be ready to merge in lockstep, + because tesseract-rs `main` will fail `cargo build` against OGAR `main`=14 + until it lands. Both PRs authored together this session. +- **[Lens 2 GAP — DROP `classify_regions`] Row 10's optional `classify_regions` + param is REMOVED.** No code precedent (repo-wide grep = 0); the shipped web + arm classifies regions unconditionally. Row 10 optional params are now just + `with_dict, harvest_profile`. A future cheap-path toggle (skip + classification) is recorded as a v3 trigger, not shipped as a dead flag. +- **[Lens 3 CORRECTED — C4] The real OGAR-AS-IR §3 tests** (OGAR-AS-IR.md:57-62) + are: (1) SSA/dataflow-explicit, (2) effect-annotations-first-class, + (3) typed-signature-not-field-bag, (4) named-lowering-passes, + (5) semantic-preservation-guarantee, (6) IR-is-canonical. Re-answered: + (1) N/A — this is a declared capability table, not a lowering (no dataflow + to make explicit); (2) YES — `reads`/`writes` are the first-class effect + annotations, exactly as the existing 8; (3) YES — `OcrActionSpec.params` + (`OcrActionParam{name,mandatory}`) IS the typed signature, not a field bag; + (4) N/A — no new lowering pass; (5) YES — additive, no existing row edited, + semantics of the 8 preserved; (6) YES — the vocab table is the canonical IR + the executor resolves against. The change PASSES (the 3 applicable tests), + and my original C4 six invented questions are withdrawn. +- **[Lens 3 GAP — BBB path] The woa-rs consumer sketch (C3) violates no + anti-pattern, but must resolve via the membrane:** woa-rs (a BBB / customer + binary, woa-rs Iron Rule 1) pulls `PAGE_IMAGE` (0x0808) through + `lance_graph_contract::ogar_codebook::canonical_concept_id`, NOT + `ogar_vocab::ports` directly (OGAR-CONSUMER-BEST-PRACTICES.md §2 Pattern 1b). + The invocation stays behavior-free at the address; behavior is at the + tesseract-ogar executor. Sketch remains SPEC-ONLY. +- **[Lens 5 CORRECTED — V2-7 docs] The six rows land in `ocr_actions.rs`'s own + module-doc capability table** (mirroring the existing 8-row table there), NOT + in `docs/ARAGO-ACTIONHANDLER-PARITY.md` — verified (401-line read) to be the + generic arago-protocol scorecard with ZERO OCR content. V2-7 is corrected. +- **[Lens 5 GAP — ledger] Append a `docs/DISCOVERY-MAP.md` D-entry** for the v2 + table growth (CLAUDE.md marks it mandatory; v1 shipped without one — a + pre-existing gap this change closes rather than repeats). + +## Net implementation delta (what the 3 reviewers should hold the diff to) + +OGAR: `ocr_actions.rs` +6 `OcrActionSpec` rows (subjects: rows 9/10/12/13 → +`page_image` 0x0808, rows 11/14 → `page_layout` 0x0807), `OCR_ACTION_NAMES` +8→14, `const _` 8→14, `OCR_SUBJECT_CLASSIDS` += `PAGE_LAYOUT`, module-doc table ++6 rows; `capability_registry.rs` test mirrors → live refs; `DISCOVERY-MAP.md` ++1 D-entry. tesseract-rs: `tesseract-ocr::recognize_document` composition helper +(+ web arm refactored onto it, DRY), `tesseract-ogar` +6 request/response +variants + execute/capability_of/param-map arms + `COVERED_CAPABILITIES` 8→14 + +hotplug concept-count 3→4. Merge OGAR first; tesseract-rs in lockstep.