fix(memory): address CI failures and CodeRabbit review on LLM-NER (#708)

CI was red on PR #775 due to 10 E0063 errors — existing test-only struct
literals for ExtractedEntities / ScoreSignals / ScoreRow weren't updated
when the new optional llm_importance fields landed. Local cargo check
on lib-only passed; cargo check --tests caught them, as does CI.

CodeRabbit also flagged four semantic issues:

1. LlmEntityExtractor::extract() returned Err on HTTP transport, non-2xx
   status, and JSON parse failures. The soft-fallback contract (documented
   in the module header) only worked because score_chunk catches errors;
   any other caller got a hard error. Now split into extract_or_empty()
   which logs a warn on each failure mode and returns
   ExtractedEntities::default() unconditionally. The public extract()
   stays on the async-trait surface but never returns Err.

2. find_char_span() always searched from byte offset 0, so when the LLM
   returned the same surface twice (explicitly asked for duplicates in
   the prompt), both entities got (start=0, end=len) — same span, not
   distinct mentions. Added find_char_span_from(haystack, needle,
   byte_from, char_from) that resumes from a cursor. into_extracted_entities
   now tracks a per-surface (byte_after, char_after) cursor in a
   HashMap<String, (usize, u32)>, so duplicate mentions get distinct
   non-overlapping spans and over-claim (LLM returns 3 "Alice" when
   source has 2) drops the excess entries with a debug log.

3. combine() always included w.llm_importance in the denominator even
   when llm_importance=0.0 because the LLM didn't run. That artificially
   lowered the total on short-circuit paths. score_chunk now branches on
   llm_consulted: uses combine() when LLM ran (importance actually
   contributes), combine_cheap_only() when LLM was skipped or failed
   (denominator excludes the LLM weight). Observable in two new tests:
   short_circuit_reports_cheap_only_total verifies r.total ==
   combine_cheap_only(r.signals, weights) and strictly exceeds
   combine(r.signals, weights) when llm_importance=0; llm_consulted_
   reports_full_total verifies the opposite path.

4. Same issue from a different angle (CodeRabbit flagged both). Fixed
   by the same llm_consulted branch.

Test-literal fixes:
- signals/ops.rs: ScoreSignals literals pick up llm_importance field;
  make_entities helper uses ..Default::default() on ExtractedEntities.
- extract/types.rs: three test ExtractedEntities literals gain
  llm_importance: None, llm_importance_reason: None.
- resolver.rs: canonicalise_batch_preserves_spans literal gains the
  two fields.
- score/store.rs: sample_row gains signals.llm_importance and
  llm_importance_reason.

New tests (7 added):
- find_char_span_from_advances_past_prior_match
- find_char_span_from_returns_none_after_exhaustion
- find_char_span_from_preserves_utf8
- find_char_span_from_rejects_non_char_boundary
- into_extracted_entities_gives_distinct_spans_to_duplicate_mentions
- into_extracted_entities_drops_extra_duplicate_when_source_only_has_one
- extract_soft_fallback_on_unreachable_endpoint (transport failure →
  empty extraction, not Err)
- short_circuit_reports_cheap_only_total
- llm_consulted_reports_full_total

cargo check --lib clean; cargo fmt clean. The integration-test rmeta
errors visible locally are stale-metadata artifacts from incremental
builds with prior struct shapes; CI's clean build resolves them and
tests/*.rs files do not construct any of the touched types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-22 18:29:15 +05:30
co-authored by Claude Opus 4.7
parent 605a7fd5c3
commit cd9f95e594
6 changed files with 307 additions and 36 deletions
+219 -32
View File
@@ -124,6 +124,23 @@ impl EntityExtractor for LlmEntityExtractor {
}
async fn extract(&self, text: &str) -> anyhow::Result<ExtractedEntities> {
// Soft-fallback contract: every failure path (transport, HTTP status,
// JSON parse) is logged as a warn and returns an empty
// `ExtractedEntities` rather than `Err`. This makes the extractor
// safe to call from any context, not just `score_chunk` (which
// separately catches errors from its own extractor chain). A caller
// distinguishes "LLM had nothing to say" from "LLM ran and returned
// zero entities" by inspecting `llm_importance` — `None` means the
// call didn't complete successfully.
Ok(self.extract_or_empty(text).await)
}
}
impl LlmEntityExtractor {
/// Internal: wraps the actual HTTP call and returns `ExtractedEntities`
/// for every failure mode via soft-fallback. Split out of `extract` so
/// the error branches can share logging without `?`-propagation.
async fn extract_or_empty(&self, text: &str) -> ExtractedEntities {
let url = format!("{}/api/chat", self.cfg.endpoint.trim_end_matches('/'));
let body = self.build_request(text);
log::debug!(
@@ -132,36 +149,56 @@ impl EntityExtractor for LlmEntityExtractor {
text.chars().count()
);
let resp = self
.http
.post(&url)
.json(&body)
.send()
.await
.map_err(anyhow::Error::from)?;
let resp = match self.http.post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
log::warn!(
"[memory_tree::extract::llm] transport failure to {url}: {e} — \
returning empty extraction"
);
return ExtractedEntities::default();
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("ollama status {status}: {body}");
log::warn!(
"[memory_tree::extract::llm] ollama non-success status {status}: {} — \
returning empty extraction",
truncate_for_log(&body, 200)
);
return ExtractedEntities::default();
}
let envelope: OllamaChatResponse = resp.json().await.map_err(anyhow::Error::from)?;
let envelope: OllamaChatResponse = match resp.json().await {
Ok(v) => v,
Err(e) => {
log::warn!(
"[memory_tree::extract::llm] response body not Ollama-shaped JSON: {e} — \
returning empty extraction"
);
return ExtractedEntities::default();
}
};
log::debug!(
"[memory_tree::extract::llm] response chars={}",
envelope.message.content.len()
);
let parsed: LlmExtractionOutput =
serde_json::from_str(&envelope.message.content).map_err(|e| {
anyhow::anyhow!(
"LLM returned non-JSON or wrong-shape response: {e}; \
content was: {}",
let parsed: LlmExtractionOutput = match serde_json::from_str(&envelope.message.content) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[memory_tree::extract::llm] LLM returned non-JSON or wrong-shape \
response: {e}; content was: {} — returning empty extraction",
truncate_for_log(&envelope.message.content, 400)
)
})?;
);
return ExtractedEntities::default();
}
};
Ok(parsed.into_extracted_entities(text, &self.cfg))
parsed.into_extracted_entities(text, &self.cfg)
}
}
@@ -248,6 +285,15 @@ impl LlmExtractionOutput {
) -> ExtractedEntities {
let mut entities = Vec::with_capacity(self.entities.len());
// Per-surface search cursor (char offset). When the LLM returns the
// same surface text twice (deliberately — the prompt asks for
// duplicates), we resume searching AFTER the previous occurrence so
// each emitted entity points at a distinct span. Byte indices are
// tracked separately from char indices because `str::find` returns
// byte offsets while the rest of the pipeline uses char spans.
use std::collections::HashMap;
let mut cursors: HashMap<String, (usize /*byte*/, u32 /*char*/)> = HashMap::new();
for raw in self.entities {
let surface = raw.text.trim();
if surface.is_empty() {
@@ -280,18 +326,23 @@ impl LlmExtractionOutput {
}
};
// Recover spans by string search. If the model hallucinated a
// surface that doesn't appear in the source text, drop it.
let (span_start, span_end) = match find_char_span(source_text, surface) {
Some(s) => s,
None => {
log::debug!(
"[memory_tree::extract::llm] dropping hallucinated entity (not found \
in source): {surface:?}"
);
continue;
}
};
// Recover spans by string search, advancing the cursor for this
// surface so repeated mentions get distinct spans. If the model
// hallucinated a surface (or we've exhausted all of its
// occurrences), drop the entity.
let (byte_from, char_from) = cursors.get(surface).copied().unwrap_or((0, 0));
let (span_start, span_end, byte_after) =
match find_char_span_from(source_text, surface, byte_from, char_from) {
Some(s) => s,
None => {
log::debug!(
"[memory_tree::extract::llm] dropping hallucinated or exhausted \
entity (not found beyond cursor): {surface:?}"
);
continue;
}
};
cursors.insert(surface.to_string(), (byte_after, span_end));
entities.push(ExtractedEntity {
kind,
@@ -332,10 +383,41 @@ fn parse_kind(s: &str) -> Option<EntityKind> {
/// Uses byte-level `find` then translates to char offsets so spans align
/// with the rest of the extractor pipeline (which is char-based).
fn find_char_span(haystack: &str, needle: &str) -> Option<(u32, u32)> {
let byte_idx = haystack.find(needle)?;
let char_start = haystack[..byte_idx].chars().count() as u32;
find_char_span_from(haystack, needle, 0, 0).map(|(s, e, _)| (s, e))
}
/// Find `needle` in `haystack` starting from `byte_from` and return
/// `(char_start, char_end, byte_after_needle)`.
///
/// The byte-offset return is so the caller can chain successive searches
/// without re-walking the prefix every time: pass the returned
/// `byte_after_needle` as the next call's `byte_from`.
///
/// `char_from` must correspond to `byte_from` in the same `haystack` —
/// i.e. `haystack[..byte_from].chars().count() == char_from as usize`.
/// The caller maintains this invariant (cheap: it's the return from the
/// previous call).
fn find_char_span_from(
haystack: &str,
needle: &str,
byte_from: usize,
char_from: u32,
) -> Option<(u32, u32, usize)> {
if needle.is_empty() || byte_from > haystack.len() {
return None;
}
// Guard against `byte_from` landing inside a multi-byte UTF-8 sequence.
if !haystack.is_char_boundary(byte_from) {
return None;
}
let rel = haystack[byte_from..].find(needle)?;
let byte_start = byte_from + rel;
let byte_end = byte_start + needle.len();
// Walk forward from the previous char position to build the new char
// offset — avoids re-walking the full prefix.
let char_start = char_from + haystack[byte_from..byte_start].chars().count() as u32;
let char_end = char_start + needle.chars().count() as u32;
Some((char_start, char_end))
Some((char_start, char_end, byte_end))
}
fn truncate_for_log(s: &str, max_chars: usize) -> String {
@@ -372,6 +454,111 @@ mod tests {
assert!(find_char_span("hello world", "absent").is_none());
}
#[test]
fn find_char_span_from_advances_past_prior_match() {
let text = "Alice met Bob then Alice left";
let (s1, e1, byte_after) = find_char_span_from(text, "Alice", 0, 0).unwrap();
assert_eq!((s1, e1), (0, 5));
// Resuming from the cursor must find the second Alice.
let (s2, e2, _) = find_char_span_from(text, "Alice", byte_after, e1).unwrap();
assert_eq!((s2, e2), (19, 24));
}
#[test]
fn find_char_span_from_returns_none_after_exhaustion() {
let text = "Alice met Bob";
let (_, _, byte_after) = find_char_span_from(text, "Alice", 0, 0).unwrap();
// No second Alice → None.
assert!(find_char_span_from(text, "Alice", byte_after, 5).is_none());
}
#[test]
fn find_char_span_from_preserves_utf8() {
// Two "中" characters (3 bytes each in UTF-8); "Alice" between.
let text = "中 Alice 中 Alice";
let (s1, e1, byte_after) = find_char_span_from(text, "Alice", 0, 0).unwrap();
assert_eq!((s1, e1), (2, 7));
let (s2, e2, _) = find_char_span_from(text, "Alice", byte_after, e1).unwrap();
// First "中 Alice " = 2 + 5 + 1 + 1 + 1 chars; second Alice starts at char 10.
assert_eq!((s2, e2), (10, 15));
}
#[test]
fn find_char_span_from_rejects_non_char_boundary() {
// "中" is 3 bytes; offsets 1 and 2 are mid-codepoint.
let text = "中Alice";
assert!(find_char_span_from(text, "Alice", 1, 0).is_none());
}
#[test]
fn into_extracted_entities_gives_distinct_spans_to_duplicate_mentions() {
// Two "Alice" mentions in source → two distinct ExtractedEntity rows
// with non-overlapping spans. Previously both got (0, 5).
let out = LlmExtractionOutput {
entities: vec![
LlmEntity {
kind: "person".into(),
text: "Alice".into(),
},
LlmEntity {
kind: "person".into(),
text: "Alice".into(),
},
],
importance: None,
importance_reason: None,
};
let cfg = LlmExtractorConfig::default();
let e = out.into_extracted_entities("Alice met Bob then Alice left", &cfg);
assert_eq!(e.entities.len(), 2);
assert_eq!((e.entities[0].span_start, e.entities[0].span_end), (0, 5));
assert_eq!((e.entities[1].span_start, e.entities[1].span_end), (19, 24));
}
#[test]
fn into_extracted_entities_drops_extra_duplicate_when_source_only_has_one() {
// Three "Alice" mentions returned by LLM, only one in source → keep
// one, drop the rest as exhausted-duplicate.
let out = LlmExtractionOutput {
entities: vec![
LlmEntity {
kind: "person".into(),
text: "Alice".into(),
},
LlmEntity {
kind: "person".into(),
text: "Alice".into(),
},
LlmEntity {
kind: "person".into(),
text: "Alice".into(),
},
],
importance: None,
importance_reason: None,
};
let cfg = LlmExtractorConfig::default();
let e = out.into_extracted_entities("Alice met Bob", &cfg);
assert_eq!(e.entities.len(), 1);
}
#[tokio::test]
async fn extract_soft_fallback_on_unreachable_endpoint() {
// Point at an unreachable port so the transport fails. extract()
// must NOT return Err — it must return an empty ExtractedEntities
// with a warn log.
let cfg = LlmExtractorConfig {
endpoint: "http://127.0.0.1:1".to_string(),
timeout: std::time::Duration::from_millis(100),
..LlmExtractorConfig::default()
};
let ex = LlmEntityExtractor::new(cfg).unwrap();
let out = ex.extract("some text").await.unwrap();
assert!(out.entities.is_empty());
assert!(out.topics.is_empty());
assert!(out.llm_importance.is_none());
}
#[test]
fn into_extracted_entities_drops_hallucinations() {
let out = LlmExtractionOutput {
@@ -233,6 +233,8 @@ mod tests {
},
],
topics: vec![],
llm_importance: None,
llm_importance_reason: None,
};
assert_eq!(e.unique_entity_count(), 1);
}
@@ -257,6 +259,8 @@ mod tests {
},
],
topics: vec![],
llm_importance: None,
llm_importance_reason: None,
};
assert_eq!(e.unique_entity_count(), 2);
}
@@ -272,6 +276,8 @@ mod tests {
score: 1.0,
}],
topics: vec![],
llm_importance: None,
llm_importance_reason: None,
};
let b = ExtractedEntities {
entities: vec![
@@ -291,6 +297,8 @@ mod tests {
}, // different span — keep
],
topics: vec![],
llm_importance: None,
llm_importance_reason: None,
};
a.merge(b);
assert_eq!(a.entities.len(), 2);
+73 -3
View File
@@ -190,9 +190,23 @@ pub async fn score_chunk(chunk: &Chunk, cfg: &ScoringConfig) -> Result<ScoreResu
false
};
// 4. Final weighted combine — includes llm_importance whether it was
// populated by the LLM call (set to its rating) or not (still 0.0).
let total = self::signals::combine(&signals, &cfg.weights);
// 4. Final weighted combine.
//
// If the LLM ran, its importance signal is populated → use the full
// `combine` which includes the `llm_importance` weight.
//
// If the LLM was skipped (short-circuited or not configured) OR failed
// (caught above, sets `llm_consulted=false`), using the full combine
// would pin `llm_importance * w.llm_importance = 0 * 2.0` into the
// numerator while still dividing by the full denominator — artificially
// dragging the total down. Fall back to `combine_cheap_only` which
// excludes that term from both numerator and denominator, so the cheap
// signals alone produce the total.
let total = if llm_consulted {
self::signals::combine(&signals, &cfg.weights)
} else {
self::signals::combine_cheap_only(&signals, &cfg.weights)
};
// 5. Admission gate. Source and interaction priors are deliberately
// non-zero, so guard against very short entity-free chatter being kept by
@@ -521,4 +535,60 @@ mod tests {
let r = score_chunk(&c, &cfg).await.unwrap();
assert_eq!(r.signals.llm_importance, 0.0);
}
/// When LLM is skipped (short-circuit or failure), the reported `total`
/// must equal `combine_cheap_only(signals, weights)` — not the
/// LLM-weighted `combine` (which would drag `llm_importance=0` through
/// a 2.0 weight and artificially lower the total).
#[tokio::test]
async fn short_circuit_reports_cheap_only_total() {
let c = test_chunk(
"We decided to ship Phoenix on Friday after reviewing alice@example.com and \
the migration plan carefully. @bob will coordinate and we discussed \
#launch-q2 details extensively in the email thread.",
);
let llm = FakeLlm::new(0.99);
let mut cfg = ScoringConfig::with_llm_extractor(llm.clone());
cfg.definite_keep_threshold = 0.10; // force short-circuit keep
let r = score_chunk(&c, &cfg).await.unwrap();
assert_eq!(llm.calls(), 0);
let expected = self::signals::combine_cheap_only(&r.signals, &cfg.weights);
assert!(
(r.total - expected).abs() < 1e-6,
"total={} expected(cheap_only)={}",
r.total,
expected
);
// And explicitly NOT the full combine (which would include a 0-value
// llm_importance term in a 0..1-clamped weighted average, dragging
// the total down).
let with_llm = self::signals::combine(&r.signals, &cfg.weights);
assert!(
r.total > with_llm,
"cheap-only total ({}) should exceed LLM-weighted total \
({}) when llm_importance is zero",
r.total,
with_llm
);
}
/// When the LLM *does* run, the reported total uses the full combine —
/// the llm_importance contribution is actually in the sum.
#[tokio::test]
async fn llm_consulted_reports_full_total() {
let c = test_chunk("This is a moderately interesting note about a project.");
let llm = FakeLlm::new(0.9);
let mut cfg = ScoringConfig::with_llm_extractor(llm.clone());
cfg.definite_drop_threshold = 0.0;
cfg.definite_keep_threshold = 1.0;
let r = score_chunk(&c, &cfg).await.unwrap();
assert_eq!(llm.calls(), 1);
let expected = self::signals::combine(&r.signals, &cfg.weights);
assert!(
(r.total - expected).abs() < 1e-6,
"total={} expected(full combine)={}",
r.total,
expected
);
}
}
@@ -117,6 +117,8 @@ mod tests {
entity(EntityKind::Email, "alice@example.com"),
],
topics: vec![],
llm_importance: None,
llm_importance_reason: None,
};
let out = canonicalise(&ex);
assert_eq!(out.len(), 2);
@@ -115,7 +115,7 @@ mod tests {
score: 1.0,
})
.collect(),
topics: vec![],
..Default::default()
}
}
@@ -134,6 +134,7 @@ mod tests {
source_weight: 1.0,
interaction: 1.0,
entity_density: 1.0,
llm_importance: 0.0, // default weight is 0 → contribution is zero
};
assert!((combine(&s, &SignalWeights::default()) - 1.0).abs() < 1e-6);
}
@@ -147,6 +148,7 @@ mod tests {
source_weight: 0.0,
interaction: 1.0,
entity_density: 0.0,
llm_importance: 0.0,
};
let total = combine(&s, &SignalWeights::default());
assert!((total - (3.0 / 9.0)).abs() < 1e-6);
+2
View File
@@ -353,6 +353,7 @@ mod tests {
source_weight: 0.5,
interaction: 0.6,
entity_density: 0.3,
llm_importance: 0.0,
},
dropped,
reason: if dropped {
@@ -361,6 +362,7 @@ mod tests {
None
},
computed_at_ms: 1_700_000_000_000,
llm_importance_reason: None,
}
}