Phase 21 Rust port + Phase 27 playbook versioning + doc-sync
Phase 21 — Rust port of scratchpad + tree-split primitives (companion to
the 2026-04-21 TS shipment). New crates/aibridge modules:
context.rs — estimate_tokens (chars/4 ceil), context_window_for,
assert_context_budget returning a BudgetCheck with
numeric diagnostics on both success and overflow.
Windows table mirrors config/models.json.
continuation.rs — generate_continuable<G: TextGenerator>. Handles the
two failure modes: empty-response from thinking
models (geometric 2x budget backoff up to budget_cap)
and truncated-non-empty (continuation with partial
as scratchpad). is_structurally_complete balances
braces then JSON.parse-checks. Guards the degen case
"all retries empty, don't loop on empty partial".
tree_split.rs — generate_tree_split map->reduce with running
scratchpad. Per-shard + reduce-prompt go through
assert_context_budget first; loud-fails rather than
silently truncating. Oldest-digest-first scratchpad
truncation at scratchpad_budget (default 6000 t).
TextGenerator trait (native async-fn-in-trait, edition 2024). AiClient
implements it; ScriptedGenerator test double lets tests inject canned
sequences without a live Ollama.
GenerateRequest gained think: Option<bool> — forwards to sidecar for
per-call hidden-reasoning opt-out on hot-path JSON emitters. Three
existing callsites updated (rag.rs x2, service.rs hybrid answer).
Phase 27 — Playbook versioning. PlaybookEntry gained four optional
fields (all #[serde(default)] so pre-Phase-27 state loads as roots):
version u32, default 1
parent_id Option<String>, previous version's playbook_id
superseded_at Option<String>, set when newer version replaces
superseded_by Option<String>, the playbook_id that replaced
New methods:
revise_entry(parent_id, new_entry) — appends new version, stamps
superseded_at+superseded_by on parent, inherits parent_id and sets
version = parent + 1 on the new entry. Rejects revising a retired
or already-superseded parent (tip-of-chain is the only valid
revise target).
history(playbook_id) — returns full chain root->tip from any node.
Walks parent_id back to root, then superseded_by forward to tip.
Cycle-safe.
Superseded entries excluded from boost (same rule as retired): filter
in compute_boost_for_filtered_with_role (both active-entries prefilter
and geo-filtered path), rebuild_geo_index, and upsert_entry's existing-
idx search. status_counts returns (total, retired, superseded, failures);
/status JSON reports active = total - retired - superseded.
Endpoints:
POST /vectors/playbook_memory/revise
GET /vectors/playbook_memory/history/{id}
Doc-sync — PHASES.md + PRD.md drifted from git after Phases 24-26
shipped. Fixes applied:
- Phase 24 marked shipped (commit b95dd86) with detail of observer
HTTP ingest + scenario outcome streaming. PRD "NOT YET WIRED"
rewritten to reflect shipped state.
- Phase 25 (validity windows, commit e0a843d) added to PHASES +
PRD.
- Phase 26 (Mem0 upsert + Letta hot cache, commit 640db8c) added.
- Phase 27 entry added to both docs.
- Phase 19.6 time decay corrected: was documented as "deferred",
actually wired via BOOST_HALF_LIFE_DAYS = 30.0 in playbook_memory.rs.
- Phase E/Phase 8 tombstone-at-compaction limit note updated —
Phase E.2 closed it.
Tests: 8 new version_tests in vectord (chain-metadata stamping,
retired/superseded parent rejection, boost exclusion, history from
root/tip/middle, legacy default round-trip, status counts). 25 new
aibridge tests (context/continuation/tree_split). Workspace total
145 green (was 120).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
640db8c63c
commit
a6f12e2609
@@ -126,6 +126,8 @@ pub fn router(state: VectorState) -> Router {
|
||||
.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/revise", post(revise_playbook_memory))
|
||||
.route("/playbook_memory/history/{id}", get(playbook_memory_history))
|
||||
.route("/playbook_memory/status", get(playbook_memory_status))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -891,6 +893,7 @@ async fn hybrid_search(
|
||||
system: None,
|
||||
temperature: Some(0.2),
|
||||
max_tokens: Some(512),
|
||||
think: None,
|
||||
}).await;
|
||||
|
||||
gen_resp.ok().map(|r| r.text.trim().to_string())
|
||||
@@ -2228,6 +2231,10 @@ async fn seed_playbook_memory(
|
||||
valid_until: None,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
version: 1,
|
||||
parent_id: None,
|
||||
superseded_at: None,
|
||||
superseded_by: None,
|
||||
};
|
||||
let text = format!(
|
||||
"{} | {} | {} | fills: {}",
|
||||
@@ -2287,6 +2294,10 @@ async fn seed_playbook_memory(
|
||||
valid_until: req.valid_until.clone(),
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
version: 1,
|
||||
parent_id: None,
|
||||
superseded_at: None,
|
||||
superseded_by: None,
|
||||
};
|
||||
|
||||
// Phase 26 — when append=true (default), route through upsert so
|
||||
@@ -2481,16 +2492,140 @@ async fn retire_playbook_memory(
|
||||
"supply either {playbook_id, reason} or {city, state, current_schema_fingerprint, reason}".into()))
|
||||
}
|
||||
|
||||
/// Phase 27 — request body for `POST /playbook_memory/revise`. Same
|
||||
/// shape as a seed request minus `append` (revise is always
|
||||
/// append-semantics for a specific parent) plus `parent_id`. The new
|
||||
/// version's `playbook_id` is derived deterministically so callers get
|
||||
/// the same id back from repeated revises with identical content —
|
||||
/// useful for idempotent retry paths.
|
||||
#[derive(Deserialize)]
|
||||
struct RevisePlaybookRequest {
|
||||
parent_id: String,
|
||||
operation: String,
|
||||
approach: String,
|
||||
context: String,
|
||||
endorsed_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
schema_fingerprint: Option<String>,
|
||||
#[serde(default)]
|
||||
valid_until: Option<String>,
|
||||
}
|
||||
|
||||
/// Phase 27 — create a new version of an existing playbook. The parent
|
||||
/// is marked superseded; the new entry inherits the chain via
|
||||
/// `parent_id` and carries `version = parent.version + 1`. Errors with
|
||||
/// 400 on a retired or already-superseded parent (must revise the tip
|
||||
/// of the chain). Embeds the new text through the same shape as
|
||||
/// `/seed` so cosine similarity stays comparable across rebuild + seed
|
||||
/// + revise entries.
|
||||
async fn revise_playbook_memory(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<RevisePlaybookRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let text = format!(
|
||||
"{} | {} | {} | fills: {}",
|
||||
req.operation, req.approach, req.context,
|
||||
req.endorsed_names.join(", "),
|
||||
);
|
||||
let resp = state.ai_client.embed(EmbedRequest { texts: vec![text], model: None })
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("embed revise: {e}")))?;
|
||||
if resp.embeddings.is_empty() {
|
||||
return Err((StatusCode::BAD_GATEWAY, "embed returned nothing".into()));
|
||||
}
|
||||
let emb: Vec<f32> = resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
||||
|
||||
let (city, state_) = {
|
||||
let after_in = req.operation.split(" in ").nth(1).unwrap_or("");
|
||||
let mut parts = after_in.splitn(2, ',');
|
||||
let city = parts.next().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let state = parts.next()
|
||||
.map(|s| s.trim().chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>())
|
||||
.filter(|s| !s.is_empty());
|
||||
(city, state)
|
||||
};
|
||||
if city.is_none() || state_.is_none() {
|
||||
return Err((StatusCode::BAD_REQUEST,
|
||||
"operation must match 'fill: Role xN in City, ST' shape".into()));
|
||||
}
|
||||
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(ts.as_bytes());
|
||||
h.update(b"|");
|
||||
h.update(req.parent_id.as_bytes());
|
||||
h.update(b"|");
|
||||
h.update(req.operation.as_bytes());
|
||||
let bytes = h.finalize();
|
||||
let pid = format!("pb-rev-{}", bytes.iter().take(8).map(|b| format!("{b:02x}")).collect::<String>());
|
||||
|
||||
let new_entry = playbook_memory::PlaybookEntry {
|
||||
playbook_id: pid.clone(),
|
||||
operation: req.operation,
|
||||
approach: req.approach,
|
||||
context: req.context,
|
||||
timestamp: ts,
|
||||
endorsed_names: req.endorsed_names,
|
||||
city, state: state_,
|
||||
embedding: Some(emb),
|
||||
schema_fingerprint: req.schema_fingerprint,
|
||||
valid_until: req.valid_until,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
// revise_entry overwrites these from the parent — values here
|
||||
// are just placeholders so the struct is well-formed.
|
||||
version: 1,
|
||||
parent_id: None,
|
||||
superseded_at: None,
|
||||
superseded_by: None,
|
||||
};
|
||||
|
||||
let outcome = state.playbook_memory.revise_entry(&req.parent_id, new_entry)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"outcome": outcome,
|
||||
"entries_after": state.playbook_memory.entry_count().await,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Phase 27 — return the full version chain containing `playbook_id`,
|
||||
/// ordered root → tip. 404 if the id isn't present. The walker is
|
||||
/// cycle-safe by construction (visited set per direction).
|
||||
async fn playbook_memory_history(
|
||||
State(state): State<VectorState>,
|
||||
Path(playbook_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let chain = state.playbook_memory.history(&playbook_id).await;
|
||||
if chain.is_empty() {
|
||||
return Err((StatusCode::NOT_FOUND, format!("no playbook with id '{playbook_id}'")));
|
||||
}
|
||||
Ok(Json(serde_json::json!({
|
||||
"playbook_id": playbook_id,
|
||||
"versions": chain.len(),
|
||||
"chain": chain,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Phase 25 status endpoint — reports retirement counts so dashboards
|
||||
/// can show "N playbooks retired (12 from 2026-05 schema migration)".
|
||||
/// Phase 27 added `superseded` as a distinct counter.
|
||||
async fn playbook_memory_status(
|
||||
State(state): State<VectorState>,
|
||||
) -> impl IntoResponse {
|
||||
let (total, retired, failures) = state.playbook_memory.status_counts().await;
|
||||
let (total, retired, superseded, failures) = state.playbook_memory.status_counts().await;
|
||||
// `active` = entries eligible for boost. Retired and superseded are
|
||||
// distinct exclusion reasons; subtract both. An entry can in principle
|
||||
// be both retired AND superseded (e.g. revised then retired) so
|
||||
// saturating_sub guards against underflow if that pathological case
|
||||
// ever lands.
|
||||
let inactive = retired + superseded;
|
||||
Json(serde_json::json!({
|
||||
"total": total,
|
||||
"retired": retired,
|
||||
"active": total.saturating_sub(retired),
|
||||
"superseded": superseded,
|
||||
"active": total.saturating_sub(inactive),
|
||||
"failures": failures,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user