use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreReason {
pub label: String,
pub detail: Option<String>,
pub delta: i32,
}
impl ScoreReason {
pub fn new(label: impl Into<String>, delta: i32) -> Self {
Self {
label: label.into(),
detail: None,
delta,
}
}
pub fn with_detail(label: impl Into<String>, delta: i32, detail: impl Into<String>) -> Self {
Self {
label: label.into(),
detail: Some(detail.into()),
delta,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Score {
value: u8,
reasons: Vec<ScoreReason>,
}
impl Score {
pub fn zero() -> Self {
Self {
value: 0,
reasons: Vec::new(),
}
}
pub fn saturating_new(value: i32, reasons: Vec<ScoreReason>) -> Self {
let clamped = value.clamp(0, 100) as u8;
Self {
value: clamped,
reasons,
}
}
pub fn from_reasons(reasons: Vec<ScoreReason>) -> Self {
let raw: i32 = reasons.iter().map(|r| r.delta).sum();
Self::saturating_new(raw, reasons)
}
pub fn value(&self) -> u8 {
self.value
}
pub fn reasons(&self) -> &[ScoreReason] {
&self.reasons
}
pub fn into_reasons(self) -> Vec<ScoreReason> {
self.reasons
}
}
impl std::ops::Add for Score {
type Output = Score;
fn add(self, other: Score) -> Score {
let mut reasons = self.reasons;
reasons.extend(other.reasons);
let raw = self.value as i32 + other.value as i32;
Score::saturating_new(raw, reasons)
}
}
pub trait Scorer<Input: ?Sized> {
fn score(&self, input: &Input) -> Score;
}
pub struct HeuristicRule<Input: ?Sized> {
predicate: Box<dyn Fn(&Input) -> bool + Send + Sync>,
contribution: ScoreReason,
}
impl<Input: ?Sized> HeuristicRule<Input> {
pub fn new(
label: impl Into<String>,
delta: i32,
predicate: impl Fn(&Input) -> bool + Send + Sync + 'static,
) -> Self {
Self {
predicate: Box::new(predicate),
contribution: ScoreReason::new(label, delta),
}
}
pub fn with_detail(
label: impl Into<String>,
delta: i32,
detail: impl Into<String>,
predicate: impl Fn(&Input) -> bool + Send + Sync + 'static,
) -> Self {
Self {
predicate: Box::new(predicate),
contribution: ScoreReason::with_detail(label, delta, detail),
}
}
}
impl<Input: ?Sized> std::fmt::Debug for HeuristicRule<Input> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HeuristicRule")
.field("contribution", &self.contribution)
.finish_non_exhaustive()
}
}
pub struct HeuristicScorer<Input: ?Sized + 'static> {
rules: Vec<HeuristicRule<Input>>,
}
impl<Input: ?Sized + 'static> Default for HeuristicScorer<Input> {
fn default() -> Self {
Self::new()
}
}
impl<Input: ?Sized + 'static> HeuristicScorer<Input> {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn with_rules(rules: Vec<HeuristicRule<Input>>) -> Self {
Self { rules }
}
pub fn push(&mut self, rule: HeuristicRule<Input>) -> &mut Self {
self.rules.push(rule);
self
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
}
impl<Input: ?Sized + 'static> Scorer<Input> for HeuristicScorer<Input> {
fn score(&self, input: &Input) -> Score {
let mut hits = Vec::with_capacity(self.rules.len());
for rule in &self.rules {
if (rule.predicate)(input) {
hits.push(rule.contribution.clone());
}
}
Score::from_reasons(hits)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct LeadCtx {
is_corporate: bool,
replied_minutes_ago: Option<u32>,
body_words: u32,
intent_label: Option<&'static str>,
signature_role_hint: Option<&'static str>,
}
fn corporate_lead() -> LeadCtx {
LeadCtx {
is_corporate: true,
replied_minutes_ago: Some(45),
body_words: 60,
intent_label: Some("ready_to_buy"),
signature_role_hint: Some("CTO"),
}
}
fn cold_lead() -> LeadCtx {
LeadCtx {
is_corporate: false,
replied_minutes_ago: None,
body_words: 5,
intent_label: None,
signature_role_hint: None,
}
}
fn marketing_scorer() -> HeuristicScorer<LeadCtx> {
let mut s = HeuristicScorer::new();
s.push(HeuristicRule::new("corporate_domain", 15, |l: &LeadCtx| {
l.is_corporate
}));
s.push(HeuristicRule::new(
"replied_within_1h",
10,
|l: &LeadCtx| l.replied_minutes_ago.map(|m| m <= 60).unwrap_or(false),
));
s.push(HeuristicRule::new(
"substantive_reply_30plus_words",
10,
|l: &LeadCtx| l.body_words >= 30,
));
s.push(HeuristicRule::new(
"intent_ready_to_buy",
20,
|l: &LeadCtx| matches!(l.intent_label, Some("ready_to_buy")),
));
s.push(HeuristicRule::new("senior_signature", 10, |l: &LeadCtx| {
matches!(
l.signature_role_hint,
Some("CTO" | "CEO" | "VP" | "Director" | "Head"),
)
}));
s
}
#[test]
fn score_zero_starts_blank() {
let s = Score::zero();
assert_eq!(s.value(), 0);
assert!(s.reasons().is_empty());
}
#[test]
fn score_saturates_below_zero_to_zero() {
let s = Score::saturating_new(-50, vec![]);
assert_eq!(s.value(), 0);
}
#[test]
fn score_saturates_above_100_to_100() {
let s = Score::saturating_new(250, vec![]);
assert_eq!(s.value(), 100);
}
#[test]
fn score_from_reasons_sums_deltas() {
let s = Score::from_reasons(vec![
ScoreReason::new("a", 30),
ScoreReason::new("b", 25),
ScoreReason::new("c", -5),
]);
assert_eq!(s.value(), 50);
assert_eq!(s.reasons().len(), 3);
}
#[test]
fn score_add_concatenates_reasons_and_saturates() {
let a = Score::from_reasons(vec![ScoreReason::new("x", 60)]);
let b = Score::from_reasons(vec![ScoreReason::new("y", 70)]);
let combined = a + b;
assert_eq!(combined.value(), 100);
assert_eq!(combined.reasons().len(), 2);
}
#[test]
fn score_serde_roundtrips() {
let s = Score::from_reasons(vec![ScoreReason::with_detail("x", 10, "rationale")]);
let json = serde_json::to_string(&s).unwrap();
let back: Score = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn corporate_lead_lands_high_score() {
let s = marketing_scorer().score(&corporate_lead());
assert_eq!(s.value(), 65);
assert_eq!(s.reasons().len(), 5);
}
#[test]
fn cold_lead_lands_zero_score() {
let s = marketing_scorer().score(&cold_lead());
assert_eq!(s.value(), 0);
assert!(s.reasons().is_empty());
}
#[test]
fn empty_scorer_returns_zero() {
let s: HeuristicScorer<LeadCtx> = HeuristicScorer::new();
assert_eq!(s.score(&corporate_lead()).value(), 0);
assert_eq!(s.rule_count(), 0);
}
#[test]
fn reason_traces_carry_labels() {
let s = marketing_scorer().score(&corporate_lead());
let labels: Vec<&str> = s.reasons().iter().map(|r| r.label.as_str()).collect();
assert!(labels.contains(&"corporate_domain"));
assert!(labels.contains(&"replied_within_1h"));
assert!(labels.contains(&"intent_ready_to_buy"));
assert!(labels.contains(&"senior_signature"));
}
#[test]
fn score_is_deterministic_for_same_input() {
let scorer = marketing_scorer();
let lead = corporate_lead();
let a = scorer.score(&lead);
let b = scorer.score(&lead);
assert_eq!(a, b);
}
#[test]
fn rules_with_negative_deltas_subtract() {
let mut scorer = HeuristicScorer::new();
scorer.push(HeuristicRule::new("good", 50, |_: &LeadCtx| true));
scorer.push(HeuristicRule::new("bad", -30, |_: &LeadCtx| true));
let s = scorer.score(&cold_lead());
assert_eq!(s.value(), 20);
}
#[test]
fn rules_can_carry_inline_detail() {
let mut scorer = HeuristicScorer::new();
scorer.push(HeuristicRule::with_detail(
"intent_ready_to_buy",
20,
"el cliente respondió `quiero comprar`",
|l: &LeadCtx| matches!(l.intent_label, Some("ready_to_buy")),
));
let s = scorer.score(&corporate_lead());
assert_eq!(s.reasons().len(), 1);
assert_eq!(
s.reasons()[0].detail.as_deref(),
Some("el cliente respondió `quiero comprar`"),
);
}
#[test]
fn scorer_composition_via_add() {
let mut domain = HeuristicScorer::new();
domain.push(HeuristicRule::new("corporate_domain", 15, |l: &LeadCtx| {
l.is_corporate
}));
let mut intent = HeuristicScorer::new();
intent.push(HeuristicRule::new("ready_to_buy", 20, |l: &LeadCtx| {
matches!(l.intent_label, Some("ready_to_buy"))
}));
let lead = corporate_lead();
let combined = domain.score(&lead) + intent.score(&lead);
assert_eq!(combined.value(), 35);
assert_eq!(combined.reasons().len(), 2);
}
#[test]
fn scorer_works_through_dyn_dispatch() {
let scorer: Box<dyn Scorer<LeadCtx>> = Box::new(marketing_scorer());
let s = scorer.score(&corporate_lead());
assert_eq!(s.value(), 65);
}
}