docs: add M3.7.8 symptom projection design — 3-stage normalization, 6 test assertions, 250 LOC implementation plan

This commit is contained in:
Story Crater Bot
2026-08-28 07:49:34 -07:00
parent 0211695880
commit 86122516f7
+554
View File
@@ -0,0 +1,554 @@
# 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.
```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<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)
```rust
struct SymptomTokens {
keywords: Vec<String>,
error_codes: Vec<String>,
modules: Vec<String>,
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: 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"