pub mod address_reuse;
pub mod coinjoin_detection;
pub mod consolidation;
pub mod distribution;
pub mod hft_consolidation;
pub mod long_term_supply_activation;
pub mod round_value;
pub mod urgent_execution;
use evidence_chain::EvidenceChain;
use crate::match_result::HeuristicMatch;
use crate::models::TxFeatures;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeuristicStatus {
Active,
Experimental,
Deprecated,
}
pub trait Heuristic: Send + Sync {
fn id(&self) -> &'static str;
fn version(&self) -> &'static str;
fn trigger_scope(&self) -> &'static str {
"transaction"
}
fn status(&self) -> HeuristicStatus;
fn evaluate(&self, features: &TxFeatures) -> Option<HeuristicMatch>;
fn build_evidence(&self, _features: &TxFeatures) -> Option<EvidenceChain> {
None
}
}
pub struct HeuristicRegistry {
heuristics: Vec<(Box<dyn Heuristic>, HeuristicStatus)>,
}
impl HeuristicRegistry {
pub fn new() -> Self {
Self {
heuristics: Vec::new(),
}
}
pub fn default_registry(coinjoin_score_threshold: f64) -> Self {
let mut registry = Self::new();
registry.register(
Box::new(consolidation::UtxoConsolidationHeuristic),
HeuristicStatus::Active,
);
registry.register(
Box::new(coinjoin_detection::CoinJoinDetectionHeuristic {
score_threshold: coinjoin_score_threshold,
}),
HeuristicStatus::Experimental,
);
registry.register(
Box::new(distribution::DistributionHeuristic),
HeuristicStatus::Active,
);
registry.register(
Box::new(urgent_execution::UrgentExecutionHeuristic::default()),
HeuristicStatus::Active,
);
registry.register(
Box::new(round_value::RoundValueHeuristic::default()),
HeuristicStatus::Experimental,
);
registry.register(
Box::new(address_reuse::AddressReuseDetectionHeuristic::default()),
HeuristicStatus::Experimental,
);
registry.register(
Box::new(hft_consolidation::HftConsolidationHeuristic),
HeuristicStatus::Experimental,
);
registry
}
pub fn register(&mut self, heuristic: Box<dyn Heuristic>, status: HeuristicStatus) {
self.heuristics.push((heuristic, status));
}
pub fn evaluate_all(&self, features: &TxFeatures) -> Vec<HeuristicMatch> {
let mut results = Vec::new();
for (h, status) in &self.heuristics {
match status {
HeuristicStatus::Deprecated => continue,
HeuristicStatus::Active | HeuristicStatus::Experimental => {
if let Some(mut result) = h.evaluate(features) {
result.evidence = h.build_evidence(features);
results.push(result);
}
}
}
}
results
}
pub fn build_evidence_for(
&self,
heuristic_id: &str,
features: &TxFeatures,
) -> Option<EvidenceChain> {
self.heuristics
.iter()
.find(|(h, _)| h.id() == heuristic_id)
.and_then(|(h, _)| h.build_evidence(features))
}
pub fn len(&self) -> usize {
self.heuristics.len()
}
pub fn is_empty(&self) -> bool {
self.heuristics.is_empty()
}
}
impl Default for HeuristicRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use evidence_chain::EvidenceCategory;
struct AlwaysFireHeuristic;
impl Heuristic for AlwaysFireHeuristic {
fn id(&self) -> &'static str {
"always-fire"
}
fn version(&self) -> &'static str {
"1.0.0"
}
fn status(&self) -> HeuristicStatus {
HeuristicStatus::Active
}
fn evaluate(&self, features: &TxFeatures) -> Option<HeuristicMatch> {
Some(HeuristicMatch::new(
self.id(),
self.version(),
"test_event",
"test",
self.trigger_scope(),
"test".to_string(),
serde_json::json!({"block_height": features.block_height}),
))
}
}
struct NeverFireHeuristic;
impl Heuristic for NeverFireHeuristic {
fn id(&self) -> &'static str {
"never-fire"
}
fn version(&self) -> &'static str {
"1.0.0"
}
fn status(&self) -> HeuristicStatus {
HeuristicStatus::Active
}
fn evaluate(&self, _features: &TxFeatures) -> Option<HeuristicMatch> {
None
}
}
struct DeprecatedHeuristic;
impl Heuristic for DeprecatedHeuristic {
fn id(&self) -> &'static str {
"deprecated"
}
fn version(&self) -> &'static str {
"0.1.0"
}
fn status(&self) -> HeuristicStatus {
HeuristicStatus::Deprecated
}
fn evaluate(&self, _features: &TxFeatures) -> Option<HeuristicMatch> {
panic!("deprecated heuristic should never be called")
}
}
fn make_features() -> TxFeatures {
TxFeatures {
txid: vec![0xaau8; 32],
block_height: 840_000,
block_timestamp: Utc::now(),
input_count: 1,
output_count: 2,
is_coinbase: false,
total_input_value: 100_000,
total_output_value: 99_000,
fee: 1_000,
output_value_min: 40_000,
output_value_max: 59_000,
output_value_median: 49_500.0,
fee_rate_sat_vb: Some(5.0),
input_p2pkh_count: 1,
output_p2pkh_count: 2,
is_simple_send: true,
tx_vsize_vbytes: 200,
tx_version: 1,
input_utxo_refs: vec![(vec![0xffu8; 32], 0)],
output_values: vec![40_000, 59_000],
block_price_usd: Some(50_000.0),
..Default::default()
}
}
fn make_consolidation_features() -> TxFeatures {
let mut f = make_features();
f.input_count = 15;
f.output_count = 2;
f.is_consolidation = true;
f.total_input_value = 2_000_000_000;
f.total_output_value = 1_990_000_000;
f.output_value_max = 1_900_000_000;
f.block_price_usd = Some(60_000.0);
f.is_input_script_homogeneous = true;
f
}
#[test]
fn test_registry_empty() {
let registry = HeuristicRegistry::new();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
}
#[test]
fn test_registry_always_fire() {
let mut registry = HeuristicRegistry::new();
registry.register(Box::new(AlwaysFireHeuristic), HeuristicStatus::Active);
let results = registry.evaluate_all(&make_features());
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, "test_event");
}
#[test]
fn test_registry_never_fire() {
let mut registry = HeuristicRegistry::new();
registry.register(Box::new(NeverFireHeuristic), HeuristicStatus::Active);
let results = registry.evaluate_all(&make_features());
assert!(results.is_empty());
}
#[test]
fn test_registry_deprecated_skipped() {
let mut registry = HeuristicRegistry::new();
registry.register(Box::new(DeprecatedHeuristic), HeuristicStatus::Deprecated);
let results = registry.evaluate_all(&make_features());
assert!(results.is_empty());
}
#[test]
fn test_registry_multiple_heuristics() {
let mut registry = HeuristicRegistry::new();
registry.register(Box::new(AlwaysFireHeuristic), HeuristicStatus::Active);
registry.register(Box::new(NeverFireHeuristic), HeuristicStatus::Active);
registry.register(Box::new(DeprecatedHeuristic), HeuristicStatus::Deprecated);
assert_eq!(registry.len(), 3);
let results = registry.evaluate_all(&make_features());
assert_eq!(results.len(), 1, "only AlwaysFireHeuristic should fire");
}
#[test]
fn test_default_registry_not_empty() {
let registry = HeuristicRegistry::default_registry(0.7);
assert!(
!registry.is_empty(),
"default registry should have heuristics"
);
}
#[test]
fn test_registry_coinjoin_threshold_no_event() {
let registry = HeuristicRegistry::default_registry(0.9);
let mut f = make_features();
f.coinjoin_score = 0.85;
let results = registry.evaluate_all(&f);
assert!(
!results.iter().any(|r| r.event_type == "coinjoin_detected"),
"score 0.85 with threshold 0.9 should not fire coinjoin"
);
}
#[test]
fn test_registry_coinjoin_threshold_fires() {
let registry = HeuristicRegistry::default_registry(0.7);
let mut f = make_features();
f.coinjoin_score = 0.85;
f.input_count = 5;
f.output_count = 5;
f.has_equal_outputs = true;
let results = registry.evaluate_all(&f);
assert!(
results.iter().any(|r| r.event_type == "coinjoin_detected"),
"score 0.85 with threshold 0.7 should fire coinjoin"
);
}
#[test]
fn test_consolidation_build_evidence() {
let h = consolidation::UtxoConsolidationHeuristic;
let f = make_consolidation_features();
let chain = h.build_evidence(&f);
assert!(
chain.is_some(),
"build_evidence should return Some for consolidation"
);
let chain = chain.unwrap();
assert!(
chain.links.len() >= 2,
"should have ≥2 links, has {}",
chain.links.len()
);
assert!(
chain.strength.passed_checks >= 1,
"at least 1 check should pass"
);
}
#[test]
fn test_distribution_build_evidence() {
let h = distribution::DistributionHeuristic;
let mut f = make_features();
f.input_count = 2;
f.output_count = 50;
f.is_distribution = true;
f.total_output_value = 199_990_000;
f.output_p2wpkh_count = 50;
let chain = h.build_evidence(&f);
assert!(
chain.is_some(),
"build_evidence should return Some for distribution"
);
let chain = chain.unwrap();
assert!(
chain.links.len() >= 2,
"should have ≥2 links, has {}",
chain.links.len()
);
}
#[test]
fn test_coinjoin_build_evidence() {
let h = coinjoin_detection::CoinJoinDetectionHeuristic::default();
let mut f = make_features();
f.coinjoin_score = 0.9;
f.input_count = 5;
f.output_count = 5;
f.has_equal_outputs = true;
let chain = h.build_evidence(&f);
assert!(
chain.is_some(),
"build_evidence should return Some for coinjoin"
);
let chain = chain.unwrap();
let has_behavioral = chain
.links
.iter()
.any(|l| l.category == EvidenceCategory::Behavioral);
assert!(
has_behavioral,
"should have Behavioral link with coinjoin_score"
);
}
#[test]
fn test_registry_build_evidence_for() {
let mut registry = HeuristicRegistry::new();
registry.register(
Box::new(consolidation::UtxoConsolidationHeuristic),
HeuristicStatus::Active,
);
let f = make_consolidation_features();
let chain = registry.build_evidence_for("institutional-consolidation-v1", &f);
assert!(
chain.is_some(),
"build_evidence_for should find heuristic by id"
);
}
#[test]
fn test_consolidation_summary_format() {
let h = consolidation::UtxoConsolidationHeuristic;
let f = make_consolidation_features();
let result = h.evaluate(&f).unwrap();
assert!(
result.summary.contains("inputs"),
"summary should mention 'inputs'"
);
assert!(
result.summary.contains("outputs"),
"summary should mention 'outputs'"
);
assert!(
result.summary.contains(&f.block_height.to_string()),
"summary should mention block_height"
);
}
#[test]
fn test_summary_no_forbidden_language() {
let registry = HeuristicRegistry::default_registry(0.7);
let forbidden = [
"suspicious",
"malicious",
"laundering",
"illegal",
"criminal",
"fraud",
"scam",
];
let results = registry.evaluate_all(&make_consolidation_features());
for result in &results {
for word in &forbidden {
assert!(
!result.summary.to_lowercase().contains(word),
"heuristic '{}' summary contains forbidden language '{}'",
result.id,
word
);
}
}
}
#[test]
fn test_evidence_populated_by_registry() {
let registry = HeuristicRegistry::default_registry(0.7);
let f = make_consolidation_features();
let results = registry.evaluate_all(&f);
let consolidation_result = results
.iter()
.find(|r| r.event_type == "institutional_consolidation");
assert!(consolidation_result.is_some(), "consolidation should fire");
assert!(
consolidation_result.unwrap().evidence.is_some(),
"registry should populate evidence field"
);
}
}