99//! - Integrates with AnalysisContext (T5)
1010
1111use crate :: analysis_context:: AnalysisContext ;
12+ use crate :: config:: { NormalizationConfig , NormalizationTier } ;
1213use crate :: findings:: { VerificationStatus , VulnerabilityFinding } ;
1314use std:: collections:: HashMap ;
15+ use std:: fs;
16+ use std:: path:: PathBuf ;
1417
1518#[ cfg( test) ]
1619use crate :: findings:: Severity ;
@@ -59,6 +62,8 @@ pub enum ConfidenceFactor {
5962 TriageTruePositive ,
6063 /// Triage identified false positive
6164 TriageFalsePositive ,
65+ /// Rationale validated by LLM-as-judge
66+ RationaleValidated ,
6267}
6368
6469/// Historical data for confidence refinement.
@@ -361,7 +366,7 @@ impl ConfidenceRefinementPhase {
361366 }
362367 }
363368
364- // Factor 9: Tiage -based adjustments
369+ // Factor 9: Triage -based adjustments
365370 if let Some ( ref notes) = finding. verification_notes {
366371 if notes. contains ( "triage" ) || notes. contains ( "Triage" ) {
367372 if notes. contains ( "false_positive" ) || notes. contains ( "False positive" ) {
@@ -376,6 +381,25 @@ impl ConfidenceRefinementPhase {
376381 }
377382 }
378383
384+ // Factor 10: Rationale validation via LLM-as-judge
385+ // This applies when a finding has been through the rationale_check step
386+ // The verification_notes may contain rationale validation results
387+ if let Some ( ref notes) = finding. verification_notes {
388+ if notes. contains ( "rationale" ) || notes. contains ( "Rationale" ) {
389+ if notes. contains ( "sound" ) || notes. contains ( "validated" ) {
390+ // Sound rationale - boost confidence
391+ refined_score = ( refined_score + 0.10 ) . min ( 1.0 ) ;
392+ factors. push ( ConfidenceFactor :: RationaleValidated ) ;
393+ explanations. push ( "Rationale validated as sound by LLM judge" . to_string ( ) ) ;
394+ } else if notes. contains ( "flawed" ) || notes. contains ( "invalid" ) {
395+ // Flawed rationale - penalize confidence
396+ refined_score = ( refined_score - 0.20 ) . max ( 0.0 ) ;
397+ factors. push ( ConfidenceFactor :: RationaleValidated ) ;
398+ explanations. push ( "Rationale identified as flawed by LLM judge" . to_string ( ) ) ;
399+ }
400+ }
401+ }
402+
379403 // Clamp final score
380404 refined_score = refined_score. clamp ( 0.0 , 1.0 ) ;
381405
@@ -512,6 +536,160 @@ pub struct ContextAnalysis {
512536 explanation : String ,
513537}
514538
539+ /// Project baseline for confidence normalization.
540+ ///
541+ /// Stores historical triage outcomes to enable per-project confidence calibration.
542+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , PartialEq ) ]
543+ pub struct ProjectBaseline {
544+ /// Total number of findings analyzed.
545+ pub total_findings : usize ,
546+ /// Number of true positives confirmed.
547+ pub true_positives : usize ,
548+ /// Number of false positives identified.
549+ pub false_positives : usize ,
550+ /// Mean confidence score of all findings.
551+ pub mean_confidence : f32 ,
552+ /// Sum of squared deviations for std dev calculation.
553+ #[ serde( default ) ]
554+ pub sum_sq_dev : f32 ,
555+ }
556+
557+ impl ProjectBaseline {
558+ /// Create an empty baseline.
559+ pub fn empty ( ) -> Self {
560+ Self {
561+ total_findings : 0 ,
562+ true_positives : 0 ,
563+ false_positives : 0 ,
564+ mean_confidence : 0.0 ,
565+ sum_sq_dev : 0.0 ,
566+ }
567+ }
568+
569+ /// Load baseline from a file path.
570+ ///
571+ /// Returns empty baseline if file doesn't exist or is invalid.
572+ pub fn load ( path : & PathBuf ) -> Self {
573+ if !path. exists ( ) {
574+ return Self :: empty ( ) ;
575+ }
576+
577+ match fs:: read_to_string ( path) {
578+ Ok ( content) => match serde_json:: from_str ( & content) {
579+ Ok ( baseline) => baseline,
580+ Err ( e) => {
581+ tracing:: warn!( "Failed to parse baseline at {:?}: {}" , path, e) ;
582+ Self :: empty ( )
583+ }
584+ } ,
585+ Err ( e) => {
586+ tracing:: warn!( "Failed to read baseline at {:?}: {}" , path, e) ;
587+ Self :: empty ( )
588+ }
589+ }
590+ }
591+
592+ /// Save baseline to a file path.
593+ pub fn save ( & self , path : & PathBuf ) -> std:: io:: Result < ( ) > {
594+ let json = serde_json:: to_string_pretty ( self ) . map_err ( std:: io:: Error :: other) ?;
595+
596+ // Ensure parent directory exists
597+ if let Some ( parent) = path. parent ( ) {
598+ fs:: create_dir_all ( parent) ?;
599+ }
600+
601+ fs:: write ( path, json)
602+ }
603+
604+ /// Get false positive rate.
605+ pub fn false_positive_rate ( & self ) -> f32 {
606+ if self . total_findings == 0 {
607+ return 0.0 ;
608+ }
609+ self . false_positives as f32 / self . total_findings as f32
610+ }
611+
612+ /// Get standard deviation of confidence scores.
613+ pub fn std_dev ( & self ) -> f32 {
614+ if self . total_findings <= 1 {
615+ return 0.0 ;
616+ }
617+ ( self . sum_sq_dev / self . total_findings as f32 ) . sqrt ( )
618+ }
619+
620+ /// Update baseline with a new finding's confidence score.
621+ pub fn update ( & mut self , confidence : f32 , is_true_positive : bool ) {
622+ let old_mean = self . mean_confidence ;
623+ self . total_findings += 1 ;
624+
625+ // Update mean using Welford's online algorithm
626+ self . mean_confidence = old_mean + ( confidence - old_mean) / self . total_findings as f32 ;
627+
628+ // Update sum of squared deviations
629+ self . sum_sq_dev += ( confidence - old_mean) * ( confidence - self . mean_confidence ) ;
630+
631+ // Update TP/FP counts
632+ if is_true_positive {
633+ self . true_positives += 1 ;
634+ } else {
635+ self . false_positives += 1 ;
636+ }
637+ }
638+ }
639+
640+ /// Normalize confidence score based on project baseline.
641+ ///
642+ /// # Arguments
643+ /// * `raw_confidence` - Original confidence score
644+ /// * `config` - Normalization configuration
645+ /// * `baseline` - Project baseline with historical data
646+ ///
647+ /// # Returns
648+ /// Normalized confidence score
649+ pub fn normalize_confidence (
650+ raw_confidence : f32 ,
651+ config : & NormalizationConfig ,
652+ baseline : & ProjectBaseline ,
653+ ) -> f32 {
654+ if !config. enabled {
655+ return raw_confidence;
656+ }
657+
658+ match config. normalization_tier {
659+ NormalizationTier :: None => raw_confidence,
660+
661+ NormalizationTier :: ProjectRelative => {
662+ let fp_rate = baseline. false_positive_rate ( ) ;
663+
664+ if fp_rate > 0.30 {
665+ // High FP rate: scale down
666+ let scale = 1.0 - fp_rate * 0.5 ;
667+ raw_confidence * scale
668+ } else if fp_rate < 0.10 {
669+ // Low FP rate: scale up (capped at 1.0)
670+ let scale = 1.0 + ( 0.10 - fp_rate) * 2.0 ;
671+ ( raw_confidence * scale) . min ( 1.0 )
672+ } else {
673+ // Medium FP rate: no adjustment
674+ raw_confidence
675+ }
676+ }
677+
678+ NormalizationTier :: Isotonic => {
679+ // Apply simple linear calibration
680+ let std_dev = baseline. std_dev ( ) ;
681+
682+ // Fallback to raw if std_dev is 0 or baseline has <10 findings
683+ if std_dev == 0.0 || baseline. total_findings < 10 {
684+ return raw_confidence;
685+ }
686+
687+ let calibrated = ( raw_confidence - baseline. mean_confidence ) / std_dev * 0.5 + 0.5 ;
688+ calibrated. clamp ( 0.0 , 1.0 )
689+ }
690+ }
691+ }
692+
515693#[ cfg( test) ]
516694mod tests {
517695 use super :: * ;
0 commit comments