Skip to main content

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}