Commit graph

1,853 commits

Author SHA1 Message Date
Trevon
b5ca9eee7c
Merge pull request #380 from trvon/release-please--branches--main--components--yams
chore(main): release 0.21.1
2026-10-27 09:13:13 -06:00
github-actions[bot]
43c9478991
chore(main): release 0.21.1 2026-10-27 15:10:30 +00:00
Trevon
0a7364ecb9
Merge pull request #379 from trvon/experimental
chore: promote experimental to main
2026-10-27 09:10:00 -06:00
Trevon
2ca79639ea
Merge pull request #377 from trvon/chore/backmerge-main-v0.21.0
chore: back-merge main into experimental after v0.21.0
2026-10-27 09:08:18 -06:00
Trevon
65c1f9bbda
Merge branch 'experimental' into chore/backmerge-main-v0.21.0 2026-10-27 09:08:07 -06:00
Trevon
6d26203140
fix(packaging): compare upgrade bodies without cmp (#378)
The v0.21.0 release failed on the Arch aarch64 lane: the upgrade
seed check ran `cmp -s` in the minimal Arch base image, which has
coreutils but not diffutils ("cmp: command not found"), so
upgrade-seed-body failed although the package was fine.

Compare the two files by SHA-256 in a same_bytes helper. The unit
oracle now stubs cmp to fail, so a check that depends on it fails
in test_package_upgrade_validation.py instead of in the release.

Signed-off-by: trvon <me@trevon.dev>
2026-10-27 09:07:47 -06:00
trvon
36fdd15e17
chore: back-merge main into experimental after v0.21.0
Signed-off-by: trvon <me@trevon.dev>
2026-10-27 07:18:33 -06:00
github-actions[bot]
0c5932a779
chore(main): release 0.21.0 (#376)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-27 07:18:17 -06:00
Trevon
0c9f1af6c8
fix(grep): bound the regex fallback instead of backtracking without limit (#373)
RE2 rejects backreferences and lookaround, and grep fell back to
std::regex for those patterns. std::regex backtracks with no limit, so
a pattern like (x+x+)+y\1 runs in exponential time (2 s at 24 chars on
grim, 4x per 2 more) and libstdc++ recursion can overflow the stack on
long lines. A daemon grep request could pin a worker indefinitely.

Use Boost.Regex (header-only, already a dependency) for the fallback.
It is non-recursive and abandons a search after
max(N*S^2, min(N^2, cap)) + 1e5 states; the cap is 1e7 here. An
abandoned line counts as no match, and grep reports the count as
searchStats.regex_budget_exceeded. The fallback also gains lookbehind.

findNext now passes the preceding bytes as context to both engines, so
^ and \b no longer match at a resumed offset ("^a" on "aa" counted 2).

Signed-off-by: trvon <me@trevon.dev>
2026-10-26 22:48:54 -06:00
github-actions[bot]
8715482ac1
Merge pull request #372 from trvon/experimental
chore: promote experimental to main
2026-10-26 22:48:40 -06:00
trvon
ed2bde4f7c
fix(grep): bound the regex fallback instead of backtracking without limit
RE2 rejects backreferences and lookaround, and grep fell back to
std::regex for those patterns. std::regex backtracks with no limit, so
a pattern like (x+x+)+y\1 runs in exponential time (2 s at 24 chars on
grim, 4x per 2 more) and libstdc++ recursion can overflow the stack on
long lines. A daemon grep request could pin a worker indefinitely.

Use Boost.Regex (header-only, already a dependency) for the fallback.
It is non-recursive and abandons a search after
max(N*S^2, min(N^2, cap)) + 1e5 states; the cap is 1e7 here. An
abandoned line counts as no match, and grep reports the count as
searchStats.regex_budget_exceeded. The fallback also gains lookbehind.

findNext now passes the preceding bytes as context to both engines, so
^ and \b no longer match at a resumed offset ("^a" on "aa" counted 2).

Signed-off-by: trvon <me@trevon.dev>
2026-10-26 22:32:47 -06:00
Trevon
356dc865df
test(memory-sync): keep background cycles out of the backfill budget test (#371)
The test drives backfill by hand and expects one published document per
cycle. A background apply cycle runs its own backfill the first time it
fires; on a slow runner that landed between the test's cycles and
published an extra document (macOS CI: 3 == 2 at cycle 2). Defer the
background backfill schedule for this test.

Signed-off-by: trvon <me@trevon.dev>
2026-10-26 05:53:27 -06:00
Trevon
5b0985f0a7
fix: Windows and macOS CI failures on experimental (#370)
* test(memory-sync): build the abandoned workspace instead of copying a pinned one

The exclusive-winner scavenge test copied the live workspace to fake an
abandoned one. The live generation is still pinned and holds a
byte-range lock on its lease. On Windows that lock is mandatory, so
std::filesystem::copy failed: "The process cannot access the file
because another process has locked a portion of the file" (CI artifact
for Tests (windows-x86_64), memory_sync). POSIX locks are advisory,
which is why Linux and macOS passed.

The test now creates the abandoned directory with the scratch marker and
a placeholder winners.sqlite, written in binary mode so the marker keeps
its LF. The scavenger checks only names and that they are regular files.

Signed-off-by: trvon <me@trevon.dev>

* fix(graph): keep native Windows paths as one query term

GraphContextService split queries on characters outside [A-Za-z0-9_:./-].
A native Windows path such as C:\Users\RUNNER~1\...\src\shared.cpp
became fragments like "Users" and "AppData", which skipped the
direct path lookup and matched unrelated files. On Windows CI,
graph_services returned two files where one was expected, and returned
results for a scoped query on an outside path. The tokenizer now treats
'\\' and '~' as part of a term.

The test fixture also wrote node keys in native mixed-separator form,
which ingest never stores. writeSource() now returns the generic
(forward-slash) path, as ingest does.

Signed-off-by: trvon <me@trevon.dev>

* fix(cli): compare graph hints against the canonical working directory

search, grep and graph took the working directory from current_path(),
while stored document paths go through weakly_canonical. On Windows the
working directory can keep an 8.3 short name (C:\Users\RUNNER~1), so
every stored path looked like it was outside the project, and hints
became absolute paths with --global. integration_smoke failed 4
assertions in search_graph_ux_smoke on Windows CI because of this.

invocationCwdForCli() returns the working directory through
weakly_canonical, and the three commands use it. On POSIX getcwd
already returns the resolved path, so nothing changes there.

Signed-off-by: trvon <me@trevon.dev>

* ci(test): print test failure reports as UTF-8

report_meson_failures.py crashed with UnicodeEncodeError on the Windows
runner's cp1252 console as soon as a test's output contained non-ASCII
text. The workflow then fell back to meson's last 100 lines, which did
not include the failing assertions. The script now reconfigures stdout
to UTF-8 (errors=replace) when run as a program.

Signed-off-by: trvon <me@trevon.dev>

* test(memory-sync): let every sync cycle run a backfill pass in the convergence test

"Daemon memory sync periodically converges and stops cleanly" waits for
a document, blob, topology node and topology edge to reach the peer
through periodic backfill. Backfill runs at most one 100 ms pass per
5 s, and on the macos-arm64 runner each publish is slow enough that the
topology edge (published last) missed the window. The CI test log shows
CHECK(topologyEdgePublished) false after three passes ("backfill
attempts before=1 after=4") in both failing runs.

The wait loop now expires the backfill schedule on each poll with the
existing test hook, so the real periodic sync callback runs a pass every
cycle and the wait is bounded by publish cost, not by the cadence.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-26 05:53:12 -06:00
trvon
0ebe9c5c9a fix(daemon): give each ServiceManagerFsm its own state
ServiceManagerFsm wrapped a tinyfsm machine, whose state and mutex are
static, so every ServiceManager in a process shared one lifecycle. One
manager's construction, reset() or shutdown changed what the others saw:
a failed second daemon's destructor drove the running daemon's FSM to
Stopped (#357 made shutdown run anyway; readiness checks such as
canBuildSearchEngine() and isReady() still read the wrong state).

The FSM now keeps a snapshot, mutex and condition variable per instance.
The public interface is unchanged, and the transitions match the former
machine: unhandled events are ignored, InitializationFailedEvent moves a
non-terminal state to Failed, and ShutdownEvent moves any state except
ShuttingDown/Stopped to ShuttingDown. The FSM suite's shared-state
recovery section now checks that one instance's construction, shutdown
and reset leave another's state alone.

Signed-off-by: trvon <me@trevon.dev>
2026-10-26 05:52:52 -06:00
trvon
b248d938d6 test(asan): detect stack-use-after-return and suppress OpenSSL init leak
A default 'asan' test setup for ASan builds keeps meson's ASAN_OPTIONS
(halt_on_error, abort_on_error, print_summary) and adds
detect_stack_use_after_return=1. Without it, ASan never flagged the
`yams daemon status -d` bug fixed in #353: a temporary request passed by
reference into a coroutine that started after the temporary died. TSan
found it by luck.

The setup also points LSAN_OPTIONS at a new lsan.suppressions with one
rule: OpenSSL's one-time config load during OPENSSL_init_crypto (first
libcurl init) leaves a 904-byte per-thread error state that OpenSSL
never frees. That leak failed mcp_submodule under gcc ASan.

Signed-off-by: trvon <me@trevon.dev>
2026-10-26 05:52:52 -06:00
Trevon
316ad040fb
test(build): table-driven Catch2 registration and one suite vocabulary (#349)
Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
* test(build): register standard Catch2 tests from a table

165 of the 224 test executables in tests/meson.build were the same nine-line
executable() block plus a test() call placed far away. Each one is now a row in
catch2_test_table, and one loop at the end of the file builds and registers
them. Rows keep their place, so the branches around them still gate them.

A pair was folded only when the executable used the standard kwargs, exactly one
test() used it, and both calls sat under the same if/else branch. Tests with
extra env, cpp_args or more than one registration stay hand-written.

meson introspect --tests --targets is identical before and after for Debug and
TSan (351 tests, 369 targets). It is identical for ASan (262 tests) except for
the order of meson's generated DYLD_LIBRARY_PATH entries.

Signed-off-by: trvon <me@trevon.dev>

* test(build): one suite vocabulary and a concurrency label

Suite labels were a mix of tiers, subsystems, ticket IDs (yams-3s4, pbi-009)
and spelling variants (connection_pool and connection-pool, plugin and
plugins). Ticket IDs are gone, yams-<x> is now <x>, labels are kebab-case,
and synonyms are merged.

54 tests now carry 'concurrency': their own sources start threads, spawn
coroutines or wait on condition variables. When several tests share one
executable, only those whose names point at concurrent behaviour get the
label. 47 of them run in the TSan lane.

Only labels changed. Tests, commands, env, timeouts and targets are identical
in meson introspect, and every tier suite (unit, integration, slow, bench,
stress, mobile, ...) selects the same tests under TSan and ASan.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-25 20:54:44 -06:00
Trevon
5eb01f4048
test(memory-sync): stop the sync service off the apply lock in stage hooks (#352)
* test(memory-sync): stop the sync service off the apply lock in stage hooks

Two integration tests stop the sync service from a stage observer to
simulate a stop during apply or backfill. The observer runs under
ServiceManager's apply lock, and the blocking stop() then takes the
service's callback lock, the reverse of the worker's order (callback
lock, then apply lock in applyMemorySyncWinners). TSan on a gcc Debug
build reports a lock-order inversion in daemon_memory_sync_apply_stop and
daemon_memory_sync_backfill_stop. stop() also joins the worker, which can
be blocked on that apply lock, so the test can hang.

Production shutdown calls stop() without the apply lock, so this is a
test hazard only. StopFromStage runs stop() on a helper thread, returns
once stopRequested() is visible, and joins after the stage call returns.

Signed-off-by: trvon <me@trevon.dev>

* fix(cli): keep the memory-sync status request alive until the call runs (#353)

fetchMemorySyncStatus passed a temporary MemorySyncRequest to
DaemonClient::call(), which is a coroutine taking the request by
reference, and returned the awaitable from the provider lambda.
runDaemonClient awaits it only after the lambda returns, so the call
read a destroyed request.

Under TSan on a gcc Debug build, `yams daemon status -d` aborted while
copying the request into the Request variant: "requested allocation size
0x7ffed4d77a51" (a stack address read as the key length). That failed
integration_smoke. ASan does not flag it without stack-use-after-return
detection.

The provider is now a coroutine lambda that owns the request. The other
providers that return call() awaitables capture their request by value
and keep the closure alive while awaiting (awaitResultSync), so they are
not affected.

Signed-off-by: trvon <me@trevon.dev>

* fix(extraction): return plugin output as a copy taken under its lock (#354)

PluginProcess::read_stdout() and read_stderr() returned a span into the
output buffer after releasing the buffer mutex. The reader thread keeps
appending to that buffer, and a reallocation frees the storage the span
points to. JsonRpcClient::read_next_response() scans that span for a
newline, so a plugin that writes while a response is being parsed reads
freed memory.

TSan on a gcc Debug build reports the race in plugin_process
(read_stdout_loop's insert against the test's unlocked read). Both
accessors now return std::vector<std::byte>, copied under the lock.
Callers use auto or pass the result as a span, so they are unchanged.

Signed-off-by: trvon <me@trevon.dev>

* fix(client): stop the write loop once close() has reset the socket (#355)

close() resets AsioConnection::socket on the strand. A write loop that
still had frames queued, or a new async_write_frame() after close, went
on to async_write(*socket, ...) through the null unique_ptr. Debug builds
with _GLIBCXX_ASSERTIONS abort there; other builds bind a reference to
null.

Seen on a gcc Debug TSan build: client_timeout_recovery aborted at
asio_connection.cpp:233, and Catch2's fatal-signal handler then
allocated inside the SIGABRT handler (TSan "signal-unsafe call") and
hung until the 60 s test timeout.

The write loop now ends with NetworkError when the socket is gone or
closed, mirroring the read loop, and the timeout path checks the socket
before closing it.

Signed-off-by: trvon <me@trevon.dev>

* test(tsan): apply the suppressions file to every test (#356)

tsan.suppressions reached only the CLI, smoke, search and services
suites, the ones whose test() env sets TSAN_OPTIONS. Every other test
ran unsuppressed. A default 'tsan' test setup now sets TSAN_OPTIONS for
all tests in a TSan build. Tests that already set it use the same value.

Two narrow rules cover false positives that a gcc TSan build reports
and clang does not:
- boost::asio::detail::ref_count_down: fetch_sub(release) then a
  standalone acquire fence, which gcc TSan does not model
  (PostIngestQueue, VectorIndexCoordinator, WorkCoordinator reports).
- walIndexTryHdr / walIndexWriteHdr: SQLite's lock-free WAL-index header
  protocol. SQLITE_NO_TSAN is clang-only (metadata commit, symspell
  commit, KG path node reports).

Signed-off-by: trvon <me@trevon.dev>

* fix(daemon): run ServiceManager teardown even when the shared FSM is stopped (#357)

ServiceManagerFsm keeps its state in tinyfsm's process-wide storage.
ServiceManager::shutdown() returned early when that state was already
ShuttingDown or Stopped, so another manager's shutdown in the same
process cancelled this one's teardown. The common case: a second daemon
that loses the instance lock destroys its never-started ServiceManager,
whose destructor runs shutdown() and drives the shared FSM to Stopped.
The running daemon's stop() then skipped every shutdown phase. Its
WorkCoordinator threads and background coroutines kept running and kept
the ServiceManager alive.

gcc TSan reported the result in daemon_catch2's concurrent-start test: a
worker still inside the FTS5 consumer read TuningSnapshotRegistry after
exit() had destroyed it (heap-use-after-free).

shutdownInvoked_ is the per-instance guard. The shared state now only
decides whether to dispatch ShutdownEvent, and completeShutdown()
already accepts a Stopped state. A new test starts a daemon, loses a
second start against it, stops the first, and checks that its
ServiceManager is released.

Signed-off-by: trvon <me@trevon.dev>

* fix(daemon): leave the repair hysteresis timestamps to the run loop (#358)

stop() cleared repairBusySince_ and repairReadySince_ from the caller's
thread while the runLoop thread updates them in updateRepairHysteresis().
TSan on a gcc Debug build reports the race in
lifecycle_single_instance_release and grep_service_expectations
(daemon.cpp:1141/1142 against runLoop at 914).

Only runLoop uses these fields, and start() resets them before the loop
thread is created, so stop() no longer touches them.

Signed-off-by: trvon <me@trevon.dev>

* test(sync-indexing): scale the indexing waits with the test timeout scale (#359)

sync_indexing_catch2 waits a fixed 3 s for a freshly added document to
show up in grep, with a 1.5 s per-call timeout and a 2 s timing check.
Under gcc TSan everything runs about 10x slower, so "Sync add then
immediate grep" failed REQUIRE(found) at 3 s.

The test now gets _test_timeout_cpp_arg like the smoke tests and
multiplies those three bounds by YAMS_TEST_TIMEOUT_SCALE (1 in normal
builds, 10 under TSan).

Signed-off-by: trvon <me@trevon.dev>

* fix(build): keep the S3 plugin mapped in Linux TSan builds (#360)

storage_submodule passed every test (3167 assertions) under gcc TSan and
then crashed in exit(). The S3 plugin links its own static OpenSSL,
whose init registers atexit(OPENSSL_cleanup) from inside the plugin.
glibc ties that handler to the DSO and runs it at dlclose, but TSan's
atexit interceptor keeps it until process exit and calls into the
unmapped plugin (gdb: at_exit_callback_installed_at -> unmapped pc in
OPENSSL_cleanup of libyams_object_storage_s3.so).

Linux TSan builds now link the plugin with -z nodelete. Other builds are
unchanged.

Signed-off-by: trvon <me@trevon.dev>

* test(cli): scale the serve_delegation per-call timeout with the build (#361)

serve_delegation copies the yams CLI into a temp dir and runs it with a
fixed 10 s timeout. In an ASan build the binary is over 500 MB, and on
macOS the first exec of a freshly copied executable waits for the system
policy scan, so under load a call can exceed 10 s. The pre-push ASan
lane failed this way twice with no product change.

The script now takes --timeout-scale, and meson passes the build's test
timeout scale: 1 by default, 3 in the ASan lane, 10 under TSan. With
scale 3 the suite passes in 50 s at load average 24.

Signed-off-by: trvon <me@trevon.dev>

* test(daemon): scale the socket probe budgets in sanitizer builds (#362)

"unix_socket_io_permitted: real probe completes within budget" requires
the first probe in under 100 ms and the cached call in under 50 us. On a
gcc ASan build running tests in parallel, the first probe missed 100 ms
while the code under test was unchanged.

The budgets are now 10x under ASan or TSan, detected at compile time,
and unchanged in normal builds.

Signed-off-by: trvon <me@trevon.dev>

* build(tsan): use atomic coverage counters in TSan + coverage builds (#363)

gcov counters are plain increments. A gcc Debug build with
-Db_sanitize=thread -Db_coverage=true reported every counter that two
threads touch as a data race (`Location is global '__gcov0...'`), 32
reports in the first 43 tests and none in YAMS code.

When b_coverage is on in a TSan build, the project now adds
-fprofile-update=atomic for C and C++. Other builds are unchanged.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-25 20:54:41 -06:00
Trevon
47f14f5093
fix(vector): prepare backend statements only once per connection (#351)
prepareStatements() prepared all nine statements over whatever handles it
already held. On an existing vectors.db, initialize() prepares them and
createTables() prepares them again, so the first nine leaked. They kept
the connection alive: close() finalized only the current handles, and
sqlite3_close_v2() left a zombie connection with 9 live statements.

LeakSanitizer on a gcc Debug ASan build reported 220 KB in 572
allocations, all indirect, from vector_submodule ("preserves vector
generation tracking while migrating update triggers"). It was found with
YAMS_TRACE_DB_LIFETIME=1, which logged close.sqlite with stmts=9.

prepareStatements() now prepares only the empty slots. A new test checks
that a second createTables() leaves the live statement count unchanged.

Signed-off-by: trvon <me@trevon.dev>
2026-10-25 20:54:40 -06:00
Trevon
774cba8b0d
fix(daemon): take the cancellation slot before moving its signal (#350)
spawnDetached passed signal->slot() and a handler that captures
std::move(signal) as arguments of one call. Argument evaluation order is
unspecified; gcc evaluates right to left, so the move ran first and
slot() was called through an empty shared_ptr. UBSan on a gcc Debug
build reports "member call on null pointer" at WorkCoordinator.h:321 and
aborts 12 tests (work_coordinator, service_manager, daemon, ...). clang
evaluates left to right, so the macOS lanes never hit it.

Present since #53.

Signed-off-by: trvon <me@trevon.dev>
2026-10-25 20:54:40 -06:00
Trevon
4f6534f65a
fix(daemon): mark the vector DB ready once its first vectors commit (#348)
readiness.vectorDbReady was set only at vector DB init, as "rows > 0".
A corpus that starts empty kept it false after every document was
embedded: status and doctor reported the vector DB not ready until a
restart, and SearchComponent already worked around it by probing the
live row count.

Set it when the embedding service reports committed vectors, once the
vector DB has been initialized; ServiceManager owns the state and
already handles that callback.

The retrieval bench waits up to 30 s for vector_db_ready before its
queries; every run hit the full wait. On grim (gcc Profiling,
search_product_clean_baseline, base is #346):

                         SciFact              NFCorpus
  setup total            56.4 -> 27.7 s       411.7 -> 382.1 s
  searchability ready    54.6 -> 25.3 s       409.8 -> 380.2 s
  guard waits            3/3 -> 0/3           3/3 -> 0/3
  MAP / MRR / nDCG       identical            within base run-to-run range
  latency p50 / p95      41.2/54.0 ->         29.8/59.3 ->
                         47.0/66.7 ms         29.3/59.0 ms

The SciFact queries now start right after ingest instead of after the
idle guard. In-window background zones stay under 0.1 s; the query
path is unchanged, and NFCorpus latency is flat.
2026-10-25 14:58:26 -06:00
Trevon
4f5241c085
perf(profiling): name metadata write-pool waits by caller (#347)
A profile showed 15.6 s of MetadataPool waits during a SciFact ingest
but not who waited, and most untagged writes shared one label.

- connection_pool.cpp: in profiling builds, a transient zone names each
  wait "MetadataPool::wait[<pool role>] <caller tag>", so a capture
  totals wait time per caller and pool (YAMS_ZONE_TRANSIENT_N).
- metadata_repository.h: an untagged executeWriteQuery/executeQuery is
  named after its calling method (std::source_location) instead of
  "write_query"; the name also reaches traces and pool-timeout holder
  lists.
- Tag the three KG enrichment acknowledgement paths (WriteCoordinator
  batch completion, GraphComponent, EntityGraphService).

On grim (gcc Profiling, SciFact) every wait is on the single write
connection: GraphComponent's per-document KG acknowledgement 318 x
5.6 s, batchClassifyOrBeginEmbeddingDerivations 37 x 3.0 s,
app_store_document_batch 322 x 2.8 s. Batching the acknowledgements
was tried and left setup time unchanged (54.7 -> 56.4 s SciFact,
410.7 -> 409.9 s NFCorpus): the waits track a busy writer, not the
ingest critical path, so this keeps only the attribution.
2026-10-25 14:58:26 -06:00
Trevon
6ea11353ec
perf(search): build the topology route index off the query path (#346)
TopologyRoutingSnapshotCache built its route index lazily, under its
lock, on the first routed lookup. The first routed query after daemon
start, and after every topology rebuild (a new epoch misses the cache),
built it inline: 2.9-3.0 s on SciFact, 7.0 s on NFCorpus, while
concurrent queries waited on the lock.

- getRoutingSnapshot(cache, epoch, options): the lookup a routed query
  makes, shared by queries and warm-ups so a warmed entry is the one
  the next query reads.
- SearchEngine::prewarmTopologyRouting(): load the latest snapshot
  with the options a routed query uses. False when routing is
  disabled or nothing is published.
- SearchEngineManager posts a warm-up after an engine build, as a
  separate task so readiness does not wait for it.
- ServiceManager warms after an async topology rebuild stores a batch,
  on the rebuild task.

Retrieval A/B on grim (gcc Profiling, search_product_clean_baseline,
3 timed reps + 1 profiled pass per arm; base is #344):

                         SciFact            NFCorpus
  first query, profiled  3251 -> 28.9 ms    7481 -> 33.6 ms
  max query, profiled    3251 -> 91.2 ms    7481 -> 90.0 ms
  buildIndex             query thread ->    query thread ->
                         background         background
  cold first, timed      84.3 -> 69.3 ms    51.0 -> 36.7 ms
  p95, timed             55.3 -> 53.3 ms    56.7 -> 52.3 ms
  MAP / MRR / nDCG       identical          within base run-to-run
                                            range (base MAP stdev
                                            0.0020, change 0.0004)
2026-10-25 14:58:26 -06:00
Trevon
d59db71d86
perf(memory-sync): skip inbound apply when replicated inputs are unchanged (#344)
* perf(profiling): attribute metadata pool waits and memory-sync stages

MetadataPool::acquire and memory_sync.cycle were the largest self-time
zones in release-prep profiles, but neither said where the time went.

- connection_pool.cpp: zone the caller tag, the pool mutex, the wait
  for a free connection and the SELECT 1 validation probe.
- memory_sync.cpp: zone the metadata, vector and topology apply stages
  (content blobs and delete drain already had zones).

On grim (gcc Profiling) the split shows acquire itself at 0.4 us and
validation at 0.6 us; SciFact's 15.2 s in acquire is 962 waits for the
single-connection write pool during ingest, none in the query window.
An idle 3-node mesh spends ~800 ms per ~6 s cycle in apply_topology,
~370 ms in apply_vectors and ~104 ms in apply_metadata.

* perf(memory-sync): skip inbound apply when replicated inputs are unchanged

An idle mesh node spent about a quarter of a core re-applying the same
winners every ~6 s cycle: each pass re-read and re-parsed every record
and re-checked the local stores (topology ~1 s, vectors ~360 ms,
metadata ~100 ms per cycle on a 1.5k-record corpus).

Skip the inbound stages when an adapter pass would read the same
records as the last complete, clean pass:

- WinnerView::identity(): a process-unique store instance plus the
  winner generation, which advances on every select, discard and ban.
  No identity while the store is building or unhealthy.
- MemorySyncService::AdapterInputs: that identity plus the overlay
  rows. Equal rows compare without lookups; otherwise only rows an
  adapter can see (keys the winner store lacks) count, so resident
  cache churn does not read as a change.
- A pass is clean when every stage scans completely with no deferral,
  failure or admission deferral. Unknown inputs never skip.
- A full pass still runs every 5 minutes, so a replicated record
  deleted or changed locally is repaired within that bound.
- A skipped cycle still counts as an apply cycle and still runs the
  outbound backfill.
- Integration tests that observe apply stages per call disable the
  skip through a lock-free test hook.

Paired A/B on grim (gcc Profiling, 3 nodes x 2 cpu / 2 GiB, records
plateaued, concurrent 600 s idle sample):

  idle CPU per node   base 24.4 / 25.0 / 23.4 %   change 3.3 / 3.9 / 4.4 %
  300 s profile, n2   full passes 46 -> 1; apply_inputs 0.55 ms/cycle
                      VectorDB::getVector 11,040 -> 240
2026-10-25 14:58:25 -06:00
Trevon
ea8120bb5a
fix(profiling): keep Tracy zones off coroutine suspension points (#343)
Daemon captures stopped after about one second with "Zone is ended
twice". 25 scoped zones stayed open across co_await, so a coroutine
that resumed on another thread ended its zone out of order.
TuningManager::loop starts in every daemon, so no daemon capture got
past its first tick.

- Remove 21 zones that wrapped whole coroutines or wait loops. They
  measured suspended time and broke the capture.
- Add YAMS_ZONE_SYNC_N/YAMS_ZONE_END for zones that close before the
  first suspension point. Use them for parse_and_plan,
  snippet_hydration and ConnectionPool::acquire. Move
  snippet_batch_fetch into the database callback.
- Add tests/scripts/check_tracy_zones.py with unit tests to the
  policy suite. It rejects zones open across co_await/co_yield.

Profiling builds also failed to link or ran an inconsistent client:

- conanfile.py: build Tracy with on_demand=True. meson already
  compiles yams with -DTRACY_ON_DEMAND, and a mismatched client
  differs in class layout and records from startup.
- setup.sh: keep Tracy's ROCm hooks out unless YAMS_TRACY_ROCPROF=1.
  Tracy's CMake picks them up whenever /opt/rocm exists.
- meson.build: link a real zone in the Tracy check (an empty program
  passed while every binary failed to link), and look up
  rocprofiler-sdk through CMAKE_PREFIX_PATH only when the client
  needs it.

Verified on grim (gcc Profiling): ingest, retrieval-load, SciFact
retrieval-quality and 3-node mesh captures run full length with no
instrumentation failures.
2026-10-25 14:58:25 -06:00
Trevon
97d207ddb4
bench(search): separate engine publication from lexical readiness (#341)
Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
Add a standalone Unix text-only SearchEngineBuilder benchmark that seeds an isolated SQLite/FTS corpus with real extractable content. Report engine publication, asynchronous Simeon lexical-ready, CPU and peak RSS separately; fail if the fixture cannot hydrate its documents or the index never publishes the expected count. Register a five-document smoke test without requiring optional Google Benchmark.

TSAN smoke at 5 and 100 documents, Meson smoke and focused Catch2 lexical/status suites pass. This does not measure daemon startup, vector PQ, KG, quality, or a production performance win; the live prior binary took ~7ms to publish SearchEngine and ~10.4s to finish async lexical work after a ~37m pre-Ready memory-sync wait.

(cherry picked from commit 850da7609c580ae31f08bb1cc7ab2cd23b09ca49)
(cherry picked from commit ebe2542d846ce49337cc59d78d411f2fa5bb68b8)

Signed-off-by: trvon <git@trevon.dev>
2026-10-23 21:16:25 -06:00
Trevon
8bb6f39748
fix(search): gate lexical status and fragment state on publication (#340)
* fix(search): avoid sampling lexical state before build publication

The async Simeon worker assigns its index, fragment state and document counters before publishing ready. getSimeonLexicalStatus read those non-atomic fields while building; a five-document TSAN benchmark reproduced three races. Report configured/building state immediately but withhold details until the one-shot build is ready and no longer building.

Add a native status regression and make its fixture corpus constant-initialized. Mechanically re-anchor two existing portability allowlist entries after line shifts; policy stays unchanged. TSAN benchmark smoke at 5 and 100 documents has zero warnings; focused status and lexical integration suites pass. Rebuilds of the same backend beyond the one-shot publication contract still need review.

(cherry picked from commit 25061c9117ec095c071161970d28bd526443ac94)
Signed-off-by: trvon <git@trevon.dev>

* fix(search): hide fragment geometry until lexical build publishes

Search requests may arrive while the async Simeon worker replaces its fragment encoder. The public readiness accessor previously read that non-atomic pointer before ready_, including through fragmentEmbeddingSpaceIdentity in query debug tracing. An integrated TSAN search test reproduced the worker-write/query-read race; gate the accessor on one-shot ready and completion atomics before inspecting the encoder and fragment data.

The race was observed in the existing [integration] Catch2 suite on the #338 stack: 121 assertions passed but TSAN reported one race and returned 66. Green TSAN verification must follow before publication.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-23 21:16:25 -06:00
Trevon
a8bf85613c
perf(memory-sync): reuse certified filesystem listings during recovery (#339)
Traverse the exclusive filesystem op store once when a complete index listing fits the existing key and byte budgets, then page from that certified snapshot. Retain ordinary paged traversal for oversized listings and opaque backends; propagate cancellation and listing failures instead of treating a short cursor-less page as complete.

Extend the listing benchmark with a separate empty-outbox startup scenario and reject mixed-size fixture reuse. The unsharded outbox/layout problem and actual SearchEngine build timing remain separate follow-ups.

Tests: catch2_memory_sync (106 cases, 3541 assertions); memory_sync_listing_bench outbox/cold 10-record smoke. No production latency claim or live deployment.
(cherry picked from commit 047a2ac97d7e3cabe4684c560ce866322cb6dba7)

Signed-off-by: trvon <git@trevon.dev>
2026-10-23 21:16:24 -06:00
0xkite
21ddb19cf7
fix(memory-sync): bound direct quarantine invalidations (#338)
* fix(memory-sync): bound direct quarantine invalidations

Signed-off-by: trvon <git@trevon.dev>

* test(memory-sync): re-check durable state after a rejected retry

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
Co-authored-by: trvon <git@trevon.dev>
2026-10-23 21:16:24 -06:00
0xkite
731c1f5c0f
fix(build): OpenSSL 4 certificate names and Boost 1.92 timer cancel (#337)
* fix(p2p): own certificate names for OpenSSL 4

Signed-off-by: trvon <git@trevon.dev>

* test(daemon): use supported Asio timer cancellation

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
Co-authored-by: trvon <git@trevon.dev>
2026-10-23 21:16:23 -06:00
Trevon
6248c87bcc
test(daemon): replay startup schedules under a fuzzer (#335)
Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
Startup ordering defects were only observable by waiting minutes on a live
daemon, and the one that aborted it took a live restart loop to find. Give the
startup contracts a deterministic harness that runs in milliseconds.

+ startup_fuzz_support.h drives the real ServiceManagerFsm and the real
  DeferralGate with virtual time: legal phase advances, injected failure,
  shutdown and terminal reset, idle/busy, pressure/calm, starvation deadlines
  and cancellation
+ oracles fail closed on shutdown resurrection without reset, failure-to-ready,
  eligibility disagreeing with fsm.isReady(), admission outside the virtual
  deadline, and ineligible work touching the admission gate; fuzzed IEEE-754
  components must never yield non-finite or out-of-range scores
+ catch2_startup_replay runs named regressions plus 1024 seeded traces per run
  and reports the root seed and action offset for replay
+ fuzz_startup builds the same harness for libFuzzer/AFL, with a seed generator
  that refuses to overwrite different existing files
- the fuzzing lane no longer defaults to TSan: harnesses link
  -fsanitize=fuzzer,address,undefined and the two runtimes cannot share a
  process; combining them now fails with an explanation instead of producing a
  binary that cannot start
- documented the lane invocation, target naming and the coverage limits,
  including that governor call ordering inside the poller is not covered

Scope: contract-valid single-owner schedules. Executor interleavings, late
callbacks, concurrent FSM instances and filesystem/model faults are not modeled.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 16:48:50 -06:00
Trevon
211db15606
fix(daemon): bound semantic neighbor scores and report startup phase (#334)
Semantic-neighbor maintenance aborted the daemon during startup:
std::clamp<float> asserted '!(__hi < __lo)' because the adaptive cosine
threshold could exceed 1.0 for a legal pair of identical vectors. The same
work was also admitted while the daemon was still initializing, so the
300-second starvation guard forced a pass before readiness.

+ semanticInverseNorm/semanticCosineSimilarity/semanticExplicitThreshold in the
  existing embed policy header, shared by the streamed and corpus branches;
  non-finite vectors or scores produce no neighbor and legal scores are bounded
  before ranking and threshold selection
+ startupMaintenanceEligible() requires ServiceManagerState::Ready, not
  stopping and idle, and is evaluated before
  ResourceGovernor::admitDeferrable(), so an elapsed deferral deadline can no
  longer manufacture missing readiness
+ StartupPhaseTracker (two atomics, no mutex) plus a warn-level init-waiter
  line reporting state, phase and phase_elapsed_ms instead of a debug-level
  state number; a 53-minute startup previously logged nothing actionable
+ regression through the public backfill and corpus-rebuild APIs using a 64-D
  vector whose old float inverse norm rounds its self-cosine above 1
- removed the duplicated inverse-norm and cosine lambdas from EmbeddingService
- renamed __test_setModelProvider/__test_setKgStore to testing_* because they
  used reserved identifiers, across 11 files
- re-anchored 13 pre-existing raw-environment allowlist entries to their new
  line numbers; no new exception was added

Startup is still blocked by corpus-sized memory-sync scans; this change stops
the abort and names the blocking phase, it does not make startup fast.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 16:48:49 -06:00
Trevon
60dd2c9afe
fix(daemon): admit cold reads and preserve sync deferral accounting (#331)
Dispatch explicit committed memory reads on admitted workers, checking the
governor before queueing and before I/O. Require the coordinator instead of
using the synchronous strand fallback; keep cached APIs and wire opcodes
unchanged and preserve task-record validation.

Use the paged content adapter, distinguish typed local admission deferral
from hard errors and retain known deferred totals/ages after incomplete
scans. Forward exact topology totals independently of diagnostic samples
and keep attempted cycles distinct from complete scans and successful apply.

Add real daemon IPC tests for authenticated off-resident reads, pressure
health, exact 70-edge status/retry accounting and authenticated cold document
retrieval through the existing MCP get handler, DaemonClient IPC and CAS.
Reanchor existing dispatcher environment-read entries without new exceptions.

Scan uncertainty remains C++-only: typed wire/status/MCP exposure, broader
fault/readiness/P2P convergence and patched resource measurements remain open.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 07:12:25 -06:00
Trevon
604e68e17b
feat(memory-sync): apply paged winners through bounded adapters (#330)
Use pinned, governed pages for metadata, content, vectors and topology.
Preflight identity/content/model prerequisites before writes and continue
independent records without softening authentication, capacity or I/O errors.

Apply topology deletions, nodes and edges in one real KG batch with bounded
transaction-local reads. Preserve ordinary-read isolation, report exact edge
deferrals independently of bounded diagnostic samples and avoid empty commits.

Retract absent local topology only from exact committed own observations and
conditionally erase the same envelope, including off-resident/stale-winner
regressions. Probe local KG presence with fallible scalar SQL rather than
materializing properties; retain SQL errors as errors, not absent records.

The public C++ KG/batch interfaces gain virtual capabilities. Defaults provide
source-level hard errors only: installed/old-binary ABI compatibility is NOT
established and providers must be rebuilt/reviewed before promotion.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 07:12:24 -06:00
Trevon
3b5d4cbc12
feat(memory-sync): add pinned adapter views and callback drains (#329)
Expose owner-validated committed views, bounded exclusive-prefix pages and
fallible exact historical payload reads without per-value reconciliation.
Keep cached reads nonblocking and separate from explicit committed I/O.

Add governed adapter indexes, revocable escaped-handle lifetime checks and
typed local pressure/contention deferral. Release the loop lock only across
audited native payload fetches and recheck cancellation/quarantine afterward.

Preserve the process-wide recursive callback serializer while releasing the
lifecycle gate before its drain on both restart and stop. Recheck workers and
callback generations after the gap; retain all cancellation and join guards.
Wire the optional local-yield callback through the config factory alongside
the constructor change.

Add native SDK concurrency, pressure, lifetime, off-resident and callback-order
regressions. Arbitrary blocking backend/callback shutdown is not signed off.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 07:12:23 -06:00
Trevon
43adae9991
feat(memory-sync): serve complete winners from bounded disk generations (#328)
Spill exclusive native-filesystem sweep observations and committed winners
into bounded SQLite workspaces on the corpus filesystem. Preserve complete
operation/vector fork observations and validate payloads before publishing
an immutable generation; keep resident caches separate from full authority.

Add fallible committed/pinned reads, bounded adapter bookkeeping, cooperative
filesystem traversal, secure scratch allocation/cleanup and pinned lifetime
limits. Retain exact durable erase intents and condition own-record erasure
on the full current envelope, including a post-reconciliation check.

Preserve the existing shared-store, cache, value and scan limits. Reanchor
only the existing filesystem environment-read entries and register SQLite
and layout-independent test hooks in the memory-sync library.

Bootstrap import/finalize/restart, broader ingress/scratch faults and final
SDK ABI/security/performance signoff remain separate work.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 07:12:23 -06:00
Trevon
59d11a1504
fix(daemon): synchronize metrics completion and data-root snapshots (#327)
Publish owning data-directory snapshots under a mutex and route bootstrap
and fallback publications through the same synchronization boundary.

Keep polling completion locked through its final notification so shutdown
cannot destroy the condition variable while the notifier still uses it.
Exercise both publication windows with deterministic event-based tests and
use the repository thread-compatibility wrapper.

Preserve Failed in status rendering instead of reporting initialization.
Reanchor the existing metrics environment-read entry; add no exceptions.

This does not close the separate queued-collector and embedding-borrower
lifetime audits or establish universal bounded callback shutdown.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 07:12:22 -06:00
Trevon
95fb407c0c
fix(build): let the compiler select the TSan runtime (#326)
Remove the bare Linux libtsan lookup that mixes GCC's shared runtime with
Clang's instrumented host. Add an ELF linkage test that requires actual
TSan instrumentation and rejects the other compiler's runtime.

Include cstring explicitly for retrieval-session memory operations.

The regression was exercised against the actual instrumented shared object;
no sanitizer instrumentation, interceptors, or checks are disabled.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 07:12:21 -06:00
Trevon
11b8443bc4
fix(memory-sync): stop re-walking the op store when a sweep cannot fit (#325)
A complete sweep fails closed when its retained state cannot hold the
corpus, returning ResourceExhausted ("complete-sweep candidate budget
exhausted"). That is correct: a truncated sweep must never commit a fork
decision. What was missing is a failure state. Every caller retried
immediately, and each attempt re-listed the whole op store, so a corpus
larger than maxTrackedIdentities pinned a core indefinitely.

Observed on a 65,612-object op store with 24,689 index records against
maxTrackedIdentities=8192: one thread in

  publish -> reconcileBeforeWrite -> syncFully -> sync
          -> FilesystemBackend::listPage("index/")
          -> visitFilesystemKeys -> recursive_directory_iterator

92% of it in kernel time, 7h26m CPU over 7h29m wall in a single run, and
17,486 "backfill domain failed: complete-sweep candidate budget
exhausted" lines. The listing snapshot cache could not help: it never
completes, because the sweep aborts before reaching the end.

Remember the unsatisfiable sweep and gate retries behind bounded
exponential backoff (5s..600s) so one attempt per window replaces one
attempt per caller. A single retry is still admitted, because one
exhaustion can be a store that is about to fit (an erase or quarantine
landing between attempts); the latch starts on consecutive failures and
clears when a sweep completes.

Only the sweep's own budget sites set sweepBoundExhausted_. A
resource-governor denial reports ResourceExhausted too, but it is
transient backpressure and must keep retrying freely, otherwise pressure
that has already lifted suppresses convergence for a whole window.

Fail-closed behaviour is unchanged: the wrapper never converts a failure
into success, nothing commits until a sweep completes, and the memoized
error carries the original code and message. Cancellation is never
memoized, so shutdown is not delayed.

Signed-off-by: trvon <git@trevon.dev>
2026-10-22 07:12:20 -06:00
Trevon
a1cb2ed643
fix(build): build libsql into the meson build tree (#324)
The libsql wrap ran `cargo build` and then copied the archive from
<src>/target/<profile>. Cargo's output directory is not fixed: an ambient
CARGO_TARGET_DIR, CARGO_BUILD_TARGET_DIR or a build.target-dir in a cargo
config relocates it, so the copy step fails *after* a successful compile
with "libsql output not found". Because it is the last step of the build
it reads as a compile failure, and cargo's own success is easy to miss.

Hand the target directory to cargo explicitly as a meson-owned path under
the build tree, and drop CARGO_TARGET_DIR from the child environment.
--target-dir outranks every cargo configuration source, so the directory
cargo writes and the directory we read can no longer disagree, and two
meson build directories no longer share rust artifacts.

Internal only: no user-facing change.
2026-10-22 07:12:19 -06:00
Trevon
d398556ee5
fix(tests): replace frozen expectations and harden release gates (#323)
* test(search,daemon,storage): assert behaviour instead of frozen literals

Several suites encoded exact internal values that legitimate tuning and
ranking changes are allowed to move: SCIENTIFIC-era profile weights and
rrfK values, corpus-statistic figures, and hardcoded timeout/cancellation
numbers. They failed on every platform after those changes landed, so the
real behaviour regressions they should catch were hidden behind noise.

Replace the frozen literals with contract assertions: weights are finite,
non-negative and normalize, state selection still holds, capability-gated
sources stay zeroed, and hybrid results keep matching canonical hash/path
sets. Deadlines are not widened and the daemon paths they cover are
unchanged.

Also add independent corpus and adapter oracles, deterministic collector
cancellation and late-result gates, and synchronize ServiceManager
shutdown observation, so the tests fail for the behaviour they name.

The portability allowlist keeps its two reviewed raw test-env reads; only
their line numbers moved.

Signed-off-by: trvon <git@trevon.dev>

* ci(tests): summarise Meson failures without flooding the log

A failing test run printed the whole Meson suite output, so the actual
failing test names and their assertion messages were buried and the
uploaded logs were dominated by passing-test chatter.

Add a bounded reporter that extracts the failing test names plus their
surrounding diagnostics, keeps an overall cap on emitted lines, and is
unit-tested against representative Meson output. Wire it into the Tests
workflow so failures stay readable.

Signed-off-by: trvon <git@trevon.dev>

* build(deb): validate candidate identity, mapping and upgrade ordering

The Debian packaging scripts took filenames and versions on trust, so a
misnamed candidate or a wrong package ordering could pass validation while
the real install path was never exercised.

Fail closed on candidate identity, version and architecture, verify the
package-to-source mapping, seed the legacy-only layout, wait on the actual
services, and assert install/upgrade/remove ordering for both Debian and
RPM in an offline container. Cases that cannot be proven on a host are
reported as skipped rather than silently passing.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-19 19:16:57 -06:00
Trevon
c3acf80bc4
fix(graph): rank explore targets and stop version crowding (#322)
Graph explore matched candidates across the whole knowledge graph by
label, then filtered by scope. For a bare basename with many foreign
matches the global ORDER BY label LIMIT filled every slot before scope
filtering, so the correct in-scope file never appeared. For a single
absolute path, logical, historical and blob representations of the same
file consumed the symbol budget and the reported files.

Seed an explicit single-term file-like target through scoped direct-key
lookup instead of relying on the saturated global pool, and rank the
remaining candidates by query coverage. Collapse whole-file hydration
representatives to one entry per path while keeping every contributing
node identity for relationship collection, so distinct symbols, text
segments and explicit source spans are still emitted. Raw lookup is
unchanged and still returns all nodes.

Truncation accounting now covers clipped snippets and omitted unreadable
sources. Explore hints keep an external target and add --global when the
target resolves outside the current directory; local hints stay scoped.

Tests cover scoped lookup under a saturated foreign label pool, file and
version budget diversity, direct-key seeding, relationship retention
across duplicate file representations, segment provenance, the
relationship cap, disabled relation and zero-symbol budgets, and
external hint scope.

Signed-off-by: trvon <git@trevon.dev>
2026-10-18 10:38:55 -06:00
Trevon
31c9e57447
fix(memory-sync): bound complete sweeps and cancel filesystem walks (#321)
* perf(memory-sync): verify each envelope's signature once

Every publish and every backfill cycle reconciles the whole op store, and
reconciliation authenticated every index envelope it read. On a store with
44k objects that put 73% of the daemon's remaining CPU inside Ed25519
verification, re-checking signatures whose bytes had not changed.

Remember positively verified envelopes for the life of a loop, keyed by the
digest of the whole serialized envelope including the signature, so a swapped
signature is a different key and gets verified. Trust set, corpus, epoch and
signature policy are fixed for a loop, so rebuilding the loop is the
invalidation: key rotation, revocation and allowlist changes all arrive as a
new loop. The set is capped by a new typed limit and cleared on overflow or on
allocation failure, so exhausting it costs a verification, never an
acceptance. Nothing is memoized for verifyDigest, and cache lookup happens
only after the envelope digest has already been checked against its key.

Signed-off-by: trvon <git@trevon.dev>

* fix(memory-sync): stop pinning document backfill on an oversized record

The document sweep is an ordered id cursor that only advances after a
document publishes. A record larger than the resident publish limit failed
with InvalidArgument, which is neither of the unrecoverable codes, so the
failure was rethrown and the cursor stayed put. The same document was then
retried every five seconds forever, and every retry re-reconciled and
re-authenticated the whole op store, which is what kept a live daemon at 40%
of a core with its status reporting idle.

Classify the rejection from the serialized record bytes rather than from the
error text, and skip that document so the pass reaches the ones behind it.
The vector sweep already behaves this way. The local record is untouched, so
a corrected value still publishes on a later pass once the store changes.

MetadataSyncAdapter gains a size helper for that classification; it reuses
the same record builder publish() serializes.

Signed-off-by: trvon <git@trevon.dev>

* fix(memory-sync): bound whole-sweep candidate state

Charge causally valid observations against an aggregate encoded-byte budget before retaining identities, including misplaced records. Bound candidate count as well as unique identities, and fail the whole sweep on exhaustion instead of committing a truncated fork decision.

Discard incomplete scan state on cancellation, listing errors, and exceptions. Honor backend cancellation between filesystem directory entries and check cancellation before durable sweep finalization. Preserve cross-page fork detection and the existing page-size default.

The budget bounds retained input, not exact RSS or total scan work. Listings remain non-snapshot and individual filesystem syscalls are not interruptible.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-18 07:29:13 -06:00
Trevon
8d439db608
perf(memory-sync): stop the idle re-verify loop (#320)
* perf(memory-sync): verify each envelope's signature once

Every publish and every backfill cycle reconciles the whole op store, and
reconciliation authenticated every index envelope it read. On a store with
44k objects that put 73% of the daemon's remaining CPU inside Ed25519
verification, re-checking signatures whose bytes had not changed.

Remember positively verified envelopes for the life of a loop, keyed by the
digest of the whole serialized envelope including the signature, so a swapped
signature is a different key and gets verified. Trust set, corpus, epoch and
signature policy are fixed for a loop, so rebuilding the loop is the
invalidation: key rotation, revocation and allowlist changes all arrive as a
new loop. The set is capped by a new typed limit and cleared on overflow or on
allocation failure, so exhausting it costs a verification, never an
acceptance. Nothing is memoized for verifyDigest, and cache lookup happens
only after the envelope digest has already been checked against its key.

Signed-off-by: trvon <git@trevon.dev>

* fix(memory-sync): stop pinning document backfill on an oversized record

The document sweep is an ordered id cursor that only advances after a
document publishes. A record larger than the resident publish limit failed
with InvalidArgument, which is neither of the unrecoverable codes, so the
failure was rethrown and the cursor stayed put. The same document was then
retried every five seconds forever, and every retry re-reconciled and
re-authenticated the whole op store, which is what kept a live daemon at 40%
of a core with its status reporting idle.

Classify the rejection from the serialized record bytes rather than from the
error text, and skip that document so the pass reaches the ones behind it.
The vector sweep already behaves this way. The local record is untouched, so
a corrected value still publishes on a later pass once the store changes.

MetadataSyncAdapter gains a size helper for that classification; it reuses
the same record builder publish() serializes.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-18 07:29:00 -06:00
Trevon
33243d8472
fix(memory-sync): stop re-walking the op store on every publish (#319)
* fix(storage): bound filesystem listPage residency

listPage() walked the whole op store and retained every matching key before
slicing one page. At 1025 records and a 512-record page it held 1025 keys
where 513 is the working set, and the retained set grew with the store.

Traverse once into a max-heap holding the smallest eligible limit+1 keys.
The walk itself stays complete, so a traversal error still returns an error
instead of a partial page. Exclusive lexical cursor, ordering, Windows key
decoding and the SIZE_MAX limit stay as they were.

Add a YAMS_TESTING-only observation seam that counts real walks, entries
visited and peak retained keys, so the traversal cost can be measured
instead of inferred. The new traversal shifts two reviewed getenv allowlist
line numbers.

Signed-off-by: trvon <git@trevon.dev>

* fix(memory-sync): keep backfill progress past an oversized vector

A record above the resident limit failed publication, the domain cursor
never advanced, and every later cycle retried the same row. Valid vectors
behind it never reconciled, so one oversized value could pin a domain
indefinitely. The daemon log showed this repeating every ~100 s.

Classify the row only after a publish fails with InvalidArgument and the
same serialized record is independently confirmed to exceed the immutable
limit min(maxValueBytes, maxCacheBytes). Skip the row for this pass, keep
the local copy, and revisit it when a new source generation or service
limit appears. A generic backend InvalidArgument stays retryable, and
healthy rows still serialize only once.

Signed-off-by: trvon <git@trevon.dev>

* fix(memory-sync): reuse a bounded complete listing across publishes

Every store invalidated the cached listing pages, so a publish followed by
syncFully walked the whole op store again, once per 512-record page. On the
profiled daemon that re-walk was the hot path: 99.6% of one core, 87.9% of
it system time, with listPage at 55% of user samples.

Keep a complete key-only prefix snapshot, built only from normal contiguous
pages starting at an empty cursor on the concrete filesystem backend. The
snapshot is capped by key count and key bytes, served only in lexical order,
and dropped as incomplete unless the walk reached the end. Any mutation
invalidates it before the backend call; a successful store or remove then
applies the exact key to matching snapshots, and a failure drops them
because the write may have committed. Initialising the async path disables
cache reuse permanently rather than racing a future.

Cache maintenance is best effort: it runs after the backend result is known
and any allocation failure clears the cache and keeps the committed result.
Opaque cursors are untouched, so only the built-in filesystem backend gets
snapshot treatment.

Active publishes now cost the same number of walks as a warm sweep instead
of four, six and nine at 511, 513 and 1025 records.

Signed-off-by: trvon <git@trevon.dev>

* test(bench): measure memory-sync listing cost across arms

Add a standalone harness that drives a real filesystem op store through
MemorySyncLoop, one scenario per process so no cache state survives a
repeat, and reports wall, user, system and peak RSS per run. It uses
production APIs only, so the same source builds against a baseline and a
candidate tree and neither arm needs a measurement seam ported into it.

The driver seeds one corpus per size, interleaves the two arms inside each
repeat, and writes results.jsonl plus a median report. Enumeration counts
are not re-derived here; the unit suite owns those.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-17 15:30:35 -06:00
Trevon
3272367ea2
fix(search): restore measured scientific ranking (#318)
* fix(search): make the final lexical tie-break a strict weak ordering

The final comparator broke near-ties toward the result with less vector
support, and its pairwise |a - b| <= epsilon test was not transitive, which
is undefined behavior for the std::sort/partial_sort callers in
applySearchTopKLimit.

Compare tie buckets floor(score / epsilon) instead, then lexical anchor,
keyword and vector evidence (more first), then doc id. Behavior with the
tie-break off is unchanged. The comparator moves to
final_result_order_internal.h so it can be tested directly.

Signed-off-by: trvon <me@trevon.dev>
(cherry picked from commit ca9b82d6ce9db31b6c9608a7d95b63203649e5a7)
Signed-off-by: trvon <git@trevon.dev>

* fix(search): keep cross-reranked scores above the unreranked tail

applyCrossRerank rewrote the window's scores to a 0..1 min-max blend while
the tail kept raw fused scores. Later whole-list sorts (concept boost,
top-k partial_sort) compared the two scales, so reranked docs could drop
below tail docs or out of the top-k.

Map the blended score back onto the window's original range, with the
floor raised just above the best tail score when needed. The window's
reranked order is unchanged and docTraces.finalScore records the mapped
score.

Signed-off-by: trvon <me@trevon.dev>
(cherry picked from commit d66c6d304ca4d32b943e164b0d7720fc7f865db1)
Signed-off-by: trvon <git@trevon.dev>

* fix(search): judge weak tier-1 queries on the raw bm25 scale

The weak-query test compared the top Text/SimeonText component score with
weakQueryMinTopTextScore, but lexical batches are min-max normalized, so the
top hit is always 1.0. With enough hits every query was strong: the
weak-query vector fanout boost and weak-query topology expansion never ran.

scoreLexicalBatch now also records absoluteLexicalScore, the hit's score on
the existing absolute scale (-bm25 / bm25NormDivisor, same multipliers),
and classifyTier1TextSignal uses it. Thresholds keep their values; with the
default divisor 25, 0.30 is bm25 <= -7.5 and 0.45 is bm25 <= -11.25.

Signed-off-by: trvon <me@trevon.dev>
(cherry picked from commit bc771c5aab1721170fb9e743013ca0efbf048d61)
Signed-off-by: trvon <git@trevon.dev>

* fix(search): restore measured scientific fusion

Give SCIENTIFIC the measured relative Simeon weight of 0.40 after normalizing the existing text/vector pair. The resulting share is 0.285714, matching the benchmark traces. Other profiles remain unchanged.

Keep its one-slot semantic rescue preset instead of raising it to three at the corpus layer. Preserve the existing large-prose recall widening. Cover the preset weights, config propagation, community blend, rich-graph ratios and corpus rescue behavior.

Signed-off-by: trvon <git@trevon.dev>

* fix(search): order non-finite ranking evidence

Treat NaN score/evidence keys as negative infinity and invalid tie epsilon as zero. This keeps comparator equivalence transitive without changing finite-input ordering. Add exhaustive triples covering NaN, infinities and invalid epsilon.

Signed-off-by: trvon <git@trevon.dev>

* docs(search): clarify lexical scoring limits

Distinguish the shared single-hit score conversion from FTS5 BM25 calibration. Note that lexical tie buckets can still reorder numerically separated rerank-window and tail scores. No behavior change.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
Signed-off-by: trvon <git@trevon.dev>
2026-10-16 09:43:41 -06:00
Trevon
d2252877ad
fix(search): skip per-hash score lists under MAX chunk aggregation (#293)
aggregateChunkVectorScores collected every chunk score per document even
when the strategy was MAX, which only needs the best record. Skip the
list for MAX; the other strategies are unchanged.

Test: search_vector_pipeline.

Signed-off-by: trvon <me@trevon.dev>
2026-10-16 09:43:40 -06:00
Trevon
3f3f3b613e
fix(topology): full rebuild when louvain/kmeans need a wider region (#285)
* test(topology): cover incremental rebuilds for louvain and kmeans

An incremental TopologyManager rebuild with the louvain or kmeans engine
publishes a snapshot that holds only the dirty documents. The store then
drops topology.cluster_id from every untouched document. Add a failing
case for both engines, and a regression case that the connected engine
still rebuilds only the seed's prior cluster.

Signed-off-by: trvon <me@trevon.dev>

* fix(topology): rebuild the corpus when a dirty region needs it

TopologyManager logged requiresWiderRebuild and then published the
engine's region update anyway. Louvain and KMeans have no incremental
path: their dirty region is the seed set and their update rebuilds only
those documents, so the published snapshot held just the dirty docs and
the store cleared the topology keys of every other document.

Honor the flag: re-extract the full corpus and call buildArtifacts, and
report the run as a full rebuild with one fallback. The connected engine
sets the flag only when a region exceeds its seed or document budget;
under budget its incremental path is unchanged. Over budget it used to
drop seeds past the cap, and now rebuilds the corpus.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-16 09:43:40 -06:00
Trevon
8881e0b3e9
perf(memory_sync): skip idle rescans and listings of a direct op store (#311)
* test(search): rename a road beam local that collides with the Windows far macro

windows.h defines far and near as empty macros, so `const auto far = ...`
in search_vector_stage_road_beam_catch2_test.cpp does not compile with MSVC
(C2513), and the Windows Tests job has failed since the road beam tests
landed. Rename it to deep.

Signed-off-by: trvon <me@trevon.dev>

* perf(memory_sync): skip rescans of an unchanged direct op store

The sync worker rescanned the whole op store every cycle: a full walk of the
sharded objects tree, then a read, hash, decode and signature check of every
index envelope. On an idle 3-node mesh (2 CPUs per node, 5,256 objects) that
cost about 1.1 s of CPU per 5 s cycle on each node, mostly in getdents/openat.

In direct transport the daemon is the op store's only writer. The loop now
counts its own backend writes and reuses the last complete scan until it
writes again or a writer is quarantined. Shared-store and temporary namespaces
keep rescanning every cycle.

Signed-off-by: trvon <me@trevon.dev>

* perf(memory_sync): keep exclusive-backend listings until the loop writes

FilesystemBackend::listPage walks every shard directory of the op store for
any prefix. After the rescan skip, each idle direct-transport cycle still
listed the store twice: replaying ready erases and inspecting the erase
outbox. The loop's write-counting backend now keeps listing pages on an
exclusive backend and drops them on any store, remove or clear.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-16 09:43:40 -06:00
Trevon
9984c5bf5d
fix(client): drop a retried attempt's EOF from streaming handlers (#315)
The daemon closes a connection that has served one request and then stays
quiet for 250 ms. A pooled client can write its next request as that
happens and read EOF. The streaming transport retries once on a fresh
connection, but the read loop had already passed the EOF to the caller's
onError. SuccessHandler and most other handlers keep that error, so
ping() returned "[ipc:eof] End of file" after the retry got a pong.
cli_responsiveness_under_load failed this way on macOS CI (1 of 4 pings,
one idle close logged).

The transport now holds each attempt's error and forwards it only when it
returns that attempt's result, not when it retries.

Signed-off-by: trvon <me@trevon.dev>
2026-10-16 09:43:39 -06:00
Trevon
716ff3a9f1
test(graph): store resolved temp paths in the graph dispatcher fixture (#314)
Ingest resolves symlinks before it writes a path (populatePathDerivedFields
calls weakly_canonical), and buildGraphCwdScopePathRanges resolves the scope
the same way. The graph dispatcher fixture wrote node keys and labels from an
unresolved temp_directory_path(). On macOS that is /var/folders/..., a
symlink to /private/var/folders/..., so the scoped listByType sections found
0 nodes there and passed on Linux. The fixture now resolves its temp dir,
like the stored paths it stands in for.

Signed-off-by: trvon <me@trevon.dev>
2026-10-16 09:43:39 -06:00