diff --git a/Cargo.toml b/Cargo.toml index 502498a1ff4..e6876e0827c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -193,6 +193,7 @@ erased-serde = { version = "0.4", default-features = false, features = [ ] } serde_derive = { version = "1.0", features = ["default"] } pyo3 = { version = "0.28", default-features = false, features = ["macros"] } +inventory = "0.3" # External CLI clap = { version = "4.5", default-features = false, features = [ diff --git a/api/v1/cu29-derive.txt b/api/v1/cu29-derive.txt index 82be025a2af..81d2643d676 100644 --- a/api/v1/cu29-derive.txt +++ b/api/v1/cu29-derive.txt @@ -3,3 +3,4 @@ pub proc macro cu29_derive::bundle_resources!() pub proc macro cu29_derive::#[copper_runtime] pub proc macro cu29_derive::gen_cumsgs!() pub proc macro cu29_derive::resources!() +pub proc macro cu29_derive::#[safety_case] diff --git a/api/v1/cu29.txt b/api/v1/cu29.txt index ce8a0f55ad7..bdfe8c6a952 100644 --- a/api/v1/cu29.txt +++ b/api/v1/cu29.txt @@ -27,6 +27,7 @@ pub use cu29::replay pub use cu29::resource pub use cu29::resources pub use cu29::rx_channels +pub use cu29::safety_case pub use cu29::simulation pub use cu29::tx_channels pub use cu29::units @@ -100,6 +101,7 @@ pub use cu29::prelude::observed_encode_bytes pub use cu29::prelude::output_msg pub use cu29::prelude::record_observed_encode_bytes pub use cu29::prelude::rx_channels +pub use cu29::prelude::safety_case pub use cu29::prelude::to_value pub use cu29::prelude::tx_channels pub use cu29::prelude::units @@ -108,6 +110,8 @@ pub macro cu29::prelude::defmt_debug! pub macro cu29::prelude::defmt_error! pub macro cu29::prelude::defmt_info! pub macro cu29::prelude::defmt_warn! +pub macro cu29::prelude::safety_check! +pub macro cu29::prelude::safety_check_eq! pub mod cu29::rtsan pub struct cu29::rtsan::ScopedDisabler pub struct cu29::rtsan::ScopedSanitizeRealtime @@ -121,3 +125,5 @@ pub macro cu29::defmt_debug! pub macro cu29::defmt_error! pub macro cu29::defmt_info! pub macro cu29::defmt_warn! +pub macro cu29::safety_check! +pub macro cu29::safety_check_eq! diff --git a/core/cu29/Cargo.toml b/core/cu29/Cargo.toml index a1ac6968d8f..926c49c2376 100644 --- a/core/cu29/Cargo.toml +++ b/core/cu29/Cargo.toml @@ -32,6 +32,8 @@ bevy_reflect_derive = { workspace = true, optional = true } bincode = { workspace = true } serde = { workspace = true } serde_derive = { workspace = true } +inventory = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true, default-features = true } # only std rayon = { workspace = true, optional = true } @@ -82,3 +84,4 @@ std = [ "cu29-value/std", ] rtsan = ["dep:rtsan-standalone", "cu29-derive/rtsan"] +safety-ids = ["std", "dep:inventory", "dep:serde_json"] diff --git a/core/cu29/src/lib.rs b/core/cu29/src/lib.rs index d2bdbbf9fbd..8e95d0e0f74 100644 --- a/core/cu29/src/lib.rs +++ b/core/cu29/src/lib.rs @@ -29,6 +29,7 @@ //! - `high-precision-limiter`: std-only hybrid sleep/spin loop limiter for tighter `rate_target_hz` cadence //! - `async-cl-io`: offload CopperList serialization/logging to a dedicated std thread //! - `parallel-rt`: prepare the runtime for a future multi-threaded deterministic executor +//! - `safety-ids`: std-only safety-case metadata collection and JSON export helpers //! //! ## Concepts behind Copper //! @@ -52,7 +53,7 @@ //! //! ## V1 API status //! -//! The V1 public contract is defined in `docs/v1-api-surface.md`. The prelude is the +//! The V1 public contract is defined in `doc/v1-api-surface.md`. The prelude is the //! canonical application import surface; lower-level modules remain addressable by //! module path when needed, but are not implicitly part of the prelude contract. //! @@ -66,7 +67,7 @@ compile_error!("feature `parallel-rt` requires `std`"); #[cfg(not(feature = "std"))] extern crate alloc; -pub use cu29_derive::{bundle_resources, resources}; +pub use cu29_derive::{bundle_resources, resources, safety_case}; pub use cu29_runtime::app; pub use cu29_runtime::config; pub use cu29_runtime::context; @@ -100,6 +101,8 @@ pub use cu29_runtime::rx_channels; #[cfg(feature = "std")] pub use cu29_runtime::simulation; pub use cu29_runtime::tx_channels; +#[cfg(feature = "safety-ids")] +pub mod safety; #[cfg(feature = "rtsan")] pub mod rtsan { @@ -207,6 +210,20 @@ macro_rules! defmt_error { ($($tt:tt)*) => {{}}; } +#[macro_export] +macro_rules! safety_check { + ($check_id:literal, $requirement_id:literal, $description:literal, $condition:expr $(,)?) => { + assert!($condition, "{}", $description); + }; +} + +#[macro_export] +macro_rules! safety_check_eq { + ($check_id:literal, $requirement_id:literal, $description:literal, $left:expr, $right:expr $(,)?) => { + assert_eq!($left, $right, "{}", $description); + }; +} + /// Canonical imports for Copper applications. /// /// This module intentionally re-exports each stable application-facing group once. @@ -217,6 +234,7 @@ pub mod prelude { #[cfg(feature = "units")] pub use crate::units; pub use crate::{defmt_debug, defmt_error, defmt_info, defmt_warn}; + pub use crate::{safety_case, safety_check, safety_check_eq}; #[cfg(feature = "reflect")] pub use bevy_reflect_derive::Reflect; #[cfg(feature = "signal-handler")] diff --git a/core/cu29/src/safety.rs b/core/cu29/src/safety.rs new file mode 100644 index 00000000000..39d10e1bd71 --- /dev/null +++ b/core/cu29/src/safety.rs @@ -0,0 +1,107 @@ +use serde::Serialize; +use std::fs; +use std::path::Path; + +pub use inventory; + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct SafetyCheckRef { + pub check_id: &'static str, + pub requirement_id: &'static str, + pub description: &'static str, + pub kind: &'static str, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct SafetyCaseRef { + pub package: &'static str, + pub case_id: &'static str, + pub function: &'static str, + pub module_path: &'static str, + pub file: &'static str, + pub checks: &'static [SafetyCheckRef], +} + +inventory::collect!(SafetyCaseRef); + +#[derive(Debug, Clone, Serialize)] +pub struct PackageSafetyIndex { + pub package: String, + pub cases: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CollectedSafetyCase { + pub package: String, + pub case_id: String, + pub function: String, + pub test_name: String, + pub file: String, + pub checks: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CollectedSafetyCheck { + pub check_id: String, + pub requirement_id: String, + pub description: String, + pub kind: String, +} + +pub fn collect_package_index(package: &str) -> PackageSafetyIndex { + let mut cases: Vec<_> = inventory::iter:: + .into_iter() + .filter(|case_ref| case_ref.package == package) + .map(|case_ref| CollectedSafetyCase { + package: case_ref.package.to_string(), + case_id: case_ref.case_id.to_string(), + function: case_ref.function.to_string(), + test_name: libtest_name(case_ref.module_path, case_ref.function), + file: case_ref.file.to_string(), + checks: case_ref + .checks + .iter() + .map(|check| CollectedSafetyCheck { + check_id: check.check_id.to_string(), + requirement_id: check.requirement_id.to_string(), + description: check.description.to_string(), + kind: check.kind.to_string(), + }) + .collect(), + }) + .collect(); + + cases.sort_by(|left, right| left.case_id.cmp(&right.case_id)); + for case in &mut cases { + case.checks + .sort_by(|left, right| left.check_id.cmp(&right.check_id)); + } + + PackageSafetyIndex { + package: package.to_string(), + cases, + } +} + +pub fn write_package_index_json( + path: impl AsRef, + index: &PackageSafetyIndex, +) -> std::io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_vec_pretty(index).expect("safety index should serialize"); + fs::write(path, json) +} + +fn libtest_name(module_path: &str, function: &str) -> String { + let mut segments = module_path.split("::"); + let _crate_name = segments.next(); + let rest: Vec<_> = segments.collect(); + if rest.is_empty() { + function.to_string() + } else { + format!("{}::{}", rest.join("::"), function) + } +} diff --git a/core/cu29_derive/src/lib.rs b/core/cu29_derive/src/lib.rs index 61fcf7d6d0e..d0f10963d86 100644 --- a/core/cu29_derive/src/lib.rs +++ b/core/cu29_derive/src/lib.rs @@ -7,9 +7,10 @@ use std::process::Command; use syn::Fields::{Named, Unnamed}; use syn::meta::parser; use syn::parse::Parser; +use syn::punctuated::Punctuated; use syn::{ - Field, Fields, ItemImpl, ItemStruct, LitStr, Type, TypeTuple, parse_macro_input, parse_quote, - parse_str, + Block, Expr, Field, Fields, ItemFn, ItemImpl, ItemStruct, Lit, LitStr, Stmt, Token, Type, + TypeTuple, parse_macro_input, parse_quote, parse_str, }; use crate::utils::{config_id_to_bridge_const, config_id_to_enum, config_id_to_struct_member}; @@ -163,6 +164,356 @@ pub fn bundle_resources(input: TokenStream) -> TokenStream { bundle_resources::bundle_resources(input) } +#[derive(Debug, Clone)] +struct ParsedSafetyCheck { + check_id: String, + requirement_id: String, + description: String, + kind: &'static str, +} + +#[proc_macro_attribute] +pub fn safety_case(args: TokenStream, input: TokenStream) -> TokenStream { + let case_id = parse_macro_input!(args as LitStr).value(); + if let Err(err) = validate_case_id(&case_id) { + return err.to_compile_error().into(); + } + + let function = parse_macro_input!(input as ItemFn); + let checks = match collect_safety_checks(&case_id, &function.block) { + Ok(checks) => checks, + Err(err) => return err.to_compile_error().into(), + }; + + if checks.is_empty() { + return syn::Error::new_spanned( + &function.sig.ident, + format!("safety case '{case_id}' must contain at least one safety_check! or safety_check_eq!"), + ) + .to_compile_error() + .into(); + } + + let function_ident = &function.sig.ident; + let checks_tokens = checks.iter().map(|check| { + let check_id = &check.check_id; + let requirement_id = &check.requirement_id; + let description = &check.description; + let kind = check.kind; + quote! { + ::cu29::safety::SafetyCheckRef { + check_id: #check_id, + requirement_id: #requirement_id, + description: #description, + kind: #kind, + } + } + }); + + quote! { + #function + + #[cfg(feature = "safety-ids")] + ::cu29::safety::inventory::submit! { + ::cu29::safety::SafetyCaseRef { + package: env!("CARGO_PKG_NAME"), + case_id: #case_id, + function: stringify!(#function_ident), + module_path: module_path!(), + file: file!(), + checks: &[#(#checks_tokens),*], + } + } + } + .into() +} + +fn collect_safety_checks( + case_id: &str, + block: &Block, +) -> Result, syn::Error> { + let mut checks = Vec::new(); + collect_safety_checks_from_block(block, &mut checks)?; + + let mut ids = BTreeSet::new(); + for check in &checks { + validate_check_id(case_id, &check.check_id)?; + validate_requirement_id(&check.requirement_id)?; + if !ids.insert(check.check_id.clone()) { + return Err(syn::Error::new( + Span::call_site(), + format!("duplicate safety check ID '{}'", check.check_id), + )); + } + } + + Ok(checks) +} + +fn collect_safety_checks_from_block( + block: &Block, + checks: &mut Vec, +) -> Result<(), syn::Error> { + for stmt in &block.stmts { + collect_safety_checks_from_stmt(stmt, checks)?; + } + Ok(()) +} + +fn collect_safety_checks_from_stmt( + stmt: &Stmt, + checks: &mut Vec, +) -> Result<(), syn::Error> { + match stmt { + Stmt::Local(local) => { + if let Some(init) = &local.init { + collect_safety_checks_from_expr(&init.expr, checks)?; + if let Some((_else, expr)) = &init.diverge { + collect_safety_checks_from_expr(expr, checks)?; + } + } + } + Stmt::Item(_) => {} + Stmt::Expr(expr, _) => collect_safety_checks_from_expr(expr, checks)?, + Stmt::Macro(stmt_macro) => { + if let Some(check) = parse_safety_check_macro(&stmt_macro.mac)? { + checks.push(check); + } + } + } + Ok(()) +} + +fn collect_safety_checks_from_expr( + expr: &Expr, + checks: &mut Vec, +) -> Result<(), syn::Error> { + match expr { + Expr::Array(expr) => { + for elem in &expr.elems { + collect_safety_checks_from_expr(elem, checks)?; + } + } + Expr::Assign(expr) => { + collect_safety_checks_from_expr(&expr.left, checks)?; + collect_safety_checks_from_expr(&expr.right, checks)?; + } + Expr::Async(expr) => collect_safety_checks_from_block(&expr.block, checks)?, + Expr::Await(expr) => collect_safety_checks_from_expr(&expr.base, checks)?, + Expr::Binary(expr) => { + collect_safety_checks_from_expr(&expr.left, checks)?; + collect_safety_checks_from_expr(&expr.right, checks)?; + } + Expr::Block(expr) => collect_safety_checks_from_block(&expr.block, checks)?, + Expr::Break(expr) => { + if let Some(value) = &expr.expr { + collect_safety_checks_from_expr(value, checks)?; + } + } + Expr::Call(expr) => { + collect_safety_checks_from_expr(&expr.func, checks)?; + for arg in &expr.args { + collect_safety_checks_from_expr(arg, checks)?; + } + } + Expr::Cast(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::Closure(expr) => collect_safety_checks_from_expr(&expr.body, checks)?, + Expr::Field(expr) => collect_safety_checks_from_expr(&expr.base, checks)?, + Expr::ForLoop(expr) => { + collect_safety_checks_from_expr(&expr.expr, checks)?; + collect_safety_checks_from_block(&expr.body, checks)?; + } + Expr::Group(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::If(expr) => { + collect_safety_checks_from_expr(&expr.cond, checks)?; + collect_safety_checks_from_block(&expr.then_branch, checks)?; + if let Some((_else, else_expr)) = &expr.else_branch { + collect_safety_checks_from_expr(else_expr, checks)?; + } + } + Expr::Index(expr) => { + collect_safety_checks_from_expr(&expr.expr, checks)?; + collect_safety_checks_from_expr(&expr.index, checks)?; + } + Expr::Let(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::Loop(expr) => collect_safety_checks_from_block(&expr.body, checks)?, + Expr::Macro(expr_macro) => { + if let Some(check) = parse_safety_check_macro(&expr_macro.mac)? { + checks.push(check); + } + } + Expr::Match(expr) => { + collect_safety_checks_from_expr(&expr.expr, checks)?; + for arm in &expr.arms { + if let Some((_, guard)) = &arm.guard { + collect_safety_checks_from_expr(guard, checks)?; + } + collect_safety_checks_from_expr(&arm.body, checks)?; + } + } + Expr::MethodCall(expr) => { + collect_safety_checks_from_expr(&expr.receiver, checks)?; + for arg in &expr.args { + collect_safety_checks_from_expr(arg, checks)?; + } + } + Expr::Paren(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::Reference(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::Repeat(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::Return(expr) => { + if let Some(value) = &expr.expr { + collect_safety_checks_from_expr(value, checks)?; + } + } + Expr::Struct(expr) => { + for field in &expr.fields { + collect_safety_checks_from_expr(&field.expr, checks)?; + } + if let Some(rest) = &expr.rest { + collect_safety_checks_from_expr(rest, checks)?; + } + } + Expr::Try(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::TryBlock(expr) => collect_safety_checks_from_block(&expr.block, checks)?, + Expr::Tuple(expr) => { + for elem in &expr.elems { + collect_safety_checks_from_expr(elem, checks)?; + } + } + Expr::Unary(expr) => collect_safety_checks_from_expr(&expr.expr, checks)?, + Expr::Unsafe(expr) => collect_safety_checks_from_block(&expr.block, checks)?, + Expr::While(expr) => { + collect_safety_checks_from_expr(&expr.cond, checks)?; + collect_safety_checks_from_block(&expr.body, checks)?; + } + Expr::Yield(expr) => { + if let Some(value) = &expr.expr { + collect_safety_checks_from_expr(value, checks)?; + } + } + _ => {} + } + Ok(()) +} + +fn parse_safety_check_macro(mac: &syn::Macro) -> Result, syn::Error> { + let Some(segment) = mac.path.segments.last() else { + return Ok(None); + }; + + let kind = match segment.ident.to_string().as_str() { + "safety_check" => "assert", + "safety_check_eq" => "assert_eq", + _ => return Ok(None), + }; + + let args = Punctuated::::parse_terminated.parse2(mac.tokens.clone())?; + let min_args = if kind == "assert_eq" { 5 } else { 4 }; + if args.len() < min_args { + return Err(syn::Error::new_spanned( + mac, + format!( + "{}! expects at least {} arguments: check ID, requirement ID, description, and assertion inputs", + segment.ident, min_args + ), + )); + } + + let check_id = string_literal_from_expr(&args[0], "safety check ID")?; + let requirement_id = string_literal_from_expr(&args[1], "requirement ID")?; + let description = string_literal_from_expr(&args[2], "description")?; + + Ok(Some(ParsedSafetyCheck { + check_id, + requirement_id, + description, + kind, + })) +} + +fn string_literal_from_expr(expr: &Expr, label: &str) -> Result { + let Expr::Lit(expr_lit) = expr else { + return Err(syn::Error::new_spanned( + expr, + format!("{label} must be a string literal"), + )); + }; + let Lit::Str(value) = &expr_lit.lit else { + return Err(syn::Error::new_spanned( + expr, + format!("{label} must be a string literal"), + )); + }; + Ok(value.value()) +} + +fn validate_case_id(case_id: &str) -> Result<(), syn::Error> { + validate_id(case_id, "TEST", false) +} + +fn validate_check_id(case_id: &str, check_id: &str) -> Result<(), syn::Error> { + validate_id(check_id, "TEST", true)?; + if !check_id.starts_with(case_id) { + return Err(syn::Error::new( + Span::call_site(), + format!("safety check ID '{check_id}' must start with case ID '{case_id}'"), + )); + } + Ok(()) +} + +fn validate_requirement_id(requirement_id: &str) -> Result<(), syn::Error> { + validate_id(requirement_id, "REQ", false) +} + +fn validate_id(value: &str, kind: &str, allow_check_suffix: bool) -> Result<(), syn::Error> { + let mut parts = value.split('-'); + let Some(prefix) = parts.next() else { + return invalid_id(value, kind, allow_check_suffix); + }; + let Some(actual_kind) = parts.next() else { + return invalid_id(value, kind, allow_check_suffix); + }; + let Some(number) = parts.next() else { + return invalid_id(value, kind, allow_check_suffix); + }; + + if !prefix.chars().all(|ch| ch.is_ascii_uppercase()) + || actual_kind != kind + || number.len() != 3 + || !number.chars().all(|ch| ch.is_ascii_digit()) + { + return invalid_id(value, kind, allow_check_suffix); + } + + match parts.next() { + None => Ok(()), + Some(check_suffix) if allow_check_suffix && check_suffix.starts_with('C') => { + let digits = &check_suffix[1..]; + if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_digit()) { + return invalid_id(value, kind, allow_check_suffix); + } + if parts.next().is_some() { + return invalid_id(value, kind, allow_check_suffix); + } + Ok(()) + } + _ => invalid_id(value, kind, allow_check_suffix), + } +} + +fn invalid_id(value: &str, kind: &str, allow_check_suffix: bool) -> Result<(), syn::Error> { + let suffix = if allow_check_suffix { + " or PREFIX-TEST-001-C1" + } else { + "" + }; + Err(syn::Error::new( + Span::call_site(), + format!("invalid ID '{value}', expected PREFIX-{kind}-001{suffix}"), + )) +} + /// Generates the CopperList content type from a config. /// gen_cumsgs!("path/to/config.toml") /// It will create a new type called CuStampedDataSet you can pass to the log reader for decoding: diff --git a/docs/v1-api-surface.md b/doc/v1-api-surface.md similarity index 95% rename from docs/v1-api-surface.md rename to doc/v1-api-surface.md index c06cd7c6c80..fb395cbdd87 100644 --- a/docs/v1-api-surface.md +++ b/doc/v1-api-surface.md @@ -41,6 +41,10 @@ This file defines the Copper V1 public contract. Anything not listed as stable i - `output_msg!` - `BridgeChannel` - `BridgeChannelSet` +- Safety case authoring APIs: + - `#[safety_case("...")]` + - `safety_check!` + - `safety_check_eq!` - Resource APIs: - `resources!` - `bundle_resources!` @@ -95,6 +99,7 @@ This file defines the Copper V1 public contract. Anything not listed as stable i - `remote-debug` feature and `cu29::remote_debug`. - `parallel-rt` feature and parallel executor APIs. - `async-cl-io` feature and async CopperList I/O internals. +- `safety-ids` feature and `cu29::safety` metadata collection/export APIs. - Runtime performance knobs: - `sysclock-perf` - `high-precision-limiter` diff --git a/examples/cu_caterpillar/Cargo.toml b/examples/cu_caterpillar/Cargo.toml index a7f7d151d2b..85d5e152eda 100644 --- a/examples/cu_caterpillar/Cargo.toml +++ b/examples/cu_caterpillar/Cargo.toml @@ -27,6 +27,11 @@ name = "cu-caterpillar-resim" path = "src/resim.rs" required-features = ["sim-debug"] +[[test]] +name = "safety_ids" +path = "tests/safety_ids.rs" +harness = false +required-features = ["safety-ids"] [dependencies] cu29 = { workspace = true } @@ -44,6 +49,7 @@ mock = ["cu-rp-gpio/mock"] sim-debug = ["mock", "cu29/reflect", "cu29/remote-debug"] rtsan = ["cu29/rtsan"] determinism_ci = [] +safety-ids = ["determinism_ci", "cu29/safety-ids"] [package.metadata.cargo-shear] ignored = ["serde"] diff --git a/examples/cu_caterpillar/src/determinism_test.rs b/examples/cu_caterpillar/src/determinism.rs similarity index 78% rename from examples/cu_caterpillar/src/determinism_test.rs rename to examples/cu_caterpillar/src/determinism.rs index 2e05c41bc4d..a8767387325 100644 --- a/examples/cu_caterpillar/src/determinism_test.rs +++ b/examples/cu_caterpillar/src/determinism.rs @@ -1,39 +1,21 @@ -//! Determinism contract unit test. -//! -//! Contract: -//! 1) record A copperlists == record B copperlists -//! 2) record A keyframes == record B keyframes -//! 3) record A copperlists == resim(A) copperlists -//! 4) record A keyframes == resim(A) keyframes - +use crate::tasks; use cu_rp_gpio::RPGpioPayload; use cu29::bincode; use cu29::prelude::*; use cu29_export::{copperlists_reader, keyframes_reader}; - -use crate::tasks; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; -/// Serialize this test even if the harness runs test threads > 1. static DET_LOCK: Mutex<()> = Mutex::new(()); - -/// Ensure unique output dirs even across multiple determinism tests in the same process. static RUN_ID: AtomicUsize = AtomicUsize::new(0); - -// Keep the log slab big enough to satisfy the config's copperlist section size. const DET_LOG_SLAB_SIZE: Option = Some(256 * 1024 * 1024); -// Put the runtime in sim_mode so we can drive a fixed number of iterations deterministically. -// IMPORTANT: define this inside the module to avoid name collisions with the "real" app in main.rs. #[copper_runtime(config = "config/copperconfig_determinism.ron", sim_mode = true)] struct CaterpillarDeterminismApp {} fn out_root_dir() -> PathBuf { - // Prefer a stable repo-local path so CI can upload artifacts on failure. - // If you set CARGO_TARGET_DIR in CI, you can switch to that. let base = std::env::var_os("CARGO_TARGET_DIR") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target")); @@ -57,7 +39,6 @@ fn record_run(log_base: &Path, iterations: usize, dt_ticks: u64) -> CuResult<()> } let (clock, clock_mock) = RobotClock::mock(); - let slab_size = DET_LOG_SLAB_SIZE; let mut sim_state = true; @@ -172,21 +153,16 @@ fn resim_one_copperlist( use default::SimStep::*; let msgs = &copper_list.msgs; - - // Sync clock to the recorded source output. let ticks = msgs.get_src_output().metadata.process_time.start.unwrap(); robot_clock_mock.set_value(ticks.as_nanos()); let mut cb = move |step: default::SimStep| -> SimOverride { match step { - // Stub source: inject recorded src output. Src(CuTaskCallbackState::Process(_, output)) => { *output = msgs.get_src_output().clone(); SimOverride::ExecutedBySim } Src(_) => SimOverride::ExecutedBySim, - - // Stub sinks: keep metadata deterministic without touching hardware. Gpio0(CuTaskCallbackState::Process(input, output)) | Gpio1(CuTaskCallbackState::Process(input, output)) | Gpio2(CuTaskCallbackState::Process(input, output)) @@ -206,8 +182,6 @@ fn resim_one_copperlist( } Gpio0(_) | Gpio1(_) | Gpio2(_) | Gpio3(_) | Gpio4(_) | Gpio5(_) | Gpio6(_) | Gpio7(_) => SimOverride::ExecutedBySim, - - // Everything else: runtime executes. _ => SimOverride::ExecuteByRuntime, } }; @@ -237,7 +211,6 @@ fn resim_run(input_log_base: &Path, output_log_base: &Path) -> CuResult<()> { app.start_all_tasks(&mut init_cb) .expect("failed to start tasks (resim)"); - // Read recorded copperlists and replay them. let UnifiedLogger::Read(dl) = UnifiedLoggerBuilder::new() .file_base_name(input_log_base) .build() @@ -259,40 +232,15 @@ fn resim_run(input_log_base: &Path, output_log_base: &Path) -> CuResult<()> { Ok(()) } -fn assert_streams_equal(label_a: &str, a: &[Vec], label_b: &str, b: &[Vec]) { - assert_eq!( - a.len(), - b.len(), - "determinism failure: stream length differs ({}={}, {}={})", - label_a, - a.len(), - label_b, - b.len() - ); - - for (i, (item_a, item_b)) in a.iter().zip(b.iter()).enumerate() { - if item_a != item_b { - panic!( - "determinism failure: mismatch at copperlist index {} ({} vs {})", - i, label_a, label_b - ); - } - } -} - -#[test] -fn determinism_record_and_resim() { - // Ensure this test is serialized (important if other tests exist). +#[cfg_attr(all(test, feature = "determinism_ci"), test)] +#[safety_case("DET-TEST-001")] +pub fn determinism_record_and_resim() { let _guard = DET_LOCK.lock().unwrap(); - // Allow overriding iteration count from CI without code changes. let iterations: usize = std::env::var("COPPER_DETERMINISM_ITERS") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or_else(|| { - // Keep it light for local debug builds. - if cfg!(debug_assertions) { 256 } else { 1024 } - }); + .unwrap_or_else(|| if cfg!(debug_assertions) { 256 } else { 1024 }); let dt_ticks: u64 = std::env::var("COPPER_DETERMINISM_DT_TICKS") .ok() @@ -304,7 +252,6 @@ fn determinism_record_and_resim() { let b_base = case_dir.join("record_b.copper"); let r_base = case_dir.join("resim_a.copper"); - // 1) record A and B record_run(&a_base, iterations, dt_ticks).expect("record A failed"); record_run(&b_base, iterations, dt_ticks).expect("record B failed"); @@ -313,23 +260,50 @@ fn determinism_record_and_resim() { let a_keyframes = read_keyframe_stream_encoded(&a_base).expect("read A keyframes failed"); let b_keyframes = read_keyframe_stream_encoded(&b_base).expect("read B keyframes failed"); - // 2) A == B (copperlists + keyframes) - assert_streams_equal("record_a", &a_stream, "record_b", &b_stream); - assert!( + safety_check_eq!( + "DET-TEST-001-C1", + "DET-REQ-001", + "record A copperlists equal record B copperlists", + &a_stream, + &b_stream, + ); + safety_check!( + "DET-TEST-001-C2", + "DET-REQ-002", + "record A emits at least one keyframe", !a_keyframes.is_empty(), - "determinism precondition failure: expected keyframes to be emitted" ); - assert_streams_equal("record_a_kf", &a_keyframes, "record_b_kf", &b_keyframes); + safety_check_eq!( + "DET-TEST-001-C3", + "DET-REQ-002", + "record A keyframes equal record B keyframes", + &a_keyframes, + &b_keyframes, + ); - // 3) resim(A) resim_run(&a_base, &r_base).expect("resim(A) failed"); let r_stream = read_copperlist_stream_encoded(&r_base).expect("read resim failed"); let r_keyframes = read_keyframe_stream_encoded(&r_base).expect("read resim keyframes failed"); - // 4) A == resim(A) (copperlists + keyframes) - assert_streams_equal("record_a", &a_stream, "resim_a", &r_stream); - assert_streams_equal("record_a_kf", &a_keyframes, "resim_a_kf", &r_keyframes); + safety_check_eq!( + "DET-TEST-001-C4", + "DET-REQ-003", + "replay A copperlists equal record A copperlists", + &a_stream, + &r_stream, + ); + safety_check_eq!( + "DET-TEST-001-C5", + "DET-REQ-004", + "replay A keyframes equal record A keyframes", + &a_keyframes, + &r_keyframes, + ); - // If you want to keep artifacts even on success, comment this out. let _ = fs::remove_dir_all(case_dir); } + +#[cfg(feature = "safety-ids")] +pub fn link_safety_ids() { + let _ = determinism_record_and_resim as fn(); +} diff --git a/examples/cu_caterpillar/src/lib.rs b/examples/cu_caterpillar/src/lib.rs index f053a00b2f6..bec7ae737fd 100644 --- a/examples/cu_caterpillar/src/lib.rs +++ b/examples/cu_caterpillar/src/lib.rs @@ -1 +1,4 @@ pub mod tasks; + +#[cfg(any(all(test, feature = "determinism_ci"), feature = "safety-ids"))] +pub mod determinism; diff --git a/examples/cu_caterpillar/src/main.rs b/examples/cu_caterpillar/src/main.rs index 6d634b687f5..92d544fc9b3 100644 --- a/examples/cu_caterpillar/src/main.rs +++ b/examples/cu_caterpillar/src/main.rs @@ -27,6 +27,3 @@ fn main() { debug!("Application Ended: {}", error) } } - -#[cfg(all(test, feature = "determinism_ci"))] -mod determinism_test; diff --git a/examples/cu_caterpillar/tests/safety_ids.rs b/examples/cu_caterpillar/tests/safety_ids.rs new file mode 100644 index 00000000000..34daf2ca786 --- /dev/null +++ b/examples/cu_caterpillar/tests/safety_ids.rs @@ -0,0 +1,21 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let out = parse_out_path(); + cu_caterpillar::determinism::link_safety_ids(); + let index = cu29::safety::collect_package_index(env!("CARGO_PKG_NAME")); + cu29::safety::write_package_index_json(&out, &index).expect("failed to write safety ID dump"); +} + +fn parse_out_path() -> PathBuf { + let mut args = env::args().skip(1); + let flag = args.next().expect("expected '--out '"); + let path = args.next().expect("expected '--out '"); + assert_eq!(flag, "--out", "expected '--out '"); + assert!( + args.next().is_none(), + "unexpected extra arguments, expected only '--out '", + ); + PathBuf::from(path) +}