Skip to main content

rustyfi_lang/
crossref.rs

1//! Cross-reference table + the fixpoint verdict, a small port of
2//! `crossRef.ml`. Owned by the compile driver (`lib.rs::compile_document_cst`),
3//! **not** reset per trial — it *is* the fixpoint state that persists while
4//! everything else (hooks, images, mutable store) resets each trial.
5
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8/// A cross-reference table as it is carried BETWEEN runs — the payload of the
9/// auxiliary file (`<doc>.satysfi-aux`). `BTreeMap` so serializing it is
10/// deterministic: the same table must always produce the same file bytes.
11pub type AuxTable = BTreeMap<String, String>;
12
13/// `crossRef.ml:23`'s `count_max`.
14const COUNT_MAX: u32 = 4;
15
16/// What the driver should do after one trial finished and hooks fired.
17#[derive(Debug, PartialEq, Eq)]
18pub enum Verdict {
19    /// The table changed this trial; run another trial.
20    NeedsAnotherTrial,
21    /// The table stabilized. Carries the keys `get` missed during the
22    /// *final* trial (unresolved forward references; `crossRef.ml:78`).
23    CanTerminate(Vec<String>),
24    /// The table kept changing through `COUNT_MAX` trials; give up rather
25    /// than loop forever.
26    CountMax,
27}
28
29/// Port of `crossRef.ml`'s mutable cross-reference table.
30#[derive(Debug, Default)]
31pub struct CrossRefs {
32    table: HashMap<String, String>,
33    count: u32,
34    /// Keys `get` missed this trial (`crossRef.ml:116`).
35    unresolved: Vec<String>,
36    /// What each key's value looked like when it was READ (`get`) this trial —
37    /// `Some(v)` for a hit, `None` for a miss — recorded at the FIRST read.
38    /// A trial's LAYOUT depends only on the cross-reference values it actually
39    /// reads; this lets `verdict` retry only when a read value was invalidated,
40    /// not merely because some key was (re)registered.
41    read_this_trial: HashMap<String, Option<String>>,
42    /// Set when a `register` this trial gives a key a value differing from what
43    /// a `get` earlier this trial had already observed for it — i.e. the layout
44    /// used a now-stale cross-reference and must be recomputed.
45    stale: bool,
46    /// Keys this table was SEEDED with from a previous run's auxiliary file
47    /// (empty for a cold run). Tracked only to police them — see
48    /// [`CrossRefs::seed_unvalidated`].
49    seeded: HashSet<String>,
50    /// Keys `register`ed during the trial now in progress. Reset by
51    /// [`CrossRefs::verdict`] alongside the rest of the per-trial bookkeeping.
52    registered_this_trial: HashSet<String>,
53    /// Did the trial that just ended read a seeded value it never re-derived?
54    /// See [`CrossRefs::seed_unvalidated`].
55    seed_unvalidated: bool,
56}
57
58impl CrossRefs {
59    pub fn new() -> CrossRefs {
60        CrossRefs::default()
61    }
62
63    /// A table pre-populated from a previous run's auxiliary file.
64    ///
65    /// Seeding only changes how fast the fixpoint converges, never where it
66    /// converges to: a seeded value a `get` observes and a later `register`
67    /// contradicts still marks the layout stale and forces another trial. It
68    /// mainly helps a forward reference (`\ref` to a later section) resolve
69    /// on trial 1 instead of trial 2.
70    pub fn seeded(table: AuxTable) -> CrossRefs {
71        CrossRefs {
72            seeded: table.keys().cloned().collect(),
73            table: table.into_iter().collect(),
74            ..CrossRefs::default()
75        }
76    }
77
78    /// The table as it should be written back out.
79    ///
80    /// Keys this run neither read nor registered are carried through
81    /// verbatim, so an auxiliary file round-trips with upstream SATySFi.
82    pub fn export(&self) -> AuxTable {
83        self.table
84            .iter()
85            .map(|(k, v)| (k.clone(), v.clone()))
86            .collect()
87    }
88
89    /// Did the trial that just ended READ a seeded value that it never
90    /// re-registered?
91    ///
92    /// If so the layout depended on a value from a previous run that was
93    /// never re-derived this run (e.g. a `\label` the document dropped but
94    /// some `\ref` still targets) — unverifiable, not necessarily wrong. The
95    /// driver's answer is to discard the seed and redo the fixpoint cold, so
96    /// a warm build is always byte-identical to a cold one.
97    pub fn seed_unvalidated(&self) -> bool {
98        self.seed_unvalidated
99    }
100
101    /// `crossRef.ml:99`, plus the fixpoint-shortcut bookkeeping: if this key was
102    /// already READ this trial and the value the reader saw differs from `v`,
103    /// the layout is now stale and another trial is required. A key that is
104    /// (re)registered but never read this trial does NOT force a retrial — its
105    /// value cannot have affected the output — which is what lets a document
106    /// that only *writes* cross-references (e.g. page labels nothing `\ref`s)
107    /// converge in ONE trial.
108    pub fn register(&mut self, k: String, v: String) {
109        self.registered_this_trial.insert(k.clone());
110        if let Some(observed) = self.read_this_trial.get(&k) {
111            let unchanged = matches!(observed, Some(o) if *o == v);
112            if !unchanged {
113                self.stale = true;
114            }
115        }
116        self.table.insert(k, v);
117    }
118
119    /// `crossRef.ml:116` — records a miss (an unresolved forward reference)
120    /// alongside the ordinary lookup, and remembers what the reader observed so
121    /// a later `register` can tell whether that observation went stale.
122    pub fn get(&mut self, k: &str) -> Option<String> {
123        match self.table.get(k) {
124            Some(v) => {
125                let v = v.clone();
126                self.read_this_trial
127                    .entry(k.to_string())
128                    .or_insert_with(|| Some(v.clone()));
129                Some(v)
130            }
131            None => {
132                self.unresolved.push(k.to_string());
133                self.read_this_trial.entry(k.to_string()).or_insert(None);
134                None
135            }
136        }
137    }
138
139    /// `crossRef.ml:112` — like [`Self::get`] but records no miss
140    /// (`probe-cross-reference`): probing an absent key must NOT force
141    /// another fixpoint trial.
142    pub fn probe(&self, k: &str) -> Option<String> {
143        self.table.get(k).cloned()
144    }
145
146    /// `crossRef.ml:78` `needs_another_trial`, tightened: retry only if a value
147    /// the layout READ this trial was invalidated (`stale`), not on every
148    /// (re)registration. Consumes this trial's bookkeeping and resets it.
149    pub fn verdict(&mut self) -> Verdict {
150        // A seeded key the layout READ but this trial never re-registered is
151        // an unverified dependency (see `seed_unvalidated`); compute it before
152        // the per-trial bookkeeping below is cleared.
153        self.seed_unvalidated = self
154            .read_this_trial
155            .keys()
156            .any(|k| self.seeded.contains(k) && !self.registered_this_trial.contains(k));
157        self.read_this_trial.clear();
158        self.registered_this_trial.clear();
159        if self.stale {
160            if self.count >= COUNT_MAX {
161                Verdict::CountMax
162            } else {
163                self.unresolved.clear();
164                self.stale = false;
165                self.count += 1;
166                Verdict::NeedsAnotherTrial
167            }
168        } else {
169            Verdict::CanTerminate(std::mem::take(&mut self.unresolved))
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn a_write_only_key_converges_on_the_first_trial() {
180        let mut cr = CrossRefs::new();
181        // A key that is registered but never READ this trial cannot have
182        // affected the layout, so the fixpoint terminates immediately.
183        cr.register("p".to_string(), "1".to_string());
184        assert_eq!(cr.verdict(), Verdict::CanTerminate(Vec::new()));
185    }
186
187    #[test]
188    fn a_forward_reference_needs_a_second_trial_then_converges() {
189        let mut cr = CrossRefs::new();
190        // Trial 1: read "p" before it exists (a forward ref) — the layout saw
191        // `None` — then register it. The observed value went stale, so retry.
192        assert_eq!(cr.get("p"), None);
193        cr.register("p".to_string(), "1".to_string());
194        assert_eq!(cr.verdict(), Verdict::NeedsAnotherTrial);
195
196        // Trial 2: the read now hits "1", and the re-registration matches it —
197        // nothing the layout read changed, so terminate.
198        assert_eq!(cr.get("p"), Some("1".to_string()));
199        cr.register("p".to_string(), "1".to_string());
200        assert_eq!(cr.verdict(), Verdict::CanTerminate(Vec::new()));
201    }
202
203    #[test]
204    fn a_read_reference_that_never_resolves_hits_count_max() {
205        let mut cr = CrossRefs::new();
206        for i in 0..(COUNT_MAX + 2) {
207            // A pathological document that READS a key and then registers a
208            // *new* value every trial never stabilizes; the cap must still
209            // terminate it (a write-only churn would converge on trial 1).
210            let _ = cr.get("k");
211            cr.register("k".to_string(), i.to_string());
212            let v = cr.verdict();
213            if v == Verdict::CountMax {
214                return;
215            }
216        }
217        panic!("expected CountMax within COUNT_MAX trials");
218    }
219
220    #[test]
221    fn an_unresolved_get_forces_another_trial_via_the_caller_not_verdict_alone() {
222        // `get` missing a key does not, by itself, set `changed` — only
223        // `register` does; the unresolved list is informational/diagnostic
224        // only. A document that never registers the key it `get`s converges
225        // immediately, with the miss surfaced in `CanTerminate`'s payload.
226        let mut cr = CrossRefs::new();
227        assert_eq!(cr.get("missing"), None);
228        assert_eq!(
229            cr.verdict(),
230            Verdict::CanTerminate(vec!["missing".to_string()])
231        );
232    }
233
234    #[test]
235    fn seeding_resolves_a_forward_reference_on_the_first_trial() {
236        // Cold, this is the two-trial case above: the layout reads "p"
237        // before anything registers it, so trial 1 goes stale. Seeded from
238        // the previous run's table, the same read hits the right value
239        // immediately and the re-registration confirms it in one trial.
240        let mut aux = AuxTable::new();
241        aux.insert("p".to_string(), "1".to_string());
242        let mut cr = CrossRefs::seeded(aux);
243
244        assert_eq!(cr.get("p"), Some("1".to_string()));
245        cr.register("p".to_string(), "1".to_string());
246        assert_eq!(cr.verdict(), Verdict::CanTerminate(Vec::new()));
247        assert!(!cr.seed_unvalidated(), "the seed was re-derived this run");
248    }
249
250    #[test]
251    fn a_seed_the_run_contradicts_still_forces_another_trial() {
252        // Seeding must not be able to freeze a wrong answer: a seeded value the
253        // layout reads and a later `register` contradicts is exactly the stale
254        // case, and retries just as an in-run value would.
255        let mut aux = AuxTable::new();
256        aux.insert("p".to_string(), "STALE".to_string());
257        let mut cr = CrossRefs::seeded(aux);
258
259        assert_eq!(cr.get("p"), Some("STALE".to_string()));
260        cr.register("p".to_string(), "1".to_string());
261        assert_eq!(cr.verdict(), Verdict::NeedsAnotherTrial);
262    }
263
264    #[test]
265    fn a_seed_that_is_read_but_never_re_registered_is_flagged() {
266        // The dangerous shape: the document still `\ref`s a key but no
267        // longer defines it, so nothing this run can confirm the seeded
268        // value. The fixpoint converges happily anyway, so `verdict` flags
269        // it and the driver redoes the run cold.
270        let mut aux = AuxTable::new();
271        aux.insert("p".to_string(), "1".to_string());
272        let mut cr = CrossRefs::seeded(aux);
273
274        assert_eq!(cr.get("p"), Some("1".to_string()));
275        assert_eq!(cr.verdict(), Verdict::CanTerminate(Vec::new()));
276        assert!(
277            cr.seed_unvalidated(),
278            "read a seeded value nothing re-derived"
279        );
280    }
281
282    #[test]
283    fn an_unread_seed_is_not_flagged_and_is_carried_through() {
284        // A seeded key the document never reads cannot have affected the
285        // layout, so it is no reason to redo anything, but it is still
286        // written back out (see `export`'s doc comment for why).
287        let mut aux = AuxTable::new();
288        aux.insert("changed".to_string(), "F".to_string());
289        aux.insert("stale-label".to_string(), "9".to_string());
290        let mut cr = CrossRefs::seeded(aux);
291
292        cr.register("other".to_string(), "1".to_string());
293        assert_eq!(cr.verdict(), Verdict::CanTerminate(Vec::new()));
294        assert!(!cr.seed_unvalidated());
295
296        let out = cr.export();
297        assert_eq!(out.get("changed").map(String::as_str), Some("F"));
298        assert_eq!(out.get("stale-label").map(String::as_str), Some("9"));
299        assert_eq!(out.get("other").map(String::as_str), Some("1"));
300    }
301}