Skip to main content

objects/object/
risk_signal.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Typed risk signals computed against a state and persisted alongside it.
3//!
4//! Computation is pure (`(prior_state, new_state, repo_config) -> Vec<RiskSignal>`)
5//! and lives in `crates/state_review/`. This module owns only the shape: what
6//! a signal is, how it serializes on disk, and the validation rules.
7//!
8//! Fired signals are referenced by detached state attachments. Tick budgeting
9//! remains a render-time concern.
10//!
11//! Wire encoding: rmp-serde MessagePack. Format version is `1`. New optional
12//! fields are appended at the tail of [`RiskSignal`] with `#[serde(default)]`,
13//! matching the convention used elsewhere in the object model.
14
15use serde::{Deserialize, Serialize};
16
17use crate::object::hash::StateId;
18
19/// Maximum length of [`RiskSignal::reason`], in bytes.
20///
21/// The reason is meant to be a single sentence, surfaced in tight gutter UI.
22/// Keeping the cap at 200 forces producers to be specific and prevents the
23/// review payload from ballooning when many signals fire.
24pub const MAX_REASON_LEN: usize = 200;
25
26/// Top-level encoded blob referenced by a risk-signal state attachment.
27#[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    /// Unix epoch seconds.
49    pub computed_at: i64,
50    /// Optional state this signal was computed against. Useful for retracing
51    /// when a signal moves between renders (e.g., anchor travel after a
52    /// rename).
53    #[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    /// Stable canonical anchor string used to group signals on the same anchor
74    /// during render-time budgeting. The format is intentionally simple so
75    /// budgeting comparisons are cheap and order-independent.
76    pub fn anchor_key(&self) -> String {
77        self.anchor.canonical()
78    }
79}
80
81/// Why a signal fired. Variants are wire-stable; new variants are appended.
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum RiskSignalKind {
85    /// New control-flow shape that doesn't appear elsewhere in the repo.
86    Novelty,
87    /// No test in the repo statically reaches the changed symbol.
88    /// Reasoning text *must* clarify this is static reachability via
89    /// tree-sitter, not runtime coverage.
90    TestReachability,
91    /// New code structurally diverges from local exemplars (sibling
92    /// functions or the prior version of the same symbol).
93    PatternDeviation,
94    /// An invariant or `enforces`-tagged annotation lives on the changed
95    /// symbol.
96    InvariantAdjacency,
97    /// Agent flagged uncertainty about its own output. Passthrough from
98    /// the captured state's provenance.
99    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    /// Render-time priority. Lower numbers surface first when budgeting.
114    /// See `state_review::budget` for the full algorithm.
115    ///
116    /// The order is load-bearing: changing it changes which signals reviewers
117    /// see first when many fire on the same state. If you bump these numbers,
118    /// update the budgeting test goldens too.
119    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/// Where in the change a signal fires. Symbol-level is preferred — symbols
131/// are durable across renames; line ranges are computed at fire time and
132/// drift as code is reformatted.
133#[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    /// Stable canonical form `<file>[:symbol][:start-end]` for grouping.
177    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/// Identifies the producer that fired this signal. The `version` lets
192/// budgeting and signal-health surfaces age out signals from old producer
193/// versions without re-running computation — important when we tune a
194/// producer's heuristics and want to compare apples to apples.
195#[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}