Rank an exact title above a longer one that starts the same (#80)

'test' and 'test2' scored identically for the query 'test' — both have it at
the start of the title — so the order fell through to the date, and 'test2'
being still to come put it above a 'test' that had already passed. Equal
scores now compare how much of the title the match covers, which is 1.0 only
for an exact hit, before date is consulted.
This commit is contained in:
2026-08-08 16:34:58 +02:00
parent a9c5bc1038
commit efd670f2ff
2 changed files with 45 additions and 2 deletions

View File

@@ -42,6 +42,8 @@ data class SearchHit(
* sat in that field (whole-field start > word start > mid-word), and an event
* ranks by the *weakest* field the query needed — one token findable only in the
* description drags the whole event below the title matches, however soon it is.
* Equal scores are separated by how much of the title the match covers, so an
* exact title beats one that merely begins the same way, and only then by date.
*/
object EventSearchRanker {
@@ -86,6 +88,7 @@ object EventSearchRanker {
.sortedWith(
compareByDescending<Scored> { it.weakest }
.thenByDescending { it.total }
.thenByDescending { it.titleCoverage }
.thenBy { if (it.hit.event.end >= now) 0 else 1 }
.thenComparator { a, b ->
val aStart = a.hit.event.start
@@ -121,10 +124,11 @@ object EventSearchRanker {
}
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
val titleSpans = spansIn(title, tokens)
return Scored(
hit = SearchHit(
event = candidate.event,
titleSpans = spansIn(title, tokens),
titleSpans = titleSpans,
locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(),
descriptionSnippet = description
?.takeIf { descriptionSpans.isNotEmpty() }
@@ -132,9 +136,21 @@ object EventSearchRanker {
),
weakest = weakest,
total = total,
titleCoverage = coverage(title, titleSpans),
)
}
/**
* How much of [text] the match accounts for — 1.0 when the query *is* the
* title. What separates an exact hit from one that merely starts the same
* way: "test" and "test2" score identically otherwise, and the shorter title
* is the closer answer whichever of the two happens to be sooner.
*/
private fun coverage(text: String, spans: List<MatchSpan>): Double {
if (text.isEmpty() || spans.isEmpty()) return 0.0
return spans.sumOf { it.end - it.start }.toDouble() / text.length
}
/** The best a single token does across the fields, or null if it is absent. */
private fun bestScore(
token: String,
@@ -217,5 +233,10 @@ object EventSearchRanker {
return DescriptionSnippet(text, rebased)
}
private data class Scored(val hit: SearchHit, val weakest: Int, val total: Int)
private data class Scored(
val hit: SearchHit,
val weakest: Int,
val total: Int,
val titleCoverage: Double,
)
}

View File

@@ -121,6 +121,28 @@ class EventSearchRankerTest {
.inOrder()
}
@Test
fun `an exact title beats one that only starts the same way, whenever it is`() {
// Reported: "test" at 19:00 had already passed and "test2" at 23:00 was
// still to come, so the date key put the inexact match first.
val exact = candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 3 * 3_600_000L)
val longer = candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 3_600_000L)
assertThat(titlesFor(longer, exact, query = "test"))
.containsExactly("test", "test2")
.inOrder()
}
@Test
fun `a shorter title wins when both merely contain the query`() {
val shorter = candidate(1L, "Team Meeting")
val longer = candidate(2L, "Team Meeting with the whole department")
assertThat(titlesFor(longer, shorter, query = "meeting"))
.containsExactly("Team Meeting", "Team Meeting with the whole department")
.inOrder()
}
@Test
fun `ties keep the nearest-first order, upcoming before past`() {
val soon = candidate(1L, "Test", startMillis = now.toEpochMilliseconds() + 86_400_000L)