Merge pull request #4035 from vitorpamplona/claude/nip50-search-role-mapping-rt4lin

fix(quartz): route the roles the search extractor was stranding in the body tier
This commit is contained in:
Vitor Pamplona
2026-09-01 15:40:06 -04:00
committed by GitHub
3 changed files with 489 additions and 18 deletions
@@ -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
@@ -27,25 +27,42 @@ 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.core.fastAny
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
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 +83,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 +96,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 +104,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 +112,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 +170,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 +287,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 +333,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 +398,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 +484,53 @@ 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`. The `alt` itself still
// goes to the summary tier whole. It is usually just Birdstar's
// boilerplate wrapper around those two names ("Bird detection:
// <Common> (<Scientific>)"), so this repeats them in a second
// role -- but only commonName()'s PREFIX match decides that the
// alt is boilerplate, and a publisher can write anything after
// the parenthetical. Dropping the alt on a prefix match lost that
// tail from every role, which is precisely the drift against
// indexableContent() this file exists to prevent: the repeat
// costs a duplicate in the weakest role, the drop cost recall.
is BirdDetectionEvent -> {
tiers(event, listOf(event.speciesName(), event.commonName()), listOf(event.summary()), 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 +577,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 -> {
@@ -475,46 +634,94 @@ object SearchFieldExtractor {
}
}
/** Single-value convenience over the list funnel — most kinds carry one title, one summary, one body. */
/**
* Single-value convenience over the list funnel — most kinds carry one
* title, one summary, one body. Cleans each value straight into its role
* rather than wrapping it in a list first: extraction runs once per stored
* event, and the wrappers were three throwaway lists on every one of them.
*/
private fun tiers(
event: Event,
primary: String?,
secondary: String?,
text: String?,
website: String? = null,
) = tiers(event, listOf(primary), listOf(secondary), text, listOf(website))
) = build(event, cleanOne(primary), cleanOne(secondary), text, cleanOne(website))
/**
* The one funnel every content branch uses — [IndexableFields.Tiered.hashtags]
* and [IndexableFields.Tiered.locations] are filled here, so no branch can
* forget them. Values stay UNJOINED: separator choices belong to the backend.
*/
private fun tiers(
event: Event,
primary: List<String?>,
secondary: List<String?>,
text: String?,
websites: List<String?> = emptyList(),
) = build(event, cleanAll(primary), cleanAll(secondary), text, cleanAll(websites))
/**
* The one funnel BOTH [tiers] overloads end in — [IndexableFields.Tiered.hashtags]
* and [IndexableFields.Tiered.locations] are filled here, so no branch can
* forget them. Values stay UNJOINED: separator choices belong to the backend.
*/
private fun build(
event: Event,
primary: List<String>,
secondary: List<String>,
text: String?,
websites: List<String>,
) = IndexableFields.Tiered(
primary = cleanAll(primary),
secondary = cleanAll(secondary),
primary = primary,
secondary = secondary,
text = clean(text),
hashtags = cleanAll(event.tags.hashtags()),
hashtags = hashtagValues(event),
locations = locationValues(event),
websites = cleanAll(websites),
websites = websites,
)
/** Trim and drop empties at the single funnel every derived string passes through. */
private fun clean(s: String?): String? = s?.trim()?.ifEmpty { null }
private fun cleanAll(parts: List<String?>): List<String> = parts.mapNotNull { clean(it) }
private fun cleanOne(s: String?): List<String> = clean(s)?.let { listOf(it) } ?: emptyList()
/**
* Collects lazily: a role whose values are all absent — the common case on
* most kinds — costs no list at all.
*/
private fun cleanAll(parts: List<String?>): List<String> {
var values: MutableList<String>? = null
for (i in parts.indices) {
val value = clean(parts[i]) ?: continue
(values ?: ArrayList<String>(parts.size).also { values = it }).add(value)
}
return values ?: emptyList()
}
/**
* The hashtag role. [hashtags] allocates unconditionally, so the scan for
* a `t` tag comes first — most events carry none. The guard is exactly
* [HashtagTag.parse]'s own acceptance test, so it can never skip a tag the
* accessor would have returned.
*/
private fun hashtagValues(event: Event): List<String> = if (!event.tags.fastAny(HashtagTag::isTagged)) emptyList() else cleanAll(event.tags.hashtags())
/**
* Every `location` tag value, on ANY kind. Deliberately a raw scan, not a
* typed accessor: Quartz's LocationTag classes are per-NIP (calendar,
* picture, classifieds) and only those kinds expose locations(), while
* this funnel must also catch location tags on kinds whose class doesn't
* model them.
* model them. Collects lazily, like [cleanAll]: an event with no location
* tag — nearly all of them — allocates nothing here.
*/
private fun locationValues(event: Event): List<String> = event.tags.mapNotNull { tag -> if (tag.getOrNull(0) != "location") null else clean(tag.getOrNull(1)) }
private fun locationValues(event: Event): List<String> {
var values: MutableList<String>? = null
event.tags.fastForEach { tag ->
if (tag.size > 1 && tag[0] == LOCATION_TAG) {
val value = clean(tag[1])
if (value != null) {
(values ?: ArrayList<String>(2).also { values = it }).add(value)
}
}
}
return values ?: emptyList()
}
private const val LOCATION_TAG = "location"
}
@@ -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,234 @@ 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() {
// The scientific name comes from the `n` tag, the vernacular one from
// commonName()'s parse of the `alt` -- both are names, so both are
// titles. The alt still reaches the summary tier whole.
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"),
secondary = listOf("Bird detection: Purple Gallinule (Porphyrio martinica)"),
),
fields,
)
}
@Test
fun aBirdSightingKeepsAltTextBeyondTheParsedNames() {
// commonName() only matches a PREFIX, so a publisher can write
// anything after the parenthetical. Dropping the alt whenever that
// prefix parsed lost the tail ("at Lake Merritt, 7am") from every
// role, while indexableContent() still carried it -- the exact drift
// this extractor exists to prevent.
val tags =
arrayOf(
arrayOf("n", "Porphyrio martinica"),
arrayOf("alt", "Bird detection: Purple Gallinule (Porphyrio martinica) at Lake Merritt, 7am"),
)
val fields = SearchFieldExtractor.extract(BirdDetectionEvent("3a".repeat(32), alice, 1L, tags, "", ""))
assertEquals(
IndexableFields.Tiered(
primary = listOf("Porphyrio martinica", "Purple Gallinule"),
secondary = listOf("Bird detection: Purple Gallinule (Porphyrio martinica) at Lake Merritt, 7am"),
),
fields,
)
}
@Test
fun aBirdSightingWithAnUnrecognizedAltStillIndexesIt() {
// An alt that does not start with the known prefix parses to no
// common name, and is carried as the summary it is.
val tags = arrayOf(arrayOf("n", "Ramphastos toco"), arrayOf("alt", "a toucan at the feeder"))
val fields = SearchFieldExtractor.extract(BirdDetectionEvent("3c".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,
)
}
}