From 22bf70c6b44e89bb98148bd844d40e97f8d543d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 13:47:41 +0000 Subject: [PATCH] fix(quartz): local search matches a searchable kind only by what it indexes EventSearchMatcher substring-matched the first value of every tag (minus client/p/e/a/alt) before walking the event's indexable fields, so local search found what no full-text store would: a list item's `medium` "music", cover URLs, ids in `d`, hashtags a kind leaves out of its index. A SearchableEvent now matches through forEachIndexableField alone, the read side of the same indexableContent() the stores index, so local and store search agree. Kinds that are not SearchableEvent have no indexable fields and keep matching their tag values and content. On the recorded relay corpus (tools/search-parity) the match set is unchanged (56 of 64 before and after). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XJSf4ekFVuhSj5m8eeNsP --- .../quartz/nip50Search/EventSearchMatcher.kt | 23 +++++++---- .../nip50Search/EventSearchMatcherTest.kt | 41 ++++++++++++++++--- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt index 3e005d88b2..59f1a9cdd3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt @@ -31,8 +31,14 @@ import com.vitorpamplona.quartz.nip01Core.core.fastAny * * ## What it matches * - * Terms are ANDed and each is a case-insensitive substring — of a tag value, or of the event's - * own indexable fields. Substring rather than token because that is what Amethyst's local search + * Terms are ANDed and each is a case-insensitive substring of the event's indexable fields — the + * same text a store's full-text index holds ([SearchableEvent.forEachIndexableField] is the read + * side of [SearchableEvent.indexableContent]), so local search and a store or relay agree on what + * an event can be found by. A tag value is searched only when the kind indexes it: a hashtag, an + * id, a URL or a data label the kind leaves out of its index is left out here too. A kind that is + * not a [SearchableEvent] has no indexable fields, so it falls back to its tag values and content. + * + * Substring rather than token because that is what Amethyst's local search * has always done (`content.contains(text, true)`), and switching to tokens would silently stop * matching mid-word queries; AND rather than one literal phrase because that is what a relay does * with the same string, and the point of routing local search through a `Filter` is that the two @@ -57,8 +63,9 @@ import com.vitorpamplona.quartz.nip01Core.core.fastAny class EventSearchMatcher( search: String?, /** - * Tag names whose values are not worth searching — `p`/`e`/`a` hold hex ids that only ever - * match by accident, `client` and `alt` hold text the author did not write. + * For a kind that is not a [SearchableEvent]: tag names whose values are not worth searching — + * `p`/`e`/`a` hold hex ids that only ever match by accident, `client` and `alt` hold text the + * author did not write. */ private val exceptTagNames: Set = DEFAULT_EXCLUDED_TAGS, ) { @@ -82,11 +89,9 @@ class EventSearchMatcher( event: Event, term: String, ): Boolean { - if (event.tags.fastAny { it.size > 1 && it[0] !in exceptTagNames && it[1].contains(term, true) }) { - return true - } - if (event !is SearchableEvent) return event.content.contains(term, true) - return visitor.matches(event, term) + if (event is SearchableEvent) return visitor.matches(event, term) + return event.content.contains(term, true) || + event.tags.fastAny { it.size > 1 && it[0] !in exceptTagNames && it[1].contains(term, true) } } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt index cf35a7b7a7..fd6a049a3d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt @@ -66,18 +66,49 @@ class EventSearchMatcherTest { } @Test - fun tagValuesAreSearchedExceptTheOnesNobodyMeansToSearch() { + fun aSearchableKindMatchesWhatItIndexesAndNothingElse() { + // Kind 1 indexes its subject and content, as a store's full-text index does; a hashtag + // the kind leaves out of its index is left out here too, so local and store search agree. assertTrue(matches("subj", note("body", arrayOf(arrayOf("subject", "a subject"))))) - assertTrue(matches("bitcoin", note("body", arrayOf(arrayOf("t", "bitcoin"))))) - // `p`/`e`/`a` hold hex ids, `client` and `alt` hold text the author did not write. + assertFalse(matches("bitcoin", note("body", arrayOf(arrayOf("t", "bitcoin"))))) assertFalse(matches("amethyst", note("body", arrayOf(arrayOf("client", "amethyst"))))) assertFalse(matches("deadbeef", note("body", arrayOf(arrayOf("p", "deadbeef".repeat(8)))))) } + @Test + fun aDecentralizedListItemMatchesItsNaturalLanguageTagsButNotItsData() { + val book = + note( + "", + arrayOf( + arrayOf("d", "ol-ol1141875w"), + arrayOf("title", "Herfsttij der Middeleeuwen"), + arrayOf("author", "Johan Huizinga"), + arrayOf("subject", "Middle Ages"), + arrayOf("medium", "music"), + arrayOf("cover", "https://covers.openlibrary.org/b/id/7245546-L.jpg"), + ), + kind = 39999, + ) + assertTrue(matches("huizinga", book)) + assertTrue(matches("middle ages", book)) + assertFalse(matches("music", book)) + assertFalse(matches("openlibrary", book)) + assertFalse(matches("ol1141875w", book)) + } + + @Test + fun aKindWithNoIndexableFieldsFallsBackToItsTagsAndContent() { + assertTrue(matches("bitcoin", note("+", arrayOf(arrayOf("t", "bitcoin")), kind = 7))) + assertTrue(matches("+", note("+", kind = 7))) + // `p`/`e`/`a` hold hex ids, `client` and `alt` hold text the author did not write. + assertFalse(matches("amethyst", note("+", arrayOf(arrayOf("client", "amethyst")), kind = 7))) + assertFalse(matches("deadbeef", note("+", arrayOf(arrayOf("p", "deadbeef".repeat(8))), kind = 7))) + } + @Test fun indexableFieldsBeyondContentAreSearched() { - // A long-form title lives in a tag, but its summary reaches the matcher through the - // visitor as well — both paths must find it. + // A long-form title and summary live in tags; they reach the matcher through the visitor. val article = note("the body", arrayOf(arrayOf("title", "Lightning"), arrayOf("summary", "a summary")), kind = 30023) assertTrue(matches("Lightning", article)) assertTrue(matches("summary", article))