Skip to main content

fugue/program/
registry.rs

1//! Named distributions and pure functions a program can call: the built-ins,
2//! and whatever a host registers.
3
4use std::collections::HashMap;
5use std::fmt;
6use std::sync::Arc;
7
8use crate::core::distribution::*;
9use crate::error::FugueResult;
10
11use super::Value;
12
13/// The value type of a sample site: which `Model` sample variant (and which
14/// `ChoiceValue`) the site uses.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum SiteType {
17    /// A continuous site (`Distribution<f64>`); binds [`Value::F64`].
18    F64,
19    /// A boolean site (`Distribution<bool>`); binds [`Value::Bool`].
20    Bool,
21    /// A count site (`Distribution<u64>`); binds [`Value::U64`].
22    U64,
23    /// An index site (`Distribution<usize>`); binds [`Value::Usize`].
24    Usize,
25    /// A signed-integer site (`Distribution<i64>`); binds [`Value::Int`].
26    I64,
27}
28
29impl SiteType {
30    /// The Rust type's name: `"f64"`, `"bool"`, `"u64"`, `"usize"` or `"i64"`.
31    pub fn name(self) -> &'static str {
32        match self {
33            SiteType::F64 => "f64",
34            SiteType::Bool => "bool",
35            SiteType::U64 => "u64",
36            SiteType::Usize => "usize",
37            SiteType::I64 => "i64",
38        }
39    }
40}
41
42impl fmt::Display for SiteType {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(self.name())
45    }
46}
47
48/// A distribution built by a registered constructor, boxed at its site type.
49///
50/// Any `Distribution<T>` implementation fits, including a host's wrapper
51/// type that carries site metadata alongside the distribution it delegates
52/// to.
53pub enum HostDist {
54    /// A continuous distribution.
55    F64(Box<dyn Distribution<f64>>),
56    /// A distribution over `bool`.
57    Bool(Box<dyn Distribution<bool>>),
58    /// A distribution over counts.
59    U64(Box<dyn Distribution<u64>>),
60    /// A distribution over indices.
61    Usize(Box<dyn Distribution<usize>>),
62    /// A distribution over signed integers.
63    I64(Box<dyn Distribution<i64>>),
64}
65
66impl HostDist {
67    /// The site type this distribution samples.
68    pub fn site_type(&self) -> SiteType {
69        match self {
70            HostDist::F64(_) => SiteType::F64,
71            HostDist::Bool(_) => SiteType::Bool,
72            HostDist::U64(_) => SiteType::U64,
73            HostDist::Usize(_) => SiteType::Usize,
74            HostDist::I64(_) => SiteType::I64,
75        }
76    }
77}
78
79impl fmt::Debug for HostDist {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "HostDist::{:?}(..)", self.site_type())
82    }
83}
84
85/// How many arguments a registered distribution or function takes. Checked
86/// when a program is compiled.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum Arity {
89    /// Exactly this many.
90    Exact(usize),
91    /// Between the two bounds, inclusive.
92    Between(usize, usize),
93    /// This many or more.
94    AtLeast(usize),
95}
96
97impl Arity {
98    /// Whether a call with `n` arguments fits.
99    pub fn accepts(self, n: usize) -> bool {
100        match self {
101            Arity::Exact(k) => n == k,
102            Arity::Between(lo, hi) => (lo..=hi).contains(&n),
103            Arity::AtLeast(k) => n >= k,
104        }
105    }
106}
107
108impl fmt::Display for Arity {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            Arity::Exact(k) => write!(f, "{k}"),
112            Arity::Between(lo, hi) => write!(f, "{lo}..={hi}"),
113            Arity::AtLeast(k) => write!(f, "at least {k}"),
114        }
115    }
116}
117
118type DistCtor = dyn Fn(&[Value]) -> Result<HostDist, String> + Send + Sync;
119type FuncImpl = dyn Fn(&[Value]) -> Result<Value, String> + Send + Sync;
120
121/// A registered distribution: its declared site type, arity and constructor.
122#[derive(Clone)]
123pub(crate) struct DistEntry {
124    pub(crate) site: SiteType,
125    pub(crate) arity: Arity,
126    pub(crate) ctor: Arc<DistCtor>,
127}
128
129/// A registered pure function.
130#[derive(Clone)]
131pub(crate) struct FuncEntry {
132    pub(crate) arity: Arity,
133    pub(crate) f: Arc<FuncImpl>,
134}
135
136/// The distributions and pure functions programs can call, by name.
137///
138/// [`Registry::new`] (also `Default`) holds the built-ins: the distributions
139/// `Normal`, `Uniform`, `LogNormal`, `Exponential`, `Beta`, `Gamma`,
140/// `InverseGamma`, `StudentT`, `Cauchy`, `Laplace`, `Weibull`, `ChiSquared`
141/// (`f64` sites), `Bernoulli` (`bool`), `Binomial`, `Poisson` (`u64`),
142/// `Categorical` (`usize`) and `DiscreteUniform` (`i64`), with fugue's
143/// constructors and parameterizations; and the functions `exp`, `ln` (alias
144/// `log`), `sqrt`, `abs`, `floor`, `sin`, `cos`, `tanh` (one argument) and
145/// `pow`, `min`, `max` (two), all on `f64`. `Categorical` takes either its
146/// probabilities as arguments, `Categorical(0.2, 0.8)`, or one array,
147/// `Categorical(probs)`, of any length.
148///
149/// A host adds its own with [`register_distribution`](Registry::register_distribution)
150/// and [`register_function`](Registry::register_function); registering an
151/// existing name replaces it. Names a program can call are identifiers (the
152/// text syntax's); an entry under any other name is unreachable.
153///
154/// ```rust
155/// use fugue::program::{Arity, Data, HostDist, Program, Registry, SiteType, Value};
156/// use fugue::{Categorical, Distribution};
157///
158/// let mut registry = Registry::new();
159/// // A usize site whose probabilities come from the program's arguments.
160/// registry.register_distribution("Pick", SiteType::Usize, Arity::Exact(1), |args| {
161///     let p = args[0].as_f64().ok_or("Pick: p must be a number")?;
162///     let d = Categorical::new(vec![1.0 - p, p]).map_err(|e| e.to_string())?;
163///     Ok(HostDist::Usize(d.clone_box()))
164/// });
165/// // A pure function over ints and bools.
166/// registry.register_function("bump", Arity::Exact(2), |args| {
167///     let n = args[0].as_i64().ok_or("bump: n must be an integer")?;
168///     let on = args[1].as_bool().ok_or("bump: flag must be a bool")?;
169///     Ok(Value::Int(if on { n + 1 } else { n }))
170/// });
171///
172/// let program = Program::parse(
173///     r#"let k <- sample(addr!("k"), Pick(0.25));
174///        pure(bump(3, k == 1))"#,
175/// )
176/// .unwrap();
177/// assert!(program.compile(&registry, &Data::new()).is_ok());
178/// // Unknown names are compile-time errors.
179/// assert!(program.compile(&Registry::new(), &Data::new()).is_err());
180/// ```
181#[derive(Clone)]
182pub struct Registry {
183    dists: HashMap<String, DistEntry>,
184    funcs: HashMap<String, FuncEntry>,
185}
186
187impl Default for Registry {
188    fn default() -> Self {
189        Registry::new()
190    }
191}
192
193impl fmt::Debug for Registry {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        let mut dists: Vec<_> = self.dists.keys().collect();
196        let mut funcs: Vec<_> = self.funcs.keys().collect();
197        dists.sort();
198        funcs.sort();
199        f.debug_struct("Registry")
200            .field("distributions", &dists)
201            .field("functions", &funcs)
202            .finish()
203    }
204}
205
206impl Registry {
207    /// A registry holding the built-in distributions and functions.
208    pub fn new() -> Registry {
209        let mut r = Registry::empty();
210        register_builtins(&mut r);
211        r
212    }
213
214    /// A registry with nothing in it, for hosts that want to expose only
215    /// their own vocabulary.
216    pub fn empty() -> Registry {
217        Registry {
218            dists: HashMap::new(),
219            funcs: HashMap::new(),
220        }
221    }
222
223    /// Register a distribution constructor under `name`.
224    ///
225    /// `site` declares the type of the sites it builds; the constructor must
226    /// return a [`HostDist`] of that type. The declaration is what lets a site
227    /// keep a stable type when construction fails: an `Err` from the
228    /// constructor (or a `HostDist` of another type) is a runtime soft error —
229    /// the site is filled with a placeholder of the declared type, the
230    /// execution's weight becomes `-inf`, and a warning is recorded (see the
231    /// module docs' error policy). `arity` is checked at compile time.
232    ///
233    /// The constructor sees the argument values exactly as the program
234    /// computed them; [`Value`]'s accessors apply the language's coercions.
235    /// It must be deterministic in its arguments (replay and scoring rebuild
236    /// the same site many times) and must not panic.
237    pub fn register_distribution<F>(
238        &mut self,
239        name: impl Into<String>,
240        site: SiteType,
241        arity: Arity,
242        ctor: F,
243    ) -> &mut Registry
244    where
245        F: Fn(&[Value]) -> Result<HostDist, String> + Send + Sync + 'static,
246    {
247        self.dists.insert(
248            name.into(),
249            DistEntry {
250                site,
251                arity,
252                ctor: Arc::new(ctor),
253            },
254        );
255        self
256    }
257
258    /// Register a pure function under `name`. `arity` is checked at compile
259    /// time; an `Err` at runtime is a soft error (weight `-inf` plus a
260    /// warning). The function must be deterministic and must not panic.
261    pub fn register_function<F>(
262        &mut self,
263        name: impl Into<String>,
264        arity: Arity,
265        f: F,
266    ) -> &mut Registry
267    where
268        F: Fn(&[Value]) -> Result<Value, String> + Send + Sync + 'static,
269    {
270        self.funcs.insert(
271            name.into(),
272            FuncEntry {
273                arity,
274                f: Arc::new(f),
275            },
276        );
277        self
278    }
279
280    /// Whether a distribution is registered under `name`.
281    pub fn has_distribution(&self, name: &str) -> bool {
282        self.dists.contains_key(name)
283    }
284
285    /// Whether a function is registered under `name`.
286    pub fn has_function(&self, name: &str) -> bool {
287        self.funcs.contains_key(name)
288    }
289
290    /// The declared site type of the distribution registered under `name`.
291    pub fn distribution_site_type(&self, name: &str) -> Option<SiteType> {
292        self.dists.get(name).map(|d| d.site)
293    }
294
295    pub(crate) fn dist(&self, name: &str) -> Option<&DistEntry> {
296        self.dists.get(name)
297    }
298
299    pub(crate) fn func(&self, name: &str) -> Option<&FuncEntry> {
300        self.funcs.get(name)
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Built-ins
306// ---------------------------------------------------------------------------
307
308/// Argument `i` of `name` as an `f64` (numbers, and bools as 0/1).
309fn num(args: &[Value], i: usize, name: &str) -> Result<f64, String> {
310    args[i].as_f64().ok_or_else(|| {
311        format!(
312            "`{name}` argument {} must be a number, found {}",
313            i + 1,
314            args[i].type_name()
315        )
316    })
317}
318
319fn boxed_f64<D: Distribution<f64> + 'static>(r: FugueResult<D>) -> Result<HostDist, String> {
320    r.map(|d| HostDist::F64(Box::new(d)))
321        .map_err(|e| e.to_string())
322}
323
324fn register_builtins(r: &mut Registry) {
325    macro_rules! f64_dist {
326        ($name:literal, $ty:ident, 1) => {
327            r.register_distribution($name, SiteType::F64, Arity::Exact(1), |a| {
328                boxed_f64($ty::new(num(a, 0, $name)?))
329            });
330        };
331        ($name:literal, $ty:ident, 2) => {
332            r.register_distribution($name, SiteType::F64, Arity::Exact(2), |a| {
333                boxed_f64($ty::new(num(a, 0, $name)?, num(a, 1, $name)?))
334            });
335        };
336        ($name:literal, $ty:ident, 3) => {
337            r.register_distribution($name, SiteType::F64, Arity::Exact(3), |a| {
338                boxed_f64($ty::new(
339                    num(a, 0, $name)?,
340                    num(a, 1, $name)?,
341                    num(a, 2, $name)?,
342                ))
343            });
344        };
345    }
346    f64_dist!("Normal", Normal, 2);
347    f64_dist!("Uniform", Uniform, 2);
348    f64_dist!("LogNormal", LogNormal, 2);
349    f64_dist!("Beta", Beta, 2);
350    f64_dist!("Gamma", Gamma, 2);
351    f64_dist!("InverseGamma", InverseGamma, 2);
352    f64_dist!("Cauchy", Cauchy, 2);
353    f64_dist!("Laplace", Laplace, 2);
354    f64_dist!("Weibull", Weibull, 2);
355    f64_dist!("Exponential", Exponential, 1);
356    f64_dist!("ChiSquared", ChiSquared, 1);
357    f64_dist!("StudentT", StudentT, 3);
358
359    r.register_distribution("Bernoulli", SiteType::Bool, Arity::Exact(1), |a| {
360        Bernoulli::new(num(a, 0, "Bernoulli")?)
361            .map(|d| HostDist::Bool(Box::new(d)))
362            .map_err(|e| e.to_string())
363    });
364    r.register_distribution("Poisson", SiteType::U64, Arity::Exact(1), |a| {
365        Poisson::new(num(a, 0, "Poisson")?)
366            .map(|d| HostDist::U64(Box::new(d)))
367            .map_err(|e| e.to_string())
368    });
369    r.register_distribution("Binomial", SiteType::U64, Arity::Exact(2), |a| {
370        let n = a[0]
371            .as_u64()
372            .ok_or_else(|| format!("Binomial n must be a non-negative integer, got {}", a[0]))?;
373        Binomial::new(n, num(a, 1, "Binomial")?)
374            .map(|d| HostDist::U64(Box::new(d)))
375            .map_err(|e| e.to_string())
376    });
377    r.register_distribution("Categorical", SiteType::Usize, Arity::AtLeast(1), |a| {
378        // One array argument, or the probabilities themselves.
379        let probs = match a {
380            [Value::Arr(p)] => p.as_ref().clone(),
381            _ => (0..a.len())
382                .map(|i| num(a, i, "Categorical"))
383                .collect::<Result<Vec<f64>, String>>()?,
384        };
385        Categorical::new(probs)
386            .map(|d| HostDist::Usize(Box::new(d)))
387            .map_err(|e| e.to_string())
388    });
389    r.register_distribution("DiscreteUniform", SiteType::I64, Arity::Exact(2), |a| {
390        let bound = |i: usize| {
391            a[i].as_i64()
392                .ok_or_else(|| format!("DiscreteUniform bounds must be integers, got {}", a[i]))
393        };
394        DiscreteUniform::new(bound(0)?, bound(1)?)
395            .map(|d| HostDist::I64(Box::new(d)))
396            .map_err(|e| e.to_string())
397    });
398
399    macro_rules! math1 {
400        ($($name:literal => $f:expr),* $(,)?) => {$(
401            r.register_function($name, Arity::Exact(1), |a| {
402                let f: fn(f64) -> f64 = $f;
403                Ok(Value::F64(f(num(a, 0, $name)?)))
404            });
405        )*};
406    }
407    math1! {
408        "exp" => f64::exp,
409        "ln" => f64::ln,
410        "log" => f64::ln,
411        "sqrt" => f64::sqrt,
412        "abs" => f64::abs,
413        "floor" => f64::floor,
414        "sin" => f64::sin,
415        "cos" => f64::cos,
416        "tanh" => f64::tanh,
417    }
418    macro_rules! math2 {
419        ($($name:literal => $f:expr),* $(,)?) => {$(
420            r.register_function($name, Arity::Exact(2), |a| {
421                let f: fn(f64, f64) -> f64 = $f;
422                Ok(Value::F64(f(num(a, 0, $name)?, num(a, 1, $name)?)))
423            });
424        )*};
425    }
426    math2! {
427        "pow" => f64::powf,
428        "min" => f64::min,
429        "max" => f64::max,
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn builtins_are_registered_with_their_site_types() {
439        let r = Registry::new();
440        for (name, site) in [
441            ("Normal", SiteType::F64),
442            ("StudentT", SiteType::F64),
443            ("Bernoulli", SiteType::Bool),
444            ("Poisson", SiteType::U64),
445            ("Binomial", SiteType::U64),
446            ("Categorical", SiteType::Usize),
447            ("DiscreteUniform", SiteType::I64),
448        ] {
449            assert_eq!(r.distribution_site_type(name), Some(site), "{name}");
450        }
451        assert_eq!(r.dists.len(), 17);
452        for f in [
453            "exp", "ln", "log", "sqrt", "abs", "floor", "sin", "cos", "tanh", "pow", "min", "max",
454        ] {
455            assert!(r.has_function(f), "{f}");
456        }
457        assert!(!Registry::empty().has_distribution("Normal"));
458        assert!(format!("{r:?}").contains("Normal"));
459    }
460
461    #[test]
462    fn builtin_constructors_validate_arguments() {
463        let r = Registry::new();
464        let build = |name: &str, args: &[Value]| (r.dist(name).unwrap().ctor)(args);
465        assert!(build("Normal", &[Value::F64(0.0), Value::F64(-1.0)]).is_err());
466        assert!(build("Normal", &[Value::from(vec![1.0]), Value::F64(1.0)]).is_err());
467        assert!(build("Binomial", &[Value::F64(2.5), Value::F64(0.5)]).is_err());
468        assert!(build("Binomial", &[Value::Int(-1), Value::F64(0.5)]).is_err());
469        assert!(build("DiscreteUniform", &[Value::F64(0.5), Value::Int(3)]).is_err());
470        let many = Value::from(vec![1.0 / 100.0; 100]);
471        match build("Categorical", &[many]) {
472            Ok(HostDist::Usize(_)) => {}
473            other => panic!("{other:?}"),
474        }
475        assert!(build("Categorical", &[Value::F64(0.5), Value::from(vec![0.5])]).is_err());
476        let exp = r.func("exp").unwrap();
477        assert_eq!((exp.f)(&[Value::Int(0)]), Ok(Value::F64(1.0)));
478        assert!((exp.f)(&[Value::from(vec![0.0])]).is_err());
479        assert!(Arity::Between(1, 2).accepts(2) && !Arity::Between(1, 2).accepts(3));
480        assert_eq!(Arity::AtLeast(1).to_string(), "at least 1");
481    }
482}