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