From 263c4787e86fd6bfdb2fea17c77cbf1779722494 Mon Sep 17 00:00:00 2001 From: Justin Scott Date: Tue, 21 Jul 2026 22:33:31 -0400 Subject: [PATCH 1/2] feat(cxf): export flat ground scalar model graphs to CXF at RT-2 fixpoint (#141) The minimal exporter replaces the R4 staged floor: a flat, ground, single-root, scalar-parameter, attribute-free ModelGraph now emits a deterministic CXF JSON-LD document whose re-import renders bit-identically to the input (RT-2), with a checked-in byte golden pinning the exact output. The synthesized root reconstructs boundary inputs from each external input's stored IRI (root hasInput -> boundary node -> minted child port), so re-import re-elides them under AD-2. Everything outside the subset - enum/non-finite params, declared connector attrs, IRI-less blocks, empty graphs - stays a typed ExportUnsupported rejection with the owning block as subject, never a panic. Co-Authored-By: Claude Fable 5 --- crates/oce-cxf/src/bridge.rs | 4 +- crates/oce-cxf/src/export.rs | 563 ++++++++++++++++++ crates/oce-cxf/src/export_tests.rs | 336 +++++++++-- crates/oce-cxf/src/lib.rs | 43 +- crates/oce-cxf/tests/export_golden.rs | 127 ++++ crates/oce-cxf/tests/export_roundtrip.rs | 195 ++++++ .../export/minimal_loop.export.cxf.json | 1 + crates/oce-cxf/tests/render/mod.rs | 110 ++++ crates/oce-cxf/tests/resolve_golden.rs | 106 +--- 9 files changed, 1321 insertions(+), 164 deletions(-) create mode 100644 crates/oce-cxf/src/export.rs create mode 100644 crates/oce-cxf/tests/export_golden.rs create mode 100644 crates/oce-cxf/tests/export_roundtrip.rs create mode 100644 crates/oce-cxf/tests/fixtures/export/minimal_loop.export.cxf.json create mode 100644 crates/oce-cxf/tests/render/mod.rs diff --git a/crates/oce-cxf/src/bridge.rs b/crates/oce-cxf/src/bridge.rs index afa7cfe..9e49aaa 100644 --- a/crates/oce-cxf/src/bridge.rs +++ b/crates/oce-cxf/src/bridge.rs @@ -16,7 +16,9 @@ //! fails the registry lookup), so a malformed `@type` is a typed diagnostic, never a crash. /// The OBC namespace prefix stripped from a class IRI fragment to obtain the canonical class path. -const OBC_PREFIX: &str = "Buildings.Controls.OBC."; +/// The exporter re-prepends the same constant (its inverse direction), so the two directions +/// cannot drift. +pub(crate) const OBC_PREFIX: &str = "Buildings.Controls.OBC."; /// Map a CXF `@type` class IRI to its candidate canonical class path (the registry key). /// diff --git a/crates/oce-cxf/src/export.rs b/crates/oce-cxf/src/export.rs new file mode 100644 index 0000000..3dc317e --- /dev/null +++ b/crates/oce-cxf/src/export.rs @@ -0,0 +1,563 @@ +//! Minimal CXF exporter (#141): emit a flat, ground, single-root, scalar-parameter, +//! attribute-free [`ModelGraph`] as a CXF JSON-LD document. +//! +//! The contract is the RT-2 fixpoint, not source recovery: for a graph `G1` produced by +//! [`import_cxf`](crate::import_cxf), `import(export(G1))` lowers to a graph that renders +//! bit-identically to `G1` (floats compared by IEEE-754 bits). Cosmetic source content (labels, +//! layout, line numbers) is not in `ModelGraph`, so none of it comes back. +//! +//! ## Naming model +//! - The original root `@id` is not recorded in `ModelGraph`; the fixed synthetic +//! [`EXPORT_ROOT_IRI`] stands in for it — re-import needs *a* root, not the original. +//! - Block node `@id`s are [`oce_model::BlockInstance::instance_iri`] verbatim (absent → +//! rejected). +//! - Parameter node `@id`s are `.`; the importer recovers the parameter name +//! as the segment after the last `.`, so names containing `.` are rejected. +//! - Connectors carry no usable source IRI (the resolver leaves `Connector::iri` `None` except +//! for the AD-2 boundary-input overwrite), so port `@id`s are minted deterministically as +//! `.in` / `.out` from the owner's port-list position. +//! Re-import rebuilds wiring from `isConnectedTo`, so port names never need to round-trip. +//! - Each `external_inputs` connector's stored boundary IRI is emitted verbatim as a boundary +//! node listed under the root's `hasInput` and wired `isConnectedTo` → the **minted** child +//! port. Re-import then re-elides the boundary (AD-2), restoring both the `external_inputs` +//! entry and the child connector's boundary IRI. The boundary `@id` never appears in the +//! owning block's own `hasInput` list — sharing one `@id` between the root's and a child's +//! port list re-imports as a rejection. +//! +//! ## Determinism +//! Emission order derives from the `ModelGraph` vectors alone: root, then blocks in `BlockId` +//! order (each followed by its parameter nodes in `ParamTable` order), then one port node per +//! connector in `ConnectorId` order, then boundary nodes in `external_inputs` first-occurrence +//! order. No map iteration feeds any ordering, and `write_document` serializes struct fields in +//! declaration order, so repeated exports are byte-identical. +//! +//! ## Value shape +//! Parameter bindings are bare JSON literals. `Real`s always serialize with a fractional part +//! (serde_json formats every finite `f64` with a `.` or exponent), so a whole-number Real never +//! re-grounds as an Integer; `Integer`s serialize bare, so they never come back as Reals. +//! Non-finite Reals would serialize as JSON `null` and are rejected instead. Strings are emitted +//! as quoted CDL string-literal expressions (`oce-expr` grounds them back to `Value::String`). +//! +//! ## Rejection surface +//! Everything outside the subset is a typed [`CxfError::Validation`] carrying +//! [`oce_diag::DiagCode::ExportUnsupported`] error diagnostics whose `subject` is the owning +//! block's `instance_iri` (connectors have none) — never a panic. + +use std::collections::BTreeMap; + +use oce_diag::{DiagCode, Diagnostic}; +use oce_model::{Attrs, Connector, Dir, IntAttrs, ModelGraph, RealAttrs, Value, ValueType}; + +use crate::dto::{Context, CxfDocument, CxfValue, IriRef, Node, OneOrMany}; +use crate::{CxfError, bridge}; + +/// `@id` of the synthesized root composite. `ModelGraph` does not record the source document's +/// root IRI, so every export uses this fixed, collision-improbable URN. A (contrived) block whose +/// `instance_iri` equals it would surface as a loud `DuplicateId` on re-import, never silently. +const EXPORT_ROOT_IRI: &str = "urn:open-control:cxf-export:root"; + +/// The `S231` prefix binding emitted in `@context` (matches the import fixtures). +const S231_CONTEXT_IRI: &str = "http://data.ashrae.org/S231P#"; + +/// Base IRI for emitted instance `@type`s: `Buildings.Controls.OBC.` — the +/// inverse of [`bridge::class_path_of`], using the same base the import fixtures use. The +/// planner verifies each emitted `@type` re-bridges to the identical `class_iri` before +/// accepting the block. +const CLASS_IRI_BASE: &str = "http://example.org#"; + +/// Whole-operation rejection for a zero-block graph: a root with no `containsBlock` is not a +/// runtime composite, and a root-only document re-imports as a zero-candidate-root +/// `MalformedDocument` — there is nothing warning-free to emit. +const MSG_EMPTY: &str = + "CXF export requires at least one block: an empty ModelGraph has no runtime composite to emit"; +/// A block without an `instance_iri` cannot name its CXF node (hand-built graphs only; every +/// imported block carries one). +const MSG_NO_INSTANCE_IRI: &str = "export subset: block has no instance_iri to name its CXF node"; +/// The emitted `@type` would not re-bridge to the identical `class_iri` (a `#` in the class path, +/// or a path already carrying the OBC prefix) — accepting it would silently flip block identity. +const MSG_CLASS_BRIDGE: &str = + "export subset: class path does not survive the class-IRI bridge round-trip"; +/// Block/connector cross-references disagree (non-dense ids, wrong owner or direction, a +/// connector claimed twice or never, a connection endpoint out of range or not output→input). +const MSG_STRUCTURE: &str = "export subset: block/connector wiring is structurally inconsistent"; +/// The connector carries declared §7.4.1 attributes; the minimal exporter emits none, so +/// accepting it would silently drop them. +const MSG_ATTRS: &str = + "export subset: connector declares §7.4.1 attributes, which the minimal exporter cannot emit"; +/// String connectors have no importable CXF form (§7.8 forbids them). +const MSG_STRING_CONNECTOR: &str = + "export subset: String connectors are not permitted in CXF (§7.8)"; +/// Enumeration connectors need an enum-class IRI inverse mapping the minimal exporter lacks. +const MSG_ENUM_CONNECTOR: &str = + "export subset: enumeration-typed connectors are outside the minimal export subset"; +/// An `external_inputs` entry whose connector has no stored boundary IRI cannot rebuild the +/// root's `hasInput` (hand-built graphs only; the resolver always stores it). +const MSG_EXTERNAL_IRI: &str = + "export subset: external input carries no boundary IRI to rebuild the root hasInput"; + +/// Everything [`build`] needs, fully validated: no `Option` left to unwrap, no index that can +/// miss. Produced by [`plan`] only when the subset checks all pass. +struct Plan { + blocks: Vec, + ports: Vec, + boundaries: Vec, +} + +/// One block node plus its parameter nodes, with every `@id` already minted. +struct PlannedBlock { + iri: String, + type_iri: String, + /// `(node @id, binding)` in `ParamTable` order. + params: Vec<(String, CxfValue)>, + input_ports: Vec, + output_ports: Vec, +} + +/// One port node: minted `@id`, `isOfDataType` term, and `isConnectedTo` targets (outputs only). +struct PlannedPort { + iri: String, + datatype: &'static str, + targets: Vec, +} + +/// One boundary-input node: the stored boundary IRI and the minted child ports it drives. +struct PlannedBoundary { + iri: String, + targets: Vec, +} + +/// Export `model` as a CXF document, or reject it with `ExportUnsupported` diagnostics. +/// +/// # Errors +/// [`CxfError::Validation`] when any subset check fails; see the module docs for the surface. +pub(crate) fn document(model: &ModelGraph) -> Result { + plan(model).map(build).map_err(CxfError::Validation) +} + +/// An `ExportUnsupported` error diagnostic with a subject. +fn reject(message: impl Into, subject: &str) -> Diagnostic { + Diagnostic::error(DiagCode::ExportUnsupported, message).with_subject(subject.to_owned()) +} + +/// The diagnostic subject for a connector: its owning block's `instance_iri`, else a synthetic +/// position tag (mirrors the resolver's `connector#N` convention for IRI-less connectors). +fn owner_subject(g: &ModelGraph, c: &Connector, position: usize) -> String { + g.blocks + .get(c.block.0 as usize) + .and_then(|b| b.instance_iri.as_deref()) + .map_or_else(|| format!("connector#{position}"), str::to_owned) +} + +/// Validate the export subset and mint every `@id`. Returns the full diagnostic list (all +/// offenders, in block-then-connector-then-connection scan order) when anything is outside the +/// subset. All ordering derives from the `ModelGraph` vectors. +fn plan(g: &ModelGraph) -> Result> { + if g.blocks.is_empty() { + return Err(vec![Diagnostic::error( + DiagCode::ExportUnsupported, + MSG_EMPTY, + )]); + } + // Dense-id invariant: every position-based index below (and re-import itself) assumes + // `BlockId.0`/`ConnectorId.0` equal vector position. The resolver guarantees it; a + // hand-built graph that breaks it is rejected wholesale rather than exported shifted. + let dense = g + .blocks + .iter() + .enumerate() + .all(|(i, b)| b.id.0 as usize == i) + && g.connectors + .iter() + .enumerate() + .all(|(i, c)| c.id.0 as usize == i); + if !dense { + return Err(vec![Diagnostic::error( + DiagCode::ExportUnsupported, + MSG_STRUCTURE, + )]); + } + + let mut diags: Vec = Vec::new(); + let mut port_iri: Vec> = vec![None; g.connectors.len()]; + let mut blocks: Vec = Vec::with_capacity(g.blocks.len()); + + for (bi, b) in g.blocks.iter().enumerate() { + let subject: String = match b.instance_iri.as_deref() { + Some(iri) => iri.to_owned(), + None => { + let synthetic = format!("block#{bi}"); + diags.push(reject(MSG_NO_INSTANCE_IRI, &synthetic)); + synthetic + } + }; + let type_iri = format!("{CLASS_IRI_BASE}{}{}", bridge::OBC_PREFIX, b.class_iri); + // Self-oracle: run the actual import bridge over the @type we are about to emit. Anything + // that would re-import under a different class identity is rejected here, loudly. + if bridge::class_path_of(&type_iri) != b.class_iri.as_ref() { + diags.push(reject(MSG_CLASS_BRIDGE, &subject)); + } + + let mut params = Vec::with_capacity(b.params.values.len()); + for (name, value) in &b.params.values { + if name.is_empty() || name.contains('.') { + diags.push(reject( + format!( + "export subset: parameter name `{name}` is not a bare member name \ + (re-import recovers the name after the last `.`)" + ), + &subject, + )); + continue; + } + if let Some(binding) = param_binding(name, value, &subject, &mut diags) { + params.push((format!("{subject}.{name}"), binding)); + } + } + + let mut input_ports = Vec::with_capacity(b.inputs.len()); + for (k, cid) in b.inputs.iter().enumerate() { + let minted = format!("{subject}.in{k}"); + if claim_port( + g, + bi, + cid.0, + Dir::In, + &minted, + &mut port_iri, + &subject, + &mut diags, + ) { + input_ports.push(minted); + } + } + let mut output_ports = Vec::with_capacity(b.outputs.len()); + for (k, cid) in b.outputs.iter().enumerate() { + let minted = format!("{subject}.out{k}"); + if claim_port( + g, + bi, + cid.0, + Dir::Out, + &minted, + &mut port_iri, + &subject, + &mut diags, + ) { + output_ports.push(minted); + } + } + + blocks.push(PlannedBlock { + iri: subject, + type_iri, + params, + input_ports, + output_ports, + }); + } + + // Every connector must be claimed by exactly one owner port list (double claims were caught + // above); an orphan would silently vanish from the document. + for (i, c) in g.connectors.iter().enumerate() { + if port_iri[i].is_none() { + diags.push(reject(MSG_STRUCTURE, &owner_subject(g, c, i))); + } + } + + // Connector-level subset checks: importable value type, and zero declared §7.4.1 attributes + // (the exporter emits none, so any `Some` field would be silently dropped otherwise). + let mut datatypes: Vec<&'static str> = Vec::with_capacity(g.connectors.len()); + for (i, c) in g.connectors.iter().enumerate() { + let subject = owner_subject(g, c, i); + datatypes.push(match c.value_type { + ValueType::Real => "S231:Real", + ValueType::Integer => "S231:Integer", + ValueType::Boolean => "S231:Boolean", + ValueType::String => { + diags.push(reject(MSG_STRING_CONNECTOR, &subject)); + "S231:Real" // placeholder; the non-empty diags gate discards the plan + } + ValueType::Enum(_) => { + diags.push(reject(MSG_ENUM_CONNECTOR, &subject)); + "S231:Real" // placeholder; the non-empty diags gate discards the plan + } + }); + if !c.attrs.matches(c.value_type) { + diags.push(reject(MSG_STRUCTURE, &subject)); + } else if !attrs_are_empty(&c.attrs) { + diags.push(reject(MSG_ATTRS, &subject)); + } + } + + // Connections become each source port's isConnectedTo list, in `connections` vector order. + let mut targets: Vec> = vec![Vec::new(); g.connectors.len()]; + for conn in &g.connections { + let (f, t) = (conn.from.0 as usize, conn.to.0 as usize); + let output_to_input = g.connectors.get(f).is_some_and(|c| c.dir == Dir::Out) + && g.connectors.get(t).is_some_and(|c| c.dir == Dir::In); + if !output_to_input { + diags.push(Diagnostic::error( + DiagCode::ExportUnsupported, + MSG_STRUCTURE, + )); + continue; + } + // An unclaimed target was already rejected by the orphan scan; nothing to add here. + if let Some(to_iri) = port_iri[t].as_ref() { + targets[f].push(to_iri.clone()); + } + } + + // Boundary inputs: group `external_inputs` by their stored boundary IRI in first-occurrence + // order (one boundary node may fan out to several child inputs). + let mut boundaries: Vec = Vec::new(); + for cid in &g.external_inputs { + let idx = cid.0 as usize; + let Some(c) = g.connectors.get(idx) else { + diags.push(Diagnostic::error( + DiagCode::ExportUnsupported, + MSG_STRUCTURE, + )); + continue; + }; + let subject = owner_subject(g, c, idx); + if c.dir != Dir::In { + diags.push(reject(MSG_STRUCTURE, &subject)); + continue; + } + let Some(boundary_iri) = c.iri.as_deref() else { + diags.push(reject(MSG_EXTERNAL_IRI, &subject)); + continue; + }; + let Some(target) = port_iri[idx].as_ref() else { + continue; // orphan connector: already rejected above + }; + match boundaries.iter_mut().find(|pb| pb.iri == boundary_iri) { + Some(pb) => pb.targets.push(target.clone()), + None => boundaries.push(PlannedBoundary { + iri: boundary_iri.to_owned(), + targets: vec![target.clone()], + }), + } + } + + if !diags.is_empty() { + return Err(diags); + } + + // All checks passed: every connector is claimed exactly once, so every `port_iri` is `Some`. + let mut ports = Vec::with_capacity(g.connectors.len()); + for (i, minted) in port_iri.into_iter().enumerate() { + let Some(iri) = minted else { + // Unreachable after the orphan scan above; kept total (never a panic) regardless. + return Err(vec![Diagnostic::error( + DiagCode::ExportUnsupported, + MSG_STRUCTURE, + )]); + }; + ports.push(PlannedPort { + iri, + datatype: datatypes[i], + targets: std::mem::take(&mut targets[i]), + }); + } + + Ok(Plan { + blocks, + ports, + boundaries, + }) +} + +/// Claim connector `raw_cid` for owner block `bi` under `dir`, recording `minted` as its port +/// `@id`. Returns `false` (with a structure diagnostic) when the connector is out of range, owned +/// by a different block, points the other way, or was already claimed. +#[allow(clippy::too_many_arguments)] // A claim needs the graph, the claim key, and the two +// accumulators; bundling them into a one-shot struct would only rename the arguments. +fn claim_port( + g: &ModelGraph, + bi: usize, + raw_cid: u32, + dir: Dir, + minted: &str, + port_iri: &mut [Option], + subject: &str, + diags: &mut Vec, +) -> bool { + let idx = raw_cid as usize; + let claimable = g + .connectors + .get(idx) + .is_some_and(|c| c.block.0 as usize == bi && c.dir == dir) + && port_iri.get(idx).is_some_and(Option::is_none); + if claimable { + port_iri[idx] = Some(minted.to_owned()); + } else { + diags.push(reject(MSG_STRUCTURE, subject)); + } + claimable +} + +/// Convert one ground parameter value to its canonical CXF binding, or reject it (enumeration +/// values have no literal form here; non-finite Reals would serialize as JSON `null`). +fn param_binding( + name: &str, + value: &Value, + subject: &str, + diags: &mut Vec, +) -> Option { + match value { + Value::Real(r) if r.is_finite() => Some(CxfValue::Float(*r)), + Value::Real(_) => { + diags.push(reject( + format!( + "export subset: parameter `{name}` is a non-finite Real, which cannot \ + round-trip through JSON" + ), + subject, + )); + None + } + Value::Integer(i) => Some(CxfValue::Int(*i)), + Value::Boolean(b) => Some(CxfValue::Bool(*b)), + Value::String(s) => Some(CxfValue::Expr(string_literal(s))), + Value::Enum { .. } => { + diags.push(reject( + format!( + "export subset: parameter `{name}` is enumeration-valued, which has no CXF \ + literal form in the minimal export subset" + ), + subject, + )); + None + } + } +} + +/// Whether a connector's attribute set is entirely undeclared (every field `None`). The minimal +/// exporter emits no §7.4.1 attributes, so anything else must be rejected, not dropped. +fn attrs_are_empty(attrs: &Attrs) -> bool { + match attrs { + Attrs::Real(a) => *a == RealAttrs::default(), + Attrs::Integer(a) => *a == IntAttrs::default(), + Attrs::Boolean(_) | Attrs::String(_) | Attrs::Enum(_) => true, + } +} + +/// Quote a `Value::String` payload as a CDL string-literal expression. Only `\` and `"` need +/// escaping (the `oce-expr` lexer resolves both; every other character — including raw control +/// characters — passes through the JSON string layer verbatim). +fn string_literal(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for ch in s.chars() { + if ch == '"' || ch == '\\' { + out.push('\\'); + } + out.push(ch); + } + out.push('"'); + out +} + +/// Render a validated [`Plan`] as the CXF document. Infallible: every `@id` and binding was +/// minted by [`plan`], and the emission order is fixed by the plan's vectors. +fn build(plan: Plan) -> CxfDocument { + let mut graph = Vec::new(); + + let mut root = blank_node(EXPORT_ROOT_IRI.to_owned()); + root.r#type = Some(OneOrMany::One("S231:Block".to_owned())); + root.has_input = refs(plan.boundaries.iter().map(|b| b.iri.clone()).collect()); + root.contains_block = refs(plan.blocks.iter().map(|b| b.iri.clone()).collect()); + graph.push(root); + + for b in plan.blocks { + let mut node = blank_node(b.iri); + node.r#type = Some(OneOrMany::One(b.type_iri)); + node.has_input = refs(b.input_ports); + node.has_output = refs(b.output_ports); + node.has_parameter = refs(b.params.iter().map(|(id, _)| id.clone()).collect()); + graph.push(node); + for (id, binding) in b.params { + let mut pnode = blank_node(id); + pnode.value = Some(binding); + graph.push(pnode); + } + } + + for port in plan.ports { + let mut node = blank_node(port.iri); + node.is_of_data_type = Some(iri_ref(port.datatype.to_owned())); + node.is_connected_to = refs(port.targets); + graph.push(node); + } + + for boundary in plan.boundaries { + let mut node = blank_node(boundary.iri); + node.is_connected_to = refs(boundary.targets); + graph.push(node); + } + + let mut context = BTreeMap::new(); + context.insert( + "S231".to_owned(), + serde_json::Value::String(S231_CONTEXT_IRI.to_owned()), + ); + CxfDocument { + context: Context::Map(context), + graph, + other: BTreeMap::new(), + } +} + +/// A [`Node`] with only its `@id` set — every other field absent/empty. +fn blank_node(id: String) -> Node { + Node { + id, + r#type: None, + has_input: OneOrMany::None, + has_output: OneOrMany::None, + has_parameter: OneOrMany::None, + has_constant: OneOrMany::None, + contains_block: OneOrMany::None, + has_instance: OneOrMany::None, + is_connected_to: OneOrMany::None, + is_of_data_type: None, + label: None, + access: None, + value: None, + min: None, + max: None, + unit: None, + quantity: None, + display_unit: None, + is_final: None, + is_array: None, + n_dims: None, + size_dims: None, + fmu_path: None, + is_conditional: None, + cond_expr: None, + is_replaceable: None, + other: BTreeMap::new(), + } +} + +/// A bare `{"@id": …}` cross-reference. +fn iri_ref(id: String) -> IriRef { + IriRef { + id, + other: BTreeMap::new(), + } +} + +/// Wrap `@id`s as a [`OneOrMany`] edge: absent for zero, a bare object for one (the producer +/// byte style [`OneOrMany::One`] serializes to), an array otherwise. +fn refs(ids: Vec) -> OneOrMany { + let mut ids = ids; + match ids.len() { + 0 => OneOrMany::None, + 1 => OneOrMany::One(iri_ref(ids.remove(0))), + _ => OneOrMany::Many(ids.into_iter().map(iri_ref).collect()), + } +} diff --git a/crates/oce-cxf/src/export_tests.rs b/crates/oce-cxf/src/export_tests.rs index 6b9e7fe..2dde969 100644 --- a/crates/oce-cxf/src/export_tests.rs +++ b/crates/oce-cxf/src/export_tests.rs @@ -1,78 +1,324 @@ -//! Tests for the staged [`export`](crate::export) floor: every model — freshly imported or -//! hand-built — is rejected with a single typed `ExportUnsupported` error diagnostic (subject -//! `None`, pinned message), never a panic, and the whole rejection is identical across calls -//! and inputs. +//! Tests for the minimal exporter's acceptance and rejection surfaces: an imported +//! `minimal_loop` now exports cleanly, while everything outside the flat/ground/scalar/attr-free +//! subset — an empty graph, declared connector attrs, enum parameters, IRI-less blocks, +//! non-finite Reals — is a typed `ExportUnsupported` rejection (subject = the owning block's +//! `instance_iri`), never a panic, and identical across repeated calls. + +use std::sync::Arc; use oce_diag::{DiagCode, Diagnostic, Severity}; -use oce_model::ModelGraph; +use oce_model::{ + Attrs, BlockId, BlockInstance, Connector, ConnectorId, Dir, ModelGraph, ParamTable, RealAttrs, + Value, ValueType, +}; use super::{CxfError, ResolveOptions, export, import_cxf}; -const FIXTURE: &str = include_str!("../tests/fixtures/minimal_loop.jsonld"); +const MINIMAL_LOOP: &str = include_str!("../tests/fixtures/minimal_loop.jsonld"); +const CONNECTOR_ATTRS: &str = include_str!("../tests/fixtures/connector_attrs.jsonld"); +const G36_ENUM_PARAM: &str = include_str!("../tests/fixtures/g36/cooling_only_dampers.jsonld"); -/// The pinned, host-visible rejection message. Changing it is a breaking stability event. -const EXPECTED_MESSAGE: &str = - "CXF export is not yet implemented; every model is rejected until the exporter lands."; +fn import(src: &str) -> ModelGraph { + let (graph, _report) = + import_cxf(src.as_bytes(), &ResolveOptions::default()).expect("fixture resolves"); + graph +} -/// Unwrap the staged rejection: `export` must return `Err(CxfError::Validation(_))` carrying -/// exactly one diagnostic. Panics (failing the calling test) on `Ok` or any other error shape. -fn sole_rejection(model: &ModelGraph) -> Diagnostic { +/// Unwrap a rejection: `export` must return `Err(CxfError::Validation(_))` with a non-empty +/// diagnostic list where every entry is an `ExportUnsupported` error. Panics (failing the +/// calling test) on `Ok` or any other error shape. +fn rejection(model: &ModelGraph) -> Vec { match export(model) { Err(CxfError::Validation(diags)) => { - assert_eq!( - diags.len(), - 1, - "the export floor must emit exactly one diagnostic, got {diags:?}" - ); - diags.into_iter().next().expect("length checked above") + assert!(!diags.is_empty(), "a rejection must carry diagnostics"); + for d in &diags { + assert_eq!( + d.code, + DiagCode::ExportUnsupported, + "unexpected code: {d:?}" + ); + assert_eq!(d.severity, Severity::Error, "unexpected severity: {d:?}"); + } + diags } Ok(bytes) => panic!( - "the export floor must reject every model, but got Ok with {} byte(s)", + "expected an export rejection, but got Ok with {} byte(s)", bytes.len() ), Err(other) => panic!("expected CxfError::Validation, got {other:?}"), } } +/// A hand-built one-block graph: `Constant` (registered, arity 0/1) with the given params and a +/// single Real output connector — the smallest graph inside the export subset. +fn constant_graph(params: Vec<(Arc, Value)>) -> ModelGraph { + ModelGraph { + blocks: vec![BlockInstance { + id: BlockId(0), + class_iri: Arc::from("CDL.Reals.Sources.Constant"), + inputs: vec![], + outputs: vec![ConnectorId(0)], + params: ParamTable { values: params }, + decl_order: 0, + instance_iri: Some(Arc::from("http://example.org#Hand.con")), + }], + connectors: vec![Connector::new( + ConnectorId(0), + BlockId(0), + Dir::Out, + ValueType::Real, + 0, + )], + connections: vec![], + external_inputs: vec![], + } +} + #[test] -fn resolved_import_is_rejected_with_a_single_export_unsupported_error() { - let (graph, _report) = - import_cxf(FIXTURE.as_bytes(), &ResolveOptions::default()).expect("minimal_loop resolves"); - let diag = sole_rejection(&graph); - assert_eq!(diag.code, DiagCode::ExportUnsupported); - assert_eq!(diag.severity, Severity::Error); +fn resolved_import_now_exports_cleanly() { + // R4's staged floor rejected this exact graph; the exporter accepts it. + let bytes = export(&import(MINIMAL_LOOP)).expect("minimal_loop is inside the export subset"); + assert!(!bytes.is_empty()); +} + +#[test] +fn empty_model_graph_is_rejected_without_a_subject() { + // Pinned decision: a zero-block ModelGraph rejects. A root with no containsBlock is not a + // runtime composite — a root-only document re-imports as MalformedDocument (zero candidate + // roots), so there is no warning-free document to emit. + let diags = rejection(&ModelGraph::new()); + assert_eq!(diags.len(), 1); + assert_eq!( + diags[0].subject, None, + "a whole-operation rejection must not blame any node" + ); + assert_eq!( + diags[0].message, + "CXF export requires at least one block: an empty ModelGraph has no runtime composite \ + to emit" + ); +} + +#[test] +fn declared_connector_attrs_are_rejected_with_the_owning_block_subject() { + // connector_attrs.jsonld's attr-bearing connector is an OUTPUT (`A.con.y`, iri=None), so the + // subject must be the OWNING BLOCK's instance_iri — connectors carry no IRI of their own. + let graph = import(CONNECTOR_ATTRS); + let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "exactly one offending connector: {diags:?}"); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#A.con") + ); assert_eq!( - diag.subject, None, - "a whole-operation deferral must not blame any node" + diags[0].message, + "export subset: connector declares §7.4.1 attributes, which the minimal exporter cannot \ + emit" ); - assert_eq!(diag.message, EXPECTED_MESSAGE); } #[test] fn rejection_is_identical_across_repeated_calls() { - let (graph, _report) = - import_cxf(FIXTURE.as_bytes(), &ResolveOptions::default()).expect("minimal_loop resolves"); - let first = sole_rejection(&graph); + let graph = import(CONNECTOR_ATTRS); + let first = rejection(&graph); for _ in 0..3 { - let again = sole_rejection(&graph); - // Diagnostic equality covers the full (code, subject, message) triple plus severity. - assert_eq!(again, first, "the rejection must be stable across calls"); + assert_eq!( + rejection(&graph), + first, + "the rejection must be stable across calls" + ); + } +} + +#[test] +fn enum_valued_parameter_is_rejected_with_the_owning_block_subject() { + // cooling_only_dampers surfaces `controllerType = Enum` on the conPID BlockInstance (the + // high-limits/EnergyStandard fixtures do NOT — their enums are specialization-consumed and + // never reach a BlockInstance param, so those graphs would be accepted). + let graph = import(G36_ENUM_PARAM); + let diags = rejection(&graph); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#g36.source.cooling_only_dampers.conPID"), + "the first offender in block order is the enum-carrying conPID" + ); + assert!( + diags[0].message.contains("parameter `controllerType`") + && diags[0].message.contains("enumeration-valued"), + "got: {}", + diags[0].message + ); + assert_eq!(rejection(&graph), diags, "stable across repeated calls"); +} + +#[test] +fn block_without_an_instance_iri_is_rejected() { + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.blocks[0].instance_iri = None; + let diags = rejection(&graph); + assert_eq!(diags[0].subject.as_deref(), Some("block#0")); + assert_eq!( + diags[0].message, + "export subset: block has no instance_iri to name its CXF node" + ); +} + +#[test] +fn non_finite_real_parameters_are_rejected() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + // serde_json serializes a non-finite f64 as `null`, which no CxfValue arm re-parses — + // emitting it would poison the whole document. Reject instead. + let graph = constant_graph(vec![(Arc::from("k"), Value::Real(bad))]); + let diags = rejection(&graph); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); + assert!( + diags[0].message.contains("non-finite Real"), + "got: {}", + diags[0].message + ); } } #[test] -fn hand_built_empty_graph_gets_the_same_rejection_as_an_imported_one() { - let (imported, _report) = - import_cxf(FIXTURE.as_bytes(), &ResolveOptions::default()).expect("minimal_loop resolves"); - let from_import = sole_rejection(&imported); +fn dotted_parameter_name_is_rejected() { + // Re-import recovers the parameter name as the segment after the last `.`; a dotted name + // would silently come back renamed. + let graph = constant_graph(vec![(Arc::from("k.nested"), Value::Real(2.0))]); + let diags = rejection(&graph); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); + assert!( + diags[0].message.contains("not a bare member name"), + "got: {}", + diags[0].message + ); +} + +#[test] +fn string_parameter_round_trips_through_export_and_reimport() { + // Strings are in-subset: emitted as a quoted CDL string-literal expression (escaping `\` and + // `"`), re-grounded by oce-expr to the bit-identical Value::String. + let tricky = r#"deg "C" \ path"#; + let graph = constant_graph(vec![ + (Arc::from("k"), Value::Real(2.0)), + (Arc::from("note"), Value::String(Arc::from(tricky))), + ]); + let bytes = export(&graph).expect("String params are inside the subset"); + let (reimported, report) = + import_cxf(&bytes, &ResolveOptions::default()).expect("exported doc re-imports"); + assert!( + report.is_empty(), + "clean reimport: {:?}", + report.diagnostics + ); + let note = reimported.blocks[0] + .params + .values + .iter() + .find(|(n, _)| n.as_ref() == "note") + .expect("note parameter survives"); + assert!( + note.1.bit_eq(&Value::String(Arc::from(tricky))), + "got {:?}", + note.1 + ); +} + +#[test] +fn mismatched_port_wiring_is_rejected_not_exported_shifted() { + // The block lists its Out connector under `inputs`: structurally inconsistent — exporting it + // would emit wiring the importer rebuilds differently. + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.blocks[0].inputs = vec![ConnectorId(0)]; + graph.blocks[0].outputs = vec![]; + let diags = rejection(&graph); + assert!( + diags + .iter() + .any(|d| d.message.contains("structurally inconsistent")), + "got: {diags:?}" + ); +} - let from_empty = sole_rejection(&ModelGraph::new()); +#[test] +fn external_input_without_a_boundary_iri_is_rejected() { + // The resolver always stores the elided boundary IRI on the driven child input; a hand-built + // graph without one cannot rebuild the root's hasInput. + let graph = ModelGraph { + blocks: vec![BlockInstance { + id: BlockId(0), + class_iri: Arc::from("CDL.Reals.Abs"), + inputs: vec![ConnectorId(0)], + outputs: vec![ConnectorId(1)], + params: ParamTable::default(), + decl_order: 0, + instance_iri: Some(Arc::from("http://example.org#Hand.abs")), + }], + connectors: vec![ + Connector::new(ConnectorId(0), BlockId(0), Dir::In, ValueType::Real, 0), + Connector::new(ConnectorId(1), BlockId(0), Dir::Out, ValueType::Real, 1), + ], + connections: vec![], + external_inputs: vec![ConnectorId(0)], + }; + let diags = rejection(&graph); assert_eq!( - from_empty, from_import, - "the floor is input-independent: every model gets the identical rejection" + diags[0].subject.as_deref(), + Some("http://example.org#Hand.abs") ); - assert_eq!(from_empty.code, DiagCode::ExportUnsupported); - assert_eq!(from_empty.severity, Severity::Error); - assert_eq!(from_empty.subject, None); - assert_eq!(from_empty.message, EXPECTED_MESSAGE); + assert!( + diags[0].message.contains("no boundary IRI"), + "got: {}", + diags[0].message + ); +} + +#[test] +fn hand_built_attr_bearing_connector_is_rejected() { + // Same rejection as the imported connector_attrs fixture, proven on the builder path: any + // Some field in the attrs set is out of subset because the exporter emits none. + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.connectors[0] = Connector::new(ConnectorId(0), BlockId(0), Dir::Out, ValueType::Real, 0) + .with_attrs(Attrs::Real(RealAttrs { + unit: Some(Arc::from("K")), + ..RealAttrs::default() + })) + .expect("Real attrs on a Real connector"); + let diags = rejection(&graph); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); +} + +#[test] +fn every_rejection_path_returns_instead_of_panicking() { + // The never-panics property from the R4 floor survives on every path: exercise each + // rejection shape and the acceptance shape through the same call. + let graphs: Vec = vec![ + ModelGraph::new(), + import(MINIMAL_LOOP), + import(CONNECTOR_ATTRS), + import(G36_ENUM_PARAM), + constant_graph(vec![(Arc::from("k"), Value::Real(f64::NAN))]), + constant_graph(vec![( + Arc::from("mode"), + Value::Enum { + class: oce_model::EnumClassId::SIMPLE_CONTROLLER, + ordinal: 1, + }, + )]), + ]; + for g in &graphs { + // Ok or a typed Validation error — anything else (or a panic) fails the test. + match export(g) { + Ok(_) | Err(CxfError::Validation(_)) => {} + Err(other) => panic!("unexpected error shape: {other:?}"), + } + } } diff --git a/crates/oce-cxf/src/lib.rs b/crates/oce-cxf/src/lib.rs index ee4628f..1356ee2 100644 --- a/crates/oce-cxf/src/lib.rs +++ b/crates/oce-cxf/src/lib.rs @@ -9,16 +9,18 @@ //! is **Group A** (no store, no database); it depends on `serde`/`serde_json`/`oce-diag` plus //! `oce-blocks` (the registry, for class resolution) and `oce-expr` (Ground-mode bindings). //! -//! The lossless Layer-A DTO ([`dto`]), [`parse_document`], and §7.1 resolver ([`import_cxf`]) are -//! implemented. The exporter ([`export`]) is deferred: until it lands it rejects every model with -//! a typed [`CxfError::Validation`] carrying [`oce_diag::DiagCode::ExportUnsupported`] — it never -//! panics, and it is never on a load path. +//! The lossless Layer-A DTO ([`dto`]), [`parse_document`], the §7.1 resolver ([`import_cxf`]), +//! and the minimal RT-2 exporter ([`export()`]) are implemented. The exporter covers the flat, +//! ground, single-root, scalar-parameter, attribute-free subset — exactly what the resolver +//! produces for that shape of document; anything outside it is a typed [`CxfError::Validation`] +//! carrying [`oce_diag::DiagCode::ExportUnsupported`] — never a panic. use oce_model::ModelGraph; mod arrays; mod bridge; pub mod dto; +mod export; #[cfg(test)] mod export_tests; #[cfg(test)] @@ -99,20 +101,27 @@ pub fn import_cxf( resolve::resolve(&doc, opts) } -/// Export a [`ModelGraph`] back to a CXF JSON-LD document. +/// Export a [`ModelGraph`] back to a CXF JSON-LD document — the RT-2 partner of [`import_cxf`]. /// -/// The exporter has not landed yet: this is a staged floor that rejects **every** model, without -/// inspecting it, via a single [`oce_diag::DiagCode::ExportUnsupported`] error diagnostic. The -/// diagnostic's `subject` is `None` because the deferral concerns the whole operation, not any -/// node in the model. The signature — including the `Result` shape — is the permanent API; only -/// the unconditional rejection is temporary. +/// Accepts the flat, ground, single-root, scalar-parameter, attribute-free subset (the shape the +/// resolver produces for documents like the `minimal_loop` fixture). The emitted bytes are +/// deterministic — repeated calls are byte-identical — and re-import to a `ModelGraph` +/// bit-identical to the input (Reals by IEEE-754 bits). The source root `@id` is not recorded in +/// [`ModelGraph`], so the root composite is emitted under the fixed synthetic IRI +/// `urn:open-control:cxf-export:root`; block nodes reuse their `instance_iri` verbatim, and port +/// nodes get deterministically minted `@id`s (re-import rebuilds wiring from `isConnectedTo`, so +/// port names never round-trip). Parameter bindings are bare JSON literals; Reals always carry a +/// fractional part or exponent, so a whole-number Real never re-grounds as an Integer. /// /// # Errors -/// Always returns [`CxfError::Validation`] with exactly one -/// [`oce_diag::DiagCode::ExportUnsupported`] error diagnostic, until the exporter lands. -pub fn export(_model: &ModelGraph) -> Result, CxfError> { - Err(CxfError::Validation(vec![oce_diag::Diagnostic::error( - oce_diag::DiagCode::ExportUnsupported, - "CXF export is not yet implemented; every model is rejected until the exporter lands.", - )])) +/// - [`CxfError::Validation`] with [`oce_diag::DiagCode::ExportUnsupported`] error diagnostics +/// (subject = the owning block's `instance_iri`; connectors carry no IRI of their own) for +/// anything outside the subset: enumeration-valued or non-finite parameters, connectors with +/// declared §7.4.1 attributes, String/Enum-typed connectors, blocks without an `instance_iri`, +/// external inputs without a recorded boundary IRI, structurally inconsistent wiring, or an +/// empty (zero-block) graph. Never panics. +/// - [`CxfError::Json`] if document serialization itself fails. +pub fn export(model: &ModelGraph) -> Result, CxfError> { + let doc = export::document(model)?; + write_document(&doc) } diff --git a/crates/oce-cxf/tests/export_golden.rs b/crates/oce-cxf/tests/export_golden.rs new file mode 100644 index 0000000..8922ebb --- /dev/null +++ b/crates/oce-cxf/tests/export_golden.rs @@ -0,0 +1,127 @@ +//! Byte-determinism goldens for the minimal CXF exporter (#141): the checked-in exported bytes, +//! export idempotence, and emission-order stability. +//! +//! The golden is the exact byte output of `export(import(minimal_loop.jsonld))`. To regenerate +//! after an *intentional* format change, run: +//! +//! ```text +//! OCE_BLESS=1 cargo test -p oce-cxf --test export_golden exported_bytes_match_the_checked_in_golden +//! ``` +//! +//! and review the diff — any unreviewed byte drift is a determinism defect. + +use std::path::PathBuf; + +use oce_cxf::{ResolveOptions, export, import_cxf}; +use oce_model::ModelGraph; + +const FIXTURE: &str = include_str!("fixtures/minimal_loop.jsonld"); +const GOLDEN_REL: &str = "tests/fixtures/export/minimal_loop.export.cxf.json"; + +/// The synthesized root `@id` (`ModelGraph` does not record the source root IRI). Pinned here +/// against the exporter's constant: changing it is a byte-format break. +const EXPORT_ROOT_IRI: &str = "urn:open-control:cxf-export:root"; + +fn import_ok(src: &str) -> ModelGraph { + let (g, report) = import_cxf(src.as_bytes(), &ResolveOptions::default()) + .expect("minimal_loop must resolve without error"); + assert!( + report.is_empty(), + "expected zero diagnostics, got: {:?}", + report.diagnostics + ); + g +} + +fn golden_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_REL) +} + +#[test] +fn exported_bytes_match_the_checked_in_golden() { + let g = import_ok(FIXTURE); + let bytes = export(&g).expect("minimal_loop is inside the minimal export subset"); + + if std::env::var_os("OCE_BLESS").is_some() { + std::fs::create_dir_all(golden_path().parent().unwrap()).unwrap(); + std::fs::write(golden_path(), &bytes).unwrap(); + return; + } + let expected = + std::fs::read(golden_path()).expect("export golden missing — regenerate with OCE_BLESS=1"); + // Compare as strings so a mismatch prints a readable diff, then re-assert raw bytes. + assert_eq!( + String::from_utf8_lossy(&bytes), + String::from_utf8_lossy(&expected), + "exported document diverged from the checked-in byte golden" + ); + assert_eq!(bytes, expected, "byte-level divergence (non-UTF-8?)"); +} + +#[test] +fn export_is_byte_idempotent_across_calls() { + let g = import_ok(FIXTURE); + let first = export(&g).expect("export succeeds"); + for _ in 0..3 { + assert_eq!( + export(&g).expect("export succeeds"), + first, + "repeated exports of the same graph must be byte-identical" + ); + } +} + +#[test] +fn graph_node_order_follows_model_graph_vector_order() { + // Targeted order-stability assertion: recompute the expected @graph @id sequence from the + // ModelGraph vectors alone (root, blocks + their params in ParamTable order, one port per + // connector in ConnectorId order via the owner's port-list position, boundaries in + // external_inputs first-occurrence order) and require the emitted sequence to match. Any + // map-iteration order leaking into emission breaks this before it breaks the byte golden. + let g = import_ok(FIXTURE); + let bytes = export(&g).expect("export succeeds"); + + let mut expected: Vec = vec![EXPORT_ROOT_IRI.to_owned()]; + let mut port_iri: Vec> = vec![None; g.connectors.len()]; + for b in &g.blocks { + let iri = b + .instance_iri + .as_deref() + .expect("imported blocks have IRIs"); + expected.push(iri.to_owned()); + for (name, _) in &b.params.values { + expected.push(format!("{iri}.{name}")); + } + for (k, cid) in b.inputs.iter().enumerate() { + port_iri[cid.0 as usize] = Some(format!("{iri}.in{k}")); + } + for (k, cid) in b.outputs.iter().enumerate() { + port_iri[cid.0 as usize] = Some(format!("{iri}.out{k}")); + } + } + for minted in port_iri { + expected.push(minted.expect("every connector is owned by a port list")); + } + for cid in &g.external_inputs { + let boundary = g.connectors[cid.0 as usize] + .iri + .as_deref() + .expect("external inputs carry their boundary IRI") + .to_owned(); + if !expected.contains(&boundary) { + expected.push(boundary); + } + } + + let doc: serde_json::Value = serde_json::from_slice(&bytes).expect("export emits JSON"); + let emitted: Vec<&str> = doc["@graph"] + .as_array() + .expect("@graph is an array") + .iter() + .map(|n| n["@id"].as_str().expect("every node has an @id")) + .collect(); + assert_eq!( + emitted, expected, + "emission order must derive from the ModelGraph vectors alone" + ); +} diff --git a/crates/oce-cxf/tests/export_roundtrip.rs b/crates/oce-cxf/tests/export_roundtrip.rs new file mode 100644 index 0000000..64ce562 --- /dev/null +++ b/crates/oce-cxf/tests/export_roundtrip.rs @@ -0,0 +1,195 @@ +//! RT-2 export fixpoint, import-parity oracle, and value-shape tests for the minimal CXF +//! exporter (#141). +//! +//! The ratified contract: `G1 = import(fixture); bytes = export(G1); G2 = import(bytes); +//! render(G1) == render(G2)` bit-exact — via the same hand-written [`render::render`] the +//! resolver goldens use (floats by `to_bits()`), because `ModelGraph` is deliberately not +//! `Serialize`/`PartialEq`. The oracle tests re-check the load-bearing fields (class identity, +//! parameter values, connector types, boundary reconstruction) explicitly, independent of the +//! render string. + +mod render; + +use oce_cxf::{ResolveOptions, export, import_cxf}; +use oce_model::{ConnectorId, ModelGraph, Value}; +use render::render; + +const FIXTURE: &str = include_str!("fixtures/minimal_loop.jsonld"); + +fn import_ok(bytes: &[u8]) -> ModelGraph { + let (g, report) = + import_cxf(bytes, &ResolveOptions::default()).expect("document must resolve without error"); + assert!( + report.is_empty(), + "expected zero diagnostics, got: {:?}", + report.diagnostics + ); + g +} + +fn export_ok(g: &ModelGraph) -> Vec { + export(g).expect("minimal_loop is inside the minimal export subset") +} + +#[test] +fn export_then_import_reaches_the_rt2_fixpoint() { + let g1 = import_ok(FIXTURE.as_bytes()); + let bytes = export_ok(&g1); + let g2 = import_ok(&bytes); + assert_eq!( + render(&g1), + render(&g2), + "render(G1) must equal render(G2) bit-exactly" + ); + // Second order: G2 renders equal to G1 in every field export reads, so exporting G2 must + // reproduce the same bytes — the fixpoint holds at the byte level too. + assert_eq!(export_ok(&g2), bytes); +} + +#[test] +fn reimport_reproduces_class_identity_params_and_types_field_by_field() { + // Oracle cross-check independent of the render string: every emitted @type re-imports to the + // identical class_iri, every parameter re-grounds bit-identically, and derive_value_type + // reproduces every connector's value_type. + let g1 = import_ok(FIXTURE.as_bytes()); + let g2 = import_ok(&export_ok(&g1)); + + assert_eq!(g1.blocks.len(), g2.blocks.len()); + for (a, b) in g1.blocks.iter().zip(&g2.blocks) { + assert_eq!(a.class_iri, b.class_iri, "class identity must survive"); + assert_eq!(a.instance_iri, b.instance_iri, "block @id must survive"); + assert_eq!(a.inputs, b.inputs); + assert_eq!(a.outputs, b.outputs); + assert_eq!(a.params.values.len(), b.params.values.len()); + for ((an, av), (bn, bv)) in a.params.values.iter().zip(&b.params.values) { + assert_eq!(an, bn, "parameter name must survive"); + assert!( + av.bit_eq(bv), + "parameter {an} must re-ground bit-identically: {av:?} vs {bv:?}" + ); + } + } + assert_eq!(g1.connectors.len(), g2.connectors.len()); + for (a, b) in g1.connectors.iter().zip(&g2.connectors) { + assert_eq!(a.value_type, b.value_type, "derive_value_type must agree"); + assert_eq!(a.dir, b.dir); + assert_eq!(a.block, b.block); + } + assert_eq!(g1.connections, g2.connections); + assert_eq!(g1.external_inputs, g2.external_inputs); +} + +#[test] +fn whole_number_reals_emit_with_a_fractional_part_and_reground_to_real() { + // The type-flip guard, Real direction: CxfValue is untagged Int-before-Float, so a + // whole-number Real emitted bare (`2`) would silently re-ground as an Integer. The canonical + // shape is a bare JSON literal WITH its fractional part. + let g1 = import_ok(FIXTURE.as_bytes()); + let bytes = export_ok(&g1); + let text = std::str::from_utf8(&bytes).expect("export emits UTF-8 JSON"); + assert!( + text.contains(r#"{"@id":"http://example.org#MinLoop.con.k","S231:value":2.0}"#), + "con.k = Real(2.0) must emit as the bare literal 2.0, got: {text}" + ); + assert!( + text.contains(r#"{"@id":"http://example.org#MinLoop.del.samplePeriod","S231:value":1.0}"#), + "del.samplePeriod = Real(1.0) must emit as the bare literal 1.0" + ); + + let g2 = import_ok(&bytes); + let con_k = &g2.blocks[0].params.values[0]; + assert_eq!(con_k.0.as_ref(), "k"); + assert!(con_k.1.bit_eq(&Value::Real(2.0)), "got {:?}", con_k.1); + let sample_period = g2.blocks[2] + .params + .values + .iter() + .find(|(n, _)| n.as_ref() == "samplePeriod") + .expect("UnitDelay samplePeriod"); + assert!(sample_period.1.bit_eq(&Value::Real(1.0))); +} + +#[test] +fn integer_params_emit_bare_and_reground_to_integer_not_real() { + // The reverse type-flip guard: del.y_start is Integer(0) (pinned by the resolver test + // `unit_delay_bare_int_grounds_to_integer_not_real`). It must emit BARE (`0`, never `0.0`) + // and come back as Integer. + let g1 = import_ok(FIXTURE.as_bytes()); + let bytes = export_ok(&g1); + let text = std::str::from_utf8(&bytes).expect("export emits UTF-8 JSON"); + assert!( + text.contains(r#"{"@id":"http://example.org#MinLoop.del.y_start","S231:value":0}"#), + "del.y_start = Integer(0) must emit as the bare literal 0" + ); + assert!( + !text.contains(r#""@id":"http://example.org#MinLoop.del.y_start","S231:value":0.0"#), + "del.y_start must never grow a fractional part" + ); + + let g2 = import_ok(&bytes); + let y_start = g2.blocks[2] + .params + .values + .iter() + .find(|(n, _)| n.as_ref() == "y_start") + .expect("UnitDelay y_start"); + assert!( + y_start.1.bit_eq(&Value::Integer(0)), + "y_start must re-ground to Integer(0), got {:?}", + y_start.1 + ); +} + +#[test] +fn boundary_input_reconstruction_reelides_to_the_original_iri() { + // Shape A, pinned: the stored boundary IRI comes back as a root hasInput node wired to a + // DISTINCT minted child port, so re-import re-elides it (AD-2) and restores both the + // external_inputs entry and the C9 iri overwrite. + let g1 = import_ok(FIXTURE.as_bytes()); + let bytes = export_ok(&g1); + let g2 = import_ok(&bytes); + + assert_eq!(g2.external_inputs, vec![ConnectorId(9)]); + assert_eq!( + g2.connectors[9].iri.as_deref(), + Some("http://example.org#MinLoop.uSet"), + "the boundary IRI must travel back onto the driven child input" + ); + + // Structural pin on the emitted document: the boundary @id sits under the ROOT's hasInput + // only; the owning block's hasInput lists minted child ports. Sharing one @id between the + // two lists is the proven-rejected shape. + let doc: serde_json::Value = serde_json::from_slice(&bytes).expect("export emits JSON"); + let graph = doc["@graph"].as_array().expect("@graph is an array"); + let node = |id: &str| { + graph + .iter() + .find(|n| n["@id"] == id) + .unwrap_or_else(|| panic!("node {id} must be emitted")) + }; + let root = node("urn:open-control:cxf-export:root"); + assert_eq!( + root["S231:hasInput"]["@id"], "http://example.org#MinLoop.uSet", + "the root lists the boundary @id" + ); + let gt = node("http://example.org#MinLoop.gt"); + let gt_inputs: Vec<&str> = gt["S231:hasInput"] + .as_array() + .expect("gt has two inputs") + .iter() + .filter_map(|r| r["@id"].as_str()) + .collect(); + assert_eq!( + gt_inputs, + vec![ + "http://example.org#MinLoop.gt.in0", + "http://example.org#MinLoop.gt.in1" + ], + "the owning block lists only minted ports, never the boundary @id" + ); + let boundary = node("http://example.org#MinLoop.uSet"); + assert_eq!( + boundary["S231:isConnectedTo"]["@id"], "http://example.org#MinLoop.gt.in1", + "the boundary drives the minted child port" + ); +} diff --git a/crates/oce-cxf/tests/fixtures/export/minimal_loop.export.cxf.json b/crates/oce-cxf/tests/fixtures/export/minimal_loop.export.cxf.json new file mode 100644 index 0000000..38b2ef6 --- /dev/null +++ b/crates/oce-cxf/tests/fixtures/export/minimal_loop.export.cxf.json @@ -0,0 +1 @@ +{"@context":{"S231":"http://data.ashrae.org/S231P#"},"@graph":[{"@id":"urn:open-control:cxf-export:root","@type":"S231:Block","S231:hasInput":{"@id":"http://example.org#MinLoop.uSet"},"S231:containsBlock":[{"@id":"http://example.org#MinLoop.con"},{"@id":"http://example.org#MinLoop.add"},{"@id":"http://example.org#MinLoop.del"},{"@id":"http://example.org#MinLoop.gain"},{"@id":"http://example.org#MinLoop.gt"}]},{"@id":"http://example.org#MinLoop.con","@type":"http://example.org#Buildings.Controls.OBC.CDL.Reals.Sources.Constant","S231:hasOutput":{"@id":"http://example.org#MinLoop.con.out0"},"S231:hasParameter":{"@id":"http://example.org#MinLoop.con.k"}},{"@id":"http://example.org#MinLoop.con.k","S231:value":2.0},{"@id":"http://example.org#MinLoop.add","@type":"http://example.org#Buildings.Controls.OBC.CDL.Reals.Add","S231:hasInput":[{"@id":"http://example.org#MinLoop.add.in0"},{"@id":"http://example.org#MinLoop.add.in1"}],"S231:hasOutput":{"@id":"http://example.org#MinLoop.add.out0"}},{"@id":"http://example.org#MinLoop.del","@type":"http://example.org#Buildings.Controls.OBC.CDL.Discrete.UnitDelay","S231:hasInput":{"@id":"http://example.org#MinLoop.del.in0"},"S231:hasOutput":{"@id":"http://example.org#MinLoop.del.out0"},"S231:hasParameter":[{"@id":"http://example.org#MinLoop.del.y_start"},{"@id":"http://example.org#MinLoop.del.samplePeriod"}]},{"@id":"http://example.org#MinLoop.del.y_start","S231:value":0},{"@id":"http://example.org#MinLoop.del.samplePeriod","S231:value":1.0},{"@id":"http://example.org#MinLoop.gain","@type":"http://example.org#Buildings.Controls.OBC.CDL.Reals.MultiplyByParameter","S231:hasInput":{"@id":"http://example.org#MinLoop.gain.in0"},"S231:hasOutput":{"@id":"http://example.org#MinLoop.gain.out0"},"S231:hasParameter":{"@id":"http://example.org#MinLoop.gain.k"}},{"@id":"http://example.org#MinLoop.gain.k","S231:value":0.5},{"@id":"http://example.org#MinLoop.gt","@type":"http://example.org#Buildings.Controls.OBC.CDL.Reals.Greater","S231:hasInput":[{"@id":"http://example.org#MinLoop.gt.in0"},{"@id":"http://example.org#MinLoop.gt.in1"}],"S231:hasOutput":{"@id":"http://example.org#MinLoop.gt.out0"}},{"@id":"http://example.org#MinLoop.con.out0","S231:isConnectedTo":{"@id":"http://example.org#MinLoop.add.in0"},"S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.add.in0","S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.add.in1","S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.add.out0","S231:isConnectedTo":{"@id":"http://example.org#MinLoop.del.in0"},"S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.del.in0","S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.del.out0","S231:isConnectedTo":[{"@id":"http://example.org#MinLoop.gain.in0"},{"@id":"http://example.org#MinLoop.gt.in0"}],"S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.gain.in0","S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.gain.out0","S231:isConnectedTo":{"@id":"http://example.org#MinLoop.add.in1"},"S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.gt.in0","S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.gt.in1","S231:isOfDataType":{"@id":"S231:Real"}},{"@id":"http://example.org#MinLoop.gt.out0","S231:isOfDataType":{"@id":"S231:Boolean"}},{"@id":"http://example.org#MinLoop.uSet","S231:isConnectedTo":{"@id":"http://example.org#MinLoop.gt.in1"}}]} \ No newline at end of file diff --git a/crates/oce-cxf/tests/render/mod.rs b/crates/oce-cxf/tests/render/mod.rs new file mode 100644 index 0000000..4102c26 --- /dev/null +++ b/crates/oce-cxf/tests/render/mod.rs @@ -0,0 +1,110 @@ +//! Deterministic, bit-exact `ModelGraph` rendering shared by the resolver goldens +//! (`resolve_golden.rs`) and the RT-2 export fixpoint tests (`export_roundtrip.rs`). +//! +//! `ModelGraph` is intentionally NOT `Serialize`/`PartialEq` (`oce-model/src/lib.rs`), so both +//! suites compare this hand-written render string instead. Vectors are printed in index order +//! (`BlockId.0` / `ConnectorId.0`), exactly the order the resolver builds them; floats are +//! printed by `to_bits()` so the comparison is bit-exact, never `==`/epsilon (`TESTING.md` +//! pillar 2). One copy on purpose: if the golden render and the fixpoint render could drift, a +//! renderer bug could hide an export defect. + +use std::fmt::Write as _; + +use oce_model::{ModelGraph, Value}; + +/// Render `g` deterministically and human-diffably; the single comparison key for both the +/// checked-in resolver goldens and `render(G1) == render(G2)` fixpoint assertions. +pub fn render(g: &ModelGraph) -> String { + let mut s = String::new(); + let _ = writeln!(s, "blocks: {}", g.blocks.len()); + for b in &g.blocks { + let _ = writeln!( + s, + " B{} decl={} class={} instance_iri={:?}", + b.id.0, + b.decl_order, + b.class_iri, + b.instance_iri.as_deref() + ); + let _ = writeln!( + s, + " inputs={:?} outputs={:?}", + id_list(&b.inputs), + id_list(&b.outputs) + ); + for (name, v) in &b.params.values { + let _ = writeln!(s, " param {name}={}", render_value(v)); + } + } + let _ = writeln!(s, "connectors: {}", g.connectors.len()); + for c in &g.connectors { + let _ = writeln!( + s, + " C{} block=B{} dir={:?} type={:?} decl={} iri={:?}", + c.id.0, + c.block.0, + c.dir, + c.value_type, + c.decl_order, + c.iri.as_deref() + ); + // The parsed §7.4.1 attrs are locked bit-exactly: a unit/quantity/displayUnit + // mis-parse, a dropped bound, or a one-ULP bound drift fails the golden loudly. + let _ = writeln!(s, " attrs={}", render_attrs(&c.attrs)); + } + let _ = writeln!(s, "connections: {}", g.connections.len()); + for c in &g.connections { + let _ = writeln!(s, " C{} -> C{}", c.from.0, c.to.0); + } + let _ = writeln!( + s, + "external_inputs: {:?}", + g.external_inputs.iter().map(|c| c.0).collect::>() + ); + s +} + +fn id_list(ids: &[T]) -> Vec { + ids.iter().map(|i| format!("{i:?}")).collect() +} + +fn render_value(v: &Value) -> String { + match v { + // Reals by exact bits — the determinism contract; never `==`/epsilon (TESTING.md). + Value::Real(r) => format!("Real(0x{:016x})", r.to_bits()), + Value::Integer(i) => format!("Integer({i})"), + Value::Boolean(b) => format!("Boolean({b})"), + Value::String(s) => format!("String({s:?})"), + Value::Enum { class, ordinal } => format!("Enum(class={},ordinal={})", class.0, ordinal), + } +} + +/// A bit-exact rendering of a connector's parsed [`oce_model::Attrs`]. `Real` bounds are printed by +/// `to_bits()` (a one-ULP drift fails loudly); unit/quantity/displayUnit by their string form. +fn render_attrs(a: &oce_model::Attrs) -> String { + use oce_model::Attrs; + match a { + Attrs::Real(r) => format!( + "Real(unit={:?} quantity={:?} display_unit={:?} min={} max={} nominal={} unbounded={:?})", + r.unit.as_deref(), + r.quantity.as_deref(), + r.display_unit.as_deref(), + render_opt_bits(r.min), + render_opt_bits(r.max), + render_opt_bits(r.nominal), + r.unbounded, + ), + Attrs::Integer(i) => format!("Integer(min={:?} max={:?})", i.min, i.max), + Attrs::Boolean(_) => "Boolean".to_owned(), + Attrs::String(_) => "String".to_owned(), + Attrs::Enum(_) => "Enum".to_owned(), + } +} + +/// An optional `f64` rendered by its IEEE-754 bits (or `-` when unset) — the determinism contract. +fn render_opt_bits(v: Option) -> String { + match v { + Some(x) => format!("0x{:016x}", x.to_bits()), + None => "-".to_owned(), + } +} diff --git a/crates/oce-cxf/tests/resolve_golden.rs b/crates/oce-cxf/tests/resolve_golden.rs index ebbeed0..8ca0190 100644 --- a/crates/oce-cxf/tests/resolve_golden.rs +++ b/crates/oce-cxf/tests/resolve_golden.rs @@ -1,8 +1,8 @@ //! Golden, determinism, and boundary-elision integration tests for the §7.1 resolver. //! //! `ModelGraph` is intentionally NOT `Serialize`/`PartialEq` (`oce-model/src/lib.rs`), so the -//! golden is a hand-written, deterministic [`render`] string compared against a checked-in -//! snapshot — floats rendered **by their IEEE-754 bits** so a one-ULP drift fails loudly +//! golden is the hand-written, deterministic [`render::render`] string (shared with the RT-2 +//! export fixpoint suite) compared against a checked-in snapshot — floats rendered **by their IEEE-754 bits** so a one-ULP drift fails loudly //! (`TESTING.md` pillar 2). To regenerate the snapshot after an *intentional* change, run: //! //! ```text @@ -11,115 +11,19 @@ //! //! and review the diff. -use std::fmt::Write as _; +mod render; + use std::path::PathBuf; use oce_cxf::{ResolveOptions, import_cxf}; use oce_model::{ModelGraph, Value}; +use render::render; const FIXTURE: &str = include_str!("fixtures/minimal_loop.jsonld"); const GOLDEN_REL: &str = "tests/fixtures/golden/minimal_loop.modelgraph.txt"; const ATTRS_RICH: &str = include_str!("fixtures/connector_attrs.jsonld"); const ATTRS_GOLDEN_REL: &str = "tests/fixtures/golden/connector_attrs.modelgraph.txt"; -/// Deterministic, human-diffable, bit-exact rendering of a `ModelGraph`. Vectors are printed in -/// index order (`BlockId.0` / `ConnectorId.0`), exactly the order the resolver builds them; floats -/// are printed by `to_bits()` so the comparison is bit-exact, never `==`/epsilon. -fn render(g: &ModelGraph) -> String { - let mut s = String::new(); - let _ = writeln!(s, "blocks: {}", g.blocks.len()); - for b in &g.blocks { - let _ = writeln!( - s, - " B{} decl={} class={} instance_iri={:?}", - b.id.0, - b.decl_order, - b.class_iri, - b.instance_iri.as_deref() - ); - let _ = writeln!( - s, - " inputs={:?} outputs={:?}", - id_list(&b.inputs), - id_list(&b.outputs) - ); - for (name, v) in &b.params.values { - let _ = writeln!(s, " param {name}={}", render_value(v)); - } - } - let _ = writeln!(s, "connectors: {}", g.connectors.len()); - for c in &g.connectors { - let _ = writeln!( - s, - " C{} block=B{} dir={:?} type={:?} decl={} iri={:?}", - c.id.0, - c.block.0, - c.dir, - c.value_type, - c.decl_order, - c.iri.as_deref() - ); - // The parsed §7.4.1 attrs are locked bit-exactly: a unit/quantity/displayUnit - // mis-parse, a dropped bound, or a one-ULP bound drift fails the golden loudly. - let _ = writeln!(s, " attrs={}", render_attrs(&c.attrs)); - } - let _ = writeln!(s, "connections: {}", g.connections.len()); - for c in &g.connections { - let _ = writeln!(s, " C{} -> C{}", c.from.0, c.to.0); - } - let _ = writeln!( - s, - "external_inputs: {:?}", - g.external_inputs.iter().map(|c| c.0).collect::>() - ); - s -} - -fn id_list(ids: &[T]) -> Vec { - ids.iter().map(|i| format!("{i:?}")).collect() -} - -fn render_value(v: &Value) -> String { - match v { - // Reals by exact bits — the determinism contract; never `==`/epsilon (TESTING.md). - Value::Real(r) => format!("Real(0x{:016x})", r.to_bits()), - Value::Integer(i) => format!("Integer({i})"), - Value::Boolean(b) => format!("Boolean({b})"), - Value::String(s) => format!("String({s:?})"), - Value::Enum { class, ordinal } => format!("Enum(class={},ordinal={})", class.0, ordinal), - } -} - -/// A bit-exact rendering of a connector's parsed [`oce_model::Attrs`]. `Real` bounds are printed by -/// `to_bits()` (a one-ULP drift fails loudly); unit/quantity/displayUnit by their string form. -fn render_attrs(a: &oce_model::Attrs) -> String { - use oce_model::Attrs; - match a { - Attrs::Real(r) => format!( - "Real(unit={:?} quantity={:?} display_unit={:?} min={} max={} nominal={} unbounded={:?})", - r.unit.as_deref(), - r.quantity.as_deref(), - r.display_unit.as_deref(), - render_opt_bits(r.min), - render_opt_bits(r.max), - render_opt_bits(r.nominal), - r.unbounded, - ), - Attrs::Integer(i) => format!("Integer(min={:?} max={:?})", i.min, i.max), - Attrs::Boolean(_) => "Boolean".to_owned(), - Attrs::String(_) => "String".to_owned(), - Attrs::Enum(_) => "Enum".to_owned(), - } -} - -/// An optional `f64` rendered by its IEEE-754 bits (or `-` when unset) — the determinism contract. -fn render_opt_bits(v: Option) -> String { - match v { - Some(x) => format!("0x{:016x}", x.to_bits()), - None => "-".to_owned(), - } -} - fn import_ok(src: &str) -> ModelGraph { let (g, report) = import_cxf(src.as_bytes(), &ResolveOptions::default()) .expect("minimal_loop must resolve without error"); From 579f52a6a575c78b09cfd353cf7004cf2ed0013e Mon Sep 17 00:00:00 2001 From: Justin Scott Date: Tue, 21 Jul 2026 23:43:27 -0400 Subject: [PATCH 2/2] fix(cxf): reject OBC-prefixed class paths and duplicate export ids; pin every rejection arm (#141) Merge-review round for the minimal exporter. Two new plan-time rejections: a class path already carrying the OBC prefix (it survives the bridge round-trip but can never name a registry class, so its bytes always failed re-import) and any @id collision among emitted nodes (duplicate instance_iris, params named like minted ports) - both typed ExportUnsupported with the owning block as subject, tracked via a membership-only BTreeSet so emission order still derives from vector order alone. Every previously untested rejection arm (bridge oracle, String/Enum connectors, dense-id invariant, backwards connections) now has an exact-triple pin, the direction check names the source owner, and the remaining message pins are full-equality. The accepted emission path is untouched - the checked-in byte golden is byte-identical. Co-Authored-By: Claude Fable 5 --- crates/oce-cxf/src/export.rs | 79 ++++++-- crates/oce-cxf/src/export_tests.rs | 283 +++++++++++++++++++++++++---- crates/oce-cxf/src/lib.rs | 8 +- 3 files changed, 321 insertions(+), 49 deletions(-) diff --git a/crates/oce-cxf/src/export.rs b/crates/oce-cxf/src/export.rs index 3dc317e..d5e8b51 100644 --- a/crates/oce-cxf/src/export.rs +++ b/crates/oce-cxf/src/export.rs @@ -43,7 +43,7 @@ //! [`oce_diag::DiagCode::ExportUnsupported`] error diagnostics whose `subject` is the owning //! block's `instance_iri` (connectors have none) — never a panic. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use oce_diag::{DiagCode, Diagnostic}; use oce_model::{Attrs, Connector, Dir, IntAttrs, ModelGraph, RealAttrs, Value, ValueType}; @@ -73,8 +73,10 @@ const MSG_EMPTY: &str = /// A block without an `instance_iri` cannot name its CXF node (hand-built graphs only; every /// imported block carries one). const MSG_NO_INSTANCE_IRI: &str = "export subset: block has no instance_iri to name its CXF node"; -/// The emitted `@type` would not re-bridge to the identical `class_iri` (a `#` in the class path, -/// or a path already carrying the OBC prefix) — accepting it would silently flip block identity. +/// The class path cannot round-trip through the class-IRI bridge: a `#` in it changes the +/// re-imported fragment (a silent identity flip), and a path already carrying the OBC prefix +/// re-bridges verbatim but can never name a registry class, so its bytes would always fail +/// re-import — both are rejected under this message. const MSG_CLASS_BRIDGE: &str = "export subset: class path does not survive the class-IRI bridge round-trip"; /// Block/connector cross-references disagree (non-dense ids, wrong owner or direction, a @@ -94,6 +96,11 @@ const MSG_ENUM_CONNECTOR: &str = /// root's `hasInput` (hand-built graphs only; the resolver always stores it). const MSG_EXTERNAL_IRI: &str = "export subset: external input carries no boundary IRI to rebuild the root hasInput"; +/// Two emitted nodes would share one `@id` — duplicate block `instance_iri`s, a parameter named +/// like a minted port (`out0`), a boundary IRI reusing another node's id. The document would +/// fail re-import with `DuplicateId`; reject at plan time with the owner named instead. +const MSG_DUPLICATE_ID: &str = + "export subset: emitted node @id collides with another emitted node @id"; /// Everything [`build`] needs, fully validated: no `Option` left to unwrap, no index that can /// miss. Produced by [`plan`] only when the subset checks all pass. @@ -139,6 +146,20 @@ fn reject(message: impl Into, subject: &str) -> Diagnostic { Diagnostic::error(DiagCode::ExportUnsupported, message).with_subject(subject.to_owned()) } +/// Record an `@id` the document will emit. A repeat is rejected at plan time with the owning +/// block as subject — the emitted document would otherwise fail re-import with `DuplicateId` +/// (or worse, silently merge two nodes in a permissive consumer). +fn claim_emitted_id( + seen: &mut BTreeSet, + id: &str, + subject: &str, + diags: &mut Vec, +) { + if !seen.insert(id.to_owned()) { + diags.push(reject(MSG_DUPLICATE_ID, subject)); + } +} + /// The diagnostic subject for a connector: its owning block's `instance_iri`, else a synthetic /// position tag (mirrors the resolver's `connector#N` convention for IRI-less connectors). fn owner_subject(g: &ModelGraph, c: &Connector, position: usize) -> String { @@ -180,6 +201,10 @@ fn plan(g: &ModelGraph) -> Result> { let mut diags: Vec = Vec::new(); let mut port_iri: Vec> = vec![None; g.connectors.len()]; let mut blocks: Vec = Vec::with_capacity(g.blocks.len()); + // Every @id the document will carry, for duplicate detection only — membership checks, + // never iteration, so emission order still derives from the ModelGraph vectors alone. + let mut seen: BTreeSet = BTreeSet::new(); + seen.insert(EXPORT_ROOT_IRI.to_owned()); for (bi, b) in g.blocks.iter().enumerate() { let subject: String = match b.instance_iri.as_deref() { @@ -190,10 +215,16 @@ fn plan(g: &ModelGraph) -> Result> { synthetic } }; + claim_emitted_id(&mut seen, &subject, &subject, &mut diags); let type_iri = format!("{CLASS_IRI_BASE}{}{}", bridge::OBC_PREFIX, b.class_iri); - // Self-oracle: run the actual import bridge over the @type we are about to emit. Anything - // that would re-import under a different class identity is rejected here, loudly. - if bridge::class_path_of(&type_iri) != b.class_iri.as_ref() { + // Self-oracle: run the actual import bridge over the @type we are about to emit — anything + // that would re-import under a different class identity is rejected here, loudly. An + // already-OBC-prefixed class path passes that round-trip (the bridge strips exactly the + // one prefix we just added) yet can never name a registry class, so it is rejected + // explicitly rather than emitted as bytes that always fail re-import. + if b.class_iri.starts_with(bridge::OBC_PREFIX) + || bridge::class_path_of(&type_iri) != b.class_iri.as_ref() + { diags.push(reject(MSG_CLASS_BRIDGE, &subject)); } @@ -210,7 +241,9 @@ fn plan(g: &ModelGraph) -> Result> { continue; } if let Some(binding) = param_binding(name, value, &subject, &mut diags) { - params.push((format!("{subject}.{name}"), binding)); + let param_id = format!("{subject}.{name}"); + claim_emitted_id(&mut seen, ¶m_id, &subject, &mut diags); + params.push((param_id, binding)); } } @@ -227,6 +260,7 @@ fn plan(g: &ModelGraph) -> Result> { &subject, &mut diags, ) { + claim_emitted_id(&mut seen, &minted, &subject, &mut diags); input_ports.push(minted); } } @@ -243,6 +277,7 @@ fn plan(g: &ModelGraph) -> Result> { &subject, &mut diags, ) { + claim_emitted_id(&mut seen, &minted, &subject, &mut diags); output_ports.push(minted); } } @@ -296,10 +331,17 @@ fn plan(g: &ModelGraph) -> Result> { let output_to_input = g.connectors.get(f).is_some_and(|c| c.dir == Dir::Out) && g.connectors.get(t).is_some_and(|c| c.dir == Dir::In); if !output_to_input { - diags.push(Diagnostic::error( - DiagCode::ExportUnsupported, - MSG_STRUCTURE, - )); + // Name the source endpoint's owner when it is in range (else the target's); only a + // fully dangling edge — both endpoints out of range — stays subjectless. + let subject = g + .connectors + .get(f) + .map(|c| owner_subject(g, c, f)) + .or_else(|| g.connectors.get(t).map(|c| owner_subject(g, c, t))); + diags.push(match &subject { + Some(s) => reject(MSG_STRUCTURE, s), + None => Diagnostic::error(DiagCode::ExportUnsupported, MSG_STRUCTURE), + }); continue; } // An unclaimed target was already rejected by the orphan scan; nothing to add here. @@ -314,6 +356,8 @@ fn plan(g: &ModelGraph) -> Result> { for cid in &g.external_inputs { let idx = cid.0 as usize; let Some(c) = g.connectors.get(idx) else { + // Out of range: there is no connector, so no owner to name — genuinely subjectless + // (like the whole-graph dense-id rejection, and unlike every per-node rejection). diags.push(Diagnostic::error( DiagCode::ExportUnsupported, MSG_STRUCTURE, @@ -334,10 +378,15 @@ fn plan(g: &ModelGraph) -> Result> { }; match boundaries.iter_mut().find(|pb| pb.iri == boundary_iri) { Some(pb) => pb.targets.push(target.clone()), - None => boundaries.push(PlannedBoundary { - iri: boundary_iri.to_owned(), - targets: vec![target.clone()], - }), + None => { + // First occurrence mints the boundary node, so its @id joins the emitted set; + // later same-IRI entries reuse the node (fan-out grouping), not a duplicate. + claim_emitted_id(&mut seen, boundary_iri, &subject, &mut diags); + boundaries.push(PlannedBoundary { + iri: boundary_iri.to_owned(), + targets: vec![target.clone()], + }); + } } } diff --git a/crates/oce-cxf/src/export_tests.rs b/crates/oce-cxf/src/export_tests.rs index 2dde969..49accc0 100644 --- a/crates/oce-cxf/src/export_tests.rs +++ b/crates/oce-cxf/src/export_tests.rs @@ -142,11 +142,10 @@ fn enum_valued_parameter_is_rejected_with_the_owning_block_subject() { Some("http://example.org#g36.source.cooling_only_dampers.conPID"), "the first offender in block order is the enum-carrying conPID" ); - assert!( - diags[0].message.contains("parameter `controllerType`") - && diags[0].message.contains("enumeration-valued"), - "got: {}", - diags[0].message + assert_eq!( + diags[0].message, + "export subset: parameter `controllerType` is enumeration-valued, which has no CXF \ + literal form in the minimal export subset" ); assert_eq!(rejection(&graph), diags, "stable across repeated calls"); } @@ -174,10 +173,10 @@ fn non_finite_real_parameters_are_rejected() { diags[0].subject.as_deref(), Some("http://example.org#Hand.con") ); - assert!( - diags[0].message.contains("non-finite Real"), - "got: {}", - diags[0].message + assert_eq!( + diags[0].message, + "export subset: parameter `k` is a non-finite Real, which cannot round-trip through \ + JSON" ); } } @@ -192,10 +191,10 @@ fn dotted_parameter_name_is_rejected() { diags[0].subject.as_deref(), Some("http://example.org#Hand.con") ); - assert!( - diags[0].message.contains("not a bare member name"), - "got: {}", - diags[0].message + assert_eq!( + diags[0].message, + "export subset: parameter name `k.nested` is not a bare member name (re-import recovers \ + the name after the last `.`)" ); } @@ -232,24 +231,26 @@ fn string_parameter_round_trips_through_export_and_reimport() { #[test] fn mismatched_port_wiring_is_rejected_not_exported_shifted() { // The block lists its Out connector under `inputs`: structurally inconsistent — exporting it - // would emit wiring the importer rebuilds differently. + // would emit wiring the importer rebuilds differently. Two diagnostics: the failed claim + // (wrong direction) and the orphan scan (the connector ends up in no port list). let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); graph.blocks[0].inputs = vec![ConnectorId(0)]; graph.blocks[0].outputs = vec![]; let diags = rejection(&graph); - assert!( - diags - .iter() - .any(|d| d.message.contains("structurally inconsistent")), - "got: {diags:?}" - ); + assert_eq!(diags.len(), 2, "got: {diags:?}"); + for d in &diags { + assert_eq!(d.subject.as_deref(), Some("http://example.org#Hand.con")); + assert_eq!( + d.message, + "export subset: block/connector wiring is structurally inconsistent" + ); + } } -#[test] -fn external_input_without_a_boundary_iri_is_rejected() { - // The resolver always stores the elided boundary IRI on the driven child input; a hand-built - // graph without one cannot rebuild the root's hasInput. - let graph = ModelGraph { +/// A hand-built one-block graph around `Abs` (one In, one Out connector) — the smallest shape +/// with an input, for the external-input and connection-direction rejection tests. +fn abs_graph() -> ModelGraph { + ModelGraph { blocks: vec![BlockInstance { id: BlockId(0), class_iri: Arc::from("CDL.Reals.Abs"), @@ -264,17 +265,25 @@ fn external_input_without_a_boundary_iri_is_rejected() { Connector::new(ConnectorId(1), BlockId(0), Dir::Out, ValueType::Real, 1), ], connections: vec![], - external_inputs: vec![ConnectorId(0)], - }; + external_inputs: vec![], + } +} + +#[test] +fn external_input_without_a_boundary_iri_is_rejected() { + // The resolver always stores the elided boundary IRI on the driven child input; a hand-built + // graph without one cannot rebuild the root's hasInput. + let mut graph = abs_graph(); + graph.external_inputs = vec![ConnectorId(0)]; let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); assert_eq!( diags[0].subject.as_deref(), Some("http://example.org#Hand.abs") ); - assert!( - diags[0].message.contains("no boundary IRI"), - "got: {}", - diags[0].message + assert_eq!( + diags[0].message, + "export subset: external input carries no boundary IRI to rebuild the root hasInput" ); } @@ -296,11 +305,188 @@ fn hand_built_attr_bearing_connector_is_rejected() { ); } +#[test] +fn class_path_with_a_hash_is_rejected_by_the_bridge_oracle() { + // A `#` in the class path shifts the fragment `class_path_of` extracts on re-import + // ("Constant" here, not the full path) — a silent identity flip, caught by running the real + // bridge over the @type before emission. + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.blocks[0].class_iri = Arc::from("CDL.Reals.Sources#Constant"); + let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); + assert_eq!( + diags[0].message, + "export subset: class path does not survive the class-IRI bridge round-trip" + ); +} + +#[test] +fn obc_prefixed_class_path_is_rejected_before_emission() { + // An already-OBC-prefixed class path PASSES the bridge round-trip (the bridge strips exactly + // the one prefix the exporter adds), but registry keys never carry the prefix, so the bytes + // would always fail re-import with ClassNotFound. Rejected explicitly instead. + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.blocks[0].class_iri = Arc::from("Buildings.Controls.OBC.CDL.Reals.Sources.Constant"); + let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); + assert_eq!( + diags[0].message, + "export subset: class path does not survive the class-IRI bridge round-trip" + ); +} + +#[test] +fn string_typed_connector_is_rejected_with_the_owning_block_subject() { + // Silent acceptance would emit the `S231:Real` placeholder as the connector's datatype — an + // exported document that lies about the signal type. + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.connectors[0] = + Connector::new(ConnectorId(0), BlockId(0), Dir::Out, ValueType::String, 0); + let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); + assert_eq!( + diags[0].message, + "export subset: String connectors are not permitted in CXF (§7.8)" + ); +} + +#[test] +fn enum_typed_connector_is_rejected_with_the_owning_block_subject() { + // Hand-built for isolation, but this arm is import-reachable too: the resolver's + // `value_type_of_datatype` maps enum datatype IRIs to `ValueType::Enum`, so an imported + // graph can carry one. Same placeholder-datatype hazard as the String arm. + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.connectors[0] = Connector::new( + ConnectorId(0), + BlockId(0), + Dir::Out, + ValueType::Enum(oce_model::EnumClassId::SIMPLE_CONTROLLER), + 0, + ); + let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); + assert_eq!( + diags[0].message, + "export subset: enumeration-typed connectors are outside the minimal export subset" + ); +} + +#[test] +fn non_dense_ids_are_rejected_wholesale_without_a_subject() { + // Every position-based index in plan() (and re-import itself) assumes BlockId.0 / + // ConnectorId.0 equal vector position; a non-dense graph is rejected whole rather than + // exported with shifted identities. Whole-graph defect, so no subject. + let expect_wholesale = |graph: &ModelGraph| { + let diags = rejection(graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); + assert_eq!(diags[0].subject, None); + assert_eq!( + diags[0].message, + "export subset: block/connector wiring is structurally inconsistent" + ); + }; + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.blocks[0].id = BlockId(5); + expect_wholesale(&graph); + + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.connectors[0].id = ConnectorId(7); + expect_wholesale(&graph); +} + +#[test] +fn backwards_connection_is_rejected_with_the_source_owner_subject() { + // An in-range connection that is not output→input: emitting it would put isConnectedTo on + // an input port and the importer would rebuild different wiring. The subject is the source + // endpoint's owning block. + let mut graph = abs_graph(); + graph.connections = vec![oce_model::Connection { + from: ConnectorId(0), // In — backwards + to: ConnectorId(1), // Out + }]; + let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.abs") + ); + assert_eq!( + diags[0].message, + "export subset: block/connector wiring is structurally inconsistent" + ); +} + +#[test] +fn duplicate_block_instance_iris_are_rejected() { + // Two blocks sharing one instance_iri would emit two @graph nodes with one @id (plus + // colliding minted port ids) — re-import would fail DuplicateId. Rejected at plan time: + // one diagnostic for the block-node collision, one for the colliding minted port. + let mut graph = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + graph.blocks.push(BlockInstance { + id: BlockId(1), + class_iri: Arc::from("CDL.Reals.Sources.Constant"), + inputs: vec![], + outputs: vec![ConnectorId(1)], + params: ParamTable::default(), + decl_order: 1, + instance_iri: Some(Arc::from("http://example.org#Hand.con")), // same @id as block 0 + }); + graph.connectors.push(Connector::new( + ConnectorId(1), + BlockId(1), + Dir::Out, + ValueType::Real, + 1, + )); + let diags = rejection(&graph); + assert_eq!(diags.len(), 2, "got: {diags:?}"); + for d in &diags { + assert_eq!(d.subject.as_deref(), Some("http://example.org#Hand.con")); + assert_eq!( + d.message, + "export subset: emitted node @id collides with another emitted node @id" + ); + } +} + +#[test] +fn parameter_named_like_a_minted_port_id_is_rejected() { + // A parameter literally named `out0` mints the same @id as the block's first minted output + // port — the emitted document would carry two nodes with one @id. Rejected at plan time. + let graph = constant_graph(vec![(Arc::from("out0"), Value::Real(1.0))]); + let diags = rejection(&graph); + assert_eq!(diags.len(), 1, "got: {diags:?}"); + assert_eq!( + diags[0].subject.as_deref(), + Some("http://example.org#Hand.con") + ); + assert_eq!( + diags[0].message, + "export subset: emitted node @id collides with another emitted node @id" + ); +} + #[test] fn every_rejection_path_returns_instead_of_panicking() { // The never-panics property from the R4 floor survives on every path: exercise each // rejection shape and the acceptance shape through the same call. - let graphs: Vec = vec![ + let mut graphs: Vec = vec![ ModelGraph::new(), import(MINIMAL_LOOP), import(CONNECTOR_ATTRS), @@ -313,7 +499,40 @@ fn every_rejection_path_returns_instead_of_panicking() { ordinal: 1, }, )]), + constant_graph(vec![(Arc::from("out0"), Value::Real(1.0))]), ]; + let with_class = |class: &str| { + let mut g = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + g.blocks[0].class_iri = Arc::from(class); + g + }; + graphs.push(with_class("CDL.Reals.Sources#Constant")); + graphs.push(with_class( + "Buildings.Controls.OBC.CDL.Reals.Sources.Constant", + )); + for value_type in [ + ValueType::String, + ValueType::Enum(oce_model::EnumClassId::SIMPLE_CONTROLLER), + ] { + let mut g = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + g.connectors[0] = Connector::new(ConnectorId(0), BlockId(0), Dir::Out, value_type, 0); + graphs.push(g); + } + let mut non_dense = constant_graph(vec![(Arc::from("k"), Value::Real(2.0))]); + non_dense.blocks[0].id = BlockId(5); + graphs.push(non_dense); + let mut backwards = abs_graph(); + backwards.connections = vec![oce_model::Connection { + from: ConnectorId(0), + to: ConnectorId(1), + }]; + graphs.push(backwards); + let mut no_boundary_iri = abs_graph(); + no_boundary_iri.external_inputs = vec![ConnectorId(0)]; + graphs.push(no_boundary_iri); + let mut out_of_range_external = abs_graph(); + out_of_range_external.external_inputs = vec![ConnectorId(9)]; + graphs.push(out_of_range_external); for g in &graphs { // Ok or a typed Validation error — anything else (or a panic) fails the test. match export(g) { diff --git a/crates/oce-cxf/src/lib.rs b/crates/oce-cxf/src/lib.rs index 1356ee2..5adb421 100644 --- a/crates/oce-cxf/src/lib.rs +++ b/crates/oce-cxf/src/lib.rs @@ -105,8 +105,12 @@ pub fn import_cxf( /// /// Accepts the flat, ground, single-root, scalar-parameter, attribute-free subset (the shape the /// resolver produces for documents like the `minimal_loop` fixture). The emitted bytes are -/// deterministic — repeated calls are byte-identical — and re-import to a `ModelGraph` -/// bit-identical to the input (Reals by IEEE-754 bits). The source root `@id` is not recorded in +/// deterministic — repeated calls are byte-identical — and, for any accepted graph whose class +/// paths name registered block classes (everything the resolver itself produces), re-import to a +/// `ModelGraph` bit-identical to the input (Reals by IEEE-754 bits). Export deliberately takes +/// no registry dependency, so a hand-built graph with an unregistered class path still exports; +/// its bytes then fail re-import loudly with `ClassNotFound` — never silently. The source root +/// `@id` is not recorded in /// [`ModelGraph`], so the root composite is emitted under the fixed synthetic IRI /// `urn:open-control:cxf-export:root`; block nodes reuse their `instance_iri` verbatim, and port /// nodes get deterministically minted `@id`s (re-import rebuilds wiring from `isConnectedTo`, so