1use serde::{Deserialize, Serialize};
16
17use crate::object::hash::StateId;
18
19pub const MAX_REASON_LEN: usize = 200;
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct RiskSignalBlob {
29 pub format_version: u8,
30 pub signals: Vec<RiskSignal>,
31}
32
33versioned_msgpack_blob! {
34 blob: RiskSignalBlob,
35 item: RiskSignal,
36 field: signals,
37 error: RiskSignalError,
38 codec_err: Encoding,
39 version: 1,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
43pub struct RiskSignal {
44 pub kind: RiskSignalKind,
45 pub anchor: SignalAnchor,
46 pub reason: String,
47 pub producer: ProducerId,
48 pub computed_at: i64,
50 #[serde(default)]
54 pub computed_against: Option<StateId>,
55}
56
57impl RiskSignal {
58 pub fn validate(&self) -> Result<(), RiskSignalError> {
59 if self.reason.is_empty() {
60 return Err(RiskSignalError::EmptyReason);
61 }
62 if self.reason.len() > MAX_REASON_LEN {
63 return Err(RiskSignalError::ReasonTooLong {
64 len: self.reason.len(),
65 max: MAX_REASON_LEN,
66 });
67 }
68 self.anchor.validate()?;
69 self.producer.validate()?;
70 Ok(())
71 }
72
73 pub fn anchor_key(&self) -> String {
77 self.anchor.canonical()
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum RiskSignalKind {
85 Novelty,
87 TestReachability,
91 PatternDeviation,
94 InvariantAdjacency,
97 SelfFlaggedUncertainty,
100}
101
102impl RiskSignalKind {
103 pub fn as_str(&self) -> &'static str {
104 match self {
105 Self::Novelty => "novelty",
106 Self::TestReachability => "test_reachability",
107 Self::PatternDeviation => "pattern_deviation",
108 Self::InvariantAdjacency => "invariant_adjacency",
109 Self::SelfFlaggedUncertainty => "self_flagged_uncertainty",
110 }
111 }
112
113 pub fn priority_rank(&self) -> u8 {
120 match self {
121 Self::InvariantAdjacency => 0,
122 Self::SelfFlaggedUncertainty => 1,
123 Self::PatternDeviation => 2,
124 Self::Novelty => 3,
125 Self::TestReachability => 4,
126 }
127 }
128}
129
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134pub struct SignalAnchor {
135 pub file: String,
136 #[serde(default)]
137 pub symbol: Option<String>,
138 #[serde(default)]
139 pub line_range: Option<(u32, u32)>,
140}
141
142impl SignalAnchor {
143 pub fn file(file: impl Into<String>) -> Self {
144 Self {
145 file: file.into(),
146 symbol: None,
147 line_range: None,
148 }
149 }
150
151 pub fn symbol(file: impl Into<String>, symbol: impl Into<String>) -> Self {
152 Self {
153 file: file.into(),
154 symbol: Some(symbol.into()),
155 line_range: None,
156 }
157 }
158
159 pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
160 self.line_range = Some((start, end));
161 self
162 }
163
164 pub fn validate(&self) -> Result<(), RiskSignalError> {
165 if self.file.is_empty() {
166 return Err(RiskSignalError::EmptyAnchorFile);
167 }
168 if let Some((start, end)) = self.line_range
169 && start > end
170 {
171 return Err(RiskSignalError::InvalidLineRange(start, end));
172 }
173 Ok(())
174 }
175
176 pub fn canonical(&self) -> String {
178 let mut s = self.file.clone();
179 if let Some(symbol) = &self.symbol {
180 s.push(':');
181 s.push_str(symbol);
182 }
183 if let Some((start, end)) = self.line_range {
184 s.push(':');
185 s.push_str(&format!("{start}-{end}"));
186 }
187 s
188 }
189}
190
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
196pub struct ProducerId {
197 pub module: String,
198 pub version: u32,
199}
200
201impl ProducerId {
202 pub fn new(module: impl Into<String>, version: u32) -> Self {
203 Self {
204 module: module.into(),
205 version,
206 }
207 }
208
209 pub fn validate(&self) -> Result<(), RiskSignalError> {
210 if self.module.is_empty() {
211 return Err(RiskSignalError::EmptyProducerModule);
212 }
213 Ok(())
214 }
215}
216
217#[derive(Debug, thiserror::Error)]
218pub enum RiskSignalError {
219 #[error("unsupported risk signal blob version {0}")]
220 UnsupportedVersion(u8),
221 #[error("risk signal reason must not be empty")]
222 EmptyReason,
223 #[error("risk signal reason too long ({len} bytes, max {max})")]
224 ReasonTooLong { len: usize, max: usize },
225 #[error("risk signal anchor must reference a non-empty file")]
226 EmptyAnchorFile,
227 #[error("risk signal line range start {0} exceeds end {1}")]
228 InvalidLineRange(u32, u32),
229 #[error("risk signal producer module must not be empty")]
230 EmptyProducerModule,
231 #[error("risk signal blob encoding error: {0}")]
232 Encoding(String),
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn sample_signal(kind: RiskSignalKind, file: &str, sym: &str) -> RiskSignal {
240 RiskSignal {
241 kind,
242 anchor: SignalAnchor::symbol(file, sym),
243 reason: "structural divergence from sibling implementations".into(),
244 producer: ProducerId::new("pattern_deviation", 1),
245 computed_at: 1_700_000_000,
246 computed_against: None,
247 }
248 }
249
250 #[test]
251 fn empty_reason_is_rejected() {
252 let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
253 sig.reason = String::new();
254 assert!(matches!(sig.validate(), Err(RiskSignalError::EmptyReason)));
255 }
256
257 #[test]
258 fn over_long_reason_is_rejected() {
259 let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
260 sig.reason = "x".repeat(MAX_REASON_LEN + 1);
261 assert!(matches!(
262 sig.validate(),
263 Err(RiskSignalError::ReasonTooLong { .. })
264 ));
265 }
266
267 #[test]
268 fn minimum_anchor_validates() {
269 let sig = sample_signal(RiskSignalKind::TestReachability, "src/lib.rs", "bar");
270 sig.validate().unwrap();
271 }
272
273 #[test]
274 fn anchor_canonical_is_stable() {
275 let a = SignalAnchor::symbol("src/lib.rs", "foo").with_line_range(10, 12);
276 let b = SignalAnchor::symbol("src/lib.rs", "foo").with_line_range(10, 12);
277 assert_eq!(a.canonical(), b.canonical());
278 assert_eq!(a.canonical(), "src/lib.rs:foo:10-12");
279 }
280
281 #[test]
282 fn priority_order_matches_spec() {
283 assert!(
284 RiskSignalKind::InvariantAdjacency.priority_rank()
285 < RiskSignalKind::SelfFlaggedUncertainty.priority_rank()
286 );
287 assert!(
288 RiskSignalKind::SelfFlaggedUncertainty.priority_rank()
289 < RiskSignalKind::PatternDeviation.priority_rank()
290 );
291 assert!(
292 RiskSignalKind::PatternDeviation.priority_rank()
293 < RiskSignalKind::Novelty.priority_rank()
294 );
295 assert!(
296 RiskSignalKind::Novelty.priority_rank()
297 < RiskSignalKind::TestReachability.priority_rank()
298 );
299 }
300
301 #[test]
302 fn blob_encode_decode_roundtrips() {
303 let blob = RiskSignalBlob::new(vec![sample_signal(
304 RiskSignalKind::Novelty,
305 "src/lib.rs",
306 "foo",
307 )]);
308 let bytes = blob.encode().unwrap();
309 let decoded = RiskSignalBlob::decode(&bytes).unwrap();
310 assert_eq!(blob, decoded);
311 }
312
313 #[test]
314 fn future_version_is_rejected() {
315 let blob = RiskSignalBlob {
316 format_version: RiskSignalBlob::FORMAT_VERSION + 1,
317 signals: vec![],
318 };
319 assert!(matches!(
320 blob.validate(),
321 Err(RiskSignalError::UnsupportedVersion(_))
322 ));
323 }
324
325 #[test]
326 fn empty_producer_module_rejected() {
327 let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
328 sig.producer.module = String::new();
329 assert!(matches!(
330 sig.validate(),
331 Err(RiskSignalError::EmptyProducerModule)
332 ));
333 }
334}