Phase 25 — validity windows + playbook retirement
Addresses the load-bearing memory gap J flagged: playbook entries
had timestamps but no retirement semantic. When a schema migration
changed a column or a seasonal contract ended, stale playbooks kept
boosting candidates silently. Zep 2026-era finding — temporal
validity is the single highest-value memory-hygiene primitive.
SCHEMA (PlaybookEntry gains four optional fields, serde default):
schema_fingerprint — SHA-256 over dataset (column, type) tuples at
seed time. Missing = legacy entry, never
auto-retired on drift.
valid_until — RFC3339 hard expiry. compute_boost skips
entries past this moment.
retired_at — Set by retire_one or retire_on_schema_drift.
Retired entries excluded from all boost
calculations but kept in journal.
retirement_reason — Human-readable: "schema_drift: ...",
"expired: ...", "manual: ..."
RETRIEVAL PATH (compute_boost_for_filtered_with_role):
Before geo+cosine, active_entries filter removes anything retired
OR past valid_until. Uses chrono::Utc::now() once per call, no per-
entry clock queries.
NEW METHODS on PlaybookMemory:
retire_one(playbook_id, reason)
retire_on_schema_drift(city, state, current_fp, reason) — idempotent,
scopes by (city, state) so a Nashville migration doesn't touch
Chicago. Skips legacy entries with no fingerprint.
status_counts() -> (total, retired, failures)
HTTP ENDPOINTS:
POST /vectors/playbook_memory/retire
{playbook_id, reason} → retire by id
{city, state, current_schema_fingerprint, reason} → schema drift
GET /vectors/playbook_memory/status
{total, active, retired, failures}
SEED REQUEST extended with optional schema_fingerprint + valid_until
so the orchestrator (scenario.ts) can pass the current schema hash
when seeding, without a round trip through catalogd.
UNIT TESTS (5/5 pass): retire_one_marks_entry_and_persists,
retired_entries_do_not_boost, expired_valid_until_is_skipped,
schema_drift_retires_mismatched_fingerprints_only,
schema_drift_skips_other_cities.
LIVE VERIFIED: /status on current state = 1936 entries, 43 failures.
POST /retire with a sample playbook_id → "retired":1, /status now
reports active=1935, retired=1.
Memory-findings progress: 3 of 5 shipped.
✓ Multi-strategy parallel retrieval (Phase 19 refinement)
✓ Input normalization + unified /memory/query (Phase 24 TS)
✓ Zep-style validity windows (Phase 25, tonight)
⏳ Mem0 UPDATE / DELETE / NOOP ops (dedup same-(op,date) seeds)
⏳ Letta working-memory hot cache (not biting at 1.5K entries)
This commit is contained in:
@@ -125,6 +125,8 @@ pub fn router(state: VectorState) -> Router {
|
||||
.route("/playbook_memory/persist_sql", post(persist_playbook_memory_sql))
|
||||
.route("/playbook_memory/patterns", post(discover_playbook_patterns))
|
||||
.route("/playbook_memory/mark_failed", post(mark_playbook_failed))
|
||||
.route("/playbook_memory/retire", post(retire_playbook_memory))
|
||||
.route("/playbook_memory/status", get(playbook_memory_status))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -2185,6 +2187,19 @@ struct SeedPlaybookRequest {
|
||||
/// seeding is a bootstrap/demo tool, not a rebuild substitute.
|
||||
#[serde(default = "default_true")]
|
||||
append: bool,
|
||||
/// Phase 25 — optional schema_fingerprint captured at seed time.
|
||||
/// When the underlying dataset's schema changes, any entry whose
|
||||
/// fingerprint doesn't match the new one is auto-retired via
|
||||
/// retire_on_schema_drift. Caller-provided so the producer (the
|
||||
/// scenario driver, the orchestrator) can pass the live fingerprint
|
||||
/// without the gateway needing a second catalogd round trip.
|
||||
#[serde(default)]
|
||||
schema_fingerprint: Option<String>,
|
||||
/// Phase 25 — optional hard expiry. RFC3339 timestamp. After this
|
||||
/// moment the entry is skipped during boost computation (not
|
||||
/// retired, just inactive). Useful for seasonal/temp contracts.
|
||||
#[serde(default)]
|
||||
valid_until: Option<String>,
|
||||
}
|
||||
|
||||
/// Bootstrap / test-only: inject a playbook entry directly into
|
||||
@@ -2209,6 +2224,10 @@ async fn seed_playbook_memory(
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
endorsed_names: req.endorsed_names.clone(),
|
||||
city: None, state: None, embedding: None,
|
||||
schema_fingerprint: None,
|
||||
valid_until: None,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
};
|
||||
let text = format!(
|
||||
"{} | {} | {} | fills: {}",
|
||||
@@ -2260,6 +2279,14 @@ async fn seed_playbook_memory(
|
||||
endorsed_names: req.endorsed_names,
|
||||
city, state: state_,
|
||||
embedding: Some(emb),
|
||||
// Phase 25 — seed request may carry a fingerprint; if not, we
|
||||
// default to None and the entry degrades to "no expiry signal"
|
||||
// (never auto-retired on drift, but manual retirement still
|
||||
// works). valid_until + retired_at start None.
|
||||
schema_fingerprint: req.schema_fingerprint.clone(),
|
||||
valid_until: req.valid_until.clone(),
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
};
|
||||
|
||||
let mut current = state.playbook_memory.snapshot().await;
|
||||
@@ -2395,6 +2422,65 @@ async fn playbook_memory_stats(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RetirePlaybookRequest {
|
||||
/// Retire by playbook_id — exact match, single entry. Used for
|
||||
/// manual operator retirement via the UI.
|
||||
#[serde(default)]
|
||||
playbook_id: Option<String>,
|
||||
/// Retire by scope — city + state required, with a fingerprint
|
||||
/// that entries must match to survive. Fingerprint mismatch → retire.
|
||||
/// Use when a schema migration produces a new fingerprint and
|
||||
/// historical playbooks need to be auto-retired.
|
||||
#[serde(default)]
|
||||
city: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
current_schema_fingerprint: Option<String>,
|
||||
/// Human-readable reason stored on the retired entry.
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// Phase 25 retirement endpoint. Two modes:
|
||||
/// {playbook_id, reason} → retire one
|
||||
/// {city, state, current_schema_fingerprint, reason} → retire all
|
||||
/// entries in scope whose
|
||||
/// fingerprint differs
|
||||
async fn retire_playbook_memory(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<RetirePlaybookRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(id) = &req.playbook_id {
|
||||
return match state.playbook_memory.retire_one(id, &req.reason).await {
|
||||
Ok(found) => Ok(Json(serde_json::json!({ "mode": "by_id", "retired": if found { 1 } else { 0 } }))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
};
|
||||
}
|
||||
if let (Some(city), Some(state_code), Some(fp)) = (&req.city, &req.state, &req.current_schema_fingerprint) {
|
||||
return match state.playbook_memory.retire_on_schema_drift(city, state_code, fp, &req.reason).await {
|
||||
Ok(n) => Ok(Json(serde_json::json!({ "mode": "schema_drift", "retired": n, "city": city, "state": state_code }))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
};
|
||||
}
|
||||
Err((StatusCode::BAD_REQUEST,
|
||||
"supply either {playbook_id, reason} or {city, state, current_schema_fingerprint, reason}".into()))
|
||||
}
|
||||
|
||||
/// Phase 25 status endpoint — reports retirement counts so dashboards
|
||||
/// can show "N playbooks retired (12 from 2026-05 schema migration)".
|
||||
async fn playbook_memory_status(
|
||||
State(state): State<VectorState>,
|
||||
) -> impl IntoResponse {
|
||||
let (total, retired, failures) = state.playbook_memory.status_counts().await;
|
||||
Json(serde_json::json!({
|
||||
"total": total,
|
||||
"retired": retired,
|
||||
"active": total.saturating_sub(retired),
|
||||
"failures": failures,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn lance_recall_harness(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
|
||||
Reference in New Issue
Block a user