Verify extraction provenance and prune semantic staging before inference

This commit is contained in:
karl committed 2026-10-13 01:14:40 +02:00
1 parent 5c18e7b49c
commit 423a1ea57d
17 files changed
+889 -67

No files matched your search

+6
View File
@@ -30,6 +30,12 @@ readiness without completed release gates and recorded evidence.
- Desktop setup must work out of the box: ship the native worker, automatically
download/verify missing Google assets, reuse cached assets, and expose progress/retry in the UI.
- Add meaningful tests for retrieval, persistence, and filesystem lifecycle changes.
- Persist SHA-256 of exact parser input bytes. Same-size/mtime overwrites must
be detected on rescans; semantic encoding and agent excerpts must reject
missing or stale extraction provenance. Preserve legacy lexical search.
- Validate sources after ANN preparation, before the publication write
transaction. Prune obsolete staging before inference without dropping
compatible completed work or modifying the active generation.
- Keep docs/ROADMAP.md current. Unexecuted platform checks remain unchecked.
- Gitea Actions are disabled for this repository at Karl's explicit request
(2026-10-12, repeated failure emails). Do not re-enable without his request.
Generated
+1
View File
@@ -2268,6 +2268,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"walkdir",
"zip",
+3 -2
View File
@@ -77,11 +77,12 @@ Incremental reuse/recovery additionally requires verified CPU FFmpeg helpers in
SEARCHI_TEST_FFMPEG and SEARCHI_TEST_FFPROBE. Run the actual Google integration:
```sh
cargo test -p searchi-service --test incremental_google_cpu --test google_cpu --locked -- --ignored --nocapture --test-threads=1
cargo test -p searchi-service --test publication_google_cpu --test incremental_google_cpu --test google_cpu --locked -- --ignored --nocapture --test-threads=1
```
Completed-group reuse must preserve actual vector bytes and remap current source
citations. Do not substitute simulated vectors or treat zero encoder calls as a
citations. Verify exact parser-input provenance and reject stale extraction before
encoding; legacy records require a normal rescan. Do not substitute simulated vectors or treat zero encoder calls as a
foreground latency benchmark. See docs/INCREMENTAL-SEMANTIC.md for remaining gates.
Record actual outputs, hardware, corpus size, background preparation, first-input
+1
View File
@@ -3719,6 +3719,7 @@ dependencies = [
"quick-xml",
"rusqlite",
"serde",
"sha2 0.10.9",
"walkdir",
"zip",
]
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
sha2 = "0.10"
rusqlite = { version = "0.32", features = ["bundled"] }
walkdir = "2"
lopdf = { version = "0.45", default-features = false }
+53 -23
View File
@@ -2,6 +2,7 @@
use crate::SourceLocation;
use anyhow::{bail, Context, Result};
use quick_xml::{events::Event, name::ResolveResult, NsReader, XmlVersion};
use sha2::{Digest, Sha256};
use std::{
fs::File,
io::{Cursor, Read},
@@ -19,32 +20,25 @@ pub(crate) struct Passage {
pub source: SourceLocation,
}
pub(crate) fn extract(path: &Path) -> Result<Option<Vec<Passage>>> {
pub(crate) struct SourceInput {
bytes: Vec<u8>,
pub hash: String,
}
/// Read once: the fingerprint and parser consume precisely the same bytes.
pub(crate) fn read_source(path: &Path) -> Result<Option<SourceInput>> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if extension == "pdf" || extension == "docx" {
let bytes = read_bounded(path, MAX_DOCUMENT)?;
return if extension == "pdf" {
pdf(&bytes).map(Some)
} else {
docx(&bytes).map(Some)
};
}
let code = crate::is_code_path(path);
if !code
let document = matches!(extension.as_str(), "pdf" | "docx");
if !document
&& !crate::is_code_path(path)
&& !matches!(
extension.as_str(),
"txt"
| "md"
| "rs"
| "py"
| "js"
| "ts"
| "tsx"
| "jsx"
| "json"
| "toml"
| "yaml"
@@ -59,16 +53,31 @@ pub(crate) fn extract(path: &Path) -> Result<Option<Vec<Passage>>> {
{
return Ok(None);
}
let file = File::open(path)?;
if file.metadata()?.len() > MAX_TEXT as u64 {
if !document && File::open(path)?.metadata()?.len() > MAX_TEXT as u64 {
return Ok(None);
}
let mut bytes = Vec::new();
file.take(MAX_TEXT as u64 + 1).read_to_end(&mut bytes)?;
if bytes.len() > MAX_TEXT || bytes.contains(&0) {
let bytes = read_bounded(path, if document { MAX_DOCUMENT } else { MAX_TEXT })?;
let hash = format!("{:x}", Sha256::digest(&bytes));
Ok(Some(SourceInput { bytes, hash }))
}
pub(crate) fn extract(path: &Path, input: SourceInput) -> Result<Option<Vec<Passage>>> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if extension == "pdf" {
return pdf(&input.bytes).map(Some);
}
if extension == "docx" {
return docx(&input.bytes).map(Some);
}
let code = crate::is_code_path(path);
if input.bytes.contains(&0) {
return Ok(None);
}
let Ok(text) = String::from_utf8(bytes) else {
let Ok(text) = String::from_utf8(input.bytes) else {
return Ok(None);
};
let lines = text.split_inclusive('\n').collect::<Vec<_>>();
@@ -288,3 +297,24 @@ fn docx(bytes: &[u8]) -> Result<Vec<Passage>> {
}
Ok(passages)
}
#[cfg(test)]
mod provenance_tests {
use super::*;
#[test]
fn parsing_and_fingerprint_share_one_snapshot_even_when_path_changes() -> Result<()> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("notes.txt");
std::fs::write(&path, "original parser input")?;
let input = read_source(&path)?.unwrap();
assert_eq!(
input.hash,
format!("{:x}", Sha256::digest(b"original parser input"))
);
std::fs::write(&path, "replacement on disk")?;
let passages = extract(&path, input)?.unwrap();
assert_eq!(passages[0].text, "original parser input");
Ok(())
}
}
+72 -12
View File
@@ -21,7 +21,7 @@ use std::{
};
use walkdir::WalkDir;
const SCHEMA_VERSION: i64 = 11;
const SCHEMA_VERSION: i64 = 12;
const MAX_RESULTS: usize = 1000;
#[derive(Debug, Default, Serialize)]
@@ -255,7 +255,14 @@ impl SearchIndex {
[],
)?;
}
initialization.execute_batch("PRAGMA user_version=11;")?;
let has_extraction_hash: bool = initialization.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('files') WHERE name='extracted_sha256')",
[], |row| row.get(0),
)?;
if !has_extraction_hash {
initialization.execute("ALTER TABLE files ADD COLUMN extracted_sha256 TEXT", [])?;
}
initialization.execute_batch("PRAGMA user_version=12;")?;
initialization.commit()?;
}
Ok(Self {
@@ -265,6 +272,21 @@ impl SearchIndex {
})
}
/// SHA-256 of the exact raw bytes used for the indexed extraction. Legacy
/// records return None until reindexed; callers must not infer provenance
/// from size/mtime or hash a later file read on their behalf.
pub fn extracted_source_hash(&self, path: &str) -> Result<Option<String>> {
Ok(self
.connection
.query_row(
"SELECT extracted_sha256 FROM files WHERE path=?1",
[path],
|row| row.get::<_, Option<String>>(0),
)
.optional()?
.flatten())
}
/// Persist genuine extracted media tags, guarded against filesystem changes.
pub fn set_media_metadata(
&mut self,
@@ -442,18 +464,46 @@ impl SearchIndex {
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let previous: Option<(i64, i64, String)> = transaction
let previous: Option<(i64, i64, String, Option<String>)> = transaction
.query_row(
"SELECT size,mtime,root FROM files WHERE path=?1",
"SELECT size,mtime,root,extracted_sha256 FROM files WHERE path=?1",
[&path],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
if previous == Some((size, mtime, root_key.clone())) {
// Supported inputs are bounded and fingerprinted even when file
// metadata is unchanged. This detects same-stat overwrites while
// unsupported media/giant files retain the metadata-only fast path.
let input = extraction::read_source(entry.path());
if matches!(&input, Ok(Some(_))) {
let after = fs::metadata(entry.path())?;
if after.len() != metadata.len() || after.modified()? != metadata.modified()? {
bail!("Source changed during extraction read; retry indexing: {path}; reconciliation rolled back");
}
}
let source_hash = input
.as_ref()
.ok()
.and_then(|input| input.as_ref())
.map(|input| input.hash.clone());
if previous
.as_ref()
.is_some_and(|(old_size, old_mtime, old_root, old_hash)| {
*old_size == size
&& *old_mtime == mtime
&& old_root == &root_key
&& input.is_ok()
&& old_hash == &source_hash
})
{
report.unchanged += 1;
continue;
}
let (extracted, extraction_error) = match extraction::extract(entry.path()) {
let extracted = input.and_then(|input| match input {
Some(input) => extraction::extract(entry.path(), input),
None => Ok(None),
});
let (extracted, extraction_error) = match extracted {
Ok(extracted) => (extracted, None),
Err(error) if matches!(extension.as_str(), "pdf" | "docx") => {
report.failures += 1;
@@ -475,11 +525,11 @@ impl SearchIndex {
.collect::<Vec<_>>()
.join("\n");
transaction.execute(
"INSERT INTO files(path,root,filename,content,size,mtime,extension,extraction_error) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)
"INSERT INTO files(path,root,filename,content,size,mtime,extension,extraction_error,extracted_sha256) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)
ON CONFLICT(path) DO UPDATE SET root=excluded.root, filename=excluded.filename,
content=excluded.content, size=excluded.size, mtime=excluded.mtime,
extension=excluded.extension, extraction_error=excluded.extraction_error",
params![path, root_key, filename, content, size, mtime, extension, extraction_error],
extension=excluded.extension, extraction_error=excluded.extraction_error, extracted_sha256=excluded.extracted_sha256",
params![path, root_key, filename, content, size, mtime, extension, extraction_error, source_hash],
)?;
transaction.execute("DELETE FROM passages WHERE file_path=?1", [&path])?;
for passage in extracted {
@@ -962,8 +1012,8 @@ impl SearchIndex {
}
/// Read an indexed document source for agent citations or explicit answers.
/// No file content is read: the canonical source must still match the indexed
/// size and nanosecond modification time. Callers must disclose this bounded
/// The canonical source must match indexed metadata and the fingerprint of
/// the exact bounded parser input. Callers must disclose this bounded
/// source excerpt before sending it to an optional external model.
pub fn source_passage(
&self,
@@ -993,6 +1043,16 @@ impl SearchIndex {
let mtime = metadata.modified()?.duration_since(UNIX_EPOCH)?;
let size = i64::try_from(metadata.len())?;
let modified = i64::try_from(mtime.as_nanos())?;
let Some(expected_hash) = self.extracted_source_hash(path)? else {
return Ok(None);
};
let current_hash = match extraction::read_source(&canonical) {
Ok(Some(input)) => input.hash,
_ => return Ok(None),
};
if current_hash != expected_hash {
return Ok(None);
}
self.connection
.query_row(
"SELECT p.id,f.path,f.filename,substr(p.text,1,?6),p.kind,p.number
@@ -0,0 +1,92 @@
//! Exact extraction provenance and same-metadata mutation regressions.
use anyhow::Result;
use searchi_core::SearchIndex;
use sha2::{Digest, Sha256};
use std::{fs, fs::FileTimes};
#[test]
fn same_stat_overwrite_invalidates_agent_excerpt_and_rescan_reextracts() -> Result<()> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("source.rs");
let original = "fn old_symbol() { /* first */ }\n";
let changed = "fn new_symbol() { /* other */ }\n";
assert_eq!(original.len(), changed.len());
fs::write(&path, original)?;
let mut index = SearchIndex::open(":memory:")?;
index.index_folder(directory.path())?;
let passage = index.passage_batch(0, 1)?.remove(0);
assert_eq!(
index.extracted_source_hash(&passage.path)?,
Some(format!("{:x}", Sha256::digest(original.as_bytes())))
);
let modified = fs::metadata(&path)?.modified()?;
fs::write(&path, changed)?;
fs::File::options()
.write(true)
.open(&path)?
.set_times(FileTimes::new().set_modified(modified))?;
assert!(index
.source_passage(&passage.path, &passage.source, 8192)?
.is_none());
let report = index.index_folder(directory.path())?;
assert_eq!(report.indexed, 1);
assert_eq!(report.unchanged, 0);
let fresh = index.passage_batch(0, 1)?.remove(0);
assert_eq!(fresh.text, changed);
assert_eq!(
index.extracted_source_hash(&fresh.path)?,
Some(format!("{:x}", Sha256::digest(changed.as_bytes())))
);
assert_eq!(
index
.source_passage(&fresh.path, &fresh.source, 8192)?
.unwrap()
.text,
changed
);
assert_eq!(index.index_folder(directory.path())?.unchanged, 1);
Ok(())
}
#[test]
fn schema_eleven_keeps_lexical_content_but_requires_extraction_provenance() -> Result<()> {
let directory = tempfile::tempdir()?;
let root = directory.path().join("docs");
fs::create_dir(&root)?;
fs::write(root.join("notes.txt"), "preserved migration evidence")?;
let db = directory.path().join("index.db");
let passage;
{
let mut index = SearchIndex::open(&db)?;
index.index_folder(&root)?;
passage = index.passage_batch(0, 1)?.remove(0);
}
let connection = rusqlite::Connection::open(&db)?;
connection
.execute_batch("ALTER TABLE files DROP COLUMN extracted_sha256; PRAGMA user_version=11;")?;
drop(connection);
let mut index = SearchIndex::open(&db)?;
assert_eq!(index.search("migration", 10)?.len(), 1);
assert!(index.extracted_source_hash(&passage.path)?.is_none());
assert!(index
.source_passage(&passage.path, &passage.source, 8192)?
.is_none());
assert_eq!(index.index_folder(&root)?.indexed, 1);
assert!(index.extracted_source_hash(&passage.path)?.is_some());
assert_eq!(index.index_folder(&root)?.unchanged, 1);
Ok(())
}
#[test]
fn oversized_text_and_unsupported_files_remain_filename_only() -> Result<()> {
let directory = tempfile::tempdir()?;
let large = directory.path().join("large.txt");
fs::File::create(&large)?.set_len(2 * 1024 * 1024 + 1)?;
fs::File::create(directory.path().join("movie.mp4"))?.set_len(100 * 1024 * 1024)?;
let mut index = SearchIndex::open(":memory:")?;
assert_eq!(index.index_folder(directory.path())?.indexed, 2);
assert!(index.passage_batch(0, 64)?.is_empty());
assert_eq!(index.index_folder(directory.path())?.unchanged, 2);
assert_eq!(index.search("large.txt", 10)?.len(), 1);
Ok(())
}
+1 -1
View File
@@ -301,7 +301,7 @@ impl HybridSearch {
"{}:text-only",
generation_fingerprint(self.embedder.fingerprint())
);
self.rebuild_generation(fingerprint, |service, generation, work| {
self.rebuild_generation(fingerprint, None, |service, generation, work| {
service.build(generation, work)
})
}
+1 -1
View File
@@ -42,7 +42,7 @@ impl HybridSearch {
limits.max_file_bytes
));
}
self.rebuild_generation(fingerprint, |service, generation, work| {
self.rebuild_generation(fingerprint, Some(limits), |service, generation, work| {
service.build(generation, work)?;
let mut after = 0;
let mut media_count = 0usize;
+91 -5
View File
@@ -53,6 +53,7 @@ impl HybridSearch {
pub(super) fn rebuild_generation(
&mut self,
fingerprint: String,
media_limits: Option<&searchi_media::MediaLimits>,
build: impl FnOnce(&mut Self, i64, &mut BuildWork) -> Result<()>,
) -> Result<EmbeddingReport> {
let lock = self.store.build_lock()?;
@@ -83,20 +84,31 @@ impl HybridSearch {
media: Vec::new(),
documents: HashMap::new(),
};
let result = build(self, generation, &mut work).and_then(|()| {
let result = self
.discover_tasks(media_limits)
.and_then(|tasks| {
self.store
.prune_before_inference(generation, &tasks, revision)?;
build(self, generation, &mut work)
})
.and_then(|()| {
self.store
.reconcile_tasks(generation, &work.keys, revision)?;
self.store
.activate_checked_with_validation(generation, revision, || {
for identity in work.media.iter().chain(work.documents.values()) {
// Full bytes, not just stat metadata: in-place changes can
// preserve size and timestamps, including on mounted volumes.
let current = searchi_media::MediaIdentity::read(&identity.path, identity.bytes)?;
let current =
searchi_media::MediaIdentity::read(&identity.path, identity.bytes)?;
ensure!(
current.sha256 == identity.sha256
&& current.modified_ns == identity.modified_ns,
"Source changed during semantic indexing; retry required"
);
}
self.store
.reconcile_tasks(generation, &work.keys, revision)?;
self.store.activate_checked(generation, revision)?;
Ok(())
})?;
Ok(work.report)
});
if result.is_err() {
@@ -105,6 +117,77 @@ impl HybridSearch {
result
}
/// Discover source identities in keyset batches before spending the encoder
/// budget. This extra byte-verification pass enables safe staging pruning;
/// it is indexing I/O, not a foreground latency optimization.
fn discover_tasks(
&self,
media_limits: Option<&searchi_media::MediaLimits>,
) -> Result<Vec<SourceTask>> {
let mut tasks = Vec::new();
let mut after = 0;
loop {
let passages = self.lexical.passage_batch(after, 64)?;
if passages.is_empty() {
break;
}
for passage in passages {
after = passage.id;
if passage.text.trim().is_empty() {
continue;
}
ensure!(
tasks.len() < MAX_SCAN_CHUNKS,
"Semantic source task budget exceeded"
);
tasks.push(text_task(&passage, &self.generation_fingerprint)?);
}
}
if let Some(limits) = media_limits {
let mut after = 0;
let mut count = 0;
loop {
let files = self.store.media_batch(after)?;
if files.is_empty() {
break;
}
for (id, path, _, _) in files {
after = id;
let Some(kind) = searchi_media::classify(Path::new(&path)) else {
continue;
};
count += 1;
ensure!(
count <= 10000,
"Media generation exceeds 10000-file resource budget"
);
ensure!(
tasks.len() < MAX_SCAN_CHUNKS,
"Semantic source task budget exceeded"
);
let identity = searchi_media::MediaIdentity::read(
Path::new(&path),
if kind == searchi_media::MediaKind::Image {
64 * 1024 * 1024
} else {
limits.max_file_bytes
},
);
tasks.push(SourceTask {
key: task_key(&["media", &path]),
path,
source_type: "media".into(),
source_hash: identity.map(|identity| identity.sha256).unwrap_or_default(),
title_hash: String::new(),
policy: self.generation_fingerprint.clone(),
passage_id: -id,
});
}
}
}
Ok(tasks)
}
pub(super) fn build(&mut self, generation: i64, work: &mut BuildWork) -> Result<()> {
let mut after_id = 0;
loop {
@@ -126,6 +209,9 @@ impl HybridSearch {
Path::new(&passage.path),
32 * 1024 * 1024,
)?;
let extracted = self.lexical.extracted_source_hash(&passage.path)?;
ensure!(extracted.as_deref() == Some(identity.sha256.as_str()),
"Document bytes differ from extracted passages; reindex the folder before semantic indexing");
work.documents.insert(passage.path.clone(), identity);
}
let task = text_task(&passage, &self.generation_fingerprint)?;
+11 -2
View File
@@ -305,10 +305,15 @@ impl Store {
#[cfg(test)]
pub(crate) fn activate(&mut self, generation: i64) -> Result<()> {
self.activate_revision(generation, None)
self.activate_revision(generation, None, || Ok(()))
}
fn activate_revision(&mut self, generation: i64, revision: Option<i64>) -> Result<()> {
fn activate_revision(
&mut self,
generation: i64,
revision: Option<i64>,
validate_sources: impl FnOnce() -> Result<()>,
) -> Result<()> {
// A failed graph build or serialization leaves the old generation active.
let ann = self.build_ann(generation)?;
let length = ann.serialized_length();
@@ -319,6 +324,10 @@ impl Store {
let mut serialized = vec![0_u8; length];
ann.save_to_buffer(&mut serialized)?;
let checksum = format!("{:x}", Sha256::digest(&serialized));
// Hashing/probing sources can be slow: run after graph preparation but
// before acquiring the write transaction. The revision check below
// still closes races with indexed changes after this validation.
validate_sources()?;
let transaction = self
.connection
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
+292 -1
View File
@@ -181,6 +181,131 @@ impl Store {
Ok(())
}
/// Reconcile completed staging groups before any encoder call. A bounded
/// SQL snapshot preserves renamed donors (including swaps and duplicates)
/// while rebuilding only current compatible memberships. Active vectors
/// are never touched. Full range/vector validation still happens on reuse.
pub(crate) fn prune_before_inference(
&mut self,
generation: i64,
tasks: &[SourceTask],
revision: i64,
) -> Result<()> {
ensure!(
tasks.len() <= MAX_SCAN_CHUNKS,
"Generation exceeds source task budget"
);
for task in tasks {
validate_task(task)?;
}
// The bounded snapshot can still contain 100K x 768d vectors. Keep it
// SQLite-backed on disk rather than turning that into a Rust/heap copy.
self.connection.pragma_update(None, "temp_store", "FILE")?;
let tx = self
.connection
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let current: i64 = tx.query_row(
"SELECT corpus_revision FROM semantic_meta WHERE singleton=1",
[],
|r| r.get(0),
)?;
ensure!(
current == revision,
"Indexed corpus changed during semantic discovery; retry required"
);
let active: bool = tx.query_row(
"SELECT active_generation=?1 FROM semantic_meta WHERE singleton=1",
[generation],
|r| r.get::<_, Option<bool>>(0).map(|v| v.unwrap_or(false)),
)?;
ensure!(!active, "Cannot prune the active semantic generation");
let existing: usize = tx.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[generation],
|r| r.get(0),
)?;
ensure!(
existing <= MAX_SCAN_CHUNKS,
"Staging generation exceeds semantic chunk budget"
);
tx.execute_batch("CREATE TEMP TABLE IF NOT EXISTS semantic_discovery_tasks(task_key TEXT PRIMARY KEY,path TEXT NOT NULL,source_type TEXT NOT NULL,source_hash TEXT NOT NULL,title_hash TEXT NOT NULL,policy TEXT NOT NULL,passage_id INTEGER NOT NULL);
DELETE FROM semantic_discovery_tasks;
CREATE TEMP TABLE IF NOT EXISTS semantic_prune_tasks AS SELECT * FROM semantic_tasks WHERE 0;
CREATE TEMP TABLE IF NOT EXISTS semantic_prune_chunks AS SELECT * FROM semantic_chunks WHERE 0;
DELETE FROM semantic_prune_tasks; DELETE FROM semantic_prune_chunks;")?;
for task in tasks {
tx.execute(
"INSERT INTO semantic_discovery_tasks VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![
task.key,
task.path,
task.source_type,
task.source_hash,
task.title_hash,
task.policy,
task.passage_id
],
)?;
}
tx.execute("INSERT INTO semantic_prune_tasks SELECT * FROM semantic_tasks t WHERE generation=?1 AND state='done' AND source_hash!='' AND expected_chunks IS NOT NULL AND expected_chunks=(SELECT count(*) FROM semantic_chunks c WHERE c.generation=t.generation AND c.task_key=t.task_key)",[generation])?;
tx.execute(
"INSERT INTO semantic_prune_chunks SELECT * FROM semantic_chunks WHERE generation=?1",
[generation],
)?;
tx.execute_batch("CREATE UNIQUE INDEX IF NOT EXISTS temp.semantic_prune_task_key ON semantic_prune_tasks(task_key);
CREATE INDEX IF NOT EXISTS temp.semantic_prune_reuse ON semantic_prune_tasks(source_hash,title_hash,policy,source_type);
CREATE INDEX IF NOT EXISTS temp.semantic_prune_membership ON semantic_prune_chunks(task_key);")?;
let mut mappings = Vec::new();
{
let mut statement = tx.prepare("SELECT d.task_key,COALESCE((SELECT old.task_key FROM semantic_prune_tasks old WHERE old.task_key=d.task_key AND old.source_hash=d.source_hash AND old.title_hash=d.title_hash AND old.policy=d.policy AND old.source_type=d.source_type),(SELECT old.task_key FROM semantic_prune_tasks old WHERE old.source_hash=d.source_hash AND old.title_hash=d.title_hash AND old.policy=d.policy AND old.source_type=d.source_type ORDER BY old.task_key LIMIT 1)) FROM semantic_discovery_tasks d")?;
let rows = statement.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
})?;
for row in rows {
let (key, donor) = row?;
if let Some(donor) = donor {
mappings.push((key, donor));
}
}
}
let mut total = 0usize;
for (_, donor) in &mappings {
let count: usize = tx.query_row(
"SELECT expected_chunks FROM semantic_prune_tasks WHERE task_key=?1",
[donor],
|r| r.get(0),
)?;
total = total
.checked_add(count)
.context("Semantic chunk count overflow")?;
ensure!(
total <= MAX_SCAN_CHUNKS,
"Current completed sources exceed semantic chunk budget"
);
}
tx.execute(
"DELETE FROM semantic_chunks WHERE generation=?1",
[generation],
)?;
tx.execute(
"DELETE FROM semantic_tasks WHERE generation=?1",
[generation],
)?;
for (key, donor) in mappings {
tx.execute("INSERT INTO semantic_tasks(generation,task_key,path,source_type,source_hash,title_hash,policy,passage_id,state,attempts,expected_chunks,message)
SELECT ?1,d.task_key,d.path,d.source_type,d.source_hash,d.title_hash,d.policy,d.passage_id,'done',old.attempts,old.expected_chunks,NULL FROM semantic_discovery_tasks d JOIN semantic_prune_tasks old ON old.task_key=?3 WHERE d.task_key=?2",params![generation,key,donor])?;
tx.execute("INSERT INTO semantic_chunks(generation,passage_id,ordinal,path,kind,number,content_hash,start_byte,end_byte,snippet,vector,source_type,file_size,file_mtime,task_key)
SELECT ?1,d.passage_id,c.ordinal,d.path,c.kind,c.number,c.content_hash,c.start_byte,c.end_byte,c.snippet,c.vector,c.source_type,c.file_size,c.file_mtime,d.task_key FROM semantic_prune_chunks c JOIN semantic_discovery_tasks d ON d.task_key=?2 WHERE c.task_key=?3",params![generation,key,donor])?;
}
tx.execute(
"DELETE FROM semantic_media_errors WHERE generation=?1",
[generation],
)?;
tx.execute_batch("DELETE FROM semantic_prune_tasks; DELETE FROM semantic_prune_chunks; DELETE FROM semantic_discovery_tasks;")?;
tx.commit()?;
Ok(())
}
pub(crate) fn reconcile_tasks(
&mut self,
generation: i64,
@@ -221,9 +346,21 @@ impl Store {
Ok(())
}
#[cfg(test)]
pub(crate) fn activate_checked(&mut self, generation: i64, revision: i64) -> Result<()> {
self.activate_checked_with_validation(generation, revision, || Ok(()))
}
/// Validate live sources after graph preparation, outside the SQLite write
/// transaction. Rejection preserves both the active reader and staging.
pub(crate) fn activate_checked_with_validation(
&mut self,
generation: i64,
revision: i64,
validate_sources: impl FnOnce() -> Result<()>,
) -> Result<()> {
validate_completion(&self.connection, generation)?;
self.activate_revision(generation, Some(revision))
self.activate_revision(generation, Some(revision), validate_sources)
}
}
@@ -468,6 +605,107 @@ mod tests {
store.complete_source(g, t, 1)
}
#[test]
fn pre_inference_pruning_preserves_renamed_donors_at_the_chunk_ceiling() -> Result<()> {
// Actual 100K membership boundary; vectors are storage-only fixtures.
let dir = tempfile::tempdir()?;
let root = dir.path().join("docs");
std::fs::create_dir(&root)?;
std::fs::write(root.join("notes.txt"), "complete source")?;
let db = dir.path().join("index.db");
let mut lexical = SearchIndex::open(&db)?;
lexical.index_folder(&root)?;
let p = lexical.passage_batch(0, 1)?.remove(0);
let mut store = Store::open(&db, 2)?;
let lock = store.build_lock()?;
let active = store.start_or_resume("prune-policy", &lock)?.0;
insert_complete(&mut store, active, &p, &task(&p))?;
store.activate_checked(active, store.revision()?)?;
let staging = store.start_or_resume("prune-policy", &lock)?.0;
let original = task(&p);
insert_complete(&mut store, staging, &p, &original)?;
store.connection.execute("WITH RECURSIVE ord(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM ord WHERE n+1<?2)
INSERT INTO semantic_chunks(generation,passage_id,ordinal,path,kind,number,content_hash,start_byte,end_byte,snippet,vector,source_type,file_size,file_mtime,task_key)
SELECT generation,passage_id,n,path,kind,number,content_hash,start_byte,end_byte,snippet,vector,source_type,file_size,file_mtime,task_key FROM semantic_chunks CROSS JOIN ord WHERE generation=?1 AND ordinal=0",params![staging,MAX_SCAN_CHUNKS as i64])?;
store.complete_source(staging, &original, MAX_SCAN_CHUNKS)?;
let moved_dir = root.join("moved");
std::fs::create_dir(&moved_dir)?;
std::fs::rename(root.join("notes.txt"), moved_dir.join("notes.txt"))?;
lexical.index_folder(&root)?;
let moved = lexical.passage_batch(0, 1)?.remove(0);
let moved_task = task(&moved);
let revision = store.revision()?;
assert!(store
.prune_before_inference(staging, std::slice::from_ref(&moved_task), revision - 1)
.is_err());
let old_count: usize = store.connection.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[staging],
|r| r.get(0),
)?;
assert_eq!(
old_count, MAX_SCAN_CHUNKS,
"Revision failure must preserve staging"
);
store.prune_before_inference(staging, std::slice::from_ref(&moved_task), revision)?;
store.stage_task(staging, &moved_task)?;
assert_eq!(
store.reuse_text_source(staging, &moved_task, &moved)?,
Some(MAX_SCAN_CHUNKS)
);
let (count, old_paths): (usize, usize) = store.connection.query_row(
"SELECT count(*),sum(path!=?2) FROM semantic_chunks WHERE generation=?1",
params![staging, moved.path],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
assert_eq!((count, old_paths), (MAX_SCAN_CHUNKS, 0));
let mut duplicate = moved_task.clone();
duplicate.key.push_str("-another-path");
duplicate.path.push_str("-another-path");
assert!(
store
.prune_before_inference(staging, &[moved_task.clone(), duplicate], revision)
.is_err(),
"True current membership over budget must reject atomically"
);
let count: usize = store.connection.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[staging],
|r| r.get(0),
)?;
assert_eq!(count, MAX_SCAN_CHUNKS);
std::fs::write(
moved_dir.join("notes.txt"),
"replacement source with new content",
)?;
lexical.index_folder(&root)?;
let replacement = lexical.passage_batch(0, 1)?.remove(0);
let replacement_task = task(&replacement);
store.prune_before_inference(
staging,
std::slice::from_ref(&replacement_task),
store.revision()?,
)?;
let count: usize = store.connection.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[staging],
|r| r.get(0),
)?;
assert_eq!(
count, 0,
"Obsolete ceiling-sized group must be removed before replacement inference"
);
insert_complete(&mut store, staging, &replacement, &replacement_task)?;
assert_eq!(store.active_fingerprint()?.as_deref(), Some("prune-policy"));
let active_count: usize = store.connection.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[active],
|r| r.get(0),
)?;
assert_eq!(active_count, 1, "Pruning must never remove active vectors");
Ok(())
}
#[test]
fn resume_retains_completed_groups_and_discards_unfinished_vectors() -> Result<()> {
let dir = tempfile::tempdir()?;
@@ -583,6 +821,59 @@ mod tests {
Ok(())
}
#[test]
fn late_source_validation_rejects_publication_without_holding_write_lock() -> Result<()> {
let dir = tempfile::tempdir()?;
let root = dir.path().join("docs");
std::fs::create_dir(&root)?;
std::fs::write(root.join("notes.txt"), "source identity before graph build")?;
let db = dir.path().join("index.db");
let mut lexical = SearchIndex::open(&db)?;
lexical.index_folder(&root)?;
let passage = lexical.passage_batch(0, 1)?.remove(0);
let source = task(&passage);
let mut store = Store::open(&db, 2)?;
let lock = store.build_lock()?;
let (active, _) = store.start_or_resume("old-policy", &lock)?;
insert_complete(&mut store, active, &passage, &source)?;
store.activate_checked(active, store.revision()?)?;
let (staging, _) = store.start_or_resume("new-policy", &lock)?;
insert_complete(&mut store, staging, &passage, &source)?;
let revision = store.revision()?;
let mut called = false;
let error = store
.activate_checked_with_validation(staging, revision, || {
called = true;
// An independent write succeeds only when validation runs outside
// the publishing connection's Immediate transaction.
let other = rusqlite::Connection::open(&db)?;
other.busy_timeout(std::time::Duration::ZERO)?;
other.execute_batch("BEGIN IMMEDIATE; COMMIT;")?;
std::fs::write(root.join("notes.txt"), "direct filesystem mutation")?;
anyhow::bail!("source changed after graph preparation")
})
.unwrap_err();
assert!(called);
assert!(error
.to_string()
.contains("source changed after graph preparation"));
assert_eq!(store.active_fingerprint()?.as_deref(), Some("old-policy"));
assert_eq!(
store.ann_cache.as_ref().map(|(generation, _)| *generation),
Some(active)
);
assert_eq!(store.start_or_resume("new-policy", &lock)?, (staging, true));
assert_eq!(
store.connection.query_row(
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
[staging],
|row| row.get::<_, usize>(0),
)?,
1
);
Ok(())
}
#[test]
fn revision_race_cannot_publish_and_failed_activation_keeps_staging() -> Result<()> {
let dir = tempfile::tempdir()?;
@@ -0,0 +1,189 @@
//! Publication faults exercised with the actual Google CPU encoder.
use anyhow::Result;
use rusqlite::Connection;
use searchi_core::SearchIndex;
use searchi_semantic::{GemmaEmbedder, TextWindow};
use searchi_service::{EmbeddingProvider, HybridSearch, SearchOptions};
use std::{
fs,
path::PathBuf,
sync::{Arc, Mutex},
};
#[derive(Default)]
struct Control {
calls: usize,
mutation: Option<(PathBuf, String)>,
}
struct GoogleWithMutation {
google: GemmaEmbedder,
control: Arc<Mutex<Control>>,
}
impl EmbeddingProvider for GoogleWithMutation {
fn fingerprint(&self) -> &str {
EmbeddingProvider::fingerprint(&self.google)
}
fn dimensions(&self) -> usize {
EmbeddingProvider::dimensions(&self.google)
}
fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>> {
self.google.split_windows(text, title)
}
fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result<Vec<Vec<f32>>> {
let vectors = self.google.embed_documents(texts, title)?;
let mut control = self.control.lock().unwrap();
control.calls += texts.len();
if let Some((path, replacement)) = control.mutation.take() {
let metadata = fs::metadata(&path)?;
assert_eq!(metadata.len(), replacement.len() as u64);
fs::write(&path, replacement)?;
fs::File::options()
.write(true)
.open(&path)?
.set_times(fs::FileTimes::new().set_modified(metadata.modified()?))?;
assert_eq!(fs::metadata(&path)?.modified()?, metadata.modified()?);
}
Ok(vectors)
}
fn embed_query(&mut self, text: &str) -> Result<Vec<f32>> {
self.google.embed_query(text)
}
fn embed_code_query(&mut self, text: &str) -> Result<Vec<f32>> {
self.google.embed_code_query(text)
}
}
#[test]
#[ignore = "Requires verified Google models and normal native worker; CPU only"]
fn genuine_google_same_metadata_mutation_rejects_publication_and_preserves_ready_sources(
) -> Result<()> {
assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1");
let google = GemmaEmbedder::load(
std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Verified Google assets required"),
std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Normal auto worker required"),
1,
)?;
assert_eq!(google.info.device, "cpu");
let temp = tempfile::tempdir()?;
let root = temp.path().join("documents");
fs::create_dir(&root)?;
let stable = root.join("garden.txt");
fs::write(
&stable,
"The garden irrigation waters tomato plants every morning.",
)?;
let changing = root.join("supplier.txt");
fs::write(
&changing,
"The supplier contract ends after sixty days written notice.",
)?;
let db = temp.path().join("index.db");
let mut lexical = SearchIndex::open(&db)?;
lexical.index_folder(&root)?;
let control = Arc::new(Mutex::new(Control::default()));
let mut service = HybridSearch::with_provider(
&db,
Box::new(GoogleWithMutation {
google,
control: control.clone(),
}),
)?;
service.index_embeddings()?;
let connection = Connection::open(&db)?;
let active = || {
connection.query_row(
"SELECT active_generation FROM semantic_meta WHERE singleton=1",
[],
|r| r.get::<_, i64>(0),
)
};
let first = active()?;
let stable_vector: Vec<u8> = connection.query_row(
"SELECT vector FROM semantic_chunks WHERE generation=?1 AND path LIKE '%garden.txt'",
[first],
|r| r.get(0),
)?;
fs::write(
&changing,
"The supplier contract ends after ninety days written notice.",
)?;
lexical.index_folder(&root)?;
control.lock().unwrap().mutation = Some((
changing.clone(),
"The supplier contract ends after thirty days written notice.".into(),
));
let error = service.index_embeddings().unwrap_err();
assert!(
format!("{error:#}").contains("Source changed during semantic indexing"),
"{error:#}"
);
assert_eq!(
active()?,
first,
"Changed bytes must never publish a stale generation"
);
assert_eq!(control.lock().unwrap().calls, 3);
let options = SearchOptions::default();
let results = service.search_semantic("tomato irrigation", 10, &options)?;
assert!(results
.iter()
.any(|r| r.filename == "garden.txt" && r.source.is_some()));
// A normal rescan must detect changed parser input even though the
// mutation restored size and modification time; no metadata workaround.
lexical.index_folder(&root)?;
let report = service.index_embeddings()?;
assert!(report.resumed);
assert_eq!(report.reused_sources, 1);
assert_eq!(report.encoded_sources, 1);
assert_eq!(control.lock().unwrap().calls, 4);
let latest = active()?;
assert_ne!(latest, first);
let latest_stable: Vec<u8> = connection.query_row(
"SELECT vector FROM semantic_chunks WHERE generation=?1 AND path LIKE '%garden.txt'",
[latest],
|r| r.get(0),
)?;
assert_eq!(latest_stable, stable_vector);
let results = service.search_semantic("supplier contract notice", 10, &options)?;
let supplier = results
.iter()
.find(|r| r.filename == "supplier.txt")
.expect("Current supplier source");
assert!(supplier.snippet.contains("thirty days"));
assert!(supplier.source.is_some());
// Mutation before indexing must be caught against the persisted exact
// parser-input hash, before spending document inference on stale passages.
let metadata = fs::metadata(&changing)?;
let replacement = "The supplier contract ends after eighty days written notice.";
assert_eq!(replacement.len() as u64, metadata.len());
fs::write(&changing, replacement)?;
fs::File::options()
.write(true)
.open(&changing)?
.set_times(fs::FileTimes::new().set_modified(metadata.modified()?))?;
assert_eq!(fs::metadata(&changing)?.modified()?, metadata.modified()?);
let error = service.index_embeddings().unwrap_err();
assert!(format!("{error:#}").contains("reindex"), "{error:#}");
assert_eq!(
control.lock().unwrap().calls,
4,
"Stale extraction must fail before document inference"
);
assert_eq!(active()?, latest);
lexical.index_folder(&root)?;
let report = service.index_embeddings()?;
assert!(report.resumed);
assert_eq!(report.reused_sources, 1);
assert_eq!(report.encoded_sources, 1);
assert_eq!(control.lock().unwrap().calls, 5);
let results = service.search_semantic("supplier contract notice", 10, &options)?;
let supplier = results
.iter()
.find(|r| r.filename == "supplier.txt")
.expect("Re-extracted supplier source");
assert!(supplier.snippet.contains("eighty days"));
assert!(supplier.source.is_some());
Ok(())
}
+45 -6
View File
@@ -36,7 +36,7 @@ transactional completion failure and malformed stored text windows. Another
fault-only pipeline test covers primary inference error preservation when
partial-source cleanup also fails. Its provider returns no simulated vectors.
## Executed checks
## Original reuse commit checks (5c18e7b)
- Windows workspace: 98 passed, 15 explicitly ignored; no failures.
- Native Ubuntu 24.04 headless workspace: 98 passed, 15 explicitly ignored.
@@ -53,11 +53,50 @@ partial-source cleanup also fails. Its provider returns no simulated vectors.
isolated verified models/normal auto worker and pinned native FFmpeg helpers.
Cold Clippy compilation ran concurrently; these are not latency comparisons.
## Publication and provenance follow-up
The follow-up moves full source validation after ANN construction/serialization,
before opening the publication write transaction. A unit regression confirms
an independent writer can acquire the database while validation runs; rejection
keeps the previous fingerprint/cache and completed resumable staging.
Pre-inference pruning uses a bounded, disk-backed transactional SQLite snapshot.
The 100,000-chunk regression checks renamed completed staging without duplicate
allocation, genuine duplicate current groups exceeding the budget, rollback,
obsolete replacement cleanup and preservation of active vectors. This is a
storage fixture, not an ANN latency or neural relevance benchmark.
Core schema 12 hashes the exact bounded parser input, without reopening the file
for parsing. Four tests cover immutable input snapshots, same-size/mtime mutation,
real schema-11 migration preserving lexical content, stale agent excerpts and
filename-only oversized/unsupported inputs. Supported rescans read and hash
bounded bytes even when metadata is unchanged; matching hashes skip parsing.
Legacy rows need reindexing to establish provenance before semantic rebuilding
or serving agent excerpts.
## Follow-up executed checks
- Windows and native Linux workspaces: 104 passed each, 16 explicitly ignored; no failures.
- Windows desktop: 17 passed.
- Strict Windows/Linux workspace and Windows desktop Clippy passed; workspace formatting passed.
- Six genuine Windows Google CPU tests passed: existing lifecycle/policy tests
32.52 s; mixed incremental tests 182.03 s; new publication mutation test
11.02 s. Compiler/resource contention was present. These are correctness-suite
times and are not search-latency measurements.
- The same six genuine native Linux Google CPU tests passed: lifecycle/policy
40.16 s, incremental/media 135.42 s, publication mutation 11.98 s. These
elapsed suites are correctness evidence, not comparative performance claims.
- The mutation test overwrites the file after actual document inference,
restores the exact mtime, rejects publication and preserves the active ID.
An ordinary folder rescan then resumes with only the changed source encoded.
Another overwrite before rebuilding rejects stale extraction before any
additional document encoder calls. Unaffected stored vector bytes and
citations remain identical.
## Remaining gates
Actual process termination during a multi-batch source, simultaneous real-model
source mutation, wider policy changes, near-limit pruning, minimum-ABI/older-CPU
checks and first-query/foreground latency during indexing remain open. Full file
hash validation currently precedes ANN construction; direct file mutation during
that construction before watcher reconciliation needs a later validation callback.
Actual process termination during a multi-batch source, wider policy changes,
minimum-ABI/older-CPU checks and first-query/foreground latency during indexing
remain open. Live filesystem reads and SQLite publication are not one atomic
operation; the later check narrows that race rather than eliminating it.
See [INCREMENTAL-SEMANTIC.md](INCREMENTAL-SEMANTIC.md).
+21 -11
View File
@@ -69,8 +69,11 @@ preserve both the previous active generation and the same reader's policy.
text groups when component encoder-key reuse is implemented.
- [ ] Terminate after a committed batch and resume without repeating completed
inference. Never reuse or publish an unfinished source group.
- [ ] Mutate or delete a queued/running source and prove stale vectors cannot
activate or become readable.
- [x] Same-size/mtime text mutation during genuine inference rejects publication;
a mutation before rebuilding fails before text encoding and ordinary rescans
reextract it. Unaffected vectors survive resumed retry.
- [ ] Concurrent deletion/media mutation and comprehensive queued/running source
search freshness tests.
- [x] Inject activation failure, preserve existing results and resume completed
staging with zero document inference; cleanup-failure unit regression retains
the primary error and old reader policy.
@@ -90,16 +93,23 @@ across media sampling changes remains open. Durable staging resumes at completed
source boundaries; a real process-kill test during a multi-batch source remains
open. Storage tests cover discarded partial groups and corpus revision races.
Full source hashes are checked before ANN construction; indexed changes during
ANN construction are rejected by the final transaction's revision guard. A direct
filesystem mutation during ANN construction before watcher reconciliation still
needs a later validation callback and concurrent real-model regression.
Full source hashes are checked after ANN construction and serialization, before
opening the publication write transaction. Indexed changes are also rejected by
the transaction's revision guard. This narrows the filesystem race; it does not
make file reads and SQLite publication one atomic filesystem operation.
Obsolete completed groups in resumed staging are removed at final reconciliation.
Near the 100,000-chunk ceiling, replacement work can temporarily hit the budget
before this cleanup and fail safely. Bounded discovery/pruning before inference
is the next improvement. Hashing, media metadata probes and graph rebuilding still
have costs: zero encoder calls does not mean zero work or prove fast first input.
Core schema 12 records the SHA-256 of the exact bounded bytes consumed by each
parser. Semantic builds verify that provenance before reusing or encoding text.
Same-size/mtime changes are detected by normal folder rescanning. Legacy records
retain lexical search but need a rescan before semantic rebuilding or serving
agent excerpts. Excerpts reject missing or mismatched source provenance.
Bounded discovery now prunes obsolete resumed groups before inference. Compatible
renamed staging groups survive with current memberships through a transactional,
disk-backed SQLite snapshot; active vectors are untouched. Genuine duplicate
current groups still count toward the 100,000-chunk ceiling. Hashing, discovery,
media metadata probes, snapshot copying and graph rebuilding have costs: zero
encoder calls does not mean zero work or prove fast first input.
Synthetic colored-image and sine-wave fixtures prove actual vector reuse and
citation equality; they do not establish useful media relevance.
+9 -3
View File
@@ -100,7 +100,7 @@ Status: implementation started; no production release yet. Windows VM tests use
Warm p95 alone does not satisfy the first-use requirement.
- [ ] Test migrations, interrupted work, invalid documents, inaccessible/offline roots and cloud placeholders.
- [ ] Automated native build matrix and documented supported cross-compilation paths.
- [x] Native Linux headless workspace tests: 98 passed, 15 explicitly ignored.
- [x] Native Linux headless workspace tests: 104 passed, 16 explicitly ignored.
- [x] Native Linux Google text/code/media reference parity and service
lifecycle/rebuild recovery: seven genuine CPU integration tests passed.
- [x] Unsigned Linux x86-64 headless archive: verify extracted checksums/modes,
@@ -147,8 +147,14 @@ Audit and measured priorities: [EMBEDDINGGEMMA2-AUDIT.md](EMBEDDINGGEMMA2-AUDIT.
with genuine Google CPU document/code/image/audio/video fixtures.
- [x] Resume completed staging sources after failed publication; incomplete
groups are purged under the OS build lock before retry.
- [ ] Process-kill/concurrent-source integration, pre-inference pruning near
chunk limits, component-policy reuse and indexing/foreground measurements.
- [x] Pre-inference staging pruning with compatible rename remapping and
100,000-chunk storage boundary; late validation after ANN preparation.
- [x] Exact parser-input SHA provenance and same-metadata overwrite detection
on normal rescans; stale/missing agent excerpts fail closed.
- [x] Actual Google CPU same-metadata text mutation before/during inference:
reject publication, rescan without metadata workarounds and reuse unaffected vectors.
- [ ] Process-kill/concurrent deletion/media mutation, component-policy reuse and
indexing/foreground measurements.
See [INCREMENTAL-SEMANTIC.md](INCREMENTAL-SEMANTIC.md).
- [ ] Benchmark genuine token-budgeted multi-sequence batching and isolation.
- [ ] Implement and measure shared foreground/indexer resource budgets/priority.