use serde::{Deserialize, Serialize};
use crate::object::hash::ChangeId;
pub const MAX_REASON_LEN: usize = 200;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RiskSignalBlob {
pub format_version: u8,
pub signals: Vec<RiskSignal>,
}
versioned_msgpack_blob! {
blob: RiskSignalBlob,
item: RiskSignal,
field: signals,
error: RiskSignalError,
codec_err: Encoding,
version: 1,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RiskSignal {
pub kind: RiskSignalKind,
pub anchor: SignalAnchor,
pub reason: String,
pub producer: ProducerId,
pub computed_at: i64,
#[serde(default)]
pub computed_against: Option<ChangeId>,
}
impl RiskSignal {
pub fn validate(&self) -> Result<(), RiskSignalError> {
if self.reason.is_empty() {
return Err(RiskSignalError::EmptyReason);
}
if self.reason.len() > MAX_REASON_LEN {
return Err(RiskSignalError::ReasonTooLong {
len: self.reason.len(),
max: MAX_REASON_LEN,
});
}
self.anchor.validate()?;
self.producer.validate()?;
Ok(())
}
pub fn anchor_key(&self) -> String {
self.anchor.canonical()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskSignalKind {
Novelty,
TestReachability,
PatternDeviation,
InvariantAdjacency,
SelfFlaggedUncertainty,
}
impl RiskSignalKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Novelty => "novelty",
Self::TestReachability => "test_reachability",
Self::PatternDeviation => "pattern_deviation",
Self::InvariantAdjacency => "invariant_adjacency",
Self::SelfFlaggedUncertainty => "self_flagged_uncertainty",
}
}
pub fn priority_rank(&self) -> u8 {
match self {
Self::InvariantAdjacency => 0,
Self::SelfFlaggedUncertainty => 1,
Self::PatternDeviation => 2,
Self::Novelty => 3,
Self::TestReachability => 4,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignalAnchor {
pub file: String,
#[serde(default)]
pub symbol: Option<String>,
#[serde(default)]
pub line_range: Option<(u32, u32)>,
}
impl SignalAnchor {
pub fn file(file: impl Into<String>) -> Self {
Self {
file: file.into(),
symbol: None,
line_range: None,
}
}
pub fn symbol(file: impl Into<String>, symbol: impl Into<String>) -> Self {
Self {
file: file.into(),
symbol: Some(symbol.into()),
line_range: None,
}
}
pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
self.line_range = Some((start, end));
self
}
pub fn validate(&self) -> Result<(), RiskSignalError> {
if self.file.is_empty() {
return Err(RiskSignalError::EmptyAnchorFile);
}
if let Some((start, end)) = self.line_range
&& start > end
{
return Err(RiskSignalError::InvalidLineRange(start, end));
}
Ok(())
}
pub fn canonical(&self) -> String {
let mut s = self.file.clone();
if let Some(symbol) = &self.symbol {
s.push(':');
s.push_str(symbol);
}
if let Some((start, end)) = self.line_range {
s.push(':');
s.push_str(&format!("{start}-{end}"));
}
s
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProducerId {
pub module: String,
pub version: u32,
}
impl ProducerId {
pub fn new(module: impl Into<String>, version: u32) -> Self {
Self {
module: module.into(),
version,
}
}
pub fn validate(&self) -> Result<(), RiskSignalError> {
if self.module.is_empty() {
return Err(RiskSignalError::EmptyProducerModule);
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum RiskSignalError {
#[error("unsupported risk signal blob version {0}")]
UnsupportedVersion(u8),
#[error("risk signal reason must not be empty")]
EmptyReason,
#[error("risk signal reason too long ({len} bytes, max {max})")]
ReasonTooLong { len: usize, max: usize },
#[error("risk signal anchor must reference a non-empty file")]
EmptyAnchorFile,
#[error("risk signal line range start {0} exceeds end {1}")]
InvalidLineRange(u32, u32),
#[error("risk signal producer module must not be empty")]
EmptyProducerModule,
#[error("risk signal blob encoding error: {0}")]
Encoding(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_signal(kind: RiskSignalKind, file: &str, sym: &str) -> RiskSignal {
RiskSignal {
kind,
anchor: SignalAnchor::symbol(file, sym),
reason: "structural divergence from sibling implementations".into(),
producer: ProducerId::new("pattern_deviation", 1),
computed_at: 1_700_000_000,
computed_against: None,
}
}
#[test]
fn empty_reason_is_rejected() {
let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
sig.reason = String::new();
assert!(matches!(sig.validate(), Err(RiskSignalError::EmptyReason)));
}
#[test]
fn over_long_reason_is_rejected() {
let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
sig.reason = "x".repeat(MAX_REASON_LEN + 1);
assert!(matches!(
sig.validate(),
Err(RiskSignalError::ReasonTooLong { .. })
));
}
#[test]
fn minimum_anchor_validates() {
let sig = sample_signal(RiskSignalKind::TestReachability, "src/lib.rs", "bar");
sig.validate().unwrap();
}
#[test]
fn anchor_canonical_is_stable() {
let a = SignalAnchor::symbol("src/lib.rs", "foo").with_line_range(10, 12);
let b = SignalAnchor::symbol("src/lib.rs", "foo").with_line_range(10, 12);
assert_eq!(a.canonical(), b.canonical());
assert_eq!(a.canonical(), "src/lib.rs:foo:10-12");
}
#[test]
fn priority_order_matches_spec() {
assert!(
RiskSignalKind::InvariantAdjacency.priority_rank()
< RiskSignalKind::SelfFlaggedUncertainty.priority_rank()
);
assert!(
RiskSignalKind::SelfFlaggedUncertainty.priority_rank()
< RiskSignalKind::PatternDeviation.priority_rank()
);
assert!(
RiskSignalKind::PatternDeviation.priority_rank()
< RiskSignalKind::Novelty.priority_rank()
);
assert!(
RiskSignalKind::Novelty.priority_rank()
< RiskSignalKind::TestReachability.priority_rank()
);
}
#[test]
fn blob_encode_decode_roundtrips() {
let blob = RiskSignalBlob::new(vec![sample_signal(
RiskSignalKind::Novelty,
"src/lib.rs",
"foo",
)]);
let bytes = blob.encode().unwrap();
let decoded = RiskSignalBlob::decode(&bytes).unwrap();
assert_eq!(blob, decoded);
}
#[test]
fn future_version_is_rejected() {
let blob = RiskSignalBlob {
format_version: RiskSignalBlob::FORMAT_VERSION + 1,
signals: vec![],
};
assert!(matches!(
blob.validate(),
Err(RiskSignalError::UnsupportedVersion(_))
));
}
#[test]
fn empty_producer_module_rejected() {
let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
sig.producer.module = String::new();
assert!(matches!(
sig.validate(),
Err(RiskSignalError::EmptyProducerModule)
));
}
}