//! Symptom projection: normalize user queries into deterministic symptom vectors //! that match extracted failure signatures from lesson extraction. //! //! Three-stage pipeline: //! 1. Extract keywords from query //! 2. Normalize to canonical form (remove stop words, expand abbreviations) //! 3. Generate deterministic SHA256 hash (sym_sha) //! //! If user query sym_sha == extracted signature sig_sha → Tier 1 (exact) match ✅ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Transform user query into normalized symptom vector for matching against /// extracted failure signatures. /// /// # Example /// ```ignore /// let symptom = project_symptom("npm", "npm ERESOLVE unable to resolve tslib"); /// // sym_sha will match signature "npm error resolve tslib" (after normalization) /// ``` pub fn project_symptom(tool: &str, query: &str) -> SymptomVector { // Stage 1: Extract keywords let tokens = extract_keywords(tool, query); // Stage 2: Normalize to canonical form let normalised = normalize_tokens(&tokens); // Stage 3: Generate deterministic hash let sym_sha = sha256(&format!("{}\n{}", tool, normalised)); // Calculate confidence based on keyword coverage let confidence = calculate_confidence(&tokens); SymptomVector { tool: tool.to_string(), raw_query: query.to_string(), normalised: normalised.clone(), sym_sha, keywords: tokens.keywords, confidence, } } // --------------------------------------------------------------------------- // Data Structures // --------------------------------------------------------------------------- /// Normalized symptom vector for matching against failure signatures. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SymptomVector { /// Tool name (npm, cargo, kubectl, etc.) pub tool: String, /// Original user query (for display/logging) pub raw_query: String, /// Normalized, sorted keywords joined by spaces pub normalised: String, /// Deterministic SHA256 hash for lookup pub sym_sha: String, /// Extracted and normalized keywords pub keywords: Vec, /// Confidence score (0.0-1.0) based on keyword coverage pub confidence: f32, } impl SymptomVector { /// Check if this symptom matches a failure signature (by sym_sha equality) pub fn matches_signature(&self, sig_sha: &str) -> bool { self.sym_sha == sig_sha } } /// Internal structure for tokens during extraction #[derive(Debug, Clone)] struct SymptomTokens { keywords: Vec, error_codes: Vec, modules: Vec, confidence: f32, } // --------------------------------------------------------------------------- // Stop Words // --------------------------------------------------------------------------- const STOP_WORDS: &[&str] = &[ // Articles "a", "an", "the", // Common verbs "is", "are", "be", "been", "being", "have", "has", "had", "do", "does", "did", "can", "could", "may", "might", "must", "shall", "should", "will", "would", "ought", "unable", // Prepositions "in", "on", "at", "to", "from", "of", "for", "by", "with", "about", "during", "before", "after", "above", "below", "between", "through", // Conjunctions "and", "or", "but", "nor", "yet", "so", // Pronouns "i", "you", "he", "she", "it", "we", "they", "me", "him", "her", "us", "them", // Common words "not", "no", "yes", "what", "which", "who", "when", "where", "why", "how", "this", "that", "these", "those", "there", "here", // Weak verbs "able", "get", "got", "make", "made", "take", "took", ]; // --------------------------------------------------------------------------- // Stage 1: Extract Keywords // --------------------------------------------------------------------------- /// Extract keywords and error signals from user query. fn extract_keywords(tool: &str, query: &str) -> SymptomTokens { let mut keywords = Vec::new(); let mut error_codes = Vec::new(); let mut modules = Vec::new(); // Tokenize by whitespace + punctuation boundaries let tokens = tokenize(query); for token in tokens { let lower = token.to_lowercase(); // Skip stop words if STOP_WORDS.contains(&lower.as_str()) { continue; } // Expand abbreviations and add expanded forms let expanded = expand_abbrev(tool, &token); for word in expanded.split_whitespace() { if !word.is_empty() && !STOP_WORDS.contains(&word) { keywords.push(word.to_lowercase()); } } // Extract structured signals if let Some(code) = extract_error_code(&token) { error_codes.push(code); } if let Some(module) = extract_module_name(tool, &token) { modules.push(module); } } // Add extracted modules and error codes as keywords (high signal) for code in &error_codes { keywords.push(code.clone()); } for module in &modules { keywords.push(module.clone()); } // Dedup + sort for idempotence keywords.sort(); keywords.dedup(); // Filter out empty strings keywords.retain(|s| !s.is_empty()); let confidence = calculate_confidence_from_tokens(&keywords, &error_codes); SymptomTokens { keywords, error_codes, modules, confidence, } } /// Tokenize query by whitespace and common punctuation boundaries. fn tokenize(s: &str) -> Vec { s.split(|c: char| c.is_whitespace() || ",:;!?()[]{}\"'".contains(c)) .filter(|t| !t.is_empty()) .map(|t| t.to_string()) .collect() } /// Extract error codes like "E0599", "EACCES", "ENOENT", "E400", etc. fn extract_error_code(token: &str) -> Option { let lower = token.to_lowercase(); // Match patterns: E followed by digits or numbers, or EXXXX if (lower.starts_with('e') && lower[1..].chars().all(|c| c.is_ascii_digit())) || lower.starts_with('e') && lower[1..] .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_') { return Some(lower); } None } /// Extract module/package names (context-dependent, minimal extraction) fn extract_module_name(tool: &str, token: &str) -> Option { let lower = token.to_lowercase(); // Skip if it's a common word or error code if STOP_WORDS.contains(&lower.as_str()) { return None; } // npm: common module names (scoped: @scope/module, or simple names) if tool == "npm" { if lower.starts_with('@') && lower.contains('/') { return Some(lower); } // Simple heuristic: if it's a lowercase identifier with hyphens/underscores if lower .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') && lower.len() > 2 { return Some(lower); } } None } // --------------------------------------------------------------------------- // Stage 2: Normalize Tokens // --------------------------------------------------------------------------- /// Normalize extracted tokens to canonical form (sorted, deduplicated). fn normalize_tokens(tokens: &SymptomTokens) -> String { let mut normalized = tokens.keywords.clone(); // Remove duplicates and sort alphabetically normalized.sort(); normalized.dedup(); // Filter out empty strings (safety) normalized.retain(|s| !s.is_empty()); // Join with spaces normalized.join(" ") } // --------------------------------------------------------------------------- // Abbreviation Expansion // --------------------------------------------------------------------------- /// Expand abbreviations per tool. /// /// Handles universal (ERR, OOM, EACCES) and tool-specific (ERESOLVE, E0599). fn expand_abbrev(tool: &str, word: &str) -> String { let lower = word.to_lowercase(); // Universal abbreviations (all tools) match lower.as_str() { "err" | "error" => return "error".to_string(), "rc" | "return" => return "return code".to_string(), "oom" => return "out of memory".to_string(), "enoent" => return "not found".to_string(), "eacces" => return "permission denied".to_string(), "eperm" => return "operation not permitted".to_string(), "econnrefused" => return "connection refused".to_string(), "econnreset" => return "connection reset".to_string(), "enotempty" => return "directory not empty".to_string(), "eexist" => return "already exists".to_string(), "eisdir" => return "is a directory".to_string(), "eof" => return "end of file".to_string(), "timeout" => return "timeout".to_string(), "cant" | "can't" => return "cannot".to_string(), "wont" | "won't" => return "will not".to_string(), "dont" | "don't" => return "do not".to_string(), _ => {} } // Tool-specific abbreviations match tool.to_lowercase().as_str() { "npm" => match lower.as_str() { "eresolve" => "error resolve".to_string(), "eacces" => "permission denied".to_string(), _ => word.to_string(), }, "cargo" => match lower.as_str() { "e0599" => "error 0599 method".to_string(), "e0308" => "error 0308 type mismatch".to_string(), "e0433" => "error 0433 unresolved import".to_string(), "e0106" => "error 0106 missing lifetime".to_string(), _ => word.to_string(), }, "kubectl" => match lower.as_str() { "crd" => "custom resource definition".to_string(), "etcd" => "etcd".to_string(), "tls" => "tls".to_string(), "pvc" => "persistent volume claim".to_string(), "rbac" => "rbac authorization".to_string(), _ => word.to_string(), }, "docker" => match lower.as_str() { "enoent" => "not found".to_string(), "eacces" => "permission denied".to_string(), _ => word.to_string(), }, "go" => match lower.as_str() { "fatal" => "fatal error".to_string(), "panic" => "panic".to_string(), _ => word.to_string(), }, _ => word.to_string(), } } // --------------------------------------------------------------------------- // Stage 3: Generate Deterministic Hash // --------------------------------------------------------------------------- /// Generate SHA256 hash of normalized symptom. fn sha256(s: &str) -> String { let mut hasher = Sha256::new(); hasher.update(s.as_bytes()); hex::encode(hasher.finalize()) } // --------------------------------------------------------------------------- // Confidence Scoring // --------------------------------------------------------------------------- /// Calculate confidence score based on keyword coverage and signal strength. fn calculate_confidence(tokens: &SymptomTokens) -> f32 { calculate_confidence_from_tokens(&tokens.keywords, &tokens.error_codes) } /// Confidence increases with: /// - Error codes present (high signal) /// - Keyword count (more specific) /// - Module names (high specificity) fn calculate_confidence_from_tokens(keywords: &[String], error_codes: &[String]) -> f32 { let mut score = 0.3; // Base confidence // Bonus for error codes (very high signal) if !error_codes.is_empty() { score += 0.3; } // Bonus for keyword count let keyword_bonus = (keywords.len() as f32).min(5.0) / 5.0 * 0.2; score += keyword_bonus; // Bonus for specific error patterns for keyword in keywords { if keyword.contains("error") || keyword.contains("failed") { score += 0.1; break; } } score.min(1.0) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn test_project_symptom_creates_vector() { let symptom = project_symptom("npm", "npm error resolve dependency"); assert_eq!(symptom.tool, "npm"); assert!(!symptom.sym_sha.is_empty()); assert!(symptom.confidence > 0.0); } #[test] fn test_deterministic_hashing() { let sym1 = project_symptom("npm", "npm error resolve dependency"); let sym2 = project_symptom("npm", "npm error resolve dependency"); assert_eq!(sym1.sym_sha, sym2.sym_sha, "Same query should produce same hash"); } #[test] fn test_stop_word_removal() { let symptom = project_symptom("npm", "npm is unable to resolve the dependency"); // Should not contain stop words as whole words (checked via split) let words: Vec<&str> = symptom.normalised.split_whitespace().collect(); for word in &words { // Check if this word is a stop word assert!(!STOP_WORDS.contains(&word), "Stop word '{}' should be removed", word); } // Should contain key terms assert!(symptom.normalised.contains("resolve")); assert!(symptom.normalised.contains("npm")); } #[test] fn test_abbreviation_expansion() { let symptom = project_symptom("npm", "npm ERESOLVE error"); assert!(symptom.normalised.contains("resolve")); assert!(symptom.normalised.contains("error")); } #[test] fn test_tool_consistency() { let npm = project_symptom("npm", "error 404 module not found"); let cargo = project_symptom("cargo", "error 404 module not found"); assert_ne!(npm.sym_sha, cargo.sym_sha, "Tool name should be part of identity"); assert_eq!(npm.tool, "npm"); assert_eq!(cargo.tool, "cargo"); } #[test] fn test_case_insensitive() { let upper = project_symptom("npm", "NPM ERROR ERESOLVE"); let lower = project_symptom("npm", "npm error eresolve"); assert_eq!(upper.sym_sha, lower.sym_sha, "Case should not affect hash"); } #[test] fn test_keyword_order_irrelevant() { let query1 = project_symptom("npm", "error npm resolve typescript"); let query2 = project_symptom("npm", "npm typescript resolve error"); assert_eq!(query1.sym_sha, query2.sym_sha, "Keyword order should not affect hash"); } #[test] fn test_matches_signature() { let symptom = project_symptom("npm", "npm error resolve dependency"); assert!( symptom.matches_signature(&symptom.sym_sha), "Symptom should match its own signature" ); assert!( !symptom.matches_signature("different_hash"), "Symptom should not match different hash" ); } #[test] fn test_error_code_extraction() { let symptom = project_symptom("cargo", "error E0599 no method found"); assert!(symptom.normalised.contains("e0599")); } #[test] fn test_confidence_scoring() { let low_conf = project_symptom("npm", "error"); let high_conf = project_symptom("npm", "npm error E404 cannot resolve typescript module"); assert!(low_conf.confidence < high_conf.confidence, "More keywords = higher confidence"); } }