ethos_core/grounding.rs
1/*
2 * Copyright 2026 The Ethos maintainers
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! The `GroundingSource` trait module (PRD §5.5) — the sole boundary between
18//! `ethos-verify` and any parser, Ethos included.
19//!
20//! Design rules (invariant 4):
21//! - This module depends on `serde` only — no canonical model, no backend types,
22//! no PDFium, nothing Ethos-parser-internal.
23//! - Foreign adapters (e.g. `adapters/grounding/opendataloader-json`) implement
24//! [`GroundingSource`] over foreign output; missing capabilities become explicit
25//! `capability_limited` downgrades in verification reports, never silent approximation.
26//!
27//! Geometry here is integer quanta when the source declares a compatible coordinate
28//! system; foreign sources that cannot provide that declare it via [`Capabilities`].
29
30use serde::{Deserialize, Serialize};
31
32/// Identity of the parser that produced the grounding data.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ParserIdentity {
35 /// Parser name, e.g. `"ethos"` or `"opendataloader-pdf"`.
36 pub name: String,
37 /// Parser version string as reported by the parser.
38 pub version: String,
39 /// Adapter identifier when the data flows through a foreign-parser adapter.
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub adapter: Option<String>,
42 /// Adapter version, when applicable.
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub adapter_version: Option<String>,
45}
46
47/// Coordinate origin declared by the grounding source.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum CoordinateOrigin {
51 /// Origin at top-left, y grows downward (Ethos canonical).
52 TopLeft,
53 /// Origin at bottom-left, y grows upward (raw PDF space).
54 BottomLeft,
55 /// Unknown/undeclared — bbox checks are capability-limited.
56 Unknown,
57}
58
59/// Capability declaration (PRD §5.5). Capability-driven downgrades are explicit:
60/// whatever is `false`/`Unknown` here must surface as a `capability_limited` warning
61/// in verification reports that needed it.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Capabilities {
64 /// Source exposes text spans.
65 pub spans: bool,
66 /// Spans carry char offsets into their owning element text.
67 pub char_offsets: bool,
68 /// Source models tables and table cells.
69 pub tables: bool,
70 /// Source declares a document fingerprint.
71 pub fingerprint: bool,
72 /// Declared coordinate origin.
73 pub coordinate_origin: CoordinateOrigin,
74 /// Source can produce region crops (L2 evidence, Milestone D).
75 pub crop_support: bool,
76}
77
78/// Page geometry as declared by the grounding source.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct PageGeometry {
81 /// Page id in the source's namespace (Ethos: `p0001`…).
82 pub id: String,
83 /// 1-based page index in the original document.
84 pub index: u32,
85 /// Page width in the source's declared units.
86 pub width: i64,
87 /// Page height in the source's declared units.
88 pub height: i64,
89 /// Normalized rotation in degrees (0/90/180/270).
90 pub rotation: u16,
91}
92
93/// An addressable element exposed for evidence checks.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct GroundingElement {
96 /// Element id in the source's namespace.
97 pub id: String,
98 /// Owning page id.
99 pub page: String,
100 /// `[x0, y0, x1, y1]` in the source's declared units/origin.
101 pub bbox: [i64; 4],
102 /// Element kind, lowercased, source-defined (e.g. `"text_block"`, `"heading"`).
103 pub kind: String,
104 /// Text content when applicable.
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub text: Option<String>,
107}
108
109/// Optional span with char offsets (capability `spans` / `char_offsets`).
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct GroundingSpan {
112 /// Span id in the source's namespace.
113 pub id: String,
114 /// Owning page id.
115 pub page: String,
116 /// `[x0, y0, x1, y1]` in the source's declared units/origin.
117 pub bbox: [i64; 4],
118 /// Span text.
119 pub text: String,
120 /// Owning element id, when ownership is known.
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub element: Option<String>,
123 /// Char offset range within the owning element's text.
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub char_start: Option<u32>,
126 /// Exclusive end offset.
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub char_end: Option<u32>,
129}
130
131/// A table cell exposed for `table_cell` claims.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct GroundingCell {
134 /// 0-based row.
135 pub row: u32,
136 /// 0-based column.
137 pub col: u32,
138 /// Rows spanned (≥1).
139 pub row_span: u32,
140 /// Columns spanned (≥1).
141 pub col_span: u32,
142 /// Cell bbox.
143 pub bbox: [i64; 4],
144 /// Cell text.
145 pub text: String,
146}
147
148/// A table exposed for `table_cell` claims.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct GroundingTable {
151 /// Table id in the source's namespace.
152 pub id: String,
153 /// Owning page id (first page for multi-page tables).
154 pub page: String,
155 /// Table bbox.
156 pub bbox: [i64; 4],
157 /// Cells; absence of a (row, col) means an empty/covered cell.
158 pub cells: Vec<GroundingCell>,
159}
160
161/// Parser output as evidence: everything `ethos-verify` is allowed to know about a
162/// document. Implementations must be deterministic — same underlying artifact, same
163/// returned data, same order.
164pub trait GroundingSource {
165 /// Identity of the producing parser (+ adapter when foreign).
166 fn parser(&self) -> ParserIdentity;
167 /// Capability declaration; drives explicit verification downgrades.
168 fn capabilities(&self) -> Capabilities;
169 /// Document fingerprint when the source declares one (`sha256:…` for Ethos).
170 fn fingerprint(&self) -> Option<String>;
171 /// Page geometry, ascending page index.
172 fn pages(&self) -> Vec<PageGeometry>;
173 /// Elements in the source's canonical order.
174 fn elements(&self) -> Vec<GroundingElement>;
175 /// Spans, when capability `spans` is true. Default: none.
176 fn spans(&self) -> Vec<GroundingSpan> {
177 Vec::new()
178 }
179 /// Tables, when the source models them. Default: none (verification of
180 /// `table_cell` claims downgrades accordingly).
181 fn tables(&self) -> Vec<GroundingTable> {
182 Vec::new()
183 }
184 /// Stable crop reference for an evidence region, when `crop_support` is true.
185 /// The verify layer treats this as an opaque audit pointer; sources own how the
186 /// referenced crop artifact is generated and stored.
187 fn crop_ref(&self, _page: &str, _bbox: [i64; 4]) -> Option<String> {
188 None
189 }
190 /// Element lookup by id. Default: linear scan over [`Self::elements`].
191 ///
192 /// Adapters may override this as a convenience API for direct callers. The
193 /// verifier builds its own deterministic per-run index from [`Self::elements`]
194 /// so duplicate handling and traversal order stay tied to the evidence list.
195 fn element_by_id(&self, id: &str) -> Option<GroundingElement> {
196 self.elements().into_iter().find(|e| e.id == id)
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 struct Tiny;
205 impl GroundingSource for Tiny {
206 fn parser(&self) -> ParserIdentity {
207 ParserIdentity {
208 name: "tiny".into(),
209 version: "0.0.0".into(),
210 adapter: None,
211 adapter_version: None,
212 }
213 }
214 fn capabilities(&self) -> Capabilities {
215 Capabilities {
216 spans: false,
217 char_offsets: false,
218 tables: false,
219 fingerprint: false,
220 coordinate_origin: CoordinateOrigin::Unknown,
221 crop_support: false,
222 }
223 }
224 fn fingerprint(&self) -> Option<String> {
225 None
226 }
227 fn pages(&self) -> Vec<PageGeometry> {
228 vec![PageGeometry {
229 id: "p1".into(),
230 index: 1,
231 width: 10,
232 height: 10,
233 rotation: 0,
234 }]
235 }
236 fn elements(&self) -> Vec<GroundingElement> {
237 vec![GroundingElement {
238 id: "e1".into(),
239 page: "p1".into(),
240 bbox: [0, 0, 5, 5],
241 kind: "text_block".into(),
242 text: Some("hello".into()),
243 }]
244 }
245 }
246
247 #[test]
248 fn defaults_are_safe() {
249 let t = Tiny;
250 assert!(t.spans().is_empty());
251 assert!(t.tables().is_empty());
252 assert_eq!(
253 t.element_by_id("e1").unwrap().text.as_deref(),
254 Some("hello")
255 );
256 assert!(t.element_by_id("nope").is_none());
257 assert!(t.crop_ref("p1", [0, 0, 5, 5]).is_none());
258 }
259}