1use aristo_core::config::Aggressiveness;
21use aristo_core::metrics::Metrics;
22
23pub mod intents;
24pub mod state;
25pub mod throttle;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Audience {
32 Agent,
34 Human,
36}
37
38#[derive(Debug, Clone)]
43pub struct EngineInputs {
44 pub metrics: Metrics,
46 pub edits_since_annotation: usize,
48 pub unreviewed_intents: usize,
50 pub proofs_awaiting_review: usize,
52 pub canon_pending: usize,
54 pub prior_score: Option<f64>,
56 pub tier_increased: bool,
58 pub signed_in: bool,
60}
61
62pub struct Signal {
66 pub id: &'static str,
68 pub audience: Audience,
70 pub base: f64,
72 pub metric: fn(&EngineInputs) -> f64,
74}
75
76impl Signal {
77 pub fn pressure(&self, inputs: &EngineInputs) -> f64 {
79 (self.metric)(inputs) / self.base
80 }
81}
82
83#[derive(Debug, Clone, PartialEq)]
87pub struct Fired {
88 pub id: &'static str,
89 pub audience: Audience,
90 pub pressure: f64,
91 pub metric: f64,
93 pub base: f64,
95}
96
97pub 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Default)]
208pub struct Decision {
209 pub human: Vec<Fired>,
211 pub agent: Vec<Fired>,
213}
214
215impl Decision {
216 pub fn recommended(&self) -> Option<&'static str> {
219 self.human.first().map(|f| f.id)
220 }
221
222 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)]
273pub 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; 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; assert!(score(&i, Aggressiveness::Low)
364 .human
365 .iter()
366 .all(|f| f.id != "review_backlog"));
367 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 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 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); assert!(score(&i, Aggressiveness::Low)
425 .human
426 .iter()
427 .any(|f| f.id == "score_slump"));
428 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 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 assert!(score_authoring_debt(1000, Aggressiveness::Off).is_none());
468 }
469
470 #[test]
471 fn human_signals_keep_static_priority_order() {
472 let mut i = inputs();
475 i.signed_in = true;
476 i.unreviewed_intents = 100; i.canon_pending = 3; i.metrics = metrics(1, 1, 0.0, Tier::Aspirant); 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}