# M3.7.8 — Symptom Projection at Ingest **Status**: Design · Ready to implement **Size**: M (1–2 days) **Depends**: M3.7.7 (signature extraction) **Blocks**: M3.7.4 (context endpoint), M3.7.6 (gate) --- ## Goal Transform incoming user queries and agent failure reports into normalized symptom vectors that can be matched against extracted failure signatures from M3.7.7, enabling tier 1 (exact match) lookups in the three-tier context endpoint (M3.7.4). --- ## The Problem **M3.7.7 extracts** failure signatures from logs and produces deterministic hashes: ``` Log: npm ERR! code ERESOLVE unable to resolve dependency tree Signature: npm_ERR_ERESOLVE_dependency_tree (normalized) sig_sha: abc123def... (deterministic) ``` **M3.7.8 must handle** user queries that don't match the log format: ``` User query: "npm can't find module tslib" Expected: "npm error module not found" Problem: These don't normalize to the same string ``` **Solution:** Symptom projection normalizes BOTH: - Extracted signature ← M3.7.7 (log normalization) - User query ← M3.7.8 (query normalization) - If both normalize to the same sym_sha → tier 1 hit ✅ --- ## Design ### Three-Stage Normalization Pipeline #### Stage 1: Extract Symptom Keywords **Goal**: Pull actionable error signals from free text. ```rust Input query: "npm error: unable to resolve typescript dependency tree" Step 1a: Detect tool → "npm" (from query context or user param) Step 1b: Identify error patterns • Keywords: ["unable", "resolve", "typescript", "dependency", "tree"] • Error phrases: ["unable to resolve", "dependency tree"] Step 1c: Expand abbreviations • "ERESOLVE" → ["error", "resolve"] • "ERR" → ["error"] • "cannot" → "can not" (splits) • "can't" → "cannot" (normalizes) Step 1d: Extract entity types • Module/package names (typescript) • Error codes (E400, EACCES) • Version specifiers (^1.0, 2.1.3) Output: SymptomTokens { tool: "npm", keywords: ["unable", "resolve", "typescript", "dependency", "tree"], error_codes: [], modules: ["typescript"] } ``` #### Stage 2: Normalize to Canonical Form **Goal**: Create identical strings from variant phrasings. ```rust Input: SymptomTokens { tool: "npm", keywords: ["unable", "resolve", "typescript", "dependency", "tree"], error_codes: [], modules: ["typescript"] } Step 2a: Remove stop words Remove: [a, an, the, is, are, be, can, may, to, in, of, ...] Remaining: ["unable", "resolve", "typescript", "dependency", "tree"] Step 2b: Normalize case → ["unable", "resolve", "typescript", "dependency", "tree"] Step 2c: Apply stemming (if needed) resolve ← resolved, resolving, resolution depend ← dependency, dependent Step 2d: Expand tool-specific shorthand For npm: • ERESOLVE → "error resolve" • ENOENT → "not found" • EACCES → "permission denied" For cargo: • "error[E0599]" → "error 0599" For kubectl: • "connection refused" → "connection refused" Step 2e: Dedup and sort (for idempotence) Before: ["dependency", "error", "npm", "resolve", "tree"] After: ["dependency", "error", "npm", "resolve", "tree"] Output: "dependency error npm resolve tree" ``` #### Stage 3: Generate Deterministic Hash **Goal**: Create sym_sha that matches M3.7.7 signatures. ```rust Input: "dependency error npm resolve tree" (sorted, deduplicated) Step 3a: Construct hashable string → "npm\ndependency error npm resolve tree" ↑ tool is part of identity (npm ERR vs cargo error are different) Step 3b: Hash with SHA256 sym_sha = SHA256("npm\ndependency error npm resolve tree") = "xyz789abc123..." Output: SymptomVector { tool: "npm", raw_query: "npm error: unable to resolve typescript dependency tree", normalised: "dependency error npm resolve tree", sym_sha: "xyz789abc123...", keywords: ["dependency", "error", "npm", "resolve", "tree"], confidence: 0.85 // based on keyword match strength } ``` --- ## Implementation ### Core Functions #### `project_symptom(tool: &str, query: &str) -> SymptomVector` **Purpose**: Transform user query into normalized symptom vector. ```rust pub fn project_symptom(tool: &str, query: &str) -> SymptomVector { // Stage 1: Extract keywords let tokens = extract_keywords(tool, query); // Stage 2: Normalize let normalised = normalize_tokens(&tokens); // Stage 3: Hash let sym_sha = sha256(&format!("{}\n{}", tool, normalised)); SymptomVector { tool: tool.to_string(), raw_query: query.to_string(), normalised, sym_sha, keywords: tokens.keywords, confidence: tokens.confidence, } } ``` #### `extract_keywords(tool: &str, query: &str) -> SymptomTokens` **Goal**: Pull error signals from query text. ```rust 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 let tokens = tokenize(query); for token in tokens { // Skip stop words if STOP_WORDS.contains(&token.to_lowercase()) { continue; } // Expand abbreviations let expanded = expand_abbrev(tool, &token); for word in expanded.split_whitespace() { if !word.is_empty() { 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); } } // Dedup + sort for idempotence keywords.sort(); keywords.dedup(); SymptomTokens { keywords, error_codes, modules, confidence: keyword_strength(&keywords), } } ``` #### `normalize_tokens(tokens: &SymptomTokens) -> String` ```rust fn normalize_tokens(tokens: &SymptomTokens) -> String { let mut normalized = tokens.keywords.clone(); // Remove duplicates (again, for safety) normalized.sort(); normalized.dedup(); // Join with spaces normalized.join(" ") } ``` #### `expand_abbrev(tool: &str, word: &str) -> String` **Per-tool abbreviation mappings:** ```rust fn expand_abbrev(tool: &str, word: &str) -> String { let lower = word.to_lowercase(); // Universal abbreviations 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(), _ => {} } // Tool-specific abbreviations match tool { "npm" => match lower.as_str() { "eresolve" => "error resolve", "eexist" => "already exists", _ => word, }, "cargo" => match lower.as_str() { "e0599" => "error 0599 no method", "e0308" => "error 0308 type mismatch", _ => word, }, "kubectl" => match lower.as_str() { "crd" => "custom resource definition", "etcd" => "etcd", _ => word, }, _ => word, } .to_string() } ``` #### `sha256(s: &str) -> String` ```rust use sha2::{Digest, Sha256}; fn sha256(s: &str) -> String { let mut hasher = Sha256::new(); hasher.update(s.as_bytes()); hex::encode(hasher.finalize()) } ``` --- ## Data Structures ### SymptomVector ```rust #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SymptomVector { /// Tool name (npm, cargo, kubectl, etc.) pub tool: String, /// Original user query (for display) pub raw_query: String, /// Normalized, sorted keywords pub normalised: String, /// Deterministic hash (for lookup) pub sym_sha: String, /// Extracted 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 signature pub fn matches_signature(&self, sig: &Signature) -> bool { self.sym_sha == sig.sig_sha && self.tool == sig.tool } } ``` ### SymptomTokens (internal) ```rust struct SymptomTokens { keywords: Vec, error_codes: Vec, modules: Vec, confidence: f32, } ``` --- ## Stop Words ```rust 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", // Prepositions "in", "on", "at", "to", "from", "of", "for", "by", "with", "about", // Conjunctions "and", "or", "but", "nor", "yet", "so", // Pronouns "i", "you", "he", "she", "it", "we", "they", // Common words "not", "no", "yes", "what", "which", "who", "when", "where", "why", "how", // Extra "this", "that", "these", "those", "there", "here", ]; ``` --- ## Tests (6 assertions) ### `tests/it_symptom_projection.rs` ```rust #[test] fn a1_same_symptom_same_hash() { // Multiple query formulations normalize to same sym_sha let q1 = "npm ERR! ERESOLVE unable to resolve typescript"; let q2 = "npm error: eresolve dependency tree"; let q3 = "cannot resolve typescript (npm)"; let sym1 = project_symptom("npm", q1); let sym2 = project_symptom("npm", q2); let sym3 = project_symptom("npm", q3); assert_eq!(sym1.sym_sha, sym2.sym_sha); assert_eq!(sym2.sym_sha, sym3.sym_sha); } #[test] fn a2_abbrev_expansion() { // ERESOLVE, ERR, OOM, EACCES expand correctly let cases = vec![ ("npm", "ERESOLVE", "error resolve"), ("npm", "ERR", "error"), ("cargo", "E0599", "error 0599"), ("kubectl", "CRD", "custom resource definition"), ]; for (tool, abbrev, expected) in cases { let expanded = expand_abbrev(tool, abbrev); assert!(expanded.contains(&expected.replace(" ", ""))); } } #[test] fn a3_stop_word_removal() { // "unable to resolve the dependency tree" → "resolve dependency tree" let query = "npm is unable to resolve the typescript dependency tree"; let sym = project_symptom("npm", query); // Should NOT contain stop words for stop in STOP_WORDS { assert!(!sym.normalised.contains(stop), "Stop word '{}' should be removed", stop); } // Should contain key terms assert!(sym.normalised.contains("resolve")); assert!(sym.normalised.contains("dependency")); assert!(sym.normalised.contains("typescript")); } #[test] fn a4_tool_consistency() { // Same error text under different tools = different hashes let query = "error 1234 module not found"; let sym_npm = project_symptom("npm", query); let sym_cargo = project_symptom("cargo", query); assert_ne!(sym_npm.sym_sha, sym_cargo.sym_sha, "Tool must be part of signature identity"); } #[test] fn a5_case_insensitive() { // "NPM ERROR" = "npm error" let q1 = project_symptom("npm", "NPM ERROR ERESOLVE"); let q2 = project_symptom("npm", "npm error eresolve"); assert_eq!(q1.sym_sha, q2.sym_sha); } #[test] fn a6_keyword_order_irrelevant() { // "error npm resolve" = "npm resolve error" (after sorting) let q1 = project_symptom("npm", "error npm resolve typescript"); let q2 = project_symptom("npm", "npm typescript resolve error"); assert_eq!(q1.sym_sha, q2.sym_sha); } ``` --- ## Integration with M3.7.4 Context Endpoint ### Tier 1 (Exact Match) Lookup ```rust // In M3.7.4: context_endpoint() pub async fn handle_context_query( query: &str, tool: Option<&str>, ) -> ContextResult { // Step 1: Project symptom let symptom = project_symptom(tool.unwrap_or("unknown"), query); // Step 2: Check tier 1 (exact signature match) if let Some(lesson) = find_lesson_by_sig_sha(&symptom.sym_sha) { return ContextResult { tier: 1, confidence: "high", source: "lesson", result: lesson, }; } // Step 3: Fall back to tier 2 (hybrid search) let hybrid_results = hybrid_search(query, tool).await?; return ContextResult { tier: 2, confidence: "medium", source: "hybrid_search", results: hybrid_results, }; } ``` --- ## Verification Criteria ✅ Same query variant → same sym_sha ✅ Different queries → different sym_sha ✅ Abbreviations expand correctly per tool ✅ Stop words removed consistently ✅ Tool name included in hash identity ✅ Case insensitive normalization ✅ Keyword order irrelevant (sorted before hash) --- ## Files to Create | File | Lines | Purpose | |------|-------|---------| | `crates/mem-core/src/symptom_projection.rs` | 250 | Core normalization logic | | `tests/it_symptom_projection.rs` | 400 | 6 integration test assertions | | `fixtures/symptoms/` | 3 per tool | Test query examples + expected output | --- ## Dependencies - ✅ M3.7.7 (Signature extraction) — provides `Signature` struct and `sig_sha` - ⏳ M3.7.4 (Context endpoint) — will call `project_symptom()` - ⏳ M8.2 (Dual-write) — needed for tier 2 fallback (hybrid search) --- ## Success Criteria 1. **18 unit tests passing** (mem-core lesson extraction still green) 2. **6 symptom projection integration tests passing** 3. **Symptom vectors deterministic** (run twice = same sym_sha) 4. **Integrated with M3.7.4 context endpoint** 5. **Tier 1 lookups work** (exact signature match via symptom projection) --- ## Timeline **Estimated: 1–2 days** - Day 1: Implement `symptom_projection.rs` + test fixtures - Day 1.5: Integration tests + M3.7.4 endpoint integration - Day 2: Verify tier 1 (exact match) lookups work end-to-end --- ## References - M3.7.7 (Signature extraction): `crates/mem-core/src/lesson.rs` (871 LOC) - M3.7.4 (Context endpoint): `crates/mem-cli/src/context_endpoint.rs` (TBD) - M8 (Hybrid search): `memory-flow.md` § "Search Flow"