From f3ecc3086c28c6d8d31c3d70b5fa6501db411506 Mon Sep 17 00:00:00 2001 From: OneWay Date: Sat, 10 Oct 2026 01:12:33 +0800 Subject: [PATCH] fix(search): order numbered headwords by numeric value The final ranking tie-break compared headwords lexicographically, so entries numbered with digits or CJK numerals sorted by text instead of by number. Compare runs of digits or numeral characters by their numeric value, saturating on very long runs. --- .../example/research/core/util/NaturalSort.kt | 128 ++++++++++++++++++ .../research/data/parser/SearchRanking.kt | 8 +- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/example/research/core/util/NaturalSort.kt diff --git a/app/src/main/java/com/example/research/core/util/NaturalSort.kt b/app/src/main/java/com/example/research/core/util/NaturalSort.kt new file mode 100644 index 0000000..581ed6c --- /dev/null +++ b/app/src/main/java/com/example/research/core/util/NaturalSort.kt @@ -0,0 +1,128 @@ +package com.example.research.core.util + +val NaturalStringComparator: Comparator = Comparator { a, b -> compareNatural(a, b) } + +private const val NUMERAL_CAP = 1_000_000_000_000_000_000L + +private fun digitOf(c: Char): Int = when (c) { + '〇', '零' -> 0 + '一', '壹', '壱', '弌' -> 1 + '二', '貳', '贰', '弐', '弍', '兩', '两' -> 2 + '三', '叁', '參', '参', '弎' -> 3 + '四', '肆' -> 4 + '五', '伍' -> 5 + '六', '陸', '陆' -> 6 + '七', '柒' -> 7 + '八', '捌' -> 8 + '九', '玖' -> 9 + else -> Character.digit(c, 10) +} + +private fun smallUnitOf(c: Char): Long = when (c) { + '十', '拾' -> 10L + '百', '佰' -> 100L + '千', '仟' -> 1000L + else -> 0L +} + +private fun fixedTensOf(c: Char): Long = when (c) { + '廿' -> 20L + '卅' -> 30L + '卌' -> 40L + else -> 0L +} + +private fun bigUnitOf(c: Char): Long = when (c) { + '万', '萬' -> 10_000L + '億', '亿' -> 100_000_000L + '兆' -> 1_000_000_000_000L + '京' -> 10_000_000_000_000_000L + else -> 0L +} + +private fun isNumeralChar(c: Char): Boolean = + digitOf(c) >= 0 || smallUnitOf(c) > 0L || fixedTensOf(c) > 0L || bigUnitOf(c) > 0L + +private fun satMul(a: Long, b: Long): Long = + if (a != 0L && b > NUMERAL_CAP / a) NUMERAL_CAP else minOf(a * b, NUMERAL_CAP) + +private fun satAdd(a: Long, b: Long): Long = minOf(a + b, NUMERAL_CAP) + +private fun numeralValue(s: String, start: Int, end: Int): Long { + var total = 0L + var section = 0L + var pending = -1L + for (k in start until end) { + val c = s[k] + val digit = digitOf(c) + if (digit >= 0) { + pending = if (pending < 0) digit.toLong() else satAdd(satMul(pending, 10L), digit.toLong()) + continue + } + val small = smallUnitOf(c) + if (small > 0L) { + section = satAdd(section, satMul(if (pending < 0) 1L else pending, small)) + pending = -1 + continue + } + val fixed = fixedTensOf(c) + if (fixed > 0L) { + section = satAdd(section, fixed) + pending = -1 + continue + } + val base = satAdd(section, maxOf(pending, 0L)) + total = satAdd(total, satMul(if (base == 0L) 1L else base, bigUnitOf(c))) + section = 0L + pending = -1 + } + return satAdd(total, satAdd(section, maxOf(pending, 0L))) +} + +private fun compareNatural(a: String, b: String): Int { + var i = 0 + var j = 0 + while (i < a.length && j < b.length) { + val ca = a[i] + val cb = b[j] + val asciiA = ca in '0'..'9' + val asciiB = cb in '0'..'9' + when { + !asciiA && !asciiB && isNumeralChar(ca) && isNumeralChar(cb) -> { + var endA = i + while (endA < a.length && isNumeralChar(a[endA])) endA++ + var endB = j + while (endB < b.length && isNumeralChar(b[endB])) endB++ + val diff = numeralValue(a, i, endA).compareTo(numeralValue(b, j, endB)) + if (diff != 0) return diff + i = endA + j = endB + } + asciiA && asciiB -> { + var endA = i + while (endA < a.length && a[endA] in '0'..'9') endA++ + var endB = j + while (endB < b.length && b[endB] in '0'..'9') endB++ + var startA = i + while (startA < endA - 1 && a[startA] == '0') startA++ + var startB = j + while (startB < endB - 1 && b[startB] == '0') startB++ + val lengthA = endA - startA + val lengthB = endB - startB + if (lengthA != lengthB) return lengthA.compareTo(lengthB) + for (k in 0 until lengthA) { + val diff = a[startA + k].compareTo(b[startB + k]) + if (diff != 0) return diff + } + i = endA + j = endB + } + else -> { + if (ca != cb) return ca.compareTo(cb) + i++ + j++ + } + } + } + return (a.length - i).compareTo(b.length - j) +} diff --git a/app/src/main/java/com/example/research/data/parser/SearchRanking.kt b/app/src/main/java/com/example/research/data/parser/SearchRanking.kt index 4185480..e3887ef 100644 --- a/app/src/main/java/com/example/research/data/parser/SearchRanking.kt +++ b/app/src/main/java/com/example/research/data/parser/SearchRanking.kt @@ -1,6 +1,7 @@ package com.example.research.data.parser import com.example.research.core.domain.model.IndexEntry +import com.example.research.core.util.NaturalStringComparator import com.example.research.core.util.foldLatinDiacritics internal data class SearchRankingContext( @@ -40,6 +41,11 @@ internal fun List.rankBySearchRelevance( entry = entry, ) } - .sortedWith(compareBy({ it.matchTier }, { it.exactMatch }, { it.charCount }, { it.word })) + .sortedWith( + compareBy { it.matchTier } + .thenBy { it.exactMatch } + .thenBy { it.charCount } + .thenBy(NaturalStringComparator) { it.word } + ) .map { it.entry } }