Parse: documents in, typed data out
Parsing turns a file nobody can query into data the whole platform can reason about: a typed document with pages, blocks, offsets, and provenance. ProtoMolt parses each document once into one canonical model, streams the pages out as they finish, and renders every output format from that same model.
flowchart LR
bytes["Document bytes, any format"] --> coord["Parsing coordinator (role parse, port 9093): sniff, route, fan out"]
coord --> grparse["gRParse C++ service: diskless, collectors, GPU OCR and layout"]
grparse --> doc["One canonical Document: pages, blocks, offsets, provenance"]
doc --> faces["TEXT, MARKDOWN, HTML, HTML_SPLIT_PAGE, JSON, YAML, DOCTAGS, DOCLANG, VTT"]
One document model, shared byte for byte
A parsed document is described with Protocol Buffers (protobuf): you write
the schema once and generate typed code for it in many languages, so every service agrees
on the shape of a document down to the field number. The model lives in one file,
document.proto, in the ProtoMolt repo under
dev-tools/protomolt/parse/document. That file is canonical:
every other repository in the parser fleet re-vendors it byte for byte, so a table
parsed by a spreadsheet specialist and a heading parsed by the C++ engine mean exactly the
same thing on the wire. The schema keeps field-for-field parity with the v2 JSON schema of
the document format this ecosystem interoperates with (the same format the DOCTAGS and
DOCLANG outputs serialize), and every enum that can grow carries a string fallback so a
newer vocabulary never breaks an older reader.
The Document is a flat store plus a reference graph, not a nested tree. The
body and the furniture (running headers, footers, page numbers)
are group roots, and groups hold only references such as #/texts/3, never
inline content. The content itself lives in typed tables: texts,
pictures, tables, key-value items, and form items. Pages are a
map from page number to a PageItem that carries the page size and an optional
rendered image. Every content item carries its own address (self_ref), its
parent and children, a content layer (BODY, FURNITURE, BACKGROUND, INVISIBLE, NOTES), a
label from a vocabulary of 30 (TITLE, SECTION_HEADER, LIST_ITEM, TABLE, PICTURE, FORMULA,
CODE, CAPTION, FOOTNOTE, and so on), and its provenance.
Provenance (the record of where something came from) is where the model earns the word typed. Every text item knows its page number, its bounding box on that page (left, top, right, bottom, with a coordinate origin of TOPLEFT or BOTTOMLEFT), and its character span inside its own source text:
dev-tools/protomolt/parse/document/.../document.proto, ProvenanceItem
message ProvenanceItem {
int32 page_no = 1; // which page this item came from
BoundingBox bbox = 2; // l / t / r / b, origin TOPLEFT or BOTTOMLEFT
IntSpan charspan = 3; // where this item's text sits in its source
}
Attribution goes one level deeper with a platform extension called CollectorSource.
The upstream format only knows media cues (start and end times for timed content such as
captions); the scatter-gather design needs every collector's output attributable, so each
item can also name the collector that produced it, the engine inside that collector, the
engine version, and a confidence when the engine reports one:
dev-tools/protomolt/parse/document/.../document.proto, CollectorSource: the attribution extension every fleet item carries
Show the actual definition
message CollectorSource {
string collector = 1; // "grparse", "libreoffice", "email", ...
optional string model = 2; // "rapidocr", "poppler-text", "slanet-plus"
optional string version = 3; // engine version, when known
optional double confidence = 4; // 0.0 to 1.0, when the engine reports one
}
message SourceType {
oneof source {
TrackSource track = 1; // media cues, for timed content
CollectorSource collector = 2; // pipestream extension
}
} Sources never overwrite each other. If two collectors both found a title, both claims are stored, and choosing a winner is left to the consumer.
gRParse, the C++ parser
gRParse is the platform's C++ gRPC parser, under active daily development. Three properties carry most of the design: diskless, streamed, and merged.
Diskless means document bytes never touch a filesystem. In the unary
ConvertSource RPC the bytes arrive base64-framed; in the streaming RPC they
arrive as ordered binary chunks. Parsing happens in memory and nothing spills. The family
rule is explicit: the only sanctioned sink for document bytes is object storage, and only
when the request itself configures that target.
Streamed means results leave the service as they are computed, not after a batch. For PDFs and images, pages render through Poppler (or decode through OpenCV for rasters) and then pass recognition: RapidOCR for text and a document layout model for structure, both served by ONNX Runtime, the cross-hardware inference engine, on NVIDIA GPUs via CUDA or on Intel hardware via OpenVINO. Recognition is selective: born-digital pages with a real text layer skip OCR, and only pages that need it hit the models, which keeps the CPU and GPU busy on the pages that earn it. Layout labels, reading order, tables with model or geometry cell grids, picture items, figure classification (line, bar, stacked bar, pie, scatter, with the chart data extracted), and barcode decoding through ZXing are live capabilities, and every page is emitted the moment it is done.
The collector family: one specialist per format, one document out
Around the CV path sits a family of collectors, each a standalone gRPC
service specializing in one format family: office documents (LibreOfficeKit), email
(envelope, body, attachments, both .eml and .msg), EPUB (the
book skeleton plus one markup leg per chapter), WARC web archives (one group per record,
payload capped so a huge crawl stays readable), XML dialects (JATS, USPTO, XBRL, METS),
EBCDIC fixed-width mainframe data (explicit selection only), audio and video (speech to
text), and text markup (markdown, HTML, LaTeX, VTT, BoxNote). Spreadsheet specialists
(Apache POI and calamine) exist as services but are reserved values on the wire, not wired
as collectors yet.
Each collector produces items tagged with its CollectorSource, and gRParse
merges them additively into one document: references renumber, sources
never overwrite each other. A failed collector degrades to a warning while any collector
succeeds; the parse fails only when every selected collector fails. No code path converts
office bytes to PDF in order to parse them: office text and tables come from the office
core exactly, and only the collector's page renders re-enter the CV engines, so a chart or
QR code inside a DOCX is still spotted and decoded.
inputs, any format
PDF DOCX .eml email EPUB WARC XLSX EBCDIC audio XMLroutes by format
merges into
Document
Without JavaScript: the chips are the inputs, the dark box routes each to its collector, and the tags are the provenance labels the merged document carries on its items.
flowchart LR
pdf["PDF and images"] --> cv["CV path, in process: render, OCR, layout, tables, figures, barcodes"]
office["Office documents"] --> lo["libreoffice collector"]
mail["Email"] --> em["email collector"]
web["WARC archives"] --> fw["fastwarc collector"]
book["EPUB"] --> ep["epub + markup collectors"]
main["EBCDIC data"] --> eb["ebcdic collector, explicit selection"]
talk["Audio and video"] --> asr["asr collector"]
cv --> merge["additive merge: references renumber, sources never overwrite"]
lo --> merge
em --> merge
fw --> merge
ep --> merge
eb --> merge
asr --> merge
merge --> out["one Document, every item tagged with its CollectorSource"]
A repair pass before anything renders
Once the merge is complete, a format-agnostic repair pass cleans the finished document: a
body line that repeats in the top or bottom band of enough pages is relabelled
PAGE_HEADER or PAGE_FOOTER and moved to the furniture; a word a line break hyphenated is
rejoined (known compounds such as well-known keep their hyphen); a paragraph
a page or column break split is merged back with its provenance appended; digital-only
bodies get their reading order from an XY-cut over layout regions. Every repair is counted
under grparse_repair_changes_total in the metrics export, so drift in the
pass is visible, not silent.
Pages stream out as they are ready
The hot path between ProtoMolt and gRParse is a single bidirectional stream. The client
sends ordered chunks (the first carries the document id, filename, and content type; the
last sets complete=true) and the server answers with one
PageData event per completed page. A page event contains only that page's
nodes, so a receiver can release it immediately instead of accumulating the whole document
before showing anything. Each text span carries append-only offsets into the document's
concatenated text stream, the OCR confidence when the text came from OCR, and a
discriminator saying OCR or digital text layer. That is transport metadata on purpose, so
downstream clients can index pages without renumbering anything in the core model.
Collector documents arrive as they finish, independent of the page stream, and the
terminal DocumentComplete event carries the origin and any collector
failures. Events are emitted when the underlying work completes; a parser that cannot
stream pages advertises that and sends only progress plus the final document.
dev-tools/protomolt/parse/grparse/.../parse_stream.proto, DocumentStreamEvent: what gRParse emits on the hot path (abridged)
Show the actual definition
message DocumentStreamEvent {
string document_id = 1;
int32 total_pages = 2; // known early: PDF page count precedes OCR
oneof event {
PageData page = 3; // one completed page, releasable at once
DocumentComplete complete = 4; // terminal metadata
CollectorDocument collector_document = 5; // a collector's document, the moment it lands
}
} text block layout region table picture or barcode
event stream, oldest first
PageData · page 1 · 2 text items, 1 table
PageData · page 2 · 1 text item, 1 picture
PageData · page 3 · 2 text items, 1 barcode
PageData · page 4 · 2 text items
DocumentComplete · 4 pages · 0 collector failures
Without JavaScript: the boxes are what recognition found on each page, and the stream column is the exact sequence of events a receiver would get.
The parsing coordinator
The parsing coordinator is the platform-side service (module
protomolt-parse-service, role parse, gRPC port 9093) that
decides which parsers see a document and folds their results back into the stored
document. Its work, in order:
- Load. The coordinator reads the stored document through the repo service with a part-masked read (parts CORE and BLOBS only; PARSED and CHUNKS are its outputs, not its inputs). Payload bytes come from the document's blob, fetched through the document service; the coordinator never touches object storage itself.
- Route. Magic-byte sniffing over the first 512 bytes builds a routing context: the sniffed content type (the routing source of truth), the declared content type (a hint, never trusted alone), filename, extension, size, and account. Each routing rule is a guard written in CEL, the Common Expression Language, a small portable expression language open sourced by Google. Routing is a set, not first-match: every matching rule contributes a planned parse, in priority order. Rules are service config, not RPCs. There is deliberately no CreateRule or UpdateRule RPC; an empty rule set is refused, and a rule that does not compile fails the boot, not the millionth document.
- Fan out. One virtual thread (a JDK lightweight thread) per planned parser, deduplicated by parser name, drives the full plugin stream through a shared client: 1 MiB data frames, deadline bounded, page texts and claims collected, and the single final document as the product.
- Record failures in the open. An unknown parser name records a FAILED result: a misconfigured plan is visible in the stored document, not thrown away. Warnings make a result PARTIAL. Every result carries a config fingerprint of the rule and parser config that produced it, so editing a rule cleanly invalidates old results.
- Fold. Each claimed key naming a string field of the document's search metadata is arbitrated: the highest-priority non-blank claim wins, because a blank claim must never fold over a real value. When no parser claims a body, one is derived from the streamed page texts of the best non-failed parser.
- Persist. One partial save of the PARSED and CORE parts, stamped
connector_id = "parse-coordinator", serialized per document node so concurrent parses cannot drop each other's results.
dev-tools/protomolt/parse/proto/.../routing.proto, a routing rule guard as authored
when: "mime_type == 'application/pdf' && size_bytes < 52428800"
flowchart LR
load["Load document: repo service, parts CORE and BLOBS only"] --> sniff["Sniff first 512 bytes, evaluate every CEL rule"]
sniff --> fan["Fan out on virtual threads, one task per parser"]
fan --> fold["Fold claims into search metadata"]
fold --> save["Persist PARSED and CORE parts"]
Every parser on the platform implements the same two-RPC plugin contract:
GetParserInfo (identity, supported types, limits, a JSON Schema for its
config) and Parse, a bidirectional stream whose first frame carries options
and whose later frames carry data, answered by sequence-numbered events with the final
document emitted exactly once. A parse that produces nothing fails the stream, and the
coordinator stores that failure rather than leaving the parse silently absent.
dev-tools/protomolt/parse/proto/.../parser_plugin.proto, ParseResponse: the envelope every parser emits (abridged)
Show the actual definition
message ParseResponse {
string document_id = 1;
uint64 sequence_number = 2; // receivers order by this, not arrival time
oneof event {
ParseProgress progress = 10; // safe to drop
ParsedPage page = 11; // one page, as soon as it exists
PagePreview preview = 12; // one page's rendered image
DocumentClaims claims = 13; // for the search-metadata fold
ParserOutput document = 14; // exactly once, then the stream closes
}
}
ProtoMolt reaches gRParse through a single adapter. The
parse/grparse module is a sidecar that implements the plugin
contract and bridges it to gRParse's page stream; the C++ fleet parser joins the platform
through this adapter with zero C++ changes, registered under the parser identity
grparse. The adapter buffers the plugin's options-first request stream and
replays it to gRParse as ordered chunks: metadata first, 1 MiB data chunks, then a
terminal complete=true. Page events become plugin page events, collector
documents are assembled additively, a collector failure degrades to warnings (a PARTIAL
result), and a stream error fails the plugin with the same status class (a FAILED
result).
Durability comes from the jobs layer: the same parse runs as a checkpointed workflow run under the jobs executor. The job row survives a process restart, a transient failure requeues with backoff, and a resumed job never re-parses work that already landed.
One parse, every output format
From the one merged document, gRParse renders every output format the wire declares:
TEXT, MARKDOWN, HTML, HTML_SPLIT_PAGE, JSON, YAML, DOCTAGS, DOCLANG, and
VTT. DOCTAGS and DOCLANG are the Docling-compatible tag and XML serializations,
for tools that speak that dialect; VTT is the caption format, the natural face for audio
and video parsed by the speech collector; HTML_SPLIT_PAGE renders the document as one
HTML page per source page. Chunking is deterministic: the same input bytes produce the
same chunk bytes on every machine, with every boundary rule versioned in a
rules_digest string, so downstream caches can key on chunks safely. The
formats page walks all nine faces and shows what each one keeps
and throws away.