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. 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 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; 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; assert!(score(&i, Aggressiveness::Low)
366 .human
367 .iter()
368 .all(|f| f.id != "review_backlog"));
369 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 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 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); assert!(score(&i, Aggressiveness::Low)
427 .human
428 .iter()
429 .any(|f| f.id == "score_slump"));
430 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 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 assert!(score_authoring_debt(1000, Aggressiveness::Off).is_none());
470 }
471
472 #[test]
473 fn human_signals_keep_static_priority_order() {
474 let mut i = inputs();
477 i.signed_in = true;
478 i.unreviewed_intents = 100; i.canon_pending = 3; i.metrics = metrics(1, 1, 0.0, Tier::Aspirant); 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}