From 720f39c15793621b6b2511d2e019a57e439f1b66 Mon Sep 17 00:00:00 2001 From: rsasaki0109 Date: Tue, 4 Aug 2026 11:49:55 +0900 Subject: [PATCH] Add bounded full-bag mapping gate --- CHANGELOG.md | 7 + crates/spatialrust-ros2/Cargo.toml | 4 + .../examples/rosbag2_full_bag_mapping.rs | 1026 +++++++++++++++++ .../examples/rosbag2_mission_cockpit.rs | 59 +- crates/spatialrust-viewer/src/lib.rs | 5 + crates/spatialrust-viewer/src/mapping_gate.rs | 647 +++++++++++ docs/REAL_DATA_ACCEPTANCE.md | 52 +- docs/ROADMAP.md | 1 + notes/2026-08-04_full_bag_mapping_gate.md | 82 ++ 9 files changed, 1873 insertions(+), 10 deletions(-) create mode 100644 crates/spatialrust-ros2/examples/rosbag2_full_bag_mapping.rs create mode 100644 crates/spatialrust-viewer/src/mapping_gate.rs create mode 100644 notes/2026-08-04_full_bag_mapping_gate.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a4420..0689a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ### Added +- **145K-A bounded full-bag mapping gate**: `FullBagMappingState` and the + feature-gated `rosbag2_full_bag_mapping` example consume complete selected + PointCloud2 streams within explicit record/point/byte bounds, reassemble + source chunks, apply only registered clock/frame evidence, and emit + odometry/pose-graph/TSDF/glTF receipts. Mission Cockpit can consume the + mapping state; the canonical run remains fail-closed until matching + calibration artifacts exist. - **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. diff --git a/crates/spatialrust-ros2/Cargo.toml b/crates/spatialrust-ros2/Cargo.toml index 6467efb..c0c3353 100644 --- a/crates/spatialrust-ros2/Cargo.toml +++ b/crates/spatialrust-ros2/Cargo.toml @@ -107,3 +107,7 @@ name = "rosbag2_edge_partition" [[example]] name = "rosbag2_mission_cockpit" required-features = ["rosbag2-sqlite"] + +[[example]] +name = "rosbag2_full_bag_mapping" +required-features = ["rosbag2-sqlite"] diff --git a/crates/spatialrust-ros2/examples/rosbag2_full_bag_mapping.rs b/crates/spatialrust-ros2/examples/rosbag2_full_bag_mapping.rs new file mode 100644 index 0000000..e841701 --- /dev/null +++ b/crates/spatialrust-ros2/examples/rosbag2_full_bag_mapping.rs @@ -0,0 +1,1026 @@ +//! Run a source-bound, bounded full-bag odometry and TSDF mapping gate. +//! +//! The runner consumes every selected PointCloud2 record within explicit +//! episode limits. It applies only a previously registered clock model and +//! root-to-sensor frame paths from `CalibrationEvidenceState`; it never solves +//! or invents calibration. Missing or mismatched evidence still produces a +//! JSON/HTML/manifest receipt and exits with status 2. + +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + env, + error::Error, + fs, + path::{Path, PathBuf}, + time::Instant, +}; + +use serde::{de::DeserializeOwned, Serialize}; +use spatialrust_core::Timestamp; +use spatialrust_interchange::export_triangle_mesh_gltf_json; +use spatialrust_io::{ + DatasetManifest, FileReceipt, ReceiptRole, StoragePreflight, DEFAULT_MIN_OUTPUT_FREE_BYTES, +}; +use spatialrust_mapping::{IcpScanMatcher, ScanOdometry, ScanOdometryConfig}; +use spatialrust_math::{Isometry3, Quat, Vec3}; +use spatialrust_records::{ + BoundedSpatialRecordSource, CancellationToken, MemoryBudget, MemoryChunkSink, + SpatialRecordSink, StreamOptions, +}; +use spatialrust_registration::IcpConfig; +use spatialrust_ros2::Rosbag2PointCloudSource; +use spatialrust_scene::TsdfVolume; +use spatialrust_sync::{ + ClockDomain, ClockId, DeterministicReplayer, EpisodeLimits, MemoryEpisodeBuilder, + StampedRecord, StampedTime, SyncQuality, SyncWindow, TopicId, +}; +use spatialrust_viewer::{ + CalibrationEvidenceFrame, CalibrationEvidenceState, FrameTransform, FullBagMappingState, + MappingOdometrySummary, MappingSourceSummary, MappingTsdfSummary, ReplayArtifact, StudioSource, +}; + +const CHUNK_POINTS: usize = 1_000_000; +const SOURCE_MEMORY_BYTES: u64 = 256 * 1024 * 1024; +const EPISODE_MAX_RECORDS: u64 = 1_000_000; +const EPISODE_MAX_POINTS: u64 = 16_777_216; +const EPISODE_MAX_BYTES: u64 = 512 * 1024 * 1024; +const MAX_DELTA_NS: u64 = 100_000_000; +const TSDF_ORIGIN: Vec3 = Vec3::new(-24.0, -24.0, -12.0); +const TSDF_VOXEL_SIZE: f32 = 0.5; +const TSDF_DIMS: [usize; 3] = [96, 96, 48]; +const TSDF_TRUNCATION: f32 = 1.0; +const STATE_FILE: &str = "full-bag-mapping.json"; +const HTML_FILE: &str = "full-bag-mapping.html"; +const MANIFEST_FILE: &str = "full-bag-mapping.manifest.json"; +const MESH_FILE: &str = "full-bag-mapping.mesh.gltf"; + +#[derive(Debug)] +struct Config { + input: PathBuf, + calibration_evidence: Option, + output_dir: PathBuf, + expected_sha256: String, + front_topic: String, + rear_topic: String, + root_frame: String, + max_delta_ns: u64, + chunk_points: usize, + source_memory_bytes: u64, + max_records: u64, + max_points: u64, + max_bytes: u64, + min_output_free_bytes: u64, +} + +#[derive(Debug)] +struct CollectedTopic { + records: Vec, + bag_message_count: u64, + chunk_count: u64, + peak_source_bytes: u64, + frame_ids: BTreeSet, +} + +#[derive(Debug)] +struct ClockCorrection { + clock_id: String, + uncertainty_ns: u64, + offset_ns: f64, + drift_ppm: f64, + anchor_nanos: Option, +} + +#[derive(Debug)] +struct MappingExecution { + source_summary: MappingSourceSummary, + odometry: MappingOdometrySummary, + tsdf: MappingTsdfSummary, + mesh_json: String, + clock_applied: bool, + frame_graph_applied: bool, +} + +fn main() { + if let Err(error) = run() { + eprintln!("rosbag2-full-bag-mapping: {error}"); + std::process::exit(2); + } +} + +fn run() -> Result<(), Box> { + let config = parse_args(env::args().skip(1))?; + validate_config(&config)?; + ensure_outputs_absent(&config)?; + + 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 mut blockers = Vec::new(); + if !source_identity_matches { + push_blocker( + &mut blockers, + format!( + "input SHA-256 mismatch: expected {}, observed {}", + config.expected_sha256, observed_sha256 + ), + ); + } + let (calibration, calibration_receipt) = load_calibration( + config.calibration_evidence.as_deref(), + &input_path, + input_size, + &observed_sha256, + &mut blockers, + )?; + + let calibration_ready = calibration.as_ref().is_some_and(|calibration| { + calibration.registration_ready + && calibration.source.identity_matches + && calibration.source.path == input_path + && calibration.source.observed_sha256 == observed_sha256 + }); + let mut execution = None; + let started = Instant::now(); + if source_identity_matches && calibration_ready { + match execute_mapping(&config, calibration.as_ref().expect("calibration is ready")) { + Ok(result) => execution = Some(result), + Err(error) => { + push_blocker(&mut blockers, format!("mapping execution blocked: {error}")) + } + } + } + let elapsed_ns = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX); + + fs::create_dir_all(&config.output_dir)?; + let mut artifacts = vec![replay_artifact("canonical-input", &input_receipt)?]; + if let Some(receipt) = &calibration_receipt { + artifacts.push(replay_artifact("calibration-evidence", receipt)?); + } + let mesh_path = config.output_dir.join(MESH_FILE); + if let Some(result) = &execution { + fs::write(&mesh_path, format!("{}\n", result.mesh_json))?; + let mesh_receipt = FileReceipt::from_path(ReceiptRole::Output, &mesh_path)?; + artifacts.push(replay_artifact("tsdf-mesh", &mesh_receipt)?); + } + + let zero_summary = || MappingSourceSummary { + front_topic: config.front_topic.clone(), + rear_topic: config.rear_topic.clone(), + front_bag_message_count: 0, + rear_bag_message_count: 0, + front_chunk_count: 0, + rear_chunk_count: 0, + front_record_count: 0, + rear_record_count: 0, + total_record_count: 0, + total_point_count: 0, + retained_bytes: 0, + peak_source_bytes: 0, + start_nanos: None, + end_nanos: None, + full_bag_processed: false, + truncated: false, + }; + let (source_summary, odometry, tsdf, clock_applied, frame_graph_applied) = + if let Some(result) = execution { + ( + result.source_summary, + Some(result.odometry), + Some(result.tsdf), + result.clock_applied, + result.frame_graph_applied, + ) + } else { + (zero_summary(), None, None, false, false) + }; + + let state = FullBagMappingState::try_new( + format!("Full-Bag Mapping Gate — {}", file_label(&config.input)), + source, + calibration, + source_summary, + odometry, + tsdf, + artifacts, + clock_applied, + frame_graph_applied, + blockers, + )?; + state.validate()?; + + 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, elapsed_ns)?)?; + + let mut manifest = DatasetManifest::new(); + manifest.entries.push(input_receipt); + if let Some(path) = config.calibration_evidence.as_deref() { + if path.is_file() { + manifest.entries.push(FileReceipt::from_path(ReceiptRole::Auxiliary, path)?); + } + } + if mesh_path.is_file() { + manifest.entries.push(FileReceipt::from_path(ReceiptRole::Output, &mesh_path)?); + } + 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!( + "Full-bag mapping receipt: {} (mapping_admitted={}, elapsed_ns={})", + state_path.display(), + state.summary.mapping_admitted, + elapsed_ns + ); + println!("Full-bag mapping dashboard: {}", html_path.display()); + println!( + "Full-bag mapping manifest: {} (checked_files={}, total_bytes={}, free_before={})", + manifest_path.display(), + validation.checked_local_files, + validation.total_bytes, + preflight.available_bytes + ); + if !state.summary.mapping_admitted { + return Err("full-bag mapping admission failed; see the external receipt blockers".into()); + } + Ok(()) +} + +fn execute_mapping( + config: &Config, + calibration: &CalibrationEvidenceState, +) -> Result> { + let mut clock = ClockCorrection::try_new(calibration)?; + let options = + StreamOptions::new(config.chunk_points, MemoryBudget::new(config.source_memory_bytes)?)?; + let front = collect_topic(&config.input, &config.front_topic, options.clone(), &mut clock)?; + let rear = collect_topic(&config.input, &config.rear_topic, options, &mut clock)?; + if front.records.len() < 2 { + return Err("at least two complete front records are required for full-bag odometry".into()); + } + let front_frame_ids = front.frame_ids.clone(); + let rear_frame_ids = rear.frame_ids.clone(); + if front_frame_ids.len() != 1 || rear_frame_ids.len() != 1 { + return Err("each selected sensor must retain exactly one source frame".into()); + } + let front_frame = front_frame_ids.iter().next().expect("front frame exists"); + let rear_frame = rear_frame_ids.iter().next().expect("rear frame exists"); + if calibration.frame.root_frame != config.root_frame { + return Err(format!( + "registered calibration root `{}` differs from requested root `{}`", + calibration.frame.root_frame, config.root_frame + ) + .into()); + } + let expected_front = calibration + .frame + .required_frames + .get("front") + .ok_or("calibration evidence has no required front frame")?; + let expected_rear = calibration + .frame + .required_frames + .get("rear") + .ok_or("calibration evidence has no required rear frame")?; + if front_frame != expected_front || rear_frame != expected_rear { + return Err(format!( + "source frames differ from registered calibration: front `{front_frame}`/`{expected_front}`, rear `{rear_frame}`/`{expected_rear}" + ) + .into()); + } + + let front_record_count = u64::try_from(front.records.len())?; + let rear_record_count = u64::try_from(rear.records.len())?; + let front_bag_message_count = front.bag_message_count; + let rear_bag_message_count = rear.bag_message_count; + let front_chunk_count = front.chunk_count; + let rear_chunk_count = rear.chunk_count; + let peak_source_bytes = front.peak_source_bytes.max(rear.peak_source_bytes); + let front_records = front.records; + let rear_records = rear.records; + let mut builder = MemoryEpisodeBuilder::try_new(EpisodeLimits::new( + config.max_records, + config.max_points, + config.max_bytes, + ))?; + for record in front_records { + builder.push(record)?; + } + for record in rear_records { + builder.push(record)?; + } + let total_record_count = u64::try_from(builder.len())?; + let total_point_count = builder.points(); + let retained_bytes = builder.bytes(); + let episode = builder.finish(); + let start_nanos = episode.records().first().map(|record| record.stamp.as_nanos()); + let end_nanos = episode.records().last().map(|record| record.stamp.as_nanos()); + let source_summary = MappingSourceSummary { + front_topic: config.front_topic.clone(), + rear_topic: config.rear_topic.clone(), + front_bag_message_count, + rear_bag_message_count, + front_chunk_count, + rear_chunk_count, + front_record_count, + rear_record_count, + total_record_count, + total_point_count, + retained_bytes, + peak_source_bytes, + start_nanos, + end_nanos, + full_bag_processed: true, + truncated: false, + }; + + let front_id = TopicId::new(config.front_topic.clone()); + let front_records: Vec<&StampedRecord> = + episode.records().iter().filter(|record| record.topic == front_id).collect(); + let rear_id = TopicId::new(config.rear_topic.clone()); + let odometry = ScanOdometry::try_new(ScanOdometryConfig::new(front_records.len(), 3))?; + let matcher = IcpScanMatcher::new(IcpConfig { + max_iterations: 10, + max_correspondence_distance: 2.0, + ..IcpConfig::default() + }); + let odometry_result = odometry.estimate(&episode, &front_id, &matcher)?; + if odometry_result.truncated { + return Err("full-bag odometry was truncated by the scan bound".into()); + } + let root_t_front = + resolve_frame_transform(&calibration.frame, &calibration.frame.root_frame, front_frame)?; + let root_t_rear = + resolve_frame_transform(&calibration.frame, &calibration.frame.root_frame, rear_frame)?; + + let mut replayer = DeterministicReplayer::new(&episode); + let mut matched_rear = Vec::new(); + let window = + SyncWindow { max_delta_ns: config.max_delta_ns, max_uncertainty_ns: clock.uncertainty_ns }; + while let Some(bundle) = + replayer.next_bundle(&front_id, std::slice::from_ref(&rear_id), window)? + { + let front_record = *bundle.get(&front_id).ok_or("front sync bundle member missing")?; + let rear_record = *bundle.get(&rear_id).ok_or("rear sync bundle member missing")?; + matched_rear.push((front_record.stamp.as_nanos(), rear_record)); + } + if matched_rear.is_empty() { + return Err("no front/rear records matched within the configured sync window".into()); + } + + let mut pose_by_stamp = BTreeMap::new(); + for (record, pose) in front_records.iter().zip(odometry_result.trajectory.samples()) { + pose_by_stamp.insert(record.stamp.as_nanos(), pose.pose.isometry); + } + let mut volume = TsdfVolume::try_new(TSDF_ORIGIN, TSDF_VOXEL_SIZE, TSDF_DIMS, TSDF_TRUNCATION)?; + let mut integrated_records = 0_u64; + let mut integrated_points = 0_u64; + for (record, pose) in front_records.iter().zip(odometry_result.trajectory.samples()) { + let volume_t_sensor = root_t_front.compose(pose.pose.isometry); + let points = volume.integrate_cloud_with_pose( + record.record.cloud(), + volume_t_sensor, + Vec3::new(0.0, 0.0, 0.0), + )?; + integrated_records = integrated_records.checked_add(1).ok_or("TSDF record overflow")?; + integrated_points = + integrated_points.checked_add(u64::try_from(points)?).ok_or("TSDF point overflow")?; + } + for (stamp, record) in matched_rear { + let pose = + *pose_by_stamp.get(&stamp).ok_or("matched rear record has no front trajectory pose")?; + let volume_t_sensor = root_t_rear.compose(pose); + let points = volume.integrate_cloud_with_pose( + record.record.cloud(), + volume_t_sensor, + Vec3::new(0.0, 0.0, 0.0), + )?; + integrated_records = integrated_records.checked_add(1).ok_or("TSDF record overflow")?; + integrated_points = + integrated_points.checked_add(u64::try_from(points)?).ok_or("TSDF point overflow")?; + } + let mesh = volume.extract_mesh(1.0); + let mesh_json = export_triangle_mesh_gltf_json(&mesh)?; + let odometry_summary = MappingOdometrySummary { + topic: odometry_result.topic.as_str().to_owned(), + source_frame: odometry_result.frame_id.0.clone(), + root_frame: calibration.frame.root_frame.clone(), + clock_id: calibration.clock.target_domain.clone(), + clock_domain: "external-calibrated".into(), + matcher: "point-to-point ICP; bounded ten-iteration full-bag run".into(), + scan_count: u64::try_from(odometry_result.trajectory.samples().len())?, + motion_count: u64::try_from(odometry_result.motions.len())?, + pose_graph_node_count: u64::try_from(odometry_result.pose_graph.nodes().len())?, + pose_graph_edge_count: u64::try_from(odometry_result.pose_graph.edges().len())?, + complete: true, + truncated: false, + }; + let tsdf_summary = MappingTsdfSummary { + frame_id: calibration.frame.root_frame.clone(), + origin: [TSDF_ORIGIN.x, TSDF_ORIGIN.y, TSDF_ORIGIN.z], + voxel_size: TSDF_VOXEL_SIZE, + dims: TSDF_DIMS, + truncation: TSDF_TRUNCATION, + integrated_record_count: integrated_records, + integrated_point_count: integrated_points, + mesh_vertex_count: u64::try_from(mesh.vertex_count())?, + mesh_triangle_count: u64::try_from(mesh.triangle_count())?, + complete: true, + }; + Ok(MappingExecution { + source_summary, + odometry: odometry_summary, + tsdf: tsdf_summary, + mesh_json, + clock_applied: true, + frame_graph_applied: true, + }) +} + +fn collect_topic( + input: &Path, + topic: &str, + options: StreamOptions, + clock: &mut ClockCorrection, +) -> Result> { + let mut source = + Rosbag2PointCloudSource::open(input, topic, options, CancellationToken::default())?; + let bag_message_count = source.topic().message_count; + let peak_source_bytes = source.max_message_bytes(); + let mut current: Option<(u64, String, MemoryChunkSink)> = None; + let mut records = Vec::new(); + let mut chunk_count = 0_u64; + let mut frame_ids = BTreeSet::new(); + while let Some(chunk) = source.next_chunk() { + let chunk = chunk?; + chunk_count = chunk_count.checked_add(1).ok_or("full-bag chunk count overflow")?; + let record = chunk.record().clone(); + let stamp = record.metadata().timestamp.as_nanos(); + let frame = record.metadata().frame_id.0.clone(); + match current.as_mut() { + Some((current_stamp, current_frame, sink)) + if *current_stamp == stamp && current_frame == &frame => + { + sink.write_record(&record)?; + } + Some(_) => { + finish_pending(&mut current, topic, clock, &mut records, &mut frame_ids)?; + let mut sink = MemoryChunkSink::new(); + sink.write_record(&record)?; + current = Some((stamp, frame, sink)); + } + None => { + let mut sink = MemoryChunkSink::new(); + sink.write_record(&record)?; + current = Some((stamp, frame, sink)); + } + } + } + finish_pending(&mut current, topic, clock, &mut records, &mut frame_ids)?; + Ok(CollectedTopic { records, bag_message_count, chunk_count, peak_source_bytes, frame_ids }) +} + +fn finish_pending( + current: &mut Option<(u64, String, MemoryChunkSink)>, + topic: &str, + clock: &mut ClockCorrection, + records: &mut Vec, + frame_ids: &mut BTreeSet, +) -> Result<(), Box> { + let Some((raw_stamp, frame, sink)) = current.take() else { + return Ok(()); + }; + let record = sink.into_record()?.ok_or("empty PointCloud2 record")?; + let corrected_stamp = clock.correct(raw_stamp)?; + let quality = SyncQuality { + offset_ns: clock.offset_i64()?, + uncertainty_ns: clock.uncertainty_ns, + estimated: true, + }; + let stamp = StampedTime { + clock: ClockId::new(clock.clock_id.clone()), + domain: ClockDomain::External, + timestamp: Timestamp::from_nanos(corrected_stamp), + quality, + }; + frame_ids.insert(frame); + records.push(StampedRecord::new(topic, stamp, record)); + Ok(()) +} + +impl ClockCorrection { + fn try_new(calibration: &CalibrationEvidenceState) -> Result> { + let clock = &calibration.clock.calibration; + let uncertainty = clock.uncertainty_nanos.unwrap_or(0.0); + let offset = clock.median_offset_nanos.unwrap_or(0.0); + let drift = clock.drift_ppm.unwrap_or(0.0); + if !uncertainty.is_finite() + || uncertainty < 0.0 + || !offset.is_finite() + || !drift.is_finite() + { + return Err("registered clock evidence has non-finite correction values".into()); + } + let uncertainty_ns = u64::try_from(uncertainty.ceil() as i128) + .map_err(|_| "clock uncertainty does not fit in u64")?; + Ok(Self { + clock_id: calibration.clock.target_domain.clone(), + uncertainty_ns, + offset_ns: offset, + drift_ppm: drift, + anchor_nanos: None, + }) + } + + fn correct(&mut self, raw: u64) -> Result> { + let anchor = *self.anchor_nanos.get_or_insert(raw); + let delta = raw as f64 - anchor as f64; + let corrected = raw as f64 + self.offset_ns + delta * self.drift_ppm / 1_000_000.0; + if !corrected.is_finite() || corrected < 0.0 || corrected > u64::MAX as f64 { + return Err("clock correction produced an out-of-range timestamp".into()); + } + Ok(corrected.round() as u64) + } + + fn offset_i64(&self) -> Result> { + let rounded = self.offset_ns.round(); + if rounded < i64::MIN as f64 || rounded > i64::MAX as f64 { + return Err("clock offset does not fit in i64".into()); + } + Ok(rounded as i64) + } +} + +fn resolve_frame_transform( + evidence: &CalibrationEvidenceFrame, + root: &str, + target: &str, +) -> Result, Box> { + if root == target { + return Ok(Isometry3::identity()); + } + let mut queue = VecDeque::from([(root.to_owned(), Isometry3::identity())]); + let mut visited = BTreeSet::from([root.to_owned()]); + while let Some((frame, root_t_frame)) = queue.pop_front() { + for edge in evidence + .edges + .iter() + .filter(|edge| edge.accepted && edge.source_bound && edge.parent_frame == frame) + { + let next = edge.child_frame.clone(); + if !visited.insert(next.clone()) { + continue; + } + let root_t_child = root_t_frame.compose(edge_isometry(edge)?); + if next == target { + return Ok(root_t_child); + } + queue.push_back((next, root_t_child)); + } + } + Err(format!("no accepted source-bound frame path from `{root}` to `{target}`").into()) +} + +fn edge_isometry(edge: &FrameTransform) -> Result, Box> { + let translation = edge.translation_m; + let rotation = edge.rotation_xyzw; + if translation.iter().any(|value| !value.is_finite()) + || rotation.iter().any(|value| !value.is_finite()) + { + return Err("frame evidence contains non-finite transform values".into()); + } + let quaternion = + Quat::new(rotation[0] as f32, rotation[1] as f32, rotation[2] as f32, rotation[3] as f32) + .normalize(); + Ok(Isometry3::new( + quaternion, + Vec3::new(translation[0] as f32, translation[1] as f32, translation[2] as f32), + )) +} + +fn load_calibration( + path: Option<&Path>, + input_path: &str, + input_size: u64, + observed_sha256: &str, + blockers: &mut Vec, +) -> Result<(Option, Option), Box> { + let Some(path) = path else { + push_blocker(blockers, "--calibration-evidence was not supplied"); + return Ok((None, None)); + }; + let receipt = if path.is_file() { + Some(FileReceipt::from_path(ReceiptRole::Auxiliary, path)?) + } else { + push_blocker( + blockers, + format!("calibration evidence file '{}' is missing", path.display()), + ); + None + }; + let Some(receipt) = receipt else { + return Ok((None, None)); + }; + let parsed: CalibrationEvidenceState = match read_json(path) { + Ok(value) => value, + Err(error) => { + push_blocker(blockers, format!("calibration evidence JSON is invalid: {error}")); + return Ok((None, Some(receipt))); + } + }; + if let Err(error) = parsed.validate() { + push_blocker(blockers, format!("calibration evidence state is invalid: {error}")); + return Ok((None, Some(receipt))); + } + if parsed.source.path != input_path + || parsed.source.observed_sha256 != observed_sha256 + || parsed.source.expected_sha256 != observed_sha256 + { + push_blocker(blockers, "calibration evidence source identity is not bound to this input"); + } + if parsed.source.path != input_path || parsed.source.observed_sha256 != observed_sha256 { + push_blocker(blockers, "calibration evidence path or observed SHA differs from this input"); + } + if input_size == 0 { + push_blocker(blockers, "input file has zero size"); + } + Ok((Some(parsed), Some(receipt))) +} + +fn replay_artifact(role: &str, receipt: &FileReceipt) -> Result> { + ReplayArtifact::try_new( + role, + receipt.path.display().to_string(), + receipt.size_bytes.ok_or("artifact size is missing")?, + receipt.sha256.clone().ok_or("artifact checksum is missing")?, + ) + .map_err(Into::into) +} + +fn validate_config(config: &Config) -> Result<(), Box> { + if !config.input.is_absolute() + || !config.output_dir.is_absolute() + || config.calibration_evidence.as_ref().is_some_and(|path| !path.is_absolute()) + { + return Err("input, calibration evidence, and output paths must be absolute".into()); + } + if config.front_topic.trim().is_empty() + || config.rear_topic.trim().is_empty() + || config.front_topic == config.rear_topic + || config.root_frame.trim().is_empty() + { + return Err("front/rear topics and root frame must be distinct and non-empty".into()); + } + for (name, value) in [ + ("max delta", config.max_delta_ns), + ("chunk points", u64::try_from(config.chunk_points).unwrap_or(0)), + ("source memory", config.source_memory_bytes), + ("max records", config.max_records), + ("max points", config.max_points), + ("max bytes", config.max_bytes), + ("minimum output free bytes", config.min_output_free_bytes), + ] { + if value == 0 { + return Err(format!("{name} 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 parse_args(args: impl IntoIterator) -> Result> { + let mut args = args.into_iter(); + let input = PathBuf::from(args.next().ok_or_else(usage)?); + let mut calibration_evidence = None; + let mut output_dir = None; + let mut expected_sha256 = None; + let mut front_topic = "/lidar_front/points_raw".to_owned(); + let mut rear_topic = "/lidar_rear/points_raw".to_owned(); + let mut root_frame = "base_link".to_owned(); + let mut max_delta_ns = MAX_DELTA_NS; + let mut chunk_points = CHUNK_POINTS; + let mut source_memory_bytes = SOURCE_MEMORY_BYTES; + let mut max_records = EPISODE_MAX_RECORDS; + let mut max_points = EPISODE_MAX_POINTS; + let mut max_bytes = EPISODE_MAX_BYTES; + let mut min_output_free_bytes = DEFAULT_MIN_OUTPUT_FREE_BYTES; + while let Some(flag) = args.next() { + match flag.as_str() { + "--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)?), + "--front-topic" => front_topic = next_value(&mut args, &flag)?, + "--rear-topic" => rear_topic = next_value(&mut args, &flag)?, + "--root-frame" => root_frame = next_value(&mut args, &flag)?, + "--max-delta-ns" => max_delta_ns = parse_u64(&mut args, &flag)?, + "--chunk-points" => chunk_points = parse_usize(&mut args, &flag)?, + "--source-memory-bytes" => source_memory_bytes = parse_u64(&mut args, &flag)?, + "--max-records" => max_records = parse_u64(&mut args, &flag)?, + "--max-points" => max_points = parse_u64(&mut args, &flag)?, + "--max-bytes" => max_bytes = parse_u64(&mut args, &flag)?, + "--min-output-free-bytes" => min_output_free_bytes = parse_u64(&mut args, &flag)?, + "-h" | "--help" => return Err(usage().into()), + _ => return Err(format!("unknown option '{flag}'\n{}", usage()).into()), + } + } + Ok(Config { + input, + calibration_evidence, + output_dir: output_dir.ok_or("--output-dir is required")?, + expected_sha256: expected_sha256.ok_or("--expected-input-sha256 is required")?, + front_topic, + rear_topic, + root_frame, + max_delta_ns, + chunk_points, + source_memory_bytes, + max_records, + max_points, + max_bytes, + min_output_free_bytes, + }) +} + +fn parse_u64(args: &mut impl Iterator, flag: &str) -> Result> { + Ok(next_value(args, flag)?.parse()?) +} + +fn parse_usize( + args: &mut impl Iterator, + flag: &str, +) -> Result> { + Ok(next_value(args, flag)?.parse()?) +} + +fn next_value( + args: &mut impl Iterator, + flag: &str, +) -> Result> { + args.next().ok_or_else(|| format!("{flag} requires another value").into()) +} + +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 read_json(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 temp = path.with_extension(format!("{}.tmp", std::process::id())); + fs::write(&temp, text)?; + fs::rename(&temp, path)?; + Ok(()) +} + +fn push_blocker(blockers: &mut Vec, blocker: impl Into) { + let blocker = blocker.into(); + if !blockers.iter().any(|existing| existing == &blocker) { + blockers.push(blocker); + } +} + +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('"', """) +} + +fn render_dashboard( + state: &FullBagMappingState, + elapsed_ns: u64, +) -> Result> { + let state_json = serde_json::to_string(state)?.replace("{}", escape_html(blocker))) + .collect::(); + Ok(format!( + r##"{title}
SpatialRust / 145K-A bounded full-bag mapping

{title}

{source}
{admission}

Source

{source_status}
{source_detail}

Calibration

{calibration}
clock / frame registration

Full bag

{full_bag}
{records} records · {points} points

Mapping

{mapping}
{elapsed_ns} ns wall time

Stage gates

clock applied{clock_applied}
frame graph applied{frame_applied}
odometry{odometry}
TSDF{tsdf}

Admission blockers

    {blockers}

Portable JSON state

"##, + title = title, + source = escape_html(&state.source.path), + badge_class = if state.summary.mapping_admitted { "ok" } else { "blocked" }, + admission = + if state.summary.mapping_admitted { "MAPPING ADMITTED" } else { "MAPPING BLOCKED" }, + source_class = if state.source.identity_matches { "" } else { "danger" }, + source_status = if state.source.identity_matches { "MATCH" } else { "MISMATCH" }, + source_detail = state.source.observed_sha256, + calibration_class = if state.summary.calibration_registered { "" } else { "danger" }, + calibration = if state.summary.calibration_registered { "REGISTERED" } else { "MISSING" }, + full_class = if state.summary.full_bag_processed { "" } else { "danger" }, + full_bag = if state.summary.full_bag_processed { "COMPLETE" } else { "BOUNDED" }, + records = state.source_summary.total_record_count, + points = state.source_summary.total_point_count, + mapping_class = if state.summary.mapping_admitted { "" } else { "danger" }, + mapping = if state.summary.mapping_admitted { "ADMITTED" } else { "BLOCKED" }, + elapsed_ns = elapsed_ns, + clock_applied = state.summary.clock_applied, + frame_applied = state.summary.frame_graph_applied, + odometry = state.summary.odometry_complete, + tsdf = state.summary.tsdf_complete, + state_json = state_json, + )) +} + +fn usage() -> String { + String::from( + "rosbag2_full_bag_mapping INPUT_DB3 --calibration-evidence STATE_JSON --output-dir ABSOLUTE_DIR --expected-input-sha256 SHA256", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use spatialrust_math::TransformPoint; + use spatialrust_viewer::{CalibrationArtifact, CalibrationEvidenceClock, ClockCalibration}; + + const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn parses_full_bag_mapping_options() { + let config = parse_args( + [ + "/media/input.db3", + "--calibration-evidence", + "/media/calibration.json", + "--output-dir", + "/media/output", + "--expected-input-sha256", + SHA, + "--max-points", + "1024", + ] + .into_iter() + .map(str::to_owned), + ) + .unwrap(); + assert_eq!(config.max_points, 1024); + assert_eq!(config.front_topic, "/lidar_front/points_raw"); + assert_eq!(config.calibration_evidence, Some(PathBuf::from("/media/calibration.json"))); + } + + #[test] + fn resolves_parent_to_child_frame_path() { + let frame = CalibrationEvidenceFrame::try_new( + "fixture", + "base_link", + BTreeMap::from([ + ("front".into(), "front_frame".into()), + ("rear".into(), "rear_frame".into()), + ]), + vec!["base_link".into(), "front_frame".into(), "rear_frame".into()], + vec![ + FrameTransform::try_new( + "base_link", + "front_frame", + [1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + FrameTransform::try_new( + "base_link", + "rear_frame", + [-1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + ], + ) + .unwrap(); + let transform = resolve_frame_transform(&frame, "base_link", "front_frame").unwrap(); + let point = transform.transform_point(Vec3::new(0.0, 0.0, 0.0)); + assert!((point.x - 1.0).abs() < 1e-6); + } + + #[test] + fn clock_correction_is_deterministic_and_anchored() { + let source = StudioSource::try_new("fixture", "/media/input.db3", SHA, SHA, true).unwrap(); + let calibration = CalibrationEvidenceState::try_new( + "fixture", + source, + CalibrationArtifact::try_new( + "clock_evidence", + "registered", + Some("/media/clock.json".into()), + Some(SHA.into()), + true, + ) + .unwrap(), + CalibrationArtifact::try_new( + "frame_evidence", + "registered", + Some("/media/frame.json".into()), + Some(SHA.into()), + true, + ) + .unwrap(), + CalibrationEvidenceClock::try_new( + "sensor", + "external", + "fixture", + ClockCalibration::try_new( + "registered", + "anchored offset", + 2, + Some(10.0), + Some(10.0), + Some(0.0), + Some(0.0), + true, + false, + ) + .unwrap(), + ) + .unwrap(), + CalibrationEvidenceFrame::try_new( + "fixture", + "base_link", + BTreeMap::from([("front".into(), "front".into()), ("rear".into(), "rear".into())]), + vec!["base_link".into(), "front".into(), "rear".into()], + vec![ + FrameTransform::try_new( + "base_link", + "front", + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + FrameTransform::try_new( + "base_link", + "rear", + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + ], + ) + .unwrap(), + Vec::new(), + ) + .unwrap(); + let mut correction = ClockCorrection::try_new(&calibration).unwrap(); + assert_eq!(correction.correct(100).unwrap(), 110); + assert_eq!(correction.correct(200).unwrap(), 210); + } +} diff --git a/crates/spatialrust-ros2/examples/rosbag2_mission_cockpit.rs b/crates/spatialrust-ros2/examples/rosbag2_mission_cockpit.rs index 82c03ee..d61f99e 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::{ - CalibrationEvidenceState, EdgePartitionState, LivePublishPacket, LivePublishState, - MissionCockpitFrame, MissionCockpitLayer, MissionCockpitLink, MissionCockpitNode, - MissionCockpitPoint, MissionCockpitState, MissionCockpitSummary, MissionCockpitTimeline, - ReplayArtifact, StudioSource, MISSION_COCKPIT_MAX_SAMPLED_POINTS, + CalibrationEvidenceState, EdgePartitionState, FullBagMappingState, LivePublishPacket, + LivePublishState, MissionCockpitFrame, MissionCockpitLayer, MissionCockpitLink, + MissionCockpitNode, MissionCockpitPoint, MissionCockpitState, MissionCockpitSummary, + MissionCockpitTimeline, ReplayArtifact, StudioSource, MISSION_COCKPIT_MAX_SAMPLED_POINTS, }; const CHUNK_POINTS: usize = 65_536; @@ -50,6 +50,7 @@ struct Config { edge_partition_json: PathBuf, calibration_readiness: PathBuf, calibration_evidence: Option, + mapping_state: Option, output_dir: PathBuf, expected_sha256: String, sample_points: usize, @@ -121,6 +122,16 @@ fn run() -> Result<(), Box> { .as_ref() .map(|path| FileReceipt::from_path(ReceiptRole::Auxiliary, path)) .transpose()?; + let mapping_state = config + .mapping_state + .as_ref() + .map(|path| read_json::(path)) + .transpose()?; + let mapping_state_receipt = config + .mapping_state + .as_ref() + .map(|path| FileReceipt::from_path(ReceiptRole::Auxiliary, path)) + .transpose()?; let mut blockers = Vec::new(); if !source_identity_match { @@ -192,6 +203,24 @@ fn run() -> Result<(), Box> { } else { true }; + let mapping_gate_admitted = if let Some(mapping) = &mapping_state { + mapping.validate()?; + let source_bound = mapping.source.identity_matches + && mapping.source.path == input_path + && mapping.source.observed_sha256 == observed_sha256; + if !source_bound { + push_blocker(&mut blockers, "full-bag mapping state is bound to a different input"); + } + if !mapping.summary.mapping_admitted { + push_blocker(&mut blockers, "full-bag mapping admission is incomplete"); + } + for blocker in &mapping.blockers { + push_blocker(&mut blockers, format!("full-bag-mapping: {blocker}")); + } + source_bound && mapping.summary.mapping_admitted + } else { + true + }; if !live_publish.summary.calibration_applied || !edge_partition.summary.calibration_applied { push_blocker( &mut blockers, @@ -263,10 +292,12 @@ fn run() -> Result<(), Box> { && readiness.registration_ready && evidence_registration_ready && live_publish.summary.calibration_registered - && edge_partition.summary.calibration_registered; + && edge_partition.summary.calibration_registered + && mapping_state.as_ref().map_or(true, |mapping| mapping.summary.calibration_registered); let calibration_applied = calibration_registered && live_publish.summary.calibration_applied - && edge_partition.summary.calibration_applied; + && edge_partition.summary.calibration_applied + && mapping_gate_admitted; let summary = MissionCockpitSummary::try_new( frame_count, frame_count, @@ -300,6 +331,9 @@ fn run() -> Result<(), Box> { if let Some(receipt) = &calibration_evidence_receipt { artifacts.push(replay_artifact("calibration-evidence", receipt)?); } + if let Some(receipt) = &mapping_state_receipt { + artifacts.push(replay_artifact("full-bag-mapping", receipt)?); + } let expected_frame_ids = live_publish.expected_frame_ids.clone(); let state = MissionCockpitState::try_new( format!("Spatial Mission Cockpit — {}", file_label(&config.input)), @@ -326,6 +360,9 @@ fn run() -> Result<(), Box> { if let Some(receipt) = calibration_evidence_receipt { manifest.entries.push(receipt); } + if let Some(receipt) = mapping_state_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()?; @@ -650,10 +687,11 @@ fn validate_config(config: &Config) -> Result<(), Box> { || !config.edge_partition_json.is_absolute() || !config.calibration_readiness.is_absolute() || config.calibration_evidence.as_ref().is_some_and(|path| !path.is_absolute()) + || config.mapping_state.as_ref().is_some_and(|path| !path.is_absolute()) || !config.output_dir.is_absolute() { return Err( - "input, receipt, --calibration-readiness, --calibration-evidence, and --output-dir paths must be absolute".into(), + "input, receipt, --calibration-readiness, --calibration-evidence, --mapping-state, and --output-dir paths must be absolute".into(), ); } if config.sample_points == 0 || config.sample_points > MISSION_COCKPIT_MAX_SAMPLED_POINTS { @@ -676,6 +714,7 @@ fn parse_args(args: impl IntoIterator) -> Result) -> Result { calibration_evidence = Some(PathBuf::from(next_value(&mut args, &flag)?)) } + "--mapping-state" => mapping_state = 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)?, @@ -709,6 +749,7 @@ fn parse_args(args: impl IntoIterator) -> Result, + /// Last corrected timestamp, when a bag was processed. + pub end_nanos: Option, + /// Whether every selected source record was consumed within the limits. + pub full_bag_processed: bool, + /// Whether a configured hard bound stopped source consumption. + pub truncated: bool, +} + +impl MappingSourceSummary { + /// Validates source identities and bounded totals. + pub fn validate(&self) -> ViewerResult<()> { + let expected_total = + self.front_record_count.checked_add(self.rear_record_count).ok_or_else(|| { + ViewerError::InvalidState("mapping source record total overflow".into()) + })?; + if self.front_topic.trim().is_empty() + || self.rear_topic.trim().is_empty() + || self.front_topic == self.rear_topic + || self.front_bag_message_count < self.front_record_count + || self.rear_bag_message_count < self.rear_record_count + || self.front_chunk_count < self.front_record_count + || self.rear_chunk_count < self.rear_record_count + || self.total_record_count != expected_total + || (self.total_record_count == 0 && self.total_point_count != 0) + || self.truncated && self.full_bag_processed + { + return Err(ViewerError::InvalidState( + "mapping source summary has invalid topic or bounded totals".into(), + )); + } + if let (Some(start), Some(end)) = (self.start_nanos, self.end_nanos) { + if start > end { + return Err(ViewerError::InvalidState( + "mapping source timestamp bounds are reversed".into(), + )); + } + } else if self.start_nanos.is_some() != self.end_nanos.is_some() { + return Err(ViewerError::InvalidState( + "mapping source timestamp bounds must be complete".into(), + )); + } + if self.full_bag_processed + && (self.total_record_count == 0 + || self.total_point_count == 0 + || self.retained_bytes == 0 + || self.peak_source_bytes == 0) + { + return Err(ViewerError::InvalidState( + "a completed full-bag mapping run must retain source records and points".into(), + )); + } + Ok(()) + } +} + +/// Odometry and pose-graph receipt for the bounded full-bag run. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct MappingOdometrySummary { + /// Topic used as the primary trajectory stream. + pub topic: String, + /// Original sensor frame of the primary trajectory stream. + pub source_frame: String, + /// Root frame after explicit frame-graph application. + pub root_frame: String, + /// Clock identifier used by corrected trajectory stamps. + pub clock_id: String, + /// Human-readable clock-domain label. + pub clock_domain: String, + /// Matcher and bounded-run description. + pub matcher: String, + /// Number of scans in the trajectory. + pub scan_count: u64, + /// Number of sequential relative motions. + pub motion_count: u64, + /// Number of pose-graph nodes. + pub pose_graph_node_count: u64, + /// Number of pose-graph edges. + pub pose_graph_edge_count: u64, + /// Whether odometry consumed the complete selected stream. + pub complete: bool, + /// Whether the selected stream was cut by a bound. + pub truncated: bool, +} + +impl MappingOdometrySummary { + /// Validates trajectory and pose-graph totals. + pub fn validate(&self) -> ViewerResult<()> { + if self.topic.trim().is_empty() + || self.source_frame.trim().is_empty() + || self.root_frame.trim().is_empty() + || self.clock_id.trim().is_empty() + || self.clock_domain.trim().is_empty() + || self.matcher.trim().is_empty() + || self.motion_count > self.scan_count + || self.pose_graph_node_count != self.scan_count + || self.pose_graph_edge_count != self.motion_count + || self.truncated && self.complete + { + return Err(ViewerError::InvalidState( + "mapping odometry summary has invalid identity or graph totals".into(), + )); + } + if self.complete + && (self.scan_count < 2 || self.motion_count.checked_add(1) != Some(self.scan_count)) + { + return Err(ViewerError::InvalidState( + "completed mapping odometry requires a connected scan trajectory".into(), + )); + } + Ok(()) + } +} + +/// TSDF and mesh receipt for one mapping run. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct MappingTsdfSummary { + /// Frame in which the volume is expressed. + pub frame_id: String, + /// Volume origin in metres. + pub origin: [f32; 3], + /// Voxel edge length in metres. + pub voxel_size: f32, + /// Number of voxels on each axis. + pub dims: [usize; 3], + /// TSDF truncation distance in metres. + pub truncation: f32, + /// Number of records integrated into the volume. + pub integrated_record_count: u64, + /// Number of point samples visited by integration. + pub integrated_point_count: u64, + /// Number of extracted mesh vertices. + pub mesh_vertex_count: u64, + /// Number of extracted mesh triangles. + pub mesh_triangle_count: u64, + /// Whether TSDF integration and extraction completed. + pub complete: bool, +} + +impl MappingTsdfSummary { + /// Validates finite volume configuration and integration totals. + pub fn validate(&self) -> ViewerResult<()> { + if self.frame_id.trim().is_empty() + || self.origin.iter().any(|value| !value.is_finite()) + || !self.voxel_size.is_finite() + || self.voxel_size <= 0.0 + || self.dims.contains(&0) + || !self.truncation.is_finite() + || self.truncation <= 0.0 + || self.mesh_vertex_count == 0 && self.mesh_triangle_count != 0 + { + return Err(ViewerError::InvalidState( + "mapping TSDF summary has invalid volume or mesh values".into(), + )); + } + if self.complete && (self.integrated_record_count == 0 || self.integrated_point_count == 0) + { + return Err(ViewerError::InvalidState( + "completed mapping TSDF requires integrated records and points".into(), + )); + } + Ok(()) + } +} + +/// Derived admission levels for bounded full-bag mapping. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct MappingGateSummary { + /// Whether source-bound calibration evidence is registered. + pub calibration_registered: bool, + /// Whether the registered clock model was applied to timestamps. + pub clock_applied: bool, + /// Whether the registered frame graph was applied to sensor geometry. + pub frame_graph_applied: bool, + /// Whether the complete selected bag was retained within bounds. + pub full_bag_processed: bool, + /// Whether bounded odometry completed over the selected stream. + pub odometry_complete: bool, + /// Whether bounded TSDF integration and extraction completed. + pub tsdf_complete: bool, + /// Whether calibrated-world mapping may be consumed downstream. + pub mapping_admitted: bool, +} + +/// Portable source-bound bounded full-bag mapping state. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct FullBagMappingState { + /// Serialized state schema version. + pub version: u32, + /// User-facing dashboard title. + pub title: String, + /// Exact input identity used by the run. + pub source: StudioSource, + /// Optional parsed calibration state; absence is a blocking condition. + pub calibration: Option, + /// Bounded source ingest totals. + pub source_summary: MappingSourceSummary, + /// Odometry receipt, when the source and calibration gates allowed it. + pub odometry: Option, + /// TSDF receipt, when mapping stages completed. + pub tsdf: Option, + /// Checksummed input and derived artifacts. + pub artifacts: Vec, + /// Derived admission levels. + pub summary: MappingGateSummary, + /// Human-readable fail-closed reasons. + pub blockers: Vec, +} + +impl FullBagMappingState { + /// Creates state and derives its mapping admission decision. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + title: impl Into, + source: StudioSource, + calibration: Option, + source_summary: MappingSourceSummary, + odometry: Option, + tsdf: Option, + artifacts: Vec, + clock_applied: bool, + frame_graph_applied: bool, + mut blockers: Vec, + ) -> ViewerResult { + let calibration_registered = calibration.as_ref().is_some_and(|calibration| { + calibration.registration_ready + && calibration.source.identity_matches + && calibration.source.path == source.path + && calibration.source.observed_sha256 == source.observed_sha256 + }); + let full_bag_processed = source_summary.full_bag_processed && !source_summary.truncated; + let odometry_complete = + odometry.as_ref().is_some_and(|odometry| odometry.complete && !odometry.truncated); + let tsdf_complete = tsdf.as_ref().is_some_and(|tsdf| tsdf.complete); + + if !source.identity_matches { + push_blocker( + &mut blockers, + "mapping input source identity does not match expected SHA-256", + ); + } + if calibration.is_none() { + push_blocker(&mut blockers, "source-bound calibration evidence was not supplied"); + } else if !calibration_registered { + push_blocker( + &mut blockers, + "source-bound calibration evidence registration is incomplete", + ); + } + if !full_bag_processed { + push_blocker( + &mut blockers, + "full-bag ingest was not completed because an admission gate blocked execution or a configured bound was reached", + ); + } + if !clock_applied { + push_blocker( + &mut blockers, + "registered clock model was not applied to the mapping timeline", + ); + } + if !frame_graph_applied { + push_blocker( + &mut blockers, + "registered frame graph was not applied to sensor geometry", + ); + } + if !odometry_complete { + push_blocker(&mut blockers, "full-bag frame-aware odometry did not complete"); + } + if !tsdf_complete { + push_blocker(&mut blockers, "full-bag TSDF integration did not complete"); + } + + let summary = MappingGateSummary { + calibration_registered, + clock_applied, + frame_graph_applied, + full_bag_processed, + odometry_complete, + tsdf_complete, + mapping_admitted: source.identity_matches + && calibration_registered + && clock_applied + && frame_graph_applied + && full_bag_processed + && odometry_complete + && tsdf_complete + && blockers.is_empty(), + }; + let state = Self { + version: FULL_BAG_MAPPING_STATE_VERSION, + title: title.into(), + source, + calibration, + source_summary, + odometry, + tsdf, + artifacts, + summary, + blockers, + }; + state.validate()?; + Ok(state) + } + + /// Validates source, calibration, stage receipts, and derived gates. + pub fn validate(&self) -> ViewerResult<()> { + if self.version != FULL_BAG_MAPPING_STATE_VERSION { + return Err(ViewerError::InvalidState(format!( + "unsupported full-bag mapping state version {}", + self.version + ))); + } + if self.title.trim().is_empty() { + return Err(ViewerError::InvalidState( + "full-bag mapping title must not be empty".into(), + )); + } + self.source.validate()?; + self.source_summary.validate()?; + if let Some(calibration) = &self.calibration { + calibration.validate()?; + } + if let Some(odometry) = &self.odometry { + odometry.validate()?; + } + if let Some(tsdf) = &self.tsdf { + tsdf.validate()?; + } + let mut roles = std::collections::BTreeSet::new(); + let mut paths = std::collections::BTreeSet::new(); + for artifact in &self.artifacts { + artifact.validate()?; + if !roles.insert(&artifact.role) || !paths.insert(&artifact.path) { + return Err(ViewerError::InvalidState( + "full-bag mapping artifacts must have unique roles and paths".into(), + )); + } + } + + let calculated_calibration = self.calibration.as_ref().is_some_and(|calibration| { + calibration.registration_ready + && calibration.source.identity_matches + && calibration.source.path == self.source.path + && calibration.source.observed_sha256 == self.source.observed_sha256 + }); + let calculated_full = + self.source_summary.full_bag_processed && !self.source_summary.truncated; + let calculated_odometry = + self.odometry.as_ref().is_some_and(|odometry| odometry.complete && !odometry.truncated); + let calculated_tsdf = self.tsdf.as_ref().is_some_and(|tsdf| tsdf.complete); + if self.summary.calibration_registered != calculated_calibration + || self.summary.full_bag_processed != calculated_full + || self.summary.odometry_complete != calculated_odometry + || self.summary.tsdf_complete != calculated_tsdf + || (self.summary.clock_applied && !calculated_calibration) + || (self.summary.frame_graph_applied && !calculated_calibration) + { + return Err(ViewerError::InvalidState( + "full-bag mapping summary disagrees with source or stage receipts".into(), + )); + } + let calculated_mapping = self.source.identity_matches + && calculated_calibration + && self.summary.clock_applied + && self.summary.frame_graph_applied + && calculated_full + && calculated_odometry + && calculated_tsdf + && self.blockers.is_empty(); + if self.summary.mapping_admitted != calculated_mapping { + return Err(ViewerError::InvalidState( + "mapping_admitted disagrees with full-bag mapping gates".into(), + )); + } + if self.summary.mapping_admitted && !self.blockers.is_empty() { + return Err(ViewerError::InvalidState( + "admitted full-bag mapping cannot contain blockers".into(), + )); + } + if !self.summary.mapping_admitted && self.blockers.is_empty() { + return Err(ViewerError::InvalidState( + "blocked full-bag mapping must expose at least one blocker".into(), + )); + } + if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) { + return Err(ViewerError::InvalidState( + "full-bag mapping blockers must not be empty".into(), + )); + } + Ok(()) + } +} + +fn push_blocker(blockers: &mut Vec, blocker: impl Into) { + let blocker = blocker.into(); + if !blockers.iter().any(|existing| existing == &blocker) { + blockers.push(blocker); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + CalibrationArtifact, CalibrationEvidenceClock, CalibrationEvidenceFrame, ClockCalibration, + FrameTransform, + }; + + const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn source() -> StudioSource { + StudioSource::try_new("fixture", "/media/fixture.db3", SHA, SHA, true).unwrap() + } + + fn source_summary() -> MappingSourceSummary { + MappingSourceSummary { + front_topic: "/front".into(), + rear_topic: "/rear".into(), + front_bag_message_count: 0, + rear_bag_message_count: 0, + front_chunk_count: 0, + rear_chunk_count: 0, + front_record_count: 0, + rear_record_count: 0, + total_record_count: 0, + total_point_count: 0, + retained_bytes: 0, + peak_source_bytes: 0, + start_nanos: None, + end_nanos: None, + full_bag_processed: false, + truncated: false, + } + } + + #[test] + fn missing_calibration_is_a_valid_blocked_state() { + let state = FullBagMappingState::try_new( + "fixture mapping", + source(), + None, + source_summary(), + None, + None, + Vec::new(), + false, + false, + Vec::new(), + ) + .unwrap(); + assert!(!state.summary.mapping_admitted); + assert!(state.blockers.iter().any(|blocker| blocker.contains("calibration"))); + state.validate().unwrap(); + } + + #[test] + fn complete_source_bound_stages_are_admitted() { + let calibration = CalibrationEvidenceState::try_new( + "fixture calibration", + source(), + CalibrationArtifact::try_new( + "clock_evidence", + "registered", + Some("/media/clock.json".into()), + Some(SHA.into()), + true, + ) + .unwrap(), + CalibrationArtifact::try_new( + "frame_evidence", + "registered", + Some("/media/frame.json".into()), + Some(SHA.into()), + true, + ) + .unwrap(), + CalibrationEvidenceClock::try_new( + "sensor", + "external", + "fixture clock fit", + ClockCalibration::try_new( + "registered", + "anchored external clock", + 2, + Some(1.0), + Some(2.0), + Some(0.0), + Some(3.0), + true, + false, + ) + .unwrap(), + ) + .unwrap(), + CalibrationEvidenceFrame::try_new( + "fixture extrinsic fit", + "base_link", + std::collections::BTreeMap::from([ + ("front".into(), "front_frame".into()), + ("rear".into(), "rear_frame".into()), + ]), + vec!["base_link".into(), "front_frame".into(), "rear_frame".into()], + vec![ + FrameTransform::try_new( + "base_link", + "front_frame", + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + FrameTransform::try_new( + "base_link", + "rear_frame", + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + None, + true, + true, + ) + .unwrap(), + ], + ) + .unwrap(), + Vec::new(), + ) + .unwrap(); + let state = FullBagMappingState::try_new( + "fixture mapping", + source(), + Some(calibration), + MappingSourceSummary { + front_topic: "/front".into(), + rear_topic: "/rear".into(), + front_bag_message_count: 2, + rear_bag_message_count: 2, + front_chunk_count: 2, + rear_chunk_count: 2, + front_record_count: 2, + rear_record_count: 2, + total_record_count: 4, + total_point_count: 12, + retained_bytes: 48, + peak_source_bytes: 256, + start_nanos: Some(1), + end_nanos: Some(2), + full_bag_processed: true, + truncated: false, + }, + Some(MappingOdometrySummary { + topic: "/front".into(), + source_frame: "front_frame".into(), + root_frame: "base_link".into(), + clock_id: "external".into(), + clock_domain: "external-calibrated".into(), + matcher: "fixture".into(), + scan_count: 2, + motion_count: 1, + pose_graph_node_count: 2, + pose_graph_edge_count: 1, + complete: true, + truncated: false, + }), + Some(MappingTsdfSummary { + frame_id: "base_link".into(), + origin: [0.0, 0.0, 0.0], + voxel_size: 0.5, + dims: [8, 8, 8], + truncation: 1.0, + integrated_record_count: 4, + integrated_point_count: 12, + mesh_vertex_count: 3, + mesh_triangle_count: 1, + complete: true, + }), + vec![ReplayArtifact::try_new("mesh", "/media/mesh.gltf", 1, SHA).unwrap()], + true, + true, + Vec::new(), + ) + .unwrap(); + assert!(state.summary.mapping_admitted); + state.validate().unwrap(); + } + + #[test] + fn summary_rejects_reversed_source_bounds() { + let mut summary = source_summary(); + summary.start_nanos = Some(20); + summary.end_nanos = Some(10); + assert!(summary.validate().is_err()); + } +} diff --git a/docs/REAL_DATA_ACCEPTANCE.md b/docs/REAL_DATA_ACCEPTANCE.md index 089bd6a..10c0f70 100644 --- a/docs/REAL_DATA_ACCEPTANCE.md +++ b/docs/REAL_DATA_ACCEPTANCE.md @@ -698,7 +698,7 @@ The comparison receipt is | Canonical artifact availability | Clock and front/rear frame artifacts matching the canonical input identity | Blocked; read-only SSD survey found no matching artifacts | | Clock calibration | Calibrated clock model and uncertainty receipt | Not accepted; no calibration evidence | | Frame calibration | Explicit front/rear `FrameGraph` path and extrinsic provenance | Not accepted; no extrinsic evidence | -| Mapping/reconstruction | Bounded odometry, pose graph, TSDF, and mesh receipt on this input | Not accepted; 143A prefix smoke only | +| Mapping/reconstruction | Bounded full-bag, frame-aware odometry, pose graph, TSDF, and mesh receipt on this input | Implementation gate recorded by 145K-A; canonical admission remains blocked by missing clock/frame evidence | | Semantic/Viewer/interchange | Record semantic entities, Viewer layer, and glTF/OpenUSD receipt on this input | Not accepted; deterministic adapter smoke only | | Data hygiene | No sensor or derived artifacts in the repository; all paths are external | Accepted | @@ -730,7 +730,53 @@ Every future E2E run must record: 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. +2. Run `rosbag2_full_bag_mapping` with those source-bound artifacts and accept + its complete-record/point/byte, corrected-clock, frame-path, odometry, and + TSDF/glTF receipts. The implementation and canonical blocked receipt now + exist under the 145K-A evidence section below. 3. Add semantic, Viewer, and interchange quality receipts only after the geometric output has a valid frame and provenance chain. + +## 145K-A bounded full-bag mapping gate evidence + +`spatialrust-viewer::FullBagMappingState` is the portable admission contract +for the next geometric stage. The feature-gated +`rosbag2_full_bag_mapping` example: + +- consumes every selected front/rear PointCloud2 record within explicit + record, point, byte, chunk, and source-memory bounds; +- reassembles source chunks sharing one PointCloud2 timestamp/frame before + odometry, applies a registered clock model with an anchored drift term, and + resolves only accepted source-bound root-to-sensor frame paths; +- runs full selected-stream ICP odometry and a pose graph, integrates both + sensors into a root-frame TSDF, writes embedded glTF, and records all stage + totals and source receipts; and +- writes a blocked JSON/HTML/manifest receipt when calibration is absent, + without opening the mapping pipeline or inventing calibration. + +Canonical no-artifact execution: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-v3/full-bag-mapping.json` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-v3/full-bag-mapping.html` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-v3/full-bag-mapping.manifest.json` + +The observed input SHA matched the canonical SHA exactly. Because no +`--calibration-evidence` state was supplied, the receipt has +`calibration_registered:false`, `full_bag_processed:false`, and +`mapping_admitted:false`, records seven non-empty blockers, checks three local +files in its manifest, and exits with status 2. No fused map or mesh was +written. + +Mission Cockpit integration with the same blocked mapping state is recorded at: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-mission-cockpit-v3/mission-cockpit.json` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-mission-cockpit-v3/mission-cockpit.html` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-mission-cockpit-v3/mission-cockpit.manifest.json` + +The cockpit includes the mapping-state checksum and propagates its blockers; +its `mapping_admitted` remains false even when packet inspection is available. + +The wrong-input negative probe is recorded at +`/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-validation-probe-v1/full-bag-mapping.json`. +It reports `identity_matches:false`, preserves the observed canonical SHA, +keeps `mapping_admitted:false`, exits 2, and writes a three-file manifest. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9030916..67046d1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -974,3 +974,4 @@ evidence, and fail-closed acceptance gates live in | 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 | +| 145K-A | Complete | Source-bound bounded full-bag PointCloud2 ingest, chunk reassembly, registered clock correction, root-to-front/rear frame application, ICP pose graph, TSDF/glTF receipt, and Mission Cockpit mapping-state gate; canonical admission remains blocked without real calibration artifacts | `spatialrust-viewer::FullBagMappingState`, `rosbag2_full_bag_mapping`, external `145k-full-bag-mapping-v3` JSON/HTML/manifest and `145k-mission-cockpit-v3` integration receipt | diff --git a/notes/2026-08-04_full_bag_mapping_gate.md b/notes/2026-08-04_full_bag_mapping_gate.md new file mode 100644 index 0000000..93071ac --- /dev/null +++ b/notes/2026-08-04_full_bag_mapping_gate.md @@ -0,0 +1,82 @@ +# 145K-A bounded full-bag mapping gate + +Date: 2026-08-04 + +## Direction + +The 145J-B registration contract is now available, but the canonical bag has +no matching clock or front/rear extrinsic artifacts. 145K-A therefore adds the +real mapping execution path without weakening that prerequisite: a mapping +state can be emitted as blocked, while calibrated-world admission requires all +source, clock, frame, full-bag, odometry, and TSDF gates. + +## Implementation + +`spatialrust-viewer::FullBagMappingState` records: + +- exact input identity and optional parsed `CalibrationEvidenceState`; +- bag message, bounded source chunk, retained record/point/byte, and peak + source-memory totals for both selected topics; +- corrected timeline bounds and ICP/pose-graph identity/counts; +- root-frame TSDF configuration, integrated records/points, and mesh totals; +- explicit `clock_applied`, `frame_graph_applied`, `full_bag_processed`, + `odometry_complete`, `tsdf_complete`, and `mapping_admitted` decisions; and +- checksummed input, calibration, and derived mesh artifacts with blockers. + +`rosbag2_full_bag_mapping` is feature-gated behind `rosbag2-sqlite`. It uses a +large but explicit source chunk bound, reassembles chunks with the same +PointCloud2 timestamp/frame, collects a bounded deterministic episode, applies +the registered clock offset plus an anchor-relative drift term, resolves the +accepted root-to-front/rear graph, runs ICP over all retained front records, +and integrates front plus synchronized rear records into a root-frame TSDF. +No calibration solver or implicit transform is introduced. + +`rosbag2_mission_cockpit` accepts `--mapping-state`, includes its checksum in +the artifact list/manifest, and forces cockpit mapping admission false when +the full-bag state is blocked or bound to a different input. + +## Canonical 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` + +No-artifact mapping run: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-v3/full-bag-mapping.json` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-v3/full-bag-mapping.html` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-v3/full-bag-mapping.manifest.json` + +The input identity matched, but `calibration` was null and +`mapping_admitted:false`. The command exited 2 after writing the state, +dashboard, and manifest; the manifest checked the input plus those two local +outputs. No mapping pipeline was run and no mesh was produced. + +Mission Cockpit propagation: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-mission-cockpit-v3/mission-cockpit.json` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-mission-cockpit-v3/mission-cockpit.html` +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-mission-cockpit-v3/mission-cockpit.manifest.json` + +The cockpit remains `mapping_admitted:false` and lists the full-bag mapping +blockers while retaining upstream packet inspection behavior. + +Wrong-input probe: + +- `/media/sasaki/aiueo/spatialrust-results/v1-3/145k-full-bag-mapping-validation-probe-v1/full-bag-mapping.json` + +It observes the canonical SHA against an all-`f` expected SHA, records +`identity_matches:false`, exits 2, and does not enter source ingest or mapping. + +## Validation + +- `cargo fmt --all` +- `cargo test -p spatialrust-viewer --features serde` +- `cargo test -p spatialrust-ros2 --features rosbag2-sqlite --example rosbag2_full_bag_mapping` +- `cargo test -p spatialrust-ros2 --features rosbag2-sqlite --example rosbag2_mission_cockpit` +- targeted `cargo clippy` for the viewer, mapping runner, and cockpit with + `-D warnings` +- canonical mapping runner negative execution: exit status 2, + `mapping_admitted:false`, checked-files manifest +- Node syntax check for the generated full-bag mapping dashboard