///|
/// Policy thresholds for deciding whether a recording can be scored.
pub(all) struct QualityPolicy {
minimum_beats : Int
minimum_valid_ratio : Double
maximum_gap_candidates : Int
maximum_drift_ms_per_beat : Double
reject_non_finite : Bool
} derive(FromJson, ToJson, Debug, Eq)
///|
/// An explainable policy decision.
pub(all) struct QualityDecision {
accepted : Bool
confidence : Double
reasons : Array[String]
recommended_method : CleaningMethod
validation : IntervalValidation
diagnostic : SignalDiagnostic
} derive(FromJson, ToJson, Debug, Eq)
///|
/// A balanced policy for daily recovery measurements.
pub fn QualityPolicy::daily_recovery() -> QualityPolicy {
{
minimum_beats: 60,
minimum_valid_ratio: 0.85,
maximum_gap_candidates: 6,
maximum_drift_ms_per_beat: 5.0,
reject_non_finite: true,
}
}
///|
/// Choose a correction method from the observed artifact ratio.
pub fn recommend_cleaning_method(
validation : IntervalValidation,
) -> CleaningMethod {
if validation.total == 0 || validation.invalid == 0 {
Remove
} else if validation.invalid.to_double() / validation.total.to_double() <=
0.10 {
InterpolateLocalMedian
} else {
InterpolateLinear
}
}
///|
/// Evaluate a signal against a policy and return rejection reasons.
pub fn evaluate_quality_policy(
intervals : Array[Double],
config : HrvConfig,
policy : QualityPolicy,
) -> QualityDecision {
let validation = validate_intervals(intervals, config)
let diagnostic = diagnose_signal(intervals, config)
let reasons = []
if validation.total < policy.minimum_beats {
reasons.push("too_few_beats")
}
if diagnostic.valid_ratio < policy.minimum_valid_ratio {
reasons.push("low_valid_ratio")
}
if diagnostic.gap_candidates > policy.maximum_gap_candidates {
reasons.push("too_many_gaps")
}
if diagnostic.drift_slope.abs() > policy.maximum_drift_ms_per_beat {
reasons.push("excessive_drift")
}
if policy.reject_non_finite && validation.non_finite > 0 {
reasons.push("non_finite_values")
}
let confidence = (diagnostic.valid_ratio * 100.0 -
diagnostic.gap_candidates.to_double() * 2.0).clamp(min=0.0, max=100.0)
{
accepted: reasons.length() == 0,
confidence,
reasons,
recommended_method: recommend_cleaning_method(validation),
validation,
diagnostic,
}
}
///|
/// Convert a policy decision into a multiplicative score weight.
pub fn quality_weight(decision : QualityDecision) -> Double {
if !decision.accepted {
0.0
} else {
decision.confidence / 100.0
}
}
///|
/// Return whether a report is safe to publish to a dashboard.
pub fn is_publishable(decision : QualityDecision) -> Bool {
decision.accepted && decision.confidence >= 70.0
}
///|
/// Combine policy decisions from a batch conservatively.
pub fn batch_quality_decision(
decisions : Array[QualityDecision],
) -> QualityDecision? {
if decisions.length() == 0 {
return None
}
let mut accepted = true
let mut confidence = 100.0
let reasons = []
let mut recommended = Remove
for decision in decisions {
if !decision.accepted {
accepted = false
}
if decision.confidence < confidence {
confidence = decision.confidence
}
for reason in decision.reasons {
reasons.push(reason)
}
if decision.recommended_method is InterpolateLinear {
recommended = InterpolateLinear
}
}
Some({
accepted,
confidence,
reasons,
recommended_method: recommended,
validation: decisions[0].validation,
diagnostic: decisions[0].diagnostic,
})
}