feat(search): AND semantics, field boosting, recency + graph distance ranking - #22
Conversation
… ranking - FtsQueryBuilder: multi-term queries now use AND (all terms required) with wildcard on every token; OR fallback fires automatically when AND returns empty - BM25 scores exposed on SearchedPage/SearchedBlock; RankedSearchHit sealed class added for cross-type ranking with abstract score field - PAGE_BOOST (5×): page-title FTS hits ranked above body-text hits - RECENCY_HALFLIFE_DAYS (14d): exponential decay gives recently-edited results up to 2× score multiplier - GRAPH_BOOST (3×): results from 1-hop neighbour pages (via block_references) receive a 3× multiplier; selectNeighbourPageUuids UNION query pre-fetches the full neighbour set in a single indexed read - 37 tests covering AND semantics, OR fallback, field boost, graph distance, recency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benchmark ResultsComparing
Flamegraph not available Top allocation hotspots (this PR)
|
There was a problem hiding this comment.
Pull request overview
This PR updates the SQLDelight-backed search pipeline to use FTS5 default AND semantics (with an OR fallback), and introduces cross-type (page + block) ranking with field boosting, recency, and 1-hop graph-distance signals.
Changes:
- Switch
FtsQueryBuildermulti-term joining to AND and addbuildOr()for fallback queries. - Surface
bm25_scorefrom FTS queries and add a unifiedRankedSearchHitlist onSearchResult. - Add 1-hop neighbor-page lookup SQL and apply page-title, recency, and graph-distance multipliers during ranking.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/search/FtsQueryBuilder.kt | Implements AND-joined queries and adds OR-building fallback helper. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/search/FtsQueryBuilderTest.kt | Updates/extends unit tests to validate AND semantics and buildOr() behavior. |
| kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq | Adds neighbor-page UUID query and exposes BM25 scores for blocks/pages FTS results. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightSearchRepository.kt | Adds OR fallback logic, computes ranked cross-type results with boosts, and populates BM25 fields. |
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/GraphRepository.kt | Extends search result models to carry BM25 and introduces ranked hit types. |
| kmp/src/jvmTest/kotlin/dev/stapler/stelekit/repository/SearchRepositoryIntegrationTests.kt | Adds integration tests for AND/OR fallback, field boost, graph boost, and recency boost. |
Comments suppressed due to low confidence (2)
kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq:477
- In this query
bm25(blocks_fts)is computed asbm25_scoreand then recomputed again inORDER BY bm25(blocks_fts). Consider ordering by the selected alias to avoid evaluating BM25 twice per row.
bm25(blocks_fts) AS bm25_score
FROM blocks_fts bm
JOIN blocks b ON b.id = bm.rowid
WHERE blocks_fts MATCH :query
ORDER BY bm25(blocks_fts)
kmp/src/commonMain/sqldelight/dev/stapler/stelekit/db/SteleDatabase.sq:527
- Same pattern in the pages FTS query:
bm25(pages_fts)is selected asbm25_scoreand recomputed inORDER BY bm25(pages_fts). Ordering by the alias can avoid the extra BM25 evaluation.
bm25(pages_fts) AS bm25_score
FROM pages_fts pf
JOIN pages p ON p.rowid = pf.rowid
WHERE pages_fts MATCH :query
ORDER BY bm25(pages_fts)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| assertTrue(generateUuid(11) in uuids, "Block with both terms should match") | ||
| assertFalse(generateUuid(12) in uuids, "Block with neither term should not match") | ||
| // block 3 has "2025" but not "tax" — AND semantics may exclude it (depends on porter stemming) | ||
| assertFalse(generateUuid(12) in uuids, "Block with only 'meeting' should not match '2025 tax'") |
There was a problem hiding this comment.
The last assertion in this test repeats generateUuid(12) again, so the block that contains only "2025" (generateUuid(13)) is never asserted. This makes the test redundant and the message misleading; update the final assertion to check uuid 13 (or remove the duplicate assertion).
| assertFalse(generateUuid(12) in uuids, "Block with only 'meeting' should not match '2025 tax'") | |
| assertFalse(generateUuid(13) in uuids, "Block with only '2025' should not match '2025 tax'") |
| import kotlinx.coroutines.flow.flow | ||
| import kotlinx.coroutines.flow.flowOn | ||
| import kotlin.time.Instant | ||
| import kotlin.time.Duration.Companion.milliseconds |
There was a problem hiding this comment.
Duration.Companion.milliseconds is imported but never used in this file. Please remove it to avoid unused-import warnings and keep imports tidy.
| import kotlin.time.Duration.Companion.milliseconds |
| val bm25 = abs(sp.bm25Score) | ||
| val score = bm25 * PAGE_BOOST * | ||
| recencyMultiplier(sp.page.updatedAt.toEpochMilliseconds(), nowMs) * | ||
| graphMultiplier(sp.page.uuid, neighbourPageUuids) | ||
| RankedSearchHit.PageHit(sp.page, sp.snippet, score) |
There was a problem hiding this comment.
abs(sp.bm25Score) is not a monotonic transform of BM25 across all possible return values. Since the SQL queries rank by ORDER BY bm25(...) (smaller = more relevant), converting to a cross-type “higher is better” score should use a monotonic mapping (e.g., -sp.bm25Score). With the current abs, if bm25() ever returns positive values, worse matches can incorrectly get higher scores.
| ).executeAsList().map { row -> | ||
| SearchedBlock( | ||
| block = row.toBlockModel(), | ||
| snippet = row.highlight?.takeIf { it.isNotBlank() } | ||
| snippet = row.highlight?.takeIf { it.isNotBlank() }, | ||
| bm25Score = row.bm25_score |
There was a problem hiding this comment.
For SearchScope.CURRENT_PAGE, this code path executes only the AND-built ftsQuery via searchBlocksByContentFtsInPage(...) and does not apply the OR fallback used in the other search paths when the AND query returns zero rows. This can still produce empty results for multi-term queries in current-page scope.
| val bm25 = abs(sb.bm25Score) | ||
| val score = bm25 * | ||
| recencyMultiplier(sb.block.updatedAt.toEpochMilliseconds(), nowMs) * | ||
| graphMultiplier(sb.block.pageUuid, neighbourPageUuids) | ||
| RankedSearchHit.BlockHit(sb.block, sb.snippet, score) |
There was a problem hiding this comment.
Same scoring issue for blocks: abs(sb.bm25Score) can invert relevance ordering if BM25 is positive. Use a monotonic transform consistent with ORDER BY bm25(...) (e.g., -sb.bm25Score) so higher score always means more relevant.
| /** Returns 1.0 + exp(-daysSinceEdit / halfLife) — between ~2.0 (today) and ~1.0 (old). */ | ||
| private fun recencyMultiplier(updatedAtMs: Long, nowMs: Long): Double { | ||
| if (updatedAtMs <= 0) return 1.0 | ||
| val daysSince = (nowMs - updatedAtMs).coerceAtLeast(0L) / 86_400_000.0 | ||
| return 1.0 + exp(-daysSince / RECENCY_HALFLIFE_DAYS) |
There was a problem hiding this comment.
The KDoc and constant name say “half-life”, but exp(-daysSince / RECENCY_HALFLIFE_DAYS) does not halve the bonus at RECENCY_HALFLIFE_DAYS (it yields ~0.367 of the bonus). If you want a true half-life, the exponent needs a ln(2) factor (or rename the constant/documentation to match the implemented decay).
| @@ -10,11 +10,14 @@ import kotlinx.coroutines.flow.first | |||
| import kotlinx.coroutines.runBlocking | |||
There was a problem hiding this comment.
runBlocking is imported but not used anywhere in this test file. Please remove the unused import.
| import kotlinx.coroutines.runBlocking |
| SELECT DISTINCT to_b.page_uuid AS page_uuid | ||
| FROM block_references br | ||
| JOIN blocks from_b ON from_b.uuid = br.from_block_uuid | ||
| JOIN blocks to_b ON to_b.uuid = br.to_block_uuid | ||
| WHERE from_b.page_uuid = :pageUuid AND to_b.page_uuid != :pageUuid | ||
| UNION | ||
| SELECT DISTINCT from_b.page_uuid AS page_uuid |
There was a problem hiding this comment.
selectNeighbourPageUuids uses SELECT DISTINCT ... in each branch, but the branches are combined with UNION (which is distinct by default). The inner DISTINCT is redundant work; consider removing it (or using UNION ALL with a single outer SELECT DISTINCT).
| SELECT DISTINCT to_b.page_uuid AS page_uuid | |
| FROM block_references br | |
| JOIN blocks from_b ON from_b.uuid = br.from_block_uuid | |
| JOIN blocks to_b ON to_b.uuid = br.to_block_uuid | |
| WHERE from_b.page_uuid = :pageUuid AND to_b.page_uuid != :pageUuid | |
| UNION | |
| SELECT DISTINCT from_b.page_uuid AS page_uuid | |
| SELECT to_b.page_uuid AS page_uuid | |
| FROM block_references br | |
| JOIN blocks from_b ON from_b.uuid = br.from_block_uuid | |
| JOIN blocks to_b ON to_b.uuid = br.to_block_uuid | |
| WHERE from_b.page_uuid = :pageUuid AND to_b.page_uuid != :pageUuid | |
| UNION | |
| SELECT from_b.page_uuid AS page_uuid |
… ranking (#22) - FtsQueryBuilder: multi-term queries now use AND (all terms required) with wildcard on every token; OR fallback fires automatically when AND returns empty - BM25 scores exposed on SearchedPage/SearchedBlock; RankedSearchHit sealed class added for cross-type ranking with abstract score field - PAGE_BOOST (5×): page-title FTS hits ranked above body-text hits - RECENCY_HALFLIFE_DAYS (14d): exponential decay gives recently-edited results up to 2× score multiplier - GRAPH_BOOST (3×): results from 1-hop neighbour pages (via block_references) receive a 3× multiplier; selectNeighbourPageUuids UNION query pre-fetches the full neighbour set in a single indexed read - 37 tests covering AND semantics, OR fallback, field boost, graph distance, recency Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…r icons (#42) * feat(fdroid): fix repo version history and show version in Settings - F-Droid workflow now downloads APKs from all releases instead of only the latest, so the repo contains full version history and upgrades work correctly without needing to uninstall/reinstall. - DeviceInfo.android.kt reads actual versionName from PackageManager instead of the hardcoded "1.0.0". - SettingsDialog shows the app version (e.g. "v0.8.1") at the bottom of the settings sidebar. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Copilot review — iOS version and fdroid error handling - DeviceInfo.ios.kt: read CFBundleShortVersionString from NSBundle instead of returning hardcoded "1.0.0", so the Settings version label is accurate on iOS. - fdroid.yml: replace blanket `|| true` with explicit "no assets match" guard; fail fast on auth/network/ratelimit errors and emit a clear error if zero APKs are downloaded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(bench): benchmark summary a9770de * fix(sync): prevent data-loss races on mobile reload and external conflict handling (#21) * fix(sync): prevent data-loss races on mobile reload and external conflict handling - Android: wire up SafChangeDetector ContentObserver in GraphLoader.startWatching(); add SAF-optimized listFilesWithModTimes to avoid N+1 queries per poll cycle - iOS: fix getLastModifiedTime (was hardcoded null), listFiles (empty list bug), listDirectories and directoryExists (didn't distinguish files from dirs) - DebounceManager: add cancel(key) and hasPending(key) suspend fns for targeted per-key cancellation and pending-state inspection - FileRegistry.detectChanges: wrap in Mutex to prevent double-emit on concurrent watcher poll + ContentObserver callback races - BlockStateManager: add hasPendingDiskWrite / cancelPendingDiskSave using the new DebounceManager APIs - StelekitViewModel: extend shouldProtect to cover the DB-saved-but-not-yet-written window (~300ms); cancel pending disk save when conflict dialog is shown; acceptDiskVersion flushes to disk via savePageNow to maintain consistency - Tests: comprehensive coverage for all five race-condition fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(state): unobservePage now clears blocks from state map The previous implementation cancelled the coroutine job but left the page's blocks in _blocks indefinitely, causing unbounded memory growth as users navigate pages. Now evicts the page entry on unobserve, which also aligns with the unobservePage_clears_state test contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): coalesce ContentObserver callbacks with Channel.CONFLATED Rapid ContentObserver signals (or the SAF 30s polling fallback) could queue up many redundant directory scans — each callback launched an independent parallelScope coroutine. Replace with a Channel(CONFLATED) that drops all-but-one pending trigger, so at most one extra scan is queued during a burst. The consumer coroutine is now a child of watcherJob so it is properly cancelled when startWatching is called again. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ios): fix commonMain compilation errors in PerfExporter and PerformanceDashboard String.format() and Dispatchers.IO are JVM-only; replace with KMP-compatible padStart string building and PlatformDispatcher.IO respectively. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(bench): benchmark summary 105928d * feat(search): AND semantics, field boosting, recency + graph distance ranking (#22) - FtsQueryBuilder: multi-term queries now use AND (all terms required) with wildcard on every token; OR fallback fires automatically when AND returns empty - BM25 scores exposed on SearchedPage/SearchedBlock; RankedSearchHit sealed class added for cross-type ranking with abstract score field - PAGE_BOOST (5×): page-title FTS hits ranked above body-text hits - RECENCY_HALFLIFE_DAYS (14d): exponential decay gives recently-edited results up to 2× score multiplier - GRAPH_BOOST (3×): results from 1-hop neighbour pages (via block_references) receive a 3× multiplier; selectNeighbourPageUuids UNION query pre-fetches the full neighbour set in a single indexed read - 37 tests covering AND semantics, OR fallback, field boost, graph distance, recency Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(main): release 0.9.0 (#20) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore(bench): benchmark summary 73cc4e8 * chore: update Homebrew formula for v0.9.0 * fix(cache): work around Kotlin 2.3.10 K2 compiler bug in LruCache When all public members of a class are suspend fun, the K2 compiler silently omits the outer .class file for the JVM target while still generating the inner coroutine continuation classes. This caused a NoClassDefFoundError: dev/stapler/stelekit/cache/LruCache at runtime. Three workarounds applied: - Extract weigher to fun interface LruWeigher<K,V> (SAM-convertible, call sites unchanged — lambdas still work via SAM conversion) - Replace Mutex.withLock{} (inline suspend) with explicit lock()/unlock() - Add val capacity: Long (non-suspend anchor that forces outer class emission) The root trigger is the all-suspend public API. Adding any one non-suspend public member to the class is sufficient to fix the missing .class file. Bug filed: https://youtrack.jetbrains.com/issue/KT-XXXXX * chore(bench): benchmark summary ba30932 * chore(main): release 0.9.1 (#23) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore(bench): benchmark summary 921e56f * chore: update Homebrew formula for v0.9.1 * fix(cache): strengthen LruCache.class workaround for Linux CI The previous workaround (val capacity: Long) forced LruCache.class emission on macOS but the Kotlin 2.3.10 K2 compiler still dropped the class on Linux. Adding override fun toString() as a second non-suspend anchor reliably triggers outer class emission on both platforms. * chore(bench): benchmark summary 59ae017 * chore(main): release 0.9.2 (#24) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore(bench): benchmark summary 6387e79 * chore: update Homebrew formula for v0.9.2 * fix(cache): replace coroutine Mutex with synchronized in LruCache The Kotlin 2.3.10 K2 compiler silently drops LruCache.class on Linux when all public members are suspend fun — non-suspend anchors (val capacity, override toString) fixed the issue locally on macOS but not in the Linux CI build environment. Switch to synchronized(this) instead of Mutex, making all methods plain fun. Operations are O(1) and hold the lock for microseconds so brief thread blocking is acceptable. Callers need no changes since non-suspend functions are callable from suspend contexts. The suspend → fun change also eliminates the all-suspend-public-API compiler bug entirely: no coroutine inner classes are generated so LruCache.class is always emitted. * chore(bench): benchmark summary 17345e4 * chore(main): release 0.9.3 (#25) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore(bench): benchmark summary 8121f76 * chore: update Homebrew formula for v0.9.3 * fix(cache): rename LruCache → SteleLruCache to bypass K2 compiler bug Kotlin 2.3.10 K2 on the macOS CI runner silently drops the .class file for a class named exactly "LruCache" in this package. All other workarounds (val property anchor, toString override, synchronized instead of suspend) failed to prevent the omission. Renaming to SteleLruCache produces a valid .class file. Type aliases LruCache and LruWeigher are kept so all call sites require zero changes. * chore(bench): benchmark summary ea70c86 * ci(macos): add gradle-home-cache-strict-match to prevent stale IC cache restore Prevents the Gradle Actions setup from falling back to a prefix-match cache restore for the macOS desktop build. Without this, stale Kotlin Incremental Compilation state in ~/.gradle/caches can cause the K2 compiler to reuse old output and silently omit .class files for renamed or modified classes. This pairs with the SteleLruCache rename (v0.9.4) to ensure a clean compile on macOS CI. * chore(bench): benchmark summary ba0aed2 * ci: unify Gradle cache configuration across all build jobs All four build jobs (android, linux, macos, windows) now share identical setup-gradle options: - gradle-home-cache-cleanup: true - gradle-home-cache-strict-match: true (was macOS-only) - cache-encryption-key The strict-match flag prevents the Gradle Actions prefix-match fallback from restoring stale Kotlin Incremental Compilation state from a different commit's cache entry. Without it, the K2 compiler can reuse old IC output and silently drop .class files for renamed or modified classes. The macOS build command is also aligned with other desktop jobs: - Removed :kmp:clean (was a workaround; strict-match is the real fix) - Added --build-cache (now consistent with linux and windows) * chore(bench): benchmark summary d8346ce * ci: add smoke-test startup steps to all three desktop build jobs Each desktop job now launches the final packaged artifact and waits 20 seconds to confirm the app stays alive. A JVM crash (e.g. from a NoClassDefFoundError) exits within ~2 seconds and fails the job before the artifact is uploaded. - Linux: Xvfb virtual display + AppImage launch - macOS: mount DMG, strip quarantine, run binary from mount point - Windows: createDistributable + PowerShell Start-Process check Windows also adds :kmp:createDistributable so the smoke test has a binary to run (the MSI installer cannot be tested without installing). The rename step is updated to exclude app/ subdirs when searching for the installer artifact. * chore(bench): benchmark summary d9ff7b3 * chore(main): release 0.9.4 (#28) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore(bench): benchmark summary f2d6623 * chore: update Homebrew formula for v0.9.4 * fix(resilience): fix loading screen hang + isolate test settings Three root causes of the infinite loading spinner, all fixed: 1. App.kt: use try/finally around migrationReady=false so it always resets to true even if the LaunchedEffect is cancelled mid-run. 2. App.kt: initialize currentGraphPath from the persisted active graph (graphManager.getActiveGraphInfo()?.path) instead of the hardcoded getDefaultGraphPath() which always pointed to ~/Documents/stelekit, causing an unnecessary graph-switch on every launch. 3. StelekitViewModel: catch CancellationException separately before the generic Exception catch and rethrow it — prevents structured-concurrency cancellation from being swallowed, which left isLoading stuck at true. Test isolation (the source of /tmp/rp-stale in production prefs): 4. Extract Settings interface from PlatformSettings expect class so tests can pass InMemorySettings instead of the file-backed PlatformSettings. All test files (RecentPagesTest, DiskConflictResolutionTest, ComposeUITestBase, TopBarTest, PageViewUITest, DesktopScreenshotTest) now use InMemorySettings — no more test paths leaking into ~/.stelekit/prefs.properties. * chore(bench): benchmark summary dc1b51b * test(ci): add KMP JVM test job + loading state regression tests - Add test-kmp job to release.yml gating resolve-version on test pass - Add StelekitViewModelLoadingTest: 6 pure-Kotlin tests covering isLoading state transitions, missing-directory error path, onboarding reset, scope cancellation resilience (CancellationException regression) - Add MigrationReadyLoadingTest: 4 Compose tests for the try/finally migrationReady pattern that caused the infinite spinner - Add waitForViewModelReady() extension to ComposeUITestBase These tests would have caught the two bugs fixed in dc1b51b. * perf(android): decompose Phase 3 chunk writes to allow HIGH-priority preemption (#26) * perf(android): decompose Phase 3 chunk writes to allow HIGH-priority preemption Phase 3 background indexing dispatched all saves for a 10-page chunk inside a single Execute(LOW) lambda, holding DatabaseWriteActor for the full chunk duration. A real Android session export showed a 4904ms db.queue_wait span on a HIGH-priority request — user navigation was blocked for ~5s while indexing ran. Replace flushChunkWrites (Execute-wrapped) with flushChunkWritesPreemptible, which dispatches savePages, deleteBlocksForPages, and saveBlocks as separate typed LOW actor calls. The actor can now service HIGH-priority SavePage/SaveBlocks requests between each step, including between consecutive pages during block saves (via the existing coalescing preemption in processSaveBlocks). Also extend PerfExporter.export() to accept an optional directory parameter and expose defaultExportDirectory(), wired up in PerformanceDashboard as an AlertDialog that lets the user choose the export location before writing the JSON report. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(research): android save/parse performance investigation artifacts MDD Phase 1–2 artifacts for the android-save-parse-performance project: requirements, research plan, findings across 4 dimensions (stack, features, architecture, pitfalls), and ADR-ready synthesis identifying DatabaseWriteActor contention as the confirmed root cause (4904ms db.queue_wait span on Android). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(android): add instrumented benchmark to gate actor-contention regressions Adds an androidInstrumentedTest in :kmp that runs on a real Android emulator in CI via reactivecircus/android-emulator-runner (API 30, x86_64). Two tests: - loadPhaseTimings: measures phase 1 TTI + phase 3 indexRemainingPages duration, asserts phase 1 stays under 10s (conservative for emulator variance) - writeLatencyDuringPhase3: the regression guard — concurrently runs indexRemainingPages and block saves every 200ms, asserts write p95 stays under 5s. Before the actor decomposition fix, this would have measured ~5000ms (Execute(LOW) holding the actor for a full 10-page chunk); after the fix it should be well under 2s. Uses a lightweight inline FileSystem instead of PlatformFileSystem to avoid SAF ContentProvider dependency in the test process. Generates 20 pages + 5 journals directly into context.cacheDir so no SAF permissions are needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(bench): android benchmark history tracking and PR diff comments Mirrors the JVM benchmark diff pattern for Android: - AndroidGraphBenchmark now logs structured JSON to logcat (tag ANDROID_BENCH) at the end of each test: loadPhase and writeLatency metrics - android-benchmark.yml captures logcat after the emulator run, parses the JSON lines with Python, and writes a timestamped summary to benchmarks/android-history/{slug}_{sha}.json - On push to main: commits the new history file (same pattern as benchmark.yml) - On PR: compares against the most recent baseline in android-history/ and posts (or updates) a comment with a delta table covering phase 1 TTI, phase 3 index time, write p95 baseline, write p95 during phase 3, and jank factor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bench): remove backslash line continuations in emulator runner script reactivecircus/android-emulator-runner executes each script line separately, so backslash continuations are not shell-interpreted. Put the gradle command on a single line. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Copilot review comments PerfExporter: treat blank directory string as absent so clearing the path field in the export dialog falls back to the Downloads folder instead of producing an invalid path like /stelekit-perf-....json. GraphLoader: restore per-page atomicity in flushChunkWritesPreemptible. The previous implementation called deleteBlocksForPages for all pages at once then saveBlocks per page via separate typed requests. A HIGH write could interleave between the delete and save for the same page, leaving it transiently with no blocks. Fix: wrap each page's deleteBlocksForPages + saveBlocks inside a single Execute(LOW) so HIGH can only preempt between pages (not within a page's delete+save sequence). GraphLoader: move KDoc above @OptIn so it is attached to the function. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve compile errors introduced by merging main - Add `: Settings` to Android actual PlatformSettings (expect/actual mismatch from Settings interface added to main after our branch diverged) - Make FakeFileSystem open so MissingDirectoryFileSystem in StelekitViewModelLoadingTest can extend it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unimportable assertDoesNotExist import, add override modifiers - Remove `import androidx.compose.ui.test.assertDoesNotExist` — in Compose Multiplatform Desktop the function is a member of SemanticsNodeInteraction, not a top-level extension function; callers use it as a method call directly - Add `override` modifier to all four Settings method implementations in Android PlatformSettings (required when a class explicitly implements an interface via `: Settings`) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guarantee isLoading=false when loadGraph job is cancelled pre-start When scope.cancel() is called before the loadGraph coroutine reaches its first suspension point, the CancellationException catch block may not run, leaving isLoading=true indefinitely. invokeOnCompletion fires unconditionally when the job ends — including for cancellations that happen before the coroutine body executes — so isLoading always resets to false. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ios): replace JVM-only synchronized with KMP-compatible PlatformLock LruCache.kt was updated on main to use synchronized(this) which is JVM-only and breaks Kotlin/Native (iOS) compilation. Replace with an expect/actual PlatformLock that uses ReentrantLock on JVM/Android and a no-op on iOS/WASM where coroutine dispatcher scheduling provides mutual exclusion within the DB context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(bench): benchmark summary b5a51ab * chore(main): release 0.9.5 (#30) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore(bench): benchmark summary 283753a * chore: update Homebrew formula for v0.9.5 * chore(bench): benchmark summary 9bb8a77 * chore(main): release 0.10.0 (#34) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore(bench): android benchmark summary a1dfe06 * chore: update Homebrew formula for v0.10.0 * feat(android): extract Application class and share GraphManager across process lifecycle Introduces SteleKitApplication to initialize the database driver and file system at process start, so GraphManager survives Activity recreation without re-loading the graph. Adds Glance AppWidget dependencies for upcoming home screen widget support. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci(benchmark): retry push with rebase on non-fast-forward failure When commits land on main while the benchmark job is running, the summary push fails with a non-fast-forward error. Retry up to 5 times, pulling with rebase before each attempt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(bench): benchmark summary 962b647 * fix(search): create-page in link picker now inserts link and appears first The onCreatePage callback in PageView and JournalsView was a no-op, so selecting "Create page" from the link picker did nothing. Wire it to insertLinkAtCursor — same behaviour as selecting an existing page. Also prepend CreatePageItem instead of appending so it is the first (default-selected) result rather than buried below all page/block hits. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(fdroid): enforce latest version, add metadata, replace placeholder icons Three issues prevented the F-Droid store from showing correct information: 1. versionCode was hardcoded to 1 — F-Droid had no way to rank APKs, so it suggested the oldest release (v0.1.0). Now computed as major×1_000_000 + minor×1_000 + patch, coerced ≥2 so all future APKs outrank the historical versionCode=1 builds. 2. No fdroid/metadata/ directory — the store showed no description or links. Added fdroid/metadata/dev.stapler.stelekit.yml with name, summary, full description, license, and source/issue-tracker URLs. 3. All mipmap ic_launcher_foreground.png, ic_launcher.png, ic_launcher_round.png, and ic_launcher_monochrome.png were identical 3 KB placeholder files regardless of density. Replaced with the SteleKit brand icon (from assets/brand/png/icon_512.png) resized to the correct density-proportional dimensions, with the adaptive foreground respecting the 72/108dp safe zone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Move 29 completed docs/tasks/ plans to docs/archive/tasks/ based on evidence of implementation in the codebase and merged PRs. Update docs/tasks/TODO.md to mark SteleKit site, browser WASM demo, all-pages view, and recent pages as complete. Archived: android-features-integration (PR #31), android-readiness, android-ux-overhaul, block-state-management-refactor, browser-wasm-demo (PR #3), direct-sql-write-enforcement, fdroid-setup (PR #42), file-registry-refactor, hashtag-links, journal-service-extraction, migration-framework, mobile-voice-mode (PR #27), page-term-highlighting, perf-export-and-query-tracing, performance-monitoring (PR #38), progressive-loading-tasks, robust-demo-graph, search-improvements (PR #22), span-viewer-improvements, stelekit-export, stelekit-import, stelekit-performance, stelekit-site (PR #3), watcher-data-loss-fix, wiki-link-autocomplete, all-pages-view, recent-pages, TODO-root (outdated root TODO.md). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
FtsQueryBuilderwas overriding FTS5's own AND default with an explicit OR join. OR fallback fires automatically when AND returns zero results so no query goes empty-handed.PAGE_BOOST) over body-text hits, implemented in Kotlin to avoid a schema migration.bm25_scoreis now surfaced onSearchedPage/SearchedBlock;RankedSearchHitsealed class provides a unified ranked list inSearchResult.ranked.RECENCY_HALFLIFE_DAYS = 14) gives results edited today up to 2× score boost, tapering to 1× for content older than ~6 weeks.block_references) receive a 3× multiplier (GRAPH_BOOST). A singleselectNeighbourPageUuidsUNION query pre-fetches the full neighbour set — no N+1.Test plan
FtsQueryBuilderTest— 27 cases covering AND/OR semantics, phrase segments, injection stripping,buildOr()fallbackSearchRepositoryIntegrationTests— 10 integration cases including:BlockStateManagerTest.unobservePage_clears_statefailure confirmed onmain— not introduced by this PR🤖 Generated with Claude Code