Skip to main content

probl_engine/
lib.rs

1//! The Probl engine: values, distributions and the world-set interpreter.
2//! The rules it implements are in docs/semantics.md.
3
4pub mod analytic;
5pub mod builtins;
6pub mod chain;
7pub mod complex;
8pub mod conjugate;
9pub mod continuous;
10pub mod data;
11pub mod dates;
12pub mod dist;
13pub mod error;
14pub mod failure;
15pub mod interp;
16pub mod joint;
17pub mod likelihood;
18mod math;
19pub mod ops;
20mod ordering;
21pub mod quadrature;
22pub mod report;
23mod stats;
24pub mod symbolic;
25mod text;
26mod type_name;
27pub mod value;
28pub mod weight;
29pub mod world;
30
31pub use error::{ErrorKind, Fault, RuntimeError};
32pub use failure::{Failure, Failures};
33pub use interp::{Stats, Updates};
34pub use probl_sema::ir::FailureMode;
35pub use symbolic::{Diagrams, Symbolic};
36pub use weight::Weight;
37
38use continuous::Rng;
39use dist::Budget;
40use error::OpError;
41use probl_sema::Liveness;
42use probl_sema::conjugate::{Conjugacy, Variable};
43use probl_sema::ir::{Mode, Program};
44use probl_syntax::Span;
45use report::{Format, Sink};
46use std::any::Any;
47use std::collections::BTreeMap;
48use std::panic::AssertUnwindSafe;
49use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
50use std::sync::{Arc, Condvar, Mutex, PoisonError, mpsc};
51use value::Value;
52
53/// Upper bounds set by whoever runs a program. A program's `@max_worlds` and
54/// `@max_iterations` can lower them, never raise them.
55#[derive(Clone, Debug)]
56pub struct Limits {
57    /// Maximum bits per integer; cannot exceed the parser's hard ceiling.
58    pub max_integer_bits: u64,
59    /// Cumulative allowance for large integer results (including shared copies).
60    pub max_integer_bytes: u64,
61    /// Maximum UTF-8 bytes in one string.
62    pub max_string_bytes: usize,
63    /// Cumulative UTF-8 payload bytes produced by the runtime (not live memory).
64    pub max_string_alloc_bytes: u64,
65    /// Worlds one statement may produce.
66    pub max_worlds: usize,
67    /// Outcomes of one distribution, or of combining distributions.
68    pub max_outcomes: usize,
69    /// Elements of one collection built by the program.
70    pub max_collection: usize,
71    /// Units of work: world-steps plus outcomes computed.
72    pub max_work: u64,
73    /// Iterations of one loop.
74    pub max_iterations: u64,
75    /// Nested calls.
76    pub max_call_depth: usize,
77    /// Function results kept for reuse (the cache is cleared when full).
78    pub max_cached_calls: usize,
79    /// Bytes of `print` output.
80    pub max_output: usize,
81    /// Stack size of the engine's thread; it bounds how deep calls can nest,
82    /// together with `max_call_depth`.
83    pub stack_size: usize,
84    /// Threads that sample batches of runs at the same time. The output
85    /// doesn't depend on it.
86    pub max_threads: usize,
87    /// States of a loop solved as a Markov chain; a loop with more is
88    /// unrolled instead.
89    pub max_chain_states: usize,
90    /// Nodes of the decision diagrams of a program answered symbolically
91    /// (`Options::symbolic`).
92    pub max_nodes: usize,
93}
94
95impl Default for Limits {
96    fn default() -> Limits {
97        Limits {
98            max_integer_bits: probl_number::MAX_INTEGER_BITS,
99            max_integer_bytes: 256 * 1024 * 1024,
100            max_string_bytes: 16 * 1024 * 1024,
101            max_string_alloc_bytes: 256 * 1024 * 1024,
102            max_worlds: 10_000_000,
103            max_outcomes: 2_000_000,
104            max_collection: 10_000_000,
105            max_work: 20_000_000_000,
106            max_iterations: 10_000_000,
107            max_call_depth: 500,
108            max_cached_calls: 1_000_000,
109            max_output: 64 * 1024 * 1024,
110            stack_size: 64 * 1024 * 1024,
111            max_threads: std::thread::available_parallelism().map_or(1, |n| n.get()),
112            max_chain_states: 50_000,
113            max_nodes: 10_000_000,
114        }
115    }
116}
117
118/// How the engine runs. The defaults are what `probl run` uses, except that
119/// the CLI also supplies an execution-date snapshot.
120#[derive(Clone, Debug)]
121pub struct Options {
122    /// Execution-date snapshot, as days since 1970-01-01. The host captures
123    /// it once or supplies a replay date. The engine never reads a clock.
124    /// Required only when the program evaluates the `today` constant.
125    pub today: Option<i32>,
126    /// Merge worlds that reach the same state (turning it off is only useful
127    /// for testing that merging doesn't change results).
128    pub merge: bool,
129    /// Reuse the result of a function called again with the same inputs.
130    pub memoize: bool,
131    /// Overrides the program's `@epsilon`.
132    pub epsilon: Option<f64>,
133    /// Also print the simplest fraction near each probability.
134    pub fractions: bool,
135    pub limits: Limits,
136    /// Set to stop a running program.
137    pub cancel: Option<Arc<AtomicBool>>,
138    /// Overrides the program's `@mode`.
139    pub mode: Option<Mode>,
140    /// The program's data, loaded with [`data::load`]. A program that reads
141    /// data can't run without it.
142    pub inputs: Option<Arc<data::Inputs>>,
143    /// When sampling, update conjugate priors exactly (docs/semantics.md,
144    /// section 14). Turning it off draws every variable from its prior, for
145    /// comparing: the estimates mean the same either way.
146    pub conjugate: bool,
147    /// When enumerating, solve loops that cycle as Markov chains
148    /// (docs/semantics.md, section 10). Turning it off unrolls them, which
149    /// is only useful for checking the engine.
150    pub solve: bool,
151    /// When sampling, told after each batch how many runs are done, and how
152    /// many there are.
153    pub progress: Option<Progress>,
154    /// Overrides the program's `@on_error`. Without either, a run is total
155    /// when it enumerates and partial when it samples (docs/semantics.md,
156    /// section 11).
157    pub on_error: Option<FailureMode>,
158    /// Turns on numerical integration, to this relative tolerance, as the
159    /// program's `@integrate` does (docs/semantics.md, section 13).
160    pub integrate: Option<f64>,
161    /// Experimental: when enumerating, answer programs with decision
162    /// diagrams instead of worlds (docs/design/symbolic-inference.md).
163    pub symbolic: Symbolic,
164}
165
166/// A function told how many runs are done, and how many there are.
167#[derive(Clone)]
168pub struct Progress(pub Arc<dyn Fn(u64, u64) + Send + Sync>);
169
170impl std::fmt::Debug for Progress {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.write_str("Progress")
173    }
174}
175
176impl Default for Options {
177    fn default() -> Options {
178        Options {
179            today: None,
180            merge: true,
181            memoize: true,
182            epsilon: None,
183            fractions: false,
184            limits: Limits::default(),
185            cancel: None,
186            mode: None,
187            inputs: None,
188            conjugate: true,
189            solve: true,
190            progress: None,
191            on_error: None,
192            integrate: None,
193            symbolic: Symbolic::Off,
194        }
195    }
196}
197
198#[derive(Clone, Debug)]
199pub struct Outcome {
200    /// The execution-date snapshot supplied by the host, for reproducibility.
201    pub today: Option<i32>,
202    /// The summary line and the reports, as printed by `probl run`.
203    pub output: String,
204    pub stats: Stats,
205    /// Weight left unresolved (upper bound on the weight of cut-off worlds).
206    pub unresolved: Weight,
207    /// The probability of the evidence, if the program observed anything.
208    pub evidence: Option<Weight>,
209    /// With `@integrate`, if results came from numerical integration: its
210    /// relative tolerance. Every result of the run, and the evidence, can
211    /// then be approximate to about that tolerance.
212    pub integration: Option<f64>,
213    /// What each `report` collected, in the order of `Program::reports`.
214    pub reports: Vec<Sink>,
215    /// What each report says, as numbers: what `output` prints them from.
216    pub results: Vec<report::ReportResult>,
217    /// How the reports were rendered into `output`, to render some of them
218    /// again with [`report::render`].
219    pub format: Format,
220    /// When sampling: how.
221    pub sample: Option<Sampled>,
222    /// The data the program read, if any.
223    pub data: Vec<data::SourceInfo>,
224    /// The variables whose draws could be delayed for exact updates when
225    /// sampling, and what happened to them.
226    pub updates: Vec<(Variable, Updates)>,
227    /// What a fault did to the other worlds: the program's `@on_error`, the
228    /// host's choice, or the mode's default.
229    pub on_error: FailureMode,
230    /// In partial mode: the worlds that failed. A run with failures is a
231    /// partial result, which the reports describe only in part.
232    pub failures: Failures,
233    /// The total weight of the worlds that finished (when sampling, the
234    /// runs').
235    pub finished: Weight,
236    /// Whether an observation used a density, which makes the evidence a
237    /// density too.
238    pub densities: bool,
239}
240
241/// How a program was sampled (docs/semantics.md, section 14).
242#[derive(Clone, Debug)]
243pub struct Sampled {
244    pub runs: u64,
245    pub seed: u64,
246    /// The effective sample size: (Σ w)² / Σ w².
247    pub effective: f64,
248    /// The runs' total weight, Σ w.
249    pub weight: Weight,
250    /// Σ w².
251    pub squares: Weight,
252    /// The standard error of the evidence estimate (`Outcome::evidence`),
253    /// relative to it.
254    pub evidence_se: f64,
255    /// Whether an observation used a density, which makes the evidence a
256    /// density too.
257    pub densities: bool,
258}
259
260/// Run a program. `print` receives the output of `print(…)` as it happens.
261/// Every problem, including a crash of the engine itself, comes back as an
262/// error.
263pub fn run(
264    program: &Program,
265    options: &Options,
266    print: &mut (dyn FnMut(&str) + Send),
267) -> Result<Outcome, RuntimeError> {
268    std::thread::scope(|scope| {
269        let handle = std::thread::Builder::new()
270            .name("probl-engine".into())
271            .stack_size(options.limits.stack_size)
272            .spawn_scoped(scope, || run_here(program, options, print));
273        let handle = match handle {
274            Ok(h) => h,
275            Err(e) => {
276                return Err(internal(format!("couldn't start the engine: {e}")));
277            }
278        };
279        handle
280            .join()
281            .unwrap_or_else(|panic| Err(internal(panic_detail(&*panic))))
282    })
283}
284
285/// Run a program on the calling thread, as [`run`] does on a thread of its
286/// own. The calling thread's stack must be large enough for the program's
287/// calls (`Limits::stack_size` doesn't apply), and a panic isn't caught.
288/// With one thread allowed (`Limits::max_threads`), sampling starts no
289/// thread either, so this works where threads aren't available, such as in
290/// WebAssembly.
291pub fn run_on_this_thread(
292    program: &Program,
293    options: &Options,
294    print: &mut (dyn FnMut(&str) + Send),
295) -> Result<Outcome, RuntimeError> {
296    run_here(program, options, print)
297}
298
299/// What a panic said.
300fn panic_detail(panic: &(dyn Any + Send)) -> String {
301    panic
302        .downcast_ref::<String>()
303        .cloned()
304        .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
305        .unwrap_or_else(|| "no details".to_string())
306}
307
308fn internal(detail: String) -> RuntimeError {
309    OpError::internal("internal error: the engine crashed")
310        .at(Span::default())
311        .with_note(detail)
312}
313
314fn run_here(
315    program: &Program,
316    options: &Options,
317    print: &mut (dyn FnMut(&str) + Send),
318) -> Result<Outcome, RuntimeError> {
319    if options.today.is_some_and(|d| !dates::valid(d)) {
320        return Err(RuntimeError::new(
321            Default::default(),
322            "execution date must be within 0001-01-01..9999-12-31",
323        ));
324    }
325    let settings = &program.settings;
326    let mode = options.mode.clone().unwrap_or_else(|| settings.mode.clone());
327    let sample = match mode {
328        Mode::Auto | Mode::Enumerate => None,
329        Mode::Sample { runs, seed } => Some((runs, seed)),
330        ref other => {
331            return Err(
332                OpError::unsupported(format!("`{}` mode isn't implemented yet", other.name()))
333                    .help("enumeration and `@mode sample(runs: 10_000)` are; particles and beam search come later")
334                    .at(settings.mode_span.unwrap_or_default()),
335            );
336        }
337    };
338    if let Some((runs, _)) = sample {
339        if runs == 0 || runs > u32::MAX as u64 {
340            return Err(OpError::new(format!(
341                "sample mode needs between 1 and {} runs",
342                report::thousands(u32::MAX as i64)
343            ))
344            .at(settings.mode_span.unwrap_or_default()));
345        }
346    }
347    // Enumeration stops at the first fault by default, and sampling ends
348    // only the run that failed (section 11).
349    let on_error = options.on_error.or(settings.on_error).unwrap_or(match sample {
350        Some(_) => FailureMode::Partial,
351        None => FailureMode::Total,
352    });
353    let limits = &options.limits;
354    let config = interp::Config {
355        today: options.today,
356        epsilon: options.epsilon.unwrap_or(settings.epsilon),
357        merging: options.merge,
358        memoizing: options.memoize,
359        max_worlds: limits.max_worlds.min(settings.max_worlds),
360        max_iterations: limits.max_iterations.min(settings.max_iterations),
361        max_call_depth: limits.max_call_depth,
362        max_cached_calls: limits.max_cached_calls,
363        max_output: limits.max_output,
364        budget: Budget {
365            max_integer_bits: limits.max_integer_bits,
366            integer_bytes_left: Arc::new(AtomicU64::new(limits.max_integer_bytes)),
367            max_string_bytes: limits.max_string_bytes,
368            string_bytes_left: Arc::new(AtomicU64::new(limits.max_string_alloc_bytes)),
369            cancel: options.cancel.clone(),
370            max_outcomes: limits.max_outcomes,
371            max_collection: limits.max_collection,
372            work_left: limits.max_work,
373            shared: None,
374        },
375        cancel: options.cancel.clone(),
376        sample_seed: sample.map(|(_, seed)| seed),
377        conjugate: options.conjugate,
378        solving: options.solve,
379        max_chain_states: limits.max_chain_states,
380        partial: on_error == FailureMode::Partial,
381        integrate: options.integrate.or(settings.integrate),
382    };
383    let epsilon = config.epsilon;
384    let inputs = inputs(program, options)?;
385    if let Some((runs, seed)) = sample {
386        let live = probl_sema::analyze(program);
387        let conj = probl_sema::conjugate::analyze(program);
388        return sampled(program, &live, &conj, config, options, print, runs, seed, on_error);
389    }
390    let integrate = config.integrate;
391    if let Some(t) = integrate.filter(|t| !(*t > 0.0 && *t <= 1e-3)) {
392        return Err(RuntimeError::new(
393            Default::default(),
394            format!("integration needs a relative tolerance above 0 and at most 0.001, not {t}"),
395        ));
396    }
397    if options.symbolic != Symbolic::Off {
398        let budget = config.budget.clone();
399        match symbolic::run(program, budget, limits.max_nodes, options.cancel.clone()) {
400            Ok(answer) => {
401                let run = Enumerated {
402                    sinks: answer.sinks,
403                    stats: Stats {
404                        diagrams: Some(answer.diagrams),
405                        ..Stats::default()
406                    },
407                    finished: answer.finished,
408                    unresolved: Weight::ZERO,
409                    failures: Failures::default(),
410                    observed: answer.observed,
411                    densities: false,
412                    integrated: None,
413                    last_ruling_out: answer.last_ruling_out,
414                };
415                return enumerated(program, options, run, on_error, epsilon, integrate);
416            }
417            Err(symbolic::Fallback::Stopped(e)) => return Err(e),
418            Err(symbolic::Fallback::Unsupported(e) | symbolic::Fallback::TooLarge(e))
419                if options.symbolic == Symbolic::Only =>
420            {
421                return Err(e);
422            }
423            // Enumerate it instead.
424            Err(_) => {}
425        }
426    }
427    let live = probl_sema::analyze(program);
428    let conj = probl_sema::conjugate::analyze(program);
429    let mut engine = interp::Engine::new(program, &live, &conj, config, inputs, print);
430    let finished = engine.run_main()?;
431    let unresolved = engine.unresolved;
432    let mut failures = std::mem::take(&mut engine.failures);
433    failures.other_units = failures.mixed_densities
434        || matches!((failures.densities, engine.reported_densities), (Some(a), Some(b)) if a != b);
435    if let Some(span) = engine.density_at.filter(|_| !unresolved.is_zero()) {
436        return Err(OpError::unsupported(
437            "observing a value from a continuous distribution while some probability is unresolved isn't supported when enumerating",
438        )
439        .help("the unresolved worlds could have any density at the value, so nothing would bound the evidence; sample the model with `@mode sample(runs: 10_000)`")
440        .at(span));
441    }
442    let run = Enumerated {
443        sinks: std::mem::take(&mut engine.sinks),
444        stats: engine.stats.clone(),
445        finished,
446        unresolved,
447        failures,
448        observed: engine.observed,
449        densities: engine.densities,
450        integrated: engine.integrated,
451        last_ruling_out: engine.last_ruling_out,
452    };
453    enumerated(program, options, run, on_error, epsilon, integrate)
454}
455
456/// What enumerating a program found, to make its outcome from.
457struct Enumerated {
458    sinks: Vec<Sink>,
459    stats: Stats,
460    /// The weight of the worlds that finished.
461    finished: Weight,
462    unresolved: Weight,
463    failures: Failures,
464    /// Whether an `observe` was reached.
465    observed: bool,
466    /// Whether an observation used a density.
467    densities: bool,
468    /// If numerical integration computed the worlds' weights: its tolerance.
469    integrated: Option<f64>,
470    /// The last `observe` that ruled out a world.
471    last_ruling_out: Option<Span>,
472}
473
474/// The outcome of an enumerated run: its summary line, its reports' results
475/// and their text.
476fn enumerated(
477    program: &Program,
478    options: &Options,
479    run: Enumerated,
480    on_error: FailureMode,
481    epsilon: f64,
482    integrate: Option<f64>,
483) -> Result<Outcome, RuntimeError> {
484    let Enumerated {
485        sinks,
486        stats,
487        finished,
488        unresolved,
489        failures,
490        observed,
491        densities,
492        integrated,
493        last_ruling_out,
494    } = run;
495    if observed && finished.is_zero() && failures.is_empty() {
496        let span = last_ruling_out.unwrap_or_default();
497        return Err(if unresolved.is_zero() {
498            RuntimeError::new(
499                span,
500                "the evidence is impossible: every world was ruled out by `observe`",
501            )
502            .with_help("check the observations; there's no answer to condition on")
503        } else {
504            RuntimeError::new(span, "every world that fits the evidence was left unresolved")
505                .with_help("lower `@epsilon` so that loops run longer")
506        });
507    }
508
509    // Reach describes control flow; unresolved weight is shown separately.
510    // Failed worlds count in it when their weight can be compared with the
511    // finished worlds' (section 11).
512    let comparable = failures.comparable();
513    let total = if comparable {
514        finished + failures.weight
515    } else {
516        finished
517    };
518    let format = Format {
519        fractions: options.fractions,
520        weighted: program.main().effects.observes,
521        unresolved,
522        program_total: total,
523        run_squares: None,
524        reach_known: comparable,
525        integrate,
526    };
527    let plain = Format {
528        fractions: false,
529        ..format
530    };
531    let results = report::results(&program.reports, &sinks, format, unresolved);
532    // What a numerical integration computed, if anything: the worlds'
533    // weights, or a mean or spread without a formula.
534    let integration = integrated.or_else(|| {
535        let numeric = |g: &report::GroupResult| g.numeric.as_ref().is_some_and(|n| n.integrated);
536        results
537            .iter()
538            .flat_map(|r| &r.groups)
539            .any(|g| numeric(g) || g.fields.iter().flat_map(|f| &f.0).any(|(_, g)| numeric(g)))
540            .then_some(integrate)
541            .flatten()
542    });
543    let mut header = String::from("enumerated");
544    if let Some(tolerance) = integration {
545        header.push_str(&format!(" · integrated to {tolerance:e}"));
546    }
547    if !failures.is_empty() {
548        header.push_str(" · partial result");
549        if comparable && !total.is_zero() {
550            header.push_str(&format!(
551                " · {} failed",
552                report::pct(failures.weight.ratio(total), plain)
553            ));
554        }
555    }
556    // With failures before later evidence, only the finished worlds'
557    // contribution to the evidence is known.
558    let evidence = observed.then_some(total);
559    let of = if comparable { "" } else { " of the finished worlds" };
560    if let Some(z) = evidence {
561        let (lo, hi) = (total.to_f64(), (total + unresolved).to_f64());
562        if densities {
563            header.push_str(&format!(" · log evidence{of} {:.4}", z.ln()));
564        } else if hi - lo >= 0.00005 {
565            header.push_str(&format!(
566                " · evidence{of} {}–{}",
567                report::pct(lo, plain),
568                report::pct(hi, plain)
569            ));
570        } else if z.to_f64() < 0.0001 {
571            header.push_str(&format!(" · evidence{of} {}", scientific(z)));
572        } else {
573            header.push_str(&format!(" · evidence{of} {}", report::pct(z.to_f64(), plain)));
574        }
575    }
576    if !unresolved.is_zero() {
577        if unresolved.to_f64() <= epsilon {
578            header.push_str(&format!(" · unresolved < {epsilon:e}"));
579        } else {
580            header.push_str(&format!(" · unresolved {:.1e}", unresolved.to_f64()));
581        }
582    }
583    let body = report::render_results(&program.reports, &results, format);
584    let output = if body.is_empty() {
585        header
586    } else {
587        format!("{header}\n\n{body}")
588    };
589    Ok(Outcome {
590        today: options.today,
591        output,
592        stats,
593        unresolved,
594        evidence,
595        integration,
596        reports: sinks,
597        results,
598        format,
599        sample: None,
600        data: sources(options),
601        updates: Vec::new(),
602        on_error,
603        failures,
604        finished,
605        densities,
606    })
607}
608
609/// A weight too small for a percentage, in scientific notation: `2.9e-25`.
610fn scientific(w: Weight) -> String {
611    let l = w.log10();
612    let exponent = l.floor();
613    let mantissa = libm::pow(10.0, l - exponent);
614    // Rounding the mantissa can make it 10.0.
615    let (mantissa, exponent) = if format!("{mantissa:.2}") == "10.00" {
616        (1.0, exponent + 1.0)
617    } else {
618        (mantissa, exponent)
619    };
620    format!("{mantissa:.2}e{exponent}")
621}
622
623/// The sampled estimate of the evidence, for the summary line (section 14):
624/// a probability, or the logarithm of a density.
625fn evidence_estimate(z: Weight, relative_se: f64, densities: bool) -> String {
626    let known = relative_se.is_finite();
627    if densities {
628        let ln = z.log10() * std::f64::consts::LN_10;
629        if !known {
630            return format!("log evidence {ln:.2}");
631        }
632        let decimals = if relative_se > 0.0 {
633            (-libm::log10(relative_se).floor()).clamp(2.0, 6.0) as usize
634        } else {
635            2
636        };
637        return format!("log evidence {ln:.decimals$} ± {relative_se:.decimals$}");
638    }
639    let x = z.to_f64();
640    if x >= 1e-4 {
641        return match known {
642            true => format!("evidence {}", report::estimate(x, x * relative_se)),
643            false => format!("evidence {}", value::fmt_prob(x)),
644        };
645    }
646    if !known {
647        return format!("evidence {}", scientific(z));
648    }
649    let pct = relative_se * 100.0;
650    let decimals = if pct >= 10.0 {
651        0
652    } else if pct >= 1.0 {
653        1
654    } else {
655        2
656    };
657    format!("evidence {} (± {pct:.decimals$}%)", scientific(z))
658}
659
660/// The values of the program's inputs: loaded, and loaded for it.
661fn inputs<'a>(program: &Program, options: &'a Options) -> Result<&'a [Value], RuntimeError> {
662    let Some(first) = program.inputs.first() else {
663        return Ok(&[]);
664    };
665    match &options.inputs {
666        Some(inputs) if inputs.fit(program) => {
667            if inputs.max_string_bytes() > options.limits.max_string_bytes {
668                return Err(RuntimeError::limit(
669                    first.span,
670                    "an input string exceeds the runtime string size limit",
671                ));
672            }
673            if inputs.max_integer_bits() > options.limits.max_integer_bits {
674                return Err(RuntimeError::limit(
675                    first.span,
676                    "an input integer exceeds the runtime integer size limit",
677                ));
678            }
679            Ok(inputs.values())
680        }
681        Some(_) => Err(internal("the data was loaded for a different program".to_string())),
682        None => Err(
683            RuntimeError::new(first.span, "the program reads data, which wasn't loaded")
684                .with_help("load it with `probl_engine::data::load` before running the program"),
685        ),
686    }
687}
688
689fn sources(options: &Options) -> Vec<data::SourceInfo> {
690    options.inputs.as_ref().map_or_else(Vec::new, |i| i.sources().to_vec())
691}
692
693/// What the batches produced so far, added up in batch order.
694struct Combined {
695    sinks: Vec<Sink>,
696    totals: interp::SampleTotals,
697    observed: bool,
698    densities: bool,
699    unresolved: Weight,
700    last_ruling_out: Option<Span>,
701    stats: Stats,
702    failures: Failures,
703}
704
705impl Combined {
706    fn new(reports: usize) -> Combined {
707        Combined {
708            sinks: vec![Sink::default(); reports],
709            totals: interp::SampleTotals {
710                weight: Weight::ZERO,
711                squares: Weight::ZERO,
712            },
713            observed: false,
714            densities: false,
715            unresolved: Weight::ZERO,
716            last_ruling_out: None,
717            stats: Stats::default(),
718            failures: Failures::default(),
719        }
720    }
721
722    fn absorb(&mut self, batch: interp::Batch) {
723        for (sink, theirs) in self.sinks.iter_mut().zip(batch.sinks) {
724            sink.absorb(theirs);
725        }
726        self.totals.weight += batch.totals.weight;
727        self.totals.squares += batch.totals.squares;
728        self.observed |= batch.observed;
729        self.densities |= batch.densities;
730        self.unresolved += batch.unresolved;
731        self.last_ruling_out = batch.last_ruling_out.or(self.last_ruling_out);
732        self.stats.absorb(&batch.stats);
733        self.failures.append(batch.failures);
734    }
735}
736
737/// A batch's result as a worker sends it: its number, what it produced (or
738/// its error), and what it printed.
739type Sent = (u64, Result<interp::Batch, RuntimeError>, interp::Printed);
740
741/// Run the batches of a sampled program on up to `max_threads` threads, and
742/// combine them in batch order: what's printed, the first error, and every
743/// sum come out the same whatever the number of threads. The threads share
744/// one budget of work.
745#[allow(clippy::too_many_arguments)]
746fn run_batches(
747    program: &Program,
748    live: &Liveness,
749    conj: &Conjugacy,
750    config: &interp::Config,
751    options: &Options,
752    print: &mut (dyn FnMut(&str) + Send),
753    runs: u64,
754    seed: u64,
755) -> Result<Combined, RuntimeError> {
756    let batches = runs.div_ceil(interp::BATCH);
757    let threads = (options.limits.max_threads.max(1) as u64).min(batches);
758    let inputs = inputs(program, options)?;
759    // How far past the batch being combined the threads may go: it bounds
760    // the finished batches waiting in memory.
761    let ahead = 2 * threads;
762    let mut config = config.clone();
763    config.budget.shared = Some(Arc::new(AtomicU64::new(config.budget.work_left)));
764    config.budget.work_left = 0;
765    let config = &config;
766    if threads == 1 {
767        return run_batches_here(program, live, conj, config, inputs, options, print, runs, seed);
768    }
769
770    let next = AtomicU64::new(0);
771    // The lowest batch that failed: no batch after it needs to run.
772    let failed = AtomicU64::new(u64::MAX);
773    // Bytes printed so far, in batch order.
774    let printed = AtomicUsize::new(0);
775    // Batches combined so far, which the threads wait for when too far ahead.
776    let combined_upto = Mutex::new(0u64);
777    let caught_up = Condvar::new();
778    let stop = |error: RuntimeError| {
779        // With the lock held, so that no thread misses the wake-up.
780        let _upto = combined_upto.lock().unwrap_or_else(PoisonError::into_inner);
781        failed.store(0, Ordering::Relaxed);
782        caught_up.notify_all();
783        Err(error)
784    };
785    let (tx, rx) = mpsc::channel::<Sent>();
786    std::thread::scope(|scope| {
787        for t in 0..threads {
788            let tx = tx.clone();
789            let (next, failed, printed) = (&next, &failed, &printed);
790            let (combined_upto, caught_up) = (&combined_upto, &caught_up);
791            let worker = move || {
792                let mut ignore = |_: &str| {};
793                // The data, copied: threads sharing it would all touch its
794                // reference counts, and slow each other down.
795                let copied: Vec<Value> = inputs.iter().map(Value::unshared).collect();
796                let mut engine = interp::Engine::new(program, live, conj, config.clone(), &copied, &mut ignore);
797                loop {
798                    let index = next.fetch_add(1, Ordering::Relaxed);
799                    if index >= batches {
800                        break;
801                    }
802                    let mut upto = combined_upto.lock().unwrap_or_else(PoisonError::into_inner);
803                    while index >= *upto + ahead && index <= failed.load(Ordering::Relaxed) {
804                        upto = caught_up.wait(upto).unwrap_or_else(PoisonError::into_inner);
805                    }
806                    drop(upto);
807                    if index > failed.load(Ordering::Relaxed) {
808                        break;
809                    }
810                    let first = index * interp::BATCH;
811                    let n = (runs - first).min(interp::BATCH);
812                    let before = printed.load(Ordering::Relaxed);
813                    let (result, lines) = std::panic::catch_unwind(AssertUnwindSafe(|| {
814                        engine.run_batch(Rng::stream(seed, index), first, n, before)
815                    }))
816                    .unwrap_or_else(|panic| (Err(internal(panic_detail(&*panic))), Vec::new()));
817                    let stopped = result.is_err();
818                    if stopped {
819                        failed.fetch_min(index, Ordering::Relaxed);
820                    }
821                    if tx.send((index, result, lines)).is_err() || stopped {
822                        break;
823                    }
824                }
825            };
826            let spawned = std::thread::Builder::new()
827                .name("probl-sampler".into())
828                .stack_size(options.limits.stack_size)
829                .spawn_scoped(scope, worker);
830            if let Err(e) = spawned {
831                // The threads already started can do the work.
832                if t == 0 {
833                    return Err(internal(format!("couldn't start a sampling thread: {e}")));
834                }
835                break;
836            }
837        }
838        drop(tx);
839
840        let mut combined = Combined::new(program.reports.len());
841        let mut pending = BTreeMap::new();
842        let mut bytes = 0;
843        for index in 0..batches {
844            let (result, lines) = loop {
845                if let Some(sent) = pending.remove(&index) {
846                    break sent;
847                }
848                match rx.recv() {
849                    Ok((i, result, lines)) => {
850                        pending.insert(i, (result, lines));
851                    }
852                    Err(_) => return stop(internal("a sampling thread stopped before its batch was done".into())),
853                }
854            };
855            for (span, line) in lines {
856                bytes += line.len() + 1;
857                if bytes > options.limits.max_output {
858                    return stop(interp::too_much_output(span));
859                }
860                print(&line);
861            }
862            printed.store(bytes, Ordering::Relaxed);
863            match result {
864                Ok(batch) => combined.absorb(batch),
865                Err(e) => return stop(e),
866            }
867            if let Some(progress) = &options.progress {
868                (progress.0)(((index + 1) * interp::BATCH).min(runs), runs);
869            }
870            *combined_upto.lock().unwrap_or_else(PoisonError::into_inner) = index + 1;
871            caught_up.notify_all();
872        }
873        Ok(combined)
874    })
875}
876
877/// Run the batches one after another on the calling thread, starting no
878/// thread: the same output as on several threads.
879#[allow(clippy::too_many_arguments)]
880fn run_batches_here(
881    program: &Program,
882    live: &Liveness,
883    conj: &Conjugacy,
884    config: &interp::Config,
885    inputs: &[Value],
886    options: &Options,
887    print: &mut (dyn FnMut(&str) + Send),
888    runs: u64,
889    seed: u64,
890) -> Result<Combined, RuntimeError> {
891    let mut ignore = |_: &str| {};
892    let mut engine = interp::Engine::new(program, live, conj, config.clone(), inputs, &mut ignore);
893    let mut combined = Combined::new(program.reports.len());
894    let mut bytes = 0;
895    for index in 0..runs.div_ceil(interp::BATCH) {
896        let first = index * interp::BATCH;
897        let n = (runs - first).min(interp::BATCH);
898        let (result, lines) = engine.run_batch(Rng::stream(seed, index), first, n, bytes);
899        for (span, line) in lines {
900            bytes += line.len() + 1;
901            if bytes > options.limits.max_output {
902                return Err(interp::too_much_output(span));
903            }
904            print(&line);
905        }
906        combined.absorb(result?);
907        if let Some(progress) = &options.progress {
908            (progress.0)(first + n, runs);
909        }
910    }
911    Ok(combined)
912}
913
914/// Sample the program and print its estimates (docs/semantics.md, section 14).
915#[allow(clippy::too_many_arguments)]
916fn sampled(
917    program: &Program,
918    live: &Liveness,
919    conj: &Conjugacy,
920    config: interp::Config,
921    options: &Options,
922    print: &mut (dyn FnMut(&str) + Send),
923    runs: u64,
924    seed: u64,
925    on_error: FailureMode,
926) -> Result<Outcome, RuntimeError> {
927    let mut engine = run_batches(program, live, conj, &config, options, print, runs, seed)?;
928    let totals = engine.totals;
929    let failures = std::mem::take(&mut engine.failures);
930    if totals.weight.is_zero() && failures.is_empty() {
931        let span = engine.last_ruling_out.unwrap_or_default();
932        return Err(RuntimeError::new(span, "every run was ruled out by `observe`")
933            .with_note(format!("{} runs were tried", report::thousands(runs as i64)))
934            .with_help(
935                "the evidence may be impossible, or too unlikely for this many runs: enumerate the model, or add runs",
936            ));
937    }
938    let effective = (totals.weight * totals.weight).ratio(totals.squares);
939    let mut header = format!("sample · {} runs · seed {seed}", report::thousands(runs as i64));
940    if !failures.is_empty() {
941        let failed = match failures.runs {
942            1 => "1 run".to_string(),
943            n => format!("{} runs", report::thousands(n as i64)),
944        };
945        header.push_str(&format!(" · partial result · {failed} failed"));
946    }
947    // Failed runs count in the evidence and reach when their weight can be
948    // compared with the finished runs' (section 11).
949    let comparable = failures.comparable();
950    let (total, total_squares) = match comparable {
951        true => (totals.weight + failures.weight, totals.squares + failures.squares),
952        false => (totals.weight, totals.squares),
953    };
954    // The evidence: the average final weight, and its standard error
955    // relative to it (section 14).
956    let evidence = total.scale(1.0 / runs as f64);
957    let n = runs as f64;
958    let evidence_se = if runs > 1 {
959        let effective = (total * total).ratio(total_squares);
960        ((n / effective - 1.0).max(0.0) / (n - 1.0)).sqrt()
961    } else {
962        f64::NAN
963    };
964    if engine.observed {
965        let of = if comparable { "" } else { " of the finished runs" };
966        header.push_str(&format!(
967            " · {}{of}",
968            evidence_estimate(evidence, evidence_se, engine.densities)
969        ));
970        header.push_str(&format!(
971            " · effective sample size {}",
972            report::thousands(effective.round() as i64)
973        ));
974    }
975    let unresolved = engine.unresolved;
976    if !unresolved.is_zero() {
977        header.push_str(&format!(
978            " · unresolved {:.1e}",
979            unresolved.ratio(totals.weight + unresolved)
980        ));
981    }
982    let format = Format {
983        fractions: false,
984        unresolved: Weight::ZERO,
985        program_total: total,
986        run_squares: Some(total_squares),
987        weighted: program.main().effects.observes,
988        reach_known: comparable,
989        integrate: None,
990    };
991    // The report lines don't show unresolved weight when sampling (the
992    // summary line does), but the results still say what's incomplete.
993    let results = report::results(&program.reports, &engine.sinks, format, unresolved);
994    let body = report::render_results(&program.reports, &results, format);
995    let output = if body.is_empty() {
996        header
997    } else {
998        format!("{header}\n\n{body}")
999    };
1000    Ok(Outcome {
1001        today: options.today,
1002        output,
1003        stats: engine.stats.clone(),
1004        unresolved,
1005        evidence: engine.observed.then_some(evidence),
1006        integration: None,
1007        densities: engine.densities,
1008        reports: std::mem::take(&mut engine.sinks),
1009        results,
1010        format,
1011        sample: Some(Sampled {
1012            runs,
1013            seed,
1014            effective,
1015            weight: totals.weight,
1016            squares: totals.squares,
1017            evidence_se,
1018            densities: engine.densities,
1019        }),
1020        data: sources(options),
1021        updates: conj
1022            .variables
1023            .iter()
1024            .enumerate()
1025            .map(|(i, v)| (v.clone(), engine.stats.updates.get(i).copied().unwrap_or_default()))
1026            .collect(),
1027        on_error,
1028        failures,
1029        finished: totals.weight,
1030    })
1031}