aristo-cli 0.2.4

Aristo CLI binary (the `aristo` command).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! The nudge/progress engine's DECIDE leg (Phase 18 #9, S0c).
//!
//! A plug-and-play scorer over a registry of `Signal`s. Each signal turns
//! some engine input into a raw "pressure" `m`; the scorer normalizes it by
//! the signal's base `b` and scales by the configured aggressiveness factor
//! `f`, firing iff `pressure * f >= 1`. Adding a nudge is one `SIGNALS`
//! entry — the scorer, ordering, and (later) throttle/render are generic.
//!
//! Two design invariants this module enforces:
//!
//! - **`aggressiveness = off` is a hard silence.** `factor()` is `0.0`, so
//!   `pressure * 0.0 >= 1` is false for every signal at every pressure —
//!   nothing can fire (cf. the `aggressiveness_off_is_hard_silence` intent
//!   in `aristo-core::config`).
//! - **Ordering is fixed priority, not pressure.** Pressures of counts and
//!   fractions are incommensurable, so the surfaced order is the static
//!   `SIGNALS` order (congrats banner, then review > canon > verify >
//!   proof-review > slump); the Agent-audience signal is a separate surface.

use aristo_core::config::Aggressiveness;
use aristo_core::metrics::Metrics;

pub mod intents;
pub mod state;
pub mod throttle;

/// Who a fired signal is addressed to. The two are surfaced through
/// different channels (D4/D10): the agent gets an inline `<system-reminder>`;
/// the human gets the consolidated review prompt the agent pops.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Audience {
    /// Nudge the coding agent directly (e.g. "you haven't annotated lately").
    Agent,
    /// Surface to the human via the agent-run review prompt.
    Human,
}

/// Everything the signal metrics read. The cli's union function (S0c+)
/// populates the runtime fields it can't see from the index alone (reviewed
/// map, queue/canon counts, the edit-window baseline); the index-derived
/// part is [`Metrics`].
#[derive(Debug, Clone)]
pub struct EngineInputs {
    /// Index-derived metrics (counts, backlog, tier, score).
    pub metrics: Metrics,
    /// Source edits since the last annotation was added (PostToolUse counter).
    pub edits_since_annotation: usize,
    /// Authored intents not yet marked reviewed (new + backlog).
    pub unreviewed_intents: usize,
    /// Proofs carrying a verdict not yet in the proof-reviewed map.
    pub proofs_awaiting_review: usize,
    /// Pending canon matches + suggestions (only meaningful when signed in).
    pub canon_pending: usize,
    /// The visible score at the edit-window baseline, if one was captured.
    pub prior_score: Option<f64>,
    /// Whether the tier rose since the edit-window baseline (instant win).
    pub tier_increased: bool,
    /// Whether a repo-scoped token is present (gates the paid canon signal).
    pub signed_in: bool,
}

/// A registered nudge. `metric` extracts the raw pressure numerator `m` from
/// the inputs; `base` is the denominator `b`; the signal fires when
/// `(m / b) * factor >= 1`.
pub struct Signal {
    /// Stable identifier (also the throttle-map key).
    pub id: &'static str,
    /// Who the nudge is for.
    pub audience: Audience,
    /// The pressure denominator `b` (> 0).
    pub base: f64,
    /// Extract the raw pressure numerator `m` from the inputs.
    pub metric: fn(&EngineInputs) -> f64,
}

impl Signal {
    /// Normalized pressure `m / b` (independent of aggressiveness).
    pub fn pressure(&self, inputs: &EngineInputs) -> f64 {
        (self.metric)(inputs) / self.base
    }
}

/// A signal that fired, with the pressure that fired it and the raw
/// `metric`/`base` (so the throttle can apply a material-increase re-arm
/// against the same numbers).
#[derive(Debug, Clone, PartialEq)]
pub struct Fired {
    pub id: &'static str,
    pub audience: Audience,
    pub pressure: f64,
    /// The raw pressure numerator `m` (`pressure == metric / base`).
    pub metric: f64,
    /// The signal's pressure denominator `b`.
    pub base: f64,
}

/// The registry. Order IS priority for the human surface: the congrats
/// banner leads, then review > canon > verify > proof-review > slump. The
/// Agent-audience `authoring_debt` sits last (separate channel, order among
/// human signals irrelevant to it).
pub static SIGNALS: &[Signal] = &[
    Signal {
        id: "congrats",
        audience: Audience::Human,
        base: 0.05,
        metric: metric_congrats,
    },
    Signal {
        id: "review_backlog",
        audience: Audience::Human,
        base: 3.0,
        metric: metric_review_backlog,
    },
    Signal {
        id: "canon_pending",
        audience: Audience::Human,
        base: 3.0,
        metric: metric_canon_pending,
    },
    Signal {
        id: "verify_backlog",
        audience: Audience::Human,
        base: 0.25,
        metric: metric_verify_backlog,
    },
    Signal {
        id: "proof_review_backlog",
        audience: Audience::Human,
        base: 3.0,
        metric: metric_proof_review_backlog,
    },
    Signal {
        id: "score_slump",
        audience: Audience::Human,
        base: 0.05,
        metric: metric_score_slump,
    },
    Signal {
        id: "authoring_debt",
        audience: Audience::Agent,
        base: 3.0,
        metric: metric_authoring_debt,
    },
];

fn metric_authoring_debt(i: &EngineInputs) -> f64 {
    i.edits_since_annotation as f64
}

fn metric_review_backlog(i: &EngineInputs) -> f64 {
    i.unreviewed_intents as f64
}

fn metric_proof_review_backlog(i: &EngineInputs) -> f64 {
    i.proofs_awaiting_review as f64
}

fn metric_canon_pending(i: &EngineInputs) -> f64 {
    // Paid signal: silent unless signed in (no upsell spam).
    if i.signed_in {
        i.canon_pending as f64
    } else {
        0.0
    }
}

fn metric_verify_backlog(i: &EngineInputs) -> f64 {
    // Fraction of the verifiable surface still unverified.
    if i.metrics.verifiable == 0 {
        0.0
    } else {
        i.metrics.unverified as f64 / i.metrics.verifiable as f64
    }
}

fn metric_score_slump(i: &EngineInputs) -> f64 {
    // Relative drop from the edit-window baseline; 0 if no baseline or no drop.
    match i.prior_score {
        Some(prior) if prior > 0.0 => {
            let drop = prior - i.metrics.visible_score;
            if drop > 0.0 {
                drop / prior
            } else {
                0.0
            }
        }
        _ => 0.0,
    }
}

fn metric_congrats(i: &EngineInputs) -> f64 {
    // A tier-up is an instant win — always congratulate (when nudges are on);
    // INFINITY pressure clears any non-zero factor's threshold but is still
    // silenced by factor 0.0 (off). Otherwise congratulate on a score gain.
    if i.tier_increased {
        return f64::INFINITY;
    }
    match i.prior_score {
        Some(prior) => (i.metrics.visible_score - prior).max(0.0),
        None => 0.0,
    }
}

/// The scorer's verdict: the fired signals in priority order, split by
/// audience, plus the recommended lead (the highest-priority fired human
/// signal — the engine kicks its low-friction/background action).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Decision {
    /// Fired Human-audience signals, highest priority first.
    pub human: Vec<Fired>,
    /// Fired Agent-audience signals.
    pub agent: Vec<Fired>,
}

impl Decision {
    /// The recommended lead signal id — the top fired human signal, or `None`
    /// when nothing human-facing fired.
    pub fn recommended(&self) -> Option<&'static str> {
        self.human.first().map(|f| f.id)
    }

    /// True when nothing fired for either audience.
    pub fn is_silent(&self) -> bool {
        self.human.is_empty() && self.agent.is_empty()
    }
}

#[aristo::intent(
    "Two invariants the scorer must preserve. First, `aggressiveness = off` \
     is an absolute silence: its factor is 0.0 and the fire test is \
     `pressure * factor >= 1`, so no signal fires at any pressure — even an \
     infinite one. Second, the surfaced order is the static \
     SIGNALS priority order, NOT the pressures: a count-pressure and a \
     fraction-pressure are incommensurable, so sorting by pressure would let \
     a noisy low-priority signal jump the queue ahead of a review the user \
     actually needs to see first.",
    verify = "test",
    id = "nudge_scorer_off_silences_and_order_is_static_priority"
)]
pub fn score(inputs: &EngineInputs, aggressiveness: Aggressiveness) -> Decision {
    let factor = aggressiveness.factor();
    let mut decision = Decision::default();
    for signal in SIGNALS {
        let metric = (signal.metric)(inputs);
        let pressure = metric / signal.base;
        if pressure * factor >= 1.0 {
            let fired = Fired {
                id: signal.id,
                audience: signal.audience,
                pressure,
                metric,
                base: signal.base,
            };
            match signal.audience {
                Audience::Human => decision.human.push(fired),
                Audience::Agent => decision.agent.push(fired),
            }
        }
    }
    decision
}

#[aristo::intent(
    "Scoring the authoring-debt (agent) signal needs ONLY the edit counter — \
     never the index-derived Metrics. The PostToolUse hook that drives it \
     fires on every edit, so it must not walk the source tree per edit; this \
     scores the one signal straight from the counter, reusing the registry's \
     base and the identical `pressure * factor >= 1` fire rule so it can't \
     drift from `score`.",
    verify = "test",
    id = "score_authoring_debt_needs_no_index_walk"
)]
/// Score ONLY the `authoring_debt` signal, from the edit counter alone — no
/// `EngineInputs`/`Metrics` and so no source walk (PostToolUse fires on every
/// edit and must stay cheap). Returns the [`Fired`] (carrying metric + base
/// for the throttle) when it fires.
pub fn score_authoring_debt(
    edits_since_annotation: usize,
    aggressiveness: Aggressiveness,
) -> Option<Fired> {
    let signal = SIGNALS.iter().find(|s| s.id == "authoring_debt")?;
    let metric = edits_since_annotation as f64;
    let pressure = metric / signal.base;
    if pressure * aggressiveness.factor() >= 1.0 {
        Some(Fired {
            id: signal.id,
            audience: signal.audience,
            pressure,
            metric,
            base: signal.base,
        })
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use aristo_core::badge::Tier;
    use aristo_core::metrics::{Metrics, METRICS_SCHEMA_VERSION};

    fn metrics(verifiable: usize, unverified: usize, score: f64, tier: Tier) -> Metrics {
        Metrics {
            schema_version: METRICS_SCHEMA_VERSION,
            intents: verifiable,
            assumes: 0,
            verifiable,
            verified_clean: verifiable - unverified,
            unverified,
            verification_rate: if verifiable == 0 {
                0.0
            } else {
                (verifiable - unverified) as f64 / verifiable as f64
            },
            tier,
            visible_score: score,
        }
    }

    fn inputs() -> EngineInputs {
        EngineInputs {
            metrics: metrics(0, 0, 0.0, Tier::Aspirant),
            edits_since_annotation: 0,
            unreviewed_intents: 0,
            proofs_awaiting_review: 0,
            canon_pending: 0,
            prior_score: None,
            tier_increased: false,
            signed_in: false,
        }
    }

    #[test]
    fn off_is_a_hard_silence_even_under_extreme_pressure() {
        let mut i = inputs();
        i.unreviewed_intents = 1000;
        i.edits_since_annotation = 1000;
        i.tier_increased = true;
        let d = score(&i, Aggressiveness::Off);
        assert!(d.is_silent(), "off must silence everything: {d:?}");
    }

    #[test]
    fn nothing_fires_at_zero_pressure() {
        let d = score(&inputs(), Aggressiveness::High);
        assert!(d.is_silent());
    }

    #[test]
    fn review_backlog_fires_at_base_under_medium() {
        let mut i = inputs();
        i.unreviewed_intents = 3; // pressure 3/3 = 1.0; *1.0 medium = 1.0 >= 1
        let d = score(&i, Aggressiveness::Medium);
        assert!(d.human.iter().any(|f| f.id == "review_backlog"));
    }

    #[test]
    fn low_aggressiveness_needs_more_pressure_than_medium() {
        let mut i = inputs();
        i.unreviewed_intents = 3; // 1.0 pressure
                                  // low factor 0.6 → 0.6 < 1 → does not fire
        assert!(score(&i, Aggressiveness::Low)
            .human
            .iter()
            .all(|f| f.id != "review_backlog"));
        // medium factor 1.0 → fires
        assert!(score(&i, Aggressiveness::Medium)
            .human
            .iter()
            .any(|f| f.id == "review_backlog"));
    }

    #[test]
    fn canon_pending_is_silent_until_signed_in() {
        let mut i = inputs();
        i.canon_pending = 10;
        assert!(score(&i, Aggressiveness::High)
            .human
            .iter()
            .all(|f| f.id != "canon_pending"));
        i.signed_in = true;
        assert!(score(&i, Aggressiveness::High)
            .human
            .iter()
            .any(|f| f.id == "canon_pending"));
    }

    #[test]
    fn tier_up_always_congratulates_when_on() {
        let mut i = inputs();
        i.tier_increased = true;
        let d = score(&i, Aggressiveness::Low);
        assert_eq!(
            d.recommended(),
            Some("congrats"),
            "tier-up leads as the banner"
        );
    }

    #[test]
    fn verify_backlog_uses_unverified_fraction() {
        let mut i = inputs();
        // 1 of 1 unverified → fraction 1.0; /0.25 = 4.0 pressure → fires easily.
        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant);
        assert!(score(&i, Aggressiveness::Low)
            .human
            .iter()
            .any(|f| f.id == "verify_backlog"));
        // 0 unverified → no pressure.
        i.metrics = metrics(4, 0, 0.5, Tier::Adept);
        assert!(score(&i, Aggressiveness::High)
            .human
            .iter()
            .all(|f| f.id != "verify_backlog"));
    }

    #[test]
    fn score_slump_fires_on_a_relative_drop_from_baseline() {
        let mut i = inputs();
        i.prior_score = Some(0.50);
        i.metrics = metrics(0, 0, 0.40, Tier::Adept); // drop 0.10 / 0.50 = 0.20
                                                      // 0.20 / 0.05 = 4.0 pressure → fires.
        assert!(score(&i, Aggressiveness::Low)
            .human
            .iter()
            .any(|f| f.id == "score_slump"));
        // No drop → silent.
        i.metrics = metrics(0, 0, 0.60, Tier::Adept);
        assert!(score(&i, Aggressiveness::High)
            .human
            .iter()
            .all(|f| f.id != "score_slump"));
    }

    #[test]
    fn authoring_debt_is_agent_audience() {
        let mut i = inputs();
        i.edits_since_annotation = 3;
        let d = score(&i, Aggressiveness::Medium);
        assert!(d.agent.iter().any(|f| f.id == "authoring_debt"));
        assert!(d.human.iter().all(|f| f.id != "authoring_debt"));
    }

    #[test]
    fn score_authoring_debt_agrees_with_full_score() {
        // The counter-only fast path must match what the full scorer decides
        // for authoring_debt at the same edit count + aggressiveness.
        for edits in [0usize, 2, 3, 10] {
            for agg in [
                Aggressiveness::Off,
                Aggressiveness::Low,
                Aggressiveness::Medium,
                Aggressiveness::High,
            ] {
                let mut i = inputs();
                i.edits_since_annotation = edits;
                let full = score(&i, agg)
                    .agent
                    .iter()
                    .any(|f| f.id == "authoring_debt");
                let fast = score_authoring_debt(edits, agg).is_some();
                assert_eq!(full, fast, "edits={edits} agg={agg:?}");
            }
        }
        // Off is a hard silence even at a huge count.
        assert!(score_authoring_debt(1000, Aggressiveness::Off).is_none());
    }

    #[test]
    fn human_signals_keep_static_priority_order() {
        // Fire review, canon, and verify together; the surfaced order must be
        // review > canon > verify regardless of their pressures.
        let mut i = inputs();
        i.signed_in = true;
        i.unreviewed_intents = 100; // huge
        i.canon_pending = 3; // exactly base
        i.metrics = metrics(1, 1, 0.0, Tier::Aspirant); // verify fraction 1.0
        let d = score(&i, Aggressiveness::Medium);
        let order: Vec<&str> = d.human.iter().map(|f| f.id).collect();
        let review = order.iter().position(|x| *x == "review_backlog").unwrap();
        let canon = order.iter().position(|x| *x == "canon_pending").unwrap();
        let verify = order.iter().position(|x| *x == "verify_backlog").unwrap();
        assert!(
            review < canon && canon < verify,
            "priority order: {order:?}"
        );
        assert_eq!(d.recommended(), Some("review_backlog"));
    }
}