anapao
anapao is a library-only deterministic Rust testing utility for simulation and stochastic workflows. It is intended to be used from Rust tests and tooling through the crate API, not as a command-line program.
This README is a linear tutorial for new users: you will build one scenario, run it deterministically, add expectations, run Monte Carlo batches, and persist CI-friendly artifacts.
The README and generated crate documentation are self-contained public documentation. Any ignored local docs/ directory is reserved for private research notes and is not tracked, packaged, shipped, or required to use the crate.
What You Will Build
By the end, you will have a repeatable testing flow that can:
- load a stable
ScenarioSpecdocument or author an immutable checkedScenario, - compile either representation into the same opaque executable model,
- execute seeded deterministic single runs,
- execute deterministic Monte Carlo batches,
- evaluate typed assertions with evidence,
- persist artifact packs (
manifest.json,events.jsonl,series.csv, and more).
Prerequisites
- Rust
1.85+ - Cargo
- A Rust test project where you want deterministic simulation checks
Add the library dependency:
[]
= "0.1.0"
The crate does not install or expose a binary target; import anapao from your Rust code.
Scenario Representations and the Validation Boundary
Anapao has four deliberately distinct stages:
ScenarioSpecis the stable serde wire DTO used to load, inspect, edit, and store documents.Scenario::try_fromchecks a DTO and produces an immutable semantic domain value.ScenarioBuilderand theScenarioNode/ScenarioEdgefamily constructors author that checked domain directly from Rust.Simulator::compile(legacy DTO input) orSimulator::compile_checked(checked input) produces an opaqueCompiledScenario, whichSimulator::runexecutes.
Checked types are not a second serde representation. Deserialize the stable DTO first:
use ;
let document = to_string.unwrap;
let dto: ScenarioSpec = from_str.unwrap;
let checked = try_from.unwrap;
assert_eq!;
For programmatic authoring, use the complete checked builder. Its consuming insertion methods
return Result because duplicate IDs are rejected:
use NonZeroU64;
use ;
use Simulator;
let source = fixture;
let pool = fixture;
let sink = fixture;
let scenario = new
.with_title
.with_description
.with_tag
.with_node?
.with_node?
.with_node?
.with_edge?
.with_edge?
.with_edge?
.with_end_condition
.with_tracked_metric
.with_metadata
.build?;
let compiled = compile_checked?;
assert_eq!;
let report = run.unwrap;
assert!;
# Ok::
The common anapao::prelude exports the checked scenario entrypoints. Individual family config
types remain available from anapao::types when their defaults need customization.
Declarative scenario! Authoring
anapao::scenario! is the concise route to the same checked Scenario authored by
ScenarioBuilder; it is not another serde format or validation path. The deliberate macro set
contains exactly one macro: scenario!. There are no expectations!, assertion, config, or
report macros. Future assertion ergonomics should use normal associated functions, with
#[track_caller] where call-site diagnostics benefit from it.
The exact queue-flow intake example is executable unchanged:
let scenario = scenario! ?;
# let _ = scenario;
# Ok::
A complete authoring shape can use every scenario-level section plus native and typed escape
forms. Here, config, transfer, connection, and condition pass existing checked values
through unchanged.
use ;
let typed_pool = default.with_capacity;
let typed_transfer = Remaining;
let typed_state = default;
let typed_end = MaxSteps ;
let typed_variables = default;
let scenario: Scenario = scenario! ?;
# let _ = scenario;
# Ok::
The canonical section order is id; optional title, description, tags, variables, and
metadata; required nodes and edges; then optional track and repeated end statements.
Use semicolons between sections and declarations; lists and blocks accept trailing separators.
Native node config/mode fields and config: ... are mutually exclusive. Consult the
scenario! rustdoc for the complete
grammar and each node, transfer, connection, state-target, and end-condition family.
Node and edge symbols turn into IDs from their exact spelling, while retaining separate node and
edge namespaces. A tracked metric is backed by its node symbol. State targets may name an edge
that is declared later. The macro deliberately delegates unknown references, duplicate symbols,
and graph semantics to ScenarioBuilder, preserving its established error diagnostics.
scenario! returns Result<Scenario, SetupError>. Propagate setup failures with ?, or handle
them explicitly:
use SetupError;
let result = scenario! ;
match result
# Ok::
Macro expressions are evaluated once. Expansion uses $crate and absolute standard-library paths,
so it is hygienic with caller names, imports, and Cargo dependency renames. It introduces no
panic path; only the checked builder performs semantic validation. In public 0.2, the grammar,
symbol mapping, evaluation count, result/error types, and root (anapao::scenario!) and wildcard
prelude (use anapao::prelude::*; scenario!) paths are SemVer promises. Keep
ScenarioBuilder for direct checked Rust authoring, and keep the ScenarioSpec load followed by
Scenario::try_from route for stable serde documents.
Step 1: Create ScenarioSpec
ScenarioSpec is your declarative model: nodes, edges, end conditions, and tracked metrics.
Snippet S01 — Build a Minimal Scenario
use ;
let mut scenario = source_sink
.with_end_condition;
scenario.tracked_metrics.insert;
assert_eq!;
assert_eq!;
What you learned:
- how to bootstrap a minimum source->sink scenario with a convenience constructor,
- how end conditions and tracked metrics are attached.
Step 2: Compile with Simulator::compile
Compilation validates and transforms your scenario into deterministic execution indexes.
Snippet S02 — Compile a Scenario
use ;
use Simulator;
let scenario = source_sink
.with_end_condition;
let compiled = compile.unwrap;
assert_eq!;
What you learned:
- compilation is explicit and deterministic,
- you should compile once and reuse the compiled form for runs.
0.2 API Migration
CompiledScenario is now an opaque, immutable execution product. Use the root-level
Simulator facade instead of the old raw compiler/engine/batch paths:
let scenario = fixture_scenario;
let run_config = deterministic_run_config;
// Before: anapao::validation::compile_scenario(&scenario)
// After:
let compiled = compile.unwrap;
// Before: anapao::engine::run_single(&compiled, &run_config)
// After:
let report = run.unwrap;
// Before: compiled.scenario.id / compiled.node_order / compiled.edge_order
// After: compiled.scenario_id() / compiled.node_ids() / compiled.edge_ids()
For checked conversion in generic code, use let compiled: anapao::CompiledScenario = scenario.try_into()?;. Read inspection data through scenario_id(), source_spec(),
node_ids(), edge_ids(), node_count(), and edge_count(); raw execution modules are private.
The legacy DTO route remains supported and its with_node/with_edge helpers keep
last-write-wins replacement semantics. The checked ScenarioBuilder instead returns a stable
error for duplicate node or edge IDs and retains the first definition.
Version 0.2 intentionally rejects semantic combinations that older execution paths could repair or reinterpret:
- a node or edge map key that differs from the embedded
id; - an explicit node-family tag paired with another family's config payload;
- a resource/state connection tag paired with an active payload for the other connection kind;
- a node state target carrying a target connection ID; and
- a resource-connection, state-connection, or formula target missing its required target ID.
These checks happen after serde parsing. Raw JSON lexical duplicate keys are not detected at this boundary, and no stored-data backfill or second checked serde format is introduced.
Step 3: Configure RunConfig
RunConfig controls deterministic single-run execution (seed, max_steps, capture policy).
Snippet S03 — Create a Deterministic RunConfig
use ;
let run = for_seed.with_max_steps.with_capture;
assert_eq!;
assert_eq!;
assert!;
What you learned:
- seeds pin determinism,
- capture configuration controls diagnostic trace granularity.
Retention, Events, and Aggregation Are Separate
CaptureConfig controls diagnostic report retention, not whether the simulation completes.
CaptureConfig::none() leaves RunReport::final_node_values and RunReport::final_metrics
available, while intentionally leaving node snapshots, variable snapshots, transfer records, and
metric series empty. Use CaptureConfig::final_only() when final step-aligned diagnostics are
useful without retaining transfers, or CaptureSchedule::Every with typed Selection values for
periodic/selective diagnostics.
Live streamed events are independent of report retention: Simulator::run_with_sink and the
assertion-streaming APIs emit the same ordered simulation events when capture is none() as they
do with default capture. Batch aggregate sampling is separate again: AggregationConfig controls
only the metric series in BatchReport, while every BatchRunSummary retains terminal metrics.
Consequently, final-value assertions work with no captured series. Step selectors, monotonic-series assertions, and series probability assertions require captured or aggregated series evidence; when it was not requested, they report missing evidence instead of inferring it.
Batch aggregation is separate from per-run diagnostic capture. Configure only the
metric schedule and selection that belong in the BatchReport:
use ;
let batch = for_runs
.with_execution_mode
.with_aggregation;
assert!;
Step 4: Execute a Deterministic Single Run
Now run one deterministic simulation and assert expected outputs.
Snippet S04 — Run Once and Verify Outputs
use ;
use MetricKey;
let compiled = compile.unwrap;
let report = run.unwrap;
assert!;
assert_eq!;
assert_eq!;
What you learned:
- deterministic single-run output can be asserted directly in tests.
Step 5: Create an Expectation Set
Expectation provides typed assertion semantics for run and batch reports.
Snippet S05 — Declare Expectations
use ;
use MetricKey;
let metric = fixture;
let expectations = vec!;
assert_eq!;
What you learned:
- expectations are data, not ad-hoc assertion code,
- final selectors read always-retained terminal metrics, while specific-step selectors require captured series evidence.
Step 6: Run with Assertions and Event Sink
Use the integrated assertion path and capture ordered events for diagnostics.
Snippet S06 — run_with_assertions_and_sink + VecEventSink
use ;
use VecEventSink;
use MetricKey;
use ;
let compiled = compile.unwrap;
let expectations = vec!;
let mut sink = new;
let = run_with_assertions_and_sink
.unwrap;
assert!;
assert!;
What you learned:
- assertions and execution can be done in one call,
- event streams provide structured debugging context.
Step 7: Configure BatchConfig
BatchConfig controls deterministic Monte Carlo execution.
Snippet S07 — Create BatchConfig
use ;
let batch = for_runs
.with_execution_mode
.with_base_seed
.with_run_template
.with_max_steps;
assert_eq!;
assert_eq!;
assert_eq!;
What you learned:
runsscales the Monte Carlo sample size,base_seed+ run index derivation preserve reproducibility.
Step 8: Execute a Deterministic Batch Run
Run many deterministic simulations and check aggregate outputs.
Snippet S08 — Run Batch and Verify Ordering/Aggregates
use ;
use MetricKey;
let compiled = compile.unwrap;
let batch = run_batch.unwrap;
assert_eq!;
assert!;
assert!;
What you learned:
- batch summaries are deterministic and index-ordered.
completed_runscounts reported run summaries; inspect eachrun.completedfor semantic completion.
Step 9: Persist Artifacts and Inspect ManifestRef
Persist reports for CI diffing and post-run diagnostics.
Snippet S09 — Full Playbook (Setup -> Run -> Assert -> Artifacts)
use write_run_artifacts_with_assertions;
use ;
use VecEventSink;
use MetricKey;
use ;
let compiled = compile.unwrap;
let expectations = vec!;
let mut sink = new;
let = run_with_assertions_and_sink
.unwrap;
assert!;
assert!;
let output_dir = temp_dir.join;
let manifest = write_run_artifacts_with_assertions
.unwrap;
assert!;
assert!;
assert!;
What you learned:
- persisted artifacts become your CI and debugging contract,
- manifest keys are stable assertions for artifact expectations.
Artifact file ownership does not change when diagnostics are disabled. Where a run or batch writer
is invoked, its manifest-owned variables.csv and series.csv files remain valid header-only CSVs
when no variable snapshots or series were retained. Supplied events still produce events.jsonl
and drive the history/replay indexes.
Step 10: Fixture-First Testing with testkit (and rstest)
Use testkit helpers to avoid duplicating setup across tests.
Snippet S10 — Reusable Fixture-Style Test Pattern
use ;
use MetricKey;
deterministic_fixture_smoke;
What you learned:
- fixture helpers keep tests concise and deterministic,
- you can wrap these helpers in your own
rstestfixture macros for larger matrices.
Common Failure Modes and Debugging Hints
- Missing tracked metric:
- symptom: expectation fails with missing observed value.
- fix: ensure metric key is in
scenario.tracked_metrics.
- Non-terminating scenarios:
- symptom: run ends at
max_stepsunexpectedly. - fix: verify
end_conditionsare configured and reachable.
- symptom: run ends at
- Seed confusion:
- symptom: output differs between runs.
- fix: pin
RunConfig.seedfor single runs and keep batchbase_seedstable (batch seeds derive frombase_seed+ run index).
- Sparse traces:
- symptom: insufficient snapshots for diagnostics.
- fix: use
CaptureConfig::final_only()or adjustRunConfig.capturewithCaptureSchedule::Every.
Feature Flags
parallel: enables Rayon-backed batch execution mode (ExecutionMode::Rayon).analysis-polars: enables Polars DataFrame shaping helpers.assertions-extended: enables extra assertion/snapshot/property helper crates.
CI intentionally validates a targeted feature surface instead of an exhaustive feature
combination matrix. The supported check surface is the default feature set, each
individual optional feature (parallel, analysis-polars, and
assertions-extended), and the combined --all-features build.
Module Surface (Reference)
anapao exports:
typeserrorrngstochasticeventsstatsartifactassertionstestkitanalysis(only withanalysis-polars)Simulator(compile/run/batch facade)
Validation Commands
Performance Workflow (Manual Compare)
# capture matching default and parallel Criterion baselines (these runs can take time)
# compare matrix
# manual non-failing regression summary (+7% threshold)
# run isolated DHAT capture-retention evidence in separate processes
# flamegraphs and csv summaries
BENCH_FEATURES=parallel
Dependency and Security Maintenance
CI runs cargo audit --deny warnings on every push and pull request to report RustSec advisories and dependency problems from Cargo.lock. Treat a failing audit as a release blocker unless the advisory is not reachable for this crate; if an advisory is not actionable immediately, document the reason and the planned follow-up in the pull request.
When updating dependencies:
- Prefer the smallest compatible version bump that resolves the advisory or maintenance need.
- Review changelogs for public API, MSRV, feature, and license changes before merging.
- Keep optional feature dependencies (
parallel,analysis-polars, andassertions-extended) checked with the normal CI matrix instead of adding one-off release automation. - Regenerate and commit
Cargo.lock, then runcargo audit --deny warningsplus the standard repository validation commands.
0.2 Capture Policy Migration
Rust configuration fields are intentionally no longer a stable struct-literal surface. Construct
policies with CaptureConfig::{none, final_only, default} and consuming builders such as
with_schedule, with_metrics, and with_variables; configure batch aggregate sampling with
AggregationConfig and BatchConfig::with_aggregation. CaptureConfig::disabled() and batch
with_capture adapters are deprecated compatibility spellings, not recommended examples.
Persisted JSON remains migration-friendly: anapao reads the historical five-field capture object
(and historical nested BatchRunTemplate.capture) with its old behavior, rejects a zero legacy
stride, and writes only the canonical tagged typed representation. New JSON should use the current
schedule, channel selections, and batch aggregation fields.
Local Pre-commit
This repo ships a native prek.toml for fast local commit gates.
The hooks intentionally stay lightweight: cargo fmt --all -- --check and cargo clippy --all-targets --all-features -- -D warnings.