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