use std::fs;
use std::path::Path;
use hallouminate::adapters::lance::LanceStore;
use hallouminate::domain::common::CorpusConfig;
use hallouminate::domain::corpus::MarkdownChunker;
use hallouminate::domain::embeddings::{EmbedBatch, EmbedRole};
use hallouminate::domain::indexer::{HandlerRegistry, index_corpus};
use hallouminate::domain::search::hybrid_search;
use text_splitter::Characters;
mod common;
use common::StubEmbedder;
const MODEL: &str = "BAAI/bge-small-en-v1.5";
#[derive(Default)]
struct RoleRecordingEmbedder {
roles: Vec<EmbedRole>,
}
impl EmbedBatch for RoleRecordingEmbedder {
fn embed_batch(
&mut self,
texts: &[String],
role: EmbedRole,
) -> hallouminate::domain::common::Result<
Vec<[f32; hallouminate::adapters::lance::EMBEDDING_DIM]>,
> {
self.roles.push(role);
Ok(texts
.iter()
.map(|_| [0.1_f32; hallouminate::adapters::lance::EMBEDDING_DIM])
.collect())
}
}
fn seed_fixture_corpus(dir: &Path) {
let files: &[(&str, &str)] = &[
(
"arrakis.md",
"# Arrakis\n\nThe spice melange flows from the deep desert.\n\n## Sandworms\n\nMassive worms churn beneath the sand.\n",
),
(
"fury-road.md",
"# Fury Road\n\nWitness me on the chrome-bright shiny journey.\n\n## War Boys\n\nHalf-life warriors of the wasteland.\n",
),
(
"shire.md",
"# The Shire\n\nHobbits till the soil between green hills and brooks.\n\n## Pipeweed\n\nLongbottom Leaf burns sweet by the hearth.\n",
),
(
"princess.md",
"# The Princess Bride\n\nInconceivable! Six fingers on the right hand of fate.\n",
),
(
"grail.md",
"# Holy Grail\n\nSeek the cup, beware the killer rabbit of caerbannog.\n",
),
(
"rust-async.md",
"# Rust Async\n\nFutures await tokio runtimes for non-blocking IO.\n",
),
(
"lancedb.md",
"# LanceDB\n\nEmbedded vector database written in Rust with built-in BM25 fulltext.\n",
),
(
"bge-small.md",
"# BGE Small\n\nA 384-dimensional sentence embedding model from BAAI.\n",
),
(
"text-splitter.md",
"# Text Splitter\n\nSemantic markdown chunking with tokenizer-aware budget.\n",
),
(
"rrf.md",
"# Reciprocal Rank Fusion\n\nCombines ranked result lists from heterogeneous retrievers.\n",
),
(
"blake3.md",
"# Blake3\n\nFast cryptographic hash function used for content fingerprinting.\n",
),
(
"fastembed.md",
"# FastEmbed\n\nLightweight Rust embeddings via ONNX without huge transformer deps.\n",
),
];
for (name, body) in files {
fs::write(dir.join(name), body).expect("write fixture");
}
}
#[tokio::test]
async fn fixture_corpus_indexes_and_serves_oracle_queries() {
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
seed_fixture_corpus(corpus_dir.path());
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut embedder = StubEmbedder;
let stats = index_corpus(&corpus, &store, Some(&mut embedder), ®istry)
.await
.expect("index_corpus");
assert_eq!(stats.files_upserted, 12, "all 12 fixture files indexed");
assert!(
stats.chunks_inserted >= 12,
"at least one chunk per file, got {}",
stats.chunks_inserted
);
assert_eq!(
stats.embeddings_inserted, stats.chunks_inserted,
"every chunk must get exactly one embedding"
);
let row_count = store.count_rows().await.unwrap();
assert!(
(12..200).contains(&row_count),
"row count out of plausible range: {row_count}"
);
let oracles: &[(&str, &str)] = &[
("melange", "arrakis.md"),
("inconceivable", "princess.md"),
("caerbannog", "grail.md"),
("Longbottom", "shire.md"),
("chrome-bright", "fury-road.md"),
];
let mut emb_for_query = StubEmbedder;
for (query, expected_file) in oracles {
let qv = emb_for_query
.embed_batch(&[(*query).to_string()], EmbedRole::Query)
.expect("embed query")[0];
let hits = hybrid_search(&store, "docs", query, &qv, 5)
.await
.expect("hybrid_search");
assert!(
!hits.is_empty(),
"no hits for oracle query {query:?} (expected file {expected_file})"
);
assert!(
hits.iter().any(|h| h.file_ref.ends_with(expected_file)),
"oracle query {query:?}: expected {expected_file} in top-{}, got {:?}",
hits.len(),
hits.iter().map(|h| h.file_ref.clone()).collect::<Vec<_>>()
);
}
}
#[tokio::test]
async fn fixture_corpus_reindex_is_idempotent_no_phantom_files() {
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
seed_fixture_corpus(corpus_dir.path());
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut embedder = StubEmbedder;
let stats1 = index_corpus(&corpus, &store, Some(&mut embedder), ®istry)
.await
.expect("first index");
let rows1 = store.count_rows().await.unwrap();
let stats2 = index_corpus(&corpus, &store, Some(&mut embedder), ®istry)
.await
.expect("second index");
let rows2 = store.count_rows().await.unwrap();
assert_eq!(rows1, rows2, "reindex must not change row count");
assert_eq!(stats2.embeddings_inserted, 0, "no chunks re-embedded");
assert_eq!(stats2.files_upserted, 0, "no files upserted");
assert_eq!(stats2.files_touched, 0, "no mtime change โ no touches");
let _ = stats1;
}
#[tokio::test]
async fn fixture_corpus_handles_file_deletion_via_index_corpus() {
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
seed_fixture_corpus(corpus_dir.path());
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut embedder = StubEmbedder;
index_corpus(&corpus, &store, Some(&mut embedder), ®istry)
.await
.expect("first index");
let initial = store.count_rows().await.unwrap();
fs::remove_file(corpus_dir.path().join("grail.md")).expect("remove grail.md");
let stats = index_corpus(&corpus, &store, Some(&mut embedder), ®istry)
.await
.expect("second index after delete");
let after = store.count_rows().await.unwrap();
assert!(
after < initial,
"row count should drop after file deletion: {initial} -> {after}"
);
assert!(stats.files_deleted >= 1, "must report at least 1 deletion");
let mut emb = StubEmbedder;
let qv = emb
.embed_batch(&["caerbannog".to_string()], EmbedRole::Query)
.expect("embed")[0];
let hits = hybrid_search(&store, "docs", "caerbannog", &qv, 5)
.await
.expect("search after delete");
assert!(
!hits.iter().any(|h| h.file_ref.ends_with("grail.md")),
"grail.md must no longer appear in results"
);
}
#[allow(dead_code)] const SMALL_BUDGET: usize = 60;
#[tokio::test]
async fn empty_files_are_skipped_and_counted_not_re_processed_each_run() {
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
fs::write(corpus_dir.path().join("real.md"), "# Real\n\nhas content\n").unwrap();
fs::write(corpus_dir.path().join("empty.md"), "").unwrap();
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut emb = StubEmbedder;
let stats1 = index_corpus(&corpus, &store, Some(&mut emb), ®istry)
.await
.expect("first index");
assert_eq!(stats1.files_upserted, 1, "only real.md upserted");
assert_eq!(
stats1.files_skipped_empty, 1,
"empty.md must be counted as skipped, not silently ignored"
);
}
#[tokio::test]
async fn prepare_file_io_errors_propagate_out_of_index_corpus() {
use hallouminate::domain::indexer::{DEFAULT_BATCH_SIZE, apply, plan};
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
let real = corpus_dir.path().join("vanishes.md");
fs::write(&real, "# vanishing\n").unwrap();
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut emb = StubEmbedder;
let disk = hallouminate::domain::corpus::scan(&corpus).expect("scan");
let db = store.list_files("docs").await.expect("list");
let p = plan(disk, db);
fs::remove_file(&real).unwrap();
let err = apply(
p,
&store,
Some(&mut emb),
®istry,
&corpus,
DEFAULT_BATCH_SIZE,
)
.await
.expect_err("missing file must surface as Err, not silent skip");
let msg = err.to_string();
assert!(
msg.contains("vanishes.md") || msg.contains("No such file") || msg.contains("not found"),
"error should reference the missing file: {msg}"
);
}
#[tokio::test]
async fn off_mode_index_and_ground_round_trip_returns_lexical_hits() {
use hallouminate::domain::ground::{GroundOpts, ground};
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
seed_fixture_corpus(corpus_dir.path());
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, false)
.await
.expect("open OFF-mode store");
let registry = HandlerRegistry::new(Characters, 1500);
let stats = index_corpus(&corpus, &store, None, ®istry)
.await
.expect("OFF-mode index_corpus");
assert_eq!(
stats.files_upserted, 12,
"all fixture files indexed in OFF mode"
);
assert!(
stats.chunks_inserted >= 12,
"chunks still inserted in OFF mode, got {}",
stats.chunks_inserted
);
assert_eq!(
stats.embeddings_inserted, 0,
"OFF mode must write zero embeddings (null vectors only)"
);
let resp = ground(
"caerbannog",
&corpus.name,
&corpus.paths,
&store,
None,
None,
GroundOpts::default(),
)
.await
.expect("OFF-mode ground");
assert!(resp.stats.hits > 0, "FTS must return at least one hit");
let top_ref = resp
.docs
.iter()
.max_by(|a, b| {
a.1.score
.partial_cmp(&b.1.score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(file_ref, _)| file_ref.clone())
.expect("at least one doc");
assert!(
top_ref.ends_with("grail.md"),
"distinctive token 'caerbannog' must surface grail.md, got {top_ref}"
);
}
#[tokio::test]
async fn index_corpus_embeds_passages_with_passage_role() {
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
seed_fixture_corpus(corpus_dir.path());
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut recorder = RoleRecordingEmbedder::default();
index_corpus(&corpus, &store, Some(&mut recorder), ®istry)
.await
.expect("index_corpus");
assert!(
!recorder.roles.is_empty(),
"indexing must embed at least one passage batch"
);
assert!(
recorder.roles.iter().all(|r| *r == EmbedRole::Passage),
"indexing must embed every batch with the Passage role, got {:?}",
recorder.roles
);
}
#[tokio::test]
async fn chunker_budget_compliance_with_characters_sizer() {
let chunker = MarkdownChunker::new(Characters, SMALL_BUDGET);
let big = "lorem ipsum dolor sit amet ".repeat(500); let chunks = chunker.chunk(&big);
assert!(!chunks.is_empty());
for c in &chunks {
assert!(
c.text.len() <= SMALL_BUDGET + 8,
"chunk exceeded budget: {} chars > {}",
c.text.len(),
SMALL_BUDGET
);
}
}
#[tokio::test]
async fn frontmatter_page_and_plain_page_both_index_and_ground_cleanly() {
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
fs::write(
corpus_dir.path().join("fm.md"),
"---\nstatus: reviewed\nowner: cheese-lord\nlast_verified: 2026-01-02\n---\n# Quokka Wisdom\n\nThe distinctive token zphyxnort lives on a known line.\n",
)
.expect("write fm fixture");
fs::write(
corpus_dir.path().join("plain.md"),
"# Plain Page\n\nA mundane page about the qwobblefrotz device.\n",
)
.expect("write plain fixture");
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut embedder = StubEmbedder;
let stats = index_corpus(&corpus, &store, Some(&mut embedder), ®istry)
.await
.expect("index_corpus");
assert_eq!(stats.files_upserted, 2, "both pages indexed");
let mut emb_for_query = StubEmbedder;
let qv = emb_for_query
.embed_batch(&["zphyxnort".to_string()], EmbedRole::Query)
.expect("embed query")[0];
let hits = hybrid_search(&store, "docs", "zphyxnort", &qv, 5)
.await
.expect("hybrid_search fm");
let hit = hits
.iter()
.find(|h| h.file_ref.ends_with("fm.md"))
.expect("fm.md must appear in hits");
assert!(
!hit.text.contains("status:"),
"chunk text leaked frontmatter: {:?}",
hit.text
);
assert!(
!hit.text.contains("cheese-lord"),
"chunk text leaked owner: {:?}",
hit.text
);
assert!(
!hit.summary.contains("status:"),
"summary leaked frontmatter: {:?}",
hit.summary
);
assert!(
!hit.heading_path
.iter()
.any(|h| h.contains("---") || h.contains("status")),
"heading path leaked frontmatter: {:?}",
hit.heading_path
);
assert!(
hit.line_start >= 6,
"fm offset not applied: line_start={} (expected >= 6)",
hit.line_start
);
assert!(
hit.line_start <= 8 && hit.line_end >= 8,
"chunk must bracket on-disk line 8 of `zphyxnort`: got [{}, {}]",
hit.line_start,
hit.line_end
);
let qv2 = emb_for_query
.embed_batch(&["qwobblefrotz".to_string()], EmbedRole::Query)
.expect("embed query")[0];
let hits2 = hybrid_search(&store, "docs", "qwobblefrotz", &qv2, 5)
.await
.expect("hybrid_search plain");
assert!(
hits2.iter().any(|h| h.file_ref.ends_with("plain.md")),
"plain page must ground: {:?}",
hits2.iter().map(|h| h.file_ref.clone()).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn claim_marks_round_trip_through_ground_with_clean_snippets() {
use hallouminate::domain::corpus::{ClaimMark, ClaimStatus};
use hallouminate::domain::ground::{GroundOpts, ground};
let store_dir = tempfile::tempdir().expect("tempdir store");
let corpus_dir = tempfile::tempdir().expect("tempdir corpus");
let page = "---\nstatus: reviewed\n---\n# Claims\n\n\
The confirmed fact about alphamelange.<!--claim:confirmed-->\n\n\
A qualified point about betamelange.<!--claim:qualified note=\"only on macOS\"-->\n\n\
A superseded statement about gammamelange.<!--claim:superseded ref=old/page.md-->\n\n\
A contradicted statement about deltamelange.<!--claim:contradicted ref=https://example.com/rfc note=\"repealed in v3\"-->\n\n\
A note about epsilonmelange with an ordinary comment.<!-- ordinary note -->\n";
let page_path = corpus_dir.path().join("claims.md");
fs::write(&page_path, page).expect("write claims fixture");
let corpus = CorpusConfig {
name: "docs".into(),
paths: vec![corpus_dir.path().to_string_lossy().into_owned()],
globs: vec!["**/*.md".into()],
exclude: vec![],
global: false,
};
let store = LanceStore::open_or_create(store_dir.path(), MODEL, false, true)
.await
.expect("open store");
let registry = HandlerRegistry::new(Characters, 1500);
let mut embedder = StubEmbedder;
let stats = index_corpus(&corpus, &store, Some(&mut embedder), ®istry)
.await
.expect("index_corpus");
assert_eq!(stats.files_upserted, 1, "the claims page indexed");
let mut embedder2 = StubEmbedder;
let resp = ground(
"alphamelange",
&corpus.name,
&corpus.paths,
&store,
Some(&mut embedder2),
None,
GroundOpts::default(),
)
.await
.expect("ground");
let doc = resp
.docs
.iter()
.find(|(path, _)| path.ends_with("claims.md"))
.map(|(_, d)| d)
.expect("claims.md must appear in ground results");
let mut marks: Vec<ClaimMark> = doc
.chunks
.iter()
.flat_map(|c| c.provenance.claim_marks.clone())
.collect();
marks.sort_by_key(|m| m.line);
assert_eq!(
marks,
vec![
ClaimMark {
status: ClaimStatus::Confirmed,
line: 6,
reference: None,
note: None,
},
ClaimMark {
status: ClaimStatus::Qualified,
line: 8,
reference: None,
note: Some("only on macOS".into()),
},
ClaimMark {
status: ClaimStatus::Superseded,
line: 10,
reference: Some("old/page.md".into()),
note: None,
},
ClaimMark {
status: ClaimStatus::Contradicted,
line: 12,
reference: Some("https://example.com/rfc".into()),
note: Some("repealed in v3".into()),
},
],
"all four statuses must round-trip with on-disk lines, refs, and notes"
);
for c in &doc.chunks {
assert!(
!c.snippet.contains("<!--claim:"),
"snippet leaked a raw claim comment: {:?}",
c.snippet
);
}
let bracketing = doc.chunks.iter().any(|c| {
let [start, end] = c.line_range;
start <= 6 && end >= 12
});
assert!(
bracketing,
"a chunk must bracket on-disk lines 6..=12 after strip: {:?}",
doc.chunks.iter().map(|c| c.line_range).collect::<Vec<_>>()
);
let on_disk = fs::read_to_string(&page_path).expect("read back claims.md");
assert_eq!(on_disk, page, "on-disk bytes must be untouched (verbatim)");
assert!(
on_disk.contains("<!--claim:confirmed-->"),
"claim comments must remain on disk for read_markdown"
);
}