EMBED
Run the guarded Quickstart
Start with the mechanically reused, compiled example, then read the host safety boundary before connecting equipment.
Embed the engine →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
Parse CXF, freeze a control schedule, and tick CDL control sequences deterministically inside your application—without bringing a daemon, runtime framework, or database.
EMBED
Start with the mechanically reused, compiled example, then read the host safety boundary before connecting equipment.
Embed the engine →CONTRIBUTE
Understand the development and release gates, what a green check proves, and the testing standard expected of every change.
Contribute to the engine →The documentation separates deterministic self-output traces, independent signal oracles, structural checks, and work that is still deferred.
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.
Reference documentation for Open Control Engine. The project README is the front door; these pages are the detail behind it.
| Page | Read it when you want to know |
|---|---|
| Architecture | How 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 evidence | What has actually been proven about this engine, what has not, and which checks are deliberately not running |
| CDL coverage | Whether your sequence runs — which classes and G36 sequences are supported, and what “supported” is defined to mean |
| CXF round trip | What export guarantees, and the conditions under which it silently drops part of your model |
| CXF composite subset | The normative contract, if you are writing a tool that emits CXF for this engine |
| Host responsibilities | What safety behavior you must implement yourself, before wiring the engine to equipment |
| CI and the gate | What runs when, and what a green check does and does not prove |
| Benchmarks | Measured Engine::tick() throughput, recorded per run with the commit and host that produced it |
| Document | Purpose |
|---|---|
README.md | Project front door: what this is, who it is for, and how to try it |
TESTING.md | The testing standard every change is held to. Read before writing a test |
CONTRIBUTING.md | How to work on the repository |
SECURITY.md | Reporting, threat model, and the known hardening limit |
CHANGELOG.md | Notable changes |
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.
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.
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.
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.
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.
The engine is, by design:
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.#![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).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.
These are different values on purpose, and conflating them has already cost this repo one reverted change (PR #209).
| Value | Where | What it means to you | |
|---|---|---|---|
| MSRV | 1.97.0 | Cargo.toml:42 (rust-version) | The floor a consumer needs. Build the engine with any toolchain at or above this. |
| Pin | 1.97.1 | rust-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.
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.
Seventeen crates. The dependency direction is acyclic and organized around the seam above.
Execution core (Group A — no store, no database):
| Crate | Responsibility |
|---|---|
oce-model | Pure value/connector/instance/connection types; the Value enum (Real/Integer/Boolean/String/Enum) and the flattened model graph — the shared executable truth. |
oce-expr | The 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-blocks | The 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-flatten | Reserved 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-validate | Loader conformance: subset rejection, single-assignment, type and attribute unification, parameter rules. |
oce-graph | The deterministic scheduler and executor: direct-feedthrough DAG, algebraic-loop rejection, its own Kahn topological sort, the tick loop. |
oce-cxf | CXF (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-semantics | Reserved 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-diag | The shared diagnostic vocabulary (Severity / DiagCode / Diagnostic) across the ingest path. Zero dependencies. |
Storage ports (the seam — traits only, no database types):
| Crate | Responsibility |
|---|---|
oce-store | The seam. The ModelStore / PointStore / SemanticStore / Durable traits plus DTOs, unified by the Store supertrait. No database types. |
oce-store-mem | The default in-memory backend, so the engine runs with no database. |
oce-reference-wal-adapter | Verification-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:
| Crate | Responsibility |
|---|---|
oce-conformance | The 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-bless | Test-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-extension | Reserved 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-docs | Reserved 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-api | The 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.
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>.
Each of these is a design commitment, not a gap waiting to be closed.
oce-store port, authored app-side.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.
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.
Five different artifacts here are called “tests”. They prove different things, and the most visible of them proves nothing about correctness at all.
| Layer | Artifact | Count | Independent of the engine? |
|---|---|---|---|
| Tier-2 determinism goldens | crates/oce-conformance/tests/fixtures/golden/g36_traces/ | 46 traces + 46 .prov.json | No — engine self-output, by construction |
| Tier-A oracles | tools/golden-gen/goldens/ | 412 provenance records, 410 signal goldens | Yes — CI-enforced code-dependency firewall |
| Structural oracle | third_party/modelica-buildings-cdl/cxf/ | 44 vendored translations; 31 comparable fixtures | Yes — an independent translation of the same upstream source |
| Tier-1 per-block oracle comparisons | crates/oce-conformance/tests/per_block_*.rs | 15 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 differentials | crates/oce-conformance/tests/fixtures/open_modelica/logical_nand/ and logical_toggle/ | 2 named Boolean cases; global report skipped | Yes — pinned OpenModelica and Buildings execution |
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.
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:
goldens/CDL/Types/types.prov.json, pinning enum ordinals, and
goldens/CDL/Constants/constants.prov.json).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).
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.
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:
"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.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.
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.
Yes, and it does so in the place where it is hardest to ignore — the end of every gate run.
.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:
ubuntu-latest and ubuntu-24.04-arm). One
machine cannot reproduce it; CI is the only place it runs.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.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.
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.
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:
| Class | Status |
|---|---|
CDL.Logical.Nor | indirect G36-sequence evidence only |
CDL.Logical.Pre | indirect G36-sequence evidence only |
CDL.Logical.Sources.Constant | indirect G36-sequence evidence only |
CDL.Reals.MovingAverage | indirect G36-sequence evidence only |
CDL.Utilities.Assert | has 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.
If you are evaluating this engine, the defensible summary is:
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.
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 engine registers 136 block classes: 133 CDL elementary classes plus 3 reserved internal lowering identities.
| Family | Classes |
|---|---|
CDL.Reals | 52 |
CDL.Logical | 26 |
CDL.Integers | 24 |
CDL.Routing | 15 |
CDL.Discrete | 7 |
CDL.Conversions | 4 |
CDL.Psychrometrics | 3 |
CDL.Utilities | 2 |
| CDL total | 133 |
| Reserved lowering identities | 3 |
| Registry total | 136 |
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).
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().
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:
runtime_sequences, all status
supported-runtime-sequence) over 31 distinct canonical class paths.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.
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:
| Requirement | Field on the catalog row |
|---|---|
Canonical Buildings.Controls.OBC.ASHRAE.G36.* class path | class_path |
| Source provenance | source |
| Supported parameter variants | supported_variant |
| Fixture | fixture |
| Deterministic golden trace | golden_trace, determinism_provenance |
| Independent oracle evidence | oracle_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 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.
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:
…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.
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.
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.
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).
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):
| Attribute | Emitted as | Applies to |
|---|---|---|
unit, quantity, displayUnit | bare string | Real connectors |
min, max | bare number, finite only | Real (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.
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):
| Fixture | Blocks in graph | Blocks deferred | Share |
|---|---|---|---|
cooling_only_controller | 213 (crates/oce-api/tests/g36_cooling_only_controller.rs:252) | 83 | 39 % |
multizone_vav_relief_fan_group | 226 (crates/oce-api/tests/g36_relief_fan_group.rs:105) | 63 | 28 % |
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.
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.
Ok export produces bytes that fail re-importBoth are documented, and both are reachable only from a hand-built ModelGraph — never from one the
resolver produced.
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).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 digestExportReport::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:
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.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.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.
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.
Every rejection is a diagnostic with three parts:
malformed-document),@id of the offending node, where one exists),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.
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.
A node is a runtime composite if and only if its
S231:containsBlocklist is non-empty AND its@typedoes not resolve to a registered leaf block class. A node whose@typeresolves to a registered class is a leaf even when it carriesS231: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).
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 withcomposite/root-count(DiagCodemalformed-document). With two or more candidates the message enumerates every candidate in@graphorder 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).
Active composite children lower depth-first in
S231:containsBlockarray 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.
composite/contains-cycle)The
containsBlockgraph reachable from the root through active children must be acyclic. A cycle rejects withcomposite/contains-cycle(DiagCodemalformed-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).
composite/array-parameter, composite/declaration-cycle, composite/duplicate-declaration)A composite’s active
S231:hasParameterandS231:hasConstantbindings 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 namedb. 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 withcomposite/declaration-cycle(DiagCodemalformed-document): one diagnostic per distinct cycle per chain evaluation. Likecontains-cycle, a composite reachable via multiplecontainsBlockpaths 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 genericgrounding-failed.- One local name declared twice in one composite’s own chain rejects with
composite/duplicate-declaration(DiagCodemalformed-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 withcomposite/array-parameter(DiagCodenon-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 withgrounding-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.
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
@idappears 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.
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 withcomposite/banned-modelica-key(DiagCodenon-subset-construct); the subject is the owning node and the message names the key exactly as authored.
S231:isReplaceable: trueon any active node rejects withcomposite/replaceable(DiagCodeunresolved-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:hasInputorS231:hasOutputlist, 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’sS231:hasInstancelist (acontainsBlockreferent 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: trueor anyS231: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 iscomposite/array-connector(DiagCodenon-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 ascomposite/array-instance(DiagCodenon-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 aS231:hasParameterlisting 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
hasInstanceinterface derivation (an instance declaring neitherhasInputnorhasOutputand carrying aS231:hasInstancelist 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(DiagCodenon-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 throughhasInput/hasOutputis untouched.composite/unsupported-instance-member(DiagCodenon-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(DiagCodenon-subset-construct, subject the colliding IRI, one per collision): a synthesized connector identity that is already an@graphnode or is minted twice for one owner, or a parameter name declared twice for one instance — across its classified members or against its ownhasParameter/hasConstantlist.
{ "@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).
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.
| Rule | Rule id | DiagCode | Message prefix |
|---|---|---|---|
| 2 | root-count | malformed-document | composite/root-count: |
| 4 | contains-cycle | malformed-document | composite/contains-cycle: |
| 7 | replaceable | unresolved-polymorphism | composite/replaceable: |
| 7 | banned-modelica-key | non-subset-construct | composite/banned-modelica-key: |
| 5 | array-parameter | non-subset-construct | composite/array-parameter: |
| 7 | array-connector | non-subset-construct | composite/array-connector: |
| 7 | array-instance | non-subset-construct | composite/array-instance: |
| 5 | declaration-cycle | malformed-document | composite/declaration-cycle: |
| 5 | duplicate-declaration | malformed-document | composite/duplicate-declaration: |
| 7 | vector-port-instance | non-subset-construct | composite/vector-port-instance: |
| 7 | unsupported-instance-member | non-subset-construct | composite/unsupported-instance-member: |
| 7 | colliding-member-identity | non-subset-construct | composite/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.
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.
A document that satisfies rules 1–7 must also meet the general import preconditions before it loads warning-free:
@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.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.@type resolves to a registered block class (else class-not-found).The engine tests itself against a checked-in conformance corpus; point your emitter’s output at the same files and drivers.
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.ModelGraph renders):
crates/oce-cxf/tests/fixtures/golden/composite_contract_*.modelgraph.txt.cargo nextest run -p oce-cxf --test composite_contract_corpusEngine::load_cxf) drivers:
cargo nextest run -p oce-api --test conformance composite_contractcargo nextest run -p oce-cxf --test composite_contract_docTo check a document your tool produced:
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/, 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/, 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).
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.
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.
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.
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:
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.Fault, Stale, Uninitialized and Override mean
for each input, and act on them before or instead of ticking. The engine will not.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).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.
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:
EngineStateSnapshot::from_bytes.restore_state before any input write, tick, simulation, dirty-parameter resume, or earlier
restore.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.
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.
@idsEvery 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.
| Bound | Limit | Defined at | Behavior when exceeded |
|---|---|---|---|
| Expression parse and AST nesting | 64 | crates/oce-expr/src/lib.rs | typed NestingTooDeep error |
| Expression size | 4096 nodes | crates/oce-expr/src/lib.rs | typed ExpressionTooLarge error |
Composite nesting (containsBlock lowering) | 64 | crates/oce-cxf/src/resolve/composite.rs | MalformedDocument diagnostic |
| Composite boundary path | 64 non-top isConnectedTo hops | crates/oce-cxf/src/resolve/composite.rs | MalformedDocument diagnostic |
| Composite boundary work | 65,536 target examinations and 8 MiB of aggregate target-IRI bytes per document | crates/oce-cxf/src/resolve/composite.rs | MalformedDocument 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.
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:
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.
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.
.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).
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.
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.
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.
release-gate.yml fires on development → main 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:
| Step | What it covers | Where |
|---|---|---|
| workspace nextest | every unit and integration test in all 17 crates | release-gate.yml:109-110 |
| workspace nextest, release codegen | release panic-freedom, debug_assert paths stripped; inherited ci-release runner policy | release-gate.yml:114-115 |
cargo test --doc | doctests — nextest cannot run them, so this is a separate step | release-gate.yml:117-118 |
two cargo public-api surface gates | exact public API text for oce-api and oce-store | release-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.
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.
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.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)..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.
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.
Measured tick throughput for the Open Control Engine, recorded per run with the commit, host and method that produced it.
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.
Steady-state cost of Engine::tick() on real G36 fixtures, through the public facade only:
Engine::in_memory() → load_cxf() → tick().
t only ever increases
across warmup and measurement.Stated explicitly, because the gap between these and the numbers below is where a wrong conclusion would come from.
load_ms column.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.b5b19e7 · Apple M5 (10 cores) · rustc 1.95.0 · macOS 26.6 · --releaseWarmup 20,000 ticks · 2.0 s measurement window · dt = 1.0 simulated second per tick.
| fixture | CDL class refs | ns/tick | ticks/sec |
|---|---|---|---|
cooling_only_controller | 222 | 2,508 | 398,801 |
multizone_vav_relief_fan_group | 228 | 2,706 | 369,558 |
multizone_vav_supply_fan | 71 | 755 | 1,325,349 |
ahu_economizer | 12 | 141 | 7,095,155 |
vav_single_zone | 8 | 144 | 6,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.
Reported as first / median / min in one process, because a single first-call figure carries process-start and page-cache cost.
| fixture | KiB | first ms | median ms | min ms | first÷med | MiB/s at median |
|---|---|---|---|---|---|---|
cooling_only_controller | 409 | 7.84 | 2.62 | 2.48 | 3.0× | 154 |
multizone_vav_relief_fan_group | 366 | 2.55 | 2.12 | 2.02 | 1.2× | 169 |
multizone_vav_supply_fan | 112 | 0.89 | 0.68 | 0.66 | 1.3× | 159 |
ahu_economizer | 16 | 0.14 | 0.11 | 0.10 | 1.3× | 144 |
vav_single_zone | 14 | 0.11 | 0.09 | 0.08 | 1.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_groupimports to 226 blocks, more than this fixture’s 213 (pinned atcrates/oce-cxf/tests/resolve_g36_relief_fan_group.rs:145-146), and 377 was the pre-importisConnectedTocount, 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.
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.
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.