briefcase-wasm 2.4.1

WebAssembly bindings for Briefcase AI
Documentation
//! WebAssembly bindings for drift detection functionality

use briefcase_core::{ConsensusConfidence, DriftCalculator, DriftMetrics, DriftStatus};
use wasm_bindgen::prelude::*;

/// WASM wrapper for DriftCalculator
#[wasm_bindgen]
pub struct WasmDriftCalculator {
    inner: DriftCalculator,
}

impl Default for WasmDriftCalculator {
    fn default() -> Self {
        Self::new()
    }
}

#[wasm_bindgen]
impl WasmDriftCalculator {
    #[wasm_bindgen(constructor)]
    pub fn new() -> WasmDriftCalculator {
        WasmDriftCalculator {
            inner: DriftCalculator::new(),
        }
    }

    #[wasm_bindgen(js_name = withThreshold)]
    pub fn with_threshold(threshold: f64) -> WasmDriftCalculator {
        WasmDriftCalculator {
            inner: DriftCalculator::with_threshold(threshold),
        }
    }

    #[wasm_bindgen(js_name = calculateDrift)]
    pub fn calculate_drift(&self, outputs: &JsValue) -> Result<WasmDriftMetrics, JsValue> {
        // Convert JsValue array to Vec<String>
        let outputs_array: js_sys::Array = outputs
            .clone()
            .dyn_into()
            .map_err(|_| JsValue::from_str("Expected an array of strings"))?;

        let mut output_strings = Vec::new();
        for i in 0..outputs_array.length() {
            let item = outputs_array.get(i);
            let string_value = item
                .as_string()
                .ok_or_else(|| JsValue::from_str("All items must be strings"))?;
            output_strings.push(string_value);
        }

        let metrics = self.inner.calculate_drift(&output_strings);
        Ok(WasmDriftMetrics { inner: metrics })
    }

    #[wasm_bindgen(js_name = calculateDriftFromOutputs)]
    pub fn calculate_drift_from_outputs(
        &self,
        outputs: &JsValue,
    ) -> Result<WasmDriftMetrics, JsValue> {
        // Convert JsValue array to Vec<Output>
        let outputs_array: js_sys::Array = outputs
            .clone()
            .dyn_into()
            .map_err(|_| JsValue::from_str("Expected an array of output objects"))?;

        let mut core_outputs = Vec::new();
        for i in 0..outputs_array.length() {
            let item = outputs_array.get(i);
            let output_value: serde_json::Value = serde_wasm_bindgen::from_value(item)
                .map_err(|e| JsValue::from_str(&format!("Failed to parse output: {}", e)))?;

            // Parse output object
            if let Some(obj) = output_value.as_object() {
                let name = obj
                    .get("name")
                    .and_then(|v| v.as_str())
                    .unwrap_or("output")
                    .to_string();
                let value = obj.get("value").cloned().unwrap_or(serde_json::Value::Null);
                let data_type = obj
                    .get("data_type")
                    .and_then(|v| v.as_str())
                    .unwrap_or("string")
                    .to_string();

                let output = briefcase_core::Output::new(name, value, data_type);
                core_outputs.push(output);
            }
        }

        let metrics = self.inner.calculate_drift_from_outputs(&core_outputs);
        Ok(WasmDriftMetrics { inner: metrics })
    }

    #[wasm_bindgen(js_name = getStatus)]
    pub fn get_status(&self, metrics: &WasmDriftMetrics) -> String {
        match self.inner.get_status(&metrics.inner) {
            DriftStatus::Stable => "stable".to_string(),
            DriftStatus::Drifting => "drifting".to_string(),
            DriftStatus::Critical => "critical".to_string(),
        }
    }
}

/// WASM wrapper for DriftMetrics
#[wasm_bindgen]
pub struct WasmDriftMetrics {
    inner: DriftMetrics,
}

#[wasm_bindgen]
impl WasmDriftMetrics {
    #[wasm_bindgen(getter)]
    pub fn consistency_score(&self) -> f64 {
        self.inner.consistency_score
    }

    #[wasm_bindgen(getter)]
    pub fn agreement_rate(&self) -> f64 {
        self.inner.agreement_rate
    }

    #[wasm_bindgen(getter)]
    pub fn drift_score(&self) -> f64 {
        self.inner.drift_score
    }

    #[wasm_bindgen(getter)]
    pub fn consensus_output(&self) -> Option<String> {
        self.inner.consensus_output.clone()
    }

    #[wasm_bindgen(getter)]
    pub fn consensus_confidence(&self) -> String {
        match self.inner.consensus_confidence {
            ConsensusConfidence::High => "high".to_string(),
            ConsensusConfidence::Medium => "medium".to_string(),
            ConsensusConfidence::Low => "low".to_string(),
            ConsensusConfidence::None => "none".to_string(),
        }
    }

    #[wasm_bindgen(getter)]
    pub fn outliers(&self) -> Result<JsValue, JsValue> {
        let outliers: Vec<u32> = self.inner.outliers.iter().map(|&i| i as u32).collect();
        serde_wasm_bindgen::to_value(&outliers)
            .map_err(|e| JsValue::from_str(&format!("Conversion error: {}", e)))
    }

    #[wasm_bindgen(js_name = toObject)]
    pub fn to_object(&self) -> Result<JsValue, JsValue> {
        let obj = serde_json::json!({
            "consistency_score": self.inner.consistency_score,
            "agreement_rate": self.inner.agreement_rate,
            "drift_score": self.inner.drift_score,
            "consensus_output": self.inner.consensus_output,
            "consensus_confidence": self.consensus_confidence(),
            "outliers": self.inner.outliers,
        });

        serde_wasm_bindgen::to_value(&obj)
            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
    }
}

#[cfg(test)]
mod tests {
    use briefcase_core::{ConsensusConfidence, DriftCalculator, DriftStatus, Output};

    // ── DriftCalculator core logic tests ────────────────────────────────

    #[test]
    fn test_drift_all_identical_outputs() {
        let calc = DriftCalculator::new();
        let outputs = vec!["positive".to_string(); 10];
        let metrics = calc.calculate_drift(&outputs);

        assert!((metrics.consistency_score - 1.0).abs() < 0.01);
        assert!((metrics.agreement_rate - 1.0).abs() < 0.01);
        assert!(metrics.drift_score < 0.1);
        assert_eq!(metrics.consensus_output, Some("positive".to_string()));
        assert!(matches!(
            metrics.consensus_confidence,
            ConsensusConfidence::High
        ));
        assert!(metrics.outliers.is_empty());
    }

    #[test]
    fn test_drift_all_different_outputs() {
        let calc = DriftCalculator::new();
        let outputs: Vec<String> = (0..10).map(|i| format!("output_{}", i)).collect();
        let metrics = calc.calculate_drift(&outputs);

        assert!(metrics.consistency_score < 1.0);
        assert!(metrics.drift_score > 0.0);
    }

    #[test]
    fn test_drift_majority_agreement() {
        let calc = DriftCalculator::new();
        let mut outputs = vec!["positive".to_string(); 8];
        outputs.push("negative".to_string());
        outputs.push("neutral".to_string());
        let metrics = calc.calculate_drift(&outputs);

        assert!(metrics.agreement_rate > 0.5);
        assert_eq!(metrics.consensus_output, Some("positive".to_string()));
    }

    #[test]
    fn test_drift_custom_threshold() {
        let calc = DriftCalculator::with_threshold(0.3);
        let outputs = vec!["a".to_string(), "a".to_string(), "b".to_string()];
        let metrics = calc.calculate_drift(&outputs);

        // With a low threshold, even moderate variation triggers drift
        let status = calc.get_status(&metrics);
        // Status depends on the threshold — just verify it returns something valid
        assert!(matches!(
            status,
            DriftStatus::Stable | DriftStatus::Drifting | DriftStatus::Critical
        ));
    }

    #[test]
    fn test_drift_single_output() {
        let calc = DriftCalculator::new();
        let outputs = vec!["only_one".to_string()];
        let metrics = calc.calculate_drift(&outputs);

        assert!((metrics.consistency_score - 1.0).abs() < 0.01);
        assert_eq!(metrics.consensus_output, Some("only_one".to_string()));
    }

    #[test]
    fn test_drift_empty_outputs() {
        let calc = DriftCalculator::new();
        let outputs: Vec<String> = vec![];
        let metrics = calc.calculate_drift(&outputs);

        // Should handle empty gracefully
        assert!(metrics.consistency_score >= 0.0);
    }

    #[test]
    fn test_drift_status_stable() {
        let calc = DriftCalculator::new();
        let outputs = vec!["same".to_string(); 20];
        let metrics = calc.calculate_drift(&outputs);
        let status = calc.get_status(&metrics);
        assert!(matches!(status, DriftStatus::Stable));
    }

    #[test]
    fn test_drift_from_outputs() {
        let calc = DriftCalculator::new();
        let outputs: Vec<Output> = (0..5)
            .map(|_| Output::new("sentiment", serde_json::json!("positive"), "string"))
            .collect();
        let metrics = calc.calculate_drift_from_outputs(&outputs);

        assert!(metrics.consistency_score > 0.5);
    }

    #[test]
    fn test_drift_from_mixed_outputs() {
        let calc = DriftCalculator::new();
        let outputs = vec![
            Output::new("sentiment", serde_json::json!("positive"), "string"),
            Output::new("sentiment", serde_json::json!("negative"), "string"),
            Output::new("sentiment", serde_json::json!("positive"), "string"),
            Output::new("sentiment", serde_json::json!("neutral"), "string"),
            Output::new("sentiment", serde_json::json!("positive"), "string"),
        ];
        let metrics = calc.calculate_drift_from_outputs(&outputs);

        assert!(metrics.drift_score > 0.0);
        assert!(!metrics.outliers.is_empty() || metrics.agreement_rate < 1.0);
    }

    #[test]
    fn test_drift_outlier_detection() {
        let calc = DriftCalculator::new();
        let mut outputs = vec!["consensus".to_string(); 9];
        outputs.push("outlier".to_string());
        let metrics = calc.calculate_drift(&outputs);

        // The outlier should be detected
        assert!(!metrics.outliers.is_empty() || metrics.agreement_rate < 1.0);
    }
}