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}