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

DETERMINISTIC CONTROL · EMBEDDABLE RUST

Open Control Engine

Parse CXF, freeze a control schedule, and tick CDL control sequences deterministically inside your application—without bringing a daemon, runtime framework, or database.

Choose your path

EMBED

Run the guarded Quickstart

Start with the mechanically reused, compiled example, then read the host safety boundary before connecting equipment.

Embed the engine →

CONTRIBUTE

Change the engine safely

Understand the development and release gates, what a green check proves, and the testing standard expected of every change.

Contribute to the engine →

Evidence, not adjectives

The documentation separates deterministic self-output traces, independent signal oracles, structural checks, and work that is still deferred.

Read the verification accounting →

Embed the engine

This is the repository Quickstart, extracted mechanically from the first Rust block in README.md. That block is byte-compared with the compiled crates/oce-api/examples/quickstart.rs example.

Before connecting real equipment, read Host responsibilities. The host owns sample quality, missing-data, timing, fault, and safe-state policy.

use oce_api::{CollectSpec, Engine, InputSource, SimSpec, Value};

const ECONOMIZER: &str = "http://example.org#g36.ahu_economizer";
const ECONOMIZER_ENABLED: &str = "http://example.org#g36.ahu_economizer.enableLatch.y";
const DAMPER_COMMAND: &str = "http://example.org#g36.ahu_economizer.damperSwitch.y";
const OA_TEMPERATURE_DELTA: &str = "http://example.org#g36.ahu_economizer.returnMinusOutdoor.y";

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // An engine with the default in-memory store — no database.
    let mut engine = Engine::in_memory();

    // Parse, validate, and freeze the schedule.
    let cxf_bytes = std::fs::read("crates/oce-cxf/tests/fixtures/g36/ahu_economizer.jsonld")?;
    engine.load_cxf(&cxf_bytes)?;

    // Simulate: feed inputs per tick, collect named outputs.
    let metrics = engine.simulate(&SimSpec {
        t_start: 0.0,
        t_stop: 4.0,
        step: 1.0,
        inputs: InputSource::Closure(Box::new(|t| {
            vec![
                (format!("{ECONOMIZER}.return_air_temp"), Value::Real(24.0)),
                (
                    format!("{ECONOMIZER}.outdoor_air_temp"),
                    Value::Real(18.0 + t),
                ),
                (format!("{ECONOMIZER}.operating_mode"), Value::Integer(1)),
            ]
        })),
        collect: CollectSpec::Named {
            points: vec![
                ECONOMIZER_ENABLED.to_string(),
                DAMPER_COMMAND.to_string(),
                OA_TEMPERATURE_DELTA.to_string(),
            ],
            stride: 1,
        },
    })?;

    println!("times: {:?}", metrics.trace.times());
    for (index, name) in metrics.trace.columns().iter().enumerate() {
        println!(
            "{name}: {:?}",
            metrics.trace.column(index).unwrap_or_default()
        );
    }
    Ok(())
}

Continue with Architecture and CDL coverage.

Documentation

Reference documentation for Open Control Engine. The project README is the front door; these pages are the detail behind it.

Start here

PageRead it when you want to know
ArchitectureHow the engine is layered, where the CDL §7.17 seam sits, what each of the 17 crates owns, and the platform and MSRV policy
Verification and evidenceWhat has actually been proven about this engine, what has not, and which checks are deliberately not running
CDL coverageWhether your sequence runs — which classes and G36 sequences are supported, and what “supported” is defined to mean
CXF round tripWhat export guarantees, and the conditions under which it silently drops part of your model
CXF composite subsetThe normative contract, if you are writing a tool that emits CXF for this engine
Host responsibilitiesWhat safety behavior you must implement yourself, before wiring the engine to equipment
CI and the gateWhat runs when, and what a green check does and does not prove
BenchmarksMeasured Engine::tick() throughput, recorded per run with the commit and host that produced it

Elsewhere in the repository

DocumentPurpose
README.mdProject front door: what this is, who it is for, and how to try it
TESTING.mdThe testing standard every change is held to. Read before writing a test
CONTRIBUTING.mdHow to work on the repository
SECURITY.mdReporting, threat model, and the known hardening limit
CHANGELOG.mdNotable changes

Two pages worth reading even if you are only evaluating

Verification and evidence is the honest accounting. Five different things in this repository are called “tests” and they prove different things — one of them proves nothing about correctness at all. That page says which is which, names the two global report tiers that are not wired, and bounds the separate two-case OpenModelica evidence.

Host responsibilities is the one to read before anything touches a physical output. The engine implements no fail-safe policy of its own, by design, and that page is the checklist of what your host layer therefore has to do.

A note on these documents

Claims here cite file:line wherever they are checkable, so you can verify rather than trust. Where something is unverified, these pages say so rather than rounding up — several of them were written specifically to correct claims that had drifted out of date.

Nothing here describes a working directory that is not in the repository. If a page cites a path, you have that path in your clone.

Architecture

This page is for an engineer deciding whether to embed the Open Control Engine in a product. It answers four questions: what the layers are, where the seam between them falls, what each of the 17 crates owns, and what this engine will never do for you.

The organising idea: the CDL §7.17 non-computational seam

CDL §7.17 states that point lists, trends, display units, tags, and all Brick / Haystack / ASHRAE 223P semantics do not affect the computation of a control signal. That is the cleanest seam available in this problem domain, and the engine is built along it rather than around it.

Above the seam sits an execution core: a small, deterministic, in-memory dataflow machine that sees only blocks, typed connections, and values. This is the hot path, and it has zero dependency on any database.

Below it sits storage behind a trait. Everything the evaluator must not read — equipment topology, points, instance structure, parameters, trends, semantic triples — plus durable persistence is reached only through the oce-store port traits (crates/oce-store/src/lib.rs:580). The library ships no first-party database. Durable or queryable backends are app-side adapters behind the port, with an in-memory default (oce-store-mem) so that a downstream project can embed the engine for load → flatten → validate → schedule → tick → simulate with no database at all.

The seam also fixes where responsibility for input quality lives, and it is not here. Staging is deliberately status-agnostic: a sample is converted from its value regardless of PointStatus, so Fault, Stale, and Uninitialized all stage exactly like Ok. A missing sample is not an error either — the connector holds its previous value indefinitely, and before the first sample it holds the type’s zero_value(). The engine therefore implements no fail-safe policy of its own. Staleness limits, fault reactions, and safe-state fallback belong to the host layer above it; see host-responsibilities.md.

The pipeline: build once, tick many

Everything expensive and everything fallible happens in BUILD. Parsing, elaboration, conformance rejection, algebraic-loop rejection, and topological sorting all run once per load. What survives is a frozen schedule over flat arrays, and TICK walks it: no graph traversal, no hashing, no store access in the graph evaluator.

Embeddability posture

The engine is, by design:

  • Library-only. No main, no [[bin]], no daemon, no server, no network listener — verified by the absence of any binary target or std::net use in crates/. The host owns process lifecycle, transport, TLS, authN/Z, multi-tenancy, off-host durability, and metrics export.
  • Synchronous and in-process. Every public method is a blocking synchronous call, and no async runtime is pulled at any layer. The check is the dependency set below, not a promise.
  • #![forbid(unsafe_code)] in all 17 crates, belt and braces: each crates/*/src/lib.rs carries the crate attribute, and the workspace sets unsafe_code = "forbid" under [workspace.lints.rust] (Cargo.toml:49).
  • Edition 2024, resolver = "3".

The whole external dependency surface on the embed path is four crates: serde, serde_json, thiserror, and libm. Two more — regex and ryu — exist only inside oce-conformance, which nothing else in the workspace depends on, so a host embedding oce-api never links them.

Two Rust versions, and they are not the same number

These are different values on purpose, and conflating them has already cost this repo one reverted change (PR #209).

ValueWhereWhat it means to you
MSRV1.97.0Cargo.toml:42 (rust-version)The floor a consumer needs. Build the engine with any toolchain at or above this.
Pin1.97.1rust-toolchain.toml (channel)What CI and local development actually build with, so the two agree exactly.

The MSRV cannot be raised to match the pin casually: the release gate’s cargo public-api surface checks shell out to a pinned nightly that identifies as rustc 1.97.0-nightly, and Cargo enforces rust-version against it. Cargo.toml carries the full explanation inline at the declaration.

Determinism, and the two allocation carve-outs

The tick is deterministic: a frozen, topologically-sorted schedule evaluated over flat arrays, with no graph walks, no hashing, and no store access in the graph evaluator. Two carve-outs, both real, both stated here rather than in a footnote.

The evaluator is not allocation-free for every block. The schedule and state arrays are preallocated and the gather scratch is reused, so most blocks tick without allocating. CDL.Reals.Sort uses fixed stack buffers through nin = 64 (SORT_STACK_WIDTH, crates/oce-blocks/src/reals_matrix.rs:340), then falls back to two heap-allocated vectors for wider inputs. CDL.Reals.Log and CDL.Reals.Log10 use static warning messages, so warning emission allocates nothing block-side — but a diagnostic sink may still allocate when it records an event, including the step_realtime collector. Size a real-time loop against the blocks and the diagnostic sink your sequence actually uses, not against a blanket guarantee.

Engine::tick is store-free only when the model declares no store-backed inputs. When it declares none, the staging path returns immediately (crates/oce-api/src/engine.rs:258). Otherwise the tick takes one store.snapshot() plus one read per staged input. With the default MemStore that snapshot is exactly one boxed allocation (crates/oce-store-mem/src/lib.rs:131 returns Box<dyn PointSnapshot> over an Arc clone); the PointStore trait places no allocation bound on a third-party backend’s snapshot().

Whether a block tick allocates on the evaluator thread is gated per-PR, registry-wide and with a positive control, by crates/oce-blocks/tests/tick_allocation_census.rs. No current block delegates work to a worker thread; such an implementation would need a companion guard for worker allocation. The facade has a narrower guard in crates/oce-api/tests/tick_purity_tests.rs. Throughput figures live in docs/benchmarks.md, recorded per run with the commit and host that produced them, because nothing re-measures them in CI.

The crate map

Seventeen crates. The dependency direction is acyclic and organized around the seam above.

Execution core (Group A — no store, no database):

CrateResponsibility
oce-modelPure value/connector/instance/connection types; the Value enum (Real/Integer/Boolean/String/Enum) and the flattened model graph — the shared executable truth.
oce-exprThe CDL §7.7.2 binding-expression parser and evaluator (closed-world, pure). Bounded on structure: input deeper than MAX_NESTING_DEPTH (64) or wider than MAX_EXPR_NODES (4096) is a typed error, not a stack overflow (crates/oce-expr/src/lib.rs:125, :132).
oce-blocksThe Block trait and the native CDL elementary-block library, publicly enumerable at runtime via catalog() (crates/oce-blocks/src/catalog.rs:155) with ports, parameter rules, and honest parameter defaults per class.
oce-flattenReserved seam; an identity passthrough today. oce-cxf owns lowering because CXF arrives pre-flattened, so flatten() returns the model unchanged (crates/oce-flatten/src/lib.rs:53). Full .mo flattening is deferred. It is on the oce-api path, so the seam is wired even though it does nothing.
oce-validateLoader conformance: subset rejection, single-assignment, type and attribute unification, parameter rules.
oce-graphThe deterministic scheduler and executor: direct-feedthrough DAG, algebraic-loop rejection, its own Kahn topological sort, the tick loop.
oce-cxfCXF (Control eXchange Format) JSON-LD ↔ model graph, both directions. Composite nesting is bounded at 64. Boundary lowering is iterative and separately bounded at 64 non-top isConnectedTo hops per path, 65,536 target examinations, and 8 MiB of aggregate target-IRI bytes per document. The accept/reject contract is written out in cxf-composite-subset.md.
oce-semanticsReserved seam; annotation parsing is deferred. The intended role is vendor-annotation parsing into effective non-computational point/trend/semantic metadata. No __cdl / __CDL annotation parsing exists today.
oce-diagThe shared diagnostic vocabulary (Severity / DiagCode / Diagnostic) across the ingest path. Zero dependencies.

Storage ports (the seam — traits only, no database types):

CrateResponsibility
oce-storeThe seam. The ModelStore / PointStore / SemanticStore / Durable traits plus DTOs, unified by the Store supertrait. No database types.
oce-store-memThe default in-memory backend, so the engine runs with no database.
oce-reference-wal-adapterVerification-only, publish = false. A std::fs WAL and atomic-snapshot adapter that exists to prove the frozen seam can carry real durability without a first-party database. Not a supported backend.

Verification, externals, and the host facade:

CrateResponsibility
oce-conformanceThe funnel-style tolerance-band and golden-trace conformance harness. Standalone: no other crate depends on it. Read TESTING.md for what it does and does not check.
oce-blessTest-support only, publish = false. The single definition of the repo’s environment-variable truthiness policy, so golden-regeneration switches cannot drift apart across crates.
oce-extensionReserved seam; nothing consumes it. The intended role is the FMI / extension-block boundary. No crate depends on it, the CXF resolver has no extension-block branch (an unknown class is a hard ClassNotFound), and DiagCode::MissingFmuPath (crates/oce-diag/src/lib.rs:167) is declared but never constructed. Do not plan FMI integration against this crate.
oce-docsReserved seam, not implemented. The sequence-spec and point-list export surface is declared; point_list_html panics with unimplemented! (crates/oce-docs/src/lib.rs:17). Nothing depends on it.
oce-apiThe embeddable host facade: Engine<S: Store = MemStore> (crates/oce-api/src/engine.rs:38) — the single public surface, spanning load, tick, simulate, parameters, IO inventory, key-selected output reads (watch), CXF export with content id, and a read-only topology view.

Four of those — oce-flatten, oce-semantics, oce-extension, oce-docs — are reserved seams rather than working components. They are named here so that nobody plans a feature against a crate that does nothing yet.

One thing that looks like a feature flag and is not

oce-api declares default = ["mem"] (crates/oce-api/Cargo.toml:23), but mem = [] gates nothing and oce-store-mem is an unconditional dependency, so disabling default features does not remove the in-memory backend. What actually makes MemStore the default is the type parameter in Engine<S: Store = MemStore>. To use a different backend, name it: Engine<MyAdapter>.

What this engine will never do

Each of these is a design commitment, not a gap waiting to be closed.

  • No first-party database. Persistence lives behind the oce-store port, authored app-side.
  • No daemon, no server, no network listener. It is a library; the host owns the process.
  • No async runtime at any layer, pulled by any crate.
  • No fail-safe policy of its own. It does not decide what a stale sample means, what a faulted point should do, or what safe state looks like for your equipment. Those decisions are yours, and they are enumerated in host-responsibilities.md.

For what the engine has and has not been verified against — including the global report tiers that are not wired — see TESTING.md and the verification section of the README.

Verification and evidence

This page is for someone deciding whether to trust this engine near real equipment. It answers one question: what has actually been proven about the Open Control Engine, and what has not.

Three questions are worth asking of any system that claims to be verified. What can tell it that it is wrong? Is that thing independent of the system it is judging? And will it tell you which checks it is not running? This page answers them in that order, and every count on it can be reproduced from a clone with find and grep.

The short version first, because it is the part that matters most: two elementary cases have been executed through OpenModelica 1.25.1 against pinned Buildings and MSL sources. The Nand case covers all four two-input Boolean states; the Toggle case covers one exact stateful event schedule with initially true input, repeated rises, and clear priority. The global Tier-3 report remains skipped; no sequence-wide, numeric, or cross-architecture OpenModelica claim follows from these cases.


What can tell this engine it is wrong?

Five different artifacts here are called “tests”. They prove different things, and the most visible of them proves nothing about correctness at all.

LayerArtifactCountIndependent of the engine?
Tier-2 determinism goldenscrates/oce-conformance/tests/fixtures/golden/g36_traces/46 traces + 46 .prov.jsonNo — engine self-output, by construction
Tier-A oraclestools/golden-gen/goldens/412 provenance records, 410 signal goldensYes — CI-enforced code-dependency firewall
Structural oraclethird_party/modelica-buildings-cdl/cxf/44 vendored translations; 31 comparable fixturesYes — an independent translation of the same upstream source
Tier-1 per-block oracle comparisonscrates/oce-conformance/tests/per_block_*.rs15 suites; 278 CDL signal goldens (257 bit-exact, 21 aligned-tolerance)Yes — Tier-A generator is outside the engine workspace
Scoped Tier-3 cross-implementation differentialscrates/oce-conformance/tests/fixtures/open_modelica/logical_nand/ and logical_toggle/2 named Boolean cases; global report skippedYes — pinned OpenModelica and Buildings execution

Tier-2 determinism goldens — they catch drift, not wrongness

Each of the 46 ASHRAE Guideline 36 fixtures carries a committed whole-sequence trace and a sidecar provenance record. All 46 records say the same two things:

{ "tier": "2",
  "source": "engine self-output (determinism snapshot); NOT a correctness oracle",
  "depends_on_oce_blocks": true }

That is not a caveat added by this page; it is a field in every one of the 46 files, and it is the whole meaning of the layer. These goldens were produced by running this engine and committing what it printed. If the engine computes a sequence wrongly and keeps computing it wrongly, all 46 pass forever. They detect one thing well: that a code change moved a number that was not supposed to move. Call that drift detection, and do not call it correctness.

Each record also carries a content_sha256 binding it to the bytes of its CSV, checked per PR by crates/oce-cxf/tests/golden_provenance/mod.rs. That guard is honest about its own limit in its first paragraph: editing a CSV together with its digest passes by design. It detects drift between two checked-in artifacts, not fabrication of both.

Tier-A oracles — 412 records behind a code-dependency firewall

The correctness layer is generated by tools/golden-gen, a crate deliberately held off the workspace. The repository’s workspace is members = ["crates/*"] (Cargo.toml:24), and tools/golden-gen/Cargo.toml declares an empty [workspace] table so the root workspace cannot absorb it. Its entire dependency list is libm, ryu, and serde_json — no oce-* crate.

That is enforced mechanically rather than by convention. .github/scripts/check-golden-gen-anti-tautology.sh runs cargo metadata over the generator and fails if any package named oce-* appears anywhere in its dependency graph. It fails closed: a cargo metadata error, or output that does not contain the golden-gen package itself, is a failure rather than a pass. It runs as its own CI job and again inside .agents/gate.sh.

The layer contains 412 Tier-A provenance records, every one of them recording "tier": "A" and "depends_on_oce_blocks": false — counts verified across the tree, with zero records carrying true:

  • 280 CDL records — 278 per-signal goldens, plus two fold-time provenance-only records with no CSV beside them (goldens/CDL/Types/types.prov.json, pinning enum ordinals, and goldens/CDL/Constants/constants.prov.json).
  • 132 G36 sequence signal goldens, spanning all 46 fixtures.

410 of those are signal goldens — 389 compared bit-exactly, 21 under a documented aligned-tolerance band. The 278 CDL signals are compared by the 15 crates/oce-conformance/tests/per_block_*.rs suites through a shared harness that drives each block through the frozen facade, asserts the comparison is unmasked, and asserts compared_points == reference.n_rows so a zero-row comparison cannot pass vacuously. Twelve of the 15 suites run ComparisonMode::Exact with zero tolerances (crates/oce-conformance/tests/block_harness/mod.rs:106-140), and four run their 21 libm-dependent Real goldens through ComparisonMode::AlignedTolerance at 1e-12 (block_harness/mod.rs:142-158, tolerances pinned at :323-332): per_block_reals_transcendental.rs, per_block_reals_sources_transcendental.rs, per_block_psychrometrics.rs, and per_block_utilities.rs — with per_block_reals_sources_transcendental.rs counted in both, because its two CalendarTime cases compare exactly while its single Sin case is banded. Boolean outputs in the aligned suites still compare by bits even in that mode (crates/oce-conformance/src/aligned.rs:214), so 257 of the 278 CDL goldens are bit-exact. The 132 G36 signals are compared by 23 *_funnel.rs and four *_oracle.rs per-fixture suites in the same directory. Their recorded comparison regimes tally exactly: 102 Value::bit_eq f64, 18 exact encoded integer, 12 exact 0.0/1.0.

“Bit-exact” here has a precise definition, in crates/oce-conformance/src/exact.rs: finite Reals compare by IEEE-754 bits, NaN compares equal to any NaN payload, and each signed infinity compares only to itself. Integer and Boolean cells compare their encoded values exactly.

The L1 funnel band is an additional layer applied over the 102 Real G36 outputs, never the primary check. Boolean and Integer outputs are deliberately kept off it, because the funnel is type-blind and a band could otherwise admit a value between two discrete levels (crates/oce-conformance/tests/g36_funnel_band/policy.rs:17-21).

The structural oracle — it bounds fixture fidelity, not block behavior

Every conformance test in the workspace derives from the same 46 catalog fixture documents. The 47th CXF document, member_list_interface.jsonld, is a resolver contract fixture and has no conformance trace. A structurally wrong catalog fixture therefore fails nothing — it makes the entire suite validate the wrong sequence, consistently and permanently. Neither goldens nor oracles can see that, because both are computed from the fixture.

The check that can is crates/oce-cxf/tests/fixture_structural_oracle.rs. It compares each fixture against modelica-json’s independent CXF translation of the same upstream G36 class — 44 .jsonld documents vendored under third_party/modelica-buildings-cdl/cxf/, at Buildings commit a131864e4c4df22ebcd52bb8da439de0087ac365 and modelica-json commit 85721b828a6ff8d9d3c1a48ff9a59808d2fa31fb, pinned and byte-checked by a hash manifest in both directions. The comparison flattens the oracle’s composite hierarchy, resolves every conditional against the fixture’s own parameter values on both sides, canonicalizes array instances and vector ports, and compares instances and undirected edges — counting orientation flips separately, since CXF §8.2 admits either endpoint as the isConnectedTo subject.

The verdict table is itself a committed golden (crates/oce-cxf/tests/fixtures/golden/structural_oracle_verdicts.txt), and its VERDICTS line reads:

VERDICTS: EXACT=30 EXACT-XFOLD=1   EXCLUDED=15

So: 30 EXACT plus 1 EXACT-XFOLD over the 31 comparable fixtures. The XFOLD case is multizone_vav_economizer_controller_single_damper_relief_damper_fixed_21, where one constant-folded subtree (ecoHigLim, 146 reference instances against 0 of ours) is excluded and named in the golden. The 15 excluded fixtures are listed with reasons and are never counted as passes: three are this repository’s own compositions with no upstream class, and twelve are parameter-specialized reductions of AirEconomizerHighLimits that are structurally unverifiable by design.

State the limit next to the result: this compares graph structure only. It contains no numerics and executes no engine code path. It bounds how faithfully the fixture corpus represents upstream G36. It says nothing whatsoever about whether a block computes the right number.

Tier 1 and the global Tier-3 report

This is not a footnote. It is the boundary of everything above.

When conformance report assembly succeeds, it emits five tiers. Tier 1 and Tier 3 are hard-coded as skipped on every successful path:

  • Tier 1 — per-block “same response” against the Buildings library. Skipped with the summary "per-block Buildings-oracle comparison is the per-block corpus, not a full-sequence run" (report.rs:131-136). A per-block corpus does exist — the per_block_*.rs suites described above — but it compares against re-derived references, not against Buildings executed output, and it is not wired into the tier report.
  • Tier 3 — the global row remains skipped because the report cannot represent partial external coverage (report.rs:138-143). The separate Nand and Toggle tests do not enter the report.

The scoped Nand fixture retains two byte-identical raw OMC runs, one semantic And control, strict raw-to-canonical projection, and the exact facade comparison. The separate Toggle fixture retains two byte-identical raw runs, a one-token Latch control, and the same class of projection and facade checks at explicit event instants. Both regeneration commands are network-disabled and native linux/arm64; CI validates committed evidence and never runs Docker. No Dymola, Spawn, FMI, whole sequence, Real, or Integer external case exists. Two Boolean cases therefore cannot make the engine-wide report pass.

Regeneration assumes a trusted host account, checkout, Docker client, and executable search path. The recorded sandbox limits the OpenModelica container; it does not defend against another process running as the invoking user.


Is the thing that judges it independent of it?

The answer differs by layer.

Tier-2: no, and it never claimed to be. The reference is prior engine output. Its provenance records say so in a field a script can read.

Tier-A: yes at the code level, mechanically enforced, with one honest caveat. The firewall guarantees the generator cannot import the implementation under test, so a Tier-A golden can never be a replay of oce-blocks. What the firewall cannot guarantee is derivational independence. tools/golden-gen/src/main.rs:9-11 states the caveat itself: some exact oracles share a pinned math kernel or restate the same documented recurrence the engine uses. Where that is true, a Tier-A pass is evidence about plumbing and transcription of a shared formula — not an independent check that the formula is right. A mechanical shared-kernel detector is filed as follow-up work and does not exist today, so treat the boundary between “independently derived” and “independently transcribed” as un-audited per class.

The structural oracle: yes. modelica-json is an LBL tool, not one of ours, translating upstream .mo sources this project did not author, at a pinned commit whose bytes are gated. It is also the narrowest claim: structure only, over 31 of 46 fixtures.

The scoped OpenModelica cases: yes at execution and source boundaries. A digest-pinned native arm64 image executes the pinned Buildings Nand and Toggle classes with inputs supplied by MSL BooleanTable. The wrappers contain no expected output. Each comparison remains a discrepancy detector rather than an oracle verdict: analytical evidence comes first in adjudication, and a mismatch cannot change a golden, tolerance, or report status. Their scope is four Boolean pairs for Nand and one event schedule for Toggle.

One more thing an evaluator should weigh: independence of the oracle does not make the comparison independent of when it runs. See the next section.


Will it tell you which checks it is not running?

Yes, and it does so in the place where it is hardest to ignore — the end of every gate run.

The gate prints what it does not cover

.agents/gate.sh finishes by printing a literal block headed NOT COVERED BY THIS SCRIPT — a green run here does not prove these pass, listing:

  • The cross-architecture determinism matrix (ubuntu-latest and ubuntu-24.04-arm). One machine cannot reproduce it; CI is the only place it runs.
  • The cargo public-api surface gates for oce-api and oce-store. They need a gate-only nightly toolchain and run in release-gate.yml.
  • cargo deny check advisories. It needs network access and a writable advisory database, so it runs in advisories.yml and in the release gate’s cargo-deny job instead.
  • That these commands still match ci.yml. Nothing verifies that mechanically. An attempt was made and withdrawn; .github/workflows/ci.yml:293-321 records why — every design either compared argv strings, which RUSTFLAGS=--cap-lints=allow leaves byte-identical while neutering clippy, or reimplemented enough of if: / needs: / matrix semantics to become its own untested gate. CI does execute the script (gate (light)), so every command in it gates a PR; the script says outright that this is coverage, not parity.

A light run adds a fifth line: it did not run the workspace test suite or the doctests, because the per-PR gate does not run them either.

The CI split, read in the dangerous direction

CI is dev-light and release-heavy. The per-PR gate into development runs engine tests for oce-api, oce-blocks, and oce-expr only — the determinism-matrix job (.github/workflows/ci.yml:148-168) and the identical step inside the gate script (.agents/gate.sh:120-124), on two architectures in debug and release codegen. The matrix emits populated portable and target-bound engine-state vectors. It requires both to match across codegen profiles, the portable bytes to match across architectures, and the target-bound bytes to differ. The x86_64 comparison job also parses the arm64 target-bound snapshot and requires restore_state to return the target-domain refusal.

Read that in the direction that costs you something. A change confined to oce-cxf, oce-store, oce-conformance, or oce-diag can show every check green having run none of its own tests. A green PR is not evidence that a change’s own tests pass.

That has a direct consequence for everything on this page. The oce-api comparison tests now run per PR, but crates/oce-conformance/tests/ does not, so the complete set of 410 oracle comparisons — 389 bit-exact, 21 aligned-tolerance — still runs only on development → main release PRs, on a daily cron against the development tip, and on manual dispatch (.github/workflows/release-gate.yml). Two input-hygiene audits do run per PR, because .agents/gate.sh invokes them directly: the fixture port-order audit and the structural oracle, the latter also carrying the vendored-tree hash manifest and the Tier-2 provenance digest guard.

One more disclosure worth knowing before you read a PR’s checks: every job in ci.yml is conditioned on github.event.pull_request.draft == false. A draft PR runs no gates at all — not a reduced set, none.

Full detail is in CI and the gate.

Coverage, stated as a fraction rather than a claim

Per-block Tier-A goldens cover 128 of the 133 CDL classes in the registry. The registry pins 136 entries — 133 CDL classes plus 3 reserved internal lowering classes (crates/oce-blocks/src/registry/manifest_tests.rs:21) — and the golden tree contains exactly 128 distinct CDL block class_path values, once the two non-block fold-time records are set aside.

The five without a per-block oracle, and why:

ClassStatus
CDL.Logical.Norindirect G36-sequence evidence only
CDL.Logical.Preindirect G36-sequence evidence only
CDL.Logical.Sources.Constantindirect G36-sequence evidence only
CDL.Reals.MovingAverageindirect G36-sequence evidence only
CDL.Utilities.Asserthas no output port; a diagnostics-channel golden is filed, not built

“Indirect G36-sequence evidence” means the class is exercised inside sequences whose outputs are oracle-compared, so a gross error would likely surface — but nothing pins that class’s behavior in isolation, and no per-class edge cases are covered by an oracle. Which classes exist and what “supported” means for sequences is in CDL coverage.


What this adds up to

If you are evaluating this engine, the defensible summary is:

  • Determinism is tested, on two architectures, per PR, against committed goldens.
  • Agreement with CDL / Buildings source semantics is bounded by 410 oracle comparisons (389 bit-exact, 21 under the documented 1e-12 aligned-tolerance band) against references generated behind a mechanically enforced code-dependency firewall — covering 128 of 133 classes, and running on the release gate rather than per PR.
  • Fixture fidelity is bounded structurally against an independent LBL translation of upstream sources, per PR, for 31 of 46 fixtures.
  • Agreement with an executed Buildings implementation is bounded for one exhaustive CDL.Logical.Nand Boolean case and one stateful CDL.Logical.Toggle schedule. Global Tier 3 remains skipped.

The gate will tell you which broader checks it skipped. The scoped OMC artifacts do not change those disclosures.

See also: Testing standard for the bar every change is held to, and CI and the gate for what runs when.

CDL coverage: what “supported” means

For anyone asking “does this run my sequence?” This page states which CDL classes the engine implements, what the 46 conformance fixtures actually are, and — the part that matters most — what the word “supported” is doing in each of those sentences.

The elementary-block registry

The engine registers 136 block classes: 133 CDL elementary classes plus 3 reserved internal lowering identities.

FamilyClasses
CDL.Reals52
CDL.Logical26
CDL.Integers24
CDL.Routing15
CDL.Discrete7
CDL.Conversions4
CDL.Psychrometrics3
CDL.Utilities2
CDL total133
Reserved lowering identities3
Registry total136

Every number in that table is checkable against the checked-in tools/reference-catalog/oce-blocks.registry-manifest.json, a 136-entry ordered JSON array generated from the registry itself and held byte-identical to it by registry::manifest_tests::checked_in_manifest_matches_regenerated_bytes in oce-blocks. The total is separately pinned at crates/oce-blocks/src/catalog_tests.rs:27.

The three reserved identities are urn:oce:lowering#PassThrough.Real, .Integer, and .Boolean (crates/oce-blocks/src/lowering.rs:66-78). They are what CXF import synthesizes for CDL’s direct boundary input→output connect. They are not authorable CDL — a hand-written CXF document cannot spell them — and each carries reserved: true in the catalog so a palette-building host can filter them out (crates/oce-blocks/src/catalog.rs:98-100).

Introspecting the registry at runtime

oce_blocks::catalog() (crates/oce-blocks/src/catalog.rs:155) returns the registered classes in deterministic registry order. Each CatalogEntry (crates/oce-blocks/src/catalog.rs:74-101) carries resolved input and output ports in declaration order, the port-naming policy, the static parameter rules, the authored parameter defaults, a width_driven flag, a conservative stateful hint, and the reserved flag.

The defaults are honest. DefaultSource (crates/oce-blocks/src/catalog.rs:47-57) has three variants — Literal, Derived { formula }, and Required. A parameter the caller must supply reports Required, not an internal fallback value dressed up as a default. That distinction is the whole reason the type exists.

The caveat that will cost you ten minutes: catalog() lives in oce-blocks, and oce-api does not re-export it. Reading crates/oce-api/src/lib.rs, the string catalog does not appear anywhere in crates/oce-api/src/ — the pub use block at crates/oce-api/src/lib.rs:50-77 re-exports Engine, the error types, ExportReport, IO and sim types, LoadReport, the parameter table, the topology view (Topology, TopologyBlock, TopologyConnection, DeclaredOutput, PassThroughPair), oce_diag::Diagnostic, oce_model::{ConnectorId, Value, ValueType}, and oce_store (including SemanticQuery) — and nothing from oce_blocks. oce-api depends on oce-blocks (crates/oce-api/Cargo.toml:29) and uses it internally, but a consumer depending only on oce-api must add oce-blocks as its own dependency to call catalog().

What “supported” means for a G36 sequence

The repo’s own catalog is explicit, and it under-claims on purpose. The support_policy block in tools/reference-catalog/Buildings.Controls.OBC.ASHRAE.G36.catalog.json records runtime_sequence_status: "selected-explicit-cxf-variants-supported" and states that supported rows “are limited to the listed checked-in explicit-CXF variants and do not imply arbitrary ASHRAE G36 composite support.”

Concretely, what exists today is:

  • 43 pre-specialized runtime variants (runtime_sequences, all status supported-runtime-sequence) over 31 distinct canonical class paths.
  • 3 hand-authored fixture-only fragments (fixture_only_sequences, status supported-fixture-only): ahu_supply_air_temp_reset, ahu_economizer, and vav_single_zone. Each carries canonical_g36_class_path_status: "fragment-of-canonical-source-not-runtime-sequence" — they are pre-flattened CXF graphs built from supported CDL elementary blocks, with source-reviewed-fragment evidence, and they make no canonical runtime-sequence claim.

All of them are pre-flattened CXF at specific parameterizations. A variant is a fixture at a fixed set of parameter values, not a general instantiation of the class.

The support vocabulary itself is defined at tools/reference-catalog/README.md:77-86. Note that one of its three terms, supported-import-fixture, currently labels zero rows — grepping the G36 catalog for that string returns no hits. It is defined vocabulary, not present state.

The promotion bar

A canonical class path is promoted to supported-runtime-sequence only once all six of these exist (tools/reference-catalog/README.md:77-80), and each runtime row carries the corresponding field:

RequirementField on the catalog row
Canonical Buildings.Controls.OBC.ASHRAE.G36.* class pathclass_path
Source provenancesource
Supported parameter variantssupported_variant
Fixturefixture
Deterministic golden tracegolden_trace, determinism_provenance
Independent oracle evidenceoracle_reference, oracle_test

A missing element is a missing promotion. That is the bar, and it is why the supported set is small.

The engine does not read Modelica

The engine executes the block graph that CXF hands it. It does not parse or flatten Modelica .mo sources. oce-flatten is a reserved seam and an identity passthrough today: CXF arrives already flattened and monomorphic, the oce-cxf resolver owns lowering, and full Modelica elaboration — parameter propagation, expression folding, conditional-instance removal, replaceable/redeclare/extends — is explicitly deferred (crates/oce-flatten/src/lib.rs:2-20). If your sequence exists only as .mo, something upstream has to produce CXF first.

47 fixture documents is not 47 sequences

crates/oce-conformance/tests/fixtures/golden/g36_traces/ holds 46 .csv traces and 46 matching .prov.json provenance records — 92 files. EXPECTED_G36_FIXTURES at crates/oce-cxf/tests/export_g36_roundtrip.rs:46 pins 47 CXF documents: those 46 catalog fixtures plus member_list_interface.jsonld, a resolver contract fixture with no conformance trace or G36 catalog claim.

The 46 catalog fixtures are configurations, not 46 distinct G36 sequences. Across the 43 runtime variants, the 31 distinct canonical class paths distribute like this:

  • 29 class paths appear once;
  • …Economizers.Subsequences.Modulations.ReturnFan appears twice;
  • Buildings.Controls.OBC.ASHRAE.G36.Generic.AirEconomizerHighLimits appears 12 times.

Those 12 are the air-economizer high-limit family: 4 ASHRAE 90.1 variants (differential, and fixed dry-bulb at 18 / 21 / 24) and 8 Title 24 variants (4 differential offsets and fixed dry-bulb at 21 / 22 / 23 / 24). Twelve fixtures, one class, twelve parameterizations.

So the honest reading is: 47 checked-in CXF documents comprise 46 catalog configurations covering 31 canonical G36 class paths plus 3 non-canonical fragments, and one resolver contract fixture. Any count of distinct sequences is smaller, and a public-facing number must say which set it means.

What the coverage is evidence of

Breadth of fixtures is not the same as correctness against the standard, and this repo separates the two deliberately. The evidence layers — engine-self-output determinism goldens, the per-PR structural diff against vendored modelica-json translations, and the oracle layer generated behind a code-dependency firewall — are described in ../README.md and ../TESTING.md.

The load-bearing limitation, stated plainly: no sequence here has been executed against an external Modelica / Buildings toolchain. Scoped OpenModelica evidence covers one exhaustive CDL.Logical.Nand Boolean case and one stateful CDL.Logical.Toggle event schedule, but no numeric tolerance or broader sequence behavior. The global Tier-3 report remains skipped, and no number on this page stands in for that deferred coverage.

For what happens when you export a loaded sequence back out to CXF, see cxf-round-trip.md.

CXF round-trip: what export guarantees

For an integrator writing or consuming CXF documents against the Open Control Engine. It answers one question: when export returns Ok, what is actually in those bytes — and what is quietly not?

CXF is bidirectional here. oce-cxf imports through the §7.1 resolver (crates/oce-cxf/src/resolve/mod.rs:1, reached via oce_cxf::import_cxf at crates/oce-cxf/src/lib.rs:106) and exports through a separate, deliberately smaller path (oce_cxf::export at crates/oce-cxf/src/lib.rs:200). Import and export do not cover the same ground, and the gap between them is where the surprises live.

The RT-2 contract

Export is specified as a fixpoint, not as source recovery (crates/oce-cxf/src/export.rs:5-9). For a graph G1 that import_cxf produced, re-importing the emitted bytes yields a graph that renders bit-identically to G1 — Reals compared by their IEEE-754 bit patterns, never by an epsilon (crates/oce-cxf/src/lib.rs:124-137; the fixpoint test is crates/oce-cxf/tests/export_roundtrip.rs, which compares through a hand-written renderer using f64::to_bits). Emission order derives from the ModelGraph vectors alone, so repeated exports of the same graph are byte-identical (crates/oce-cxf/src/export.rs:47-52).

The carve-out belongs right here rather than in a footnote: bit-identity holds over the survivor cone, not necessarily over the whole input graph. When nothing is deferred the survivor cone is the whole graph. When deferral fires, it is not, and no re-import can restore what was omitted. The next-but-one section is about exactly that.

That promise is for graphs produced by import_cxf. A hand-built legacy graph may carry external_inputs with an empty boundary_inputs sidecar. Export keeps accepting that shape and emits attribute-free root input declarations; re-import then materializes empty sidecars, so the re-imported graph is not structurally identical to the hand-built input even when no warning was reported (crates/oce-cxf/src/lib.rs:139-143).

What never round-trips at all: cosmetic source content. Labels, layout, and line numbers are not in ModelGraph, so none of them come back. The original root @id is not recorded either — the root composite is emitted under the fixed synthetic IRI urn:open-control:cxf-export:root (crates/oce-cxf/src/export.rs:11-14).

The export subset

Export accepts the flat, ground, single-root, scalar-parameter subset — the shape the resolver produces (crates/oce-cxf/src/lib.rs:114-120). Everything outside it is a typed CxfError::Validation carrying DiagCode::ExportUnsupported error diagnostics whose subject is the offending block, connector owner, or declared boundary node. Never a panic (crates/oce-cxf/src/export.rs:61-64).

Of the §7.4.1 connector attributes, five survive, each emitted as a bare JSON scalar on minted child ports and represented root boundary-input and boundary-output nodes. A boundary input keeps its declaration attrs separate from every child target, including fan-out; export never infers one from the other. Engine::load_cxf joins a declared output and its source in the same §7.10 cluster: conflicting values refuse the load, while a value declared on only one side propagates to the unset peer before export. Boundary-input declaration unification is a separate acceptance change and is not implemented. Low-level callers that compose oce_cxf::import_cxf and export directly must run the graph through oce_validate to apply the current output-side load contract (crates/oce-cxf/src/export.rs:31-45):

AttributeEmitted asApplies to
unit, quantity, displayUnitbare stringReal connectors
min, maxbare number, finite onlyReal (float) and Integer (int) connectors

Attributes are emitted only when Some; an all-default connector emits zero attribute keys, which is byte-identical to an attribute-free port node.

Two attributes are rejected rather than dropped — and the distinction between rejected and dropped is the point. On a surviving block, a connector carrying nominal or unbounded fails the export (crates/oce-cxf/src/export_attrs.rs:42-55), because the importer hardcodes both to None and the value would vanish silently. A non-finite Real min/max bound is rejected for the same reason: serde_json writes it as JSON null, which re-imports as None (crates/oce-cxf/src/export_attrs.rs:56-88).

On a deferred ordinary block, none of that runs. The block is omitted from the document and therefore contributes no error diagnostic of its own — not from its connector attributes, not from its parameters, not from its boundary entries (crates/oce-cxf/src/lib.rs:168-208). A reserved pass-through with hidden state is the exception: the resolver-produced lowering shape is the only valid form in the reserved namespace, so it rejects even when an enum parameter also marks the block deferred. A boundary-input sidecar follows its target owner into that omission; invalid attrs on a declaration whose entire target set is deferred do not abort the partial export. Whole-graph guards behave differently: an empty (zero-block) graph, non-dense ids, and a connection that is not output→input reject either way, because they are attributable to no single block’s presence in the document.

The deferral trap

This is the most important thing on this page.

Ordinary enum-carrying blocks — any ValueType::Enum connector or Value::Enum parameter — are deferred, not rejected. The block and its entire transitive downstream cone are omitted from the emitted document so that the enum-free remainder can still export. Reserved pass-through blocks remain strict: an enum parameter violates the resolver-produced lowering shape, so it rejects despite being selected for deferral. Each omission is reported as a DiagCode::ExportDeferred warning, which is non-aborting (crates/oce-cxf/src/export_defer.rs:1-32). The cone is a least fixpoint: a single enum connector near the front of a chain dooms everything downstream of it.

How large does that get in practice? The G36 corpus pins two cases as tripwires (crates/oce-cxf/tests/export_g36_roundtrip.rs:678-698):

FixtureBlocks in graphBlocks deferredShare
cooling_only_controller213 (crates/oce-api/tests/g36_cooling_only_controller.rs:252)8339 %
multizone_vav_relief_fan_group226 (crates/oce-api/tests/g36_relief_fan_group.rs:105)6328 %

Rejection fires only on total deferral — a graph with no emitted runtime block left after deferred and reserved lowering-only blocks are removed, which would be an unloadable root-only shell (crates/oce-cxf/src/export.rs:112-116). In principle, then, all but one block can vanish from an export that returns Ok.

And export() discards the warnings (crates/oce-cxf/src/lib.rs:210-212 — it destructures them into _warnings). A caller using export() alone cannot distinguish a complete export from one that dropped 39 % of the graph. Both return Ok(Vec<u8>).

Use export_with_report (crates/oce-cxf/src/lib.rs:254). It returns an ExportReport with bytes and warnings (crates/oce-cxf/src/lib.rs:215-240); the bytes are identical to what export() returns for the same graph. An empty warnings list is what certifies that the round trip covered the whole resolver-produced input. The legacy empty-sidecar exception above still applies to hand-built graphs. Treat a non-empty list as “this document is a subset of the model I asked you to write.”

Through the facade, Engine::export_cxf() (crates/oce-api/src/export.rs:98) always goes through export_with_report and keeps the warnings, so the facade route does not expose the trap. It is oce_cxf::export() specifically that drops them.

Pass-through elision is not deferral

CDL allows a boundary input wired straight to a boundary output. Import lowers each such connect to a reserved internal identity block — urn:oce:lowering#PassThrough.Real, .Integer, or .Boolean (crates/oce-blocks/src/lowering.rs:66-78) — and export elides those blocks back to the bare boundary edge (crates/oce-cxf/src/export.rs:743-812, :827-842). Re-import re-synthesizes them, so RT-2 holds by render identity.

The visible consequence: the emitted document lists fewer containsBlock entries than the graph holds blocks, and a canonical imported pass-through produces no warning at all (crates/oce-cxf/src/lib.rs:144-148). Reserved connectors have no emitted child-port node, so a host-built boundary alias or connection involving a surviving reserved block is rejected rather than silently omitted. If cascade deferral omits the reserved owner, well-directed relationships follow the ordinary survivor-cone rule: they are omitted with ExportDeferred warnings. Structural direction errors still reject before survivor filtering. An authored instance identity, parameter, connector attribute, or class/type mismatch on the reserved block rejects because elision has no wire representation for that internal state. Declaration-side attrs remain representable on the emitted boundary input and output. If cascade deferral omits the reserved block, those declarations leave with it rather than appearing without a target. An empty warning list means nothing was deferred; it does not mean the document explicitly lists every internal lowering block. If you are reconciling counts between a ModelGraph and an emitted document, that is the difference to expect.

Two ways an Ok export produces bytes that fail re-import

Both are documented, and both are reachable only from a hand-built ModelGraph — never from one the resolver produced.

  1. Port arity contradicting the class. Export takes no registry dependency, so it does not check a block’s declared port count against the class its class_path names. A hand-built block naming a registered class while declaring fewer ports than that class requires exports Ok; the bytes then fail re-import with MalformedDocument (crates/oce-cxf/src/lib.rs:150-156).
  2. An unregistered class path. Same root cause, different symptom: the bytes export fine and fail re-import loudly with ClassNotFound — never silently (crates/oce-cxf/src/lib.rs:158-160).

Every graph the resolver produces is correct by construction on both axes.

content_id_complete: a checked integrity tag, not a digest

ExportReport::content_id_complete() returns cxf:fnv1a128:<32 hex chars> computed over exactly the emitted bytes when the export is complete. If any content was deferred, it returns the typed ContentIdError::Incomplete { warning_count, .. } instead of minting an identity. Its rustdoc carries a runnable reproduction of the tag computation so a host can verify a returned tag independently. Three properties worth internalizing:

  • It is explicitly non-cryptographic and not a security boundary. A host needing a cryptographic digest must hash the same bytes itself.
  • It is not LoadReport::model_id. model_id preserves the authored top-composite @id; export uses a synthetic root, and resumed parameter edits change exported bytes without recomputing model_id.
  • When warnings is non-empty, the unchecked tag would name only the partial survivor document; content_id_complete() refuses that case and reports the exact warning count. The older content_id() method remains only as deprecated compatibility behavior and should not be used to mint version identities. The checked behavior is pinned by crates/oce-api/tests/export_cxf.rs.

The import side

This page is about export. The normative contract for what nested-composite shapes import accepts and rejects — how S231:containsBlock hierarchies flatten, which shapes reject, and the machine-readable composite/<rule-id>: message tags an emitter can match on — is cxf-composite-subset.md in this directory. Read that one if you are writing a CXF generator.

Composite nesting is bounded at 64. Boundary lowering is iterative and separately rejects paths beyond 64 non-top isConnectedTo hops or documents beyond 65,536 target examinations or 8 MiB of aggregate target-IRI bytes within boundary walks. These are engine acceptance bounds, not CDL semantics. Hosts must still bound input bytes before JSON deserialization when accepting untrusted documents.

For which CDL classes and G36 sequences exist on the other end of that pipe, see cdl-coverage.md.

The CXF composite-subset import contract

This is the canonical statement of which nested-composite CXF shapes the Open Control Engine import accepts and rejects. It is written for the author of an external CXF-emitting tool who has never read the engine source. The behavior described here is what oce_cxf::import_cxf — and therefore Engine::load_cxf in the oce-api facade — enforces; every rule is pinned by tests against the checked-in conformance corpus (see Testing your emitter).

Scope: this contract covers the composite subset of CXF lowering — how S231:containsBlock hierarchies flatten, which hierarchy shapes reject, and the document-wide rejection of active array-valued connector and block-instance nodes. Other leaf-block semantics, connector typing, and post-lowering validation (unit checks, single-assignment) have their own diagnostics and are out of scope here.

Resource bounds are part of this engine’s accepted subset, not CDL semantics. containsBlock nesting is limited to 64. Boundary lowering is iterative and permits at most 64 non-top boundary hops per path, 65,536 target examinations, and 8 MiB of aggregate target-IRI bytes across boundary walks in the document. Exceeding a boundary limit returns MalformedDocument without constructing a partial flat graph. Ordinary direct leaf wiring does not consume the boundary-work budgets.

How rejections are reported

Every rejection is a diagnostic with three parts:

  • a DiagCode (a stable kebab-case code string, e.g. malformed-document),
  • an optional subject (the @id of the offending node, where one exists),
  • a message.

The rejecting rules below — except Rule 6, whose rejections are generic diagnostics with no tag — are contract rules: their messages begin with a stable machine-readable tag of the form composite/<rule-id>: (note the single trailing space after the colon). Match rejections with message.starts_with("composite/<rule-id>: "); the rest of the message is human prose and may change. The tag-to-code mapping is published twice — in the rule catalog table below and as the machine-readable artifact tools/reference-catalog/oce-cxf.composite-rules.json — and a drift-guard test holds this document, the artifact, and the emitting code to the same catalog identities.

Rules 1 and 3 are non-rejecting classification and ordering rules. They carry no DiagCode, no message tag, and no catalog entry — there is nothing to match, because they never fail. They are stated here because an emitter that misunderstands them produces a model that imports cleanly but means something else.

JSON-LD fragments below are illustrative: they elide @context, connector nodes, and unrelated keys. Complete importable documents live in the conformance corpus.

Active nodes

Source profiles may mark components and connectors conditional (S231:isConditionalComponent: true plus an S231:conditionalExpression guard). At load time the guard is evaluated against the owning composite’s own grounded parameters and constants; a false guard makes the node — and, recursively, its inputs, outputs, parameters, constants, and contained blocks — inactive. Everything else is active.

Rules 3, 4, 5, and 7 operate on active nodes only: inactive children are not traversed (their whole subtree drops out of the leaf order), inactive parameters are not grounded — an inactive array-valued parameter does not reject — and banned Modelica keys or S231:isReplaceable on an inactive node are tolerated. Root classification (rule 2) does not consult activity. Connections are not exempt: an active connection into or out of an inactive node rejects with the generic inactive-conditional-node diagnostic — prune conditional structure so inactive nodes take their connections with them.

Rule 1 — Composite discriminator (non-rejecting)

A node is a runtime composite if and only if its S231:containsBlock list is non-empty AND its @type does not resolve to a registered leaf block class. A node whose @type resolves to a registered class is a leaf even when it carries S231:containsBlock — the carve-out that keeps protected implementation children out of composite classification. Classification never rejects; rule 1 has no DiagCode.

@type resolution operates on the @context-expanded form: the token is first expanded against the document @context (a CURIE with a declared prefix becomes its absolute IRI; anything else stays as written — a typing token is never refused), then take the fragment after the last # (the whole string when there is no #), strip a leading Buildings.Controls.OBC., and look the remainder up in the native block registry (published as tools/reference-catalog/oce-blocks.registry-manifest.json). So http://example.org#Buildings.Controls.OBC.CDL.Reals.Add — or the compact ex:Buildings.Controls.OBC.CDL.Reals.Add under "ex": "http://example.org#" — resolves to the registered class CDL.Reals.Add and is a leaf; S231:Block (expanded, http://data.ashrae.org/S231P#Block) or a vendor class path resolves to nothing and — with children — is a composite. Note the contrast with rule 7: identities and typing tokens expand; property KEYS match by suffix — the banned-key and array-marker matching below stays on the term after the last :, #, or /, whatever the spelling.

{ "@id": "…#M.sub", "@type": "http://…#Vendor.Sequences.ScaleAndForward",
  "S231:containsBlock": { "@id": "…#M.sub.gain" } }

is a runtime composite, while

{ "@id": "…#M.con", "@type": "http://…#Buildings.Controls.OBC.CDL.Reals.Sources.Constant",
  "S231:containsBlock": { "@id": "…#M.con.protected" } }

stays a leaf: it imports as a normal CDL.Reals.Sources.Constant block and the protected child is elided (corpus fixture accepted/registered_leaf_carveout.jsonld).

Rule 2 — Single top root (rejects: composite/root-count)

After classification, exactly one runtime composite must be unreferenced by any other runtime composite’s S231:containsBlock. Zero candidates, or two or more, reject with composite/root-count (DiagCode malformed-document). With two or more candidates the message enumerates every candidate in @graph order and the first candidate is the subject. With zero candidates the diagnostic carries no subject — there is no candidate to name.

Normative consequence: a pure composite containsBlock cycle (every composite referenced, no root at all) classifies as zero roots and is reported as composite/root-count, never as composite/contains-cycle (corpus fixture rejected/pure_cycle.jsonld). The cycle detector of rule 4 only runs below a valid single root.

{ "@id": "…#M",  "@type": "S231:Block", "S231:containsBlock": [ … ] },
{ "@id": "…#M2", "@type": "S231:Block", "S231:containsBlock": [ … ] }

rejects with subject …#M and a message ending found 2 candidate roots: …#M, …#M2 (corpus fixture rejected/multi_root.jsonld).

Rule 3 — Nesting traversal order (non-rejecting)

Active composite children lower depth-first in S231:containsBlock array order; inactive children are skipped along with their entire subtrees. The flat leaf order — and with it every dense block id, connector id, and declaration order in the imported model — derives from that traversal. Non-rejecting; no DiagCode.

Array order is significant: reordering a containsBlock array reorders the imported model’s block and connector ids, which changes goldens, point ids, and any consumer keyed on dense ids. An emitter must produce containsBlock arrays in a deterministic order of its own choosing and keep that order stable across exports of the same source.

The full order contract: 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. Two carve-outs: the boundary-input elision vector (external_inputs) and the pass-through pair list 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); and a S231:hasInstance member array’s order is load-bearing for nothing — derived ports bind by name against the class signature, synthesized connectors order by (owner @graph position, class-signature position), and classified parameter members append in class-signature order, so permuting the array moves no ConnectorId, no decl_order, and no param row. Neither array order nor node position is a stable identity: key by authored name, never by position.

"S231:containsBlock": [ { "@id": "…#M.sub" }, { "@id": "…#M.post" } ]

lowers …#M.sub’s leaves (depth-first) before …#M.post.

Rule 4 — Acyclicity (rejects: composite/contains-cycle)

The containsBlock graph reachable from the root through active children must be acyclic. A cycle rejects with composite/contains-cycle (DiagCode malformed-document); the message names all participants in traversal path order, ending at the re-entered id, and the re-entered id is the subject.

Normative consequence: one diagnostic per re-entry. A cycle reachable via k distinct paths yields k truthful path-ordered diagnostics; a consumer must not assume one diagnostic per structural cycle (corpus fixture rejected/diamond_cycle.jsonld: one cycle, two paths, two diagnostics). The degenerate self-loop (A contains A) reports the two-entry list …#A -> …#A (corpus fixture rejected/self_loop.jsonld).

{ "@id": "…#R", "@type": "S231:Block", "S231:containsBlock": { "@id": "…#A" } },
{ "@id": "…#A", "@type": "S231:Block", "S231:containsBlock": { "@id": "…#B" } },
{ "@id": "…#B", "@type": "S231:Block", "S231:containsBlock": { "@id": "…#C" } },
{ "@id": "…#C", "@type": "S231:Block", "S231:containsBlock": { "@id": "…#A" } }

rejects with subject …#A and message tail …#A -> …#B -> …#C -> …#A (corpus fixture rejected/reachable_cycle.jsonld).

Rule 5 — Parameter-scope inheritance (rejects: composite/array-parameter, composite/declaration-cycle, composite/duplicate-declaration)

A composite’s active S231:hasParameter and S231:hasConstant bindings form one mutual scope: every binding’s value may reference any sibling of either kind, declared earlier or later — declaration array order carries no meaning for the composite’s own scope. A document that loads does so with a byte-identical imported model and an identical diagnostic vector under any permutation of the two arrays; a document these rules refuse refuses under every permutation with the same rule ids and the same participant sets — only a diagnostic’s subject may relocate, because subjects follow the chained declaration order that permutation changes. Inside an own binding’s value, an own local name always denotes the own sibling, shadowing a same-named binding of an enclosing composite; only names with no own binding fall through to the enclosing scope chain (innermost composite first). The grounded scope is inherited by every child composite and leaf. Identifier-shaped text inside an expression String literal is data, not a sibling reference: S231:value: "\"b\"" creates no dependency on an own declaration named b. Three shapes reject:

  • A reference cycle among a composite’s own bindings — including the length-1 self-reference x = "x * 2", which is never an enclosing read — rejects with composite/declaration-cycle (DiagCode malformed-document): one diagnostic per distinct cycle per chain evaluation. Like contains-cycle, a composite reachable via multiple containsBlock paths is evaluated once per path (enclosing scopes can differ per path), so the same cycle can surface once per visit — consumers must not assume one diagnostic per structural cycle document-wide. Subject = the participant earliest in the params-then-constants chained declaration order; the message’s arrow list is the participant ring in chained declaration order closing on the first (…#M.a -> …#M.b -> …#M.a) — not the discovered edge path. Bindings outside the cycle still ground (maximal progress); cycle members keep their own binding’s name — masking a same-named enclosing binding — but are absent from the scope, so a reference to one fails with a generic grounding-failed.
  • One local name declared twice in one composite’s own chain rejects with composite/duplicate-declaration (DiagCode malformed-document): one diagnostic per occurrence beyond the first in chained order (three declarations of one name emit two), subject = that later occurrence, message naming it and the first occurrence’s @id. The first occurrence stays a normal binding.
  • An array-valued (S231:isArray: true) active parameter or constant on a composite rejects with composite/array-parameter (DiagCode non-subset-construct); the subject is the parameter node.

Leaf members are a different level and keep their order-sensitive contract: a leaf member’s value reference resolves enclosing-first — when the name is bound both in the enclosing scope chain and by an earlier sibling member, the enclosing binding wins, and within each region the most recently grounded binding shadows earlier ones (issue #239) — so a leaf member’s forward reference to a sibling member still fails grounding. A leaf dimension reference (S231:sizeOfDimensions) still resolves nearest-wins over the undivided scope, so there a sibling binding shadows a same-named enclosing one — when the sibling is grounded earlier; member array order still decides the dimension reading (values are order-invariant under member order, dimensions are not). When the two readings of one name disagree on an array’s shape, the element-count divergence refuses with grounding-failed (both counts in the message); a value divergence with a matching count is silent, exactly like the scalar path.

Conditional-guard specialization evaluates guards against the same own-scope semantics through the same mechanism, so guard decisions are equally order-independent. The specialization pass also grounds leaf declaration chains that carry conditional members; on that pass, generic grounding machinery is non-emitting, and the two tagged rules above apply to composite chains only — a leaf chain’s bindings are member modifications (the leaf level described above), so a cycle or duplicate among them produces no tagged finding there: the participants simply fail to ground in the guard scope. A composite-chain defect visible to both passes is reported once, from the lowering view; a composite chain only the specialization pass grounds (for example one pruned by a false guard) still surfaces through the two tagged rules; and a guard that genuinely cannot evaluate refuses through the guard’s own diagnostics — never as a bare grounding-failed. A leaf with a legal array parameter plus a conditional member therefore loads (corpus fixture accepted/leaf_array_parameter_conditional_member.jsonld), and so does the leaf identity-modification idiom — a leaf parameter samplePeriod = "samplePeriod" reading the same-named enclosing composite parameter, beside a conditional member (corpus fixture accepted/leaf_identity_parameter_modification.jsonld, the member value grounding enclosing-first per the leaf rules above); the specialization model itself — what a guard means and how pruning propagates — is unchanged.

References use the local name — the segment after the last . of the binding’s @id — so two same-named bindings at different nesting levels shadow (own-scope-wins for the composite’s own bindings, enclosing-first for leaf member values, nearest-wins for dimensions), while two same-named bindings in one composite’s own chain reject under composite/duplicate-declaration. Give bindings distinct local names unless shadowing is intended; the corpus does. Element names minted by leaf array expansion (k[2]k_1, k_2) shadow like any sibling binding: a later member’s value reference to k_1 reads a same-named enclosing binding when one exists, not the minted element, while a same-named sibling parameter collides and refuses (ArrayFlattenCollision). Because grounded values feed block construction, own-scope resolution can change what a document means, so a constructed document that imported under the older order-sensitive reading can refuse under this rule (a cycle or a duplicate) or ground differently (a forward or shadowed sibling reference). Measured against the pre-change base (43d8a13, which held 147 checked-in CXF documents: 103 crate fixtures plus 44 vendored modelica-json translations), all 103 crate documents are byte-identical in import outcome under the rule; 12 vendored documents — every one still refusing on unrelated grounds — shed 48 grounding-failed diagnostics in exactly the two ruled classes (forward sibling references now grounding, and specialization-pass generic machinery going non-emitting), with zero new diagnostics anywhere. The tree now holds 197 documents (153 crate plus 44 vendored; the growth is the conformance fixtures the declaration-scope and hasInstance-interface rules added). The wider reach exists off-corpus.

{ "@id": "…#M", "@type": "S231:Block",
  "S231:hasParameter": [ { "@id": "…#M.kBase" }, { "@id": "…#M.kTop" } ], … },
{ "@id": "…#M.kBase",      "S231:value": { "@value": "0.25", "@type": "…#double" } },
{ "@id": "…#M.kTop",       "S231:value": "kBase + 0.25" },
{ "@id": "…#M.sub.kInner", "S231:value": "kTop" }

grounds the sibling reference kTop to 0.5; the child composite’s constant kInner (declared under …#M.sub via S231:hasConstant) inherits it through the parent scope, and the leaf parameter "S231:value": "kInner" grounds the chain’s end — the kBase → kTop → kInner → gain.k chain of corpus fixture accepted/minimal_nested.jsonld.

Rule 6 — Boundary elision (rejects: generic diagnostics)

Composite boundary connectors are lowered away. A boundary input rewires to the child connectors it drives; the top composite’s boundary inputs surface as the imported model’s external inputs. A boundary output of a non-top composite is followed through to its final targets. A boundary output of the top composite is elided outright: its @id appears on no connector in the flat model, and a leaf output whose only target is a top boundary output ends with no connection at all — the driving leaf connector remains, carrying no source @id. The composite node itself never becomes a runtime block. Boundary elision rejects invalid direction (DirectionMismatch), mismatched value types (TypeMismatch), unresolved endpoints or missing boundary nodes (UnresolvedReference), and boundary datatype declarations that cannot be derived (MalformedDocument).

CXF §8.2 permits either endpoint of a connection to carry S231:isConnectedTo; subject position does not encode signal direction. Before boundary elision, the importer therefore derives each endpoint’s source/sink role from its owning block, its port direction, and the peer’s location inside or outside that owning composite, then re-anchors reverse-spelled edges on their canonical driver. This is an orientation rule over the existing connector and containment data, not a new runtime model. Edges whose roles cannot be derived — a dangling or non-connector peer, a port claimed by two owners, non-tree containment — as well as same-polarity (contradictory) pairs and reverse spellings whose canonical driver has neither a node nor a synthesized connector identity, are left exactly as authored and reject under the existing Rule 6 diagnostics when the relation survives lowering. If boundary elision would erase an active relation that cannot be kept or swapped, the importer defers a direction diagnostic until the bounded boundary walk succeeds. This applies whether the boundary is the authored source or target. An active elided boundary source targeting an inactive node similarly retains the ordinary inactive-node refusal. A node-less output listed by hasInstance, or padded from an omitted declared output, can be a canonical driver; its lowered edges follow authored sources in derived connector order. Re-anchoring never invents or silently removes a relation: an input driven twice still rejects. Authoring the same relation from both endpoints collapses when either spelling required re-anchoring. In particular, both directions between one composite’s input and output denote one pass-through relation, not a boundary cycle.

The boundary walk preserves canonical target order and duplicate multiplicity below its resource limits. It checks an active-path cycle or missing boundary node before the hop limit, so those shapes retain their UnresolvedReference outcome at the boundary. The target-examination budget counts inactive, terminal, dangling, and cycle-revisit targets before classification; it prevents a shallow branching graph from expanding without bound even when every path is short. The aggregate byte budget also charges an authored target that orientation turns into a synthesized canonical driver, before the completed target lists are cloned. Resource-limit diagnostics omit the attempted target subject to avoid an additional untrusted IRI copy at refusal. Deferred diagnostics, including the inactive-target variant, also omit their subject. If bounded expansion repeats one missing endpoint through ordinary or boundary-specific orientation, the importer emits one unresolved-reference diagnostic for that endpoint rather than copying its subject once per edge.

What an emitter must NOT expect to survive import: composite nodes as blocks, boundary connector hops, nesting depth, or the authored bytes. The import-parity boundary is flat by contract: re-importing an exported document reproduces the flat ModelGraph — never the original nested/authored bytes. Round-tripping a nested document through the engine and comparing bytes will always “fail”; compare imported models instead.

{ "@id": "…#M.u",     "@type": "S231:RealInput",
  "S231:isConnectedTo": { "@id": "…#M.sub.u" } },
{ "@id": "…#M.sub.u", "@type": "S231:RealInput",
  "S231:isConnectedTo": { "@id": "…#M.sub.gain.u" } }

imports as one external input feeding …#M.sub.gain.u directly; …#M.sub.u is gone.

Rule 7 — Rejected constructs (rejects: composite/banned-modelica-key, composite/replaceable, composite/array-connector, composite/array-instance, composite/vector-port-instance, composite/unsupported-instance-member, composite/colliding-member-identity)

Six Modelica construct keys are banned on any active node: redeclare, constrainedby, extends, extendsFrom, moSource, modelicaSource. Matching is on the term after the last :, #, or / in the key, so the bare (extends), prefixed (S231:extends), and absolute-IRI (http://data.ashrae.org/S231P#extends) spellings all reject. A banned key rejects with composite/banned-modelica-key (DiagCode non-subset-construct); the subject is the owning node and the message names the key exactly as authored.

S231:isReplaceable: true on any active node rejects with composite/replaceable (DiagCode unresolved-polymorphism). The subject is the replaceable node. Replaceable components must be resolved to concrete classes before export.

An active connector — any node referenced by an active node’s S231:hasInput or S231:hasOutput list, anywhere in the document, whether or not the referencing node is reachable from the top-level root, or any member of an active derivation-shaped node’s S231:hasInstance list (a containsBlock referent that is not a runtime composite, declares neither port list, and carries a member list) — rejects when it carries an array marker. The member source matches the existing sources on reachability and is narrower only in shape: an orphan node’s list and a runtime composite’s list contribute nothing, where the existing sources take any active node’s list at all; the scan stays reference-based and class-independent on both. The markers: S231:isArray: true or any S231:sizeOfDimensions. Marker keys match on the term after the last :, #, or /, like the banned-key matching above, so absolute-IRI spellings reject too. The rejection is composite/array-connector (DiagCode non-subset-construct) with the connector node as subject. Flatten connector arrays to one connector per element.

An active block instance — any node referenced by an active node’s S231:containsBlock — rejects under the same array markers as composite/array-instance (DiagCode non-subset-construct) with the instance node as subject. Flatten block arrays to one instance per element. A node referenced as both connector and instance receives both rejections, and a S231:hasParameter listing does not exempt it. Array-valued parameters on a composite are governed by Rule 5; an array-valued parameter on a leaf block is preserved and expanded, not rejected. Inactive conditional subtrees are invisible to these checks.

Three rules govern the hasInstance interface derivation (an instance declaring neither hasInput nor hasOutput and carrying a S231:hasInstance list derives its interface from the list; a node declaring either port list keeps its own interface). Each refusal skips the instance’s derivation whole — the tagged rejection replaces the generic arity mismatch rather than doubling it:

  • composite/vector-port-instance (DiagCode non-subset-construct, subject the instance node, one per instance): the resolved class publishes no declared port names — its port count is a function of a parameter, so one member stands for N scalar connectors and this subset derives scalar interfaces only. A document declaring the same class’s ports explicitly through hasInput/hasOutput is untouched.
  • composite/unsupported-instance-member (DiagCode non-subset-construct, subject the member IRI, one per offending member): a member outside its owner’s namespace (not <owner>.<oneSegment>), a member that is itself a block instance, or a member whose local name is neither a declared port nor a declared parameter of the class.
  • composite/colliding-member-identity (DiagCode non-subset-construct, subject the colliding IRI, one per collision): a synthesized connector identity that is already an @graph node or is minted twice for one owner, or a parameter name declared twice for one instance — across its classified members or against its own hasParameter/hasConstant list.
{ "@id": "…#M.c2", "@type": "…MultiplyByParameter",
  "S231:isReplaceable": true,
  "redeclare": "…#SomeBase", … }

rejects twice: once under composite/banned-modelica-key naming `redeclare`, once under composite/replaceable (corpus fixtures rejected/banned_key_*.jsonld, rejected/replaceable.jsonld).

Rule catalog

The contract identities, mirroring tools/reference-catalog/oce-cxf.composite-rules.json (catalog order). Rules 1 and 3 do not appear here because they are non-rejecting. Rule 6 has no composite/ rule identity; its rejections are generic diagnostics.

RuleRule idDiagCodeMessage prefix
2root-countmalformed-documentcomposite/root-count:
4contains-cyclemalformed-documentcomposite/contains-cycle:
7replaceableunresolved-polymorphismcomposite/replaceable:
7banned-modelica-keynon-subset-constructcomposite/banned-modelica-key:
5array-parameternon-subset-constructcomposite/array-parameter:
7array-connectornon-subset-constructcomposite/array-connector:
7array-instancenon-subset-constructcomposite/array-instance:
5declaration-cyclemalformed-documentcomposite/declaration-cycle:
5duplicate-declarationmalformed-documentcomposite/duplicate-declaration:
7vector-port-instancenon-subset-constructcomposite/vector-port-instance:
7unsupported-instance-membernon-subset-constructcomposite/unsupported-instance-member:
7colliding-member-identitynon-subset-constructcomposite/colliding-member-identity:

Every message prefix is composite/<rule-id>: — colon, then one trailing space (U+0020), which markdown table cells cannot render unambiguously. Match with starts_with("composite/<rule-id>: "), trailing space included. The drift-guard test (crates/oce-cxf/tests/composite_contract_doc.rs) checks the Rule id and DiagCode columns of this table against the catalog artifact and derives the prefix from the rule id; the prefix column above is display-only.

Generic diagnostics

Three diagnostics that can accompany or replace a contract rejection are shared import machinery, deliberately untagged (no composite/ prefix, no catalog entry):

  • unresolved-reference — a containsBlock child, parameter node, composite @id, or a connection/boundary reference naming a hasInstance member of an instance whose interface was not derived (an unregistered class, or a composite/vector-port-instance refusal), referenced but not resolvable. A classifiable member itself is never reported under this code: a node-less port member becomes a synthesized connector and a node-less parameter member refuses as grounding-failed at derivation.
  • grounding-failed — a parameter value that cannot ground: a missing S231:value (values are required — Ground mode, on the hasParameter/hasConstant route and the hasInstance member route alike), an unknown identifier (including a reference to a cycle-refused sibling, or a leaf member’s forward reference to a later sibling member), or an expression error.
  • conflicting-interface-declaration — an instance declaring hasInput/hasOutput beside a hasInstance list whose class-declared names its own routes do not carry (a warning; compared one-directional, list minus own), or one parameter name valued differently on the two routes (an error — two values for one name state a contradiction).

They are not contract rules because they do not describe a composite shape; they fire anywhere in the import pipeline. Match them by DiagCode, not by message. The conditional-pruning rejection inactive-conditional-node (see Active nodes) is generic machinery in the same sense — untagged, no catalog entry.

Acceptance preconditions

A document that satisfies rules 1–7 must also meet the general import preconditions before it loads warning-free:

  1. Exactly one top composite, and its @type must be unregistered (S231:Block works). A registered leaf standing alone — even one with containsBlock — classifies as zero composites and rejects under rule 2 with zero candidates.
  2. Every parameter and constant carries a S231:value (missing values are grounding-failed). A parameter declared through a S231:hasInstance member is covered too: a valueless or node-less member named after a declared parameter refuses the same way.
  3. Every leaf @type resolves to a registered block class (else class-not-found).
  4. Accepted means warning-free: the corpus drivers assert an empty diagnostic report, not merely a non-error one.

Testing your emitter

The engine tests itself against a checked-in conformance corpus; point your emitter’s output at the same files and drivers.

  • Corpus: crates/oce-cxf/tests/fixtures/composite_contract/{accepted,warned,rejected}/*.jsonld, one fixture per contract behavior, indexed in the corpus README.md. The warned/ category holds documents that load successfully with a pinned advisory vector — untagged import machinery such as undriven-boundary-output, not a composite-shape rule.
  • Accepted-fixture goldens (byte-exact ModelGraph renders): crates/oce-cxf/tests/fixtures/golden/composite_contract_*.modelgraph.txt.
  • Resolver-layer drivers: cargo nextest run -p oce-cxf --test composite_contract_corpus
  • Full-pipeline (Engine::load_cxf) drivers: cargo nextest run -p oce-api --test conformance composite_contract
  • Doc/catalog drift guard: cargo nextest run -p oce-cxf --test composite_contract_doc

To check a document your tool produced:

  • Rejected: drop it under rejected/, add a README index row, and add its expected (DiagCode, subject, message) triples to the pin tables in both drivers (expected_rejections() in composite_contract_corpus.rs, COMPOSITE_REJECTIONS in crates/oce-api/tests/conformance.rs).
  • Warned: drop it under warned/, add a README index row, add its exact complete warning vector to expected_warnings() in composite_contract_corpus.rs, and add its ordered warning triples to the composite_warnings() table in crates/oce-api/tests/conformance.rs — the end-to-end warned driver is table-driven and its on-disk listing pin is taken over that table, so a warned fixture cannot land half-wired.
  • Accepted: drop it under accepted/, add a README index row, add the pair to the ACCEPTED table in composite_contract_corpus.rs — the golden filename convention is tests/fixtures/golden/composite_contract_<fixture-stem>.modelgraph.txt — then bless the golden with OCE_BLESS=1 cargo test -p oce-cxf --test composite_contract_corpus accepted_fixtures_match_their_blessed_modelgraph_goldens_byte_exactly and review the blessed bytes before committing. The oce-api driver picks the new file up automatically and requires the warning-free load.

The corpus completeness tests fail on any unindexed or unpinned fixture, so a fixture cannot land half-wired.

The 44 vendored modelica-json translations under third_party/modelica-buildings-cdl/cxf/ are additionally held to a per-document characterization capture (crates/oce-cxf/tests/vendored_corpus_delta.rs): per-DiagCode counts, severities, and duplicate diagnostic triples, re-blessed only deliberately. The hasInstance interface derivation moved that capture in both directions, every increase declared: 904 arity mismatches and 2,579 unresolved references removed, 30 arity mismatches replaced by composite/vector-port-instance, single-assignment arriving at 57 (31 undriven and 8 multiply-driven derived inputs, plus 18 multiply-driven declared boundary outputs assessed for this dialect for the first time), grounding-failed rising 62 → 204 as member values and member connector bounds ground for the first time on class-translation documents, and inactive-conditional-node rising 11 → 44 because pruning now reaches a conditional instance’s listed members as well as its @graph nodes (32 members still carrying active connections, 12 connection targets).

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.

CI and the gate

For contributors, and for anyone looking at a green check mark on a pull request and wondering what it proves. The short answer is: less than you would assume. The split is deliberate, and it is easy to misread in the dangerous direction.

One command, one source of truth

.agents/gate.sh is the only place the gate’s command list is written down. Every other document in this repo — including this page — points at it rather than restating it, because nine divergent prose copies existed before the script was written and two of them were materially weaker than CI (.agents/gate.sh:3-7). There are two invocations:

bash .agents/gate.sh        # light — mirrors the per-PR gate
bash .agents/gate.sh full   # full  — adds the workspace suite and doctests

CI does not merely mirror that script, it executes it: the gate (light) job at .github/workflows/ci.yml:293-338 and gate (full) at .github/workflows/release-gate.yml:334-349. So every command in the script gates a pull request whether or not ci.yml also runs it as its own job. Read that as coverage, not as parity, and note that the implication does not run the other way: gate (light) is bash .agents/gate.sh plus any steps of its own. The Quickstart-executes step was exactly that for a while — a required check no local run of the script performed — and an earlier revision of this paragraph cited the job as ci.yml:256-270, stopping one line short of it. Nothing verifies mechanically that the two files still list the same commands. That check was attempted and withdrawn, and ci.yml:293-321 records why — every design either compared argv strings that RUSTFLAGS=--cap-lints=allow leaves byte-identical while neutering clippy, or reimplemented enough of GitHub’s if:/needs:/matrix semantics to become its own untested gate.

The script’s steps group into: formatting, file-size and secret hygiene; the repository-invariant gates (the default build links no database or async runtime; the golden generator cannot bless its own output as the oracle); behavior fixtures for those gates, because a gate that cannot fail is not a gate; build, clippy and rustdoc under -D warnings; supply-chain checks; the determinism subset; and two fixture input-hygiene audits. A failing step never aborts the run, so one round trip reports every problem instead of the first (.agents/gate.sh:41-56).

Dev-light, release-heavy

A green PR is not evidence that the change’s own tests pass.

The per-PR gate into development runs engine tests for oce-api, oce-blocks, and oce-expr only. That is the determinism-matrix job: two runners, ubuntu-latest and ubuntu-24.04-arm (ci.yml:148-156), each running that three-crate subset twice — once under debug codegen, once under release codegen (ci.yml:167-176). No other crate’s test suite runs. Each architecture emits populated revision-1 portable and target-bound state vectors. The matrix compares both across codegen profiles; a dependent job requires the portable files to match and the target-bound files to differ across architectures, then parses and refuses the arm64 target-bound bytes on x86_64. The gate script runs the test commands locally and adds two named oce-cxf test binaries, which are input hygiene rather than engine coverage: the port-order audit sweeps 47 CXF documents, of which 46 are Guideline 36 catalog fixtures and one is a resolver contract; the structural oracle compares the catalog fixtures it can pair with vendored modelica-json translations (.agents/gate.sh:126-154). That oracle compares document structure — instances and undirected edges — not simulated behavior.

Everything else waits for the release gate. A change confined to oce-cxf, oce-store, oce-conformance, or oce-diag can show a fully green PR having executed none of its own tests. Before claiming tests pass, run bash .agents/gate.sh full first-hand and read the tail.

Draft pull requests run nothing

Not a reduced subset — nothing. All fifteen jobs in ci.yml are conditioned on github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch', from ci.yml:55 through ci.yml:324. A draft PR with no checks looks a lot like a PR with no failing checks. Confirm the checks actually ran.

cargo-deny is not skippable, but advisories do not gate a PR

The standalone cargo-deny job in ci.yml:281-291 is conditional on a manifest change, computed by the paths filter at ci.yml:64-69. That conditional does not make the check skippable: the gate script runs cargo-deny’s bans, licenses and sources checks unconditionally (.agents/gate.sh:110-114), and CI runs the script. Leaving manifests alone does not dodge it.

advisories is a different story, and the carve-out belongs next to the claim. It is deliberately excluded from the script — it needs network access and a writable advisory database, neither of which a sandboxed lane has. It runs daily in advisories.yml (advisories.yml:11-14, 38) and on release PRs (release-gate.yml:310-322). advisories.yml has no pull_request trigger at all, so a PR into development that introduces a dependency with a known RustSec advisory merges green and is caught by the next scheduled run, not by its own gate.

What the release gate adds

release-gate.yml fires on developmentmain PRs, on manual dispatch, and on a daily cron against the development tip (release-gate.yml:46-54). It is disjoint from ci.yml by base branch, so the two never both fire on one PR. It re-runs the light correctness gates against the release tip and adds four things:

StepWhat it coversWhere
workspace nextestevery unit and integration test in all 17 cratesrelease-gate.yml:109-110
workspace nextest, release codegenrelease panic-freedom, debug_assert paths stripped; inherited ci-release runner policyrelease-gate.yml:114-115
cargo test --docdoctests — nextest cannot run them, so this is a separate steprelease-gate.yml:117-118
two cargo public-api surface gatesexact public API text for oce-api and oce-storerelease-gate.yml:136-153

--no-tests=fail is explicit on the nextest steps: a run that discovers zero tests hard-fails rather than passing, which catches tests that silently stop compiling or being found.

Nextest policy and reports

Local setup and CI pin cargo-nextest 0.9.143; .config/nextest.toml also declares that version as both required and recommended, so an older local binary exits before testing. The default profile is fail-fast. Automated debug runs use ci; release-codegen runs use ci-release, which inherits the same retries, timeout, leak, and reporter policy instead of copying it. The two public-API runs inherit that policy through separate child profiles because their nested nightly builds need a longer per-test timeout and separate reports.

Retries are zero and a flaky pass is still a failure. Ordinary tests terminate after 120 seconds; the public-API surface tests allow 10 minutes for their nested nightly rustdoc builds. A run stops after 15 minutes, and a child process retaining inherited output handles for more than two seconds fails as a leak. CI writes Jenkins-compatible JUnit XML to target/nextest/<profile>/junit.xml and uploads the determinism-matrix and release-suite reports for 14 days, including failure output and ignored tests.

Partitioning and build archives are deliberately off: the full test execution takes seconds while compilation dominates, and each determinism runner must execute the complete selected set under its own architecture and codegen mode. Experimental record/replay is also off in CI; enabling a feature that nextest still marks unstable would make the gate depend on a non-stable format. Test groups and thread reservations remain available when measurement identifies a shared resource or heavy test; none is known today.

The public-api baselines are the strongest stability evidence in this repo. They are checked-in text files — crates/oce-api/tests/public-api.txt (1374 lines) and crates/oce-store/tests/public-api.txt (1230 lines) — and the tests at crates/oce-api/tests/public_api.rs and crates/oce-store/tests/public_api.rs diff the crate’s real surface against them, so any unintended addition, removal or signature change fails the gate rather than shipping. Two env vars interlock to keep the gate honest: OCE_PUBLIC_API_NIGHTLY arms it and names the pinned nightly to shell out to, and OCE_REQUIRE_SURFACE_CHECK=1 turns a missing nightly into a hard panic instead of a silent skip, so disarming the gate turns it red, never green (release-gate.yml:127-153). The two crates run as separate steps on purpose: merging the package selectors would let one surviving crate hide the other’s vanished test.

What CI cannot observe

  • Operating systems other than Linux. Every runs-on: in all five workflows — ci.yml, release-gate.yml, advisories.yml, release.yml, and docs-pages.yml (per-PR on docs/**, README.md, scripts/docs/**, and site/**) — is ubuntu-latest or ubuntu-24.04-arm. Cross-architecture is covered — x86_64 and arm64, debug and release. macOS and Windows are not built or tested anywhere.
  • Anything derived from git history. No workflow sets fetch-depth, so actions/checkout@v4 takes its default of a single commit. A check that needs history cannot run in CI. The visible consequence: golden provenance records bind to a content digest of the checked-in bytes rather than to the engine revision that produced them (crates/oce-cxf/tests/golden_provenance/mod.rs:3-5).
  • Line-ending behavior. .gitattributes:1 pins * text=auto eol=lf, but an ubuntu-only CI never performs a CRLF checkout, so that normalization is asserted by git configuration and exercised by no test. Goldens here are compared bit-exactly, which is precisely where a stray \r would show up.

The script says the rest itself, in its closing report (.agents/gate.sh:180-207): a green local run does not prove the cross-arch determinism matrix passes (one machine cannot reproduce it), does not prove the two cargo public-api surface gates pass (they need the gate-only nightly), does not prove cargo deny check advisories passes, and does not prove that the script and ci.yml still agree. A light run additionally does not prove the workspace suite or doctests pass, because the per-PR gate does not run them.

Publishing

release.yml is decoupled from both gates and from each other’s triggers. Pushing a v* tag runs verify only — tag/version match, fmt, clippy, a workspace cargo test, and a full cargo publish --dry-run — with no token and no publish, so a tag can be re-cut safely (release.yml:39-71). Publishing is a separate manual workflow_dispatch into the release GitHub Environment (release.yml:73-87). The crates are not on crates.io yet.

Related: host-responsibilities.md for what the engine deliberately leaves to the embedder, and ../TESTING.md for the testing standard a change is expected to meet.

Benchmarks

Measured tick throughput for the Open Control Engine, recorded per run with the commit, host and method that produced it.

Read this first

These numbers are not gated. Nothing in CI or in .agents/gate.sh re-measures them, so they are a record of what was observed, not a promise about HEAD. A performance figure that no test enforces drifts silently — the same failure mode that got a git SHA deleted from every provenance record in PR #204, and the reason the numbers live here rather than in README.md, where they would be read as a standing claim.

Treat a run below as evidence about that commit on that host. To make a claim about a different commit, re-run it — the method is fully specified, and the harness is reproduced in this file so anyone can.

A gated harness is tracked work. Until it lands, this file is updated by hand.

What is measured

Steady-state cost of Engine::tick() on real G36 fixtures, through the public facade only: Engine::in_memory()load_cxf()tick().

  • Load is excluded from the tick figure and reported separately. Loading happens once; ticking happens forever, so blending them would flatter the result and describe neither.
  • Ticks are run for a fixed wall-clock window after a warmup, so first-touch page faults and any lazily initialised state land in the warmup rather than the measurement.
  • The clock is read in batches, so the timing call is not itself the workload.
  • Time advances monotonically. The engine rejects time regression, so t only ever increases across warmup and measurement.

What is not measured

Stated explicitly, because the gap between these and the numbers below is where a wrong conclusion would come from.

  • Load / parse / resolve throughput beyond the single load_ms column.
  • Tail latency. These are means over millions of ticks. For equipment control the tail usually matters more than the mean, and one property that governs it — whether the evaluator thread allocates during a block tick — is gated separately and much more strictly, by crates/oce-blocks/tests/tick_allocation_census.rs (registry-wide, with a positive control), which runs per-PR. The narrower facade guard in crates/oce-api/tests/tick_purity_tests.rs runs on the release gate, as every oce-api test does. This file is not gated at all.
  • Multi-core or concurrent engines. Single engine, single thread.
  • Any architecture other than the one in the run header. CI runs a determinism matrix across x86_64 and arm64 precisely because one machine does not speak for both.
  • Store-backed input staging under a real durable store. Runs below use the in-memory store.

Runs

2026-07-30 · b5b19e7 · Apple M5 (10 cores) · rustc 1.95.0 · macOS 26.6 · --release

Warmup 20,000 ticks · 2.0 s measurement window · dt = 1.0 simulated second per tick.

fixtureCDL class refsns/tickticks/sec
cooling_only_controller2222,508398,801
multizone_vav_relief_fan_group2282,706369,558
multizone_vav_supply_fan717551,325,349
ahu_economizer121417,095,155
vav_single_zone81446,932,518

Repeated back to back; the two runs agreed within ~2% on the large fixtures and ~6% on the smallest. Measured on an otherwise idle machine — an earlier attempt taken while a parallel build was running produced numbers that were not reproducible, which is why the method above insists on it.

Load, same commit and host — 60 iterations per fixture

Reported as first / median / min in one process, because a single first-call figure carries process-start and page-cache cost.

fixtureKiBfirst msmedian msmin msfirst÷medMiB/s at median
cooling_only_controller4097.842.622.483.0×154
multizone_vav_relief_fan_group3662.552.122.021.2×169
multizone_vav_supply_fan1120.890.680.661.3×159
ahu_economizer160.140.110.101.3×144
vav_single_zone140.110.090.081.3×150

These numbers were measured before #230, which added a working-clone @context expansion pass to ingest; load cost moved roughly +9–12% there, tick cost not at all.

Correction to an earlier revision of this file. It reported 11.0 ms for cooling_only_controller and placed the “runs agreed within ~2%” sentence where it read as covering that column too. Both were wrong. That figure was a single first call in a cold process — it is the first fixture measured, so it absorbed process start and page-cache misses. Measured properly it is 2.62 ms, a 4.2× overstatement. A second process invocation shows the same fixture’s first/median ratio collapse from 3.0× to 1.3× once the page cache is warm, while every other fixture sat at 1.1–1.5× in both runs. The tick figures above were unaffected: they were always taken after a 20,000-tick warmup, which is exactly the discipline the load column lacked.

Observation — load throughput is flat. 144–169 MiB/s across a 29× size range, through the whole load_cxf pipeline: import_cxf (JSON-LD parse and resolve to a flat ground ModelGraph), flatten, §7.10 attribute unification, structural validation, then the build tail (registry, schedule, state, outputs, io, params, store recovery). That is not a JSON parse, so a plain parser MB/s intuition does not apply. The measured revision re-ran pure validation in the build tail. Current load_cxf validates once before entering build_validated_model_in_memory (crates/oce-api/src/engine.rs:231-244), so this historical table includes work the current path no longer performs.

Observation — cost is linear in block count. Across a 28× size range the per-block cost holds at roughly 11 ns (11.3 / 11.9 / 10.6 / 11.8 ns for the four largest). vav_single_zone is the exception at ~18 ns per block, and it is the expected one: at 8 blocks the fixed per-tick overhead (finite/monotonic time checks, output refresh) stops being amortised. No superlinear term is visible, so a sequence twice the size costs about twice as much.

Caveat on “CDL class refs”. That column counts CDL type references in the fixture’s JSON-LD. It is a proxy for scheduled block count, not the count itself, so the per-block figures are indicative rather than exact. The linearity across 28× is the load-bearing part and does not depend on the proxy being tight.

In deployment terms. cooling_only_controller is the largest fixture document in the corpus (409 KiB; 213 blocks and 268 connections after import) and ticks in ~2.5 µs. Building control sequences run at a 1 Hz cadence or slower.

Correction, 2026-07-31. This paragraph previously called it “the largest sequence in the fixture corpus (213 instances, 377 edges)”. Both halves were wrong: relief_fan_group imports to 226 blocks, more than this fixture’s 213 (pinned at crates/oce-cxf/tests/resolve_g36_relief_fan_group.rs:145-146), and 377 was the pre-import isConnectedTo count, not the 268 connections the tick loop actually runs. The measured timing above is unchanged and was not re-run — only the description of what was measured is corrected.

Reproducing a run

The harness deliberately lives outside the repository, in a scratch directory. It is not a crate target, so it ships nothing, adds no dependency to the workspace, and cannot perturb the nextest test count or cargo package. Where a permanent harness should live is the open design question in the tracked follow-up.

mkdir -p /tmp/tickbench/src && cd /tmp/tickbench
cat > Cargo.toml <<'EOF'
[workspace]

[package]
name = "tickbench"
version = "0.0.0"
edition = "2024"

[dependencies]
oce-api = { path = "/ABSOLUTE/PATH/TO/open-control/crates/oce-api" }

[profile.release]
debug = true
EOF

src/main.rs — adjust the include_str! paths to your checkout:

use std::time::Instant;

use oce_api::Engine;

const FIXTURES: &[(&str, &str)] = &[(
    "cooling_only_controller",
    include_str!("/ABSOLUTE/PATH/TO/open-control/crates/oce-cxf/tests/fixtures/g36/cooling_only_controller.jsonld"),
)];

const MEASURE_SECS: f64 = 2.0;
const WARMUP_TICKS: u64 = 20_000;
const DT: f64 = 1.0;

fn main() {
    for (name, cxf) in FIXTURES {
        let load_start = Instant::now();
        let mut engine = Engine::in_memory();
        if let Err(e) = engine.load_cxf(cxf.as_bytes()) {
            println!("{name}: load failed: {e:?}");
            continue;
        }
        let load_ms = load_start.elapsed().as_secs_f64() * 1e3;

        let mut t = 0.0_f64;
        for _ in 0..WARMUP_TICKS {
            t += DT;
            if engine.tick(t).is_err() {
                break;
            }
        }

        let start = Instant::now();
        let mut ticks: u64 = 0;
        loop {
            t += DT;
            if engine.tick(t).is_err() {
                break;
            }
            ticks += 1;
            if ticks % 4096 == 0 && start.elapsed().as_secs_f64() >= MEASURE_SECS {
                break;
            }
        }
        let secs = start.elapsed().as_secs_f64();
        println!(
            "{name}: load {load_ms:.1} ms · {:.0} ns/tick · {:.0} ticks/sec",
            secs * 1e9 / ticks as f64,
            ticks as f64 / secs
        );
    }
}
cargo build --release && ./target/release/tickbench

Run it on an idle machine, and run it at least twice — a figure that does not reproduce is not a measurement.

To measure load rather than ticks, loop the Engine::in_memory() + load_cxf pair on its own (60 iterations is plenty) and report first / median / min, not a single call. The first call in a cold process absorbs process start and page-cache misses; on the largest fixture that inflated the figure by 3× and produced the erroneous 11.0 ms corrected above. Reporting only a median hides the cold cost from anyone who cares about startup, and reporting only a first call is simply wrong — report both. Time Engine::in_memory() inside the measured region and let the engine drop outside it, since teardown is not part of load.

Adding a run

Append a new ### <date> · <short SHA> · <host> · <toolchain> · <profile> section above the previous ones, newest first. Never edit an older run to match a newer one: the value of this file is the trend, and a rewritten history has no trend in it. If a run regresses, record it and say so — that is the entire point of keeping the record.