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}