Skip to main content

heddle_object_model/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    ///
115    /// The live review path (`daemon` local `get_review_payload`) currently
116    /// treats every signal as visible rather than applying a ranked budget
117    /// split; keep this order load-bearing for callers that sort by it.
118    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/// Where in the change a signal fires. Symbol-level is preferred — symbols
130/// are durable across renames; line ranges are computed at fire time and
131/// drift as code is reformatted.
132#[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    /// Stable canonical form `<file>[:symbol][:start-end]` for grouping.
176    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/// Identifies the producer that fired this signal. The `version` lets
191/// budgeting and signal-health surfaces age out signals from old producer
192/// versions without re-running computation — important when we tune a
193/// producer's heuristics and want to compare apples to apples.
194#[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}