diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa41be..f8a4420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ### Added +- **145J-B calibration evidence gate**: `CalibrationEvidenceState` and the + feature-gated `rosbag2_calibration_evidence` example validate explicit, + source-bound clock quality values and a root-to-front/rear extrinsic graph. + `rosbag2_mission_cockpit` can consume the resulting receipt and expose its + registration blockers without applying an unverified clock or transform. - **145J-A interactive Mission Cockpit**: `MissionCockpitState` and the feature-gated `rosbag2_mission_cockpit` example join source-bound 145H/145I receipts with a bounded source-indexed XYZ sample. The self-contained diff --git a/crates/spatialrust-ros2/Cargo.toml b/crates/spatialrust-ros2/Cargo.toml index a1d7c2d..6467efb 100644 --- a/crates/spatialrust-ros2/Cargo.toml +++ b/crates/spatialrust-ros2/Cargo.toml @@ -73,6 +73,10 @@ required-features = ["rosbag2-sqlite"] name = "rosbag2_calibration_observatory" required-features = ["rosbag2-sqlite"] +[[example]] +name = "rosbag2_calibration_evidence" +required-features = ["rosbag2-sqlite"] + [[example]] name = "rosbag2_replay_demo" required-features = ["rosbag2-sqlite"] diff --git a/crates/spatialrust-ros2/examples/rosbag2_calibration_evidence.rs b/crates/spatialrust-ros2/examples/rosbag2_calibration_evidence.rs new file mode 100644 index 0000000..10fa71c --- /dev/null +++ b/crates/spatialrust-ros2/examples/rosbag2_calibration_evidence.rs @@ -0,0 +1,929 @@ +//! Register source-bound clock and front/rear extrinsic evidence. +//! +//! This example validates explicit JSON evidence documents; it does not solve +//! calibration, infer a root frame, or apply a timestamp/TF transform. A +//! blocked state is still written so missing or mismatched evidence remains +//! auditable on the external result disk. + +use std::{ + collections::{BTreeMap, BTreeSet}, + env, + error::Error, + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use spatialrust_io::{ + DatasetManifest, FileReceipt, ReceiptRole, StoragePreflight, DEFAULT_MIN_OUTPUT_FREE_BYTES, +}; +use spatialrust_viewer::{ + CalibrationArtifact, CalibrationEvidenceClock, CalibrationEvidenceFrame, + CalibrationEvidenceState, ClockCalibration, FrameTransform, StudioSource, +}; + +const CLOCK_SCHEMA: &str = "spatialrust.calibration.clock-evidence"; +const FRAME_SCHEMA: &str = "spatialrust.calibration.frame-evidence"; +const READINESS_SCHEMA: &str = "spatialrust.rosbag2.calibration-readiness"; +const EVIDENCE_VERSION: u32 = 1; +const STATE_FILE: &str = "calibration-evidence.json"; +const HTML_FILE: &str = "calibration-evidence.html"; +const MANIFEST_FILE: &str = "calibration-evidence.manifest.json"; + +#[derive(Debug)] +struct Config { + input: PathBuf, + readiness: PathBuf, + clock_artifact: Option, + frame_artifact: Option, + output_dir: PathBuf, + expected_sha256: String, + root_frame: String, + front_frame: String, + rear_frame: String, + min_output_free_bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceBindingDocument { + path: String, + size_bytes: u64, + sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ClockEvidenceDocument { + schema: String, + version: u32, + source: SourceBindingDocument, + source_domain: String, + target_domain: String, + method: String, + time_basis: String, + sample_count: u64, + median_offset_nanos: Option, + p95_abs_offset_nanos: Option, + drift_ppm: Option, + uncertainty_nanos: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FrameEvidenceDocument { + schema: String, + version: u32, + source: SourceBindingDocument, + method: String, + root_frame: String, + edges: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FrameEvidenceEdgeDocument { + parent_frame: String, + child_frame: String, + translation_m: [f64; 3], + rotation_xyzw: [f64; 4], + stamp_nanos: Option, +} + +#[derive(Debug)] +struct ArtifactFile { + receipt: Option, + path: Option, +} + +fn main() { + if let Err(error) = run() { + eprintln!("rosbag2-calibration-evidence: {error}"); + std::process::exit(2); + } +} + +fn run() -> Result<(), Box> { + let config = parse_args(env::args().skip(1))?; + validate_config(&config)?; + ensure_outputs_absent(&config)?; + if !config.input.is_file() { + return Err(format!("input bag '{}' is not a regular file", config.input.display()).into()); + } + + let output_parent = + config.output_dir.parent().ok_or("--output-dir must have an existing parent directory")?; + let preflight = StoragePreflight::check(output_parent, config.min_output_free_bytes)?; + let input_receipt = FileReceipt::from_path(ReceiptRole::Input, &config.input)?; + let input_size = input_receipt.size_bytes.ok_or("input size was not produced")?; + let observed_sha256 = input_receipt.sha256.clone().ok_or("input checksum was not produced")?; + let input_path = config.input.display().to_string(); + let source_identity_matches = observed_sha256 == config.expected_sha256; + let source = StudioSource::try_new( + "canonical rosbag2 input", + input_path.clone(), + config.expected_sha256.clone(), + observed_sha256.clone(), + source_identity_matches, + )?; + + let readiness_receipt = FileReceipt::from_path(ReceiptRole::Auxiliary, &config.readiness)?; + let readiness: Value = read_json(&config.readiness)?; + let mut blockers = Vec::new(); + if !source_identity_matches { + push_blocker( + &mut blockers, + format!( + "input SHA-256 mismatch: expected {}, observed {}", + config.expected_sha256, observed_sha256 + ), + ); + } + inspect_readiness(&readiness, &input_path, input_size, &observed_sha256, &mut blockers)?; + + let clock_file = inspect_artifact_file(config.clock_artifact.as_deref())?; + let frame_file = inspect_artifact_file(config.frame_artifact.as_deref())?; + let (clock_artifact, clock, clock_blockers) = + build_clock_evidence(&clock_file, &input_path, input_size, &observed_sha256)?; + let (frame_artifact, frame, frame_blockers) = build_frame_evidence( + &frame_file, + &input_path, + input_size, + &observed_sha256, + &config.root_frame, + &config.front_frame, + &config.rear_frame, + )?; + blockers.extend(clock_blockers); + blockers.extend(frame_blockers); + if !clock.registration_ready() { + push_blocker(&mut blockers, "clock evidence registration is incomplete"); + } + if !frame.registration_ready() { + push_blocker(&mut blockers, "frame evidence has no complete root-to-front/rear path"); + } + + let state = CalibrationEvidenceState::try_new( + format!("Calibration Evidence — {}", file_label(&config.input)), + source, + clock_artifact, + frame_artifact, + clock, + frame, + blockers, + )?; + state.validate()?; + + fs::create_dir_all(&config.output_dir)?; + let state_path = config.output_dir.join(STATE_FILE); + let html_path = config.output_dir.join(HTML_FILE); + let manifest_path = config.output_dir.join(MANIFEST_FILE); + write_json_atomically(&state_path, &state)?; + write_text_atomically(&html_path, &render_dashboard(&state)?)?; + + let mut manifest = DatasetManifest::new(); + manifest.entries.push(input_receipt); + manifest.entries.push(readiness_receipt); + if let Some(receipt) = clock_file.receipt { + manifest.entries.push(receipt); + } + if let Some(receipt) = frame_file.receipt { + manifest.entries.push(receipt); + } + manifest.entries.push(FileReceipt::from_path(ReceiptRole::Output, &state_path)?); + manifest.entries.push(FileReceipt::from_path(ReceiptRole::Output, &html_path)?); + let validation = manifest.validate_local_files()?; + manifest.write_json(&manifest_path)?; + + println!("{}", serde_json::to_string_pretty(&state)?); + println!( + "Calibration evidence receipt: {} (registration_ready={})", + state_path.display(), + state.registration_ready + ); + println!("Calibration evidence dashboard: {}", html_path.display()); + println!( + "Calibration evidence manifest: {} (checked_files={}, total_bytes={}, free_before={})", + manifest_path.display(), + validation.checked_local_files, + validation.total_bytes, + preflight.available_bytes + ); + if !state.registration_ready { + return Err( + "calibration evidence registration gate failed; see the external receipt blockers" + .into(), + ); + } + Ok(()) +} + +fn build_clock_evidence( + artifact_file: &ArtifactFile, + input_path: &str, + input_size: u64, + observed_sha256: &str, +) -> Result<(CalibrationArtifact, CalibrationEvidenceClock, Vec), Box> { + let Some(path) = artifact_file.path.as_deref() else { + return Ok(( + CalibrationArtifact::try_new("clock_evidence", "not_registered", None, None, false)?, + unregistered_clock(), + vec!["clock evidence file was not supplied".into()], + )); + }; + let Some(receipt) = artifact_file.receipt.as_ref() else { + return Ok(( + CalibrationArtifact::try_new( + "clock_evidence", + "missing", + Some(path.to_owned()), + None, + false, + )?, + invalid_clock("clock evidence file is missing"), + vec![format!("clock evidence file '{}' is missing", path)], + )); + }; + let artifact_path = Some(path.to_owned()); + let artifact_sha = receipt.sha256.clone(); + let document: ClockEvidenceDocument = match read_json(Path::new(path)) { + Ok(document) => document, + Err(error) => { + return Ok(( + CalibrationArtifact::try_new( + "clock_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + invalid_clock(format!("clock evidence JSON is invalid: {error}")), + vec![format!("clock evidence JSON is invalid: {error}")], + )); + } + }; + if document.schema != CLOCK_SCHEMA || document.version != EVIDENCE_VERSION { + return Ok(( + CalibrationArtifact::try_new( + "clock_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + invalid_clock("clock evidence schema or version is unsupported"), + vec!["clock evidence schema or version is unsupported".into()], + )); + } + if !source_binding_matches(&document.source, input_path, input_size, observed_sha256) { + return Ok(( + CalibrationArtifact::try_new( + "clock_evidence", + "source_mismatch", + artifact_path, + artifact_sha, + false, + )?, + invalid_clock("clock evidence source identity does not match the canonical input"), + vec!["clock evidence source identity does not match the canonical input".into()], + )); + } + let calibration = match ClockCalibration::try_new( + "registered", + document.time_basis, + document.sample_count, + document.median_offset_nanos, + document.p95_abs_offset_nanos, + document.drift_ppm, + document.uncertainty_nanos, + true, + false, + ) { + Ok(calibration) => calibration, + Err(error) => { + return Ok(( + CalibrationArtifact::try_new( + "clock_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + invalid_clock(error.to_string()), + vec![format!("clock evidence values are invalid: {error}")], + )); + } + }; + let clock = match CalibrationEvidenceClock::try_new( + document.source_domain, + document.target_domain, + document.method, + calibration, + ) { + Ok(clock) => clock, + Err(error) => { + return Ok(( + CalibrationArtifact::try_new( + "clock_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + invalid_clock(error.to_string()), + vec![format!("clock evidence contract is invalid: {error}")], + )); + } + }; + Ok(( + CalibrationArtifact::try_new( + "clock_evidence", + "registered", + artifact_path, + artifact_sha, + true, + )?, + clock, + Vec::new(), + )) +} + +fn build_frame_evidence( + artifact_file: &ArtifactFile, + input_path: &str, + input_size: u64, + observed_sha256: &str, + root_frame: &str, + front_frame: &str, + rear_frame: &str, +) -> Result<(CalibrationArtifact, CalibrationEvidenceFrame, Vec), Box> { + let required_frames = required_frames_for(front_frame, rear_frame); + let Some(path) = artifact_file.path.as_deref() else { + return Ok(( + CalibrationArtifact::try_new("frame_evidence", "not_registered", None, None, false)?, + empty_frame("frame evidence file was not supplied", root_frame, required_frames)?, + vec!["frame evidence file was not supplied".into()], + )); + }; + let Some(receipt) = artifact_file.receipt.as_ref() else { + return Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "missing", + Some(path.to_owned()), + None, + false, + )?, + empty_frame("frame evidence file is missing", root_frame, required_frames)?, + vec![format!("frame evidence file '{}' is missing", path)], + )); + }; + let artifact_path = Some(path.to_owned()); + let artifact_sha = receipt.sha256.clone(); + let document: FrameEvidenceDocument = match read_json(Path::new(path)) { + Ok(document) => document, + Err(error) => { + return Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + empty_frame( + format!("frame evidence JSON is invalid: {error}"), + root_frame, + required_frames, + )?, + vec![format!("frame evidence JSON is invalid: {error}")], + )); + } + }; + if document.schema != FRAME_SCHEMA || document.version != EVIDENCE_VERSION { + return Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + empty_frame( + "frame evidence schema or version is unsupported", + root_frame, + required_frames, + )?, + vec!["frame evidence schema or version is unsupported".into()], + )); + } + if !source_binding_matches(&document.source, input_path, input_size, observed_sha256) { + return Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "source_mismatch", + artifact_path, + artifact_sha, + false, + )?, + empty_frame( + "frame evidence source identity does not match the canonical input", + root_frame, + required_frames, + )?, + vec!["frame evidence source identity does not match the canonical input".into()], + )); + } + if document.root_frame != root_frame { + return Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "root_mismatch", + artifact_path, + artifact_sha, + false, + )?, + empty_frame( + "frame evidence root frame does not match the requested root", + root_frame, + required_frames, + )?, + vec!["frame evidence root frame does not match the requested root".into()], + )); + } + let mut frame_ids = BTreeSet::from([document.root_frame.clone()]); + frame_ids.extend(required_frames.values().cloned()); + let mut edges = Vec::with_capacity(document.edges.len()); + for edge in document.edges { + frame_ids.insert(edge.parent_frame.clone()); + frame_ids.insert(edge.child_frame.clone()); + let transform = match FrameTransform::try_new( + edge.parent_frame, + edge.child_frame, + edge.translation_m, + edge.rotation_xyzw, + edge.stamp_nanos, + true, + true, + ) { + Ok(transform) => transform, + Err(error) => { + return Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + empty_frame( + format!("frame evidence edge is invalid: {error}"), + root_frame, + required_frames, + )?, + vec![format!("frame evidence edge is invalid: {error}")], + )); + } + }; + edges.push(transform); + } + let frame = match CalibrationEvidenceFrame::try_new( + document.method, + document.root_frame, + required_frames, + frame_ids.into_iter().collect(), + edges, + ) { + Ok(frame) => frame, + Err(error) => { + return Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "invalid", + artifact_path, + artifact_sha, + false, + )?, + empty_frame( + format!("frame evidence contract is invalid: {error}"), + root_frame, + required_frames_for(front_frame, rear_frame), + )?, + vec![format!("frame evidence contract is invalid: {error}")], + )); + } + }; + let mut blockers = Vec::new(); + if !frame.registration_ready() { + blockers.push("frame evidence graph does not connect root to both required sensors".into()); + } + Ok(( + CalibrationArtifact::try_new( + "frame_evidence", + "registered", + artifact_path, + artifact_sha, + true, + )?, + frame, + blockers, + )) +} + +fn required_frames_for(front_frame: &str, rear_frame: &str) -> BTreeMap { + BTreeMap::from([ + ("front".to_owned(), front_frame.to_owned()), + ("rear".to_owned(), rear_frame.to_owned()), + ]) +} + +fn empty_frame( + method: impl Into, + root_frame: &str, + required_frames: BTreeMap, +) -> Result> { + Ok(CalibrationEvidenceFrame::try_new( + method, + root_frame, + required_frames, + Vec::new(), + Vec::new(), + )?) +} + +fn unregistered_clock() -> CalibrationEvidenceClock { + CalibrationEvidenceClock::try_new( + "unknown", + "uncalibrated", + "not_registered", + ClockCalibration::try_new( + "not_registered", + "PointCloud2 header stamp; no clock calibration applied", + 0, + None, + None, + None, + None, + false, + false, + ) + .expect("static unregistered clock must be valid"), + ) + .expect("static unregistered clock evidence must be valid") +} + +fn invalid_clock(reason: impl Into) -> CalibrationEvidenceClock { + CalibrationEvidenceClock::try_new( + "unknown", + "uncalibrated", + reason, + ClockCalibration::try_new( + "invalid", + "PointCloud2 header stamp; clock evidence rejected", + 0, + None, + None, + None, + None, + false, + false, + ) + .expect("static invalid clock must be valid"), + ) + .expect("static invalid clock evidence must be valid") +} + +fn inspect_artifact_file(path: Option<&Path>) -> Result> { + let Some(path) = path else { + return Ok(ArtifactFile { receipt: None, path: None }); + }; + let path_string = path.display().to_string(); + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(ArtifactFile { + receipt: Some(FileReceipt::from_path(ReceiptRole::Auxiliary, path)?), + path: Some(path_string), + }), + Ok(_) => Ok(ArtifactFile { receipt: None, path: Some(path_string) }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(ArtifactFile { receipt: None, path: Some(path_string) }) + } + Err(error) => Err(error.into()), + } +} + +fn source_binding_matches( + source: &SourceBindingDocument, + input_path: &str, + input_size: u64, + observed_sha256: &str, +) -> bool { + source.path == input_path && source.size_bytes == input_size && source.sha256 == observed_sha256 +} + +fn inspect_readiness( + readiness: &Value, + input_path: &str, + input_size: u64, + observed_sha256: &str, + blockers: &mut Vec, +) -> Result<(), Box> { + if readiness.get("schema").and_then(Value::as_str) != Some(READINESS_SCHEMA) { + push_blocker(blockers, "calibration readiness schema is unsupported"); + } + if readiness.get("version").and_then(Value::as_u64) != Some(EVIDENCE_VERSION as u64) { + push_blocker(blockers, "calibration readiness version is unsupported"); + } + let path = string_at(readiness, &["input", "path"]); + let sha256 = string_at(readiness, &["input", "sha256"]); + let size_bytes = + readiness.get("input").and_then(|input| input.get("size_bytes")).and_then(Value::as_u64); + if path.as_deref() != Some(input_path) + || sha256.as_deref() != Some(observed_sha256) + || size_bytes != Some(input_size) + { + push_blocker(blockers, "calibration readiness receipt is not bound to the canonical input"); + } + if !readiness.get("registration_ready").and_then(Value::as_bool).unwrap_or(false) { + push_blocker(blockers, "calibration readiness registration is incomplete"); + } + for blocker in string_array(readiness.get("blockers")) { + push_blocker(blockers, format!("readiness: {blocker}")); + } + Ok(()) +} + +fn next_value( + args: &mut impl Iterator, + flag: &str, +) -> Result> { + args.next().ok_or_else(|| format!("{flag} requires another value").into()) +} + +fn parse_args(args: impl IntoIterator) -> Result> { + let mut args = args.into_iter(); + let input = PathBuf::from(args.next().ok_or_else(usage)?); + let mut readiness = None; + let mut clock_artifact = None; + let mut frame_artifact = None; + let mut output_dir = None; + let mut expected_sha256 = None; + let mut root_frame = None; + let mut front_frame = None; + let mut rear_frame = None; + let mut min_output_free_bytes = DEFAULT_MIN_OUTPUT_FREE_BYTES; + while let Some(flag) = args.next() { + match flag.as_str() { + "--readiness" => readiness = Some(PathBuf::from(next_value(&mut args, &flag)?)), + "--clock-artifact" => { + clock_artifact = Some(PathBuf::from(next_value(&mut args, &flag)?)) + } + "--frame-artifact" => { + frame_artifact = Some(PathBuf::from(next_value(&mut args, &flag)?)) + } + "--output-dir" => output_dir = Some(PathBuf::from(next_value(&mut args, &flag)?)), + "--expected-input-sha256" => expected_sha256 = Some(next_value(&mut args, &flag)?), + "--root-frame" => root_frame = Some(next_value(&mut args, &flag)?), + "--front-frame" => front_frame = Some(next_value(&mut args, &flag)?), + "--rear-frame" => rear_frame = Some(next_value(&mut args, &flag)?), + "--min-output-free-bytes" => { + min_output_free_bytes = next_value(&mut args, &flag)?.parse()? + } + "-h" | "--help" => return Err(usage().into()), + _ => return Err(format!("unknown option '{flag}'\n{}", usage()).into()), + } + } + Ok(Config { + input, + readiness: readiness.ok_or("--readiness is required")?, + clock_artifact, + frame_artifact, + output_dir: output_dir.ok_or("--output-dir is required")?, + expected_sha256: expected_sha256.ok_or("--expected-input-sha256 is required")?, + root_frame: root_frame.ok_or("--root-frame is required")?, + front_frame: front_frame.ok_or("--front-frame is required")?, + rear_frame: rear_frame.ok_or("--rear-frame is required")?, + min_output_free_bytes, + }) +} + +fn validate_config(config: &Config) -> Result<(), Box> { + let paths = [ + ("input", &config.input), + ("readiness", &config.readiness), + ("output", &config.output_dir), + ]; + if paths.iter().any(|(_, path)| !path.is_absolute()) + || config.clock_artifact.as_ref().is_some_and(|path| !path.is_absolute()) + || config.frame_artifact.as_ref().is_some_and(|path| !path.is_absolute()) + { + return Err("input, readiness, artifact, and output paths must be absolute".into()); + } + for (label, value) in [ + ("root frame", config.root_frame.as_str()), + ("front frame", config.front_frame.as_str()), + ("rear frame", config.rear_frame.as_str()), + ] { + if value.trim().is_empty() { + return Err(format!("{label} must not be empty").into()); + } + } + if config.front_frame == config.rear_frame + || config.root_frame == config.front_frame + || config.root_frame == config.rear_frame + { + return Err("root, front, and rear frames must be distinct".into()); + } + if config.min_output_free_bytes == 0 { + return Err("--min-output-free-bytes must be greater than zero".into()); + } + validate_sha256(&config.expected_sha256) +} + +fn ensure_outputs_absent(config: &Config) -> Result<(), Box> { + if config.output_dir.exists() { + return Err(format!( + "output directory '{}' already exists; choose a new run directory", + config.output_dir.display() + ) + .into()); + } + Ok(()) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result> { + Ok(serde_json::from_str(&fs::read_to_string(path)?)?) +} + +fn write_json_atomically(path: &Path, value: &T) -> Result<(), Box> { + write_text_atomically(path, &format!("{}\n", serde_json::to_string_pretty(value)?)) +} + +fn write_text_atomically(path: &Path, text: &str) -> Result<(), Box> { + if path.exists() { + return Err(format!("output '{}' already exists", path.display()).into()); + } + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + fs::write(&temporary, text)?; + fs::rename(&temporary, path)?; + Ok(()) +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + current.as_str().map(str::to_owned) +} + +fn string_array(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .into_iter() + .flat_map(|values| values.iter()) + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() +} + +fn push_blocker(blockers: &mut Vec, blocker: impl Into) { + let blocker = blocker.into(); + if !blockers.iter().any(|existing| existing == &blocker) { + blockers.push(blocker); + } +} + +fn validate_sha256(value: &str) -> Result<(), Box> { + if value.len() != 64 + || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + || value.bytes().any(|byte| byte.is_ascii_uppercase()) + { + return Err("--expected-input-sha256 must be 64 lowercase hexadecimal characters".into()); + } + Ok(()) +} + +fn file_label(path: &Path) -> String { + path.file_name().and_then(|name| name.to_str()).unwrap_or("input").to_owned() +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn render_dashboard(state: &CalibrationEvidenceState) -> Result> { + let state_json = serde_json::to_string(state)?.replace(" + +__TITLE__
+
SpatialRust / 145J-B calibration evidence gate
__TITLE__
+
+

Source identity

+

Clock evidence

+

Frame graph

+

Registration

+

Clock provenance

+

Required sensor paths

+

Source-bound extrinsic edges

+

Registration blockers

    +

    Evidence receipts

    +

    Portable JSON state

    +
    +"##; + Ok(template.replace("__TITLE__", &title).replace("__STATE_JSON__", &state_json)) +} + +fn usage() -> String { + "usage: rosbag2_calibration_evidence INPUT_DB3 --readiness ABSOLUTE_READINESS_JSON \ + --output-dir ABSOLUTE_OUTPUT_DIR --expected-input-sha256 SHA256 \ + --root-frame FRAME --front-frame FRAME --rear-frame FRAME \ + [--clock-artifact ABSOLUTE_JSON] [--frame-artifact ABSOLUTE_JSON] \ + [--min-output-free-bytes BYTES]" + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn args() -> Vec { + [ + "/media/input.db3", + "--readiness", + "/media/readiness.json", + "--output-dir", + "/media/evidence", + "--expected-input-sha256", + SHA, + "--root-frame", + "base_link", + "--front-frame", + "lidar_front", + "--rear-frame", + "lidar_rear", + ] + .into_iter() + .map(str::to_owned) + .collect() + } + + #[test] + fn parses_explicit_frame_contract() { + let config = parse_args(args()).unwrap(); + assert_eq!(config.root_frame, "base_link"); + assert_eq!(config.front_frame, "lidar_front"); + assert!(config.clock_artifact.is_none()); + validate_config(&config).unwrap(); + } + + #[test] + fn rejects_relative_paths_and_colliding_frames() { + let mut relative = args(); + relative[0] = "input.db3".into(); + assert!(parse_args(relative).and_then(|config| validate_config(&config)).is_err()); + + let mut duplicate = args(); + let rear_index = duplicate.iter().position(|value| value == "lidar_rear").unwrap(); + duplicate[rear_index] = "lidar_front".into(); + let config = parse_args(duplicate).unwrap(); + assert!(validate_config(&config).is_err()); + } + + #[test] + fn source_binding_requires_path_size_and_sha() { + let source = SourceBindingDocument { + path: "/media/input.db3".into(), + size_bytes: 42, + sha256: SHA.into(), + }; + assert!(source_binding_matches(&source, "/media/input.db3", 42, SHA)); + assert!(!source_binding_matches(&source, "/media/other.db3", 42, SHA)); + assert!(!source_binding_matches(&source, "/media/input.db3", 43, SHA)); + } + + #[test] + fn missing_artifact_file_is_recorded_without_fabricating_evidence() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing.json"); + let artifact = inspect_artifact_file(Some(&path)).unwrap(); + assert!(artifact.receipt.is_none()); + assert_eq!(artifact.path, Some(path.display().to_string())); + } +} diff --git a/crates/spatialrust-ros2/examples/rosbag2_mission_cockpit.rs b/crates/spatialrust-ros2/examples/rosbag2_mission_cockpit.rs index 269dfbd..82c03ee 100644 --- a/crates/spatialrust-ros2/examples/rosbag2_mission_cockpit.rs +++ b/crates/spatialrust-ros2/examples/rosbag2_mission_cockpit.rs @@ -28,10 +28,10 @@ use spatialrust_sync::{ StampedTime, }; use spatialrust_viewer::{ - EdgePartitionState, LivePublishPacket, LivePublishState, MissionCockpitFrame, - MissionCockpitLayer, MissionCockpitLink, MissionCockpitNode, MissionCockpitPoint, - MissionCockpitState, MissionCockpitSummary, MissionCockpitTimeline, ReplayArtifact, - StudioSource, MISSION_COCKPIT_MAX_SAMPLED_POINTS, + CalibrationEvidenceState, EdgePartitionState, LivePublishPacket, LivePublishState, + MissionCockpitFrame, MissionCockpitLayer, MissionCockpitLink, MissionCockpitNode, + MissionCockpitPoint, MissionCockpitState, MissionCockpitSummary, MissionCockpitTimeline, + ReplayArtifact, StudioSource, MISSION_COCKPIT_MAX_SAMPLED_POINTS, }; const CHUNK_POINTS: usize = 65_536; @@ -49,6 +49,7 @@ struct Config { live_publish_json: PathBuf, edge_partition_json: PathBuf, calibration_readiness: PathBuf, + calibration_evidence: Option, output_dir: PathBuf, expected_sha256: String, sample_points: usize, @@ -110,6 +111,16 @@ fn run() -> Result<(), Box> { let readiness_value: Value = read_json(&config.calibration_readiness)?; let readiness = readiness_gate(&readiness_value, &input_path, &observed_sha256, input_receipt.size_bytes)?; + let calibration_evidence = config + .calibration_evidence + .as_ref() + .map(|path| read_json::(path)) + .transpose()?; + let calibration_evidence_receipt = config + .calibration_evidence + .as_ref() + .map(|path| FileReceipt::from_path(ReceiptRole::Auxiliary, path)) + .transpose()?; let mut blockers = Vec::new(); if !source_identity_match { @@ -163,6 +174,24 @@ fn run() -> Result<(), Box> { for blocker in &edge_partition.blockers { push_blocker(&mut blockers, format!("edge-partition: {blocker}")); } + let evidence_registration_ready = if let Some(evidence) = &calibration_evidence { + evidence.validate()?; + let source_bound = evidence.source.identity_matches + && evidence.source.path == input_path + && evidence.source.observed_sha256 == observed_sha256; + if !source_bound { + push_blocker(&mut blockers, "calibration evidence is bound to a different input"); + } + if !evidence.registration_ready { + push_blocker(&mut blockers, "calibration evidence registration is incomplete"); + } + for blocker in &evidence.blockers { + push_blocker(&mut blockers, format!("calibration-evidence: {blocker}")); + } + source_bound && evidence.registration_ready + } else { + true + }; if !live_publish.summary.calibration_applied || !edge_partition.summary.calibration_applied { push_blocker( &mut blockers, @@ -232,6 +261,7 @@ fn run() -> Result<(), Box> { let calibration_registered = admitted_frames && readiness.source_bound && readiness.registration_ready + && evidence_registration_ready && live_publish.summary.calibration_registered && edge_partition.summary.calibration_registered; let calibration_applied = calibration_registered @@ -261,12 +291,15 @@ fn run() -> Result<(), Box> { let edge_receipt = FileReceipt::from_path(ReceiptRole::Auxiliary, &config.edge_partition_json)?; let readiness_receipt = FileReceipt::from_path(ReceiptRole::Auxiliary, &config.calibration_readiness)?; - let artifacts = vec![ + let mut artifacts = vec![ replay_artifact("canonical-input", &input_receipt)?, replay_artifact("live-publish", &live_receipt)?, replay_artifact("edge-partition", &edge_receipt)?, replay_artifact("calibration-readiness", &readiness_receipt)?, ]; + if let Some(receipt) = &calibration_evidence_receipt { + artifacts.push(replay_artifact("calibration-evidence", receipt)?); + } let expected_frame_ids = live_publish.expected_frame_ids.clone(); let state = MissionCockpitState::try_new( format!("Spatial Mission Cockpit — {}", file_label(&config.input)), @@ -290,6 +323,9 @@ fn run() -> Result<(), Box> { manifest.entries.push(live_receipt); manifest.entries.push(edge_receipt); manifest.entries.push(readiness_receipt); + if let Some(receipt) = calibration_evidence_receipt { + manifest.entries.push(receipt); + } manifest.entries.push(FileReceipt::from_path(ReceiptRole::Output, &state_path)?); manifest.entries.push(FileReceipt::from_path(ReceiptRole::Output, &html_path)?); let validation = manifest.validate_local_files()?; @@ -613,11 +649,11 @@ fn validate_config(config: &Config) -> Result<(), Box> { || !config.live_publish_json.is_absolute() || !config.edge_partition_json.is_absolute() || !config.calibration_readiness.is_absolute() + || config.calibration_evidence.as_ref().is_some_and(|path| !path.is_absolute()) || !config.output_dir.is_absolute() { return Err( - "input, receipt, --calibration-readiness, and --output-dir paths must be absolute" - .into(), + "input, receipt, --calibration-readiness, --calibration-evidence, and --output-dir paths must be absolute".into(), ); } if config.sample_points == 0 || config.sample_points > MISSION_COCKPIT_MAX_SAMPLED_POINTS { @@ -639,6 +675,7 @@ fn parse_args(args: impl IntoIterator) -> Result) -> Result { calibration_readiness = Some(PathBuf::from(next_value(&mut args, &flag)?)) } + "--calibration-evidence" => { + calibration_evidence = Some(PathBuf::from(next_value(&mut args, &flag)?)) + } "--output-dir" => output_dir = Some(PathBuf::from(next_value(&mut args, &flag)?)), "--expected-input-sha256" => expected_sha256 = Some(next_value(&mut args, &flag)?), "--sample-points" => sample_points = parse_usize(&mut args, &flag)?, @@ -668,6 +708,7 @@ fn parse_args(args: impl IntoIterator) -> Result, + target_domain: impl Into, + method: impl Into, + calibration: ClockCalibration, + ) -> ViewerResult { + let clock = Self { + source_domain: source_domain.into(), + target_domain: target_domain.into(), + method: method.into(), + calibration, + }; + clock.validate()?; + Ok(clock) + } + + /// Validates clock provenance, quality values, and registration ordering. + pub fn validate(&self) -> ViewerResult<()> { + if self.source_domain.trim().is_empty() + || self.target_domain.trim().is_empty() + || self.method.trim().is_empty() + { + return Err(ViewerError::InvalidState( + "calibration clock evidence requires domains and a method".into(), + )); + } + self.calibration.validate()?; + if self.calibration.status == "registered" { + if self.source_domain == self.target_domain { + return Err(ViewerError::InvalidState( + "registered clock evidence requires distinct source and target domains".into(), + )); + } + if !self.calibration.source_bound || self.calibration.sample_count == 0 { + return Err(ViewerError::InvalidState( + "registered clock evidence requires source binding and samples".into(), + )); + } + for (label, value) in [ + ("p95 absolute offset", self.calibration.p95_abs_offset_nanos), + ("clock uncertainty", self.calibration.uncertainty_nanos), + ] { + if value.map_or(true, |value| value < 0.0) { + return Err(ViewerError::InvalidState(format!( + "registered clock evidence requires non-negative {label}" + ))); + } + } + } + Ok(()) + } + + /// Returns whether this clock document is ready for registration. + #[must_use] + pub fn registration_ready(&self) -> bool { + self.calibration.status == "registered" + && self.calibration.source_bound + && self.calibration.sample_count > 0 + && self.calibration.p95_abs_offset_nanos.is_some_and(|value| value >= 0.0) + && self.calibration.uncertainty_nanos.is_some_and(|value| value >= 0.0) + && self.source_domain != self.target_domain + } +} + +/// Explicit root-to-sensor frame evidence carried by a registration document. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct CalibrationEvidenceFrame { + /// Human-readable calibration method or provenance label. + pub method: String, + /// Root frame from which the sensor paths are evaluated. + pub root_frame: String, + /// Required sensor role to frame mapping, including `front` and `rear`. + pub required_frames: BTreeMap, + /// All frame IDs present in the source-bound graph. + pub frames: Vec, + /// Source-bound rigid edges in parent-to-child direction. + pub edges: Vec, + /// Whether the graph satisfies the root and required-sensor path checks. + pub graph_ready: bool, +} + +impl CalibrationEvidenceFrame { + /// Creates a frame evidence document and derives its graph readiness. + pub fn try_new( + method: impl Into, + root_frame: impl Into, + required_frames: BTreeMap, + frames: Vec, + edges: Vec, + ) -> ViewerResult { + let frame = Self { + method: method.into(), + root_frame: root_frame.into(), + required_frames, + frames, + edges, + graph_ready: false, + }; + let graph_ready = frame.calculate_graph_ready()?; + let frame = Self { graph_ready, ..frame }; + frame.validate()?; + Ok(frame) + } + + /// Validates graph identity, topology, and the derived readiness bit. + pub fn validate(&self) -> ViewerResult<()> { + if self.method.trim().is_empty() || self.root_frame.trim().is_empty() { + return Err(ViewerError::InvalidState( + "calibration frame evidence requires a method and root frame".into(), + )); + } + let mut required_values = BTreeSet::new(); + for (role, frame) in &self.required_frames { + if role.trim().is_empty() || frame.trim().is_empty() || !required_values.insert(frame) { + return Err(ViewerError::InvalidState( + "required calibration sensor roles and frames must be unique and non-empty" + .into(), + )); + } + } + if !self.required_frames.contains_key("front") || !self.required_frames.contains_key("rear") + { + return Err(ViewerError::InvalidState( + "calibration frame evidence requires front and rear sensor frames".into(), + )); + } + let mut frame_ids = BTreeSet::new(); + for frame in &self.frames { + if frame.trim().is_empty() || !frame_ids.insert(frame) { + return Err(ViewerError::InvalidState( + "calibration frame graph IDs must be unique and non-empty".into(), + )); + } + } + let mut edges = BTreeSet::new(); + for edge in &self.edges { + edge.validate()?; + if !frame_ids.contains(&edge.parent_frame) || !frame_ids.contains(&edge.child_frame) { + return Err(ViewerError::InvalidState( + "calibration frame edge refers to an unknown frame".into(), + )); + } + if !edge.source_bound || !edge.accepted { + return Err(ViewerError::InvalidState( + "calibration evidence edges must be source-bound and accepted".into(), + )); + } + if !edges.insert((&edge.parent_frame, &edge.child_frame)) { + return Err(ViewerError::InvalidState( + "calibration frame graph contains a duplicate edge".into(), + )); + } + } + if !graph_is_acyclic(&self.frames, &self.edges) { + return Err(ViewerError::InvalidState( + "calibration frame graph must be acyclic".into(), + )); + } + let calculated_ready = self.calculate_graph_ready()?; + if self.graph_ready != calculated_ready { + return Err(ViewerError::InvalidState( + "calibration frame graph_ready disagrees with graph topology".into(), + )); + } + Ok(()) + } + + fn calculate_graph_ready(&self) -> ViewerResult { + let mut frame_ids = BTreeSet::new(); + for frame in &self.frames { + if frame.trim().is_empty() || !frame_ids.insert(frame) { + return Err(ViewerError::InvalidState( + "calibration frame graph IDs must be unique and non-empty".into(), + )); + } + } + if !frame_ids.contains(&self.root_frame) + || self.edges.is_empty() + || !self.edges.iter().all(|edge| { + edge.source_bound + && edge.accepted + && frame_ids.contains(&edge.parent_frame) + && frame_ids.contains(&edge.child_frame) + }) + || !graph_is_acyclic(&self.frames, &self.edges) + { + return Ok(false); + } + Ok(self.required_frames.values().all(|target| { + target != &self.root_frame + && frame_ids.contains(target) + && path_exists(&self.edges, &self.root_frame, target) + })) + } + + /// Returns whether a complete root-to-front/rear graph is registered. + #[must_use] + pub fn registration_ready(&self) -> bool { + self.graph_ready + } +} + +/// Portable source-bound calibration registration state. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct CalibrationEvidenceState { + /// Serialized state schema version. + pub version: u32, + /// User-facing title. + pub title: String, + /// Exact source identity to which both evidence documents refer. + pub source: StudioSource, + /// Checksummed clock evidence file receipt. + pub clock_artifact: CalibrationArtifact, + /// Checksummed frame evidence file receipt. + pub frame_artifact: CalibrationArtifact, + /// Parsed clock registration evidence. + pub clock: CalibrationEvidenceClock, + /// Parsed frame graph registration evidence. + pub frame: CalibrationEvidenceFrame, + /// Whether all registration gates passed. + pub registration_ready: bool, + /// Fail-closed reasons for registration or later application. + pub blockers: Vec, +} + +impl CalibrationEvidenceState { + /// Creates a state and derives source-bound registration admission. + pub fn try_new( + title: impl Into, + source: StudioSource, + clock_artifact: CalibrationArtifact, + frame_artifact: CalibrationArtifact, + clock: CalibrationEvidenceClock, + frame: CalibrationEvidenceFrame, + blockers: Vec, + ) -> ViewerResult { + let registration_ready = source.identity_matches + && clock_artifact.status == "registered" + && clock_artifact.source_bound + && frame_artifact.status == "registered" + && frame_artifact.source_bound + && clock.registration_ready() + && frame.registration_ready() + && blockers.is_empty(); + let state = Self { + version: CALIBRATION_EVIDENCE_STATE_VERSION, + title: title.into(), + source, + clock_artifact, + frame_artifact, + clock, + frame, + registration_ready, + blockers, + }; + state.validate()?; + Ok(state) + } + + /// Validates evidence documents and the derived registration decision. + pub fn validate(&self) -> ViewerResult<()> { + if self.version != CALIBRATION_EVIDENCE_STATE_VERSION { + return Err(ViewerError::InvalidState(format!( + "unsupported calibration evidence state version {}", + self.version + ))); + } + if self.title.trim().is_empty() { + return Err(ViewerError::InvalidState( + "calibration evidence title must not be empty".into(), + )); + } + self.source.validate()?; + self.clock_artifact.validate()?; + self.frame_artifact.validate()?; + self.clock.validate()?; + self.frame.validate()?; + let calculated_registration = self.source.identity_matches + && self.clock_artifact.status == "registered" + && self.clock_artifact.source_bound + && self.frame_artifact.status == "registered" + && self.frame_artifact.source_bound + && self.clock.registration_ready() + && self.frame.registration_ready() + && self.blockers.is_empty(); + if self.registration_ready != calculated_registration { + return Err(ViewerError::InvalidState( + "calibration registration_ready disagrees with evidence gates".into(), + )); + } + if self.registration_ready && !self.blockers.is_empty() { + return Err(ViewerError::InvalidState( + "admitted calibration evidence cannot contain blockers".into(), + )); + } + if !self.registration_ready && self.blockers.is_empty() { + return Err(ViewerError::InvalidState( + "blocked calibration evidence must expose at least one blocker".into(), + )); + } + if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) { + return Err(ViewerError::InvalidState( + "calibration evidence blockers must not be empty".into(), + )); + } + Ok(()) + } +} + +fn graph_is_acyclic(frames: &[String], edges: &[FrameTransform]) -> bool { + let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + let mut indegree: BTreeMap<&str, usize> = BTreeMap::new(); + for frame in frames { + adjacency.entry(frame.as_str()).or_default(); + indegree.insert(frame.as_str(), 0); + } + for edge in edges.iter().filter(|edge| edge.accepted) { + let Some(value) = indegree.get_mut(edge.child_frame.as_str()) else { + return false; + }; + adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str()); + *value += 1; + } + let mut queue = indegree + .iter() + .filter_map(|(frame, degree)| (*degree == 0).then_some(*frame)) + .collect::>(); + let mut visited = 0_usize; + while let Some(frame) = queue.pop() { + visited += 1; + for child in adjacency.get(frame).into_iter().flatten() { + let Some(degree) = indegree.get_mut(child) else { + return false; + }; + *degree -= 1; + if *degree == 0 { + queue.push(child); + } + } + } + visited == indegree.len() +} + +fn path_exists(edges: &[FrameTransform], root: &str, target: &str) -> bool { + let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for edge in edges.iter().filter(|edge| edge.accepted) { + adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str()); + } + let mut queue = VecDeque::from([root]); + let mut visited = BTreeSet::from([root]); + while let Some(frame) = queue.pop_front() { + if frame == target { + return true; + } + for child in adjacency.get(frame).into_iter().flatten() { + if visited.insert(child) { + queue.push_back(child); + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn source(matches: bool) -> StudioSource { + let observed = if matches { + SHA + } else { + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + }; + StudioSource::try_new("canonical bag", "/media/canonical.db3", SHA, observed, matches) + .unwrap() + } + + fn artifacts(bound: bool) -> (CalibrationArtifact, CalibrationArtifact) { + ( + CalibrationArtifact::try_new( + "clock_evidence", + "registered", + Some("/media/clock.json".into()), + Some(SHA.into()), + bound, + ) + .unwrap(), + CalibrationArtifact::try_new( + "frame_evidence", + "registered", + Some("/media/frame.json".into()), + Some(SHA.into()), + bound, + ) + .unwrap(), + ) + } + + fn clock(bound: bool) -> CalibrationEvidenceClock { + CalibrationEvidenceClock::try_new( + "ros2-external", + "canonical", + "fixture-clock-fit", + ClockCalibration::try_new( + "registered", + "explicit clock model; not applied", + 12, + Some(-2.0), + Some(5.0), + Some(0.1), + Some(10.0), + bound, + false, + ) + .unwrap(), + ) + .unwrap() + } + + fn frame(cycle: bool) -> ViewerResult { + let required = BTreeMap::from([ + ("front".into(), "lidar_front".into()), + ("rear".into(), "lidar_rear".into()), + ]); + let mut edges = vec![ + FrameTransform::try_new( + "base_link", + "lidar_front", + [1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + FrameTransform::try_new( + "base_link", + "lidar_rear", + [-1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + ]; + if cycle { + edges.push( + FrameTransform::try_new( + "lidar_front", + "base_link", + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + ); + } + CalibrationEvidenceFrame::try_new( + "fixture-extrinsic-fit", + "base_link", + required, + vec!["base_link".into(), "lidar_front".into(), "lidar_rear".into()], + edges, + ) + } + + #[test] + fn healthy_source_bound_evidence_is_registration_ready() { + let (clock_artifact, frame_artifact) = artifacts(true); + let state = CalibrationEvidenceState::try_new( + "Calibration Evidence", + source(true), + clock_artifact, + frame_artifact, + clock(true), + frame(false).unwrap(), + Vec::new(), + ) + .unwrap(); + assert!(state.registration_ready); + state.validate().unwrap(); + } + + #[test] + fn source_mismatch_withholds_registration() { + let (clock_artifact, frame_artifact) = artifacts(true); + let state = CalibrationEvidenceState::try_new( + "Calibration Evidence", + source(false), + clock_artifact, + frame_artifact, + clock(true), + frame(false).unwrap(), + vec!["input SHA mismatch".into()], + ) + .unwrap(); + assert!(!state.registration_ready); + } + + #[test] + fn missing_evidence_is_a_valid_blocked_state() { + let required = BTreeMap::from([ + ("front".into(), "lidar_front".into()), + ("rear".into(), "lidar_rear".into()), + ]); + let (clock_artifact, frame_artifact) = ( + CalibrationArtifact::try_new("clock_evidence", "not_registered", None, None, false) + .unwrap(), + CalibrationArtifact::try_new("frame_evidence", "not_registered", None, None, false) + .unwrap(), + ); + let state = CalibrationEvidenceState::try_new( + "Calibration Evidence", + source(true), + clock_artifact, + frame_artifact, + CalibrationEvidenceClock::try_new( + "unknown", + "uncalibrated", + "not_registered", + ClockCalibration::try_new( + "not_registered", + "header stamp", + 0, + None, + None, + None, + None, + false, + false, + ) + .unwrap(), + ) + .unwrap(), + CalibrationEvidenceFrame::try_new( + "not_registered", + "base_link", + required, + Vec::new(), + Vec::new(), + ) + .unwrap(), + vec!["clock evidence is not registered".into()], + ) + .unwrap(); + assert!(!state.registration_ready); + state.validate().unwrap(); + } + + #[test] + fn cyclic_frame_graph_is_rejected() { + assert!(frame(true).is_err()); + } +} diff --git a/crates/spatialrust-viewer/src/lib.rs b/crates/spatialrust-viewer/src/lib.rs index 35a4ffa..6d0606e 100644 --- a/crates/spatialrust-viewer/src/lib.rs +++ b/crates/spatialrust-viewer/src/lib.rs @@ -8,6 +8,7 @@ #![warn(missing_docs)] mod adapters; +mod calibration_evidence; mod controls; mod dataset_health; mod digital_twin; @@ -37,6 +38,10 @@ pub use adapters::{pose_graph_visual, trajectory_visual}; #[cfg(feature = "semantic")] pub use adapters::{semantic_overlay_visual, semantic_visual, spatial_record_entity_visual}; pub use adapters::{AdaptedGeometry, AdaptedVisual, AdapterReceipt}; +pub use calibration_evidence::{ + CalibrationEvidenceClock, CalibrationEvidenceFrame, CalibrationEvidenceState, + CALIBRATION_EVIDENCE_STATE_VERSION, +}; pub use controls::{InputAction, ViewerController}; pub use dataset_health::{ DatasetHealthCheck, DatasetHealthStage, DatasetHealthState, DatasetHealthSummary, diff --git a/docs/REAL_DATA_ACCEPTANCE.md b/docs/REAL_DATA_ACCEPTANCE.md index d1bf85d..089bd6a 100644 --- a/docs/REAL_DATA_ACCEPTANCE.md +++ b/docs/REAL_DATA_ACCEPTANCE.md @@ -602,6 +602,43 @@ transfer links, set `publish_ready:false`, `partition_ready:false`, and `mapping_admitted:false`, and exited with status 2. Its manifest still records the canonical input and all upstream receipts for auditability. +## 145J-B Calibration Evidence Gate evidence + +`rosbag2_calibration_evidence` is the next registration boundary after the +opaque 143B artifact inventory. It accepts explicit clock and frame JSON +documents only when their embedded source path, byte size, and SHA-256 exactly +match the canonical bag. Clock evidence must expose source/target domains, +method, sample count, p95 offset, and uncertainty. Frame evidence must expose a +root, finite rigid edges, and an acyclic path to both requested front/rear +frames. The example records evidence but does not solve, apply, or invent a +clock model or TF transform. + +The canonical run intentionally supplied no calibration artifacts and wrote: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-v2/calibration-evidence.json` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-v2/calibration-evidence.html` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-v2/calibration-evidence.manifest.json` + +The source identity matched, but both artifact statuses are +`not_registered`, the frame graph is empty, and `registration_ready:false`. +The command exits 2 after atomically preserving the four-file manifest. This +is the expected fail-closed result because the external SSD survey found no +canonical clock or front/rear extrinsic artifact. + +The wrong-source validation probe is retained at: + +`/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-validation-probe-v1/calibration-evidence.json`. + +It uses an all-`f` expected SHA, reports `identity_matches:false`, keeps +`registration_ready:false`, and exits 2. The Mission Cockpit integration also +consumed the canonical blocked evidence receipt at: + +`/media/sasaki/aiueo/spatialrust-results/v1-3/145j-mission-cockpit-145jb-v1/mission-cockpit.json`. + +That state retains four admitted packet frames and 768 bounded samples with +`publish_ready:true` and `partition_ready:true`, adds the calibration-evidence +receipt to its artifact list, and keeps `mapping_admitted:false`. + ## 144A performance baseline evidence Receipt version 2 adds an explicit `performance` section with a run mode, @@ -687,9 +724,12 @@ Every future E2E run must record: ## Next implementation gates -1. Provide and register clock calibration and front/rear extrinsic artifacts for - the canonical input; the separate `all-sensors-bag1` TF receipt cannot - satisfy this gate, and the latest survey receipt records both as missing. +1. Provide matching clock calibration and front/rear extrinsic JSON evidence + documents for the canonical input, register them with both + `rosbag2_calibration_readiness` and `rosbag2_calibration_evidence`, and + preserve their external file receipts. The separate `all-sensors-bag1` TF + receipt cannot satisfy this gate, and the latest survey receipt records both + canonical artifacts as missing. 2. Extend the 143A prefix smoke to a bounded full-bag, frame-aware odometry and TSDF run before adding any semantic model runtime. 3. Add semantic, Viewer, and interchange quality receipts only after the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a897867..9030916 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -973,3 +973,4 @@ evidence, and fail-closed acceptance gates live in | 145H | Complete | Source-bound ROS 2 PointCloud2 Live Publish Bridge with explicit topic/frame mapping, bounded deterministic packets, CPU CDR loopback round-trip receipt, transport counters, dashboard, and fail-closed source/frame/calibration gates | `spatialrust-viewer::LivePublishState`, `rosbag2_live_publish`, external `145h-live-publish-v2` JSON/HTML/manifest plus wrong-source validation probe | | 145I | Complete | Source-bound edge-to-host partition execution receipt consuming live-publish packets with deterministic PartitionGraph topology, named explicit-copy transfers, bounded queue/backpressure counters, dashboard, and fail-closed source/upstream/calibration gates | `spatialrust-viewer::EdgePartitionState`, `rosbag2_edge_partition`, external `145i-edge-partition-v2` JSON/HTML/manifest plus wrong-source validation probe | | 145J-A | Complete | Bounded interactive Mission Cockpit joining source-indexed packet samples, timeline playback, point selection/measurement, and edge-to-host execution graph while preserving source/frame/calibration gates | `spatialrust-viewer::MissionCockpitState`, `rosbag2_mission_cockpit`, external `145j-mission-cockpit-v2` JSON/HTML/manifest plus wrong-source validation probe | +| 145J-B | Complete | Explicit source-bound clock and front/rear extrinsic evidence manifest, quality/path validation, fail-closed registration receipt, and Mission Cockpit integration; real calibration artifacts remain an external prerequisite | `spatialrust-viewer::CalibrationEvidenceState`, `rosbag2_calibration_evidence`, external `145j-calibration-evidence-v2` JSON/HTML/manifest plus wrong-source validation probe | diff --git a/notes/2026-08-04_calibration_evidence_gate.md b/notes/2026-08-04_calibration_evidence_gate.md new file mode 100644 index 0000000..1ce2a27 --- /dev/null +++ b/notes/2026-08-04_calibration_evidence_gate.md @@ -0,0 +1,79 @@ +# 145J-B Calibration Evidence Gate + +Date: 2026-08-04 + +## Direction + +The canonical rosbag2 snapshot has front/rear PointCloud2 topics but no +`/clock`, `/tf`, `/tf_static`, `/odom`, clock model, or extrinsic artifact. +145J-B therefore implements the registration contract and audit surface without +manufacturing calibration values. + +## Implementation + +`spatialrust-viewer::CalibrationEvidenceState` is a small serde-compatible +contract with: + +- exact source identity inherited from `StudioSource`; +- separate clock/frame artifact receipts; +- explicit clock source/target domains, method, sample count, p95 offset, and + uncertainty; +- finite source-bound `FrameTransform` edges; +- an acyclic graph with a root-to-`front` and root-to-`rear` path check; +- derived `registration_ready` and non-empty fail-closed blockers. + +`rosbag2_calibration_evidence` reads two strict JSON document shapes: + +- `spatialrust.calibration.clock-evidence` version 1; +- `spatialrust.calibration.frame-evidence` version 1. + +The embedded source binding must match the canonical input path, byte size, and +SHA-256. The command writes JSON, HTML, and a re-hashed manifest even when the +gate is blocked, then exits 2. It never applies a clock correction or TF edge. + +`rosbag2_mission_cockpit` accepts the optional +`--calibration-evidence` receipt. When supplied, it validates the state, +includes its checksum in the cockpit artifact list and manifest, and surfaces +its blockers while leaving publish/partition inspection available. + +## External evidence + +Canonical input: + +- `/media/sasaki/aiueo/datasets/migrated/autoware_data/rosbag2_2020_09_23-15_58_07/rosbag2_2020_09_23-15_58_07.db3` +- SHA-256: `b00d31e25dc0b53cba89cfbe16e5b118079c514a1d8c6f4089fac9c0e3ffd7c8` + +Canonical no-artifact run: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-v2/calibration-evidence.json` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-v2/calibration-evidence.html` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-v2/calibration-evidence.manifest.json` + +It reports `identity_matches:true`, both artifact statuses +`not_registered`, an empty frame graph, `registration_ready:false`, and exits +2. The wrong-source probe is: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-calibration-evidence-validation-probe-v1/calibration-evidence.json` + +It reports `identity_matches:false`, `registration_ready:false`, and exits 2. + +Mission Cockpit integration: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-mission-cockpit-145jb-v1/mission-cockpit.json` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-mission-cockpit-145jb-v1/mission-cockpit.html` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145j-mission-cockpit-145jb-v1/mission-cockpit.manifest.json` + +The cockpit retained four packet frames and 768 bounded samples, +`publish_ready:true`, `partition_ready:true`, and +`mapping_admitted:false`. Its manifest checked seven files, including the +calibration evidence receipt. + +## Validation + +- `cargo fmt --all -- --check` +- `cargo test -p spatialrust-viewer --features serde` +- `cargo test -p spatialrust-ros2 --features rosbag2-sqlite --example rosbag2_calibration_evidence` +- `cargo test -p spatialrust-ros2 --features rosbag2-sqlite --example rosbag2_mission_cockpit` +- targeted clippy for viewer, calibration evidence, and Mission Cockpit with + `-D warnings` +- Node browser-script syntax checks for both evidence dashboards