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. 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            by_verify_level: Default::default(),
319            status_distribution: Default::default(),
320        }
321    }
322
323    fn inputs() -> EngineInputs {
324        EngineInputs {
325            metrics: metrics(0, 0, 0.0, Tier::Aspirant),
326            edits_since_annotation: 0,
327            unreviewed_intents: 0,
328            proofs_awaiting_review: 0,
329            canon_pending: 0,
330            prior_score: None,
331            tier_increased: false,
332            signed_in: false,
333        }
334    }
335
336    #[test]
337    fn off_is_a_hard_silence_even_under_extreme_pressure() {
338        let mut i = inputs();
339        i.unreviewed_intents = 1000;
340        i.edits_since_annotation = 1000;
341        i.tier_increased = true;
342        let d = score(&i, Aggressiveness::Off);
343        assert!(d.is_silent(), "off must silence everything: {d:?}");
344    }
345
346    #[test]
347    fn nothing_fires_at_zero_pressure() {
348        let d = score(&inputs(), Aggressiveness::High);
349        assert!(d.is_silent());
350    }
351
352    #[test]
353    fn review_backlog_fires_at_base_under_medium() {
354        let mut i = inputs();
355        i.unreviewed_intents = 3; // pressure 3/3 = 1.0; *1.0 medium = 1.0 >= 1
356        let d = score(&i, Aggressiveness::Medium);
357        assert!(d.human.iter().any(|f| f.id == "review_backlog"));
358    }
359
360    #[test]
361    fn low_aggressiveness_needs_more_pressure_than_medium() {
362        let mut i = inputs();
363        i.unreviewed_intents = 3; // 1.0 pressure
364                                  // low factor 0.6 → 0.6 < 1 → does not fire
365        assert!(score(&i, Aggressiveness::Low)
366            .human
367            .iter()
368            .all(|f| f.id != "review_backlog"));
369        // medium factor 1.0 → fires
370        assert!(score(&i, Aggressiveness::Medium)
371            .human
372            .iter()
373            .any(|f| f.id == "review_backlog"));
374    }
375
376    #[test]
377    fn canon_pending_is_silent_until_signed_in() {
378        let mut i = inputs();
379        i.canon_pending = 10;
380        assert!(score(&i, Aggressiveness::High)
381            .human
382            .iter()
383            .all(|f| f.id != "canon_pending"));
384        i.signed_in = true;
385        assert!(score(&i, Aggressiveness::High)
386            .human
387            .iter()
388            .any(|f| f.id == "canon_pending"));
389    }
390
391    #[test]
392    fn tier_up_always_congratulates_when_on() {
393        let mut i = inputs();
394        i.tier_increased = true;
395        let d = score(&i, Aggressiveness::Low);
396        assert_eq!(
397            d.recommended(),
398            Some("congrats"),
399            "tier-up leads as the banner"
400        );
401    }
402
403    #[test]
404    fn verify_backlog_uses_unverified_fraction() {
405        let mut i = inputs();
406        // 1 of 1 unverified → fraction 1.0; /0.25 = 4.0 pressure → fires easily.
407        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant);
408        assert!(score(&i, Aggressiveness::Low)
409            .human
410            .iter()
411            .any(|f| f.id == "verify_backlog"));
412        // 0 unverified → no pressure.
413        i.metrics = metrics(4, 0, 0.5, Tier::Adept);
414        assert!(score(&i, Aggressiveness::High)
415            .human
416            .iter()
417            .all(|f| f.id != "verify_backlog"));
418    }
419
420    #[test]
421    fn score_slump_fires_on_a_relative_drop_from_baseline() {
422        let mut i = inputs();
423        i.prior_score = Some(0.50);
424        i.metrics = metrics(0, 0, 0.40, Tier::Adept); // drop 0.10 / 0.50 = 0.20
425                                                      // 0.20 / 0.05 = 4.0 pressure → fires.
426        assert!(score(&i, Aggressiveness::Low)
427            .human
428            .iter()
429            .any(|f| f.id == "score_slump"));
430        // No drop → silent.
431        i.metrics = metrics(0, 0, 0.60, Tier::Adept);
432        assert!(score(&i, Aggressiveness::High)
433            .human
434            .iter()
435            .all(|f| f.id != "score_slump"));
436    }
437
438    #[test]
439    fn authoring_debt_is_agent_audience() {
440        let mut i = inputs();
441        i.edits_since_annotation = 3;
442        let d = score(&i, Aggressiveness::Medium);
443        assert!(d.agent.iter().any(|f| f.id == "authoring_debt"));
444        assert!(d.human.iter().all(|f| f.id != "authoring_debt"));
445    }
446
447    #[test]
448    fn score_authoring_debt_agrees_with_full_score() {
449        // The counter-only fast path must match what the full scorer decides
450        // for authoring_debt at the same edit count + aggressiveness.
451        for edits in [0usize, 2, 3, 10] {
452            for agg in [
453                Aggressiveness::Off,
454                Aggressiveness::Low,
455                Aggressiveness::Medium,
456                Aggressiveness::High,
457            ] {
458                let mut i = inputs();
459                i.edits_since_annotation = edits;
460                let full = score(&i, agg)
461                    .agent
462                    .iter()
463                    .any(|f| f.id == "authoring_debt");
464                let fast = score_authoring_debt(edits, agg).is_some();
465                assert_eq!(full, fast, "edits={edits} agg={agg:?}");
466            }
467        }
468        // Off is a hard silence even at a huge count.
469        assert!(score_authoring_debt(1000, Aggressiveness::Off).is_none());
470    }
471
472    #[test]
473    fn human_signals_keep_static_priority_order() {
474        // Fire review, canon, and verify together; the surfaced order must be
475        // review > canon > verify regardless of their pressures.
476        let mut i = inputs();
477        i.signed_in = true;
478        i.unreviewed_intents = 100; // huge
479        i.canon_pending = 3; // exactly base
480        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant); // verify fraction 1.0
481        let d = score(&i, Aggressiveness::Medium);
482        let order: Vec<&str> = d.human.iter().map(|f| f.id).collect();
483        let review = order.iter().position(|x| *x == "review_backlog").unwrap();
484        let canon = order.iter().position(|x| *x == "canon_pending").unwrap();
485        let verify = order.iter().position(|x| *x == "verify_backlog").unwrap();
486        assert!(
487            review < canon && canon < verify,
488            "priority order: {order:?}"
489        );
490        assert_eq!(d.recommended(), Some("review_backlog"));
491    }
492}