From 0856717db34cee802cd4da65722706ea646293f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:39:06 +0300 Subject: [PATCH 1/2] test(index): add MLT coverage tests for edge cases - mlt_with_doc_id_not_found_returns_error: nonexistent doc_id returns empty - mlt_with_like_and_empty_fields_auto_infers_from_like_json: empty fields list auto-inferred from like JSON keys, terms extracted from like content - mlt_with_min_word_length_filters_short_terms: min_word_length=4 filters single-char tokens - mlt_with_max_word_length_filters_long_terms: max_word_length=4 filters tokens longer than threshold - mlt_all_terms_filtered_returns_empty_or_error: all terms filtered by min_term_freq returns empty results All 9 MLT tests in coverage.rs now pass (previously 4, now 9). --- .../cloudsearch-index/tests/coverage.rs | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/rust/crates/cloudsearch-index/tests/coverage.rs b/rust/crates/cloudsearch-index/tests/coverage.rs index 0328618..6831355 100644 --- a/rust/crates/cloudsearch-index/tests/coverage.rs +++ b/rust/crates/cloudsearch-index/tests/coverage.rs @@ -884,6 +884,279 @@ async fn mlt_respects_min_term_freq_and_max_query_terms() { ); } +#[tokio::test] +async fn mlt_with_doc_id_not_found_returns_error() { + let temp_dir = TempDir::new().expect("temp dir"); + let catalog = Arc::new(IndexCatalog::new(temp_dir.path())); + catalog.initialize().await.expect("init catalog"); + catalog + .create_index( + "test", + CreateIndexRequest { + settings: IndexSettings::default(), + ..Default::default() + }, + ) + .await + .expect("create index"); + let mut handle = catalog.open_index("test").await.expect("open index"); + + handle + .index_document(doc("doc1", serde_json::json!({"content": "test content"}))) + .await + .expect("index doc1"); + handle.refresh().await.expect("refresh"); + handle.flush().await.expect("flush"); + + // MLT with a doc_id that does not exist should return an error + let result = handle.search(&SearchRequest { + query: Some(SearchQuery::Mlt(MltQuery { + doc_id: Some("nonexistent".to_string()), + like: None, + fields: vec!["content".to_string()], + min_term_freq: 1, + min_doc_freq: 1, + max_query_terms: 25, + min_word_length: None, + max_word_length: None, + field_boost_factor: 1.0, + })), + ..Default::default() + }); + + // Should return empty because the document does not exist + // (build_mlt_bool_query returns error → search returns empty response) + assert_eq!( + result.hits.total, 0, + "MLT with nonexistent doc_id should return empty results" + ); +} + +#[tokio::test] +async fn mlt_with_like_and_empty_fields_auto_infers_from_like_json() { + // When fields is empty and like is provided, MLT should auto-infer fields + // from the keys in the like JSON. The like JSON content becomes the reference text. + let temp_dir = TempDir::new().expect("temp dir"); + let catalog = Arc::new(IndexCatalog::new(temp_dir.path())); + catalog.initialize().await.expect("init catalog"); + catalog + .create_index( + "test", + CreateIndexRequest { + settings: IndexSettings::default(), + ..Default::default() + }, + ) + .await + .expect("create index"); + let mut handle = catalog.open_index("test").await.expect("open index"); + + handle + .index_document(doc( + "doc1", + serde_json::json!({"title": "rust programming", "body": "systems language"}), + )) + .await + .expect("index doc1"); + handle + .index_document(doc( + "doc2", + serde_json::json!({"title": "rust metal", "body": "durable material"}), + )) + .await + .expect("index doc2"); + handle.refresh().await.expect("refresh"); + handle.flush().await.expect("flush"); + + // MLT with like containing "rust" in title and "systems" in body. + // Empty fields list → auto-inferred from like JSON keys: ["title", "body"] + // Reference terms: "rust" (from title) and "systems" (from body) + // doc1 has both "rust" and "systems" → highest score + // doc2 has "rust" but not "systems" → lower score + let result = handle.search(&SearchRequest { + query: Some(SearchQuery::Mlt(MltQuery { + doc_id: None, + like: Some(serde_json::json!({"title": "rustacean", "body": "systems"})), + fields: vec![], + min_term_freq: 1, + min_doc_freq: 1, + max_query_terms: 25, + min_word_length: None, + max_word_length: None, + field_boost_factor: 1.0, + })), + ..Default::default() + }); + + // Should find doc1 (matches "rust" in title and "systems" in body) + assert!( + result.hits.total >= 1, + "MLT with auto-inferred fields from like JSON should find matching docs, got total: {}", + result.hits.total + ); +} + +#[tokio::test] +async fn mlt_with_min_word_length_filters_short_terms() { + let temp_dir = TempDir::new().expect("temp dir"); + let catalog = Arc::new(IndexCatalog::new(temp_dir.path())); + catalog.initialize().await.expect("init catalog"); + catalog + .create_index( + "test", + CreateIndexRequest { + settings: IndexSettings::default(), + ..Default::default() + }, + ) + .await + .expect("create index"); + let mut handle = catalog.open_index("test").await.expect("open index"); + + // doc1 has short term "a" and normal term "rust" + handle + .index_document(doc("doc1", serde_json::json!({"content": "a rust"}))) + .await + .expect("index doc1"); + handle + .index_document(doc("doc2", serde_json::json!({"content": "rust"}))) + .await + .expect("index doc2"); + handle.refresh().await.expect("refresh"); + handle.flush().await.expect("flush"); + + // With min_word_length=4, "a" is filtered out — only "rust" is used + let result = handle.search(&SearchRequest { + query: Some(SearchQuery::Mlt(MltQuery { + doc_id: Some("doc1".to_string()), + like: None, + fields: vec!["content".to_string()], + min_term_freq: 1, + min_doc_freq: 1, + max_query_terms: 25, + min_word_length: Some(4), + max_word_length: None, + field_boost_factor: 1.0, + })), + ..Default::default() + }); + + // doc1 excluded (source), doc2 has "rust" — should match + assert!( + result.hits.total >= 1, + "MLT with min_word_length=4 should still find doc2 via 'rust', got: {}", + result.hits.total + ); +} + +#[tokio::test] +async fn mlt_with_max_word_length_filters_long_terms() { + let temp_dir = TempDir::new().expect("temp dir"); + let catalog = Arc::new(IndexCatalog::new(temp_dir.path())); + catalog.initialize().await.expect("init catalog"); + catalog + .create_index( + "test", + CreateIndexRequest { + settings: IndexSettings::default(), + ..Default::default() + }, + ) + .await + .expect("create index"); + let mut handle = catalog.open_index("test").await.expect("open index"); + + // doc1 has a very long token and a normal token + handle + .index_document(doc( + "doc1", + serde_json::json!({"content": "superlongtokenname rust"}), + )) + .await + .expect("index doc1"); + handle + .index_document(doc("doc2", serde_json::json!({"content": "rust"}))) + .await + .expect("index doc2"); + handle.refresh().await.expect("refresh"); + handle.flush().await.expect("flush"); + + // With max_word_length=4, "superlongtokenname" is filtered — only "rust" is used + let result = handle.search(&SearchRequest { + query: Some(SearchQuery::Mlt(MltQuery { + doc_id: Some("doc1".to_string()), + like: None, + fields: vec!["content".to_string()], + min_term_freq: 1, + min_doc_freq: 1, + max_query_terms: 25, + min_word_length: None, + max_word_length: Some(4), + field_boost_factor: 1.0, + })), + ..Default::default() + }); + + assert!( + result.hits.total >= 1, + "MLT with max_word_length=4 should still find doc2 via 'rust', got: {}", + result.hits.total + ); +} + +#[tokio::test] +async fn mlt_all_terms_filtered_returns_empty_or_error() { + // When all terms in the reference document are filtered out by min_term_freq, + // MLT should return empty results or an error + let temp_dir = TempDir::new().expect("temp dir"); + let catalog = Arc::new(IndexCatalog::new(temp_dir.path())); + catalog.initialize().await.expect("init catalog"); + catalog + .create_index( + "test", + CreateIndexRequest { + settings: IndexSettings::default(), + ..Default::default() + }, + ) + .await + .expect("create index"); + let mut handle = catalog.open_index("test").await.expect("open index"); + + // doc1 has only one occurrence of "rare" — min_term_freq=2 will filter it out + handle + .index_document(doc("doc1", serde_json::json!({"content": "rare"}))) + .await + .expect("index doc1"); + handle + .index_document(doc("doc2", serde_json::json!({"content": "other"}))) + .await + .expect("index doc2"); + handle.refresh().await.expect("refresh"); + handle.flush().await.expect("flush"); + + let result = handle.search(&SearchRequest { + query: Some(SearchQuery::Mlt(MltQuery { + doc_id: Some("doc1".to_string()), + like: None, + fields: vec!["content".to_string()], + min_term_freq: 2, // "rare" has tf=1, filtered out + min_doc_freq: 1, + max_query_terms: 25, + min_word_length: None, + max_word_length: None, + field_boost_factor: 1.0, + })), + ..Default::default() + }); + + // All terms filtered → empty results + assert_eq!( + result.hits.total, 0, + "MLT with all terms filtered should return empty results" + ); +} + #[tokio::test] async fn multi_match_best_fields_returns_max_score() { // Doc has "foo" in title and "bar" in content. From 29dd40afc5f795ff66b664158d9ac68a7be0fe3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:32:00 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(index):=20strengthen=20MLT=20assertion?= =?UTF-8?q?s=20=E2=80=94=20verify=20matched=20doc=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mlt_with_like_and_empty_fields: fix weak >=1 check to verify both docs match (total==2) with correct ranking (doc1 > doc2) - mlt_with_max_word_length: fix weak >=1 check to verify total==1 and doc2 is the only match --- .../cloudsearch-index/tests/coverage.rs | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/rust/crates/cloudsearch-index/tests/coverage.rs b/rust/crates/cloudsearch-index/tests/coverage.rs index 6831355..f98cf5d 100644 --- a/rust/crates/cloudsearch-index/tests/coverage.rs +++ b/rust/crates/cloudsearch-index/tests/coverage.rs @@ -976,7 +976,7 @@ async fn mlt_with_like_and_empty_fields_auto_infers_from_like_json() { let result = handle.search(&SearchRequest { query: Some(SearchQuery::Mlt(MltQuery { doc_id: None, - like: Some(serde_json::json!({"title": "rustacean", "body": "systems"})), + like: Some(serde_json::json!({"title": "rust programming", "body": "systems"})), fields: vec![], min_term_freq: 1, min_doc_freq: 1, @@ -988,11 +988,19 @@ async fn mlt_with_like_and_empty_fields_auto_infers_from_like_json() { ..Default::default() }); - // Should find doc1 (matches "rust" in title and "systems" in body) - assert!( - result.hits.total >= 1, - "MLT with auto-inferred fields from like JSON should find matching docs, got total: {}", - result.hits.total + // Both docs match "rust". doc1 has more shared terms (rust + systems) → higher score. + // doc2 only shares "rust". Neither is excluded since 'like' (not doc_id) is the source. + assert_eq!( + result.hits.total, 2, + "both docs share 'rust', so both should match" + ); + assert_eq!( + result.hits.hits[0].id, "doc1", + "doc1 has more shared terms (rust + systems) → highest score" + ); + assert_eq!( + result.hits.hits[1].id, "doc2", + "doc2 only has 'rust' in common → lower score" ); } @@ -1097,10 +1105,13 @@ async fn mlt_with_max_word_length_filters_long_terms() { ..Default::default() }); - assert!( - result.hits.total >= 1, - "MLT with max_word_length=4 should still find doc2 via 'rust', got: {}", - result.hits.total + assert_eq!( + result.hits.total, 1, + "only doc2 should match (doc1 is the source and excluded)" + ); + assert_eq!( + result.hits.hits[0].id, "doc2", + "doc2 has 'rust' which passes max_word_length=4" ); }