Add shared-engine calibration-only relevance diagnostics
This commit is contained in:
1 parent
4e60038c2f
commit
43c43ebdc9
5 files changed
+1668
No files matched your search
Generated
+1311
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "searchi-relevance-eval"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
searchi-core = { path = "../../crates/searchi-core" }
|
||||
searchi-service = { path = "../../crates/searchi-service" }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,50 @@
|
||||
# Calibration-only relevance diagnostic
|
||||
|
||||
This internal runner uses searchi's shared Rust engine and genuine Google
|
||||
EmbeddingGemma 2. It has **no held-out execution option**. It indexes all 14 frozen
|
||||
source files as the fixed distractor library, but evaluates only the 24
|
||||
calibration queries. Held-out queries stay unevaluated until a policy and its
|
||||
parameters are frozen using calibration evidence.
|
||||
|
||||
First verify all frozen labels, source bytes, licenses and split integrity:
|
||||
|
||||
```powershell
|
||||
python fixtures/relevance-v2/verify.py
|
||||
cargo test --manifest-path tools/relevance-eval/Cargo.toml --locked
|
||||
cargo build --release --manifest-path tools/relevance-eval/Cargo.toml --locked
|
||||
```
|
||||
|
||||
Only run when the shared CPU inference slot is free:
|
||||
|
||||
```powershell
|
||||
$env:SEARCHI_FORCE_CPU = '1'
|
||||
tools/relevance-eval/target/release/searchi-relevance-eval.exe fixtures/relevance-v2 C:/path/to/verified-models C:/path/to/searchi-gemma-worker.exe .searchi/qa/calibration-v2.json
|
||||
```
|
||||
|
||||
Supply an existing output directory and a new output filename. Existing evidence
|
||||
is never overwritten: the output is reserved with `create_new` before costly
|
||||
work and subsequent phase/row checkpoints replace JSON atomically. The runner verifies fixture SHA-256, exact source bytes,
|
||||
and both pinned Google assets before indexing. It records runner/worker/model
|
||||
hashes, actual worker capabilities, indexing/startup timings, all lexical
|
||||
candidates (including match kinds and source locations), raw semantic cosine
|
||||
scores, and final hybrid results with detailed engine traces. Code intents run
|
||||
both All files and Code scope; all lanes use the same strict scope filters.
|
||||
|
||||
The final 20-result hybrid request uses an 80-candidate budget. The diagnostic
|
||||
therefore records 80 lexical/semantic candidates separately. Semantic candidates
|
||||
are the production routed, per-file lane, not all passage vectors. Hybrid scores
|
||||
are reciprocal-rank-fusion scores, **not confidence**. No arbitrary cosine cutoff
|
||||
is applied, no vectors are simulated, and no quality acceptance claim follows
|
||||
from a successful run.
|
||||
|
||||
Queries execute lexical → semantic → hybrid in a retained query-role session.
|
||||
Hybrid consequently reuses the preceding semantic query vector. These timings
|
||||
are ordered diagnostic timings, not cold first input, UI latency, independent
|
||||
warm p95, or production performance evidence. Partial completed rows are saved
|
||||
after each request; an interrupted/failed report remains `complete: false`.
|
||||
|
||||
This corpus is small: fictional customer documents, two real source files and
|
||||
independent AI annotation. It does not cover media or production scale. Preserve
|
||||
labels and original raw output. A later policy experiment must fix all routing,
|
||||
abstention and parameter choices on calibration only before exposing held-out
|
||||
queries, and must retain exact filename priority and source provenance.
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Calibration-only diagnostics through the production shared engine.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use searchi_core::{SearchIndex, SearchOptions, SearchResult};
|
||||
use searchi_service::HybridSearch;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
fn digest(path: &Path) -> Result<String> {
|
||||
let mut stream = fs::File::open(path)?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0; 65536];
|
||||
loop {
|
||||
let count = stream.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..count]);
|
||||
}
|
||||
Ok(format!("{:x}", hash.finalize()))
|
||||
}
|
||||
|
||||
fn calibration_queries(fixture: &Value) -> Result<Vec<&Value>> {
|
||||
let queries = fixture["queries"].as_array().context("Missing queries")?;
|
||||
ensure!(
|
||||
queries
|
||||
.iter()
|
||||
.all(|q| matches!(q["split"].as_str(), Some("calibration" | "held_out"))),
|
||||
"Unknown split"
|
||||
);
|
||||
let selected: Vec<_> = queries
|
||||
.iter()
|
||||
.filter(|q| q["split"] == "calibration")
|
||||
.collect();
|
||||
ensure!(
|
||||
selected.len() == 24,
|
||||
"Expected exactly 24 frozen calibration queries"
|
||||
);
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
fn checkpoint(path: &Path, report: &Value) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
let mut staged = tempfile::NamedTempFile::new_in(parent)?;
|
||||
staged.write_all(&serde_json::to_vec_pretty(report)?)?;
|
||||
staged.as_file().sync_all()?;
|
||||
staged.persist(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn score(hits: &[SearchResult], query: &Value, fixture: &Value) -> Result<Value> {
|
||||
let expected = query["expected_ids"]
|
||||
.as_array()
|
||||
.context("Missing expected labels")?;
|
||||
let mut ids = Vec::new();
|
||||
for hit in hits {
|
||||
let asset = fixture["assets"]
|
||||
.as_array()
|
||||
.context("Missing assets")?
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
Path::new(asset["path"].as_str().unwrap_or_default())
|
||||
.file_name()
|
||||
.is_some_and(|name| name == hit.filename.as_str())
|
||||
})
|
||||
.context("Result outside fixed corpus")?;
|
||||
if !ids.contains(&asset["id"]) {
|
||||
ids.push(asset["id"].clone());
|
||||
}
|
||||
}
|
||||
let rank = ids
|
||||
.iter()
|
||||
.position(|id| expected.contains(id))
|
||||
.map(|rank| rank + 1);
|
||||
let absent = expected.is_empty();
|
||||
Ok(
|
||||
json!({"asset_ranking":ids,"first_relevant_rank":rank,"top1":if absent {None} else {Some(rank == Some(1))},"recall_at_5":if absent {None} else {Some(ids.iter().take(5).filter(|id| expected.contains(id)).count() as f64 / expected.len() as f64)},"absent_artifact_returned_neighbors":if absent {Some(!ids.is_empty())} else {None}}),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
ensure!(
|
||||
args.len() == 4,
|
||||
"Supply FIXTURE MODEL_DIR WORKER OUTPUT_JSON; held-out execution is unsupported"
|
||||
);
|
||||
ensure!(
|
||||
std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1"),
|
||||
"Set SEARCHI_FORCE_CPU=1"
|
||||
);
|
||||
let fixture_root = PathBuf::from(&args[0]).canonicalize()?;
|
||||
let models = PathBuf::from(&args[1]).canonicalize()?;
|
||||
let worker = PathBuf::from(&args[2]).canonicalize()?;
|
||||
let output = PathBuf::from(&args[3]);
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&output)
|
||||
.context("Cannot reserve output; existing evidence is never overwritten")?;
|
||||
let mut report = json!({"complete":false,"phase":"verifying","split":"calibration","held_out_evaluated":false,"cpu_threads":1,"rows":[]});
|
||||
checkpoint(&output, &report)?;
|
||||
let fixture_path = fixture_root.join("fixture.json");
|
||||
let fixture: Value = serde_json::from_slice(&fs::read(&fixture_path)?)?;
|
||||
ensure!(
|
||||
digest(&fixture_path)?
|
||||
== "000047fd17accd315b6138093bbdc9f353cd877b4bf1958fbe336e4a6b10cd2e",
|
||||
"This runner accepts only the reviewed frozen v2 fixture"
|
||||
);
|
||||
let lock: Value = serde_json::from_slice(&fs::read(fixture_root.join("fixture.lock.json"))?)?;
|
||||
ensure!(
|
||||
lock["sha256"]["fixture.json"] == digest(&fixture_path)?,
|
||||
"Frozen fixture changed"
|
||||
);
|
||||
let queries = calibration_queries(&fixture)?;
|
||||
let manifest_path =
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../embeddinggemma2/model-manifest.json");
|
||||
let manifest: Value = serde_json::from_slice(&fs::read(&manifest_path)?)?;
|
||||
for asset in manifest["files"]
|
||||
.as_array()
|
||||
.context("Missing model assets")?
|
||||
{
|
||||
let path = models.join(asset["path"].as_str().context("Missing model path")?);
|
||||
ensure!(
|
||||
fs::metadata(&path)?.len() == asset["bytes"].as_u64().context("Missing model size")?,
|
||||
"Model size mismatch"
|
||||
);
|
||||
ensure!(digest(&path)? == asset["sha256"], "Model hash mismatch");
|
||||
}
|
||||
report["fixture_sha256"] = json!(digest(&fixture_path)?);
|
||||
report["model_manifest_sha256"] = json!(digest(&manifest_path)?);
|
||||
report["model"] = manifest;
|
||||
report["assets"] = fixture["assets"].clone();
|
||||
report["worker_sha256"] = json!(digest(&worker)?);
|
||||
report["runner_sha256"] = json!(digest(&std::env::current_exe()?)?);
|
||||
report["phase"] = json!("materializing_and_lexical_index");
|
||||
checkpoint(&output, &report)?;
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let corpus = temporary.path().join("corpus");
|
||||
fs::create_dir(&corpus)?;
|
||||
// Index both splits as the fixed distractor library, but execute calibration queries only.
|
||||
for asset in fixture["assets"].as_array().context("Missing assets")? {
|
||||
let source = fixture_root
|
||||
.join(asset["path"].as_str().context("Missing asset path")?)
|
||||
.canonicalize()?;
|
||||
ensure!(
|
||||
source.starts_with(&fixture_root),
|
||||
"Source escapes frozen fixture"
|
||||
);
|
||||
ensure!(
|
||||
fs::metadata(&source)?.len()
|
||||
== asset["bytes"].as_u64().context("Missing asset size")?
|
||||
&& digest(&source)? == asset["sha256"],
|
||||
"Source changed"
|
||||
);
|
||||
let destination = corpus.join(source.file_name().context("Missing filename")?);
|
||||
ensure!(!destination.exists(), "Duplicate materialized filename");
|
||||
fs::copy(source, destination)?;
|
||||
}
|
||||
let db = temporary.path().join("index.sqlite");
|
||||
let mut lexical = SearchIndex::open(&db)?;
|
||||
let start = Instant::now();
|
||||
let lexical_report = lexical.index_folder(&corpus)?;
|
||||
let lexical_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
report["lexical_report"] = json!(lexical_report);
|
||||
report["lexical_index_ms"] = json!(lexical_ms);
|
||||
report["phase"] = json!("semantic_index");
|
||||
checkpoint(&output, &report)?;
|
||||
let start = Instant::now();
|
||||
let mut indexing = HybridSearch::open(&db, &models, &worker, 1)?;
|
||||
let index_report = indexing.index_embeddings()?;
|
||||
let semantic_index_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
report["index_report"] = json!(index_report);
|
||||
report["semantic_index_ms"] = json!(semantic_index_ms);
|
||||
report["phase"] = json!("query_warmup");
|
||||
checkpoint(&output, &report)?;
|
||||
ensure!(index_report.failures.is_empty(), "Semantic indexing failed");
|
||||
drop(indexing);
|
||||
let start = Instant::now();
|
||||
let mut query_session = HybridSearch::open_query(&db, &models, &worker, 1)?;
|
||||
query_session.warmup()?;
|
||||
let query_startup_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
report["worker_info"] = json!(query_session.worker_info());
|
||||
report["query_startup_ms"] = json!(query_startup_ms);
|
||||
report["phase"] = json!("calibration_queries");
|
||||
checkpoint(&output, &report)?;
|
||||
for query in queries {
|
||||
ensure!(
|
||||
query["split"] == "calibration",
|
||||
"Held-out execution forbidden"
|
||||
);
|
||||
let text = query["query"].as_str().context("Missing query text")?;
|
||||
let code_intent = query["id"]
|
||||
.as_str()
|
||||
.context("Missing query ID")?
|
||||
.starts_with("pattern-");
|
||||
for code_only in [false, true]
|
||||
.into_iter()
|
||||
.filter(|code_only| !*code_only || code_intent)
|
||||
{
|
||||
let options = SearchOptions {
|
||||
code_only,
|
||||
..Default::default()
|
||||
};
|
||||
// Match the production hybrid candidate budget for a final limit of 20.
|
||||
let lexical_hits = lexical.search_with_options_measured(text, 80, &options, true)?;
|
||||
let start = Instant::now();
|
||||
let semantic_hits = query_session.search_semantic(text, 80, &options)?;
|
||||
let semantic_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let hybrid_hits = query_session.search_measured(text, 20, &options, true)?;
|
||||
let scores = json!({"lexical":score(&lexical_hits.results,query,&fixture)?,"semantic":score(&semantic_hits,query,&fixture)?,"hybrid":score(&hybrid_hits.results,query,&fixture)?});
|
||||
report["rows"].as_array_mut().context("Missing output rows")?.push(json!({"query":query,"scope":if code_only {"code"} else {"all_files"},"lexical":lexical_hits,"semantic_raw":semantic_hits,"semantic_ms":semantic_ms,"hybrid":hybrid_hits,"scores":scores,"order_note":"lexical then semantic then hybrid; hybrid reuses the preceding query-vector cache. Raw semantic scores are cosine, hybrid scores are RRF, not confidence."}));
|
||||
checkpoint(&output, &report)?;
|
||||
}
|
||||
}
|
||||
report["complete"] = json!(true);
|
||||
report["phase"] = json!("complete");
|
||||
checkpoint(&output, &report)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn checkpoint_replaces_complete_json_and_existing_evidence_is_reserved() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let path = temporary.path().join("evidence.json");
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
checkpoint(&path, &json!({"complete":false,"rows":[1]})).unwrap();
|
||||
assert!(fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.is_err());
|
||||
checkpoint(&path, &json!({"complete":true,"rows":[1,2]})).unwrap();
|
||||
let report: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(report["rows"], json!([1, 2]));
|
||||
assert_eq!(fs::read_dir(temporary.path()).unwrap().count(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn rejects_unknown_split_and_never_selects_held_out() {
|
||||
let mut queries = vec![json!({"split":"calibration"}); 24];
|
||||
queries.push(json!({"split":"held_out","query":"DO NOT EXECUTE"}));
|
||||
let fixture = json!({"queries":queries});
|
||||
assert!(calibration_queries(&fixture)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|q| q["split"] == "calibration"));
|
||||
assert!(calibration_queries(&json!({"queries":[{"split":"unknown"}]})).is_err());
|
||||
assert!(calibration_queries(&json!({"queries":[{"split":"held_out"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_calibration_has_six_code_requests_and_disjoint_query_ids() {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../fixtures/relevance-v2/fixture.json");
|
||||
let fixture: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
|
||||
let selected = calibration_queries(&fixture).unwrap();
|
||||
assert_eq!(
|
||||
selected
|
||||
.iter()
|
||||
.filter(|q| q["id"].as_str().unwrap().starts_with("pattern-"))
|
||||
.count(),
|
||||
6
|
||||
);
|
||||
for query in selected {
|
||||
assert!(!fixture["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|held| held["split"] == "held_out" && held["id"] == query["id"]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user