Skip to main content

typst_library/introspection/
convergence.rs

1use std::any::{Any, TypeId};
2use std::fmt::{Debug, Write};
3use std::hash::{Hash, Hasher};
4use std::sync::Arc;
5
6use comemo::{Track, Tracked};
7use ecow::{EcoString, EcoVec, eco_format};
8use typst_syntax::Span;
9use typst_utils::Protected;
10
11use crate::World;
12use crate::diag::{SourceDiagnostic, warning};
13use crate::engine::{Engine, Route, Sink, Traced};
14use crate::introspection::Introspector;
15
16pub const MAX_ITERS: usize = 5;
17pub const ITER_NAMES: &[&str] =
18    &["iter (1)", "iter (2)", "iter (3)", "iter (4)", "iter (5)"];
19
20const INSTANCES: usize = MAX_ITERS + 1;
21
22/// Analyzes all introspections that were performed during compilation and
23/// produces non-convergence diagnostics.
24#[typst_macros::time(name = "analyze introspections")]
25pub fn analyze(
26    world: Tracked<dyn World + '_>,
27    introspectors: [&dyn Introspector; INSTANCES],
28    introspections: &[Introspection],
29) -> EcoVec<SourceDiagnostic> {
30    let mut sink = Sink::new();
31    for introspection in introspections {
32        if let Some(warning) = introspection.0.diagnose(world, introspectors) {
33            sink.warn(warning);
34        }
35    }
36
37    // Let's say you want to write some code that depends on the presence of an
38    // element in the document. You could (1) use the `QueryIntrospection` and
39    // then do your emptyness check afterwards or you could (2) write a new
40    // [introspection](Introspect) that queries internally and returns a
41    // boolean.
42    //
43    // In case (1), the introspection observes the same data as comemo. Thus,
44    // the introspection will have converged if and only if comemo validation
45    // also passed.
46    //
47    // However, in case (2) the introspection is filtering out data that comemo
48    // did observe. Thus, the validation may fail but the document actually did
49    // converge! In this case, we reach `analyze` (because comemo validation
50    // failed), but we get zero diagnostics. In this case, we do _not_ issue the
51    // convergence warning, since the document did in fact converge.
52    //
53    // Note that we could also entirely decouple convergence checks from comemo.
54    // However, it's nice to have as a fast path as the comemo checks are more
55    // lightweight.
56    let mut diags = sink.warnings();
57    if !diags.is_empty() {
58        let summary = warning!(
59            Span::detached(),
60            "document did not converge within five attempts";
61            hint: "see {} additional warning{} for more details",
62                diags.len(),
63                if diags.len() > 1 { "s" } else { "" };
64            hint: "see https://typst.app/help/convergence for help";
65        );
66        diags.insert(0, summary);
67    }
68
69    diags
70}
71
72/// An inquiry for retrieving a piece of information from the document.
73///
74/// This includes queries, counter retrievals, and various other things that can
75/// be observed on a finished document.
76///
77/// Document iteration N+1 observes the `Output` values from the document built
78/// by iteration N. If the output values do not stabilize by the iteration
79/// limit, a non-convergence warning will be created via
80/// [`diagnose`](Self::diagnose).
81///
82/// Some introspections directly map to functions on the introspector while
83/// others are more high-level. To decide between these two options, think about
84/// how you want a non-convergence diagnostic to look. If the diagnostic for the
85/// generic introspection (e.g. `QueryIntrospection`) is sufficiently clear, you
86/// can use that one directly. If you'd rather fine-tune the diagnostic for
87/// non-convergence, create a new introspection.
88///
89/// A good example of this are counters and state: They could just use
90/// `QueryIntrospection`, but are custom introspections so that the diagnostics
91/// can expose non-convergence in a way that's closer to what the user operates
92/// with.
93pub trait Introspect: Debug + PartialEq + Hash + Send + Sync + Sized + 'static {
94    /// The kind of output the introspection produces. This is what should
95    /// stabilize.
96    ///
97    /// Note that how much information you have in the output may affect
98    /// convergence behavior. For instance, if you reduce down the result of a
99    /// query introspection to a boolean specifying whether the query yielded at
100    /// least one element, this may converge one iteration sooner than a raw
101    /// query would have (even if you always reduce the query to the same bool
102    /// externally).
103    ///
104    /// Thus, it matters whether this reduction is performed as part of the
105    /// introspection or externally. This is similar to how `location.page()`
106    /// may converge one iteration sooner than `location.position().page`.
107    /// Consider this example:
108    ///
109    /// ```typ
110    /// #switch(n => if n == 5 {
111    ///   v(1cm)
112    ///   _ = locate(heading).page()
113    /// })
114    /// = Heading
115    /// ```
116    ///
117    /// Both will always result in the same output, but observing the X/Y
118    /// position may end up requiring one extra iteration and if this happens
119    /// exactly at the limit of five iterations, the warning may appear (without
120    /// any effect on the document, which did actually converge).
121    ///
122    /// In theory, we could detect this scenario by compiling one more time and
123    /// ensuring the document is _exactly_ the same. For now, we're not doing
124    /// this, but it's an option.
125    type Output: Hash;
126
127    /// Resolves the output value.
128    ///
129    /// Will primarily use the `introspector`, but is passed the full engine in
130    /// case user functions need to be called (as is the case for counters).
131    fn introspect(
132        &self,
133        engine: &mut Engine,
134        introspector: Tracked<dyn Introspector + '_>,
135    ) -> Self::Output;
136
137    /// Produces a diagnostic for non-convergence given the history of its
138    /// output values.
139    fn diagnose(&self, history: &History<Self::Output>) -> SourceDiagnostic;
140}
141
142/// A type-erased representation of an [introspection](Introspect) that was
143/// recorded during compilation.
144#[derive(Debug, Clone, Hash)]
145#[allow(clippy::derived_hash_with_manual_eq)]
146pub struct Introspection(Arc<dyn Bounds>);
147
148impl Introspection {
149    /// Type erase a strongly-typed introspection.
150    pub fn new<I>(inner: I) -> Self
151    where
152        I: Introspect,
153    {
154        Self(Arc::new(inner))
155    }
156}
157
158impl PartialEq for Introspection {
159    fn eq(&self, other: &Self) -> bool {
160        self.0.dyn_eq(other)
161    }
162}
163
164trait Bounds: Debug + Send + Sync + Any + 'static {
165    fn diagnose(
166        &self,
167        world: Tracked<dyn World + '_>,
168        introspectors: [&dyn Introspector; INSTANCES],
169    ) -> Option<SourceDiagnostic>;
170    fn dyn_eq(&self, other: &Introspection) -> bool;
171    fn dyn_hash(&self, state: &mut dyn Hasher);
172}
173
174impl<T> Bounds for T
175where
176    T: Introspect,
177{
178    fn diagnose(
179        &self,
180        world: Tracked<dyn World + '_>,
181        introspectors: [&dyn Introspector; INSTANCES],
182    ) -> Option<SourceDiagnostic> {
183        let history = History::compute(world, introspectors, |engine, introspector| {
184            self.introspect(engine, introspector)
185        });
186        (!history.converged()).then(|| self.diagnose(&history))
187    }
188
189    fn dyn_eq(&self, other: &Introspection) -> bool {
190        let inner: &dyn Bounds = &*other.0;
191        let Some(other) = (inner as &dyn Any).downcast_ref::<Self>() else {
192            return false;
193        };
194        self == other
195    }
196
197    fn dyn_hash(&self, mut state: &mut dyn Hasher) {
198        // Also hash the TypeId since introspections with different types but
199        // equal data should be different.
200        TypeId::of::<Self>().hash(&mut state);
201        self.hash(&mut state);
202    }
203}
204
205impl Hash for dyn Bounds {
206    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
207        self.dyn_hash(state);
208    }
209}
210
211/// A history of values that were observed throughout iterations, alongside the
212/// introspectors they were observed for.
213pub struct History<'a, T>([(&'a dyn Introspector, T); INSTANCES]);
214
215impl<'a, T> History<'a, T> {
216    /// Computes the value for each introspector with an ad-hoc engine.
217    fn compute(
218        world: Tracked<dyn World + '_>,
219        introspectors: [&'a dyn Introspector; INSTANCES],
220        f: impl Fn(&mut Engine, Tracked<'a, dyn Introspector + '_>) -> T,
221    ) -> Self {
222        Self(introspectors.map(|introspector| {
223            let tracked = introspector.track();
224            let traced = Traced::default();
225            let mut sink = Sink::new();
226            let mut engine = Engine {
227                library: world.library(),
228                world,
229                introspector: Protected::new(tracked),
230                traced: traced.track(),
231                sink: sink.track_mut(),
232                route: Route::default(),
233            };
234            (introspector, f(&mut engine, tracked))
235        }))
236    }
237
238    /// Whether the values in this history converged, i.e. the final and
239    /// pre-final values are the same.
240    pub fn converged(&self) -> bool
241    where
242        T: Hash,
243    {
244        // We compare by hash because the values should be fully the same, i.e.
245        // with no observable difference. When a state changes from `0.0`
246        // (float) to `0` (int), that could be observed in the next iteration
247        // and should not count as converged.
248        typst_utils::hash128(&self.0[MAX_ITERS - 1].1)
249            == typst_utils::hash128(&self.0[MAX_ITERS].1)
250    }
251
252    /// Transforms the contained values with `f`.
253    pub fn map<U>(self, mut f: impl FnMut(T) -> U) -> History<'a, U> {
254        History(self.0.map(move |(i, t)| (i, f(t))))
255    }
256
257    /// Takes a reference to the contained values.
258    pub fn as_ref(&self) -> History<'a, &T> {
259        History(self.0.each_ref().map(|(i, t)| (*i, t)))
260    }
261
262    /// Accesses the final iteration's introspector.
263    pub fn final_introspector(&self) -> &'a dyn Introspector {
264        self.0[MAX_ITERS].0
265    }
266
267    /// Produces a hint with the observed values for each iteration.
268    pub fn hint(&self, what: &str, mut f: impl FnMut(&T) -> EcoString) -> EcoString {
269        let mut hint = eco_format!("the following {what} were observed:");
270        for (i, (_, val)) in self.0.iter().enumerate() {
271            let attempt = match i {
272                0..MAX_ITERS => eco_format!("run {}", i + 1),
273                MAX_ITERS => eco_format!("final"),
274                _ => panic!(),
275            };
276            let output = f(val);
277            write!(hint, "\n- {attempt}: {output}").unwrap();
278        }
279        hint
280    }
281}