fix: give Find Similar an explicit empty-result filter state and clamp similarity

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 15:59:53 +00:00
co-authored by Claude Haiku 5.5
parent 1c815fb3f8
commit eb009d1bd6
5 changed files with 41 additions and 8 deletions
+7
View File
@@ -29,6 +29,10 @@ pub struct Filter {
/// Only these photos (Find Similar results…); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub only: Vec<PhotoId>,
/// Show nothing: an explicit empty result (Find Similar with no matches), since an empty
/// `only` means "no constraint".
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub only_none: bool,
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<ColorLabel>,
@@ -325,6 +329,9 @@ impl Filter {
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if self.only_none {
return false;
}
if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) {
return false;
}
+3 -5
View File
@@ -106,17 +106,15 @@ fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = sig(s, q) {
let sim = lightcraft_pipeline::cull::similarity(&me, &v);
if sim >= min {
hits.push((q, sim));
hits.push((q, sim.clamp(0.0, 1.0)));
}
}
}
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {
s.filter.only = hits.iter().map(|h| h.0).collect();
if s.filter.only.is_empty() {
// an empty `only` means "no constraint": use an id no photo has so no hits show no photos
s.filter.only = vec![PhotoId(u64::MAX)];
}
// an empty `only` means "no constraint": no hits is its own state that shows no photos
s.filter.only_none = s.filter.only.is_empty();
}
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
}
+27
View File
@@ -1112,6 +1112,33 @@ fn find_similar_with_no_hits_shows_no_photos() {
assert_eq!(s.visible_cloned().len(), s.catalog.len(), "Clear Filters brings everything back");
}
#[test]
fn find_similar_of_a_flat_photo_shows_an_empty_view_and_counts_zero() {
let mut s = demo();
let photo = s.catalog.photos().next().unwrap().clone();
// a flat photo's signature is all zeros: it is not similar even to itself
s.signatures.insert(crate::media::content_key(&photo), [0.0; 64]);
let r = s.execute("library.findSimilar", &json!({"id": photo.id.0})).unwrap();
assert_eq!(r["photos"].as_array().map(Vec::len), Some(0));
assert_eq!(s.visible_cloned().len(), 0, "no matches show nothing, not the whole library");
assert_eq!(s.catalog.count(&s.filter), 0);
let chips = crate::filter_chips(&s.filter, &s.catalog);
assert_eq!(chips.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["Only 0 photos"]);
s.execute("library.filter", &chips[0].clear).unwrap();
assert_eq!(s.filter, Default::default(), "the chip clears the empty state");
assert_eq!(s.visible_cloned().len(), s.catalog.len());
}
#[test]
fn find_similar_values_stay_within_zero_and_one() {
let mut s = demo();
let id = s.catalog.photos().next().unwrap().id;
let r = s.execute("library.findSimilar", &json!({"id": id.0, "similarity": -1.0})).unwrap();
let sims: Vec<f64> = r["photos"].as_array().unwrap().iter().map(|h| h["similarity"].as_f64().unwrap()).collect();
assert_eq!(sims.len(), s.catalog.len());
assert!(sims.iter().all(|v| (0.0..=1.0).contains(v)), "{sims:?}");
}
#[test]
fn quick_develop_adds_to_each_photo() {
use lightcraft_catalog::PhotoId;
+3 -2
View File
@@ -214,8 +214,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null}));
}
if !f.only.is_empty() {
add(format!("Only {} photos", f.only.len()), json!({"only": []}));
if f.only_none || !f.only.is_empty() {
let n = if f.only_none { 0 } else { f.only.len() };
add(format!("Only {n} photos"), json!({"only": [], "onlyNone": false}));
}
v
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt
};
}
if chip.clear.get("only").is_some() {
return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = filter.only.len()));
return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = if filter.only_none { 0 } else { filter.only.len() }));
}
if let Some((prefix, value)) = chip.label.split_once(": ") {
let value = if matches!(prefix, "Flag" | "Type") {