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