mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
fix(quartz): route the roles the search extractor was stranding in the body tier
Audited all ~70 branches of SearchFieldExtractor.base() against the file's own stated invariant -- "each explicit branch splits exactly the accessors that kind's indexableContent() concatenates" -- for all 133 searchable kinds. Nothing tested it, and it had drifted three ways. The full table is in the PR description; this commit is what it turned up. 19 kinds gain a branch. 1. A title in the wrong tier. 17 kinds fell through to the catch-all, which dumps the whole indexableContent() into the body role, so their titles could never reach the title band a weighted backend gives one. The marketplace family (30017/30018/30019/30020) and the Podcasting 2.0 pair (30054/30055) are the sharpest -- a stall name and an episode title are what people actually type. Kind 9002 is the tell-tale: it edits the very metadata kind 39000 publishes, and 39000 had a branch while 9002 did not. Also branched: 1010, 1065, 1068, 1163, 1985, 2473, 6969, 12473, 38192, 38383. Every kind still falling through is now body-only -- its whole searchable text really is a body (a chat message, a zap comment, a git patch, a DVM prompt) -- so no title is left stranded. 2. A role the branch forgot. hashtags and locations are filled systemically by the tiers() funnel, but websites is per-branch, and four kinds with a public URL were not passing one: GitRepositoryEvent (clones(), the URL most people would search a repo by), MeetingSpaceEvent (endpoint(), the same `streaming` tag kind 30311 already carries), and both nSite kinds (source()). Image, icon and infrastructure URLs stay out on purpose. 3. Drift against indexableContent(). Six kinds concatenated their `t` tags INTO the flat blob while the funnel also carried them as hashtags, so the same words were indexed twice, in the weakest role -- exactly the shape most likely to skew a term-frequency ranker. Fixed by their new branches (1111, 1311, 9002, 30018, 30020, 30054), the same treatment InterestSetEvent and ContactCardEvent already had. Two of those branches avoid creating the same duplication they remove: kind 2473's `alt` is Birdstar's boilerplate wrapper around the two species names, so it is indexed only when commonName() proves it is NOT that shape; kind 12473 is a life LIST, so its unbounded species collection sits in the secondary tier rather than claiming the title band once per bird. Also writes down the PROFILE XOR TIERED contract in the IndexableFields KDoc. The sealed type enforces it, and weighted backends already depend on it: a ranker that scores the two role groups independently and sums them stays correct only while no document can answer from a naming column in each group. A shape filling Profile.name and Tiered.primary at once would claim the top band twice -- measured downstream at ~260 000 against the ~130 000 a whole-field title match earns, i.e. one word per column outranking a document that IS the query. Saying so makes a future both-shapes kind a decision with a known cost rather than an accident. This is derived data: consumers must re-run IEventStore.reindexFullTextSearch() after upgrading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GznRZiv3zS7V9c2QQ9aMk9
This commit is contained in:
@@ -40,6 +40,25 @@ package com.vitorpamplona.quartz.nip50Search
|
||||
* once values are pre-joined a backend can't unmix them. Values produced by
|
||||
* [SearchFieldExtractor] are trimmed and non-empty; the types themselves do
|
||||
* not enforce it.
|
||||
*
|
||||
* ## PROFILE XOR TIERED — a contract, not an implementation detail
|
||||
*
|
||||
* A kind fills the profile roles or the content roles, NEVER both. The sealed
|
||||
* type enforces it today, and weighted backends are entitled to depend on it:
|
||||
* a ranker that scores the two role groups independently and SUMS them stays
|
||||
* correct only while no document can answer from a naming column in each
|
||||
* group. A shape that filled, say, [Profile.name] and [Tiered.primary] at
|
||||
* once would claim the top band twice — in the store this extractor was
|
||||
* built for, ~260 000 against the ~130 000 a whole-field title match earns,
|
||||
* i.e. a document matching one word per column outranking one that IS the
|
||||
* query.
|
||||
*
|
||||
* So a new kind that looks like both is a deliberate decision with a known
|
||||
* downstream cost, not an accident of the extractor. Pick the shape the kind
|
||||
* really has and route the rest through it — the way kind 31990 (an app
|
||||
* handler, whose metadata is a kind-0 clone) returns [Profile] wholesale
|
||||
* rather than a profile plus a tier. If a future shape genuinely must fill
|
||||
* both, the backends that sum these groups have to be told.
|
||||
*/
|
||||
sealed interface IndexableFields {
|
||||
fun isEmpty(): Boolean
|
||||
|
||||
+157
-4
@@ -27,25 +27,39 @@ import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.marketplace.MarketplaceEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
@@ -66,6 +80,7 @@ import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEven
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
@@ -78,6 +93,7 @@ import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
@@ -85,6 +101,7 @@ import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
@@ -92,6 +109,8 @@ import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
/**
|
||||
* Decomposes every [SearchableEvent] into [IndexableFields] by priority tier:
|
||||
@@ -148,8 +167,35 @@ object SearchFieldExtractor {
|
||||
tiers(event, event.title(), event.summary(), event.content)
|
||||
}
|
||||
|
||||
// NIP-15 kinds 30017/30018/30019/30020 -- the marketplace family
|
||||
// keeps its name and description inside a JSON `content` blob, so
|
||||
// the fallback dropped a stall/product/auction NAME into the body
|
||||
// tier, where a title can never reach the title band. Decoding
|
||||
// failures still reach the funnel, as they do for the buzz kinds.
|
||||
is StallEvent -> {
|
||||
event.stallData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
// ProductEvent.categories() is `t` under another name, so the
|
||||
// funnel already carries it in the hashtag role -- passing it
|
||||
// again would index the same words twice.
|
||||
is ProductEvent -> {
|
||||
event.productData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
is MarketplaceEvent -> {
|
||||
event.marketplaceData()?.let { tiers(event, it.name, it.about, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
is AuctionEvent -> {
|
||||
event.auctionData()?.let { tiers(event, it.name, it.description, null) } ?: tiers(event, null, null, null)
|
||||
}
|
||||
|
||||
// The clone URL is as much a repository's public address as its
|
||||
// homepage is -- `github.com/owner/repo.git` is how most people
|
||||
// would search for it -- so both fill the affiliation role.
|
||||
is GitRepositoryEvent -> {
|
||||
tiers(event, listOf(event.name()), listOf(event.description()), event.content, websites = event.webs())
|
||||
tiers(event, listOf(event.name()), listOf(event.description()), event.content, websites = (event.webs() + event.clones()).distinct())
|
||||
}
|
||||
|
||||
is GitIssueEvent -> {
|
||||
@@ -238,8 +284,10 @@ object SearchFieldExtractor {
|
||||
tiers(event, event.title(), event.summary(), event.content)
|
||||
}
|
||||
|
||||
// endpoint() is the `streaming` tag -- the same role
|
||||
// LiveActivitiesEvent.streaming() fills above.
|
||||
is MeetingSpaceEvent -> {
|
||||
tiers(event, event.room(), event.summary(), event.content)
|
||||
tiers(event, event.room(), event.summary(), event.content, website = event.endpoint())
|
||||
}
|
||||
|
||||
is MeetingRoomEvent -> {
|
||||
@@ -282,10 +330,29 @@ object SearchFieldExtractor {
|
||||
tiers(event, listOf(event.title()), listOf(event.description()), null, websites = event.websites())
|
||||
}
|
||||
|
||||
// kinds 30054/30055 -- the Podcasting 2.0 pair carries the same
|
||||
// title/description shape kind 54 does and was falling through:
|
||||
// an episode title indexed as body text. topics() is hashtags()
|
||||
// under another name, so the funnel carries it once.
|
||||
is Podcasting20EpisodeEvent -> {
|
||||
tiers(event, event.title(), event.description(), event.content)
|
||||
}
|
||||
|
||||
is Podcasting20TrailerEvent -> {
|
||||
tiers(event, event.title(), null, event.content)
|
||||
}
|
||||
|
||||
is GroupMetadataEvent -> {
|
||||
tiers(event, event.name(), event.about(), null)
|
||||
}
|
||||
|
||||
// kind 9002 edits the very metadata kind 39000 publishes, so it
|
||||
// splits the same way -- it was the only half of the pair falling
|
||||
// through. Its hashtags() is `t`, carried once by the funnel.
|
||||
is EditMetadataEvent -> {
|
||||
tiers(event, event.name(), event.about(), null)
|
||||
}
|
||||
|
||||
is InterestSetEvent -> {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
}
|
||||
@@ -328,12 +395,14 @@ object SearchFieldExtractor {
|
||||
tiers(event, event.title(), event.description(), null, website = event.url())
|
||||
}
|
||||
|
||||
// A site's `source` tag is the URL its files were published
|
||||
// from -- the same affiliation role a repo's homepage fills.
|
||||
is NamedSiteEvent -> {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
tiers(event, event.title(), event.description(), null, website = event.source())
|
||||
}
|
||||
|
||||
is RootSiteEvent -> {
|
||||
tiers(event, event.title(), event.description(), null)
|
||||
tiers(event, event.title(), event.description(), null, website = event.source())
|
||||
}
|
||||
|
||||
is RootNappletEvent -> {
|
||||
@@ -412,6 +481,50 @@ object SearchFieldExtractor {
|
||||
tiers(event, null, event.summary(), event.content)
|
||||
}
|
||||
|
||||
// kinds 1065/1163 -- summary-only kinds. Their whole searchable
|
||||
// text IS a summary, so it belongs in the summary tier, next to
|
||||
// kind 1063's, rather than in the body tier the fallback gave it.
|
||||
is FileStorageHeaderEvent -> {
|
||||
tiers(event, null, event.summary(), null)
|
||||
}
|
||||
|
||||
is ProfileGalleryEntryEvent -> {
|
||||
tiers(event, null, event.summary(), null)
|
||||
}
|
||||
|
||||
// kind 2473 -- a sighting IS its species, under both names: the
|
||||
// scientific one from the `n` tag and the vernacular one
|
||||
// commonName() parses out of the `alt`. Once that parse succeeds
|
||||
// the alt is only Birdstar's boilerplate wrapper around the two
|
||||
// ("Bird detection: <Common> (<Scientific>)"), so indexing it
|
||||
// whole would repeat both names in a second role; the alt is
|
||||
// carried only when it is NOT that shape and so holds text of its
|
||||
// own.
|
||||
is BirdDetectionEvent -> {
|
||||
val common = event.commonName()
|
||||
tiers(event, listOf(event.speciesName(), common), listOf(event.summary().takeIf { common == null }), null)
|
||||
}
|
||||
|
||||
// kind 12473 is a life LIST, not a sighting: its species names are
|
||||
// an unbounded collection, so they sit in the secondary tier with
|
||||
// a torrent's file names rather than claiming the title band once
|
||||
// per bird.
|
||||
is BirdexEvent -> {
|
||||
tiers(event, emptyList(), listOf(event.summary()) + event.speciesNames(), null)
|
||||
}
|
||||
|
||||
// kind 38192 -- the save's title is a title; region and filename
|
||||
// are the keywords beside it, like a torrent's file names.
|
||||
is Ps1SaveEvent -> {
|
||||
tiers(event, listOf(event.saveTitle()), listOf(event.summary(), event.region(), event.filename()), null)
|
||||
}
|
||||
|
||||
// kind 38383 -- an order is looked up by who is offering it;
|
||||
// currency and payment methods are the keywords that qualify it.
|
||||
is P2POrderEvent -> {
|
||||
tiers(event, listOf(event.makerName()), listOf(event.currency()) + event.paymentMethods().orEmpty(), null)
|
||||
}
|
||||
|
||||
is AudioTrackEvent -> {
|
||||
tiers(event, event.subject(), null, null)
|
||||
}
|
||||
@@ -458,6 +571,46 @@ object SearchFieldExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
// kind 1010 -- `content` is the proposed replacement text and
|
||||
// `summary` describes the edit, so they split the way kind 1063's
|
||||
// summary and body do (indexableContent concatenates them in the
|
||||
// other order; the roles, not the order, are what a weighted
|
||||
// backend reads).
|
||||
is TextNoteModificationEvent -> {
|
||||
tiers(event, null, event.summary(), event.content)
|
||||
}
|
||||
|
||||
// kinds 1068/6969 -- the question is the body, the option labels
|
||||
// are short answer-like values a searcher matches whole, so they
|
||||
// sit in the secondary tier UNJOINED instead of being appended to
|
||||
// the body the way indexableContent() has to.
|
||||
is PollEvent -> {
|
||||
tiers(event, emptyList(), event.options().map { it.label }, event.content)
|
||||
}
|
||||
|
||||
is ZapPollEvent -> {
|
||||
tiers(event, emptyList(), event.pollOptionsArray().map { it.descriptor }, event.content)
|
||||
}
|
||||
|
||||
// kind 1985 -- the label values are the keywords of the event;
|
||||
// the reasoning, if any, is the body.
|
||||
is LabelEvent -> {
|
||||
tiers(event, emptyList(), event.labels().map { it.label }, event.content)
|
||||
}
|
||||
|
||||
// kinds 1111/1311 -- body-only kinds whose indexableContent()
|
||||
// concatenates the `t` tags INTO the body. The funnel already
|
||||
// carries them in the hashtag role, so passing the body alone
|
||||
// stops the same words being indexed twice (the ContactCard
|
||||
// reasoning above, applied to the two chat/comment kinds).
|
||||
is CommentEvent -> {
|
||||
tiers(event, null, null, event.content)
|
||||
}
|
||||
|
||||
is LiveActivitiesChatMessageEvent -> {
|
||||
tiers(event, null, null, event.content)
|
||||
}
|
||||
|
||||
// kind 1 LAST among the explicit branches, defensively: a future
|
||||
// kind extending the text-note base must hit its own branch first.
|
||||
is TextNoteEvent -> {
|
||||
|
||||
+217
@@ -21,16 +21,31 @@
|
||||
package com.vitorpamplona.quartz.nip50Search
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent
|
||||
import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -219,4 +234,206 @@ class SearchFieldExtractorTest {
|
||||
val fields = SearchFieldExtractor.extract(MetadataEvent("9".repeat(64), alice, 1L, tags, "{}", ""))
|
||||
assertEquals(IndexableFields.None, fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marketplaceNamesReachTheTitleTierNotTheBody() {
|
||||
// NIP-15 keeps the stall's name inside a JSON content blob. Falling
|
||||
// through to the catch-all put that NAME in the body tier, where a
|
||||
// weighted backend can never rank it as a title.
|
||||
val content = """{"id":"s1","name":"Vitor's Coffee","description":"beans from Minas","currency":"BRL"}"""
|
||||
val fields = SearchFieldExtractor.extract(StallEvent("20".repeat(32), alice, 1L, arrayOf(arrayOf("d", "s1")), content, ""))
|
||||
assertEquals(IndexableFields.Tiered(primary = listOf("Vitor's Coffee"), secondary = listOf("beans from Minas")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun productCategoriesAreCarriedOnceAsHashtags() {
|
||||
// categories() is `t` under another name: indexableContent()
|
||||
// concatenates it into the flat blob, but the funnel already carries
|
||||
// it in the hashtag role, so the branch must not pass it again.
|
||||
val content = """{"id":"p1","stall_id":"s1","name":"Bag of Beans","description":"1kg","currency":"BRL","price":90.0}"""
|
||||
val tags = arrayOf(arrayOf("d", "p1"), arrayOf("t", "coffee"))
|
||||
val fields = SearchFieldExtractor.extract(ProductEvent("21".repeat(32), alice, 1L, tags, content, ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Bag of Beans"), secondary = listOf("1kg"), hashtags = listOf("coffee")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unparseableMarketplaceContentStillIndexesItsHashtags() {
|
||||
val fields = SearchFieldExtractor.extract(StallEvent("22".repeat(32), alice, 1L, arrayOf(arrayOf("t", "coffee")), "not json", ""))
|
||||
assertEquals(IndexableFields.Tiered(hashtags = listOf("coffee")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupMetadataEditsSplitLikeTheMetadataTheyEdit() {
|
||||
// kind 9002 edits what kind 39000 publishes; it was the only half of
|
||||
// the pair without a branch. Its hashtags() is `t`: carried once.
|
||||
val tags = arrayOf(arrayOf("h", "grp"), arrayOf("name", "Nostr Devs"), arrayOf("about", "we build"), arrayOf("t", "nostr"))
|
||||
val fields = SearchFieldExtractor.extract(EditMetadataEvent("23".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Nostr Devs"), secondary = listOf("we build"), hashtags = listOf("nostr")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun podcasting20EpisodesSplitLikeEveryOtherTitledKind() {
|
||||
val tags = arrayOf(arrayOf("d", "ep1"), arrayOf("title", "Episode 42"), arrayOf("description", "on search"), arrayOf("t", "podcast"))
|
||||
val fields = SearchFieldExtractor.extract(Podcasting20EpisodeEvent("24".repeat(32), alice, 1L, tags, "show notes", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Episode 42"), secondary = listOf("on search"), text = "show notes", hashtags = listOf("podcast")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun summaryOnlyKindsUseTheSummaryTier() {
|
||||
// kind 1065's whole searchable text IS a summary -- it belongs beside
|
||||
// kind 1063's, not in the body tier.
|
||||
val tags = arrayOf(arrayOf("summary", "the quarterly report"))
|
||||
val fields = SearchFieldExtractor.extract(FileStorageHeaderEvent("25".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(IndexableFields.Tiered(secondary = listOf("the quarterly report")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun birdSightingsAreNamedByTheirSpeciesUnderBothNames() {
|
||||
// Birdstar's `alt` is a boilerplate wrapper around the two names, so
|
||||
// once commonName() has parsed it there is nothing left in it to
|
||||
// index -- carrying it whole would repeat both names in a second role.
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("n", "Porphyrio martinica"),
|
||||
arrayOf("alt", "Bird detection: Purple Gallinule (Porphyrio martinica)"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(BirdDetectionEvent("26".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(IndexableFields.Tiered(primary = listOf("Porphyrio martinica", "Purple Gallinule")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBirdSightingWithAnUnrecognizedAltStillIndexesIt() {
|
||||
// A publisher that words the alt differently keeps it: the summary is
|
||||
// dropped only when commonName() proves it was the boilerplate.
|
||||
val tags = arrayOf(arrayOf("n", "Ramphastos toco"), arrayOf("alt", "a toucan at the feeder"))
|
||||
val fields = SearchFieldExtractor.extract(BirdDetectionEvent("3a".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Ramphastos toco"), secondary = listOf("a toucan at the feeder")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLifeListKeepsItsSpeciesOutOfTheTitleTier() {
|
||||
// kind 12473 is an unbounded collection, not a sighting: one title
|
||||
// band per bird would dilute the tier the way a torrent's file list
|
||||
// would.
|
||||
val tags = arrayOf(arrayOf("n", "Ramphastos toco"), arrayOf("n", "Porphyrio martinica"), arrayOf("alt", "my life list"))
|
||||
val fields = SearchFieldExtractor.extract(BirdexEvent("3b".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(secondary = listOf("my life list", "Ramphastos toco", "Porphyrio martinica")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTitlesAreTitlesAndTheRestAreKeywords() {
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "save1"),
|
||||
arrayOf("title", "Final Fantasy VII"),
|
||||
arrayOf("filename", "BASCUS-94163"),
|
||||
arrayOf("region", "NTSC-U"),
|
||||
arrayOf("alt", "a memory card save"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(Ps1SaveEvent("27".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(
|
||||
primary = listOf("Final Fantasy VII"),
|
||||
secondary = listOf("a memory card save", "NTSC-U", "BASCUS-94163"),
|
||||
),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun p2pOrdersAreNamedByTheirMaker() {
|
||||
val tags = arrayOf(arrayOf("d", "o1"), arrayOf("name", "Satoshi"), arrayOf("f", "BRL"), arrayOf("pm", "pix", "wire"))
|
||||
val fields = SearchFieldExtractor.extract(P2POrderEvent("28".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Satoshi"), secondary = listOf("BRL", "pix", "wire")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pollOptionsAreCarriedUnjoinedInTheSecondaryTier() {
|
||||
// indexableContent() has to append the labels to the body; the roles
|
||||
// keep them separate, so the backend chooses how to weight them.
|
||||
val tags = arrayOf(arrayOf("option", "1", "Coffee"), arrayOf("option", "2", "Tea"))
|
||||
val fields = SearchFieldExtractor.extract(PollEvent("29".repeat(32), alice, 1L, tags, "what should I drink?", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(secondary = listOf("Coffee", "Tea"), text = "what should I drink?"),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labelValuesAreKeywordsNotBody() {
|
||||
val tags = arrayOf(arrayOf("l", "spam", "report"), arrayOf("L", "report"))
|
||||
val fields = SearchFieldExtractor.extract(LabelEvent("2b".repeat(32), alice, 1L, tags, "obvious bot", ""))
|
||||
assertEquals(IndexableFields.Tiered(secondary = listOf("spam"), text = "obvious bot"), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commentHashtagsAreNotIndexedTwice() {
|
||||
// indexableContent() concatenates the `t` tags INTO the body for kind
|
||||
// 1111; the funnel already carries them in the hashtag role, so the
|
||||
// branch passes the body alone.
|
||||
val tags = arrayOf(arrayOf("t", "nostr"))
|
||||
val fields = SearchFieldExtractor.extract(CommentEvent("2c".repeat(32), alice, 1L, tags, "good point", ""))
|
||||
assertEquals(IndexableFields.Tiered(text = "good point", hashtags = listOf("nostr")), fields)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repositoriesAreFindableByCloneUrlAndHomepage() {
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "amethyst"),
|
||||
arrayOf("name", "Amethyst"),
|
||||
arrayOf("description", "a nostr client"),
|
||||
arrayOf("web", "https://amethyst.social"),
|
||||
arrayOf("clone", "https://github.com/vitorpamplona/amethyst.git"),
|
||||
// A repo whose homepage IS its clone URL must not index it twice.
|
||||
arrayOf("clone", "https://amethyst.social"),
|
||||
)
|
||||
val fields = SearchFieldExtractor.extract(GitRepositoryEvent("2d".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(
|
||||
primary = listOf("Amethyst"),
|
||||
secondary = listOf("a nostr client"),
|
||||
websites = listOf("https://amethyst.social", "https://github.com/vitorpamplona/amethyst.git"),
|
||||
),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun meetingSpacesCarryTheirStreamingUrlLikeLiveActivitiesDo() {
|
||||
val tags = arrayOf(arrayOf("d", "room1"), arrayOf("title", "Design Sync"), arrayOf("streaming", "https://nests.example/room1"))
|
||||
val fields = SearchFieldExtractor.extract(MeetingSpaceEvent("2e".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("Design Sync"), websites = listOf("https://nests.example/room1")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staticSitesCarryTheirSourceUrl() {
|
||||
val tags = arrayOf(arrayOf("d", "blog"), arrayOf("title", "My Blog"), arrayOf("source", "https://github.com/me/blog"))
|
||||
val fields = SearchFieldExtractor.extract(NamedSiteEvent("2f".repeat(32), alice, 1L, tags, "", ""))
|
||||
assertEquals(
|
||||
IndexableFields.Tiered(primary = listOf("My Blog"), websites = listOf("https://github.com/me/blog")),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user