Skip to main content

aristo_cli/nudge/
mod.rs

1//! The nudge/progress engine's DECIDE leg (Phase 18 #9, S0c).
2//!
3//! A plug-and-play scorer over a registry of `Signal`s. Each signal turns
4//! some engine input into a raw "pressure" `m`; the scorer normalizes it by
5//! the signal's base `b` and scales by the configured aggressiveness factor
6//! `f`, firing iff `pressure * f >= 1`. Adding a nudge is one `SIGNALS`
7//! entry — the scorer, ordering, and (later) throttle/render are generic.
8//!
9//! Two design invariants this module enforces:
10//!
11//! - **`aggressiveness = off` is a hard silence.** `factor()` is `0.0`, so
12//!   `pressure * 0.0 >= 1` is false for every signal at every pressure —
13//!   nothing can fire (cf. the `aggressiveness_off_is_hard_silence` intent
14//!   in `aristo-core::config`).
15//! - **Ordering is fixed priority, not pressure.** Pressures of counts and
16//!   fractions are incommensurable, so the surfaced order is the static
17//!   `SIGNALS` order (congrats banner, then review > canon > verify >
18//!   proof-review > slump); the Agent-audience signal is a separate surface.
19
20use aristo_core::config::Aggressiveness;
21use aristo_core::metrics::Metrics;
22
23pub mod intents;
24pub mod state;
25pub mod throttle;
26
27/// Who a fired signal is addressed to. The two are surfaced through
28/// different channels (D4/D10): the agent gets an inline `<system-reminder>`;
29/// the human gets the consolidated review prompt the agent pops.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Audience {
32    /// Nudge the coding agent directly (e.g. "you haven't annotated lately").
33    Agent,
34    /// Surface to the human via the agent-run review prompt.
35    Human,
36}
37
38/// Everything the signal metrics read. The cli's union function (S0c+)
39/// populates the runtime fields it can't see from the index alone (reviewed
40/// map, queue/canon counts, the edit-window baseline); the index-derived
41/// part is [`Metrics`].
42#[derive(Debug, Clone)]
43pub struct EngineInputs {
44    /// Index-derived metrics (counts, backlog, tier, score).
45    pub metrics: Metrics,
46    /// Source edits since the last annotation was added (PostToolUse counter).
47    pub edits_since_annotation: usize,
48    /// Authored intents not yet marked reviewed (new + backlog).
49    pub unreviewed_intents: usize,
50    /// Proofs carrying a verdict not yet in the proof-reviewed map.
51    pub proofs_awaiting_review: usize,
52    /// Pending canon matches + suggestions (only meaningful when signed in).
53    pub canon_pending: usize,
54    /// The visible score at the edit-window baseline, if one was captured.
55    pub prior_score: Option<f64>,
56    /// Whether the tier rose since the edit-window baseline (instant win).
57    pub tier_increased: bool,
58    /// Whether a repo-scoped token is present (gates the paid canon signal).
59    pub signed_in: bool,
60}
61
62/// A registered nudge. `metric` extracts the raw pressure numerator `m` from
63/// the inputs; `base` is the denominator `b`; the signal fires when
64/// `(m / b) * factor >= 1`.
65pub struct Signal {
66    /// Stable identifier (also the throttle-map key).
67    pub id: &'static str,
68    /// Who the nudge is for.
69    pub audience: Audience,
70    /// The pressure denominator `b` (> 0).
71    pub base: f64,
72    /// Extract the raw pressure numerator `m` from the inputs.
73    pub metric: fn(&EngineInputs) -> f64,
74}
75
76impl Signal {
77    /// Normalized pressure `m / b` (independent of aggressiveness).
78    pub fn pressure(&self, inputs: &EngineInputs) -> f64 {
79        (self.metric)(inputs) / self.base
80    }
81}
82
83/// A signal that fired, with the pressure that fired it and the raw
84/// `metric`/`base` (so the throttle can apply a material-increase re-arm
85/// against the same numbers).
86#[derive(Debug, Clone, PartialEq)]
87pub struct Fired {
88    pub id: &'static str,
89    pub audience: Audience,
90    pub pressure: f64,
91    /// The raw pressure numerator `m` (`pressure == metric / base`).
92    pub metric: f64,
93    /// The signal's pressure denominator `b`.
94    pub base: f64,
95}
96
97/// The registry. Order IS priority for the human surface: the congrats
98/// banner leads, then review > canon > verify > proof-review > slump. The
99/// Agent-audience `authoring_debt` sits last (separate channel, order among
100/// human signals irrelevant to it).
101pub static SIGNALS: &[Signal] = &[
102    Signal {
103        id: "congrats",
104        audience: Audience::Human,
105        base: 0.05,
106        metric: metric_congrats,
107    },
108    Signal {
109        id: "review_backlog",
110        audience: Audience::Human,
111        base: 3.0,
112        metric: metric_review_backlog,
113    },
114    Signal {
115        id: "canon_pending",
116        audience: Audience::Human,
117        base: 3.0,
118        metric: metric_canon_pending,
119    },
120    Signal {
121        id: "verify_backlog",
122        audience: Audience::Human,
123        base: 0.25,
124        metric: metric_verify_backlog,
125    },
126    Signal {
127        id: "proof_review_backlog",
128        audience: Audience::Human,
129        base: 3.0,
130        metric: metric_proof_review_backlog,
131    },
132    Signal {
133        id: "score_slump",
134        audience: Audience::Human,
135        base: 0.05,
136        metric: metric_score_slump,
137    },
138    Signal {
139        id: "authoring_debt",
140        audience: Audience::Agent,
141        base: 3.0,
142        metric: metric_authoring_debt,
143    },
144];
145
146fn metric_authoring_debt(i: &EngineInputs) -> f64 {
147    i.edits_since_annotation as f64
148}
149
150fn metric_review_backlog(i: &EngineInputs) -> f64 {
151    i.unreviewed_intents as f64
152}
153
154fn metric_proof_review_backlog(i: &EngineInputs) -> f64 {
155    i.proofs_awaiting_review as f64
156}
157
158fn metric_canon_pending(i: &EngineInputs) -> f64 {
159    // Paid signal: silent unless signed in (no upsell spam).
160    if i.signed_in {
161        i.canon_pending as f64
162    } else {
163        0.0
164    }
165}
166
167fn metric_verify_backlog(i: &EngineInputs) -> f64 {
168    // Fraction of the verifiable surface still unverified.
169    if i.metrics.verifiable == 0 {
170        0.0
171    } else {
172        i.metrics.unverified as f64 / i.metrics.verifiable as f64
173    }
174}
175
176fn metric_score_slump(i: &EngineInputs) -> f64 {
177    // Relative drop from the edit-window baseline; 0 if no baseline or no drop.
178    match i.prior_score {
179        Some(prior) if prior > 0.0 => {
180            let drop = prior - i.metrics.visible_score;
181            if drop > 0.0 {
182                drop / prior
183            } else {
184                0.0
185            }
186        }
187        _ => 0.0,
188    }
189}
190
191fn metric_congrats(i: &EngineInputs) -> f64 {
192    // A tier-up is an instant win — always congratulate (when nudges are on);
193    // INFINITY pressure clears any non-zero factor's threshold but is still
194    // silenced by factor 0.0 (off). Otherwise congratulate on a score gain.
195    if i.tier_increased {
196        return f64::INFINITY;
197    }
198    match i.prior_score {
199        Some(prior) => (i.metrics.visible_score - prior).max(0.0),
200        None => 0.0,
201    }
202}
203
204/// The scorer's verdict: the fired signals in priority order, split by
205/// audience, plus the recommended lead (the highest-priority fired human
206/// signal — the engine kicks its low-friction/background action).
207#[derive(Debug, Clone, PartialEq, Default)]
208pub struct Decision {
209    /// Fired Human-audience signals, highest priority first.
210    pub human: Vec<Fired>,
211    /// Fired Agent-audience signals.
212    pub agent: Vec<Fired>,
213}
214
215impl Decision {
216    /// The recommended lead signal id — the top fired human signal, or `None`
217    /// when nothing human-facing fired.
218    pub fn recommended(&self) -> Option<&'static str> {
219        self.human.first().map(|f| f.id)
220    }
221
222    /// True when nothing fired for either audience.
223    pub fn is_silent(&self) -> bool {
224        self.human.is_empty() && self.agent.is_empty()
225    }
226}
227
228#[aristo::intent(
229    "The surfaced order follows the static SIGNALS priority order, never the \
230     pressures. A count-pressure and a fraction-pressure are incommensurable, \
231     so sorting by pressure would let a noisy low-priority signal jump ahead \
232     of a review the user needs to see first.",
233    verify = "test",
234    id = "nudge_scorer_off_silences_and_order_is_static_priority"
235)]
236pub fn score(inputs: &EngineInputs, aggressiveness: Aggressiveness) -> Decision {
237    let factor = aggressiveness.factor();
238    let mut decision = Decision::default();
239    for signal in SIGNALS {
240        let metric = (signal.metric)(inputs);
241        let pressure = metric / signal.base;
242        if pressure * factor >= 1.0 {
243            let fired = Fired {
244                id: signal.id,
245                audience: signal.audience,
246                pressure,
247                metric,
248                base: signal.base,
249            };
250            match signal.audience {
251                Audience::Human => decision.human.push(fired),
252                Audience::Agent => decision.agent.push(fired),
253            }
254        }
255    }
256    decision
257}
258
259#[aristo::intent(
260    "Scoring the authoring-debt signal reads only the edit counter, never \
261     the index-derived metrics. The hook that drives it fires on every edit, \
262     so it must not walk the source tree each time. It scores that single \
263     signal straight from the counter and applies the same fire threshold as \
264     the general scorer, so the two cannot drift apart.",
265    verify = "test",
266    id = "score_authoring_debt_needs_no_index_walk"
267)]
268/// Score ONLY the `authoring_debt` signal, from the edit counter alone — no
269/// `EngineInputs`/`Metrics` and so no source walk (PostToolUse fires on every
270/// edit and must stay cheap). Returns the [`Fired`] (carrying metric + base
271/// for the throttle) when it fires.
272pub fn score_authoring_debt(
273    edits_since_annotation: usize,
274    aggressiveness: Aggressiveness,
275) -> Option<Fired> {
276    let signal = SIGNALS.iter().find(|s| s.id == "authoring_debt")?;
277    let metric = edits_since_annotation as f64;
278    let pressure = metric / signal.base;
279    if pressure * aggressiveness.factor() >= 1.0 {
280        Some(Fired {
281            id: signal.id,
282            audience: signal.audience,
283            pressure,
284            metric,
285            base: signal.base,
286        })
287    } else {
288        None
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use aristo_core::badge::Tier;
296    use aristo_core::metrics::{Metrics, METRICS_SCHEMA_VERSION};
297
298    fn metrics(verifiable: usize, unverified: usize, score: f64, tier: Tier) -> Metrics {
299        Metrics {
300            schema_version: METRICS_SCHEMA_VERSION,
301            intents: verifiable,
302            assumes: 0,
303            verifiable,
304            verified_clean: verifiable - unverified,
305            unverified,
306            verification_rate: if verifiable == 0 {
307                0.0
308            } else {
309                (verifiable - unverified) as f64 / verifiable as f64
310            },
311            tier,
312            visible_score: score,
313            by_verify_level: Default::default(),
314            status_distribution: Default::default(),
315        }
316    }
317
318    fn inputs() -> EngineInputs {
319        EngineInputs {
320            metrics: metrics(0, 0, 0.0, Tier::Aspirant),
321            edits_since_annotation: 0,
322            unreviewed_intents: 0,
323            proofs_awaiting_review: 0,
324            canon_pending: 0,
325            prior_score: None,
326            tier_increased: false,
327            signed_in: false,
328        }
329    }
330
331    #[test]
332    fn off_is_a_hard_silence_even_under_extreme_pressure() {
333        let mut i = inputs();
334        i.unreviewed_intents = 1000;
335        i.edits_since_annotation = 1000;
336        i.tier_increased = true;
337        let d = score(&i, Aggressiveness::Off);
338        assert!(d.is_silent(), "off must silence everything: {d:?}");
339    }
340
341    #[test]
342    fn nothing_fires_at_zero_pressure() {
343        let d = score(&inputs(), Aggressiveness::High);
344        assert!(d.is_silent());
345    }
346
347    #[test]
348    fn review_backlog_fires_at_base_under_medium() {
349        let mut i = inputs();
350        i.unreviewed_intents = 3; // pressure 3/3 = 1.0; *1.0 medium = 1.0 >= 1
351        let d = score(&i, Aggressiveness::Medium);
352        assert!(d.human.iter().any(|f| f.id == "review_backlog"));
353    }
354
355    #[test]
356    fn low_aggressiveness_needs_more_pressure_than_medium() {
357        let mut i = inputs();
358        i.unreviewed_intents = 3; // 1.0 pressure
359                                  // low factor 0.6 → 0.6 < 1 → does not fire
360        assert!(score(&i, Aggressiveness::Low)
361            .human
362            .iter()
363            .all(|f| f.id != "review_backlog"));
364        // medium factor 1.0 → fires
365        assert!(score(&i, Aggressiveness::Medium)
366            .human
367            .iter()
368            .any(|f| f.id == "review_backlog"));
369    }
370
371    #[test]
372    fn canon_pending_is_silent_until_signed_in() {
373        let mut i = inputs();
374        i.canon_pending = 10;
375        assert!(score(&i, Aggressiveness::High)
376            .human
377            .iter()
378            .all(|f| f.id != "canon_pending"));
379        i.signed_in = true;
380        assert!(score(&i, Aggressiveness::High)
381            .human
382            .iter()
383            .any(|f| f.id == "canon_pending"));
384    }
385
386    #[test]
387    fn tier_up_always_congratulates_when_on() {
388        let mut i = inputs();
389        i.tier_increased = true;
390        let d = score(&i, Aggressiveness::Low);
391        assert_eq!(
392            d.recommended(),
393            Some("congrats"),
394            "tier-up leads as the banner"
395        );
396    }
397
398    #[test]
399    fn verify_backlog_uses_unverified_fraction() {
400        let mut i = inputs();
401        // 1 of 1 unverified → fraction 1.0; /0.25 = 4.0 pressure → fires easily.
402        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant);
403        assert!(score(&i, Aggressiveness::Low)
404            .human
405            .iter()
406            .any(|f| f.id == "verify_backlog"));
407        // 0 unverified → no pressure.
408        i.metrics = metrics(4, 0, 0.5, Tier::Adept);
409        assert!(score(&i, Aggressiveness::High)
410            .human
411            .iter()
412            .all(|f| f.id != "verify_backlog"));
413    }
414
415    #[test]
416    fn score_slump_fires_on_a_relative_drop_from_baseline() {
417        let mut i = inputs();
418        i.prior_score = Some(0.50);
419        i.metrics = metrics(0, 0, 0.40, Tier::Adept); // drop 0.10 / 0.50 = 0.20
420                                                      // 0.20 / 0.05 = 4.0 pressure → fires.
421        assert!(score(&i, Aggressiveness::Low)
422            .human
423            .iter()
424            .any(|f| f.id == "score_slump"));
425        // No drop → silent.
426        i.metrics = metrics(0, 0, 0.60, Tier::Adept);
427        assert!(score(&i, Aggressiveness::High)
428            .human
429            .iter()
430            .all(|f| f.id != "score_slump"));
431    }
432
433    #[test]
434    fn authoring_debt_is_agent_audience() {
435        let mut i = inputs();
436        i.edits_since_annotation = 3;
437        let d = score(&i, Aggressiveness::Medium);
438        assert!(d.agent.iter().any(|f| f.id == "authoring_debt"));
439        assert!(d.human.iter().all(|f| f.id != "authoring_debt"));
440    }
441
442    #[test]
443    fn score_authoring_debt_agrees_with_full_score() {
444        // The counter-only fast path must match what the full scorer decides
445        // for authoring_debt at the same edit count + aggressiveness.
446        for edits in [0usize, 2, 3, 10] {
447            for agg in [
448                Aggressiveness::Off,
449                Aggressiveness::Low,
450                Aggressiveness::Medium,
451                Aggressiveness::High,
452            ] {
453                let mut i = inputs();
454                i.edits_since_annotation = edits;
455                let full = score(&i, agg)
456                    .agent
457                    .iter()
458                    .any(|f| f.id == "authoring_debt");
459                let fast = score_authoring_debt(edits, agg).is_some();
460                assert_eq!(full, fast, "edits={edits} agg={agg:?}");
461            }
462        }
463        // Off is a hard silence even at a huge count.
464        assert!(score_authoring_debt(1000, Aggressiveness::Off).is_none());
465    }
466
467    #[test]
468    fn human_signals_keep_static_priority_order() {
469        // Fire review, canon, and verify together; the surfaced order must be
470        // review > canon > verify regardless of their pressures.
471        let mut i = inputs();
472        i.signed_in = true;
473        i.unreviewed_intents = 100; // huge
474        i.canon_pending = 3; // exactly base
475        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant); // verify fraction 1.0
476        let d = score(&i, Aggressiveness::Medium);
477        let order: Vec<&str> = d.human.iter().map(|f| f.id).collect();
478        let review = order.iter().position(|x| *x == "review_backlog").unwrap();
479        let canon = order.iter().position(|x| *x == "canon_pending").unwrap();
480        let verify = order.iter().position(|x| *x == "verify_backlog").unwrap();
481        assert!(
482            review < canon && canon < verify,
483            "priority order: {order:?}"
484        );
485        assert_eq!(d.recommended(), Some("review_backlog"));
486    }
487}