Skip to main content

ethos_core/
model.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//! Canonical document model (`urn:ethos:schema:document:1`). Field-for-field mirror of
18//! the schema; serialization through these types + [`crate::c14n`] is the only way Ethos
19//! emits the document artifact.
20
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24use crate::codes::WarningCode;
25use crate::error::{ErrorCode, EthosError};
26use crate::geom::QRect;
27
28/// Top-level document artifact (`ethos.json`).
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct Document {
31    /// Contract schema version.
32    pub schema_version: String,
33    /// Producing parser.
34    pub parser: ParserInfo,
35    /// Deterministic profile identity.
36    pub profile: ProfileRef,
37    /// Source identity.
38    pub source: SourceInfo,
39    /// sha256 of c14n(effective-config subset).
40    pub config_sha256: String,
41    /// sha256 of c14n(stable payload projection).
42    pub payload_sha256: String,
43    /// Composite document fingerprint (`sha256:…`).
44    pub fingerprint: String,
45    /// The emitted payload; `payload_sha256` binds its stable projection.
46    pub payload: Payload,
47    /// Runtime-only diagnostics; excluded from canonical equality and all fingerprints.
48    /// Omitted by default (`--diagnostics` opts in) so default outputs are byte-identical.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub diagnostics: Option<Value>,
51}
52
53/// Producing parser identity.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ParserInfo {
56    /// Always `"ethos"`.
57    pub name: String,
58    /// Crate/workspace version.
59    pub version: String,
60}
61
62/// Deterministic profile reference.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ProfileRef {
65    /// e.g. `"ethos-deterministic-v1"`.
66    pub id: String,
67    /// sha256 of c14n(profile artifact).
68    pub sha256: String,
69}
70
71/// Source identity.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SourceInfo {
74    /// `sha256:…` over the input bytes.
75    pub fingerprint: String,
76    /// Input size in bytes.
77    pub bytes: u64,
78}
79
80/// The emitted document payload.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct Payload {
83    /// Uniform coordinate system for every bbox.
84    pub coordinate_system: CoordinateSystem,
85    /// Pages, ascending original index.
86    pub pages: Vec<Page>,
87    /// Elements — array order IS reading order.
88    pub elements: Vec<Element>,
89    /// Spans, content-stream order.
90    pub spans: Vec<Span>,
91    /// Tables.
92    pub tables: Vec<Table>,
93    /// Chunks.
94    pub chunks: Vec<Chunk>,
95    /// Non-text regions with stable coordinates.
96    pub regions: Vec<Region>,
97    /// Security-class warnings (contract §8 code split).
98    pub security_warnings: Vec<Warning>,
99    /// Parser warnings.
100    pub parser_warnings: Vec<Warning>,
101}
102
103/// Coordinate system declaration.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct CoordinateSystem {
106    /// Always `"top-left"`.
107    pub origin: String,
108    /// Always `"quantum"`.
109    pub unit: String,
110    /// Quanta per PDF point (profile: 100).
111    pub quantum_per_point: u32,
112}
113
114/// A page.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Page {
117    /// `p%04d` from the 1-based original index.
118    pub id: String,
119    /// 1-based index in the ORIGINAL document (page-filtered parses keep original indices).
120    pub index: u32,
121    /// Width in quanta.
122    pub width: i64,
123    /// Height in quanta.
124    pub height: i64,
125    /// Normalized rotation: 0/90/180/270.
126    pub rotation: u16,
127}
128
129/// Element type enum (wire: snake_case).
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum ElementType {
133    /// Paragraph-level text block.
134    TextBlock,
135    /// Heading.
136    Heading,
137    /// List container.
138    List,
139    /// List item.
140    ListItem,
141    /// Table anchor element (see `table_ref`).
142    Table,
143    /// Non-text region anchor (see `region_ref`).
144    Region,
145    /// Running header.
146    Header,
147    /// Running footer.
148    Footer,
149    /// Caption.
150    Caption,
151    /// Anything else.
152    Other,
153}
154
155/// A layout element.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct Element {
158    /// `e%06d`, reading order.
159    pub id: String,
160    /// Element type.
161    #[serde(rename = "type")]
162    pub element_type: ElementType,
163    /// Owning page id.
164    pub page: String,
165    /// Bounding box.
166    pub bbox: QRect,
167    /// Text, when applicable. Preserved exactly as extracted.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub text: Option<String>,
170    /// Heading level (1–9) for headings.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub heading_level: Option<u8>,
173    /// Table reference for table anchors.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub table_ref: Option<String>,
176    /// Region reference for region anchors.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub region_ref: Option<String>,
179    /// Heuristic confidence, integer per-mille.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub confidence: Option<u16>,
182    /// Owned spans.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub span_refs: Vec<String>,
185    /// Attached warnings.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub warning_refs: Vec<String>,
188}
189
190/// An extracted text span.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Span {
193    /// `s%06d`, content-stream order.
194    pub id: String,
195    /// Owning page id.
196    pub page: String,
197    /// Bounding box.
198    pub bbox: QRect,
199    /// Stable origin-derived locator used by the fingerprint projection.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub origin_locator: Option<SpanOriginLocator>,
202    /// Span text, exactly as extracted.
203    pub text: String,
204    /// Deterministic font identity (ADR-0003): `embedded:…` or `subst:…`.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub font_id: Option<String>,
207    /// Font size in quanta.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub font_size_q: Option<i64>,
210    /// Char offset into the owning element's text.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub char_start: Option<u32>,
213    /// Exclusive end offset.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub char_end: Option<u32>,
216    /// Attached warnings.
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub warning_refs: Vec<String>,
219}
220
221/// Origin-derived text locator that remains stable when PDFium bbox dimensions drift.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct SpanOriginLocator {
224    /// Locator policy id.
225    pub policy: String,
226    /// First included character origin in top-left quanta.
227    pub first_origin: [i64; 2],
228    /// Last included character origin in top-left quanta.
229    pub last_origin: [i64; 2],
230}
231
232/// A table.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct Table {
235    /// `t%04d`.
236    pub id: String,
237    /// Owning pages.
238    pub page_refs: Vec<String>,
239    /// Bounding box.
240    pub bbox: QRect,
241    /// Row count.
242    pub n_rows: u32,
243    /// Column count.
244    pub n_cols: u32,
245    /// Leading header rows.
246    pub header_rows: u32,
247    /// Leading header columns.
248    pub header_cols: u32,
249    /// Cells.
250    pub cells: Vec<Cell>,
251    /// Structure confidence, per-mille.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub confidence: Option<u16>,
254    /// Structure warnings.
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub warning_refs: Vec<String>,
257    /// Optional derived exports — deterministic functions of cells.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub exports: Option<TableExports>,
260}
261
262/// Optional derived table exports.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct TableExports {
265    /// CSV form.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub csv: Option<String>,
268    /// Markdown form.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub markdown: Option<String>,
271}
272
273/// A table cell.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct Cell {
276    /// 0-based row.
277    pub row: u32,
278    /// 0-based column.
279    pub col: u32,
280    /// Rows spanned (≥1).
281    pub row_span: u32,
282    /// Columns spanned (≥1).
283    pub col_span: u32,
284    /// Bounding box.
285    pub bbox: QRect,
286    /// Cell text.
287    pub text: String,
288    /// Contributing spans.
289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
290    pub span_refs: Vec<String>,
291    /// Contributing elements.
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub element_refs: Vec<String>,
294}
295
296/// A RAG chunk.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct Chunk {
299    /// `c%06d`.
300    pub id: String,
301    /// Chunk text.
302    pub text: String,
303    /// Source elements (≥1).
304    pub element_refs: Vec<String>,
305    /// Source pages (≥1).
306    pub page_refs: Vec<String>,
307    /// Citation bboxes (≥1).
308    pub bboxes: Vec<PageBox>,
309    /// Token estimate with pinned estimator.
310    pub token_estimate: TokenEstimate,
311    /// Warnings inherited from source regions. Hidden/off-page/low-contrast content
312    /// is NEVER in default chunks (PRD §14).
313    #[serde(default, skip_serializing_if = "Vec::is_empty")]
314    pub warning_refs: Vec<String>,
315}
316
317/// A page-anchored bbox (citation target).
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct PageBox {
320    /// Page id.
321    pub page: String,
322    /// Bbox on that page.
323    pub bbox: QRect,
324}
325
326/// Token estimate.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct TokenEstimate {
329    /// Estimated count.
330    pub count: u32,
331    /// Pinned estimator id (`name@version`).
332    pub estimator: String,
333    /// Explicit approximation flag.
334    pub approximate: bool,
335}
336
337/// Non-text region kind. Base tier emits `unknown` unless deterministic gates are met.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum RegionKind {
341    /// Unclassified (base tier default).
342    Unknown,
343    /// Raster image.
344    Image,
345    /// Figure.
346    Figure,
347    /// Formula.
348    Formula,
349    /// Chart.
350    Chart,
351}
352
353/// A non-text region with stable coordinates.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct Region {
356    /// `r%04d`.
357    pub id: String,
358    /// Owning page.
359    pub page: String,
360    /// Bounding box.
361    pub bbox: QRect,
362    /// Kind label.
363    pub kind: RegionKind,
364    /// Attached warnings.
365    #[serde(default, skip_serializing_if = "Vec::is_empty")]
366    pub warning_refs: Vec<String>,
367}
368
369/// A stable, deterministic warning (fixed-template message).
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct Warning {
372    /// `w%04d` (contract §5 numbering).
373    pub id: String,
374    /// Stable code.
375    pub code: WarningCode,
376    /// Fixed-template message — no timestamps, paths, or host data.
377    pub message: String,
378    /// Page attachment.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub page: Option<String>,
381    /// Element attachment.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub element_ref: Option<String>,
384    /// Span attachment.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub span_ref: Option<String>,
387    /// Region attachment.
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub region_ref: Option<String>,
390}
391
392impl ElementType {
393    /// Stable wire string (matches the serde snake_case form).
394    pub fn as_str(self) -> &'static str {
395        match self {
396            ElementType::TextBlock => "text_block",
397            ElementType::Heading => "heading",
398            ElementType::List => "list",
399            ElementType::ListItem => "list_item",
400            ElementType::Table => "table",
401            ElementType::Region => "region",
402            ElementType::Header => "header",
403            ElementType::Footer => "footer",
404            ElementType::Caption => "caption",
405            ElementType::Other => "other",
406        }
407    }
408}
409
410fn grounding_element_from_element(e: &Element) -> crate::grounding::GroundingElement {
411    crate::grounding::GroundingElement {
412        id: e.id.clone(),
413        page: e.page.clone(),
414        bbox: e.bbox.to_array(),
415        kind: e.element_type.as_str().to_string(),
416        text: e.text.clone(),
417    }
418}
419
420/// Ethos output is itself just another grounding source (PRD §1.5): the verify layer
421/// sees Ethos through the same trait as any foreign parser.
422impl crate::grounding::GroundingSource for Document {
423    fn parser(&self) -> crate::grounding::ParserIdentity {
424        crate::grounding::ParserIdentity {
425            name: self.parser.name.clone(),
426            version: self.parser.version.clone(),
427            adapter: None,
428            adapter_version: None,
429        }
430    }
431
432    fn capabilities(&self) -> crate::grounding::Capabilities {
433        crate::grounding::Capabilities {
434            spans: true,
435            char_offsets: true,
436            tables: true,
437            fingerprint: true,
438            coordinate_origin: crate::grounding::CoordinateOrigin::TopLeft,
439            // crop support arrives with ethos-render (Milestone C/D)
440            crop_support: false,
441        }
442    }
443
444    fn fingerprint(&self) -> Option<String> {
445        Some(self.fingerprint.clone())
446    }
447
448    fn pages(&self) -> Vec<crate::grounding::PageGeometry> {
449        self.payload
450            .pages
451            .iter()
452            .map(|p| crate::grounding::PageGeometry {
453                id: p.id.clone(),
454                index: p.index,
455                width: p.width,
456                height: p.height,
457                rotation: p.rotation,
458            })
459            .collect()
460    }
461
462    fn elements(&self) -> Vec<crate::grounding::GroundingElement> {
463        self.payload
464            .elements
465            .iter()
466            .map(grounding_element_from_element)
467            .collect()
468    }
469
470    fn element_by_id(&self, id: &str) -> Option<crate::grounding::GroundingElement> {
471        self.payload
472            .elements
473            .iter()
474            .find(|e| e.id == id)
475            .map(grounding_element_from_element)
476    }
477
478    fn spans(&self) -> Vec<crate::grounding::GroundingSpan> {
479        self.payload
480            .spans
481            .iter()
482            .map(|s| crate::grounding::GroundingSpan {
483                id: s.id.clone(),
484                page: s.page.clone(),
485                bbox: s.bbox.to_array(),
486                text: s.text.clone(),
487                element: None,
488                char_start: s.char_start,
489                char_end: s.char_end,
490            })
491            .collect()
492    }
493
494    fn tables(&self) -> Vec<crate::grounding::GroundingTable> {
495        self.payload
496            .tables
497            .iter()
498            .map(|t| crate::grounding::GroundingTable {
499                id: t.id.clone(),
500                page: t.page_refs.first().cloned().unwrap_or_default(),
501                bbox: t.bbox.to_array(),
502                cells: t
503                    .cells
504                    .iter()
505                    .map(|c| crate::grounding::GroundingCell {
506                        row: c.row,
507                        col: c.col,
508                        row_span: c.row_span,
509                        col_span: c.col_span,
510                        bbox: c.bbox.to_array(),
511                        text: c.text.clone(),
512                    })
513                    .collect(),
514            })
515            .collect()
516    }
517}
518
519impl Document {
520    /// c14n bytes of the emitted payload.
521    pub fn payload_c14n(&self) -> Result<Vec<u8>, EthosError> {
522        let value = serde_json::to_value(&self.payload)
523            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.to_string()))?;
524        crate::c14n::c14n_bytes(&value)
525            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.message))
526    }
527
528    /// Stable c14n bytes of the payload projection used by fingerprints and G3.
529    pub fn payload_fingerprint_c14n(&self) -> Result<Vec<u8>, EthosError> {
530        let value = stable_payload_projection(&self.payload)?;
531        crate::c14n::c14n_bytes(&value)
532            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.message))
533    }
534
535    /// Recompute `payload_sha256` from the stable payload projection.
536    pub fn compute_payload_sha256(&self) -> Result<String, EthosError> {
537        let value = stable_payload_projection(&self.payload)?;
538        crate::c14n::sha256_hex(&value)
539            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.message))
540    }
541
542    /// Compute `payload_sha256` for an assembled payload before the envelope exists.
543    pub fn compute_payload_sha256_for_payload(payload: &Payload) -> Result<String, EthosError> {
544        let value = stable_payload_projection(payload)?;
545        crate::c14n::sha256_hex(&value)
546            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.message))
547    }
548
549    /// Build the stable payload projection used by `payload_sha256`.
550    pub fn payload_fingerprint_value(&self) -> Result<Value, EthosError> {
551        stable_payload_projection(&self.payload)
552    }
553
554    /// Recompute the raw payload hash for diagnostics only.
555    pub fn compute_raw_payload_sha256(&self) -> Result<String, EthosError> {
556        let value = serde_json::to_value(&self.payload)
557            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.to_string()))?;
558        crate::c14n::sha256_hex(&value)
559            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.message))
560    }
561
562    /// Recompute the composite fingerprint from embedded envelope fields.
563    pub fn compute_fingerprint(&self) -> Result<String, EthosError> {
564        let manifest = crate::fingerprint::FingerprintManifest {
565            config_sha256: self.config_sha256.clone(),
566            payload_sha256: self.compute_payload_sha256()?,
567            profile_id: self.profile.id.clone(),
568            profile_sha256: self.profile.sha256.clone(),
569            schema_version: self.schema_version.clone(),
570            source_fingerprint: self.source.fingerprint.clone(),
571        };
572        manifest
573            .document_fingerprint()
574            .map_err(|e| EthosError::new(ErrorCode::InternalError, e.message))
575    }
576
577    /// Verify internal hash consistency (used by `ethos fingerprint`):
578    /// embedded `payload_sha256` and `fingerprint` match recomputation.
579    pub fn verify_integrity(&self) -> Result<(), EthosError> {
580        let payload = self.compute_payload_sha256()?;
581        if payload != self.payload_sha256 {
582            return Err(EthosError::new(
583                ErrorCode::InternalError,
584                "payload_sha256 mismatch: document was modified or produced non-canonically",
585            ));
586        }
587        let fp = self.compute_fingerprint()?;
588        if fp != self.fingerprint {
589            return Err(EthosError::new(
590                ErrorCode::InternalError,
591                "fingerprint mismatch: envelope and payload disagree",
592            ));
593        }
594        Ok(())
595    }
596}
597
598fn stable_payload_projection(payload: &Payload) -> Result<Value, EthosError> {
599    let mut value = serde_json::to_value(payload)
600        .map_err(|e| EthosError::new(ErrorCode::InternalError, e.to_string()))?;
601    remove_unstable_geometry(&mut value);
602    Ok(value)
603}
604
605fn remove_unstable_geometry(value: &mut Value) {
606    match value {
607        Value::Object(map) => {
608            map.remove("bbox");
609            map.remove("bboxes");
610            for child in map.values_mut() {
611                remove_unstable_geometry(child);
612            }
613        }
614        Value::Array(items) => {
615            for child in items {
616                remove_unstable_geometry(child);
617            }
618        }
619        _ => {}
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    fn example() -> (&'static str, Document) {
628        let raw = include_str!(concat!(
629            env!("CARGO_MANIFEST_DIR"),
630            "/../../schemas/examples/document.example.json"
631        ));
632        (
633            raw,
634            serde_json::from_str(raw).expect("example deserializes"),
635        )
636    }
637
638    #[test]
639    fn example_round_trips_at_value_level() {
640        let (raw, doc) = example();
641        let original: Value = serde_json::from_str(raw).unwrap();
642        let reserialized = serde_json::to_value(&doc).unwrap();
643        assert_eq!(
644            original, reserialized,
645            "model drops or reorders schema fields"
646        );
647    }
648
649    #[test]
650    fn example_hashes_are_self_consistent() {
651        let (_, doc) = example();
652        doc.verify_integrity()
653            .expect("example hashes must be real (regenerated, not fake)");
654    }
655
656    #[test]
657    fn reserialization_is_stable() {
658        let (_, doc) = example();
659        let a = doc.payload_c14n().unwrap();
660        let b = doc.payload_c14n().unwrap();
661        assert_eq!(a, b);
662        // parse(serialize(doc)) == doc
663        let v = serde_json::to_value(&doc).unwrap();
664        let doc2: Document = serde_json::from_value(v).unwrap();
665        assert_eq!(doc, doc2);
666    }
667
668    #[test]
669    fn payload_hash_ignores_precise_bbox_geometry() {
670        let (_, doc) = example();
671        let mut shifted = doc.clone();
672        shifted.payload.elements[0].bbox = QRect::new(1, 2, 3, 4).unwrap();
673        shifted.payload.spans[0].bbox = QRect::new(5, 6, 7, 8).unwrap();
674        shifted.payload.tables[0].bbox = QRect::new(9, 10, 11, 12).unwrap();
675        shifted.payload.tables[0].cells[0].bbox = QRect::new(13, 14, 15, 16).unwrap();
676        shifted.payload.chunks[0].bboxes[0].bbox = QRect::new(17, 18, 19, 20).unwrap();
677        shifted.payload.regions[0].bbox = QRect::new(21, 22, 23, 24).unwrap();
678
679        assert_eq!(
680            doc.compute_payload_sha256().unwrap(),
681            shifted.compute_payload_sha256().unwrap()
682        );
683    }
684
685    #[test]
686    fn payload_hash_binds_origin_locator() {
687        let (_, doc) = example();
688        let mut changed = doc.clone();
689        changed.payload.spans[0].origin_locator = Some(SpanOriginLocator {
690            policy: "origin-run-locator-v1".to_string(),
691            first_origin: [7200, 7200],
692            last_origin: [30480, 7200],
693        });
694
695        assert_ne!(
696            doc.compute_payload_sha256().unwrap(),
697            changed.compute_payload_sha256().unwrap()
698        );
699    }
700}