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
46 changes: 28 additions & 18 deletions crates/ogar-vocab/src/capability_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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
));
}
}
236 changes: 234 additions & 2 deletions crates/ogar-vocab/src/ocr_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<OcrActionSpec> {
vec![
Expand Down Expand Up @@ -313,6 +397,67 @@ pub fn ocr_actions() -> Vec<OcrActionSpec> {
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,
},
]
}

Expand All @@ -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,
];

Expand Down Expand Up @@ -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<u16> = 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<u16> = OCR_SUBJECT_CLASSIDS.iter().copied().collect();
assert_eq!(
from_rows, declared,
"OCR_SUBJECT_CLASSIDS must equal the exact set of subjects the rows bind"
);
}
}
32 changes: 32 additions & 0 deletions docs/DISCOVERY-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading