use super::*;
impl Space {
pub(super) fn kick_memory_self_test(self: &Arc<Self>) {
if self.memory_policy().self_test_queries_per_cycle == 0 {
return;
}
let space = self.clone();
tokio::spawn(async move {
match space.run_memory_self_test(unix_ms()).await {
Ok(Some(report)) => {
log::info!(
target: "brain",
space_id = space.id,
report:serde = report;
"memory self-test completed"
);
}
Ok(None) => {}
Err(err) => {
log::warn!(
target: "brain",
space_id = space.id;
"memory self-test failed: {err:?}"
);
}
}
});
}
pub(super) async fn run_memory_self_test(
&self,
now_ms: u64,
) -> Result<Option<SelfTestReport>, BoxError> {
let Ok(_guard) = self.self_test_lock.try_lock() else {
return Ok(None);
};
let policy = self.memory_policy();
let budget = policy.self_test_queries_per_cycle as usize;
if budget == 0 {
return Ok(None);
}
let cursor: SelfTestCursor = self
.db
.get_extension_as("memory_self_test_cursor")
.unwrap_or_default();
let window = budget * 4;
let response = self
.execute_kip_readonly(kip::request_with(
format!(
r#"FIND(?p.id, ?p._system.space_seq, ?s, ?o)
WHERE {{
?p (?s, ?predicate, ?o)
FILTER(?p._system.space_seq > :after)
}}
ORDER BY ?p._system.space_seq
LIMIT {window}"#
),
kip::param("after", cursor.after),
))
.await?;
if assess::single_read_result(&response).is_err() {
log::error!(
target: "brain",
space_id = self.id;
"self-test sampling scan failed — dream self-test is NOT running \
(graph past the full-scan engine cap?): {}",
kip::error_message(&response)
);
return Ok(None);
}
let sampled = self_test_candidates(assess::single_read_result(&response)?);
let reached_end = sampled.len() < window;
let sampled_after = sampled.last().map(|c| c.seq).unwrap_or(cursor.after);
let mut candidates = Vec::new();
for candidate in sampled {
let usage = self.ledger.get(&candidate.id).await?;
if usage
.as_ref()
.is_none_or(|row| row.recall_count == 0 && row.self_test_count == 0)
{
candidates.push(candidate);
}
if candidates.len() >= budget {
break;
}
}
candidates.retain(|candidate| !candidate.subject_name.is_empty());
if candidates.is_empty() {
let wrap =
reached_end && now_ms.saturating_sub(cursor.cycled_at) >= SELF_TEST_RETEST_MS;
self.db
.save_extension_from(
"memory_self_test_cursor".into(),
&SelfTestCursor {
after: if wrap { 0 } else { sampled_after },
cycled_at: if wrap { now_ms } else { cursor.cycled_at },
},
)
.await?;
return Ok(None);
}
let max_prompt_chars = (policy.self_test_token_budget as usize).saturating_mul(3);
while candidates.len() > 1
&& serde_json::to_string(&candidates)
.map(|prompt| prompt.len() > max_prompt_chars)
.unwrap_or(false)
{
candidates.pop();
}
let output = assess::AssessContext::complete(
self,
anda_core::CompletionRequest {
instructions: SELF_TEST_INSTRUCTIONS.to_string(),
prompt: serde_json::to_string_pretty(&candidates).unwrap_or_default(),
effort: Some(anda_core::ModelEffort::Low),
..Default::default()
},
)
.await?;
let queries: SelfTestQueries = assess::parse_json_payload(&output.content)?;
let mut report = SelfTestReport {
tested_at: now_ms,
usage: output.usage,
..Default::default()
};
let budget_tokens = report
.usage
.input_tokens
.saturating_add(report.usage.output_tokens);
if budget_tokens > policy.self_test_token_budget {
log::warn!(
target: "brain",
space_id = self.id;
"memory self-test used {budget_tokens} tokens, over policy budget {}",
policy.self_test_token_budget
);
}
let mut tested_entities = BTreeSet::new();
for candidate in &candidates {
let Some(query) = queries
.queries
.iter()
.find(|query| query.id == candidate.id)
.map(|query| query.query.trim())
.filter(|query| !query.is_empty())
else {
return Err(
"self-test query generation did not cover every selected candidate".into(),
);
};
let response = self
.execute_kip_readonly(kip::request_with(
"SEARCH CONCEPT :query LIMIT 8",
kip::param("query", query),
))
.await?;
let observation = assess::search_observation(&response)?;
let result = assess::single_read_result(&response)?;
let mut hit_ids = BTreeSet::new();
assess::collect_entity_objects(result, &mut |id, _| {
hit_ids.insert(id.to_string());
});
let grounded =
hit_ids.contains(&candidate.subject) || hit_ids.contains(&candidate.object);
if !grounded && observation.exhaustive != Some(true) {
return Err(
"self-test grounding is unknown under incomplete search coverage".into(),
);
}
report.tested += 1;
tested_entities.insert(candidate.id.clone());
if grounded {
report.grounded += 1;
continue;
}
if self.has_pending_review_task(&candidate.subject).await? {
continue;
}
match self
.run_kip_settlement(self_test_task_request(candidate, query, now_ms))
.await
{
Ok(response) if kip::succeeded(&response) => report.reencode_tasks += 1,
Ok(response) => {
log::warn!(
target: "brain",
space_id = self.id;
"self-test SleepTask creation failed: {}",
kip::error_message(&response)
);
}
Err(err) => {
log::warn!(
target: "brain",
space_id = self.id;
"self-test SleepTask creation failed: {err:?}"
);
}
}
}
self.ledger
.record_self_test(&tested_entities, now_ms)
.await?;
self.bump_metrics(|metrics| {
metrics.self_test_tested += report.tested;
metrics.self_test_grounded += report.grounded;
metrics.reencode_tasks += report.reencode_tasks;
});
self.db
.set_extension_from("memory_self_test".to_string(), report.clone());
let next_after = candidates.last().map(|c| c.seq).unwrap_or(cursor.after);
let wrap = reached_end
&& next_after == sampled_after
&& now_ms.saturating_sub(cursor.cycled_at) >= SELF_TEST_RETEST_MS;
self.db
.save_extension_from(
"memory_self_test_cursor".into(),
&SelfTestCursor {
after: if wrap { 0 } else { next_after },
cycled_at: if wrap { now_ms } else { cursor.cycled_at },
},
)
.await?;
self.db.flush_metadata(now_ms).await?;
Ok(Some(report))
}
async fn has_pending_review_task(&self, target: &str) -> Result<bool, BoxError> {
let response = self
.execute_kip_readonly(kip::request_with(
r#"FIND(?task) WHERE {
?task CONCEPT {type: "SleepTask"}
FILTER(?task.attributes.status == "pending")
?about CONCEPT {id: :target}
STRUCTURAL (?task, "about", ?about)
} LIMIT 1"#,
kip::param("target", target),
))
.await?;
Ok(kip::ok_result(&response).is_some_and(|result| {
let mut found = false;
assess::collect_entity_objects(result, &mut |_, _| found = true);
found
}))
}
}
const SELF_TEST_RETEST_MS: u64 = 30 * 24 * 3_600 * 1_000;
#[derive(Debug, Clone, Copy, Default, serde::Deserialize, serde::Serialize)]
struct SelfTestCursor {
after: u64,
cycled_at: u64,
}
#[derive(Debug, serde::Serialize)]
struct SelfTestCandidate {
id: String,
#[serde(skip)]
seq: u64,
subject: String,
object: String,
subject_type: String,
subject_name: String,
object_name: String,
}
fn self_test_candidates(result: &serde_json::Value) -> Vec<SelfTestCandidate> {
let Some(rows) = result.as_array() else {
return Vec::new();
};
rows.iter()
.filter_map(|row| {
let columns = row.as_array()?;
let id = columns.first().and_then(serde_json::Value::as_str)?;
let seq = columns.get(1).and_then(serde_json::Value::as_u64)?;
let subject = columns.get(2)?;
let object = columns.get(3);
Some(SelfTestCandidate {
id: id.to_string(),
seq,
subject: element_field(subject, "id"),
object: object.map(|o| element_field(o, "id")).unwrap_or_default(),
subject_type: assess::local_symbol_name(&element_field(subject, "schema_ref"))
.to_string(),
subject_name: element_field(subject, "name"),
object_name: object.map(element_label).unwrap_or_default(),
})
})
.collect()
}
fn element_field(value: &serde_json::Value, field: &str) -> String {
value
.get(field)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
}
fn element_label(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Object(_) => element_field(value, "name"),
other if other.is_null() => String::new(),
other => other.to_string(),
}
}
#[derive(Debug, Default, serde::Deserialize)]
struct SelfTestQueries {
#[serde(default)]
queries: Vec<SelfTestQuery>,
}
#[derive(Debug, serde::Deserialize)]
struct SelfTestQuery {
id: String,
query: String,
}
const SELF_TEST_INSTRUCTIONS: &str = r#"You test the searchability of an AI's memory graph. You will receive a JSON array of memories, each a proposition with a subject, predicate, and object.
For each memory, write ONE short natural-language query a real user would plausibly ask that this memory should answer. Use the everyday words of the subject/object names — never internal ids, never the predicate name verbatim unless a user would say it.
Respond with ONLY a JSON object:
{"queries": [{"id": "<memory id>", "query": "..."}]}"#;
fn self_test_task_request(candidate: &SelfTestCandidate, query: &str, now_ms: u64) -> Request {
let summary = format!(
"memory self-test: the query {query:?} did not surface `{}` ({}) via search; re-encode the \
Concept with aliases, a richer description, or links to neighbouring memory so it becomes \
findable",
candidate.subject_name, candidate.id
);
let parameters = serde_json::Map::from_iter([
(
"key".to_string(),
serde_json::Value::from(format!("self_test:{}", candidate.subject)),
),
(
"name".to_string(),
serde_json::Value::from(format!("Re-encode {}", candidate.subject_name)),
),
("summary".to_string(), serde_json::Value::from(summary)),
(
"created_at".to_string(),
serde_json::Value::from(kip::timestamp(now_ms)),
),
(
"target".to_string(),
serde_json::Value::from(candidate.subject.clone()),
),
]);
kip::request_with(
r#"UPSERT CONCEPT ?task {
MATCH { type: "SleepTask", key: :key }
SET FIELDS { name: :name }
SET ATTRIBUTES {
task_class: "consolidate",
summary: :summary,
status: "pending",
priority: 2,
created_at: :created_at
}
SET STRUCTURAL { ("about", :target) }
}"#,
parameters,
)
}