fix(predict): surface degraded state instead of silent empty responses

All four PredictiveMemory calls (predict_needed_memories, get_proactive_suggestions,
get_top_interests, prediction_accuracy) return Result, and all four were being
swallowed by `.unwrap_or_default()` / `.unwrap_or(0.0)` — every lock-poisoning or
internal error produced a response indistinguishable from a genuine cold-start
"I have no predictions yet." Callers (dashboard, Claude Code, Cursor) had no way
to tell "the system is broken" from "there genuinely isn't anything to predict."

Now each call uses `unwrap_or_else` to (a) `tracing::warn!` the error with its
source channel for observability, (b) flip a local `degraded` flag. The JSON
response gains a new `predict_degraded: bool` field. Empty + degraded=false =
cold start (expected). Empty + degraded=true = something went wrong, check logs.

6 existing predict tests pass (return shape unchanged on success path).
This commit is contained in:
Sam Valladares 2026-04-19 16:46:31 -05:00
parent 01d2e006dc
commit 72e353ae02

View file

@ -67,20 +67,62 @@ pub async fn execute(
), ),
}; };
// Get predictions from predictive memory // Get predictions from predictive memory.
//
// Each of these four calls can fail (lock poisoning, internal PredictiveMemory
// errors). Before v2.0.7 the failures were silently swallowed by
// `unwrap_or_default()`, producing an empty response that was indistinguishable
// from a genuine cold-start "I don't have any predictions yet" state. That
// ambiguity is a user-visible bug: callers can't tell "model is degraded"
// from "nothing to predict." Now we track whether ANY call errored and expose
// it as `predict_degraded: true` in the response, with per-channel errors
// logged via `tracing::warn!` for observability.
let mut degraded = false;
let predictions = cog let predictions = cog
.predictive_memory .predictive_memory
.predict_needed_memories(&session_ctx) .predict_needed_memories(&session_ctx)
.unwrap_or_default(); .unwrap_or_else(|e| {
tracing::warn!(
target: "vestige::predict",
error = %e,
"predict_needed_memories failed; returning empty predictions"
);
degraded = true;
Vec::new()
});
let suggestions = cog let suggestions = cog
.predictive_memory .predictive_memory
.get_proactive_suggestions(0.3) .get_proactive_suggestions(0.3)
.unwrap_or_default(); .unwrap_or_else(|e| {
tracing::warn!(
target: "vestige::predict",
error = %e,
"get_proactive_suggestions failed; returning empty suggestions"
);
degraded = true;
Vec::new()
});
let top_interests = cog let top_interests = cog
.predictive_memory .predictive_memory
.get_top_interests(10) .get_top_interests(10)
.unwrap_or_default(); .unwrap_or_else(|e| {
let accuracy = cog.predictive_memory.prediction_accuracy().unwrap_or(0.0); tracing::warn!(
target: "vestige::predict",
error = %e,
"get_top_interests failed; returning empty interests"
);
degraded = true;
Vec::new()
});
let accuracy = cog.predictive_memory.prediction_accuracy().unwrap_or_else(|e| {
tracing::warn!(
target: "vestige::predict",
error = %e,
"prediction_accuracy failed; returning 0.0"
);
degraded = true;
0.0
});
// Build speculative context // Build speculative context
let speculative_context = vestige_core::PredictionContext { let speculative_context = vestige_core::PredictionContext {
@ -123,6 +165,13 @@ pub async fn execute(
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
"top_interests": top_interests, "top_interests": top_interests,
"prediction_accuracy": accuracy, "prediction_accuracy": accuracy,
// predict_degraded == true means at least one of the four underlying
// PredictiveMemory calls errored (lock poisoning, internal failure)
// and the corresponding field above was substituted with an empty
// default. Callers should treat an empty response as "genuinely nothing
// to predict" only when predict_degraded is false. See tracing logs
// under target "vestige::predict" for per-channel error details.
"predict_degraded": degraded,
})) }))
} }