mod cache;
mod call_graph_adapter;
mod known_pure_functions;
pub use cache::PurityCache;
pub use call_graph_adapter::PurityCallGraphAdapter;
pub use known_pure_functions::{
aggregate_callee_purity, resolve_callee_purity, CalleeEvidence, CalleePurity,
};
use crate::analysis::purity_analysis::{PurityAnalysis, PurityAnalyzer, PurityLevel};
use crate::core::FunctionMetrics;
use crate::priority::call_graph::FunctionId;
use anyhow::{anyhow, Result};
use dashmap::DashMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PurityResult {
pub level: PurityLevel,
pub confidence: f64,
pub reason: PurityReason,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum PurityReason {
Intrinsic,
PropagatedFromDeps { depth: usize },
SideEffects { effects: Vec<String> },
RecursiveWithSideEffects,
RecursivePure,
UnknownDeps { count: usize },
}
impl PurityResult {
pub fn from_analysis(analysis: PurityAnalysis) -> Self {
let reason = if !analysis.violations.is_empty() {
PurityReason::SideEffects {
effects: analysis
.violations
.iter()
.map(|v| v.description())
.collect(),
}
} else {
PurityReason::Intrinsic
};
Self {
level: analysis.purity,
confidence: 1.0,
reason,
}
}
}
pub struct PurityPropagator {
cache: DashMap<FunctionId, PurityResult>,
call_graph: PurityCallGraphAdapter,
#[allow(dead_code)]
purity_analyzer: PurityAnalyzer,
}
struct DependencyPuritySummary {
all_deps_pure: bool,
aggregated_confidence: f64,
impure_reasons: Vec<String>,
max_depth: usize,
unknown_count: usize,
}
impl PurityPropagator {
pub fn new(call_graph: PurityCallGraphAdapter, purity_analyzer: PurityAnalyzer) -> Self {
Self {
cache: DashMap::new(),
call_graph,
purity_analyzer,
}
}
pub fn propagate(&mut self, functions: &[FunctionMetrics]) -> Result<()> {
for func in functions {
let initial = self.analyze_intrinsic_purity(func)?;
let func_id = FunctionId::new(func.file.clone(), func.name.clone(), func.line);
self.cache.insert(func_id, initial);
}
let sorted = self.call_graph.topological_sort()?;
for func_id in sorted {
self.propagate_for_function(&func_id)?;
}
Ok(())
}
fn analyze_intrinsic_purity(&self, func: &FunctionMetrics) -> Result<PurityResult> {
if let (Some(is_pure), Some(confidence)) = (func.is_pure, func.purity_confidence) {
let level = if is_pure {
PurityLevel::StrictlyPure
} else {
PurityLevel::Impure
};
return Ok(PurityResult {
level,
confidence: confidence as f64,
reason: PurityReason::Intrinsic,
});
}
Ok(PurityResult {
level: PurityLevel::Impure,
confidence: 0.3,
reason: PurityReason::UnknownDeps { count: 0 },
})
}
fn propagate_for_function(&mut self, func_id: &FunctionId) -> Result<()> {
let result = self.cached_result(func_id)?;
let deps = self.call_graph.get_dependencies(func_id);
let result = if self.call_graph.is_in_cycle(func_id) {
propagate_recursive_result(result)
} else {
let callee_evidence = self.callee_evidence(&deps);
let summary = self.dependency_summary(&deps, &callee_evidence);
propagate_dependency_result(result, summary)
};
self.cache.insert(func_id.clone(), result);
Ok(())
}
fn cached_result(&self, func_id: &FunctionId) -> Result<PurityResult> {
self.cache
.get(func_id)
.map(|result| result.clone())
.ok_or_else(|| anyhow!("Function not in cache"))
}
fn callee_evidence(&self, deps: &[FunctionId]) -> Vec<CalleeEvidence> {
deps.iter()
.map(|dep_id| CalleeEvidence {
callee_name: dep_id.name.clone(),
callee_purity: resolve_callee_purity(
&dep_id.name,
None,
self.cached_callee_purity(dep_id),
),
})
.collect()
}
fn cached_callee_purity(&self, dep_id: &FunctionId) -> Option<(bool, f64)> {
self.cache.get(dep_id).map(|result| {
let is_pure = result.level == PurityLevel::StrictlyPure;
(is_pure, result.confidence)
})
}
fn dependency_summary(
&self,
deps: &[FunctionId],
evidence: &[CalleeEvidence],
) -> DependencyPuritySummary {
let (all_deps_pure, aggregated_confidence, impure_reasons) =
aggregate_callee_purity(evidence);
DependencyPuritySummary {
all_deps_pure,
aggregated_confidence,
impure_reasons,
max_depth: self.max_dependency_depth(deps),
unknown_count: count_unknown_dependencies(evidence),
}
}
fn max_dependency_depth(&self, deps: &[FunctionId]) -> usize {
deps.iter()
.filter_map(|dep_id| self.cache.get(dep_id))
.filter_map(|result| propagated_depth(&result.reason))
.max()
.unwrap_or(0)
}
pub fn get_result(&self, func_id: &FunctionId) -> Option<PurityResult> {
self.cache.get(func_id).map(|r| r.clone())
}
}
fn propagate_recursive_result(mut result: PurityResult) -> PurityResult {
if is_recursion_eligible_pure(&result.level) {
result.reason = PurityReason::RecursivePure;
result.confidence *= 0.7;
return result;
}
result.level = PurityLevel::Impure;
result.reason = PurityReason::RecursiveWithSideEffects;
result.confidence = 0.95;
result
}
fn propagate_dependency_result(
result: PurityResult,
summary: DependencyPuritySummary,
) -> PurityResult {
if has_pure_dependencies(&result, &summary) {
return propagated_pure_result(result, &summary);
}
if !summary.impure_reasons.is_empty() {
return propagated_impure_result(summary);
}
if summary.unknown_count > 0 {
return propagated_unknown_result(result, &summary);
}
result
}
fn is_recursion_eligible_pure(level: &PurityLevel) -> bool {
matches!(level, PurityLevel::StrictlyPure | PurityLevel::LocallyPure)
}
fn has_pure_dependencies(result: &PurityResult, summary: &DependencyPuritySummary) -> bool {
summary.all_deps_pure
&& result.level != PurityLevel::Impure
&& summary.impure_reasons.is_empty()
}
fn propagated_pure_result(
mut result: PurityResult,
summary: &DependencyPuritySummary,
) -> PurityResult {
let depth = summary.max_depth + 1;
let depth_confidence = 0.9_f64.powi(depth as i32);
result.level = PurityLevel::StrictlyPure;
result.reason = PurityReason::PropagatedFromDeps { depth };
result.confidence =
(result.confidence * depth_confidence * summary.aggregated_confidence).clamp(0.5, 1.0);
result
}
fn propagated_impure_result(summary: DependencyPuritySummary) -> PurityResult {
PurityResult {
level: PurityLevel::Impure,
confidence: summary.aggregated_confidence,
reason: PurityReason::SideEffects {
effects: summary.impure_reasons,
},
}
}
fn propagated_unknown_result(
mut result: PurityResult,
summary: &DependencyPuritySummary,
) -> PurityResult {
result.reason = PurityReason::UnknownDeps {
count: summary.unknown_count,
};
result.confidence = (result.confidence * summary.aggregated_confidence).clamp(0.3, 1.0);
result
}
fn propagated_depth(reason: &PurityReason) -> Option<usize> {
match reason {
PurityReason::PropagatedFromDeps { depth } => Some(*depth),
_ => None,
}
}
fn count_unknown_dependencies(evidence: &[CalleeEvidence]) -> usize {
evidence
.iter()
.filter(|entry| matches!(entry.callee_purity, CalleePurity::Unknown))
.count()
}
#[cfg(test)]
mod tests {
use super::*;
fn result(level: PurityLevel, confidence: f64) -> PurityResult {
PurityResult {
level,
confidence,
reason: PurityReason::Intrinsic,
}
}
fn summary(
all_deps_pure: bool,
aggregated_confidence: f64,
impure_reasons: Vec<String>,
max_depth: usize,
unknown_count: usize,
) -> DependencyPuritySummary {
DependencyPuritySummary {
all_deps_pure,
aggregated_confidence,
impure_reasons,
max_depth,
unknown_count,
}
}
#[test]
fn recursive_pure_result_keeps_purity_with_penalty() {
let propagated = propagate_recursive_result(result(PurityLevel::StrictlyPure, 0.9));
assert_eq!(propagated.level, PurityLevel::StrictlyPure);
assert_eq!(propagated.reason, PurityReason::RecursivePure);
assert!((propagated.confidence - 0.63).abs() < f64::EPSILON);
}
#[test]
fn recursive_impure_result_marks_side_effect_cycle() {
let propagated = propagate_recursive_result(result(PurityLevel::Impure, 0.4));
assert_eq!(propagated.level, PurityLevel::Impure);
assert_eq!(propagated.reason, PurityReason::RecursiveWithSideEffects);
assert!((propagated.confidence - 0.95).abs() < f64::EPSILON);
}
#[test]
fn pure_dependencies_set_depth_and_confidence() {
let propagated = propagate_dependency_result(
result(PurityLevel::LocallyPure, 0.95),
summary(true, 0.9, Vec::new(), 2, 0),
);
assert_eq!(propagated.level, PurityLevel::StrictlyPure);
assert_eq!(
propagated.reason,
PurityReason::PropagatedFromDeps { depth: 3 }
);
assert!(propagated.confidence < 0.95);
}
#[test]
fn impure_dependencies_override_current_result() {
let propagated = propagate_dependency_result(
result(PurityLevel::StrictlyPure, 0.95),
summary(
false,
0.95,
vec!["Calls impure function: write".into()],
0,
0,
),
);
assert_eq!(propagated.level, PurityLevel::Impure);
assert_eq!(
propagated.reason,
PurityReason::SideEffects {
effects: vec!["Calls impure function: write".into()]
}
);
}
#[test]
fn unknown_dependencies_reduce_confidence_without_changing_level() {
let propagated = propagate_dependency_result(
result(PurityLevel::StrictlyPure, 0.8),
summary(true, 0.9, Vec::new(), 0, 2),
);
assert_eq!(propagated.level, PurityLevel::StrictlyPure);
assert_eq!(
propagated.reason,
PurityReason::PropagatedFromDeps { depth: 1 }
);
}
#[test]
fn counts_unknown_dependencies_from_evidence() {
let evidence = vec![
CalleeEvidence {
callee_name: "external".into(),
callee_purity: CalleePurity::Unknown,
},
CalleeEvidence {
callee_name: "len".into(),
callee_purity: CalleePurity::KnownPure,
},
];
assert_eq!(count_unknown_dependencies(&evidence), 1);
}
}