heddle_object_model/object/
risk_signal.rs1use 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 {
119 match self {
120 Self::InvariantAdjacency => 0,
121 Self::SelfFlaggedUncertainty => 1,
122 Self::PatternDeviation => 2,
123 Self::Novelty => 3,
124 Self::TestReachability => 4,
125 }
126 }
127}
128
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
133pub struct SignalAnchor {
134 pub file: String,
135 #[serde(default)]
136 pub symbol: Option<String>,
137 #[serde(default)]
138 pub line_range: Option<(u32, u32)>,
139}
140
141impl SignalAnchor {
142 pub fn file(file: impl Into<String>) -> Self {
143 Self {
144 file: file.into(),
145 symbol: None,
146 line_range: None,
147 }
148 }
149
150 pub fn symbol(file: impl Into<String>, symbol: impl Into<String>) -> Self {
151 Self {
152 file: file.into(),
153 symbol: Some(symbol.into()),
154 line_range: None,
155 }
156 }
157
158 pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
159 self.line_range = Some((start, end));
160 self
161 }
162
163 pub fn validate(&self) -> Result<(), RiskSignalError> {
164 if self.file.is_empty() {
165 return Err(RiskSignalError::EmptyAnchorFile);
166 }
167 if let Some((start, end)) = self.line_range
168 && start > end
169 {
170 return Err(RiskSignalError::InvalidLineRange(start, end));
171 }
172 Ok(())
173 }
174
175 pub fn canonical(&self) -> String {
177 let mut s = self.file.clone();
178 if let Some(symbol) = &self.symbol {
179 s.push(':');
180 s.push_str(symbol);
181 }
182 if let Some((start, end)) = self.line_range {
183 s.push(':');
184 s.push_str(&format!("{start}-{end}"));
185 }
186 s
187 }
188}
189
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195pub struct ProducerId {
196 pub module: String,
197 pub version: u32,
198}
199
200impl ProducerId {
201 pub fn new(module: impl Into<String>, version: u32) -> Self {
202 Self {
203 module: module.into(),
204 version,
205 }
206 }
207
208 pub fn validate(&self) -> Result<(), RiskSignalError> {
209 if self.module.is_empty() {
210 return Err(RiskSignalError::EmptyProducerModule);
211 }
212 Ok(())
213 }
214}
215
216#[derive(Debug, thiserror::Error)]
217pub enum RiskSignalError {
218 #[error("unsupported risk signal blob version {0}")]
219 UnsupportedVersion(u8),
220 #[error("risk signal reason must not be empty")]
221 EmptyReason,
222 #[error("risk signal reason too long ({len} bytes, max {max})")]
223 ReasonTooLong { len: usize, max: usize },
224 #[error("risk signal anchor must reference a non-empty file")]
225 EmptyAnchorFile,
226 #[error("risk signal line range start {0} exceeds end {1}")]
227 InvalidLineRange(u32, u32),
228 #[error("risk signal producer module must not be empty")]
229 EmptyProducerModule,
230 #[error("risk signal blob encoding error: {0}")]
231 Encoding(String),
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn sample_signal(kind: RiskSignalKind, file: &str, sym: &str) -> RiskSignal {
239 RiskSignal {
240 kind,
241 anchor: SignalAnchor::symbol(file, sym),
242 reason: "structural divergence from sibling implementations".into(),
243 producer: ProducerId::new("pattern_deviation", 1),
244 computed_at: 1_700_000_000,
245 computed_against: None,
246 }
247 }
248
249 #[test]
250 fn empty_reason_is_rejected() {
251 let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
252 sig.reason = String::new();
253 assert!(matches!(sig.validate(), Err(RiskSignalError::EmptyReason)));
254 }
255
256 #[test]
257 fn over_long_reason_is_rejected() {
258 let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
259 sig.reason = "x".repeat(MAX_REASON_LEN + 1);
260 assert!(matches!(
261 sig.validate(),
262 Err(RiskSignalError::ReasonTooLong { .. })
263 ));
264 }
265
266 #[test]
267 fn minimum_anchor_validates() {
268 let sig = sample_signal(RiskSignalKind::TestReachability, "src/lib.rs", "bar");
269 sig.validate().unwrap();
270 }
271
272 #[test]
273 fn anchor_canonical_is_stable() {
274 let a = SignalAnchor::symbol("src/lib.rs", "foo").with_line_range(10, 12);
275 let b = SignalAnchor::symbol("src/lib.rs", "foo").with_line_range(10, 12);
276 assert_eq!(a.canonical(), b.canonical());
277 assert_eq!(a.canonical(), "src/lib.rs:foo:10-12");
278 }
279
280 #[test]
281 fn priority_order_matches_spec() {
282 assert!(
283 RiskSignalKind::InvariantAdjacency.priority_rank()
284 < RiskSignalKind::SelfFlaggedUncertainty.priority_rank()
285 );
286 assert!(
287 RiskSignalKind::SelfFlaggedUncertainty.priority_rank()
288 < RiskSignalKind::PatternDeviation.priority_rank()
289 );
290 assert!(
291 RiskSignalKind::PatternDeviation.priority_rank()
292 < RiskSignalKind::Novelty.priority_rank()
293 );
294 assert!(
295 RiskSignalKind::Novelty.priority_rank()
296 < RiskSignalKind::TestReachability.priority_rank()
297 );
298 }
299
300 #[test]
301 fn blob_encode_decode_roundtrips() {
302 let blob = RiskSignalBlob::new(vec![sample_signal(
303 RiskSignalKind::Novelty,
304 "src/lib.rs",
305 "foo",
306 )]);
307 let bytes = blob.encode().unwrap();
308 let decoded = RiskSignalBlob::decode(&bytes).unwrap();
309 assert_eq!(blob, decoded);
310 }
311
312 #[test]
313 fn future_version_is_rejected() {
314 let blob = RiskSignalBlob {
315 format_version: RiskSignalBlob::FORMAT_VERSION + 1,
316 signals: vec![],
317 };
318 assert!(matches!(
319 blob.validate(),
320 Err(RiskSignalError::UnsupportedVersion(_))
321 ));
322 }
323
324 #[test]
325 fn empty_producer_module_rejected() {
326 let mut sig = sample_signal(RiskSignalKind::Novelty, "src/lib.rs", "foo");
327 sig.producer.module = String::new();
328 assert!(matches!(
329 sig.validate(),
330 Err(RiskSignalError::EmptyProducerModule)
331 ));
332 }
333}