Models, Components and Experiments

Model declarations

Use cimba.sim for modeling. A model is a Model subclass whose annotated fields are typed by their simulation role:

Model, Component, Experiment, Env, Handle, Param, Output, State, FloatState, Const, Queue, Resource, Pool, Store, Dataset, Condition, Predicate, Event, Processes, PQueues, Ref, Refs, Struct, Trace, capacity(), count(), process(), predicate(), event(), collect(), function().

sim.Param values are expanded into parameter combinations, sim.Output values are collected after each trial, sim.State and sim.FloatState hold mutable trial-local state, and entities such as sim.Queue, sim.Resource, sim.Pool, sim.Store, sim.PQueues, sim.Condition, and sim.Dataset are created for each trial.

A parameter may declare a scalar default with normal Python syntax, mean_service: sim.Param = 0.25. Omitted experiment arguments use that value; explicit scalar or swept values override it. Parameters without defaults remain required, and model.param_defaults reports defaults by flattened field name.

import cimba.sim as sim

class Clinic(sim.Model):
    arrival_rate: sim.Param
    wait_time: sim.Output
    queue: sim.Queue
    doctor: sim.Resource
    waits: sim.Dataset

model = Clinic("clinic")

Breaking change: callbacks live on the class

Model callbacks are class declarations. The former instance-bound decorators have been removed without compatibility aliases. Move each callback into the sim.Model subclass and use the exported marker:

# Before (no longer supported)
model = Clinic("clinic")

@model.process
def arrivals(self: Clinic):
    ...
# Now
class Clinic(sim.Model):
    @sim.process
    def arrivals(self: "Clinic"):
        ...

model = Clinic("clinic")

The same change applies to collect, predicate, and event. If a callback publishes into a declared sim.Processes, sim.Predicate, or sim.Event field, give the callback a distinct method name and bind it with field="field_name". Behavioral variants should be subclasses; direct sim.Model(...) construction is supported only for callback-free models.

Components

Components group related declarations and process methods. Methods decorated with top-level @sim.process are lowered into ordinary model processes at model construction, and model callbacks use self as the root trial environment; they can read component fields with self.retailer.orders. Component fields are exposed in experiments with flattened names such as retailer__orders. Methods decorated with top-level @sim.collect run once per instance at the end of each trial, before the model-level @sim.collect callback, typically assigning the component’s sim.Output fields.

Read-only synchronous behavior is declared with top-level @sim.function. Its non-self parameters and return value must be explicitly annotated as bool, int/sim.Handle, or float. A process or collector can call env.policy.decide(level), and another method on the same component can call self.decide(level). The helper may read scalar component parameters, outputs, state, and explicitly declared sim.Const values, including through nested components and Ref/Refs paths, but cannot mutate fields or call scheduling and entity operations.

The root model may own the same kind of helper. Its first argument is self, which represents the root trial environment view. Model callbacks call it through self.<name>(...); component callbacks call it through env.<name>(...):

class Inventory(sim.Model):
    stock: sim.State

    @sim.function
    def shortage(self: "Inventory", demand: float) -> float:
        return max(0.0, demand - float(self.stock))

Root helpers may read scalar model fields and component scalar namespaces and may call other root or component functions. They follow the same annotation, read-only, and non-recursion rules as component functions.

Components may contain other components, and flattened names follow the same recursive convention, for example env.attraction.queues.line becomes attraction__queues__line. Nested component process methods are also lowered with their component path in the process name.

Components may declare a spawnable process with @sim.process(spawnable=True). It can be spawned from component or model code with natural paths such as sim.spawn(self.visitor, env) or sim.spawn(env.park.entrance.visitor, env). These component processes may receive a final sim.Struct view parameter. They may also pass struct=SomeStruct to attach storage without injecting a view; if both forms are used they must name the same struct type.

Components may reference other declared components with sim.Ref[Target] fields and routing tables of collection items with sim.Refs[Target], letting method bodies route through paths such as self.downstream.inbox.put(h) or self.routes[i].inbox.put(h); see Components for wiring and routing details.

Fixed repeated structures can be declared with standard list[Component] annotations, for example attractions: list[Attraction] = [...]. Model callbacks can use indexed access such as self.attractions[i].queues[j]; runtime fields remain flattened, for example attractions__queues. Nested collections are linearized behind the scenes, so self.campus.zones[i].gates[j].queue remains valid model source while the trial table stores a one-dimensional campus__zones__gates__queue field.

Per-process fields

Declare a sim.Struct subclass with float and int annotations. A process can receive its own field view as a final annotated parameter: def visitor(self, view: Visitor). Multi-copy processes can also receive the copy index: def visitor(self, idx, view: Visitor). Visitor(handle) returns a read/write view of another process’s fields when model code already has that process handle.

Compilation plans and cache

Reusable class-declared model and component callbacks are planned from the first normally constructed model instance; importing a module or defining a model class does not construct a hidden prototype. Model.compilation_status() reports pending, ready, failed, or unavailable together with elapsed time, callback counts, persistent-cache hits/misses, and an error message when preparation failed. Model.compilation_plan() returns the immutable sim.CompilationPlan after a plan has been built. After an instance compiles its remaining processes, predicates, events, and collectors, model.callback_cache_stats() reports their cache hits, misses, and writes separately from the reusable class preparation.

The default __cimba_precompile__ = "eager" prepares reusable class callbacks during the first real model construction. A subclass can select "lazy" to prepare them on its first experiment or "explicit" and call Model.precompile(*constructor_args, **constructor_kwargs) itself. Explicit precompilation retries a previous failure, which is useful when callback globals are initialized later during module startup.

Compiled native callbacks are cached by code, signature, record layout, compiler versions, operating system, architecture, and CPU target. The cache is enabled by default. Set CIMBA_CACHE=0 to disable both memory and disk reuse, or CIMBA_CACHE_DIR to choose the persistent cache directory. Cache entries are optimization-only: an absent, stale, or unreadable entry falls back to normal compilation. Process-local handles returned by sim.log_text() are placed in a runtime sidecar, so callbacks that write logs or reports can safely reuse persisted object code in another process.

Process graphs

Call model.process_dag() to infer a resource-aware graph from class-declared process bodies. The returned ProcessDAG contains ProcessDAGNode and ProcessDAGEdge records for processes and model fields, and can render Mermaid or Graphviz DOT text. The inference follows direct sim calls, simple aliases, helper functions called with env, spawnables, stores, priority queues, conditions, events, mutable state, and shared resources. Synchronous component methods appear as function: nodes, with read edges from referenced parameters/state and call edges from processes or other functions:

graph = model.process_dag()
print(graph.to_mermaid())
print(graph.to_dot())

Experiments

model.experiment(...) returns an Experiment; exp.run() executes the trial table in place and returns the number of failed trials, and exp["field"] reads any trial column as an array. exp.summary() condenses the outputs across replications: it returns a structured array with one record per design point holding the swept parameter values and, for each output, its replication mean (name) and Student-t confidence-interval half-width (name_hw, 95% by default):

exp = model.experiment(utilization=[0.7, 0.8, 0.9], replications=20,
                       duration=10_000.0, seed=42)
exp.run()
for row in exp.summary("avg_wait"):
    print(f"rho={row['utilization']:.1f}  "
          f"wait={row['avg_wait']:.2f} +- {row['avg_wait_hw']:.2f}")

exp.summary("a", "b", confidence=0.99) selects outputs and the confidence level; failed trials (NaN) are excluded per output. exp.replications and exp.swept expose the layout (trial order is design-point-major with replications innermost).

Typed result namespaces

Experiments also expose retained structured results through exp.results. Output paths follow the model’s component structure, so callers do not need to construct flattened __ names:

exp.run()
queue_means = exp.results.counters.mean_queue_length
served = exp.results.customers_served

Output leaves are the same NumPy trial-column views returned by exp["..."]; dtype, replication order, and component collection axes are unchanged. A component collection therefore remains one array with its collection dimension, rather than becoming one attribute per item.

Outputs, captured datasets, and captured histories share the model’s object tree. Callers do not need to know which storage mechanism produced a result, and a result declared inside a component is found through that component:

all_wait_samples = exp.results.waits
all_station_samples = exp.results.station.samples
all_queue_rows = exp.results.station.queue

Dataset leaves match exp.datasets(name) and history leaves match exp.histories(name): both are tuples aligned with experiment trials, with an additional inner tuple for indexed component histories. The existing dataset(), datasets(), history(), and histories() methods keep their original behavior and error handling. Output views share the trial table’s storage; captured datasets and histories are the copied arrays already returned by their existing methods.

The runtime namespaces discover declared/captured names through dir() and raise a path-aware AttributeError for unknown names. Dynamically named outputs remain available through the string-key API, which is the general fallback for names not represented by the namespace.

For exact model-specific static completion, parameterize Model with a result Protocol describing this shared object tree. Pyright then propagates that schema to model.experiment().results:

class QueueResults(Protocol):
    customers_served: NDArray[np.float64]
    waits: tuple[NDArray[np.float64], ...]
    counters: CounterResults

class QueueModel(sim.Model[QueueResults]):
    ...

Unparameterized models continue to use the general dynamic result namespace.

If a model-level collector declares self.<entity>.history().capture(), exp.history("field", trial=i) returns that trial’s raw time-series rows as a NumPy array with columns time, value, and duration. exp.histories("field") returns one such array per trial, aligned with the experiment row order. For fields owned by a component collection, indexed captures return one inner array per collection item; use exp.history("field", trial=i, index=j) to select one item.

If a model-level collector declares self.<dataset>.capture(), exp.dataset("field", trial=i) returns that trial’s raw dataset samples as a one-dimensional NumPy array. exp.datasets("field") returns one array per trial, also aligned with the experiment row order.