use anyhow::{Result, bail};
use std::collections::HashMap;
use crate::engage::{MatchResult, fuzzy_match};
use crate::knowledge::KnowledgeEntry;
use crate::store::{AgentContext, KnowledgeStore, WakeCascade};
use crate::wake_chunk::{
ChunkPlan, PhraseMatch, chunk_threshold, compare_phrase, compute_chunks, extract_auto_phrase,
extract_salient_phrase,
};
use crate::wake_guess::{Bucket, MatchKind, WakeGuessRow, content_hash};
use crate::wake_token::*;
struct ChunkPhrases {
phrases: Vec<String>,
source: PhraseSource,
}
fn phrases_for_chunk(
entry: &KnowledgeEntry,
chunk_idx: u16,
chunk_total: u16,
chunk_content: &str,
) -> ChunkPhrases {
let authored = authored_phrases(entry);
if authored.is_empty() {
return ChunkPhrases {
phrases: vec![extract_auto_phrase(chunk_content, &entry.title)],
source: PhraseSource::Auto,
};
}
if (chunk_idx as usize) < authored.len() {
return ChunkPhrases {
phrases: authored,
source: PhraseSource::Authored,
};
}
ChunkPhrases {
phrases: vec![extract_salient_phrase(
chunk_content,
chunk_idx,
chunk_total,
)],
source: PhraseSource::Derived,
}
}
fn best_match(guess: &str, phrases: &[String]) -> (MatchKind, Option<usize>) {
let mut best: Option<usize> = None;
for (idx, phrase) in phrases.iter().enumerate() {
let kind = match compare_phrase(guess, phrase) {
PhraseMatch::Exact => MatchKind::Exact,
PhraseMatch::Tolerant => MatchKind::Close,
PhraseMatch::Mismatch => match fuzzy_match(guess, phrase) {
MatchResult::Exact => MatchKind::Exact,
MatchResult::Close => MatchKind::Close,
MatchResult::Partial | MatchResult::Wrong => MatchKind::None,
},
};
match kind {
MatchKind::Exact => return (MatchKind::Exact, Some(idx)),
MatchKind::Close if best.is_none() => best = Some(idx),
_ => {}
}
}
match best {
Some(idx) => (MatchKind::Close, Some(idx)),
None => (MatchKind::None, None),
}
}
fn total_chunks_across_cascade(
session: &WakeSession,
blooms: &HashMap<String, KnowledgeEntry>,
) -> usize {
let threshold = chunk_threshold();
let mut total: usize = 0;
for id in &session.bloom_ids {
if let Some(entry) = blooms.get(id) {
let content = bloom_content(entry);
let plan = compute_chunks(&content, threshold);
total += plan.total as usize;
} else {
total += 1; }
}
total
}
fn bloom_content(entry: &KnowledgeEntry) -> String {
entry
.body
.clone()
.or_else(|| entry.summary.clone())
.unwrap_or_else(|| "(no content)".to_string())
}
fn title_for_chunk(entry: &KnowledgeEntry, chunk_idx: u16, plan: &ChunkPlan) -> String {
if plan.total > 1 {
format!("{} (Part {}/{})", entry.title, chunk_idx + 1, plan.total)
} else {
entry.title.clone()
}
}
fn chunk_ref(chunk_idx: u16, plan: &ChunkPlan) -> Option<ChunkRef> {
if plan.total > 1 {
Some(ChunkRef {
index: chunk_idx + 1,
total: plan.total,
oversized: if plan.is_oversized(chunk_idx) {
Some(true)
} else {
None
},
})
} else {
None
}
}
fn chunk_text<'a>(plan: &ChunkPlan, content: &'a str, chunk_idx: u16) -> &'a str {
if plan.total > 1 {
plan.chunk(content, chunk_idx)
} else {
content
}
}
fn build_prompt_for_chunk(
entry: &KnowledgeEntry,
chunk_idx: u16,
plan: &ChunkPlan,
content: &str,
) -> BloomPrompt {
let resolved = phrases_for_chunk(
entry,
chunk_idx,
plan.total,
chunk_text(plan, content, chunk_idx),
);
BloomPrompt {
id: entry.id.clone(),
title: title_for_chunk(entry, chunk_idx, plan),
phrase_source: resolved.source.as_str().to_string(),
chunk: chunk_ref(chunk_idx, plan),
}
}
fn build_full_for_chunk(
entry: &KnowledgeEntry,
chunk_idx: u16,
plan: &ChunkPlan,
content: &str,
phrases: Vec<String>,
source: PhraseSource,
) -> BloomFull {
BloomFull {
id: entry.id.clone(),
title: title_for_chunk(entry, chunk_idx, plan),
phrases,
phrase_source: source.as_str().to_string(),
content: chunk_text(plan, content, chunk_idx).to_string(),
chunk: chunk_ref(chunk_idx, plan),
}
}
const MAX_GUESS_CHARS: usize = 2_000;
fn validate_guess(guess: &str) -> Result<()> {
let chars = guess.chars().count();
if chars > MAX_GUESS_CHARS {
bail!(
"Guess is {} characters; the limit is {}. Send a shorter guess.",
chars,
MAX_GUESS_CHARS
);
}
if !guess.chars().any(|c| c.is_alphanumeric()) {
bail!("Guess is empty. Send what the title brought to mind, then continue.");
}
Ok(())
}
pub struct RitualMeta {
pub agent: String,
pub wake: Option<i64>,
pub model_id: Option<String>,
}
pub fn begin_ritual(
db: &dyn KnowledgeStore,
cascade: &WakeCascade,
meta: RitualMeta,
) -> Result<String> {
if cascade.core.is_empty() && cascade.recent.is_empty() && cascade.bridges.is_empty() {
if !cascade.excluded.is_empty() {
let counts: Vec<String> = cascade
.excluded
.iter()
.map(|(tag, n)| format!("{}: {}", tag, n))
.collect();
bail!(
"No blooms to wake: every entry that qualified was excluded by tag ({}). \
Pass --include-excluded to wake them anyway, or remove the tag.",
counts.join(", ")
);
}
bail!("No blooms to wake");
}
let session = WakeSession::new(cascade, meta.agent, meta.wake, meta.model_id);
let owned_blooms: HashMap<String, KnowledgeEntry> = build_bloom_map_owned(cascade);
let total_steps = total_chunks_across_cascade(&session, &owned_blooms);
let first_id = session
.current_bloom_id()
.ok_or_else(|| anyhow::anyhow!("No blooms in session"))?;
let first_bloom = owned_blooms
.get(first_id)
.ok_or_else(|| anyhow::anyhow!("Bloom not found: {}", first_id))?;
let first_content = bloom_content(first_bloom);
let first_plan = compute_chunks(&first_content, chunk_threshold());
let prompt = build_prompt_for_chunk(first_bloom, 0, &first_plan, &first_content);
let session_id = db.create_wake_session(&session)?;
let token = create_token(&session_id, session.step);
let response = WakeBeginResponse {
status: "ritual_started".to_string(),
session: token,
prompt,
progress: Progress {
current: 1,
total: total_steps.max(1),
bloom_current: 1,
bloom_total: session.total_blooms(),
buckets: None,
},
excluded: cascade.excluded.clone(),
};
Ok(serde_json::to_string(&response)?)
}
pub fn respond_ritual(
db: &dyn KnowledgeStore,
ctx: &AgentContext,
bloom_id: &str,
guess: &str,
token_str: &str,
) -> Result<String> {
let (session_id, token_step) =
verify_token(token_str).map_err(|e| anyhow::anyhow!("Token verification failed: {}", e))?;
let mut session = db.get_wake_session(&session_id)?.ok_or_else(|| {
anyhow::anyhow!(
"Session not found: {}. Run `mx memory wake --begin` to start a ritual.",
session_id
)
})?;
if session.agent.is_empty() {
bail!(
"This session was created by an older version of mx and cannot be continued. \
Run `mx memory wake --begin` to start a new ritual."
);
}
if ctx.agent_id.as_deref() != Some(session.agent.as_str()) {
bail!(
"This ritual was begun by another agent. Run `mx memory wake --begin` \
to start your own."
);
}
if session.step != token_step {
bail!(
"Token out of sync: token step {} but session at step {}",
token_step,
session.step
);
}
let all_blooms = fetch_blooms_by_ids(db, ctx, &session.bloom_ids)?;
let mut vanished: Vec<String> = Vec::new();
while !session.is_complete() && session.current_bloom_is_missing(&all_blooms) {
if let Some(id) = session.current_bloom_id() {
vanished.push(id.to_string());
}
session.advance_unjudged();
}
if vanished.iter().any(|id| id == bloom_id) {
return Ok(serde_json::to_string(&unjudged_response(
db,
&mut session,
&session_id,
&all_blooms,
"bloom_missing",
None,
)?)?);
}
let Some(expected_id) = session.current_bloom_id().map(str::to_string) else {
let response = WakeErrorResponse {
status: "error".to_string(),
error: "invalid_bloom_id".to_string(),
message: format!(
"No bloom is awaiting a response; {} was not part of this ritual",
bloom_id
),
expected_id: None,
};
return Ok(serde_json::to_string(&response)?);
};
if bloom_id != expected_id {
let response = WakeErrorResponse {
status: "error".to_string(),
error: "invalid_bloom_id".to_string(),
message: format!("Expected bloom {}, got {}", expected_id, bloom_id),
expected_id: Some(expected_id),
};
return Ok(serde_json::to_string(&response)?);
}
validate_guess(guess)?;
let bloom = all_blooms
.get(&expected_id)
.ok_or_else(|| anyhow::anyhow!("Bloom not found after the missing-entry sweep"))?;
let content = bloom_content(bloom);
let plan = compute_chunks(&content, chunk_threshold());
if session.clamp_if_chunks_shrank(plan.total) {
let resolved = phrases_for_chunk(bloom, 0, plan.total, chunk_text(&plan, &content, 0));
let shown =
build_full_for_chunk(bloom, 0, &plan, &content, resolved.phrases, resolved.source);
return Ok(serde_json::to_string(&unjudged_response(
db,
&mut session,
&session_id,
&all_blooms,
"chunk_truncated",
Some(shown),
)?)?);
}
let chunk_idx = session.current_chunk_index;
let chunk_content = chunk_text(&plan, &content, chunk_idx);
let resolved = phrases_for_chunk(bloom, chunk_idx, plan.total, chunk_content);
let (match_kind, match_index) = best_match(guess, &resolved.phrases);
let bucket = match match_kind {
MatchKind::None => Bucket::Revealed,
_ => Bucket::Unhinted,
};
db.insert_wake_guess(&WakeGuessRow {
agent: session.agent.clone(),
wake: session.wake,
session_id: session_id.clone(),
bloom_id: expected_id.clone(),
chunk_index: chunk_idx,
chunk_total: plan.total,
position: session.step,
bloom_position: session.current_index + 1,
bloom_total: session.total_blooms(),
title_shown: title_for_chunk(bloom, chunk_idx, &plan),
guess: guess.to_string(),
model_id: session.model_id.clone(),
phrase_source: resolved.source.as_str().to_string(),
phrases: resolved.phrases.clone(),
match_kind: match_kind.as_str().to_string(),
match_index,
bucket: bucket.as_str().to_string(),
content_hash: content_hash(chunk_content),
})?;
let shown = build_full_for_chunk(
bloom,
chunk_idx,
&plan,
&content,
resolved.phrases,
resolved.source,
);
session.advance(plan.total, bucket, resolved.source);
skip_missing_blooms(&mut session, &all_blooms);
let (next, progress, summary) = get_next_and_progress(&session, &all_blooms)?;
if session.is_complete() {
db.delete_wake_session(&session_id)?;
} else {
db.update_wake_session(&session)?;
}
let response = WakeRespondResponse {
status: "shown".to_string(),
bucket: Some(bucket.as_str().to_string()),
guess: Some(guess.to_string()),
match_info: Some(MatchInfo {
kind: match_kind.as_str().to_string(),
phrase_index: match_index,
}),
bloom: Some(shown),
session: create_token(&session_id, session.step),
next,
progress: Some(progress),
summary,
};
Ok(serde_json::to_string(&response)?)
}
fn unjudged_response(
db: &dyn KnowledgeStore,
session: &mut WakeSession,
session_id: &str,
all_blooms: &HashMap<String, KnowledgeEntry>,
status: &str,
shown: Option<BloomFull>,
) -> Result<WakeRespondResponse> {
skip_missing_blooms(session, all_blooms);
let (next, progress, summary) = get_next_and_progress(session, all_blooms)?;
if session.is_complete() {
db.delete_wake_session(session_id)?;
} else {
db.update_wake_session(session)?;
}
Ok(WakeRespondResponse {
status: status.to_string(),
bucket: None,
guess: None,
match_info: None,
bloom: shown,
session: create_token(session_id, session.step),
next,
progress: Some(progress),
summary,
})
}
fn skip_missing_blooms(session: &mut WakeSession, all_blooms: &HashMap<String, KnowledgeEntry>) {
while !session.is_complete() && session.current_bloom_is_missing(all_blooms) {
session.advance_unjudged();
}
}
fn fetch_blooms_by_ids(
db: &dyn KnowledgeStore,
ctx: &AgentContext,
bloom_ids: &[String],
) -> Result<HashMap<String, KnowledgeEntry>> {
let mut map = HashMap::new();
for id in bloom_ids {
if let Some(entry) = db.get(id, ctx)? {
map.insert(id.clone(), entry);
}
}
Ok(map)
}
fn build_bloom_map_owned(cascade: &WakeCascade) -> HashMap<String, KnowledgeEntry> {
let mut map = HashMap::new();
for entry in &cascade.core {
map.insert(entry.id.clone(), entry.clone());
}
for entry in &cascade.recent {
map.insert(entry.id.clone(), entry.clone());
}
for entry in &cascade.bridges {
map.insert(entry.id.clone(), entry.clone());
}
map
}
fn get_next_and_progress(
session: &WakeSession,
all_blooms: &HashMap<String, KnowledgeEntry>,
) -> Result<(Option<BloomPrompt>, Progress, Option<Summary>)> {
let total_chunks = total_chunks_across_cascade(session, all_blooms).max(1);
let walked = session.step as usize;
let display_current = if session.is_complete() {
walked
} else {
walked + 1
}
.min(total_chunks);
let bloom_current = (session.current_index + 1).min(session.total_blooms());
let buckets = session.bucket_totals();
let progress = Progress {
current: display_current,
total: total_chunks,
bloom_current,
bloom_total: session.total_blooms(),
buckets: Some(buckets.totals()),
};
if session.is_complete() {
let summary = Summary {
chunks: session.step as usize,
blooms: session.total_blooms(),
buckets,
unjudged: session.unjudged_count,
};
Ok((None, progress, Some(summary)))
} else {
let next_id = session
.current_bloom_id()
.ok_or_else(|| anyhow::anyhow!("Failed to get next bloom"))?;
let next_bloom = all_blooms
.get(next_id)
.ok_or_else(|| anyhow::anyhow!("Next bloom not found: {}", next_id))?;
let next_content = bloom_content(next_bloom);
let next_plan = compute_chunks(&next_content, chunk_threshold());
Ok((
Some(build_prompt_for_chunk(
next_bloom,
session.current_chunk_index,
&next_plan,
&next_content,
)),
progress,
None,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wake_chunk::PhraseMatch;
fn test_entry() -> KnowledgeEntry {
serde_json::from_str::<KnowledgeEntry>(
r#"{"id":"kn-test","category_id":"bloom","title":"Test","body":"body"}"#,
)
.expect("test entry deserialize")
}
fn entry_with_phrases(phrases: Vec<&str>) -> KnowledgeEntry {
let mut e = test_entry();
e.wake_phrases = phrases.into_iter().map(|s| s.to_string()).collect();
e
}
fn test_cascade(entries: Vec<KnowledgeEntry>) -> WakeCascade {
WakeCascade {
core: entries,
..Default::default()
}
}
fn meta() -> RitualMeta {
RitualMeta {
agent: "test-agent".to_string(),
wake: Some(7),
model_id: Some("test-model".to_string()),
}
}
fn make_large_bloom(target_bytes: usize, phrases: Vec<&str>) -> KnowledgeEntry {
let mut body = String::new();
let mut section = 0;
while body.len() < target_bytes {
section += 1;
body.push_str(&format!(
"\n## Section {section}\n\n\
This is section {section} of the test bloom. It contains \
enough text that multiple sections will cross the chunking \
threshold. The wake ritual should walk each chunk in turn.\n\n\
- bullet one for section {section}\n\
- bullet two for section {section}\n\
- bullet three for section {section}\n\n"
));
}
let mut e = test_entry();
e.title = "Ops".to_string();
e.body = Some(body);
e.wake_phrases = phrases.into_iter().map(|s| s.to_string()).collect();
e
}
const MISS: &str = "zzz quibbling flugelhorn marmalade";
fn token_from_response(json: &serde_json::Value) -> String {
json.get("session")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
fn begin(store: &MockStore, cascade: &WakeCascade) -> serde_json::Value {
serde_json::from_str(&begin_ritual(store, cascade, meta()).unwrap()).unwrap()
}
fn respond(store: &MockStore, bloom_id: &str, guess: &str, token: &str) -> serde_json::Value {
let ctx = AgentContext::for_agent("test-agent");
serde_json::from_str(&respond_ritual(store, &ctx, bloom_id, guess, token).unwrap()).unwrap()
}
#[test]
fn authored_chunk_is_matched_against_every_authored_phrase() {
let e = entry_with_phrases(vec!["alpha", "beta", "gamma"]);
let resolved = phrases_for_chunk(&e, 0, 5, "chunk 0 content");
assert_eq!(resolved.source, PhraseSource::Authored);
assert_eq!(resolved.phrases, vec!["alpha", "beta", "gamma"]);
let resolved = phrases_for_chunk(&e, 2, 5, "chunk 2 content");
assert_eq!(resolved.source, PhraseSource::Authored);
assert_eq!(resolved.phrases, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn chunk_beyond_authored_count_derives_one_phrase() {
let e = entry_with_phrases(vec!["alpha"]);
let resolved = phrases_for_chunk(&e, 3, 5, "\n## Derived heading here\n\nbody text");
assert_eq!(resolved.source, PhraseSource::Derived);
assert_eq!(resolved.phrases, vec!["Derived heading here"]);
}
#[test]
fn phraseless_bloom_gets_one_auto_phrase() {
let e = entry_with_phrases(vec![]);
let resolved = phrases_for_chunk(&e, 2, 3, "## A heading\n\nbody");
assert_eq!(resolved.source, PhraseSource::Auto);
assert_eq!(resolved.phrases, vec!["A heading"]);
}
#[test]
fn legacy_single_wake_phrase_is_treated_as_authored() {
let mut e = test_entry();
e.wake_phrase = Some("legacy phrase".to_string());
let resolved = phrases_for_chunk(&e, 0, 1, "chunk");
assert_eq!(resolved.source, PhraseSource::Authored);
assert_eq!(resolved.phrases, vec!["legacy phrase"]);
}
#[test]
fn best_match_reports_the_index_of_the_matching_phrase() {
let phrases = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
assert_eq!(best_match("gamma", &phrases), (MatchKind::Exact, Some(2)));
assert_eq!(best_match("beta", &phrases), (MatchKind::Exact, Some(1)));
assert_eq!(best_match(MISS, &phrases), (MatchKind::None, None));
}
#[test]
fn best_match_prefers_an_exact_hit_over_an_earlier_close_one() {
let phrases = vec!["Alpha".to_string(), "beta".to_string()];
assert_eq!(compare_phrase("alpha", &phrases[0]), PhraseMatch::Tolerant);
assert_eq!(best_match("beta", &phrases), (MatchKind::Exact, Some(1)));
assert_eq!(best_match("alpha", &phrases), (MatchKind::Close, Some(0)));
}
#[test]
fn a_matching_guess_is_unhinted_and_shows_the_bloom() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let token = token_from_response(&begin_json);
let resp = respond(&store, &bloom_id, "alpha", &token);
assert_eq!(resp["status"], "shown");
assert_eq!(resp["bucket"], "unhinted");
assert_eq!(resp["guess"], "alpha");
assert_eq!(resp["match"]["kind"], "exact");
assert_eq!(resp["match"]["phrase_index"], 0);
assert_eq!(resp["bloom"]["content"], "body");
assert_eq!(resp["bloom"]["phrase_source"], "authored");
}
#[test]
fn a_missed_guess_is_revealed_and_still_shows_the_bloom() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let token = token_from_response(&begin_json);
let resp = respond(&store, &bloom_id, MISS, &token);
assert_eq!(resp["status"], "shown");
assert_eq!(resp["bucket"], "revealed");
assert_eq!(resp["match"]["kind"], "none");
assert!(resp["match"]["phrase_index"].is_null());
assert_eq!(resp["bloom"]["content"], "body");
assert_eq!(resp["bloom"]["phrases"][0], "alpha");
}
#[test]
fn one_guess_ends_the_bloom_so_a_second_respond_is_rejected() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let second = entry_with_phrases(vec!["bravo"]);
let mut second = second;
second.id = "kn-second".to_string();
store.seed(&second);
let begin_json = begin(&store, &test_cascade(vec![bloom, second]));
let token = token_from_response(&begin_json);
let resp = respond(&store, &bloom_id, MISS, &token);
assert_eq!(resp["status"], "shown");
let ctx = AgentContext::for_agent("test-agent");
let err = respond_ritual(&store, &ctx, &bloom_id, "alpha", &token).unwrap_err();
assert!(
err.to_string().contains("Token out of sync"),
"expected a replay rejection, got: {err}"
);
assert_eq!(
store.guesses.borrow().len(),
1,
"no row for a rejected call"
);
}
#[test]
fn the_response_carries_no_hint_ladder_and_no_retired_vocabulary() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let raw = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
&bloom_id,
MISS,
&token_from_response(&begin_json),
)
.unwrap();
for retired in [
"hint",
"attempt",
"remembered",
"needed_help",
"incorrect",
"skipped",
"match_type",
"derived_phrase_mismatch",
"wake_phrase_count",
"matched_phrase",
"all_phrases",
"blooms_complete",
] {
let key = format!("\"{retired}\"");
assert!(
!raw.contains(&key),
"respond payload still carries {key}: {raw}"
);
}
}
#[test]
fn a_guess_matching_the_second_or_third_authored_phrase_is_unhinted() {
for (guess, expected_index) in [("beta", 1), ("gamma", 2)] {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha", "beta", "gamma"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let resp = respond(&store, &bloom_id, guess, &token_from_response(&begin_json));
assert_eq!(resp["bucket"], "unhinted", "guess {guess:?}");
assert_eq!(resp["match"]["kind"], "exact", "guess {guess:?}");
assert_eq!(resp["match"]["phrase_index"], expected_index);
assert_eq!(store.guesses.borrow()[0].match_index, Some(expected_index));
}
}
#[test]
fn an_authored_phrase_matches_case_insensitively() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["The Long Way Round"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let resp = respond(
&store,
&bloom_id,
"the long way round.",
&token_from_response(&begin_json),
);
assert_eq!(resp["bucket"], "unhinted");
assert_eq!(resp["match"]["kind"], "close");
assert_eq!(resp["match"]["phrase_index"], 0);
}
#[test]
fn each_respond_writes_exactly_one_row_with_both_positions() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
first.title = "First".to_string();
let mut second = entry_with_phrases(vec!["bravo"]);
second.id = "kn-second".to_string();
second.title = "Second".to_string();
store.seed(&first);
store.seed(&second);
let begin_json = begin(&store, &test_cascade(vec![first, second]));
let mut token = token_from_response(&begin_json);
for (bloom_id, guess) in [("kn-first", "alpha"), ("kn-second", MISS)] {
let resp = respond(&store, bloom_id, guess, &token);
token = token_from_response(&resp);
}
let rows = store.guesses.borrow();
assert_eq!(rows.len(), 2, "one row per respond");
assert_eq!(rows[0].bloom_id, "kn-first");
assert_eq!(rows[0].position, 0);
assert_eq!(rows[0].bloom_position, 1);
assert_eq!(rows[0].bloom_total, 2);
assert_eq!(rows[0].chunk_index, 0);
assert_eq!(rows[0].chunk_total, 1);
assert_eq!(rows[0].title_shown, "First");
assert_eq!(rows[0].bucket, "unhinted");
assert_eq!(rows[0].match_kind, "exact");
assert_eq!(rows[0].phrases, vec!["alpha"]);
assert_eq!(
rows[0].content_hash,
crate::wake_guess::content_hash("body")
);
assert_eq!(rows[1].bloom_id, "kn-second");
assert_eq!(rows[1].position, 1);
assert_eq!(rows[1].bloom_position, 2);
assert_eq!(rows[1].bucket, "revealed");
assert_eq!(rows[1].match_kind, "none");
assert_eq!(rows[1].match_index, None);
assert_eq!(rows[1].guess, MISS);
}
#[test]
fn the_wake_and_model_flags_land_on_every_row() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
respond(
&store,
&bloom_id,
"alpha",
&token_from_response(&begin_json),
);
let rows = store.guesses.borrow();
assert_eq!(rows[0].wake, Some(7));
assert_eq!(rows[0].model_id.as_deref(), Some("test-model"));
assert_eq!(rows[0].agent, "test-agent");
}
#[test]
fn an_omitted_wake_number_logs_as_absent() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json: serde_json::Value = serde_json::from_str(
&begin_ritual(
&store,
&test_cascade(vec![bloom]),
RitualMeta {
agent: "test-agent".to_string(),
wake: None,
model_id: None,
},
)
.unwrap(),
)
.unwrap();
respond(
&store,
&bloom_id,
"alpha",
&token_from_response(&begin_json),
);
let rows = store.guesses.borrow();
assert_eq!(rows[0].wake, None);
assert_eq!(rows[0].model_id, None);
}
#[test]
fn a_chunked_bloom_advances_position_but_not_bloom_position() {
let store = MockStore::new();
let bloom = make_large_bloom(95_000, vec!["alpha", "beta", "gamma"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let total_chunks = begin_json["progress"]["total"].as_u64().unwrap();
assert!(total_chunks >= 3, "fixture must chunk; got {total_chunks}");
let mut token = token_from_response(&begin_json);
for _ in 0..total_chunks {
let resp = respond(&store, &bloom_id, MISS, &token);
token = token_from_response(&resp);
}
let rows = store.guesses.borrow();
assert_eq!(rows.len(), total_chunks as usize, "one row per chunk");
for (idx, row) in rows.iter().enumerate() {
assert_eq!(row.position, idx as u32, "position advances per chunk");
assert_eq!(row.bloom_position, 1, "still the same bloom");
assert_eq!(row.chunk_index, idx as u16);
assert!(
row.title_shown.contains(&format!("(Part {}/", idx + 1)),
"chunked title must carry its part suffix: {}",
row.title_shown
);
}
}
#[test]
fn a_chunk_truncated_response_writes_no_row() {
let store = MockStore::new();
let bloom = make_large_bloom(95_000, vec!["alpha", "beta", "gamma"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let mut token = token_from_response(&begin_json);
for _ in 0..2 {
let resp = respond(&store, &bloom_id, MISS, &token);
token = token_from_response(&resp);
}
assert_eq!(store.guesses.borrow().len(), 2);
store.mutate_bloom(&bloom_id, |entry| {
entry.body = Some("shrunk down to a single tiny chunk now.".to_string());
});
let resp = respond(&store, &bloom_id, "ignored", &token);
assert_eq!(resp["status"], "chunk_truncated");
assert!(
resp.get("bucket").is_none(),
"no bucket without a judgement"
);
assert!(resp.get("guess").is_none());
assert_eq!(
store.guesses.borrow().len(),
2,
"a truncated chunk judges no guess, so it logs none"
);
}
#[test]
fn a_failed_row_write_fails_the_respond_and_leaves_the_session_put() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let token = token_from_response(&begin_json);
store.fail_guess_write.set(true);
let err = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
&bloom_id,
"alpha",
&token,
)
.unwrap_err();
assert!(
err.to_string().contains("guess log unavailable"),
"the write failure must surface, got: {err}"
);
{
let sessions = store.sessions.borrow();
let session = sessions.values().next().expect("session must survive");
assert_eq!(
session.step, 0,
"session must not advance past a lost guess"
);
assert_eq!(session.current_index, 0);
assert_eq!(session.unhinted_count, 0);
}
store.fail_guess_write.set(false);
let resp = respond(&store, &bloom_id, "alpha", &token);
assert_eq!(resp["status"], "shown");
assert_eq!(store.guesses.borrow().len(), 1);
}
#[test]
fn a_bloom_deleted_while_it_is_on_the_table_is_stepped_over() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut second = entry_with_phrases(vec!["bravo"]);
second.id = "kn-second".to_string();
store.seed(&first);
store.seed(&second);
let begin_json = begin(&store, &test_cascade(vec![first, second]));
let token = token_from_response(&begin_json);
store.blooms.borrow_mut().remove("kn-first");
let resp = respond(&store, "kn-first", "alpha", &token);
assert_eq!(resp["status"], "bloom_missing");
assert!(resp.get("bloom").is_none(), "there is nothing to show");
assert!(resp.get("bucket").is_none(), "nothing was judged");
assert!(
store.guesses.borrow().is_empty(),
"a vanished bloom logs no guess"
);
assert_eq!(resp["next"]["id"], "kn-second");
let resp = respond(&store, "kn-second", "bravo", &token_from_response(&resp));
assert_eq!(resp["status"], "shown");
assert_eq!(resp["bucket"], "unhinted");
assert_eq!(resp["summary"]["chunks"], 2);
assert_eq!(resp["summary"]["buckets"]["unhinted"]["authored"], 1);
assert_eq!(resp["summary"]["buckets"]["revealed"]["authored"], 0);
}
#[test]
fn a_bloom_deleted_further_down_the_sequence_is_never_prompted_for() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut second = entry_with_phrases(vec!["bravo"]);
second.id = "kn-second".to_string();
let mut third = entry_with_phrases(vec!["charlie"]);
third.id = "kn-third".to_string();
store.seed(&first);
store.seed(&second);
store.seed(&third);
let begin_json = begin(&store, &test_cascade(vec![first, second, third]));
let token = token_from_response(&begin_json);
store.blooms.borrow_mut().remove("kn-second");
let resp = respond(&store, "kn-first", "alpha", &token);
assert_eq!(resp["status"], "shown");
assert_eq!(
resp["next"]["id"], "kn-third",
"the deleted entry must be stepped over, not prompted for: {resp}"
);
}
#[test]
fn the_summary_reports_bucket_counts_split_by_phrase_source() {
let store = MockStore::new();
let mut authored = entry_with_phrases(vec!["alpha"]);
authored.id = "kn-authored".to_string();
let mut auto = test_entry(); auto.id = "kn-auto".to_string();
auto.body = Some("## The Discovery\n\nBody text here.".to_string());
store.seed(&authored);
store.seed(&auto);
let begin_json = begin(&store, &test_cascade(vec![authored, auto]));
assert!(
begin_json["progress"].get("buckets").is_none(),
"nothing is guessed at begin time"
);
let mut token = token_from_response(&begin_json);
let resp = respond(&store, "kn-authored", "alpha", &token);
assert_eq!(resp["progress"]["buckets"]["unhinted"], 1);
assert_eq!(resp["progress"]["buckets"]["revealed"], 0);
token = token_from_response(&resp);
let resp = respond(&store, "kn-auto", MISS, &token);
let summary = &resp["summary"];
assert_eq!(summary["chunks"], 2);
assert_eq!(summary["blooms"], 2);
assert_eq!(summary["buckets"]["unhinted"]["authored"], 1);
assert_eq!(summary["buckets"]["unhinted"]["derived"], 0);
assert_eq!(summary["buckets"]["unhinted"]["auto"], 0);
assert_eq!(summary["buckets"]["revealed"]["auto"], 1);
assert_eq!(summary["buckets"]["revealed"]["authored"], 0);
assert!(
resp.get("next").is_none(),
"the last respond has no next prompt"
);
assert!(
store.sessions.borrow().is_empty(),
"session is deleted on completion"
);
}
#[test]
fn the_begin_response_reports_excluded_entries_per_tag() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
store.seed(&bloom);
let mut cascade = test_cascade(vec![bloom]);
cascade.excluded.insert("archive".to_string(), 2);
cascade.excluded.insert("wake-exclude".to_string(), 1);
let begin_json = begin(&store, &cascade);
assert_eq!(begin_json["excluded"]["archive"], 2);
assert_eq!(begin_json["excluded"]["wake-exclude"], 1);
}
#[test]
fn a_begin_with_nothing_excluded_reports_an_empty_object() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
assert_eq!(
begin_json["excluded"],
serde_json::json!({}),
"excluded must be present and empty: {begin_json}"
);
}
use mock_store::MockStore;
mod mock_store {
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use anyhow::Result;
use crate::knowledge::KnowledgeEntry;
use crate::store::{
AgentContext, EditResult, KnowledgeFilter, KnowledgeStore, ReinforcementResult,
WakeCascade,
};
use crate::types::{
Agent, ApplicabilityType, Category, ContentType, EntryType, MemoryBackup, Project,
Relationship, RelationshipType, Session, SessionType, SourceType,
};
use crate::wake_guess::WakeGuessRow;
use crate::wake_token::WakeSession;
pub struct MockStore {
pub blooms: RefCell<HashMap<String, KnowledgeEntry>>,
pub sessions: RefCell<HashMap<String, WakeSession>>,
pub guesses: RefCell<Vec<WakeGuessRow>>,
pub fail_guess_write: Cell<bool>,
}
impl MockStore {
pub fn new() -> Self {
Self {
blooms: RefCell::new(HashMap::new()),
sessions: RefCell::new(HashMap::new()),
guesses: RefCell::new(Vec::new()),
fail_guess_write: Cell::new(false),
}
}
pub fn seed(&self, entry: &KnowledgeEntry) {
self.blooms
.borrow_mut()
.insert(entry.id.clone(), entry.clone());
}
pub fn mutate_bloom(&self, id: &str, mutate: impl FnOnce(&mut KnowledgeEntry)) {
let mut blooms = self.blooms.borrow_mut();
let entry = blooms.get_mut(id).expect("bloom to mutate must exist");
mutate(entry);
}
}
impl KnowledgeStore for MockStore {
fn get(&self, id: &str, _ctx: &AgentContext) -> Result<Option<KnowledgeEntry>> {
Ok(self.blooms.borrow().get(id).cloned())
}
fn create_wake_session(&self, session: &WakeSession) -> Result<String> {
self.sessions
.borrow_mut()
.insert(session.session_id.clone(), session.clone());
Ok(session.session_id.clone())
}
fn get_wake_session(&self, session_id: &str) -> Result<Option<WakeSession>> {
Ok(self.sessions.borrow().get(session_id).cloned())
}
fn update_wake_session(&self, session: &WakeSession) -> Result<()> {
self.sessions
.borrow_mut()
.insert(session.session_id.clone(), session.clone());
Ok(())
}
fn delete_wake_session(&self, session_id: &str) -> Result<()> {
self.sessions.borrow_mut().remove(session_id);
Ok(())
}
fn insert_wake_guess(&self, row: &WakeGuessRow) -> Result<()> {
if self.fail_guess_write.get() {
anyhow::bail!("guess log unavailable");
}
self.guesses.borrow_mut().push(row.clone());
Ok(())
}
fn upsert_knowledge(&self, _entry: &KnowledgeEntry) -> Result<()> {
unreachable!("wake ritual does not write blooms")
}
fn delete(&self, _id: &str, _ctx: &AgentContext) -> Result<bool> {
unreachable!()
}
fn search(
&self,
_q: &str,
_ctx: &AgentContext,
_f: &KnowledgeFilter,
) -> Result<Vec<KnowledgeEntry>> {
unreachable!()
}
fn semantic_search(
&self,
_emb: &[f32],
_ctx: &AgentContext,
_f: &KnowledgeFilter,
_l: usize,
) -> Result<Vec<KnowledgeEntry>> {
unreachable!()
}
fn semantic_search_scored(
&self,
_emb: &[f32],
_ctx: &AgentContext,
_f: &KnowledgeFilter,
_l: usize,
) -> Result<Vec<(KnowledgeEntry, f32)>> {
unreachable!()
}
fn semantic_search_entries_scored(
&self,
_emb: &[f32],
_ctx: &AgentContext,
_l: usize,
) -> Result<Vec<(KnowledgeEntry, f32)>> {
unreachable!()
}
fn list_by_category(
&self,
_c: &str,
_ctx: &AgentContext,
_f: &KnowledgeFilter,
) -> Result<Vec<KnowledgeEntry>> {
unreachable!()
}
fn count_by_category(
&self,
_c: &str,
_ctx: &AgentContext,
_f: &KnowledgeFilter,
) -> Result<usize> {
unreachable!()
}
fn owned_private_matching(
&self,
_agent: &str,
_query: Option<&str>,
_filter: &KnowledgeFilter,
) -> Result<Vec<KnowledgeEntry>> {
unreachable!()
}
fn list_all(&self, _ctx: &AgentContext) -> Result<Vec<KnowledgeEntry>> {
unreachable!()
}
fn count(&self) -> Result<usize> {
unreachable!()
}
fn wake_cascade(
&self,
_ctx: &AgentContext,
_l: usize,
_r: Option<i32>,
_d: i64,
_include_excluded: bool,
) -> Result<WakeCascade> {
unreachable!()
}
fn update_activations(&self, _ids: &[String]) -> Result<()> {
unreachable!()
}
fn update_summary(&self, _id: &str, _s: &str, _ctx: &AgentContext) -> Result<bool> {
unreachable!()
}
fn apply_update(
&self,
_id: &str,
_spec: &crate::store_update::UpdateSpec,
_ctx: &AgentContext,
) -> Result<crate::store_update::UpdateOutcome> {
unreachable!()
}
fn increment_activation_count(&self, _ids: &[String]) -> Result<()> {
unreachable!()
}
fn query_recent_facts(&self, _d: i32) -> Result<Vec<KnowledgeEntry>> {
unreachable!()
}
fn query_recent_facts_all_types(&self, _d: i32) -> Result<Vec<KnowledgeEntry>> {
unreachable!()
}
fn reinforce(
&self,
_id: &str,
_a: i32,
_c: Option<i32>,
_ctx: &AgentContext,
) -> Result<Option<ReinforcementResult>> {
unreachable!()
}
fn edit_content(
&self,
_id: &str,
_ctx: &AgentContext,
_o: &str,
_n: &str,
_r: bool,
_nth: Option<usize>,
) -> Result<EditResult> {
unreachable!()
}
fn append_content(&self, _id: &str, _ctx: &AgentContext, _c: &str) -> Result<()> {
unreachable!()
}
fn prepend_content(&self, _id: &str, _ctx: &AgentContext, _c: &str) -> Result<()> {
unreachable!()
}
fn backup_content(
&self,
_e: &KnowledgeEntry,
_o: &str,
_a: Option<&str>,
) -> Result<String> {
unreachable!()
}
fn list_backups(&self, _id: &str) -> Result<Vec<MemoryBackup>> {
unreachable!()
}
fn latest_backup(&self, _id: &str) -> Result<Option<MemoryBackup>> {
unreachable!()
}
fn purge_backups(&self, _id: &str, _k: usize) -> Result<()> {
unreachable!()
}
fn get_tags_for_entry(&self, _id: &str) -> Result<Vec<String>> {
unreachable!()
}
fn set_tags_for_entry(&self, _id: &str, _t: &[String]) -> Result<()> {
unreachable!()
}
fn list_all_tags(&self, _c: Option<&str>) -> Result<Vec<String>> {
unreachable!()
}
fn get_applicability_for_entry(&self, _id: &str) -> Result<Vec<String>> {
unreachable!()
}
fn set_applicability_for_entry(&self, _id: &str, _ids: &[String]) -> Result<()> {
unreachable!()
}
fn list_applicability_types(&self) -> Result<Vec<ApplicabilityType>> {
unreachable!()
}
fn upsert_applicability_type(&self, _a: &ApplicabilityType) -> Result<()> {
unreachable!()
}
fn list_categories(&self) -> Result<Vec<Category>> {
unreachable!()
}
fn get_category(&self, _id: &str) -> Result<Option<Category>> {
unreachable!()
}
fn upsert_category(&self, _c: &Category) -> Result<()> {
unreachable!()
}
fn delete_category(&self, _id: &str) -> Result<bool> {
unreachable!()
}
fn list_projects(&self, _a: bool) -> Result<Vec<Project>> {
unreachable!()
}
fn get_project(&self, _id: &str) -> Result<Option<Project>> {
unreachable!()
}
fn upsert_project(&self, _p: &Project) -> Result<()> {
unreachable!()
}
fn get_tags_for_project(&self, _id: &str) -> Result<Vec<String>> {
unreachable!()
}
fn set_tags_for_project(&self, _id: &str, _t: &[String]) -> Result<()> {
unreachable!()
}
fn get_applicability_for_project(&self, _id: &str) -> Result<Vec<String>> {
unreachable!()
}
fn set_applicability_for_project(&self, _id: &str, _ids: &[String]) -> Result<()> {
unreachable!()
}
fn list_agents(&self) -> Result<Vec<Agent>> {
unreachable!()
}
fn get_agent(&self, _id: &str) -> Result<Option<Agent>> {
unreachable!()
}
fn upsert_agent(&self, _a: &Agent) -> Result<()> {
unreachable!()
}
fn list_relationships_for_entry(&self, _id: &str) -> Result<Vec<Relationship>> {
unreachable!()
}
fn add_relationship(&self, _f: &str, _t: &str, _r: &str) -> Result<String> {
unreachable!()
}
fn delete_relationship(&self, _id: &str) -> Result<bool> {
unreachable!()
}
fn get_facts_for_session(&self, _id: &str) -> Result<Vec<String>> {
unreachable!()
}
fn get_entries_for_session(
&self,
_id: &str,
_owner: Option<&str>,
_category: &str,
_ctx: &AgentContext,
) -> Result<Vec<crate::store::DedupCandidate>> {
unreachable!()
}
fn get_session_for_fact(&self, _id: &str) -> Result<Option<String>> {
unreachable!()
}
fn list_sessions(&self, _p: Option<&str>) -> Result<Vec<Session>> {
unreachable!()
}
fn get_session(&self, _id: &str) -> Result<Option<Session>> {
unreachable!()
}
fn upsert_session(&self, _s: &Session) -> Result<()> {
unreachable!()
}
fn list_source_types(&self) -> Result<Vec<SourceType>> {
unreachable!()
}
fn list_entry_types(&self) -> Result<Vec<EntryType>> {
unreachable!()
}
fn list_content_types(&self) -> Result<Vec<ContentType>> {
unreachable!()
}
fn list_session_types(&self) -> Result<Vec<SessionType>> {
unreachable!()
}
fn list_relationship_types(&self) -> Result<Vec<RelationshipType>> {
unreachable!()
}
fn list_tables(&self) -> Result<Vec<String>> {
unreachable!()
}
fn delete_embedding_chunks(&self, _id: &str) -> Result<()> {
unreachable!()
}
fn insert_embedding_chunk(
&self,
_id: &str,
_ci: usize,
_ct: &str,
_to: usize,
_tc: usize,
_emb: &[f32],
_m: &str,
) -> Result<()> {
unreachable!()
}
fn semantic_search_chunks(
&self,
_emb: &[f32],
_l: usize,
) -> Result<Vec<(String, f32)>> {
unreachable!()
}
fn sweep_ghost_anchors(
&self,
_dry_run: bool,
) -> Result<crate::store::GhostSweepResult> {
unreachable!()
}
}
}
mod adversarial {
use super::mock_store::MockStore;
use super::*;
#[test]
fn a_guess_that_normalizes_to_nothing_is_refused() {
for guess in ["", " ", "\t\n", "\u{2014}", "...", "???"] {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["\u{2014}"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let out = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
&bloom_id,
guess,
&token_from_response(&begin_json),
);
assert!(
out.is_err(),
"guess {guess:?} was accepted instead of refused: {out:?}"
);
assert!(
store.guesses.borrow().is_empty(),
"guess {guess:?} reached the log"
);
let sessions = store.sessions.borrow();
let session = sessions.values().next().expect("session must survive");
assert_eq!(
session.step, 0,
"guess {guess:?} advanced the session it was refused from"
);
assert_eq!(session.current_index, 0, "guess {guess:?}");
}
}
#[test]
fn the_match_tolerance_does_not_widen_for_multibyte_text() {
let ascii = vec!["abcdefghij".to_string()];
assert_eq!(
best_match("abcdeqrstu", &ascii),
(MatchKind::None, None),
"precondition: half-wrong ASCII is not a match"
);
let kana = vec![
"\u{3042}\u{3044}\u{3046}\u{3048}\u{304A}\u{304B}\u{304D}\u{304F}\u{3051}\u{3053}"
.to_string(),
];
let half_wrong =
"\u{3042}\u{3044}\u{3046}\u{3048}\u{304A}\u{3055}\u{3057}\u{3059}\u{305B}\u{305D}";
assert_eq!(
best_match(half_wrong, &kana),
(MatchKind::None, None),
"a half-wrong multibyte guess was accepted as a match"
);
}
#[test]
fn deleting_an_already_shown_bloom_does_not_brick_the_ritual() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut second = entry_with_phrases(vec!["bravo"]);
second.id = "kn-second".to_string();
store.seed(&first);
store.seed(&second);
let begin_json = begin(&store, &test_cascade(vec![first, second]));
let resp = respond(
&store,
"kn-first",
"alpha",
&token_from_response(&begin_json),
);
let token = token_from_response(&resp);
store.blooms.borrow_mut().remove("kn-first");
let out = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
"kn-second",
"bravo",
&token,
);
assert!(
out.is_ok(),
"a deleted, already-shown bloom killed the rest of the ritual: {:?}",
out.err()
);
}
#[test]
fn a_chunk_truncated_response_retires_the_token_it_consumed() {
let store = MockStore::new();
let big = make_large_bloom(95_000, vec!["alpha", "beta", "gamma"]);
let big_id = big.id.clone();
let mut tail = entry_with_phrases(vec!["bravo"]);
tail.id = "kn-tail".to_string();
tail.title = "Tail".to_string();
store.seed(&big);
store.seed(&tail);
let begin_json = begin(&store, &test_cascade(vec![big, tail]));
let mut token = token_from_response(&begin_json);
for _ in 0..2 {
let resp = respond(&store, &big_id, MISS, &token);
token = token_from_response(&resp);
}
store.mutate_bloom(&big_id, |entry| {
entry.body = Some("shrunk down to a single tiny chunk now.".to_string());
});
let spent = token.clone();
let resp = respond(&store, &big_id, "ignored", &spent);
assert_eq!(resp["status"], "chunk_truncated");
assert_ne!(
token_from_response(&resp),
spent,
"a truncation handed back the very token it consumed"
);
}
#[test]
fn a_respond_from_another_agent_is_refused() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let out = respond_ritual(
&store,
&AgentContext::for_agent("some-other-agent"),
&bloom_id,
"alpha",
&token_from_response(&begin_json),
);
assert!(out.is_err(), "another agent walked this session: {out:?}");
assert!(
store.guesses.borrow().is_empty(),
"a foreign respond wrote a row stamped with the owner's agent: {:?}",
store.guesses.borrow().first().map(|r| r.agent.clone())
);
}
#[test]
fn a_session_from_an_older_binary_is_refused_rather_than_walked() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let mut legacy =
WakeSession::new(&test_cascade(vec![bloom]), String::new(), None, None);
legacy.agent = String::new();
let session_id = legacy.session_id.clone();
store.create_wake_session(&legacy).unwrap();
let token = create_token(&session_id, legacy.step);
let out = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
&bloom_id,
"alpha",
&token,
);
let refused = match out {
Err(ref e) => e.to_string().contains("--begin"),
Ok(_) => false,
};
assert!(
refused,
"an agent-less session was walked instead of refused: {out:?}"
);
assert!(
store
.guesses
.borrow()
.iter()
.all(|row| !row.agent.is_empty()),
"a guess row was written with an empty agent"
);
}
#[test]
fn the_final_response_does_not_report_a_position_past_the_end() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut second = entry_with_phrases(vec!["bravo"]);
second.id = "kn-second".to_string();
store.seed(&first);
store.seed(&second);
let begin_json = begin(&store, &test_cascade(vec![first, second]));
let mut token = token_from_response(&begin_json);
let mut last = serde_json::Value::Null;
for (id, guess) in [("kn-first", "alpha"), ("kn-second", "bravo")] {
last = respond(&store, id, guess, &token);
token = token_from_response(&last);
}
let current = last["progress"]["current"].as_u64().unwrap();
let total = last["progress"]["total"].as_u64().unwrap();
assert!(
current <= total,
"final progress reads {current} of {total}: {}",
last["progress"]
);
}
#[test]
fn a_guess_over_two_thousand_characters_is_refused() {
const LIMIT: usize = 2_000;
fn fixture(store: &MockStore) -> (String, String) {
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(store, &test_cascade(vec![bloom]));
(bloom_id, token_from_response(&begin_json))
}
for filler in ["a", "\u{3042}"] {
let store = MockStore::new();
let (bloom_id, token) = fixture(&store);
let at_limit = filler.repeat(LIMIT);
let out = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
&bloom_id,
&at_limit,
&token,
);
assert!(
out.is_ok(),
"a guess of exactly {LIMIT} characters must be accepted \
(filler {filler:?}): {out:?}"
);
let rows = store.guesses.borrow();
let row = rows.first().expect("an accepted guess is logged");
assert_eq!(
row.guess, at_limit,
"a guess at the limit must be logged whole, not trimmed \
(filler {filler:?})"
);
}
for filler in ["a", "\u{3042}"] {
let store = MockStore::new();
let (bloom_id, token) = fixture(&store);
let over = filler.repeat(LIMIT + 1);
let out = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
&bloom_id,
&over,
&token,
);
assert!(
out.is_err(),
"a guess of {} characters was accepted (filler {filler:?}): {out:?}",
LIMIT + 1
);
assert!(
store.guesses.borrow().is_empty(),
"an over-long guess reached the log (filler {filler:?})"
);
let sessions = store.sessions.borrow();
let session = sessions.values().next().expect("session must survive");
assert_eq!(
session.step, 0,
"an over-long guess advanced the session it was refused from \
(filler {filler:?})"
);
assert_eq!(session.current_index, 0, "filler {filler:?}");
}
}
#[test]
#[ignore = "session lifecycle is follow-up work, not part of this change"]
fn a_stale_session_is_expired_by_age() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let token = token_from_response(&begin_json);
{
let mut sessions = store.sessions.borrow_mut();
let session = sessions.values_mut().next().expect("session exists");
session.created_at -= 365 * 24 * 60 * 60;
}
let out = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
&bloom_id,
"alpha",
&token,
);
assert!(
out.is_err(),
"a year-old session token still walked the ritual: {out:?}"
);
}
#[test]
#[ignore = "session lifecycle is follow-up work, not part of this change"]
fn beginning_again_does_not_orphan_the_previous_session() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
store.seed(&bloom);
begin(&store, &test_cascade(vec![bloom.clone()]));
begin(&store, &test_cascade(vec![bloom]));
assert_eq!(
store.sessions.borrow().len(),
1,
"a second begin left the first ritual's session behind"
);
}
#[test]
fn a_respond_from_a_caller_with_no_agent_is_refused() {
let store = MockStore::new();
let bloom = entry_with_phrases(vec!["alpha"]);
let bloom_id = bloom.id.clone();
store.seed(&bloom);
let begin_json = begin(&store, &test_cascade(vec![bloom]));
let out = respond_ritual(
&store,
&AgentContext::public_only(),
&bloom_id,
"alpha",
&token_from_response(&begin_json),
);
assert!(
out.is_err(),
"an anonymous caller walked an owned ritual: {out:?}"
);
assert!(
store.guesses.borrow().is_empty(),
"an anonymous respond filed a row under the owner's agent: {:?}",
store.guesses.borrow().first().map(|r| r.agent.clone())
);
}
#[test]
fn an_unreadable_entry_is_accounted_for_in_the_summary() {
let store = MockStore::new();
let mut visible = entry_with_phrases(vec!["alpha"]);
visible.id = "kn-visible".to_string();
let mut hidden = entry_with_phrases(vec!["bravo"]);
hidden.id = "kn-hidden".to_string();
store.seed(&visible);
store.seed(&hidden);
let begin_json = begin(&store, &test_cascade(vec![visible, hidden]));
store.blooms.borrow_mut().remove("kn-hidden");
let resp = respond(
&store,
"kn-visible",
"alpha",
&token_from_response(&begin_json),
);
let logged = store.guesses.borrow().len();
assert_eq!(
resp["summary"]["blooms"], 2,
"precondition: both entries were in the sequence: {resp}"
);
assert_eq!(logged, 1, "only the readable entry could be judged");
assert_eq!(
resp["summary"]["unjudged"], 1,
"an entry vanished from the log and the summary did not say so: \
{} of 2 rows written, summary {}",
logged, resp["summary"]
);
assert_eq!(
resp["summary"]["chunks"].as_u64().unwrap(),
logged as u64 + resp["summary"]["unjudged"].as_u64().unwrap(),
"the chunk count must be fully explained by the two halves"
);
}
#[test]
fn the_summary_does_not_leave_an_unexplained_gap_to_subtract() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut gone = entry_with_phrases(vec!["bravo"]);
gone.id = "kn-gone".to_string();
let mut last = entry_with_phrases(vec!["charlie"]);
last.id = "kn-last".to_string();
store.seed(&first);
store.seed(&gone);
store.seed(&last);
let begin_json = begin(&store, &test_cascade(vec![first, gone, last]));
let mut token = token_from_response(&begin_json);
let resp = respond(&store, "kn-first", "alpha", &token);
token = token_from_response(&resp);
store.blooms.borrow_mut().remove("kn-gone");
let resp = respond(&store, "kn-last", "charlie", &token);
let summary = &resp["summary"];
let chunks = summary["chunks"].as_u64().unwrap();
let bucketed = ["unhinted", "revealed"]
.iter()
.flat_map(|b| {
["authored", "derived", "auto"]
.iter()
.map(move |s| summary["buckets"][b][s].as_u64().unwrap_or(0))
})
.sum::<u64>();
assert!(
chunks == bucketed || summary.get("unjudged").is_some(),
"summary reports {chunks} chunks against {bucketed} bucketed \
guesses and does not name the difference: {summary}"
);
}
#[test]
fn a_bogus_id_is_still_rejected_when_the_sweep_ends_the_ritual() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut last = entry_with_phrases(vec!["bravo"]);
last.id = "kn-last".to_string();
store.seed(&first);
store.seed(&last);
let begin_json = begin(&store, &test_cascade(vec![first, last]));
let resp = respond(
&store,
"kn-first",
"alpha",
&token_from_response(&begin_json),
);
let token = token_from_response(&resp);
store.blooms.borrow_mut().remove("kn-last");
let resp = respond(&store, "kn-never-in-this-session", "alpha", &token);
assert_eq!(
resp["status"], "error",
"an id that was never in the session must not be answered as \
though it were the sequence: {resp}"
);
assert_eq!(resp["error"], "invalid_bloom_id");
assert!(
!store.sessions.borrow().is_empty(),
"a wrong id must not delete the session out from under the caller"
);
}
#[test]
fn a_vanished_entry_is_reported_before_the_guess_is_judged_unusable() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut last = entry_with_phrases(vec!["bravo"]);
last.id = "kn-last".to_string();
store.seed(&first);
store.seed(&last);
let begin_json = begin(&store, &test_cascade(vec![first, last]));
let resp = respond(
&store,
"kn-first",
"alpha",
&token_from_response(&begin_json),
);
let token = token_from_response(&resp);
store.blooms.borrow_mut().remove("kn-last");
let out = respond_ritual(
&store,
&AgentContext::for_agent("test-agent"),
"kn-last",
"",
&token,
);
let reported_as_missing = match out {
Ok(ref json) => json.contains("\"status\":\"bloom_missing\""),
Err(_) => false,
};
assert!(
reported_as_missing,
"a vanished entry was reported as a bad guess instead: {out:?}"
);
}
#[test]
#[ignore = "running progress has the same gap the summary now names; \
reporting it there too is follow-up work, not part of this change"]
fn running_progress_accounts_for_every_completed_step() {
let store = MockStore::new();
let mut first = entry_with_phrases(vec!["alpha"]);
first.id = "kn-first".to_string();
let mut gone = entry_with_phrases(vec!["bravo"]);
gone.id = "kn-gone".to_string();
let mut last = entry_with_phrases(vec!["charlie"]);
last.id = "kn-last".to_string();
store.seed(&first);
store.seed(&gone);
store.seed(&last);
let begin_json = begin(&store, &test_cascade(vec![first, gone, last]));
let resp = respond(
&store,
"kn-first",
"alpha",
&token_from_response(&begin_json),
);
let token = token_from_response(&resp);
store.blooms.borrow_mut().remove("kn-gone");
let resp = respond(&store, "kn-gone", "bravo", &token);
assert_eq!(resp["status"], "bloom_missing", "{resp}");
let progress = &resp["progress"];
let completed = progress["current"].as_u64().unwrap() - 1;
let bucketed = progress["buckets"]["unhinted"].as_u64().unwrap()
+ progress["buckets"]["revealed"].as_u64().unwrap();
assert!(
completed == bucketed || progress.get("unjudged").is_some(),
"progress reports {completed} completed steps against \
{bucketed} bucketed guesses and does not name the \
difference: {progress}"
);
}
}
}