Contracts for 14 default categories and the category suggestion (UX-3, UX-8)

- contracts/schema/migrations/0002_categories_v2.sql: server migration 0002 rebuilds categories with the
  14-key default_key CHECK, inserts housing, insurance, children and fees with server_versions taken from
  the global sequence, sets schema_version 2. server-v1.sql unchanged.
- contracts/category-keywords.csv: the keyword table of the suggestion (148 entries, from docs/ux).
- contracts/test-vectors/suggest_category.json: 65 Kotlin-only cases of CS-4 to CS-6.
- contracts/examples/E-1-health: schema_version 2.
- tools/contracts-check.py: reference suggestCategory, keyword-table checks, 0002 load checks on a fresh
  and an in-use database, plain icon/color check, new icon and colour tokens.
- android/domain: suggestCategory (CS-4 to CS-6, code points, platform NFC and categories through
  UnicodeText), vector runner for suggest_category, eight vector files.
- server/crates/kvitt-domain/tests/vectors.rs: suggest_category is Kotlin-only.

The OpenAPI DefaultCategoryKey extension and the two new category examples are held back until the
server's wire types know the new keys (M12b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:10:57 +02:00
parent 86e08a12cf
commit aa765bbb48
12 changed files with 8295 additions and 13 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ dependencies {
}
// The shared test vectors (SPEC §14.5.5) are copied from the repository's contracts/ folder into the test resources.
// A missing file is not tolerated: VectorFilesTest fails unless exactly the seven files of §14.5.5 are present.
// A missing file is not tolerated: VectorFilesTest fails unless exactly the eight files of §14.5.5 are present.
val testVectorsRoot = layout.buildDirectory.dir("generated/test-vectors")
val copyTestVectors = tasks.register<Sync>("copyTestVectors") {
description = "Copies contracts/test-vectors/*.json into the :domain test resources."
@@ -0,0 +1,127 @@
package se.hagstedt.kvitt.domain
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.model.CategoryId
// Category suggestion from the description (UX-3, category-suggestion.md CS-4 to CS-6). Pure; the vectors are
// contracts/test-vectors/suggest_category.json (Kotlin only).
//
// The input records live here and not in :core:model: default keys are open strings because the
// :core:model DefaultCategoryKey enum does not yet carry the four keys of schema v2 (housing, insurance,
// children, fees), and the keyword table (contracts/category-keywords.csv) is data, not a UI contract.
/** One non-deleted expense on the phone (every account, either user). */
data class SuggestionHistoryEntry(
val description: String,
val categoryId: CategoryId,
val expenseDate: LocalDate,
/** Fixed RFC 3339 form (`YYYY-MM-DDTHH:MM:SS.mmmZ`), so string order is time order. */
val createdAt: String,
)
enum class KeywordMatch { TOKEN, PREFIX, PHRASE }
/** One row of the keyword table, `keyword` already normalised (CS-6). */
data class CategoryKeyword(val keyword: String, val match: KeywordMatch, val defaultKey: String)
/** A category that is neither archived nor deleted; `defaultKey` null for a user-created one. */
data class ActiveCategory(val id: CategoryId, val defaultKey: String?)
/**
* The Unicode services CS-4 needs that kotlin.* does not offer in a pure module: NFC and the general category of a
* code point. The app binds the platform's (java.text.Normalizer, Character); tests bind the same.
*/
interface UnicodeText {
fun nfc(text: String): String
/** General category Lu, Ll, Lt, Lm or Lo. */
fun isLetter(codePoint: Int): Boolean
/** General category Nd. */
fun isDigit(codePoint: Int): Boolean
}
/**
* CS-4: NFC, lowercase without locale rules, delete `'`, `’` and `:`, every other code point that is not a letter,
* a decimal digit or `&` becomes a space, then whitespace is collapsed and trimmed. Iterates code points.
*/
fun normalizeDescription(text: String, unicode: UnicodeText): String {
val lowered = unicode.nfc(text).lowercase()
val out = StringBuilder(lowered.length)
var i = 0
while (i < lowered.length) {
val c = lowered[i]
val pair = c.isHighSurrogate() && i + 1 < lowered.length && lowered[i + 1].isLowSurrogate()
val width = if (pair) 2 else 1
val codePoint = if (pair) 0x10000 + ((c.code - 0xD800) shl 10) + (lowered[i + 1].code - 0xDC00) else c.code
when {
codePoint == APOSTROPHE || codePoint == RIGHT_SINGLE_QUOTE || codePoint == COLON -> Unit
codePoint == AMPERSAND || unicode.isLetter(codePoint) || unicode.isDigit(codePoint) -> out.append(lowered, i, i + width)
else -> out.append(' ')
}
i += width
}
return out.split(' ').filter { it.isNotEmpty() }.joinToString(" ")
}
/**
* CS-5: history exact, then history first token, then the keyword table (CS-6), then [fallback] (the AC-8.6 default).
* An empty normalised description gives [fallback]. History counts only categories in [activeCategories]; the most
* often used wins, a tie goes to the category whose latest expense is latest (`expenseDate`, then `createdAt`), and a
* remaining tie to the lower category id (TODO(spec): not stated in category-suggestion.md).
*/
fun suggestCategory(
description: String,
history: List<SuggestionHistoryEntry>,
keywords: List<CategoryKeyword>,
activeCategories: List<ActiveCategory>,
fallback: CategoryId?,
unicode: UnicodeText,
): CategoryId? {
val normalised = normalizeDescription(description, unicode)
if (normalised.isEmpty()) return fallback
val tokens = normalised.split(' ')
val active = activeCategories.map { it.id }.toSet()
val stored = history.map { normalizeDescription(it.description, unicode) to it }
pickFromHistory(stored.filter { it.first == normalised }.map { it.second }, active)?.let { return it }
pickFromHistory(stored.filter { it.first.isNotEmpty() && it.first.substringBefore(' ') == tokens[0] }.map { it.second }, active)
?.let { return it }
val byKey = activeCategories.mapNotNull { c -> c.defaultKey?.let { it to c.id } }.toMap()
return keywords.withIndex()
.filter { matches(it.value, tokens) }
.sortedWith(compareByDescending<IndexedValue<CategoryKeyword>> { codePointLength(it.value.keyword) }.thenBy { it.index })
.firstNotNullOfOrNull { byKey[it.value.defaultKey] }
?: fallback
}
private class Tally(val id: CategoryId, val count: Int, val latest: SuggestionHistoryEntry)
private val RECENCY: Comparator<SuggestionHistoryEntry> = compareBy({ it.expenseDate }, { it.createdAt })
private fun pickFromHistory(rows: List<SuggestionHistoryEntry>, active: Set<CategoryId>): CategoryId? = rows
.filter { it.categoryId in active }
.groupBy { it.categoryId }
.map { (id, group) -> Tally(id, group.size, group.maxWith(RECENCY)) }
.sortedWith(
compareByDescending<Tally> { it.count }
.thenComparator { a, b -> RECENCY.compare(b.latest, a.latest) }
.thenBy { it.id.value },
)
.firstOrNull()
?.id
private fun matches(entry: CategoryKeyword, tokens: List<String>): Boolean = when (entry.match) {
KeywordMatch.TOKEN -> entry.keyword in tokens
KeywordMatch.PREFIX -> tokens.any { it.startsWith(entry.keyword) }
KeywordMatch.PHRASE -> {
val phrase = entry.keyword.split(' ')
(0..tokens.size - phrase.size).any { start -> tokens.subList(start, start + phrase.size) == phrase }
}
}
private fun codePointLength(text: String): Int = text.count { !it.isLowSurrogate() }
private const val APOSTROPHE = 0x27
private const val RIGHT_SINGLE_QUOTE = 0x2019
private const val COLON = 0x3A
private const val AMPERSAND = 0x26
@@ -0,0 +1,12 @@
package se.hagstedt.kvitt.domain
import java.text.Normalizer
/** The platform's Unicode services for the tests (the app binds the same in :data). */
object JvmUnicodeText : UnicodeText {
override fun nfc(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC)
override fun isLetter(codePoint: Int): Boolean = Character.isLetter(codePoint)
override fun isDigit(codePoint: Int): Boolean = Character.getType(codePoint) == Character.DECIMAL_DIGIT_NUMBER.toInt()
}
@@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** The vector directory holds exactly the seven files of SPEC §14.5.5; a missing or untested file fails. */
/** The vector directory holds exactly the eight files of SPEC §14.5.5; a missing or untested file fails. */
class VectorFilesTest {
@Test
fun everyVectorFileIsPresentAndTested() {
@@ -12,6 +12,7 @@ import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.analytics.AnalyticsExpense
import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity
@@ -48,6 +49,7 @@ class VectorTest(private val case: VectorCase, private val label: String) {
"over_settled_accounts" -> ok(strings(overSettledAccounts(allocations(input), positions(input))))
"spend_series" -> series(input)
"resolve_range" -> range(input)
"suggest_category" -> suggest(input)
else -> error("no runner for $function")
}
@@ -149,6 +151,19 @@ class VectorTest(private val case: VectorCase, private val label: String) {
)
}
private fun suggest(input: JsonObject): JsonElement {
val history = input.objects("history").map {
SuggestionHistoryEntry(it.str("description"), CategoryId(it.str("category_id")), LocalDate.parse(it.str("expense_date")), it.str("created_at"))
}
val keywords = input.objects("keywords").map {
CategoryKeyword(it.str("keyword"), KeywordMatch.valueOf(it.str("match").uppercase()), it.str("default_key"))
}
val active = input.objects("active_categories").map { ActiveCategory(CategoryId(it.str("id")), it.strOrNull("default_key")) }
val fallback = input.strOrNull("fallback")?.let { CategoryId(it) }
val r = suggestCategory(input.str("description"), history, keywords, active, fallback, JvmUnicodeText)
return ok(r?.let { JsonPrimitive(it.value) } ?: JsonNull)
}
private fun range(input: JsonObject): JsonElement {
val preset = RangePreset.valueOf(input.str("preset").uppercase())
fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) }
@@ -13,10 +13,10 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
/** The seven vector files of SPEC §14.5.5, keyed by function name. */
/** The eight vector files of SPEC §14.5.5 (with suggest_category, UX-3), keyed by function name. */
val VECTOR_FUNCTIONS: List<String> = listOf(
"compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts",
"spend_series", "resolve_range",
"spend_series", "resolve_range", "suggest_category",
)
data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement)