use std::collections::BTreeSet;
use frankensearch_lexical as tantivy_cass;
use frankensearch_quill as quill;
pub const PROBE_LIMIT: usize = 90_000;
pub const TIE_EXPANSION: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CassProbeComparison {
pub query: String,
pub incumbent_total: usize,
pub quill_total: usize,
pub only_incumbent: Vec<String>,
pub only_quill: Vec<String>,
pub saturated: bool,
}
impl CassProbeComparison {
#[must_use]
pub fn agrees(&self) -> bool {
if self.incumbent_total != self.quill_total {
return false;
}
self.saturated || (self.only_incumbent.is_empty() && self.only_quill.is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CassEquivalenceReport {
pub incumbent_doc_count: usize,
pub quill_doc_count: usize,
pub probes: Vec<CassProbeComparison>,
}
impl CassEquivalenceReport {
#[must_use]
pub fn divergences(&self) -> Vec<&CassProbeComparison> {
self.probes.iter().filter(|probe| !probe.agrees()).collect()
}
#[must_use]
pub fn discriminating_probes(&self) -> usize {
self.probes
.iter()
.filter(|probe| probe.incumbent_total > 0)
.count()
}
#[must_use]
pub fn saturated_probes(&self) -> usize {
self.probes.iter().filter(|probe| probe.saturated).count()
}
#[must_use]
pub fn equivalent(&self) -> bool {
self.incumbent_doc_count == self.quill_doc_count && self.divergences().is_empty()
}
#[must_use]
pub fn render_divergences(&self) -> String {
self.divergences()
.iter()
.map(|probe| {
format!(
" {:>32} incumbent={:<6} quill={:<6} only_incumbent={:?} only_quill={:?}",
probe.query,
probe.incumbent_total,
probe.quill_total,
probe.only_incumbent.iter().take(4).collect::<Vec<_>>(),
probe.only_quill.iter().take(4).collect::<Vec<_>>(),
)
})
.collect::<Vec<_>>()
.join("\n")
}
}
#[must_use]
pub fn default_probe_set(terms: &[String]) -> Vec<String> {
let pick = |index: usize| -> &str {
if terms.is_empty() {
"term"
} else {
terms[index % terms.len()].as_str()
}
};
let mut probes = Vec::new();
for index in 0..12 {
probes.push(pick(index * 7).to_owned());
}
for index in 0..6 {
probes.push(format!("{} {}", pick(index), pick(index * 13 + 1)));
}
for index in 0..6 {
probes.push(format!("{} AND {}", pick(index), pick(index * 5 + 2)));
}
for index in 0..6 {
probes.push(format!("{} OR {}", pick(index), pick(index * 11 + 3)));
}
for index in 0..4 {
probes.push(format!("{} NOT {}", pick(index), pick(index * 3 + 4)));
}
for index in 0..3 {
probes.push(format!("\"{} {}\"", pick(index), pick(index + 1)));
}
for index in 0..4 {
let term = pick(index * 9);
let cut = term.len().min(4);
probes.push(format!("{}*", &term[..cut]));
}
probes.push("well-known".to_owned());
probes.push("multi-part-token".to_owned());
probes.push("日本語".to_owned());
probes.push("検索".to_owned());
probes.push(pick(1).to_uppercase());
probes.push("zzzznonexistenttokenzzzz".to_owned());
probes
}
pub async fn cass_engine_equivalence_report(
cx: &asupersync::Cx,
documents: &[quill::cass::CassDocument],
queries: &[String],
) -> Result<CassEquivalenceReport, Box<dyn std::error::Error + Send + Sync>> {
let mut oracle = tantivy_cass::CassTantivyIndex::in_memory_single_threaded_oracle()?;
let incumbent_documents: Vec<tantivy_cass::CassDocument> = documents
.iter()
.map(|document| tantivy_cass::CassDocument {
agent: document.agent.clone(),
workspace: document.workspace.clone(),
workspace_original: document.workspace_original.clone(),
source_path: document.source_path.clone(),
msg_idx: document.msg_idx,
created_at: document.created_at,
title: document.title.clone(),
content: document.content.clone(),
source_id: document.source_id.clone(),
origin_kind: document.origin_kind.clone(),
origin_host: document.origin_host.clone(),
conversation_id: document.conversation_id,
})
.collect();
oracle.add_cass_documents(&incumbent_documents)?;
oracle.commit()?;
let directory = tempfile::tempdir()?;
let index = quill::QuillIndex::create_with_schema(
cx,
directory.path(),
quill::schema::CASS_SEMANTIC_SCHEMA,
quill::QuillConfig::default(),
)
.await?;
let projected: Vec<quill::SchemaDocument> = documents
.iter()
.map(quill::cass::CassDocument::to_schema_document)
.collect();
index.index_schema_documents(cx, &projected).await?;
index.commit(cx).await?;
let reader = quill::QuillSearchIndex::open_with_schema(
cx,
directory.path(),
quill::schema::CASS_SEMANTIC_SCHEMA,
quill::QuillConfig::default(),
)
.await?;
let parser = quill::query::CassQueryParser::new(quill::schema::CASS_SEMANTIC_SCHEMA)?;
let mut probes = Vec::with_capacity(queries.len());
let mut incumbent_doc_count = 0_usize;
for raw in queries {
let observed = oracle.cass_oracle_observe_query(
raw,
&tantivy_cass::CassQueryFilters::default(),
PROBE_LIMIT,
TIE_EXPANSION,
)?;
incumbent_doc_count = observed.doc_count;
let parsed = parser.parse(raw, &quill::query::CassQueryFilters::default());
let result = reader.search_preparsed_paginated(cx, &parsed.query, PROBE_LIMIT, 0, true)?;
let incumbent_ids: BTreeSet<&str> = observed
.hits
.iter()
.map(|hit| hit.doc_id.as_str())
.collect();
let quill_ids: BTreeSet<&str> = result
.hits
.iter()
.map(|hit| hit.document_id.as_str())
.collect();
let saturated = observed.hits.len() >= PROBE_LIMIT || result.hits.len() >= PROBE_LIMIT;
probes.push(CassProbeComparison {
query: raw.clone(),
saturated,
incumbent_total: observed.total_count,
quill_total: usize::try_from(result.total_count.unwrap_or_default())
.expect("exact match count fits usize"),
only_incumbent: incumbent_ids
.difference(&quill_ids)
.map(|id| (*id).to_owned())
.collect(),
only_quill: quill_ids
.difference(&incumbent_ids)
.map(|id| (*id).to_owned())
.collect(),
});
}
Ok(CassEquivalenceReport {
incumbent_doc_count,
quill_doc_count: usize::try_from(reader.doc_count()?).expect("doc count fits usize"),
probes,
})
}
pub fn load_cass_corpus_jsonl(
path: &std::path::Path,
) -> Result<Vec<quill::cass::CassDocument>, Box<dyn std::error::Error + Send + Sync>> {
use std::io::BufRead as _;
let file = std::fs::File::open(path)?;
let mut documents = Vec::new();
for (offset, line) in std::io::BufReader::new(file).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let row: CassCorpusRow = serde_json::from_str(&line)
.map_err(|error| format!("{}:{}: {error}", path.display(), offset + 1))?;
documents.push(row.into_document());
}
Ok(documents)
}
#[derive(serde::Deserialize)]
struct CassCorpusRow {
agent: String,
#[serde(default)]
workspace: Option<String>,
#[serde(default)]
workspace_original: Option<String>,
source_path: String,
msg_idx: u64,
#[serde(default)]
created_at: Option<i64>,
#[serde(default)]
title: Option<String>,
content: String,
source_id: String,
origin_kind: String,
#[serde(default)]
origin_host: Option<String>,
#[serde(default)]
conversation_id: Option<i64>,
}
impl CassCorpusRow {
fn into_document(self) -> quill::cass::CassDocument {
quill::cass::CassDocument {
agent: self.agent,
workspace: self.workspace,
workspace_original: self.workspace_original,
source_path: self.source_path,
msg_idx: self.msg_idx,
created_at: self.created_at,
title: self.title,
content: self.content,
source_id: self.source_id,
origin_kind: self.origin_kind,
origin_host: self.origin_host,
conversation_id: self.conversation_id,
}
}
}