diff --git a/README.md b/README.md index 51133a4..0d3f21c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,13 @@ sound. Use **All files** to narrow results by folder, file extension, modification date, file size or media duration. Choose a result to inspect it before opening. +The current source also offers **Artist (exact)** and **Track title (exact)** +filters for music. Enter the complete tag, with or without a text query. These +filters ignore letter case and surrounding spaces; files without the matching +music tag stay out of the results. Existing indexes need a media indexing pass +before these new filters can use their tags. This improvement is not yet in the +v0.1.4 downloads. + ### Find the implementation Choose **Code** in the **All files** filters to search source files. Look up a diff --git a/crates/searchi-service/README.md b/crates/searchi-service/README.md index 535354d..e760d62 100644 --- a/crates/searchi-service/README.md +++ b/crates/searchi-service/README.md @@ -44,6 +44,7 @@ unchanged-vector reuse and production platform checks remain release gates. Exact artist/title constraints use the core's normalized structured probe keys before ANN limits and in the eligibility cache key. Metadata-only writes advance -the corpus revision and dirty path so warmed readers cannot retain old eligibility. +a separate durable metadata epoch so warmed readers cannot retain old eligibility. +They do not change source-corpus revisions during the indexer's own probe publication. Empty metadata-only hybrid requests use core results without model inference. These explicit constraints do not establish abstention for free-form topic queries. diff --git a/crates/searchi-service/src/store.rs b/crates/searchi-service/src/store.rs index 40680bf..e42bdb6 100644 --- a/crates/searchi-service/src/store.rs +++ b/crates/searchi-service/src/store.rs @@ -33,6 +33,7 @@ struct FilterKey { code_only: bool, media_artist: Option, media_title: Option, + metadata_revision: i64, } struct EligibilityCache { @@ -186,16 +187,18 @@ impl Store { } } initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_errors(generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,path TEXT NOT NULL,message TEXT NOT NULL,PRIMARY KEY(generation,path))")?; - // Metadata-only writes change strict eligibility even when file bytes do not change. - for (name, event, row) in [ - ("insert", "INSERT", "new"), - ("update", "UPDATE", "new"), - ("delete", "DELETE", "old"), + // Probe publication changes filter eligibility, not source discovery identity. + // Keep its epoch separate so indexing does not invalidate its own corpus snapshot. + initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_metadata_revision(singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL); + INSERT OR IGNORE INTO semantic_media_metadata_revision(singleton,revision) VALUES(1,0);")?; + for (name, event) in [ + ("insert", "INSERT"), + ("update", "UPDATE"), + ("delete", "DELETE"), ] { - initialization.execute_batch(&format!("CREATE TRIGGER IF NOT EXISTS semantic_media_metadata_{name} AFTER {event} ON media_metadata BEGIN - UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1; - INSERT INTO semantic_dirty_paths(path,revision) VALUES({row}.path,(SELECT corpus_revision FROM semantic_meta WHERE singleton=1)) - ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END;"))?; + initialization.execute_batch(&format!("DROP TRIGGER IF EXISTS semantic_media_metadata_{name}; + CREATE TRIGGER semantic_media_metadata_{name} AFTER {event} ON media_metadata BEGIN + UPDATE semantic_media_metadata_revision SET revision=revision+1 WHERE singleton=1; END;"))?; } incremental::migrate(&initialization)?; initialization.commit()?; @@ -646,6 +649,19 @@ impl Store { .map(|ext| ext.trim_start_matches('.').to_ascii_lowercase()); let min_size = options.min_size.map(i64::try_from).transpose()?; let max_size = options.max_size.map(i64::try_from).transpose()?; + let metadata_revision = if options.media_artist.is_some() + || options.media_title.is_some() + || options.min_duration_ms.is_some() + || options.max_duration_ms.is_some() + { + snapshot.query_row( + "SELECT revision FROM semantic_media_metadata_revision WHERE singleton=1", + [], + |row| row.get(0), + )? + } else { + 0 + }; let filter = FilterKey { folder, extension, @@ -667,6 +683,7 @@ impl Store { .as_deref() .map(searchi_core::normalize_media_field) .transpose()?, + metadata_revision, }; trace.end(filter_span, Default::default()); let eligibility_span = trace.begin("semantic.eligibility", parent); @@ -1403,6 +1420,11 @@ mod tests { .len(), 1 ); + let corpus_before: i64 = store.connection.query_row( + "SELECT corpus_revision FROM semantic_meta WHERE singleton=1", + [], + |row| row.get(0), + )?; tags.insert("artist".into(), "Replacement".into()); lexical.set_structured_media_metadata_at_revision( &path, @@ -1412,6 +1434,15 @@ mod tests { identity.modified_ns, revision, )?; + let corpus_after: i64 = store.connection.query_row( + "SELECT corpus_revision FROM semantic_meta WHERE singleton=1", + [], + |row| row.get(0), + )?; + assert_eq!( + corpus_before, corpus_after, + "Probe publication must not invalidate source discovery" + ); assert!(store .search(&vector, "metadata-filter-proof", 1, &options)? .is_empty()); diff --git a/crates/searchi-service/tests/media_constraints_google_cpu.rs b/crates/searchi-service/tests/media_constraints_google_cpu.rs new file mode 100644 index 0000000..364f843 --- /dev/null +++ b/crates/searchi-service/tests/media_constraints_google_cpu.rs @@ -0,0 +1,140 @@ +//! Explicit identities constrain genuine Google media retrieval, without a cosine cutoff. +use anyhow::{Context, Result}; +use searchi_core::{IndexOptions, SearchIndex}; +use searchi_service::{FfmpegTools, HybridSearch, MediaLimits, SearchOptions}; +use std::{fs, path::Path, process::Command, sync::atomic::AtomicBool}; + +fn tagged_tone(ffmpeg: &Path, path: &Path, artist: &str, title: &str) -> Result<()> { + let output = Command::new(ffmpeg) + .args([ + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-threads", + "1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=16000:duration=2", + "-c:a", + "pcm_s16le", + "-metadata", + ]) + .arg(format!("artist={artist}")) + .arg("-metadata") + .arg(format!("title={title}")) + .arg(path) + .output()?; + anyhow::ensure!( + output.status.success(), + "Owned FFmpeg fixture creation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +#[test] +#[ignore = "Requires verified Google assets, normal native worker and FFmpeg helpers; CPU only"] +fn genuine_google_exact_music_constraints_survive_publication_and_hint_refresh() -> Result<()> { + assert_eq!( + std::env::var("SEARCHI_FORCE_CPU")?, + "1", + "VM inference must use CPU only" + ); + let model = + std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").context("Set SEARCHI_GEMMA_MODEL_DIR")?; + let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").context("Set SEARCHI_GEMMA_WORKER")?; + let tools = FfmpegTools { + ffmpeg: std::env::var_os("SEARCHI_TEST_FFMPEG") + .context("Set SEARCHI_TEST_FFMPEG")? + .into(), + ffprobe: std::env::var_os("SEARCHI_TEST_FFPROBE") + .context("Set SEARCHI_TEST_FFPROBE")? + .into(), + }; + let temp = tempfile::tempdir()?; + let root = temp.path().join("music"); + fs::create_dir(&root)?; + let target = root.join("z-target.wav"); + tagged_tone(&tools.ffmpeg, &target, "Björk", "Jóga")?; + // Identical audio is a deliberately strong distractor; identity must come from tags. + tagged_tone( + &tools.ffmpeg, + &root.join("a-distractor.wav"), + "Other Artist", + "Björk", + )?; + let db = temp.path().join("index.db"); + let mut lexical = SearchIndex::open(&db)?; + lexical.index_folder(&root)?; + let mut indexer = HybridSearch::open(&db, &model, &worker, 1)?; + assert_eq!( + indexer.worker_info().context("Google worker info")?.device, + "cpu" + ); + let report = indexer.index_embeddings_with_media(&tools, &MediaLimits::default())?; + assert!(report.failures.is_empty()); + assert_eq!(report.media_files, 2); + assert_eq!(report.audio_inference_calls, 2); + drop(indexer); + let mut search = HybridSearch::open_query(&db, &model, &worker, 1)?; + let options = SearchOptions { + media_artist: Some("BJÖRK".into()), + media_title: Some("JÓGA".into()), + ..Default::default() + }; + let metadata = search.search_measured("", 1, &options, true)?; + assert_eq!(metadata.results.len(), 1); + assert_eq!(metadata.results[0].filename, "z-target.wav"); + assert!(!metadata + .metrics + .trace + .context("debug trace")? + .iter() + .any(|span| span.name == "semantic.query_embedding")); + let query = "the sound of a sine wave tone"; + let semantic = search.search_semantic(query, 1, &options)?; + assert_eq!(semantic.len(), 1); + assert_eq!(semantic[0].filename, "z-target.wav"); + assert_eq!( + search.search(query, 1, &options)?[0].filename, + "z-target.wav" + ); + let absent = SearchOptions { + media_artist: Some("Absent Artist".into()), + ..Default::default() + }; + assert!(search.search(query, 1, &absent)?.is_empty()); + assert!(search.search("", 1, &absent)?.is_empty()); + let collision = SearchOptions { + media_artist: Some("Jóga".into()), + ..Default::default() + }; + assert!(search.search(query, 1, &collision)?.is_empty()); + // A native same-stat hint invalidates old tags and warmed ANN eligibility. + let canonical = target.canonicalize()?; + lexical.index_paths_cancellable( + &root, + std::slice::from_ref(&canonical), + &IndexOptions::default(), + &AtomicBool::new(false), + )?; + assert!(search.search("", 1, &options)?.is_empty()); + assert!(search.search_semantic(query, 1, &options)?.is_empty()); + let mut indexer = HybridSearch::open(&db, &model, &worker, 1)?; + let refreshed = indexer.index_embeddings_with_media(&tools, &MediaLimits::default())?; + assert!(refreshed.failures.is_empty()); + drop(indexer); + assert_eq!(search.search("", 1, &options)?[0].filename, "z-target.wav"); + assert_eq!( + search.search_semantic(query, 1, &options)?[0].filename, + "z-target.wav" + ); + // Deletion is suppressed without retaining an exact identity match. + fs::remove_file(&target)?; + lexical.index_folder(&root)?; + assert!(search.search("", 1, &options)?.is_empty()); + assert!(search.search_semantic(query, 1, &options)?.is_empty()); + Ok(()) +} diff --git a/docs/MEDIA-CONSTRAINTS.md b/docs/MEDIA-CONSTRAINTS.md new file mode 100644 index 0000000..14c208a --- /dev/null +++ b/docs/MEDIA-CONSTRAINTS.md @@ -0,0 +1,56 @@ +# Exact music identity filters + +The current source supports exact `media_artist` and `media_title` constraints +in the desktop filter panel, CLI and read-only MCP. They compare genuine probed +full tag fields using Unicode lowercase and trimmed outer whitespace. Both +constraints combine with existing filters before lexical and ANN candidate +limits; comment text cannot impersonate an artist or title. + +A metadata-only request can omit query text. It returns a bounded stable list +without query inference. Missing tags yield no match, including in semantic +search. This is a narrow, explicit constraint, not general natural-language +no-answer detection. Blank or over-1024-byte request fields fail explicitly; +oversized genuine tags remain keyword-searchable but have no exact identity key. + +SQLite schema 14 preserves legacy keyword tags without guessing structured +identities from flattened text. Existing music needs a media indexing pass to +populate the new fields. A separate durable metadata epoch refreshes filtered eligibility without changing +the source-corpus snapshot during indexing. Metadata revision guards, same-stat event invalidation, +deletions and eligibility-cache invalidation retain source freshness. + +CLI examples: + +```sh +searchi search --db /path/to/index.db --media-artist "Nina Simone" +searchi search --db /path/to/index.db --media-title "Feeling Good" +searchi search --db /path/to/index.db "live recording" --media-artist "Nina Simone" +``` + +Replace `/path/to/index.db` with your existing media-indexed database. The desktop controls are **Artist (exact)** and +**Track title (exact)**. v0.1.4 downloads predate these additions. + +Windows CPU checks: the full ordinary workspace suite passed (asset-dependent +checks remain explicitly ignored). Shared migration/lifecycle/eligibility tests, five CLI unit +tests including real MCP response, 48 native desktop tests and 61 frontend tests +passed. Strict workspace/CLI/native Clippy and formatting passed; frontend build +passed. Native visual interaction remains unexecuted. Actual Google CPU integration also passed in 58.59 seconds (whole test, not +search latency): two owned two-second 16 kHz mono PCM WAVs carried real +artist/title tags and identical audio. The initial generation made two actual +audio encodes. Query-role semantic and hybrid searches with limit one returned +the eligible file, absent artist/field-collision constraints returned empty, +metadata-only traces contained no query embedding, and same-stat invalidation, +actual media refresh and deletion remained correct with warmed readers. + +The normal auto worker was forced to CPU with one thread. Its SHA-256 was +`c01cb3ec13cfca722ee768e8350e1399a8b9a4d14a24a35adc3473a5cfc82a73`. +The first integration run failed because metadata publication advanced the +source-corpus revision; the separate metadata epoch corrected this, and the +rerun passed. Service tests and strict workspace Clippy passed after the fix. +This tiny identity-contract test does not measure music understanding or general +no-answer quality. + +Reproduce with verified assets/helpers and `SEARCHI_FORCE_CPU=1`: + +```sh +cargo test -p searchi-service --test media_constraints_google_cpu --locked -- --ignored --nocapture --test-threads=1 +``` diff --git a/docs/MUSIC-METADATA.md b/docs/MUSIC-METADATA.md new file mode 100644 index 0000000..1f1d5a7 --- /dev/null +++ b/docs/MUSIC-METADATA.md @@ -0,0 +1,76 @@ +# Music metadata hypothesis: actual Google CPU diagnostic + +This known two-file diagnostic compares real audio evidence with real embedded +metadata. It does **not** change production ranking or establish independent +music quality, general abstention, or a higher media rating. + +The preserved v1 run encoded eleven default audio windows and two whole-tag +passages. Adding the whole passage left music memories at **2/4 first hits**: +the violin's English similarity increased, but stayed below the wrong piano. + +The subsequent v2 policy was frozen in source commit `7055004` before inference. +It encodes artist, title, album, genre and composer separately, in fixed order +with a shared 2048-byte UTF-8 budget and the neutral document title `File metadata`. +The file score is the maximum of its actual evidence, with no weights or cutoff. +This is a hypothesis motivated by v1, not untouched held-out testing. + +## Recomputed results + +All 65 original queries remain in the report. Three filter queries are explicitly +unsupported. Among 62 executed queries, only seven positive queries name one of +the two candidates; 50 positives name files outside this deliberately limited +pool and cannot receive a quality score. Five unfiltered no-answer queries remain. + +| Two-file diagnostic | Audio only | Whole tags v1 | Individual fields v2 | +| --- | ---: | ---: | ---: | +| Music memories: correct first file | 2/4 | 2/4 | 4/4 | +| Music identity queries: correct first file | 2/3 | 2/3 | 3/3 | +| All eligible positives: correct first file | 4/7 | 4/7 | 7/7 | +| Eligible positives: recall within first five | 7/7 | 7/7 | 7/7 | +| Eligible positive MRR | 0.786 | 0.786 | 1.000 | +| No-answer queries returning candidates, failure | 5/5 | 5/5 | 5/5 | + +The first-hit gains are `q049`, `q050` and `q056`, with no scored positive +regression. English violin selected the literal composer field +`Violinist WTD/LPC Jazz Band` (cosine 0.73714); German violin selected the actual +title `Jazz Violin Solo` (0.73508). Their wrong piano audio maxima were 0.68972 +and 0.70654 respectively. The piano memories retained their original audio hits. + +This improvement also strengthens unrelated nearest neighbors: the purple-elephant +violin negatives increased by 0.04994 and 0.04801 cosine, and the German quantum +entanglement negative by 0.00749. All negatives still return files. Consequently, +the diagnostic provides **no abstention improvement** and no safe rejection +threshold. Metadata is useful evidence, not a calibrated confidence score. + +## Inputs, execution and limits + +The actual piano tags identify Kimiko Ishizaka, Goldberg Variation 13, Classical +and J.S. Bach. The violin tags identify Violinist WTD, Jazz Violin Solo, Jazz and +the LPC Jazz Band. No instrument descriptions were generated from the waveform. +Blank/missing whitelisted fields add no vector; encoder, comments, URLs and +copyright are excluded. Misleading tags remain unverified literal evidence. +Metadata citations have a field and text, never an invented playback timestamp. + +V2 used actual Google EmbeddingGemma 2 Q8 through the same native +`b11461-no-logits-v1` worker on CPU, one thread, 768 dimensions and a 2048-token +indexing context. Ten field encodes took **750.03 ms**, excluding model startup +and query inference. It made **zero audio inference calls**: per-query audio +maxima were reused verbatim from the SHA-verified v1 report. Independent read-only +recomputation checked query equality and exact retained audio/whole-tag rankings +for all 62 executed rows. This tiny shared-VM measurement is not a throughput +guarantee or a production ANN/hybrid measurement. + +- Frozen v2 plan SHA-256: + `566bcd608ccf898abaceab32b247786e895368c8428b1bfa6e0bbb8396211b05`. +- Preserved v1 report SHA-256: + `f985ba639a14b8c0288b8fa7812d580f179f31c8cad0c21d67531c8a4fddc5b6`. +- Ignored v2 report `.searchi/qa/music-fields-v2-results.json`, 288944 bytes, + SHA-256 `c13ceca2150059a21e10ee190893a66f5ac82570675e7725e54524c660a2ff30`. + +Reproduction commands and structural tests are in the +[evaluation tool README](../tools/media-eval/README.md). Before deployment, +evaluate independently frozen unseen music, missing/misleading metadata, additional +instrument/style hard negatives and the complete mixed candidate pool. Production +integration must retain real metadata field citations, source-byte/revision guards, +strict filters and generation policy invalidation. The broader +[media quality gates and experimental rating](MEDIA-QUALITY.md) remain unchanged. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 82c30bb..b0cc9a8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,8 +12,24 @@ timestamps and irrelevant results when the requested content is absent. The million-record lexical benchmark is deferred while this quality work proceeds. Google EmbeddingGemma 2 remains the main text/image/audio/video search model. -- [ ] Evaluate actual whitelisted music metadata as additional Google text evidence, retaining raw audio and real metadata source citations. -- [ ] Evaluate finer local ASR token/word timing and source-preserving phrase windows with Google text retrieval; preserve every competitor and reject invalid timestamps. +- [x] Run frozen actual Google music metadata diagnostics: separate fields improved + known two-file music memories from 2/4 to 4/4; no-answer remained 5/5 failing. + This is hypothesis evidence, not independent production validation. + See [music evidence](MUSIC-METADATA.md). +- [ ] Validate and integrate metadata embeddings on independent unseen music and + the full mixed pool with real field citations and stale-source guards. +- [x] Implement exact artist/title constraints in shared storage, lexical/ANN + eligibility, CLI/MCP and desktop controls; metadata-only search skips inference. + Unit/lifecycle evidence is recorded separately from actual model validation. +- [x] Actual Google CPU constraint integration passed with genuine tagged WAVs, + semantic/hybrid limit-one retrieval, empty absent identities, no-inference + metadata lookup and warmed lifecycle refresh; see [evidence](MEDIA-CONSTRAINTS.md). +- [ ] Package and install an updated desktop release with these exact filters. +- [ ] Evaluate finer local ASR token/word timing and source-preserving phrase windows + with Google text retrieval. Ordinary-token run completed but failed interval + validation; DTW run stopped on invalid native UTF-8 and remains incomplete. + Neither receives an accepted score. Future diagnostics retain bounded failing + raw bytes and atomic checkpoints; see tools/speech-eval/README.md. - [ ] Validate richer no-answer evidence on new independent positive/negative data; do not retune the failed universal cosine cutoff on old held-out results. - [ ] Integrate accepted improvements into shared engine/UI/headless clients with verified setup, lifecycle and CPU performance evidence. Diagnostics alone do not raise the media quality rating. - [x] Publish v0.1.0 Windows installer/checksum, verify public download bytes,