Files
poimen-memory/docs/M3.7.8-SYMPTOM_PROJECTION.md
T

14 KiB
Raw Blame History

M3.7.8 — Symptom Projection at Ingest

Status: Design · Ready to implement
Size: M (12 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.

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.

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.

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.

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.

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

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:

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

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

#[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<String>,
    
    /// 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)

struct SymptomTokens {
    keywords: Vec<String>,
    error_codes: Vec<String>,
    modules: Vec<String>,
    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",
    
    // 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

#[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

// 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: 12 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"