Deploy Poimen Memory K8s cluster with ArgoCD tracking (M2.2, M3.5-M3.7)
ci / markdown (push) Waiting to run

This commit is contained in:
Story Crater Bot
2026-08-22 23:13:42 -07:00
parent 9c723fe66f
commit d3be7f6fd4
105 changed files with 10973 additions and 113 deletions
+1
View File
@@ -8,6 +8,7 @@ tokio = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
sha2 = { workspace = true }
+181
View File
@@ -0,0 +1,181 @@
use thiserror::Error;
/// Parsed gate response from the model.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GateResponse {
pub think: String,
pub update_gate: bool,
pub candidate: String,
pub exit_gate: bool,
}
/// Parse error with context.
#[derive(Error, Debug, Clone)]
#[error("Parse error in {tag}: {message}\nRaw: {raw}")]
pub struct ParseError {
pub tag: String,
pub message: String,
pub raw: String,
}
/// Parse a gate response from model output.
pub fn parse_gate_response(response: &str) -> Result<GateResponse, ParseError> {
// Extract <think>...</think> — last one before first <check>
let think = extract_think(response)?;
// Extract <check>yes|no</check>
let check_value = extract_tag_value(response, "check")?;
let update_gate = match check_value.trim().to_lowercase().as_str() {
"yes" => true,
"no" => false,
_ => {
return Err(ParseError {
tag: "check".to_string(),
message: format!("must be 'yes' or 'no', got '{}'", check_value),
raw: truncate(response, 200),
});
}
};
// Extract <update>...</update>
let candidate = extract_tag_value(response, "update")?;
// Extract <next>continue|end</next>
let next_value = extract_tag_value(response, "next")?;
let exit_gate = match next_value.trim().to_lowercase().as_str() {
"continue" => false,
"end" => true,
_ => {
return Err(ParseError {
tag: "next".to_string(),
message: format!("must be 'continue' or 'end', got '{}'", next_value),
raw: truncate(response, 200),
});
}
};
Ok(GateResponse {
think,
update_gate,
candidate,
exit_gate,
})
}
/// Extract the last <think>...</think> before first <check>.
fn extract_think(response: &str) -> Result<String, ParseError> {
let check_pos = response.find("<check>").ok_or_else(|| ParseError {
tag: "check".to_string(),
message: "tag not found".to_string(),
raw: truncate(response, 200),
})?;
// Look for the last </think> before the <check>
let before_check = &response[..check_pos];
if let Some(end_pos) = before_check.rfind("</think>") {
// Look for the last <think> before this </think>
if let Some(start_pos) = before_check[..end_pos].rfind("<think>") {
let think_content = &before_check[start_pos + 7..end_pos]; // 7 = "<think>".len()
return Ok(think_content.to_string());
}
}
Err(ParseError {
tag: "think".to_string(),
message: "tag not found or not properly closed".to_string(),
raw: truncate(response, 200),
})
}
/// Extract content between <tag>...</tag>, ensuring it appears exactly once.
fn extract_tag_value(response: &str, tag: &str) -> Result<String, ParseError> {
let open_tag = format!("<{}>", tag);
let close_tag = format!("</{}>", tag);
// Check if tag appears at all
if !response.contains(&open_tag) {
return Err(ParseError {
tag: tag.to_string(),
message: "tag not found".to_string(),
raw: truncate(response, 200),
});
}
// Check for duplicates
let open_count = response.matches(&open_tag).count();
let close_count = response.matches(&close_tag).count();
if open_count > 1 || close_count > 1 {
return Err(ParseError {
tag: tag.to_string(),
message: format!(
"tag appears {} times (expected exactly 1)",
open_count.max(close_count)
),
raw: truncate(response, 200),
});
}
if close_count == 0 {
return Err(ParseError {
tag: tag.to_string(),
message: "tag not properly closed".to_string(),
raw: truncate(response, 200),
});
}
// Extract content
let start_idx = response.find(&open_tag).unwrap() + open_tag.len();
let end_idx = response.find(&close_tag).unwrap();
if start_idx > end_idx {
return Err(ParseError {
tag: tag.to_string(),
message: "malformed tag structure".to_string(),
raw: truncate(response, 200),
});
}
Ok(response[start_idx..end_idx].to_string())
}
/// Truncate a string for display.
fn truncate(s: &str, max_len: usize) -> String {
if s.len() > max_len {
format!("{}...", &s[..max_len])
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wellformed_yes_continue() {
let response = r#"
<think>This is reasoning</think>
<check>yes</check>
<update>Memory update text</update>
<next>continue</next>
"#;
let result = parse_gate_response(response).unwrap();
assert_eq!(result.think, "This is reasoning");
assert!(result.update_gate);
assert_eq!(result.candidate, "Memory update text");
assert!(!result.exit_gate);
}
#[test]
fn test_missing_tag() {
let response = r#"
<think>This is reasoning</think>
<check>yes</check>
<next>continue</next>
"#;
let result = parse_gate_response(response);
assert!(result.is_err());
assert_eq!(result.unwrap_err().tag, "update");
}
}
+145
View File
@@ -0,0 +1,145 @@
use crate::domain::{Chunk, Level};
use crate::gate_parser::parse_gate_response;
use crate::prompt::PromptBuilder;
use crate::query::Query;
use anyhow::Result;
/// LLM client trait for dependency injection.
pub trait LlmClient: Send + Sync {
fn complete_blocking(&self, system: &str, user: &str, max_tokens: usize) -> Result<String>;
}
/// Loop configuration.
#[derive(Debug, Clone)]
pub struct LoopConfig {
pub level: Level,
pub query: Query,
pub memory_budget: u32,
pub use_exit_gate: bool,
}
/// Events emitted by the loop.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoopEvent {
Evidence { turn: u32 },
Memory { turn: u32, update: bool },
Gate { turn: u32, update: bool, exit: bool },
ParseFailed { turn: u32, attempts: u32 },
BudgetExceeded { turn: u32 },
RunEnd { chunks_seen: u32, chunks_used: u32 },
}
/// Outcome of a loop run.
#[derive(Debug, Clone)]
pub struct RunOutcome {
pub chunks_seen: u32,
pub chunks_used: u32,
pub final_memory: String,
pub events: Vec<LoopEvent>,
}
/// Run the gated loop over chunks.
pub fn run_loop(
config: LoopConfig,
chunks: Vec<Chunk>,
llm: &dyn LlmClient,
) -> Result<RunOutcome> {
let mut memory = String::new();
let mut chunks_seen = 0u32;
let mut chunks_used = 0u32;
let mut events = Vec::new();
for chunk in chunks {
chunks_seen += 1;
let turn = chunks_seen;
// Build prompt
let memory_ref = if memory.is_empty() { None } else { Some(memory.as_str()) };
let (system_prompt, user_prompt) = PromptBuilder::build(&config.query, memory_ref, &chunk)?;
// Try parse up to 3 times
let mut should_exit = false;
let mut parse_ok = false;
for attempt in 1..=3 {
match llm.complete_blocking(&system_prompt, &user_prompt, 2048) {
Ok(response) => match parse_gate_response(&response) {
Ok(gated) => {
// Check memory budget
if gated.candidate.len() as u32 > config.memory_budget {
events.push(LoopEvent::BudgetExceeded { turn });
events.push(LoopEvent::Gate {
turn,
update: false,
exit: gated.exit_gate,
});
parse_ok = true;
should_exit = gated.exit_gate && config.use_exit_gate;
break;
}
// Apply update rule
if gated.update_gate {
memory = gated.candidate.clone();
chunks_used += 1;
events.push(LoopEvent::Evidence { turn });
}
events.push(LoopEvent::Memory {
turn,
update: gated.update_gate,
});
events.push(LoopEvent::Gate {
turn,
update: gated.update_gate,
exit: gated.exit_gate,
});
parse_ok = true;
should_exit = gated.exit_gate && config.use_exit_gate;
break;
}
Err(_) if attempt < 3 => continue,
Err(_) => {
events.push(LoopEvent::ParseFailed { turn, attempts: attempt });
parse_ok = true;
break;
}
},
Err(_) if attempt < 3 => continue,
Err(e) => return Err(e),
}
}
if !parse_ok {
return Err(anyhow::anyhow!("Failed to parse after all retries"));
}
if should_exit {
break;
}
}
events.push(LoopEvent::RunEnd {
chunks_seen,
chunks_used,
});
Ok(RunOutcome {
chunks_seen,
chunks_used,
final_memory: memory,
events,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_loop_basic() {
// Placeholder test to verify it compiles
assert!(true);
}
}
+871
View File
@@ -0,0 +1,871 @@
//! Failure lessons: capture, normalise, match, materialise.
//!
//! Follows Claude Code's file conventions on purpose. Lessons materialise as
//! `SKILL.md` files and a `CLAUDE.md` fragment, so the filesystem is the API and
//! no client integration is required -- Claude Code, pi and anything else that
//! reads those conventions get the memory for free.
//!
//! What we add over hand-written CLAUDE.md is authorship: lessons are captured
//! from real failures and their observed resolutions, and they carry provenance.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
// ---------------------------------------------------------------------------
// Events -- the authoritative append-only record
// ---------------------------------------------------------------------------
/// One observed command execution. Appended to the JSONL log, never mutated.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub ts: String,
pub cwd: String,
pub cmd: String,
pub exit: i32,
/// Tail of combined output. Capped at capture time.
pub output: String,
}
impl Event {
/// Commands are compared after dropping volatile arguments, so that
/// `kubectl apply -f /tmp/abc123.yaml` pairs with a later retry.
pub fn cmd_key(&self) -> String {
normalise_cmd(&self.cmd)
}
}
// ---------------------------------------------------------------------------
// Normalisation
// ---------------------------------------------------------------------------
/// Remove ANSI SGR sequences. Coloured output otherwise hashes differently
/// depending on whether a TTY was attached.
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
// CSI introducer '[' is itself inside the final-byte range, so it
// must be consumed before scanning for the terminator.
if chars.peek() == Some(&'[') {
chars.next();
}
// parameter bytes 0x30-0x3f, intermediates 0x20-0x2f, final 0x40-0x7e
for c2 in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c2) {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn is_hex_sha(s: &str) -> bool {
// Bias against matching: require length and at least two digits, so that
// English words made of hex letters ("deadbeef" is rare, "facade" is not)
// are left alone. Under-normalising costs a tier-1 miss; over-normalising
// costs a confident wrong answer.
let n = s.len();
if !(7..=40).contains(&n) {
return false;
}
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
return false;
}
s.chars().filter(|c| c.is_ascii_digit()).count() >= 2
}
fn is_timestamp(s: &str) -> bool {
let b = s.as_bytes();
// ISO-8601-ish: 4 digits, '-', ... with a 'T'
if b.len() >= 10
&& b[..4].iter().all(|c| c.is_ascii_digit())
&& b[4] == b'-'
&& s.contains('T')
{
return true;
}
// bare epoch seconds / millis
if (10 == b.len() || 13 == b.len()) && b.iter().all(|c| c.is_ascii_digit()) {
return true;
}
false
}
fn is_duration(s: &str) -> bool {
let b = s.as_bytes();
if b.is_empty() || !b[0].is_ascii_digit() {
return false;
}
let unit_tail = s.ends_with("ms")
|| s.ends_with('s')
|| s.ends_with('m')
|| s.ends_with('h')
|| s.ends_with("\u{b5}s");
if !unit_tail {
return false;
}
s.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c.is_ascii_alphabetic())
}
/// Split a trailing `:LINE` or `:LINE:COL` off a token.
fn split_line_col(tok: &str) -> (&str, Option<String>) {
let parts: Vec<&str> = tok.rsplitn(3, ':').collect();
match parts.as_slice() {
[c, l, head] if c.chars().all(|x| x.is_ascii_digit())
&& l.chars().all(|x| x.is_ascii_digit())
&& !c.is_empty()
&& !l.is_empty() =>
{
(head, Some(":<LINE>:<COL>".into()))
}
[l, head] if l.chars().all(|x| x.is_ascii_digit()) && !l.is_empty() => {
(head, Some(":<LINE>".into()))
}
_ => (tok, None),
}
}
fn normalise_token(tok: &str) -> String {
let (core, suffix) = split_line_col(tok);
let repl = if core.starts_with("0x") && core.len() > 2 {
"<ADDR>".to_string()
} else if is_timestamp(core) {
"<TS>".to_string()
} else if is_duration(core) {
"<DUR>".to_string()
} else if is_hex_sha(core) {
"<SHA>".to_string()
} else if core.contains('/') && core.len() > 3 {
// Keep the basename: which file failed is meaningful, the workspace
// prefix it sat under is not.
match core.rsplit_once('/') {
Some((_, base)) if !base.is_empty() => format!("<PATH>/{base}"),
_ => "<PATH>".to_string(),
}
} else {
core.to_string()
};
match suffix {
Some(s) => format!("{repl}{s}"),
None => repl,
}
}
/// Reduce a line to a form that is stable across runs of the same failure.
///
/// Deliberately does NOT touch bare integers: `exit status 1` and
/// `exit status 137` must stay distinguishable, or OOM collides with a test
/// failure.
pub fn normalise(raw: &str) -> String {
strip_ansi(raw)
.split_whitespace()
.map(normalise_token)
.collect::<Vec<_>>()
.join(" ")
}
/// Normalise a *command line* for identity comparison.
///
/// Harsher than [`normalise`], which keeps basenames because knowing which file
/// failed to compile is meaningful. For a command, the opposite holds: applying
/// `np-x7f2.yaml` then `np-a91c.yaml` is the same action on a regenerated temp
/// file, and keeping the basename stops the pair from ever being found.
pub fn normalise_cmd(cmd: &str) -> String {
strip_ansi(cmd)
.split_whitespace()
.map(|tok| {
let (core, _) = split_line_col(tok);
if core.contains('/') && core.len() > 3 {
"<PATH>".to_string()
} else {
normalise_token(tok)
}
})
.collect::<Vec<_>>()
.join(" ")
}
// ---------------------------------------------------------------------------
// Signature extraction
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Signature {
pub tool: String,
/// The original error line, for display.
pub raw: String,
pub normalised: String,
pub sig_sha: String,
/// Which rule fired. The debugging surface for the whole tier.
pub rule: String,
}
fn markers(tool: &str) -> &'static [&'static str] {
match tool {
"npm" | "pnpm" | "yarn" => &["npm ERR!", "ERR_", "error "],
"cargo" | "rust" => &["error[", "error:", "panicked at"],
"go" => &["panic:", "undefined:", "cannot use", "error:"],
"kubectl" | "k8s" => &["error:", "Error from server", "Unable to connect"],
"github-actions" | "gha" => &["##[error]", "Error:", "error:"],
"docker" => &["ERROR:", "failed to", "Error response from daemon"],
"terraform" => &["Error:", "\u{2502} Error:"],
_ => &[],
}
}
const GENERIC_MARKERS: &[&str] = &[
"error:", "Error:", "ERROR", "ERR!", "FAILED", "fatal:", "panic:", "Exception",
];
/// A bare error-code declaration such as `npm ERR! code ERESOLVE`, which
/// prefixes the descriptive line rather than replacing it.
///
/// Found by fixture: some runs emit it and some do not, so anchoring here
/// splits one failure into two signatures and dilutes `seen`.
fn is_code_declaration(line: &str) -> bool {
let t = line.trim();
if let Some(idx) = t.find(" code ") {
// "<prefix> code <TOKEN>" with nothing after the token
let rest = t[idx + 6..].trim();
return !rest.is_empty() && !rest.contains(' ');
}
false
}
/// Lines that are consequences of an earlier failure. Anchoring on these keys
/// the lesson to a symptom of a symptom -- and the last line of a GitHub
/// Actions log is identical across every failure it has ever produced.
fn is_cascade(line: &str) -> bool {
const SUPPRESS: &[&str] = &[
"##[error]Process completed with exit code",
"make: ***",
"npm ERR! A complete log of this run",
"error: could not compile",
"error: build failed",
"FAILED (",
"Error: Process completed",
"exit status",
];
let t = line.trim();
SUPPRESS.iter().any(|s| t.starts_with(s) || t.contains(s))
}
/// Extract the first error that is not a consequence of another.
///
/// Falls back to the last non-empty line for unknown tools: a worse signature
/// is still a signature, and failing because a tool is unrecognised is useless
/// in exactly the moment someone needs an answer.
pub fn extract(tool: &str, output: &str) -> Option<Signature> {
let clean = strip_ansi(output);
let lines: Vec<&str> = clean.lines().map(|l| l.trim_end()).collect();
let tool_markers = markers(tool);
let mut found: Option<(String, &'static str)> = None;
let skip = |l: &str| l.trim().is_empty() || is_cascade(l) || is_code_declaration(l);
for line in lines.iter() {
if skip(line) {
continue;
}
if tool_markers.iter().any(|m| line.contains(m)) {
found = Some((line.trim().to_string(), "tool-rule"));
break;
}
}
if found.is_none() {
for line in lines.iter() {
if skip(line) {
continue;
}
if GENERIC_MARKERS.iter().any(|m| line.contains(m)) {
found = Some((line.trim().to_string(), "generic-marker"));
break;
}
}
}
if found.is_none() {
let last = lines.iter().rev().find(|l| !l.trim().is_empty())?;
found = Some((last.trim().to_string(), "last-line-fallback"));
}
let (raw, rule) = found?;
let normalised = normalise(&raw);
if normalised.is_empty() {
return None;
}
// Tool is part of identity: `exit status 1` means different things
// in different tools.
let mut h = Sha256::new();
h.update(tool.as_bytes());
h.update(b"\n");
h.update(normalised.as_bytes());
let sig_sha = hex(&h.finalize());
Some(Signature {
tool: tool.to_string(),
raw,
normalised,
sig_sha,
rule: rule.to_string(),
})
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
// ---------------------------------------------------------------------------
// Similarity -- tier 2 without embeddings
// ---------------------------------------------------------------------------
fn trigrams(s: &str) -> HashSet<[char; 3]> {
let cs: Vec<char> = s.to_lowercase().chars().collect();
let mut set = HashSet::new();
for w in cs.windows(3) {
set.insert([w[0], w[1], w[2]]);
}
set
}
/// Jaccard similarity over character trigrams. Deterministic, no model, no
/// index. Good enough to decide whether embeddings are worth adding -- if this
/// never misses, the vector store is unjustified.
pub fn similarity(a: &str, b: &str) -> f32 {
let (ta, tb) = (trigrams(a), trigrams(b));
if ta.is_empty() || tb.is_empty() {
return 0.0;
}
let inter = ta.intersection(&tb).count() as f32;
let union = ta.union(&tb).count() as f32;
inter / union
}
// ---------------------------------------------------------------------------
// Lessons
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
/// Derived mechanically from a fail -> success pair. Might be coincidence.
Inferred,
/// A human kept it. Outranks inferred at equal similarity.
Confirmed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson {
pub sig_sha: String,
pub tool: String,
pub raw: String,
pub normalised: String,
/// Commands observed between the failure and the next success.
pub resolution: Vec<String>,
pub seen: u32,
pub last_seen: String,
pub cwd: String,
pub confidence: Confidence,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Tier {
/// Exact signature match: this precise failure happened here before.
Exact,
/// Similar signature: something like it happened.
Similar(f32),
}
#[derive(Debug, Clone)]
pub struct Hit {
pub lesson: Lesson,
pub tier: Tier,
}
/// Look a failure up against known lessons.
///
/// Abstention is a first-class outcome. An agent acts on the top result, so
/// a plausible-but-wrong lesson is worse than silence -- it turns a confused
/// agent into a confident one going the wrong way.
pub fn lookup(sig: &Signature, lessons: &[Lesson], floor: f32) -> Option<Hit> {
if let Some(l) = lessons.iter().find(|l| l.sig_sha == sig.sig_sha) {
return Some(Hit {
lesson: l.clone(),
tier: Tier::Exact,
});
}
let mut best: Option<(f32, &Lesson)> = None;
for l in lessons.iter().filter(|l| l.tool == sig.tool) {
let s = similarity(&sig.normalised, &l.normalised);
if s >= floor && best.map_or(true, |(bs, _)| s > bs) {
best = Some((s, l));
}
}
best.map(|(s, l)| Hit {
lesson: l.clone(),
tier: Tier::Similar(s),
})
}
/// Pair failures with the next success of the same command in the same
/// directory. The commands in between are the candidate resolution.
///
/// Mechanical and self-labelling: no model, no human prompt. Noisy, which is
/// why everything it produces is `Inferred`.
pub fn derive_lessons(events: &[Event], tool_of: impl Fn(&str) -> String) -> Vec<Lesson> {
let mut out: Vec<Lesson> = Vec::new();
for (i, ev) in events.iter().enumerate() {
if ev.exit == 0 {
continue;
}
let key = ev.cmd_key();
// find the next success of the same command in the same cwd
let Some(succ_idx) = events
.iter()
.enumerate()
.skip(i + 1)
.find(|(_, e)| e.exit == 0 && e.cwd == ev.cwd && e.cmd_key() == key)
.map(|(j, _)| j)
else {
continue;
};
let resolution: Vec<String> = events[i + 1..succ_idx]
.iter()
.filter(|e| e.cwd == ev.cwd && e.exit == 0)
.map(|e| e.cmd.clone())
.filter(|c| !is_opaque_action(c))
.collect();
if resolution.is_empty() {
// Either a bare retry (flaky, not a lesson) or a delta consisting
// only of opaque actions, which teaches nothing.
continue;
}
let tool = tool_of(&ev.cmd);
let Some(sig) = extract(&tool, &ev.output) else {
continue;
};
if let Some(existing) = out.iter_mut().find(|l| l.sig_sha == sig.sig_sha) {
existing.seen += 1;
existing.last_seen = ev.ts.clone();
// Prefer the most recent resolution: if the same failure recurred,
// whatever was done last is the version that stuck.
existing.resolution = resolution;
continue;
}
out.push(Lesson {
sig_sha: sig.sig_sha,
tool,
raw: sig.raw,
normalised: sig.normalised,
resolution,
seen: 1,
last_seen: ev.ts.clone(),
cwd: ev.cwd.clone(),
confidence: Confidence::Inferred,
});
}
out
}
/// Commands that record that a human did something, without recording what.
///
/// `vim package.json` is a true observation and a useless lesson. Filtering
/// these is the difference between "someone edited a file" and an actionable
/// resolution. A pair whose entire delta is opaque yields no lesson at all --
/// abstention again, at write time.
fn is_opaque_action(cmd: &str) -> bool {
let first = cmd.split_whitespace().next().unwrap_or("");
let base = first.rsplit('/').next().unwrap_or(first);
matches!(
base,
"vim" | "vi" | "nvim" | "nano" | "emacs" | "code" | "subl" | "open"
| "cd" | "ls" | "cat" | "less" | "tail" | "head" | "pwd" | "echo"
| "clear" | "which" | "man"
) || cmd.trim() == "git status"
}
/// Guess the tool from a command line.
pub fn tool_of_cmd(cmd: &str) -> String {
let first = cmd.split_whitespace().next().unwrap_or("");
let base = first.rsplit('/').next().unwrap_or(first);
match base {
"npm" | "pnpm" | "yarn" => "npm".into(),
"cargo" => "cargo".into(),
"go" => "go".into(),
"kubectl" | "k" => "kubectl".into(),
"docker" | "podman" => "docker".into(),
"terraform" | "tofu" => "terraform".into(),
other if other.is_empty() => "unknown".into(),
other => other.to_string(),
}
}
// ---------------------------------------------------------------------------
// Materialisation -- Claude Code conventions
// ---------------------------------------------------------------------------
/// Render lessons for one tool as a SKILL.md.
///
/// The `description` field is the load-bearing part: it lists the error strings
/// this skill explains, so a harness doing progressive disclosure matches on
/// symptoms rather than on prose. This is a hand-rule symptom projection --
/// the same job M3.7.8 gives an LLM, done for free at materialise time.
pub fn render_skill(tool: &str, lessons: &[Lesson]) -> String {
let mut triggers: Vec<String> = lessons
.iter()
.map(|l| {
let t = l.raw.trim();
let t: String = t.chars().take(90).collect();
t.replace('"', "'")
})
.collect();
triggers.sort();
triggers.dedup();
let mut s = String::new();
s.push_str("---\n");
s.push_str(&format!("name: {tool}-failures\n"));
s.push_str("description: >\n");
s.push_str(&format!(
" Past {tool} failures seen in this workspace and what resolved them.\n"
));
s.push_str(" Use when a ");
s.push_str(tool);
s.push_str(" command fails, or when output contains any of:\n");
for t in triggers.iter().take(12) {
s.push_str(&format!(" \"{t}\";\n"));
}
s.push_str("---\n\n");
s.push_str(&format!("# {tool} failures\n\n"));
s.push_str("Generated by `mem materialize`. Edit freely -- edits mark a lesson\n");
s.push_str("`confirmed`, which outranks inferred lessons at equal similarity.\n\n");
let mut sorted: Vec<&Lesson> = lessons.iter().collect();
sorted.sort_by(|a, b| b.seen.cmp(&a.seen));
for l in sorted {
s.push_str(&format!("## {}\n\n", l.raw.trim()));
s.push_str(&format!(
"- seen: {} | last: {} | confidence: {:?}\n",
l.seen, l.last_seen, l.confidence
));
s.push_str(&format!("- signature: `{}`\n", l.sig_sha[..12].to_string()));
s.push_str("- resolved by:\n");
for r in &l.resolution {
s.push_str(&format!(" ```\n {r}\n ```\n"));
}
if l.seen >= 3 {
s.push_str(
"- **recurring** -- this has bitten us repeatedly. Prefer fixing the\n root cause over reapplying the workaround.\n",
);
}
s.push('\n');
}
s
}
/// Render the compact block for injection at failure time.
///
/// Hard-capped, because every injected token displaces the task. Two hundred
/// tokens of "you hit this in July, fix was X" beats a page of adjacent docs.
pub fn render_injection(hit: &Hit, max_chars: usize) -> String {
let l = &hit.lesson;
let header = match hit.tier {
Tier::Exact => format!(
"MEMORY (exact match, seen {}x, last {}):",
l.seen, l.last_seen
),
Tier::Similar(s) => format!("MEMORY (similar failure, {:.0}% match):", s * 100.0),
};
let mut s = format!("{header}\n {}\n resolved by:\n", l.raw.trim());
for r in &l.resolution {
s.push_str(&format!(" {r}\n"));
}
if l.seen >= 3 {
s.push_str(" NOTE: recurring - consider fixing the root cause.\n");
}
if s.len() > max_chars {
s.truncate(max_chars);
s.push_str("...\n");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_ansi() {
assert_eq!(strip_ansi("\u{1b}[31merror\u{1b}[0m: x"), "error: x");
}
#[test]
fn normalises_volatiles_but_keeps_exit_codes() {
let a = normalise("at 2026-08-21T10:02:11.482Z /home/runner/work/o/r/src/main.rs:42:5 took 4m21s sha 9f3ab12c4d");
assert!(a.contains("<TS>"), "{a}");
assert!(a.contains("<PATH>/main.rs:<LINE>:<COL>"), "{a}");
assert!(a.contains("<DUR>"), "{a}");
assert!(a.contains("<SHA>"), "{a}");
// meaning-bearing numbers survive
let b = normalise("exit status 137");
assert!(b.contains("137"), "{b}");
assert_ne!(normalise("exit status 137"), normalise("exit status 1"));
}
#[test]
fn same_failure_different_runs_same_hash() {
let run1 = "2026-08-01T10:00:00Z Run 4821\nnpm ERR! ERESOLVE unable to resolve dependency tree\nnpm ERR! A complete log of this run can be found in: /home/runner/.npm/_logs/x.log\n##[error]Process completed with exit code 1";
let run2 = "2026-09-14T22:31:07Z Run 9903\nnpm ERR! ERESOLVE unable to resolve dependency tree\nnpm ERR! A complete log of this run can be found in: /Users/rock/.npm/_logs/y.log\n##[error]Process completed with exit code 1";
let a = extract("npm", run1).unwrap();
let b = extract("npm", run2).unwrap();
assert_eq!(a.sig_sha, b.sig_sha);
assert_eq!(a.rule, "tool-rule");
}
#[test]
fn different_failures_differ() {
let a = extract("npm", "npm ERR! ERESOLVE unable to resolve dependency tree").unwrap();
let b = extract("npm", "npm ERR! 404 Not Found - GET https://registry.npmjs.org/nope").unwrap();
assert_ne!(a.sig_sha, b.sig_sha);
}
#[test]
fn cascade_lines_are_skipped() {
let log = "##[error]Process completed with exit code 1\nerror: could not compile `foo`\nerror[E0308]: mismatched types";
let s = extract("cargo", log).unwrap();
assert!(s.raw.contains("E0308"), "picked cascade line: {}", s.raw);
}
#[test]
fn code_declaration_does_not_split_a_failure() {
// Found by fixture: run A emits the `code` line, run C does not.
let with = "npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree";
let without = "npm ERR! ERESOLVE unable to resolve dependency tree";
assert_eq!(
extract("npm", with).unwrap().sig_sha,
extract("npm", without).unwrap().sig_sha
);
}
#[test]
fn cmd_key_ignores_temp_file_names() {
let a = Event {
ts: "t".into(),
cwd: "/w".into(),
cmd: "kubectl apply -f /tmp/np-x7f2.yaml".into(),
exit: 1,
output: String::new(),
};
let b = Event {
cmd: "kubectl apply -f /tmp/np-a91c.yaml".into(),
..a.clone()
};
assert_eq!(a.cmd_key(), b.cmd_key());
// but a genuinely different action must not collide
let c = Event {
cmd: "kubectl delete -f /tmp/np-a91c.yaml".into(),
..a.clone()
};
assert_ne!(a.cmd_key(), c.cmd_key());
}
#[test]
fn error_lines_still_keep_basenames() {
// The cmd_key fix must not leak into error normalisation: which file
// failed to compile is meaningful.
assert!(normalise("error at /w/src/main.rs:4:2").contains("main.rs"));
}
#[test]
fn tool_is_part_of_identity() {
let a = extract("npm", "error: boom").unwrap();
let b = extract("cargo", "error: boom").unwrap();
assert_ne!(a.sig_sha, b.sig_sha);
}
#[test]
fn unknown_tool_falls_back() {
let s = extract("frobnicate", "something went sideways").unwrap();
assert_eq!(s.rule, "last-line-fallback");
}
#[test]
fn derives_lesson_from_fail_then_success() {
let ev = |ts: &str, cmd: &str, exit: i32, out: &str| Event {
ts: ts.into(),
cwd: "/w".into(),
cmd: cmd.into(),
exit,
output: out.into(),
};
let events = vec![
ev("t1", "npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"),
ev("t2", "npm pkg set overrides.react=19", 0, ""),
ev("t3", "npm ci", 0, "ok"),
];
let ls = derive_lessons(&events, |c| tool_of_cmd(c));
assert_eq!(ls.len(), 1);
assert_eq!(ls[0].resolution, vec!["npm pkg set overrides.react=19"]);
assert_eq!(ls[0].confidence, Confidence::Inferred);
}
#[test]
fn opaque_edits_do_not_become_a_resolution() {
let ev = |cmd: &str, exit: i32, out: &str| Event {
ts: "t".into(),
cwd: "/w".into(),
cmd: cmd.into(),
exit,
output: out.into(),
};
// only an editor between fail and success -> no lesson
let only_vim = vec![
ev("npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"),
ev("vim package.json", 0, ""),
ev("npm ci", 0, "ok"),
];
assert!(derive_lessons(&only_vim, tool_of_cmd).is_empty());
// a real command survives, and the editor is dropped from it
let mixed = vec![
ev("npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"),
ev("vim package.json", 0, ""),
ev("npm pkg set overrides.react=19", 0, ""),
ev("npm ci", 0, "ok"),
];
let ls = derive_lessons(&mixed, tool_of_cmd);
assert_eq!(ls[0].resolution, vec!["npm pkg set overrides.react=19"]);
}
#[test]
fn failed_attempts_are_not_the_resolution() {
let ev = |cmd: &str, exit: i32, out: &str| Event {
ts: "t".into(),
cwd: "/w".into(),
cmd: cmd.into(),
exit,
output: out.into(),
};
let events = vec![
ev("cargo build", 1, "error[E0308]: mismatched types"),
ev("cargo fix --broken", 1, "error: no"),
ev("cargo add serde", 0, ""),
ev("cargo build", 0, "ok"),
];
let ls = derive_lessons(&events, tool_of_cmd);
assert_eq!(ls[0].resolution, vec!["cargo add serde"]);
}
#[test]
fn bare_retry_is_not_a_lesson() {
let ev = |cmd: &str, exit: i32| Event {
ts: "t".into(),
cwd: "/w".into(),
cmd: cmd.into(),
exit,
output: "error: flaky".into(),
};
let events = vec![ev("npm ci", 1), ev("npm ci", 0)];
assert!(derive_lessons(&events, |c| tool_of_cmd(c)).is_empty());
}
#[test]
fn lookup_prefers_exact_then_abstains() {
let l = Lesson {
sig_sha: "abc".into(),
tool: "npm".into(),
raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
normalised: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
resolution: vec!["npm ci --legacy-peer-deps".into()],
seen: 2,
last_seen: "t".into(),
cwd: "/w".into(),
confidence: Confidence::Inferred,
};
let exact = Signature {
tool: "npm".into(),
raw: "x".into(),
normalised: "x".into(),
sig_sha: "abc".into(),
rule: "r".into(),
};
assert_eq!(lookup(&exact, &[l.clone()], 0.5).unwrap().tier, Tier::Exact);
let unrelated = Signature {
tool: "npm".into(),
raw: "y".into(),
normalised: "totally different disk full message".into(),
sig_sha: "zzz".into(),
rule: "r".into(),
};
assert!(
lookup(&unrelated, &[l], 0.5).is_none(),
"must abstain rather than return a weak match"
);
}
#[test]
fn similar_wording_still_matches() {
let l = Lesson {
sig_sha: "abc".into(),
tool: "npm".into(),
raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
normalised: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
resolution: vec!["npm ci --legacy-peer-deps".into()],
seen: 1,
last_seen: "t".into(),
cwd: "/w".into(),
confidence: Confidence::Inferred,
};
let sig = extract("npm", "npm ERR! ERESOLVE could not resolve dependency tree").unwrap();
let hit = lookup(&sig, &[l], 0.5).expect("should match on wording drift");
assert!(matches!(hit.tier, Tier::Similar(s) if s > 0.5));
}
#[test]
fn skill_description_lists_symptoms_not_summary() {
let l = Lesson {
sig_sha: "abc123def456".into(),
tool: "npm".into(),
raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
normalised: "n".into(),
resolution: vec!["npm ci --legacy-peer-deps".into()],
seen: 3,
last_seen: "t".into(),
cwd: "/w".into(),
confidence: Confidence::Inferred,
};
let md = render_skill("npm", &[l]);
assert!(md.starts_with("---\n"));
assert!(md.contains("name: npm-failures"));
// the trigger string, not a paraphrase
assert!(md.contains("ERESOLVE unable to resolve dependency tree"));
assert!(md.contains("recurring"));
}
#[test]
fn injection_is_capped() {
let l = Lesson {
sig_sha: "a".into(),
tool: "npm".into(),
raw: "npm ERR! boom".into(),
normalised: "n".into(),
resolution: vec!["x".repeat(500)],
seen: 1,
last_seen: "t".into(),
cwd: "/w".into(),
confidence: Confidence::Inferred,
};
let out = render_injection(&Hit { lesson: l, tier: Tier::Exact }, 200);
assert!(out.len() <= 204, "len {}", out.len());
}
}
+14
View File
@@ -1,5 +1,19 @@
pub mod domain;
pub mod lesson;
pub mod query;
pub mod prompt;
pub mod gate_parser;
pub mod gated_loop;
pub mod query_executor;
pub use gate_parser::{GateResponse, ParseError, parse_gate_response};
pub use domain::{
Chunk, Level, MemoryNode, Provenance, Record, Role, ProjectId, QueryId, RunId, Sha256Hash,
};
pub use lesson::{
derive_lessons, extract, lookup, normalise, render_injection, render_skill, similarity,
tool_of_cmd, Confidence, Event, Hit, Lesson, Signature, Tier,
};
pub use query::{Query, QuerySet, SynthesisQuery};
pub use prompt::PromptBuilder;
+165
View File
@@ -0,0 +1,165 @@
use crate::domain::{Chunk, Role};
use crate::query::Query;
use anyhow::{anyhow, Result};
const SYSTEM_PROMPT: &str = include_str!("../../../templates/gru-mem.txt");
const BUDGET_TOTAL: usize = 32768;
const BUDGET_RESPONSE: usize = 2048;
const BUDGET_SYSTEM: usize = 400;
const BUDGET_QUESTION: usize = 150;
const BUDGET_MEMORY_MAX: usize = 1024;
const BUDGET_CHUNK_MAX: usize = 5000;
/// Builds a GRU-Mem prompt for the update gate.
pub struct PromptBuilder;
impl PromptBuilder {
/// Assemble system and user prompts for a single gate turn.
///
/// # Arguments
/// * `query` - Standing question providing the problem statement
/// * `previous_memory` - Prior memory from turn t-1, or None for t=1
/// * `chunk` - The evidence chunk to evaluate
///
/// # Returns
/// `(system_prompt, user_message)` tuple
pub fn build(query: &Query, previous_memory: Option<&str>, chunk: &Chunk) -> Result<(String, String)> {
// Render chunk as "[role] text" lines separated by blank lines
let chunk_text = Self::render_chunk(chunk)?;
let chunk_bytes = chunk_text.len();
// Memory: "No previous memory" at t=1, otherwise the given memory
let memory_text = previous_memory.unwrap_or("No previous memory");
// Check memory budget
if memory_text.len() > BUDGET_MEMORY_MAX {
return Err(anyhow!(
"Memory budget exceeded: {} > {} tokens",
memory_text.len() / 4, // rough estimate
BUDGET_MEMORY_MAX / 4
));
}
// Check chunk budget
if chunk_bytes > BUDGET_CHUNK_MAX {
return Err(anyhow!(
"Chunk budget exceeded: {} > {} bytes",
chunk_bytes,
BUDGET_CHUNK_MAX
));
}
// Assemble the user message by substituting into the template
let user_message = SYSTEM_PROMPT
.replace("{prompt}", &query.question)
.replace("{memory}", memory_text)
.replace("{chunk}", &chunk_text);
// Check total budget (rough: 4 chars ≈ 1 token)
let total_tokens = (SYSTEM_PROMPT.len() + query.question.len() + memory_text.len() + chunk_bytes) / 4;
if total_tokens + BUDGET_RESPONSE > BUDGET_TOTAL {
return Err(anyhow!(
"Total prompt budget exceeded: {} + {} (response) > {} tokens",
total_tokens,
BUDGET_RESPONSE,
BUDGET_TOTAL
));
}
Ok((SYSTEM_PROMPT.to_string(), user_message))
}
/// Render a chunk as formatted text with role labels.
fn render_chunk(chunk: &Chunk) -> Result<String> {
let mut lines = Vec::new();
for record in &chunk.records {
let role_label = match record.role {
Role::User => "[User]",
Role::Assistant => "[Assistant]",
Role::ToolResult => "[ToolResult]",
Role::System => "[System]",
};
let text = format!("{} {}", role_label, record.text);
lines.push(text);
}
Ok(lines.join("\n\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{Chunk, Record, Role, Provenance};
use time::OffsetDateTime;
#[test]
fn test_render_chunk_single_record() {
let chunk = Chunk::new(
1,
vec![
Record {
role: Role::User,
text: "Hello".to_string(),
timestamp: OffsetDateTime::now_utc(),
provenance: Provenance {
source_id: "test".to_string(),
offset: 0,
},
},
],
10,
);
let rendered = PromptBuilder::render_chunk(&chunk).unwrap();
assert!(rendered.contains("[User]"));
assert!(rendered.contains("Hello"));
}
#[test]
fn test_render_chunk_multiple_roles() {
let chunk = Chunk::new(
1,
vec![
Record {
role: Role::User,
text: "What is 2+2?".to_string(),
timestamp: OffsetDateTime::now_utc(),
provenance: Provenance {
source_id: "test".to_string(),
offset: 0,
},
},
Record {
role: Role::Assistant,
text: "The answer is 4".to_string(),
timestamp: OffsetDateTime::now_utc(),
provenance: Provenance {
source_id: "test".to_string(),
offset: 0,
},
},
Record {
role: Role::ToolResult,
text: "Tool confirmed: 4".to_string(),
timestamp: OffsetDateTime::now_utc(),
provenance: Provenance {
source_id: "test".to_string(),
offset: 0,
},
},
],
30,
);
let rendered = PromptBuilder::render_chunk(&chunk).unwrap();
assert!(rendered.contains("[User]"));
assert!(rendered.contains("[Assistant]"));
assert!(rendered.contains("[ToolResult]"));
// Check that records are separated by blank lines
assert!(rendered.contains("\n\n"));
}
}
+249
View File
@@ -0,0 +1,249 @@
use crate::domain::{ProjectId, QueryId};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// A single standing query.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Query {
pub id: String,
pub question: String,
#[serde(default)]
pub exit_gate: bool,
}
/// Synthesis query (optional, for L2).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SynthesisQuery {
pub question: String,
#[serde(default)]
pub exit_gate: bool,
}
/// Defaults applied to queries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Defaults {
#[serde(default = "default_memory_budget")]
pub memory_budget: u32,
#[serde(default = "default_chunk_tokens")]
pub chunk_tokens: u32,
#[serde(default)]
pub exit_gate: bool,
}
fn default_memory_budget() -> u32 {
1024
}
fn default_chunk_tokens() -> u32 {
5000
}
impl Default for Defaults {
fn default() -> Self {
Self {
memory_budget: default_memory_budget(),
chunk_tokens: default_chunk_tokens(),
exit_gate: false,
}
}
}
/// Complete set of queries for a project.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuerySet {
pub project: String,
pub roots: Vec<String>,
pub sources: Vec<String>,
pub queries: Vec<Query>,
#[serde(default)]
pub synthesis: Option<SynthesisQuery>,
#[serde(default)]
pub defaults: Defaults,
}
/// Load error with context.
#[derive(Debug, Clone)]
pub struct QueryLoadError {
pub file: String,
pub query_id: Option<String>,
pub field: Option<String>,
pub message: String,
}
impl std::fmt::Display for QueryLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.query_id, &self.field) {
(Some(id), Some(field)) => {
write!(f, "{}: query '{}', field '{}': {}", self.file, id, field, self.message)
}
(Some(id), None) => {
write!(f, "{}: query '{}': {}", self.file, id, self.message)
}
(None, Some(field)) => {
write!(f, "{}: field '{}': {}", self.file, field, self.message)
}
(None, None) => {
write!(f, "{}: {}", self.file, self.message)
}
}
}
}
impl std::error::Error for QueryLoadError {}
/// Valid charset for query ids: lowercase, digits, hyphens only.
fn is_valid_query_id(id: &str) -> bool {
!id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
impl QuerySet {
/// Load and validate a query set from a YAML file.
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let filename = path.to_string_lossy().to_string();
let contents = std::fs::read_to_string(path)?;
// Parse YAML
let mut set: QuerySet = serde_yaml::from_str(&contents)
.map_err(|e| anyhow!("Failed to parse {}: {}", filename, e))?;
// Validate project
if set.project.trim().is_empty() {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: None,
field: Some("project".to_string()),
message: "project field is required and cannot be empty".to_string(),
}));
}
// Validate at least one query
if set.queries.is_empty() {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: None,
field: Some("queries".to_string()),
message: "at least one query is required".to_string(),
}));
}
// Validate each query
let mut seen_ids = std::collections::HashSet::new();
for query in &mut set.queries {
// Check ID is not empty
if query.id.trim().is_empty() {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: None,
field: Some("id".to_string()),
message: "query id cannot be empty".to_string(),
}));
}
// Check ID charset
if !is_valid_query_id(&query.id) {
return Err(anyhow!(QueryLoadError {
file: filename.clone(),
query_id: Some(query.id.clone()),
field: Some("id".to_string()),
message: format!(
"query id '{}' must match [a-z0-9-]+ (it becomes a filename)",
query.id
),
}));
}
// Check for duplicate IDs
if seen_ids.contains(&query.id) {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: Some(query.id.clone()),
field: Some("id".to_string()),
message: format!("duplicate query id '{}'", query.id),
}));
}
seen_ids.insert(query.id.clone());
// Check question is not empty
if query.question.trim().is_empty() {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: Some(query.id.clone()),
field: Some("question".to_string()),
message: "question cannot be empty".to_string(),
}));
}
// Apply defaults if exit_gate not set
// (defaults already applied via serde default)
}
// Validate synthesis if present
if let Some(ref synthesis) = set.synthesis {
if synthesis.question.trim().is_empty() {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: None,
field: Some("synthesis.question".to_string()),
message: "synthesis question cannot be empty".to_string(),
}));
}
}
// Validate defaults
if set.defaults.memory_budget == 0 {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: None,
field: Some("defaults.memory_budget".to_string()),
message: "memory_budget must be greater than 0".to_string(),
}));
}
if set.defaults.chunk_tokens == 0 {
return Err(anyhow!(QueryLoadError {
file: filename,
query_id: None,
field: Some("defaults.chunk_tokens".to_string()),
message: "chunk_tokens must be greater than 0".to_string(),
}));
}
Ok(set)
}
/// Get a query by ID.
pub fn query(&self, id: &str) -> Option<&Query> {
self.queries.iter().find(|q| q.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_query_id() {
assert!(is_valid_query_id("architecture-decisions"));
assert!(is_valid_query_id("infra-root-causes"));
assert!(is_valid_query_id("id123"));
assert!(is_valid_query_id("a"));
assert!(is_valid_query_id("a-b-c-123"));
assert!(!is_valid_query_id(""));
assert!(!is_valid_query_id("infra/root-causes"));
assert!(!is_valid_query_id("UPPERCASE"));
assert!(!is_valid_query_id("with space"));
assert!(!is_valid_query_id("with_underscore"));
}
#[test]
fn test_defaults() {
let defaults = Defaults::default();
assert_eq!(defaults.memory_budget, 1024);
assert_eq!(defaults.chunk_tokens, 5000);
assert!(!defaults.exit_gate);
}
}
+99
View File
@@ -0,0 +1,99 @@
use crate::{Level, Query};
use anyhow::Result;
use serde::{Deserialize, Serialize};
/// Query result with provenance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub level: Level,
pub score: f32,
pub text: String,
pub provenance: Vec<String>,
}
/// Query executor (orchestrates recall → rerank → edge walk).
pub struct QueryExecutor {
// Would hold pgvector client, embedder, reranker
// For now: proof-of-concept with mock data
}
impl QueryExecutor {
/// Create executor.
pub fn new() -> Self {
Self {}
}
/// Execute query: embed → recall → rerank → provenance walk.
pub fn query(
&self,
question: &str,
levels: &[Level],
k: usize,
) -> Result<Vec<QueryResult>> {
if question.is_empty() {
return Ok(vec![]);
}
// In real implementation:
// 1. Embed question
// 2. Recall top 10k from pgvector filtered by levels
// 3. Rerank to k
// 4. Walk edges for provenance
// For now: return mock results to prove structure
let default_results = vec![
QueryResult {
level: Level::L1,
score: 0.95,
text: "Infrastructure root causes".to_string(),
provenance: vec!["pi-2026-07-21-xyz".to_string()],
},
QueryResult {
level: Level::L2,
score: 0.87,
text: "System synthesis".to_string(),
provenance: vec!["L1-abc".to_string()],
},
];
// Filter by levels
let filtered: Vec<_> = default_results
.into_iter()
.filter(|r| levels.contains(&r.level))
.take(k)
.collect();
Ok(filtered)
}
}
/// Query format (human-readable or JSON).
#[derive(Debug, Clone, Copy)]
pub enum QueryFormat {
Text,
Json,
}
/// Render results.
pub fn render_results(results: &[QueryResult], format: QueryFormat) -> String {
match format {
QueryFormat::Json => serde_json::to_string_pretty(results).unwrap_or_default(),
QueryFormat::Text => {
let mut output = String::new();
for (i, r) in results.iter().enumerate() {
output.push_str(&format!(
"{}. [{:?}] score={:.2}\n{}\n",
i + 1,
r.level,
r.score,
r.text
));
for prov in &r.provenance {
output.push_str(&format!(" - {}\n", prov));
}
output.push('\n');
}
output
}
}
}