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    "Two invariants the scorer must preserve. First, `aggressiveness = off` \
230     is an absolute silence: its factor is 0.0 and the fire test is \
231     `pressure * factor >= 1`, so no signal fires at any pressure — even an \
232     infinite one (a tier-up). Second, the surfaced order is the static \
233     SIGNALS priority order, NOT the pressures: a count-pressure and a \
234     fraction-pressure are incommensurable, so sorting by pressure would let \
235     a noisy low-priority signal jump the queue ahead of a review the user \
236     actually needs to see first.",
237    verify = "test",
238    id = "nudge_scorer_off_silences_and_order_is_static_priority"
239)]
240pub fn score(inputs: &EngineInputs, aggressiveness: Aggressiveness) -> Decision {
241    let factor = aggressiveness.factor();
242    let mut decision = Decision::default();
243    for signal in SIGNALS {
244        let metric = (signal.metric)(inputs);
245        let pressure = metric / signal.base;
246        if pressure * factor >= 1.0 {
247            let fired = Fired {
248                id: signal.id,
249                audience: signal.audience,
250                pressure,
251                metric,
252                base: signal.base,
253            };
254            match signal.audience {
255                Audience::Human => decision.human.push(fired),
256                Audience::Agent => decision.agent.push(fired),
257            }
258        }
259    }
260    decision
261}
262
263#[aristo::intent(
264    "Scoring the authoring-debt (agent) signal needs ONLY the edit counter — \
265     never the index-derived Metrics. The PostToolUse hook that drives it \
266     fires on every edit, so it must not walk the source tree per edit; this \
267     scores the one signal straight from the counter, reusing the registry's \
268     base and the identical `pressure * factor >= 1` fire rule so it can't \
269     drift from `score`.",
270    verify = "test",
271    id = "score_authoring_debt_needs_no_index_walk"
272)]
273/// Score ONLY the `authoring_debt` signal, from the edit counter alone — no
274/// `EngineInputs`/`Metrics` and so no source walk (PostToolUse fires on every
275/// edit and must stay cheap). Returns the [`Fired`] (carrying metric + base
276/// for the throttle) when it fires.
277pub fn score_authoring_debt(
278    edits_since_annotation: usize,
279    aggressiveness: Aggressiveness,
280) -> Option<Fired> {
281    let signal = SIGNALS.iter().find(|s| s.id == "authoring_debt")?;
282    let metric = edits_since_annotation as f64;
283    let pressure = metric / signal.base;
284    if pressure * aggressiveness.factor() >= 1.0 {
285        Some(Fired {
286            id: signal.id,
287            audience: signal.audience,
288            pressure,
289            metric,
290            base: signal.base,
291        })
292    } else {
293        None
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use aristo_core::badge::Tier;
301    use aristo_core::metrics::{Metrics, METRICS_SCHEMA_VERSION};
302
303    fn metrics(verifiable: usize, unverified: usize, score: f64, tier: Tier) -> Metrics {
304        Metrics {
305            schema_version: METRICS_SCHEMA_VERSION,
306            intents: verifiable,
307            assumes: 0,
308            verifiable,
309            verified_clean: verifiable - unverified,
310            unverified,
311            verification_rate: if verifiable == 0 {
312                0.0
313            } else {
314                (verifiable - unverified) as f64 / verifiable as f64
315            },
316            tier,
317            visible_score: score,
318        }
319    }
320
321    fn inputs() -> EngineInputs {
322        EngineInputs {
323            metrics: metrics(0, 0, 0.0, Tier::Aspirant),
324            edits_since_annotation: 0,
325            unreviewed_intents: 0,
326            proofs_awaiting_review: 0,
327            canon_pending: 0,
328            prior_score: None,
329            tier_increased: false,
330            signed_in: false,
331        }
332    }
333
334    #[test]
335    fn off_is_a_hard_silence_even_under_extreme_pressure() {
336        let mut i = inputs();
337        i.unreviewed_intents = 1000;
338        i.edits_since_annotation = 1000;
339        i.tier_increased = true;
340        let d = score(&i, Aggressiveness::Off);
341        assert!(d.is_silent(), "off must silence everything: {d:?}");
342    }
343
344    #[test]
345    fn nothing_fires_at_zero_pressure() {
346        let d = score(&inputs(), Aggressiveness::High);
347        assert!(d.is_silent());
348    }
349
350    #[test]
351    fn review_backlog_fires_at_base_under_medium() {
352        let mut i = inputs();
353        i.unreviewed_intents = 3; // pressure 3/3 = 1.0; *1.0 medium = 1.0 >= 1
354        let d = score(&i, Aggressiveness::Medium);
355        assert!(d.human.iter().any(|f| f.id == "review_backlog"));
356    }
357
358    #[test]
359    fn low_aggressiveness_needs_more_pressure_than_medium() {
360        let mut i = inputs();
361        i.unreviewed_intents = 3; // 1.0 pressure
362                                  // low factor 0.6 → 0.6 < 1 → does not fire
363        assert!(score(&i, Aggressiveness::Low)
364            .human
365            .iter()
366            .all(|f| f.id != "review_backlog"));
367        // medium factor 1.0 → fires
368        assert!(score(&i, Aggressiveness::Medium)
369            .human
370            .iter()
371            .any(|f| f.id == "review_backlog"));
372    }
373
374    #[test]
375    fn canon_pending_is_silent_until_signed_in() {
376        let mut i = inputs();
377        i.canon_pending = 10;
378        assert!(score(&i, Aggressiveness::High)
379            .human
380            .iter()
381            .all(|f| f.id != "canon_pending"));
382        i.signed_in = true;
383        assert!(score(&i, Aggressiveness::High)
384            .human
385            .iter()
386            .any(|f| f.id == "canon_pending"));
387    }
388
389    #[test]
390    fn tier_up_always_congratulates_when_on() {
391        let mut i = inputs();
392        i.tier_increased = true;
393        let d = score(&i, Aggressiveness::Low);
394        assert_eq!(
395            d.recommended(),
396            Some("congrats"),
397            "tier-up leads as the banner"
398        );
399    }
400
401    #[test]
402    fn verify_backlog_uses_unverified_fraction() {
403        let mut i = inputs();
404        // 1 of 1 unverified → fraction 1.0; /0.25 = 4.0 pressure → fires easily.
405        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant);
406        assert!(score(&i, Aggressiveness::Low)
407            .human
408            .iter()
409            .any(|f| f.id == "verify_backlog"));
410        // 0 unverified → no pressure.
411        i.metrics = metrics(4, 0, 0.5, Tier::Adept);
412        assert!(score(&i, Aggressiveness::High)
413            .human
414            .iter()
415            .all(|f| f.id != "verify_backlog"));
416    }
417
418    #[test]
419    fn score_slump_fires_on_a_relative_drop_from_baseline() {
420        let mut i = inputs();
421        i.prior_score = Some(0.50);
422        i.metrics = metrics(0, 0, 0.40, Tier::Adept); // drop 0.10 / 0.50 = 0.20
423                                                      // 0.20 / 0.05 = 4.0 pressure → fires.
424        assert!(score(&i, Aggressiveness::Low)
425            .human
426            .iter()
427            .any(|f| f.id == "score_slump"));
428        // No drop → silent.
429        i.metrics = metrics(0, 0, 0.60, Tier::Adept);
430        assert!(score(&i, Aggressiveness::High)
431            .human
432            .iter()
433            .all(|f| f.id != "score_slump"));
434    }
435
436    #[test]
437    fn authoring_debt_is_agent_audience() {
438        let mut i = inputs();
439        i.edits_since_annotation = 3;
440        let d = score(&i, Aggressiveness::Medium);
441        assert!(d.agent.iter().any(|f| f.id == "authoring_debt"));
442        assert!(d.human.iter().all(|f| f.id != "authoring_debt"));
443    }
444
445    #[test]
446    fn score_authoring_debt_agrees_with_full_score() {
447        // The counter-only fast path must match what the full scorer decides
448        // for authoring_debt at the same edit count + aggressiveness.
449        for edits in [0usize, 2, 3, 10] {
450            for agg in [
451                Aggressiveness::Off,
452                Aggressiveness::Low,
453                Aggressiveness::Medium,
454                Aggressiveness::High,
455            ] {
456                let mut i = inputs();
457                i.edits_since_annotation = edits;
458                let full = score(&i, agg)
459                    .agent
460                    .iter()
461                    .any(|f| f.id == "authoring_debt");
462                let fast = score_authoring_debt(edits, agg).is_some();
463                assert_eq!(full, fast, "edits={edits} agg={agg:?}");
464            }
465        }
466        // Off is a hard silence even at a huge count.
467        assert!(score_authoring_debt(1000, Aggressiveness::Off).is_none());
468    }
469
470    #[test]
471    fn human_signals_keep_static_priority_order() {
472        // Fire review, canon, and verify together; the surfaced order must be
473        // review > canon > verify regardless of their pressures.
474        let mut i = inputs();
475        i.signed_in = true;
476        i.unreviewed_intents = 100; // huge
477        i.canon_pending = 3; // exactly base
478        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant); // verify fraction 1.0
479        let d = score(&i, Aggressiveness::Medium);
480        let order: Vec<&str> = d.human.iter().map(|f| f.id).collect();
481        let review = order.iter().position(|x| *x == "review_backlog").unwrap();
482        let canon = order.iter().position(|x| *x == "canon_pending").unwrap();
483        let verify = order.iter().position(|x| *x == "verify_backlog").unwrap();
484        assert!(
485            review < canon && canon < verify,
486            "priority order: {order:?}"
487        );
488        assert_eq!(d.recommended(), Some("review_backlog"));
489    }
490}