Skip to content

feat(search): AND semantics, field boosting, recency + graph distance ranking - #22

Merged
tstapler merged 1 commit into
mainfrom
stelekit-better-search
Apr 23, 2026
Merged

tstapler merged 1 commit into
mainfrom
stelekit-better-search

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Summary

  • AND semantics: Multi-term queries now require all terms (FTS5 AND), fixing the root bug where FtsQueryBuilder was 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.
  • Field boosting: Page-title FTS hits receive a 5× score multiplier (PAGE_BOOST) over body-text hits, implemented in Kotlin to avoid a schema migration. bm25_score is now surfaced on SearchedPage/SearchedBlock; RankedSearchHit sealed class provides a unified ranked list in SearchResult.ranked.
  • Recency signal: Exponential decay (RECENCY_HALFLIFE_DAYS = 14) gives results edited today up to 2× score boost, tapering to 1× for content older than ~6 weeks.
  • Graph distance signal: Results on pages 1 hop away from the current page (via block_references) receive a 3× multiplier (GRAPH_BOOST). A single selectNeighbourPageUuids UNION query pre-fetches the full neighbour set — no N+1.

Test plan

  • FtsQueryBuilderTest — 27 cases covering AND/OR semantics, phrase segments, injection stripping, buildOr() fallback
  • SearchRepositoryIntegrationTests — 10 integration cases including:
    • AND semantics (only blocks with all terms match)
    • OR fallback fires when AND returns empty
    • Field boosting (page title ranks above block body)
    • Graph distance boost (linked-page block ranks above unlinked block)
    • Recency boost (recently-edited block ranks above 90-day-old block)
  • Pre-existing BlockStateManagerTest.unobservePage_clears_state failure confirmed on main — not introduced by this PR

🤖 Generated with Claude Code

… 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>
Copilot AI review requested due to automatic review settings April 23, 2026 17:04
@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results

Comparing 5c8d254 (this PR) vs 105928d (baseline)
Graph config: small — ? pages

Metric This PR Baseline Delta
Phase 1 TTI 9ms 11ms (-2ms)
Phase 2 11ms 3ms (+8ms)
Phase 3 index 10ms 15ms (-5ms)
Total 29ms 28ms (+1ms)
Write p95 (baseline) 28ms 33ms -
Write p95 (under load) -1ms -1ms -
Jank factor -0.04x -0.03x -

Flamegraph not available

Top allocation hotspots (this PR)

62.6% byte[][k]
9.6% java.lang.Object[]
[k]
4.5% java.lang.StringBuilder_[k]
2.5% java.lang.String_[k]
2.5% jdk.internal.org.objectweb.asm.SymbolTable$Entry_[k]

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 FtsQueryBuilder multi-term joining to AND and add buildOr() for fallback queries.
  • Surface bm25_score from FTS queries and add a unified RankedSearchHit list on SearchResult.
  • 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 as bm25_score and then recomputed again in ORDER 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 as bm25_score and recomputed in ORDER 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'")

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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'")

Copilot uses AI. Check for mistakes.
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlin.time.Instant
import kotlin.time.Duration.Companion.milliseconds

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duration.Companion.milliseconds is imported but never used in this file. Please remove it to avoid unused-import warnings and keep imports tidy.

Suggested change
import kotlin.time.Duration.Companion.milliseconds

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +295
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)

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 223 to +227
).executeAsList().map { row ->
SearchedBlock(
block = row.toBlockModel(),
snippet = row.highlight?.takeIf { it.isNotBlank() }
snippet = row.highlight?.takeIf { it.isNotBlank() },
bm25Score = row.bm25_score

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +298 to +302
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)

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +307 to +311
/** 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)

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
@@ -10,11 +10,14 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runBlocking is imported but not used anywhere in this test file. Please remove the unused import.

Suggested change
import kotlinx.coroutines.runBlocking

Copilot uses AI. Check for mistakes.
Comment on lines +358 to +364
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

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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

Copilot uses AI. Check for mistakes.
@tstapler
tstapler merged commit 8d23437 into main Apr 23, 2026
12 checks passed
tstapler added a commit that referenced this pull request Apr 26, 2026
… 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>
tstapler added a commit that referenced this pull request Apr 26, 2026
…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>
tstapler added a commit that referenced this pull request Apr 27, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants