Skip to main content

mos_eval/
semantic_hash.rs

1//! Authored semantic input snapshots, taken before resolution mutates nodes.
2
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6use mos_core::{AttrValue, ContentHasher, Document, NodeKind};
7
8use crate::image_lower::{
9    BITS_PER_COMPONENT_ATTR, COLOR_SPACE_ATTR, PIXEL_HEIGHT_ATTR, PIXEL_WIDTH_ATTR, PIXELS_ATTR,
10};
11use crate::{ExternalDependency, LABEL_SPAN_END_ATTR, LABEL_SPAN_START_ATTR};
12
13/// Must run before citation/label resolution: derived bibliography children
14/// and rewritten reference/caption text are not authored semantic inputs.
15pub(crate) fn stamp(document: &mut Document, dependencies: &[ExternalDependency]) {
16    let files: BTreeMap<_, _> = dependencies
17        .iter()
18        .map(|dependency| {
19            (
20                dependency.path.as_path(),
21                dependency
22                    .fingerprint
23                    .map(|fingerprint| fingerprint.content),
24            )
25        })
26        .collect();
27    document.update_content_hashes(|node| {
28        let mut hasher = ContentHasher::new();
29        hasher
30            .field(b"mos-eval/authored-node/v1")
31            .field(kind_tag(node.kind));
32        // Before resolution, only label edit offsets and decoded image data
33        // sit alongside authored attrs. Paths locate the recorded byte hash
34        // below; machine-specific identities never enter the hash encoding.
35        for (key, value) in &node.attributes {
36            if matches!(
37                key.as_str(),
38                LABEL_SPAN_START_ATTR | LABEL_SPAN_END_ATTR | "resolved_path"
39            ) || (node.kind == NodeKind::Image
40                && matches!(
41                    key.as_str(),
42                    PIXELS_ATTR
43                        | PIXEL_WIDTH_ATTR
44                        | PIXEL_HEIGHT_ATTR
45                        | COLOR_SPACE_ATTR
46                        | BITS_PER_COMPONENT_ATTR
47                ))
48                || (matches!(
49                    node.kind,
50                    NodeKind::Reference | NodeKind::PageReference | NodeKind::Citation
51                ) && key == "text")
52            {
53                continue;
54            }
55            hasher.field(b"attr").field(key.as_bytes());
56            hash_value(&mut hasher, value);
57        }
58        if matches!(node.kind, NodeKind::Image | NodeKind::Bibliography) {
59            let path = if node.kind == NodeKind::Bibliography {
60                // The bibliography loader skips sources without a UTF-8 path,
61                // even when lowering records their fingerprint for invalidation.
62                match node.attributes.get("resolved_path") {
63                    Some(AttrValue::Str(path)) => Some(PathBuf::from(path)),
64                    _ => None,
65                }
66            } else {
67                // Images load using the original path; their resolved_path
68                // attribute is a lossy display string on non-UTF-8 filesystems.
69                match node.attributes.get("src") {
70                    Some(AttrValue::Str(src)) => {
71                        mos_core::resolve_source_path(src, &node.span.file).ok()
72                    }
73                    _ => None,
74                }
75            };
76            hasher.field(b"external-input");
77            match path.as_deref().and_then(|path| files.get(path)) {
78                Some(Some(content)) => {
79                    hasher.field(b"read").field(&content.0.to_le_bytes());
80                }
81                Some(None) => {
82                    hasher.field(b"unreadable");
83                }
84                None => {
85                    hasher.field(b"unloaded");
86                }
87            }
88        }
89        hasher.finish()
90    });
91}
92
93// Explicit tags keep the encoding independent of Rust enum discriminants.
94fn kind_tag(kind: NodeKind) -> &'static [u8] {
95    match kind {
96        NodeKind::Document => b"document",
97        NodeKind::Section => b"section",
98        NodeKind::Paragraph => b"paragraph",
99        NodeKind::Text => b"text",
100        NodeKind::Emphasis => b"emphasis",
101        NodeKind::Strong => b"strong",
102        NodeKind::BoldItalic => b"bold-italic",
103        NodeKind::Math => b"math",
104        NodeKind::Equation => b"equation",
105        NodeKind::Figure => b"figure",
106        NodeKind::Image => b"image",
107        NodeKind::Table => b"table",
108        NodeKind::Citation => b"citation",
109        NodeKind::Reference => b"reference",
110        NodeKind::PageReference => b"page-reference",
111        NodeKind::Theorem => b"theorem",
112        NodeKind::Footnote => b"footnote",
113        NodeKind::Bibliography => b"bibliography",
114        NodeKind::Raw => b"raw",
115        NodeKind::List => b"list",
116        NodeKind::ListItem => b"list-item",
117        NodeKind::HardBreak => b"hard-break",
118    }
119}
120
121fn hash_value(hasher: &mut ContentHasher, value: &AttrValue) {
122    match value {
123        AttrValue::Bool(value) => {
124            hasher.field(b"bool").u32(u32::from(*value));
125        }
126        AttrValue::Int(value) => {
127            hasher.field(b"int").field(&value.to_le_bytes());
128        }
129        AttrValue::Float(value) => {
130            hasher
131                .field(b"float")
132                .field(&float_bits(*value).to_le_bytes());
133        }
134        AttrValue::Str(value) => {
135            hasher.field(b"str").field(value.as_bytes());
136        }
137        AttrValue::List(values) => {
138            hasher.field(b"list");
139            for value in values {
140                hash_value(hasher, value);
141            }
142            hasher.field(b"end-list");
143        }
144        AttrValue::Length(value) => {
145            // Authored dimensions use the design note's 1/64-pt grid. Encode
146            // the integral count as canonical f64 bits, avoiding a lossy cast.
147            hasher
148                .field(b"length")
149                .field(&float_bits((value * 64.0).round()).to_le_bytes());
150        }
151        AttrValue::Bytes(value) => {
152            hasher.field(b"bytes").field(value);
153        }
154    }
155}
156
157fn float_bits(value: f64) -> u64 {
158    if value.is_nan() {
159        // Pin the encoding, including sign/payload, across target platforms.
160        0x7ff8_0000_0000_0000
161    } else if value == 0.0 {
162        0
163    } else {
164        value.to_bits()
165    }
166}
167
168#[cfg(test)]
169mod tests;