Skip to content

Commit 7c6ad14

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/medcare-ruff-codebook-handover-5ulx0i
# Conflicts: # crates/ogar-vocab/src/capability_registry.rs
2 parents bcee3da + 743f1a3 commit 7c6ad14

4 files changed

Lines changed: 554 additions & 20 deletions

File tree

crates/ogar-vocab/src/capability_registry.rs

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -401,25 +401,25 @@ pub enum HotplugDrift {
401401
mod hotplug_tests {
402402
use super::*;
403403

404-
const OCR_IDS: &[u16] = &[0x0805, 0x0808, 0x0809];
405-
const OCR_COVERED: &[&str] = &[
406-
"extract_page_image",
407-
"extract_text_layer",
408-
"recognize_line",
409-
"recognize_page",
410-
"render_hocr",
411-
"render_searchable_pdf",
412-
"render_text",
413-
"render_tsv",
414-
];
404+
// LIVE references (not hand-mirrored) so these can never drift from the
405+
// authoritative table — the v2 growth (8→14 caps, +PAGE_LAYOUT subject,
406+
// 2026-07-10) flows in automatically. `resolve_hotplug` does SET
407+
// comparison, so the slice order is irrelevant.
408+
const OCR_IDS: &[u16] = crate::ocr_actions::OCR_SUBJECT_CLASSIDS;
409+
const OCR_COVERED: &[&str] = crate::ocr_actions::OCR_ACTION_NAMES;
415410

416411
#[test]
417412
fn ocr_hotplug_resolves_vocab_and_actions() {
418413
let (concepts, caps) =
419414
resolve_hotplug("tesseract-ogar", OCR_IDS, OCR_COVERED).expect("green");
420-
assert_eq!(concepts.len(), 3);
415+
assert_eq!(
416+
concepts.len(),
417+
OCR_IDS.len(),
418+
"one concept per subject classid"
419+
);
421420
assert!(concepts.contains(&("textline", 0x0805)));
422-
assert_eq!(caps.len(), 8);
421+
assert!(concepts.contains(&("page_layout", 0x0807)));
422+
assert_eq!(caps.len(), crate::ocr_actions::OCR_ACTION_NAMES.len());
423423
}
424424

425425
/// The parity-plan P3 probe: `resolve_hotplug("medcare-rs",
@@ -596,13 +596,15 @@ mod hotplug_tests {
596596
);
597597
}
598598

599-
/// The hand-authored OCR table, routed through the generic derive, is
600-
/// byte-for-byte what it was before the refactor — the "config becomes
601-
/// data" seam subsumes the bespoke path with zero behavior change.
599+
/// The hand-authored OCR table, routed through the generic derive, has
600+
/// one join row per declared capability (14 after the v2 growth,
601+
/// 2026-07-10) — the "config becomes data" seam subsumes the bespoke
602+
/// path with zero behavior change. Count keyed to the live
603+
/// `OCR_ACTION_NAMES` so it can never re-drift.
602604
#[test]
603-
fn ocr_entries_still_derive_the_eight_known_rows() {
605+
fn ocr_entries_derive_one_row_per_declared_capability() {
604606
let ocr = ocr_entries();
605-
assert_eq!(ocr.len(), 8);
607+
assert_eq!(ocr.len(), crate::ocr_actions::OCR_ACTION_NAMES.len());
606608
assert!(
607609
ocr.iter()
608610
.any(|(cap, id)| cap == "recognize_line" && *id == crate::class_ids::TEXTLINE)
@@ -611,5 +613,13 @@ mod hotplug_tests {
611613
ocr.iter()
612614
.any(|(cap, id)| cap == "render_hocr" && *id == crate::class_ids::OCR_RENDERER)
613615
);
616+
// v2 rows resolve to their page_image / page_layout subjects.
617+
assert!(
618+
ocr.iter()
619+
.any(|(cap, id)| cap == "recognize_document" && *id == crate::class_ids::PAGE_IMAGE)
620+
);
621+
assert!(ocr.iter().any(
622+
|(cap, id)| cap == "detect_page_furniture" && *id == crate::class_ids::PAGE_LAYOUT
623+
));
614624
}
615625
}

crates/ogar-vocab/src/ocr_actions.rs

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,18 @@ pub const OCR_ACTION_NAMES: &[&str] = &[
130130
"render_tsv",
131131
"render_hocr",
132132
"render_searchable_pdf",
133+
// v2 (2026-07-10) — the structured-document + layout-classifier surface
134+
// the tesseract-rs arc shipped after the original eight. See
135+
// docs/OCR-ACTIONS-V2-PROPOSAL.md.
136+
"recognize_page_words",
137+
"recognize_document",
138+
"harvest_fields",
139+
"segment_page",
140+
"detect_halftone_regions",
141+
"detect_page_furniture",
133142
];
134143

135-
const _: () = assert!(OCR_ACTION_NAMES.len() == 8);
144+
const _: () = assert!(OCR_ACTION_NAMES.len() == 14);
136145

137146
const RECOGNIZE_LINE_PARAMS: &[OcrActionParam] = &[
138147
OcrActionParam::required("grey_line"),
@@ -185,6 +194,75 @@ const RENDER_SEARCHABLE_PDF_PARAMS: &[OcrActionParam] = &[
185194
];
186195
const RENDER_SEARCHABLE_PDF_PRODUCES: &[&str] = &["pdf_bytes"];
187196

197+
// ── v2 capability signatures (2026-07-10) ──────────────────────────────────
198+
199+
/// `recognize_page_words` — a full grey page recognized to WORD/box output
200+
/// (`LineWords`: per-line words each carrying char boxes + confidences),
201+
/// the word-level counterpart of `recognize_page`'s flat-text `textlines`.
202+
const RECOGNIZE_PAGE_WORDS_PARAMS: &[OcrActionParam] = &[
203+
OcrActionParam::required("grey_page"),
204+
OcrActionParam::required("width"),
205+
OcrActionParam::required("height"),
206+
OcrActionParam::optional("with_dict"),
207+
];
208+
const RECOGNIZE_PAGE_WORDS_PRODUCES: &[&str] = &["line_words"];
209+
210+
/// `recognize_document` — the ONE-SHOT: grey page in → `doc.v1` structured
211+
/// JSON (regions/lines/words with typed region classification) + a typed
212+
/// field harvest out. `harvest_profile` selects the field set (v2 vocabulary:
213+
/// `"german_invoice"`; absent = no harvest, empty `fields`; an unknown value
214+
/// is an executor-side FAIL, not a silent no-harvest).
215+
const RECOGNIZE_DOCUMENT_PARAMS: &[OcrActionParam] = &[
216+
OcrActionParam::required("grey_page"),
217+
OcrActionParam::required("width"),
218+
OcrActionParam::required("height"),
219+
OcrActionParam::optional("with_dict"),
220+
OcrActionParam::optional("harvest_profile"),
221+
];
222+
const RECOGNIZE_DOCUMENT_PRODUCES: &[&str] = &["doc_json", "fields"];
223+
224+
/// `harvest_fields` — the typed field harvest over an already-recognized
225+
/// page's word output (numeric hardening + label-proximity + IBAN mod-97 +
226+
/// the netto+ust==brutto arithmetic cross-check). `harvest_profile` as above.
227+
const HARVEST_FIELDS_PARAMS: &[OcrActionParam] = &[
228+
OcrActionParam::required("line_words"),
229+
OcrActionParam::required("page_w"),
230+
OcrActionParam::required("page_h"),
231+
OcrActionParam::required("harvest_profile"),
232+
];
233+
const HARVEST_FIELDS_PRODUCES: &[&str] = &["fields"];
234+
235+
/// `segment_page` — recursive XY-cut layout segmentation (columns /
236+
/// deimposition) → reading-ordered `(l,t,r,b)` region rects.
237+
const SEGMENT_PAGE_PARAMS: &[OcrActionParam] = &[
238+
OcrActionParam::required("grey_page"),
239+
OcrActionParam::required("width"),
240+
OcrActionParam::required("height"),
241+
OcrActionParam::optional("min_gap_frac"),
242+
OcrActionParam::optional("min_region_px"),
243+
OcrActionParam::optional("max_depth"),
244+
];
245+
const SEGMENT_PAGE_PRODUCES: &[&str] = &["regions_rects"];
246+
247+
/// `detect_halftone_regions` — leptonica-parity `pixGenerateHalftoneMask`
248+
/// image-region detector over a BINARIZED page → figure component rects (+
249+
/// the mask dims and the found flag; the mask may be smaller than the page).
250+
const DETECT_HALFTONE_REGIONS_PARAMS: &[OcrActionParam] = &[
251+
OcrActionParam::required("binary_page"),
252+
OcrActionParam::required("width"),
253+
OcrActionParam::required("height"),
254+
];
255+
const DETECT_HALFTONE_REGIONS_PRODUCES: &[&str] = &["figure_rects", "mask_w", "mask_h", "found"];
256+
257+
/// `detect_page_furniture` — header / footer / page-number detection over an
258+
/// already-recognized page's word output.
259+
const DETECT_PAGE_FURNITURE_PARAMS: &[OcrActionParam] = &[
260+
OcrActionParam::required("line_words"),
261+
OcrActionParam::required("page_w"),
262+
OcrActionParam::required("page_h"),
263+
];
264+
const DETECT_PAGE_FURNITURE_PRODUCES: &[&str] = &["header_lines", "footer_lines", "page_number"];
265+
188266
/// Build one [`ActionDef`] for an OCR capability. `subject_concept` MUST
189267
/// be a name minted in [`class_ids::ALL`] under the `0x08XX` (OCR) domain
190268
/// — enforced by this module's tests, not by this constructor (the tables
@@ -230,6 +308,12 @@ fn ocr_action_def(
230308
/// | `render_tsv` | `ocr_renderer` (`0x0809`) | `lines, page_w, page_h` | — | `tsv` |
231309
/// | `render_hocr` | `ocr_renderer` (`0x0809`) | `lines, page_w, page_h, image_name` | — | `hocr` |
232310
/// | `render_searchable_pdf` | `ocr_renderer` (`0x0809`) | `pages, dpi` | — | `pdf_bytes` |
311+
/// | `recognize_page_words` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict` | `line_words` |
312+
/// | `recognize_document` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict, harvest_profile` | `doc_json, fields` |
313+
/// | `harvest_fields` | `page_layout` (`0x0807`) | `line_words, page_w, page_h, harvest_profile` | — | `fields` |
314+
/// | `segment_page` | `page_image` (`0x0808`) | `grey_page, width, height` | `min_gap_frac, min_region_px, max_depth` | `regions_rects` |
315+
/// | `detect_halftone_regions` | `page_image` (`0x0808`) | `binary_page, width, height` | — | `figure_rects, mask_w, mask_h, found` |
316+
/// | `detect_page_furniture` | `page_layout` (`0x0807`) | `line_words, page_w, page_h` | — | `header_lines, footer_lines, page_number` |
233317
#[must_use]
234318
pub fn ocr_actions() -> Vec<OcrActionSpec> {
235319
vec![
@@ -313,6 +397,67 @@ pub fn ocr_actions() -> Vec<OcrActionSpec> {
313397
params: RENDER_SEARCHABLE_PDF_PARAMS,
314398
produces: RENDER_SEARCHABLE_PDF_PRODUCES,
315399
},
400+
// ── v2 rows (2026-07-10) ──
401+
OcrActionSpec {
402+
def: ocr_action_def(
403+
"recognize_page_words",
404+
"page_image",
405+
RECOGNIZE_PAGE_WORDS_PARAMS,
406+
RECOGNIZE_PAGE_WORDS_PRODUCES,
407+
),
408+
params: RECOGNIZE_PAGE_WORDS_PARAMS,
409+
produces: RECOGNIZE_PAGE_WORDS_PRODUCES,
410+
},
411+
OcrActionSpec {
412+
def: ocr_action_def(
413+
"recognize_document",
414+
"page_image",
415+
RECOGNIZE_DOCUMENT_PARAMS,
416+
RECOGNIZE_DOCUMENT_PRODUCES,
417+
),
418+
params: RECOGNIZE_DOCUMENT_PARAMS,
419+
produces: RECOGNIZE_DOCUMENT_PRODUCES,
420+
},
421+
OcrActionSpec {
422+
def: ocr_action_def(
423+
"harvest_fields",
424+
"page_layout",
425+
HARVEST_FIELDS_PARAMS,
426+
HARVEST_FIELDS_PRODUCES,
427+
),
428+
params: HARVEST_FIELDS_PARAMS,
429+
produces: HARVEST_FIELDS_PRODUCES,
430+
},
431+
OcrActionSpec {
432+
def: ocr_action_def(
433+
"segment_page",
434+
"page_image",
435+
SEGMENT_PAGE_PARAMS,
436+
SEGMENT_PAGE_PRODUCES,
437+
),
438+
params: SEGMENT_PAGE_PARAMS,
439+
produces: SEGMENT_PAGE_PRODUCES,
440+
},
441+
OcrActionSpec {
442+
def: ocr_action_def(
443+
"detect_halftone_regions",
444+
"page_image",
445+
DETECT_HALFTONE_REGIONS_PARAMS,
446+
DETECT_HALFTONE_REGIONS_PRODUCES,
447+
),
448+
params: DETECT_HALFTONE_REGIONS_PARAMS,
449+
produces: DETECT_HALFTONE_REGIONS_PRODUCES,
450+
},
451+
OcrActionSpec {
452+
def: ocr_action_def(
453+
"detect_page_furniture",
454+
"page_layout",
455+
DETECT_PAGE_FURNITURE_PARAMS,
456+
DETECT_PAGE_FURNITURE_PRODUCES,
457+
),
458+
params: DETECT_PAGE_FURNITURE_PARAMS,
459+
produces: DETECT_PAGE_FURNITURE_PRODUCES,
460+
},
316461
]
317462
}
318463

@@ -323,10 +468,14 @@ pub const OCR_EXPECTED_EXECUTORS: &[&str] = &["tesseract-ogar"];
323468

324469
/// The distinct subject classids this table binds (canon-high concept ids).
325470
/// A registering consumer must activate exactly this set — verified via
326-
/// [`crate::capability_registry::verify_registration`].
471+
/// [`crate::capability_registry::resolve_hotplug`] (the live hot-plug fuse;
472+
/// [`crate::capability_registry::verify_registration`] is the equivalent
473+
/// standalone check). `PAGE_LAYOUT` was added with the v2 rows
474+
/// (`harvest_fields` / `detect_page_furniture`, 2026-07-10).
327475
pub const OCR_SUBJECT_CLASSIDS: &[u16] = &[
328476
crate::class_ids::TEXTLINE,
329477
crate::class_ids::PAGE_IMAGE,
478+
crate::class_ids::PAGE_LAYOUT,
330479
crate::class_ids::OCR_RENDERER,
331480
];
332481

@@ -442,4 +591,87 @@ mod tests {
442591
);
443592
}
444593
}
594+
595+
/// v2: `recognize_document` is the one-shot composition of the word-level
596+
/// recognition — so its mandatory inputs must be a SUPERSET of
597+
/// `recognize_page_words`'s mandatory inputs (the one-shot cannot need
598+
/// less than the first stage it composes).
599+
#[test]
600+
fn recognize_document_reads_cover_the_word_stage() {
601+
let actions = ocr_actions();
602+
let get = |name: &str| {
603+
actions
604+
.iter()
605+
.find(|s| s.def.predicate == name)
606+
.unwrap_or_else(|| panic!("missing capability {name}"))
607+
};
608+
let words: BTreeSet<&str> = get("recognize_page_words")
609+
.params
610+
.iter()
611+
.filter(|p| p.mandatory)
612+
.map(|p| p.name)
613+
.collect();
614+
let doc: BTreeSet<&str> = get("recognize_document")
615+
.params
616+
.iter()
617+
.filter(|p| p.mandatory)
618+
.map(|p| p.name)
619+
.collect();
620+
assert!(
621+
words.is_subset(&doc),
622+
"recognize_document mandatory reads {doc:?} must cover recognize_page_words' {words:?}"
623+
);
624+
}
625+
626+
/// v2: the `harvest_profile` vocabulary has exactly one documented value
627+
/// in v2 (`"german_invoice"`); pin it so a rename is a visible breaking
628+
/// change. `harvest_fields` requires the profile; `recognize_document`
629+
/// makes it optional (absent = no harvest).
630+
#[test]
631+
fn harvest_profile_slot_is_present_where_documented() {
632+
let actions = ocr_actions();
633+
let harvest = actions
634+
.iter()
635+
.find(|s| s.def.predicate == "harvest_fields")
636+
.expect("harvest_fields present");
637+
assert!(
638+
harvest
639+
.params
640+
.iter()
641+
.any(|p| p.name == "harvest_profile" && p.mandatory),
642+
"harvest_fields must require harvest_profile"
643+
);
644+
let doc = actions
645+
.iter()
646+
.find(|s| s.def.predicate == "recognize_document")
647+
.expect("recognize_document present");
648+
assert!(
649+
doc.params
650+
.iter()
651+
.any(|p| p.name == "harvest_profile" && !p.mandatory),
652+
"recognize_document must offer harvest_profile as optional"
653+
);
654+
}
655+
656+
/// v2: the two `page_layout`-subject rows drove the single net-new entry
657+
/// in [`OCR_SUBJECT_CLASSIDS`]; assert the set is exactly the four minted
658+
/// concepts the 14 rows bind, no more, no less.
659+
#[test]
660+
fn subject_classids_match_the_actual_row_subjects() {
661+
let mut from_rows: BTreeSet<u16> = BTreeSet::new();
662+
for spec in ocr_actions() {
663+
let concept = subject_concept_of(&spec.def);
664+
let id = class_ids::ALL
665+
.iter()
666+
.find(|(name, _)| *name == concept)
667+
.expect("subject minted")
668+
.1;
669+
from_rows.insert(id);
670+
}
671+
let declared: BTreeSet<u16> = OCR_SUBJECT_CLASSIDS.iter().copied().collect();
672+
assert_eq!(
673+
from_rows, declared,
674+
"OCR_SUBJECT_CLASSIDS must equal the exact set of subjects the rows bind"
675+
);
676+
}
445677
}

docs/DISCOVERY-MAP.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,3 +1181,35 @@ isolation. The map's job is to keep them visible.
11811181
untouched. Full table + two fidelity notes (config-YAML nests the filter
11821182
under `Var:`; `Mode: string` is a match-type discriminator):
11831183
`ARAGO-ACTIONHANDLER-PARITY.md` §7 addendum.
1184+
1185+
- **D-OCR-ACTIONS-V2 (tesseract-rs structured-document capability surface;
1186+
2026-07-10; [G] — shipped + tested, 5-savant-verified pre-merge):** The
1187+
`ogar_vocab::ocr_actions` authoritative table grew from **8 to 14**
1188+
capabilities, adding the structured-document + layout-classifier surface the
1189+
tesseract-rs arc shipped after the original eight: `recognize_page_words`
1190+
(word/box page → `line_words`), `recognize_document` (the ONE-SHOT: grey
1191+
page → `doc.v1` JSON + typed `fields`, the WoA Rechnungs-Erfassung path),
1192+
`harvest_fields` (typed invoice harvest — numeric hardening, IBAN mod-97,
1193+
netto+ust==brutto cross-check), `segment_page` (recursive XY-cut /
1194+
deimposition), `detect_halftone_regions` (leptonica-parity
1195+
`pixGenerateHalftoneMask` figure detector), `detect_page_furniture`
1196+
(header/footer/page-number). **Zero new mints** — subjects are the already-
1197+
minted `page_image` (0x0808, rows 9/10/12/13) and `page_layout` (0x0807,
1198+
rows 11/14). `OCR_SUBJECT_CLASSIDS` gained exactly `{PAGE_LAYOUT}` (PAGE_IMAGE
1199+
was already present); the `capability_registry` hot-plug test mirrors
1200+
(`OCR_IDS`/`OCR_COVERED`) were converted to LIVE references to
1201+
`ocr_actions::{OCR_SUBJECT_CLASSIDS, OCR_ACTION_NAMES}` so they can never
1202+
re-drift. The `const _` fuse (`OCR_ACTION_NAMES.len()`) is 8→14; the
1203+
tesseract-ogar executor's `COVERED_CAPABILITIES` grows in lockstep (the
1204+
interim is a HARD workspace compile failure via the sibling path-dep, so the
1205+
OGAR PR merges FIRST). Deferred (recorded, not omitted): a `typed_field`
1206+
concept mint (would-be 0x080A) — only when a consumer persists harvested
1207+
fields as graph nodes; a `language` param slot — only WITH a multi-model
1208+
executor (eng-only ships today, so a dead param would be a lie in the facts);
1209+
a `classify_regions` cheap-path toggle — no precedent, regions always
1210+
classified today. Spec + phase-1 consolidation:
1211+
`docs/OCR-ACTIONS-V2-PROPOSAL.md`. Non-moves per OGAR-AS-IR §3: no new
1212+
`ActionDef` field, no lowering pass, additive rows only — the 3 applicable
1213+
IR-shape tests (effect-annotations-first-class, typed-signature, semantic-
1214+
preservation) pass; the change is a declared-capability growth, not an IR
1215+
reshape.

0 commit comments

Comments
 (0)