Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/oce-cxf/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down
612 changes: 612 additions & 0 deletions crates/oce-cxf/src/export.rs

Large diffs are not rendered by default.

555 changes: 510 additions & 45 deletions crates/oce-cxf/src/export_tests.rs

Large diffs are not rendered by default.

47 changes: 30 additions & 17 deletions crates/oce-cxf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -99,20 +101,31 @@ 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, 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
/// 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<Vec<u8>, 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<Vec<u8>, CxfError> {
let doc = export::document(model)?;
write_document(&doc)
}
127 changes: 127 additions & 0 deletions crates/oce-cxf/tests/export_golden.rs
Original file line number Diff line number Diff line change
@@ -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<String> = vec![EXPORT_ROOT_IRI.to_owned()];
let mut port_iri: Vec<Option<String>> = 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"
);
}
Loading
Loading