KS67: Schema-driven fact extraction + Greptile AI review#1
Conversation
- Add ExtractedFact struct + FactType enum (7 variants) to shrimpk-core - Replace hardcoded 5-fact cap with dynamic_max_facts (1-12 based on content length) - v2 open-domain extraction prompt: removes verb whitelist, supports multi-entity facts - Structured JSON parser: text/subject/type/confidence per fact - Consolidation migrated to extract_facts_and_labels with structured_facts - Near-duplicate child dedup (cosine > 0.95 skip) - Embedding-based supersession detection (0.80 supersede, 0.95 identity-skip) - Subject diversity cap in echo results (max 3 per subject entity) Target: 55% → 75% recall (benchmark validation pending)
- Strictness 2 globally, 3 for stubs/tests - Logic + syntax only (style deferred to clippy) - Per-crate directoryRules: core (semver), memory (math), daemon (async), mcp, python (PyO3) - Custom rules: no unwrap outside tests, no lock-across-await, serde consistency - Ignores target/, lockfiles, dist/, profiling artifacts
- Add parent-content supersession (Pass 3): detect contradictions
against parent memory text, not just enrichment children. Fixes
cases where LLM extracts 0 matching child facts but parent content
contains the relationship.
- Set supersedes_demotion default to 0.15 (was 0.0 — supersession
edges had zero scoring effect)
- Expand relationship regex: working at, started at, living in,
moving to, using, switching to
- Restrict regex supersession to single-valued relations (WorksAt,
LivesIn) — prevents false positives from PrefersTool
- Lower embedding supersession threshold from 0.80 to 0.70
- Add verb pattern hints to v2 extraction prompt
- Micro-benchmark: 14/20 (70%) → 16/20 (80%), target was 75%
… errors
- ci.yml: exclude shrimpk-tray and shrimpk-viz from Linux/macOS builds
(glib-sys dependency only available on Windows)
- Fix ~60 clippy errors masked behind glib-sys build failure (5 sprints)
- Collapsible if → let chains, clamp, is_none_or, div_ceil,
from_ref, char arrays, dead code annotations, type complexity allows
- Fix rustfmt diffs in persistence.rs and store.rs
- Fix 2 rustdoc errors: broken intra-doc link, unescaped generics
Greptile SummaryThis PR delivers KS67's schema-driven fact extraction pipeline on top of a supersession correctness overhaul and a CI fix for platform-specific crates. Key changes:
Confidence Score: 3/5Mostly ready; Pass 2 embedding supersession can silently corrupt rankings for multi-valued facts now that supersedes_demotion is active by default — needs the same category guard as Passes 1 and 3 before merge. The two previously-flagged P1 bugs (Supersedes self-loop, Pass 3 missing subjects_overlap) are both correctly resolved. A new P1 logic gap remains: Pass 2 fires on any semantically-similar enrichment pair with subject overlap and no category restriction, meaning normal user activity facts (two concurrent hobbies, two concurrent preferences) will be wrongly demoted. With supersedes_demotion now defaulting to 0.15 instead of 0.0, each false-positive supersession causes a silent ranking penalty that degrades recall for valid memories in production. The fix is a two-line guard identical to what Passes 1 and 3 already apply, making this a targeted, low-risk change. crates/shrimpk-memory/src/consolidation.rs (Pass 2 category guard, stale doc-comment); crates/shrimpk-core/src/traits.rs (ConsolidationOutput #[non_exhaustive]) Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[consolidate loop — idx] --> B[extract_facts_and_labels\nv2 structured prompt]
B --> C{structured_facts\nnon-empty?}
C -- yes --> D[fact_entries = structured_facts]
C -- no --> E[fact_entries = legacy strings]
D --> F[embed each fact]
E --> F
F --> G{near-dup cosine >0.95\nvs existing children?}
G -- yes --> H[skip child creation]
G -- no --> I[add enrichment child]
I --> J[detect_relationship\n→ Hebbian edge]
F --> K[fact_embeddings collected]
K --> L[detect_supersedes_pairs]
L --> M[Pass 1: regex\nworks_at/lives_in only ✓]
L --> N[Pass 2: embedding cosine >0.70\n+ subject overlap\nNO category guard ⚠️]
L --> O[Pass 3: parent-content\nworks_at/lives_in only ✓]
M --> P[Supersedes Hebbian edge\ndemotion=0.15 default]
N --> P
O --> P
Reviews (7): Last reviewed commit: "Fix fmt + clippy in consolidation.rs (si..." | Re-trigger Greptile |
- persistence.rs:334: merge parent/dir if-let chain (KS67 code)
- echo_multimodal_bench.rs:595: triple-nested if → let chain - Only visible on Linux CI (cfg-gated block, invisible on Windows)
- Add subjects_overlap check to Pass 3 parent-content supersession to prevent cross-persona false positives (@greptile P1 review) - Cap dynamic_max_facts with config.max_facts_per_memory so operators
can override the dynamic scaling
- Bump max_facts_per_memory default from 5 to 12 (matches dynamic ceiling)
- Prevent Supersedes(idx, idx) self-edge when old child's parent_id
resolves to current parent being enriched — with demotion=0.15
this silently demoted the memory in every ranking pass
- Skip same-parent siblings in detect_supersedes_pairs Pass 1 so
temporal facts from a single extraction ("worked at Google, now
works at Meta") don't trigger mutual supersession
- Collapse nested if-let for parent_id check - Fix line wrapping on current_parent_id binding
| // Pass 2: Embedding-based supersession (KS67) | ||
| // For facts that have pre-computed embeddings, check cosine similarity | ||
| // against existing enrichment children. This catches semantic supersession | ||
| // that regex patterns miss (e.g., paraphrased contradictions). | ||
| for (fact_idx, fact) in new_facts.iter().enumerate() { | ||
| let new_emb = match new_embeddings.get(fact_idx) { | ||
| Some(emb) if !emb.is_empty() => emb, | ||
| _ => continue, | ||
| }; | ||
|
|
||
| for i in 0..store.len() { | ||
| // Skip if already matched by regex pass | ||
| if matched_old_indices.contains(&i) { | ||
| continue; | ||
| } | ||
|
|
||
| let entry = match store.entry_at(i) { | ||
| Some(e) => e, | ||
| None => continue, | ||
| }; | ||
|
|
||
| // Only compare against enrichment-sourced entries (child facts) | ||
| if entry.source != "enrichment" { | ||
| continue; | ||
| } | ||
|
|
||
| let existing_emb = match store.embedding_at(i) { | ||
| Some(emb) if !emb.is_empty() => emb, | ||
| _ => continue, | ||
| }; | ||
|
|
||
| let cosine = crate::similarity::cosine_similarity(new_emb, existing_emb); | ||
|
|
||
| // >0.95: near-identity repeat — skip (not a supersession, just a duplicate) | ||
| if cosine > 0.95 { | ||
| continue; | ||
| } | ||
|
|
||
| // >0.70 + subject overlap: semantic supersession (lowered from 0.80 | ||
| // to catch v2 open-domain facts with different verb forms) | ||
| if cosine > 0.70 && subjects_overlap(fact, &entry.content) { | ||
| matched_old_indices.insert(i); | ||
| pairs.push((i, fact.clone())); | ||
| break; // One supersession per fact is enough | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Pass 2 embedding supersession missing single-valued-relationship guard
Pass 1 (line 789) and Pass 3 (line 929) both restrict supersession to works_at / lives_in — the only categories where a person can hold exactly one current value. Pass 2 applies no such restriction:
// Line 874 — no category check before firing a supersession
if cosine > 0.70 && subjects_overlap(fact, &entry.content) {
matched_old_indices.insert(i);
pairs.push((i, fact.clone()));
break;
}Concrete false-positive with supersedes_demotion = 0.15 now enabled by default:
- Enrichment child A:
"Alice enjoys hiking on weekends" - New fact B:
"Alice enjoys rock-climbing on weekends"
Both are about Alice, both likely cosine ≥ 0.70 (shared activity frame), subjects_overlap fires → child A is incorrectly demoted. Activities and preferences are multi-valued: Alice can do both.
Add the same guard already used by Passes 1 and 3:
// Pass 2: Embedding-based supersession (KS67)
for (fact_idx, fact) in new_facts.iter().enumerate() {
+ // Limit to single-valued categories (same rationale as Pass 1 and Pass 3)
+ let new_rel_opt = detect_relationship(fact);
+ let category = new_rel_opt.as_ref().map(|r| categorize_relationship(r).0);
+ if !matches!(category.as_deref(), Some("works_at") | Some("lives_in")) {
+ continue;
+ }
let new_emb = match new_embeddings.get(fact_idx) {
Some(emb) if !emb.is_empty() => emb,
_ => continue,
};
// ... rest of loop unchanged
Summary
greptile.jsonconfig — strictness 2 for core crates, custom rules (no unwrap outside tests, no lock-across-await, semver enforcement), directory-level review scoping across all 11 cratesExtractedFact/FactTypetypes, dynamicmax_factsbased on content length, v2 LLM prompt with structured JSON output, near-duplicate child dedup (cosine >0.95), embedding-based supersession detectionworking at,started at,living in, etc.), lowered embedding threshold to 0.70, enabledsupersedes_demotiondefault (was 0.0)shrimpk-tray,shrimpk-ros2) from CI matrix, resolve all clippy/fmt/doc errorsMicro-benchmark recall: 55% baseline → 80% (target was 75%)
Files changed
traits.rs,lib.rs,config.rsFactTypeenum,ExtractedFactstruct,supersedes_demotiondefaultconsolidator.rsconsolidation.rsdynamic_max_facts, parent-content supersession, regex expansion, dedup guard, subject fallbackecho.rsbuild_subject_map,enforce_subject_diversitycapci.yml,hebbian.rsgreptile.jsonecho_micro_benchmark.rs+ 9 test filesTest plan
cargo test --workspace— 333+ tests pass (shrimpk-memory), 73 (shrimpk-core)cargo test --release --test echo_micro_benchmark— 16/20 (80%) with Ollama consolidation🤖 Generated with Claude Code