//! Plaid API Integration with Browser-Local Learning //! //! This module provides privacy-preserving financial data analysis that runs entirely //! in the browser. No financial data, learning patterns, or AI models ever leave the //! client device. //! //! ## Modules //! //! - `zkproofs` - Zero-knowledge proofs for financial statements //! - `wasm` - WASM bindings for browser integration //! - `zk_wasm` - WASM bindings for ZK proofs pub mod zkproofs; pub mod zkproofs_prod; #[cfg(feature = "wasm")] pub mod wasm; #[cfg(feature = "wasm")] pub mod zk_wasm; #[cfg(feature = "wasm")] pub mod zk_wasm_prod; // Re-export demo ZK types (for backward compatibility) pub use zkproofs::{ ZkProof, ProofType, VerificationResult, Commitment, FinancialProofBuilder, RentalApplicationProof, }; // Re-export production ZK types pub use zkproofs_prod::{ PedersenCommitment, ZkRangeProof, ProofMetadata, VerificationResult as ProdVerificationResult, FinancialProver, FinancialVerifier, RentalApplicationBundle, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Financial transaction from Plaid #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Transaction { pub transaction_id: String, pub account_id: String, pub amount: f64, pub date: String, pub name: String, pub merchant_name: Option, pub category: Vec, pub pending: bool, pub payment_channel: String, } /// Spending pattern learned from transactions #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpendingPattern { pub pattern_id: String, pub category: String, pub avg_amount: f64, pub frequency_days: f32, pub confidence: f64, pub last_seen: u64, } /// Category prediction result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CategoryPrediction { pub category: String, pub confidence: f64, pub similar_transactions: Vec, } /// Anomaly detection result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnomalyResult { pub is_anomaly: bool, pub anomaly_score: f64, pub reason: String, pub expected_amount: f64, } /// Budget recommendation from learning #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BudgetRecommendation { pub category: String, pub recommended_limit: f64, pub current_avg: f64, pub trend: String, // "increasing", "stable", "decreasing" pub confidence: f64, } /// Local learning state for financial patterns #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FinancialLearningState { pub version: u64, pub patterns: HashMap, /// Category embeddings - HashMap prevents unbounded growth (was Vec which leaked memory) pub category_embeddings: HashMap>, pub q_values: HashMap, // state|action -> Q-value pub temporal_weights: Vec, // Day-of-week weights (7 days: Sun-Sat) pub monthly_weights: Vec, // Day-of-month weights (31 days) /// Maximum embeddings to store (LRU eviction when exceeded) #[serde(default = "default_max_embeddings")] pub max_embeddings: usize, } fn default_max_embeddings() -> usize { 10_000 // ~400KB at 10 floats per embedding } impl Default for FinancialLearningState { fn default() -> Self { Self { version: 0, patterns: HashMap::new(), category_embeddings: HashMap::new(), q_values: HashMap::new(), temporal_weights: vec![1.0; 7], // 7 days monthly_weights: vec![1.0; 31], // 31 days max_embeddings: default_max_embeddings(), } } } /// Transaction feature vector for ML #[derive(Debug, Clone)] pub struct TransactionFeatures { pub amount_normalized: f32, pub day_of_week: f32, pub day_of_month: f32, pub hour_of_day: f32, pub is_weekend: f32, pub category_hash: Vec, // LSH of category text pub merchant_hash: Vec, // LSH of merchant name } impl TransactionFeatures { /// Convert to embedding vector for HNSW indexing pub fn to_embedding(&self) -> Vec { let mut vec = vec![ self.amount_normalized, self.day_of_week / 7.0, self.day_of_month / 31.0, self.hour_of_day / 24.0, self.is_weekend, ]; vec.extend(&self.category_hash); vec.extend(&self.merchant_hash); vec } } /// Extract features from a transaction pub fn extract_features(tx: &Transaction) -> TransactionFeatures { // Parse date for temporal features let (dow, dom, _hour) = parse_date(&tx.date); // Normalize amount (log scale, clipped) let amount_normalized = (tx.amount.abs().ln() / 10.0).min(1.0) as f32; // LSH hash for category let category_text = tx.category.join(" "); let category_hash = simple_lsh(&category_text, 8); // LSH hash for merchant let merchant = tx.merchant_name.as_deref().unwrap_or(&tx.name); let merchant_hash = simple_lsh(merchant, 8); TransactionFeatures { amount_normalized, day_of_week: dow as f32, day_of_month: dom as f32, hour_of_day: 12.0, // Default to noon if no time is_weekend: if dow >= 5 { 1.0 } else { 0.0 }, category_hash, merchant_hash, } } /// Simple LSH (locality-sensitive hashing) for text fn simple_lsh(text: &str, dims: usize) -> Vec { let mut hash = vec![0.0f32; dims]; let text_lower = text.to_lowercase(); for (i, c) in text_lower.chars().enumerate() { let idx = (c as usize + i * 31) % dims; hash[idx] += 1.0; } // Normalize let norm: f32 = hash.iter().map(|x| x * x).sum::().sqrt().max(1.0); hash.iter_mut().for_each(|x| *x /= norm); hash } /// Parse date string to (day_of_week, day_of_month, hour) fn parse_date(date_str: &str) -> (u8, u8, u8) { // Simple parser for YYYY-MM-DD format let parts: Vec<&str> = date_str.split('-').collect(); if parts.len() >= 3 { let day: u8 = parts[2].parse().unwrap_or(1); let month: u8 = parts[1].parse().unwrap_or(1); let year: u16 = parts[0].parse().unwrap_or(2024); // Simple day-of-week calculation (Zeller's congruence simplified) let dow = ((day as u16 + 13 * (month as u16 + 1) / 5 + year + year / 4) % 7) as u8; (dow, day, 12) // Default hour } else { (0, 1, 12) } } /// Q-learning update for spending decisions pub fn update_q_value( state: &FinancialLearningState, category: &str, action: &str, // "under_budget", "at_budget", "over_budget" reward: f64, learning_rate: f64, ) -> f64 { let key = format!("{}|{}", category, action); let current_q = state.q_values.get(&key).copied().unwrap_or(0.0); // Q-learning update: Q(s,a) = Q(s,a) + α * (r - Q(s,a)) current_q + learning_rate * (reward - current_q) } /// Generate spending recommendation based on learned Q-values pub fn get_recommendation( state: &FinancialLearningState, category: &str, current_spending: f64, budget: f64, ) -> BudgetRecommendation { let ratio = current_spending / budget.max(1.0); let actions = ["under_budget", "at_budget", "over_budget"]; let mut best_action = "at_budget"; let mut best_q = f64::NEG_INFINITY; for action in &actions { let key = format!("{}|{}", category, action); if let Some(&q) = state.q_values.get(&key) { if q > best_q { best_q = q; best_action = action; } } } let trend = if ratio < 0.8 { "decreasing" } else if ratio > 1.2 { "increasing" } else { "stable" }; BudgetRecommendation { category: category.to_string(), recommended_limit: budget * best_q.max(0.5).min(2.0), current_avg: current_spending, trend: trend.to_string(), confidence: (1.0 - 1.0 / (state.version as f64 + 1.0)).max(0.1), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_features() { let tx = Transaction { transaction_id: "tx123".to_string(), account_id: "acc456".to_string(), amount: 50.0, date: "2024-03-15".to_string(), name: "Coffee Shop".to_string(), merchant_name: Some("Starbucks".to_string()), category: vec!["Food".to_string(), "Coffee".to_string()], pending: false, payment_channel: "in_store".to_string(), }; let features = extract_features(&tx); assert!(features.amount_normalized >= 0.0); assert!(features.amount_normalized <= 1.0); assert_eq!(features.category_hash.len(), 8); } #[test] fn test_q_learning() { let state = FinancialLearningState::default(); let new_q = update_q_value(&state, "Food", "under_budget", 1.0, 0.1); assert!(new_q > 0.0); } }