Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Host responsibilities

For anyone about to wire this engine to real equipment. It answers one question: what safety behavior must you implement, because the engine deliberately does not?

This engine executes a control sequence. It does not supervise the equipment that sequence drives, and it does not judge the quality of the data it is fed. Those are your job, and the engine will not warn you if you skip them.

Staging is status-agnostic

A sample is converted from its value regardless of PointStatus. Fault, Stale, Uninitialized and Override all stage exactly like Ok.

The conversion function destructures the sample and discards both quality fields: crates/oce-api/src/engine.rs:443-448 binds status: _ and at_unix_nanos: _, then dispatches purely on the value and the target type. The five statuses are defined at crates/oce-store/src/lib.rs:81-92; nothing in the engine reads them. The behavior is pinned by store_backed_input_staging_is_status_agnostic (crates/oce-api/src/tests/store_backed_inputs.rs:88), which ticks the same fixture once per status and asserts identical staging.

This is a design decision, not an oversight — point quality is metadata for the application and BMS layer, and an engine that silently reinterpreted a faulted reading would be harder to reason about than one that never looks. But it means a faulted sensor reading drives your sequence exactly as a healthy one does.

A missing sample is not an error

If no sample is available for a bound input, the connector keeps its current value and the tick proceeds. There is no diagnostic. Before the first sample ever arrives, that held value is the type’s zero_value() — so an input that has never been written reads as 0, 0.0 or false, not as “unknown”.

The hold is explicit: crates/oce-api/src/engine.rs:417-420 continues past a missing sample with the comment “Deliberate hold-last: no store sample means no overwrite of the current state value”, and the policy is documented at engine.rs:396-402. missing_store_sample_holds_prior_input_value (crates/oce-api/src/tests/store_backed_inputs.rs:112) pins it.

A dead sensor and a steady sensor are indistinguishable to the engine, forever. Nothing in the engine will ever notice that a point stopped updating.

The engine implements no fail-safe policy of its own

Taken together, the two behaviors above mean the engine has no concept of degraded operation. It will keep computing and keep writing outputs from held, stale, faulted values indefinitely. If your plant needs to fail safe, the logic that makes it fail safe lives above the engine, in your host layer. At minimum, implement all of the following:

  1. Per-point staleness limits. PointSample carries at_unix_nanos (crates/oce-store/src/lib.rs:97-104), and the engine throws it away at staging. Track sample age yourself and define, per point, how old is too old.
  2. A status reaction policy. Decide what Fault, Stale, Uninitialized and Override mean for each input, and act on them before or instead of ticking. The engine will not.
  3. A defined safe state, and a path to it. Know what output set is safe for the equipment, and drive it from the host when the input contract is violated — do not expect the sequence to produce it.
  4. Plausibility checks on inputs. Range, rate-of-change and cross-sensor consistency, applied before staging.
  5. Equipment protection below the engine. Any interlock you are relying on to prevent physical damage — freeze protection, high-limit cutouts, minimum off-times enforced in hardware — must exist in the host layer or in the equipment itself. The engine executes the sequence you gave it and nothing else; a sequence that omits an interlock has no interlock.
  6. Write-failure handling. Engine::step_realtime is not transactional: if the batched store write fails, the tick has already completed and model time and outputs have advanced, and they are not rolled back (crates/oce-api/src/sim.rs:457-458).

Time is host-supplied

The engine never reads a wall clock. std::time::Instant appears only as a monotonic timer for latency metrics, never as a time source for the model (crates/oce-api/src/sim.rs:6). Model time arrives as a f64 argument you pass in, and it must be monotonic — a decrease returns OcError::TimeRegression (crates/oce-api/src/error.rs:64-71).

For real-time stepping you must first configure the UNIX epoch corresponding to model t = 0, via Engine::set_realtime_epoch_unix_nanos (crates/oce-api/src/sim.rs:360-373). If you never do, step_realtime returns OcError::RealtimeEpochUnset before ticking rather than silently stamping samples at 1970 (crates/oce-api/src/sim.rs:457-461, variant at crates/oce-api/src/error.rs:72-74, pinned by host_epoch_is_required_and_exact_mapping_handles_signed_model_time at crates/oce-api/src/tests/realtime_write_back_tests.rs:79). The epoch-plus-offset mapping is explicitly range-checked, so a non-finite or out-of-range instant fails with OcError::RealtimeInstantUnrepresentable rather than clamping, wrapping or panicking (crates/oce-api/src/sim.rs:265-279).

Supply time from a source you trust to be monotonic. The engine cannot detect a clock that jumped.

Do not interleave horizon simulation with real-time stepping if you rely on the monotonic-time guard across that boundary. After preflight succeeds, simulate deliberately clears the prior tick time, so a following step_realtime cannot detect regression relative to a real-time step that happened before the simulation.

simulate is a run restart, not a continuation. Before restarting, it resolves recorded columns, fixed inputs, and the first list returned by an input closure. A refusal there leaves the prior run unchanged, including its monotonic-time guard and state words. After preflight succeeds, the engine clears the prior tick time and re-seeds stateful blocks to their authored start values. It leaves connector values alone, so it is narrower than the resume re-seed described below, which replaces the whole run state and only when parameters are dirty.

An input closure remains dynamic after the first tick. If a later call returns an unknown point or a wrong-typed value, completed ticks stay in effect and any valid pairs before the failing pair stay staged. A simulation is not transactional after execution begins.

Store-backed inputs are staged inside each tick, not during simulation preflight. A snapshot error or wrong-typed store sample on the first tick therefore returns after the run clock and state words have reset, even though no block evaluated. Model time and the output snapshot still describe the prior run. A snapshot error stages no store input; a wrong-typed sample leaves any valid store samples staged before it in the connector image.

Two consequences to plan for. Splitting a horizon across two calls does not continue the trajectory: simulating 0..10 then 11..20 is not the same as simulating 0..20, because the second call restarts from the seed. And a what-if interleaved into a live run resets that engine’s stateful blocks, which for held and sampled values means a jump rather than an advance. Use a process-local checkpoint to save and restore the live run around the simulation; checkpoint restore may rewind a compatible engine (crates/oce-api/src/state.rs:367-394).

Connector values that simulate does not overwrite carry into the horizon: InputSource writes the slots it names on every step, and the rest hold whatever was there. Whether a value staged through set_input reaches a given block depends on how that input is fed — a store-bound point is re-staged from the snapshot on any tick the snapshot has a sample for, and an input driven by another block inside the model is read from its driver rather than from its own slot.

Persist engine state outside the store port

Engine::state_snapshot returns the engine-owned canonical bytes needed to continue a run. It does not write them anywhere. Engine::checkpoint, state_snapshot, restore_checkpoint, and restore_state call no Store method (crates/oce-api/src/state.rs:367-421). The host owns durable storage, authentication, generation fencing, and the decision that a restored process may command equipment.

Capture only after a model has loaded successfully and while no parameter edits are pending. A durable capture also requires authored stable identities and registered state contracts for every stateful block. The decoder enforces a 64 MiB limit, validates canonical ordering and manifest self-consistency, and checks an integrity trailer (crates/oce-api/src/state.rs:13-15,42-53; crates/oce-api/src/state_codec.rs:101-239). Class-specific block-state invariants are checked during restore, when a target engine is available. The trailer detects accidental corruption; it is not an authenticity or freshness proof. Protect snapshot bytes according to the trust boundary of the host that consumes them.

Durable continuation has a narrow restore window:

  1. Load the model into a fresh engine.
  2. Parse persisted bytes with EngineStateSnapshot::from_bytes.
  3. Call restore_state before any input write, tick, simulation, dirty-parameter resume, or earlier restore.
  4. Re-establish host-owned wall-clock mapping, actuator ownership, and generation fencing before resuming equipment writes.

The target model must have the same executable manifest: block classes and parameters, port bindings, connector types, schedule, state-slot layout, enum descriptors, external inputs, and boundary outputs. The diagnostic model id may differ; executable compatibility may not. A refusal is atomic and leaves engine and store state unchanged. Durable restore also refuses after the target crosses a mutation boundary, even if that mutation was otherwise harmless (crates/oce-api/src/state.rs:412-438).

Snapshots for models that use the revision-1 libm-dependent class set are target-bound. They restore only on the same architecture and operating system; restore_state returns EngineStateError::TargetDomainMismatch before commit on another target. Other revision-1 models are portable. If restart scheduling may cross machine types, retain the capture target alongside the opaque bytes and treat a target-domain refusal as a placement failure, not as recoverable model state.

Snapshots restore absolute model time and the prior-tick monotonicity guard. They do not carry the real-time UNIX epoch, backend point history, point status or timestamps, backend transaction state, or host safety policy. The current connector image, including staged input values, is part of the snapshot. Restore the other state outside the engine. Use EngineCheckpoint instead when branching or rewinding within one process; it is opaque and has no persistence format.

Lifecycle names are not equipment controls

Engine::halt() does not stop ticks, real-time steps, simulations, or output writes. It changes only the parameter-edit permission mode: set_param is accepted while halted. The host must stop calling execution methods if it intends execution to stop.

A halt / set_param / resume cycle is also a run restart, not live tuning. When parameters are dirty, resume rebuilds blocks, allocates all state again, refreshes outputs, and clears the prior model time. Every stateful block—including integrators, latches, timers, and filters—is re-seeded, and monotonic-time history is lost. Plan parameter edits as a new run.

The stable API also contains two loaders that do not work yet: load_from_semantic and load_modelica always return OcError::Load. Use load_cxf for working ingest. Likewise, the public AssertLevel::Error variant is never emitted today; the sole assertion collector produces Warning, so hosts must not depend on receiving Error for escalation.

CXF point identities are the authored @ids

Every point path — on the host-visible IoInventory and in the durable PointDto projection sent through the PointStore port — is an authored @id from the source CXF document, expanded against the document’s @context to canonical absolute form at ingest: for a connector driven by a composite boundary input it is the declared boundary input’s @id (one host point fans out to every internal consumer, which is why the G36 corpus’s 3020 connectors surface as 2895 points), and for every other connector it is the connector’s own node’s @id. CXF ingest rejects a connector node without an @id, so a document-loaded point can never receive a positional identity. Because keys are canonical, a document re-serialized between compact and expanded spellings keeps its point paths; a relative @id that no @context can canonicalize is refused at load with a typed relative-iri diagnostic rather than admitted under a spelling-dependent key. The supported @context form is an inline prefix map — a single map, or a list of maps merged in order with later bindings winning; a remote context reference, @base, @import, @vocab, prefix bindings that are not absolute IRIs, and term definitions that use another active prefix are refused at load as non-subset constructs rather than silently ignored. The last case is a nested compact IRI; it includes an absolute-looking value such as urn:oce:names# when the same context also declares urn as a term. Recursive context-term expansion is outside the supported subset. A direct @context on an @graph node, one of its identity/type reference objects, or a modeled value/term object is also refused: context bindings are document-level only, and the engine never applies a scoped context to one semantic value. The canonical-key guarantee therefore holds for every document that loads at all.

The document’s declared boundary-output names (root S231:hasOutput) are a second read-only identity space: each resolves on get_output, watch, and CollectSpec::Named as an alias for its driving internal connector’s slot, and Topology.boundary_outputs enumerates the (path, driver_path) pairs. Declared names stay out of point_list, to_map, IoSummary, and the durable store batch — a declared name and its driver are two keys over one value, and only the driver’s path carries samples. Their unit, quantity, and bounds are one §7.10 contract: conflicts refuse at load and one-sided values propagate to the unset peer. set_input never accepts a declared output name. Because the driver’s connector supplies host point metadata, a declared alias can supply a previously unset driver unit, quantity, or bound. That changes the driver’s IoInventory and point_list(None) row for an unchanged input document; propagated unit and quantity also reach the durable PointDto. IoSummary remains a count-only surface and does not change when metadata propagates. Hosts that retain point metadata outside the store port must refresh it after loading with this rule. An undriven declared output resolves nowhere; its load-time undriven-boundary-output warning is its only representation.

A related contract for emitters and durable stores: array order is load-bearing wherever the resolver reads an array — @graph node position, containsBlock order, each instance’s port and parameter lists, isConnectedTo order. The one carve-out is the boundary-input elision vector (external_inputs) and the pass-through pair list: both are re-keyed on the boundary port’s own @graph node position instead of inheriting the order of that port’s isConnectedTo array (crates/oce-cxf/src/resolve/mod.rs, Step 9). Neither array order nor node position is a stable identity: key by authored name, never by position.

Point histories persisted under the earlier positional conn#<N> keys are disposable, not migratable: an index is not traceable to an authored connector after the document that produced it changes.

Ingest resource bounds

BoundLimitDefined atBehavior when exceeded
Expression parse and AST nesting64crates/oce-expr/src/lib.rstyped NestingTooDeep error
Expression size4096 nodescrates/oce-expr/src/lib.rstyped ExpressionTooLarge error
Composite nesting (containsBlock lowering)64crates/oce-cxf/src/resolve/composite.rsMalformedDocument diagnostic
Composite boundary path64 non-top isConnectedTo hopscrates/oce-cxf/src/resolve/composite.rsMalformedDocument diagnostic
Composite boundary work65,536 target examinations and 8 MiB of aggregate target-IRI bytes per documentcrates/oce-cxf/src/resolve/composite.rsMalformedDocument diagnostic

Composite nesting and boundary traversal are different walks and have separate limits. Boundary traversal is iterative, so an accepted path does not consume one call-stack frame per hop. Its work budgets also bound shallow fan-out and repeated long IRIs that a depth limit alone would miss. Direct leaf wiring is outside those budgets. Below the limits the walk keeps target order and duplicate paths intact for single-assignment validation.

Treat untrusted CXF as untrusted input

A CXF document is a program. Loading one from a source you do not control is running code you did not write. If you must:

  • Bound document size before handing bytes to the loader; JSON deserialization has no engine-level byte cap.
  • Load in a process or thread whose loss you can absorb when your threat model requires isolation.
  • Never load an untrusted document in the same process that is actively commanding equipment.

The structural ingest paths above are bounded and return typed diagnostics rather than panicking. ../TESTING.md requires new ingest code to assert the specific DiagCode or error variant rather than “an error occurred.”

The tests cited on this page live in oce-api and run per PR on x86_64 and arm64 under debug and release codegen. The full workspace and doctests still wait for the release gate. See ci-and-the-gate.md for the exact split.